ccdesk 0.0.0-stage → 0.1.0

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 (44) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +114 -2
  4. package/dist/server/backups.js +226 -0
  5. package/dist/server/cli.js +92 -0
  6. package/dist/server/mutations.js +188 -0
  7. package/dist/server/open.js +139 -0
  8. package/dist/server/scan/context.js +139 -0
  9. package/dist/server/scan/env.js +23 -0
  10. package/dist/server/scan/files.js +390 -0
  11. package/dist/server/scan/index.js +266 -0
  12. package/dist/server/scan/instructions.js +285 -0
  13. package/dist/server/scan/inventory.js +360 -0
  14. package/dist/server/scan/mcp.js +255 -0
  15. package/dist/server/scan/memory.js +91 -0
  16. package/dist/server/scan/path-test.js +99 -0
  17. package/dist/server/scan/permissions.js +85 -0
  18. package/dist/server/scan/plugins.js +117 -0
  19. package/dist/server/scan/projects.js +100 -0
  20. package/dist/server/scan/settings.js +317 -0
  21. package/dist/server/scan/tools.js +107 -0
  22. package/dist/server/scan/usage.js +441 -0
  23. package/dist/server/server.js +258 -0
  24. package/dist/server/settings-edit.js +152 -0
  25. package/dist/server/watch.js +172 -0
  26. package/dist/server/write.js +195 -0
  27. package/dist/shared/builtin-tools.json +235 -0
  28. package/dist/shared/scopes.js +18 -0
  29. package/dist/shared/settings-scopes.json +208 -0
  30. package/dist/shared/settings.schema.json +4623 -0
  31. package/dist/shared/types.js +2 -0
  32. package/dist/ui/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
  33. package/dist/ui/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
  34. package/dist/ui/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
  35. package/dist/ui/assets/index-BM4K7hSO.css +1 -0
  36. package/dist/ui/assets/index-CN3vkVjv.js +45 -0
  37. package/dist/ui/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  38. package/dist/ui/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  39. package/dist/ui/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  40. package/dist/ui/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  41. package/dist/ui/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  42. package/dist/ui/index.html +15 -0
  43. package/dist/ui/logo.png +0 -0
  44. package/package.json +80 -4
