ccdesk 0.0.0-stage → 0.2.0

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 (56) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/LICENSE +21 -0
  3. package/README.md +131 -2
  4. package/dist/server/agent-edit.js +77 -0
  5. package/dist/server/backups.js +303 -0
  6. package/dist/server/cli.js +105 -0
  7. package/dist/server/mcp-edit.js +263 -0
  8. package/dist/server/mutations.js +193 -0
  9. package/dist/server/open.js +139 -0
  10. package/dist/server/scan/compare.js +34 -0
  11. package/dist/server/scan/context.js +140 -0
  12. package/dist/server/scan/env.js +28 -0
  13. package/dist/server/scan/files.js +478 -0
  14. package/dist/server/scan/index.js +280 -0
  15. package/dist/server/scan/instructions.js +289 -0
  16. package/dist/server/scan/inventory.js +452 -0
  17. package/dist/server/scan/mcp.js +313 -0
  18. package/dist/server/scan/memory.js +90 -0
  19. package/dist/server/scan/path-test.js +121 -0
  20. package/dist/server/scan/permissions.js +101 -0
  21. package/dist/server/scan/plugins.js +124 -0
  22. package/dist/server/scan/projects.js +101 -0
  23. package/dist/server/scan/sandbox.js +161 -0
  24. package/dist/server/scan/settings.js +330 -0
  25. package/dist/server/scan/tools.js +111 -0
  26. package/dist/server/scan/usage.js +514 -0
  27. package/dist/server/schema.js +15 -0
  28. package/dist/server/secrets.js +63 -0
  29. package/dist/server/server.js +352 -0
  30. package/dist/server/settings-edit.js +221 -0
  31. package/dist/server/watch.js +214 -0
  32. package/dist/server/write.js +233 -0
  33. package/dist/shared/builtin-tools.json +235 -0
  34. package/dist/shared/compare.js +1 -0
  35. package/dist/shared/hook-events.json +35 -0
  36. package/dist/shared/mcp-server.schema.json +25 -0
  37. package/dist/shared/memory.js +4 -0
  38. package/dist/shared/scopes.js +20 -0
  39. package/dist/shared/settings-scopes.json +208 -0
  40. package/dist/shared/settings.schema.json +4623 -0
  41. package/dist/shared/types.js +2 -0
  42. package/dist/ui/assets/hanken-grotesk-cyrillic-ext-wght-normal-D_UHSL_T.woff2 +0 -0
  43. package/dist/ui/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
  44. package/dist/ui/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
  45. package/dist/ui/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
  46. package/dist/ui/assets/index-BQ3Lhx8z.js +60 -0
  47. package/dist/ui/assets/index-BRgGlpjd.css +1 -0
  48. package/dist/ui/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
  49. package/dist/ui/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  50. package/dist/ui/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  51. package/dist/ui/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  52. package/dist/ui/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  53. package/dist/ui/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  54. package/dist/ui/index.html +15 -0
  55. package/dist/ui/logo.png +0 -0
  56. package/package.json +86 -4
