ccdesk 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +9 -5
  3. package/dist/core/agent-edit.d.ts +6 -0
  4. package/dist/{server → core}/agent-edit.js +21 -22
  5. package/dist/core/backups.d.ts +19 -0
  6. package/dist/{server → core}/backups.js +37 -45
  7. package/dist/core/core.d.ts +96 -0
  8. package/dist/core/core.js +57 -0
  9. package/dist/core/index.d.ts +22 -0
  10. package/dist/core/index.js +38 -0
  11. package/dist/core/mcp-edit.d.ts +25 -0
  12. package/dist/{server → core}/mcp-edit.js +116 -95
  13. package/dist/core/mutations.d.ts +20 -0
  14. package/dist/{server → core}/mutations.js +8 -15
  15. package/dist/core/open.d.ts +31 -0
  16. package/dist/{server → core}/open.js +39 -12
  17. package/dist/core/parse-edit.d.ts +12 -0
  18. package/dist/core/parse-edit.js +64 -0
  19. package/dist/core/reference.d.ts +3 -0
  20. package/dist/core/reference.js +11 -0
  21. package/dist/core/scan/compare.d.ts +7 -0
  22. package/dist/{server → core}/scan/compare.js +5 -3
  23. package/dist/core/scan/context.d.ts +13 -0
  24. package/dist/{server → core}/scan/context.js +58 -32
  25. package/dist/core/scan/data.d.ts +8 -0
  26. package/dist/core/scan/data.js +18 -0
  27. package/dist/core/scan/env.d.ts +26 -0
  28. package/dist/{server → core}/scan/env.js +10 -3
  29. package/dist/core/scan/files.d.ts +68 -0
  30. package/dist/core/scan/files.js +270 -0
  31. package/dist/core/scan/git.d.ts +14 -0
  32. package/dist/core/scan/git.js +98 -0
  33. package/dist/core/scan/index.d.ts +5 -0
  34. package/dist/{server → core}/scan/index.js +115 -81
  35. package/dist/core/scan/instructions.d.ts +54 -0
  36. package/dist/{server → core}/scan/instructions.js +141 -73
  37. package/dist/core/scan/inventory.d.ts +38 -0
  38. package/dist/{server → core}/scan/inventory.js +134 -115
  39. package/dist/core/scan/mcp.d.ts +28 -0
  40. package/dist/{server → core}/scan/mcp.js +151 -94
  41. package/dist/core/scan/memory.d.ts +8 -0
  42. package/dist/{server → core}/scan/memory.js +8 -8
  43. package/dist/core/scan/path-test.d.ts +9 -0
  44. package/dist/core/scan/path-test.js +156 -0
  45. package/dist/core/scan/permissions.d.ts +31 -0
  46. package/dist/{server → core}/scan/permissions.js +6 -9
  47. package/dist/core/scan/plugins.d.ts +39 -0
  48. package/dist/{server → core}/scan/plugins.js +36 -27
  49. package/dist/core/scan/projects.d.ts +9 -0
  50. package/dist/{server → core}/scan/projects.js +5 -4
  51. package/dist/core/scan/sandbox.d.ts +14 -0
  52. package/dist/{server → core}/scan/sandbox.js +87 -59
  53. package/dist/core/scan/settings.d.ts +35 -0
  54. package/dist/{server → core}/scan/settings.js +88 -92
  55. package/dist/core/scan/tools.d.ts +10 -0
  56. package/dist/{server → core}/scan/tools.js +28 -20
  57. package/dist/core/scan/usage.d.ts +25 -0
  58. package/dist/{server → core}/scan/usage.js +117 -92
  59. package/dist/core/schema.d.ts +8 -0
  60. package/dist/core/schema.js +67 -0
  61. package/dist/core/secrets.d.ts +44 -0
  62. package/dist/core/secrets.js +187 -0
  63. package/dist/core/settings-edit.d.ts +9 -0
  64. package/dist/core/settings-edit.js +216 -0
  65. package/dist/core/watch.d.ts +45 -0
  66. package/dist/{server → core}/watch.js +129 -20
  67. package/dist/core/write.d.ts +119 -0
  68. package/dist/{server → core}/write.js +63 -8
  69. package/dist/server/args.d.ts +32 -0
  70. package/dist/server/args.js +106 -0
  71. package/dist/server/cli.d.ts +2 -0
  72. package/dist/server/cli.js +58 -83
  73. package/dist/server/commands.d.ts +8 -0
  74. package/dist/server/commands.js +39 -0
  75. package/dist/server/server.d.ts +12 -0
  76. package/dist/server/server.js +80 -195
  77. package/dist/server/start.d.ts +13 -0
  78. package/dist/server/start.js +21 -0
  79. package/dist/shared/compare.d.ts +25 -0
  80. package/dist/shared/memory.d.ts +2 -0
  81. package/dist/shared/requests.d.ts +104 -0
  82. package/dist/shared/requests.js +73 -0
  83. package/dist/shared/scopes.d.ts +10 -0
  84. package/dist/shared/settings-catalog.json +2199 -0
  85. package/dist/shared/types.d.ts +530 -0
  86. package/dist/shared/types.js +11 -0
  87. package/dist/ui/assets/index-CH1HoC38.js +60 -0
  88. package/dist/ui/index.html +1 -1
  89. package/dist/view/diff.d.ts +16 -0
  90. package/dist/view/diff.js +85 -0
  91. package/dist/view/format.d.ts +17 -0
  92. package/dist/view/format.js +47 -0
  93. package/dist/view/index.d.ts +11 -0
  94. package/dist/view/index.js +13 -0
  95. package/dist/view/lines.d.ts +9 -0
  96. package/dist/view/lines.js +12 -0
  97. package/dist/view/path-test.d.ts +3 -0
  98. package/dist/view/path-test.js +7 -0
  99. package/dist/view/sections/catalog.d.ts +9 -0
  100. package/dist/view/sections/catalog.js +47 -0
  101. package/dist/view/sections/extensions.d.ts +1 -0
  102. package/dist/view/sections/extensions.js +48 -0
  103. package/dist/view/sections/hooks.d.ts +1 -0
  104. package/dist/view/sections/hooks.js +32 -0
  105. package/dist/view/sections/index.d.ts +6 -0
  106. package/dist/view/sections/index.js +27 -0
  107. package/dist/view/sections/instructions.d.ts +6 -0
  108. package/dist/view/sections/instructions.js +38 -0
  109. package/dist/view/sections/mcp.d.ts +3 -0
  110. package/dist/view/sections/mcp.js +38 -0
  111. package/dist/view/sections/memory.d.ts +1 -0
  112. package/dist/view/sections/memory.js +31 -0
  113. package/dist/view/sections/permissions.d.ts +17 -0
  114. package/dist/view/sections/permissions.js +87 -0
  115. package/dist/view/sections/settings.d.ts +1 -0
  116. package/dist/view/sections/settings.js +33 -0
  117. package/dist/view/sections/tools.d.ts +6 -0
  118. package/dist/view/sections/tools.js +57 -0
  119. package/dist/view/sections/types.d.ts +123 -0
  120. package/dist/view/sections/types.js +10 -0
  121. package/dist/view/settings.d.ts +12 -0
  122. package/dist/view/settings.js +31 -0
  123. package/dist/view/usage.d.ts +8 -0
  124. package/dist/view/usage.js +18 -0
  125. package/package.json +13 -2
  126. package/dist/server/scan/files.js +0 -478
  127. package/dist/server/scan/path-test.js +0 -121
  128. package/dist/server/schema.js +0 -15
  129. package/dist/server/secrets.js +0 -63
  130. package/dist/server/settings-edit.js +0 -221
  131. package/dist/ui/assets/index-XqJwI3k9.js +0 -60
