ccdesk 0.2.2 → 0.2.4

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.
Files changed (131) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +7 -4
  3. package/dist/core/agent-edit.d.ts +6 -0
  4. package/dist/{server → core}/agent-edit.js +15 -21
  5. package/dist/core/backups.d.ts +19 -0
  6. package/dist/{server → core}/backups.js +64 -48
  7. package/dist/core/core.d.ts +96 -0
  8. package/dist/core/core.js +57 -0
  9. package/dist/core/index.d.ts +22 -0
  10. package/dist/core/index.js +38 -0
  11. package/dist/core/mcp-edit.d.ts +23 -0
  12. package/dist/{server → core}/mcp-edit.js +121 -100
  13. package/dist/core/mutations.d.ts +20 -0
  14. package/dist/{server → core}/mutations.js +8 -15
  15. package/dist/core/open.d.ts +28 -0
  16. package/dist/{server → core}/open.js +38 -13
  17. package/dist/core/parse-edit.d.ts +12 -0
  18. package/dist/core/parse-edit.js +64 -0
  19. package/dist/core/reference.d.ts +3 -0
  20. package/dist/core/reference.js +11 -0
  21. package/dist/core/scan/compare.d.ts +7 -0
  22. package/dist/{server → core}/scan/compare.js +2 -2
  23. package/dist/core/scan/context.d.ts +13 -0
  24. package/dist/{server → core}/scan/context.js +55 -32
  25. package/dist/core/scan/data.d.ts +14 -0
  26. package/dist/core/scan/data.js +26 -0
  27. package/dist/core/scan/env.d.ts +28 -0
  28. package/dist/{server → core}/scan/env.js +15 -4
  29. package/dist/core/scan/files.d.ts +68 -0
  30. package/dist/core/scan/files.js +270 -0
  31. package/dist/core/scan/git.d.ts +20 -0
  32. package/dist/core/scan/git.js +106 -0
  33. package/dist/core/scan/index.d.ts +5 -0
  34. package/dist/{server → core}/scan/index.js +119 -82
  35. package/dist/core/scan/instructions.d.ts +54 -0
  36. package/dist/{server → core}/scan/instructions.js +141 -73
  37. package/dist/core/scan/inventory.d.ts +38 -0
  38. package/dist/{server → core}/scan/inventory.js +134 -115
  39. package/dist/core/scan/mcp.d.ts +28 -0
  40. package/dist/{server → core}/scan/mcp.js +153 -95
  41. package/dist/core/scan/memory.d.ts +8 -0
  42. package/dist/{server → core}/scan/memory.js +8 -8
  43. package/dist/core/scan/path-test.d.ts +9 -0
  44. package/dist/core/scan/path-test.js +156 -0
  45. package/dist/core/scan/permissions.d.ts +31 -0
  46. package/dist/{server → core}/scan/permissions.js +5 -8
  47. package/dist/core/scan/plugins.d.ts +39 -0
  48. package/dist/{server → core}/scan/plugins.js +36 -27
  49. package/dist/core/scan/projects.d.ts +9 -0
  50. package/dist/{server → core}/scan/projects.js +7 -5
  51. package/dist/core/scan/sandbox.d.ts +14 -0
  52. package/dist/{server → core}/scan/sandbox.js +87 -59
  53. package/dist/core/scan/settings.d.ts +37 -0
  54. package/dist/{server → core}/scan/settings.js +98 -93
  55. package/dist/core/scan/tools.d.ts +10 -0
  56. package/dist/{server → core}/scan/tools.js +28 -20
  57. package/dist/core/scan/usage.d.ts +19 -0
  58. package/dist/{server → core}/scan/usage.js +122 -97
  59. package/dist/core/schema.d.ts +8 -0
  60. package/dist/core/schema.js +67 -0
  61. package/dist/core/secrets.d.ts +45 -0
  62. package/dist/core/secrets.js +207 -0
  63. package/dist/core/settings-edit.d.ts +9 -0
  64. package/dist/{server → core}/settings-edit.js +91 -112
  65. package/dist/core/watch.d.ts +45 -0
  66. package/dist/{server → core}/watch.js +54 -17
  67. package/dist/core/write.d.ts +117 -0
  68. package/dist/{server → core}/write.js +61 -8
  69. package/dist/server/args.d.ts +32 -0
  70. package/dist/server/args.js +106 -0
  71. package/dist/server/cli.d.ts +2 -0
  72. package/dist/server/cli.js +58 -83
  73. package/dist/server/commands.d.ts +8 -0
  74. package/dist/server/commands.js +39 -0
  75. package/dist/server/server.d.ts +12 -0
  76. package/dist/server/server.js +80 -195
  77. package/dist/server/start.d.ts +13 -0
  78. package/dist/server/start.js +21 -0
  79. package/dist/shared/compare.d.ts +25 -0
  80. package/dist/shared/memory.d.ts +6 -0
  81. package/dist/shared/memory.js +18 -2
  82. package/dist/shared/requests.d.ts +104 -0
  83. package/dist/shared/requests.js +73 -0
  84. package/dist/shared/scopes.d.ts +10 -0
  85. package/dist/shared/settings-catalog.json +2199 -0
  86. package/dist/shared/types.d.ts +535 -0
  87. package/dist/shared/types.js +11 -0
  88. package/dist/ui/assets/index-C9qqEI5Z.js +60 -0
  89. package/dist/ui/index.html +1 -1
  90. package/dist/view/diff.d.ts +16 -0
  91. package/dist/view/diff.js +85 -0
  92. package/dist/view/format.d.ts +17 -0
  93. package/dist/view/format.js +47 -0
  94. package/dist/view/index.d.ts +11 -0
  95. package/dist/view/index.js +13 -0
  96. package/dist/view/lines.d.ts +9 -0
  97. package/dist/view/lines.js +12 -0
  98. package/dist/view/path-test.d.ts +3 -0
  99. package/dist/view/path-test.js +7 -0
  100. package/dist/view/sections/catalog.d.ts +9 -0
  101. package/dist/view/sections/catalog.js +47 -0
  102. package/dist/view/sections/extensions.d.ts +1 -0
  103. package/dist/view/sections/extensions.js +48 -0
  104. package/dist/view/sections/hooks.d.ts +1 -0
  105. package/dist/view/sections/hooks.js +32 -0
  106. package/dist/view/sections/index.d.ts +6 -0
  107. package/dist/view/sections/index.js +27 -0
  108. package/dist/view/sections/instructions.d.ts +6 -0
  109. package/dist/view/sections/instructions.js +38 -0
  110. package/dist/view/sections/mcp.d.ts +3 -0
  111. package/dist/view/sections/mcp.js +38 -0
  112. package/dist/view/sections/memory.d.ts +1 -0
  113. package/dist/view/sections/memory.js +31 -0
  114. package/dist/view/sections/permissions.d.ts +17 -0
  115. package/dist/view/sections/permissions.js +87 -0
  116. package/dist/view/sections/settings.d.ts +1 -0
  117. package/dist/view/sections/settings.js +33 -0
  118. package/dist/view/sections/tools.d.ts +6 -0
  119. package/dist/view/sections/tools.js +57 -0
  120. package/dist/view/sections/types.d.ts +123 -0
  121. package/dist/view/sections/types.js +10 -0
  122. package/dist/view/settings.d.ts +12 -0
  123. package/dist/view/settings.js +31 -0
  124. package/dist/view/usage.d.ts +8 -0
  125. package/dist/view/usage.js +18 -0
  126. package/package.json +13 -2
  127. package/dist/server/scan/files.js +0 -480
  128. package/dist/server/scan/path-test.js +0 -136
  129. package/dist/server/schema.js +0 -15
  130. package/dist/server/secrets.js +0 -63
  131. package/dist/ui/assets/index-Dg9dxngR.js +0 -60
