@klhapp/skillmux 1.10.0 → 1.11.1
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 +44 -0
- package/README.md +26 -19
- package/docs/README.md +3 -3
- package/docs/assets/architecture-dark.svg +39 -32
- package/docs/assets/architecture-light.svg +25 -18
- package/docs/cli.md +80 -33
- package/docs/concepts.md +15 -11
- package/docs/configuration.md +6 -4
- package/docs/deployment.md +1 -1
- package/docs/getting-started.md +21 -13
- package/docs/mcp-routing.md +1 -1
- package/docs/skill-management.md +39 -19
- package/docs/troubleshooting.md +4 -4
- package/package.json +1 -1
- package/src/adapters.ts +11 -11
- package/src/cli.ts +255 -72
- package/src/commands/audit.ts +2 -2
- package/src/commands/config.ts +23 -15
- package/src/commands/context.ts +11 -10
- package/src/commands/core.ts +2 -2
- package/src/commands/doctor.ts +31 -10
- package/src/commands/eval.ts +14 -4
- package/src/commands/init.ts +175 -124
- package/src/commands/install.ts +36 -13
- package/src/commands/project.ts +177 -44
- package/src/commands/report.ts +3 -3
- package/src/commands/scan.ts +18 -8
- package/src/commands/shared.ts +7 -14
- package/src/commands/skill.ts +2 -1
- package/src/commands/target.ts +27 -9
- package/src/commands/update.ts +16 -9
- package/src/completions.ts +41 -15
- package/src/config-service.ts +3 -3
- package/src/init-agents.ts +329 -0
- package/src/init-instructions.ts +47 -28
- package/src/mcp-registration.ts +89 -0
- package/src/output.ts +53 -16
- package/src/prompts.ts +75 -20
- package/src/scan.ts +53 -19
- package/src/server.ts +1 -1
- package/src/init-clients.ts +0 -220
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getAgentDefinition,
|
|
3
|
+
SUPPORTED_AGENT_IDS,
|
|
4
|
+
type AgentId,
|
|
5
|
+
type McpRegistrationScope,
|
|
6
|
+
} from "./init-agents";
|
|
7
|
+
|
|
8
|
+
export type { McpRegistrationScope };
|
|
9
|
+
|
|
10
|
+
export const MCP_REGISTRABLE_AGENTS = SUPPORTED_AGENT_IDS.filter(
|
|
11
|
+
(agent) => getAgentDefinition(agent).mcpRegistration !== undefined,
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
export const MCP_PROJECT_REGISTRABLE_AGENTS = MCP_REGISTRABLE_AGENTS.filter(
|
|
15
|
+
(agent) => getAgentDefinition(agent).mcpRegistration!.buildArgs("project") !== undefined,
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
export function isMcpRegistrable(agent: AgentId): boolean {
|
|
19
|
+
return getAgentDefinition(agent).mcpRegistration !== undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface McpRegistrationResult {
|
|
23
|
+
agent: AgentId;
|
|
24
|
+
ok: boolean;
|
|
25
|
+
error?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type SpawnFn = (
|
|
29
|
+
cmd: string[],
|
|
30
|
+
opts?: { cwd?: string },
|
|
31
|
+
) => {
|
|
32
|
+
exited: Promise<number>;
|
|
33
|
+
stderr: ReadableStream<Uint8Array> | number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Runs the agent's own CLI to register skillmux as an MCP server. Never
|
|
38
|
+
* throws — registration failure (tool not installed, command rejected,
|
|
39
|
+
* etc.) is reported in the result, not fatal to the caller's larger flow.
|
|
40
|
+
*/
|
|
41
|
+
export async function registerMcpServer(
|
|
42
|
+
agent: AgentId,
|
|
43
|
+
options: {
|
|
44
|
+
spawn?: SpawnFn;
|
|
45
|
+
scope?: McpRegistrationScope;
|
|
46
|
+
cwd?: string;
|
|
47
|
+
} = {},
|
|
48
|
+
): Promise<McpRegistrationResult> {
|
|
49
|
+
const entry = getAgentDefinition(agent).mcpRegistration;
|
|
50
|
+
const scope = options.scope ?? "user";
|
|
51
|
+
const args = entry?.buildArgs(scope);
|
|
52
|
+
if (!entry || !args) {
|
|
53
|
+
return {
|
|
54
|
+
agent,
|
|
55
|
+
ok: false,
|
|
56
|
+
error: `no ${scope === "project" ? "project-scoped " : ""}MCP registration command known for agent "${agent}"`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const spawn: SpawnFn =
|
|
60
|
+
options.spawn ??
|
|
61
|
+
((cmd, opts) => Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe", cwd: opts?.cwd }));
|
|
62
|
+
try {
|
|
63
|
+
const proc = spawn([entry.command, ...args], { cwd: options.cwd });
|
|
64
|
+
const stderrText =
|
|
65
|
+
typeof proc.stderr === "number"
|
|
66
|
+
? ""
|
|
67
|
+
: await new Response(proc.stderr).text();
|
|
68
|
+
const exitCode = await proc.exited;
|
|
69
|
+
if (exitCode !== 0) {
|
|
70
|
+
return {
|
|
71
|
+
agent,
|
|
72
|
+
ok: false,
|
|
73
|
+
error:
|
|
74
|
+
stderrText.trim() || `${entry.command} exited with code ${exitCode}`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return { agent, ok: true };
|
|
78
|
+
} catch (error) {
|
|
79
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
80
|
+
const notFound = /ENOENT|not found|no such file/i.test(message);
|
|
81
|
+
return {
|
|
82
|
+
agent,
|
|
83
|
+
ok: false,
|
|
84
|
+
error: notFound
|
|
85
|
+
? `"${entry.command}" is not installed or not on PATH`
|
|
86
|
+
: message,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/output.ts
CHANGED
|
@@ -3,6 +3,8 @@ import type { ResolvedContext } from "./context";
|
|
|
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 Use `context` instead. Retained as a compatibility alias; no removal planned. */
|
|
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 Use `context` instead. Retained as a compatibility alias; no removal planned. */
|
|
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 Use `context` instead. Retained as a compatibility alias; no removal planned. */
|
|
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,
|
|
@@ -144,12 +181,12 @@ export function warn(line: string): void {
|
|
|
144
181
|
console.error(yellow(`warning: ${line}`));
|
|
145
182
|
}
|
|
146
183
|
|
|
147
|
-
export function
|
|
184
|
+
export function renderContextBanner(context: ResolvedContext): void {
|
|
148
185
|
if (!isInteractive()) return;
|
|
149
|
-
if (
|
|
150
|
-
console.log(`
|
|
186
|
+
if (context.type === "local") {
|
|
187
|
+
console.log(`Context: local`);
|
|
151
188
|
} else {
|
|
152
|
-
console.log(`
|
|
189
|
+
console.log(`Context: remote (${context.name} -> ${context.server})`);
|
|
153
190
|
}
|
|
154
191
|
}
|
|
155
192
|
|
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/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) {
|
|
@@ -264,3 +264,37 @@ export function scanExitCode(findings: RuleMatch[], failOn: ScanSeverity | undef
|
|
|
264
264
|
const threshold = SEVERITY_RANK[failOn];
|
|
265
265
|
return findings.some((f) => SEVERITY_RANK[f.severity] >= threshold) ? 1 : 0;
|
|
266
266
|
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* A parsed `--fail-on` value. "none" is accepted on the command line to mean
|
|
270
|
+
* "never fail" and is not a finding severity, which is why it is kept out of
|
|
271
|
+
* ScanSeverity itself.
|
|
272
|
+
*
|
|
273
|
+
* Note the ordering: a lower threshold is stricter. `low` fails on low, medium
|
|
274
|
+
* and high; `high` fails only on high.
|
|
275
|
+
*/
|
|
276
|
+
export type FailOnOption = ScanSeverity | "none";
|
|
277
|
+
|
|
278
|
+
export const FAIL_ON_USAGE = "low|medium|high|none";
|
|
279
|
+
|
|
280
|
+
export function parseFailOn(value: string | undefined): FailOnOption {
|
|
281
|
+
if (value !== "low" && value !== "medium" && value !== "high" && value !== "none") {
|
|
282
|
+
throw new Error("--fail-on must be low, medium, high, or none");
|
|
283
|
+
}
|
|
284
|
+
return value;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Resolves the threshold for the commands that fetch remote content into the
|
|
289
|
+
* vault (install, update). They default to blocking on a high-severity finding,
|
|
290
|
+
* because printing a warning and proceeding anyway is the wrong default on the
|
|
291
|
+
* supply-chain path. `--fail-on none` restores the permissive behavior.
|
|
292
|
+
*
|
|
293
|
+
* `skillmux scan` deliberately does NOT use this: it is a reporting command
|
|
294
|
+
* whose exit code the caller opts into, and giving it a default would change
|
|
295
|
+
* the exit code of existing CI pipelines that just run `skillmux scan`.
|
|
296
|
+
*/
|
|
297
|
+
export function resolveMutatingFailOn(failOn: FailOnOption | undefined): ScanSeverity | undefined {
|
|
298
|
+
if (failOn === undefined) return "high";
|
|
299
|
+
return failOn === "none" ? undefined : failOn;
|
|
300
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -668,7 +668,7 @@ export async function startServer(opts?: {
|
|
|
668
668
|
const auditChanges: AdminAuditChange[] = [];
|
|
669
669
|
for (const [k, v] of Object.entries(body.changes ?? {})) {
|
|
670
670
|
lastResult = await setDottedKey(k, String(v), {
|
|
671
|
-
|
|
671
|
+
contextName: "remote",
|
|
672
672
|
});
|
|
673
673
|
auditChanges.push({
|
|
674
674
|
key: k,
|
package/src/init-clients.ts
DELETED
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
|
|
5
|
-
export const SUPPORTED_CLIENT_IDS = [
|
|
6
|
-
"claude-code",
|
|
7
|
-
"codex",
|
|
8
|
-
"gemini-cli",
|
|
9
|
-
"opencode",
|
|
10
|
-
"github-copilot",
|
|
11
|
-
"windsurf",
|
|
12
|
-
"antigravity",
|
|
13
|
-
"goose",
|
|
14
|
-
"hermes",
|
|
15
|
-
"skillmux-mcp",
|
|
16
|
-
] as const;
|
|
17
|
-
|
|
18
|
-
export type ClientId = (typeof SUPPORTED_CLIENT_IDS)[number];
|
|
19
|
-
export type DeliveryMode = "managed-pins" | "full-vault" | "mcp";
|
|
20
|
-
|
|
21
|
-
export interface DetectedClient {
|
|
22
|
-
client: ClientId;
|
|
23
|
-
evidence: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
interface ClientDefinition {
|
|
27
|
-
id: ClientId;
|
|
28
|
-
surfaceId?: "agent-skills" | "claude-code" | "codex" | "antigravity";
|
|
29
|
-
deliveryMode: DeliveryMode;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface PlannedClientSurface {
|
|
33
|
-
id: string;
|
|
34
|
-
targetName: string;
|
|
35
|
-
path: string;
|
|
36
|
-
deliveryMode: "managed-pins";
|
|
37
|
-
clients: ClientId[];
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface ClientSurfacePlan {
|
|
41
|
-
clients: ClientDefinition[];
|
|
42
|
-
surfaces: PlannedClientSurface[];
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
type ReadinessStatus = "ready" | "planned" | "manual" | "not-applicable";
|
|
46
|
-
|
|
47
|
-
export interface ReadinessAxis {
|
|
48
|
-
status: ReadinessStatus;
|
|
49
|
-
detail: string;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export interface ClientReadiness {
|
|
53
|
-
client: ClientId;
|
|
54
|
-
skillSurface: ReadinessAxis;
|
|
55
|
-
mcpRegistration: ReadinessAxis;
|
|
56
|
-
instructionSetup: ReadinessAxis;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export interface ResolvedBuiltInTarget {
|
|
60
|
-
targetName: string;
|
|
61
|
-
path: string;
|
|
62
|
-
warning?: string;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const CLIENTS: Record<ClientId, ClientDefinition> = {
|
|
66
|
-
"claude-code": { id: "claude-code", surfaceId: "claude-code", deliveryMode: "managed-pins" },
|
|
67
|
-
codex: { id: "codex", surfaceId: "codex", deliveryMode: "managed-pins" },
|
|
68
|
-
"gemini-cli": { id: "gemini-cli", surfaceId: "agent-skills", deliveryMode: "managed-pins" },
|
|
69
|
-
opencode: { id: "opencode", surfaceId: "agent-skills", deliveryMode: "managed-pins" },
|
|
70
|
-
"github-copilot": { id: "github-copilot", surfaceId: "agent-skills", deliveryMode: "managed-pins" },
|
|
71
|
-
windsurf: { id: "windsurf", surfaceId: "agent-skills", deliveryMode: "managed-pins" },
|
|
72
|
-
antigravity: { id: "antigravity", surfaceId: "antigravity", deliveryMode: "managed-pins" },
|
|
73
|
-
goose: { id: "goose", deliveryMode: "full-vault" },
|
|
74
|
-
hermes: { id: "hermes", deliveryMode: "full-vault" },
|
|
75
|
-
"skillmux-mcp": { id: "skillmux-mcp", deliveryMode: "mcp" },
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
export function detectInstalledClients(
|
|
79
|
-
options: {
|
|
80
|
-
home?: string;
|
|
81
|
-
codexHome?: string;
|
|
82
|
-
exists?: (path: string) => boolean;
|
|
83
|
-
} = {},
|
|
84
|
-
): DetectedClient[] {
|
|
85
|
-
const home = options.home ?? homedir();
|
|
86
|
-
const codexHome = options.codexHome ?? join(home, ".codex");
|
|
87
|
-
const exists = options.exists ?? existsSync;
|
|
88
|
-
const candidates: Array<[ClientId, string]> = [
|
|
89
|
-
["claude-code", join(home, ".claude")],
|
|
90
|
-
["codex", codexHome],
|
|
91
|
-
["gemini-cli", join(home, ".gemini")],
|
|
92
|
-
["opencode", join(home, ".config", "opencode")],
|
|
93
|
-
["github-copilot", join(home, ".config", "github-copilot")],
|
|
94
|
-
["windsurf", join(home, ".codeium", "windsurf")],
|
|
95
|
-
["goose", join(home, ".config", "goose")],
|
|
96
|
-
["hermes", join(home, ".hermes")],
|
|
97
|
-
];
|
|
98
|
-
return candidates
|
|
99
|
-
.filter(([, evidence]) => exists(evidence))
|
|
100
|
-
.map(([client, evidence]) => ({ client, evidence }));
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function surfacePath(
|
|
104
|
-
surfaceId: NonNullable<ClientDefinition["surfaceId"]>,
|
|
105
|
-
options: { home: string; codexHome?: string },
|
|
106
|
-
): string {
|
|
107
|
-
if (surfaceId === "agent-skills") return join(options.home, ".agents", "skills");
|
|
108
|
-
if (surfaceId === "claude-code") return join(options.home, ".claude", "skills");
|
|
109
|
-
if (surfaceId === "codex") return join(options.codexHome ?? join(options.home, ".codex"), "skills");
|
|
110
|
-
return join(options.home, ".gemini", "config", "skills");
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function resolveBuiltInTarget(
|
|
114
|
-
name: string,
|
|
115
|
-
options: { home?: string; codexHome?: string; customPath?: string } = {},
|
|
116
|
-
): ResolvedBuiltInTarget {
|
|
117
|
-
const home = options.home ?? homedir();
|
|
118
|
-
if (name === "custom") {
|
|
119
|
-
if (!options.customPath) throw new Error("--target custom requires --path <dir>");
|
|
120
|
-
return { targetName: name, path: options.customPath };
|
|
121
|
-
}
|
|
122
|
-
if (name === "agent-skills" || name === "agents") {
|
|
123
|
-
return {
|
|
124
|
-
targetName: name,
|
|
125
|
-
path: surfacePath("agent-skills", { home }),
|
|
126
|
-
...(name === "agents"
|
|
127
|
-
? { warning: "--target agents is deprecated; use --target agent-skills" }
|
|
128
|
-
: {}),
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
if (name === "claude-code" || name === "claude") {
|
|
132
|
-
return {
|
|
133
|
-
targetName: name,
|
|
134
|
-
path: surfacePath("claude-code", { home }),
|
|
135
|
-
...(name === "claude"
|
|
136
|
-
? { warning: "--target claude is deprecated; use --target claude-code" }
|
|
137
|
-
: {}),
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
if (name === "codex") {
|
|
141
|
-
return {
|
|
142
|
-
targetName: name,
|
|
143
|
-
path: surfacePath("codex", { home, codexHome: options.codexHome }),
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
throw new Error(
|
|
147
|
-
`unknown --target "${name}"; supported targets: agent-skills, claude-code, codex, custom`,
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
export function planClientSurfaces(
|
|
152
|
-
requestedClients: readonly string[],
|
|
153
|
-
options: { home?: string; codexHome?: string } = {},
|
|
154
|
-
): ClientSurfacePlan {
|
|
155
|
-
const clients = [...new Set(requestedClients)].map((id) => {
|
|
156
|
-
if (!SUPPORTED_CLIENT_IDS.includes(id as ClientId)) {
|
|
157
|
-
throw new Error(
|
|
158
|
-
`unsupported client "${id}"; supported clients: ${SUPPORTED_CLIENT_IDS.join(", ")}`,
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
return CLIENTS[id as ClientId];
|
|
162
|
-
});
|
|
163
|
-
const home = options.home ?? homedir();
|
|
164
|
-
const surfaces = new Map<string, PlannedClientSurface>();
|
|
165
|
-
|
|
166
|
-
for (const client of clients) {
|
|
167
|
-
if (!client.surfaceId) continue;
|
|
168
|
-
const path = surfacePath(client.surfaceId, { home, codexHome: options.codexHome });
|
|
169
|
-
const existing = surfaces.get(path);
|
|
170
|
-
if (existing) {
|
|
171
|
-
if (!existing.clients.includes(client.id)) existing.clients.push(client.id);
|
|
172
|
-
continue;
|
|
173
|
-
}
|
|
174
|
-
surfaces.set(path, {
|
|
175
|
-
id: client.surfaceId,
|
|
176
|
-
targetName: client.surfaceId,
|
|
177
|
-
path,
|
|
178
|
-
deliveryMode: "managed-pins",
|
|
179
|
-
clients: [client.id],
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
return { clients, surfaces: [...surfaces.values()] };
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
export function assessClientReadiness(
|
|
187
|
-
plan: ClientSurfacePlan,
|
|
188
|
-
instructionReadiness: Partial<Record<ClientId, ReadinessAxis>> = {},
|
|
189
|
-
): ClientReadiness[] {
|
|
190
|
-
return plan.clients.map((client) => {
|
|
191
|
-
const surface = plan.surfaces.find((candidate) => candidate.clients.includes(client.id));
|
|
192
|
-
let skillSurface: ReadinessAxis;
|
|
193
|
-
if (surface) {
|
|
194
|
-
skillSurface = { status: "planned", detail: surface.path };
|
|
195
|
-
} else if (client.id === "goose") {
|
|
196
|
-
skillSurface = { status: "manual", detail: "configure the full vault in Goose" };
|
|
197
|
-
} else if (client.id === "hermes") {
|
|
198
|
-
skillSurface = { status: "manual", detail: "configure the full vault in Hermes external_dirs" };
|
|
199
|
-
} else {
|
|
200
|
-
skillSurface = {
|
|
201
|
-
status: "not-applicable",
|
|
202
|
-
detail: "skills resolve through Skillmux MCP",
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const mcpRegistration: ReadinessAxis = client.deliveryMode === "mcp"
|
|
207
|
-
? { status: "manual", detail: "register the Skillmux MCP server" }
|
|
208
|
-
: { status: "not-applicable", detail: "native skill loading" };
|
|
209
|
-
|
|
210
|
-
return {
|
|
211
|
-
client: client.id,
|
|
212
|
-
skillSurface,
|
|
213
|
-
mcpRegistration,
|
|
214
|
-
instructionSetup: instructionReadiness[client.id] ?? {
|
|
215
|
-
status: "manual",
|
|
216
|
-
detail: "instruction adapter not applied",
|
|
217
|
-
},
|
|
218
|
-
};
|
|
219
|
-
});
|
|
220
|
-
}
|