ccdesk 0.0.0-stage → 0.1.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 +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +114 -2
  4. package/dist/server/backups.js +226 -0
  5. package/dist/server/cli.js +92 -0
  6. package/dist/server/mutations.js +188 -0
  7. package/dist/server/open.js +139 -0
  8. package/dist/server/scan/context.js +139 -0
  9. package/dist/server/scan/env.js +23 -0
  10. package/dist/server/scan/files.js +390 -0
  11. package/dist/server/scan/index.js +266 -0
  12. package/dist/server/scan/instructions.js +285 -0
  13. package/dist/server/scan/inventory.js +360 -0
  14. package/dist/server/scan/mcp.js +255 -0
  15. package/dist/server/scan/memory.js +91 -0
  16. package/dist/server/scan/path-test.js +99 -0
  17. package/dist/server/scan/permissions.js +85 -0
  18. package/dist/server/scan/plugins.js +117 -0
  19. package/dist/server/scan/projects.js +100 -0
  20. package/dist/server/scan/settings.js +317 -0
  21. package/dist/server/scan/tools.js +107 -0
  22. package/dist/server/scan/usage.js +441 -0
  23. package/dist/server/server.js +258 -0
  24. package/dist/server/settings-edit.js +152 -0
  25. package/dist/server/watch.js +172 -0
  26. package/dist/server/write.js +195 -0
  27. package/dist/shared/builtin-tools.json +235 -0
  28. package/dist/shared/scopes.js +18 -0
  29. package/dist/shared/settings-scopes.json +208 -0
  30. package/dist/shared/settings.schema.json +4623 -0
  31. package/dist/shared/types.js +2 -0
  32. package/dist/ui/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
  33. package/dist/ui/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
  34. package/dist/ui/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
  35. package/dist/ui/assets/index-BM4K7hSO.css +1 -0
  36. package/dist/ui/assets/index-CN3vkVjv.js +45 -0
  37. package/dist/ui/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  38. package/dist/ui/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  39. package/dist/ui/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  40. package/dist/ui/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  41. package/dist/ui/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  42. package/dist/ui/index.html +15 -0
  43. package/dist/ui/logo.png +0 -0
  44. package/package.json +80 -4
