Skip to content

fix(cli): report missing projects with one closed code and refuse empty native run selections - #394

Merged
roodboi merged 1 commit into
nextfrom
wf/g37-cli-run-followups
Oct 22, 2026
Merged

roodboi merged 1 commit into
nextfrom
wf/g37-cli-run-followups

Conversation

@roodboi

@roodboi roodboi commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Small CLI follow-ups to #368 and #370 (HACK-1339, and the code parts of HACK-1338; not the M3 host gate).

Root cause

  • Missing project codes differed (HACK-1339 item 1). requireProjectContext threw a plain MissingProjectContextError. Only up, down and restart caught it and mapped it to E_PROJECT_NOT_FOUND. Every other project command let it reach the top-level handler, which reported it as E_UNEXPECTED under --json and, without --json, printed a stack trace. Before this change:
    • hack ps -p <empty dir> --json and hack logs -p <empty dir> --json reported E_UNEXPECTED.
    • hack up -p <empty dir> --json and down reported E_PROJECT_NOT_FOUND.
  • Empty native run selections were ambiguous (HACK-1338 item 5). On the native runtime, hack run --env "" resolved to the base env (null) and --profile "" resolved to no profiles ([]). Each was then compared with the running selection. Against a project started with the base env and no profiles, both silently matched and the command ran. Against any other selection, they were refused with the "does not match" message. So an empty value meant different things depending on what was running.

Fix

  • MissingProjectContextError now extends HackCliError with code E_PROJECT_NOT_FOUND. Most commands already used that code, and the MCP and target-inspect contracts use it too. ps, logs, run, exec and open now report the same envelope as up, down and restart. Without --json they print one closed E_PROJECT_NOT_FOUND line and no stack. The existing up/down/restart catch sites are unchanged.

  • Native run: an empty --env or --profile is a closed E_USAGE usage error. Values that contain only whitespace (--env " ") or only commas (--profile " , ") count as empty. The check runs in runBackendSelection, before the value is resolved, the project is loaded, or any runtime request is made. The error message is exported as NATIVE_RUN_EMPTY_SELECTION_REFUSAL:

    Native hack run does not accept an empty --env or --profile value. Omit the option to use the running selection, or pass the env or profiles hack up started with. No command ran.

    Omitting both options still inherits the running selection. The Compose run path is unchanged: there, an empty --env still means the base layers.

  • requireNativeRunSelection: removed the redundant run.effectiveEnvName === undefined || clause (item 7). A legacy mapping with no recorded env still refuses: undefined never equals a given selection, which is always a string or null. The existing legacy-mapping unit test still covers this.

  • Docs:

    • docs/cli.md has a new "Option parsing" section. It describes per-command parsing and the residual edge (item 3): an option placed before the command name that is a flag in one command and takes a value in another is read as a flag. It also records the parse differences that fix(cli): parse options per resolved command so -p means --path again #370's old-versus-new comparison found (item 4). The only difference was the intended one: -p <dir> on a project command now sets --path. The section lists the edge cases that parse the same as before.
    • docs/cli.md now states the missing-project code.
    • docs/guides/native-candidate.md states the empty-value rule.

