fix: extract flags module to fix KNOWN_FLAGS drift in tests (#1757)

KNOWN_FLAGS in unknown-flags.test.ts was copy-pasted from index.ts and
was missing the --name flag, causing silent test gaps. Extract
KNOWN_FLAGS, findUnknownFlag, and expandEqualsFlags into a new flags.ts
module so tests import the real source of truth.

- Create cli/src/flags.ts with KNOWN_FLAGS, findUnknownFlag, expandEqualsFlags
- Update index.ts to import from flags.ts (checkUnknownFlags now uses findUnknownFlag)
- Update unknown-flags.test.ts to import from flags.ts instead of copy-pasting
- Add tests for --name flag, KNOWN_FLAGS completeness, and expandEqualsFlags
- Bump CLI version to 0.6.15

Fixes #1744

Agent: test-engineer

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
A 2026-02-22 15:10:07 -08:00 committed by GitHub
parent 7e7d4aa3d7
commit 545ddafe4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 138 additions and 107 deletions

View file

@ -1,48 +1,13 @@
import { describe, it, expect } from "bun:test";
import { KNOWN_FLAGS, findUnknownFlag, expandEqualsFlags } from "../flags";
/**
* Tests for unknown flag detection in CLI argument parsing.
* Tests for unknown flag detection and flag expansion in CLI argument parsing.
*
* Since index.ts runs main() on import, we replicate the checkUnknownFlags
* logic as a pure function to test it without side effects.
* Imports KNOWN_FLAGS and findUnknownFlag directly from flags.ts to avoid
* copy-paste drift (see issue #1744).
*/
const KNOWN_FLAGS = new Set([
"--help",
"-h",
"--version",
"-v",
"-V",
"--prompt",
"-p",
"--prompt-file",
"-f",
"--dry-run",
"-n",
"--debug",
"--headless",
"--output",
"--default",
"-a",
"-c",
"--agent",
"--cloud",
"--clear",
]);
/** Replicated from index.ts for testability - returns the first unknown flag or null */
function findUnknownFlag(args: string[]): string | null {
for (const arg of args) {
if (
(arg.startsWith("--") || (arg.startsWith("-") && arg.length > 1 && !/^-\d/.test(arg))) &&
!KNOWN_FLAGS.has(arg)
) {
return arg;
}
}
return null;
}
describe("Unknown Flag Detection", () => {
describe("detects unknown flags", () => {
it("should detect --json as unknown", () => {
@ -246,6 +211,10 @@ describe("Unknown Flag Detection", () => {
]),
).toBeNull();
});
it("should allow --name", () => {
expect(findUnknownFlag(["claude", "sprite", "--name", "my-box"])).toBeNull();
});
});
describe("ignores positional arguments", () => {
@ -333,3 +302,64 @@ describe("Unknown Flag Detection", () => {
});
});
});
describe("KNOWN_FLAGS completeness", () => {
it("should contain --name flag", () => {
expect(KNOWN_FLAGS.has("--name")).toBe(true);
});
it("should contain all expected flags", () => {
const expected = [
"--help", "-h",
"--version", "-v", "-V",
"--prompt", "-p", "--prompt-file", "-f",
"--dry-run", "-n",
"--debug",
"--headless",
"--output",
"--name",
"--default",
"-a", "-c", "--agent", "--cloud",
"--clear",
];
for (const flag of expected) {
expect(KNOWN_FLAGS.has(flag)).toBe(true);
}
});
});
describe("expandEqualsFlags", () => {
it("should expand --flag=value into two args", () => {
expect(expandEqualsFlags(["--prompt=hello"])).toEqual(["--prompt", "hello"]);
});
it("should expand multiple --flag=value pairs", () => {
expect(expandEqualsFlags(["--prompt=hello", "--name=box"])).toEqual([
"--prompt", "hello", "--name", "box",
]);
});
it("should pass through args without equals", () => {
expect(expandEqualsFlags(["--help", "claude", "sprite"])).toEqual([
"--help", "claude", "sprite",
]);
});
it("should not expand short flags", () => {
expect(expandEqualsFlags(["-p=value"])).toEqual(["-p=value"]);
});
it("should handle empty args", () => {
expect(expandEqualsFlags([])).toEqual([]);
});
it("should handle value containing equals sign", () => {
expect(expandEqualsFlags(["--prompt=a=b"])).toEqual(["--prompt", "a=b"]);
});
it("should handle mixed args", () => {
expect(expandEqualsFlags(["claude", "--prompt=hello", "sprite", "--dry-run"])).toEqual([
"claude", "--prompt", "hello", "sprite", "--dry-run",
]);
});
});

42
cli/src/flags.ts Normal file
View file

