diff --git a/docs/release/upgrading-from-bash.md b/docs/release/upgrading-from-bash.md index 4664a1561..ff3d68450 100644 --- a/docs/release/upgrading-from-bash.md +++ b/docs/release/upgrading-from-bash.md @@ -12,11 +12,11 @@ Use this guide if all of these are true: - You installed Bash docket from the `v0.9.2` or `v0.9.3` tag. - Your repositories keep their docket records on a separate `docket` branch. -- You use docket with Claude Code. +- You use docket with Claude Code, Cursor, or both. Repositories that keep their records on the default branch (the single-branch layout) are not -covered. The guide covers Claude Code only. Sections for Cursor and OpenCode arrive in later -pre-releases. Codex is not supported. +covered. The guide covers Claude Code and Cursor. OpenCode follows in a later pre-release. Codex is +not supported. ## 2. Before you start @@ -28,16 +28,17 @@ pre-releases. Codex is not supported. ## 3. Install the docket binary Run these commands outside any repository. They download the installer and the checksum file into -a new folder, check the installer against the checksum, then run it for Claude Code. +a new folder, check the installer against the checksum, then run it for Claude Code and Cursor. If +you use only one of them, drop the other one's `--harness` flag, here and in section 4. ```sh mkdir -p ~/docket-download && cd ~/docket-download -VERSION=v1.0.0-alpha.1 +VERSION=v1.0.0-alpha.2 curl -fsSLO "https://github.com/danielhanold/docket/releases/download/$VERSION/install.sh" curl -fsSLO "https://github.com/danielhanold/docket/releases/download/$VERSION/checksums.txt" grep ' install.sh$' checksums.txt | shasum -a 256 -c - -sh install.sh --harness claude +sh install.sh --harness claude --harness cursor ``` The `shasum` line must print `install.sh: OK`. If it prints anything else, stop and do not run @@ -53,7 +54,7 @@ clears the conflicts. If you build docket from a source checkout instead, see [Installing docket](../install/install.md). That route needs Go and is not part of this guide. -## 4. Take over the old Claude Code install +## 4. Take over the old Claude Code and Cursor install The installer takes over the agent files Bash docket wrote under `~/.claude/agents/` by itself, as long as they still hold exactly what the Bash installer wrote. @@ -65,14 +66,31 @@ you to move or delete it. For a Bash `v0.9.2` or `v0.9.3` install, that list is: Deleting a link leaves the checkout alone. - on `v0.9.3` only, `~/.claude/agents/docket-plan-writer.md`. -Delete them, then run the installer again from the same folder. The failed run left no `docket` -command, so run `sh install.sh` again, not `docket install`. +For Cursor, the installer takes over Bash's agent files under `~/.cursor/agents/` by itself in the +same way. It reports these Cursor paths as conflicts: + +- every `docket-*` link under `~/.cursor/skills/`. These also point into your old Bash checkout. +- on `v0.9.3` only, `~/.cursor/agents/docket-plan-writer.md` and Bash's user-level rule + `~/.cursor/rules/docket-dispatch.mdc`. On `v0.9.2` it is not reported as a conflict; the block + below deletes it either way. + +Delete the Cursor paths first. If you don't use Cursor, skip this block. + + +```sh +rm ~/.cursor/skills/docket-* +rm -f ~/.cursor/agents/docket-plan-writer.md ~/.cursor/rules/docket-dispatch.mdc +``` + +Then delete the Claude Code paths and run the installer again from the same folder, with the same +`--harness` flags as in section 3. If you don't use Claude Code, leave out the two `rm` lines. The +failed run left no `docket` command, so run `sh install.sh` again, not `docket install`. ```sh rm ~/.claude/skills/docket-* rm -f ~/.claude/agents/docket-plan-writer.md -sh install.sh --harness claude +sh install.sh --harness claude --harness cursor ``` This time the installer reports `install: applied`. @@ -177,9 +195,8 @@ docket repository configure-tests Next, choose the coding agents docket writes instructions for. On a terminal, `docket repository configure-harnesses` shows a checklist. The command below records "none yet", -because Bash docket's `CLAUDE.md` block would get in the way. After you remove that block (the last -step), run `docket repository configure-harnesses --harnesses claude` (or your agents) and commit -the result. +because Bash docket's `CLAUDE.md` block would get in the way. The last step of this section, after +you remove that block, chooses your agents. ```sh @@ -236,9 +253,19 @@ git commit -am "Remove the Bash docket block from CLAUDE.md" git push ``` -Last, choose your coding agents, which the configure-harnesses step left as "none yet": run -`docket repository configure-harnesses --harnesses ` (for example `claude`) and commit the -paths it lists. +Last, choose your coding agents, which the configure-harnesses step left as "none yet". The command +lists the paths to review and commit. For Claude Code, it writes docket's own block into `CLAUDE.md`. +For Cursor, it writes `.cursor/rules/docket-dispatch.mdc` into the repository, which the +`.gitignore` block keeps out of commits. Drop the agent you don't use from `--harnesses`, and if you +don't use Claude Code, leave `CLAUDE.md` off the `git add` line. + + +```sh +docket repository configure-harnesses --harnesses claude,cursor +git add .docket.yml CLAUDE.md +git commit -m "Choose docket's agents" +git push +``` ## 6. Settings that changed @@ -277,22 +304,31 @@ does not mention them. Delete them: The test covers zsh, where those lines are in `~/.zshenv`. Other shells keep them in a different startup file, which the test does not cover. -Your old Bash checkout, usually `~/dev/docket`, is no longer used by Claude Code: nothing under -`~/.claude` points into it after the upgrade. The links Bash docket made for other tools, under -`~/.cursor`, `~/.codex` and `~/.agents`, still do. The guide does not cover those tools. Using -docket from Cursor or OpenCode on an upgraded repository is not supported until their sections -arrive, and Codex is not supported at all (see section 1). +Your old Bash checkout, usually `~/dev/docket`, is no longer used by Claude Code or Cursor: nothing +under `~/.claude` or `~/.cursor` points into it after the upgrade. The links Bash docket made for +other tools, under `~/.codex` and `~/.agents`, still do. The guide does not cover those tools. +Using docket from OpenCode on an upgraded repository is not supported until its section arrives, +and Codex is not supported at all (see section 1). + +If you skipped a harness in section 4, its Bash links under `~/.claude` or `~/.cursor` still point +into the checkout; delete them with that harness's `rm` lines from section 4. + +In Cursor, docket must run outside Cursor's sandbox. [Running docket under Cursor](../install/cursor.md) +shows the permission setup that allows it. Bash docket also wrote agent files into a repository when its `.docket.yml` has an `agents:` -setting: one `docket-*.md` file per agent under the repository's `.claude/agents/` folder. Claude -Code uses an agent file in the repository instead of the one with the same name in -`~/.claude/agents/`, so these old files hide the agents the installer just wrote. Delete them in -every repository. If a repository has none, the command does nothing. +setting: one `docket-*.md` file per agent under the repository's `.claude/agents/` folder, and for +Cursor under `.cursor/agents/`. Claude Code uses an agent file in the repository instead of the one +with the same name in `~/.claude/agents/`, so these old files hide the agents the installer just +wrote. Delete them in every repository. If a repository has none, the command does nothing. The +saved installs the test runs carry no repository Cursor agent files, so the test does not prove the +`.cursor/agents` line. ```sh cd rm -f .claude/agents/docket-*.md +rm -f .cursor/agents/docket-*.md ``` The `.gitignore` block from section 5 keeps these files out of commits, so there is nothing to @@ -304,10 +340,10 @@ default branch, such as `Bash(git -C * push origin HEAD:main)`, and it can also your own. This guide does not cover that file: the test leaves it in place and does not show whether deleting it is safe. -## 8. Restart Claude Code +## 8. Restart Claude Code and Cursor -Quit Claude Code and start it again. Claude Code loads agents and skills when it starts, so -clearing a conversation is not enough. +Quit Claude Code and Cursor and start each one again. Both load agents and skills when they start, +so clearing a conversation is not enough. ## 9. If something goes wrong diff --git a/docs/superpowers/specs/2026-10-08-cursor-section-of-the-bash-upgrade-guide-proven-on-the-saved-design.md b/docs/superpowers/specs/2026-10-08-cursor-section-of-the-bash-upgrade-guide-proven-on-the-saved-design.md new file mode 100644 index 000000000..85a570f94 --- /dev/null +++ b/docs/superpowers/specs/2026-10-08-cursor-section-of-the-bash-upgrade-guide-proven-on-the-saved-design.md @@ -0,0 +1,118 @@ +# Cursor section of the Bash upgrade guide, proven on the saved cases + +Change 0543 — Cursor section of the Bash upgrade guide, proven on the saved cases + +**Change:** 0543 · **Type:** docs · **Priority:** high · **Groomed:** 2026-10-08 · **Status:** Approved design + +## Purpose + +`docs/release/upgrading-from-bash.md` (change 0511) moves a Bash docket install to the Go binary +for Claude Code only. This change extends it to Cursor, proven by the same +`internal/bashupgrade` integration test against the saved `v0.9.2` and `v0.9.3` cases, which +already hold the Bash Cursor files. It must merge before the `v1.0.0-alpha.2` candidate is cut +(change 0512), so the test runs inside that candidate's source gate. + +No product code changes. If the test exposes something the binary does wrong, rather than +something the guide can explain, the build halts and that bug gets its own fix change, as in 0511. + +## What the saved cases show (traced 2026-10-08) + +A scratch reproduction ran the current `docket install --harness cursor` against each restored +saved home: + +| Path | v0.9.2 | v0.9.3 | +|---|---|---| +| `~/.cursor/agents/docket-*.md` | taken over by the installer (legacy byte match) | taken over, except `docket-plan-writer.md`: conflict | +| `~/.cursor/skills/docket-*` (links into the old checkout) | conflict | conflict | +| `~/.cursor/rules/docket-dispatch.mdc` (Bash's user-level dispatch rule) | removed by the installer (matches the frozen v0.9.2 rule) | conflict (v0.9.3's bytes differ from the frozen floor) | + +After `rm ~/.cursor/skills/docket-*` and +`rm -f ~/.cursor/agents/docket-plan-writer.md ~/.cursor/rules/docket-dispatch.mdc`, the second +install reports `applied` on both cases. `install check` is then clean except the `runtime.bash` +`obsolete-setting` warning, which the guide's existing global-config step clears. Nothing under +`~/.cursor` links into the old checkout. The installer also writes `~/.cursor/hooks.json`. + +The v0.9.3 rule conflict is by design (ADR-0096: the legacy reproducer is a frozen v0.9.2 +floor), so it is a guide remedy, the same way `docket-plan-writer.md` already is for Claude Code. + +There is no Cursor counterpart of the repository `CLAUDE.md` dispatch block: Bash wrote its Cursor +rule at user level. The Go binary writes `.cursor/rules/docket-dispatch.mdc` per repository when +`agent_harnesses` lists `cursor`, and the guide's `.gitignore` block already ignores it. + +## Design (human, 2026-10-08) + +**One combined path.** The guide has a single flow for Claude Code and Cursor. Each harness's +lines are labelled, and the reader drops the harness they don't use. The test runs each saved case +once, through both harnesses. A separate Claude-only run is not kept: it would differ by one +`--harness` flag, and each harness's install targets are separate. Every existing Claude Code step +and assertion stays, because the test is what keeps the guide's Claude Code half true against each +new binary. + +### Guide changes + +- **§1 Who this is for.** "You use docket with Claude Code, Cursor, or both." Cursor is covered; + OpenCode follows in a later pre-release; Codex is not supported. +- **§3 Install the docket binary.** `VERSION=v1.0.0-alpha.2`. The installer line becomes + `sh install.sh --harness claude --harness cursor`, with a sentence to drop the `--harness` you + don't use. The text about the first run stopping with `conflict` lines is unchanged. +- **§4** becomes "Take over the old Claude Code and Cursor install". + - The existing Claude Code text and its marked `takeover-remedy` step stay. Its re-run line + becomes the same two-harness `sh install.sh` line as §3. + - A Cursor paragraph: the installer takes over Bash's agent files under `~/.cursor/agents/` by + itself. It reports as conflicts every `docket-*` link under `~/.cursor/skills/`, and on `v0.9.3` + `~/.cursor/agents/docket-plan-writer.md` and Bash's user-level rule + `~/.cursor/rules/docket-dispatch.mdc`. On `v0.9.2` the installer removes that rule itself. + - A new marked step, `cursor-takeover-remedy`, run before the re-run: + + ```sh + rm ~/.cursor/skills/docket-* + rm -f ~/.cursor/agents/docket-plan-writer.md ~/.cursor/rules/docket-dispatch.mdc + ``` + + - The global-config cleanup and `confirm-install` steps are unchanged. +- **§5 Upgrade each repository.** The final `configure-harnesses` example becomes + `--harnesses claude,cursor`. For Cursor, it writes `.cursor/rules/docket-dispatch.mdc` into the + repository, which the `.gitignore` block keeps out of commits. +- **§7 Leftovers you can delete.** + - Nothing under `~/.claude` or `~/.cursor` points into the old checkout after the upgrade. The + links Bash made under `~/.codex` and `~/.agents` still do. Using docket from OpenCode on an + upgraded repository is not supported until its section arrives; Codex is not supported. + - The marked `repo-agent-files` step also runs `rm -f .cursor/agents/docket-*.md`. The saved + cases carry no repository Cursor agent files, so the guide says the test does not prove that + line. + - A sentence that docket must run outside Cursor's sandbox, linking `docs/install/cursor.md`. +- **§8** becomes "Restart Claude Code and Cursor": quit each and start it again; both load agents + and skills at startup. + +### Test changes (`internal/bashupgrade`) + +The guide-driven run (`TestIntegrationBashUpgradeGuide`) executes the new and changed marked steps +like every other. New assertions: + +- The first install's conflicts include every `~/.cursor/skills/docket-*` link on both cases, and on + `v0.9.3` also `~/.cursor/agents/docket-plan-writer.md` and `~/.cursor/rules/docket-dispatch.mdc`. +- After the guide, `~/.cursor/rules/docket-dispatch.mdc` does not exist on either case. +- The clean end state covers both harnesses: `docket install check --json` reports `cursor` among + its harnesses with no error or warning finding. +- Nothing under `~/.cursor` links into the old checkout. The existing assertion that `~/.cursor` + still has such links flips to require zero; `~/.codex` and `~/.agents` keep requiring at least one. +- The clone has `.cursor/rules/docket-dispatch.mdc` after the final `configure-harnesses` step, + with any guide-shape check the test already applies to marked steps. + +Existing assertions stay, including the guide-shape and finding/settings table checks. The test +stays in the integration build tag and inside the whole-suite gate. + +## Out of scope + +- OpenCode (alpha.3, change 0513) and Codex. +- The release itself (change 0512). +- Retiring the saved cases and the test (change 0514). +- A Claude-only test run, and new saved cases (for example repository `.cursor/agents` files). +- Any change to the binary's takeover rules, including teaching the frozen floor v0.9.3's rule. + +## Acceptance + +- The guide reads as above, and every marked step runs in the test. +- `TestBashUpgrade` passes on both saved cases with the new Cursor assertions, inside the whole + suite. +- Mutation check: deleting the `cursor-takeover-remedy` step from the guide makes the test fail. diff --git a/internal/bashupgrade/bashupgrade_integration_test.go b/internal/bashupgrade/bashupgrade_integration_test.go index 4040244a7..d6b76957d 100644 --- a/internal/bashupgrade/bashupgrade_integration_test.go +++ b/internal/bashupgrade/bashupgrade_integration_test.go @@ -4,6 +4,8 @@ package bashupgrade import ( "encoding/json" + "errors" + "io/fs" "os" "path/filepath" "reflect" @@ -106,9 +108,15 @@ func TestIntegrationBashUpgradeGuide(t *testing.T) { t.Fatalf("the remedy did not lead to a second install run") } assertCleanEndState(t, c) + if _, err := os.Lstat(filepath.Join(c.Home, ".cursor", "rules", "docket-dispatch.mdc")); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("Bash's user-level Cursor rule ~/.cursor/rules/docket-dispatch.mdc is still there after the guide (%v)", err) + } assertRecordsSurvive(t, c, before) - if after, _ := os.ReadFile(filepath.Join(c.Clone, "CLAUDE.md")); strings.Contains(string(after), "docket:dispatch:") { - t.Errorf("the repository CLAUDE.md still carries the Bash dispatch block after the guide") + if after, _ := os.ReadFile(filepath.Join(c.Clone, "CLAUDE.md")); strings.Contains(string(after), bashDispatchSignature) { + t.Errorf("the repository CLAUDE.md still carries Bash docket's dispatch block (it runs docket.sh) after the guide") + } + if fi, err := os.Lstat(filepath.Join(c.Clone, ".cursor", "rules", "docket-dispatch.mdc")); err != nil || !fi.Mode().IsRegular() { + t.Errorf("the repository has no .cursor/rules/docket-dispatch.mdc after the guide (%v)", err) } dispatchRemoved = dispatchRemoved || st.DispatchBlockRemoved repoAgentShadow = repoAgentShadow || st.RepoAgentFilesShadow > 0 @@ -148,7 +156,8 @@ func TestIntegrationBashUpgradeGuide(t *testing.T) { } } -// assertCleanEndState: install check, repository check and status all clean. +// assertCleanEndState: install check (for every harness the guide installs), +// repository check and status all clean. func assertCleanEndState(t *testing.T, c *upgradeCase) { t.Helper() for _, args := range [][]string{{"--json", "install", "check"}, {"--json", "repository", "check"}} { @@ -157,6 +166,23 @@ func assertCleanEndState(t *testing.T, c *upgradeCase) { t.Errorf("docket %s exited %d\nstdout:\n%s\nstderr:\n%s", strings.Join(args, " "), r.Code, r.Stdout, r.Stderr) continue } + if args[1] == "install" { + var doc struct { + Harnesses []string `json:"harnesses"` + } + if err := json.Unmarshal([]byte(r.Stdout), &doc); err != nil { + t.Errorf("decode install check JSON: %v\n%s", err, r.Stdout) + } + have := map[string]bool{} + for _, h := range doc.Harnesses { + have[h] = true + } + for _, h := range guideHarnesses { + if !have[h] { + t.Errorf("docket install check reports harnesses %v; the guide installs %s", doc.Harnesses, h) + } + } + } for _, f := range findingCodes(t, r.Stdout) { if f.Severity == "error" || f.Severity == "warning" { t.Errorf("docket %s still reports %s (%s)", strings.Join(args, " "), f.Code, f.Severity) diff --git a/internal/bashupgrade/registry_test.go b/internal/bashupgrade/registry_test.go index 4aab7e125..a75ea6a0f 100644 --- a/internal/bashupgrade/registry_test.go +++ b/internal/bashupgrade/registry_test.go @@ -43,13 +43,14 @@ type runState struct { Conflicts []string Observed map[string][]findingRef - Guide string // the full guide text, for prose and table checks - Cwd string // the reader's terminal directory after the last block - DownloadDir string // where install-binary downloaded install.sh - InstallLine string // install-binary's installer invocation, re-run by takeover-remedy - CheckJSON string // repo-check's --json stdout - Settings []string // settings-table patterns config-cleanup matched to a finding - Removed []string // settings config-cleanup removed from .docket.yml + Guide string // the full guide text, for prose and table checks + Cwd string // the reader's terminal directory after the last block + DownloadDir string // where install-binary downloaded install.sh + InstallLine string // install-binary's installer invocation, re-run by takeover-remedy + InstallHarnesses []string // the --harness names of InstallLine, in line order + CheckJSON string // repo-check's --json stdout + Settings []string // settings-table patterns config-cleanup matched to a finding + Removed []string // settings config-cleanup removed from .docket.yml DispatchBlockRemoved bool // dispatch-block found and removed the CLAUDE.md block RepoAgentFilesShadow int // repository agent files that hid an installed agent before repo-agent-files @@ -59,24 +60,26 @@ type runState struct { type stepAction func(t *testing.T, c *upgradeCase, st *runState, body string) var stepRegistry = map[string]stepAction{ - "install-binary": mirrorInstallBinary, - "takeover-remedy": mirrorTakeoverRemedy, - "global-config-cleanup": mirrorGlobalConfigCleanup, - "confirm-install": runConfirmInstall, - "repo-prepare": runRepoPrepare, - "repo-check": observeRepoCheck, - "fix-gitignore": mirrorFixGitignore, - "config-cleanup": mirrorConfigCleanup, - "commit-fixes": runCommitFixes, - "configure-tests": runPlain, - "configure-harnesses": runPlain, - "commit-config": runCommit, - "repair-preview": observeRepairPreview, - "repair-apply": runRepairApply, - "repo-confirm": runRepoConfirm, - "dispatch-block": mirrorDispatchBlock, - "leftovers": mirrorLeftovers, - "repo-agent-files": runRepoAgentFiles, + "install-binary": mirrorInstallBinary, + "cursor-takeover-remedy": mirrorCursorTakeoverRemedy, + "takeover-remedy": mirrorTakeoverRemedy, + "global-config-cleanup": mirrorGlobalConfigCleanup, + "confirm-install": runConfirmInstall, + "repo-prepare": runRepoPrepare, + "repo-check": observeRepoCheck, + "fix-gitignore": mirrorFixGitignore, + "config-cleanup": mirrorConfigCleanup, + "commit-fixes": runCommitFixes, + "configure-tests": runPlain, + "configure-harnesses": runPlain, + "commit-config": runCommit, + "repair-preview": observeRepairPreview, + "repair-apply": runRepairApply, + "repo-confirm": runRepoConfirm, + "dispatch-block": mirrorDispatchBlock, + "choose-harnesses": runChooseHarnesses, + "leftovers": mirrorLeftovers, + "repo-agent-files": runRepoAgentFiles, } // Guide headings the table checks anchor on. @@ -190,6 +193,74 @@ func mustContain(t *testing.T, what, text, want string) { } } +// guideHarnesses are the harnesses the guide's combined path covers; the test runs +// every saved case once, through all of them. +var guideHarnesses = []string{"claude", "cursor"} + +// installLineHarnesses parses the guide's installer invocation: exactly `sh install.sh` +// followed by one or more `--harness ` pairs, each name one the downloader +// accepts and none repeated. It returns the names in line order. +func installLineHarnesses(line string) ([]string, bool) { + f := strings.Fields(line) + if len(f) < 4 || len(f)%2 != 0 || f[0] != "sh" || f[1] != "install.sh" { + return nil, false + } + known := map[string]bool{"claude": true, "codex": true, "cursor": true, "opencode": true} + seen := map[string]bool{} + var out []string + for i := 2; i < len(f); i += 2 { + if f[i] != "--harness" || !known[f[i+1]] || seen[f[i+1]] { + return nil, false + } + seen[f[i+1]] = true + out = append(out, f[i+1]) + } + return out, true +} + +// containsProse reports whether text contains want once every whitespace run in both +// is collapsed to one space, so a re-flowed guide paragraph still matches the claim. +func containsProse(text, want string) bool { + return strings.Contains(strings.Join(strings.Fields(text), " "), strings.Join(strings.Fields(want), " ")) +} + +func mustContainProse(t *testing.T, what, text, want string) { + t.Helper() + if !containsProse(text, want) { + t.Fatalf("%s does not contain %q (whitespace collapsed):\n%s", what, want, text) + } +} + +// pendingPathsLead introduces the paths `docket repository configure-harnesses` asks +// the reader to review and commit, in its human output. +const pendingPathsLead = "review and commit the pending paths: " + +// pendingPathsFrom returns, sorted, the paths after pendingPathsLead up to the end of +// that line, or nil when the output carries no such list. +func pendingPathsFrom(out string) []string { + _, rest, ok := strings.Cut(out, pendingPathsLead) + if !ok { + return nil + } + line, _, _ := strings.Cut(rest, "\n") + var paths []string + for _, p := range strings.Split(line, ",") { + if p = strings.TrimSpace(p); p != "" { + paths = append(paths, p) + } + } + sort.Strings(paths) + return paths +} + +// sameSet reports whether a and b hold the same strings, ignoring order. +func sameSet(a, b []string) bool { + x, y := append([]string(nil), a...), append([]string(nil), b...) + sort.Strings(x) + sort.Strings(y) + return strings.Join(x, "\x00") == strings.Join(y, "\x00") && len(x) == len(y) +} + // findingObjects decodes one JSON document and returns, in document order, every // object carrying string code and severity fields (the shape findingCodes reads). func findingObjects(t *testing.T, stdout string) []map[string]any { @@ -373,6 +444,18 @@ func snapshotTree(t *testing.T, dir string) map[string]string { return out } +// harnessHomesSnapshot snapshots every harness folder the guide's install writes. +func harnessHomesSnapshot(t *testing.T, c *upgradeCase) map[string]string { + t.Helper() + out := map[string]string{} + for _, d := range []string{".claude", ".cursor"} { + for k, v := range snapshotTree(t, filepath.Join(c.Home, d)) { + out[k] = v + } + } + return out +} + func gitRev(t *testing.T, c *upgradeCase, ref string) string { t.Helper() return strings.TrimSpace(c.mustGit(t, c.Origin, "rev-parse", ref)) @@ -413,23 +496,27 @@ func mirrorInstallBinary(t *testing.T, c *upgradeCase, st *runState, body string sawChecksums = true case strings.Contains(line, "checksums.txt") && strings.Contains(line, "shasum -a 256 -c"): sawVerify = true - case line == "sh install.sh --harness claude": - st.InstallLine = line + case f[0] == "sh": + hs, ok := installLineHarnesses(line) + if !ok || !sameSet(hs, guideHarnesses) { + t.Fatalf("install-binary: installer line %q must be `sh install.sh` with one --harness per covered harness %v", line, guideHarnesses) + } + st.InstallLine, st.InstallHarnesses = line, hs default: t.Fatalf("install-binary: line %q is not a shape the test mirrors", line) } } if st.DownloadDir == "" || !sawInstallSh || !sawChecksums || !sawVerify || st.InstallLine == "" { - t.Fatalf("install-binary block must make a download folder, fetch install.sh and checksums.txt, verify with shasum -a 256 -c, and run `sh install.sh --harness claude`:\n%s", body) + t.Fatalf("install-binary block must make a download folder, fetch install.sh and checksums.txt, verify with shasum -a 256 -c, and run the installer for every covered harness:\n%s", body) } installHandoff(t, c, st) } // installHandoff mirrors internal/release/downloader/install.sh from the point it // has a verified binary: stage it beside the destination, run the staged binary's -// `install --harness claude`, and only on success move it into place, place the -// relative dckt alias beside it, write the ownership record (naming the alias), and -// run `docket install check`. +// `install` with one `--harness` per harness on the guide's installer line, and +// only on success move it into place, place the relative dckt alias beside it, +// write the ownership record (naming the alias), and run `docket install check`. func installHandoff(t *testing.T, c *upgradeCase, st *runState) { t.Helper() dest := filepath.Join(c.BinDir, "docket") @@ -450,10 +537,14 @@ func installHandoff(t *testing.T, c *upgradeCase, st *runState) { if err := os.WriteFile(stage, bin, 0o755); err != nil { t.Fatal(err) } - before := snapshotTree(t, filepath.Join(c.Home, ".claude")) + args := []string{"install"} + for _, h := range st.InstallHarnesses { + args = append(args, "--harness", h) + } + before := harnessHomesSnapshot(t, c) // The downloader runs the staged binary in human mode, so the reader sees these // lines; the guide quotes them. - r := c.run(t, st.DownloadDir, stage, "install", "--harness", "claude") + r := c.run(t, st.DownloadDir, stage, args...) st.InstallAttempts = append(st.InstallAttempts, r) human := r.Stdout + r.Stderr if r.Code != 0 { @@ -464,7 +555,7 @@ func installHandoff(t *testing.T, c *upgradeCase, st *runState) { // A failed run changes nothing, so a --json re-run reads the same conflict // set in a form the test can parse; the human output must list each path on // a conflict line. - j := c.run(t, st.DownloadDir, stage, "--json", "install", "--harness", "claude") + j := c.run(t, st.DownloadDir, stage, append([]string{"--json"}, args...)...) _ = os.Remove(stage) var conflicts []string var doc struct { @@ -499,8 +590,8 @@ func installHandoff(t *testing.T, c *upgradeCase, st *runState) { if _, err := os.Lstat(dest); err == nil { t.Fatalf("a failed install left %s behind", dest) } - if after := snapshotTree(t, filepath.Join(c.Home, ".claude")); !mapsEqual(before, after) { - t.Fatalf("a failed install changed ~/.claude") + if after := harnessHomesSnapshot(t, c); !mapsEqual(before, after) { + t.Fatalf("a failed install changed ~/.claude or ~/.cursor") } st.Conflicts = conflicts return @@ -611,6 +702,84 @@ func mirrorTakeoverRemedy(t *testing.T, c *upgradeCase, st *runState, body strin } } +// The Cursor paths the guide's cursor-takeover-remedy block deletes, and the guide's +// claims about them. +const ( + cursorSkillsGlob = "~/.cursor/skills/docket-*" + cursorPlanWriter = "~/.cursor/agents/docket-plan-writer.md" + cursorUserRule = "~/.cursor/rules/docket-dispatch.mdc" + cursorRuleNoConflictProse = "On `v0.9.2` it is not reported as a conflict; the block below deletes it either way." + cursorRemedySkipProse = "If you don't use Cursor, skip this block." + cursorSandboxLink = "](../install/cursor.md)" +) + +// mirrorCursorTakeoverRemedy runs the block's Cursor deletes after proving the first +// install's Cursor conflicts are exactly the ones the guide lists: every +// ~/.cursor/skills/docket-* link, plus, on v0.9.3 only, docket-plan-writer.md and Bash's +// user-level rule. On v0.9.2 the rule is present but no conflict, which is what the +// guide's "not reported as a conflict; the block below deletes it either way" claims: +// the first install fails on the skills conflicts, so the block's rm -f is what removes it. +// The installer re-run stays in takeover-remedy, so this block must come first. +func mirrorCursorTakeoverRemedy(t *testing.T, c *upgradeCase, st *runState, body string) { + t.Helper() + lines := blockLines(body) + if len(lines) == 0 { + t.Fatalf("cursor-takeover-remedy block is empty") + } + for _, l := range lines { + if !strings.HasPrefix(l, "rm ") { + t.Fatalf("cursor-takeover-remedy line %q is not a delete the test mirrors; the installer re-run belongs to takeover-remedy", l) + } + } + for _, p := range []string{cursorSkillsGlob, cursorPlanWriter, cursorUserRule} { + mustContain(t, "cursor-takeover-remedy block", body, p) + } + mustContainProse(t, "guide", st.Guide, cursorRuleNoConflictProse) + mustContainProse(t, "guide", st.Guide, cursorRemedySkipProse) + if len(st.Conflicts) == 0 { + t.Fatalf("cursor-takeover-remedy: the first install reported no conflict; the guide says every %s link conflicts", cursorSkillsGlob) + } + links, _ := filepath.Glob(c.expandHome(cursorSkillsGlob)) + if len(links) == 0 { + t.Fatalf("population floor: the saved %s home has no %s link", c.Tag, cursorSkillsGlob) + } + planWriter, rule := c.expandHome(cursorPlanWriter), c.expandHome(cursorUserRule) + want := map[string]bool{} + for _, l := range links { + want[l] = true + } + if c.Tag == "v0.9.3" { + want[planWriter], want[rule] = true, true + } else if _, err := os.Lstat(rule); err != nil { + t.Errorf("guide says the block deletes Bash's rule on %s, but the saved home has no %s: %v", c.Tag, rule, err) + } + got := map[string]bool{} + for _, p := range st.Conflicts { + if strings.HasPrefix(p, filepath.Join(c.Home, ".cursor")+"/") { + got[p] = true + } + } + for p := range want { + if !got[p] { + t.Errorf("guide says %s conflicts on %s; the installer did not report it", p, c.Tag) + } + } + for p := range got { + if !want[p] { + t.Errorf("the installer reported %s as a conflict on %s, which the guide does not list", p, c.Tag) + } + } + if t.Failed() { + t.FailNow() + } + runBlock(t, c, st, body) + for p := range want { + if _, err := os.Lstat(p); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("cursor-takeover-remedy left the conflict path %s in place", p) + } + } +} + // mirrorGlobalConfigCleanup deletes the marked runtime.bash block from the global // config, after checking it is the block the guide shows. func mirrorGlobalConfigCleanup(t *testing.T, c *upgradeCase, st *runState, body string) { @@ -977,6 +1146,9 @@ const ( // repository without the block, which mirrorDispatchBlock proves. dispatchBlockSkip = "If there is no such block, skip this step." dispatchBlockSkipWhy = "There is nothing to commit, and `git commit` stops with an error." + // bashDispatchSignature is what marks a dispatch block as Bash docket's: its body + // runs docket.sh. docket's own block shares the marker lines but never this. + bashDispatchSignature = "/docket.sh " ) // dispatchBlockSpan validates the dispatch block's markers in lines and returns the @@ -1050,7 +1222,7 @@ func mirrorDispatchBlock(t *testing.T, c *upgradeCase, st *runState, body string // Guide: the upgrade leaves the block in place and `docket repository check` // does not report it. repo-confirm already saw `healthy` with the block present. mustContain(t, "guide", st.Guide, "`docket.sh`") - if !strings.Contains(strings.Join(lines[start:end+1], "\n"), "/docket.sh ") { + if !strings.Contains(strings.Join(lines[start:end+1], "\n"), bashDispatchSignature) { t.Errorf("the guide says the block tells Claude to run Bash docket's docket.sh; it does not") } out := append(append([]string{}, lines[:start]...), lines[end+1:]...) @@ -1077,6 +1249,73 @@ func mirrorDispatchBlock(t *testing.T, c *upgradeCase, st *runState, body string st.DispatchBlockRemoved = true } +// The guide's final section-5 step: choose the agents, then commit what the command +// lists. The Cursor rule it writes is ignored, so only the listed paths are committed. +const ( + chooseHarnessesLine = "docket repository configure-harnesses --harnesses claude,cursor" + cursorRepoRule = ".cursor/rules/docket-dispatch.mdc" + cursorRepoRuleProse = "For Cursor, it writes `.cursor/rules/docket-dispatch.mdc` into the repository, which the `.gitignore` block keeps out of commits." + claudeRepoBlockProse = "For Claude Code, it writes docket's own block into `CLAUDE.md`." +) + +// runChooseHarnesses runs the block and checks the guide's claims: the git add line +// names exactly the paths configure-harnesses lists, the commit reaches origin main and +// leaves nothing behind, .docket.yml there records both harnesses, CLAUDE.md there +// carries docket's dispatch block (not Bash's), and the repository Cursor rule exists +// and is ignored by git. +func runChooseHarnesses(t *testing.T, c *upgradeCase, st *runState, body string) { + t.Helper() + lines := blockLines(body) + if len(lines) != 4 || lines[0] != chooseHarnessesLine || !strings.HasPrefix(lines[1], "git add ") || + !strings.HasPrefix(lines[2], "git commit -m ") || lines[3] != "git push" { + t.Fatalf("choose-harnesses block must be `%s`, `git add `, `git commit -m …` and `git push`:\n%s", chooseHarnessesLine, body) + } + mustContainProse(t, "guide", st.Guide, cursorRepoRuleProse) + mustContainProse(t, "guide", st.Guide, claudeRepoBlockProse) + if st.Cwd != c.Clone { + t.Fatalf("choose-harnesses runs in the repository; the reader is in %s", st.Cwd) + } + if left := strings.TrimSpace(c.mustGit(t, st.Cwd, "status", "--porcelain")); left != "" { + t.Fatalf("choose-harnesses starts from a clean repository; git status shows:\n%s", left) + } + before := gitRev(t, c, "main") + r := runBlock(t, c, st, body) + pending := pendingPathsFrom(r.Stdout + r.Stderr) + added := strings.Fields(strings.TrimPrefix(lines[1], "git add ")) + if len(pending) == 0 || !sameSet(pending, added) { + t.Fatalf("the guide's git add line names %v; configure-harnesses asked to commit %v\nstdout:\n%s\nstderr:\n%s", added, pending, r.Stdout, r.Stderr) + } + if gitRev(t, c, "main") == before { + t.Fatalf("choose-harnesses did not push a new commit to main") + } + if left := strings.TrimSpace(c.mustGit(t, st.Cwd, "status", "--porcelain")); left != "" { + t.Fatalf("choose-harnesses left changes behind:\n%s", left) + } + if fi, err := os.Lstat(filepath.Join(c.Clone, filepath.FromSlash(cursorRepoRule))); err != nil || !fi.Mode().IsRegular() { + t.Fatalf("configure-harnesses did not write %s into the repository (%v)", cursorRepoRule, err) + } + if ig := c.run(t, c.Clone, "git", "check-ignore", "-q", "--", cursorRepoRule); ig.Code != 0 { + t.Errorf("guide says the .gitignore block keeps %s out of commits; git does not ignore it", cursorRepoRule) + } + var cfg struct { + AgentHarnesses []string `yaml:"agent_harnesses"` + } + if err := yaml.Unmarshal([]byte(c.mustGit(t, c.Origin, "show", "main:.docket.yml")), &cfg); err != nil { + t.Fatalf("parse main:.docket.yml: %v", err) + } + if !sameSet(cfg.AgentHarnesses, guideHarnesses) { + t.Errorf("origin main:.docket.yml records agent_harnesses %v; the guide chooses %v", cfg.AgentHarnesses, guideHarnesses) + } + claudeLines := strings.Split(c.mustGit(t, c.Origin, "show", "main:CLAUDE.md"), "\n") + start, end, err := dispatchBlockSpan(claudeLines) + switch { + case err != nil || start < 0: + t.Errorf("origin main:CLAUDE.md has no docket dispatch block after choose-harnesses (%v)", err) + case strings.Contains(strings.Join(claudeLines[start:end+1], "\n"), bashDispatchSignature): + t.Errorf("origin main:CLAUDE.md carries Bash docket's dispatch block after choose-harnesses, not docket's") + } +} + // ---- section 7: leftovers ---------------------------------------------------------- // mirrorLeftovers deletes each leftover the block lists, after proving it exists and @@ -1101,29 +1340,39 @@ func mirrorLeftovers(t *testing.T, c *upgradeCase, st *runState, body string) { t.Fatalf("leftovers line %q names a path the test does not mirror", line) } } - // Guide: nothing under ~/.claude points into the old checkout; the other tools' - // links under ~/.cursor, ~/.codex and ~/.agents still do. The guide states that + // Guide: nothing under ~/.claude or ~/.cursor points into the old checkout; the + // other tools' links under ~/.codex and ~/.agents still do. The guide states that // using docket from those tools on an upgraded repository is not supported, rather // than advising to keep using Bash docket there: the test observes only the links. mustContain(t, "guide", st.Guide, "`~/dev/docket`") - mustContain(t, "guide", st.Guide, "on an upgraded repository is not supported") + mustContainProse(t, "guide", st.Guide, "on an upgraded repository is not supported") checkout := filepath.Join(c.Home, "dev", "docket") - if n := linksInto(t, filepath.Join(c.Home, ".claude"), checkout); n != 0 { - t.Errorf("guide says nothing under ~/.claude points into the old checkout; %d links do", n) + for _, d := range []string{".claude", ".cursor"} { + mustContain(t, "guide", st.Guide, "`~/"+d+"`") + if n := linksInto(t, filepath.Join(c.Home, d), checkout); n != 0 { + t.Errorf("guide says nothing under ~/%s points into the old checkout; %d links do", d, n) + } } - for _, d := range []string{".cursor", ".codex", ".agents"} { + for _, d := range []string{".codex", ".agents"} { mustContain(t, "guide", st.Guide, "`~/"+d+"`") if linksInto(t, filepath.Join(c.Home, d), checkout) == 0 { t.Errorf("guide says links under ~/%s still point into the old checkout; none do", d) } } + // Guide: docket must run outside Cursor's sandbox, with a link to the Cursor page. + mustContain(t, "guide", st.Guide, cursorSandboxLink) + if _, err := os.Stat(filepath.Join(repoRoot(t), "docs", "install", "cursor.md")); err != nil { + t.Errorf("the guide links %s, which does not exist: %v", cursorSandboxLink, err) + } } // The per-repository leftovers the guide names. repoAgentGlob is the exact block line // the test requires; repoSettings is the file the guide says it does not cover. const ( - repoAgentGlob = "rm -f .claude/agents/docket-*.md" - repoSettings = ".claude/settings.local.json" + repoAgentGlob = "rm -f .claude/agents/docket-*.md" + repoSettings = ".claude/settings.local.json" + repoCursorAgentGlob = "rm -f .cursor/agents/docket-*.md" + repoCursorAgentUnproven = "so the test does not prove the `.cursor/agents` line" ) // runRepoAgentFiles runs the block that deletes the repository's Bash agent files. @@ -1135,14 +1384,19 @@ const ( func runRepoAgentFiles(t *testing.T, c *upgradeCase, st *runState, body string) { t.Helper() lines := blockLines(body) - if len(lines) != 2 || lines[0] != "cd "+c.Clone || lines[1] != repoAgentGlob { - t.Fatalf("repo-agent-files block must be exactly `cd ` and `%s`:\n%s", repoAgentGlob, body) + if len(lines) != 3 || lines[0] != "cd "+c.Clone || lines[1] != repoAgentGlob || lines[2] != repoCursorAgentGlob { + t.Fatalf("repo-agent-files block must be exactly `cd `, `%s` and `%s`:\n%s", repoAgentGlob, repoCursorAgentGlob, body) } - mustContain(t, "guide", st.Guide, "these old files hide the agents the installer just wrote") - mustContain(t, "guide", st.Guide, "`"+repoSettings+"`") - mustContain(t, "guide", st.Guide, "the test leaves it in place") + mustContainProse(t, "guide", st.Guide, "these old files hide the agents the installer just wrote") + mustContainProse(t, "guide", st.Guide, "`"+repoSettings+"`") + mustContainProse(t, "guide", st.Guide, "the test leaves it in place") + mustContainProse(t, "guide", st.Guide, repoCursorAgentUnproven) glob := filepath.Join(c.Clone, ".claude", "agents", "docket-*.md") + cursorGlob := filepath.Join(c.Clone, ".cursor", "agents", "docket-*.md") + if found, _ := filepath.Glob(cursorGlob); len(found) > 0 { + t.Errorf("the saved %s clone carries repository Cursor agent files %v; the guide says the test does not prove the .cursor/agents line, so prove it and drop that sentence", c.Tag, found) + } before, _ := filepath.Glob(glob) for _, p := range before { rel := strings.TrimPrefix(p, c.Clone+"/") @@ -1165,8 +1419,10 @@ func runRepoAgentFiles(t *testing.T, c *upgradeCase, st *runState, body string) runBlock(t, c, st, body) - if left, _ := filepath.Glob(glob); len(left) > 0 { - t.Errorf("repo-agent-files left %v behind", left) + for _, g := range []string{glob, cursorGlob} { + if left, _ := filepath.Glob(g); len(left) > 0 { + t.Errorf("repo-agent-files left %v behind", left) + } } if left := strings.TrimSpace(c.mustGit(t, c.Clone, "status", "--porcelain")); left != "" { t.Errorf("guide says there is nothing to commit after repo-agent-files; git status shows:\n%s", left) diff --git a/internal/bashupgrade/registry_unit_test.go b/internal/bashupgrade/registry_unit_test.go new file mode 100644 index 000000000..4d39d60b1 --- /dev/null +++ b/internal/bashupgrade/registry_unit_test.go @@ -0,0 +1,68 @@ +package bashupgrade + +import ( + "strings" + "testing" +) + +func TestInstallLineHarnesses(t *testing.T) { + for line, want := range map[string][]string{ + "sh install.sh --harness claude --harness cursor": {"claude", "cursor"}, + "sh install.sh --harness cursor": {"cursor"}, + "sh install.sh --harness claude": {"claude"}, + "sh install.sh --harness cursor --harness claude": {"cursor", "claude"}, + } { + got, ok := installLineHarnesses(line) + if !ok || strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("%q: got %v, %v; want %v", line, got, ok, want) + } + } + for _, line := range []string{ + "sh install.sh", + "sh install.sh --harness", + "sh install.sh --harness claude extra", + "sh install.sh --harness claude --harness claude", + "sh install.sh --harness vim", + "sh install.sh --harnesses claude", + "bash install.sh --harness claude", + "sh ./install.sh --harness claude", + } { + if got, ok := installLineHarnesses(line); ok { + t.Errorf("%q was accepted as an installer line (%v)", line, got) + } + } +} + +func TestContainsProse(t *testing.T) { + if !containsProse("one two\n three\tfour", "two three four") { + t.Error("a wrapped sentence did not match its one-line form") + } + if !containsProse("On `v0.9.2` it is not reported as a conflict; the block\n below deletes it either way.", "On `v0.9.2` it is not reported as a conflict; the block below deletes it either way.") { + t.Error("a wrapped guide sentence did not match") + } + if containsProse("one two three", "two four") { + t.Error("different words matched") + } + if containsProse("onetwo", "one two") { + t.Error("collapsing must not delete whitespace between words") + } +} + +func TestPendingPathsFrom(t *testing.T) { + out := "agent harnesses set to `[claude, cursor]` (needs-review); review and commit the pending paths: CLAUDE.md, .docket.yml\nnext line\n" + if got := pendingPathsFrom(out); strings.Join(got, ",") != ".docket.yml,CLAUDE.md" { + t.Fatalf("got %v", got) + } + if got := pendingPathsFrom("agent harnesses set to `[claude]` (healthy)\n"); got != nil { + t.Fatalf("no lead must give nil, got %v", got) + } +} + +func TestSameSet(t *testing.T) { + if !sameSet([]string{"b", "a"}, []string{"a", "b"}) { + t.Error("order mattered") + } + if sameSet([]string{"a"}, []string{"a", "b"}) || sameSet([]string{"a", "a"}, []string{"a", "b"}) { + t.Error("different sets compared equal") + } +}