ccdesk 0.1.0 → 0.2.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 +75 -0
  2. package/README.md +84 -67
  3. package/dist/server/agent-edit.js +77 -0
  4. package/dist/server/backups.js +129 -52
  5. package/dist/server/cli.js +21 -8
  6. package/dist/server/mcp-edit.js +263 -0
  7. package/dist/server/mutations.js +8 -3
  8. package/dist/server/scan/compare.js +34 -0
  9. package/dist/server/scan/context.js +6 -5
  10. package/dist/server/scan/env.js +8 -3
  11. package/dist/server/scan/files.js +118 -30
  12. package/dist/server/scan/index.js +29 -15
  13. package/dist/server/scan/instructions.js +11 -7
  14. package/dist/server/scan/inventory.js +134 -42
  15. package/dist/server/scan/mcp.js +88 -30
  16. package/dist/server/scan/memory.js +8 -9
  17. package/dist/server/scan/path-test.js +32 -10
  18. package/dist/server/scan/permissions.js +22 -6
  19. package/dist/server/scan/plugins.js +18 -11
  20. package/dist/server/scan/projects.js +7 -6
  21. package/dist/server/scan/sandbox.js +161 -0
  22. package/dist/server/scan/settings.js +33 -20
  23. package/dist/server/scan/tools.js +7 -3
  24. package/dist/server/scan/usage.js +119 -46
  25. package/dist/server/schema.js +15 -0
  26. package/dist/server/secrets.js +63 -0
  27. package/dist/server/server.js +222 -128
  28. package/dist/server/settings-edit.js +90 -21
  29. package/dist/server/watch.js +52 -10
  30. package/dist/server/write.js +58 -20
  31. package/dist/shared/compare.js +1 -0
  32. package/dist/shared/hook-events.json +35 -0
  33. package/dist/shared/mcp-server.schema.json +25 -0
  34. package/dist/shared/memory.js +4 -0
  35. package/dist/shared/scopes.js +3 -1
  36. package/dist/shared/types.js +1 -1
  37. package/dist/ui/assets/hanken-grotesk-cyrillic-ext-wght-normal-D_UHSL_T.woff2 +0 -0
  38. package/dist/ui/assets/index-BQ3Lhx8z.js +60 -0
  39. package/dist/ui/assets/index-BRgGlpjd.css +1 -0
  40. package/dist/ui/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
  41. package/dist/ui/index.html +2 -2
  42. package/package.json +10 -4
  43. package/dist/ui/assets/index-BM4K7hSO.css +0 -1
  44. package/dist/ui/assets/index-CN3vkVjv.js +0 -45
@@ -0,0 +1,34 @@
1
+ import { resolve } from "node:path";
2
+ import {} from "../../shared/compare.js";
3
+ import {} from "./env.js";
4
+ import { effectiveSettings, readSettingsLayers } from "./settings.js";
5
+ /** Each key Claude Code applies in `dir`, with its value and the files it comes from. */
6
+ async function valuesIn(env, dir) {
7
+ const layers = await readSettingsLayers(env, dir);
8
+ const values = new Map();
9
+ for (const setting of effectiveSettings(layers)) {
10
+ // A key every file sets where Claude Code does not read it applies nowhere.
11
+ if (setting.from.length === 0)
12
+ continue;
13
+ const sources = setting.from.flatMap((scope) => {
14
+ const layer = layers.find((l) => l.scope === scope && l.data?.[setting.key] !== undefined);
15
+ return layer ? [{ scope, path: layer.path }] : [];
16
+ });
17
+ values.set(setting.key, { value: setting.value, sources });
18
+ }
19
+ return values;
20
+ }
21
+ /**
22
+ * The effective settings of two directories side by side. Values are compared as ccdesk shows
23
+ * them, so hidden values stay hidden and two of them count as the same.
24
+ */
25
+ export async function compareSettings(env, a, b) {
26
+ const [left, right] = await Promise.all([valuesIn(env, resolve(a)), valuesIn(env, resolve(b))]);
27
+ const keys = [...new Set([...left.keys(), ...right.keys()])].sort();
28
+ const settings = keys.map((key) => {
29
+ const x = left.get(key) ?? null;
30
+ const y = right.get(key) ?? null;
31
+ return { key, a: x, b: y, same: JSON.stringify(x?.value) === JSON.stringify(y?.value) };
32
+ });
33
+ return { a: resolve(a), b: resolve(b), settings };
34
+ }
@@ -35,10 +35,11 @@ const BUILT_IN_STYLES = ["default", "explanatory", "learning"];
35
35
  function skillListing(skills) {
36
36
  return skills
37
37
  .map((s) => {
38
+ const name = s.id ?? s.name;
38
39
  if (s.override === "name-only")
39
- return `- ${s.name}`;
40
+ return `- ${name}`;
40
41
  const text = [s.description, s.whenToUse].filter(Boolean).join(" ");
41
- return `- ${s.name}: ${text.slice(0, SKILL_DESCRIPTION_CAP)}`;
42
+ return `- ${name}: ${text.slice(0, SKILL_DESCRIPTION_CAP)}`;
42
43
  })
43
44
  .join("\n");
44
45
  }
