From 6760b6e33f36c493945a0f90a89186df76c2b5a7 Mon Sep 17 00:00:00 2001 From: tamirdresher Date: Tue, 2 Jun 2026 15:22:41 +0300 Subject: [PATCH 1/4] fix(cli): intercept --help on every subcommand (#1201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously `squad --help` and `squad -h` were silently dropped by the CLI router and the command would execute for real — sometimes with destructive side effects (`squad init --help` scaffolded .squad/, .github/, .gitignore into the cwd; `squad triage --help` and `squad watch --help` started a long polling loop; `squad doctor --help` ran the doctor; etc.). Only `loop --help` and `state-mcp --help` were intercepting the flag. Fix: - Add `packages/squad-cli/src/cli/core/command-help.ts` with a per-command help registry (`printCommandHelp`) and a generic fallback (`printGenericCommandHelp`) for commands without dedicated text. - In `cli-entry.ts`, intercept `--help`/`-h` for every subcommand in one place near the top of the router, after the existing top-level `--help` and `--version` handlers. Skip the intercept only when no command is present or when the command itself is `help`/`version` (already handled above). - Remove the now-redundant inline `--help` blocks from the `state-mcp` and `loop` routes; the early intercept handles them and dispatches into the same `command-help.ts` registry, so help text stays centralized. Tests: - `test/cli/command-help.test.ts` — unit tests for `printCommandHelp` and `printGenericCommandHelp`, plus end-to-end spawns that prove `squad init --help`, `init -h`, `triage --help`, `watch --help`, `doctor --help`, `status --help` print help and do not trigger any of the side effects (no .squad/.github scaffolding, no polling loop, no doctor banner, no active-squad print). - `test/acceptance/features/subcommand-help.feature` — Gherkin scenarios wired into the existing acceptance runner. Closes #1201 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/fix-subcommand-help.md | 16 + packages/squad-cli/src/cli-entry.ts | 66 ++- .../squad-cli/src/cli/core/command-help.ts | 416 ++++++++++++++++++ test/acceptance/acceptance.test.ts | 3 + .../features/subcommand-help.feature | 26 ++ test/cli/command-help.test.ts | 234 ++++++++++ 6 files changed, 721 insertions(+), 40 deletions(-) create mode 100644 .changeset/fix-subcommand-help.md create mode 100644 packages/squad-cli/src/cli/core/command-help.ts create mode 100644 test/acceptance/features/subcommand-help.feature create mode 100644 test/cli/command-help.test.ts diff --git a/.changeset/fix-subcommand-help.md b/.changeset/fix-subcommand-help.md new file mode 100644 index 000000000..777cc7467 --- /dev/null +++ b/.changeset/fix-subcommand-help.md @@ -0,0 +1,16 @@ +--- +"@bradygaster/squad-cli": patch +--- + +Fix `squad --help` silently running the command instead of showing help (#1201) + +Previously, passing `--help` or `-h` after a subcommand (e.g. `squad init --help`, +`squad triage --help`, `squad doctor --help`) was silently dropped and the command +would execute for real — sometimes with destructive side effects (init scaffolded +files into the cwd, triage/watch started a polling loop). Only `squad loop --help` +and `squad state-mcp --help` were intercepting the flag. + +The CLI now intercepts `--help`/`-h` for every registered subcommand in one place +at the top of the router and prints command-specific help via a new +`printCommandHelp(cmd, version)` helper. Unrecognized commands fall back to a +friendly "see `squad help`" message. No side effects are triggered. diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 71ea5c36c..bd9de28a0 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -98,6 +98,7 @@ import { BOLD, RESET, DIM, RED, GREEN, YELLOW } from './cli/core/output.js'; import { runInit } from './cli/core/init.js'; import { runCost } from './cli/commands/cost.js'; import { getPackageVersion } from './cli/core/version.js'; +import { printCommandHelp, printGenericCommandHelp } from './cli/core/command-help.js'; // Lazy-load squad-sdk to avoid triggering @github/copilot-sdk import on Node 24+ // (Issue: copilot-sdk has broken ESM imports - vscode-jsonrpc/node without .js extension) @@ -282,6 +283,27 @@ async function main(): Promise { return; } + // --help / -h on a subcommand → print command-specific help and exit. + // Without this intercept the flag was silently dropped and the command + // would execute for real (sometimes with destructive side effects, e.g. + // `squad init --help` scaffolding files, or `squad triage --help` starting + // the polling loop). See #1201. + if ( + cmd && + cmd !== 'help' && + cmd !== '--help' && + cmd !== '-h' && + cmd !== 'version' && + cmd !== '--version' && + cmd !== '-v' && + (args.includes('--help') || args.includes('-h')) + ) { + if (!printCommandHelp(cmd, VERSION)) { + printGenericCommandHelp(cmd); + } + return; + } + // No args → launch interactive shell; whitespace-only arg → show help if (rawCmd === undefined) { // Fire-and-forget update check — non-blocking, never delays shell startup @@ -423,15 +445,10 @@ async function main(): Promise { return; } - if (cmd === 'state-mcp') { - if (args.includes('--help') || args.includes('-h')) { - const { printStateMcpHelp } = await import('./cli/commands/state-mcp.js'); - printStateMcpHelp(); - return; - } - const { runStateMcp } = await import('./cli/commands/state-mcp.js'); - await runStateMcp(getSquadStartDir()); - return; + if (cmd === 'state-mcp') { + const { runStateMcp } = await import('./cli/commands/state-mcp.js'); + await runStateMcp(getSquadStartDir()); + return; } if (cmd === 'migrate') { @@ -621,37 +638,6 @@ async function main(): Promise { } if (cmd === 'loop') { - // --help - if (args.includes('--help') || args.includes('-h')) { - console.log(`\n${BOLD}squad loop${RESET} — Prompt-driven continuous work loop\n`); - console.log(`Usage: squad loop [options]\n`); - console.log(`Reads loop.md and runs it as a continuous work loop.\n`); - console.log(`Options:`); - console.log(` ${BOLD}--init${RESET} Generate a boilerplate loop.md`); - console.log(` ${BOLD}--file ${RESET} Path to loop file (default: loop.md)`); - console.log(` ${BOLD}--interval ${RESET} Override loop interval in minutes`); - console.log(` ${BOLD}--timeout ${RESET} Override max minutes per cycle`); - console.log(` ${BOLD}--copilot-flags "..."${RESET} Extra flags for Copilot CLI`); - console.log(` ${BOLD}--agent-cmd ${RESET} Override the agent command`); - console.log(`\nCapabilities (composable with the loop):`); - console.log(` ${BOLD}--self-pull${RESET} git fetch/pull at round start`); - console.log(` ${BOLD}--monitor-email${RESET} Scan email for actionable items`); - console.log(` ${BOLD}--monitor-teams${RESET} Scan Teams for actionable messages`); - console.log(` ${BOLD}--decision-hygiene${RESET} Auto-merge decision inbox`); - console.log(` ${BOLD}--retro${RESET} Enforce retrospective checks`); - console.log(`\nFrontmatter (in loop.md):`); - console.log(` configured: true ${DIM}(required — confirms intentional setup)${RESET}`); - console.log(` interval: 10 ${DIM}(minutes between cycles)${RESET}`); - console.log(` timeout: 30 ${DIM}(max minutes per cycle)${RESET}`); - console.log(` description: "..." ${DIM}(shown in status output)${RESET}`); - console.log(`\nExamples:`); - console.log(` squad loop ${DIM}# run loop.md${RESET}`); - console.log(` squad loop --init ${DIM}# generate boilerplate${RESET}`); - console.log(` squad loop --file ops/loop.md ${DIM}# custom loop file${RESET}`); - console.log(` squad loop --monitor-email ${DIM}# with email monitoring${RESET}`); - return; - } - const { runLoop, generateLoopFile } = await import('./cli/commands/loop.js'); // --init: scaffold a boilerplate loop.md diff --git a/packages/squad-cli/src/cli/core/command-help.ts b/packages/squad-cli/src/cli/core/command-help.ts new file mode 100644 index 000000000..4fd44efcf --- /dev/null +++ b/packages/squad-cli/src/cli/core/command-help.ts @@ -0,0 +1,416 @@ +/** + * Per-command help text for `squad --help` / `-h`. + * + * Fixes #1201: previously `--help` after a subcommand was silently dropped + * by the CLI router and the command would execute for real, sometimes with + * destructive side effects (`squad init --help` would scaffold files into + * the cwd, `squad triage --help` / `watch --help` would start a polling + * loop, etc.). + * + * The entry point in `cli-entry.ts` intercepts `--help`/`-h` whenever it + * appears on a subcommand and calls `printCommandHelp(cmd, version)`. If + * the command has a dedicated help block, it is printed and `true` is + * returned. Otherwise `false` is returned so the caller can show a generic + * fallback that points users at the top-level `squad help`. + */ + +/* eslint-disable no-console -- help printers stream to stdout via console.log, + matching the style of the top-level help block in cli-entry.ts. */ + +import { BOLD, DIM, RESET } from './output.js'; + +type HelpPrinter = (version: string) => void; + +function header(cmd: string, version: string, tagline: string): void { + console.log(`\n${BOLD}squad ${cmd}${RESET} v${version} — ${tagline}\n`); +} + +/** + * Registry of per-command help printers. Adding a new command here is the + * one place that needs updating when a new subcommand is added to the CLI. + */ +const COMMAND_HELP: Record = { + init: (version) => { + header('init', version, 'Initialize Squad in the current project'); + console.log(`Usage: squad init [options]`); + console.log(` squad init --mode remote \n`); + console.log(`Creates a markdown-based squad layout under .squad/ plus default agent`); + console.log(`workflows under .github/. Safe to re-run — existing files are preserved.\n`); + console.log(`Options:`); + console.log(` ${BOLD}--sdk${RESET} Use SDK builder syntax (squad.config.ts)`); + console.log(` ${BOLD}--roles${RESET} Seed the team with built-in base roles`); + console.log(` ${BOLD}--global${RESET} Initialize in the personal (global) squad directory`); + console.log(` ${BOLD}--no-workflows${RESET} Skip writing GitHub Actions workflows`); + console.log(` ${BOLD}--preset ${RESET} Apply a curated preset after init`); + console.log(` ${BOLD}--state-backend ${RESET} State backend (local|orphan|two-layer)`); + console.log(` ${BOLD}--mode remote ${RESET} Point at an external team root (creates .squad/config.json)\n`); + }, + + upgrade: (version) => { + header('upgrade', version, 'Update Squad-owned files to the latest version'); + console.log(`Usage: squad upgrade [options]\n`); + console.log(`Overwrites Squad-owned files (squad.agent.md, .squad/templates/) while`); + console.log(`leaving your team state under .squad/ and .ai-team/ untouched.\n`); + console.log(`Options:`); + console.log(` ${BOLD}--global${RESET} Upgrade the personal (global) squad`); + console.log(` ${BOLD}--migrate-directory${RESET} Rename legacy .ai-team/ to .squad/`); + console.log(` ${BOLD}--state-backend ${RESET} Migrate to a new backend (orphan|two-layer)`); + console.log(` ${BOLD}--self${RESET} Upgrade the squad CLI package itself`); + console.log(` ${BOLD}--insider${RESET} With --self, install the @insider tag`); + console.log(` ${BOLD}--force${RESET} Overwrite files without prompting\n`); + }, + + migrate: (version) => { + header('migrate', version, 'Convert between markdown and SDK-First squad formats'); + console.log(`Usage: squad migrate --to sdk|markdown [options]\n`); + console.log(`Options:`); + console.log(` ${BOLD}--to sdk|markdown${RESET} Target format`); + console.log(` ${BOLD}--from ai-team${RESET} Source format (defaults to current)`); + console.log(` ${BOLD}--dry-run${RESET} Show planned changes without writing\n`); + }, + + status: (version) => { + header('status', version, 'Show which squad is active and why'); + console.log(`Usage: squad status\n`); + console.log(`Reports the resolved squad directory, the resolution reason (repo vs.`); + console.log(`global), and the state of the personal squad path.\n`); + }, + + roles: (version) => { + header('roles', version, 'List built-in Squad roles'); + console.log(`Usage: squad roles [--category ] [--search ]\n`); + console.log(`Options:`); + console.log(` ${BOLD}--category ${RESET} Filter to a single category`); + console.log(` ${BOLD}--search ${RESET} Match role name or description\n`); + }, + + cost: (version) => { + header('cost', version, 'Report token usage from orchestration logs'); + console.log(`Usage: squad cost [options]\n`); + console.log(`Options:`); + console.log(` ${BOLD}--all${RESET} Include all logged sessions, not just recent`); + console.log(` ${BOLD}--agent ${RESET} Filter to a single agent\n`); + }, + + triage: (version) => printWatchHelp('triage', version), + watch: (version) => printWatchHelp('watch', version), + + loop: (version) => { + header('loop', version, 'Prompt-driven continuous work loop'); + console.log(`Usage: squad loop [options]\n`); + console.log(`Reads loop.md and runs it as a continuous work loop.\n`); + console.log(`Options:`); + console.log(` ${BOLD}--init${RESET} Generate a boilerplate loop.md`); + console.log(` ${BOLD}--file ${RESET} Path to loop file (default: loop.md)`); + console.log(` ${BOLD}--interval ${RESET} Override loop interval in minutes`); + console.log(` ${BOLD}--timeout ${RESET} Override max minutes per cycle`); + console.log(` ${BOLD}--copilot-flags "..."${RESET} Extra flags for Copilot CLI`); + console.log(` ${BOLD}--agent-cmd ${RESET} Override the agent command`); + console.log(`\nCapabilities (composable with the loop):`); + console.log(` ${BOLD}--self-pull${RESET} git fetch/pull at round start`); + console.log(` ${BOLD}--monitor-email${RESET} Scan email for actionable items`); + console.log(` ${BOLD}--monitor-teams${RESET} Scan Teams for actionable messages`); + console.log(` ${BOLD}--decision-hygiene${RESET} Auto-merge decision inbox`); + console.log(` ${BOLD}--retro${RESET} Enforce retrospective checks`); + console.log(`\nFrontmatter (in loop.md):`); + console.log(` configured: true ${DIM}(required — confirms intentional setup)${RESET}`); + console.log(` interval: 10 ${DIM}(minutes between cycles)${RESET}`); + console.log(` timeout: 30 ${DIM}(max minutes per cycle)${RESET}`); + console.log(` description: "..." ${DIM}(shown in status output)${RESET}`); + console.log(`\nExamples:`); + console.log(` squad loop ${DIM}# run loop.md${RESET}`); + console.log(` squad loop --init ${DIM}# generate boilerplate${RESET}`); + console.log(` squad loop --file ops/loop.md ${DIM}# custom loop file${RESET}`); + console.log(` squad loop --monitor-email ${DIM}# with email monitoring${RESET}`); + }, + + hire: (version) => { + header('hire', version, 'Team creation wizard'); + console.log(`Usage: squad hire [--name ] [--role ]\n`); + console.log(`Interactive wizard that walks you through adding a new agent to the team.\n`); + console.log(`Options:`); + console.log(` ${BOLD}--name ${RESET} Pre-fill the agent name`); + console.log(` ${BOLD}--role ${RESET} Pre-select a built-in role (see 'squad roles')\n`); + }, + + copilot: (version) => { + header('copilot', version, 'Add or remove the Copilot coding agent (@copilot)'); + console.log(`Usage: squad copilot [--off] [--auto-assign]\n`); + console.log(`Options:`); + console.log(` ${BOLD}--off${RESET} Remove the @copilot agent from the team`); + console.log(` ${BOLD}--auto-assign${RESET} Configure auto-assignment of issues to @copilot\n`); + }, + + plugin: (version) => { + header('plugin', version, 'Manage plugin marketplaces'); + console.log(`Usage: squad plugin marketplace add|remove|list|browse\n`); + console.log(`Subcommands:`); + console.log(` ${BOLD}marketplace add ${RESET} Register a plugin marketplace`); + console.log(` ${BOLD}marketplace remove ${RESET} Unregister a marketplace`); + console.log(` ${BOLD}marketplace list${RESET} List registered marketplaces`); + console.log(` ${BOLD}marketplace browse${RESET} Browse available plugins\n`); + }, + + export: (version) => { + header('export', version, 'Export squad to a portable JSON snapshot'); + console.log(`Usage: squad export [--out ]\n`); + console.log(`Options:`); + console.log(` ${BOLD}--out ${RESET} Output file (default: squad-export.json)\n`); + }, + + import: (version) => { + header('import', version, 'Import squad from an export file'); + console.log(`Usage: squad import [--force]\n`); + console.log(`Options:`); + console.log(` ${BOLD}--force${RESET} Overwrite existing files in target\n`); + }, + + 'scrub-emails': (version) => { + header('scrub-emails', version, 'Remove email addresses from Squad state files'); + console.log(`Usage: squad scrub-emails [directory]\n`); + console.log(`Defaults to scrubbing .ai-team/ (or .squad/) in the current directory.\n`); + }, + + start: (version) => { + header('start', version, 'Start Copilot with remote access from phone / browser'); + console.log(`Usage: squad start [--tunnel] [--port ] [--command ] [copilot flags...]\n`); + console.log(`Options:`); + console.log(` ${BOLD}--tunnel${RESET} Expose the session over a public devtunnel`); + console.log(` ${BOLD}--port ${RESET} Bind to a specific local port`); + console.log(` ${BOLD}--command ${RESET} Override the agent command (default: copilot)\n`); + console.log(`Examples:`); + console.log(` squad start --tunnel --yolo`); + console.log(` squad start --tunnel --model claude-sonnet-4`); + console.log(` squad start --tunnel --command "gh copilot"\n`); + }, + + nap: (version) => { + header('nap', version, 'Context hygiene — compress, prune, archive .squad/ state'); + console.log(`Usage: squad nap [--deep] [--dry-run]\n`); + console.log(`Options:`); + console.log(` ${BOLD}--deep${RESET} Thorough cleanup, including older history`); + console.log(` ${BOLD}--dry-run${RESET} Preview changes without writing\n`); + }, + + memory: (version) => { + header('memory', version, 'Governed memory operations'); + console.log(`Usage: squad memory [options]\n`); + console.log(`Common subcommands:`); + console.log(` ${BOLD}write --content "..." --class LOCAL${RESET} Write a memory entry`); + console.log(` ${BOLD}list${RESET} List memory entries`); + console.log(` ${BOLD}read ${RESET} Read a single entry\n`); + console.log(`Diagnostics:`); + console.log(` ${BOLD}--log-level info|debug${RESET} Increase log verbosity`); + console.log(` ${BOLD}--verbose${RESET} Shortcut for --log-level debug\n`); + }, + + 'state-mcp': (version) => { + header('state-mcp', version, 'MCP bridge exposing Squad runtime state tools'); + console.log(`Usage: squad state-mcp\n`); + console.log(`Starts a Model Context Protocol server over stdio that exposes Squad`); + console.log(`state read/write tools to MCP-compatible hosts.\n`); + }, + + doctor: (version) => { + header('doctor', version, 'Validate squad setup'); + console.log(`Usage: squad doctor\n`); + console.log(`Checks for required files, valid config, and a reachable Copilot CLI.`); + console.log(`Exits non-zero if any required check fails.\n`); + }, + + consult: (version) => { + header('consult', version, 'Enter consult mode with your personal squad'); + console.log(`Usage: squad consult [--status] [--check]\n`); + console.log(`Options:`); + console.log(` ${BOLD}--status${RESET} Show consult session status`); + console.log(` ${BOLD}--check${RESET} Run consult-mode prerequisites check\n`); + }, + + extract: (version) => { + header('extract', version, 'Extract learnings from a consult-mode session'); + console.log(`Usage: squad extract [options]\n`); + console.log(`Options:`); + console.log(` ${BOLD}--dry-run${RESET} Preview the extracted learnings`); + console.log(` ${BOLD}--clean${RESET} Remove transient session files after extract`); + console.log(` ${BOLD}--yes${RESET} Skip interactive confirmations`); + console.log(` ${BOLD}--accept-risks${RESET} Acknowledge data-handling implications\n`); + }, + + subsquads: (version) => { + header('subsquads', version, 'Manage Squad SubSquads (multi-Codespace scaling)'); + console.log(`Usage: squad subsquads >\n`); + console.log(`Aliases (deprecated): ${BOLD}workstreams${RESET}, ${BOLD}streams${RESET}\n`); + }, + + link: (version) => { + header('link', version, 'Link this project to a remote team root'); + console.log(`Usage: squad link \n`); + }, + + build: (version) => { + header('build', version, 'Compile squad.config.ts into .squad/ markdown'); + console.log(`Usage: squad build [options]\n`); + console.log(`Options:`); + console.log(` ${BOLD}--check${RESET} Validate only, do not write files`); + console.log(` ${BOLD}--dry-run${RESET} Preview the generated markdown`); + console.log(` ${BOLD}--watch${RESET} Rebuild on changes to squad.config.ts\n`); + }, + + aspire: (version) => { + header('aspire', version, 'Launch the .NET Aspire dashboard for observability'); + console.log(`Usage: squad aspire [options]\n`); + console.log(`Options:`); + console.log(` ${BOLD}--docker${RESET} Force the Docker launch path`); + console.log(` ${BOLD}--port ${RESET} Bind the dashboard to a specific port\n`); + }, + + schedule: (version) => { + header('schedule', version, 'Manage scheduled Squad tasks'); + console.log(`Usage: squad schedule |init|status>\n`); + }, + + personal: (version) => { + header('personal', version, 'Manage your personal squad (ambient agents)'); + console.log(`Usage: squad personal |remove > [options]\n`); + console.log(`Options:`); + console.log(` ${BOLD}--role ${RESET} Built-in role to attach (see 'squad roles')\n`); + }, + + preset: (version) => { + header('preset', version, 'Manage squad presets (curated agent collections)'); + console.log(`Usage: squad preset |apply |save |init> [options]\n`); + console.log(`Options:`); + console.log(` ${BOLD}--force${RESET} Overwrite existing agents on apply`); + console.log(` ${BOLD}--remote${RESET} init: back presets with a GitHub repo\n`); + }, + + cast: (version) => { + header('cast', version, 'Show the current session cast (project + personal agents)'); + console.log(`Usage: squad cast\n`); + }, + + rc: (version) => printRcHelp('rc', version), + 'remote-control': (version) => printRcHelp('remote-control', version), + + 'copilot-bridge': (version) => { + header('copilot-bridge', version, 'Check Copilot ACP stdio compatibility'); + console.log(`Usage: squad copilot-bridge\n`); + }, + + 'init-remote': (version) => { + header('init-remote', version, 'Link project to a remote team root (shorthand)'); + console.log(`Usage: squad init-remote \n`); + }, + + 'rc-tunnel': (version) => { + header('rc-tunnel', version, 'Check devtunnel CLI availability'); + console.log(`Usage: squad rc-tunnel\n`); + }, + + discover: (version) => { + header('discover', version, 'List known squads and their capabilities'); + console.log(`Usage: squad discover\n`); + }, + + delegate: (version) => { + header('delegate', version, 'Create work in another squad'); + console.log(`Usage: squad delegate \n`); + }, + + upstream: (version) => { + header('upstream', version, 'Manage upstream Squad sources'); + console.log(`Usage: squad upstream [options]\n`); + console.log(`Examples:`); + console.log(` squad upstream add [--name ] [--ref ]`); + console.log(` squad upstream remove `); + console.log(` squad upstream list`); + console.log(` squad upstream sync [name]\n`); + }, + + economy: (version) => { + header('economy', version, 'Toggle economy mode (cost-conscious model selection)'); + console.log(`Usage: squad economy [on|off]\n`); + }, + + externalize: (version) => { + header('externalize', version, 'Move local squad state to an external team root'); + console.log(`Usage: squad externalize\n`); + }, + + internalize: (version) => { + header('internalize', version, 'Pull an external team root back into the project'); + console.log(`Usage: squad internalize\n`); + }, + + config: (version) => { + header('config', version, 'Manage Squad configuration'); + console.log(`Usage: squad config [options]\n`); + }, +}; + +function printWatchHelp(name: 'triage' | 'watch', version: string): void { + header(name, version, 'Scan for work and categorize issues'); + console.log(`Usage: squad ${name} [--interval ] [--execute] [options]\n`); + console.log(`Default: checks every 10 minutes (Ctrl+C to stop).\n`); + console.log(`Core flags:`); + console.log(` ${BOLD}--execute${RESET} Spawn agents to work on issues`); + console.log(` ${BOLD}--copilot-flags "..."${RESET} Extra flags for Copilot CLI`); + console.log(` ${BOLD}--max-concurrent N${RESET} Parallel issue limit (default 1)`); + console.log(` ${BOLD}--timeout N${RESET} Max minutes per issue (default 30)`); + console.log(` ${BOLD}--interval ${RESET} Polling interval (default 10)`); + console.log(` ${BOLD}--health${RESET} (watch only) print watch instance status\n`); + console.log(`Capabilities (opt-in via -- or .squad/config.json):`); + console.log(` ${BOLD}--self-pull${RESET} git fetch/pull at round start`); + console.log(` ${BOLD}--board${RESET} Project board lifecycle + reconciliation`); + console.log(` ${BOLD}--board-project N${RESET} Project board number (default 1)`); + console.log(` ${BOLD}--monitor-teams${RESET} Scan Teams for actionable messages`); + console.log(` ${BOLD}--monitor-email${RESET} Scan email for actionable items`); + console.log(` ${BOLD}--two-pass${RESET} Lightweight list then hydrate actionable`); + console.log(` ${BOLD}--wave-dispatch${RESET} Wave-based parallel sub-task dispatch`); + console.log(` ${BOLD}--retro${RESET} Enforce retrospective checks`); + console.log(` ${BOLD}--decision-hygiene${RESET} Auto-merge decision inbox\n`); + console.log(`Disable any capability with ${BOLD}--no-${RESET}.`); + console.log(`Logging: ${BOLD}--log-file ${RESET} Tee output to file with timestamps\n`); +} + +function printRcHelp(name: 'rc' | 'remote-control', version: string): void { + header(name, version, 'Start the Remote Control bridge (phone / browser → Copilot)'); + console.log(`Usage: squad ${name} [--tunnel] [--port ] [--path ]\n`); + console.log(`Options:`); + console.log(` ${BOLD}--tunnel${RESET} Expose the bridge over a public devtunnel`); + console.log(` ${BOLD}--port ${RESET} Bind to a specific local port`); + console.log(` ${BOLD}--path ${RESET} Squad path to expose (default: cwd)\n`); + console.log(`${DIM}Note: this command is deprecated; prefer "gh copilot".${RESET}\n`); +} + +/** + * Print help for `cmd` if a dedicated help block exists. + * Returns `true` when help was printed, `false` otherwise so the caller can + * fall back to a generic "see `squad help`" message. + */ +export function printCommandHelp(cmd: string, version: string): boolean { + const printer = COMMAND_HELP[cmd]; + if (!printer) { + return false; + } + printer(version); + return true; +} + +/** + * Print a friendly generic fallback for commands that don't have a dedicated + * help block yet. Always exits without side effects. + */ +export function printGenericCommandHelp(cmd: string): void { + console.log(`\n${BOLD}squad ${cmd}${RESET}\n`); + console.log(`No detailed help is available for '${cmd}'.`); + console.log(`Run '${BOLD}squad help${RESET}' for the full command list and usage.\n`); +} + +/** + * Test helper: list the commands that have dedicated help blocks. Used by + * unit tests to guard against regression when new commands are added. + */ +export function commandsWithHelp(): readonly string[] { + return Object.keys(COMMAND_HELP).sort(); +} diff --git a/test/acceptance/acceptance.test.ts b/test/acceptance/acceptance.test.ts index b17b9e07f..300355fed 100644 --- a/test/acceptance/acceptance.test.ts +++ b/test/acceptance/acceptance.test.ts @@ -43,3 +43,6 @@ runFeature(join(featuresDir, 'exit-codes.feature'), registry); // Consult and extract command tests runFeature(join(featuresDir, 'consult-command.feature'), registry); runFeature(join(featuresDir, 'extract-command.feature'), registry); + +// Subcommand --help intercept (#1201) +runFeature(join(featuresDir, 'subcommand-help.feature'), registry); diff --git a/test/acceptance/features/subcommand-help.feature b/test/acceptance/features/subcommand-help.feature new file mode 100644 index 000000000..d8126c6ae --- /dev/null +++ b/test/acceptance/features/subcommand-help.feature @@ -0,0 +1,26 @@ +Feature: Subcommand --help intercept (#1201) + + Scenario: init --help prints help instead of scaffolding files + Given a directory without a ".squad" directory + When I run "squad init --help" in the temp directory + Then the output contains "squad init" + And the output contains "Usage:" + And the exit code is 0 + + Scenario: triage --help prints help instead of starting a polling loop + When I run "squad triage --help" + Then the output contains "squad triage" + And the output contains "Usage:" + And the exit code is 0 + + Scenario: doctor --help prints help instead of running the doctor + When I run "squad doctor --help" + Then the output contains "squad doctor" + And the output contains "Usage:" + And the exit code is 0 + + Scenario: status -h short flag prints help + When I run "squad status -h" + Then the output contains "squad status" + And the output contains "Usage:" + And the exit code is 0 diff --git a/test/cli/command-help.test.ts b/test/cli/command-help.test.ts new file mode 100644 index 000000000..ad8552584 --- /dev/null +++ b/test/cli/command-help.test.ts @@ -0,0 +1,234 @@ +/** + * CLI Help — Per-Command --help Tests (Bug #1201) + * + * Previously `squad --help` / `-h` was silently ignored and the + * command would execute for real (e.g. `squad init --help` scaffolded + * files; `squad triage --help` started a polling loop). + * + * These tests pin down the new behavior: + * 1) `printCommandHelp(cmd, version)` returns true and prints when the + * command is registered, false (with no output) otherwise. + * 2) The end-to-end CLI does not execute the command when --help/-h is + * passed after a known subcommand. Verified by spawning the built + * CLI in an empty temp dir and asserting: + * - exit code 0 + * - no .squad/ or .github/ scaffolded by init + * - help banner contains the expected command name + */ + +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { mkdtempSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { + printCommandHelp, + printGenericCommandHelp, + commandsWithHelp, +} from '../../packages/squad-cli/src/cli/core/command-help.js'; + +const execFileAsync = promisify(execFile); + +// ── Unit tests for the help registry ───────────────────────────────── + +describe('printCommandHelp', () => { + const logs: string[] = []; + const originalLog = console.log; + + beforeAll(() => { + console.log = (...args: unknown[]) => { + logs.push(args.map(String).join(' ')); + }; + }); + + afterAll(() => { + console.log = originalLog; + }); + + afterEach(() => { + logs.length = 0; + }); + + it('returns true and prints help for known commands', () => { + const result = printCommandHelp('init', '9.9.9-test'); + expect(result).toBe(true); + const blob = logs.join('\n'); + expect(blob).toContain('squad init'); + expect(blob).toContain('9.9.9-test'); + expect(blob).toContain('Usage:'); + }); + + it('returns false and prints nothing for unknown commands', () => { + const result = printCommandHelp('definitely-not-a-real-command', '0.0.0'); + expect(result).toBe(false); + expect(logs).toEqual([]); + }); + + it('covers every documented subcommand with a dedicated help block', () => { + // Guard against regression: when a new command is added to the CLI + // router, this list must grow too — otherwise ` --help` will + // fall through to the generic fallback instead of helpful text. + const expected = [ + 'aspire', + 'build', + 'cast', + 'config', + 'consult', + 'copilot', + 'copilot-bridge', + 'cost', + 'delegate', + 'discover', + 'doctor', + 'economy', + 'export', + 'externalize', + 'extract', + 'hire', + 'import', + 'init', + 'init-remote', + 'internalize', + 'link', + 'loop', + 'memory', + 'migrate', + 'nap', + 'personal', + 'plugin', + 'preset', + 'rc', + 'rc-tunnel', + 'remote-control', + 'roles', + 'schedule', + 'scrub-emails', + 'start', + 'state-mcp', + 'status', + 'subsquads', + 'triage', + 'upgrade', + 'upstream', + 'watch', + ]; + expect(commandsWithHelp()).toEqual(expected); + }); +}); + +describe('printGenericCommandHelp', () => { + const logs: string[] = []; + const originalLog = console.log; + + beforeAll(() => { + console.log = (...args: unknown[]) => { + logs.push(args.map(String).join(' ')); + }; + }); + + afterAll(() => { + console.log = originalLog; + }); + + afterEach(() => { + logs.length = 0; + }); + + it('mentions the command name and points at squad help', () => { + printGenericCommandHelp('made-up-cmd'); + const blob = logs.join('\n'); + expect(blob).toContain('made-up-cmd'); + expect(blob).toContain('squad help'); + }); +}); + +// ── End-to-end: spawn the built CLI in an isolated temp dir ────────── + +const cliEntry = resolve( + process.cwd(), + 'packages/squad-cli/dist/cli-entry.js', +); + +const cliBuilt = existsSync(cliEntry); + +const runSquad = async (args: string[], cwd: string) => { + return execFileAsync('node', [cliEntry, ...args], { + cwd, + env: { ...process.env, NODE_NO_WARNINGS: '1' }, + timeout: 20_000, + maxBuffer: 4 * 1024 * 1024, + }); +}; + +describe.skipIf(!cliBuilt)('squad --help end-to-end', () => { + let tempDir = ''; + + beforeAll(() => { + tempDir = mkdtempSync(join(tmpdir(), 'squad-help-bug-')); + }); + + afterAll(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('init --help prints help and does NOT scaffold files', async () => { + const { stdout, stderr } = await runSquad(['init', '--help'], tempDir); + const out = stdout + stderr; + expect(out).toContain('squad init'); + expect(out).toContain('Usage:'); + // The smoking gun: previously this call wrote .squad/, .github/, etc. + expect(existsSync(join(tempDir, '.squad'))).toBe(false); + expect(existsSync(join(tempDir, '.github'))).toBe(false); + expect(existsSync(join(tempDir, '.gitignore'))).toBe(false); + }); + + it('init -h short flag also prints help and does NOT scaffold', async () => { + const { stdout, stderr } = await runSquad(['init', '-h'], tempDir); + const out = stdout + stderr; + expect(out).toContain('squad init'); + expect(existsSync(join(tempDir, '.squad'))).toBe(false); + }); + + it('triage --help prints help and does NOT start a polling loop', async () => { + // Previously this would hang on a 10-minute polling loop. The timeout + // on runSquad is 20s — if help isn't intercepted, this assertion fails + // with ETIMEDOUT before the body runs. + const { stdout, stderr } = await runSquad(['triage', '--help'], tempDir); + const out = stdout + stderr; + expect(out).toContain('squad triage'); + expect(out).toContain('Usage:'); + expect(out).toContain('--execute'); + }); + + it('watch --help prints help (alias of triage)', async () => { + const { stdout, stderr } = await runSquad(['watch', '--help'], tempDir); + const out = stdout + stderr; + expect(out).toContain('squad watch'); + expect(out).toContain('Usage:'); + }); + + it('doctor --help prints help instead of running the doctor', async () => { + const { stdout, stderr } = await runSquad(['doctor', '--help'], tempDir); + const out = stdout + stderr; + expect(out).toContain('squad doctor'); + expect(out).toContain('Usage:'); + // The real doctor prints this banner; help must NOT. + expect(out).not.toContain('🩺 Squad Doctor'); + }); + + it('status --help prints help and does NOT print active squad path', async () => { + const { stdout, stderr } = await runSquad(['status', '--help'], tempDir); + const out = stdout + stderr; + expect(out).toContain('squad status'); + expect(out).toContain('Usage:'); + expect(out).not.toMatch(/Active squad:/); + }); + + it('falls back to a generic help message for unknown commands', async () => { + const { stdout, stderr } = await runSquad(['discover', '--help'], tempDir); + const out = stdout + stderr; + // discover IS registered, so verify it prints command-specific help. + expect(out).toContain('squad discover'); + }); +}); From 69aeee07f825a6ff93db9c29c023bf8711d933cc Mon Sep 17 00:00:00 2001 From: tamirdresher Date: Wed, 3 Jun 2026 08:31:24 +0300 Subject: [PATCH 2/4] fix(cli): normalize subsquads aliases before help-registry lookup (PR #1202 review) The help registry is keyed by canonical command name, but cli-entry.ts routes 'streams' and 'workstreams' to subsquads. Without normalization, 'squad streams --help' / 'squad workstreams --help' fell through to the generic fallback. Add a COMMAND_ALIASES map + normalizeCommandAlias() helper, apply it inside printCommandHelp, and cover both aliases in test/cli/command-help.test.ts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../squad-cli/src/cli/core/command-help.ts | 38 ++++++++++++++++++- test/cli/command-help.test.ts | 25 ++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/packages/squad-cli/src/cli/core/command-help.ts b/packages/squad-cli/src/cli/core/command-help.ts index 4fd44efcf..e92d8f961 100644 --- a/packages/squad-cli/src/cli/core/command-help.ts +++ b/packages/squad-cli/src/cli/core/command-help.ts @@ -383,13 +383,49 @@ function printRcHelp(name: 'rc' | 'remote-control', version: string): void { console.log(`${DIM}Note: this command is deprecated; prefer "gh copilot".${RESET}\n`); } +/** + * Map of command aliases to their canonical name in the help registry. + * + * The CLI router in `cli-entry.ts` accepts several aliases for the same + * command (e.g. `streams` / `workstreams` both route to `subsquads`). The + * help registry is keyed by the canonical name only, so alias lookups must + * be normalized before the registry lookup — otherwise `squad streams --help` + * falls through to the generic fallback instead of showing the dedicated + * subsquads help block. + * + * Keep this in sync with the alias `||` chains in `cli-entry.ts` (search + * for `cmd === ''`). Canonical commands that already have a + * dedicated entry in `COMMAND_HELP` (e.g. `rc` and `remote-control` both + * have explicit help blocks) do NOT need an entry here. + */ +const COMMAND_ALIASES: Readonly> = { + streams: 'subsquads', + workstreams: 'subsquads', +}; + +/** + * Normalize a CLI command alias to its canonical name for help-registry + * lookup. Returns the input unchanged when no alias mapping applies. + * + * Exported for testing. The runtime caller (`printCommandHelp`) applies + * this automatically. + */ +export function normalizeCommandAlias(cmd: string): string { + return COMMAND_ALIASES[cmd] ?? cmd; +} + /** * Print help for `cmd` if a dedicated help block exists. * Returns `true` when help was printed, `false` otherwise so the caller can * fall back to a generic "see `squad help`" message. + * + * Aliases (see `COMMAND_ALIASES`) are normalized to their canonical command + * before the registry lookup, so `printCommandHelp('streams', v)` prints + * the `subsquads` help block. */ export function printCommandHelp(cmd: string, version: string): boolean { - const printer = COMMAND_HELP[cmd]; + const canonical = normalizeCommandAlias(cmd); + const printer = COMMAND_HELP[canonical]; if (!printer) { return false; } diff --git a/test/cli/command-help.test.ts b/test/cli/command-help.test.ts index ad8552584..6e8736f1d 100644 --- a/test/cli/command-help.test.ts +++ b/test/cli/command-help.test.ts @@ -26,6 +26,7 @@ import { printCommandHelp, printGenericCommandHelp, commandsWithHelp, + normalizeCommandAlias, } from '../../packages/squad-cli/src/cli/core/command-help.js'; const execFileAsync = promisify(execFile); @@ -65,6 +66,30 @@ describe('printCommandHelp', () => { expect(logs).toEqual([]); }); + it('normalizes subsquads aliases ("streams", "workstreams") to the canonical help block', () => { + // Regression guard for PR #1202 review nit: cli-entry.ts routes + // `squad streams` and `squad workstreams` to the subsquads command, + // but the help registry is keyed by canonical name only. Without + // alias normalization both `--help` invocations would fall through + // to the generic fallback. + for (const alias of ['streams', 'workstreams']) { + logs.length = 0; + const result = printCommandHelp(alias, '9.9.9-test'); + expect(result, `printCommandHelp('${alias}') should resolve via alias`).toBe(true); + const blob = logs.join('\n'); + expect(blob, `'${alias}' --help should print subsquads block`).toContain('squad subsquads'); + expect(blob).toContain('9.9.9-test'); + } + }); + + it('normalizeCommandAlias maps known aliases and leaves others untouched', () => { + expect(normalizeCommandAlias('streams')).toBe('subsquads'); + expect(normalizeCommandAlias('workstreams')).toBe('subsquads'); + expect(normalizeCommandAlias('subsquads')).toBe('subsquads'); + expect(normalizeCommandAlias('init')).toBe('init'); + expect(normalizeCommandAlias('made-up')).toBe('made-up'); + }); + it('covers every documented subcommand with a dedicated help block', () => { // Guard against regression: when a new command is added to the CLI // router, this list must grow too — otherwise ` --help` will From 532edf032d61135a79cfed03f624b9e302ba2dc7 Mon Sep 17 00:00:00 2001 From: tamirdresher Date: Wed, 3 Jun 2026 08:31:52 +0300 Subject: [PATCH 3/4] test(cli): rename misleading help test + add real generic-fallback coverage (PR #1202 review) The 'falls back to a generic help message for unknown commands' case actually ran 'discover --help' (a registered command) and only asserted command-specific output, leaving the generic-fallback CLI path untested. Rename it to describe what it does and add a new case that spawns the CLI with a guaranteed-unknown command name and asserts the generic-fallback wording. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/cli/command-help.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/test/cli/command-help.test.ts b/test/cli/command-help.test.ts index 6e8736f1d..7c73e09fe 100644 --- a/test/cli/command-help.test.ts +++ b/test/cli/command-help.test.ts @@ -250,10 +250,24 @@ describe.skipIf(!cliBuilt)('squad --help end-to-end', () => { expect(out).not.toMatch(/Active squad:/); }); - it('falls back to a generic help message for unknown commands', async () => { + it('discover --help prints discover-specific help (not the generic fallback)', async () => { const { stdout, stderr } = await runSquad(['discover', '--help'], tempDir); const out = stdout + stderr; // discover IS registered, so verify it prints command-specific help. expect(out).toContain('squad discover'); }); + + it('falls back to a generic help message for unknown commands', async () => { + // Use a name guaranteed not to be in COMMAND_HELP nor routed by + // cli-entry.ts. The early --help intercept (cli-entry.ts ~L290) must + // still kick in and dispatch to printGenericCommandHelp, which + // mentions the cmd verbatim and points users at `squad help`. + const bogus = 'this-command-does-not-exist-xyz'; + const { stdout, stderr } = await runSquad([bogus, '--help'], tempDir); + const out = stdout + stderr; + expect(out).toContain(bogus); + expect(out).toContain('squad help'); + // Ensure no scaffolding / side effects from an unknown command. + expect(existsSync(join(tempDir, '.squad'))).toBe(false); + }); }); From 64b1953163bd0177c1fa28f55941a233a63a6e79 Mon Sep 17 00:00:00 2001 From: tamirdresher Date: Wed, 3 Jun 2026 08:33:02 +0300 Subject: [PATCH 4/4] test(acceptance): assert no files scaffolded on init --help (PR #1202 review) The Gherkin scenario for 'init --help' only checked stdout / exit code, so a regression that writes .squad/, .github/, or .gitignore while printing help would still pass. Add a new 'the temp directory has no entry' step definition and assert all three paths are absent after the run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../features/subcommand-help.feature | 3 +++ test/acceptance/steps/cli-steps.ts | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/test/acceptance/features/subcommand-help.feature b/test/acceptance/features/subcommand-help.feature index d8126c6ae..cca5c549a 100644 --- a/test/acceptance/features/subcommand-help.feature +++ b/test/acceptance/features/subcommand-help.feature @@ -6,6 +6,9 @@ Feature: Subcommand --help intercept (#1201) Then the output contains "squad init" And the output contains "Usage:" And the exit code is 0 + And the temp directory has no ".squad" entry + And the temp directory has no ".github" entry + And the temp directory has no ".gitignore" entry Scenario: triage --help prints help instead of starting a polling loop When I run "squad triage --help" diff --git a/test/acceptance/steps/cli-steps.ts b/test/acceptance/steps/cli-steps.ts index eb8f96a40..a69a74853 100644 --- a/test/acceptance/steps/cli-steps.ts +++ b/test/acceptance/steps/cli-steps.ts @@ -154,4 +154,28 @@ export function registerCLISteps(registry: StepDefinitions): void { }, registry ); + + registerStep( + 'Then', + /the temp directory has no "(.+)" entry/, + async (stepText, context) => { + // Guards against regressions where a `--help` intercept misfires and + // the underlying command still scaffolds files (e.g. `squad init --help` + // writing `.squad/`, `.github/`, `.gitignore`). + const match = stepText.match(/the temp directory has no "(.+)" entry/); + if (!match) throw new Error('Pattern match failed'); + + const entry = match[1]; + const tempDir = context.tempDir as string | undefined; + if (!tempDir) { + throw new Error('No temp directory in context — use the Given step that creates one'); + } + const target = join(tempDir, entry); + expect( + existsSync(target), + `expected no '${entry}' under temp dir but found ${target}` + ).toBe(false); + }, + registry + ); }