ccdesk 0.1.0 → 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 (44) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +84 -67
  3. package/dist/server/agent-edit.js +77 -0
  4. package/dist/server/backups.js +129 -52
  5. package/dist/server/cli.js +21 -8
  6. package/dist/server/mcp-edit.js +263 -0
  7. package/dist/server/mutations.js +8 -3
  8. package/dist/server/scan/compare.js +34 -0
  9. package/dist/server/scan/context.js +6 -5
  10. package/dist/server/scan/env.js +8 -3
  11. package/dist/server/scan/files.js +118 -30
  12. package/dist/server/scan/index.js +29 -15
  13. package/dist/server/scan/instructions.js +11 -7
  14. package/dist/server/scan/inventory.js +134 -42
  15. package/dist/server/scan/mcp.js +88 -30
  16. package/dist/server/scan/memory.js +8 -9
  17. package/dist/server/scan/path-test.js +32 -10
  18. package/dist/server/scan/permissions.js +22 -6
  19. package/dist/server/scan/plugins.js +18 -11
  20. package/dist/server/scan/projects.js +7 -6
  21. package/dist/server/scan/sandbox.js +161 -0
  22. package/dist/server/scan/settings.js +33 -20
  23. package/dist/server/scan/tools.js +7 -3
  24. package/dist/server/scan/usage.js +119 -46
  25. package/dist/server/schema.js +15 -0
  26. package/dist/server/secrets.js +63 -0
  27. package/dist/server/server.js +222 -128
  28. package/dist/server/settings-edit.js +90 -21
  29. package/dist/server/watch.js +52 -10
  30. package/dist/server/write.js +58 -20
  31. package/dist/shared/compare.js +1 -0
  32. package/dist/shared/hook-events.json +35 -0
  33. package/dist/shared/mcp-server.schema.json +25 -0
  34. package/dist/shared/memory.js +4 -0
  35. package/dist/shared/scopes.js +3 -1
  36. package/dist/shared/types.js +1 -1
  37. package/dist/ui/assets/hanken-grotesk-cyrillic-ext-wght-normal-D_UHSL_T.woff2 +0 -0
  38. package/dist/ui/assets/index-BQ3Lhx8z.js +60 -0
  39. package/dist/ui/assets/index-BRgGlpjd.css +1 -0
  40. package/dist/ui/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
  41. package/dist/ui/index.html +2 -2
  42. package/package.json +10 -4
  43. package/dist/ui/assets/index-BM4K7hSO.css +0 -1
  44. package/dist/ui/assets/index-CN3vkVjv.js +0 -45
@@ -1,9 +1,11 @@
1
1
  import { watch } from "node:fs";
2
+ import { stat } from "node:fs/promises";
2
3
  import { basename, dirname, join } from "node:path";
4
+ import { storeDirName } from "../shared/memory.js";
3
5
  import {} from "../shared/types.js";
4
6
  import {} from "./scan/env.js";
5
7
  import { isNetworkPath, mainWorktreeRoot } from "./scan/files.js";
6
- import { storeDirName } from "./scan/memory.js";
8
+ import { scanDirectory } from "./scan/index.js";
7
9
  /** How long one request waits for a change before answering "nothing changed". */
8
10
  const WAIT_MS = 25_000;
9
11
  /** Editors write a file in several steps; one change covers them all. */
@@ -71,18 +73,53 @@ export function dependencies(view) {
71
73
  /** One set of file watchers per directory a page is looking at, shared by its requests. */
72
74
  export class Watcher {
73
75
  env;
76
+ scanOf;
74
77
  subscriptions = new Map();
75
- constructor(env) {
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) {
76
82
  this.env = env;
83
+ this.scanOf = scanOf;
77
84
  }
78
- /** Remembers what the latest scan of `dir` read, so those files are watched too. */
79
- track(dir, view) {
80
- const subscription = this.subscriptions.get(dir);
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 });
81
94
  const extra = dependencies(view);
82
- if (!subscription || JSON.stringify(extra) === JSON.stringify(subscription.extra))
83
- return;
84
- subscription.extra = extra;
85
- void this.rewatch(dir, subscription);
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";
86
123
  }
87
124
  /**
88
125
  * Answers once something changed after `since` (ms since the epoch), or after `timeoutMs` with
@@ -122,8 +159,10 @@ export class Watcher {
122
159
  const subscription = {
123
160
  watchers: [],
124
161
  changedAt: 0,
162
+ touchedAt: 0,
163
+ watchingSince: Number.POSITIVE_INFINITY,
125
164
  waiters: new Set(),
126
- extra: [],
165
+ extra: this.scanned.get(dir) ?? [],
127
166
  };
128
167
  this.subscriptions.set(dir, subscription);
129
168
  await this.rewatch(dir, subscription);
@@ -138,6 +177,7 @@ export class Watcher {
138
177
  for (const w of subscription.watchers)
139
178
  w.close();
140
179
  this.subscriptions.delete(dir);
180
+ this.latest.delete(dir);
141
181
  }
142
182
  /** (Re)creates the watchers, so a folder created since, such as a new .claude, is watched. */
