ccdesk 0.1.0 → 0.2.1
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 +86 -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-D5XqP5c_.css +1 -0
- package/dist/ui/assets/index-XqJwI3k9.js +60 -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/server.js
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import { readFile, stat } from "node:fs/promises";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
3
|
import { extname, isAbsolute, join, normalize, resolve, sep } from "node:path";
|
|
4
|
-
import
|
|
4
|
+
import HOOK_EVENTS from "../shared/hook-events.json" with { type: "json" };
|
|
5
|
+
import { EVERY_FILE, PERMISSION_LISTS } from "../shared/scopes.js";
|
|
6
|
+
import { SESSION_PROFILES, } from "../shared/types.js";
|
|
5
7
|
import { listBackups, previewRestore, restoreBackup } from "./backups.js";
|
|
8
|
+
import { previewAgentEdit, saveAgentEdit } from "./agent-edit.js";
|
|
9
|
+
import { previewMcpEdit, runClaude, saveMcpEdit } from "./mcp-edit.js";
|
|
6
10
|
import { changeMemory, isMemoryAction, previewMemoryChange } from "./mutations.js";
|
|
7
11
|
import { openInEditor } from "./open.js";
|
|
12
|
+
import { compareSettings } from "./scan/compare.js";
|
|
8
13
|
import {} from "./scan/env.js";
|
|
9
|
-
import { isRecord } from "./scan/files.js";
|
|
10
|
-
import {
|
|
11
|
-
import { readUsage } from "./scan/usage.js";
|
|
14
|
+
import { isCredentials, isRecord } from "./scan/files.js";
|
|
15
|
+
import { scanOverview } from "./scan/index.js";
|
|
16
|
+
import { CountStopped, readUsage } from "./scan/usage.js";
|
|
12
17
|
import { testPath } from "./scan/path-test.js";
|
|
13
|
-
import { previewPermissionEdit, previewSettingsEdit, savePermissionEdit, saveSettingsEdit, } from "./settings-edit.js";
|
|
18
|
+
import { previewHookEdit, previewPermissionEdit, previewSettingsEdit, saveHookEdit, savePermissionEdit, saveSettingsEdit, } from "./settings-edit.js";
|
|
14
19
|
import { Watcher } from "./watch.js";
|
|
15
|
-
import {
|
|
20
|
+
import { RefusedError } from "./write.js";
|
|
16
21
|
const MIME = {
|
|
17
22
|
".html": "text/html; charset=utf-8",
|
|
18
23
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -38,17 +43,141 @@ class RequestError extends Error {
|
|
|
38
43
|
}
|
|
39
44
|
export function createCcdeskServer(options) {
|
|
40
45
|
const watcher = new Watcher(options.env);
|
|
46
|
+
const table = routes(options, watcher);
|
|
41
47
|
const server = createServer((req, res) => {
|
|
42
|
-
handle(options,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
send(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
48
|
+
handle(options, table, server, req, res).catch((error) => {
|
|
49
|
+
const [status, message] = failure(error);
|
|
50
|
+
send(res, status, { error: message });
|
|
46
51
|
});
|
|
47
52
|
});
|
|
48
53
|
server.on("close", () => watcher.close());
|
|
49
54
|
return server;
|
|
50
55
|
}
|
|
51
|
-
|
|
56
|
+
/** The status and message an API request that failed is answered with. */
|
|
57
|
+
function failure(error) {
|
|
58
|
+
if (error instanceof RequestError)
|
|
59
|
+
return [error.status, error.message];
|
|
60
|
+
// A change ccdesk refuses on purpose.
|
|
61
|
+
if (error instanceof RefusedError)
|
|
62
|
+
return [409, error.message];
|
|
63
|
+
if (error instanceof CountStopped)
|
|
64
|
+
return [503, error.message];
|
|
65
|
+
return [500, error instanceof Error ? error.message : String(error)];
|
|
66
|
+
}
|
|
67
|
+
/** The query parameter `name`; a missing one is a 400. */
|
|
68
|
+
function param(url, name, message = `${name} is required`) {
|
|
69
|
+
const value = url.searchParams.get(name);
|
|
70
|
+
if (!value)
|
|
71
|
+
throw new RequestError(400, message);
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
/** Aborts when the page goes away or asks for something else. */
|
|
75
|
+
function stopOnClose(res) {
|
|
76
|
+
const stop = new AbortController();
|
|
77
|
+
res.once("close", () => stop.abort());
|
|
78
|
+
return stop.signal;
|
|
79
|
+
}
|
|
80
|
+
function routes(options, watcher) {
|
|
81
|
+
const { env, launchDir } = options;
|
|
82
|
+
const run = options.runClaude ?? runClaude;
|
|
83
|
+
const revision = (body) => stringFields(body, ["revision"]).revision;
|
|
84
|
+
const get = (path, action) => ({ method: "GET", path, action });
|
|
85
|
+
const post = (path, action) => ({ method: "POST", path, action });
|
|
86
|
+
return [
|
|
87
|
+
get("/api/overview", () => scanOverview(env, launchDir)),
|
|
88
|
+
get("/api/directory", ({ url }) => {
|
|
89
|
+
const profile = url.searchParams.get("profile") ?? "interactive";
|
|
90
|
+
if (!SESSION_PROFILES.includes(profile))
|
|
91
|
+
throw new RequestError(400, `profile is one of ${SESSION_PROFILES.join(", ")}`);
|
|
92
|
+
return watcher.scan(param(url, "path"), profile);
|
|
93
|
+
}),
|
|
94
|
+
post("/api/usage", ({ body, res }) => {
|
|
95
|
+
const { path } = stringFields(body, ["path"]);
|
|
96
|
+
const rules = body.rules;
|
|
97
|
+
if (!Array.isArray(rules))
|
|
98
|
+
throw new RequestError(400, "rules must be a list");
|
|
99
|
+
const inEffect = rules.map((r) => {
|
|
100
|
+
const { list, rule } = stringFields(r, ["list", "rule"]);
|
|
101
|
+
if (!PERMISSION_LISTS.includes(list))
|
|
102
|
+
throw new RequestError(400, "a rule needs a list: allow, ask or deny");
|
|
103
|
+
return { list: list, rule };
|
|
104
|
+
});
|
|
105
|
+
return readUsage(env, path, inEffect, { signal: stopOnClose(res) });
|
|
106
|
+
}),
|
|
107
|
+
post("/api/memory/preview", async ({ body }) => {
|
|
108
|
+
const { action, path } = stringFields(body, ["action", "path"]);
|
|
109
|
+
if (!isMemoryAction(action))
|
|
110
|
+
throw new RequestError(400, "unknown memory action");
|
|
111
|
+
return previewMemoryChange(env, action, path);
|
|
112
|
+
}),
|
|
113
|
+
post("/api/memory/change", async ({ body }) => {
|
|
114
|
+
const { action, path, revision } = stringFields(body, ["action", "path", "revision"]);
|
|
115
|
+
if (!isMemoryAction(action))
|
|
116
|
+
throw new RequestError(400, "unknown memory action");
|
|
117
|
+
return changeMemory(env, action, path, revision);
|
|
118
|
+
}),
|
|
119
|
+
post("/api/open", async ({ body }) => {
|
|
120
|
+
const { path } = stringFields(body, ["path"]);
|
|
121
|
+
if (!isAbsolute(path))
|
|
122
|
+
throw new RequestError(400, "an absolute path is required");
|
|
123
|
+
const info = await stat(path).catch(() => null);
|
|
124
|
+
if (!info?.isFile() && !info?.isDirectory())
|
|
125
|
+
throw new RefusedError("That file or folder no longer exists.");
|
|
126
|
+
if (await isCredentials(env, path))
|
|
127
|
+
throw new RefusedError("ccdesk does not open credentials.");
|
|
128
|
+
return { editor: await (options.openFile ?? openInEditor)(path) };
|
|
129
|
+
}),
|
|
130
|
+
post("/api/settings/preview", async ({ body }) => previewSettingsEdit(env, settingsEditFrom(body))),
|
|
131
|
+
post("/api/settings/save", async ({ body }) => saveSettingsEdit(env, settingsEditFrom(body), revision(body))),
|
|
132
|
+
post("/api/permissions/preview", async ({ body }) => previewPermissionEdit(env, permissionEditFrom(body))),
|
|
133
|
+
post("/api/permissions/save", async ({ body }) => savePermissionEdit(env, permissionEditFrom(body), revision(body))),
|
|
134
|
+
post("/api/hooks/preview", async ({ body }) => previewHookEdit(env, hookEditFrom(body))),
|
|
135
|
+
post("/api/hooks/save", async ({ body }) => saveHookEdit(env, hookEditFrom(body), revision(body))),
|
|
136
|
+
post("/api/agents/preview", async ({ body }) => previewAgentEdit(env, agentEditFrom(body))),
|
|
137
|
+
post("/api/agents/save", async ({ body }) => saveAgentEdit(env, agentEditFrom(body), revision(body))),
|
|
138
|
+
post("/api/mcp/preview", async ({ body }) => previewMcpEdit(env, mcpEditFrom(body), run)),
|
|
139
|
+
post("/api/mcp/save", async ({ body }) => saveMcpEdit(env, mcpEditFrom(body), revision(body), run)),
|
|
140
|
+
get("/api/watch", async ({ url, res }) => {
|
|
141
|
+
const dir = url.searchParams.get("dir");
|
|
142
|
+
const since = Number(url.searchParams.get("since") ?? "0");
|
|
143
|
+
if (!dir || !isAbsolute(dir) || !Number.isInteger(since))
|
|
144
|
+
throw new RequestError(400, "dir is required");
|
|
145
|
+
return watcher.wait(resolve(dir), since, stopOnClose(res));
|
|
146
|
+
}),
|
|
147
|
+
get("/api/compare", async ({ url }) => {
|
|
148
|
+
const a = url.searchParams.get("a");
|
|
149
|
+
const b = url.searchParams.get("b");
|
|
150
|
+
if (!a || !b || !isAbsolute(a) || !isAbsolute(b))
|
|
151
|
+
throw new RequestError(400, "a and b must be absolute paths");
|
|
152
|
+
return compareSettings(env, a, b);
|
|
153
|
+
}),
|
|
154
|
+
get("/api/path-test", async ({ url }) => {
|
|
155
|
+
const dir = url.searchParams.get("dir");
|
|
156
|
+
const file = url.searchParams.get("file");
|
|
157
|
+
if (!dir || !isAbsolute(dir) || !file)
|
|
158
|
+
throw new RequestError(400, "dir and file are required");
|
|
159
|
+
return testPath(env, dir, file, (cwd) => watcher.view(cwd));
|
|
160
|
+
}),
|
|
161
|
+
get("/api/backups", async ({ url }) => {
|
|
162
|
+
const before = url.searchParams.get("before") ?? undefined;
|
|
163
|
+
const limit = Number(url.searchParams.get("limit") ?? "50");
|
|
164
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 200)
|
|
165
|
+
throw new RequestError(400, "limit must be 1 to 200");
|
|
166
|
+
return listBackups(env, { ...(before ? { before } : {}), limit });
|
|
167
|
+
}),
|
|
168
|
+
post("/api/backups/preview", async ({ body }) => previewRestore(env, stringFields(body, ["id"]).id)),
|
|
169
|
+
post("/api/backups/restore", async ({ body }) => {
|
|
170
|
+
const { id, revision } = stringFields(body, ["id", "revision"]);
|
|
171
|
+
return restoreBackup(env, id, revision);
|
|
172
|
+
}),
|
|
173
|
+
];
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Every request passes the same checks before its route runs: the Host header, then for the API
|
|
177
|
+
* the token, the Origin of anything but a GET, and a POST's body. Failures are answered by
|
|
178
|
+
* `failure`.
|
|
179
|
+
*/
|
|
180
|
+
async function handle(options, table, server, req, res) {
|
|
52
181
|
const address = server.address();
|
|
53
182
|
const port = typeof address === "object" && address ? address.port : 0;
|
|
54
183
|
// Reject DNS-rebinding requests: only our own loopback origin may talk to this server.
|
|
@@ -66,123 +195,21 @@ async function handle(options, watcher, server, req, res) {
|
|
|
66
195
|
!allowedHosts.some((h) => origin === `http://${h}`)) {
|
|
67
196
|
return send(res, 403, { error: "Cross-origin request refused" });
|
|
68
197
|
}
|
|
69
|
-
const
|
|
70
|
-
if (
|
|
71
|
-
return send(res,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const path = url.searchParams.get("path");
|
|
75
|
-
if (!path)
|
|
76
|
-
return send(res, 400, { error: "path is required" });
|
|
77
|
-
const view = await scanDirectory(env, path);
|
|
78
|
-
watcher.track(view.path, view);
|
|
79
|
-
return send(res, 200, view);
|
|
80
|
-
}
|
|
81
|
-
if (req.method === "POST" && url.pathname === "/api/usage") {
|
|
82
|
-
const body = await readBody(req);
|
|
83
|
-
const { path } = stringFields(body, ["path"]);
|
|
84
|
-
const rules = body.rules;
|
|
85
|
-
if (!Array.isArray(rules))
|
|
86
|
-
return send(res, 400, { error: "rules must be a list" });
|
|
87
|
-
return send(res, 200, await readUsage(env, path, rules.map((r) => {
|
|
88
|
-
const { list, rule } = stringFields(r, ["list", "rule"]);
|
|
89
|
-
if (!LISTS.includes(list))
|
|
90
|
-
throw new RequestError(400, "a rule needs a list: allow, ask or deny");
|
|
91
|
-
return { list: list, rule };
|
|
92
|
-
})));
|
|
93
|
-
}
|
|
94
|
-
if (req.method === "POST" && url.pathname === "/api/memory/preview") {
|
|
95
|
-
const { action, path } = await fields(req, ["action", "path"]);
|
|
96
|
-
if (!isMemoryAction(action))
|
|
97
|
-
return send(res, 400, { error: "unknown memory action" });
|
|
98
|
-
return answer(res, () => previewMemoryChange(env, action, path));
|
|
99
|
-
}
|
|
100
|
-
if (req.method === "POST" && url.pathname === "/api/memory/change") {
|
|
101
|
-
const { action, path, revision } = await fields(req, ["action", "path", "revision"]);
|
|
102
|
-
if (!isMemoryAction(action))
|
|
103
|
-
return send(res, 400, { error: "unknown memory action" });
|
|
104
|
-
return answer(res, () => changeMemory(env, action, path, revision));
|
|
105
|
-
}
|
|
106
|
-
if (req.method === "POST" && url.pathname === "/api/open") {
|
|
107
|
-
const { path } = await fields(req, ["path"]);
|
|
108
|
-
if (!isAbsolute(path))
|
|
109
|
-
return send(res, 400, { error: "an absolute path is required" });
|
|
110
|
-
const info = await stat(path).catch(() => null);
|
|
111
|
-
if (!info?.isFile() && !info?.isDirectory())
|
|
112
|
-
return send(res, 409, { error: "That file or folder no longer exists." });
|
|
113
|
-
if (await isCredentials(env, path))
|
|
114
|
-
return send(res, 409, { error: "ccdesk does not open credentials." });
|
|
115
|
-
const open = options.openFile ?? openInEditor;
|
|
116
|
-
return answer(res, async () => ({ editor: await open(path) }));
|
|
117
|
-
}
|
|
118
|
-
if (req.method === "POST" && url.pathname === "/api/settings/preview") {
|
|
119
|
-
const edit = settingsEditFrom(await readBody(req));
|
|
120
|
-
return answer(res, () => previewSettingsEdit(env, edit));
|
|
121
|
-
}
|
|
122
|
-
if (req.method === "POST" && url.pathname === "/api/settings/save") {
|
|
123
|
-
const body = await readBody(req);
|
|
124
|
-
const edit = settingsEditFrom(body);
|
|
125
|
-
const { revision } = stringFields(body, ["revision"]);
|
|
126
|
-
return answer(res, () => saveSettingsEdit(env, edit, revision));
|
|
127
|
-
}
|
|
128
|
-
if (req.method === "POST" && url.pathname === "/api/permissions/preview") {
|
|
129
|
-
const edit = permissionEditFrom(await readBody(req));
|
|
130
|
-
return answer(res, () => previewPermissionEdit(env, edit));
|
|
131
|
-
}
|
|
132
|
-
if (req.method === "POST" && url.pathname === "/api/permissions/save") {
|
|
133
|
-
const body = await readBody(req);
|
|
134
|
-
const edit = permissionEditFrom(body);
|
|
135
|
-
const { revision } = stringFields(body, ["revision"]);
|
|
136
|
-
return answer(res, () => savePermissionEdit(env, edit, revision));
|
|
137
|
-
}
|
|
138
|
-
if (req.method === "GET" && url.pathname === "/api/watch") {
|
|
139
|
-
const dir = url.searchParams.get("dir");
|
|
140
|
-
const since = Number(url.searchParams.get("since") ?? "0");
|
|
141
|
-
if (!dir || !isAbsolute(dir) || !Number.isInteger(since))
|
|
142
|
-
return send(res, 400, { error: "dir is required" });
|
|
143
|
-
// Stop waiting when the page goes away or asks for another directory.
|
|
144
|
-
const stop = new AbortController();
|
|
145
|
-
res.once("close", () => stop.abort());
|
|
146
|
-
return send(res, 200, await watcher.wait(resolve(dir), since, stop.signal));
|
|
147
|
-
}
|
|
148
|
-
if (req.method === "GET" && url.pathname === "/api/path-test") {
|
|
149
|
-
const dir = url.searchParams.get("dir");
|
|
150
|
-
const file = url.searchParams.get("file");
|
|
151
|
-
if (!dir || !isAbsolute(dir) || !file)
|
|
152
|
-
return send(res, 400, { error: "dir and file are required" });
|
|
153
|
-
return answer(res, () => testPath(env, dir, file));
|
|
154
|
-
}
|
|
155
|
-
if (req.method === "GET" && url.pathname === "/api/backups") {
|
|
156
|
-
const before = url.searchParams.get("before") ?? undefined;
|
|
157
|
-
const limit = Number(url.searchParams.get("limit") ?? "50");
|
|
158
|
-
if (!Number.isInteger(limit) || limit < 1 || limit > 200)
|
|
159
|
-
return send(res, 400, { error: "limit must be 1 to 200" });
|
|
160
|
-
return send(res, 200, await listBackups(env, { ...(before ? { before } : {}), limit }));
|
|
161
|
-
}
|
|
162
|
-
if (req.method === "POST" && url.pathname === "/api/backups/preview") {
|
|
163
|
-
const { id } = await fields(req, ["id"]);
|
|
164
|
-
return answer(res, () => previewRestore(env, id));
|
|
165
|
-
}
|
|
166
|
-
if (req.method === "POST" && url.pathname === "/api/backups/restore") {
|
|
167
|
-
const { id, revision } = await fields(req, ["id", "revision"]);
|
|
168
|
-
return answer(res, () => restoreBackup(env, id, revision));
|
|
169
|
-
}
|
|
170
|
-
return send(res, 404, { error: "Not found" });
|
|
198
|
+
const route = table.find((r) => r.method === req.method && r.path === url.pathname);
|
|
199
|
+
if (!route)
|
|
200
|
+
return send(res, 404, { error: "Not found" });
|
|
201
|
+
const body = route.method === "POST" ? await readBody(req) : undefined;
|
|
202
|
+
return send(res, 200, await route.action({ url, body, res }));
|
|
171
203
|
}
|
|
172
|
-
const SCOPES = ["managed", "user", "project", "local"];
|
|
173
|
-
const LISTS = ["allow", "ask", "deny"];
|
|
174
204
|
/** The named string fields of a JSON body; anything else is a 400. */
|
|
175
205
|
function stringFields(body, names) {
|
|
176
206
|
if (!isRecord(body) || names.some((n) => typeof body[n] !== "string"))
|
|
177
207
|
throw new RequestError(400, `${names.join(", ")} ${names.length === 1 ? "is" : "are"} required`);
|
|
178
208
|
return body;
|
|
179
209
|
}
|
|
180
|
-
async function fields(req, names) {
|
|
181
|
-
return stringFields(await readBody(req), names);
|
|
182
|
-
}
|
|
183
210
|
function settingsEditFrom(body) {
|
|
184
211
|
const { dir, scope, key } = stringFields(body, ["dir", "scope", "key"]);
|
|
185
|
-
if (!
|
|
212
|
+
if (!EVERY_FILE.includes(scope))
|
|
186
213
|
throw new RequestError(400, "unknown scope");
|
|
187
214
|
const value = body.value;
|
|
188
215
|
return { dir, scope: scope, key, ...(value === undefined ? {} : { value }) };
|
|
@@ -193,7 +220,7 @@ function permissionEditFrom(body) {
|
|
|
193
220
|
if (value === undefined)
|
|
194
221
|
return undefined;
|
|
195
222
|
const { list, rule } = stringFields(value, ["list", "rule"]);
|
|
196
|
-
if (!
|
|
223
|
+
if (!PERMISSION_LISTS.includes(list) || rule.trim() === "")
|
|
197
224
|
throw new RequestError(400, "a rule needs a list (allow, ask or deny) and text");
|
|
198
225
|
return { list: list, rule };
|
|
199
226
|
};
|
|
@@ -209,15 +236,75 @@ function permissionEditFrom(body) {
|
|
|
209
236
|
throw new RequestError(400, "remove or add is required");
|
|
210
237
|
return edit;
|
|
211
238
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
239
|
+
function hookEditFrom(body) {
|
|
240
|
+
const { dir, file } = stringFields(body, ["dir", "file"]);
|
|
241
|
+
const { remove, add } = body;
|
|
242
|
+
const edit = { dir, file };
|
|
243
|
+
if (remove !== undefined) {
|
|
244
|
+
const { event } = stringFields(remove, ["event"]);
|
|
245
|
+
const { at, definition } = remove;
|
|
246
|
+
const index = (n) => (Number.isInteger(n) && n >= 0 ? n : -1);
|
|
247
|
+
const group = isRecord(at) ? index(at.group) : -1;
|
|
248
|
+
const hook = isRecord(at) ? index(at.index) : -1;
|
|
249
|
+
if (group < 0 || hook < 0)
|
|
250
|
+
throw new RequestError(400, "remove needs the hook's place");
|
|
251
|
+
edit.remove = { event, at: { group, index: hook }, definition };
|
|
252
|
+
}
|
|
253
|
+
if (add !== undefined) {
|
|
254
|
+
const { event } = stringFields(add, ["event"]);
|
|
255
|
+
if (!HOOK_EVENTS.includes(event))
|
|
256
|
+
throw new RequestError(400, `${event} is not a hook event Claude Code knows`);
|
|
257
|
+
const { matcher, definition } = add;
|
|
258
|
+
if (matcher !== undefined && typeof matcher !== "string")
|
|
259
|
+
throw new RequestError(400, "matcher must be text");
|
|
260
|
+
if (!isRecord(definition))
|
|
261
|
+
throw new RequestError(400, "a hook must be a JSON object");
|
|
262
|
+
edit.add = { event, ...(matcher ? { matcher } : {}), definition };
|
|
263
|
+
}
|
|
264
|
+
if (!edit.remove && !edit.add)
|
|
265
|
+
throw new RequestError(400, "remove or add is required");
|
|
266
|
+
return edit;
|
|
267
|
+
}
|
|
268
|
+
function agentEditFrom(body) {
|
|
269
|
+
const { dir } = stringFields(body, ["dir"]);
|
|
270
|
+
const { path, create, text } = body;
|
|
271
|
+
if (text !== null && typeof text !== "string")
|
|
272
|
+
throw new RequestError(400, "text must be the agent file, or null to delete it");
|
|
273
|
+
const edit = { dir, text };
|
|
274
|
+
if (typeof path === "string")
|
|
275
|
+
edit.path = path;
|
|
276
|
+
else if (create !== undefined) {
|
|
277
|
+
const { scope, file } = stringFields(create, ["scope", "file"]);
|
|
278
|
+
if (scope !== "user" && scope !== "project")
|
|
279
|
+
throw new RequestError(400, "a new agent goes in user or project scope");
|
|
280
|
+
if (text === null)
|
|
281
|
+
throw new RequestError(400, "a new agent needs its text");
|
|
282
|
+
edit.create = { scope, file };
|
|
215
283
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
284
|
+
else
|
|
285
|
+
throw new RequestError(400, "path or create is required");
|
|
286
|
+
return edit;
|
|
287
|
+
}
|
|
288
|
+
function mcpEditFrom(body) {
|
|
289
|
+
const { dir, scope } = stringFields(body, ["dir", "scope"]);
|
|
290
|
+
if (scope !== "user" && scope !== "local" && scope !== "project")
|
|
291
|
+
throw new RequestError(400, "scope must be user, local or project");
|
|
292
|
+
const { remove, add } = body;
|
|
293
|
+
const edit = { dir, scope };
|
|
294
|
+
if (remove !== undefined) {
|
|
295
|
+
const { name } = stringFields(remove, ["name"]);
|
|
296
|
+
edit.remove = { name, definition: remove.definition };
|
|
220
297
|
}
|
|
298
|
+
if (add !== undefined) {
|
|
299
|
+
const { name } = stringFields(add, ["name"]);
|
|
300
|
+
const { definition } = add;
|
|
301
|
+
if (!isRecord(definition))
|
|
302
|
+
throw new RequestError(400, "a server must be a JSON object");
|
|
303
|
+
edit.add = { name, definition };
|
|
304
|
+
}
|
|
305
|
+
if (!edit.remove && !edit.add)
|
|
306
|
+
throw new RequestError(400, "remove or add is required");
|
|
307
|
+
return edit;
|
|
221
308
|
}
|
|
222
309
|
const MAX_BODY = 64 * 1024;
|
|
223
310
|
async function readBody(req) {
|
|
@@ -241,7 +328,14 @@ async function readBody(req) {
|
|
|
241
328
|
async function serveStatic(uiDir, pathname, res) {
|
|
242
329
|
if (!uiDir)
|
|
243
330
|
return send(res, 404, { error: "UI is served by the Vite dev server" });
|
|
244
|
-
|
|
331
|
+
let decoded;
|
|
332
|
+
try {
|
|
333
|
+
decoded = decodeURIComponent(pathname);
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return send(res, 400, { error: "Bad request" });
|
|
337
|
+
}
|
|
338
|
+
const rel = normalize(decoded).replace(/^[/\\]+/, "");
|
|
245
339
|
const target = join(uiDir, rel === "" ? "index.html" : rel);
|
|
246
340
|
if (!target.startsWith(uiDir + sep) && target !== uiDir)
|
|
247
341
|
return send(res, 403, { error: "Forbidden" });
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { stat } from "node:fs/promises";
|
|
2
|
-
import {
|
|
2
|
+
import {} from "ajv";
|
|
3
3
|
import { allowedScopes, ignoredReason } from "../shared/scopes.js";
|
|
4
4
|
import schema from "../shared/settings.schema.json" with { type: "json" };
|
|
5
5
|
import {} from "../shared/types.js";
|
|
6
6
|
import {} from "./scan/env.js";
|
|
7
7
|
import { isRecord, jsonSyntaxError, redactSecrets, revisionOf } from "./scan/files.js";
|
|
8
|
+
import { isSkippedMcpRule } from "./scan/permissions.js";
|
|
8
9
|
import { settingsFiles } from "./scan/settings.js";
|
|
10
|
+
import { ajv, schemaErrors } from "./schema.js";
|
|
11
|
+
import { hasHiddenValues, shown } from "./secrets.js";
|
|
9
12
|
import { RefusedError, readIfExists, recordChange, serially, writeAtomic } from "./write.js";
|
|
10
13
|
const properties = schema.definitions.ClaudeCodeSettings.properties;
|
|
11
|
-
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
12
14
|
const validators = new Map();
|
|
13
15
|
function validatorFor(key) {
|
|
14
16
|
if (!Object.hasOwn(properties, key))
|
|
@@ -20,12 +22,6 @@ function validatorFor(key) {
|
|
|
20
22
|
}
|
|
21
23
|
return validate;
|
|
22
24
|
}
|
|
23
|
-
function shown(data) {
|
|
24
|
-
return `${JSON.stringify(redactSecrets(data), null, 2)}\n`;
|
|
25
|
-
}
|
|
26
|
-
function hasHiddenValues(value) {
|
|
27
|
-
return JSON.stringify(redactSecrets(value)) !== JSON.stringify(value);
|
|
28
|
-
}
|
|
29
25
|
async function editableFile(env, dir, pick, missing = "That is not a settings file Claude Code reads here.") {
|
|
30
26
|
const file = (await settingsFiles(env, dir)).find(pick);
|
|
31
27
|
if (!file)
|
|
@@ -51,8 +47,12 @@ async function readSettings(env, path) {
|
|
|
51
47
|
throw new RefusedError(`${path} does not hold a JSON object.`);
|
|
52
48
|
return { raw, data: parsed };
|
|
53
49
|
}
|
|
54
|
-
/**
|
|
55
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Works out the file change that sets or removes one key, refusing what ccdesk cannot do safely.
|
|
52
|
+
* `checksHiddenValues` is for a key whose edit checks the part it changes itself, so values it
|
|
53
|
+
* leaves alone are written back exactly as they were.
|
|
54
|
+
*/
|
|
55
|
+
async function planKey(env, file, key, value, checksHiddenValues = false) {
|
|
56
56
|
if (!allowedScopes(key).includes(file.scope))
|
|
57
57
|
throw new RefusedError(`${ignoredReason(key)}, so a ${file.scope} value would do nothing.`);
|
|
58
58
|
const validate = validatorFor(key);
|
|
@@ -61,20 +61,19 @@ async function planKey(env, file, key, value) {
|
|
|
61
61
|
const { path } = file;
|
|
62
62
|
const { raw, data } = await readSettings(env, path);
|
|
63
63
|
const nextValue = value(data);
|
|
64
|
-
// ccdesk never shows
|
|
65
|
-
if (
|
|
64
|
+
// ccdesk never shows hidden values, so it must not write them either.
|
|
65
|
+
if (!checksHiddenValues &&
|
|
66
|
+
(hasHiddenValues({ [key]: data[key] }) || hasHiddenValues({ [key]: nextValue })))
|
|
66
67
|
throw new RefusedError(`${key} holds hidden values; edit it in the file instead.`);
|
|
68
|
+
if (raw !== null && JSON.stringify(nextValue) === JSON.stringify(data[key]))
|
|
69
|
+
throw new RefusedError("This file already says that; there is nothing to change.");
|
|
67
70
|
const before = raw === null ? null : shown(data);
|
|
68
71
|
// The diff is rendered from the parsed file, so a file in another layout is reported as
|
|
69
72
|
// reformatted instead of diffing its raw text.
|
|
70
73
|
const reformats = raw !== null && raw.trim() !== "" && raw !== `${JSON.stringify(data, null, 2)}\n`;
|
|
71
74
|
const base = { path, before, revision: revisionOf(raw), reformats };
|
|
72
|
-
const
|
|
73
|
-
if (
|
|
74
|
-
delete next[key];
|
|
75
|
-
}
|
|
76
|
-
else if (!validate(nextValue)) {
|
|
77
|
-
const errors = (validate.errors ?? []).map((e) => `${key}${e.instancePath.replaceAll("/", ".")} ${e.message ?? "is invalid"}`);
|
|
75
|
+
const errors = nextValue === undefined ? [] : schemaErrors(validate, nextValue, key);
|
|
76
|
+
if (errors.length > 0)
|
|
78
77
|
return {
|
|
79
78
|
path,
|
|
80
79
|
scope: file.scope,
|
|
@@ -82,10 +81,11 @@ async function planKey(env, file, key, value) {
|
|
|
82
81
|
next: null,
|
|
83
82
|
preview: { ...base, after: before ?? "", errors },
|
|
84
83
|
};
|
|
85
|
-
}
|
|
86
|
-
|
|
84
|
+
const next = { ...data };
|
|
85
|
+
if (nextValue === undefined)
|
|
86
|
+
delete next[key];
|
|
87
|
+
else
|
|
87
88
|
next[key] = nextValue;
|
|
88
|
-
}
|
|
89
89
|
return {
|
|
90
90
|
path,
|
|
91
91
|
scope: file.scope,
|
|
@@ -119,6 +119,8 @@ async function planPermissionEdit(env, edit) {
|
|
|
119
119
|
delete permissions[remove.list];
|
|
120
120
|
}
|
|
121
121
|
if (add) {
|
|
122
|
+
if (isSkippedMcpRule(add.rule))
|
|
123
|
+
throw new RefusedError("Claude Code skips an MCP rule with parentheses in a settings file. To match a parameter of an MCP tool, pass a deny rule with --disallowedTools.");
|
|
122
124
|
const list = Array.isArray(permissions[add.list]) ? permissions[add.list] : [];
|
|
123
125
|
if (!list.includes(add.rule))
|
|
124
126
|
permissions[add.list] = [...list, add.rule];
|
|
@@ -126,6 +128,67 @@ async function planPermissionEdit(env, edit) {
|
|
|
126
128
|
return Object.keys(permissions).length > 0 ? permissions : undefined;
|
|
127
129
|
});
|
|
128
130
|
}
|
|
131
|
+
/** The matcher a group is written with; a missing or empty one matches everything. */
|
|
132
|
+
function matcherOf(group) {
|
|
133
|
+
return isRecord(group) && typeof group.matcher === "string" ? group.matcher : "";
|
|
134
|
+
}
|
|
135
|
+
/** One hook taken out, replaced or added, applied to the file as it is on disk. */
|
|
136
|
+
async function planHookEdit(env, edit) {
|
|
137
|
+
const file = await editableFile(env, edit.dir, (f) => f.path === edit.file);
|
|
138
|
+
return planKey(env, file, "hooks", (data) => {
|
|
139
|
+
const hooks = isRecord(data.hooks)
|
|
140
|
+
? structuredClone(data.hooks)
|
|
141
|
+
: {};
|
|
142
|
+
const groupsOf = (event) => {
|
|
143
|
+
const groups = hooks[event];
|
|
144
|
+
return Array.isArray(groups) ? groups : [];
|
|
145
|
+
};
|
|
146
|
+
const { remove, add } = edit;
|
|
147
|
+
if (remove) {
|
|
148
|
+
const groups = groupsOf(remove.event);
|
|
149
|
+
const group = groups[remove.at.group];
|
|
150
|
+
const list = isRecord(group) && Array.isArray(group.hooks) ? group.hooks : null;
|
|
151
|
+
const hook = list?.[remove.at.index];
|
|
152
|
+
if (!list || JSON.stringify(redactSecrets(hook)) !== JSON.stringify(remove.definition))
|
|
153
|
+
throw new RefusedError("This hook changed in the file since ccdesk read it; review it again.");
|
|
154
|
+
// The page saw this hook with its header values hidden, so a changed copy would lose them.
|
|
155
|
+
if (add && hasHiddenValues(hook))
|
|
156
|
+
throw new RefusedError("This hook sends header values ccdesk does not show; edit it in the file, or remove it and add it again.");
|
|
157
|
+
const matcher = add?.matcher ?? "";
|
|
158
|
+
// A hook alone in its group keeps the group when only its matcher changes, unless another
|
|
159
|
+
// group already has that matcher.
|
|
160
|
+
const inPlace = add?.event === remove.event &&
|
|
161
|
+
(matcher === matcherOf(group) ||
|
|
162
|
+
(list.length === 1 && !groups.some((g) => g !== group && matcherOf(g) === matcher)));
|
|
163
|
+
if (add && inPlace && isRecord(group)) {
|
|
164
|
+
list[remove.at.index] = add.definition;
|
|
165
|
+
if (matcher)
|
|
166
|
+
group.matcher = matcher;
|
|
167
|
+
else
|
|
168
|
+
delete group.matcher;
|
|
169
|
+
return hooks;
|
|
170
|
+
}
|
|
171
|
+
list.splice(remove.at.index, 1);
|
|
172
|
+
if (list.length === 0)
|
|
173
|
+
groups.splice(remove.at.group, 1);
|
|
174
|
+
if (groups.length === 0 && add?.event !== remove.event)
|
|
175
|
+
delete hooks[remove.event];
|
|
176
|
+
}
|
|
177
|
+
if (add) {
|
|
178
|
+
if (hasHiddenValues(add.definition))
|
|
179
|
+
throw new RefusedError("ccdesk does not write header values it would then hide; add this hook in the file instead.");
|
|
180
|
+
const groups = groupsOf(add.event);
|
|
181
|
+
const matcher = add.matcher ?? "";
|
|
182
|
+
const group = groups.find((g) => matcherOf(g) === matcher && isRecord(g) && Array.isArray(g.hooks));
|
|
183
|
+
if (isRecord(group) && Array.isArray(group.hooks))
|
|
184
|
+
group.hooks.push(add.definition);
|
|
185
|
+
else
|
|
186
|
+
groups.push({ ...(matcher ? { matcher } : {}), hooks: [add.definition] });
|
|
187
|
+
hooks[add.event] = groups;
|
|
188
|
+
}
|
|
189
|
+
return Object.keys(hooks).length > 0 ? hooks : undefined;
|
|
190
|
+
}, true);
|
|
191
|
+
}
|
|
129
192
|
/** Writes a planned edit, but only if the file is still the revision the preview was made from. */
|
|
130
193
|
async function commit(env, plan, expectedRevision) {
|
|
131
194
|
if (plan.next === null)
|
|
@@ -150,3 +213,9 @@ export async function previewPermissionEdit(env, edit) {
|
|
|
150
213
|
export function savePermissionEdit(env, edit, expectedRevision) {
|
|
151
214
|
return serially(async () => commit(env, await planPermissionEdit(env, edit), expectedRevision));
|
|
152
215
|
}
|
|
216
|
+
export async function previewHookEdit(env, edit) {
|
|
217
|
+
return (await planHookEdit(env, edit)).preview;
|
|
218
|
+
}
|
|
219
|
+
export function saveHookEdit(env, edit, expectedRevision) {
|
|
220
|
+
return serially(async () => commit(env, await planHookEdit(env, edit), expectedRevision));
|
|
221
|
+
}
|