@@ -0,0 +1,390 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { lstat, open, readdir, readFile, readlink, realpath, stat } from "node:fs/promises";
4
+ import { basename, dirname, join, normalize, relative, resolve, sep } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { parse as parseYaml } from "yaml";
7
+ import {} from "../../shared/types.js";
8
+ const run = promisify(execFile);
9
+ /** Claude Code skips a CLAUDE.md larger than this (docs: memory#auto-memory), so ccdesk reads no more. */
10
+ export const MAX_TEXT_BYTES = 4 * 1024 * 1024;
11
+ /** ccdesk never reads Claude Code's credentials, whatever name or link leads to them. */
12
+ export function isSecretPath(path) {
13
+ return basename(path) === ".credentials.json";
14
+ }
15
+ /**
16
+ * Whether a path, any link on the way, or the file it finally leads to is the credentials file, so
17
+ * a credentials file that is itself a link is caught too. Checked before opening.
18
+ */
19
+ export async function leadsToSecret(path) {
20
+ let current = path;
21
+ for (let hop = 0; hop < 40; hop++) {
22
+ if (isSecretPath(current))
23
+ return true;
24
+ const info = await lstat(current).catch(() => null);
25
+ if (!info?.isSymbolicLink())
26
+ break;
27
+ current = resolve(dirname(current), await readlink(current));
28
+ }
29
+ return isSecretPath(await realpath(path).catch(() => path));
30
+ }
31
+ /**
32
+ * A JSON parse failure without the parser's message, which quotes the text around the fault and
33
+ * so can quote a secret.
34
+ */
35
+ export function jsonSyntaxError(error) {
36
+ const at = /line (\d+) column (\d+)/.exec(String(error));
37
+ return at ? `not valid JSON (line ${at[1]}, column ${at[2]})` : "not valid JSON";
38
+ }
39
+ /**
40
+ * The text ccdesk shows for a file reached as an instruction, import, memory or memory backup. A
41
+ * settings or MCP file read this way, whatever it is named, would otherwise show its env and
42
+ * headers values, so anything that parses as JSON is shown parsed with them hidden.
43
+ */
44
+ export function shownText(text) {
45
+ if (text === null)
46
+ return null;
47
+ let data;
48
+ try {
49
+ data = JSON.parse(text);
50
+ }
51
+ catch {
52
+ return text;
53
+ }
54
+ return typeof data === "object" && data !== null
55
+ ? `${JSON.stringify(redactSecrets(data), null, 2)}\n`
56
+ : text;
57
+ }
58
+ /** A file named .json that does not parse is not shown: its text could still hold a secret. */
59
+ function shownJsonFile(text) {
60
+ try {
61
+ JSON.parse(text);
62
+ }
63
+ catch {
64
+ return "(a JSON file ccdesk could not parse, so its text is not shown; open it to see it)\n";
65
+ }
66
+ return shownText(text) ?? "";
67
+ }
68
+ /**
69
+ * A UNC share or a path under /net or /Network. Claude Code does not follow a link there, because
70
+ * looking it up can contact the host it names (docs: memory#share-rules-across-projects-with-symlinks).
71
+ */
72
+ export function isNetworkPath(path) {
73
+ if (/^[\\/]{2}wsl(\$|\.localhost)[\\/]/i.test(path))
74
+ return false;
75
+ return /^[\\/]{2}[^\\/]/.test(path) || /^\/(net|Network)(\/|$)/.test(path);
76
+ }
77
+ /** Where a symlink points, read without following it; null for anything else. */
78
+ async function linkTarget(path) {
79
+ try {
80
+ if (!(await lstat(path)).isSymbolicLink())
81
+ return null;
82
+ return resolve(dirname(path), await readlink(path));
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ /** A link to a network location, which ccdesk, like Claude Code, does not follow. */
89
+ async function linksToNetwork(path) {
90
+ const target = await linkTarget(path);
91
+ return target !== null && isNetworkPath(target);
92
+ }
93
+ /** A pattern where `*` matches anything, matched against the whole string. */
94
+ export function wildcard(pattern, flags = "") {
95
+ const body = pattern
96
+ .split("*")
97
+ .map((p) => p.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
98
+ .join(".*");
99
+ return new RegExp(`^${body}$`, flags);
100
+ }
101
+ export function isWithin(parent, child) {
102
+ const rel = relative(parent, child);
103
+ return rel === "" || (!rel.startsWith("..") && !rel.startsWith(sep) && !/^[a-zA-Z]:/.test(rel));
104
+ }
105
+ export async function isDir(path) {
106
+ try {
107
+ return (await stat(path)).isDirectory();
108
+ }
109
+ catch {
110
+ return false;
111
+ }
112
+ }
113
+ export async function isFile(path) {
114
+ try {
115
+ return (await stat(path)).isFile();
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
121
+ export async function listDir(path) {
122
+ try {
123
+ return (await readdir(path)).sort();
124
+ }
125
+ catch {
126
+ return [];
127
+ }
128
+ }
129
+ export async function readTextFile(path) {
130
+ try {
131
+ if (await linksToNetwork(path))
132
+ return null;
133
+ if (await leadsToSecret(path))
134
+ return null;
135
+ const real = await realpath(path);
136
+ const info = await stat(path);
137
+ if (!info.isFile())
138
+ return null;
139
+ const handle = await open(path, "r");
140
+ let text;
141
+ try {
142
+ const buffer = Buffer.alloc(Math.min(info.size, MAX_TEXT_BYTES));
143
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
144
+ text = buffer.subarray(0, bytesRead).toString("utf8");
145
+ }
146
+ finally {
147
+ await handle.close();
148
+ }
149
+ text = real.endsWith(".json") ? shownJsonFile(text) : (shownText(text) ?? "");
150
+ const ref = {
151
+ path,
152
+ mtime: info.mtime.toISOString(),
153
+ bytes: info.size,
154
+ lines: text === "" ? 0 : text.split("\n").length,
155
+ text,
156
+ };
157
+ if ((await lstat(path)).isSymbolicLink())
158
+ ref.realPath = real;
159
+ return ref;
160
+ }
161
+ catch {
162
+ return null;
163
+ }
164
+ }
165
+ export async function resolvedPath(path) {
166
+ try {
167
+ return await realpath(path);
168
+ }
169
+ catch {
170
+ return path;
171
+ }
172
+ }
173
+ export async function readJsonObject(path) {
174
+ let raw;
175
+ try {
176
+ if (await leadsToSecret(path))
177
+ return { ok: false, missing: false, error: "ccdesk does not read credentials" };
178
+ raw = await readFile(path, "utf8");
179
+ }
180
+ catch (error) {
181
+ const missing = error.code === "ENOENT";
182
+ return { ok: false, missing, error: String(error) };
183
+ }
184
+ try {
185
+ const data = JSON.parse(raw);
186
+ if (!isRecord(data))
187
+ return { ok: false, missing: false, error: "not a JSON object" };
188
+ return { ok: true, data };
189
+ }
190
+ catch (error) {
191
+ return { ok: false, missing: false, error: jsonSyntaxError(error) };
192
+ }
193
+ }
194
+ export function isRecord(value) {
195
+ return typeof value === "object" && value !== null && !Array.isArray(value);
196
+ }
197
+ /**
198
+ * A server URL as ccdesk shows it: a user name, password or query string can carry a key, so they
199
+ * are hidden, as Claude Code hides the URL in its own status output (docs: mcp).
200
+ */
201
+ export function shownUrl(url) {
202
+ try {
203
+ const u = new URL(url);
204
+ if (!u.username && !u.password && !u.search)
205
+ return url;
206
+ if (u.username || u.password) {
207
+ u.username = "•••";
208
+ u.password = "";
209
+ }
210
+ if (u.search)
211
+ u.search = "?•••";
212
+ return decodeURI(u.toString());
213
+ }
214
+ catch {
215
+ return url.replace(/\/\/[^/@]*@/, "//•••@").replace(/\?.*$/, "?•••");
216
+ }
217
+ }
218
+ /**
219
+ * Replace values that commonly hold secrets with a placeholder, keeping the keys: env and headers
220
+ * values, and the user, password and query of a `url`.
221
+ */
222
+ export function redactSecrets(value) {
223
+ if (Array.isArray(value))
224
+ return value.map(redactSecrets);
225
+ if (!isRecord(value))
226
+ return value;
227
+ const out = {};
228
+ for (const [key, inner] of Object.entries(value)) {
229
+ if ((key === "env" || key === "headers") && isRecord(inner)) {
230
+ out[key] = Object.fromEntries(Object.keys(inner).map((k) => [k, "•••"]));
231
+ }
232
+ else if (key === "url" && typeof inner === "string") {
233
+ out[key] = shownUrl(inner);
234
+ }
235
+ else {
236
+ out[key] = redactSecrets(inner);
237
+ }
238
+ }
239
+ return out;
240
+ }
241
+ export function parseFrontmatter(text) {
242
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
243
+ if (!match)
244
+ return { data: {}, body: text };
245
+ try {
246
+ const data = parseYaml(match[1] ?? "");
247
+ return { data: isRecord(data) ? data : {}, body: text.slice(match[0].length) };
248
+ }
249
+ catch {
250
+ return { data: {}, body: text.slice(match[0].length) };
251
+ }
252
+ }
253
+ /** The strings in a JSON list; anything else counts as no strings. */
254
+ export function stringsIn(value) {
255
+ return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
256
+ }
257
+ /** A frontmatter list given as a YAML list or a comma-separated string, such as `paths`. */
258
+ export function toStringList(value) {
259
+ if (typeof value === "string")
260
+ return value
261
+ .split(",")
262
+ .map((s) => s.trim())
263
+ .filter(Boolean);
264
+ return stringsIn(value);
265
+ }
266
+ export function stringField(data, key) {
267
+ const value = data[key];
268
+ return typeof value === "string" ? value.trim() : "";
269
+ }
270
+ /** Fingerprint of a file's text, so a change can require the file to be as it was reviewed. */
271
+ export function revisionOf(text) {
272
+ return text === null ? "missing" : createHash("sha256").update(text).digest("hex").slice(0, 16);
273
+ }
274
+ export function shortHash(text) {
275
+ return createHash("sha1").update(text).digest("hex").slice(0, 10);
276
+ }
277
+ export async function gitRoot(dir) {
278
+ try {
279
+ const { stdout } = await run("git", ["-C", dir, "rev-parse", "--show-toplevel"], {
280
+ timeout: 5000,
281
+ });
282
+ // Git prints forward slashes on Windows too; normalise so path comparisons hold.
283
+ return stdout.trim() ? normalize(stdout.trim()) : null;
284
+ }
285
+ catch {
286
+ return null;
287
+ }
288
+ }
289
+ /** Every checkout of the repository `dir` is in, the main one first; empty outside git. */
290
+ export async function worktrees(dir) {
291
+ try {
292
+ const { stdout } = await run("git", ["-C", dir, "worktree", "list", "--porcelain"], {
293
+ timeout: 5000,
294
+ });
295
+ return stdout
296
+ .split("\n")
297
+ .filter((line) => line.startsWith("worktree "))
298
+ .map((line) => normalize(line.slice("worktree ".length)));
299
+ }
300
+ catch {
301
+ return [];
302
+ }
303
+ }
304
+ /** Main worktree root: worktrees of one repository share Claude Code's auto memory. */
305
+ export async function mainWorktreeRoot(dir) {
306
+ try {
307
+ const { stdout } = await run("git", ["-C", dir, "rev-parse", "--path-format=absolute", "--git-common-dir"], { timeout: 5000 });
308
+ const common = stdout.trim();
309
+ if (!common)
310
+ return null;
311
+ return common.endsWith("/.git") || common.endsWith("\\.git")
312
+ ? normalize(dirname(common))
313
+ : null;
314
+ }
315
+ catch {
316
+ return null;
317
+ }
318
+ }
319
+ /** Git visibility of each path, batched into two git calls per repository. */
320
+ export async function visibilityOf(root, paths) {
321
+ const result = new Map();
322
+ if (paths.length === 0)
323
+ return result;
324
+ if (!root) {
325
+ for (const p of paths)
326
+ result.set(p, "no-git");
327
+ return result;
328
+ }
329
+ const inside = paths.filter((p) => p !== root && isWithin(root, p));
330
+ for (const p of paths)
331
+ if (!inside.includes(p))
332
+ result.set(p, "no-git");
333
+ if (inside.length === 0)
334
+ return result;
335
+ const rels = new Map(inside.map((p) => [p, relative(root, p).replaceAll("\\", "/")]));
336
+ const tracked = await gitLines(root, ["ls-files", "--full-name", "-z", "--", ...inside], "\0");
337
+ // check-ignore echoes the paths it matched, and on Windows an absolute path does not come back
338
+ // as written, so it gets relative ones. It accepts -z only with --stdin, so its output is split
339
+ // by line.
340
+ const ignored = await gitLines(root, ["check-ignore", "--no-index", "--", ...rels.values()], "\n");
341
+ for (const [p, rel] of rels) {
342
+ if (tracked.has(rel))
343
+ result.set(p, "tracked");
344
+ else if (ignored.has(rel))
345
+ result.set(p, "ignored");
346
+ else
347
+ result.set(p, "untracked");
348
+ }
349
+ return result;
350
+ }
351
+ // Failures (including check-ignore's exit 1 for "nothing ignored") read as an empty set.
352
+ async function gitLines(root, args, separator) {
353
+ try {
354
+ const { stdout } = await run("git", ["-C", root, ...args], { timeout: 5000 });
355
+ return new Set(stdout.split(separator).filter(Boolean));
356
+ }
357
+ catch {
358
+ return new Set();
359
+ }
360
+ }
361
+ /**
362
+ * Markdown files under `dir`. A symlinked folder is entered once, so a link loop ends, and a link
363
+ * to a network location is not followed. Depth and count are bounded by ccdesk, since a link can
364
+ * lead anywhere; Claude Code's own limits are not documented.
365
+ */
366
+ export async function walkMarkdown(dir) {
367
+ const out = [];
368
+ const seen = new Set();
369
+ const walk = async (folder, depth) => {
370
+ const real = await resolvedPath(folder);
371
+ if (seen.has(real) || depth > MAX_WALK_DEPTH)
372
+ return;
373
+ seen.add(real);
374
+ for (const name of await listDir(folder)) {
375
+ if (out.length >= MAX_WALK_FILES)
376
+ return;
377
+ const path = join(folder, name);
378
+ if (await linksToNetwork(path))
379
+ continue;
380
+ if (await isDir(path))
381
+ await walk(path, depth + 1);
382
+ else if (name.endsWith(".md"))
383
+ out.push(path);
384
+ }
385
+ };
386
+ await walk(dir, 0);
387
+ return out;
388
+ }
389
+ const MAX_WALK_DEPTH = 8;
390
+ const MAX_WALK_FILES = 1000;
@@ -0,0 +1,266 @@
1
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
2
+ import {} from "../../shared/types.js";
3
+ import { buildContext } from "./context.js";
4
+ import {} from "./env.js";
5
+ import { gitRoot, isDir, isFile, isRecord, mainWorktreeRoot, readJsonObject, stringsIn, visibilityOf, } from "./files.js";
6
+ import { AGENTS_MD_MODES, resolveInstructions } from "./instructions.js";
7
+ import { readInventory } from "./inventory.js";
8
+ import { readMemoryStore, storeDirName } from "./memory.js";
9
+ import { permissionsOf, trustWaits } from "./permissions.js";
10
+ import { listProjects } from "./projects.js";
11
+ import { effectiveSettings, readSettingsLayers } from "./settings.js";
12
+ import { builtinTools } from "./tools.js";
13
+ async function readClaudeJson(env) {
14
+ const result = await readJsonObject(env.claudeJson);
15
+ return result.ok ? result.data : null;
16
+ }
17
+ export async function scanOverview(env, launchDir) {
18
+ const claudeJson = await readClaudeJson(env);
19
+ const projects = await listProjects(env, claudeJson, launchDir);
20
+ const hashesByName = new Map();
21
+ const memoryCatalog = [];
22
+ for (const project of projects) {
23
+ for (const memory of project.store?.memories ?? []) {
24
+ memoryCatalog.push({
25
+ projectPath: project.path,
26
+ path: memory.path,
27
+ name: memory.name,
28
+ description: memory.description,
29
+ type: memory.type,
30
+ });
31
+ const list = hashesByName.get(memory.name) ?? [];
32
+ list.push({ projectPath: project.path, hash: memory.hash });
33
+ hashesByName.set(memory.name, list);
34
+ }
35
+ }
36
+ return {
37
+ generatedAt: new Date().toISOString(),
38
+ home: env.home,
39
+ claudeDir: env.claudeDir,
40
+ launchDir: resolve(launchDir),
41
+ platform: env.platform,
42
+ projects: projects.map(({ store: _store, ...summary }) => summary),
43
+ sharedMemories: [...hashesByName.entries()]
44
+ .filter(([, stores]) => stores.length > 1)
45
+ .map(([name, stores]) => ({ name, stores }))
46
+ .sort((a, b) => a.name.localeCompare(b.name)),
47
+ memoryCatalog,
48
+ };
49
+ }
50
+ /** Everything Claude Code reads when started in `dir`. */
51
+ export async function scanDirectory(env, dir) {
52
+ const path = resolve(dir);
53
+ const exists = await isDir(path);
54
+ const claudeJson = await readClaudeJson(env);
55
+ const root = exists ? await gitRoot(path) : null;
56
+ const settings = await readSettingsLayers(env, path);
57
+ const effective = effectiveSettings(settings);
58
+ const mode = agentsMdMode(settings);
59
+ const excludes = effective.find((s) => s.key === "claudeMdExcludes")?.value;
60
+ const mainRoot = exists ? await mainWorktreeRoot(path) : null;
61
+ const trust = trustOf(env, claudeJson, path, mainRoot ?? root);
62
+ const managedLayer = settings.find((l) => l.scope === "managed");
63
+ const managedClaudeMd = managedLayer?.data?.claudeMd;
64
+ const problems = [];
65
+ const instructions = await resolveInstructions({
66
+ env,
67
+ cwd: path,
68
+ mode,
69
+ excludes: stringsIn(excludes),
70
+ externalImports: trust.externalImports,
71
+ managedClaudeMd: managedLayer && typeof managedClaudeMd === "string"
72
+ ? { path: `${managedLayer.path}#claudeMd`, text: managedClaudeMd }
73
+ : null,
74
+ problems,
75
+ });
76
+ const inventory = await readInventory({
77
+ env,
78
+ projectDir: path,
79
+ gitRoot: root,
80
+ layers: settings,
81
+ claudeJson,
82
+ trust,
83
+ });
84
+ // Every project file that shapes Claude here, so the UI can say who sees each one.
85
+ const mcpJson = join(path, ".mcp.json");
86
+ const projectFiles = [
87
+ ...instructions.filter((i) => i.scope === "project" || i.scope === "local").map((i) => i.path),
88
+ ...settings
89
+ .filter((s) => s.exists && (s.scope === "project" || s.scope === "local"))
90
+ .map((s) => s.path),
91
+ ...((await isFile(mcpJson)) ? [mcpJson] : []),
92
+ ...[...inventory.agents, ...inventory.skills, ...inventory.commands, ...inventory.outputStyles]
93
+ .filter((e) => e.scope === "project" || e.scope === "local")
94
+ .map((e) => e.path),
95
+ ];
96
+ const visibility = await visibilityOf(root, projectFiles);
97
+ for (const item of [...instructions, ...settings]) {
98
+ const v = visibility.get(item.path);
99
+ if (v)
100
+ item.visibility = v;
101
+ }
102
+ let sharing = null;
103
+ if (root) {
104
+ sharing = { tracked: [], untracked: [], ignored: [] };
105
+ for (const file of new Set(projectFiles)) {
106
+ const v = visibility.get(file);
107
+ // Shown as repository paths, with forward slashes on every platform.
108
+ const rel = relative(root, file).replaceAll("\\", "/");
109
+ if (v === "tracked")
110
+ sharing.tracked.push(rel);
111
+ else if (v === "untracked")
112
+ sharing.untracked.push(rel);
113
+ else if (v === "ignored")
114
+ sharing.ignored.push(rel);
115
+ }
116
+ }
117
+ // docs: permissions#when-your-local-settings-file-needs-trust
118
+ const localFromRepository = settings.some((l) => l.scope === "local" && l.visibility === "tracked");
119
+ const trustOptions = { trust, inConfigHome: isConfigHome(env, path), localFromRepository };
120
+ const memory = await readMemory(env, mainRoot, path, effective, trustOptions);
121
+ const permissions = permissionsOf(settings, trustOptions);
122
+ for (const l of settings)
123
+ if (l.error)
124
+ problems.push({ path: l.path, error: l.error });
125
+ const mcp = await readJsonObject(mcpJson);
126
+ if (!mcp.ok && !mcp.missing)
127
+ problems.push({ path: mcpJson, error: mcp.error });
128
+ return {
129
+ path,
130
+ exists,
131
+ gitRoot: root,
132
+ agentsMdMode: mode,
133
+ instructions,
134
+ settings,
135
+ effective,
136
+ memory,
137
+ inventory,
138
+ context: await buildContext({
139
+ env,
140
+ projectRoot: root ?? path,
141
+ instructions,
142
+ memory,
143
+ inventory,
144
+ effective,
145
+ }),
146
+ permissions,
147
+ tools: builtinTools(permissions, inventory.hooks),
148
+ trust,
149
+ problems,
150
+ sharing,
151
+ };
152
+ }
153
+ /** Your home directory, or the folder whose .claude is CLAUDE_CONFIG_DIR. */
154
+ function isConfigHome(env, path) {
155
+ return path === env.home || join(path, ".claude") === env.claudeDir;
156
+ }
157
+ /**
158
+ * Your answers to Claude Code's trust dialogs, from ~/.claude.json (docs: permissions#project-allow-rules-and-workspace-trust).
159
+ * In a repository the key is the main checkout's root; outside one it is the folder, and a trusted
160
+ * parent folder covers it. In your home directory trust lasts one session and is never saved.
161
+ */
162
+ function trustOf(env, claudeJson, path, repositoryRoot) {
163
+ const projects = isRecord(claudeJson?.projects) ? claudeJson.projects : {};
164
+ const entry = (key) => (isRecord(projects[key]) ? projects[key] : {});
165
+ const root = repositoryRoot ?? path;
166
+ const own = entry(root);
167
+ const trust = {
168
+ root,
169
+ trusted: own.hasTrustDialogAccepted === true,
170
+ sessionOnly: path === env.home,
171
+ // These two keys are not documented; they are what Claude Code writes for the import dialog.
172
+ externalImports: own.hasClaudeMdExternalIncludesApproved === true
173
+ ? "approved"
174
+ : own.hasClaudeMdExternalIncludesWarningShown === true
175
+ ? "declined"
176
+ : "not-asked",
177
+ };
178
+ if (!trust.trusted && !repositoryRoot)
179
+ for (let d = dirname(path); d !== dirname(d); d = dirname(d))
180
+ if (entry(d).hasTrustDialogAccepted === true) {
181
+ trust.inheritedFrom = d;
182
+ break;
183
+ }
184
+ return trust;
185
+ }
186
+ /**
187
+ * The auto memory Claude Code uses here (docs: memory#storage-location, settings-reference#automemoryenabled).
188
+ * Worktrees and subdirectories of one repository share one store. The store is returned even
189
+ * when its folder does not exist yet, so a note about memory being off still shows.
190
+ */
191
+ async function readMemory(env, mainRoot, path, effective, trustOptions) {
192
+ const key = mainRoot ?? path;
193
+ const setting = effective.find((s) => s.key === "autoMemoryDirectory");
194
+ // A value from a repository file counts once the folder is trusted, like hooks (docs:
195
+ // settings-reference#automemorydirectory); until then the next file down that sets it decides.
196
+ const waitsForTrust = trustWaits(trustOptions);
197
+ const candidates = setting
198
+ ? [{ scope: setting.from[0], value: setting.value }, ...setting.overridden]
199
+ : [];
200
+ const waiting = candidates.find((c) => c.scope && waitsForTrust(c.scope));
201
+ const custom = candidates.find((c) => c.scope && !waitsForTrust(c.scope));
202
+ const customDir = typeof custom?.value === "string" ? expandHome(env, custom.value) : null;
203
+ const storeName = env.projectDirName ?? storeDirName(key);
204
+ const dir = customDir ?? join(env.claudeDir, "projects", storeName, "memory");
205
+ const store = (await readMemoryStore(dir, key)) ?? {
206
+ dir,
207
+ loaded: true,
208
+ projectPath: key,
209
+ index: null,
210
+ memories: [],
211
+ };
212
+ if (customDir)
213
+ store.custom = true;
214
+ const disable = env.disableAutoMemory;
215
+ const enabled = effective.find((s) => s.key === "autoMemoryEnabled");
216
+ const fromRepository = custom?.scope === "project" || (custom?.scope === "local" && trustOptions.localFromRepository);
217
+ if (disable === "1" || disable === "true") {
218
+ store.loaded = false;
219
+ store.note = "CLAUDE_CODE_DISABLE_AUTO_MEMORY turns auto memory off in this shell";
220
+ }
221
+ else if (disable === undefined && enabled?.value === false) {
222
+ store.loaded = false;
223
+ store.note = `autoMemoryEnabled is false in ${enabled.from.join(", ")} settings`;
224
+ }
225
+ else if (customDir && custom && fromRepository && blocksOutsideReads(effective)) {
226
+ // docs: settings-reference#permissions-blockreadsoutsideworkingdirectories
227
+ store.loaded = false;
228
+ store.note =
229
+ "autoMemoryDirectory comes from repository settings and blockReadsOutsideWorkingDirectories is on, so Claude Code loads no auto memory from it";
230
+ }
231
+ else if (customDir && custom) {
232
+ store.note = `autoMemoryDirectory in ${custom.scope} settings points here; ccdesk only changes stores under ~/.claude/projects`;
233
+ }
234
+ else if (waiting) {
235
+ store.note = `autoMemoryDirectory in ${waiting.scope} settings takes over once you trust this folder; until then Claude Code uses this store`;
236
+ }
237
+ else if (env.projectDirName) {
238
+ store.note = `CLAUDE_CODE_PROJECT_DIR_NAME names this store "${env.projectDirName}" in every project`;
239
+ }
240
+ return store;
241
+ }
242
+ function blocksOutsideReads(effective) {
243
+ const permissions = effective.find((s) => s.key === "permissions")?.value;
244
+ return isRecord(permissions) && permissions.blockReadsOutsideWorkingDirectories === true;
245
+ }
246
+ /** autoMemoryDirectory takes an absolute or ~/ path; anything else is not a valid value. */
247
+ function expandHome(env, path) {
248
+ if (path.startsWith("~/"))
249
+ return join(env.home, path.slice(2));
250
+ return isAbsolute(path) ? path : null;
251
+ }
252
+ /** Read from user, --settings or managed settings only; project and local files are ignored for it. */
253
+ function agentsMdMode(layers) {
254
+ for (const scope of ["managed", "user"]) {
255
+ const configs = layers.find((l) => l.scope === scope)?.data?.pluginConfigs;
256
+ if (!isRecord(configs))
257
+ continue;
258
+ for (const id of ["cc-plugin-agents-md@builtin", "agents-md@builtin"]) {
259
+ const entry = configs[id];
260
+ const value = isRecord(entry) && isRecord(entry.options) ? entry.options.instructionFiles : undefined;
261
+ if (typeof value === "string" && AGENTS_MD_MODES.includes(value))
262
+ return value;
263
+ }
264
+ }
265
+ return "claude-md-or-agents-md";
266
+ }