@supercorks/skills-installer 1.0.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 ADDED
@@ -0,0 +1,82 @@
1
+ # @supercorks/skills-installer
2
+
3
+ Interactive CLI installer for AI agent skills. Selectively install skills for GitHub Copilot, Claude, and other AI assistants using Git sparse-checkout.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ npx @supercorks/skills-installer install
9
+ ```
10
+
11
+ Or with the longer form:
12
+
13
+ ```bash
14
+ npx --package=@supercorks/skills-installer skills-installer install
15
+ ```
16
+
17
+ ## What it does
18
+
19
+ 1. **Choose installation path** - Select where skills should be installed:
20
+ - `.github/skills/` (GitHub Copilot default)
21
+ - `.claude/skills/` (Claude)
22
+ - Custom path of your choice
23
+
24
+ 2. **Gitignore option** - Optionally add the installation path to `.gitignore`
25
+
26
+ 3. **Select skills** - Interactive checkbox to pick which skills to install:
27
+ - Use `↑`/`↓` to navigate
28
+ - Use `SPACE` to toggle selection
29
+ - Use `A` to toggle all
30
+ - Press `ENTER` to confirm
31
+
32
+ 4. **Sparse clone** - Only downloads the selected skills using Git sparse-checkout, keeping the download minimal while preserving full git functionality.
33
+
34
+ ## Features
35
+
36
+ - **Minimal download** - Uses `git clone --filter=blob:none` for efficient cloning
37
+ - **Push capable** - The sparse clone preserves the full git history, allowing you to commit and push changes
38
+ - **Auto-discovery** - Fetches the latest skill list from the repository
39
+ - **Recursive directory creation** - Custom paths are created automatically
40
+
41
+ ## Requirements
42
+
43
+ - Node.js 18+
44
+ - Git
45
+
46
+ ## Updating skills
47
+
48
+ Since the installation uses a sparse git checkout, you can pull updates:
49
+
50
+ ```bash
51
+ cd .github/skills # or wherever you installed
52
+ git pull
53
+ ```
54
+
55
+ ## Adding more skills later
56
+
57
+ You can add more skills to an existing installation:
58
+
59
+ ```bash
60
+ cd .github/skills
61
+ git sparse-checkout add new-skill-name
62
+ ```
63
+
64
+ ## Development
65
+
66
+ ```bash
67
+ # Clone the repo
68
+ git clone https://github.com/supercorks/agent-skills-installer.git
69
+ cd agent-skills-installer
70
+
71
+ # Install dependencies
72
+ npm install
73
+
74
+ # Run locally
75
+ npm start
76
+ # or
77
+ node bin/install.js install
78
+ ```
79
+
80
+ ## License
81
+
82
+ MIT
package/bin/install.js ADDED
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @supercorks/skills-installer
5
+ * Interactive CLI installer for AI agent skills
6
+ *
7
+ * Usage: npx @supercorks/skills-installer install
8
+ */
9
+
10
+ import { existsSync, appendFileSync, readFileSync, writeFileSync } from 'fs';
11
+ import { resolve, join } from 'path';
12
+ import {
13
+ promptInstallPath,
14
+ promptGitignore,
15
+ promptSkillSelection,
16
+ showSpinner,
17
+ showSuccess,
18
+ showError
19
+ } from '../lib/prompts.js';
20
+ import { fetchAvailableSkills } from '../lib/skills.js';
21
+ import { sparseCloneSkills, isGitAvailable } from '../lib/git.js';
22
+
23
+ const VERSION = '1.0.0';
24
+
25
+ /**
26
+ * Print usage information
27
+ */
28
+ function printUsage() {
29
+ console.log(`
30
+ @supercorks/skills-installer v${VERSION}
31
+
32
+ Usage:
33
+ npx @supercorks/skills-installer Install skills interactively (default)
34
+ npx @supercorks/skills-installer --help Show this help message
35
+ npx @supercorks/skills-installer --version Show version
36
+
37
+ Examples:
38
+ npx @supercorks/skills-installer
39
+ npx @supercorks/skills-installer install
40
+ `);
41
+ }
42
+
43
+ /**
44
+ * Add a path to .gitignore if not already present
45
+ * @param {string} gitignorePath - Path to .gitignore file
46
+ * @param {string} pathToIgnore - Path to add to .gitignore
47
+ */
48
+ function addToGitignore(gitignorePath, pathToIgnore) {
49
+ // Normalize the path for gitignore (remove trailing slash for consistency)
50
+ const normalizedPath = pathToIgnore.replace(/\/$/, '');
51
+ const gitignoreEntry = `\n# AI Agent Skills\n${normalizedPath}/\n`;
52
+
53
+ if (existsSync(gitignorePath)) {
54
+ const content = readFileSync(gitignorePath, 'utf-8');
55
+ if (content.includes(normalizedPath)) {
56
+ console.log(`ℹ️ "${normalizedPath}" is already in .gitignore`);
57
+ return;
58
+ }
59
+ appendFileSync(gitignorePath, gitignoreEntry);
60
+ } else {
61
+ writeFileSync(gitignorePath, gitignoreEntry.trim() + '\n');
62
+ }
63
+ console.log(`✅ Added "${normalizedPath}/" to .gitignore`);
64
+ }
65
+
66
+ /**
67
+ * Main installation flow
68
+ */
69
+ async function runInstall() {
70
+ console.log('\n🔧 AI Agent Skills Installer\n');
71
+
72
+ // Check git availability
73
+ if (!isGitAvailable()) {
74
+ showError('Git is not installed or not available in PATH. Please install git first.');
75
+ process.exit(1);
76
+ }
77
+
78
+ // Step 1: Fetch available skills
79
+ let skills;
80
+ const fetchSpinner = showSpinner('Fetching available skills from repository...');
81
+ try {
82
+ skills = await fetchAvailableSkills();
83
+ fetchSpinner.stop(`✅ Found ${skills.length} available skills`);
84
+ } catch (error) {
85
+ fetchSpinner.stop('❌ Failed to fetch skills');
86
+ showError(`Could not fetch skills list: ${error.message}`);
87
+ process.exit(1);
88
+ }
89
+
90
+ if (skills.length === 0) {
91
+ showError('No skills found in the repository');
92
+ process.exit(1);
93
+ }
94
+
95
+ // Step 2: Ask where to install
96
+ const installPath = await promptInstallPath();
97
+ const absoluteInstallPath = resolve(process.cwd(), installPath);
98
+
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);
103
+ }
104
+
105
+ // Step 3: Ask about .gitignore
106
+ const shouldGitignore = await promptGitignore(installPath);
107
+
108
+ // Step 4: Select skills
109
+ const selectedSkills = await promptSkillSelection(skills);
110
+
111
+ // Step 5: Perform installation
112
+ console.log('');
113
+ const installSpinner = showSpinner('Installing selected skills...');
114
+
115
+ try {
116
+ await sparseCloneSkills(installPath, selectedSkills, (message) => {
117
+ installSpinner.stop(` ${message}`);
118
+ });
119
+ } catch (error) {
120
+ installSpinner.stop('❌ Installation failed');
121
+ showError(error.message);
122
+ process.exit(1);
123
+ }
124
+
125
+ // Step 6: Update .gitignore if requested
126
+ if (shouldGitignore) {
127
+ const gitignorePath = resolve(process.cwd(), '.gitignore');
128
+ addToGitignore(gitignorePath, installPath);
129
+ }
130
+
131
+ // Step 7: Show success
132
+ const installedSkillNames = skills
133
+ .filter(s => selectedSkills.includes(s.folder))
134
+ .map(s => s.name);
135
+
136
+ showSuccess(installPath, installedSkillNames);
137
+ }
138
+
139
+ /**
140
+ * Parse command line arguments and run
141
+ */
142
+ async function main() {
143
+ const args = process.argv.slice(2);
144
+ const command = args[0];
145
+
146
+ if (command === '--help' || command === '-h') {
147
+ printUsage();
148
+ process.exit(0);
149
+ }
150
+
151
+ if (command === '--version' || command === '-v') {
152
+ console.log(VERSION);
153
+ process.exit(0);
154
+ }
155
+
156
+ // Default to install if no command or explicit 'install' command
157
+ if (!command || command === 'install') {
158
+ try {
159
+ await runInstall();
160
+ } catch (error) {
161
+ if (error.message.includes('User force closed')) {
162
+ console.log('\n\n👋 Installation cancelled.\n');
163
+ process.exit(0);
164
+ }
165
+ showError(error.message);
166
+ process.exit(1);
167
+ }
168
+ } else {
169
+ console.error(`Unknown command: ${command}`);
170
+ printUsage();
171
+ process.exit(1);
172
+ }
173
+ }
174
+
175
+ main();
package/lib/git.js ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Git sparse-checkout utilities for cloning selected skills
3
+ */
4
+
5
+ import { execSync, spawn } from 'child_process';
6
+ import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync, appendFileSync } from 'fs';
7
+ import { join, resolve } from 'path';
8
+ import { getRepoUrl } from './skills.js';
9
+
10
+ /**
11
+ * Check if git is available
12
+ * @returns {boolean}
13
+ */
14
+ export function isGitAvailable() {
15
+ try {
16
+ execSync('git --version', { stdio: 'ignore' });
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Execute a git command and return the result
25
+ * @param {string[]} args - Git command arguments
26
+ * @param {string} cwd - Working directory
27
+ * @returns {Promise<string>}
28
+ */
29
+ function runGitCommand(args, cwd) {
30
+ return new Promise((resolve, reject) => {
31
+ const proc = spawn('git', args, {
32
+ cwd,
33
+ stdio: ['ignore', 'pipe', 'pipe']
34
+ });
35
+
36
+ let stdout = '';
37
+ let stderr = '';
38
+
39
+ proc.stdout.on('data', (data) => { stdout += data.toString(); });
40
+ proc.stderr.on('data', (data) => { stderr += data.toString(); });
41
+
42
+ proc.on('close', (code) => {
43
+ if (code === 0) {
44
+ resolve(stdout.trim());
45
+ } else {
46
+ reject(new Error(stderr || `Git command failed with code ${code}`));
47
+ }
48
+ });
49
+
50
+ proc.on('error', reject);
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Perform a sparse clone of the repository with only selected skills
56
+ * This method preserves full git history and allows push capability
57
+ *
58
+ * @param {string} targetPath - Where to clone the repository
59
+ * @param {string[]} skillFolders - Array of skill folder names to include
60
+ * @param {(message: string) => void} onProgress - Progress callback
61
+ * @returns {Promise<void>}
62
+ */
63
+ export async function sparseCloneSkills(targetPath, skillFolders, onProgress = () => {}) {
64
+ const repoUrl = getRepoUrl();
65
+ const absolutePath = resolve(targetPath);
66
+
67
+ // Check if target already exists and has content
68
+ if (existsSync(absolutePath)) {
69
+ const gitDir = join(absolutePath, '.git');
70
+ if (existsSync(gitDir)) {
71
+ throw new Error(`Directory "${targetPath}" already contains a git repository. Please remove it first or choose a different path.`);
72
+ }
73
+ }
74
+
75
+ // Create target directory
76
+ mkdirSync(absolutePath, { recursive: true });
77
+
78
+ try {
79
+ // Clone with blob filter for minimal download, no checkout yet
80
+ onProgress('Initializing sparse clone...');
81
+ await runGitCommand([
82
+ 'clone',
83
+ '--filter=blob:none',
84
+ '--no-checkout',
85
+ '--sparse',
86
+ repoUrl,
87
+ '.'
88
+ ], absolutePath);
89
+
90
+ // Use non-cone mode for precise control over what's checked out
91
+ // This prevents root-level files (README.md, etc.) from being included
92
+ onProgress('Configuring sparse-checkout...');
93
+ await runGitCommand([
94
+ 'sparse-checkout',
95
+ 'init',
96
+ '--no-cone'
97
+ ], absolutePath);
98
+
99
+ // Write explicit patterns - only skill folders, no root files
100
+ // Format: /folder/* includes everything in that folder recursively
101
+ const sparseCheckoutPath = join(absolutePath, '.git', 'info', 'sparse-checkout');
102
+ const patterns = skillFolders.map(folder => `/${folder}/`).join('\n');
103
+ writeFileSync(sparseCheckoutPath, patterns + '\n');
104
+
105
+ // Checkout the files
106
+ onProgress('Checking out files...');
107
+ await runGitCommand(['checkout'], absolutePath);
108
+
109
+ onProgress('Done!');
110
+ } catch (error) {
111
+ // Clean up on failure
112
+ try {
113
+ rmSync(absolutePath, { recursive: true, force: true });
114
+ } catch {
115
+ // Ignore cleanup errors
116
+ }
117
+ throw error;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Add more skills to an existing sparse-checkout
123
+ * @param {string} repoPath - Path to the existing sparse-checkout repo
124
+ * @param {string[]} skillFolders - Additional skill folders to add
125
+ * @returns {Promise<void>}
126
+ */
127
+ export async function addSkillsToSparseCheckout(repoPath, skillFolders) {
128
+ const absolutePath = resolve(repoPath);
129
+
130
+ if (!existsSync(join(absolutePath, '.git'))) {
131
+ throw new Error(`"${repoPath}" is not a git repository`);
132
+ }
133
+
134
+ // Append new patterns to sparse-checkout file (non-cone mode format)
135
+ const sparseCheckoutPath = join(absolutePath, '.git', 'info', 'sparse-checkout');
136
+ const newPatterns = skillFolders.map(folder => `/${folder}/`).join('\n');
137
+ appendFileSync(sparseCheckoutPath, newPatterns + '\n');
138
+
139
+ // Re-apply sparse-checkout
140
+ await runGitCommand(['read-tree', '-mu', 'HEAD'], absolutePath);
141
+ }
142
+
143
+ /**
144
+ * List currently checked out skills in a sparse-checkout repo
145
+ * @param {string} repoPath - Path to the sparse-checkout repo
146
+ * @returns {Promise<string[]>} List of skill folder names
147
+ */
148
+ export async function listCheckedOutSkills(repoPath) {
149
+ const absolutePath = resolve(repoPath);
150
+
151
+ if (!existsSync(join(absolutePath, '.git'))) {
152
+ throw new Error(`"${repoPath}" is not a git repository`);
153
+ }
154
+
155
+ // Read patterns from sparse-checkout file (non-cone mode format: /folder/)
156
+ const sparseCheckoutPath = join(absolutePath, '.git', 'info', 'sparse-checkout');
157
+ if (!existsSync(sparseCheckoutPath)) {
158
+ return [];
159
+ }
160
+
161
+ const content = readFileSync(sparseCheckoutPath, 'utf-8');
162
+ const patterns = content.split('\n').filter(Boolean);
163
+
164
+ // Extract folder names from patterns like /folder/
165
+ return patterns
166
+ .map(p => p.replace(/^\/|\/$/g, '')) // Remove leading/trailing slashes
167
+ .filter(p => p && !p.startsWith('.'));
168
+ }
169
+
170
+ /**
171
+ * Pull latest changes in a sparse-checkout repo
172
+ * @param {string} repoPath - Path to the sparse-checkout repo
173
+ * @returns {Promise<string>}
174
+ */
175
+ export async function pullUpdates(repoPath) {
176
+ const absolutePath = resolve(repoPath);
177
+ return runGitCommand(['pull'], absolutePath);
178
+ }
package/lib/prompts.js ADDED
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Interactive CLI prompts using inquirer
3
+ */
4
+
5
+ import inquirer from 'inquirer';
6
+ import * as readline from 'readline';
7
+
8
+ const PATH_CHOICES = {
9
+ GITHUB: '.github/skills/',
10
+ CLAUDE: '.claude/skills/',
11
+ CUSTOM: '__custom__'
12
+ };
13
+
14
+ /**
15
+ * Extract the first sentence from a description
16
+ * @param {string} text - Full description text
17
+ * @param {number} maxLength - Maximum length before truncating
18
+ * @returns {string}
19
+ */
20
+ function getFirstSentence(text, maxLength = 60) {
21
+ if (!text) return '';
22
+ // Match first sentence (ends with . ! or ?)
23
+ const match = text.match(/^[^.!?]+[.!?]/);
24
+ const sentence = match ? match[0].trim() : text;
25
+ if (sentence.length <= maxLength) return sentence;
26
+ return sentence.slice(0, maxLength - 3) + '...';
27
+ }
28
+
29
+ /**
30
+ * Prompt user to select installation path
31
+ * @returns {Promise<string>} The selected or custom path
32
+ */
33
+ export async function promptInstallPath() {
34
+ const { pathChoice } = await inquirer.prompt([
35
+ {
36
+ type: 'list',
37
+ 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
45
+ }
46
+ ]);
47
+
48
+ if (pathChoice === PATH_CHOICES.CUSTOM) {
49
+ const { customPath } = await inquirer.prompt([
50
+ {
51
+ type: 'input',
52
+ name: 'customPath',
53
+ message: 'Enter custom installation path:',
54
+ validate: (input) => {
55
+ if (!input.trim()) {
56
+ return 'Please enter a valid path';
57
+ }
58
+ return true;
59
+ }
60
+ }
61
+ ]);
62
+ return customPath.trim();
63
+ }
64
+
65
+ return pathChoice;
66
+ }
67
+
68
+ /**
69
+ * Prompt user whether to add path to .gitignore
70
+ * @param {string} installPath - The installation path to potentially ignore
71
+ * @returns {Promise<boolean>}
72
+ */
73
+ export async function promptGitignore(installPath) {
74
+ const { shouldIgnore } = await inquirer.prompt([
75
+ {
76
+ type: 'confirm',
77
+ name: 'shouldIgnore',
78
+ message: `Add "${installPath}" to .gitignore?`,
79
+ default: true
80
+ }
81
+ ]);
82
+
83
+ return shouldIgnore;
84
+ }
85
+
86
+ /**
87
+ * Prompt user to select skills to install with expand/collapse support
88
+ * @param {Array<{name: string, description: string, folder: string}>} skills - Available skills
89
+ * @returns {Promise<string[]>} Selected skill folder names
90
+ */
91
+ export async function promptSkillSelection(skills) {
92
+ return new Promise((resolve, reject) => {
93
+ const rl = readline.createInterface({
94
+ input: process.stdin,
95
+ output: process.stdout
96
+ });
97
+
98
+ // Enable raw mode for keypress detection
99
+ if (process.stdin.isTTY) {
100
+ process.stdin.setRawMode(true);
101
+ }
102
+ readline.emitKeypressEvents(process.stdin, rl);
103
+
104
+ let cursor = 0;
105
+ const selected = new Set();
106
+ const expanded = new Set();
107
+
108
+ const render = () => {
109
+ // Clear screen and move to top
110
+ process.stdout.write('\x1B[2J\x1B[H');
111
+
112
+ console.log('\n📦 Available Skills');
113
+ console.log('─'.repeat(60));
114
+ console.log('↑↓ navigate SPACE toggle → expand ← collapse A all ENTER confirm\n');
115
+ console.log('Select skills to install:\n');
116
+
117
+ skills.forEach((skill, i) => {
118
+ const isSelected = selected.has(skill.folder);
119
+ const isCursor = i === cursor;
120
+ const isExpanded = expanded.has(skill.folder);
121
+
122
+ const checkbox = isSelected ? '◉' : '○';
123
+ const pointer = isCursor ? '❯' : ' ';
124
+ const expandIcon = isExpanded ? '▼' : '▶';
125
+
126
+ // Highlight current line
127
+ const highlight = isCursor ? '\x1B[36m' : ''; // Cyan for selected
128
+ const reset = '\x1B[0m';
129
+
130
+ const shortDesc = getFirstSentence(skill.description);
131
+
132
+ if (isExpanded) {
133
+ console.log(`${highlight}${pointer} ${checkbox} ${skill.name}${reset}`);
134
+ // Show full description indented
135
+ const fullDesc = skill.description || 'No description available';
136
+ const lines = fullDesc.match(/.{1,55}/g) || [fullDesc];
137
+ lines.forEach(line => {
138
+ console.log(` ${highlight}${line}${reset}`);
139
+ });
140
+ } else {
141
+ console.log(`${highlight}${pointer} ${checkbox} ${skill.name} ${expandIcon} ${shortDesc}${reset}`);
142
+ }
143
+ });
144
+
145
+ const selectedCount = selected.size;
146
+ console.log(`\n${selectedCount} skill${selectedCount !== 1 ? 's' : ''} selected`);
147
+ };
148
+
149
+ const cleanup = () => {
150
+ process.stdin.removeListener('keypress', handleKeypress);
151
+ if (process.stdin.isTTY) {
152
+ process.stdin.setRawMode(false);
153
+ }
154
+ rl.close();
155
+ };
156
+
157
+ const handleKeypress = (str, key) => {
158
+ if (!key) return;
159
+
160
+ if (key.ctrl && key.name === 'c') {
161
+ cleanup();
162
+ reject(new Error('User force closed the prompt'));
163
+ return;
164
+ }
165
+
166
+ switch (key.name) {
167
+ case 'up':
168
+ cursor = cursor > 0 ? cursor - 1 : skills.length - 1;
169
+ render();
170
+ break;
171
+ case 'down':
172
+ cursor = cursor < skills.length - 1 ? cursor + 1 : 0;
173
+ render();
174
+ break;
175
+ case 'right':
176
+ expanded.add(skills[cursor].folder);
177
+ render();
178
+ break;
179
+ case 'left':
180
+ expanded.delete(skills[cursor].folder);
181
+ render();
182
+ break;
183
+ case 'space':
184
+ const folder = skills[cursor].folder;
185
+ if (selected.has(folder)) {
186
+ selected.delete(folder);
187
+ } else {
188
+ selected.add(folder);
189
+ }
190
+ render();
191
+ break;
192
+ case 'a':
193
+ // Toggle all
194
+ if (selected.size === skills.length) {
195
+ selected.clear();
196
+ } else {
197
+ skills.forEach(s => selected.add(s.folder));
198
+ }
199
+ render();
200
+ break;
201
+ case 'return':
202
+ if (selected.size === 0) {
203
+ // Show error inline
204
+ process.stdout.write('\x1B[31mPlease select at least one skill\x1B[0m');
205
+ setTimeout(render, 1000);
206
+ } else {
207
+ cleanup();
208
+ // Clear and show final selection
209
+ 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}`);
213
+ });
214
+ console.log('');
215
+ resolve(Array.from(selected));
216
+ }
217
+ break;
218
+ }
219
+ };
220
+
221
+ process.stdin.on('keypress', handleKeypress);
222
+ render();
223
+ });
224
+ }
225
+
226
+ /**
227
+ * Display a spinner/loading message
228
+ * @param {string} message - Message to display
229
+ * @returns {{stop: (finalMessage: string) => void}}
230
+ */
231
+ export function showSpinner(message) {
232
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
233
+ let i = 0;
234
+
235
+ process.stdout.write(`${frames[0]} ${message}`);
236
+
237
+ const interval = setInterval(() => {
238
+ i = (i + 1) % frames.length;
239
+ process.stdout.clearLine(0);
240
+ process.stdout.cursorTo(0);
241
+ process.stdout.write(`${frames[i]} ${message}`);
242
+ }, 80);
243
+
244
+ return {
245
+ stop: (finalMessage) => {
246
+ clearInterval(interval);
247
+ process.stdout.clearLine(0);
248
+ process.stdout.cursorTo(0);
249
+ console.log(finalMessage);
250
+ }
251
+ };
252
+ }
253
+
254
+ /**
255
+ * Display success message with next steps
256
+ * @param {string} installPath - Where skills were installed
257
+ * @param {string[]} installedSkills - List of installed skill names
258
+ */
259
+ export function showSuccess(installPath, installedSkills) {
260
+ console.log('\n' + '═'.repeat(50));
261
+ console.log('✅ Skills installed successfully!');
262
+ console.log('═'.repeat(50));
263
+ console.log(`\n📁 Location: ${installPath}`);
264
+ console.log(`\n📦 Installed skills (${installedSkills.length}):`);
265
+ installedSkills.forEach(skill => console.log(` • ${skill}`));
266
+ console.log('\n🚀 Your AI agent will automatically discover these skills.');
267
+ console.log('═'.repeat(50) + '\n');
268
+ }
269
+
270
+ /**
271
+ * Display error message
272
+ * @param {string} message - Error message
273
+ */
274
+ export function showError(message) {
275
+ console.error(`\n❌ Error: ${message}\n`);
276
+ }
package/lib/skills.js ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Fetch and parse available skills from the GitHub repository
3
+ */
4
+
5
+ const REPO_OWNER = 'supercorks';
6
+ const REPO_NAME = 'agent-skills';
7
+ const GITHUB_API = 'https://api.github.com';
8
+
9
+ // Folders to exclude from skill detection (not actual skills)
10
+ const EXCLUDED_FOLDERS = ['.github', '.claude', 'node_modules'];
11
+
12
+ /**
13
+ * Fetch the list of skill directories from the repository
14
+ * Skills are at the repo root level, each folder with a SKILL.md is a skill
15
+ * @returns {Promise<Array<{name: string, description: string, folder: string}>>}
16
+ */
17
+ export async function fetchAvailableSkills() {
18
+ const repoUrl = `${GITHUB_API}/repos/${REPO_OWNER}/${REPO_NAME}/contents`;
19
+
20
+ const response = await fetch(repoUrl, {
21
+ headers: {
22
+ 'Accept': 'application/vnd.github.v3+json',
23
+ 'User-Agent': '@supercorks/skills-installer'
24
+ }
25
+ });
26
+
27
+ if (!response.ok) {
28
+ throw new Error(`Failed to fetch skills list: ${response.status} ${response.statusText}`);
29
+ }
30
+
31
+ const contents = await response.json();
32
+
33
+ // Filter to directories that aren't excluded
34
+ const potentialSkillDirs = contents.filter(
35
+ item => item.type === 'dir' && !EXCLUDED_FOLDERS.includes(item.name) && !item.name.startsWith('.')
36
+ );
37
+
38
+ // Check each directory for SKILL.md to confirm it's a skill
39
+ const skillChecks = await Promise.all(
40
+ potentialSkillDirs.map(async (dir) => {
41
+ const metadata = await fetchSkillMetadata(dir.name);
42
+ // Only include if we found valid metadata (has a SKILL.md)
43
+ if (metadata.name || metadata.description) {
44
+ return {
45
+ folder: dir.name,
46
+ name: metadata.name || dir.name,
47
+ description: metadata.description || 'No description available'
48
+ };
49
+ }
50
+ return null;
51
+ })
52
+ );
53
+
54
+ return skillChecks.filter(Boolean);
55
+ }
56
+
57
+ /**
58
+ * Fetch and parse SKILL.md frontmatter for a specific skill
59
+ * @param {string} skillFolder - The skill folder name
60
+ * @returns {Promise<{name: string, description: string}>}
61
+ */
62
+ async function fetchSkillMetadata(skillFolder) {
63
+ const skillMdUrl = `${GITHUB_API}/repos/${REPO_OWNER}/${REPO_NAME}/contents/${skillFolder}/SKILL.md`;
64
+
65
+ try {
66
+ const response = await fetch(skillMdUrl, {
67
+ headers: {
68
+ 'Accept': 'application/vnd.github.v3+json',
69
+ 'User-Agent': '@supercorks/skills-installer'
70
+ }
71
+ });
72
+
73
+ if (!response.ok) {
74
+ return { name: '', description: '' };
75
+ }
76
+
77
+ const data = await response.json();
78
+ const content = Buffer.from(data.content, 'base64').toString('utf-8');
79
+
80
+ return parseSkillFrontmatter(content);
81
+ } catch (error) {
82
+ return { name: '', description: '' };
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Parse the SKILL.md frontmatter to extract name and description
88
+ * Supports both ```skill code fence and standard --- frontmatter
89
+ * @param {string} content - The SKILL.md file content
90
+ * @returns {{name: string, description: string}}
91
+ */
92
+ function parseSkillFrontmatter(content) {
93
+ // Try to match ```skill fenced frontmatter first
94
+ const skillFenceMatch = content.match(/```skill\s*\n---\s*\n([\s\S]*?)\n---\s*\n/);
95
+
96
+ // Fall back to standard --- frontmatter
97
+ const standardMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
98
+
99
+ const frontmatterContent = skillFenceMatch?.[1] || standardMatch?.[1];
100
+
101
+ if (!frontmatterContent) {
102
+ return { name: '', description: '' };
103
+ }
104
+
105
+ const nameMatch = frontmatterContent.match(/name:\s*['"]?([^'"\n]+)['"]?/);
106
+ const descMatch = frontmatterContent.match(/description:\s*['"]?([^'"\n]+)['"]?/);
107
+
108
+ return {
109
+ name: nameMatch?.[1]?.trim() || '',
110
+ description: descMatch?.[1]?.trim() || ''
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Get the repository clone URL
116
+ * @returns {string}
117
+ */
118
+ export function getRepoUrl() {
119
+ return `https://github.com/${REPO_OWNER}/${REPO_NAME}.git`;
120
+ }
121
+
122
+ export { REPO_OWNER, REPO_NAME };
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@supercorks/skills-installer",
3
+ "version": "1.0.0",
4
+ "description": "Interactive CLI installer for AI agent skills",
5
+ "type": "module",
6
+ "bin": {
7
+ "skills-installer": "./bin/install.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "lib/"
12
+ ],
13
+ "scripts": {
14
+ "start": "node bin/install.js install"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/supercorks/agent-skills-installer.git"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/supercorks/agent-skills-installer/issues"
22
+ },
23
+ "homepage": "https://github.com/supercorks/agent-skills-installer#readme",
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "keywords": [
28
+ "ai",
29
+ "agent",
30
+ "skills",
31
+ "copilot",
32
+ "claude",
33
+ "installer",
34
+ "cli"
35
+ ],
36
+ "author": "supercorks",
37
+ "license": "MIT",
38
+ "dependencies": {
39
+ "inquirer": "^9.2.12"
40
+ },
41
+ "engines": {
42
+ "node": ">=18.0.0"
43
+ }
44
+ }