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 .delphi/manifest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# MINOR something new that existing clones need nothing for
# MAJOR clones need cleanup (MINOR while 0.x): add .delphi/migrations/<version>-<slug>.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
Expand Down
83 changes: 70 additions & 13 deletions .delphi/plugins.sh
Original file line number Diff line number Diff line change
@@ -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 " " -)

# `<id> <scope> <gone>` 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 <id>`, `drop <id>` (a local install here this workspace doesn't enable),
# `uninstall <id> <scope>` (gone from the marketplace), `missing <id>` (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 <plugin> --scope local"
[ -z "$stale" ] ||
echo "delphi: plugins $stale are gone from the Delphi marketplace; uninstall each with: claude plugin uninstall <plugin> --scope <its scope>"
[ -z "$extra" ] ||
echo "delphi: plugins $extra are installed here but this workspace doesn't enable them; uninstall each with: claude plugin uninstall <plugin> --scope local"
6 changes: 3 additions & 3 deletions .delphi/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
# to another workspace or main parks them. First
# it runs the .delphi/migrations/<version>-*.sh this workspace hasn't (oldest first; the last one done
# is `git config delphi.<name>.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")/.."
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions .delphi/versions
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 16 additions & 5 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <plugin> --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 <plugin>
--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`
Expand Down
58 changes: 43 additions & 15 deletions tests/e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ echo "gh \$*" >>"$t/gh.log"
EOF
cat >"$t/bin/claude" <<EOF
#!/usr/bin/env bash
[ "\$*" != "plugin list --json" ] || cat "$t/plugins.json" 2>/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
Expand Down Expand Up @@ -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() { # <id> <scope> <projectPath or ->...: 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() { # <id> <scope> <projectPath or -> <gone 0/1>...: 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/<branch>, 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
Expand Down
Loading