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.
- package/CHANGELOG.md +25 -0
- package/README.md +6 -3
- package/dist/core/agent-edit.d.ts +6 -0
- package/dist/{server → core}/agent-edit.js +15 -21
- package/dist/core/backups.d.ts +19 -0
- package/dist/{server → core}/backups.js +37 -45
- package/dist/core/core.d.ts +96 -0
- package/dist/core/core.js +57 -0
- package/dist/core/index.d.ts +22 -0
- package/dist/core/index.js +38 -0
- package/dist/core/mcp-edit.d.ts +25 -0
- package/dist/{server → core}/mcp-edit.js +115 -98
- package/dist/core/mutations.d.ts +20 -0
- package/dist/{server → core}/mutations.js +8 -15
- package/dist/core/open.d.ts +31 -0
- package/dist/{server → core}/open.js +39 -12
- package/dist/core/parse-edit.d.ts +12 -0
- package/dist/core/parse-edit.js +64 -0
- package/dist/core/reference.d.ts +3 -0
- package/dist/core/reference.js +11 -0
- package/dist/core/scan/compare.d.ts +7 -0
- package/dist/{server → core}/scan/compare.js +2 -2
- package/dist/core/scan/context.d.ts +13 -0
- package/dist/{server → core}/scan/context.js +55 -32
- package/dist/core/scan/data.d.ts +8 -0
- package/dist/core/scan/data.js +18 -0
- package/dist/core/scan/env.d.ts +26 -0
- package/dist/{server → core}/scan/env.js +7 -3
- package/dist/core/scan/files.d.ts +68 -0
- package/dist/core/scan/files.js +270 -0
- package/dist/core/scan/git.d.ts +14 -0
- package/dist/core/scan/git.js +98 -0
- package/dist/core/scan/index.d.ts +5 -0
- package/dist/{server → core}/scan/index.js +115 -81
- package/dist/core/scan/instructions.d.ts +54 -0
- package/dist/{server → core}/scan/instructions.js +141 -73
- package/dist/core/scan/inventory.d.ts +38 -0
- package/dist/{server → core}/scan/inventory.js +134 -115
- package/dist/core/scan/mcp.d.ts +28 -0
- package/dist/{server → core}/scan/mcp.js +151 -94
- package/dist/core/scan/memory.d.ts +8 -0
- package/dist/{server → core}/scan/memory.js +8 -8
- package/dist/core/scan/path-test.d.ts +9 -0
- package/dist/core/scan/path-test.js +156 -0
- package/dist/core/scan/permissions.d.ts +31 -0
- package/dist/{server → core}/scan/permissions.js +5 -8
- package/dist/core/scan/plugins.d.ts +39 -0
- package/dist/{server → core}/scan/plugins.js +36 -27
- package/dist/core/scan/projects.d.ts +9 -0
- package/dist/{server → core}/scan/projects.js +5 -4
- package/dist/core/scan/sandbox.d.ts +14 -0
- package/dist/{server → core}/scan/sandbox.js +87 -59
- package/dist/core/scan/settings.d.ts +35 -0
- package/dist/{server → core}/scan/settings.js +88 -92
- package/dist/core/scan/tools.d.ts +10 -0
- package/dist/{server → core}/scan/tools.js +28 -20
- package/dist/core/scan/usage.d.ts +25 -0
- package/dist/{server → core}/scan/usage.js +117 -95
- package/dist/core/schema.d.ts +8 -0
- package/dist/core/schema.js +67 -0
- package/dist/core/secrets.d.ts +44 -0
- package/dist/core/secrets.js +187 -0
- package/dist/core/settings-edit.d.ts +9 -0
- package/dist/{server → core}/settings-edit.js +91 -112
- package/dist/core/watch.d.ts +45 -0
- package/dist/{server → core}/watch.js +54 -17
- package/dist/core/write.d.ts +119 -0
- package/dist/{server → core}/write.js +63 -8
- package/dist/server/args.d.ts +32 -0
- package/dist/server/args.js +106 -0
- package/dist/server/cli.d.ts +2 -0
- package/dist/server/cli.js +58 -83
- package/dist/server/commands.d.ts +8 -0
- package/dist/server/commands.js +39 -0
- package/dist/server/server.d.ts +12 -0
- package/dist/server/server.js +80 -195
- package/dist/server/start.d.ts +13 -0
- package/dist/server/start.js +21 -0
- package/dist/shared/compare.d.ts +25 -0
- package/dist/shared/memory.d.ts +2 -0
- package/dist/shared/requests.d.ts +104 -0
- package/dist/shared/requests.js +73 -0
- package/dist/shared/scopes.d.ts +10 -0
- package/dist/shared/settings-catalog.json +2199 -0
- package/dist/shared/types.d.ts +530 -0
- package/dist/shared/types.js +11 -0
- package/dist/ui/assets/index-CH1HoC38.js +60 -0
- package/dist/ui/index.html +1 -1
- package/dist/view/diff.d.ts +16 -0
- package/dist/view/diff.js +85 -0
- package/dist/view/format.d.ts +17 -0
- package/dist/view/format.js +47 -0
- package/dist/view/index.d.ts +11 -0
- package/dist/view/index.js +13 -0
- package/dist/view/lines.d.ts +9 -0
- package/dist/view/lines.js +12 -0
- package/dist/view/path-test.d.ts +3 -0
- package/dist/view/path-test.js +7 -0
- package/dist/view/sections/catalog.d.ts +9 -0
- package/dist/view/sections/catalog.js +47 -0
- package/dist/view/sections/extensions.d.ts +1 -0
- package/dist/view/sections/extensions.js +48 -0
- package/dist/view/sections/hooks.d.ts +1 -0
- package/dist/view/sections/hooks.js +32 -0
- package/dist/view/sections/index.d.ts +6 -0
- package/dist/view/sections/index.js +27 -0
- package/dist/view/sections/instructions.d.ts +6 -0
- package/dist/view/sections/instructions.js +38 -0
- package/dist/view/sections/mcp.d.ts +3 -0
- package/dist/view/sections/mcp.js +38 -0
- package/dist/view/sections/memory.d.ts +1 -0
- package/dist/view/sections/memory.js +31 -0
- package/dist/view/sections/permissions.d.ts +17 -0
- package/dist/view/sections/permissions.js +87 -0
- package/dist/view/sections/settings.d.ts +1 -0
- package/dist/view/sections/settings.js +33 -0
- package/dist/view/sections/tools.d.ts +6 -0
- package/dist/view/sections/tools.js +57 -0
- package/dist/view/sections/types.d.ts +123 -0
- package/dist/view/sections/types.js +10 -0
- package/dist/view/settings.d.ts +12 -0
- package/dist/view/settings.js +31 -0
- package/dist/view/usage.d.ts +8 -0
- package/dist/view/usage.js +18 -0
- package/package.json +13 -2
- package/dist/server/scan/files.js +0 -480
- package/dist/server/scan/path-test.js +0 -136
- package/dist/server/schema.js +0 -15
- package/dist/server/secrets.js +0 -63
- 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
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
const
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 "
|
|
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
|
-
|
|
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
|
-
*
|
|
18
|
-
*
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
+
}
|