Tests (each checked by mutation)

  • New tests/missing-project-error-code.test.ts. It spawns the source CLI with privateCliEnv and checks two things:

    • ps, logs, up, down and restart with -p <empty dir> --json all return {ok:false, error:{code:"E_PROJECT_NOT_FOUND", ...}}.
    • ps without --json prints exactly one ERROR: E_PROJECT_NOT_FOUND: ... line and no stack.

    Mutation: reverting the class to a plain Error fails the ps, logs and non-JSON ps tests.

  • tests/native-run-selection-cli.test.ts:

    • Five new refusal cases: --env "", --env=, --env " ", --profile "" and --profile " , ". They run against a project started with the base env and no profiles, where the old code would have run the command. Each must print one closed E_USAGE line and make no runtime call.
    • One control case: omitting both options still runs.

    Mutations:

    • Dropping the env check fails 3 tests.
    • Matching only a literal "" profile fails the comma case.
    • Not trimming the env value fails the whitespace case.
    • Always refusing fails the 6 existing match and mismatch tests.
  • tests/cli-per-command-options.test.ts (item 2): I mutated each branch of scanInvocation, scanPositional, scanOption, consumesNextToken, omitsOptionValue and representativeDeclarations, then ran the parser suites against each mutant. Two non-equivalent mutants survived, and I added a test for each:

    • A lone - is a positional, so it can start the child command. Mutation: treating - as an option.
    • After a command's own positional, a subcommand name is the child command. This uses a synthetic passthrough command that also has a subcommand. Mutation: not clearing subcommands after a positional.

    Both new tests fail under their mutation. Two mutants still survive, and both are equivalent:

    • Removing the includes("=") early return in scanOption: no long or short key can contain =.
    • Continuing the scan after "done": no subcommands remain, and no passthrough index is set.
  • tests/cli-closed-error-output.test.ts (item 6):

    • The handler-usage-refusal test used the removed "restart with the requested selections first" string. It now uses the run handler's current CliUsageError ("Missing required argument: service").
    • A new test renders the current NATIVE_RUN_SELECTION_REFUSAL, raised by the real requireNativeRunSelection, as one coded E_USAGE line with no hint.
    • The --json usage test's stale "Native exec does not support --env." text is updated to the current exec refusal.

Evidence

  • Bun 1.4.2 (mise x bun@1.4.2), base origin/next 16f4547 (which includes a25160d).
  • bun run --cwd packages/cli typecheck: 0 errors. Root bun run check (includes privacy:check: ok): 0 errors. The changed files pass biome check cleanly.
  • Focused suites, run locally: 176/176 across cli-per-command-options, cli-closed-error-output, native-run-selection-cli, missing-project-error-code, native-project-one-off, cli-passthrough-argv, cli-command, project-up-command, lifecycle-json, project-run-command, native-project-exec, cli-reference-docs and output-hygiene. Also 44/44 across env-key-argument-redaction-cli, cli-renderer-loading, integration-ownership and mcp.
  • bun run docs:cli-reference: no change to docs/reference/cli.md, because the help surface is unchanged. No consumer agent guidance changed, so I did not regenerate the plugins. No package.json scripts changed, so nothing needed re-pinning.
  • gitleaks 8.30.1 (gitleaks dir over a git log -p origin/next..HEAD export): no leaks.
  • Full suite on the M3 at exactly 07306f2 (detached worktree, Bun 1.4.2, after bun run build:config-compiler): 7691 pass, 126 skip, 4 fail, across 549 files in 2750.78s. None of the 4 failures touch this change, and the same 4 also fail on unrelated branches run on the same M3:
    • repository catalog > the checked-in .hack/tasks.json recipes are all reviewed. On the M3 it fails with E_USAGE detached_checkout ("Detached linked worktree cannot select an instance implicitly"), because the M3 harness checks out a detached worktree. It passes locally on a branch checkout at this head. It also failed this way in the F20r3, G35, G36 and G41 runs.
    • Three native recovery tests in native-authored-process-incarnation and native-authored-project-recovery ("original capture uses its live session…", "new no-host death uses real read-only quiescence facts…" and "new no-host recovery retains original bindings on last-await"). These are timing-dependent. The identical set failed in the F20r3 run, and they also show up in the G35 and G41 runs.
    • I queued serial reruns of these files with --timeout 60000 at this head. The shared M3 CI lock stayed busy for over 100 minutes, so I cancelled the reruns before they got the lock. No lock or worktree was left behind.
  • Focused suites on a trial merge with the current next (8e53df3), which merges cleanly: 68/68 across missing-project-error-code, native-run-selection-cli, cli-per-command-options, cli-closed-error-output, project-run-command and lifecycle-json.
  • Hosted CI at 07306f2: every job passes except docker-e2e shard 3/4. That shard failed in pattern-p01-dependency-bootstrap with docker inspect <id>, then Error: No such object: the fixture container disappeared between listing and inspect. That scenario does not use the code this PR changes. I did not re-run the job.

