agentloom 0.1.5 → 0.1.6

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.
@@ -1,14 +1,19 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { cancel, isCancel, multiselect, select, text as promptText, } from "@clack/prompts";
4
+ import TOML from "@iarna/toml";
5
+ import matter from "gray-matter";
6
+ import YAML from "yaml";
4
7
  import { buildAgentMarkdown, parseAgentsDir, targetFileNameForAgent, } from "./agents.js";
5
- import { normalizeCommandSelector, parseCommandsDir, resolveCommandSelections, } from "./commands.js";
8
+ import { normalizeCommandArgumentsForCanonical, normalizeCommandSelector, parseCommandsDir, resolveCommandSelections, } from "./commands.js";
9
+ import { normalizeRuleSelector, parseRulesDir, resolveRuleSelections, stripRuleFileExtension, } from "./rules.js";
6
10
  import { applySkillProviderSideEffects, copySkillArtifacts, normalizeSkillSelector, parseSkillsDir, resolveSkillSelections, skillContentMatchesTarget, } from "./skills.js";
7
- import { ensureDir, hashContent, readJsonIfExists, relativePosix, slugify, writeTextAtomic, } from "./fs.js";
11
+ import { ensureDir, hashContent, isObject, readJsonIfExists, relativePosix, slugify, writeTextAtomic, } from "./fs.js";
8
12
  import { ALL_PROVIDERS } from "../types.js";
9
13
  import { readLockfile, upsertLockEntry, writeLockfile } from "./lockfile.js";
10
14
  import { readCanonicalMcp, writeCanonicalMcp } from "./mcp.js";
