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,313 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import {} from "../../shared/types.js";
|
|
3
|
+
import { isConfigHome } from "./env.js";
|
|
4
|
+
import { isRecord, readJsonObject, redactSecrets, shownUrl, stringField, stringsIn, wildcard, } from "./files.js";
|
|
5
|
+
import { manifestConfigs } from "./plugins.js";
|
|
6
|
+
import serverSchema from "../../shared/mcp-server.schema.json" with { type: "json" };
|
|
7
|
+
import { ajv, schemaErrors } from "../schema.js";
|
|
8
|
+
import { readManagedSettings } from "./settings.js";
|
|
9
|
+
const validateServer = ajv.compile(serverSchema);
|
|
10
|
+
/**
|
|
11
|
+
* What makes a server definition one Claude Code cannot start: a missing command or url, a type it
|
|
12
|
+
* does not know, or a field the docs define holding the wrong kind of value (docs: mcp). It checks
|
|
13
|
+
* the rest when it starts the server.
|
|
14
|
+
*/
|
|
15
|
+
export function serverProblems(definition) {
|
|
16
|
+
if (!isRecord(definition))
|
|
17
|
+
return ["A server must be a JSON object."];
|
|
18
|
+
const type = typeof definition.type === "string" ? definition.type : "";
|
|
19
|
+
if (type === "sdk")
|
|
20
|
+
return ['Only an SDK host application can register a "sdk" server; Claude Code skips it here.'];
|
|
21
|
+
const problems = [];
|
|
22
|
+
if (type === "" || type === "stdio") {
|
|
23
|
+
if (typeof definition.command !== "string" || definition.command.trim() === "")
|
|
24
|
+
problems.push(typeof definition.url === "string"
|
|
25
|
+
? 'A server with a url needs a "type", such as "http"; without one it is read as a stdio server.'
|
|
26
|
+
: 'A stdio server needs a "command".');
|
|
27
|
+
}
|
|
28
|
+
else if (typeof definition.url !== "string" || definition.url === "")
|
|
29
|
+
problems.push(`A ${type} server needs a "url".`);
|
|
30
|
+
return [...problems, ...schemaErrors(validateServer, definition, "")];
|
|
31
|
+
}
|
|
32
|
+
function define(servers, scope, sourcePath, organisation = false) {
|
|
33
|
+
if (!isRecord(servers))
|
|
34
|
+
return [];
|
|
35
|
+
return Object.entries(servers)
|
|
36
|
+
.filter(([, config]) => isRecord(config))
|
|
37
|
+
.map(([name, config]) => {
|
|
38
|
+
const c = config;
|
|
39
|
+
const type = stringField(c, "type");
|
|
40
|
+
const url = stringField(c, "url") || null;
|
|
41
|
+
const command = stringField(c, "command")
|
|
42
|
+
? [stringField(c, "command"), ...stringsIn(c.args)]
|
|
43
|
+
: null;
|
|
44
|
+
const definition = redactSecrets(c);
|
|
45
|
+
const server = {
|
|
46
|
+
name,
|
|
47
|
+
scope,
|
|
48
|
+
// An entry without a type is read as stdio, even one with a url (docs: mcp).
|
|
49
|
+
transport: type || "stdio",
|
|
50
|
+
target: url
|
|
51
|
+
? shownUrl(url)
|
|
52
|
+
: command
|
|
53
|
+
? [command[0], ...stringsIn(definition.args)].join(" ")
|
|
54
|
+
: "",
|
|
55
|
+
sourcePath,
|
|
56
|
+
status: "active",
|
|
57
|
+
definition,
|
|
58
|
+
};
|
|
59
|
+
const [problem] = serverProblems(c);
|
|
60
|
+
if (problem) {
|
|
61
|
+
server.status = "invalid";
|
|
62
|
+
server.note = problem;
|
|
63
|
+
}
|
|
64
|
+
// A ${VAR} in an organisation entry means it still has to pass the allowlist.
|
|
65
|
+
const usesVariables = JSON.stringify(c).includes("${");
|
|
66
|
+
return {
|
|
67
|
+
server,
|
|
68
|
+
command: type && type !== "stdio" ? null : command,
|
|
69
|
+
url: type ? url : null,
|
|
70
|
+
organisation: organisation && !usesVariables,
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/** Control and invisible formatting characters, such as zero-width spaces and bidi overrides. */
|
|
75
|
+
const INVISIBLE = /[\p{Cc}\p{Cf}]/u;
|
|
76
|
+
/** Every key and string value in a JSON value, the entry's name included by the caller. */
|
|
77
|
+
function textsIn(value) {
|
|
78
|
+
if (typeof value === "string")
|
|
79
|
+
return [value];
|
|
80
|
+
if (Array.isArray(value))
|
|
81
|
+
return value.flatMap(textsIn);
|
|
82
|
+
if (!isRecord(value))
|
|
83
|
+
return [];
|
|
84
|
+
return Object.entries(value).flatMap(([key, inner]) => [key, ...textsIn(inner)]);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Why Claude Code drops a managedMcpServers entry, or null when it loads it
|
|
88
|
+
* (docs: managed-mcp#what-an-entry-can-contain). `config` is the entry as written, headers included.
|
|
89
|
+
*/
|
|
90
|
+
function managedEntryProblem(name, config) {
|
|
91
|
+
if (!isRecord(config))
|
|
92
|
+
return "Not an object";
|
|
93
|
+
const type = stringField(config, "type");
|
|
94
|
+
if (!["http", "sse", "streamable-http"].includes(type))
|
|
95
|
+
return "managedMcpServers entries must be http or sse servers";
|
|
96
|
+
if (!stringField(config, "url").startsWith("https://"))
|
|
97
|
+
return "managedMcpServers entries need an https:// URL";
|
|
98
|
+
const member = ["command", "args", "env", "headersHelper"].find((key) => key in config);
|
|
99
|
+
if (member)
|
|
100
|
+
return `managedMcpServers entries may not have "${member}"`;
|
|
101
|
+
if (JSON.stringify(config).includes("${"))
|
|
102
|
+
return "managedMcpServers entries may not use environment variable references";
|
|
103
|
+
if (!/^[\w-]+$/.test(name))
|
|
104
|
+
return "A managedMcpServers name may hold only letters, numbers, hyphens and underscores";
|
|
105
|
+
if (textsIn(config).some((text) => INVISIBLE.test(text)))
|
|
106
|
+
return "managedMcpServers entries may not contain control or invisible characters";
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
/** managedMcpServers from managed settings, with entries Claude Code drops marked invalid. */
|
|
110
|
+
function defineManaged(servers, sourcePath) {
|
|
111
|
+
const defined = define(servers, "managed", sourcePath, true);
|
|
112
|
+
for (const d of defined) {
|
|
113
|
+
const problem = managedEntryProblem(d.server.name, servers[d.server.name]);
|
|
114
|
+
if (problem) {
|
|
115
|
+
d.server.status = "invalid";
|
|
116
|
+
d.server.note = problem;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return defined;
|
|
120
|
+
}
|
|
121
|
+
/** Two URLs are one endpoint when they differ only in scheme or host case, a default port or a trailing slash. */
|
|
122
|
+
function endpoint(d) {
|
|
123
|
+
if (d.command)
|
|
124
|
+
return `command:${JSON.stringify(d.command)}`;
|
|
125
|
+
if (!d.url)
|
|
126
|
+
return `none:${d.server.name}`;
|
|
127
|
+
try {
|
|
128
|
+
const u = new URL(d.url);
|
|
129
|
+
const path = u.pathname.replace(/\/$/, "");
|
|
130
|
+
const user = u.username ? `${u.username}${u.password ? `:${u.password}` : ""}@` : "";
|
|
131
|
+
return `url:${u.protocol}//${user}${u.host}${path}${u.search}`;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return `url:${d.url}`;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function policyEntries(value) {
|
|
138
|
+
if (!Array.isArray(value))
|
|
139
|
+
return [];
|
|
140
|
+
return value.filter((e) => isRecord(e) &&
|
|
141
|
+
(typeof e.serverName === "string" ||
|
|
142
|
+
(Array.isArray(e.serverCommand) && e.serverCommand.every((p) => typeof p === "string")) ||
|
|
143
|
+
typeof e.serverUrl === "string"));
|
|
144
|
+
}
|
|
145
|
+
/** A `serverUrl` pattern (docs: managed-mcp#how-serverurl-entries-match). */
|
|
146
|
+
function urlMatches(pattern, url) {
|
|
147
|
+
const p = /^([^:/]+):\/\/([^/:]+)(?::([^/]*))?(\/.*)?$/.exec(pattern);
|
|
148
|
+
let u;
|
|
149
|
+
try {
|
|
150
|
+
u = new URL(url);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
if (!p)
|
|
156
|
+
return false;
|
|
157
|
+
const [, scheme = "", host = "", port, path] = p;
|
|
158
|
+
const scheme_ = u.protocol.slice(0, -1);
|
|
159
|
+
if (!wildcard(scheme).test(scheme_))
|
|
160
|
+
return false;
|
|
161
|
+
if (!wildcard(host.replace(/\.$/, ""), "i").test(u.hostname.replace(/\.$/, "")))
|
|
162
|
+
return false;
|
|
163
|
+
const actualPort = u.port || (scheme_ === "https" ? "443" : scheme_ === "http" ? "80" : "");
|
|
164
|
+
// No port: a full hostname means the default port only, a wildcard hostname any port.
|
|
165
|
+
if (port !== undefined) {
|
|
166
|
+
if (!wildcard(port).test(actualPort))
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
else if (!host.includes("*") && u.port !== "")
|
|
170
|
+
return false;
|
|
171
|
+
return path === undefined || wildcard(path).test(u.pathname + u.search);
|
|
172
|
+
}
|
|
173
|
+
function matches(entry, d) {
|
|
174
|
+
if ("serverName" in entry)
|
|
175
|
+
return entry.serverName === d.server.name;
|
|
176
|
+
if ("serverCommand" in entry)
|
|
177
|
+
return d.command !== null && JSON.stringify(entry.serverCommand) === JSON.stringify(d.command);
|
|
178
|
+
return d.url !== null && urlMatches(entry.serverUrl, d.url);
|
|
179
|
+
}
|
|
180
|
+
/** The allowlist check by server type (docs: managed-mcp#how-a-server-is-evaluated). */
|
|
181
|
+
function allowed(list, d) {
|
|
182
|
+
const kind = d.url ? "serverUrl" : "serverCommand";
|
|
183
|
+
const byKind = list.filter((e) => kind in e);
|
|
184
|
+
if (byKind.some((e) => matches(e, d)))
|
|
185
|
+
return true;
|
|
186
|
+
return byKind.length === 0 && list.some((e) => "serverName" in e && matches(e, d));
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Every MCP server definition, each marked with whether Claude Code would start it here
|
|
190
|
+
* (docs: mcp#scope-hierarchy-and-precedence, managed-mcp). ccdesk never connects, so an active
|
|
191
|
+
* server is one that is configured and allowed, not one that is known to work.
|
|
192
|
+
*/
|
|
193
|
+
export async function readMcpServers(input, plugins) {
|
|
194
|
+
const { env, projectDir, layers, claudeJson, trust, profile } = input;
|
|
195
|
+
const projects = isRecord(claudeJson?.projects) ? claudeJson.projects : {};
|
|
196
|
+
// Local servers live in the entry under the trust key, even from a subdirectory: claude mcp add
|
|
197
|
+
// --scope local writes there and claude mcp list reads only that entry, ignoring one for the
|
|
198
|
+
// subdirectory (observed with v2.1.296; the docs do not say).
|
|
199
|
+
const entry = projects[trust.root];
|
|
200
|
+
const project = isRecord(entry) ? entry : {};
|
|
201
|
+
const managed = layers.find((l) => l.scope === "managed");
|
|
202
|
+
// Checked as written: the layer has header values hidden, which would hide a ${VAR} in one.
|
|
203
|
+
const managedServers = (await readManagedSettings(env)).data?.managedMcpServers;
|
|
204
|
+
const managedFile = join(env.managedDir, "managed-mcp.json");
|
|
205
|
+
const managedJson = await readJsonObject(env, managedFile);
|
|
206
|
+
const mcpJson = join(projectDir, ".mcp.json");
|
|
207
|
+
const shared = await readJsonObject(env, mcpJson);
|
|
208
|
+
const defined = [
|
|
209
|
+
...(managedJson.ok ? define(managedJson.data.mcpServers, "managed", managedFile, true) : []),
|
|
210
|
+
...defineManaged(managedServers, managed?.path ?? ""),
|
|
211
|
+
...define(project.mcpServers, "local", env.claudeJson),
|
|
212
|
+
...(shared.ok ? define(shared.data.mcpServers, "project", mcpJson) : []),
|
|
213
|
+
...define(claudeJson?.mcpServers, "user", env.claudeJson),
|
|
214
|
+
];
|
|
215
|
+
for (const plugin of plugins) {
|
|
216
|
+
const path = join(plugin.installPath, ".mcp.json");
|
|
217
|
+
const file = await readJsonObject(env, path);
|
|
218
|
+
// A server name declared later in the same plugin replaces an earlier one.
|
|
219
|
+
const own = new Map();
|
|
220
|
+
const add = (list) => {
|
|
221
|
+
for (const d of list)
|
|
222
|
+
own.set(d.server.name, d);
|
|
223
|
+
};
|
|
224
|
+
if (file.ok)
|
|
225
|
+
add(define(file.data.mcpServers ?? file.data, "plugin", path));
|
|
226
|
+
for (const part of await manifestConfigs(env, plugin, "mcpServers"))
|
|
227
|
+
add(define(part.value, "plugin", part.path));
|
|
228
|
+
for (const d of own.values())
|
|
229
|
+
d.server.plugin = plugin.id;
|
|
230
|
+
defined.push(...own.values());
|
|
231
|
+
}
|
|
232
|
+
const exclusive = managedJson.ok;
|
|
233
|
+
const denied = layers.flatMap((l) => policyEntries(l.data?.deniedMcpServers));
|
|
234
|
+
const managedOnly = managed?.data?.allowManagedMcpServersOnly === true;
|
|
235
|
+
const allowLayers = layers.filter((l) => !managedOnly || l.scope === "managed");
|
|
236
|
+
const allowSet = allowLayers.some((l) => Array.isArray(l.data?.allowedMcpServers));
|
|
237
|
+
const allowList = allowLayers.flatMap((l) => policyEntries(l.data?.allowedMcpServers));
|
|
238
|
+
const disabledHere = stringsIn(project.disabledMcpServers);
|
|
239
|
+
// A cloned repository cannot approve its own servers: project and local approvals count once the
|
|
240
|
+
// folder is trusted, local ones in your configuration home right away (docs: mcp#project-server-approvals-and-workspace-trust).
|
|
241
|
+
// A disabledMcpjsonServers entry in any file rejects the server.
|
|
242
|
+
const approvalCounts = (scope) => (scope !== "project" && scope !== "local") ||
|
|
243
|
+
trust.trusted ||
|
|
244
|
+
(scope === "local" && isConfigHome(env, projectDir));
|
|
245
|
+
// Your answers to the .mcp.json prompt sit in the project entry: claude mcp list counts an
|
|
246
|
+
// enableAllProjectMcpServers there, and claude mcp reset-project-choices clears it (v2.1.296).
|
|
247
|
+
const sources = [
|
|
248
|
+
...layers.flatMap((l) => (l.data ? [{ source: l.scope, path: l.path, data: l.data }] : [])),
|
|
249
|
+
{ source: "claude-json", path: env.claudeJson, data: project },
|
|
250
|
+
];
|
|
251
|
+
const answersFor = (name) => sources.flatMap(({ source, path, data }) => {
|
|
252
|
+
const counts = source === "claude-json" || approvalCounts(source);
|
|
253
|
+
const out = [];
|
|
254
|
+
if (data.enableAllProjectMcpServers === true)
|
|
255
|
+
out.push({ key: "enableAllProjectMcpServers", source, path, counts });
|
|
256
|
+
if (stringsIn(data.enabledMcpjsonServers).includes(name))
|
|
257
|
+
out.push({ key: "enabledMcpjsonServers", source, path, counts });
|
|
258
|
+
if (stringsIn(data.disabledMcpjsonServers).includes(name))
|
|
259
|
+
out.push({ key: "disabledMcpjsonServers", source, path, counts: true });
|
|
260
|
+
return out;
|
|
261
|
+
});
|
|
262
|
+
const from = (a) => a.source === "claude-json" ? "your answer in ~/.claude.json" : `${a.source} settings`;
|
|
263
|
+
const set = (server, status, note) => {
|
|
264
|
+
server.status = status;
|
|
265
|
+
server.note = note;
|
|
266
|
+
};
|
|
267
|
+
const byName = new Map();
|
|
268
|
+
const enabledEndpoints = new Map();
|
|
269
|
+
for (const d of defined) {
|
|
270
|
+
const { server } = d;
|
|
271
|
+
if (server.status === "invalid")
|
|
272
|
+
continue;
|
|
273
|
+
if (exclusive && !d.organisation && server.scope !== "managed") {
|
|
274
|
+
set(server, "blocked", "managed-mcp.json takes exclusive control of MCP servers");
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
// Scopes dedupe by name; plugins by endpoint against the enabled servers above them.
|
|
278
|
+
const earlier = server.scope === "plugin" ? enabledEndpoints.get(endpoint(d)) : byName.get(server.name);
|
|
279
|
+
if (earlier) {
|
|
280
|
+
set(server, "shadowed", `The ${earlier.server.scope} definition of ${earlier.server.name} is used instead`);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (server.scope !== "plugin")
|
|
284
|
+
byName.set(server.name, d);
|
|
285
|
+
if (denied.some((e) => matches(e, d)))
|
|
286
|
+
set(server, "blocked", "Blocked by deniedMcpServers");
|
|
287
|
+
else if (allowSet && !d.organisation && !allowed(allowList, d))
|
|
288
|
+
set(server, "blocked", managedOnly ? "Not in the managed allowedMcpServers" : "Not in allowedMcpServers");
|
|
289
|
+
else if (disabledHere.includes(server.name))
|
|
290
|
+
set(server, "disabled", "Turned off for this project in /mcp");
|
|
291
|
+
else if (server.scope === "project") {
|
|
292
|
+
const answers = answersFor(server.name);
|
|
293
|
+
if (answers.length > 0)
|
|
294
|
+
server.approvals = answers;
|
|
295
|
+
const rejection = answers.find((a) => a.key === "disabledMcpjsonServers");
|
|
296
|
+
const waiting = answers.find((a) => a.key !== "disabledMcpjsonServers" && !a.counts);
|
|
297
|
+
const approved = answers.some((a) => a.key !== "disabledMcpjsonServers" && a.counts);
|
|
298
|
+
if (rejection)
|
|
299
|
+
set(server, "rejected", `Rejected by disabledMcpjsonServers in ${from(rejection)}`);
|
|
300
|
+
// claude -p connects them without asking, approved or not (docs: permissions#what-runs-before-you-trust-a-folder).
|
|
301
|
+
else if (!approved && profile === "print")
|
|
302
|
+
server.note =
|
|
303
|
+
"Not approved, but claude -p connects .mcp.json servers without asking; the SDK does when settingSources includes project";
|
|
304
|
+
else if (!approved)
|
|
305
|
+
set(server, "pending", waiting
|
|
306
|
+
? `Claude Code asks before starting it: ${waiting.key} in ${from(waiting)} counts once you trust this folder`
|
|
307
|
+
: "Claude Code asks before starting servers from .mcp.json: answer its prompt, or list the server in enabledMcpjsonServers in your user settings");
|
|
308
|
+
}
|
|
309
|
+
if (server.status === "active" || server.status === "pending")
|
|
310
|
+
enabledEndpoints.set(endpoint(d), d);
|
|
311
|
+
}
|
|
312
|
+
return defined.map((d) => d.server);
|
|
313
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import {} from "../../shared/types.js";
|
|
3
|
+
import {} from "./env.js";
|
|
4
|
+
import { isDir, listDir, parseFrontmatter, readTextFile, revisionOf, shortHash, stringField, } from "./files.js";
|
|
5
|
+
const INDEX_FILE = "MEMORY.md";
|
|
6
|
+
/** Claude Code reads only this much of MEMORY.md at session start (docs: memory#auto-memory). */
|
|
7
|
+
const INDEX_LINE_LIMIT = 200;
|
|
8
|
+
const INDEX_BYTE_LIMIT = 25 * 1024;
|
|
9
|
+
const TYPES = ["user", "feedback", "project", "reference"];
|
|
10
|
+
export async function readMemoryStore(env, dir, projectPath) {
|
|
11
|
+
if (!(await isDir(dir)))
|
|
12
|
+
return null;
|
|
13
|
+
const indexFile = await readTextFile(env, join(dir, INDEX_FILE));
|
|
14
|
+
const files = (await listDir(dir)).filter((f) => f.endsWith(".md") && f !== INDEX_FILE);
|
|
15
|
+
const memories = [];
|
|
16
|
+
for (const file of files) {
|
|
17
|
+
const text = await readTextFile(env, join(dir, file));
|
|
18
|
+
if (!text)
|
|
19
|
+
continue;
|
|
20
|
+
const { data, body } = parseFrontmatter(text.text);
|
|
21
|
+
const metadata = typeof data.metadata === "object" && data.metadata !== null
|
|
22
|
+
? data.metadata
|
|
23
|
+
: {};
|
|
24
|
+
const rawType = stringField(metadata, "type") || stringField(data, "type");
|
|
25
|
+
memories.push({
|
|
26
|
+
file,
|
|
27
|
+
path: text.path,
|
|
28
|
+
name: stringField(data, "name") || file.replace(/\.md$/, ""),
|
|
29
|
+
description: stringField(data, "description"),
|
|
30
|
+
type: TYPES.includes(rawType) ? rawType : "unknown",
|
|
31
|
+
body,
|
|
32
|
+
mtime: text.mtime,
|
|
33
|
+
bytes: text.bytes,
|
|
34
|
+
revision: revisionOf(text.text),
|
|
35
|
+
inIndex: indexFile ? indexMentions(indexFile.text, file) : false,
|
|
36
|
+
hash: shortHash(body.trim()),
|
|
37
|
+
links: wikiLinks(body),
|
|
38
|
+
brokenLinks: [],
|
|
39
|
+
linkedFrom: [],
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const names = new Set(memories.map((m) => m.name));
|
|
43
|
+
for (const memory of memories) {
|
|
44
|
+
memory.brokenLinks = memory.links.filter((link) => !names.has(link));
|
|
45
|
+
for (const link of memory.links) {
|
|
46
|
+
const target = memories.find((m) => m.name === link);
|
|
47
|
+
if (target && target !== memory && !target.linkedFrom.includes(memory.name))
|
|
48
|
+
target.linkedFrom.push(memory.name);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
dir,
|
|
53
|
+
loaded: true,
|
|
54
|
+
projectPath,
|
|
55
|
+
index: indexFile ? summarizeIndex(indexFile, files) : null,
|
|
56
|
+
memories,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function summarizeIndex(file, files) {
|
|
60
|
+
// A file's last newline ends its last line; it does not start another one.
|
|
61
|
+
const lines = file.text.replace(/\n$/, "").split("\n");
|
|
62
|
+
let loadedLines = 0;
|
|
63
|
+
let bytes = 0;
|
|
64
|
+
for (const line of lines) {
|
|
65
|
+
bytes += Buffer.byteLength(line, "utf8") + 1;
|
|
66
|
+
if (loadedLines >= INDEX_LINE_LIMIT || bytes > INDEX_BYTE_LIMIT)
|
|
67
|
+
break;
|
|
68
|
+
loadedLines++;
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
...file,
|
|
72
|
+
revision: revisionOf(file.text),
|
|
73
|
+
truncated: loadedLines < lines.length,
|
|
74
|
+
loadedLines,
|
|
75
|
+
missingFiles: [...new Set(file.text.split("\n").flatMap((l) => deadLinks(l, files)))],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** Memory files an index line links to that are not in the store. */
|
|
79
|
+
export function deadLinks(line, files) {
|
|
80
|
+
return [...line.matchAll(/\]\(([^)\s/\\]+\.md)\)/g)]
|
|
81
|
+
.map((m) => m[1] ?? "")
|
|
82
|
+
.filter((f) => !files.includes(f));
|
|
83
|
+
}
|
|
84
|
+
/** Whether MEMORY.md text points at a memory file, as a Markdown link or a [[name]] link. */
|
|
85
|
+
export function indexMentions(index, file) {
|
|
86
|
+
return index.includes(`(${file})`) || index.includes(`[[${file.replace(/\.md$/, "")}]]`);
|
|
87
|
+
}
|
|
88
|
+
function wikiLinks(body) {
|
|
89
|
+
return [...new Set([...body.matchAll(/\[\[([^\]\n|]+)\]\]/g)].map((m) => (m[1] ?? "").trim()))];
|
|
90
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { dirname, isAbsolute, join, matchesGlob, relative, resolve, sep } from "node:path";
|
|
2
|
+
import {} from "../../shared/types.js";
|
|
3
|
+
import {} from "./env.js";
|
|
4
|
+
import { isFile, isWithin, listDir, parseFrontmatter, readTextFile, stringsIn, toStringList, walkMarkdown, } from "./files.js";
|
|
5
|
+
import { scanDirectory } from "./index.js";
|
|
6
|
+
import { applySkillOverrides, readSkill } from "./inventory.js";
|
|
7
|
+
/** Globs are written with forward slashes on every platform. */
|
|
8
|
+
function slashed(path) {
|
|
9
|
+
return path.split(sep).join("/");
|
|
10
|
+
}
|
|
11
|
+
function firstMatch(globs, path) {
|
|
12
|
+
return globs.find((glob) => {
|
|
13
|
+
try {
|
|
14
|
+
return matchesGlob(path, glob);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// An invalid pattern matches nothing; the others still count (docs: memory#path-specific-rules).
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* What Claude Code adds to context once Claude reads or edits `file` in a session started in `dir`:
|
|
24
|
+
* path-scoped rules, skills with matching `paths`, and CLAUDE.md files in the folders between.
|
|
25
|
+
*/
|
|
26
|
+
export async function testPath(env, dir, file,
|
|
27
|
+
/** The view of the start directory; a server passes one that reuses a current scan. */
|
|
28
|
+
viewOf = (cwd) => scanDirectory(env, cwd)) {
|
|
29
|
+
const cwd = resolve(dir);
|
|
30
|
+
const target = isAbsolute(file) ? resolve(file) : resolve(cwd, file);
|
|
31
|
+
if (target === cwd || !isWithin(cwd, target))
|
|
32
|
+
return { file: target, inside: false, exists: false, matches: [] };
|
|
33
|
+
const view = await viewOf(cwd);
|
|
34
|
+
const projectRoot = view.gitRoot ?? cwd;
|
|
35
|
+
const matches = [];
|
|
36
|
+
const excludes = stringsIn(view.effective.find((e) => e.key === "claudeMdExcludes")?.value);
|
|
37
|
+
const overrides = view.effective.find((e) => e.key === "skillOverrides")?.value;
|
|
38
|
+
const excluded = (path) => excludes.some((glob) => firstMatch([glob], path) !== undefined);
|
|
39
|
+
// Path rules Claude Code would use at all: not excluded, not waiting for approval.
|
|
40
|
+
for (const rule of view.instructions) {
|
|
41
|
+
if (!rule.paths || rule.status !== "on-match")
|
|
42
|
+
continue;
|
|
43
|
+
// A project rule's globs are relative to the folder holding its .claude; others to the project.
|
|
44
|
+
const base = rule.scope === "project" || rule.scope === "local" ? rule.dir : projectRoot;
|
|
45
|
+
const glob = firstMatch(rule.paths, slashed(relative(base, target)));
|
|
46
|
+
if (glob)
|
|
47
|
+
matches.push({ kind: "rule", path: rule.path, scope: rule.scope, glob });
|
|
48
|
+
}
|
|
49
|
+
// Folders below the start directory load their own instructions, rules and skills on demand.
|
|
50
|
+
const mode = view.agentsMdMode;
|
|
51
|
+
const startSkills = new Set(view.inventory.skills.filter((s) => s.scope !== "plugin").map((s) => s.name));
|
|
52
|
+
const between = [];
|
|
53
|
+
for (let d = dirname(target); d !== cwd && isWithin(cwd, d); d = dirname(d))
|
|
54
|
+
between.unshift(d);
|
|
55
|
+
for (const folder of between) {
|
|
56
|
+
const nested = async (name, scope) => {
|
|
57
|
+
const path = join(folder, name);
|
|
58
|
+
if (excluded(path) || !(await readTextFile(env, path)))
|
|
59
|
+
return false;
|
|
60
|
+
matches.push({ kind: "nested", path, scope });
|
|
61
|
+
return true;
|
|
62
|
+
};
|
|
63
|
+
// managed-only leaves out files at launch, but a subfolder's CLAUDE.md and rules still load
|
|
64
|
+
// on demand (docs: memory#choose-which-instruction-files-load).
|
|
65
|
+
let hasClaudeMd = false;
|
|
66
|
+
for (const name of ["CLAUDE.md", ".claude/CLAUDE.md"])
|
|
67
|
+
if (await nested(name, "project"))
|
|
68
|
+
hasClaudeMd = true;
|
|
69
|
+
if (mode !== "managed-only" && (await nested("CLAUDE.local.md", "local")))
|
|
70
|
+
hasClaudeMd = true;
|
|
71
|
+
const agents = mode === "claude-md-and-agents-md" || (mode === "claude-md-or-agents-md" && !hasClaudeMd);
|
|
72
|
+
if (agents)
|
|
73
|
+
await nested("AGENTS.md", "project");
|
|
74
|
+
for (const path of await walkMarkdown(join(folder, ".claude", "rules"))) {
|
|
75
|
+
const text = await readTextFile(env, path);
|
|
76
|
+
if (!text || excluded(path))
|
|
77
|
+
continue;
|
|
78
|
+
const globs = toStringList(parseFrontmatter(text.text).data.paths);
|
|
79
|
+
const glob = globs.length === 0 ? undefined : firstMatch(globs, slashed(relative(folder, target)));
|
|
80
|
+
if (globs.length === 0 || glob)
|
|
81
|
+
matches.push({ kind: "nested", path, scope: "project", ...(glob ? { glob } : {}) });
|
|
82
|
+
}
|
|
83
|
+
// Only skills Claude may pick itself: not off, user-invocable-only or disable-model-invocation;
|
|
84
|
+
// one with paths only for a matching file, its globs read from its folder like a rule's.
|
|
85
|
+
for (const name of await listDir(join(folder, ".claude", "skills"))) {
|
|
86
|
+
const skill = await readSkill(env, join(folder, ".claude", "skills", name), "project");
|
|
87
|
+
if (!skill)
|
|
88
|
+
continue;
|
|
89
|
+
applySkillOverrides([skill], overrides);
|
|
90
|
+
if (skill.listed === false)
|
|
91
|
+
continue;
|
|
92
|
+
const glob = skill.paths && firstMatch(skill.paths, slashed(relative(folder, target)));
|
|
93
|
+
if (skill.paths && !glob)
|
|
94
|
+
continue;
|
|
95
|
+
// A nested skill whose folder name another skill has is called by its folder as well, as
|
|
96
|
+
// apps/web:deploy (docs: skills#how-a-skill-gets-its-command-name).
|
|
97
|
+
matches.push({
|
|
98
|
+
kind: "skill",
|
|
99
|
+
path: skill.path,
|
|
100
|
+
scope: "project",
|
|
101
|
+
name: startSkills.has(name) ? `${slashed(relative(cwd, folder))}:${name}` : skill.name,
|
|
102
|
+
...(glob ? { glob } : {}),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Skills Claude can pick on its own; one with disable-model-invocation only runs when you call it.
|
|
107
|
+
for (const skill of view.inventory.skills) {
|
|
108
|
+
if (!skill.paths || skill.listed === false || skill.shadowedBy)
|
|
109
|
+
continue;
|
|
110
|
+
const glob = firstMatch(skill.paths, slashed(relative(projectRoot, target)));
|
|
111
|
+
if (glob)
|
|
112
|
+
matches.push({
|
|
113
|
+
kind: "skill",
|
|
114
|
+
path: skill.path,
|
|
115
|
+
scope: skill.scope,
|
|
116
|
+
glob,
|
|
117
|
+
name: skill.id ?? skill.name,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return { file: target, inside: true, exists: await isFile(target), matches };
|
|
121
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { HIGHEST_FIRST } from "../../shared/scopes.js";
|
|
2
|
+
import {} from "../../shared/types.js";
|
|
3
|
+
import { isRecord, stringsIn } from "./files.js";
|
|
4
|
+
import { ignoredModeReason } from "./settings.js";
|
|
5
|
+
/**
|
|
6
|
+
* Whether a file's trust-gated settings (permission grants, autoMemoryDirectory) wait for
|
|
7
|
+
* the workspace trust dialog. Claude Code runs git to tell a private local file from a committed one
|
|
8
|
+
* only once the folder or a parent is trusted, or in claude -p and the SDK, which count as accepted;
|
|
9
|
+
* until then the local file waits too, except in your configuration home. A committed local file
|
|
10
|
+
* waits like project settings, which -p and the SDK never apply in a folder you have not trusted
|
|
11
|
+
* (docs: permissions#when-your-local-settings-file-needs-trust, #what-runs-before-you-trust-a-folder).
|
|
12
|
+
*/
|
|
13
|
+
export function trustWaits({ trust, inConfigHome, localFromRepository, profile, }) {
|
|
14
|
+
const gitChecked = profile === "print" || trust.inheritedFrom !== undefined;
|
|
15
|
+
const localWaits = !inConfigHome && !trust.trusted && (!gitChecked || localFromRepository);
|
|
16
|
+
return (scope) => (scope === "project" && !trust.trusted) || (scope === "local" && localWaits);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* An MCP rule with a parameter, which Claude Code skips when it reads a settings file; such a rule
|
|
20
|
+
* works only from --disallowedTools (docs: permissions#match-specific-tool-uses).
|
|
21
|
+
*/
|
|
22
|
+
export function isSkippedMcpRule(rule) {
|
|
23
|
+
return /^mcp__[^(]*\(/.test(rule);
|
|
24
|
+
}
|
|
25
|
+
/** Claude Code checks every file's rules together: deny, then ask, then allow (docs: permissions). */
|
|
26
|
+
export const CHECK_ORDER = ["deny", "ask", "allow"];
|
|
27
|
+
/**
|
|
28
|
+
* Every permission entry from every settings file, each marked with whether Claude Code applies it
|
|
29
|
+
* (docs: permissions#project-allow-rules-and-workspace-trust, settings-reference#permissions-defaultmode,
|
|
30
|
+
* #allowmanagedpermissionrulesonly).
|
|
31
|
+
*/
|
|
32
|
+
export function permissionsOf(layers, options) {
|
|
33
|
+
const sources = HIGHEST_FIRST.flatMap((scope) => layers
|
|
34
|
+
.filter((l) => l.scope === scope && isRecord(l.data?.permissions))
|
|
35
|
+
.map((l) => ({
|
|
36
|
+
scope,
|
|
37
|
+
path: l.path,
|
|
38
|
+
permissions: l.data?.permissions,
|
|
39
|
+
})));
|
|
40
|
+
const managedOnly = layers.some((l) => l.scope === "managed" && l.data?.allowManagedPermissionRulesOnly === true);
|
|
41
|
+
// Grants from repository files wait for the workspace trust dialog; deny and ask apply at once.
|
|
42
|
+
const waitsForTrust = trustWaits(options);
|
|
43
|
+
const notTrusted = "Waits until you trust this folder in Claude Code";
|
|
44
|
+
const rules = sources.flatMap(({ scope, path, permissions }) => CHECK_ORDER.flatMap((list) => stringsIn(permissions[list]).map((rule) => {
|
|
45
|
+
const base = { kind: "rule", list, rule, scope, path };
|
|
46
|
+
if (isSkippedMcpRule(rule))
|
|
47
|
+
return {
|
|
48
|
+
...base,
|
|
49
|
+
inEffect: false,
|
|
50
|
+
note: "Claude Code skips an MCP rule with parentheses in a settings file; pass it with --disallowedTools",
|
|
51
|
+
};
|
|
52
|
+
if (managedOnly && scope !== "managed")
|
|
53
|
+
return {
|
|
54
|
+
...base,
|
|
55
|
+
inEffect: false,
|
|
56
|
+
note: "Managed settings allow only their own permission rules",
|
|
57
|
+
};
|
|
58
|
+
if (list === "allow" && waitsForTrust(scope))
|
|
59
|
+
return { ...base, inEffect: false, note: notTrusted };
|
|
60
|
+
return { ...base, inEffect: true };
|
|
61
|
+
})));
|
|
62
|
+
for (const r of rules) {
|
|
63
|
+
if (!r.inEffect)
|
|
64
|
+
continue;
|
|
65
|
+
const first = CHECK_ORDER.map((list) => rules.find((o) => o.inEffect && o.list === list && o.rule === r.rule)).find((o) => o !== undefined);
|
|
66
|
+
if (first && first.list !== r.list)
|
|
67
|
+
r.decidedBy = { list: first.list, scope: first.scope };
|
|
68
|
+
}
|
|
69
|
+
const out = [...rules];
|
|
70
|
+
let modeTaken = false;
|
|
71
|
+
for (const { scope, permissions } of sources) {
|
|
72
|
+
const mode = permissions.defaultMode;
|
|
73
|
+
if (typeof mode !== "string")
|
|
74
|
+
continue;
|
|
75
|
+
let note = ignoredModeReason(scope, mode);
|
|
76
|
+
if (!note && modeTaken)
|
|
77
|
+
note = "A higher settings file sets the default mode";
|
|
78
|
+
out.push({
|
|
79
|
+
kind: "defaultMode",
|
|
80
|
+
value: mode,
|
|
81
|
+
scope,
|
|
82
|
+
inEffect: !note,
|
|
83
|
+
...(note ? { note } : {}),
|
|
84
|
+
});
|
|
85
|
+
if (!note)
|
|
86
|
+
modeTaken = true;
|
|
87
|
+
}
|
|
88
|
+
for (const { scope, permissions } of sources) {
|
|
89
|
+
for (const dir of stringsIn(permissions.additionalDirectories)) {
|
|
90
|
+
const note = waitsForTrust(scope) ? notTrusted : undefined;
|
|
91
|
+
out.push({
|
|
92
|
+
kind: "additionalDirectories",
|
|
93
|
+
value: dir,
|
|
94
|
+
scope,
|
|
95
|
+
inEffect: !note,
|
|
96
|
+
...(note ? { note } : {}),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|