Gaps

  • The empty-value rule covers only the native one-off path (HACK_RUNTIME_BACKEND=native, nativeProjectOneOff). Two paths are unchanged:
    • The authored native-Compose run path (tryNativeComposeCommand), which prepares a new generation, still resolves --env "" as the base env.
    • Compose run, where an empty --env still means the base env.
  • The P02 example README was not changed, to avoid churning the pattern fingerprints. The native guide carries the rule.
  • The M3 host gate in HACK-1338 is out of scope.

Refs HACK-1339
Refs HACK-1338

🤖 Generated with Claude Code

…ty native run selections

- `ps`, `logs`, `run`, `exec` and `open` now report a path without a project as
  `E_PROJECT_NOT_FOUND`, the code `up`, `down` and `restart` already used,
  instead of `E_UNEXPECTED` (and a stack trace without `--json`).
- Native `hack run --env ""` and `--profile ""` (or whitespace/comma-only
  values) refuse with a closed `E_USAGE` line before any runtime request,
  instead of silently meaning base env or no profiles.
- Remove the redundant legacy-env clause in `requireNativeRunSelection`.
- Cover the scan's lone `-` positional and subcommand-after-positional
  branches; update closed-error tests to current refusal text.
- Document the per-command option parsing edge and the #370 parse
  differences in docs/cli.md, and the empty-value rule in the native guide.

Refs HACK-1339
Refs HACK-1338

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

HACK-1339

HACK-1338

@blacksmith-sh

blacksmith-sh Bot commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

Found 1 test failure on Blacksmith runners:

Failure

Test View Logs
pattern-p01-dependency-bootstrap (181.9s) — docker inspect pattern fixture operation/
pattern-p01-dependency-bootstrap (181.9s) — docker inspect pattern fixture operation
View Logs

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