11
- import { discoverSourceAgentsDir, discoverSourceCommandsDir, discoverSourceMcpPath, discoverSourceSkillsDir, prepareSource, } from "./sources.js";
15
+ import { discoverSourceAgentsDir, discoverSourceCommandsDir, discoverSourceCommandsDirs, discoverSourceMcpPath, discoverSourceRulesDir, discoverSourceSkillsDir, prepareSource, } from "./sources.js";
16
+ import { isProviderEntityFileName } from "./provider-entity-validation.js";
12
17
  export class NonInteractiveConflictError extends Error {
13
18
  constructor(message) {
14
19
  super(message);
@@ -24,10 +29,13 @@ export async function importSource(options) {
24
29
  const requireAgents = options.requireAgents ?? shouldImportAgents;
25
30
  const shouldImportCommands = options.importCommands ?? true;
26
31
  const shouldImportMcp = options.importMcp ?? true;
32
+ const shouldImportRules = options.importRules ?? false;
33
+ const requireRules = options.requireRules ?? shouldImportRules;
27
34
  const shouldImportSkills = options.importSkills ?? false;
28
35
  if (!shouldImportAgents &&
29
36
  !shouldImportCommands &&
30
37
  !shouldImportMcp &&
38
+ !shouldImportRules &&
31
39
  !shouldImportSkills) {
32
40
  throw new Error("No import targets selected.");
33
41
  }
@@ -46,36 +54,47 @@ export async function importSource(options) {
46
54
  const sourceAgentsDir = shouldImportAgents
47
55
  ? discoverSourceAgentsDir(prepared.importRoot)
48
56
  : null;
49
- const sourceCommandsDir = shouldImportCommands
50
- ? discoverSourceCommandsDir(prepared.importRoot)
51
- : null;
57
+ const sourceCommandsDirs = shouldImportCommands
58
+ ? discoverSourceCommandsDirs(prepared.importRoot)
59
+ : [];
60
+ const sourceCommandsDir = sourceCommandsDirs[0] ??
61
+ (shouldImportCommands
62
+ ? discoverSourceCommandsDir(prepared.importRoot)
63
+ : null);
52
64
  const sourceMcpPath = shouldImportMcp
53
65
  ? discoverSourceMcpPath(prepared.importRoot)
54
66
  : null;
67
+ const sourceRulesDir = shouldImportRules
68
+ ? discoverSourceRulesDir(prepared.importRoot)
69
+ : null;
55
70
  const sourceSkillsDir = shouldImportSkills
56
71
  ? discoverSourceSkillsDir(prepared.importRoot)
57
72
  : null;
58
- const sourceAgents = sourceAgentsDir ? parseAgentsDir(sourceAgentsDir) : [];
59
- const sourceCommands = sourceCommandsDir
60
- ? parseCommandsDir(sourceCommandsDir)
73
+ const sourceAgents = sourceAgentsDir
74
+ ? parseSourceAgentsForImport(sourceAgentsDir)
75
+ : [];
76
+ const sourceCommands = sourceCommandsDirs.length > 0
77
+ ? parseSourceCommandsForImport(sourceCommandsDirs)
61
78
  : [];
62
79
  const sourceMcp = sourceMcpPath
63
80
  ? normalizeMcp(readJsonIfExists(sourceMcpPath))
64
81
  : null;
82
+ const sourceRules = sourceRulesDir ? parseRulesDir(sourceRulesDir) : [];
65
83
  const sourceSkills = sourceSkillsDir ? parseSkillsDir(sourceSkillsDir) : [];
66
84
  const hasExplicitCommandSelection = (options.commandSelectors?.length ?? 0) > 0;
67
85
  const isAggregateImport = shouldImportAgents &&
68
86
  shouldImportCommands &&
69
87
  shouldImportMcp &&
88
+ (options.importRules === undefined || shouldImportRules) &&
70
89
  shouldImportSkills;
71
90
  if (shouldImportAgents && requireAgents && !sourceAgentsDir) {
72
- throw new Error(`No source agents directory found under ${prepared.importRoot} (expected agents/ or .agents/agents/).`);
91
+ throw new Error(`No source agents directory found under ${prepared.importRoot} (expected agents/, .agents/agents/, or .github/agents/).`);
73
92
  }
74
93
  if (shouldImportAgents && requireAgents && sourceAgents.length === 0) {
75
94
  throw new Error(`No agent files found in ${sourceAgentsDir}.`);
76
95
  }
77
96
  if (shouldImportCommands && options.requireCommands && !sourceCommandsDir) {
78
- throw new Error(`No source commands directory found under ${prepared.importRoot} (expected .agents/commands/, commands/, or prompts/).`);
97
+ throw new Error(`No source commands directory found under ${prepared.importRoot} (expected .agents/commands/, commands/, prompts/, .gemini/commands/, or .github/prompts/).`);
79
98
  }
80
99
  if (shouldImportCommands &&
81
100
  options.requireCommands &&
@@ -85,6 +104,12 @@ export async function importSource(options) {
85
104
  if (shouldImportMcp && options.requireMcp && !sourceMcpPath) {
86
105
  throw new Error(`No source mcp.json found under ${prepared.importRoot} (expected mcp.json or .agents/mcp.json).`);
87
106
  }
107
+ if (shouldImportRules && requireRules && !sourceRulesDir) {
108
+ throw new Error(`No source rules directory found under ${prepared.importRoot} (expected .agents/rules/ or rules/).`);
109
+ }
110
+ if (shouldImportRules && requireRules && sourceRules.length === 0) {
111
+ throw new Error(`No rule files found in ${sourceRulesDir}.`);
112
+ }
88
113
  if (shouldImportSkills && options.requireSkills && !sourceSkillsDir) {
89
114
  throw new Error(`No source skills directory found under ${prepared.importRoot} (expected .agents/skills/, skills/, or root SKILL.md).`);
90
115
  }
@@ -96,9 +121,10 @@ export async function importSource(options) {
96
121
  if (isAggregateImport &&
97
122
  sourceAgents.length === 0 &&
98
123
  sourceCommands.length === 0 &&
124
+ sourceRules.length === 0 &&
99
125
  sourceSkills.length === 0 &&
100
126
  Object.keys(sourceMcp?.mcpServers ?? {}).length === 0) {
101
- throw new Error(`No importable entities found in source "${sourceLocation}".\nExpected agents/, .agents/agents/, commands/, .agents/commands/, prompts/, mcp.json/.agents/mcp.json, skills/, .agents/skills/, or root SKILL.md.`);
127
+ throw new Error(`No importable entities found in source "${sourceLocation}".\nExpected agents/, .agents/agents/, .github/agents/, commands/, .agents/commands/, prompts/, .gemini/commands/, .github/prompts/, mcp.json/.agents/mcp.json, rules/.agents/rules/, skills/, .agents/skills/, or root SKILL.md.`);
102
128
  }
103
129
  const shouldResolveAgents = shouldImportAgents &&
104
130
  (sourceAgents.length > 0 ||
@@ -145,6 +171,21 @@ export async function importSource(options) {
145
171
  selectedSourceMcpServers = mcpSelection.selectedServerNames;
146
172
  mcpSelectionMode = mcpSelection.selectionMode;
147
173
  }
174
+ let selectedRules = [];
175
+ let selectedSourceRules = [];
176
+ let ruleSelectionMode = "all";
177
+ if (shouldImportRules && sourceRulesDir) {
178
+ const ruleSelection = await resolveRulesToImport({
179
+ sourceRules,
180
+ selectors: options.ruleSelectors ?? [],
181
+ promptForRules: options.promptForRules ?? true,
182
+ nonInteractive: Boolean(options.nonInteractive),
183
+ selectionMode: options.selectionMode,
184
+ });
185
+ selectedRules = ruleSelection.selectedRules;
186
+ ruleSelectionMode = ruleSelection.selectionMode;
187
+ selectedSourceRules = selectedRules.map((rule) => rule.fileName);
188
+ }
148
189
  let selectedSkills = [];
149
190
  let selectedSourceSkills = [];
150
191
  let skillSelectionMode = "all";
@@ -240,6 +281,47 @@ export async function importSource(options) {
240
281
  }
241
282
  importedMcpServers.push(...selectedSourceMcpServers);
242
283
  }
284
+ const importedRules = [];
285
+ const telemetryRules = [];
286
+ const importedRuleRenameMap = {};
287
+ if (shouldImportRules && sourceRulesDir) {
288
+ if (selectedRules.length > 0) {
289
+ ensureDir(options.paths.rulesDir);
290
+ }
291
+ for (const [index, rule] of selectedRules.entries()) {
292
+ let targetFileName = rule.fileName;
293
+ const mappedTargetFileName = resolveMappedTargetRuleFileName(rule.fileName, options.ruleRenameMap);
294
+ if (mappedTargetFileName) {
295
+ targetFileName = mappedTargetFileName;
296
+ }
297
+ else if (options.rename &&
298
+ selectedRules.length === 1 &&
299
+ importedAgents.length === 0 &&
300
+ importedCommands.length === 0 &&
301
+ importedMcpServers.length === 0 &&
302
+ selectedSkills.length === 0) {
303
+ targetFileName = `${slugify(options.rename) || "rule"}.md`;
304
+ }
305
+ const resolvedFileName = await resolveRuleConflict({
306
+ targetFileName,
307
+ ruleContent: rule.content,
308
+ paths: options.paths,
309
+ yes: !!options.yes,
310
+ nonInteractive: !!options.nonInteractive,
311
+ promptLabel: `${rule.fileName} (${index + 1}/${selectedRules.length})`,
312
+ });
313
+ if (!resolvedFileName)
314
+ continue;
315
+ const targetPath = path.join(options.paths.rulesDir, resolvedFileName);
316
+ writeTextAtomic(targetPath, rule.content);
317
+ importedRules.push(relativePosix(options.paths.agentsRoot, targetPath));
318
+ telemetryRules.push({
319
+ name: stripRuleFileExtension(rule.fileName),
320
+ filePath: relativePosix(prepared.rootPath, rule.sourcePath),
321
+ });
322
+ importedRuleRenameMap[rule.fileName] = resolvedFileName;
323
+ }
324
+ }
243
325
  const importedSkills = [];
244
326
  const telemetrySkills = [];
245
327
  const importedSkillRenameMap = {};
@@ -258,7 +340,8 @@ export async function importSource(options) {
258
340
  selectedSkills.length === 1 &&
259
341
  importedAgents.length === 0 &&
260
342
  importedCommands.length === 0 &&
261
- importedMcpServers.length === 0) {
343
+ importedMcpServers.length === 0 &&
344
+ importedRules.length === 0) {
262
345
  targetSkillDirName = slugify(options.rename) || "skill";
263
346
  }
264
347
  const resolvedSkillDirName = await resolveSkillConflict({
@@ -303,10 +386,12 @@ export async function importSource(options) {
303
386
  selectedSourceCommandFiles.length < sourceCommands.length;
304
387
  const selectedSubsetOfSourceMcp = sourceMcpServerNames.length > 0 &&
305
388
  selectedSourceMcpServers.length < sourceMcpServerNames.length;
389
+ const selectedSubsetOfSourceRules = sourceRules.length > 0 && selectedSourceRules.length < sourceRules.length;
306
390
  const selectedSubsetOfSourceSkills = sourceSkills.length > 0 &&
307
391
  selectedSourceSkills.length < sourceSkills.length;
308
392
  const shouldPersistCommandSelection = shouldImportCommands && commandSelectionMode === "custom";
309
393
  const shouldPersistMcpSelection = shouldImportMcp && mcpSelectionMode === "custom";
394
+ const shouldPersistRuleSelection = shouldImportRules && ruleSelectionMode === "custom";
310
395
  const shouldPersistSkillSelection = shouldImportSkills && skillSelectionMode === "custom";
311
396
  const selectedSourceCommandsForLock = shouldPersistCommandSelection || selectedSubsetOfSourceCommands
312
397
  ? selectedSourceCommandFiles
@@ -314,6 +399,9 @@ export async function importSource(options) {
314
399
  const selectedSourceMcpServersForLock = shouldPersistMcpSelection || selectedSubsetOfSourceMcp
315
400
  ? selectedSourceMcpServers
316
401
  : undefined;
402
+ const selectedSourceRulesForLock = shouldPersistRuleSelection || selectedSubsetOfSourceRules
403
+ ? selectedSourceRules
404
+ : undefined;
317
405
  const selectedSourceSkillsForLock = shouldPersistSkillSelection || selectedSubsetOfSourceSkills
318
406
  ? selectedSourceSkills
319
407
  : undefined;
@@ -321,6 +409,7 @@ export async function importSource(options) {
321
409
  const isCommandOnlyImport = !shouldImportAgents &&
322
410
  shouldImportCommands &&
323
411
  !shouldImportMcp &&
412
+ !shouldImportRules &&
324
413
  !shouldImportSkills;
325
414
  const existingEntry = isCommandOnlyImport
326
415
  ? findRelaxedCommandEntry(lockfile.entries, {
@@ -338,6 +427,7 @@ export async function importSource(options) {
338
427
  : options.agents,
339
428
  selectedSourceCommands: selectedSourceCommandsForLock,
340
429
  selectedSourceMcpServers: selectedSourceMcpServersForLock,
430
+ selectedSourceRules: selectedSourceRulesForLock,
341
431
  selectedSourceSkills: selectedSourceSkillsForLock,
342
432
  skillsProviders: skillsProvidersForLock,
343
433
  });
@@ -360,6 +450,9 @@ export async function importSource(options) {
360
450
  const lockImportedMcpServers = shouldImportMcp
361
451
  ? importedMcpServers
362
452
  : (existingEntry?.importedMcpServers ?? []);
453
+ const lockImportedRules = shouldImportRules
454
+ ? importedRules
455
+ : (existingEntry?.importedRules ?? []);
363
456
  const lockImportedSkills = shouldImportSkills
364
457
  ? importedSkills
365
458
  : (existingEntry?.importedSkills ?? []);
@@ -392,11 +485,19 @@ export async function importSource(options) {
392
485
  ? [...selectedSourceMcpServers]
393
486
  : undefined
394
487
  : existingEntry?.selectedSourceMcpServers;
488
+ const lockSelectedSourceRules = shouldImportRules
489
+ ? shouldPersistRuleSelection || selectedSubsetOfSourceRules
490
+ ? [...selectedSourceRules]
491
+ : undefined
492
+ : existingEntry?.selectedSourceRules;
395
493
  const lockSelectedSourceSkills = shouldImportSkills
396
494
  ? shouldPersistSkillSelection || selectedSubsetOfSourceSkills
397
495
  ? [...selectedSourceSkills]
398
496
  : undefined
399
497
  : existingEntry?.selectedSourceSkills;
498
+ const lockRuleRenameMap = shouldImportRules
499
+ ? normalizeRuleRenameMap(importedRuleRenameMap)
500
+ : existingEntry?.ruleRenameMap;
400
501
  const lockSkillsProviders = shouldImportSkills
401
502
  ? (skillsProvidersForLock ?? existingEntry?.skillsProviders)
402
503
  : existingEntry?.skillsProviders;
@@ -426,6 +527,9 @@ export async function importSource(options) {
426
527
  commandRenameMap: lockCommandRenameMap,
427
528
  importedMcpServers: lockImportedMcpServers,
428
529
  selectedSourceMcpServers: lockSelectedSourceMcpServers,
530
+ importedRules: lockImportedRules,
531
+ selectedSourceRules: lockSelectedSourceRules,
532
+ ruleRenameMap: lockRuleRenameMap,
429
533
  importedSkills: lockImportedSkills,
430
534
  selectedSourceSkills: lockSelectedSourceSkills,
431
535
  skillsProviders: lockSkillsProviders,
@@ -438,6 +542,9 @@ export async function importSource(options) {
438
542
  commandRenameMap: lockCommandRenameMap ?? {},
439
543
  mcp: lockImportedMcpServers,
440
544
  selectedSourceMcpServers: lockSelectedSourceMcpServers ?? [],
545
+ rules: lockImportedRules,
546
+ selectedSourceRules: lockSelectedSourceRules ?? [],
547
+ ruleRenameMap: lockRuleRenameMap ?? {},
441
548
  skills: lockImportedSkills,
442
549
  selectedSourceSkills: lockSelectedSourceSkills ?? [],
443
550
  skillsProviders: lockSkillsProviders ?? [],
@@ -458,6 +565,9 @@ export async function importSource(options) {
458
565
  commandRenameMap: lockCommandRenameMap,
459
566
  importedMcpServers: lockImportedMcpServers,
460
567
  selectedSourceMcpServers: lockSelectedSourceMcpServers,
568
+ importedRules: lockImportedRules,
569
+ selectedSourceRules: lockSelectedSourceRules,
570
+ ruleRenameMap: lockRuleRenameMap,
461
571
  importedSkills: lockImportedSkills,
462
572
  selectedSourceSkills: lockSelectedSourceSkills,
463
573
  skillsProviders: lockSkillsProviders,
@@ -484,7 +594,9 @@ export async function importSource(options) {
484
594
  importedAgents,
485
595
  importedCommands,
486
596
  importedMcpServers,
597
+ importedRules,
487
598
  importedSkills,
599
+ telemetryRules: telemetryRules.length > 0 ? telemetryRules : undefined,
488
600
  telemetrySkills: telemetrySkills.length > 0 ? telemetrySkills : undefined,
489
601
  resolvedCommit: prepared.resolvedCommit,
490
602
  };
@@ -493,6 +605,357 @@ export async function importSource(options) {
493
605
  prepared.cleanup();
494
606
  }
495
607
  }
608
+ function parseSourceAgentsForImport(sourceAgentsDir) {
609
+ if (isGitHubAgentsDir(sourceAgentsDir)) {
610
+ return parseGitHubAgentsDirForImport(sourceAgentsDir);
611
+ }
612
+ return parseAgentsDir(sourceAgentsDir);
613
+ }
614
+ function parseSourceCommandsForImport(sourceCommandsDir) {
615
+ const sourceCommandsDirs = Array.isArray(sourceCommandsDir)
616
+ ? sourceCommandsDir
617
+ : [sourceCommandsDir];
618
+ return mergeCanonicalCommandFiles(sourceCommandsDirs.flatMap((dirPath) => parseSourceCommandsFromDir(dirPath)));
619
+ }
620
+ function parseSourceCommandsFromDir(sourceCommandsDir) {
621
+ if (isGeminiCommandsDir(sourceCommandsDir)) {
622
+ const geminiMarkdownCommands = parseCommandsDir(sourceCommandsDir).filter((command) => isProviderEntityFileName({
623
+ provider: "gemini",
624
+ entity: "command",
625
+ fileName: command.fileName,
626
+ }));
627
+ const parsedCommands = mergeCommandsByCanonicalFileName([
628
+ ...parseGeminiTomlCommandsForImport(sourceCommandsDir),
629
+ ...geminiMarkdownCommands,
630
+ ]);
631
+ return parsedCommands.map((command) => normalizeGeminiCommandForImport(command));
632
+ }
633
+ const commands = parseCommandsDir(sourceCommandsDir);
634
+ if (!isGitHubPromptsDir(sourceCommandsDir)) {
635
+ return commands;
636
+ }
637
+ return commands
638
+ .filter((command) => isProviderEntityFileName({
639
+ provider: "copilot",
640
+ entity: "command",
641
+ fileName: command.fileName,
642
+ }))
643
+ .map((command) => normalizeGitHubPromptForImport(command));
644
+ }
645
+ function mergeCommandsByCanonicalFileName(commands) {
646
+ const byFileName = new Map();
647
+ for (const command of commands) {
648
+ if (!byFileName.has(command.fileName)) {
649
+ byFileName.set(command.fileName, command);
650
+ }
651
+ }
652
+ return [...byFileName.values()];
653
+ }
654
+ function isGitHubAgentsDir(sourceAgentsDir) {
655
+ return (path.basename(sourceAgentsDir).toLowerCase() === "agents" &&
656
+ path.basename(path.dirname(sourceAgentsDir)).toLowerCase() === ".github");
657
+ }
658
+ function isGitHubPromptsDir(sourceCommandsDir) {
659
+ return (path.basename(sourceCommandsDir).toLowerCase() === "prompts" &&
660
+ path.basename(path.dirname(sourceCommandsDir)).toLowerCase() === ".github");
661
+ }
662
+ function isGeminiCommandsDir(sourceCommandsDir) {
663
+ return (path.basename(sourceCommandsDir).toLowerCase() === "commands" &&
664
+ path.basename(path.dirname(sourceCommandsDir)).toLowerCase() === ".gemini");
665
+ }
666
+ function parseGeminiTomlCommandsForImport(sourceCommandsDir) {
667
+ return fs
668
+ .readdirSync(sourceCommandsDir, { withFileTypes: true })
669
+ .filter((entry) => entry.isFile())
670
+ .map((entry) => entry.name)
671
+ .filter((fileName) => isProviderEntityFileName({
672
+ provider: "gemini",
673
+ entity: "command",
674
+ fileName,
675
+ }))
676
+ .filter((fileName) => fileName.toLowerCase().endsWith(".toml"))
677
+ .sort((a, b) => a.localeCompare(b))
678
+ .map((fileName) => parseGeminiTomlCommandForImport(path.join(sourceCommandsDir, fileName)))
679
+ .filter((command) => command !== null);
680
+ }
681
+ function mergeCanonicalCommandFiles(commands) {
682
+ const merged = new Map();
683
+ for (const command of commands) {
684
+ const fileName = toCanonicalCommandFileName(command.fileName);
685
+ const normalizedCommand = fileName === command.fileName ? command : { ...command, fileName };
686
+ const existing = merged.get(fileName);
687
+ if (!existing) {
688
+ merged.set(fileName, normalizedCommand);
689
+ continue;
690
+ }
691
+ const existingHasBody = normalizeImportedCommandBody(existing.body).length > 0;
692
+ const incomingHasBody = normalizeImportedCommandBody(normalizedCommand.body).length > 0;
693
+ if (existingHasBody &&
694
+ incomingHasBody &&
695
+ !sameNormalizedImportedCommandBody(existing.body, normalizedCommand.body)) {
696
+ throw new Error(`Conflicting command bodies found for "${fileName}" in ${existing.sourcePath} and ${normalizedCommand.sourcePath}. Align the provider-specific prompts before importing, or import a single provider directory with --subdir.`);
697
+ }
698
+ const body = existingHasBody ? existing.body : normalizedCommand.body;
699
+ const frontmatter = mergeCommandFrontmatterForImport(existing.frontmatter, normalizedCommand.frontmatter);
700
+ merged.set(fileName, {
701
+ ...existing,
702
+ fileName,
703
+ body,
704
+ frontmatter,
705
+ content: buildCommandMarkdownForImport(frontmatter, body),
706
+ });
707
+ }
708
+ return [...merged.values()];
709
+ }
710
+ function sameNormalizedImportedCommandBody(left, right) {
711
+ return (normalizeImportedCommandBody(left) === normalizeImportedCommandBody(right));
712
+ }
713
+ function normalizeImportedCommandBody(value) {
714
+ return value.trim().replace(/\r\n/g, "\n");
715
+ }
716
+ function mergeCommandFrontmatterForImport(existing, incoming) {
717
+ if (!existing && !incoming) {
718
+ return undefined;
719
+ }
720
+ const merged = existing ? cloneUnknown(existing) : {};
721
+ for (const [key, value] of Object.entries(incoming ?? {})) {
722
+ const current = merged[key];
723
+ if (current === undefined) {
724
+ merged[key] = cloneUnknown(value);
725
+ continue;
726
+ }
727
+ if (isObject(current) && isObject(value)) {
728
+ merged[key] = {
729
+ ...cloneUnknown(value),
730
+ ...cloneUnknown(current),
731
+ };
732
+ }
733
+ }
734
+ return Object.keys(merged).length > 0 ? merged : undefined;
735
+ }
736
+ function parseGeminiTomlCommandForImport(sourcePath) {
737
+ const raw = fs.readFileSync(sourcePath, "utf8");
738
+ let parsed;
739
+ try {
740
+ parsed = TOML.parse(raw);
741
+ }
742
+ catch {
743
+ return null;
744
+ }
745
+ if (!isObject(parsed) || typeof parsed.prompt !== "string") {
746
+ return null;
747
+ }
748
+ const body = normalizeCommandArgumentsForCanonical(parsed.prompt, "gemini");
749
+ const frontmatter = cloneUnknown(parsed);
750
+ delete frontmatter.prompt;
751
+ const normalizedFrontmatter = Object.keys(frontmatter).length > 0 ? frontmatter : undefined;
752
+ const fileName = toCanonicalCommandFileName(path.basename(sourcePath));
753
+ const content = buildCommandMarkdownForImport(normalizedFrontmatter, body);
754
+ return {
755
+ fileName,
756
+ sourcePath,
757
+ content,
758
+ body,
759
+ frontmatter: normalizedFrontmatter,
760
+ };
761
+ }
762
+ function parseGitHubAgentsDirForImport(sourceAgentsDir) {
763
+ return fs
764
+ .readdirSync(sourceAgentsDir)
765
+ .filter((entry) => isProviderEntityFileName({
766
+ provider: "copilot",
767
+ entity: "agent",
768
+ fileName: entry,
769
+ }))
770
+ .sort((a, b) => a.localeCompare(b))
771
+ .map((entry) => parseGitHubAgentForImport(path.join(sourceAgentsDir, entry)));
772
+ }
773
+ function parseGitHubAgentForImport(sourcePath) {
774
+ const raw = fs.readFileSync(sourcePath, "utf8");
775
+ const parsed = matter(raw);
776
+ const data = isObject(parsed.data)
777
+ ? parsed.data
778
+ : {};
779
+ const fileName = path.basename(sourcePath);
780
+ const fallbackName = inferAgentNameFromFile(fileName);
781
+ const name = typeof data.name === "string" && data.name.trim().length > 0
782
+ ? data.name.trim()
783
+ : fallbackName;
784
+ const description = typeof data.description === "string" && data.description.trim().length > 0
785
+ ? data.description.trim()
786
+ : `Imported from Copilot agent "${name}".`;
787
+ const frontmatter = {
788
+ name,
789
+ description,
790
+ };
791
+ for (const provider of ALL_PROVIDERS) {
792
+ if (provider === "copilot")
793
+ continue;
794
+ const value = data[provider];
795
+ if (value === false || isObject(value)) {
796
+ frontmatter[provider] = cloneUnknown(value);
797
+ }
798
+ }
799
+ const inferredCopilotConfig = {};
800
+ for (const [key, value] of Object.entries(data)) {
801
+ if (key === "name" || key === "description")
802
+ continue;
803
+ if (ALL_PROVIDERS.includes(key))
804
+ continue;
805
+ inferredCopilotConfig[key] = cloneUnknown(value);
806
+ }
807
+ const explicitCopilot = data.copilot;
808
+ if (explicitCopilot === false) {
809
+ frontmatter.copilot = false;
810
+ }
811
+ else {
812
+ const copilotConfig = isObject(explicitCopilot)
813
+ ? cloneUnknown(explicitCopilot)
814
+ : {};
815
+ for (const [key, value] of Object.entries(inferredCopilotConfig)) {
816
+ if (!(key in copilotConfig)) {
817
+ copilotConfig[key] = value;
818
+ }
819
+ }
820
+ if (Object.keys(copilotConfig).length > 0) {
821
+ frontmatter.copilot = copilotConfig;
822
+ }
823
+ }
824
+ return {
825
+ name,
826
+ description,
827
+ body: parsed.content.trimStart(),
828
+ frontmatter: frontmatter,
829
+ sourcePath,
830
+ fileName,
831
+ };
832
+ }
833
+ function normalizeGitHubPromptForImport(command) {
834
+ const fileName = toCanonicalCommandFileName(command.fileName);
835
+ if (!command.frontmatter) {
836
+ return {
837
+ ...command,
838
+ fileName,
839
+ };
840
+ }
841
+ const nextFrontmatter = {};
842
+ for (const provider of ALL_PROVIDERS) {
843
+ if (provider === "copilot")
844
+ continue;
845
+ const value = command.frontmatter[provider];
846
+ if (value === false || isObject(value)) {
847
+ nextFrontmatter[provider] = cloneUnknown(value);
848
+ }
849
+ }
850
+ const explicitCopilot = command.frontmatter.copilot;
851
+ if (explicitCopilot === false) {
852
+ nextFrontmatter.copilot = false;
853
+ }
854
+ else {
855
+ const copilotConfig = isObject(explicitCopilot)
856
+ ? cloneUnknown(explicitCopilot)
857
+ : {};
858
+ for (const [key, value] of Object.entries(command.frontmatter)) {
859
+ if (ALL_PROVIDERS.includes(key))
860
+ continue;
861
+ if (!(key in copilotConfig)) {
862
+ copilotConfig[key] = cloneUnknown(value);
863
+ }
864
+ }
865
+ if (Object.keys(copilotConfig).length > 0) {
866
+ nextFrontmatter.copilot = copilotConfig;
867
+ }
868
+ }
869
+ const frontmatter = Object.keys(nextFrontmatter).length > 0 ? nextFrontmatter : undefined;
870
+ const body = normalizeCommandArgumentsForCanonical(command.body, "copilot").trimStart();
871
+ return {
872
+ ...command,
873
+ fileName,
874
+ body,
875
+ frontmatter,
876
+ content: buildCommandMarkdownForImport(frontmatter, body),
877
+ };
878
+ }
879
+ function normalizeGeminiCommandForImport(command) {
880
+ const fileName = toCanonicalCommandFileName(command.fileName);
881
+ const body = normalizeCommandArgumentsForCanonical(command.body, "gemini");
882
+ if (!command.frontmatter) {
883
+ return {
884
+ ...command,
885
+ fileName,
886
+ body,
887
+ content: buildCommandMarkdownForImport(undefined, body),
888
+ };
889
+ }
890
+ const nextFrontmatter = {};
891
+ for (const provider of ALL_PROVIDERS) {
892
+ if (provider === "gemini")
893
+ continue;
894
+ const value = command.frontmatter[provider];
895
+ if (value === false || isObject(value)) {
896
+ nextFrontmatter[provider] = cloneUnknown(value);
897
+ }
898
+ }
899
+ if (typeof command.frontmatter.description === "string") {
900
+ nextFrontmatter.description = command.frontmatter.description;
901
+ }
902
+ const explicitGemini = command.frontmatter.gemini;
903
+ if (explicitGemini === false) {
904
+ nextFrontmatter.gemini = false;
905
+ }
906
+ else {
907
+ const geminiConfig = isObject(explicitGemini)
908
+ ? cloneUnknown(explicitGemini)
909
+ : {};
910
+ for (const [key, value] of Object.entries(command.frontmatter)) {
911
+ if (key === "description")
912
+ continue;
913
+ if (ALL_PROVIDERS.includes(key))
914
+ continue;
915
+ if (!(key in geminiConfig)) {
916
+ geminiConfig[key] = cloneUnknown(value);
917
+ }
918
+ }
919
+ if (Object.keys(geminiConfig).length > 0) {
920
+ nextFrontmatter.gemini = geminiConfig;
921
+ }
922
+ }
923
+ const frontmatter = Object.keys(nextFrontmatter).length > 0 ? nextFrontmatter : undefined;
924
+ return {
925
+ ...command,
926
+ fileName,
927
+ body,
928
+ frontmatter,
929
+ content: buildCommandMarkdownForImport(frontmatter, body),
930
+ };
931
+ }
932
+ function buildCommandMarkdownForImport(frontmatter, body) {
933
+ if (!frontmatter) {
934
+ return body.endsWith("\n") ? body : `${body}\n`;
935
+ }
936
+ const fm = YAML.stringify(frontmatter, { lineWidth: 0 }).trimEnd();
937
+ return `---\n${fm}\n---\n\n${body}${body.endsWith("\n") ? "" : "\n"}`;
938
+ }
939
+ function inferAgentNameFromFile(fileName) {
940
+ const base = fileName
941
+ .replace(/\.agent\.md$/i, "")
942
+ .replace(/\.md$/i, "")
943
+ .trim();
944
+ return base || "agent";
945
+ }
946
+ function toCanonicalCommandFileName(fileName) {
947
+ const lower = fileName.toLowerCase();
948
+ if (lower.endsWith(".prompt.md")) {
949
+ return `${fileName.slice(0, -".prompt.md".length)}.md`;
950
+ }
951
+ if (lower.endsWith(".toml")) {
952
+ return `${fileName.slice(0, -".toml".length)}.md`;
953
+ }
954
+ return fileName;
955
+ }
956
+ function cloneUnknown(value) {
957
+ return JSON.parse(JSON.stringify(value));
958
+ }
496
959
  async function resolveAgentsToImport(options) {
497
960
  const requestedAgents = normalizeRequestedAgents(options.requestedAgents);
498
961
  if (requestedAgents && requestedAgents.length > 0) {
@@ -653,6 +1116,7 @@ function findMatchingLockEntry(entries, key) {
653
1116
  sameRequestedAgentsForMatch(entry.requestedAgents, key.requestedAgents) &&
654
1117
  sameStringSelectionForMatch(entry.selectedSourceCommands, key.selectedSourceCommands) &&
655
1118
  sameStringSelectionForMatch(entry.selectedSourceMcpServers, key.selectedSourceMcpServers) &&
1119
+ sameStringSelectionForMatch(entry.selectedSourceRules, key.selectedSourceRules, { wildcardWhenRightIsUndefined: true }) &&
656
1120
  sameStringSelectionForMatch(entry.selectedSourceSkills, key.selectedSourceSkills, { wildcardWhenRightIsUndefined: true }) &&
657
1121
  sameStringSelectionForMatch(entry.skillsProviders, key.skillsProviders, {
658
1122
  wildcardWhenRightIsUndefined: true,
@@ -667,6 +1131,7 @@ function findRelaxedCommandEntry(entries, key) {
667
1131
  return undefined;
668
1132
  const mixed = matches.find((entry) => entry.importedAgents.length > 0 ||
669
1133
  entry.importedMcpServers.length > 0 ||
1134
+ entry.importedRules.length > 0 ||
670
1135
  entry.importedSkills.length > 0);
671
1136
  return mixed ?? matches[0];
672
1137
  }
@@ -714,6 +1179,11 @@ function computeTrackedEntitiesForLock(options) {
714
1179
  (options.selectedSourceMcpServers?.length ?? 0) > 0) {
715
1180
  tracked.push("mcp");
716
1181
  }
1182
+ if (options.importedRules.length > 0 ||
1183
+ (options.selectedSourceRules?.length ?? 0) > 0 ||
1184
+ Object.keys(options.ruleRenameMap ?? {}).length > 0) {
1185
+ tracked.push("rule");
1186
+ }
717
1187
  if (options.importedSkills.length > 0 ||
718
1188
  (options.selectedSourceSkills?.length ?? 0) > 0 ||
719
1189
  (options.skillsProviders?.length ?? 0) > 0 ||
@@ -754,6 +1224,44 @@ function resolveMappedTargetFileName(sourceFileName, renameMap) {
754
1224
  }
755
1225
  return undefined;
756
1226
  }
1227
+ function normalizeRuleRenameMap(renameMap) {
1228
+ if (!renameMap)
1229
+ return undefined;
1230
+ const normalizedEntries = Object.entries(renameMap)
1231
+ .map(([sourceSelector, importedName]) => {
1232
+ const normalizedSourceSelector = normalizeRuleSelector(sourceSelector);
1233
+ const importedBaseName = path.basename(importedName.trim());
1234
+ if (!normalizedSourceSelector || !importedBaseName) {
1235
+ return null;
1236
+ }
1237
+ const ext = path.extname(importedBaseName);
1238
+ const stem = stripRuleFileExtension(importedBaseName);
1239
+ const normalizedTarget = `${slugify(stem) || "rule"}${ext || ".md"}`;
1240
+ return [normalizedSourceSelector, normalizedTarget];
1241
+ })
1242
+ .filter((entry) => entry !== null);
1243
+ if (normalizedEntries.length === 0)
1244
+ return undefined;
1245
+ return Object.fromEntries(normalizedEntries);
1246
+ }
1247
+ function resolveMappedTargetRuleFileName(sourceFileName, renameMap) {
1248
+ if (!renameMap)
1249
+ return undefined;
1250
+ const normalizedSourceName = normalizeRuleSelector(sourceFileName);
1251
+ for (const [sourceSelector, importedName] of Object.entries(renameMap)) {
1252
+ if (normalizeRuleSelector(sourceSelector) !== normalizedSourceName) {
1253
+ continue;
1254
+ }
1255
+ const importedBaseName = path.basename(importedName.trim());
1256
+ if (!importedBaseName)
1257
+ return undefined;
1258
+ const ext = path.extname(importedBaseName);
1259
+ if (ext)
1260
+ return importedBaseName;
1261
+ return `${slugify(importedBaseName) || "rule"}.md`;
1262
+ }
1263
+ return undefined;
1264
+ }
757
1265
  function normalizeSkillRenameMap(renameMap) {
758
1266
  if (!renameMap)
759
1267
  return undefined;
@@ -954,6 +1462,58 @@ async function resolveCommandConflict(options) {
954
1462
  }
955
1463
  return options.targetFileName;
956
1464
  }
1465
+ async function resolveRuleConflict(options) {
1466
+ const targetPath = path.join(options.paths.rulesDir, options.targetFileName);
1467
+ if (!fs.existsSync(targetPath))
1468
+ return options.targetFileName;
1469
+ const existing = fs.readFileSync(targetPath, "utf8");
1470
+ if (existing === options.ruleContent)
1471
+ return options.targetFileName;
1472
+ if (options.yes) {
1473
+ return options.targetFileName;
1474
+ }
1475
+ if (options.nonInteractive) {
1476
+ throw new NonInteractiveConflictError(`Conflict for ${options.targetFileName}. Use --yes or run interactively.`);
1477
+ }
1478
+ const choice = await select({
1479
+ message: `Rule conflict for ${options.promptLabel}`,
1480
+ options: [
1481
+ { value: "overwrite", label: `Overwrite ${options.targetFileName}` },
1482
+ { value: "skip", label: "Skip this rule" },
1483
+ { value: "rename", label: "Rename imported rule" },
1484
+ ],
1485
+ });
1486
+ if (isCancel(choice)) {
1487
+ cancel("Operation cancelled.");
1488
+ process.exit(1);
1489
+ }
1490
+ if (choice === "skip")
1491
+ return null;
1492
+ if (choice === "rename") {
1493
+ const entered = await promptText({
1494
+ message: `New filename (without extension) for ${options.promptLabel}`,
1495
+ placeholder: options.targetFileName.replace(/\.(md|mdc)$/i, ""),
1496
+ validate(value) {
1497
+ if (!value.trim())
1498
+ return "Name is required.";
1499
+ if (/[\\/]/.test(value))
1500
+ return "Use a simple filename.";
1501
+ return undefined;
1502
+ },
1503
+ });
1504
+ if (isCancel(entered)) {
1505
+ cancel("Operation cancelled.");
1506
+ process.exit(1);
1507
+ }
1508
+ const extension = path.extname(options.targetFileName) || ".md";
1509
+ const renamedFileName = `${slugify(String(entered)) || "rule"}${extension}`;
1510
+ return resolveRuleConflict({
1511
+ ...options,
1512
+ targetFileName: renamedFileName,
1513
+ });
1514
+ }
1515
+ return options.targetFileName;
1516
+ }
957
1517
  async function resolveCommandsToImport(options) {
958
1518
  const selectors = options.selectors
959
1519
  .map((selector) => selector.trim())
@@ -1073,6 +1633,62 @@ async function resolveMcpServersToImport(options) {
1073
1633
  selectionMode,
1074
1634
  };
1075
1635
  }
1636
+ async function resolveRulesToImport(options) {
1637
+ const selectors = options.selectors
1638
+ .map((selector) => selector.trim())
1639
+ .filter(Boolean);
1640
+ if (selectors.length > 0) {
1641
+ const { selected, unmatched } = resolveRuleSelections(options.sourceRules, selectors);
1642
+ if (unmatched.length > 0) {
1643
+ throw new Error(`Rule(s) not found in source: ${unmatched.join(", ")}. Available: ${options.sourceRules.map((item) => item.fileName).join(", ")}`);
1644
+ }
1645
+ return {
1646
+ selectedRules: selected,
1647
+ selectionMode: "custom",
1648
+ };
1649
+ }
1650
+ const selectionResolution = await resolveSelectionModeWithSkip({
1651
+ entityLabel: "rules",
1652
+ selectionMode: options.selectionMode,
1653
+ promptForSelection: options.promptForRules,
1654
+ nonInteractive: options.nonInteractive,
1655
+ });
1656
+ const selectionMode = selectionResolution.selectionMode;
1657
+ if (selectionResolution.skipImport) {
1658
+ return {
1659
+ selectedRules: [],
1660
+ selectionMode: "custom",
1661
+ };
1662
+ }
1663
+ if (selectionMode === "all" ||
1664
+ !options.promptForRules ||
1665
+ options.nonInteractive) {
1666
+ return {
1667
+ selectedRules: options.sourceRules,
1668
+ selectionMode,
1669
+ };
1670
+ }
1671
+ const selected = await multiselect({
1672
+ message: withMultiselectHelp("Select rules to import"),
1673
+ options: options.sourceRules.map((item) => ({
1674
+ value: item.fileName,
1675
+ label: item.fileName,
1676
+ hint: item.name,
1677
+ })),
1678
+ initialValues: options.sourceRules.map((item) => item.fileName),
1679
+ });
1680
+ if (isCancel(selected)) {
1681
+ cancel("Operation cancelled.");
1682
+ process.exit(1);
1683
+ }
1684
+ const selectedNames = Array.isArray(selected)
1685
+ ? new Set(selected.map((value) => String(value)))
1686
+ : new Set();
1687
+ return {
1688
+ selectedRules: options.sourceRules.filter((item) => selectedNames.has(item.fileName)),
1689
+ selectionMode,
1690
+ };
1691
+ }
1076
1692
  async function resolveSkillsToImport(options) {
1077
1693
  const selectors = options.selectors
1078
1694
  .map((item) => item.trim())