ccdesk 0.2.1 → 0.2.3

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 +54 -0
  2. package/README.md +9 -5
  3. package/dist/core/agent-edit.d.ts +6 -0
  4. package/dist/{server → core}/agent-edit.js +21 -22
  5. package/dist/core/backups.d.ts +19 -0
  6. package/dist/{server → core}/backups.js +37 -45
  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 +25 -0
  12. package/dist/{server → core}/mcp-edit.js +116 -95
  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 +31 -0
  16. package/dist/{server → core}/open.js +39 -12
  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 +5 -3
  23. package/dist/core/scan/context.d.ts +13 -0
  24. package/dist/{server → core}/scan/context.js +58 -32
  25. package/dist/core/scan/data.d.ts +8 -0
  26. package/dist/core/scan/data.js +18 -0
  27. package/dist/core/scan/env.d.ts +26 -0
  28. package/dist/{server → core}/scan/env.js +10 -3
  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 +14 -0
  32. package/dist/core/scan/git.js +98 -0
  33. package/dist/core/scan/index.d.ts +5 -0
  34. package/dist/{server → core}/scan/index.js +115 -81
  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 +151 -94
  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 +6 -9
  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 +5 -4
  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 +35 -0
  54. package/dist/{server → core}/scan/settings.js +88 -92
  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 +25 -0
  58. package/dist/{server → core}/scan/usage.js +117 -92
  59. package/dist/core/schema.d.ts +8 -0
  60. package/dist/core/schema.js +67 -0
  61. package/dist/core/secrets.d.ts +44 -0
  62. package/dist/core/secrets.js +187 -0
  63. package/dist/core/settings-edit.d.ts +9 -0
  64. package/dist/core/settings-edit.js +216 -0
  65. package/dist/core/watch.d.ts +45 -0
  66. package/dist/{server → core}/watch.js +129 -20
  67. package/dist/core/write.d.ts +119 -0
  68. package/dist/{server → core}/write.js +63 -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 +2 -0
  81. package/dist/shared/requests.d.ts +104 -0
  82. package/dist/shared/requests.js +73 -0
  83. package/dist/shared/scopes.d.ts +10 -0
  84. package/dist/shared/settings-catalog.json +2199 -0
  85. package/dist/shared/types.d.ts +530 -0
  86. package/dist/shared/types.js +11 -0
  87. package/dist/ui/assets/index-CH1HoC38.js +60 -0
  88. package/dist/ui/index.html +1 -1
  89. package/dist/view/diff.d.ts +16 -0
  90. package/dist/view/diff.js +85 -0
  91. package/dist/view/format.d.ts +17 -0
  92. package/dist/view/format.js +47 -0
  93. package/dist/view/index.d.ts +11 -0
  94. package/dist/view/index.js +13 -0
  95. package/dist/view/lines.d.ts +9 -0
  96. package/dist/view/lines.js +12 -0
  97. package/dist/view/path-test.d.ts +3 -0
  98. package/dist/view/path-test.js +7 -0
  99. package/dist/view/sections/catalog.d.ts +9 -0
  100. package/dist/view/sections/catalog.js +47 -0
  101. package/dist/view/sections/extensions.d.ts +1 -0
  102. package/dist/view/sections/extensions.js +48 -0
  103. package/dist/view/sections/hooks.d.ts +1 -0
  104. package/dist/view/sections/hooks.js +32 -0
  105. package/dist/view/sections/index.d.ts +6 -0
  106. package/dist/view/sections/index.js +27 -0
  107. package/dist/view/sections/instructions.d.ts +6 -0
  108. package/dist/view/sections/instructions.js +38 -0
  109. package/dist/view/sections/mcp.d.ts +3 -0
  110. package/dist/view/sections/mcp.js +38 -0
  111. package/dist/view/sections/memory.d.ts +1 -0
  112. package/dist/view/sections/memory.js +31 -0
  113. package/dist/view/sections/permissions.d.ts +17 -0
  114. package/dist/view/sections/permissions.js +87 -0
  115. package/dist/view/sections/settings.d.ts +1 -0
  116. package/dist/view/sections/settings.js +33 -0
  117. package/dist/view/sections/tools.d.ts +6 -0
  118. package/dist/view/sections/tools.js +57 -0
  119. package/dist/view/sections/types.d.ts +123 -0
  120. package/dist/view/sections/types.js +10 -0
  121. package/dist/view/settings.d.ts +12 -0
  122. package/dist/view/settings.js +31 -0
  123. package/dist/view/usage.d.ts +8 -0
  124. package/dist/view/usage.js +18 -0
  125. package/package.json +13 -2
  126. package/dist/server/scan/files.js +0 -478
  127. package/dist/server/scan/path-test.js +0 -121
  128. package/dist/server/schema.js +0 -15
  129. package/dist/server/secrets.js +0 -63
  130. package/dist/server/settings-edit.js +0 -221
  131. package/dist/ui/assets/index-XqJwI3k9.js +0 -60
