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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +5 -0
- package/README.md +8 -0
- package/install.mjs +495 -60
- package/package.json +1 -1
|
@@ -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
|
+
{"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.
|
|
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
|
|
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
|
|
8
|
-
* npx wicked-garden update
|
|
9
|
-
* npx wicked-garden status
|
|
10
|
-
* npx wicked-garden pack <verb>
|
|
11
|
-
* npx wicked-garden --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 {
|
|
14
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
);
|
|
208
|
+
function lstatOrNull(p) {
|
|
209
|
+
try { return lstatSync(p); } catch (err) { if (err.code === "ENOENT") return null; throw err; }
|
|
210
|
+
}
|
|
56
211
|
|
|
57
|
-
|
|
212
|
+
function isSymlink(p) {
|
|
213
|
+
const st = lstatOrNull(p);
|
|
214
|
+
return st !== null && st.isSymbolicLink();
|
|
215
|
+
}
|
|
58
216
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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.
|
|
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": [
|