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
2 changes: 1 addition & 1 deletion .abcd/development/brief/02-constraints/04-naming.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Commands and abcd-owned directories use ship/voyage metaphors where natural.
|---|---|
| `/abcd:ahoy` | bare invocation — status + help: shows folder kind, install state, detected gaps, last install date. ZERO writes. |
| `/abcd:ahoy install` | mutating sub-verb — applies detected gaps (skeleton, config-change, history-store, marker-block, PATH symlink, version stamp). Centralised per-category approval. |
| `/abcd:ahoy uninstall` | reversible removal — strips the marker block from `CLAUDE.md` / `AGENTS.md` and the `/usr/local/bin/abcd` symlink if owned. Preserves `.abcd/`, `~/.abcd/`, and `hooks/hooks.json`. |
| `/abcd:ahoy uninstall` | reversible removal — strips the marker block from `CLAUDE.md` / `AGENTS.md` and abcd's own `PATH` entry (`~/.local/bin/abcd` by default) if owned. Preserves `.abcd/`, `~/.abcd/`, and `hooks/hooks.json`. |
| `/abcd:ahoy dry-run` | read-only emit of the `DetectionResult` envelope as JSON. ZERO writes. Drives the Claude Code skill's two-pass approval protocol. |
| `/abcd:ahoy doctor` | read-only audit — detection envelope + cross-machine `history_audit.audit_repos()` gaps. ZERO writes. |
| `/abcd:disembark` | leave the ship → pack a lifeboat for the journey |
Expand Down
34 changes: 25 additions & 9 deletions .abcd/development/brief/04-surfaces/01-ahoy.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ the CLI for anything that writes; the sub-verbs below ship on the CLI as
(idempotent; covers first-install and upgrade). Runs the detection pass, then
the apply pass over the resulting gaps.
- **`/abcd:ahoy uninstall`** — reversible marker-only removal: removes the
marker block and the `/usr/local/bin/abcd` symlink (if owned by this plugin).
marker block and abcd's own `PATH` entry (if owned by this plugin).
NEVER mutates `hooks/hooks.json` (plugin-static per spc-14 T7 + spc-16 T1).
Leaves `.abcd/` intact. Re-running `install` re-installs cleanly.
- **`/abcd:ahoy dry-run`** — run the detection pass and render the
Expand Down Expand Up @@ -158,8 +158,18 @@ Steps, run in parallel where independent:
against the current template; classify `current` / `outdated` / `missing`.
The marker block stands alone — there is no parent-`CLAUDE.md` reference to
verify.
8. **PATH symlink** — does `/usr/local/bin/abcd` exist, and does it point at
this plugin, a different binary, or nothing?
8. **PATH entry** — scan `PATH` for `abcd`, resolving symlinks, and classify each
hit as this plugin's own entry, our dev shim, or a foreign binary. An
abcd-owned entry anywhere on `PATH` is the install. With none, the default
location `~/.local/bin/abcd` answers the same question — present, pointing at
this plugin, at a different binary, or at nothing. An owned entry whose target
has gone is `symlink.dangling`; an install directory absent from `PATH` is
`path.bin_dir_not_on_path`, required but not resolvable — abcd prints the
one-line `export` fix and never edits a shell profile; and any `abcd` that
comes BEFORE abcd's own entry on `PATH` is `symlink.shadowed`, because an
entry that is correct and never reached is not an install (iss-171). Install
carries the two non-resolvable ones on its own result as notes: a fresh user
cannot run `doctor` by name on a machine where abcd is not yet on `PATH`.
9. **Hook manifest verification** (verify-only per spc-16 T1) — VERIFY that
`hooks/hooks.json` is present in the plugin install AND contains the three
required event entries (`UserPromptSubmit`, `SessionStart`, `PreCompact`)
Expand Down Expand Up @@ -325,11 +335,16 @@ closes stdin and pre-answers: `abcd ahoy install --yes --refuse-adopt
users edit outside the markers.** Content comes from
`internal/core/ahoy/defaults/claude-md-marker-block.md`. Write the minimal
`.abcd/rules.json` skeleton if missing.
9. **PATH symlink** (`config-change`) — transparent prompt: "Install `abcd`
symlink to `/usr/local/bin/abcd`? Default: yes for private repos, no for
public." If accepted AND the target is absent or already points at this
plugin → write it. If a different `abcd` binary exists → refuse, show what
it points to, suggest manual resolution.
9. **PATH entry** (`config-change`) — transparent prompt: "Install `abcd`
symlink to `~/.local/bin/abcd`? Default: yes for private repos, no for
public." An abcd-owned entry already on `PATH` is adopted where it stands
rather than duplicated; `--bin-dir <dir>` names a different directory (the
only route to a system-wide one) and fails loudly when it is not writable.
abcd NEVER escalates privileges. If accepted AND the target is absent or
already points at this plugin → write it, provided the binary it would point
at exists; a link to a missing target is refused, because a dangling `abcd`
early on `PATH` shadows every working one behind it. If a different `abcd`
binary exists → refuse, show what it points to, suggest manual resolution.
10. **Hook registration** (`plugin-owned`, VERIFY-ONLY per spc-16 T1) — install
verifies that `hooks/hooks.json` is present (the manifest is plugin-static
per spc-14 T7). Install NEVER writes `hooks.json`; uninstall NEVER mutates
Expand Down Expand Up @@ -370,7 +385,8 @@ notes the orphaned-predecessor possibility in the summary.
## Sub-verb semantics

**Uninstall (`/abcd:ahoy uninstall`):** removes the BEGIN/END marker block from
CLAUDE.md/AGENTS.md and the `/usr/local/bin/abcd` symlink **if it points at this
CLAUDE.md/AGENTS.md and abcd's own `PATH` entry (`~/.local/bin/abcd`, or wherever
on `PATH` it sits) **if it points at this
plugin** (otherwise leave it alone). `hooks/hooks.json` is plugin-static per
spc-14 T7 — uninstall NEVER mutates it (per spc-16 T1 brief amendment).
**Leaves the entire `.abcd/` namespace intact** (`config/`, `config.json`,
Expand Down
17 changes: 16 additions & 1 deletion .abcd/development/release/surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@
"required": false,
"hidden": false
},
{
"name": "bin-dir",
"shorthand": "",
"type": "string",
"required": false,
"hidden": false
},
{
"name": "dev",
"shorthand": "",
Expand Down Expand Up @@ -99,7 +106,15 @@
{
"path": "abcd ahoy uninstall",
"hidden": false,
"flags": []
"flags": [
{
"name": "bin-dir",
"shorthand": "",
"type": "string",
"required": false,
"hidden": false
}
]
},
{
"path": "abcd audit",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ category: "observation"
source: "user-observation"
found_during: "second-repo install session"
found_at: "internal/core/ahoy/store.go"
resolution: "Detection scans PATH and adopts an abcd-owned entry wherever it sits; install defaults to ~/.local/bin (created when absent), adopts an existing owned install in place, reaches a system-wide dir only via an explicit --bin-dir that fails loudly when unwritable, never escalates privileges, and refuses a symlink whose target does not exist. An install dir absent from PATH is its own required gap carrying the one-line export fix; abcd never patches a shell profile. The README one-liner drops sudo."
impact: breaking
---

The PATH-install detector and installer assume one blessed layout: binTarget defaults to /usr/local/bin/abcd (sudo territory abcd cannot assume) and symlink detection only recognises that exact target, so a working ~/.local/bin/abcd symlink install — the field-standard single-user location used by uv/pipx/rustup-class tools — reports symlink.missing, a false gap; the detector is itself running as abcd from PATH while reporting abcd not installed. Letting ahoy install 'fix' it would write a symlink to <plugin-root>/abcd without validating the target exists — a dangling symlink shadowing the working install. Redesign per fix-the-detector and no-sudo: detection scans PATH for abcd, resolves symlinks (EvalSymlinks, same seam as iss-170), classifies dev-shim/owned/foreign; install defaults to ~/.local/bin (create if needed), adopts an existing owned install in place, does system-wide dirs only behind an explicit --bin-dir flag failing loudly when unwritable, never escalates privileges, refuses to create a symlink whose target does not exist; '~/.local/bin not on PATH' becomes its own loud gap with the printed one-line fix (script-first: print the export line, do not auto-patch shell profiles as the first rung).
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,36 @@ called out in a **Breaking** section.

## [Unreleased]

### Breaking

- **The documented install location is `~/.local/bin`, and nothing abcd runs asks
for administrator rights** (iss-171). The README one-liner drops `sudo` and
copies the verified binary into `~/.local/bin`; `abcd ahoy install` writes its
`PATH` entry there too, creating the directory when absent. **What existing
users see depends on how their binary got there.** An abcd-owned symlink — the
thing `ahoy install` writes — is found anywhere on `PATH` and adopted in place,
so nothing changes. A binary the old one-liner *copied* into `/usr/local/bin`
is a plain file abcd does not own: it is never adopted, and a new entry in
`~/.local/bin` lands behind it on `PATH`, so the copy is still what runs. That
case now reports a `symlink.shadowed` gap naming the copy, both from `ahoy`
and on the install run itself; the remedy is to delete the stale copy
(`rm /usr/local/bin/abcd`, which needs the rights that put it there) or to
install ahead of it with `--bin-dir`. abcd will not remove it — it never
touches a binary it does not own. A system-wide directory is reachable only
through an explicit `--bin-dir`, which fails loudly when it is not writable
rather than re-running itself with privilege: abcd escalates nothing, so there
is no fallback to hide the refusal behind. Two more gaps arrive with it:
`~/.local/bin` not on `PATH` is its own named gap carrying the one-line
`export PATH="$HOME/.local/bin:$PATH"` fix (abcd prints it and never edits a
shell profile), and an abcd-owned `PATH` entry whose binary has gone is
reported as dangling rather than silently trusted. Install refuses to create a
link whose target does not exist, because a dangling `abcd` early on `PATH`
shadows every working one behind it, and it reports every such refusal on the
result rather than leaving a gap to speak for it. The old detector recognised
exactly one blessed target, so a working `~/.local/bin/abcd` reported
`symlink.missing` while the detector was itself running as that very binary,
and "fixing" it would have written the shadowing link.

### Added

- **The attribution gate reads the git identity, not only the message.** A commit
Expand Down
38 changes: 28 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,22 +177,40 @@ That "someone" is your technical facilitator, who triages those captures later:

## Install

One line, checksum-verified. It detects your OS/architecture, downloads the
binary and the `checksums.txt` manifest from the latest release, verifies the
binary's SHA-256 against the manifest (and refuses to install on any
mismatch — or if the manifest doesn't list the binary at all), then installs
to `/usr/local/bin`:
One line, checksum-verified, no administrator rights. It detects your
OS/architecture, downloads the binary and the `checksums.txt` manifest from the
latest release, verifies the binary's SHA-256 against the manifest (and refuses
to install on any mismatch — or if the manifest doesn't list the binary at all),
then installs to `~/.local/bin`, the single-user location:

```sh
sh -c 'set -eu; cd "$(mktemp -d)"; os=$(uname -s | tr "[:upper:]" "[:lower:]"); arch=$(uname -m); case "$arch" in x86_64) arch=amd64;; aarch64) arch=arm64;; esac; b="abcd-$os-$arch"; curl -fsSLO "https://github.com/REPPL/abcd-cli/releases/latest/download/$b"; curl -fsSLO "https://github.com/REPPL/abcd-cli/releases/latest/download/checksums.txt"; grep " $b$" checksums.txt | if command -v sha256sum >/dev/null; then sha256sum -c -; else shasum -a 256 -c -; fi; sudo install -m 0755 "$b" /usr/local/bin/abcd; abcd version'
sh -c 'set -eu; cd "$(mktemp -d)"; os=$(uname -s | tr "[:upper:]" "[:lower:]"); arch=$(uname -m); case "$arch" in x86_64) arch=amd64;; aarch64) arch=arm64;; esac; b="abcd-$os-$arch"; curl -fsSLO "https://github.com/REPPL/abcd-cli/releases/latest/download/$b"; curl -fsSLO "https://github.com/REPPL/abcd-cli/releases/latest/download/checksums.txt"; grep " $b$" checksums.txt | if command -v sha256sum >/dev/null; then sha256sum -c -; else shasum -a 256 -c -; fi; mkdir -p "$HOME/.local/bin"; install -m 0755 "$b" "$HOME/.local/bin/abcd"; "$HOME/.local/bin/abcd" version'
```

If `abcd` isn't found by name afterwards, `~/.local/bin` isn't on your `PATH`.
Add this line to your shell profile:

```sh
export PATH="$HOME/.local/bin:$PATH"
```

`abcd ahoy` reports the same thing as a named gap with the same one-line fix,
and the installer writes its own `PATH` entry to `~/.local/bin` unless you
point it elsewhere with `--bin-dir`. abcd never escalates privileges: a
directory it can't write to is an error, not a prompt for your password.

Already have an `abcd` in a system directory from an earlier install? Delete it
(`rm /usr/local/bin/abcd`, with whatever rights put it there) — otherwise it
comes first on `PATH` and keeps answering instead of the new one. `abcd ahoy`
names it in a gap rather than removing it: abcd does not touch a binary it does
not own.

Prefer to inspect before running? The command is exactly what it says: two
downloads from [the latest release](https://github.com/REPPL/abcd-cli/releases/latest),
a checksum verification, and a `sudo install`. You can do the same by hand —
grab the binary for your platform plus `checksums.txt` from the releases
page, run `shasum -a 256 -c` (or `sha256sum -c`) against the matching line,
and copy the binary anywhere on your `PATH`. Every release is built and
a checksum verification, and a copy into a directory you own. You can do the
same by hand — grab the binary for your platform plus `checksums.txt` from the
releases page, run `shasum -a 256 -c` (or `sha256sum -c`) against the matching
line, and copy the binary anywhere on your `PATH`. Every release is built and
published by CI from the exact tagged commit, with the checksums generated
over the same bytes that are uploaded.

Expand Down
33 changes: 28 additions & 5 deletions commands/ahoy.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,28 @@ them. If `folder_kind` is `unmanaged-folder`, note there is nothing to act on
```