@@ -1,105 +1,80 @@
1
1
  #!/usr/bin/env node
2
- import { spawn } from "node:child_process";
3
2
  import { randomBytes } from "node:crypto";
4
3
  import { statSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { parseArgs } from "node:util";
7
- import { scanEnvFromProcess } from "./scan/env.js";
8
- import { createCcdeskServer } from "./server.js";
9
- const HELP = `ccdesk: see and change what Claude Code loads on this machine
10
-
11
- Usage: npx ccdesk [options]
12
-
13
- Options:
14
- --port <n> Port to listen on (default: a free port)
15
- --no-open Print the URL instead of opening a browser
16
- --config-dir <path> Claude folder to read, as CLAUDE_CONFIG_DIR sets it for Claude Code
17
- (wins over $CLAUDE_CONFIG_DIR)
18
- -h, --help Show this help
19
-
20
- ccdesk reads ~/.claude (or --config-dir, or $CLAUDE_CONFIG_DIR) and opens on the directory you
21
- run it from.
22
- It listens on 127.0.0.1 only.`;
23
- const DEV_PORT = 4319;
6
+ import { createCore } from "../core/core.js";
7
+ import { HELP, parse, UsageError } from "./args.js";
8
+ import { explainText, scanJson } from "./commands.js";
9
+ import { openBrowser } from "../core/open.js";
10
+ import { scanEnvFromProcess } from "../core/scan/env.js";
24
11
  const DEV_TOKEN = "dev";
25
- function options() {
12
+ function isFolder(path) {
13
+ return statSync(path, { throwIfNoEntry: false })?.isDirectory() ?? false;
14
+ }
15
+ async function main() {
16
+ let command;
26
17
  try {
27
- return parseArgs({
28
- options: {
29
- port: { type: "string" },
30
- "config-dir": { type: "string" },
31
- open: { type: "boolean", default: true },
32
- dev: { type: "boolean", default: false },
33
- help: { type: "boolean", short: "h", default: false },
34
- },
35
- allowNegative: true,
36
- }).values;
18
+ command = parse(process.argv.slice(2));
37
19
  }
38
20
  catch (error) {
39
- console.error(`ccdesk: ${error.message}\n\n${HELP}`);
40
- process.exit(2);
21
+ if (!(error instanceof UsageError))
22
+ throw error;
23
+ console.error(error.message);
24
+ process.exitCode = 2;
25
+ return;
41
26
  }
42
- }
43
- function main() {
44
- const values = options();
45
- if (values.help) {
27
+ if (command.command === "help") {
46
28
  console.log(HELP);
47
29
  return;
48
30
  }
49
- const dev = values.dev;
50
- const port = values.port ? Number(values.port) : dev ? DEV_PORT : 0;
51
- if (!Number.isInteger(port) || port < 0 || port > 65535) {
52
- console.error(`ccdesk: invalid --port ${values.port}`);
31
+ const { configDir } = command;
32
+ if (configDir !== undefined && !isFolder(configDir)) {
33
+ console.error(`ccdesk: --config-dir ${configDir} is not a folder`);
53
34
  process.exitCode = 2;
54
35
  return;
55
36
  }
56
- const configDir = values["config-dir"];
57
- if (configDir !== undefined && !statSync(configDir, { throwIfNoEntry: false })?.isDirectory()) {
58
- console.error(`ccdesk: --config-dir ${configDir} is not a folder`);
37
+ const core = createCore(scanEnvFromProcess(configDir));
38
+ if (command.command === "serve")
39
+ return serve(core, command);
40
+ const dir = resolve(command.dir ?? ".");
41
+ if (!isFolder(dir)) {
42
+ console.error(`ccdesk: ${command.dir} is not a folder`);
59
43
  process.exitCode = 2;
60
44
  return;
61
45
  }
62
- const token = dev ? DEV_TOKEN : randomBytes(18).toString("base64url");
63
- const server = createCcdeskServer({
64
- env: scanEnvFromProcess(configDir),
65
- launchDir: process.cwd(),
66
- token,
67
- uiDir: dev ? null : fileURLToPath(new URL("../ui", import.meta.url)),
68
- });
69
- server.on("error", (error) => {
70
- console.error(error.code === "EADDRINUSE"
71
- ? `ccdesk: port ${port} is already in use`
72
- : `ccdesk: ${error.message}`);
46
+ console.log(command.command === "scan"
47
+ ? await scanJson(core, dir)
48
+ : await explainText(core, dir, resolve(command.file)));
49
+ }
50
+ async function serve(core, { dev, port, open }) {
51
+ // Loaded here so that the subcommands, which only read, never load the HTTP server.
52
+ const { startServer } = await import("./start.js");
53
+ let server;
54
+ try {
55
+ server = await startServer({
56
+ core,
57
+ launchDir: process.cwd(),
58
+ token: dev ? DEV_TOKEN : randomBytes(18).toString("base64url"),
59
+ uiDir: dev ? null : fileURLToPath(new URL("../ui", import.meta.url)),
60
+ port,
61
+ });
62
+ }
63
+ catch (error) {
64
+ const { code, message } = error;
65
+ console.error(code === "EADDRINUSE" ? `ccdesk: port ${port} is already in use` : `ccdesk: ${message}`);
73
66
  process.exitCode = 1;
74
- });
75
- server.listen(port, "127.0.0.1", () => {
76
- const address = server.address();
77
- const actual = typeof address === "object" && address ? address.port : port;
78
- if (dev) {
79
- console.log(`ccdesk API on http://127.0.0.1:${actual} (dev token "${DEV_TOKEN}"); run \`pnpm dev:ui\` for the UI`);
80
- return;
81
- }
82
- const url = `http://127.0.0.1:${actual}/#token=${token}`;
83
- console.log(`ccdesk is running at ${url}\nPress Ctrl+C to stop.`);
84
- if (values.open)
85
- openBrowser(url);
86
- });
87
- const stop = () => {
88
- // An open browser tab keeps a connection alive, which would hold close() open.
89
- server.close(() => process.exit(0));
90
- server.closeAllConnections();
91
- };
67
+ return;
68
+ }
69
+ const stop = () => server.close().finally(() => process.exit(0));
92
70
  process.once("SIGINT", stop);
93
71
  process.once("SIGTERM", stop);
72
+ if (dev) {
73
+ console.log(`ccdesk API on ${new URL(server.url).origin} (dev token "${DEV_TOKEN}"); run \`pnpm dev:ui\` for the UI`);
74
+ return;
75
+ }
76
+ console.log(`ccdesk is running at ${server.url}\nPress Ctrl+C to stop.`);
77
+ if (open && (await openBrowser(server.url, core.env)) !== null)
78
+ console.log("Could not open a browser; open the URL above.");
94
79
  }
95
- function openBrowser(url) {
96
- const [command, args] = process.platform === "darwin"
97
- ? ["open", [url]]
98
- : process.platform === "win32"
99
- ? ["cmd", ["/c", "start", "", url]]
100
- : ["xdg-open", [url]];
101
- const child = spawn(command, args, { stdio: "ignore", detached: true });
102
- child.on("error", () => console.log("Could not open a browser; open the URL above."));
103
- child.unref();
104
- }
105
- main();
80
+ await main();
@@ -0,0 +1,8 @@
1
+ import { type Core } from "../core/index.ts";
2
+ /** `ccdesk scan --json`: the DirectoryView the page gets for `dir`. */
3
+ export declare function scanJson(core: Core, dir: string): Promise<string>;
4
+ /**
5
+ * `ccdesk explain`: what joins a session started in `dir` once Claude reads or edits `file`, as the
6
+ * page's path test lists it. Both paths are absolute.
7
+ */
8
+ export declare function explainText(core: Core, dir: string, file: string): Promise<string>;
@@ -0,0 +1,39 @@
1
+ import {} from "../core/index.js";
2
+ import { formatter, MATCH_KIND } from "../view/index.js";
3
+ // The subcommands of `ccdesk`. They only read, through the core, and print what the page would
4
+ // show: the scan is already redacted, so no value the page hides reaches the terminal.
5
+ /** `ccdesk scan --json`: the DirectoryView the page gets for `dir`. */
6
+ export async function scanJson(core, dir) {
7
+ return JSON.stringify(await core.scanDirectory(dir), null, 2);
8
+ }
9
+ /**
10
+ * `ccdesk explain`: what joins a session started in `dir` once Claude reads or edits `file`, as the
11
+ * page's path test lists it. Both paths are absolute.
12
+ */
13
+ export async function explainText(core, dir, file) {
14
+ const { tilde, relativeTo } = formatter(core.env.home);
15
+ const result = await core.testPath(dir, file);
16
+ if (!result.inside)
17
+ return `${tilde(result.file)} is outside ${tilde(dir)}, so nothing there applies to it.`;
18
+ const name = relativeTo(result.file, dir);
19
+ const note = result.exists ? "" : " (the file does not exist yet; paths are checked anyway)";
20
+ if (result.matches.length === 0)
21
+ return `Nothing more loads for ${name}${note}.`;
22
+ const shownName = (m) => m.name ?? relativeTo(m.path, dir);
23
+ const width = (cells) => Math.max(...cells.map((cell) => cell.length));
24
+ const scopeWidth = width(result.matches.map((m) => m.scope));
25
+ const kindWidth = width(result.matches.map((m) => MATCH_KIND[m.kind]));
26
+ const nameWidth = width(result.matches.map(shownName));
27
+ return [
28
+ `Once Claude reads or edits ${name} in ${tilde(dir)}${note}, these load:`,
29
+ ...result.matches.map((m) => [
30
+ "",
31
+ m.scope.padEnd(scopeWidth),
32
+ MATCH_KIND[m.kind].padEnd(kindWidth),
33
+ shownName(m).padEnd(nameWidth),
34
+ m.glob ? `matches ${m.glob}` : "",
35
+ ]
36
+ .join(" ")
37
+ .trimEnd()),
38
+ ].join("\n");
39
+ }
@@ -0,0 +1,12 @@
1
+ import { type Server } from "node:http";
2
+ import { type Core } from "../core/core.ts";
3
+ export interface ServerOptions {
4
+ /** What the routes read and change; tests make one with fakes so nothing is launched. */
5
+ core: Core;
6
+ launchDir: string;
7
+ /** Required on every API request; the browser gets it once, in the URL fragment. */
8
+ token: string;
9
+ /** Built UI directory; null serves the API only (the Vite dev server serves the UI). */
10
+ uiDir: string | null;
11
+ }
12
+ export declare function createCcdeskServer(options: ServerOptions): Server;
@@ -1,25 +1,21 @@
1
- import { readFile, stat } from "node:fs/promises";
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 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";
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";
10
- import { changeMemory, isMemoryAction, previewMemoryChange } from "./mutations.js";
11
- import { openInEditor } from "./open.js";
12
- import { compareSettings } from "./scan/compare.js";
13
- import {} from "./scan/env.js";
14
- import { isCredentials, isRecord } from "./scan/files.js";
15
- import { scanOverview } from "./scan/index.js";
16
- import { CountStopped, readUsage } from "./scan/usage.js";
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": "text/html; charset=utf-8",
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, message = `${name} is required`) {
66
+ function param(url, name) {
69
67
  const value = url.searchParams.get(name);
70
68
  if (!value)
71
- throw new RequestError(400, message);
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(options, watcher) {
81
- const { env, launchDir } = options;
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(env, launchDir)),
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 profile = url.searchParams.get("profile") ?? "interactive";
90
- if (!SESSION_PROFILES.includes(profile))
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(param(url, "path"), profile);
109
+ return watcher.scan(absoluteParam(url, "path"), profile);
93
110
  }),
94
111
  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) });
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 } = stringFields(body, ["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(env, action, path);
119
+ return core.previewMemoryChange(action, path);
112
120
  }),
113
121
  post("/api/memory/change", async ({ body }) => {
114
- const { action, path, revision } = stringFields(body, ["action", "path", "revision"]);
122
+ const { action, path } = checked(memoryBody, body);
115
123
  if (!isMemoryAction(action))
116
124
  throw new RequestError(400, "unknown memory action");
117
- return changeMemory(env, action, path, revision);
125
+ return core.changeMemory(action, path, revision(body));
118
126
  }),
119
127
  post("/api/open", async ({ body }) => {
120
- const { path } = stringFields(body, ["path"]);
128
+ const { path } = checked(openBody, body);
121
129
  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) };
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(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)),
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.searchParams.get("dir");
144
+ const dir = absoluteParam(url, "dir");
142
145
  const since = Number(url.searchParams.get("since") ?? "0");
143
- if (!dir || !isAbsolute(dir) || !Number.isInteger(since))
144
- throw new RequestError(400, "dir is required");
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
- 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
- }),
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(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);
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
- size += chunk.length;
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(chunk);
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") : MIME[".html"];
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
+ }