ccdesk 0.2.2 → 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 (130) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +6 -3
  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 +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 +115 -98
  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 +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 +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 +7 -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 +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 +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 -95
  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/{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 +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 -480
  127. package/dist/server/scan/path-test.js +0 -136
  128. package/dist/server/schema.js +0 -15
  129. package/dist/server/secrets.js +0 -63
  130. package/dist/ui/assets/index-Dg9dxngR.js +0 -60
@@ -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,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 {};