ccdesk 0.2.3 → 0.2.5

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 (46) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +2 -2
  3. package/THIRD_PARTY_NOTICES.md +224 -0
  4. package/dist/core/agent-edit.d.ts +1 -1
  5. package/dist/core/agent-edit.js +2 -2
  6. package/dist/core/backups.d.ts +1 -1
  7. package/dist/core/backups.js +29 -5
  8. package/dist/core/index.d.ts +6 -6
  9. package/dist/core/index.js +14 -14
  10. package/dist/core/mcp-edit.d.ts +1 -3
  11. package/dist/core/mcp-edit.js +15 -11
  12. package/dist/core/mutations.d.ts +1 -1
  13. package/dist/core/mutations.js +2 -2
  14. package/dist/core/open.d.ts +2 -5
  15. package/dist/core/open.js +6 -8
  16. package/dist/core/scan/data.d.ts +6 -0
  17. package/dist/core/scan/data.js +8 -0
  18. package/dist/core/scan/env.d.ts +2 -0
  19. package/dist/core/scan/env.js +8 -1
  20. package/dist/core/scan/files.js +2 -2
  21. package/dist/core/scan/git.d.ts +6 -0
  22. package/dist/core/scan/git.js +8 -0
  23. package/dist/core/scan/index.js +5 -2
  24. package/dist/core/scan/mcp.js +2 -1
  25. package/dist/core/scan/projects.js +2 -1
  26. package/dist/core/scan/settings.d.ts +2 -0
  27. package/dist/core/scan/settings.js +12 -3
  28. package/dist/core/scan/usage.d.ts +0 -6
  29. package/dist/core/scan/usage.js +7 -4
  30. package/dist/core/secrets.d.ts +3 -2
  31. package/dist/core/secrets.js +49 -29
  32. package/dist/core/settings-edit.d.ts +3 -3
  33. package/dist/core/settings-edit.js +4 -4
  34. package/dist/core/write.d.ts +0 -2
  35. package/dist/core/write.js +2 -4
  36. package/dist/shared/memory.d.ts +5 -1
  37. package/dist/shared/memory.js +18 -2
  38. package/dist/shared/settings.schema.json +129 -1
  39. package/dist/shared/types.d.ts +7 -0
  40. package/dist/ui/assets/{index-CH1HoC38.js → index-CGpaJLzd.js} +4 -4
  41. package/dist/ui/assets/index-DM3TASgn.css +1 -0
  42. package/dist/ui/index.html +2 -2
  43. package/dist/view/sections/catalog.d.ts +4 -1
  44. package/dist/view/sections/catalog.js +9 -2
  45. package/package.json +4 -2
  46. package/dist/ui/assets/index-D5XqP5c_.css +0 -1
@@ -3,6 +3,14 @@ import { parse as parseYaml } from "yaml";
3
3
  export function isRecord(value) {
4
4
  return typeof value === "object" && value !== null && !Array.isArray(value);
5
5
  }
6
+ /**
7
+ * JSON.parse after dropping a leading byte order mark, as Claude Code does before it parses a
8
+ * settings, MCP or state file (observed in v2.1.296 and the SDK's safeParseJSON; the docs do not
9
+ * say). Windows PowerShell 5.1 and some editors write one.
10
+ */
11
+ export function parseJson(text) {
12
+ return JSON.parse(text.startsWith("\uFEFF") ? text.slice(1) : text);
13
+ }
6
14
  export const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
