agentloom 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -8
- package/dist/cli.js +5 -1
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +14 -0
- package/dist/commands/mcp.js +2 -9
- package/dist/commands/skills.js +6 -27
- package/dist/commands/sync.d.ts +1 -0
- package/dist/commands/sync.js +82 -11
- package/dist/core/argv.js +3 -7
- package/dist/core/copy.d.ts +1 -0
- package/dist/core/copy.js +19 -3
- package/dist/core/migration.d.ts +28 -0
- package/dist/core/migration.js +809 -0
- package/dist/core/provider-paths.d.ts +16 -0
- package/dist/core/provider-paths.js +154 -0
- package/dist/core/router.d.ts +1 -1
- package/dist/core/router.js +1 -0
- package/dist/core/skills.js +2 -16
- package/dist/core/version-notifier.d.ts +1 -0
- package/dist/core/version-notifier.js +22 -4
- package/dist/sync/index.js +27 -104
- package/dist/types.d.ts +2 -1
- package/dist/types.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isDeepStrictEqual } from "node:util";
|
|
4
|
+
import { cancel, isCancel, select } from "@clack/prompts";
|
|
5
|
+
import TOML from "@iarna/toml";
|
|
6
|
+
import matter from "gray-matter";
|
|
7
|
+
import { buildAgentMarkdown, parseAgentsDir } from "./agents.js";
|
|
8
|
+
import { parseCommandsDir } from "./commands.js";
|
|
9
|
+
import { ensureDir, isObject, readJsonIfExists, slugify } from "./fs.js";
|
|
10
|
+
import { readLockfile, writeLockfile } from "./lockfile.js";
|
|
11
|
+
import { readManifest, writeManifest } from "./manifest.js";
|
|
12
|
+
import { readCanonicalMcp, writeCanonicalMcp } from "./mcp.js";
|
|
13
|
+
import { getClaudeMcpPath, getCodexConfigPath, getCopilotMcpPath, getCursorMcpPath, getGeminiSettingsPath, getOpenCodeConfigPath, getPiMcpPath, getProviderAgentsDir, getProviderCommandsDir, getProviderSkillsPaths, } from "./provider-paths.js";
|
|
14
|
+
import { readSettings, writeSettings } from "./settings.js";
|
|
15
|
+
import { applySkillProviderSideEffects, copySkillArtifacts, parseSkillsDir, skillContentMatchesTarget, } from "./skills.js";
|
|
16
|
+
import { ALL_PROVIDERS } from "../types.js";
|
|
17
|
+
const PROVIDER_NAME_KEYS = new Set(ALL_PROVIDERS);
|
|
18
|
+
export class MigrationConflictError extends Error {
|
|
19
|
+
constructor(message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "MigrationConflictError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function createEmptyMigrationSummary(providers, target) {
|
|
25
|
+
return {
|
|
26
|
+
providers: [...providers],
|
|
27
|
+
target,
|
|
28
|
+
entities: {
|
|
29
|
+
agent: { detected: 0, imported: 0, conflicts: 0, skipped: 0 },
|
|
30
|
+
command: { detected: 0, imported: 0, conflicts: 0, skipped: 0 },
|
|
31
|
+
mcp: { detected: 0, imported: 0, conflicts: 0, skipped: 0 },
|
|
32
|
+
skill: { detected: 0, imported: 0, conflicts: 0, skipped: 0 },
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function initializeCanonicalLayout(paths, providers) {
|
|
37
|
+
ensureDir(paths.agentsRoot);
|
|
38
|
+
ensureDir(paths.agentsDir);
|
|
39
|
+
ensureDir(paths.commandsDir);
|
|
40
|
+
ensureDir(paths.skillsDir);
|
|
41
|
+
if (!fs.existsSync(paths.mcpPath)) {
|
|
42
|
+
writeCanonicalMcp({ mcpPath: paths.mcpPath }, { version: 1, mcpServers: {} });
|
|
43
|
+
}
|
|
44
|
+
if (!fs.existsSync(paths.lockPath)) {
|
|
45
|
+
writeLockfile(paths, {
|
|
46
|
+
version: 1,
|
|
47
|
+
entries: [],
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
// Normalize existing lock format but never create synthetic entries.
|
|
52
|
+
const current = readLockfile(paths);
|
|
53
|
+
writeLockfile(paths, current);
|
|
54
|
+
}
|
|
55
|
+
const existingSettings = readSettings(paths.settingsPath);
|
|
56
|
+
writeSettings(paths.settingsPath, {
|
|
57
|
+
...existingSettings,
|
|
58
|
+
version: 1,
|
|
59
|
+
lastScope: paths.scope,
|
|
60
|
+
defaultProviders: providers && providers.length > 0
|
|
61
|
+
? dedupeProviders(providers)
|
|
62
|
+
: existingSettings.defaultProviders,
|
|
63
|
+
});
|
|
64
|
+
if (!fs.existsSync(paths.manifestPath)) {
|
|
65
|
+
const manifest = {
|
|
66
|
+
version: 1,
|
|
67
|
+
generatedFiles: [],
|
|
68
|
+
generatedByEntity: {},
|
|
69
|
+
};
|
|
70
|
+
writeManifest(paths, manifest);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const manifest = readManifest(paths);
|
|
74
|
+
writeManifest(paths, manifest);
|
|
75
|
+
}
|
|
76
|
+
export async function migrateProviderStateToCanonical(options) {
|
|
77
|
+
const summary = createEmptyMigrationSummary(options.providers, options.target);
|
|
78
|
+
if (includesTarget(options.target, "agent")) {
|
|
79
|
+
await migrateAgents(options, summary.entities.agent);
|
|
80
|
+
}
|
|
81
|
+
if (includesTarget(options.target, "command")) {
|
|
82
|
+
await migrateCommands(options, summary.entities.command);
|
|
83
|
+
}
|
|
84
|
+
if (includesTarget(options.target, "mcp")) {
|
|
85
|
+
await migrateMcp(options, summary.entities.mcp);
|
|
86
|
+
}
|
|
87
|
+
if (includesTarget(options.target, "skill")) {
|
|
88
|
+
await migrateSkills(options, summary.entities.skill);
|
|
89
|
+
}
|
|
90
|
+
return summary;
|
|
91
|
+
}
|
|
92
|
+
export function formatMigrationSummary(summary) {
|
|
93
|
+
const lines = ["Migration summary (provider -> canonical):"];
|
|
94
|
+
for (const entity of ["agent", "command", "mcp", "skill"]) {
|
|
95
|
+
const row = summary.entities[entity];
|
|
96
|
+
lines.push(`${entity}: detected=${row.detected}, imported=${row.imported}, conflicts=${row.conflicts}, skipped=${row.skipped}`);
|
|
97
|
+
}
|
|
98
|
+
return lines.join("\n");
|
|
99
|
+
}
|
|
100
|
+
async function migrateAgents(options, summary) {
|
|
101
|
+
const canonicalAgents = parseAgentsDir(options.paths.agentsDir);
|
|
102
|
+
const canonicalByKey = new Map();
|
|
103
|
+
for (const agent of canonicalAgents) {
|
|
104
|
+
const key = agentKey(agent.name, agent.fileName);
|
|
105
|
+
if (!canonicalByKey.has(key)) {
|
|
106
|
+
canonicalByKey.set(key, agent);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const recordsByKey = new Map();
|
|
110
|
+
for (const provider of options.providers) {
|
|
111
|
+
const records = provider === "codex"
|
|
112
|
+
? readCodexProviderAgents(options.paths)
|
|
113
|
+
: readMarkdownProviderAgents(options.paths, provider);
|
|
114
|
+
summary.detected += records.length;
|
|
115
|
+
for (const record of records) {
|
|
116
|
+
const next = recordsByKey.get(record.key) ?? [];
|
|
117
|
+
next.push(record);
|
|
118
|
+
recordsByKey.set(record.key, next);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
for (const [key, records] of recordsByKey.entries()) {
|
|
122
|
+
if (records.length === 0)
|
|
123
|
+
continue;
|
|
124
|
+
const canonical = canonicalByKey.get(key);
|
|
125
|
+
const existingRaw = canonical
|
|
126
|
+
? fs.readFileSync(canonical.sourcePath, "utf8")
|
|
127
|
+
: null;
|
|
128
|
+
const resolved = await resolveAgentMerge({
|
|
129
|
+
key,
|
|
130
|
+
canonical,
|
|
131
|
+
records,
|
|
132
|
+
paths: options.paths,
|
|
133
|
+
yes: Boolean(options.yes),
|
|
134
|
+
nonInteractive: Boolean(options.nonInteractive),
|
|
135
|
+
onConflict() {
|
|
136
|
+
summary.conflicts += 1;
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
if (!resolved) {
|
|
140
|
+
summary.skipped += 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const changed = existingRaw === null ? true : existingRaw !== resolved.markdown;
|
|
144
|
+
if (!changed) {
|
|
145
|
+
summary.skipped += 1;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (shouldWriteCanonical(options)) {
|
|
149
|
+
ensureDir(options.paths.agentsDir);
|
|
150
|
+
fs.writeFileSync(resolved.outputPath, resolved.markdown, "utf8");
|
|
151
|
+
}
|
|
152
|
+
summary.imported += 1;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function readMarkdownProviderAgents(paths, provider) {
|
|
156
|
+
const dirPath = getProviderAgentsDir(paths, provider);
|
|
157
|
+
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
const entries = fs
|
|
161
|
+
.readdirSync(dirPath, { withFileTypes: true })
|
|
162
|
+
.filter((entry) => entry.isFile())
|
|
163
|
+
.map((entry) => entry.name)
|
|
164
|
+
.filter((name) => name.toLowerCase().endsWith(".md"))
|
|
165
|
+
.sort();
|
|
166
|
+
const records = [];
|
|
167
|
+
for (const fileName of entries) {
|
|
168
|
+
const sourcePath = path.join(dirPath, fileName);
|
|
169
|
+
const raw = fs.readFileSync(sourcePath, "utf8");
|
|
170
|
+
const parsed = matter(raw);
|
|
171
|
+
const data = isObject(parsed.data) ? parsed.data : {};
|
|
172
|
+
const parsedName = typeof data.name === "string" && data.name.trim().length > 0
|
|
173
|
+
? data.name.trim()
|
|
174
|
+
: guessAgentNameFromFile(fileName);
|
|
175
|
+
const parsedDescription = typeof data.description === "string" && data.description.trim().length > 0
|
|
176
|
+
? data.description.trim()
|
|
177
|
+
: `Migrated from ${provider}`;
|
|
178
|
+
const providerConfig = extractProviderConfigFromAgentFrontmatter(data, provider);
|
|
179
|
+
records.push({
|
|
180
|
+
provider,
|
|
181
|
+
sourcePath,
|
|
182
|
+
key: agentKey(parsedName, fileName),
|
|
183
|
+
name: parsedName,
|
|
184
|
+
description: parsedDescription,
|
|
185
|
+
body: parsed.content.trimStart(),
|
|
186
|
+
providerConfig,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return records;
|
|
190
|
+
}
|
|
191
|
+
function readCodexProviderAgents(paths) {
|
|
192
|
+
const configPath = getCodexConfigPath(paths);
|
|
193
|
+
if (!fs.existsSync(configPath) || !fs.statSync(configPath).isFile()) {
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
const raw = fs.readFileSync(configPath, "utf8");
|
|
197
|
+
const parsed = raw.trim() ? TOML.parse(raw) : {};
|
|
198
|
+
const codexRootDir = path.dirname(configPath);
|
|
199
|
+
const agentsTable = isObject(parsed.agents)
|
|
200
|
+
? parsed.agents
|
|
201
|
+
: {};
|
|
202
|
+
const records = [];
|
|
203
|
+
for (const [roleName, roleEntry] of Object.entries(agentsTable)) {
|
|
204
|
+
const roleConfigFile = isObject(roleEntry)
|
|
205
|
+
? roleEntry.config_file
|
|
206
|
+
: undefined;
|
|
207
|
+
if (typeof roleConfigFile !== "string" || roleConfigFile.trim() === "") {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const roleTomlPath = resolveCodexPath(codexRootDir, roleConfigFile);
|
|
211
|
+
if (!fs.existsSync(roleTomlPath) || !fs.statSync(roleTomlPath).isFile()) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const roleTomlRaw = fs.readFileSync(roleTomlPath, "utf8");
|
|
215
|
+
const roleToml = roleTomlRaw.trim()
|
|
216
|
+
? TOML.parse(roleTomlRaw)
|
|
217
|
+
: {};
|
|
218
|
+
const instructionRef = roleToml.model_instructions_file;
|
|
219
|
+
const instructionPath = typeof instructionRef === "string" && instructionRef.trim().length > 0
|
|
220
|
+
? resolveCodexPath(path.dirname(roleTomlPath), instructionRef)
|
|
221
|
+
: null;
|
|
222
|
+
const body = instructionPath && fs.existsSync(instructionPath)
|
|
223
|
+
? fs.readFileSync(instructionPath, "utf8").trimStart()
|
|
224
|
+
: "";
|
|
225
|
+
const description = isObject(roleEntry) && typeof roleEntry.description === "string"
|
|
226
|
+
? roleEntry.description.trim()
|
|
227
|
+
: roleName;
|
|
228
|
+
const providerConfig = {};
|
|
229
|
+
if (typeof roleToml.model === "string") {
|
|
230
|
+
providerConfig.model = roleToml.model;
|
|
231
|
+
}
|
|
232
|
+
if (typeof roleToml.model_reasoning_effort === "string") {
|
|
233
|
+
providerConfig.reasoningEffort = roleToml.model_reasoning_effort;
|
|
234
|
+
}
|
|
235
|
+
if (typeof roleToml.approval_policy === "string") {
|
|
236
|
+
providerConfig.approvalPolicy = roleToml.approval_policy;
|
|
237
|
+
}
|
|
238
|
+
if (typeof roleToml.sandbox_mode === "string") {
|
|
239
|
+
providerConfig.sandboxMode = roleToml.sandbox_mode;
|
|
240
|
+
}
|
|
241
|
+
if (isObject(roleToml.tools) &&
|
|
242
|
+
typeof roleToml.tools.web_search === "boolean") {
|
|
243
|
+
providerConfig.webSearch = roleToml.tools.web_search;
|
|
244
|
+
}
|
|
245
|
+
records.push({
|
|
246
|
+
provider: "codex",
|
|
247
|
+
sourcePath: roleTomlPath,
|
|
248
|
+
key: agentKey(roleName, `${roleName}.md`),
|
|
249
|
+
name: roleName,
|
|
250
|
+
description: description || roleName,
|
|
251
|
+
body,
|
|
252
|
+
providerConfig,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return records;
|
|
256
|
+
}
|
|
257
|
+
function resolveCodexPath(baseDir, referencePath) {
|
|
258
|
+
const trimmed = referencePath.trim();
|
|
259
|
+
if (path.isAbsolute(trimmed))
|
|
260
|
+
return trimmed;
|
|
261
|
+
return path.resolve(baseDir, trimmed);
|
|
262
|
+
}
|
|
263
|
+
function extractProviderConfigFromAgentFrontmatter(data, provider) {
|
|
264
|
+
const explicit = data[provider];
|
|
265
|
+
if (isObject(explicit)) {
|
|
266
|
+
return cloneRecord(explicit);
|
|
267
|
+
}
|
|
268
|
+
const config = {};
|
|
269
|
+
for (const [key, value] of Object.entries(data)) {
|
|
270
|
+
if (key === "name" || key === "description")
|
|
271
|
+
continue;
|
|
272
|
+
if (PROVIDER_NAME_KEYS.has(key))
|
|
273
|
+
continue;
|
|
274
|
+
config[key] = value;
|
|
275
|
+
}
|
|
276
|
+
return config;
|
|
277
|
+
}
|
|
278
|
+
function guessAgentNameFromFile(fileName) {
|
|
279
|
+
const base = fileName.replace(/\.agent\.md$/i, "").replace(/\.md$/i, "");
|
|
280
|
+
return base.trim() || "agent";
|
|
281
|
+
}
|
|
282
|
+
function agentKey(name, fileName) {
|
|
283
|
+
return slugify(name) || slugify(fileName.replace(/\.md$/i, "")) || "agent";
|
|
284
|
+
}
|
|
285
|
+
async function resolveAgentMerge(options) {
|
|
286
|
+
const records = dedupeAgentRecords(options.records);
|
|
287
|
+
if (records.length === 0) {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
let name = options.canonical?.name ?? records[0].name;
|
|
291
|
+
let description = options.canonical?.description ?? records[0].description;
|
|
292
|
+
let body = options.canonical?.body ?? records[0].body;
|
|
293
|
+
const frontmatter = options.canonical
|
|
294
|
+
? { ...options.canonical.frontmatter }
|
|
295
|
+
: {
|
|
296
|
+
name,
|
|
297
|
+
description,
|
|
298
|
+
};
|
|
299
|
+
if (!options.canonical && records.length > 1) {
|
|
300
|
+
const first = records[0];
|
|
301
|
+
const different = records.some((record) => !sameAgentContent(first, record));
|
|
302
|
+
if (different) {
|
|
303
|
+
options.onConflict();
|
|
304
|
+
const chosen = await chooseProviderSource({
|
|
305
|
+
conflictLabel: `agent "${options.key}"`,
|
|
306
|
+
records,
|
|
307
|
+
yes: options.yes,
|
|
308
|
+
nonInteractive: options.nonInteractive,
|
|
309
|
+
});
|
|
310
|
+
name = chosen.name;
|
|
311
|
+
description = chosen.description;
|
|
312
|
+
body = chosen.body;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (options.canonical) {
|
|
316
|
+
for (const record of records) {
|
|
317
|
+
if (!sameAgentContent({ name, description, body }, record)) {
|
|
318
|
+
options.onConflict();
|
|
319
|
+
const decision = await resolveCanonicalConflict({
|
|
320
|
+
conflictLabel: `agent "${record.key}" from ${record.provider}`,
|
|
321
|
+
yes: options.yes,
|
|
322
|
+
nonInteractive: options.nonInteractive,
|
|
323
|
+
});
|
|
324
|
+
if (decision === "provider") {
|
|
325
|
+
name = record.name;
|
|
326
|
+
description = record.description;
|
|
327
|
+
body = record.body;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
for (const record of records) {
|
|
333
|
+
frontmatter[record.provider] = cloneRecord(record.providerConfig);
|
|
334
|
+
}
|
|
335
|
+
frontmatter.name = name;
|
|
336
|
+
frontmatter.description = description;
|
|
337
|
+
const markdown = buildAgentMarkdown(frontmatter, body);
|
|
338
|
+
const outputPath = options.canonical
|
|
339
|
+
? options.canonical.sourcePath
|
|
340
|
+
: path.join(options.paths.agentsDir, `${options.key}.md`);
|
|
341
|
+
return {
|
|
342
|
+
outputPath,
|
|
343
|
+
markdown,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
function dedupeAgentRecords(records) {
|
|
347
|
+
const unique = [];
|
|
348
|
+
for (const record of records) {
|
|
349
|
+
const match = unique.find((item) => item.provider === record.provider &&
|
|
350
|
+
item.sourcePath === record.sourcePath);
|
|
351
|
+
if (!match)
|
|
352
|
+
unique.push(record);
|
|
353
|
+
}
|
|
354
|
+
return unique;
|
|
355
|
+
}
|
|
356
|
+
function sameAgentContent(left, right) {
|
|
357
|
+
return (left.name.trim() === right.name.trim() &&
|
|
358
|
+
left.description.trim() === right.description.trim() &&
|
|
359
|
+
normalizeBody(left.body) === normalizeBody(right.body));
|
|
360
|
+
}
|
|
361
|
+
async function migrateCommands(options, summary) {
|
|
362
|
+
const canonicalCommands = parseCommandsDir(options.paths.commandsDir);
|
|
363
|
+
const canonicalByFile = new Map(canonicalCommands.map((command) => [command.fileName, command]));
|
|
364
|
+
const grouped = new Map();
|
|
365
|
+
for (const provider of options.providers) {
|
|
366
|
+
const records = readProviderCommands(options.paths, provider);
|
|
367
|
+
summary.detected += records.length;
|
|
368
|
+
for (const record of records) {
|
|
369
|
+
const next = grouped.get(record.targetFileName) ?? [];
|
|
370
|
+
next.push(record);
|
|
371
|
+
grouped.set(record.targetFileName, next);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
for (const [fileName, records] of grouped.entries()) {
|
|
375
|
+
const canonical = canonicalByFile.get(fileName);
|
|
376
|
+
let content = canonical?.content ?? records[0].content;
|
|
377
|
+
let hadConflict = false;
|
|
378
|
+
if (!canonical) {
|
|
379
|
+
const allSame = records.every((record) => normalizeBody(record.content) === normalizeBody(records[0].content));
|
|
380
|
+
if (!allSame) {
|
|
381
|
+
hadConflict = true;
|
|
382
|
+
summary.conflicts += 1;
|
|
383
|
+
const chosen = await resolveProviderDuplicateConflict({
|
|
384
|
+
conflictLabel: `command "${fileName}"`,
|
|
385
|
+
records,
|
|
386
|
+
yes: Boolean(options.yes),
|
|
387
|
+
nonInteractive: Boolean(options.nonInteractive),
|
|
388
|
+
});
|
|
389
|
+
content = chosen.content;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
for (const record of records) {
|
|
394
|
+
if (normalizeBody(record.content) === normalizeBody(content))
|
|
395
|
+
continue;
|
|
396
|
+
hadConflict = true;
|
|
397
|
+
summary.conflicts += 1;
|
|
398
|
+
const decision = await resolveCanonicalConflict({
|
|
399
|
+
conflictLabel: `command "${fileName}" from ${record.provider}`,
|
|
400
|
+
yes: Boolean(options.yes),
|
|
401
|
+
nonInteractive: Boolean(options.nonInteractive),
|
|
402
|
+
});
|
|
403
|
+
if (decision === "provider") {
|
|
404
|
+
content = record.content;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
const hasChanged = canonical
|
|
409
|
+
? canonical.content !== content
|
|
410
|
+
: records.length > 0;
|
|
411
|
+
if (!hasChanged) {
|
|
412
|
+
summary.skipped += hadConflict ? 0 : 1;
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (shouldWriteCanonical(options)) {
|
|
416
|
+
ensureDir(options.paths.commandsDir);
|
|
417
|
+
fs.writeFileSync(path.join(options.paths.commandsDir, fileName), content, "utf8");
|
|
418
|
+
}
|
|
419
|
+
summary.imported += 1;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
function readProviderCommands(paths, provider) {
|
|
423
|
+
const commandsDir = getProviderCommandsDir(paths, provider);
|
|
424
|
+
if (!fs.existsSync(commandsDir) || !fs.statSync(commandsDir).isDirectory()) {
|
|
425
|
+
return [];
|
|
426
|
+
}
|
|
427
|
+
const files = parseCommandsDir(commandsDir);
|
|
428
|
+
return files.map((file) => ({
|
|
429
|
+
provider,
|
|
430
|
+
sourcePath: file.sourcePath,
|
|
431
|
+
targetFileName: toCanonicalCommandFileName(file.fileName),
|
|
432
|
+
content: file.content,
|
|
433
|
+
}));
|
|
434
|
+
}
|
|
435
|
+
function toCanonicalCommandFileName(fileName) {
|
|
436
|
+
const lower = fileName.toLowerCase();
|
|
437
|
+
if (lower.endsWith(".prompt.md")) {
|
|
438
|
+
return `${fileName.slice(0, -".prompt.md".length)}.md`;
|
|
439
|
+
}
|
|
440
|
+
if (lower.endsWith(".mdc")) {
|
|
441
|
+
return `${fileName.slice(0, -".mdc".length)}.md`;
|
|
442
|
+
}
|
|
443
|
+
if (lower.endsWith(".md")) {
|
|
444
|
+
return fileName;
|
|
445
|
+
}
|
|
446
|
+
const ext = path.extname(fileName);
|
|
447
|
+
if (ext.length > 0) {
|
|
448
|
+
return `${fileName.slice(0, -ext.length)}.md`;
|
|
449
|
+
}
|
|
450
|
+
return `${fileName}.md`;
|
|
451
|
+
}
|
|
452
|
+
async function migrateMcp(options, summary) {
|
|
453
|
+
const canonical = readCanonicalMcp(options.paths);
|
|
454
|
+
const merged = {
|
|
455
|
+
version: 1,
|
|
456
|
+
mcpServers: cloneRecord(canonical.mcpServers),
|
|
457
|
+
};
|
|
458
|
+
const providerServers = collectProviderMcpServers(options.paths, options.providers);
|
|
459
|
+
summary.detected = providerServers.detected;
|
|
460
|
+
for (const [serverName, byProvider] of providerServers.servers.entries()) {
|
|
461
|
+
const existingServer = merged.mcpServers[serverName];
|
|
462
|
+
const hasExisting = Boolean(existingServer);
|
|
463
|
+
const normalizedExisting = normalizeCanonicalServer(existingServer);
|
|
464
|
+
let base = normalizedExisting.base;
|
|
465
|
+
let providerOverrides = cloneRecord(normalizedExisting.providers);
|
|
466
|
+
if (!hasExisting) {
|
|
467
|
+
const firstProvider = options.providers.find((provider) => byProvider[provider]);
|
|
468
|
+
if (!firstProvider)
|
|
469
|
+
continue;
|
|
470
|
+
base = cloneRecord(byProvider[firstProvider] ?? {});
|
|
471
|
+
providerOverrides = {};
|
|
472
|
+
for (const provider of ALL_PROVIDERS) {
|
|
473
|
+
const config = byProvider[provider];
|
|
474
|
+
if (!config) {
|
|
475
|
+
providerOverrides[provider] = false;
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if (!isDeepStrictEqual(config, base)) {
|
|
479
|
+
providerOverrides[provider] = cloneRecord(config);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
for (const provider of options.providers) {
|
|
485
|
+
const config = byProvider[provider];
|
|
486
|
+
if (!config)
|
|
487
|
+
continue;
|
|
488
|
+
if (isDeepStrictEqual(config, base)) {
|
|
489
|
+
delete providerOverrides[provider];
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
providerOverrides[provider] = cloneRecord(config);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
const nextServer = {
|
|
496
|
+
base,
|
|
497
|
+
};
|
|
498
|
+
if (Object.keys(providerOverrides).length > 0) {
|
|
499
|
+
nextServer.providers = providerOverrides;
|
|
500
|
+
}
|
|
501
|
+
if (isCanonicalServerEqual(existingServer, nextServer)) {
|
|
502
|
+
summary.skipped += 1;
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
merged.mcpServers[serverName] = nextServer;
|
|
506
|
+
summary.imported += 1;
|
|
507
|
+
}
|
|
508
|
+
if (summary.imported > 0 && shouldWriteCanonical(options)) {
|
|
509
|
+
writeCanonicalMcp(options.paths, merged);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
function collectProviderMcpServers(paths, providers) {
|
|
513
|
+
let detected = 0;
|
|
514
|
+
const servers = new Map();
|
|
515
|
+
for (const provider of providers) {
|
|
516
|
+
const providerServers = readProviderMcp(paths, provider);
|
|
517
|
+
for (const [name, config] of Object.entries(providerServers)) {
|
|
518
|
+
detected += 1;
|
|
519
|
+
const current = servers.get(name) ?? {};
|
|
520
|
+
current[provider] = cloneRecord(config);
|
|
521
|
+
servers.set(name, current);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return { detected, servers };
|
|
525
|
+
}
|
|
526
|
+
function readProviderMcp(paths, provider) {
|
|
527
|
+
if (provider === "cursor") {
|
|
528
|
+
return readJsonMcpServers(getCursorMcpPath(paths));
|
|
529
|
+
}
|
|
530
|
+
if (provider === "claude") {
|
|
531
|
+
return readJsonMcpServers(getClaudeMcpPath(paths));
|
|
532
|
+
}
|
|
533
|
+
if (provider === "copilot") {
|
|
534
|
+
return readJsonMcpServers(getCopilotMcpPath(paths));
|
|
535
|
+
}
|
|
536
|
+
if (provider === "opencode") {
|
|
537
|
+
return readOpenCodeMcp(getOpenCodeConfigPath(paths));
|
|
538
|
+
}
|
|
539
|
+
if (provider === "gemini") {
|
|
540
|
+
return readGeminiMcp(getGeminiSettingsPath(paths));
|
|
541
|
+
}
|
|
542
|
+
if (provider === "codex") {
|
|
543
|
+
return readCodexMcp(getCodexConfigPath(paths));
|
|
544
|
+
}
|
|
545
|
+
if (provider === "pi") {
|
|
546
|
+
return readJsonMcpServers(getPiMcpPath(paths));
|
|
547
|
+
}
|
|
548
|
+
return {};
|
|
549
|
+
}
|
|
550
|
+
function readJsonMcpServers(filePath) {
|
|
551
|
+
const parsed = readJsonIfExists(filePath);
|
|
552
|
+
if (!parsed || !isObject(parsed.mcpServers)) {
|
|
553
|
+
return {};
|
|
554
|
+
}
|
|
555
|
+
return normalizeMcpServerRecord(parsed.mcpServers);
|
|
556
|
+
}
|
|
557
|
+
function readOpenCodeMcp(filePath) {
|
|
558
|
+
const parsed = readJsonIfExists(filePath);
|
|
559
|
+
if (!parsed || !isObject(parsed.mcp))
|
|
560
|
+
return {};
|
|
561
|
+
const mapped = {};
|
|
562
|
+
for (const [name, config] of Object.entries(parsed.mcp)) {
|
|
563
|
+
if (!isObject(config))
|
|
564
|
+
continue;
|
|
565
|
+
const next = {};
|
|
566
|
+
if (typeof config.url === "string")
|
|
567
|
+
next.url = config.url;
|
|
568
|
+
if (typeof config.command === "string")
|
|
569
|
+
next.command = config.command;
|
|
570
|
+
if (Array.isArray(config.args))
|
|
571
|
+
next.args = [...config.args];
|
|
572
|
+
if (isObject(config.environment)) {
|
|
573
|
+
next.env = cloneRecord(config.environment);
|
|
574
|
+
}
|
|
575
|
+
if (Object.keys(next).length > 0) {
|
|
576
|
+
mapped[name] = next;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return mapped;
|
|
580
|
+
}
|
|
581
|
+
function readGeminiMcp(filePath) {
|
|
582
|
+
const parsed = readJsonIfExists(filePath);
|
|
583
|
+
if (!parsed || !isObject(parsed.mcpServers))
|
|
584
|
+
return {};
|
|
585
|
+
const mapped = {};
|
|
586
|
+
for (const [name, config] of Object.entries(parsed.mcpServers)) {
|
|
587
|
+
if (!isObject(config))
|
|
588
|
+
continue;
|
|
589
|
+
const next = {};
|
|
590
|
+
if (typeof config.httpUrl === "string")
|
|
591
|
+
next.url = config.httpUrl;
|
|
592
|
+
if (typeof config.command === "string")
|
|
593
|
+
next.command = config.command;
|
|
594
|
+
if (Array.isArray(config.args))
|
|
595
|
+
next.args = [...config.args];
|
|
596
|
+
if (isObject(config.env))
|
|
597
|
+
next.env = cloneRecord(config.env);
|
|
598
|
+
if (Object.keys(next).length > 0) {
|
|
599
|
+
mapped[name] = next;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return mapped;
|
|
603
|
+
}
|
|
604
|
+
function readCodexMcp(filePath) {
|
|
605
|
+
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile())
|
|
606
|
+
return {};
|
|
607
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
608
|
+
const parsed = raw.trim() ? TOML.parse(raw) : {};
|
|
609
|
+
if (!isObject(parsed.mcp_servers))
|
|
610
|
+
return {};
|
|
611
|
+
return normalizeMcpServerRecord(parsed.mcp_servers);
|
|
612
|
+
}
|
|
613
|
+
function normalizeMcpServerRecord(raw) {
|
|
614
|
+
const servers = {};
|
|
615
|
+
for (const [name, config] of Object.entries(raw)) {
|
|
616
|
+
if (!isObject(config))
|
|
617
|
+
continue;
|
|
618
|
+
servers[name] = cloneRecord(config);
|
|
619
|
+
}
|
|
620
|
+
return servers;
|
|
621
|
+
}
|
|
622
|
+
function normalizeCanonicalServer(server) {
|
|
623
|
+
if (!server) {
|
|
624
|
+
return {
|
|
625
|
+
base: {},
|
|
626
|
+
providers: {},
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
const base = isObject(server.base)
|
|
630
|
+
? cloneRecord(server.base)
|
|
631
|
+
: {};
|
|
632
|
+
for (const [key, value] of Object.entries(server)) {
|
|
633
|
+
if (key === "base" || key === "providers")
|
|
634
|
+
continue;
|
|
635
|
+
base[key] = value;
|
|
636
|
+
}
|
|
637
|
+
const providers = isObject(server.providers)
|
|
638
|
+
? cloneRecord(server.providers)
|
|
639
|
+
: {};
|
|
640
|
+
return { base, providers };
|
|
641
|
+
}
|
|
642
|
+
function isCanonicalServerEqual(left, right) {
|
|
643
|
+
if (!left)
|
|
644
|
+
return false;
|
|
645
|
+
return isDeepStrictEqual(normalizeCanonicalServer(left), normalizeCanonicalServer(right));
|
|
646
|
+
}
|
|
647
|
+
async function migrateSkills(options, summary) {
|
|
648
|
+
const providerSkillDirs = getProviderSkillsPaths(options.paths, options.providers)
|
|
649
|
+
.filter((dirPath) => fs.existsSync(dirPath))
|
|
650
|
+
.filter((dirPath) => {
|
|
651
|
+
try {
|
|
652
|
+
return fs.lstatSync(dirPath).isDirectory();
|
|
653
|
+
}
|
|
654
|
+
catch {
|
|
655
|
+
return false;
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
for (const providerSkillsDir of providerSkillDirs) {
|
|
659
|
+
const providerLabel = providerSkillsDir.includes(`${path.sep}.cursor${path.sep}`)
|
|
660
|
+
? "cursor"
|
|
661
|
+
: providerSkillsDir.includes(`${path.sep}.pi${path.sep}`)
|
|
662
|
+
? "pi"
|
|
663
|
+
: "claude";
|
|
664
|
+
const skills = parseSkillsDir(providerSkillsDir);
|
|
665
|
+
summary.detected += skills.length;
|
|
666
|
+
for (const skill of skills) {
|
|
667
|
+
const targetName = skill.layout === "nested"
|
|
668
|
+
? path.basename(skill.sourcePath)
|
|
669
|
+
: slugify(skill.name) || "skill";
|
|
670
|
+
const targetDir = path.join(options.paths.skillsDir, targetName);
|
|
671
|
+
if (!fs.existsSync(targetDir)) {
|
|
672
|
+
if (shouldWriteCanonical(options)) {
|
|
673
|
+
ensureDir(options.paths.skillsDir);
|
|
674
|
+
copySkillArtifacts(skill, targetDir);
|
|
675
|
+
}
|
|
676
|
+
summary.imported += 1;
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
if (!fs.statSync(targetDir).isDirectory()) {
|
|
680
|
+
summary.conflicts += 1;
|
|
681
|
+
const decision = await resolveCanonicalConflict({
|
|
682
|
+
conflictLabel: `skill "${targetName}" from ${providerLabel}`,
|
|
683
|
+
yes: Boolean(options.yes),
|
|
684
|
+
nonInteractive: Boolean(options.nonInteractive),
|
|
685
|
+
});
|
|
686
|
+
if (decision === "canonical") {
|
|
687
|
+
summary.skipped += 1;
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
if (shouldWriteCanonical(options)) {
|
|
691
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
692
|
+
ensureDir(options.paths.skillsDir);
|
|
693
|
+
copySkillArtifacts(skill, targetDir);
|
|
694
|
+
}
|
|
695
|
+
summary.imported += 1;
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
if (skillContentMatchesTarget(skill, targetDir)) {
|
|
699
|
+
summary.skipped += 1;
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
summary.conflicts += 1;
|
|
703
|
+
const decision = await resolveCanonicalConflict({
|
|
704
|
+
conflictLabel: `skill "${targetName}" from ${providerLabel}`,
|
|
705
|
+
yes: Boolean(options.yes),
|
|
706
|
+
nonInteractive: Boolean(options.nonInteractive),
|
|
707
|
+
});
|
|
708
|
+
if (decision === "canonical") {
|
|
709
|
+
summary.skipped += 1;
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
if (shouldWriteCanonical(options)) {
|
|
713
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
714
|
+
copySkillArtifacts(skill, targetDir);
|
|
715
|
+
}
|
|
716
|
+
summary.imported += 1;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
applySkillProviderSideEffects({
|
|
720
|
+
paths: options.paths,
|
|
721
|
+
providers: options.providers,
|
|
722
|
+
dryRun: Boolean(options.dryRun),
|
|
723
|
+
warn(message) {
|
|
724
|
+
// Keep migration non-fatal for provider-side cleanup path noise.
|
|
725
|
+
console.warn(`Warning: ${message}`);
|
|
726
|
+
},
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
async function resolveCanonicalConflict(options) {
|
|
730
|
+
if (options.yes || options.nonInteractive) {
|
|
731
|
+
throw new MigrationConflictError(`Migration conflict for ${options.conflictLabel}.\nRun without --yes in an interactive terminal to choose between canonical and provider content.`);
|
|
732
|
+
}
|
|
733
|
+
const choice = await select({
|
|
734
|
+
message: `Conflict for ${options.conflictLabel}`,
|
|
735
|
+
options: [
|
|
736
|
+
{ value: "canonical", label: "Keep canonical version" },
|
|
737
|
+
{ value: "provider", label: "Use provider version" },
|
|
738
|
+
],
|
|
739
|
+
initialValue: "canonical",
|
|
740
|
+
});
|
|
741
|
+
if (isCancel(choice)) {
|
|
742
|
+
cancel("Operation cancelled.");
|
|
743
|
+
process.exit(1);
|
|
744
|
+
}
|
|
745
|
+
return choice === "provider" ? "provider" : "canonical";
|
|
746
|
+
}
|
|
747
|
+
async function resolveProviderDuplicateConflict(options) {
|
|
748
|
+
if (options.yes || options.nonInteractive) {
|
|
749
|
+
throw new MigrationConflictError(`Migration conflict for ${options.conflictLabel} across multiple providers.\nRun without --yes in an interactive terminal to select a source provider.`);
|
|
750
|
+
}
|
|
751
|
+
const choice = await select({
|
|
752
|
+
message: `Duplicate provider content for ${options.conflictLabel}`,
|
|
753
|
+
options: options.records.map((record) => ({
|
|
754
|
+
value: record.sourcePath,
|
|
755
|
+
label: `${record.provider}: ${path.basename(record.sourcePath)}`,
|
|
756
|
+
})),
|
|
757
|
+
});
|
|
758
|
+
if (isCancel(choice)) {
|
|
759
|
+
cancel("Operation cancelled.");
|
|
760
|
+
process.exit(1);
|
|
761
|
+
}
|
|
762
|
+
const selected = options.records.find((record) => record.sourcePath === choice);
|
|
763
|
+
if (!selected) {
|
|
764
|
+
return options.records[0];
|
|
765
|
+
}
|
|
766
|
+
return selected;
|
|
767
|
+
}
|
|
768
|
+
async function chooseProviderSource(options) {
|
|
769
|
+
if (options.yes || options.nonInteractive) {
|
|
770
|
+
throw new MigrationConflictError(`Migration conflict for ${options.conflictLabel} across multiple providers.\nRun without --yes in an interactive terminal to select a source provider.`);
|
|
771
|
+
}
|
|
772
|
+
const choice = await select({
|
|
773
|
+
message: `Duplicate provider content for ${options.conflictLabel}`,
|
|
774
|
+
options: options.records.map((record) => ({
|
|
775
|
+
value: record.sourcePath,
|
|
776
|
+
label: `${record.provider}: ${path.basename(record.sourcePath)}`,
|
|
777
|
+
})),
|
|
778
|
+
});
|
|
779
|
+
if (isCancel(choice)) {
|
|
780
|
+
cancel("Operation cancelled.");
|
|
781
|
+
process.exit(1);
|
|
782
|
+
}
|
|
783
|
+
const selected = options.records.find((record) => record.sourcePath === choice);
|
|
784
|
+
if (!selected) {
|
|
785
|
+
return options.records[0];
|
|
786
|
+
}
|
|
787
|
+
return selected;
|
|
788
|
+
}
|
|
789
|
+
function includesTarget(target, entity) {
|
|
790
|
+
return target === "all" || target === entity;
|
|
791
|
+
}
|
|
792
|
+
function normalizeBody(value) {
|
|
793
|
+
return value.trim().replace(/\r\n/g, "\n");
|
|
794
|
+
}
|
|
795
|
+
function cloneRecord(value) {
|
|
796
|
+
return JSON.parse(JSON.stringify(value));
|
|
797
|
+
}
|
|
798
|
+
function dedupeProviders(providers) {
|
|
799
|
+
const seen = new Set();
|
|
800
|
+
for (const provider of providers) {
|
|
801
|
+
if (ALL_PROVIDERS.includes(provider)) {
|
|
802
|
+
seen.add(provider);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return [...seen];
|
|
806
|
+
}
|
|
807
|
+
function shouldWriteCanonical(options) {
|
|
808
|
+
return !options.dryRun || Boolean(options.materializeCanonical);
|
|
809
|
+
}
|