claude-code-arcane 2.15.0 → 2.17.0

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/CHANGELOG.md CHANGED
@@ -1,3 +1,23 @@
1
+ # [2.17.0](https://github.com/SebastianLuser/claude-code-arcane/compare/v2.16.0...v2.17.0) (2026-10-16)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **update:** narrow the old Bash(gh *) allow in existing installs ([4b033be](https://github.com/SebastianLuser/claude-code-arcane/commit/4b033befa1c3f68e84855cbf9e430f21be836293))
7
+
8
+
9
+ ### Features
10
+
11
+ * **cli:** add arcane doctor to check that an install works ([cd36de9](https://github.com/SebastianLuser/claude-code-arcane/commit/cd36de93baad06f6e5e47151eb2902fc6d6d1651))
12
+
13
+ # [2.16.0](https://github.com/SebastianLuser/claude-code-arcane/compare/v2.15.0...v2.16.0) (2026-10-16)
14
+
15
+
16
+ ### Features
17
+
18
+ * **profiles:** ship playwright-pro with the web profiles ([a6d3785](https://github.com/SebastianLuser/claude-code-arcane/commit/a6d37851f1c6dd2384a96e7b3a00e797e178b10f))
19
+ * **rules:** review a finished goal with a fresh subagent ([9aab70b](https://github.com/SebastianLuser/claude-code-arcane/commit/9aab70bf1a26af19d8bfe9ba9d7e75b7c651637b))
20
+
1
21
  # [2.15.0](https://github.com/SebastianLuser/claude-code-arcane/compare/v2.14.0...v2.15.0) (2026-10-16)
2
22
 
3
23
 
package/README.md CHANGED
@@ -149,6 +149,7 @@ Installs worktree-isolation scripts into `~/.claude/scripts/` and adds a `Sessio
149
149
  ```bash
150
150
  npx claude-code-arcane list # List all profiles and skills
151
151
  npx claude-code-arcane status # Show current installation
152
+ npx claude-code-arcane doctor # Check that hooks, settings and content work
152
153
  npx claude-code-arcane update # Check for updates
153
154
  npx claude-code-arcane clean --force # Remove Arcane entirely from project
154
155
  ```
@@ -194,13 +195,13 @@ Older names still install and update: `backend-ts`, `frontend`, `mobile`, `flutt
194
195
  | `backend-go` | Backend Go — Clean Arch, DB, auth, API, CI | 26 | engineering |
195
196
  | `backend-dotnet` | Backend .NET — ASP.NET Core, Vertical Slice/Clean, EF Core, JWT | 27 | engineering |
196
197
  | `backend-nestjs` | Backend NestJS — feature modules, DI, Prisma, JWT, microservices | 27 | engineering |
197
- | `backend-nextjs` | Next.js full-stack — App Router, RSC, Server Actions, SEO, AI SDK | 24 | engineering |
198
+ | `backend-nextjs` | Next.js full-stack — App Router, RSC, Server Actions, SEO, AI SDK | 25 | engineering |
198
199
 
199
200
  ### Frontend & Design
200
201
 
201
202
  | Profile | Stack | Skills | Agents |
202
203
  |---------|-------|--------|--------|
203
- | `frontend-react-vite` | React + Vite + TypeScript | 17 | engineering |
204
+ | `frontend-react-vite` | React + Vite + TypeScript | 18 | engineering |
204
205
  | `design` | Figma, UX review, design system, handoff | 10 | product |
205
206
 
206
207
  ### Mobile
@@ -304,7 +305,7 @@ It ships the in-editor skills that operate and extend the MCP (`unreal-mcp`, `cr
304
305
  | ClickUp | `/clickup` | `+clickup` |
305
306
  | Figma | `/figma`, `/figma-to-code`, `/figma-tokens` | `+design` |
306
307
  | Atlassian (Confluence) | `/confluence-expert`, `/atlassian-templates` | `+agile` |
307
- | TestRail + BrowserStack | `/playwright-pro` (ships its own `.mcp.json`) | `+testing` |
308
+ | TestRail + BrowserStack | `/playwright-pro` (ships its own `.mcp.json`) | `+testing`, `frontend-react-vite`, `backend-nextjs` |
308
309
 
309
310
  `/jira-tickets` is the exception: it talks to the Jira REST API v3 over `curl`, not MCP.
310
311
 
package/dist/cli.js CHANGED
@@ -2940,6 +2940,13 @@ function syncUnhashedFiles(root, claudeDir, merged, dryRun) {
2940
2940
  dirty = true;
2941
2941
  }
2942
2942
  }
2943
+ const allow = settings.permissions?.allow;
2944
+ if (Array.isArray(allow) && allow.includes("Bash(gh *)")) {
2945
+ const narrowed = merged.permissions.allow.filter((r) => r.startsWith("Bash(gh ") && !allow.includes(r));
2946
+ settings.permissions.allow = allow.flatMap((r) => r === "Bash(gh *)" ? narrowed : [r]);
2947
+ changed.push("settings.json (Bash(gh *) narrowed)");
2948
+ dirty = true;
2949
+ }
2943
2950
  if (dirty && !dryRun) {
2944
2951
  fs15.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}
2945
2952
  `, "utf-8");
@@ -3368,12 +3375,296 @@ function removeHookFromSettings() {
3368
3375
  writeSettings(settings);
3369
3376
  }
3370
3377
 
3371
- // src/update-check.ts
3378
+ // src/commands/doctor.ts
3372
3379
  import fs18 from "fs";
3373
3380
  import path21 from "path";
3381
+ import { spawnSync as spawnSync2 } from "child_process";
3374
3382
  import chalk11 from "chalk";
3383
+ var DANGEROUS_ALLOWS = /* @__PURE__ */ new Set([
3384
+ "Bash",
3385
+ "Bash(*)",
3386
+ "Bash(gh *)",
3387
+ "Bash(git *)",
3388
+ "Bash(git push*)",
3389
+ "Bash(git reset*)",
3390
+ "Bash(rm *)",
3391
+ "Bash(sudo *)",
3392
+ "Bash(bash *)",
3393
+ "Bash(sh *)",
3394
+ "Bash(eval *)"
3395
+ ]);
3396
+ var SECRET_PATTERNS = [
3397
+ ["Anthropic key", /sk-ant-[A-Za-z0-9_-]{20,}/],
3398
+ ["OpenAI key", /sk-(?:proj-)?[A-Za-z0-9]{32,}/],
3399
+ ["GitHub token", /\b(?:gh[pousr]_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{22,})/],
3400
+ ["Slack token", /\bxox[abpr]-[A-Za-z0-9-]{10,}/],
3401
+ ["AWS access key", /\bAKIA[0-9A-Z]{16}\b/],
3402
+ ["Google API key", /\bAIza[0-9A-Za-z_-]{35}/],
3403
+ ["Stripe live key", /\b[sr]k_live_[0-9A-Za-z]{20,}/],
3404
+ ["private key", /-----BEGIN [A-Z ]*PRIVATE KEY-----/]
3405
+ ];
3406
+ var SECRET_KEY = /(?:TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|ACCESS_?KEY)$/i;
3407
+ var defaultProbes = {
3408
+ findBash,
3409
+ checkSyntax(bash, file) {
3410
+ const result = spawnSync2(bash, ["-n", file.replace(/\\/g, "/")], { encoding: "utf-8" });
3411
+ if (result.error) return result.error.message;
3412
+ return result.status === 0 ? null : (result.stderr || "syntax error").trim();
3413
+ },
3414
+ findPython() {
3415
+ for (const py of ["python3", "python", "py"]) {
3416
+ const result = spawnSync2(py, ["--version"], { encoding: "utf-8" });
3417
+ if (!result.error && result.status === 0) return py;
3418
+ }
3419
+ return null;
3420
+ }
3421
+ };
3422
+ function findBash() {
3423
+ if (process.platform !== "win32") {
3424
+ return spawnSync2("bash", ["--version"]).error ? null : "bash";
3425
+ }
3426
+ const override = process.env.CLAUDE_CODE_GIT_BASH_PATH;
3427
+ if (override && fs18.existsSync(override)) return override;
3428
+ const systemRoot = (process.env.SystemRoot ?? "C:\\Windows").toLowerCase();
3429
+ for (const dir of (process.env.PATH ?? "").split(path21.delimiter)) {
3430
+ const lower = dir.toLowerCase();
3431
+ if (!dir || lower.startsWith(systemRoot) || lower.includes("windowsapps")) continue;
3432
+ const candidate = path21.join(dir, "bash.exe");
3433
+ if (fs18.existsSync(candidate)) return candidate;
3434
+ }
3435
+ return null;
3436
+ }
3437
+ function readJson(file) {
3438
+ try {
3439
+ return { value: JSON.parse(fs18.readFileSync(file, "utf-8")) };
3440
+ } catch (error) {
3441
+ return { error: error.message };
3442
+ }
3443
+ }
3444
+ function commandsOf(settings) {
3445
+ const commands = [];
3446
+ const hooks = settings.hooks ?? {};
3447
+ for (const groups of Object.values(hooks)) {
3448
+ for (const group of Array.isArray(groups) ? groups : []) {
3449
+ for (const entry of group.hooks ?? []) {
3450
+ if (typeof entry.command === "string") commands.push(entry.command);
3451
+ }
3452
+ }
3453
+ }
3454
+ const status = settings.statusLine;
3455
+ if (typeof status?.command === "string") commands.push(status.command);
3456
+ return commands;
3457
+ }
3458
+ function scriptsOf(command) {
3459
+ const scripts = [...command.matchAll(/\.claude\/((?:hooks\/)?[\w.-]+\.(?:sh|py))/g)].map((m) => m[1]);
3460
+ const python = command.match(/run-py\.sh"?\s+([\w.-]+\.py)/);
3461
+ if (python) scripts.push(`hooks/${python[1]}`);
3462
+ return scripts;
3463
+ }
3464
+ function checkSettings(claudeDir, cli) {
3465
+ const file = path21.join(claudeDir, "settings.json");
3466
+ if (!fs18.existsSync(file)) {
3467
+ return {
3468
+ check: { name: "settings.json", verdict: "fail", detail: "missing", fix: `Run \`${cli} update --here --force\`.` }
3469
+ };
3470
+ }
3471
+ const { value, error } = readJson(file);
3472
+ if (error || typeof value !== "object" || value === null || Array.isArray(value)) {
3473
+ return {
3474
+ check: {
3475
+ name: "settings.json",
3476
+ verdict: "fail",
3477
+ detail: `not valid JSON: ${error ?? "not an object"}`,
3478
+ fix: "Fix the syntax by hand, or restore it from .claude.bak/."
3479
+ }
3480
+ };
3481
+ }
3482
+ return { check: { name: "settings.json", verdict: "ok", detail: "valid JSON" }, settings: value };
3483
+ }
3484
+ function checkHooks(claudeDir, settings, probes, cli) {
3485
+ const commands = commandsOf(settings);
3486
+ const scripts = [...new Set(commands.flatMap(scriptsOf))];
3487
+ const checks = [];
3488
+ const missing = scripts.filter((s) => !fs18.existsSync(path21.join(claudeDir, s)));
3489
+ checks.push(
3490
+ missing.length ? {
3491
+ name: "hook scripts",
3492
+ verdict: "fail",
3493
+ detail: `settings.json runs files that are not there: ${missing.join(", ")}`,
3494
+ fix: `If Arcane ships them, \`${cli} update --here\` restores them; otherwise remove those hooks from settings.json.`
3495
+ } : { name: "hook scripts", verdict: "ok", detail: `${scripts.length} scripts found` }
3496
+ );
3497
+ const relative = commands.filter((c) => /^bash \.claude\/hooks\//.test(c));
3498
+ if (relative.length) {
3499
+ checks.push({
3500
+ name: "hook paths",
3501
+ verdict: "warn",
3502
+ detail: `${relative.length} hooks use a relative path, which fails inside a worktree`,
3503
+ fix: `Run \`${cli} update --here\`: it rewrites them with $CLAUDE_PROJECT_DIR.`
3504
+ });
3505
+ }
3506
+ const bash = probes.findBash();
3507
+ if (!bash) {
3508
+ checks.push({
3509
+ name: "bash",
3510
+ verdict: "fail",
3511
+ detail: "not found: no hook can run",
3512
+ fix: process.platform === "win32" ? "Install Git for Windows, or set CLAUDE_CODE_GIT_BASH_PATH." : "Install bash."
3513
+ });
3514
+ return checks;
3515
+ }
3516
+ const broken = scripts.filter((s) => s.endsWith(".sh") && !missing.includes(s)).map((s) => [s, probes.checkSyntax(bash, path21.join(claudeDir, s))]).filter(([, error]) => error);
3517
+ checks.push(
3518
+ broken.length ? {
3519
+ name: "hook syntax",
3520
+ verdict: "fail",
3521
+ detail: broken.map(([s, error]) => `${s}: ${error}`).join("; "),
3522
+ fix: `Run \`${cli} update --here --force\` to restore the shipped version.`
3523
+ } : { name: "hook syntax", verdict: "ok", detail: `bash parses every script (${bash})` }
3524
+ );
3525
+ return checks;
3526
+ }
3527
+ function checkPython(probes) {
3528
+ const python = probes.findPython();
3529
+ return python ? { name: "python", verdict: "ok", detail: `${python} starts` } : {
3530
+ name: "python",
3531
+ verdict: "warn",
3532
+ detail: "no working Python: the guard and the session watch do nothing",
3533
+ fix: "Install Python 3.9+ and make sure `python --version` works in Git Bash."
3534
+ };
3535
+ }
3536
+ function checkContent(claudeDir, manifest, cli) {
3537
+ const missing = [
3538
+ ...(manifest.installed_skills ?? []).filter((s) => !fs18.existsSync(path21.join(claudeDir, "skills", s, "SKILL.md"))).map((s) => `skill ${s}`),
3539
+ ...(manifest.installed_rules ?? []).filter((r) => !fs18.existsSync(path21.join(claudeDir, "rules", `${r}.md`))).map((r) => `rule ${r}`)
3540
+ ];
3541
+ if (!missing.length) {
3542
+ return { name: "installed content", verdict: "ok", detail: "every skill and rule in the manifest is on disk" };
3543
+ }
3544
+ const shown = missing.slice(0, 5).join(", ") + (missing.length > 5 ? ` and ${missing.length - 5} more` : "");
3545
+ return {
3546
+ name: "installed content",
3547
+ verdict: "fail",
3548
+ detail: `missing from disk: ${shown}`,
3549
+ fix: `Run \`${cli} update --here\` to restore them.`
3550
+ };
3551
+ }
3552
+ function checkPermissions(claudeDir, settings, cli) {
3553
+ const found = [];
3554
+ for (const [file, data] of [
3555
+ ["settings.json", settings],
3556
+ ["settings.local.json", readJson(path21.join(claudeDir, "settings.local.json")).value]
3557
+ ]) {
3558
+ const permissions = data?.permissions;
3559
+ const allow = Array.isArray(permissions?.allow) ? permissions.allow : [];
3560
+ for (const rule of allow) {
3561
+ if (typeof rule === "string" && DANGEROUS_ALLOWS.has(rule.trim())) found.push(`${file}: ${rule}`);
3562
+ }
3563
+ if (permissions?.defaultMode === "bypassPermissions") found.push(`${file}: defaultMode bypassPermissions`);
3564
+ }
3565
+ return found.length ? {
3566
+ name: "permissions",
3567
+ verdict: "warn",
3568
+ detail: `rules that skip every confirmation: ${found.join(", ")}`,
3569
+ fix: found.some((f) => f.endsWith("Bash(gh *)")) ? `Run \`${cli} update --here\` to narrow the old \`Bash(gh *)\`; narrow any other by hand, unless you mean it.` : "Narrow them by hand (e.g. `Bash(npm test*)` instead of `Bash(*)`), unless you mean it."
3570
+ } : { name: "permissions", verdict: "ok", detail: "no allow rule covers a whole tool or a destructive command" };
3571
+ }
3572
+ function* strings(value, key = "") {
3573
+ if (typeof value === "string") yield [key, value];
3574
+ else if (Array.isArray(value)) for (const item of value) yield* strings(item, key);
3575
+ else if (value && typeof value === "object") {
3576
+ for (const [k, v] of Object.entries(value)) yield* strings(v, k);
3577
+ }
3578
+ }
3579
+ function secretsIn(value) {
3580
+ const found = /* @__PURE__ */ new Set();
3581
+ for (const [key, text] of strings(value)) {
3582
+ for (const [label, pattern] of SECRET_PATTERNS) if (pattern.test(text)) found.add(label);
3583
+ if (SECRET_KEY.test(key) && text.length >= 8 && !/\$\{?[A-Za-z_]/.test(text)) found.add(`literal ${key}`);
3584
+ }
3585
+ return [...found];
3586
+ }
3587
+ function checkSecrets(target, claudeDir) {
3588
+ const committed = [];
3589
+ const local = [];
3590
+ for (const [file, bucket] of [
3591
+ [path21.join(claudeDir, "settings.json"), committed],
3592
+ [path21.join(target, ".mcp.json"), committed],
3593
+ [path21.join(claudeDir, "settings.local.json"), local]
3594
+ ]) {
3595
+ const { value } = readJson(file);
3596
+ for (const label of secretsIn(value)) bucket.push(`${path21.relative(target, file).replace(/\\/g, "/")} (${label})`);
3597
+ }
3598
+ if (committed.length) {
3599
+ return {
3600
+ name: "secrets",
3601
+ verdict: "fail",
3602
+ detail: `credentials in files that get committed: ${committed.join(", ")}`,
3603
+ fix: "Move them to environment variables and reference them as ${VAR}; rotate any that were pushed."
3604
+ };
3605
+ }
3606
+ if (local.length) {
3607
+ return {
3608
+ name: "secrets",
3609
+ verdict: "warn",
3610
+ detail: `credentials in settings.local.json: ${local.join(", ")}`,
3611
+ fix: "It is not committed, but an environment variable keeps it out of backups and shares."
3612
+ };
3613
+ }
3614
+ return { name: "secrets", verdict: "ok", detail: "no credentials found in settings or .mcp.json" };
3615
+ }
3616
+ function diagnose(target, probes = defaultProbes) {
3617
+ const claudeDir = path21.join(target, ".claude");
3618
+ const cli = cliInvocation();
3619
+ const manifest = readManifest(target);
3620
+ if (!manifest) {
3621
+ return [
3622
+ {
3623
+ name: "installation",
3624
+ verdict: "fail",
3625
+ detail: "no .claude/arcane-manifest.json",
3626
+ fix: `Run \`${cli} install <profile>\`.`
3627
+ }
3628
+ ];
3629
+ }
3630
+ const checks = [
3631
+ { name: "installation", verdict: "ok", detail: `Arcane ${manifest.arcane_version}, ${manifest.profiles.join(" + ")}` }
3632
+ ];
3633
+ const { check, settings } = checkSettings(claudeDir, cli);
3634
+ checks.push(check);
3635
+ if (settings) checks.push(...checkHooks(claudeDir, settings, probes, cli));
3636
+ checks.push(checkPython(probes), checkContent(claudeDir, manifest, cli));
3637
+ if (settings) checks.push(checkPermissions(claudeDir, settings, cli));
3638
+ checks.push(checkSecrets(target, claudeDir));
3639
+ return checks;
3640
+ }
3641
+ var ICONS = { ok: chalk11.green("\u2713"), warn: chalk11.yellow("!"), fail: chalk11.red("\u2717") };
3642
+ async function doctorCommand(opts = {}) {
3643
+ const target = path21.resolve(opts.target ?? process.cwd());
3644
+ const checks = diagnose(target);
3645
+ console.log(chalk11.bold(`
3646
+ === Arcane Doctor: ${target} ===
3647
+ `));
3648
+ for (const check of checks) {
3649
+ console.log(` ${ICONS[check.verdict]} ${chalk11.bold(check.name)}: ${check.detail}`);
3650
+ if (check.fix && check.verdict !== "ok") console.log(chalk11.dim(` fix: ${check.fix}`));
3651
+ }
3652
+ const fails = checks.filter((c) => c.verdict === "fail").length;
3653
+ const warns = checks.filter((c) => c.verdict === "warn").length;
3654
+ console.log(
3655
+ `
3656
+ ${fails ? chalk11.red(`${fails} failed`) : chalk11.green("nothing failed")}${warns ? chalk11.yellow(`, ${warns} warnings`) : ""}
3657
+ `
3658
+ );
3659
+ if (fails) process.exitCode = 1;
3660
+ }
3661
+
3662
+ // src/update-check.ts
3663
+ import fs19 from "fs";
3664
+ import path22 from "path";
3665
+ import chalk12 from "chalk";
3375
3666
  function checkFile() {
3376
- return path21.join(arcaneHome(), "last-check.json");
3667
+ return path22.join(arcaneHome(), "last-check.json");
3377
3668
  }
3378
3669
  var CHECK_INTERVAL_MS = 4 * 60 * 60 * 1e3;
3379
3670
  var GITHUB_OWNER2 = "SebastianLuser";
@@ -3443,23 +3734,23 @@ async function checkForUpdates(opts = {}) {
3443
3734
  function printUpdateNotice(result) {
3444
3735
  if (result.cli_update_available) {
3445
3736
  console.log(
3446
- chalk11.yellow(
3737
+ chalk12.yellow(
3447
3738
  `
3448
3739
  Arcane CLI update: ${result.cli_version} \u2192 ${result.latest_cli_version}`
3449
3740
  )
3450
3741
  );
3451
3742
  const how = isGloballyInstalled() ? ` Run: npm install -g ${PACKAGE_NAME3}@latest` : ` You are on an older CLI. Latest: ${PACKAGE_NAME3}@${result.latest_cli_version}`;
3452
- console.log(chalk11.dim(how));
3743
+ console.log(chalk12.dim(how));
3453
3744
  }
3454
3745
  if (result.content_update_available ?? true) {
3455
3746
  console.log(
3456
- chalk11.yellow(
3747
+ chalk12.yellow(
3457
3748
  `
3458
3749
  Arcane content update: ${result.local_version} \u2192 ${result.remote_sha}`
3459
3750
  )
3460
3751
  );
3461
3752
  console.log(
3462
- chalk11.dim(` Run: ${cliInvocation()} update (--dry-run to preview)`)
3753
+ chalk12.dim(` Run: ${cliInvocation()} update (--dry-run to preview)`)
3463
3754
  );
3464
3755
  }
3465
3756
  console.log("");
@@ -3502,8 +3793,8 @@ async function getLatestNpmVersion() {
3502
3793
  function readCachedCheck() {
3503
3794
  try {
3504
3795
  const file = checkFile();
3505
- if (!fs18.existsSync(file)) return null;
3506
- return JSON.parse(fs18.readFileSync(file, "utf-8"));
3796
+ if (!fs19.existsSync(file)) return null;
3797
+ return JSON.parse(fs19.readFileSync(file, "utf-8"));
3507
3798
  } catch {
3508
3799
  return null;
3509
3800
  }
@@ -3511,11 +3802,11 @@ function readCachedCheck() {
3511
3802
  function writeCachedCheck(result) {
3512
3803
  try {
3513
3804
  const file = checkFile();
3514
- const dir = path21.dirname(file);
3515
- if (!fs18.existsSync(dir)) {
3516
- fs18.mkdirSync(dir, { recursive: true });
3805
+ const dir = path22.dirname(file);
3806
+ if (!fs19.existsSync(dir)) {
3807
+ fs19.mkdirSync(dir, { recursive: true });
3517
3808
  }
3518
- fs18.writeFileSync(file, JSON.stringify(result, null, 2) + "\n");
3809
+ fs19.writeFileSync(file, JSON.stringify(result, null, 2) + "\n");
3519
3810
  } catch {
3520
3811
  }
3521
3812
  }
@@ -3552,6 +3843,9 @@ program.command("list").description("List all available profiles and skills.").a
3552
3843
  program.command("status").description("Show current Arcane installation status.").action(async () => {
3553
3844
  await statusCommand();
3554
3845
  });
3846
+ program.command("doctor").description("Check that this project's Arcane install works: hooks, settings, content, permissions, secrets.").option("-t, --target <dir>", "Project directory", process.cwd()).action(async (opts) => {
3847
+ await doctorCommand(opts);
3848
+ });
3555
3849
  program.command("update").description(
3556
3850
  "Update everything: the global npm package + all registered Arcane installations."
3557
3851
  ).option("-q, --quiet", "Quiet mode for hook usage").option("-n, --dry-run", "Show what would change without applying").option("-f, --force", "Overwrite even locally modified files").option("--here", "Update only the current repo (legacy per-repo behavior)").option("--no-self-update", "Skip updating the global npm package").option("--source <source>", "Content source: auto, github, or bundled", "auto").action(async (opts) => {
@@ -141,6 +141,23 @@ npx claude-code-arcane status
141
141
 
142
142
  Shows installed profiles, skill count, rules, agents, installation date, and worktree info.
143
143
 
144
+ ### `npx claude-code-arcane doctor` — Check that the install works
145
+
146
+ ```bash
147
+ npx claude-code-arcane doctor
148
+ npx claude-code-arcane doctor --target ../other-project
149
+ ```
150
+
151
+ Read-only. Each check prints ✓, `!` (warning) or ✗ (failure) with a fix:
152
+
153
+ - **Hooks:** every script `settings.json` runs exists and parses with bash; hooks still wired with a relative path, which fails inside a worktree.
154
+ - **Settings:** `settings.json` is valid JSON.
155
+ - **Content:** every skill and rule in the manifest is on disk.
156
+ - **Python:** one starts, since the guard and the session watch need it.
157
+ - **Permissions and secrets:** allow rules that skip every confirmation (`Bash(*)`, `Bash(gh *)`, `bypassPermissions`), and credentials in `settings.json`, `settings.local.json` or `.mcp.json`.
158
+
159
+ It exits with code 1 when a check fails.
160
+
144
161
  ### `npx claude-code-arcane update` — Check for updates
145
162
 
146
163
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-arcane",
3
- "version": "2.15.0",
3
+ "version": "2.17.0",
4
4
  "description": "Skills, agents, hooks and rules for Claude Code — installable via npx claude-code-arcane",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,6 +23,7 @@ skills:
23
23
  # Frontend quality
24
24
  - accessibility
25
25
  - performance
26
+ - playwright-pro
26
27
  # Ops & quality
27
28
  - testing
28
29
  - deps-audit
@@ -20,6 +20,7 @@ skills:
20
20
  - file-uploads
21
21
  - frontend-dev
22
22
  - csp-headers
23
+ - playwright-pro
23
24
 
24
25
  rules:
25
26
  universal:
@@ -22,6 +22,7 @@ skills:
22
22
  - file-uploads
23
23
  - frontend-dev
24
24
  - csp-headers
25
+ - playwright-pro
25
26
 
26
27
  rules:
27
28
  universal:
@@ -55,6 +55,6 @@ paths:
55
55
  2. Screenshots at 320, 768, 1024 and 1440 px wide, in both themes if the app has a dark mode.
56
56
  3. Walk the flow you changed: clicks, forms, navigation.
57
57
  4. Keyboard: every control in the flow is reachable with Tab, usable with Enter or Space, and shows a visible focus.
58
- 5. Keep the screenshots and show them as evidence.
59
- - If Playwright is not installed, propose installing it once (`npm i -D @playwright/test`, then `npx playwright install chromium`). If the user declines, close the task with "visual result not verified".
58
+ 5. Save the screenshots to files and show them as evidence; the goal review gets their paths.
59
+ - If Playwright is not installed, propose setting it up once with `/playwright-pro init` (or `npm i -D @playwright/test`, then `npx playwright install chromium`). If the user declines, close the task with "visual result not verified".
60
60
  - In React Native or Flutter Playwright does not apply: say what was not verified visually.
@@ -34,9 +34,9 @@ Use AskUserQuestion exactly like this:
34
34
 
35
35
  ## After selection
36
36
 
37
- - **Plan + Goal:** First define the verifiable session goal with validation criteria. Then EnterPlanMode and design the implementation plan linked to the goal. Plan includes explicit stop conditions (goal met, blocker, scope creep). After approval, create tasks and execute. After each significant step, check progress against the goal. When goal is met, STOP and run validation.
37
+ - **Plan + Goal:** First define the verifiable session goal with validation criteria. Then EnterPlanMode and design the implementation plan linked to the goal. Plan includes explicit stop conditions (goal met, blocker, scope creep). After approval, create tasks and execute. After each significant step, check progress against the goal. When goal is met, STOP and run the goal review below.
38
38
  - **Action Plan:** Use EnterPlanMode, design the approach, get approval, create tasks, then implement step by step. No formal goal — implement until the plan is done.
39
- - **Goal-driven:** Help the user define a verifiable goal statement and 2-4 checkpoints, create tasks, implement toward each checkpoint. No formal plan.
39
+ - **Goal-driven:** Help the user define a verifiable goal statement and 2-4 checkpoints, create tasks, implement toward each checkpoint. No formal plan. When the last checkpoint is met, run the goal review below.
40
40
  - **Directo:** Proceed immediately without ceremony.
41
41
 
42
42
  ## Progress file
@@ -63,6 +63,26 @@ Not verified: <what has not been checked yet>
63
63
 
64
64
  Directo and Action Plan tasks do not need it.
65
65
 
66
+ ## Goal review
67
+
68
+ In **Plan + Goal** and **Goal-driven** mode, whoever did the work does not decide it is done. When the goal looks met, start one `general-purpose` subagent with a clean context. Its prompt carries only:
69
+
70
+ - the goal and the criteria from `active.md`;
71
+ - the diff against the base branch, untracked files included;
72
+ - how to verify: test, build and type-check commands, and how to start the app;
73
+ - if the task changed a web UI: the paths of the Playwright screenshots, or that the user declined Playwright;
74
+ - the instructions below, verbatim.
75
+
76
+ Never pass your reasoning or a summary of the conversation: the reviewer judges the work, not the story behind it.
77
+
78
+ > You are reviewing finished work against its goal. Do not trust any reported result: run every verification yourself. Check each criterion against the code and your own runs. If the diff touches authentication, user input, database queries, file system paths, external APIs, cryptography or payments, also check that input is validated at the boundary, queries are parameterized, no secret is in code or logs, every new path checks authorization, and errors leak no internals. If the task changed a web UI, there are no Playwright screenshots and you cannot produce them, the verdict is FAIL, unless the user declined Playwright: then note "visual result not verified". Answer with `PASS` or `FAIL` on the first line, then one line per criterion with its evidence (the command and its result, or `file:line`), then what is missing.
79
+
80
+ Then:
81
+
82
+ 1. Show the user the reviewer's verdict as it came, before your own comments.
83
+ 2. **FAIL:** fix what it found and review again with a new subagent. After a second FAIL, stop and bring both verdicts to the user.
84
+ 3. **PASS:** close `active.md` with the verdict.
85
+
66
86
  ## Before writing new code
67
87
 
68
88
  In every mode, search in this order and take the first option that fully works: