@supercorks/skills-installer 1.11.1 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @supercorks/skills-installer
2
2
 
3
- Interactive CLI installer for AI agent skills and subagents. Selectively install resources for GitHub Copilot, Codex, Claude, and other AI assistants using Git sparse-checkout.
3
+ Interactive CLI installer for AI agent skills and subagents. Selectively install resources for GitHub Copilot, Codex, Claude, and other AI assistants using Git sparse-checkout and Codex agent conversion where needed.
4
4
 
5
5
  ## Usage
6
6
 
@@ -18,25 +18,35 @@ npx @supercorks/skills-installer install
18
18
 
19
19
  1. **Choose installation type** - Install skills, subagents, or both.
20
20
 
21
- 2. **Choose installation path(s)** - Select one or more locations where resources should be installed:
22
- - `.github/skills/` (Copilot)
23
- - `~/.codex/skills/` (Codex)
24
- - `.claude/skills/` (Claude)
25
- - `.github/agents/` (Copilot)
26
- - `.agents/agents/` (Codex)
27
- - `.claude/agents/` (Claude)
21
+ 2. **Choose installation path(s)** - Select one or more locations where resources should be installed. Global locations are shown before local locations. The installer labels each option with harness, scope, and available count, for example `~/.agents/skills/ (copilot/codex | global | 24 skills)`.
22
+
23
+ Skills:
24
+ - `~/.agents/skills/` (copilot/codex | global)
25
+ - `~/.claude/skills/` (claude | global)
26
+ - `.agents/skills/` (copilot/codex | local)
27
+ - `.claude/skills/` (claude | local)
28
+
29
+ Agents:
30
+ - `~/.agents/agents/` (copilot | global)
31
+ - `~/.claude/agents/` (claude | global)
32
+ - `~/.codex/agents/` (codex | global, installed as converted TOML agents)
33
+ - `.agents/agents/` (copilot | local)
34
+ - `.claude/agents/` (claude | local)
35
+ - `.codex/agents/` (codex | local, installed as converted TOML agents)
28
36
  - Custom path of your choice
29
37
 
30
38
  3. **Gitignore option** - If launched from inside a git repository, optionally add the installation path to `.gitignore`
31
39
 
32
- 4. **Select skills/subagents** - Interactive checkbox to pick what to install:
40
+ 4. **Select skills/subagents** - Interactive checkbox to pick what to install. If multiple locations are selected, the installer asks once and applies the same selection to every selected location:
33
41
  - Use `↑`/`↓` to navigate
34
42
  - Use `SPACE` to toggle selection
35
43
  - Use `→` to expand and lazy-load descriptions
36
44
  - Use `A` to toggle all
37
45
  - Press `ENTER` to confirm
38
46
 
39
- 5. **Sparse clone** - Only downloads selected skills/subagents using Git sparse-checkout, keeping the download minimal while preserving full git functionality.
47
+ 5. **Install backend**
48
+ - Skills and Markdown-based agents use Git sparse-checkout for minimal download while preserving full git functionality.
49
+ - Codex agents are generated as TOML files from the source Markdown agent definitions.
40
50
 
41
51
  ## Installed repositories
42
52
 
@@ -48,6 +58,8 @@ npx @supercorks/skills-installer install
48
58
  - **Minimal download** - Uses `git clone --filter=blob:none` for efficient cloning
49
59
  - **Push capable** - The sparse clone preserves the full git history, allowing you to commit and push changes
50
60
  - **Auto-discovery** - Fetches the latest skill list from the repository
61
+ - **Global and local targets** - Offers documented project/user locations for Copilot, Codex, and Claude where the resource format is compatible, with shared generic `~/.agents/skills/` and `.agents/skills/` targets for Copilot/Codex skills
62
+ - **Codex agent conversion** - Converts Markdown subagents into Codex TOML custom agents for `.codex/agents/` targets
51
63
  - **Recursive directory creation** - Custom paths are created automatically
52
64
 
53
65
  ## Requirements
@@ -60,7 +72,7 @@ npx @supercorks/skills-installer install
60
72
  Since the installation uses a sparse git checkout, you can pull updates:
61
73
 
62
74
  ```bash
63
- cd .github/skills # or wherever you installed
75
+ cd .agents/skills # or wherever you installed
64
76
  git pull
65
77
  ```
66
78
 
@@ -69,7 +81,7 @@ git pull
69
81
  You can add more skills to an existing installation:
70
82
 
71
83
  ```bash
72
- cd .github/skills
84
+ cd .agents/skills
73
85
  git sparse-checkout add new-skill-name
74
86
  ```
75
87
 
package/bin/install.js CHANGED
@@ -37,13 +37,15 @@ import {
37
37
  checkSubagentsForUpdates
38
38
  } from '../lib/git.js';
39
39
  import { createRequire } from 'module';
40
+ import { allAgentDetectionTargets, allSkillDetectionTargets, getAgentInstallMode } from '../lib/install-targets.js';
41
+ import { checkCodexAgentUpdates, listInstalledCodexAgents, syncCodexAgents } from '../lib/codex-agents.js';
40
42
 
41
43
  const require = createRequire(import.meta.url);
42
44
  const { version: VERSION } = require('../package.json');
43
45
 
44
46
  // Common installation paths to check for existing installations
45
- const SKILL_PATHS = ['.github/skills/', '~/.codex/skills/', '.claude/skills/'];
46
- const AGENT_PATHS = ['.github/agents/', '.agents/agents/', '.claude/agents/'];
47
+ const SKILL_PATHS = allSkillDetectionTargets().map(target => target.path);
48
+ const AGENT_PATHS = allAgentDetectionTargets().map(target => target.path);
47
49
 
