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
package/dist/server/server.js
CHANGED
|
@@ -1,25 +1,21 @@
|
|
|
1
|
-
import { readFile
|
|
1
|
+
import { readFile } 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
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {} from "
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
import { testPath } from "./scan/path-test.js";
|
|
18
|
-
import { previewHookEdit, previewPermissionEdit, previewSettingsEdit, saveHookEdit, savePermissionEdit, saveSettingsEdit, } from "./settings-edit.js";
|
|
19
|
-
import { Watcher } from "./watch.js";
|
|
20
|
-
import { RefusedError } from "./write.js";
|
|
4
|
+
import {} from "typebox";
|
|
5
|
+
import { Compile } from "typebox/compile";
|
|
6
|
+
import {} from "../core/core.js";
|
|
7
|
+
import { BackupBody, MemoryBody, OpenBody, RevisionBody, UsageBody } from "../shared/requests.js";
|
|
8
|
+
import { SESSION_PROFILES } from "../shared/types.js";
|
|
9
|
+
import { isMemoryAction } from "../core/mutations.js";
|
|
10
|
+
import { InvalidEditError, parseAgentEdit, parseHookEdit, parseMcpEdit, parsePermissionEdit, parseSettingsEdit, } from "../core/parse-edit.js";
|
|
11
|
+
import { REFERENCE } from "../core/reference.js";
|
|
12
|
+
import { CountStopped } from "../core/scan/usage.js";
|
|
13
|
+
import { firstError } from "../core/schema.js";
|
|
14
|
+
import { Watcher } from "../core/watch.js";
|
|
15
|
+
import { RefusedError } from "../core/write.js";
|
|
16
|
+
const HTML = "text/html; charset=utf-8";
|
|
21
17
|
const MIME = {
|
|
22
|
-
".html":
|
|
18
|
+
".html": HTML,
|
|
23
19
|
".js": "text/javascript; charset=utf-8",
|
|
24
20
|
".css": "text/css; charset=utf-8",
|
|
25
21
|
".svg": "image/svg+xml",
|
|
@@ -42,8 +38,8 @@ class RequestError extends Error {
|
|
|
42
38
|
}
|
|
43
39
|
}
|
|
44
40
|
export function createCcdeskServer(options) {
|
|
45
|
-
const watcher = new Watcher(options.env);
|
|
46
|
-
const table = routes(options, watcher);
|
|
41
|
+
const watcher = new Watcher(options.core.env);
|
|
42
|
+
const table = routes(options.core, watcher, options.launchDir);
|
|
47
43
|
const server = createServer((req, res) => {
|
|
48
44
|
handle(options, table, server, req, res).catch((error) => {
|
|
49
45
|
const [status, message] = failure(error);
|
|
@@ -57,6 +53,8 @@ export function createCcdeskServer(options) {
|
|
|
57
53
|
function failure(error) {
|
|
58
54
|
if (error instanceof RequestError)
|
|
59
55
|
return [error.status, error.message];
|
|
56
|
+
if (error instanceof InvalidEditError)
|
|
57
|
+
return [400, error.message];
|
|
60
58
|
// A change ccdesk refuses on purpose.
|
|
61
59
|
if (error instanceof RefusedError)
|
|
62
60
|
return [409, error.message];
|
|
@@ -65,111 +63,101 @@ function failure(error) {
|
|
|
65
63
|
return [500, error instanceof Error ? error.message : String(error)];
|
|
66
64
|
}
|
|
67
65
|
/** The query parameter `name`; a missing one is a 400. */
|
|
68
|
-
function param(url, name
|
|
66
|
+
function param(url, name) {
|
|
69
67
|
const value = url.searchParams.get(name);
|
|
70
68
|
if (!value)
|
|
71
|
-
throw new RequestError(400,
|
|
69
|
+
throw new RequestError(400, `${name} is required`);
|
|
72
70
|
return value;
|
|
73
71
|
}
|
|
72
|
+
/** The query parameter `name`, which must be an absolute path. */
|
|
73
|
+
function absoluteParam(url, name) {
|
|
74
|
+
const value = param(url, name);
|
|
75
|
+
if (!isAbsolute(value))
|
|
76
|
+
throw new RequestError(400, `${name} must be an absolute path`);
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
const revisionBody = Compile(RevisionBody);
|
|
80
|
+
const usageBody = Compile(UsageBody);
|
|
81
|
+
const memoryBody = Compile(MemoryBody);
|
|
82
|
+
const openBody = Compile(OpenBody);
|
|
83
|
+
const backupBody = Compile(BackupBody);
|
|
84
|
+
/** A POST body `validator` accepts; anything else is a 400 naming what is wrong. */
|
|
85
|
+
function checked(validator, body) {
|
|
86
|
+
if (!validator.Check(body))
|
|
87
|
+
throw new RequestError(400, firstError(validator, body));
|
|
88
|
+
return body;
|
|
89
|
+
}
|
|
74
90
|
/** Aborts when the page goes away or asks for something else. */
|
|
75
91
|
function stopOnClose(res) {
|
|
76
92
|
const stop = new AbortController();
|
|
77
93
|
res.once("close", () => stop.abort());
|
|
78
94
|
return stop.signal;
|
|
79
95
|
}
|
|
80
|
-
function routes(
|
|
81
|
-
const
|
|
82
|
-
const run = options.runClaude ?? runClaude;
|
|
83
|
-
const revision = (body) => stringFields(body, ["revision"]).revision;
|
|
96
|
+
function routes(core, watcher, launchDir) {
|
|
97
|
+
const revision = (body) => checked(revisionBody, body).revision;
|
|
84
98
|
const get = (path, action) => ({ method: "GET", path, action });
|
|
85
99
|
const post = (path, action) => ({ method: "POST", path, action });
|
|
86
100
|
return [
|
|
87
|
-
get("/api/overview", () => scanOverview(
|
|
101
|
+
get("/api/overview", () => core.scanOverview(launchDir)),
|
|
102
|
+
// Sent instead of bundled with the page: the catalog and schema are most of its size.
|
|
103
|
+
get("/api/reference", async () => REFERENCE),
|
|
88
104
|
get("/api/directory", ({ url }) => {
|
|
89
|
-
const
|
|
90
|
-
|
|
105
|
+
const asked = url.searchParams.get("profile") ?? "interactive";
|
|
106
|
+
const profile = SESSION_PROFILES.find((p) => p === asked);
|
|
107
|
+
if (!profile)
|
|
91
108
|
throw new RequestError(400, `profile is one of ${SESSION_PROFILES.join(", ")}`);
|
|
92
|
-
return watcher.scan(
|
|
109
|
+
return watcher.scan(absoluteParam(url, "path"), profile);
|
|
93
110
|
}),
|
|
94
111
|
post("/api/usage", ({ body, res }) => {
|
|
95
|
-
const { path } =
|
|
96
|
-
|
|
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) });
|
|
112
|
+
const { path, rules } = checked(usageBody, body);
|
|
113
|
+
return core.readUsage(path, rules, { signal: stopOnClose(res) });
|
|
106
114
|
}),
|
|
107
115
|
post("/api/memory/preview", async ({ body }) => {
|
|
108
|
-
const { action, path } =
|
|
116
|
+
const { action, path } = checked(memoryBody, body);
|
|
109
117
|
if (!isMemoryAction(action))
|
|
110
118
|
throw new RequestError(400, "unknown memory action");
|
|
111
|
-
return previewMemoryChange(
|
|
119
|
+
return core.previewMemoryChange(action, path);
|
|
112
120
|
}),
|
|
113
121
|
post("/api/memory/change", async ({ body }) => {
|
|
114
|
-
const { action, path
|
|
122
|
+
const { action, path } = checked(memoryBody, body);
|
|
115
123
|
if (!isMemoryAction(action))
|
|
116
124
|
throw new RequestError(400, "unknown memory action");
|
|
117
|
-
return changeMemory(
|
|
125
|
+
return core.changeMemory(action, path, revision(body));
|
|
118
126
|
}),
|
|
119
127
|
post("/api/open", async ({ body }) => {
|
|
120
|
-
const { path } =
|
|
128
|
+
const { path } = checked(openBody, body);
|
|
121
129
|
if (!isAbsolute(path))
|
|
122
|
-
throw new RequestError(400, "an absolute path
|
|
123
|
-
|
|
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) };
|
|
130
|
+
throw new RequestError(400, "path must be an absolute path");
|
|
131
|
+
return { editor: await core.openFile(path) };
|
|
129
132
|
}),
|
|
130
|
-
post("/api/settings/preview", async ({ body }) => previewSettingsEdit(
|
|
131
|
-
post("/api/settings/save", async ({ body }) => saveSettingsEdit(
|
|
132
|
-
post("/api/permissions/preview", async ({ body }) => previewPermissionEdit(
|
|
133
|
-
post("/api/permissions/save", async ({ body }) => savePermissionEdit(
|
|
134
|
-
post("/api/hooks/preview", async ({ body }) => previewHookEdit(
|
|
135
|
-
post("/api/hooks/save", async ({ body }) => saveHookEdit(
|
|
136
|
-
post("/api/agents/preview", async ({ body }) => previewAgentEdit(
|
|
137
|
-
post("/api/agents/save", async ({ body }) => saveAgentEdit(
|
|
138
|
-
post("/api/mcp/preview", async ({ body }) => previewMcpEdit(
|
|
139
|
-
post("/api/mcp/save", async ({ body }) => saveMcpEdit(
|
|
133
|
+
post("/api/settings/preview", async ({ body }) => core.previewSettingsEdit(parseSettingsEdit(body))),
|
|
134
|
+
post("/api/settings/save", async ({ body }) => core.saveSettingsEdit(parseSettingsEdit(body), revision(body))),
|
|
135
|
+
post("/api/permissions/preview", async ({ body }) => core.previewPermissionEdit(parsePermissionEdit(body))),
|
|
136
|
+
post("/api/permissions/save", async ({ body }) => core.savePermissionEdit(parsePermissionEdit(body), revision(body))),
|
|
137
|
+
post("/api/hooks/preview", async ({ body }) => core.previewHookEdit(parseHookEdit(body))),
|
|
138
|
+
post("/api/hooks/save", async ({ body }) => core.saveHookEdit(parseHookEdit(body), revision(body))),
|
|
139
|
+
post("/api/agents/preview", async ({ body }) => core.previewAgentEdit(parseAgentEdit(body))),
|
|
140
|
+
post("/api/agents/save", async ({ body }) => core.saveAgentEdit(parseAgentEdit(body), revision(body))),
|
|
141
|
+
post("/api/mcp/preview", async ({ body }) => core.previewMcpEdit(parseMcpEdit(body))),
|
|
142
|
+
post("/api/mcp/save", async ({ body }) => core.saveMcpEdit(parseMcpEdit(body), revision(body))),
|
|
140
143
|
get("/api/watch", async ({ url, res }) => {
|
|
141
|
-
const dir = url
|
|
144
|
+
const dir = absoluteParam(url, "dir");
|
|
142
145
|
const since = Number(url.searchParams.get("since") ?? "0");
|
|
143
|
-
if (!
|
|
144
|
-
throw new RequestError(400, "
|
|
146
|
+
if (!Number.isInteger(since))
|
|
147
|
+
throw new RequestError(400, "since must be a whole number");
|
|
145
148
|
return watcher.wait(resolve(dir), since, stopOnClose(res));
|
|
146
149
|
}),
|
|
147
|
-
get("/api/compare", async ({ url }) =>
|
|
148
|
-
|
|
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
|
-
}),
|
|
150
|
+
get("/api/compare", async ({ url }) => core.compareSettings(absoluteParam(url, "a"), absoluteParam(url, "b"))),
|
|
151
|
+
get("/api/path-test", async ({ url }) => core.testPath(absoluteParam(url, "dir"), param(url, "file"), (cwd) => watcher.view(cwd))),
|
|
161
152
|
get("/api/backups", async ({ url }) => {
|
|
162
153
|
const before = url.searchParams.get("before") ?? undefined;
|
|
163
154
|
const limit = Number(url.searchParams.get("limit") ?? "50");
|
|
164
155
|
if (!Number.isInteger(limit) || limit < 1 || limit > 200)
|
|
165
156
|
throw new RequestError(400, "limit must be 1 to 200");
|
|
166
|
-
return listBackups(
|
|
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);
|
|
157
|
+
return core.listBackups({ ...(before ? { before } : {}), limit });
|
|
172
158
|
}),
|
|
159
|
+
post("/api/backups/preview", async ({ body }) => core.previewRestore(checked(backupBody, body).id)),
|
|
160
|
+
post("/api/backups/restore", async ({ body }) => core.restoreBackup(checked(backupBody, body).id, revision(body))),
|
|
173
161
|
];
|
|
174
162
|
}
|
|
175
163
|
/**
|
|
@@ -201,111 +189,6 @@ async function handle(options, table, server, req, res) {
|
|
|
201
189
|
const body = route.method === "POST" ? await readBody(req) : undefined;
|
|
202
190
|
return send(res, 200, await route.action({ url, body, res }));
|
|
203
191
|
}
|
|
204
|
-
/** The named string fields of a JSON body; anything else is a 400. */
|
|
205
|
-
function stringFields(body, names) {
|
|
206
|
-
if (!isRecord(body) || names.some((n) => typeof body[n] !== "string"))
|
|
207
|
-
throw new RequestError(400, `${names.join(", ")} ${names.length === 1 ? "is" : "are"} required`);
|
|
208
|
-
return body;
|
|
209
|
-
}
|
|
210
|
-
function settingsEditFrom(body) {
|
|
211
|
-
const { dir, scope, key } = stringFields(body, ["dir", "scope", "key"]);
|
|
212
|
-
if (!EVERY_FILE.includes(scope))
|
|
213
|
-
throw new RequestError(400, "unknown scope");
|
|
214
|
-
const value = body.value;
|
|
215
|
-
return { dir, scope: scope, key, ...(value === undefined ? {} : { value }) };
|
|
216
|
-
}
|
|
217
|
-
function permissionEditFrom(body) {
|
|
218
|
-
const { dir, file } = stringFields(body, ["dir", "file"]);
|
|
219
|
-
const rule = (value) => {
|
|
220
|
-
if (value === undefined)
|
|
221
|
-
return undefined;
|
|
222
|
-
const { list, rule } = stringFields(value, ["list", "rule"]);
|
|
223
|
-
if (!PERMISSION_LISTS.includes(list) || rule.trim() === "")
|
|
224
|
-
throw new RequestError(400, "a rule needs a list (allow, ask or deny) and text");
|
|
225
|
-
return { list: list, rule };
|
|
226
|
-
};
|
|
227
|
-
const { remove, add } = body;
|
|
228
|
-
const edit = { dir, file };
|
|
229
|
-
const removed = rule(remove);
|
|
230
|
-
const added = rule(add);
|
|
231
|
-
if (removed)
|
|
232
|
-
edit.remove = removed;
|
|
233
|
-
if (added)
|
|
234
|
-
edit.add = added;
|
|
235
|
-
if (!removed && !added)
|
|
236
|
-
throw new RequestError(400, "remove or add is required");
|
|
237
|
-
return edit;
|
|
238
|
-
}
|
|
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 };
|
|
283
|
-
}
|
|
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 };
|
|
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;
|
|
308
|
-
}
|
|
309
192
|
const MAX_BODY = 64 * 1024;
|
|
310
193
|
async function readBody(req) {
|
|
311
194
|
if (!(req.headers["content-type"] ?? "").startsWith("application/json"))
|
|
@@ -313,10 +196,12 @@ async function readBody(req) {
|
|
|
313
196
|
const chunks = [];
|
|
314
197
|
let size = 0;
|
|
315
198
|
for await (const chunk of req) {
|
|
316
|
-
|
|
199
|
+
// A request without setEncoding yields Buffers.
|
|
200
|
+
const piece = chunk;
|
|
201
|
+
size += piece.length;
|
|
317
202
|
if (size > MAX_BODY)
|
|
318
203
|
throw new RequestError(413, "Request too large");
|
|
319
|
-
chunks.push(
|
|
204
|
+
chunks.push(piece);
|
|
320
205
|
}
|
|
321
206
|
try {
|
|
322
207
|
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
@@ -342,7 +227,7 @@ async function serveStatic(uiDir, pathname, res) {
|
|
|
342
227
|
const file = await readFile(target).catch(() => null);
|
|
343
228
|
// Unknown paths fall back to the app shell so client-side routes reload correctly.
|
|
344
229
|
const body = file ?? (await readFile(join(uiDir, "index.html")));
|
|
345
|
-
const type = file ? (MIME[extname(target)] ?? "application/octet-stream") :
|
|
230
|
+
const type = file ? (MIME[extname(target)] ?? "application/octet-stream") : HTML;
|
|
346
231
|
res.writeHead(200, { ...SECURITY_HEADERS, "Content-Type": type });
|
|
347
232
|
res.end(body);
|
|
348
233
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ServerOptions } from "./server.ts";
|
|
2
|
+
export interface StartOptions extends ServerOptions {
|
|
3
|
+
/** 0 picks a free port. */
|
|
4
|
+
port: number;
|
|
5
|
+
}
|
|
6
|
+
export interface RunningServer {
|
|
7
|
+
/** The page's address on 127.0.0.1, with the token in its fragment. */
|
|
8
|
+
url: string;
|
|
9
|
+
/** Stops listening and drops open connections, such as a browser tab's. */
|
|
10
|
+
close: () => Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
/** Serves `options.core` on 127.0.0.1; rejects when it cannot listen, such as on a port in use. */
|
|
13
|
+
export declare function startServer(options: StartOptions): Promise<RunningServer>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import {} from "node:net";
|
|
2
|
+
import { createCcdeskServer } from "./server.js";
|
|
3
|
+
/** Serves `options.core` on 127.0.0.1; rejects when it cannot listen, such as on a port in use. */
|
|
4
|
+
export function startServer(options) {
|
|
5
|
+
const server = createCcdeskServer(options);
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
server.once("error", reject);
|
|
8
|
+
server.listen(options.port, "127.0.0.1", () => {
|
|
9
|
+
server.off("error", reject);
|
|
10
|
+
const { port } = server.address();
|
|
11
|
+
resolve({
|
|
12
|
+
url: `http://127.0.0.1:${port}/#token=${options.token}`,
|
|
13
|
+
close: () => new Promise((closed, failed) => {
|
|
14
|
+
server.close((error) => (error ? failed(error) : closed()));
|
|
15
|
+
// An open browser tab keeps a connection alive, which would hold close() open.
|
|
16
|
+
server.closeAllConnections();
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type Scope } from "./types.ts";
|
|
2
|
+
/** The value a key has in one directory, and the settings files it comes from. */
|
|
3
|
+
export interface ComparedValue {
|
|
4
|
+
/** As ccdesk shows it, with hidden values as •••. */
|
|
5
|
+
value: unknown;
|
|
6
|
+
/** The files that decide it, lowest precedence first; several when lists or objects are combined. */
|
|
7
|
+
sources: Array<{
|
|
8
|
+
scope: Scope;
|
|
9
|
+
path: string;
|
|
10
|
+
}>;
|
|
11
|
+
}
|
|
12
|
+
export interface ComparedSetting {
|
|
13
|
+
key: string;
|
|
14
|
+
/** Null where no settings file Claude Code reads there sets the key. */
|
|
15
|
+
a: ComparedValue | null;
|
|
16
|
+
b: ComparedValue | null;
|
|
17
|
+
/** Whether both have the same value as ccdesk shows it; two hidden values count as the same. */
|
|
18
|
+
same: boolean;
|
|
19
|
+
}
|
|
20
|
+
/** The effective settings of two directories, key by key. */
|
|
21
|
+
export interface SettingsComparison {
|
|
22
|
+
a: string;
|
|
23
|
+
b: string;
|
|
24
|
+
settings: ComparedSetting[];
|
|
25
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code names a project's store after its path with every non-alphanumeric character as "-".
|
|
3
|
+
* A name over 200 characters is cut there and ends with "-" and a hash of the whole path, so deep
|
|
4
|
+
* paths that share a start get different stores (observed in the SDK 0.3.296; the docs do not say).
|
|
5
|
+
*/
|
|
6
|
+
export declare function storeDirName(projectPath: string): string;
|
package/dist/shared/memory.js
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
|
-
/** Claude Code
|
|
1
|
+
/** Longest store name Claude Code keeps as it is; a longer one is cut and given a hash of the path. */
|
|
2
|
+
const MAX_STORE_NAME = 200;
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code names a project's store after its path with every non-alphanumeric character as "-".
|
|
5
|
+
* A name over 200 characters is cut there and ends with "-" and a hash of the whole path, so deep
|
|
6
|
+
* paths that share a start get different stores (observed in the SDK 0.3.296; the docs do not say).
|
|
7
|
+
*/
|
|
2
8
|
export function storeDirName(projectPath) {
|
|
3
|
-
|
|
9
|
+
const name = projectPath.replace(/[^a-zA-Z0-9]/g, "-");
|
|
10
|
+
if (name.length <= MAX_STORE_NAME)
|
|
11
|
+
return name;
|
|
12
|
+
return `${name.slice(0, MAX_STORE_NAME)}-${Math.abs(pathHash(projectPath)).toString(36)}`;
|
|
13
|
+
}
|
|
14
|
+
/** Java's 32-bit string hash, over UTF-16 code units, as Claude Code computes it. */
|
|
15
|
+
function pathHash(text) {
|
|
16
|
+
let hash = 0;
|
|
17
|
+
for (let i = 0; i < text.length; i++)
|
|
18
|
+
hash = ((hash << 5) - hash + text.charCodeAt(i)) | 0;
|
|
19
|
+
return hash;
|
|
4
20
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { type Static, Type } from "typebox";
|
|
2
|
+
/** Set one top-level key in one settings file; `value` undefined removes the key. */
|
|
3
|
+
export declare const SettingsEditBody: Type.TObject<{
|
|
4
|
+
dir: Type.TString;
|
|
5
|
+
scope: Type.TEnum<import("./types.ts").Scope[]>;
|
|
6
|
+
key: Type.TString;
|
|
7
|
+
value: Type.TOptional<Type.TUnknown>;
|
|
8
|
+
}>;
|
|
9
|
+
export type SettingsEdit = Static<typeof SettingsEditBody>;
|
|
10
|
+
/** Take one permission rule out of its list and/or add one, in one settings file. */
|
|
11
|
+
export declare const PermissionEditBody: Type.TObject<{
|
|
12
|
+
dir: Type.TString;
|
|
13
|
+
file: Type.TString;
|
|
14
|
+
remove: Type.TOptional<Type.TObject<{
|
|
15
|
+
list: Type.TEnum<import("./types.ts").PermissionList[]>;
|
|
16
|
+
rule: Type.TString;
|
|
17
|
+
}>>;
|
|
18
|
+
add: Type.TOptional<Type.TObject<{
|
|
19
|
+
list: Type.TEnum<import("./types.ts").PermissionList[]>;
|
|
20
|
+
rule: Type.TString;
|
|
21
|
+
}>>;
|
|
22
|
+
}>;
|
|
23
|
+
export type PermissionEdit = Static<typeof PermissionEditBody>;
|
|
24
|
+
/**
|
|
25
|
+
* One hook change to a settings file. With both, the hook is replaced in place when it stays in
|
|
26
|
+
* the same event and matcher, and moved otherwise.
|
|
27
|
+
*/
|
|
28
|
+
export declare const HookEditBody: Type.TObject<{
|
|
29
|
+
dir: Type.TString;
|
|
30
|
+
file: Type.TString;
|
|
31
|
+
/** The hook to take out, as the scan showed it; refused when the file no longer has it there. */
|
|
32
|
+
remove: Type.TOptional<Type.TObject<{
|
|
33
|
+
event: Type.TString;
|
|
34
|
+
at: Type.TObject<{
|
|
35
|
+
group: Type.TInteger;
|
|
36
|
+
index: Type.TInteger;
|
|
37
|
+
}>;
|
|
38
|
+
definition: Type.TUnknown;
|
|
39
|
+
}>>;
|
|
40
|
+
/** The hook to write: the matcher group it goes in, and the hook itself. */
|
|
41
|
+
add: Type.TOptional<Type.TObject<{
|
|
42
|
+
event: Type.TString;
|
|
43
|
+
/** Empty or absent for a group without a matcher, which matches everything. */
|
|
44
|
+
matcher: Type.TOptional<Type.TString>;
|
|
45
|
+
definition: Type.TUnknown;
|
|
46
|
+
}>>;
|
|
47
|
+
}>;
|
|
48
|
+
export type HookEdit = Static<typeof HookEditBody>;
|
|
49
|
+
/** One subagent file change: a new agent, a changed one, or one deleted. */
|
|
50
|
+
export declare const AgentEditBody: Type.TObject<{
|
|
51
|
+
dir: Type.TString;
|
|
52
|
+
/** An agent the scan shows here; absent when creating one. */
|
|
53
|
+
path: Type.TOptional<Type.TString>;
|
|
54
|
+
/** Where a new agent goes, and its file name without .md. */
|
|
55
|
+
create: Type.TOptional<Type.TObject<{
|
|
56
|
+
scope: Type.TEnum<["user", "project"]>;
|
|
57
|
+
file: Type.TString;
|
|
58
|
+
}>>;
|
|
59
|
+
/** The whole file; null deletes the agent. */
|
|
60
|
+
text: Type.TUnion<[Type.TString, Type.TNull]>;
|
|
61
|
+
}>;
|
|
62
|
+
export type AgentEdit = Static<typeof AgentEditBody>;
|
|
63
|
+
/**
|
|
64
|
+
* One MCP server change. Project servers are changed in .mcp.json; user and local ones through
|
|
65
|
+
* `claude mcp`. With both, the server is replaced, keeping its place in .mcp.json.
|
|
66
|
+
*/
|
|
67
|
+
export declare const McpEditBody: Type.TObject<{
|
|
68
|
+
dir: Type.TString;
|
|
69
|
+
scope: Type.TEnum<["user", "local", "project"]>;
|
|
70
|
+
/** The server to take out, as the scan showed it; refused when it changed since. */
|
|
71
|
+
remove: Type.TOptional<Type.TObject<{
|
|
72
|
+
name: Type.TString;
|
|
73
|
+
definition: Type.TUnknown;
|
|
74
|
+
}>>;
|
|
75
|
+
add: Type.TOptional<Type.TObject<{
|
|
76
|
+
name: Type.TString;
|
|
77
|
+
definition: Type.TUnknown;
|
|
78
|
+
}>>;
|
|
79
|
+
}>;
|
|
80
|
+
export type McpEdit = Static<typeof McpEditBody>;
|
|
81
|
+
/** A save's or restore's revision: the one its preview returned. */
|
|
82
|
+
export declare const RevisionBody: Type.TObject<{
|
|
83
|
+
revision: Type.TString;
|
|
84
|
+
}>;
|
|
85
|
+
/** Count which permission rules the transcripts under `path` used. */
|
|
86
|
+
export declare const UsageBody: Type.TObject<{
|
|
87
|
+
path: Type.TString;
|
|
88
|
+
rules: Type.TArray<Type.TObject<{
|
|
89
|
+
list: Type.TEnum<import("./types.ts").PermissionList[]>;
|
|
90
|
+
rule: Type.TString;
|
|
91
|
+
}>>;
|
|
92
|
+
}>;
|
|
93
|
+
/** Preview a memory change; changing it also sends the revision. */
|
|
94
|
+
export declare const MemoryBody: Type.TObject<{
|
|
95
|
+
action: Type.TString;
|
|
96
|
+
path: Type.TString;
|
|
97
|
+
}>;
|
|
98
|
+
export declare const OpenBody: Type.TObject<{
|
|
99
|
+
path: Type.TString;
|
|
100
|
+
}>;
|
|
101
|
+
/** Preview restoring a backup; restoring it also sends the revision. */
|
|
102
|
+
export declare const BackupBody: Type.TObject<{
|
|
103
|
+
id: Type.TString;
|
|
104
|
+
}>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// What each API request sends, as JSON Schema. The server checks bodies against these, and each
|
|
2
|
+
// type is read from its schema. The page imports only the types, re-exported from types.ts, so
|
|
3
|
+
// TypeBox stays out of its bundle.
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { EVERY_FILE, PERMISSION_LISTS } from "./scopes.js";
|
|
6
|
+
/** Set one top-level key in one settings file; `value` undefined removes the key. */
|
|
7
|
+
export const SettingsEditBody = Type.Object({
|
|
8
|
+
dir: Type.String(),
|
|
9
|
+
scope: Type.Enum(EVERY_FILE),
|
|
10
|
+
key: Type.String(),
|
|
11
|
+
value: Type.Optional(Type.Unknown()),
|
|
12
|
+
});
|
|
13
|
+
/** One permission rule. */
|
|
14
|
+
const Rule = Type.Object({ list: Type.Enum(PERMISSION_LISTS), rule: Type.String() });
|
|
15
|
+
/** Take one permission rule out of its list and/or add one, in one settings file. */
|
|
16
|
+
export const PermissionEditBody = Type.Object({
|
|
17
|
+
dir: Type.String(),
|
|
18
|
+
file: Type.String(),
|
|
19
|
+
remove: Type.Optional(Rule),
|
|
20
|
+
add: Type.Optional(Rule),
|
|
21
|
+
});
|
|
22
|
+
const place = Type.Integer({ minimum: 0 });
|
|
23
|
+
/**
|
|
24
|
+
* One hook change to a settings file. With both, the hook is replaced in place when it stays in
|
|
25
|
+
* the same event and matcher, and moved otherwise.
|
|
26
|
+
*/
|
|
27
|
+
export const HookEditBody = Type.Object({
|
|
28
|
+
dir: Type.String(),
|
|
29
|
+
file: Type.String(),
|
|
30
|
+
/** The hook to take out, as the scan showed it; refused when the file no longer has it there. */
|
|
31
|
+
remove: Type.Optional(Type.Object({
|
|
32
|
+
event: Type.String(),
|
|
33
|
+
at: Type.Object({ group: place, index: place }),
|
|
34
|
+
definition: Type.Unknown(),
|
|
35
|
+
})),
|
|
36
|
+
/** The hook to write: the matcher group it goes in, and the hook itself. */
|
|
37
|
+
add: Type.Optional(Type.Object({
|
|
38
|
+
event: Type.String(),
|
|
39
|
+
/** Empty or absent for a group without a matcher, which matches everything. */
|
|
40
|
+
matcher: Type.Optional(Type.String()),
|
|
41
|
+
definition: Type.Unknown(),
|
|
42
|
+
})),
|
|
43
|
+
});
|
|
44
|
+
/** One subagent file change: a new agent, a changed one, or one deleted. */
|
|
45
|
+
export const AgentEditBody = Type.Object({
|
|
46
|
+
dir: Type.String(),
|
|
47
|
+
/** An agent the scan shows here; absent when creating one. */
|
|
48
|
+
path: Type.Optional(Type.String()),
|
|
49
|
+
/** Where a new agent goes, and its file name without .md. */
|
|
50
|
+
create: Type.Optional(Type.Object({ scope: Type.Enum(["user", "project"]), file: Type.String() })),
|
|
51
|
+
/** The whole file; null deletes the agent. */
|
|
52
|
+
text: Type.Union([Type.String(), Type.Null()]),
|
|
53
|
+
});
|
|
54
|
+
/**
|
|
55
|
+
* One MCP server change. Project servers are changed in .mcp.json; user and local ones through
|
|
56
|
+
* `claude mcp`. With both, the server is replaced, keeping its place in .mcp.json.
|
|
57
|
+
*/
|
|
58
|
+
export const McpEditBody = Type.Object({
|
|
59
|
+
dir: Type.String(),
|
|
60
|
+
scope: Type.Enum(["user", "local", "project"]),
|
|
61
|
+
/** The server to take out, as the scan showed it; refused when it changed since. */
|
|
62
|
+
remove: Type.Optional(Type.Object({ name: Type.String(), definition: Type.Unknown() })),
|
|
63
|
+
add: Type.Optional(Type.Object({ name: Type.String(), definition: Type.Unknown() })),
|
|
64
|
+
});
|
|
65
|
+
/** A save's or restore's revision: the one its preview returned. */
|
|
66
|
+
export const RevisionBody = Type.Object({ revision: Type.String() });
|
|
67
|
+
/** Count which permission rules the transcripts under `path` used. */
|
|
68
|
+
export const UsageBody = Type.Object({ path: Type.String(), rules: Type.Array(Rule) });
|
|
69
|
+
/** Preview a memory change; changing it also sends the revision. */
|
|
70
|
+
export const MemoryBody = Type.Object({ action: Type.String(), path: Type.String() });
|
|
71
|
+
export const OpenBody = Type.Object({ path: Type.String() });
|
|
72
|
+
/** Preview restoring a backup; restoring it also sends the revision. */
|
|
73
|
+
export const BackupBody = Type.Object({ id: Type.String() });
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type PermissionList, type Scope } from "./types.ts";
|
|
2
|
+
export declare const EVERY_FILE: Scope[];
|
|
3
|
+
/** Settings files by precedence: managed beats local beats project beats user. */
|
|
4
|
+
export declare const HIGHEST_FIRST: Scope[];
|
|
5
|
+
/** The settings files ccdesk writes to; managed settings belong to your organisation. */
|
|
6
|
+
export declare const EDITABLE_SCOPES: Scope[];
|
|
7
|
+
/** The permission lists, in the order ccdesk shows them. */
|
|
8
|
+
export declare const PERMISSION_LISTS: PermissionList[];
|
|
9
|
+
export declare function allowedScopes(key: string): Scope[];
|
|
10
|
+
export declare function ignoredReason(key: string): string;
|