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,452 @@
|
|
|
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, redactSecrets, resolvedPath, shownUrl, stringField, toStringList, } from "./files.js";
|
|
6
|
+
import { ancestorsRootFirst } from "./instructions.js";
|
|
7
|
+
import { readMcpServers } from "./mcp.js";
|
|
8
|
+
import { manifestCommandMap, manifestConfigs, manifestPaths, pluginNamespace, 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
|
+
/**
|
|
14
|
+
* A `.md` file as an agent, command or output style. `fileName` is its name from its path, which a
|
|
15
|
+
* frontmatter `name` replaces, except for a command (docs: skills#how-a-skill-gets-its-command-name).
|
|
16
|
+
*/
|
|
17
|
+
async function readEntry(env, path, fileName, scope, { plugin, isCommand = false } = {}) {
|
|
18
|
+
const file = await readTextFile(env, path);
|
|
19
|
+
if (!file)
|
|
20
|
+
return null;
|
|
21
|
+
const { data } = parseFrontmatter(file.text);
|
|
22
|
+
const entry = {
|
|
23
|
+
name: (!isCommand && stringField(data, "name")) || fileName,
|
|
24
|
+
description: stringField(data, "description"),
|
|
25
|
+
path,
|
|
26
|
+
scope,
|
|
27
|
+
text: file.text,
|
|
28
|
+
};
|
|
29
|
+
const model = stringField(data, "model");
|
|
30
|
+
if (model)
|
|
31
|
+
entry.model = model;
|
|
32
|
+
if (plugin)
|
|
33
|
+
entry.plugin = plugin;
|
|
34
|
+
const real = await resolvedPath(path);
|
|
35
|
+
if (real !== path)
|
|
36
|
+
entry.realPath = real;
|
|
37
|
+
return entry;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* `*.md` files in a folder and its subfolders. A file in a subfolder is named by its path, joined
|
|
41
|
+
* with `separator`: commands use ":" (`frontend/component.md` is `/frontend:component`).
|
|
42
|
+
*/
|
|
43
|
+
async function readFileEntries(env, dir, scope, { plugin, separator = "/" } = {}, base = dir, depth = 0) {
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const name of await listDir(dir)) {
|
|
46
|
+
if (name.startsWith("."))
|
|
47
|
+
continue;
|
|
48
|
+
const path = join(dir, name);
|
|
49
|
+
if (await isDir(path)) {
|
|
50
|
+
if (depth < 4)
|
|
51
|
+
out.push(...(await readFileEntries(env, path, scope, { plugin, separator }, base, depth + 1)));
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (!name.endsWith(".md"))
|
|
55
|
+
continue;
|
|
56
|
+
const fileName = relative(base, path).replace(/\.md$/, "").split(/[/\\]/).join(separator);
|
|
57
|
+
// Commands are the entries joined with ":".
|
|
58
|
+
const entry = await readEntry(env, path, fileName, scope, {
|
|
59
|
+
plugin,
|
|
60
|
+
isCommand: separator === ":",
|
|
61
|
+
});
|
|
62
|
+
if (entry)
|
|
63
|
+
out.push(entry);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
/** Files or folders of `.md` files. */
|
|
68
|
+
async function readPaths(env, paths, scope, options) {
|
|
69
|
+
const out = [];
|
|
70
|
+
for (const path of paths) {
|
|
71
|
+
if (await isDir(path))
|
|
72
|
+
out.push(...(await readFileEntries(env, path, scope, options)));
|
|
73
|
+
else if (path.endsWith(".md")) {
|
|
74
|
+
const entry = await readEntry(env, path, basename(path, ".md"), scope, {
|
|
75
|
+
plugin: options.plugin,
|
|
76
|
+
isCommand: options.separator === ":",
|
|
77
|
+
});
|
|
78
|
+
if (entry)
|
|
79
|
+
out.push(entry);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
export async function readSkill(env, folder, scope, plugin) {
|
|
85
|
+
const path = join(folder, "SKILL.md");
|
|
86
|
+
const file = await readTextFile(env, path);
|
|
87
|
+
if (!file)
|
|
88
|
+
return null;
|
|
89
|
+
const { data, body } = parseFrontmatter(file.text);
|
|
90
|
+
const entry = {
|
|
91
|
+
name: stringField(data, "name") || (folder.split(/[/\\]/).pop() ?? ""),
|
|
92
|
+
// Without a description, Claude Code lists the first non-empty line of the body.
|
|
93
|
+
description: stringField(data, "description") ||
|
|
94
|
+
(body.split("\n").find((line) => line.trim() !== "") ?? "").trim(),
|
|
95
|
+
path,
|
|
96
|
+
folder,
|
|
97
|
+
listed: !isTrue(data["disable-model-invocation"]),
|
|
98
|
+
scope,
|
|
99
|
+
text: file.text,
|
|
100
|
+
};
|
|
101
|
+
const paths = toStringList(data.paths);
|
|
102
|
+
if (paths.length > 0)
|
|
103
|
+
entry.paths = paths;
|
|
104
|
+
const whenToUse = stringField(data, "when_to_use");
|
|
105
|
+
if (whenToUse)
|
|
106
|
+
entry.whenToUse = whenToUse;
|
|
107
|
+
const model = stringField(data, "model");
|
|
108
|
+
if (model)
|
|
109
|
+
entry.model = model;
|
|
110
|
+
if (plugin)
|
|
111
|
+
entry.plugin = plugin;
|
|
112
|
+
const real = await resolvedPath(folder);
|
|
113
|
+
if (real !== folder)
|
|
114
|
+
entry.realPath = real;
|
|
115
|
+
return entry;
|
|
116
|
+
}
|
|
117
|
+
/** `<name>/SKILL.md` folders, or one folder holding SKILL.md itself. */
|
|
118
|
+
async function readSkills(env, dir, scope, plugin) {
|
|
119
|
+
if (plugin && (await isFile(join(dir, "SKILL.md")))) {
|
|
120
|
+
const skill = await readSkill(env, dir, scope, plugin);
|
|
121
|
+
return skill ? [skill] : [];
|
|
122
|
+
}
|
|
123
|
+
const out = [];
|
|
124
|
+
for (const name of await listDir(dir)) {
|
|
125
|
+
if (name.startsWith(".") || (scope === "user" && name === "synced"))
|
|
126
|
+
continue;
|
|
127
|
+
const skill = await readSkill(env, join(dir, name), scope, plugin);
|
|
128
|
+
if (skill)
|
|
129
|
+
out.push(skill);
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
/** A plugin's skills, agents, commands and output styles, from its folders and its manifest. */
|
|
134
|
+
async function pluginEntries(env, plugin) {
|
|
135
|
+
const root = plugin.installPath;
|
|
136
|
+
const options = { plugin: plugin.id };
|
|
137
|
+
// `skills` adds to the default folder; the other keys replace theirs (docs: plugins-reference).
|
|
138
|
+
const extraSkills = await manifestPaths(env, plugin, "skills");
|
|
139
|
+
const skillDirs = [join(root, "skills"), ...(extraSkills ?? [])];
|
|
140
|
+
const rootSkill = extraSkills === null && !(await isDir(join(root, "skills")));
|
|
141
|
+
const namespace = await pluginNamespace(env, plugin);
|
|
142
|
+
const skills = [];
|
|
143
|
+
if (rootSkill)
|
|
144
|
+
for (const skill of await readSkills(env, root, "plugin", plugin.id)) {
|
|
145
|
+
// Without a name, a root SKILL.md is named after the plugin (docs: skills#how-a-skill-gets-its-command-name).
|
|
146
|
+
if (skill.folder === root && !stringField(parseFrontmatter(skill.text).data, "name"))
|
|
147
|
+
skill.name = namespace;
|
|
148
|
+
skills.push(skill);
|
|
149
|
+
}
|
|
150
|
+
for (const dir of skillDirs)
|
|
151
|
+
skills.push(...(await readSkills(env, dir, "plugin", plugin.id)));
|
|
152
|
+
const listed = async (key, folder, separator = "/") => readPaths(env, (await manifestPaths(env, plugin, key)) ?? [join(root, folder)], "plugin", {
|
|
153
|
+
...options,
|
|
154
|
+
separator,
|
|
155
|
+
});
|
|
156
|
+
const commands = await listed("commands", "commands", ":");
|
|
157
|
+
for (const c of await manifestCommandMap(env, plugin))
|
|
158
|
+
commands.push(c.text === null
|
|
159
|
+
? ((await readEntry(env, c.path, c.name, "plugin", {
|
|
160
|
+
plugin: plugin.id,
|
|
161
|
+
isCommand: true,
|
|
162
|
+
})) ?? {
|
|
163
|
+
name: c.name,
|
|
164
|
+
description: c.description,
|
|
165
|
+
path: c.path,
|
|
166
|
+
scope: "plugin",
|
|
167
|
+
plugin: plugin.id,
|
|
168
|
+
text: "",
|
|
169
|
+
})
|
|
170
|
+
: {
|
|
171
|
+
name: c.name,
|
|
172
|
+
description: c.description,
|
|
173
|
+
path: c.path,
|
|
174
|
+
scope: "plugin",
|
|
175
|
+
plugin: plugin.id,
|
|
176
|
+
text: c.text,
|
|
177
|
+
});
|
|
178
|
+
// A plugin's skills and commands are called as plugin-name:name; a name that already has the
|
|
179
|
+
// prefix keeps it once (docs: skills#how-a-skill-gets-its-command-name).
|
|
180
|
+
for (const entry of [...skills, ...commands])
|
|
181
|
+
entry.id = entry.name.startsWith(`${namespace}:`) ? entry.name : `${namespace}:${entry.name}`;
|
|
182
|
+
// A plugin subagent is named with the plugin and its subfolders (docs: sub-agents#choose-the-subagent-scope).
|
|
183
|
+
const agents = [];
|
|
184
|
+
for (const path of (await manifestPaths(env, plugin, "agents")) ?? [join(root, "agents")]) {
|
|
185
|
+
const folder = (await isDir(path)) ? path : null;
|
|
186
|
+
for (const agent of await readPaths(env, [path], "plugin", options)) {
|
|
187
|
+
const subfolders = folder ? relative(folder, dirname(agent.path)).split(/[/\\]/) : [];
|
|
188
|
+
const own = agent.name.split("/").at(-1) ?? agent.name;
|
|
189
|
+
agent.id = [namespace, ...subfolders.filter(Boolean), own].join(":");
|
|
190
|
+
agents.push(agent);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
skills,
|
|
195
|
+
agents,
|
|
196
|
+
commands,
|
|
197
|
+
outputStyles: await listed("outputStyles", "output-styles"),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The `.claude/<sub>` folders of the start directory and every parent up to the repository root
|
|
202
|
+
* (only the start directory outside git), root first, leaving out the user folder.
|
|
203
|
+
*/
|
|
204
|
+
function projectFolders(env, projectDir, gitRoot, sub) {
|
|
205
|
+
return ancestorsRootFirst(projectDir)
|
|
206
|
+
.filter((dir) => !gitRoot || isWithin(gitRoot, dir))
|
|
207
|
+
.slice(gitRoot ? 0 : -1)
|
|
208
|
+
.map((dir) => join(dir, ".claude", sub))
|
|
209
|
+
.filter((dir) => dir !== join(env.claudeDir, sub));
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* What Claude Code requires of a subagent file; it skips one that falls short, and treats one
|
|
213
|
+
* without a name as documentation (docs: sub-agents#subagent-files-claude-code-skips).
|
|
214
|
+
*/
|
|
215
|
+
export function agentProblems(text) {
|
|
216
|
+
const { data } = parseFrontmatter(text);
|
|
217
|
+
const name = stringField(data, "name");
|
|
218
|
+
const problems = [];
|
|
219
|
+
if (!name)
|
|
220
|
+
problems.push("The frontmatter needs a name.");
|
|
221
|
+
else if (name.length > 256 || name.includes(":") || name.startsWith("-"))
|
|
222
|
+
problems.push("A name has at most 256 characters, no ':' and does not start with '-'.");
|
|
223
|
+
if (!stringField(data, "description"))
|
|
224
|
+
problems.push("The frontmatter needs a description: when Claude should use this agent.");
|
|
225
|
+
return problems;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Subagents by priority: managed, then project folders closest to the start directory first, then
|
|
229
|
+
* yours. A name defined again lower down is marked as shadowed (docs: sub-agents#choose-the-subagent-scope).
|
|
230
|
+
* A file Claude Code skips takes no part in that.
|
|
231
|
+
*/
|
|
232
|
+
async function readAgents(env, projectDir, gitRoot) {
|
|
233
|
+
const sources = [
|
|
234
|
+
[join(env.managedDir, ".claude", "agents"), "managed"],
|
|
235
|
+
...projectFolders(env, projectDir, gitRoot, "agents")
|
|
236
|
+
.reverse()
|
|
237
|
+
.map((dir) => [dir, "project"]),
|
|
238
|
+
[join(env.claudeDir, "agents"), "user"],
|
|
239
|
+
];
|
|
240
|
+
const agents = [];
|
|
241
|
+
const winners = new Map();
|
|
242
|
+
for (const [dir, scope] of sources)
|
|
243
|
+
for (const agent of await readFileEntries(env, dir, scope)) {
|
|
244
|
+
const [problem] = agentProblems(agent.text);
|
|
245
|
+
if (problem) {
|
|
246
|
+
agent.skipped = problem;
|
|
247
|
+
agents.push(agent);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const winner = winners.get(agent.name);
|
|
251
|
+
if (winner)
|
|
252
|
+
agent.shadowedBy = winner.path;
|
|
253
|
+
else
|
|
254
|
+
winners.set(agent.name, agent);
|
|
255
|
+
agents.push(agent);
|
|
256
|
+
}
|
|
257
|
+
return agents;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Output styles from the managed folder, every project folder from the start directory up to the
|
|
261
|
+
* repository root (the closest wins) and yours (docs: output-styles#create-a-custom-output-style).
|
|
262
|
+
* The docs order only the project folders; managed, project, then yours follows subagents.
|
|
263
|
+
*/
|
|
264
|
+
async function readOutputStyles(env, projectDir, gitRoot) {
|
|
265
|
+
const sources = [
|
|
266
|
+
[join(env.managedDir, ".claude", "output-styles"), "managed"],
|
|
267
|
+
...projectFolders(env, projectDir, gitRoot, "output-styles")
|
|
268
|
+
.reverse()
|
|
269
|
+
.map((dir) => [dir, "project"]),
|
|
270
|
+
[join(env.claudeDir, "output-styles"), "user"],
|
|
271
|
+
];
|
|
272
|
+
const styles = [];
|
|
273
|
+
for (const [dir, scope] of sources)
|
|
274
|
+
styles.push(...(await readFileEntries(env, dir, scope)));
|
|
275
|
+
return styles;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Applies skillOverrides to your skills and command files; plugin skills are not affected
|
|
279
|
+
* (docs: skills#override-skill-visibility-from-settings).
|
|
280
|
+
*/
|
|
281
|
+
export function applySkillOverrides(entries, overrides) {
|
|
282
|
+
if (!isRecord(overrides))
|
|
283
|
+
return;
|
|
284
|
+
for (const entry of entries) {
|
|
285
|
+
const state = overrides[entry.name];
|
|
286
|
+
if (entry.scope === "plugin")
|
|
287
|
+
continue;
|
|
288
|
+
if (state !== "name-only" && state !== "user-invocable-only" && state !== "off")
|
|
289
|
+
continue;
|
|
290
|
+
entry.override = state;
|
|
291
|
+
if (state !== "name-only")
|
|
292
|
+
entry.listed = false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
export async function readInventory(input) {
|
|
296
|
+
const { env, projectDir, gitRoot, layers } = input;
|
|
297
|
+
const plugins = await readPlugins(env, layers, projectDir, gitRoot);
|
|
298
|
+
const active = plugins.filter((p) => p.enabled);
|
|
299
|
+
const fromPlugins = await Promise.all(active.map((plugin) => pluginEntries(env, plugin)));
|
|
300
|
+
const projectClaude = join(projectDir, ".claude");
|
|
301
|
+
const hasProjectDir = projectClaude !== env.claudeDir;
|
|
302
|
+
const files = async (sub, separator = "/") => [
|
|
303
|
+
...(await readFileEntries(env, join(env.claudeDir, sub), "user", { separator })),
|
|
304
|
+
...(hasProjectDir
|
|
305
|
+
? await readFileEntries(env, join(projectClaude, sub), "project", { separator })
|
|
306
|
+
: []),
|
|
307
|
+
];
|
|
308
|
+
// Project skills come from the start directory and every parent up to the repository root.
|
|
309
|
+
const skillDirs = [
|
|
310
|
+
[join(env.managedDir, ".claude", "skills"), "managed"],
|
|
311
|
+
[join(env.claudeDir, "skills"), "user"],
|
|
312
|
+
...(await listDir(join(env.claudeDir, "skills", "synced")))
|
|
313
|
+
.filter((bucket) => !bucket.startsWith("."))
|
|
314
|
+
.map((bucket) => [join(env.claudeDir, "skills", "synced", bucket), "user"]),
|
|
315
|
+
...projectFolders(env, projectDir, gitRoot, "skills").map((dir) => [
|
|
316
|
+
dir,
|
|
317
|
+
"project",
|
|
318
|
+
]),
|
|
319
|
+
];
|
|
320
|
+
const skills = [];
|
|
321
|
+
const seenFolders = new Set();
|
|
322
|
+
// The same name in two of managed, yours and the project runs the first, in that order
|
|
323
|
+
// (docs: skills#resolve-skills-that-share-a-name).
|
|
324
|
+
const winners = new Map();
|
|
325
|
+
for (const [dir, scope] of skillDirs) {
|
|
326
|
+
for (const skill of await readSkills(env, dir, scope)) {
|
|
327
|
+
// A folder that several locations link to loads once.
|
|
328
|
+
const real = skill.realPath ?? skill.folder ?? skill.path;
|
|
329
|
+
if (seenFolders.has(real))
|
|
330
|
+
continue;
|
|
331
|
+
seenFolders.add(real);
|
|
332
|
+
const winner = winners.get(skill.name);
|
|
333
|
+
if (winner && winner.scope !== skill.scope)
|
|
334
|
+
skill.shadowedBy = winner.path;
|
|
335
|
+
else if (!winner)
|
|
336
|
+
winners.set(skill.name, skill);
|
|
337
|
+
skills.push(skill);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// A command file is a skill too: one with disable-model-invocation is not listed to Claude.
|
|
341
|
+
const commands = await files("commands", ":");
|
|
342
|
+
// A skill of the same name runs instead of it (docs: skills#resolve-skills-that-share-a-name).
|
|
343
|
+
for (const command of commands) {
|
|
344
|
+
if (isTrue(parseFrontmatter(command.text).data["disable-model-invocation"]))
|
|
345
|
+
command.listed = false;
|
|
346
|
+
const skill = winners.get(command.name);
|
|
347
|
+
if (skill)
|
|
348
|
+
command.shadowedBy = skill.path;
|
|
349
|
+
}
|
|
350
|
+
const overrides = effectiveSettings(layers).find((s) => s.key === "skillOverrides")?.value;
|
|
351
|
+
applySkillOverrides([...skills, ...commands], overrides);
|
|
352
|
+
return {
|
|
353
|
+
agents: [
|
|
354
|
+
...(await readAgents(env, projectDir, gitRoot)),
|
|
355
|
+
...fromPlugins.flatMap((p) => p.agents),
|
|
356
|
+
],
|
|
357
|
+
skills: [...skills, ...fromPlugins.flatMap((p) => p.skills)],
|
|
358
|
+
commands: [...commands, ...fromPlugins.flatMap((p) => p.commands)],
|
|
359
|
+
outputStyles: [
|
|
360
|
+
...(await readOutputStyles(env, projectDir, gitRoot)),
|
|
361
|
+
...fromPlugins.flatMap((p) => p.outputStyles),
|
|
362
|
+
],
|
|
363
|
+
hooks: await readHooks(env, layers, active, input.trust, input.profile),
|
|
364
|
+
mcpServers: await readMcpServers(input, active),
|
|
365
|
+
plugins,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
async function readHooks(env, layers, plugins, trust, profile) {
|
|
369
|
+
const out = [];
|
|
370
|
+
for (const layer of layers) {
|
|
371
|
+
if (layer.data)
|
|
372
|
+
out.push(...hooksFrom(layer.data.hooks, layer.scope, layer.path));
|
|
373
|
+
}
|
|
374
|
+
for (const plugin of plugins) {
|
|
375
|
+
const path = join(plugin.installPath, "hooks", "hooks.json");
|
|
376
|
+
const file = await readJsonObject(env, path);
|
|
377
|
+
if (file.ok)
|
|
378
|
+
out.push(...hooksFrom(file.data.hooks, "plugin", path, plugin));
|
|
379
|
+
for (const part of await manifestConfigs(env, plugin, "hooks"))
|
|
380
|
+
out.push(...hooksFrom(part.value, "plugin", part.path, plugin));
|
|
381
|
+
}
|
|
382
|
+
// docs: hooks#disable-or-remove-hooks, settings-reference#allowmanagedhooksonly
|
|
383
|
+
const managed = layers.find((l) => l.scope === "managed")?.data;
|
|
384
|
+
const managedOff = managed?.disableAllHooks === true;
|
|
385
|
+
// The value left after precedence counts, so a project false beats a user true.
|
|
386
|
+
const othersOff = HIGHEST_FIRST.filter((s) => s !== "managed")
|
|
387
|
+
.flatMap((scope) => layers.filter((l) => l.scope === scope))
|
|
388
|
+
.find((l) => typeof l.data?.disableAllHooks === "boolean")?.data?.disableAllHooks === true;
|
|
389
|
+
const managedOnly = managed?.allowManagedHooksOnly === true;
|
|
390
|
+
const forcedByManaged = new Set(plugins.filter((p) => p.enabledIn === "managed").map((p) => p.id));
|
|
391
|
+
// An interactive session holds back hooks from every settings file, managed and yours included,
|
|
392
|
+
// until the folder or a parent covering it is trusted; -p and the SDK do not (docs:
|
|
393
|
+
// hooks#workspace-trust, permissions#what-runs-before-you-trust-a-folder). The docs say nothing
|
|
394
|
+
// of plugin hooks, so those are left as they are.
|
|
395
|
+
const heldBack = profile === "interactive" && !trust.trusted && !trust.inheritedFrom;
|
|
396
|
+
for (const hook of out) {
|
|
397
|
+
const organisation = hook.source === "managed" || (hook.plugin !== undefined && forcedByManaged.has(hook.plugin));
|
|
398
|
+
if (managedOff) {
|
|
399
|
+
hook.active = false;
|
|
400
|
+
hook.note = "Managed settings turn off every hook (disableAllHooks)";
|
|
401
|
+
}
|
|
402
|
+
else if (hook.source !== "managed" && othersOff) {
|
|
403
|
+
hook.active = false;
|
|
404
|
+
hook.note = "disableAllHooks is on, so only managed hooks run";
|
|
405
|
+
}
|
|
406
|
+
else if (!organisation && managedOnly) {
|
|
407
|
+
hook.active = false;
|
|
408
|
+
hook.note = "Managed settings allow only managed hooks (allowManagedHooksOnly)";
|
|
409
|
+
}
|
|
410
|
+
else if (hook.source !== "plugin" && heldBack) {
|
|
411
|
+
hook.active = false;
|
|
412
|
+
hook.note = "Waits until you accept Claude Code's trust dialog for this folder";
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return out;
|
|
416
|
+
}
|
|
417
|
+
function hooksFrom(config, source, sourcePath, plugin) {
|
|
418
|
+
if (!isRecord(config))
|
|
419
|
+
return [];
|
|
420
|
+
const out = [];
|
|
421
|
+
for (const [event, groups] of Object.entries(config)) {
|
|
422
|
+
if (!Array.isArray(groups))
|
|
423
|
+
continue;
|
|
424
|
+
for (const [g, group] of groups.entries()) {
|
|
425
|
+
if (!isRecord(group) || !Array.isArray(group.hooks))
|
|
426
|
+
continue;
|
|
427
|
+
for (const [index, h] of group.hooks.entries()) {
|
|
428
|
+
if (!isRecord(h))
|
|
429
|
+
continue;
|
|
430
|
+
const entry = {
|
|
431
|
+
event,
|
|
432
|
+
type: stringField(h, "type") || "command",
|
|
433
|
+
command: stringField(h, "command") ||
|
|
434
|
+
stringField(h, "prompt") ||
|
|
435
|
+
shownUrl(stringField(h, "url")),
|
|
436
|
+
source,
|
|
437
|
+
sourcePath,
|
|
438
|
+
active: true,
|
|
439
|
+
at: { group: g, index },
|
|
440
|
+
definition: redactSecrets(h),
|
|
441
|
+
};
|
|
442
|
+
const matcher = stringField(group, "matcher");
|
|
443
|
+
if (matcher)
|
|
444
|
+
entry.matcher = matcher;
|
|
445
|
+
if (plugin)
|
|
446
|
+
entry.plugin = plugin.id;
|
|
447
|
+
out.push(entry);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return out;
|
|
452
|
+
}
|