Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 19 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 — `'<plugin-root>/abcd' ahoy install`.
The path is absolute because `abcd` is not on your `PATH` yet, which is what
that one run fixes. `<plugin-root>` 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

Expand Down
24 changes: 22 additions & 2 deletions hooks/bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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")"
153 changes: 153 additions & 0 deletions internal/surface/cli/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `'<plugin-root>/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 := `'<plugin-root>/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 <plugin-root> 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 <plugin-root> 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
Expand Down