**This writes.** It applies the actionable gaps the detection pass found — the
marker block, the `.abcd/` scaffolding, the owned `PATH` symlink. Report the
returned `status` and what changed; the engine prompts before an ambiguous
adoption, so surface any prompt to the user rather than answering it for them.
marker block, the `.abcd/` scaffolding, the owned `PATH` entry. Report the
returned `status`, what changed, and any `notes` — a note is a refusal, stating
something abcd deliberately did not do and why. The engine prompts before an
ambiguous adoption, so surface any prompt to the user rather than answering it
for them.

The `PATH` entry goes to `~/.local/bin` (created when absent), or to an
abcd-owned entry already on `PATH`, which is adopted exactly where it stands.
`--bin-dir <dir>` names a different directory — the only way to reach a
system-wide one — and fails loudly when it is not writable. abcd never escalates
privileges, so never suggest re-running any of this under `sudo`; report the
failure and let the user pick a directory they own. If the report carries a
`path.bin_dir_not_on_path` gap, relay its one-line `export` fix verbatim and
leave the user's shell profile alone.

A `symlink.shadowed` gap (or a note saying the same) means another `abcd` comes
first on `PATH`, so the entry abcd just wrote is NOT what runs — typically a
binary an older install copied into a system directory. Relay it prominently:
the install is not finished from the user's point of view. abcd will not remove
that binary, and neither should you offer to; state the two remedies it gives
(delete the stale one, or install ahead of it with `--bin-dir`) and let the user
choose.

