Files
shannon/apps/cli/src/args.ts
T
ezl-keygraph d41ae9c20d feat(cli): overhaul commands and add live scan status (#424)
* refactor(cli): list workspaces natively instead of via the worker image

* feat(cli): preflight that Docker is installed and running

* feat(cli): stop scans by workspace or --all, terminating their Temporal workflows

* fix(worker): abort the running agent on cancellation so Temporal cancel takes effect

* refactor(cli): split destructive teardown out of stop into a reset command

* refactor(cli): centralise flag parsing and confirmation across commands

* fix(cli): pass provider credentials to docker by name to keep secrets out of argv

* feat(cli): add per-command help via <command> --help/-h and help <command>

* feat(cli): replace raw docker output with clack spinners for infra and scan teardown

* fix(cli): verify scan stop by re-querying container and workflow state instead of assuming success

* fix(cli): resolve running state before prompting on stop and report no-op stops honestly

* refactor(cli): show splash first and drive start with one spinner resolving to a clean line

* fix(cli): validate --url up front so a bad value fails cleanly instead of a late crash

* refactor(cli): centralize error reporting with fail() for expected errors and a crash handler that logs the stack and links the issue tracker

* feat(cli): add --json/--plain machine-readable output to workspaces and status

* refactor(cli): remove the workspaces command

* refactor(cli): remove the status command

* feat(cli): add 'progress <workspace>' — live scan progress from Temporal

* fix(cli): mark metric-less agents as skipped in progress, not done

* feat(cli): animate running agents in progress with a clack-style spinner

* feat(cli): rename progress->status, reveal agents as they run, show live per-agent elapsed

* fix(cli): mark passed-over phases as skipped live, not pending

* style(cli): rename status footer 'Wall-clock' to 'Time Taken', drop the parenthetical

* style(cli): drop '(sum of agents)' from status total cost line

* style(cli): green filled circle for completed, Shannon gold for running

* style(cli): use Shannon gold in place of green in status

* feat(cli): suggest closest command or flag on typo

* refactor(cli): single-source start help and drop ./repos bare-name shortcut

* feat(cli): name providers and fix in multi-provider credential error

* feat(cli): support --flag=value syntax and expand leading ~ in paths

* refactor(cli): centralize ANSI color codes in colors.ts

* feat(cli): add scans command listing completed scans with cost and duration

* fix(cli): keep stdout clean off-TTY for logs and start

* feat(cli): add repo link to top-level help

* feat(worker): record auth-validation metrics and register resume attempts early

* refactor(cli): share resume-aware workflow-id resolution and surface root-cause failures

* feat(cli): add status --json, auth phase, dashboard link, and stable live redraw

* refactor(cli): drop cost from status and scans output

* feat(worker): surface both PDF and markdown report at run root

* refactor(cli): normalize error/warning prefixing through fail and warn

* feat(cli): add version --json for machine-readable output

* refactor(cli): rename start --debug to --keep-container

* refactor(cli): point start's progress hint at status instead of the Temporal dashboard

* refactor(cli): centralize the mode-aware command prefix

* refactor(cli): trim start and logs output to durable facts off-TTY

* feat(cli): require typed confirmation for reset instead of --yes

reset permanently wipes all Temporal data and volumes — a severe,
irreversible action. Replace its default y/N confirm (bypassable with
--yes) with a typed-word confirmation that has no bypass, so the wipe
can only be triggered by a deliberate interactive answer.

* feat(cli): surface logs and status hints after start on a TTY

* feat(cli): exit 2 on usage errors, distinct from operational failures

* feat(cli): add start --follow to stream logs and exit on scan outcome

* refactor(cli): redesign splash with sunset-gradient wordmark and truecolor

* refactor(cli): remove the uninstall command

* docs: sync CLI docs with removed uninstall/workspaces, new scans and --follow

* docs: fix reset confirmation — typed confirm, not --yes/-y

* style(cli): restructure status footer with divider, aligned Logs/Temporal rows

* feat(cli): show splash in the status command

* fix(worker): validate auth-state shape, not entry count

* docs: correct reset confirmation and add markdown report to run-root docs
2026-08-18 15:46:25 +05:30

107 lines
3.7 KiB
TypeScript

/**
* Shared argument parsing for CLI commands.
*
* Every command declares which boolean flags, value options, and positionals it
* accepts; `parseArgs` resolves aliases, rejects anything unrecognized, and hands
* back a typed result. This centralizes the common flags (notably `--yes`/`-y`) so
* each command no longer re-hardcodes `args.includes('--yes')`, and it makes
* unknown flags and stray arguments fail loudly instead of being silently ignored.
*/
import { closestMatch } from './suggest.js';
/** Thrown when argv does not match a command's schema. The dispatcher formats it. */
export class ArgError extends Error {}
/** Tokens that set the "skip confirmation" flag, declared once for every command. */
export const YES_FLAGS = ['--yes', '-y'] as const;
export interface ArgSchema {
/** Boolean flags: result key -> accepted tokens (canonical plus any aliases). */
readonly booleans?: Record<string, readonly string[]>;
/** Value-taking options: result key -> accepted tokens. */
readonly values?: Record<string, readonly string[]>;
/** Maximum positional arguments allowed. Defaults to 0. */
readonly maxPositionals?: number;
/** Extra guidance appended to the error when too many positionals are given. */
readonly positionalHint?: string;
}
export interface ParsedArgs {
readonly flags: Record<string, boolean>;
readonly values: Record<string, string>;
readonly positionals: readonly string[];
}
/** Build a token -> result-key lookup from a schema section. */
function indexTokens(section: Record<string, readonly string[]>): Map<string, string> {
const byToken = new Map<string, string>();
for (const [key, tokens] of Object.entries(section)) {
for (const token of tokens) {
byToken.set(token, key);
}
}
return byToken;
}
export function parseArgs(argv: readonly string[], schema: ArgSchema): ParsedArgs {
const booleanByToken = indexTokens(schema.booleans ?? {});
const valueByToken = indexTokens(schema.values ?? {});
const maxPositionals = schema.maxPositionals ?? 0;
const flags: Record<string, boolean> = {};
const values: Record<string, string> = {};
const positionals: string[] = [];
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === undefined) {
continue;
}
const equalsIndex = arg.startsWith('--') ? arg.indexOf('=') : -1;
const token = equalsIndex === -1 ? arg : arg.slice(0, equalsIndex);
const inlineValue = equalsIndex === -1 ? undefined : arg.slice(equalsIndex + 1);
const booleanKey = booleanByToken.get(token);
if (booleanKey !== undefined) {
if (inlineValue !== undefined) {
throw new ArgError(`Flag ${token} does not take a value`);
}
flags[booleanKey] = true;
continue;
}
const valueKey = valueByToken.get(token);
if (valueKey !== undefined) {
if (inlineValue !== undefined) {
values[valueKey] = inlineValue;
continue;
}
const next = argv[i + 1];
if (next === undefined || next.startsWith('-')) {
throw new ArgError(`Option ${token} requires a value`);
}
values[valueKey] = next;
i++;
continue;
}
if (arg.startsWith('-')) {
const suggestion = closestMatch(token, [...booleanByToken.keys(), ...valueByToken.keys()]);
const hint = suggestion ? `\nDid you mean '${suggestion}'?` : '';
throw new ArgError(`Unknown option: ${token}${hint}`);
}
positionals.push(arg);
}
if (positionals.length > maxPositionals) {
const extra = positionals[maxPositionals];
const hint = schema.positionalHint ? `\n${schema.positionalHint}` : '';
throw new ArgError(`Unexpected argument: ${extra}${hint}`);
}
return { flags, values, positionals };
}