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.
- package/CHANGELOG.md +75 -0
- package/README.md +84 -67
- package/dist/server/agent-edit.js +77 -0
- package/dist/server/backups.js +129 -52
- package/dist/server/cli.js +21 -8
- package/dist/server/mcp-edit.js +263 -0
- package/dist/server/mutations.js +8 -3
- package/dist/server/scan/compare.js +34 -0
- package/dist/server/scan/context.js +6 -5
- package/dist/server/scan/env.js +8 -3
- package/dist/server/scan/files.js +118 -30
- package/dist/server/scan/index.js +29 -15
- package/dist/server/scan/instructions.js +11 -7
- package/dist/server/scan/inventory.js +134 -42
- package/dist/server/scan/mcp.js +88 -30
- package/dist/server/scan/memory.js +8 -9
- package/dist/server/scan/path-test.js +32 -10
- package/dist/server/scan/permissions.js +22 -6
- package/dist/server/scan/plugins.js +18 -11
- package/dist/server/scan/projects.js +7 -6
- package/dist/server/scan/sandbox.js +161 -0
- package/dist/server/scan/settings.js +33 -20
- package/dist/server/scan/tools.js +7 -3
- package/dist/server/scan/usage.js +119 -46
- package/dist/server/schema.js +15 -0
- package/dist/server/secrets.js +63 -0
- package/dist/server/server.js +222 -128
- package/dist/server/settings-edit.js +90 -21
- package/dist/server/watch.js +52 -10
- package/dist/server/write.js +58 -20
- package/dist/shared/compare.js +1 -0
- package/dist/shared/hook-events.json +35 -0
- package/dist/shared/mcp-server.schema.json +25 -0
- package/dist/shared/memory.js +4 -0
- package/dist/shared/scopes.js +3 -1
- package/dist/shared/types.js +1 -1
- package/dist/ui/assets/hanken-grotesk-cyrillic-ext-wght-normal-D_UHSL_T.woff2 +0 -0
- package/dist/ui/assets/index-BQ3Lhx8z.js +60 -0
- package/dist/ui/assets/index-BRgGlpjd.css +1 -0
- package/dist/ui/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
- package/dist/ui/index.html +2 -2
- package/package.json +10 -4
- package/dist/ui/assets/index-BM4K7hSO.css +0 -1
- package/dist/ui/assets/index-CN3vkVjv.js +0 -45
package/dist/server/watch.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
/**
|
|
79
|
-
|
|
80
|
-
const
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
}
|
package/dist/server/write.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
+
}
|
package/dist/shared/scopes.js
CHANGED
|
@@ -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) {
|
package/dist/shared/types.js
CHANGED
|
@@ -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"];
|