ccdesk 0.2.1 → 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 +54 -0
- package/README.md +9 -5
- package/dist/core/agent-edit.d.ts +6 -0
- package/dist/{server → core}/agent-edit.js +21 -22
- 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 +116 -95
- 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 +5 -3
- package/dist/core/scan/context.d.ts +13 -0
- package/dist/{server → core}/scan/context.js +58 -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 +10 -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 +6 -9
- 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 -92
- 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/core/settings-edit.js +216 -0
- package/dist/core/watch.d.ts +45 -0
- package/dist/{server → core}/watch.js +129 -20
- 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 -478
- package/dist/server/scan/path-test.js +0 -121
- package/dist/server/schema.js +0 -15
- package/dist/server/secrets.js +0 -63
- package/dist/server/settings-edit.js +0 -221
- package/dist/ui/assets/index-XqJwI3k9.js +0 -60
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import {} from "typebox/error";
|
|
2
|
+
import { Settings } from "typebox/system";
|
|
3
|
+
import {} from "typebox/compile";
|
|
4
|
+
/**
|
|
5
|
+
* What `validator` found wrong with `value`, each led by where: `label` and the path inside it,
|
|
6
|
+
* such as `sandbox.network`.
|
|
7
|
+
*/
|
|
8
|
+
export function schemaErrors(validator, value, label) {
|
|
9
|
+
if (validator.Check(value))
|
|
10
|
+
return [];
|
|
11
|
+
return allErrors(validator, value).flatMap((e) => messages(e).map(([path, message]) => {
|
|
12
|
+
const where = [label, ...path.split("/")].filter(Boolean).join(".");
|
|
13
|
+
return where ? `${where} ${message}` : message;
|
|
14
|
+
}));
|
|
15
|
+
}
|
|
16
|
+
/** The first thing wrong with a request body, such as "add.list must be string". */
|
|
17
|
+
export function firstError(validator, value) {
|
|
18
|
+
return schemaErrors(validator, value, "")[0] ?? "the request is not well formed";
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Every error, not TypeBox's first eight: a wrong hook lists what each kind of hook needs. Each
|
|
22
|
+
* value checked is one setting, server or request body, so the list stays short. The limit is
|
|
23
|
+
* TypeBox's process-wide setting, so it is put back for anything else in the process using it.
|
|
24
|
+
*/
|
|
25
|
+
function allErrors(validator, value) {
|
|
26
|
+
const { maxErrors } = Settings.Get();
|
|
27
|
+
Settings.Set({ maxErrors: Number.POSITIVE_INFINITY });
|
|
28
|
+
try {
|
|
29
|
+
return validator.Errors(value);
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
Settings.Set({ maxErrors });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* An error as where it is and what is wrong, worded as ccdesk has always shown them: one missing
|
|
37
|
+
* property at a time, and one "must NOT have additional properties" for each property a closed
|
|
38
|
+
* object does not allow.
|
|
39
|
+
*/
|
|
40
|
+
function messages(e) {
|
|
41
|
+
switch (e.keyword) {
|
|
42
|
+
case "required":
|
|
43
|
+
return e.params.requiredProperties.map((p) => [
|
|
44
|
+
e.instancePath,
|
|
45
|
+
`must have required property '${p}'`,
|
|
46
|
+
]);
|
|
47
|
+
// Said once for each extra property, through its "schema is false" error below; a property
|
|
48
|
+
// that fails an additionalProperties schema has its own error already.
|
|
49
|
+
case "additionalProperties":
|
|
50
|
+
return [];
|
|
51
|
+
case "boolean":
|
|
52
|
+
if (!e.schemaPath.endsWith("/additionalProperties"))
|
|
53
|
+
return [[e.instancePath, e.message]];
|
|
54
|
+
return [
|
|
55
|
+
[
|
|
56
|
+
e.instancePath.slice(0, e.instancePath.lastIndexOf("/")),
|
|
57
|
+
"must NOT have additional properties",
|
|
58
|
+
],
|
|
59
|
+
];
|
|
60
|
+
case "enum":
|
|
61
|
+
return [[e.instancePath, `${e.message}: ${e.params.allowedValues.join(", ")}`]];
|
|
62
|
+
case "minItems":
|
|
63
|
+
return [[e.instancePath, `must NOT have fewer than ${e.params.limit} items`]];
|
|
64
|
+
default:
|
|
65
|
+
return [[e.instancePath, e.message]];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A server URL as ccdesk shows it: a user name, password or query string can carry a key, so they
|
|
3
|
+
* are hidden, as Claude Code hides the URL in its own status output (docs: mcp).
|
|
4
|
+
*/
|
|
5
|
+
export declare function shownUrl(url: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Replace values that commonly hold secrets with a placeholder, keeping the keys: env and headers
|
|
8
|
+
* values, the user, password and query of a `url`, and secret values in the `args` of a command.
|
|
9
|
+
*/
|
|
10
|
+
export declare function redactSecrets(value: Record<string, unknown>): Record<string, unknown>;
|
|
11
|
+
export declare function redactSecrets(value: unknown): unknown;
|
|
12
|
+
/**
|
|
13
|
+
* A JSON parse failure without the parser's message, which quotes the text around the fault and
|
|
14
|
+
* so can quote a secret.
|
|
15
|
+
*/
|
|
16
|
+
export declare function jsonSyntaxError(error: unknown): string;
|
|
17
|
+
/**
|
|
18
|
+
* The text ccdesk shows for a file reached as an instruction, import, memory, agent or skill. A
|
|
19
|
+
* settings or MCP file read this way, whatever it is named, would otherwise show its env and
|
|
20
|
+
* headers values, so anything that parses as JSON is shown parsed with them hidden. Frontmatter can
|
|
21
|
+
* hold the same values, in a subagent's inline MCP servers (docs: sub-agents#scope-mcp-servers-to-a-subagent),
|
|
22
|
+
* so frontmatter that holds any is shown re-written with them hidden.
|
|
23
|
+
*/
|
|
24
|
+
export declare function shownText(text: string | null): string | null;
|
|
25
|
+
/** A file named .json that does not parse is not shown: its text could still hold a secret. */
|
|
26
|
+
export declare function shownJsonFile(text: string): string;
|
|
27
|
+
/**
|
|
28
|
+
* Helpers for the values ccdesk hides. What is hidden is decided only by redactSecrets; these
|
|
29
|
+
* compare a value with its redacted form.
|
|
30
|
+
*/
|
|
31
|
+
/** A value as ccdesk shows it: JSON with hidden values as •••. */
|
|
32
|
+
export declare function shown(value: unknown): string;
|
|
33
|
+
export declare function hasHiddenValues(value: unknown): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* A fingerprint of the hidden values in a JSON file, so a restore can tell whether they changed.
|
|
36
|
+
* Null for a file that is not valid JSON.
|
|
37
|
+
*/
|
|
38
|
+
export declare function hiddenValues(text: string | null): string | null;
|
|
39
|
+
/**
|
|
40
|
+
* The secret text in a value, to take out of a message that may quote it: an env or header value
|
|
41
|
+
* or a hidden argument whole, the part after `=` of `--token=value`, and a URL's user name,
|
|
42
|
+
* password and query. Longest first, so a secret that contains another is taken out whole.
|
|
43
|
+
*/
|
|
44
|
+
export declare function secretTexts(value: unknown): string[];
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
2
|
+
import { FRONTMATTER, isRecord } from "./scan/data.js";
|
|
3
|
+
/**
|
|
4
|
+
* A server URL as ccdesk shows it: a user name, password or query string can carry a key, so they
|
|
5
|
+
* are hidden, as Claude Code hides the URL in its own status output (docs: mcp).
|
|
6
|
+
*/
|
|
7
|
+
export function shownUrl(url) {
|
|
8
|
+
try {
|
|
9
|
+
const u = new URL(url);
|
|
10
|
+
if (!u.username && !u.password && !u.search)
|
|
11
|
+
return url;
|
|
12
|
+
if (u.username || u.password) {
|
|
13
|
+
u.username = "•••";
|
|
14
|
+
u.password = "";
|
|
15
|
+
}
|
|
16
|
+
if (u.search)
|
|
17
|
+
u.search = "?•••";
|
|
18
|
+
return decodeURI(u.toString());
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return url.replace(/\/\/[^/@]*@/, "//•••@").replace(/\?.*$/, "?•••");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** A flag whose value is a secret, such as `--token` or `--api-key`. */
|
|
25
|
+
const SECRET_FLAG = /^--(token|api[-_]?key|apikey|password|secret|auth|key)$/i;
|
|
26
|
+
/** A value that starts like a common API key or token. */
|
|
27
|
+
const KEY_PREFIX = /^(sk-|ghp_|gho_|ghu_|ghs_|ghr_|github_pat_|xox[bp]-|glpat-|AKIA[0-9A-Z]{16})/;
|
|
28
|
+
/**
|
|
29
|
+
* A server's command-line arguments as ccdesk shows them: the value after a secret flag, given as
|
|
30
|
+
* `--token value` or `--token=value`, a value that starts like a known key, and the credentials in
|
|
31
|
+
* a URL are hidden.
|
|
32
|
+
*/
|
|
33
|
+
function shownArgs(args) {
|
|
34
|
+
return args.map((arg, i) => {
|
|
35
|
+
if (typeof arg !== "string")
|
|
36
|
+
return arg;
|
|
37
|
+
const previous = args[i - 1];
|
|
38
|
+
if (typeof previous === "string" && SECRET_FLAG.test(previous) && !arg.startsWith("-"))
|
|
39
|
+
return "•••";
|
|
40
|
+
const equals = arg.indexOf("=");
|
|
41
|
+
if (equals > 0) {
|
|
42
|
+
const name = arg.slice(0, equals);
|
|
43
|
+
if (SECRET_FLAG.test(name) || KEY_PREFIX.test(arg.slice(equals + 1)))
|
|
44
|
+
return `${name}=•••`;
|
|
45
|
+
}
|
|
46
|
+
if (KEY_PREFIX.test(arg))
|
|
47
|
+
return "•••";
|
|
48
|
+
if (/^https?:\/\//i.test(arg))
|
|
49
|
+
return shownUrl(arg);
|
|
50
|
+
return arg;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
export function redactSecrets(value) {
|
|
54
|
+
if (Array.isArray(value))
|
|
55
|
+
return value.map(redactSecrets);
|
|
56
|
+
if (!isRecord(value))
|
|
57
|
+
return value;
|
|
58
|
+
// Built with fromEntries: assigning a key named __proto__ would set the prototype instead.
|
|
59
|
+
return Object.fromEntries(Object.entries(value).map(([key, inner]) => {
|
|
60
|
+
if ((key === "env" || key === "headers") && isRecord(inner))
|
|
61
|
+
return [key, Object.fromEntries(Object.keys(inner).map((k) => [k, "•••"]))];
|
|
62
|
+
if (key === "url" && typeof inner === "string")
|
|
63
|
+
return [key, shownUrl(inner)];
|
|
64
|
+
if (key === "args" && "command" in value && Array.isArray(inner))
|
|
65
|
+
return [key, shownArgs(inner)];
|
|
66
|
+
return [key, redactSecrets(inner)];
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* A JSON parse failure without the parser's message, which quotes the text around the fault and
|
|
71
|
+
* so can quote a secret.
|
|
72
|
+
*/
|
|
73
|
+
export function jsonSyntaxError(error) {
|
|
74
|
+
const at = /line (\d+) column (\d+)/.exec(String(error));
|
|
75
|
+
return at ? `not valid JSON (line ${at[1]}, column ${at[2]})` : "not valid JSON";
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The text ccdesk shows for a file reached as an instruction, import, memory, agent or skill. A
|
|
79
|
+
* settings or MCP file read this way, whatever it is named, would otherwise show its env and
|
|
80
|
+
* headers values, so anything that parses as JSON is shown parsed with them hidden. Frontmatter can
|
|
81
|
+
* hold the same values, in a subagent's inline MCP servers (docs: sub-agents#scope-mcp-servers-to-a-subagent),
|
|
82
|
+
* so frontmatter that holds any is shown re-written with them hidden.
|
|
83
|
+
*/
|
|
84
|
+
export function shownText(text) {
|
|
85
|
+
if (text === null)
|
|
86
|
+
return null;
|
|
87
|
+
let data;
|
|
88
|
+
try {
|
|
89
|
+
data = JSON.parse(text);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return shownFrontmatter(text);
|
|
93
|
+
}
|
|
94
|
+
return typeof data === "object" && data !== null
|
|
95
|
+
? `${JSON.stringify(redactSecrets(data), null, 2)}\n`
|
|
96
|
+
: text;
|
|
97
|
+
}
|
|
98
|
+
function shownFrontmatter(text) {
|
|
99
|
+
const match = FRONTMATTER.exec(text);
|
|
100
|
+
if (!match)
|
|
101
|
+
return text;
|
|
102
|
+
let data;
|
|
103
|
+
try {
|
|
104
|
+
data = parseYaml(match[1] ?? "");
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return text;
|
|
108
|
+
}
|
|
109
|
+
const redacted = redactSecrets(data);
|
|
110
|
+
if (JSON.stringify(redacted) === JSON.stringify(data))
|
|
111
|
+
return text;
|
|
112
|
+
return `---\n${stringifyYaml(redacted)}---\n${text.slice(match[0].length)}`;
|
|
113
|
+
}
|
|
114
|
+
/** A file named .json that does not parse is not shown: its text could still hold a secret. */
|
|
115
|
+
export function shownJsonFile(text) {
|
|
116
|
+
try {
|
|
117
|
+
JSON.parse(text);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return "(a JSON file ccdesk could not parse, so its text is not shown; open it to see it)\n";
|
|
121
|
+
}
|
|
122
|
+
return shownText(text) ?? "";
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Helpers for the values ccdesk hides. What is hidden is decided only by redactSecrets; these
|
|
126
|
+
* compare a value with its redacted form.
|
|
127
|
+
*/
|
|
128
|
+
/** A value as ccdesk shows it: JSON with hidden values as •••. */
|
|
129
|
+
export function shown(value) {
|
|
130
|
+
return `${JSON.stringify(redactSecrets(value), null, 2)}\n`;
|
|
131
|
+
}
|
|
132
|
+
/** Every value redactSecrets changes, with where it is, what it is, and how it is shown. */
|
|
133
|
+
function hiddenIn(value) {
|
|
134
|
+
const found = [];
|
|
135
|
+
// redactSecrets keeps every key and list length, so the two can be walked side by side.
|
|
136
|
+
const walk = (raw, shownValue, path) => {
|
|
137
|
+
if (Array.isArray(raw) && Array.isArray(shownValue)) {
|
|
138
|
+
for (const [i, inner] of raw.entries())
|
|
139
|
+
walk(inner, shownValue[i], [...path, i]);
|
|
140
|
+
}
|
|
141
|
+
else if (isRecord(raw) && isRecord(shownValue))
|
|
142
|
+
for (const [key, inner] of Object.entries(raw))
|
|
143
|
+
walk(inner, shownValue[key], [...path, key]);
|
|
144
|
+
else if (raw !== shownValue)
|
|
145
|
+
found.push({ path, raw, shown: shownValue });
|
|
146
|
+
};
|
|
147
|
+
walk(value, redactSecrets(value), []);
|
|
148
|
+
return found;
|
|
149
|
+
}
|
|
150
|
+
export function hasHiddenValues(value) {
|
|
151
|
+
return hiddenIn(value).length > 0;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* A fingerprint of the hidden values in a JSON file, so a restore can tell whether they changed.
|
|
155
|
+
* Null for a file that is not valid JSON.
|
|
156
|
+
*/
|
|
157
|
+
export function hiddenValues(text) {
|
|
158
|
+
try {
|
|
159
|
+
const data = text === null ? null : JSON.parse(text);
|
|
160
|
+
return JSON.stringify(hiddenIn(data).map(({ path, raw }) => [path, raw]));
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The secret text in a value, to take out of a message that may quote it: an env or header value
|
|
168
|
+
* or a hidden argument whole, the part after `=` of `--token=value`, and a URL's user name,
|
|
169
|
+
* password and query. Longest first, so a secret that contains another is taken out whole.
|
|
170
|
+
*/
|
|
171
|
+
export function secretTexts(value) {
|
|
172
|
+
const found = hiddenIn(value).flatMap(({ raw, shown: hidden }) => {
|
|
173
|
+
if (typeof raw !== "string")
|
|
174
|
+
return [];
|
|
175
|
+
if (hidden === "•••")
|
|
176
|
+
return [raw];
|
|
177
|
+
if (typeof hidden === "string" && hidden.endsWith("=•••"))
|
|
178
|
+
return [raw.slice(raw.indexOf("=") + 1)];
|
|
179
|
+
if (shownUrl(raw) === raw)
|
|
180
|
+
return [];
|
|
181
|
+
const url = URL.parse(raw);
|
|
182
|
+
return [url?.username, url?.password, url?.search.slice(1)];
|
|
183
|
+
});
|
|
184
|
+
return found
|
|
185
|
+
.filter((s) => s !== undefined && s !== "")
|
|
186
|
+
.sort((a, b) => b.length - a.length);
|
|
187
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type Change, type HookEdit, type PermissionEdit, type SettingsEdit, type SettingsEditPreview } from "../shared/types.ts";
|
|
2
|
+
import { type ScanEnv } from "./scan/env.ts";
|
|
3
|
+
import { type Serially } from "./write.ts";
|
|
4
|
+
export declare function previewSettingsEdit(env: ScanEnv, edit: SettingsEdit): Promise<SettingsEditPreview>;
|
|
5
|
+
export declare function saveSettingsEdit(env: ScanEnv, edit: SettingsEdit, expectedRevision: string, queue?: Serially): Promise<Change>;
|
|
6
|
+
export declare function previewPermissionEdit(env: ScanEnv, edit: PermissionEdit): Promise<SettingsEditPreview>;
|
|
7
|
+
export declare function savePermissionEdit(env: ScanEnv, edit: PermissionEdit, expectedRevision: string, queue?: Serially): Promise<Change>;
|
|
8
|
+
export declare function previewHookEdit(env: ScanEnv, edit: HookEdit): Promise<SettingsEditPreview>;
|
|
9
|
+
export declare function saveHookEdit(env: ScanEnv, edit: HookEdit, expectedRevision: string, queue?: Serially): Promise<Change>;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import { Compile } from "typebox/compile";
|
|
3
|
+
import HOOK_EVENTS from "../shared/hook-events.json" with { type: "json" };
|
|
4
|
+
import { allowedScopes, ignoredReason } from "../shared/scopes.js";
|
|
5
|
+
import schema from "../shared/settings.schema.json" with { type: "json" };
|
|
6
|
+
import {} from "../shared/types.js";
|
|
7
|
+
import {} from "./scan/env.js";
|
|
8
|
+
import { revisionOf } from "./scan/files.js";
|
|
9
|
+
import { isRecord } from "./scan/data.js";
|
|
10
|
+
import { isSkippedMcpRule } from "./scan/permissions.js";
|
|
11
|
+
import { settingsFiles } from "./scan/settings.js";
|
|
12
|
+
import { schemaErrors } from "./schema.js";
|
|
13
|
+
import { hasHiddenValues, redactSecrets, shown } from "./secrets.js";
|
|
14
|
+
import { RefusedError, readJsonObject, recordChange, saveReviewed, serially, writeAtomic, } from "./write.js";
|
|
15
|
+
const properties = schema.definitions.ClaudeCodeSettings.properties;
|
|
16
|
+
const validators = new Map();
|
|
17
|
+
function validatorFor(key) {
|
|
18
|
+
if (!Object.hasOwn(properties, key))
|
|
19
|
+
return null;
|
|
20
|
+
let validate = validators.get(key);
|
|
21
|
+
if (!validate) {
|
|
22
|
+
validate = Compile(properties[key]);
|
|
23
|
+
validators.set(key, validate);
|
|
24
|
+
}
|
|
25
|
+
return validate;
|
|
26
|
+
}
|
|
27
|
+
async function editableFile(env, dir, pick, missing = "That is not a settings file Claude Code reads here.") {
|
|
28
|
+
const file = (await settingsFiles(env, dir)).find(pick);
|
|
29
|
+
if (!file)
|
|
30
|
+
throw new RefusedError(missing);
|
|
31
|
+
if (file.scope === "managed")
|
|
32
|
+
throw new RefusedError("Managed settings are set by your organisation.");
|
|
33
|
+
if (file.scope !== "user" && !(await stat(dir).catch(() => null))?.isDirectory())
|
|
34
|
+
throw new RefusedError(`${dir} is not a directory on this machine.`);
|
|
35
|
+
return file;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Works out the file change that sets or removes one key, refusing what ccdesk cannot do safely.
|
|
39
|
+
* `checksHiddenValues` is for a key whose edit checks the part it changes itself, so values it
|
|
40
|
+
* leaves alone are written back exactly as they were.
|
|
41
|
+
*/
|
|
42
|
+
async function planKey(env, file, key, value, checksHiddenValues = false) {
|
|
43
|
+
if (!allowedScopes(key).includes(file.scope))
|
|
44
|
+
throw new RefusedError(`${ignoredReason(key)}, so a ${file.scope} value would do nothing.`);
|
|
45
|
+
const validate = validatorFor(key);
|
|
46
|
+
if (!validate)
|
|
47
|
+
throw new RefusedError(`${key} is not in Claude Code's settings schema (${schema.$comment}).`);
|
|
48
|
+
const { path } = file;
|
|
49
|
+
const { raw, data, reformats } = await readJsonObject(env, path);
|
|
50
|
+
const nextValue = value(data);
|
|
51
|
+
// ccdesk never shows hidden values, so it must not write them either.
|
|
52
|
+
if (!checksHiddenValues &&
|
|
53
|
+
(hasHiddenValues({ [key]: data[key] }) || hasHiddenValues({ [key]: nextValue })))
|
|
54
|
+
throw new RefusedError(`${key} holds hidden values; edit it in the file instead.`);
|
|
55
|
+
if (raw !== null && JSON.stringify(nextValue) === JSON.stringify(data[key]))
|
|
56
|
+
throw new RefusedError("This file already says that; there is nothing to change.");
|
|
57
|
+
const errors = nextValue === undefined
|
|
58
|
+
? []
|
|
59
|
+
: [...schemaErrors(validate, nextValue, key), ...newHookEvents(key, data[key], nextValue)];
|
|
60
|
+
const next = { ...data };
|
|
61
|
+
if (nextValue === undefined)
|
|
62
|
+
delete next[key];
|
|
63
|
+
else
|
|
64
|
+
next[key] = nextValue;
|
|
65
|
+
const text = `${JSON.stringify(next, null, 2)}\n`;
|
|
66
|
+
const before = raw === null ? null : shown(data);
|
|
67
|
+
return {
|
|
68
|
+
preview: {
|
|
69
|
+
path,
|
|
70
|
+
before,
|
|
71
|
+
after: errors.length > 0 ? (before ?? "") : shown(next),
|
|
72
|
+
revision: revisionOf(raw),
|
|
73
|
+
reformats,
|
|
74
|
+
errors,
|
|
75
|
+
},
|
|
76
|
+
save: () => recordChange(env, { kind: "settings", target: path, before: raw }, async () => {
|
|
77
|
+
await writeAtomic(env, path, text, revisionOf(raw));
|
|
78
|
+
return { target: path, revision: revisionOf(text) };
|
|
79
|
+
}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
async function planSettingsEdit(env, edit) {
|
|
83
|
+
const file = await editableFile(env, edit.dir, (f) => f.scope === edit.scope && !f.legacy, edit.scope === "project"
|
|
84
|
+
? "In your home directory this file is your user settings; edit it as User."
|
|
85
|
+
: undefined);
|
|
86
|
+
return planKey(env, file, edit.key, () => edit.value);
|
|
87
|
+
}
|
|
88
|
+
/** One rule taken out of its list and/or added to another, applied to the file as it is on disk. */
|
|
89
|
+
async function planPermissionEdit(env, edit) {
|
|
90
|
+
const file = await editableFile(env, edit.dir, (f) => f.path === edit.file);
|
|
91
|
+
return planKey(env, file, "permissions", (data) => {
|
|
92
|
+
const permissions = isRecord(data.permissions) ? { ...data.permissions } : {};
|
|
93
|
+
const { remove, add } = edit;
|
|
94
|
+
if (remove) {
|
|
95
|
+
const list = Array.isArray(permissions[remove.list])
|
|
96
|
+
? permissions[remove.list]
|
|
97
|
+
: [];
|
|
98
|
+
if (!list.includes(remove.rule))
|
|
99
|
+
throw new RefusedError(`${remove.rule} is no longer in ${remove.list} in this file.`);
|
|
100
|
+
const kept = list.filter((r) => r !== remove.rule);
|
|
101
|
+
if (kept.length > 0)
|
|
102
|
+
permissions[remove.list] = kept;
|
|
103
|
+
else
|
|
104
|
+
delete permissions[remove.list];
|
|
105
|
+
}
|
|
106
|
+
if (add) {
|
|
107
|
+
if (isSkippedMcpRule(add.rule))
|
|
108
|
+
throw new RefusedError("Claude Code skips an MCP rule with parentheses in a settings file. To match a parameter of an MCP tool, pass a deny rule with --disallowedTools.");
|
|
109
|
+
const list = Array.isArray(permissions[add.list]) ? permissions[add.list] : [];
|
|
110
|
+
if (!list.includes(add.rule))
|
|
111
|
+
permissions[add.list] = [...list, add.rule];
|
|
112
|
+
}
|
|
113
|
+
return Object.keys(permissions).length > 0 ? permissions : undefined;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Hook events an edit brings in that Claude Code does not know, which the schema allows. Ones the
|
|
118
|
+
* file already has stay, so a file written for another Claude Code version can still be edited.
|
|
119
|
+
*/
|
|
120
|
+
function newHookEvents(key, before, after) {
|
|
121
|
+
if (key !== "hooks" || !isRecord(after))
|
|
122
|
+
return [];
|
|
123
|
+
const had = isRecord(before) ? Object.keys(before) : [];
|
|
124
|
+
return Object.keys(after)
|
|
125
|
+
.filter((event) => !HOOK_EVENTS.includes(event) && !had.includes(event))
|
|
126
|
+
.map((event) => `${event} is not a hook event Claude Code knows.`);
|
|
127
|
+
}
|
|
128
|
+
/** The matcher a group is written with; a missing or empty one matches everything. */
|
|
129
|
+
function matcherOf(group) {
|
|
130
|
+
return isRecord(group) && typeof group.matcher === "string" ? group.matcher : "";
|
|
131
|
+
}
|
|
132
|
+
/** One hook taken out, replaced or added, applied to the file as it is on disk. */
|
|
133
|
+
async function planHookEdit(env, edit) {
|
|
134
|
+
const file = await editableFile(env, edit.dir, (f) => f.path === edit.file);
|
|
135
|
+
return planKey(env, file, "hooks", (data) => editedHooks(data.hooks, edit), true);
|
|
136
|
+
}
|
|
137
|
+
/** The hooks setting with the edit applied, or undefined when no hooks are left. */
|
|
138
|
+
function editedHooks(value, edit) {
|
|
139
|
+
const hooks = isRecord(value) ? structuredClone(value) : {};
|
|
140
|
+
const { remove, add } = edit;
|
|
141
|
+
// Checked before any branch, so neither a new hook nor a replacement writes them.
|
|
142
|
+
if (add && hasHiddenValues(add.definition))
|
|
143
|
+
throw new RefusedError("ccdesk does not write header values it would then hide; add this hook in the file instead.");
|
|
144
|
+
if (remove && takeOutHook(hooks, remove, add) === "replaced")
|
|
145
|
+
return hooks;
|
|
146
|
+
if (add) {
|
|
147
|
+
const groups = groupsOf(hooks, add.event);
|
|
148
|
+
const matcher = add.matcher ?? "";
|
|
149
|
+
const group = groups.find((g) => matcherOf(g) === matcher && isRecord(g) && Array.isArray(g.hooks));
|
|
150
|
+
if (isRecord(group) && Array.isArray(group.hooks))
|
|
151
|
+
group.hooks.push(add.definition);
|
|
152
|
+
else
|
|
153
|
+
groups.push({ ...(matcher ? { matcher } : {}), hooks: [add.definition] });
|
|
154
|
+
hooks[add.event] = groups;
|
|
155
|
+
}
|
|
156
|
+
return Object.keys(hooks).length > 0 ? hooks : undefined;
|
|
157
|
+
}
|
|
158
|
+
/** An event's matcher groups as stored in `hooks`, or a new list when it has none. */
|
|
159
|
+
function groupsOf(hooks, event) {
|
|
160
|
+
const groups = hooks[event];
|
|
161
|
+
return Array.isArray(groups) ? groups : [];
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Takes the reviewed hook out of `hooks`, or puts `add` in its place when only its matcher or
|
|
165
|
+
* nothing about where it sits changes.
|
|
166
|
+
*/
|
|
167
|
+
function takeOutHook(hooks, remove, add) {
|
|
168
|
+
const groups = groupsOf(hooks, remove.event);
|
|
169
|
+
const group = groups[remove.at.group];
|
|
170
|
+
const list = isRecord(group) && Array.isArray(group.hooks) ? group.hooks : null;
|
|
171
|
+
const hook = list?.[remove.at.index];
|
|
172
|
+
if (!list || JSON.stringify(redactSecrets(hook)) !== JSON.stringify(remove.definition))
|
|
173
|
+
throw new RefusedError("This hook changed in the file since ccdesk read it; review it again.");
|
|
174
|
+
// The page saw this hook with its header values hidden, so a changed copy would lose them.
|
|
175
|
+
if (add && hasHiddenValues(hook))
|
|
176
|
+
throw new RefusedError("This hook sends header values ccdesk does not show; edit it in the file, or remove it and add it again.");
|
|
177
|
+
const matcher = add?.matcher ?? "";
|
|
178
|
+
// A hook alone in its group keeps the group when only its matcher changes, unless another
|
|
179
|
+
// group already has that matcher.
|
|
180
|
+
const inPlace = add?.event === remove.event &&
|
|
181
|
+
(matcher === matcherOf(group) ||
|
|
182
|
+
(list.length === 1 && !groups.some((g) => g !== group && matcherOf(g) === matcher)));
|
|
183
|
+
if (add && inPlace && isRecord(group)) {
|
|
184
|
+
list[remove.at.index] = add.definition;
|
|
185
|
+
if (matcher)
|
|
186
|
+
group.matcher = matcher;
|
|
187
|
+
else
|
|
188
|
+
delete group.matcher;
|
|
189
|
+
return "replaced";
|
|
190
|
+
}
|
|
191
|
+
list.splice(remove.at.index, 1);
|
|
192
|
+
if (list.length === 0)
|
|
193
|
+
groups.splice(remove.at.group, 1);
|
|
194
|
+
if (groups.length === 0 && add?.event !== remove.event)
|
|
195
|
+
delete hooks[remove.event];
|
|
196
|
+
return "removed";
|
|
197
|
+
}
|
|
198
|
+
const CHANGED = "The file changed since you reviewed this edit; review it again.";
|
|
199
|
+
export async function previewSettingsEdit(env, edit) {
|
|
200
|
+
return (await planSettingsEdit(env, edit)).preview;
|
|
201
|
+
}
|
|
202
|
+
export function saveSettingsEdit(env, edit, expectedRevision, queue = serially) {
|
|
203
|
+
return saveReviewed(queue, () => planSettingsEdit(env, edit), expectedRevision, CHANGED);
|
|
204
|
+
}
|
|
205
|
+
export async function previewPermissionEdit(env, edit) {
|
|
206
|
+
return (await planPermissionEdit(env, edit)).preview;
|
|
207
|
+
}
|
|
208
|
+
export function savePermissionEdit(env, edit, expectedRevision, queue = serially) {
|
|
209
|
+
return saveReviewed(queue, () => planPermissionEdit(env, edit), expectedRevision, CHANGED);
|
|
210
|
+
}
|
|
211
|
+
export async function previewHookEdit(env, edit) {
|
|
212
|
+
return (await planHookEdit(env, edit)).preview;
|
|
213
|
+
}
|
|
214
|
+
export function saveHookEdit(env, edit, expectedRevision, queue = serially) {
|
|
215
|
+
return saveReviewed(queue, () => planHookEdit(env, edit), expectedRevision, CHANGED);
|
|
216
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type DirectoryView, type SessionProfile } from "../shared/types.ts";
|
|
2
|
+
import { type ScanEnv } from "./scan/env.ts";
|
|
3
|
+
import { scanDirectory } from "./scan/index.ts";
|
|
4
|
+
/** One set of file watchers per directory a page is looking at, shared by its requests. */
|
|
5
|
+
export declare class Watcher {
|
|
6
|
+
private readonly subscriptions;
|
|
7
|
+
/** What the latest scan of each directory read, kept for a watch that starts after the scan. */
|
|
8
|
+
private readonly scanned;
|
|
9
|
+
private readonly latest;
|
|
10
|
+
private readonly env;
|
|
11
|
+
private readonly scanOf;
|
|
12
|
+
constructor(env: ScanEnv, scanOf?: typeof scanDirectory);
|
|
13
|
+
/** Scans `dir` and remembers what the scan read, so those files are watched too. */
|
|
14
|
+
scan(dir: string, profile?: SessionProfile): Promise<DirectoryView>;
|
|
15
|
+
/**
|
|
16
|
+
* The view of `dir`: the latest scan while it is known to be current, else a new scan. It is
|
|
17
|
+
* current only while watchers that started before it have seen no change since it started, and
|
|
18
|
+
* ~/.claude.json, which is not watched, is as it was. Only an interactive scan is reused, as the
|
|
19
|
+
* path tester describes an interactive session.
|
|
20
|
+
*/
|
|
21
|
+
view(dir: string): Promise<DirectoryView>;
|
|
22
|
+
/** ~/.claude.json's size and time; Claude Code rewrites it too often to watch. */
|
|
23
|
+
private stateFile;
|
|
24
|
+
/**
|
|
25
|
+
* Answers once something changed after `since` (ms since the epoch), or after `timeoutMs` with
|
|
26
|
+
* the last change time, whichever comes first.
|
|
27
|
+
*/
|
|
28
|
+
wait(dir: string, since: number, signal: AbortSignal, timeoutMs?: number): Promise<{
|
|
29
|
+
changed: boolean;
|
|
30
|
+
at: number;
|
|
31
|
+
}>;
|
|
32
|
+
/**
|
|
33
|
+
* Calls `onChange` once after each change to what ccdesk shows for `dir`, until the returned
|
|
34
|
+
* function is called. Resolves once the watchers have started, so a change made after it
|
|
35
|
+
* resolves is seen. `close` stops the calls without making one.
|
|
36
|
+
*/
|
|
37
|
+
subscribe(dir: string, onChange: () => void): Promise<() => void>;
|
|
38
|
+
/** Drops the watchers of `dir` after a while once no one waits or listens for changes. */
|
|
39
|
+
private release;
|
|
40
|
+
close(): void;
|
|
41
|
+
private watching;
|
|
42
|
+
private drop;
|
|
43
|
+
/** (Re)creates the watchers, so a folder created since, such as a new .claude, is watched. */
|
|
44
|
+
private rewatch;
|
|
45
|
+
}
|