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.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +114 -2
- package/dist/server/backups.js +226 -0
- package/dist/server/cli.js +92 -0
- package/dist/server/mutations.js +188 -0
- package/dist/server/open.js +139 -0
- package/dist/server/scan/context.js +139 -0
- package/dist/server/scan/env.js +23 -0
- package/dist/server/scan/files.js +390 -0
- package/dist/server/scan/index.js +266 -0
- package/dist/server/scan/instructions.js +285 -0
- package/dist/server/scan/inventory.js +360 -0
- package/dist/server/scan/mcp.js +255 -0
- package/dist/server/scan/memory.js +91 -0
- package/dist/server/scan/path-test.js +99 -0
- package/dist/server/scan/permissions.js +85 -0
- package/dist/server/scan/plugins.js +117 -0
- package/dist/server/scan/projects.js +100 -0
- package/dist/server/scan/settings.js +317 -0
- package/dist/server/scan/tools.js +107 -0
- package/dist/server/scan/usage.js +441 -0
- package/dist/server/server.js +258 -0
- package/dist/server/settings-edit.js +152 -0
- package/dist/server/watch.js +172 -0
- package/dist/server/write.js +195 -0
- package/dist/shared/builtin-tools.json +235 -0
- package/dist/shared/scopes.js +18 -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-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-BM4K7hSO.css +1 -0
- package/dist/ui/assets/index-CN3vkVjv.js +45 -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 +80 -4
|
@@ -0,0 +1,285 @@
|
|
|
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(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
|
+
home: env.home,
|
|
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
|
+
else if (file.paths)
|
|
154
|
+
skip(file, "Links outside this project, so its paths field is not used");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
addChecks(out);
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
function skip(file, reason) {
|
|
161
|
+
if (file.status === "skipped")
|
|
162
|
+
return;
|
|
163
|
+
file.status = "skipped";
|
|
164
|
+
file.reason = reason;
|
|
165
|
+
}
|
|
166
|
+
/** An invalid pattern matches nothing. */
|
|
167
|
+
function safeMatch(path, glob) {
|
|
168
|
+
try {
|
|
169
|
+
return matchesGlob(path, glob);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** Claude Code's docs suggest keeping each CLAUDE.md under this many lines. */
|
|
176
|
+
const LONG_FILE_LINES = 200;
|
|
177
|
+
function paragraphs(text) {
|
|
178
|
+
return parseFrontmatter(text)
|
|
179
|
+
.body.split(/\n\s*\n/)
|
|
180
|
+
.map((p) => p.trim().replace(/\s+/g, " "))
|
|
181
|
+
.filter((p) => p.length >= 60);
|
|
182
|
+
}
|
|
183
|
+
/** Upkeep notes: files longer than the docs recommend, paragraphs repeated across files, missing imports. */
|
|
184
|
+
function addChecks(files) {
|
|
185
|
+
const seen = new Map();
|
|
186
|
+
for (const file of files.filter((f) => f.status !== "skipped")) {
|
|
187
|
+
if (file.lines > LONG_FILE_LINES)
|
|
188
|
+
file.checks.push(`${file.lines} lines; Claude Code's docs suggest under ${LONG_FILE_LINES} for better adherence`);
|
|
189
|
+
const repeated = new Set();
|
|
190
|
+
for (const paragraph of paragraphs(file.text)) {
|
|
191
|
+
const first = seen.get(paragraph);
|
|
192
|
+
if (first && first !== file)
|
|
193
|
+
repeated.add(first.path);
|
|
194
|
+
else
|
|
195
|
+
seen.set(paragraph, file);
|
|
196
|
+
}
|
|
197
|
+
for (const path of repeated)
|
|
198
|
+
file.checks.push(`Repeats a paragraph from ${path}`);
|
|
199
|
+
// `@name` without a slash or dot is as likely a mention as a path, so only path-like ones count.
|
|
200
|
+
for (const ref of file.imports)
|
|
201
|
+
if (!ref.exists && /[./]/.test(ref.raw))
|
|
202
|
+
file.checks.push(`Imports @${ref.raw}, which does not exist`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async function rule(file, scope, dir, ctx) {
|
|
206
|
+
const paths = toStringList(parseFrontmatter(file.text).data.paths);
|
|
207
|
+
const entry = await instruction(file, "rule", scope, dir, ctx, paths.length > 0
|
|
208
|
+
? "Loads only when Claude works with matching files"
|
|
209
|
+
: "Rule loaded every session");
|
|
210
|
+
if (paths.length > 0) {
|
|
211
|
+
entry.paths = paths;
|
|
212
|
+
entry.status = "on-match";
|
|
213
|
+
}
|
|
214
|
+
// A rule reached through a linked folder is not a link itself, but its target can be outside.
|
|
215
|
+
const real = await resolvedPath(file.path);
|
|
216
|
+
if (!entry.realPath && !isWithin(ctx.realCwd, real) && !isWithin(await resolvedPath(dir), real))
|
|
217
|
+
entry.realPath = real;
|
|
218
|
+
return entry;
|
|
219
|
+
}
|
|
220
|
+
async function instruction(file, kind, scope, dir, ctx, reason) {
|
|
221
|
+
// Your own files' imports load without asking; a project's imports outside it wait for approval.
|
|
222
|
+
const own = scope === "managed" || scope === "user";
|
|
223
|
+
const imports = await collectImports(file, ctx, own || ctx.approved);
|
|
224
|
+
if (!own && !ctx.approved)
|
|
225
|
+
for (const ref of imports)
|
|
226
|
+
if (ref.external)
|
|
227
|
+
ref.pending = true;
|
|
228
|
+
return { ...file, kind, scope, dir, status: "loaded", reason, imports, checks: [] };
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* `@path` imports, depth first up to four hops, each with the file that imports it. An import
|
|
232
|
+
* outside the project is read only when Claude Code would read it: from your own files, or once
|
|
233
|
+
* you approved the project's external imports.
|
|
234
|
+
*/
|
|
235
|
+
async function collectImports(file, ctx, readExternal) {
|
|
236
|
+
const seen = new Set([file.realPath ?? file.path]);
|
|
237
|
+
const out = [];
|
|
238
|
+
const visit = async (source, via, hop) => {
|
|
239
|
+
if (hop >= MAX_IMPORT_HOPS)
|
|
240
|
+
return;
|
|
241
|
+
for (const raw of importPaths(source.text)) {
|
|
242
|
+
const target = expandImport(raw, dirname(source.path), ctx.home);
|
|
243
|
+
if (isNetworkPath(target))
|
|
244
|
+
continue;
|
|
245
|
+
const real = await resolvedPath(target);
|
|
246
|
+
if (seen.has(real))
|
|
247
|
+
continue;
|
|
248
|
+
seen.add(real);
|
|
249
|
+
const external = !isWithin(ctx.realCwd, real);
|
|
250
|
+
const imported = !external || readExternal ? await readTextFile(target) : null;
|
|
251
|
+
const ref = {
|
|
252
|
+
raw,
|
|
253
|
+
resolved: real,
|
|
254
|
+
via,
|
|
255
|
+
exists: imported !== null || (await isFile(target)),
|
|
256
|
+
external,
|
|
257
|
+
};
|
|
258
|
+
if (imported && imported.bytes > MAX_TEXT_BYTES)
|
|
259
|
+
ref.tooLarge = true;
|
|
260
|
+
out.push(ref);
|
|
261
|
+
if (imported)
|
|
262
|
+
await visit(imported, real, hop + 1);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
await visit(file, file.path, 0);
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
268
|
+
function expandImport(raw, baseDir, home) {
|
|
269
|
+
const unescaped = raw.replaceAll("\\ ", " ");
|
|
270
|
+
if (unescaped.startsWith("~/"))
|
|
271
|
+
return join(home, unescaped.slice(2));
|
|
272
|
+
return isAbsolute(unescaped) ? unescaped : resolve(baseDir, unescaped);
|
|
273
|
+
}
|
|
274
|
+
/** `@path` references outside code spans and fences; a backslash keeps a space in the path. */
|
|
275
|
+
function importPaths(text) {
|
|
276
|
+
const withoutFences = text.replace(/^(```|~~~)[\s\S]*?^\1/gm, "");
|
|
277
|
+
const withoutSpans = withoutFences.replace(/`[^`\n]*`/g, "");
|
|
278
|
+
const found = [];
|
|
279
|
+
for (const match of withoutSpans.matchAll(/(?:^|\s)@((?:[^\s\\]|\\ )+)/g)) {
|
|
280
|
+
const path = match[1]?.replace(/[.,;:!?)\]]+$/, "");
|
|
281
|
+
if (path)
|
|
282
|
+
found.push(path);
|
|
283
|
+
}
|
|
284
|
+
return found;
|
|
285
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
2
|
+
import { HIGHEST_FIRST } from "../../shared/scopes.js";
|
|
3
|
+
import {} from "../../shared/types.js";
|
|
4
|
+
import {} from "./env.js";
|
|
5
|
+
import { isDir, isFile, isRecord, isWithin, listDir, parseFrontmatter, readJsonObject, readTextFile, resolvedPath, stringField, toStringList, } from "./files.js";
|
|
6
|
+
import { ancestorsRootFirst } from "./instructions.js";
|
|
7
|
+
import { readMcpServers } from "./mcp.js";
|
|
8
|
+
import { manifestCommandMap, manifestConfigs, manifestPaths, readPlugins } from "./plugins.js";
|
|
9
|
+
import { effectiveSettings } from "./settings.js";
|
|
10
|
+
function isTrue(value) {
|
|
11
|
+
return value === true || (typeof value === "string" && /^(true|yes|on|1)$/i.test(value));
|
|
12
|
+
}
|
|
13
|
+
/** A `.md` file as an agent, command or output style. */
|
|
14
|
+
async function readEntry(path, name, scope, plugin) {
|
|
15
|
+
const file = await readTextFile(path);
|
|
16
|
+
if (!file)
|
|
17
|
+
return null;
|
|
18
|
+
const { data } = parseFrontmatter(file.text);
|
|
19
|
+
const entry = {
|
|
20
|
+
name: stringField(data, "name") || name,
|
|
21
|
+
description: stringField(data, "description"),
|
|
22
|
+
path,
|
|
23
|
+
scope,
|
|
24
|
+
text: file.text,
|
|
25
|
+
};
|
|
26
|
+
const model = stringField(data, "model");
|
|
27
|
+
if (model)
|
|
28
|
+
entry.model = model;
|
|
29
|
+
if (plugin)
|
|
30
|
+
entry.plugin = plugin;
|
|
31
|
+
const real = await resolvedPath(path);
|
|
32
|
+
if (real !== path)
|
|
33
|
+
entry.realPath = real;
|
|
34
|
+
return entry;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* `*.md` files in a folder and its subfolders. A file in a subfolder is named by its path, joined
|
|
38
|
+
* with `separator`: commands use ":" (`frontend/component.md` is `/frontend:component`).
|
|
39
|
+
*/
|
|
40
|
+
async function readFileEntries(dir, scope, { plugin, separator = "/" } = {}, base = dir, depth = 0) {
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const name of await listDir(dir)) {
|
|
43
|
+
if (name.startsWith("."))
|
|
44
|
+
continue;
|
|
45
|
+
const path = join(dir, name);
|
|
46
|
+
if (await isDir(path)) {
|
|
47
|
+
if (depth < 4)
|
|
48
|
+
out.push(...(await readFileEntries(path, scope, { plugin, separator }, base, depth + 1)));
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (!name.endsWith(".md"))
|
|
52
|
+
continue;
|
|
53
|
+
const fallback = relative(base, path).replace(/\.md$/, "").split(/[/\\]/).join(separator);
|
|
54
|
+
const entry = await readEntry(path, fallback, scope, plugin);
|
|
55
|
+
if (entry)
|
|
56
|
+
out.push(entry);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
/** Files or folders of `.md` files. */
|
|
61
|
+
async function readPaths(paths, scope, options) {
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const path of paths) {
|
|
64
|
+
if (await isDir(path))
|
|
65
|
+
out.push(...(await readFileEntries(path, scope, options)));
|
|
66
|
+
else if (path.endsWith(".md")) {
|
|
67
|
+
const entry = await readEntry(path, basename(path, ".md"), scope, options.plugin);
|
|
68
|
+
if (entry)
|
|
69
|
+
out.push(entry);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
export async function readSkill(folder, scope, plugin) {
|
|
75
|
+
const path = join(folder, "SKILL.md");
|
|
76
|
+
const file = await readTextFile(path);
|
|
77
|
+
if (!file)
|
|
78
|
+
return null;
|
|
79
|
+
const { data, body } = parseFrontmatter(file.text);
|
|
80
|
+
const entry = {
|
|
81
|
+
name: stringField(data, "name") || (folder.split(/[/\\]/).pop() ?? ""),
|
|
82
|
+
// Without a description, Claude Code lists the first non-empty line of the body.
|
|
83
|
+
description: stringField(data, "description") ||
|
|
84
|
+
(body.split("\n").find((line) => line.trim() !== "") ?? "").trim(),
|
|
85
|
+
path,
|
|
86
|
+
folder,
|
|
87
|
+
listed: !isTrue(data["disable-model-invocation"]),
|
|
88
|
+
scope,
|
|
89
|
+
text: file.text,
|
|
90
|
+
};
|
|
91
|
+
const paths = toStringList(data.paths);
|
|
92
|
+
if (paths.length > 0)
|
|
93
|
+
entry.paths = paths;
|
|
94
|
+
const whenToUse = stringField(data, "when_to_use");
|
|
95
|
+
if (whenToUse)
|
|
96
|
+
entry.whenToUse = whenToUse;
|
|
97
|
+
const model = stringField(data, "model");
|
|
98
|
+
if (model)
|
|
99
|
+
entry.model = model;
|
|
100
|
+
if (plugin)
|
|
101
|
+
entry.plugin = plugin;
|
|
102
|
+
const real = await resolvedPath(folder);
|
|
103
|
+
if (real !== folder)
|
|
104
|
+
entry.realPath = real;
|
|
105
|
+
return entry;
|
|
106
|
+
}
|
|
107
|
+
/** `<name>/SKILL.md` folders, or one folder holding SKILL.md itself. */
|
|
108
|
+
async function readSkills(dir, scope, plugin) {
|
|
109
|
+
if (plugin && (await isFile(join(dir, "SKILL.md")))) {
|
|
110
|
+
const skill = await readSkill(dir, scope, plugin);
|
|
111
|
+
return skill ? [skill] : [];
|
|
112
|
+
}
|
|
113
|
+
const out = [];
|
|
114
|
+
for (const name of await listDir(dir)) {
|
|
115
|
+
if (name.startsWith(".") || (scope === "user" && name === "synced"))
|
|
116
|
+
continue;
|
|
117
|
+
const skill = await readSkill(join(dir, name), scope, plugin);
|
|
118
|
+
if (skill)
|
|
119
|
+
out.push(skill);
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
/** A plugin's skills, agents, commands and output styles, from its folders and its manifest. */
|
|
124
|
+
async function pluginEntries(plugin) {
|
|
125
|
+
const root = plugin.installPath;
|
|
126
|
+
const options = { plugin: plugin.id };
|
|
127
|
+
// `skills` adds to the default folder; the other keys replace theirs (docs: plugins-reference).
|
|
128
|
+
const extraSkills = await manifestPaths(plugin, "skills");
|
|
129
|
+
const skillDirs = [join(root, "skills"), ...(extraSkills ?? [])];
|
|
130
|
+
const rootSkill = extraSkills === null && !(await isDir(join(root, "skills")));
|
|
131
|
+
const skills = rootSkill ? await readSkills(root, "plugin", plugin.id) : [];
|
|
132
|
+
for (const dir of skillDirs)
|
|
133
|
+
skills.push(...(await readSkills(dir, "plugin", plugin.id)));
|
|
134
|
+
const listed = async (key, folder, separator = "/") => readPaths((await manifestPaths(plugin, key)) ?? [join(root, folder)], "plugin", {
|
|
135
|
+
...options,
|
|
136
|
+
separator,
|
|
137
|
+
});
|
|
138
|
+
const commands = await listed("commands", "commands", ":");
|
|
139
|
+
for (const c of await manifestCommandMap(plugin))
|
|
140
|
+
commands.push(c.text === null
|
|
141
|
+
? ((await readEntry(c.path, c.name, "plugin", plugin.id)) ?? {
|
|
142
|
+
name: c.name,
|
|
143
|
+
description: c.description,
|
|
144
|
+
path: c.path,
|
|
145
|
+
scope: "plugin",
|
|
146
|
+
plugin: plugin.id,
|
|
147
|
+
text: "",
|
|
148
|
+
})
|
|
149
|
+
: {
|
|
150
|
+
name: c.name,
|
|
151
|
+
description: c.description,
|
|
152
|
+
path: c.path,
|
|
153
|
+
scope: "plugin",
|
|
154
|
+
plugin: plugin.id,
|
|
155
|
+
text: c.text,
|
|
156
|
+
});
|
|
157
|
+
// A plugin subagent is named with the plugin and its subfolders (docs: sub-agents#choose-the-subagent-scope).
|
|
158
|
+
const agents = [];
|
|
159
|
+
for (const path of (await manifestPaths(plugin, "agents")) ?? [join(root, "agents")]) {
|
|
160
|
+
const folder = (await isDir(path)) ? path : null;
|
|
161
|
+
for (const agent of await readPaths([path], "plugin", options)) {
|
|
162
|
+
const subfolders = folder ? relative(folder, dirname(agent.path)).split(/[/\\]/) : [];
|
|
163
|
+
const own = agent.name.split("/").at(-1) ?? agent.name;
|
|
164
|
+
agent.id = [plugin.id.split("@")[0], ...subfolders.filter(Boolean), own].join(":");
|
|
165
|
+
agents.push(agent);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
skills,
|
|
170
|
+
agents,
|
|
171
|
+
commands,
|
|
172
|
+
outputStyles: await listed("outputStyles", "output-styles"),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* The `.claude/<sub>` folders of the start directory and every parent up to the repository root
|
|
177
|
+
* (only the start directory outside git), root first, leaving out the user folder.
|
|
178
|
+
*/
|
|
179
|
+
function projectFolders(env, projectDir, gitRoot, sub) {
|
|
180
|
+
return ancestorsRootFirst(projectDir)
|
|
181
|
+
.filter((dir) => !gitRoot || isWithin(gitRoot, dir))
|
|
182
|
+
.slice(gitRoot ? 0 : -1)
|
|
183
|
+
.map((dir) => join(dir, ".claude", sub))
|
|
184
|
+
.filter((dir) => dir !== join(env.claudeDir, sub));
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Subagents by priority: managed, then project folders closest to the start directory first, then
|
|
188
|
+
* yours. A name defined again lower down is marked as shadowed (docs: sub-agents#choose-the-subagent-scope).
|
|
189
|
+
*/
|
|
190
|
+
async function readAgents(env, projectDir, gitRoot) {
|
|
191
|
+
const sources = [
|
|
192
|
+
[join(env.managedDir, ".claude", "agents"), "managed"],
|
|
193
|
+
...projectFolders(env, projectDir, gitRoot, "agents")
|
|
194
|
+
.reverse()
|
|
195
|
+
.map((dir) => [dir, "project"]),
|
|
196
|
+
[join(env.claudeDir, "agents"), "user"],
|
|
197
|
+
];
|
|
198
|
+
const agents = [];
|
|
199
|
+
const winners = new Map();
|
|
200
|
+
for (const [dir, scope] of sources)
|
|
201
|
+
for (const agent of await readFileEntries(dir, scope)) {
|
|
202
|
+
const winner = winners.get(agent.name);
|
|
203
|
+
if (winner)
|
|
204
|
+
agent.shadowedBy = winner.path;
|
|
205
|
+
else
|
|
206
|
+
winners.set(agent.name, agent);
|
|
207
|
+
agents.push(agent);
|
|
208
|
+
}
|
|
209
|
+
return agents;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Applies skillOverrides to your skills and command files; plugin skills are not affected
|
|
213
|
+
* (docs: skills#override-skill-visibility-from-settings).
|
|
214
|
+
*/
|
|
215
|
+
export function applySkillOverrides(entries, overrides) {
|
|
216
|
+
if (!isRecord(overrides))
|
|
217
|
+
return;
|
|
218
|
+
for (const entry of entries) {
|
|
219
|
+
const state = overrides[entry.name];
|
|
220
|
+
if (entry.scope === "plugin")
|
|
221
|
+
continue;
|
|
222
|
+
if (state !== "name-only" && state !== "user-invocable-only" && state !== "off")
|
|
223
|
+
continue;
|
|
224
|
+
entry.override = state;
|
|
225
|
+
if (state !== "name-only")
|
|
226
|
+
entry.listed = false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
export async function readInventory(input) {
|
|
230
|
+
const { env, projectDir, gitRoot, layers } = input;
|
|
231
|
+
const plugins = await readPlugins(env, layers, projectDir, gitRoot);
|
|
232
|
+
const active = plugins.filter((p) => p.enabled);
|
|
233
|
+
const fromPlugins = await Promise.all(active.map(pluginEntries));
|
|
234
|
+
const projectClaude = join(projectDir, ".claude");
|
|
235
|
+
const hasProjectDir = projectClaude !== env.claudeDir;
|
|
236
|
+
const files = async (sub, separator = "/") => [
|
|
237
|
+
...(await readFileEntries(join(env.claudeDir, sub), "user", { separator })),
|
|
238
|
+
...(hasProjectDir
|
|
239
|
+
? await readFileEntries(join(projectClaude, sub), "project", { separator })
|
|
240
|
+
: []),
|
|
241
|
+
];
|
|
242
|
+
// Project skills come from the start directory and every parent up to the repository root.
|
|
243
|
+
const skillDirs = [
|
|
244
|
+
[join(env.managedDir, ".claude", "skills"), "managed"],
|
|
245
|
+
[join(env.claudeDir, "skills"), "user"],
|
|
246
|
+
...(await listDir(join(env.claudeDir, "skills", "synced")))
|
|
247
|
+
.filter((bucket) => !bucket.startsWith("."))
|
|
248
|
+
.map((bucket) => [join(env.claudeDir, "skills", "synced", bucket), "user"]),
|
|
249
|
+
...projectFolders(env, projectDir, gitRoot, "skills").map((dir) => [
|
|
250
|
+
dir,
|
|
251
|
+
"project",
|
|
252
|
+
]),
|
|
253
|
+
];
|
|
254
|
+
const skills = [];
|
|
255
|
+
const seenFolders = new Set();
|
|
256
|
+
for (const [dir, scope] of skillDirs) {
|
|
257
|
+
for (const skill of await readSkills(dir, scope)) {
|
|
258
|
+
// A folder that several locations link to loads once.
|
|
259
|
+
const real = skill.realPath ?? skill.folder ?? skill.path;
|
|
260
|
+
if (seenFolders.has(real))
|
|
261
|
+
continue;
|
|
262
|
+
seenFolders.add(real);
|
|
263
|
+
skills.push(skill);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// A command file is a skill too: one with disable-model-invocation is not listed to Claude.
|
|
267
|
+
const commands = await files("commands", ":");
|
|
268
|
+
for (const command of commands)
|
|
269
|
+
if (isTrue(parseFrontmatter(command.text).data["disable-model-invocation"]))
|
|
270
|
+
command.listed = false;
|
|
271
|
+
const overrides = effectiveSettings(layers).find((s) => s.key === "skillOverrides")?.value;
|
|
272
|
+
applySkillOverrides([...skills, ...commands], overrides);
|
|
273
|
+
return {
|
|
274
|
+
agents: [
|
|
275
|
+
...(await readAgents(env, projectDir, gitRoot)),
|
|
276
|
+
...fromPlugins.flatMap((p) => p.agents),
|
|
277
|
+
],
|
|
278
|
+
skills: [...skills, ...fromPlugins.flatMap((p) => p.skills)],
|
|
279
|
+
commands: [...commands, ...fromPlugins.flatMap((p) => p.commands)],
|
|
280
|
+
outputStyles: [
|
|
281
|
+
...(await files("output-styles")),
|
|
282
|
+
...fromPlugins.flatMap((p) => p.outputStyles),
|
|
283
|
+
],
|
|
284
|
+
hooks: await readHooks(layers, active),
|
|
285
|
+
mcpServers: await readMcpServers(input, active),
|
|
286
|
+
plugins,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
async function readHooks(layers, plugins) {
|
|
290
|
+
const out = [];
|
|
291
|
+
for (const layer of layers) {
|
|
292
|
+
if (layer.data)
|
|
293
|
+
out.push(...hooksFrom(layer.data.hooks, layer.scope, layer.path));
|
|
294
|
+
}
|
|
295
|
+
for (const plugin of plugins) {
|
|
296
|
+
const path = join(plugin.installPath, "hooks", "hooks.json");
|
|
297
|
+
const file = await readJsonObject(path);
|
|
298
|
+
if (file.ok)
|
|
299
|
+
out.push(...hooksFrom(file.data.hooks, "plugin", path, plugin));
|
|
300
|
+
for (const part of await manifestConfigs(plugin, "hooks"))
|
|
301
|
+
out.push(...hooksFrom(part.value, "plugin", part.path, plugin));
|
|
302
|
+
}
|
|
303
|
+
// docs: hooks#disable-or-remove-hooks, settings-reference#allowmanagedhooksonly
|
|
304
|
+
const managed = layers.find((l) => l.scope === "managed")?.data;
|
|
305
|
+
const managedOff = managed?.disableAllHooks === true;
|
|
306
|
+
// The value left after precedence counts, so a project false beats a user true.
|
|
307
|
+
const othersOff = HIGHEST_FIRST.filter((s) => s !== "managed")
|
|
308
|
+
.flatMap((scope) => layers.filter((l) => l.scope === scope))
|
|
309
|
+
.find((l) => typeof l.data?.disableAllHooks === "boolean")?.data?.disableAllHooks === true;
|
|
310
|
+
const managedOnly = managed?.allowManagedHooksOnly === true;
|
|
311
|
+
const forcedByManaged = new Set(plugins.filter((p) => p.enabledIn === "managed").map((p) => p.id));
|
|
312
|
+
for (const hook of out) {
|
|
313
|
+
const organisation = hook.source === "managed" || (hook.plugin !== undefined && forcedByManaged.has(hook.plugin));
|
|
314
|
+
if (managedOff) {
|
|
315
|
+
hook.active = false;
|
|
316
|
+
hook.note = "Managed settings turn off every hook (disableAllHooks)";
|
|
317
|
+
}
|
|
318
|
+
else if (hook.source !== "managed" && othersOff) {
|
|
319
|
+
hook.active = false;
|
|
320
|
+
hook.note = "disableAllHooks is on, so only managed hooks run";
|
|
321
|
+
}
|
|
322
|
+
else if (!organisation && managedOnly) {
|
|
323
|
+
hook.active = false;
|
|
324
|
+
hook.note = "Managed settings allow only managed hooks (allowManagedHooksOnly)";
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
function hooksFrom(config, source, sourcePath, plugin) {
|
|
330
|
+
if (!isRecord(config))
|
|
331
|
+
return [];
|
|
332
|
+
const out = [];
|
|
333
|
+
for (const [event, groups] of Object.entries(config)) {
|
|
334
|
+
if (!Array.isArray(groups))
|
|
335
|
+
continue;
|
|
336
|
+
for (const group of groups) {
|
|
337
|
+
if (!isRecord(group) || !Array.isArray(group.hooks))
|
|
338
|
+
continue;
|
|
339
|
+
for (const h of group.hooks) {
|
|
340
|
+
if (!isRecord(h))
|
|
341
|
+
continue;
|
|
342
|
+
const entry = {
|
|
343
|
+
event,
|
|
344
|
+
type: stringField(h, "type") || "command",
|
|
345
|
+
command: stringField(h, "command") || stringField(h, "prompt") || stringField(h, "url"),
|
|
346
|
+
source,
|
|
347
|
+
sourcePath,
|
|
348
|
+
active: true,
|
|
349
|
+
};
|
|
350
|
+
const matcher = stringField(group, "matcher");
|
|
351
|
+
if (matcher)
|
|
352
|
+
entry.matcher = matcher;
|
|
353
|
+
if (plugin)
|
|
354
|
+
entry.plugin = plugin.id;
|
|
355
|
+
out.push(entry);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return out;
|
|
360
|
+
}
|