cli-ai-skills 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.
@@ -0,0 +1,136 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const { execSync} = require('child_process');
5
+
6
+ class PlatformDetector {
7
+ constructor() {
8
+ this.homeDir = os.homedir();
9
+ }
10
+
11
+ /**
12
+ * Detect all available platforms
13
+ * @returns {Object} Platform detection results
14
+ */
15
+ async detectAll() {
16
+ return {
17
+ copilot: await this.detectCopilot(),
18
+ claude: await this.detectClaude()
19
+ };
20
+ }
21
+
22
+ /**
23
+ * Detect GitHub Copilot CLI
24
+ * @returns {Object} Detection result with installation status and paths
25
+ */
26
+ async detectCopilot() {
27
+ const result = {
28
+ installed: false,
29
+ cliInstalled: false,
30
+ version: null,
31
+ globalPath: path.join(this.homeDir, '.copilot', 'skills'),
32
+ localPath: path.join(process.cwd(), '.github', 'skills')
33
+ };
34
+
35
+ // Check if gh copilot is installed
36
+ try {
37
+ const version = execSync('gh copilot --version 2>&1', { encoding: 'utf8' });
38
+ result.cliInstalled = true;
39
+ result.version = version.trim();
40
+ } catch (error) {
41
+ result.cliInstalled = false;
42
+ }
43
+
44
+ // Check if global skills directory exists
45
+ result.installed = await fs.pathExists(path.join(this.homeDir, '.copilot'));
46
+
47
+ return result;
48
+ }
49
+
50
+ /**
51
+ * Detect Claude Code
52
+ * @returns {Object} Detection result with installation status and paths
53
+ */
54
+ async detectClaude() {
55
+ const result = {
56
+ installed: false,
57
+ version: null,
58
+ globalPath: path.join(this.homeDir, '.claude', 'skills'),
59
+ localPath: path.join(process.cwd(), '.claude', 'skills')
60
+ };
61
+
62
+ // Check if .claude directory exists
63
+ const claudeDir = path.join(this.homeDir, '.claude');
64
+ result.installed = await fs.pathExists(claudeDir);
65
+
66
+ // Try to get version from config
67
+ if (result.installed) {
68
+ const configPath = path.join(claudeDir, 'settings.local.json');
69
+ if (await fs.pathExists(configPath)) {
70
+ try {
71
+ const config = await fs.readJson(configPath);
72
+ result.version = config.version || 'unknown';
73
+ } catch (error) {
74
+ // Ignore JSON parse errors
75
+ }
76
+ }
77
+ }
78
+
79
+ return result;
80
+ }
81
+
82
+ /**
83
+ * Get installation paths based on scope
84
+ * @param {string} scope - 'global' or 'local'
85
+ * @param {Object} platforms - Platform detection results
86
+ * @returns {Object} Installation paths
87
+ */
88
+ getInstallPaths(scope, platforms) {
89
+ const paths = {};
90
+
91
+ if (scope === 'global') {
92
+ if (platforms.copilot) {
93
+ paths.copilot = platforms.copilot.globalPath;
94
+ }
95
+ if (platforms.claude) {
96
+ paths.claude = platforms.claude.globalPath;
97
+ }
98
+ } else {
99
+ // local
100
+ if (platforms.copilot) {
101
+ paths.copilot = platforms.copilot.localPath;
102
+ }
103
+ if (platforms.claude) {
104
+ paths.claude = platforms.claude.localPath;
105
+ }
106
+ }
107
+
108
+ return paths;
109
+ }
110
+
111
+ /**
112
+ * Ensure installation directories exist
113
+ * @param {Object} paths - Installation paths
114
+ */
115
+ async ensureDirectories(paths) {
116
+ for (const [platform, dirPath] of Object.entries(paths)) {
117
+ await fs.ensureDir(dirPath);
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Check if directory is writable
123
+ * @param {string} dirPath - Directory path to check
124
+ * @returns {boolean} True if writable
125
+ */
126
+ async isWritable(dirPath) {
127
+ try {
128
+ await fs.access(dirPath, fs.constants.W_OK);
129
+ return true;
130
+ } catch (error) {
131
+ return false;
132
+ }
133
+ }
134
+ }
135
+
136
+ module.exports = PlatformDetector;
@@ -0,0 +1,161 @@
1
+ const axios = require('axios');
2
+ const fs = require('fs-extra');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const ora = require('ora');
6
+ const yaml = require('js-yaml');
7
+
8
+ class SkillDownloader {
9
+ constructor() {
10
+ this.repoOwner = 'ericgandrade';
11
+ this.repoName = 'cli-ai-skills';
12
+ this.branch = 'main';
13
+ this.cacheDir = path.join(os.homedir(), '.cli-ai-skills', 'cache');
14
+ }
15
+
16
+ /**
17
+ * List available skills from GitHub
18
+ * @returns {Array} List of skill objects
19
+ */
20
+ async listAvailableSkills() {
21
+ const spinner = ora('Fetching available skills...').start();
22
+
23
+ try {
24
+ const url = `https://api.github.com/repos/${this.repoOwner}/${this.repoName}/contents/.github/skills`;
25
+ const response = await axios.get(url, {
26
+ headers: {
27
+ 'Accept': 'application/vnd.github.v3+json',
28
+ 'User-Agent': 'cli-ai-skills-installer'
29
+ }
30
+ });
31
+
32
+ const skills = [];
33
+
34
+ for (const item of response.data) {
35
+ if (item.type === 'dir') {
36
+ // Get SKILL.md to extract metadata
37
+ const skillMeta = await this.getSkillMetadata(item.name, '.github');
38
+ skills.push({
39
+ name: item.name,
40
+ version: skillMeta.version,
41
+ description: skillMeta.description
42
+ });
43
+ }
44
+ }
45
+
46
+ spinner.succeed(`Found ${skills.length} skills`);
47
+ return skills;
48
+ } catch (error) {
49
+ spinner.fail('Failed to fetch skills');
50
+ throw new Error(`GitHub API error: ${error.message}`);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Get skill metadata from SKILL.md
56
+ * @param {string} skillName - Name of the skill
57
+ * @param {string} basePath - '.github' or '.claude'
58
+ * @returns {Object} Skill metadata
59
+ */
60
+ async getSkillMetadata(skillName, basePath = '.github') {
61
+ try {
62
+ const url = `https://raw.githubusercontent.com/${this.repoOwner}/${this.repoName}/${this.branch}/${basePath}/skills/${skillName}/SKILL.md`;
63
+ const response = await axios.get(url);
64
+
65
+ // Extract YAML frontmatter
66
+ const frontmatterMatch = response.data.match(/^---\n([\s\S]*?)\n---/);
67
+ if (frontmatterMatch) {
68
+ return yaml.load(frontmatterMatch[1]);
69
+ }
70
+
71
+ return { version: 'unknown', description: 'No description' };
72
+ } catch (error) {
73
+ return { version: 'unknown', description: 'No description' };
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Download a skill directory from GitHub
79
+ * @param {string} skillName - Name of the skill
80
+ * @param {string} platform - 'copilot' or 'claude'
81
+ * @returns {string} Path to downloaded skill
82
+ */
83
+ async downloadSkill(skillName, platform = 'copilot') {
84
+ const spinner = ora(`Downloading ${skillName}...`).start();
85
+
86
+ const basePath = platform === 'copilot' ? '.github/skills' : '.claude/skills';
87
+ const skillPath = path.join(this.cacheDir, platform, skillName);
88
+
89
+ try {
90
+ // Ensure cache directory exists
91
+ await fs.ensureDir(skillPath);
92
+
93
+ // Get directory contents from GitHub API
94
+ const url = `https://api.github.com/repos/${this.repoOwner}/${this.repoName}/contents/${basePath}/${skillName}`;
95
+ const response = await axios.get(url, {
96
+ headers: {
97
+ 'Accept': 'application/vnd.github.v3+json',
98
+ 'User-Agent': 'cli-ai-skills-installer'
99
+ }
100
+ });
101
+
102
+ // Download each file
103
+ await this.downloadDirectory(response.data, skillPath);
104
+
105
+ spinner.succeed(`Downloaded ${skillName}`);
106
+ return skillPath;
107
+ } catch (error) {
108
+ spinner.fail(`Failed to download ${skillName}`);
109
+ throw new Error(`Download error: ${error.message}`);
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Recursively download directory contents
115
+ * @param {Array} contents - GitHub API contents response
116
+ * @param {string} targetPath - Target directory path
117
+ */
118
+ async downloadDirectory(contents, targetPath) {
119
+ for (const item of contents) {
120
+ const itemPath = path.join(targetPath, item.name);
121
+
122
+ if (item.type === 'file') {
123
+ // Download file
124
+ const response = await axios.get(item.download_url, {
125
+ responseType: 'arraybuffer',
126
+ headers: {
127
+ 'User-Agent': 'cli-ai-skills-installer'
128
+ }
129
+ });
130
+ await fs.writeFile(itemPath, response.data);
131
+ } else if (item.type === 'dir') {
132
+ // Recursively download subdirectory
133
+ await fs.ensureDir(itemPath);
134
+ const subDirResponse = await axios.get(item.url, {
135
+ headers: {
136
+ 'Accept': 'application/vnd.github.v3+json',
137
+ 'User-Agent': 'cli-ai-skills-installer'
138
+ }
139
+ });
140
+ await this.downloadDirectory(subDirResponse.data, itemPath);
141
+ }
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Clear download cache
147
+ */
148
+ async clearCache() {
149
+ await fs.remove(this.cacheDir);
150
+ }
151
+
152
+ /**
153
+ * Get cache directory path
154
+ * @returns {string} Cache directory path
155
+ */
156
+ getCacheDir() {
157
+ return this.cacheDir;
158
+ }
159
+ }
160
+
161
+ module.exports = SkillDownloader;
@@ -0,0 +1,150 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const chalk = require('chalk');
4
+ const ora = require('ora');
5
+
6
+ class SkillInstaller {
7
+ constructor(detector, downloader, versionChecker) {
8
+ this.detector = detector;
9
+ this.downloader = downloader;
10
+ this.versionChecker = versionChecker;
11
+ }
12
+
13
+ /**
14
+ * Install a skill
15
+ * @param {string} skillName - Name of the skill
16
+ * @param {Object} options - Installation options
17
+ * @returns {Object} Installation result
18
+ */
19
+ async install(skillName, options = {}) {
20
+ const {
21
+ platforms = ['copilot', 'claude'],
22
+ scope = 'global',
23
+ method = 'symlink',
24
+ force = false
25
+ } = options;
26
+
27
+ const results = {
28
+ skillName,
29
+ success: false,
30
+ platforms: {},
31
+ errors: []
32
+ };
33
+
34
+ try {
35
+ // Get platform paths
36
+ const platformInfo = await this.detector.detectAll();
37
+ const installPaths = this.detector.getInstallPaths(scope, platformInfo);
38
+
39
+ // Ensure directories exist
40
+ await this.detector.ensureDirectories(installPaths);
41
+
42
+ // Install for each selected platform
43
+ for (const platform of platforms) {
44
+ if (!installPaths[platform]) {
45
+ results.errors.push(`${platform} not available`);
46
+ continue;
47
+ }
48
+
49
+ try {
50
+ // Download skill for this platform
51
+ const sourcePath = await this.downloader.downloadSkill(skillName, platform);
52
+ const targetPath = path.join(installPaths[platform], skillName);
53
+
54
+ // Check if already exists
55
+ const exists = await fs.pathExists(targetPath);
56
+ if (exists && !force) {
57
+ // Get version info
58
+ const sourceVersion = await this.versionChecker.extractVersionFromSkill(sourcePath);
59
+ const targetVersion = await this.versionChecker.extractVersionFromSkill(targetPath);
60
+
61
+ results.platforms[platform] = {
62
+ status: 'exists',
63
+ currentVersion: targetVersion,
64
+ availableVersion: sourceVersion,
65
+ skipped: true
66
+ };
67
+ continue;
68
+ }
69
+
70
+ // Remove existing if force
71
+ if (exists) {
72
+ await fs.remove(targetPath);
73
+ }
74
+
75
+ // Install based on method
76
+ if (method === 'symlink' && scope === 'global') {
77
+ // Create symlink (only for global installations)
78
+ await fs.symlink(sourcePath, targetPath, 'dir');
79
+ results.platforms[platform] = {
80
+ status: 'installed',
81
+ method: 'symlink',
82
+ path: targetPath
83
+ };
84
+ } else {
85
+ // Copy files (for local or when symlink not desired)
86
+ await fs.copy(sourcePath, targetPath);
87
+ results.platforms[platform] = {
88
+ status: 'installed',
89
+ method: 'copy',
90
+ path: targetPath
91
+ };
92
+ }
93
+
94
+ } catch (error) {
95
+ results.errors.push(`${platform}: ${error.message}`);
96
+ results.platforms[platform] = {
97
+ status: 'failed',
98
+ error: error.message
99
+ };
100
+ }
101
+ }
102
+
103
+ results.success = Object.values(results.platforms).some(p => p.status === 'installed');
104
+ } catch (error) {
105
+ results.errors.push(error.message);
106
+ }
107
+
108
+ return results;
109
+ }
110
+
111
+ /**
112
+ * Uninstall a skill
113
+ * @param {string} skillName - Name of the skill
114
+ * @param {Object} options - Uninstall options
115
+ */
116
+ async uninstall(skillName, options = {}) {
117
+ const { platforms = ['copilot', 'claude'], scope = 'global' } = options;
118
+
119
+ const platformInfo = await this.detector.detectAll();
120
+ const installPaths = this.detector.getInstallPaths(scope, platformInfo);
121
+
122
+ const results = { removed: [], failed: [] };
123
+
124
+ for (const platform of platforms) {
125
+ if (!installPaths[platform]) continue;
126
+
127
+ const skillPath = path.join(installPaths[platform], skillName);
128
+
129
+ if (await fs.pathExists(skillPath)) {
130
+ try {
131
+ await fs.remove(skillPath);
132
+ results.removed.push(platform);
133
+ } catch (error) {
134
+ results.failed.push({ platform, error: error.message });
135
+ }
136
+ }
137
+ }
138
+
139
+ return results;
140
+ }
141
+
142
+ /**
143
+ * Get version from downloaded skill
144
+ */
145
+ async getDownloadedVersion(skillPath) {
146
+ return await this.versionChecker.extractVersionFromSkill(skillPath);
147
+ }
148
+ }
149
+
150
+ module.exports = SkillInstaller;
@@ -0,0 +1,129 @@
1
+ const semver = require('semver');
2
+ const fs = require('fs-extra');
3
+ const path = require('path');
4
+ const yaml = require('js-yaml');
5
+
6
+ class VersionChecker {
7
+ /**
8
+ * Extract version from SKILL.md frontmatter
9
+ * @param {string} skillPath - Path to skill directory (containing SKILL.md)
10
+ * @returns {string|null} Version string or null
11
+ */
12
+ async extractVersionFromSkill(skillPath) {
13
+ const skillFile = path.join(skillPath, 'SKILL.md');
14
+
15
+ if (!await fs.pathExists(skillFile)) {
16
+ return null;
17
+ }
18
+
19
+ try {
20
+ const content = await fs.readFile(skillFile, 'utf8');
21
+
22
+ // Extract YAML frontmatter
23
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
24
+ if (!frontmatterMatch) {
25
+ return null;
26
+ }
27
+
28
+ const frontmatter = yaml.load(frontmatterMatch[1]);
29
+ return frontmatter.version || null;
30
+ } catch (error) {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Compare two versions
37
+ * @param {string} current - Current version
38
+ * @param {string} latest - Latest version
39
+ * @returns {string} 'outdated', 'latest', 'newer', or 'unknown'
40
+ */
41
+ compareVersions(current, latest) {
42
+ if (!semver.valid(current) || !semver.valid(latest)) {
43
+ return 'unknown';
44
+ }
45
+
46
+ if (semver.gt(latest, current)) {
47
+ return 'outdated'; // Update available
48
+ } else if (semver.eq(current, latest)) {
49
+ return 'latest';
50
+ } else {
51
+ return 'newer'; // Installed version is newer (beta/dev)
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Check if skill needs update
57
+ * @param {string} installedPath - Path to installed skill
58
+ * @param {string} latestVersion - Latest available version
59
+ * @returns {Object} Update status
60
+ */
61
+ async checkUpdate(installedPath, latestVersion) {
62
+ const currentVersion = await this.extractVersionFromSkill(installedPath);
63
+
64
+ if (!currentVersion) {
65
+ return {
66
+ installed: false,
67
+ needsUpdate: false,
68
+ currentVersion: null,
69
+ latestVersion
70
+ };
71
+ }
72
+
73
+ const status = this.compareVersions(currentVersion, latestVersion);
74
+
75
+ return {
76
+ installed: true,
77
+ needsUpdate: status === 'outdated',
78
+ currentVersion,
79
+ latestVersion,
80
+ status
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Get installed version for a skill
86
+ * @param {string} skillName - Name of the skill
87
+ * @param {string} platform - Platform (copilot or claude)
88
+ * @returns {string|null} Version string or null
89
+ */
90
+ async getInstalledVersion(skillName, platform) {
91
+ const os = require('os');
92
+ const homeDir = os.homedir();
93
+
94
+ let skillPath;
95
+ if (platform === 'copilot') {
96
+ skillPath = path.join(homeDir, '.copilot', 'skills', skillName);
97
+ } else if (platform === 'claude') {
98
+ skillPath = path.join(homeDir, '.claude', 'skills', skillName);
99
+ } else {
100
+ return null;
101
+ }
102
+
103
+ return await this.extractVersionFromSkill(skillPath);
104
+ }
105
+
106
+ /**
107
+ * Check version status for a skill
108
+ * @param {string} skillName - Name of the skill
109
+ * @param {string} platform - Platform (copilot or claude)
110
+ * @param {string} latestVersion - Latest version (optional, will fetch if not provided)
111
+ * @returns {string} Status: 'outdated', 'latest', 'newer', 'not_installed', 'unknown'
112
+ */
113
+ async checkVersion(skillName, platform, latestVersion = null) {
114
+ const installedVersion = await this.getInstalledVersion(skillName, platform);
115
+
116
+ if (!installedVersion) {
117
+ return 'not_installed';
118
+ }
119
+
120
+ if (!latestVersion) {
121
+ // If latest version not provided, we can't compare
122
+ return 'unknown';
123
+ }
124
+
125
+ return this.compareVersions(installedVersion, latestVersion);
126
+ }
127
+ }
128
+
129
+ module.exports = VersionChecker;
@@ -0,0 +1,132 @@
1
+ const chalk = require('chalk');
2
+
3
+ /**
4
+ * Visual progress gauge for multi-step operations
5
+ * Creates a progress bar like: [████████████░░░░░░] 60% - Step 3/5: Installing skills
6
+ */
7
+ class ProgressGauge {
8
+ constructor(totalSteps) {
9
+ this.totalSteps = totalSteps;
10
+ this.currentStep = 0;
11
+ this.barLength = 20; // Total characters in the progress bar
12
+ }
13
+
14
+ /**
15
+ * Update progress to next step
16
+ * @param {string} stepName - Description of current step
17
+ */
18
+ next(stepName) {
19
+ this.currentStep++;
20
+ this.render(stepName);
21
+ }
22
+
23
+ /**
24
+ * Set progress to specific step
25
+ * @param {number} step - Step number (1-indexed)
26
+ * @param {string} stepName - Description of current step
27
+ */
28
+ setStep(step, stepName) {
29
+ this.currentStep = step;
30
+ this.render(stepName);
31
+ }
32
+
33
+ /**
34
+ * Render the progress gauge
35
+ * @param {string} stepName - Description of current step
36
+ */
37
+ render(stepName = '') {
38
+ const percentage = Math.round((this.currentStep / this.totalSteps) * 100);
39
+ const filledLength = Math.round((this.currentStep / this.totalSteps) * this.barLength);
40
+ const emptyLength = this.barLength - filledLength;
41
+
42
+ // Create the visual bar
43
+ const filledBar = '█'.repeat(filledLength);
44
+ const emptyBar = '░'.repeat(emptyLength);
45
+ const bar = chalk.cyan(filledBar) + chalk.dim(emptyBar);
46
+
47
+ // Format the output
48
+ const percentageStr = chalk.bold(`${percentage}%`);
49
+ const stepStr = chalk.dim(`Step ${this.currentStep}/${this.totalSteps}`);
50
+ const nameStr = stepName ? chalk.white(`: ${stepName}`) : '';
51
+
52
+ console.log(`[${bar}] ${percentageStr} - ${stepStr}${nameStr}`);
53
+ }
54
+
55
+ /**
56
+ * Mark as complete
57
+ */
58
+ complete(message = 'Complete!') {
59
+ this.currentStep = this.totalSteps;
60
+ const bar = chalk.green('█'.repeat(this.barLength));
61
+ console.log(`[${bar}] ${chalk.green.bold('100%')} - ${chalk.green(message)}`);
62
+ }
63
+
64
+ /**
65
+ * Create a simple inline gauge (single line, no steps)
66
+ * @param {number} current - Current value
67
+ * @param {number} total - Total value
68
+ * @param {string} label - Label to display
69
+ * @returns {string} Formatted gauge string
70
+ */
71
+ static inline(current, total, label = '') {
72
+ const percentage = Math.round((current / total) * 100);
73
+ const barLength = 15;
74
+ const filledLength = Math.round((current / total) * barLength);
75
+ const emptyLength = barLength - filledLength;
76
+
77
+ const filledBar = chalk.cyan('█'.repeat(filledLength));
78
+ const emptyBar = chalk.dim('░'.repeat(emptyLength));
79
+
80
+ const labelStr = label ? `${label} ` : '';
81
+ return `${labelStr}[${filledBar}${emptyBar}] ${chalk.bold(percentage + '%')}`;
82
+ }
83
+
84
+ /**
85
+ * Create a compact gauge for lists
86
+ * @param {number} current - Current value
87
+ * @param {number} total - Total value
88
+ * @returns {string} Compact gauge (just the bar)
89
+ */
90
+ static compact(current, total) {
91
+ const percentage = Math.round((current / total) * 100);
92
+ const barLength = 10;
93
+ const filledLength = Math.round((current / total) * barLength);
94
+ const emptyLength = barLength - filledLength;
95
+
96
+ const filledBar = '█'.repeat(filledLength);
97
+ const emptyBar = '░'.repeat(emptyLength);
98
+
99
+ return `[${chalk.cyan(filledBar)}${chalk.dim(emptyBar)}]`;
100
+ }
101
+
102
+ /**
103
+ * Render a multi-operation progress summary
104
+ * @param {Object} stats - Statistics object { completed, failed, total }
105
+ */
106
+ static summary(stats) {
107
+ const { completed = 0, failed = 0, total = 0 } = stats;
108
+ const skipped = total - completed - failed;
109
+
110
+ console.log(chalk.cyan('\n📊 Progress Summary:\n'));
111
+
112
+ if (completed > 0) {
113
+ const gauge = ProgressGauge.compact(completed, total);
114
+ console.log(` ${gauge} ${chalk.green(`✅ Completed: ${completed}`)}`);
115
+ }
116
+
117
+ if (failed > 0) {
118
+ const gauge = ProgressGauge.compact(failed, total);
119
+ console.log(` ${gauge} ${chalk.red(`❌ Failed: ${failed}`)}`);
120
+ }
121
+
122
+ if (skipped > 0) {
123
+ const gauge = ProgressGauge.compact(skipped, total);
124
+ console.log(` ${gauge} ${chalk.yellow(`⏭️ Skipped: ${skipped}`)}`);
125
+ }
126
+
127
+ const overallGauge = ProgressGauge.compact(completed, total);
128
+ console.log(`\n ${overallGauge} ${chalk.bold(`Overall: ${completed}/${total} successful`)}`);
129
+ }
130
+ }
131
+
132
+ module.exports = ProgressGauge;