wicked-garden 12.32.1 → 12.32.2

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.
@@ -1 +1 @@
1
- {"name": "wicked-garden", "owner": {"name": "Mike Parcewski"}, "plugins": [{"name": "wicked-garden", "source": "./", "description": "Your coding agent already plans and swarms \u2014 Wicked Garden is the curated toolkit for what it can't: proof-not-claims gates, code links grep can't see, deterministic refactor, cross-session memory, real multi-model second opinions. Don't fight the harness; fill its gaps. The gate needs wicked-vault (a self-contained infra peer, installed directly) and re-derives through garden's built-in engine; estate/bus are opt-in layers (wicked-testing and wicked-brain are retired \u2014 QE ships in-catalog as the qe domain, memory lives in wicked-estate).", "version": "12.32.1"}], "version": "12.32.1"}
1
+ {"name": "wicked-garden", "owner": {"name": "Mike Parcewski"}, "plugins": [{"name": "wicked-garden", "source": "./", "description": "Your coding agent already plans and swarms \u2014 Wicked Garden is the curated toolkit for what it can't: proof-not-claims gates, code links grep can't see, deterministic refactor, cross-session memory, real multi-model second opinions. Don't fight the harness; fill its gaps. The gate needs wicked-vault (a self-contained infra peer, installed directly) and re-derives through garden's built-in engine; estate/bus are opt-in layers (wicked-testing and wicked-brain are retired \u2014 QE ships in-catalog as the qe domain, memory lives in wicked-estate).", "version": "12.32.2"}], "version": "12.32.2"}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-garden",
3
- "version": "12.32.1",
3
+ "version": "12.32.2",
4
4
  "description": "Your coding agent's harness already plans and swarms; Wicked Garden is the curated toolkit for what it can't do alone. Proof, not claims \u2014 gates re-derive 'done' through the internal loom engine (fail-closed, independently attested via wicked-vault), so a self-asserted pass can't lie its way green. Relationships grep can't see \u2014 the wicked-estate code graph + injected bus/dispatch/capability/archetype edges power blast-radius & lineage. Plus deterministic multi-file refactor (wicked-patch), cross-session memory + knowledge (the mem domain over wicked-estate), real multi-model second opinions (jam:council), portable expertise as on-demand skill-refs, and evidence-gated testing (the in-catalog qe domain). It reads the shape of your work to apply the right rigor \u2014 steering, not a pipeline \u2014 then gets out of the way. The compiler stamps a self-contained evidence gate into any repo that runs with no wicked-garden installed. Don't fight the harness; fill its gaps. The evidence gate requires wicked-vault, a self-contained infra peer wicked-garden installs directly; wicked-bus is an opt-in layer \u2014 install what you'll use, the toolkit works without the rest.",
5
5
  "wicked_vault_version": "^0.5.0",
6
6
  "wicked_ledger_version": "^0.4.0",
package/CHANGELOG.md CHANGED
@@ -22,6 +22,11 @@
22
22
  - **Site: the wicked-interactive card tells the document-engine story** (recon DT-12) — wicked-interactive moved planes rather than retiring: it is the foundation's document engine (storage, version lineage, HTML/PDF/PPTX/video rendering) that wicked-crew spawns and proxies, not a standalone visual builder. Install command unchanged (two-step marketplace add + install).
23
23
  - **Backfilled the 12.30.0 and 12.31.0 changelog entries below** (recon DT-17) — the previous release entry was 12.29.1 while npm shipped 12.31.0; the vault-direct-peer entry that sat under [Unreleased] shipped in 12.31.0 and now lives there.
24
24
 
