Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 94 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ import { logger } from "./utils/external-logger.js";
import { getPromptTraceById, listPromptTraces } from "./agent/prompt-trace.js";
import { allTools } from "./agent/tools/index.js";
import { getMCPTools } from "./agent/mcp-client.js";
import { loadSkills, parseFrontmatter } from "./skills/loader.js";
import { loadSkills, parseFrontmatter, clearSkillCache } from "./skills/loader.js";
import { dataPath, getSkillsDir } from "./utils/paths.js";
import { importSkill, previewSkill } from "./skills/import.js";
import { readLock, updateLockEntry, removeLockEntry } from "./skills/lock.js";
import { scanSkillDirectory } from "./skills/scanner.js";
import { randomUUID } from "node:crypto";
import type { JobQueue } from "./scheduler/job-queue.js";
import type { CronStore } from "./scheduler/cron-store.js";
Expand Down Expand Up @@ -598,6 +601,7 @@ export function startServer(deps: ServerDeps) {
const defaultSkillsDir = path.resolve("skills");
const dirs = skillsDir !== defaultSkillsDir ? [skillsDir, defaultSkillsDir] : [skillsDir];
const allSkills = loadSkills(dirs);
const lock = readLock();

// Build usedBy map: skill name → agent IDs that have it enabled
const usedByMap: Record<string, string[]> = {};
Expand Down Expand Up @@ -628,15 +632,104 @@ export function startServer(deps: ServerDeps) {
return {
name: s.name,
description: s.description,
dir: s.dir,
eligible: s.eligible,
requires,
usedBy: usedByMap[s.name] ?? [],
lock: lock[s.name] ?? null,
};
});
return json(res, { skills });
} catch (err) { return json(res, { error: err instanceof Error ? err.message : String(err) }, 500); }
}

// ── Skill install / preview / uninstall ───────────────
if (req.method === "POST" && pathname === "/api/skills/preview") {
try {
const body = await readBody(req);
const { source } = JSON.parse(body || "{}") as { source?: string };
if (!source || typeof source !== "string") {
return json(res, { error: "source is required" }, 400);
}
const preview = await previewSkill(source);
return json(res, preview);
} catch (err) {
return json(res, { error: err instanceof Error ? err.message : String(err) }, 400);
}
}

if (req.method === "POST" && pathname === "/api/skills/install") {
try {
const body = await readBody(req);
const parsed = JSON.parse(body || "{}") as {
source?: string;
ref?: string;
attachToAgents?: string[];
};
if (!parsed.source || typeof parsed.source !== "string") {
return json(res, { error: "source is required" }, 400);
}

const result = await importSkill({ source: parsed.source, ref: parsed.ref });
updateLockEntry(result.key, {
source: result.source,
sourceType: result.sourceType,
ref: result.ref,
installedAt: new Date().toISOString(),
});

// Post-install security scan — warnings only, never blocks.
const scan = scanSkillDirectory(result.destDir);

// Optional: attach to one or more agents (write into agent.skills).
const attachedAgents: string[] = [];
if (Array.isArray(parsed.attachToAgents) && parsed.attachToAgents.length > 0 && deps.agentStore) {
for (const agentId of parsed.attachToAgents) {
const cfg = deps.agentStore.getConfig(agentId);
if (!cfg) continue;
const next = Array.from(new Set([...(cfg.skills ?? []), result.key]));
deps.agentStore.updateConfig(agentId, { skills: next });
attachedAgents.push(agentId);
}
}

clearSkillCache();
return json(res, {
ok: true,
key: result.key,
source: result.source,
sourceType: result.sourceType,
ref: result.ref,
scan,
attachedAgents,
});
} catch (err) {
logger.warn("Skill install failed", { error: err instanceof Error ? err.message : String(err) });
return json(res, { error: err instanceof Error ? err.message : String(err) }, 400);
}
}

const skillKeyMatch = pathname.match(/^\/api\/skills\/([a-z0-9][a-z0-9-]*)$/);
if (req.method === "DELETE" && skillKeyMatch) {
try {
const key = skillKeyMatch[1];
const target = path.resolve(getSkillsDir(), key);
const skillsRoot = path.resolve(getSkillsDir()) + path.sep;
if (!target.startsWith(skillsRoot)) {
return json(res, { error: "invalid skill path" }, 400);
}
if (!fs.existsSync(target)) {
return json(res, { error: `skill not found: ${key}` }, 404);
}
fs.rmSync(target, { recursive: true, force: true });
removeLockEntry(key);
clearSkillCache();
return json(res, { ok: true, key });
} catch (err) {
return json(res, { error: err instanceof Error ? err.message : String(err) }, 500);
}
}

