ccdesk 0.2.1 → 0.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.2](https://github.com/ayagmar/ccdesk/compare/v0.2.1...v0.2.2) (2026-10-12)
4
+
5
+ ### Bug Fixes
6
+
7
+ * **agents:** refuse agent text with values its review would hide ([3a9b39f](https://github.com/ayagmar/ccdesk/commit/3a9b39f2634cf4a10a9fc9a7ba7c627dddebd9da))
8
+ * **compare:** ignore key order when comparing settings ([db71cde](https://github.com/ayagmar/ccdesk/commit/db71cde8c19c4bae292e5f41cd8d8c0cf7c578e4))
9
+ * **context:** count a linked rule imported by its target's path once ([63830ef](https://github.com/ayagmar/ccdesk/commit/63830ef9096c2a86005cb60bad3d1c872921cdc1))
10
+ * **context:** count a rule in a linked folder once when it is also imported ([168039b](https://github.com/ayagmar/ccdesk/commit/168039b738524b1d72f5e48edfcb51bea069a506))
11
+ * **context:** prefer the real path the scan recorded when deduplicating ([d5a3e75](https://github.com/ayagmar/ccdesk/commit/d5a3e7566668156de9a3739b9fa51a39301cf697))
12
+ * **env:** make a relative CLAUDE_CONFIG_DIR absolute ([2939488](https://github.com/ayagmar/ccdesk/commit/2939488c5ded1869367ca769218f17be8880fcd4))
13
+ * **hooks:** refuse a replacement hook with hidden header values ([c40e7b4](https://github.com/ayagmar/ccdesk/commit/c40e7b4dfeda44fe104ad24cbd790a846da31266))
14
+ * **mcp:** name the chosen profile in the claude commands ccdesk shows ([4d99fbd](https://github.com/ayagmar/ccdesk/commit/4d99fbd37087fa0a6332bc997975efb510d123b5))
15
+ * **mcp:** run claude with the profile chosen by --config-dir ([02c6af0](https://github.com/ayagmar/ccdesk/commit/02c6af0bd56235874dd1a46dd1a3aaf124f299b8))
16
+ * **path-test:** apply startup's rules to a subfolder's linked rules ([49d426c](https://github.com/ayagmar/ccdesk/commit/49d426c07beb5ce8e6538c2091b5e3c989d356e1))
17
+ * **path-test:** keep AGENTS.md out when a subfolder's CLAUDE.md is skipped ([c05ed89](https://github.com/ayagmar/ccdesk/commit/c05ed89f04db3a83b1dad2e35b803cbb4af2e4a3))
18
+ * **path-test:** match a linked rule's target against excludes only as startup does ([dfb6a79](https://github.com/ayagmar/ccdesk/commit/dfb6a79ae70c7eb4c94f4905c76b78f081c8afce))
19
+ * **path-test:** skip subfolder instructions and rules over 4 MiB ([3696d74](https://github.com/ayagmar/ccdesk/commit/3696d74ec070dda64184b100b9eecef181e52884))
20
+ * **scan:** cite the docs section on MCP rules with parentheses ([901c372](https://github.com/ayagmar/ccdesk/commit/901c3729ed4089bdfd5dd19a6265a5995aae1458))
21
+ * **scan:** treat a name starting with two dots as inside its folder ([af6b025](https://github.com/ayagmar/ccdesk/commit/af6b02549e55a24394197da08326412bb1890059))
22
+ * **settings:** refuse hook events Claude Code does not know ([b6561e6](https://github.com/ayagmar/ccdesk/commit/b6561e6f9b5a86a9fa27b3138cb570f493edb0d5))
23
+ * **usage:** do not start a count for a page that already left ([4984a58](https://github.com/ayagmar/ccdesk/commit/4984a585d6a6ccd3914f2e06b9f274bd4b66eb21))
24
+ * **watch:** answer a wait whose watcher closed while it was starting ([5c63129](https://github.com/ayagmar/ccdesk/commit/5c63129bdb60d9bc5f6d13a7665e4f273dcfb406))
25
+ * **watch:** answer waiting pages when the watcher closes ([ad1c5ea](https://github.com/ayagmar/ccdesk/commit/ad1c5ea1080362858755e5f2c09e4bf19a3682e2))
26
+ * **watch:** refresh when a file inside an installed plugin changes ([a09a99e](https://github.com/ayagmar/ccdesk/commit/a09a99e38c37c765c5117138324d50174ad7b142))
27
+ * **watch:** refresh when a missing import is created ([279b033](https://github.com/ayagmar/ccdesk/commit/279b03353f414ed9faeaf0a86450c49da56aea1a))
28
+ * **watch:** refresh when a missing import's folder appears later ([91f6444](https://github.com/ayagmar/ccdesk/commit/91f644452c3975dd09b2f1fbb86b2c96cf5da561))
29
+ * **watch:** watch ~/.claude/plugins only for the install list ([382b6b0](https://github.com/ayagmar/ccdesk/commit/382b6b0990c82f71dab37cfd8840bd1aec42c47e))
30
+ * **watch:** watch only the plugin files the scan reads ([8d12adc](https://github.com/ayagmar/ccdesk/commit/8d12adcfa0cdf74097273516d04130a99cffe578))
31
+
3
32
  ## [0.2.1](https://github.com/ayagmar/ccdesk/compare/v0.2.0...v0.2.1) (2026-10-12)
4
33
 
5
34
  ### Features
package/README.md CHANGED
@@ -8,6 +8,7 @@
8
8
  [![npm](https://img.shields.io/npm/v/ccdesk?style=flat-square&color=141414)](https://www.npmjs.com/package/ccdesk)
9
9
  [![CI](https://img.shields.io/github/actions/workflow/status/ayagmar/ccdesk/ci.yml?branch=master&style=flat-square&label=ci)](https://github.com/ayagmar/ccdesk/actions/workflows/ci.yml)
10
10
  [![node](https://img.shields.io/node/v/ccdesk?style=flat-square&color=141414)](https://github.com/ayagmar/ccdesk/blob/master/package.json)
11
+ [![Ask DeepWiki](https://img.shields.io/badge/Ask-DeepWiki-blue)](https://deepwiki.com/ayagmar/ccdesk)
11
12
  [![license](https://img.shields.io/github/license/ayagmar/ccdesk?style=flat-square&color=141414)](https://github.com/ayagmar/ccdesk/blob/master/LICENSE)
12
13
 
13
14
  See and change everything Claude Code loads on your machine: instruction files, settings, permissions, hooks, MCP servers, subagents, skills, plugins and auto memory.
@@ -16,7 +17,7 @@ See and change everything Claude Code loads on your machine: instruction files,
16
17
  npx ccdesk
17
18
  ```
18
19
 
19
- Each piece lives in its own file, at one of six scopes: managed, user, project, local, auto memory or a plugin. ccdesk opens in your browser on the directory you ran it from, lists everything that applies there in the order Claude Code loads it, and names the file behind each piece. When something is wrong, change it on the same page: you review the diff first, and every edit has a backup and an undo. It is not a session viewer or a usage dashboard.
20
+ Each piece lives in its own file, at one of six scopes: managed, user, project, local, auto memory or a plugin. ccdesk opens in your browser on the directory you ran it from, lists everything that applies there in the order Claude Code loads it, and names the file behind each piece. When something is wrong, change it on the same page: you review the diff first, and every file ccdesk writes is backed up and can be undone. It is not a session viewer or a usage dashboard.
20
21
 
21
22
  **Docs:** [ayagmar.github.io/ccdesk](https://ayagmar.github.io/ccdesk/)
22
23
 
@@ -74,7 +75,7 @@ Each piece lives in its own file, at one of six scopes: managed, user, project,
74
75
  - The old file is backed up to `~/.claude/backups/ccdesk/` first. Undo is one click, and **Recent changes** can restore any earlier edit.
75
76
  - A settings file that is a symlink, such as one in a dotfiles repository, is written through and stays a link.
76
77
  - A file with other hard links is not saved: replacing it would split it from its other names. Edit it in your editor.
77
- - ccdesk never writes `~/.claude.json`. User and local MCP server changes go through Claude Code's own `claude mcp` command, so they need `claude` on your PATH.
78
+ - ccdesk never writes `~/.claude.json`. User and local MCP server changes go through Claude Code's own `claude mcp` command, so they need `claude` on your PATH. ccdesk keeps no backup of these and cannot undo them; **Recent changes** lists each one with the commands that reverse it.
78
79
 
79
80
  Details: [Recent changes, backups and undo](https://ayagmar.github.io/ccdesk/recent-changes/)
80
81
 
@@ -104,7 +105,7 @@ npx ccdesk [options]
104
105
  -h, --help Show this help
105
106
  ```
106
107
 
107
- ccdesk reads `~/.claude` and `~/.claude.json`, or `$CLAUDE_CONFIG_DIR` when it is set, as Claude Code does. To look at another profile without exporting the variable, pass `--config-dir <path>`. Edits made in another editor or by Claude show up within a second. Requires Node.js 22.19 or newer. See [Platform notes](https://ayagmar.github.io/ccdesk/platforms/) for Windows, macOS and WSL.
108
+ ccdesk reads `~/.claude` and `~/.claude.json`, or `$CLAUDE_CONFIG_DIR` when it is set, as Claude Code does. To look at another profile without exporting the variable, pass `--config-dir <path>`. Edits made in another editor or by Claude show up within a second. Changes to `~/.claude.json`, such as trust decisions and MCP approvals, show up when you return to the tab. Requires Node.js 22.19 or newer. See [Platform notes](https://ayagmar.github.io/ccdesk/platforms/) for Windows, macOS and WSL.
108
109
 
109
110
  ## Privacy and safety
110
111
 
@@ -40,7 +40,12 @@ async function plan(env, edit) {
40
40
  // from it would lose them; it can still be deleted, as a whole.
41
41
  if (edit.text !== null && before !== raw)
42
42
  throw new RefusedError(`${path} has env, header or URL values ccdesk does not show; edit it in your editor instead.`);
43
- const errors = edit.text === null ? [] : agentProblems(edit.text);
43
+ let errors = edit.text === null ? [] : agentProblems(edit.text);
44
+ // Nor does it write values its review would hide.
45
+ if (edit.text !== null && errors.length === 0 && shownText(edit.text) !== edit.text)
46
+ errors = [
47
+ "ccdesk does not write env, header or URL values it would then hide; add them in your editor instead.",
48
+ ];
44
49
  return {
45
50
  path,
46
51
  raw,
@@ -44,10 +44,12 @@ function secretsIn(args) {
44
44
  }
45
45
  });
46
46
  }
47
- export const runClaude = async (args, cwd) => {
47
+ export const runClaude = async (args, cwd, configDir) => {
48
48
  const program = await claudeProgram();
49
+ // Set only for a chosen profile: Claude Code also moves .claude.json into a CLAUDE_CONFIG_DIR.
50
+ const env = configDir === undefined ? process.env : { ...process.env, CLAUDE_CONFIG_DIR: configDir };
49
51
  return new Promise((done, fail) => {
50
- execFile(program, args, { cwd, timeout: 30_000 }, (error, stdout, stderr) => {
52
+ execFile(program, args, { cwd, env, timeout: 30_000 }, (error, stdout, stderr) => {
51
53
  if (error?.code === "ENOENT")
52
54
  return fail(new RefusedError("ccdesk changes your user and local MCP servers through the claude command, which is not on PATH."));
53
55
  // Node's own message repeats the command, with the server's env and header values in it.
@@ -181,7 +183,9 @@ async function planClaudeJson(env, edit, run) {
181
183
  name,
182
184
  JSON.stringify(definition),
183
185
  ];
184
- const addCommand = (name, definition) => `claude mcp add-json --scope ${edit.scope} -- ${quoted(name)} ${quoted(JSON.stringify(definition))}`;
186
+ // The commands shown and recorded name the profile, so pasting one changes the same servers.
187
+ const claude = env.configDir === undefined ? "claude" : `CLAUDE_CONFIG_DIR=${quoted(env.configDir)} claude`;
188
+ const addCommand = (name, definition) => `${claude} mcp add-json --scope ${edit.scope} -- ${quoted(name)} ${quoted(JSON.stringify(definition))}`;
185
189
  const before = Object.keys(servers).length > 0 ? shown(servers) : null;
186
190
  // Recent changes lists the edit with the servers it touched, values hidden as the page shows
187
191
  // them, and the commands that put them back from the folder claude ran in.
@@ -194,7 +198,7 @@ async function planClaudeJson(env, edit, run) {
194
198
  shownBefore: shown(touched(servers)),
195
199
  shownAfter: shown(touched(edited(servers, edit))),
196
200
  undoCommands: [
197
- ...(add ? [`${at}claude mcp remove --scope ${edit.scope} -- ${quoted(add.name)}`] : []),
201
+ ...(add ? [`${at}${claude} mcp remove --scope ${edit.scope} -- ${quoted(add.name)}`] : []),
198
202
  ...(remove ? [`${at}${addCommand(remove.name, redactSecrets(servers[remove.name]))}`] : []),
199
203
  ],
200
204
  });
@@ -211,20 +215,20 @@ async function planClaudeJson(env, edit, run) {
211
215
  errors,
212
216
  // Shown with secrets hidden; the command that runs has the values you typed.
213
217
  commands: [
214
- ...(remove ? [`claude mcp remove --scope ${edit.scope} -- ${quoted(remove.name)}`] : []),
218
+ ...(remove ? [`${claude} mcp remove --scope ${edit.scope} -- ${quoted(remove.name)}`] : []),
215
219
  ...(add ? [addCommand(add.name, redactSecrets(add.definition))] : []),
216
220
  ],
217
221
  },
218
222
  save: async () => {
219
223
  if (remove)
220
- await run(["mcp", "remove", "--scope", edit.scope, "--", remove.name], cwd);
224
+ await run(["mcp", "remove", "--scope", edit.scope, "--", remove.name], cwd, env.configDir);
221
225
  // The change is made by then; a record that cannot be written only leaves it unlisted.
222
226
  if (!add) {
223
227
  await record().catch(() => undefined);
224
228
  return { target: env.claudeJson };
225
229
  }
226
230
  try {
227
- await run(addArgs(add.name, add.definition), cwd);
231
+ await run(addArgs(add.name, add.definition), cwd, env.configDir);
228
232
  }
229
233
  catch (error) {
230
234
  if (!remove)
@@ -234,7 +238,7 @@ async function planClaudeJson(env, edit, run) {
234
238
  // belongs to; a replaced server has no hidden values (see checked), so the command shows
235
239
  // nothing the page does not.
236
240
  const original = servers[remove.name];
237
- await run(addArgs(remove.name, original), cwd).catch(() => {
241
+ await run(addArgs(remove.name, original), cwd, env.configDir).catch(() => {
238
242
  throw new RefusedError(`ccdesk removed ${remove.name}, then could not add it back. In bash, zsh or Git Bash, put it back with: ${edit.scope === "local" ? `cd ${quoted(cwd)} && ` : ""}${addCommand(remove.name, original)} (${error instanceof Error ? error.message : String(error)})`);
239
243
  });
240
244
  throw error;
@@ -1,4 +1,5 @@
1
1
  import { resolve } from "node:path";
2
+ import { isDeepStrictEqual } from "node:util";
2
3
  import {} from "../../shared/compare.js";
3
4
  import {} from "./env.js";
4
5
  import { effectiveSettings, readSettingsLayers } from "./settings.js";
@@ -28,7 +29,8 @@ export async function compareSettings(env, a, b) {
28
29
  const settings = keys.map((key) => {
29
30
  const x = left.get(key) ?? null;
30
31
  const y = right.get(key) ?? null;
31
- return { key, a: x, b: y, same: JSON.stringify(x?.value) === JSON.stringify(y?.value) };
32
+ // The order keys are written in does not change a setting.
33
+ return { key, a: x, b: y, same: isDeepStrictEqual(x?.value, y?.value) };
32
34
  });
33
35
  return { a: resolve(a), b: resolve(b), settings };
34
36
  }
@@ -1,7 +1,7 @@
1
1
  import { basename, join } from "node:path";
2
2
  import {} from "../../shared/types.js";
3
3
  import {} from "./env.js";
4
- import { isWithin, parseFrontmatter, readTextFile } from "./files.js";
4
+ import { isWithin, parseFrontmatter, readTextFile, resolvedPath } from "./files.js";
5
5
  /**
6
6
  * Imports can name any file, so a cloned repo's CLAUDE.md could point at private files.
7
7
  * ccdesk shows an import's text only from the project, your Claude folders and ~/.agents.
@@ -71,8 +71,11 @@ export async function buildContext(input) {
71
71
  note: "ccdesk could not find this style's file",
72
72
  }, null));
73
73
  }
74
- // A file both imported and loaded on its own (a rule, an AGENTS.md) is counted once, as itself.
75
- const loaded = new Set(instructions.filter((i) => i.status === "loaded").map((i) => i.path));
74
+ // A file both imported and loaded on its own (a rule, an AGENTS.md) is counted once, as itself,
75
+ // also when it is loaded through a link, to it or to its folder, and imported by its real path.
76
+ const loaded = new Set((await Promise.all(instructions
77
+ .filter((i) => i.status === "loaded")
78
+ .map(async (i) => [i.path, i.realPath ?? (await resolvedPath(i.path))]))).flat());
76
79
  const added = new Set();
77
80
  // An imported file comes before the text that imports it ("Claude reads the imported file
78
81
  // first, then the rest"), and its own imports before it.
@@ -3,10 +3,13 @@ import { join, resolve } from "node:path";
3
3
  /** `configDirFlag`, from `--config-dir`, stands in for CLAUDE_CONFIG_DIR and wins over it. */
4
4
  export function scanEnvFromProcess(configDirFlag) {
5
5
  const home = homedir();
6
- const configDir = configDirFlag !== undefined ? resolve(configDirFlag) : process.env.CLAUDE_CONFIG_DIR;
6
+ // Made absolute here, from the folder ccdesk started in, because claude runs elsewhere.
7
+ const chosen = configDirFlag ?? (process.env.CLAUDE_CONFIG_DIR || undefined);
8
+ const configDir = chosen === undefined ? undefined : resolve(chosen);
7
9
  return {
8
10
  home,
9
11
  claudeDir: configDir ?? join(home, ".claude"),
12
+ configDir,
10
13
  // Claude Code keeps .claude.json beside the config dir it was given.
11
14
  claudeJson: configDir ? join(configDir, ".claude.json") : join(home, ".claude.json"),
12
15
  managedDir: managedDirFor(process.platform),
@@ -152,7 +152,9 @@ export function wildcard(pattern, flags = "") {
152
152
  }
153
153
  export function isWithin(parent, child) {
154
154
  const rel = relative(parent, child);
155
- return rel === "" || (!rel.startsWith("..") && !rel.startsWith(sep) && !/^[a-zA-Z]:/.test(rel));
155
+ // Only the parent segment leads out; a name such as ..cache is inside.
156
+ const up = rel === ".." || rel.startsWith(`..${sep}`);
157
+ return rel === "" || (!up && !rel.startsWith(sep) && !/^[a-zA-Z]:/.test(rel));
156
158
  }
157
159
  export async function isDir(path) {
158
160
  try {
@@ -1,7 +1,7 @@
1
1
  import { dirname, isAbsolute, join, matchesGlob, relative, resolve, sep } from "node:path";
2
2
  import {} from "../../shared/types.js";
3
3
  import {} from "./env.js";
4
- import { isFile, isWithin, listDir, parseFrontmatter, readTextFile, stringsIn, toStringList, walkMarkdown, } from "./files.js";
4
+ import { isFile, isWithin, listDir, MAX_TEXT_BYTES, parseFrontmatter, readTextFile, resolvedPath, stringsIn, toStringList, walkMarkdown, } from "./files.js";
5
5
  import { scanDirectory } from "./index.js";
6
6
  import { applySkillOverrides, readSkill } from "./inventory.js";
7
7
  /** Globs are written with forward slashes on every platform. */
@@ -46,6 +46,8 @@ viewOf = (cwd) => scanDirectory(env, cwd)) {
46
46
  if (glob)
47
47
  matches.push({ kind: "rule", path: rule.path, scope: rule.scope, glob });
48
48
  }
49
+ const realCwd = await resolvedPath(cwd);
50
+ const approved = view.trust.externalImports === "approved";
49
51
  // Folders below the start directory load their own instructions, rules and skills on demand.
50
52
  const mode = view.agentsMdMode;
51
53
  const startSkills = new Set(view.inventory.skills.filter((s) => s.scope !== "plugin").map((s) => s.name));
@@ -53,11 +55,14 @@ viewOf = (cwd) => scanDirectory(env, cwd)) {
53
55
  for (let d = dirname(target); d !== cwd && isWithin(cwd, d); d = dirname(d))
54
56
  between.unshift(d);
55
57
  for (const folder of between) {
58
+ /** Whether the file is there; an excluded one or one over the limit is there but not loaded. */
56
59
  const nested = async (name, scope) => {
57
60
  const path = join(folder, name);
58
- if (excluded(path) || !(await readTextFile(env, path)))
61
+ const file = await readTextFile(env, path);
62
+ if (!file)
59
63
  return false;
60
- matches.push({ kind: "nested", path, scope });
64
+ if (!excluded(path) && file.bytes <= MAX_TEXT_BYTES)
65
+ matches.push({ kind: "nested", path, scope });
61
66
  return true;
62
67
  };
63
68
  // managed-only leaves out files at launch, but a subfolder's CLAUDE.md and rules still load
@@ -73,9 +78,19 @@ viewOf = (cwd) => scanDirectory(env, cwd)) {
73
78
  await nested("AGENTS.md", "project");
74
79
  for (const path of await walkMarkdown(join(folder, ".claude", "rules"))) {
75
80
  const text = await readTextFile(env, path);
76
- if (!text || excluded(path))
81
+ if (!text || text.bytes > MAX_TEXT_BYTES || excluded(path))
82
+ continue;
83
+ // As at startup: excluded by its target's path too, and a rule linked from outside the
84
+ // project waits for your approval, then loads only without paths
85
+ // (docs: memory#share-rules-across-projects-with-symlinks).
86
+ const real = await resolvedPath(path);
87
+ const outside = !isWithin(realCwd, real) && !isWithin(await resolvedPath(folder), real);
88
+ const linked = text.realPath ?? (outside ? real : undefined);
89
+ if (linked !== undefined && excluded(linked))
77
90
  continue;
78
91
  const globs = toStringList(parseFrontmatter(text.text).data.paths);
92
+ if (!isWithin(realCwd, real) && (!approved || globs.length > 0))
93
+ continue;
79
94
  const glob = globs.length === 0 ? undefined : firstMatch(globs, slashed(relative(folder, target)));
80
95
  if (globs.length === 0 || glob)
81
96
  matches.push({ kind: "nested", path, scope: "project", ...(glob ? { glob } : {}) });
@@ -17,7 +17,7 @@ export function trustWaits({ trust, inConfigHome, localFromRepository, profile,
17
17
  }
18
18
  /**
19
19
  * An MCP rule with a parameter, which Claude Code skips when it reads a settings file; such a rule
20
- * works only from --disallowedTools (docs: permissions#match-specific-tool-uses).
20
+ * works only from --disallowedTools (docs: permissions#match-by-input-parameter).
21
21
  */
22
22
  export function isSkippedMcpRule(rule) {
23
23
  return /^mcp__[^(]*\(/.test(rule);
@@ -36,6 +36,9 @@ export class CountStopped extends Error {
36
36
  export function readUsage(env, dir, rules, { signal, timeLimitMs = TIME_LIMIT_MS, maxCachedCalls = MAX_CACHED_CALLS, } = {}) {
37
37
  const key = JSON.stringify([env.claudeDir, resolve(dir), rules, timeLimitMs, maxCachedCalls]);
38
38
  let run = waiting.get(key);
39
+ // Nobody would wait for a new count, so none starts to hold up the queue.
40
+ if (!run && signal?.aborted)
41
+ return Promise.reject(stoppedBecause(signal));
39
42
  if (!run) {
40
43
  const stop = new AbortController();
41
44
  const promise = queue.then(() => {
@@ -1,5 +1,6 @@
1
1
  import { stat } from "node:fs/promises";
2
2
  import {} from "ajv";
3
+ import HOOK_EVENTS from "../shared/hook-events.json" with { type: "json" };
3
4
  import { allowedScopes, ignoredReason } from "../shared/scopes.js";
4
5
  import schema from "../shared/settings.schema.json" with { type: "json" };
5
6
  import {} from "../shared/types.js";
@@ -72,7 +73,9 @@ async function planKey(env, file, key, value, checksHiddenValues = false) {
72
73
  // reformatted instead of diffing its raw text.
73
74
  const reformats = raw !== null && raw.trim() !== "" && raw !== `${JSON.stringify(data, null, 2)}\n`;
74
75
  const base = { path, before, revision: revisionOf(raw), reformats };
75
- const errors = nextValue === undefined ? [] : schemaErrors(validate, nextValue, key);
76
+ const errors = nextValue === undefined
77
+ ? []
78
+ : [...schemaErrors(validate, nextValue, key), ...newHookEvents(key, data[key], nextValue)];
76
79
  if (errors.length > 0)
77
80
  return {
78
81
  path,
@@ -128,6 +131,18 @@ async function planPermissionEdit(env, edit) {
128
131
  return Object.keys(permissions).length > 0 ? permissions : undefined;
129
132
  });
130
133
  }
134
+ /**
135
+ * Hook events an edit brings in that Claude Code does not know, which the schema allows. Ones the
136
+ * file already has stay, so a file written for another Claude Code version can still be edited.
137
+ */
138
+ function newHookEvents(key, before, after) {
139
+ if (key !== "hooks" || !isRecord(after))
140
+ return [];
141
+ const had = isRecord(before) ? Object.keys(before) : [];
142
+ return Object.keys(after)
143
+ .filter((event) => !HOOK_EVENTS.includes(event) && !had.includes(event))
144
+ .map((event) => `${event} is not a hook event Claude Code knows.`);
145
+ }
131
146
  /** The matcher a group is written with; a missing or empty one matches everything. */
132
147
  function matcherOf(group) {
133
148
  return isRecord(group) && typeof group.matcher === "string" ? group.matcher : "";
@@ -144,6 +159,9 @@ async function planHookEdit(env, edit) {
144
159
  return Array.isArray(groups) ? groups : [];
145
160
  };
146
161
  const { remove, add } = edit;
162
+ // Checked before any branch, so neither a new hook nor a replacement writes them.
163
+ if (add && hasHiddenValues(add.definition))
164
+ throw new RefusedError("ccdesk does not write header values it would then hide; add this hook in the file instead.");
147
165
  if (remove) {
148
166
  const groups = groupsOf(remove.event);
149
167
  const group = groups[remove.at.group];
@@ -175,8 +193,6 @@ async function planHookEdit(env, edit) {
175
193
  delete hooks[remove.event];
176
194
  }
177
195
  if (add) {
178
- if (hasHiddenValues(add.definition))
179
- throw new RefusedError("ccdesk does not write header values it would then hide; add this hook in the file instead.");
180
196
  const groups = groupsOf(add.event);
181
197
  const matcher = add.matcher ?? "";
182
198
  const group = groups.find((g) => matcherOf(g) === matcher && isRecord(g) && Array.isArray(g.hooks));
@@ -1,11 +1,12 @@
1
1
  import { watch } from "node:fs";
2
2
  import { stat } from "node:fs/promises";
3
- import { basename, dirname, join } from "node:path";
3
+ import { basename, dirname, isAbsolute, join } from "node:path";
4
4
  import { storeDirName } from "../shared/memory.js";
5
5
  import {} from "../shared/types.js";
6
6
  import {} from "./scan/env.js";
7
- import { isNetworkPath, mainWorktreeRoot } from "./scan/files.js";
7
+ import { isDir, isFile, isNetworkPath, mainWorktreeRoot } from "./scan/files.js";
8
8
  import { scanDirectory } from "./scan/index.js";
9
+ import { manifestCommandMap, manifestPaths } from "./scan/plugins.js";
9
10
  /** How long one request waits for a change before answering "nothing changed". */
10
11
  const WAIT_MS = 25_000;
11
12
  /** Editors write a file in several steps; one change covers them all. */
@@ -31,7 +32,8 @@ async function targets(env, dir, extra) {
31
32
  const memoryKey = (await mainWorktreeRoot(dir)) ?? dir;
32
33
  const out = [
33
34
  ...claude(env.claudeDir),
34
- { path: join(env.claudeDir, "plugins"), recursive: false },
35
+ // Only the install list: Windows reports a write deep in a plugin as a change to `cache`.
36
+ { path: join(env.claudeDir, "plugins"), recursive: false, names: ["installed_plugins.json"] },
35
37
  { path: join(env.claudeDir, "projects", storeDirName(memoryKey), "memory"), recursive: false },
36
38
  { path: env.managedDir, recursive: false },
37
39
  { path: join(env.managedDir, "managed-settings.d"), recursive: false },
@@ -53,11 +55,15 @@ async function targets(env, dir, extra) {
53
55
  out.push(...claude(join(memoryKey, ".claude")));
54
56
  return out.filter((t) => !isNetworkPath(t.path));
55
57
  }
56
- /** Files a scan read that the fixed list does not cover: a custom memory folder, imports. */
57
- export function dependencies(view) {
58
+ /**
59
+ * Files a scan read that the fixed list does not cover: a custom memory folder, imports, installed
60
+ * plugins. A missing import is watched too, so creating it shows up, even when its folder is missing
61
+ * as well.
62
+ */
63
+ export async function dependencies(env, view) {
58
64
  const files = view.instructions.flatMap((i) => [
59
65
  i.realPath ?? i.path,
60
- ...i.imports.filter((r) => r.exists).map((r) => r.resolved),
66
+ ...i.imports.map((r) => r.resolved),
61
67
  ]);
62
68
  const byFolder = new Map();
63
69
  for (const file of files) {
@@ -65,10 +71,69 @@ export function dependencies(view) {
65
71
  names.add(basename(file));
66
72
  byFolder.set(dirname(file), names);
67
73
  }
74
+ const plugins = await Promise.all(view.inventory.plugins.map((p) => pluginTargets(env, view, p)));
68
75
  return [
69
76
  ...[...byFolder].map(([path, names]) => ({ path, recursive: false, names: [...names] })),
70
77
  ...(view.memory ? [{ path: view.memory.dir, recursive: false }] : []),
78
+ ...plugins.flat(),
79
+ ];
80
+ }
81
+ /** A plugin's folders the scan reads from, when they are not named in its manifest. */
82
+ const PLUGIN_FOLDERS = ["agents", "skills", "commands", "output-styles", "hooks"];
83
+ /** Manifest keys that name files or folders inside the plugin. */
84
+ const PLUGIN_KEYS = ["agents", "skills", "commands", "outputStyles", "hooks", "mcpServers"];
85
+ /**
86
+ * What the scan reads in a plugin: its manifest, .mcp.json, a skill at its root, its component
87
+ * folders and the paths its manifest names. Not the whole plugin, which can hold an MCP server's
88
+ * node_modules and files the server writes while it runs.
89
+ */
90
+ async function pluginTargets(env, view, plugin) {
91
+ const root = plugin.installPath;
92
+ // A path from installed_plugins.json that is not absolute names no folder Claude Code would load.
93
+ if (!isAbsolute(root))
94
+ return [];
95
+ const named = await Promise.all(PLUGIN_KEYS.map((key) => manifestPaths(env, plugin, key)));
96
+ const commands = await manifestCommandMap(env, plugin);
97
+ // Without a skills folder, each folder at the root holding SKILL.md is a skill.
98
+ const rootSkills = view.inventory.skills
99
+ .filter((s) => s.plugin === plugin.id && s.folder && dirname(s.folder) === root)
100
+ .map((s) => s.folder);
101
+ const paths = [
102
+ ...PLUGIN_FOLDERS.map((name) => join(root, name)),
103
+ ...named.flatMap((list) => list ?? []),
104
+ ...commands.map((c) => c.path),
105
+ ...rootSkills,
106
+ ];
107
+ const out = [
108
+ {
109
+ path: root,
110
+ recursive: false,
111
+ names: [".claude-plugin", ".mcp.json", "SKILL.md", ...PLUGIN_FOLDERS],
112
+ },
113
+ { path: join(root, ".claude-plugin"), recursive: false, names: ["plugin.json"] },
71
114
  ];
115
+ for (const path of new Set(paths)) {
116
+ if (await isDir(path))
117
+ out.push({ path, recursive: true });
118
+ else if (await isFile(path))
119
+ out.push({ path: dirname(path), recursive: false, names: [basename(path)] });
120
+ }
121
+ return out;
122
+ }
123
+ /**
124
+ * `target`, or when it does not exist yet, the nearest folder above it that does, watched for the
125
+ * next missing name only. Watching needs a folder that exists, and the watchers are rebuilt after
126
+ * every change, so each folder that appears moves the watch one level closer.
127
+ */
128
+ async function nearestWatchable(target) {
129
+ if (isNetworkPath(target.path) || (await stat(target.path).catch(() => null)))
130
+ return target;
131
+ for (let missing = target.path; dirname(missing) !== missing; missing = dirname(missing)) {
132
+ const parent = dirname(missing);
133
+ if (await stat(parent).catch(() => null))
134
+ return { path: parent, recursive: false, names: [basename(missing)] };
135
+ }
136
+ return target;
72
137
  }
73
138
  /** One set of file watchers per directory a page is looking at, shared by its requests. */
74
139
  export class Watcher {
@@ -91,7 +156,7 @@ export class Watcher {
91
156
  // Only a watched directory's view can be known to be current later.
92
157
  if (subscription)
93
158
  this.latest.set(view.path, { view, startedAt, stateFile });
94
- const extra = dependencies(view);
159
+ const extra = await dependencies(this.env, view);
95
160
  this.scanned.set(view.path, extra);
96
161
  if (subscription && JSON.stringify(extra) !== JSON.stringify(subscription.extra)) {
97
162
  subscription.extra = extra;
@@ -128,8 +193,10 @@ export class Watcher {
128
193
  async wait(dir, since, signal, timeoutMs = WAIT_MS) {
129
194
  const subscription = await this.subscribe(dir);
130
195
  clearTimeout(subscription.idle);
196
+ // Closed while its watchers were starting: there is nothing left to wait on.
197
+ const closed = this.subscriptions.get(dir) !== subscription;
131
198
  try {
132
- if (subscription.changedAt <= since && !signal.aborted)
199
+ if (!closed && subscription.changedAt <= since && !signal.aborted)
133
200
  await new Promise((resolve) => {
134
201
  const done = () => {
135
202
  clearTimeout(timer);
@@ -143,7 +210,8 @@ export class Watcher {
143
210
  });
144
211
  }
145
212
  finally {
146
- if (subscription.waiters.size === 0)
213
+ // A dropped subscription has nothing left to clean up later.
214
+ if (subscription.waiters.size === 0 && this.subscriptions.get(dir) === subscription)
147
215
  subscription.idle = setTimeout(() => this.drop(dir), IDLE_MS);
148
216
  }
149
217
  return { changed: subscription.changedAt > since, at: subscription.changedAt };
@@ -178,10 +246,14 @@ export class Watcher {
178
246
  w.close();
179
247
  this.subscriptions.delete(dir);
180
248
  this.latest.delete(dir);
249
+ // Pages still waiting are answered now, so no timer outlives the watcher.
250
+ for (const wake of [...subscription.waiters])
251
+ wake();
181
252
  }
182
253
  /** (Re)creates the watchers, so a folder created since, such as a new .claude, is watched. */
183
254
  async rewatch(dir, subscription) {
184
- const list = await targets(this.env, dir, subscription.extra);
255
+ const extra = await Promise.all(subscription.extra.map(nearestWatchable));
256
+ const list = await targets(this.env, dir, extra);
185
257
  for (const w of subscription.watchers)
186
258
  w.close();
187
259
  subscription.watchers = [];