ccdesk 0.1.0 → 0.2.1
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.
- package/CHANGELOG.md +86 -0
- package/README.md +84 -67
- package/dist/server/agent-edit.js +77 -0
- package/dist/server/backups.js +129 -52
- package/dist/server/cli.js +21 -8
- package/dist/server/mcp-edit.js +263 -0
- package/dist/server/mutations.js +8 -3
- package/dist/server/scan/compare.js +34 -0
- package/dist/server/scan/context.js +6 -5
- package/dist/server/scan/env.js +8 -3
- package/dist/server/scan/files.js +118 -30
- package/dist/server/scan/index.js +29 -15
- package/dist/server/scan/instructions.js +11 -7
- package/dist/server/scan/inventory.js +134 -42
- package/dist/server/scan/mcp.js +88 -30
- package/dist/server/scan/memory.js +8 -9
- package/dist/server/scan/path-test.js +32 -10
- package/dist/server/scan/permissions.js +22 -6
- package/dist/server/scan/plugins.js +18 -11
- package/dist/server/scan/projects.js +7 -6
- package/dist/server/scan/sandbox.js +161 -0
- package/dist/server/scan/settings.js +33 -20
- package/dist/server/scan/tools.js +7 -3
- package/dist/server/scan/usage.js +119 -46
- package/dist/server/schema.js +15 -0
- package/dist/server/secrets.js +63 -0
- package/dist/server/server.js +222 -128
- package/dist/server/settings-edit.js +90 -21
- package/dist/server/watch.js +52 -10
- package/dist/server/write.js +58 -20
- package/dist/shared/compare.js +1 -0
- package/dist/shared/hook-events.json +35 -0
- package/dist/shared/mcp-server.schema.json +25 -0
- package/dist/shared/memory.js +4 -0
- package/dist/shared/scopes.js +3 -1
- package/dist/shared/types.js +1 -1
- package/dist/ui/assets/hanken-grotesk-cyrillic-ext-wght-normal-D_UHSL_T.woff2 +0 -0
- package/dist/ui/assets/index-D5XqP5c_.css +1 -0
- package/dist/ui/assets/index-XqJwI3k9.js +60 -0
- package/dist/ui/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
- package/dist/ui/index.html +2 -2
- package/package.json +10 -4
- package/dist/ui/assets/index-BM4K7hSO.css +0 -1
- 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 `- ${
|
|
40
|
+
return `- ${name}`;
|
|
40
41
|
const text = [s.description, s.whenToUse].filter(Boolean).join(" ");
|
|
41
|
-
return `- ${
|
|
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)
|
package/dist/server/scan/env.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
229
|
+
const handle = await openUnlessCredentials(env, path);
|
|
230
|
+
if (!handle)
|
|
177
231
|
return { ok: false, missing: false, error: "ccdesk does not read credentials" };
|
|
178
|
-
|
|
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,
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
if ((key === "env" || key === "headers") && isRecord(inner))
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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 =
|
|
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
|
|
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
|
-
//
|
|
118
|
-
|
|
119
|
-
const
|
|
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({
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
/**
|
|
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(
|
|
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(/[.,;:!?)\]]+$/, "");
|