@@ -0,0 +1,214 @@
1
+ import { watch } from "node:fs";
2
+ import { stat } from "node:fs/promises";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { storeDirName } from "../shared/memory.js";
5
+ import {} from "../shared/types.js";
6
+ import {} from "./scan/env.js";
7
+ import { isNetworkPath, mainWorktreeRoot } from "./scan/files.js";
8
+ import { scanDirectory } from "./scan/index.js";
9
+ /** How long one request waits for a change before answering "nothing changed". */
10
+ const WAIT_MS = 25_000;
11
+ /** Editors write a file in several steps; one change covers them all. */
12
+ const SETTLE_MS = 300;
13
+ /** Watchers outlive a request by this much, so nothing is missed between two polls. */
14
+ const IDLE_MS = 60_000;
15
+ const CONFIG_FOLDERS = ["rules", "agents", "skills", "commands", "output-styles"];
16
+ /**
17
+ * The files and folders whose changes alter what ccdesk shows for `dir`. A `.claude` folder is
18
+ * watched only for its config files and folders, never recursively, because Claude Code keeps
19
+ * whole checkouts in `.claude/worktrees`. ~/.claude.json is left out: Claude Code rewrites it
20
+ * during every session, and focusing the tab picks up its changes.
21
+ */
22
+ async function targets(env, dir, extra) {
23
+ const claude = (folder) => [
24
+ {
25
+ path: folder,
26
+ recursive: false,
27
+ names: ["settings.json", "settings.local.json", "CLAUDE.md", "AGENTS.md", ...CONFIG_FOLDERS],
28
+ },
29
+ ...CONFIG_FOLDERS.map((sub) => ({ path: join(folder, sub), recursive: true })),
30
+ ];
31
+ const memoryKey = (await mainWorktreeRoot(dir)) ?? dir;
32
+ const out = [
33
+ ...claude(env.claudeDir),
34
+ { path: join(env.claudeDir, "plugins"), recursive: false },
35
+ { path: join(env.claudeDir, "projects", storeDirName(memoryKey), "memory"), recursive: false },
36
+ { path: env.managedDir, recursive: false },
37
+ { path: join(env.managedDir, "managed-settings.d"), recursive: false },
38
+ ...extra,
39
+ ];
40
+ // The start directory and every parent can hold instructions, rules and settings.
41
+ for (let d = dir;; d = dirname(d)) {
42
+ out.push({
43
+ path: d,
44
+ recursive: false,
45
+ names: ["CLAUDE.md", "CLAUDE.local.md", "AGENTS.md", ".mcp.json", ".claude"],
46
+ });
47
+ if (join(d, ".claude") !== env.claudeDir)
48
+ out.push(...claude(join(d, ".claude")));
49
+ if (dirname(d) === d)
50
+ break;
51
+ }
52
+ if (memoryKey !== dir)
53
+ out.push(...claude(join(memoryKey, ".claude")));
54
+ return out.filter((t) => !isNetworkPath(t.path));
55
+ }
56
+ /** Files a scan read that the fixed list does not cover: a custom memory folder, imports. */
57
+ export function dependencies(view) {
58
+ const files = view.instructions.flatMap((i) => [
59
+ i.realPath ?? i.path,
60
+ ...i.imports.filter((r) => r.exists).map((r) => r.resolved),
61
+ ]);
62
+ const byFolder = new Map();
63
+ for (const file of files) {
64
+ const names = byFolder.get(dirname(file)) ?? new Set();
65
+ names.add(basename(file));
66
+ byFolder.set(dirname(file), names);
67
+ }
68
+ return [
69
+ ...[...byFolder].map(([path, names]) => ({ path, recursive: false, names: [...names] })),
70
+ ...(view.memory ? [{ path: view.memory.dir, recursive: false }] : []),
71
+ ];
72
+ }
73
+ /** One set of file watchers per directory a page is looking at, shared by its requests. */
74
+ export class Watcher {
75
+ env;
76
+ scanOf;
77
+ subscriptions = new Map();
78
+ /** What the latest scan of each directory read, kept for a watch that starts after the scan. */
79
+ scanned = new Map();
80
+ latest = new Map();
81
+ constructor(env, scanOf = scanDirectory) {
82
+ this.env = env;
83
+ this.scanOf = scanOf;
84
+ }
85
+ /** Scans `dir` and remembers what the scan read, so those files are watched too. */
86
+ async scan(dir, profile = "interactive") {
87
+ const startedAt = Date.now();
88
+ const stateFile = await this.stateFile();
89
+ const view = await this.scanOf(this.env, dir, profile);
90
+ const subscription = this.subscriptions.get(view.path);
91
+ // Only a watched directory's view can be known to be current later.
92
+ if (subscription)
93
+ this.latest.set(view.path, { view, startedAt, stateFile });
94
+ const extra = dependencies(view);
95
+ this.scanned.set(view.path, extra);
96
+ if (subscription && JSON.stringify(extra) !== JSON.stringify(subscription.extra)) {
97
+ subscription.extra = extra;
98
+ void this.rewatch(view.path, subscription);
99
+ }
100
+ return view;
101
+ }
102
+ /**
103
+ * The view of `dir`: the latest scan while it is known to be current, else a new scan. It is
104
+ * current only while watchers that started before it have seen no change since it started, and
105
+ * ~/.claude.json, which is not watched, is as it was. Only an interactive scan is reused, as the
106
+ * path tester describes an interactive session.
107
+ */
108
+ async view(dir) {
109
+ const latest = this.latest.get(dir);
110
+ const subscription = this.subscriptions.get(dir);
111
+ if (latest?.view.profile === "interactive" &&
112
+ subscription &&
113
+ subscription.watchingSince < latest.startedAt &&
114
+ subscription.touchedAt < latest.startedAt &&
115
+ latest.stateFile === (await this.stateFile()))
116
+ return latest.view;
117
+ return this.scan(dir);
118
+ }
119
+ /** ~/.claude.json's size and time; Claude Code rewrites it too often to watch. */
120
+ async stateFile() {
121
+ const info = await stat(this.env.claudeJson).catch(() => null);
122
+ return info ? `${info.size}:${info.mtimeMs}` : "missing";
123
+ }
124
+ /**
125
+ * Answers once something changed after `since` (ms since the epoch), or after `timeoutMs` with
126
+ * the last change time, whichever comes first.
127
+ */
128
+ async wait(dir, since, signal, timeoutMs = WAIT_MS) {
129
+ const subscription = await this.subscribe(dir);
130
+ clearTimeout(subscription.idle);
131
+ try {
132
+ if (subscription.changedAt <= since && !signal.aborted)
133
+ await new Promise((resolve) => {
134
+ const done = () => {
135
+ clearTimeout(timer);
136
+ signal.removeEventListener("abort", done);
137
+ subscription.waiters.delete(done);
138
+ resolve();
139
+ };
140
+ const timer = setTimeout(done, timeoutMs);
141
+ signal.addEventListener("abort", done);
142
+ subscription.waiters.add(done);
143
+ });
144
+ }
145
+ finally {
146
+ if (subscription.waiters.size === 0)
147
+ subscription.idle = setTimeout(() => this.drop(dir), IDLE_MS);
148
+ }
149
+ return { changed: subscription.changedAt > since, at: subscription.changedAt };
150
+ }
151
+ close() {
152
+ for (const dir of [...this.subscriptions.keys()])
153
+ this.drop(dir);
154
+ }
155
+ async subscribe(dir) {
156
+ const existing = this.subscriptions.get(dir);
157
+ if (existing)
158
+ return existing;
159
+ const subscription = {
160
+ watchers: [],
161
+ changedAt: 0,
162
+ touchedAt: 0,
163
+ watchingSince: Number.POSITIVE_INFINITY,
164
+ waiters: new Set(),
165
+ extra: this.scanned.get(dir) ?? [],
166
+ };
167
+ this.subscriptions.set(dir, subscription);
168
+ await this.rewatch(dir, subscription);
169
+ return subscription;
170
+ }
171
+ drop(dir) {
172
+ const subscription = this.subscriptions.get(dir);
173
+ if (!subscription)
174
+ return;
175
+ clearTimeout(subscription.idle);
176
+ clearTimeout(subscription.settle);
177
+ for (const w of subscription.watchers)
178
+ w.close();
179
+ this.subscriptions.delete(dir);
180
+ this.latest.delete(dir);
181
+ }
182
+ /** (Re)creates the watchers, so a folder created since, such as a new .claude, is watched. */
183
+ async rewatch(dir, subscription) {
184
+ const list = await targets(this.env, dir, subscription.extra);
185
+ for (const w of subscription.watchers)
186
+ w.close();
187
+ subscription.watchers = [];
188
+ if (this.subscriptions.get(dir) !== subscription)
189
+ return;
190
+ for (const target of list) {
191
+ try {
192
+ const watcher = watch(target.path, { recursive: target.recursive }, (_event, name) => {
193
+ const changed = name === null ? "" : String(name);
194
+ if (target.names && !target.names.includes(basename(changed)))
195
+ return;
196
+ subscription.touchedAt = Date.now();
197
+ clearTimeout(subscription.settle);
198
+ subscription.settle = setTimeout(() => {
199
+ subscription.changedAt = Date.now();
200
+ for (const wake of [...subscription.waiters])
201
+ wake();
202
+ void this.rewatch(dir, subscription);
203
+ }, SETTLE_MS);
204
+ });
205
+ watcher.on("error", () => watcher.close());
206
+ subscription.watchers.push(watcher);
207
+ }
208
+ catch {
209
+ // A folder that does not exist yet is caught by watching its parent.
210
+ }
211
+ }
212
+ subscription.watchingSince = Date.now();
213
+ }
214
+ }
@@ -0,0 +1,233 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { chmod, link, lstat, mkdir, open, readFile, realpath, rename, rm, writeFile, } from "node:fs/promises";
3
+ import { basename, dirname, join } from "node:path";
4
+ import {} from "../shared/types.js";
5
+ import {} from "./scan/env.js";
6
+ import { isCredentials, isSecretPath, revisionOf } from "./scan/files.js";
7
+ /** A change ccdesk refuses on purpose; the server answers 409 with the message. */
8
+ export class RefusedError extends Error {
9
+ }
10
+ /** A write refused because its file changed after review: nothing was written. */
11
+ export class StaleError extends RefusedError {
12
+ }
13
+ const CREDENTIALS = "ccdesk does not read or change Claude Code's credentials.";
14
+ const STATE_FILE = "ccdesk does not change ~/.claude.json; Claude Code rewrites it all the time.";
15
+ let queue = Promise.resolve();
16
+ /**
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.
19
+ */
20
+ export function serially(change) {
21
+ const run = queue.then(change);
22
+ queue = run.catch(() => undefined);
23
+ return run;
24
+ }
25
+ export function backupRoot(env) {
26
+ return join(env.claudeDir, "backups", "ccdesk");
27
+ }
28
+ /** Where a path leads after links, or null when it does not exist. */
29
+ function realOrNull(path) {
30
+ return realpath(path).catch(() => null);
31
+ }
32
+ /** The file a path leads to after links; a missing file keeps its own path. */
33
+ async function target(env, path) {
34
+ if (await isCredentials(env, path))
35
+ throw new RefusedError(CREDENTIALS);
36
+ try {
37
+ const real = await realpath(path);
38
+ if (isSecretPath(real) || isSecretPath(path))
39
+ throw new RefusedError(CREDENTIALS);
40
+ return real;
41
+ }
42
+ catch (error) {
43
+ if (error.code !== "ENOENT")
44
+ throw error;
45
+ if ((await lstat(path).catch(() => null))?.isSymbolicLink())
46
+ throw new RefusedError(`${path} is a link to a file that does not exist; fix the link first.`);
47
+ if (isSecretPath(path))
48
+ throw new RefusedError(CREDENTIALS);
49
+ return path;
50
+ }
51
+ }
52
+ /** The file's text, or null when it does not exist. Any other read error is thrown, not hidden. */
53
+ export async function readIfExists(env, path) {
54
+ try {
55
+ return await readFile(await target(env, path), "utf8");
56
+ }
57
+ catch (error) {
58
+ if (error.code === "ENOENT")
59
+ return null;
60
+ throw error;
61
+ }
62
+ }
63
+ /**
64
+ * Replaces a file's content, but only while it still has `expected` revision. The new text is
65
+ * written to a temporary file first and the revision is checked again just before the rename, so
66
+ * an edit made elsewhere while ccdesk was saving is kept and the save refused. A symlink is written
67
+ * through, so links into a dotfiles repository survive; the temporary file is new, unguessable and
68
+ * created exclusively, so nothing planted beside the target can redirect the write. A file with
69
+ * other hard links is refused, since the rename would leave them on the old text.
70
+ */
71
+ export async function writeAtomic(env, path, content, expected) {
72
+ const file = await target(env, path);
73
+ if (basename(file) === ".claude.json" || file === (await realOrNull(env.claudeJson)))
74
+ throw new RefusedError(STATE_FILE);
75
+ await mkdir(dirname(file), { recursive: true });
76
+ const existing = await lstat(file).catch(() => null);
77
+ if (existing && existing.nlink > 1)
78
+ throw new RefusedError(`${path} has other hard links, and saving it would split it from its other names; edit it in your editor.`);
79
+ const temp = join(dirname(file), `.${basename(file)}.ccdesk-${randomBytes(6).toString("hex")}`);
80
+ const handle = await open(temp, "wx", 0o600);
81
+ try {
82
+ try {
83
+ await handle.writeFile(content, "utf8");
84
+ await handle.chmod(existing ? existing.mode & 0o7777 : 0o666 & ~process.umask());
85
+ await handle.sync();
86
+ }
87
+ finally {
88
+ await handle.close();
89
+ }
90
+ if (revisionOf(await readIfExists(env, file)) !== expected)
91
+ throw new StaleError(`${path} changed while ccdesk was saving; nothing was written. Review it again.`);
92
+ await rename(temp, file);
93
+ }
94
+ catch (error) {
95
+ await rm(temp, { force: true });
96
+ throw error;
97
+ }
98
+ }
99
+ /**
100
+ * Deletes a file only while it still has `expected` revision, and says whether it did. The file is
101
+ * renamed aside first, so what is removed is exactly what was checked; one that differs is put
102
+ * back, unless a new file took its place meanwhile, which ccdesk then refuses to overwrite.
103
+ */
104
+ export async function removeExactly(path, expected) {
105
+ const aside = join(dirname(path), `.${basename(path)}.ccdesk-${randomBytes(6).toString("hex")}`);
106
+ await rename(path, aside);
107
+ let text;
108
+ try {
109
+ text = await readFile(aside, "utf8");
110
+ }
111
+ catch (error) {
112
+ await rename(aside, path);
113
+ throw error;
114
+ }
115
+ if (revisionOf(text) === expected) {
116
+ await rm(aside);
117
+ return true;
118
+ }
119
+ try {
120
+ await link(aside, path);
121
+ }
122
+ catch (error) {
123
+ const replaced = error.code === "EEXIST" ||
124
+ (await lstat(path).catch(() => null)) !== null;
125
+ if (replaced)
126
+ throw new RefusedError(`${path} changed and was replaced while ccdesk removed it; the earlier text is kept in ${aside}.`);
127
+ // A file system without hard links: put it back by name. Unlike a link, this would replace a
128
+ // file created in the instant since the check above.
129
+ await rename(aside, path);
130
+ return false;
131
+ }
132
+ await rm(aside);
133
+ return false;
134
+ }
135
+ export const MANIFEST = "ccdesk-backup.json";
136
+ /** Takes a change out of Recent changes, for one whose effect was undone again right away. */
137
+ export function forgetChange(env, id) {
138
+ return rm(join(backupRoot(env), id), { recursive: true, force: true });
139
+ }
140
+ /** The copy of the target inside a backup folder. */
141
+ export const SNAPSHOT = "before";
142
+ /** For a change made through `claude mcp`: the affected servers after it, values hidden. */
143
+ export const SHOWN_AFTER = "after";
144
+ /** A new, empty backup folder that only you can read, and its id. */
145
+ async function newBackup(env, kind) {
146
+ const createdAt = new Date().toISOString();
147
+ const id = `${createdAt.replace(/[:.]/g, "-")}-${randomBytes(3).toString("hex")}-${kind}`;
148
+ const root = backupRoot(env);
149
+ const dir = join(root, id);
150
+ await mkdir(dir, { recursive: true, mode: 0o700 });
151
+ await chmod(root, 0o700);
152
+ return { createdAt, id, dir };
153
+ }
154
+ /**
155
+ * Lists a change Claude Code made through `claude mcp` in Recent changes. ccdesk cannot undo it,
156
+ * so it keeps only the affected servers before and after as the page shows them, secrets hidden,
157
+ * and the commands that would put them back; never a value the page does not show.
158
+ */
159
+ export async function recordClaudeMcpChange(env, record) {
160
+ const { createdAt, dir } = await newBackup(env, "mcp-claude");
161
+ const manifest = {
162
+ kind: "mcp-claude",
163
+ target: record.target,
164
+ createdAt,
165
+ before: revisionOf(record.shownBefore),
166
+ after: revisionOf(record.shownAfter),
167
+ undoCommands: record.undoCommands,
168
+ };
169
+ try {
170
+ await writeFile(join(dir, SNAPSHOT), record.shownBefore, { mode: 0o600 });
171
+ await writeFile(join(dir, SHOWN_AFTER), record.shownAfter, { mode: 0o600 });
172
+ // Written last, so a folder without it is never listed.
173
+ await writeFile(join(dir, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
174
+ }
175
+ catch (error) {
176
+ await rm(dir, { recursive: true, force: true });
177
+ throw error;
178
+ }
179
+ }
180
+ /**
181
+ * Makes a change and records it in Recent changes. The file's previous text and a manifest are put
182
+ * in a new backup folder first, so a change interrupted halfway can still be restored; the manifest
183
+ * gets the new revision once the change is made. A change that fails is removed from Recent changes
184
+ * when its first write was refused as stale, or when `unchanged` confirms nothing on disk moved;
185
+ * otherwise it stays, marked partial, so it can be put back. Backups can hold settings with secrets, so only the user can read them.
186
+ */
187
+ export async function recordChange(env, record, change) {
188
+ const { createdAt, id, dir } = await newBackup(env, record.kind);
189
+ const manifest = {
190
+ kind: record.kind,
191
+ target: record.target,
192
+ createdAt,
193
+ before: revisionOf(record.before),
194
+ after: "pending",
195
+ ...(record.removedIndexLines ? { removedIndexLines: record.removedIndexLines } : {}),
196
+ };
197
+ // Replaced by rename, so an interrupted rewrite leaves the earlier manifest rather than half of one.
198
+ const writeManifest = async () => {
199
+ const temp = join(dir, `${MANIFEST}.next`);
200
+ await writeFile(temp, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
201
+ await rename(temp, join(dir, MANIFEST));
202
+ };
203
+ try {
204
+ if (record.before !== null)
205
+ await writeFile(join(dir, SNAPSHOT), record.before, { mode: 0o600 });
206
+ await writeManifest();
207
+ }
208
+ catch (error) {
209
+ await rm(dir, { recursive: true, force: true });
210
+ throw error;
211
+ }
212
+ let result;
213
+ try {
214
+ result = await change();
215
+ }
216
+ catch (error) {
217
+ const unchanged = record.unchanged ??
218
+ (async () => revisionOf(await readIfExists(env, record.target)) === revisionOf(record.before));
219
+ // A stale refusal from the change's own first write means ccdesk wrote nothing; changes that
220
+ // write more than once turn a later stale refusal into a plain one (see deleteMemory).
221
+ if (error instanceof StaleError || (await unchanged().catch(() => false))) {
222
+ await rm(dir, { recursive: true, force: true });
223
+ }
224
+ else {
225
+ manifest.after = "partial";
226
+ await writeManifest().catch(() => undefined);
227
+ }
228
+ throw error;
229
+ }
230
+ manifest.after = result.revision;
231
+ await writeManifest();
232
+ return { ...result, id, created: record.before === null };
233
+ }