diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index b655e8a65..9b60694dc 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -283,8 +283,10 @@ runs local preflight checks. ### Scan options and output `--path` scopes a scan to one or more paths, `--diff` scans committed changes, -and `--working-tree` scans staged and unstaged changes. Deep scans support -repository and path targets. +and `--working-tree` scans staged and unstaged changes. These selectors are +mutually exclusive. `--head` requires `--diff`; `--base` requires +`--working-tree`. Deep scans support repository and path targets, and deep-scan +settings require `--mode deep`. Working-tree snapshots include files from untracked nested Git repositories. Initialized submodules must be clean and checked out at the commit recorded by @@ -990,6 +992,8 @@ calling workflow or issue tracker; assessments remain separate recommendations. Commands default to the current repository. Select scans by full ID or a unique prefix of at least eight characters. +`--scan-root` filters indexed artifact paths; it does not import report directories. + | Command | Purpose | | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | `scans list [REPOSITORY]` | List scans. Filter by artifact root with `--scan-root DIR`. | @@ -1044,11 +1048,21 @@ npx @openai/codex-security scan . \ --fail-on-severity high > "$SCAN_ROOT/findings.json" ``` +`scan --json` writes results to stdout and progress to stderr. Result fields are +`manifest`, `findings`, `coverage`, `repositoryFindings`, `scanDir`, `reportPath`, +`artifactsDir`, `sarifPath`, `threadId`, `cost`, and `turn`. `sarifPath` and `cost` +may be null; `repositoryFindings` may be absent. `findings` describes this scan; +`repositoryFindings` includes open findings across scans when available. +A changed target adds `warnings` and exits with code `2`; the saved results +do not describe the current checkout. + Scan exit codes are `0` for a completed report-only scan or passing policy, -`1` for a policy violation, `2` for invalid input, incomplete coverage, or a -runtime/export error, `130` for interruption, and `143` for termination. -JSON scans do not use interactive controls. `validate`, `login`, and `logout` -reject `--json`. +`1` for a policy violation, `2` for invalid input, incomplete coverage, a changed +target, or a runtime/export error, `130` for interruption, and `143` for termination. +JSON scans do not use interactive controls. `verify-fix`, saved-finding patches, +and resumed patch publication support JSON and JSONL results; literal issue and +file patches do not. `validate`, `login`, and `logout` reject structured result +output. These restrictions do not apply to manifest or schema discovery. `install-hook` scans staged and unstaged changes before each commit. It blocks on high-severity findings or failed scans, respects `core.hooksPath`, and leaves @@ -1139,10 +1153,17 @@ inconclusive or couldn't finish. ### Command discovery and integrations -The CLI uses [Incur](https://github.com/wevm/incur). Use `--llms` for the -command manifest, `scan --schema --format json` for a command schema, and -`completions bash|zsh|fish` for shell completions. Scan output supports -`--format toon|json|yaml|jsonl` and `--full-output`. +The CLI uses [Incur](https://github.com/wevm/incur). Use `--llms` for a command +index and `--llms-full` for a Markdown reference generated from the live schemas +and this operating guide. Scope the reference to a command or group, such as +`scans --llms-full`; scoped references omit the operating guide. Add +`--format json` for the original structured manifest. Its property names are +parsed option keys; command-line flags use kebab-case. + +Use `scan --schema --format json` for a command schema and +`completions bash|zsh|fish` for shell completions. Scan results support +`--format toon|json|yaml|jsonl` and `--full-output`, but not Markdown or +`--filter-output`. `skills add` syncs agent skills; `mcp add` registers the CLI as an MCP server. MCP exposes only the read-only `info` command because the transport cannot diff --git a/sdk/typescript/scripts/check-package.mjs b/sdk/typescript/scripts/check-package.mjs index e66a29eea..bcbac64f3 100644 --- a/sdk/typescript/scripts/check-package.mjs +++ b/sdk/typescript/scripts/check-package.mjs @@ -168,6 +168,7 @@ const distFiles = new Set( "auth", "bulk-scan-discovery", "cli", + "cli-manifest", "classify-severity", "classify-scan-severity", "severity-store", diff --git a/sdk/typescript/scripts/smoke-package.mjs b/sdk/typescript/scripts/smoke-package.mjs index de4ffed33..c739183b6 100644 --- a/sdk/typescript/scripts/smoke-package.mjs +++ b/sdk/typescript/scripts/smoke-package.mjs @@ -617,6 +617,12 @@ try { /lin_api_|security@example\.test/u, ); + const manifest = runInstalledCli("--llms-full"); + assert.match(manifest, /^# codex-security$/mu); + assert.match(manifest, /^## Authentication$/mu); + assert.match(manifest, /\| `--working-tree` \|/u); + assert.doesNotMatch(manifest, /--[a-z][a-z0-9-]*[A-Z][A-Za-z0-9-]*/u); + const { startFindingsServer } = await import( pathToFileURL(join(installedRoot, "dist/server/server.js")).href ); diff --git a/sdk/typescript/src/cli-manifest.ts b/sdk/typescript/src/cli-manifest.ts new file mode 100644 index 000000000..7b23648a7 --- /dev/null +++ b/sdk/typescript/src/cli-manifest.ts @@ -0,0 +1,523 @@ +import { readFileSync } from "node:fs"; +import { Cli, Help, Skill, z } from "incur"; +import { DEFAULT_CODEX_CONFIG, scanModelConfiguration } from "./config.js"; +import { + BUNDLED_PLUGIN_VERSION, + CODEX_EXECUTABLE_VERSION, + CODEX_SDK_VERSION, + VERSION, +} from "./version.js"; + +interface Manifest { + commands: { name: string }[]; +} + +const INCUR_FLAGS = new Set([ + "--full-output", + "--llms", + "--llms-full", + "--mcp", + "--help", + "-h", + "--version", + "--schema", + "--token-count", +]); +export const INCUR_VALUE_OPTIONS = new Set([ + "--format", + "--filter-output", + "--token-limit", + "--token-offset", +]); + +export const INFO_OUTPUT_SCHEMA = z.object({ + sdkVersion: z.string().describe("Codex Security package version."), + bundledPluginVersion: z.string().describe("Bundled security plugin version."), + scanMcp: z + .literal(false) + .describe("Whether scans are available over MCP; always false."), + cancellationNote: z.string().describe("Why scans are CLI-only."), + cliVersion: z.string().describe("Codex Security CLI version."), + codexVersion: z.string().describe("Bundled Codex executable version."), + codexSdkVersion: z.string().describe("Bundled Codex SDK version."), + model: z.string().describe("Default scan model."), + reasoningEffort: z.string().describe("Default scan reasoning effort."), + nextStep: z.string().describe("Suggested first local preflight command."), +}); + +const INFO_METADATA_FIELDS = new Set(Object.keys(INFO_OUTPUT_SCHEMA.shape)); +export const SCAN_MARKDOWN_RESULT_RESTRICTION = + "Markdown output is not supported for scan results."; +export const PATCH_STRUCTURED_OUTPUT_RESTRICTION = + "JSON and JSONL patch output require a saved finding identifier, --scan, or --resume-pr."; + +interface CommandResultRule { + commands: readonly string[]; + message(command: string): string; + rejects?(argv: readonly string[]): boolean; +} + +function hasOptionValue( + argv: readonly string[], + option: string, + value: string, +): boolean { + return argv.some( + (argument, index) => + argument === `${option}=${value}` || + (argument === option && argv[index + 1] === value), + ); +} + +function structuredOutputRequested(argv: readonly string[]): boolean { + return ( + argv.includes("--json") || + hasOptionValue(argv, "--format", "json") || + hasOptionValue(argv, "--format", "jsonl") + ); +} + +const COMMAND_RESULT_RULES: readonly CommandResultRule[] = [ + { + commands: ["validate", "login", "logout", "serve"], + message: (command) => + `${command} does not support noninteractive JSON output; run it without --json, --format json, or --format jsonl.`, + rejects: structuredOutputRequested, + }, + { + commands: ["patch"], + message: () => PATCH_STRUCTURED_OUTPUT_RESTRICTION, + }, + { + commands: ["export"], + message: () => + "CSV stdout cannot be combined with JSON output; write CSV to a file or omit --json.", + rejects: (argv) => + structuredOutputRequested(argv) && + hasOptionValue(argv, "--output", "-") && + hasOptionValue(argv, "--export-format", "csv"), + }, + { + commands: ["scan"], + message: () => "--filter-output is not supported for scan results.", + rejects: (argv) => + argv.some( + (argument) => + argument === "--filter-output" || + argument.startsWith("--filter-output="), + ), + }, + { + commands: ["scan"], + message: () => SCAN_MARKDOWN_RESULT_RESTRICTION, + rejects: (argv) => hasOptionValue(argv, "--format", "md"), + }, + { + commands: ["info"], + message: () => "--filter-output must select an info metadata field.", + rejects: (argv) => + argv.some((argument, index) => { + if ( + argument !== "--filter-output" && + !argument.startsWith("--filter-output=") + ) { + return false; + } + const selector = argument.includes("=") + ? argument.slice(argument.indexOf("=") + 1) + : argv[index + 1]; + return ( + selector !== undefined && + !selector.split(",").every((field) => INFO_METADATA_FIELDS.has(field)) + ); + }), + }, +]; + +function commandResultRules(command: string): CommandResultRule[] { + const root = command.split(" ", 1)[0]!; + return COMMAND_RESULT_RULES.filter((rule) => rule.commands.includes(root)); +} + +export function commandResultRestrictions(command: string): string[] { + const root = command.split(" ", 1)[0]!; + return commandResultRules(command).map((rule) => rule.message(root)); +} + +export function validateCommandResultOptions( + command: string, + argv: readonly string[], +): string | undefined { + const root = command.split(" ", 1)[0]!; + return commandResultRules(command) + .find((rule) => rule.rejects?.(argv) === true) + ?.message(root); +} + +/** Keep command lookup aligned with Incur's built-in option consumption. */ +export function parseIncurArguments(argv: readonly string[]): { + commandArguments: string[]; + commandIndex: number; + format: string | undefined; +} { + let format: string | undefined; + let commandIndex = -1; + const commandArguments: string[] = []; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]!; + if (argument === "--json") format = "json"; + else if (argument === "--format") format = argv[++index]; + else if (INCUR_FLAGS.has(argument)) continue; + else if ( + INCUR_VALUE_OPTIONS.has(argument) && + argv[index + 1] !== undefined + ) { + index += 1; + } else { + if (commandIndex < 0) commandIndex = index; + commandArguments.push(argument); + } + } + return { commandArguments, commandIndex, format }; +} + +export function fullMarkdownManifestArguments( + argv: readonly string[], +): string[] | undefined { + if (!argv.includes("--llms-full") || argv.includes("--mcp")) return undefined; + // Incur 0.4.13 omits the requested path from its structured manifest. + const { commandArguments, format } = parseIncurArguments(argv); + return format === undefined || format === "md" ? commandArguments : undefined; +} + +export async function applyManifestTokenControls( + markdown: string, + argv: readonly string[], +): Promise { + const controls: string[] = []; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]!; + if (argument === "--token-count") { + controls.push(argument); + } else if (argument === "--token-limit" || argument === "--token-offset") { + const value = argv[++index]; + if (value !== undefined) controls.push(argument, value); + } + } + if (controls.length === 0) return markdown; + + const renderer = Cli.create("codex-security-manifest", { + output: z.string(), + run: () => markdown, + }); + let output = ""; + let exitCode: number | undefined; + await renderer.serve(["--format", "md", ...controls], { + stdout: (value) => { + output += value; + }, + exit: (code) => { + exitCode = code; + }, + }); + if (exitCode !== undefined) { + throw new Error("Could not apply manifest token controls."); + } + return output; +} + +function commandScope( + commands: readonly Skill.CommandInfo[], + commandArguments: readonly string[], +): string { + let scope = ""; + for (const argument of commandArguments) { + const next = scope ? `${scope} ${argument}` : argument; + if ( + !commands.some( + ({ name }) => name === next || name?.startsWith(`${next} `), + ) + ) { + break; + } + scope = next; + if (commands.some(({ name }) => name === scope)) break; + } + return scope; +} + +/** Reconstruct only schema-owned guidance from Incur's human validation block. */ +export function humanValidationMessage( + cli: Cli.Cli, + commandArguments: readonly string[], + output: string, +): string | undefined { + const lines = output.split("\n"); + const usage = lines.indexOf("See below for usage."); + if (usage <= 0) return undefined; + const commands = Cli.collectSkillCommands( + Cli.toCommands.get(cli)!, + [], + new Map(), + ); + const scope = commandScope(commands, commandArguments); + const command = commands.find(({ name }) => name === scope); + if (command?.options === undefined) return undefined; + const input = z.toJSONSchema(command.options, { + io: "input", + unrepresentable: "any", + }); + const required = new Set(input.required); + const messages: string[] = []; + for (const line of lines.slice(0, usage)) { + const field = Object.keys(command.options.shape).find((name) => { + const flag = `--${optionName(name)}`; + return ( + line.startsWith(`Error: invalid value for ${flag}: `) || + (required.has(name) && + line === `Error: missing required option ${flag}`) + ); + }); + if (field === undefined) return undefined; + const property = input.properties?.[field]; + if (typeof property !== "object" || property === null) return undefined; + const constraints = staticInputConstraints(property); + if (constraints === undefined) return undefined; + const flag = `--${optionName(field)}`; + const problem = line.startsWith("Error: missing required option ") + ? "Missing required option" + : "Invalid value for"; + messages.push( + `${problem} ${flag}. ${describeConstraints(constraints, plainValue, true).join(" ")}`, + ); + } + return messages.join("\n"); +} + +/** Render a documentation-only view; keep Incur's parsed schemas unchanged. */ +export function renderFullMarkdownManifest( + cli: Cli.Cli, + manifest: Manifest, + commandArguments: readonly string[] = [], +): string { + const selected = new Set(manifest.commands.map(({ name }) => name)); + const groups = new Map(); + const allCommands = Cli.collectSkillCommands( + Cli.toCommands.get(cli)!, + [], + groups, + ); + const scope = commandScope(allCommands, commandArguments); + const commands = allCommands + .filter((command) => selected.has(command.name!)) + .map((command) => ({ + ...command, + args: documentInputs(command.args, false), + options: documentInputs(command.options, true), + })); + const groupRows = [...groups] + .filter(([name]) => + commands.some((command) => command.name?.startsWith(`${name} `)), + ) + .map( + ([name, description]) => `| \`${cli.name} ${name}\` | ${description} |`, + ); + const defaults = scanModelConfiguration(DEFAULT_CODEX_CONFIG); + const scopedName = scope ? `${cli.name} ${scope}` : cli.name; + const description = scope + ? groups.get(scope) ?? + allCommands.find(({ name }) => name === scope)?.description + : cli.description; + + return [ + Skill.index(cli.name, commands, description).replace( + /^# [^\n]+/u, + `# ${scopedName}`, + ), + `CLI/SDK version: ${VERSION}. Bundled plugin: ${BUNDLED_PLUGIN_VERSION}. ` + + `Codex runtime: ${CODEX_EXECUTABLE_VERSION}. Codex SDK: ${CODEX_SDK_VERSION}. ` + + `Default model: ${defaults.model}; reasoning effort: ${defaults.reasoningEffort}.`, + scope + ? `Run \`${cli.name} --llms-full\` for the operating guide.` + : readOperatingGuide(), + "## Global options and integrations", + "`--format` and `--json` select the output format for `--llms`, `--llms-full`, and `--schema`. `--filter-output` applies only to command results, which follow the restrictions in each command reference.", + "```text\n" + Help.formatRoot(cli.name, { root: true }) + "\n```", + ...(groupRows.length === 0 + ? [] + : [ + "## Command groups", + [ + "| Group | Description |", + "|-------|-------------|", + ...groupRows, + ].join("\n"), + ]), + "## Command reference", + ...commands.map((command) => { + const restrictions = commandResultRestrictions(command.name ?? ""); + return [ + Skill.generate(cli.name, [command]).replace(/^#/gmu, "###"), + ...(restrictions.length === 0 + ? [] + : [ + "#### Command result restrictions", + restrictions.map((restriction) => `- ${restriction}`).join("\n"), + ]), + ].join("\n\n"); + }), + "", + ].join("\n\n"); +} + +function readOperatingGuide(): string { + return readFileSync(new URL("../README.md", import.meta.url), "utf8") + .replace(/\r\n/gu, "\n") + .split(/(?=^## )/mu) + .filter((section) => + /^## (?:Install|Authentication|CLI|Local security model)\n/u.test( + section, + ), + ) + .join("") + .trim(); +} + +function documentInputs( + schema: z.ZodObject | undefined, + options: boolean, +): z.ZodObject | undefined { + if (schema === undefined) return undefined; + // Incur 0.4.13 renders schema keys as flags and omits input constraints. + // Adapt only the Markdown view, not the parser or machine-readable schema. + const input = z.toJSONSchema(schema, { + io: "input", + unrepresentable: "any", + }); + const required = new Set(input.required); + return z.object( + Object.fromEntries( + Object.entries(schema.shape).map(([name, field]) => { + const property = (input.properties?.[name] ?? + {}) as z.core.JSONSchema.JSONSchema; + const details = [field.description ?? ""]; + if (options && required.has(name)) details.push("Required."); + details.push(...describeConstraints(property)); + if (options && property.type === "array") { + details.push("Repeat this flag for multiple values."); + if (Array.isArray(property.default)) { + details.push(`Default: ${codeValue(property.default)}.`); + } + } + if ( + property.type === "array" && + typeof property.items === "object" && + !Array.isArray(property.items) + ) { + const itemDetails = describeConstraints(property.items); + if (itemDetails.length > 0) { + details.push(`Each value: ${itemDetails.join(" ")}`); + } + } + const key = options ? optionName(name) : name; + return [key, field.describe(details.join(" "))]; + }), + ), + ); +} + +const CONSTRAINT_LABELS = [ + ["minimum", "Minimum"], + ["exclusiveMinimum", "Must be greater than"], + ["maximum", "Maximum"], + ["exclusiveMaximum", "Must be less than"], + ["minLength", "Minimum length"], + ["maxLength", "Maximum length"], +] as const; + +function staticInputConstraints( + property: z.core.JSONSchema.JSONSchema, +): z.core.JSONSchema.JSONSchema | undefined { + if ( + typeof property.type !== "string" || + !["string", "number", "integer", "boolean", "null"].includes( + property.type, + ) || + property.$ref !== undefined || + property.anyOf !== undefined || + property.oneOf !== undefined || + property.allOf !== undefined || + property.not !== undefined || + property.if !== undefined + ) { + return undefined; + } + const values = + property.enum ?? + (property.const === undefined ? undefined : [property.const]); + if ( + values !== undefined && + !values.every( + (value) => + value === null || + typeof value === "string" || + typeof value === "boolean" || + (typeof value === "number" && Number.isFinite(value)), + ) + ) { + return undefined; + } + const constraints: z.core.JSONSchema.JSONSchema = { + type: property.type, + ...(values === undefined ? {} : { enum: values }), + ...(property.pattern === undefined ? {} : { pattern: property.pattern }), + }; + for (const [key] of CONSTRAINT_LABELS) { + const value = property[key]; + if (value === undefined) continue; + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + constraints[key] = value; + } + return constraints; +} + +function describeConstraints( + property: z.core.JSONSchema.JSONSchema, + renderValue: (value: unknown) => string = codeValue, + includeType = false, +): string[] { + const details: string[] = []; + if (includeType) details.push(`Expected type: ${property.type}.`); + const values = + property.enum ?? + (property.const === undefined ? undefined : [property.const]); + if (values !== undefined) { + details.push(`Allowed values: ${values.map(renderValue).join(", ")}.`); + } + if (property.type === "integer" && !includeType) { + details.push("Must be an integer."); + } + if (property.pattern !== undefined) { + details.push(`Pattern: ${renderValue(property.pattern)}.`); + } + for (const [key, label] of CONSTRAINT_LABELS) { + if (property[key] !== undefined) { + details.push(`${label}: ${property[key]}.`); + } + } + return details; +} + +function optionName(name: string): string { + return name.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`); +} + +function plainValue(value: unknown): string { + return typeof value === "string" + ? value + : JSON.stringify(value) ?? String(value); +} + +function codeValue(value: unknown): string { + return `\`${plainValue(value)}\``; +} diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 8ae7515df..757926a0b 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -75,6 +75,18 @@ import { resolveWorkflowScan, type SavedScan, } from "./saved-scan.js"; +import { + applyManifestTokenControls, + fullMarkdownManifestArguments, + humanValidationMessage, + INCUR_VALUE_OPTIONS, + INFO_OUTPUT_SCHEMA, + parseIncurArguments, + PATCH_STRUCTURED_OUTPUT_RESTRICTION, + renderFullMarkdownManifest, + SCAN_MARKDOWN_RESULT_RESTRICTION, + validateCommandResultOptions, +} from "./cli-manifest.js"; import { publishFindingsCsvToCloud, publishScanToCloud, @@ -206,6 +218,7 @@ const CHILD_TERMINATION_GRACE_MS = 1_000; const PUBLICATION_GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme", }); +const DOCUMENTATION_FLAGS = new Set(["--help", "-h", "--llms", "--llms-full"]); type Writable = Pick & { on?(event: "error", listener: (error: Error) => void): unknown; @@ -249,7 +262,7 @@ const EXPORT_DEFAULT_OUTPUTS = { json: "findings.json", sarif: "results.sarif", } as const; -const VALUE_OPTIONS = new Set([ +const COMMAND_VALUE_OPTIONS = new Set([ "--port", "--workflow-id", "--auth", @@ -297,10 +310,6 @@ const VALUE_OPTIONS = new Set([ "--csv", "--output", "--source-root", - "--format", - "--filter-output", - "--token-limit", - "--token-offset", "--scan-root", "--reason", "--to", @@ -313,7 +322,9 @@ const VALUE_OPTIONS = new Set([ const PROVIDER_OPTION = z .enum(["openai", "openrouter", "fireworks", "amazon-bedrock"]) .default("openai") - .describe("Inference provider for scans."); + .describe( + "Inference provider; non-OpenAI providers require an explicit model.", + ); const CREATE_PR_OPTION = z .boolean() .default(false) @@ -360,6 +371,19 @@ const PUBLICATION_DESTINATION_OPTIONS = z.object({ ), }); +const PUBLICATION_DESTINATION_REQUIREMENTS = { + team: "--linear-team or CODEX_SECURITY_LINEAR_TEAM is required.", + assignee: + "--linear-assignee requires --linear-api-key or CODEX_SECURITY_LINEAR_API_KEY.", + project: "--linear-project and --project must select the same project.", +}; +const PUBLICATION_SOURCE_REQUIREMENTS = { + scan: "Use --scan or scan directory inputs, not both.", + csv: "Use --csv or scan directory and ID inputs, not both.", +}; +const COMPONENT_SELECTION_REQUIREMENT = + "Choose exactly one of --component, --components-file, or --auto."; + function publicationDestination( options: z.infer, environment: NodeJS.ProcessEnv, @@ -370,26 +394,20 @@ function publicationDestination( throw new CodexSecurityError("--linear-assignee must not be empty."); } if (assigneeId !== undefined && linearApiKey === undefined) { - throw new CodexSecurityError( - "--linear-assignee requires --linear-api-key or CODEX_SECURITY_LINEAR_API_KEY.", - ); + throw new CodexSecurityError(PUBLICATION_DESTINATION_REQUIREMENTS.assignee); } const teamId = options.linearTeam?.trim() || environment["CODEX_SECURITY_LINEAR_TEAM"]?.trim(); if (!teamId) { - throw new CodexSecurityError( - "--linear-team or CODEX_SECURITY_LINEAR_TEAM is required.", - ); + throw new CodexSecurityError(PUBLICATION_DESTINATION_REQUIREMENTS.team); } if ( options.linearProject !== undefined && options.project !== undefined && options.linearProject.trim() !== options.project.trim() ) { - throw new CodexSecurityError( - "--linear-project and --project must select the same project.", - ); + throw new CodexSecurityError(PUBLICATION_DESTINATION_REQUIREMENTS.project); } const projectOption = options.linearProject ?? options.project; const selectedProject = projectOption?.trim(); @@ -867,31 +885,39 @@ const DEEP_SCAN_OPTION_SCHEMAS = { .int() .positive() .optional() - .describe("Maximum concurrent deep-scan discovery workers."), + .describe( + "Maximum concurrent deep-scan discovery workers (bundled default: 4).", + ), subagents: z .number() .int() .nonnegative() .optional() - .describe("Subagents available to each deep-scan worker."), + .describe( + "Subagents available to each deep-scan worker (bundled default: 3).", + ), stopAfterNoNew: z .number() .int() .positive() .optional() - .describe("Stop after this many runs find no new issues."), + .describe( + "Stop after this many runs find no new issues (bundled default: 4).", + ), maxDiscoveryRuns: z .number() .int() .positive() .optional() - .describe("Maximum deep-scan discovery runs."), + .describe("Maximum deep-scan discovery runs (bundled default: 40)."), maxTimeHours: z .number() .positive() .max(96) .optional() - .describe("Maximum deep-scan discovery hours (default: 96; maximum: 96)."), + .describe( + "Maximum deep-scan discovery hours (bundled default: 96; maximum: 96).", + ), }; async function readPromptFiles( @@ -1588,15 +1614,29 @@ export async function main( errorOutput: Writable = process.stderr, dependencies: CliDependencies = DEFAULT_DEPENDENCIES, ): Promise { - argv = defaultListCommand(argv); + const completing = Boolean(process.env["COMPLETE"]); + if (!completing) { + argv = defaultListCommand( + argv.flatMap((argument) => { + const equals = argument.indexOf("="); + const option = argument.slice(0, equals); + return equals >= 0 && INCUR_VALUE_OPTIONS.has(option) + ? [option, argument.slice(equals + 1)] + : [argument]; + }), + ); + } const positionals: string[] = []; - const argumentError = validateCliArguments(argv, positionals); + const argumentError = completing + ? undefined + : validateCliArguments(argv, positionals); if (argumentError !== undefined) { errorOutput.write(`codex-security: ${argumentError}\n`); return 2; } const updateController = new AbortController(); const pendingUpdate = + !completing && errorOutput.isTTY === true && argv.length > 0 && argv[0] !== "completions" && @@ -1816,7 +1856,7 @@ export async function main( scanRoot: z .string() .optional() - .describe("Include scans whose output is under ROOT."), + .describe("Include indexed scans whose output is under ROOT."), }), output: z.record(z.string(), z.unknown()).optional(), async run({ args, format, options }) { @@ -2019,6 +2059,7 @@ export async function main( .default(false) .describe("Recompute an existing semantic finding comparison."), }), + hint: "Provide two scan identifiers, or use --all without identifiers.", output: z.record(z.string(), z.unknown()).optional(), async run({ args, format, options }) { try { @@ -2159,6 +2200,10 @@ export async function main( "Skip findings already recorded for this exact Linear destination.", ), }), + hint: [ + ...Object.values(PUBLICATION_SOURCE_REQUIREMENTS), + ...Object.values(PUBLICATION_DESTINATION_REQUIREMENTS), + ].join(" "), output: z.record(z.string(), z.unknown()).optional(), async run({ args, format, formatExplicit, options }) { const controller = new AbortController(); @@ -2245,9 +2290,7 @@ export async function main( ); } if (options.scan.length > 0 && directories.length > 0) { - throw new CodexSecurityError( - "Use --scan or scan directory inputs, not both.", - ); + throw new CodexSecurityError(PUBLICATION_SOURCE_REQUIREMENTS.scan); } if ( csvPath !== undefined && @@ -2255,9 +2298,7 @@ export async function main( options.scan.length > 0 || options.scanDir.length > 0) ) { - throw new CodexSecurityError( - "Use --csv or scan directory and ID inputs, not both.", - ); + throw new CodexSecurityError(PUBLICATION_SOURCE_REQUIREMENTS.csv); } if (csvPath !== undefined && options.to !== "cloud") { throw new CodexSecurityError( @@ -2701,6 +2742,7 @@ export async function main( scanDir: z.string().describe("Completed scan directory."), }), options: PUBLICATION_DESTINATION_OPTIONS, + hint: Object.values(PUBLICATION_DESTINATION_REQUIREMENTS).join(" "), output: z.record(z.string(), z.unknown()).optional(), async run({ args, options }) { const controller = new AbortController(); @@ -2897,7 +2939,7 @@ export async function main( model: optionValue("--model") .optional() .describe( - `OpenAI model to use (default: ${DEFAULT_SCAN_MODEL_CONFIGURATION.model}).`, + `Model identifier for the selected provider (OpenAI default: ${DEFAULT_SCAN_MODEL_CONFIGURATION.model}).`, ), effort: effortOption(), provider: PROVIDER_OPTION, @@ -3029,11 +3071,16 @@ export async function main( }, }, ], + hint: + "--path, --diff, and --working-tree are mutually exclusive. " + + "Deep-scan settings require --mode deep. --patch-severity and --create-pr require --patch, and --patch cannot be combined with --dry-run. " + + "Use --json for scan results; " + + "--dry-run checks local inputs without verifying authentication or model access.", output: z.record(z.string(), z.unknown()).optional(), async run({ args, error: incurError, format, options }) { if (format === "md") { errorOutput.write( - "codex-security: Markdown output is not supported for scan results.\n", + `codex-security: ${SCAN_MARKDOWN_RESULT_RESTRICTION}\n`, ); exitCode = 2; return; @@ -3117,8 +3164,10 @@ export async function main( }), output: z .object({ - hook: z.string(), - failOnSeverity: z.enum(REPORTABLE_SEVERITIES), + hook: z.string().describe("Installed pre-commit hook path."), + failOnSeverity: z + .enum(REPORTABLE_SEVERITIES) + .describe("Finding severity threshold that blocks commits."), }) .optional(), async run({ args, options }) { @@ -3458,10 +3507,10 @@ export async function main( Number(options.auto) === 1, { - message: - "Choose exactly one of --component, --components-file, or --auto.", + message: COMPONENT_SELECTION_REQUIREMENT, }, ), + hint: COMPONENT_SELECTION_REQUIREMENT, output: z.record(z.string(), z.unknown()).optional(), async run({ args, options }) { const controller = new AbortController(); @@ -3665,7 +3714,7 @@ export async function main( model: optionValue("--model") .optional() .describe( - `OpenAI model for each repository (default: ${DEFAULT_SCAN_MODEL_CONFIGURATION.model}).`, + `Model identifier for each repository's provider (OpenAI default: ${DEFAULT_SCAN_MODEL_CONFIGURATION.model}).`, ), effort: effortOption(), provider: PROVIDER_OPTION, @@ -3700,10 +3749,12 @@ export async function main( }, ], hint: - "CSV example:\n" + - " codex-security bulk-scan repositories.csv " + + "A repository CSV requires --output-dir. " + + "--output-dir can only be used with a repository CSV; omit it to " + + "choose an output directory interactively. For example: " + + "`codex-security bulk-scan repositories.csv " + "--output-dir /path/outside/repositories/results " + - "--workers 4 --max-attempts 3", + "--workers 4 --max-attempts 3`.", output: z.record(z.string(), z.unknown()).optional(), async run({ args, options }) { const controller = new AbortController(); @@ -3830,7 +3881,7 @@ export async function main( sourceRoot: optionValue("--source-root") .optional() .describe( - "Repository checkout used for SARIF source-line fingerprints.", + "Repository checkout used for source-line fingerprints; requires --export-format sarif.", ), python: optionValue("--python") .optional() @@ -3933,10 +3984,14 @@ export async function main( linearIssue: z .array(optionValue("--linear-issue")) .default([]) - .describe("Linear issue identifier or URL; repeat for more issues."), + .describe( + "Linear issue identifier or URL; repeat for more issues; cannot be combined with --linear-project or saved findings.", + ), linearProject: optionValue("--linear-project") .optional() - .describe("Verify issues in this Linear project."), + .describe( + "Verify issues in this Linear project; cannot be combined with --linear-issue or saved findings.", + ), linearFilter: optionValue("--linear-filter") .optional() .describe("JSON Linear issue filter for --linear-project."), @@ -3948,6 +4003,10 @@ export async function main( 'Repeat TOML model="gpt-5.6-terra", model_reasoning_effort="high", or analytics.enabled=false.', ), }), + hint: + "Use finding text or files, saved finding identifiers with optional --scan, or Linear selectors. " + + "Saved findings cannot be combined with Linear selectors. --severity requires saved findings; " + + "--linear-filter requires --linear-project, and --linear-api-key requires a Linear selector.", output: z.record(z.string(), z.unknown()).optional(), async run({ format, options }) { try { @@ -4149,10 +4208,14 @@ export async function main( linearIssue: z .array(optionValue("--linear-issue")) .default([]) - .describe("Linear issue identifier or URL; repeat for more issues."), + .describe( + "Linear issue identifier or URL; repeat for more issues; cannot be combined with --linear-project or saved findings.", + ), linearProject: optionValue("--linear-project") .optional() - .describe("Patch every open issue in this Linear project."), + .describe( + "Patch every open issue in this Linear project; cannot be combined with --linear-issue or saved findings.", + ), linearFilter: optionValue("--linear-filter") .optional() .describe("JSON Linear issue filter for --linear-project."), @@ -4162,7 +4225,7 @@ export async function main( resumePr: optionValue("--resume-pr") .optional() .describe( - "Resume publication of a saved patch branch without patching again.", + "Resume publication of a saved patch branch without other patch inputs or options.", ), codex: z .array(optionValue("--codex")) @@ -4171,6 +4234,11 @@ export async function main( 'Repeat TOML model="gpt-5.6-terra", model_reasoning_effort="high", or analytics.enabled=false.', ), }), + hint: + "Use issue text or files, saved finding identifiers with optional --scan, Linear selectors, or --resume-pr. " + + "Saved findings cannot be combined with Linear selectors. --severity and --create-pr require saved findings; " + + "--linear-filter requires --linear-project, and --linear-api-key requires a Linear selector. " + + "--resume-pr cannot be combined with other patch inputs or options.", output: z.record(z.string(), z.unknown()).optional(), async run({ format, options }) { try { @@ -4297,9 +4365,7 @@ export async function main( ); } if (format === "json" || format === "jsonl") { - throw new CodexSecurityError( - "JSON patch output requires a saved finding identifier or --scan.", - ); + throw new CodexSecurityError(PATCH_STRUCTURED_OUTPUT_RESTRICTION); } const imports = linear @@ -4561,18 +4627,7 @@ export async function main( openWorldHint: false, }, }, - output: z.object({ - sdkVersion: z.string(), - bundledPluginVersion: z.string(), - scanMcp: z.literal(false), - cancellationNote: z.string(), - cliVersion: z.string(), - codexVersion: z.string(), - codexSdkVersion: z.string(), - model: z.string(), - reasoningEffort: z.string(), - nextStep: z.string(), - }), + output: INFO_OUTPUT_SCHEMA, run() { return { sdkVersion: VERSION, @@ -4590,13 +4645,13 @@ export async function main( }); let notice: UpdateNotice | undefined; + const manifestArguments = completing + ? undefined + : fullMarkdownManifestArguments(argv); + const markdownManifest = manifestArguments !== undefined; try { await cli.serve( - argv.flatMap((argument) => - argument.startsWith("--format=") - ? ["--format", argument.slice("--format=".length)] - : [argument], - ), + [...argv, ...(markdownManifest ? ["--format", "json"] : [])], { stdout: (value) => { frameworkOutput += value; @@ -4616,12 +4671,22 @@ export async function main( if (frameworkExit !== undefined) { if (exitCode !== 0) return exitCode; errorOutput.write( - `codex-security: ${errorMessage(incurErrorMessage(frameworkOutput))}\n`, + `codex-security: ${safeIncurErrorMessage(frameworkOutput, cli, argv)}\n`, ); return 2; } if (frameworkOutput.length === 0) return exitCode; try { + if (markdownManifest) { + frameworkOutput = await applyManifestTokenControls( + renderFullMarkdownManifest( + cli, + JSON.parse(frameworkOutput), + manifestArguments, + ), + argv, + ); + } await writeCliOutput( output, renderedPublication ?? renderedHistory ?? frameworkOutput, @@ -4634,15 +4699,11 @@ export async function main( } function defaultListCommand(argv: readonly string[]): readonly string[] { - const commandIndex = argv.findIndex((value, index) => { - if (value.startsWith("-")) return false; - return index === 0 || !VALUE_OPTIONS.has(argv[index - 1]!); - }); + const { commandArguments, commandIndex } = parseIncurArguments(argv); if ( commandIndex < 0 || - !["scans", "findings"].includes(argv[commandIndex]!) || - argv.includes("--help") || - argv.includes("-h") + !["scans", "findings"].includes(commandArguments[0]!) || + argv.some((argument) => DOCUMENTATION_FLAGS.has(argument)) ) { return argv; } @@ -4810,13 +4871,42 @@ function scanArgumentsFromRecipe( }; } +function hasFlagValue(argv: readonly string[], index: number): boolean { + const next = argv[index + 1]; + return next !== undefined && !next.startsWith("--") && next !== "-h"; +} + function validateCliArguments( argv: readonly string[], positionals: string[], ): string | undefined { - if (argv.includes("--help") || argv.includes("-h")) return undefined; - const commandIndex = argv.findIndex((value) => - [ + for (let index = 0; index < argv.length; index += 1) { + const option = argv[index]!; + if (!INCUR_VALUE_OPTIONS.has(option)) continue; + const value = argv[index + 1]; + if (value === undefined || value === "" || !hasFlagValue(argv, index)) { + return `Missing value for flag: ${option}`; + } + // Keep Incur's accepted numeric values without echoing rejected operands. + if ( + (option === "--token-limit" || option === "--token-offset") && + (!Number.isFinite(Number(value)) || value.trim() === "") + ) { + return `Invalid value for ${option}: expected a finite number.`; + } + index += 1; + } + if ( + argv.includes("--schema") || + argv.some((argument) => DOCUMENTATION_FLAGS.has(argument)) + ) { + return undefined; + } + const commandArguments = parseIncurArguments(argv).commandArguments; + const command = commandArguments[0]; + if ( + command === undefined || + ![ "scan", "install-hook", "bulk-scan", @@ -4833,124 +4923,37 @@ function validateCliArguments( "logout", "serve", "info", - ].includes(value), - ); - if (commandIndex < 0) return undefined; - const command = argv[commandIndex]!; - const structuredOutput = argv.some( - (value, index) => - value === "--json" || - ((value === "--format" || - value === "--format=json" || - value === "--format=jsonl") && - (value.endsWith("=json") || - value.endsWith("=jsonl") || - argv[index + 1] === "json" || - argv[index + 1] === "jsonl")), - ); - if ( - structuredOutput && - ["validate", "login", "logout", "serve"].includes(command) && - !argv.includes("--schema") - ) { - return `${command} does not support noninteractive JSON output; run it without --json, --format json, or --format jsonl.`; - } - if ( - command === "export" && - structuredOutput && - argv.some( - (value, index) => - value === "--output=-" || - (value === "--output" && argv[index + 1] === "-"), - ) && - argv.some( - (value, index) => - value === "--export-format=csv" || - (value === "--export-format" && argv[index + 1] === "csv"), - ) + ].includes(command) ) { - return "CSV stdout cannot be combined with JSON output; write CSV to a file or omit --json."; - } - if (command === "scan" && !argv.includes("--schema")) { - if ( - argv.some( - (value) => - value === "--filter-output" || value.startsWith("--filter-output="), - ) - ) { - return "--filter-output is not supported for scan results."; - } - if ( - argv.some( - (value, index) => - value === "--format=md" || - (value === "--format" && argv[index + 1] === "md"), - ) - ) { - return "Markdown output is not supported for scan results."; - } + return undefined; } + const resultOptionError = validateCommandResultOptions(command, argv); + if (resultOptionError !== undefined) return resultOptionError; const nestedCommand = command === "scans" || command === "findings" || command === "publish" || command === "import"; - const subcommand = nestedCommand ? argv[commandIndex + 1] : undefined; - if (command === "info") { - const metadataFields = new Set([ - "sdkVersion", - "bundledPluginVersion", - "scanMcp", - "cancellationNote", - "cliVersion", - "codexVersion", - "codexSdkVersion", - "model", - "reasoningEffort", - "nextStep", - ]); - for (let index = 0; index < argv.length; index += 1) { - const argument = argv[index]!; - if ( - argument !== "--filter-output" && - !argument.startsWith("--filter-output=") - ) { - continue; - } - const selector = argument.includes("=") - ? argument.slice(argument.indexOf("=") + 1) - : argv[index + 1]; - if ( - selector !== undefined && - !selector.split(",").every((field) => metadataFields.has(field)) - ) { - return "--filter-output must select an info metadata field."; - } - } - } + const subcommand = nestedCommand ? commandArguments[1] : undefined; for ( - let index = commandIndex + (nestedCommand ? 2 : 1); - index < argv.length; + let index = nestedCommand ? 2 : 1; + index < commandArguments.length; index += 1 ) { - const value = argv[index]!; + const value = commandArguments[index]!; if (!value.startsWith("-")) { positionals.push(value); continue; } const equals = value.indexOf("="); const option = equals < 0 ? value : value.slice(0, equals); - if (equals >= 0 || !VALUE_OPTIONS.has(option)) continue; - const next = argv[index + 1]; - if (next === undefined || next.startsWith("--") || next === "-h") { + if (equals >= 0 || !COMMAND_VALUE_OPTIONS.has(option)) continue; + if (!hasFlagValue(commandArguments, index)) { return `Missing value for flag: ${option}`; } index += 1; } - if ( - subcommand === "match" && - !argv.some((value) => ["--schema", "--llms", "--llms-full"].includes(value)) - ) { + if (subcommand === "match") { if (argv.includes("--all") && positionals.length > 0) { return "scans match --all does not accept scan identifiers."; } @@ -6276,18 +6279,30 @@ export function skillCommandFailure( return `${command} failed with exit code ${status}.`; } -function incurErrorMessage(output: string): string { +export function safeIncurErrorMessage( + output: string, + cli: Cli.Cli, + argv: readonly string[], +): string { const message = output .split("\n") .find((line) => line.startsWith("message: ")) ?.slice("message: ".length); - if (message === undefined) return output.trim(); - try { - const parsed: unknown = JSON.parse(message); - return typeof parsed === "string" ? parsed : message; - } catch { - return message; + let detail = message ?? output.trim(); + if (message !== undefined) { + try { + const parsed: unknown = JSON.parse(message); + if (typeof parsed === "string") detail = parsed; + } catch {} } + const safe = safeErrorMessage(detail); + return safe === "[redacted]" + ? humanValidationMessage( + cli, + parseIncurArguments(argv).commandArguments, + output, + ) ?? safe + : safe; } function isOutsidePath(path: string): boolean { diff --git a/sdk/typescript/tests-ts/cli-manifest.test.ts b/sdk/typescript/tests-ts/cli-manifest.test.ts new file mode 100644 index 000000000..ed1ecb706 --- /dev/null +++ b/sdk/typescript/tests-ts/cli-manifest.test.ts @@ -0,0 +1,689 @@ +import { describe, expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; +import { Cli, Schema, z } from "incur"; +import { main } from "../src/cli.js"; +import { + commandResultRestrictions, + fullMarkdownManifestArguments, + INCUR_VALUE_OPTIONS, + parseIncurArguments, + PATCH_STRUCTURED_OUTPUT_RESTRICTION, + renderFullMarkdownManifest, + validateCommandResultOptions, +} from "../src/cli-manifest.js"; +import { DEFAULT_CODEX_CONFIG, scanModelConfiguration } from "../src/config.js"; +import { + BUNDLED_PLUGIN_VERSION, + CODEX_EXECUTABLE_VERSION, + CODEX_SDK_VERSION, + VERSION, +} from "../src/version.js"; +import { capture, dependencies, fakeResult } from "./cli-fixtures.js"; + +interface ObjectSchema { + properties?: Record; + required?: string[]; +} + +interface Command { + name: string; + schema?: { + args?: ObjectSchema; + options?: ObjectSchema; + output?: ObjectSchema; + }; + examples?: { command: string }[]; +} + +interface Manifest { + version: string; + commands: Command[]; +} + +function documentationDependencies() { + const unexpected = (): never => { + throw new Error("Documentation must not run a command or access state."); + }; + const deps = dependencies({ + environment: { + OPENAI_API_KEY: "SYNTHETIC_MANIFEST_KEY", + CODEX_SECURITY_STATE_DIR: "/synthetic/private-state", + }, + }); + deps.createSecurity = unexpected; + deps.prepareAuthenticationHome = unexpected; + deps.hasStoredChatGPTSignIn = unexpected; + deps.currentDirectory = unexpected; + deps.runCodex = unexpected; + deps.runWorkbench = unexpected; + deps.linearClient = unexpected; + deps.matchFindings = unexpected; + deps.exportFindings = unexpected; + deps.publishScan = unexpected; + deps.checkForUpdate = unexpected; + return deps; +} + +async function invoke(args: readonly string[]): Promise { + const stdout = capture(); + const stderr = capture(true); + expect( + await main(args, stdout.stream, stderr.stream, documentationDependencies()), + ).toBe(0); + expect(stderr.text()).toBe(""); + expect(stdout.text()).not.toContain("SYNTHETIC_MANIFEST_KEY"); + expect(stdout.text()).not.toContain("/synthetic/private-state"); + return stdout.text(); +} + +async function readManifest(args: readonly string[] = []): Promise { + return JSON.parse( + await invoke([...args, "--llms-full", "--format", "json"]), + ) as Manifest; +} + +function commandSections(markdown: string): Map { + return new Map( + markdown + .split(/^### codex-security /mu) + .slice(1) + .map((section) => { + const newline = section.indexOf("\n"); + return [section.slice(0, newline), section.slice(newline + 1)]; + }), + ); +} + +function flag(name: string): string { + return `--${name.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`)}`; +} + +describe("full CLI manifest", () => { + test("documents every live command, argument, option, and allowed value", async () => { + const manifest = await readManifest(); + const markdown = await invoke(["--llms-full"]); + const sections = commandSections(markdown); + + expect(manifest.version).toBe("incur.v1"); + expect([...sections.keys()]).toEqual( + manifest.commands.map(({ name }) => name), + ); + expect(markdown).not.toMatch(/--[a-z][a-z0-9-]*[A-Z][A-Za-z0-9-]*/u); + + for (const command of manifest.commands) { + const section = sections.get(command.name)!; + for (const [name, field] of Object.entries( + command.schema?.args?.properties ?? {}, + )) { + const row = section + .split("\n") + .find((line) => line.startsWith(`| \`${name}\` |`)); + expect(row).toBeDefined(); + expect(row).toContain(field.description!); + const required = + command.schema?.args?.required?.includes(name) === true; + expect(row!.split("|")[3]?.trim()).toBe(required ? "yes" : "no"); + } + for (const [name, field] of Object.entries( + command.schema?.options?.properties ?? {}, + )) { + const row = section + .split("\n") + .find((line) => line.startsWith(`| \`${flag(name)}\` |`)); + expect(row).toBeDefined(); + expect(row).toContain(field.description!); + expect(row!.includes("**Deprecated.**")).toBe( + field.deprecated === true, + ); + const details = row!.slice( + row!.indexOf(field.description!) + field.description!.length, + ); + const schemas = [field]; + if (typeof field.items === "object" && !Array.isArray(field.items)) { + schemas.push(field.items); + } + for (const schema of schemas) { + for (const value of schema.enum ?? + (schema.const === undefined ? [] : [schema.const])) { + expect(details).toContain(`\`${String(value)}\``); + } + if (schema.pattern !== undefined) { + expect(details).toContain(`\`${schema.pattern}\``); + } + for (const constraint of [ + "minimum", + "exclusiveMinimum", + "maximum", + "exclusiveMaximum", + "minLength", + "maxLength", + ] as const) { + if (typeof schema[constraint] === "number") { + expect(details).toContain(String(schema[constraint])); + } + } + } + const required = + command.schema?.options?.required?.includes(name) === true && + field.default === undefined; + expect(/\brequired\b/iu.test(details)).toBe(required); + } + for (const example of command.examples ?? []) { + expect(section).toContain(`codex-security ${example.command}`); + } + } + + expect(sections.get("info")).toContain("| `sdkVersion` |"); + expect(sections.get("bulk-scan")).toContain( + "--output-dir /path/outside/repositories/results", + ); + }); + + test("includes current metadata and the packaged operating guide", async () => { + const markdown = await invoke(["--llms-full"]); + const readme = ( + await readFile(new URL("../README.md", import.meta.url), "utf8") + ).replace(/\r\n/gu, "\n"); + for (const title of [ + "Install", + "Authentication", + "CLI", + "Local security model", + ]) { + const heading = `## ${title}\n`; + const start = readme.indexOf(heading); + expect(start).toBeGreaterThanOrEqual(0); + const end = readme.indexOf("\n## ", start + heading.length); + expect(markdown).toContain( + readme.slice(start, end < 0 ? undefined : end).trim(), + ); + } + for (const title of [ + "Run a scan from TypeScript", + "Containerized bulk scans", + ]) { + expect(markdown).not.toContain(`\n## ${title}\n`); + } + const defaults = scanModelConfiguration(DEFAULT_CODEX_CONFIG); + for (const value of [ + VERSION, + BUNDLED_PLUGIN_VERSION, + CODEX_EXECUTABLE_VERSION, + CODEX_SDK_VERSION, + defaults.model, + defaults.reasoningEffort, + "--schema", + "--mcp", + "completions", + "## Global options and integrations", + "## Command groups", + "## Command reference", + ]) { + expect(markdown).toContain(value); + } + for (const key of [...Object.keys(fakeResult().toJSON()), "warnings"]) { + expect(markdown).toContain(`\`${key}\``); + } + }); + + test("preserves descriptions for selected command groups", () => { + const descriptions = { + first: "First group metadata.", + nested: "Nested group metadata.", + second: "Second group metadata.", + }; + const cli = Cli.create("sample") + .command( + Cli.create("first", { description: descriptions.first }) + .command("show", { run() {} }) + .command( + Cli.create("nested", { description: descriptions.nested }).command( + "show", + { run() {} }, + ), + ), + ) + .command( + Cli.create("second", { description: descriptions.second }).command( + "show", + { run() {} }, + ), + ); + const commands = ["first show", "first nested show", "second show"].map( + (name) => ({ name }), + ); + const full = renderFullMarkdownManifest(cli, { commands }); + for (const description of Object.values(descriptions)) { + expect(full).toContain(description); + } + const scoped = renderFullMarkdownManifest( + cli, + { + commands: [{ name: "first nested show" }], + }, + ["first", "nested"], + ); + expect(scoped).toContain(descriptions.first); + expect(scoped).toContain(descriptions.nested); + expect(scoped).not.toContain(descriptions.second); + }); + + test("preserves group and leaf discovery without executing handlers", async () => { + const root = await readManifest(); + const groups = new Set( + root.commands.flatMap(({ name }) => + name.includes(" ") ? [name.split(" ")[0]!] : [], + ), + ); + for (const group of groups) { + const expected = root.commands.filter(({ name }) => + name.startsWith(`${group} `), + ); + expect((await readManifest([group])).commands).toEqual(expected); + const short = JSON.parse( + await invoke([group, "--llms", "--json"]), + ) as Manifest; + expect(short.commands.map(({ name }) => name)).toEqual( + expected.map(({ name }) => name), + ); + const markdown = await invoke([group, "--llms-full"]); + expect(markdown).toStartWith(`# codex-security ${group}\n`); + expect(markdown).not.toContain("\n## Authentication\n"); + expect([...commandSections(markdown).keys()]).toEqual( + expected.map(({ name }) => name), + ); + } + for (const command of root.commands) { + const args = command.name.split(" "); + expect((await readManifest(args)).commands).toEqual([command]); + const markdown = await invoke([...args, "--llms-full", "--format=md"]); + expect(markdown).toStartWith(`# codex-security ${command.name}\n`); + expect(markdown).not.toContain("\n## Authentication\n"); + expect([...commandSections(markdown).keys()]).toEqual([command.name]); + } + }); + + test("includes bulk-scan output restrictions in scoped discovery", async () => { + const stderr = capture(); + expect( + await main( + ["bulk-scan", "--output-dir", "results"], + capture().stream, + stderr.stream, + dependencies(), + ), + ).toBe(2); + + const restriction = stderr + .text() + .replace(/^codex-security:\s*/u, "") + .trim(); + expect(restriction).toContain("--output-dir"); + expect(restriction).toContain("repository CSV"); + expect(await invoke(["bulk-scan", "--llms-full"])).toContain(restriction); + }); + + test.each([ + ["scan-components", ["--output-dir", "synthetic-output"]], + [ + "scan-components", + ["--output-dir", "synthetic-output", "--auto", "--component", "src"], + ], + ["publish check", ["synthetic-scan", "--to", "linear"]], + [ + "publish check", + [ + "synthetic-scan", + "--to", + "linear", + "--linear-assignee", + "synthetic-user", + ], + ], + [ + "publish check", + [ + "synthetic-scan", + "--to", + "linear", + "--linear-team", + "synthetic-team", + "--linear-project", + "first", + "--project", + "second", + ], + ], + [ + "publish scan", + ["synthetic-scan", "--to", "linear", "--scan", "synthetic-id"], + ], + [ + "publish scan", + [ + "--scan-dir", + "synthetic-scan", + "--to", + "linear", + "--scan", + "synthetic-id", + ], + ], + [ + "publish scan", + ["synthetic-scan", "--to", "linear", "--csv", "synthetic.csv"], + ], + [ + "publish scan", + ["--scan", "synthetic-id", "--to", "linear", "--csv", "synthetic.csv"], + ], + ] as const)( + "documents the runtime requirement for %s %j", + async (command, args) => { + const stderr = capture(); + expect( + await main( + [...command.split(" "), ...args], + capture().stream, + stderr.stream, + dependencies({ environment: {} }), + ), + ).toBe(2); + const restriction = stderr + .text() + .split("\n", 1)[0]! + .replace(/^codex-security:\s*/u, "") + .trim(); + expect(restriction).not.toBe(""); + expect(restriction).not.toContain("[redacted]"); + expect(await invoke([...command.split(" "), "--llms-full"])).toContain( + restriction, + ); + }, + ); + + test("keeps built-in operands out of the shared command-argument view", () => { + for (const [option, value] of [ + ["--format", "md"], + ["--filter-output", "scan"], + ["--token-limit", "100000"], + ["--token-offset", "0"], + ] as const) { + expect( + parseIncurArguments([option, value, "scans", "show", "--llms-full"]), + ).toEqual({ + commandArguments: ["scans", "show"], + commandIndex: 2, + format: option === "--format" ? value : undefined, + }); + } + expect( + parseIncurArguments([ + "scans", + "compare", + "before", + "after", + "--filter-output", + "summary", + ]), + ).toEqual({ + commandArguments: ["scans", "compare", "before", "after"], + commandIndex: 0, + format: undefined, + }); + }); + + test("preserves scoped paths when global discovery flags come first or between commands", async () => { + for (const args of [ + ["--llms-full", "--format", "md", "scans", "show"], + ["--filter-output", "scan", "scans", "show", "--llms-full"], + ["--filter-output=scan", "scans", "show", "--llms-full"], + ["--token-limit=100000", "scans", "show", "--llms-full"], + ["scans", "--token-offset=0", "show", "--llms-full"], + ]) { + const markdown = await invoke(args); + expect(markdown).toStartWith("# codex-security scans show\n"); + expect([...commandSections(markdown).keys()]).toEqual(["scans show"]); + expect(markdown).not.toContain("\n## Authentication\n"); + } + expect( + await invoke(["--filter-output", "scan", "--llms-full"]), + ).toStartWith("# codex-security\n"); + }); + + test("applies token controls to the generated Markdown reference", async () => { + const count = await invoke(["--llms-full", "--token-count"]); + expect(Number(count.trim())).toBeGreaterThan(0); + const scopedCount = await invoke([ + "scans", + "--llms-full", + "--token-count", + "show", + ]); + expect(Number(scopedCount.trim())).toBeGreaterThan(0); + expect(Number(scopedCount.trim())).toBeLessThan(Number(count.trim())); + + const limited = await invoke(["--llms-full", "--token-limit", "1"]); + expect(limited).toContain("[truncated: showing tokens 0–1 of "); + expect(limited.length).toBeLessThan((await invoke(["--llms-full"])).length); + }); + + test("keeps schema discovery separate from execution-only format checks", async () => { + const schema = JSON.parse( + await invoke(["export", "--schema", "--format", "json"]), + ); + expect( + JSON.parse( + await invoke([ + "export", + "--schema", + "--format", + "json", + "--output", + "-", + "--export-format", + "csv", + ]), + ), + ).toEqual(schema); + }); + + test("rejects missing or empty global values before discovery", async () => { + const rejectsMissingValue = async ( + args: readonly string[], + option: string, + ) => { + const stdout = capture(); + const stderr = capture(); + expect( + await main( + args, + stdout.stream, + stderr.stream, + documentationDependencies(), + ), + ).toBe(2); + expect(stdout.text()).toBe(""); + expect(stderr.text()).toContain(`Missing value for flag: ${option}`); + }; + for (const option of INCUR_VALUE_OPTIONS) { + await rejectsMissingValue([option], option); + await rejectsMissingValue([option, "", "scans", "--llms-full"], option); + } + await rejectsMissingValue( + ["scans", "--llms-full", "--format="], + "--format", + ); + for (const command of ["scan", "logout"]) { + for (const discovery of ["--llms", "--llms-full", "--schema"]) { + await rejectsMissingValue( + [command, "--filter-output", discovery], + "--filter-output", + ); + } + } + }); + + test("delegates partial shell-completion words without command validation", async () => { + const original = { + COMPLETE: process.env["COMPLETE"], + _COMPLETE_INDEX: process.env["_COMPLETE_INDEX"], + }; + try { + process.env["COMPLETE"] = "bash"; + for (const option of INCUR_VALUE_OPTIONS) { + for (const suffix of [[option], [option, ""]]) { + const words = ["codex-security", ...suffix]; + process.env["_COMPLETE_INDEX"] = String(words.length - 1); + await invoke(["--", ...words]); + } + } + process.env["_COMPLETE_INDEX"] = "3"; + const completions = await invoke([ + "--", + "codex-security", + "--format=json", + "scan", + "--mo", + ]); + expect(completions).toContain("--model"); + expect(completions).toContain("--mode"); + } finally { + for (const [name, value] of Object.entries(original)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + } + }); + + test("honors explicit output formats without rewriting structured manifests", async () => { + const markdown = await invoke(["scan", "--llms-full"]); + for (const format of [ + ["--format", "md"], + ["--format=md"], + ["--json", "--format", "md"], + ]) { + expect(await invoke(["scan", "--llms-full", ...format])).toBe(markdown); + } + const manifest = await readManifest(["scan"]); + expect( + JSON.parse( + await invoke(["scan", "--llms-full", "--format", "md", "--json"]), + ), + ).toEqual(manifest); + expect(manifest.commands[0]?.schema?.options?.properties).toHaveProperty( + "outputDir", + ); + expect( + manifest.commands[0]?.schema?.options?.properties, + ).not.toHaveProperty("output-dir"); + expect( + fullMarkdownManifestArguments(["--llms-full", "--mcp"]), + ).toBeUndefined(); + }); + + test("does not mutate the schemas used by the command parser", () => { + const options = z.object({ + requiredValue: z.string().min(1), + defaultValue: z.enum(["one", "two"]).default("one"), + }); + const before = Schema.toJsonSchema(options); + const cli = Cli.create("sample").command("show", { + options, + run() {}, + }); + const markdown = renderFullMarkdownManifest(cli, { + commands: [{ name: "show" }], + }); + expect(markdown).toContain("--required-value"); + expect(markdown).toContain("--default-value"); + expect(Schema.toJsonSchema(options)).toEqual(before); + expect(options.parse({ requiredValue: "value" })).toEqual({ + requiredValue: "value", + defaultValue: "one", + }); + }); + + test("keeps unsupported result formats rejected", async () => { + for (const args of [ + ["scan", "--format", "md"], + ["scan", "--filter-output", "findings"], + ["validate", "--json"], + ["patch", "--format", "jsonl"], + ["login", "--json"], + ["logout", "--json"], + ["serve", "--json"], + ]) { + const stdout = capture(); + const stderr = capture(); + expect( + await main( + args, + stdout.stream, + stderr.stream, + documentationDependencies(), + ), + ).toBe(2); + expect(stdout.text()).toBe(""); + expect(stderr.text()).not.toBe(""); + } + }); + + test("documents the same result restrictions that the wrapper enforces", () => { + const names = [ + "scan", + "validate", + "patch", + "login", + "logout", + "serve", + "export", + "info", + ]; + const cli = Cli.create("codex-security"); + for (const name of names) { + cli.command(name, { + run() { + throw new Error("Manifest rendering must not run a command."); + }, + }); + } + const root = renderFullMarkdownManifest(cli, { + commands: names.map((name) => ({ name })), + }); + const sections = commandSections(root); + for (const [command, args] of [ + ["scan", ["scan", "--format", "md"]], + ["scan", ["scan", "--filter-output", "findings"]], + ["validate", ["validate", "--json"]], + ["login", ["login", "--json"]], + ["logout", ["logout", "--json"]], + ["serve", ["serve", "--json"]], + [ + "export", + ["export", "--json", "--output", "-", "--export-format", "csv"], + ], + ["info", ["info", "--filter-output", "findings"]], + ] as const) { + const restriction = validateCommandResultOptions(command, args); + expect(restriction).toBeDefined(); + expect(commandResultRestrictions(command)).toContain(restriction!); + expect(sections.get(command)).toContain(restriction!); + const scoped = renderFullMarkdownManifest( + cli, + { commands: [{ name: command }] }, + [command], + ); + expect(scoped).toContain(restriction!); + } + expect( + validateCommandResultOptions("patch", ["patch", "--format", "jsonl"]), + ).toBeUndefined(); + expect(commandResultRestrictions("patch")).toContain( + PATCH_STRUCTURED_OUTPUT_RESTRICTION, + ); + expect(sections.get("patch")).toContain( + PATCH_STRUCTURED_OUTPUT_RESTRICTION, + ); + }); +}); diff --git a/sdk/typescript/tests-ts/cli-workbench.test.ts b/sdk/typescript/tests-ts/cli-workbench.test.ts index 9c227303e..a11b523cf 100644 --- a/sdk/typescript/tests-ts/cli-workbench.test.ts +++ b/sdk/typescript/tests-ts/cli-workbench.test.ts @@ -96,10 +96,26 @@ describe("CLI workbench", () => { ["scans", "list", "other"], ["list-scans", "--repository", resolve(repository, "other")], ], + [ + ["scans", "--format", "json", "other"], + ["list-scans", "--repository", resolve(repository, "other")], + ], + [ + ["scans", "--json", "show"], + ["list-scans", "--repository", resolve(repository, "show")], + ], + [ + ["--format", "json", "scans", "--json", "other"], + ["list-scans", "--repository", resolve(repository, "other")], + ], [ ["scans", "list", "--scan-root", "/tmp/history"], ["list-scans", "--scan-root", resolve("/tmp/history")], ], + [ + ["scans", "list", "--scan-root", ""], + ["list-scans", "--scan-root", repository], + ], ]; for (const [argv, expected] of cases) { let invocation: readonly string[] | undefined; @@ -130,6 +146,35 @@ describe("CLI workbench", () => { expect(JSON.parse(stdout.text())).toMatchObject({ repository: "scans" }); }); + test("keeps shorthand finding repositories after global output options", async () => { + for (const [argv, selected] of [ + [["findings", "--format", "json", "other"], "other"], + [["findings", "--json", "list"], "list"], + [["--format", "json", "findings", "--json", "other"], "other"], + ] as const) { + const stdout = capture(); + const calls: Array = []; + expect( + await main( + argv, + stdout.stream, + capture().stream, + dependencies({ + onWorkbench: (args) => { + calls.push(args); + return { repositories: [] }; + }, + }), + ), + ).toBe(0); + expect(calls).toEqual([["list-repositories"]]); + expect(JSON.parse(stdout.text())).toEqual({ + repository: resolve("/current/repository", selected), + findings: [], + }); + } + }); + test("shows scans and returns cached comparisons with one workbench call", async () => { const cases: Array<[string[], string[], JsonObject, JsonObject]> = [ [ diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index 897ca5041..cc4df8ae7 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -13,6 +13,7 @@ import { PassThrough, Writable } from "node:stream"; import { fileURLToPath } from "node:url"; import { stripVTControlCharacters } from "node:util"; import { describe, expect, test } from "bun:test"; +import { Cli, z } from "incur"; import { parse as parseToml } from "smol-toml"; import type { CodexSecurityConfig, @@ -37,6 +38,7 @@ import { parseCodexOverrides, Progress, resolveCliPath, + safeIncurErrorMessage, } from "../src/cli.js"; import { scanPreflightCodexConfig } from "../src/api.js"; import { CODEX_EXECUTABLE_VERSION, CODEX_SDK_VERSION } from "../src/version.js"; @@ -444,6 +446,28 @@ describe("CLI", () => { maxTimeHours: number; }; expect(defaults.workers).toBe(4); + const schema = capture(); + expect( + await main( + ["scan", "--schema", "--format", "json"], + schema.stream, + capture().stream, + dependencies(), + ), + ).toBe(0); + const scanOptions = JSON.parse(schema.text()).options + .properties as Record; + for (const name of [ + "workers", + "subagents", + "stopAfterNoNew", + "maxDiscoveryRuns", + "maxTimeHours", + ] as const) { + expect(scanOptions[name]?.description).toContain( + `bundled default: ${defaults[name]}`, + ); + } const documentedDeepScan = documentedConfigs.find( (config) => typeof config["deep_scan"] === "object" && @@ -2360,7 +2384,7 @@ describe("CLI", () => { "--provider ", ); expect(help.text()).toContain( - `OpenAI model to use (default: ${DEFAULT_SCAN_MODEL_CONFIGURATION.model}).`, + `OpenAI default: ${DEFAULT_SCAN_MODEL_CONFIGURATION.model}`, ); expect(help.text()).toContain( "--effort ", @@ -2398,7 +2422,7 @@ describe("CLI", () => { ).toBe(0); expect(help.text()).toContain("--model "); expect(help.text()).toContain( - `OpenAI model for each repository (default: ${DEFAULT_SCAN_MODEL_CONFIGURATION.model}).`, + `OpenAI default: ${DEFAULT_SCAN_MODEL_CONFIGURATION.model}`, ); expect(help.text()).toContain( "--effort ", @@ -2907,6 +2931,65 @@ describe("CLI", () => { } }); + test("rebuilds value-free guidance from the selected command schema", () => { + const unexpected = (): never => { + throw new Error("Formatting must not run a command."); + }; + const cli = Cli.create("sample") + .command("scan", { + options: z.object({ auth: z.enum(["auto", "chatgpt", "api-key"]) }), + run: unexpected, + }) + .command( + Cli.create("publish").command("scan", { + options: z.object({ + linearApiKey: z + .string() + .min(1) + .default("documentation-only") + .describe("Description must not become a diagnostic."), + }), + run: unexpected, + }), + ) + .command("opaque", { + options: z.object({ apiKey: z.unknown() }), + run: unexpected, + }); + const humanError = (flag: string) => + [ + `Error: invalid value for ${flag}: Discard this formatter detail`, + "See below for usage.", + "", + "Usage: sample ", + ].join("\n"); + + const auth = safeIncurErrorMessage(humanError("--auth"), cli, ["scan"]); + expect(auth).toContain("Invalid value for --auth."); + expect(auth).toContain("Allowed values: auto, chatgpt, api-key."); + const key = safeIncurErrorMessage(humanError("--linear-api-key"), cli, [ + "publish", + "scan", + ]); + expect(key).toContain("Invalid value for --linear-api-key."); + expect(key).toContain("Expected type: string."); + expect(key).toContain("Minimum length: 1."); + for (const message of [auth, key]) { + expect(message).not.toContain("formatter detail"); + expect(message).not.toContain("documentation-only"); + expect(message).not.toContain("Description must not"); + } + for (const [output, command] of [ + [humanError("--linear-api-key"), ["scan"]], + [humanError("--linearApiKey"), ["publish", "scan"]], + [humanError("--access-token"), ["publish", "scan"]], + [humanError("--api-key"), ["opaque"]], + ["Error: invalid value for --auth: Invalid option", ["scan"]], + ] as const) { + expect(safeIncurErrorMessage(output, cli, command)).toBe("[redacted]"); + } + }); + test("honors Incur help before command validation", async () => { const stdout = capture(); const stderr = capture();