From b98d5bb8e539930df4a7450c09396364a9798da2 Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 20 Feb 2026 11:03:21 +0100 Subject: [PATCH 01/19] feat: interactive install wizard with @clack/prompts When skill-install is run without --agent/--scope flags and stdin is a TTY, launches a step-by-step wizard to select agent, scope, path, and force option. Non-interactive CLI with flags works exactly as before. --- package-lock.json | 29 +- package.json | 1 + src/install/cli.ts | 367 +++++++++++++++++++-- test/integration/skill-install-cli.test.ts | 70 ++++ 4 files changed, 434 insertions(+), 33 deletions(-) create mode 100644 test/integration/skill-install-cli.test.ts diff --git a/package-lock.json b/package-lock.json index c82f9c1..4f9f4fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@clack/prompts": "^1.0.1", "tar-stream": "^3.1.7" }, "bin": { @@ -56,6 +57,27 @@ "node": ">=6.9.0" } }, + "node_modules/@clack/core": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.0.1.tgz", + "integrity": "sha512-WKeyK3NOBwDOzagPR5H08rFk9D/WuN705yEbuZvKqlkmoLM2woKtXb10OO2k1NoSU4SFG947i2/SCYh+2u5e4g==", + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.0.1.tgz", + "integrity": "sha512-/42G73JkuYdyWZ6m8d/CJtBrGl1Hegyc7Fy78m5Ob+jF85TOUmLR5XLce/U3LxYAw0kJ8CT5aI99RIvPHcGp/Q==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.0.1", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -5742,7 +5764,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -6633,6 +6654,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, "node_modules/slash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", diff --git a/package.json b/package.json index bfb6268..d76b8fd 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "access": "public" }, "dependencies": { + "@clack/prompts": "^1.0.1", "tar-stream": "^3.1.7" }, "devDependencies": { diff --git a/src/install/cli.ts b/src/install/cli.ts index 631252c..6b88a45 100644 --- a/src/install/cli.ts +++ b/src/install/cli.ts @@ -1,21 +1,42 @@ import process from "node:process"; import fs from "node:fs"; -import { Readable } from "node:stream"; +import path from "node:path"; +import { Readable, Writable } from "node:stream"; +import { + confirm, + intro, + isCancel, + note, + outro, + select, + spinner, + text, +} from "@clack/prompts"; +import type { Option } from "@clack/prompts"; import { InstallError, toErrorMessage } from "./errors.js"; import { assertAgent, assertScope, installSkill } from "./install.js"; +import { resolveSkillsRoot, type Agent, type Scope } from "./resolve.js"; +import { assertSkillDir, readSkillMetadata } from "./validate.js"; export type InstallCliOptions = { stdin?: Readable; - stdout?: NodeJS.WritableStream; - stderr?: NodeJS.WritableStream; + stdout?: Writable; + stderr?: Writable; cwd?: string; }; type ParsedArgs = { inputPath?: string; - agent: string; - scope: string; + agent?: string; + scope?: string; + force: boolean; +}; + +type ResolvedInstallArgs = { + inputPath?: string; + agent: Agent; + scope: Scope; force: boolean; }; @@ -25,6 +46,82 @@ const usageLines = [ " skill-install --agent --scope < tar", ]; +const agentOptions: Option[] = [ + { + value: "codex", + label: "codex", + hint: "OpenAI Codex CLI skills (.codex/skills or CODEX_HOME/skills)", + }, + { + value: "claude", + label: "claude", + hint: "Claude Code skills (.claude/skills)", + }, + { + value: "portable", + label: "portable", + hint: "Portable agents skills (.agents/skills)", + }, + { + value: "vscode", + label: "vscode", + hint: "VS Code skills in .github/skills", + }, + { + value: "copilot", + label: "copilot", + hint: "GitHub Copilot skills in .github/skills", + }, + { + value: "amp", + label: "amp", + hint: "Amp agent skills (.agents/skills)", + }, + { + value: "goose", + label: "goose", + hint: "Goose agent skills (.agents/skills)", + }, + { + value: "opencode", + label: "opencode", + hint: "OpenCode skills (.opencode/skill)", + }, + { + value: "factory", + label: "factory", + hint: "Factory skills (.factory/skills)", + }, + { + value: "cursor", + label: "cursor", + hint: "Cursor skills (.cursor/skills)", + }, +]; + +const scopeDescriptions: Record = { + repo: "Install to the current git repo root.", + user: "Install to your user-level skills directory.", + admin: "Install system-wide under /etc (typically needs sudo).", + cwd: "Install relative to the current working directory.", + parent: "Install relative to the parent of the current directory.", +}; + +const supportedScopesByAgent: Record = { + codex: ["repo", "user", "admin", "cwd", "parent"], + claude: ["repo", "user"], + portable: ["repo", "user"], + vscode: ["repo"], + copilot: ["repo"], + amp: ["repo", "user"], + goose: ["repo", "user"], + opencode: ["repo", "user"], + factory: ["repo", "user"], + cursor: ["repo"], +}; + +const allScopes: Scope[] = ["repo", "user", "admin", "cwd", "parent"]; + function parseArgs(args: string[]): ParsedArgs { const rest = [...args]; let inputPath: string | undefined; @@ -58,10 +155,6 @@ function parseArgs(args: string[]): ParsedArgs { throw new InstallError(`Unexpected argument: ${arg}`); } - if (!agent || !scope) { - throw new InstallError(`Missing required flags.\n${usageLines.join("\n")}`); - } - return { inputPath, agent, scope, force }; } @@ -72,43 +165,253 @@ function stdinHasData(stream: Readable): boolean { return !stream.readableEnded; } +function stdinIsTty(stream: Readable): boolean { + return (stream as { isTTY?: boolean }).isTTY === true; +} + +function asAgent(value: string | undefined): Agent | undefined { + if (!value) return undefined; + try { + return assertAgent(value); + } catch { + return undefined; + } +} + +function asScope(value: string | undefined): Scope | undefined { + if (!value) return undefined; + try { + return assertScope(value); + } catch { + return undefined; + } +} + +function validateRequiredFlags(parsed: ParsedArgs): ResolvedInstallArgs { + if (!parsed.agent || !parsed.scope) { + throw new InstallError(`Missing required flags.\n${usageLines.join("\n")}`); + } + return { + inputPath: parsed.inputPath, + agent: assertAgent(parsed.agent), + scope: assertScope(parsed.scope), + force: parsed.force, + }; +} + +function resolveInstallInput( + inputPath: string | undefined, + stdin: Readable, +): { kind: "dir"; dir: string } | { kind: "tar"; stream: Readable } { + if (inputPath) { + const stat = fs.statSync(inputPath); + if (!stat.isDirectory()) { + throw new InstallError("PATH must be a directory containing SKILL.md."); + } + return { kind: "dir", dir: inputPath }; + } + if (stdinHasData(stdin)) { + return { kind: "tar", stream: stdin }; + } + throw new InstallError( + `Missing PATH or tar stream on stdin.\n${usageLines.join("\n")}`, + ); +} + +function cancelWizard( + stdin: Readable, + stdout: Writable, + message = "Install cancelled.", +): null { + outro(message, { input: stdin, output: stdout }); + return null; +} + +function validatePathPrompt(value: string | undefined): string | undefined { + const candidate = value?.trim() ?? ""; + if (!candidate) return "PATH is required."; + try { + const stat = fs.statSync(candidate); + if (!stat.isDirectory()) { + return "PATH must be a directory."; + } + } catch { + return "PATH does not exist."; + } + return undefined; +} + +async function runInstallWizard( + parsed: ParsedArgs, + stdin: Readable, + stdout: Writable, + cwd: string, +): Promise { + intro("skill-install wizard", { input: stdin, output: stdout }); + + const defaultPath = parsed.inputPath ?? cwd; + const pathValue = await text({ + message: "PATH to a skill directory (defaults to current directory)", + placeholder: defaultPath, + defaultValue: defaultPath, + validate: validatePathPrompt, + input: stdin, + output: stdout, + }); + if (isCancel(pathValue)) return cancelWizard(stdin, stdout); + const inputPath = pathValue.trim() || defaultPath; + + const agentInitial = asAgent(parsed.agent) ?? "codex"; + const agentValue = await select({ + message: "Agent target", + options: agentOptions, + initialValue: agentInitial, + input: stdin, + output: stdout, + }); + if (isCancel(agentValue)) return cancelWizard(stdin, stdout); + const agent = assertAgent(agentValue); + + const supportedScopes = supportedScopesByAgent[agent]; + const scopeInitial = (() => { + const initialScope = asScope(parsed.scope); + if (initialScope && supportedScopes.includes(initialScope)) { + return initialScope; + } + return supportedScopes[0]; + })(); + const scopeOptions: Option[] = allScopes.map((scope) => ({ + value: scope, + label: scope, + hint: + scopeDescriptions[scope] + + (supportedScopes.includes(scope) ? "" : " (not supported for this agent)"), + disabled: !supportedScopes.includes(scope), + })); + const scopeValue = await select({ + message: "Scope target", + options: scopeOptions, + initialValue: scopeInitial, + input: stdin, + output: stdout, + }); + if (isCancel(scopeValue)) return cancelWizard(stdin, stdout); + const scope = assertScope(scopeValue); + + const forceValue = await confirm({ + message: "Force overwrite if the destination already exists? (--force)", + initialValue: parsed.force, + input: stdin, + output: stdout, + }); + if (isCancel(forceValue)) return cancelWizard(stdin, stdout); + const force = forceValue; + + const sourceDir = path.resolve(inputPath); + await assertSkillDir(sourceDir); + const meta = await readSkillMetadata(sourceDir); + const skillsRoot = resolveSkillsRoot(agent, scope, cwd); + const destDir = path.join(skillsRoot, meta.name); + + note( + [ + `Source: ${sourceDir}`, + `Skill: ${meta.name}`, + `Agent: ${agent}`, + `Scope: ${scope}`, + `Destination: ${destDir}`, + `Force: ${force ? "yes" : "no"}`, + ].join("\n"), + "Install summary", + { input: stdin, output: stdout }, + ); + + const confirmed = await confirm({ + message: "Proceed with install?", + initialValue: true, + input: stdin, + output: stdout, + }); + if (isCancel(confirmed) || !confirmed) return cancelWizard(stdin, stdout); + + return { inputPath, agent, scope, force }; +} + +async function runInstall( + args: ResolvedInstallArgs, + stdin: Readable, + stdout: Writable, + cwd: string, + useSpinner: boolean, +): Promise<{ skillId: string; installedTo: string }> { + const input = resolveInstallInput(args.inputPath, stdin); + if (!useSpinner) { + return installSkill(input, { + agent: args.agent, + scope: args.scope, + cwd, + force: args.force, + }); + } + + const s = spinner({ input: stdin, output: stdout }); + s.start("Installing skill..."); + try { + const result = await installSkill(input, { + agent: args.agent, + scope: args.scope, + cwd, + force: args.force, + }); + s.stop("Install complete."); + return result; + } catch (err) { + s.error("Install failed."); + throw err; + } +} + export async function runInstallCli( argv: string[], opts: InstallCliOptions = {}, ): Promise { - const stderr = opts.stderr ?? process.stderr; + const stdout = (opts.stdout ?? process.stdout) as Writable; + const stderr = (opts.stderr ?? process.stderr) as Writable; const stdin = opts.stdin ?? process.stdin; const cwd = opts.cwd ?? process.cwd(); try { const parsed = parseArgs(argv.slice(2)); - const agent = assertAgent(parsed.agent); - const scope = assertScope(parsed.scope); - - let input: { kind: "dir"; dir: string } | { kind: "tar"; stream: Readable }; - - if (parsed.inputPath) { - const stat = fs.statSync(parsed.inputPath); - if (!stat.isDirectory()) { - throw new InstallError("PATH must be a directory containing SKILL.md."); - } - input = { kind: "dir", dir: parsed.inputPath }; - } else if (stdinHasData(stdin)) { - input = { kind: "tar", stream: stdin }; - } else { - throw new InstallError( - `Missing PATH or tar stream on stdin.\n${usageLines.join("\n")}`, - ); + let wizardUsed = false; + + const installArgs = !parsed.agent || !parsed.scope + ? (() => { + if (!stdinIsTty(stdin)) { + return validateRequiredFlags(parsed); + } + wizardUsed = true; + return null; + })() + : validateRequiredFlags(parsed); + + const resolved = + installArgs ?? (await runInstallWizard(parsed, stdin, stdout, cwd)); + if (!resolved) { + return 1; } - const result = await installSkill(input, { - agent, - scope, + const result = await runInstall( + resolved, + stdin, + stdout, cwd, - force: parsed.force, - }); + wizardUsed, + ); stderr.write(`Installed ${result.skillId} to ${result.installedTo}\n`); + if (wizardUsed) { + outro("Done.", { input: stdin, output: stdout }); + } return 0; } catch (err) { stderr.write(`${toErrorMessage(err)}\n`); diff --git a/test/integration/skill-install-cli.test.ts b/test/integration/skill-install-cli.test.ts new file mode 100644 index 0000000..a011e2a --- /dev/null +++ b/test/integration/skill-install-cli.test.ts @@ -0,0 +1,70 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { Readable } from "node:stream"; + +import { runInstallCli } from "../../src/install/cli.js"; +import { createCapture } from "../helpers/capture.js"; +import { makeTempDir, writeFile } from "../helpers/tmp.js"; + +function initGit(repoDir: string): void { + execFileSync("git", ["init"], { cwd: repoDir }); +} + +test("runInstallCli requires flags without an interactive tty", async () => { + const stderr = createCapture(); + + const exitCode = await runInstallCli(["node", "skill-install"], { + stdin: Readable.from([]), + stderr: stderr.stream, + }); + + assert.equal(exitCode, 1); + assert.match(stderr.text(), /Missing required flags/); + assert.match(stderr.text(), /skill-install \[PATH]/); +}); + +test("runInstallCli keeps non-interactive install behavior with flags", async (t) => { + const repo = await makeTempDir("skill-install-cli-repo-"); + const skill = await makeTempDir("skill-install-cli-skill-"); + t.after(async () => { + await repo.cleanup(); + await skill.cleanup(); + }); + + initGit(repo.dir); + + await writeFile( + skill.dir, + "SKILL.md", + "---\nname: cli-skill\ndescription: CLI test skill\n---\n", + ); + await writeFile(skill.dir, "templates/readme.txt", "hello\n"); + + const stderr = createCapture(); + const exitCode = await runInstallCli( + [ + "node", + "skill-install", + skill.dir, + "--agent", + "codex", + "--scope", + "repo", + ], + { + stdin: Readable.from([]), + stderr: stderr.stream, + cwd: repo.dir, + }, + ); + + assert.equal(exitCode, 0); + assert.match(stderr.text(), /Installed cli-skill to/); + + const installedSkill = path.join(repo.dir, ".codex/skills/cli-skill/SKILL.md"); + const installedContent = await fs.readFile(installedSkill, "utf8"); + assert.match(installedContent, /name: cli-skill/); +}); From ac705b36a8009a37733bce48ff71af7b50254491 Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 20 Feb 2026 11:57:22 +0100 Subject: [PATCH 02/19] docs(spec): add optional --skill install convenience section --- docs/SKILLFLAG_SPEC.md | 61 ++++++++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/docs/SKILLFLAG_SPEC.md b/docs/SKILLFLAG_SPEC.md index 8ae7763..e8e1854 100644 --- a/docs/SKILLFLAG_SPEC.md +++ b/docs/SKILLFLAG_SPEC.md @@ -79,15 +79,29 @@ A producer CLI **MAY** additionally implement: Skillflag does **not** require any particular command substructure (`tool skills ...`) because the goal is a "`--help`-class" universal convention based on flags. -## 6. Discovery: `--skill list` +## 6. Optional convenience: `--skill install []` -### 6.1 Behavior +A Skillflag-compliant producer CLI **MAY** implement `--skill install []` as a convenience that combines export + install in a single step. + +When invoked without required arguments (`agent`, `scope`) and stdin is a TTY, implementations **SHOULD** offer an interactive selection flow. + +The interactive behavior, prompts, defaults, and UX are implementation-defined. + +When invoked with all required arguments, it **MUST** behave equivalently to: + +```bash +tool --skill export | skill-install --agent --scope +``` + +## 7. Discovery: `--skill list` + +### 7.1 Behavior - `tool --skill list` **MUST** print the list of available Skill IDs to **stdout**. - Output **MUST NOT** include banners, progress text, or other non-data content on stdout. - Diagnostics and errors **MUST** go to **stderr**. -### 6.2 Output format (text) +### 7.2 Output format (text) - Each skill **MUST** appear on a single line. - The line **MUST** begin with the `Skill ID`. @@ -103,12 +117,12 @@ If summaries are included: - `` **MUST NOT** contain newlines or tabs. -### 6.3 Ordering +### 7.3 Ordering - Output ordering **SHOULD** be stable and predictable. - Recommended: sort lexicographically by Skill ID. -### 6.4 Optional JSON mode +### 7.4 Optional JSON mode If `tool --skill list --json` is provided: @@ -142,7 +156,7 @@ Field requirements: Optional fields MUST be omitted if not provided. Producers MUST NOT emit `null` values. Empty string (`""`) is invalid for `version` and `digest`. -## 7. Viewing: `--skill show ` (optional) +## 8. Viewing: `--skill show ` (optional) If implemented: @@ -151,16 +165,16 @@ If implemented: This provides a “manpage-like” experience without OS-specific manpage infrastructure. -## 8. Export: `--skill export ` +## 9. Export: `--skill export ` -### 8.1 Behavior +### 9.1 Behavior - `tool --skill export ` **MUST** write the skill bundle to **stdout** as a tar stream. - The tar stream **MUST** contain exactly one top-level directory named `/`. - The directory **MUST** include `/SKILL.md`. - No additional output is permitted on stdout. -### 8.2 Tar format requirements +### 9.2 Tar format requirements To maximize portability, exporters: @@ -175,7 +189,7 @@ Exporters **MUST** ensure: - No `..` path traversal segments. - All entries are relative under `/`. -### 8.3 Determinism +### 9.3 Determinism For reproducible installs and caching: @@ -187,13 +201,13 @@ For reproducible installs and caching: This ensures identical skill content produces identical tar output and matching digests. -### 8.4 Error handling and exit codes +### 9.4 Error handling and exit codes - Exit `0` on success. - Exit `1` on any error. - Write error details to **stderr**. -## 9. Skill directory layout (bundling convention) +## 10. Skill directory layout (bundling convention) Inside the producer CLI’s distribution artifact, skills **SHOULD** be stored under a dedicated resource path: @@ -207,7 +221,7 @@ The producer CLI **MUST** map these bundled resources to the Skillflag interface This deliberately avoids any assumption about package managers or OS-level install roots. -## 10. Skill ID conventions +## 11. Skill ID conventions Skill IDs **SHOULD** be: @@ -217,7 +231,7 @@ Skill IDs **SHOULD** be: Rationale: IDs appear in shell scripts and filesystem paths. -## 11. Metadata (optional, minimal) +## 12. Metadata (optional, minimal) Skillflag does not require a manifest file, but implementations **MAY** include metadata as YAML frontmatter at the top of `SKILL.md`. @@ -228,7 +242,7 @@ Critically: - **Producer CLIs MUST NOT execute** any bundled scripts as part of export. - Installers **SHOULD NOT execute** bundled scripts by default. -## 12. Security considerations +## 13. Security considerations Skillflag keeps the producer CLI in a “data export” role. That reduces risk, but does not eliminate it. @@ -241,7 +255,7 @@ Recommendations: - Bundles may include binaries or scripts; installers should surface that fact clearly. -## 13. Interoperability with a separate installer +## 14. Interoperability with a separate installer Skillflag is designed to compose cleanly with a dedicated installer/adaptor CLI that knows how to install into specific agent tools and scopes. @@ -260,33 +274,33 @@ The installer is responsible for: None of that logic belongs in the producer CLI. -## 14. Examples +## 15. Examples -### 14.1 List skills +### 15.1 List skills ```bash tool --skill list ``` -### 14.2 View the skill documentation (if supported) +### 15.2 View the skill documentation (if supported) ```bash tool --skill show tmux ``` -### 14.3 Export and inspect without installing +### 15.3 Export and inspect without installing ```bash tool --skill export tmux | tar -tf - ``` -### 14.4 Export and install via an adaptor +### 15.4 Export and install via an adaptor ```bash tool --skill export tmux | skill-install --agent codex --scope user ``` -### 14.5 Export and manually place somewhere (no adaptor needed) +### 15.5 Export and manually place somewhere (no adaptor needed) ```bash mkdir -p .agents/skills/tmux @@ -295,13 +309,14 @@ tool --skill export tmux | tar -x -C .agents/skills (That last example assumes the installer semantics are simply “untar into a skills root”.) -## 15. Conformance checklist +## 16. Conformance checklist A producer CLI is **Skillflag-compliant** if: - [ ] `--skill list` outputs Skill IDs on stdout with no extra stdout noise. - [ ] `--skill list --json` includes `digest` for each skill. - [ ] `--skill export ` emits a tar stream on stdout. +- [ ] (Optional) `--skill install []` follows the convenience behavior described in section 6. - [ ] The tar stream contains exactly one top-level directory `/`. - [ ] `/SKILL.md` exists in the exported stream. - [ ] Tar entries are deterministic (stable order, normalized metadata). From ab95b1ec3e0fa8ba9ccce367881a26dfc40012d0 Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 20 Feb 2026 12:38:51 +0100 Subject: [PATCH 03/19] spec: reword runtime discovery non-goal --- docs/SKILLFLAG_SPEC.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/SKILLFLAG_SPEC.md b/docs/SKILLFLAG_SPEC.md index e8e1854..bb44596 100644 --- a/docs/SKILLFLAG_SPEC.md +++ b/docs/SKILLFLAG_SPEC.md @@ -53,10 +53,12 @@ Skillflag is designed around these constraints: ### 3.2 Non-goals -- Defining how a specific agent tool discovers skills on disk. +- Defining how agent tools internally discover, load, or activate skills at runtime. - Defining a central skill registry, marketplace, signing infrastructure, or dependency installation mechanism. - Defining how installers resolve conflicts, pin versions, or manage lockfiles (those can exist, but are outside the core Skillflag interface). +Note: Skillflag does define where installers place skill files (see skill-install companion spec). Runtime discovery and activation by the agent tool itself is out of scope. + ## 4. Terminology - **Producer CLI**: The tool that _bundles_ skills and implements the Skillflag interface (e.g., `mycli`). From 94c757ab6f58d5f4cbeee07b50e6fe6e24e7769c Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 20 Feb 2026 12:39:05 +0100 Subject: [PATCH 04/19] spec: require --skill install delegation to installer --- docs/SKILLFLAG_SPEC.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/SKILLFLAG_SPEC.md b/docs/SKILLFLAG_SPEC.md index bb44596..c170217 100644 --- a/docs/SKILLFLAG_SPEC.md +++ b/docs/SKILLFLAG_SPEC.md @@ -85,6 +85,10 @@ Skillflag does **not** require any particular command substructure (`tool skills A Skillflag-compliant producer CLI **MAY** implement `--skill install []` as a convenience that combines export + install in a single step. +Producers **MUST NOT** implement agent-specific path resolution. + +The `--skill install` convenience **MUST** delegate installation to a Skillflag-compliant installer (e.g. `skill-install`). Producers are responsible for skill discovery and export; installers are responsible for agent-specific placement. + When invoked without required arguments (`agent`, `scope`) and stdin is a TTY, implementations **SHOULD** offer an interactive selection flow. The interactive behavior, prompts, defaults, and UX are implementation-defined. From a1b40a730d3a2f58237ff6c302467f91f1424db2 Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 20 Feb 2026 12:39:12 +0100 Subject: [PATCH 05/19] spec: clarify companion agent list is non-normative --- docs/SKILLFLAG_SPEC.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/SKILLFLAG_SPEC.md b/docs/SKILLFLAG_SPEC.md index c170217..cb799d5 100644 --- a/docs/SKILLFLAG_SPEC.md +++ b/docs/SKILLFLAG_SPEC.md @@ -465,6 +465,8 @@ If the producer provides a digest (via `--skill list --json`), installers **shou ## 7) Destination mapping (what `--agent` + `--scope` means) +The agent list below is maintained by the reference implementation and is not normative. Implementations MAY support additional agents. The `--dest` escape hatch provides forward compatibility for unlisted agents. + This section is intentionally concrete and only covers widely-used tools with documented/observable conventions. ### 7.1 `--agent pi` (Pi / pi-mono) From 4bbd9038c1b6b78d31e21ed37760303ab4651186 Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 20 Feb 2026 12:40:41 +0100 Subject: [PATCH 06/19] spec/install: defer SKILL.md format to Agent Skills spec --- docs/SKILLFLAG_SPEC.md | 17 +++-------------- src/install/validate.ts | 32 +++++++++++++++++++++----------- 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/docs/SKILLFLAG_SPEC.md b/docs/SKILLFLAG_SPEC.md index cb799d5..7645b1f 100644 --- a/docs/SKILLFLAG_SPEC.md +++ b/docs/SKILLFLAG_SPEC.md @@ -237,16 +237,9 @@ Skill IDs **SHOULD** be: Rationale: IDs appear in shell scripts and filesystem paths. -## 12. Metadata (optional, minimal) +## 12. Metadata -Skillflag does not require a manifest file, but implementations **MAY** include metadata as YAML frontmatter at the top of `SKILL.md`. - -If metadata exists, it **SHOULD** be treated as advisory by installers. - -Critically: - -- **Producer CLIs MUST NOT execute** any bundled scripts as part of export. -- Installers **SHOULD NOT execute** bundled scripts by default. +Skill directories **MUST** conform to the Agent Skills specification (https://agentskills.io/specification). Skillflag does not define additional `SKILL.md` format requirements. ## 13. Security considerations @@ -404,11 +397,7 @@ Rationale: avoid silent installs into the wrong agent/tool. By default, `skill-install` **must** validate: - `SKILL.md` exists at bundle root. -- YAML frontmatter exists and includes: - - `name` (string) - - `description` (string) - -This matches major implementations’ documented expectations. ([Claude Code][3]) +- `name` and `description` are present in `SKILL.md` metadata (as required by the Agent Skills specification). ### 3.2 Skill ID selection (destination folder name) diff --git a/src/install/validate.ts b/src/install/validate.ts index 61615f6..3092cf6 100644 --- a/src/install/validate.ts +++ b/src/install/validate.ts @@ -18,21 +18,21 @@ export async function assertSkillDir(rootDir: string): Promise { } function parseFrontmatter(content: string): Record { - if (!content.startsWith("---\n")) { - throw new InstallError("Missing YAML frontmatter in SKILL.md."); + const frontmatterMatch = content.match( + /^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/, + ); + if (!frontmatterMatch) { + return {}; } - const endIdx = content.indexOf("\n---", 4); - if (endIdx === -1) { - throw new InstallError("Unterminated YAML frontmatter in SKILL.md."); - } - const block = content.slice(4, endIdx + 1); - const lines = block.split("\n").filter((line) => line.trim().length > 0); + const block = frontmatterMatch[1]; + const lines = block.split(/\r?\n/).filter((line) => line.trim().length > 0); const fields: Record = {}; for (const line of lines) { const idx = line.indexOf(":"); if (idx === -1) continue; const key = line.slice(0, idx).trim(); - const value = line.slice(idx + 1).trim(); + const rawValue = line.slice(idx + 1).trim(); + const value = stripYamlQuotes(rawValue); if (key && value) { fields[key] = value; } @@ -40,6 +40,16 @@ function parseFrontmatter(content: string): Record { return fields; } +function stripYamlQuotes(value: string): string { + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1).trim(); + } + return value; +} + export async function readSkillMetadata( rootDir: string, ): Promise { @@ -50,10 +60,10 @@ export async function readSkillMetadata( const description = fields.description; if (!name) { - throw new InstallError("SKILL.md frontmatter is missing name."); + throw new InstallError("SKILL.md metadata is missing name."); } if (!description) { - throw new InstallError("SKILL.md frontmatter is missing description."); + throw new InstallError("SKILL.md metadata is missing description."); } return { name, description }; From 76bcd9878d629055c963bcbe6f451a7ed27c5f64 Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 20 Feb 2026 12:45:25 +0100 Subject: [PATCH 07/19] style: fix prettier formatting --- src/install/cli.ts | 31 ++++++++++------------ test/integration/skill-install-cli.test.ts | 15 ++++------- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/src/install/cli.ts b/src/install/cli.ts index 6b88a45..aed55fc 100644 --- a/src/install/cli.ts +++ b/src/install/cli.ts @@ -285,7 +285,9 @@ async function runInstallWizard( label: scope, hint: scopeDescriptions[scope] + - (supportedScopes.includes(scope) ? "" : " (not supported for this agent)"), + (supportedScopes.includes(scope) + ? "" + : " (not supported for this agent)"), disabled: !supportedScopes.includes(scope), })); const scopeValue = await select({ @@ -384,15 +386,16 @@ export async function runInstallCli( const parsed = parseArgs(argv.slice(2)); let wizardUsed = false; - const installArgs = !parsed.agent || !parsed.scope - ? (() => { - if (!stdinIsTty(stdin)) { - return validateRequiredFlags(parsed); - } - wizardUsed = true; - return null; - })() - : validateRequiredFlags(parsed); + const installArgs = + !parsed.agent || !parsed.scope + ? (() => { + if (!stdinIsTty(stdin)) { + return validateRequiredFlags(parsed); + } + wizardUsed = true; + return null; + })() + : validateRequiredFlags(parsed); const resolved = installArgs ?? (await runInstallWizard(parsed, stdin, stdout, cwd)); @@ -400,13 +403,7 @@ export async function runInstallCli( return 1; } - const result = await runInstall( - resolved, - stdin, - stdout, - cwd, - wizardUsed, - ); + const result = await runInstall(resolved, stdin, stdout, cwd, wizardUsed); stderr.write(`Installed ${result.skillId} to ${result.installedTo}\n`); if (wizardUsed) { diff --git a/test/integration/skill-install-cli.test.ts b/test/integration/skill-install-cli.test.ts index a011e2a..fa43d2d 100644 --- a/test/integration/skill-install-cli.test.ts +++ b/test/integration/skill-install-cli.test.ts @@ -45,15 +45,7 @@ test("runInstallCli keeps non-interactive install behavior with flags", async (t const stderr = createCapture(); const exitCode = await runInstallCli( - [ - "node", - "skill-install", - skill.dir, - "--agent", - "codex", - "--scope", - "repo", - ], + ["node", "skill-install", skill.dir, "--agent", "codex", "--scope", "repo"], { stdin: Readable.from([]), stderr: stderr.stream, @@ -64,7 +56,10 @@ test("runInstallCli keeps non-interactive install behavior with flags", async (t assert.equal(exitCode, 0); assert.match(stderr.text(), /Installed cli-skill to/); - const installedSkill = path.join(repo.dir, ".codex/skills/cli-skill/SKILL.md"); + const installedSkill = path.join( + repo.dir, + ".codex/skills/cli-skill/SKILL.md", + ); const installedContent = await fs.readFile(installedSkill, "utf8"); assert.match(installedContent, /name: cli-skill/); }); From a7471d4cd1f3210da24b97ce307ab5517db877cc Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 20 Feb 2026 13:02:50 +0100 Subject: [PATCH 08/19] feat: add public handleSkillflag install integration --- src/bin/skillflag.ts | 21 +-- src/core/skillflag.ts | 176 +----------------- src/index.ts | 4 +- src/install/cli.ts | 99 +++++++--- src/skillflag.ts | 282 +++++++++++++++++++++++++++++ test/integration/skillflag.test.ts | 102 ++++++++++- 6 files changed, 475 insertions(+), 209 deletions(-) create mode 100644 src/skillflag.ts diff --git a/src/bin/skillflag.ts b/src/bin/skillflag.ts index e61ab07..9a81d57 100644 --- a/src/bin/skillflag.ts +++ b/src/bin/skillflag.ts @@ -1,21 +1,10 @@ #!/usr/bin/env node import process from "node:process"; -import { handleSkillflag } from "../core/skillflag.js"; +import { handleSkillflag } from "../skillflag.js"; import { defaultSkillsRoot } from "../core/paths.js"; -import { runInstallCli } from "../install/cli.js"; -const args = process.argv.slice(2); -if (args[0] === "install") { - const exitCode = await runInstallCli([ - process.argv[0], - process.argv[1], - ...args.slice(1), - ]); - process.exitCode = exitCode; -} else { - const exitCode = await handleSkillflag(process.argv, { - skillsRoot: defaultSkillsRoot(), - }); - process.exitCode = exitCode; -} +const exitCode = await handleSkillflag(process.argv, { + skillsRoot: defaultSkillsRoot(), +}); +process.exitCode = exitCode; diff --git a/src/core/skillflag.ts b/src/core/skillflag.ts index a913354..f1acc7c 100644 --- a/src/core/skillflag.ts +++ b/src/core/skillflag.ts @@ -1,167 +1,9 @@ -import process from "node:process"; -import { SkillflagError, toErrorMessage } from "./errors.js"; -import { exportSkill } from "./export.js"; -import { listSkills, listSkillsJson } from "./list.js"; -import { - defaultSkillsRoot, - resolveSkillDirFromRoots, - resolveSkillsRoot, -} from "./paths.js"; -import { showSkill } from "./show.js"; - -export type SkillflagOptions = { - skillsRoot: URL | string; - stdout?: NodeJS.WritableStream; - stderr?: NodeJS.WritableStream; - includeBundledSkill?: boolean; -}; - -export type SkillflagDispatchOptions = SkillflagOptions & { - exit?: ((code: number) => void) | false; -}; - -type SkillAction = - | { kind: "list"; json: boolean } - | { kind: "export"; id: string } - | { kind: "show"; id: string } - | { kind: "help" }; - -const usageLines = [ - "Usage:", - " --skill list [--json]", - " --skill export ", - " --skill show ", - " --skill help", -]; - -export const SKILLFLAG_HELP_TEXT = [ - "Skillflag help", - "", - "Install skillflag globally to get both binaries on your PATH:", - " npm install -g skillflag", - "", - "Prefer not to install globally? Use npx for one-off runs:", - " npx skillflag --skill list", - " npx skillflag install --agent codex --scope repo ./skills/your-skill", - "", - "List available skills:", - " tool --skill list", - " tool --skill list --json", - "", - "Show a skill's documentation:", - " tool --skill show ", - "", - "Export a skill bundle:", - " tool --skill export ", - "", - "Install a skill bundle into an agent:", - " tool --skill export | skill-install --agent --scope ", - " skillflag install --agent --scope ", - "", - "For full details, read docs/SKILLFLAG_SPEC.md.", -].join("\n"); - -function parseSkillArgs(args: string[]): SkillAction { - const idx = args.indexOf("--skill"); - if (idx === -1) { - throw new SkillflagError(`Missing --skill flag.\n${usageLines.join("\n")}`); - } - - const action = args[idx + 1]; - if (!action || action.startsWith("-")) { - throw new SkillflagError( - `Missing --skill action.\n${usageLines.join("\n")}`, - ); - } - - if (action === "list") { - const json = args.slice(idx + 2).includes("--json"); - return { kind: "list", json }; - } - - if (action === "help") { - return { kind: "help" }; - } - - if (action === "export" || action === "show") { - const id = args[idx + 2]; - if (!id || id.startsWith("-")) { - throw new SkillflagError(`Missing skill id.\n${usageLines.join("\n")}`); - } - return { kind: action, id }; - } - - throw new SkillflagError( - `Unknown --skill action: ${action}.\n${usageLines.join("\n")}`, - ); -} - -export async function handleSkillflag( - argv: string[], - opts: SkillflagOptions, -): Promise { - const stdout = opts.stdout ?? process.stdout; - const stderr = opts.stderr ?? process.stderr; - - try { - const action = parseSkillArgs(argv.slice(2)); - const skillsRoot = resolveSkillsRoot(opts.skillsRoot); - const bundledRoot = resolveSkillsRoot(defaultSkillsRoot()); - const includeBundled = opts.includeBundledSkill !== false; - const rootDirs = - includeBundled && bundledRoot !== skillsRoot - ? [skillsRoot, bundledRoot] - : [skillsRoot]; - - if (action.kind === "list") { - if (action.json) { - const payload = await listSkillsJson(rootDirs); - stdout.write(JSON.stringify(payload)); - } else { - const skills = await listSkills(rootDirs); - if (skills.length > 0) { - const lines = skills.map((skill) => - skill.summary ? `${skill.id}\t${skill.summary}` : skill.id, - ); - stdout.write(`${lines.join("\n")}\n`); - } - } - return 0; - } - - if (action.kind === "export") { - const skillDir = await resolveSkillDirFromRoots(rootDirs, action.id); - await exportSkill(skillDir, action.id, stdout); - return 0; - } - - if (action.kind === "help") { - stdout.write(`${SKILLFLAG_HELP_TEXT}\n`); - return 0; - } - - const skillDir = await resolveSkillDirFromRoots(rootDirs, action.id); - await showSkill(skillDir, action.id, stdout); - return 0; - } catch (err) { - const message = toErrorMessage(err); - stderr.write(`${message}\n`); - return err instanceof SkillflagError ? err.exitCode : 1; - } -} - -export async function maybeHandleSkillflag( - argv: string[], - opts: SkillflagDispatchOptions, -): Promise { - if (!argv.includes("--skill")) { - return false; - } - const { exit, ...skillOpts } = opts; - const exitCode = await handleSkillflag(argv, skillOpts); - if (exit !== false) { - const exitFn = exit ?? process.exit; - exitFn(exitCode); - } - return true; -} +export { + handleSkillflag, + maybeHandleSkillflag, + SKILLFLAG_HELP_TEXT, +} from "../skillflag.js"; +export type { + SkillflagDispatchOptions, + SkillflagOptions, +} from "../skillflag.js"; diff --git a/src/index.ts b/src/index.ts index 69a7b33..82ff9a3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,9 +2,9 @@ export { handleSkillflag, maybeHandleSkillflag, SKILLFLAG_HELP_TEXT, -} from "./core/skillflag.js"; +} from "./skillflag.js"; export type { SkillflagDispatchOptions, SkillflagOptions, -} from "./core/skillflag.js"; +} from "./skillflag.js"; export { findSkillsRoot } from "./core/paths.js"; diff --git a/src/install/cli.ts b/src/install/cli.ts index aed55fc..61b6d4f 100644 --- a/src/install/cli.ts +++ b/src/install/cli.ts @@ -15,7 +15,12 @@ import { import type { Option } from "@clack/prompts"; import { InstallError, toErrorMessage } from "./errors.js"; -import { assertAgent, assertScope, installSkill } from "./install.js"; +import { + assertAgent, + assertScope, + installSkill, + type InstallInput, +} from "./install.js"; import { resolveSkillsRoot, type Agent, type Scope } from "./resolve.js"; import { assertSkillDir, readSkillMetadata } from "./validate.js"; @@ -24,6 +29,8 @@ export type InstallCliOptions = { stdout?: Writable; stderr?: Writable; cwd?: string; + providedInput?: InstallInput; + providedSkillId?: string; }; type ParsedArgs = { @@ -202,7 +209,11 @@ function validateRequiredFlags(parsed: ParsedArgs): ResolvedInstallArgs { function resolveInstallInput( inputPath: string | undefined, stdin: Readable, -): { kind: "dir"; dir: string } | { kind: "tar"; stream: Readable } { + providedInput?: InstallInput, +): InstallInput { + if (inputPath && providedInput) { + throw new InstallError("PATH cannot be used when install input is preset."); + } if (inputPath) { const stat = fs.statSync(inputPath); if (!stat.isDirectory()) { @@ -210,6 +221,9 @@ function resolveInstallInput( } return { kind: "dir", dir: inputPath }; } + if (providedInput) { + return providedInput; + } if (stdinHasData(stdin)) { return { kind: "tar", stream: stdin }; } @@ -246,20 +260,25 @@ async function runInstallWizard( stdin: Readable, stdout: Writable, cwd: string, + providedInput?: InstallInput, + providedSkillId?: string, ): Promise { intro("skill-install wizard", { input: stdin, output: stdout }); - const defaultPath = parsed.inputPath ?? cwd; - const pathValue = await text({ - message: "PATH to a skill directory (defaults to current directory)", - placeholder: defaultPath, - defaultValue: defaultPath, - validate: validatePathPrompt, - input: stdin, - output: stdout, - }); - if (isCancel(pathValue)) return cancelWizard(stdin, stdout); - const inputPath = pathValue.trim() || defaultPath; + let inputPath = parsed.inputPath; + if (!providedInput) { + const defaultPath = parsed.inputPath ?? cwd; + const pathValue = await text({ + message: "PATH to a skill directory (defaults to current directory)", + placeholder: defaultPath, + defaultValue: defaultPath, + validate: validatePathPrompt, + input: stdin, + output: stdout, + }); + if (isCancel(pathValue)) return cancelWizard(stdin, stdout); + inputPath = pathValue.trim() || defaultPath; + } const agentInitial = asAgent(parsed.agent) ?? "codex"; const agentValue = await select({ @@ -309,16 +328,31 @@ async function runInstallWizard( if (isCancel(forceValue)) return cancelWizard(stdin, stdout); const force = forceValue; - const sourceDir = path.resolve(inputPath); - await assertSkillDir(sourceDir); - const meta = await readSkillMetadata(sourceDir); const skillsRoot = resolveSkillsRoot(agent, scope, cwd); - const destDir = path.join(skillsRoot, meta.name); + let source = ""; + let skillId = providedSkillId; + + if (providedInput?.kind === "tar") { + source = "tar stream"; + if (!skillId) { + skillId = ""; + } + } else { + const sourceDir = path.resolve( + providedInput?.kind === "dir" ? providedInput.dir : (inputPath ?? cwd), + ); + await assertSkillDir(sourceDir); + const meta = await readSkillMetadata(sourceDir); + source = sourceDir; + skillId = meta.name; + } + + const destDir = path.join(skillsRoot, skillId); note( [ - `Source: ${sourceDir}`, - `Skill: ${meta.name}`, + `Source: ${source}`, + `Skill: ${skillId}`, `Agent: ${agent}`, `Scope: ${scope}`, `Destination: ${destDir}`, @@ -345,8 +379,9 @@ async function runInstall( stdout: Writable, cwd: string, useSpinner: boolean, + providedInput?: InstallInput, ): Promise<{ skillId: string; installedTo: string }> { - const input = resolveInstallInput(args.inputPath, stdin); + const input = resolveInstallInput(args.inputPath, stdin, providedInput); if (!useSpinner) { return installSkill(input, { agent: args.agent, @@ -384,6 +419,11 @@ export async function runInstallCli( try { const parsed = parseArgs(argv.slice(2)); + if (opts.providedInput && parsed.inputPath) { + throw new InstallError( + "PATH cannot be used when install input is preset.", + ); + } let wizardUsed = false; const installArgs = @@ -398,12 +438,27 @@ export async function runInstallCli( : validateRequiredFlags(parsed); const resolved = - installArgs ?? (await runInstallWizard(parsed, stdin, stdout, cwd)); + installArgs ?? + (await runInstallWizard( + parsed, + stdin, + stdout, + cwd, + opts.providedInput, + opts.providedSkillId, + )); if (!resolved) { return 1; } - const result = await runInstall(resolved, stdin, stdout, cwd, wizardUsed); + const result = await runInstall( + resolved, + stdin, + stdout, + cwd, + wizardUsed, + opts.providedInput, + ); stderr.write(`Installed ${result.skillId} to ${result.installedTo}\n`); if (wizardUsed) { diff --git a/src/skillflag.ts b/src/skillflag.ts new file mode 100644 index 0000000..6fc6304 --- /dev/null +++ b/src/skillflag.ts @@ -0,0 +1,282 @@ +import process from "node:process"; +import type { Readable, Writable } from "node:stream"; +import { isCancel, select } from "@clack/prompts"; +import type { Option } from "@clack/prompts"; + +import { SkillflagError, toErrorMessage } from "./core/errors.js"; +import { exportSkill } from "./core/export.js"; +import { listSkills, listSkillsJson } from "./core/list.js"; +import { + defaultSkillsRoot, + resolveSkillDirFromRoots, + resolveSkillsRoot, +} from "./core/paths.js"; +import { showSkill } from "./core/show.js"; +import { collectSkillEntries, createTarStream } from "./core/tar.js"; +import { runInstallCli } from "./install/cli.js"; + +export type SkillflagOptions = { + skillsRoot: URL | string; + stdin?: NodeJS.ReadableStream; + stdout?: NodeJS.WritableStream; + stderr?: NodeJS.WritableStream; + cwd?: string; + includeBundledSkill?: boolean; +}; + +export type SkillflagDispatchOptions = SkillflagOptions & { + exit?: ((code: number) => void) | false; +}; + +type SkillAction = + | { kind: "install"; id?: string; installArgs: string[] } + | { kind: "list"; json: boolean } + | { kind: "export"; id: string } + | { kind: "show"; id: string } + | { kind: "help" }; + +const usageLines = [ + "Usage:", + " --skill install [] [--agent ] [--scope ] [--force]", + " --skill list [--json]", + " --skill export ", + " --skill show ", + " --skill help", +]; + +export const SKILLFLAG_HELP_TEXT = [ + "Skillflag help", + "", + "Install skillflag globally to get both binaries on your PATH:", + " npm install -g skillflag", + "", + "Prefer not to install globally? Use npx for one-off runs:", + " npx skillflag list", + " npx skillflag install --agent codex --scope repo < ./skill.tar", + "", + "List available skills:", + " tool --skill list", + " tool --skill list --json", + "", + "Show a skill's documentation:", + " tool --skill show ", + "", + "Export a skill bundle:", + " tool --skill export ", + "", + "Install a skill bundle into an agent:", + " tool --skill install []", + " tool --skill export | skill-install --agent --scope ", + "", + "For full details, read docs/SKILLFLAG_SPEC.md.", +].join("\n"); + +function isSkillActionKind(value: string | undefined): boolean { + return ( + value === "install" || + value === "list" || + value === "export" || + value === "show" || + value === "help" + ); +} + +function extractSkillArgs(argv: string[]): string[] { + const idx = argv.indexOf("--skill"); + if (idx !== -1) { + return argv.slice(idx + 1); + } + if (isSkillActionKind(argv[0])) { + return argv; + } + if (isSkillActionKind(argv[1])) { + return argv.slice(1); + } + if (isSkillActionKind(argv[2])) { + return argv.slice(2); + } + return argv; +} + +function parseSkillArgs(argv: string[]): SkillAction { + const args = extractSkillArgs(argv); + const action = args[0]; + if (!action || action.startsWith("-")) { + throw new SkillflagError( + `Missing --skill action.\n${usageLines.join("\n")}`, + ); + } + + if (action === "install") { + const rest = args.slice(1); + if (rest[0] && !rest[0].startsWith("-")) { + return { kind: "install", id: rest[0], installArgs: rest.slice(1) }; + } + return { kind: "install", installArgs: rest }; + } + + if (action === "list") { + const json = args.slice(1).includes("--json"); + return { kind: "list", json }; + } + + if (action === "help") { + return { kind: "help" }; + } + + if (action === "export" || action === "show") { + const id = args[1]; + if (!id || id.startsWith("-")) { + throw new SkillflagError(`Missing skill id.\n${usageLines.join("\n")}`); + } + return { kind: action, id }; + } + + throw new SkillflagError( + `Unknown --skill action: ${action}.\n${usageLines.join("\n")}`, + ); +} + +function stdinIsTty(stream: NodeJS.ReadableStream): boolean { + return (stream as { isTTY?: boolean }).isTTY === true; +} + +async function resolveInstallSkillId( + action: { id?: string }, + rootDirs: string[], + stdin: NodeJS.ReadableStream, + stdout: NodeJS.WritableStream, +): Promise { + if (action.id) { + return action.id; + } + + const skills = await listSkills(rootDirs); + if (skills.length === 0) { + throw new SkillflagError("No skills are available to install."); + } + + if (skills.length === 1) { + return skills[0].id; + } + + if (!stdinIsTty(stdin)) { + throw new SkillflagError( + "Multiple skills are available; pass an id with --skill install .", + ); + } + + const options: Option[] = skills.map((skill) => ({ + value: skill.id, + label: skill.id, + hint: skill.summary, + })); + const selected = await select({ + message: "Select a skill to install", + options, + input: stdin as Readable, + output: stdout as Writable, + }); + if (isCancel(selected)) { + throw new SkillflagError("Install cancelled."); + } + return selected; +} + +async function runInstallAction( + action: { id?: string; installArgs: string[] }, + rootDirs: string[], + opts: SkillflagOptions, + stdin: NodeJS.ReadableStream, + stdout: NodeJS.WritableStream, + stderr: NodeJS.WritableStream, +): Promise { + const skillId = await resolveInstallSkillId(action, rootDirs, stdin, stdout); + const skillDir = await resolveSkillDirFromRoots(rootDirs, skillId); + const { entries } = await collectSkillEntries(skillDir, skillId); + const stream = createTarStream(entries); + + return runInstallCli(["node", "skill-install", ...action.installArgs], { + stdin: stdin as Readable, + stdout: stdout as Writable, + stderr: stderr as Writable, + cwd: opts.cwd, + providedInput: { kind: "tar", stream }, + providedSkillId: skillId, + }); +} + +export async function handleSkillflag( + argv: string[], + opts: SkillflagOptions, +): Promise { + const stdin = opts.stdin ?? process.stdin; + const stdout = opts.stdout ?? process.stdout; + const stderr = opts.stderr ?? process.stderr; + + try { + const action = parseSkillArgs(argv); + const skillsRoot = resolveSkillsRoot(opts.skillsRoot); + const bundledRoot = resolveSkillsRoot(defaultSkillsRoot()); + const includeBundled = opts.includeBundledSkill !== false; + const rootDirs = + includeBundled && bundledRoot !== skillsRoot + ? [skillsRoot, bundledRoot] + : [skillsRoot]; + + if (action.kind === "install") { + return runInstallAction(action, rootDirs, opts, stdin, stdout, stderr); + } + + if (action.kind === "list") { + if (action.json) { + const payload = await listSkillsJson(rootDirs); + stdout.write(JSON.stringify(payload)); + } else { + const skills = await listSkills(rootDirs); + if (skills.length > 0) { + const lines = skills.map((skill) => + skill.summary ? `${skill.id}\t${skill.summary}` : skill.id, + ); + stdout.write(`${lines.join("\n")}\n`); + } + } + return 0; + } + + if (action.kind === "export") { + const skillDir = await resolveSkillDirFromRoots(rootDirs, action.id); + await exportSkill(skillDir, action.id, stdout); + return 0; + } + + if (action.kind === "help") { + stdout.write(`${SKILLFLAG_HELP_TEXT}\n`); + return 0; + } + + const skillDir = await resolveSkillDirFromRoots(rootDirs, action.id); + await showSkill(skillDir, action.id, stdout); + return 0; + } catch (err) { + const message = toErrorMessage(err); + stderr.write(`${message}\n`); + return err instanceof SkillflagError ? err.exitCode : 1; + } +} + +export async function maybeHandleSkillflag( + argv: string[], + opts: SkillflagDispatchOptions, +): Promise { + if (!argv.includes("--skill")) { + return false; + } + const { exit, ...skillOpts } = opts; + const exitCode = await handleSkillflag(argv, skillOpts); + if (exit !== false) { + const exitFn = exit ?? process.exit; + exitFn(exitCode); + } + return true; +} diff --git a/test/integration/skillflag.test.ts b/test/integration/skillflag.test.ts index f6864f4..170f084 100644 --- a/test/integration/skillflag.test.ts +++ b/test/integration/skillflag.test.ts @@ -3,6 +3,8 @@ import assert from "node:assert/strict"; import path from "node:path"; import fs from "node:fs/promises"; import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { Readable } from "node:stream"; import * as tar from "tar-stream"; import { fileURLToPath } from "node:url"; @@ -10,13 +12,18 @@ import { handleSkillflag, maybeHandleSkillflag, SKILLFLAG_HELP_TEXT, -} from "../../src/core/skillflag.js"; -import { findSkillsRoot } from "../../src/core/paths.js"; + findSkillsRoot, +} from "../../src/index.js"; import { createCapture } from "../helpers/capture.js"; +import { makeTempDir, writeFile } from "../helpers/tmp.js"; const fixturesRoot = path.resolve(process.cwd(), "test/fixtures/skills"); const bundledSkillsRoot = path.resolve(process.cwd(), "skills"); +function initGit(repoDir: string): void { + execFileSync("git", ["init"], { cwd: repoDir }); +} + function sha256(buffer: Buffer): string { const hash = createHash("sha256"); hash.update(buffer); @@ -312,3 +319,94 @@ test("--skill help shows bundled skillflag docs", async () => { assert.equal(stderr.text(), ""); assert.equal(stdout.text(), `${SKILLFLAG_HELP_TEXT}\n`); }); + +test("--skill install delegates to installer with exported tar input", async (t) => { + const repo = await makeTempDir("skillflag-install-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", + "--scope", + "repo", + ], + { + skillsRoot: fixturesRoot, + stdin: Readable.from([]), + stdout: stdout.stream, + stderr: stderr.stream, + cwd: repo.dir, + includeBundledSkill: false, + }, + ); + + assert.equal(exitCode, 0); + assert.match(stderr.text(), /Installed alpha to/); + + const installedPath = path.join(repo.dir, ".codex/skills/alpha/SKILL.md"); + const installedContent = await fs.readFile(installedPath, "utf8"); + assert.match(installedContent, /name: alpha/); +}); + +test("--skill install without id picks the only available skill", async (t) => { + const repo = await makeTempDir("skillflag-install-only-repo-"); + const skillsRoot = await makeTempDir("skillflag-install-only-skills-"); + t.after(async () => { + await repo.cleanup(); + await skillsRoot.cleanup(); + }); + + initGit(repo.dir); + await writeFile( + skillsRoot.dir, + "only-skill/SKILL.md", + "---\nname: only-skill\ndescription: Only skill\n---\n", + ); + + const stdout = createCapture(); + const stderr = createCapture(); + + const exitCode = await handleSkillflag( + [ + "node", + "cli", + "--skill", + "install", + "--agent", + "codex", + "--scope", + "repo", + ], + { + skillsRoot: skillsRoot.dir, + stdin: Readable.from([]), + stdout: stdout.stream, + stderr: stderr.stream, + cwd: repo.dir, + includeBundledSkill: false, + }, + ); + + assert.equal(exitCode, 0); + assert.match(stderr.text(), /Installed only-skill to/); + + const installedPath = path.join( + repo.dir, + ".codex/skills/only-skill/SKILL.md", + ); + const installedContent = await fs.readFile(installedPath, "utf8"); + assert.match(installedContent, /name: only-skill/); +}); From eff093234e1aa135aa539248fb4f2273d20fc579 Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 20 Feb 2026 13:35:18 +0100 Subject: [PATCH 09/19] feat: support multi-skill and multi-scope installs --- src/install/cli.ts | 490 ++++++++++++++------- src/skillflag.ts | 109 +++-- test/integration/skill-install-cli.test.ts | 109 ++++- test/integration/skillflag.test.ts | 79 ++++ 4 files changed, 611 insertions(+), 176 deletions(-) diff --git a/src/install/cli.ts b/src/install/cli.ts index 61b6d4f..1313f38 100644 --- a/src/install/cli.ts +++ b/src/install/cli.ts @@ -6,6 +6,7 @@ import { confirm, intro, isCancel, + multiselect, note, outro, select, @@ -30,27 +31,57 @@ export type InstallCliOptions = { stderr?: Writable; cwd?: string; providedInput?: InstallInput; + providedInputs?: InstallInput[]; providedSkillId?: string; + providedSkillIds?: string[]; }; type ParsedArgs = { - inputPath?: string; + inputPaths: string[]; agent?: string; - scope?: string; + scopes: string[]; force: boolean; }; type ResolvedInstallArgs = { - inputPath?: string; + inputPaths: string[]; agent: Agent; - scope: Scope; + scopes: Scope[]; force: boolean; }; +type ProvidedInstallInputs = { + inputs: InstallInput[]; + skillIds: string[]; +}; + +type PreparedInstallSource = { + source: string; + skillIdHint: string; + makeInput: () => InstallInput; +}; + +type InstallPlanItem = { + source: PreparedInstallSource; + scope: Scope; + destination: string; +}; + +type InstallExecutionResult = { + skillId: string; + installedTo: string; + scope: Scope; +}; + +type WizardResult = { + args: ResolvedInstallArgs; + sources: PreparedInstallSource[]; +}; + const usageLines = [ "Usage:", - " skill-install [PATH] --agent --scope ", - " skill-install --agent --scope < tar", + " skill-install [PATH ...] --agent --scope [,...] [--scope [,...]] [--force]", + " skill-install --agent --scope [,...] [--scope [,...]] [--force] < tar", ]; const agentOptions: Option[] = [ @@ -127,24 +158,50 @@ const supportedScopesByAgent: Record = { cursor: ["repo"], }; -const allScopes: Scope[] = ["repo", "user", "admin", "cwd", "parent"]; +function uniqueValues(values: T[]): T[] { + const out: T[] = []; + for (const value of values) { + if (!out.includes(value)) { + out.push(value); + } + } + return out; +} + +function parseScopeValues(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) { + throw new InstallError("Missing value for --scope."); + } + return scopes; +} function parseArgs(args: string[]): ParsedArgs { const rest = [...args]; - let inputPath: string | undefined; + const inputPaths: string[] = []; let agent: string | undefined; - let scope: string | undefined; + const scopes: string[] = []; let force = false; for (let i = 0; i < rest.length; i += 1) { const arg = rest[i]; if (arg === "--agent") { - agent = rest[i + 1]; + const value = rest[i + 1]; + if (!value || value.startsWith("-")) { + throw new InstallError("Missing value for --agent."); + } + agent = value; i += 1; continue; } if (arg === "--scope") { - scope = rest[i + 1]; + scopes.push(...parseScopeValues(rest[i + 1])); i += 1; continue; } @@ -155,14 +212,10 @@ function parseArgs(args: string[]): ParsedArgs { if (arg.startsWith("-")) { throw new InstallError(`Unknown option: ${arg}`); } - if (!inputPath) { - inputPath = arg; - continue; - } - throw new InstallError(`Unexpected argument: ${arg}`); + inputPaths.push(arg); } - return { inputPath, agent, scope, force }; + return { inputPaths, agent, scopes: uniqueValues(scopes), force }; } function stdinHasData(stream: Readable): boolean { @@ -194,44 +247,85 @@ function asScope(value: string | undefined): Scope | undefined { } } +function asScopes(values: string[]): Scope[] { + return values + .map((value) => asScope(value)) + .filter((value): value is Scope => value !== undefined); +} + function validateRequiredFlags(parsed: ParsedArgs): ResolvedInstallArgs { - if (!parsed.agent || !parsed.scope) { + if (!parsed.agent || parsed.scopes.length === 0) { throw new InstallError(`Missing required flags.\n${usageLines.join("\n")}`); } return { - inputPath: parsed.inputPath, + inputPaths: parsed.inputPaths, agent: assertAgent(parsed.agent), - scope: assertScope(parsed.scope), + scopes: uniqueValues(parsed.scopes.map((scope) => assertScope(scope))), force: parsed.force, }; } -function resolveInstallInput( - inputPath: string | undefined, - stdin: Readable, - providedInput?: InstallInput, -): InstallInput { - if (inputPath && providedInput) { - throw new InstallError("PATH cannot be used when install input is preset."); +function normalizeProvidedInputs( + opts: InstallCliOptions, +): ProvidedInstallInputs { + if (opts.providedInput && opts.providedInputs?.length) { + throw new InstallError( + "providedInput and providedInputs cannot be used together.", + ); } - if (inputPath) { - const stat = fs.statSync(inputPath); - if (!stat.isDirectory()) { - throw new InstallError("PATH must be a directory containing SKILL.md."); - } - return { kind: "dir", dir: inputPath }; + if (opts.providedSkillId && opts.providedSkillIds?.length) { + throw new InstallError( + "providedSkillId and providedSkillIds cannot be used together.", + ); } - if (providedInput) { - return providedInput; + + const inputs = + opts.providedInputs ?? (opts.providedInput ? [opts.providedInput] : []); + const skillIds = + opts.providedSkillIds ?? + (opts.providedSkillId ? [opts.providedSkillId] : []); + + if (skillIds.length > 0 && inputs.length === 0) { + throw new InstallError("Preset skill ids require preset install inputs."); } - if (stdinHasData(stdin)) { - return { kind: "tar", stream: stdin }; + if (skillIds.length > 0 && skillIds.length !== inputs.length) { + throw new InstallError( + "Preset skill id count must match preset install input count.", + ); } - throw new InstallError( - `Missing PATH or tar stream on stdin.\n${usageLines.join("\n")}`, + + return { inputs, skillIds }; +} + +function parsePathList(value: string | undefined): string[] { + const raw = value?.trim() ?? ""; + if (!raw) { + return []; + } + return uniqueValues( + raw + .split(",") + .map((item) => item.trim()) + .filter((item) => item.length > 0), ); } +function validatePathPrompt(value: string | undefined): string | undefined { + const candidates = parsePathList(value); + if (candidates.length === 0) return "PATH is required."; + for (const candidate of candidates) { + try { + const stat = fs.statSync(candidate); + if (!stat.isDirectory()) { + return `PATH must be a directory: ${candidate}`; + } + } catch { + return `PATH does not exist: ${candidate}`; + } + } + return undefined; +} + function cancelWizard( stdin: Readable, stdout: Writable, @@ -241,18 +335,104 @@ function cancelWizard( return null; } -function validatePathPrompt(value: string | undefined): string | undefined { - const candidate = value?.trim() ?? ""; - if (!candidate) return "PATH is required."; - try { - const stat = fs.statSync(candidate); +async function streamToBuffer(stream: Readable): Promise { + const chunks: Buffer[] = []; + return new Promise((resolve, reject) => { + stream.on("data", (chunk) => { + chunks.push(Buffer.from(chunk)); + }); + stream.on("end", () => { + resolve(Buffer.concat(chunks)); + }); + stream.on("error", reject); + }); +} + +async function prepareInstallSource( + input: InstallInput, + skillId?: string, +): Promise { + if (input.kind === "dir") { + const sourceDir = path.resolve(input.dir); + let stat; + try { + stat = fs.statSync(sourceDir); + } catch { + throw new InstallError(`PATH does not exist: ${sourceDir}`); + } if (!stat.isDirectory()) { - return "PATH must be a directory."; + throw new InstallError("PATH must be a directory containing SKILL.md."); } - } catch { - return "PATH does not exist."; + + await assertSkillDir(sourceDir); + const meta = await readSkillMetadata(sourceDir); + return { + source: sourceDir, + skillIdHint: meta.name, + makeInput: () => ({ kind: "dir", dir: sourceDir }), + }; } - return undefined; + + const tarBuffer = await streamToBuffer(input.stream); + return { + source: "tar stream", + skillIdHint: skillId ?? "", + makeInput: () => ({ kind: "tar", stream: Readable.from(tarBuffer) }), + }; +} + +async function resolveInstallSources( + inputPaths: string[], + stdin: Readable, + provided: ProvidedInstallInputs, +): Promise { + if (inputPaths.length > 0 && provided.inputs.length > 0) { + throw new InstallError("PATH cannot be used when install input is preset."); + } + + if (inputPaths.length > 0) { + return Promise.all( + inputPaths.map((inputPath) => + prepareInstallSource({ kind: "dir", dir: inputPath }), + ), + ); + } + + if (provided.inputs.length > 0) { + return Promise.all( + provided.inputs.map((input, index) => + prepareInstallSource(input, provided.skillIds[index]), + ), + ); + } + + if (stdinHasData(stdin)) { + return [await prepareInstallSource({ kind: "tar", stream: stdin })]; + } + + throw new InstallError( + `Missing PATH or tar stream on stdin.\n${usageLines.join("\n")}`, + ); +} + +function buildInstallPlan( + sources: PreparedInstallSource[], + agent: Agent, + scopes: Scope[], + cwd: string, +): InstallPlanItem[] { + const plan: InstallPlanItem[] = []; + for (const source of sources) { + for (const scope of scopes) { + const skillsRoot = resolveSkillsRoot(agent, scope, cwd); + plan.push({ + source, + scope, + destination: path.join(skillsRoot, source.skillIdHint), + }); + } + } + return plan; } async function runInstallWizard( @@ -260,16 +440,16 @@ async function runInstallWizard( stdin: Readable, stdout: Writable, cwd: string, - providedInput?: InstallInput, - providedSkillId?: string, -): Promise { + provided: ProvidedInstallInputs, +): Promise { intro("skill-install wizard", { input: stdin, output: stdout }); - let inputPath = parsed.inputPath; - if (!providedInput) { - const defaultPath = parsed.inputPath ?? cwd; + let inputPaths = parsed.inputPaths; + if (provided.inputs.length === 0 && inputPaths.length === 0) { + const defaultPath = cwd; const pathValue = await text({ - message: "PATH to a skill directory (defaults to current directory)", + message: + "PATH to skill directory (comma-separated for multiple, defaults to current directory)", placeholder: defaultPath, defaultValue: defaultPath, validate: validatePathPrompt, @@ -277,7 +457,7 @@ async function runInstallWizard( output: stdout, }); if (isCancel(pathValue)) return cancelWizard(stdin, stdout); - inputPath = pathValue.trim() || defaultPath; + inputPaths = parsePathList(pathValue.trim() || defaultPath); } const agentInitial = asAgent(parsed.agent) ?? "codex"; @@ -292,32 +472,31 @@ async function runInstallWizard( const agent = assertAgent(agentValue); const supportedScopes = supportedScopesByAgent[agent]; - const scopeInitial = (() => { - const initialScope = asScope(parsed.scope); - if (initialScope && supportedScopes.includes(initialScope)) { - return initialScope; - } - return supportedScopes[0]; - })(); - const scopeOptions: Option[] = allScopes.map((scope) => ({ - value: scope, - label: scope, - hint: - scopeDescriptions[scope] + - (supportedScopes.includes(scope) - ? "" - : " (not supported for this agent)"), - disabled: !supportedScopes.includes(scope), - })); - const scopeValue = await select({ - message: "Scope target", - options: scopeOptions, - initialValue: scopeInitial, - input: stdin, - output: stdout, - }); - if (isCancel(scopeValue)) return cancelWizard(stdin, stdout); - const scope = assertScope(scopeValue); + const parsedScopes = asScopes(parsed.scopes).filter((scope) => + supportedScopes.includes(scope), + ); + + let scopes: Scope[]; + if (supportedScopes.length === 1) { + scopes = [supportedScopes[0]]; + } else { + const scopeOptions: Option[] = supportedScopes.map((scope) => ({ + value: scope, + label: scope, + hint: scopeDescriptions[scope], + })); + const scopeValues = await multiselect({ + message: "Scope targets", + options: scopeOptions, + initialValues: + parsedScopes.length > 0 ? parsedScopes : [supportedScopes[0]], + required: true, + input: stdin, + output: stdout, + }); + if (isCancel(scopeValues)) return cancelWizard(stdin, stdout); + scopes = uniqueValues(scopeValues.map((scope) => assertScope(scope))); + } const forceValue = await confirm({ message: "Force overwrite if the destination already exists? (--force)", @@ -328,34 +507,25 @@ async function runInstallWizard( if (isCancel(forceValue)) return cancelWizard(stdin, stdout); const force = forceValue; - const skillsRoot = resolveSkillsRoot(agent, scope, cwd); - let source = ""; - let skillId = providedSkillId; + const sources = await resolveInstallSources(inputPaths, stdin, provided); + const plan = buildInstallPlan(sources, agent, scopes, cwd); - if (providedInput?.kind === "tar") { - source = "tar stream"; - if (!skillId) { - skillId = ""; - } - } else { - const sourceDir = path.resolve( - providedInput?.kind === "dir" ? providedInput.dir : (inputPath ?? cwd), - ); - await assertSkillDir(sourceDir); - const meta = await readSkillMetadata(sourceDir); - source = sourceDir; - skillId = meta.name; - } - - const destDir = path.join(skillsRoot, skillId); + const sourceLines = sources.map( + (source) => `${source.skillIdHint} <= ${source.source}`, + ); + const installLines = plan.map( + (item) => + `${item.source.skillIdHint} @ ${item.scope} -> ${item.destination}`, + ); note( [ - `Source: ${source}`, - `Skill: ${skillId}`, + `Sources (${sources.length}):`, + ...sourceLines, `Agent: ${agent}`, - `Scope: ${scope}`, - `Destination: ${destDir}`, + `Scopes: ${scopes.join(", ")}`, + `Planned installs (${plan.length}):`, + ...installLines, `Force: ${force ? "yes" : "no"}`, ].join("\n"), "Install summary", @@ -370,36 +540,44 @@ async function runInstallWizard( }); if (isCancel(confirmed) || !confirmed) return cancelWizard(stdin, stdout); - return { inputPath, agent, scope, force }; + return { + args: { inputPaths, agent, scopes, force }, + sources, + }; } async function runInstall( args: ResolvedInstallArgs, + sources: PreparedInstallSource[], stdin: Readable, stdout: Writable, cwd: string, useSpinner: boolean, - providedInput?: InstallInput, -): Promise<{ skillId: string; installedTo: string }> { - const input = resolveInstallInput(args.inputPath, stdin, providedInput); +): Promise { + const plan = buildInstallPlan(sources, args.agent, args.scopes, cwd); + + const execute = async (): Promise => { + const results: InstallExecutionResult[] = []; + for (const item of plan) { + const result = await installSkill(item.source.makeInput(), { + agent: args.agent, + scope: item.scope, + cwd, + force: args.force, + }); + results.push({ ...result, scope: item.scope }); + } + return results; + }; + if (!useSpinner) { - return installSkill(input, { - agent: args.agent, - scope: args.scope, - cwd, - force: args.force, - }); + return execute(); } const s = spinner({ input: stdin, output: stdout }); - s.start("Installing skill..."); + s.start(`Installing ${plan.length} target${plan.length === 1 ? "" : "s"}...`); try { - const result = await installSkill(input, { - agent: args.agent, - scope: args.scope, - cwd, - force: args.force, - }); + const result = await execute(); s.stop("Install complete."); return result; } catch (err) { @@ -419,48 +597,62 @@ export async function runInstallCli( try { const parsed = parseArgs(argv.slice(2)); - if (opts.providedInput && parsed.inputPath) { + const provided = normalizeProvidedInputs(opts); + if (provided.inputs.length > 0 && parsed.inputPaths.length > 0) { throw new InstallError( "PATH cannot be used when install input is preset.", ); } + let wizardUsed = false; - const installArgs = - !parsed.agent || !parsed.scope - ? (() => { - if (!stdinIsTty(stdin)) { - return validateRequiredFlags(parsed); - } - wizardUsed = true; - return null; - })() - : validateRequiredFlags(parsed); - - const resolved = - installArgs ?? - (await runInstallWizard( - parsed, + let resolvedArgs: ResolvedInstallArgs; + let sources: PreparedInstallSource[]; + + if (!parsed.agent || parsed.scopes.length === 0) { + if (!stdinIsTty(stdin)) { + resolvedArgs = validateRequiredFlags(parsed); + sources = await resolveInstallSources( + resolvedArgs.inputPaths, + stdin, + provided, + ); + } else { + wizardUsed = true; + const wizardResult = await runInstallWizard( + parsed, + stdin, + stdout, + cwd, + provided, + ); + if (!wizardResult) { + return 1; + } + resolvedArgs = wizardResult.args; + sources = wizardResult.sources; + } + } else { + resolvedArgs = validateRequiredFlags(parsed); + sources = await resolveInstallSources( + resolvedArgs.inputPaths, stdin, - stdout, - cwd, - opts.providedInput, - opts.providedSkillId, - )); - if (!resolved) { - return 1; + provided, + ); } - const result = await runInstall( - resolved, + const results = await runInstall( + resolvedArgs, + sources, stdin, stdout, cwd, wizardUsed, - opts.providedInput, ); - stderr.write(`Installed ${result.skillId} to ${result.installedTo}\n`); + for (const result of results) { + stderr.write(`Installed ${result.skillId} to ${result.installedTo}\n`); + } if (wizardUsed) { outro("Done.", { input: stdin, output: stdout }); } diff --git a/src/skillflag.ts b/src/skillflag.ts index 6fc6304..d2c4ee1 100644 --- a/src/skillflag.ts +++ b/src/skillflag.ts @@ -1,6 +1,6 @@ import process from "node:process"; import type { Readable, Writable } from "node:stream"; -import { isCancel, select } from "@clack/prompts"; +import { isCancel, multiselect } from "@clack/prompts"; import type { Option } from "@clack/prompts"; import { SkillflagError, toErrorMessage } from "./core/errors.js"; @@ -29,7 +29,7 @@ export type SkillflagDispatchOptions = SkillflagOptions & { }; type SkillAction = - | { kind: "install"; id?: string; installArgs: string[] } + | { kind: "install"; ids?: string[]; installArgs: string[] } | { kind: "list"; json: boolean } | { kind: "export"; id: string } | { kind: "show"; id: string } @@ -37,7 +37,7 @@ type SkillAction = const usageLines = [ "Usage:", - " --skill install [] [--agent ] [--scope ] [--force]", + " --skill install [ ...] [--agent ] [--scope ] [--force]", " --skill list [--json]", " --skill export ", " --skill show ", @@ -65,7 +65,7 @@ export const SKILLFLAG_HELP_TEXT = [ " tool --skill export ", "", "Install a skill bundle into an agent:", - " tool --skill install []", + " tool --skill install [ ...]", " tool --skill export | skill-install --agent --scope ", "", "For full details, read docs/SKILLFLAG_SPEC.md.", @@ -81,6 +81,16 @@ function isSkillActionKind(value: string | undefined): boolean { ); } +function uniqueValues(values: T[]): T[] { + const out: T[] = []; + for (const value of values) { + if (!out.includes(value)) { + out.push(value); + } + } + return out; +} + function extractSkillArgs(argv: string[]): string[] { const idx = argv.indexOf("--skill"); if (idx !== -1) { @@ -98,6 +108,33 @@ function extractSkillArgs(argv: string[]): string[] { return argv; } +function parseInstallIds(values: string[]): { + ids?: string[]; + installArgs: string[]; +} { + const ids: string[] = []; + let index = 0; + + while (index < values.length) { + const value = values[index]; + if (value.startsWith("-")) { + break; + } + + const parsed = value + .split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0); + ids.push(...parsed); + index += 1; + } + + return { + ids: ids.length > 0 ? uniqueValues(ids) : undefined, + installArgs: values.slice(index), + }; +} + function parseSkillArgs(argv: string[]): SkillAction { const args = extractSkillArgs(argv); const action = args[0]; @@ -109,10 +146,12 @@ function parseSkillArgs(argv: string[]): SkillAction { if (action === "install") { const rest = args.slice(1); - if (rest[0] && !rest[0].startsWith("-")) { - return { kind: "install", id: rest[0], installArgs: rest.slice(1) }; - } - return { kind: "install", installArgs: rest }; + const parsed = parseInstallIds(rest); + return { + kind: "install", + ids: parsed.ids, + installArgs: parsed.installArgs, + }; } if (action === "list") { @@ -141,14 +180,14 @@ function stdinIsTty(stream: NodeJS.ReadableStream): boolean { return (stream as { isTTY?: boolean }).isTTY === true; } -async function resolveInstallSkillId( - action: { id?: string }, +async function resolveInstallSkillIds( + action: { ids?: string[] }, rootDirs: string[], stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream, -): Promise { - if (action.id) { - return action.id; +): Promise { + if (action.ids && action.ids.length > 0) { + return action.ids; } const skills = await listSkills(rootDirs); @@ -157,12 +196,12 @@ async function resolveInstallSkillId( } if (skills.length === 1) { - return skills[0].id; + return [skills[0].id]; } if (!stdinIsTty(stdin)) { throw new SkillflagError( - "Multiple skills are available; pass an id with --skill install .", + "Multiple skills are available; pass one or more ids with --skill install [...].", ); } @@ -171,38 +210,49 @@ async function resolveInstallSkillId( label: skill.id, hint: skill.summary, })); - const selected = await select({ - message: "Select a skill to install", + const selected = await multiselect({ + message: "Select skills to install", options, + required: true, input: stdin as Readable, output: stdout as Writable, }); if (isCancel(selected)) { throw new SkillflagError("Install cancelled."); } - return selected; + return uniqueValues(selected); } async function runInstallAction( - action: { id?: string; installArgs: string[] }, + action: { ids?: string[]; installArgs: string[] }, rootDirs: string[], opts: SkillflagOptions, stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream, stderr: NodeJS.WritableStream, ): Promise { - const skillId = await resolveInstallSkillId(action, rootDirs, stdin, stdout); - const skillDir = await resolveSkillDirFromRoots(rootDirs, skillId); - const { entries } = await collectSkillEntries(skillDir, skillId); - const stream = createTarStream(entries); + const skillIds = await resolveInstallSkillIds( + action, + rootDirs, + stdin, + stdout, + ); + + const inputs = await Promise.all( + skillIds.map(async (skillId) => { + const skillDir = await resolveSkillDirFromRoots(rootDirs, skillId); + const { entries } = await collectSkillEntries(skillDir, skillId); + return { kind: "tar" as const, stream: createTarStream(entries) }; + }), + ); return runInstallCli(["node", "skill-install", ...action.installArgs], { stdin: stdin as Readable, stdout: stdout as Writable, stderr: stderr as Writable, cwd: opts.cwd, - providedInput: { kind: "tar", stream }, - providedSkillId: skillId, + providedInputs: inputs, + providedSkillIds: skillIds, }); } @@ -225,7 +275,14 @@ export async function handleSkillflag( : [skillsRoot]; if (action.kind === "install") { - return runInstallAction(action, rootDirs, opts, stdin, stdout, stderr); + return await runInstallAction( + action, + rootDirs, + opts, + stdin, + stdout, + stderr, + ); } if (action.kind === "list") { diff --git a/test/integration/skill-install-cli.test.ts b/test/integration/skill-install-cli.test.ts index fa43d2d..d040998 100644 --- a/test/integration/skill-install-cli.test.ts +++ b/test/integration/skill-install-cli.test.ts @@ -23,7 +23,7 @@ test("runInstallCli requires flags without an interactive tty", async () => { assert.equal(exitCode, 1); assert.match(stderr.text(), /Missing required flags/); - assert.match(stderr.text(), /skill-install \[PATH]/); + assert.match(stderr.text(), /skill-install \[PATH/); }); test("runInstallCli keeps non-interactive install behavior with flags", async (t) => { @@ -63,3 +63,110 @@ test("runInstallCli keeps non-interactive install behavior with flags", async (t const installedContent = await fs.readFile(installedSkill, "utf8"); assert.match(installedContent, /name: cli-skill/); }); + +test("runInstallCli installs multiple skills across repeated scopes", 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(); + }); + + initGit(repo.dir); + + await writeFile( + skillOne.dir, + "SKILL.md", + "---\nname: cli-skill-one\ndescription: CLI multi test skill one\n---\n", + ); + await writeFile( + skillTwo.dir, + "SKILL.md", + "---\nname: cli-skill-two\ndescription: CLI multi test skill two\n---\n", + ); + + const stderr = createCapture(); + const exitCode = await runInstallCli( + [ + "node", + "skill-install", + skillOne.dir, + skillTwo.dir, + "--agent", + "codex", + "--scope", + "repo", + "--scope", + "user", + ], + { + stdin: Readable.from([]), + stderr: stderr.stream, + cwd: repo.dir, + }, + ); + + assert.equal(exitCode, 0); + assert.match(stderr.text(), /Installed cli-skill-one to/); + assert.match(stderr.text(), /Installed cli-skill-two to/); + + 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; + t.after(async () => { + process.env.CODEX_HOME = previousCodexHome; + await repo.cleanup(); + await codexHome.cleanup(); + await skill.cleanup(); + }); + + initGit(repo.dir); + + await writeFile( + skill.dir, + "SKILL.md", + "---\nname: cli-skill-comma\ndescription: CLI comma scope skill\n---\n", + ); + + const stderr = createCapture(); + const exitCode = await runInstallCli( + [ + "node", + "skill-install", + skill.dir, + "--agent", + "codex", + "--scope", + "repo,user", + ], + { + stdin: Readable.from([]), + stderr: stderr.stream, + cwd: repo.dir, + }, + ); + + 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"), + ); + await fs.access(path.join(codexHome.dir, "skills/cli-skill-comma/SKILL.md")); +}); diff --git a/test/integration/skillflag.test.ts b/test/integration/skillflag.test.ts index 170f084..6f6b903 100644 --- a/test/integration/skillflag.test.ts +++ b/test/integration/skillflag.test.ts @@ -361,6 +361,85 @@ 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) => { + 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); + + const stdout = createCapture(); + const stderr = createCapture(); + + const exitCode = await handleSkillflag( + [ + "node", + "cli", + "--skill", + "install", + "alpha", + "beta", + "--agent", + "codex", + "--scope", + "repo", + "--scope", + "user", + ], + { + skillsRoot: fixturesRoot, + stdin: Readable.from([]), + stdout: stdout.stream, + stderr: stderr.stream, + cwd: repo.dir, + includeBundledSkill: false, + }, + ); + + assert.equal(exitCode, 0); + assert.match(stderr.text(), /Installed alpha to/); + assert.match(stderr.text(), /Installed beta to/); + + 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 without id in non-interactive mode requires explicit ids when multiple skills exist", async () => { + const stdout = createCapture(); + const stderr = createCapture(); + + const exitCode = await handleSkillflag( + [ + "node", + "cli", + "--skill", + "install", + "--agent", + "codex", + "--scope", + "repo", + ], + { + skillsRoot: fixturesRoot, + stdin: Readable.from([]), + stdout: stdout.stream, + stderr: stderr.stream, + includeBundledSkill: false, + }, + ); + + assert.equal(exitCode, 1); + assert.match(stderr.text(), /pass one or more ids with --skill install/); +}); + test("--skill install without id picks the only available skill", async (t) => { const repo = await makeTempDir("skillflag-install-only-repo-"); const skillsRoot = await makeTempDir("skillflag-install-only-skills-"); From 04f2b0fbda3cdfc13070c1ff13b3c9a265e208e6 Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 20 Feb 2026 13:52:30 +0100 Subject: [PATCH 10/19] feat: support multi-agent install matrix --- src/install/cli.ts | 185 +++++++++++++++------ src/skillflag.ts | 6 +- test/integration/skill-install-cli.test.ts | 110 ++++++++++++ test/integration/skillflag.test.ts | 60 +++++++ 4 files changed, 311 insertions(+), 50 deletions(-) diff --git a/src/install/cli.ts b/src/install/cli.ts index 1313f38..67464a3 100644 --- a/src/install/cli.ts +++ b/src/install/cli.ts @@ -9,7 +9,6 @@ import { multiselect, note, outro, - select, spinner, text, } from "@clack/prompts"; @@ -38,14 +37,14 @@ export type InstallCliOptions = { type ParsedArgs = { inputPaths: string[]; - agent?: string; + agents: string[]; scopes: string[]; force: boolean; }; type ResolvedInstallArgs = { inputPaths: string[]; - agent: Agent; + agents: Agent[]; scopes: Scope[]; force: boolean; }; @@ -63,12 +62,14 @@ type PreparedInstallSource = { type InstallPlanItem = { source: PreparedInstallSource; + agent: Agent; scope: Scope; destination: string; }; type InstallExecutionResult = { skillId: string; + agent: Agent; installedTo: string; scope: Scope; }; @@ -80,8 +81,8 @@ type WizardResult = { const usageLines = [ "Usage:", - " skill-install [PATH ...] --agent --scope [,...] [--scope [,...]] [--force]", - " skill-install --agent --scope [,...] [--scope [,...]] [--force] < tar", + " skill-install [PATH ...] --agent [,...] [--agent [,...]] --scope [,...] [--scope [,...]] [--force]", + " skill-install --agent [,...] [--agent [,...]] --scope [,...] [--scope [,...]] [--force] < tar", ]; const agentOptions: Option[] = [ @@ -182,21 +183,31 @@ function parseScopeValues(value: string | undefined): string[] { return scopes; } +function parseAgentValues(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) { + throw new InstallError("Missing value for --agent."); + } + return agents; +} + function parseArgs(args: string[]): ParsedArgs { const rest = [...args]; const inputPaths: string[] = []; - let agent: string | undefined; + const agents: string[] = []; const scopes: string[] = []; let force = false; for (let i = 0; i < rest.length; i += 1) { const arg = rest[i]; if (arg === "--agent") { - const value = rest[i + 1]; - if (!value || value.startsWith("-")) { - throw new InstallError("Missing value for --agent."); - } - agent = value; + agents.push(...parseAgentValues(rest[i + 1])); i += 1; continue; } @@ -215,7 +226,12 @@ function parseArgs(args: string[]): ParsedArgs { inputPaths.push(arg); } - return { inputPaths, agent, scopes: uniqueValues(scopes), force }; + return { + inputPaths, + agents: uniqueValues(agents), + scopes: uniqueValues(scopes), + force, + }; } function stdinHasData(stream: Readable): boolean { @@ -238,6 +254,14 @@ function asAgent(value: string | undefined): Agent | undefined { } } +function asAgents(values: string[]): Agent[] { + return uniqueValues( + values + .map((value) => asAgent(value)) + .filter((value): value is Agent => value !== undefined), + ); +} + function asScope(value: string | undefined): Scope | undefined { if (!value) return undefined; try { @@ -253,14 +277,35 @@ function asScopes(values: string[]): Scope[] { .filter((value): value is Scope => value !== undefined); } +function getSharedScopes(agents: Agent[]): Scope[] { + if (agents.length === 0) return []; + return supportedScopesByAgent[agents[0]].filter((scope) => + agents.every((agent) => supportedScopesByAgent[agent].includes(scope)), + ); +} + +function assertSupportedAgentScopes(agents: Agent[], scopes: Scope[]): void { + for (const agent of agents) { + const supportedScopes = supportedScopesByAgent[agent]; + for (const scope of scopes) { + if (!supportedScopes.includes(scope)) { + throw new InstallError(`Unsupported agent/scope: ${agent} ${scope}`); + } + } + } +} + function validateRequiredFlags(parsed: ParsedArgs): ResolvedInstallArgs { - if (!parsed.agent || parsed.scopes.length === 0) { + if (parsed.agents.length === 0 || parsed.scopes.length === 0) { throw new InstallError(`Missing required flags.\n${usageLines.join("\n")}`); } + const agents = uniqueValues(parsed.agents.map((agent) => assertAgent(agent))); + const scopes = uniqueValues(parsed.scopes.map((scope) => assertScope(scope))); + assertSupportedAgentScopes(agents, scopes); return { inputPaths: parsed.inputPaths, - agent: assertAgent(parsed.agent), - scopes: uniqueValues(parsed.scopes.map((scope) => assertScope(scope))), + agents, + scopes, force: parsed.force, }; } @@ -417,24 +462,44 @@ async function resolveInstallSources( function buildInstallPlan( sources: PreparedInstallSource[], - agent: Agent, + agents: Agent[], scopes: Scope[], cwd: string, ): InstallPlanItem[] { const plan: InstallPlanItem[] = []; for (const source of sources) { - for (const scope of scopes) { - const skillsRoot = resolveSkillsRoot(agent, scope, cwd); - plan.push({ - source, - scope, - destination: path.join(skillsRoot, source.skillIdHint), - }); + for (const agent of agents) { + for (const scope of scopes) { + const skillsRoot = resolveSkillsRoot(agent, scope, cwd); + plan.push({ + source, + agent, + scope, + destination: path.join(skillsRoot, source.skillIdHint), + }); + } } } return plan; } +function dedupeInstallPlan(plan: InstallPlanItem[]): InstallPlanItem[] { + const seenByDestination = new Map>(); + const deduped: InstallPlanItem[] = []; + + for (const item of plan) { + const seenSources = seenByDestination.get(item.destination) ?? new Set(); + if (seenSources.has(item.source)) { + continue; + } + seenSources.add(item.source); + seenByDestination.set(item.destination, seenSources); + deduped.push(item); + } + + return deduped; +} + async function runInstallWizard( parsed: ParsedArgs, stdin: Readable, @@ -460,18 +525,34 @@ async function runInstallWizard( inputPaths = parsePathList(pathValue.trim() || defaultPath); } - const agentInitial = asAgent(parsed.agent) ?? "codex"; - const agentValue = await select({ - message: "Agent target", - options: agentOptions, - initialValue: agentInitial, - input: stdin, - output: stdout, - }); - if (isCancel(agentValue)) return cancelWizard(stdin, stdout); - const agent = assertAgent(agentValue); + const parsedAgents = asAgents(parsed.agents); + let agents: Agent[]; + if ( + parsedAgents.length > 0 && + parsedAgents.length === uniqueValues(parsed.agents).length + ) { + agents = parsedAgents; + } else if (agentOptions.length === 1) { + agents = [assertAgent(agentOptions[0].value)]; + } else { + const agentValues = await multiselect({ + message: "Agent targets", + options: agentOptions, + initialValues: parsedAgents.length > 0 ? parsedAgents : ["codex"], + required: true, + input: stdin, + output: stdout, + }); + if (isCancel(agentValues)) return cancelWizard(stdin, stdout); + agents = uniqueValues(agentValues.map((value) => assertAgent(value))); + } - const supportedScopes = supportedScopesByAgent[agent]; + const supportedScopes = getSharedScopes(agents); + if (supportedScopes.length === 0) { + throw new InstallError( + `No shared scopes for selected agents: ${agents.join(", ")}`, + ); + } const parsedScopes = asScopes(parsed.scopes).filter((scope) => supportedScopes.includes(scope), ); @@ -507,24 +588,29 @@ async function runInstallWizard( if (isCancel(forceValue)) return cancelWizard(stdin, stdout); const force = forceValue; + assertSupportedAgentScopes(agents, scopes); + const sources = await resolveInstallSources(inputPaths, stdin, provided); - const plan = buildInstallPlan(sources, agent, scopes, cwd); + const plan = buildInstallPlan(sources, agents, scopes, cwd); + const executionPlan = dedupeInstallPlan(plan); const sourceLines = sources.map( (source) => `${source.skillIdHint} <= ${source.source}`, ); const installLines = plan.map( (item) => - `${item.source.skillIdHint} @ ${item.scope} -> ${item.destination}`, + `${item.source.skillIdHint} @ ${item.agent}/${item.scope} -> ${item.destination}`, ); note( [ `Sources (${sources.length}):`, ...sourceLines, - `Agent: ${agent}`, - `Scopes: ${scopes.join(", ")}`, - `Planned installs (${plan.length}):`, + `Agents (${agents.length}): ${agents.join(", ")}`, + `Scopes (${scopes.length}): ${scopes.join(", ")}`, + `Matrix: ${sources.length} skill(s) × ${agents.length} agent(s) × ${scopes.length} scope(s) = ${plan.length} combination(s)`, + `Execution targets: ${executionPlan.length}`, + `Planned combinations (${plan.length}):`, ...installLines, `Force: ${force ? "yes" : "no"}`, ].join("\n"), @@ -541,7 +627,7 @@ async function runInstallWizard( if (isCancel(confirmed) || !confirmed) return cancelWizard(stdin, stdout); return { - args: { inputPaths, agent, scopes, force }, + args: { inputPaths, agents, scopes, force }, sources, }; } @@ -554,18 +640,19 @@ async function runInstall( cwd: string, useSpinner: boolean, ): Promise { - const plan = buildInstallPlan(sources, args.agent, args.scopes, cwd); + const plan = buildInstallPlan(sources, args.agents, args.scopes, cwd); + const executionPlan = dedupeInstallPlan(plan); const execute = async (): Promise => { const results: InstallExecutionResult[] = []; - for (const item of plan) { + for (const item of executionPlan) { const result = await installSkill(item.source.makeInput(), { - agent: args.agent, + agent: item.agent, scope: item.scope, cwd, force: args.force, }); - results.push({ ...result, scope: item.scope }); + results.push({ ...result, agent: item.agent, scope: item.scope }); } return results; }; @@ -575,7 +662,9 @@ async function runInstall( } const s = spinner({ input: stdin, output: stdout }); - s.start(`Installing ${plan.length} target${plan.length === 1 ? "" : "s"}...`); + s.start( + `Installing ${executionPlan.length} target${executionPlan.length === 1 ? "" : "s"}...`, + ); try { const result = await execute(); s.stop("Install complete."); @@ -609,7 +698,7 @@ export async function runInstallCli( let resolvedArgs: ResolvedInstallArgs; let sources: PreparedInstallSource[]; - if (!parsed.agent || parsed.scopes.length === 0) { + if (parsed.agents.length === 0 || parsed.scopes.length === 0) { if (!stdinIsTty(stdin)) { resolvedArgs = validateRequiredFlags(parsed); sources = await resolveInstallSources( @@ -651,7 +740,9 @@ export async function runInstallCli( ); for (const result of results) { - stderr.write(`Installed ${result.skillId} to ${result.installedTo}\n`); + stderr.write( + `Installed ${result.skillId} to ${result.installedTo} (${result.agent}/${result.scope})\n`, + ); } if (wizardUsed) { outro("Done.", { input: stdin, output: stdout }); diff --git a/src/skillflag.ts b/src/skillflag.ts index d2c4ee1..f902c8f 100644 --- a/src/skillflag.ts +++ b/src/skillflag.ts @@ -37,7 +37,7 @@ type SkillAction = const usageLines = [ "Usage:", - " --skill install [ ...] [--agent ] [--scope ] [--force]", + " --skill install [ ...] [--agent [,...]] [--agent [,...]] [--scope [,...]] [--scope [,...]] [--force]", " --skill list [--json]", " --skill export ", " --skill show ", @@ -64,9 +64,9 @@ export const SKILLFLAG_HELP_TEXT = [ "Export a skill bundle:", " tool --skill export ", "", - "Install a skill bundle into an agent:", + "Install a skill bundle into one or more agents:", " tool --skill install [ ...]", - " tool --skill export | skill-install --agent --scope ", + " tool --skill export | skill-install --agent [--agent ] --scope [--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 d040998..dde7f80 100644 --- a/test/integration/skill-install-cli.test.ts +++ b/test/integration/skill-install-cli.test.ts @@ -170,3 +170,113 @@ test("runInstallCli accepts comma-separated scopes", async (t) => { ); 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; + t.after(async () => { + process.env.CODEX_HOME = previousCodexHome; + process.env.HOME = previousHome; + await repo.cleanup(); + await codexHome.cleanup(); + await home.cleanup(); + await skill.cleanup(); + }); + + initGit(repo.dir); + + await writeFile( + skill.dir, + "SKILL.md", + "---\nname: cli-skill-agents\ndescription: CLI multi-agent test skill\n---\n", + ); + + const stderr = createCapture(); + const exitCode = await runInstallCli( + [ + "node", + "skill-install", + skill.dir, + "--agent", + "codex", + "--agent", + "claude", + "--scope", + "repo", + "--scope", + "user", + ], + { + stdin: Readable.from([]), + stderr: stderr.stream, + cwd: repo.dir, + }, + ); + + assert.equal(exitCode, 0); + const installLines = stderr + .text() + .split("\n") + .filter((line) => line.startsWith("Installed ")); + assert.equal(installLines.length, 4); + + await fs.access( + path.join(repo.dir, ".codex/skills/cli-skill-agents/SKILL.md"), + ); + await fs.access( + path.join(repo.dir, ".claude/skills/cli-skill-agents/SKILL.md"), + ); + 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"), + ); +}); + +test("runInstallCli handles multi-agent installs when agents share a 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 exitCode = await runInstallCli( + [ + "node", + "skill-install", + skill.dir, + "--agent", + "portable", + "--agent", + "amp", + "--scope", + "repo", + ], + { + stdin: Readable.from([]), + stderr: stderr.stream, + cwd: repo.dir, + }, + ); + + assert.equal(exitCode, 0); + assert.match(stderr.text(), /Installed cli-skill-shared to/); + await fs.access( + path.join(repo.dir, ".agents/skills/cli-skill-shared/SKILL.md"), + ); +}); diff --git a/test/integration/skillflag.test.ts b/test/integration/skillflag.test.ts index 6f6b903..a153296 100644 --- a/test/integration/skillflag.test.ts +++ b/test/integration/skillflag.test.ts @@ -412,6 +412,66 @@ test("--skill install supports multiple ids and multiple scopes", async (t) => { 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; + t.after(async () => { + process.env.CODEX_HOME = previousCodexHome; + process.env.HOME = previousHome; + await repo.cleanup(); + await codexHome.cleanup(); + await home.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", + "--scope", + "user", + ], + { + skillsRoot: fixturesRoot, + stdin: Readable.from([]), + stdout: stdout.stream, + stderr: stderr.stream, + cwd: repo.dir, + includeBundledSkill: false, + }, + ); + + assert.equal(exitCode, 0); + const installLines = stderr + .text() + .split("\n") + .filter((line) => line.startsWith("Installed ")); + assert.equal(installLines.length, 4); + + 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 without id in non-interactive mode requires explicit ids when multiple skills exist", async () => { const stdout = createCapture(); const stderr = createCapture(); From e742484578f2723c86d8a04bb05d1e87215c9a50 Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 20 Feb 2026 13:55:17 +0100 Subject: [PATCH 11/19] fix: no pre-selected values in wizard multiselects --- src/install/cli.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/install/cli.ts b/src/install/cli.ts index 67464a3..4f14942 100644 --- a/src/install/cli.ts +++ b/src/install/cli.ts @@ -538,7 +538,7 @@ async function runInstallWizard( const agentValues = await multiselect({ message: "Agent targets", options: agentOptions, - initialValues: parsedAgents.length > 0 ? parsedAgents : ["codex"], + initialValues: parsedAgents.length > 0 ? parsedAgents : [], required: true, input: stdin, output: stdout, @@ -569,8 +569,7 @@ async function runInstallWizard( const scopeValues = await multiselect({ message: "Scope targets", options: scopeOptions, - initialValues: - parsedScopes.length > 0 ? parsedScopes : [supportedScopes[0]], + initialValues: parsedScopes.length > 0 ? parsedScopes : [], required: true, input: stdin, output: stdout, From 0657d4ecfe2aec63848a8ee1bd1dba5f8fe28a28 Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 20 Feb 2026 14:20:37 +0100 Subject: [PATCH 12/19] fix: route skillflag install to installer cli --- src/bin/skillflag.ts | 15 ++- src/skillflag.ts | 75 +++++++-------- src/utils/collections.ts | 9 ++ test/integration/skillflag.test.ts | 143 ++++++++++++++++++++++++++++- 4 files changed, 202 insertions(+), 40 deletions(-) create mode 100644 src/utils/collections.ts diff --git a/src/bin/skillflag.ts b/src/bin/skillflag.ts index 9a81d57..8a36214 100644 --- a/src/bin/skillflag.ts +++ b/src/bin/skillflag.ts @@ -3,8 +3,17 @@ import process from "node:process"; import { handleSkillflag } from "../skillflag.js"; import { defaultSkillsRoot } from "../core/paths.js"; +import { runInstallCli } from "../install/cli.js"; -const exitCode = await handleSkillflag(process.argv, { - skillsRoot: defaultSkillsRoot(), -}); +const cliArgs = process.argv.slice(2); +const exitCode = + cliArgs[0] === "install" + ? await runInstallCli([ + process.argv[0] ?? "node", + "skill-install", + ...cliArgs.slice(1), + ]) + : await handleSkillflag(process.argv, { + skillsRoot: defaultSkillsRoot(), + }); process.exitCode = exitCode; diff --git a/src/skillflag.ts b/src/skillflag.ts index f902c8f..ee4e637 100644 --- a/src/skillflag.ts +++ b/src/skillflag.ts @@ -14,6 +14,7 @@ import { import { showSkill } from "./core/show.js"; import { collectSkillEntries, createTarStream } from "./core/tar.js"; import { runInstallCli } from "./install/cli.js"; +import { uniqueValues } from "./utils/collections.js"; export type SkillflagOptions = { skillsRoot: URL | string; @@ -22,6 +23,7 @@ export type SkillflagOptions = { stderr?: NodeJS.WritableStream; cwd?: string; includeBundledSkill?: boolean; + promptApi?: SkillflagPromptApi; }; export type SkillflagDispatchOptions = SkillflagOptions & { @@ -35,6 +37,17 @@ type SkillAction = | { kind: "show"; id: string } | { kind: "help" }; +export type SkillflagPromptApi = { + multiselect: (opts: { + message: string; + options: Option[]; + required?: boolean; + input?: Readable; + output?: Writable; + }) => Promise; + isCancel: (value: unknown) => value is symbol; +}; + const usageLines = [ "Usage:", " --skill install [ ...] [--agent [,...]] [--agent [,...]] [--scope [,...]] [--scope [,...]] [--force]", @@ -71,41 +84,18 @@ export const SKILLFLAG_HELP_TEXT = [ "For full details, read docs/SKILLFLAG_SPEC.md.", ].join("\n"); -function isSkillActionKind(value: string | undefined): boolean { - return ( - value === "install" || - value === "list" || - value === "export" || - value === "show" || - value === "help" - ); -} - -function uniqueValues(values: T[]): T[] { - const out: T[] = []; - for (const value of values) { - if (!out.includes(value)) { - out.push(value); - } - } - return out; -} +const defaultPromptApi: SkillflagPromptApi = { + multiselect, + isCancel, +}; -function extractSkillArgs(argv: string[]): string[] { - const idx = argv.indexOf("--skill"); - if (idx !== -1) { - return argv.slice(idx + 1); - } - if (isSkillActionKind(argv[0])) { - return argv; - } - if (isSkillActionKind(argv[1])) { - return argv.slice(1); - } - if (isSkillActionKind(argv[2])) { - return argv.slice(2); +function resolveSkillActionArgs(argv: string[]): string[] { + const cliArgs = argv.length > 2 ? argv.slice(2) : [...argv]; + const skillIndex = cliArgs.indexOf("--skill"); + if (skillIndex >= 0) { + return cliArgs.slice(skillIndex + 1); } - return argv; + return cliArgs; } function parseInstallIds(values: string[]): { @@ -135,8 +125,18 @@ function parseInstallIds(values: string[]): { }; } +/** + * Parse skillflag action arguments from argv. + * + * Expected forms: + * - Node-style argv: `[execPath, scriptPath, ...cliArgs]` + * - Already-trimmed args: `["--skill", "list"]` or `["list"]` + * + * For producer CLIs, parsing starts right after `--skill`. + * For the standalone `skillflag` binary, parsing starts at `cliArgs[0]`. + */ function parseSkillArgs(argv: string[]): SkillAction { - const args = extractSkillArgs(argv); + const args = resolveSkillActionArgs(argv); const action = args[0]; if (!action || action.startsWith("-")) { throw new SkillflagError( @@ -185,6 +185,7 @@ async function resolveInstallSkillIds( rootDirs: string[], stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream, + promptApi: SkillflagPromptApi, ): Promise { if (action.ids && action.ids.length > 0) { return action.ids; @@ -210,14 +211,14 @@ async function resolveInstallSkillIds( label: skill.id, hint: skill.summary, })); - const selected = await multiselect({ + const selected = await promptApi.multiselect({ message: "Select skills to install", options, required: true, input: stdin as Readable, output: stdout as Writable, }); - if (isCancel(selected)) { + if (promptApi.isCancel(selected)) { throw new SkillflagError("Install cancelled."); } return uniqueValues(selected); @@ -231,11 +232,13 @@ async function runInstallAction( stdout: NodeJS.WritableStream, stderr: NodeJS.WritableStream, ): Promise { + const promptApi = opts.promptApi ?? defaultPromptApi; const skillIds = await resolveInstallSkillIds( action, rootDirs, stdin, stdout, + promptApi, ); const inputs = await Promise.all( diff --git a/src/utils/collections.ts b/src/utils/collections.ts new file mode 100644 index 0000000..0e91013 --- /dev/null +++ b/src/utils/collections.ts @@ -0,0 +1,9 @@ +export function uniqueValues(values: readonly T[]): T[] { + const out: T[] = []; + for (const value of values) { + if (!out.includes(value)) { + out.push(value); + } + } + return out; +} diff --git a/test/integration/skillflag.test.ts b/test/integration/skillflag.test.ts index a153296..a79cb5f 100644 --- a/test/integration/skillflag.test.ts +++ b/test/integration/skillflag.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import path from "node:path"; import fs from "node:fs/promises"; import { createHash } from "node:crypto"; -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import { Readable } from "node:stream"; import * as tar from "tar-stream"; import { fileURLToPath } from "node:url"; @@ -14,11 +14,17 @@ import { SKILLFLAG_HELP_TEXT, findSkillsRoot, } from "../../src/index.js"; +import { collectSkillEntries, createTarStream } from "../../src/core/tar.js"; import { createCapture } from "../helpers/capture.js"; import { makeTempDir, writeFile } from "../helpers/tmp.js"; const fixturesRoot = path.resolve(process.cwd(), "test/fixtures/skills"); const bundledSkillsRoot = path.resolve(process.cwd(), "skills"); +const skillflagBinaryPath = path.resolve( + process.cwd(), + "dist-test/src/bin/skillflag.js", +); +const PROMPT_CANCEL = Symbol("prompt-cancel"); function initGit(repoDir: string): void { execFileSync("git", ["init"], { cwd: repoDir }); @@ -30,6 +36,23 @@ function sha256(buffer: Buffer): string { return `sha256:${hash.digest("hex")}`; } +async function bufferFromStream( + stream: NodeJS.ReadableStream, +): Promise { + const chunks: Buffer[] = []; + return new Promise((resolve, reject) => { + stream.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + stream.on("end", () => resolve(Buffer.concat(chunks))); + stream.on("error", reject); + }); +} + +function createTtyStdin(): Readable { + const stdin = Readable.from([]); + (stdin as Readable & { isTTY?: boolean }).isTTY = true; + return stdin; +} + async function collectTarEntries(buffer: Buffer): Promise { const extract = tar.extract(); const entries: string[] = []; @@ -549,3 +572,121 @@ test("--skill install without id picks the only available skill", async (t) => { const installedContent = await fs.readFile(installedPath, "utf8"); assert.match(installedContent, /name: only-skill/); }); + +test("--skill install supports interactive multi-select when multiple skills are available", async (t) => { + const repo = await makeTempDir("skillflag-install-select-repo-"); + t.after(async () => { + await repo.cleanup(); + }); + + initGit(repo.dir); + + const stdout = createCapture(); + const stderr = createCapture(); + + const exitCode = await handleSkillflag( + [ + "node", + "cli", + "--skill", + "install", + "--agent", + "codex", + "--scope", + "repo", + ], + { + skillsRoot: fixturesRoot, + stdin: createTtyStdin(), + stdout: stdout.stream, + stderr: stderr.stream, + cwd: repo.dir, + includeBundledSkill: false, + promptApi: { + multiselect: async () => ["alpha", "beta"] as Value[], + isCancel: (value): value is symbol => value === PROMPT_CANCEL, + }, + }, + ); + + assert.equal(exitCode, 0); + assert.match(stderr.text(), /Installed alpha to/); + assert.match(stderr.text(), /Installed beta to/); + await fs.access(path.join(repo.dir, ".codex/skills/alpha/SKILL.md")); + await fs.access(path.join(repo.dir, ".codex/skills/beta/SKILL.md")); +}); + +test("skillflag binary routes install PATH to the installer CLI", async (t) => { + const repo = await makeTempDir("skillflag-bin-install-repo-"); + const skill = await makeTempDir("skillflag-bin-install-skill-"); + t.after(async () => { + await repo.cleanup(); + await skill.cleanup(); + }); + + initGit(repo.dir); + await writeFile( + skill.dir, + "SKILL.md", + "---\nname: skillflag-bin-path\ndescription: Binary install path test\n---\n", + ); + + const result = spawnSync( + process.execPath, + [ + skillflagBinaryPath, + "install", + skill.dir, + "--agent", + "codex", + "--scope", + "repo", + ], + { + cwd: repo.dir, + encoding: "utf8", + }, + ); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /Installed skillflag-bin-path to/); + await fs.access( + path.join(repo.dir, ".codex/skills/skillflag-bin-path/SKILL.md"), + ); +}); + +test("skillflag binary routes piped tar install to the installer CLI", async (t) => { + const repo = await makeTempDir("skillflag-bin-tar-repo-"); + const skill = await makeTempDir("skillflag-bin-tar-skill-"); + t.after(async () => { + await repo.cleanup(); + await skill.cleanup(); + }); + + initGit(repo.dir); + await writeFile( + skill.dir, + "SKILL.md", + "---\nname: skillflag-bin-tar\ndescription: Binary install tar test\n---\n", + ); + await writeFile(skill.dir, "templates/example.txt", "hello\n"); + + const { entries } = await collectSkillEntries(skill.dir, "skillflag-bin-tar"); + const tarBuffer = await bufferFromStream(createTarStream(entries)); + + const result = spawnSync( + process.execPath, + [skillflagBinaryPath, "install", "--agent", "codex", "--scope", "repo"], + { + cwd: repo.dir, + encoding: "utf8", + input: tarBuffer, + }, + ); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /Installed skillflag-bin-tar to/); + await fs.access( + path.join(repo.dir, ".codex/skills/skillflag-bin-tar/SKILL.md"), + ); +}); From 18dec004dca3a7cf22355567d5193ca936c59ce3 Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 20 Feb 2026 14:20:44 +0100 Subject: [PATCH 13/19] feat: add installer collision preflight and shared install metadata parsing --- src/core/list.ts | 25 +- src/install/cli.ts | 272 +++++++++--------- src/install/install.ts | 32 --- src/install/resolve.ts | 196 +++++++------ src/install/validate.ts | 34 +-- src/shared/frontmatter.ts | 35 +++ test/integration/skill-install-cli.test.ts | 311 ++++++++++++++++++++- 7 files changed, 591 insertions(+), 314 deletions(-) create mode 100644 src/shared/frontmatter.ts diff --git a/src/core/list.ts b/src/core/list.ts index 184b943..cd1b76c 100644 --- a/src/core/list.ts +++ b/src/core/list.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { collectSkillEntries, createTarStream } from "./tar.js"; import { digestStreamSha256 } from "./digest.js"; import { listSkillDirs } from "./paths.js"; +import { parseFrontmatter } from "../shared/frontmatter.js"; export type SkillListJsonItem = { id: string; @@ -25,30 +26,6 @@ type SkillInfo = { version?: string; }; -function parseFrontmatter(content: string): Record { - if (!content.startsWith("---\n")) return {}; - const endIdx = content.indexOf("\n---", 4); - if (endIdx === -1) return {}; - const block = content.slice(4, endIdx + 1); - const lines = block.split("\n").filter((line) => line.trim().length > 0); - const fields: Record = {}; - for (const line of lines) { - const idx = line.indexOf(":"); - if (idx === -1) continue; - const key = line.slice(0, idx).trim(); - let value = line.slice(idx + 1).trim(); - if (!key || !value) continue; - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1).trim(); - } - fields[key] = value; - } - return fields; -} - async function readSkillInfo(dir: string, id: string): Promise { const skillMdPath = path.join(dir, "SKILL.md"); try { diff --git a/src/install/cli.ts b/src/install/cli.ts index 4f14942..cdaa115 100644 --- a/src/install/cli.ts +++ b/src/install/cli.ts @@ -15,14 +15,19 @@ import { import type { Option } from "@clack/prompts"; import { InstallError, toErrorMessage } from "./errors.js"; +import { installSkill, type InstallInput } from "./install.js"; import { + AGENTS, assertAgent, assertScope, - installSkill, - type InstallInput, -} from "./install.js"; -import { resolveSkillsRoot, type Agent, type Scope } from "./resolve.js"; + assertSupportedAgentScopes, + resolveSkillsRoot, + sharedScopesForAgents, + type Agent, + type Scope, +} from "./resolve.js"; import { assertSkillDir, readSkillMetadata } from "./validate.js"; +import { uniqueValues } from "../utils/collections.js"; export type InstallCliOptions = { stdin?: Readable; @@ -33,6 +38,39 @@ export type InstallCliOptions = { providedInputs?: InstallInput[]; providedSkillId?: string; providedSkillIds?: string[]; + promptApi?: InstallPromptApi; +}; + +export type InstallPromptApi = { + confirm: (opts: Parameters[0]) => Promise; + intro: ( + message?: string, + opts?: { input?: Readable; output?: Writable }, + ) => void; + isCancel: (value: unknown) => value is symbol; + multiselect: (opts: { + message: string; + options: Option[]; + initialValues?: Value[]; + required?: boolean; + input?: Readable; + output?: Writable; + }) => Promise; + note: ( + message?: string, + title?: string, + opts?: { input?: Readable; output?: Writable }, + ) => void; + outro: ( + message?: string, + opts?: { input?: Readable; output?: Writable }, + ) => void; + spinner: (opts?: { input?: Readable; output?: Writable }) => { + start: (message?: string) => void; + stop: (message?: string) => void; + error: (message?: string) => void; + }; + text: (opts: Parameters[0]) => Promise; }; type ParsedArgs = { @@ -85,58 +123,35 @@ const usageLines = [ " skill-install --agent [,...] [--agent [,...]] --scope [,...] [--scope [,...]] [--force] < tar", ]; -const agentOptions: Option[] = [ - { - value: "codex", - label: "codex", - hint: "OpenAI Codex CLI skills (.codex/skills or CODEX_HOME/skills)", - }, - { - value: "claude", - label: "claude", - hint: "Claude Code skills (.claude/skills)", - }, - { - value: "portable", - label: "portable", - hint: "Portable agents skills (.agents/skills)", - }, - { - value: "vscode", - label: "vscode", - hint: "VS Code skills in .github/skills", - }, - { - value: "copilot", - label: "copilot", - hint: "GitHub Copilot skills in .github/skills", - }, - { - value: "amp", - label: "amp", - hint: "Amp agent skills (.agents/skills)", - }, - { - value: "goose", - label: "goose", - hint: "Goose agent skills (.agents/skills)", - }, - { - value: "opencode", - label: "opencode", - hint: "OpenCode skills (.opencode/skill)", - }, - { - value: "factory", - label: "factory", - hint: "Factory skills (.factory/skills)", - }, - { - value: "cursor", - label: "cursor", - hint: "Cursor skills (.cursor/skills)", - }, -]; +const defaultPromptApi: InstallPromptApi = { + confirm, + intro, + isCancel, + multiselect, + note, + outro, + spinner, + text, +}; + +const agentHints: Record = { + codex: "OpenAI Codex CLI skills (.codex/skills or CODEX_HOME/skills)", + claude: "Claude Code skills (.claude/skills)", + portable: "Portable agents skills (.agents/skills)", + vscode: "VS Code skills in .github/skills", + copilot: "GitHub Copilot skills in .github/skills", + amp: "Amp agent skills (.agents/skills)", + goose: "Goose agent skills (.agents/skills)", + opencode: "OpenCode skills (.opencode/skill)", + factory: "Factory skills (.factory/skills)", + cursor: "Cursor skills (.cursor/skills)", +}; + +const agentOptions: Option[] = AGENTS.map((agent) => ({ + value: agent, + label: agent, + hint: agentHints[agent], +})); const scopeDescriptions: Record = { repo: "Install to the current git repo root.", @@ -146,29 +161,6 @@ const scopeDescriptions: Record = { parent: "Install relative to the parent of the current directory.", }; -const supportedScopesByAgent: Record = { - codex: ["repo", "user", "admin", "cwd", "parent"], - claude: ["repo", "user"], - portable: ["repo", "user"], - vscode: ["repo"], - copilot: ["repo"], - amp: ["repo", "user"], - goose: ["repo", "user"], - opencode: ["repo", "user"], - factory: ["repo", "user"], - cursor: ["repo"], -}; - -function uniqueValues(values: T[]): T[] { - const out: T[] = []; - for (const value of values) { - if (!out.includes(value)) { - out.push(value); - } - } - return out; -} - function parseScopeValues(value: string | undefined): string[] { if (!value || value.startsWith("-")) { throw new InstallError("Missing value for --scope."); @@ -272,29 +264,13 @@ function asScope(value: string | undefined): Scope | undefined { } function asScopes(values: string[]): Scope[] { - return values - .map((value) => asScope(value)) - .filter((value): value is Scope => value !== undefined); -} - -function getSharedScopes(agents: Agent[]): Scope[] { - if (agents.length === 0) return []; - return supportedScopesByAgent[agents[0]].filter((scope) => - agents.every((agent) => supportedScopesByAgent[agent].includes(scope)), + return uniqueValues( + values + .map((value) => asScope(value)) + .filter((value): value is Scope => value !== undefined), ); } -function assertSupportedAgentScopes(agents: Agent[], scopes: Scope[]): void { - for (const agent of agents) { - const supportedScopes = supportedScopesByAgent[agent]; - for (const scope of scopes) { - if (!supportedScopes.includes(scope)) { - throw new InstallError(`Unsupported agent/scope: ${agent} ${scope}`); - } - } - } -} - function validateRequiredFlags(parsed: ParsedArgs): ResolvedInstallArgs { if (parsed.agents.length === 0 || parsed.scopes.length === 0) { throw new InstallError(`Missing required flags.\n${usageLines.join("\n")}`); @@ -374,9 +350,10 @@ function validatePathPrompt(value: string | undefined): string | undefined { function cancelWizard( stdin: Readable, stdout: Writable, + promptApi: InstallPromptApi, message = "Install cancelled.", ): null { - outro(message, { input: stdin, output: stdout }); + promptApi.outro(message, { input: stdin, output: stdout }); return null; } @@ -483,21 +460,35 @@ function buildInstallPlan( return plan; } -function dedupeInstallPlan(plan: InstallPlanItem[]): InstallPlanItem[] { - const seenByDestination = new Map>(); - const deduped: InstallPlanItem[] = []; - +function assertNoInstallCollisions(plan: InstallPlanItem[]): void { + const plansByDestination = new Map(); for (const item of plan) { - const seenSources = seenByDestination.get(item.destination) ?? new Set(); - if (seenSources.has(item.source)) { - continue; - } - seenSources.add(item.source); - seenByDestination.set(item.destination, seenSources); - deduped.push(item); + const entries = plansByDestination.get(item.destination) ?? []; + entries.push(item); + plansByDestination.set(item.destination, entries); } - return deduped; + const collisions = [...plansByDestination.entries()] + .filter(([, items]) => items.length > 1) + .sort(([a], [b]) => a.localeCompare(b)); + + if (collisions.length === 0) { + return; + } + + const lines = ["Install destination collisions detected:"]; + for (const [destination, items] of collisions) { + lines.push(`- ${destination}`); + for (const item of items) { + lines.push( + ` - ${item.source.skillIdHint} @ ${item.agent}/${item.scope} (source: ${item.source.source})`, + ); + } + } + lines.push( + "Resolve collisions by changing skill IDs, sources, --agent, or --scope so each combination has a unique destination.", + ); + throw new InstallError(lines.join("\n")); } async function runInstallWizard( @@ -506,13 +497,14 @@ async function runInstallWizard( stdout: Writable, cwd: string, provided: ProvidedInstallInputs, + promptApi: InstallPromptApi, ): Promise { - intro("skill-install wizard", { input: stdin, output: stdout }); + promptApi.intro("skill-install wizard", { input: stdin, output: stdout }); let inputPaths = parsed.inputPaths; if (provided.inputs.length === 0 && inputPaths.length === 0) { const defaultPath = cwd; - const pathValue = await text({ + const pathValue = await promptApi.text({ message: "PATH to skill directory (comma-separated for multiple, defaults to current directory)", placeholder: defaultPath, @@ -521,7 +513,9 @@ async function runInstallWizard( input: stdin, output: stdout, }); - if (isCancel(pathValue)) return cancelWizard(stdin, stdout); + if (promptApi.isCancel(pathValue)) { + return cancelWizard(stdin, stdout, promptApi); + } inputPaths = parsePathList(pathValue.trim() || defaultPath); } @@ -535,7 +529,7 @@ async function runInstallWizard( } else if (agentOptions.length === 1) { agents = [assertAgent(agentOptions[0].value)]; } else { - const agentValues = await multiselect({ + const agentValues = await promptApi.multiselect({ message: "Agent targets", options: agentOptions, initialValues: parsedAgents.length > 0 ? parsedAgents : [], @@ -543,11 +537,13 @@ async function runInstallWizard( input: stdin, output: stdout, }); - if (isCancel(agentValues)) return cancelWizard(stdin, stdout); + if (promptApi.isCancel(agentValues)) { + return cancelWizard(stdin, stdout, promptApi); + } agents = uniqueValues(agentValues.map((value) => assertAgent(value))); } - const supportedScopes = getSharedScopes(agents); + const supportedScopes = sharedScopesForAgents(agents); if (supportedScopes.length === 0) { throw new InstallError( `No shared scopes for selected agents: ${agents.join(", ")}`, @@ -566,7 +562,7 @@ async function runInstallWizard( label: scope, hint: scopeDescriptions[scope], })); - const scopeValues = await multiselect({ + const scopeValues = await promptApi.multiselect({ message: "Scope targets", options: scopeOptions, initialValues: parsedScopes.length > 0 ? parsedScopes : [], @@ -574,24 +570,28 @@ async function runInstallWizard( input: stdin, output: stdout, }); - if (isCancel(scopeValues)) return cancelWizard(stdin, stdout); + if (promptApi.isCancel(scopeValues)) { + return cancelWizard(stdin, stdout, promptApi); + } scopes = uniqueValues(scopeValues.map((scope) => assertScope(scope))); } - const forceValue = await confirm({ + const forceValue = await promptApi.confirm({ message: "Force overwrite if the destination already exists? (--force)", initialValue: parsed.force, input: stdin, output: stdout, }); - if (isCancel(forceValue)) return cancelWizard(stdin, stdout); + if (promptApi.isCancel(forceValue)) { + return cancelWizard(stdin, stdout, promptApi); + } const force = forceValue; assertSupportedAgentScopes(agents, scopes); const sources = await resolveInstallSources(inputPaths, stdin, provided); const plan = buildInstallPlan(sources, agents, scopes, cwd); - const executionPlan = dedupeInstallPlan(plan); + assertNoInstallCollisions(plan); const sourceLines = sources.map( (source) => `${source.skillIdHint} <= ${source.source}`, @@ -601,14 +601,14 @@ async function runInstallWizard( `${item.source.skillIdHint} @ ${item.agent}/${item.scope} -> ${item.destination}`, ); - note( + promptApi.note( [ `Sources (${sources.length}):`, ...sourceLines, `Agents (${agents.length}): ${agents.join(", ")}`, `Scopes (${scopes.length}): ${scopes.join(", ")}`, `Matrix: ${sources.length} skill(s) × ${agents.length} agent(s) × ${scopes.length} scope(s) = ${plan.length} combination(s)`, - `Execution targets: ${executionPlan.length}`, + `Execution targets: ${plan.length}`, `Planned combinations (${plan.length}):`, ...installLines, `Force: ${force ? "yes" : "no"}`, @@ -617,13 +617,15 @@ async function runInstallWizard( { input: stdin, output: stdout }, ); - const confirmed = await confirm({ + const confirmed = await promptApi.confirm({ message: "Proceed with install?", initialValue: true, input: stdin, output: stdout, }); - if (isCancel(confirmed) || !confirmed) return cancelWizard(stdin, stdout); + if (promptApi.isCancel(confirmed) || !confirmed) { + return cancelWizard(stdin, stdout, promptApi); + } return { args: { inputPaths, agents, scopes, force }, @@ -638,13 +640,14 @@ async function runInstall( stdout: Writable, cwd: string, useSpinner: boolean, + promptApi: InstallPromptApi, ): Promise { const plan = buildInstallPlan(sources, args.agents, args.scopes, cwd); - const executionPlan = dedupeInstallPlan(plan); + assertNoInstallCollisions(plan); const execute = async (): Promise => { const results: InstallExecutionResult[] = []; - for (const item of executionPlan) { + for (const item of plan) { const result = await installSkill(item.source.makeInput(), { agent: item.agent, scope: item.scope, @@ -660,10 +663,8 @@ async function runInstall( return execute(); } - const s = spinner({ input: stdin, output: stdout }); - s.start( - `Installing ${executionPlan.length} target${executionPlan.length === 1 ? "" : "s"}...`, - ); + const s = promptApi.spinner({ input: stdin, output: stdout }); + s.start(`Installing ${plan.length} target${plan.length === 1 ? "" : "s"}...`); try { const result = await execute(); s.stop("Install complete."); @@ -682,6 +683,7 @@ export async function runInstallCli( const stderr = (opts.stderr ?? process.stderr) as Writable; const stdin = opts.stdin ?? process.stdin; const cwd = opts.cwd ?? process.cwd(); + const promptApi = opts.promptApi ?? defaultPromptApi; try { const parsed = parseArgs(argv.slice(2)); @@ -713,6 +715,7 @@ export async function runInstallCli( stdout, cwd, provided, + promptApi, ); if (!wizardResult) { return 1; @@ -736,6 +739,7 @@ export async function runInstallCli( stdout, cwd, wizardUsed, + promptApi, ); for (const result of results) { @@ -744,7 +748,7 @@ export async function runInstallCli( ); } if (wizardUsed) { - outro("Done.", { input: stdin, output: stdout }); + promptApi.outro("Done.", { input: stdin, output: stdout }); } return 0; } catch (err) { diff --git a/src/install/install.ts b/src/install/install.ts index 7e9ae0f..4f7984c 100644 --- a/src/install/install.ts +++ b/src/install/install.ts @@ -3,7 +3,6 @@ import os from "node:os"; import path from "node:path"; import { Readable } from "node:stream"; -import { InstallError } from "./errors.js"; import { assertSkillDir, readSkillMetadata } from "./validate.js"; import { extractSkillTarToTemp } from "./extract.js"; import { resolveSkillsRoot, type Agent, type Scope } from "./resolve.js"; @@ -58,34 +57,3 @@ export async function installSkill( await cleanup(); } } - -export function assertAgent(value: string): Agent { - if ( - value === "codex" || - value === "claude" || - value === "portable" || - value === "vscode" || - value === "copilot" || - value === "amp" || - value === "goose" || - value === "opencode" || - value === "factory" || - value === "cursor" - ) { - return value; - } - throw new InstallError(`Unsupported agent: ${value}`); -} - -export function assertScope(value: string): Scope { - if ( - value === "repo" || - value === "user" || - value === "admin" || - value === "cwd" || - value === "parent" - ) { - return value; - } - throw new InstallError(`Unsupported scope: ${value}`); -} diff --git a/src/install/resolve.ts b/src/install/resolve.ts index 0c4e07c..8522281 100644 --- a/src/install/resolve.ts +++ b/src/install/resolve.ts @@ -3,19 +3,27 @@ import path from "node:path"; import { execFileSync } from "node:child_process"; import { InstallError } from "./errors.js"; +import { uniqueValues } from "../utils/collections.js"; -export type Agent = - | "codex" - | "claude" - | "portable" - | "vscode" - | "copilot" - | "amp" - | "goose" - | "opencode" - | "factory" - | "cursor"; -export type Scope = "repo" | "user" | "admin" | "cwd" | "parent"; +export const AGENTS = [ + "codex", + "claude", + "portable", + "vscode", + "copilot", + "amp", + "goose", + "opencode", + "factory", + "cursor", +] as const; + +export const SCOPES = ["repo", "user", "admin", "cwd", "parent"] as const; + +export type Agent = (typeof AGENTS)[number]; +export type Scope = (typeof SCOPES)[number]; + +type ScopeResolvers = Partial string>>; export function resolveRepoRoot(cwd: string): string { try { @@ -30,92 +38,110 @@ export function resolveRepoRoot(cwd: string): string { return cwd; } -export function resolveSkillsRoot( - agent: Agent, - scope: Scope, - cwd: string, -): string { - if (agent === "codex") { - if (scope === "repo") { - return path.join(resolveRepoRoot(cwd), ".codex/skills"); - } - if (scope === "cwd") { - return path.join(cwd, ".codex/skills"); - } - if (scope === "parent") { - return path.join(path.resolve(cwd, ".."), ".codex/skills"); - } - if (scope === "user") { +function configRoot(): string { + return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"); +} + +const scopeResolversByAgent: Record = { + codex: { + repo: (cwd) => path.join(resolveRepoRoot(cwd), ".codex/skills"), + cwd: (cwd) => path.join(cwd, ".codex/skills"), + parent: (cwd) => path.join(path.resolve(cwd, ".."), ".codex/skills"), + user: () => { const root = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex"); return path.join(root, "skills"); - } - if (scope === "admin") { - return "/etc/codex/skills"; - } - } + }, + admin: () => "/etc/codex/skills", + }, + claude: { + repo: (cwd) => path.join(resolveRepoRoot(cwd), ".claude/skills"), + user: () => path.join(os.homedir(), ".claude/skills"), + }, + portable: { + repo: (cwd) => path.join(resolveRepoRoot(cwd), ".agents/skills"), + user: () => path.join(configRoot(), "agents/skills"), + }, + vscode: { + repo: (cwd) => path.join(resolveRepoRoot(cwd), ".github/skills"), + }, + copilot: { + repo: (cwd) => path.join(resolveRepoRoot(cwd), ".github/skills"), + }, + amp: { + repo: (cwd) => path.join(resolveRepoRoot(cwd), ".agents/skills"), + user: () => path.join(configRoot(), "agents/skills"), + }, + goose: { + repo: (cwd) => path.join(resolveRepoRoot(cwd), ".agents/skills"), + user: () => path.join(configRoot(), "agents/skills"), + }, + opencode: { + repo: (cwd) => path.join(resolveRepoRoot(cwd), ".opencode/skill"), + user: () => path.join(configRoot(), "opencode/skill"), + }, + factory: { + repo: (cwd) => path.join(resolveRepoRoot(cwd), ".factory/skills"), + user: () => path.join(os.homedir(), ".factory/skills"), + }, + cursor: { + repo: (cwd) => path.join(resolveRepoRoot(cwd), ".cursor/skills"), + }, +}; - if (agent === "claude") { - if (scope === "repo") { - return path.join(resolveRepoRoot(cwd), ".claude/skills"); - } - if (scope === "user") { - return path.join(os.homedir(), ".claude/skills"); - } +export function assertAgent(value: string): Agent { + if ((AGENTS as readonly string[]).includes(value)) { + return value as Agent; } + throw new InstallError(`Unsupported agent: ${value}`); +} - if (agent === "portable") { - if (scope === "repo") { - return path.join(resolveRepoRoot(cwd), ".agents/skills"); - } - if (scope === "user") { - const root = - process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"); - return path.join(root, "agents/skills"); - } +export function assertScope(value: string): Scope { + if ((SCOPES as readonly string[]).includes(value)) { + return value as Scope; } + throw new InstallError(`Unsupported scope: ${value}`); +} - if (agent === "vscode" || agent === "copilot") { - if (scope === "repo") { - return path.join(resolveRepoRoot(cwd), ".github/skills"); - } - } +export function supportedScopesForAgent(agent: Agent): Scope[] { + return Object.keys(scopeResolversByAgent[agent]) as Scope[]; +} - if (agent === "amp" || agent === "goose") { - if (scope === "repo") { - return path.join(resolveRepoRoot(cwd), ".agents/skills"); - } - if (scope === "user") { - const root = - process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"); - return path.join(root, "agents/skills"); - } +export function sharedScopesForAgents(agents: Agent[]): Scope[] { + const uniqueAgents = uniqueValues(agents); + if (uniqueAgents.length === 0) { + return []; } - if (agent === "opencode") { - if (scope === "repo") { - return path.join(resolveRepoRoot(cwd), ".opencode/skill"); - } - if (scope === "user") { - const root = - process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"); - return path.join(root, "opencode/skill"); - } - } + const first = uniqueAgents[0]; + return supportedScopesForAgent(first).filter((scope) => + uniqueAgents.every((agent) => + supportedScopesForAgent(agent).includes(scope), + ), + ); +} - if (agent === "factory") { - if (scope === "repo") { - return path.join(resolveRepoRoot(cwd), ".factory/skills"); - } - if (scope === "user") { - return path.join(os.homedir(), ".factory/skills"); +export function assertSupportedAgentScopes( + agents: Agent[], + scopes: Scope[], +): void { + for (const agent of uniqueValues(agents)) { + const supported = supportedScopesForAgent(agent); + for (const scope of uniqueValues(scopes)) { + if (!supported.includes(scope)) { + throw new InstallError(`Unsupported agent/scope: ${agent} ${scope}`); + } } } +} - if (agent === "cursor") { - if (scope === "repo") { - return path.join(resolveRepoRoot(cwd), ".cursor/skills"); - } +export function resolveSkillsRoot( + agent: Agent, + scope: Scope, + cwd: string, +): string { + const resolver = scopeResolversByAgent[agent][scope]; + if (!resolver) { + throw new InstallError(`Unsupported agent/scope: ${agent} ${scope}`); } - - throw new InstallError(`Unsupported agent/scope: ${agent} ${scope}`); + return resolver(cwd); } diff --git a/src/install/validate.ts b/src/install/validate.ts index 3092cf6..213c33c 100644 --- a/src/install/validate.ts +++ b/src/install/validate.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { InstallError } from "./errors.js"; +import { parseFrontmatter } from "../shared/frontmatter.js"; export type SkillMetadata = { name: string; @@ -17,39 +18,6 @@ export async function assertSkillDir(rootDir: string): Promise { } } -function parseFrontmatter(content: string): Record { - const frontmatterMatch = content.match( - /^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/, - ); - if (!frontmatterMatch) { - return {}; - } - const block = frontmatterMatch[1]; - const lines = block.split(/\r?\n/).filter((line) => line.trim().length > 0); - const fields: Record = {}; - for (const line of lines) { - const idx = line.indexOf(":"); - if (idx === -1) continue; - const key = line.slice(0, idx).trim(); - const rawValue = line.slice(idx + 1).trim(); - const value = stripYamlQuotes(rawValue); - if (key && value) { - fields[key] = value; - } - } - return fields; -} - -function stripYamlQuotes(value: string): string { - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - return value.slice(1, -1).trim(); - } - return value; -} - export async function readSkillMetadata( rootDir: string, ): Promise { diff --git a/src/shared/frontmatter.ts b/src/shared/frontmatter.ts new file mode 100644 index 0000000..d6bf46f --- /dev/null +++ b/src/shared/frontmatter.ts @@ -0,0 +1,35 @@ +function stripYamlQuotes(value: string): string { + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1).trim(); + } + return value; +} + +export function parseFrontmatter(content: string): Record { + const frontmatterMatch = content.match( + /^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/, + ); + if (!frontmatterMatch) { + return {}; + } + + const block = frontmatterMatch[1]; + const lines = block.split(/\r?\n/).filter((line) => line.trim().length > 0); + const fields: Record = {}; + + for (const line of lines) { + const idx = line.indexOf(":"); + if (idx === -1) continue; + + const key = line.slice(0, idx).trim(); + const value = stripYamlQuotes(line.slice(idx + 1).trim()); + if (key && value) { + fields[key] = value; + } + } + + return fields; +} diff --git a/test/integration/skill-install-cli.test.ts b/test/integration/skill-install-cli.test.ts index dde7f80..ccf7a4f 100644 --- a/test/integration/skill-install-cli.test.ts +++ b/test/integration/skill-install-cli.test.ts @@ -5,7 +5,7 @@ import fs from "node:fs/promises"; import { execFileSync } from "node:child_process"; import { Readable } from "node:stream"; -import { runInstallCli } from "../../src/install/cli.js"; +import { runInstallCli, type InstallPromptApi } from "../../src/install/cli.js"; import { createCapture } from "../helpers/capture.js"; import { makeTempDir, writeFile } from "../helpers/tmp.js"; @@ -13,6 +13,71 @@ function initGit(repoDir: string): void { execFileSync("git", ["init"], { cwd: repoDir }); } +const PROMPT_CANCEL = Symbol("prompt-cancel"); + +function createTtyStdin(): Readable { + const stdin = Readable.from([]); + (stdin as Readable & { isTTY?: boolean }).isTTY = true; + return stdin; +} + +type PromptStubOptions = { + textResponses?: Array; + multiselectResponses?: Array; + confirmResponses?: Array; +}; + +type PromptStub = { + promptApi: InstallPromptApi; + notes: string[]; + outros: string[]; +}; + +function createPromptStub(options: PromptStubOptions = {}): PromptStub { + const textResponses = [...(options.textResponses ?? [])]; + const multiselectResponses = [...(options.multiselectResponses ?? [])]; + const confirmResponses = [...(options.confirmResponses ?? [])]; + const notes: string[] = []; + const outros: string[] = []; + + const promptApi: InstallPromptApi = { + confirm: async () => { + if (confirmResponses.length === 0) { + throw new Error("No prompt stub response configured for confirm."); + } + return confirmResponses.shift() as boolean | symbol; + }, + intro: () => {}, + isCancel: (value: unknown): value is symbol => value === PROMPT_CANCEL, + multiselect: async () => { + if (multiselectResponses.length === 0) { + throw new Error("No prompt stub response configured for multiselect."); + } + const response = multiselectResponses.shift(); + return response as Value[] | symbol; + }, + note: (message?: string) => { + notes.push(message ?? ""); + }, + outro: (message?: string) => { + outros.push(message ?? ""); + }, + spinner: () => ({ + start: () => {}, + stop: () => {}, + error: () => {}, + }), + text: async () => { + if (textResponses.length === 0) { + throw new Error("No prompt stub response configured for text."); + } + return textResponses.shift() as string | symbol; + }, + }; + + return { promptApi, notes, outros }; +} + test("runInstallCli requires flags without an interactive tty", async () => { const stderr = createCapture(); @@ -238,7 +303,7 @@ test("runInstallCli supports repeated --agent and installs matrix combinations", ); }); -test("runInstallCli handles multi-agent installs when agents share a destination", async (t) => { +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 () => { @@ -274,9 +339,243 @@ test("runInstallCli handles multi-agent installs when agents share a destination }, ); - assert.equal(exitCode, 0); - assert.match(stderr.text(), /Installed cli-skill-shared to/); - await fs.access( - path.join(repo.dir, ".agents/skills/cli-skill-shared/SKILL.md"), + assert.equal(exitCode, 1); + assert.match(stderr.text(), /Install destination collisions detected/); + assert.match(stderr.text(), /\.agents\/skills\/cli-skill-shared/); + await assert.rejects( + fs.access(path.join(repo.dir, ".agents/skills/cli-skill-shared/SKILL.md")), + ); +}); + +test("runInstallCli fails for unsupported agent/scope combinations", async (t) => { + const repo = await makeTempDir("skill-install-cli-unsupported-repo-"); + const skill = await makeTempDir("skill-install-cli-unsupported-skill-"); + t.after(async () => { + await repo.cleanup(); + await skill.cleanup(); + }); + + initGit(repo.dir); + + await writeFile( + skill.dir, + "SKILL.md", + "---\nname: cli-skill-unsupported\ndescription: Unsupported combo skill\n---\n", + ); + + const stderr = createCapture(); + const exitCode = await runInstallCli( + [ + "node", + "skill-install", + skill.dir, + "--agent", + "claude", + "--scope", + "admin", + ], + { + stdin: Readable.from([]), + stderr: stderr.stream, + cwd: repo.dir, + }, + ); + + assert.equal(exitCode, 1); + assert.match(stderr.text(), /Unsupported agent\/scope: claude admin/); +}); + +test("runInstallCli detects collisions for different sources with the same skill name", async (t) => { + const repo = await makeTempDir("skill-install-cli-collision-repo-"); + const skillOne = await makeTempDir("skill-install-cli-collision-one-"); + const skillTwo = await makeTempDir("skill-install-cli-collision-two-"); + t.after(async () => { + await repo.cleanup(); + await skillOne.cleanup(); + await skillTwo.cleanup(); + }); + + initGit(repo.dir); + + await writeFile( + skillOne.dir, + "SKILL.md", + "---\nname: cli-skill-collision\ndescription: Collision test one\n---\n", + ); + await writeFile( + skillTwo.dir, + "SKILL.md", + "---\nname: cli-skill-collision\ndescription: Collision test two\n---\n", + ); + + const stderr = createCapture(); + const exitCode = await runInstallCli( + [ + "node", + "skill-install", + skillOne.dir, + skillTwo.dir, + "--agent", + "codex", + "--scope", + "repo", + ], + { + stdin: Readable.from([]), + stderr: stderr.stream, + cwd: repo.dir, + }, + ); + + assert.equal(exitCode, 1); + assert.match(stderr.text(), /Install destination collisions detected/); + assert.match(stderr.text(), /cli-skill-collision @ codex\/repo/); + await assert.rejects( + fs.access( + path.join(repo.dir, ".codex/skills/cli-skill-collision/SKILL.md"), + ), + ); +}); + +test("runInstallCli wizard cancellation works at each prompt step", async (t) => { + const repo = await makeTempDir("skill-install-cli-cancel-repo-"); + const skill = await makeTempDir("skill-install-cli-cancel-skill-"); + t.after(async () => { + await repo.cleanup(); + await skill.cleanup(); + }); + + initGit(repo.dir); + await writeFile( + skill.dir, + "SKILL.md", + "---\nname: cli-skill-cancel\ndescription: Wizard cancellation skill\n---\n", + ); + + await t.test("path prompt", async () => { + const prompt = createPromptStub({ + textResponses: [PROMPT_CANCEL], + }); + const exitCode = await runInstallCli(["node", "skill-install"], { + stdin: createTtyStdin(), + cwd: repo.dir, + promptApi: prompt.promptApi, + }); + assert.equal(exitCode, 1); + assert.deepEqual(prompt.outros, ["Install cancelled."]); + }); + + await t.test("agent prompt", async () => { + const prompt = createPromptStub({ + multiselectResponses: [PROMPT_CANCEL], + }); + const exitCode = await runInstallCli(["node", "skill-install", skill.dir], { + stdin: createTtyStdin(), + cwd: repo.dir, + promptApi: prompt.promptApi, + }); + assert.equal(exitCode, 1); + assert.deepEqual(prompt.outros, ["Install cancelled."]); + }); + + await t.test("scope prompt", async () => { + const prompt = createPromptStub({ + multiselectResponses: [PROMPT_CANCEL], + }); + const exitCode = await runInstallCli( + ["node", "skill-install", skill.dir, "--agent", "codex"], + { + stdin: createTtyStdin(), + cwd: repo.dir, + promptApi: prompt.promptApi, + }, + ); + assert.equal(exitCode, 1); + assert.deepEqual(prompt.outros, ["Install cancelled."]); + }); + + await t.test("force prompt", async () => { + const prompt = createPromptStub({ + multiselectResponses: [["repo"]], + confirmResponses: [PROMPT_CANCEL], + }); + const exitCode = await runInstallCli( + ["node", "skill-install", skill.dir, "--agent", "codex"], + { + stdin: createTtyStdin(), + cwd: repo.dir, + promptApi: prompt.promptApi, + }, + ); + assert.equal(exitCode, 1); + assert.deepEqual(prompt.outros, ["Install cancelled."]); + }); + + await t.test("confirmation prompt", async () => { + const prompt = createPromptStub({ + multiselectResponses: [["repo"]], + confirmResponses: [false, PROMPT_CANCEL], + }); + const exitCode = await runInstallCli( + ["node", "skill-install", skill.dir, "--agent", "codex"], + { + stdin: createTtyStdin(), + cwd: repo.dir, + promptApi: prompt.promptApi, + }, + ); + assert.equal(exitCode, 1); + assert.deepEqual(prompt.outros, ["Install cancelled."]); + }); +}); + +test("runInstallCli wizard confirmation matrix includes expected content", async (t) => { + const repo = await makeTempDir("skill-install-cli-matrix-repo-"); + const skillOne = await makeTempDir("skill-install-cli-matrix-one-"); + const skillTwo = await makeTempDir("skill-install-cli-matrix-two-"); + t.after(async () => { + await repo.cleanup(); + await skillOne.cleanup(); + await skillTwo.cleanup(); + }); + + initGit(repo.dir); + + await writeFile( + skillOne.dir, + "SKILL.md", + "---\nname: cli-skill-matrix-one\ndescription: Matrix test one\n---\n", + ); + await writeFile( + skillTwo.dir, + "SKILL.md", + "---\nname: cli-skill-matrix-two\ndescription: Matrix test two\n---\n", + ); + + const prompt = createPromptStub({ + textResponses: [`${skillOne.dir},${skillTwo.dir}`], + multiselectResponses: [ + ["codex", "claude"], + ["repo", "user"], + ], + confirmResponses: [false, false], + }); + + const exitCode = await runInstallCli(["node", "skill-install"], { + stdin: createTtyStdin(), + cwd: repo.dir, + promptApi: prompt.promptApi, + }); + + assert.equal(exitCode, 1); + assert.equal(prompt.notes.length, 1); + const summary = prompt.notes[0]; + assert.match(summary, /Sources \(2\):/); + assert.match(summary, /Agents \(2\): codex, claude/); + assert.match(summary, /Scopes \(2\): repo, user/); + assert.match( + summary, + /Matrix: 2 skill\(s\) × 2 agent\(s\) × 2 scope\(s\) = 8 combination\(s\)/, ); + assert.match(summary, /Planned combinations \(8\):/); }); From bb73ccb38cba51072a733449475670c72c720b20 Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 20 Feb 2026 14:20:48 +0100 Subject: [PATCH 14/19] docs: allow optional multi-target skill-install invocations --- docs/SKILLFLAG_SPEC.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/SKILLFLAG_SPEC.md b/docs/SKILLFLAG_SPEC.md index 7645b1f..cb26f61 100644 --- a/docs/SKILLFLAG_SPEC.md +++ b/docs/SKILLFLAG_SPEC.md @@ -326,13 +326,13 @@ A producer CLI is **Skillflag-compliant** if: # `skill-install` companion spec (installer side) -Scope: installs **one** skill bundle into **one** target agent/tool + scope. +Scope: baseline behavior installs one skill bundle into one target agent/tool + scope. Implementations **MAY** additionally support multi-install in a single invocation (multiple sources, agents, and scopes). ### Motivation - **Skills are directories**, not just `SKILL.md`: they can include scripts, templates, references, assets, etc. (multiple tools describe skills this way). ([OpenAI Developers][1]) - **Producer CLIs should not encode per-agent install logic.** The producer just exposes skill bundles (via Skillflag: `--skill list`, `--skill export `). The installer maps to agent-specific locations. -- **Users must opt in**: installing a CLI must not automatically install all of its skills into every local agent. So `skill-install` targets exactly one agent and one scope at a time. +- **Users must opt in**: installing a CLI must not automatically install all of its skills into every local agent. A single explicit target remains the baseline, but implementations **MAY** offer explicit multi-target invocations. - **Cross-agent portability exists but paths differ**: several tools already read “portable” directories (notably `.agents/skills` and `~/.config/agents/skills`), while others have native roots like `.claude/skills`, `.codex/skills`, `.github/skills`, etc. ([Block][2]) ## 1) Inputs `skill-install` accepts @@ -342,6 +342,7 @@ Scope: installs **one** skill bundle into **one** target agent/tool + scope. Install from a local skill directory: - `PATH` **must** be a directory containing `SKILL.md` at its root. +- Implementations **MAY** accept multiple `PATH` values in one command. If supported, each path is treated as an independent source skill bundle. Example: @@ -369,7 +370,7 @@ producer --skill export tmux | skill-install --agent claude --scope user ### 2.1 Synopsis ```bash -skill-install [PATH] +skill-install [PATH ...] --agent --scope [--root ] @@ -383,6 +384,8 @@ 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. + ### 2.2 Required flags - `--agent` is **required** unless `--dest` is provided. From c82d4a0d7f9324440facde1fb54b1c36e077e0b7 Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 20 Feb 2026 14:36:13 +0100 Subject: [PATCH 15/19] Handle piped stdin wizard prompts via /dev/tty --- src/install/cli.ts | 179 ++++++++++++++--- test/integration/skill-install-cli.test.ts | 215 +++++++++++++++++++-- 2 files changed, 350 insertions(+), 44 deletions(-) diff --git a/src/install/cli.ts b/src/install/cli.ts index cdaa115..c105e43 100644 --- a/src/install/cli.ts +++ b/src/install/cli.ts @@ -34,6 +34,7 @@ export type InstallCliOptions = { stdout?: Writable; stderr?: Writable; cwd?: string; + openPromptTty?: () => PromptTtyStreams | null; providedInput?: InstallInput; providedInputs?: InstallInput[]; providedSkillId?: string; @@ -117,6 +118,12 @@ type WizardResult = { sources: PreparedInstallSource[]; }; +type PromptTtyStreams = { + input: Readable; + output: Writable; + close: () => void; +}; + const usageLines = [ "Usage:", " skill-install [PATH ...] --agent [,...] [--agent [,...]] --scope [,...] [--scope [,...]] [--force]", @@ -237,6 +244,72 @@ function stdinIsTty(stream: Readable): boolean { return (stream as { isTTY?: boolean }).isTTY === true; } +function stdinIsPipe(stream: Readable): boolean { + return (stream as { isTTY?: boolean }).isTTY === false; +} + +function openPromptTty(): PromptTtyStreams | null { + let inputFd: number | undefined; + let outputFd: number | undefined; + + try { + inputFd = fs.openSync("/dev/tty", "r"); + outputFd = fs.openSync("/dev/tty", "w"); + const promptInputFd = inputFd; + const promptOutputFd = outputFd; + + const input = fs.createReadStream("/dev/tty", { + fd: inputFd, + autoClose: false, + }); + const output = fs.createWriteStream("/dev/tty", { + fd: outputFd, + autoClose: false, + }); + + let closed = false; + return { + input, + output, + close: () => { + if (closed) { + return; + } + closed = true; + + input.destroy(); + output.destroy(); + try { + fs.closeSync(promptInputFd); + } catch { + // Ignore close errors. + } + try { + fs.closeSync(promptOutputFd); + } catch { + // Ignore close errors. + } + }, + }; + } catch { + if (inputFd !== undefined) { + try { + fs.closeSync(inputFd); + } catch { + // Ignore close errors. + } + } + if (outputFd !== undefined) { + try { + fs.closeSync(outputFd); + } catch { + // Ignore close errors. + } + } + return null; + } +} + function asAgent(value: string | undefined): Agent | undefined { if (!value) return undefined; try { @@ -348,12 +421,12 @@ function validatePathPrompt(value: string | undefined): string | undefined { } function cancelWizard( - stdin: Readable, - stdout: Writable, + promptInput: Readable, + promptOutput: Writable, promptApi: InstallPromptApi, message = "Install cancelled.", ): null { - promptApi.outro(message, { input: stdin, output: stdout }); + promptApi.outro(message, { input: promptInput, output: promptOutput }); return null; } @@ -494,15 +567,23 @@ function assertNoInstallCollisions(plan: InstallPlanItem[]): void { async function runInstallWizard( parsed: ParsedArgs, stdin: Readable, - stdout: Writable, + promptInput: Readable, + promptOutput: Writable, cwd: string, provided: ProvidedInstallInputs, promptApi: InstallPromptApi, ): Promise { - promptApi.intro("skill-install wizard", { input: stdin, output: stdout }); + promptApi.intro("skill-install wizard", { + input: promptInput, + output: promptOutput, + }); let inputPaths = parsed.inputPaths; - if (provided.inputs.length === 0 && inputPaths.length === 0) { + if ( + provided.inputs.length === 0 && + inputPaths.length === 0 && + !stdinHasData(stdin) + ) { const defaultPath = cwd; const pathValue = await promptApi.text({ message: @@ -510,11 +591,11 @@ async function runInstallWizard( placeholder: defaultPath, defaultValue: defaultPath, validate: validatePathPrompt, - input: stdin, - output: stdout, + input: promptInput, + output: promptOutput, }); if (promptApi.isCancel(pathValue)) { - return cancelWizard(stdin, stdout, promptApi); + return cancelWizard(promptInput, promptOutput, promptApi); } inputPaths = parsePathList(pathValue.trim() || defaultPath); } @@ -534,11 +615,11 @@ async function runInstallWizard( options: agentOptions, initialValues: parsedAgents.length > 0 ? parsedAgents : [], required: true, - input: stdin, - output: stdout, + input: promptInput, + output: promptOutput, }); if (promptApi.isCancel(agentValues)) { - return cancelWizard(stdin, stdout, promptApi); + return cancelWizard(promptInput, promptOutput, promptApi); } agents = uniqueValues(agentValues.map((value) => assertAgent(value))); } @@ -567,11 +648,11 @@ async function runInstallWizard( options: scopeOptions, initialValues: parsedScopes.length > 0 ? parsedScopes : [], required: true, - input: stdin, - output: stdout, + input: promptInput, + output: promptOutput, }); if (promptApi.isCancel(scopeValues)) { - return cancelWizard(stdin, stdout, promptApi); + return cancelWizard(promptInput, promptOutput, promptApi); } scopes = uniqueValues(scopeValues.map((scope) => assertScope(scope))); } @@ -579,11 +660,11 @@ async function runInstallWizard( const forceValue = await promptApi.confirm({ message: "Force overwrite if the destination already exists? (--force)", initialValue: parsed.force, - input: stdin, - output: stdout, + input: promptInput, + output: promptOutput, }); if (promptApi.isCancel(forceValue)) { - return cancelWizard(stdin, stdout, promptApi); + return cancelWizard(promptInput, promptOutput, promptApi); } const force = forceValue; @@ -614,17 +695,17 @@ async function runInstallWizard( `Force: ${force ? "yes" : "no"}`, ].join("\n"), "Install summary", - { input: stdin, output: stdout }, + { input: promptInput, output: promptOutput }, ); const confirmed = await promptApi.confirm({ message: "Proceed with install?", initialValue: true, - input: stdin, - output: stdout, + input: promptInput, + output: promptOutput, }); if (promptApi.isCancel(confirmed) || !confirmed) { - return cancelWizard(stdin, stdout, promptApi); + return cancelWizard(promptInput, promptOutput, promptApi); } return { @@ -636,8 +717,8 @@ async function runInstallWizard( async function runInstall( args: ResolvedInstallArgs, sources: PreparedInstallSource[], - stdin: Readable, - stdout: Writable, + promptInput: Readable, + promptOutput: Writable, cwd: string, useSpinner: boolean, promptApi: InstallPromptApi, @@ -663,7 +744,7 @@ async function runInstall( return execute(); } - const s = promptApi.spinner({ input: stdin, output: stdout }); + const s = promptApi.spinner({ input: promptInput, output: promptOutput }); s.start(`Installing ${plan.length} target${plan.length === 1 ? "" : "s"}...`); try { const result = await execute(); @@ -675,6 +756,17 @@ async function runInstall( } } +async function drainStream(stream: Readable): Promise { + try { + for await (const chunk of stream) { + // Drain source stdin so upstream writers do not hit EPIPE when we exit early. + void chunk; + } + } catch { + // Ignore drain failures; original command error should still be reported. + } +} + export async function runInstallCli( argv: string[], opts: InstallCliOptions = {}, @@ -684,6 +776,11 @@ export async function runInstallCli( const stdin = opts.stdin ?? process.stdin; const cwd = opts.cwd ?? process.cwd(); const promptApi = opts.promptApi ?? defaultPromptApi; + const openPromptTtyFn = opts.openPromptTty ?? openPromptTty; + + let promptInput = stdin; + let promptOutput = stdout; + let closePromptTty: (() => void) | null = null; try { const parsed = parseArgs(argv.slice(2)); @@ -700,7 +797,19 @@ export async function runInstallCli( let sources: PreparedInstallSource[]; if (parsed.agents.length === 0 || parsed.scopes.length === 0) { - if (!stdinIsTty(stdin)) { + if (stdinIsTty(stdin)) { + wizardUsed = true; + } else if (stdinIsPipe(stdin) && stdinHasData(stdin)) { + const promptTty = openPromptTtyFn(); + if (promptTty) { + wizardUsed = true; + promptInput = promptTty.input; + promptOutput = promptTty.output; + closePromptTty = promptTty.close; + } + } + + if (!wizardUsed) { resolvedArgs = validateRequiredFlags(parsed); sources = await resolveInstallSources( resolvedArgs.inputPaths, @@ -708,16 +817,19 @@ export async function runInstallCli( provided, ); } else { - wizardUsed = true; const wizardResult = await runInstallWizard( parsed, stdin, - stdout, + promptInput, + promptOutput, cwd, provided, promptApi, ); if (!wizardResult) { + if (stdinIsPipe(stdin)) { + await drainStream(stdin); + } return 1; } resolvedArgs = wizardResult.args; @@ -735,8 +847,8 @@ export async function runInstallCli( const results = await runInstall( resolvedArgs, sources, - stdin, - stdout, + promptInput, + promptOutput, cwd, wizardUsed, promptApi, @@ -748,11 +860,16 @@ export async function runInstallCli( ); } if (wizardUsed) { - promptApi.outro("Done.", { input: stdin, output: stdout }); + promptApi.outro("Done.", { input: promptInput, output: promptOutput }); } return 0; } catch (err) { + if (stdinIsPipe(stdin)) { + await drainStream(stdin); + } stderr.write(`${toErrorMessage(err)}\n`); return err instanceof InstallError ? err.exitCode : 1; + } finally { + closePromptTty?.(); } } diff --git a/test/integration/skill-install-cli.test.ts b/test/integration/skill-install-cli.test.ts index ccf7a4f..4c32e89 100644 --- a/test/integration/skill-install-cli.test.ts +++ b/test/integration/skill-install-cli.test.ts @@ -3,9 +3,11 @@ import assert from "node:assert/strict"; import path from "node:path"; import fs from "node:fs/promises"; import { execFileSync } from "node:child_process"; -import { Readable } from "node:stream"; +import { Readable, type Writable } from "node:stream"; +import type { Option } from "@clack/prompts"; import { runInstallCli, type InstallPromptApi } from "../../src/install/cli.js"; +import { collectSkillEntries, createTarStream } from "../../src/core/tar.js"; import { createCapture } from "../helpers/capture.js"; import { makeTempDir, writeFile } from "../helpers/tmp.js"; @@ -21,6 +23,46 @@ function createTtyStdin(): Readable { return stdin; } +function createPipeStdin(buffer: Buffer): Readable { + const stdin = Readable.from([buffer]); + (stdin as Readable & { isTTY?: boolean }).isTTY = false; + return stdin; +} + +function createCountingPipeStdin(totalBytes: number): { + stdin: Readable; + pushedBytes: () => number; +} { + let pushed = 0; + const stdin = new Readable({ + read() { + if (pushed >= totalBytes) { + this.push(null); + return; + } + const size = Math.min(16 * 1024, totalBytes - pushed); + pushed += size; + this.push(Buffer.alloc(size, 0x78)); + }, + }); + (stdin as Readable & { isTTY?: boolean }).isTTY = false; + return { + stdin, + pushedBytes: () => pushed, + }; +} + +async function bufferFromStream( + stream: NodeJS.ReadableStream, +): Promise { + const chunks: Buffer[] = []; + return new Promise((resolve, reject) => { + stream.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + stream.on("end", () => resolve(Buffer.concat(chunks))); + stream.on("error", reject); + }); +} + type PromptStubOptions = { textResponses?: Array; multiselectResponses?: Array; @@ -31,6 +73,8 @@ type PromptStub = { promptApi: InstallPromptApi; notes: string[]; outros: string[]; + promptInputs: Array; + promptOutputs: Array; }; function createPromptStub(options: PromptStubOptions = {}): PromptStub { @@ -39,35 +83,69 @@ function createPromptStub(options: PromptStubOptions = {}): PromptStub { const confirmResponses = [...(options.confirmResponses ?? [])]; const notes: string[] = []; const outros: string[] = []; + const promptInputs: Array = []; + const promptOutputs: Array = []; + + const trackIo = (opts?: { input?: Readable; output?: Writable }): void => { + promptInputs.push(opts?.input); + promptOutputs.push(opts?.output); + }; const promptApi: InstallPromptApi = { - confirm: async () => { + confirm: async (opts) => { + trackIo(opts); if (confirmResponses.length === 0) { throw new Error("No prompt stub response configured for confirm."); } return confirmResponses.shift() as boolean | symbol; }, - intro: () => {}, + intro: (_message, opts) => { + trackIo(opts); + }, isCancel: (value: unknown): value is symbol => value === PROMPT_CANCEL, - multiselect: async () => { + multiselect: async (opts: { + message: string; + options: Option[]; + initialValues?: Value[]; + required?: boolean; + input?: Readable; + output?: Writable; + }) => { + trackIo(opts); if (multiselectResponses.length === 0) { throw new Error("No prompt stub response configured for multiselect."); } const response = multiselectResponses.shift(); return response as Value[] | symbol; }, - note: (message?: string) => { + note: ( + message?: string, + _title?: string, + opts?: { + input?: Readable; + output?: Writable; + }, + ) => { + trackIo(opts); notes.push(message ?? ""); }, - outro: (message?: string) => { + outro: ( + message?: string, + opts?: { input?: Readable; output?: Writable }, + ) => { + trackIo(opts); outros.push(message ?? ""); }, - spinner: () => ({ - start: () => {}, - stop: () => {}, - error: () => {}, - }), - text: async () => { + spinner: (opts) => { + trackIo(opts); + return { + start: () => {}, + stop: () => {}, + error: () => {}, + }; + }, + text: async (opts) => { + trackIo(opts); if (textResponses.length === 0) { throw new Error("No prompt stub response configured for text."); } @@ -75,7 +153,7 @@ function createPromptStub(options: PromptStubOptions = {}): PromptStub { }, }; - return { promptApi, notes, outros }; + return { promptApi, notes, outros, promptInputs, promptOutputs }; } test("runInstallCli requires flags without an interactive tty", async () => { @@ -91,6 +169,117 @@ test("runInstallCli requires flags without an interactive tty", async () => { assert.match(stderr.text(), /skill-install \[PATH/); }); +test("runInstallCli uses tty prompts when stdin is piped and required flags are missing", async (t) => { + const repo = await makeTempDir("skill-install-cli-pipe-wizard-repo-"); + const skill = await makeTempDir("skill-install-cli-pipe-wizard-skill-"); + t.after(async () => { + await repo.cleanup(); + await skill.cleanup(); + }); + + initGit(repo.dir); + await writeFile( + skill.dir, + "SKILL.md", + "---\nname: cli-skill-pipe-wizard\ndescription: Pipe wizard skill\n---\n", + ); + await writeFile(skill.dir, "templates/example.txt", "hello\n"); + + const { entries } = await collectSkillEntries( + skill.dir, + "cli-skill-pipe-wizard", + ); + const tarBuffer = await bufferFromStream(createTarStream(entries)); + const promptStdin = createTtyStdin(); + const promptStdout = createCapture(); + const prompt = createPromptStub({ + multiselectResponses: [["codex"], ["repo"]], + confirmResponses: [false, true], + }); + const stderr = createCapture(); + + const exitCode = await runInstallCli(["node", "skill-install"], { + stdin: createPipeStdin(tarBuffer), + stderr: stderr.stream, + cwd: repo.dir, + promptApi: prompt.promptApi, + openPromptTty: () => ({ + input: promptStdin, + output: promptStdout.stream, + close: () => {}, + }), + }); + + assert.equal(exitCode, 0); + assert.match(stderr.text(), /Installed cli-skill-pipe-wizard to/); + await fs.access( + path.join(repo.dir, ".codex/skills/cli-skill-pipe-wizard/SKILL.md"), + ); + assert.ok(prompt.promptInputs.length > 0); + assert.ok( + prompt.promptInputs.every((input) => input === promptStdin), + "expected wizard prompts to use tty input", + ); + assert.ok( + prompt.promptOutputs.every((output) => output === promptStdout.stream), + "expected wizard prompts to use tty output", + ); +}); + +test("runInstallCli falls back to required flags and drains piped stdin when tty prompts are unavailable", async () => { + const stderr = createCapture(); + const source = createCountingPipeStdin(256 * 1024); + + const exitCode = await runInstallCli(["node", "skill-install"], { + stdin: source.stdin, + stderr: stderr.stream, + openPromptTty: () => null, + }); + + assert.equal(exitCode, 1); + assert.match(stderr.text(), /Missing required flags/); + assert.equal(source.pushedBytes(), 256 * 1024); +}); + +test("runInstallCli installs from piped tar with explicit flags non-interactively", async (t) => { + const repo = await makeTempDir("skill-install-cli-pipe-flags-repo-"); + const skill = await makeTempDir("skill-install-cli-pipe-flags-skill-"); + t.after(async () => { + await repo.cleanup(); + await skill.cleanup(); + }); + + initGit(repo.dir); + await writeFile( + skill.dir, + "SKILL.md", + "---\nname: cli-skill-pipe-flags\ndescription: Pipe flags skill\n---\n", + ); + await writeFile(skill.dir, "templates/example.txt", "hello\n"); + + const { entries } = await collectSkillEntries( + skill.dir, + "cli-skill-pipe-flags", + ); + const tarBuffer = await bufferFromStream(createTarStream(entries)); + + const stderr = createCapture(); + const exitCode = await runInstallCli( + ["node", "skill-install", "--agent", "codex", "--scope", "repo"], + { + stdin: createPipeStdin(tarBuffer), + stderr: stderr.stream, + cwd: repo.dir, + }, + ); + + assert.equal(exitCode, 0); + assert.match(stderr.text(), /Installed cli-skill-pipe-flags to/); + await fs.access( + path.join(repo.dir, ".codex/skills/cli-skill-pipe-flags/SKILL.md"), + ); +}); + test("runInstallCli keeps non-interactive install behavior with flags", async (t) => { const repo = await makeTempDir("skill-install-cli-repo-"); const skill = await makeTempDir("skill-install-cli-skill-"); From 826aec652bb08bba3a2af7a2983d494f25023f28 Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 20 Feb 2026 15:55:21 +0100 Subject: [PATCH 16/19] fix: interactive stdin + help for skill-install --- skills/skillflag/SKILL.md | 12 +++++ src/install/cli.ts | 55 +++++++++++++++++++--- test/integration/skill-install-cli.test.ts | 20 +++++++- 3 files changed, 78 insertions(+), 9 deletions(-) diff --git a/skills/skillflag/SKILL.md b/skills/skillflag/SKILL.md index 59bd7d9..4aa0076 100644 --- a/skills/skillflag/SKILL.md +++ b/skills/skillflag/SKILL.md @@ -47,6 +47,18 @@ Tar stream input (from producer): skillflag --skill export skillflag | skill-install --agent codex --scope repo ``` +Tar stream input with interactive target selection (when a TTY is available): + +``` +skillflag --skill export skillflag | skill-install +``` + +Show installer usage/help: + +``` +skill-install --help +``` + ## Notes - `skillflag` is the producer interface; it never installs. diff --git a/src/install/cli.ts b/src/install/cli.ts index c105e43..71b08bb 100644 --- a/src/install/cli.ts +++ b/src/install/cli.ts @@ -18,6 +18,7 @@ import { InstallError, toErrorMessage } from "./errors.js"; import { installSkill, type InstallInput } from "./install.js"; import { AGENTS, + SCOPES, assertAgent, assertScope, assertSupportedAgentScopes, @@ -79,6 +80,7 @@ type ParsedArgs = { agents: string[]; scopes: string[]; force: boolean; + help: boolean; }; type ResolvedInstallArgs = { @@ -124,12 +126,32 @@ type PromptTtyStreams = { close: () => void; }; +const agentList = AGENTS.join(", "); +const scopeList = SCOPES.join(", "); + const usageLines = [ "Usage:", - " skill-install [PATH ...] --agent [,...] [--agent [,...]] --scope [,...] [--scope [,...]] [--force]", - " skill-install --agent [,...] [--agent [,...]] --scope [,...] [--scope [,...]] [--force] < tar", + " 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.", + ` Supported agents: ${agentList}`, + " --scope Target scope(s); repeat or use comma-separated values.", + ` Supported scopes: ${scopeList}`, + " --force Overwrite destination if it already exists.", + " -h, --help Show this help.", + "", + "Behavior:", + " If --agent or --scope is missing and an interactive TTY is available,", + " the installer launches a wizard to collect missing values.", ]; +const usageText = usageLines.join("\n"); + const defaultPromptApi: InstallPromptApi = { confirm, intro, @@ -202,6 +224,7 @@ function parseArgs(args: string[]): ParsedArgs { const agents: string[] = []; const scopes: string[] = []; let force = false; + let help = false; for (let i = 0; i < rest.length; i += 1) { const arg = rest[i]; @@ -219,6 +242,10 @@ function parseArgs(args: string[]): ParsedArgs { force = true; continue; } + if (arg === "--help" || arg === "-h") { + help = true; + continue; + } if (arg.startsWith("-")) { throw new InstallError(`Unknown option: ${arg}`); } @@ -230,6 +257,7 @@ function parseArgs(args: string[]): ParsedArgs { agents: uniqueValues(agents), scopes: uniqueValues(scopes), force, + help, }; } @@ -245,7 +273,14 @@ function stdinIsTty(stream: Readable): boolean { } function stdinIsPipe(stream: Readable): boolean { - return (stream as { isTTY?: boolean }).isTTY === false; + const tty = (stream as { isTTY?: boolean }).isTTY; + if (tty === false) { + return true; + } + if (tty === true) { + return false; + } + return (stream as { fd?: unknown }).fd === 0; } function openPromptTty(): PromptTtyStreams | null { @@ -346,7 +381,7 @@ function asScopes(values: string[]): Scope[] { function validateRequiredFlags(parsed: ParsedArgs): ResolvedInstallArgs { if (parsed.agents.length === 0 || parsed.scopes.length === 0) { - throw new InstallError(`Missing required flags.\n${usageLines.join("\n")}`); + throw new InstallError(`Missing required flags.\n${usageText}`); } const agents = uniqueValues(parsed.agents.map((agent) => assertAgent(agent))); const scopes = uniqueValues(parsed.scopes.map((scope) => assertScope(scope))); @@ -505,9 +540,7 @@ async function resolveInstallSources( return [await prepareInstallSource({ kind: "tar", stream: stdin })]; } - throw new InstallError( - `Missing PATH or tar stream on stdin.\n${usageLines.join("\n")}`, - ); + throw new InstallError(`Missing PATH or tar stream on stdin.\n${usageText}`); } function buildInstallPlan( @@ -784,6 +817,14 @@ export async function runInstallCli( try { const parsed = parseArgs(argv.slice(2)); + if (parsed.help) { + stdout.write(`${usageText}\n`); + if (stdinIsPipe(stdin)) { + await drainStream(stdin); + } + return 0; + } + const provided = normalizeProvidedInputs(opts); if (provided.inputs.length > 0 && parsed.inputPaths.length > 0) { throw new InstallError( diff --git a/test/integration/skill-install-cli.test.ts b/test/integration/skill-install-cli.test.ts index 4c32e89..ed59364 100644 --- a/test/integration/skill-install-cli.test.ts +++ b/test/integration/skill-install-cli.test.ts @@ -25,7 +25,7 @@ function createTtyStdin(): Readable { function createPipeStdin(buffer: Buffer): Readable { const stdin = Readable.from([buffer]); - (stdin as Readable & { isTTY?: boolean }).isTTY = false; + (stdin as Readable & { fd?: number }).fd = 0; return stdin; } @@ -45,7 +45,7 @@ function createCountingPipeStdin(totalBytes: number): { this.push(Buffer.alloc(size, 0x78)); }, }); - (stdin as Readable & { isTTY?: boolean }).isTTY = false; + (stdin as Readable & { fd?: number }).fd = 0; return { stdin, pushedBytes: () => pushed, @@ -169,6 +169,22 @@ test("runInstallCli requires flags without an interactive tty", async () => { assert.match(stderr.text(), /skill-install \[PATH/); }); +test("runInstallCli supports --help", async () => { + const stdout = createCapture(); + const stderr = createCapture(); + + const exitCode = await runInstallCli(["node", "skill-install", "--help"], { + stdin: Readable.from([]), + stdout: stdout.stream, + stderr: stderr.stream, + }); + + assert.equal(exitCode, 0); + assert.match(stdout.text(), /Usage:/); + assert.match(stdout.text(), /--help/); + assert.equal(stderr.text(), ""); +}); + test("runInstallCli uses tty prompts when stdin is piped and required flags are missing", async (t) => { const repo = await makeTempDir("skill-install-cli-pipe-wizard-repo-"); const skill = await makeTempDir("skill-install-cli-pipe-wizard-skill-"); From 2bb692351284f0e003a5b2384bd85cbe5412dc8a Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 20 Feb 2026 16:07:47 +0100 Subject: [PATCH 17/19] fix: use tty streams for wizard --- src/install/cli.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/install/cli.ts b/src/install/cli.ts index 71b08bb..03f5e48 100644 --- a/src/install/cli.ts +++ b/src/install/cli.ts @@ -1,6 +1,10 @@ import process from "node:process"; import fs from "node:fs"; import path from "node:path"; +import { + ReadStream as TtyReadStream, + WriteStream as TtyWriteStream, +} from "node:tty"; import { Readable, Writable } from "node:stream"; import { confirm, @@ -293,14 +297,9 @@ function openPromptTty(): PromptTtyStreams | null { const promptInputFd = inputFd; const promptOutputFd = outputFd; - const input = fs.createReadStream("/dev/tty", { - fd: inputFd, - autoClose: false, - }); - const output = fs.createWriteStream("/dev/tty", { - fd: outputFd, - autoClose: false, - }); + // Use tty streams so prompt libraries can switch raw mode and parse arrows. + const input = new TtyReadStream(promptInputFd); + const output = new TtyWriteStream(promptOutputFd); let closed = false; return { From b175f818f0361a3c78992edaba475ebc312f3a82 Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 20 Feb 2026 16:43:13 +0100 Subject: [PATCH 18/19] fix: lazy-load clack to prevent tty interference in pipelines --- src/bin/skillflag.ts | 5 +- src/install/cli.ts | 17 +++++- src/skillflag.ts | 16 +++--- test/integration/skill-install-cli.test.ts | 67 ++++++++++++++++++++++ 4 files changed, 95 insertions(+), 10 deletions(-) diff --git a/src/bin/skillflag.ts b/src/bin/skillflag.ts index 8a36214..56b6311 100644 --- a/src/bin/skillflag.ts +++ b/src/bin/skillflag.ts @@ -3,12 +3,13 @@ import process from "node:process"; import { handleSkillflag } from "../skillflag.js"; import { defaultSkillsRoot } from "../core/paths.js"; -import { runInstallCli } from "../install/cli.js"; const cliArgs = process.argv.slice(2); const exitCode = cliArgs[0] === "install" - ? await runInstallCli([ + ? await ( + await import("../install/cli.js") + ).runInstallCli([ process.argv[0] ?? "node", "skill-install", ...cliArgs.slice(1), diff --git a/src/install/cli.ts b/src/install/cli.ts index 03f5e48..fc7a7a7 100644 --- a/src/install/cli.ts +++ b/src/install/cli.ts @@ -824,7 +824,7 @@ export async function runInstallCli( return 0; } - const provided = normalizeProvidedInputs(opts); + let provided = normalizeProvidedInputs(opts); if (provided.inputs.length > 0 && parsed.inputPaths.length > 0) { throw new InstallError( "PATH cannot be used when install input is preset.", @@ -857,6 +857,21 @@ export async function runInstallCli( provided, ); } else { + if ( + stdinIsPipe(stdin) && + stdinHasData(stdin) && + parsed.inputPaths.length === 0 && + provided.inputs.length === 0 + ) { + // Drain piped tar input before prompting so interactive key handling + // is not affected by an active upstream writer in the same pipeline. + const stdinTarBuffer = await streamToBuffer(stdin); + provided = { + inputs: [{ kind: "tar", stream: Readable.from(stdinTarBuffer) }], + skillIds: [], + }; + } + const wizardResult = await runInstallWizard( parsed, stdin, diff --git a/src/skillflag.ts b/src/skillflag.ts index ee4e637..dfccd65 100644 --- a/src/skillflag.ts +++ b/src/skillflag.ts @@ -1,6 +1,5 @@ import process from "node:process"; import type { Readable, Writable } from "node:stream"; -import { isCancel, multiselect } from "@clack/prompts"; import type { Option } from "@clack/prompts"; import { SkillflagError, toErrorMessage } from "./core/errors.js"; @@ -13,7 +12,6 @@ import { } from "./core/paths.js"; import { showSkill } from "./core/show.js"; import { collectSkillEntries, createTarStream } from "./core/tar.js"; -import { runInstallCli } from "./install/cli.js"; import { uniqueValues } from "./utils/collections.js"; export type SkillflagOptions = { @@ -84,10 +82,13 @@ export const SKILLFLAG_HELP_TEXT = [ "For full details, read docs/SKILLFLAG_SPEC.md.", ].join("\n"); -const defaultPromptApi: SkillflagPromptApi = { - multiselect, - isCancel, -}; +async function defaultPromptApi(): Promise { + const prompts = await import("@clack/prompts"); + return { + multiselect: prompts.multiselect, + isCancel: prompts.isCancel, + }; +} function resolveSkillActionArgs(argv: string[]): string[] { const cliArgs = argv.length > 2 ? argv.slice(2) : [...argv]; @@ -232,7 +233,7 @@ async function runInstallAction( stdout: NodeJS.WritableStream, stderr: NodeJS.WritableStream, ): Promise { - const promptApi = opts.promptApi ?? defaultPromptApi; + const promptApi = opts.promptApi ?? (await defaultPromptApi()); const skillIds = await resolveInstallSkillIds( action, rootDirs, @@ -249,6 +250,7 @@ async function runInstallAction( }), ); + const { runInstallCli } = await import("./install/cli.js"); return runInstallCli(["node", "skill-install", ...action.installArgs], { stdin: stdin as Readable, stdout: stdout as Writable, diff --git a/test/integration/skill-install-cli.test.ts b/test/integration/skill-install-cli.test.ts index ed59364..6471696 100644 --- a/test/integration/skill-install-cli.test.ts +++ b/test/integration/skill-install-cli.test.ts @@ -242,6 +242,73 @@ test("runInstallCli uses tty prompts when stdin is piped and required flags are ); }); +test("runInstallCli buffers piped stdin before interactive wizard prompts", async (t) => { + const repo = await makeTempDir("skill-install-cli-pipe-buffer-repo-"); + const skill = await makeTempDir("skill-install-cli-pipe-buffer-skill-"); + t.after(async () => { + await repo.cleanup(); + await skill.cleanup(); + }); + + initGit(repo.dir); + await writeFile( + skill.dir, + "SKILL.md", + "---\nname: cli-skill-pipe-buffer\ndescription: Pipe buffer skill\n---\n", + ); + await writeFile(skill.dir, "templates/example.txt", "hello\n"); + + const { entries } = await collectSkillEntries( + skill.dir, + "cli-skill-pipe-buffer", + ); + const tarBuffer = await bufferFromStream(createTarStream(entries)); + const sourceStdin = createPipeStdin(tarBuffer); + const promptStdin = createTtyStdin(); + const promptStdout = createCapture(); + const stderr = createCapture(); + + let observedSourceEndedAtFirstPrompt: boolean | undefined; + const multiselectResponses: Array = [["codex"], ["repo"]]; + const confirmResponses = [false, true]; + + const promptApi: InstallPromptApi = { + confirm: async () => confirmResponses.shift() as boolean, + intro: () => {}, + isCancel: (value: unknown): value is symbol => typeof value === "symbol", + multiselect: async () => { + if (observedSourceEndedAtFirstPrompt === undefined) { + observedSourceEndedAtFirstPrompt = sourceStdin.readableEnded; + } + return multiselectResponses.shift() as Value[]; + }, + note: () => {}, + outro: () => {}, + spinner: () => ({ + start: () => {}, + stop: () => {}, + error: () => {}, + }), + text: async () => "", + }; + + const exitCode = await runInstallCli(["node", "skill-install"], { + stdin: sourceStdin, + stderr: stderr.stream, + cwd: repo.dir, + promptApi, + openPromptTty: () => ({ + input: promptStdin, + output: promptStdout.stream, + close: () => {}, + }), + }); + + assert.equal(exitCode, 0); + assert.equal(observedSourceEndedAtFirstPrompt, true); + assert.match(stderr.text(), /Installed cli-skill-pipe-buffer to/); +}); + test("runInstallCli falls back to required flags and drains piped stdin when tty prompts are unavailable", async () => { const stderr = createCapture(); const source = createCountingPipeStdin(256 * 1024); From 6eba6df55480432464fb27fc6d1bc2f786937df5 Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 20 Feb 2026 16:55:21 +0100 Subject: [PATCH 19/19] feat: remove parent and admin scope options --- docs/SKILLFLAG_SPEC.md | 9 ++------- src/install/cli.ts | 2 -- src/install/resolve.ts | 4 +--- test/integration/skill-install-cli.test.ts | 12 ++---------- test/integration/skill-install-resolve.test.ts | 8 +------- 5 files changed, 6 insertions(+), 29 deletions(-) diff --git a/docs/SKILLFLAG_SPEC.md b/docs/SKILLFLAG_SPEC.md index cb26f61..952e662 100644 --- a/docs/SKILLFLAG_SPEC.md +++ b/docs/SKILLFLAG_SPEC.md @@ -372,7 +372,7 @@ producer --skill export tmux | skill-install --agent claude --scope user ```bash skill-install [PATH ...] --agent - --scope + --scope [--root ] [--mode ] [--force] @@ -477,18 +477,13 @@ OpenCode documents these locations (plus Claude-compatible ones it also searches ### 7.3 `--agent codex` (OpenAI Codex CLI / IDE) -Codex documents multiple repo layers and user/admin/system scopes. ([OpenAI Developers][1]) +Codex documents repo and user-level scopes. ([OpenAI Developers][1]) Default mapping: - `repo` → `/.codex/skills//` - `user` → `${CODEX_HOME:-~/.codex}/skills//` -- `admin` → `/etc/codex/skills//` (if supported/allowed) - -Optional advanced repo scopes (because Codex distinguishes them): - - `cwd` → `$PWD/.codex/skills//` -- `parent` → `$PWD/../.codex/skills//` ([OpenAI Developers][1]) ### 7.4 `--agent claude` (Claude Code) diff --git a/src/install/cli.ts b/src/install/cli.ts index fc7a7a7..c56e482 100644 --- a/src/install/cli.ts +++ b/src/install/cli.ts @@ -189,9 +189,7 @@ const agentOptions: Option[] = AGENTS.map((agent) => ({ const scopeDescriptions: Record = { repo: "Install to the current git repo root.", user: "Install to your user-level skills directory.", - admin: "Install system-wide under /etc (typically needs sudo).", cwd: "Install relative to the current working directory.", - parent: "Install relative to the parent of the current directory.", }; function parseScopeValues(value: string | undefined): string[] { diff --git a/src/install/resolve.ts b/src/install/resolve.ts index 8522281..5a9cec6 100644 --- a/src/install/resolve.ts +++ b/src/install/resolve.ts @@ -18,7 +18,7 @@ export const AGENTS = [ "cursor", ] as const; -export const SCOPES = ["repo", "user", "admin", "cwd", "parent"] as const; +export const SCOPES = ["repo", "user", "cwd"] as const; export type Agent = (typeof AGENTS)[number]; export type Scope = (typeof SCOPES)[number]; @@ -46,12 +46,10 @@ const scopeResolversByAgent: Record = { codex: { repo: (cwd) => path.join(resolveRepoRoot(cwd), ".codex/skills"), cwd: (cwd) => path.join(cwd, ".codex/skills"), - parent: (cwd) => path.join(path.resolve(cwd, ".."), ".codex/skills"), user: () => { const root = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex"); return path.join(root, "skills"); }, - admin: () => "/etc/codex/skills", }, claude: { repo: (cwd) => path.join(resolveRepoRoot(cwd), ".claude/skills"), diff --git a/test/integration/skill-install-cli.test.ts b/test/integration/skill-install-cli.test.ts index 6471696..8221442 100644 --- a/test/integration/skill-install-cli.test.ts +++ b/test/integration/skill-install-cli.test.ts @@ -637,15 +637,7 @@ test("runInstallCli fails for unsupported agent/scope combinations", async (t) = const stderr = createCapture(); const exitCode = await runInstallCli( - [ - "node", - "skill-install", - skill.dir, - "--agent", - "claude", - "--scope", - "admin", - ], + ["node", "skill-install", skill.dir, "--agent", "claude", "--scope", "cwd"], { stdin: Readable.from([]), stderr: stderr.stream, @@ -654,7 +646,7 @@ test("runInstallCli fails for unsupported agent/scope combinations", async (t) = ); assert.equal(exitCode, 1); - assert.match(stderr.text(), /Unsupported agent\/scope: claude admin/); + assert.match(stderr.text(), /Unsupported agent\/scope: claude cwd/); }); test("runInstallCli detects collisions for different sources with the same skill name", async (t) => { diff --git a/test/integration/skill-install-resolve.test.ts b/test/integration/skill-install-resolve.test.ts index 55cec7d..0de9eae 100644 --- a/test/integration/skill-install-resolve.test.ts +++ b/test/integration/skill-install-resolve.test.ts @@ -18,7 +18,7 @@ test("resolve portable repo/user roots", () => { process.env.XDG_CONFIG_HOME = prevXdg; }); -test("resolve codex repo/user/cwd/parent/admin roots", () => { +test("resolve codex repo/user/cwd roots", () => { const repoRoot = resolveSkillsRoot("codex", "repo", cwd); assert.equal(repoRoot, path.join(cwd, ".codex/skills")); @@ -27,12 +27,6 @@ test("resolve codex repo/user/cwd/parent/admin roots", () => { const cwdRoot = resolveSkillsRoot("codex", "cwd", cwd); assert.equal(cwdRoot, path.join(cwd, ".codex/skills")); - - const parentRoot = resolveSkillsRoot("codex", "parent", cwd); - assert.equal(parentRoot, path.join(path.resolve(cwd, ".."), ".codex/skills")); - - const adminRoot = resolveSkillsRoot("codex", "admin", cwd); - assert.equal(adminRoot, "/etc/codex/skills"); }); test("resolve claude repo/user roots", () => {