ccdesk 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +114 -2
- package/dist/server/backups.js +226 -0
- package/dist/server/cli.js +92 -0
- package/dist/server/mutations.js +188 -0
- package/dist/server/open.js +139 -0
- package/dist/server/scan/context.js +139 -0
- package/dist/server/scan/env.js +23 -0
- package/dist/server/scan/files.js +390 -0
- package/dist/server/scan/index.js +266 -0
- package/dist/server/scan/instructions.js +285 -0
- package/dist/server/scan/inventory.js +360 -0
- package/dist/server/scan/mcp.js +255 -0
- package/dist/server/scan/memory.js +91 -0
- package/dist/server/scan/path-test.js +99 -0
- package/dist/server/scan/permissions.js +85 -0
- package/dist/server/scan/plugins.js +117 -0
- package/dist/server/scan/projects.js +100 -0
- package/dist/server/scan/settings.js +317 -0
- package/dist/server/scan/tools.js +107 -0
- package/dist/server/scan/usage.js +441 -0
- package/dist/server/server.js +258 -0
- package/dist/server/settings-edit.js +152 -0
- package/dist/server/watch.js +172 -0
- package/dist/server/write.js +195 -0
- package/dist/shared/builtin-tools.json +235 -0
- package/dist/shared/scopes.js +18 -0
- package/dist/shared/settings-scopes.json +208 -0
- package/dist/shared/settings.schema.json +4623 -0
- package/dist/shared/types.js +2 -0
- package/dist/ui/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
- package/dist/ui/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
- package/dist/ui/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
- package/dist/ui/assets/index-BM4K7hSO.css +1 -0
- package/dist/ui/assets/index-CN3vkVjv.js +45 -0
- package/dist/ui/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/dist/ui/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/dist/ui/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist/ui/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/ui/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/dist/ui/index.html +15 -0
- package/dist/ui/logo.png +0 -0
- package/package.json +80 -4
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import {} from "../../shared/types.js";
|
|
3
|
+
import {} from "./env.js";
|
|
4
|
+
import { isRecord, readJsonObject, shownUrl, stringField, stringsIn, wildcard } from "./files.js";
|
|
5
|
+
import { manifestConfigs } from "./plugins.js";
|
|
6
|
+
import { readManagedSettings } from "./settings.js";
|
|
7
|
+
function define(servers, scope, sourcePath, organisation = false) {
|
|
8
|
+
if (!isRecord(servers))
|
|
9
|
+
return [];
|
|
10
|
+
return Object.entries(servers)
|
|
11
|
+
.filter(([, config]) => isRecord(config))
|
|
12
|
+
.map(([name, config]) => {
|
|
13
|
+
const c = config;
|
|
14
|
+
const type = stringField(c, "type");
|
|
15
|
+
const url = stringField(c, "url") || null;
|
|
16
|
+
const command = stringField(c, "command")
|
|
17
|
+
? [stringField(c, "command"), ...stringsIn(c.args)]
|
|
18
|
+
: null;
|
|
19
|
+
const server = {
|
|
20
|
+
name,
|
|
21
|
+
scope,
|
|
22
|
+
// An entry without a type is read as stdio, even one with a url (docs: mcp).
|
|
23
|
+
transport: type || "stdio",
|
|
24
|
+
target: url ? shownUrl(url) : (command?.join(" ") ?? ""),
|
|
25
|
+
sourcePath,
|
|
26
|
+
status: "active",
|
|
27
|
+
};
|
|
28
|
+
if (!type && url) {
|
|
29
|
+
server.status = "invalid";
|
|
30
|
+
server.note =
|
|
31
|
+
'Has a url but no "type", so Claude Code reads it as a stdio server and it fails';
|
|
32
|
+
}
|
|
33
|
+
// A ${VAR} in an organisation entry means it still has to pass the allowlist.
|
|
34
|
+
const usesVariables = JSON.stringify(c).includes("${");
|
|
35
|
+
return {
|
|
36
|
+
server,
|
|
37
|
+
command: type && type !== "stdio" ? null : command,
|
|
38
|
+
url: type ? url : null,
|
|
39
|
+
organisation: organisation && !usesVariables,
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/** Control and invisible formatting characters, such as zero-width spaces and bidi overrides. */
|
|
44
|
+
const INVISIBLE = /[\p{Cc}\p{Cf}]/u;
|
|
45
|
+
/** Every key and string value in a JSON value, the entry's name included by the caller. */
|
|
46
|
+
function textsIn(value) {
|
|
47
|
+
if (typeof value === "string")
|
|
48
|
+
return [value];
|
|
49
|
+
if (Array.isArray(value))
|
|
50
|
+
return value.flatMap(textsIn);
|
|
51
|
+
if (!isRecord(value))
|
|
52
|
+
return [];
|
|
53
|
+
return Object.entries(value).flatMap(([key, inner]) => [key, ...textsIn(inner)]);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Why Claude Code drops a managedMcpServers entry, or null when it loads it
|
|
57
|
+
* (docs: managed-mcp#what-an-entry-can-contain). `config` is the entry as written, headers included.
|
|
58
|
+
*/
|
|
59
|
+
function managedEntryProblem(name, config) {
|
|
60
|
+
if (!isRecord(config))
|
|
61
|
+
return "Not an object";
|
|
62
|
+
const type = stringField(config, "type");
|
|
63
|
+
if (!["http", "sse", "streamable-http"].includes(type))
|
|
64
|
+
return "managedMcpServers entries must be http or sse servers";
|
|
65
|
+
if (!stringField(config, "url").startsWith("https://"))
|
|
66
|
+
return "managedMcpServers entries need an https:// URL";
|
|
67
|
+
const member = ["command", "args", "env", "headersHelper"].find((key) => key in config);
|
|
68
|
+
if (member)
|
|
69
|
+
return `managedMcpServers entries may not have "${member}"`;
|
|
70
|
+
if (JSON.stringify(config).includes("${"))
|
|
71
|
+
return "managedMcpServers entries may not use environment variable references";
|
|
72
|
+
if (!/^[\w-]+$/.test(name))
|
|
73
|
+
return "A managedMcpServers name may hold only letters, numbers, hyphens and underscores";
|
|
74
|
+
if (textsIn(config).some((text) => INVISIBLE.test(text)))
|
|
75
|
+
return "managedMcpServers entries may not contain control or invisible characters";
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
/** managedMcpServers from managed settings, with entries Claude Code drops marked invalid. */
|
|
79
|
+
function defineManaged(servers, sourcePath) {
|
|
80
|
+
const defined = define(servers, "managed", sourcePath, true);
|
|
81
|
+
for (const d of defined) {
|
|
82
|
+
const problem = managedEntryProblem(d.server.name, servers[d.server.name]);
|
|
83
|
+
if (problem) {
|
|
84
|
+
d.server.status = "invalid";
|
|
85
|
+
d.server.note = problem;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return defined;
|
|
89
|
+
}
|
|
90
|
+
/** Two URLs are one endpoint when they differ only in scheme or host case, a default port or a trailing slash. */
|
|
91
|
+
export function endpoint(d) {
|
|
92
|
+
if (d.command)
|
|
93
|
+
return `command:${JSON.stringify(d.command)}`;
|
|
94
|
+
if (!d.url)
|
|
95
|
+
return `none:${d.server.name}`;
|
|
96
|
+
try {
|
|
97
|
+
const u = new URL(d.url);
|
|
98
|
+
const path = u.pathname.replace(/\/$/, "");
|
|
99
|
+
const user = u.username ? `${u.username}${u.password ? `:${u.password}` : ""}@` : "";
|
|
100
|
+
return `url:${u.protocol}//${user}${u.host}${path}${u.search}`;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return `url:${d.url}`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function policyEntries(value) {
|
|
107
|
+
if (!Array.isArray(value))
|
|
108
|
+
return [];
|
|
109
|
+
return value.filter((e) => isRecord(e) &&
|
|
110
|
+
(typeof e.serverName === "string" ||
|
|
111
|
+
(Array.isArray(e.serverCommand) && e.serverCommand.every((p) => typeof p === "string")) ||
|
|
112
|
+
typeof e.serverUrl === "string"));
|
|
113
|
+
}
|
|
114
|
+
/** A `serverUrl` pattern (docs: managed-mcp#how-serverurl-entries-match). */
|
|
115
|
+
function urlMatches(pattern, url) {
|
|
116
|
+
const p = /^([^:/]+):\/\/([^/:]+)(?::([^/]*))?(\/.*)?$/.exec(pattern);
|
|
117
|
+
let u;
|
|
118
|
+
try {
|
|
119
|
+
u = new URL(url);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
if (!p)
|
|
125
|
+
return false;
|
|
126
|
+
const [, scheme = "", host = "", port, path] = p;
|
|
127
|
+
const scheme_ = u.protocol.slice(0, -1);
|
|
128
|
+
if (!wildcard(scheme).test(scheme_))
|
|
129
|
+
return false;
|
|
130
|
+
if (!wildcard(host.replace(/\.$/, ""), "i").test(u.hostname.replace(/\.$/, "")))
|
|
131
|
+
return false;
|
|
132
|
+
const actualPort = u.port || (scheme_ === "https" ? "443" : scheme_ === "http" ? "80" : "");
|
|
133
|
+
// No port: a full hostname means the default port only, a wildcard hostname any port.
|
|
134
|
+
if (port !== undefined) {
|
|
135
|
+
if (!wildcard(port).test(actualPort))
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
else if (!host.includes("*") && u.port !== "")
|
|
139
|
+
return false;
|
|
140
|
+
return path === undefined || wildcard(path).test(u.pathname + u.search);
|
|
141
|
+
}
|
|
142
|
+
function matches(entry, d) {
|
|
143
|
+
if ("serverName" in entry)
|
|
144
|
+
return entry.serverName === d.server.name;
|
|
145
|
+
if ("serverCommand" in entry)
|
|
146
|
+
return d.command !== null && JSON.stringify(entry.serverCommand) === JSON.stringify(d.command);
|
|
147
|
+
return d.url !== null && urlMatches(entry.serverUrl, d.url);
|
|
148
|
+
}
|
|
149
|
+
/** The allowlist check by server type (docs: managed-mcp#how-a-server-is-evaluated). */
|
|
150
|
+
function allowed(list, d) {
|
|
151
|
+
const kind = d.url ? "serverUrl" : "serverCommand";
|
|
152
|
+
const byKind = list.filter((e) => kind in e);
|
|
153
|
+
if (byKind.some((e) => matches(e, d)))
|
|
154
|
+
return true;
|
|
155
|
+
return byKind.length === 0 && list.some((e) => "serverName" in e && matches(e, d));
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Every MCP server definition, each marked with whether Claude Code would start it here
|
|
159
|
+
* (docs: mcp#scope-hierarchy-and-precedence, managed-mcp). ccdesk never connects, so an active
|
|
160
|
+
* server is one that is configured and allowed, not one that is known to work.
|
|
161
|
+
*/
|
|
162
|
+
export async function readMcpServers(input, plugins) {
|
|
163
|
+
const { env, projectDir, layers, claudeJson, trust } = input;
|
|
164
|
+
const projects = isRecord(claudeJson?.projects) ? claudeJson.projects : {};
|
|
165
|
+
const entry = [trust.root, projectDir].map((key) => projects[key]).find(isRecord);
|
|
166
|
+
const project = entry ?? {};
|
|
167
|
+
const managed = layers.find((l) => l.scope === "managed");
|
|
168
|
+
// Checked as written: the layer has header values hidden, which would hide a ${VAR} in one.
|
|
169
|
+
const managedServers = (await readManagedSettings(env)).data?.managedMcpServers;
|
|
170
|
+
const managedFile = join(env.managedDir, "managed-mcp.json");
|
|
171
|
+
const managedJson = await readJsonObject(managedFile);
|
|
172
|
+
const mcpJson = join(projectDir, ".mcp.json");
|
|
173
|
+
const shared = await readJsonObject(mcpJson);
|
|
174
|
+
const defined = [
|
|
175
|
+
...(managedJson.ok ? define(managedJson.data.mcpServers, "managed", managedFile, true) : []),
|
|
176
|
+
...defineManaged(managedServers, managed?.path ?? ""),
|
|
177
|
+
...define(project.mcpServers, "local", env.claudeJson),
|
|
178
|
+
...(shared.ok ? define(shared.data.mcpServers, "project", mcpJson) : []),
|
|
179
|
+
...define(claudeJson?.mcpServers, "user", env.claudeJson),
|
|
180
|
+
];
|
|
181
|
+
for (const plugin of plugins) {
|
|
182
|
+
const path = join(plugin.installPath, ".mcp.json");
|
|
183
|
+
const file = await readJsonObject(path);
|
|
184
|
+
// A server name declared later in the same plugin replaces an earlier one.
|
|
185
|
+
const own = new Map();
|
|
186
|
+
const add = (list) => {
|
|
187
|
+
for (const d of list)
|
|
188
|
+
own.set(d.server.name, d);
|
|
189
|
+
};
|
|
190
|
+
if (file.ok)
|
|
191
|
+
add(define(file.data.mcpServers ?? file.data, "plugin", path));
|
|
192
|
+
for (const part of await manifestConfigs(plugin, "mcpServers"))
|
|
193
|
+
add(define(part.value, "plugin", part.path));
|
|
194
|
+
for (const d of own.values())
|
|
195
|
+
d.server.plugin = plugin.id;
|
|
196
|
+
defined.push(...own.values());
|
|
197
|
+
}
|
|
198
|
+
const exclusive = managedJson.ok;
|
|
199
|
+
const denied = layers.flatMap((l) => policyEntries(l.data?.deniedMcpServers));
|
|
200
|
+
const managedOnly = managed?.data?.allowManagedMcpServersOnly === true;
|
|
201
|
+
const allowLayers = layers.filter((l) => !managedOnly || l.scope === "managed");
|
|
202
|
+
const allowSet = allowLayers.some((l) => Array.isArray(l.data?.allowedMcpServers));
|
|
203
|
+
const allowList = allowLayers.flatMap((l) => policyEntries(l.data?.allowedMcpServers));
|
|
204
|
+
const disabledHere = stringsIn(project.disabledMcpServers);
|
|
205
|
+
// A cloned repository cannot approve its own servers: project and local approvals count once the
|
|
206
|
+
// folder is trusted, local ones in your home directory right away (docs: mcp#project-server-approvals-and-workspace-trust).
|
|
207
|
+
const approvalLayers = layers.filter((l) => (l.scope !== "project" && l.scope !== "local") ||
|
|
208
|
+
trust.trusted ||
|
|
209
|
+
(l.scope === "local" && projectDir === env.home));
|
|
210
|
+
const approveAll = approvalLayers.some((l) => l.data?.enableAllProjectMcpServers === true);
|
|
211
|
+
const approved = new Set([
|
|
212
|
+
...approvalLayers.flatMap((l) => stringsIn(l.data?.enabledMcpjsonServers)),
|
|
213
|
+
...stringsIn(project.enabledMcpjsonServers),
|
|
214
|
+
]);
|
|
215
|
+
const rejected = new Set([
|
|
216
|
+
...layers.flatMap((l) => stringsIn(l.data?.disabledMcpjsonServers)),
|
|
217
|
+
...stringsIn(project.disabledMcpjsonServers),
|
|
218
|
+
]);
|
|
219
|
+
const set = (server, status, note) => {
|
|
220
|
+
server.status = status;
|
|
221
|
+
server.note = note;
|
|
222
|
+
};
|
|
223
|
+
const byName = new Map();
|
|
224
|
+
const enabledEndpoints = new Map();
|
|
225
|
+
for (const d of defined) {
|
|
226
|
+
const { server } = d;
|
|
227
|
+
if (server.status === "invalid")
|
|
228
|
+
continue;
|
|
229
|
+
if (exclusive && !d.organisation && server.scope !== "managed") {
|
|
230
|
+
set(server, "blocked", "managed-mcp.json takes exclusive control of MCP servers");
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
// Scopes dedupe by name; plugins by endpoint against the enabled servers above them.
|
|
234
|
+
const earlier = server.scope === "plugin" ? enabledEndpoints.get(endpoint(d)) : byName.get(server.name);
|
|
235
|
+
if (earlier) {
|
|
236
|
+
set(server, "shadowed", `The ${earlier.server.scope} definition of ${earlier.server.name} is used instead`);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (server.scope !== "plugin")
|
|
240
|
+
byName.set(server.name, d);
|
|
241
|
+
if (denied.some((e) => matches(e, d)))
|
|
242
|
+
set(server, "blocked", "Blocked by deniedMcpServers");
|
|
243
|
+
else if (allowSet && !d.organisation && !allowed(allowList, d))
|
|
244
|
+
set(server, "blocked", managedOnly ? "Not in the managed allowedMcpServers" : "Not in allowedMcpServers");
|
|
245
|
+
else if (disabledHere.includes(server.name))
|
|
246
|
+
set(server, "disabled", "Turned off for this project in /mcp");
|
|
247
|
+
else if (server.scope === "project" && rejected.has(server.name))
|
|
248
|
+
set(server, "rejected", "Rejected by disabledMcpjsonServers");
|
|
249
|
+
else if (server.scope === "project" && !approveAll && !approved.has(server.name))
|
|
250
|
+
set(server, "pending", "Claude Code asks before starting servers from .mcp.json");
|
|
251
|
+
if (server.status === "active" || server.status === "pending")
|
|
252
|
+
enabledEndpoints.set(endpoint(d), d);
|
|
253
|
+
}
|
|
254
|
+
return defined.map((d) => d.server);
|
|
255
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import {} from "../../shared/types.js";
|
|
3
|
+
import { isDir, listDir, parseFrontmatter, readTextFile, revisionOf, shortHash, stringField, } from "./files.js";
|
|
4
|
+
const INDEX_FILE = "MEMORY.md";
|
|
5
|
+
/** Claude Code reads only this much of MEMORY.md at session start (docs: memory#auto-memory). */
|
|
6
|
+
const INDEX_LINE_LIMIT = 200;
|
|
7
|
+
const INDEX_BYTE_LIMIT = 25 * 1024;
|
|
8
|
+
const TYPES = ["user", "feedback", "project", "reference"];
|
|
9
|
+
/** Claude Code names a project's store after its path with every non-alphanumeric character as "-". */
|
|
10
|
+
export function storeDirName(projectPath) {
|
|
11
|
+
return projectPath.replace(/[^a-zA-Z0-9]/g, "-");
|
|
12
|
+
}
|
|
13
|
+
export async function readMemoryStore(dir, projectPath) {
|
|
14
|
+
if (!(await isDir(dir)))
|
|
15
|
+
return null;
|
|
16
|
+
const indexFile = await readTextFile(join(dir, INDEX_FILE));
|
|
17
|
+
const files = (await listDir(dir)).filter((f) => f.endsWith(".md") && f !== INDEX_FILE);
|
|
18
|
+
const memories = [];
|
|
19
|
+
for (const file of files) {
|
|
20
|
+
const text = await readTextFile(join(dir, file));
|
|
21
|
+
if (!text)
|
|
22
|
+
continue;
|
|
23
|
+
const { data, body } = parseFrontmatter(text.text);
|
|
24
|
+
const metadata = typeof data.metadata === "object" && data.metadata !== null
|
|
25
|
+
? data.metadata
|
|
26
|
+
: {};
|
|
27
|
+
const rawType = stringField(metadata, "type") || stringField(data, "type");
|
|
28
|
+
memories.push({
|
|
29
|
+
file,
|
|
30
|
+
path: text.path,
|
|
31
|
+
name: stringField(data, "name") || file.replace(/\.md$/, ""),
|
|
32
|
+
description: stringField(data, "description"),
|
|
33
|
+
type: TYPES.includes(rawType) ? rawType : "unknown",
|
|
34
|
+
body,
|
|
35
|
+
mtime: text.mtime,
|
|
36
|
+
bytes: text.bytes,
|
|
37
|
+
revision: revisionOf(text.text),
|
|
38
|
+
inIndex: indexFile ? indexMentions(indexFile.text, file) : false,
|
|
39
|
+
hash: shortHash(body.trim()),
|
|
40
|
+
links: wikiLinks(body),
|
|
41
|
+
brokenLinks: [],
|
|
42
|
+
linkedFrom: [],
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
const names = new Set(memories.map((m) => m.name));
|
|
46
|
+
for (const memory of memories) {
|
|
47
|
+
memory.brokenLinks = memory.links.filter((link) => !names.has(link));
|
|
48
|
+
for (const link of memory.links) {
|
|
49
|
+
const target = memories.find((m) => m.name === link);
|
|
50
|
+
if (target && target !== memory && !target.linkedFrom.includes(memory.name))
|
|
51
|
+
target.linkedFrom.push(memory.name);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
dir,
|
|
56
|
+
loaded: true,
|
|
57
|
+
projectPath,
|
|
58
|
+
index: indexFile ? summarizeIndex(indexFile, files) : null,
|
|
59
|
+
memories,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function summarizeIndex(file, files) {
|
|
63
|
+
const lines = file.text.split("\n");
|
|
64
|
+
let loadedLines = 0;
|
|
65
|
+
let bytes = 0;
|
|
66
|
+
for (const line of lines) {
|
|
67
|
+
bytes += Buffer.byteLength(line, "utf8") + 1;
|
|
68
|
+
if (loadedLines >= INDEX_LINE_LIMIT || bytes > INDEX_BYTE_LIMIT)
|
|
69
|
+
break;
|
|
70
|
+
loadedLines++;
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
...file,
|
|
74
|
+
revision: revisionOf(file.text),
|
|
75
|
+
truncated: loadedLines < lines.length,
|
|
76
|
+
loadedLines,
|
|
77
|
+
missingFiles: [...new Set(file.text.split("\n").flatMap((l) => deadLinks(l, files)))],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** Memory files an index line links to that are not in the store. */
|
|
81
|
+
export function deadLinks(line, files) {
|
|
82
|
+
return [...line.matchAll(/\]\(([^)\s/\\]+\.md)\)/g)]
|
|
83
|
+
.map((m) => m[1] ?? "")
|
|
84
|
+
.filter((f) => !files.includes(f));
|
|
85
|
+
}
|
|
86
|
+
function indexMentions(index, file) {
|
|
87
|
+
return index.includes(`(${file})`) || index.includes(`[[${file.replace(/\.md$/, "")}]]`);
|
|
88
|
+
}
|
|
89
|
+
function wikiLinks(body) {
|
|
90
|
+
return [...new Set([...body.matchAll(/\[\[([^\]\n|]+)\]\]/g)].map((m) => (m[1] ?? "").trim()))];
|
|
91
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
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
|
+
const cwd = resolve(dir);
|
|
28
|
+
const target = isAbsolute(file) ? resolve(file) : resolve(cwd, file);
|
|
29
|
+
if (target === cwd || !isWithin(cwd, target))
|
|
30
|
+
return { file: target, inside: false, exists: false, matches: [] };
|
|
31
|
+
const view = await scanDirectory(env, cwd);
|
|
32
|
+
const projectRoot = view.gitRoot ?? cwd;
|
|
33
|
+
const matches = [];
|
|
34
|
+
const excludes = stringsIn(view.effective.find((e) => e.key === "claudeMdExcludes")?.value);
|
|
35
|
+
const overrides = view.effective.find((e) => e.key === "skillOverrides")?.value;
|
|
36
|
+
const excluded = (path) => excludes.some((glob) => firstMatch([glob], path) !== undefined);
|
|
37
|
+
// Path rules Claude Code would use at all: not excluded, not waiting for approval.
|
|
38
|
+
for (const rule of view.instructions) {
|
|
39
|
+
if (!rule.paths || rule.status !== "on-match")
|
|
40
|
+
continue;
|
|
41
|
+
// A project rule's globs are relative to the folder holding its .claude; others to the project.
|
|
42
|
+
const base = rule.scope === "project" || rule.scope === "local" ? rule.dir : projectRoot;
|
|
43
|
+
const glob = firstMatch(rule.paths, slashed(relative(base, target)));
|
|
44
|
+
if (glob)
|
|
45
|
+
matches.push({ kind: "rule", path: rule.path, scope: rule.scope, glob });
|
|
46
|
+
}
|
|
47
|
+
// Folders below the start directory load their own instructions, rules and skills on demand.
|
|
48
|
+
const mode = view.agentsMdMode;
|
|
49
|
+
const between = [];
|
|
50
|
+
for (let d = dirname(target); d !== cwd && isWithin(cwd, d); d = dirname(d))
|
|
51
|
+
between.unshift(d);
|
|
52
|
+
for (const folder of between) {
|
|
53
|
+
const nested = async (name, scope) => {
|
|
54
|
+
const path = join(folder, name);
|
|
55
|
+
if (excluded(path) || !(await readTextFile(path)))
|
|
56
|
+
return false;
|
|
57
|
+
matches.push({ kind: "nested", path, scope });
|
|
58
|
+
return true;
|
|
59
|
+
};
|
|
60
|
+
// managed-only leaves out files at launch, but a subfolder's CLAUDE.md and rules still load
|
|
61
|
+
// on demand (docs: memory#choose-which-instruction-files-load).
|
|
62
|
+
let hasClaudeMd = false;
|
|
63
|
+
for (const name of ["CLAUDE.md", ".claude/CLAUDE.md"])
|
|
64
|
+
if (await nested(name, "project"))
|
|
65
|
+
hasClaudeMd = true;
|
|
66
|
+
if (mode !== "managed-only" && (await nested("CLAUDE.local.md", "local")))
|
|
67
|
+
hasClaudeMd = true;
|
|
68
|
+
const agents = mode === "claude-md-and-agents-md" || (mode === "claude-md-or-agents-md" && !hasClaudeMd);
|
|
69
|
+
if (agents)
|
|
70
|
+
await nested("AGENTS.md", "project");
|
|
71
|
+
for (const path of await walkMarkdown(join(folder, ".claude", "rules"))) {
|
|
72
|
+
const text = await readTextFile(path);
|
|
73
|
+
if (!text || excluded(path))
|
|
74
|
+
continue;
|
|
75
|
+
const globs = toStringList(parseFrontmatter(text.text).data.paths);
|
|
76
|
+
const glob = globs.length === 0 ? undefined : firstMatch(globs, slashed(relative(folder, target)));
|
|
77
|
+
if (globs.length === 0 || glob)
|
|
78
|
+
matches.push({ kind: "nested", path, scope: "project", ...(glob ? { glob } : {}) });
|
|
79
|
+
}
|
|
80
|
+
// Only skills Claude may pick itself: not off, user-invocable-only or disable-model-invocation.
|
|
81
|
+
for (const name of await listDir(join(folder, ".claude", "skills"))) {
|
|
82
|
+
const skill = await readSkill(join(folder, ".claude", "skills", name), "project");
|
|
83
|
+
if (!skill)
|
|
84
|
+
continue;
|
|
85
|
+
applySkillOverrides([skill], overrides);
|
|
86
|
+
if (skill.listed !== false)
|
|
87
|
+
matches.push({ kind: "skill", path: skill.path, scope: "project", name: skill.name });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Skills Claude can pick on its own; one with disable-model-invocation only runs when you call it.
|
|
91
|
+
for (const skill of view.inventory.skills) {
|
|
92
|
+
if (!skill.paths || skill.listed === false)
|
|
93
|
+
continue;
|
|
94
|
+
const glob = firstMatch(skill.paths, slashed(relative(projectRoot, target)));
|
|
95
|
+
if (glob)
|
|
96
|
+
matches.push({ kind: "skill", path: skill.path, scope: skill.scope, glob, name: skill.name });
|
|
97
|
+
}
|
|
98
|
+
return { file: target, inside: true, exists: await isFile(target), matches };
|
|
99
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
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, hooks, 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, so until then the local file waits too, except in
|
|
9
|
+
* your configuration home; a committed local file waits like project settings.
|
|
10
|
+
*/
|
|
11
|
+
export function trustWaits({ trust, inConfigHome, localFromRepository, }) {
|
|
12
|
+
const localWaits = !inConfigHome && (trust.trusted ? false : !trust.inheritedFrom || localFromRepository);
|
|
13
|
+
return (scope) => (scope === "project" && !trust.trusted) || (scope === "local" && localWaits);
|
|
14
|
+
}
|
|
15
|
+
/** Claude Code checks every file's rules together: deny, then ask, then allow (docs: permissions). */
|
|
16
|
+
const CHECK_ORDER = ["deny", "ask", "allow"];
|
|
17
|
+
/**
|
|
18
|
+
* Every permission entry from every settings file, each marked with whether Claude Code applies it
|
|
19
|
+
* (docs: permissions#project-allow-rules-and-workspace-trust, settings-reference#permissions-defaultmode,
|
|
20
|
+
* #allowmanagedpermissionrulesonly).
|
|
21
|
+
*/
|
|
22
|
+
export function permissionsOf(layers, options) {
|
|
23
|
+
const sources = HIGHEST_FIRST.flatMap((scope) => layers
|
|
24
|
+
.filter((l) => l.scope === scope && isRecord(l.data?.permissions))
|
|
25
|
+
.map((l) => ({
|
|
26
|
+
scope,
|
|
27
|
+
path: l.path,
|
|
28
|
+
permissions: l.data?.permissions,
|
|
29
|
+
})));
|
|
30
|
+
const managedOnly = layers.some((l) => l.scope === "managed" && l.data?.allowManagedPermissionRulesOnly === true);
|
|
31
|
+
// Grants from repository files wait for the workspace trust dialog; deny and ask apply at once.
|
|
32
|
+
const waitsForTrust = trustWaits(options);
|
|
33
|
+
const notTrusted = "Waits until you trust this folder in Claude Code";
|
|
34
|
+
const rules = sources.flatMap(({ scope, path, permissions }) => CHECK_ORDER.flatMap((list) => stringsIn(permissions[list]).map((rule) => {
|
|
35
|
+
const base = { kind: "rule", list, rule, scope, path };
|
|
36
|
+
if (managedOnly && scope !== "managed")
|
|
37
|
+
return {
|
|
38
|
+
...base,
|
|
39
|
+
inEffect: false,
|
|
40
|
+
note: "Managed settings allow only their own permission rules",
|
|
41
|
+
};
|
|
42
|
+
if (list === "allow" && waitsForTrust(scope))
|
|
43
|
+
return { ...base, inEffect: false, note: notTrusted };
|
|
44
|
+
return { ...base, inEffect: true };
|
|
45
|
+
})));
|
|
46
|
+
for (const r of rules) {
|
|
47
|
+
if (!r.inEffect)
|
|
48
|
+
continue;
|
|
49
|
+
const first = CHECK_ORDER.map((list) => rules.find((o) => o.inEffect && o.list === list && o.rule === r.rule)).find((o) => o !== undefined);
|
|
50
|
+
if (first && first.list !== r.list)
|
|
51
|
+
r.decidedBy = { list: first.list, scope: first.scope };
|
|
52
|
+
}
|
|
53
|
+
const out = [...rules];
|
|
54
|
+
let modeTaken = false;
|
|
55
|
+
for (const { scope, permissions } of sources) {
|
|
56
|
+
const mode = permissions.defaultMode;
|
|
57
|
+
if (typeof mode !== "string")
|
|
58
|
+
continue;
|
|
59
|
+
let note = ignoredModeReason(scope, mode);
|
|
60
|
+
if (!note && modeTaken)
|
|
61
|
+
note = "A higher settings file sets the default mode";
|
|
62
|
+
out.push({
|
|
63
|
+
kind: "defaultMode",
|
|
64
|
+
value: mode,
|
|
65
|
+
scope,
|
|
66
|
+
inEffect: !note,
|
|
67
|
+
...(note ? { note } : {}),
|
|
68
|
+
});
|
|
69
|
+
if (!note)
|
|
70
|
+
modeTaken = true;
|
|
71
|
+
}
|
|
72
|
+
for (const { scope, permissions } of sources) {
|
|
73
|
+
for (const dir of stringsIn(permissions.additionalDirectories)) {
|
|
74
|
+
const note = waitsForTrust(scope) ? notTrusted : undefined;
|
|
75
|
+
out.push({
|
|
76
|
+
kind: "additionalDirectories",
|
|
77
|
+
value: dir,
|
|
78
|
+
scope,
|
|
79
|
+
inEffect: !note,
|
|
80
|
+
...(note ? { note } : {}),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
import { HIGHEST_FIRST } from "../../shared/scopes.js";
|
|
3
|
+
import {} from "../../shared/types.js";
|
|
4
|
+
import {} from "./env.js";
|
|
5
|
+
import { isRecord, isWithin, readJsonObject, resolvedPath, stringField } from "./files.js";
|
|
6
|
+
function manifestPath(plugin) {
|
|
7
|
+
return join(plugin.installPath, ".claude-plugin", "plugin.json");
|
|
8
|
+
}
|
|
9
|
+
async function manifest(plugin) {
|
|
10
|
+
const result = await readJsonObject(manifestPath(plugin));
|
|
11
|
+
return result.ok ? result.data : {};
|
|
12
|
+
}
|
|
13
|
+
/** Installed plugins that apply in `projectDir`, with whether each is enabled and by which file. */
|
|
14
|
+
export async function readPlugins(env, layers, projectDir, gitRoot) {
|
|
15
|
+
const installed = await readJsonObject(join(env.claudeDir, "plugins", "installed_plugins.json"));
|
|
16
|
+
if (!installed.ok || !isRecord(installed.data.plugins))
|
|
17
|
+
return [];
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const [id, installs] of Object.entries(installed.data.plugins)) {
|
|
20
|
+
if (!Array.isArray(installs))
|
|
21
|
+
continue;
|
|
22
|
+
// A project or local install applies only in its own project; it beats a user install there.
|
|
23
|
+
const records = installs.filter((i) => isRecord(i));
|
|
24
|
+
const install = records.find((i) => i.projectPath === projectDir || (gitRoot && i.projectPath === gitRoot)) ??
|
|
25
|
+
records.find((i) => typeof i.projectPath !== "string");
|
|
26
|
+
if (!install)
|
|
27
|
+
continue;
|
|
28
|
+
const plugin = {
|
|
29
|
+
id,
|
|
30
|
+
version: stringField(install, "version"),
|
|
31
|
+
scope: stringField(install, "scope"),
|
|
32
|
+
installPath: stringField(install, "installPath"),
|
|
33
|
+
updated: stringField(install, "lastUpdated") || null,
|
|
34
|
+
enabled: true,
|
|
35
|
+
note: "",
|
|
36
|
+
};
|
|
37
|
+
const setting = HIGHEST_FIRST.flatMap((scope) => layers
|
|
38
|
+
.filter((l) => l.scope === scope)
|
|
39
|
+
.map((l) => l.data?.enabledPlugins)
|
|
40
|
+
.filter(isRecord)
|
|
41
|
+
.filter((flags) => flags[id] !== undefined)
|
|
42
|
+
.map((flags) => ({ scope, value: flags[id] })))[0];
|
|
43
|
+
if (setting) {
|
|
44
|
+
plugin.enabled = setting.value !== false;
|
|
45
|
+
plugin.enabledIn = setting.scope;
|
|
46
|
+
plugin.note = `${plugin.enabled ? "Enabled" : "Turned off"} in ${setting.scope} settings`;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
plugin.enabled = (await manifest(plugin)).defaultEnabled !== false;
|
|
50
|
+
plugin.note = plugin.enabled
|
|
51
|
+
? "On by default (no enabledPlugins entry)"
|
|
52
|
+
: "Off by default (no enabledPlugins entry)";
|
|
53
|
+
}
|
|
54
|
+
out.push(plugin);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
/** A path from a manifest, if it stays inside the plugin once links are followed. */
|
|
59
|
+
async function inside(plugin, path) {
|
|
60
|
+
const target = resolve(plugin.installPath, path);
|
|
61
|
+
const root = await resolvedPath(plugin.installPath);
|
|
62
|
+
return isWithin(root, await resolvedPath(target)) ? target : null;
|
|
63
|
+
}
|
|
64
|
+
/** The paths a manifest key names (one path or a list), inside the plugin; null when unset. */
|
|
65
|
+
export async function manifestPaths(plugin, key) {
|
|
66
|
+
const value = (await manifest(plugin))[key];
|
|
67
|
+
const list = typeof value === "string" ? [value] : Array.isArray(value) ? value : null;
|
|
68
|
+
if (!list)
|
|
69
|
+
return null;
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const item of list) {
|
|
72
|
+
if (typeof item !== "string")
|
|
73
|
+
continue;
|
|
74
|
+
const path = await inside(plugin, item);
|
|
75
|
+
if (path)
|
|
76
|
+
out.push(path);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
/** The value of a manifest key that is a path, an inline object, or a list of either. */
|
|
81
|
+
export async function manifestConfigs(plugin, key) {
|
|
82
|
+
const value = (await manifest(plugin))[key];
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const item of Array.isArray(value) ? value : value === undefined ? [] : [value]) {
|
|
85
|
+
if (isRecord(item))
|
|
86
|
+
out.push({ value: item, path: manifestPath(plugin) });
|
|
87
|
+
if (typeof item !== "string")
|
|
88
|
+
continue;
|
|
89
|
+
const path = await inside(plugin, item);
|
|
90
|
+
const file = path ? await readJsonObject(path) : null;
|
|
91
|
+
if (path && file?.ok)
|
|
92
|
+
out.push({ value: file.data[key] ?? file.data, path });
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
/** The manifest's `commands` object form: command name to a file or inline content. */
|
|
97
|
+
export async function manifestCommandMap(plugin) {
|
|
98
|
+
const value = (await manifest(plugin)).commands;
|
|
99
|
+
if (!isRecord(value))
|
|
100
|
+
return [];
|
|
101
|
+
const out = [];
|
|
102
|
+
for (const [name, entry] of Object.entries(value)) {
|
|
103
|
+
if (!isRecord(entry))
|
|
104
|
+
continue;
|
|
105
|
+
const source = typeof entry.source === "string" ? await inside(plugin, entry.source) : null;
|
|
106
|
+
const content = typeof entry.content === "string" ? entry.content : null;
|
|
107
|
+
if (!source && content === null)
|
|
108
|
+
continue;
|
|
109
|
+
out.push({
|
|
110
|
+
name,
|
|
111
|
+
path: source ?? manifestPath(plugin),
|
|
112
|
+
description: stringField(entry, "description"),
|
|
113
|
+
text: content,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|