// ── Crons CRUD ────────────────────────────────────────
if (req.method === "GET" && pathname === "/api/crons" && deps.cronStore) {
try {
Expand Down
101 changes: 101 additions & 0 deletions src/skills/import-source.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, test } from "bun:test";
import { parseSource, isValidSkillKey } from "./import-source.js";

describe("parseSource", () => {
test("github shorthand: owner/repo", () => {
expect(parseSource("github:anthropics/skills")).toEqual({
kind: "github",
owner: "anthropics",
repo: "skills",
skillName: undefined,
});
});

test("github shorthand: owner/repo/skill", () => {
expect(parseSource("github:anthropics/skills/release")).toEqual({
kind: "github",
owner: "anthropics",
repo: "skills",
skillName: "release",
});
});

test("github shorthand @ form", () => {
expect(parseSource("github:anthropics/skills@release")).toEqual({
kind: "github",
owner: "anthropics",
repo: "skills",
skillName: "release",
});
});

test("github.com URL", () => {
const r = parseSource("https://github.com/anthropics/skills");
expect(r?.owner).toBe("anthropics");
expect(r?.repo).toBe("skills");
expect(r?.kind).toBe("github");
});

test("github.com URL with .git suffix", () => {
expect(parseSource("https://github.com/anthropics/skills.git")?.repo).toBe("skills");
});

test("github.com URL with /tree/<ref> captures ref", () => {
const r = parseSource("https://github.com/anthropics/skills/tree/main/release");
expect(r?.ref).toBe("main");
});

test("skills.sh URL", () => {
expect(parseSource("https://skills.sh/anthropics/skills/release")).toEqual({
kind: "skills_sh",
owner: "anthropics",
repo: "skills",
skillName: "release",
});
});

test("www.skills.sh URL", () => {
expect(parseSource("https://www.skills.sh/juliusbrussee/caveman/caveman")).toEqual({
kind: "skills_sh",
owner: "juliusbrussee",
repo: "caveman",
skillName: "caveman",
});
});

test("strips `npx skills add` prefix", () => {
expect(parseSource("npx skills add github:anthropics/skills/release")?.skillName).toBe(
"release",
);
});

test("folds --skill flag into skillName", () => {
expect(parseSource("github:anthropics/skills --skill release")?.skillName).toBe("release");
});

test("rejects unknown formats", () => {
expect(parseSource("local:/etc/passwd")).toBeNull();
expect(parseSource("not a source")).toBeNull();
expect(parseSource("")).toBeNull();
});
});

describe("isValidSkillKey", () => {
test("accepts kebab-case", () => {
expect(isValidSkillKey("release")).toBe(true);
expect(isValidSkillKey("my-skill")).toBe(true);
expect(isValidSkillKey("a1-b2-c3")).toBe(true);
});

test("rejects path traversal and separators", () => {
expect(isValidSkillKey("../etc")).toBe(false);
expect(isValidSkillKey("foo/bar")).toBe(false);
expect(isValidSkillKey(".hidden")).toBe(false);
expect(isValidSkillKey("Foo")).toBe(false);
expect(isValidSkillKey("foo_bar")).toBe(false);
expect(isValidSkillKey("-leading")).toBe(false);
expect(isValidSkillKey("trailing-")).toBe(false);
expect(isValidSkillKey("")).toBe(false);
expect(isValidSkillKey("a".repeat(65))).toBe(false);
});
});
86 changes: 86 additions & 0 deletions src/skills/import-source.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Parse a skill install source string into a normalized descriptor.
//
// Accepted forms (v1):
// github:owner/repo
// github:owner/repo/skillName
// github:owner/repo@skillName (CLI-style filter)
// https://github.com/owner/repo
// https://github.com/owner/repo/tree/<ref>/<path>...
// https://skills.sh/owner/repo
// https://skills.sh/owner/repo/skillName
// `npx skills add ...` / `skills add ...` prefixes are stripped.
// `--skill <name>` flag is folded into skillName.

export interface ParsedSource {
kind: "github" | "skills_sh";
owner: string;
repo: string;
skillName?: string;
ref?: string;
}

export function parseSource(raw: string): ParsedSource | null {
let trimmed = raw.trim().replace(/^(?:npx\s+)?skills\s+add\s+/i, "");
const flagMatch = trimmed.match(/\s+--skill[=\s]+([^\s]+)/);
let flagSkill: string | undefined;
if (flagMatch) {
flagSkill = flagMatch[1];
trimmed = trimmed.replace(flagMatch[0], "").trim();
}

const skillsSh = trimmed.match(
/^https?:\/\/(?:www\.)?skills\.sh\/([^/?#\s]+)\/([^/?#\s]+)(?:\/([^/?#\s]+))?/,
);
if (skillsSh) {
return {
kind: "skills_sh",
owner: skillsSh[1],
repo: skillsSh[2],
skillName: skillsSh[3] ?? flagSkill,
};
}

const gh = trimmed.match(
/^https?:\/\/github\.com\/([^/?#\s]+)\/([^/?#\s]+?)(?:\.git)?(?:\/(?:tree|blob)\/([^/?#\s]+))?(?:\/.*)?$/,
);
if (gh) {
return {
kind: "github",
owner: gh[1],
repo: gh[2],
skillName: flagSkill,
ref: gh[3],
};
}

const shorthandAt = trimmed.match(/^github:([^/?#\s]+)\/([^/@?#\s]+)@([^/?#\s]+)$/);
if (shorthandAt) {
return {
kind: "github",
owner: shorthandAt[1],
repo: shorthandAt[2],
skillName: shorthandAt[3],
};
}

const shorthand = trimmed.match(/^github:([^/?#\s]+)\/([^/?#\s]+?)(?:\/([^/?#\s]+))?$/);
if (shorthand) {
return {
kind: "github",
owner: shorthand[1],
repo: shorthand[2],
skillName: shorthand[3] ?? flagSkill,
};
}

return null;
}

// Strict kebab-case key. Used as a directory name under the skills root,
// so it must not contain path separators or hidden-dir prefixes.
const SKILL_KEY_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;

export function isValidSkillKey(s: string): boolean {
if (!s || s.length > 64) return false;
return SKILL_KEY_RE.test(s);
}
Loading
Loading