@klhapp/skillmux 1.9.3 → 1.11.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 +46 -0
- package/README.md +19 -19
- package/docs/README.md +4 -4
- package/docs/assets/architecture-dark.svg +39 -32
- package/docs/assets/architecture-light.svg +25 -18
- package/docs/cli.md +147 -36
- package/docs/concepts.md +11 -11
- package/docs/configuration.md +7 -5
- package/docs/deployment.md +10 -6
- package/docs/getting-started.md +18 -14
- package/docs/mcp-routing.md +1 -1
- package/docs/skill-management.md +17 -11
- package/docs/troubleshooting.md +4 -4
- package/package.json +1 -1
- package/src/adapters.ts +157 -11
- package/src/cli.ts +396 -1319
- package/src/commands/audit.ts +53 -56
- package/src/commands/config.ts +33 -26
- package/src/commands/context.ts +104 -0
- package/src/commands/core.ts +7 -3
- package/src/commands/doctor.ts +97 -0
- package/src/commands/eval.ts +22 -15
- package/src/commands/init.ts +672 -0
- package/src/commands/install.ts +132 -0
- package/src/commands/local-vault.ts +60 -0
- package/src/commands/models.ts +10 -0
- package/src/commands/outdated.ts +2 -1
- package/src/commands/project.ts +194 -51
- package/src/commands/report.ts +66 -0
- package/src/commands/scan.ts +61 -0
- package/src/commands/shared.ts +7 -14
- package/src/commands/skill.ts +33 -0
- package/src/commands/sync.ts +232 -0
- package/src/commands/target.ts +45 -15
- package/src/commands/update.ts +2 -1
- package/src/completions.ts +41 -15
- package/src/config-service.ts +4 -54
- package/src/context.ts +8 -3
- package/src/db-audit.ts +286 -0
- package/src/db-index.ts +238 -0
- package/src/db.ts +3 -521
- package/src/global-flags.ts +46 -0
- package/src/init-agents.ts +329 -0
- package/src/init-instructions.ts +47 -28
- package/src/logger.ts +26 -0
- package/src/mcp-registration.ts +89 -0
- package/src/output.ts +80 -18
- package/src/prompts.ts +75 -20
- package/src/router-core.ts +8 -27
- package/src/scan.ts +19 -19
- package/src/server.ts +161 -14
- package/src/toml-writer.ts +51 -0
- package/src/init-clients.ts +0 -220
package/src/output.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ResolvedContext } from "./context";
|
|
2
2
|
|
|
3
3
|
export interface JsonEnvelope<T = any> {
|
|
4
4
|
schema_version: 1;
|
|
5
5
|
ok: boolean;
|
|
6
|
+
context: string | { name: string; server: string };
|
|
7
|
+
/** @deprecated Slated for removal in the next major version. Use `context` instead. */
|
|
6
8
|
target: string | { name: string; server: string };
|
|
7
9
|
data: T | null;
|
|
8
10
|
error: { code: string; message: string; details?: any } | null;
|
|
@@ -10,27 +12,34 @@ export interface JsonEnvelope<T = any> {
|
|
|
10
12
|
|
|
11
13
|
export function formatJsonEnvelope<T>(opts: {
|
|
12
14
|
ok: boolean;
|
|
13
|
-
|
|
15
|
+
/** @deprecated Slated for removal in the next major version. Use `context` instead. */
|
|
16
|
+
target?: ResolvedContext | string | { name: string; server: string };
|
|
17
|
+
context?: ResolvedContext | string | { name: string; server: string };
|
|
14
18
|
data?: T;
|
|
15
19
|
error?: { code: string; message: string; details?: any } | null;
|
|
16
20
|
}): JsonEnvelope<T> {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
const input: ResolvedContext | string | { name: string; server: string } =
|
|
22
|
+
opts.context ?? opts.target ?? "local";
|
|
23
|
+
let contextVal: string | { name: string; server: string };
|
|
24
|
+
if (typeof input === "string") {
|
|
25
|
+
contextVal = input;
|
|
26
|
+
} else if (typeof input === "object" && input !== null) {
|
|
27
|
+
if ("type" in input && (input as any).type === "local") {
|
|
28
|
+
contextVal = "local";
|
|
29
|
+
} else if ("name" in input && "server" in input) {
|
|
30
|
+
contextVal = { name: input.name, server: input.server };
|
|
23
31
|
} else {
|
|
24
|
-
|
|
32
|
+
contextVal = "local";
|
|
25
33
|
}
|
|
26
34
|
} else {
|
|
27
|
-
|
|
35
|
+
contextVal = "local";
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
return {
|
|
31
39
|
schema_version: 1,
|
|
32
40
|
ok: opts.ok,
|
|
33
|
-
|
|
41
|
+
context: contextVal,
|
|
42
|
+
target: contextVal,
|
|
34
43
|
data: opts.data ?? null,
|
|
35
44
|
error: opts.error ?? null,
|
|
36
45
|
};
|
|
@@ -51,12 +60,18 @@ export class CliError extends Error {
|
|
|
51
60
|
}
|
|
52
61
|
|
|
53
62
|
export function emitSuccess<T>(
|
|
54
|
-
ctx: {
|
|
63
|
+
ctx: {
|
|
64
|
+
isJson: boolean;
|
|
65
|
+
/** @deprecated Slated for removal in the next major version. Use `context` instead. */
|
|
66
|
+
target?: ResolvedContext | string | { name: string; server: string };
|
|
67
|
+
context?: ResolvedContext | string | { name: string; server: string };
|
|
68
|
+
},
|
|
55
69
|
data: T,
|
|
56
70
|
renderText: () => void,
|
|
57
71
|
): void {
|
|
58
72
|
if (ctx.isJson) {
|
|
59
|
-
|
|
73
|
+
const contextVal = ctx.context ?? ctx.target ?? "local";
|
|
74
|
+
console.log(JSON.stringify(formatJsonEnvelope({ ok: true, context: contextVal, target: contextVal, data })));
|
|
60
75
|
} else {
|
|
61
76
|
renderText();
|
|
62
77
|
}
|
|
@@ -112,6 +127,28 @@ export function suggestCorrection(input: string, candidates: string[]): string |
|
|
|
112
127
|
return bestMatch;
|
|
113
128
|
}
|
|
114
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Builds the error for an unrecognized subcommand: "did you mean X" when
|
|
132
|
+
* close to a valid one, otherwise the full <a|b|c> usage list — never a
|
|
133
|
+
* fixed, possibly-unrelated usage string for just one of several valid
|
|
134
|
+
* subcommands (that's what `config`'s fallback used to do before this
|
|
135
|
+
* existed: any invalid subcommand got told "usage: skillmux config show",
|
|
136
|
+
* silently omitting get/set/validate/diff/status/init).
|
|
137
|
+
*/
|
|
138
|
+
export function unknownSubcommandError(
|
|
139
|
+
command: string,
|
|
140
|
+
subCommand: string,
|
|
141
|
+
validSubcommands: string[],
|
|
142
|
+
): Error {
|
|
143
|
+
const suggestion = subCommand ? suggestCorrection(subCommand, validSubcommands) : null;
|
|
144
|
+
if (suggestion) {
|
|
145
|
+
return new Error(
|
|
146
|
+
`Unknown "${command} ${subCommand}" subcommand. Did you mean "${command} ${suggestion}"?`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
return new Error(`usage: skillmux ${command} <${validSubcommands.join("|")}>`);
|
|
150
|
+
}
|
|
151
|
+
|
|
115
152
|
export function isInteractive(
|
|
116
153
|
env: NodeJS.ProcessEnv = process.env,
|
|
117
154
|
stdoutIsTTY = process.stdout.isTTY,
|
|
@@ -119,12 +156,37 @@ export function isInteractive(
|
|
|
119
156
|
return stdoutIsTTY === true && env.TERM !== "dumb";
|
|
120
157
|
}
|
|
121
158
|
|
|
122
|
-
|
|
159
|
+
/** Color is opt-out only: https://no-color.org, plus the same TTY check as isInteractive(). */
|
|
160
|
+
export function isColorEnabled(
|
|
161
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
162
|
+
stdoutIsTTY = process.stdout.isTTY,
|
|
163
|
+
): boolean {
|
|
164
|
+
if (env.NO_COLOR !== undefined) return false;
|
|
165
|
+
return isInteractive(env, stdoutIsTTY);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const ANSI = { reset: "\x1b[0m", bold: "\x1b[1m", red: "\x1b[31m", yellow: "\x1b[33m", green: "\x1b[32m" } as const;
|
|
169
|
+
|
|
170
|
+
function paint(code: string, text: string): string {
|
|
171
|
+
return isColorEnabled() ? `${code}${text}${ANSI.reset}` : text;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const red = (text: string): string => paint(ANSI.red, text);
|
|
175
|
+
export const yellow = (text: string): string => paint(ANSI.yellow, text);
|
|
176
|
+
export const green = (text: string): string => paint(ANSI.green, text);
|
|
177
|
+
export const bold = (text: string): string => paint(ANSI.bold, text);
|
|
178
|
+
|
|
179
|
+
/** Prints a "warning: <line>" message to stderr, colored yellow when color is enabled. */
|
|
180
|
+
export function warn(line: string): void {
|
|
181
|
+
console.error(yellow(`warning: ${line}`));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function renderContextBanner(context: ResolvedContext): void {
|
|
123
185
|
if (!isInteractive()) return;
|
|
124
|
-
if (
|
|
125
|
-
console.log(`
|
|
186
|
+
if (context.type === "local") {
|
|
187
|
+
console.log(`Context: local`);
|
|
126
188
|
} else {
|
|
127
|
-
console.log(`
|
|
189
|
+
console.log(`Context: remote (${context.name} -> ${context.server})`);
|
|
128
190
|
}
|
|
129
191
|
}
|
|
130
192
|
|
|
@@ -143,7 +205,7 @@ export function renderTable(columns: { key: string; header: string }[], rows: Re
|
|
|
143
205
|
const headerLine = columns.map((col) => col.header.padEnd(widths.get(col.key) ?? 0)).join(" ");
|
|
144
206
|
const sepLine = columns.map((col) => "-".repeat(widths.get(col.key) ?? 0)).join(" ");
|
|
145
207
|
|
|
146
|
-
console.log(headerLine);
|
|
208
|
+
console.log(bold(headerLine));
|
|
147
209
|
console.log(sepLine);
|
|
148
210
|
for (const row of rows) {
|
|
149
211
|
const line = columns.map((col) => String(row[col.key] ?? "").padEnd(widths.get(col.key) ?? 0)).join(" ");
|
package/src/prompts.ts
CHANGED
|
@@ -1,4 +1,63 @@
|
|
|
1
1
|
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import type { Readable, Writable } from "node:stream";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Streams a prompt reads from and writes to. Injectable so prompts can be
|
|
6
|
+
* tested without a terminal; defaults to the real process streams.
|
|
7
|
+
*/
|
|
8
|
+
export interface PromptIO {
|
|
9
|
+
input?: Readable;
|
|
10
|
+
output?: Writable;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const NO_INPUT_ERROR = "no input available on stdin; re-run with --yes";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Asks one question and resolves with the raw answer.
|
|
17
|
+
*
|
|
18
|
+
* `isInteractive()` only inspects stdout, so a caller can legitimately reach a
|
|
19
|
+
* prompt while stdin is already at EOF (a pty'd stdout under CI, a wrapper
|
|
20
|
+
* process, `skillmux init < /dev/null`). A bare `readline.question()` never
|
|
21
|
+
* settles in that case and the CLI hangs forever, so the input stream ending
|
|
22
|
+
* before an answer arrives is turned into an actionable error instead.
|
|
23
|
+
*
|
|
24
|
+
* Piped answers (`printf 'y\n' | skillmux ...`) still work: `close` only wins
|
|
25
|
+
* the race when the stream ends with the question still outstanding.
|
|
26
|
+
*/
|
|
27
|
+
export async function askQuestion(query: string, io: PromptIO = {}): Promise<string> {
|
|
28
|
+
const readline = createInterface({
|
|
29
|
+
input: io.input ?? process.stdin,
|
|
30
|
+
output: io.output ?? process.stdout,
|
|
31
|
+
});
|
|
32
|
+
let settled = false;
|
|
33
|
+
try {
|
|
34
|
+
return await new Promise<string>((resolve, reject) => {
|
|
35
|
+
readline.question(query).then(
|
|
36
|
+
(answer) => {
|
|
37
|
+
settled = true;
|
|
38
|
+
resolve(answer);
|
|
39
|
+
},
|
|
40
|
+
(error) => {
|
|
41
|
+
settled = true;
|
|
42
|
+
reject(error);
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
// Fires on EOF, and also from the `finally` below. A piped stream ends
|
|
46
|
+
// immediately after delivering its answer, so `close` can arrive before
|
|
47
|
+
// the resolution microtask runs — deferring past pending microtasks lets
|
|
48
|
+
// a real answer win the race, leaving only a genuine EOF to reject.
|
|
49
|
+
readline.once("close", () => {
|
|
50
|
+
setImmediate(() => {
|
|
51
|
+
if (settled) return;
|
|
52
|
+
settled = true;
|
|
53
|
+
reject(new Error(NO_INPUT_ERROR));
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
} finally {
|
|
58
|
+
readline.close();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
2
61
|
|
|
3
62
|
export interface SelectOption<T extends string> {
|
|
4
63
|
value: T;
|
|
@@ -35,35 +94,31 @@ export function shouldUseWizard(
|
|
|
35
94
|
export async function promptMultiSelect<T extends string>(
|
|
36
95
|
question: string,
|
|
37
96
|
options: readonly SelectOption<T>[],
|
|
97
|
+
io: PromptIO = {},
|
|
38
98
|
): Promise<T[]> {
|
|
39
|
-
|
|
99
|
+
const output = io.output ?? process.stdout;
|
|
100
|
+
output.write(`\n${question}\n`);
|
|
40
101
|
options.forEach((option, index) => {
|
|
41
102
|
const checked = option.selected ? "x" : " ";
|
|
42
103
|
const detail = option.detail ? ` ${option.detail}` : "";
|
|
43
|
-
|
|
104
|
+
output.write(` ${index + 1}. [${checked}] ${option.label}${detail}\n`);
|
|
44
105
|
});
|
|
45
106
|
const defaults = options
|
|
46
107
|
.map((option, index) => option.selected ? String(index + 1) : "")
|
|
47
108
|
.filter(Boolean)
|
|
48
109
|
.join(",");
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const selection = answer.trim() === "" && defaults ? defaults : answer;
|
|
54
|
-
return parseNumberSelection(selection, options.length).map((index) => options[index]!.value);
|
|
55
|
-
} finally {
|
|
56
|
-
readline.close();
|
|
57
|
-
}
|
|
110
|
+
const suffix = defaults ? ` [${defaults}]` : "";
|
|
111
|
+
const answer = await askQuestion(`Select numbers, comma-separated${suffix}: `, io);
|
|
112
|
+
const selection = answer.trim() === "" && defaults ? defaults : answer;
|
|
113
|
+
return parseNumberSelection(selection, options.length).map((index) => options[index]!.value);
|
|
58
114
|
}
|
|
59
115
|
|
|
60
|
-
export async function promptText(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
}
|
|
116
|
+
export async function promptText(
|
|
117
|
+
question: string,
|
|
118
|
+
defaultValue = "",
|
|
119
|
+
io: PromptIO = {},
|
|
120
|
+
): Promise<string> {
|
|
121
|
+
const suffix = defaultValue ? ` [${defaultValue}]` : "";
|
|
122
|
+
const answer = (await askQuestion(`${question}${suffix}: `, io)).trim();
|
|
123
|
+
return answer || defaultValue;
|
|
69
124
|
}
|
package/src/router-core.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { join } from "node:path";
|
|
|
4
4
|
import { buildAuditRow } from "./audit";
|
|
5
5
|
import { embeddingDimension, embeddingFingerprint, expandHome, loadConfig } from "./config";
|
|
6
6
|
import { RemoteInferenceError } from "./clients";
|
|
7
|
+
import { log } from "./logger";
|
|
8
|
+
import { warn } from "./output";
|
|
7
9
|
import {
|
|
8
10
|
deleteSkill,
|
|
9
11
|
findExactMatch,
|
|
@@ -274,7 +276,7 @@ export async function syncVaultIfNeeded(): Promise<void> {
|
|
|
274
276
|
const invalidIds: string[] = [];
|
|
275
277
|
const skills = await scanVaults(vaultPath, localVaultPaths, (skillId, error) => {
|
|
276
278
|
invalidIds.push(skillId);
|
|
277
|
-
|
|
279
|
+
warn(`keeping previous index entry for ${skillId}: ${error}`);
|
|
278
280
|
});
|
|
279
281
|
const rows = skills.map(toSkillRow);
|
|
280
282
|
// See rebuildIndex: a skill_id invalid in one root can still be valid in
|
|
@@ -361,7 +363,7 @@ async function reindexOneSkill(db: Database, vaultPath: string, skillId: string)
|
|
|
361
363
|
upsertSkill(db, await readSkill(vaultPath, skillId));
|
|
362
364
|
backfillEmbeddings().catch(() => {});
|
|
363
365
|
} catch (error) {
|
|
364
|
-
|
|
366
|
+
warn(`keeping previous index entry for ${skillId}: ${error}`);
|
|
365
367
|
}
|
|
366
368
|
}
|
|
367
369
|
|
|
@@ -395,7 +397,7 @@ export async function startVaultWatcher(): Promise<() => void> {
|
|
|
395
397
|
// A watcher error (e.g. the vault root disappearing) must degrade the index,
|
|
396
398
|
// not crash the server — an unhandled 'error' event would throw.
|
|
397
399
|
watcher.on("error", (error) => {
|
|
398
|
-
|
|
400
|
+
warn(`vault watcher error, live updates paused: ${error}`);
|
|
399
401
|
});
|
|
400
402
|
|
|
401
403
|
return () => {
|
|
@@ -554,14 +556,7 @@ export async function retrieveAndRerank(
|
|
|
554
556
|
retrieval = "lexical";
|
|
555
557
|
degraded_from = clients.rerank ? "reranked" : "hybrid";
|
|
556
558
|
degradation_reason = classifyInferenceError("embedding", embedRes.error);
|
|
557
|
-
|
|
558
|
-
JSON.stringify({
|
|
559
|
-
level: "warn",
|
|
560
|
-
stage: "embedding",
|
|
561
|
-
degraded_from,
|
|
562
|
-
reason: degradation_reason,
|
|
563
|
-
}),
|
|
564
|
-
);
|
|
559
|
+
log.warn("embedding", { degraded_from, reason: degradation_reason });
|
|
565
560
|
} else {
|
|
566
561
|
try {
|
|
567
562
|
const queryVec = (embedRes as Float32Array[])[0];
|
|
@@ -574,14 +569,7 @@ export async function retrieveAndRerank(
|
|
|
574
569
|
retrieval = "lexical";
|
|
575
570
|
degraded_from = clients.rerank ? "reranked" : "hybrid";
|
|
576
571
|
degradation_reason = classifyInferenceError("embedding", embedError);
|
|
577
|
-
|
|
578
|
-
JSON.stringify({
|
|
579
|
-
level: "warn",
|
|
580
|
-
stage: "embedding",
|
|
581
|
-
degraded_from,
|
|
582
|
-
reason: degradation_reason,
|
|
583
|
-
}),
|
|
584
|
-
);
|
|
572
|
+
log.warn("embedding", { degraded_from, reason: degradation_reason });
|
|
585
573
|
}
|
|
586
574
|
}
|
|
587
575
|
}
|
|
@@ -601,14 +589,7 @@ export async function retrieveAndRerank(
|
|
|
601
589
|
scores = null;
|
|
602
590
|
degraded_from = "reranked";
|
|
603
591
|
degradation_reason = classifyInferenceError("reranker", rerankError);
|
|
604
|
-
|
|
605
|
-
JSON.stringify({
|
|
606
|
-
level: "warn",
|
|
607
|
-
stage: "reranker",
|
|
608
|
-
degraded_from,
|
|
609
|
-
reason: degradation_reason,
|
|
610
|
-
}),
|
|
611
|
-
);
|
|
592
|
+
log.warn("reranker", { degraded_from, reason: degradation_reason });
|
|
612
593
|
}
|
|
613
594
|
}
|
|
614
595
|
|
package/src/scan.ts
CHANGED
|
@@ -156,7 +156,7 @@ export interface ScanResult {
|
|
|
156
156
|
findings: ScanFinding[];
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
interface
|
|
159
|
+
interface ScanContentFile {
|
|
160
160
|
skill_id: string;
|
|
161
161
|
file: string;
|
|
162
162
|
content: string;
|
|
@@ -177,21 +177,21 @@ export async function readTextFileOrNull(path: string): Promise<string | null> {
|
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
async function
|
|
180
|
+
async function collectSkillFiles(
|
|
181
181
|
vaultPath: string,
|
|
182
182
|
skillId: string,
|
|
183
183
|
skillMdBody: string,
|
|
184
|
-
): Promise<
|
|
185
|
-
const
|
|
184
|
+
): Promise<ScanContentFile[]> {
|
|
185
|
+
const files: ScanContentFile[] = [{ skill_id: skillId, file: "SKILL.md", content: skillMdBody }];
|
|
186
186
|
for (const rel of listSupportingFiles(vaultPath, skillId)) {
|
|
187
187
|
const content = await readTextFileOrNull(join(vaultPath, skillId, rel));
|
|
188
|
-
if (content !== null)
|
|
188
|
+
if (content !== null) files.push({ skill_id: skillId, file: rel, content });
|
|
189
189
|
}
|
|
190
|
-
return
|
|
190
|
+
return files;
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
interface
|
|
194
|
-
|
|
193
|
+
interface ResolvedScanFiles {
|
|
194
|
+
files: ScanContentFile[];
|
|
195
195
|
/** skill_ids whose SKILL.md could not be parsed/decoded — must still be counted and
|
|
196
196
|
* flagged, never silently dropped, or a malformed SKILL.md becomes a scan-evasion trick. */
|
|
197
197
|
unparseable: string[];
|
|
@@ -199,32 +199,32 @@ interface ResolvedScanTargets {
|
|
|
199
199
|
|
|
200
200
|
/** Single-skill-dir mode when `rootPath` itself holds a SKILL.md; otherwise treats
|
|
201
201
|
* `rootPath` as a vault root and enumerates every skill dir under it. */
|
|
202
|
-
async function
|
|
202
|
+
async function resolveScanFiles(rootPath: string): Promise<ResolvedScanFiles> {
|
|
203
203
|
if (existsSync(join(rootPath, "SKILL.md"))) {
|
|
204
204
|
const skillId = basename(rootPath);
|
|
205
205
|
const vaultPath = dirname(rootPath);
|
|
206
206
|
const body = await readTextFileOrNull(join(rootPath, "SKILL.md"));
|
|
207
|
-
if (body === null) return {
|
|
208
|
-
return {
|
|
207
|
+
if (body === null) return { files: [], unparseable: [skillId] };
|
|
208
|
+
return { files: await collectSkillFiles(vaultPath, skillId, body), unparseable: [] };
|
|
209
209
|
}
|
|
210
210
|
|
|
211
211
|
const unparseable: string[] = [];
|
|
212
212
|
const skills = await scanVault(rootPath, (skillId) => unparseable.push(skillId));
|
|
213
|
-
const
|
|
213
|
+
const files: ScanContentFile[] = [];
|
|
214
214
|
for (const skill of skills) {
|
|
215
|
-
|
|
215
|
+
files.push(...(await collectSkillFiles(rootPath, skill.skill_id, skill.body)));
|
|
216
216
|
}
|
|
217
|
-
return {
|
|
217
|
+
return { files, unparseable };
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
export async function scanPath(rootPath: string): Promise<ScanResult> {
|
|
221
|
-
const {
|
|
221
|
+
const { files, unparseable } = await resolveScanFiles(rootPath);
|
|
222
222
|
const findings: ScanFinding[] = [];
|
|
223
223
|
const skillIds = new Set<string>();
|
|
224
|
-
for (const
|
|
225
|
-
skillIds.add(
|
|
226
|
-
for (const match of scanContent(
|
|
227
|
-
findings.push({ ...match, skill_id:
|
|
224
|
+
for (const file of files) {
|
|
225
|
+
skillIds.add(file.skill_id);
|
|
226
|
+
for (const match of scanContent(file.content)) {
|
|
227
|
+
findings.push({ ...match, skill_id: file.skill_id, file: file.file });
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
230
|
for (const skillId of unparseable) {
|
package/src/server.ts
CHANGED
|
@@ -16,13 +16,15 @@ import {
|
|
|
16
16
|
resolveSkill,
|
|
17
17
|
} from "./router-core";
|
|
18
18
|
import { closeRuntime, getRuntime, startVaultWatcher } from "./router-core";
|
|
19
|
-
import { getStats, SINCE_PATTERN } from "./stats";
|
|
19
|
+
import { getStats, parseSince, SINCE_PATTERN } from "./stats";
|
|
20
|
+
import { countPrunable, insertAdminAuditRow, pruneAuditBefore, type AdminAuditChange } from "./db";
|
|
21
|
+
import { buildPromotedCases, evalVault, queryPromotableFetches } from "./eval";
|
|
20
22
|
import { SKILL_ID_PATTERN } from "./vault";
|
|
21
23
|
import { MetricsRegistry } from "./metrics";
|
|
22
24
|
import { ReadinessState } from "./readiness";
|
|
23
25
|
import { initializeRuntime } from "./lifecycle";
|
|
24
26
|
import { buildRedactor } from "./redact";
|
|
25
|
-
import {
|
|
27
|
+
import { redactedErrorLog } from "./logger";
|
|
26
28
|
import type { Clients, Config } from "./types";
|
|
27
29
|
import {
|
|
28
30
|
computeHash,
|
|
@@ -41,16 +43,6 @@ export const readinessState = new ReadinessState();
|
|
|
41
43
|
const DEFAULT_MAX_BODY_BYTES = 1_048_576; // 1 MiB
|
|
42
44
|
const DEFAULT_MAX_CONCURRENT_REQUESTS = 100;
|
|
43
45
|
|
|
44
|
-
/** Pairs a fixed log prefix with a redacted error message, for console.error. */
|
|
45
|
-
export function redactedErrorLog(
|
|
46
|
-
prefix: string,
|
|
47
|
-
err: unknown,
|
|
48
|
-
redact: (text: string) => string,
|
|
49
|
-
): [string, string] {
|
|
50
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
51
|
-
return [prefix, redact(msg)];
|
|
52
|
-
}
|
|
53
|
-
|
|
54
46
|
export interface ServerHandle {
|
|
55
47
|
port?: number;
|
|
56
48
|
statsPort?: number;
|
|
@@ -409,7 +401,7 @@ export async function startServer(opts?: {
|
|
|
409
401
|
allowed_origins: [],
|
|
410
402
|
};
|
|
411
403
|
const origin = req.headers.get("origin") || "";
|
|
412
|
-
const allowedOrigins = serverConfig.allowed_origins;
|
|
404
|
+
const allowedOrigins = serverConfig.allowed_origins || [];
|
|
413
405
|
const isAllowed =
|
|
414
406
|
allowedOrigins.includes("*") || allowedOrigins.includes(origin);
|
|
415
407
|
const allowOriginHeader = isAllowed
|
|
@@ -676,7 +668,7 @@ export async function startServer(opts?: {
|
|
|
676
668
|
const auditChanges: AdminAuditChange[] = [];
|
|
677
669
|
for (const [k, v] of Object.entries(body.changes ?? {})) {
|
|
678
670
|
lastResult = await setDottedKey(k, String(v), {
|
|
679
|
-
|
|
671
|
+
contextName: "remote",
|
|
680
672
|
});
|
|
681
673
|
auditChanges.push({
|
|
682
674
|
key: k,
|
|
@@ -700,6 +692,161 @@ export async function startServer(opts?: {
|
|
|
700
692
|
});
|
|
701
693
|
}
|
|
702
694
|
|
|
695
|
+
if (
|
|
696
|
+
req.method === "POST" &&
|
|
697
|
+
url.pathname === "/admin/v1/audit/prune"
|
|
698
|
+
) {
|
|
699
|
+
let body: {
|
|
700
|
+
older_than?: string;
|
|
701
|
+
dry_run?: boolean;
|
|
702
|
+
confirm?: boolean;
|
|
703
|
+
} = {};
|
|
704
|
+
try {
|
|
705
|
+
const text = await req.text();
|
|
706
|
+
if (text.trim()) {
|
|
707
|
+
body = JSON.parse(text);
|
|
708
|
+
}
|
|
709
|
+
} catch {
|
|
710
|
+
return new Response(
|
|
711
|
+
JSON.stringify({
|
|
712
|
+
error: "INVALID_JSON",
|
|
713
|
+
message: "Request body must be valid JSON",
|
|
714
|
+
}),
|
|
715
|
+
{ status: 400, headers },
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const dryRun = body.dry_run ?? false;
|
|
720
|
+
const confirm = body.confirm ?? false;
|
|
721
|
+
if (!dryRun && !confirm) {
|
|
722
|
+
return new Response(
|
|
723
|
+
JSON.stringify({
|
|
724
|
+
error: "CONFIRMATION_REQUIRED",
|
|
725
|
+
message:
|
|
726
|
+
"Non-dry-run audit prune requires confirm: true",
|
|
727
|
+
}),
|
|
728
|
+
{ status: 400, headers },
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const { effective } = await getEffectiveConfig(configPath);
|
|
733
|
+
let cutoff: Date;
|
|
734
|
+
if (body.older_than) {
|
|
735
|
+
try {
|
|
736
|
+
cutoff = parseSince(body.older_than);
|
|
737
|
+
} catch (err: any) {
|
|
738
|
+
return new Response(
|
|
739
|
+
JSON.stringify({
|
|
740
|
+
error: "INVALID_CUTOFF",
|
|
741
|
+
message: err.message,
|
|
742
|
+
}),
|
|
743
|
+
{ status: 400, headers },
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
} else {
|
|
747
|
+
const retentionDays = effective.audit?.retention_days ?? 90;
|
|
748
|
+
if (retentionDays <= 0) {
|
|
749
|
+
return new Response(
|
|
750
|
+
JSON.stringify({
|
|
751
|
+
audit_deleted: 0,
|
|
752
|
+
fetch_deleted: 0,
|
|
753
|
+
admin_audit_deleted: 0,
|
|
754
|
+
dry_run: dryRun,
|
|
755
|
+
cutoff: null,
|
|
756
|
+
}),
|
|
757
|
+
{ status: 200, headers },
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
cutoff = new Date(Date.now() - retentionDays * 86_400_000);
|
|
761
|
+
}
|
|
762
|
+
const cutoffIso = cutoff.toISOString();
|
|
763
|
+
|
|
764
|
+
const { auditDb } = await getRuntime();
|
|
765
|
+
if (dryRun) {
|
|
766
|
+
const counts = countPrunable(auditDb, cutoffIso);
|
|
767
|
+
return new Response(
|
|
768
|
+
JSON.stringify({
|
|
769
|
+
...counts,
|
|
770
|
+
dry_run: true,
|
|
771
|
+
cutoff: cutoffIso,
|
|
772
|
+
}),
|
|
773
|
+
{ status: 200, headers },
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const counts = pruneAuditBefore(auditDb, cutoffIso);
|
|
778
|
+
return new Response(
|
|
779
|
+
JSON.stringify({
|
|
780
|
+
...counts,
|
|
781
|
+
dry_run: false,
|
|
782
|
+
cutoff: cutoffIso,
|
|
783
|
+
}),
|
|
784
|
+
{ status: 200, headers },
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (req.method === "POST" && url.pathname === "/admin/v1/eval") {
|
|
789
|
+
const report = await evalVault();
|
|
790
|
+
return new Response(JSON.stringify(report), {
|
|
791
|
+
status: 200,
|
|
792
|
+
headers,
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (
|
|
797
|
+
req.method === "POST" &&
|
|
798
|
+
url.pathname === "/admin/v1/eval/promote"
|
|
799
|
+
) {
|
|
800
|
+
let body: { since?: string } = {};
|
|
801
|
+
try {
|
|
802
|
+
const text = await req.text();
|
|
803
|
+
if (text.trim()) {
|
|
804
|
+
body = JSON.parse(text);
|
|
805
|
+
}
|
|
806
|
+
} catch {
|
|
807
|
+
return new Response(
|
|
808
|
+
JSON.stringify({
|
|
809
|
+
error: "INVALID_JSON",
|
|
810
|
+
message: "Request body must be valid JSON",
|
|
811
|
+
}),
|
|
812
|
+
{ status: 400, headers },
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (!body.since || typeof body.since !== "string") {
|
|
817
|
+
return new Response(
|
|
818
|
+
JSON.stringify({
|
|
819
|
+
error: "MISSING_SINCE",
|
|
820
|
+
message: "Field 'since' is required",
|
|
821
|
+
}),
|
|
822
|
+
{ status: 400, headers },
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
let sinceDate: Date;
|
|
827
|
+
try {
|
|
828
|
+
sinceDate = parseSince(body.since);
|
|
829
|
+
} catch (err: any) {
|
|
830
|
+
return new Response(
|
|
831
|
+
JSON.stringify({
|
|
832
|
+
error: "INVALID_SINCE",
|
|
833
|
+
message: err.message,
|
|
834
|
+
}),
|
|
835
|
+
{ status: 400, headers },
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
const sinceIso = sinceDate.toISOString();
|
|
839
|
+
|
|
840
|
+
const { auditDb } = await getRuntime();
|
|
841
|
+
const candidates = buildPromotedCases(
|
|
842
|
+
queryPromotableFetches(auditDb, sinceIso),
|
|
843
|
+
);
|
|
844
|
+
return new Response(JSON.stringify({ candidates }), {
|
|
845
|
+
status: 200,
|
|
846
|
+
headers,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
|
|
703
850
|
return new Response("Not Found", { status: 404, headers });
|
|
704
851
|
}
|
|
705
852
|
|