diff --git a/docs/SKILLFLAG_SPEC.md b/docs/SKILLFLAG_SPEC.md index 8ae7763..952e662 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`). @@ -79,15 +81,33 @@ 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 []` + +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. + +When invoked with all required arguments, it **MUST** behave equivalently to: + +```bash +tool --skill export | skill-install --agent --scope +``` + +## 7. Discovery: `--skill list` -### 6.1 Behavior +### 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 +123,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 +162,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 +171,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 +195,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 +207,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 +227,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,18 +237,11 @@ Skill IDs **SHOULD** be: Rationale: IDs appear in shell scripts and filesystem paths. -## 11. Metadata (optional, minimal) - -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: +## 12. Metadata -- **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. -## 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 +254,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 +273,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 +308,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). @@ -312,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 @@ -328,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: @@ -355,9 +370,9 @@ producer --skill export tmux | skill-install --agent claude --scope user ### 2.1 Synopsis ```bash -skill-install [PATH] +skill-install [PATH ...] --agent - --scope + --scope [--root ] [--mode ] [--force] @@ -369,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. @@ -383,11 +400,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) @@ -444,6 +457,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) @@ -462,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/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/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/bin/skillflag.ts b/src/bin/skillflag.ts index e61ab07..56b6311 100644 --- a/src/bin/skillflag.ts +++ b/src/bin/skillflag.ts @@ -1,21 +1,20 @@ #!/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 cliArgs = process.argv.slice(2); +const exitCode = + cliArgs[0] === "install" + ? await ( + await import("../install/cli.js") + ).runInstallCli([ + process.argv[0] ?? "node", + "skill-install", + ...cliArgs.slice(1), + ]) + : await handleSkillflag(process.argv, { + skillsRoot: defaultSkillsRoot(), + }); +process.exitCode = exitCode; 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/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 631252c..c56e482 100644 --- a/src/install/cli.ts +++ b/src/install/cli.ts @@ -1,46 +1,242 @@ import process from "node:process"; import fs from "node:fs"; -import { Readable } from "node:stream"; +import path from "node:path"; +import { + ReadStream as TtyReadStream, + WriteStream as TtyWriteStream, +} from "node:tty"; +import { Readable, Writable } from "node:stream"; +import { + confirm, + intro, + isCancel, + multiselect, + note, + outro, + 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 { installSkill, type InstallInput } from "./install.js"; +import { + AGENTS, + SCOPES, + assertAgent, + assertScope, + 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; - stdout?: NodeJS.WritableStream; - stderr?: NodeJS.WritableStream; + stdout?: Writable; + stderr?: Writable; cwd?: string; + openPromptTty?: () => PromptTtyStreams | null; + providedInput?: InstallInput; + 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 = { - inputPath?: string; - agent: string; - scope: string; + inputPaths: string[]; + agents: string[]; + scopes: string[]; + force: boolean; + help: boolean; +}; + +type ResolvedInstallArgs = { + inputPaths: string[]; + agents: Agent[]; + scopes: Scope[]; force: boolean; }; +type ProvidedInstallInputs = { + inputs: InstallInput[]; + skillIds: string[]; +}; + +type PreparedInstallSource = { + source: string; + skillIdHint: string; + makeInput: () => InstallInput; +}; + +type InstallPlanItem = { + source: PreparedInstallSource; + agent: Agent; + scope: Scope; + destination: string; +}; + +type InstallExecutionResult = { + skillId: string; + agent: Agent; + installedTo: string; + scope: Scope; +}; + +type WizardResult = { + args: ResolvedInstallArgs; + sources: PreparedInstallSource[]; +}; + +type PromptTtyStreams = { + input: Readable; + output: Writable; + close: () => void; +}; + +const agentList = AGENTS.join(", "); +const scopeList = SCOPES.join(", "); + const usageLines = [ "Usage:", - " skill-install [PATH] --agent --scope ", - " skill-install --agent --scope < 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, + 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.", + user: "Install to your user-level skills directory.", + cwd: "Install relative to the current working directory.", +}; + +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 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]; - let inputPath: string | undefined; - let agent: string | undefined; - let scope: string | undefined; + const inputPaths: string[] = []; + 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]; if (arg === "--agent") { - agent = rest[i + 1]; + agents.push(...parseAgentValues(rest[i + 1])); i += 1; continue; } if (arg === "--scope") { - scope = rest[i + 1]; + scopes.push(...parseScopeValues(rest[i + 1])); i += 1; continue; } @@ -48,21 +244,23 @@ 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}`); } - if (!inputPath) { - inputPath = arg; - continue; - } - throw new InstallError(`Unexpected argument: ${arg}`); - } - - if (!agent || !scope) { - throw new InstallError(`Missing required flags.\n${usageLines.join("\n")}`); + inputPaths.push(arg); } - return { inputPath, agent, scope, force }; + return { + inputPaths, + agents: uniqueValues(agents), + scopes: uniqueValues(scopes), + force, + help, + }; } function stdinHasData(stream: Readable): boolean { @@ -72,46 +270,659 @@ function stdinHasData(stream: Readable): boolean { return !stream.readableEnded; } +function stdinIsTty(stream: Readable): boolean { + return (stream as { isTTY?: boolean }).isTTY === true; +} + +function stdinIsPipe(stream: Readable): boolean { + 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 { + 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; + + // 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 { + 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 { + return assertAgent(value); + } catch { + return 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 { + return assertScope(value); + } catch { + return undefined; + } +} + +function asScopes(values: string[]): Scope[] { + return uniqueValues( + values + .map((value) => asScope(value)) + .filter((value): value is Scope => value !== undefined), + ); +} + +function validateRequiredFlags(parsed: ParsedArgs): ResolvedInstallArgs { + if (parsed.agents.length === 0 || parsed.scopes.length === 0) { + 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))); + assertSupportedAgentScopes(agents, scopes); + return { + inputPaths: parsed.inputPaths, + agents, + scopes, + force: parsed.force, + }; +} + +function normalizeProvidedInputs( + opts: InstallCliOptions, +): ProvidedInstallInputs { + if (opts.providedInput && opts.providedInputs?.length) { + throw new InstallError( + "providedInput and providedInputs cannot be used together.", + ); + } + if (opts.providedSkillId && opts.providedSkillIds?.length) { + throw new InstallError( + "providedSkillId and providedSkillIds cannot be used together.", + ); + } + + 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 (skillIds.length > 0 && skillIds.length !== inputs.length) { + throw new InstallError( + "Preset skill id count must match preset install input count.", + ); + } + + 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( + promptInput: Readable, + promptOutput: Writable, + promptApi: InstallPromptApi, + message = "Install cancelled.", +): null { + promptApi.outro(message, { input: promptInput, output: promptOutput }); + return null; +} + +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()) { + throw new InstallError("PATH must be a directory containing SKILL.md."); + } + + await assertSkillDir(sourceDir); + const meta = await readSkillMetadata(sourceDir); + return { + source: sourceDir, + skillIdHint: meta.name, + makeInput: () => ({ kind: "dir", dir: sourceDir }), + }; + } + + 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${usageText}`); +} + +function buildInstallPlan( + sources: PreparedInstallSource[], + agents: Agent[], + scopes: Scope[], + cwd: string, +): InstallPlanItem[] { + const plan: InstallPlanItem[] = []; + for (const source of sources) { + 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 assertNoInstallCollisions(plan: InstallPlanItem[]): void { + const plansByDestination = new Map(); + for (const item of plan) { + const entries = plansByDestination.get(item.destination) ?? []; + entries.push(item); + plansByDestination.set(item.destination, entries); + } + + 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( + parsed: ParsedArgs, + stdin: Readable, + promptInput: Readable, + promptOutput: Writable, + cwd: string, + provided: ProvidedInstallInputs, + promptApi: InstallPromptApi, +): Promise { + promptApi.intro("skill-install wizard", { + input: promptInput, + output: promptOutput, + }); + + let inputPaths = parsed.inputPaths; + if ( + provided.inputs.length === 0 && + inputPaths.length === 0 && + !stdinHasData(stdin) + ) { + const defaultPath = cwd; + const pathValue = await promptApi.text({ + message: + "PATH to skill directory (comma-separated for multiple, defaults to current directory)", + placeholder: defaultPath, + defaultValue: defaultPath, + validate: validatePathPrompt, + input: promptInput, + output: promptOutput, + }); + if (promptApi.isCancel(pathValue)) { + return cancelWizard(promptInput, promptOutput, promptApi); + } + inputPaths = parsePathList(pathValue.trim() || defaultPath); + } + + 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 promptApi.multiselect({ + message: "Agent targets", + options: agentOptions, + initialValues: parsedAgents.length > 0 ? parsedAgents : [], + required: true, + input: promptInput, + output: promptOutput, + }); + if (promptApi.isCancel(agentValues)) { + return cancelWizard(promptInput, promptOutput, promptApi); + } + agents = uniqueValues(agentValues.map((value) => assertAgent(value))); + } + + const supportedScopes = sharedScopesForAgents(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), + ); + + 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 promptApi.multiselect({ + message: "Scope targets", + options: scopeOptions, + initialValues: parsedScopes.length > 0 ? parsedScopes : [], + required: true, + input: promptInput, + output: promptOutput, + }); + if (promptApi.isCancel(scopeValues)) { + return cancelWizard(promptInput, promptOutput, promptApi); + } + scopes = uniqueValues(scopeValues.map((scope) => assertScope(scope))); + } + + const forceValue = await promptApi.confirm({ + message: "Force overwrite if the destination already exists? (--force)", + initialValue: parsed.force, + input: promptInput, + output: promptOutput, + }); + if (promptApi.isCancel(forceValue)) { + return cancelWizard(promptInput, promptOutput, promptApi); + } + const force = forceValue; + + assertSupportedAgentScopes(agents, scopes); + + const sources = await resolveInstallSources(inputPaths, stdin, provided); + const plan = buildInstallPlan(sources, agents, scopes, cwd); + assertNoInstallCollisions(plan); + + const sourceLines = sources.map( + (source) => `${source.skillIdHint} <= ${source.source}`, + ); + const installLines = plan.map( + (item) => + `${item.source.skillIdHint} @ ${item.agent}/${item.scope} -> ${item.destination}`, + ); + + 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: ${plan.length}`, + `Planned combinations (${plan.length}):`, + ...installLines, + `Force: ${force ? "yes" : "no"}`, + ].join("\n"), + "Install summary", + { input: promptInput, output: promptOutput }, + ); + + const confirmed = await promptApi.confirm({ + message: "Proceed with install?", + initialValue: true, + input: promptInput, + output: promptOutput, + }); + if (promptApi.isCancel(confirmed) || !confirmed) { + return cancelWizard(promptInput, promptOutput, promptApi); + } + + return { + args: { inputPaths, agents, scopes, force }, + sources, + }; +} + +async function runInstall( + args: ResolvedInstallArgs, + sources: PreparedInstallSource[], + promptInput: Readable, + promptOutput: Writable, + cwd: string, + useSpinner: boolean, + promptApi: InstallPromptApi, +): Promise { + const plan = buildInstallPlan(sources, args.agents, args.scopes, cwd); + assertNoInstallCollisions(plan); + + const execute = async (): Promise => { + const results: InstallExecutionResult[] = []; + for (const item of plan) { + const result = await installSkill(item.source.makeInput(), { + agent: item.agent, + scope: item.scope, + cwd, + force: args.force, + }); + results.push({ ...result, agent: item.agent, scope: item.scope }); + } + return results; + }; + + if (!useSpinner) { + return execute(); + } + + const s = promptApi.spinner({ input: promptInput, output: promptOutput }); + s.start(`Installing ${plan.length} target${plan.length === 1 ? "" : "s"}...`); + try { + const result = await execute(); + s.stop("Install complete."); + return result; + } catch (err) { + s.error("Install failed."); + throw err; + } +} + +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 = {}, ): 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(); + 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)); - const agent = assertAgent(parsed.agent); - const scope = assertScope(parsed.scope); + if (parsed.help) { + stdout.write(`${usageText}\n`); + if (stdinIsPipe(stdin)) { + await drainStream(stdin); + } + return 0; + } - let input: { kind: "dir"; dir: string } | { kind: "tar"; stream: Readable }; + 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.", + ); + } - if (parsed.inputPath) { - const stat = fs.statSync(parsed.inputPath); - if (!stat.isDirectory()) { - throw new InstallError("PATH must be a directory containing SKILL.md."); + let wizardUsed = false; + + let resolvedArgs: ResolvedInstallArgs; + let sources: PreparedInstallSource[]; + + if (parsed.agents.length === 0 || parsed.scopes.length === 0) { + 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, + stdin, + 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, + promptInput, + promptOutput, + cwd, + provided, + promptApi, + ); + if (!wizardResult) { + if (stdinIsPipe(stdin)) { + await drainStream(stdin); + } + return 1; + } + resolvedArgs = wizardResult.args; + sources = wizardResult.sources; } - 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")}`, + resolvedArgs = validateRequiredFlags(parsed); + sources = await resolveInstallSources( + resolvedArgs.inputPaths, + stdin, + provided, ); } - const result = await installSkill(input, { - agent, - scope, + const results = await runInstall( + resolvedArgs, + sources, + promptInput, + promptOutput, cwd, - force: parsed.force, - }); + wizardUsed, + promptApi, + ); - stderr.write(`Installed ${result.skillId} to ${result.installedTo}\n`); + for (const result of results) { + stderr.write( + `Installed ${result.skillId} to ${result.installedTo} (${result.agent}/${result.scope})\n`, + ); + } + if (wizardUsed) { + 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/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..5a9cec6 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", "cwd"] 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,108 @@ 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"), + user: () => { const root = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex"); return path.join(root, "skills"); - } - if (scope === "admin") { - return "/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 61615f6..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,29 +18,6 @@ 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 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 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(); - if (key && value) { - fields[key] = value; - } - } - return fields; -} - export async function readSkillMetadata( rootDir: string, ): Promise { @@ -50,10 +28,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 }; 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/src/skillflag.ts b/src/skillflag.ts new file mode 100644 index 0000000..dfccd65 --- /dev/null +++ b/src/skillflag.ts @@ -0,0 +1,344 @@ +import process from "node:process"; +import type { Readable, Writable } from "node:stream"; +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 { uniqueValues } from "./utils/collections.js"; + +export type SkillflagOptions = { + skillsRoot: URL | string; + stdin?: NodeJS.ReadableStream; + stdout?: NodeJS.WritableStream; + stderr?: NodeJS.WritableStream; + cwd?: string; + includeBundledSkill?: boolean; + promptApi?: SkillflagPromptApi; +}; + +export type SkillflagDispatchOptions = SkillflagOptions & { + exit?: ((code: number) => void) | false; +}; + +type SkillAction = + | { kind: "install"; ids?: string[]; installArgs: string[] } + | { kind: "list"; json: boolean } + | { kind: "export"; id: string } + | { 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]", + " --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 one or more agents:", + " tool --skill install [ ...]", + " tool --skill export | skill-install --agent [--agent ] --scope [--scope ]", + "", + "For full details, read docs/SKILLFLAG_SPEC.md.", +].join("\n"); + +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]; + const skillIndex = cliArgs.indexOf("--skill"); + if (skillIndex >= 0) { + return cliArgs.slice(skillIndex + 1); + } + return cliArgs; +} + +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), + }; +} + +/** + * 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 = resolveSkillActionArgs(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); + const parsed = parseInstallIds(rest); + return { + kind: "install", + ids: parsed.ids, + installArgs: parsed.installArgs, + }; + } + + 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 resolveInstallSkillIds( + action: { ids?: string[] }, + rootDirs: string[], + stdin: NodeJS.ReadableStream, + stdout: NodeJS.WritableStream, + promptApi: SkillflagPromptApi, +): Promise { + if (action.ids && action.ids.length > 0) { + return action.ids; + } + + 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 one or more ids with --skill install [...].", + ); + } + + const options: Option[] = skills.map((skill) => ({ + value: skill.id, + label: skill.id, + hint: skill.summary, + })); + const selected = await promptApi.multiselect({ + message: "Select skills to install", + options, + required: true, + input: stdin as Readable, + output: stdout as Writable, + }); + if (promptApi.isCancel(selected)) { + throw new SkillflagError("Install cancelled."); + } + return uniqueValues(selected); +} + +async function runInstallAction( + action: { ids?: string[]; installArgs: string[] }, + rootDirs: string[], + opts: SkillflagOptions, + stdin: NodeJS.ReadableStream, + stdout: NodeJS.WritableStream, + stderr: NodeJS.WritableStream, +): Promise { + const promptApi = opts.promptApi ?? (await defaultPromptApi()); + const skillIds = await resolveInstallSkillIds( + action, + rootDirs, + stdin, + stdout, + promptApi, + ); + + 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) }; + }), + ); + + const { runInstallCli } = await import("./install/cli.js"); + return runInstallCli(["node", "skill-install", ...action.installArgs], { + stdin: stdin as Readable, + stdout: stdout as Writable, + stderr: stderr as Writable, + cwd: opts.cwd, + providedInputs: inputs, + providedSkillIds: skillIds, + }); +} + +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 await 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/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/skill-install-cli.test.ts b/test/integration/skill-install-cli.test.ts new file mode 100644 index 0000000..8221442 --- /dev/null +++ b/test/integration/skill-install-cli.test.ts @@ -0,0 +1,845 @@ +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, 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"; + +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; +} + +function createPipeStdin(buffer: Buffer): Readable { + const stdin = Readable.from([buffer]); + (stdin as Readable & { fd?: number }).fd = 0; + 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 & { fd?: number }).fd = 0; + 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; + confirmResponses?: Array; +}; + +type PromptStub = { + promptApi: InstallPromptApi; + notes: string[]; + outros: string[]; + promptInputs: Array; + promptOutputs: Array; +}; + +function createPromptStub(options: PromptStubOptions = {}): PromptStub { + const textResponses = [...(options.textResponses ?? [])]; + const multiselectResponses = [...(options.multiselectResponses ?? [])]; + 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 (opts) => { + trackIo(opts); + if (confirmResponses.length === 0) { + throw new Error("No prompt stub response configured for confirm."); + } + return confirmResponses.shift() as boolean | symbol; + }, + intro: (_message, opts) => { + trackIo(opts); + }, + isCancel: (value: unknown): value is symbol => value === PROMPT_CANCEL, + 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, + _title?: string, + opts?: { + input?: Readable; + output?: Writable; + }, + ) => { + trackIo(opts); + notes.push(message ?? ""); + }, + outro: ( + message?: string, + opts?: { input?: Readable; output?: Writable }, + ) => { + trackIo(opts); + outros.push(message ?? ""); + }, + 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."); + } + return textResponses.shift() as string | symbol; + }, + }; + + return { promptApi, notes, outros, promptInputs, promptOutputs }; +} + +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 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-"); + 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 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); + + 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-"); + 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/); +}); + +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")); +}); + +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 fails preflight when selected agents/scopes collide on destination", async (t) => { + const repo = await makeTempDir("skill-install-cli-shared-dest-repo-"); + const skill = await makeTempDir("skill-install-cli-shared-dest-skill-"); + t.after(async () => { + await repo.cleanup(); + await skill.cleanup(); + }); + + initGit(repo.dir); + + await writeFile( + skill.dir, + "SKILL.md", + "---\nname: cli-skill-shared\ndescription: CLI shared destination test skill\n---\n", + ); + + const stderr = createCapture(); + const 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, 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", "cwd"], + { + stdin: Readable.from([]), + stderr: stderr.stream, + cwd: repo.dir, + }, + ); + + assert.equal(exitCode, 1); + assert.match(stderr.text(), /Unsupported agent\/scope: claude cwd/); +}); + +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\):/); +}); 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", () => { diff --git a/test/integration/skillflag.test.ts b/test/integration/skillflag.test.ts index f6864f4..a79cb5f 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, spawnSync } from "node:child_process"; +import { Readable } from "node:stream"; import * as tar from "tar-stream"; import { fileURLToPath } from "node:url"; @@ -10,12 +12,23 @@ import { handleSkillflag, maybeHandleSkillflag, SKILLFLAG_HELP_TEXT, -} from "../../src/core/skillflag.js"; -import { findSkillsRoot } from "../../src/core/paths.js"; + 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 }); +} function sha256(buffer: Buffer): string { const hash = createHash("sha256"); @@ -23,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[] = []; @@ -312,3 +342,351 @@ 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 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 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(); + + 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-"); + 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/); +}); + +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"), + ); +});