docks-kit 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/AGENTS.md +14 -15
  2. package/README.md +12 -11
  3. package/SoT/.agents/skills.txt +3 -3
  4. package/SoT/.claude/settings.json +3 -26
  5. package/SoT/models.json +1 -1
  6. package/SoT/toolchain.json +3 -3
  7. package/cli/docs/install.md +39 -12
  8. package/cli/docs/overview.md +3 -4
  9. package/cli/docs/platforms.md +35 -22
  10. package/cli/src/commands/sync.ts +32 -4
  11. package/cli/src/commands/update.ts +116 -0
  12. package/cli/src/engine-native/DESIGN.md +89 -0
  13. package/cli/src/engine-native/claudeModel.ts +48 -0
  14. package/cli/src/engine-native/claudeSync.ts +746 -0
  15. package/cli/src/engine-native/codexSync.ts +439 -0
  16. package/cli/src/engine-native/codexToml.ts +67 -0
  17. package/cli/src/engine-native/exec.ts +54 -0
  18. package/cli/src/engine-native/index.ts +109 -0
  19. package/cli/src/engine-native/jq.ts +63 -0
  20. package/cli/src/engine-native/models.ts +73 -0
  21. package/cli/src/engine-native/modes.ts +133 -0
  22. package/cli/src/engine-native/output.ts +20 -0
  23. package/cli/src/engine-native/parseArgs.ts +218 -0
  24. package/cli/src/engine-native/settings.ts +41 -0
  25. package/cli/src/engine-native/skillsSync.ts +369 -0
  26. package/cli/src/engine-native/toolchain.ts +257 -0
  27. package/cli/src/engine.ts +35 -18
  28. package/cli/src/kitHome.ts +4 -4
  29. package/cli/src/main.ts +14 -1
  30. package/cli/src/manifests.ts +3 -2
  31. package/cli/tsconfig.json +1 -1
  32. package/docks-kit +6 -3
  33. package/package.json +8 -4
  34. package/lib/claude.sh +0 -846
  35. package/lib/codex.sh +0 -518
  36. package/lib/common.sh +0 -229
  37. package/lib/engine.sh +0 -153
  38. package/lib/skills.sh +0 -373
  39. package/lib/toolchain.sh +0 -222