@@ -0,0 +1,9 @@
1
+ import { type Change, type HookEdit, type PermissionEdit, type SettingsEdit, type SettingsEditPreview } from "../shared/types.ts";
2
+ import { type ScanEnv } from "./scan/env.ts";
3
+ import { type Serially } from "./write.ts";
4
+ export declare function previewSettingsEdit(env: ScanEnv, edit: SettingsEdit): Promise<SettingsEditPreview>;
5
+ export declare function saveSettingsEdit(env: ScanEnv, edit: SettingsEdit, expectedRevision: string, queue: Serially): Promise<Change>;
6
+ export declare function previewPermissionEdit(env: ScanEnv, edit: PermissionEdit): Promise<SettingsEditPreview>;
7
+ export declare function savePermissionEdit(env: ScanEnv, edit: PermissionEdit, expectedRevision: string, queue: Serially): Promise<Change>;
8
+ export declare function previewHookEdit(env: ScanEnv, edit: HookEdit): Promise<SettingsEditPreview>;
9
+ export declare function saveHookEdit(env: ScanEnv, edit: HookEdit, expectedRevision: string, queue: Serially): Promise<Change>;
@@ -1,16 +1,17 @@
1
1
  import { stat } from "node:fs/promises";
2
- import {} from "ajv";
2
+ import { Compile } from "typebox/compile";
3
3
  import HOOK_EVENTS from "../shared/hook-events.json" with { type: "json" };
4
4
  import { allowedScopes, ignoredReason } from "../shared/scopes.js";
5
5
  import schema from "../shared/settings.schema.json" with { type: "json" };
6
6
  import {} from "../shared/types.js";
7
7
  import {} from "./scan/env.js";
8
- import { isRecord, jsonSyntaxError, redactSecrets, revisionOf } from "./scan/files.js";
8
+ import { revisionOf } from "./scan/files.js";
9
+ import { isRecord } from "./scan/data.js";
9
10
  import { isSkippedMcpRule } from "./scan/permissions.js";
10
11
  import { settingsFiles } from "./scan/settings.js";