Prompts read stdin whether or not stdin is a terminal, so an answer can be
relayed without one:
Expand Down Expand Up @@ -104,8 +123,12 @@ the source tip on every call and fails loudly on a broken build. Re-running
```

**This writes.** It removes the BEGIN/END marker block and abcd's own `PATH`
symlink and leaves `.abcd/` intact, so the repo's record survives. Report
`marker.removed` and the symlink note. It never touches `hooks.json`.
entry — found wherever it sits on `PATH` — and leaves `.abcd/` intact, so the
repo's record survives. Report `marker.removed` and the symlink note; the
receipt's `symlink.target` is already rendered in tilde form, so relay it as
given rather than expanding it. It never touches `hooks.json`. An entry that was
installed with `--bin-dir` into a directory outside `PATH` cannot be found by a
`PATH` scan — pass the same `--bin-dir <dir>` to `uninstall` to remove it.

## `doctor` — the full read-only report

Expand Down
9 changes: 8 additions & 1 deletion docs/reference/cli/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ Install or update abcd in this repo (idempotent)

```
--adopt adopt an unmanaged repo without prompting
--bin-dir string directory for the PATH entry (default ~/.local/bin, or an existing abcd install adopted in place); fails when it is not writable — abcd never escalates privileges
--dev track-latest dogfood mode: the PATH entry rebuilds from the source tip on every call instead of pinning the built binary
--docs-target string marker target: claude_md | agents_md | both | skip
--oracle-backend string oracle backend: host-delegated | native | cli | api | mcp
Expand All @@ -68,7 +69,13 @@ Install or update abcd in this repo (idempotent)

