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 +29 -0
- package/README.md +4 -3
- package/dist/server/agent-edit.js +6 -1
- package/dist/server/mcp-edit.js +12 -8
- package/dist/server/scan/compare.js +3 -1
- package/dist/server/scan/context.js +6 -3
- package/dist/server/scan/env.js +4 -1
- package/dist/server/scan/files.js +3 -1
- package/dist/server/scan/path-test.js +19 -4
- package/dist/server/scan/permissions.js +1 -1
- package/dist/server/scan/usage.js +3 -0
- package/dist/server/settings-edit.js +19 -3
- package/dist/server/watch.js +82 -10
- package/dist/ui/assets/{index-XqJwI3k9.js → index-Dg9dxngR.js} +1 -1
- package/dist/ui/index.html +1 -1
- package/package.json +1 -1
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
|
[](https://www.npmjs.com/package/ccdesk)
|
|
9
9
|
[](https://github.com/ayagmar/ccdesk/actions/workflows/ci.yml)
|
|
10
10
|
[](https://github.com/ayagmar/ccdesk/blob/master/package.json)
|
|
11
|
+
[](https://deepwiki.com/ayagmar/ccdesk)
|
|
11
12
|
[](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
|
|
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
|
-
|
|
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,
|
package/dist/server/mcp-edit.js
CHANGED
|
@@ -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
|
-
|
|
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 ? [
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/dist/server/scan/env.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
+
const file = await readTextFile(env, path);
|
|
62
|
+
if (!file)
|
|
59
63
|
return false;
|
|
60
|
-
|
|
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-
|
|
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
|
|
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));
|
package/dist/server/watch.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
57
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 = [];
|