@klhapp/skillmux 1.10.0 → 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/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
- console.log(`\n${question}`);
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
- console.log(` ${index + 1}. [${checked}] ${option.label}${detail}`);
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 readline = createInterface({ input: process.stdin, output: process.stdout });
50
- try {
51
- const suffix = defaults ? ` [${defaults}]` : "";
52
- const answer = await readline.question(`Select numbers, comma-separated${suffix}: `);
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(question: string, defaultValue = ""): Promise<string> {
61
- const readline = createInterface({ input: process.stdin, output: process.stdout });
62
- try {
63
- const suffix = defaultValue ? ` [${defaultValue}]` : "";
64
- const answer = (await readline.question(`${question}${suffix}: `)).trim();
65
- return answer || defaultValue;
66
- } finally {
67
- readline.close();
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 ScanContentTarget {
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 collectSkillTargets(
180
+ async function collectSkillFiles(
181
181
  vaultPath: string,
182
182
  skillId: string,
183
183
  skillMdBody: string,
184
- ): Promise<ScanContentTarget[]> {
185
- const targets: ScanContentTarget[] = [{ skill_id: skillId, file: "SKILL.md", content: skillMdBody }];
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) targets.push({ skill_id: skillId, file: rel, content });
188
+ if (content !== null) files.push({ skill_id: skillId, file: rel, content });
189
189
  }
190
- return targets;
190
+ return files;
191
191
  }
192
192
 
193
- interface ResolvedScanTargets {
194
- targets: ScanContentTarget[];
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 resolveScanTargets(rootPath: string): Promise<ResolvedScanTargets> {
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 { targets: [], unparseable: [skillId] };
208
- return { targets: await collectSkillTargets(vaultPath, skillId, body), unparseable: [] };
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 targets: ScanContentTarget[] = [];
213
+ const files: ScanContentFile[] = [];
214
214
  for (const skill of skills) {
215
- targets.push(...(await collectSkillTargets(rootPath, skill.skill_id, skill.body)));
215
+ files.push(...(await collectSkillFiles(rootPath, skill.skill_id, skill.body)));
216
216
  }
217
- return { targets, unparseable };
217
+ return { files, unparseable };
218
218
  }
219
219
 
220
220
  export async function scanPath(rootPath: string): Promise<ScanResult> {
221
- const { targets, unparseable } = await resolveScanTargets(rootPath);
221
+ const { files, unparseable } = await resolveScanFiles(rootPath);
222
222
  const findings: ScanFinding[] = [];
223
223
  const skillIds = new Set<string>();
224
- for (const target of targets) {
225
- skillIds.add(target.skill_id);
226
- for (const match of scanContent(target.content)) {
227
- findings.push({ ...match, skill_id: target.skill_id, file: target.file });
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
@@ -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
- targetName: "remote",
671
+ contextName: "remote",
672
672
  });
673
673
  auditChanges.push({
674
674
  key: k,
@@ -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
- }