@@ -92,7 +93,7 @@ export async function buildContext(input) {
92
93
  note: "Outside this project and your Claude folders, so ccdesk does not show or count it. Claude Code loads it.",
93
94
  }, null));
94
95
  else {
95
- const text = await readTextFile(ref.resolved);
96
+ const text = await readTextFile(env, ref.resolved);
96
97
  blocks.push(block(fields, text ? injectedText(text.text) : null));
97
98
  }
98
99
  }
@@ -107,7 +108,7 @@ export async function buildContext(input) {
107
108
  if (memory?.loaded && index) {
108
109
  blocks.push(block({ kind: "memory-index", path: index.path, scope: "memory" }, index.text.split("\n").slice(0, index.loadedLines).join("\n")));
109
110
  }
110
- const listed = [...inventory.skills, ...inventory.commands].filter((s) => s.listed !== false);
111
+ const listed = [...inventory.skills, ...inventory.commands].filter((s) => s.listed !== false && !s.shadowedBy);
111
112
  const listing = skillListing(listed);
112
113
  if (listing)
113
114
  blocks.push(block({
@@ -117,7 +118,7 @@ export async function buildContext(input) {
117
118
  note: "Names and descriptions only; a skill's body loads when it is used. Claude Code drops descriptions if the listing passes 1% of the context window.",
118
119
  }, listing));
119
120
  const agents = inventory.agents
120
- .filter((a) => !a.shadowedBy)
121
+ .filter((a) => !a.shadowedBy && !a.skipped)
121
122
  .map((a) => `- ${a.id ?? a.name}: ${a.description}`)
122
123
  .join("\n");
123
124
  if (agents)
@@ -1,8 +1,9 @@
1
1
  import { homedir } from "node:os";
2
- import { join } from "node:path";
3
- export function scanEnvFromProcess() {
2
+ import { join, resolve } from "node:path";
3
+ /** `configDirFlag`, from `--config-dir`, stands in for CLAUDE_CONFIG_DIR and wins over it. */
4
+ export function scanEnvFromProcess(configDirFlag) {
4
5
  const home = homedir();
5
- const configDir = process.env.CLAUDE_CONFIG_DIR;
6
+ const configDir = configDirFlag !== undefined ? resolve(configDirFlag) : process.env.CLAUDE_CONFIG_DIR;
6
7
  return {
7
8
  home,
8
9
  claudeDir: configDir ?? join(home, ".claude"),
@@ -14,6 +15,10 @@ export function scanEnvFromProcess() {
14
15
  projectDirName: configDir ? process.env.CLAUDE_CODE_PROJECT_DIR_NAME || undefined : undefined,
15
16
  };
16
17
  }
18
+ /** Your configuration home: your home directory, or the folder whose .claude is CLAUDE_CONFIG_DIR. */
19
+ export function isConfigHome(env, path) {
20
+ return path === env.home || join(path, ".claude") === env.claudeDir;
21
+ }
17
22
  function managedDirFor(platform) {
18
23
  if (platform === "darwin")
19
24
  return "/Library/Application Support/ClaudeCode";
@@ -1,10 +1,12 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
- import { lstat, open, readdir, readFile, readlink, realpath, stat } from "node:fs/promises";
3
+ import {} from "node:fs";
4
+ import { lstat, open, readdir, readlink, realpath, stat } from "node:fs/promises";
4
5
  import { basename, dirname, join, normalize, relative, resolve, sep } from "node:path";
5
6
  import { promisify } from "node:util";
6
- import { parse as parseYaml } from "yaml";
7
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
7
8
  import {} from "../../shared/types.js";
9
+ import {} from "./env.js";
8
10
  const run = promisify(execFile);
9
11
  /** Claude Code skips a CLAUDE.md larger than this (docs: memory#auto-memory), so ccdesk reads no more. */
10
12
  export const MAX_TEXT_BYTES = 4 * 1024 * 1024;
@@ -16,7 +18,7 @@ export function isSecretPath(path) {
16
18
  * Whether a path, any link on the way, or the file it finally leads to is the credentials file, so
17
19
  * a credentials file that is itself a link is caught too. Checked before opening.
18
20
  */
19
- export async function leadsToSecret(path) {
21
+ async function leadsToSecret(path) {
20
22
  let current = path;
21
23
  for (let hop = 0; hop < 40; hop++) {
22
24
  if (isSecretPath(current))
@@ -28,6 +30,38 @@ export async function leadsToSecret(path) {
28
30
  }
29
31
  return isSecretPath(await realpath(path).catch(() => path));
30
32
  }
33
+ /** The identity of `<config>/.credentials.json`, or null when there is none. */
34
+ function credentialsIdentity(env) {
35
+ return stat(join(env.claudeDir, ".credentials.json"), { bigint: true }).catch(() => null);
36
+ }
37
+ function sameFile(a, b) {
38
+ return b !== null && a.dev === b.dev && a.ino === b.ino;
39
+ }
40
+ /**
41
+ * Whether a path is Claude Code's credentials file: by name, through any link on the way, or by
42
+ * being the same file as `<config>/.credentials.json`, so a copy kept under another name or a hard
43
+ * link to it counts too.
44
+ */
45
+ export async function isCredentials(env, path) {
46
+ if (await leadsToSecret(path))
47
+ return true;
48
+ const info = await stat(path, { bigint: true }).catch(() => null);
49
+ return info !== null && sameFile(info, await credentialsIdentity(env));
50
+ }
51
+ /**
52
+ * Opens a file to read, or null for the credentials file. The open file is checked again, so a
53
+ * file swapped in after the first check is not read either.
54
+ */
55
+ async function openUnlessCredentials(env, path) {
56
+ if (await isCredentials(env, path))
57
+ return null;
58
+ const handle = await open(path, "r");
59
+ if (sameFile(await handle.stat({ bigint: true }), await credentialsIdentity(env))) {
60
+ await handle.close();
61
+ return null;
62
+ }
63
+ return handle;
64
+ }
31
65
  /**
32
66
  * A JSON parse failure without the parser's message, which quotes the text around the fault and
33
67
  * so can quote a secret.
@@ -37,9 +71,11 @@ export function jsonSyntaxError(error) {
37
71
  return at ? `not valid JSON (line ${at[1]}, column ${at[2]})` : "not valid JSON";
38
72
  }
39
73
  /**
40
- * The text ccdesk shows for a file reached as an instruction, import, memory or memory backup. A
74
+ * The text ccdesk shows for a file reached as an instruction, import, memory, agent or skill. A
41
75
  * 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.
76
+ * headers values, so anything that parses as JSON is shown parsed with them hidden. Frontmatter can
77
+ * hold the same values, in a subagent's inline MCP servers (docs: sub-agents#scope-mcp-servers-to-a-subagent),
78
+ * so frontmatter that holds any is shown re-written with them hidden.
43
79
  */
44
80
  export function shownText(text) {
45
81
  if (text === null)
@@ -49,12 +85,28 @@ export function shownText(text) {
49
85
  data = JSON.parse(text);
50
86
  }
51
87
  catch {
52
- return text;
88
+ return shownFrontmatter(text);
53
89
  }
54
90
  return typeof data === "object" && data !== null
55
91
  ? `${JSON.stringify(redactSecrets(data), null, 2)}\n`
56
92
  : text;
57
93
  }
94
+ function shownFrontmatter(text) {
95
+ const match = FRONTMATTER.exec(text);
96
+ if (!match)
97
+ return text;
98
+ let data;
99
+ try {
100
+ data = parseYaml(match[1] ?? "");
101
+ }
102
+ catch {
103
+ return text;
104
+ }
105
+ const redacted = redactSecrets(data);
106
+ if (JSON.stringify(redacted) === JSON.stringify(data))
107
+ return text;
108
+ return `---\n${stringifyYaml(redacted)}---\n${text.slice(match[0].length)}`;
109
+ }
58
110
  /** A file named .json that does not parse is not shown: its text could still hold a secret. */
59
111
  function shownJsonFile(text) {
60
112
  try {
@@ -126,17 +178,17 @@ export async function listDir(path) {
126
178
  return [];
127
179
  }
128
180
  }
129
- export async function readTextFile(path) {
181
+ export async function readTextFile(env, path) {
130
182
  try {
131
183
  if (await linksToNetwork(path))
132
184
  return null;
133
- if (await leadsToSecret(path))
134
- return null;
135
185
  const real = await realpath(path);
136
186
  const info = await stat(path);
137
187
  if (!info.isFile())
138
188
  return null;
139
- const handle = await open(path, "r");
189
+ const handle = await openUnlessCredentials(env, path);
190
+ if (!handle)
191
+ return null;
140
192
  let text;
141
193
  try {
142
194
  const buffer = Buffer.alloc(Math.min(info.size, MAX_TEXT_BYTES));
@@ -170,25 +222,34 @@ export async function resolvedPath(path) {
170
222
  return path;
171
223
  }
172
224
  }
173
- export async function readJsonObject(path) {
225
+ /** A JSON object file; `emptyIsObject` reads an empty file as `{}`. */
226
+ export async function readJsonObject(env, path, { emptyIsObject = false } = {}) {
174
227
  let raw;
175
228
  try {
176
- if (await leadsToSecret(path))
229
+ const handle = await openUnlessCredentials(env, path);
230
+ if (!handle)
177
231
  return { ok: false, missing: false, error: "ccdesk does not read credentials" };
178
- raw = await readFile(path, "utf8");
232
+ try {
233
+ raw = await handle.readFile("utf8");
234
+ }
235
+ finally {
236
+ await handle.close();
237
+ }
179
238
  }
180
239
  catch (error) {
181
240
  const missing = error.code === "ENOENT";
182
241
  return { ok: false, missing, error: String(error) };
183
242
  }
243
+ if (emptyIsObject && raw.trim() === "")
244
+ return { ok: true, data: {} };
184
245
  try {
185
246
  const data = JSON.parse(raw);
186
247
  if (!isRecord(data))
187
- return { ok: false, missing: false, error: "not a JSON object" };
248
+ return { ok: false, missing: false, unparsed: true, error: "not a JSON object" };
188
249
  return { ok: true, data };
189
250
  }
190
251
  catch (error) {
191
- return { ok: false, missing: false, error: jsonSyntaxError(error) };
252
+ return { ok: false, missing: false, unparsed: true, error: jsonSyntaxError(error) };
192
253
  }
193
254
  }
194
255
  export function isRecord(value) {
@@ -215,31 +276,58 @@ export function shownUrl(url) {
215
276
  return url.replace(/\/\/[^/@]*@/, "//•••@").replace(/\?.*$/, "?•••");
216
277
  }
217
278
  }
279
+ /** A flag whose value is a secret, such as `--token` or `--api-key`. */
280
+ const SECRET_FLAG = /^--(token|api[-_]?key|apikey|password|secret|auth|key)$/i;
281
+ /** A value that starts like a common API key or token. */
282
+ const KEY_PREFIX = /^(sk-|ghp_|gho_|ghu_|ghs_|ghr_|github_pat_|xox[bp]-|glpat-|AKIA[0-9A-Z]{16})/;
283
+ /**
284
+ * A server's command-line arguments as ccdesk shows them: the value after a secret flag, given as
285
+ * `--token value` or `--token=value`, a value that starts like a known key, and the credentials in
286
+ * a URL are hidden.
287
+ */
288
+ export function shownArgs(args) {
289
+ return args.map((arg, i) => {
290
+ if (typeof arg !== "string")
291
+ return arg;
292
+ const previous = args[i - 1];
293
+ if (typeof previous === "string" && SECRET_FLAG.test(previous) && !arg.startsWith("-"))
294
+ return "•••";
295
+ const equals = arg.indexOf("=");
296
+ if (equals > 0) {
297
+ const name = arg.slice(0, equals);
298
+ if (SECRET_FLAG.test(name) || KEY_PREFIX.test(arg.slice(equals + 1)))
299
+ return `${name}=•••`;
300
+ }
301
+ if (KEY_PREFIX.test(arg))
302
+ return "•••";
303
+ if (/^https?:\/\//i.test(arg))
304
+ return shownUrl(arg);
305
+ return arg;
306
+ });
307
+ }
218
308
  /**
219
309
  * 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`.
310
+ * values, the user, password and query of a `url`, and secret values in the `args` of a command.
221
311
  */
222
312
  export function redactSecrets(value) {
223
313
  if (Array.isArray(value))
224
314
  return value.map(redactSecrets);
225
315
  if (!isRecord(value))
226
316
  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;
317
+ // Built with fromEntries: assigning a key named __proto__ would set the prototype instead.
318
+ return Object.fromEntries(Object.entries(value).map(([key, inner]) => {
319
+ if ((key === "env" || key === "headers") && isRecord(inner))
320
+ return [key, Object.fromEntries(Object.keys(inner).map((k) => [k, "•••"]))];
321
+ if (key === "url" && typeof inner === "string")
322
+ return [key, shownUrl(inner)];
323
+ if (key === "args" && "command" in value && Array.isArray(inner))
324
+ return [key, shownArgs(inner)];
325
+ return [key, redactSecrets(inner)];
326
+ }));
240
327
  }
328
+ const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
241
329
  export function parseFrontmatter(text) {
242
- const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
330
+ const match = FRONTMATTER.exec(text);
243
331
  if (!match)
244
332
  return { data: {}, body: text };
245
333
  try {
@@ -1,17 +1,19 @@
1
+ import { lstat } from "node:fs/promises";
1
2
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { storeDirName } from "../../shared/memory.js";
2
4
  import {} from "../../shared/types.js";
3
5
  import { buildContext } from "./context.js";
4
- import {} from "./env.js";
6
+ import { isConfigHome } from "./env.js";
5
7
  import { gitRoot, isDir, isFile, isRecord, mainWorktreeRoot, readJsonObject, stringsIn, visibilityOf, } from "./files.js";
6
8
  import { AGENTS_MD_MODES, resolveInstructions } from "./instructions.js";
7
9
  import { readInventory } from "./inventory.js";
8
- import { readMemoryStore, storeDirName } from "./memory.js";
10
+ import { readMemoryStore } from "./memory.js";
9
11
  import { permissionsOf, trustWaits } from "./permissions.js";
10
12
  import { listProjects } from "./projects.js";
11
13
  import { effectiveSettings, readSettingsLayers } from "./settings.js";
12
14
  import { builtinTools } from "./tools.js";
13
15
  async function readClaudeJson(env) {
14
- const result = await readJsonObject(env.claudeJson);
16
+ const result = await readJsonObject(env, env.claudeJson);
15
17
  return result.ok ? result.data : null;
16
18
  }
17
19
  export async function scanOverview(env, launchDir) {
@@ -48,7 +50,7 @@ export async function scanOverview(env, launchDir) {
48
50
  };
49
51
  }
50
52
  /** Everything Claude Code reads when started in `dir`. */
51
- export async function scanDirectory(env, dir) {
53
+ export async function scanDirectory(env, dir, profile = "interactive") {
52
54
  const path = resolve(dir);
53
55
  const exists = await isDir(path);
54
56
  const claudeJson = await readClaudeJson(env);
@@ -80,6 +82,7 @@ export async function scanDirectory(env, dir) {
80
82
  layers: settings,
81
83
  claudeJson,
82
84
  trust,
85
+ profile,
83
86
  });
84
87
  // Every project file that shapes Claude here, so the UI can say who sees each one.
85
88
  const mcpJson = join(path, ".mcp.json");
@@ -114,19 +117,33 @@ export async function scanDirectory(env, dir) {
114
117
  sharing.ignored.push(rel);
115
118
  }
116
119
  }
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
+ // A local file tracked in git, or in a .claude that is a link, counts as the repository's
121
+ // (docs: permissions#when-your-local-settings-file-needs-trust).
122
+ const localFromRepository = (await Promise.all(settings
123
+ .filter((l) => l.scope === "local" && l.exists)
124
+ .map(async (l) => l.visibility === "tracked" ||
125
+ ((await lstat(dirname(l.path)).catch(() => null))?.isSymbolicLink() ?? false)))).some(Boolean);
126
+ const trustOptions = {
127
+ trust,
128
+ inConfigHome: isConfigHome(env, path),
129
+ localFromRepository,
130
+ profile,
131
+ };
120
132
  const memory = await readMemory(env, mainRoot, path, effective, trustOptions);
121
133
  const permissions = permissionsOf(settings, trustOptions);
122
134
  for (const l of settings)
123
135
  if (l.error)
124
- problems.push({ path: l.path, error: l.error });
125
- const mcp = await readJsonObject(mcpJson);
136
+ problems.push({
137
+ path: l.path,
138
+ error: l.error,
139
+ ...(l.blocksStart ? { blocksStart: true } : {}),
140
+ });
141
+ const mcp = await readJsonObject(env, mcpJson);
126
142
  if (!mcp.ok && !mcp.missing)
127
143
  problems.push({ path: mcpJson, error: mcp.error });
128
144
  return {
129
145
  path,
146
+ profile,
130
147
  exists,
131
148
  gitRoot: root,
132
149
  agentsMdMode: mode,
@@ -150,10 +167,6 @@ export async function scanDirectory(env, dir) {
150
167
  sharing,
151
168
  };
152
169
  }
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
170
  /**
158
171
  * Your answers to Claude Code's trust dialogs, from ~/.claude.json (docs: permissions#project-allow-rules-and-workspace-trust).
159
172
  * In a repository the key is the main checkout's root; outside one it is the folder, and a trusted
@@ -193,7 +206,8 @@ async function readMemory(env, mainRoot, path, effective, trustOptions) {
193
206
  const setting = effective.find((s) => s.key === "autoMemoryDirectory");
194
207
  // A value from a repository file counts once the folder is trusted, like hooks (docs:
195
208
  // settings-reference#automemorydirectory); until then the next file down that sets it decides.
196
- const waitsForTrust = trustWaits(trustOptions);
209
+ // claude -p and the SDK treat the folder as trusted for hooks, so for this too.
210
+ const waitsForTrust = trustOptions.profile === "print" ? () => false : trustWaits(trustOptions);
197
211
  const candidates = setting
198
212
  ? [{ scope: setting.from[0], value: setting.value }, ...setting.overridden]
199
213
  : [];
@@ -202,7 +216,7 @@ async function readMemory(env, mainRoot, path, effective, trustOptions) {
202
216
  const customDir = typeof custom?.value === "string" ? expandHome(env, custom.value) : null;
203
217
  const storeName = env.projectDirName ?? storeDirName(key);
204
218
  const dir = customDir ?? join(env.claudeDir, "projects", storeName, "memory");
205
- const store = (await readMemoryStore(dir, key)) ?? {
219
+ const store = (await readMemoryStore(env, dir, key)) ?? {
206
220
  dir,
207
221
  loaded: true,
208
222
  projectPath: key,
@@ -33,7 +33,7 @@ export async function resolveInstructions(options) {
33
33
  const { env, cwd, mode, excludes, externalImports, managedClaudeMd, problems } = options;
34
34
  /** A file that exists but cannot be read is reported, not taken for a missing one. */
35
35
  const readInstruction = async (path) => {
36
- const file = await readTextFile(path);
36
+ const file = await readTextFile(env, path);
37
37
  if (file || !(await stat(path).catch(() => null))?.isFile())
38
38
  return file;
39
39
  const denied = await access(path, constants.R_OK).then(() => null, (error) => error.code ?? String(error));
@@ -43,7 +43,7 @@ export async function resolveInstructions(options) {
43
43
  };
44
44
  const ctx = {
45
45
  realCwd: await resolvedPath(resolve(cwd)),
46
- home: env.home,
46
+ env,
47
47
  approved: externalImports === "approved",
48
48
  };
49
49
  const managedOnly = mode === "managed-only";
@@ -150,8 +150,9 @@ export async function resolveInstructions(options) {
150
150
  if (file.kind === "rule" && file.realPath && !isWithin(ctx.realCwd, file.realPath)) {
151
151
  if (!ctx.approved)
152
152
  skip(file, `${waiting}; this rule links outside it`);
153
+ // docs: memory#share-rules-across-projects-with-symlinks
153
154
  else if (file.paths)
154
- skip(file, "Links outside this project, so its paths field is not used");
155
+ skip(file, "Links outside this project, and such a rule loads only without a paths field");
155
156
  }
156
157
  }
157
158
  addChecks(out);
@@ -239,7 +240,7 @@ async function collectImports(file, ctx, readExternal) {
239
240
  if (hop >= MAX_IMPORT_HOPS)
240
241
  return;
241
242
  for (const raw of importPaths(source.text)) {
242
- const target = expandImport(raw, dirname(source.path), ctx.home);
243
+ const target = expandImport(raw, dirname(source.path), ctx.env.home);
243
244
  if (isNetworkPath(target))
244
245
  continue;
245
246
  const real = await resolvedPath(target);
@@ -247,7 +248,7 @@ async function collectImports(file, ctx, readExternal) {
247
248
  continue;
248
249
  seen.add(real);
249
250
  const external = !isWithin(ctx.realCwd, real);
250
- const imported = !external || readExternal ? await readTextFile(target) : null;
251
+ const imported = !external || readExternal ? await readTextFile(ctx.env, target) : null;
251
252
  const ref = {
252
253
  raw,
253
254
  resolved: real,
@@ -271,10 +272,13 @@ function expandImport(raw, baseDir, home) {
271
272
  return join(home, unescaped.slice(2));
272
273
  return isAbsolute(unescaped) ? unescaped : resolve(baseDir, unescaped);
273
274
  }
274
- /** `@path` references outside code spans and fences; a backslash keeps a space in the path. */
275
+ /**
276
+ * `@path` references outside code spans and fences; a backslash keeps a space in the path. A code
277
+ * span opens with a run of backticks and closes at the next run of the same length.
278
+ */
275
279
  function importPaths(text) {
276
280
  const withoutFences = text.replace(/^(```|~~~)[\s\S]*?^\1/gm, "");
277
- const withoutSpans = withoutFences.replace(/`[^`\n]*`/g, "");
281
+ const withoutSpans = withoutFences.replace(/(?<!`)(`+)(?!`)[\s\S]*?(?<!`)\1(?!`)/g, "");
278
282
  const found = [];
279
283
  for (const match of withoutSpans.matchAll(/(?:^|\s)@((?:[^\s\\]|\\ )+)/g)) {
280
284
  const path = match[1]?.replace(/[.,;:!?)\]]+$/, "");