25
+ ## [12.32.2] — 2026-09-10
26
+
27
+ ### Fixed
28
+ - **`install.mjs` honours `CLAUDE_CONFIG_DIR` and `--dry-run`; registration is the installer's job (#1117, fixed in #1118).** `cmdInstall()`/`cmdStatus()` hard-coded `~/.claude/plugins/wicked-garden`, so with `CLAUDE_CONFIG_DIR` pointing at a non-default config dir the copy landed in a dir Claude Code never reads, and `--dry-run` (which wicked-installer passes through) was silently treated as an install. The target is now resolved the way wicked-installer's `install-claude.ts` resolves it: `--claude-home <dir>` (repeatable, also `--claude-home=<dir>`) is the full set → `CLAUDE_CONFIG_DIR` (authoritative whenever the key is present; may list several dirs, split on the platform list separator and `,`, `~`-expanded, de-duplicated; present but naming no directory — `""`, `:,`, blanks — fails closed with exit 2 instead of silently landing in `~/.claude`) → `~/.claude` only when the key is absent; the copy lands under `<dir>/plugins/wicked-garden` for EACH resolved dir and `status` reports per dir (config dir + origin, copy version vs package version, leftover transient dirs; an unreadable or corrupt manifest is a named diagnostic and exit 1). **A copy lands stage → verify → atomic swap:** the plugin is copied into a staging dir the run creates (`plugins/.staging-wicked-garden-<pid>-<hex>`, so no pre-existing symlink can ever be written through), the staged tree is lstat-walked (no symlinks; every directory realpath-contained via `path.relative`, case-insensitive on Windows), then rename(2)'d over `plugins/wicked-garden` — a previous copy passes through `plugins/.old-wicked-garden-<pid>-<hex>` and is removed ONLY after the installed tree passed post-swap verification (walk, containment, manifest parse, version == package); if that fails, the failed tree is set aside as `plugins/.failed-wicked-garden-<pid>-<hex>`, the previous copy is renamed back and re-verified, and the run exits 1 naming the failed check — a rollback that itself fails is never swallowed: `ROLLBACK FAILED: previous install left at …, failed install at …` and exit 3 (on any failure before the swap the staging dir is removed and the previous copy is left intact). `plugins/` and `plugins/wicked-garden` are refused when symlinked; manifests are opened `O_NOFOLLOW` (lstat-then-read on Windows); the installed tree is re-walked and re-verified after the swap. Documented residual: a parent directory swapped between our verify and our use (the same discipline as crew's v3.5). The three transient names are not plugins — a leftover after an interrupted install is safe to delete (README + `status` say so). `--dry-run` prints what would be staged/copied and that `uv sync` would run, writes nothing and **spawns nothing** (not even the `command -v uv` probe — proven under a `child_process` spawn-guard preload), exits 0; flags are accepted before or after the command, and `pack` verbs keep their verbatim pass-through. After a copy the script says so honestly — `copied (unregistered)` — and points at `npx wicked-installer install wicked-garden`, because Claude Code loads plugins through its marketplace registry and registration is owned by the installer (wicked-installer#18); the script deliberately does NOT grow a second registration code path. Argument handling is strict: unknown commands, unknown options and stray arguments exit 2 with usage on stderr (`--help`/`-h` print it on stdout); `--claude-home` never swallows an option in either form (`--claude-home --dry-run`, `--claude-home=--dry-run`, `--claude-home=-x`, blank values are usage errors — write `./-x` for a directory whose name starts with `-`); `--dry-run` is an install/update option — `status` is read-only and rejects it, and `--dry-run`/`--claude-home` given with any other command (`--dry-run pack check`, `--version --dry-run`) are a usage error too, never accepted-and-ignored while the command runs for real. A failed `uv sync` is reported (`WARNING` + the uv error on stderr), the summary is a non-success and the exit code is 1 — the copy still lands and setup retries, but the run never claims success. npm `bin`/`prepublishOnly` behaviour is unchanged. Tests: `tests/test_install_mjs.py` drives the real CLI against a temp HOME + temp `CLAUDE_CONFIG_DIR` with PATH reduced to an empty dir (`uv` unreachable unless a failing sentinel is planted; `tests/fixtures/spawn_guard.cjs` proves dry-run spawns nothing; a doctored package copy whose manifest version disagrees with package.json proves a post-swap failure restores the previous copy byte-for-byte, and the test-only `WICKED_GARDEN_INSTALL_FAULT=rollback-restore` proves the ROLLBACK FAILED path; hermetic; never touches the operator's config dirs).
29
+
25
30
  ## [12.32.1] — 2026-09-09
26
31
 
27
32
  ### Fixed
package/README.md CHANGED
@@ -61,6 +61,14 @@ claude plugins install wicked-garden
61
61
  Or use the family installer — [`npx wicked-installer`](https://www.npmjs.com/package/wicked-installer)
62
62
  installs/updates the whole wicked-\* family (garden, its peers, and the rest).
63
63
 
64
+ > `npx wicked-garden install` makes a bare, **unregistered** copy under `<config-dir>/plugins/wicked-garden`
65
+ > (honours `CLAUDE_CONFIG_DIR` / `--claude-home`, `--dry-run`); registration is
66
+ > `npx wicked-installer install wicked-garden`. The copy is staged in `plugins/.staging-wicked-garden-<pid>-<hex>`
67
+ > and swapped in atomically; a previous copy passes through `plugins/.old-wicked-garden-<pid>-<hex>` and is removed only
68
+ > after the installed tree verified (a tree that fails verification is set aside as `plugins/.failed-wicked-garden-<pid>-<hex>`
69
+ > and the previous copy is put back). Those transient dirs are **not plugins**; a leftover after an interrupted install is
70
+ > safe to delete (`status` lists them).
71
+
64
72
  Then, in a Claude Code session:
65
73
 
66
74
  ```bash
package/install.mjs CHANGED
@@ -1,19 +1,56 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * wicked-garden installer
4
- * Copies plugin files to ~/.claude/plugins/wicked-garden/ and optionally syncs Python deps.
4
+ * Copies plugin files to <config-dir>/plugins/wicked-garden/ and optionally syncs Python deps.
5
+ *
6
+ * The copy is UNREGISTERED: Claude Code loads plugins through its marketplace
7
+ * registry, and registration is wicked-installer's job — run
8
+ * `npx wicked-installer install wicked-garden` afterwards (wicked-installer#18).
5
9
  *
6
10
  * Usage:
7
- * npx wicked-garden install Install or update the plugin
8
- * npx wicked-garden update Same as install
9
- * npx wicked-garden status Show current install state
10
- * npx wicked-garden pack <verb> Third-party pack tooling (check/register/list/…)
11
- * npx wicked-garden --version Print version
11
+ * npx wicked-garden install [options] Install or update the plugin copy
12
+ * npx wicked-garden update [options] Same as install
13
+ * npx wicked-garden status [options] Show the install state per config dir
14
+ * npx wicked-garden pack <verb> Third-party pack tooling (check/register/list/…)
15
+ * npx wicked-garden --version Print version
16
+ * npx wicked-garden --help Print usage
17
+ *
18
+ * Options:
19
+ * --claude-home <dir> Config dir to target (repeatable; default: $CLAUDE_CONFIG_DIR or ~/.claude)
20
+ * — install/update/status
21
+ * --dry-run Print what would be copied/synced, write nothing, spawn nothing
22
+ * — install/update only (status is read-only and rejects it)
23
+ *
24
+ * --claude-home / --dry-run belong to install/update/status only; given with any other
25
+ * command (`--dry-run pack check`, `--version --dry-run`) they are a usage error rather
26
+ * than being silently ignored while the command runs for real. Pack verbs own their flags.
27
+ *
28
+ * How a copy lands (stage → verify → atomic swap): the plugin is copied into a staging
29
+ * directory this run creates — `plugins/.staging-wicked-garden-<pid>-<hex>` — so no
30
+ * pre-existing symlink can ever be written through; the staged tree is lstat-walked
31
+ * (no symlinks, realpath-contained), then swapped into `plugins/wicked-garden` with
32
+ * rename(2); a previous copy is moved to `plugins/.old-wicked-garden-<pid>-<hex>` and is
33
+ * removed ONLY after the installed tree passed post-swap verification (walk, containment,
34
+ * manifest parse, version) — if that fails, the failed tree goes to
35
+ * `plugins/.failed-wicked-garden-<pid>-<hex>`, the previous copy is renamed back and
36
+ * re-verified, and the run exits 1 naming the failed check. Those three transient names
37
+ * are NOT plugins — a leftover after an interrupted install is safe to delete (status lists them).
38
+ *
39
+ * Exit codes: 0 ok · 1 install/status failure (refused symlink, copy/swap error, failed
40
+ * verification with the previous copy restored, unreadable manifest, uv sync
41
+ * failed) · 2 usage (unknown command/option, bad --claude-home value,
42
+ * CLAUDE_CONFIG_DIR set but naming no directory, install-only flag on another
43
+ * command) · 3 ROLLBACK FAILED (a previous copy could not be put back — the
44
+ * diagnostic names where the previous and the failed trees were left).
12
45
  */
13
- import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs";
14
- import { join, dirname } from "node:path";
46
+ import {
47
+ closeSync, constants, cpSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync,
48
+ readdirSync, readFileSync, realpathSync, renameSync, rmSync,
49
+ } from "node:fs";
50
+ import { join, dirname, isAbsolute, relative, resolve, sep } from "node:path";
15
51
  import { homedir } from "node:os";
16
52
  import { fileURLToPath } from "node:url";
53
+ import { randomBytes } from "node:crypto";
17
54
  import { execSync, spawnSync } from "node:child_process";
18
55
 
19
56
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -25,6 +62,25 @@ const SKIP = [/__pycache__/, /\.pyc$/, /\.pyo$/, /\.DS_Store$/];
25
62
  // Dev-only subdirs within scripts/ — not needed at runtime
26
63
  const SCRIPTS_DEV = ["ci", "wg"];
27
64
 
65
+ // Transient names used during a swap. Never treated as plugins; safe to delete if left over.
66
+ const STAGING_PREFIX = ".staging-wicked-garden-";
67
+ const OLD_PREFIX = ".old-wicked-garden-";
68
+ const FAILED_PREFIX = ".failed-wicked-garden-";
69
+ const TRANSIENT_PREFIXES = [STAGING_PREFIX, OLD_PREFIX, FAILED_PREFIX];
70
+
71
+ // Test-only fault injection (tests/test_install_mjs.py): `rollback-restore` makes the
72
+ // rename that puts a previous copy back fail, to prove the ROLLBACK FAILED path. The
73
+ // variable is never set in production and no other value has any effect.
74
+ const INSTALL_FAULT = process.env.WICKED_GARDEN_INSTALL_FAULT;
75
+
76
+ class RollbackFailed extends Error {
77
+ constructor(message) { super(message); this.exitCode = 3; }
78
+ }
79
+
80
+ const REGISTER_NOTE =
81
+ "copied (unregistered). Claude Code loads plugins through its marketplace registry — " +
82
+ "run `npx wicked-installer install wicked-garden` to register it in the active config dir.";
83
+
28
84
  function skip(src) {
29
85
  if (SKIP.some(p => p.test(src))) return true;
30
86
  // Strip dev-only scripts/ subdirs — they exist in the npm package but serve no runtime purpose
@@ -45,62 +101,398 @@ function findBin(name) {
45
101
  }
46
102
  }
47
103
 
48
- async function cmdInstall() {
49
- const dest = join(homedir(), ".claude", "plugins", "wicked-garden");
50
- const isUpdate = existsSync(dest);
104
+ // ---------------------------------------------------------------------------
105
+ // Argument parsing. Flags are accepted before or after the command
106
+ // (`npx wicked-garden --dry-run`, `npx wicked-garden install --dry-run`);
107
+ // `pack` verbs own their flags, so everything after `pack` passes through verbatim.
108
+ // ---------------------------------------------------------------------------
109
+
110
+ class UsageError extends Error {}
111
+
112
+ // A value-taking flag never swallows an option, in either form: `--claude-home --dry-run`,
113
+ // `--claude-home=--dry-run` and `--claude-home=-x` are errors, as are empty/blank values.
114
+ // A directory whose name really starts with "-" is written `./-x` or as an absolute path.
115
+ // The value is trimmed once and the TRIMMED value is what gets validated and used.
116
+ function claudeHomeValue(raw, form) {
117
+ const value = raw === undefined ? "" : raw.trim();
118
+ if (value === "") {
119
+ throw new UsageError(`${form} requires a directory`);
120
+ }
121
+ if (value.startsWith("-")) {
122
+ throw new UsageError(`${form} requires a directory (got option-like value "${value}"); write ./${value} or an absolute path for a directory whose name starts with "-"`);
123
+ }
124
+ return value;
125
+ }
126
+
127
+ function parseArgs(argv) {
128
+ const opts = { cmd: undefined, rest: [], claudeHomes: [], dryRun: false };
129
+ for (let i = 0; i < argv.length; i++) {
130
+ const arg = argv[i];
131
+ if (opts.cmd === "pack") { opts.rest.push(arg); continue; }
132
+ if (arg === "--dry-run") {
133
+ opts.dryRun = true;
134
+ } else if (arg === "--claude-home") {
135
+ opts.claudeHomes.push(claudeHomeValue(argv[i + 1], "--claude-home"));
136
+ i += 1;
137
+ } else if (arg.startsWith("--claude-home=")) {
138
+ opts.claudeHomes.push(claudeHomeValue(arg.slice("--claude-home=".length), "--claude-home="));
139
+ } else if (opts.cmd === undefined) {
140
+ opts.cmd = arg; // includes --version / -v / --help / -h, dispatched below
141
+ } else if (arg.startsWith("-")) {
142
+ throw new UsageError(`unknown option: ${arg}`);
143
+ } else {
144
+ throw new UsageError(`unexpected argument: ${arg}`);
145
+ }
146
+ }
147
+ return opts;
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Target resolution — the same rule as wicked-installer (install-claude.ts) so
152
+ // both tools land in the same config dirs:
153
+ // 1. --claude-home flags are the full set (trusted; created if absent);
154
+ // 2. CLAUDE_CONFIG_DIR is authoritative and exclusive when PRESENT in the
155
+ // environment — it may list several dirs, split on the platform list
156
+ // separator + ','. Present but naming no directory ("" / ":,", blanks) is an
157
+ // error, never a silent ~/.claude;
158
+ // 3. only when the key is absent: ~/.claude.
159
+ // ---------------------------------------------------------------------------
160
+
161
+ function expandHome(value) {
162
+ return value.replace(/^~(?=$|[/\\])/, () => homedir());
163
+ }
164
+
165
+ // ';'+',' on Windows so a bare ':' can never shatter a C:\ path; ':'+',' elsewhere.
166
+ function splitConfigDirValue(value) {
167
+ return value.split(process.platform === "win32" ? /[;,]/ : /[:,]/).map(p => p.trim()).filter(Boolean);
168
+ }
169
+
170
+ function resolveClaudeHomes(claudeHomes, env = process.env) {
171
+ let dirs;
172
+ let origin;
173
+ if (claudeHomes.length > 0) {
174
+ dirs = claudeHomes;
175
+ origin = "--claude-home";
176
+ } else if ("CLAUDE_CONFIG_DIR" in env) {
177
+ dirs = splitConfigDirValue(env.CLAUDE_CONFIG_DIR);
178
+ origin = "CLAUDE_CONFIG_DIR";
179
+ if (dirs.length === 0) {
180
+ throw new UsageError(`CLAUDE_CONFIG_DIR is set but names no directory (value: ${JSON.stringify(env.CLAUDE_CONFIG_DIR)}); unset it or point it at a config dir`);
181
+ }
182
+ } else {
183
+ dirs = [join(homedir(), ".claude")];
184
+ origin = "default";
185
+ }
186
+ const out = [];
187
+ const seen = new Set();
188
+ for (const raw of dirs) {
189
+ const dir = resolve(expandHome(raw));
190
+ if (seen.has(dir)) continue;
191
+ seen.add(dir);
192
+ out.push({ dir, pluginsDir: join(dir, "plugins"), dest: join(dir, "plugins", "wicked-garden"), origin });
193
+ }
194
+ return out;
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Symlink containment. The config dir itself may be a symlink (dotfiles-managed
199
+ // ~/.claude is common and we never create it when it exists). Under it we never
200
+ // write into a pre-existing tree: the copy goes into a staging dir THIS run
201
+ // created (nothing can be planted inside it), is lstat-walked, and is swapped in
202
+ // with rename(2). plugins/ and plugins/wicked-garden themselves are refused when
203
+ // symlinked, and manifests are opened O_NOFOLLOW. Residual (documented, same
204
+ // discipline as crew's v3.5): a parent directory swapped between our verify and
205
+ // our use — the window between lstat/realpath and the following syscall.
206
+ // ---------------------------------------------------------------------------
51
207
 
52
- console.log(isUpdate
53
- ? `Updating wicked-garden v${pkg.version} at ${dest}...`
54
- : `Installing wicked-garden v${pkg.version} to ${dest}...`
55
- );
208
+ function lstatOrNull(p) {
209
+ try { return lstatSync(p); } catch (err) { if (err.code === "ENOENT") return null; throw err; }
210
+ }
56
211
 
57
- mkdirSync(dest, { recursive: true });
212
+ function isSymlink(p) {
213
+ const st = lstatOrNull(p);
214
+ return st !== null && st.isSymbolicLink();
215
+ }
58
216
 
59
- for (const dir of PLUGIN_DIRS) {
60
- const src = join(__dirname, dir);
61
- if (!existsSync(src)) continue;
62
- process.stdout.write(` ${dir}/... `);
63
- cpSync(src, join(dest, dir), { recursive: true, force: true, filter: (s) => !skip(s) });
64
- console.log("done");
217
+ // Both sides are realpaths. path.relative() must neither climb out nor be absolute;
218
+ // Windows paths are case-insensitive and realpath may re-case the drive letter.
219
+ function isContained(rootReal, targetReal) {
220
+ const norm = (p) => (process.platform === "win32" ? p.toLowerCase() : p);
221
+ const rel = relative(norm(rootReal), norm(targetReal));
222
+ return rel === "" || (rel !== ".." && !rel.startsWith(".." + sep) && !isAbsolute(rel));
223
+ }
224
+
225
+ function assertContained(root, target, what) {
226
+ const rootReal = realpathSync(root);
227
+ const targetReal = realpathSync(target);
228
+ if (!isContained(rootReal, targetReal)) {
229
+ throw new Error(`refusing: ${what} ${target} resolves to ${targetReal}, outside ${rootReal}`);
65
230
  }
231
+ }
66
232
 
67
- for (const file of PLUGIN_FILES) {
68
- const src = join(__dirname, file);
69
- if (existsSync(src)) cpSync(src, join(dest, file), { force: true });
233
+ // Walk a tree with lstat: no symlink anywhere, every directory realpath-contained in root.
234
+ function verifyTree(root) {
235
+ const rootReal = realpathSync(root);
236
+ const stack = [root];
237
+ let entries = 0;
238
+ while (stack.length > 0) {
239
+ const dir = stack.pop();
240
+ for (const name of readdirSync(dir)) {
241
+ const p = join(dir, name);
242
+ const st = lstatSync(p);
243
+ if (st.isSymbolicLink()) throw new Error(`refusing: ${p} is a symlink (wicked-garden never follows symlinks under plugins/)`);
244
+ entries += 1;
245
+ if (st.isDirectory()) {
246
+ if (!isContained(rootReal, realpathSync(p))) throw new Error(`refusing: ${p} escapes ${rootReal}`);
247
+ stack.push(p);
248
+ }
249
+ }
70
250
  }
251
+ return entries;
252
+ }
71
253
 
72
- // Sync Python deps via uv if available (setup will retry if this is skipped)
73
- const uv = findBin("uv");
74
- if (uv) {
75
- process.stdout.write(" Python deps (uv sync)... ");
254
+ // Read a file without following a symlink at the final component: O_NOFOLLOW where
255
+ // the platform has it (POSIX), lstat-then-read on win32 (TOCTOU residual, see above).
256
+ function readNoFollow(p) {
257
+ if (constants.O_NOFOLLOW !== undefined) {
258
+ const fd = openSync(p, constants.O_RDONLY | constants.O_NOFOLLOW);
76
259
  try {
77
- execSync(`"${uv}" sync --quiet`, { cwd: dest, stdio: "pipe" });
260
+ if (!fstatSync(fd).isFile()) throw new Error(`${p} is not a regular file`);
261
+ return readFileSync(fd, "utf8");
262
+ } finally {
263
+ closeSync(fd);
264
+ }
265
+ }
266
+ const st = lstatSync(p);
267
+ if (st.isSymbolicLink()) throw new Error(`${p} is a symlink`);
268
+ if (!st.isFile()) throw new Error(`${p} is not a regular file`);
269
+ return readFileSync(p, "utf8");
270
+ }
271
+
272
+ function transientName(prefix) {
273
+ return `${prefix}${process.pid}-${randomBytes(4).toString("hex")}`;
274
+ }
275
+
276
+ function leftoverTransients(pluginsDir) {
277
+ try {
278
+ return readdirSync(pluginsDir).filter(n => TRANSIENT_PREFIXES.some(p => n.startsWith(p))).map(n => join(pluginsDir, n));
279
+ } catch {
280
+ return [];
281
+ }
282
+ }
283
+
284
+ // The four post-swap checks, each named in its failure so a diagnostic can cite it.
285
+ // `expectVersion === null` verifies a restored (possibly older) copy without pinning it.
286
+ function verifyInstalledTree(home, dest, expectVersion) {
287
+ try { verifyTree(dest); } catch (err) { throw new Error(`tree walk: ${err.message}`); }
288
+ try { assertContained(home, dest, "plugin dir"); } catch (err) { throw new Error(`containment: ${err.message}`); }
289
+ let installed;
290
+ try {
291
+ installed = JSON.parse(readNoFollow(join(dest, ".claude-plugin", "plugin.json")));
292
+ } catch (err) {
293
+ throw new Error(`manifest parse: ${join(dest, ".claude-plugin", "plugin.json")}: ${err.message}`);
294
+ }
295
+ if (expectVersion !== null && installed.version !== expectVersion) {
296
+ throw new Error(`version check: ${dest} reports ${installed.version}, expected ${expectVersion}`);
297
+ }
298
+ return installed;
299
+ }
300
+
301
+ // Post-swap verification failed: move the failed tree aside, put the previous copy back,
302
+ // verify it, and report. Anything going wrong in here is a ROLLBACK FAILED — printed with
303
+ // both locations, never swallowed.
304
+ function rollback(home, pluginsDir, dest, old, cause) {
305
+ const failed = join(pluginsDir, transientName(FAILED_PREFIX));
306
+ let failedAt = dest;
307
+ let previousAt = old;
308
+ try {
309
+ renameSync(dest, failed);
310
+ failedAt = failed;
311
+ if (old) {
312
+ if (INSTALL_FAULT === "rollback-restore") throw new Error("injected fault: rollback-restore");
313
+ renameSync(old, dest);
314
+ previousAt = dest;
315
+ verifyInstalledTree(home, dest, null);
316
+ }
317
+ rmSync(failed, { recursive: true, force: true });
318
+ failedAt = null;
319
+ } catch (rbErr) {
320
+ throw new RollbackFailed(
321
+ `ROLLBACK FAILED: previous install left at ${previousAt ?? "(none existed)"}, failed install at ${failedAt ?? "(removed)"}` +
322
+ ` — verification failure: ${cause.message}; rollback error: ${rbErr.message}`);
323
+ }
324
+ throw new Error(`post-install verification failed (${cause.message}); ${old ? `previous install restored at ${dest}` : `no previous install existed, so ${dest} is now absent`}`);
325
+ }
326
+
327
+ function planCopies() {
328
+ return {
329
+ dirs: PLUGIN_DIRS.filter(d => existsSync(join(__dirname, d))),
330
+ files: PLUGIN_FILES.filter(f => existsSync(join(__dirname, f))),
331
+ };
332
+ }
333
+
334
+ // Stage → verify → swap → verify → discard old, for one config dir. The previous copy is
335
+ // discarded only after the installed tree passed verification; on any failure before that
336
+ // the staging dir is removed and the previous copy is left (or put back) where it was.
337
+ function stageAndSwap({ dir: home, pluginsDir, dest }, { dirs, files }) {
338
+ mkdirSync(pluginsDir, { recursive: true });
339
+ assertContained(home, pluginsDir, "plugins dir");
340
+ const staging = join(pluginsDir, transientName(STAGING_PREFIX));
341
+ mkdirSync(staging); // not recursive: must not pre-exist (EEXIST is a failure, not a reuse)
342
+ console.log(` staging: ${staging}`);
343
+ let old;
344
+ try {
345
+ for (const d of dirs) {
346
+ process.stdout.write(` ${d}/... `);
347
+ cpSync(join(__dirname, d), join(staging, d), { recursive: true, force: true, filter: (s) => !skip(s) });
78
348
  console.log("done");
79
- } catch {
80
- console.log("skipped — the wicked-garden-core setup action will retry");
81
349
  }
350
+ for (const f of files) {
351
+ cpSync(join(__dirname, f), join(staging, f), { force: true });
352
+ }
353
+ const entries = verifyTree(staging);
354
+ assertContained(home, staging, "staging dir");
355
+
356
+ const finalSt = lstatOrNull(dest);
357
+ if (finalSt && finalSt.isSymbolicLink()) {
358
+ throw new Error(`refusing to install: ${dest} is a symlink (wicked-garden never follows symlinks under plugins/)`);
359
+ }
360
+ if (finalSt) {
361
+ old = join(pluginsDir, transientName(OLD_PREFIX));
362
+ renameSync(dest, old);
363
+ }
364
+ try {
365
+ renameSync(staging, dest);
366
+ } catch (err) {
367
+ if (old) {
368
+ try {
369
+ renameSync(old, dest);
370
+ } catch (rbErr) {
371
+ throw new RollbackFailed(`ROLLBACK FAILED: previous install left at ${old}, ${dest} is absent — swap error: ${err.message}; rollback error: ${rbErr.message}`);
372
+ }
373
+ }
374
+ throw err;
375
+ }
376
+ console.log(` swapped ${entries} entries into ${dest}`);
377
+ } catch (err) {
378
+ rmSync(staging, { recursive: true, force: true });
379
+ throw err;
82
380
  }
83
381
 
84
- console.log(`\nwicked-garden v${pkg.version} ${isUpdate ? "updated" : "installed"}.`);
85
- console.log('Next: in Claude Code, ask for "wicked-garden setup" (the wicked-garden-core skill) to complete configuration.');
382
+ // Post-swap verification of the tree we just installed, in place (see the residual note
383
+ // above) — BEFORE the previous copy is discarded, so a failure can still be rolled back.
384
+ try {
385
+ verifyInstalledTree(home, dest, pkg.version);
386
+ } catch (verifyErr) {
387
+ rollback(home, pluginsDir, dest, old, verifyErr); // always throws
388
+ }
389
+ if (old) rmSync(old, { recursive: true, force: true });
390
+ console.log(` verified${old ? " (previous copy replaced)" : ""}`);
86
391
  }
87
392
 
88
- function cmdStatus() {
89
- const dest = join(homedir(), ".claude", "plugins", "wicked-garden");
90
- if (!existsSync(dest)) {
91
- console.log("wicked-garden: not installed");
92
- console.log(` Run: npx wicked-garden@${pkg.version} install`);
393
+ async function cmdInstall(homes, { dryRun }) {
394
+ const plan = planCopies();
395
+ const tag = dryRun ? "[dry-run] " : "";
396
+ const uvFailures = [];
397
+
398
+ // Refuse up front, before anything is written anywhere.
399
+ for (const { pluginsDir, dest } of homes) {
400
+ const link = [pluginsDir, dest].find(isSymlink);
401
+ if (link) throw new Error(`refusing to install: ${link} is a symlink (wicked-garden never follows symlinks under plugins/)`);
402
+ }
403
+
404
+ // Dry-run spawns nothing — not even the `command -v uv` probe — so uv is resolved
405
+ // only once we are actually going to sync.
406
+ const uv = dryRun ? undefined : findBin("uv");
407
+
408
+ for (const home of homes) {
409
+ const { dir, pluginsDir, dest, origin } = home;
410
+ const isUpdate = existsSync(dest);
411
+ console.log(`${tag}${isUpdate ? "Updating" : "Installing"} wicked-garden v${pkg.version} ${isUpdate ? "at" : "to"} ${dest}`);
412
+ console.log(` config dir: ${dir} (${origin})`);
413
+
414
+ if (dryRun) {
415
+ console.log(` would stage into ${join(pluginsDir, `${STAGING_PREFIX}<pid>-<hex>`)} and swap it into ${dest}`);
416
+ for (const d of plan.dirs) console.log(` would copy ${d}/ -> ${join(dest, d)}`);
417
+ for (const f of plan.files) console.log(` would copy ${f} -> ${join(dest, f)}`);
418
+ console.log(` would run: uv sync --quiet (cwd ${dest}) if uv is on PATH — not probed in dry-run`);
419
+ continue;
420
+ }
421
+
422
+ stageAndSwap(home, plan);
423
+
424
+ // Sync Python deps via uv if available. A failure is reported, never swallowed:
425
+ // the copy stays usable and the wicked-garden-core setup action retries the sync,
426
+ // but this run does not claim success.
427
+ if (uv) {
428
+ process.stdout.write(" Python deps (uv sync)... ");
429
+ // argv array, no shell: the resolved uv path is never re-parsed by a shell.
430
+ const res = spawnSync(uv, ["sync", "--quiet"], { cwd: dest, stdio: "pipe", encoding: "utf8" });
431
+ if (!res.error && res.status === 0) {
432
+ console.log("done");
433
+ } else {
434
+ console.log("FAILED");
435
+ const raw = res.error ? res.error.message : `${res.stderr || ""}${res.status === null ? ` (signal ${res.signal})` : ` (exit ${res.status})`}`;
436
+ const detail = raw.trim().split("\n").slice(-3).join(" | ");
437
+ console.error(`WARNING: uv sync failed in ${dest}${detail ? `: ${detail}` : ""}`);
438
+ uvFailures.push(dest);
439
+ }
440
+ }
441
+
442
+ console.log(` ${REGISTER_NOTE}`);
443
+ }
444
+
445
+ const n = homes.length;
446
+ const dirsWord = `${n} config dir${n === 1 ? "" : "s"}`;
447
+ if (dryRun) {
448
+ console.log(`\n[dry-run] Nothing was written. wicked-garden v${pkg.version} would be copied into ${dirsWord}.`);
93
449
  return;
94
450
  }
95
- try {
96
- const installed = JSON.parse(readFileSync(join(dest, ".claude-plugin", "plugin.json"), "utf8"));
97
- const upToDate = installed.version === pkg.version;
98
- console.log(`wicked-garden: installed`);
451
+ if (uvFailures.length > 0) {
452
+ console.error(`\nwicked-garden v${pkg.version} copied (unregistered) into ${dirsWord}, but Python deps FAILED to sync in ${uvFailures.length} of them — fix the uv error above and re-run install, or let the wicked-garden-core setup action retry.`);
453
+ console.error("Registration is still the installer's job: `npx wicked-installer install wicked-garden`.");
454
+ process.exitCode = 1;
455
+ return;
456
+ }
457
+ console.log(`\nwicked-garden v${pkg.version} ${REGISTER_NOTE}`);
458
+ console.log('Then, in Claude Code, ask for "wicked-garden setup" (the wicked-garden-core skill) to complete configuration.');
459
+ }
460
+
461
+ function cmdStatus(homes) {
462
+ let failures = 0;
463
+ for (const { dir: home, pluginsDir, dest, origin } of homes) {
464
+ console.log(`config dir: ${home} (${origin})`);
465
+ const manifest = join(dest, ".claude-plugin", "plugin.json");
466
+ const link = [pluginsDir, dest, join(dest, ".claude-plugin"), manifest].find(isSymlink);
467
+ if (link) {
468
+ console.error(` refused: ${link} is a symlink (wicked-garden never follows symlinks under plugins/)`);
469
+ failures += 1;
470
+ continue;
471
+ }
472
+ for (const t of leftoverTransients(pluginsDir)) {
473
+ console.log(` leftover transient dir from an interrupted install (not a plugin; safe to delete): ${t}`);
474
+ }
475
+ if (!existsSync(dest)) {
476
+ console.log(" wicked-garden: not installed");
477
+ const hint = origin === "--claude-home" ? ` --claude-home "${home}"` : "";
478
+ console.log(` Run: npx wicked-garden@${pkg.version} install${hint}`);
479
+ continue;
480
+ }
481
+ let installed;
482
+ try {
483
+ installed = JSON.parse(readNoFollow(manifest));
484
+ } catch (err) {
485
+ console.error(` error: cannot read ${manifest}: ${err.code ? `${err.code}: ` : ""}${err.message} — re-run install to repair the copy`);
486
+ failures += 1;
487
+ continue;
488
+ }
489
+ const upToDate = installed.version === pkg.version;
490
+ console.log(" wicked-garden: installed (copy)");
99
491
  console.log(` path: ${dest}`);
100
492
  console.log(` version: ${installed.version}${upToDate ? "" : ` (package: ${pkg.version} — run install to update)`}`);
101
- } catch {
102
- console.log(`wicked-garden: installed at ${dest} (plugin.json unreadable)`);
493
+ console.log(" registration: owned by wicked-installer — `npx wicked-installer status` reports Claude Code's registry state");
103
494
  }
495
+ return failures > 0 ? 1 : 0;
104
496
  }
105
497
 
106
498
  // ---------------------------------------------------------------------------
@@ -181,31 +573,74 @@ function cmdPack(argv) {
181
573
  }
182
574
  }
183
575
 
184
- const cmd = process.argv[2];
185
- switch (cmd) {
576
+ function usage() {
577
+ return [
578
+ `wicked-garden v${pkg.version}`,
579
+ "",
580
+ "Usage:",
581
+ " npx wicked-garden install [options] Install or update the plugin copy (`update` is an alias)",
582
+ " npx wicked-garden status [options] Show the install state per config dir (read-only)",
583
+ " npx wicked-garden pack <verb> Third-party pack tooling (check/register/list/floors/install)",
584
+ " npx wicked-garden --version Show version",
585
+ " npx wicked-garden --help Show this usage",
586
+ "",
587
+ "Options:",
588
+ " --claude-home <dir> Config dir to target (repeatable; default: $CLAUDE_CONFIG_DIR or ~/.claude) — install/update/status",
589
+ " --dry-run Print what would be copied/synced, write nothing, spawn nothing — install/update only",
590
+ "",
591
+ "The copy is staged in plugins/.staging-wicked-garden-<pid>-<hex> and swapped in atomically; a previous",
592
+ "copy passes through plugins/.old-wicked-garden-<pid>-<hex> (a tree that fails verification through",
593
+ "plugins/.failed-wicked-garden-<pid>-<hex>). None of the three is a plugin; leftovers are safe to delete.",
594
+ "The copy is unregistered — `npx wicked-installer install wicked-garden` registers it with Claude Code.",
595
+ ].join("\n");
596
+ }
597
+
598
+ const TARGETED = new Set(["install", "update", undefined, "status"]);
599
+
600
+ let opts;
601
+ let homes;
602
+ try {
603
+ opts = parseArgs(process.argv.slice(2));
604
+ if (!TARGETED.has(opts.cmd) && (opts.dryRun || opts.claudeHomes.length > 0)) {
605
+ const given = [opts.dryRun && "--dry-run", opts.claudeHomes.length > 0 && "--claude-home"].filter(Boolean).join(" / ");
606
+ throw new UsageError(`${given} applies to install/update/status only, not to \`${opts.cmd}\`${opts.cmd === "pack" ? " (pack verbs own their flags — place them after the verb)" : ""}`);
607
+ }
608
+ if (opts.cmd === "status" && opts.dryRun) {
609
+ throw new UsageError("--dry-run applies to install/update only (status is read-only)");
610
+ }
611
+ if (TARGETED.has(opts.cmd)) homes = resolveClaudeHomes(opts.claudeHomes);
612
+ } catch (err) {
613
+ if (!(err instanceof UsageError)) throw err;
614
+ console.error(`Error: ${err.message}\n`);
615
+ console.error(usage());
616
+ process.exit(2);
617
+ }
618
+
619
+ switch (opts.cmd) {
186
620
  case "install":
187
621
  case "update":
188
622
  case undefined:
189
- cmdInstall().catch(err => { console.error("Error:", err.message); process.exit(1); });
623
+ cmdInstall(homes, { dryRun: opts.dryRun })
624
+ .catch(err => { console.error("Error:", err.message); process.exit(err.exitCode ?? 1); });
190
625
  break;
191
626
  case "status":
192
- cmdStatus();
627
+ process.exitCode = cmdStatus(homes);
193
628
  break;
194
629
  case "pack":
195
- process.exit(cmdPack(process.argv.slice(3)));
630
+ process.exit(cmdPack(opts.rest));
196
631
  break;
197
632
  case "--version":
198
633
  case "-v":
199
634
  console.log(pkg.version);
200
635
  break;
201
- default:
202
- console.log([
203
- `wicked-garden v${pkg.version}`,
204
- "",
205
- "Usage:",
206
- " npx wicked-garden install Install or update the plugin",
207
- " npx wicked-garden status Show current install state",
208
- " npx wicked-garden pack <verb> Third-party pack tooling (check/register/list/floors/install)",
209
- " npx wicked-garden --version Show version",
210
- ].join("\n"));
636
+ case "--help":
637
+ case "-h":
638
+ console.log(usage());
639
+ break;
640
+ default: {
641
+ const what = opts.cmd.startsWith("-") ? "unknown option" : "unknown command";
642
+ console.error(`Error: ${what}: ${opts.cmd}\n`);
643
+ console.error(usage());
644
+ process.exit(2);
645
+ }
211
646
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-garden",
3
- "version": "12.32.1",
3
+ "version": "12.32.2",
4
4
  "type": "module",
5
5
  "description": "Curated toolkit for what AI coding agents can't do alone: evidence-gated work, codegraph relationships, deterministic multi-file refactor, multi-model second opinions, and cross-session memory. Installs as a Claude Code plugin.",
6
6
  "keywords": [