@@ -0,0 +1,441 @@
1
+ import { open, realpath, stat } from "node:fs/promises";
2
+ import { join, resolve, sep } from "node:path";
3
+ import {} from "../../shared/types.js";
4
+ import {} from "./env.js";
5
+ import { isDir, isRecord, isWithin, leadsToSecret, listDir, wildcard, worktrees } from "./files.js";
6
+ import { storeDirName } from "./memory.js";
7
+ import { namesTool, parseRule } from "./tools.js";
8
+ /**
9
+ * Transcripts already counted, by path, so reopening a directory reads only what was added. It
10
+ * keeps the commands of every directory opened since ccdesk started, in memory only.
11
+ */
12
+ const transcripts = new Map();
13
+ /** A transcript is read in parts this size, so one huge file never becomes one huge string. */
14
+ const CHUNK = 8 * 1024 * 1024;
15
+ let queue = Promise.resolve();
16
+ /** Requests waiting their turn, so a burst of the same request runs once. */
17
+ const waiting = new Map();
18
+ /**
19
+ * Counts the tool calls in the sessions started in `dir`, or anywhere in its repository and its
20
+ * worktrees, since rules at a repository's root apply there too (docs: permissions#permission-system).
21
+ * Subagents are included. It also counts the calls you approved or refused by hand, which Claude
22
+ * Code records on each call's result as `permissionDecision` (not documented; older transcripts
23
+ * lack it), and how many calls each rule covers. Requests run one at a time, since they share the cache.
24
+ */
25
+ export function readUsage(env, dir, rules) {
26
+ const key = JSON.stringify([env.claudeDir, resolve(dir), rules]);
27
+ const queued = waiting.get(key);
28
+ if (queued)
29
+ return queued;
30
+ const run = queue.then(() => {
31
+ waiting.delete(key);
32
+ return count(env, resolve(dir), rules);
33
+ });
34
+ waiting.set(key, run);
35
+ queue = run.catch(() => undefined);
36
+ return run;
37
+ }
38
+ async function count(env, dir, rules) {
39
+ const checkouts = await worktrees(dir);
40
+ // $HOME may end in a slash or be a link, while git prints the resolved path.
41
+ const homes = new Set([
42
+ resolve(env.home),
43
+ await realpath(env.home).catch(() => resolve(env.home)),
44
+ ]);
45
+ const isHome = (folder) => homes.has(resolve(folder));
46
+ // A home directory kept in git (dotfiles) is not a project: its folders are separate projects.
47
+ const repository = checkouts.length > 0 && !isHome(checkouts[0] ?? "");
48
+ const folders = repository ? checkouts : [dir];
49
+ const projects = join(env.claudeDir, "projects");
50
+ const storeNames = await listDir(projects);
51
+ const taken = new Set();
52
+ const calls = [];
53
+ let sessions = 0;
54
+ for (const folder of folders) {
55
+ const own = storeDirName(folder);
56
+ // Sessions started in a subfolder or a nested worktree have their own store, named with the
57
+ // folder's as a prefix; the prefix also matches siblings such as app-old, so each session is
58
+ // kept by the folder it started in. A home directory would pull in every store, so only its
59
+ // own is read.
60
+ const stores = storeNames.filter((name) => name === own || (!isHome(folder) && name.startsWith(`${own}-`)));
61
+ for (const name of stores) {
62
+ const store = join(projects, name);
63
+ const files = await transcriptsIn(store);
64
+ for (const [file, session] of files) {
65
+ if (taken.has(file))
66
+ continue;
67
+ // A subagent's session decides; one whose session file is gone goes by its own start.
68
+ const start = (await transcript(session))?.startCwd ?? (await transcript(file))?.startCwd;
69
+ if (start ? !isWithin(folder, start) : name !== own)
70
+ continue;
71
+ taken.add(file);
72
+ calls.push(...((await transcript(file))?.calls ?? []));
73
+ if (file === session)
74
+ sessions++;
75
+ }
76
+ for (const path of transcripts.keys())
77
+ if (path.startsWith(store + sep) && !files.has(path))
78
+ transcripts.delete(path);
79
+ }
80
+ }
81
+ return {
82
+ sessions,
83
+ repository,
84
+ since: calls.reduce((min, c) => (min === null || c.at < min ? c.at : min), null),
85
+ tools: toolUsage(calls),
86
+ approvals: approvals(calls, rules),
87
+ matches: Object.fromEntries(rules.map((r) => [r.rule, matchCount(r, calls)])),
88
+ };
89
+ }
90
+ /** A store's transcripts, each with the session it belongs to: itself, or a subagent's session. */
91
+ async function transcriptsIn(store) {
92
+ const files = new Map();
93
+ for (const name of await listDir(store)) {
94
+ if (name.endsWith(".jsonl"))
95
+ files.set(join(store, name), join(store, name));
96
+ else if (await isDir(join(store, name, "subagents")))
97
+ for (const sub of await listDir(join(store, name, "subagents")))
98
+ if (sub.endsWith(".jsonl"))
99
+ files.set(join(store, name, "subagents", sub), join(store, `${name}.jsonl`));
100
+ }
101
+ return files;
102
+ }
103
+ /** One transcript, read up to its end; one that cannot be read keeps what was read before. */
104
+ async function transcript(file) {
105
+ if (await leadsToSecret(file))
106
+ return null;
107
+ const info = await stat(file).catch(() => null);
108
+ if (!info?.isFile())
109
+ return null;
110
+ let known = transcripts.get(file);
111
+ if (!known || info.size < known.offset) {
112
+ known = { size: 0, mtimeMs: 0, offset: 0, calls: [], byId: new Map() };
113
+ transcripts.set(file, known);
114
+ }
115
+ if (known.size === info.size && known.mtimeMs === info.mtimeMs)
116
+ return known;
117
+ try {
118
+ await readFrom(file, known, info.size);
119
+ known.size = info.size;
120
+ known.mtimeMs = info.mtimeMs;
121
+ }
122
+ catch {
123
+ // Gone or unreadable since the stat: keep what was counted; the offset still marks it.
124
+ }
125
+ return known;
126
+ }
127
+ /** Reads whole lines from the offset up to `size`; a line still being written is read next time. */
128
+ async function readFrom(file, known, size) {
129
+ const handle = await open(file, "r");
130
+ try {
131
+ let carried = Buffer.alloc(0);
132
+ let position = known.offset;
133
+ while (position < size) {
134
+ const chunk = Buffer.alloc(Math.min(CHUNK, size - position));
135
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, position);
136
+ if (bytesRead === 0)
137
+ break;
138
+ position += bytesRead;
139
+ const data = Buffer.concat([carried, chunk.subarray(0, bytesRead)]);
140
+ const end = data.lastIndexOf(0x0a) + 1;
141
+ for (const line of data.subarray(0, end).toString("utf8").split("\n"))
142
+ read(line, known);
143
+ known.offset += end;
144
+ carried = data.subarray(end);
145
+ }
146
+ }
147
+ finally {
148
+ await handle.close();
149
+ }
150
+ }
151
+ /** Takes the tool calls from an assistant line and your answers from a tool result line. */
152
+ function read(line, transcript) {
153
+ const hasCall = line.includes('"tool_use"');
154
+ const hasDecision = line.includes('"permissionDecision"');
155
+ const hasStart = !transcript.startCwd && line.includes('"cwd"');
156
+ if (!hasCall && !hasDecision && !hasStart)
157
+ return;
158
+ let record;
159
+ try {
160
+ record = JSON.parse(line);
161
+ }
162
+ catch {
163
+ return;
164
+ }
165
+ if (!isRecord(record))
166
+ return;
167
+ if (!transcript.startCwd && typeof record.cwd === "string")
168
+ transcript.startCwd = record.cwd;
169
+ if (!isRecord(record.message))
170
+ return;
171
+ const content = record.message.content;
172
+ if (!Array.isArray(content))
173
+ return;
174
+ const at = typeof record.timestamp === "string" ? record.timestamp : "";
175
+ if (record.type === "assistant") {
176
+ for (const block of content) {
177
+ if (!isRecord(block) || block.type !== "tool_use")
178
+ continue;
179
+ if (typeof block.id !== "string" || typeof block.name !== "string")
180
+ continue;
181
+ const call = callOf(block.name, isRecord(block.input) ? block.input : {}, at);
182
+ transcript.calls.push(call);
183
+ transcript.byId.set(block.id, call);
184
+ }
185
+ return;
186
+ }
187
+ const decision = record.permissionDecision;
188
+ if (record.type !== "user" || !isRecord(decision))
189
+ return;
190
+ if (typeof decision.source !== "string" || !decision.source.startsWith("user"))
191
+ return;
192
+ for (const block of content) {
193
+ if (!isRecord(block) || block.type !== "tool_result")
194
+ continue;
195
+ const call = typeof block.tool_use_id === "string" && transcript.byId.get(block.tool_use_id);
196
+ if (call)
197
+ call.byYou = decision.decision === "accept";
198
+ }
199
+ }
200
+ function callOf(tool, input, at) {
201
+ const call = { tool, at };
202
+ if ((tool === "Bash" || tool === "Monitor") && typeof input.command === "string")
203
+ call.command = input.command;
204
+ if (tool === "WebFetch" && typeof input.url === "string") {
205
+ try {
206
+ call.host = new URL(input.url).hostname.replace(/\.$/, "");
207
+ }
208
+ catch {
209
+ // Not a URL: no domain rule can match it.
210
+ }
211
+ }
212
+ return call;
213
+ }
214
+ function toolUsage(calls) {
215
+ const byTool = new Map();
216
+ for (const c of calls) {
217
+ const usage = byTool.get(c.tool) ?? { tool: c.tool, calls: 0, approved: 0, refused: 0 };
218
+ usage.calls++;
219
+ if (c.byYou === true)
220
+ usage.approved++;
221
+ if (c.byYou === false)
222
+ usage.refused++;
223
+ byTool.set(c.tool, usage);
224
+ }
225
+ return [...byTool.values()].sort((a, b) => b.calls - a.calls);
226
+ }
227
+ /**
228
+ * Calls you allowed by hand, grouped by the allow rule that would cover them. A rule is left out
229
+ * when it would also cover a call you refused, or when rules in effect already decide every call
230
+ * behind it: allowing it then changes nothing.
231
+ */
232
+ function approvals(calls, rules) {
233
+ const matchers = rules.flatMap((r) => matcherFor(r) ?? []);
234
+ const groups = new Map();
235
+ for (const c of calls) {
236
+ if (c.byYou !== true)
237
+ continue;
238
+ const rule = ruleFor(c);
239
+ if (rule)
240
+ groups.set(rule, [...(groups.get(rule) ?? []), c]);
241
+ }
242
+ const refused = calls.filter((c) => c.byYou === false);
243
+ const out = [];
244
+ for (const [rule, approved] of groups) {
245
+ if (rules.some((r) => r.rule === rule))
246
+ continue;
247
+ const covers = matcherFor({ list: "allow", rule });
248
+ if (covers && refused.some(covers))
249
+ continue;
250
+ if (approved.every((c) => matchers.some((m) => m(c))))
251
+ continue;
252
+ const last = approved.reduce((max, c) => (c.at > max ? c.at : max), "");
253
+ out.push({ rule, approved: approved.length, last });
254
+ }
255
+ return out.sort((a, b) => b.approved - a.approved);
256
+ }
257
+ /**
258
+ * Programs a prefix rule may name on their own: read-only commands with no flag that runs a
259
+ * command or writes a file (docs: permissions#read-only-commands). A rule for any other program
260
+ * could allow far more than you approved, as `sed`'s `e` command or `make --eval` do, so ccdesk
261
+ * suggests none.
262
+ */
263
+ const SAFE_PROGRAMS = new Set("ls cat head tail wc grep diff cmp comm cut tr jq stat du df pwd echo printf date whoami uname which basename dirname realpath readlink nproc".split(" "));
264
+ /**
265
+ * Programs with subcommands, and the subcommands a rule may name, one or two words each: those
266
+ * whose flags neither run another command nor write a file you did not name
267
+ * (docs: permissions#wildcard-patterns). The exceptions are the script runners `test`, `run`,
268
+ * `build` and `lint`, and `git commit`, whose hooks may run scripts from package.json: they run
269
+ * the project's own code, and npm and pnpm config flags such as `--node-options` can make them
270
+ * run any code. A rule for them is the one Claude Code's own prompt offers, `npm test *`
271
+ * (docs: permissions#permission-system), and no wider.
272
+ *
273
+ * Left out on purpose: `git stash show`, `git blame` and `git log` write anywhere with
274
+ * `--output`; `git grep -O` and `git fetch --upload-pack` run a command; the rest of `gh repo`
275
+ * reaches delete, visibility and deploy keys; `bun build --compile --outfile` writes an
276
+ * executable; `npm audit fix` installs; every pnpm command loads a pnpmfile, which
277
+ * `--config.pnpmfile` can point at any script; podman takes `--runtime` after the subcommand;
278
+ * `go test -exec`, `cargo --config`, kubeconfig exec plugins, `pip --python` and installs run
279
+ * whatever they are given.
280
+ */
281
+ const SUBCOMMANDS = {
282
+ git: "status add commit switch restore tag branch rev-parse describe ls-files".split(" "),
283
+ npm: "test run ls outdated view".split(" "),
284
+ pnpm: "test run build lint".split(" "),
285
+ yarn: "test run build lint".split(" "),
286
+ bun: ["test"],
287
+ gh: [
288
+ "status",
289
+ "pr view",
290
+ "pr list",
291
+ "pr diff",
292
+ "pr checks",
293
+ "issue view",
294
+ "issue list",
295
+ "repo view",
296
+ ],
297
+ docker: "ps images logs inspect".split(" "),
298
+ systemctl: ["status"],
299
+ };
300
+ /** Path rules for these need a path ccdesk should not guess. */
301
+ const PATH_TOOLS = new Set(["Read", "Edit", "Write", "NotebookEdit", "Glob", "Grep", "LSP"]);
302
+ /**
303
+ * The allow rule that would have covered a call, in the form Claude Code's "don't ask again"
304
+ * writes: a Bash program on the allowlists above, with its subcommand where it has them, a fetch's
305
+ * domain, or the tool. None for compound commands, other programs and subcommands, a leading
306
+ * variable assignment (an allow rule does not match past most of them, docs:
307
+ * permissions#process-wrappers) and file tools. A rule takes no other words from a command, so
308
+ * arguments never leave the server.
309
+ */
310
+ function ruleFor(call) {
311
+ if (call.tool === "Bash") {
312
+ if (call.command === undefined || /[`$<>(){}]/.test(call.command))
313
+ return null;
314
+ if (/^\s*\w+=/.test(call.command))
315
+ return null;
316
+ const parts = subcommands(call.command);
317
+ if (parts.length !== 1)
318
+ return null;
319
+ const [program, ...rest] = (parts[0] ?? "").split(/\s+/);
320
+ if (!program)
321
+ return null;
322
+ if (SAFE_PROGRAMS.has(program))
323
+ return `Bash(${program} *)`;
324
+ const sub = SUBCOMMANDS[program]?.find((words) => words.split(" ").every((word, i) => rest[i] === word));
325
+ return sub ? `Bash(${program} ${sub} *)` : null;
326
+ }
327
+ if (call.tool === "WebFetch")
328
+ return call.host ? `WebFetch(domain:${call.host})` : null;
329
+ if (PATH_TOOLS.has(call.tool) || call.tool === "PowerShell" || call.tool === "Monitor")
330
+ return null;
331
+ return call.tool;
332
+ }
333
+ const SEPARATORS = /&&|\|\||\|&|[;|&\n]/;
334
+ /** docs: permissions#process-wrappers */
335
+ const WRAPPERS = new Set([
336
+ "timeout",
337
+ "time",
338
+ "nice",
339
+ "nohup",
340
+ "stdbuf",
341
+ "command",
342
+ "builtin",
343
+ "noglob",
344
+ ]);
345
+ const KEYWORDS = new Set(["if", "then", "else", "elif", "do", "while", "until", "!"]);
346
+ /** A command's parts as Claude Code splits them for allow rules: at shell operators, wrappers stripped. */
347
+ function subcommands(command) {
348
+ return command
349
+ .split(SEPARATORS)
350
+ .map((part) => withoutWrappers(part.trim()))
351
+ .filter(Boolean);
352
+ }
353
+ /**
354
+ * Every command a rule might see in a call: the whole text, its parts, and commands nested in a
355
+ * subshell, a substitution or a loop body, which deny and ask rules also reach (docs:
356
+ * permissions#compound-commands), each also without its redirections, which Claude Code checks
357
+ * apart from the rule (docs: permissions#redirections). Generous on purpose, so a rule counted as
358
+ * covering no call really covered none.
359
+ */
360
+ function everyCommandIn(command) {
361
+ const variants = [command, withoutRedirections(command)];
362
+ return [
363
+ ...new Set(variants.flatMap((text) => {
364
+ const parts = text
365
+ .replace(/\$\(|[`(){}]/g, "\n")
366
+ .split(SEPARATORS)
367
+ .map((part) => {
368
+ const words = part.trim().split(/\s+/);
369
+ while (KEYWORDS.has(words[0] ?? ""))
370
+ words.shift();
371
+ return withoutWrappers(words.join(" "));
372
+ });
373
+ return [withoutWrappers(text.trim()), ...parts];
374
+ })),
375
+ ].filter(Boolean);
376
+ }
377
+ /** `2>&1`, `> out.txt`, `&>> log`, `< in`: removed with their target, before `&` is taken for a separator. */
378
+ function withoutRedirections(command) {
379
+ return command.replace(/\s*(?:\d*>>?\|?|&>>?|\d*<)&?\s*(?:'[^']*'|"[^"]*"|[^\s;&|]*)/g, "");
380
+ }
381
+ function withoutWrappers(command) {
382
+ const words = command.split(/\s+/);
383
+ for (;;) {
384
+ const first = words[0] ?? "";
385
+ if (/^\w+=/.test(first))
386
+ words.shift();
387
+ else if (first === "xargs" && words.length > 1 && !(words[1] ?? "").startsWith("-"))
388
+ words.shift();
389
+ else if (WRAPPERS.has(first) &&
390
+ words.length > 1 &&
391
+ !(first === "command" && words[1] === "-v")) {
392
+ words.shift();
393
+ while (/^(-\S+|\d+(\.\d+)?[smhd]?)$/.test(words[0] ?? ""))
394
+ words.shift();
395
+ }
396
+ else
397
+ break;
398
+ }
399
+ return words.join(" ");
400
+ }
401
+ /** How many calls a rule covers, or null for kinds ccdesk does not match (paths, parameters, PowerShell). */
402
+ function matchCount(rule, calls) {
403
+ const matches = matcherFor(rule);
404
+ return matches ? calls.filter(matches).length : null;
405
+ }
406
+ /** Whether a rule covers a call, erring towards yes; null for kinds ccdesk does not match. */
407
+ function matcherFor({ list, rule }) {
408
+ const { tool, specifier } = parseRule(rule);
409
+ if (specifier === null) {
410
+ if (tool.startsWith("mcp__") && tool.split("__").length === 2)
411
+ return (c) => c.tool.startsWith(`${tool}__`);
412
+ // Monitor runs commands under the Bash rules (docs: tools-reference#configure-tools-with-permission-rules-and-hooks).
413
+ return (c) => namesTool(list, tool, c.tool) || (c.tool === "Monitor" && namesTool(list, tool, "Bash"));
414
+ }
415
+ if (tool === "Bash") {
416
+ const pattern = bashPattern(specifier);
417
+ if (pattern === null)
418
+ return null;
419
+ return (c) => c.command !== undefined && everyCommandIn(c.command).some(pattern);
420
+ }
421
+ if (tool === "WebFetch" && specifier.startsWith("domain:")) {
422
+ const host = wildcard(specifier.slice(7).replace(/\.$/, ""), "i");
423
+ return (c) => c.host !== undefined && host.test(c.host);
424
+ }
425
+ return null;
426
+ }
427
+ /** A Bash rule's command pattern (docs: permissions#wildcard-patterns); null for a parameter rule. */
428
+ function bashPattern(specifier) {
429
+ let pattern = specifier;
430
+ if (pattern.endsWith(":*") && !pattern.slice(0, -2).includes("*"))
431
+ pattern = `${pattern.slice(0, -2)} *`;
432
+ else if (/^[A-Za-z_]+:/.test(pattern))
433
+ return null;
434
+ const star = pattern.indexOf("*");
435
+ if (pattern.endsWith(" *") && star === pattern.length - 1) {
436
+ const prefix = pattern.slice(0, -2);
437
+ return (command) => command === prefix || command.startsWith(`${prefix} `);
438
+ }
439
+ const regex = wildcard(pattern, "s");
440
+ return (command) => regex.test(command);
441
+ }