package/lib/claude.sh DELETED
@@ -1,846 +0,0 @@
1
- #!/bin/bash
2
- set -euo pipefail
3
-
4
- # Source-order guards: this lib depends on common.sh's log/warn/err and on
5
- # the entry script's REPO_DIR. Fail fast with a clear message if sourced standalone.
6
- declare -F log >/dev/null 2>&1 || { printf '\033[1;31m[err]\033[0m %s\n' "lib/claude.sh must be sourced after lib/common.sh" >&2; exit 1; }
7
- [[ -n "${REPO_DIR:-}" ]] || { printf '\033[1;31m[err]\033[0m %s\n' "REPO_DIR must be set before sourcing lib/claude.sh" >&2; exit 1; }
8
-
9
- claude::sync() {
10
- CLAUDE_DIR="$HOME/.claude"
11
- CLAUDE_SYNCED=1
12
-
13
- if [[ "$DRY_RUN" -eq 0 ]]; then
14
- mkdir -p "$CLAUDE_DIR"
15
- fi
16
-
17
- # Toolchain before config: on a first-ever install `rtk init --global`
18
- # rewrites ~/.claude/settings.json (clears hooks.PreToolUse), so it must run
19
- # BEFORE the settings merge + deploy-time modifiers — the merge then
20
- # normalizes whatever rtk wrote and the modifiers land last, unclobberable.
21
- claude::sync_rtk
22
- claude::sync_scripts
23
- claude::sync_hooks
24
- claude::sync_claude_md
25
- claude::sync_settings
26
- claude::sync_compact_window
27
- claude::sync_permissive
28
- claude::sync_model
29
- claude::sync_claude_json
30
- claude::sync_connector_env
31
- claude::sync_removals
32
- claude::sync_plugins
33
- claude::sync_optional_plugins
34
- claude::sync_lsp_servers
35
- }
36
-
37
- claude::sync_scripts() {
38
- if [[ "$DRY_RUN" -eq 1 ]]; then
39
- echo "[dry-run] cp statusline.sh, fetch-usage.sh, notification.mp3"
40
- return
41
- fi
42
-
43
- [[ -f "$REPO_DIR/SoT/.claude/statusline.sh" ]] && { cp "$REPO_DIR/SoT/.claude/statusline.sh" "$CLAUDE_DIR/"; chmod +x "$CLAUDE_DIR/statusline.sh"; }
44
- [[ -f "$REPO_DIR/SoT/.claude/fetch-usage.sh" ]] && { cp "$REPO_DIR/SoT/.claude/fetch-usage.sh" "$CLAUDE_DIR/"; chmod +x "$CLAUDE_DIR/fetch-usage.sh"; }
45
- [[ -f "$REPO_DIR/notification.mp3" ]] && cp "$REPO_DIR/notification.mp3" "$CLAUDE_DIR/"
46
- log "Scripts synced (statusline, fetch-usage, notification)"
47
- }
48
-
49
- claude::sync_hooks() {
50
- local hook_count
51
-
52
- [[ -d "$REPO_DIR/SoT/.claude/hooks" ]] || return
53
-
54
- if [[ "$DRY_RUN" -eq 1 ]]; then
55
- echo "[dry-run] cp -R $REPO_DIR/SoT/.claude/hooks/. $CLAUDE_DIR/hooks/"
56
- return
57
- fi
58
-
59
- mkdir -p "$CLAUDE_DIR/hooks"
60
- # cp -R (not rsync — rsync is not coreutils and is absent on minimal images
61
- # like the Claude-Code-on-the-web Ubuntu sandbox). `src/.` copies contents
62
- # (incl. dotfiles) into dst; additive, like rsync without --delete.
63
- cp -R "$REPO_DIR/SoT/.claude/hooks/." "$CLAUDE_DIR/hooks/"
64
- find "$CLAUDE_DIR/hooks" -maxdepth 1 -name '*.sh' -exec chmod +x {} +
65
- hook_count=$(find "$CLAUDE_DIR/hooks" -maxdepth 1 -name '*.sh' 2>/dev/null | wc -l)
66
- log "Hooks synced ($hook_count scripts)"
67
- }
68
-
69
- claude::sync_claude_md() {
70
- if [[ "$DRY_RUN" -eq 1 ]]; then
71
- if [[ "${SKIP_RTK:-0}" -eq 1 ]]; then
72
- echo "[dry-run] cp SoT/.claude/CLAUDE.md -> ~/.claude/CLAUDE.md (stripping @RTK.md import: --skip-rtk)"
73
- else
74
- echo "[dry-run] cp SoT/.claude/CLAUDE.md -> ~/.claude/CLAUDE.md"
75
- fi
76
- return
77
- fi
78
-
79
- # The strip must happen HERE, on the file being written: sync_rtk runs
80
- # earlier (toolchain-before-config), so a strip there would be clobbered
81
- # by this copy.
82
- if [[ "${SKIP_RTK:-0}" -eq 1 ]]; then
83
- grep -v '^@RTK\.md$' "$REPO_DIR/SoT/.claude/CLAUDE.md" > "$CLAUDE_DIR/CLAUDE.md"
84
- log "CLAUDE.md synced (@RTK.md import stripped: --skip-rtk)"
85
- else
86
- cp "$REPO_DIR/SoT/.claude/CLAUDE.md" "$CLAUDE_DIR/CLAUDE.md"
87
- log "CLAUDE.md synced"
88
- fi
89
- }
90
-
91
- # Validate that the existing user settings file is parseable. Returns 0 if
92
- # the file is missing (caller will install) or valid; 1 if invalid (caller
93
- # returns early without touching the file).
94
- claude::_settings_validate() {
95
- local user_settings="$1"
96
- [[ -f "$user_settings" ]] || return 0
97
- if ! jq empty "$user_settings" 2>/dev/null; then
98
- err "Skipping settings sync: $user_settings is not valid JSON. Fix it manually or delete it to reinstall."
99
- return 1
100
- fi
101
- return 0
102
- }
103
-
104
- # First-install path — no user settings exist yet, copy SoT verbatim.
105
- claude::_settings_install() {
106
- local repo_settings="$1" user_settings="$2"
107
- cp "$repo_settings" "$user_settings"
108
- log "Settings installed"
109
- }
110
-
111
- # Force-reconcile path: SoT keys win, permissions arrays REPLACED wholesale by
112
- # SoT (user-added permissions are discarded), user-only top-level keys survive.
113
- claude::_settings_reconcile() {
114
- local repo_settings="$1" user_settings="$2"
115
- cp "$user_settings" "$user_settings.bak"
116
- if ! jq -s '.[0] as $repo | .[1] as $user | $user * $repo' \
117
- "$repo_settings" "$user_settings" > "$user_settings.tmp"; then
118
- rm -f "$user_settings.tmp"
119
- err "jq reconcile failed — settings unchanged (backup at settings.json.bak)"
120
- return
121
- fi
122
- mv "$user_settings.tmp" "$user_settings"
123
- log "Settings reconciled (backup at settings.json.bak; user-only keys preserved, permissions arrays replaced by SoT)"
124
- }
125
-
126
- # Default-merge path: SoT keys win, permissions.{allow,deny,ask} arrays UNIONED
127
- # (user + repo, deduplicated), user-only top-level keys survive.
128
- claude::_settings_merge() {
129
- local repo_settings="$1" user_settings="$2"
130
- cp "$user_settings" "$user_settings.bak"
131
- if ! jq -s '
132
- .[0] as $repo | .[1] as $user |
133
- ($user * $repo) |
134
- .permissions.allow = (($user.permissions.allow // []) + ($repo.permissions.allow // []) | unique) |
135
- .permissions.deny = (($user.permissions.deny // []) + ($repo.permissions.deny // []) | unique) |
136
- .permissions.ask = (($user.permissions.ask // []) + ($repo.permissions.ask // []) | unique)
137
- ' "$repo_settings" "$user_settings" > "$user_settings.tmp"; then
138
- rm -f "$user_settings.tmp"
139
- err "jq merge failed — settings unchanged (backup at settings.json.bak)"
140
- return
141
- fi
142
- mv "$user_settings.tmp" "$user_settings"
143
- log "Settings merged (backup at settings.json.bak)"
144
- }
145
-
146
- claude::sync_settings() {
147
- local repo_settings="$REPO_DIR/SoT/.claude/settings.json"
148
- local user_settings="$CLAUDE_DIR/settings.json"
149
-
150
- if [[ "$DRY_RUN" -eq 1 ]]; then
151
- if [[ ! -f "$user_settings" ]]; then
152
- echo "[dry-run] install $repo_settings -> $user_settings"
153
- elif [[ "$RECONCILE" -eq 1 ]]; then
154
- echo "[dry-run] reconcile $repo_settings -> $user_settings (SoT keys win; permissions arrays replaced; user-only keys preserved)"
155
- else
156
- echo "[dry-run] merge $repo_settings -> $user_settings (SoT keys win; permissions arrays unioned; user-only keys preserved)"
157
- fi
158
- return
159
- fi
160
-
161
- claude::_settings_validate "$user_settings" || return
162
-
163
- if [[ ! -f "$user_settings" ]]; then
164
- claude::_settings_install "$repo_settings" "$user_settings"
165
- elif [[ "$RECONCILE" -eq 1 ]]; then
166
- claude::_settings_reconcile "$repo_settings" "$user_settings"
167
- else
168
- claude::_settings_merge "$repo_settings" "$user_settings"
169
- fi
170
- }
171
-
172
- # Deploy-time modifier (--claude-compact-window=<tokens>): set the DEPLOYED
173
- # autocompact window (e.g. 680k for disposable containers) — host machines stay
174
- # on the SoT cap. Touches only ~/.claude/settings.json — the SoT stays at its
175
- # cap and the model selection is never changed. A later flag-less sync restores
176
- # the SoT value via the repo-wins merge, so re-pass the flag on machines that
177
- # should keep the override.
178
- claude::sync_compact_window() {
179
- local user_settings="${CLAUDE_DIR:-$HOME/.claude}/settings.json"
180
-
181
- [[ -n "$CLAUDE_COMPACT_WINDOW" ]] || return 0
182
-
183
- if [[ "$DRY_RUN" -eq 1 ]]; then
184
- echo "[dry-run] (--claude-compact-window) set env.CLAUDE_CODE_AUTO_COMPACT_WINDOW=$CLAUDE_COMPACT_WINDOW in $user_settings"
185
- return
186
- fi
187
-
188
- [[ -f "$user_settings" ]] || { warn "(--claude-compact-window) $user_settings missing — skipped"; return; }
189
- jq empty "$user_settings" 2>/dev/null || { err "(--claude-compact-window) $user_settings is not valid JSON — skipped"; return; }
190
- if ! jq --arg w "$CLAUDE_COMPACT_WINDOW" '.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = $w' "$user_settings" > "$user_settings.tmp"; then
191
- rm -f "$user_settings.tmp"
192
- err "(--claude-compact-window) jq edit failed — settings unchanged"
193
- return
194
- fi
195
- mv "$user_settings.tmp" "$user_settings"
196
- log "Compact window: set to $CLAUDE_COMPACT_WINDOW tokens in deployed settings (SoT and model unchanged; flag-less sync reverts)"
197
- }
198
-
199
- # Deploy-time modifier (--claude-permissive): for disposable sandboxes/containers.
200
- # Empties permissions.ask and permissions.deny in the DEPLOYED settings so no
201
- # rule prompts or blocks — git push drops out of ask and is covered by the
202
- # existing Bash(git *) allow rule, so commits and pushes run unattended. The SoT
203
- # arrays are untouched; a later flag-less sync re-unions them back in.
204
- claude::sync_permissive() {
205
- local user_settings="${CLAUDE_DIR:-$HOME/.claude}/settings.json"
206
-
207
- [[ "$CLAUDE_PERMISSIVE" -eq 1 ]] || return 0
208
-
209
- if [[ "$DRY_RUN" -eq 1 ]]; then
210
- echo "[dry-run] (--claude-permissive) empty permissions.ask and permissions.deny in $user_settings"
211
- return
212
- fi
213
-
214
- [[ -f "$user_settings" ]] || { warn "(--claude-permissive) $user_settings missing — skipped"; return; }
215
- jq empty "$user_settings" 2>/dev/null || { err "(--claude-permissive) $user_settings is not valid JSON — skipped"; return; }
216
- if ! jq '.permissions.ask = [] | .permissions.deny = []' "$user_settings" > "$user_settings.tmp"; then
217
- rm -f "$user_settings.tmp"
218
- err "(--claude-permissive) jq edit failed — settings unchanged"
219
- return
220
- fi
221
- mv "$user_settings.tmp" "$user_settings"
222
- log "Permissive mode: permissions.ask/deny emptied in deployed settings (sandbox use; SoT unchanged)"
223
- }
224
-
225
- # Deploy-time modifier (--claude-model=<m>): set the DEPLOYED model selection.
226
- # Same contract as the other modifiers: only ~/.claude/settings.json changes,
227
- # the SoT keeps its own pin, and a later flag-less sync restores the SoT value
228
- # via the repo-wins merge. 'default' deletes the key so the account default
229
- # applies. Also callable standalone (docks-kit model claude <m>) — hence the
230
- # ${CLAUDE_DIR:-} fallback, since claude::sync may not have run.
231
- claude::sync_model() {
232
- local user_settings="${CLAUDE_DIR:-$HOME/.claude}/settings.json"
233
-
234
- [[ -n "$CLAUDE_MODEL" ]] || return 0
235
-
236
- if [[ "$DRY_RUN" -eq 1 ]]; then
237
- if [[ "$CLAUDE_MODEL" == "default" ]]; then
238
- echo "[dry-run] (--claude-model) delete .model in $user_settings (account default applies)"
239
- else
240
- echo "[dry-run] (--claude-model) set .model=$CLAUDE_MODEL in $user_settings"
241
- fi
242
- return
243
- fi
244
-
245
- [[ -f "$user_settings" ]] || { warn "(--claude-model) $user_settings missing — skipped"; return; }
246
- jq empty "$user_settings" 2>/dev/null || { err "(--claude-model) $user_settings is not valid JSON — skipped"; return; }
247
- if [[ "$CLAUDE_MODEL" == "default" ]]; then
248
- jq 'del(.model)' "$user_settings" > "$user_settings.tmp"
249
- else
250
- jq --arg m "$CLAUDE_MODEL" '.model = $m' "$user_settings" > "$user_settings.tmp"
251
- fi || {
252
- rm -f "$user_settings.tmp"
253
- err "(--claude-model) jq edit failed — settings unchanged"
254
- return
255
- }
256
- mv "$user_settings.tmp" "$user_settings"
257
- log "Model: deployed settings model set to $CLAUDE_MODEL (SoT unchanged; flag-less sync reverts)"
258
- }
259
-
260
- claude::sync_claude_json() {
261
- local claude_json="$HOME/.claude.json"
262
- local mcp_sot="$REPO_DIR/SoT/.claude/mcp-servers.json"
263
- local jq_args=() filter='.showTurnDuration = true' have_mcp=0
264
-
265
- # settings.json cannot hold mcpServers (schema rejects it), so user-scoped MCP
266
- # servers the kit manages live here in ~/.claude.json. Merge is additive:
267
- # `(.mcpServers // {}) * SoT` — the user's own servers survive, SoT wins per
268
- # server key. Dropping a server from the SoT file does NOT remove it (additive
269
- # by default, like every other sync layer).
270
- if [[ -f "$mcp_sot" ]] && jq empty "$mcp_sot" 2>/dev/null; then
271
- have_mcp=1
272
- jq_args=(--slurpfile mcp "$mcp_sot")
273
- filter+=' | .mcpServers = ((.mcpServers // {}) * ($mcp[0].mcpServers // {}))'
274
- fi
275
-
276
- if [[ "$DRY_RUN" -eq 1 ]]; then
277
- echo "[dry-run] set showTurnDuration=true in ~/.claude.json"
278
- [[ "$have_mcp" -eq 1 ]] && echo "[dry-run] merge mcpServers from SoT/.claude/mcp-servers.json into ~/.claude.json"
279
- return
280
- fi
281
-
282
- if [[ -f "$claude_json" ]]; then
283
- if ! jq empty "$claude_json" 2>/dev/null; then
284
- err "Skipping ~/.claude.json edit: not valid JSON. Fix or delete it."
285
- return
286
- fi
287
- if ! jq "${jq_args[@]+"${jq_args[@]}"}" "$filter" "$claude_json" > "$claude_json.tmp"; then
288
- rm -f "$claude_json.tmp"
289
- err "jq edit of ~/.claude.json failed — file unchanged"
290
- return
291
- fi
292
- mv "$claude_json.tmp" "$claude_json"
293
- else
294
- jq -n "${jq_args[@]+"${jq_args[@]}"}" "{} | $filter" > "$claude_json"
295
- fi
296
- log "~/.claude.json updated (showTurnDuration$([[ "$have_mcp" -eq 1 ]] && printf ', mcpServers'))"
297
- }
298
-
299
- # Ensure ENABLE_CLAUDEAI_MCP_SERVERS=false is exported as a REAL shell env var.
300
- # This is the only working way to suppress claude.ai cloud connectors (Figma,
301
- # Drive, Gmail, ...): they're fetched from the account at session start and
302
- # IGNORE the settings.json `env` block (applied too late), so the var must be
303
- # present in the process before `claude` launches — i.e. in the shell rc.
304
- # Surgical: disables ONLY claude.ai connectors (MCP source #5 in Claude Code's
305
- # scope hierarchy). Local/project/user/plugin servers (supabase, n8n, .mcp.json)
306
- # are untouched. Idempotent + non-clobbering: if the var is already set in any
307
- # common rc (any value), it's left as-is — set it to `true` yourself to keep
308
- # connectors. Multi-platform: targets ~/.zshrc (zsh) or ~/.bashrc (bash),
309
- # ~/.profile otherwise.
310
- claude::sync_connector_env() {
311
- local line="export ENABLE_CLAUDEAI_MCP_SERVERS=false"
312
- local marker="# docks-kit: disable claude.ai cloud MCP connectors (set =true to keep them)"
313
- local -a candidates=("$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.profile" "$HOME/.zshenv")
314
- local f target shell_name
315
-
316
- for f in "${candidates[@]}"; do
317
- if [[ -f "$f" ]] && grep -q 'ENABLE_CLAUDEAI_MCP_SERVERS' "$f" 2>/dev/null; then
318
- if [[ "$DRY_RUN" -eq 1 ]]; then
319
- echo "[dry-run] ENABLE_CLAUDEAI_MCP_SERVERS already in $f — would skip"
320
- else
321
- log "claude.ai connectors: ENABLE_CLAUDEAI_MCP_SERVERS already set in $f (left as-is)"
322
- fi
323
- return
324
- fi
325
- done
326
-
327
- shell_name="$(basename "${SHELL:-bash}")"
328
- case "$shell_name" in
329
- zsh) target="$HOME/.zshrc" ;;
330
- bash) target="$HOME/.bashrc" ;;
331
- *) target="$HOME/.profile" ;;
332
- esac
333
-
334
- if [[ "$DRY_RUN" -eq 1 ]]; then
335
- echo "[dry-run] append 'export ENABLE_CLAUDEAI_MCP_SERVERS=false' to $target"
336
- return
337
- fi
338
-
339
- printf '\n%s\n%s\n' "$marker" "$line" >> "$target"
340
- log "claude.ai connectors disabled via $target (start a new shell to apply)"
341
- }
342
-
343
- # --- Removed-artifact pruning -------------------------------------------------
344
- # Declarative manifest of kit-owned artifacts the kit no longer ships. The
345
- # default sync is additive (the jq merge keeps user-only keys; cp -R never
346
- # deletes), so config the kit dropped in an older version would otherwise
347
- # linger forever on an already-synced machine. `claude::sync_removals` prunes
348
- # the items below on every sync.
349
- #
350
- # NARROW exception to "additive by default": entries here are force-removed from
351
- # EVERY synced ~/.claude. List kit-owned keys the kit used to set and has since
352
- # dropped — they are pruned from the kit-managed settings.json. A deliberate
353
- # per-machine override of any of these belongs in settings.local.json, which
354
- # sync never touches. Do NOT list a key the kit never owned (a user's custom env
355
- # vars, mcpServers, theme) — the additive merge already preserves those.
356
- # hooks hook scripts under ~/.claude/hooks/ to delete (the matching
357
- # settings.json hook entry is already dropped by the SoT
358
- # settings merge, which replaces .hooks wholesale)
359
- # files other paths under ~/.claude/ to delete
360
- # settingsKeys dotted key paths to del() from ~/.claude/settings.json
361
- # claudeJsonKeys dotted key paths to del() from ~/.claude.json
362
- claude::_removed_manifest() {
363
- cat <<'JSON'
364
- {
365
- "hooks": ["disable-claudeai-connectors.sh"],
366
- "files": ["alert_bubble.mp3"],
367
- "settingsKeys": [
368
- "showTurnDuration",
369
- "env.CLAUDE_CODE_SUBAGENT_MODEL",
370
- "env.ANTHROPIC_DEFAULT_OPUS_MODEL",
371
- "env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE",
372
- "env.CLAUDE_CODE_DISABLE_1M_CONTEXT",
373
- "env.CLAUDE_CODE_FORK_SUBAGENT",
374
- "env.CLAUDE_CODE_EFFORT_LEVEL"
375
- ],
376
- "claudeJsonKeys": []
377
- }
378
- JSON
379
- }
380
-
381
- # Delete dotted key paths from a JSON file via jq delpaths. Echoes the count of
382
- # keys that were actually present (and removed); echoes 0 when the file is
383
- # missing/invalid or no listed key exists. Honors DRY_RUN (counts, no write).
384
- # delpaths ignores absent paths, so this is idempotent.
385
- claude::_prune_json_keys() {
386
- local file="$1" keys="$2" present
387
- { [[ "$keys" == "[]" || ! -f "$file" ]] || ! jq empty "$file" 2>/dev/null; } && { echo 0; return; }
388
- present=$(jq --argjson k "$keys" '. as $doc | [ $k[] | split(".") as $p | select($doc | getpath($p) != null) ] | length' "$file" 2>/dev/null || echo 0)
389
- [[ "$present" -gt 0 ]] || { echo 0; return; }
390
- if [[ "$DRY_RUN" -eq 0 ]]; then
391
- if jq --argjson k "$keys" 'delpaths([ $k[] | split(".") ])' "$file" > "$file.tmp" 2>/dev/null; then
392
- mv "$file.tmp" "$file"
393
- else
394
- rm -f "$file.tmp"; warn "Failed to prune stale keys from $file"; echo 0; return
395
- fi
396
- fi
397
- echo "$present"
398
- }
399
-
400
- claude::sync_removals() {
401
- local manifest name path hooks_removed=0 files_removed=0 skeys cjkeys
402
-
403
- manifest="$(claude::_removed_manifest)"
404
-
405
- while IFS= read -r name; do
406
- [[ -z "$name" || ! -e "$CLAUDE_DIR/hooks/$name" ]] && continue
407
- if [[ "$DRY_RUN" -eq 1 ]]; then
408
- echo "[dry-run] rm $CLAUDE_DIR/hooks/$name"
409
- else
410
- rm -f "$CLAUDE_DIR/hooks/$name"; hooks_removed=$((hooks_removed + 1))
411
- fi
412
- done < <(jq -r '.hooks[]? // empty' <<<"$manifest")
413
-
414
- while IFS= read -r path; do
415
- [[ -z "$path" || ! -e "$CLAUDE_DIR/$path" ]] && continue
416
- if [[ "$DRY_RUN" -eq 1 ]]; then
417
- echo "[dry-run] rm $CLAUDE_DIR/$path"
418
- else
419
- rm -f "$CLAUDE_DIR/$path"; files_removed=$((files_removed + 1))
420
- fi
421
- done < <(jq -r '.files[]? // empty' <<<"$manifest")
422
-
423
- skeys=$(claude::_prune_json_keys "$CLAUDE_DIR/settings.json" "$(jq -c '.settingsKeys // []' <<<"$manifest")")
424
- cjkeys=$(claude::_prune_json_keys "$HOME/.claude.json" "$(jq -c '.claudeJsonKeys // []' <<<"$manifest")")
425
-
426
- if [[ "$DRY_RUN" -eq 1 ]]; then
427
- [[ "$skeys" -gt 0 ]] && echo "[dry-run] del $skeys stale key(s) from $CLAUDE_DIR/settings.json"
428
- [[ "$cjkeys" -gt 0 ]] && echo "[dry-run] del $cjkeys stale key(s) from $HOME/.claude.json"
429
- # Explicit 0: the trailing [[ ]] tests above leave $?=1 when their counts are
430
- # 0, and a bare `return` would propagate that — aborting sync under `set -e`.
431
- return 0
432
- fi
433
-
434
- if [[ $((hooks_removed + files_removed + skeys + cjkeys)) -gt 0 ]]; then
435
- log "Pruned stale artifacts (hooks: $hooks_removed, files: $files_removed, settings keys: $skeys, claude.json keys: $cjkeys)"
436
- fi
437
- }
438
-
439
- # Thin facade around the `claude` CLI plugin pipeline. Centralizes the kit's
440
- # coupling so renaming the CLI or changing the binary lookup is a one-place
441
- # edit instead of touching 6 call sites across 5 plugin-pipeline helpers.
442
- # `command` prefix avoids potential alias/function interference.
443
- claude::_cli() {
444
- command claude "$@"
445
- }
446
-
447
- # Pass 1 — install any marketplace in SoT that is not yet known. Echoes
448
- # "<added> <failed>" on stdout (bash 3.2-portable counter return; namerefs
449
- # require bash 4.3+ and would break macOS /bin/bash).
450
- claude::_plugins_add_marketplaces() {
451
- local repo_settings="$1" known_marketplaces="$2"
452
- local mp_name mp_repo added=0 failed=0
453
-
454
- while IFS=$'\t' read -r mp_name mp_repo; do
455
- [[ -z "$mp_name" ]] && continue
456
- if [[ -f "$known_marketplaces" ]] && jq -e --arg n "$mp_name" '.[$n]' "$known_marketplaces" >/dev/null 2>&1; then
457
- continue
458
- fi
459
- if claude::_cli plugin marketplace add "$mp_repo" >/dev/null 2>&1; then
460
- added=$((added + 1))
461
- else
462
- warn "Failed to add marketplace: $mp_name ($mp_repo)"
463
- failed=$((failed + 1))
464
- fi
465
- done < <(jq -r '.extraKnownMarketplaces // {} | to_entries[] | "\(.key)\t\(.value.source.repo)"' "$repo_settings")
466
-
467
- echo "$added $failed"
468
- }
469
-
470
- # True when installed_plugins.json records a USER-scope install for the plugin.
471
- # Registry values are per-scope record arrays (Claude Code >= 2.1.198); the
472
- # type guard tolerates the older single-object form. A project/local-scope
473
- # record does NOT count — the kit's tri-state contract requires user scope.
474
- claude::_plugin_user_scope_installed() {
475
- local installed_plugins="$1" plugin_id="$2"
476
- [[ -f "$installed_plugins" ]] || return 1
477
- jq -e --arg n "$plugin_id" \
478
- '.plugins[$n] // empty | (if type == "array" then . else [.] end) | any(.scope? == "user")' \
479
- "$installed_plugins" >/dev/null 2>&1
480
- }
481
-
482
- # Pass 2 — install any SoT-enabled plugin not yet installed at user scope.
483
- claude::_plugins_install() {
484
- local repo_settings="$1" installed_plugins="$2"
485
- local plugin_id added=0 failed=0 refreshed=0
486
-
487
- while IFS= read -r plugin_id; do
488
- [[ -z "$plugin_id" ]] && continue
489
- if claude::_plugin_user_scope_installed "$installed_plugins" "$plugin_id"; then
490
- continue
491
- fi
492
- # Stale-manifest guard: an already-cloned marketplace may predate a plugin
493
- # later added to it, and pass 3's manifest refresh runs after this pass —
494
- # too late for the install. Refresh once before the first install attempt.
495
- if [[ "$refreshed" -eq 0 ]]; then
496
- claude::_cli plugin marketplace update >/dev/null 2>&1 || true
497
- refreshed=1
498
- fi
499
- if claude::_cli plugin install "$plugin_id" >/dev/null 2>&1; then
500
- added=$((added + 1))
501
- else
502
- warn "Failed to install plugin: $plugin_id"
503
- failed=$((failed + 1))
504
- fi
505
- done < <(jq -r '.enabledPlugins // {} | keys[]' "$repo_settings")
506
-
507
- echo "$added $failed"
508
- }
509
-
510
- # Pass 3 — refresh every installed plugin. Failures are not counted (best
511
- # effort); only the "Successfully updated" classification bumps the counter.
512
- claude::_plugins_update() {
513
- local installed_plugins="$1"
514
- local plugin_id out updated=0
515
-
516
- claude::_cli plugin marketplace update >/dev/null 2>&1 || true
517
-
518
- if [[ -f "$installed_plugins" ]]; then
519
- while IFS= read -r plugin_id; do
520
- [[ -z "$plugin_id" ]] && continue
521
- out=$(claude::_cli plugin update "$plugin_id" 2>&1 || true)
522
- if [[ "$out" == *"Successfully updated"* ]]; then
523
- updated=$((updated + 1))
524
- fi
525
- done < <(jq -r '.plugins | keys[]' "$installed_plugins")
526
- fi
527
-
528
- echo "$updated 0"
529
- }
530
-
531
- # Pass 4 — uninstall any installed plugin no longer declared in SoT. Gated by
532
- # PRUNE by the caller; helper assumes it should run.
533
- claude::_plugins_uninstall() {
534
- local repo_settings="$1" installed_plugins="$2"
535
- local plugin_id removed=0 failed=0
536
-
537
- if [[ -f "$installed_plugins" ]]; then
538
- while IFS= read -r plugin_id; do
539
- [[ -z "$plugin_id" ]] && continue
540
- if ! jq -e --arg n "$plugin_id" '.enabledPlugins | has($n)' "$repo_settings" >/dev/null 2>&1; then
541
- claude::_plugin_user_scope_installed "$installed_plugins" "$plugin_id" || continue
542
- if claude::_cli plugin uninstall -y --scope user "$plugin_id" >/dev/null 2>&1; then
543
- removed=$((removed + 1))
544
- else
545
- warn "Failed to uninstall plugin: $plugin_id"
546
- failed=$((failed + 1))
547
- fi
548
- fi
549
- done < <(jq -r '.plugins | keys[]' "$installed_plugins")
550
- fi
551
-
552
- echo "$removed $failed"
553
- }
554
-
555
- # Pass 5 — remove any extra marketplace not declared in SoT.
556
- # claude-plugins-official is built-in and never removed (protection contract).
557
- claude::_plugins_remove_marketplaces() {
558
- local repo_settings="$1" known_marketplaces="$2"
559
- local mp_name removed=0 failed=0
560
-
561
- if [[ -f "$known_marketplaces" ]]; then
562
- while IFS= read -r mp_name; do
563
- [[ -z "$mp_name" ]] && continue
564
- [[ "$mp_name" == "claude-plugins-official" ]] && continue
565
- if ! jq -e --arg n "$mp_name" '.extraKnownMarketplaces[$n]' "$repo_settings" >/dev/null 2>&1; then
566
- if claude::_cli plugin marketplace remove "$mp_name" >/dev/null 2>&1; then
567
- removed=$((removed + 1))
568
- else
569
- warn "Failed to remove marketplace: $mp_name"
570
- failed=$((failed + 1))
571
- fi
572
- fi
573
- done < <(jq -r '. | keys[]' "$known_marketplaces")
574
- fi
575
-
576
- echo "$removed $failed"
577
- }
578
-
579
- # Pass 6 — enforce SoT enabled-state in ~/.claude/settings.json. `claude plugin
580
- # install` (pass 2) enables at user scope as a side effect. A plain jq rewrite of
581
- # that back to false loses a race: the CLI owns settings.json and reverts the
582
- # external edit, so the false value only sticks on a SECOND sync (once the plugin
583
- # is already installed and pass 2 skips it). Fix: disable SoT-false plugins
584
- # through the CLI's own `plugin disable` verb — authoritative, sticks in one pass
585
- # — THEN jq-normalize so every SoT-declared value wins and user-only
586
- # enabledPlugins entries are preserved. The disable is guarded on the plugin
587
- # being currently enabled (re-disabling an already-disabled plugin is a CLI
588
- # error), which keeps steady-state syncs quiet. If `plugin disable` is missing or
589
- # fails the jq-normalize still runs, degrading to the old two-sync behavior
590
- # rather than breaking. Idempotent — safe to run every sync.
591
- claude::_plugins_reassert_enabled_state() {
592
- local repo_settings="$1" user_settings="$2" plugin_id
593
- [[ -f "$user_settings" ]] || return 0
594
-
595
- while IFS= read -r plugin_id; do
596
- [[ -z "$plugin_id" ]] && continue
597
- jq -e --arg n "$plugin_id" '.enabledPlugins[$n] == true' "$user_settings" >/dev/null 2>&1 || continue
598
- claude::_cli plugin disable "$plugin_id" >/dev/null 2>&1 \
599
- || warn "Failed to disable SoT-false plugin: $plugin_id (will retry next sync)"
600
- done < <(jq -r '.enabledPlugins // {} | to_entries[] | select(.value == false) | .key' "$repo_settings")
601
-
602
- if jq -s '.[0].enabledPlugins as $sot | .[1]
603
- | .enabledPlugins = ((.enabledPlugins // {}) * $sot)' \
604
- "$repo_settings" "$user_settings" > "$user_settings.tmp"; then
605
- mv "$user_settings.tmp" "$user_settings"
606
- else
607
- rm -f "$user_settings.tmp"
608
- warn "enabledPlugins re-assert failed — false-keyed plugins may be left enabled"
609
- fi
610
- }
611
-
612
- claude::sync_plugins() {
613
- local repo_settings="$REPO_DIR/SoT/.claude/settings.json"
614
- local known_marketplaces="$CLAUDE_DIR/plugins/known_marketplaces.json"
615
- local installed_plugins="$CLAUDE_DIR/plugins/installed_plugins.json"
616
- local added_mp added_pl updated_pl removed_pl=0 removed_mp=0
617
- local f1 f2 f3 f4=0 f5=0 failed
618
-
619
- if [[ "$DRY_RUN" -eq 1 ]]; then
620
- echo "[dry-run] bootstrap + update plugin marketplaces + plugins from SoT"
621
- if [[ "$PRUNE" -eq 1 ]]; then
622
- echo "[dry-run] (--prune) would also uninstall plugins not in SoT and remove extra marketplaces"
623
- fi
624
- return
625
- fi
626
-
627
- if ! command -v claude >/dev/null 2>&1; then
628
- warn "claude CLI not in PATH — skipping plugin reconcile (run /plugin marketplace add + /plugin install manually)"
629
- return
630
- fi
631
-
632
- read -r added_mp f1 < <(claude::_plugins_add_marketplaces "$repo_settings" "$known_marketplaces")
633
- read -r added_pl f2 < <(claude::_plugins_install "$repo_settings" "$installed_plugins")
634
- read -r updated_pl f3 < <(claude::_plugins_update "$installed_plugins")
635
- if [[ "$PRUNE" -eq 1 ]]; then
636
- read -r removed_pl f4 < <(claude::_plugins_uninstall "$repo_settings" "$installed_plugins")
637
- read -r removed_mp f5 < <(claude::_plugins_remove_marketplaces "$repo_settings" "$known_marketplaces")
638
- fi
639
- claude::_plugins_reassert_enabled_state "$repo_settings" "$CLAUDE_DIR/settings.json"
640
- failed=$((f1 + f2 + f3 + f4 + f5))
641
-
642
- if [[ "$added_mp" -gt 0 || "$added_pl" -gt 0 || "$updated_pl" -gt 0 || "$removed_pl" -gt 0 || "$removed_mp" -gt 0 ]]; then
643
- log "Plugins synced (marketplaces: +$added_mp -$removed_mp, plugins: +$added_pl ~$updated_pl -$removed_pl)"
644
- else
645
- log "Plugins already in sync"
646
- fi
647
- if [[ "$failed" -gt 0 ]]; then
648
- warn "$failed plugin operation(s) failed — re-run sync or install manually"
649
- fi
650
- }
651
-
652
- # --- Optional (flag-gated) plugins --------------------------------------------
653
- # supabase and n8n are kept OUT of the SoT entirely — neither key is in
654
- # enabledPlugins, so a flag-less sync installs, loads, and enables neither (an
655
- # absent plugin is simply not installed; only `--prune` uninstalls one already
656
- # present). n8n's marketplace is dropped from the SoT too; supabase's is the
657
- # built-in claude-plugins-official one, which stays. `--claude-plugin=<name>`
658
- # opts one in on THIS machine — add its marketplace when third-party, install it,
659
- # enable it — a sticky, install-once opt-in that survives later flag-less syncs
660
- # (both keys are absent from the SoT, so the pass-6 reassert never touches them).
661
- claude::_enable_optional_plugin() {
662
- local plugin_id="$1" marketplace_repo="$2"
663
- local installed_plugins="$CLAUDE_DIR/plugins/installed_plugins.json"
664
- local known_marketplaces="$CLAUDE_DIR/plugins/known_marketplaces.json"
665
- local mp_name="${plugin_id##*@}"
666
-
667
- # Third-party plugins need their marketplace known first; official plugins
668
- # (claude-plugins-official) are built in, so marketplace_repo is empty.
669
- if [[ -n "$marketplace_repo" ]] \
670
- && ! { [[ -f "$known_marketplaces" ]] && jq -e --arg n "$mp_name" '.[$n]' "$known_marketplaces" >/dev/null 2>&1; }; then
671
- claude::_cli plugin marketplace add "$marketplace_repo" >/dev/null 2>&1 \
672
- || { warn "Failed to add marketplace $marketplace_repo for $plugin_id"; return; }
673
- fi
674
-
675
- if ! claude::_plugin_user_scope_installed "$installed_plugins" "$plugin_id"; then
676
- claude::_cli plugin install "$plugin_id" >/dev/null 2>&1 \
677
- || { warn "Failed to install optional plugin $plugin_id"; return; }
678
- fi
679
-
680
- # `plugin install` enables as a side effect, but call `plugin enable`
681
- # explicitly so an already-installed-but-disabled plugin (e.g. supabase left
682
- # `false` on a machine by an earlier kit version) also flips on. The CLI owns
683
- # settings.json, so this persists where a plain jq edit would lose the race.
684
- if claude::_cli plugin enable "$plugin_id" >/dev/null 2>&1; then
685
- log "Optional plugin opted in: $plugin_id"
686
- else
687
- warn "Failed to enable optional plugin $plugin_id"
688
- fi
689
- }
690
-
691
- claude::sync_optional_plugins() {
692
- [[ -n "$CLAUDE_PLUGINS" ]] || return 0
693
-
694
- if [[ "$DRY_RUN" -eq 1 ]]; then
695
- common::claude_plugin_wanted supabase && echo "[dry-run] (--claude-plugin=supabase) install + enable supabase@claude-plugins-official in deployed settings"
696
- common::claude_plugin_wanted n8n && echo "[dry-run] (--claude-plugin=n8n) add czlonkowski/n8n-skills marketplace + install + enable n8n-mcp-skills@n8n-mcp-skills"
697
- return 0
698
- fi
699
-
700
- if ! command -v claude >/dev/null 2>&1; then
701
- warn "claude CLI not in PATH — cannot opt in optional plugins (--claude-plugin)"
702
- return
703
- fi
704
-
705
- if common::claude_plugin_wanted supabase; then
706
- claude::_enable_optional_plugin "supabase@claude-plugins-official" ""
707
- fi
708
- if common::claude_plugin_wanted n8n; then
709
- claude::_enable_optional_plugin "n8n-mcp-skills@n8n-mcp-skills" "czlonkowski/n8n-skills"
710
- fi
711
- return 0
712
- }
713
-
714
- # The php-lsp / typescript-lsp plugins only register lspServers config (it
715
- # ships in the marketplace manifest) — the language-server binaries are
716
- # separate npm globals, without which the plugins are silent no-ops. Gated on
717
- # key presence in SoT enabledPlugins (true OR false: false-keyed plugins can
718
- # be enabled per-project, so the binary must still exist).
719
- # npm install spec for an LSP tool, pinned to its toolchain.json `verified`
720
- # version when declared: <manifest tool key> <npm package name>.
721
- claude::_lsp_pkg() {
722
- local tool="$1" pkg="$2" v
723
- v="$(toolchain::field "$tool" verified 2>/dev/null || true)"
724
- printf '%s' "$pkg${v:+@$v}"
725
- }
726
-
727
- claude::sync_lsp_servers() {
728
- local sot_settings="$REPO_DIR/SoT/.claude/settings.json"
729
- local missing=()
730
-
731
- jq -e '.enabledPlugins | has("php-lsp@claude-plugins-official") or has("typescript-lsp@claude-plugins-official")' \
732
- "$sot_settings" >/dev/null 2>&1 || return 0
733
-
734
- # Install specs are pinned to the manifest `verified` versions (supply-chain:
735
- # no floating npm globals). Manifest tool key first, npm package name second.
736
- if jq -e '.enabledPlugins | has("php-lsp@claude-plugins-official")' "$sot_settings" >/dev/null 2>&1; then
737
- command -v intelephense >/dev/null 2>&1 || missing+=("$(claude::_lsp_pkg intelephense intelephense)")
738
- fi
739
- if jq -e '.enabledPlugins | has("typescript-lsp@claude-plugins-official")' "$sot_settings" >/dev/null 2>&1; then
740
- command -v typescript-language-server >/dev/null 2>&1 || missing+=("$(claude::_lsp_pkg typescript-language-server typescript-language-server)")
741
- command -v tsc >/dev/null 2>&1 || missing+=("$(claude::_lsp_pkg tsc typescript)")
742
- fi
743
-
744
- if [[ ${#missing[@]} -eq 0 ]]; then
745
- if [[ "$DRY_RUN" -eq 1 ]]; then
746
- echo "[dry-run] LSP server binaries present"
747
- else
748
- log "LSP server binaries present"
749
- fi
750
- return
751
- fi
752
-
753
- if [[ "$DRY_RUN" -eq 1 ]]; then
754
- echo "[dry-run] would install: npm install -g ${missing[*]}"
755
- return
756
- fi
757
-
758
- if ! command -v npm >/dev/null 2>&1; then
759
- warn "npm not found — cannot install LSP servers (${missing[*]}); the php-lsp/typescript-lsp plugins stay no-ops. Install Node.js, then re-run sync."
760
- return
761
- fi
762
-
763
- log "Installing LSP servers via npm: ${missing[*]}..."
764
- if npm install -g "${missing[@]}" >/dev/null 2>&1; then
765
- log "LSP servers installed (${missing[*]})"
766
- else
767
- warn "npm install -g ${missing[*]} failed. Try manually: npm install -g ${missing[*]}"
768
- fi
769
- }
770
-
771
- # toolchain::ensure callback: download-then-run install/upgrade of RTK, with an
772
- # optional exact-version pin via the installer's RTK_VERSION env (empty = latest).
773
- # RTK runs as a PreToolUse bash hook, so unverified upgrades are gated by the
774
- # toolchain verified pin (SoT/toolchain.json) — review releases before bumping it.
775
- claude::_rtk_install() {
776
- local mode="$1" version="${2:-}"
777
- local tmp_rtk_installer
778
- # Pinned installs fetch the installer from the version TAG, not master —
779
- # otherwise the pinned binary is still bootstrapped by a mutable script.
780
- local installer_ref="refs/heads/master"
781
- [[ -n "$version" ]] && installer_ref="refs/tags/v$version"
782
-
783
- [[ "$mode" == "upgrade" ]] && log "Upgrading RTK${version:+ to $version}..." || warn "RTK not found. Installing${version:+ $version}..."
784
- tmp_rtk_installer=$(mktemp 2>/dev/null || echo "/tmp/rtk-install-$$.sh")
785
- curl -fsSL "https://raw.githubusercontent.com/rtk-ai/rtk/$installer_ref/install.sh" -o "$tmp_rtk_installer" \
786
- && RTK_VERSION="${version:+v$version}" bash "$tmp_rtk_installer"
787
- rm -f "$tmp_rtk_installer"
788
- export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
789
- if command -v rtk >/dev/null 2>&1; then
790
- log "RTK ready ($(rtk --version 2>/dev/null || echo 'version unknown'))"
791
- else
792
- err "RTK install failed. Install manually: https://github.com/rtk-ai/rtk"
793
- return 1
794
- fi
795
- }
796
-
797
- claude::sync_rtk() {
798
- if [[ "${SKIP_RTK:-0}" -eq 1 ]]; then
799
- # The @RTK.md import strip lives in claude::sync_claude_md (which runs
800
- # after this and would clobber a strip done here).
801
- warn "Skipping RTK (--skip-rtk)"
802
- return
803
- fi
804
-
805
- # `|| warn` (not a plain call): under set -e a failed install would otherwise
806
- # abort the whole sync — and rtk runs FIRST, so nothing would get deployed.
807
- toolchain::ensure rtk claude::_rtk_install || warn "RTK bootstrap failed — continuing sync without it"
808
-
809
- command -v rtk >/dev/null 2>&1 || return 0
810
- if [[ ! -f "$CLAUDE_DIR/RTK.md" ]]; then
811
- if [[ "$DRY_RUN" -eq 1 ]]; then
812
- echo "[dry-run] rtk init --global (RTK.md missing; runs before the settings merge, which normalizes rtk's settings rewrite)"
813
- return
814
- fi
815
- rtk init --global
816
- log "RTK initialized (RTK.md generated; the following settings merge re-asserts the SoT hooks)"
817
- elif [[ "$DRY_RUN" -eq 0 ]]; then
818
- log "RTK already initialized"
819
- fi
820
- }
821
-
822
- claude::summary() {
823
- local hook_count plugin_count
824
-
825
- [[ "${CLAUDE_SYNCED:-0}" -eq 1 ]] || return
826
- echo "Claude: ${CLAUDE_DIR:-$HOME/.claude}"
827
- if [[ "$DRY_RUN" -eq 0 ]]; then
828
- hook_count=$(find "${CLAUDE_DIR:-$HOME/.claude}/hooks" -maxdepth 1 -name '*.sh' 2>/dev/null | wc -l)
829
- echo "Hooks: $hook_count scripts"
830
- if command -v rtk >/dev/null 2>&1; then
831
- echo "RTK: $(rtk --version 2>/dev/null || echo 'installed')"
832
- else
833
- echo "RTK: not installed"
834
- fi
835
- if command -v claude >/dev/null 2>&1; then
836
- plugin_count=$(jq -r '.plugins | keys | length' "${CLAUDE_DIR:-$HOME/.claude}/plugins/installed_plugins.json" 2>/dev/null || echo 0)
837
- echo "Plugins: $plugin_count installed (from SoT enabledPlugins + Anthropic auto-installs)"
838
- fi
839
- fi
840
- }
841
-
842
- claude::next_steps() {
843
- [[ "${CLAUDE_SYNCED:-0}" -eq 1 ]] || return
844
- echo "In a Claude Code session, run /reload-plugins to pick up newly installed plugins."
845
- echo "Restart Claude Code for hook/env-var changes to take effect."
846
- }