agentloom 0.1.5 → 0.1.7

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.
@@ -4,15 +4,18 @@ import { isDeepStrictEqual } from "node:util";
4
4
  import { cancel, isCancel, select } from "@clack/prompts";
5
5
  import TOML from "@iarna/toml";
6
6
  import matter from "gray-matter";
7
+ import YAML from "yaml";
7
8
  import { buildAgentMarkdown, parseAgentsDir } from "./agents.js";
8
- import { parseCommandsDir } from "./commands.js";
9
+ import { normalizeCommandArgumentsForCanonical, parseCommandsDir, } from "./commands.js";
9
10
  import { ensureDir, isObject, readJsonIfExists, slugify } from "./fs.js";
10
11
  import { readLockfile, writeLockfile } from "./lockfile.js";
11
12
  import { readManifest, writeManifest } from "./manifest.js";
12
13
  import { readCanonicalMcp, writeCanonicalMcp } from "./mcp.js";
13
- import { getClaudeMcpPath, getCodexConfigPath, getCopilotMcpPath, getCursorMcpPath, getGeminiSettingsPath, getOpenCodeConfigPath, getPiMcpPath, getProviderAgentsDir, getProviderCommandsDir, getProviderSkillsPaths, } from "./provider-paths.js";
14
+ import { getClaudeMcpPath, getCodexConfigPath, getCopilotMcpPath, getCursorRulesDir, getCursorMcpPath, getGeminiSettingsPath, getOpenCodeConfigPath, getPiMcpPath, getProviderAgentsDir, getProviderCommandsDir, getRuleInstructionPaths, getProviderSkillsPaths, } from "./provider-paths.js";
14
15
  import { readSettings, writeSettings } from "./settings.js";
15
- import { applySkillProviderSideEffects, copySkillArtifacts, parseSkillsDir, skillContentMatchesTarget, } from "./skills.js";
16
+ import { applySkillProviderSideEffects, copySkillArtifacts, getLegacyCopilotSkillDirs, parseSkillsDir, skillContentMatchesTarget, } from "./skills.js";
17
+ import { parseManagedRuleBlocks, parseRuleMarkdown, stripRuleFileExtension, } from "./rules.js";
18
+ import { isProviderEntityFileName } from "./provider-entity-validation.js";
16
19
  import { ALL_PROVIDERS } from "../types.js";
17
20
  const PROVIDER_NAME_KEYS = new Set(ALL_PROVIDERS);
18
21
  export class MigrationConflictError extends Error {
@@ -29,6 +32,7 @@ export function createEmptyMigrationSummary(providers, target) {
29
32
  agent: { detected: 0, imported: 0, conflicts: 0, skipped: 0 },
30
33
  command: { detected: 0, imported: 0, conflicts: 0, skipped: 0 },
31
34
  mcp: { detected: 0, imported: 0, conflicts: 0, skipped: 0 },
35
+ rule: { detected: 0, imported: 0, conflicts: 0, skipped: 0 },
32
36
  skill: { detected: 0, imported: 0, conflicts: 0, skipped: 0 },
33
37
  },
34
38
  };
