From ae3dd10d8df08d27de2cd800593d8018820536bd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:43:07 +0000 Subject: [PATCH 1/4] Bump zod from 4.5.4 to 4.6.1 Bumps [zod](https://github.com/colinhacks/zod) from 4.5.4 to 4.6.1. - [Release notes](https://github.com/colinhacks/zod/releases) - [Commits](https://github.com/colinhacks/zod/compare/v4.5.4...v4.6.1) --- updated-dependencies: - dependency-name: zod dependency-version: 4.6.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- pnpm-lock.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a158b2a..5f82186 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,7 +22,7 @@ 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 @@ -1209,8 +1209,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: @@ -2071,4 +2071,4 @@ snapshots: yaml@2.9.0: {} - zod@4.5.4: {} + zod@4.6.1: {} From 51506817abf314644e8c1bf58f6450e911db23a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:43:17 +0000 Subject: [PATCH 2/4] Bump typescript from 5.9.3 to 7.0.2 Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 7.0.2. - [Release notes](https://github.com/microsoft/TypeScript/releases) - [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v7.0.2) --- updated-dependencies: - dependency-name: typescript dependency-version: 7.0.2 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- package.json | 2 +- pnpm-lock.yaml | 219 +++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 211 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 51f28ca..f890a8c 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..b1c91e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,13 +29,13 @@ importers: 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: @@ -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: {} From e79c1405b8620ee4f7efe7c8a95a9fec7e21b19b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Bacik?= Date: Tue, 29 Sep 2026 21:54:36 +0200 Subject: [PATCH 3/4] Pass --ref and --skill-max-depth through to scan/install subcommands Both options are registered on the root program and on the scan/install subcommands. With Commander's default parsing the root consumed them even when they followed the subcommand name, so `install --ref ` silently cloned the default branch HEAD and recorded no requestedRef. Enable positional options so options after a subcommand name reach the subcommand, and reject root-level --ref/--skill-max-depth placed before a subcommand instead of dropping them. Regression tests drive the CLI against a local Git repository mapped to an HTTPS URL via url.insteadOf. Fixes #63 Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++ src/cli.ts | 19 +++++++ tests/cli-json.test.ts | 114 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 134 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7945c6c..519c436 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 ``` +Source options such as `--ref` and `--skill-max-depth` belong after the subcommand name. Placing them before it +(`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/src/cli.ts b/src/cli.ts index d96a249..7140892 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,21 @@ function createProgram(): Command { await runInteractive(inputPath, scanOptionsFromCommand(options), options.listLength, options.ref); }); + // Root-level source options only configure interactive mode; reject them ahead of a subcommand instead of + // dropping them silently. + program.hook("preSubcommand", (thisCommand, subcommand) => { + const rootOptions = thisCommand.opts(); + const misplaced = [ + ...(rootOptions.ref === undefined ? [] : ["--ref"]), + ...(rootOptions.skillMaxDepth === undefined ? [] : ["--skill-max-depth"]) + ]; + if (misplaced.length > 0) { + thisCommand.error( + `error: ${misplaced.join(" and ")} must follow the subcommand name, for example: agent-installer ${subcommand.name()} ${misplaced[0]} ` + ); + } + }); + 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..c3ec20e 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,113 @@ describe("sync --json", () => { ); }); }); + +const REMOTE_URL = "https://git.example.test/owner/skills"; + +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; secondCommit: string }> { + const repo = await makeRepo(); + await git(["init", "--quiet", "--initial-branch", "main"], repo); + await git(["add", "."], repo); + await git(["-c", "user.name=Test", "-c", "user.email=test@example.test", "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(["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "--quiet", "-am", "second"], repo); + const secondCommit = await git(["rev-parse", "HEAD"], 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, + secondCommit + }; +} + +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 must follow the subcommand name/); + await expect(fs.access(path.join(home, ".agents", "skills", "review"))).rejects.toThrow(); + }); +}); + +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"]); + }); +}); From 71090c31377eb4c85e3969326d8e9158f9589943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Bacik?= Date: Tue, 29 Sep 2026 21:56:41 +0200 Subject: [PATCH 4/4] Reject any root option placed before a subcommand Address review feedback on the misplaced-option guard: derive the guarded set from the root's own CLI-sourced options so --list-length is covered too, and only suggest moving a flag after the subcommand when that subcommand actually accepts it. Refs #63 Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- src/cli.ts | 26 +++++++++++++++----------- tests/cli-json.test.ts | 34 +++++++++++++++++++++++++++------- 3 files changed, 44 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 519c436..69629bf 100644 --- a/README.md +++ b/README.md @@ -207,8 +207,8 @@ Show scan results for a remote tag, branch, or commit: agent-installer scan https://github.com/org/agents.git --ref v1.2.0 ``` -Source options such as `--ref` and `--skill-max-depth` belong after the subcommand name. Placing them before it -(`agent-installer --ref v1.2.0 scan ...`) is rejected with an error rather than silently ignored. +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: diff --git a/src/cli.ts b/src/cli.ts index 7140892..9fdd32d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -163,19 +163,23 @@ function createProgram(): Command { await runInteractive(inputPath, scanOptionsFromCommand(options), options.listLength, options.ref); }); - // Root-level source options only configure interactive mode; reject them ahead of a subcommand instead of - // dropping them silently. + // Root-level options only configure interactive mode; reject them ahead of a subcommand instead of dropping + // them silently. program.hook("preSubcommand", (thisCommand, subcommand) => { - const rootOptions = thisCommand.opts(); - const misplaced = [ - ...(rootOptions.ref === undefined ? [] : ["--ref"]), - ...(rootOptions.skillMaxDepth === undefined ? [] : ["--skill-max-depth"]) - ]; - if (misplaced.length > 0) { - thisCommand.error( - `error: ${misplaced.join(" and ")} must follow the subcommand name, for example: agent-installer ${subcommand.name()} ${misplaced[0]} ` - ); + 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( diff --git a/tests/cli-json.test.ts b/tests/cli-json.test.ts index c3ec20e..d433df4 100644 --- a/tests/cli-json.test.ts +++ b/tests/cli-json.test.ts @@ -463,6 +463,7 @@ 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 }); @@ -474,15 +475,14 @@ async function git(args: string[], cwd: string): Promise { * 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; secondCommit: string }> { +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(["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "--quiet", "-m", "first"], 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(["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "--quiet", "-am", "second"], repo); - const secondCommit = await git(["rev-parse", "HEAD"], repo); + await git([...GIT_IDENTITY, "commit", "--quiet", "-am", "second"], repo); return { env: { @@ -492,8 +492,7 @@ async function makeRemoteRepo(): Promise<{ env: NodeJS.ProcessEnv; firstCommit: GIT_CONFIG_KEY_1: "protocol.file.allow", GIT_CONFIG_VALUE_1: "always" }, - firstCommit, - secondCommit + firstCommit }; } @@ -550,11 +549,32 @@ describe("--ref on subcommands", () => { 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 must follow the subcommand name/); + 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-");