@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 +82 -0
- package/bin/install.js +175 -0
- package/lib/git.js +178 -0
- package/lib/prompts.js +276 -0
- package/lib/skills.js +122 -0
- package/package.json +44 -0
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
|
+
}
|