facult 1.0.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/LICENSE +21 -0
- package/README.md +383 -0
- package/bin/facult.cjs +302 -0
- package/package.json +78 -0
- package/src/adapters/claude-cli.ts +18 -0
- package/src/adapters/claude-desktop.ts +15 -0
- package/src/adapters/clawdbot.ts +18 -0
- package/src/adapters/codex.ts +19 -0
- package/src/adapters/cursor.ts +18 -0
- package/src/adapters/index.ts +69 -0
- package/src/adapters/mcp.ts +270 -0
- package/src/adapters/reference.ts +9 -0
- package/src/adapters/skills.ts +47 -0
- package/src/adapters/types.ts +42 -0
- package/src/adapters/version.ts +18 -0
- package/src/audit/agent.ts +1071 -0
- package/src/audit/index.ts +74 -0
- package/src/audit/static.ts +1130 -0
- package/src/audit/tui.ts +704 -0
- package/src/audit/types.ts +68 -0
- package/src/audit/update-index.ts +115 -0
- package/src/conflicts.ts +135 -0
- package/src/consolidate-conflict-action.ts +57 -0
- package/src/consolidate.ts +1637 -0
- package/src/enable-disable.ts +349 -0
- package/src/index-builder.ts +562 -0
- package/src/index.ts +589 -0
- package/src/manage.ts +894 -0
- package/src/migrate.ts +272 -0
- package/src/paths.ts +238 -0
- package/src/quarantine.ts +217 -0
- package/src/query.ts +186 -0
- package/src/remote-manifest-integrity.ts +367 -0
- package/src/remote-providers.ts +905 -0
- package/src/remote-source-policy.ts +237 -0
- package/src/remote-sources.ts +162 -0
- package/src/remote-types.ts +136 -0
- package/src/remote.ts +1970 -0
- package/src/scan.ts +2427 -0
- package/src/schema.ts +39 -0
- package/src/self-update.ts +408 -0
- package/src/snippets-cli.ts +293 -0
- package/src/snippets.ts +706 -0
- package/src/source-trust.ts +203 -0
- package/src/trust-list.ts +232 -0
- package/src/trust.ts +170 -0
- package/src/tui.ts +118 -0
- package/src/util/codex-toml.ts +126 -0
- package/src/util/json.ts +32 -0
- package/src/util/skills.ts +55 -0
|
@@ -0,0 +1,1637 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, readdir, rename, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { homedir, tmpdir } from "node:os";
|
|
3
|
+
import { basename, dirname, extname, join } from "node:path";
|
|
4
|
+
import { confirm, intro, isCancel, note, outro, select } from "@clack/prompts";
|
|
5
|
+
import {
|
|
6
|
+
type AutoDecision,
|
|
7
|
+
type AutoMode,
|
|
8
|
+
contentHash,
|
|
9
|
+
decideAuto,
|
|
10
|
+
mcpServerHash,
|
|
11
|
+
normalizeJson,
|
|
12
|
+
normalizeText,
|
|
13
|
+
} from "./conflicts";
|
|
14
|
+
import { resolveConflictAction } from "./consolidate-conflict-action";
|
|
15
|
+
import { facultRootDir } from "./paths";
|
|
16
|
+
import { type McpConfig, type ScanResult, scan } from "./scan";
|
|
17
|
+
import type {
|
|
18
|
+
CanonicalMcpRegistry,
|
|
19
|
+
CanonicalMcpServer,
|
|
20
|
+
McpTransport,
|
|
21
|
+
} from "./schema";
|
|
22
|
+
import { computeSkillOccurrences, lastModified } from "./util/skills";
|
|
23
|
+
|
|
24
|
+
interface ConsolidatedEntry {
|
|
25
|
+
source: string;
|
|
26
|
+
target: string;
|
|
27
|
+
consolidatedAt: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ConsolidatedState {
|
|
31
|
+
version: 1;
|
|
32
|
+
skills: Record<string, ConsolidatedEntry>;
|
|
33
|
+
mcpServers: Record<string, ConsolidatedEntry>;
|
|
34
|
+
mcpConfigs: Record<string, ConsolidatedEntry>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface SkillLocation {
|
|
38
|
+
entryDir: string;
|
|
39
|
+
sourceId: string;
|
|
40
|
+
modified: Date | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface McpServerLocation {
|
|
44
|
+
configPath: string;
|
|
45
|
+
sourceId: string;
|
|
46
|
+
modified: Date | null;
|
|
47
|
+
serverName: string;
|
|
48
|
+
serverConfig: unknown;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface McpConfigLocation {
|
|
52
|
+
configPath: string;
|
|
53
|
+
sourceId: string;
|
|
54
|
+
modified: Date | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type McpConsolidatedObject = CanonicalMcpRegistry;
|
|
58
|
+
|
|
59
|
+
const CONSOLIDATED_VERSION = 1;
|
|
60
|
+
|
|
61
|
+
function homePath(home: string, ...parts: string[]): string {
|
|
62
|
+
return join(home, ...parts);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatDate(d: Date | null): string {
|
|
66
|
+
if (!d) {
|
|
67
|
+
return "unknown";
|
|
68
|
+
}
|
|
69
|
+
return d.toISOString().replace("T", " ").replace("Z", "");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function ensureDir(p: string) {
|
|
73
|
+
await mkdir(p, { recursive: true });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function fileExists(p: string): Promise<boolean> {
|
|
77
|
+
try {
|
|
78
|
+
await stat(p);
|
|
79
|
+
return true;
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function archiveExisting(p: string): Promise<string | null> {
|
|
86
|
+
if (!(await fileExists(p))) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
90
|
+
const backup = `${p}.bak.${stamp}`;
|
|
91
|
+
await rename(p, backup);
|
|
92
|
+
return backup;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function readTextSafe(p: string): Promise<string | null> {
|
|
96
|
+
try {
|
|
97
|
+
return await Bun.file(p).text();
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizedTextHash(text: string | null): string | null {
|
|
104
|
+
if (text === null) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
return contentHash(normalizeText(text));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizedJsonHash(text: string | null): string | null {
|
|
111
|
+
if (text === null) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
return contentHash(normalizeJson(text));
|
|
116
|
+
} catch {
|
|
117
|
+
return contentHash(normalizeText(text));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function nextAvailableName({
|
|
122
|
+
base,
|
|
123
|
+
suffix,
|
|
124
|
+
exists,
|
|
125
|
+
}: {
|
|
126
|
+
base: string;
|
|
127
|
+
suffix: string;
|
|
128
|
+
exists: (candidate: string) => Promise<boolean> | boolean;
|
|
129
|
+
}): Promise<string> {
|
|
130
|
+
let candidate = `${base}-${suffix}`;
|
|
131
|
+
let index = 2;
|
|
132
|
+
while (await exists(candidate)) {
|
|
133
|
+
candidate = `${base}-${suffix}-${index}`;
|
|
134
|
+
index += 1;
|
|
135
|
+
}
|
|
136
|
+
return candidate;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function copyDir(src: string, dest: string, force: boolean) {
|
|
140
|
+
if (force) {
|
|
141
|
+
await rm(dest, { recursive: true, force: true });
|
|
142
|
+
}
|
|
143
|
+
await ensureDir(dest);
|
|
144
|
+
const entries = await readdir(src, { withFileTypes: true });
|
|
145
|
+
for (const entry of entries) {
|
|
146
|
+
const from = join(src, entry.name);
|
|
147
|
+
const to = join(dest, entry.name);
|
|
148
|
+
if (entry.isDirectory()) {
|
|
149
|
+
await copyDir(from, to, force);
|
|
150
|
+
} else if (entry.isFile()) {
|
|
151
|
+
await Bun.write(to, Bun.file(from));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function diffPreview({
|
|
157
|
+
currentLabel,
|
|
158
|
+
incomingLabel,
|
|
159
|
+
currentContent,
|
|
160
|
+
incomingContent,
|
|
161
|
+
}: {
|
|
162
|
+
currentLabel: string;
|
|
163
|
+
incomingLabel: string;
|
|
164
|
+
currentContent: string;
|
|
165
|
+
incomingContent: string;
|
|
166
|
+
}) {
|
|
167
|
+
const dir = await mkdtemp(join(tmpdir(), "facult-diff-"));
|
|
168
|
+
const currentPath = join(dir, "current");
|
|
169
|
+
const incomingPath = join(dir, "incoming");
|
|
170
|
+
await Bun.write(currentPath, currentContent);
|
|
171
|
+
await Bun.write(incomingPath, incomingContent);
|
|
172
|
+
|
|
173
|
+
const { spawnSync } = await import("node:child_process");
|
|
174
|
+
const res = spawnSync("diff", ["-u", currentPath, incomingPath], {
|
|
175
|
+
encoding: "utf8",
|
|
176
|
+
});
|
|
177
|
+
const raw = res.stdout?.trim() || "(no diff)";
|
|
178
|
+
const lines = raw.split("\n");
|
|
179
|
+
const maxLines = 200;
|
|
180
|
+
const preview = lines.slice(0, maxLines).join("\n");
|
|
181
|
+
const suffix =
|
|
182
|
+
lines.length > maxLines
|
|
183
|
+
? `\n... (${lines.length - maxLines} more lines)`
|
|
184
|
+
: "";
|
|
185
|
+
note(`${preview}${suffix}`, `${currentLabel} ↔ ${incomingLabel}`);
|
|
186
|
+
await rm(dir, { recursive: true, force: true });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function loadState(home: string): Promise<ConsolidatedState> {
|
|
190
|
+
const p = homePath(home, ".facult", "consolidated.json");
|
|
191
|
+
if (!(await fileExists(p))) {
|
|
192
|
+
return {
|
|
193
|
+
version: CONSOLIDATED_VERSION,
|
|
194
|
+
skills: {},
|
|
195
|
+
mcpServers: {},
|
|
196
|
+
mcpConfigs: {},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const txt = await Bun.file(p).text();
|
|
201
|
+
const data = JSON.parse(txt) as {
|
|
202
|
+
skills?: Record<string, ConsolidatedEntry>;
|
|
203
|
+
mcpServers?: Record<string, ConsolidatedEntry>;
|
|
204
|
+
mcpConfigs?: Record<string, ConsolidatedEntry>;
|
|
205
|
+
} | null;
|
|
206
|
+
return {
|
|
207
|
+
version: CONSOLIDATED_VERSION,
|
|
208
|
+
skills: data?.skills ?? {},
|
|
209
|
+
mcpServers: data?.mcpServers ?? {},
|
|
210
|
+
mcpConfigs: data?.mcpConfigs ?? {},
|
|
211
|
+
};
|
|
212
|
+
} catch {
|
|
213
|
+
return {
|
|
214
|
+
version: CONSOLIDATED_VERSION,
|
|
215
|
+
skills: {},
|
|
216
|
+
mcpServers: {},
|
|
217
|
+
mcpConfigs: {},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function saveState(home: string, state: ConsolidatedState) {
|
|
223
|
+
const stateDir = homePath(home, ".facult");
|
|
224
|
+
await ensureDir(stateDir);
|
|
225
|
+
const outPath = join(stateDir, "consolidated.json");
|
|
226
|
+
await Bun.write(outPath, `${JSON.stringify(state, null, 2)}\n`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function parseLocation(loc: string): { sourceId: string; entryDir: string } {
|
|
230
|
+
const i = loc.indexOf(":");
|
|
231
|
+
if (i < 0) {
|
|
232
|
+
return { sourceId: "", entryDir: loc };
|
|
233
|
+
}
|
|
234
|
+
return { sourceId: loc.slice(0, i), entryDir: loc.slice(i + 1) };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function buildSkillLocations(
|
|
238
|
+
res: ScanResult
|
|
239
|
+
): Promise<Map<string, SkillLocation[]>> {
|
|
240
|
+
const map = new Map<string, SkillLocation[]>();
|
|
241
|
+
const occurrences = computeSkillOccurrences(res);
|
|
242
|
+
for (const skill of occurrences) {
|
|
243
|
+
const locs: SkillLocation[] = [];
|
|
244
|
+
for (const loc of skill.locations) {
|
|
245
|
+
const { sourceId, entryDir } = parseLocation(loc);
|
|
246
|
+
locs.push({
|
|
247
|
+
entryDir,
|
|
248
|
+
sourceId,
|
|
249
|
+
modified: await lastModified(entryDir),
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
map.set(skill.name, locs);
|
|
253
|
+
}
|
|
254
|
+
return map;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function collectMcpConfigs(
|
|
258
|
+
res: ScanResult
|
|
259
|
+
): { config: McpConfig; sourceId: string }[] {
|
|
260
|
+
const out: { config: McpConfig; sourceId: string }[] = [];
|
|
261
|
+
for (const src of res.sources) {
|
|
262
|
+
for (const cfg of src.mcp.configs) {
|
|
263
|
+
out.push({ config: cfg, sourceId: src.id });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function readMcpServers(
|
|
270
|
+
p: string
|
|
271
|
+
): Promise<Record<string, unknown> | null> {
|
|
272
|
+
try {
|
|
273
|
+
const txt = await Bun.file(p).text();
|
|
274
|
+
const obj = JSON.parse(txt) as Record<string, unknown> | null;
|
|
275
|
+
const servers =
|
|
276
|
+
(obj?.mcpServers as Record<string, unknown> | undefined) ??
|
|
277
|
+
((obj?.mcp as Record<string, unknown> | undefined)?.servers as
|
|
278
|
+
| Record<string, unknown>
|
|
279
|
+
| undefined) ??
|
|
280
|
+
(obj?.servers as Record<string, unknown> | undefined);
|
|
281
|
+
if (servers && typeof servers === "object" && !Array.isArray(servers)) {
|
|
282
|
+
return servers;
|
|
283
|
+
}
|
|
284
|
+
} catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function buildMcpLocations(res: ScanResult): Promise<{
|
|
291
|
+
servers: Map<string, McpServerLocation[]>;
|
|
292
|
+
configs: McpConfigLocation[];
|
|
293
|
+
}> {
|
|
294
|
+
const servers = new Map<string, McpServerLocation[]>();
|
|
295
|
+
const configs: McpConfigLocation[] = [];
|
|
296
|
+
const items = collectMcpConfigs(res);
|
|
297
|
+
for (const item of items) {
|
|
298
|
+
const configPath = item.config.path;
|
|
299
|
+
const modified = await lastModified(configPath);
|
|
300
|
+
const serverDefs = await readMcpServers(configPath);
|
|
301
|
+
if (serverDefs) {
|
|
302
|
+
for (const [serverName, serverConfig] of Object.entries(serverDefs)) {
|
|
303
|
+
const list = servers.get(serverName) ?? [];
|
|
304
|
+
list.push({
|
|
305
|
+
configPath,
|
|
306
|
+
sourceId: item.sourceId,
|
|
307
|
+
modified,
|
|
308
|
+
serverName,
|
|
309
|
+
serverConfig,
|
|
310
|
+
});
|
|
311
|
+
servers.set(serverName, list);
|
|
312
|
+
}
|
|
313
|
+
} else {
|
|
314
|
+
configs.push({ configPath, sourceId: item.sourceId, modified });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return { servers, configs };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function locationLabel(loc: {
|
|
321
|
+
sourceId: string;
|
|
322
|
+
modified: Date | null;
|
|
323
|
+
entryDir?: string;
|
|
324
|
+
configPath?: string;
|
|
325
|
+
}) {
|
|
326
|
+
const p = loc.entryDir ?? loc.configPath ?? "";
|
|
327
|
+
return `${p} (${loc.sourceId}, modified ${formatDate(loc.modified)})`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function skillChoiceValue(loc: SkillLocation) {
|
|
331
|
+
return `${loc.sourceId}:${loc.entryDir}`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function mcpChoiceValue(loc: { sourceId: string; configPath: string }) {
|
|
335
|
+
return `${loc.sourceId}:${loc.configPath}`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function chooseByAutoMode<T extends { modified: Date | null }>(
|
|
339
|
+
locs: T[],
|
|
340
|
+
autoMode: AutoMode
|
|
341
|
+
): T | null {
|
|
342
|
+
const first = locs[0];
|
|
343
|
+
if (!first) {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
let chosen = first;
|
|
348
|
+
for (const loc of locs.slice(1)) {
|
|
349
|
+
const decision = decideAuto(
|
|
350
|
+
autoMode,
|
|
351
|
+
{ modified: chosen.modified },
|
|
352
|
+
{ modified: loc.modified }
|
|
353
|
+
);
|
|
354
|
+
if (decision === "keep-incoming") {
|
|
355
|
+
chosen = loc;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return chosen;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function promptViewSkillContents(locs: SkillLocation[]) {
|
|
362
|
+
const choice = await select({
|
|
363
|
+
message: "View SKILL.md from which location?",
|
|
364
|
+
options: locs.map((loc) => ({
|
|
365
|
+
value: skillChoiceValue(loc),
|
|
366
|
+
label: locationLabel(loc),
|
|
367
|
+
})),
|
|
368
|
+
});
|
|
369
|
+
if (isCancel(choice)) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const selected = locs.find((loc) => skillChoiceValue(loc) === choice);
|
|
373
|
+
if (!selected) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const file = join(selected.entryDir, "SKILL.md");
|
|
377
|
+
try {
|
|
378
|
+
const content = await Bun.file(file).text();
|
|
379
|
+
note(content.trim() || "(empty)", `SKILL.md — ${choice}`);
|
|
380
|
+
} catch (e: unknown) {
|
|
381
|
+
const err = e as { message?: string } | null;
|
|
382
|
+
note(`Unable to read ${file}: ${String(err?.message ?? e)}`, "Error");
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function promptViewMcpContents(
|
|
387
|
+
locs: { configPath: string; sourceId?: string }[]
|
|
388
|
+
) {
|
|
389
|
+
const choice = await select({
|
|
390
|
+
message: "View MCP JSON from which config?",
|
|
391
|
+
options: locs.map((loc) => ({
|
|
392
|
+
value: mcpChoiceValue({
|
|
393
|
+
sourceId: loc.sourceId ?? "",
|
|
394
|
+
configPath: loc.configPath,
|
|
395
|
+
}),
|
|
396
|
+
label: loc.configPath,
|
|
397
|
+
})),
|
|
398
|
+
});
|
|
399
|
+
if (isCancel(choice)) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const selected = locs.find(
|
|
403
|
+
(loc) =>
|
|
404
|
+
mcpChoiceValue({
|
|
405
|
+
sourceId: loc.sourceId ?? "",
|
|
406
|
+
configPath: loc.configPath,
|
|
407
|
+
}) === choice
|
|
408
|
+
);
|
|
409
|
+
if (!selected) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
try {
|
|
413
|
+
const content = await Bun.file(selected.configPath).text();
|
|
414
|
+
note(content.trim() || "(empty)", `MCP config — ${selected.configPath}`);
|
|
415
|
+
} catch (e: unknown) {
|
|
416
|
+
const err = e as { message?: string } | null;
|
|
417
|
+
note(
|
|
418
|
+
`Unable to read ${selected.configPath}: ${String(err?.message ?? e)}`,
|
|
419
|
+
"Error"
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function promptConflictResolution({
|
|
425
|
+
title,
|
|
426
|
+
currentLabel,
|
|
427
|
+
incomingLabel,
|
|
428
|
+
currentContent,
|
|
429
|
+
incomingContent,
|
|
430
|
+
}: {
|
|
431
|
+
title: string;
|
|
432
|
+
currentLabel: string;
|
|
433
|
+
incomingLabel: string;
|
|
434
|
+
currentContent: string;
|
|
435
|
+
incomingContent: string;
|
|
436
|
+
}): Promise<AutoDecision | "skip"> {
|
|
437
|
+
while (true) {
|
|
438
|
+
const selection = await select({
|
|
439
|
+
message: `Conflict detected: ${title}`,
|
|
440
|
+
options: [
|
|
441
|
+
{ value: "keep-current", label: `Keep current (${currentLabel})` },
|
|
442
|
+
{ value: "keep-incoming", label: `Keep incoming (${incomingLabel})` },
|
|
443
|
+
{ value: "keep-both", label: "Keep both" },
|
|
444
|
+
{ value: "diff", label: "View diff" },
|
|
445
|
+
{ value: "skip", label: "Cancel" },
|
|
446
|
+
],
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
if (isCancel(selection) || selection === "skip") {
|
|
450
|
+
return "skip";
|
|
451
|
+
}
|
|
452
|
+
if (selection === "diff") {
|
|
453
|
+
await diffPreview({
|
|
454
|
+
currentLabel,
|
|
455
|
+
incomingLabel,
|
|
456
|
+
currentContent,
|
|
457
|
+
incomingContent,
|
|
458
|
+
});
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
if (selection === "keep-current") {
|
|
462
|
+
return "keep-current";
|
|
463
|
+
}
|
|
464
|
+
if (selection === "keep-incoming") {
|
|
465
|
+
return "keep-incoming";
|
|
466
|
+
}
|
|
467
|
+
if (selection === "keep-both") {
|
|
468
|
+
return "keep-both";
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function copySkillAndUpdateState({
|
|
474
|
+
name,
|
|
475
|
+
sourceDir,
|
|
476
|
+
dest,
|
|
477
|
+
state,
|
|
478
|
+
force,
|
|
479
|
+
}: {
|
|
480
|
+
name: string;
|
|
481
|
+
sourceDir: string;
|
|
482
|
+
dest: string;
|
|
483
|
+
state: ConsolidatedState;
|
|
484
|
+
force: boolean;
|
|
485
|
+
}): Promise<boolean> {
|
|
486
|
+
try {
|
|
487
|
+
await copyDir(sourceDir, dest, force);
|
|
488
|
+
state.skills[name] = {
|
|
489
|
+
source: sourceDir,
|
|
490
|
+
target: dest,
|
|
491
|
+
consolidatedAt: new Date().toISOString(),
|
|
492
|
+
};
|
|
493
|
+
note(`Copied to ${dest}`, `Skill: ${name}`);
|
|
494
|
+
return true;
|
|
495
|
+
} catch (e: unknown) {
|
|
496
|
+
const err = e as { message?: string } | null;
|
|
497
|
+
note(`Copy failed: ${String(err?.message ?? e)}`, `Skill: ${name}`);
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function resolveSkillConflictAndCopy({
|
|
503
|
+
name,
|
|
504
|
+
sourceDir,
|
|
505
|
+
dest,
|
|
506
|
+
state,
|
|
507
|
+
force,
|
|
508
|
+
autoMode,
|
|
509
|
+
incomingModified,
|
|
510
|
+
}: {
|
|
511
|
+
name: string;
|
|
512
|
+
sourceDir: string;
|
|
513
|
+
dest: string;
|
|
514
|
+
state: ConsolidatedState;
|
|
515
|
+
force: boolean;
|
|
516
|
+
autoMode: AutoMode | undefined;
|
|
517
|
+
incomingModified: Date | null;
|
|
518
|
+
}): Promise<void> {
|
|
519
|
+
if (!(await fileExists(dest))) {
|
|
520
|
+
await copySkillAndUpdateState({
|
|
521
|
+
name,
|
|
522
|
+
sourceDir,
|
|
523
|
+
dest,
|
|
524
|
+
state,
|
|
525
|
+
force,
|
|
526
|
+
});
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const currentContent = await readTextSafe(join(dest, "SKILL.md"));
|
|
531
|
+
const incomingContent = await readTextSafe(join(sourceDir, "SKILL.md"));
|
|
532
|
+
const currentHash = normalizedTextHash(currentContent);
|
|
533
|
+
const incomingHash = normalizedTextHash(incomingContent);
|
|
534
|
+
|
|
535
|
+
const decision = await resolveConflictAction({
|
|
536
|
+
title: `Skill ${name}`,
|
|
537
|
+
currentLabel: dest,
|
|
538
|
+
incomingLabel: sourceDir,
|
|
539
|
+
currentContent,
|
|
540
|
+
incomingContent,
|
|
541
|
+
currentHash,
|
|
542
|
+
incomingHash,
|
|
543
|
+
autoMode,
|
|
544
|
+
currentMeta: { modified: await lastModified(dest) },
|
|
545
|
+
incomingMeta: { modified: incomingModified },
|
|
546
|
+
promptConflictResolution,
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
if (decision === "skip") {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (decision === "keep-current") {
|
|
554
|
+
state.skills[name] = {
|
|
555
|
+
source: dest,
|
|
556
|
+
target: dest,
|
|
557
|
+
consolidatedAt: new Date().toISOString(),
|
|
558
|
+
};
|
|
559
|
+
note(`Kept existing skill at ${dest}`, `Skill: ${name}`);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (decision === "keep-incoming") {
|
|
564
|
+
const backup = await archiveExisting(dest);
|
|
565
|
+
if (backup) {
|
|
566
|
+
note(`Archived existing skill to ${backup}`, `Skill: ${name}`);
|
|
567
|
+
}
|
|
568
|
+
await copySkillAndUpdateState({
|
|
569
|
+
name,
|
|
570
|
+
sourceDir,
|
|
571
|
+
dest,
|
|
572
|
+
state,
|
|
573
|
+
force: true,
|
|
574
|
+
});
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const parent = dirname(dest);
|
|
579
|
+
const newName = await nextAvailableName({
|
|
580
|
+
base: name,
|
|
581
|
+
suffix: "incoming",
|
|
582
|
+
exists: async (candidate) => fileExists(join(parent, candidate)),
|
|
583
|
+
});
|
|
584
|
+
const newDest = join(parent, newName);
|
|
585
|
+
await copySkillAndUpdateState({
|
|
586
|
+
name: newName,
|
|
587
|
+
sourceDir,
|
|
588
|
+
dest: newDest,
|
|
589
|
+
state,
|
|
590
|
+
force,
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
if (!state.skills[name]) {
|
|
594
|
+
state.skills[name] = {
|
|
595
|
+
source: dest,
|
|
596
|
+
target: dest,
|
|
597
|
+
consolidatedAt: new Date().toISOString(),
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
note(`Kept both skills: ${name} + ${newName}`, `Skill: ${name}`);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async function handleSingleSkillLocation({
|
|
604
|
+
name,
|
|
605
|
+
loc,
|
|
606
|
+
dest,
|
|
607
|
+
state,
|
|
608
|
+
force,
|
|
609
|
+
autoMode,
|
|
610
|
+
}: {
|
|
611
|
+
name: string;
|
|
612
|
+
loc: SkillLocation;
|
|
613
|
+
dest: string;
|
|
614
|
+
state: ConsolidatedState;
|
|
615
|
+
force: boolean;
|
|
616
|
+
autoMode: AutoMode | undefined;
|
|
617
|
+
}): Promise<void> {
|
|
618
|
+
if (!autoMode) {
|
|
619
|
+
const ok = await confirm({
|
|
620
|
+
message: `Copy ${name} from ${loc.entryDir} (modified ${formatDate(loc.modified)})?`,
|
|
621
|
+
});
|
|
622
|
+
if (isCancel(ok) || !ok) {
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
await resolveSkillConflictAndCopy({
|
|
627
|
+
name,
|
|
628
|
+
sourceDir: loc.entryDir,
|
|
629
|
+
dest,
|
|
630
|
+
state,
|
|
631
|
+
force,
|
|
632
|
+
autoMode,
|
|
633
|
+
incomingModified: loc.modified,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function handleMultipleSkillLocations({
|
|
638
|
+
name,
|
|
639
|
+
locs,
|
|
640
|
+
dest,
|
|
641
|
+
state,
|
|
642
|
+
force,
|
|
643
|
+
autoMode,
|
|
644
|
+
}: {
|
|
645
|
+
name: string;
|
|
646
|
+
locs: SkillLocation[];
|
|
647
|
+
dest: string;
|
|
648
|
+
state: ConsolidatedState;
|
|
649
|
+
force: boolean;
|
|
650
|
+
autoMode: AutoMode | undefined;
|
|
651
|
+
}): Promise<void> {
|
|
652
|
+
if (autoMode) {
|
|
653
|
+
const chosen = chooseByAutoMode(locs, autoMode);
|
|
654
|
+
if (!chosen) {
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
await resolveSkillConflictAndCopy({
|
|
658
|
+
name,
|
|
659
|
+
sourceDir: chosen.entryDir,
|
|
660
|
+
dest,
|
|
661
|
+
state,
|
|
662
|
+
force,
|
|
663
|
+
autoMode,
|
|
664
|
+
incomingModified: chosen.modified,
|
|
665
|
+
});
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
while (true) {
|
|
670
|
+
const selection = await select({
|
|
671
|
+
message: `Choose source for skill "${name}"`,
|
|
672
|
+
options: [
|
|
673
|
+
...locs.map((loc) => ({
|
|
674
|
+
value: skillChoiceValue(loc),
|
|
675
|
+
label: locationLabel(loc),
|
|
676
|
+
})),
|
|
677
|
+
{ value: "view", label: "View SKILL.md" },
|
|
678
|
+
{ value: "skip", label: "Skip" },
|
|
679
|
+
],
|
|
680
|
+
});
|
|
681
|
+
if (isCancel(selection) || selection === "skip") {
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
if (selection === "view") {
|
|
685
|
+
await promptViewSkillContents(locs);
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
const chosen = locs.find((loc) => skillChoiceValue(loc) === selection);
|
|
689
|
+
if (!chosen) {
|
|
690
|
+
break;
|
|
691
|
+
}
|
|
692
|
+
await resolveSkillConflictAndCopy({
|
|
693
|
+
name,
|
|
694
|
+
sourceDir: chosen.entryDir,
|
|
695
|
+
dest,
|
|
696
|
+
state,
|
|
697
|
+
force,
|
|
698
|
+
autoMode,
|
|
699
|
+
incomingModified: chosen.modified,
|
|
700
|
+
});
|
|
701
|
+
break;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async function consolidateSkills(
|
|
706
|
+
res: ScanResult,
|
|
707
|
+
state: ConsolidatedState,
|
|
708
|
+
targets: { skills: string },
|
|
709
|
+
force: boolean,
|
|
710
|
+
autoMode: AutoMode | undefined
|
|
711
|
+
) {
|
|
712
|
+
const skillMap = await buildSkillLocations(res);
|
|
713
|
+
const skillNames = [...skillMap.keys()].sort();
|
|
714
|
+
if (!skillNames.length) {
|
|
715
|
+
note("No skills found to consolidate.", "Skills");
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
for (const name of skillNames) {
|
|
720
|
+
if (state.skills[name] && !force) {
|
|
721
|
+
note(
|
|
722
|
+
`Already consolidated from ${state.skills[name].source} → ${state.skills[name].target}`,
|
|
723
|
+
`Skill: ${name}`
|
|
724
|
+
);
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const locs = skillMap.get(name) ?? [];
|
|
729
|
+
if (locs.length === 0) {
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const dest = join(targets.skills, name);
|
|
734
|
+
if (locs.length === 1 && locs[0]) {
|
|
735
|
+
await handleSingleSkillLocation({
|
|
736
|
+
name,
|
|
737
|
+
loc: locs[0],
|
|
738
|
+
dest,
|
|
739
|
+
state,
|
|
740
|
+
force,
|
|
741
|
+
autoMode,
|
|
742
|
+
});
|
|
743
|
+
} else {
|
|
744
|
+
await handleMultipleSkillLocations({
|
|
745
|
+
name,
|
|
746
|
+
locs,
|
|
747
|
+
dest,
|
|
748
|
+
state,
|
|
749
|
+
force,
|
|
750
|
+
autoMode,
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function nowIso(): string {
|
|
757
|
+
return new Date().toISOString();
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
761
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function coerceTransport(v: unknown): McpTransport | undefined {
|
|
765
|
+
if (v === "stdio" || v === "http" || v === "sse") {
|
|
766
|
+
return v;
|
|
767
|
+
}
|
|
768
|
+
return undefined;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function coerceStringArray(v: unknown): string[] | undefined {
|
|
772
|
+
if (!Array.isArray(v)) {
|
|
773
|
+
return undefined;
|
|
774
|
+
}
|
|
775
|
+
const out: string[] = [];
|
|
776
|
+
for (const item of v) {
|
|
777
|
+
if (typeof item === "string") {
|
|
778
|
+
out.push(item);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return out.length ? out : undefined;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function coerceEnv(v: unknown): Record<string, string> | undefined {
|
|
785
|
+
if (!isPlainObject(v)) {
|
|
786
|
+
return undefined;
|
|
787
|
+
}
|
|
788
|
+
const out: Record<string, string> = {};
|
|
789
|
+
for (const [k, val] of Object.entries(v)) {
|
|
790
|
+
if (typeof val === "string") {
|
|
791
|
+
out[k] = val;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return Object.keys(out).length ? out : undefined;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function canonicalizeMcpServer({
|
|
798
|
+
serverName,
|
|
799
|
+
serverConfig,
|
|
800
|
+
sourceId,
|
|
801
|
+
configPath,
|
|
802
|
+
modified,
|
|
803
|
+
}: {
|
|
804
|
+
serverName: string;
|
|
805
|
+
serverConfig: unknown;
|
|
806
|
+
sourceId: string;
|
|
807
|
+
configPath: string;
|
|
808
|
+
modified: Date | null;
|
|
809
|
+
}): CanonicalMcpServer {
|
|
810
|
+
const importedAt = nowIso();
|
|
811
|
+
|
|
812
|
+
if (!isPlainObject(serverConfig)) {
|
|
813
|
+
// Preserve the raw non-object definition in vendorExtensions so we never lose it.
|
|
814
|
+
return {
|
|
815
|
+
name: serverName,
|
|
816
|
+
vendorExtensions: { raw: serverConfig },
|
|
817
|
+
provenance: {
|
|
818
|
+
sourceId,
|
|
819
|
+
sourcePath: configPath,
|
|
820
|
+
importedAt,
|
|
821
|
+
sourceModifiedAt: modified ? modified.toISOString() : undefined,
|
|
822
|
+
},
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
let transport = coerceTransport(serverConfig.transport);
|
|
827
|
+
if (!transport) {
|
|
828
|
+
if (typeof serverConfig.command === "string") {
|
|
829
|
+
transport = "stdio";
|
|
830
|
+
} else if (typeof serverConfig.url === "string") {
|
|
831
|
+
transport = "http";
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const command =
|
|
836
|
+
typeof serverConfig.command === "string" ? serverConfig.command : undefined;
|
|
837
|
+
const args = coerceStringArray(serverConfig.args);
|
|
838
|
+
const url =
|
|
839
|
+
typeof serverConfig.url === "string" ? serverConfig.url : undefined;
|
|
840
|
+
const env = coerceEnv(serverConfig.env);
|
|
841
|
+
|
|
842
|
+
const knownKeys = new Set(["transport", "command", "args", "url", "env"]);
|
|
843
|
+
const vendorExtensions: Record<string, unknown> = {};
|
|
844
|
+
for (const [k, v] of Object.entries(serverConfig)) {
|
|
845
|
+
if (!knownKeys.has(k)) {
|
|
846
|
+
vendorExtensions[k] = v;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
return {
|
|
851
|
+
name: serverName,
|
|
852
|
+
transport,
|
|
853
|
+
command,
|
|
854
|
+
args,
|
|
855
|
+
url,
|
|
856
|
+
env,
|
|
857
|
+
vendorExtensions: Object.keys(vendorExtensions).length
|
|
858
|
+
? vendorExtensions
|
|
859
|
+
: undefined,
|
|
860
|
+
provenance: {
|
|
861
|
+
sourceId,
|
|
862
|
+
sourcePath: configPath,
|
|
863
|
+
importedAt,
|
|
864
|
+
sourceModifiedAt: modified ? modified.toISOString() : undefined,
|
|
865
|
+
},
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function defaultCanonicalMcpObject(): McpConsolidatedObject {
|
|
870
|
+
return {
|
|
871
|
+
version: 1,
|
|
872
|
+
updatedAt: nowIso(),
|
|
873
|
+
mcpServers: {},
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function coerceCanonicalMcpRegistry(
|
|
878
|
+
parsedUnknown: unknown
|
|
879
|
+
): McpConsolidatedObject | null {
|
|
880
|
+
if (!isPlainObject(parsedUnknown)) {
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const parsedObj = parsedUnknown as Record<string, unknown>;
|
|
885
|
+
const rawServers = parsedObj.mcpServers;
|
|
886
|
+
if (!isPlainObject(rawServers)) {
|
|
887
|
+
return null;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Already canonical.
|
|
891
|
+
if (
|
|
892
|
+
parsedObj.version === 1 &&
|
|
893
|
+
typeof parsedObj.updatedAt === "string" &&
|
|
894
|
+
isPlainObject(parsedObj.mcpServers)
|
|
895
|
+
) {
|
|
896
|
+
return parsedUnknown as unknown as McpConsolidatedObject;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Legacy shape: migrate minimally and preserve unknown fields.
|
|
900
|
+
const migrated = defaultCanonicalMcpObject();
|
|
901
|
+
|
|
902
|
+
for (const [name, cfg] of Object.entries(rawServers)) {
|
|
903
|
+
migrated.mcpServers[name] = {
|
|
904
|
+
name,
|
|
905
|
+
vendorExtensions: isPlainObject(cfg) ? { ...cfg } : { raw: cfg },
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const vendorTop: Record<string, unknown> = {};
|
|
910
|
+
for (const [k, v] of Object.entries(parsedObj)) {
|
|
911
|
+
if (k === "mcpServers" || k === "version" || k === "updatedAt") {
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
vendorTop[k] = v;
|
|
915
|
+
}
|
|
916
|
+
if (Object.keys(vendorTop).length) {
|
|
917
|
+
migrated.vendorExtensions = vendorTop;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
return migrated;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
async function loadConsolidatedMcpObject(
|
|
924
|
+
path: string
|
|
925
|
+
): Promise<McpConsolidatedObject> {
|
|
926
|
+
if (!(await fileExists(path))) {
|
|
927
|
+
return defaultCanonicalMcpObject();
|
|
928
|
+
}
|
|
929
|
+
try {
|
|
930
|
+
const txt = await Bun.file(path).text();
|
|
931
|
+
const parsedUnknown = JSON.parse(txt) as unknown;
|
|
932
|
+
return (
|
|
933
|
+
coerceCanonicalMcpRegistry(parsedUnknown) ?? defaultCanonicalMcpObject()
|
|
934
|
+
);
|
|
935
|
+
} catch {
|
|
936
|
+
return defaultCanonicalMcpObject();
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
async function mergeServerAndSave({
|
|
941
|
+
serverName,
|
|
942
|
+
serverConfig,
|
|
943
|
+
sourceId,
|
|
944
|
+
modified,
|
|
945
|
+
configPath,
|
|
946
|
+
consolidatedPath,
|
|
947
|
+
consolidatedObj,
|
|
948
|
+
state,
|
|
949
|
+
}: {
|
|
950
|
+
serverName: string;
|
|
951
|
+
serverConfig: unknown;
|
|
952
|
+
sourceId: string;
|
|
953
|
+
modified: Date | null;
|
|
954
|
+
configPath: string;
|
|
955
|
+
consolidatedPath: string;
|
|
956
|
+
consolidatedObj: McpConsolidatedObject;
|
|
957
|
+
state: ConsolidatedState;
|
|
958
|
+
}): Promise<void> {
|
|
959
|
+
consolidatedObj.updatedAt = nowIso();
|
|
960
|
+
consolidatedObj.mcpServers[serverName] = canonicalizeMcpServer({
|
|
961
|
+
serverName,
|
|
962
|
+
serverConfig,
|
|
963
|
+
sourceId,
|
|
964
|
+
configPath,
|
|
965
|
+
modified,
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
state.mcpServers[serverName] = {
|
|
969
|
+
source: configPath,
|
|
970
|
+
target: consolidatedPath,
|
|
971
|
+
consolidatedAt: nowIso(),
|
|
972
|
+
};
|
|
973
|
+
await Bun.write(
|
|
974
|
+
consolidatedPath,
|
|
975
|
+
`${JSON.stringify(consolidatedObj, null, 2)}\n`
|
|
976
|
+
);
|
|
977
|
+
note(`Merged into ${consolidatedPath}`, `MCP server: ${serverName}`);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
async function resolveMcpServerConflictAndMerge({
|
|
981
|
+
serverName,
|
|
982
|
+
loc,
|
|
983
|
+
consolidatedPath,
|
|
984
|
+
consolidatedObj,
|
|
985
|
+
state,
|
|
986
|
+
autoMode,
|
|
987
|
+
}: {
|
|
988
|
+
serverName: string;
|
|
989
|
+
loc: McpServerLocation;
|
|
990
|
+
consolidatedPath: string;
|
|
991
|
+
consolidatedObj: McpConsolidatedObject;
|
|
992
|
+
state: ConsolidatedState;
|
|
993
|
+
autoMode: AutoMode | undefined;
|
|
994
|
+
}): Promise<void> {
|
|
995
|
+
const currentEntry = consolidatedObj.mcpServers[serverName];
|
|
996
|
+
if (!currentEntry) {
|
|
997
|
+
await mergeServerAndSave({
|
|
998
|
+
serverName,
|
|
999
|
+
serverConfig: loc.serverConfig,
|
|
1000
|
+
sourceId: loc.sourceId,
|
|
1001
|
+
modified: loc.modified,
|
|
1002
|
+
configPath: loc.configPath,
|
|
1003
|
+
consolidatedPath,
|
|
1004
|
+
consolidatedObj,
|
|
1005
|
+
state,
|
|
1006
|
+
});
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
const currentContent = JSON.stringify(currentEntry, null, 2);
|
|
1011
|
+
const incomingCanonical = canonicalizeMcpServer({
|
|
1012
|
+
serverName,
|
|
1013
|
+
serverConfig: loc.serverConfig,
|
|
1014
|
+
sourceId: loc.sourceId,
|
|
1015
|
+
configPath: loc.configPath,
|
|
1016
|
+
modified: loc.modified,
|
|
1017
|
+
});
|
|
1018
|
+
const incomingContent = JSON.stringify(incomingCanonical, null, 2);
|
|
1019
|
+
const currentHash = await mcpServerHash(currentEntry);
|
|
1020
|
+
const incomingHash = await mcpServerHash(incomingCanonical);
|
|
1021
|
+
|
|
1022
|
+
const decision = await resolveConflictAction({
|
|
1023
|
+
title: `MCP server ${serverName}`,
|
|
1024
|
+
currentLabel: consolidatedPath,
|
|
1025
|
+
incomingLabel: loc.configPath,
|
|
1026
|
+
currentContent,
|
|
1027
|
+
incomingContent,
|
|
1028
|
+
currentHash,
|
|
1029
|
+
incomingHash,
|
|
1030
|
+
autoMode,
|
|
1031
|
+
currentMeta: { modified: await lastModified(consolidatedPath) },
|
|
1032
|
+
incomingMeta: { modified: loc.modified },
|
|
1033
|
+
promptConflictResolution,
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
if (decision === "skip") {
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if (decision === "keep-current") {
|
|
1041
|
+
if (!state.mcpServers[serverName]) {
|
|
1042
|
+
state.mcpServers[serverName] = {
|
|
1043
|
+
source: consolidatedPath,
|
|
1044
|
+
target: consolidatedPath,
|
|
1045
|
+
consolidatedAt: nowIso(),
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
note(
|
|
1049
|
+
`Kept existing MCP server in ${consolidatedPath}`,
|
|
1050
|
+
`MCP server: ${serverName}`
|
|
1051
|
+
);
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
if (decision === "keep-incoming") {
|
|
1056
|
+
const backup = await archiveExisting(consolidatedPath);
|
|
1057
|
+
if (backup) {
|
|
1058
|
+
note(`Archived existing MCP registry to ${backup}`, "MCP registry");
|
|
1059
|
+
}
|
|
1060
|
+
consolidatedObj.updatedAt = nowIso();
|
|
1061
|
+
consolidatedObj.mcpServers[serverName] = incomingCanonical;
|
|
1062
|
+
state.mcpServers[serverName] = {
|
|
1063
|
+
source: loc.configPath,
|
|
1064
|
+
target: consolidatedPath,
|
|
1065
|
+
consolidatedAt: nowIso(),
|
|
1066
|
+
};
|
|
1067
|
+
await Bun.write(
|
|
1068
|
+
consolidatedPath,
|
|
1069
|
+
`${JSON.stringify(consolidatedObj, null, 2)}\n`
|
|
1070
|
+
);
|
|
1071
|
+
note(`Merged into ${consolidatedPath}`, `MCP server: ${serverName}`);
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const newName = await nextAvailableName({
|
|
1076
|
+
base: serverName,
|
|
1077
|
+
suffix: "incoming",
|
|
1078
|
+
exists: (candidate) => candidate in consolidatedObj.mcpServers,
|
|
1079
|
+
});
|
|
1080
|
+
const renamedCanonical = canonicalizeMcpServer({
|
|
1081
|
+
serverName: newName,
|
|
1082
|
+
serverConfig: loc.serverConfig,
|
|
1083
|
+
sourceId: loc.sourceId,
|
|
1084
|
+
configPath: loc.configPath,
|
|
1085
|
+
modified: loc.modified,
|
|
1086
|
+
});
|
|
1087
|
+
consolidatedObj.updatedAt = nowIso();
|
|
1088
|
+
consolidatedObj.mcpServers[newName] = renamedCanonical;
|
|
1089
|
+
state.mcpServers[newName] = {
|
|
1090
|
+
source: loc.configPath,
|
|
1091
|
+
target: consolidatedPath,
|
|
1092
|
+
consolidatedAt: nowIso(),
|
|
1093
|
+
};
|
|
1094
|
+
if (!state.mcpServers[serverName]) {
|
|
1095
|
+
state.mcpServers[serverName] = {
|
|
1096
|
+
source: consolidatedPath,
|
|
1097
|
+
target: consolidatedPath,
|
|
1098
|
+
consolidatedAt: nowIso(),
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
await Bun.write(
|
|
1102
|
+
consolidatedPath,
|
|
1103
|
+
`${JSON.stringify(consolidatedObj, null, 2)}\n`
|
|
1104
|
+
);
|
|
1105
|
+
note(`Kept both servers: ${serverName} + ${newName}`, "MCP server");
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
async function handleSingleMcpServerLocation({
|
|
1109
|
+
serverName,
|
|
1110
|
+
loc,
|
|
1111
|
+
consolidatedPath,
|
|
1112
|
+
consolidatedObj,
|
|
1113
|
+
state,
|
|
1114
|
+
autoMode,
|
|
1115
|
+
}: {
|
|
1116
|
+
serverName: string;
|
|
1117
|
+
loc: McpServerLocation;
|
|
1118
|
+
consolidatedPath: string;
|
|
1119
|
+
consolidatedObj: McpConsolidatedObject;
|
|
1120
|
+
state: ConsolidatedState;
|
|
1121
|
+
autoMode: AutoMode | undefined;
|
|
1122
|
+
}): Promise<void> {
|
|
1123
|
+
if (!autoMode) {
|
|
1124
|
+
const ok = await confirm({
|
|
1125
|
+
message: `Add MCP server "${serverName}" from ${loc.configPath} (modified ${formatDate(loc.modified)})?`,
|
|
1126
|
+
});
|
|
1127
|
+
if (isCancel(ok) || !ok) {
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
await resolveMcpServerConflictAndMerge({
|
|
1132
|
+
serverName,
|
|
1133
|
+
loc,
|
|
1134
|
+
consolidatedPath,
|
|
1135
|
+
consolidatedObj,
|
|
1136
|
+
state,
|
|
1137
|
+
autoMode,
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
async function handleMultipleMcpServerLocations({
|
|
1142
|
+
serverName,
|
|
1143
|
+
locs,
|
|
1144
|
+
consolidatedPath,
|
|
1145
|
+
consolidatedObj,
|
|
1146
|
+
state,
|
|
1147
|
+
autoMode,
|
|
1148
|
+
}: {
|
|
1149
|
+
serverName: string;
|
|
1150
|
+
locs: McpServerLocation[];
|
|
1151
|
+
consolidatedPath: string;
|
|
1152
|
+
consolidatedObj: McpConsolidatedObject;
|
|
1153
|
+
state: ConsolidatedState;
|
|
1154
|
+
autoMode: AutoMode | undefined;
|
|
1155
|
+
}): Promise<void> {
|
|
1156
|
+
if (autoMode) {
|
|
1157
|
+
const chosen = chooseByAutoMode(locs, autoMode);
|
|
1158
|
+
if (!chosen) {
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
await resolveMcpServerConflictAndMerge({
|
|
1162
|
+
serverName,
|
|
1163
|
+
loc: chosen,
|
|
1164
|
+
consolidatedPath,
|
|
1165
|
+
consolidatedObj,
|
|
1166
|
+
state,
|
|
1167
|
+
autoMode,
|
|
1168
|
+
});
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
while (true) {
|
|
1173
|
+
const selection = await select({
|
|
1174
|
+
message: `Choose source for MCP server "${serverName}"`,
|
|
1175
|
+
options: [
|
|
1176
|
+
...locs.map((loc) => ({
|
|
1177
|
+
value: mcpChoiceValue(loc),
|
|
1178
|
+
label: locationLabel(loc),
|
|
1179
|
+
})),
|
|
1180
|
+
{ value: "view", label: "View MCP JSON" },
|
|
1181
|
+
{ value: "skip", label: "Skip" },
|
|
1182
|
+
],
|
|
1183
|
+
});
|
|
1184
|
+
if (isCancel(selection) || selection === "skip") {
|
|
1185
|
+
break;
|
|
1186
|
+
}
|
|
1187
|
+
if (selection === "view") {
|
|
1188
|
+
await promptViewMcpContents(locs);
|
|
1189
|
+
continue;
|
|
1190
|
+
}
|
|
1191
|
+
const chosen = locs.find((loc) => mcpChoiceValue(loc) === selection);
|
|
1192
|
+
if (!chosen) {
|
|
1193
|
+
break;
|
|
1194
|
+
}
|
|
1195
|
+
await resolveMcpServerConflictAndMerge({
|
|
1196
|
+
serverName,
|
|
1197
|
+
loc: chosen,
|
|
1198
|
+
consolidatedPath,
|
|
1199
|
+
consolidatedObj,
|
|
1200
|
+
state,
|
|
1201
|
+
autoMode,
|
|
1202
|
+
});
|
|
1203
|
+
break;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
async function resolveMcpConfigConflictAndCopy({
|
|
1208
|
+
config,
|
|
1209
|
+
dest,
|
|
1210
|
+
state,
|
|
1211
|
+
autoMode,
|
|
1212
|
+
key,
|
|
1213
|
+
mcpDir,
|
|
1214
|
+
}: {
|
|
1215
|
+
config: McpConfigLocation;
|
|
1216
|
+
dest: string;
|
|
1217
|
+
state: ConsolidatedState;
|
|
1218
|
+
autoMode: AutoMode | undefined;
|
|
1219
|
+
key: string;
|
|
1220
|
+
mcpDir: string;
|
|
1221
|
+
}): Promise<void> {
|
|
1222
|
+
if (!(await fileExists(dest))) {
|
|
1223
|
+
try {
|
|
1224
|
+
await Bun.write(dest, Bun.file(config.configPath));
|
|
1225
|
+
state.mcpConfigs[key] = {
|
|
1226
|
+
source: config.configPath,
|
|
1227
|
+
target: dest,
|
|
1228
|
+
consolidatedAt: new Date().toISOString(),
|
|
1229
|
+
};
|
|
1230
|
+
note(`Copied to ${dest}`, `MCP config: ${basename(config.configPath)}`);
|
|
1231
|
+
} catch (e: unknown) {
|
|
1232
|
+
const err = e as { message?: string } | null;
|
|
1233
|
+
note(
|
|
1234
|
+
`Copy failed: ${String(err?.message ?? e)}`,
|
|
1235
|
+
`MCP config: ${config.configPath}`
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
const currentContent = await readTextSafe(dest);
|
|
1242
|
+
const incomingContent = await readTextSafe(config.configPath);
|
|
1243
|
+
const currentHash = normalizedJsonHash(currentContent);
|
|
1244
|
+
const incomingHash = normalizedJsonHash(incomingContent);
|
|
1245
|
+
|
|
1246
|
+
const decision = await resolveConflictAction({
|
|
1247
|
+
title: `MCP config ${basename(config.configPath)}`,
|
|
1248
|
+
currentLabel: dest,
|
|
1249
|
+
incomingLabel: config.configPath,
|
|
1250
|
+
currentContent,
|
|
1251
|
+
incomingContent,
|
|
1252
|
+
currentHash,
|
|
1253
|
+
incomingHash,
|
|
1254
|
+
autoMode,
|
|
1255
|
+
currentMeta: { modified: await lastModified(dest) },
|
|
1256
|
+
incomingMeta: { modified: config.modified },
|
|
1257
|
+
promptConflictResolution,
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
if (decision === "skip") {
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
if (decision === "keep-current") {
|
|
1265
|
+
state.mcpConfigs[key] = {
|
|
1266
|
+
source: dest,
|
|
1267
|
+
target: dest,
|
|
1268
|
+
consolidatedAt: new Date().toISOString(),
|
|
1269
|
+
};
|
|
1270
|
+
note(
|
|
1271
|
+
`Kept existing MCP config at ${dest}`,
|
|
1272
|
+
`MCP config: ${basename(dest)}`
|
|
1273
|
+
);
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
if (decision === "keep-incoming") {
|
|
1278
|
+
const backup = await archiveExisting(dest);
|
|
1279
|
+
if (backup) {
|
|
1280
|
+
note(`Archived existing MCP config to ${backup}`, "MCP config");
|
|
1281
|
+
}
|
|
1282
|
+
try {
|
|
1283
|
+
await Bun.write(dest, Bun.file(config.configPath));
|
|
1284
|
+
state.mcpConfigs[key] = {
|
|
1285
|
+
source: config.configPath,
|
|
1286
|
+
target: dest,
|
|
1287
|
+
consolidatedAt: new Date().toISOString(),
|
|
1288
|
+
};
|
|
1289
|
+
note(`Copied to ${dest}`, `MCP config: ${basename(config.configPath)}`);
|
|
1290
|
+
} catch (e: unknown) {
|
|
1291
|
+
const err = e as { message?: string } | null;
|
|
1292
|
+
note(
|
|
1293
|
+
`Copy failed: ${String(err?.message ?? e)}`,
|
|
1294
|
+
`MCP config: ${config.configPath}`
|
|
1295
|
+
);
|
|
1296
|
+
}
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
const baseName = basename(config.configPath);
|
|
1301
|
+
const ext = extname(baseName);
|
|
1302
|
+
const stem = ext ? baseName.slice(0, -ext.length) : baseName;
|
|
1303
|
+
const newBase = await nextAvailableName({
|
|
1304
|
+
base: stem,
|
|
1305
|
+
suffix: "incoming",
|
|
1306
|
+
exists: async (candidate) => fileExists(join(mcpDir, `${candidate}${ext}`)),
|
|
1307
|
+
});
|
|
1308
|
+
const newName = `${newBase}${ext}`;
|
|
1309
|
+
const newDest = join(mcpDir, newName);
|
|
1310
|
+
try {
|
|
1311
|
+
await Bun.write(newDest, Bun.file(config.configPath));
|
|
1312
|
+
state.mcpConfigs[key] = {
|
|
1313
|
+
source: config.configPath,
|
|
1314
|
+
target: newDest,
|
|
1315
|
+
consolidatedAt: new Date().toISOString(),
|
|
1316
|
+
};
|
|
1317
|
+
note(`Copied to ${newDest}`, `MCP config: ${newName}`);
|
|
1318
|
+
} catch (e: unknown) {
|
|
1319
|
+
const err = e as { message?: string } | null;
|
|
1320
|
+
note(
|
|
1321
|
+
`Copy failed: ${String(err?.message ?? e)}`,
|
|
1322
|
+
`MCP config: ${config.configPath}`
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
async function consolidateMcpConfigFiles(
|
|
1328
|
+
configs: McpConfigLocation[],
|
|
1329
|
+
state: ConsolidatedState,
|
|
1330
|
+
mcpDir: string,
|
|
1331
|
+
force: boolean,
|
|
1332
|
+
autoMode: AutoMode | undefined
|
|
1333
|
+
): Promise<void> {
|
|
1334
|
+
const sorted = configs.sort((a, b) =>
|
|
1335
|
+
a.configPath.localeCompare(b.configPath)
|
|
1336
|
+
);
|
|
1337
|
+
for (const config of sorted) {
|
|
1338
|
+
const key = config.configPath;
|
|
1339
|
+
if (state.mcpConfigs[key] && !force) {
|
|
1340
|
+
note(
|
|
1341
|
+
`Already consolidated from ${state.mcpConfigs[key].source}`,
|
|
1342
|
+
`MCP config: ${key}`
|
|
1343
|
+
);
|
|
1344
|
+
continue;
|
|
1345
|
+
}
|
|
1346
|
+
const dest = join(mcpDir, basename(config.configPath));
|
|
1347
|
+
if (!autoMode) {
|
|
1348
|
+
const ok = await confirm({
|
|
1349
|
+
message: `Copy MCP config ${config.configPath} (modified ${formatDate(config.modified)})?`,
|
|
1350
|
+
});
|
|
1351
|
+
if (isCancel(ok) || !ok) {
|
|
1352
|
+
continue;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
await resolveMcpConfigConflictAndCopy({
|
|
1356
|
+
config,
|
|
1357
|
+
dest,
|
|
1358
|
+
state,
|
|
1359
|
+
autoMode,
|
|
1360
|
+
key,
|
|
1361
|
+
mcpDir,
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
async function consolidateMcpServers(
|
|
1367
|
+
res: ScanResult,
|
|
1368
|
+
state: ConsolidatedState,
|
|
1369
|
+
targets: { mcp: string },
|
|
1370
|
+
force: boolean,
|
|
1371
|
+
autoMode: AutoMode | undefined
|
|
1372
|
+
) {
|
|
1373
|
+
const { servers, configs } = await buildMcpLocations(res);
|
|
1374
|
+
const serverNames = [...servers.keys()].sort();
|
|
1375
|
+
const consolidatedPath = join(targets.mcp, "mcp.json");
|
|
1376
|
+
const consolidatedObj = await loadConsolidatedMcpObject(consolidatedPath);
|
|
1377
|
+
|
|
1378
|
+
for (const serverName of serverNames) {
|
|
1379
|
+
if (state.mcpServers[serverName] && !force) {
|
|
1380
|
+
note(
|
|
1381
|
+
`Already consolidated from ${state.mcpServers[serverName].source}`,
|
|
1382
|
+
`MCP server: ${serverName}`
|
|
1383
|
+
);
|
|
1384
|
+
continue;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
const locs = servers.get(serverName) ?? [];
|
|
1388
|
+
if (locs.length === 1 && locs[0]) {
|
|
1389
|
+
await handleSingleMcpServerLocation({
|
|
1390
|
+
serverName,
|
|
1391
|
+
loc: locs[0],
|
|
1392
|
+
consolidatedPath,
|
|
1393
|
+
consolidatedObj,
|
|
1394
|
+
state,
|
|
1395
|
+
autoMode,
|
|
1396
|
+
});
|
|
1397
|
+
} else if (locs.length > 1) {
|
|
1398
|
+
await handleMultipleMcpServerLocations({
|
|
1399
|
+
serverName,
|
|
1400
|
+
locs,
|
|
1401
|
+
consolidatedPath,
|
|
1402
|
+
consolidatedObj,
|
|
1403
|
+
state,
|
|
1404
|
+
autoMode,
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
await consolidateMcpConfigFiles(configs, state, targets.mcp, force, autoMode);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
function parseAutoMode(argv: string[]): AutoMode | undefined {
|
|
1413
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
1414
|
+
const arg = argv[i];
|
|
1415
|
+
if (!arg) {
|
|
1416
|
+
continue;
|
|
1417
|
+
}
|
|
1418
|
+
if (arg === "--auto") {
|
|
1419
|
+
const value = argv[i + 1];
|
|
1420
|
+
if (
|
|
1421
|
+
value === "keep-newest" ||
|
|
1422
|
+
value === "keep-current" ||
|
|
1423
|
+
value === "keep-incoming"
|
|
1424
|
+
) {
|
|
1425
|
+
return value;
|
|
1426
|
+
}
|
|
1427
|
+
throw new Error(
|
|
1428
|
+
"--auto requires keep-newest, keep-current, or keep-incoming"
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
if (arg.startsWith("--auto=")) {
|
|
1432
|
+
const value = arg.slice("--auto=".length);
|
|
1433
|
+
if (
|
|
1434
|
+
value === "keep-newest" ||
|
|
1435
|
+
value === "keep-current" ||
|
|
1436
|
+
value === "keep-incoming"
|
|
1437
|
+
) {
|
|
1438
|
+
return value;
|
|
1439
|
+
}
|
|
1440
|
+
throw new Error(
|
|
1441
|
+
"--auto requires keep-newest, keep-current, or keep-incoming"
|
|
1442
|
+
);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
return undefined;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
function parsePositiveIntFlag(
|
|
1449
|
+
argv: string[],
|
|
1450
|
+
i: number,
|
|
1451
|
+
flag: string
|
|
1452
|
+
): { value: number; advance: number } {
|
|
1453
|
+
const arg = argv[i];
|
|
1454
|
+
if (arg === flag) {
|
|
1455
|
+
const next = argv[i + 1];
|
|
1456
|
+
if (!next) {
|
|
1457
|
+
throw new Error(`${flag} requires a number`);
|
|
1458
|
+
}
|
|
1459
|
+
const n = Number(next);
|
|
1460
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
1461
|
+
throw new Error(`Invalid ${flag} value: ${next}`);
|
|
1462
|
+
}
|
|
1463
|
+
return { value: Math.floor(n), advance: 1 };
|
|
1464
|
+
}
|
|
1465
|
+
const raw = arg?.slice(`${flag}=`.length) ?? "";
|
|
1466
|
+
const n = Number(raw);
|
|
1467
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
1468
|
+
throw new Error(`Invalid ${flag} value: ${raw}`);
|
|
1469
|
+
}
|
|
1470
|
+
return { value: Math.floor(n), advance: 0 };
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
function parseConsolidateScanOptions(argv: string[]): {
|
|
1474
|
+
includeConfigFrom: boolean;
|
|
1475
|
+
includeGitHooks: boolean;
|
|
1476
|
+
from: string[];
|
|
1477
|
+
fromOptions: {
|
|
1478
|
+
ignoreDirNames: string[];
|
|
1479
|
+
noDefaultIgnore: boolean;
|
|
1480
|
+
maxVisits?: number;
|
|
1481
|
+
maxResults?: number;
|
|
1482
|
+
};
|
|
1483
|
+
} {
|
|
1484
|
+
const noConfigFrom = argv.includes("--no-config-from");
|
|
1485
|
+
const includeGitHooks = argv.includes("--include-git-hooks");
|
|
1486
|
+
const from: string[] = [];
|
|
1487
|
+
const fromIgnore: string[] = [];
|
|
1488
|
+
let fromNoDefaultIgnore = false;
|
|
1489
|
+
let fromMaxVisits: number | undefined;
|
|
1490
|
+
let fromMaxResults: number | undefined;
|
|
1491
|
+
|
|
1492
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
1493
|
+
const arg = argv[i];
|
|
1494
|
+
if (!arg) {
|
|
1495
|
+
continue;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
if (arg === "--from") {
|
|
1499
|
+
const next = argv[i + 1];
|
|
1500
|
+
if (!next) {
|
|
1501
|
+
throw new Error("--from requires a path");
|
|
1502
|
+
}
|
|
1503
|
+
from.push(next);
|
|
1504
|
+
i += 1;
|
|
1505
|
+
continue;
|
|
1506
|
+
}
|
|
1507
|
+
if (arg.startsWith("--from=")) {
|
|
1508
|
+
const value = arg.slice("--from=".length);
|
|
1509
|
+
if (!value) {
|
|
1510
|
+
throw new Error("--from requires a path");
|
|
1511
|
+
}
|
|
1512
|
+
from.push(value);
|
|
1513
|
+
continue;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
if (arg === "--from-ignore") {
|
|
1517
|
+
const next = argv[i + 1];
|
|
1518
|
+
if (!next) {
|
|
1519
|
+
throw new Error("--from-ignore requires a directory name");
|
|
1520
|
+
}
|
|
1521
|
+
fromIgnore.push(next);
|
|
1522
|
+
i += 1;
|
|
1523
|
+
continue;
|
|
1524
|
+
}
|
|
1525
|
+
if (arg.startsWith("--from-ignore=")) {
|
|
1526
|
+
const value = arg.slice("--from-ignore=".length);
|
|
1527
|
+
if (!value) {
|
|
1528
|
+
throw new Error("--from-ignore requires a directory name");
|
|
1529
|
+
}
|
|
1530
|
+
fromIgnore.push(value);
|
|
1531
|
+
continue;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
if (arg === "--from-no-default-ignore") {
|
|
1535
|
+
fromNoDefaultIgnore = true;
|
|
1536
|
+
continue;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
if (arg === "--from-max-visits" || arg.startsWith("--from-max-visits=")) {
|
|
1540
|
+
const parsed = parsePositiveIntFlag(argv, i, "--from-max-visits");
|
|
1541
|
+
fromMaxVisits = parsed.value;
|
|
1542
|
+
i += parsed.advance;
|
|
1543
|
+
continue;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
if (arg === "--from-max-results" || arg.startsWith("--from-max-results=")) {
|
|
1547
|
+
const parsed = parsePositiveIntFlag(argv, i, "--from-max-results");
|
|
1548
|
+
fromMaxResults = parsed.value;
|
|
1549
|
+
i += parsed.advance;
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
return {
|
|
1554
|
+
includeConfigFrom: !noConfigFrom,
|
|
1555
|
+
includeGitHooks,
|
|
1556
|
+
from,
|
|
1557
|
+
fromOptions: {
|
|
1558
|
+
ignoreDirNames: fromIgnore,
|
|
1559
|
+
noDefaultIgnore: fromNoDefaultIgnore,
|
|
1560
|
+
maxVisits: fromMaxVisits,
|
|
1561
|
+
maxResults: fromMaxResults,
|
|
1562
|
+
},
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
export async function consolidateCommand(
|
|
1567
|
+
argv: string[],
|
|
1568
|
+
ctx: { homeDir?: string; rootDir?: string; cwd?: string } = {}
|
|
1569
|
+
) {
|
|
1570
|
+
if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
|
|
1571
|
+
console.log(`facult consolidate — deduplicate and copy skills + MCP configs into the canonical store
|
|
1572
|
+
|
|
1573
|
+
Usage:
|
|
1574
|
+
facult consolidate [--force] [--auto <keep-newest|keep-current|keep-incoming>] [scan options]
|
|
1575
|
+
|
|
1576
|
+
Options:
|
|
1577
|
+
--force Re-copy items already consolidated
|
|
1578
|
+
--auto Auto-resolve conflicts (non-interactive)
|
|
1579
|
+
--no-config-from Disable scanFrom roots from ~/.facult/config.json
|
|
1580
|
+
--from Add scan root (repeatable): --from ~/dev
|
|
1581
|
+
--include-git-hooks Include .git/hooks and .husky hooks in --from scans
|
|
1582
|
+
--from-ignore Ignore directory basename in --from scans (repeatable)
|
|
1583
|
+
--from-no-default-ignore Disable default ignore list for --from scans
|
|
1584
|
+
--from-max-visits Max directories visited per --from root
|
|
1585
|
+
--from-max-results Max discovered paths per --from root
|
|
1586
|
+
`);
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
try {
|
|
1590
|
+
const force = argv.includes("--force");
|
|
1591
|
+
const autoMode = parseAutoMode(argv);
|
|
1592
|
+
const scanOptions = parseConsolidateScanOptions(argv);
|
|
1593
|
+
const home = ctx.homeDir ?? homedir();
|
|
1594
|
+
const rootDir = ctx.rootDir ?? facultRootDir(home);
|
|
1595
|
+
intro("facult consolidate");
|
|
1596
|
+
|
|
1597
|
+
const res = await scan([], {
|
|
1598
|
+
...scanOptions,
|
|
1599
|
+
homeDir: home,
|
|
1600
|
+
cwd: ctx.cwd,
|
|
1601
|
+
});
|
|
1602
|
+
const state = await loadState(home);
|
|
1603
|
+
|
|
1604
|
+
const targets = {
|
|
1605
|
+
skills: join(rootDir, "skills"),
|
|
1606
|
+
mcp: join(rootDir, "mcp"),
|
|
1607
|
+
agents: join(rootDir, "agents"),
|
|
1608
|
+
};
|
|
1609
|
+
|
|
1610
|
+
await ensureDir(targets.skills);
|
|
1611
|
+
await ensureDir(targets.mcp);
|
|
1612
|
+
await ensureDir(targets.agents);
|
|
1613
|
+
|
|
1614
|
+
await consolidateSkills(
|
|
1615
|
+
res,
|
|
1616
|
+
state,
|
|
1617
|
+
{ skills: targets.skills },
|
|
1618
|
+
force,
|
|
1619
|
+
autoMode
|
|
1620
|
+
);
|
|
1621
|
+
await consolidateMcpServers(
|
|
1622
|
+
res,
|
|
1623
|
+
state,
|
|
1624
|
+
{ mcp: targets.mcp },
|
|
1625
|
+
force,
|
|
1626
|
+
autoMode
|
|
1627
|
+
);
|
|
1628
|
+
|
|
1629
|
+
await saveState(home, state);
|
|
1630
|
+
outro(
|
|
1631
|
+
`Consolidation complete. State saved to ${homePath(home, ".facult", "consolidated.json")}`
|
|
1632
|
+
);
|
|
1633
|
+
} catch (err) {
|
|
1634
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1635
|
+
process.exitCode = 1;
|
|
1636
|
+
}
|
|
1637
|
+
}
|