diff --git a/README.md b/README.md index 7945c6c..69629bf 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,9 @@ Show scan results for a remote tag, branch, or commit: agent-installer scan https://github.com/org/agents.git --ref v1.2.0 ``` +Options such as `--ref` and `--skill-max-depth` belong after the subcommand name. An option placed before a +subcommand (`agent-installer --ref v1.2.0 scan ...`) is rejected with an error rather than silently ignored. + Scan deeper nested skill catalogs: ```bash diff --git a/package.json b/package.json index 7c6741f..ed1d366 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "@types/node": "^24.0.13", "tsup": "^8.5.0", "tsx": "^4.20.3", - "typescript": "^5.8.3", + "typescript": "^7.0.2", "vitest": "^5.0.0" }, "packageManager": "pnpm@10.32.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a158b2a..963e24e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,20 +22,20 @@ importers: version: 2.9.0 zod: specifier: ^4.0.5 - version: 4.5.4 + version: 4.6.1 devDependencies: '@types/node': specifier: ^24.0.13 version: 24.13.2 tsup: specifier: ^8.5.0 - version: 8.5.1(postcss@8.5.28)(tsx@4.23.13)(typescript@5.9.3)(yaml@2.9.0) + version: 8.5.1(postcss@8.5.28)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0) tsx: specifier: ^4.20.3 version: 4.23.13 typescript: - specifier: ^5.8.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 vitest: specifier: ^5.0.0 version: 5.0.0(@types/node@24.13.2)(vite@7.3.6(@types/node@24.13.2)(tsx@4.23.13)(yaml@2.9.0)) @@ -796,6 +796,126 @@ packages: '@types/node@24.13.2': resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + '@vitest/mocker@5.0.0': resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==} peerDependencies: @@ -1107,9 +1227,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} hasBin: true ufo@1.6.4: @@ -1209,8 +1329,8 @@ packages: engines: {node: '>= 14.6'} hasBin: true - zod@4.5.4: - resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} + zod@4.6.1: + resolution: {integrity: sha512-341aRWQsve0rvronKNTqZpjmzdbUDlFuzHaI/XLg/Ej82qffDJRRfBTCuv7+9q/rMjB6LSLyEBnW4InJeMtt/Q==} snapshots: @@ -1669,6 +1789,66 @@ snapshots: dependencies: undici-types: 7.18.2 + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + '@vitest/mocker@5.0.0(vite@7.3.6(@types/node@24.13.2)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -1989,7 +2169,7 @@ snapshots: ts-interface-checker@0.1.13: {} - tsup@8.5.1(postcss@8.5.28)(tsx@4.23.13)(typescript@5.9.3)(yaml@2.9.0): + tsup@8.5.1(postcss@8.5.28)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 @@ -2010,7 +2190,7 @@ snapshots: tree-kill: 1.2.2 optionalDependencies: postcss: 8.5.28 - typescript: 5.9.3 + typescript: 7.0.2 transitivePeerDependencies: - jiti - supports-color @@ -2023,7 +2203,28 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - typescript@5.9.3: {} + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 ufo@1.6.4: {} @@ -2071,4 +2272,4 @@ snapshots: yaml@2.9.0: {} - zod@4.5.4: {} + zod@4.6.1: {} diff --git a/src/cli.ts b/src/cli.ts index d96a249..9fdd32d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -145,6 +145,10 @@ async function runInteractive(inputPath?: string, scanOptions?: ScanSourceOption function createProgram(): Command { const program = new Command(); + // The root command and the scan/install subcommands share --ref and --skill-max-depth. Without positional + // options, Commander hands a shared option to the root even when it follows the subcommand name, so the + // subcommand would silently run without it. + program.enablePositionalOptions(); addRefOption(addSkillMaxDepthOption(program)) .name("agent-installer") .description("Install Codex skills and Claude Code skills and commands from a local or HTTPS Git repository.") @@ -159,6 +163,25 @@ function createProgram(): Command { await runInteractive(inputPath, scanOptionsFromCommand(options), options.listLength, options.ref); }); + // Root-level options only configure interactive mode; reject them ahead of a subcommand instead of dropping + // them silently. + program.hook("preSubcommand", (thisCommand, subcommand) => { + const misplaced = thisCommand.options.filter( + (option) => thisCommand.getOptionValueSource(option.attributeName()) === "cli" + ); + if (misplaced.length === 0) { + return; + } + + const flags = misplaced.map((option) => option.long ?? option.flags); + const accepted = flags.filter((flag) => subcommand.options.some((option) => option.long === flag)); + const hint = + accepted.length > 0 + ? `; place ${accepted.join(" and ")} after the subcommand name, for example: agent-installer ${subcommand.name()} ${accepted[0]} ` + : ""; + thisCommand.error(`error: ${flags.join(" and ")} cannot precede the ${subcommand.name()} subcommand${hint}`); + }); + addJsonOption( addRefOption(addSkillMaxDepthOption(program.command("scan"))), "Emit a machine-readable JSON report on stdout instead of human-readable output" diff --git a/tests/cli-json.test.ts b/tests/cli-json.test.ts index f9c9c75..d433df4 100644 --- a/tests/cli-json.test.ts +++ b/tests/cli-json.test.ts @@ -18,10 +18,10 @@ interface CliResult { exitCode: number; } -async function runCli(args: string[], home: string): Promise { +async function runCli(args: string[], home: string, extraEnv: NodeJS.ProcessEnv = {}): Promise { try { const { stdout, stderr } = await execFileAsync(TSX_BIN, [CLI_ENTRY, ...args], { - env: { ...process.env, HOME: home } + env: { ...process.env, HOME: home, ...extraEnv } }); return { stdout, stderr, exitCode: 0 }; } catch (error) { @@ -461,3 +461,133 @@ describe("sync --json", () => { ); }); }); + +const REMOTE_URL = "https://git.example.test/owner/skills"; +const GIT_IDENTITY = ["-c", "user.name=Test", "-c", "user.email=test@example.test"]; + +async function git(args: string[], cwd: string): Promise { + const { stdout } = await execFileAsync("git", args, { cwd }); + return stdout.trim(); +} + +/** + * Builds a local Git repository with two commits and returns environment + * variables that make `git` resolve REMOTE_URL to it, so the CLI exercises its + * real HTTPS clone/fetch path without network access. + */ +async function makeRemoteRepo(): Promise<{ env: NodeJS.ProcessEnv; firstCommit: string }> { + const repo = await makeRepo(); + await git(["init", "--quiet", "--initial-branch", "main"], repo); + await git(["add", "."], repo); + await git([...GIT_IDENTITY, "commit", "--quiet", "-m", "first"], repo); + const firstCommit = await git(["rev-parse", "HEAD"], repo); + await fs.writeFile(path.join(repo, "skills", "review", "SKILL.md"), "# Review v2\n", "utf8"); + await git([...GIT_IDENTITY, "commit", "--quiet", "-am", "second"], repo); + + return { + env: { + GIT_CONFIG_COUNT: "2", + GIT_CONFIG_KEY_0: `url.file://${repo}.insteadOf`, + GIT_CONFIG_VALUE_0: REMOTE_URL, + GIT_CONFIG_KEY_1: "protocol.file.allow", + GIT_CONFIG_VALUE_1: "always" + }, + firstCommit + }; +} + +describe("--ref on subcommands", () => { + it("install --ref checks out the requested commit instead of the default branch HEAD", async () => { + const remote = await makeRemoteRepo(); + const home = await makeTempDir("agent-installer-cli-home-"); + + const install = await runCli(["install", REMOTE_URL, "--ref", remote.firstCommit, "--only", "skill:review"], home, remote.env); + + expect(install.stderr).toBe(""); + expect(install.exitCode).toBe(0); + const listed = parseStdoutJson((await runCli(["list", "--json"], home)).stdout) as { + artifacts: Array<{ id: string; requestedRef?: string; resolvedCommit?: string }>; + }; + expect(listed.artifacts).toEqual([ + expect.objectContaining({ id: "skill:review", requestedRef: remote.firstCommit, resolvedCommit: remote.firstCommit }) + ]); + expect(await fs.readFile(path.join(home, ".agents", "skills", "review", "SKILL.md"), "utf8")).toBe("# Review\n"); + }); + + it("scan --ref scans the requested commit instead of the default branch HEAD", async () => { + const remote = await makeRemoteRepo(); + const home = await makeTempDir("agent-installer-cli-home-"); + + const scan = await runCli(["scan", REMOTE_URL, "--ref", remote.firstCommit, "--json"], home, remote.env); + + expect(scan.exitCode).toBe(0); + const parsed = parseStdoutJson(scan.stdout) as { + artifacts: Array<{ id: string; requestedRef?: string; resolvedCommit?: string }>; + }; + expect(parsed.artifacts.length).toBeGreaterThan(0); + for (const artifact of parsed.artifacts) { + expect(artifact).toEqual( + expect.objectContaining({ requestedRef: remote.firstCommit, resolvedCommit: remote.firstCommit }) + ); + } + }); + + it("scan --ref on a local source is rejected instead of silently ignored", async () => { + const repo = await makeRepo(); + const home = await makeTempDir("agent-installer-cli-home-"); + + const scan = await runCli(["scan", repo, "--ref", "main"], home); + + expect(scan.exitCode).not.toBe(0); + expect(scan.stderr).toMatch(/--ref can only be used with an HTTPS Git source/); + }); + + it("rejects --ref placed before the subcommand name instead of silently dropping it", async () => { + const remote = await makeRemoteRepo(); + const home = await makeTempDir("agent-installer-cli-home-"); + + const install = await runCli(["--ref", remote.firstCommit, "install", REMOTE_URL, "--all"], home, remote.env); + + expect(install.exitCode).not.toBe(0); + expect(install.stderr).toMatch(/--ref cannot precede the install subcommand; place --ref after the subcommand name/); + await expect(fs.access(path.join(home, ".agents", "skills", "review"))).rejects.toThrow(); + }); +}); + +describe("root options before a subcommand", () => { + it("are rejected without suggesting a flag the subcommand does not accept", async () => { + const home = await makeTempDir("agent-installer-cli-home-"); + + const result = await runCli(["--ref", "main", "list", "--json"], home); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toMatch(/--ref cannot precede the list subcommand/); + expect(result.stderr).not.toMatch(/agent-installer list --ref/); + }); + + it("reject --list-length ahead of list instead of silently ignoring it", async () => { + const home = await makeTempDir("agent-installer-cli-home-"); + + const result = await runCli(["--list-length", "3", "list", "--json"], home); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toMatch(/--list-length cannot precede the list subcommand/); + }); +}); + +describe("--skill-max-depth on subcommands", () => { + it("is applied by scan when given after the subcommand name", async () => { + const repo = await makeTempDir("agent-installer-cli-repo-"); + await fs.mkdir(path.join(repo, "skills", "one", "two", "three", "deep-skill"), { recursive: true }); + await fs.writeFile(path.join(repo, "skills", "one", "two", "three", "deep-skill", "SKILL.md"), "# Deep\n", "utf8"); + const home = await makeTempDir("agent-installer-cli-home-"); + + const shallow = await runCli(["scan", repo, "--json"], home); + const deep = await runCli(["scan", repo, "--skill-max-depth", "4", "--json"], home); + + const ids = (result: CliResult) => + (parseStdoutJson(result.stdout) as { artifacts: Array<{ id: string }> }).artifacts.map((artifact) => artifact.id); + expect(ids(shallow)).toEqual([]); + expect(ids(deep)).toEqual(["skill:deep-skill"]); + }); +});