Skip to content

feat(setup): preview and roll back migration to the skills-only plugin - #339

Merged
roodboi merged 2 commits into
nextfrom
wf/f16-plugin-migration-b16
Oct 20, 2026
Merged

roodboi merged 2 commits into
nextfrom
wf/f16-plugin-migration-b16

Conversation

@roodboi

@roodboi roodboi commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Linear: HACK-1132 (WU19, unit F16). Scoped migration with preview, replacement verification and rollback.

Final behavior

New hack setup migrate subcommand. It moves one scope at a time from standalone Hack skill and rule files to the skills-only hack plugin (the #305 layout).

  • Preview (default, read-only). Lists every standalone target in the selected scope (--path, --global, --all-scopes) and clients (--claude, --codex, --cursor; default all) with one action:

    • remove: Hack-owned (W11 classifier: current or known earlier render) and the replacement plugin is verified or explicitly skipped.
    • blocked: Hack-owned, but the replacement is not verified.
    • preserve: customized, unknown, or reached through a symlink. Each has a closed, value-free reason.
    • absent.

    Targets: the Codex hack-cli/hack-init skills, the Claude Code hack-init skill, the Cursor hack.mdc rule, and the shared ~/.ai/skills/hack-cli. The shared skill follows Claude Code or Codex.

  • Replacement verification reads configuration only (src/agents/plugin-replacement.ts):

    • Claude Code: the nearest settings file must enable hack@hack-dance (project settings.local.json, then project settings.json, then user). ~/.claude/plugins/installed_plugins.json must also list an installed copy that carries the hack-cli and hack-init skills.
    • Codex: [plugins."hack@hack-dance"] enabled = true in the project or user config.toml.
    • The global scope counts only user-level configuration.
    • Cursor is not-inspectable, so it needs --skip-plugin-check cursor. --skip-plugin-check <clients> accepts claude, codex, cursor or all.
  • --apply is all or nothing.

    • It refuses with exit 1 and writes nothing when any target is blocked, a scope root is unresolved, or a file can't be read for backup.
    • Otherwise it first commits a 0600 manifest under $HACK_HOME/agent-migrations/<id>.json. The write is fsynced, then renamed into place. The manifest holds the exact bytes, sha256 and mode of each file.
    • It then removes each file only if its bytes still match the backup. Removal uses the W11 owned-remove path, and an empty skill directory goes with its file.
    • A file edited in between is reported as changed and left in place, with exit 1.
    • When nothing is Hack-owned, apply writes no manifest.
  • --rollback-manifest <manifest>:

    • Restores each entry into an empty path with exclusive create, then sets the recorded mode.
    • Never overwrites. Identical bytes are reported as already-present; anything else is conflict (exit 1) and left unchanged.
    • Idempotent, and it recovers an interrupted apply.
    • Refuses the whole manifest, restoring nothing, if any entry's path is not exactly a known target under its root, its digest does not match, or its bytes are not a Hack render for that target.
  • Claude primer hooks, MCP entries and AGENTS.md/CLAUDE.md sections are never touched.

  • --json returns the usual {ok, data} envelope. Messages never include file contents.

  • setup-sync.ts now exports listOwnedFileTargets so migration and sync share one path/surface table.

Docs: docs/integrations.md (new "Migrate standalone files to the plugin" section, and the switching guidance now points to it), docs/cli.md, the tests/e2e/README.md freeze description, and a regenerated docs/reference/cli.md. No consumer guidance or render changes, so there are no fingerprint or revision updates.

Validation (local, macOS, source CLI)

  • tests/plugin-migration.test.ts (15 tests). Temp project and HOME roots are passed explicitly, so the process HOME is never touched. Covered:
    • preview leaves hashes and modes byte-identical
    • blocked apply refuses with no writes
    • verified apply, then rollback restores exact bytes and mode; a second rollback reports already-present
    • incomplete Claude install copy
    • project-local disable overrides a user enable
    • Codex disabled, malformed and other-plugin configs
    • project-only config does not verify user-scope duplicates (all-scopes apply refused)
    • Cursor skip
    • client filtering
    • symlinked skill dir preserved
    • interrupted apply recovered by rollback
    • edit between backup and removal reported as changed
    • rollback conflict leaves the user file alone
    • tampered manifests (path, bytes, target, digest, non-Hack bytes) and a missing manifest refused with no writes
    • unresolved scope root refused
  • tests/setup-migrate-command.test.ts (4 tests). Runs the source CLI through privateCliEnv with private HOME and HACK_HOME, and covers preview JSON, refused apply, skip-verified apply, rollback, flag conflicts and --skip-plugin-check parsing.
  • Flag name: the rollback flag is --rollback-manifest, not --rollback. The CLI parses argv once against the union of every command's options, keyed by long name. A value-typed setup migrate --rollback retyped the boolean config adopt --rollback, so config adopt --rollback --json took --json as its value and failed with a usage error. That failed all ten native-compose-adoption-*-worktrees docker E2E scenarios in CI run 39280939414. tests/cli-command.test.ts now requires each long option to keep one value type across the command tree, and checks that config adopt --rollback --json still parses as two booleans.
  • agent-surface-freeze e2e: added setup migrate --all-scopes and setup migrate --all-scopes --apply --skip-plugin-check all, twice each. Ran locally with bun tests/e2e/run.ts --only=agent-surface-freeze: pass, all customized seeded surfaces byte-identical, positive control intact.
  • Required local checks:
    • tests/integration-ownership.test.ts, tests/cli-spawn-env-isolation.test.ts, tests/cli-reference-docs.test.ts, tests/cli-help.test.ts and tests/integration-sync.test.ts pass.
    • bun run --cwd packages/cli typecheck: 0 errors.
    • bun run --cwd packages/cli check: 0 errors. Its 34 warnings come from files this PR does not change.
    • bunx ultracite check on the changed files is clean.
    • bun run privacy:check passes.

Negative controls (mutation proofs)

Each mutation below was applied alone. The named test file failed, and the source was then restored:

  • apply ignores blocked targets
  • Claude installed-copy check skipped
  • Claude settings precedence reversed
  • Codex enabled forced true
  • global scope reads project Codex config
  • symlink preservation removed
  • byte recheck before removal removed
  • rollback overwrites a conflict
  • manifest path validation removed
  • manifest digest validation removed
  • rollback accepts non-Hack bytes
  • restored mode ignored
  • manifest not committed before removal (caught by the interrupted-apply test)
  • customized files treated as owned
  • --skip-plugin-check accepts unknown names
  • refused apply exits 0
  • --rollback-manifest accepts extra flags
  • setup migrate flag named --rollback again (value-typed): both new tests/cli-command.test.ts guards fail

Remaining gaps (host-bound)

  • Replacement verification is configuration evidence, not loading evidence. A verified status does not prove a fresh client session loads the plugin. The command and docs tell the operator to check that first.
  • The Codex plugin cache layout is not inspected, so only config.toml enablement is checked. The Claude installed_plugins.json shape is the v2 registry form (array or object, both accepted), and this PR does not re-observe it against the current client release.
  • Cursor plugin installs are not inspectable; Cursor migration always needs an explicit skip.
  • No real client home or user/global migration was run; all evidence comes from temp fixtures. The new freeze rows also need their first hosted CI pass.
  • Concurrent setup migrate --apply runs are not serialized. Each removal rechecks bytes and ownership, which leaves the same single read-then-unlink window documented for W11.

🤖 Generated with Claude Code


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

roodboi and others added 2 commits October 19, 2026 11:49
Add `hack setup migrate` for moving one scope at a time from standalone
Hack skill and rule files to the skills-only `hack` plugin (HACK-1132).

- The default preview is read-only. It lists each standalone target as
  remove, blocked, preserve or absent, using the W11 ownership
  classifier. Customized, unknown and symlinked paths are preserved.
- Replacement checks read client configuration per scope: Claude Code
  settings precedence plus an installed copy carrying the replacement
  skills; Codex `[plugins."hack@hack-dance"] enabled = true`. Cursor is
  not inspectable and needs an explicit --skip-plugin-check.
- --apply is all or nothing. It refuses without writing when a target is
  blocked, commits a 0600 backup manifest with exact bytes and mode
  before removing anything, and rechecks bytes before each removal.
- --rollback restores only into empty paths, never overwrites, refuses
  manifests whose paths, digests or bytes are not Hack-owned targets,
  and recovers an interrupted apply.
- Hooks, MCP entries and AGENTS.md/CLAUDE.md sections are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
argv is parsed once against the union of every command's options keyed by
long name. Declaring `setup migrate --rollback <manifest>` as a value option
retyped the existing boolean `config adopt --rollback`, so
`hack config adopt --rollback --json` consumed `--json` as its value and
failed with a usage error. That broke every legacy-compose adoption
rollback in the docker E2E shards.

Rename the migration flag to `--rollback-manifest <manifest>` and add a
command-tree guard that each long option keeps one value type, plus a
parse regression for `config adopt --rollback --json`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi
roodboi marked this pull request as ready for review October 20, 2026 13:57
@roodboi
roodboi merged commit 9b523dc into next Oct 20, 2026
17 checks passed
@roodboi
roodboi deleted the wf/f16-plugin-migration-b16 branch October 20, 2026 13:57
roodboi added a commit that referenced this pull request Oct 22, 2026
…#370)

## Root cause

`parseCliArgv` parsed argv once against the union of every command's options, keyed by long name
(`buildUnionParseOptions`). Two options shared a short flag: `--path -p` (project commands) and
`ssh --port -p`. Bun's `parseArgs` resolved `-p` to whichever came last, which was `ssh --port`.
So `hack up -p <dir>` (and `init`, `down`, `ps`, `logs`, `env ...`, `config ...`, `setup ...`)
parsed as `--port`, and `run.ts` refused it with `Option(s) not valid for "up": --port`. The
same union let one long name's type in one command change how every other command parsed it
(#339 hit this with `--rollback`), which is why a guard test banned cross-command type
conflicts.

## Fix

`src/cli/command.ts` now parses in two passes:

1. **`scanInvocation`** resolves the command path, and the child-command boundary for
passthrough positionals, from the leading positionals. It reads each option with the options of
the command resolved so far, plus the global and built-in options, and skips that option's value
token, including the omitted-value form of options with a default. An option placed before the
command name falls back to the other declarations of that spelling. If those declarations
disagree about taking a value, the option is read as a flag, so it never consumes the command
name. 2. **`parseCliArgv`** then parses with that command's own options plus the global and
built-in options, by long and short spelling. Options that only other commands declare are still
parsed by long name, never by short flag. A misplaced option therefore keeps its existing
`Option(s) not valid for "<command>"` refusal.

`findPassthroughStart` and `hackOwnedArgv` use the same scan, so #359's child-argv passthrough
uses the resolved command's option types. That covers `--` handling, flags after the child
command, and the JSON envelope selected only by Hack's own `--json`. Default-value normalization
is scoped the same way. `run.ts` is unchanged.

The only parse difference found by comparing old and new output on edge cases (`--help --all`,
`help --all`, `-- up`, `--json run web tool --json`, `--path /x up`, `logs --tail`, `up --tail`,
`ssh --path /x`, `x ext cmd -p 1`, `--no-interactive up -p /x`) is the intended one: `up -p /x`
now sets `path` instead of `port`.

## Tests

- New `tests/cli-per-command-options.test.ts`:
  - `-p` sets `--path` for every command that declares `--path -p`. The test walks the spec and
  asserts that it includes init/up/down/ps/logs/env list/config validate/setup migrate.
  - `ssh -p` sets `--port`.
  - `-p` before the command name follows that command.
  - A misplaced foreign long option is still refused as "not valid here", while an unknown short
  flag is unknown.
  - `config adopt --rollback --json` stays boolean, and `setup migrate --rollback-manifest
  <file>` takes a value.
  - A synthetic CLI where `--rollback`/`-r` is a flag in one command and takes a value in
  another parses correctly per command, before the command name, and at the passthrough
  boundary.
  - A source-CLI spawn under `privateCliEnv` checks that `hack ps -p <dir> --json` no longer
  reports `--port`.
- `tests/cli-command.test.ts`: the cross-tree "one value type per long option" guard is replaced
  by a per-scope guard. Within a command's scope (built-ins, globals and the command's own
  options), each long and short spelling must name exactly one option.
- The #359 passthrough tests in `tests/cli-passthrough-argv.test.ts` are unchanged and pass.

Each new test was checked by mutation:
- With the old `command.ts`, 11 of the new tests fail, including the spawn test. It printed
  `Option(s) not valid for "ps": --port`.
- Making disagreeing declarations keep the last type fails the before-command-name test.
- Removing other commands' long names from the parse table fails the "not valid here" test.
- Giving `ssh --port` the short flag `-h` fails the per-scope guard with `hack ssh: -h names
  --help and --port`.

## Evidence

Bun 1.4.2 (`mise x bun@1.4.2`), base ace57ee. `next` has since moved to f2278c7; it only adds
an unrelated `run.ts` hunk and merges cleanly.

- `bun run --cwd packages/cli typecheck`: 0 errors. `bun run typecheck`: passes.
- `bun run --cwd packages/cli check` and `bun run check`: 0 errors. The changed files lint
  clean, with no new warnings.
- Focused suites pass: 131/131 for the new test file, cli-passthrough-argv, cli-command, cli-
  spawn-env-isolation, cli-reference-docs, integration-ownership, mcp and mcp-inspect-tools. One
  run had a transient failure in an `mcp.test.ts` spawn test under load; it passed alone and in
  the rerun.
- `bun run docs:cli-reference`: no change to `docs/reference/cli.md`, because the help surface
  is unchanged.
- After `bun run build`, `dist/hack ps -p <dir> --json` and `dist/hack up -p <dir> --json`
  (private HOME) reach project discovery ("No .hack/ found") instead of the `--port` refusal.
- No native refusal codes or messages changed, so the native plan-CLI scripts were not rerun.
- gitleaks 8.30.1 over a `git log -p origin/next..HEAD` export: no leaks.

## Remaining gaps

- Full `bun run test` ran on a heavily loaded host for 4253s and ended with 6824 pass and 640
  fail across 105 files. Rerun serially with `--timeout 60000`, 89 of those files pass.
  - The 16 that still fail are native-compose/native-authored files. Rerun with `origin/next`'s
  `command.ts` swapped in, they fail the same way or worse; for example, `native-compose-
  adoption-generation` fails 0/111 on both.
  - Two files with a single failure each (`dependency-startup-progress`, `native-compose-
  adoption-probe`) pass alone, and neither imports the CLI parser.
  - None of these failures is caused by this change. A green full run on a quiet runner or CI is
  still outstanding.
- Edge case, documented in `scanInvocation`: an option placed before the command name whose
  spelling is a flag in one command and takes a value in another is read as a flag. No such
  spelling exists in the current CLI.

Refs HACK-1293

🤖 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