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,480 +0,0 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
3
|
-
import {} from "node:fs";
|
|
4
|
-
import { lstat, open, readdir, readlink, realpath, stat } from "node:fs/promises";
|
|
5
|
-
import { basename, dirname, join, normalize, relative, resolve, sep } from "node:path";
|
|
6
|
-
import { promisify } from "node:util";
|
|
7
|
-
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
8
|
-
import {} from "../../shared/types.js";
|
|
9
|
-
import {} from "./env.js";
|
|
10
|
-
const run = promisify(execFile);
|
|
11
|
-
/** Claude Code skips a CLAUDE.md larger than this (docs: memory#auto-memory), so ccdesk reads no more. */
|
|
12
|
-
export const MAX_TEXT_BYTES = 4 * 1024 * 1024;
|
|
13
|
-
/** ccdesk never reads Claude Code's credentials, whatever name or link leads to them. */
|
|
14
|
-
export function isSecretPath(path) {
|
|
15
|
-
return basename(path) === ".credentials.json";
|
|
16
|
-
}
|
|
17
|
-
/**
|
|
18
|
-
* Whether a path, any link on the way, or the file it finally leads to is the credentials file, so
|
|
19
|
-
* a credentials file that is itself a link is caught too. Checked before opening.
|
|
20
|
-
*/
|
|
21
|
-
async function leadsToSecret(path) {
|
|
22
|
-
let current = path;
|
|
23
|
-
for (let hop = 0; hop < 40; hop++) {
|
|
24
|
-
if (isSecretPath(current))
|
|
25
|
-
return true;
|
|
26
|
-
const info = await lstat(current).catch(() => null);
|
|
27
|
-
if (!info?.isSymbolicLink())
|
|
28
|
-
break;
|
|
29
|
-
current = resolve(dirname(current), await readlink(current));
|
|
30
|
-
}
|
|
31
|
-
return isSecretPath(await realpath(path).catch(() => path));
|
|
32
|
-
}
|
|
33
|
-
/** The identity of `<config>/.credentials.json`, or null when there is none. */
|
|
34
|
-
function credentialsIdentity(env) {
|
|
35
|
-
return stat(join(env.claudeDir, ".credentials.json"), { bigint: true }).catch(() => null);
|
|
36
|
-
}
|
|
37
|
-
function sameFile(a, b) {
|
|
38
|
-
return b !== null && a.dev === b.dev && a.ino === b.ino;
|
|
39
|
-
}
|
|
40
|
-
/**
|
|
41
|
-
* Whether a path is Claude Code's credentials file: by name, through any link on the way, or by
|
|
42
|
-
* being the same file as `<config>/.credentials.json`, so a copy kept under another name or a hard
|
|
43
|
-
* link to it counts too.
|
|
44
|
-
*/
|
|
45
|
-
export async function isCredentials(env, path) {
|
|
46
|
-
if (await leadsToSecret(path))
|
|
47
|
-
return true;
|
|
48
|
-
const info = await stat(path, { bigint: true }).catch(() => null);
|
|
49
|
-
return info !== null && sameFile(info, await credentialsIdentity(env));
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Opens a file to read, or null for the credentials file. The open file is checked again, so a
|
|
53
|
-
* file swapped in after the first check is not read either.
|
|
54
|
-
*/
|
|
55
|
-
async function openUnlessCredentials(env, path) {
|
|
56
|
-
if (await isCredentials(env, path))
|
|
57
|
-
return null;
|
|
58
|
-
const handle = await open(path, "r");
|
|
59
|
-
if (sameFile(await handle.stat({ bigint: true }), await credentialsIdentity(env))) {
|
|
60
|
-
await handle.close();
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
63
|
-
return handle;
|
|
64
|
-
}
|
|
65
|
-
/**
|
|
66
|
-
* A JSON parse failure without the parser's message, which quotes the text around the fault and
|
|
67
|
-
* so can quote a secret.
|
|
68
|
-
*/
|
|
69
|
-
export function jsonSyntaxError(error) {
|
|
70
|
-
const at = /line (\d+) column (\d+)/.exec(String(error));
|
|
71
|
-
return at ? `not valid JSON (line ${at[1]}, column ${at[2]})` : "not valid JSON";
|
|
72
|
-
}
|
|
73
|
-
/**
|
|
74
|
-
* The text ccdesk shows for a file reached as an instruction, import, memory, agent or skill. A
|
|
75
|
-
* settings or MCP file read this way, whatever it is named, would otherwise show its env and
|
|
76
|
-
* headers values, so anything that parses as JSON is shown parsed with them hidden. Frontmatter can
|
|
77
|
-
* hold the same values, in a subagent's inline MCP servers (docs: sub-agents#scope-mcp-servers-to-a-subagent),
|
|
78
|
-
* so frontmatter that holds any is shown re-written with them hidden.
|
|
79
|
-
*/
|
|
80
|
-
export function shownText(text) {
|
|
81
|
-
if (text === null)
|
|
82
|
-
return null;
|
|
83
|
-
let data;
|
|
84
|
-
try {
|
|
85
|
-
data = JSON.parse(text);
|
|
86
|
-
}
|
|
87
|
-
catch {
|
|
88
|
-
return shownFrontmatter(text);
|
|
89
|
-
}
|
|
90
|
-
return typeof data === "object" && data !== null
|
|
91
|
-
? `${JSON.stringify(redactSecrets(data), null, 2)}\n`
|
|
92
|
-
: text;
|
|
93
|
-
}
|
|
94
|
-
function shownFrontmatter(text) {
|
|
95
|
-
const match = FRONTMATTER.exec(text);
|
|
96
|
-
if (!match)
|
|
97
|
-
return text;
|
|
98
|
-
let data;
|
|
99
|
-
try {
|
|
100
|
-
data = parseYaml(match[1] ?? "");
|
|
101
|
-
}
|
|
102
|
-
catch {
|
|
103
|
-
return text;
|
|
104
|
-
}
|
|
105
|
-
const redacted = redactSecrets(data);
|
|
106
|
-
if (JSON.stringify(redacted) === JSON.stringify(data))
|
|
107
|
-
return text;
|
|
108
|
-
return `---\n${stringifyYaml(redacted)}---\n${text.slice(match[0].length)}`;
|
|
109
|
-
}
|
|
110
|
-
/** A file named .json that does not parse is not shown: its text could still hold a secret. */
|
|
111
|
-
function shownJsonFile(text) {
|
|
112
|
-
try {
|
|
113
|
-
JSON.parse(text);
|
|
114
|
-
}
|
|
115
|
-
catch {
|
|
116
|
-
return "(a JSON file ccdesk could not parse, so its text is not shown; open it to see it)\n";
|
|
117
|
-
}
|
|
118
|
-
return shownText(text) ?? "";
|
|
119
|
-
}
|
|
120
|
-
/**
|
|
121
|
-
* A UNC share or a path under /net or /Network. Claude Code does not follow a link there, because
|
|
122
|
-
* looking it up can contact the host it names (docs: memory#share-rules-across-projects-with-symlinks).
|
|
123
|
-
*/
|
|
124
|
-
export function isNetworkPath(path) {
|
|
125
|
-
if (/^[\\/]{2}wsl(\$|\.localhost)[\\/]/i.test(path))
|
|
126
|
-
return false;
|
|
127
|
-
return /^[\\/]{2}[^\\/]/.test(path) || /^\/(net|Network)(\/|$)/.test(path);
|
|
128
|
-
}
|
|
129
|
-
/** Where a symlink points, read without following it; null for anything else. */
|
|
130
|
-
async function linkTarget(path) {
|
|
131
|
-
try {
|
|
132
|
-
if (!(await lstat(path)).isSymbolicLink())
|
|
133
|
-
return null;
|
|
134
|
-
return resolve(dirname(path), await readlink(path));
|
|
135
|
-
}
|
|
136
|
-
catch {
|
|
137
|
-
return null;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
/** A link to a network location, which ccdesk, like Claude Code, does not follow. */
|
|
141
|
-
async function linksToNetwork(path) {
|
|
142
|
-
const target = await linkTarget(path);
|
|
143
|
-
return target !== null && isNetworkPath(target);
|
|
144
|
-
}
|
|
145
|
-
/** A pattern where `*` matches anything, matched against the whole string. */
|
|
146
|
-
export function wildcard(pattern, flags = "") {
|
|
147
|
-
const body = pattern
|
|
148
|
-
.split("*")
|
|
149
|
-
.map((p) => p.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
|
150
|
-
.join(".*");
|
|
151
|
-
return new RegExp(`^${body}$`, flags);
|
|
152
|
-
}
|
|
153
|
-
export function isWithin(parent, child) {
|
|
154
|
-
const rel = relative(parent, child);
|
|
155
|
-
// Only the parent segment leads out; a name such as ..cache is inside.
|
|
156
|
-
const up = rel === ".." || rel.startsWith(`..${sep}`);
|
|
157
|
-
return rel === "" || (!up && !rel.startsWith(sep) && !/^[a-zA-Z]:/.test(rel));
|
|
158
|
-
}
|
|
159
|
-
export async function isDir(path) {
|
|
160
|
-
try {
|
|
161
|
-
return (await stat(path)).isDirectory();
|
|
162
|
-
}
|
|
163
|
-
catch {
|
|
164
|
-
return false;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
export async function isFile(path) {
|
|
168
|
-
try {
|
|
169
|
-
return (await stat(path)).isFile();
|
|
170
|
-
}
|
|
171
|
-
catch {
|
|
172
|
-
return false;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
export async function listDir(path) {
|
|
176
|
-
try {
|
|
177
|
-
return (await readdir(path)).sort();
|
|
178
|
-
}
|
|
179
|
-
catch {
|
|
180
|
-
return [];
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
export async function readTextFile(env, path) {
|
|
184
|
-
try {
|
|
185
|
-
if (await linksToNetwork(path))
|
|
186
|
-
return null;
|
|
187
|
-
const real = await realpath(path);
|
|
188
|
-
const info = await stat(path);
|
|
189
|
-
if (!info.isFile())
|
|
190
|
-
return null;
|
|
191
|
-
const handle = await openUnlessCredentials(env, path);
|
|
192
|
-
if (!handle)
|
|
193
|
-
return null;
|
|
194
|
-
let text;
|
|
195
|
-
try {
|
|
196
|
-
const buffer = Buffer.alloc(Math.min(info.size, MAX_TEXT_BYTES));
|
|
197
|
-
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
198
|
-
text = buffer.subarray(0, bytesRead).toString("utf8");
|
|
199
|
-
}
|
|
200
|
-
finally {
|
|
201
|
-
await handle.close();
|
|
202
|
-
}
|
|
203
|
-
text = real.endsWith(".json") ? shownJsonFile(text) : (shownText(text) ?? "");
|
|
204
|
-
const ref = {
|
|
205
|
-
path,
|
|
206
|
-
mtime: info.mtime.toISOString(),
|
|
207
|
-
bytes: info.size,
|
|
208
|
-
lines: text === "" ? 0 : text.split("\n").length,
|
|
209
|
-
text,
|
|
210
|
-
};
|
|
211
|
-
if ((await lstat(path)).isSymbolicLink())
|
|
212
|
-
ref.realPath = real;
|
|
213
|
-
return ref;
|
|
214
|
-
}
|
|
215
|
-
catch {
|
|
216
|
-
return null;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
export async function resolvedPath(path) {
|
|
220
|
-
try {
|
|
221
|
-
return await realpath(path);
|
|
222
|
-
}
|
|
223
|
-
catch {
|
|
224
|
-
return path;
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
/** A JSON object file; `emptyIsObject` reads an empty file as `{}`. */
|
|
228
|
-
export async function readJsonObject(env, path, { emptyIsObject = false } = {}) {
|
|
229
|
-
let raw;
|
|
230
|
-
try {
|
|
231
|
-
const handle = await openUnlessCredentials(env, path);
|
|
232
|
-
if (!handle)
|
|
233
|
-
return { ok: false, missing: false, error: "ccdesk does not read credentials" };
|
|
234
|
-
try {
|
|
235
|
-
raw = await handle.readFile("utf8");
|
|
236
|
-
}
|
|
237
|
-
finally {
|
|
238
|
-
await handle.close();
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
catch (error) {
|
|
242
|
-
const missing = error.code === "ENOENT";
|
|
243
|
-
return { ok: false, missing, error: String(error) };
|
|
244
|
-
}
|
|
245
|
-
if (emptyIsObject && raw.trim() === "")
|
|
246
|
-
return { ok: true, data: {} };
|
|
247
|
-
try {
|
|
248
|
-
const data = JSON.parse(raw);
|
|
249
|
-
if (!isRecord(data))
|
|
250
|
-
return { ok: false, missing: false, unparsed: true, error: "not a JSON object" };
|
|
251
|
-
return { ok: true, data };
|
|
252
|
-
}
|
|
253
|
-
catch (error) {
|
|
254
|
-
return { ok: false, missing: false, unparsed: true, error: jsonSyntaxError(error) };
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
export function isRecord(value) {
|
|
258
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
259
|
-
}
|
|
260
|
-
/**
|
|
261
|
-
* A server URL as ccdesk shows it: a user name, password or query string can carry a key, so they
|
|
262
|
-
* are hidden, as Claude Code hides the URL in its own status output (docs: mcp).
|
|
263
|
-
*/
|
|
264
|
-
export function shownUrl(url) {
|
|
265
|
-
try {
|
|
266
|
-
const u = new URL(url);
|
|
267
|
-
if (!u.username && !u.password && !u.search)
|
|
268
|
-
return url;
|
|
269
|
-
if (u.username || u.password) {
|
|
270
|
-
u.username = "•••";
|
|
271
|
-
u.password = "";
|
|
272
|
-
}
|
|
273
|
-
if (u.search)
|
|
274
|
-
u.search = "?•••";
|
|
275
|
-
return decodeURI(u.toString());
|
|
276
|
-
}
|
|
277
|
-
catch {
|
|
278
|
-
return url.replace(/\/\/[^/@]*@/, "//•••@").replace(/\?.*$/, "?•••");
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
/** A flag whose value is a secret, such as `--token` or `--api-key`. */
|
|
282
|
-
const SECRET_FLAG = /^--(token|api[-_]?key|apikey|password|secret|auth|key)$/i;
|
|
283
|
-
/** A value that starts like a common API key or token. */
|
|
284
|
-
const KEY_PREFIX = /^(sk-|ghp_|gho_|ghu_|ghs_|ghr_|github_pat_|xox[bp]-|glpat-|AKIA[0-9A-Z]{16})/;
|
|
285
|
-
/**
|
|
286
|
-
* A server's command-line arguments as ccdesk shows them: the value after a secret flag, given as
|
|
287
|
-
* `--token value` or `--token=value`, a value that starts like a known key, and the credentials in
|
|
288
|
-
* a URL are hidden.
|
|
289
|
-
*/
|
|
290
|
-
export function shownArgs(args) {
|
|
291
|
-
return args.map((arg, i) => {
|
|
292
|
-
if (typeof arg !== "string")
|
|
293
|
-
return arg;
|
|
294
|
-
const previous = args[i - 1];
|
|
295
|
-
if (typeof previous === "string" && SECRET_FLAG.test(previous) && !arg.startsWith("-"))
|
|
296
|
-
return "•••";
|
|
297
|
-
const equals = arg.indexOf("=");
|
|
298
|
-
if (equals > 0) {
|
|
299
|
-
const name = arg.slice(0, equals);
|
|
300
|
-
if (SECRET_FLAG.test(name) || KEY_PREFIX.test(arg.slice(equals + 1)))
|
|
301
|
-
return `${name}=•••`;
|
|
302
|
-
}
|
|
303
|
-
if (KEY_PREFIX.test(arg))
|
|
304
|
-
return "•••";
|
|
305
|
-
if (/^https?:\/\//i.test(arg))
|
|
306
|
-
return shownUrl(arg);
|
|
307
|
-
return arg;
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
/**
|
|
311
|
-
* Replace values that commonly hold secrets with a placeholder, keeping the keys: env and headers
|
|
312
|
-
* values, the user, password and query of a `url`, and secret values in the `args` of a command.
|
|
313
|
-
*/
|
|
314
|
-
export function redactSecrets(value) {
|
|
315
|
-
if (Array.isArray(value))
|
|
316
|
-
return value.map(redactSecrets);
|
|
317
|
-
if (!isRecord(value))
|
|
318
|
-
return value;
|
|
319
|
-
// Built with fromEntries: assigning a key named __proto__ would set the prototype instead.
|
|
320
|
-
return Object.fromEntries(Object.entries(value).map(([key, inner]) => {
|
|
321
|
-
if ((key === "env" || key === "headers") && isRecord(inner))
|
|
322
|
-
return [key, Object.fromEntries(Object.keys(inner).map((k) => [k, "•••"]))];
|
|
323
|
-
if (key === "url" && typeof inner === "string")
|
|
324
|
-
return [key, shownUrl(inner)];
|
|
325
|
-
if (key === "args" && "command" in value && Array.isArray(inner))
|
|
326
|
-
return [key, shownArgs(inner)];
|
|
327
|
-
return [key, redactSecrets(inner)];
|
|
328
|
-
}));
|
|
329
|
-
}
|
|
330
|
-
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
331
|
-
export function parseFrontmatter(text) {
|
|
332
|
-
const match = FRONTMATTER.exec(text);
|
|
333
|
-
if (!match)
|
|
334
|
-
return { data: {}, body: text };
|
|
335
|
-
try {
|
|
336
|
-
const data = parseYaml(match[1] ?? "");
|
|
337
|
-
return { data: isRecord(data) ? data : {}, body: text.slice(match[0].length) };
|
|
338
|
-
}
|
|
339
|
-
catch {
|
|
340
|
-
return { data: {}, body: text.slice(match[0].length) };
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
/** The strings in a JSON list; anything else counts as no strings. */
|
|
344
|
-
export function stringsIn(value) {
|
|
345
|
-
return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
|
|
346
|
-
}
|
|
347
|
-
/** A frontmatter list given as a YAML list or a comma-separated string, such as `paths`. */
|
|
348
|
-
export function toStringList(value) {
|
|
349
|
-
if (typeof value === "string")
|
|
350
|
-
return value
|
|
351
|
-
.split(",")
|
|
352
|
-
.map((s) => s.trim())
|
|
353
|
-
.filter(Boolean);
|
|
354
|
-
return stringsIn(value);
|
|
355
|
-
}
|
|
356
|
-
export function stringField(data, key) {
|
|
357
|
-
const value = data[key];
|
|
358
|
-
return typeof value === "string" ? value.trim() : "";
|
|
359
|
-
}
|
|
360
|
-
/** Fingerprint of a file's text, so a change can require the file to be as it was reviewed. */
|
|
361
|
-
export function revisionOf(text) {
|
|
362
|
-
return text === null ? "missing" : createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
363
|
-
}
|
|
364
|
-
export function shortHash(text) {
|
|
365
|
-
return createHash("sha1").update(text).digest("hex").slice(0, 10);
|
|
366
|
-
}
|
|
367
|
-
export async function gitRoot(dir) {
|
|
368
|
-
try {
|
|
369
|
-
const { stdout } = await run("git", ["-C", dir, "rev-parse", "--show-toplevel"], {
|
|
370
|
-
timeout: 5000,
|
|
371
|
-
});
|
|
372
|
-
// Git prints forward slashes on Windows too; normalise so path comparisons hold.
|
|
373
|
-
return stdout.trim() ? normalize(stdout.trim()) : null;
|
|
374
|
-
}
|
|
375
|
-
catch {
|
|
376
|
-
return null;
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
/** Every checkout of the repository `dir` is in, the main one first; empty outside git. */
|
|
380
|
-
export async function worktrees(dir) {
|
|
381
|
-
try {
|
|
382
|
-
const { stdout } = await run("git", ["-C", dir, "worktree", "list", "--porcelain"], {
|
|
383
|
-
timeout: 5000,
|
|
384
|
-
});
|
|
385
|
-
return stdout
|
|
386
|
-
.split("\n")
|
|
387
|
-
.filter((line) => line.startsWith("worktree "))
|
|
388
|
-
.map((line) => normalize(line.slice("worktree ".length)));
|
|
389
|
-
}
|
|
390
|
-
catch {
|
|
391
|
-
return [];
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
/** Main worktree root: worktrees of one repository share Claude Code's auto memory. */
|
|
395
|
-
export async function mainWorktreeRoot(dir) {
|
|
396
|
-
try {
|
|
397
|
-
const { stdout } = await run("git", ["-C", dir, "rev-parse", "--path-format=absolute", "--git-common-dir"], { timeout: 5000 });
|
|
398
|
-
const common = stdout.trim();
|
|
399
|
-
if (!common)
|
|
400
|
-
return null;
|
|
401
|
-
return common.endsWith("/.git") || common.endsWith("\\.git")
|
|
402
|
-
? normalize(dirname(common))
|
|
403
|
-
: null;
|
|
404
|
-
}
|
|
405
|
-
catch {
|
|
406
|
-
return null;
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
/** Git visibility of each path, batched into two git calls per repository. */
|
|
410
|
-
export async function visibilityOf(root, paths) {
|
|
411
|
-
const result = new Map();
|
|
412
|
-
if (paths.length === 0)
|
|
413
|
-
return result;
|
|
414
|
-
if (!root) {
|
|
415
|
-
for (const p of paths)
|
|
416
|
-
result.set(p, "no-git");
|
|
417
|
-
return result;
|
|
418
|
-
}
|
|
419
|
-
const inside = paths.filter((p) => p !== root && isWithin(root, p));
|
|
420
|
-
for (const p of paths)
|
|
421
|
-
if (!inside.includes(p))
|
|
422
|
-
result.set(p, "no-git");
|
|
423
|
-
if (inside.length === 0)
|
|
424
|
-
return result;
|
|
425
|
-
const rels = new Map(inside.map((p) => [p, relative(root, p).replaceAll("\\", "/")]));
|
|
426
|
-
const tracked = await gitLines(root, ["ls-files", "--full-name", "-z", "--", ...inside], "\0");
|
|
427
|
-
// check-ignore echoes the paths it matched, and on Windows an absolute path does not come back
|
|
428
|
-
// as written, so it gets relative ones. It accepts -z only with --stdin, so its output is split
|
|
429
|
-
// by line.
|
|
430
|
-
const ignored = await gitLines(root, ["check-ignore", "--no-index", "--", ...rels.values()], "\n");
|
|
431
|
-
for (const [p, rel] of rels) {
|
|
432
|
-
if (tracked.has(rel))
|
|
433
|
-
result.set(p, "tracked");
|
|
434
|
-
else if (ignored.has(rel))
|
|
435
|
-
result.set(p, "ignored");
|
|
436
|
-
else
|
|
437
|
-
result.set(p, "untracked");
|
|
438
|
-
}
|
|
439
|
-
return result;
|
|
440
|
-
}
|
|
441
|
-
// Failures (including check-ignore's exit 1 for "nothing ignored") read as an empty set.
|
|
442
|
-
async function gitLines(root, args, separator) {
|
|
443
|
-
try {
|
|
444
|
-
const { stdout } = await run("git", ["-C", root, ...args], { timeout: 5000 });
|
|
445
|
-
return new Set(stdout.split(separator).filter(Boolean));
|
|
446
|
-
}
|
|
447
|
-
catch {
|
|
448
|
-
return new Set();
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
/**
|
|
452
|
-
* Markdown files under `dir`. A symlinked folder is entered once, so a link loop ends, and a link
|
|
453
|
-
* to a network location is not followed. Depth and count are bounded by ccdesk, since a link can
|
|
454
|
-
* lead anywhere; Claude Code's own limits are not documented.
|
|
455
|
-
*/
|
|
456
|
-
export async function walkMarkdown(dir) {
|
|
457
|
-
const out = [];
|
|
458
|
-
const seen = new Set();
|
|
459
|
-
const walk = async (folder, depth) => {
|
|
460
|
-
const real = await resolvedPath(folder);
|
|
461
|
-
if (seen.has(real) || depth > MAX_WALK_DEPTH)
|
|
462
|
-
return;
|
|
463
|
-
seen.add(real);
|
|
464
|
-
for (const name of await listDir(folder)) {
|
|
465
|
-
if (out.length >= MAX_WALK_FILES)
|
|
466
|
-
return;
|
|
467
|
-
const path = join(folder, name);
|
|
468
|
-
if (await linksToNetwork(path))
|
|
469
|
-
continue;
|
|
470
|
-
if (await isDir(path))
|
|
471
|
-
await walk(path, depth + 1);
|
|
472
|
-
else if (name.endsWith(".md"))
|
|
473
|
-
out.push(path);
|
|
474
|
-
}
|
|
475
|
-
};
|
|
476
|
-
await walk(dir, 0);
|
|
477
|
-
return out;
|
|
478
|
-
}
|
|
479
|
-
const MAX_WALK_DEPTH = 8;
|
|
480
|
-
const MAX_WALK_FILES = 1000;
|
|
@@ -1,136 +0,0 @@
|
|
|
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, MAX_TEXT_BYTES, parseFrontmatter, readTextFile, resolvedPath, 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
|
-
const realCwd = await resolvedPath(cwd);
|
|
50
|
-
const approved = view.trust.externalImports === "approved";
|
|
51
|
-
// Folders below the start directory load their own instructions, rules and skills on demand.
|
|
52
|
-
const mode = view.agentsMdMode;
|
|
53
|
-
const startSkills = new Set(view.inventory.skills.filter((s) => s.scope !== "plugin").map((s) => s.name));
|
|
54
|
-
const between = [];
|
|
55
|
-
for (let d = dirname(target); d !== cwd && isWithin(cwd, d); d = dirname(d))
|
|
56
|
-
between.unshift(d);
|
|
57
|
-
for (const folder of between) {
|
|
58
|
-
/** Whether the file is there; an excluded one or one over the limit is there but not loaded. */
|
|
59
|
-
const nested = async (name, scope) => {
|
|
60
|
-
const path = join(folder, name);
|
|
61
|
-
const file = await readTextFile(env, path);
|
|
62
|
-
if (!file)
|
|
63
|
-
return false;
|
|
64
|
-
if (!excluded(path) && file.bytes <= MAX_TEXT_BYTES)
|
|
65
|
-
matches.push({ kind: "nested", path, scope });
|
|
66
|
-
return true;
|
|
67
|
-
};
|
|
68
|
-
// managed-only leaves out files at launch, but a subfolder's CLAUDE.md and rules still load
|
|
69
|
-
// on demand (docs: memory#choose-which-instruction-files-load).
|
|
70
|
-
let hasClaudeMd = false;
|
|
71
|
-
for (const name of ["CLAUDE.md", ".claude/CLAUDE.md"])
|
|
72
|
-
if (await nested(name, "project"))
|
|
73
|
-
hasClaudeMd = true;
|
|
74
|
-
if (mode !== "managed-only" && (await nested("CLAUDE.local.md", "local")))
|
|
75
|
-
hasClaudeMd = true;
|
|
76
|
-
const agents = mode === "claude-md-and-agents-md" || (mode === "claude-md-or-agents-md" && !hasClaudeMd);
|
|
77
|
-
if (agents)
|
|
78
|
-
await nested("AGENTS.md", "project");
|
|
79
|
-
for (const path of await walkMarkdown(join(folder, ".claude", "rules"))) {
|
|
80
|
-
const text = await readTextFile(env, path);
|
|
81
|
-
if (!text || text.bytes > MAX_TEXT_BYTES || excluded(path))
|
|
82
|
-
continue;
|
|
83
|
-
// As at startup: excluded by its target's path too, and a rule linked from outside the
|
|
84
|
-
// project waits for your approval, then loads only without paths
|
|
85
|
-
// (docs: memory#share-rules-across-projects-with-symlinks).
|
|
86
|
-
const real = await resolvedPath(path);
|
|
87
|
-
const outside = !isWithin(realCwd, real) && !isWithin(await resolvedPath(folder), real);
|
|
88
|
-
const linked = text.realPath ?? (outside ? real : undefined);
|
|
89
|
-
if (linked !== undefined && excluded(linked))
|
|
90
|
-
continue;
|
|
91
|
-
const globs = toStringList(parseFrontmatter(text.text).data.paths);
|
|
92
|
-
if (!isWithin(realCwd, real) && (!approved || globs.length > 0))
|
|
93
|
-
continue;
|
|
94
|
-
const glob = globs.length === 0 ? undefined : firstMatch(globs, slashed(relative(folder, target)));
|
|
95
|
-
if (globs.length === 0 || glob)
|
|
96
|
-
matches.push({ kind: "nested", path, scope: "project", ...(glob ? { glob } : {}) });
|
|
97
|
-
}
|
|
98
|
-
// Only skills Claude may pick itself: not off, user-invocable-only or disable-model-invocation;
|
|
99
|
-
// one with paths only for a matching file, its globs read from its folder like a rule's.
|
|
100
|
-
for (const name of await listDir(join(folder, ".claude", "skills"))) {
|
|
101
|
-
const skill = await readSkill(env, join(folder, ".claude", "skills", name), "project");
|
|
102
|
-
if (!skill)
|
|
103
|
-
continue;
|
|
104
|
-
applySkillOverrides([skill], overrides);
|
|
105
|
-
if (skill.listed === false)
|
|
106
|
-
continue;
|
|
107
|
-
const glob = skill.paths && firstMatch(skill.paths, slashed(relative(folder, target)));
|
|
108
|
-
if (skill.paths && !glob)
|
|
109
|
-
continue;
|
|
110
|
-
// A nested skill whose folder name another skill has is called by its folder as well, as
|
|
111
|
-
// apps/web:deploy (docs: skills#how-a-skill-gets-its-command-name).
|
|
112
|
-
matches.push({
|
|
113
|
-
kind: "skill",
|
|
114
|
-
path: skill.path,
|
|
115
|
-
scope: "project",
|
|
116
|
-
name: startSkills.has(name) ? `${slashed(relative(cwd, folder))}:${name}` : skill.name,
|
|
117
|
-
...(glob ? { glob } : {}),
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
// Skills Claude can pick on its own; one with disable-model-invocation only runs when you call it.
|
|
122
|
-
for (const skill of view.inventory.skills) {
|
|
123
|
-
if (!skill.paths || skill.listed === false || skill.shadowedBy)
|
|
124
|
-
continue;
|
|
125
|
-
const glob = firstMatch(skill.paths, slashed(relative(projectRoot, target)));
|
|
126
|
-
if (glob)
|
|
127
|
-
matches.push({
|
|
128
|
-
kind: "skill",
|
|
129
|
-
path: skill.path,
|
|
130
|
-
scope: skill.scope,
|
|
131
|
-
glob,
|
|
132
|
-
name: skill.id ?? skill.name,
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
return { file: target, inside: true, exists: await isFile(target), matches };
|
|
136
|
-
}
|
package/dist/server/schema.js
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { Ajv } from "ajv";
|
|
2
|
-
export const ajv = new Ajv({ allErrors: true, strict: false });
|
|
3
|
-
/**
|
|
4
|
-
* What `validate` found wrong with `value`, each led by where: `label` and the path inside it, such
|
|
5
|
-
* as `sandbox.network`.
|
|
6
|
-
*/
|
|
7
|
-
export function schemaErrors(validate, value, label) {
|
|
8
|
-
if (validate(value))
|
|
9
|
-
return [];
|
|
10
|
-
return (validate.errors ?? []).map((e) => {
|
|
11
|
-
const where = [label, ...e.instancePath.split("/")].filter(Boolean).join(".");
|
|
12
|
-
const allowed = e.keyword === "enum" ? `: ${e.params.allowedValues.join(", ")}` : "";
|
|
13
|
-
return `${where} ${e.message ?? "is invalid"}${allowed}`;
|
|
14
|
-
});
|
|
15
|
-
}
|
package/dist/server/secrets.js
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import { isRecord, redactSecrets, shownUrl } from "./scan/files.js";
|
|
2
|
-
/**
|
|
3
|
-
* Helpers for the values ccdesk hides. What is hidden is decided only by redactSecrets; these
|
|
4
|
-
* compare a value with its redacted form.
|
|
5
|
-
*/
|
|
6
|
-
/** A value as ccdesk shows it: JSON with hidden values as •••. */
|
|
7
|
-
export function shown(value) {
|
|
8
|
-
return `${JSON.stringify(redactSecrets(value), null, 2)}\n`;
|
|
9
|
-
}
|
|
10
|
-
/** Every value redactSecrets changes, with where it is, what it is, and how it is shown. */
|
|
11
|
-
function hiddenIn(value) {
|
|
12
|
-
const found = [];
|
|
13
|
-
// redactSecrets keeps every key and list length, so the two can be walked side by side.
|
|
14
|
-
const walk = (raw, shownValue, path) => {
|
|
15
|
-
if (Array.isArray(raw) && Array.isArray(shownValue)) {
|
|
16
|
-
for (const [i, inner] of raw.entries())
|
|
17
|
-
walk(inner, shownValue[i], [...path, i]);
|
|
18
|
-
}
|
|
19
|
-
else if (isRecord(raw) && isRecord(shownValue))
|
|
20
|
-
for (const [key, inner] of Object.entries(raw))
|
|
21
|
-
walk(inner, shownValue[key], [...path, key]);
|
|
22
|
-
else if (raw !== shownValue)
|
|
23
|
-
found.push({ path, raw, shown: shownValue });
|
|
24
|
-
};
|
|
25
|
-
walk(value, redactSecrets(value), []);
|
|
26
|
-
return found;
|
|
27
|
-
}
|
|
28
|
-
export function hasHiddenValues(value) {
|
|
29
|
-
return hiddenIn(value).length > 0;
|
|
30
|
-
}
|
|
31
|
-
/**
|
|
32
|
-
* A fingerprint of the hidden values in a JSON file, so a restore can tell whether they changed.
|
|
33
|
-
* Null for a file that is not valid JSON.
|
|
34
|
-
*/
|
|
35
|
-
export function hiddenValues(text) {
|
|
36
|
-
try {
|
|
37
|
-
const data = text === null ? null : JSON.parse(text);
|
|
38
|
-
return JSON.stringify(hiddenIn(data).map(({ path, raw }) => [path, raw]));
|
|
39
|
-
}
|
|
40
|
-
catch {
|
|
41
|
-
return null;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
/**
|
|
45
|
-
* The secret text in a value, to take out of a message that may quote it: an env or header value
|
|
46
|
-
* or a hidden argument whole, the part after `=` of `--token=value`, and a URL's user name,
|
|
47
|
-
* password and query. Longest first, so a secret that contains another is taken out whole.
|
|
48
|
-
*/
|
|
49
|
-
export function secretTexts(value) {
|
|
50
|
-
const found = hiddenIn(value).flatMap(({ raw, shown }) => {
|
|
51
|
-
if (typeof raw !== "string")
|
|
52
|
-
return [];
|
|
53
|
-
if (shown === "•••")
|
|
54
|
-
return [raw];
|
|
55
|
-
if (typeof shown === "string" && shown.endsWith("=•••"))
|
|
56
|
-
return [raw.slice(raw.indexOf("=") + 1)];
|
|
57
|
-
if (shownUrl(raw) === raw)
|
|
58
|
-
return [];
|
|
59
|
-
const url = URL.parse(raw);
|
|
60
|
-
return [url?.username, url?.password, url?.search.slice(1)];
|
|
61
|
-
});
|
|
62
|
-
return found.filter((s) => !!s).sort((a, b) => b.length - a.length);
|
|
63
|
-
}
|