@supercorks/skills-installer 1.0.0 → 1.2.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
@@ -5,13 +5,13 @@ Interactive CLI installer for AI agent skills. Selectively install skills for Gi
5
5
  ## Usage
6
6
 
7
7
  ```bash
8
- npx @supercorks/skills-installer install
8
+ npx @supercorks/skills-installer
9
9
  ```
10
10
 
11
- Or with the longer form:
11
+ Or explicitly with the `install` command:
12
12
 
13
13
  ```bash
14
- npx --package=@supercorks/skills-installer skills-installer install
14
+ npx @supercorks/skills-installer install
15
15
  ```
16
16
 
17
17
  ## What it does
@@ -74,7 +74,7 @@ npm install
74
74
  # Run locally
75
75
  npm start
76
76
  # or
77
- node bin/install.js install
77
+ node bin/install.js
78
78
  ```
79
79
 
80
80
  ## License
package/bin/install.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  /**
4
4
  * @supercorks/skills-installer
5
- * Interactive CLI installer for AI agent skills
5
+ * Interactive CLI installer for AI agent skills and subagents
6
6
  *
7
7
  * Usage: npx @supercorks/skills-installer install
8
8
  */
@@ -10,17 +10,90 @@
10
10
  import { existsSync, appendFileSync, readFileSync, writeFileSync } from 'fs';
11
11
  import { resolve, join } from 'path';
12
12
  import {
13
- promptInstallPath,
13
+ promptInstallType,
14
+ promptInstallPath,
15
+ promptAgentInstallPath,
14
16
  promptGitignore,
15
17
  promptSkillSelection,
18
+ promptSubagentSelection,
16
19
  showSpinner,
17
20
  showSuccess,
21
+ showSubagentSuccess,
18
22
  showError
19
23
  } from '../lib/prompts.js';
20
24
  import { fetchAvailableSkills } from '../lib/skills.js';
21
- import { sparseCloneSkills, isGitAvailable } from '../lib/git.js';
25
+ import { fetchAvailableSubagents } from '../lib/subagents.js';
26
+ import {
27
+ sparseCloneSkills,
28
+ isGitAvailable,
29
+ listCheckedOutSkills,
30
+ updateSparseCheckout,
31
+ sparseCloneSubagents,
32
+ listCheckedOutSubagents,
33
+ updateSubagentsSparseCheckout
34
+ } from '../lib/git.js';
35
+
36
+ const VERSION = '1.2.0';
22
37
 
23
- const VERSION = '1.0.0';
38
+ // Common installation paths to check for existing installations
39
+ const SKILL_PATHS = ['.github/skills/', '.claude/skills/'];
40
+ const AGENT_PATHS = ['.github/agents/', '.claude/agents/'];
41
+
42
+ /**
43
+ * Detect existing skill installations in common paths
44
+ * @returns {Promise<Array<{path: string, skillCount: number, skills: string[]}>>}
45
+ */
46
+ async function detectExistingSkillInstallations() {
47
+ const installations = [];
48
+
49
+ for (const path of SKILL_PATHS) {
50
+ const absolutePath = resolve(process.cwd(), path);
51
+ const gitDir = join(absolutePath, '.git');
52
+
53
+ if (existsSync(gitDir)) {
54
+ try {
55
+ const skills = await listCheckedOutSkills(absolutePath);
56
+ installations.push({
57
+ path,
58
+ skillCount: skills.length,
59
+ skills
60
+ });
61
+ } catch {
62
+ // Ignore errors reading existing installations
63
+ }
64
+ }
65
+ }
66
+
67
+ return installations;
68
+ }
69
+
70
+ /**
71
+ * Detect existing subagent installations in common paths
72
+ * @returns {Promise<Array<{path: string, agentCount: number, agents: string[]}>>}
73
+ */
74
+ async function detectExistingAgentInstallations() {
75
+ const installations = [];
76
+
77
+ for (const path of AGENT_PATHS) {
78
+ const absolutePath = resolve(process.cwd(), path);
79
+ const gitDir = join(absolutePath, '.git');
80
+
81
+ if (existsSync(gitDir)) {
82
+ try {
83
+ const agents = await listCheckedOutSubagents(absolutePath);
84
+ installations.push({
85
+ path,
86
+ agentCount: agents.length,
87
+ agents
88
+ });
89
+ } catch {
90
+ // Ignore errors reading existing installations
91
+ }
92
+ }
93
+ }
94
+
95
+ return installations;
96
+ }
24
97
 
25
98
  /**
26
99
  * Print usage information
@@ -30,7 +103,7 @@ function printUsage() {
30
103
  @supercorks/skills-installer v${VERSION}
31
104
 
32
105
  Usage:
33
- npx @supercorks/skills-installer Install skills interactively (default)
106
+ npx @supercorks/skills-installer Install skills/subagents interactively (default)
34
107
  npx @supercorks/skills-installer --help Show this help message
35
108
  npx @supercorks/skills-installer --version Show version
36
109
 
@@ -67,7 +140,7 @@ function addToGitignore(gitignorePath, pathToIgnore) {
67
140
  * Main installation flow
68
141
  */
69
142
  async function runInstall() {
70
- console.log('\n🔧 AI Agent Skills Installer\n');
143
+ console.log('\n🔧 AI Agent Skills & Subagents Installer\n');
71
144
 
72
145
  // Check git availability
73
146
  if (!isGitAvailable()) {
@@ -75,7 +148,27 @@ async function runInstall() {
75
148
  process.exit(1);
76
149
  }
77
150
 
78
- // Step 1: Fetch available skills
151
+ // Step 1: Ask what to install
152
+ const { skills: installSkills, subagents: installSubagents } = await promptInstallType();
153
+
154
+ // Install skills if selected
155
+ if (installSkills) {
156
+ await runSkillsInstall();
157
+ }
158
+
159
+ // Install subagents if selected
160
+ if (installSubagents) {
161
+ await runSubagentsInstall();
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Skills installation flow
167
+ */
168
+ async function runSkillsInstall() {
169
+ console.log('\n📦 Skills Installation\n');
170
+
171
+ // Fetch available skills
79
172
  let skills;
80
173
  const fetchSpinner = showSpinner('Fetching available skills from repository...');
81
174
  try {
@@ -92,48 +185,279 @@ async function runInstall() {
92
185
  process.exit(1);
93
186
  }
94
187
 
95
- // Step 2: Ask where to install
96
- const installPath = await promptInstallPath();
188
+ // Detect existing installations
189
+ const existingInstalls = await detectExistingSkillInstallations();
190
+
191
+ // Ask where to install (showing existing installations if any)
192
+ const { path: installPath, isExisting } = await promptInstallPath(existingInstalls);
97
193
  const absoluteInstallPath = resolve(process.cwd(), installPath);
98
194
 
99
- // Check if path already exists with a git repo
100
- if (existsSync(join(absoluteInstallPath, '.git'))) {
101
- showError(`"${installPath}" already contains a git repository. Please remove it first or choose a different path.`);
102
- process.exit(1);
195
+ // Get currently installed skills if managing existing installation
196
+ let installedSkills = [];
197
+ if (isExisting) {
198
+ const existingInstall = existingInstalls.find(i => i.path === installPath);
199
+ installedSkills = existingInstall?.skills || [];
200
+ } else {
201
+ // Check if manually entered path has an existing installation
202
+ const gitDir = join(absoluteInstallPath, '.git');
203
+ if (existsSync(gitDir)) {
204
+ try {
205
+ installedSkills = await listCheckedOutSkills(absoluteInstallPath);
206
+ } catch {
207
+ // If we can't read it, treat as fresh install
208
+ }
209
+ }
103
210
  }
104
211
 
105
- // Step 3: Ask about .gitignore
106
- const shouldGitignore = await promptGitignore(installPath);
212
+ const isManageMode = installedSkills.length > 0;
107
213
 
108
- // Step 4: Select skills
109
- const selectedSkills = await promptSkillSelection(skills);
214
+ // Ask about .gitignore (only for fresh installs)
215
+ let shouldGitignore = false;
216
+ if (!isManageMode) {
217
+ shouldGitignore = await promptGitignore(installPath);
218
+ }
219
+
220
+ // Select skills (pre-select installed skills in manage mode)
221
+ const selectedSkills = await promptSkillSelection(skills, installedSkills);
110
222
 
111
- // Step 5: Perform installation
223
+ // Perform installation or update
112
224
  console.log('');
113
- const installSpinner = showSpinner('Installing selected skills...');
114
225
 
226
+ if (isManageMode) {
227
+ // Calculate changes
228
+ const toAdd = selectedSkills.filter(s => !installedSkills.includes(s));
229
+ const toRemove = installedSkills.filter(s => !selectedSkills.includes(s));
230
+ const unchanged = selectedSkills.filter(s => installedSkills.includes(s));
231
+
232
+ if (toAdd.length === 0 && toRemove.length === 0) {
233
+ console.log('ℹ️ No changes to apply. Pulling latest updates...');
234
+ }
235
+
236
+ const updateSpinner = showSpinner('Updating skills installation...');
237
+
238
+ try {
239
+ await updateSparseCheckout(absoluteInstallPath, selectedSkills, (message) => {
240
+ updateSpinner.stop(` ${message}`);
241
+ });
242
+ } catch (error) {
243
+ updateSpinner.stop('❌ Update failed');
244
+ showError(error.message);
245
+ process.exit(1);
246
+ }
247
+
248
+ // Show summary of changes
249
+ showSkillManageSuccess(installPath, skills, toAdd, toRemove, unchanged);
250
+ } else {
251
+ const installSpinner = showSpinner('Installing selected skills...');
252
+
253
+ try {
254
+ await sparseCloneSkills(installPath, selectedSkills, (message) => {
255
+ installSpinner.stop(` ${message}`);
256
+ });
257
+ } catch (error) {
258
+ installSpinner.stop('❌ Installation failed');
259
+ showError(error.message);
260
+ process.exit(1);
261
+ }
262
+
263
+ // Update .gitignore if requested
264
+ if (shouldGitignore) {
265
+ const gitignorePath = resolve(process.cwd(), '.gitignore');
266
+ addToGitignore(gitignorePath, installPath);
267
+ }
268
+
269
+ // Show success
270
+ const installedSkillNames = skills
271
+ .filter(s => selectedSkills.includes(s.folder))
272
+ .map(s => s.name);
273
+
274
+ showSuccess(installPath, installedSkillNames);
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Subagents installation flow
280
+ */
281
+ async function runSubagentsInstall() {
282
+ console.log('\n🤖 Subagents Installation\n');
283
+
284
+ // Fetch available subagents
285
+ let subagents;
286
+ const fetchSpinner = showSpinner('Fetching available subagents from repository...');
115
287
  try {
116
- await sparseCloneSkills(installPath, selectedSkills, (message) => {
117
- installSpinner.stop(` ${message}`);
118
- });
288
+ subagents = await fetchAvailableSubagents();
289
+ fetchSpinner.stop(`✅ Found ${subagents.length} available subagents`);
119
290
  } catch (error) {
120
- installSpinner.stop('❌ Installation failed');
121
- showError(error.message);
291
+ fetchSpinner.stop('❌ Failed to fetch subagents');
292
+ showError(`Could not fetch subagents list: ${error.message}`);
293
+ process.exit(1);
294
+ }
295
+
296
+ if (subagents.length === 0) {
297
+ showError('No subagents found in the repository');
122
298
  process.exit(1);
123
299
  }
124
300
 
125
- // Step 6: Update .gitignore if requested
126
- if (shouldGitignore) {
127
- const gitignorePath = resolve(process.cwd(), '.gitignore');
128
- addToGitignore(gitignorePath, installPath);
301
+ // Detect existing installations
302
+ const existingInstalls = await detectExistingAgentInstallations();
303
+
304
+ // Ask where to install (showing existing installations if any)
305
+ const { path: installPath, isExisting } = await promptAgentInstallPath(existingInstalls);
306
+ const absoluteInstallPath = resolve(process.cwd(), installPath);
307
+
308
+ // Get currently installed subagents if managing existing installation
309
+ let installedAgents = [];
310
+ if (isExisting) {
311
+ const existingInstall = existingInstalls.find(i => i.path === installPath);
312
+ installedAgents = existingInstall?.agents || [];
313
+ } else {
314
+ // Check if manually entered path has an existing installation
315
+ const gitDir = join(absoluteInstallPath, '.git');
316
+ if (existsSync(gitDir)) {
317
+ try {
318
+ installedAgents = await listCheckedOutSubagents(absoluteInstallPath);
319
+ } catch {
320
+ // If we can't read it, treat as fresh install
321
+ }
322
+ }
323
+ }
324
+
325
+ const isManageMode = installedAgents.length > 0;
326
+
327
+ // Ask about .gitignore (only for fresh installs)
328
+ let shouldGitignore = false;
329
+ if (!isManageMode) {
330
+ shouldGitignore = await promptGitignore(installPath);
331
+ }
332
+
333
+ // Select subagents (pre-select installed ones in manage mode)
334
+ const selectedAgents = await promptSubagentSelection(subagents, installedAgents);
335
+
336
+ // Perform installation or update
337
+ console.log('');
338
+
339
+ if (isManageMode) {
340
+ // Calculate changes
341
+ const toAdd = selectedAgents.filter(s => !installedAgents.includes(s));
342
+ const toRemove = installedAgents.filter(s => !selectedAgents.includes(s));
343
+ const unchanged = selectedAgents.filter(s => installedAgents.includes(s));
344
+
345
+ if (toAdd.length === 0 && toRemove.length === 0) {
346
+ console.log('ℹ️ No changes to apply. Pulling latest updates...');
347
+ }
348
+
349
+ const updateSpinner = showSpinner('Updating subagents installation...');
350
+
351
+ try {
352
+ await updateSubagentsSparseCheckout(absoluteInstallPath, selectedAgents, (message) => {
353
+ updateSpinner.stop(` ${message}`);
354
+ });
355
+ } catch (error) {
356
+ updateSpinner.stop('❌ Update failed');
357
+ showError(error.message);
358
+ process.exit(1);
359
+ }
360
+
361
+ // Show summary of changes
362
+ showAgentManageSuccess(installPath, subagents, toAdd, toRemove, unchanged);
363
+ } else {
364
+ const installSpinner = showSpinner('Installing selected subagents...');
365
+
366
+ try {
367
+ await sparseCloneSubagents(installPath, selectedAgents, (message) => {
368
+ installSpinner.stop(` ${message}`);
369
+ });
370
+ } catch (error) {
371
+ installSpinner.stop('❌ Installation failed');
372
+ showError(error.message);
373
+ process.exit(1);
374
+ }
375
+
376
+ // Update .gitignore if requested
377
+ if (shouldGitignore) {
378
+ const gitignorePath = resolve(process.cwd(), '.gitignore');
379
+ addToGitignore(gitignorePath, installPath);
380
+ }
381
+
382
+ // Show success
383
+ const installedAgentNames = subagents
384
+ .filter(s => selectedAgents.includes(s.filename))
385
+ .map(s => s.name);
386
+
387
+ showSubagentSuccess(installPath, installedAgentNames);
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Display success message for manage mode with skill change summary
393
+ * @param {string} installPath - Where skills are installed
394
+ * @param {Array<{name: string, folder: string}>} allSkills - All available skills
395
+ * @param {string[]} added - Skill folders that were added
396
+ * @param {string[]} removed - Skill folders that were removed
397
+ * @param {string[]} unchanged - Skill folders that were unchanged
398
+ */
399
+ function showSkillManageSuccess(installPath, allSkills, added, removed, unchanged) {
400
+ const getSkillName = (folder) => allSkills.find(s => s.folder === folder)?.name || folder;
401
+
402
+ console.log('\n' + '═'.repeat(50));
403
+ console.log('✅ Skills updated successfully!');
404
+ console.log('═'.repeat(50));
405
+ console.log(`\n📁 Location: ${installPath}`);
406
+
407
+ if (added.length > 0) {
408
+ console.log(`\n➕ Added (${added.length}):`);
409
+ added.forEach(folder => console.log(` • ${getSkillName(folder)}`));
129
410
  }
411
+
412
+ if (removed.length > 0) {
413
+ console.log(`\n➖ Removed (${removed.length}):`);
414
+ removed.forEach(folder => console.log(` • ${getSkillName(folder)}`));
415
+ }
416
+
417
+ if (unchanged.length > 0) {
418
+ console.log(`\n📦 Unchanged (${unchanged.length}):`);
419
+ unchanged.forEach(folder => console.log(` • ${getSkillName(folder)}`));
420
+ }
421
+
422
+ const totalInstalled = added.length + unchanged.length;
423
+ console.log(`\n🚀 ${totalInstalled} skill${totalInstalled !== 1 ? 's' : ''} now installed.`);
424
+ console.log('═'.repeat(50) + '\n');
425
+ }
130
426
 
131
- // Step 7: Show success
132
- const installedSkillNames = skills
133
- .filter(s => selectedSkills.includes(s.folder))
134
- .map(s => s.name);
427
+ /**
428
+ * Display success message for manage mode with subagent change summary
429
+ * @param {string} installPath - Where subagents are installed
430
+ * @param {Array<{name: string, filename: string}>} allAgents - All available subagents
431
+ * @param {string[]} added - Agent filenames that were added
432
+ * @param {string[]} removed - Agent filenames that were removed
433
+ * @param {string[]} unchanged - Agent filenames that were unchanged
434
+ */
435
+ function showAgentManageSuccess(installPath, allAgents, added, removed, unchanged) {
436
+ const getAgentName = (filename) => allAgents.find(s => s.filename === filename)?.name || filename;
437
+
438
+ console.log('\n' + '═'.repeat(50));
439
+ console.log('✅ Subagents updated successfully!');
440
+ console.log('═'.repeat(50));
441
+ console.log(`\n📁 Location: ${installPath}`);
442
+
443
+ if (added.length > 0) {
444
+ console.log(`\n➕ Added (${added.length}):`);
445
+ added.forEach(filename => console.log(` • ${getAgentName(filename)}`));
446
+ }
447
+
448
+ if (removed.length > 0) {
449
+ console.log(`\n➖ Removed (${removed.length}):`);
450
+ removed.forEach(filename => console.log(` • ${getAgentName(filename)}`));
451
+ }
452
+
453
+ if (unchanged.length > 0) {
454
+ console.log(`\n🤖 Unchanged (${unchanged.length}):`);
455
+ unchanged.forEach(filename => console.log(` • ${getAgentName(filename)}`));
456
+ }
135
457
 
136
- showSuccess(installPath, installedSkillNames);
458
+ const totalInstalled = added.length + unchanged.length;
459
+ console.log(`\n🚀 ${totalInstalled} subagent${totalInstalled !== 1 ? 's' : ''} now installed.`);
460
+ console.log('═'.repeat(50) + '\n');
137
461
  }
138
462
 
139
463
  /**
package/lib/git.js CHANGED
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Git sparse-checkout utilities for cloning selected skills
2
+ * Git sparse-checkout utilities for cloning selected skills and subagents
3
3
  */
4
4
 
5
5
  import { execSync, spawn } from 'child_process';
6
6
  import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync, appendFileSync } from 'fs';
7
7
  import { join, resolve } from 'path';
8
8
  import { getRepoUrl } from './skills.js';
9
+ import { getSubagentsRepoUrl } from './subagents.js';
9
10
 
10
11
  /**
11
12
  * Check if git is available
@@ -118,6 +119,42 @@ export async function sparseCloneSkills(targetPath, skillFolders, onProgress = (
118
119
  }
119
120
  }
120
121
 
122
+ /**
123
+ * Update the sparse-checkout to include exactly the specified skills
124
+ * This replaces all existing skills with the new selection
125
+ * @param {string} repoPath - Path to the existing sparse-checkout repo
126
+ * @param {string[]} skillFolders - Skill folders to include (replaces existing)
127
+ * @param {(message: string) => void} onProgress - Progress callback
128
+ * @returns {Promise<void>}
129
+ */
130
+ export async function updateSparseCheckout(repoPath, skillFolders, onProgress = () => {}) {
131
+ const absolutePath = resolve(repoPath);
132
+
133
+ if (!existsSync(join(absolutePath, '.git'))) {
134
+ throw new Error(`"${repoPath}" is not a git repository`);
135
+ }
136
+
137
+ // Pull latest changes first
138
+ onProgress('Pulling latest changes...');
139
+ try {
140
+ await runGitCommand(['pull'], absolutePath);
141
+ } catch (error) {
142
+ // Ignore pull errors (e.g., no upstream configured)
143
+ }
144
+
145
+ // Write new patterns to sparse-checkout file (replaces existing)
146
+ onProgress('Updating sparse-checkout configuration...');
147
+ const sparseCheckoutPath = join(absolutePath, '.git', 'info', 'sparse-checkout');
148
+ const patterns = skillFolders.map(folder => `/${folder}/`).join('\n');
149
+ writeFileSync(sparseCheckoutPath, patterns + '\n');
150
+
151
+ // Re-apply sparse-checkout
152
+ onProgress('Applying changes...');
153
+ await runGitCommand(['read-tree', '-mu', 'HEAD'], absolutePath);
154
+
155
+ onProgress('Done!');
156
+ }
157
+
121
158
  /**
122
159
  * Add more skills to an existing sparse-checkout
123
160
  * @param {string} repoPath - Path to the existing sparse-checkout repo
@@ -176,3 +213,131 @@ export async function pullUpdates(repoPath) {
176
213
  const absolutePath = resolve(repoPath);
177
214
  return runGitCommand(['pull'], absolutePath);
178
215
  }
216
+
217
+ // ==================== SUBAGENTS FUNCTIONS ====================
218
+
219
+ /**
220
+ * Perform a sparse clone of the subagents repository with only selected agent files
221
+ *
222
+ * @param {string} targetPath - Where to clone the repository
223
+ * @param {string[]} agentFilenames - Array of agent filenames to include (e.g., 'Developer.agent.md')
224
+ * @param {(message: string) => void} onProgress - Progress callback
225
+ * @returns {Promise<void>}
226
+ */
227
+ export async function sparseCloneSubagents(targetPath, agentFilenames, onProgress = () => {}) {
228
+ const repoUrl = getSubagentsRepoUrl();
229
+ const absolutePath = resolve(targetPath);
230
+
231
+ // Check if target already exists and has content
232
+ if (existsSync(absolutePath)) {
233
+ const gitDir = join(absolutePath, '.git');
234
+ if (existsSync(gitDir)) {
235
+ throw new Error(`Directory "${targetPath}" already contains a git repository. Please remove it first or choose a different path.`);
236
+ }
237
+ }
238
+
239
+ // Create target directory
240
+ mkdirSync(absolutePath, { recursive: true });
241
+
242
+ try {
243
+ // Clone with blob filter for minimal download, no checkout yet
244
+ onProgress('Initializing sparse clone...');
245
+ await runGitCommand([
246
+ 'clone',
247
+ '--filter=blob:none',
248
+ '--no-checkout',
249
+ '--sparse',
250
+ repoUrl,
251
+ '.'
252
+ ], absolutePath);
253
+
254
+ // Use non-cone mode for precise control over what's checked out
255
+ onProgress('Configuring sparse-checkout...');
256
+ await runGitCommand([
257
+ 'sparse-checkout',
258
+ 'init',
259
+ '--no-cone'
260
+ ], absolutePath);
261
+
262
+ // Write explicit patterns - only selected agent files
263
+ const sparseCheckoutPath = join(absolutePath, '.git', 'info', 'sparse-checkout');
264
+ const patterns = agentFilenames.map(filename => `/${filename}`).join('\n');
265
+ writeFileSync(sparseCheckoutPath, patterns + '\n');
266
+
267
+ // Checkout the files
268
+ onProgress('Checking out files...');
269
+ await runGitCommand(['checkout'], absolutePath);
270
+
271
+ onProgress('Done!');
272
+ } catch (error) {
273
+ // Clean up on failure
274
+ try {
275
+ rmSync(absolutePath, { recursive: true, force: true });
276
+ } catch {
277
+ // Ignore cleanup errors
278
+ }
279
+ throw error;
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Update the sparse-checkout for subagents to include exactly the specified agents
285
+ * @param {string} repoPath - Path to the existing sparse-checkout repo
286
+ * @param {string[]} agentFilenames - Agent filenames to include (replaces existing)
287
+ * @param {(message: string) => void} onProgress - Progress callback
288
+ * @returns {Promise<void>}
289
+ */
290
+ export async function updateSubagentsSparseCheckout(repoPath, agentFilenames, onProgress = () => {}) {
291
+ const absolutePath = resolve(repoPath);
292
+
293
+ if (!existsSync(join(absolutePath, '.git'))) {
294
+ throw new Error(`"${repoPath}" is not a git repository`);
295
+ }
296
+
297
+ // Pull latest changes first
298
+ onProgress('Pulling latest changes...');
299
+ try {
300
+ await runGitCommand(['pull'], absolutePath);
301
+ } catch (error) {
302
+ // Ignore pull errors (e.g., no upstream configured)
303
+ }
304
+
305
+ // Write new patterns to sparse-checkout file (replaces existing)
306
+ onProgress('Updating sparse-checkout configuration...');
307
+ const sparseCheckoutPath = join(absolutePath, '.git', 'info', 'sparse-checkout');
308
+ const patterns = agentFilenames.map(filename => `/${filename}`).join('\n');
309
+ writeFileSync(sparseCheckoutPath, patterns + '\n');
310
+
311
+ // Re-apply sparse-checkout
312
+ onProgress('Applying changes...');
313
+ await runGitCommand(['read-tree', '-mu', 'HEAD'], absolutePath);
314
+
315
+ onProgress('Done!');
316
+ }
317
+
318
+ /**
319
+ * List currently checked out subagents in a sparse-checkout repo
320
+ * @param {string} repoPath - Path to the sparse-checkout repo
321
+ * @returns {Promise<string[]>} List of agent filenames
322
+ */
323
+ export async function listCheckedOutSubagents(repoPath) {
324
+ const absolutePath = resolve(repoPath);
325
+
326
+ if (!existsSync(join(absolutePath, '.git'))) {
327
+ throw new Error(`"${repoPath}" is not a git repository`);
328
+ }
329
+
330
+ // Read patterns from sparse-checkout file (non-cone mode format: /filename)
331
+ const sparseCheckoutPath = join(absolutePath, '.git', 'info', 'sparse-checkout');
332
+ if (!existsSync(sparseCheckoutPath)) {
333
+ return [];
334
+ }
335
+
336
+ const content = readFileSync(sparseCheckoutPath, 'utf-8');
337
+ const patterns = content.split('\n').filter(Boolean);
338
+
339
+ // Extract filenames from patterns like /Developer.agent.md
340
+ return patterns
341
+ .map(p => p.replace(/^\//, '')) // Remove leading slash
342
+ .filter(p => p && p.endsWith('.agent.md'));
343
+ }
package/lib/prompts.js CHANGED
@@ -5,12 +5,18 @@
5
5
  import inquirer from 'inquirer';
6
6
  import * as readline from 'readline';
7
7
 
8
- const PATH_CHOICES = {
8
+ const SKILL_PATH_CHOICES = {
9
9
  GITHUB: '.github/skills/',
10
10
  CLAUDE: '.claude/skills/',
11
11
  CUSTOM: '__custom__'
12
12
  };
13
13
 
14
+ const AGENT_PATH_CHOICES = {
15
+ GITHUB: '.github/agents/',
16
+ CLAUDE: '.claude/agents/',
17
+ CUSTOM: '__custom__'
18
+ };
19
+
14
20
  /**
15
21
  * Extract the first sentence from a description
16
22
  * @param {string} text - Full description text
@@ -27,25 +33,72 @@ function getFirstSentence(text, maxLength = 60) {
27
33
  }
28
34
 
29
35
  /**
30
- * Prompt user to select installation path
31
- * @returns {Promise<string>} The selected or custom path
36
+ * Prompt user to select what to install
37
+ * @returns {Promise<{skills: boolean, subagents: boolean}>}
38
+ */
39
+ export async function promptInstallType() {
40
+ const { installType } = await inquirer.prompt([
41
+ {
42
+ type: 'list',
43
+ name: 'installType',
44
+ message: 'What would you like to install?',
45
+ choices: [
46
+ { name: 'Skills only', value: 'skills' },
47
+ { name: 'Subagents only', value: 'subagents' },
48
+ { name: 'Both skills and subagents', value: 'both' }
49
+ ]
50
+ }
51
+ ]);
52
+
53
+ return {
54
+ skills: installType === 'skills' || installType === 'both',
55
+ subagents: installType === 'subagents' || installType === 'both'
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Prompt user to select installation path, showing existing installations
61
+ * @param {Array<{path: string, skillCount: number}>} existingInstalls - Detected existing installations
62
+ * @returns {Promise<{path: string, isExisting: boolean}>} The selected path and whether it's existing
32
63
  */
33
- export async function promptInstallPath() {
64
+ export async function promptInstallPath(existingInstalls = []) {
65
+ const choices = [];
66
+
67
+ // Add existing installations at the top
68
+ if (existingInstalls.length > 0) {
69
+ existingInstalls.forEach(install => {
70
+ choices.push({
71
+ name: `${install.path} (${install.skillCount} skill${install.skillCount !== 1 ? 's' : ''} installed)`,
72
+ value: { path: install.path, isExisting: true }
73
+ });
74
+ });
75
+ choices.push(new inquirer.Separator('── New installation ──'));
76
+ }
77
+
78
+ // Standard path options
79
+ const standardPaths = [SKILL_PATH_CHOICES.GITHUB, SKILL_PATH_CHOICES.CLAUDE];
80
+ const existingPaths = existingInstalls.map(i => i.path);
81
+
82
+ standardPaths.forEach(path => {
83
+ if (!existingPaths.includes(path)) {
84
+ choices.push({ name: path, value: { path, isExisting: false } });
85
+ }
86
+ });
87
+
88
+ choices.push({ name: 'Custom path...', value: { path: SKILL_PATH_CHOICES.CUSTOM, isExisting: false } });
89
+
34
90
  const { pathChoice } = await inquirer.prompt([
35
91
  {
36
92
  type: 'list',
37
93
  name: 'pathChoice',
38
- message: 'Where would you like to install the skills?',
39
- choices: [
40
- { name: '.github/skills/', value: PATH_CHOICES.GITHUB },
41
- { name: '.claude/skills/', value: PATH_CHOICES.CLAUDE },
42
- { name: 'Custom path...', value: PATH_CHOICES.CUSTOM }
43
- ],
44
- default: PATH_CHOICES.GITHUB
94
+ message: existingInstalls.length > 0
95
+ ? 'Select an existing installation to manage, or choose a new location:'
96
+ : 'Where would you like to install the skills?',
97
+ choices
45
98
  }
46
99
  ]);
47
100
 
48
- if (pathChoice === PATH_CHOICES.CUSTOM) {
101
+ if (pathChoice.path === SKILL_PATH_CHOICES.CUSTOM) {
49
102
  const { customPath } = await inquirer.prompt([
50
103
  {
51
104
  type: 'input',
@@ -59,7 +112,69 @@ export async function promptInstallPath() {
59
112
  }
60
113
  }
61
114
  ]);
62
- return customPath.trim();
115
+ return { path: customPath.trim(), isExisting: false };
116
+ }
117
+
118
+ return pathChoice;
119
+ }
120
+
121
+ /**
122
+ * Prompt user to select subagent installation path
123
+ * @param {Array<{path: string, agentCount: number}>} existingInstalls - Detected existing installations
124
+ * @returns {Promise<{path: string, isExisting: boolean}>} The selected path and whether it's existing
125
+ */
126
+ export async function promptAgentInstallPath(existingInstalls = []) {
127
+ const choices = [];
128
+
129
+ // Add existing installations at the top
130
+ if (existingInstalls.length > 0) {
131
+ existingInstalls.forEach(install => {
132
+ choices.push({
133
+ name: `${install.path} (${install.agentCount} agent${install.agentCount !== 1 ? 's' : ''} installed)`,
134
+ value: { path: install.path, isExisting: true }
135
+ });
136
+ });
137
+ choices.push(new inquirer.Separator('── New installation ──'));
138
+ }
139
+
140
+ // Standard path options
141
+ const standardPaths = [AGENT_PATH_CHOICES.GITHUB, AGENT_PATH_CHOICES.CLAUDE];
142
+ const existingPaths = existingInstalls.map(i => i.path);
143
+
144
+ standardPaths.forEach(path => {
145
+ if (!existingPaths.includes(path)) {
146
+ choices.push({ name: path, value: { path, isExisting: false } });
147
+ }
148
+ });
149
+
150
+ choices.push({ name: 'Custom path...', value: { path: AGENT_PATH_CHOICES.CUSTOM, isExisting: false } });
151
+
152
+ const { pathChoice } = await inquirer.prompt([
153
+ {
154
+ type: 'list',
155
+ name: 'pathChoice',
156
+ message: existingInstalls.length > 0
157
+ ? 'Select an existing installation to manage, or choose a new location:'
158
+ : 'Where would you like to install the subagents?',
159
+ choices
160
+ }
161
+ ]);
162
+
163
+ if (pathChoice.path === AGENT_PATH_CHOICES.CUSTOM) {
164
+ const { customPath } = await inquirer.prompt([
165
+ {
166
+ type: 'input',
167
+ name: 'customPath',
168
+ message: 'Enter custom installation path:',
169
+ validate: (input) => {
170
+ if (!input.trim()) {
171
+ return 'Please enter a valid path';
172
+ }
173
+ return true;
174
+ }
175
+ }
176
+ ]);
177
+ return { path: customPath.trim(), isExisting: false };
63
178
  }
64
179
 
65
180
  return pathChoice;
@@ -86,9 +201,39 @@ export async function promptGitignore(installPath) {
86
201
  /**
87
202
  * Prompt user to select skills to install with expand/collapse support
88
203
  * @param {Array<{name: string, description: string, folder: string}>} skills - Available skills
204
+ * @param {string[]} installedSkills - Already installed skill folder names (will be pre-selected)
89
205
  * @returns {Promise<string[]>} Selected skill folder names
90
206
  */
91
- export async function promptSkillSelection(skills) {
207
+ export async function promptSkillSelection(skills, installedSkills = []) {
208
+ return promptItemSelection(
209
+ skills.map(s => ({ id: s.folder, name: s.name, description: s.description })),
210
+ installedSkills,
211
+ '📦 Available Skills'
212
+ );
213
+ }
214
+
215
+ /**
216
+ * Prompt user to select subagents to install with expand/collapse support
217
+ * @param {Array<{name: string, description: string, filename: string}>} subagents - Available subagents
218
+ * @param {string[]} installedSubagents - Already installed subagent filenames (will be pre-selected)
219
+ * @returns {Promise<string[]>} Selected subagent filenames
220
+ */
221
+ export async function promptSubagentSelection(subagents, installedSubagents = []) {
222
+ return promptItemSelection(
223
+ subagents.map(s => ({ id: s.filename, name: s.name, description: s.description })),
224
+ installedSubagents,
225
+ '🤖 Available Subagents'
226
+ );
227
+ }
228
+
229
+ /**
230
+ * Generic item selection prompt with expand/collapse support
231
+ * @param {Array<{id: string, name: string, description: string}>} items - Available items
232
+ * @param {string[]} installedItems - Already installed item IDs (will be pre-selected)
233
+ * @param {string} title - Title to display
234
+ * @returns {Promise<string[]>} Selected item IDs
235
+ */
236
+ function promptItemSelection(items, installedItems = [], title = '📦 Available Items') {
92
237
  return new Promise((resolve, reject) => {
93
238
  const rl = readline.createInterface({
94
239
  input: process.stdin,
@@ -102,22 +247,22 @@ export async function promptSkillSelection(skills) {
102
247
  readline.emitKeypressEvents(process.stdin, rl);
103
248
 
104
249
  let cursor = 0;
105
- const selected = new Set();
250
+ const selected = new Set(installedItems);
106
251
  const expanded = new Set();
107
252
 
108
253
  const render = () => {
109
254
  // Clear screen and move to top
110
255
  process.stdout.write('\x1B[2J\x1B[H');
111
256
 
112
- console.log('\n📦 Available Skills');
257
+ console.log(`\n${title}`);
113
258
  console.log('─'.repeat(60));
114
259
  console.log('↑↓ navigate SPACE toggle → expand ← collapse A all ENTER confirm\n');
115
- console.log('Select skills to install:\n');
260
+ console.log('Select items to install:\n');
116
261
 
117
- skills.forEach((skill, i) => {
118
- const isSelected = selected.has(skill.folder);
262
+ items.forEach((item, i) => {
263
+ const isSelected = selected.has(item.id);
119
264
  const isCursor = i === cursor;
120
- const isExpanded = expanded.has(skill.folder);
265
+ const isExpanded = expanded.has(item.id);
121
266
 
122
267
  const checkbox = isSelected ? '◉' : '○';
123
268
  const pointer = isCursor ? '❯' : ' ';
@@ -127,23 +272,23 @@ export async function promptSkillSelection(skills) {
127
272
  const highlight = isCursor ? '\x1B[36m' : ''; // Cyan for selected
128
273
  const reset = '\x1B[0m';
129
274
 
130
- const shortDesc = getFirstSentence(skill.description);
275
+ const shortDesc = getFirstSentence(item.description);
131
276
 
132
277
  if (isExpanded) {
133
- console.log(`${highlight}${pointer} ${checkbox} ${skill.name}${reset}`);
278
+ console.log(`${highlight}${pointer} ${checkbox} ${item.name}${reset}`);
134
279
  // Show full description indented
135
- const fullDesc = skill.description || 'No description available';
280
+ const fullDesc = item.description || 'No description available';
136
281
  const lines = fullDesc.match(/.{1,55}/g) || [fullDesc];
137
282
  lines.forEach(line => {
138
283
  console.log(` ${highlight}${line}${reset}`);
139
284
  });
140
285
  } else {
141
- console.log(`${highlight}${pointer} ${checkbox} ${skill.name} ${expandIcon} ${shortDesc}${reset}`);
286
+ console.log(`${highlight}${pointer} ${checkbox} ${item.name} ${expandIcon} ${shortDesc}${reset}`);
142
287
  }
143
288
  });
144
289
 
145
290
  const selectedCount = selected.size;
146
- console.log(`\n${selectedCount} skill${selectedCount !== 1 ? 's' : ''} selected`);
291
+ console.log(`\n${selectedCount} item${selectedCount !== 1 ? 's' : ''} selected`);
147
292
  };
148
293
 
149
294
  const cleanup = () => {
@@ -165,51 +310,51 @@ export async function promptSkillSelection(skills) {
165
310
 
166
311
  switch (key.name) {
167
312
  case 'up':
168
- cursor = cursor > 0 ? cursor - 1 : skills.length - 1;
313
+ cursor = cursor > 0 ? cursor - 1 : items.length - 1;
169
314
  render();
170
315
  break;
171
316
  case 'down':
172
- cursor = cursor < skills.length - 1 ? cursor + 1 : 0;
317
+ cursor = cursor < items.length - 1 ? cursor + 1 : 0;
173
318
  render();
174
319
  break;
175
320
  case 'right':
176
- expanded.add(skills[cursor].folder);
321
+ expanded.add(items[cursor].id);
177
322
  render();
178
323
  break;
179
324
  case 'left':
180
- expanded.delete(skills[cursor].folder);
325
+ expanded.delete(items[cursor].id);
181
326
  render();
182
327
  break;
183
328
  case 'space':
184
- const folder = skills[cursor].folder;
185
- if (selected.has(folder)) {
186
- selected.delete(folder);
329
+ const itemId = items[cursor].id;
330
+ if (selected.has(itemId)) {
331
+ selected.delete(itemId);
187
332
  } else {
188
- selected.add(folder);
333
+ selected.add(itemId);
189
334
  }
190
335
  render();
191
336
  break;
192
337
  case 'a':
193
338
  // Toggle all
194
- if (selected.size === skills.length) {
339
+ if (selected.size === items.length) {
195
340
  selected.clear();
196
341
  } else {
197
- skills.forEach(s => selected.add(s.folder));
342
+ items.forEach(item => selected.add(item.id));
198
343
  }
199
344
  render();
200
345
  break;
201
346
  case 'return':
202
347
  if (selected.size === 0) {
203
348
  // Show error inline
204
- process.stdout.write('\x1B[31mPlease select at least one skill\x1B[0m');
349
+ process.stdout.write('\x1B[31mPlease select at least one item\x1B[0m');
205
350
  setTimeout(render, 1000);
206
351
  } else {
207
352
  cleanup();
208
353
  // Clear and show final selection
209
354
  process.stdout.write('\x1B[2J\x1B[H');
210
- console.log('\n📦 Selected skills:');
211
- skills.filter(s => selected.has(s.folder)).forEach(s => {
212
- console.log(` ✓ ${s.name}`);
355
+ console.log(`\n${title.split(' ')[0]} Selected:`);
356
+ items.filter(item => selected.has(item.id)).forEach(item => {
357
+ console.log(` ✓ ${item.name}`);
213
358
  });
214
359
  console.log('');
215
360
  resolve(Array.from(selected));
@@ -267,6 +412,22 @@ export function showSuccess(installPath, installedSkills) {
267
412
  console.log('═'.repeat(50) + '\n');
268
413
  }
269
414
 
415
+ /**
416
+ * Display success message for subagent installation
417
+ * @param {string} installPath - Where subagents were installed
418
+ * @param {string[]} installedAgents - List of installed subagent names
419
+ */
420
+ export function showSubagentSuccess(installPath, installedAgents) {
421
+ console.log('\n' + '═'.repeat(50));
422
+ console.log('✅ Subagents installed successfully!');
423
+ console.log('═'.repeat(50));
424
+ console.log(`\n📁 Location: ${installPath}`);
425
+ console.log(`\n🤖 Installed subagents (${installedAgents.length}):`);
426
+ installedAgents.forEach(agent => console.log(` • ${agent}`));
427
+ console.log('\n🚀 Your AI agent will automatically discover these subagents.');
428
+ console.log('═'.repeat(50) + '\n');
429
+ }
430
+
270
431
  /**
271
432
  * Display error message
272
433
  * @param {string} message - Error message
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Fetch and parse available subagents from the GitHub repository
3
+ */
4
+
5
+ const SUBAGENTS_REPO_OWNER = 'supercorks';
6
+ const SUBAGENTS_REPO_NAME = 'subagents';
7
+ const GITHUB_API = 'https://api.github.com';
8
+
9
+ /**
10
+ * Fetch the list of subagent files from the repository
11
+ * Subagents are .agent.md files at the repo root
12
+ * @returns {Promise<Array<{name: string, description: string, filename: string}>>}
13
+ */
14
+ export async function fetchAvailableSubagents() {
15
+ const repoUrl = `${GITHUB_API}/repos/${SUBAGENTS_REPO_OWNER}/${SUBAGENTS_REPO_NAME}/contents`;
16
+
17
+ const response = await fetch(repoUrl, {
18
+ headers: {
19
+ 'Accept': 'application/vnd.github.v3+json',
20
+ 'User-Agent': '@supercorks/skills-installer'
21
+ }
22
+ });
23
+
24
+ if (!response.ok) {
25
+ throw new Error(`Failed to fetch subagents list: ${response.status} ${response.statusText}`);
26
+ }
27
+
28
+ const contents = await response.json();
29
+
30
+ // Filter to .agent.md files only
31
+ const agentFiles = contents.filter(
32
+ item => item.type === 'file' && item.name.endsWith('.agent.md')
33
+ );
34
+
35
+ // Fetch metadata for each agent file
36
+ const agentChecks = await Promise.all(
37
+ agentFiles.map(async (file) => {
38
+ const metadata = await fetchSubagentMetadata(file.name);
39
+ return {
40
+ filename: file.name,
41
+ name: metadata.name || file.name.replace('.agent.md', ''),
42
+ description: metadata.description || 'No description available'
43
+ };
44
+ })
45
+ );
46
+
47
+ return agentChecks;
48
+ }
49
+
50
+ /**
51
+ * Fetch and parse frontmatter from a subagent file
52
+ * @param {string} filename - The agent filename
53
+ * @returns {Promise<{name: string, description: string}>}
54
+ */
55
+ async function fetchSubagentMetadata(filename) {
56
+ const fileUrl = `${GITHUB_API}/repos/${SUBAGENTS_REPO_OWNER}/${SUBAGENTS_REPO_NAME}/contents/${filename}`;
57
+
58
+ try {
59
+ const response = await fetch(fileUrl, {
60
+ headers: {
61
+ 'Accept': 'application/vnd.github.v3+json',
62
+ 'User-Agent': '@supercorks/skills-installer'
63
+ }
64
+ });
65
+
66
+ if (!response.ok) {
67
+ return { name: '', description: '' };
68
+ }
69
+
70
+ const data = await response.json();
71
+ const content = Buffer.from(data.content, 'base64').toString('utf-8');
72
+
73
+ return parseSubagentFrontmatter(content);
74
+ } catch (error) {
75
+ return { name: '', description: '' };
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Parse the subagent frontmatter to extract name and description
81
+ * Supports both standard YAML frontmatter and chatagent format
82
+ * @param {string} content - The .agent.md file content
83
+ * @returns {{name: string, description: string}}
84
+ */
85
+ function parseSubagentFrontmatter(content) {
86
+ // Try to match standard --- frontmatter
87
+ const standardMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
88
+
89
+ // Try to match ```chatagent fenced frontmatter
90
+ const chatAgentMatch = content.match(/```chatagent\s*\n---\s*\n([\s\S]*?)\n---/);
91
+
92
+ const frontmatterContent = standardMatch?.[1] || chatAgentMatch?.[1];
93
+
94
+ if (!frontmatterContent) {
95
+ return { name: '', description: '' };
96
+ }
97
+
98
+ const nameMatch = frontmatterContent.match(/name:\s*['"]?([^'"\n]+)['"]?/);
99
+ const descMatch = frontmatterContent.match(/description:\s*['"]?([^'"\n]+)['"]?/);
100
+
101
+ return {
102
+ name: nameMatch?.[1]?.trim() || '',
103
+ description: descMatch?.[1]?.trim() || ''
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Get the subagents repository clone URL
109
+ * @returns {string}
110
+ */
111
+ export function getSubagentsRepoUrl() {
112
+ return `https://github.com/${SUBAGENTS_REPO_OWNER}/${SUBAGENTS_REPO_NAME}.git`;
113
+ }
114
+
115
+ export { SUBAGENTS_REPO_OWNER, SUBAGENTS_REPO_NAME };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@supercorks/skills-installer",
3
- "version": "1.0.0",
4
- "description": "Interactive CLI installer for AI agent skills",
3
+ "version": "1.2.0",
4
+ "description": "Interactive CLI installer for AI agent skills and subagents",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "skills-installer": "./bin/install.js"
@@ -11,7 +11,9 @@
11
11
  "lib/"
12
12
  ],
13
13
  "scripts": {
14
- "start": "node bin/install.js install"
14
+ "start": "node bin/install.js install",
15
+ "test": "vitest run",
16
+ "test:watch": "vitest"
15
17
  },
16
18
  "repository": {
17
19
  "type": "git",
@@ -28,6 +30,7 @@
28
30
  "ai",
29
31
  "agent",
30
32
  "skills",
33
+ "subagents",
31
34
  "copilot",
32
35
  "claude",
33
36
  "installer",
@@ -38,6 +41,9 @@
38
41
  "dependencies": {
39
42
  "inquirer": "^9.2.12"
40
43
  },
44
+ "devDependencies": {
45
+ "vitest": "^2.1.0"
46
+ },
41
47
  "engines": {
42
48
  "node": ">=18.0.0"
43
49
  }