diff --git a/README.md b/README.md index c1a821f..cfac99a 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,41 @@ Installed skill philips-hue to ~/.claude/skills/philips-hue Even better, once this convention becomes commonplace, agents will by default do all these before they even run the tool, so when you ask it to "install hue-cli", it will know to run `--skill list` the same way a human would run `--help` after downloading a program, and install the necessary skills themselves without being asked to. +## Quick setup — add skillflag to your CLI + +Copy the prompt below and paste it into your coding agent. It will add skillflag support to your project. + +Currently TypeScript/Node only. [Open an issue](https://github.com/osolmaz/skillflag/issues) if you'd like support for another language. + +```text +Add skillflag to this project so the CLI can bundle and expose agent skills. + +1. Install the skillflag library: + npm install skillflag + +2. Create a skill directory at skills//SKILL.md with a YAML + frontmatter (name, description) and markdown instructions for the agent. + +3. In the CLI entrypoint, intercept --skill and delegate to skillflag: + + import { findSkillsRoot, maybeHandleSkillflag } from "skillflag"; + + await maybeHandleSkillflag(process.argv, { + skillsRoot: findSkillsRoot(import.meta.url), + }); + +4. Verify it works: + --skill list + --skill show + --skill export | npx skillflag install + +5. For the full integration guide: + https://raw.githubusercontent.com/osolmaz/skillflag/main/docs/INTEGRATION.md + +6. For the skillflag specification: + https://raw.githubusercontent.com/osolmaz/skillflag/main/docs/SKILLFLAG_SPEC.md +``` + ## Install (optional) ```bash @@ -56,7 +91,7 @@ Any CLI that implements the skillflag convention can be used like this: ```bash # list skills the tool can export --skill list -# show a single skill’s metadata +# show a single skill's metadata --skill show # install into Codex user skills --skill export | npx skillflag install --agent codex @@ -64,6 +99,28 @@ Any CLI that implements the skillflag convention can be used like this: --skill export | npx skillflag install --agent claude --scope repo ``` +### Interactive mode + +When `--agent` or `--scope` is omitted and a TTY is available, `skill-install` launches an interactive wizard: + +```bash +# pipe a skill and let the wizard guide you + --skill export | npx skillflag install +``` + +The wizard lets you pick agents and scopes with arrow keys and space to select, then confirms before installing. This works even when stdin is piped. + +### Multi-target install + +`--agent` and `--scope` flags accept a single value each. +To install to multiple agents/scopes in one run, use the interactive wizard: + +```bash + --skill export | npx skillflag install +``` + +In the wizard, select multiple entries with space, then confirm the matrix install. + ## Add skillflag to your CLI 1. Add the library and ship your skill directory in the package. @@ -84,6 +141,24 @@ await maybeHandleSkillflag(process.argv, { See the full guide in [docs/INTEGRATION.md](docs/INTEGRATION.md). +## Supported agents + +codex, claude, portable, vscode, copilot, amp, goose, opencode, factory, cursor + +## Supported scopes + +| Scope | Description | +| ------ | ------------------------------------------------------------- | +| `repo` | Install into the current repository (e.g. `.codex/skills/`) | +| `user` | Install into the user's home config (e.g. `~/.codex/skills/`) | +| `cwd` | Install relative to the current working directory | + +## Help + +```bash +skill-install --help +``` + ## Bundled skill This repo ships a single bundled skill at `skills/skillflag/` that documents both `skillflag` and `skill-install`. diff --git a/docs/SKILLFLAG_SPEC.md b/docs/SKILLFLAG_SPEC.md index 952e662..610f40d 100644 --- a/docs/SKILLFLAG_SPEC.md +++ b/docs/SKILLFLAG_SPEC.md @@ -384,7 +384,9 @@ skill-install [PATH ...] [--legacy] # only where a legacy target exists (vscode/copilot -> .claude/skills) ``` -Implementations **MAY** accept repeated and/or comma-separated `--agent` and `--scope` flags, and apply an install matrix across selected sources and targets. +The reference implementation accepts only one value for `--agent` and one value for `--scope`. +Repeated flags and comma-separated values are rejected. +Multi-target installs remain available through the interactive wizard (multi-select). ### 2.2 Required flags diff --git a/src/install/cli.ts b/src/install/cli.ts index c56e482..e9c7135 100644 --- a/src/install/cli.ts +++ b/src/install/cli.ts @@ -135,16 +135,16 @@ const scopeList = SCOPES.join(", "); const usageLines = [ "Usage:", - " skill-install [PATH ...] [--agent [,...]] [--scope [,...]] [--force]", + " skill-install [PATH ...] [--agent ] [--scope ] [--force]", "", "Input:", " PATH ... Skill directory path(s) containing SKILL.md.", " stdin tar stream If PATH is omitted, reads a tar bundle from stdin.", "", "Options:", - " --agent Target agent(s); repeat or use comma-separated values.", + " --agent Target agent (single value).", ` Supported agents: ${agentList}`, - " --scope Target scope(s); repeat or use comma-separated values.", + " --scope Target scope (single value).", ` Supported scopes: ${scopeList}`, " --force Overwrite destination if it already exists.", " -h, --help Show this help.", @@ -152,6 +152,8 @@ const usageLines = [ "Behavior:", " If --agent or --scope is missing and an interactive TTY is available,", " the installer launches a wizard to collect missing values.", + " CLI flags accept only one --agent and one --scope.", + " Use the wizard to select multiple agents/scopes.", ]; const usageText = usageLines.join("\n"); @@ -192,51 +194,61 @@ const scopeDescriptions: Record = { cwd: "Install relative to the current working directory.", }; -function parseScopeValues(value: string | undefined): string[] { +function parseScopeValue(value: string | undefined): string { if (!value || value.startsWith("-")) { throw new InstallError("Missing value for --scope."); } - const scopes = value - .split(",") - .map((item) => item.trim()) - .filter((item) => item.length > 0); - if (scopes.length === 0) { + const scope = value.trim(); + if (scope.length === 0) { throw new InstallError("Missing value for --scope."); } - return scopes; + if (scope.includes(",")) { + throw new InstallError( + "Only one value is allowed for --scope. Comma-separated values are not supported.", + ); + } + return scope; } -function parseAgentValues(value: string | undefined): string[] { +function parseAgentValue(value: string | undefined): string { if (!value || value.startsWith("-")) { throw new InstallError("Missing value for --agent."); } - const agents = value - .split(",") - .map((item) => item.trim()) - .filter((item) => item.length > 0); - if (agents.length === 0) { + const agent = value.trim(); + if (agent.length === 0) { throw new InstallError("Missing value for --agent."); } - return agents; + if (agent.includes(",")) { + throw new InstallError( + "Only one value is allowed for --agent. Comma-separated values are not supported.", + ); + } + return agent; } function parseArgs(args: string[]): ParsedArgs { const rest = [...args]; const inputPaths: string[] = []; - const agents: string[] = []; - const scopes: string[] = []; + let agentValue: string | undefined; + let scopeValue: string | undefined; let force = false; let help = false; for (let i = 0; i < rest.length; i += 1) { const arg = rest[i]; if (arg === "--agent") { - agents.push(...parseAgentValues(rest[i + 1])); + if (agentValue !== undefined) { + throw new InstallError("Only one --agent flag is allowed."); + } + agentValue = parseAgentValue(rest[i + 1]); i += 1; continue; } if (arg === "--scope") { - scopes.push(...parseScopeValues(rest[i + 1])); + if (scopeValue !== undefined) { + throw new InstallError("Only one --scope flag is allowed."); + } + scopeValue = parseScopeValue(rest[i + 1]); i += 1; continue; } @@ -256,8 +268,8 @@ function parseArgs(args: string[]): ParsedArgs { return { inputPaths, - agents: uniqueValues(agents), - scopes: uniqueValues(scopes), + agents: agentValue ? [agentValue] : [], + scopes: scopeValue ? [scopeValue] : [], force, help, }; diff --git a/src/skillflag.ts b/src/skillflag.ts index dfccd65..13b52a3 100644 --- a/src/skillflag.ts +++ b/src/skillflag.ts @@ -48,7 +48,7 @@ export type SkillflagPromptApi = { const usageLines = [ "Usage:", - " --skill install [ ...] [--agent [,...]] [--agent [,...]] [--scope [,...]] [--scope [,...]] [--force]", + " --skill install [ ...] [--agent ] [--scope ] [--force]", " --skill list [--json]", " --skill export ", " --skill show ", @@ -75,9 +75,9 @@ export const SKILLFLAG_HELP_TEXT = [ "Export a skill bundle:", " tool --skill export ", "", - "Install a skill bundle into one or more agents:", - " tool --skill install [ ...]", - " tool --skill export | skill-install --agent [--agent ] --scope [--scope ]", + "Install a skill bundle:", + " tool --skill install [ ...] [--agent ] [--scope ]", + " tool --skill export | skill-install --agent --scope ", "", "For full details, read docs/SKILLFLAG_SPEC.md.", ].join("\n"); diff --git a/test/integration/skill-install-cli.test.ts b/test/integration/skill-install-cli.test.ts index 8221442..1060721 100644 --- a/test/integration/skill-install-cli.test.ts +++ b/test/integration/skill-install-cli.test.ts @@ -401,17 +401,12 @@ test("runInstallCli keeps non-interactive install behavior with flags", async (t assert.match(installedContent, /name: cli-skill/); }); -test("runInstallCli installs multiple skills across repeated scopes", async (t) => { +test("runInstallCli installs multiple skills with a single agent/scope target", async (t) => { const repo = await makeTempDir("skill-install-cli-multi-repo-"); - const codexHome = await makeTempDir("skill-install-cli-multi-codex-home-"); const skillOne = await makeTempDir("skill-install-cli-skill-one-"); const skillTwo = await makeTempDir("skill-install-cli-skill-two-"); - const previousCodexHome = process.env.CODEX_HOME; - process.env.CODEX_HOME = codexHome.dir; t.after(async () => { - process.env.CODEX_HOME = previousCodexHome; await repo.cleanup(); - await codexHome.cleanup(); await skillOne.cleanup(); await skillTwo.cleanup(); }); @@ -440,8 +435,6 @@ test("runInstallCli installs multiple skills across repeated scopes", async (t) "codex", "--scope", "repo", - "--scope", - "user", ], { stdin: Readable.from([]), @@ -456,20 +449,13 @@ test("runInstallCli installs multiple skills across repeated scopes", async (t) await fs.access(path.join(repo.dir, ".codex/skills/cli-skill-one/SKILL.md")); await fs.access(path.join(repo.dir, ".codex/skills/cli-skill-two/SKILL.md")); - await fs.access(path.join(codexHome.dir, "skills/cli-skill-one/SKILL.md")); - await fs.access(path.join(codexHome.dir, "skills/cli-skill-two/SKILL.md")); }); -test("runInstallCli accepts comma-separated scopes", async (t) => { - const repo = await makeTempDir("skill-install-cli-comma-repo-"); - const codexHome = await makeTempDir("skill-install-cli-comma-codex-home-"); - const skill = await makeTempDir("skill-install-cli-comma-skill-"); - const previousCodexHome = process.env.CODEX_HOME; - process.env.CODEX_HOME = codexHome.dir; +test("runInstallCli rejects repeated --scope flags", async (t) => { + const repo = await makeTempDir("skill-install-cli-repeat-scope-repo-"); + const skill = await makeTempDir("skill-install-cli-repeat-scope-skill-"); t.after(async () => { - process.env.CODEX_HOME = previousCodexHome; await repo.cleanup(); - await codexHome.cleanup(); await skill.cleanup(); }); @@ -490,7 +476,9 @@ test("runInstallCli accepts comma-separated scopes", async (t) => { "--agent", "codex", "--scope", - "repo,user", + "repo", + "--scope", + "user", ], { stdin: Readable.from([]), @@ -499,30 +487,18 @@ test("runInstallCli accepts comma-separated scopes", async (t) => { }, ); - assert.equal(exitCode, 0); - assert.match(stderr.text(), /Installed cli-skill-comma to/); - - await fs.access( - path.join(repo.dir, ".codex/skills/cli-skill-comma/SKILL.md"), + assert.equal(exitCode, 1); + assert.match(stderr.text(), /Only one --scope flag is allowed/); + await assert.rejects( + fs.access(path.join(repo.dir, ".codex/skills/cli-skill-comma/SKILL.md")), ); - await fs.access(path.join(codexHome.dir, "skills/cli-skill-comma/SKILL.md")); }); -test("runInstallCli supports repeated --agent and installs matrix combinations", async (t) => { - const repo = await makeTempDir("skill-install-cli-agents-repo-"); - const codexHome = await makeTempDir("skill-install-cli-agents-codex-home-"); - const home = await makeTempDir("skill-install-cli-agents-home-"); - const skill = await makeTempDir("skill-install-cli-agents-skill-"); - const previousCodexHome = process.env.CODEX_HOME; - const previousHome = process.env.HOME; - process.env.CODEX_HOME = codexHome.dir; - process.env.HOME = home.dir; +test("runInstallCli rejects comma-separated --scope values", async (t) => { + const repo = await makeTempDir("skill-install-cli-comma-repo-"); + const skill = await makeTempDir("skill-install-cli-comma-skill-"); t.after(async () => { - process.env.CODEX_HOME = previousCodexHome; - process.env.HOME = previousHome; await repo.cleanup(); - await codexHome.cleanup(); - await home.cleanup(); await skill.cleanup(); }); @@ -531,7 +507,7 @@ test("runInstallCli supports repeated --agent and installs matrix combinations", await writeFile( skill.dir, "SKILL.md", - "---\nname: cli-skill-agents\ndescription: CLI multi-agent test skill\n---\n", + "---\nname: cli-skill-comma\ndescription: CLI comma scope skill\n---\n", ); const stderr = createCapture(); @@ -542,12 +518,8 @@ test("runInstallCli supports repeated --agent and installs matrix combinations", skill.dir, "--agent", "codex", - "--agent", - "claude", - "--scope", - "repo", "--scope", - "user", + "repo,user", ], { stdin: Readable.from([]), @@ -556,28 +528,59 @@ test("runInstallCli supports repeated --agent and installs matrix combinations", }, ); - assert.equal(exitCode, 0); - const installLines = stderr - .text() - .split("\n") - .filter((line) => line.startsWith("Installed ")); - assert.equal(installLines.length, 4); + assert.equal(exitCode, 1); + assert.match(stderr.text(), /Only one value is allowed for --scope/); + await assert.rejects( + fs.access(path.join(repo.dir, ".codex/skills/cli-skill-comma/SKILL.md")), + ); +}); - await fs.access( - path.join(repo.dir, ".codex/skills/cli-skill-agents/SKILL.md"), +test("runInstallCli rejects comma-separated --agent values", async (t) => { + const repo = await makeTempDir("skill-install-cli-comma-agent-repo-"); + const skill = await makeTempDir("skill-install-cli-comma-agent-skill-"); + t.after(async () => { + await repo.cleanup(); + await skill.cleanup(); + }); + + initGit(repo.dir); + + await writeFile( + skill.dir, + "SKILL.md", + "---\nname: cli-skill-comma-agent\ndescription: CLI comma agent skill\n---\n", ); - await fs.access( - path.join(repo.dir, ".claude/skills/cli-skill-agents/SKILL.md"), + + const stderr = createCapture(); + const exitCode = await runInstallCli( + [ + "node", + "skill-install", + skill.dir, + "--agent", + "codex,claude", + "--scope", + "repo", + ], + { + stdin: Readable.from([]), + stderr: stderr.stream, + cwd: repo.dir, + }, ); - await fs.access(path.join(codexHome.dir, "skills/cli-skill-agents/SKILL.md")); - await fs.access( - path.join(home.dir, ".claude/skills/cli-skill-agents/SKILL.md"), + + assert.equal(exitCode, 1); + assert.match(stderr.text(), /Only one value is allowed for --agent/); + await assert.rejects( + fs.access( + path.join(repo.dir, ".codex/skills/cli-skill-comma-agent/SKILL.md"), + ), ); }); -test("runInstallCli fails preflight when selected agents/scopes collide on destination", async (t) => { - const repo = await makeTempDir("skill-install-cli-shared-dest-repo-"); - const skill = await makeTempDir("skill-install-cli-shared-dest-skill-"); +test("runInstallCli rejects repeated --agent flags", async (t) => { + const repo = await makeTempDir("skill-install-cli-agents-repo-"); + const skill = await makeTempDir("skill-install-cli-agents-skill-"); t.after(async () => { await repo.cleanup(); await skill.cleanup(); @@ -588,7 +591,7 @@ test("runInstallCli fails preflight when selected agents/scopes collide on desti await writeFile( skill.dir, "SKILL.md", - "---\nname: cli-skill-shared\ndescription: CLI shared destination test skill\n---\n", + "---\nname: cli-skill-agents\ndescription: CLI multi-agent test skill\n---\n", ); const stderr = createCapture(); @@ -598,9 +601,9 @@ test("runInstallCli fails preflight when selected agents/scopes collide on desti "skill-install", skill.dir, "--agent", - "portable", + "codex", "--agent", - "amp", + "claude", "--scope", "repo", ], @@ -611,6 +614,41 @@ test("runInstallCli fails preflight when selected agents/scopes collide on desti }, ); + assert.equal(exitCode, 1); + assert.match(stderr.text(), /Only one --agent flag is allowed/); + await assert.rejects( + fs.access(path.join(repo.dir, ".codex/skills/cli-skill-agents/SKILL.md")), + ); +}); + +test("runInstallCli fails preflight when selected agents/scopes collide on destination", async (t) => { + const repo = await makeTempDir("skill-install-cli-shared-dest-repo-"); + const skill = await makeTempDir("skill-install-cli-shared-dest-skill-"); + t.after(async () => { + await repo.cleanup(); + await skill.cleanup(); + }); + + initGit(repo.dir); + + await writeFile( + skill.dir, + "SKILL.md", + "---\nname: cli-skill-shared\ndescription: CLI shared destination test skill\n---\n", + ); + + const stderr = createCapture(); + const prompt = createPromptStub({ + multiselectResponses: [["portable", "amp"], ["repo"]], + confirmResponses: [false], + }); + const exitCode = await runInstallCli(["node", "skill-install", skill.dir], { + stdin: createTtyStdin(), + stderr: stderr.stream, + cwd: repo.dir, + promptApi: prompt.promptApi, + }); + assert.equal(exitCode, 1); assert.match(stderr.text(), /Install destination collisions detected/); assert.match(stderr.text(), /\.agents\/skills\/cli-skill-shared/); diff --git a/test/integration/skillflag.test.ts b/test/integration/skillflag.test.ts index a79cb5f..fc9be20 100644 --- a/test/integration/skillflag.test.ts +++ b/test/integration/skillflag.test.ts @@ -384,15 +384,10 @@ test("--skill install delegates to installer with exported tar input", async (t) assert.match(installedContent, /name: alpha/); }); -test("--skill install supports multiple ids and multiple scopes", async (t) => { +test("--skill install supports multiple ids with a single scope", async (t) => { const repo = await makeTempDir("skillflag-install-multi-repo-"); - const codexHome = await makeTempDir("skillflag-install-multi-codex-home-"); - const previousCodexHome = process.env.CODEX_HOME; - process.env.CODEX_HOME = codexHome.dir; t.after(async () => { - process.env.CODEX_HOME = previousCodexHome; await repo.cleanup(); - await codexHome.cleanup(); }); initGit(repo.dir); @@ -412,8 +407,6 @@ test("--skill install supports multiple ids and multiple scopes", async (t) => { "codex", "--scope", "repo", - "--scope", - "user", ], { skillsRoot: fixturesRoot, @@ -431,24 +424,12 @@ test("--skill install supports multiple ids and multiple scopes", async (t) => { await fs.access(path.join(repo.dir, ".codex/skills/alpha/SKILL.md")); await fs.access(path.join(repo.dir, ".codex/skills/beta/SKILL.md")); - await fs.access(path.join(codexHome.dir, "skills/alpha/SKILL.md")); - await fs.access(path.join(codexHome.dir, "skills/beta/SKILL.md")); }); -test("--skill install supports multiple agents and multiple scopes", async (t) => { - const repo = await makeTempDir("skillflag-install-agents-repo-"); - const codexHome = await makeTempDir("skillflag-install-agents-codex-home-"); - const home = await makeTempDir("skillflag-install-agents-home-"); - const previousCodexHome = process.env.CODEX_HOME; - const previousHome = process.env.HOME; - process.env.CODEX_HOME = codexHome.dir; - process.env.HOME = home.dir; +test("--skill install rejects repeated --scope flags", async (t) => { + const repo = await makeTempDir("skillflag-install-repeat-scope-repo-"); t.after(async () => { - process.env.CODEX_HOME = previousCodexHome; - process.env.HOME = previousHome; await repo.cleanup(); - await codexHome.cleanup(); - await home.cleanup(); }); initGit(repo.dir); @@ -465,8 +446,6 @@ test("--skill install supports multiple agents and multiple scopes", async (t) = "alpha", "--agent", "codex", - "--agent", - "claude", "--scope", "repo", "--scope", @@ -482,17 +461,56 @@ test("--skill install supports multiple agents and multiple scopes", async (t) = }, ); - assert.equal(exitCode, 0); - const installLines = stderr - .text() - .split("\n") - .filter((line) => line.startsWith("Installed ")); - assert.equal(installLines.length, 4); + assert.equal(exitCode, 1); + assert.match(stderr.text(), /Only one --scope flag is allowed/); + await assert.rejects( + fs.access(path.join(repo.dir, ".codex/skills/alpha/SKILL.md")), + ); +}); - await fs.access(path.join(repo.dir, ".codex/skills/alpha/SKILL.md")); - await fs.access(path.join(repo.dir, ".claude/skills/alpha/SKILL.md")); - await fs.access(path.join(codexHome.dir, "skills/alpha/SKILL.md")); - await fs.access(path.join(home.dir, ".claude/skills/alpha/SKILL.md")); +test("--skill install rejects repeated --agent flags", async (t) => { + const repo = await makeTempDir("skillflag-install-agents-repo-"); + t.after(async () => { + await repo.cleanup(); + }); + + initGit(repo.dir); + + const stdout = createCapture(); + const stderr = createCapture(); + + const exitCode = await handleSkillflag( + [ + "node", + "cli", + "--skill", + "install", + "alpha", + "--agent", + "codex", + "--agent", + "claude", + "--scope", + "repo", + ], + { + skillsRoot: fixturesRoot, + stdin: Readable.from([]), + stdout: stdout.stream, + stderr: stderr.stream, + cwd: repo.dir, + includeBundledSkill: false, + }, + ); + + assert.equal(exitCode, 1); + assert.match(stderr.text(), /Only one --agent flag is allowed/); + await assert.rejects( + fs.access(path.join(repo.dir, ".codex/skills/alpha/SKILL.md")), + ); + await assert.rejects( + fs.access(path.join(repo.dir, ".claude/skills/alpha/SKILL.md")), + ); }); test("--skill install without id in non-interactive mode requires explicit ids when multiple skills exist", async () => {