@@ -37,6 +41,7 @@ export function initializeCanonicalLayout(paths, providers) {
37
41
  ensureDir(paths.agentsRoot);
38
42
  ensureDir(paths.agentsDir);
39
43
  ensureDir(paths.commandsDir);
44
+ ensureDir(paths.rulesDir);
40
45
  ensureDir(paths.skillsDir);
41
46
  if (!fs.existsSync(paths.mcpPath)) {
42
47
  writeCanonicalMcp({ mcpPath: paths.mcpPath }, { version: 1, mcpServers: {} });
@@ -84,6 +89,9 @@ export async function migrateProviderStateToCanonical(options) {
84
89
  if (includesTarget(options.target, "mcp")) {
85
90
  await migrateMcp(options, summary.entities.mcp);
86
91
  }
92
+ if (includesTarget(options.target, "rule")) {
93
+ await migrateRules(options, summary.entities.rule);
94
+ }
87
95
  if (includesTarget(options.target, "skill")) {
88
96
  await migrateSkills(options, summary.entities.skill);
89
97
  }
@@ -91,7 +99,7 @@ export async function migrateProviderStateToCanonical(options) {
91
99
  }
92
100
  export function formatMigrationSummary(summary) {
93
101
  const lines = ["Migration summary (provider -> canonical):"];
94
- for (const entity of ["agent", "command", "mcp", "skill"]) {
102
+ for (const entity of ["agent", "command", "mcp", "rule", "skill"]) {
95
103
  const row = summary.entities[entity];
96
104
  lines.push(`${entity}: detected=${row.detected}, imported=${row.imported}, conflicts=${row.conflicts}, skipped=${row.skipped}`);
97
105
  }
@@ -102,8 +110,13 @@ async function migrateAgents(options, summary) {
102
110
  const canonicalByKey = new Map();
103
111
  for (const agent of canonicalAgents) {
104
112
  const key = agentKey(agent.name, agent.fileName);
105
- if (!canonicalByKey.has(key)) {
106
- canonicalByKey.set(key, agent);
113
+ // Always let file-derived keys win over name aliases from earlier entries.
114
+ canonicalByKey.set(key, agent);
115
+ }
116
+ for (const agent of canonicalAgents) {
117
+ const nameKey = slugify(agent.name);
118
+ if (nameKey && !canonicalByKey.has(nameKey)) {
119
+ canonicalByKey.set(nameKey, agent);
107
120
  }
108
121
  }
109
122
  const recordsByKey = new Map();
@@ -118,10 +131,11 @@ async function migrateAgents(options, summary) {
118
131
  recordsByKey.set(record.key, next);
119
132
  }
120
133
  }
134
+ mergeAgentRecordsByName(recordsByKey);
121
135
  for (const [key, records] of recordsByKey.entries()) {
122
136
  if (records.length === 0)
123
137
  continue;
124
- const canonical = canonicalByKey.get(key);
138
+ const canonical = resolveCanonicalAgentForMerge(canonicalByKey, key, records);
125
139
  const existingRaw = canonical
126
140
  ? fs.readFileSync(canonical.sourcePath, "utf8")
127
141
  : null;
@@ -152,41 +166,192 @@ async function migrateAgents(options, summary) {
152
166
  summary.imported += 1;
153
167
  }
154
168
  }
169
+ function mergeAgentRecordsByName(recordsByKey) {
170
+ const groupKeyByName = new Map();
171
+ for (const [key, records] of [...recordsByKey.entries()]) {
172
+ if (!recordsByKey.has(key))
173
+ continue;
174
+ let targetKey = key;
175
+ for (const record of records) {
176
+ const nameKey = slugify(record.name);
177
+ if (!nameKey)
178
+ continue;
179
+ const existingKey = groupKeyByName.get(nameKey);
180
+ if (!existingKey || existingKey === targetKey) {
181
+ groupKeyByName.set(nameKey, targetKey);
182
+ continue;
183
+ }
184
+ const existing = recordsByKey.get(existingKey) ?? [];
185
+ const current = recordsByKey.get(targetKey) ?? [];
186
+ existing.push(...current);
187
+ recordsByKey.set(existingKey, existing);
188
+ recordsByKey.delete(targetKey);
189
+ targetKey = existingKey;
190
+ targetKey = rekeyMergedAgentGroup(recordsByKey, targetKey, nameKey);
191
+ groupKeyByName.set(nameKey, targetKey);
192
+ }
193
+ const merged = recordsByKey.get(targetKey) ?? [];
194
+ for (const record of merged) {
195
+ const nameKey = slugify(record.name);
196
+ if (nameKey) {
197
+ groupKeyByName.set(nameKey, targetKey);
198
+ }
199
+ }
200
+ }
201
+ }
202
+ function rekeyMergedAgentGroup(recordsByKey, currentKey, nameKey) {
203
+ if (!nameKey || currentKey === nameKey) {
204
+ return currentKey;
205
+ }
206
+ const current = recordsByKey.get(currentKey);
207
+ if (!current) {
208
+ return currentKey;
209
+ }
210
+ const existingNameKeyGroup = recordsByKey.get(nameKey);
211
+ if (existingNameKeyGroup && existingNameKeyGroup !== current) {
212
+ existingNameKeyGroup.push(...current);
213
+ recordsByKey.set(nameKey, existingNameKeyGroup);
214
+ }
215
+ else {
216
+ recordsByKey.set(nameKey, current);
217
+ }
218
+ recordsByKey.delete(currentKey);
219
+ return nameKey;
220
+ }
221
+ function resolveCanonicalAgentForMerge(canonicalByKey, key, records) {
222
+ const direct = canonicalByKey.get(key);
223
+ if (direct) {
224
+ return direct;
225
+ }
226
+ for (const record of records) {
227
+ const nameKey = slugify(record.name);
228
+ if (!nameKey)
229
+ continue;
230
+ const match = canonicalByKey.get(nameKey);
231
+ if (match) {
232
+ return match;
233
+ }
234
+ }
235
+ return undefined;
236
+ }
155
237
  function readMarkdownProviderAgents(paths, provider) {
156
- const dirPath = getProviderAgentsDir(paths, provider);
238
+ const records = [];
239
+ const seenSourcePaths = new Set();
240
+ const candidateDirs = [
241
+ {
242
+ path: getProviderAgentsDir(paths, provider),
243
+ sourcePriority: 0,
244
+ fallback: false,
245
+ },
246
+ ];
247
+ // Keep backward compatibility with historical global Copilot output.
248
+ if (provider === "copilot" && paths.scope === "global") {
249
+ candidateDirs.push({
250
+ path: path.join(paths.homeDir, ".github", "agents"),
251
+ sourcePriority: 1,
252
+ fallback: true,
253
+ });
254
+ candidateDirs.push({
255
+ path: path.join(paths.homeDir, ".vscode", "chatmodes"),
256
+ sourcePriority: 2,
257
+ fallback: true,
258
+ });
259
+ }
260
+ const entriesByPath = new Map();
261
+ for (const candidateDir of candidateDirs) {
262
+ entriesByPath.set(candidateDir.path, listProviderAgentEntries(candidateDir.path, provider));
263
+ }
264
+ for (const candidateDir of candidateDirs) {
265
+ const dirPath = candidateDir.path;
266
+ const entries = entriesByPath.get(dirPath) ?? [];
267
+ for (const fileName of entries) {
268
+ const sourcePath = path.join(dirPath, fileName);
269
+ if (seenSourcePaths.has(sourcePath))
270
+ continue;
271
+ seenSourcePaths.add(sourcePath);
272
+ const raw = fs.readFileSync(sourcePath, "utf8");
273
+ const parsed = matter(raw);
274
+ const data = isObject(parsed.data) ? parsed.data : {};
275
+ const parsedName = typeof data.name === "string" && data.name.trim().length > 0
276
+ ? data.name.trim()
277
+ : guessAgentNameFromFile(fileName);
278
+ const parsedDescription = typeof data.description === "string" &&
279
+ data.description.trim().length > 0
280
+ ? data.description.trim()
281
+ : `Migrated from ${provider}`;
282
+ const nameKey = providerAgentNameKey(parsedName, fileName);
283
+ const key = agentKey(parsedName, fileName);
284
+ const providerConfig = extractProviderConfigFromAgentFrontmatter(data, provider);
285
+ records.push({
286
+ provider,
287
+ sourcePath,
288
+ fileName,
289
+ sourcePriority: candidateDir.sourcePriority,
290
+ nameKey,
291
+ key,
292
+ name: parsedName,
293
+ description: parsedDescription,
294
+ body: parsed.content.trimStart(),
295
+ providerConfig,
296
+ });
297
+ }
298
+ }
299
+ return dedupeProviderAgentRecords(records);
300
+ }
301
+ function listProviderAgentEntries(dirPath, provider) {
157
302
  if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
158
303
  return [];
159
304
  }
160
- const entries = fs
305
+ return fs
161
306
  .readdirSync(dirPath, { withFileTypes: true })
162
307
  .filter((entry) => entry.isFile())
163
308
  .map((entry) => entry.name)
164
- .filter((name) => name.toLowerCase().endsWith(".md"))
309
+ .filter((name) => isProviderEntityFileName({
310
+ provider,
311
+ entity: "agent",
312
+ fileName: name,
313
+ }))
165
314
  .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
- });
315
+ }
316
+ function providerAgentNameKey(name, fileName) {
317
+ return slugify(name) || agentFileKey(fileName);
318
+ }
319
+ function dedupeProviderAgentRecords(records) {
320
+ const recordsByName = new Map();
321
+ for (const record of records) {
322
+ const next = recordsByName.get(record.nameKey) ?? [];
323
+ next.push(record);
324
+ recordsByName.set(record.nameKey, next);
188
325
  }
189
- return records;
326
+ const deduped = [];
327
+ for (const [nameKey, bucket] of recordsByName.entries()) {
328
+ if (bucket.length === 1) {
329
+ deduped.push(bucket[0]);
330
+ continue;
331
+ }
332
+ const ranked = [...bucket].sort(compareProviderAgentCollisionPriority);
333
+ const winner = ranked[0];
334
+ const ignored = ranked.slice(1).map((record) => record.sourcePath);
335
+ console.warn(`Warning: Duplicate ${winner.provider} agents share canonical name "${nameKey}". Keeping ${winner.sourcePath} and ignoring ${ignored.join(", ")}.`);
336
+ deduped.push(winner);
337
+ }
338
+ return deduped.sort((a, b) => a.key.localeCompare(b.key) || a.sourcePath.localeCompare(b.sourcePath));
339
+ }
340
+ function compareProviderAgentCollisionPriority(left, right) {
341
+ if (left.sourcePriority !== right.sourcePriority) {
342
+ return left.sourcePriority - right.sourcePriority;
343
+ }
344
+ const leftFileMatchesName = isProviderAgentFileAlignedWithName(left) ? 0 : 1;
345
+ const rightFileMatchesName = isProviderAgentFileAlignedWithName(right)
346
+ ? 0
347
+ : 1;
348
+ if (leftFileMatchesName !== rightFileMatchesName) {
349
+ return leftFileMatchesName - rightFileMatchesName;
350
+ }
351
+ return left.sourcePath.localeCompare(right.sourcePath);
352
+ }
353
+ function isProviderAgentFileAlignedWithName(record) {
354
+ return agentFileKey(record.fileName) === record.nameKey;
190
355
  }
191
356
  function readCodexProviderAgents(paths) {
192
357
  const configPath = getCodexConfigPath(paths);
@@ -215,13 +380,17 @@ function readCodexProviderAgents(paths) {
215
380
  const roleToml = roleTomlRaw.trim()
216
381
  ? TOML.parse(roleTomlRaw)
217
382
  : {};
383
+ const inlineInstructions = typeof roleToml.developer_instructions === "string"
384
+ ? roleToml.developer_instructions.trim()
385
+ : "";
218
386
  const instructionRef = roleToml.model_instructions_file;
219
387
  const instructionPath = typeof instructionRef === "string" && instructionRef.trim().length > 0
220
388
  ? resolveCodexPath(path.dirname(roleTomlPath), instructionRef)
221
389
  : null;
222
- const body = instructionPath && fs.existsSync(instructionPath)
223
- ? fs.readFileSync(instructionPath, "utf8").trimStart()
390
+ const fileInstructions = instructionPath && fs.existsSync(instructionPath)
391
+ ? fs.readFileSync(instructionPath, "utf8").trimStart().trimEnd()
224
392
  : "";
393
+ const body = inlineInstructions || fileInstructions;
225
394
  const description = isObject(roleEntry) && typeof roleEntry.description === "string"
226
395
  ? roleEntry.description.trim()
227
396
  : roleName;
@@ -232,19 +401,32 @@ function readCodexProviderAgents(paths) {
232
401
  if (typeof roleToml.model_reasoning_effort === "string") {
233
402
  providerConfig.reasoningEffort = roleToml.model_reasoning_effort;
234
403
  }
404
+ if (typeof roleToml.model_reasoning_summary === "string") {
405
+ providerConfig.reasoningSummary = roleToml.model_reasoning_summary;
406
+ }
407
+ if (typeof roleToml.model_verbosity === "string") {
408
+ providerConfig.verbosity = roleToml.model_verbosity;
409
+ }
235
410
  if (typeof roleToml.approval_policy === "string") {
236
411
  providerConfig.approvalPolicy = roleToml.approval_policy;
237
412
  }
238
413
  if (typeof roleToml.sandbox_mode === "string") {
239
414
  providerConfig.sandboxMode = roleToml.sandbox_mode;
240
415
  }
241
- if (isObject(roleToml.tools) &&
416
+ if (typeof roleToml.web_search === "boolean") {
417
+ providerConfig.webSearch = roleToml.web_search;
418
+ }
419
+ if (typeof providerConfig.webSearch !== "boolean" &&
420
+ isObject(roleToml.tools) &&
242
421
  typeof roleToml.tools.web_search === "boolean") {
243
422
  providerConfig.webSearch = roleToml.tools.web_search;
244
423
  }
245
424
  records.push({
246
425
  provider: "codex",
247
426
  sourcePath: roleTomlPath,
427
+ fileName: `${roleName}.md`,
428
+ sourcePriority: 0,
429
+ nameKey: providerAgentNameKey(roleName, `${roleName}.md`),
248
430
  key: agentKey(roleName, `${roleName}.md`),
249
431
  name: roleName,
250
432
  description: description || roleName,
@@ -279,17 +461,24 @@ function guessAgentNameFromFile(fileName) {
279
461
  const base = fileName.replace(/\.agent\.md$/i, "").replace(/\.md$/i, "");
280
462
  return base.trim() || "agent";
281
463
  }
464
+ function agentFileKey(fileName) {
465
+ const normalizedFile = fileName
466
+ .replace(/\.agent\.md$/i, "")
467
+ .replace(/\.md$/i, "");
468
+ return slugify(normalizedFile);
469
+ }
282
470
  function agentKey(name, fileName) {
283
- return slugify(name) || slugify(fileName.replace(/\.md$/i, "")) || "agent";
471
+ return agentFileKey(fileName) || slugify(name) || "agent";
284
472
  }
285
473
  async function resolveAgentMerge(options) {
286
474
  const records = dedupeAgentRecords(options.records);
287
475
  if (records.length === 0) {
288
476
  return null;
289
477
  }
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;
478
+ const preferredRecord = choosePreferredProviderAgent(records);
479
+ let name = options.canonical?.name ?? preferredRecord.name;
480
+ let description = options.canonical?.description ?? preferredRecord.description;
481
+ let body = options.canonical?.body ?? preferredRecord.body;
293
482
  const frontmatter = options.canonical
294
483
  ? { ...options.canonical.frontmatter }
295
484
  : {
@@ -297,9 +486,8 @@ async function resolveAgentMerge(options) {
297
486
  description,
298
487
  };
299
488
  if (!options.canonical && records.length > 1) {
300
- const first = records[0];
301
- const different = records.some((record) => !sameAgentContent(first, record));
302
- if (different) {
489
+ const allSameBody = records.every((record) => sameNormalizedBody(record.body, records[0].body));
490
+ if (!allSameBody) {
303
491
  options.onConflict();
304
492
  const chosen = await chooseProviderSource({
305
493
  conflictLabel: `agent "${options.key}"`,
@@ -314,7 +502,7 @@ async function resolveAgentMerge(options) {
314
502
  }
315
503
  if (options.canonical) {
316
504
  for (const record of records) {
317
- if (!sameAgentContent({ name, description, body }, record)) {
505
+ if (!sameNormalizedBody(record.body, body)) {
318
506
  options.onConflict();
319
507
  const decision = await resolveCanonicalConflict({
320
508
  conflictLabel: `agent "${record.key}" from ${record.provider}`,
@@ -330,7 +518,14 @@ async function resolveAgentMerge(options) {
330
518
  }
331
519
  }
332
520
  for (const record of records) {
333
- frontmatter[record.provider] = cloneRecord(record.providerConfig);
521
+ const providerConfig = cloneRecord(record.providerConfig);
522
+ if (record.name.trim() !== name.trim()) {
523
+ providerConfig.name = record.name;
524
+ }
525
+ if (record.description.trim() !== description.trim()) {
526
+ providerConfig.description = record.description;
527
+ }
528
+ frontmatter[record.provider] = providerConfig;
334
529
  }
335
530
  frontmatter.name = name;
336
531
  frontmatter.description = description;
@@ -343,6 +538,9 @@ async function resolveAgentMerge(options) {
343
538
  markdown,
344
539
  };
345
540
  }
541
+ function choosePreferredProviderAgent(records) {
542
+ return records.find((record) => record.provider === "copilot") ?? records[0];
543
+ }
346
544
  function dedupeAgentRecords(records) {
347
545
  const unique = [];
348
546
  for (const record of records) {
@@ -353,10 +551,8 @@ function dedupeAgentRecords(records) {
353
551
  }
354
552
  return unique;
355
553
  }
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));
554
+ function sameNormalizedBody(left, right) {
555
+ return normalizeBody(left) === normalizeBody(right);
360
556
  }
361
557
  async function migrateCommands(options, summary) {
362
558
  const canonicalCommands = parseCommandsDir(options.paths.commandsDir);
@@ -373,11 +569,12 @@ async function migrateCommands(options, summary) {
373
569
  }
374
570
  for (const [fileName, records] of grouped.entries()) {
375
571
  const canonical = canonicalByFile.get(fileName);
376
- let content = canonical?.content ?? records[0].content;
572
+ let preferredRecord = choosePreferredProviderCommand(records);
573
+ let body = canonical?.body ?? preferredRecord.body;
377
574
  let hadConflict = false;
378
575
  if (!canonical) {
379
- const allSame = records.every((record) => normalizeBody(record.content) === normalizeBody(records[0].content));
380
- if (!allSame) {
576
+ const allSameBody = records.every((record) => sameNormalizedBody(record.body, records[0].body));
577
+ if (!allSameBody) {
381
578
  hadConflict = true;
382
579
  summary.conflicts += 1;
383
580
  const chosen = await resolveProviderDuplicateConflict({
@@ -386,13 +583,18 @@ async function migrateCommands(options, summary) {
386
583
  yes: Boolean(options.yes),
387
584
  nonInteractive: Boolean(options.nonInteractive),
388
585
  });
389
- content = chosen.content;
586
+ preferredRecord = chosen;
587
+ body = chosen.body;
390
588
  }
391
589
  }
392
590
  else {
393
591
  for (const record of records) {
394
- if (normalizeBody(record.content) === normalizeBody(content))
592
+ if (canonical.frontmatter?.[record.provider] === false) {
395
593
  continue;
594
+ }
595
+ if (sameNormalizedBody(record.body, body)) {
596
+ continue;
597
+ }
396
598
  hadConflict = true;
397
599
  summary.conflicts += 1;
398
600
  const decision = await resolveCanonicalConflict({
@@ -401,10 +603,17 @@ async function migrateCommands(options, summary) {
401
603
  nonInteractive: Boolean(options.nonInteractive),
402
604
  });
403
605
  if (decision === "provider") {
404
- content = record.content;
606
+ preferredRecord = record;
607
+ body = record.body;
405
608
  }
406
609
  }
407
610
  }
611
+ const frontmatter = mergeCommandFrontmatter({
612
+ canonicalFrontmatter: canonical?.frontmatter,
613
+ records,
614
+ preferredRecord,
615
+ });
616
+ const content = buildCommandMarkdown(frontmatter, body);
408
617
  const hasChanged = canonical
409
618
  ? canonical.content !== content
410
619
  : records.length > 0;
@@ -419,29 +628,222 @@ async function migrateCommands(options, summary) {
419
628
  summary.imported += 1;
420
629
  }
421
630
  }
631
+ function choosePreferredProviderCommand(records) {
632
+ const withFrontmatter = records.filter((record) => record.frontmatter);
633
+ if (withFrontmatter.length === 0) {
634
+ return records[0];
635
+ }
636
+ return (withFrontmatter.find((record) => record.provider === "copilot") ??
637
+ withFrontmatter[0]);
638
+ }
639
+ const COMMAND_GENERIC_FRONTMATTER_KEYS = new Set([
640
+ "name",
641
+ "description",
642
+ ]);
643
+ function mergeCommandFrontmatter(options) {
644
+ const hasCanonical = options.canonicalFrontmatter !== undefined;
645
+ const merged = options.canonicalFrontmatter
646
+ ? cloneRecord(options.canonicalFrontmatter)
647
+ : {};
648
+ const sharedGeneric = new Map();
649
+ for (const key of COMMAND_GENERIC_FRONTMATTER_KEYS) {
650
+ if (merged[key] !== undefined) {
651
+ sharedGeneric.set(key, merged[key]);
652
+ continue;
653
+ }
654
+ if (!hasCanonical) {
655
+ const preferredValue = options.preferredRecord.frontmatter?.[key];
656
+ if (preferredValue !== undefined) {
657
+ const cloned = cloneRecord(preferredValue);
658
+ merged[key] = cloned;
659
+ sharedGeneric.set(key, cloned);
660
+ }
661
+ }
662
+ }
663
+ for (const record of options.records) {
664
+ const existingProviderValue = merged[record.provider];
665
+ if (existingProviderValue === false) {
666
+ continue;
667
+ }
668
+ const hadProviderObject = isObject(existingProviderValue);
669
+ const existingProviderConfig = hadProviderObject
670
+ ? cloneRecord(existingProviderValue)
671
+ : {};
672
+ const providerConfig = existingProviderConfig;
673
+ const data = record.frontmatter ?? {};
674
+ for (const [key, value] of Object.entries(data)) {
675
+ if (PROVIDER_NAME_KEYS.has(key))
676
+ continue;
677
+ if (COMMAND_GENERIC_FRONTMATTER_KEYS.has(key)) {
678
+ if (!sharedGeneric.has(key)) {
679
+ if (hasCanonical) {
680
+ providerConfig[key] = cloneRecord(value);
681
+ continue;
682
+ }
683
+ const cloned = cloneRecord(value);
684
+ merged[key] = cloned;
685
+ sharedGeneric.set(key, cloned);
686
+ continue;
687
+ }
688
+ const sharedValue = sharedGeneric.get(key);
689
+ if (!isDeepStrictEqual(sharedValue, value)) {
690
+ providerConfig[key] = cloneRecord(value);
691
+ }
692
+ continue;
693
+ }
694
+ const sharedValue = merged[key];
695
+ if (sharedValue !== undefined && isDeepStrictEqual(sharedValue, value)) {
696
+ continue;
697
+ }
698
+ providerConfig[key] = cloneRecord(value);
699
+ }
700
+ if (Object.keys(providerConfig).length > 0 || hadProviderObject) {
701
+ merged[record.provider] = providerConfig;
702
+ }
703
+ }
704
+ return Object.keys(merged).length > 0 ? merged : undefined;
705
+ }
706
+ function buildCommandMarkdown(frontmatter, body) {
707
+ const normalizedBody = body.trimStart();
708
+ if (!frontmatter || Object.keys(frontmatter).length === 0) {
709
+ return normalizedBody.endsWith("\n")
710
+ ? normalizedBody
711
+ : `${normalizedBody}\n`;
712
+ }
713
+ const fm = YAML.stringify(frontmatter, { lineWidth: 0 }).trimEnd();
714
+ return `---\n${fm}\n---\n\n${normalizedBody}${normalizedBody.endsWith("\n") ? "" : "\n"}`;
715
+ }
422
716
  function readProviderCommands(paths, provider) {
423
717
  // Codex prompts are home-scoped; importing them into local canonical state
424
718
  // causes unrelated global prompts to appear in fresh repositories.
425
719
  if (provider === "codex" && paths.scope === "local") {
426
720
  return [];
427
721
  }
428
- const commandsDir = getProviderCommandsDir(paths, provider);
722
+ const candidateDirs = [
723
+ {
724
+ path: getProviderCommandsDir(paths, provider),
725
+ sourcePriority: 0,
726
+ },
727
+ ];
728
+ if (provider === "copilot" && paths.scope === "global") {
729
+ candidateDirs.push({
730
+ path: path.join(paths.homeDir, ".github", "prompts"),
731
+ sourcePriority: 1,
732
+ });
733
+ }
734
+ const records = [];
735
+ for (const candidateDir of candidateDirs) {
736
+ records.push(...readProviderCommandsFromDir(candidateDir.path, provider, candidateDir.sourcePriority));
737
+ }
738
+ return dedupeProviderCommandRecords(records);
739
+ }
740
+ function readProviderCommandsFromDir(commandsDir, provider, sourcePriority) {
429
741
  if (!fs.existsSync(commandsDir) || !fs.statSync(commandsDir).isDirectory()) {
430
742
  return [];
431
743
  }
432
- const files = parseCommandsDir(commandsDir);
433
- return files.map((file) => ({
744
+ const markdownFiles = parseCommandsDir(commandsDir).filter((file) => isProviderEntityFileName({
745
+ provider,
746
+ entity: "command",
747
+ fileName: file.fileName,
748
+ }));
749
+ const files = provider === "gemini"
750
+ ? [...parseGeminiTomlCommandsForMigration(commandsDir), ...markdownFiles]
751
+ : markdownFiles;
752
+ const dedupedByTargetFile = new Map();
753
+ for (const file of files) {
754
+ const targetFileName = toCanonicalCommandFileName(file.fileName);
755
+ if (!dedupedByTargetFile.has(targetFileName)) {
756
+ dedupedByTargetFile.set(targetFileName, file);
757
+ }
758
+ }
759
+ return [...dedupedByTargetFile.entries()].map(([targetFileName, file]) => ({
434
760
  provider,
435
761
  sourcePath: file.sourcePath,
436
- targetFileName: toCanonicalCommandFileName(file.fileName),
762
+ sourcePriority,
763
+ targetFileName,
437
764
  content: file.content,
765
+ body: provider === "gemini"
766
+ ? normalizeCommandArgumentsForCanonical(file.body, provider)
767
+ : file.body,
768
+ frontmatter: file.frontmatter,
438
769
  }));
439
770
  }
771
+ function dedupeProviderCommandRecords(records) {
772
+ const recordsByTarget = new Map();
773
+ for (const record of records) {
774
+ const next = recordsByTarget.get(record.targetFileName) ?? [];
775
+ next.push(record);
776
+ recordsByTarget.set(record.targetFileName, next);
777
+ }
778
+ const deduped = [];
779
+ for (const [targetFileName, bucket] of recordsByTarget.entries()) {
780
+ if (bucket.length === 1) {
781
+ deduped.push(bucket[0]);
782
+ continue;
783
+ }
784
+ const ranked = [...bucket].sort(compareProviderCommandCollisionPriority);
785
+ const winner = ranked[0];
786
+ const ignored = ranked.slice(1).map((record) => record.sourcePath);
787
+ console.warn(`Warning: Duplicate ${winner.provider} commands map to "${targetFileName}". Keeping ${winner.sourcePath} and ignoring ${ignored.join(", ")}.`);
788
+ deduped.push(winner);
789
+ }
790
+ return deduped.sort((left, right) => left.targetFileName.localeCompare(right.targetFileName));
791
+ }
792
+ function compareProviderCommandCollisionPriority(left, right) {
793
+ if (left.sourcePriority !== right.sourcePriority) {
794
+ return left.sourcePriority - right.sourcePriority;
795
+ }
796
+ return left.sourcePath.localeCompare(right.sourcePath);
797
+ }
798
+ function parseGeminiTomlCommandsForMigration(commandsDir) {
799
+ return fs
800
+ .readdirSync(commandsDir, { withFileTypes: true })
801
+ .filter((entry) => entry.isFile())
802
+ .map((entry) => entry.name)
803
+ .filter((fileName) => isProviderEntityFileName({
804
+ provider: "gemini",
805
+ entity: "command",
806
+ fileName,
807
+ }))
808
+ .filter((fileName) => fileName.toLowerCase().endsWith(".toml"))
809
+ .sort((a, b) => a.localeCompare(b))
810
+ .map((fileName) => parseGeminiTomlCommandForMigration(path.join(commandsDir, fileName)))
811
+ .filter((command) => command !== null);
812
+ }
813
+ function parseGeminiTomlCommandForMigration(sourcePath) {
814
+ const raw = fs.readFileSync(sourcePath, "utf8");
815
+ let parsed;
816
+ try {
817
+ parsed = TOML.parse(raw);
818
+ }
819
+ catch {
820
+ return null;
821
+ }
822
+ if (!isObject(parsed) || typeof parsed.prompt !== "string") {
823
+ return null;
824
+ }
825
+ const body = normalizeCommandArgumentsForCanonical(parsed.prompt, "gemini");
826
+ const frontmatter = cloneRecord(parsed);
827
+ delete frontmatter.prompt;
828
+ const normalizedFrontmatter = Object.keys(frontmatter).length > 0 ? frontmatter : undefined;
829
+ const fileName = path.basename(sourcePath);
830
+ const content = buildCommandMarkdown(normalizedFrontmatter, body);
831
+ return {
832
+ fileName,
833
+ sourcePath,
834
+ content,
835
+ body,
836
+ frontmatter: normalizedFrontmatter,
837
+ };
838
+ }
440
839
  function toCanonicalCommandFileName(fileName) {
441
840
  const lower = fileName.toLowerCase();
442
841
  if (lower.endsWith(".prompt.md")) {
443
842
  return `${fileName.slice(0, -".prompt.md".length)}.md`;
444
843
  }
844
+ if (lower.endsWith(".toml")) {
845
+ return `${fileName.slice(0, -".toml".length)}.md`;
846
+ }
445
847
  if (lower.endsWith(".mdc")) {
446
848
  return `${fileName.slice(0, -".mdc".length)}.md`;
447
849
  }
@@ -649,8 +1051,185 @@ function isCanonicalServerEqual(left, right) {
649
1051
  return false;
650
1052
  return isDeepStrictEqual(normalizeCanonicalServer(left), normalizeCanonicalServer(right));
651
1053
  }
1054
+ async function migrateRules(options, summary) {
1055
+ const detectedEntries = [
1056
+ ...getCursorRuleMigrationEntries(options),
1057
+ ...getManagedInstructionRuleMigrationEntries(options),
1058
+ ];
1059
+ const entries = dedupeRuleMigrationEntries(detectedEntries);
1060
+ summary.detected += detectedEntries.length;
1061
+ for (const entry of entries) {
1062
+ const targetPath = path.join(options.paths.rulesDir, `${entry.targetStem}.md`);
1063
+ if (!fs.existsSync(targetPath)) {
1064
+ if (shouldWriteCanonical(options)) {
1065
+ ensureDir(options.paths.rulesDir);
1066
+ fs.writeFileSync(targetPath, entry.canonicalContent, "utf8");
1067
+ }
1068
+ summary.imported += 1;
1069
+ continue;
1070
+ }
1071
+ const existing = fs.readFileSync(targetPath, "utf8");
1072
+ if (existing === entry.canonicalContent) {
1073
+ summary.skipped += 1;
1074
+ continue;
1075
+ }
1076
+ summary.conflicts += 1;
1077
+ const decision = await resolveCanonicalConflict({
1078
+ conflictLabel: entry.conflictLabel,
1079
+ yes: Boolean(options.yes),
1080
+ nonInteractive: Boolean(options.nonInteractive),
1081
+ });
1082
+ if (decision === "canonical") {
1083
+ summary.skipped += 1;
1084
+ continue;
1085
+ }
1086
+ if (shouldWriteCanonical(options)) {
1087
+ ensureDir(options.paths.rulesDir);
1088
+ fs.writeFileSync(targetPath, entry.canonicalContent, "utf8");
1089
+ }
1090
+ summary.imported += 1;
1091
+ }
1092
+ }
1093
+ function dedupeRuleMigrationEntries(entries) {
1094
+ const deduped = [];
1095
+ for (const entry of entries) {
1096
+ const existingIndex = deduped.findIndex((item) => item.targetStem === entry.targetStem);
1097
+ if (existingIndex < 0) {
1098
+ deduped.push(entry);
1099
+ continue;
1100
+ }
1101
+ const existing = deduped[existingIndex];
1102
+ if (existing.canonicalContent === entry.canonicalContent) {
1103
+ continue;
1104
+ }
1105
+ const preferred = choosePreferredRuleMigrationEntry(existing, entry);
1106
+ if (preferred === existing) {
1107
+ continue;
1108
+ }
1109
+ if (preferred === entry) {
1110
+ deduped[existingIndex] = entry;
1111
+ continue;
1112
+ }
1113
+ deduped.push(entry);
1114
+ }
1115
+ return deduped;
1116
+ }
1117
+ function choosePreferredRuleMigrationEntry(left, right) {
1118
+ const hasCursor = left.sourceKind === "cursor" || right.sourceKind === "cursor";
1119
+ const hasManaged = left.sourceKind === "managed" || right.sourceKind === "managed";
1120
+ if (!hasCursor || !hasManaged) {
1121
+ return undefined;
1122
+ }
1123
+ try {
1124
+ const leftParsed = parseRuleMarkdown(left.canonicalContent, left.conflictLabel);
1125
+ const rightParsed = parseRuleMarkdown(right.canonicalContent, right.conflictLabel);
1126
+ if (leftParsed.name !== rightParsed.name) {
1127
+ return undefined;
1128
+ }
1129
+ if (!sameNormalizedBody(leftParsed.body, rightParsed.body)) {
1130
+ return undefined;
1131
+ }
1132
+ }
1133
+ catch {
1134
+ return undefined;
1135
+ }
1136
+ return left.sourceKind === "cursor" ? left : right;
1137
+ }
1138
+ function getCursorRuleMigrationEntries(options) {
1139
+ if (!options.providers.includes("cursor")) {
1140
+ return [];
1141
+ }
1142
+ if (options.paths.scope !== "local") {
1143
+ return [];
1144
+ }
1145
+ const cursorRulesDir = getCursorRulesDir(options.paths);
1146
+ if (!fs.existsSync(cursorRulesDir) ||
1147
+ !fs.statSync(cursorRulesDir).isDirectory()) {
1148
+ return [];
1149
+ }
1150
+ return fs
1151
+ .readdirSync(cursorRulesDir, { withFileTypes: true })
1152
+ .filter((entry) => entry.isFile())
1153
+ .map((entry) => entry.name)
1154
+ .filter((name) => /\.(md|mdc)$/i.test(name))
1155
+ .filter((name) => stripRuleFileExtension(name).toLowerCase() !== "readme")
1156
+ .sort((a, b) => a.localeCompare(b))
1157
+ .map((fileName) => {
1158
+ const sourcePath = path.join(cursorRulesDir, fileName);
1159
+ const raw = fs.readFileSync(sourcePath, "utf8");
1160
+ const targetStem = slugify(stripRuleFileExtension(fileName)) || "rule";
1161
+ return {
1162
+ canonicalContent: toCanonicalRuleMarkdown(raw, fileName, sourcePath),
1163
+ conflictLabel: `rule "${targetStem}" from cursor`,
1164
+ sourceKind: "cursor",
1165
+ targetStem,
1166
+ };
1167
+ });
1168
+ }
1169
+ function getManagedInstructionRuleMigrationEntries(options) {
1170
+ const instructionPaths = getManagedInstructionRulePaths(options).sort((left, right) => left.localeCompare(right));
1171
+ const entries = [];
1172
+ for (const instructionPath of instructionPaths) {
1173
+ if (!fs.existsSync(instructionPath) ||
1174
+ !fs.statSync(instructionPath).isFile()) {
1175
+ continue;
1176
+ }
1177
+ const raw = fs.readFileSync(instructionPath, "utf8");
1178
+ for (const block of parseManagedRuleBlocks(raw)) {
1179
+ const targetStem = slugify(block.id) || "rule";
1180
+ entries.push({
1181
+ canonicalContent: toCanonicalRuleMarkdownFromManagedBlock(block),
1182
+ conflictLabel: `rule "${targetStem}" from ${path.basename(instructionPath)}`,
1183
+ sourceKind: "managed",
1184
+ targetStem,
1185
+ });
1186
+ }
1187
+ }
1188
+ return entries;
1189
+ }
1190
+ function getManagedInstructionRulePaths(options) {
1191
+ const instructionPaths = getRuleInstructionPaths(options.paths, options.providers);
1192
+ if (options.paths.scope === "global" &&
1193
+ options.providers.includes("copilot")) {
1194
+ const legacyCopilotInstructionPath = path.join(options.paths.homeDir, ".github", "copilot-instructions.md");
1195
+ if (fs.existsSync(legacyCopilotInstructionPath) &&
1196
+ fs.statSync(legacyCopilotInstructionPath).isFile()) {
1197
+ instructionPaths.push(legacyCopilotInstructionPath);
1198
+ }
1199
+ }
1200
+ return [...new Set(instructionPaths)];
1201
+ }
1202
+ function toCanonicalRuleMarkdown(raw, fileName, sourcePath) {
1203
+ try {
1204
+ const parsed = parseRuleMarkdown(raw, sourcePath);
1205
+ const fm = YAML.stringify(parsed.frontmatter, { lineWidth: 0 }).trimEnd();
1206
+ return `---\n${fm}\n---\n\n${parsed.body}${parsed.body.endsWith("\n") ? "" : "\n"}`;
1207
+ }
1208
+ catch {
1209
+ const parsed = matter(raw);
1210
+ const data = isObject(parsed.data)
1211
+ ? cloneRecord(parsed.data)
1212
+ : {};
1213
+ if (typeof data.name !== "string" || data.name.trim() === "") {
1214
+ data.name = stripRuleFileExtension(fileName);
1215
+ }
1216
+ const fm = YAML.stringify(data, { lineWidth: 0 }).trimEnd();
1217
+ const body = parsed.content.trimStart();
1218
+ return `---\n${fm}\n---\n\n${body}${body.endsWith("\n") ? "" : "\n"}`;
1219
+ }
1220
+ }
1221
+ function toCanonicalRuleMarkdownFromManagedBlock(block) {
1222
+ const fm = YAML.stringify({ name: block.name }, { lineWidth: 0 }).trimEnd();
1223
+ const body = block.body.trim();
1224
+ return `---\n${fm}\n---\n\n${body}${body.endsWith("\n") ? "" : "\n"}`;
1225
+ }
652
1226
  async function migrateSkills(options, summary) {
653
- const providerSkillDirs = getProviderSkillsPaths(options.paths, options.providers)
1227
+ const providerSkillDirs = getProviderSkillsPaths(options.paths, options.providers);
1228
+ if (options.providers.includes("copilot")) {
1229
+ providerSkillDirs.push(...getLegacyCopilotSkillDirs(options.paths));
1230
+ }
1231
+ const legacyCopilotSkillDirs = new Set(getLegacyCopilotSkillDirs(options.paths));
1232
+ const existingProviderSkillDirs = [...new Set(providerSkillDirs)]
654
1233
  .filter((dirPath) => fs.existsSync(dirPath))
655
1234
  .filter((dirPath) => {
656
1235
  try {
@@ -660,12 +1239,17 @@ async function migrateSkills(options, summary) {
660
1239
  return false;
661
1240
  }
662
1241
  });
663
- for (const providerSkillsDir of providerSkillDirs) {
664
- const providerLabel = providerSkillsDir.includes(`${path.sep}.cursor${path.sep}`)
665
- ? "cursor"
666
- : providerSkillsDir.includes(`${path.sep}.pi${path.sep}`)
667
- ? "pi"
668
- : "claude";
1242
+ for (const providerSkillsDir of existingProviderSkillDirs) {
1243
+ const providerLabel = legacyCopilotSkillDirs.has(providerSkillsDir)
1244
+ ? "copilot"
1245
+ : providerSkillsDir.includes(`${path.sep}.cursor${path.sep}`)
1246
+ ? "cursor"
1247
+ : providerSkillsDir.includes(`${path.sep}.github${path.sep}`) ||
1248
+ providerSkillsDir.includes(`${path.sep}.copilot${path.sep}`)
1249
+ ? "copilot"
1250
+ : providerSkillsDir.includes(`${path.sep}.pi${path.sep}`)
1251
+ ? "pi"
1252
+ : "claude";
669
1253
  const skills = parseSkillsDir(providerSkillsDir);
670
1254
  summary.detected += skills.length;
671
1255
  for (const skill of skills) {