ccdesk 0.2.2 → 0.2.4
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 +46 -0
- package/README.md +7 -4
- 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 +64 -48
- 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 +23 -0
- package/dist/{server → core}/mcp-edit.js +121 -100
- package/dist/core/mutations.d.ts +20 -0
- package/dist/{server → core}/mutations.js +8 -15
- package/dist/core/open.d.ts +28 -0
- package/dist/{server → core}/open.js +38 -13
- 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 +14 -0
- package/dist/core/scan/data.js +26 -0
- package/dist/core/scan/env.d.ts +28 -0
- package/dist/{server → core}/scan/env.js +15 -4
- package/dist/core/scan/files.d.ts +68 -0
- package/dist/core/scan/files.js +270 -0
- package/dist/core/scan/git.d.ts +20 -0
- package/dist/core/scan/git.js +106 -0
- package/dist/core/scan/index.d.ts +5 -0
- package/dist/{server → core}/scan/index.js +119 -82
- 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 +153 -95
- 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 +7 -5
- 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 +37 -0
- package/dist/{server → core}/scan/settings.js +98 -93
- 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 +19 -0
- package/dist/{server → core}/scan/usage.js +122 -97
- package/dist/core/schema.d.ts +8 -0
- package/dist/core/schema.js +67 -0
- package/dist/core/secrets.d.ts +45 -0
- package/dist/core/secrets.js +207 -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 +117 -0
- package/dist/{server → core}/write.js +61 -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 +6 -0
- package/dist/shared/memory.js +18 -2
- 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 +535 -0
- package/dist/shared/types.js +11 -0
- package/dist/ui/assets/index-C9qqEI5Z.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
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type Change, type HookEdit, type PermissionEdit, type SettingsEdit, type SettingsEditPreview } from "../shared/types.ts";
|
|
2
|
+
import { type ScanEnv } from "./scan/env.ts";
|
|
3
|
+
import { type Serially } from "./write.ts";
|
|
4
|
+
export declare function previewSettingsEdit(env: ScanEnv, edit: SettingsEdit): Promise<SettingsEditPreview>;
|
|
5
|
+
export declare function saveSettingsEdit(env: ScanEnv, edit: SettingsEdit, expectedRevision: string, queue: Serially): Promise<Change>;
|
|
6
|
+
export declare function previewPermissionEdit(env: ScanEnv, edit: PermissionEdit): Promise<SettingsEditPreview>;
|
|
7
|
+
export declare function savePermissionEdit(env: ScanEnv, edit: PermissionEdit, expectedRevision: string, queue: Serially): Promise<Change>;
|
|
8
|
+
export declare function previewHookEdit(env: ScanEnv, edit: HookEdit): Promise<SettingsEditPreview>;
|
|
9
|
+
export declare function saveHookEdit(env: ScanEnv, edit: HookEdit, expectedRevision: string, queue: Serially): Promise<Change>;
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { stat } from "node:fs/promises";
|
|
2
|
-
import {} from "
|
|
2
|
+
import { Compile } from "typebox/compile";
|
|
3
3
|
import HOOK_EVENTS from "../shared/hook-events.json" with { type: "json" };
|
|
4
4
|
import { allowedScopes, ignoredReason } from "../shared/scopes.js";
|
|
5
5
|
import schema from "../shared/settings.schema.json" with { type: "json" };
|
|
6
6
|
import {} from "../shared/types.js";
|
|
7
7
|
import {} from "./scan/env.js";
|
|
8
|
-
import {
|
|
8
|
+
import { revisionOf } from "./scan/files.js";
|
|
9
|
+
import { isRecord } from "./scan/data.js";
|
|
9
10
|
import { isSkippedMcpRule } from "./scan/permissions.js";
|
|
10
11
|
import { settingsFiles } from "./scan/settings.js";
|
|
11
|
-
import {
|
|
12
|
-
import { hasHiddenValues, shown } from "./secrets.js";
|
|
13
|
-
import { RefusedError,
|
|
12
|
+
import { schemaErrors } from "./schema.js";
|
|
13
|
+
import { hasHiddenValues, redactSecrets, shown } from "./secrets.js";
|
|
14
|
+
import { RefusedError, readJsonObject, recordChange, saveReviewed, writeAtomic, } from "./write.js";
|
|
14
15
|
const properties = schema.definitions.ClaudeCodeSettings.properties;
|
|
15
16
|
const validators = new Map();
|
|
16
17
|
function validatorFor(key) {
|
|
@@ -18,7 +19,7 @@ function validatorFor(key) {
|
|
|
18
19
|
return null;
|
|
19
20
|
let validate = validators.get(key);
|
|
20
21
|
if (!validate) {
|
|
21
|
-
validate =
|
|
22
|
+
validate = Compile(properties[key]);
|
|
22
23
|
validators.set(key, validate);
|
|
23
24
|
}
|
|
24
25
|
return validate;
|
|
@@ -33,21 +34,6 @@ async function editableFile(env, dir, pick, missing = "That is not a settings fi
|
|
|
33
34
|
throw new RefusedError(`${dir} is not a directory on this machine.`);
|
|
34
35
|
return file;
|
|
35
36
|
}
|
|
36
|
-
async function readSettings(env, path) {
|
|
37
|
-
const raw = await readIfExists(env, path);
|
|
38
|
-
if (raw === null || raw.trim() === "")
|
|
39
|
-
return { raw, data: {} };
|
|
40
|
-
let parsed;
|
|
41
|
-
try {
|
|
42
|
-
parsed = JSON.parse(raw);
|
|
43
|
-
}
|
|
44
|
-
catch (error) {
|
|
45
|
-
throw new RefusedError(`${path} is ${jsonSyntaxError(error)}; fix it by hand first.`);
|
|
46
|
-
}
|
|
47
|
-
if (!isRecord(parsed))
|
|
48
|
-
throw new RefusedError(`${path} does not hold a JSON object.`);
|
|
49
|
-
return { raw, data: parsed };
|
|
50
|
-
}
|
|
51
37
|
/**
|
|
52
38
|
* Works out the file change that sets or removes one key, refusing what ccdesk cannot do safely.
|
|
53
39
|
* `checksHiddenValues` is for a key whose edit checks the part it changes itself, so values it
|
|
@@ -60,7 +46,7 @@ async function planKey(env, file, key, value, checksHiddenValues = false) {
|
|
|
60
46
|
if (!validate)
|
|
61
47
|
throw new RefusedError(`${key} is not in Claude Code's settings schema (${schema.$comment}).`);
|
|
62
48
|
const { path } = file;
|
|
63
|
-
const { raw, data } = await
|
|
49
|
+
const { raw, data, reformats } = await readJsonObject(env, path);
|
|
64
50
|
const nextValue = value(data);
|
|
65
51
|
// ccdesk never shows hidden values, so it must not write them either.
|
|
66
52
|
if (!checksHiddenValues &&
|
|
@@ -68,33 +54,29 @@ async function planKey(env, file, key, value, checksHiddenValues = false) {
|
|
|
68
54
|
throw new RefusedError(`${key} holds hidden values; edit it in the file instead.`);
|
|
69
55
|
if (raw !== null && JSON.stringify(nextValue) === JSON.stringify(data[key]))
|
|
70
56
|
throw new RefusedError("This file already says that; there is nothing to change.");
|
|
71
|
-
const before = raw === null ? null : shown(data);
|
|
72
|
-
// The diff is rendered from the parsed file, so a file in another layout is reported as
|
|
73
|
-
// reformatted instead of diffing its raw text.
|
|
74
|
-
const reformats = raw !== null && raw.trim() !== "" && raw !== `${JSON.stringify(data, null, 2)}\n`;
|
|
75
|
-
const base = { path, before, revision: revisionOf(raw), reformats };
|
|
76
57
|
const errors = nextValue === undefined
|
|
77
58
|
? []
|
|
78
59
|
: [...schemaErrors(validate, nextValue, key), ...newHookEvents(key, data[key], nextValue)];
|
|
79
|
-
if (errors.length > 0)
|
|
80
|
-
return {
|
|
81
|
-
path,
|
|
82
|
-
scope: file.scope,
|
|
83
|
-
raw,
|
|
84
|
-
next: null,
|
|
85
|
-
preview: { ...base, after: before ?? "", errors },
|
|
86
|
-
};
|
|
87
60
|
const next = { ...data };
|
|
88
61
|
if (nextValue === undefined)
|
|
89
62
|
delete next[key];
|
|
90
63
|
else
|
|
91
64
|
next[key] = nextValue;
|
|
65
|
+
const text = `${JSON.stringify(next, null, 2)}\n`;
|
|
66
|
+
const before = raw === null ? null : shown(data);
|
|
92
67
|
return {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
68
|
+
preview: {
|
|
69
|
+
path,
|
|
70
|
+
before,
|
|
71
|
+
after: errors.length > 0 ? (before ?? "") : shown(next),
|
|
72
|
+
revision: revisionOf(raw),
|
|
73
|
+
reformats,
|
|
74
|
+
errors,
|
|
75
|
+
},
|
|
76
|
+
save: () => recordChange(env, { kind: "settings", target: path, before: raw }, async () => {
|
|
77
|
+
await writeAtomic(env, path, text, revisionOf(raw));
|
|
78
|
+
return { target: path, revision: revisionOf(text) };
|
|
79
|
+
}),
|
|
98
80
|
};
|
|
99
81
|
}
|
|
100
82
|
async function planSettingsEdit(env, edit) {
|
|
@@ -150,88 +132,85 @@ function matcherOf(group) {
|
|
|
150
132
|
/** One hook taken out, replaced or added, applied to the file as it is on disk. */
|
|
151
133
|
async function planHookEdit(env, edit) {
|
|
152
134
|
const file = await editableFile(env, edit.dir, (f) => f.path === edit.file);
|
|
153
|
-
return planKey(env, file, "hooks", (data) =>
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const matcher = add?.matcher ?? "";
|
|
176
|
-
// A hook alone in its group keeps the group when only its matcher changes, unless another
|
|
177
|
-
// group already has that matcher.
|
|
178
|
-
const inPlace = add?.event === remove.event &&
|
|
179
|
-
(matcher === matcherOf(group) ||
|
|
180
|
-
(list.length === 1 && !groups.some((g) => g !== group && matcherOf(g) === matcher)));
|
|
181
|
-
if (add && inPlace && isRecord(group)) {
|
|
182
|
-
list[remove.at.index] = add.definition;
|
|
183
|
-
if (matcher)
|
|
184
|
-
group.matcher = matcher;
|
|
185
|
-
else
|
|
186
|
-
delete group.matcher;
|
|
187
|
-
return hooks;
|
|
188
|
-
}
|
|
189
|
-
list.splice(remove.at.index, 1);
|
|
190
|
-
if (list.length === 0)
|
|
191
|
-
groups.splice(remove.at.group, 1);
|
|
192
|
-
if (groups.length === 0 && add?.event !== remove.event)
|
|
193
|
-
delete hooks[remove.event];
|
|
194
|
-
}
|
|
195
|
-
if (add) {
|
|
196
|
-
const groups = groupsOf(add.event);
|
|
197
|
-
const matcher = add.matcher ?? "";
|
|
198
|
-
const group = groups.find((g) => matcherOf(g) === matcher && isRecord(g) && Array.isArray(g.hooks));
|
|
199
|
-
if (isRecord(group) && Array.isArray(group.hooks))
|
|
200
|
-
group.hooks.push(add.definition);
|
|
201
|
-
else
|
|
202
|
-
groups.push({ ...(matcher ? { matcher } : {}), hooks: [add.definition] });
|
|
203
|
-
hooks[add.event] = groups;
|
|
204
|
-
}
|
|
205
|
-
return Object.keys(hooks).length > 0 ? hooks : undefined;
|
|
206
|
-
}, true);
|
|
207
|
-
}
|
|
208
|
-
/** Writes a planned edit, but only if the file is still the revision the preview was made from. */
|
|
209
|
-
async function commit(env, plan, expectedRevision) {
|
|
210
|
-
if (plan.next === null)
|
|
211
|
-
throw new RefusedError(plan.preview.errors.join("; "));
|
|
212
|
-
if (plan.preview.revision !== expectedRevision)
|
|
213
|
-
throw new RefusedError("The file changed since you reviewed this edit; review it again.");
|
|
214
|
-
const next = plan.next;
|
|
215
|
-
return recordChange(env, { kind: "settings", target: plan.path, before: plan.raw }, async () => {
|
|
216
|
-
await writeAtomic(env, plan.path, next, expectedRevision);
|
|
217
|
-
return { target: plan.path, revision: revisionOf(next) };
|
|
218
|
-
});
|
|
135
|
+
return planKey(env, file, "hooks", (data) => editedHooks(data.hooks, edit), true);
|
|
136
|
+
}
|
|
137
|
+
/** The hooks setting with the edit applied, or undefined when no hooks are left. */
|
|
138
|
+
function editedHooks(value, edit) {
|
|
139
|
+
const hooks = isRecord(value) ? structuredClone(value) : {};
|
|
140
|
+
const { remove, add } = edit;
|
|
141
|
+
// Checked before any branch, so neither a new hook nor a replacement writes them.
|
|
142
|
+
if (add && hasHiddenValues(add.definition))
|
|
143
|
+
throw new RefusedError("ccdesk does not write header values it would then hide; add this hook in the file instead.");
|
|
144
|
+
if (remove && takeOutHook(hooks, remove, add) === "replaced")
|
|
145
|
+
return hooks;
|
|
146
|
+
if (add) {
|
|
147
|
+
const groups = groupsOf(hooks, add.event);
|
|
148
|
+
const matcher = add.matcher ?? "";
|
|
149
|
+
const group = groups.find((g) => matcherOf(g) === matcher && isRecord(g) && Array.isArray(g.hooks));
|
|
150
|
+
if (isRecord(group) && Array.isArray(group.hooks))
|
|
151
|
+
group.hooks.push(add.definition);
|
|
152
|
+
else
|
|
153
|
+
groups.push({ ...(matcher ? { matcher } : {}), hooks: [add.definition] });
|
|
154
|
+
hooks[add.event] = groups;
|
|
155
|
+
}
|
|
156
|
+
return Object.keys(hooks).length > 0 ? hooks : undefined;
|
|
219
157
|
}
|
|
158
|
+
/** An event's matcher groups as stored in `hooks`, or a new list when it has none. */
|
|
159
|
+
function groupsOf(hooks, event) {
|
|
160
|
+
const groups = hooks[event];
|
|
161
|
+
return Array.isArray(groups) ? groups : [];
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Takes the reviewed hook out of `hooks`, or puts `add` in its place when only its matcher or
|
|
165
|
+
* nothing about where it sits changes.
|
|
166
|
+
*/
|
|
167
|
+
function takeOutHook(hooks, remove, add) {
|
|
168
|
+
const groups = groupsOf(hooks, remove.event);
|
|
169
|
+
const group = groups[remove.at.group];
|
|
170
|
+
const list = isRecord(group) && Array.isArray(group.hooks) ? group.hooks : null;
|
|
171
|
+
const hook = list?.[remove.at.index];
|
|
172
|
+
if (!list || JSON.stringify(redactSecrets(hook)) !== JSON.stringify(remove.definition))
|
|
173
|
+
throw new RefusedError("This hook changed in the file since ccdesk read it; review it again.");
|
|
174
|
+
// The page saw this hook with its header values hidden, so a changed copy would lose them.
|
|
175
|
+
if (add && hasHiddenValues(hook))
|
|
176
|
+
throw new RefusedError("This hook sends header values ccdesk does not show; edit it in the file, or remove it and add it again.");
|
|
177
|
+
const matcher = add?.matcher ?? "";
|
|
178
|
+
// A hook alone in its group keeps the group when only its matcher changes, unless another
|
|
179
|
+
// group already has that matcher.
|
|
180
|
+
const inPlace = add?.event === remove.event &&
|
|
181
|
+
(matcher === matcherOf(group) ||
|
|
182
|
+
(list.length === 1 && !groups.some((g) => g !== group && matcherOf(g) === matcher)));
|
|
183
|
+
if (add && inPlace && isRecord(group)) {
|
|
184
|
+
list[remove.at.index] = add.definition;
|
|
185
|
+
if (matcher)
|
|
186
|
+
group.matcher = matcher;
|
|
187
|
+
else
|
|
188
|
+
delete group.matcher;
|
|
189
|
+
return "replaced";
|
|
190
|
+
}
|
|
191
|
+
list.splice(remove.at.index, 1);
|
|
192
|
+
if (list.length === 0)
|
|
193
|
+
groups.splice(remove.at.group, 1);
|
|
194
|
+
if (groups.length === 0 && add?.event !== remove.event)
|
|
195
|
+
delete hooks[remove.event];
|
|
196
|
+
return "removed";
|
|
197
|
+
}
|
|
198
|
+
const CHANGED = "The file changed since you reviewed this edit; review it again.";
|
|
220
199
|
export async function previewSettingsEdit(env, edit) {
|
|
221
200
|
return (await planSettingsEdit(env, edit)).preview;
|
|
222
201
|
}
|
|
223
|
-
export function saveSettingsEdit(env, edit, expectedRevision) {
|
|
224
|
-
return
|
|
202
|
+
export function saveSettingsEdit(env, edit, expectedRevision, queue) {
|
|
203
|
+
return saveReviewed(queue, () => planSettingsEdit(env, edit), expectedRevision, CHANGED);
|
|
225
204
|
}
|
|
226
205
|
export async function previewPermissionEdit(env, edit) {
|
|
227
206
|
return (await planPermissionEdit(env, edit)).preview;
|
|
228
207
|
}
|
|
229
|
-
export function savePermissionEdit(env, edit, expectedRevision) {
|
|
230
|
-
return
|
|
208
|
+
export function savePermissionEdit(env, edit, expectedRevision, queue) {
|
|
209
|
+
return saveReviewed(queue, () => planPermissionEdit(env, edit), expectedRevision, CHANGED);
|
|
231
210
|
}
|
|
232
211
|
export async function previewHookEdit(env, edit) {
|
|
233
212
|
return (await planHookEdit(env, edit)).preview;
|
|
234
213
|
}
|
|
235
|
-
export function saveHookEdit(env, edit, expectedRevision) {
|
|
236
|
-
return
|
|
214
|
+
export function saveHookEdit(env, edit, expectedRevision, queue) {
|
|
215
|
+
return saveReviewed(queue, () => planHookEdit(env, edit), expectedRevision, CHANGED);
|
|
237
216
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type DirectoryView, type SessionProfile } from "../shared/types.ts";
|
|
2
|
+
import { type ScanEnv } from "./scan/env.ts";
|
|
3
|
+
import { scanDirectory } from "./scan/index.ts";
|
|
4
|
+
/** One set of file watchers per directory a page is looking at, shared by its requests. */
|
|
5
|
+
export declare class Watcher {
|
|
6
|
+
private readonly subscriptions;
|
|
7
|
+
/** What the latest scan of each directory read, kept for a watch that starts after the scan. */
|
|
8
|
+
private readonly scanned;
|
|
9
|
+
private readonly latest;
|
|
10
|
+
private readonly env;
|
|
11
|
+
private readonly scanOf;
|
|
12
|
+
constructor(env: ScanEnv, scanOf?: typeof scanDirectory);
|
|
13
|
+
/** Scans `dir` and remembers what the scan read, so those files are watched too. */
|
|
14
|
+
scan(dir: string, profile?: SessionProfile): Promise<DirectoryView>;
|
|
15
|
+
/**
|
|
16
|
+
* The view of `dir`: the latest scan while it is known to be current, else a new scan. It is
|
|
17
|
+
* current only while watchers that started before it have seen no change since it started, and
|
|
18
|
+
* ~/.claude.json, which is not watched, is as it was. Only an interactive scan is reused, as the
|
|
19
|
+
* path tester describes an interactive session.
|
|
20
|
+
*/
|
|
21
|
+
view(dir: string): Promise<DirectoryView>;
|
|
22
|
+
/** ~/.claude.json's size and time; Claude Code rewrites it too often to watch. */
|
|
23
|
+
private stateFile;
|
|
24
|
+
/**
|
|
25
|
+
* Answers once something changed after `since` (ms since the epoch), or after `timeoutMs` with
|
|
26
|
+
* the last change time, whichever comes first.
|
|
27
|
+
*/
|
|
28
|
+
wait(dir: string, since: number, signal: AbortSignal, timeoutMs?: number): Promise<{
|
|
29
|
+
changed: boolean;
|
|
30
|
+
at: number;
|
|
31
|
+
}>;
|
|
32
|
+
/**
|
|
33
|
+
* Calls `onChange` once after each change to what ccdesk shows for `dir`, until the returned
|
|
34
|
+
* function is called. Resolves once the watchers have started, so a change made after it
|
|
35
|
+
* resolves is seen. `close` stops the calls without making one.
|
|
36
|
+
*/
|
|
37
|
+
subscribe(dir: string, onChange: () => void): Promise<() => void>;
|
|
38
|
+
/** Drops the watchers of `dir` after a while once no one waits or listens for changes. */
|
|
39
|
+
private release;
|
|
40
|
+
close(): void;
|
|
41
|
+
private watching;
|
|
42
|
+
private drop;
|
|
43
|
+
/** (Re)creates the watchers, so a folder created since, such as a new .claude, is watched. */
|
|
44
|
+
private rewatch;
|
|
45
|
+
}
|
|
@@ -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,117 @@
|
|
|
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
|
+
/** What every change's review has: the revision it was worked out from and why it cannot be saved. */
|
|
18
|
+
interface Review {
|
|
19
|
+
/** Fingerprint of everything the change was worked out from. */
|
|
20
|
+
revision: string;
|
|
21
|
+
/** When there are any, nothing is saved. */
|
|
22
|
+
errors?: string[];
|
|
23
|
+
}
|
|
24
|
+
/** A change worked out from disk as it is now: what it shows for review, and how to make it. */
|
|
25
|
+
export interface Planned<Preview extends Review, T> {
|
|
26
|
+
preview: Preview;
|
|
27
|
+
save: () => Promise<T>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
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.
|
|
33
|
+
*/
|
|
34
|
+
export declare function saveReviewed<T>(queue: Serially, plan: () => Promise<Planned<Review, T>>, expectedRevision: string, changed: string): Promise<T>;
|
|
35
|
+
export declare function backupRoot(env: ScanEnv): string;
|
|
36
|
+
/** The file's text, or null when it does not exist. Any other read error is thrown, not hidden. */
|
|
37
|
+
export declare function readIfExists(env: ScanEnv, path: string): Promise<string | null>;
|
|
38
|
+
/**
|
|
39
|
+
* A JSON file that holds an object, as ccdesk edits it: missing or empty is `{}`, anything else
|
|
40
|
+
* that is not an object is refused. `reformats` says whether writing it back as 2-space JSON
|
|
41
|
+
* changes more than the edit, since its diff is rendered from the parsed file.
|
|
42
|
+
*/
|
|
43
|
+
export declare function readJsonObject(env: ScanEnv, path: string): Promise<{
|
|
44
|
+
raw: string | null;
|
|
45
|
+
data: Record<string, unknown>;
|
|
46
|
+
reformats: boolean;
|
|
47
|
+
}>;
|
|
48
|
+
/**
|
|
49
|
+
* Replaces a file's content, but only while it still has `expected` revision. The new text is
|
|
50
|
+
* written to a temporary file first and the revision is checked again just before the rename, so
|
|
51
|
+
* an edit made elsewhere while ccdesk was saving is kept and the save refused. A symlink is written
|
|
52
|
+
* through, so links into a dotfiles repository survive; the temporary file is new, unguessable and
|
|
53
|
+
* created exclusively, so nothing planted beside the target can redirect the write. A file with
|
|
54
|
+
* other hard links is refused, since the rename would leave them on the old text.
|
|
55
|
+
*/
|
|
56
|
+
export declare function writeAtomic(env: ScanEnv, path: string, content: string, expected: string): Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* Deletes a file only while it still has `expected` revision, and says whether it did. The file is
|
|
59
|
+
* renamed aside first, so what is removed is exactly what was checked; one that differs is put
|
|
60
|
+
* back, unless a new file took its place meanwhile, which ccdesk then refuses to overwrite.
|
|
61
|
+
*/
|
|
62
|
+
export declare function removeExactly(path: string, expected: string): Promise<boolean>;
|
|
63
|
+
/** What a backup folder records about its change, in its manifest. */
|
|
64
|
+
export declare const BackupManifestSchema: Type.TObject<{
|
|
65
|
+
kind: Type.TEnum<["settings", "mcp", "agent", "memory-index", "memory-delete", "memory-restore", "mcp-claude"]>;
|
|
66
|
+
/** The file that changed (for a deleted memory: the memory file). */
|
|
67
|
+
target: Type.TString;
|
|
68
|
+
createdAt: Type.TString;
|
|
69
|
+
/** Revision of the target before the change; "missing" when ccdesk created it. */
|
|
70
|
+
before: Type.TString;
|
|
71
|
+
/** Revision of the target right after the change; "pending" while it runs, "partial" when it failed halfway. */
|
|
72
|
+
after: Type.TString;
|
|
73
|
+
/** For a deleted memory: the MEMORY.md lines removed with it. */
|
|
74
|
+
removedIndexLines: Type.TOptional<Type.TArray<Type.TString>>;
|
|
75
|
+
/** For a change made through `claude mcp`: the commands that put it back, values hidden. */
|
|
76
|
+
undoCommands: Type.TOptional<Type.TArray<Type.TString>>;
|
|
77
|
+
}>;
|
|
78
|
+
export type BackupManifest = Static<typeof BackupManifestSchema>;
|
|
79
|
+
export declare const MANIFEST = "ccdesk-backup.json";
|
|
80
|
+
/** Takes a change out of Recent changes, for one whose effect was undone again right away. */
|
|
81
|
+
export declare function forgetChange(env: ScanEnv, id: string): Promise<void>;
|
|
82
|
+
/** The copy of the target inside a backup folder. */
|
|
83
|
+
export declare const SNAPSHOT = "before";
|
|
84
|
+
/** For a change made through `claude mcp`: the affected servers after it, values hidden. */
|
|
85
|
+
export declare const SHOWN_AFTER = "after";
|
|
86
|
+
/**
|
|
87
|
+
* Lists a change Claude Code made through `claude mcp` in Recent changes. ccdesk cannot undo it,
|
|
88
|
+
* so it keeps only the affected servers before and after as the page shows them, secrets hidden,
|
|
89
|
+
* and the commands that would put them back; never a value the page does not show.
|
|
90
|
+
*/
|
|
91
|
+
export declare function recordClaudeMcpChange(env: ScanEnv, record: {
|
|
92
|
+
target: string;
|
|
93
|
+
shownBefore: string;
|
|
94
|
+
shownAfter: string;
|
|
95
|
+
undoCommands: string[];
|
|
96
|
+
}): Promise<void>;
|
|
97
|
+
/**
|
|
98
|
+
* Makes a change and records it in Recent changes. The file's previous text and a manifest are put
|
|
99
|
+
* in a new backup folder first, so a change interrupted halfway can still be restored; the manifest
|
|
100
|
+
* gets the new revision once the change is made. A change that fails is removed from Recent changes
|
|
101
|
+
* when its first write was refused as stale, or when `unchanged` confirms nothing on disk moved;
|
|
102
|
+
* otherwise it stays, marked partial, so it can be put back. Backups can hold settings with secrets, so only the user can read them.
|
|
103
|
+
*/
|
|
104
|
+
export declare function recordChange<T extends {
|
|
105
|
+
revision: string;
|
|
106
|
+
}>(env: ScanEnv, record: {
|
|
107
|
+
kind: BackupKind;
|
|
108
|
+
target: string;
|
|
109
|
+
before: string | null;
|
|
110
|
+
removedIndexLines?: string[];
|
|
111
|
+
/** Whether disk is still as before the change; by default, whether the target is. */
|
|
112
|
+
unchanged?: () => Promise<boolean>;
|
|
113
|
+
}, change: () => Promise<T>): Promise<T & {
|
|
114
|
+
id: string;
|
|
115
|
+
created: boolean;
|
|
116
|
+
}>;
|
|
117
|
+
export {};
|