From 22292e3698829c72dc5cf012d95ec24c8e343e92 Mon Sep 17 00:00:00 2001 From: mldangelo-oai <269034524+mldangelo-oai@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:47:17 +0000 Subject: [PATCH 1/3] fix(cli): organize and clarify command help --- sdk/typescript/README.md | 8 +- sdk/typescript/scripts/check-package.mjs | 1 + sdk/typescript/src/cli-help.ts | 342 ++++++++++++++++++ sdk/typescript/src/cli.ts | 171 +++++---- .../tests-ts/cli-cloud-publish.test.ts | 2 +- sdk/typescript/tests-ts/cli-help.test.ts | 254 +++++++++++++ .../tests-ts/cli-scan-import.test.ts | 4 +- sdk/typescript/tests-ts/cli-skills.test.ts | 6 +- sdk/typescript/tests-ts/cli.test.ts | 142 ++++---- 9 files changed, 777 insertions(+), 153 deletions(-) create mode 100644 sdk/typescript/src/cli-help.ts create mode 100644 sdk/typescript/tests-ts/cli-help.test.ts diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 084f0c5b1f..ef0ca07f85 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -488,7 +488,13 @@ npx @openai/codex-security scan /path/to/repository --output-dir /path/outside/r npx @openai/codex-security scan /path/to/repository --dry-run ``` -Use `scan --help` for options, `--version` for the installed version, and +Use `--help` for commands grouped by task, and ` --help` (or `-h`) +for examples and options. For example, `scan --help` groups scope, Deep Scan, +results, and patching options; `publish scan --help` separates destination +settings. Help wraps descriptions to the terminal width and keeps example +commands copyable. `scans` and `findings` default to their `list` subcommands. + +Use `--version` for the installed version, and `info --json` for package, plugin, runtime, and model details. `--dry-run` runs local preflight checks. `info -c FILE --json` inspects resolved configuration and its sources without a repository or runtime. diff --git a/sdk/typescript/scripts/check-package.mjs b/sdk/typescript/scripts/check-package.mjs index 4a9471a7f5..3c520c5e9c 100644 --- a/sdk/typescript/scripts/check-package.mjs +++ b/sdk/typescript/scripts/check-package.mjs @@ -172,6 +172,7 @@ const distFiles = new Set( "auth", "bulk-scan-discovery", "cli", + "cli-help", "cli-scan-logs-json", "classify-severity", "classify-scan-severity", diff --git a/sdk/typescript/src/cli-help.ts b/sdk/typescript/src/cli-help.ts new file mode 100644 index 0000000000..9669cde9b7 --- /dev/null +++ b/sdk/typescript/src/cli-help.ts @@ -0,0 +1,342 @@ +import { DEEP_SCAN_SETTINGS, DeepScanSettingsSchema } from "./scan-settings.js"; + +type Row = { label: string; description: string }; +type Group = readonly [title: string, names: readonly string[]]; + +const COMMAND_GROUPS: readonly Group[] = [ + ["Scan and fix", ["scan", "policy", "validate", "patch", "verify-fix"]], + [ + "Review and share", + [ + "scans", + "findings", + "classify-severity", + "dedupe", + "suggest-owners", + "export", + "import", + "publish", + ], + ], + ["Automate", ["bulk-scan", "scan-components", "install-hook", "serve"]], + ["Setup and support", ["login", "logout", "init", "info", "feedback"]], +]; + +const SCAN_GROUPS: readonly Group[] = [ + ["Scope", ["path", "diff", "head", "working-tree", "base"]], + ["Configuration", ["config", "mode", "knowledge-base", "dry-run"]], + ["Model and authentication", ["auth", "model", "effort", "provider"]], + [ + "Deep Scan", + DEEP_SCAN_SETTINGS.flatMap(([, , , flag]) => + flag === null ? [] : [flag.slice(2)], + ), + ], + [ + "Results, progress, and limits", + [ + "output-dir", + "archive-existing", + "max-cost", + "show-cost", + "fail-on-severity", + "headless", + "verbose", + ], + ], + ["Patch after scanning", ["patch", "patch-severity", "create-pr"]], +]; + +const PUBLISH_GROUPS: readonly Group[] = [ + ["Input", ["scan", "scan-dir", "csv", "finding-id", "workflow-id"]], + ["Destination", ["to", "findings-url"]], + [ + "Linear", + [ + "linear-team", + "linear-project", + "linear-assignee", + "linear-api-key", + "project", + ], + ], + ["Publishing", ["dry-run", "skip-existing"]], +]; + +const VALUE_LABELS: Record = { + config: "file", + path: "path", + "knowledge-base": "path", + "scan-prompt-file": "file", + "validation-prompt-file": "file", + "post-scan-prompt-file": "file", + diff: "ref", + head: "ref", + base: "ref", + model: "model", + "output-dir": "path", + "scan-dir": "path", + "plugin-path": "path", + "source-root": "path", + python: "path", + codex: "key=value", + "max-cost": "usd", + "max-time-hours": "hours", + "fail-on-severity": "level", + "patch-severity": "level", + severity: "level", + scan: "scan-id", + "finding-id": "id", + "findings-url": "url", + output: "file", + csv: "file", + json: "file", + rubric: "file", + "export-format": "format", + "filter-output": "keys", + "token-limit": "count", + "token-offset": "count", + port: "port", + to: "destination", +}; + +const OUTPUT_GROUPS: readonly Group[] = [ + ["Help and updates", ["help", "version", "update"]], + ["Output", ["format", "json", "filter-output", "full-output"]], +]; + +function rowName(row: Row): string { + return row.label.match(/--([\w-]+)/u)?.[1] ?? row.label; +} + +function parseRows(lines: string[]): Row[] { + return lines.map((line) => { + const match = line.trim().match(/^(.*?)\s{2,}(.*)$/u); + return { label: match?.[1] ?? line.trim(), description: match?.[2] ?? "" }; + }); +} + +function wrap(text: string, width: number, prefix = "", next = prefix): string { + const lines: string[] = []; + let line = prefix; + let indent = prefix.length; + for (const word of text.split(/\s+/u)) { + if (line.length > indent && line.length + 1 + word.length > width) { + lines.push(line); + line = next; + indent = next.length; + } + line += `${line.length > indent ? " " : ""}${word}`; + } + lines.push(line); + return lines.join("\n"); +} + +function renderRows(title: string, rows: Row[], width: number): string { + // Keep long option names from moving every description across the terminal. + const column = Math.min( + 30, + Math.floor(width / 2), + Math.max(...rows.map((row) => row.label.length)) + 4, + ); + return [ + `${title}:`, + ...rows.map(({ label, description }) => { + if (!description) return ` ${label}`; + if (label.length + 4 > column) { + return ` ${label}\n${wrap(description, width, " ")}`; + } + return wrap( + description, + width, + ` ${label}${" ".repeat(column - label.length - 2)}`, + " ".repeat(column), + ); + }), + ].join("\n"); +} + +function groupedRows( + rows: Row[], + groups: readonly Group[], + remainingTitle: string, + width: number, +): string[] { + const remaining = new Map(rows.map((row) => [rowName(row), row])); + const sections: string[] = []; + for (const [title, names] of groups) { + const selected = names.flatMap((name) => { + const row = remaining.get(name); + remaining.delete(name); + return row === undefined ? [] : [row]; + }); + if (selected.length) sections.push(renderRows(title, selected, width)); + } + if (remaining.size) { + sections.push(renderRows(remainingTitle, [...remaining.values()], width)); + } + return sections; +} + +function optionRow(row: Row, command: string): Row { + const name = rowName(row); + let { label, description } = row; + label = label.replace(/<([^>]+)>/u, (_, type: string) => { + const choices = type.split("|"); + if (choices.length > 1) { + description += `${description.endsWith(".") ? "" : "."} Choices: ${choices.join(", ")}.`; + } + const value = VALUE_LABELS[name] ?? (type === "number" ? "count" : name); + return `<${value}>`; + }); + label = label.replace(/^--([\w-]+), (-\w)/u, "$2, --$1"); + if (command === "scan" && !/default:/iu.test(description)) { + const setting = DEEP_SCAN_SETTINGS.find( + ([, , , flag]) => flag === `--${name}`, + ); + if (setting) { + const value = + DeepScanSettingsSchema.shape[setting[0]].meta()?.["default"]; + if (value !== undefined) description += ` (default: ${value})`; + } + } + return { label, description }; +} + +function globalRows(rows: Row[], command: string): Row[] { + const scan = command === "scan" || command === "scan import"; + const plainOutput = [ + "validate", + "login", + "logout", + "serve", + "export", + ].includes(command); + const visible = rows.filter(({ label }) => { + if (scan && label.startsWith("--filter-output")) return false; + return !( + plainOutput && + /^(--format |--filter-output |--full-output$|--token-)/u.test(label) + ); + }); + const formats = visible.find((row) => rowName(row) === "format"); + if (formats && (scan || command === "scans rerun")) { + formats.label = formats.label.replace("|md", ""); + } + if (formats && command !== "scan import") { + visible.push({ + label: "--json", + description: "Shorthand for --format json.", + }); + } + return visible.map((row) => + optionRow( + row.label === "--help" ? { ...row, label: "-h, --help" } : row, + command, + ), + ); +} + +/** Present Incur's generated help without changing its routing or command output. */ +export function formatCliHelp(text: string, columns = 80): string { + const header = text.match( + /^(codex-security(?:@[^\s]+)?((?: [a-z][a-z-]*)*))(?: — ([^\n]*))?\n\nUsage: /u, + ); + if (!header) return text; + const command = header[2]!.trim(); + const sections: string[] = []; + // Presentation width only; long paths and copyable commands remain intact. + const width = columns > 0 ? columns : 80; + const blocks = text.trimEnd().split(/\n\n/u); + const examples = blocks.find((block) => block.startsWith("Examples:\n")); + for (const [index, block] of blocks.entries()) { + const [title = "", ...lines] = block.split("\n"); + if (index === 0) { + sections.push( + [header[1], header[3] && wrap(header[3], width)] + .filter(Boolean) + .join("\n"), + ); + } else if (title.startsWith("Usage: ")) { + let usage = block.replace( + /([a-z])([A-Z])/gu, + (_, lower: string, upper: string) => `${lower}-${upper.toLowerCase()}`, + ); + if (command === "scans" || command === "findings") { + usage = usage.replace("", "[command]"); + usage += "\nDefault command: list"; + } + sections.push( + usage + .split("\n") + .map((line) => wrap(line, width, "", " ")) + .join("\n"), + ); + if (command === "") { + sections.push( + "Get started:\n" + + " codex-security login\n" + + " codex-security scan .\n" + + " codex-security findings", + ); + } else if (examples) { + sections.push(examples); + } + } else if (title === "Examples:") { + continue; + } else if (title === "Commands:") { + const rows = parseRows(lines); + sections.push( + ...(command === "" + ? groupedRows(rows, COMMAND_GROUPS, "Other commands", width) + : [renderRows("Commands", rows, width)]), + ); + } else if (title === "Options:") { + const rows = parseRows(lines).map((row) => optionRow(row, command)); + sections.push( + ...(command === "scan" + ? groupedRows(rows, SCAN_GROUPS, "Advanced", width) + : command === "publish scan" + ? groupedRows(rows, PUBLISH_GROUPS, "Options", width) + : [renderRows("Options", rows, width)]), + ); + } else if (title === "Global Options:") { + sections.push( + ...groupedRows( + globalRows(parseRows(lines), command), + OUTPUT_GROUPS, + "Agent options", + width, + ), + ); + } else if ( + ["Arguments:", "Integrations:", "Environment Variables:"].includes(title) + ) { + const rows = parseRows(lines).map((row) => ({ + ...row, + label: + title === "Arguments:" + ? row.label.replace(/([a-z])([A-Z])/gu, "$1-$2").toLowerCase() + : row.label, + })); + sections.push(renderRows(title.slice(0, -1), rows, width)); + } else { + sections.push( + block + .split("\n") + .map((line) => (line.startsWith(" ") ? line : wrap(line, width))) + .join("\n"), + ); + } + } + if (command === "") { + sections.push( + wrap( + "Use codex-security --help for options and examples.", + width, + ), + ); + sections.push("Docs: https://learn.chatgpt.com/docs/security/cli"); + } + return `${sections.join("\n\n")}\n`; +} diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 05537c3ee7..db6a3a2b90 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -45,6 +45,7 @@ import { pipeline } from "node:stream/promises"; import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify, stripVTControlCharacters } from "node:util"; import { Cli, z } from "incur"; +import { formatCliHelp } from "./cli-help.js"; import { scanLogsJson } from "./cli-scan-logs-json.js"; import { parse as parseToml } from "smol-toml"; import { @@ -2033,7 +2034,7 @@ export async function main( return result; }; const findingFeedback = Cli.create("findings", { - description: "Review and manage saved Codex Security findings.", + description: "Review saved findings (default: list).", }).command("false-positive", { description: "Mark a finding as a false positive for future scans.", destructive: true, @@ -2108,8 +2109,7 @@ export async function main( }, }); const scanHistory = Cli.create("scans", { - description: - "List, inspect, rerun, match, and compare saved Codex Security scans.", + description: "Review saved scans (default: list).", }) .command("list", { description: "List saved scans for a repository or scan root.", @@ -2172,6 +2172,13 @@ export async function main( .default(false) .describe("Show findings linked across previous scans."), }), + examples: [ + { args: {}, description: "Show the latest completed scan." }, + { + args: { scanId: "scan_abc123" }, + description: "Show a saved scan by ID or unique prefix.", + }, + ], output: z.record(z.string(), z.unknown()).optional(), async run({ args, format, options }) { const scanId = args.scanId ?? (await latestScans())?.[0]?.scanId; @@ -2201,6 +2208,13 @@ export async function main( .optional() .describe("Scan identifier or unique prefix (default: latest)."), }), + examples: [ + { args: {}, description: "Show activity from the latest scan." }, + { + args: { scanId: "scan_abc123" }, + description: "Show activity from a saved scan.", + }, + ], output: z.record(z.string(), z.unknown()).optional(), async run({ args, format }) { const scanId = @@ -2543,6 +2557,10 @@ export async function main( description: "Publish Codex Security findings.", }).command("scan", { description: "Publish findings from a completed scan or CSV.", + hint: + "Examples:\n" + + " codex-security publish scan --to linear --scan latest --linear-team TEAM_ID --dry-run\n" + + " codex-security publish scan --to custom --scan latest --findings-url http://localhost:3000", destructive: true, mcp: false, args: z.object({ @@ -2586,7 +2604,7 @@ export async function main( "Unsupported publication destination. Use --to linear or --to custom.", }, ) - .describe("Publication destination (linear or custom)."), + .describe("Required publication destination: linear or custom."), findingsUrl: optionValue("--findings-url") .url() .optional() @@ -3175,9 +3193,10 @@ export async function main( }, }); const imports = Cli.create("import", { - description: "Read upstream findings for local validation or triage.", + description: "Read GitHub code scanning alerts.", }).command("github", { - description: "Import GitHub code scanning alerts without changing GitHub.", + description: "Read GitHub code scanning alerts without changing GitHub.", + hint: "To save CSV or JSON findings as a local scan, use codex-security scan import --help.", destructive: false, mcp: false, args: z.object({ @@ -3260,8 +3279,7 @@ export async function main( }, }); const cli = Cli.create("codex-security", { - description: - "Draft security policies; run, import, validate, patch, verify fixes, export, and publish Codex Security findings.", + description: "Find, review, and fix security issues in your code.", version: VERSION, mcp: { command: "npx --yes @openai/codex-security --mcp", @@ -3271,8 +3289,7 @@ export async function main( }, }) .command("policy", { - description: - "Draft SECURITY.md guidance for future scans and owner review.", + description: "Draft SECURITY.md guidance for review.", destructive: true, mcp: false, args: z.object({ @@ -3311,7 +3328,9 @@ export async function main( auth: z .enum(["auto", "chatgpt", "api-key"]) .default("auto") - .describe("Select ChatGPT, API-key, or automatic authentication."), + .describe( + "Select ChatGPT, OPENAI_API_KEY/CODEX_API_KEY, or automatic authentication.", + ), model: optionValue("--model") .optional() .describe( @@ -3464,8 +3483,14 @@ export async function main( }, }) .command("scan", { - description: "Run a Codex Security scan.", + description: "Scan a repository, selected paths, or Git changes.", hint: + "Examples:\n" + + " codex-security scan .\n" + + " codex-security scan . --path src --path tests\n" + + " codex-security scan . --working-tree\n" + + " codex-security scan . --diff origin/main\n" + + " codex-security scan . --mode deep\n\n" + "Import existing findings without security analysis:\n" + " codex-security scan import --csv findings.csv\n" + " codex-security scan import --json findings.json\n" + @@ -3579,7 +3604,9 @@ export async function main( .enum(REPORTABLE_SEVERITIES) .optional() .describe("Patch findings at or above LEVEL; requires --patch."), - createPr: CREATE_PR_OPTION, + createPr: CREATE_PR_OPTION.describe( + "Create a draft pull request or merge request after verified patches; requires --patch.", + ), maxCost: ScanSettingsSchema.shape.maxCostUsd.describe( "Stop above AMOUNT in estimated USD; the dashboard offers increases near the limit.", ), @@ -3619,28 +3646,6 @@ export async function main( message: "--mock cannot be combined with --dry-run or --patch.", }, ), - examples: [ - { args: { repository: "." } }, - { - args: { repository: "." }, - options: { config: "codex-security.yaml" }, - }, - { args: { repository: "." }, options: { model: "gpt-5.6-terra" } }, - { - args: { repository: "." }, - options: { model: "gpt-5.6-terra", effort: "high" }, - }, - { args: { repository: "." }, options: { path: ["src"] } }, - { args: { repository: "." }, options: { diff: "origin/main" } }, - { - args: { repository: "." }, - options: { - codex: [ - "features.multi_agent_v2.max_concurrent_threads_per_session=4", - ], - }, - }, - ], output: scanOutputSchema, async run({ args, error: incurError, format, options }) { if (format === "md") { @@ -3758,8 +3763,8 @@ export async function main( }, }) .command("install-hook", { - description: - "Install an advisory local Git pre-commit check. Require a passing scan in CI.", + description: "Install an advisory Git pre-commit check.", + hint: "Require a passing scan in CI to enforce a scan before merging.", destructive: true, mcp: false, args: z.object({ @@ -3834,8 +3839,7 @@ export async function main( .command(findingFeedback) .command(publication) .command("suggest-owners", { - description: - "Suggest finding owners from committed source and Git history.", + description: "Suggest finding owners from source and Git history.", destructive: false, mcp: false, args: z.object({ @@ -3910,8 +3914,8 @@ export async function main( }, }) .command("classify-severity", { - description: - "Classify saved findings using an optional rubric and save a separate severity assessment.", + description: "Assess severity for saved findings.", + hint: "Use --rubric to classify findings with a policy. The assessment is saved separately.", destructive: true, mcp: false, options: z.object({ @@ -4014,8 +4018,8 @@ export async function main( }, }) .command("dedupe", { - description: - "Dedupe a saved scan, or use --records for host-provided reviews over JSON-RPC.", + description: "Identify duplicate findings in a saved scan.", + hint: "Use --records alone for host-provided reviews over JSON-RPC.", destructive: true, mcp: false, options: z.object({ @@ -4145,8 +4149,7 @@ export async function main( }) .command(imports) .command("scan-components", { - description: - "Run standard scans for project components and combine the results.", + description: "Scan project components and combine results.", destructive: true, mcp: false, alias: { config: "c" }, @@ -4161,7 +4164,7 @@ export async function main( .object({ config: PROJECT_CONFIG_OPTION, auth: ScanSettingsSchema.shape.auth.describe( - "Select ChatGPT, OPENAI_API_KEY/CODEX_API_KEY, or automatic authentication.", + "Select ChatGPT, OPENAI_API_KEY/CODEX_API_KEY, or automatic authentication (default: auto).", ), component: z .array(optionValue("--component")) @@ -4426,8 +4429,7 @@ export async function main( }, }) .command("bulk-scan", { - description: - "Discover repositories and run resumable bulk security scans.", + description: "Discover and scan multiple repositories.", destructive: true, mcp: false, alias: { config: "c" }, @@ -4762,8 +4764,14 @@ export async function main( }, }) .command("export", { - description: - "Export findings from a completed scan as CSV, JSON, or SARIF.", + description: "Export a completed scan as SARIF, JSON, or CSV.", + hint: + "Examples:\n" + + " codex-security export\n" + + " codex-security export --export-format json --output -\n" + + " codex-security export ../scan-results --export-format csv\n\n" + + "Use --export-format for artifact format. The global --format option controls\n" + + "framework output such as --schema; it does not change exported files.", destructive: true, mcp: false, args: z.object({ @@ -4843,7 +4851,9 @@ export async function main( auth: z .enum(SCAN_AUTH_MODES) .default("auto") - .describe("Credential source: auto, chatgpt, or api-key."), + .describe( + "Select ChatGPT, OPENAI_API_KEY/CODEX_API_KEY, or automatic authentication.", + ), effort: effortOption(), codex: z .array(optionValue("--codex")) @@ -4886,11 +4896,13 @@ export async function main( auth: z .enum(SCAN_AUTH_MODES) .default("auto") - .describe("Credential source: auto, chatgpt, or api-key."), + .describe( + "Select ChatGPT, OPENAI_API_KEY/CODEX_API_KEY, or automatic authentication.", + ), effort: effortOption(), scan: optionValue("--scan") .optional() - .describe("Verify open findings from a saved scan."), + .describe("Verify open findings from a saved scan ID or latest."), severity: z .enum(REPORTABLE_SEVERITIES) .optional() @@ -4913,6 +4925,16 @@ export async function main( 'Repeat TOML model="gpt-5.6-terra", model_reasoning_effort="high", or analytics.enabled=false.', ), }), + examples: [ + { + options: { scan: "latest" }, + description: "Verify fixes for the latest completed scan.", + }, + { + args: { "findings...": "finding.md" }, + description: "Verify a fix described in a file.", + }, + ], output: z.record(z.string(), z.unknown()).optional(), async run({ format, options }) { try { @@ -5101,13 +5123,15 @@ export async function main( .string() .min(1, "An issue must not be empty.") .optional() - .describe("Issue text or a file containing issues."), + .describe("Issue text, a file, or a saved finding identifier."), }), options: z.object({ auth: z .enum(SCAN_AUTH_MODES) .default("auto") - .describe("Credential source: auto, chatgpt, or api-key."), + .describe( + "Select ChatGPT, OPENAI_API_KEY/CODEX_API_KEY, or automatic authentication.", + ), effort: effortOption(), externalSandbox: z .boolean() @@ -5117,7 +5141,7 @@ export async function main( ), scan: optionValue("--scan") .optional() - .describe("Patch open findings from a saved scan."), + .describe("Patch open findings from a saved scan ID or latest."), severity: z .enum(REPORTABLE_SEVERITIES) .optional() @@ -5152,6 +5176,16 @@ export async function main( 'Repeat TOML model="gpt-5.6-terra", model_reasoning_effort="high", or analytics.enabled=false.', ), }), + examples: [ + { + options: { scan: "latest", severity: "high" }, + description: "Patch high and critical findings from the latest scan.", + }, + { + args: { "issues...": "finding.md" }, + description: "Patch a finding described in a file.", + }, + ], output: z.record(z.string(), z.unknown()).optional(), async run({ format, options, error: commandError }) { const jsonOutput = format === "json" || format === "jsonl"; @@ -5475,7 +5509,10 @@ export async function main( destructive: true, mcp: false, args: z.object({ - action: z.enum(["status"]).optional().describe("Show login status."), + action: z + .enum(["status"]) + .optional() + .describe("Use status to inspect credentials; omit to sign in."), }), options: z.object({ deviceAuth: z @@ -5491,6 +5528,10 @@ export async function main( .default(false) .describe("Read an access token from stdin."), }), + examples: [ + { args: {}, description: "Sign in with ChatGPT." }, + { args: { action: "status" }, description: "Check authentication." }, + ], async run({ args, options }) { const credentialHome = dependencies.prepareAuthenticationHome !== undefined @@ -5606,8 +5647,11 @@ export async function main( }, }) .command("serve", { - description: - "Start the findings HTTP service (HOST=127.0.0.1, PORT=3000). CODEX_SECURITY_EMBEDDINGS_URL overrides the embeddings endpoint (default: https://api.openai.com/v1/embeddings).", + description: "Start the local findings HTTP service.", + hint: + "Environment: HOST=127.0.0.1, PORT=3000.\n" + + "CODEX_SECURITY_EMBEDDINGS_URL overrides the embeddings endpoint\n" + + "(default: https://api.openai.com/v1/embeddings).", destructive: true, mcp: false, options: z.object({ @@ -5637,8 +5681,8 @@ export async function main( }, }) .command("init", { - description: - "Write a starter project configuration without overwriting an existing file.", + description: "Create a starter project configuration.", + hint: "Existing files are never overwritten.", destructive: true, mcp: false, args: z.object({ @@ -5742,8 +5786,7 @@ export async function main( }, }) .command("info", { - description: - "Show SDK metadata and resolved configuration without preparing a scan.", + description: "Show SDK metadata and resolved configuration.", alias: { config: "c" }, options: z.object({ config: PROJECT_CONFIG_OPTION }), mcp: { @@ -5949,7 +5992,7 @@ export async function main( renderedPatch ?? renderedPublication ?? renderedHistory ?? - frameworkOutput, + formatCliHelp(frameworkOutput, output.columns), ); return exitCode; } catch (error) { diff --git a/sdk/typescript/tests-ts/cli-cloud-publish.test.ts b/sdk/typescript/tests-ts/cli-cloud-publish.test.ts index 60b7f0b2d5..2f3af3c541 100644 --- a/sdk/typescript/tests-ts/cli-cloud-publish.test.ts +++ b/sdk/typescript/tests-ts/cli-cloud-publish.test.ts @@ -67,7 +67,7 @@ describe("publish scan to Cloud", () => { dependencies(), ), ).toBe(0); - expect(stdout.text()).toContain("--csv "); + expect(stdout.text()).toContain("--csv "); expect(stdout.text()).toContain("Findings CSV"); }); diff --git a/sdk/typescript/tests-ts/cli-help.test.ts b/sdk/typescript/tests-ts/cli-help.test.ts new file mode 100644 index 0000000000..9c56ce23b3 --- /dev/null +++ b/sdk/typescript/tests-ts/cli-help.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, test } from "bun:test"; +import { main } from "../src/cli.js"; +import { DEFAULT_DEEP_SCAN_SETTINGS } from "../src/deep-scan-defaults.js"; +import { capture, dependencies } from "./cli-fixtures.js"; + +async function help(args: readonly string[], columns?: number) { + const stdout = capture(true); + const stderr = capture(true); + const calls: string[] = []; + const unexpected = (name: string): never => { + calls.push(name); + throw new Error(`Help must not call ${name}.`); + }; + const deps = dependencies({ + onConfig: () => unexpected("createSecurity"), + onCodex: () => unexpected("runCodex"), + onWorkbench: () => unexpected("runWorkbench"), + onRepositoryCommand: () => unexpected("runRepositoryCommand"), + onUpdateCheck: async () => unexpected("checkForUpdate"), + }); + deps.prepareAuthenticationHome = async () => + unexpected("prepareAuthenticationHome"); + deps.importScan = async () => unexpected("importScan"); + const code = await main( + args, + { ...stdout.stream, ...(columns === undefined ? {} : { columns }) }, + stderr.stream, + deps, + ); + expect(code).toBe(0); + expect(stderr.text()).toBe(""); + expect(calls).toEqual([]); + return stdout.text(); +} + +function option(helpText: string, flag: string): string { + const lines = helpText.split("\n"); + const start = lines.findIndex((line) => line.startsWith(` ${flag} `)); + if (start === -1) return ""; + let end = start + 1; + while ( + end < lines.length && + lines[end]!.trim() !== "" && + !/^ --[a-z]/u.test(lines[end]!) && + /^\s/u.test(lines[end]!) + ) { + end += 1; + } + return lines.slice(start, end).join(" ").replace(/\s+/gu, " "); +} + +describe("CLI help", () => { + test("groups the common workflow ahead of setup and integrations", async () => { + const text = await help(["--help"]); + const sections = text.split(/\n(?=[A-Z][^\n]+:\n)/u); + const sectionFor = (command: string) => + sections.findIndex((section) => + new RegExp(`^ ${command} {2,}`, "mu").test(section), + ); + expect(sectionFor("scan")).toBeGreaterThanOrEqual(0); + expect(sectionFor("patch")).toBe(sectionFor("scan")); + expect(sectionFor("scans")).toBeGreaterThan(sectionFor("scan")); + expect(sectionFor("findings")).toBe(sectionFor("scans")); + expect(sectionFor("login")).toBeGreaterThan(sectionFor("scan")); + expect(sectionFor("init")).toBe(sectionFor("login")); + expect(sectionFor("completions")).toBeGreaterThan(sectionFor("scan")); + expect(text).toContain("codex-security scan ."); + expect(text).toContain(" --help"); + }); + + test("shows semantic values, repeatable inputs, choices, and scan defaults", async () => { + const text = await help(["scan", "--help"]); + expect(text).not.toMatch(/<(?:array|string|number)>/u); + expect(option(text, "--path")).toContain(""); + expect(option(text, "--path")).toMatch(/repeat/iu); + expect(option(text, "--codex")).toContain(""); + expect(option(text, "--codex")).toMatch(/repeat/iu); + expect(option(text, "--max-cost")).toContain(""); + expect(option(text, "--mode")).toContain(""); + expect(option(text, "--mode")).toContain("standard"); + expect(option(text, "--mode")).toContain("deep"); + expect(option(text, "--provider")).toContain("amazon-bedrock"); + for (const [flag, value] of [ + ["--workers", DEFAULT_DEEP_SCAN_SETTINGS.workers], + ["--subagents", DEFAULT_DEEP_SCAN_SETTINGS.subagents], + ["--stop-after-no-new", DEFAULT_DEEP_SCAN_SETTINGS.stopAfterNoNew], + ["--max-discovery-runs", DEFAULT_DEEP_SCAN_SETTINGS.maxDiscoveryRuns], + ] as const) { + expect(option(text, flag)).toMatch( + new RegExp(`default: ${value}\\b`, "iu"), + ); + } + }); + + test("uses repeatable flags and bare booleans in copyable examples", async () => { + const scan = await help(["scan", "--help"]); + expect(scan).toContain("--path src --path tests"); + expect(scan).toMatch( + /^ codex-security scan \. --working-tree(?:\s+#.*)?$/mu, + ); + const publish = await help(["publish", "scan", "--help"]); + expect(publish).not.toMatch(/--dry-run true\b/u); + expect(publish).toMatch( + /^ codex-security publish scan .*--dry-run(?:\s+#.*)?$/mu, + ); + }); + + test.each([ + { command: ["scan"] }, + { command: ["scan", "import"] }, + { command: ["scans", "rerun"] }, + ])("advertises supported formats for $command", async ({ command }) => { + const text = await help([...command, "--help"]); + const format = option(text, "--format"); + expect(format).toContain("json"); + expect(format).not.toMatch(/\bmd\b/u); + if (command[0] === "scan") { + expect(option(text, "--filter-output")).toBe(""); + } + }); + + test.each(["validate", "login", "logout", "serve"])( + "%s does not promise unsupported JSON command output", + async (command) => { + const text = await help([command, "--help"]); + expect(option(text, "--format")).not.toMatch(/\bjsonl?\b/u); + expect(option(text, "--json")).toBe(""); + const schema = capture(); + expect( + await main( + [command, "--schema", "--json"], + schema.stream, + capture().stream, + dependencies(), + ), + ).toBe(0); + expect(JSON.parse(schema.text())).toEqual(expect.any(Object)); + }, + ); + + test("documents raw export formats without promising output transforms", async () => { + const text = await help(["export", "--help"]); + expect(option(text, "--export-format")).toContain(""); + for (const flag of [ + "--format", + "--json", + "--filter-output", + "--full-output", + "--token-count", + "--token-limit", + "--token-offset", + ]) { + expect(option(text, flag)).toBe(""); + } + const schema = await help(["export", "--schema", "--json"]); + expect(JSON.parse(schema)).toMatchObject({ + options: { + properties: { + exportFormat: { enum: ["csv", "json", "sarif"], default: "sarif" }, + }, + }, + }); + }); + + test("distinguishes JSON file import from JSON output", async () => { + const text = await help(["scan", "import", "--help"]); + expect(option(text, "--json")).toContain(""); + expect(option(text, "--csv")).toContain(""); + expect(text.replace(/\s+/gu, " ")).toContain("--format json"); + expect(option(text, "--format")).toContain("json"); + }); + + test.each([ + { args: ["-h"], usage: "codex-security " }, + { args: ["--help", "scan"], usage: "codex-security scan [repository]" }, + { + args: ["scan", "--mode", "bogus", "--help"], + usage: "codex-security scan [repository]", + }, + { + args: ["scan", "--config", "missing.yaml", "--help"], + usage: "codex-security scan [repository]", + }, + { + args: ["scan", "import", "--json", "--help"], + usage: "codex-security scan import", + }, + { + args: ["--format", "json", "scan", "import", "--help"], + usage: "codex-security scan import", + }, + { args: ["scans", "--help"], usage: "codex-security scans [command]" }, + { + args: ["findings", "--help"], + usage: "codex-security findings [command]", + }, + { args: ["login", "status", "--help"], usage: "codex-security login" }, + { args: ["unknown", "--help"], usage: "codex-security " }, + ])("preserves help routing for $args", async ({ args, usage }) => { + const text = await help(args); + expect(text).toContain(`Usage: ${usage}`); + }); + + test.each([ + { command: ["mcp"] }, + { command: ["mcp", "add"] }, + { command: ["skills"] }, + { command: ["skills", "list"] }, + { command: ["completions"] }, + ])("retains framework help for $command", async ({ command }) => { + const text = await help([...command, "--help"]); + expect(text).toContain(`Usage: codex-security ${command.join(" ")}`); + if (command[0] === "skills") { + expect(text).toMatch( + command.length === 1 ? /^Aliases: skill$/mu : /^Aliases: ls$/mu, + ); + } + if (command[0] === "completions") { + for (const shell of ["bash", "fish", "nushell", "zsh"]) { + expect(text).toContain(shell); + } + } + }); + + test.each([60, 80])( + "wraps descriptions at %i columns without breaking examples", + async (columns) => { + for (const command of [ + [], + ["scan"], + ["publish", "scan"], + ["scan", "import"], + ]) { + const text = await help([...command, "--help"], columns); + expect(text).not.toMatch(/\u001b\[/u); + for (const line of text.split("\n")) { + if (line.startsWith(" codex-security ")) continue; + const indent = line.length - line.trimStart().length; + const longestToken = Math.max( + ...line + .trim() + .split(/\s+/u) + .map((word) => word.length), + ); + expect(line.length, line).toBeLessThanOrEqual( + Math.max(columns, indent + longestToken), + ); + } + } + const text = await help(["scan", "--help"], columns); + expect(text).toMatch(/^ codex-security scan \.(?:\s+#.*)?$/mu); + }, + ); +}); diff --git a/sdk/typescript/tests-ts/cli-scan-import.test.ts b/sdk/typescript/tests-ts/cli-scan-import.test.ts index ac96dce28c..8e0260c0f3 100644 --- a/sdk/typescript/tests-ts/cli-scan-import.test.ts +++ b/sdk/typescript/tests-ts/cli-scan-import.test.ts @@ -163,8 +163,8 @@ describe("scan import", () => { deps, ), ).toBe(0); - expect(help.text()).toContain("--csv "); - expect(help.text()).toContain("--json "); + expect(help.text()).toContain("--csv "); + expect(help.text()).toContain("--json "); expect(help.text()).toContain("--format json"); const schema = capture(); expect( diff --git a/sdk/typescript/tests-ts/cli-skills.test.ts b/sdk/typescript/tests-ts/cli-skills.test.ts index 70d0267a43..8d0a5a1340 100644 --- a/sdk/typescript/tests-ts/cli-skills.test.ts +++ b/sdk/typescript/tests-ts/cli-skills.test.ts @@ -138,10 +138,8 @@ describe("CLI skill commands", () => { expect(help.text()).toContain( `Usage: codex-security ${command} ${command === "patch" ? `[${argument}]` : `<${argument}>`}`, ); - expect(help.text()).toContain( - "--effort ", - ); - expect(help.text()).toContain("--codex "); + expect(help.text()).toContain("--effort "); + expect(help.text()).toContain("--codex "); expect(help.text()).toContain('model="gpt-5.6-terra"'); expect(help.text()).toContain('model_reasoning_effort="high"'); expect(help.text()).toContain("analytics.enabled=false"); diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index 239ef7109c..2c2a6040f8 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -122,9 +122,9 @@ describe("CLI", () => { expect(root.text()).toContain("bulk-scan"); expect(root.text()).toContain("install-hook"); expect(root.text()).not.toContain("multiscan"); - expect(root.text()).toContain("Integrations:"); expect(root.text()).toContain("completions"); - expect(root.text()).toContain("--llms, --llms-full"); + expect(root.text()).toContain("--llms"); + expect(root.text()).toContain("--llms-full"); expect(stderr.text()).toBe(""); const schema = capture(); @@ -285,23 +285,37 @@ describe("CLI", () => { test("documents every public command argument and option", async () => { const commands = [ + ["policy"], ["scan"], + ["scan", "import"], + ["scan-components"], ["dedupe"], + ["classify-severity"], + ["suggest-owners"], ["bulk-scan"], ["export"], ["validate"], + ["verify-fix"], ["patch"], ["login"], ["logout"], ["feedback"], ["info"], ["install-hook"], + ["init"], + ["serve"], + ["publish", "scan"], + ["publish", "check"], + ["import", "github"], ["scans", "list"], + ["scans", "logs"], + ["scans", "resume"], ["scans", "show"], ["scans", "rerun"], ["scans", "match"], ["scans", "compare"], ["findings", "false-positive"], + ["findings", "list"], ] as const; for (const command of commands) { @@ -2354,9 +2368,10 @@ describe("CLI", () => { expect( await main(["export", "--help"], stdout.stream, stderr.stream, deps), ).toBe(0); - expect(stdout.text()).toContain("Usage: codex-security export [scanDir]"); - expect(stdout.text()).toContain("--export-format "); - expect(stdout.text()).toContain("--source-root "); + expect(stdout.text()).toContain("Usage: codex-security export [scan-dir]"); + expect(stdout.text()).toContain("--export-format "); + expect(stdout.text()).toContain("--source-root "); + expect(stdout.text()).toContain("sarif"); expect(stdout.text()).not.toContain("--format {sarif}"); expect(stderr.text()).toBe(""); }); @@ -2379,47 +2394,32 @@ describe("CLI", () => { dependencies(), ), ).toBe(0); - expect(help.text()).toContain("Usage: codex-security scan [repository]"); - expect(help.text()).toContain("--verbose"); - expect(help.text()).toContain("--path "); - expect(help.text()).toContain("--max-cost "); - expect(help.text()).toContain("--show-cost"); - expect(help.text()).toContain("--workers "); - expect(help.text()).toContain("--subagents "); - expect(help.text()).toContain("--stop-after-no-new "); - expect(help.text()).toContain("--max-discovery-runs "); - expect(help.text()).toContain("--max-time-hours "); - expect(help.text()).toContain("--headless"); - expect(help.text()).toContain( - "Use plain text progress instead of the interactive dashboard.", - ); - expect(help.text()).toContain("--model "); - expect(help.text()).toContain( - "--provider ", - ); - expect(help.text()).toContain( - `OpenAI model to use (default: ${DEFAULT_SCAN_MODEL_CONFIGURATION.model}).`, - ); - expect(help.text()).toContain( - "--effort ", - ); - expect(help.text()).toContain( - `Model reasoning effort (default: ${DEFAULT_SCAN_MODEL_CONFIGURATION.reasoningEffort}).`, - ); - expect(help.text()).toContain('model_reasoning_effort="high"'); - expect(help.text()).toContain( - "features.multi_agent_v2.max_concurrent_threads_per_session=4", - ); - expect(help.text()).toContain("default: Codex Security state"); - expect(help.text()).toContain( - "codex-security scan . --model gpt-5.6-terra", - ); - expect(help.text()).toContain( - "codex-security scan . --model gpt-5.6-terra --effort high", - ); - expect(help.text()).not.toContain("openai:gpt"); - expect(help.text()).not.toContain("codex-security scan . --path src,tests"); - expect(help.text()).toContain("--format "); + const text = help.text().replace(/\s+/gu, " "); + expect(text).toContain("Usage: codex-security scan [repository]"); + for (const option of [ + "--verbose", + "--path ", + "--max-cost ", + "--show-cost", + "--workers ", + "--subagents ", + "--stop-after-no-new ", + "--max-discovery-runs ", + "--max-time-hours ", + "--headless", + "--model ", + "--provider ", + "--effort ", + ]) { + expect(text).toContain(option); + } + expect(text).toContain(DEFAULT_SCAN_MODEL_CONFIGURATION.model); + expect(text).toContain(DEFAULT_SCAN_MODEL_CONFIGURATION.reasoningEffort); + expect(text).toContain('model_reasoning_effort="high"'); + expect(text).toContain("CODEX_SECURITY_STATE_DIR"); + expect(text).not.toContain("openai:gpt"); + expect(text).not.toContain("codex-security scan . --path src,tests"); + expect(stderr.text()).toBe(""); }); test("documents existing model and reasoning options in bulk-scan help", async () => { @@ -2434,41 +2434,21 @@ describe("CLI", () => { dependencies(), ), ).toBe(0); - expect(help.text()).toContain("--model "); - expect(help.text()).toContain( - `OpenAI model for each repository (default: ${DEFAULT_SCAN_MODEL_CONFIGURATION.model}).`, - ); - expect(help.text()).toContain( - "--effort ", - ); - expect(help.text()).toContain( - `Model reasoning effort (default: ${DEFAULT_SCAN_MODEL_CONFIGURATION.reasoningEffort}).`, - ); - expect(help.text()).toContain("--codex "); - expect(help.text()).toContain('model_reasoning_effort="high"'); - expect(help.text()).toContain( - "features.multi_agent_v2.max_concurrent_threads_per_session=4", - ); - expect(help.text()).toContain("Concurrent repository scans."); - expect(help.text()).toContain( - "Default scan mode for repositories without a CSV mode.", - ); - expect(help.text()).toContain( - "Codex Security plugin directory or ZIP (default: bundled plugin).", - ); - expect(help.text()).toContain( - "Python interpreter (default: PYTHON or automatic discovery).", - ); - expect(help.text()).toContain( - "codex-security bulk-scan repositories.csv " + - "--output-dir /path/outside/repositories/results " + - "--workers 4 --max-attempts 3", - ); - expect(help.text()).not.toContain("--outputDir"); - expect(help.text()).not.toContain("--maxAttempts"); - expect(help.text()).toContain( - "--provider ", - ); + const text = help.text().replace(/\s+/gu, " "); + expect(text).toContain("--model "); + expect(text).toContain(DEFAULT_SCAN_MODEL_CONFIGURATION.model); + expect(text).toContain("--effort "); + expect(text).toContain(DEFAULT_SCAN_MODEL_CONFIGURATION.reasoningEffort); + expect(text).toContain("--codex "); + expect(text).toContain('model_reasoning_effort="high"'); + expect(text).toContain("Concurrent repository scans."); + expect(text).toContain("--mode "); + expect(text).toContain("bundled plugin"); + expect(text).toContain("PYTHON"); + expect(text).toContain("codex-security bulk-scan repositories.csv"); + expect(text).not.toContain("--outputDir"); + expect(text).not.toContain("--maxAttempts"); + expect(text).toContain("--provider "); expect(stderr.text()).toBe(""); }); From f42aaf724c96c99ba8602b2ede23656b7eb7ffb0 Mon Sep 17 00:00:00 2001 From: mldangelo-oai <269034524+mldangelo-oai@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:32:52 +0000 Subject: [PATCH 2/3] fix(cli): align help with supported publication inputs --- sdk/typescript/README.md | 9 ++++----- sdk/typescript/src/cli-help.ts | 6 ++++-- sdk/typescript/src/cli.ts | 13 +++++++++---- .../tests-ts/cli-cloud-publish.test.ts | 6 +++--- sdk/typescript/tests-ts/cli.test.ts | 16 +++++++++++++++- 5 files changed, 35 insertions(+), 15 deletions(-) diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index ef0ca07f85..636097cff5 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -488,11 +488,10 @@ npx @openai/codex-security scan /path/to/repository --output-dir /path/outside/r npx @openai/codex-security scan /path/to/repository --dry-run ``` -Use `--help` for commands grouped by task, and ` --help` (or `-h`) -for examples and options. For example, `scan --help` groups scope, Deep Scan, -results, and patching options; `publish scan --help` separates destination -settings. Help wraps descriptions to the terminal width and keeps example -commands copyable. `scans` and `findings` default to their `list` subcommands. +Use `--help` to browse commands by task, or ` --help` (or `-h`) for +options and examples. `scan --help` groups options by scope, Deep Scan, results, +and patching; `publish scan --help` groups destination settings. `scans` and +`findings` run `list` when no subcommand is given. Use `--version` for the installed version, and `info --json` for package, plugin, runtime, and model details. `--dry-run` diff --git a/sdk/typescript/src/cli-help.ts b/sdk/typescript/src/cli-help.ts index 9669cde9b7..5da0cb16ec 100644 --- a/sdk/typescript/src/cli-help.ts +++ b/sdk/typescript/src/cli-help.ts @@ -48,7 +48,7 @@ const SCAN_GROUPS: readonly Group[] = [ ]; const PUBLISH_GROUPS: readonly Group[] = [ - ["Input", ["scan", "scan-dir", "csv", "finding-id", "workflow-id"]], + ["Input", ["scan", "scan-dir", "finding-id", "workflow-id"]], ["Destination", ["to", "findings-url"]], [ "Linear", @@ -292,7 +292,9 @@ export function formatCliHelp(text: string, columns = 80): string { : [renderRows("Commands", rows, width)]), ); } else if (title === "Options:") { - const rows = parseRows(lines).map((row) => optionRow(row, command)); + const rows = parseRows(lines) + .filter((row) => command !== "publish scan" || rowName(row) !== "csv") + .map((row) => optionRow(row, command)); sections.push( ...(command === "scan" ? groupedRows(rows, SCAN_GROUPS, "Advanced", width) diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index db6a3a2b90..5121c1e6a9 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -2556,7 +2556,7 @@ export async function main( const publication = Cli.create("publish", { description: "Publish Codex Security findings.", }).command("scan", { - description: "Publish findings from a completed scan or CSV.", + description: "Publish findings from a completed scan.", hint: "Examples:\n" + " codex-security publish scan --to linear --scan latest --linear-team TEAM_ID --dry-run\n" + @@ -4902,7 +4902,9 @@ export async function main( effort: effortOption(), scan: optionValue("--scan") .optional() - .describe("Verify open findings from a saved scan ID or latest."), + .describe( + "Verify open findings from a saved scan ID or the latest completed scan.", + ), severity: z .enum(REPORTABLE_SEVERITIES) .optional() @@ -5141,7 +5143,9 @@ export async function main( ), scan: optionValue("--scan") .optional() - .describe("Patch open findings from a saved scan ID or latest."), + .describe( + "Patch open findings from a saved scan ID or the latest completed scan.", + ), severity: z .enum(REPORTABLE_SEVERITIES) .optional() @@ -5179,7 +5183,8 @@ export async function main( examples: [ { options: { scan: "latest", severity: "high" }, - description: "Patch high and critical findings from the latest scan.", + description: + "Patch high and critical findings from the latest completed scan.", }, { args: { "issues...": "finding.md" }, diff --git a/sdk/typescript/tests-ts/cli-cloud-publish.test.ts b/sdk/typescript/tests-ts/cli-cloud-publish.test.ts index 2f3af3c541..905217fc2a 100644 --- a/sdk/typescript/tests-ts/cli-cloud-publish.test.ts +++ b/sdk/typescript/tests-ts/cli-cloud-publish.test.ts @@ -57,7 +57,7 @@ async function savedScansFixture() { } describe("publish scan to Cloud", () => { - test("documents the findings CSV option", async () => { + test("omits the internal CSV input from public help", async () => { const stdout = capture(); expect( await main( @@ -67,8 +67,8 @@ describe("publish scan to Cloud", () => { dependencies(), ), ).toBe(0); - expect(stdout.text()).toContain("--csv "); - expect(stdout.text()).toContain("Findings CSV"); + expect(stdout.text()).not.toContain("--csv"); + expect(stdout.text()).not.toContain("CSV"); }); test("passes --dry-run through when publishing a findings CSV", async () => { diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index 2c2a6040f8..aa9efa55f4 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -348,9 +348,21 @@ describe("CLI", () => { }; }; - for (const argument of Object.values( + const argumentLabels = help + .text() + .split("\n\n") + .find((section) => section.startsWith("Arguments:\n")) + ?.split("\n") + .filter((line) => /^ {2}\S/u.test(line)) + .map((line) => line.trim().split(/\s/u)[0]); + for (const [name, argument] of Object.entries( definitions.args?.properties ?? {}, )) { + const label = name.replace( + /[A-Z]/gu, + (letter) => `-${letter.toLowerCase()}`, + ); + expect(argumentLabels).toContain(label); expect(typeof argument.description).toBe("string"); expect(argument.description?.trim().length).toBeGreaterThan(0); } @@ -358,6 +370,8 @@ describe("CLI", () => { for (const [name, option] of Object.entries( definitions.options?.properties ?? {}, )) { + // CSV publication is internal and intentionally omitted from public help. + if (command.join(" ") === "publish scan" && name === "csv") continue; const flag = `--${name.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`)}`; expect(help.text()).toContain(flag); expect(typeof option.description).toBe("string"); From 5f1bccd98a206fe3c8506c32ff2694383f798b34 Mon Sep 17 00:00:00 2001 From: mldangelo-oai <269034524+mldangelo-oai@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:08:56 +0000 Subject: [PATCH 3/3] fix(cli): restore CSV help with publication scope --- sdk/typescript/src/cli-help.ts | 6 ++---- sdk/typescript/src/cli.ts | 7 +++++-- sdk/typescript/tests-ts/cli-cloud-publish.test.ts | 6 +++--- sdk/typescript/tests-ts/cli.test.ts | 2 -- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/sdk/typescript/src/cli-help.ts b/sdk/typescript/src/cli-help.ts index 5da0cb16ec..9669cde9b7 100644 --- a/sdk/typescript/src/cli-help.ts +++ b/sdk/typescript/src/cli-help.ts @@ -48,7 +48,7 @@ const SCAN_GROUPS: readonly Group[] = [ ]; const PUBLISH_GROUPS: readonly Group[] = [ - ["Input", ["scan", "scan-dir", "finding-id", "workflow-id"]], + ["Input", ["scan", "scan-dir", "csv", "finding-id", "workflow-id"]], ["Destination", ["to", "findings-url"]], [ "Linear", @@ -292,9 +292,7 @@ export function formatCliHelp(text: string, columns = 80): string { : [renderRows("Commands", rows, width)]), ); } else if (title === "Options:") { - const rows = parseRows(lines) - .filter((row) => command !== "publish scan" || rowName(row) !== "csv") - .map((row) => optionRow(row, command)); + const rows = parseRows(lines).map((row) => optionRow(row, command)); sections.push( ...(command === "scan" ? groupedRows(rows, SCAN_GROUPS, "Advanced", width) diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 5121c1e6a9..358fab80da 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -2556,7 +2556,8 @@ export async function main( const publication = Cli.create("publish", { description: "Publish Codex Security findings.", }).command("scan", { - description: "Publish findings from a completed scan.", + description: + "Publish findings from a completed scan, or a CSV for internal publication.", hint: "Examples:\n" + " codex-security publish scan --to linear --scan latest --linear-team TEAM_ID --dry-run\n" + @@ -2617,7 +2618,9 @@ export async function main( .describe("Preview the findings without publishing them."), csv: optionValue("--csv") .optional() - .describe("Findings CSV to publish instead of a completed scan."), + .describe( + "Findings CSV for internal publication; not supported with linear or custom.", + ), skipExisting: z .boolean() .default(false) diff --git a/sdk/typescript/tests-ts/cli-cloud-publish.test.ts b/sdk/typescript/tests-ts/cli-cloud-publish.test.ts index 905217fc2a..2f3af3c541 100644 --- a/sdk/typescript/tests-ts/cli-cloud-publish.test.ts +++ b/sdk/typescript/tests-ts/cli-cloud-publish.test.ts @@ -57,7 +57,7 @@ async function savedScansFixture() { } describe("publish scan to Cloud", () => { - test("omits the internal CSV input from public help", async () => { + test("documents the findings CSV option", async () => { const stdout = capture(); expect( await main( @@ -67,8 +67,8 @@ describe("publish scan to Cloud", () => { dependencies(), ), ).toBe(0); - expect(stdout.text()).not.toContain("--csv"); - expect(stdout.text()).not.toContain("CSV"); + expect(stdout.text()).toContain("--csv "); + expect(stdout.text()).toContain("Findings CSV"); }); test("passes --dry-run through when publishing a findings CSV", async () => { diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index aa9efa55f4..14eb41fff2 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -370,8 +370,6 @@ describe("CLI", () => { for (const [name, option] of Object.entries( definitions.options?.properties ?? {}, )) { - // CSV publication is internal and intentionally omitted from public help. - if (command.join(" ") === "publish scan" && name === "csv") continue; const flag = `--${name.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`)}`; expect(help.text()).toContain(flag); expect(typeof option.description).toBe("string");