ccdesk 0.2.2 → 0.2.3
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 +25 -0
- package/README.md +6 -3
- package/dist/core/agent-edit.d.ts +6 -0
- package/dist/{server → core}/agent-edit.js +15 -21
- package/dist/core/backups.d.ts +19 -0
- package/dist/{server → core}/backups.js +37 -45
- package/dist/core/core.d.ts +96 -0
- package/dist/core/core.js +57 -0
- package/dist/core/index.d.ts +22 -0
- package/dist/core/index.js +38 -0
- package/dist/core/mcp-edit.d.ts +25 -0
- package/dist/{server → core}/mcp-edit.js +115 -98
- package/dist/core/mutations.d.ts +20 -0
- package/dist/{server → core}/mutations.js +8 -15
- package/dist/core/open.d.ts +31 -0
- package/dist/{server → core}/open.js +39 -12
- package/dist/core/parse-edit.d.ts +12 -0
- package/dist/core/parse-edit.js +64 -0
- package/dist/core/reference.d.ts +3 -0
- package/dist/core/reference.js +11 -0
- package/dist/core/scan/compare.d.ts +7 -0
- package/dist/{server → core}/scan/compare.js +2 -2
- package/dist/core/scan/context.d.ts +13 -0
- package/dist/{server → core}/scan/context.js +55 -32
- package/dist/core/scan/data.d.ts +8 -0
- package/dist/core/scan/data.js +18 -0
- package/dist/core/scan/env.d.ts +26 -0
- package/dist/{server → core}/scan/env.js +7 -3
- package/dist/core/scan/files.d.ts +68 -0
- package/dist/core/scan/files.js +270 -0
- package/dist/core/scan/git.d.ts +14 -0
- package/dist/core/scan/git.js +98 -0
- package/dist/core/scan/index.d.ts +5 -0
- package/dist/{server → core}/scan/index.js +115 -81
- package/dist/core/scan/instructions.d.ts +54 -0
- package/dist/{server → core}/scan/instructions.js +141 -73
- package/dist/core/scan/inventory.d.ts +38 -0
- package/dist/{server → core}/scan/inventory.js +134 -115
- package/dist/core/scan/mcp.d.ts +28 -0
- package/dist/{server → core}/scan/mcp.js +151 -94
- package/dist/core/scan/memory.d.ts +8 -0
- package/dist/{server → core}/scan/memory.js +8 -8
- package/dist/core/scan/path-test.d.ts +9 -0
- package/dist/core/scan/path-test.js +156 -0
- package/dist/core/scan/permissions.d.ts +31 -0
- package/dist/{server → core}/scan/permissions.js +5 -8
- package/dist/core/scan/plugins.d.ts +39 -0
- package/dist/{server → core}/scan/plugins.js +36 -27
- package/dist/core/scan/projects.d.ts +9 -0
- package/dist/{server → core}/scan/projects.js +5 -4
- package/dist/core/scan/sandbox.d.ts +14 -0
- package/dist/{server → core}/scan/sandbox.js +87 -59
- package/dist/core/scan/settings.d.ts +35 -0
- package/dist/{server → core}/scan/settings.js +88 -92
- package/dist/core/scan/tools.d.ts +10 -0
- package/dist/{server → core}/scan/tools.js +28 -20
- package/dist/core/scan/usage.d.ts +25 -0
- package/dist/{server → core}/scan/usage.js +117 -95
- package/dist/core/schema.d.ts +8 -0
- package/dist/core/schema.js +67 -0
- package/dist/core/secrets.d.ts +44 -0
- package/dist/core/secrets.js +187 -0
- package/dist/core/settings-edit.d.ts +9 -0
- package/dist/{server → core}/settings-edit.js +91 -112
- package/dist/core/watch.d.ts +45 -0
- package/dist/{server → core}/watch.js +54 -17
- package/dist/core/write.d.ts +119 -0
- package/dist/{server → core}/write.js +63 -8
- package/dist/server/args.d.ts +32 -0
- package/dist/server/args.js +106 -0
- package/dist/server/cli.d.ts +2 -0
- package/dist/server/cli.js +58 -83
- package/dist/server/commands.d.ts +8 -0
- package/dist/server/commands.js +39 -0
- package/dist/server/server.d.ts +12 -0
- package/dist/server/server.js +80 -195
- package/dist/server/start.d.ts +13 -0
- package/dist/server/start.js +21 -0
- package/dist/shared/compare.d.ts +25 -0
- package/dist/shared/memory.d.ts +2 -0
- package/dist/shared/requests.d.ts +104 -0
- package/dist/shared/requests.js +73 -0
- package/dist/shared/scopes.d.ts +10 -0
- package/dist/shared/settings-catalog.json +2199 -0
- package/dist/shared/types.d.ts +530 -0
- package/dist/shared/types.js +11 -0
- package/dist/ui/assets/index-CH1HoC38.js +60 -0
- package/dist/ui/index.html +1 -1
- package/dist/view/diff.d.ts +16 -0
- package/dist/view/diff.js +85 -0
- package/dist/view/format.d.ts +17 -0
- package/dist/view/format.js +47 -0
- package/dist/view/index.d.ts +11 -0
- package/dist/view/index.js +13 -0
- package/dist/view/lines.d.ts +9 -0
- package/dist/view/lines.js +12 -0
- package/dist/view/path-test.d.ts +3 -0
- package/dist/view/path-test.js +7 -0
- package/dist/view/sections/catalog.d.ts +9 -0
- package/dist/view/sections/catalog.js +47 -0
- package/dist/view/sections/extensions.d.ts +1 -0
- package/dist/view/sections/extensions.js +48 -0
- package/dist/view/sections/hooks.d.ts +1 -0
- package/dist/view/sections/hooks.js +32 -0
- package/dist/view/sections/index.d.ts +6 -0
- package/dist/view/sections/index.js +27 -0
- package/dist/view/sections/instructions.d.ts +6 -0
- package/dist/view/sections/instructions.js +38 -0
- package/dist/view/sections/mcp.d.ts +3 -0
- package/dist/view/sections/mcp.js +38 -0
- package/dist/view/sections/memory.d.ts +1 -0
- package/dist/view/sections/memory.js +31 -0
- package/dist/view/sections/permissions.d.ts +17 -0
- package/dist/view/sections/permissions.js +87 -0
- package/dist/view/sections/settings.d.ts +1 -0
- package/dist/view/sections/settings.js +33 -0
- package/dist/view/sections/tools.d.ts +6 -0
- package/dist/view/sections/tools.js +57 -0
- package/dist/view/sections/types.d.ts +123 -0
- package/dist/view/sections/types.js +10 -0
- package/dist/view/settings.d.ts +12 -0
- package/dist/view/settings.js +31 -0
- package/dist/view/usage.d.ts +8 -0
- package/dist/view/usage.js +18 -0
- package/package.json +13 -2
- package/dist/server/scan/files.js +0 -480
- package/dist/server/scan/path-test.js +0 -136
- package/dist/server/schema.js +0 -15
- package/dist/server/secrets.js +0 -63
- package/dist/ui/assets/index-Dg9dxngR.js +0 -60
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
+
import { Compile } from "typebox/compile";
|
|
2
3
|
import {} from "../../shared/types.js";
|
|
4
|
+
import { redactSecrets, shownUrl } from "../secrets.js";
|
|
3
5
|
import { isConfigHome } from "./env.js";
|
|
4
|
-
import {
|
|
6
|
+
import { readJsonOrReport, stringField, stringsIn, wildcard } from "./files.js";
|
|
7
|
+
import { isRecord } from "./data.js";
|
|
5
8
|
import { manifestConfigs } from "./plugins.js";
|
|
6
9
|
import serverSchema from "../../shared/mcp-server.schema.json" with { type: "json" };
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
const validateServer = ajv.compile(serverSchema);
|
|
10
|
+
import { schemaErrors } from "../schema.js";
|
|
11
|
+
const validateServer = Compile(serverSchema);
|
|
10
12
|
/**
|
|
11
13
|
* What makes a server definition one Claude Code cannot start: a missing command or url, a type it
|
|
12
14
|
* does not know, or a field the docs define holding the wrong kind of value (docs: mcp). It checks
|
|
@@ -29,13 +31,20 @@ export function serverProblems(definition) {
|
|
|
29
31
|
problems.push(`A ${type} server needs a "url".`);
|
|
30
32
|
return [...problems, ...schemaErrors(validateServer, definition, "")];
|
|
31
33
|
}
|
|
34
|
+
/** What the page shows a server connects to: its URL, or its command line with values hidden. */
|
|
35
|
+
function targetOf(url, command, definition) {
|
|
36
|
+
if (url)
|
|
37
|
+
return shownUrl(url);
|
|
38
|
+
if (!command)
|
|
39
|
+
return "";
|
|
40
|
+
return [command[0], ...stringsIn(definition.args)].join(" ");
|
|
41
|
+
}
|
|
32
42
|
function define(servers, scope, sourcePath, organisation = false) {
|
|
33
43
|
if (!isRecord(servers))
|
|
34
44
|
return [];
|
|
35
|
-
return Object.entries(servers)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const c = config;
|
|
45
|
+
return Object.entries(servers).flatMap(([name, c]) => {
|
|
46
|
+
if (!isRecord(c))
|
|
47
|
+
return [];
|
|
39
48
|
const type = stringField(c, "type");
|
|
40
49
|
const url = stringField(c, "url") || null;
|
|
41
50
|
const command = stringField(c, "command")
|
|
@@ -47,11 +56,7 @@ function define(servers, scope, sourcePath, organisation = false) {
|
|
|
47
56
|
scope,
|
|
48
57
|
// An entry without a type is read as stdio, even one with a url (docs: mcp).
|
|
49
58
|
transport: type || "stdio",
|
|
50
|
-
target: url
|
|
51
|
-
? shownUrl(url)
|
|
52
|
-
: command
|
|
53
|
-
? [command[0], ...stringsIn(definition.args)].join(" ")
|
|
54
|
-
: "",
|
|
59
|
+
target: targetOf(url, command, definition),
|
|
55
60
|
sourcePath,
|
|
56
61
|
status: "active",
|
|
57
62
|
definition,
|
|
@@ -63,12 +68,14 @@ function define(servers, scope, sourcePath, organisation = false) {
|
|
|
63
68
|
}
|
|
64
69
|
// A ${VAR} in an organisation entry means it still has to pass the allowlist.
|
|
65
70
|
const usesVariables = JSON.stringify(c).includes("${");
|
|
66
|
-
return
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
return [
|
|
72
|
+
{
|
|
73
|
+
server,
|
|
74
|
+
command: type && type !== "stdio" ? null : command,
|
|
75
|
+
url: type ? url : null,
|
|
76
|
+
organisation: organisation && !usesVariables,
|
|
77
|
+
},
|
|
78
|
+
];
|
|
72
79
|
});
|
|
73
80
|
}
|
|
74
81
|
/** Control and invisible formatting characters, such as zero-width spaces and bidi overrides. */
|
|
@@ -108,6 +115,8 @@ function managedEntryProblem(name, config) {
|
|
|
108
115
|
}
|
|
109
116
|
/** managedMcpServers from managed settings, with entries Claude Code drops marked invalid. */
|
|
110
117
|
function defineManaged(servers, sourcePath) {
|
|
118
|
+
if (!isRecord(servers))
|
|
119
|
+
return [];
|
|
111
120
|
const defined = define(servers, "managed", sourcePath, true);
|
|
112
121
|
for (const d of defined) {
|
|
113
122
|
const problem = managedEntryProblem(d.server.name, servers[d.server.name]);
|
|
@@ -143,6 +152,10 @@ function policyEntries(value) {
|
|
|
143
152
|
typeof e.serverUrl === "string"));
|
|
144
153
|
}
|
|
145
154
|
/** A `serverUrl` pattern (docs: managed-mcp#how-serverurl-entries-match). */
|
|
155
|
+
const DEFAULT_PORTS = new Map([
|
|
156
|
+
["https", "443"],
|
|
157
|
+
["http", "80"],
|
|
158
|
+
]);
|
|
146
159
|
function urlMatches(pattern, url) {
|
|
147
160
|
const p = /^([^:/]+):\/\/([^/:]+)(?::([^/]*))?(\/.*)?$/.exec(pattern);
|
|
148
161
|
let u;
|
|
@@ -160,7 +173,7 @@ function urlMatches(pattern, url) {
|
|
|
160
173
|
return false;
|
|
161
174
|
if (!wildcard(host.replace(/\.$/, ""), "i").test(u.hostname.replace(/\.$/, "")))
|
|
162
175
|
return false;
|
|
163
|
-
const actualPort = u.port || (scheme_
|
|
176
|
+
const actualPort = u.port || (DEFAULT_PORTS.get(scheme_) ?? "");
|
|
164
177
|
// No port: a full hostname means the default port only, a wildcard hostname any port.
|
|
165
178
|
if (port !== undefined) {
|
|
166
179
|
if (!wildcard(port).test(actualPort))
|
|
@@ -191,64 +204,132 @@ function allowed(list, d) {
|
|
|
191
204
|
* server is one that is configured and allowed, not one that is known to work.
|
|
192
205
|
*/
|
|
193
206
|
export async function readMcpServers(input, plugins) {
|
|
194
|
-
const { env,
|
|
207
|
+
const { env, layers, claudeJson, trust, profile, problems } = input;
|
|
195
208
|
const projects = isRecord(claudeJson?.projects) ? claudeJson.projects : {};
|
|
196
209
|
// Local servers live in the entry under the trust key, even from a subdirectory: claude mcp add
|
|
197
210
|
// --scope local writes there and claude mcp list reads only that entry, ignoring one for the
|
|
198
211
|
// subdirectory (observed with v2.1.296; the docs do not say).
|
|
199
212
|
const entry = projects[trust.root];
|
|
200
213
|
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
214
|
const managedFile = join(env.managedDir, "managed-mcp.json");
|
|
205
|
-
const managedJson = await
|
|
206
|
-
const mcpJson = join(projectDir, ".mcp.json");
|
|
207
|
-
const shared = await readJsonObject(env, mcpJson);
|
|
215
|
+
const managedJson = await readJsonOrReport(env, managedFile, problems);
|
|
208
216
|
const defined = [
|
|
209
|
-
...(
|
|
210
|
-
...
|
|
217
|
+
...(await configuredServers(input, project, managedFile, managedJson)),
|
|
218
|
+
...(await pluginServers(env, plugins, problems)),
|
|
219
|
+
];
|
|
220
|
+
// A managed-mcp.json that does not parse is reported; ccdesk cannot tell which servers it lists,
|
|
221
|
+
// so it does not show it taking control.
|
|
222
|
+
const exclusive = managedJson !== null;
|
|
223
|
+
const policy = policyOf(layers);
|
|
224
|
+
const disabledHere = stringsIn(project.disabledMcpServers);
|
|
225
|
+
const answersFor = approvalAnswers(input, project);
|
|
226
|
+
const byName = new Map();
|
|
227
|
+
const enabledEndpoints = new Map();
|
|
228
|
+
for (const d of defined) {
|
|
229
|
+
const { server } = d;
|
|
230
|
+
if (server.status === "invalid")
|
|
231
|
+
continue;
|
|
232
|
+
if (exclusive && !d.organisation && server.scope !== "managed") {
|
|
233
|
+
set(server, "blocked", "managed-mcp.json takes exclusive control of MCP servers");
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
// Scopes dedupe by name; plugins by endpoint against the enabled servers above them.
|
|
237
|
+
const earlier = server.scope === "plugin" ? enabledEndpoints.get(endpoint(d)) : byName.get(server.name);
|
|
238
|
+
if (earlier) {
|
|
239
|
+
set(server, "shadowed", `The ${earlier.server.scope} definition of ${earlier.server.name} is used instead`);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (server.scope !== "plugin")
|
|
243
|
+
byName.set(server.name, d);
|
|
244
|
+
const blocked = policy.blocks(d);
|
|
245
|
+
if (blocked)
|
|
246
|
+
set(server, "blocked", blocked);
|
|
247
|
+
else if (disabledHere.includes(server.name))
|
|
248
|
+
set(server, "disabled", "Turned off for this project in /mcp");
|
|
249
|
+
else if (server.scope === "project")
|
|
250
|
+
approve(server, answersFor(server.name), profile);
|
|
251
|
+
if (server.status === "active" || server.status === "pending")
|
|
252
|
+
enabledEndpoints.set(endpoint(d), d);
|
|
253
|
+
}
|
|
254
|
+
return defined.map((d) => d.server);
|
|
255
|
+
}
|
|
256
|
+
function set(server, status, note) {
|
|
257
|
+
server.status = status;
|
|
258
|
+
server.note = note;
|
|
259
|
+
}
|
|
260
|
+
/** The servers your settings, ~/.claude.json and the project define, in precedence order. */
|
|
261
|
+
async function configuredServers(input, project, managedFile, managedJson) {
|
|
262
|
+
const { env, projectDir, layers, claudeJson, problems } = input;
|
|
263
|
+
const managed = layers.find((l) => l.scope === "managed");
|
|
264
|
+
const mcpJson = join(projectDir, ".mcp.json");
|
|
265
|
+
const shared = await readJsonOrReport(env, mcpJson, problems);
|
|
266
|
+
return [
|
|
267
|
+
...(managedJson ? define(managedJson.mcpServers, "managed", managedFile, true) : []),
|
|
268
|
+
// Checked as written: the layer has header values hidden, which would hide a ${VAR} in one.
|
|
269
|
+
...defineManaged(input.managed?.managedMcpServers, managed?.path ?? ""),
|
|
211
270
|
...define(project.mcpServers, "local", env.claudeJson),
|
|
212
|
-
...(shared
|
|
271
|
+
...(shared ? define(shared.mcpServers, "project", mcpJson) : []),
|
|
213
272
|
...define(claudeJson?.mcpServers, "user", env.claudeJson),
|
|
214
273
|
];
|
|
215
|
-
|
|
274
|
+
}
|
|
275
|
+
/** The servers each enabled plugin brings, from its .mcp.json and its manifest. */
|
|
276
|
+
async function pluginServers(env, plugins, problems) {
|
|
277
|
+
const defined = [];
|
|
278
|
+
for (const loaded of plugins) {
|
|
279
|
+
const { plugin } = loaded;
|
|
216
280
|
const path = join(plugin.installPath, ".mcp.json");
|
|
217
|
-
const file = await
|
|
281
|
+
const file = await readJsonOrReport(env, path, problems);
|
|
218
282
|
// A server name declared later in the same plugin replaces an earlier one.
|
|
219
283
|
const own = new Map();
|
|
220
284
|
const add = (list) => {
|
|
221
285
|
for (const d of list)
|
|
222
286
|
own.set(d.server.name, d);
|
|
223
287
|
};
|
|
224
|
-
if (file
|
|
225
|
-
add(define(file.
|
|
226
|
-
for (const part of await manifestConfigs(env,
|
|
288
|
+
if (file)
|
|
289
|
+
add(define(file.mcpServers ?? file, "plugin", path));
|
|
290
|
+
for (const part of await manifestConfigs(env, loaded, "mcpServers", problems))
|
|
227
291
|
add(define(part.value, "plugin", part.path));
|
|
228
292
|
for (const d of own.values())
|
|
229
293
|
d.server.plugin = plugin.id;
|
|
230
294
|
defined.push(...own.values());
|
|
231
295
|
}
|
|
232
|
-
|
|
296
|
+
return defined;
|
|
297
|
+
}
|
|
298
|
+
/** deniedMcpServers and allowedMcpServers across the settings: why a server is blocked, if it is. */
|
|
299
|
+
function policyOf(layers) {
|
|
233
300
|
const denied = layers.flatMap((l) => policyEntries(l.data?.deniedMcpServers));
|
|
234
|
-
const managedOnly = managed?.data?.allowManagedMcpServersOnly === true;
|
|
301
|
+
const managedOnly = layers.find((l) => l.scope === "managed")?.data?.allowManagedMcpServersOnly === true;
|
|
235
302
|
const allowLayers = layers.filter((l) => !managedOnly || l.scope === "managed");
|
|
236
303
|
const allowSet = allowLayers.some((l) => Array.isArray(l.data?.allowedMcpServers));
|
|
237
304
|
const allowList = allowLayers.flatMap((l) => policyEntries(l.data?.allowedMcpServers));
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
305
|
+
return {
|
|
306
|
+
blocks: (d) => {
|
|
307
|
+
if (denied.some((e) => matches(e, d)))
|
|
308
|
+
return "Blocked by deniedMcpServers";
|
|
309
|
+
if (allowSet && !d.organisation && !allowed(allowList, d))
|
|
310
|
+
return managedOnly ? "Not in the managed allowedMcpServers" : "Not in allowedMcpServers";
|
|
311
|
+
return null;
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* The answers about a .mcp.json server, each with whether it counts here. A cloned repository
|
|
317
|
+
* cannot approve its own servers: project and local approvals count once the folder is trusted,
|
|
318
|
+
* local ones in your configuration home right away (docs: mcp#project-server-approvals-and-workspace-trust).
|
|
319
|
+
* A disabledMcpjsonServers entry in any file rejects the server. Your answers to the .mcp.json
|
|
320
|
+
* prompt sit in the project entry: claude mcp list counts an enableAllProjectMcpServers there, and
|
|
321
|
+
* claude mcp reset-project-choices clears it (v2.1.296).
|
|
322
|
+
*/
|
|
323
|
+
function approvalAnswers(input, project) {
|
|
324
|
+
const { env, projectDir, layers, trust } = input;
|
|
242
325
|
const approvalCounts = (scope) => (scope !== "project" && scope !== "local") ||
|
|
243
326
|
trust.trusted ||
|
|
244
327
|
(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
328
|
const sources = [
|
|
248
329
|
...layers.flatMap((l) => (l.data ? [{ source: l.scope, path: l.path, data: l.data }] : [])),
|
|
249
330
|
{ source: "claude-json", path: env.claudeJson, data: project },
|
|
250
331
|
];
|
|
251
|
-
|
|
332
|
+
return (name) => sources.flatMap(({ source, path, data }) => {
|
|
252
333
|
const counts = source === "claude-json" || approvalCounts(source);
|
|
253
334
|
const out = [];
|
|
254
335
|
if (data.enableAllProjectMcpServers === true)
|
|
@@ -259,55 +340,31 @@ export async function readMcpServers(input, plugins) {
|
|
|
259
340
|
out.push({ key: "disabledMcpjsonServers", source, path, counts: true });
|
|
260
341
|
return out;
|
|
261
342
|
});
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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);
|
|
343
|
+
}
|
|
344
|
+
/** Where an approval answer was given, as the page names it. */
|
|
345
|
+
function answerSource(a) {
|
|
346
|
+
return a.source === "claude-json" ? "your answer in ~/.claude.json" : `${a.source} settings`;
|
|
347
|
+
}
|
|
348
|
+
/** Marks a .mcp.json server rejected, waiting for approval, or left active by its answers. */
|
|
349
|
+
function approve(server, answers, profile) {
|
|
350
|
+
if (answers.length > 0)
|
|
351
|
+
server.approvals = answers;
|
|
352
|
+
const rejection = answers.find((a) => a.key === "disabledMcpjsonServers");
|
|
353
|
+
const waiting = answers.find((a) => a.key !== "disabledMcpjsonServers" && !a.counts);
|
|
354
|
+
const approved = answers.some((a) => a.key !== "disabledMcpjsonServers" && a.counts);
|
|
355
|
+
if (rejection) {
|
|
356
|
+
set(server, "rejected", `Rejected by disabledMcpjsonServers in ${answerSource(rejection)}`);
|
|
357
|
+
return;
|
|
311
358
|
}
|
|
312
|
-
|
|
359
|
+
if (approved)
|
|
360
|
+
return;
|
|
361
|
+
// claude -p connects them without asking, approved or not (docs: permissions#what-runs-before-you-trust-a-folder).
|
|
362
|
+
if (profile === "print") {
|
|
363
|
+
server.note =
|
|
364
|
+
"Not approved, but claude -p connects .mcp.json servers without asking; the SDK does when settingSources includes project";
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
set(server, "pending", waiting
|
|
368
|
+
? `Claude Code asks before starting it: ${waiting.key} in ${answerSource(waiting)} counts once you trust this folder`
|
|
369
|
+
: "Claude Code asks before starting servers from .mcp.json: answer its prompt, or list the server in enabledMcpjsonServers in your user settings");
|
|
313
370
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type MemoryStore, type Problem } from "../../shared/types.ts";
|
|
2
|
+
import { type ScanEnv } from "./env.ts";
|
|
3
|
+
/** The memory files in `dir`; a file that is there but cannot be read goes into `problems`. */
|
|
4
|
+
export declare function readMemoryStore(env: ScanEnv, dir: string, projectPath: string | null, problems?: Problem[]): Promise<MemoryStore | null>;
|
|
5
|
+
/** Memory files an index line links to that are not in the store. */
|
|
6
|
+
export declare function deadLinks(line: string, files: string[]): string[];
|
|
7
|
+
/** Whether MEMORY.md text points at a memory file, as a Markdown link or a [[name]] link. */
|
|
8
|
+
export declare function indexMentions(index: string, file: string): boolean;
|
|
@@ -1,33 +1,33 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import {} from "../../shared/types.js";
|
|
3
3
|
import {} from "./env.js";
|
|
4
|
-
import { isDir, listDir,
|
|
4
|
+
import { isDir, listDir, readTextFile, revisionOf, shortHash, stringField } from "./files.js";
|
|
5
|
+
import { isRecord, parseFrontmatter } from "./data.js";
|
|
5
6
|
const INDEX_FILE = "MEMORY.md";
|
|
6
7
|
/** Claude Code reads only this much of MEMORY.md at session start (docs: memory#auto-memory). */
|
|
7
8
|
const INDEX_LINE_LIMIT = 200;
|
|
8
9
|
const INDEX_BYTE_LIMIT = 25 * 1024;
|
|
9
10
|
const TYPES = ["user", "feedback", "project", "reference"];
|
|
10
|
-
|
|
11
|
+
/** The memory files in `dir`; a file that is there but cannot be read goes into `problems`. */
|
|
12
|
+
export async function readMemoryStore(env, dir, projectPath, problems) {
|
|
11
13
|
if (!(await isDir(dir)))
|
|
12
14
|
return null;
|
|
13
|
-
const indexFile = await readTextFile(env, join(dir, INDEX_FILE));
|
|
15
|
+
const indexFile = await readTextFile(env, join(dir, INDEX_FILE), problems);
|
|
14
16
|
const files = (await listDir(dir)).filter((f) => f.endsWith(".md") && f !== INDEX_FILE);
|
|
15
17
|
const memories = [];
|
|
16
18
|
for (const file of files) {
|
|
17
|
-
const text = await readTextFile(env, join(dir, file));
|
|
19
|
+
const text = await readTextFile(env, join(dir, file), problems);
|
|
18
20
|
if (!text)
|
|
19
21
|
continue;
|
|
20
22
|
const { data, body } = parseFrontmatter(text.text);
|
|
21
|
-
const metadata =
|
|
22
|
-
? data.metadata
|
|
23
|
-
: {};
|
|
23
|
+
const metadata = isRecord(data.metadata) ? data.metadata : {};
|
|
24
24
|
const rawType = stringField(metadata, "type") || stringField(data, "type");
|
|
25
25
|
memories.push({
|
|
26
26
|
file,
|
|
27
27
|
path: text.path,
|
|
28
28
|
name: stringField(data, "name") || file.replace(/\.md$/, ""),
|
|
29
29
|
description: stringField(data, "description"),
|
|
30
|
-
type: TYPES.
|
|
30
|
+
type: TYPES.find((t) => t === rawType) ?? "unknown",
|
|
31
31
|
body,
|
|
32
32
|
mtime: text.mtime,
|
|
33
33
|
bytes: text.bytes,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type DirectoryView, type PathTest } from "../../shared/types.ts";
|
|
2
|
+
import { type ScanEnv } from "./env.ts";
|
|
3
|
+
/**
|
|
4
|
+
* What Claude Code adds to context once Claude reads or edits `file` in a session started in `dir`:
|
|
5
|
+
* path-scoped rules, skills with matching `paths`, and CLAUDE.md files in the folders between.
|
|
6
|
+
*/
|
|
7
|
+
export declare function testPath(env: ScanEnv, dir: string, file: string,
|
|
8
|
+
/** The view of the start directory; a server passes one that reuses a current scan. */
|
|
9
|
+
viewOf?: (cwd: string) => Promise<DirectoryView>): Promise<PathTest>;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
2
|
+
import {} from "../../shared/types.js";
|
|
3
|
+
import {} from "./env.js";
|
|
4
|
+
import { isFile, isWithin, listDir, MAX_TEXT_BYTES, readTextFile, resolvedPath, stringsIn, toStringList, walkMarkdown, } from "./files.js";
|
|
5
|
+
import { parseFrontmatter } from "./data.js";
|
|
6
|
+
import { scanDirectory } from "./index.js";
|
|
7
|
+
import { AGENTS_MD_NAMES, agentsMdLoads, CLAUDE_MD_NAMES, isExcluded, linkedRuleBlock, ruleTarget, safeMatch, } from "./instructions.js";
|
|
8
|
+
import { applySkillOverrides, readSkill } from "./inventory.js";
|
|
9
|
+
/** Globs are written with forward slashes on every platform. */
|
|
10
|
+
function slashed(path) {
|
|
11
|
+
return path.split(sep).join("/");
|
|
12
|
+
}
|
|
13
|
+
/** An invalid pattern matches nothing; the others still count (docs: memory#path-specific-rules). */
|
|
14
|
+
function firstMatch(globs, path) {
|
|
15
|
+
return globs.find((glob) => safeMatch(path, glob));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* What Claude Code adds to context once Claude reads or edits `file` in a session started in `dir`:
|
|
19
|
+
* path-scoped rules, skills with matching `paths`, and CLAUDE.md files in the folders between.
|
|
20
|
+
*/
|
|
21
|
+
export async function testPath(env, dir, file,
|
|
22
|
+
/** The view of the start directory; a server passes one that reuses a current scan. */
|
|
23
|
+
viewOf = (cwd) => scanDirectory(env, cwd)) {
|
|
24
|
+
const cwd = resolve(dir);
|
|
25
|
+
const target = isAbsolute(file) ? resolve(file) : resolve(cwd, file);
|
|
26
|
+
if (target === cwd || !isWithin(cwd, target))
|
|
27
|
+
return { file: target, inside: false, exists: false, matches: [] };
|
|
28
|
+
const view = await viewOf(cwd);
|
|
29
|
+
const projectRoot = view.gitRoot ?? cwd;
|
|
30
|
+
const test = {
|
|
31
|
+
env,
|
|
32
|
+
cwd,
|
|
33
|
+
target,
|
|
34
|
+
realCwd: await resolvedPath(cwd),
|
|
35
|
+
approved: view.trust.externalImports === "approved",
|
|
36
|
+
mode: view.agentsMdMode,
|
|
37
|
+
excludes: stringsIn(view.effective.find((e) => e.key === "claudeMdExcludes")?.value),
|
|
38
|
+
overrides: view.effective.find((e) => e.key === "skillOverrides")?.value,
|
|
39
|
+
startSkills: new Set(view.inventory.skills.filter((s) => s.scope !== "plugin").map((s) => s.name)),
|
|
40
|
+
};
|
|
41
|
+
const matches = startRuleMatches(view, target, projectRoot);
|
|
42
|
+
// Folders below the start directory load their own instructions, rules and skills on demand.
|
|
43
|
+
const between = [];
|
|
44
|
+
for (let d = dirname(target); d !== cwd && isWithin(cwd, d); d = dirname(d))
|
|
45
|
+
between.unshift(d);
|
|
46
|
+
for (const folder of between)
|
|
47
|
+
matches.push(...(await nestedInstructions(test, folder)), ...(await nestedRules(test, folder)), ...(await nestedSkills(test, folder)));
|
|
48
|
+
matches.push(...startSkillMatches(view, target, projectRoot));
|
|
49
|
+
return { file: target, inside: true, exists: await isFile(target), matches };
|
|
50
|
+
}
|
|
51
|
+
/** Path rules Claude Code would use at all: not excluded, not waiting for approval. */
|
|
52
|
+
function startRuleMatches(view, target, projectRoot) {
|
|
53
|
+
const matches = [];
|
|
54
|
+
for (const rule of view.instructions) {
|
|
55
|
+
if (!rule.paths || rule.status !== "on-match")
|
|
56
|
+
continue;
|
|
57
|
+
// A project rule's globs are relative to the folder holding its .claude; others to the project.
|
|
58
|
+
const base = rule.scope === "project" || rule.scope === "local" ? rule.dir : projectRoot;
|
|
59
|
+
const glob = firstMatch(rule.paths, slashed(relative(base, target)));
|
|
60
|
+
if (glob)
|
|
61
|
+
matches.push({ kind: "rule", path: rule.path, scope: rule.scope, glob });
|
|
62
|
+
}
|
|
63
|
+
return matches;
|
|
64
|
+
}
|
|
65
|
+
/** A folder's CLAUDE.md, CLAUDE.local.md and AGENTS.md files that load on demand. */
|
|
66
|
+
async function nestedInstructions(test, folder) {
|
|
67
|
+
const { env, excludes, mode } = test;
|
|
68
|
+
const matches = [];
|
|
69
|
+
/** Whether the file is there; an excluded one or one over the limit is there but not loaded. */
|
|
70
|
+
const nested = async (name, scope) => {
|
|
71
|
+
const path = join(folder, name);
|
|
72
|
+
const text = await readTextFile(env, path);
|
|
73
|
+
if (!text)
|
|
74
|
+
return false;
|
|
75
|
+
if (!isExcluded(excludes, path, text.realPath) && text.bytes <= MAX_TEXT_BYTES)
|
|
76
|
+
matches.push({ kind: "nested", path, scope });
|
|
77
|
+
return true;
|
|
78
|
+
};
|
|
79
|
+
// managed-only leaves out files at launch, but a subfolder's CLAUDE.md and rules still load
|
|
80
|
+
// on demand (docs: memory#choose-which-instruction-files-load).
|
|
81
|
+
let hasClaudeMd = false;
|
|
82
|
+
for (const name of CLAUDE_MD_NAMES)
|
|
83
|
+
if (await nested(name, "project"))
|
|
84
|
+
hasClaudeMd = true;
|
|
85
|
+
if (mode !== "managed-only" && (await nested("CLAUDE.local.md", "local")))
|
|
86
|
+
hasClaudeMd = true;
|
|
87
|
+
if (agentsMdLoads(mode, hasClaudeMd))
|
|
88
|
+
for (const name of AGENTS_MD_NAMES)
|
|
89
|
+
await nested(name, "project");
|
|
90
|
+
return matches;
|
|
91
|
+
}
|
|
92
|
+
/** A folder's rules that load for the file: ones without paths, and ones whose paths match it. */
|
|
93
|
+
async function nestedRules(test, folder) {
|
|
94
|
+
const { env, target, realCwd, approved, excludes } = test;
|
|
95
|
+
const matches = [];
|
|
96
|
+
for (const path of await walkMarkdown(join(folder, ".claude", "rules"))) {
|
|
97
|
+
const text = await readTextFile(env, path);
|
|
98
|
+
if (!text || text.bytes > MAX_TEXT_BYTES)
|
|
99
|
+
continue;
|
|
100
|
+
// As at startup: excluded by its target's path too, and one linked from outside the project
|
|
101
|
+
// waits for your approval, then loads only without paths.
|
|
102
|
+
const linked = await ruleTarget(text, folder, realCwd);
|
|
103
|
+
if (isExcluded(excludes, path, linked))
|
|
104
|
+
continue;
|
|
105
|
+
const globs = toStringList(parseFrontmatter(text.text).data.paths);
|
|
106
|
+
if (linkedRuleBlock(linked, realCwd, approved, globs.length > 0))
|
|
107
|
+
continue;
|
|
108
|
+
const glob = globs.length === 0 ? undefined : firstMatch(globs, slashed(relative(folder, target)));
|
|
109
|
+
if (globs.length === 0 || glob)
|
|
110
|
+
matches.push({ kind: "nested", path, scope: "project", ...(glob ? { glob } : {}) });
|
|
111
|
+
}
|
|
112
|
+
return matches;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* A folder's skills Claude may pick itself: not off, user-invocable-only or
|
|
116
|
+
* disable-model-invocation; one with paths only for a matching file, its globs read from its
|
|
117
|
+
* folder like a rule's.
|
|
118
|
+
*/
|
|
119
|
+
async function nestedSkills(test, folder) {
|
|
120
|
+
const { env, cwd, target, overrides, startSkills } = test;
|
|
121
|
+
const matches = [];
|
|
122
|
+
for (const name of await listDir(join(folder, ".claude", "skills"))) {
|
|
123
|
+
const skill = await readSkill(env, join(folder, ".claude", "skills", name), "project");
|
|
124
|
+
if (!skill)
|
|
125
|
+
continue;
|
|
126
|
+
applySkillOverrides([skill], overrides);
|
|
127
|
+
if (skill.listed === false)
|
|
128
|
+
continue;
|
|
129
|
+
const glob = skill.paths && firstMatch(skill.paths, slashed(relative(folder, target)));
|
|
130
|
+
if (skill.paths && !glob)
|
|
131
|
+
continue;
|
|
132
|
+
// A nested skill whose folder name another skill has is called by its folder as well, as
|
|
133
|
+
// apps/web:deploy (docs: skills#how-a-skill-gets-its-command-name).
|
|
134
|
+
matches.push({
|
|
135
|
+
kind: "skill",
|
|
136
|
+
path: skill.path,
|
|
137
|
+
scope: "project",
|
|
138
|
+
name: startSkills.has(name) ? `${slashed(relative(cwd, folder))}:${name}` : skill.name,
|
|
139
|
+
...(glob ? { glob } : {}),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return matches;
|
|
143
|
+
}
|
|
144
|
+
/** Skills Claude can pick on its own; one with disable-model-invocation only runs when you call it. */
|
|
145
|
+
function startSkillMatches(view, target, projectRoot) {
|
|
146
|
+
return view.inventory.skills.flatMap((skill) => {
|
|
147
|
+
if (!skill.paths || skill.listed === false || skill.shadowedBy)
|
|
148
|
+
return [];
|
|
149
|
+
const glob = firstMatch(skill.paths, slashed(relative(projectRoot, target)));
|
|
150
|
+
if (!glob)
|
|
151
|
+
return [];
|
|
152
|
+
return [
|
|
153
|
+
{ kind: "skill", path: skill.path, scope: skill.scope, glob, name: skill.id ?? skill.name },
|
|
154
|
+
];
|
|
155
|
+
});
|
|
156
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type Permission, type PermissionList, type Scope, type SessionProfile, type SettingsLayer, type Trust } from "../../shared/types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Whether a file's trust-gated settings (permission grants, autoMemoryDirectory) wait for
|
|
4
|
+
* the workspace trust dialog. Claude Code runs git to tell a private local file from a committed one
|
|
5
|
+
* only once the folder or a parent is trusted, or in claude -p and the SDK, which count as accepted;
|
|
6
|
+
* until then the local file waits too, except in your configuration home. A committed local file
|
|
7
|
+
* waits like project settings, which -p and the SDK never apply in a folder you have not trusted
|
|
8
|
+
* (docs: permissions#when-your-local-settings-file-needs-trust, #what-runs-before-you-trust-a-folder).
|
|
9
|
+
*/
|
|
10
|
+
export declare function trustWaits({ trust, inConfigHome, localFromRepository, profile, }: Options): (scope: Scope) => boolean;
|
|
11
|
+
/**
|
|
12
|
+
* An MCP rule with a parameter, which Claude Code skips when it reads a settings file; such a rule
|
|
13
|
+
* works only from --disallowedTools (docs: permissions#match-by-input-parameter).
|
|
14
|
+
*/
|
|
15
|
+
export declare function isSkippedMcpRule(rule: string): boolean;
|
|
16
|
+
/** Claude Code checks every file's rules together: deny, then ask, then allow (docs: permissions). */
|
|
17
|
+
export declare const CHECK_ORDER: PermissionList[];
|
|
18
|
+
export interface Options {
|
|
19
|
+
trust: Trust;
|
|
20
|
+
/** Claude Code started in your configuration home, where the local file applies without the trust step. */
|
|
21
|
+
inConfigHome: boolean;
|
|
22
|
+
/** The local settings file is tracked in git, so Claude Code treats it like repository settings. */
|
|
23
|
+
localFromRepository: boolean;
|
|
24
|
+
profile: SessionProfile;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Every permission entry from every settings file, each marked with whether Claude Code applies it
|
|
28
|
+
* (docs: permissions#project-allow-rules-and-workspace-trust, settings-reference#permissions-defaultmode,
|
|
29
|
+
* #allowmanagedpermissionrulesonly).
|
|
30
|
+
*/
|
|
31
|
+
export declare function permissionsOf(layers: SettingsLayer[], options: Options): Permission[];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { HIGHEST_FIRST } from "../../shared/scopes.js";
|
|
2
2
|
import {} from "../../shared/types.js";
|
|
3
|
-
import {
|
|
3
|
+
import { stringsIn } from "./files.js";
|
|
4
|
+
import { isRecord } from "./data.js";
|
|
4
5
|
import { ignoredModeReason } from "./settings.js";
|
|
5
6
|
/**
|
|
6
7
|
* Whether a file's trust-gated settings (permission grants, autoMemoryDirectory) wait for
|
|
@@ -30,13 +31,9 @@ export const CHECK_ORDER = ["deny", "ask", "allow"];
|
|
|
30
31
|
* #allowmanagedpermissionrulesonly).
|
|
31
32
|
*/
|
|
32
33
|
export function permissionsOf(layers, options) {
|
|
33
|
-
const sources = HIGHEST_FIRST.flatMap((scope) => layers
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
scope,
|
|
37
|
-
path: l.path,
|
|
38
|
-
permissions: l.data?.permissions,
|
|
39
|
-
})));
|
|
34
|
+
const sources = HIGHEST_FIRST.flatMap((scope) => layers.flatMap((l) => l.scope === scope && isRecord(l.data?.permissions)
|
|
35
|
+
? [{ scope, path: l.path, permissions: l.data.permissions }]
|
|
36
|
+
: []));
|
|
40
37
|
const managedOnly = layers.some((l) => l.scope === "managed" && l.data?.allowManagedPermissionRulesOnly === true);
|
|
41
38
|
// Grants from repository files wait for the workspace trust dialog; deny and ask apply at once.
|
|
42
39
|
const waitsForTrust = trustWaits(options);
|