Remove the marker block and owned PATH symlink (leaves .abcd/ intact)

**Usage:** `abcd ahoy uninstall`
**Usage:** `abcd ahoy uninstall [flags]`

**Flags:**

```
--bin-dir string directory holding the PATH entry to remove; needed only when it was installed with --bin-dir into a directory that is not on PATH
```

### `abcd audit`

Expand Down
10 changes: 10 additions & 0 deletions internal/core/ahoy/ahoy.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ type InstallOptions struct {
// binary (failing loudly on a broken build), instead of a symlink to the
// pinned built binary. --dev.
Dev bool
// BinDir is an explicit directory for the PATH entry (--bin-dir), the only
// way to reach a system-wide location. Empty means the default single-user
// location (~/.local/bin), or an existing owned install adopted in place.
// abcd NEVER escalates privileges: a BinDir it cannot write to is a loud
// error, never a silent skip and never a re-run under sudo.
BinDir string
}

// InstallResult is the outcome of Install.
Expand All @@ -117,6 +123,10 @@ type InstallResult struct {
Changes []string `json:"changes,omitempty"` // value overwrites an explicit override forced ("visibility: private -> public")
Remaining []string `json:"remaining"` // required+resolvable gap ids left
DeclinedCategories []string `json:"declined_categories"` // sorted category wire values
// Notes carries an apply step's loud refusal — a thing abcd deliberately did
// not do, and why. A refusal that only shows up as a still-open gap reads as a
// silent failure, so the reason travels with the result.
Notes []string `json:"notes,omitempty"`
// OptionalSkipped names the optional gaps a --yes run deliberately did not
// apply — today the advisory git-identity pin alone. --yes approves every
// resolvable CATEGORY, but it never writes the pin, because the pin captures
Expand Down
Loading