diff --git a/.delphi/manifest.yml b/.delphi/manifest.yml index adaebe1..12a3b4b 100644 --- a/.delphi/manifest.yml +++ b/.delphi/manifest.yml @@ -6,7 +6,7 @@ # MINOR something new that existing clones need nothing for # MAJOR clones need cleanup (MINOR while 0.x): add .delphi/migrations/-.sh, which # setup.sh runs once per workspace in each clone (rules in Delphi's CLAUDE.md) -version: 0.5.1 +version: 0.6.0 files: # repo-relative paths; a trailing / means everything under it - .delphi/manifest.yml - .delphi/.gitattributes diff --git a/.delphi/plugins.sh b/.delphi/plugins.sh index 2ad077e..7449048 100755 --- a/.delphi/plugins.sh +++ b/.delphi/plugins.sh @@ -1,21 +1,78 @@ #!/usr/bin/env bash -# .delphi/plugins.sh: setup.sh runs it to warn about the plugins this workspace's generated harness -# settings enable but this machine hasn't installed, so their skills are missing (CLAUDE.md says how to -# install them). Prints nothing when all are installed, or when it can't tell. Only `harness: -# claude-code` so far: installed means at user scope, or at project or local scope for this root. -# Finds the workspace from its own place, never by searching. Bash 3.2, Git Bash safe. +# .delphi/plugins.sh: setup.sh runs it to keep this clone's Delphi plugins in step with this +# workspace, so a plugin change on main reaches everyone on their next setup, switch or pull, with no +# migration of its own. Of the `@delphi` plugins (the Delphi marketplace's; others are only warned +# about), this workspace's generated .claude/settings.json is the list: it installs the ones it +# enables but this clone hasn't (claude plugin install --scope local, for this root), uninstalls this +# root's local installs it doesn't enable (a switch leaves the last workspace's behind, still enabled +# in the git-ignored .claude/settings.local.json), and uninstalls installed ones the marketplace no +# longer has (Claude Code marks them plugin-not-found), at any scope. Other clones' installs and +# user-scope ones the marketplace still has are never touched. It refreshes the marketplace before +# installing or uninstalling what it dropped, only then; if it can't (offline), it does only the +# local uninstalls and warns about the rest. Installed means at user scope, or at project or local +# scope for this root. Says what it did in one line; prints nothing when all is in order or it can't +# tell. Only `harness: claude-code` so far. Finds the workspace from its own place, never by +# searching. Bash 3.2, Git Bash safe. set -euo pipefail cd "$(dirname "$0")/.." [ -f workspace.yml ] && [ "$(awk '/^harness:/ { print $2; exit }' workspace.yml)" = claude-code ] || exit 0 +command -v claude >/dev/null || exit 0 enabled=$(sed -nE 's/^[[:space:]]*"([^"]+@[^"]+)": true,?$/\1/p' .claude/settings.json 2>/dev/null || true) -[ -n "$enabled" ] && command -v claude >/dev/null || exit 0 root=$(pwd -W 2>/dev/null || pwd) # Git Bash: the Windows path Claude Code records -installed=$(claude plugin list --json 2>/dev/null | awk -v root="$root" ' - function val(l) { sub(/^[^:]*:[[:space:]]*"/, "", l); sub(/"[^"]*$/, "", l); gsub(/\\\\/, "/", l); return l } - /"id":/ { id = val($0); scope = path = "" } - /"scope":/ { scope = val($0) } - /"projectPath":/ { path = val($0) } - /^[[:space:]]*}/ && id != "" { if (scope == "user" || tolower(path) == tolower(root)) print id; id = "" }') || exit 0 -missing=$(for p in $enabled; do echo "$installed" | grep -qxF "$p" || echo "$p"; done | paste -sd " " -) + +# ` ` for each plugin installed for this root (gone: 1 if its marketplace lost it) +installed() { + claude plugin list --json 2>/dev/null | awk -v root="$root" ' + function val(l) { sub(/^[^:]*:[[:space:]]*"/, "", l); sub(/"[^"]*$/, "", l); gsub(/\\\\/, "/", l); return l } + depth == 1 && /"id":/ { id = val($0); scope = path = ""; gone = 0 } + depth == 1 && /"scope":/ { scope = val($0) } + depth == 1 && /"projectPath":/ { path = val($0) } + /"type":[[:space:]]*"plugin-not-found"/ { gone = 1 } + { opened = gsub(/\{/, "{"); closed = gsub(/\}/, "}"); depth += opened - closed } + closed && depth == 0 && id != "" { if (scope == "user" || tolower(path) == tolower(root)) print id, scope, gone; id = "" }' +} +# What's left to do: `install `, `drop ` (a local install here this workspace doesn't enable), +# `uninstall ` (gone from the marketplace), `missing ` (another marketplace's) +todo() { + local have + have=$(installed) || return 1 + for p in $enabled; do + echo "$have" | awk -v p="$p" '$1 == p { f = 1 } END { exit !f }' && continue + case $p in *@delphi) echo "install $p" ;; *) echo "missing $p" ;; esac + done + echo "$have" | awk -v on=" ${enabled//$'\n'/ } " '$1 ~ /@delphi$/ { + if ($2 == "local" && !index(on, " " $1 " ")) print "drop", $1; else if ($3 == 1) print "uninstall", $1, $2 }' +} +work=$(todo) || exit 0 +[ -n "$work" ] || exit 0 +online="" +if echo "$work" | grep -qE '^(un)?install ' && claude plugin marketplace update delphi >/dev/null 2>&1; then + online=1 + work=$(todo) || exit 0 # again, against the marketplace as it is now +fi +did="" left="" +while read -r verb id scope; do + case $verb in + drop) claude plugin uninstall "$id" --scope local >/dev/null 2>&1 && + did="$did; uninstalled $id (this workspace doesn't enable it)" || left="$left +drop $id" ;; + install) [ -n "$online" ] && claude plugin install "$id" --scope local >/dev/null 2>&1 && + did="$did; installed $id" || left="$left +missing $id" ;; + uninstall) [ -n "$online" ] && claude plugin uninstall "$id" --scope "$scope" >/dev/null 2>&1 && + did="$did; uninstalled $id (gone from the Delphi marketplace)" || left="$left +uninstall $id" ;; + *) left="$left +$verb $id" ;; + esac +done <<<"$work" +[ -z "$did" ] || echo "delphi: plugins: ${did#; }; run /reload-plugins in Claude Code (or restart it) to load the change" +missing=$(echo "$left" | awk '$1 == "missing" || $1 == "install" { print $2 }' | paste -sd " " -) +stale=$(echo "$left" | awk '$1 == "uninstall" { print $2 }' | paste -sd " " -) +extra=$(echo "$left" | awk '$1 == "drop" { print $2 }' | paste -sd " " -) [ -z "$missing" ] || echo "delphi: this workspace's plugins $missing aren't installed here, so their skills are missing; install each with: claude plugin install --scope local" +[ -z "$stale" ] || + echo "delphi: plugins $stale are gone from the Delphi marketplace; uninstall each with: claude plugin uninstall --scope " +[ -z "$extra" ] || + echo "delphi: plugins $extra are installed here but this workspace doesn't enable them; uninstall each with: claude plugin uninstall --scope local" diff --git a/.delphi/setup.sh b/.delphi/setup.sh index d47c97b..c3ed2e1 100755 --- a/.delphi/setup.sh +++ b/.delphi/setup.sh @@ -14,8 +14,8 @@ # to another workspace or main parks them. First # it runs the .delphi/migrations/-*.sh this workspace hasn't (oldest first; the last one done # is `git config delphi..version`, 0.0.0 if unset), stopping at one that fails, and warns if -# .delphi/ isn't exactly the version it says (.delphi/version.sh). Last, it warns about plugins the workspace -# enables that this machine hasn't installed (.delphi/plugins.sh). Safe to re-run. Keep it bash 3.2 +# .delphi/ isn't exactly the version it says (.delphi/version.sh). Last, it brings this machine's Delphi plugins in +# step with the workspace (.delphi/plugins.sh: installs what it enables, uninstalls local ones it doesn't and what the marketplace dropped). Safe to re-run. Keep it bash 3.2 # (macOS) and Git Bash safe: no bash-4 features, POSIX awk only. set -euo pipefail cd "$(dirname "$0")/.." @@ -120,4 +120,4 @@ EOF3 else echo "setup: $hook is another hook; not installing .delphi/park.sh, so checkouts won't park repos/" >&2 fi -bash .delphi/plugins.sh >&2 || true # a warning, never a failure +bash .delphi/plugins.sh >&2 || true # never a failure diff --git a/.delphi/versions b/.delphi/versions index 5c6b1b6..535515b 100644 --- a/.delphi/versions +++ b/.delphi/versions @@ -10,3 +10,4 @@ 0.4.1 c5e413632e1c setup.sh also excludes what tools leave at the root: /.playwright-mcp/, /pictures/ and .DS_Store 0.5.0 6088d535fdd9 switch.sh --list prints main and the workspaces, marking this clone's; the delphi mod's /ws pane reads it 0.5.1 3fd96f222b77 the delphi mod's command is /ws-delphi (was /ws); switch.sh's comment says so +0.6.0 c65ffc578bfa setup.sh (.delphi/plugins.sh) makes each clone's @delphi plugins what its workspace enables (installs them, uninstalls local ones it doesn't) and uninstalls ones the marketplace dropped, instead of warning; plugin changes need no migration diff --git a/CLAUDE.md b/CLAUDE.md index db65379..b413327 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,8 @@ source of truth; `docs/goals.md` lists what any change must keep. `default` set of every folder above), and its `.claude/settings.json` (enabled sets, and the SessionStart hook that sets `$DELPHI_ROOT`; never edit it by hand). Run it after changing a set or a `workspace.yml`'s `plugins:`. - `.delphi/{setup,new-worktree,switch,park,plugins}.sh`: the scripts every workspace runs (setup.sh - runs `plugins.sh` to warn about enabled plugins not installed); `.delphi/paths.sh` + runs `plugins.sh`, which makes this clone's local `@delphi` installs what the workspace enables and + uninstalls ones the marketplace dropped, so plugin changes need no migration); `.delphi/paths.sh` is the layout (`DELPHI_ROOT`, `DELPHI_WORKTREES`, …) that they and skill scripts source. Only here: sync puts `.delphi/` at the root of every `ws/*` branch and strips it from proposals. - `.delphi/manifest.yml` (Delphi's files and their `version:`), `.delphi/versions` (one line per diff --git a/docs/design.md b/docs/design.md index 34eecf5..5ac72db 100644 --- a/docs/design.md +++ b/docs/design.md @@ -123,11 +123,22 @@ rest, and `ci/check.sh` fails if the list or either file is out of date: (git-ignored by Claude Code), shared ones in `workspace.yml` or the generator. Sync regenerates it on every `ws/*` branch (section 3); a PR to `main` that changes a set or a `workspace.yml` runs `ci/plugins.sh` itself. -- Enabled isn't installed: someone's Claude Code may never have installed a set, and then its skills - are silently missing. `setup.sh` warns, through `.delphi/plugins.sh`, about enabled sets not - installed for this workspace (`claude plugin list --json`: user scope, or project or local scope - for this root); install with `claude plugin install --scope local`, which leaves the - generated file alone (`delphi` at `--scope user` also has `/ws-delphi` on `main` and in every clone). +- Enabled isn't installed: Claude Code only offers to install a project's enabled plugins, so + someone may never have installed a set, and then its skills are silently missing; and a set that + leaves the marketplace stays installed, failing to load. So `setup.sh` (on every setup, switch and + pull) runs `.delphi/plugins.sh`, which makes this clone's `@delphi` plugins what the workspace's + `settings.json` enables: it installs the ones it enables but this root lacks (`claude plugin list + --json`: user scope, or project or local scope for this root) with `claude plugin install + --scope local`, which leaves the generated file alone; uninstalls this root's local installs it + doesn't enable (a local install is per folder, not per workspace, and the git-ignored + `.claude/settings.local.json` keeps it enabled after a switch, so it would load in the next + workspace); and uninstalls, at any scope, installed ones Claude Code marks `plugin-not-found` in the + Delphi marketplace. Other clones' installs and user-scope plugins the marketplace still has are + never touched. It refreshes the marketplace first, only before installing or uninstalling what it + dropped; offline it does only the local uninstalls and warns, as it does for other marketplaces' + plugins. So switching back reinstalls, and renaming, + splitting or dropping a set needs no migration: every clone catches up on its next pull or switch. + (`delphi` at `--scope user` also has `/ws-delphi` on `main` and in every clone.) Claude Code installs a plugin from `main` by copying only its folder, so it can't use files outside it; at run time its skills read the workspace as usual. With no `version`, each commit to `main` diff --git a/tests/e2e.sh b/tests/e2e.sh index 3ebe7c4..41f0e1b 100644 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -19,7 +19,11 @@ echo "gh \$*" >>"$t/gh.log" EOF cat >"$t/bin/claude" </dev/null || echo "[]" +case "\$*" in + "plugin list --json") cat "$t/plugins.json" 2>/dev/null || echo "[]" ;; + "plugin marketplace update delphi") echo "claude \$*" >>"$t/claude.log" && [ ! -e "$t/offline" ] ;; # touch offline: no network + "plugin install "* | "plugin uninstall "*) echo "claude \$*" >>"$t/claude.log" ;; +esac EOF chmod +x "$t/bin/gh" "$t/bin/claude" export PATH=$t/bin:$PATH @@ -508,25 +512,49 @@ cap=$src/software/workspace-inherit/.claude/skills/journal/scripts/capture.sh ! (unset DELPHI_ROOT && bash "$cap" "a note") >/dev/null 2>&1 || fail "capture.sh ran without DELPHI_ROOT" ! DELPHI_ROOT=$t bash "$cap" "a note" >/dev/null 2>&1 || fail "capture.sh accepted a DELPHI_ROOT that is not a workspace root" ok "paths.sh gives the layout, sourced or as an export line; capture.sh reads \$DELPHI_ROOT, refusing it unset or not a root" -# plugins.sh: setup.sh warns about the enabled sets not installed for this workspace (user scope, or this root's) -printf '{\n "extraKnownMarketplaces": {\n "delphi": {\n "source": {\n "source": "github",\n "repo": "o/Delphi"\n },\n "autoUpdate": true\n }\n },\n "enabledPlugins": {\n "app@delphi": true,\n "argos@delphi": true\n }\n}\n' >"$w/.claude/settings.json" -pl() { # ...: what `claude plugin list --json` reports +# plugins.sh: setup.sh installs the @delphi plugins this workspace enables but this clone hasn't, +# uninstalls this clone's local ones it doesn't enable and ones the marketplace dropped, leaves other +# clones' and user-scope ones alone, and only warns about other marketplaces' or offline +printf '{\n "extraKnownMarketplaces": {\n "delphi": {\n "source": {\n "source": "github",\n "repo": "o/Delphi"\n },\n "autoUpdate": true\n }\n },\n "enabledPlugins": {\n "app@delphi": true,\n "argos@delphi": true,\n "x@other": true\n }\n}\n' >"$w/.claude/settings.json" +pl() { # ...: what `claude plugin list --json` reports local sep="" - printf '[' && while [ $# -ge 3 ]; do + printf '[' && while [ $# -ge 4 ]; do printf '%s\n {\n "id": "%s",\n "scope": "%s",\n' "$sep" "$1" "$2" [ "$3" = - ] || printf ' "projectPath": "%s",\n' "$3" - printf ' "enabled": true\n }' && sep=, && shift 3 + [ "$4" = 0 ] || printf ' "errorDetails": [\n {\n "type": "plugin-not-found",\n "plugin": "%s"\n }\n ],\n' "${1%@*}" + printf ' "enabled": true\n }' && sep=, && shift 4 done && printf '\n]\n' } -rm -f "$t/plugins.json" -{ out=$(bash "$w/.delphi/plugins.sh") && echo "$out" | grep -qF "plugins app@delphi argos@delphi aren't installed"; } || fail "plugins.sh with none installed: $out" -pl app@delphi user - argos@delphi local "$t/elsewhere" >"$t/plugins.json" -{ out=$(bash "$w/.delphi/plugins.sh") && echo "$out" | grep -qF "plugins argos@delphi aren't"; } || fail "plugins.sh counted another clone's install: $out" -{ err=$(bash "$w/.delphi/setup.sh" 2>&1 >/dev/null) && echo "$err" | grep -qF "argos@delphi aren't installed"; } || - fail "setup.sh doesn't warn about plugins not installed: $err" -pl app@delphi user - argos@delphi local "$w" >"$t/plugins.json" -[ -z "$(bash "$w/.delphi/plugins.sh")" ] || fail "plugins.sh warned with every set installed" -ok "setup.sh warns (plugins.sh) about enabled plugin sets not installed for this workspace, and is quiet when all are" +plugins() { : >"$t/claude.log" && bash "$w/.delphi/plugins.sh"; } # its output; what it ran is in claude.log +rm -f "$t/plugins.json" && touch "$t/offline" +{ out=$(plugins) && echo "$out" | grep -qF "plugins app@delphi argos@delphi x@other aren't installed" && ! grep -q install "$t/claude.log"; } || + fail "plugins.sh offline didn't just warn: $out $(cat "$t/claude.log")" +rm "$t/offline" +{ out=$(plugins) && echo "$out" | grep -qF "delphi: plugins: installed app@delphi; installed argos@delphi; run /reload-plugins" && + echo "$out" | grep -qF "plugins x@other aren't installed" && grep -qx "claude plugin marketplace update delphi" "$t/claude.log" && + grep -qx "claude plugin install app@delphi --scope local" "$t/claude.log" && ! grep -q "install x@other" "$t/claude.log"; } || + fail "plugins.sh with none installed: $out $(cat "$t/claude.log")" +pl app@delphi user - 0 argos@delphi local "$t/elsewhere" 0 x@other user - 0 >"$t/plugins.json" +{ out=$(plugins) && [ "$out" = "delphi: plugins: installed argos@delphi; run /reload-plugins in Claude Code (or restart it) to load the change" ]; } || + fail "plugins.sh counted another clone's install: $out" +pl app@delphi user - 0 argos@delphi local "$w" 0 x@other user - 0 old@delphi user - 1 gone@other user - 1 \ + extra@delphi local "$w" 0 theirs@delphi local "$t/elsewhere" 0 spare@delphi user - 0 >"$t/plugins.json" +{ : >"$t/claude.log" && err=$(bash "$w/.delphi/setup.sh" 2>&1 >/dev/null) && + echo "$err" | grep -qF "delphi: plugins: uninstalled old@delphi (gone from the Delphi marketplace); uninstalled extra@delphi (this workspace doesn't enable it)" && + grep -qx "claude plugin uninstall old@delphi --scope user" "$t/claude.log" && grep -qx "claude plugin uninstall extra@delphi --scope local" "$t/claude.log" && + ! grep -q "gone@other\|theirs@\|spare@\|install a" "$t/claude.log"; } || + fail "setup.sh doesn't uninstall what the marketplace dropped and this workspace's unenabled local installs (only): $err $(cat "$t/claude.log")" +touch "$t/offline" +{ out=$(plugins) && echo "$out" | grep -qF "plugins old@delphi are gone from the Delphi marketplace; uninstall each with" && ! grep -q "uninstall old" "$t/claude.log" && + grep -qx "claude plugin uninstall extra@delphi --scope local" "$t/claude.log"; } || + fail "plugins.sh offline uninstalled a dropped plugin, didn't warn, or kept an unenabled local install: $out $(cat "$t/claude.log")" +rm "$t/offline" +pl app@delphi user - 0 argos@delphi local "$w" 0 extra@delphi local "$w" 0 >"$t/plugins.json" +{ out=$(plugins) && echo "$out" | grep -qF "uninstalled extra@delphi" && ! grep -q "marketplace update" "$t/claude.log"; } || + fail "plugins.sh refreshed the marketplace just to uninstall a local plugin: $out $(cat "$t/claude.log")" +pl app@delphi user - 0 argos@delphi local "$w" 0 x@other user - 0 >"$t/plugins.json" +{ [ -z "$(plugins)" ] && [ ! -s "$t/claude.log" ]; } || fail "plugins.sh did something with every plugin in order: $(cat "$t/claude.log")" +ok "setup.sh (plugins.sh) makes this clone's @delphi plugins what the workspace enables and uninstalls ones the marketplace dropped; warns offline or for other marketplaces; quiet when all is in order" # --- 12. workspaces: setup.sh checks out listed workspaces and main read-only at linked/, refreshed on re-run; # migrations: setup.sh runs the pending ones (0.0.1 replaces old link.sh worktrees), stopping where one can't go on safely