7
15
  export function parseFrontmatter(text) {
8
16
  const match = FRONTMATTER.exec(text);
@@ -9,6 +9,8 @@ export interface ScanEnv {
9
9
  claudeJson: string;
10
10
  /** System directory for organisation-managed CLAUDE.md and managed-settings.json. */
11
11
  managedDir: string;
12
+ /** macOS configuration profiles that deliver managed settings (MDM), which outrank managedDir's files. */
13
+ managedProfiles?: string[] | undefined;
12
14
  platform: NodeJS.Platform;
13
15
  /**
14
16
  * The environment ccdesk runs in: where it finds an editor and `claude` (PATH, VISUAL, EDITOR,
@@ -1,4 +1,4 @@
1
- import { homedir } from "node:os";
1
+ import { homedir, userInfo } from "node:os";
2
2
  import { join, resolve } from "node:path";
3
3
  /** `configDirFlag`, from `--config-dir`, stands in for CLAUDE_CONFIG_DIR and wins over it. */
4
4
  export function scanEnvFromProcess(configDirFlag) {
@@ -16,6 +16,7 @@ export function scanEnvFromProcess(configDirFlag) {
16
16
  // Claude Code keeps .claude.json beside the config dir it was given.
17
17
  claudeJson: configDir ? join(configDir, ".claude.json") : join(home, ".claude.json"),
18
18
  managedDir: managedDirFor(process.platform),
19
+ managedProfiles: process.platform === "darwin" ? managedProfiles() : undefined,
19
20
  platform: process.platform,
20
21
  processEnv,
21
22
  disableAutoMemory: processEnv.CLAUDE_CODE_DISABLE_AUTO_MEMORY,
@@ -26,6 +27,12 @@ export function scanEnvFromProcess(configDirFlag) {
26
27
  export function isConfigHome(env, path) {
27
28
  return path === env.home || join(path, ".claude") === env.claudeDir;
28
29
  }
30
+ /** Where macOS puts the com.anthropic.claudecode profile: for this user, then for the device. */
31
+ function managedProfiles() {
32
+ const dir = "/Library/Managed Preferences";
33
+ const profile = "com.anthropic.claudecode.plist";
34
+ return [join(dir, userInfo().username, profile), join(dir, profile)];
35
+ }
29
36
  function managedDirFor(platform) {
30
37
  if (platform === "darwin")
31
38
  return "/Library/Application Support/ClaudeCode";
@@ -4,7 +4,7 @@ import { lstat, open, readdir, readlink, realpath, stat } from "node:fs/promises
4
4
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
5
5
  import {} from "../../shared/types.js";
6
6
  import { jsonSyntaxError, shownJsonFile, shownText } from "../secrets.js";
7
- import { isRecord } from "./data.js";
7
+ import { isRecord, parseJson } from "./data.js";
8
8
  import {} from "./env.js";
9
9
  /** Claude Code skips a CLAUDE.md larger than this (docs: memory#auto-memory), so ccdesk reads no more. */
10
10
  export const MAX_TEXT_BYTES = 4 * 1024 * 1024;
@@ -196,7 +196,7 @@ export async function readJsonObject(env, path, { emptyIsObject = false } = {})
196
196
  if (emptyIsObject && raw.trim() === "")
197
197
  return { ok: true, data: {} };
198
198
  try {
199
- const data = JSON.parse(raw);
199
+ const data = parseJson(raw);
200
200
  if (!isRecord(data))
201
201
  return { ok: false, missing: false, unparsed: true, error: "not a JSON object" };
202
202
  return { ok: true, data };
@@ -1,4 +1,5 @@
1
1
  import { type Visibility } from "../../shared/types.ts";
2
+ import { type ScanEnv } from "./env.ts";
2
3
  export declare function gitRoot(dir: string): Promise<string | null>;
3
4
  /** Every checkout of the repository `dir` is in, the main one first; empty outside git. */
4
5
  export declare function worktrees(dir: string): Promise<string[]>;
@@ -10,5 +11,10 @@ export declare function mainWorktreeRoot(dir: string): Promise<string | null>;
10
11
  * under it and `claude mcp add --scope local` writes under it, so both take it from here.
11
12
  */
12
13
  export declare function trustKey(dir: string, mainRoot: string | null, root: string | null): string;
14
+ /**
15
+ * A folder as ~/.claude.json names it under `projects`: Claude Code writes Windows paths with
16
+ * forward slashes (observed in v2.1.296; the docs do not say), so lookups go through this.
17
+ */
18
+ export declare function claudeJsonKey(env: ScanEnv, path: string): string;
13
19
  /** Git visibility of each path, batched into two git calls per repository. */
14
20
  export declare function visibilityOf(root: string | null, paths: string[]): Promise<Map<string, Visibility>>;
@@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
2
2
  import { dirname, normalize, relative } from "node:path";
3
3
  import { promisify } from "node:util";
4
4
  import {} from "../../shared/types.js";
5
+ import {} from "./env.js";
5
6
  import { isWithin } from "./files.js";
6
7
  const run = promisify(execFile);
7
8
  export async function gitRoot(dir) {
@@ -54,6 +55,13 @@ export async function mainWorktreeRoot(dir) {
54
55
  export function trustKey(dir, mainRoot, root) {
55
56
  return mainRoot ?? root ?? dir;
56
57
  }
58
+ /**
59
+ * A folder as ~/.claude.json names it under `projects`: Claude Code writes Windows paths with
60
+ * forward slashes (observed in v2.1.296; the docs do not say), so lookups go through this.
61
+ */
62
+ export function claudeJsonKey(env, path) {
63
+ return env.platform === "win32" ? path.replaceAll("\\", "/") : path;
64
+ }
57
65
  /** Git visibility of each path, batched into two git calls per repository. */
58
66
  export async function visibilityOf(root, paths) {
59
67
  const result = new Map();
@@ -6,7 +6,7 @@ import { buildContext } from "./context.js";
6
6
  import { isConfigHome } from "./env.js";
7
7
  import { isDir, isFile, readJsonObject, stringsIn } from "./files.js";
8
8
  import { isRecord } from "./data.js";
9
- import { gitRoot, mainWorktreeRoot, trustKey, visibilityOf } from "./git.js";
9
+ import { claudeJsonKey, gitRoot, mainWorktreeRoot, trustKey, visibilityOf } from "./git.js";
10
10
  import { AGENTS_MD_MODES, resolveInstructions } from "./instructions.js";
11
11
  import { readInventory } from "./inventory.js";
12
12
  import { readMemoryStore } from "./memory.js";
@@ -201,7 +201,10 @@ async function localFromRepository(settings) {
201
201
  */
202
202
  function trustOf(env, claudeJson, path, mainRoot, repoRoot) {
203
203
  const projects = isRecord(claudeJson?.projects) ? claudeJson.projects : {};
204
- const entry = (key) => (isRecord(projects[key]) ? projects[key] : {});
204
+ const entry = (dir) => {
205
+ const found = projects[claudeJsonKey(env, dir)];
206
+ return isRecord(found) ? found : {};
207
+ };
205
208
  const root = trustKey(path, mainRoot, repoRoot);
206
209
  const own = entry(root);
207
210
  const trust = {
@@ -5,6 +5,7 @@ import { redactSecrets, shownUrl } from "../secrets.js";
5
5
  import { isConfigHome } from "./env.js";
6
6
  import { readJsonOrReport, stringField, stringsIn, wildcard } from "./files.js";
7
7
  import { isRecord } from "./data.js";
8
+ import { claudeJsonKey } from "./git.js";
8
9
  import { manifestConfigs } from "./plugins.js";
9
10
  import serverSchema from "../../shared/mcp-server.schema.json" with { type: "json" };
10
11
  import { schemaErrors } from "../schema.js";
@@ -209,7 +210,7 @@ export async function readMcpServers(input, plugins) {
209
210
  // Local servers live in the entry under the trust key, even from a subdirectory: claude mcp add
210
211
  // --scope local writes there and claude mcp list reads only that entry, ignoring one for the
211
212
  // subdirectory (observed with v2.1.296; the docs do not say).
212
- const entry = projects[trust.root];
213
+ const entry = projects[claudeJsonKey(env, trust.root)];
213
214
  const project = isRecord(entry) ? entry : {};
214
215
  const managedFile = join(env.managedDir, "managed-mcp.json");
215
216
  const managedJson = await readJsonOrReport(env, managedFile, problems);
@@ -11,7 +11,8 @@ import { readMemoryStore } from "./memory.js";
11
11
  export async function listProjects(env, claudeJson, launchDir) {
12
12
  const byPath = new Map();
13
13
  const storesRoot = join(env.claudeDir, "projects");
14
- const knownPaths = isRecord(claudeJson?.projects) ? Object.keys(claudeJson.projects) : [];
14
+ // Claude Code keys Windows folders with forward slashes; listed here as Windows writes them.
15
+ const knownPaths = (isRecord(claudeJson?.projects) ? Object.keys(claudeJson.projects) : []).map((key) => (env.platform === "win32" ? key.replaceAll("/", "\\") : key));
15
16
  for (const name of await listDir(storesRoot)) {
16
17
  const storeDir = join(storesRoot, name);
17
18
  if (!(await isDir(storeDir)))
@@ -21,6 +21,8 @@ export interface ManagedSettings {
21
21
  data: Record<string, unknown> | null;
22
22
  sources: string[];
23
23
  dropIns: string[];
24
+ /** Managed sources ccdesk does not read that rank above the files (higherSources). */
25
+ higher: string[];
24
26
  errors: string[];
25
27
  /** A managed file that is there but not a JSON object: Claude Code refuses to start (docs: managed-settings#find-entries-claude-code-dropped). */
26
28
  blocksStart: boolean;
@@ -4,7 +4,7 @@ import { allowedScopes, EVERY_FILE, ignoredReason } from "../../shared/scopes.js
4
4
  import {} from "../../shared/types.js";
5
5
  import { redactSecrets } from "../secrets.js";
6
6
  import {} from "./env.js";
7
- import { listDir, readJsonObject } from "./files.js";
7
+ import { isFile, listDir, readJsonObject } from "./files.js";
8
8
  import { isRecord } from "./data.js";
9
9
  import { mainWorktreeRoot } from "./git.js";
10
10
  import { sandboxFilter } from "./sandbox.js";
@@ -189,10 +189,17 @@ export async function readManagedSettings(env) {
189
189
  sources.push(source);
190
190
  data = data ? mergeSettings(data, result.data) : result.data;
191
191
  }
192
- return { data, sources, dropIns, errors, blocksStart };
192
+ // Server-managed settings, then MDM, outrank the files; by default Claude Code uses the first
193
+ // source that sets a policy key and skips the rest (docs: managed-settings#how-claude-code-combines-managed-sources).
194
+ const candidates = [join(env.claudeDir, "remote-settings.json"), ...(env.managedProfiles ?? [])];
195
+ const higher = [];
196
+ for (const source of candidates)
197
+ if (await isFile(source))
198
+ higher.push(source);
199
+ return { data, sources, dropIns, higher, errors, blocksStart };
193
200
  }
194
201
  function managedLayer(path, managed) {
195
- const { data, sources, dropIns, errors, blocksStart } = managed;
202
+ const { data, sources, dropIns, higher, errors, blocksStart } = managed;
196
203
  const layer = { scope: "managed", path, exists: false, data: null };
197
204
  if (data)
198
205
  layer.data = redactSecrets(data);
@@ -203,6 +210,8 @@ function managedLayer(path, managed) {
203
210
  layer.error = errors.join("; ");
204
211
  if (blocksStart)
205
212
  layer.blocksStart = true;
213
+ if (higher.length > 0)
214
+ layer.higherSources = higher;
206
215
  return layer;
207
216
  }
208
217
  /** The value Claude Code ends up with for each top-level key, and where it came from. */
@@ -17,9 +17,3 @@ export declare function usageCounter(): (env: ScanEnv, dir: string, rules: RuleI
17
17
  timeLimitMs?: number;
18
18
  maxCachedCalls?: number;
19
19
  }) => Promise<Usage>;
20
- /** Counts with a cache and queue shared by every caller in the process; use a core's instead. */
21
- export declare const readUsage: (env: ScanEnv, dir: string, rules: RuleInEffect[], { signal, timeLimitMs, maxCachedCalls, }?: {
22
- signal?: AbortSignal;
23
- timeLimitMs?: number;
24
- maxCachedCalls?: number;
25
- }) => Promise<Usage>;
@@ -75,8 +75,6 @@ export function usageCounter() {
75
75
  return current.promise;
76
76
  };
77
77
  }
78
- /** Counts with a cache and queue shared by every caller in the process; use a core's instead. */
79
- export const readUsage = usageCounter();
80
78
  function stoppedBecause(signal) {
81
79
  return new CountStopped(signal.reason instanceof DOMException && signal.reason.name === "TimeoutError"
82
80
  ? "Counting tool calls took too long and stopped; what was read is kept, so the next count continues."
@@ -334,8 +332,13 @@ function approvals(calls, rules) {
334
332
  for (const c of calls) {
335
333
  if (c.byYou !== true)
336
334
  continue;
337
- if (c.rule)
338
- groups.set(c.rule, [...(groups.get(c.rule) ?? []), c]);
335
+ if (!c.rule)
336
+ continue;
337
+ const group = groups.get(c.rule);
338
+ if (group)
339
+ group.push(c);
340
+ else
341
+ groups.set(c.rule, [c]);
339
342
  }
340
343
  const refused = calls.filter((c) => c.byYou === false);
341
344
  const out = [];
@@ -38,7 +38,8 @@ export declare function hasHiddenValues(value: unknown): boolean;
38
38
  export declare function hiddenValues(text: string | null): string | null;
39
39
  /**
40
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.
41
+ * or a hidden argument whole, the hidden end of one whose start is shown (`--token=value`,
42
+ * `Authorization: Bearer value`), and a URL's user name, password and query. Longest first, so a
43
+ * secret that contains another is taken out whole.
43
44
  */
44
45
  export declare function secretTexts(value: unknown): string[];
@@ -1,5 +1,5 @@
1
1
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
2
- import { FRONTMATTER, isRecord } from "./scan/data.js";
2
+ import { FRONTMATTER, isRecord, parseJson } from "./scan/data.js";
3
3
  /**
4
4
  * A server URL as ccdesk shows it: a user name, password or query string can carry a key, so they
5
5
  * are hidden, as Claude Code hides the URL in its own status output (docs: mcp).
@@ -21,34 +21,52 @@ export function shownUrl(url) {
21
21
  return url.replace(/\/\/[^/@]*@/, "//•••@").replace(/\?.*$/, "?•••");
22
22
  }
23
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;
24
+ /** A flag whose value is a secret, such as `--token`, `--api-key`, `--client-secret` or `--auth-token`. */
25
+ const SECRET_FLAG = /^--([\w-]*[-_])?(token|api[-_]?key|apikey|password|secret|auth\w*|key)$/i;
26
+ /** A flag whose value is an HTTP header, as mcp-remote takes `--header "Authorization: Bearer …"`. */
27
+ const HEADER_FLAG = /^(--header|-H)$/;
28
+ /** An argument that is itself a credential header or an Authorization value. */
29
+ const SECRET_HEADER = /^([\w-]*(authorization|api-key|token|cookie)\s*:|(bearer|basic)\s+\S)/i;
26
30
  /** A value that starts like a common API key or token. */
27
31
  const KEY_PREFIX = /^(sk-|ghp_|gho_|ghu_|ghs_|ghr_|github_pat_|xox[bp]-|glpat-|AKIA[0-9A-Z]{16})/;
32
+ /** A header as ccdesk shows it: the name and the `Bearer` or `Basic` scheme stay, the rest is hidden. */
33
+ function shownHeader(header) {
34
+ const kept = /^([^:]*:\s*)?((bearer|basic)\s+)?/i.exec(header)?.[0] ?? "";
35
+ return `${kept}•••`;
36
+ }
28
37
  /**
29
38
  * 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.
39
+ * `--token value` or `--token=value`, a header's value after `--header` or `-H`, a credential
40
+ * header or Authorization value on its own, a value that starts like a known key, and the
41
+ * credentials in a URL are hidden.
32
42
  */
33
43
  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))
44
+ return args.map((arg, i) => (typeof arg === "string" ? shownArg(arg, args[i - 1]) : arg));
45
+ }
46
+ /** One argument as ccdesk shows it, given the one before it. */
47
+ function shownArg(arg, previous) {
48
+ if (typeof previous === "string" && !arg.startsWith("-")) {
49
+ if (SECRET_FLAG.test(previous))
47
50
  return "•••";
48
- if (/^https?:\/\//i.test(arg))
49
- return shownUrl(arg);
50
- return arg;
51
- });
51
+ if (HEADER_FLAG.test(previous))
52
+ return shownHeader(arg);
53
+ }
54
+ const equals = arg.indexOf("=");
55
+ if (equals > 0) {
56
+ const name = arg.slice(0, equals);
57
+ const value = arg.slice(equals + 1);
58
+ if (HEADER_FLAG.test(name))
59
+ return `${name}=${shownHeader(value)}`;
60
+ if (SECRET_FLAG.test(name) || KEY_PREFIX.test(value))
61
+ return `${name}=•••`;
62
+ }
63
+ if (SECRET_HEADER.test(arg))
64
+ return shownHeader(arg);
65
+ if (KEY_PREFIX.test(arg))
66
+ return "•••";
67
+ if (/^https?:\/\//i.test(arg))
68
+ return shownUrl(arg);
69
+ return arg;
52
70
  }
53
71
  export function redactSecrets(value) {
54
72
  if (Array.isArray(value))
@@ -86,7 +104,7 @@ export function shownText(text) {
86
104
  return null;
87
105
  let data;
88
106
  try {
89
- data = JSON.parse(text);
107
+ data = parseJson(text);
90
108
  }
91
109
  catch {
92
110
  return shownFrontmatter(text);
@@ -114,7 +132,7 @@ function shownFrontmatter(text) {
114
132
  /** A file named .json that does not parse is not shown: its text could still hold a secret. */
115
133
  export function shownJsonFile(text) {
116
134
  try {
117
- JSON.parse(text);
135
+ parseJson(text);
118
136
  }
119
137
  catch {
120
138
  return "(a JSON file ccdesk could not parse, so its text is not shown; open it to see it)\n";
@@ -156,7 +174,7 @@ export function hasHiddenValues(value) {
156
174
  */
157
175
  export function hiddenValues(text) {
158
176
  try {
159
- const data = text === null ? null : JSON.parse(text);
177
+ const data = text === null ? null : parseJson(text);
160
178
  return JSON.stringify(hiddenIn(data).map(({ path, raw }) => [path, raw]));
161
179
  }
162
180
  catch {
@@ -165,8 +183,9 @@ export function hiddenValues(text) {
165
183
  }
166
184
  /**
167
185
  * 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.
186
+ * or a hidden argument whole, the hidden end of one whose start is shown (`--token=value`,
187
+ * `Authorization: Bearer value`), and a URL's user name, password and query. Longest first, so a
188
+ * secret that contains another is taken out whole.
170
189
  */
171
190
  export function secretTexts(value) {
172
191
  const found = hiddenIn(value).flatMap(({ raw, shown: hidden }) => {
@@ -174,8 +193,9 @@ export function secretTexts(value) {
174
193
  return [];
175
194
  if (hidden === "•••")
176
195
  return [raw];
177
- if (typeof hidden === "string" && hidden.endsWith("=•••"))
178
- return [raw.slice(raw.indexOf("=") + 1)];
196
+ // The start was kept, as in `--token=•••` or `Authorization: Bearer •••`: the rest is the secret.
197
+ if (typeof hidden === "string" && hidden.endsWith("•••") && raw.startsWith(hidden.slice(0, -3)))
198
+ return [raw.slice(hidden.length - 3)];
179
199
  if (shownUrl(raw) === raw)
180
200
  return [];
181
201
  const url = URL.parse(raw);
@@ -2,8 +2,8 @@ import { type Change, type HookEdit, type PermissionEdit, type SettingsEdit, typ
2
2
  import { type ScanEnv } from "./scan/env.ts";
3
3
  import { type Serially } from "./write.ts";
4
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>;
5
+ export declare function saveSettingsEdit(env: ScanEnv, edit: SettingsEdit, expectedRevision: string, queue: Serially): Promise<Change>;
6
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>;
7
+ export declare function savePermissionEdit(env: ScanEnv, edit: PermissionEdit, expectedRevision: string, queue: Serially): Promise<Change>;
8
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>;
9
+ export declare function saveHookEdit(env: ScanEnv, edit: HookEdit, expectedRevision: string, queue: Serially): Promise<Change>;
@@ -11,7 +11,7 @@ import { isSkippedMcpRule } from "./scan/permissions.js";
11
11
  import { settingsFiles } from "./scan/settings.js";
12
12
  import { schemaErrors } from "./schema.js";
13
13
  import { hasHiddenValues, redactSecrets, shown } from "./secrets.js";
14
- import { RefusedError, readJsonObject, recordChange, saveReviewed, serially, writeAtomic, } from "./write.js";
14
+ import { RefusedError, readJsonObject, recordChange, saveReviewed, writeAtomic, } from "./write.js";
15
15
  const properties = schema.definitions.ClaudeCodeSettings.properties;
16
16
  const validators = new Map();
17
17
  function validatorFor(key) {
@@ -199,18 +199,18 @@ const CHANGED = "The file changed since you reviewed this edit; review it again.
199
199
  export async function previewSettingsEdit(env, edit) {
200
200
  return (await planSettingsEdit(env, edit)).preview;
201
201
  }
202
- export function saveSettingsEdit(env, edit, expectedRevision, queue = serially) {
202
+ export function saveSettingsEdit(env, edit, expectedRevision, queue) {
203
203
  return saveReviewed(queue, () => planSettingsEdit(env, edit), expectedRevision, CHANGED);
204
204
  }
205
205
  export async function previewPermissionEdit(env, edit) {
206
206
  return (await planPermissionEdit(env, edit)).preview;
207
207
  }
208
- export function savePermissionEdit(env, edit, expectedRevision, queue = serially) {
208
+ export function savePermissionEdit(env, edit, expectedRevision, queue) {
209
209
  return saveReviewed(queue, () => planPermissionEdit(env, edit), expectedRevision, CHANGED);
210
210
  }
211
211
  export async function previewHookEdit(env, edit) {
212
212
  return (await planHookEdit(env, edit)).preview;
213
213
  }
214
- export function saveHookEdit(env, edit, expectedRevision, queue = serially) {
214
+ export function saveHookEdit(env, edit, expectedRevision, queue) {
215
215
  return saveReviewed(queue, () => planHookEdit(env, edit), expectedRevision, CHANGED);
216
216
  }
@@ -14,8 +14,6 @@ export declare class StaleError extends RefusedError {
14
14
  export type Serially = <T>(change: () => Promise<T>) => Promise<T>;
15
15
  /** A new, empty queue of changes. Each core has its own. */
16
16
  export declare function changeQueue(): Serially;
17
- /** The queue the save functions use when they are called without a core's. */
18
- export declare const serially: Serially;
19
17
  /** What every change's review has: the revision it was worked out from and why it cannot be saved. */
20
18
  interface Review {
21
19
  /** Fingerprint of everything the change was worked out from. */
@@ -3,7 +3,7 @@ import { chmod, link, lstat, mkdir, open, readFile, realpath, rename, rm, writeF
3
3
  import { basename, dirname, join } from "node:path";
4
4
  import { Type } from "typebox";
5
5
  import { BACKUP_KINDS } from "../shared/types.js";
6
- import { isRecord } from "./scan/data.js";
6
+ import { isRecord, parseJson } from "./scan/data.js";
7
7
  import {} from "./scan/env.js";
8
8
  import { isCredentials, isSecretPath, revisionOf } from "./scan/files.js";
9
9
  import { jsonSyntaxError } from "./secrets.js";
@@ -24,8 +24,6 @@ export function changeQueue() {
24
24
  return run;
25
25
  };
26
26
  }
27
- /** The queue the save functions use when they are called without a core's. */
28
- export const serially = changeQueue();
29
27
  /**
30
28
  * Plans a change again and makes it, but only when it has no errors and everything it was worked
31
29
  * out from is as reviewed; `changed` is the refusal otherwise. Planning and saving share one turn
@@ -91,7 +89,7 @@ export async function readJsonObject(env, path) {
91
89
  return { raw, data: {}, reformats: false };
92
90
  let parsed;
93
91
  try {
94
- parsed = JSON.parse(raw);
92
+ parsed = parseJson(raw);
95
93
  }
96
94
  catch (error) {
97
95
  throw new RefusedError(`${path} is ${jsonSyntaxError(error)}; fix it by hand first.`);
@@ -1,2 +1,6 @@
1
- /** Claude Code names a project's store after its path with every non-alphanumeric character as "-". */
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
+ */
2
6
  export declare function storeDirName(projectPath: string): string;
@@ -1,4 +1,20 @@
1
- /** Claude Code names a project's store after its path with every non-alphanumeric character as "-". */
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
- return projectPath.replace(/[^a-zA-Z0-9]/g, "-");
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
  }