@roodboi
roodboi marked this pull request as ready for review October 22, 2026 19:39
@roodboi
roodboi merged commit d0c8184 into next Oct 22, 2026
14 of 16 checks passed
@roodboi
roodboi deleted the wf/g37-cli-run-followups branch October 22, 2026 19:39
roodboi added a commit that referenced this pull request Oct 23, 2026
…t commands (#427)

Follow-ups to #394 (merged into `next` as d0c8184) for HACK-1371.

## Rework (4d30eff): hosted `test` failure on e3d6917

**Root cause.** Fix 4 below moved the empty `--env`/`--profile` check ahead of authored
dispatch, so `hack run --profile ""` on an authored native project now refuses with `E_USAGE`,
not `E_NATIVE_PROJECT_UNSUPPORTED`. The source CLI run table in `tests/native-authored-project-
oneoff.test.ts` still expected `E_NATIVE_PROJECT_UNSUPPORTED` for the `["--profile", ""]` row.
That failure also set the file's `uncertain` latch, so 4 later tests in the file failed as well
(`--profile dev`, `--env base`, "public cancellation after run completion returns130…", and
"unconfirmed run completion maps to the closed run refusal"). Hosted run 39846392499 reported 5
failures. My earlier focused run (33 files) did not include this file.

**Fix (test only).** Each row of that table now carries its expected refusal:
- The `--profile ""` row expects `ERROR: E_USAGE: ${NATIVE_RUN_EMPTY_SELECTION_REFUSAL}` and no
  runtime attempt.
- The `--profile dev` and `--env base` rows keep `E_NATIVE_PROJECT_UNSUPPORTED` and no runtime
  attempt.

No source file changed.

**Proof.**
- `bun test tests/native-authored-project-oneoff.test.ts`: 50 pass, 0 fail. Before the fix: 45
  pass, 5 fail.
- Mutation: I swapped in `src/commands/project.ts` from `origin/next` (old dispatch order), and
  the `--profile ""` row failed. The two later rows then failed through the existing latch, as
  designed. I restored the file afterwards.
- I searched the other tests for an empty `--env`/`--profile` expecting
  `E_NATIVE_PROJECT_UNSUPPORTED`. Only the `logs` and `exec` tables do that, and the early
  refusal applies only to `run`. `native-authored-project-logs` and `native-authored-project-
  exec` pass 91/91.

## Root cause (original commit e3d6917)

1. **Commands still reported a missing project with different codes.** #394 gave `project.ts` a
closed `MissingProjectContextError`, but other command files kept their own copy of
`requireProjectContext`. For `hack <cmd> -p <empty dir>`:    - `branch add/list/remove/open`,
`config get/set` and `project owner show` threw a plain `Error`. They printed `ERROR: Error:
...`, a stack trace, and a stray `└` line on stdout. `project owner show --json` reported
`E_UNEXPECTED`.    - `env ...` and `host exec/shell` threw `CliUsageError("No .hack/ found. Run:
hack init")`. That reported `E_USAGE` and added a "See --help" hint.    - `tui` (with a TTY)
threw a plain `Error`.

`docs/cli.md` said every project command reported `E_PROJECT_NOT_FOUND`, which was not true. 2.
**The docs gave the wrong refusal for a short spelling.** `docs/cli.md` said an option that only
another command declares is refused as `Option(s) not valid for "<command>"`. That is true only
for long spellings. The per-command parser (#370) reads another command's option only by its
long name, so a short spelling the command does not declare is unknown to it. For example, `hack
doctor -f --json` gives `Unknown option '-f'. ...`. The old union parser gave `Option(s) not
valid for "doctor": --follow` instead, and the #370 differential notes did not record that
difference. 3. **A mutant survived.** Changing `.every(...)` to `.some(...)` in
`requireNonEmptyNativeRunOptions` survived, because no test passed a profile list that mixes
empty and real entries. 4. **Authored projects got the wrong refusal.** On the native backend,
`hack run` ran authored dispatch (`tryNativeComposeCommand`) before the empty check. So an
authored `hack.project.json` project refused `--profile ""` with `E_NATIVE_PROJECT_UNSUPPORTED`
instead of `E_USAGE`.

## Fix

1. I changed the commands to match the doc instead of narrowing the doc. A new `src/lib/missing-
project-error.ts` exports `MissingProjectContextError` (`E_PROJECT_NOT_FOUND`, message `No
.hack/ (or legacy .dev/) found. Run: hack init`). `project.ts`, `branch.ts`, `config.ts`,
`env.ts` (which covers `host`), `project-admin.ts` and `tui.ts` now throw it. `docs/cli.md`
lists the commands and the documented exceptions:    - `tui` without a TTY refuses for that
reason first.    - `env get` prints one fixed stderr line, because its output is raw values.
- `config validate/plan/import/adopt` are native configuration commands and keep their own
refusals.    - `project target/task` use the same code with their own message.    - `init`,
`doctor`, `crash-capture`, `setup`, `mcp` and `agent` do not need a project. 2. I documented the
short-spelling refusal in "Option parsing", and added it as a second difference in the #370 old-
versus-new notes. I checked the old behaviour by running the pre-#370 tree (a25160d^) with the
same arguments. Making the two match would mean reading other commands' short flags again, which
brings back the `-p`/`-d` conflict that #370 removed. So I documented the difference instead. 3.
No source change; new tests pin the behaviour (see Tests). 4. I moved the empty check earlier,
into `refuseEmptyNativeRunSelection`, which `handleRun` calls first when
`HACK_RUNTIME_BACKEND=native`. Authored, adopted and legacy projects now all refuse an empty
value with the same `E_USAGE` line, before any project is selected. The Compose backend is
unchanged: there an empty `--env` still means the base layers. `docs/guides/native-candidate.md`
states which check runs first and the `web,` rule.

`docs/reference/cli.md` is unchanged, because `bun run docs:cli-reference` produced no diff (the
help output did not change).

## Tests (each proven by mutation)

- `tests/missing-project-error-code.test.ts` was rewritten:
  - A table classifies every command that declares `--path`, and one test asserts that the table
  equals the set of commands walked from `CLI_SPEC`. A new `--path` command therefore fails
  until someone classifies it. Mutation: dropping the `crash-capture` row fails the enumeration
  test.
  - Each project command is spawned against an empty dir (`privateCliEnv`, temp HOME) and must
  print the exact envelope or line, with no stack trace. The documented exceptions are pinned to
  their exact output.
  - Mutations: restoring the `origin/next` version of `branch.ts` fails 4 tests, `config.ts`
  fails 2, `env.ts` fails 12 (env and host), and `project-admin.ts` fails 1.
- `tests/cli-renderer-loading.test.ts`: the `tui-missing-project` control (forced TTY) now
  expects `ERROR: E_PROJECT_NOT_FOUND: No .hack/`. Mutation: restoring the `origin/next` version
  of `tui.ts` fails it.
- `tests/cli-per-command-options.test.ts`: every short spelling that some other command declares
  but `doctor` does not (`-f`, `-d`, `-c`, `-H`, `-u`, `-t`) throws `Unknown option '<short>'`
  on `doctor`. `doctor --follow` still parses, so the CLI can say it is not valid. Mutation:
  letting `optionTableFor` also read other commands' short spellings fails it.
- `tests/native-run-selection-cli.test.ts`:
  - `--profile "toolchain,"`, `",toolchain"` and `" , toolchain , "` run against a project
  started with `toolchain`.
  - `--profile "web,"` against `toolchain` gets the mismatch refusal, not the empty one.
  - Mutation `.every` to `.some`: all 4 fail.
- `tests/native-authored-command-dispatch.test.ts`: on an authored project with the native
  backend, `run` with `--profile ""`, `--profile " , "`, `--env ""` or `--env=  ` prints exactly
  `ERROR: E_USAGE: <NATIVE_RUN_EMPTY_SELECTION_REFUSAL>`, and neither the compiler nor the
  runtime runs. Mutation: disabling the early check fails all 4 (they report
  `E_NATIVE_PROJECT_UNSUPPORTED`).
- `tests/native-authored-project-oneoff.test.ts`: the source CLI run table, as described in
  Rework.

## Evidence

- Bun 1.4.2 (`mise x bun@1.4.2`). Base `origin/next` d0c8184, which includes a25160d.
- At 4d30eff: `bun run --cwd packages/cli typecheck` has 0 errors. Root `bun run check` has 0
  errors and prints `privacy-check: ok`. `biome check` is clean on the changed test file.
- Focused suites at 4d30eff, run on this Mac:
  - The 6 changed test files: 155/155 pass.
  - `native-authored-project-logs` and `native-authored-project-exec`: 91/91 pass.
  - An earlier run at e3d6917 passed 436/436 across 33 files: the branch, config, env, host,
  project owner/target/task, MCP, lifecycle-json, CLI parsing, help and reference-docs, renderer
  loading, closed-error output, native run selection, native one-off and authored dispatch
  suites. That run did not include `native-authored-project-oneoff`, which is how the failure
  got through.
- `bun run docs:cli-reference`: no change. No consumer agent guidance or `package.json` scripts
  changed.
- gitleaks 8.30.1 (`gitleaks dir` over a `git log -p origin/next..HEAD` export of both commits):
  no leaks.
- Hosted CI at 4d30eff (run 39854071300): the `Tests (Turbo)` step of the `test` job passed.
  That step runs the full suite on a macOS runner. Every other job that had finished when I
  wrote this also passed; see the checks tab for the final state.
- Full suite on the M3: **not run.** At 17:01 I queued it at exactly 4d30eff behind the shared
  `m3-ci/.lock`. M3 had about 67 GiB free then. The lock was held first by G41, then from 17:39
  by G52, whose 10-round loaded loop was still running at 20:28. About a dozen other units were
  also waiting, some for over 9 hours. I did not break a lock I don't own. At 20:30 I stopped my
  own waiter, which never took the lock and never created a worktree or ref. The hosted `Tests
  (Turbo)` full-suite step passed at this exact head.

## Gaps

- The M3 full suite has not run at 4d30eff, because the shared lock queue never reached it (see
  Evidence). The hosted macOS `Tests (Turbo)` step ran the full suite at this head and passed.
- On the Compose backend, an authored native-Compose `run` still resolves `--env ""` as the base
  env. This is unchanged and documented.
- `remote` and `gateway` (unsupported) and `internal` keep their own missing-project errors.
- `tui` with a real TTY is covered only by the renderer-loading fixture, which forces `isTTY`.

Refs HACK-1371

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant