ccdesk 0.0.0-stage → 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.
- package/CHANGELOG.md +90 -0
- package/LICENSE +21 -0
- package/README.md +131 -2
- package/dist/server/agent-edit.js +77 -0
- package/dist/server/backups.js +303 -0
- package/dist/server/cli.js +105 -0
- package/dist/server/mcp-edit.js +263 -0
- package/dist/server/mutations.js +193 -0
- package/dist/server/open.js +139 -0
- package/dist/server/scan/compare.js +34 -0
- package/dist/server/scan/context.js +140 -0
- package/dist/server/scan/env.js +28 -0
- package/dist/server/scan/files.js +478 -0
- package/dist/server/scan/index.js +280 -0
- package/dist/server/scan/instructions.js +289 -0
- package/dist/server/scan/inventory.js +452 -0
- package/dist/server/scan/mcp.js +313 -0
- package/dist/server/scan/memory.js +90 -0
- package/dist/server/scan/path-test.js +121 -0
- package/dist/server/scan/permissions.js +101 -0
- package/dist/server/scan/plugins.js +124 -0
- package/dist/server/scan/projects.js +101 -0
- package/dist/server/scan/sandbox.js +161 -0
- package/dist/server/scan/settings.js +330 -0
- package/dist/server/scan/tools.js +111 -0
- package/dist/server/scan/usage.js +514 -0
- package/dist/server/schema.js +15 -0
- package/dist/server/secrets.js +63 -0
- package/dist/server/server.js +352 -0
- package/dist/server/settings-edit.js +221 -0
- package/dist/server/watch.js +214 -0
- package/dist/server/write.js +233 -0
- package/dist/shared/builtin-tools.json +235 -0
- 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 +20 -0
- package/dist/shared/settings-scopes.json +208 -0
- package/dist/shared/settings.schema.json +4623 -0
- package/dist/shared/types.js +2 -0
- package/dist/ui/assets/hanken-grotesk-cyrillic-ext-wght-normal-D_UHSL_T.woff2 +0 -0
- package/dist/ui/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
- package/dist/ui/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
- package/dist/ui/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
- package/dist/ui/assets/index-BQ3Lhx8z.js +60 -0
- package/dist/ui/assets/index-BRgGlpjd.css +1 -0
- package/dist/ui/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
- package/dist/ui/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/dist/ui/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/dist/ui/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist/ui/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/ui/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/dist/ui/index.html +15 -0
- package/dist/ui/logo.png +0 -0
- package/package.json +86 -4
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { storeDirName } from "../../shared/memory.js";
|
|
4
|
+
import {} from "../../shared/types.js";
|
|
5
|
+
import { buildContext } from "./context.js";
|
|
6
|
+
import { isConfigHome } from "./env.js";
|
|
7
|
+
import { gitRoot, isDir, isFile, isRecord, mainWorktreeRoot, readJsonObject, stringsIn, visibilityOf, } from "./files.js";
|
|
8
|
+
import { AGENTS_MD_MODES, resolveInstructions } from "./instructions.js";
|
|
9
|
+
import { readInventory } from "./inventory.js";
|
|
10
|
+
import { readMemoryStore } from "./memory.js";
|
|
11
|
+
import { permissionsOf, trustWaits } from "./permissions.js";
|
|
12
|
+
import { listProjects } from "./projects.js";
|
|
13
|
+
import { effectiveSettings, readSettingsLayers } from "./settings.js";
|
|
14
|
+
import { builtinTools } from "./tools.js";
|
|
15
|
+
async function readClaudeJson(env) {
|
|
16
|
+
const result = await readJsonObject(env, env.claudeJson);
|
|
17
|
+
return result.ok ? result.data : null;
|
|
18
|
+
}
|
|
19
|
+
export async function scanOverview(env, launchDir) {
|
|
20
|
+
const claudeJson = await readClaudeJson(env);
|
|
21
|
+
const projects = await listProjects(env, claudeJson, launchDir);
|
|
22
|
+
const hashesByName = new Map();
|
|
23
|
+
const memoryCatalog = [];
|
|
24
|
+
for (const project of projects) {
|
|
25
|
+
for (const memory of project.store?.memories ?? []) {
|
|
26
|
+
memoryCatalog.push({
|
|
27
|
+
projectPath: project.path,
|
|
28
|
+
path: memory.path,
|
|
29
|
+
name: memory.name,
|
|
30
|
+
description: memory.description,
|
|
31
|
+
type: memory.type,
|
|
32
|
+
});
|
|
33
|
+
const list = hashesByName.get(memory.name) ?? [];
|
|
34
|
+
list.push({ projectPath: project.path, hash: memory.hash });
|
|
35
|
+
hashesByName.set(memory.name, list);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
generatedAt: new Date().toISOString(),
|
|
40
|
+
home: env.home,
|
|
41
|
+
claudeDir: env.claudeDir,
|
|
42
|
+
launchDir: resolve(launchDir),
|
|
43
|
+
platform: env.platform,
|
|
44
|
+
projects: projects.map(({ store: _store, ...summary }) => summary),
|
|
45
|
+
sharedMemories: [...hashesByName.entries()]
|
|
46
|
+
.filter(([, stores]) => stores.length > 1)
|
|
47
|
+
.map(([name, stores]) => ({ name, stores }))
|
|
48
|
+
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
49
|
+
memoryCatalog,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Everything Claude Code reads when started in `dir`. */
|
|
53
|
+
export async function scanDirectory(env, dir, profile = "interactive") {
|
|
54
|
+
const path = resolve(dir);
|
|
55
|
+
const exists = await isDir(path);
|
|
56
|
+
const claudeJson = await readClaudeJson(env);
|
|
57
|
+
const root = exists ? await gitRoot(path) : null;
|
|
58
|
+
const settings = await readSettingsLayers(env, path);
|
|
59
|
+
const effective = effectiveSettings(settings);
|
|
60
|
+
const mode = agentsMdMode(settings);
|
|
61
|
+
const excludes = effective.find((s) => s.key === "claudeMdExcludes")?.value;
|
|
62
|
+
const mainRoot = exists ? await mainWorktreeRoot(path) : null;
|
|
63
|
+
const trust = trustOf(env, claudeJson, path, mainRoot ?? root);
|
|
64
|
+
const managedLayer = settings.find((l) => l.scope === "managed");
|
|
65
|
+
const managedClaudeMd = managedLayer?.data?.claudeMd;
|
|
66
|
+
const problems = [];
|
|
67
|
+
const instructions = await resolveInstructions({
|
|
68
|
+
env,
|
|
69
|
+
cwd: path,
|
|
70
|
+
mode,
|
|
71
|
+
excludes: stringsIn(excludes),
|
|
72
|
+
externalImports: trust.externalImports,
|
|
73
|
+
managedClaudeMd: managedLayer && typeof managedClaudeMd === "string"
|
|
74
|
+
? { path: `${managedLayer.path}#claudeMd`, text: managedClaudeMd }
|
|
75
|
+
: null,
|
|
76
|
+
problems,
|
|
77
|
+
});
|
|
78
|
+
const inventory = await readInventory({
|
|
79
|
+
env,
|
|
80
|
+
projectDir: path,
|
|
81
|
+
gitRoot: root,
|
|
82
|
+
layers: settings,
|
|
83
|
+
claudeJson,
|
|
84
|
+
trust,
|
|
85
|
+
profile,
|
|
86
|
+
});
|
|
87
|
+
// Every project file that shapes Claude here, so the UI can say who sees each one.
|
|
88
|
+
const mcpJson = join(path, ".mcp.json");
|
|
89
|
+
const projectFiles = [
|
|
90
|
+
...instructions.filter((i) => i.scope === "project" || i.scope === "local").map((i) => i.path),
|
|
91
|
+
...settings
|
|
92
|
+
.filter((s) => s.exists && (s.scope === "project" || s.scope === "local"))
|
|
93
|
+
.map((s) => s.path),
|
|
94
|
+
...((await isFile(mcpJson)) ? [mcpJson] : []),
|
|
95
|
+
...[...inventory.agents, ...inventory.skills, ...inventory.commands, ...inventory.outputStyles]
|
|
96
|
+
.filter((e) => e.scope === "project" || e.scope === "local")
|
|
97
|
+
.map((e) => e.path),
|
|
98
|
+
];
|
|
99
|
+
const visibility = await visibilityOf(root, projectFiles);
|
|
100
|
+
for (const item of [...instructions, ...settings]) {
|
|
101
|
+
const v = visibility.get(item.path);
|
|
102
|
+
if (v)
|
|
103
|
+
item.visibility = v;
|
|
104
|
+
}
|
|
105
|
+
let sharing = null;
|
|
106
|
+
if (root) {
|
|
107
|
+
sharing = { tracked: [], untracked: [], ignored: [] };
|
|
108
|
+
for (const file of new Set(projectFiles)) {
|
|
109
|
+
const v = visibility.get(file);
|
|
110
|
+
// Shown as repository paths, with forward slashes on every platform.
|
|
111
|
+
const rel = relative(root, file).replaceAll("\\", "/");
|
|
112
|
+
if (v === "tracked")
|
|
113
|
+
sharing.tracked.push(rel);
|
|
114
|
+
else if (v === "untracked")
|
|
115
|
+
sharing.untracked.push(rel);
|
|
116
|
+
else if (v === "ignored")
|
|
117
|
+
sharing.ignored.push(rel);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
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
|
+
};
|
|
132
|
+
const memory = await readMemory(env, mainRoot, path, effective, trustOptions);
|
|
133
|
+
const permissions = permissionsOf(settings, trustOptions);
|
|
134
|
+
for (const l of settings)
|
|
135
|
+
if (l.error)
|
|
136
|
+
problems.push({
|
|
137
|
+
path: l.path,
|
|
138
|
+
error: l.error,
|
|
139
|
+
...(l.blocksStart ? { blocksStart: true } : {}),
|
|
140
|
+
});
|
|
141
|
+
const mcp = await readJsonObject(env, mcpJson);
|
|
142
|
+
if (!mcp.ok && !mcp.missing)
|
|
143
|
+
problems.push({ path: mcpJson, error: mcp.error });
|
|
144
|
+
return {
|
|
145
|
+
path,
|
|
146
|
+
profile,
|
|
147
|
+
exists,
|
|
148
|
+
gitRoot: root,
|
|
149
|
+
agentsMdMode: mode,
|
|
150
|
+
instructions,
|
|
151
|
+
settings,
|
|
152
|
+
effective,
|
|
153
|
+
memory,
|
|
154
|
+
inventory,
|
|
155
|
+
context: await buildContext({
|
|
156
|
+
env,
|
|
157
|
+
projectRoot: root ?? path,
|
|
158
|
+
instructions,
|
|
159
|
+
memory,
|
|
160
|
+
inventory,
|
|
161
|
+
effective,
|
|
162
|
+
}),
|
|
163
|
+
permissions,
|
|
164
|
+
tools: builtinTools(permissions, inventory.hooks),
|
|
165
|
+
trust,
|
|
166
|
+
problems,
|
|
167
|
+
sharing,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Your answers to Claude Code's trust dialogs, from ~/.claude.json (docs: permissions#project-allow-rules-and-workspace-trust).
|
|
172
|
+
* In a repository the key is the main checkout's root; outside one it is the folder, and a trusted
|
|
173
|
+
* parent folder covers it. In your home directory trust lasts one session and is never saved.
|
|
174
|
+
*/
|
|
175
|
+
function trustOf(env, claudeJson, path, repositoryRoot) {
|
|
176
|
+
const projects = isRecord(claudeJson?.projects) ? claudeJson.projects : {};
|
|
177
|
+
const entry = (key) => (isRecord(projects[key]) ? projects[key] : {});
|
|
178
|
+
const root = repositoryRoot ?? path;
|
|
179
|
+
const own = entry(root);
|
|
180
|
+
const trust = {
|
|
181
|
+
root,
|
|
182
|
+
trusted: own.hasTrustDialogAccepted === true,
|
|
183
|
+
sessionOnly: path === env.home,
|
|
184
|
+
// These two keys are not documented; they are what Claude Code writes for the import dialog.
|
|
185
|
+
externalImports: own.hasClaudeMdExternalIncludesApproved === true
|
|
186
|
+
? "approved"
|
|
187
|
+
: own.hasClaudeMdExternalIncludesWarningShown === true
|
|
188
|
+
? "declined"
|
|
189
|
+
: "not-asked",
|
|
190
|
+
};
|
|
191
|
+
if (!trust.trusted && !repositoryRoot)
|
|
192
|
+
for (let d = dirname(path); d !== dirname(d); d = dirname(d))
|
|
193
|
+
if (entry(d).hasTrustDialogAccepted === true) {
|
|
194
|
+
trust.inheritedFrom = d;
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
return trust;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* The auto memory Claude Code uses here (docs: memory#storage-location, settings-reference#automemoryenabled).
|
|
201
|
+
* Worktrees and subdirectories of one repository share one store. The store is returned even
|
|
202
|
+
* when its folder does not exist yet, so a note about memory being off still shows.
|
|
203
|
+
*/
|
|
204
|
+
async function readMemory(env, mainRoot, path, effective, trustOptions) {
|
|
205
|
+
const key = mainRoot ?? path;
|
|
206
|
+
const setting = effective.find((s) => s.key === "autoMemoryDirectory");
|
|
207
|
+
// A value from a repository file counts once the folder is trusted, like hooks (docs:
|
|
208
|
+
// settings-reference#automemorydirectory); until then the next file down that sets it decides.
|
|
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);
|
|
211
|
+
const candidates = setting
|
|
212
|
+
? [{ scope: setting.from[0], value: setting.value }, ...setting.overridden]
|
|
213
|
+
: [];
|
|
214
|
+
const waiting = candidates.find((c) => c.scope && waitsForTrust(c.scope));
|
|
215
|
+
const custom = candidates.find((c) => c.scope && !waitsForTrust(c.scope));
|
|
216
|
+
const customDir = typeof custom?.value === "string" ? expandHome(env, custom.value) : null;
|
|
217
|
+
const storeName = env.projectDirName ?? storeDirName(key);
|
|
218
|
+
const dir = customDir ?? join(env.claudeDir, "projects", storeName, "memory");
|
|
219
|
+
const store = (await readMemoryStore(env, dir, key)) ?? {
|
|
220
|
+
dir,
|
|
221
|
+
loaded: true,
|
|
222
|
+
projectPath: key,
|
|
223
|
+
index: null,
|
|
224
|
+
memories: [],
|
|
225
|
+
};
|
|
226
|
+
if (customDir)
|
|
227
|
+
store.custom = true;
|
|
228
|
+
const disable = env.disableAutoMemory;
|
|
229
|
+
const enabled = effective.find((s) => s.key === "autoMemoryEnabled");
|
|
230
|
+
const fromRepository = custom?.scope === "project" || (custom?.scope === "local" && trustOptions.localFromRepository);
|
|
231
|
+
if (disable === "1" || disable === "true") {
|
|
232
|
+
store.loaded = false;
|
|
233
|
+
store.note = "CLAUDE_CODE_DISABLE_AUTO_MEMORY turns auto memory off in this shell";
|
|
234
|
+
}
|
|
235
|
+
else if (disable === undefined && enabled?.value === false) {
|
|
236
|
+
store.loaded = false;
|
|
237
|
+
store.note = `autoMemoryEnabled is false in ${enabled.from.join(", ")} settings`;
|
|
238
|
+
}
|
|
239
|
+
else if (customDir && custom && fromRepository && blocksOutsideReads(effective)) {
|
|
240
|
+
// docs: settings-reference#permissions-blockreadsoutsideworkingdirectories
|
|
241
|
+
store.loaded = false;
|
|
242
|
+
store.note =
|
|
243
|
+
"autoMemoryDirectory comes from repository settings and blockReadsOutsideWorkingDirectories is on, so Claude Code loads no auto memory from it";
|
|
244
|
+
}
|
|
245
|
+
else if (customDir && custom) {
|
|
246
|
+
store.note = `autoMemoryDirectory in ${custom.scope} settings points here; ccdesk only changes stores under ~/.claude/projects`;
|
|
247
|
+
}
|
|
248
|
+
else if (waiting) {
|
|
249
|
+
store.note = `autoMemoryDirectory in ${waiting.scope} settings takes over once you trust this folder; until then Claude Code uses this store`;
|
|
250
|
+
}
|
|
251
|
+
else if (env.projectDirName) {
|
|
252
|
+
store.note = `CLAUDE_CODE_PROJECT_DIR_NAME names this store "${env.projectDirName}" in every project`;
|
|
253
|
+
}
|
|
254
|
+
return store;
|
|
255
|
+
}
|
|
256
|
+
function blocksOutsideReads(effective) {
|
|
257
|
+
const permissions = effective.find((s) => s.key === "permissions")?.value;
|
|
258
|
+
return isRecord(permissions) && permissions.blockReadsOutsideWorkingDirectories === true;
|
|
259
|
+
}
|
|
260
|
+
/** autoMemoryDirectory takes an absolute or ~/ path; anything else is not a valid value. */
|
|
261
|
+
function expandHome(env, path) {
|
|
262
|
+
if (path.startsWith("~/"))
|
|
263
|
+
return join(env.home, path.slice(2));
|
|
264
|
+
return isAbsolute(path) ? path : null;
|
|
265
|
+
}
|
|
266
|
+
/** Read from user, --settings or managed settings only; project and local files are ignored for it. */
|
|
267
|
+
function agentsMdMode(layers) {
|
|
268
|
+
for (const scope of ["managed", "user"]) {
|
|
269
|
+
const configs = layers.find((l) => l.scope === scope)?.data?.pluginConfigs;
|
|
270
|
+
if (!isRecord(configs))
|
|
271
|
+
continue;
|
|
272
|
+
for (const id of ["cc-plugin-agents-md@builtin", "agents-md@builtin"]) {
|
|
273
|
+
const entry = configs[id];
|
|
274
|
+
const value = isRecord(entry) && isRecord(entry.options) ? entry.options.instructionFiles : undefined;
|
|
275
|
+
if (typeof value === "string" && AGENTS_MD_MODES.includes(value))
|
|
276
|
+
return value;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return "claude-md-or-agents-md";
|
|
280
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access, stat } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, join, matchesGlob, parse, resolve } from "node:path";
|
|
4
|
+
import {} from "../../shared/types.js";
|
|
5
|
+
import {} from "./env.js";
|
|
6
|
+
import { isFile, isNetworkPath, isWithin, MAX_TEXT_BYTES, parseFrontmatter, readTextFile, resolvedPath, toStringList, walkMarkdown, } from "./files.js";
|
|
7
|
+
const MAX_IMPORT_HOPS = 4;
|
|
8
|
+
export const AGENTS_MD_MODES = [
|
|
9
|
+
"claude-md-or-agents-md",
|
|
10
|
+
"claude-md-and-agents-md",
|
|
11
|
+
"claude-md",
|
|
12
|
+
"managed-only",
|
|
13
|
+
];
|
|
14
|
+
/** Directories from the filesystem root down to `cwd`, the order Claude Code concatenates them in. */
|
|
15
|
+
export function ancestorsRootFirst(cwd) {
|
|
16
|
+
const dirs = [];
|
|
17
|
+
let current = resolve(cwd);
|
|
18
|
+
const { root } = parse(current);
|
|
19
|
+
while (true) {
|
|
20
|
+
dirs.unshift(current);
|
|
21
|
+
if (current === root)
|
|
22
|
+
break;
|
|
23
|
+
current = dirname(current);
|
|
24
|
+
}
|
|
25
|
+
return dirs;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The instruction files Claude Code reads in `cwd`, in load order, each with whether it loads at
|
|
29
|
+
* session start, when Claude works with matching files, or not at all, and why.
|
|
30
|
+
* Follows code.claude.com/docs/en/memory.
|
|
31
|
+
*/
|
|
32
|
+
export async function resolveInstructions(options) {
|
|
33
|
+
const { env, cwd, mode, excludes, externalImports, managedClaudeMd, problems } = options;
|
|
34
|
+
/** A file that exists but cannot be read is reported, not taken for a missing one. */
|
|
35
|
+
const readInstruction = async (path) => {
|
|
36
|
+
const file = await readTextFile(env, path);
|
|
37
|
+
if (file || !(await stat(path).catch(() => null))?.isFile())
|
|
38
|
+
return file;
|
|
39
|
+
const denied = await access(path, constants.R_OK).then(() => null, (error) => error.code ?? String(error));
|
|
40
|
+
if (denied)
|
|
41
|
+
problems.push({ path, error: `cannot be read (${denied})` });
|
|
42
|
+
return null;
|
|
43
|
+
};
|
|
44
|
+
const ctx = {
|
|
45
|
+
realCwd: await resolvedPath(resolve(cwd)),
|
|
46
|
+
env,
|
|
47
|
+
approved: externalImports === "approved",
|
|
48
|
+
};
|
|
49
|
+
const managedOnly = mode === "managed-only";
|
|
50
|
+
const skippedByMode = `Skipped: instruction mode is ${mode}`;
|
|
51
|
+
const out = [];
|
|
52
|
+
const managed = await readInstruction(join(env.managedDir, "CLAUDE.md"));
|
|
53
|
+
if (managed)
|
|
54
|
+
out.push(await instruction(managed, "claude-md", "managed", env.managedDir, ctx, "Organisation policy, always loaded"));
|
|
55
|
+
if (managedClaudeMd) {
|
|
56
|
+
const { path, text } = managedClaudeMd;
|
|
57
|
+
const file = {
|
|
58
|
+
path,
|
|
59
|
+
mtime: "",
|
|
60
|
+
bytes: Buffer.byteLength(text),
|
|
61
|
+
lines: text.split("\n").length,
|
|
62
|
+
text,
|
|
63
|
+
};
|
|
64
|
+
const entry = await instruction(file, "claude-md", "managed", env.managedDir, ctx, "Organisation policy from the claudeMd key in managed settings");
|
|
65
|
+
entry.imports = [];
|
|
66
|
+
out.push(entry);
|
|
67
|
+
}
|
|
68
|
+
const user = await readInstruction(join(env.claudeDir, "CLAUDE.md"));
|
|
69
|
+
if (user)
|
|
70
|
+
out.push(await instruction(user, "claude-md", "user", env.claudeDir, ctx, "Your instructions for every project"));
|
|
71
|
+
for (const path of await walkMarkdown(join(env.claudeDir, "rules"))) {
|
|
72
|
+
const file = await readInstruction(path);
|
|
73
|
+
if (file)
|
|
74
|
+
out.push(await rule(file, "user", env.claudeDir, ctx));
|
|
75
|
+
}
|
|
76
|
+
// Per directory, root first: CLAUDE.md, .claude/CLAUDE.md, rules, then CLAUDE.local.md last
|
|
77
|
+
// ("your personal notes are the last thing Claude reads at that level"), then AGENTS.md.
|
|
78
|
+
const claudeFiles = [];
|
|
79
|
+
const agentsFiles = [];
|
|
80
|
+
for (const dir of ancestorsRootFirst(cwd)) {
|
|
81
|
+
// ~/.claude/CLAUDE.md and ~/.claude/rules are the user files above, not the home's project files.
|
|
82
|
+
const isConfigHome = join(dir, ".claude") === env.claudeDir;
|
|
83
|
+
for (const name of ["CLAUDE.md", ".claude/CLAUDE.md"]) {
|
|
84
|
+
if (isConfigHome && name === ".claude/CLAUDE.md")
|
|
85
|
+
continue;
|
|
86
|
+
const file = await readInstruction(join(dir, name));
|
|
87
|
+
if (file)
|
|
88
|
+
claudeFiles.push(await instruction(file, "claude-md", "project", dir, ctx, ""));
|
|
89
|
+
}
|
|
90
|
+
if (!isConfigHome && dir !== env.claudeDir)
|
|
91
|
+
for (const path of await walkMarkdown(join(dir, ".claude", "rules"))) {
|
|
92
|
+
const file = await readInstruction(path);
|
|
93
|
+
if (file)
|
|
94
|
+
claudeFiles.push(await rule(file, "project", dir, ctx));
|
|
95
|
+
}
|
|
96
|
+
const local = await readInstruction(join(dir, "CLAUDE.local.md"));
|
|
97
|
+
if (local)
|
|
98
|
+
claudeFiles.push(await instruction(local, "claude-local-md", "local", dir, ctx, ""));
|
|
99
|
+
for (const name of ["AGENTS.md", ".claude/AGENTS.md"]) {
|
|
100
|
+
const file = await readInstruction(join(dir, name));
|
|
101
|
+
if (file)
|
|
102
|
+
agentsFiles.push(await instruction(file, "agents-md", "project", dir, ctx, ""));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const hasClaudeMd = claudeFiles.some((f) => f.kind !== "rule");
|
|
106
|
+
for (const file of claudeFiles) {
|
|
107
|
+
if (file.kind === "rule")
|
|
108
|
+
continue;
|
|
109
|
+
file.reason =
|
|
110
|
+
file.kind === "claude-local-md"
|
|
111
|
+
? "Your private notes for this directory"
|
|
112
|
+
: file.dir === resolve(cwd)
|
|
113
|
+
? "Instructions for this directory"
|
|
114
|
+
: "Inherited from a parent directory";
|
|
115
|
+
}
|
|
116
|
+
const includedElsewhere = new Set(claudeFiles.flatMap((f) => [f.realPath ?? f.path, ...f.imports.map((i) => i.resolved)]));
|
|
117
|
+
for (const file of agentsFiles) {
|
|
118
|
+
if (mode === "managed-only" || mode === "claude-md")
|
|
119
|
+
skip(file, skippedByMode);
|
|
120
|
+
else if (includedElsewhere.has(file.realPath ?? file.path))
|
|
121
|
+
skip(file, "Already included through a CLAUDE.md import or symlink");
|
|
122
|
+
else if (mode === "claude-md-and-agents-md")
|
|
123
|
+
file.reason = "Loaded alongside CLAUDE.md (instruction mode claude-md-and-agents-md)";
|
|
124
|
+
else if (hasClaudeMd)
|
|
125
|
+
skip(file, "Ignored: a CLAUDE.md or CLAUDE.local.md exists here or above, so Claude Code reads that instead");
|
|
126
|
+
else
|
|
127
|
+
file.reason = "Loaded because there is no CLAUDE.md here or above";
|
|
128
|
+
}
|
|
129
|
+
for (const dir of ancestorsRootFirst(cwd))
|
|
130
|
+
out.push(...claudeFiles.filter((f) => f.dir === dir), ...agentsFiles.filter((f) => f.dir === dir));
|
|
131
|
+
const waiting = externalImports === "declined"
|
|
132
|
+
? "You declined imports from outside this project"
|
|
133
|
+
: "Waits for you to approve imports from outside this project";
|
|
134
|
+
for (const file of out) {
|
|
135
|
+
if (file.scope === "managed")
|
|
136
|
+
continue;
|
|
137
|
+
// Path-scoped rules still load on demand in managed-only mode (docs: memory#choose-which-instruction-files-load).
|
|
138
|
+
if (managedOnly && file.status === "loaded")
|
|
139
|
+
skip(file, skippedByMode);
|
|
140
|
+
// Managed files cannot be excluded; a linked file is excluded by its own path or its target's
|
|
141
|
+
// (docs: memory#exclude-specific-claude-md-files).
|
|
142
|
+
const excluded = excludes.some((glob) => [file.path, file.realPath].some((p) => p !== undefined && safeMatch(p, glob)));
|
|
143
|
+
if (excluded)
|
|
144
|
+
skip(file, "Skipped by claudeMdExcludes");
|
|
145
|
+
if (file.status !== "skipped" && file.bytes > MAX_TEXT_BYTES)
|
|
146
|
+
skip(file, "Skipped: Claude Code does not load an instruction file over 4 MiB");
|
|
147
|
+
// A project's links outside it need your approval, like its imports; your own files do not.
|
|
148
|
+
if (file.scope !== "project" && file.scope !== "local")
|
|
149
|
+
continue;
|
|
150
|
+
if (file.kind === "rule" && file.realPath && !isWithin(ctx.realCwd, file.realPath)) {
|
|
151
|
+
if (!ctx.approved)
|
|
152
|
+
skip(file, `${waiting}; this rule links outside it`);
|
|
153
|
+
// docs: memory#share-rules-across-projects-with-symlinks
|
|
154
|
+
else if (file.paths)
|
|
155
|
+
skip(file, "Links outside this project, and such a rule loads only without a paths field");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
addChecks(out);
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
function skip(file, reason) {
|
|
162
|
+
if (file.status === "skipped")
|
|
163
|
+
return;
|
|
164
|
+
file.status = "skipped";
|
|
165
|
+
file.reason = reason;
|
|
166
|
+
}
|
|
167
|
+
/** An invalid pattern matches nothing. */
|
|
168
|
+
function safeMatch(path, glob) {
|
|
169
|
+
try {
|
|
170
|
+
return matchesGlob(path, glob);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/** Claude Code's docs suggest keeping each CLAUDE.md under this many lines. */
|
|
177
|
+
const LONG_FILE_LINES = 200;
|
|
178
|
+
function paragraphs(text) {
|
|
179
|
+
return parseFrontmatter(text)
|
|
180
|
+
.body.split(/\n\s*\n/)
|
|
181
|
+
.map((p) => p.trim().replace(/\s+/g, " "))
|
|
182
|
+
.filter((p) => p.length >= 60);
|
|
183
|
+
}
|
|
184
|
+
/** Upkeep notes: files longer than the docs recommend, paragraphs repeated across files, missing imports. */
|
|
185
|
+
function addChecks(files) {
|
|
186
|
+
const seen = new Map();
|
|
187
|
+
for (const file of files.filter((f) => f.status !== "skipped")) {
|
|
188
|
+
if (file.lines > LONG_FILE_LINES)
|
|
189
|
+
file.checks.push(`${file.lines} lines; Claude Code's docs suggest under ${LONG_FILE_LINES} for better adherence`);
|
|
190
|
+
const repeated = new Set();
|
|
191
|
+
for (const paragraph of paragraphs(file.text)) {
|
|
192
|
+
const first = seen.get(paragraph);
|
|
193
|
+
if (first && first !== file)
|
|
194
|
+
repeated.add(first.path);
|
|
195
|
+
else
|
|
196
|
+
seen.set(paragraph, file);
|
|
197
|
+
}
|
|
198
|
+
for (const path of repeated)
|
|
199
|
+
file.checks.push(`Repeats a paragraph from ${path}`);
|
|
200
|
+
// `@name` without a slash or dot is as likely a mention as a path, so only path-like ones count.
|
|
201
|
+
for (const ref of file.imports)
|
|
202
|
+
if (!ref.exists && /[./]/.test(ref.raw))
|
|
203
|
+
file.checks.push(`Imports @${ref.raw}, which does not exist`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
async function rule(file, scope, dir, ctx) {
|
|
207
|
+
const paths = toStringList(parseFrontmatter(file.text).data.paths);
|
|
208
|
+
const entry = await instruction(file, "rule", scope, dir, ctx, paths.length > 0
|
|
209
|
+
? "Loads only when Claude works with matching files"
|
|
210
|
+
: "Rule loaded every session");
|
|
211
|
+
if (paths.length > 0) {
|
|
212
|
+
entry.paths = paths;
|
|
213
|
+
entry.status = "on-match";
|
|
214
|
+
}
|
|
215
|
+
// A rule reached through a linked folder is not a link itself, but its target can be outside.
|
|
216
|
+
const real = await resolvedPath(file.path);
|
|
217
|
+
if (!entry.realPath && !isWithin(ctx.realCwd, real) && !isWithin(await resolvedPath(dir), real))
|
|
218
|
+
entry.realPath = real;
|
|
219
|
+
return entry;
|
|
220
|
+
}
|
|
221
|
+
async function instruction(file, kind, scope, dir, ctx, reason) {
|
|
222
|
+
// Your own files' imports load without asking; a project's imports outside it wait for approval.
|
|
223
|
+
const own = scope === "managed" || scope === "user";
|
|
224
|
+
const imports = await collectImports(file, ctx, own || ctx.approved);
|
|
225
|
+
if (!own && !ctx.approved)
|
|
226
|
+
for (const ref of imports)
|
|
227
|
+
if (ref.external)
|
|
228
|
+
ref.pending = true;
|
|
229
|
+
return { ...file, kind, scope, dir, status: "loaded", reason, imports, checks: [] };
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* `@path` imports, depth first up to four hops, each with the file that imports it. An import
|
|
233
|
+
* outside the project is read only when Claude Code would read it: from your own files, or once
|
|
234
|
+
* you approved the project's external imports.
|
|
235
|
+
*/
|
|
236
|
+
async function collectImports(file, ctx, readExternal) {
|
|
237
|
+
const seen = new Set([file.realPath ?? file.path]);
|
|
238
|
+
const out = [];
|
|
239
|
+
const visit = async (source, via, hop) => {
|
|
240
|
+
if (hop >= MAX_IMPORT_HOPS)
|
|
241
|
+
return;
|
|
242
|
+
for (const raw of importPaths(source.text)) {
|
|
243
|
+
const target = expandImport(raw, dirname(source.path), ctx.env.home);
|
|
244
|
+
if (isNetworkPath(target))
|
|
245
|
+
continue;
|
|
246
|
+
const real = await resolvedPath(target);
|
|
247
|
+
if (seen.has(real))
|
|
248
|
+
continue;
|
|
249
|
+
seen.add(real);
|
|
250
|
+
const external = !isWithin(ctx.realCwd, real);
|
|
251
|
+
const imported = !external || readExternal ? await readTextFile(ctx.env, target) : null;
|
|
252
|
+
const ref = {
|
|
253
|
+
raw,
|
|
254
|
+
resolved: real,
|
|
255
|
+
via,
|
|
256
|
+
exists: imported !== null || (await isFile(target)),
|
|
257
|
+
external,
|
|
258
|
+
};
|
|
259
|
+
if (imported && imported.bytes > MAX_TEXT_BYTES)
|
|
260
|
+
ref.tooLarge = true;
|
|
261
|
+
out.push(ref);
|
|
262
|
+
if (imported)
|
|
263
|
+
await visit(imported, real, hop + 1);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
await visit(file, file.path, 0);
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
function expandImport(raw, baseDir, home) {
|
|
270
|
+
const unescaped = raw.replaceAll("\\ ", " ");
|
|
271
|
+
if (unescaped.startsWith("~/"))
|
|
272
|
+
return join(home, unescaped.slice(2));
|
|
273
|
+
return isAbsolute(unescaped) ? unescaped : resolve(baseDir, unescaped);
|
|
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
|
+
*/
|
|
279
|
+
function importPaths(text) {
|
|
280
|
+
const withoutFences = text.replace(/^(```|~~~)[\s\S]*?^\1/gm, "");
|
|
281
|
+
const withoutSpans = withoutFences.replace(/(?<!`)(`+)(?!`)[\s\S]*?(?<!`)\1(?!`)/g, "");
|
|
282
|
+
const found = [];
|
|
283
|
+
for (const match of withoutSpans.matchAll(/(?:^|\s)@((?:[^\s\\]|\\ )+)/g)) {
|
|
284
|
+
const path = match[1]?.replace(/[.,;:!?)\]]+$/, "");
|
|
285
|
+
if (path)
|
|
286
|
+
found.push(path);
|
|
287
|
+
}
|
|
288
|
+
return found;
|
|
289
|
+
}
|