Harness — Sync
Makes this machine match your personal agent repo.
Composition boundary
harness:setup invokes this skill for repository discovery or cloning, link
reconciliation, and portability checks. Run the same phases and preserve every
existing prompt, conflict stop, authentication boundary, and report field. Return
the Phase 4 report to Setup; do not make rubric decisions or capability claims on
Setup's behalf.
Default repo: $HOME/.agents. If $AGENTS_REPO is set, use that instead.
Dry run
If the user asks what would change, or passes --dry-run, run only the
read-only pieces — link-plan.sh (Phase 1's command),
reconcile_shared_settings.py --check for the shared settings file,
mcp-manifest.sh --check for the portable MCP manifest, Phase 2.5's MCP
reconcile block (table and plan only, no question), Phase 2.6's step 0
detection block (the git ls-files --error-unmatch tracked-check and the .gitignore presence
check — not the fix block right after it) and step 1 (the
skills-reconcile.sh call, nothing that follows it),
portability-lint.sh (Phase 3's command), and rubric-audit.sh (Phase 3.5's
command — it only reads transcripts) — then print the report from
Phase 4 and stop. Phase 2.5's MCP block only reads the portable manifest and Claude Code's
machine-local user registry, then prints the per-host table and plan lines;
it belongs in a dry run because that's exactly the kind of thing someone
previewing a sync wants to see. The match / replace / merge question is not
asked and nothing is installed, removed, or imported.
Phase 2.6's step 0 detection and step 1 are the same shape: git ls-files --error-unmatch and grep only read, and skills-reconcile.sh only
reads the manifest, .fleet-local.json, and npx skills list -g --json
output — all of them print findings without touching disk.
Skip everything else, explicitly:
- Phase 2.2 (render
codex/AGENTS.md) — a write; reportDerived: [skipped in dry run]. - Phase 2's reconciliation and Phase 3.75's final transaction — they write shared settings, manifests, the repo, or the remote.
- Phase 0.5's remote preflight — it may fetch and fast-forward the repo.
- Phase 2.5's plugin half (marketplace add / plugin install) — installs software. Run only its MCP reconcile block's table and plan, not the question or anything after it.
- Phase 2.6's step 0 fix block (the
.gitignorewrite andgit rm --cached) — a write, and a one-time repo migration, not something a preview should perform. Only step 0's detection block above it belongs in a dry run. - Phase 2.6's step 2 (installs, removals, override writes) and step 3
(the
skills-manifest.shregeneration) — running either would write real files or install real software; skip both. Only step 1's read/ compare/report belongs in a dry run. - Phase 1's action column (create/re-link/remove) and Phase 3's fix suggestions — both are writes, not part of the dry run.
Nothing is created, installed, moved, or removed. The read-only pieces that do run are read-only, so this is safe to offer unprompted when the user seems unsure.
Phase 0: Locate the repo
repo="${AGENTS_REPO:-$HOME/.agents}"
claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
[ -d "$repo/.git" ] && echo "found" || echo "absent"
link-plan.sh resolves $claude the same way internally — use this $claude
variable everywhere below instead of hardcoding ~/.claude or $HOME/.claude,
so the skill and the script always agree on which directory is being managed.
Each phase below may run as a separate command, in a separate shell.
Nothing set in one command block — repo=, claude=, and later
diff_status=, $diff_err's path — persists into the next. Re-resolve
repo=/claude= at the top of any later block that references them, rather
than assuming Phase 0's values are still set. Same rule for the diff
sequence in Phase 1: run the diff, the $diff_err check, the resulting
removal, and the rm -f "$diff_err" cleanup as one command block, not as
separately-issued commands — splitting them loses the temp file's path and
the diff_status value in between.
absent — first run on this machine. Do not assume a remote already exists. Continue through First-run safety and adoption below.
found — continue to Phase 0.5.
First-run safety and adoption
Run this section only when Phase 0 reported absent. It is the one path allowed
to create the personal repository; ordinary Sync runs never re-bootstrap it.
0.1 Back up the live configuration before any destructive step
Create a new timestamped archive under $HOME containing every present live entry
managed by link-plan.sh: skills, output-styles, CLAUDE.md, settings.json,
statusline-command.sh, the cross-tool studio-moser config directory, and Codex
AGENTS.md. Back up a present legacy $claude/mcp.json separately in the same
archive before cleaning it up, but never copy Claude Code's global .claude.json
state or adopt either file into Git. Resolve configured roots exactly as Phase 1 does.
Append each present entry to the archive separately. Missing optional entries are
normal and must not make the archive fail. Never overwrite an earlier backup, and
keep the new archive until Phase 1 reports every expected link resolved and
settings.json parses. No live path may be removed or replaced before its content
is either in this archive or explicitly declined after a shown diff.
0.2 Choose the source of truth
Ask one question:
- Existing private repository — ask for its exact URL; never guess it. Clone it
into
$repo, run Phase 0.5, then use Phase 1's recursive diff and keep/discard prompts for every live file or directory that conflicts with the clone. - Loose configuration with no repository yet — adopt the current machine using the procedure below.
For an existing repository:
git clone <url> "${AGENTS_REPO:-$HOME/.agents}"
If the clone fails on authentication, say so plainly and stop — do not fall back
to another protocol without asking. A common cause is an SSH remote with no key
loaded (ssh-add -l reports no identities); gh auth status will show whether
HTTPS is the configured protocol instead.
For loose configuration:
- If skills exist in more than one live location, compare each duplicated pair recursively and read the differences. A newer modification time is not proof that a copy is correct. Resolve the winning content before consolidation.
- Localize machine-only skill routing before copying shared
settings.json:
claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
harness="${CLAUDE_PLUGIN_ROOT:-$(ls -d "$HOME"/.claude/plugins/cache/*/harness/*/ 2>/dev/null | sort -V | tail -1)}"; harness="${harness%/}"
[ ! -e "$claude/settings.json" ] || "$harness/scripts/localize-skill-overrides.py" "$claude/settings.json" "$claude/settings.local.json"
This removes only skillOverrides from the shared file and merges it into
live settings.local.json, preserving every other local key.
3. Initialize an otherwise empty $repo on main. Do not copy any managed entry
into it yet.
4. After the archive and duplicate inspection establish the intended loose source,
ask for the exact private remote URL and configure it as origin; never invent
one. Confirm the remote is private and its target branch is absent. If the branch
already exists, stop and use the existing-repository clone path instead. On
authentication failure, stop rather than silently switching protocols. A full
Sync cannot continue without a remote because preflight must bind the final push
before any repository content is adopted.
5. Continue to Phase 0.5, not Phase 1. Do not copy, link, render, reconcile, or
otherwise write repository content until preflight reports ready.
Phase 0.5: Ingest the remote before any reconciliation
Run this exactly once on every full Sync after locating a normal repository, cloning an existing repository, or initializing an empty loose-adoption repository. It is the single join point before Phase 1 or any newly adopted content can write shared state. Do not run it in dry-run mode because a clean repository may be fetched and fast-forwarded.
set -euo pipefail
repo="${AGENTS_REPO:-$HOME/.agents}"
harness="${CLAUDE_PLUGIN_ROOT:-$(ls -d "$HOME"/.claude/plugins/cache/*/harness/*/ 2>/dev/null | sort -V | tail -1)}"; harness="${harness%/}"
"$harness/scripts/sync-preflight.sh" "$repo"
The helper queries the actual remote branch. A clean repository is fetched and fast-forwarded before any derived output is computed. Local work may continue only while that remote still matches the already-known tracking SHA. If both local work and a newer remote exist, it stops with an explicit instruction to commit or stash the local work, ingest the remote, and rerun Sync. Divergence also stops; never merge, rebase, or force automatically. A remote branch that does not exist yet is a valid first-push state.
If the remote moves after this preflight, Phase 3.75 queries the actual remote again and stops before commit or push. It never pulls after the derived files and scans have run.
Complete loose-config adoption after preflight
Run this section only for the loose-configuration path after Phase 0.5 reports ready. Normal and cloned repositories skip directly to Phase 1.
- Create
skills/,claude/,config/studio-moser/, andcodex/under$repoas needed. Copy every present managed entry into its Phase 1 repo path except machine-local MCP state; copy, do not move, so the originals remain recoverable until verification. Generate the secret-free$repo/mcp.manifest.jsonby reading each top-levelmcpServersentry's secret-free shape from Claude Code's global.claude.jsonstate. Never copy that state file. Do not adopt CodexAGENTS.mdas a source: preserve any unique instruction inHouse Style.mdorCLAUDE.md, then let Phase 2.2 render the derived file. - Keep local-only state out of Git:
claude/mcp.json,settings.local.json, runtime project/session stores, credentials, secret-bearing profiles, resolved machine paths, approvals, temporary evidence, Shelby state,.fleet-local.json, and.skill-lock.json. - Continue to Phase 1 and replace only verified originals with links; the backup remains the recovery copy. Complete every later reconciliation and Phase 3's portability rules before Phase 3.75 stages and synchronizes the full result.
Phase 1: Link check
Remote ingestion must already be complete. CLAUDE.md and settings.json are both rewritten by
tooling — a memory tool's bootstrap block edits one, Claude Code writes the other
on plugin toggle. A writer that does atomic-replace (temp file + rename) rather
than write-in-place silently converts a symlink back into a real file, and sync
stops working with no signal. This phase is how that gets noticed.
repo="${AGENTS_REPO:-$HOME/.agents}"
harness="${CLAUDE_PLUGIN_ROOT:-$(ls -d "$HOME"/.claude/plugins/cache/*/harness/*/ 2>/dev/null | sort -V | tail -1)}"; harness="${harness%/}"
"$harness/scripts/link-plan.sh" "$repo"
Each line ends in a state:
| state | meaning | action |
|---|---|---|
ok |
correct symlink | nothing |
ABSENT |
no such path in $claude |
create the link |
REAL-FILE |
a real file (or directory — skills, output-styles, and studio-moser are directories among the seven portable entries) sits where the link should be |
diff first (below) |
RELINK(->X) |
symlink points somewhere else | show X, confirm, re-link |
MISSING-IN-REPO |
the repo has no such file | report; do not create anything |
Claude Code's global .claude.json state is deliberately absent from this table.
Commands, arguments, environment variables, credentials, OAuth data, and application
state are machine-local. Phase 2.5 compares its top-level user-scope MCP servers
to the portable mcp.manifest.json; Sync never copies, links, prints, or tracks the
global state file.
MISSING-IN-REPO is checked first and masks the other states. If the
repo lacks the file, link-plan.sh reports MISSING-IN-REPO for that entry
no matter what $claude currently has there (a real file, a drifted
symlink, or nothing) — the five states are not independent signals about
both sides at once. Don't infer "no real file exists on this machine" from a
MISSING-IN-REPO line.
link-plan.sh resolves both paths before comparing them, so equivalent relative
and absolute targets both report ok. A RELINK(->X) result therefore means the
resolved target is genuinely different.
On REAL-FILE, never overwrite silently. That file may hold edits made on
this machine since the link broke. skills is a directory, so the diff must
be recursive:
repo="${AGENTS_REPO:-$HOME/.agents}"
claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
diff_err="$(mktemp)"
diff -ru "$repo/<rel>" "$claude/<name>" 2>"$diff_err"
diff_status=$?
diff_status, not status — status is a read-only special variable in
zsh (an alias for $?), and assigning to it aborts the command with
read-only variable: status instead of setting anything. Since this skill
runs its Bash blocks through whatever shell the agent's tool uses, which may
be zsh, name it something the shell won't reject.
First, before looking at diff_status at all: if $diff_err is
non-empty, stop and report. Do not remove or re-link anything, and do not
fall through to the status bullets below — a stderr line means part of the
comparison didn't happen, so no status value is trustworthy:
if [ -s "$diff_err" ]; then cat "$diff_err"; rm -f "$diff_err"; exit 1; fi
This matters because BSD diff -r (macOS's default diff) can leave a
non-empty diff_err — a diff: …: Permission denied line from a
subdirectory it can't read — under two distinct exit codes. If that
unreadable subtree is the only difference, diff -r exits 0
("identical") even though part of the tree was never compared, which would
otherwise walk straight into the keep/discard prompt below and rm -r a
tree that was never actually fully compared. If a different, readable part
of the same tree also genuinely differs, diff -r instead exits 1
(diff_status=1) for that real difference, while the unreadable subtree is
still unchecked. Either exit code is untrustworthy once diff_err is
non-empty, which is why it's checked first and unconditionally, before
diff_status is read at all — removing the need to repeat the check on
every status branch.
Once $diff_err is confirmed empty, branch on diff_status:
diff_status= 0 (identical) → remove the stray file (orrm -rthe stray directory) and re-link.diff_status= 1 (differs) → show the diff and ask: keep the machine's version, or discard it. Never pick for the user. Either way, remove the stray path before re-linking — re-linking does not itself replace a real file or directory (see theln -sfnnote below), so skipping the removal step lands the new link inside the surviving path instead of replacing it, and the run reports success while the drift persists:- keep, file:
cp "$claude/<name>" "$repo/<rel>"(commit it in the repo if the user wants it tracked), thenrm "$claude/<name>", thenln -sfn. - keep, directory (e.g.
skills): copy contents, not the directory itself —cp -R "$claude/<name>/." "$repo/<rel>/". A plaincp -R "$claude/<name>" "$repo/<rel>"nests one level too deep ($repo/<rel>/<name>/…) because both sides already exist as directories, so the file ends up somewhere the repo's own<rel>path doesn't cover — "keep the machine's version" would silently not happen even though the command succeeds. Thenrm -r "$claude/<name>", thenln -sfn. - discard:
rm -r "$claude/<name>", thenln -sfnto the repo's version.
- keep, file:
diff_status>= 2 without adiff_errmessage is not expected fromdiff, but treat it the same as the stderr case above: stop and report, take no action.
Whichever branch fires, clean up the temp file once done:
rm -f "$diff_err". All three branches above create and leave $diff_err
behind — this isn't specific to the >= 2 case.
A plain diff -u here is a data-loss trap: non-recursive diff against
two directories prints only Common subdirectories: … and exits 0 even
when their contents differ, so the "no differences" branch would fire and
delete a skills tree that might hold this machine's only copy of local
edits. -r is required, not optional.
To create or re-link, use:
repo="${AGENTS_REPO:-$HOME/.agents}"
claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
ln -sfn "$repo/<rel>" "$claude/<name>"
An absolute or relative target is accepted; link-plan.sh compares canonical
resolved paths. Prefer a relative target when the link itself will be tracked,
because Phase 3 rejects absolute targets in Git.
The studio-moser entry lives under ${XDG_CONFIG_HOME:-$HOME/.config}, not $claude.
For it, the link is "${XDG_CONFIG_HOME:-$HOME/.config}/studio-moser" and the target is
"$repo/config/studio-moser". Create the parent first — mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}" —
a fresh machine may not have it. The REAL-FILE diff/keep/merge procedure applies to it exactly
as it does to skills (both are directories). When keeping the machine's copy, also ensure
$repo/.gitignore covers config/studio-moser/*.bak* so local backup files never sync.
The AGENTS.md entry lives under ${CODEX_HOME:-$HOME/.codex}, not $claude.
For it, the link is "${CODEX_HOME:-$HOME/.codex}/AGENTS.md" and the target is
"$repo/codex/AGENTS.md". Create the parent first — mkdir -p "${CODEX_HOME:-$HOME/.codex}" —
a fresh machine may not have Codex installed yet, and the link is still correct to create.
It is a single file, so on REAL-FILE the diff is a plain diff -u (not
-r). The target is generated by Phase 2.25, so never cp a real
~/.codex/AGENTS.md into the repo — the render would overwrite it before
commit and the machine's hand-written instructions would silently vanish.
Instead: show the diff, move the file aside (mv "$link" "$link.bak-$(date +%F)" — the backup lives under ~/.codex, not the repo),
tell the operator that anything worth keeping belongs in House Style.md or
CLAUDE.md (the sources), then link.
For a real settings.json whose machine version is kept, localize before the
keep-file cp. Run this in the same command block as the copy so the sanitized
file, never its machine-only routing, becomes the repo copy:
set -euo pipefail
repo="${AGENTS_REPO:-$HOME/.agents}"
claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
harness="${CLAUDE_PLUGIN_ROOT:-$(ls -d "$HOME"/.claude/plugins/cache/*/harness/*/ 2>/dev/null | sort -V | tail -1)}"; harness="${harness%/}"
"$harness/scripts/localize-skill-overrides.py" "$claude/settings.json" "$claude/settings.local.json"
cp "$claude/settings.json" "$repo/claude/settings.json"
-sfn only repoints an existing symlink (that's what its -n guards —
it treats the destination as the link itself, not as a directory to drop the
link into). It does not replace a real file or a real directory: run
against a real path — including a REAL-FILE directory like skills, or a
skills directory left over after only part of a diff_status = 1 cleanup ran —
ln -sfn creates "$claude/<name>/<basename of $repo/<rel>>" inside the
surviving path and exits 0, reporting success while nothing was actually
replaced. The real file or directory must be removed (or moved aside)
first, on every branch that re-links over an existing path, not only the
symlink-to-directory case.
Phase 2: Reconcile shared and derived state
The Git transaction occurs only in Phase 3.75. Phase 2 prepares every shared write so validation can see the complete staged result before anything reaches the remote.
2.1 Reconcile shared settings
repo="${AGENTS_REPO:-$HOME/.agents}"
claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
harness="${CLAUDE_PLUGIN_ROOT:-$(ls -d "$HOME"/.claude/plugins/cache/*/harness/*/ 2>/dev/null | sort -V | tail -1)}"; harness="${harness%/}"
[ ! -e "$repo/claude/settings.json" ] || "$harness/scripts/localize-skill-overrides.py" "$repo/claude/settings.json" "$claude/settings.local.json"
[ ! -e "$repo/claude/settings.json" ] || "$harness/scripts/reconcile_shared_settings.py" "$repo/claude/settings.json"
git -C "$repo" status --short
The localization command removes machine-only skill routing from the shared file.
The settings reconciliation then removes machine@studio-moser and requires
harness@studio-moser: true. Both run before staging. In dry-run mode, use
--check for the shared settings helper and do not run either writer.
2.2 Render derived files
repo="${AGENTS_REPO:-$HOME/.agents}"
harness="${CLAUDE_PLUGIN_ROOT:-$(ls -d "$HOME"/.claude/plugins/cache/*/harness/*/ 2>/dev/null | sort -V | tail -1)}"; harness="${harness%/}"
"$harness/scripts/render-codex-agents.sh" "$repo"
codex/AGENTS.md is Codex's global instructions and is generated from the
Claude-side sources (claude/output-styles/House Style.md and claude/CLAUDE.md)
so House Style stays the one file you edit. It runs here — after settings
reconciliation and before staging — so a machine whose sources moved
gets one fresh render in the same transaction. If it prints
RENDER_STATE=regenerated, leave it uncommitted for Phase 3.75.
RENDER_STATE=failed: <reason> (exit 3) means a source or a required section is
missing — nothing was written; carry the reason into the report and continue.
Never hand-edit codex/AGENTS.md; the next sync overwrites it.
If Phase 1 reported AGENTS.md -> codex/AGENTS.md MISSING-IN-REPO, the file
exists now (pulled or rendered): create the link here — mkdir -p "${CODEX_HOME:-$HOME/.codex}" then ln -s "$repo/codex/AGENTS.md" "${CODEX_HOME:-$HOME/.codex}/AGENTS.md" — unless a real file sits there, in which
case follow the Phase 1 AGENTS.md REAL-FILE rule (move it aside, then link).
2.3 Generate the portable MCP inventory and clean up the legacy tracked file
Claude Code stores user-scope MCP servers in the top-level mcpServers object of
${CLAUDE_CONFIG_DIR}/.claude.json when CLAUDE_CONFIG_DIR is set, or
$HOME/.claude.json otherwise. Read that file only to generate
mcp.manifest.json: each server's portable shape (type, command, args,
url, env, headers) with every env and header value replaced by a ${NAME}
reference, plus a machines list naming the hosts that have it. The generator
adds this host to every server present here and removes it from every server
that is not. Never copy, link, print, stage, or commit the state file.
A names-only mcp.manifest from an older sync is migrated once: its names
become shapeless entries ({"machines": []}), the old file is untracked and
deleted, and the generator prints MCP_MANIFEST_STATE=migrated. A shapeless
entry shows as NO-CONFIG in Phase 2.5 until a machine that has the server
syncs.
The generator fails, naming the server only, when an arg looks like a token or
a --flag=value with a long value, or when command or an arg is an absolute
home path. Fix the server locally (move the secret to env, or the binary onto
PATH) and rerun.
$claude/mcp.json is a legacy path. If an older repo tracks it or the live legacy
path is a symlink into the repo, preserve its resolved bytes as a regular live file
before untracking it. Never print its command, args, URL, headers, or environment:
repo="${AGENTS_REPO:-$HOME/.agents}"
claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
harness="${CLAUDE_PLUGIN_ROOT:-$(ls -d "$HOME"/.claude/plugins/cache/*/harness/*/ 2>/dev/null | sort -V | tail -1)}"; harness="${harness%/}"
runtime_mcp="${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json"
legacy_runtime="$claude/mcp.json"
if [ -L "$legacy_runtime" ]; then
temporary="$(mktemp "$claude/.mcp.json.migrate.XXXXXX")"
trap 'rm -f "$temporary"' EXIT HUP INT TERM
cp -pL "$legacy_runtime" "$temporary"
unlink "$legacy_runtime"
mv "$temporary" "$legacy_runtime"
temporary=""
trap - EXIT HUP INT TERM
fi
if [ -f "$runtime_mcp" ]; then
"$harness/scripts/mcp-manifest.sh" "$runtime_mcp" "$repo/mcp.manifest.json" || exit $?
else
echo "MCP_STATE=not configured"
fi
grep -qxF 'claude/mcp.json' "$repo/.gitignore" 2>/dev/null || printf '%s\n' 'claude/mcp.json' >> "$repo/.gitignore"
git -C "$repo" rm --cached claude/mcp.json --ignore-unmatch -q
If the user registry is missing, report MCP_STATE=not configured and do not
invent a manifest. In dry-run mode, do not run this block; validate an existing
manifest with mcp-manifest.sh --check "$repo/mcp.manifest.json" and run Phase
2.5's reconcile block read-only.
Phase 2.5: Reconcile installed plugins and MCP servers
This phase reconciles machine state before the final Git transaction. Plugin operations may rewrite shared settings, so they must complete before Phase 3.75 stages and validates the final result. MCP comparison is read-only until the user chooses how to reconcile the plan.
Plugins — install what's missing, automatically
The plugin inventory is already in the tracked settings.json — read the
repo's copy ($repo/claude/settings.json), not the possibly-drifted live file
at $claude/settings.json
(Phase 1 runs first and would have already flagged or fixed any such drift,
but reading the repo copy here keeps both halves of this phase consistent on
principle rather than by coincidence). extraKnownMarketplaces names the
marketplaces this developer's config expects; enabledPlugins names the
plugins. Merge settings.local.json over it — settings.local.json is
never tracked (it's machine-local by design, per the "keep machine-local
things local" table), so it's always read from $claude, live. A local
false for a plugin the shared file marks true is a deliberate per-machine
override (how a local fork wins over a marketplace plugin of the same name),
and installing it anyway would overwrite that choice. Local wins.
repo="${AGENTS_REPO:-$HOME/.agents}"
claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
plugin_reconcile_script='import json, re, shutil, subprocess, sys
def load(path):
try:
with open(path) as f:
return json.load(f)
except FileNotFoundError:
return {}
if shutil.which("claude") is None:
print("PLUGINS_STATE=skipped: claude CLI not on PATH")
raise SystemExit(0)
settings = load(sys.argv[1])
local = load(sys.argv[2])
marketplaces = settings.get("extraKnownMarketplaces", {})
enabled = dict(settings.get("enabledPlugins", {}))
enabled.update(local.get("enabledPlugins", {})) # local wins over shared
def names(cmd):
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
cmd_str = " ".join(cmd)
print(f"PLUGINS_STATE=failed: running {cmd_str} exited {r.returncode}: {(r.stderr or r.stdout).strip()}")
raise SystemExit(0)
return {m.group(1) for m in re.finditer(r"❯\s+(\S+)", r.stdout)}
have_marketplaces = names(["claude", "plugin", "marketplace", "list"])
have_plugins = names(["claude", "plugin", "list"])
added_marketplaces = []
for name, cfg in marketplaces.items():
if name in have_marketplaces:
continue
repo = cfg.get("source", {}).get("repo")
if not repo:
continue
r = subprocess.run(["claude", "plugin", "marketplace", "add", repo], capture_output=True, text=True)
if r.returncode == 0:
added_marketplaces.append(name)
have_marketplaces.add(name)
else:
print(f"marketplace add failed: {name} ({repo}): {(r.stderr or r.stdout).strip()}", file=sys.stderr)
installed_plugins = []
for name, on in enabled.items():
if not on or name in have_plugins:
continue
r = subprocess.run(["claude", "plugin", "install", name], capture_output=True, text=True)
if r.returncode == 0:
installed_plugins.append(name)
have_plugins.add(name)
else:
print(f"plugin install failed: {name}: {(r.stderr or r.stdout).strip()}", file=sys.stderr)
# Update pass: refresh every marketplace, then bring each enabled plugin to the
# latest version the marketplace resolves. Third-party marketplaces have
# auto-update OFF by default, so without this a machine only ever gets the
# version it first installed.
r = subprocess.run(["claude", "plugin", "marketplace", "update"], capture_output=True, text=True)
if r.returncode != 0:
print(f"marketplace update failed: {(r.stderr or r.stdout).strip()}", file=sys.stderr)
updated_plugins = []
for name, on in enabled.items():
if not on or name not in have_plugins:
continue
r = subprocess.run(["claude", "plugin", "update", name], capture_output=True, text=True)
out = (r.stdout or "") + (r.stderr or "")
if r.returncode != 0:
print(f"plugin update failed: {name}: {out.strip()}", file=sys.stderr)
elif "updated from" in out:
updated_plugins.append(name)
# Orphans: registered on this machine but not declared in shared settings.
# Report only — removal uninstalls the marketplace plugins, so the user runs it.
for name in sorted(have_marketplaces - set(marketplaces) - {"claude-plugins-official"}):
print(f"orphan marketplace: {name} registered here but not in shared settings — remove with: claude plugin marketplace remove {name} (also uninstalls its plugins)", file=sys.stderr)
if not added_marketplaces and not installed_plugins and not updated_plugins:
print("PLUGINS_STATE=up to date")
else:
print(f"PLUGINS_STATE=added {len(added_marketplaces)} marketplace(s), installed {len(installed_plugins)}, updated {len(updated_plugins)} — restart or /reload-plugins to apply")
'
printf '%s\n' "$plugin_reconcile_script" | python3 - "$repo/claude/settings.json" "$claude/settings.local.json"
names() fails closed: a non-zero exit from claude plugin list or
claude plugin marketplace list reports PLUGINS_STATE=failed: ... and
stops rather than treating empty/error output as "nothing installed," which
would otherwise try to install everything. The claude CLI itself missing
from $PATH is checked up front the same way, before any subprocess call.
Anything printed to stderr above (a failed marketplace add or plugin install)
is a finding — carry it into the Phase 4 report the same way an unfixed lint
finding is carried, never silently. Plugin installs need a restart to take
effect — the PLUGINS_STATE line already says so whenever it installed
anything; repeat it in the report.
The update pass runs every time: claude plugin marketplace update refreshes all
registered marketplaces, then claude plugin update <name> runs for each enabled
plugin. This exists because third-party marketplaces have auto-update off by
default — a machine that only installs would stay on its first-installed version
forever. updated K in PLUGINS_STATE counts plugins whose version actually
changed; a plugin update failed: line is a finding. An orphan marketplace:
line means this machine has a marketplace registered that shared settings no
longer declare (a retired plugin's source, typically). Sync never removes it —
removal also uninstalls that marketplace's plugins — so carry the printed
claude plugin marketplace remove <name> command into the report for the user
to run.
MCP servers — compare, choose, apply
Read the tracked mcp.manifest.json and the machine-local user registry
(${CLAUDE_CONFIG_DIR}/.claude.json when configured, otherwise
$HOME/.claude.json). Validate the manifest first. The reconcile script is
read-only: it prints a table with one column per host that has ever synced plus
here, a blank line, then plan lines. Honour disabledMcpjsonServers in
settings.local.json; a server disabled here is not a finding. Never print a
command, URL, header, env value, or credential; findings name servers and
variable names only.
repo="${AGENTS_REPO:-$HOME/.agents}"
claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
runtime_mcp="${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json"
harness="${CLAUDE_PLUGIN_ROOT:-$(ls -d "$HOME"/.claude/plugins/cache/*/harness/*/ 2>/dev/null | sort -V | tail -1)}"; harness="${harness%/}"
[ ! -e "$repo/mcp.manifest.json" ] || "$harness/scripts/mcp-manifest.sh" --check "$repo/mcp.manifest.json" || exit $?
[ -f "$runtime_mcp" ] || { echo "MCP_STATE=not configured"; runtime_mcp=/dev/null; }
"$harness/scripts/mcp-reconcile.sh" "$repo" "$runtime_mcp" "$claude/settings.local.json"
/dev/null as the registry makes the planner treat this machine as empty, so
every declared server shows as INSTALL or NO-CONFIG; that is the correct
picture for a machine that has never added a server.
Read the plan here; do not carry it in a shell variable (nothing persists between blocks, see Phase 0). The plan line kinds:
| line | meaning |
|---|---|
INSTALL <name> |
declared with a shape, not here |
NO-CONFIG <name> |
declared without a shape; nothing to install from until a machine that has it syncs |
SKIP <name> |
declared, not here, recorded in .fleet-local.json skipMcp |
EXTRA <name> |
here, not declared |
KEEP-LOCAL <name> |
here, not declared, recorded in keepLocalMcp |
NEEDS-SECRET <name> <VAR> |
declared or installed here, but VAR has no value on this machine |
UNRESOLVED <name> |
here, command not on PATH |
In a dry run, stop after the table and plan. Otherwise, if there is no
INSTALL, NO-CONFIG, EXTRA, UNRESOLVED, or NEEDS-SECRET line, print
MCP_STATE=up to date and continue to Phase 2.6. If the only lines are NEEDS-SECRET
or UNRESOLVED (or both), skip the question: run the Secrets step for the
NEEDS-SECRET lines, then the unresolved-command follow-up for the UNRESOLVED
lines, both below. Otherwise ask one question with exactly these
three options, listing the affected names under each:
Match this machine to the repo — install every
INSTALLserver here, then list theEXTRAservers and ask a second confirm before removing them from this machine. A declined removal is recorded askeepLocalMcp.Replace the repo with this machine — the manifest's server set becomes this machine's set. List, by name, every server only other machines have, and confirm: the next sync on those machines will offer to remove them. On yes, record the choice on disk, then continue. Nothing is installed here:
claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"; : > "$claude/.mcp-prune-to-local"Merge — install every
INSTALLserver here and let Phase 3.75 add everyEXTRAto the manifest. No removals anywhere.
Never pick for the user. NO-CONFIG and SKIP lines are reported, never acted
on.
Installing a server uses the manifest entry with its references intact.
Claude Code expands ${VAR} from the environment at launch, and the secrets
flow below replaces the reference with the value in the live registry when the
user supplies one:
repo="${AGENTS_REPO:-$HOME/.agents}"
name="<name>"
claude mcp add-json -s user "$name" "$(python3 -c '
import json, sys
entry = json.load(open(sys.argv[1]))["servers"][sys.argv[2]]
print(json.dumps({k: v for k, v in entry.items() if k != "machines"}))
' "$repo/mcp.manifest.json" "$name")"
If the command exits non-zero, report install failed: <name> as an unresolved
finding and never write it to overrides.
Removing a server from this machine (match, after the confirm):
claude mcp remove -s user "<name>"
A non-zero exit is remove failed: <name>, an unresolved finding.
Secrets. For every NEEDS-SECRET <name> <VAR> line, whether from an install
or from a server already here, say once that pasted values pass through this
session's transcript, then print the command to run on a machine that has the
values:
echo "On the other machine, run its harness copy of mcp-secrets.sh: \"\$(ls -d \$HOME/.claude/plugins/cache/*/harness/*/ | sort -V | tail -1)scripts/mcp-secrets.sh\" export \"\${AGENTS_REPO:-\$HOME/.agents}/mcp.manifest.json\" \"\${CLAUDE_CONFIG_DIR:-\$HOME}/.claude.json\""
Then ask for the values. The user may paste the whole export block at the first
prompt; feed everything received to import, which skips empty values and
names it does not find, and then skip the remaining prompts for names it
covered:
runtime_mcp="${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json"
harness="${CLAUDE_PLUGIN_ROOT:-$(ls -d "$HOME"/.claude/plugins/cache/*/harness/*/ 2>/dev/null | sort -V | tail -1)}"; harness="${harness%/}"
"$harness/scripts/mcp-secrets.sh" import "$runtime_mcp" <<'EOF'
<NAME=value lines>
EOF
import prints counts only. Never echo a value back. A variable the user
leaves empty stays a NEEDS-SECRET finding in the report.
Unresolved commands. Whenever UNRESOLVED <name> lines exist, rerun the
reconcile block after installs if there were any, otherwise go directly to this
follow-up: list them once and ask a single question:
skip these on this machine? On yes, remove each with claude mcp remove -s user "<name>" and record it as skipMcp (see Overrides in Phase 2.6), so a machine
without Blender does not keep a blender server. On no, they stay as
<name> command unavailable on this machine findings.
Servers present here but absent from the manifest after a choice, except those
recorded in keepLocalMcp, are picked up by Phase 3.75's regeneration, which
stamps this host into machines.
Phase 2.6: Reconcile third-party skills
The store. npx skills (vercel-labs) hardcodes its install directory to
$HOME/.agents/skills — it never looks at $AGENTS_REPO. When this
developer's repo is $HOME/.agents (the default), $repo/skills/<name>
holds the real code for a skill and $claude/skills is a symlink to it, so
other agent config already reads it directly (Phase 1 covers that link like
any other tracked entry). Some skills are authored right here in the repo;
others are installed from elsewhere with npx skills and should be
declared, not vendored. npx skills list -g --json reports every skill on
this machine across every agent, each with a source field — null for
locally-authored skills, "owner/repo" for an installed third-party one.
That field is the only thing that tells the two apart; a skill's own files
don't say where it came from.
$repo/skills.manifest is the developer's declared set of third-party
skills — one name<TAB>source line each, sorted, generated, never
hand-edited. It's regenerated from reality at the end of this phase, so a
skill removed by any means (this machine, another machine, or by hand)
simply disappears from the next regeneration. Other agents (Cursor, Pi, …)
symlink into the same store and manage themselves — never touch ~/.cursor
or ~/.pi. Codex reads the store natively, so it needs no registration and
this plugin writes nothing under ~/.codex/skills; the only thing it manages
under ~/.codex is the AGENTS.md link (Phase 1). The manifest only ever
covers entries whose path falls under $HOME/.agents/skills/.
0. One-time migration: untrack .skill-lock.json
npx skills writes its own lockfile, .skill-lock.json, and reads a
skill's source back from that lockfile — not by inspecting the skill
itself. If this repo tracks it, the failure mode is silent and exactly the
kind this phase exists to prevent: machine A removes a skill, its lock
entry goes with it and that removal gets committed; machine B pulls, but
skills/<name> is gitignored so the pull can't
…(truncated)