diff --git a/README.md b/README.md index e9931fe4..2ce715c9 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,12 @@ bun run review:hourly # inspect run state for one issue bun run src/index.ts status --project --issue ENG-123 + +# skills management +bun run src/index.ts skills list [--project ] +bun run src/index.ts skills add --title "" --description "<DESCRIPTION>" --content "<CONTENT>" [--project <PROJECT_ID>] +bun run src/index.ts skills update <NAME> [--title "<TITLE>"] [--description "<DESCRIPTION>"] [--content "<CONTENT>"] [--project <PROJECT_ID>] +bun run src/index.ts skills remove <NAME> [--project <PROJECT_ID>] ``` After linking/installing the package bin, you can also use `adhd-ai ...` directly. diff --git a/skills/README.md b/skills/README.md index fa4541d8..a86de08f 100644 --- a/skills/README.md +++ b/skills/README.md @@ -40,6 +40,27 @@ skills: { } ``` +## Manage skills via CLI + +Use the CLI to list, add, update, and remove skill folders under the configured +`skills.root` for the selected project: + +```bash +bun run src/index.ts skills list [--project <PROJECT_ID>] +bun run src/index.ts skills add --title "<TITLE>" --description "<DESCRIPTION>" --content "<CONTENT>" [--project <PROJECT_ID>] +bun run src/index.ts skills update <NAME> [--title "<TITLE>"] [--description "<DESCRIPTION>"] [--content "<CONTENT>"] [--project <PROJECT_ID>] +bun run src/index.ts skills remove <NAME> [--project <PROJECT_ID>] +``` + +Generated `SKILL.md` template: + +```md +name: <skill title> +description: <skill description> + +<skill content> +``` + ## Using skills from another repository You can source skills from another repo in three common ways: diff --git a/src/args.ts b/src/args.ts index 4875e5a7..a5298c8a 100644 --- a/src/args.ts +++ b/src/args.ts @@ -1,10 +1,34 @@ import type { RunOptions } from "./core/types"; +export type SkillsCommand = + | { action: "list"; projectId?: string } + | { + action: "add"; + projectId?: string; + title: string; + description: string; + content: string; + } + | { + action: "update"; + projectId?: string; + name: string; + title?: string; + description?: string; + content?: string; + } + | { + action: "remove"; + projectId?: string; + name: string; + }; + export type CliCommand = | { kind: "run"; options: RunOptions } | { kind: "cron"; jobId?: string } | { kind: "status"; issueKey: string; projectId: string } | { kind: "projects" } + | { kind: "skills"; command: SkillsCommand } | { kind: "setup"; check: boolean } | { kind: "help" }; @@ -76,9 +100,89 @@ export function parseArgs(argv: string[]): CliCommand { return { kind: "projects" }; } + if (command === "skills") { + return { + kind: "skills", + command: parseSkillsCommand(rest.slice(1)), + }; + } + throw new Error(`Unknown command: ${command}`); } +function parseSkillsCommand(args: string[]): SkillsCommand { + const action = args[0]; + if (!action) { + throw new Error( + "skills command requires an action: list | add | update | remove", + ); + } + + if (action === "list") { + return { + action: "list", + projectId: readFlagValue(args.slice(1), "--project"), + }; + } + + if (action === "add") { + const actionArgs = args.slice(1); + return { + action: "add", + projectId: readFlagValue(actionArgs, "--project"), + title: readRequiredFlagValue(actionArgs, "--title", "skills add"), + description: readRequiredFlagValue( + actionArgs, + "--description", + "skills add", + ), + content: readRequiredFlagValue(actionArgs, "--content", "skills add"), + }; + } + + if (action === "update") { + const name = args[1]; + if (!name) { + throw new Error("skills update requires <NAME>"); + } + const actionArgs = args.slice(2); + const title = readFlagValue(actionArgs, "--title"); + const description = readFlagValue(actionArgs, "--description"); + const content = readFlagValue(actionArgs, "--content"); + if ( + title === undefined && + description === undefined && + content === undefined + ) { + throw new Error( + "skills update requires at least one of --title, --description, or --content", + ); + } + return { + action: "update", + name, + projectId: readFlagValue(actionArgs, "--project"), + title, + description, + content, + }; + } + + if (action === "remove") { + const name = args[1]; + if (!name) { + throw new Error("skills remove requires <NAME>"); + } + return { + action: "remove", + name, + projectId: readFlagValue(args.slice(2), "--project"), + }; + } + + throw new Error(`Unknown skills action: ${action}`); +} + function readFlagValue(args: string[], flag: string): string | undefined { const index = args.indexOf(flag); if (index < 0) { @@ -87,6 +191,18 @@ function readFlagValue(args: string[], flag: string): string | undefined { return args[index + 1]; } +function readRequiredFlagValue( + args: string[], + flag: string, + commandLabel: string, +): string { + const value = readFlagValue(args, flag); + if (!value) { + throw new Error(`${commandLabel} requires ${flag} <VALUE>`); + } + return value; +} + function readOptionalPositiveInt( args: string[], flag: string, diff --git a/src/commands/handlers.ts b/src/commands/handlers.ts index 7591887f..677c9f7b 100644 --- a/src/commands/handlers.ts +++ b/src/commands/handlers.ts @@ -5,7 +5,12 @@ import { runSetupCheck, runSetupWizard } from "../core/setup"; import { loadRunState, normalizeIssueKey } from "../core/state"; import { runWorkflow } from "../core/workflow"; import { runCronScheduler } from "../services/cron"; -import { formatWorkflowStageDisplay } from "../utils/status"; +import { + addSkill, + listSkills, + removeSkill, + updateSkill, +} from "../skills/manage"; type SetupCommand = Extract<CliCommand, { kind: "setup" }>; type RunnableCommand = Exclude<CliCommand, { kind: "help" } | SetupCommand>; @@ -49,6 +54,68 @@ export async function handleCommand( return; } + if (command.kind === "skills") { + const selectedProject = command.command.projectId + ? getProjectById(config, command.command.projectId) + : config.projects[0]; + if (command.command.projectId && !selectedProject) { + throw new Error(`Project '${command.command.projectId}' not found`); + } + const project = selectedProject; + if (!project) { + throw new Error("No project is configured"); + } + + if (command.command.action === "list") { + const skills = await listSkills(project.skills.root); + if (skills.length === 0) { + process.stdout.write(`No skills found in ${project.skills.root}\n`); + return; + } + for (const skill of skills) { + process.stdout.write( + `${[skill.name, skill.title, skill.description || "-"].join("\t")}\n`, + ); + } + return; + } + + if (command.command.action === "add") { + const created = await addSkill(project.skills.root, { + title: command.command.title, + description: command.command.description, + content: command.command.content, + }); + process.stdout.write(`Added skill ${created.name} at ${created.path}\n`); + return; + } + + if (command.command.action === "update") { + const updated = await updateSkill( + project.skills.root, + command.command.name, + { + title: command.command.title, + description: command.command.description, + content: command.command.content, + }, + ); + process.stdout.write( + `Updated skill ${updated.name} at ${updated.path}\n`, + ); + return; + } + + const removed = await removeSkill( + project.skills.root, + command.command.name, + ); + process.stdout.write( + `Removed skill ${removed.name} from ${removed.path}\n`, + ); + return; + } + const project = getProjectById(config, command.projectId); if (!project) { throw new Error(`Project '${command.projectId}' not found`); @@ -79,6 +146,10 @@ export function printHelp(): void { " adhd-ai cron [--job <JOB_ID>]", " adhd-ai status --project <PROJECT_ID> --issue <LINEAR_KEY>", " adhd-ai projects", + " adhd-ai skills list [--project <PROJECT_ID>]", + " adhd-ai skills add --title <TITLE> --description <TEXT> --content <TEXT> [--project <PROJECT_ID>]", + " adhd-ai skills update <NAME> [--title <TITLE>] [--description <TEXT>] [--content <TEXT>] [--project <PROJECT_ID>]", + " adhd-ai skills remove <NAME> [--project <PROJECT_ID>]", " adhd-ai setup [--check]", " adhd-ai help", "", diff --git a/src/skills/manage.ts b/src/skills/manage.ts new file mode 100644 index 00000000..9cb6a0b5 --- /dev/null +++ b/src/skills/manage.ts @@ -0,0 +1,264 @@ +import { + access, + mkdir, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; + +interface SkillDocumentParts { + title: string; + description: string; + content: string; +} + +type SkillDocumentFormat = "plain" | "frontmatter"; + +interface ParsedSkillDocument extends SkillDocumentParts { + format: SkillDocumentFormat; +} + +export interface ListedSkill { + name: string; + path: string; + title: string; + description: string; +} + +export async function listSkills(skillsRoot: string): Promise<ListedSkill[]> { + const root = path.resolve(skillsRoot); + try { + await access(root); + } catch { + return []; + } + + const entries = await readdir(root, { withFileTypes: true }); + const skills: ListedSkill[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const skillDir = assertPathWithinRoot(root, path.join(root, entry.name)); + const skillFile = path.join(skillDir, "SKILL.md"); + try { + const raw = await readFile(skillFile, "utf8"); + const parsed = parseSkillDocument(raw); + skills.push({ + name: entry.name, + path: skillFile, + title: parsed.title || entry.name, + description: parsed.description, + }); + } catch {} + } + + skills.sort((a, b) => a.name.localeCompare(b.name)); + return skills; +} + +export async function addSkill( + skillsRoot: string, + input: SkillDocumentParts, +): Promise<{ name: string; path: string }> { + const root = path.resolve(skillsRoot); + await mkdir(root, { recursive: true }); + + const normalized = normalizeNewSkillInput(input); + const name = normalizeSkillName(normalized.title); + const skillDir = assertPathWithinRoot(root, path.join(root, name)); + const skillFile = path.join(skillDir, "SKILL.md"); + + try { + await access(skillDir); + throw new Error(`Skill '${name}' already exists`); + } catch (error) { + if (!isNotFoundError(error)) { + throw error; + } + } + + await mkdir(skillDir, { recursive: true }); + await writeFile(skillFile, renderSkillDocument(normalized), "utf8"); + return { name, path: skillFile }; +} + +export async function updateSkill( + skillsRoot: string, + nameOrAlias: string, + updates: Partial<SkillDocumentParts>, +): Promise<{ name: string; path: string }> { + const root = path.resolve(skillsRoot); + const skillDir = await resolveExistingSkillDir(root, nameOrAlias); + const skillFile = path.join(skillDir, "SKILL.md"); + const existing = parseSkillDocument(await readFile(skillFile, "utf8")); + + const title = + updates.title?.trim() || existing.title || path.basename(skillDir); + const description = updates.description?.trim() ?? existing.description; + const content = updates.content ?? existing.content; + + await writeFile( + skillFile, + renderSkillDocument({ title, description, content }, existing.format), + "utf8", + ); + + return { name: path.basename(skillDir), path: skillFile }; +} + +export async function removeSkill( + skillsRoot: string, + nameOrAlias: string, +): Promise<{ name: string; path: string }> { + const root = path.resolve(skillsRoot); + const skillDir = await resolveExistingSkillDir(root, nameOrAlias); + await rm(skillDir, { recursive: true, force: false }); + return { name: path.basename(skillDir), path: skillDir }; +} + +function normalizeNewSkillInput(input: SkillDocumentParts): SkillDocumentParts { + const title = input.title.trim(); + const description = input.description.trim(); + const content = input.content.trim(); + if (!title) { + throw new Error("Skill title cannot be empty"); + } + if (!description) { + throw new Error("Skill description cannot be empty"); + } + if (!content) { + throw new Error("Skill content cannot be empty"); + } + return { title, description, content }; +} + +function normalizeSkillName(raw: string): string { + const normalized = raw + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/-{2,}/g, "-"); + if (!normalized) { + throw new Error(`Invalid skill name: '${raw}'`); + } + return normalized; +} + +function normalizeExistingSkillLookup(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + throw new Error("Skill name cannot be empty"); + } + if ( + trimmed.includes("/") || + trimmed.includes("\\") || + trimmed.includes("..") + ) { + throw new Error(`Invalid skill name: '${raw}'`); + } + return normalizeSkillName(trimmed); +} + +async function resolveExistingSkillDir( + root: string, + nameOrAlias: string, +): Promise<string> { + const trimmed = nameOrAlias.trim(); + const candidates = Array.from( + new Set([trimmed, normalizeExistingSkillLookup(nameOrAlias)]), + ); + for (const candidate of candidates) { + const skillDir = assertPathWithinRoot(root, path.join(root, candidate)); + try { + const skillFile = path.join(skillDir, "SKILL.md"); + await access(skillFile); + return skillDir; + } catch {} + } + throw new Error(`Skill '${nameOrAlias}' does not exist`); +} + +function parseSkillDocument(input: string): ParsedSkillDocument { + const normalized = input.replace(/\r\n/g, "\n"); + + const frontmatterMatch = normalized.match( + /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/i, + ); + if (frontmatterMatch) { + const frontmatterBlock = frontmatterMatch[1] ?? ""; + const body = frontmatterMatch[2] ?? ""; + return { + title: parseField(frontmatterBlock, "name"), + description: parseField(frontmatterBlock, "description"), + content: body.trim(), + format: "frontmatter", + }; + } + + const titleMatch = normalized.match(/^name:\s*(.+)$/im); + const descriptionMatch = normalized.match(/^description:\s*(.+)$/im); + const bodyMatch = normalized.match( + /^name:[^\n]*\ndescription:[^\n]*\n\n?([\s\S]*)$/i, + ); + return { + title: titleMatch?.[1]?.trim() || "", + description: descriptionMatch?.[1]?.trim() || "", + content: bodyMatch?.[1]?.trim() || "", + format: "plain", + }; +} + +function parseField(input: string, fieldName: string): string { + const match = input.match(new RegExp(`^${fieldName}:\\s*(.+)$`, "im")); + return match?.[1]?.trim() || ""; +} + +function renderSkillDocument( + input: SkillDocumentParts, + format: SkillDocumentFormat = "plain", +): string { + if (format === "frontmatter") { + return [ + "---", + `name: ${input.title.trim()}`, + `description: ${input.description.trim()}`, + "---", + "", + input.content.trim(), + "", + ].join("\n"); + } + + return [ + `name: ${input.title.trim()}`, + `description: ${input.description.trim()}`, + "", + input.content.trim(), + "", + ].join("\n"); +} + +function assertPathWithinRoot(root: string, target: string): string { + const resolvedRoot = path.resolve(root); + const resolvedTarget = path.resolve(target); + const relative = path.relative(resolvedRoot, resolvedTarget); + if ( + relative === "" || + (!relative.startsWith("..") && !path.isAbsolute(relative)) + ) { + return resolvedTarget; + } + throw new Error(`Path escapes skills root: ${target}`); +} + +function isNotFoundError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: string }).code === "ENOENT" + ); +} diff --git a/tests/args.test.ts b/tests/args.test.ts index c3b7fbf1..705b0ba6 100644 --- a/tests/args.test.ts +++ b/tests/args.test.ts @@ -131,6 +131,109 @@ describe("parseArgs", () => { }); }); + it("parses skills list command", () => { + expect(parseArgs(["bun", "adhd-ai", "skills", "list"])).toEqual({ + kind: "skills", + command: { + action: "list", + projectId: undefined, + }, + }); + }); + + it("parses skills add command", () => { + expect( + parseArgs([ + "bun", + "adhd-ai", + "skills", + "add", + "--title", + "Backend Standard", + "--description", + "Rules", + "--content", + "Use consistent module boundaries.", + "--project", + "api", + ]), + ).toEqual({ + kind: "skills", + command: { + action: "add", + title: "Backend Standard", + description: "Rules", + content: "Use consistent module boundaries.", + projectId: "api", + }, + }); + }); + + it("parses skills update command", () => { + expect( + parseArgs([ + "bun", + "adhd-ai", + "skills", + "update", + "backend-standard", + "--description", + "Updated description", + ]), + ).toEqual({ + kind: "skills", + command: { + action: "update", + name: "backend-standard", + title: undefined, + description: "Updated description", + content: undefined, + projectId: undefined, + }, + }); + }); + + it("parses skills remove command", () => { + expect( + parseArgs([ + "bun", + "adhd-ai", + "skills", + "remove", + "backend-standard", + "--project", + "default", + ]), + ).toEqual({ + kind: "skills", + command: { + action: "remove", + name: "backend-standard", + projectId: "default", + }, + }); + }); + + it("rejects skills add without required flags", () => { + expect(() => + parseArgs(["bun", "adhd-ai", "skills", "add", "--title", "t"]), + ).toThrow("skills add requires --description <VALUE>"); + }); + + it("rejects skills update without any fields", () => { + expect(() => + parseArgs(["bun", "adhd-ai", "skills", "update", "backend-standard"]), + ).toThrow( + "skills update requires at least one of --title, --description, or --content", + ); + }); + + it("rejects unknown skills action", () => { + expect(() => parseArgs(["bun", "adhd-ai", "skills", "ship-it"])).toThrow( + "Unknown skills action: ship-it", + ); + }); + it("rejects project with all-projects", () => { expect(() => parseArgs([ diff --git a/tests/skills-manage.test.ts b/tests/skills-manage.test.ts new file mode 100644 index 00000000..0d6c99a0 --- /dev/null +++ b/tests/skills-manage.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { + addSkill, + listSkills, + removeSkill, + updateSkill, +} from "../src/skills/manage"; + +describe("skills manage", () => { + it("adds and lists a skill using the SKILL.md template", async () => { + const tempDir = await mkdtemp( + path.join(os.tmpdir(), "adhd-skills-manage-"), + ); + try { + const created = await addSkill(tempDir, { + title: "Backend Standard", + description: "Repository backend practices", + content: "Keep modules focused and test changes.", + }); + expect(created.name).toBe("backend-standard"); + + const skillFile = await readFile(created.path, "utf8"); + expect(skillFile).toContain("name: Backend Standard"); + expect(skillFile).toContain("description: Repository backend practices"); + expect(skillFile).toContain("Keep modules focused and test changes."); + + const listed = await listSkills(tempDir); + expect(listed).toHaveLength(1); + expect(listed[0]?.name).toBe("backend-standard"); + expect(listed[0]?.title).toBe("Backend Standard"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("prevents overwriting an existing skill on add", async () => { + const tempDir = await mkdtemp( + path.join(os.tmpdir(), "adhd-skills-manage-"), + ); + try { + await addSkill(tempDir, { + title: "Backend Standard", + description: "Repository backend practices", + content: "Initial content.", + }); + await expect( + addSkill(tempDir, { + title: "Backend Standard", + description: "Repository backend practices", + content: "Replacement content.", + }), + ).rejects.toThrow("Skill 'backend-standard' already exists"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("updates skill fields and preserves unspecified values", async () => { + const tempDir = await mkdtemp( + path.join(os.tmpdir(), "adhd-skills-manage-"), + ); + try { + const created = await addSkill(tempDir, { + title: "Backend Standard", + description: "Repository backend practices", + content: "Initial content.", + }); + + await updateSkill(tempDir, created.name, { + description: "Updated description", + }); + + const skillFile = await readFile(created.path, "utf8"); + expect(skillFile).toContain("name: Backend Standard"); + expect(skillFile).toContain("description: Updated description"); + expect(skillFile).toContain("Initial content."); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("removes a skill directory", async () => { + const tempDir = await mkdtemp( + path.join(os.tmpdir(), "adhd-skills-manage-"), + ); + try { + const created = await addSkill(tempDir, { + title: "Backend Standard", + description: "Repository backend practices", + content: "Initial content.", + }); + await removeSkill(tempDir, created.name); + + const listed = await listSkills(tempDir); + expect(listed).toHaveLength(0); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("rejects unsafe skill name input for existing-skill operations", async () => { + const tempDir = await mkdtemp( + path.join(os.tmpdir(), "adhd-skills-manage-"), + ); + try { + await expect(removeSkill(tempDir, "../escape")).rejects.toThrow( + "Invalid skill name: '../escape'", + ); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("preserves frontmatter skill content when updating metadata only", async () => { + const tempDir = await mkdtemp( + path.join(os.tmpdir(), "adhd-skills-manage-"), + ); + const skillDir = path.join(tempDir, "piv-plan"); + const skillPath = path.join(skillDir, "SKILL.md"); + try { + await mkdir(skillDir, { recursive: true }); + const sourceSkill = await readFile( + path.join(process.cwd(), "skills", "piv-plan", "SKILL.md"), + "utf8", + ); + await writeFile(skillPath, sourceSkill, "utf8"); + + await updateSkill(tempDir, "piv-plan", { + description: "Updated planning description", + }); + + const updatedSkill = await readFile(skillPath, "utf8"); + expect(updatedSkill).toContain("---\nname: adhd-plan"); + expect(updatedSkill).toContain( + "description: Updated planning description", + ); + expect(updatedSkill).toContain("# ADHD.ai Plan Skill"); + expect(updatedSkill).toContain("## Goals"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); +});