@@ -1,11 +1,13 @@
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 } from "./scan/files.js";
8
+ import { mainWorktreeRoot } from "./scan/git.js";
8
9
  import { scanDirectory } from "./scan/index.js";
10
+ import { loadPlugin, manifestCommandMap, manifestPaths } from "./scan/plugins.js";
9
11
  /** How long one request waits for a change before answering "nothing changed". */
10
12
  const WAIT_MS = 25_000;
11
13
  /** Editors write a file in several steps; one change covers them all. */
@@ -31,7 +33,8 @@ async function targets(env, dir, extra) {
31
33
  const memoryKey = (await mainWorktreeRoot(dir)) ?? dir;
32
34
  const out = [
33
35
  ...claude(env.claudeDir),
34
- { path: join(env.claudeDir, "plugins"), recursive: false },
36
+ // Only the install list: Windows reports a write deep in a plugin as a change to `cache`.
37
+ { path: join(env.claudeDir, "plugins"), recursive: false, names: ["installed_plugins.json"] },
35
38
  { path: join(env.claudeDir, "projects", storeDirName(memoryKey), "memory"), recursive: false },
36
39
  { path: env.managedDir, recursive: false },
37
40
  { path: join(env.managedDir, "managed-settings.d"), recursive: false },
@@ -53,11 +56,15 @@ async function targets(env, dir, extra) {
53
56
  out.push(...claude(join(memoryKey, ".claude")));
54
57
  return out.filter((t) => !isNetworkPath(t.path));
55
58
  }
56
- /** Files a scan read that the fixed list does not cover: a custom memory folder, imports. */
57
- export function dependencies(view) {
59
+ /**
60
+ * Files a scan read that the fixed list does not cover: a custom memory folder, imports, installed
61
+ * plugins. A missing import is watched too, so creating it shows up, even when its folder is missing
62
+ * as well.
63
+ */
64
+ async function dependencies(env, view) {
58
65
  const files = view.instructions.flatMap((i) => [
59
66
  i.realPath ?? i.path,
60
- ...i.imports.filter((r) => r.exists).map((r) => r.resolved),
67
+ ...i.imports.map((r) => r.resolved),
61
68
  ]);
62
69
  const byFolder = new Map();
63
70
  for (const file of files) {
@@ -65,19 +72,80 @@ export function dependencies(view) {
65
72
  names.add(basename(file));
66
73
  byFolder.set(dirname(file), names);
67
74
  }
75
+ const plugins = await Promise.all(view.inventory.plugins.map((p) => pluginTargets(env, view, p)));
68
76
  return [
69
77
  ...[...byFolder].map(([path, names]) => ({ path, recursive: false, names: [...names] })),
70
78
  ...(view.memory ? [{ path: view.memory.dir, recursive: false }] : []),
79
+ ...plugins.flat(),
80
+ ];
81
+ }
82
+ /** A plugin's folders the scan reads from, when they are not named in its manifest. */
83
+ const PLUGIN_FOLDERS = ["agents", "skills", "commands", "output-styles", "hooks"];
84
+ /** Manifest keys that name files or folders inside the plugin. */
85
+ const PLUGIN_KEYS = ["agents", "skills", "commands", "outputStyles", "hooks", "mcpServers"];
86
+ /**
87
+ * What the scan reads in a plugin: its manifest, .mcp.json, a skill at its root, its component
88
+ * folders and the paths its manifest names. Not the whole plugin, which can hold an MCP server's
89
+ * node_modules and files the server writes while it runs.
90
+ */
91
+ async function pluginTargets(env, view, plugin) {
92
+ const root = plugin.installPath;
93
+ // A path from installed_plugins.json that is not absolute names no folder Claude Code would load.
94
+ if (!isAbsolute(root))
95
+ return [];
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);
100
+ // Without a skills folder, each folder at the root holding SKILL.md is a skill.
101
+ const rootSkills = view.inventory.skills
102
+ .filter((s) => s.plugin === plugin.id && s.folder && dirname(s.folder) === root)
103
+ .map((s) => s.folder);
104
+ const paths = [
105
+ ...PLUGIN_FOLDERS.map((name) => join(root, name)),
106
+ ...named.flatMap((list) => list ?? []),
107
+ ...commands.map((c) => c.path),
108
+ ...rootSkills,
109
+ ];
110
+ const out = [
111
+ {
112
+ path: root,
113
+ recursive: false,
114
+ names: [".claude-plugin", ".mcp.json", "SKILL.md", ...PLUGIN_FOLDERS],
115
+ },
116
+ { path: join(root, ".claude-plugin"), recursive: false, names: ["plugin.json"] },
71
117
  ];
118
+ for (const path of new Set(paths)) {
119
+ if (await isDir(path))
120
+ out.push({ path, recursive: true });
121
+ else if (await isFile(path))
122
+ out.push({ path: dirname(path), recursive: false, names: [basename(path)] });
123
+ }
124
+ return out;
125
+ }
126
+ /**
127
+ * `target`, or when it does not exist yet, the nearest folder above it that does, watched for the
128
+ * next missing name only. Watching needs a folder that exists, and the watchers are rebuilt after
129
+ * every change, so each folder that appears moves the watch one level closer.
130
+ */
131
+ async function nearestWatchable(target) {
132
+ if (isNetworkPath(target.path) || (await stat(target.path).catch(() => null)))
133
+ return target;
134
+ for (let missing = target.path; dirname(missing) !== missing; missing = dirname(missing)) {
135
+ const parent = dirname(missing);
136
+ if (await stat(parent).catch(() => null))
137
+ return { path: parent, recursive: false, names: [basename(missing)] };
138
+ }
139
+ return target;
72
140
  }
73
141
  /** One set of file watchers per directory a page is looking at, shared by its requests. */
74
142
  export class Watcher {
75
- env;
76
- scanOf;
77
143
  subscriptions = new Map();
78
144
  /** What the latest scan of each directory read, kept for a watch that starts after the scan. */
79
145
  scanned = new Map();
80
146
  latest = new Map();
147
+ env;
148
+ scanOf;
81
149
  constructor(env, scanOf = scanDirectory) {
82
150
  this.env = env;
83
151
  this.scanOf = scanOf;
@@ -91,11 +159,11 @@ export class Watcher {
91
159
  // Only a watched directory's view can be known to be current later.
92
160
  if (subscription)
93
161
  this.latest.set(view.path, { view, startedAt, stateFile });
94
- const extra = dependencies(view);
162
+ const extra = await dependencies(this.env, view);
95
163
  this.scanned.set(view.path, extra);
96
164
  if (subscription && JSON.stringify(extra) !== JSON.stringify(subscription.extra)) {
97
165
  subscription.extra = extra;
98
- void this.rewatch(view.path, subscription);
166
+ subscription.ready = this.rewatch(view.path, subscription);
99
167
  }
100
168
  return view;
101
169
  }
@@ -126,10 +194,12 @@ export class Watcher {
126
194
  * the last change time, whichever comes first.
127
195
  */
128
196
  async wait(dir, since, signal, timeoutMs = WAIT_MS) {
129
- const subscription = await this.subscribe(dir);
197
+ const subscription = await this.watching(dir);
130
198
  clearTimeout(subscription.idle);
199
+ // Closed while its watchers were starting: there is nothing left to wait on.
200
+ const closed = this.subscriptions.get(dir) !== subscription;
131
201
  try {
132
- if (subscription.changedAt <= since && !signal.aborted)
202
+ if (!closed && subscription.changedAt <= since && !signal.aborted)
133
203
  await new Promise((resolve) => {
134
204
  const done = () => {
135
205
  clearTimeout(timer);
@@ -143,29 +213,63 @@ export class Watcher {
143
213
  });
144
214
  }
145
215
  finally {
146
- if (subscription.waiters.size === 0)
147
- subscription.idle = setTimeout(() => this.drop(dir), IDLE_MS);
216
+ this.release(dir, subscription);
148
217
  }
149
218
  return { changed: subscription.changedAt > since, at: subscription.changedAt };
150
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
+ }
151
249
  close() {
152
250
  for (const dir of [...this.subscriptions.keys()])
153
251
  this.drop(dir);
154
252
  }
155
- async subscribe(dir) {
253
+ async watching(dir) {
156
254
  const existing = this.subscriptions.get(dir);
157
- if (existing)
255
+ if (existing) {
256
+ // Watchers moving to a new target after a change are not watching it yet.
257
+ await existing.ready;
158
258
  return existing;
259
+ }
159
260
  const subscription = {
160
261
  watchers: [],
262
+ ready: Promise.resolve(),
161
263
  changedAt: 0,
162
264
  touchedAt: 0,
163
265
  watchingSince: Number.POSITIVE_INFINITY,
164
266
  waiters: new Set(),
267
+ listeners: new Set(),
165
268
  extra: this.scanned.get(dir) ?? [],
166
269
  };
167
270
  this.subscriptions.set(dir, subscription);
168
- await this.rewatch(dir, subscription);
271
+ subscription.ready = this.rewatch(dir, subscription);
272
+ await subscription.ready;
169
273
  return subscription;
170
274
  }
171
275
  drop(dir) {
@@ -178,10 +282,14 @@ export class Watcher {
178
282
  w.close();
179
283
  this.subscriptions.delete(dir);
180
284
  this.latest.delete(dir);
285
+ // Pages still waiting are answered now, so no timer outlives the watcher.
286
+ for (const wake of [...subscription.waiters])
287
+ wake();
181
288
  }
182
289
  /** (Re)creates the watchers, so a folder created since, such as a new .claude, is watched. */
183
290
  async rewatch(dir, subscription) {
184
- const list = await targets(this.env, dir, subscription.extra);
291
+ const extra = await Promise.all(subscription.extra.map(nearestWatchable));
292
+ const list = await targets(this.env, dir, extra);
185
293
  for (const w of subscription.watchers)
186
294
  w.close();
187
295
  subscription.watchers = [];
@@ -197,9 +305,10 @@ export class Watcher {
197
305
  clearTimeout(subscription.settle);
198
306
  subscription.settle = setTimeout(() => {
199
307
  subscription.changedAt = Date.now();
200
- 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])
201
311
  wake();
202
- void this.rewatch(dir, subscription);
203
312
  }, SETTLE_MS);
204
313
  });
205
314
  watcher.on("error", () => watcher.close());
@@ -0,0 +1,119 @@
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
+ /** The queue the save functions use when they are called without a core's. */
18
+ export declare const serially: Serially;
19
+ /** What every change's review has: the revision it was worked out from and why it cannot be saved. */
20
+ interface Review {
21
+ /** Fingerprint of everything the change was worked out from. */
22
+ revision: string;
23
+ /** When there are any, nothing is saved. */
24
+ errors?: string[];
25
+ }
26
+ /** A change worked out from disk as it is now: what it shows for review, and how to make it. */
27
+ export interface Planned<Preview extends Review, T> {
28
+ preview: Preview;
29
+ save: () => Promise<T>;
30
+ }
31
+ /**
32
+ * Plans a change again and makes it, but only when it has no errors and everything it was worked
33
+ * out from is as reviewed; `changed` is the refusal otherwise. Planning and saving share one turn
34
+ * of the queue, so no other change lands between the check and the write.
35
+ */
36
+ export declare function saveReviewed<T>(queue: Serially, plan: () => Promise<Planned<Review, T>>, expectedRevision: string, changed: string): Promise<T>;
37
+ export declare function backupRoot(env: ScanEnv): string;
38
+ /** The file's text, or null when it does not exist. Any other read error is thrown, not hidden. */
39
+ export declare function readIfExists(env: ScanEnv, path: string): Promise<string | null>;
40
+ /**
41
+ * A JSON file that holds an object, as ccdesk edits it: missing or empty is `{}`, anything else
42
+ * that is not an object is refused. `reformats` says whether writing it back as 2-space JSON
43
+ * changes more than the edit, since its diff is rendered from the parsed file.
44
+ */
45
+ export declare function readJsonObject(env: ScanEnv, path: string): Promise<{
46
+ raw: string | null;
47
+ data: Record<string, unknown>;
48
+ reformats: boolean;
49
+ }>;
50
+ /**
51
+ * Replaces a file's content, but only while it still has `expected` revision. The new text is
52
+ * written to a temporary file first and the revision is checked again just before the rename, so
53
+ * an edit made elsewhere while ccdesk was saving is kept and the save refused. A symlink is written
54
+ * through, so links into a dotfiles repository survive; the temporary file is new, unguessable and
55
+ * created exclusively, so nothing planted beside the target can redirect the write. A file with
56
+ * other hard links is refused, since the rename would leave them on the old text.
57
+ */
58
+ export declare function writeAtomic(env: ScanEnv, path: string, content: string, expected: string): Promise<void>;
59
+ /**
60
+ * Deletes a file only while it still has `expected` revision, and says whether it did. The file is
61
+ * renamed aside first, so what is removed is exactly what was checked; one that differs is put
62
+ * back, unless a new file took its place meanwhile, which ccdesk then refuses to overwrite.
63
+ */
64
+ export declare function removeExactly(path: string, expected: string): Promise<boolean>;
65
+ /** What a backup folder records about its change, in its manifest. */
66
+ export declare const BackupManifestSchema: Type.TObject<{
67
+ kind: Type.TEnum<["settings", "mcp", "agent", "memory-index", "memory-delete", "memory-restore", "mcp-claude"]>;
68
+ /** The file that changed (for a deleted memory: the memory file). */
69
+ target: Type.TString;
70
+ createdAt: Type.TString;
71
+ /** Revision of the target before the change; "missing" when ccdesk created it. */
72
+ before: Type.TString;
73
+ /** Revision of the target right after the change; "pending" while it runs, "partial" when it failed halfway. */
74
+ after: Type.TString;
75
+ /** For a deleted memory: the MEMORY.md lines removed with it. */
76
+ removedIndexLines: Type.TOptional<Type.TArray<Type.TString>>;
77
+ /** For a change made through `claude mcp`: the commands that put it back, values hidden. */
78
+ undoCommands: Type.TOptional<Type.TArray<Type.TString>>;
79
+ }>;
80
+ export type BackupManifest = Static<typeof BackupManifestSchema>;
81
+ export declare const MANIFEST = "ccdesk-backup.json";
82
+ /** Takes a change out of Recent changes, for one whose effect was undone again right away. */
83
+ export declare function forgetChange(env: ScanEnv, id: string): Promise<void>;
84
+ /** The copy of the target inside a backup folder. */
85
+ export declare const SNAPSHOT = "before";
86
+ /** For a change made through `claude mcp`: the affected servers after it, values hidden. */
87
+ export declare const SHOWN_AFTER = "after";
88
+ /**
89
+ * Lists a change Claude Code made through `claude mcp` in Recent changes. ccdesk cannot undo it,
90
+ * so it keeps only the affected servers before and after as the page shows them, secrets hidden,
91
+ * and the commands that would put them back; never a value the page does not show.
92
+ */
93
+ export declare function recordClaudeMcpChange(env: ScanEnv, record: {
94
+ target: string;
95
+ shownBefore: string;
96
+ shownAfter: string;
97
+ undoCommands: string[];
98
+ }): Promise<void>;
99
+ /**
100
+ * Makes a change and records it in Recent changes. The file's previous text and a manifest are put
101
+ * in a new backup folder first, so a change interrupted halfway can still be restored; the manifest
102
+ * gets the new revision once the change is made. A change that fails is removed from Recent changes
103
+ * when its first write was refused as stale, or when `unchanged` confirms nothing on disk moved;
104
+ * otherwise it stays, marked partial, so it can be put back. Backups can hold settings with secrets, so only the user can read them.
105
+ */
106
+ export declare function recordChange<T extends {
107
+ revision: string;
108
+ }>(env: ScanEnv, record: {
109
+ kind: BackupKind;
110
+ target: string;
111
+ before: string | null;
112
+ removedIndexLines?: string[];
113
+ /** Whether disk is still as before the change; by default, whether the target is. */
114
+ unchanged?: () => Promise<boolean>;
115
+ }, change: () => Promise<T>): Promise<T & {
116
+ id: string;
117
+ created: boolean;
118
+ }>;
119
+ export {};
@@ -1,9 +1,12 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { chmod, link, lstat, mkdir, open, readFile, realpath, rename, rm, writeFile, } from "node:fs/promises";
3
3
  import { basename, dirname, join } from "node:path";
4
- import {} from "../shared/types.js";
4
+ import { Type } from "typebox";
5
+ import { BACKUP_KINDS } from "../shared/types.js";
6
+ import { isRecord } from "./scan/data.js";
5
7
  import {} from "./scan/env.js";
6
8
  import { isCredentials, isSecretPath, revisionOf } from "./scan/files.js";
9
+ import { jsonSyntaxError } from "./secrets.js";
7
10
  /** A change ccdesk refuses on purpose; the server answers 409 with the message. */
8
11
  export class RefusedError extends Error {
9
12
  }
@@ -12,15 +15,32 @@ export class StaleError extends RefusedError {
12
15
  }
13
16
  const CREDENTIALS = "ccdesk does not read or change Claude Code's credentials.";
14
17
  const STATE_FILE = "ccdesk does not change ~/.claude.json; Claude Code rewrites it all the time.";
15
- let queue = Promise.resolve();
18
+ /** A new, empty queue of changes. Each core has its own. */
19
+ export function changeQueue() {
20
+ let queue = Promise.resolve();
21
+ return (change) => {
22
+ const run = queue.then(change);
23
+ queue = run.catch(() => undefined);
24
+ return run;
25
+ };
26
+ }
27
+ /** The queue the save functions use when they are called without a core's. */
28
+ export const serially = changeQueue();
16
29
  /**
17
- * Runs changes one at a time, so two of them can never both pass a revision check before either
18
- * writes. Every exported change goes through here; helpers it calls must not.
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.
19
33
  */
20
- export function serially(change) {
21
- const run = queue.then(change);
22
- queue = run.catch(() => undefined);
23
- return run;
34
+ export function saveReviewed(queue, plan, expectedRevision, changed) {
35
+ return queue(async () => {
36
+ const { preview, save } = await plan();
37
+ const errors = preview.errors ?? [];
38
+ if (errors.length > 0)
39
+ throw new RefusedError(errors.join("; "));
40
+ if (preview.revision !== expectedRevision)
41
+ throw new RefusedError(changed);
42
+ return save();
43
+ });
24
44
  }
25
45
  export function backupRoot(env) {
26
46
  return join(env.claudeDir, "backups", "ccdesk");
@@ -60,6 +80,26 @@ export async function readIfExists(env, path) {
60
80
  throw error;
61
81
  }
62
82
  }
83
+ /**
84
+ * A JSON file that holds an object, as ccdesk edits it: missing or empty is `{}`, anything else
85
+ * that is not an object is refused. `reformats` says whether writing it back as 2-space JSON
86
+ * changes more than the edit, since its diff is rendered from the parsed file.
87
+ */
88
+ export async function readJsonObject(env, path) {
89
+ const raw = await readIfExists(env, path);
90
+ if (raw === null || raw.trim() === "")
91
+ return { raw, data: {}, reformats: false };
92
+ let parsed;
93
+ try {
94
+ parsed = JSON.parse(raw);
95
+ }
96
+ catch (error) {
97
+ throw new RefusedError(`${path} is ${jsonSyntaxError(error)}; fix it by hand first.`);
98
+ }
99
+ if (!isRecord(parsed))
100
+ throw new RefusedError(`${path} does not hold a JSON object.`);
101
+ return { raw, data: parsed, reformats: raw !== `${JSON.stringify(parsed, null, 2)}\n` };
102
+ }
63
103
  /**
64
104
  * Replaces a file's content, but only while it still has `expected` revision. The new text is
65
105
  * written to a temporary file first and the revision is checked again just before the rename, so
@@ -132,6 +172,21 @@ export async function removeExactly(path, expected) {
132
172
  await rm(aside);
133
173
  return false;
134
174
  }
175
+ /** What a backup folder records about its change, in its manifest. */
176
+ export const BackupManifestSchema = Type.Object({
177
+ kind: Type.Enum(BACKUP_KINDS),
178
+ /** The file that changed (for a deleted memory: the memory file). */
179
+ target: Type.String(),
180
+ createdAt: Type.String(),
181
+ /** Revision of the target before the change; "missing" when ccdesk created it. */
182
+ before: Type.String(),
183
+ /** Revision of the target right after the change; "pending" while it runs, "partial" when it failed halfway. */
184
+ after: Type.String(),
185
+ /** For a deleted memory: the MEMORY.md lines removed with it. */
186
+ removedIndexLines: Type.Optional(Type.Array(Type.String())),
187
+ /** For a change made through `claude mcp`: the commands that put it back, values hidden. */
188
+ undoCommands: Type.Optional(Type.Array(Type.String())),
189
+ });
135
190
  export const MANIFEST = "ccdesk-backup.json";
136
191
  /** Takes a change out of Recent changes, for one whose effect was undone again right away. */
137
192
  export function forgetChange(env, id) {
@@ -0,0 +1,32 @@
1
+ export declare const HELP = "ccdesk: see and change what Claude Code loads on this machine\n\nUsage:\n npx ccdesk [options] Open the page in your browser\n npx ccdesk scan [dir] --json Print what Claude Code loads in dir, as JSON\n npx ccdesk explain <file> [--dir <dir>] Print what loads once Claude reads or edits file\n\nOptions:\n --port <n> Port to listen on (default: a free port)\n --no-open Print the URL instead of opening a browser\n --config-dir <path> Claude folder to read, as CLAUDE_CONFIG_DIR sets it for Claude Code\n (wins over $CLAUDE_CONFIG_DIR)\n --dir <dir> For explain: the folder the session starts in (default: the current one)\n -h, --help Show this help\n\nccdesk reads ~/.claude (or --config-dir, or $CLAUDE_CONFIG_DIR). The page opens on the directory\nyou run ccdesk from and listens on 127.0.0.1 only. dir and file are relative to that directory.\nscan and explain write nothing and print no value the page hides.";
2
+ /** The port `--dev` listens on, which `pnpm dev:ui` proxies to. */
3
+ export declare const DEV_PORT = 4319;
4
+ /** What `ccdesk` was asked to do. */
5
+ export type Command = {
6
+ command: "help";
7
+ } | {
8
+ command: "serve";
9
+ port: number;
10
+ configDir: string | undefined;
11
+ open: boolean;
12
+ /** Serve the API only, on DEV_PORT with a fixed token, for the Vite dev server. */
13
+ dev: boolean;
14
+ }
15
+ /** Print the DirectoryView of `dir` (default: the current one) as JSON. */
16
+ | {
17
+ command: "scan";
18
+ dir: string | undefined;
19
+ configDir: string | undefined;
20
+ }
21
+ /** Print what loads once Claude touches `file` in a session started in `dir`. */
22
+ | {
23
+ command: "explain";
24
+ file: string;
25
+ dir: string | undefined;
26
+ configDir: string | undefined;
27
+ };
28
+ /** Arguments ccdesk cannot run with; the message is what to print. */
29
+ export declare class UsageError extends Error {
30
+ }
31
+ /** Reads the arguments after `ccdesk`, without touching the file system. */
32
+ export declare function parse(argv: string[]): Command;
@@ -0,0 +1,106 @@
1
+ import { parseArgs } from "node:util";
2
+ export const HELP = `ccdesk: see and change what Claude Code loads on this machine
3
+
4
+ Usage:
5
+ npx ccdesk [options] Open the page in your browser
6
+ npx ccdesk scan [dir] --json Print what Claude Code loads in dir, as JSON
7
+ npx ccdesk explain <file> [--dir <dir>] Print what loads once Claude reads or edits file
8
+
9
+ Options:
10
+ --port <n> Port to listen on (default: a free port)
11
+ --no-open Print the URL instead of opening a browser
12
+ --config-dir <path> Claude folder to read, as CLAUDE_CONFIG_DIR sets it for Claude Code
13
+ (wins over $CLAUDE_CONFIG_DIR)
14
+ --dir <dir> For explain: the folder the session starts in (default: the current one)
15
+ -h, --help Show this help
16
+
17
+ ccdesk reads ~/.claude (or --config-dir, or $CLAUDE_CONFIG_DIR). The page opens on the directory
18
+ you run ccdesk from and listens on 127.0.0.1 only. dir and file are relative to that directory.
19
+ scan and explain write nothing and print no value the page hides.`;
20
+ /** The port `--dev` listens on, which `pnpm dev:ui` proxies to. */
21
+ export const DEV_PORT = 4319;
22
+ /** Arguments ccdesk cannot run with; the message is what to print. */
23
+ export class UsageError extends Error {
24
+ }
25
+ /** The flags each command takes; --help goes with any. */
26
+ const FLAGS = {
27
+ serve: ["port", "open", "dev", "config-dir"],
28
+ scan: ["json", "config-dir"],
29
+ explain: ["dir", "config-dir"],
30
+ };
31
+ /** Reads the arguments after `ccdesk`, without touching the file system. */
32
+ export function parse(argv) {
33
+ let parsed;
34
+ try {
35
+ parsed = options(argv);
36
+ }
37
+ catch (error) {
38
+ throw usage(error.message);
39
+ }
40
+ const { values, positionals } = parsed;
41
+ if (values.help)
42
+ return { command: "help" };
43
+ const [first, ...rest] = positionals;
44
+ const name = commandOf(first);
45
+ for (const flag of Object.keys(values))
46
+ if (!FLAGS[name].includes(flag))
47
+ throw usage(`--${flag} does not go with ${name === "serve" ? "the page" : name}`);
48
+ const configDir = values["config-dir"];
49
+ if (name === "scan") {
50
+ if (rest.length > 1)
51
+ throw usage("scan takes one dir");
52
+ if (!values.json)
53
+ throw usage("scan prints JSON; pass --json");
54
+ return { command: "scan", dir: rest[0], configDir };
55
+ }
56
+ if (name === "explain") {
57
+ const [file, ...extra] = rest;
58
+ if (file === undefined || extra.length > 0)
59
+ throw usage("explain takes one file");
60
+ return { command: "explain", file, dir: values.dir, configDir };
61
+ }
62
+ return {
63
+ command: "serve",
64
+ port: portOf(values),
65
+ configDir,
66
+ open: values.open ?? true,
67
+ dev: values.dev ?? false,
68
+ };
69
+ }
70
+ /** The command the first word names; without one, the page. */
71
+ function commandOf(first) {
72
+ if (first === undefined)
73
+ return "serve";
74
+ if (first === "scan" || first === "explain")
75
+ return first;
76
+ throw usage(`unknown command ${first}`);
77
+ }
78
+ /** The port to serve on: --port, the dev server's with --dev, or else any free one. */
79
+ function portOf(values) {
80
+ if (!values.port)
81
+ return values.dev ? DEV_PORT : 0;
82
+ const port = Number(values.port);
83
+ if (!Number.isInteger(port) || port < 0 || port > 65535)
84
+ throw usage(`invalid --port ${values.port}`);
85
+ return port;
86
+ }
87
+ function usage(message) {
88
+ return new UsageError(`ccdesk: ${message}\n\n${HELP}`);
89
+ }
90
+ /** The flags as given: one left out is absent, so each command can refuse the others' flags. */
91
+ function options(args) {
92
+ return parseArgs({
93
+ args,
94
+ options: {
95
+ port: { type: "string" },
96
+ "config-dir": { type: "string" },
97
+ open: { type: "boolean" },
98
+ dev: { type: "boolean" },
99
+ json: { type: "boolean" },
100
+ dir: { type: "string" },
101
+ help: { type: "boolean", short: "h" },
102
+ },
103
+ allowPositionals: true,
104
+ allowNegative: true,
105
+ });
106
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};