diff --git a/.abcd/work/issues/open/iss-207-the-one-instruction-that-resolves-the-no-binary-on-path-stat.md b/.abcd/work/issues/resolved/iss-207-the-one-instruction-that-resolves-the-no-binary-on-path-stat.md similarity index 84% rename from .abcd/work/issues/open/iss-207-the-one-instruction-that-resolves-the-no-binary-on-path-stat.md rename to .abcd/work/issues/resolved/iss-207-the-one-instruction-that-resolves-the-no-binary-on-path-stat.md index 1ac95bb..e3d3e90 100644 --- a/.abcd/work/issues/open/iss-207-the-one-instruction-that-resolves-the-no-binary-on-path-stat.md +++ b/.abcd/work/issues/resolved/iss-207-the-one-instruction-that-resolves-the-no-binary-on-path-stat.md @@ -7,6 +7,8 @@ category: "documentation" source: "user-observation" found_during: "first manual plugin install test (2026-08-10)" found_at: "hooks/bootstrap.sh" +resolution: "The bootstrap notice and the README now name the plugin-root binary by path, so the one-time PATH setup instruction runs in the no-binary-on-PATH state it addresses; a bare abcd ahoy install on either surface is held by test." +impact: fix --- The one instruction that resolves the no-binary-on-PATH state cannot be run in that state. hooks/bootstrap.sh's success notice ends 'For the abcd command in your own terminal, run `abcd ahoy install` once.' and README.md repeats it: 'run `abcd ahoy install` once to put the plugin-root binary on your PATH.' But abcd is not on PATH — that is exactly the condition the sentence addresses — so the command as written fails with 'command not found'. Observed consequence on the first manual install (2026-08-10): the agent, unable to run the printed command, invented 'cd ~/.claude/plugins/marketplaces/abcd-marketplace && go run ./cmd/abcd ahoy install' and told the user to run that instead — a source-build instruction reaching into the harness's plugin cache, which is not the documented install path and requires a Go toolchain. Fix direction: print the absolute plugin-root path in the notice (the script already holds it as $binary) and give README the same concrete form, so the sentence is copy-pasteable in the state it describes. Related to iss-205, which is the same root confusion on the command surface. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 32bfcd1..6191dbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,27 @@ called out in a **Breaking** section. ### Fixed +- **The one instruction that resolves the no-binary-on-`PATH` state can now be run + in that state** (iss-207). The bootstrap's success notice and the README both + said to run `abcd ahoy install` once — a command whose whole premise is that + `abcd` is not a name the shell can resolve, so it failed with "command not + found" for precisely the reader it was written for. On the first manual install + the consequence was not cosmetic: the agent reading the notice could not run the + printed command, invented a `go run` incantation reaching into the harness's + plugin cache, and told the user to run that instead — a source-build path + needing a Go toolchain, and not the documented install at all. The notice now + prints the absolute plugin-root path the script already holds, shell-quoted so + a plugin root containing a space, an apostrophe, a `$` or a backtick still + pastes as one word, and with the invocation last on the line so it stays + copy-pasteable to the end. The README carries the same form with the one part a + committed file cannot know left as a placeholder, says what that placeholder is + in host-agnostic terms, and points a reader who cannot instantiate it at the + install one-liner, which needs no plugin root. CI holds both surfaces — every + `ahoy install` either one prints must be reached through a path, not a bare + name, and the printed command is handed to a real shell against a hostile path + to prove it runs as pasted — while the end-to-end reading of it on a real + plugin cache remains the manual install gate. + - **The session-start hooks run after the bootstrap that provisions their binary, and a successful install reads as success** (iss-204, iss-208). The hook manifest listed the bootstrap and the two binary-backed commands as three diff --git a/README.md b/README.md index a6df01a..edf361e 100644 --- a/README.md +++ b/README.md @@ -233,14 +233,25 @@ and arm64) installs nothing and says why in plain language. A plugin root that already holds the binary costs one file test and no network. That covers the hooks. For the `abcd` command in your own terminal, keep the -[install](#install) above, or run `abcd ahoy install` once to put the -plugin-root binary on your `PATH`. For a stronger root of trust than -same-origin checksums, build from source — `go build ./cmd/abcd` — and place -the binary in the plugin root and on your `PATH` yourself. A binary placed -there by hand takes the same no-network fast path, so the `.binary-meta` -provenance record beside it still describes whichever release the bootstrap -last fetched: delete that file so no version-skew notice is rendered from a -release the binary in place did not come from. +[install](#install) above, or put the plugin-root binary on your `PATH` by +running it once by its absolute path — `'/abcd' ahoy install`. +The path is absolute because `abcd` is not on your `PATH` yet, which is what +that one run fixes. `` is the directory the agent harness unpacked +the abcd plugin into, with the binary sitting directly inside it as `abcd`; the +bootstrap's success notice prints that full binary path, so the shortest route +is to copy the command straight out of the notice. That notice appears once per +plugin root — later sessions take the fast path and stay silent — so if it has +scrolled away and you would rather not go looking for the directory, the +[install](#install) one-liner above needs no plugin root at all and gets you to +the same place. + +For a stronger root of trust than same-origin checksums, build from source — +`go build ./cmd/abcd` — and place the binary in the plugin root and on your +`PATH` yourself. A binary placed there by hand takes the same no-network fast +path, so the `.binary-meta` provenance record beside it still describes +whichever release the bootstrap last fetched: delete that file so no +version-skew notice is rendered from a release the binary in place did not come +from. ## Build diff --git a/hooks/bootstrap.sh b/hooks/bootstrap.sh index 5c9481a..44f4760 100755 --- a/hooks/bootstrap.sh +++ b/hooks/bootstrap.sh @@ -271,5 +271,25 @@ fi # The one place PATH setup is suggested; the symlink itself stays owned by ahoy. # This prints once per plugin root, because every later session takes the fast # path above. -notice "$(printf 'abcd bootstrap: installed the checksum-verified abcd binary (release %s) into the plugin root, so the abcd hooks are live for this session. For the abcd command in your own terminal, run `abcd ahoy install` once.%s' \ - "$release_tag" "$meta_note")" +# +# The instruction names the binary by the ABSOLUTE path this script already +# holds, and does so for a reason worth stating: the sentence is addressed to a +# reader for whom `abcd` is not a name the shell can resolve — that is the whole +# condition it exists to fix — so an instruction reading `abcd ahoy install` +# fails with "command not found" for everyone who needs it. On the first manual +# install the agent that read it invented a `go run` incantation into the +# harness's plugin cache instead (iss-207). $binary is resolvable right now, by +# anyone, with no toolchain. Keep the invocation LAST on the line so it stays +# copy-pasteable, and keep the success leading the first word: only the first +# line of a hook's stderr reaches the transcript. +# +# The path is wrapped in SINGLE quotes, the same form internal/core/ahoy's +# shSingleQuote produces, because this string is printed to be pasted into a +# shell and the plugin root is not a path this script chose. Double quotes carry +# a space or an apostrophe safely and then leave $, a backtick and a " live: a +# path holding one of those would expand, substitute, or terminate the string on +# paste. The sed rewrites each embedded ' as '\'' — close, escape, reopen — which +# is the one form that survives every byte a path can contain. +binary_quoted="'$(printf '%s' "$binary" | sed "s/'/'\\\\''/g")'" +notice "$(printf 'abcd bootstrap: installed the checksum-verified abcd binary (release %s) into the plugin root, so the abcd hooks are live for this session.%s For the abcd command in your own terminal, run this once — the path is absolute because abcd is not on your PATH yet, which is exactly what the command fixes: %s ahoy install' \ + "$release_tag" "$meta_note" "$binary_quoted")" diff --git a/internal/surface/cli/bootstrap_test.go b/internal/surface/cli/bootstrap_test.go index e6c64c9..11b7128 100644 --- a/internal/surface/cli/bootstrap_test.go +++ b/internal/surface/cli/bootstrap_test.go @@ -411,6 +411,159 @@ func TestBootstrapInstallsVerifiedBinary(t *testing.T) { } } +// bootstrapRepoFile locates a committed file at the repository root from this +// test file's own on-disk position, the same way bootstrapScript does, so the +// assertions below read the bytes that actually ship. +func bootstrapRepoFile(t *testing.T, rel string) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed to locate the test source file") + } + path := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "..", rel)) + if _, err := os.Stat(path); err != nil { + t.Fatalf("the committed %s must exist: %v", rel, err) + } + return path +} + +// bootstrapReadmeInstruction is the shape the README's one-time PATH setup +// instruction has to take. The README cannot know the absolute plugin root at +// authoring time, so it carries the same INVOCATION with the one part it cannot +// know left as a placeholder the notice fills in — not a bare `abcd`, which is +// the name that does not resolve, and not `${CLAUDE_PLUGIN_ROOT}` either, which +// is set for a hook and unset in the terminal the sentence is addressed to. +// Single quotes, matching the bytes the notice prints, so the two surfaces do +// not show the reader two different shapes of the same command. +const bootstrapReadmeInstruction = `'/abcd' ahoy install` + +// bootstrapShSingleQuote mirrors internal/core/ahoy's shSingleQuote: the POSIX +// single-quote wrapping that survives every character a path can hold. It is +// restated here rather than imported (it is unexported, in another package) so +// this test states the bytes it EXPECTS independently of the code that produces +// them — a shared helper would agree with the script by construction and prove +// nothing about it. +func bootstrapShSingleQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// bootstrapEveryInvocationIsPathQualified reports each occurrence of `ahoy +// install` in body that is NOT reached through prefix. Splitting on the verb +// rather than searching for the good form is deliberate: a text may hold the +// runnable instruction AND still leave the unrunnable one standing beside it, +// and only an every-occurrence check sees the second one. +func bootstrapEveryInvocationIsPathQualified(body, prefix string) []string { + parts := strings.Split(body, "ahoy install") + var bad []string + for _, before := range parts[:len(parts)-1] { + if strings.HasSuffix(before, prefix) { + continue + } + if len(before) > 80 { + before = "…" + before[len(before)-80:] + } + bad = append(bad, before+"ahoy install") + } + return bad +} + +// TestBootstrapPrintsARunnableInstruction is iss-207: the ONE instruction that +// resolves the no-binary-on-PATH state could not be run in that state. The +// notice said "run `abcd ahoy install` once" while `abcd` is, by the notice's +// own premise, not a name the shell can resolve — so the sentence fails with +// "command not found" for exactly the reader it is written for. On the first +// manual install this was not a cosmetic failure: the agent reading it could not +// run the printed command, invented a `go run` incantation reaching into the +// harness's plugin cache, and told the user to run that instead — a +// source-build path needing a Go toolchain, which is not the documented install +// at all. +// +// The script already holds the absolute path as $binary, so the fix is to print +// it. Both surfaces are held here: the notice, whose path is concrete, and the +// README, whose corresponding sentence carries the same invocation with a +// placeholder for the one part a committed file cannot know. +func TestBootstrapPrintsARunnableInstruction(t *testing.T) { + t.Run("the notice names the absolute binary", func(t *testing.T) { + root := bootstrapRoot(t) + body := []byte("payload") + fx := bootstrapServer(t, body, bootstrapManifest(body)) + + out, code := runBootstrap(t, root, fx, "") + if code != 2 { + t.Fatalf("a verified download must exit 2 (a notice the user is shown), got %d (output %q)", code, out) + } + binary := filepath.Join(root, "abcd") + prefix := bootstrapShSingleQuote(binary) + " " + if !strings.Contains(out, prefix+"ahoy install") { + t.Errorf("the notice must print the absolute plugin-root binary the script already holds, so the instruction runs in the state it describes; want %q in output %q", prefix+"ahoy install", out) + } + for _, bad := range bootstrapEveryInvocationIsPathQualified(out, prefix) { + t.Errorf("the notice still prints an `ahoy install` a reader cannot run — `abcd` is not on PATH, which is the very state this sentence addresses: %q", bad) + } + // A2's contract, held here because this test rewrites the same sentence: + // the transcript renders only the first line of a hook's stderr, so the + // success has to lead it. + if got := firstLine(out); !strings.HasPrefix(got, "abcd bootstrap: installed") { + t.Errorf("the success must still lead the first visible line; first line = %q", got) + } + }) + + t.Run("the README carries the same concrete form", func(t *testing.T) { + body := mustReadFile(t, bootstrapRepoFile(t, "README.md")) + if !strings.Contains(body, bootstrapReadmeInstruction) { + t.Errorf("README.md must give the one-time PATH setup as %q; a bare `abcd ahoy install` cannot be run by the reader it is written for", bootstrapReadmeInstruction) + } + prefix := `'/abcd' ` + for _, bad := range bootstrapEveryInvocationIsPathQualified(body, prefix) { + t.Errorf("README.md still instructs an `ahoy install` that cannot be run before `abcd` is on PATH: %q", bad) + } + // The placeholder has to be instantiable from THIS file. The notice that + // prints the path in full prints once per plugin root (every later session + // takes the bootstrap's fast path), so a reader who meets this sentence on + // day five has no notice on screen — and the plugin root's location cannot + // be named here, because abcd's published prose stays host-agnostic and + // docs-lint blocks naming a specific harness. So the README owes two + // things: what the placeholder IS in terms the reader can act on, and a + // route that needs no plugin root at all. + if !strings.Contains(body, "directory the agent harness unpacked") { + t.Error("README.md must say what is in host-agnostic terms; a placeholder defined only as \"what the notice printed\" cannot be instantiated once the notice has scrolled away") + } + if !strings.Contains(body, "needs no plugin root") { + t.Error("README.md must point the reader who cannot instantiate at the install route that does not need one") + } + }) + + // The printed command is not merely path-qualified, it is QUOTED for a shell, + // and the double-quoted form this started as survives a space or an apostrophe + // while `$`, a backtick or a `"` inside the path still expand or terminate the + // string. The repo already owns the robust form (internal/core/ahoy's + // shSingleQuote), so the notice uses it. The proof is not a string comparison: + // the printed command is handed to a real `sh`, which either runs the binary + // at that path or does not. + t.Run("the printed command runs when pasted, whatever the path holds", func(t *testing.T) { + // Every metacharacter that defeats double quoting, plus the apostrophe + // that defeats naive single quoting, in one directory name. + root := bootstrapRootNamed(t, `it's $HOME "and" `+"`backticks`") + body := []byte("#!/bin/sh\nexit 0\n") + fx := bootstrapServer(t, body, bootstrapManifest(body)) + + out, code := runBootstrap(t, root, fx, "") + if code != 2 { + t.Fatalf("a verified download must exit 2 (a notice the user is shown), got %d (output %q)", code, out) + } + command := bootstrapShSingleQuote(filepath.Join(root, "abcd")) + " ahoy install" + if !strings.Contains(out, " "+command) { + t.Fatalf("the notice must print the shell-quoted command as its own word; want %q in output %q", command, out) + } + // The installed fixture binary is `exit 0`, so a shell that resolves the + // path runs it and exits 0. A path that broke out of its quoting instead + // yields "command not found", a syntax error, or an unrelated exit. + if err := exec.Command("sh", "-c", command).Run(); err != nil { + t.Errorf("the command the notice printed does not run when pasted into a shell: %v (command %q)", err, command) + } + }) +} + // TestBootstrapRecordsTheRawPluginRootBasename is the observability half of the // commit-stamped-cache WARRANT. plugin_sha is gated to exactly forty lowercase // hex characters because itd-105 assumes the harness names each plugin cache