11
- import { ajv, schemaErrors } from "./schema.js";
12
- import { hasHiddenValues, shown } from "./secrets.js";
13
- import { RefusedError, readIfExists, recordChange, serially, writeAtomic } from "./write.js";
12
+ import { schemaErrors } from "./schema.js";
13
+ import { hasHiddenValues, redactSecrets, shown } from "./secrets.js";
14
+ import { RefusedError, readJsonObject, recordChange, saveReviewed, writeAtomic, } from "./write.js";
14
15
  const properties = schema.definitions.ClaudeCodeSettings.properties;
15
16
  const validators = new Map();
16
17
  function validatorFor(key) {
@@ -18,7 +19,7 @@ function validatorFor(key) {
18
19
  return null;
19
20
  let validate = validators.get(key);
20
21
  if (!validate) {
21
- validate = ajv.compile(properties[key]);
22
+ validate = Compile(properties[key]);
22
23
  validators.set(key, validate);
23
24
  }
24
25
  return validate;
@@ -33,21 +34,6 @@ async function editableFile(env, dir, pick, missing = "That is not a settings fi
33
34
  throw new RefusedError(`${dir} is not a directory on this machine.`);
34
35
  return file;
35
36
  }
36
- async function readSettings(env, path) {
37
- const raw = await readIfExists(env, path);
38
- if (raw === null || raw.trim() === "")
39
- return { raw, data: {} };
40
- let parsed;
41
- try {
42
- parsed = JSON.parse(raw);
43
- }
44
- catch (error) {
45
- throw new RefusedError(`${path} is ${jsonSyntaxError(error)}; fix it by hand first.`);
46
- }
47
- if (!isRecord(parsed))
48
- throw new RefusedError(`${path} does not hold a JSON object.`);
49
- return { raw, data: parsed };
50
- }
51
37
  /**
52
38
  * Works out the file change that sets or removes one key, refusing what ccdesk cannot do safely.
53
39
  * `checksHiddenValues` is for a key whose edit checks the part it changes itself, so values it
@@ -60,7 +46,7 @@ async function planKey(env, file, key, value, checksHiddenValues = false) {
60
46
  if (!validate)
61
47
  throw new RefusedError(`${key} is not in Claude Code's settings schema (${schema.$comment}).`);
62
48
  const { path } = file;
63
- const { raw, data } = await readSettings(env, path);
49
+ const { raw, data, reformats } = await readJsonObject(env, path);
64
50
  const nextValue = value(data);
65
51
  // ccdesk never shows hidden values, so it must not write them either.
66
52
  if (!checksHiddenValues &&
@@ -68,33 +54,29 @@ async function planKey(env, file, key, value, checksHiddenValues = false) {
68
54
  throw new RefusedError(`${key} holds hidden values; edit it in the file instead.`);
69
55
  if (raw !== null && JSON.stringify(nextValue) === JSON.stringify(data[key]))
70
56
  throw new RefusedError("This file already says that; there is nothing to change.");
71
- const before = raw === null ? null : shown(data);
72
- // The diff is rendered from the parsed file, so a file in another layout is reported as
73
- // reformatted instead of diffing its raw text.
74
- const reformats = raw !== null && raw.trim() !== "" && raw !== `${JSON.stringify(data, null, 2)}\n`;
75
- const base = { path, before, revision: revisionOf(raw), reformats };
76
57
  const errors = nextValue === undefined
77
58
  ? []
78
59
  : [...schemaErrors(validate, nextValue, key), ...newHookEvents(key, data[key], nextValue)];
79
- if (errors.length > 0)
80
- return {
81
- path,
82
- scope: file.scope,
83
- raw,
84
- next: null,
85
- preview: { ...base, after: before ?? "", errors },
86
- };
87
60
  const next = { ...data };
88
61
  if (nextValue === undefined)
89
62
  delete next[key];
90
63
  else
91
64
  next[key] = nextValue;
65
+ const text = `${JSON.stringify(next, null, 2)}\n`;
66
+ const before = raw === null ? null : shown(data);
92
67
  return {
93
- path,
94
- scope: file.scope,
95
- raw,
96
- next: `${JSON.stringify(next, null, 2)}\n`,
97
- preview: { ...base, after: shown(next), errors: [] },
68
+ preview: {
69
+ path,
70
+ before,
71
+ after: errors.length > 0 ? (before ?? "") : shown(next),
72
+ revision: revisionOf(raw),
73
+ reformats,
74
+ errors,
75
+ },
76
+ save: () => recordChange(env, { kind: "settings", target: path, before: raw }, async () => {
77
+ await writeAtomic(env, path, text, revisionOf(raw));
78
+ return { target: path, revision: revisionOf(text) };
79
+ }),
98
80
  };
99
81
  }
100
82
  async function planSettingsEdit(env, edit) {
@@ -150,88 +132,85 @@ function matcherOf(group) {
150
132
  /** One hook taken out, replaced or added, applied to the file as it is on disk. */
151
133
  async function planHookEdit(env, edit) {
152
134
  const file = await editableFile(env, edit.dir, (f) => f.path === edit.file);
153
- return planKey(env, file, "hooks", (data) => {
154
- const hooks = isRecord(data.hooks)
155
- ? structuredClone(data.hooks)
156
- : {};
157
- const groupsOf = (event) => {
158
- const groups = hooks[event];
159
- return Array.isArray(groups) ? groups : [];
160
- };
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.");
165
- if (remove) {
166
- const groups = groupsOf(remove.event);
167
- const group = groups[remove.at.group];
168
- const list = isRecord(group) && Array.isArray(group.hooks) ? group.hooks : null;
169
- const hook = list?.[remove.at.index];
170
- if (!list || JSON.stringify(redactSecrets(hook)) !== JSON.stringify(remove.definition))
171
- throw new RefusedError("This hook changed in the file since ccdesk read it; review it again.");
172
- // The page saw this hook with its header values hidden, so a changed copy would lose them.
173
- if (add && hasHiddenValues(hook))
174
- throw new RefusedError("This hook sends header values ccdesk does not show; edit it in the file, or remove it and add it again.");
175
- const matcher = add?.matcher ?? "";
176
- // A hook alone in its group keeps the group when only its matcher changes, unless another
177
- // group already has that matcher.
178
- const inPlace = add?.event === remove.event &&
179
- (matcher === matcherOf(group) ||
180
- (list.length === 1 && !groups.some((g) => g !== group && matcherOf(g) === matcher)));
181
- if (add && inPlace && isRecord(group)) {
182
- list[remove.at.index] = add.definition;
183
- if (matcher)
184
- group.matcher = matcher;
185
- else
186
- delete group.matcher;
187
- return hooks;
188
- }
189
- list.splice(remove.at.index, 1);
190
- if (list.length === 0)
191
- groups.splice(remove.at.group, 1);
192
- if (groups.length === 0 && add?.event !== remove.event)
193
- delete hooks[remove.event];
194
- }
195
- if (add) {
196
- const groups = groupsOf(add.event);
197
- const matcher = add.matcher ?? "";
198
- const group = groups.find((g) => matcherOf(g) === matcher && isRecord(g) && Array.isArray(g.hooks));
199
- if (isRecord(group) && Array.isArray(group.hooks))
200
- group.hooks.push(add.definition);
201
- else
202
- groups.push({ ...(matcher ? { matcher } : {}), hooks: [add.definition] });
203
- hooks[add.event] = groups;
204
- }
205
- return Object.keys(hooks).length > 0 ? hooks : undefined;
206
- }, true);
207
- }
208
- /** Writes a planned edit, but only if the file is still the revision the preview was made from. */
209
- async function commit(env, plan, expectedRevision) {
210
- if (plan.next === null)
211
- throw new RefusedError(plan.preview.errors.join("; "));
212
- if (plan.preview.revision !== expectedRevision)
213
- throw new RefusedError("The file changed since you reviewed this edit; review it again.");
214
- const next = plan.next;
215
- return recordChange(env, { kind: "settings", target: plan.path, before: plan.raw }, async () => {
216
- await writeAtomic(env, plan.path, next, expectedRevision);
217
- return { target: plan.path, revision: revisionOf(next) };
218
- });
135
+ return planKey(env, file, "hooks", (data) => editedHooks(data.hooks, edit), true);
136
+ }
137
+ /** The hooks setting with the edit applied, or undefined when no hooks are left. */
138
+ function editedHooks(value, edit) {
139
+ const hooks = isRecord(value) ? structuredClone(value) : {};
140
+ const { remove, add } = edit;
141
+ // Checked before any branch, so neither a new hook nor a replacement writes them.
142
+ if (add && hasHiddenValues(add.definition))
143
+ throw new RefusedError("ccdesk does not write header values it would then hide; add this hook in the file instead.");
144
+ if (remove && takeOutHook(hooks, remove, add) === "replaced")
145
+ return hooks;
146
+ if (add) {
147
+ const groups = groupsOf(hooks, add.event);
148
+ const matcher = add.matcher ?? "";
149
+ const group = groups.find((g) => matcherOf(g) === matcher && isRecord(g) && Array.isArray(g.hooks));
150
+ if (isRecord(group) && Array.isArray(group.hooks))
151
+ group.hooks.push(add.definition);
152
+ else
153
+ groups.push({ ...(matcher ? { matcher } : {}), hooks: [add.definition] });
154
+ hooks[add.event] = groups;
155
+ }
156
+ return Object.keys(hooks).length > 0 ? hooks : undefined;
219
157
  }
158
+ /** An event's matcher groups as stored in `hooks`, or a new list when it has none. */
159
+ function groupsOf(hooks, event) {
160
+ const groups = hooks[event];
161
+ return Array.isArray(groups) ? groups : [];
162
+ }
163
+ /**
164
+ * Takes the reviewed hook out of `hooks`, or puts `add` in its place when only its matcher or
165
+ * nothing about where it sits changes.
166
+ */
167
+ function takeOutHook(hooks, remove, add) {
168
+ const groups = groupsOf(hooks, remove.event);
169
+ const group = groups[remove.at.group];
170
+ const list = isRecord(group) && Array.isArray(group.hooks) ? group.hooks : null;
171
+ const hook = list?.[remove.at.index];
172
+ if (!list || JSON.stringify(redactSecrets(hook)) !== JSON.stringify(remove.definition))
173
+ throw new RefusedError("This hook changed in the file since ccdesk read it; review it again.");
174
+ // The page saw this hook with its header values hidden, so a changed copy would lose them.
175
+ if (add && hasHiddenValues(hook))
176
+ throw new RefusedError("This hook sends header values ccdesk does not show; edit it in the file, or remove it and add it again.");
177
+ const matcher = add?.matcher ?? "";
178
+ // A hook alone in its group keeps the group when only its matcher changes, unless another
179
+ // group already has that matcher.
180
+ const inPlace = add?.event === remove.event &&
181
+ (matcher === matcherOf(group) ||
182
+ (list.length === 1 && !groups.some((g) => g !== group && matcherOf(g) === matcher)));
183
+ if (add && inPlace && isRecord(group)) {
184
+ list[remove.at.index] = add.definition;
185
+ if (matcher)
186
+ group.matcher = matcher;
187
+ else
188
+ delete group.matcher;
189
+ return "replaced";
190
+ }
191
+ list.splice(remove.at.index, 1);
192
+ if (list.length === 0)
193
+ groups.splice(remove.at.group, 1);
194
+ if (groups.length === 0 && add?.event !== remove.event)
195
+ delete hooks[remove.event];
196
+ return "removed";
197
+ }
198
+ const CHANGED = "The file changed since you reviewed this edit; review it again.";
220
199
  export async function previewSettingsEdit(env, edit) {
221
200
  return (await planSettingsEdit(env, edit)).preview;
222
201
  }
223
- export function saveSettingsEdit(env, edit, expectedRevision) {
224
- return serially(async () => commit(env, await planSettingsEdit(env, edit), expectedRevision));
202
+ export function saveSettingsEdit(env, edit, expectedRevision, queue) {
203
+ return saveReviewed(queue, () => planSettingsEdit(env, edit), expectedRevision, CHANGED);
225
204
  }
226
205
  export async function previewPermissionEdit(env, edit) {
227
206
  return (await planPermissionEdit(env, edit)).preview;
228
207
  }
229
- export function savePermissionEdit(env, edit, expectedRevision) {
230
- return serially(async () => commit(env, await planPermissionEdit(env, edit), expectedRevision));
208
+ export function savePermissionEdit(env, edit, expectedRevision, queue) {
209
+ return saveReviewed(queue, () => planPermissionEdit(env, edit), expectedRevision, CHANGED);
231
210
  }
232
211
  export async function previewHookEdit(env, edit) {
233
212
  return (await planHookEdit(env, edit)).preview;
234
213
  }
235
- export function saveHookEdit(env, edit, expectedRevision) {
236
- return serially(async () => commit(env, await planHookEdit(env, edit), expectedRevision));
214
+ export function saveHookEdit(env, edit, expectedRevision, queue) {
215
+ return saveReviewed(queue, () => planHookEdit(env, edit), expectedRevision, CHANGED);
237
216
  }
@@ -0,0 +1,45 @@
1
+ import { type DirectoryView, type SessionProfile } from "../shared/types.ts";
2
+ import { type ScanEnv } from "./scan/env.ts";
3
+ import { scanDirectory } from "./scan/index.ts";
4
+ /** One set of file watchers per directory a page is looking at, shared by its requests. */
5
+ export declare class Watcher {
6
+ private readonly subscriptions;
7
+ /** What the latest scan of each directory read, kept for a watch that starts after the scan. */
8
+ private readonly scanned;
9
+ private readonly latest;
10
+ private readonly env;
11
+ private readonly scanOf;
12
+ constructor(env: ScanEnv, scanOf?: typeof scanDirectory);
13
+ /** Scans `dir` and remembers what the scan read, so those files are watched too. */
14
+ scan(dir: string, profile?: SessionProfile): Promise<DirectoryView>;
15
+ /**
16
+ * The view of `dir`: the latest scan while it is known to be current, else a new scan. It is
17
+ * current only while watchers that started before it have seen no change since it started, and
18
+ * ~/.claude.json, which is not watched, is as it was. Only an interactive scan is reused, as the
19
+ * path tester describes an interactive session.
20
+ */
21
+ view(dir: string): Promise<DirectoryView>;
22
+ /** ~/.claude.json's size and time; Claude Code rewrites it too often to watch. */
23
+ private stateFile;
24
+ /**
25
+ * Answers once something changed after `since` (ms since the epoch), or after `timeoutMs` with
26
+ * the last change time, whichever comes first.
27
+ */
28
+ wait(dir: string, since: number, signal: AbortSignal, timeoutMs?: number): Promise<{
29
+ changed: boolean;
30
+ at: number;
31
+ }>;
32
+ /**
33
+ * Calls `onChange` once after each change to what ccdesk shows for `dir`, until the returned
34
+ * function is called. Resolves once the watchers have started, so a change made after it
35
+ * resolves is seen. `close` stops the calls without making one.
36
+ */
37
+ subscribe(dir: string, onChange: () => void): Promise<() => void>;
38
+ /** Drops the watchers of `dir` after a while once no one waits or listens for changes. */
39
+ private release;
40
+ close(): void;
41
+ private watching;
42
+ private drop;
43
+ /** (Re)creates the watchers, so a folder created since, such as a new .claude, is watched. */
44
+ private rewatch;
45
+ }
@@ -4,9 +4,10 @@ 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 { isDir, isFile, isNetworkPath, mainWorktreeRoot } from "./scan/files.js";
7
+ import { isDir, isFile, isNetworkPath } from "./scan/files.js";
8
+ import { mainWorktreeRoot } from "./scan/git.js";
8
9
  import { scanDirectory } from "./scan/index.js";
9
- import { manifestCommandMap, manifestPaths } from "./scan/plugins.js";
10
+ import { loadPlugin, manifestCommandMap, manifestPaths } from "./scan/plugins.js";
10
11
  /** How long one request waits for a change before answering "nothing changed". */
11
12
  const WAIT_MS = 25_000;
12
13
  /** Editors write a file in several steps; one change covers them all. */
@@ -60,7 +61,7 @@ async function targets(env, dir, extra) {
60
61
  * plugins. A missing import is watched too, so creating it shows up, even when its folder is missing
61
62
  * as well.
62
63
  */
63
- export async function dependencies(env, view) {
64
+ async function dependencies(env, view) {
64
65
  const files = view.instructions.flatMap((i) => [
65
66
  i.realPath ?? i.path,
66
67
  ...i.imports.map((r) => r.resolved),
@@ -92,8 +93,10 @@ async function pluginTargets(env, view, plugin) {
92
93
  // A path from installed_plugins.json that is not absolute names no folder Claude Code would load.
93
94
  if (!isAbsolute(root))
94
95
  return [];
95
- const named = await Promise.all(PLUGIN_KEYS.map((key) => manifestPaths(env, plugin, key)));
96
- const commands = await manifestCommandMap(env, plugin);
96
+ // The scan reports a manifest that does not parse; here it only names paths to watch.
97
+ const loaded = await loadPlugin(env, plugin, []);
98
+ const named = await Promise.all(PLUGIN_KEYS.map((key) => manifestPaths(loaded, key)));
99
+ const commands = await manifestCommandMap(loaded);
97
100
  // Without a skills folder, each folder at the root holding SKILL.md is a skill.
98
101
  const rootSkills = view.inventory.skills
99
102
  .filter((s) => s.plugin === plugin.id && s.folder && dirname(s.folder) === root)
@@ -137,12 +140,12 @@ async function nearestWatchable(target) {
137
140
  }
138
141
  /** One set of file watchers per directory a page is looking at, shared by its requests. */
139
142
  export class Watcher {
140
- env;
141
- scanOf;
142
143
  subscriptions = new Map();
143
144
  /** What the latest scan of each directory read, kept for a watch that starts after the scan. */
144
145
  scanned = new Map();
145
146
  latest = new Map();
147
+ env;
148
+ scanOf;
146
149
  constructor(env, scanOf = scanDirectory) {
147
150
  this.env = env;
148
151
  this.scanOf = scanOf;
@@ -160,7 +163,7 @@ export class Watcher {
160
163
  this.scanned.set(view.path, extra);
161
164
  if (subscription && JSON.stringify(extra) !== JSON.stringify(subscription.extra)) {
162
165
  subscription.extra = extra;
163
- void this.rewatch(view.path, subscription);
166
+ subscription.ready = this.rewatch(view.path, subscription);
164
167
  }
165
168
  return view;
166
169
  }
@@ -191,7 +194,7 @@ export class Watcher {
191
194
  * the last change time, whichever comes first.
192
195
  */
193
196
  async wait(dir, since, signal, timeoutMs = WAIT_MS) {
194
- const subscription = await this.subscribe(dir);
197
+ const subscription = await this.watching(dir);
195
198
  clearTimeout(subscription.idle);
196
199
  // Closed while its watchers were starting: there is nothing left to wait on.
197
200
  const closed = this.subscriptions.get(dir) !== subscription;
@@ -210,30 +213,63 @@ export class Watcher {
210
213
  });
211
214
  }
212
215
  finally {
213
- // A dropped subscription has nothing left to clean up later.
214
- if (subscription.waiters.size === 0 && this.subscriptions.get(dir) === subscription)
215
- subscription.idle = setTimeout(() => this.drop(dir), IDLE_MS);
216
+ this.release(dir, subscription);
216
217
  }
217
218
  return { changed: subscription.changedAt > since, at: subscription.changedAt };
218
219
  }
220
+ /**
221
+ * Calls `onChange` once after each change to what ccdesk shows for `dir`, until the returned
222
+ * function is called. Resolves once the watchers have started, so a change made after it
223
+ * resolves is seen. `close` stops the calls without making one.
224
+ */
225
+ async subscribe(dir, onChange) {
226
+ const subscription = await this.watching(dir);
227
+ // Closed while its watchers were starting: nothing will change.
228
+ if (this.subscriptions.get(dir) !== subscription)
229
+ return () => {
230
+ // Nothing was subscribed, so there is nothing to stop.
231
+ };
232
+ clearTimeout(subscription.idle);
233
+ // A function of its own, so the same onChange subscribed twice is unsubscribed twice.
234
+ const listener = () => onChange();
235
+ subscription.listeners.add(listener);
236
+ return () => {
237
+ if (subscription.listeners.delete(listener))
238
+ this.release(dir, subscription);
239
+ };
240
+ }
241
+ /** Drops the watchers of `dir` after a while once no one waits or listens for changes. */
242
+ release(dir, subscription) {
243
+ // A dropped subscription has nothing left to clean up later.
244
+ if (subscription.waiters.size === 0 &&
245
+ subscription.listeners.size === 0 &&
246
+ this.subscriptions.get(dir) === subscription)
247
+ subscription.idle = setTimeout(() => this.drop(dir), IDLE_MS);
248
+ }
219
249
  close() {
220
250
  for (const dir of [...this.subscriptions.keys()])
221
251
  this.drop(dir);
222
252
  }
223
- async subscribe(dir) {
253
+ async watching(dir) {
224
254
  const existing = this.subscriptions.get(dir);
225
- if (existing)
255
+ if (existing) {
256
+ // Watchers moving to a new target after a change are not watching it yet.
257
+ await existing.ready;
226
258
  return existing;
259
+ }
227
260
  const subscription = {
228
261
  watchers: [],
262
+ ready: Promise.resolve(),
229
263
  changedAt: 0,
230
264
  touchedAt: 0,
231
265
  watchingSince: Number.POSITIVE_INFINITY,
232
266
  waiters: new Set(),
267
+ listeners: new Set(),
233
268
  extra: this.scanned.get(dir) ?? [],
234
269
  };
235
270
  this.subscriptions.set(dir, subscription);
236
- await this.rewatch(dir, subscription);
271
+ subscription.ready = this.rewatch(dir, subscription);
272
+ await subscription.ready;
237
273
  return subscription;
238
274
  }
239
275
  drop(dir) {
@@ -269,9 +305,10 @@ export class Watcher {
269
305
  clearTimeout(subscription.settle);
270
306
  subscription.settle = setTimeout(() => {
271
307
  subscription.changedAt = Date.now();
272
- for (const wake of [...subscription.waiters])
308
+ // First, so a subscriber that throws cannot leave the old watchers in place.
309
+ subscription.ready = this.rewatch(dir, subscription);
310
+ for (const wake of [...subscription.waiters, ...subscription.listeners])
273
311
  wake();
274
- void this.rewatch(dir, subscription);
275
312
  }, SETTLE_MS);
276
313
  });
277
314
  watcher.on("error", () => watcher.close());
@@ -0,0 +1,117 @@
1
+ import { type Static, Type } from "typebox";
2
+ import { type BackupKind } from "../shared/types.ts";
3
+ import { type ScanEnv } from "./scan/env.ts";
4
+ /** A change ccdesk refuses on purpose; the server answers 409 with the message. */
5
+ export declare class RefusedError extends Error {
6
+ }
7
+ /** A write refused because its file changed after review: nothing was written. */
8
+ export declare class StaleError extends RefusedError {
9
+ }
10
+ /**
11
+ * Runs changes one at a time, so two of them can never both pass a revision check before either
12
+ * writes. Every exported change goes through one; helpers it calls must not.
13
+ */
14
+ export type Serially = <T>(change: () => Promise<T>) => Promise<T>;
15
+ /** A new, empty queue of changes. Each core has its own. */
16
+ export declare function changeQueue(): Serially;
17
+ /** What every change's review has: the revision it was worked out from and why it cannot be saved. */
18
+ interface Review {
19
+ /** Fingerprint of everything the change was worked out from. */
20
+ revision: string;
21
+ /** When there are any, nothing is saved. */
22
+ errors?: string[];
23
+ }
24
+ /** A change worked out from disk as it is now: what it shows for review, and how to make it. */
25
+ export interface Planned<Preview extends Review, T> {
26
+ preview: Preview;
27
+ save: () => Promise<T>;
28
+ }
29
+ /**
30
+ * Plans a change again and makes it, but only when it has no errors and everything it was worked
31
+ * out from is as reviewed; `changed` is the refusal otherwise. Planning and saving share one turn
32
+ * of the queue, so no other change lands between the check and the write.
33
+ */
34
+ export declare function saveReviewed<T>(queue: Serially, plan: () => Promise<Planned<Review, T>>, expectedRevision: string, changed: string): Promise<T>;
35
+ export declare function backupRoot(env: ScanEnv): string;
36
+ /** The file's text, or null when it does not exist. Any other read error is thrown, not hidden. */
37
+ export declare function readIfExists(env: ScanEnv, path: string): Promise<string | null>;
38
+ /**
39
+ * A JSON file that holds an object, as ccdesk edits it: missing or empty is `{}`, anything else
40
+ * that is not an object is refused. `reformats` says whether writing it back as 2-space JSON
41
+ * changes more than the edit, since its diff is rendered from the parsed file.
42
+ */
43
+ export declare function readJsonObject(env: ScanEnv, path: string): Promise<{
44
+ raw: string | null;
45
+ data: Record<string, unknown>;
46
+ reformats: boolean;
47
+ }>;
48
+ /**
49
+ * Replaces a file's content, but only while it still has `expected` revision. The new text is
50
+ * written to a temporary file first and the revision is checked again just before the rename, so
51
+ * an edit made elsewhere while ccdesk was saving is kept and the save refused. A symlink is written
52
+ * through, so links into a dotfiles repository survive; the temporary file is new, unguessable and
53
+ * created exclusively, so nothing planted beside the target can redirect the write. A file with
54
+ * other hard links is refused, since the rename would leave them on the old text.
55
+ */
56
+ export declare function writeAtomic(env: ScanEnv, path: string, content: string, expected: string): Promise<void>;
57
+ /**
58
+ * Deletes a file only while it still has `expected` revision, and says whether it did. The file is
59
+ * renamed aside first, so what is removed is exactly what was checked; one that differs is put
60
+ * back, unless a new file took its place meanwhile, which ccdesk then refuses to overwrite.
61
+ */
62
+ export declare function removeExactly(path: string, expected: string): Promise<boolean>;
63
+ /** What a backup folder records about its change, in its manifest. */
64
+ export declare const BackupManifestSchema: Type.TObject<{
65
+ kind: Type.TEnum<["settings", "mcp", "agent", "memory-index", "memory-delete", "memory-restore", "mcp-claude"]>;
66
+ /** The file that changed (for a deleted memory: the memory file). */
67
+ target: Type.TString;
68
+ createdAt: Type.TString;
69
+ /** Revision of the target before the change; "missing" when ccdesk created it. */
70
+ before: Type.TString;
71
+ /** Revision of the target right after the change; "pending" while it runs, "partial" when it failed halfway. */
72
+ after: Type.TString;
73
+ /** For a deleted memory: the MEMORY.md lines removed with it. */
74
+ removedIndexLines: Type.TOptional<Type.TArray<Type.TString>>;
75
+ /** For a change made through `claude mcp`: the commands that put it back, values hidden. */
76
+ undoCommands: Type.TOptional<Type.TArray<Type.TString>>;
77
+ }>;
78
+ export type BackupManifest = Static<typeof BackupManifestSchema>;
79
+ export declare const MANIFEST = "ccdesk-backup.json";
80
+ /** Takes a change out of Recent changes, for one whose effect was undone again right away. */
81
+ export declare function forgetChange(env: ScanEnv, id: string): Promise<void>;
82
+ /** The copy of the target inside a backup folder. */
83
+ export declare const SNAPSHOT = "before";
84
+ /** For a change made through `claude mcp`: the affected servers after it, values hidden. */
85
+ export declare const SHOWN_AFTER = "after";
86
+ /**
87
+ * Lists a change Claude Code made through `claude mcp` in Recent changes. ccdesk cannot undo it,
88
+ * so it keeps only the affected servers before and after as the page shows them, secrets hidden,
89
+ * and the commands that would put them back; never a value the page does not show.
90
+ */
91
+ export declare function recordClaudeMcpChange(env: ScanEnv, record: {
92
+ target: string;
93
+ shownBefore: string;
94
+ shownAfter: string;
95
+ undoCommands: string[];
96
+ }): Promise<void>;
97
+ /**
98
+ * Makes a change and records it in Recent changes. The file's previous text and a manifest are put
99
+ * in a new backup folder first, so a change interrupted halfway can still be restored; the manifest
100
+ * gets the new revision once the change is made. A change that fails is removed from Recent changes
101
+ * when its first write was refused as stale, or when `unchanged` confirms nothing on disk moved;
102
+ * otherwise it stays, marked partial, so it can be put back. Backups can hold settings with secrets, so only the user can read them.
103
+ */
104
+ export declare function recordChange<T extends {
105
+ revision: string;
106
+ }>(env: ScanEnv, record: {
107
+ kind: BackupKind;
108
+ target: string;
109
+ before: string | null;
110
+ removedIndexLines?: string[];
111
+ /** Whether disk is still as before the change; by default, whether the target is. */
112
+ unchanged?: () => Promise<boolean>;
113
+ }, change: () => Promise<T>): Promise<T & {
114
+ id: string;
115
+ created: boolean;
116
+ }>;
117
+ export {};