48
50
  function resolveInstallPath(path) {
49
51
  if (path === '~') return homedir();
@@ -55,6 +57,14 @@ function isHomePath(path) {
55
57
  return path === '~' || path.startsWith('~/');
56
58
  }
57
59
 
60
+ function uniqueItems(items) {
61
+ return Array.from(new Set(items));
62
+ }
63
+
64
+ function unionSets(sets) {
65
+ return new Set(sets.flatMap(set => Array.from(set)));
66
+ }
67
+
58
68
  /**
59
69
  * Detect existing skill installations in common paths
60
70
  * @returns {Promise<Array<{path: string, skillCount: number, skills: string[]}>>}
@@ -92,6 +102,24 @@ async function detectExistingAgentInstallations() {
92
102
 
93
103
  for (const path of AGENT_PATHS) {
94
104
  const absolutePath = resolveInstallPath(path);
105
+ const installMode = getAgentInstallMode(path);
106
+
107
+ if (installMode === 'codex-toml') {
108
+ try {
109
+ const agents = await listInstalledCodexAgents(absolutePath);
110
+ if (agents.length > 0) {
111
+ installations.push({
112
+ path,
113
+ agentCount: agents.length,
114
+ agents
115
+ });
116
+ }
117
+ } catch {
118
+ // Ignore errors reading existing installations
119
+ }
120
+ continue;
121
+ }
122
+
95
123
  const gitDir = join(absolutePath, '.git');
96
124
 
97
125
  if (existsSync(gitDir)) {
@@ -220,24 +248,41 @@ async function runSkillsInstall() {
220
248
  const existingInstalls = await detectExistingSkillInstallations();
221
249
 
222
250
  // Ask where to install (showing existing installations if any)
223
- const installTargets = await promptInstallPath(existingInstalls);
251
+ const installTargets = await promptInstallPath(existingInstalls, skills.length);
224
252
 
225
- for (let i = 0; i < installTargets.length; i++) {
226
- const target = installTargets[i];
253
+ const targetContexts = [];
254
+ for (const [index, target] of installTargets.entries()) {
227
255
  if (installTargets.length > 1) {
228
- console.log(`\n📍 Skills target ${i + 1}/${installTargets.length}: ${target.path}`);
256
+ console.log(`\n📍 Preparing skills target ${index + 1}/${installTargets.length}: ${target.path}`);
257
+ }
258
+ targetContexts.push(await prepareSkillsInstallTarget(existingInstalls, target));
259
+ }
260
+
261
+ const installedSkills = uniqueItems(targetContexts.flatMap(context => context.installedSkills));
262
+ const skillsNeedingUpdate = unionSets(targetContexts.map(context => context.skillsNeedingUpdate));
263
+
264
+ const selectedSkills = await promptSkillSelection(
265
+ skills,
266
+ installedSkills,
267
+ skillsNeedingUpdate,
268
+ (skillFolder) => fetchSkillMetadata(skillFolder)
269
+ );
270
+
271
+ for (let i = 0; i < targetContexts.length; i++) {
272
+ if (targetContexts.length > 1) {
273
+ console.log(`\n📍 Skills target ${i + 1}/${targetContexts.length}: ${targetContexts[i].installPath}`);
229
274
  }
230
- await runSkillsInstallForTarget(skills, existingInstalls, target);
275
+ await runSkillsInstallForTarget(skills, targetContexts[i], selectedSkills);
231
276
  }
232
277
  }
233
278
 
234
279
  /**
235
- * Install/update skills for a specific target path
236
- * @param {Array<{name: string, description: string, folder: string}>} skills
280
+ * Prepare a specific skills target for installation/update.
237
281
  * @param {Array<{path: string, skillCount: number, skills: string[]}>} existingInstalls
238
282
  * @param {{path: string, isExisting: boolean}} target
283
+ * @returns {Promise<object>}
239
284
  */
240
- async function runSkillsInstallForTarget(skills, existingInstalls, target) {
285
+ async function prepareSkillsInstallTarget(existingInstalls, target) {
241
286
  const { path: installPath, isExisting } = target;
242
287
  const absoluteInstallPath = resolveInstallPath(installPath);
243
288
  const gitDir = join(absoluteInstallPath, '.git');
@@ -291,13 +336,33 @@ async function runSkillsInstallForTarget(skills, existingInstalls, target) {
291
336
  shouldGitignore = await promptGitignore(installPath);
292
337
  }
293
338
 
294
- // Select skills (pre-select installed skills in manage mode)
295
- const selectedSkills = await promptSkillSelection(
296
- skills,
339
+ return {
340
+ ...target,
341
+ installPath,
342
+ absoluteInstallPath,
297
343
  installedSkills,
298
- skillsNeedingUpdate,
299
- (skillFolder) => fetchSkillMetadata(skillFolder)
300
- );
344
+ isManageMode,
345
+ shouldGitignore,
346
+ gitignorePath,
347
+ skillsNeedingUpdate
348
+ };
349
+ }
350
+
351
+ /**
352
+ * Install/update skills for a specific target path
353
+ * @param {Array<{name: string, description: string, folder: string}>} skills
354
+ * @param {object} targetContext
355
+ * @param {string[]} selectedSkills
356
+ */
357
+ async function runSkillsInstallForTarget(skills, targetContext, selectedSkills) {
358
+ const {
359
+ installPath,
360
+ absoluteInstallPath,
361
+ installedSkills,
362
+ isManageMode,
363
+ shouldGitignore,
364
+ gitignorePath
365
+ } = targetContext;
301
366
 
302
367
  // Perform installation or update
303
368
  console.log('');
@@ -380,35 +445,53 @@ async function runSubagentsInstall() {
380
445
  const existingInstalls = await detectExistingAgentInstallations();
381
446
 
382
447
  // Ask where to install (showing existing installations if any)
383
- const installTargets = await promptAgentInstallPath(existingInstalls);
448
+ const installTargets = await promptAgentInstallPath(existingInstalls, subagents.length);
384
449
 
385
- for (let i = 0; i < installTargets.length; i++) {
386
- const target = installTargets[i];
450
+ const targetContexts = [];
451
+ for (const [index, target] of installTargets.entries()) {
387
452
  if (installTargets.length > 1) {
388
- console.log(`\n📍 Subagents target ${i + 1}/${installTargets.length}: ${target.path}`);
453
+ console.log(`\n📍 Preparing subagents target ${index + 1}/${installTargets.length}: ${target.path}`);
454
+ }
455
+ targetContexts.push(await prepareSubagentsInstallTarget(existingInstalls, target));
456
+ }
457
+
458
+ const installedAgents = uniqueItems(targetContexts.flatMap(context => context.installedAgents));
459
+ const subagentsNeedingUpdate = unionSets(targetContexts.map(context => context.subagentsNeedingUpdate));
460
+
461
+ const selectedAgents = await promptSubagentSelection(
462
+ subagents,
463
+ installedAgents,
464
+ subagentsNeedingUpdate,
465
+ (filename) => fetchSubagentMetadata(filename)
466
+ );
467
+
468
+ for (let i = 0; i < targetContexts.length; i++) {
469
+ if (targetContexts.length > 1) {
470
+ console.log(`\n📍 Subagents target ${i + 1}/${targetContexts.length}: ${targetContexts[i].installPath}`);
389
471
  }
390
- await runSubagentsInstallForTarget(subagents, existingInstalls, target);
472
+ await runSubagentsInstallForTarget(subagents, targetContexts[i], selectedAgents);
391
473
  }
392
474
  }
393
475
 
394
476
  /**
395
- * Install/update subagents for a specific target path
396
- * @param {Array<{name: string, description: string, filename: string}>} subagents
477
+ * Prepare a specific subagent target for installation/update.
397
478
  * @param {Array<{path: string, agentCount: number, agents: string[]}>} existingInstalls
398
479
  * @param {{path: string, isExisting: boolean}} target
480
+ * @returns {Promise<object>}
399
481
  */
400
- async function runSubagentsInstallForTarget(subagents, existingInstalls, target) {
482
+ async function prepareSubagentsInstallTarget(existingInstalls, target) {
401
483
  const { path: installPath, isExisting } = target;
402
484
  const absoluteInstallPath = resolveInstallPath(installPath);
485
+ const installMode = getAgentInstallMode(installPath);
403
486
  const gitDir = join(absoluteInstallPath, '.git');
404
- const hasExistingRepo = existsSync(gitDir);
487
+ const hasExistingRepo = installMode === 'sparse-git' && existsSync(gitDir);
405
488
 
406
489
  // Get currently installed subagents if managing existing installation
407
490
  let installedAgents = [];
408
491
  if (isExisting) {
409
492
  const existingInstall = existingInstalls.find(i => i.path === installPath);
410
493
  installedAgents = existingInstall?.agents || [];
411
- } else {
494
+ } else if (installMode === 'sparse-git') {
412
495
  // Check if manually entered path has an existing installation
413
496
  if (hasExistingRepo) {
414
497
  try {
@@ -418,16 +501,27 @@ async function runSubagentsInstallForTarget(subagents, existingInstalls, target)
418
501
  // Prompt will default to selecting all subagents.
419
502
  }
420
503
  }
504
+ } else {
505
+ try {
506
+ installedAgents = await listInstalledCodexAgents(absoluteInstallPath);
507
+ } catch {
508
+ // Ignore detection failures for custom Codex agent paths.
509
+ }
421
510
  }
422
511
 
423
- const isManageMode = isExisting || hasExistingRepo || installedAgents.length > 0;
512
+ const isManageMode = installMode === 'sparse-git'
513
+ ? (isExisting || hasExistingRepo || installedAgents.length > 0)
514
+ : installedAgents.length > 0;
424
515
 
425
516
  // Check for updates if in manage mode
426
517
  let subagentsNeedingUpdate = new Set();
427
518
  if (isManageMode) {
428
519
  const updateSpinner = showSpinner('Checking for available updates...');
429
520
  try {
430
- subagentsNeedingUpdate = await checkSubagentsForUpdates(absoluteInstallPath, installedAgents);
521
+ subagentsNeedingUpdate = installMode === 'sparse-git'
522
+ ? await checkSubagentsForUpdates(absoluteInstallPath, installedAgents)
523
+ : await checkCodexAgentUpdates(absoluteInstallPath, installedAgents);
524
+
431
525
  if (subagentsNeedingUpdate.size > 0) {
432
526
  updateSpinner.stop(`✅ Found ${subagentsNeedingUpdate.size} subagent${subagentsNeedingUpdate.size !== 1 ? 's' : ''} with updates available`);
433
527
  } else {
@@ -451,13 +545,35 @@ async function runSubagentsInstallForTarget(subagents, existingInstalls, target)
451
545
  shouldGitignore = await promptGitignore(installPath);
452
546
  }
453
547
 
454
- // Select subagents (pre-select installed ones in manage mode)
455
- const selectedAgents = await promptSubagentSelection(
456
- subagents,
548
+ return {
549
+ ...target,
550
+ installPath,
551
+ absoluteInstallPath,
552
+ installMode,
457
553
  installedAgents,
458
- subagentsNeedingUpdate,
459
- (filename) => fetchSubagentMetadata(filename)
460
- );
554
+ isManageMode,
555
+ shouldGitignore,
556
+ gitignorePath,
557
+ subagentsNeedingUpdate
558
+ };
559
+ }
560
+
561
+ /**
562
+ * Install/update subagents for a specific target path
563
+ * @param {Array<{name: string, description: string, filename: string}>} subagents
564
+ * @param {object} targetContext
565
+ * @param {string[]} selectedAgents
566
+ */
567
+ async function runSubagentsInstallForTarget(subagents, targetContext, selectedAgents) {
568
+ const {
569
+ installPath,
570
+ absoluteInstallPath,
571
+ installMode,
572
+ installedAgents,
573
+ isManageMode,
574
+ shouldGitignore,
575
+ gitignorePath
576
+ } = targetContext;
461
577
 
462
578
  // Perform installation or update
463
579
  console.log('');
@@ -475,9 +591,15 @@ async function runSubagentsInstallForTarget(subagents, existingInstalls, target)
475
591
  const updateSpinner = showSpinner('Updating subagents installation...');
476
592
 
477
593
  try {
478
- await updateSubagentsSparseCheckout(absoluteInstallPath, selectedAgents, (message) => {
479
- updateSpinner.stop(` ${message}`);
480
- });
594
+ if (installMode === 'sparse-git') {
595
+ await updateSubagentsSparseCheckout(absoluteInstallPath, selectedAgents, (message) => {
596
+ updateSpinner.stop(` ${message}`);
597
+ });
598
+ } else {
599
+ await syncCodexAgents(absoluteInstallPath, selectedAgents, (message) => {
600
+ updateSpinner.stop(` ${message}`);
601
+ });
602
+ }
481
603
  } catch (error) {
482
604
  updateSpinner.stop('❌ Update failed');
483
605
  showError(error.message);
@@ -490,9 +612,15 @@ async function runSubagentsInstallForTarget(subagents, existingInstalls, target)
490
612
  const installSpinner = showSpinner('Installing selected subagents...');
491
613
 
492
614
  try {
493
- await sparseCloneSubagents(installPath, selectedAgents, (message) => {
494
- installSpinner.stop(` ${message}`);
495
- });
615
+ if (installMode === 'sparse-git') {
616
+ await sparseCloneSubagents(installPath, selectedAgents, (message) => {
617
+ installSpinner.stop(` ${message}`);
618
+ });
619
+ } else {
620
+ await syncCodexAgents(absoluteInstallPath, selectedAgents, (message) => {
621
+ installSpinner.stop(` ${message}`);
622
+ });
623
+ }
496
624
  } catch (error) {
497
625
  installSpinner.stop('❌ Installation failed');
498
626
  showError(error.message);
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Convert Markdown subagent definitions into Codex TOML custom agents.
3
+ */
4
+
5
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
6
+ import { join, resolve } from 'path';
7
+ import { homedir } from 'os';
8
+ import { fetchSubagentContent, humanizeAgentName, parseSubagentDefinition } from './subagents.js';
9
+
10
+ const GENERATED_COMMENT_PREFIX = '# Generated by @supercorks/skills-installer from ';
11
+
12
+ function resolvePath(path) {
13
+ if (path === '~') return homedir();
14
+ if (path.startsWith('~/')) return resolve(homedir(), path.slice(2));
15
+ return resolve(path);
16
+ }
17
+
18
+ export function codexTomlFilenameForAgent(agentFilename) {
19
+ return `${agentFilename.replace(/\.agent\.md$/i, '')}.toml`;
20
+ }
21
+
22
+ export function codexAgentNameForAgent(agentFilename, displayName = '') {
23
+ const preferred = displayName || agentFilename.replace(/\.agent\.md$/i, '');
24
+ return preferred
25
+ .trim()
26
+ .toLowerCase()
27
+ .replace(/[^a-z0-9]+/g, '_')
28
+ .replace(/^_+|_+$/g, '') || 'agent';
29
+ }
30
+
31
+ function escapeTomlBasicString(value) {
32
+ return String(value)
33
+ .replace(/\\/g, '\\\\')
34
+ .replace(/"/g, '\\"')
35
+ .replace(/\n/g, ' ')
36
+ .trim();
37
+ }
38
+
39
+ function formatDeveloperInstructions(body) {
40
+ const normalizedBody = (body || '').replace(/\r\n/g, '\n').trim();
41
+ if (!normalizedBody) {
42
+ return "developer_instructions = '''\nAct as the requested specialist and complete the assigned task.\n'''";
43
+ }
44
+
45
+ if (!normalizedBody.includes("'''")) {
46
+ return `developer_instructions = '''\n${normalizedBody}\n'''`;
47
+ }
48
+
49
+ const escaped = normalizedBody
50
+ .replace(/\\/g, '\\\\')
51
+ .replace(/"""/g, '\\"\\"\\"');
52
+
53
+ return `developer_instructions = """\n${escaped}\n"""`;
54
+ }
55
+
56
+ export function convertSubagentMarkdownToCodexToml(content, agentFilename) {
57
+ const definition = parseSubagentDefinition(content, agentFilename);
58
+ const outputFilename = codexTomlFilenameForAgent(agentFilename);
59
+ const codexName = codexAgentNameForAgent(agentFilename, definition.name);
60
+ const description = definition.description || `${humanizeAgentName(agentFilename)} custom agent`;
61
+
62
+ const toml = [
63
+ `${GENERATED_COMMENT_PREFIX}${agentFilename}`,
64
+ `name = "${escapeTomlBasicString(codexName)}"`,
65
+ `description = "${escapeTomlBasicString(description)}"`,
66
+ formatDeveloperInstructions(definition.body),
67
+ '',
68
+ ].join('\n');
69
+
70
+ return {
71
+ sourceFilename: agentFilename,
72
+ outputFilename,
73
+ name: definition.name,
74
+ description,
75
+ toml,
76
+ };
77
+ }
78
+
79
+ function parseGeneratedSourceFilename(tomlContent, outputFilename) {
80
+ const commentMatch = tomlContent.match(/^# Generated by @supercorks\/skills-installer from ([^\n]+)$/m);
81
+ if (commentMatch) {
82
+ return commentMatch[1].trim();
83
+ }
84
+
85
+ return null;
86
+ }
87
+
88
+ function listGeneratedCodexAgentEntries(targetPath) {
89
+ const absolutePath = resolvePath(targetPath);
90
+ if (!existsSync(absolutePath)) {
91
+ return [];
92
+ }
93
+
94
+ return readdirSync(absolutePath)
95
+ .filter(name => name.endsWith('.toml'))
96
+ .map(outputFilename => {
97
+ const absoluteFilePath = join(absolutePath, outputFilename);
98
+ const content = readFileSync(absoluteFilePath, 'utf8');
99
+ const sourceFilename = parseGeneratedSourceFilename(content, outputFilename);
100
+ if (!sourceFilename) {
101
+ return null;
102
+ }
103
+
104
+ return {
105
+ sourceFilename,
106
+ outputFilename,
107
+ absoluteFilePath,
108
+ content,
109
+ };
110
+ })
111
+ .filter(Boolean);
112
+ }
113
+
114
+ export async function listInstalledCodexAgents(targetPath) {
115
+ return listGeneratedCodexAgentEntries(targetPath).map(entry => entry.sourceFilename);
116
+ }
117
+
118
+ export async function checkCodexAgentUpdates(targetPath, agentFilenames) {
119
+ const installedEntries = listGeneratedCodexAgentEntries(targetPath);
120
+ const bySourceFilename = new Map(installedEntries.map(entry => [entry.sourceFilename, entry]));
121
+ const needsUpdate = new Set();
122
+
123
+ for (const agentFilename of agentFilenames) {
124
+ const entry = bySourceFilename.get(agentFilename);
125
+ if (!entry) {
126
+ continue;
127
+ }
128
+
129
+ try {
130
+ const content = await fetchSubagentContent(agentFilename);
131
+ const converted = convertSubagentMarkdownToCodexToml(content, agentFilename);
132
+ if (converted.toml !== entry.content) {
133
+ needsUpdate.add(agentFilename);
134
+ }
135
+ } catch {
136
+ // Skip update markers when the remote file cannot be fetched.
137
+ }
138
+ }
139
+
140
+ return needsUpdate;
141
+ }
142
+
143
+ export async function syncCodexAgents(targetPath, agentFilenames, onProgress = () => {}) {
144
+ const absolutePath = resolvePath(targetPath);
145
+ mkdirSync(absolutePath, { recursive: true });
146
+
147
+ const existingEntries = listGeneratedCodexAgentEntries(absolutePath);
148
+ const selectedSet = new Set(agentFilenames);
149
+
150
+ for (const entry of existingEntries) {
151
+ if (!selectedSet.has(entry.sourceFilename) && existsSync(entry.absoluteFilePath)) {
152
+ rmSync(entry.absoluteFilePath, { force: true });
153
+ }
154
+ }
155
+
156
+ for (let index = 0; index < agentFilenames.length; index += 1) {
157
+ const agentFilename = agentFilenames[index];
158
+ onProgress(`Converting ${index + 1}/${agentFilenames.length}: ${agentFilename}`);
159
+ const content = await fetchSubagentContent(agentFilename);
160
+ const converted = convertSubagentMarkdownToCodexToml(content, agentFilename);
161
+ writeFileSync(join(absolutePath, converted.outputFilename), converted.toml, 'utf8');
162
+ }
163
+
164
+ onProgress('Done!');
165
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Supported install targets for skills and agents.
3
+ */
4
+
5
+ export const SKILL_INSTALL_TARGETS = [
6
+ { path: '~/.agents/skills/', harness: 'copilot/codex', scope: 'global' },
7
+ { path: '~/.claude/skills/', harness: 'claude', scope: 'global' },
8
+ { path: '.agents/skills/', harness: 'copilot/codex', scope: 'local' },
9
+ { path: '.claude/skills/', harness: 'claude', scope: 'local' }
10
+ ];
11
+
12
+ export const LEGACY_SKILL_INSTALL_TARGETS = [
13
+ {
14
+ path: '.github/skills/',
15
+ harness: 'copilot',
16
+ scope: 'legacy local'
17
+ },
18
+ {
19
+ path: '~/.copilot/skills/',
20
+ harness: 'copilot',
21
+ scope: 'legacy global'
22
+ },
23
+ {
24
+ path: '~/.codex/skills/',
25
+ harness: 'codex',
26
+ scope: 'legacy global'
27
+ }
28
+ ];
29
+
30
+ export const AGENT_INSTALL_TARGETS = [
31
+ { path: '~/.agents/agents/', harness: 'copilot', scope: 'global', installMode: 'sparse-git' },
32
+ { path: '~/.claude/agents/', harness: 'claude', scope: 'global', installMode: 'sparse-git' },
33
+ { path: '~/.codex/agents/', harness: 'codex', scope: 'global', installMode: 'codex-toml' },
34
+ { path: '.agents/agents/', harness: 'copilot', scope: 'local', installMode: 'sparse-git' },
35
+ { path: '.claude/agents/', harness: 'claude', scope: 'local', installMode: 'sparse-git' },
36
+ { path: '.codex/agents/', harness: 'codex', scope: 'local', installMode: 'codex-toml' }
37
+ ];
38
+
39
+ export const LEGACY_AGENT_INSTALL_TARGETS = [
40
+ {
41
+ path: '.github/agents/',
42
+ harness: 'copilot',
43
+ scope: 'legacy local'
44
+ },
45
+ {
46
+ path: '~/.copilot/agents/',
47
+ harness: 'copilot',
48
+ scope: 'legacy global'
49
+ }
50
+ ];
51
+
52
+ export function allSkillDetectionTargets() {
53
+ return [...SKILL_INSTALL_TARGETS, ...LEGACY_SKILL_INSTALL_TARGETS];
54
+ }
55
+
56
+ export function allAgentDetectionTargets() {
57
+ return [...AGENT_INSTALL_TARGETS, ...LEGACY_AGENT_INSTALL_TARGETS];
58
+ }
59
+
60
+ export function orderTargetsGlobalFirst(targets) {
61
+ return [...targets].sort((left, right) => {
62
+ const leftGlobal = left.scope.includes('global') ? 0 : 1;
63
+ const rightGlobal = right.scope.includes('global') ? 0 : 1;
64
+
65
+ if (leftGlobal !== rightGlobal) {
66
+ return leftGlobal - rightGlobal;
67
+ }
68
+
69
+ return targets.indexOf(left) - targets.indexOf(right);
70
+ });
71
+ }
72
+
73
+ export function getAgentInstallMode(path) {
74
+ const exactTarget = getTargetByPath(AGENT_INSTALL_TARGETS, path);
75
+ if (exactTarget?.installMode) {
76
+ return exactTarget.installMode;
77
+ }
78
+
79
+ const normalizedPath = path.replace(/\\/g, '/').replace(/\/+$/, '');
80
+ if (normalizedPath.endsWith('/.codex/agents') || normalizedPath === '.codex/agents' || normalizedPath === '~/.codex/agents') {
81
+ return 'codex-toml';
82
+ }
83
+
84
+ return 'sparse-git';
85
+ }
86
+
87
+ export function getTargetByPath(targets, path) {
88
+ return targets.find(target => target.path === path);
89
+ }
90
+
91
+ export function formatTargetLabel(target, count, noun, { installed = false } = {}) {
92
+ const plural = count === 1 ? noun : `${noun}s`;
93
+ const countText = installed ? `${count} ${plural} installed` : `${count} ${plural}`;
94
+ const detail = target
95
+ ? `${target.harness} | ${target.scope} | ${countText}`
96
+ : countText;
97
+
98
+ return `${target?.path || ''} (${detail})`;
99
+ }
package/lib/prompts.js CHANGED
@@ -4,18 +4,21 @@
4
4
 
5
5
  import inquirer from 'inquirer';
6
6
  import * as readline from 'readline';
7
+ import {
8
+ AGENT_INSTALL_TARGETS,
9
+ SKILL_INSTALL_TARGETS,
10
+ allAgentDetectionTargets,
11
+ allSkillDetectionTargets,
12
+ formatTargetLabel,
13
+ getTargetByPath,
14
+ orderTargetsGlobalFirst
15
+ } from './install-targets.js';
7
16
 
8
17
  const SKILL_PATH_CHOICES = {
9
- GITHUB: '.github/skills/',
10
- CODEX_HOME: '~/.codex/skills/',
11
- CLAUDE: '.claude/skills/',
12
18
  CUSTOM: '__custom__'
13
19
  };
14
20
 
15
21
  const AGENT_PATH_CHOICES = {
16
- GITHUB: '.github/agents/',
17
- CODEX: '.agents/agents/',
18
- CLAUDE: '.claude/agents/',
19
22
  CUSTOM: '__custom__'
20
23
  };
21
24
 
@@ -61,36 +64,44 @@ export async function promptInstallType() {
61
64
  /**
62
65
  * Prompt user to select one or more installation paths, showing existing installations
63
66
  * @param {Array<{path: string, skillCount: number}>} existingInstalls - Detected existing installations
67
+ * @param {number} availableSkillCount - Number of skills available for new installations
64
68
  * @returns {Promise<Array<{path: string, isExisting: boolean}>>} Selected paths and whether each is existing
65
69
  */
66
- export async function promptInstallPath(existingInstalls = []) {
70
+ export async function promptInstallPath(existingInstalls = [], availableSkillCount = 0) {
67
71
  const choices = [];
68
- const existingPaths = existingInstalls.map(i => i.path);
72
+ const detectionTargets = allSkillDetectionTargets();
73
+ const existingByPath = new Map(existingInstalls.map(install => [install.path, install]));
74
+ const standardPaths = new Set(SKILL_INSTALL_TARGETS.map(target => target.path));
69
75
 
70
- // Add existing installations at the top
71
- if (existingInstalls.length > 0) {
72
- existingInstalls.forEach(install => {
76
+ orderTargetsGlobalFirst(SKILL_INSTALL_TARGETS).forEach(target => {
77
+ const install = existingByPath.get(target.path);
78
+ choices.push({
79
+ name: install
80
+ ? formatTargetLabel(target, install.skillCount, 'skill', { installed: true })
81
+ : formatTargetLabel(target, availableSkillCount, 'skill'),
82
+ value: target.path
83
+ });
84
+ });
85
+
86
+ const existingLegacyOrCustom = existingInstalls.filter(install => !standardPaths.has(install.path));
87
+ if (existingLegacyOrCustom.length > 0) {
88
+ choices.push(new inquirer.Separator('── Existing legacy/custom installations ──'));
89
+ orderTargetsGlobalFirst(existingLegacyOrCustom.map(install => getTargetByPath(detectionTargets, install.path) || {
90
+ path: install.path,
91
+ harness: 'custom',
92
+ scope: 'existing'
93
+ })).forEach(target => {
94
+ const install = existingByPath.get(target.path);
73
95
  choices.push({
74
- name: `${install.path} (${install.skillCount} skill${install.skillCount !== 1 ? 's' : ''} installed)`,
96
+ name: target
97
+ ? formatTargetLabel(target, install.skillCount, 'skill', { installed: true })
98
+ : `${install.path} (${install.skillCount} skill${install.skillCount !== 1 ? 's' : ''} installed)`,
75
99
  value: install.path
76
100
  });
77
101
  });
78
- choices.push(new inquirer.Separator('── New installation ──'));
79
102
  }
80
-
81
- // Standard path options
82
- const standardPaths = [
83
- { path: SKILL_PATH_CHOICES.GITHUB, label: `${SKILL_PATH_CHOICES.GITHUB} (Copilot)` },
84
- { path: SKILL_PATH_CHOICES.CODEX_HOME, label: `${SKILL_PATH_CHOICES.CODEX_HOME} (Codex)` },
85
- { path: SKILL_PATH_CHOICES.CLAUDE, label: `${SKILL_PATH_CHOICES.CLAUDE} (Claude)` }
86
- ];
87
-
88
- standardPaths.forEach(({ path, label }) => {
89
- if (!existingPaths.includes(path)) {
90
- choices.push({ name: label, value: path });
91
- }
92
- });
93
-
103
+
104
+ choices.push(new inquirer.Separator('── Other ──'));
94
105
  choices.push({ name: 'Custom path...', value: SKILL_PATH_CHOICES.CUSTOM });
95
106
 
96
107
  const { pathChoices } = await inquirer.prompt([
@@ -116,7 +127,7 @@ export async function promptInstallPath(existingInstalls = []) {
116
127
  pathChoices
117
128
  .filter(path => path !== SKILL_PATH_CHOICES.CUSTOM)
118
129
  .forEach(path => {
119
- selected.push({ path, isExisting: existingPaths.includes(path) });
130
+ selected.push({ path, isExisting: existingByPath.has(path) });
120
131
  });
121
132
 
122
133
  if (selectedSet.has(SKILL_PATH_CHOICES.CUSTOM)) {
@@ -144,36 +155,45 @@ export async function promptInstallPath(existingInstalls = []) {
144
155
  /**
145
156
  * Prompt user to select one or more subagent installation paths
146
157
  * @param {Array<{path: string, agentCount: number}>} existingInstalls - Detected existing installations
158
+ * @param {number} availableAgentCount - Number of agents available for new installations
147
159
  * @returns {Promise<Array<{path: string, isExisting: boolean}>>} Selected paths and whether each is existing
148
160
  */
149
- export async function promptAgentInstallPath(existingInstalls = []) {
161
+ export async function promptAgentInstallPath(existingInstalls = [], availableAgentCount = 0) {
150
162
  const choices = [];
151
- const existingPaths = existingInstalls.map(i => i.path);
163
+ const detectionTargets = allAgentDetectionTargets();
164
+ const existingByPath = new Map(existingInstalls.map(install => [install.path, install]));
165
+ const standardPaths = new Set(AGENT_INSTALL_TARGETS.map(target => target.path));
152
166
 
153
- // Add existing installations at the top
154
- if (existingInstalls.length > 0) {
155
- existingInstalls.forEach(install => {
167
+ orderTargetsGlobalFirst(AGENT_INSTALL_TARGETS).forEach(target => {
168
+ const install = existingByPath.get(target.path);
169
+ choices.push({
170
+ name: install
171
+ ? formatTargetLabel(target, install.agentCount, 'agent', { installed: true })
172
+ : formatTargetLabel(target, availableAgentCount, 'agent'),
173
+ value: target.path,
174
+ disabled: target.disabledReason
175
+ });
176
+ });
177
+
178
+ const existingLegacyOrCustom = existingInstalls.filter(install => !standardPaths.has(install.path));
179
+ if (existingLegacyOrCustom.length > 0) {
180
+ choices.push(new inquirer.Separator('── Existing legacy/custom installations ──'));
181
+ orderTargetsGlobalFirst(existingLegacyOrCustom.map(install => getTargetByPath(detectionTargets, install.path) || {
182
+ path: install.path,
183
+ harness: 'custom',
184
+ scope: 'existing'
185
+ })).forEach(target => {
186
+ const install = existingByPath.get(target.path);
156
187
  choices.push({
157
- name: `${install.path} (${install.agentCount} agent${install.agentCount !== 1 ? 's' : ''} installed)`,
188
+ name: target
189
+ ? formatTargetLabel(target, install.agentCount, 'agent', { installed: true })
190
+ : `${install.path} (${install.agentCount} agent${install.agentCount !== 1 ? 's' : ''} installed)`,
158
191
  value: install.path
159
192
  });
160
193
  });
161
- choices.push(new inquirer.Separator('── New installation ──'));
162
194
  }
163
-
164
- // Standard path options
165
- const standardPaths = [
166
- { path: AGENT_PATH_CHOICES.GITHUB, label: `${AGENT_PATH_CHOICES.GITHUB} (Copilot)` },
167
- { path: AGENT_PATH_CHOICES.CODEX, label: `${AGENT_PATH_CHOICES.CODEX} (Codex)` },
168
- { path: AGENT_PATH_CHOICES.CLAUDE, label: `${AGENT_PATH_CHOICES.CLAUDE} (Claude)` }
169
- ];
170
-
171
- standardPaths.forEach(({ path, label }) => {
172
- if (!existingPaths.includes(path)) {
173
- choices.push({ name: label, value: path });
174
- }
175
- });
176
-
195
+
196
+ choices.push(new inquirer.Separator('── Other ──'));
177
197
  choices.push({ name: 'Custom path...', value: AGENT_PATH_CHOICES.CUSTOM });
178
198
 
179
199
  const { pathChoices } = await inquirer.prompt([
@@ -199,7 +219,7 @@ export async function promptAgentInstallPath(existingInstalls = []) {
199
219
  pathChoices
200
220
  .filter(path => path !== AGENT_PATH_CHOICES.CUSTOM)
201
221
  .forEach(path => {
202
- selected.push({ path, isExisting: existingPaths.includes(path) });
222
+ selected.push({ path, isExisting: existingByPath.has(path) });
203
223
  });
204
224
 
205
225
  if (selectedSet.has(AGENT_PATH_CHOICES.CUSTOM)) {
package/lib/subagents.js CHANGED
@@ -17,6 +17,29 @@ function humanizeAgentName(filename) {
17
17
  .join(' ');
18
18
  }
19
19
 
20
+ function extractSubagentSections(content) {
21
+ const standardMatch = content.match(/^(---\s*\n[\s\S]*?\n---)(?:\s*\n)?/);
22
+ if (standardMatch) {
23
+ return {
24
+ frontmatter: standardMatch[1].replace(/^---\s*\n|\n---$/g, ''),
25
+ body: content.slice(standardMatch[0].length).trim(),
26
+ };
27
+ }
28
+
29
+ const chatAgentMatch = content.match(/^```chatagent\s*\n---\s*\n([\s\S]*?)\n---\s*\n```(?:\s*\n)?/);
30
+ if (chatAgentMatch) {
31
+ return {
32
+ frontmatter: chatAgentMatch[1],
33
+ body: content.slice(chatAgentMatch[0].length).trim(),
34
+ };
35
+ }
36
+
37
+ return {
38
+ frontmatter: '',
39
+ body: content.trim(),
40
+ };
41
+ }
42
+
20
43
  /**
21
44
  * Fetch the list of subagent files from the repository
22
45
  * Subagents are .agent.md files at the repo root
@@ -54,6 +77,20 @@ export async function fetchAvailableSubagents() {
54
77
  * @returns {Promise<{name: string, description: string}>}
55
78
  */
56
79
  export async function fetchSubagentMetadata(filename) {
80
+ try {
81
+ const content = await fetchSubagentContent(filename);
82
+ return parseSubagentFrontmatter(content);
83
+ } catch (error) {
84
+ throw error;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Fetch the raw source for a subagent file
90
+ * @param {string} filename - The agent filename
91
+ * @returns {Promise<string>}
92
+ */
93
+ export async function fetchSubagentContent(filename) {
57
94
  const fileUrl = `${GITHUB_API}/repos/${SUBAGENTS_REPO_OWNER}/${SUBAGENTS_REPO_NAME}/contents/${filename}`;
58
95
 
59
96
  try {
@@ -66,9 +103,7 @@ export async function fetchSubagentMetadata(filename) {
66
103
  }
67
104
 
68
105
  const data = await response.json();
69
- const content = Buffer.from(data.content, 'base64').toString('utf-8');
70
-
71
- return parseSubagentFrontmatter(content);
106
+ return Buffer.from(data.content, 'base64').toString('utf-8');
72
107
  } catch (error) {
73
108
  throw error;
74
109
  }
@@ -81,13 +116,7 @@ export async function fetchSubagentMetadata(filename) {
81
116
  * @returns {{name: string, description: string}}
82
117
  */
83
118
  function parseSubagentFrontmatter(content) {
84
- // Try to match standard --- frontmatter
85
- const standardMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
86
-
87
- // Try to match ```chatagent fenced frontmatter
88
- const chatAgentMatch = content.match(/```chatagent\s*\n---\s*\n([\s\S]*?)\n---/);
89
-
90
- const frontmatterContent = standardMatch?.[1] || chatAgentMatch?.[1];
119
+ const { frontmatter: frontmatterContent } = extractSubagentSections(content);
91
120
 
92
121
  if (!frontmatterContent) {
93
122
  return { name: '', description: '' };
@@ -102,6 +131,24 @@ function parseSubagentFrontmatter(content) {
102
131
  };
103
132
  }
104
133
 
134
+ /**
135
+ * Parse a subagent file into metadata and body content.
136
+ * @param {string} content - The .agent.md file content
137
+ * @param {string} filename - The source filename for fallback naming
138
+ * @returns {{name: string, description: string, body: string, filename: string}}
139
+ */
140
+ export function parseSubagentDefinition(content, filename = '') {
141
+ const metadata = parseSubagentFrontmatter(content);
142
+ const { body } = extractSubagentSections(content);
143
+
144
+ return {
145
+ filename,
146
+ name: metadata.name || humanizeAgentName(filename),
147
+ description: metadata.description || '',
148
+ body,
149
+ };
150
+ }
151
+
105
152
  /**
106
153
  * Get the subagents repository clone URL
107
154
  * @returns {string}
@@ -110,4 +157,4 @@ export function getSubagentsRepoUrl() {
110
157
  return `https://github.com/${SUBAGENTS_REPO_OWNER}/${SUBAGENTS_REPO_NAME}.git`;
111
158
  }
112
159
 
113
- export { SUBAGENTS_REPO_OWNER, SUBAGENTS_REPO_NAME };
160
+ export { SUBAGENTS_REPO_OWNER, SUBAGENTS_REPO_NAME, humanizeAgentName };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supercorks/skills-installer",
3
- "version": "1.11.1",
3
+ "version": "1.13.0",
4
4
  "description": "Interactive CLI installer for AI agent skills and subagents",
5
5
  "type": "module",
6
6
  "bin": {