143
183
  async rewatch(dir, subscription) {
@@ -153,6 +193,7 @@ export class Watcher {
153
193
  const changed = name === null ? "" : String(name);
154
194
  if (target.names && !target.names.includes(basename(changed)))
155
195
  return;
196
+ subscription.touchedAt = Date.now();
156
197
  clearTimeout(subscription.settle);
157
198
  subscription.settle = setTimeout(() => {
158
199
  subscription.changedAt = Date.now();
@@ -168,5 +209,6 @@ export class Watcher {
168
209
  // A folder that does not exist yet is caught by watching its parent.
169
210
  }
170
211
  }
212
+ subscription.watchingSince = Date.now();
171
213
  }
172
214
  }
@@ -3,7 +3,7 @@ import { chmod, link, lstat, mkdir, open, readFile, realpath, rename, rm, writeF
3
3
  import { basename, dirname, join } from "node:path";
4
4
  import {} from "../shared/types.js";
5
5
  import {} from "./scan/env.js";
6
- import { isSecretPath, leadsToSecret, revisionOf } from "./scan/files.js";
6
+ import { isCredentials, isSecretPath, revisionOf } from "./scan/files.js";
7
7
  /** A change ccdesk refuses on purpose; the server answers 409 with the message. */
8
8
  export class RefusedError extends Error {
9
9
  }
@@ -29,16 +29,6 @@ export function backupRoot(env) {
29
29
  function realOrNull(path) {
30
30
  return realpath(path).catch(() => null);
31
31
  }
32
- /**
33
- * Whether a path is Claude Code's credentials file: by name, through any link on the way, or by
34
- * ending at the same file as `<config>/.credentials.json`, so one kept under another name counts.
35
- */
36
- export async function isCredentials(env, path) {
37
- if (await leadsToSecret(path))
38
- return true;
39
- const real = await realOrNull(path);
40
- return real !== null && real === (await realOrNull(join(env.claudeDir, ".credentials.json")));
41
- }
42
32
  /** The file a path leads to after links; a missing file keeps its own path. */
43
33
  async function target(env, path) {
44
34
  if (await isCredentials(env, path))
@@ -75,7 +65,8 @@ export async function readIfExists(env, path) {
75
65
  * written to a temporary file first and the revision is checked again just before the rename, so
76
66
  * an edit made elsewhere while ccdesk was saving is kept and the save refused. A symlink is written
77
67
  * through, so links into a dotfiles repository survive; the temporary file is new, unguessable and
78
- * created exclusively, so nothing planted beside the target can redirect the write.
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.
79
70
  */
80
71
  export async function writeAtomic(env, path, content, expected) {
81
72
  const file = await target(env, path);
@@ -83,6 +74,8 @@ export async function writeAtomic(env, path, content, expected) {
83
74
  throw new RefusedError(STATE_FILE);
84
75
  await mkdir(dirname(file), { recursive: true });
85
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.`);
86
79
  const temp = join(dirname(file), `.${basename(file)}.ccdesk-${randomBytes(6).toString("hex")}`);
87
80
  const handle = await open(temp, "wx", 0o600);
88
81
  try {
@@ -111,7 +104,15 @@ export async function writeAtomic(env, path, content, expected) {
111
104
  export async function removeExactly(path, expected) {
112
105
  const aside = join(dirname(path), `.${basename(path)}.ccdesk-${randomBytes(6).toString("hex")}`);
113
106
  await rename(path, aside);
114
- if (revisionOf(await readFile(aside, "utf8")) === expected) {
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) {
115
116
  await rm(aside);
116
117
  return true;
117
118
  }
@@ -132,8 +133,50 @@ export async function removeExactly(path, expected) {
132
133
  return false;
133
134
  }
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
+ }
135
140
  /** The copy of the target inside a backup folder. */
136
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
+ }
137
180
  /**
138
181
  * Makes a change and records it in Recent changes. The file's previous text and a manifest are put
139
182
  * in a new backup folder first, so a change interrupted halfway can still be restored; the manifest
@@ -142,12 +185,7 @@ export const SNAPSHOT = "before";
142
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.
143
186
  */
144
187
  export async function recordChange(env, record, change) {
145
- const createdAt = new Date().toISOString();
146
- const id = `${createdAt.replace(/[:.]/g, "-")}-${randomBytes(3).toString("hex")}-${record.kind}`;
147
- const root = backupRoot(env);
148
- const dir = join(root, id);
149
- await mkdir(dir, { recursive: true, mode: 0o700 });
150
- await chmod(root, 0o700);
188
+ const { createdAt, id, dir } = await newBackup(env, record.kind);
151
189
  const manifest = {
152
190
  kind: record.kind,
153
191
  target: record.target,
@@ -191,5 +229,5 @@ export async function recordChange(env, record, change) {
191
229
  }
192
230
  manifest.after = result.revision;
193
231
  await writeManifest();
194
- return { ...result, id };
232
+ return { ...result, id, created: record.before === null };
195
233
  }
@@ -0,0 +1 @@
1
+ import {} from "./types.js";
@@ -0,0 +1,35 @@
1
+ [
2
+ "PreToolUse",
3
+ "PostToolUse",
4
+ "PostToolUseFailure",
5
+ "PostToolBatch",
6
+ "Notification",
7
+ "UserPromptSubmit",
8
+ "UserPromptExpansion",
9
+ "SessionStart",
10
+ "SessionEnd",
11
+ "Stop",
12
+ "StopFailure",
13
+ "SubagentStart",
14
+ "SubagentStop",
15
+ "PreCompact",
16
+ "PostCompact",
17
+ "PreModelSwitch",
18
+ "PostModelSwitch",
19
+ "PermissionRequest",
20
+ "PermissionDenied",
21
+ "Setup",
22
+ "TeammateIdle",
23
+ "TaskCreated",
24
+ "TaskCompleted",
25
+ "Elicitation",
26
+ "ElicitationResult",
27
+ "ConfigChange",
28
+ "WorktreeCreate",
29
+ "WorktreeRemove",
30
+ "InstructionsLoaded",
31
+ "CwdChanged",
32
+ "FileChanged",
33
+ "DirectoryAdded",
34
+ "MessageDisplay"
35
+ ]
@@ -0,0 +1,25 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$comment": "One server entry of .mcp.json, ~/.claude.json or claude mcp add-json, with the fields https://code.claude.com/docs/en/mcp defines. Fields it does not name are left alone.",
4
+ "type": "object",
5
+ "properties": {
6
+ "type": { "enum": ["stdio", "http", "streamable-http", "sse", "ws"] },
7
+ "command": { "type": "string" },
8
+ "args": { "type": "array", "items": { "type": "string" } },
9
+ "env": { "type": "object", "additionalProperties": { "type": "string" } },
10
+ "url": { "type": "string" },
11
+ "headers": { "type": "object", "additionalProperties": { "type": "string" } },
12
+ "headersHelper": { "type": "string" },
13
+ "oauth": {
14
+ "type": "object",
15
+ "properties": {
16
+ "clientId": { "type": "string" },
17
+ "callbackPort": { "type": "integer" },
18
+ "authServerMetadataUrl": { "type": "string" },
19
+ "scopes": { "type": "string" }
20
+ }
21
+ },
22
+ "timeout": { "type": "number" },
23
+ "alwaysLoad": { "type": "boolean" }
24
+ }
25
+ }
@@ -0,0 +1,4 @@
1
+ /** Claude Code names a project's store after its path with every non-alphanumeric character as "-". */
2
+ export function storeDirName(projectPath) {
3
+ return projectPath.replace(/[^a-zA-Z0-9]/g, "-");
4
+ }
@@ -1,10 +1,12 @@
1
1
  import scopeTable from "./settings-scopes.json" with { type: "json" };
2
2
  import {} from "./types.js";
3
- const EVERY_FILE = ["user", "project", "local", "managed"];
3
+ export const EVERY_FILE = ["user", "project", "local", "managed"];
4
4
  /** Settings files by precedence: managed beats local beats project beats user. */
5
5
  export const HIGHEST_FIRST = ["managed", "local", "project", "user"];
6
6
  /** The settings files ccdesk writes to; managed settings belong to your organisation. */
7
7
  export const EDITABLE_SCOPES = ["user", "project", "local"];
8
+ /** The permission lists, in the order ccdesk shows them. */
9
+ export const PERMISSION_LISTS = ["allow", "ask", "deny"];
8
10
  /** Keys Claude Code reads only from some files, from the settings index (`pnpm scopes`). */
9
11
  const SCOPE_LIMITED_KEYS = scopeTable.limited;
10
12
  export function allowedScopes(key) {
@@ -1,2 +1,2 @@
1
1
  // Shapes the server sends to the UI. Paths are absolute; the UI shortens them for display.
2
- export {};
2
+ export const SESSION_PROFILES = ["interactive", "print"];