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/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"]); + }); +});