ccdesk 0.2.2 → 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 (130) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +6 -3
  3. package/dist/core/agent-edit.d.ts +6 -0
  4. package/dist/{server → core}/agent-edit.js +15 -21
  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 +115 -98
  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 +2 -2
  23. package/dist/core/scan/context.d.ts +13 -0
  24. package/dist/{server → core}/scan/context.js +55 -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 +7 -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 +5 -8
  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 -95
  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/{server → core}/settings-edit.js +91 -112
  65. package/dist/core/watch.d.ts +45 -0
  66. package/dist/{server → core}/watch.js +54 -17
  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 -480
  127. package/dist/server/scan/path-test.js +0 -136
  128. package/dist/server/schema.js +0 -15
  129. package/dist/server/secrets.js +0 -63
  130. package/dist/ui/assets/index-Dg9dxngR.js +0 -60
@@ -0,0 +1,67 @@
1
+ import {} from "typebox/error";
2
+ import { Settings } from "typebox/system";
3
+ import {} from "typebox/compile";
4
+ /**
5
+ * What `validator` found wrong with `value`, each led by where: `label` and the path inside it,
6
+ * such as `sandbox.network`.
7
+ */
8
+ export function schemaErrors(validator, value, label) {
9
+ if (validator.Check(value))
10
+ return [];
11
+ return allErrors(validator, value).flatMap((e) => messages(e).map(([path, message]) => {
12
+ const where = [label, ...path.split("/")].filter(Boolean).join(".");
13
+ return where ? `${where} ${message}` : message;
14
+ }));
15
+ }
16
+ /** The first thing wrong with a request body, such as "add.list must be string". */
17
+ export function firstError(validator, value) {
18
+ return schemaErrors(validator, value, "")[0] ?? "the request is not well formed";
19
+ }
20
+ /**
21
+ * Every error, not TypeBox's first eight: a wrong hook lists what each kind of hook needs. Each
22
+ * value checked is one setting, server or request body, so the list stays short. The limit is
23
+ * TypeBox's process-wide setting, so it is put back for anything else in the process using it.
24
+ */
25
+ function allErrors(validator, value) {
26
+ const { maxErrors } = Settings.Get();
27
+ Settings.Set({ maxErrors: Number.POSITIVE_INFINITY });
28
+ try {
29
+ return validator.Errors(value);
30
+ }
31
+ finally {
32
+ Settings.Set({ maxErrors });
33
+ }
34
+ }
35
+ /**
36
+ * An error as where it is and what is wrong, worded as ccdesk has always shown them: one missing
37
+ * property at a time, and one "must NOT have additional properties" for each property a closed
38
+ * object does not allow.
39
+ */
40
+ function messages(e) {
41
+ switch (e.keyword) {
42
+ case "required":
43
+ return e.params.requiredProperties.map((p) => [
44
+ e.instancePath,
45
+ `must have required property '${p}'`,
46
+ ]);
47
+ // Said once for each extra property, through its "schema is false" error below; a property
48
+ // that fails an additionalProperties schema has its own error already.
49
+ case "additionalProperties":
50
+ return [];
51
+ case "boolean":
52
+ if (!e.schemaPath.endsWith("/additionalProperties"))
53
+ return [[e.instancePath, e.message]];
54
+ return [
55
+ [
56
+ e.instancePath.slice(0, e.instancePath.lastIndexOf("/")),
57
+ "must NOT have additional properties",
58
+ ],
59
+ ];
60
+ case "enum":
61
+ return [[e.instancePath, `${e.message}: ${e.params.allowedValues.join(", ")}`]];
62
+ case "minItems":
63
+ return [[e.instancePath, `must NOT have fewer than ${e.params.limit} items`]];
64
+ default:
65
+ return [[e.instancePath, e.message]];
66
+ }
67
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * A server URL as ccdesk shows it: a user name, password or query string can carry a key, so they
3
+ * are hidden, as Claude Code hides the URL in its own status output (docs: mcp).
4
+ */
5
+ export declare function shownUrl(url: string): string;
6
+ /**
7
+ * Replace values that commonly hold secrets with a placeholder, keeping the keys: env and headers
8
+ * values, the user, password and query of a `url`, and secret values in the `args` of a command.
9
+ */
10
+ export declare function redactSecrets(value: Record<string, unknown>): Record<string, unknown>;
11
+ export declare function redactSecrets(value: unknown): unknown;
12
+ /**
13
+ * A JSON parse failure without the parser's message, which quotes the text around the fault and
14
+ * so can quote a secret.
15
+ */
16
+ export declare function jsonSyntaxError(error: unknown): string;
17
+ /**
18
+ * The text ccdesk shows for a file reached as an instruction, import, memory, agent or skill. A
19
+ * settings or MCP file read this way, whatever it is named, would otherwise show its env and
20
+ * headers values, so anything that parses as JSON is shown parsed with them hidden. Frontmatter can
21
+ * hold the same values, in a subagent's inline MCP servers (docs: sub-agents#scope-mcp-servers-to-a-subagent),
22
+ * so frontmatter that holds any is shown re-written with them hidden.
23
+ */
24
+ export declare function shownText(text: string | null): string | null;
25
+ /** A file named .json that does not parse is not shown: its text could still hold a secret. */
26
+ export declare function shownJsonFile(text: string): string;
27
+ /**
28
+ * Helpers for the values ccdesk hides. What is hidden is decided only by redactSecrets; these
29
+ * compare a value with its redacted form.
30
+ */
31
+ /** A value as ccdesk shows it: JSON with hidden values as •••. */
32
+ export declare function shown(value: unknown): string;
33
+ export declare function hasHiddenValues(value: unknown): boolean;
34
+ /**
35
+ * A fingerprint of the hidden values in a JSON file, so a restore can tell whether they changed.
36
+ * Null for a file that is not valid JSON.
37
+ */
38
+ export declare function hiddenValues(text: string | null): string | null;
39
+ /**
40
+ * The secret text in a value, to take out of a message that may quote it: an env or header value
41
+ * or a hidden argument whole, the part after `=` of `--token=value`, and a URL's user name,
42
+ * password and query. Longest first, so a secret that contains another is taken out whole.
43
+ */
44
+ export declare function secretTexts(value: unknown): string[];
@@ -0,0 +1,187 @@
1
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
2
+ import { FRONTMATTER, isRecord } from "./scan/data.js";
3
+ /**
4
+ * A server URL as ccdesk shows it: a user name, password or query string can carry a key, so they
5
+ * are hidden, as Claude Code hides the URL in its own status output (docs: mcp).
6
+ */
7
+ export function shownUrl(url) {
8
+ try {
9
+ const u = new URL(url);
10
+ if (!u.username && !u.password && !u.search)
11
+ return url;
12
+ if (u.username || u.password) {
13
+ u.username = "•••";
14
+ u.password = "";
15
+ }
16
+ if (u.search)
17
+ u.search = "?•••";
18
+ return decodeURI(u.toString());
19
+ }
20
+ catch {
21
+ return url.replace(/\/\/[^/@]*@/, "//•••@").replace(/\?.*$/, "?•••");
22
+ }
23
+ }
24
+ /** A flag whose value is a secret, such as `--token` or `--api-key`. */
25
+ const SECRET_FLAG = /^--(token|api[-_]?key|apikey|password|secret|auth|key)$/i;
26
+ /** A value that starts like a common API key or token. */
27
+ const KEY_PREFIX = /^(sk-|ghp_|gho_|ghu_|ghs_|ghr_|github_pat_|xox[bp]-|glpat-|AKIA[0-9A-Z]{16})/;
28
+ /**
29
+ * A server's command-line arguments as ccdesk shows them: the value after a secret flag, given as
30
+ * `--token value` or `--token=value`, a value that starts like a known key, and the credentials in
31
+ * a URL are hidden.
32
+ */
33
+ function shownArgs(args) {
34
+ return args.map((arg, i) => {
35
+ if (typeof arg !== "string")
36
+ return arg;
37
+ const previous = args[i - 1];
38
+ if (typeof previous === "string" && SECRET_FLAG.test(previous) && !arg.startsWith("-"))
39
+ return "•••";
40
+ const equals = arg.indexOf("=");
41
+ if (equals > 0) {
42
+ const name = arg.slice(0, equals);
43
+ if (SECRET_FLAG.test(name) || KEY_PREFIX.test(arg.slice(equals + 1)))
44
+ return `${name}=•••`;
45
+ }
46
+ if (KEY_PREFIX.test(arg))
47
+ return "•••";
48
+ if (/^https?:\/\//i.test(arg))
49
+ return shownUrl(arg);
50
+ return arg;
51
+ });
52
+ }
53
+ export function redactSecrets(value) {
54
+ if (Array.isArray(value))
55
+ return value.map(redactSecrets);
56
+ if (!isRecord(value))
57
+ return value;
58
+ // Built with fromEntries: assigning a key named __proto__ would set the prototype instead.
59
+ return Object.fromEntries(Object.entries(value).map(([key, inner]) => {
60
+ if ((key === "env" || key === "headers") && isRecord(inner))
61
+ return [key, Object.fromEntries(Object.keys(inner).map((k) => [k, "•••"]))];
62
+ if (key === "url" && typeof inner === "string")
63
+ return [key, shownUrl(inner)];
64
+ if (key === "args" && "command" in value && Array.isArray(inner))
65
+ return [key, shownArgs(inner)];
66
+ return [key, redactSecrets(inner)];
67
+ }));
68
+ }
69
+ /**
70
+ * A JSON parse failure without the parser's message, which quotes the text around the fault and
71
+ * so can quote a secret.
72
+ */
73
+ export function jsonSyntaxError(error) {
74
+ const at = /line (\d+) column (\d+)/.exec(String(error));
75
+ return at ? `not valid JSON (line ${at[1]}, column ${at[2]})` : "not valid JSON";
76
+ }
77
+ /**
78
+ * The text ccdesk shows for a file reached as an instruction, import, memory, agent or skill. A
79
+ * settings or MCP file read this way, whatever it is named, would otherwise show its env and
80
+ * headers values, so anything that parses as JSON is shown parsed with them hidden. Frontmatter can
81
+ * hold the same values, in a subagent's inline MCP servers (docs: sub-agents#scope-mcp-servers-to-a-subagent),
82
+ * so frontmatter that holds any is shown re-written with them hidden.
83
+ */
84
+ export function shownText(text) {
85
+ if (text === null)
86
+ return null;
87
+ let data;
88
+ try {
89
+ data = JSON.parse(text);
90
+ }
91
+ catch {
92
+ return shownFrontmatter(text);
93
+ }
94
+ return typeof data === "object" && data !== null
95
+ ? `${JSON.stringify(redactSecrets(data), null, 2)}\n`
96
+ : text;
97
+ }
98
+ function shownFrontmatter(text) {
99
+ const match = FRONTMATTER.exec(text);
100
+ if (!match)
101
+ return text;
102
+ let data;
103
+ try {
104
+ data = parseYaml(match[1] ?? "");
105
+ }
106
+ catch {
107
+ return text;
108
+ }
109
+ const redacted = redactSecrets(data);
110
+ if (JSON.stringify(redacted) === JSON.stringify(data))
111
+ return text;
112
+ return `---\n${stringifyYaml(redacted)}---\n${text.slice(match[0].length)}`;
113
+ }
114
+ /** A file named .json that does not parse is not shown: its text could still hold a secret. */
115
+ export function shownJsonFile(text) {
116
+ try {
117
+ JSON.parse(text);
118
+ }
119
+ catch {
120
+ return "(a JSON file ccdesk could not parse, so its text is not shown; open it to see it)\n";
121
+ }
122
+ return shownText(text) ?? "";
123
+ }
124
+ /**
125
+ * Helpers for the values ccdesk hides. What is hidden is decided only by redactSecrets; these
126
+ * compare a value with its redacted form.
127
+ */
128
+ /** A value as ccdesk shows it: JSON with hidden values as •••. */
129
+ export function shown(value) {
130
+ return `${JSON.stringify(redactSecrets(value), null, 2)}\n`;
131
+ }
132
+ /** Every value redactSecrets changes, with where it is, what it is, and how it is shown. */
133
+ function hiddenIn(value) {
134
+ const found = [];
135
+ // redactSecrets keeps every key and list length, so the two can be walked side by side.
136
+ const walk = (raw, shownValue, path) => {
137
+ if (Array.isArray(raw) && Array.isArray(shownValue)) {
138
+ for (const [i, inner] of raw.entries())
139
+ walk(inner, shownValue[i], [...path, i]);
140
+ }
141
+ else if (isRecord(raw) && isRecord(shownValue))
142
+ for (const [key, inner] of Object.entries(raw))
143
+ walk(inner, shownValue[key], [...path, key]);
144
+ else if (raw !== shownValue)
145
+ found.push({ path, raw, shown: shownValue });
146
+ };
147
+ walk(value, redactSecrets(value), []);
148
+ return found;
149
+ }
150
+ export function hasHiddenValues(value) {
151
+ return hiddenIn(value).length > 0;
152
+ }
153
+ /**
154
+ * A fingerprint of the hidden values in a JSON file, so a restore can tell whether they changed.
155
+ * Null for a file that is not valid JSON.
156
+ */
157
+ export function hiddenValues(text) {
158
+ try {
159
+ const data = text === null ? null : JSON.parse(text);
160
+ return JSON.stringify(hiddenIn(data).map(({ path, raw }) => [path, raw]));
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ }
166
+ /**
167
+ * The secret text in a value, to take out of a message that may quote it: an env or header value
168
+ * or a hidden argument whole, the part after `=` of `--token=value`, and a URL's user name,
169
+ * password and query. Longest first, so a secret that contains another is taken out whole.
170
+ */
171
+ export function secretTexts(value) {
172
+ const found = hiddenIn(value).flatMap(({ raw, shown: hidden }) => {
173
+ if (typeof raw !== "string")
174
+ return [];
175
+ if (hidden === "•••")
176
+ return [raw];
177
+ if (typeof hidden === "string" && hidden.endsWith("=•••"))
178
+ return [raw.slice(raw.indexOf("=") + 1)];
179
+ if (shownUrl(raw) === raw)
180
+ return [];
181
+ const url = URL.parse(raw);
182
+ return [url?.username, url?.password, url?.search.slice(1)];
183
+ });
184
+ return found
185
+ .filter((s) => s !== undefined && s !== "")
186
+ .sort((a, b) => b.length - a.length);
187
+ }
@@ -0,0 +1,9 @@
1
+ import { type Change, type HookEdit, type PermissionEdit, type SettingsEdit, type SettingsEditPreview } from "../shared/types.ts";
2
+ import { type ScanEnv } from "./scan/env.ts";
3
+ import { type Serially } from "./write.ts";
4
+ export declare function previewSettingsEdit(env: ScanEnv, edit: SettingsEdit): Promise<SettingsEditPreview>;
5
+ export declare function saveSettingsEdit(env: ScanEnv, edit: SettingsEdit, expectedRevision: string, queue?: Serially): Promise<Change>;
6
+ export declare function previewPermissionEdit(env: ScanEnv, edit: PermissionEdit): Promise<SettingsEditPreview>;
7
+ export declare function savePermissionEdit(env: ScanEnv, edit: PermissionEdit, expectedRevision: string, queue?: Serially): Promise<Change>;
8
+ export declare function previewHookEdit(env: ScanEnv, edit: HookEdit): Promise<SettingsEditPreview>;
9
+ export declare function saveHookEdit(env: ScanEnv, edit: HookEdit, expectedRevision: string, queue?: Serially): Promise<Change>;
@@ -1,16 +1,17 @@
1
1
  import { stat } from "node:fs/promises";
2
- import {} from "ajv";
2
+ import { Compile } from "typebox/compile";
3
3
  import HOOK_EVENTS from "../shared/hook-events.json" with { type: "json" };
4
4
  import { allowedScopes, ignoredReason } from "../shared/scopes.js";
5
5
  import schema from "../shared/settings.schema.json" with { type: "json" };
6
6
  import {} from "../shared/types.js";
7
7
  import {} from "./scan/env.js";
8
- import { isRecord, jsonSyntaxError, redactSecrets, revisionOf } from "./scan/files.js";
8
+ import { revisionOf } from "./scan/files.js";
9
+ import { isRecord } from "./scan/data.js";
9
10
  import { isSkippedMcpRule } from "./scan/permissions.js";
10
11
  import { settingsFiles } from "./scan/settings.js";
11
- import { ajv, schemaErrors } from "./schema.js";
12
- import { hasHiddenValues, shown } from "./secrets.js";
13
- import { RefusedError, readIfExists, recordChange, serially, writeAtomic } from "./write.js";
12
+ import { schemaErrors } from "./schema.js";
13
+ import { hasHiddenValues, redactSecrets, shown } from "./secrets.js";
14
+ import { RefusedError, readJsonObject, recordChange, saveReviewed, serially, writeAtomic, } from "./write.js";
14
15
  const properties = schema.definitions.ClaudeCodeSettings.properties;
15
16
  const validators = new Map();
16
17
  function validatorFor(key) {
@@ -18,7 +19,7 @@ function validatorFor(key) {
18
19
  return null;
19
20
  let validate = validators.get(key);
20
21
  if (!validate) {
21
- validate = ajv.compile(properties[key]);
22
+ validate = Compile(properties[key]);
22
23
  validators.set(key, validate);
23
24
  }
24
25
  return validate;
@@ -33,21 +34,6 @@ async function editableFile(env, dir, pick, missing = "That is not a settings fi
33
34
  throw new RefusedError(`${dir} is not a directory on this machine.`);
34
35
  return file;
35
36
  }
36
- async function readSettings(env, path) {
37
- const raw = await readIfExists(env, path);
38
- if (raw === null || raw.trim() === "")
39
- return { raw, data: {} };
40
- let parsed;
41
- try {
42
- parsed = JSON.parse(raw);
43
- }
44
- catch (error) {
45
- throw new RefusedError(`${path} is ${jsonSyntaxError(error)}; fix it by hand first.`);
46
- }
47
- if (!isRecord(parsed))
48
- throw new RefusedError(`${path} does not hold a JSON object.`);
49
- return { raw, data: parsed };
50
- }
51
37
  /**
52
38
  * Works out the file change that sets or removes one key, refusing what ccdesk cannot do safely.
53
39
  * `checksHiddenValues` is for a key whose edit checks the part it changes itself, so values it
@@ -60,7 +46,7 @@ async function planKey(env, file, key, value, checksHiddenValues = false) {
60
46
  if (!validate)
61
47
  throw new RefusedError(`${key} is not in Claude Code's settings schema (${schema.$comment}).`);
62
48
  const { path } = file;
63
- const { raw, data } = await readSettings(env, path);
49
+ const { raw, data, reformats } = await readJsonObject(env, path);
64
50
  const nextValue = value(data);
65
51
  // ccdesk never shows hidden values, so it must not write them either.
66
52
  if (!checksHiddenValues &&
@@ -68,33 +54,29 @@ async function planKey(env, file, key, value, checksHiddenValues = false) {
68
54
  throw new RefusedError(`${key} holds hidden values; edit it in the file instead.`);
69
55
  if (raw !== null && JSON.stringify(nextValue) === JSON.stringify(data[key]))
70
56
  throw new RefusedError("This file already says that; there is nothing to change.");
71
- const before = raw === null ? null : shown(data);
72
- // The diff is rendered from the parsed file, so a file in another layout is reported as
73
- // reformatted instead of diffing its raw text.
74
- const reformats = raw !== null && raw.trim() !== "" && raw !== `${JSON.stringify(data, null, 2)}\n`;
75
- const base = { path, before, revision: revisionOf(raw), reformats };
76
57
  const errors = nextValue === undefined
77
58
  ? []
78
59
  : [...schemaErrors(validate, nextValue, key), ...newHookEvents(key, data[key], nextValue)];
79
- if (errors.length > 0)
80
- return {
81
- path,
82
- scope: file.scope,
83
- raw,
84
- next: null,
85
- preview: { ...base, after: before ?? "", errors },
86
- };
87
60
  const next = { ...data };
88
61
  if (nextValue === undefined)
89
62
  delete next[key];
90
63
  else
91
64
  next[key] = nextValue;
65
+ const text = `${JSON.stringify(next, null, 2)}\n`;
66
+ const before = raw === null ? null : shown(data);
92
67
  return {
93
- path,
94
- scope: file.scope,
95
- raw,
96
- next: `${JSON.stringify(next, null, 2)}\n`,
97
- preview: { ...base, after: shown(next), errors: [] },
68
+ preview: {
69
+ path,
70
+ before,
71
+ after: errors.length > 0 ? (before ?? "") : shown(next),
72
+ revision: revisionOf(raw),
73
+ reformats,
74
+ errors,
75
+ },
76
+ save: () => recordChange(env, { kind: "settings", target: path, before: raw }, async () => {
77
+ await writeAtomic(env, path, text, revisionOf(raw));
78
+ return { target: path, revision: revisionOf(text) };
79
+ }),
98
80
  };
99
81
  }
100
82
  async function planSettingsEdit(env, edit) {
@@ -150,88 +132,85 @@ function matcherOf(group) {
150
132
  /** One hook taken out, replaced or added, applied to the file as it is on disk. */
151
133
  async function planHookEdit(env, edit) {
152
134
  const file = await editableFile(env, edit.dir, (f) => f.path === edit.file);
153
- return planKey(env, file, "hooks", (data) => {
154
- const hooks = isRecord(data.hooks)
155
- ? structuredClone(data.hooks)
156
- : {};
157
- const groupsOf = (event) => {
158
- const groups = hooks[event];
159
- return Array.isArray(groups) ? groups : [];
160
- };
161
- const { remove, add } = edit;
162
- // Checked before any branch, so neither a new hook nor a replacement writes them.
163
- if (add && hasHiddenValues(add.definition))
164
- throw new RefusedError("ccdesk does not write header values it would then hide; add this hook in the file instead.");
165
- if (remove) {
166
- const groups = groupsOf(remove.event);
167
- const group = groups[remove.at.group];
168
- const list = isRecord(group) && Array.isArray(group.hooks) ? group.hooks : null;
169
- const hook = list?.[remove.at.index];
170
- if (!list || JSON.stringify(redactSecrets(hook)) !== JSON.stringify(remove.definition))
171
- throw new RefusedError("This hook changed in the file since ccdesk read it; review it again.");
172
- // The page saw this hook with its header values hidden, so a changed copy would lose them.
173
- if (add && hasHiddenValues(hook))
174
- throw new RefusedError("This hook sends header values ccdesk does not show; edit it in the file, or remove it and add it again.");
175
- const matcher = add?.matcher ?? "";
176
- // A hook alone in its group keeps the group when only its matcher changes, unless another
177
- // group already has that matcher.
178
- const inPlace = add?.event === remove.event &&
179
- (matcher === matcherOf(group) ||
180
- (list.length === 1 && !groups.some((g) => g !== group && matcherOf(g) === matcher)));
181
- if (add && inPlace && isRecord(group)) {
182
- list[remove.at.index] = add.definition;
183
- if (matcher)
184
- group.matcher = matcher;
185
- else
186
- delete group.matcher;
187
- return hooks;
188
- }
189
- list.splice(remove.at.index, 1);
190
- if (list.length === 0)
191
- groups.splice(remove.at.group, 1);
192
- if (groups.length === 0 && add?.event !== remove.event)
193
- delete hooks[remove.event];
194
- }
195
- if (add) {
196
- const groups = groupsOf(add.event);
197
- const matcher = add.matcher ?? "";
198
- const group = groups.find((g) => matcherOf(g) === matcher && isRecord(g) && Array.isArray(g.hooks));
199
- if (isRecord(group) && Array.isArray(group.hooks))
200
- group.hooks.push(add.definition);
201
- else
202
- groups.push({ ...(matcher ? { matcher } : {}), hooks: [add.definition] });
203
- hooks[add.event] = groups;
204
- }
205
- return Object.keys(hooks).length > 0 ? hooks : undefined;
206
- }, true);
207
- }
208
- /** Writes a planned edit, but only if the file is still the revision the preview was made from. */
209
- async function commit(env, plan, expectedRevision) {
210
- if (plan.next === null)
211
- throw new RefusedError(plan.preview.errors.join("; "));
212
- if (plan.preview.revision !== expectedRevision)
213
- throw new RefusedError("The file changed since you reviewed this edit; review it again.");
214
- const next = plan.next;
215
- return recordChange(env, { kind: "settings", target: plan.path, before: plan.raw }, async () => {
216
- await writeAtomic(env, plan.path, next, expectedRevision);
217
- return { target: plan.path, revision: revisionOf(next) };
218
- });
135
+ return planKey(env, file, "hooks", (data) => editedHooks(data.hooks, edit), true);
136
+ }
137
+ /** The hooks setting with the edit applied, or undefined when no hooks are left. */
138
+ function editedHooks(value, edit) {
139
+ const hooks = isRecord(value) ? structuredClone(value) : {};
140
+ const { remove, add } = edit;
141
+ // Checked before any branch, so neither a new hook nor a replacement writes them.
142
+ if (add && hasHiddenValues(add.definition))
143
+ throw new RefusedError("ccdesk does not write header values it would then hide; add this hook in the file instead.");
144
+ if (remove && takeOutHook(hooks, remove, add) === "replaced")
145
+ return hooks;
146
+ if (add) {
147
+ const groups = groupsOf(hooks, add.event);
148
+ const matcher = add.matcher ?? "";
149
+ const group = groups.find((g) => matcherOf(g) === matcher && isRecord(g) && Array.isArray(g.hooks));
150
+ if (isRecord(group) && Array.isArray(group.hooks))
151
+ group.hooks.push(add.definition);
152
+ else
153
+ groups.push({ ...(matcher ? { matcher } : {}), hooks: [add.definition] });
154
+ hooks[add.event] = groups;
155
+ }
156
+ return Object.keys(hooks).length > 0 ? hooks : undefined;
219
157
  }
158
+ /** An event's matcher groups as stored in `hooks`, or a new list when it has none. */
159
+ function groupsOf(hooks, event) {
160
+ const groups = hooks[event];
161
+ return Array.isArray(groups) ? groups : [];
162
+ }
163
+ /**
164
+ * Takes the reviewed hook out of `hooks`, or puts `add` in its place when only its matcher or
165
+ * nothing about where it sits changes.
166
+ */
167
+ function takeOutHook(hooks, remove, add) {
168
+ const groups = groupsOf(hooks, remove.event);
169
+ const group = groups[remove.at.group];
170
+ const list = isRecord(group) && Array.isArray(group.hooks) ? group.hooks : null;
171
+ const hook = list?.[remove.at.index];
172
+ if (!list || JSON.stringify(redactSecrets(hook)) !== JSON.stringify(remove.definition))
173
+ throw new RefusedError("This hook changed in the file since ccdesk read it; review it again.");
174
+ // The page saw this hook with its header values hidden, so a changed copy would lose them.
175
+ if (add && hasHiddenValues(hook))
176
+ throw new RefusedError("This hook sends header values ccdesk does not show; edit it in the file, or remove it and add it again.");
177
+ const matcher = add?.matcher ?? "";
178
+ // A hook alone in its group keeps the group when only its matcher changes, unless another
179
+ // group already has that matcher.
180
+ const inPlace = add?.event === remove.event &&
181
+ (matcher === matcherOf(group) ||
182
+ (list.length === 1 && !groups.some((g) => g !== group && matcherOf(g) === matcher)));
183
+ if (add && inPlace && isRecord(group)) {
184
+ list[remove.at.index] = add.definition;
185
+ if (matcher)
186
+ group.matcher = matcher;
187
+ else
188
+ delete group.matcher;
189
+ return "replaced";
190
+ }
191
+ list.splice(remove.at.index, 1);
192
+ if (list.length === 0)
193
+ groups.splice(remove.at.group, 1);
194
+ if (groups.length === 0 && add?.event !== remove.event)
195
+ delete hooks[remove.event];
196
+ return "removed";
197
+ }
198
+ const CHANGED = "The file changed since you reviewed this edit; review it again.";
220
199
  export async function previewSettingsEdit(env, edit) {
221
200
  return (await planSettingsEdit(env, edit)).preview;
222
201
  }
223
- export function saveSettingsEdit(env, edit, expectedRevision) {
224
- return serially(async () => commit(env, await planSettingsEdit(env, edit), expectedRevision));
202
+ export function saveSettingsEdit(env, edit, expectedRevision, queue = serially) {
203
+ return saveReviewed(queue, () => planSettingsEdit(env, edit), expectedRevision, CHANGED);
225
204
  }
226
205
  export async function previewPermissionEdit(env, edit) {
227
206
  return (await planPermissionEdit(env, edit)).preview;
228
207
  }
229
- export function savePermissionEdit(env, edit, expectedRevision) {
230
- return serially(async () => commit(env, await planPermissionEdit(env, edit), expectedRevision));
208
+ export function savePermissionEdit(env, edit, expectedRevision, queue = serially) {
209
+ return saveReviewed(queue, () => planPermissionEdit(env, edit), expectedRevision, CHANGED);
231
210
  }
232
211
  export async function previewHookEdit(env, edit) {
233
212
  return (await planHookEdit(env, edit)).preview;
234
213
  }
235
- export function saveHookEdit(env, edit, expectedRevision) {
236
- return serially(async () => commit(env, await planHookEdit(env, edit), expectedRevision));
214
+ export function saveHookEdit(env, edit, expectedRevision, queue = serially) {
215
+ return saveReviewed(queue, () => planHookEdit(env, edit), expectedRevision, CHANGED);
237
216
  }
@@ -0,0 +1,45 @@
1
+ import { type DirectoryView, type SessionProfile } from "../shared/types.ts";
2
+ import { type ScanEnv } from "./scan/env.ts";
3
+ import { scanDirectory } from "./scan/index.ts";
4
+ /** One set of file watchers per directory a page is looking at, shared by its requests. */
5
+ export declare class Watcher {
6
+ private readonly subscriptions;
7
+ /** What the latest scan of each directory read, kept for a watch that starts after the scan. */
8
+ private readonly scanned;
9
+ private readonly latest;
10
+ private readonly env;
11
+ private readonly scanOf;
12
+ constructor(env: ScanEnv, scanOf?: typeof scanDirectory);
13
+ /** Scans `dir` and remembers what the scan read, so those files are watched too. */
14
+ scan(dir: string, profile?: SessionProfile): Promise<DirectoryView>;
15
+ /**
16
+ * The view of `dir`: the latest scan while it is known to be current, else a new scan. It is
17
+ * current only while watchers that started before it have seen no change since it started, and
18
+ * ~/.claude.json, which is not watched, is as it was. Only an interactive scan is reused, as the
19
+ * path tester describes an interactive session.
20
+ */
21
+ view(dir: string): Promise<DirectoryView>;
22
+ /** ~/.claude.json's size and time; Claude Code rewrites it too often to watch. */
23
+ private stateFile;
24
+ /**
25
+ * Answers once something changed after `since` (ms since the epoch), or after `timeoutMs` with
26
+ * the last change time, whichever comes first.
27
+ */
28
+ wait(dir: string, since: number, signal: AbortSignal, timeoutMs?: number): Promise<{
29
+ changed: boolean;
30
+ at: number;
31
+ }>;
32
+ /**
33
+ * Calls `onChange` once after each change to what ccdesk shows for `dir`, until the returned
34
+ * function is called. Resolves once the watchers have started, so a change made after it
35
+ * resolves is seen. `close` stops the calls without making one.
36
+ */
37
+ subscribe(dir: string, onChange: () => void): Promise<() => void>;
38
+ /** Drops the watchers of `dir` after a while once no one waits or listens for changes. */
39
+ private release;
40
+ close(): void;
41
+ private watching;
42
+ private drop;
43
+ /** (Re)creates the watchers, so a folder created since, such as a new .claude, is watched. */
44
+ private rewatch;
45
+ }