@ -0,0 +1,42 @@
/** CLI flag definitions and utilities — single source of truth for flag validation */
export const KNOWN_FLAGS = new Set([
"--help", "-h",
"--version", "-v", "-V",
"--prompt", "-p", "--prompt-file", "-f",
"--dry-run", "-n",
"--debug",
"--headless",
"--output",
"--name",
"--default",
"-a", "-c", "--agent", "--cloud",
"--clear",
]);
/** Return the first unknown flag in args, or null if all are known/positional */
export function findUnknownFlag(args: string[]): string | null {
for (const arg of args) {
if (
(arg.startsWith("--") || (arg.startsWith("-") && arg.length > 1 && !/^-\d/.test(arg))) &&
!KNOWN_FLAGS.has(arg)
) {
return arg;
}
}
return null;
}
/** Expand --flag=value into --flag value so all flag parsing works uniformly */
export function expandEqualsFlags(args: string[]): string[] {
const result: string[] = [];
for (const arg of args) {
if (arg.startsWith("--") && arg.includes("=")) {
const eqIdx = arg.indexOf("=");
result.push(arg.slice(0, eqIdx), arg.slice(eqIdx + 1));
} else {
result.push(arg);
}
}
return result;
}

View file

@ -26,6 +26,7 @@ import pc from "picocolors";
import pkg from "../package.json" with { type: "json" };
import { checkForUpdates } from "./update-check.js";
import { loadManifest, agentKeys, cloudKeys, getCacheAge } from "./manifest.js";
import { KNOWN_FLAGS, findUnknownFlag, expandEqualsFlags } from "./flags.js";
const VERSION = pkg.version;
@ -78,75 +79,33 @@ const HELP_FLAGS = [
"help",
];
const KNOWN_FLAGS = new Set([
"--help",
"-h",
"--version",
"-v",
"-V",
"--prompt",
"-p",
"--prompt-file",
"-f",
"--dry-run",
"-n",
"--debug",
"--headless",
"--output",
"--name",
"--default",
"-a",
"-c",
"--agent",
"--cloud",
"--clear",
]);
/** Expand --flag=value into --flag value so all flag parsing works uniformly */
export function expandEqualsFlags(args: string[]): string[] {
const result: string[] = [];
for (const arg of args) {
if (arg.startsWith("--") && arg.includes("=")) {
const eqIdx = arg.indexOf("=");
result.push(arg.slice(0, eqIdx), arg.slice(eqIdx + 1));
} else {
result.push(arg);
}
}
return result;
}
/** Check for unknown flags and show an actionable error */
function checkUnknownFlags(args: string[]): void {
for (const arg of args) {
if (
(arg.startsWith("--") || (arg.startsWith("-") && arg.length > 1 && !/^-\d/.test(arg))) &&
!KNOWN_FLAGS.has(arg)
) {
console.error(pc.red(`Unknown flag: ${pc.bold(arg)}`));
console.error();
console.error(" Supported flags:");
console.error(` ${pc.cyan("--prompt, -p")} Provide a prompt for non-interactive execution`);
console.error(` ${pc.cyan("--prompt-file, -f")} Read prompt from a file`);
console.error(` ${pc.cyan("--dry-run, -n")} Preview what would be provisioned`);
console.error(` ${pc.cyan("--debug")} Show all commands being executed`);
console.error(` ${pc.cyan("--headless")} Non-interactive mode (no prompts, no SSH session)`);
console.error(` ${pc.cyan("--output json")} Output structured JSON to stdout`);
console.error(` ${pc.cyan("--name")} Set the spawn/resource name`);
console.error(` ${pc.cyan("--help, -h")} Show help information`);
console.error(` ${pc.cyan("--version, -v")} Show version`);
console.error();
console.error(` For ${pc.cyan("spawn pick")}:`);
console.error(` ${pc.cyan("--default")} Pre-selected value in the picker`);
console.error();
console.error(` For ${pc.cyan("spawn list")}:`);
console.error(` ${pc.cyan("-a, --agent")} Filter history by agent`);
console.error(` ${pc.cyan("-c, --cloud")} Filter history by cloud`);
console.error(` ${pc.cyan("--clear")} Clear all spawn history`);
console.error();
console.error(` Run ${pc.cyan("spawn help")} for full usage information.`);
process.exit(1);
}
const unknown = findUnknownFlag(args);
if (unknown) {
console.error(pc.red(`Unknown flag: ${pc.bold(unknown)}`));
console.error();
console.error(` Supported flags:`);
console.error(` ${pc.cyan("--prompt, -p")} Provide a prompt for non-interactive execution`);
console.error(` ${pc.cyan("--prompt-file, -f")} Read prompt from a file`);
console.error(` ${pc.cyan("--dry-run, -n")} Preview what would be provisioned`);
console.error(` ${pc.cyan("--debug")} Show all commands being executed`);
console.error(` ${pc.cyan("--headless")} Non-interactive mode (no prompts, no SSH session)`);
console.error(` ${pc.cyan("--output json")} Output structured JSON to stdout`);
console.error(` ${pc.cyan("--name")} Set the spawn/resource name`);
console.error(` ${pc.cyan("--help, -h")} Show help information`);
console.error(` ${pc.cyan("--version, -v")} Show version`);
console.error();
console.error(` For ${pc.cyan("spawn pick")}:`);
console.error(` ${pc.cyan("--default")} Pre-selected value in the picker`);
console.error();
console.error(` For ${pc.cyan("spawn list")}:`);
console.error(` ${pc.cyan("-a, --agent")} Filter history by agent`);
console.error(` ${pc.cyan("-c, --cloud")} Filter history by cloud`);
console.error(` ${pc.cyan("--clear")} Clear all spawn history`);
console.error();
console.error(` Run ${pc.cyan("spawn help")} for full usage information.`);
process.exit(1);
}
}