ccdesk 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +1 -1
  3. package/dist/core/agent-edit.d.ts +1 -1
  4. package/dist/core/agent-edit.js +2 -2
  5. package/dist/core/backups.d.ts +1 -1
  6. package/dist/core/backups.js +29 -5
  7. package/dist/core/index.d.ts +6 -6
  8. package/dist/core/index.js +14 -14
  9. package/dist/core/mcp-edit.d.ts +1 -3
  10. package/dist/core/mcp-edit.js +15 -11
  11. package/dist/core/mutations.d.ts +1 -1
  12. package/dist/core/mutations.js +2 -2
  13. package/dist/core/open.d.ts +2 -5
  14. package/dist/core/open.js +6 -8
  15. package/dist/core/scan/data.d.ts +6 -0
  16. package/dist/core/scan/data.js +8 -0
  17. package/dist/core/scan/env.d.ts +2 -0
  18. package/dist/core/scan/env.js +8 -1
  19. package/dist/core/scan/files.js +2 -2
  20. package/dist/core/scan/git.d.ts +6 -0
  21. package/dist/core/scan/git.js +8 -0
  22. package/dist/core/scan/index.js +5 -2
  23. package/dist/core/scan/mcp.js +2 -1
  24. package/dist/core/scan/projects.js +2 -1
  25. package/dist/core/scan/settings.d.ts +2 -0
  26. package/dist/core/scan/settings.js +12 -3
  27. package/dist/core/scan/usage.d.ts +0 -6
  28. package/dist/core/scan/usage.js +7 -4
  29. package/dist/core/secrets.d.ts +3 -2
  30. package/dist/core/secrets.js +49 -29
  31. package/dist/core/settings-edit.d.ts +3 -3
  32. package/dist/core/settings-edit.js +4 -4
  33. package/dist/core/write.d.ts +0 -2
  34. package/dist/core/write.js +2 -4
  35. package/dist/shared/memory.d.ts +5 -1
  36. package/dist/shared/memory.js +18 -2
  37. package/dist/shared/types.d.ts +5 -0
  38. package/dist/ui/assets/{index-CH1HoC38.js → index-C9qqEI5Z.js} +2 -2
  39. package/dist/ui/index.html +1 -1
  40. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.4](https://github.com/ayagmar/ccdesk/compare/v0.2.3...v0.2.4) (2026-10-13)
4
+
5
+ ### Features
6
+
7
+ * **core:** warn when a higher managed source than the files is present ([cb04a30](https://github.com/ayagmar/ccdesk/commit/cb04a30e72293270bdf4e3529a938ef13188bb41))
8
+
9
+ ### Bug Fixes
10
+
11
+ * **core:** count Explorer as having opened the file on Windows ([eb1a120](https://github.com/ayagmar/ccdesk/commit/eb1a12090507be426765c38d2e71a5f5500622ef))
12
+ * **core:** hide header values and more secret flags in MCP server args ([3a9ee02](https://github.com/ayagmar/ccdesk/commit/3a9ee024a124602f486b3e8f41e23fffa7c84565))
13
+ * **core:** look up ~/.claude.json folders as Claude Code keys them on Windows ([67a445d](https://github.com/ayagmar/ccdesk/commit/67a445d2a569bccd8f1615e798af099ece5dbe38))
14
+ * **core:** name long project stores as Claude Code does ([98f4dd8](https://github.com/ayagmar/ccdesk/commit/98f4dd8f4efbe1ce0c2154e58b554c9a05baed95))
15
+ * **core:** read JSON files that start with a byte order mark ([6bb2fd3](https://github.com/ayagmar/ccdesk/commit/6bb2fd302d468f1603d2230270ce5c9999027e93))
16
+ * **core:** report a backup folder ccdesk cannot read instead of listing none ([1209b9c](https://github.com/ayagmar/ccdesk/commit/1209b9c0a360939a3247fd3e92ad9b0d41b579f6))
17
+ * **core:** say why claude did not start on Windows instead of "not on PATH" ([9c0d3ad](https://github.com/ayagmar/ccdesk/commit/9c0d3adcc0ad0cb1ff4ed6b66377b603c1b1f4da))
18
+
19
+ ### Performance Improvements
20
+
21
+ * **core:** group hand-approved calls without copying each group ([9ab7219](https://github.com/ayagmar/ccdesk/commit/9ab7219986690e08669f3aceacb93f3f48777e11))
22
+ * **ui:** refresh the overview on focus and saves, not on watched changes ([9010576](https://github.com/ayagmar/ccdesk/commit/901057643dfcded5f2463ccb09353a5d264f109d))
23
+
3
24
  ## [0.2.3](https://github.com/ayagmar/ccdesk/compare/v0.2.2...v0.2.3) (2026-10-13)
4
25
 
5
26
  ### Features
package/README.md CHANGED
@@ -113,7 +113,7 @@ ccdesk reads `~/.claude` and `~/.claude.json`, or `$CLAUDE_CONFIG_DIR` when it i
113
113
  ## Privacy and safety
114
114
 
115
115
  - The server listens on `127.0.0.1` only, and every API call needs a token generated for that run. Nothing leaves your machine.
116
- - Values under `env` and `headers`, and keys in an MCP server's `args` (after flags such as `--token` or `--api-key`, or starting like `sk-` or `ghp_`), are hidden everywhere in the UI, diffs included, and ccdesk refuses any edit that would change them.
116
+ - Values under `env` and `headers`, and keys in an MCP server's `args` (after flags such as `--token` or `--api-key`, in a `--header` such as `Authorization: Bearer …`, or starting like `sk-` or `ghp_`), are hidden everywhere in the UI, diffs included, and ccdesk refuses any edit that would change them.
117
117
  - ccdesk never reads `~/.claude/.credentials.json`.
118
118
  - From your transcripts, only counts and rule patterns (a command's program and subcommand, a fetch's domain) reach the page, never commands or file contents.
119
119
  - Backups are readable only by you on Linux and macOS.
@@ -3,4 +3,4 @@ import { type ScanEnv } from "./scan/env.ts";
3
3
  import { type Serially } from "./write.ts";
4
4
  export declare function previewAgentEdit(env: ScanEnv, edit: AgentEdit): Promise<SettingsEditPreview>;
5
5
  /** Writes or deletes an agent file, backed up first, only while it is as reviewed. */
6
- export declare function saveAgentEdit(env: ScanEnv, edit: AgentEdit, expectedRevision: string, queue?: Serially): Promise<Change>;
6
+ export declare function saveAgentEdit(env: ScanEnv, edit: AgentEdit, expectedRevision: string, queue: Serially): Promise<Change>;
@@ -6,7 +6,7 @@ import { gitRoot } from "./scan/git.js";
6
6
  import { shownText } from "./secrets.js";
7
7
  import { scanDirectory } from "./scan/index.js";
8
8
  import { agentProblems } from "./scan/inventory.js";
9
- import { RefusedError, readIfExists, recordChange, removeExactly, StaleError, saveReviewed, serially, writeAtomic, } from "./write.js";
9
+ import { RefusedError, readIfExists, recordChange, removeExactly, StaleError, saveReviewed, writeAtomic, } from "./write.js";
10
10
  /** The file an edit changes: an agent the scan shows here, or a new one in your or the project's folder. */
11
11
  async function targetOf(env, edit) {
12
12
  const dir = resolve(edit.dir);
@@ -71,6 +71,6 @@ export async function previewAgentEdit(env, edit) {
71
71
  return (await plan(env, edit)).preview;
72
72
  }
73
73
  /** Writes or deletes an agent file, backed up first, only while it is as reviewed. */
74
- export function saveAgentEdit(env, edit, expectedRevision, queue = serially) {
74
+ export function saveAgentEdit(env, edit, expectedRevision, queue) {
75
75
  return saveReviewed(queue, () => plan(env, edit), expectedRevision, "The agent changed since you reviewed this; review it again.");
76
76
  }
@@ -14,6 +14,6 @@ export declare function previewRestore(env: ScanEnv, id: string): Promise<Restor
14
14
  * Puts a backed-up file back, or removes a file ccdesk created, if the file is still as it was
15
15
  * when the restore was reviewed. A removed file is backed up first, so it can be put back too.
16
16
  */
17
- export declare function restoreBackup(env: ScanEnv, id: string, expectedRevision: string, queue?: Serially): Promise<{
17
+ export declare function restoreBackup(env: ScanEnv, id: string, expectedRevision: string, queue: Serially): Promise<{
18
18
  target: string;
19
19
  }>;
@@ -3,17 +3,27 @@ import { basename, dirname, join } from "node:path";
3
3
  import { Compile } from "typebox/compile";
4
4
  import {} from "../shared/types.js";
5
5
  import { assertMemoryDir, indexPathIn, revisionOfBoth } from "./mutations.js";
6
+ import { parseJson } from "./scan/data.js";
6
7
  import {} from "./scan/env.js";
7
8
  import { isWithin, revisionOf } from "./scan/files.js";
8
9
  import { hiddenValues, shown, shownText } from "./secrets.js";
9
- import { BackupManifestSchema, backupRoot, forgetChange, MANIFEST, RefusedError, readIfExists, recordChange, removeExactly, SHOWN_AFTER, SNAPSHOT, StaleError, serially, writeAtomic, } from "./write.js";
10
+ import { BackupManifestSchema, backupRoot, forgetChange, MANIFEST, RefusedError, readIfExists, recordChange, removeExactly, SHOWN_AFTER, SNAPSHOT, StaleError, writeAtomic, } from "./write.js";
10
11
  const backupManifest = Compile(BackupManifestSchema);
11
12
  /** A backup folder's manifest; anything that does not look like one ccdesk wrote is skipped. */
12
13
  async function readManifest(env, id) {
13
14
  // ccdesk's ids are letters, digits and dashes, so no id can name a folder outside the root.
14
15
  if (!/^[\w-]+$/.test(id))
15
16
  return null;
16
- const text = await readIfExists(env, join(backupRoot(env), id, MANIFEST)).catch(() => null);
17
+ let text;
18
+ try {
19
+ text = await readIfExists(env, join(backupRoot(env), id, MANIFEST));
20
+ }
21
+ catch (error) {
22
+ // A stray file in the backup folder is not a backup; a folder ccdesk cannot read is an error.
23
+ if (error.code === "ENOTDIR")
24
+ return null;
25
+ throw error;
26
+ }
17
27
  if (text === null)
18
28
  return null;
19
29
  let data;
@@ -25,9 +35,23 @@ async function readManifest(env, id) {
25
35
  }
26
36
  return backupManifest.Check(data) ? data : null;
27
37
  }
38
+ /**
39
+ * Backup folder names, newest first. No folder yet means no changes; one ccdesk cannot read, such
40
+ * as one a `sudo ccdesk` left owned by root, is an error rather than an empty list.
41
+ */
42
+ async function backupIds(env) {
43
+ try {
44
+ return (await readdir(backupRoot(env))).sort().reverse();
45
+ }
46
+ catch (error) {
47
+ if (error.code === "ENOENT")
48
+ return [];
49
+ throw error;
50
+ }
51
+ }
28
52
  /** The changes ccdesk made, newest first, a page at a time. */
29
53
  export async function listBackups(env, { before, limit }) {
30
- const ids = (await readdir(backupRoot(env)).catch(() => [])).sort().reverse();
54
+ const ids = await backupIds(env);
31
55
  const changes = [];
32
56
  for (const id of ids) {
33
57
  if (before !== undefined && id >= before)
@@ -69,7 +93,7 @@ function shownSettings(text) {
69
93
  if (text === null)
70
94
  return null;
71
95
  try {
72
- return shown(JSON.parse(text));
96
+ return shown(parseJson(text));
73
97
  }
74
98
  catch {
75
99
  return "(not valid JSON; open the file to see it)\n";
@@ -239,7 +263,7 @@ export async function previewRestore(env, id) {
239
263
  * Puts a backed-up file back, or removes a file ccdesk created, if the file is still as it was
240
264
  * when the restore was reviewed. A removed file is backed up first, so it can be put back too.
241
265
  */
242
- export function restoreBackup(env, id, expectedRevision, queue = serially) {
266
+ export function restoreBackup(env, id, expectedRevision, queue) {
243
267
  return queue(async () => {
244
268
  const plan = await planRestore(env, id);
245
269
  const { manifest, current, saved, index } = plan;
@@ -4,16 +4,16 @@ export { compareSettings } from "./scan/compare.ts";
4
4
  export { revisionOf } from "./scan/files.ts";
5
5
  export { scanDirectory, scanOverview } from "./scan/index.ts";
6
6
  export { testPath } from "./scan/path-test.ts";
7
- export { CountStopped, readUsage } from "./scan/usage.ts";
7
+ export { CountStopped } from "./scan/usage.ts";
8
8
  export { hasHiddenValues, redactSecrets, shown, shownText } from "./secrets.ts";
9
9
  export { Watcher } from "./watch.ts";
10
- export { previewAgentEdit, saveAgentEdit } from "./agent-edit.ts";
11
- export { claudeProgram, claudeRunner, previewMcpEdit, type RunClaude, runClaude, saveMcpEdit, } from "./mcp-edit.ts";
12
- export { changeMemory, isMemoryAction, previewMemoryChange } from "./mutations.ts";
10
+ export { previewAgentEdit } from "./agent-edit.ts";
11
+ export { claudeProgram, claudeRunner, previewMcpEdit, type RunClaude } from "./mcp-edit.ts";
12
+ export { isMemoryAction, previewMemoryChange } from "./mutations.ts";
13
13
  export { InvalidEditError, parseAgentEdit, parseHookEdit, parseMcpEdit, parsePermissionEdit, parseSettingsEdit, } from "./parse-edit.ts";
14
- export { previewHookEdit, previewPermissionEdit, previewSettingsEdit, saveHookEdit, savePermissionEdit, saveSettingsEdit, } from "./settings-edit.ts";
14
+ export { previewHookEdit, previewPermissionEdit, previewSettingsEdit } from "./settings-edit.ts";
15
15
  export { RefusedError, StaleError } from "./write.ts";
16
- export { listBackups, previewRestore, restoreBackup } from "./backups.ts";
16
+ export { listBackups, previewRestore } from "./backups.ts";
17
17
  export { openInEditor } from "./open.ts";
18
18
  export { REFERENCE } from "./reference.ts";
19
19
  export * from "../shared/compare.ts";
@@ -1,32 +1,32 @@
1
1
  // The core: everything a front end needs to read and change Claude Code's configuration, without
2
2
  // HTTP. It re-exports the modules in src/core and src/shared; see ARCHITECTURE.md for how they
3
3
  // fit together and docs/split-plan.md for where they are going.
4
- // The core for one machine: createCore(env) returns the reads and changes below with the env
5
- // already passed, its own change queue and usage cache, and the editor and claude its env names.
4
+ // The core for one machine: createCore(env) returns every read and change with the env already
5
+ // passed, its own change queue and usage cache, and the editor and claude its env names. Changes,
6
+ // usage counts and undo are made only through a core, so they run on its queue and cache.
6
7
  export { createCore } from "./core.js";
7
- // Environment: every function below takes the ScanEnv first. Called directly, they share one
8
- // change queue and one usage cache across the process; they stay for one release beside createCore.
8
+ // Environment: every function below takes the ScanEnv first.
9
9
  export { isConfigHome, scanEnvFromProcess } from "./scan/env.js";
10
10
  // Read.
11
11
  export { compareSettings } from "./scan/compare.js";
12
12
  export { revisionOf } from "./scan/files.js";
13
13
  export { scanDirectory, scanOverview } from "./scan/index.js";
14
14
  export { testPath } from "./scan/path-test.js";
15
- export { CountStopped, readUsage } from "./scan/usage.js";
15
+ export { CountStopped } from "./scan/usage.js";
16
16
  export { hasHiddenValues, redactSecrets, shown, shownText } from "./secrets.js";
17
17
  export { Watcher } from "./watch.js";
18
18
  // Edits: the parse functions turn input from outside (JSON, arguments) into an edit, or throw
19
- // InvalidEditError. Preview returns a revision, save takes it back. Both refuse with RefusedError,
20
- // also when the file changed since the preview; StaleError, a RefusedError, means it changed while
21
- // ccdesk was writing it.
22
- export { previewAgentEdit, saveAgentEdit } from "./agent-edit.js";
23
- export { claudeProgram, claudeRunner, previewMcpEdit, runClaude, saveMcpEdit, } from "./mcp-edit.js";
24
- export { changeMemory, isMemoryAction, previewMemoryChange } from "./mutations.js";
19
+ // InvalidEditError. Preview returns a revision, and a core's save takes it back. Both refuse with
20
+ // RefusedError, also when the file changed since the preview; StaleError, a RefusedError, means it
21
+ // changed while ccdesk was writing it.
22
+ export { previewAgentEdit } from "./agent-edit.js";
23
+ export { claudeProgram, claudeRunner, previewMcpEdit } from "./mcp-edit.js";
24
+ export { isMemoryAction, previewMemoryChange } from "./mutations.js";
25
25
  export { InvalidEditError, parseAgentEdit, parseHookEdit, parseMcpEdit, parsePermissionEdit, parseSettingsEdit, } from "./parse-edit.js";
26
- export { previewHookEdit, previewPermissionEdit, previewSettingsEdit, saveHookEdit, savePermissionEdit, saveSettingsEdit, } from "./settings-edit.js";
26
+ export { previewHookEdit, previewPermissionEdit, previewSettingsEdit } from "./settings-edit.js";
27
27
  export { RefusedError, StaleError } from "./write.js";
28
- // History: every change ccdesk saves is a backup that can be restored by its id.
29
- export { listBackups, previewRestore, restoreBackup } from "./backups.js";
28
+ // History: every change ccdesk saves is a backup that a core can restore by its id.
29
+ export { listBackups, previewRestore } from "./backups.js";
30
30
  // Host.
31
31
  export { openInEditor } from "./open.js";
32
32
  // Claude Code's settings schema, settings catalog and hook events.
@@ -16,10 +16,8 @@ export type RunClaude = (args: string[], cwd: string, configDir: string | undefi
16
16
  export declare function claudeProgram(path: string, platform: NodeJS.Platform): Promise<string>;
17
17
  /** Runs the claude on `host`'s PATH, with `host`'s environment. */
18
18
  export declare function claudeRunner(host: Pick<ScanEnv, "platform" | "processEnv">): RunClaude;
19
- /** claudeRunner for this process's PATH and environment, read on each run; use a core's instead. */
20
- export declare const runClaude: RunClaude;
21
19
  export declare function previewMcpEdit(env: ScanEnv, edit: McpEdit, run: RunClaude): Promise<SettingsEditPreview>;
22
20
  /** Makes a reviewed MCP edit, only while the servers are still as reviewed. */
23
- export declare function saveMcpEdit(env: ScanEnv, edit: McpEdit, expectedRevision: string, run: RunClaude, queue?: Serially): Promise<Change | {
21
+ export declare function saveMcpEdit(env: ScanEnv, edit: McpEdit, expectedRevision: string, run: RunClaude, queue: Serially): Promise<Change | {
24
22
  target: string;
25
23
  }>;
@@ -4,11 +4,11 @@ import { delimiter, join, resolve } from "node:path";
4
4
  import {} from "../shared/types.js";
5
5
  import {} from "./scan/env.js";
6
6
  import { revisionOf } from "./scan/files.js";
7
- import { isRecord } from "./scan/data.js";
8
- import { gitRoot, mainWorktreeRoot, trustKey } from "./scan/git.js";
7
+ import { isRecord, parseJson } from "./scan/data.js";
8
+ import { claudeJsonKey, gitRoot, mainWorktreeRoot, trustKey } from "./scan/git.js";
9
9
  import { serverProblems } from "./scan/mcp.js";
10
10
  import { hasHiddenValues, jsonSyntaxError, redactSecrets, secretTexts, shown } from "./secrets.js";
11
- import { RefusedError, readIfExists, readJsonObject, recordChange, recordClaudeMcpChange, saveReviewed, serially, writeAtomic, } from "./write.js";
11
+ import { RefusedError, readIfExists, readJsonObject, recordChange, recordClaudeMcpChange, saveReviewed, writeAtomic, } from "./write.js";
12
12
  const exists = (path) => access(path).then(() => true, () => false);
13
13
  /**
14
14
  * The program to run for `claude`. On Windows, npm installs Claude Code as a claude.cmd that starts
@@ -46,6 +46,14 @@ function secretsIn(args) {
46
46
  }
47
47
  });
48
48
  }
49
+ /**
50
+ * Why claude did not start. On Windows a claude.cmd that npm did not install, such as Scoop's,
51
+ * Volta's or pnpm's, is on PATH but cannot be run without a shell, so "not on PATH" would mislead.
52
+ */
53
+ const CLAUDE_NOT_FOUND = {
54
+ win32: "ccdesk changes your user and local MCP servers through the claude command and could not start it from PATH. On Windows it runs claude.exe, or the claude.exe inside npm's package when only npm's claude.cmd is there; another installer's claude.cmd or claude.ps1 cannot be started without a shell.",
55
+ other: "ccdesk changes your user and local MCP servers through the claude command, which is not on PATH.",
56
+ };
49
57
  /** Runs the claude on `host`'s PATH, with `host`'s environment. */
50
58
  export function claudeRunner(host) {
51
59
  return async (args, cwd, configDir) => {
@@ -57,7 +65,7 @@ export function claudeRunner(host) {
57
65
  return new Promise((done, fail) => {
58
66
  execFile(program, args, { cwd, env, timeout: 30_000 }, (error, stdout, stderr) => {
59
67
  if (error?.code === "ENOENT")
60
- return fail(new RefusedError("ccdesk changes your user and local MCP servers through the claude command, which is not on PATH."));
68
+ return fail(new RefusedError(CLAUDE_NOT_FOUND[host.platform === "win32" ? "win32" : "other"]));
61
69
  // Node's own message repeats the command, with the server's env and header values in it.
62
70
  if (error) {
63
71
  // claude's own message may quote the definition it was given.
@@ -71,10 +79,6 @@ export function claudeRunner(host) {
71
79
  });
72
80
  };
73
81
  }
74
- /** claudeRunner for this process's PATH and environment, read on each run; use a core's instead. */
75
- export const runClaude = (args, cwd, configDir) =>
76
- // biome-ignore lint/style/noProcessEnv: the documented default of the free runClaude; a core passes its own.
77
- claudeRunner({ platform: process.platform, processEnv: process.env })(args, cwd, configDir);
78
82
  /** Quotes a word for a POSIX shell, so a command shown to you can be pasted as it is. */
79
83
  function quoted(word) {
80
84
  return `'${word.replaceAll("'", "'\\''")}'`;
@@ -152,7 +156,7 @@ async function claudeJsonServers(env, edit) {
152
156
  const raw = await readIfExists(env, env.claudeJson);
153
157
  let data = {};
154
158
  try {
155
- const parsed = raw === null ? {} : JSON.parse(raw);
159
+ const parsed = raw === null ? {} : parseJson(raw);
156
160
  if (isRecord(parsed))
157
161
  data = parsed;
158
162
  }
@@ -166,7 +170,7 @@ async function claudeJsonServers(env, edit) {
166
170
  if (!(await stat(cwd).catch(() => null))?.isDirectory())
167
171
  throw new RefusedError(`${cwd} is not a directory on this machine.`);
168
172
  const projects = isRecord(data.projects) ? data.projects : {};
169
- const holder = edit.scope === "user" ? data : projects[cwd];
173
+ const holder = edit.scope === "user" ? data : projects[claudeJsonKey(env, cwd)];
170
174
  const servers = isRecord(holder) && isRecord(holder.mcpServers) ? holder.mcpServers : {};
171
175
  return { cwd, servers };
172
176
  }
@@ -279,6 +283,6 @@ export async function previewMcpEdit(env, edit, run) {
279
283
  return (await plan(env, edit, run)).preview;
280
284
  }
281
285
  /** Makes a reviewed MCP edit, only while the servers are still as reviewed. */
282
- export function saveMcpEdit(env, edit, expectedRevision, run, queue = serially) {
286
+ export function saveMcpEdit(env, edit, expectedRevision, run, queue) {
283
287
  return saveReviewed(queue, () => plan(env, edit, run), expectedRevision, "The servers changed since you reviewed this edit; review it again.");
284
288
  }
@@ -17,4 +17,4 @@ export declare function previewMemoryChange(env: ScanEnv, action: MemoryAction,
17
17
  * Makes a reviewed memory action. `path` is the memory file, or MEMORY.md for remove-dead; the
18
18
  * action runs only while everything its review was worked out from is unchanged.
19
19
  */
20
- export declare function changeMemory(env: ScanEnv, action: MemoryAction, path: string, expectedRevision: string, queue?: Serially): Promise<Change>;
20
+ export declare function changeMemory(env: ScanEnv, action: MemoryAction, path: string, expectedRevision: string, queue: Serially): Promise<Change>;
@@ -6,7 +6,7 @@ import { isWithin, revisionOf, stringField } from "./scan/files.js";
6
6
  import { parseFrontmatter } from "./scan/data.js";
7
7
  import { shownText } from "./secrets.js";
8
8
  import { deadLinks, indexMentions } from "./scan/memory.js";
9
- import { RefusedError, readIfExists, recordChange, removeExactly, saveReviewed, serially, writeAtomic, } from "./write.js";
9
+ import { RefusedError, readIfExists, recordChange, removeExactly, saveReviewed, writeAtomic, } from "./write.js";
10
10
  async function projectsRoot(env) {
11
11
  return realpath(join(env.claudeDir, "projects")).catch(() => {
12
12
  throw new RefusedError("There is no auto memory folder.");
@@ -181,6 +181,6 @@ export async function previewMemoryChange(env, action, path) {
181
181
  * Makes a reviewed memory action. `path` is the memory file, or MEMORY.md for remove-dead; the
182
182
  * action runs only while everything its review was worked out from is unchanged.
183
183
  */
184
- export function changeMemory(env, action, path, expectedRevision, queue = serially) {
184
+ export function changeMemory(env, action, path, expectedRevision, queue) {
185
185
  return saveReviewed(queue, () => PLANS[action](env, path), expectedRevision, REVIEW_AGAIN);
186
186
  }
@@ -23,9 +23,6 @@ export declare function spawnPlan(command: string, args: string[], path: string,
23
23
  * failed. On Windows `start` is a cmd.exe builtin, so cmd.exe is the command.
24
24
  */
25
25
  export declare function openBrowser(url: string, host: Host): Promise<string | null>;
26
- /**
27
- * Opens a file or folder in the first editor that starts on `host`; resolves with the command
28
- * used. Without `host` it uses this process's platform and environment; use a core's instead.
29
- */
30
- export declare function openInEditor(path: string, host?: Host): Promise<string>;
26
+ /** Opens a file or folder in the first editor that starts on `host`; resolves with the command used. */
27
+ export declare function openInEditor(path: string, host: Host): Promise<string>;
31
28
  export {};
package/dist/core/open.js CHANGED
@@ -121,7 +121,10 @@ function launch([command, args], path, host) {
121
121
  });
122
122
  child.once("exit", (code) => {
123
123
  clearTimeout(timer);
124
- resolve(code === 0 ? null : `${command} exited with code ${code}`);
124
+ // explorer.exe hands the file to the running shell and exits with 1 even when that worked.
125
+ if (code === 0 || command === "explorer")
126
+ return resolve(null);
127
+ resolve(`${command} exited with code ${code}`);
125
128
  });
126
129
  });
127
130
  }
@@ -148,13 +151,8 @@ function browserOpener(platform) {
148
151
  return ["cmd.exe", ["/c", "start", ""]];
149
152
  return ["xdg-open", []];
150
153
  }
151
- /**
152
- * Opens a file or folder in the first editor that starts on `host`; resolves with the command
153
- * used. Without `host` it uses this process's platform and environment; use a core's instead.
154
- */
155
- export async function openInEditor(path,
156
- // biome-ignore lint/style/noProcessEnv: the documented default of the free openInEditor; a core passes its own.
157
- host = { platform: process.platform, processEnv: process.env }) {
154
+ /** Opens a file or folder in the first editor that starts on `host`; resolves with the command used. */
155
+ export async function openInEditor(path, host) {
158
156
  const failures = [];
159
157
  for (const command of editorCommands(host.processEnv, host.platform)) {
160
158
  const failure = await launch(command, path, host);
@@ -3,6 +3,12 @@ interface Frontmatter {
3
3
  data: Record<string, unknown>;
4
4
  body: string;
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 declare function parseJson(text: string): unknown;
6
12
  export declare const FRONTMATTER: RegExp;
7
13
  export declare function parseFrontmatter(text: string): Frontmatter;
8
14
  export {};
@@ -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>;