skill-linker 1.1.0 → 3.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 +51 -48
- package/bin/cli.js +3 -43
- package/package.json +11 -5
- package/src/cli.js +45 -0
- package/src/commands/install.js +260 -0
- package/src/commands/list.js +78 -0
- package/src/utils/agents.js +91 -0
- package/src/utils/file-system.js +166 -0
- package/src/utils/git.js +108 -0
- package/link-skill.sh +0 -316
package/README.md
CHANGED
|
@@ -1,22 +1,23 @@
|
|
|
1
|
-
# AI Agent Skill Installer
|
|
1
|
+
# AI Agent Skill Installer (skill-linker)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
一個現代化的互動式 CLI 工具,用於將 AI Agent Skills 快速連結(Symlink)到各種 AI Agent 的專案或全域目錄中。
|
|
4
4
|
|
|
5
5
|
## ✨ 功能特色
|
|
6
6
|
|
|
7
|
-
-
|
|
7
|
+
- **現代化 TUI 介面**:使用 `prompts` 提供流暢的互動體驗。
|
|
8
|
+
- **模糊搜尋 (Fuzzy Search)**:在選擇 Skill 時可輸入關鍵字快速過濾。
|
|
9
|
+
- **智慧偵測**:自動偵測系統中已安裝的 Agent,並在選單中預設勾選。
|
|
10
|
+
- **多 Agent 支援**:支援 Claude Code, GitHub Copilot, Antigravity, Cursor, Windsurf, OpenCode, Gemini CLI 等。
|
|
8
11
|
- **雙重範圍 (Scope)**:可選擇安裝到當前 `專案目錄 (Project)` 或 `全域目錄 (Global)`。
|
|
9
|
-
- **自動 Clone
|
|
10
|
-
-
|
|
12
|
+
- **自動 Clone**:支援從 GitHub Clone 並自動處理 Multi-skill Repos。
|
|
13
|
+
- **完全相容 npx**:無需安裝,隨插即用。
|
|
11
14
|
|
|
12
15
|
## 🚀 快速開始
|
|
13
16
|
|
|
14
17
|
### 方式 1:使用 npx (推薦)
|
|
15
18
|
|
|
16
|
-
無需安裝,直接執行:
|
|
17
|
-
|
|
18
19
|
```bash
|
|
19
|
-
#
|
|
20
|
+
# 啟動互動式安裝介面
|
|
20
21
|
npx skill-linker
|
|
21
22
|
|
|
22
23
|
# 從 GitHub Clone 並安裝
|
|
@@ -26,63 +27,57 @@ npx skill-linker --from https://github.com/user/my-skill
|
|
|
26
27
|
npx skill-linker /path/to/my-skill
|
|
27
28
|
```
|
|
28
29
|
|
|
29
|
-
### 方式 2
|
|
30
|
+
### 方式 2:本地開發/安裝
|
|
30
31
|
|
|
31
32
|
```bash
|
|
32
|
-
git clone https://github.com/
|
|
33
|
-
cd skill-
|
|
34
|
-
|
|
33
|
+
git clone https://github.com/raybird/skill-linker.git
|
|
34
|
+
cd skill-linker
|
|
35
|
+
npm install
|
|
36
|
+
npm link # 之後可直接使用 skill-linker 指令
|
|
35
37
|
```
|
|
36
38
|
|
|
37
39
|
## 🛠️ 命令說明
|
|
38
40
|
|
|
39
41
|
```
|
|
40
|
-
Usage:
|
|
42
|
+
Usage: skill-linker [options] [command] [skill-path]
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
--from <github_url> 從 GitHub Clone Skill 後再連結
|
|
44
|
-
--help 顯示說明
|
|
45
|
-
|
|
46
|
-
Examples:
|
|
47
|
-
./link-skill.sh # 互動式選擇
|
|
48
|
-
./link-skill.sh /path/to/skill # 指定本地 Skill
|
|
49
|
-
./link-skill.sh --from https://github.com/user/my-skill
|
|
50
|
-
./link-skill.sh --from https://github.com/anthropics/skills/tree/main/skills/pdf
|
|
51
|
-
```
|
|
44
|
+
Interactive CLI to link AI Agent Skills to various agents
|
|
52
45
|
|
|
53
|
-
|
|
46
|
+
Arguments:
|
|
47
|
+
skill-path 指定本地 Skill 目錄路徑
|
|
54
48
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
49
|
+
Options:
|
|
50
|
+
-V, --version 顯示版本號
|
|
51
|
+
--from <github-url> 從 GitHub Clone Skill 後再連結
|
|
52
|
+
-l, --list 列出庫中的 Skills (可互動選擇)
|
|
53
|
+
-h, --help 顯示說明
|
|
59
54
|
|
|
60
|
-
|
|
55
|
+
Commands:
|
|
56
|
+
list 列出所有庫中的 Repos 與其 Skills
|
|
57
|
+
```
|
|
61
58
|
|
|
62
|
-
|
|
63
|
-
|------|------|
|
|
64
|
-
| [anthropics/skills](https://github.com/anthropics/skills) | Claude 官方 Skills (pdf, docx, pptx, xlsx...) |
|
|
65
|
-
| [obra/superpowers](https://github.com/obra/superpowers) | 開發流程 Skills (TDD, debugging, code-review...) |
|
|
59
|
+
### 📋 瀏覽模式 (List Mode)
|
|
66
60
|
|
|
67
|
-
|
|
68
|
-
# 安裝 Anthropic 的 PDF Skill
|
|
69
|
-
npx skill-linker --from https://github.com/anthropics/skills/tree/main/skills/pdf
|
|
61
|
+
使用 `list` 子指令或 `-l` 參數可以啟動兩層式的互動瀏覽:
|
|
70
62
|
|
|
71
|
-
|
|
72
|
-
npx skill-linker
|
|
63
|
+
```bash
|
|
64
|
+
npx skill-linker list
|
|
73
65
|
```
|
|
74
66
|
|
|
75
|
-
|
|
67
|
+
1. **第一層**:選擇已 Clone 的 Repository (會標註是否有 `skills/` 子目錄)。
|
|
68
|
+
2. **第二層**:如果該 Repo 包含多個 Skills,會進階列出供您查看。
|
|
76
69
|
|
|
77
|
-
|
|
70
|
+
## 📂 Skill Library 管理
|
|
78
71
|
|
|
79
|
-
|
|
80
|
-
mkdir -p ~/Documents/AgentSkills
|
|
81
|
-
cd ~/Documents/AgentSkills
|
|
82
|
-
git clone https://github.com/anthropics/skills.git
|
|
83
|
-
```
|
|
72
|
+
當您使用 `--from` 參數時,Skills 會自動存放到 `~/Documents/AgentSkills`,並以 **owner/repo** 結構分層:
|
|
84
73
|
|
|
85
|
-
|
|
74
|
+
```
|
|
75
|
+
~/Documents/AgentSkills/
|
|
76
|
+
├── anthropics/
|
|
77
|
+
│ └── skills/ # https://github.com/anthropics/skills
|
|
78
|
+
└── your-org/
|
|
79
|
+
└── your-skill/ # https://github.com/your-org/your-skill
|
|
80
|
+
```
|
|
86
81
|
|
|
87
82
|
## 🛠️ 支援的 Agent 與路徑
|
|
88
83
|
|
|
@@ -97,11 +92,19 @@ git clone https://github.com/anthropics/skills.git
|
|
|
97
92
|
| **Gemini CLI** | `.gemini/skills/` | `~/.gemini/skills/` |
|
|
98
93
|
| **Windsurf** | `.windsurf/skills/` | `~/.codeium/windsurf/skills/` |
|
|
99
94
|
|
|
95
|
+
## 📦 推薦的 Public Skill Repos
|
|
96
|
+
|
|
97
|
+
| Repo | 說明 |
|
|
98
|
+
|------|------|
|
|
99
|
+
| [anthropics/skills](https://github.com/anthropics/skills) | Claude 官方 Skills (pdf, docx, pptx, xlsx...) |
|
|
100
|
+
| [moltbot/skills](https://github.com/moltbot/skills) | 各種封存的 AI Agent Skills (來自 clawdhub.com) |
|
|
101
|
+
|
|
100
102
|
## ⚠️ 注意事項
|
|
101
103
|
|
|
102
|
-
1.
|
|
103
|
-
2.
|
|
104
|
+
1. **權限問題**:在建立 Symlink 時,請確保您有對應目錄的寫入權限。
|
|
105
|
+
2. **環境需求**:需安裝 Node.js 14.0.0 以上版本。
|
|
104
106
|
|
|
105
107
|
## 授權
|
|
106
108
|
|
|
107
109
|
MIT License
|
|
110
|
+
|
package/bin/cli.js
CHANGED
|
@@ -1,48 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* skill-linker CLI
|
|
5
|
-
* Node.js
|
|
4
|
+
* skill-linker CLI entry point
|
|
5
|
+
* Modern Node.js implementation with interactive TUI
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
const path = require('path');
|
|
10
|
-
const fs = require('fs');
|
|
11
|
-
|
|
12
|
-
// Get the path to the shell script
|
|
13
|
-
const scriptPath = path.join(__dirname, '..', 'link-skill.sh');
|
|
14
|
-
|
|
15
|
-
// Check if bash is available
|
|
16
|
-
const isWindows = process.platform === 'win32';
|
|
17
|
-
|
|
18
|
-
if (isWindows) {
|
|
19
|
-
console.error('\x1b[31m[ERROR]\x1b[0m This tool requires Bash.');
|
|
20
|
-
console.error('On Windows, please use WSL (Windows Subsystem for Linux) or Git Bash.');
|
|
21
|
-
console.error('');
|
|
22
|
-
console.error('Example with WSL:');
|
|
23
|
-
console.error(' wsl npx skill-linker');
|
|
24
|
-
process.exit(1);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// Check if script exists
|
|
28
|
-
if (!fs.existsSync(scriptPath)) {
|
|
29
|
-
console.error('\x1b[31m[ERROR]\x1b[0m Shell script not found:', scriptPath);
|
|
30
|
-
process.exit(1);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Forward all arguments to the shell script
|
|
34
|
-
const args = process.argv.slice(2);
|
|
35
|
-
|
|
36
|
-
const child = spawn('bash', [scriptPath, ...args], {
|
|
37
|
-
stdio: 'inherit',
|
|
38
|
-
cwd: process.cwd()
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
child.on('error', (err) => {
|
|
42
|
-
console.error('\x1b[31m[ERROR]\x1b[0m Failed to run script:', err.message);
|
|
43
|
-
process.exit(1);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
child.on('close', (code) => {
|
|
47
|
-
process.exit(code || 0);
|
|
48
|
-
});
|
|
8
|
+
require('../src/cli.js');
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skill-linker",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Interactive CLI to link AI Agent Skills to various agents (Claude, Copilot, Antigravity, Cursor, etc.)",
|
|
5
5
|
"main": "bin/cli.js",
|
|
6
6
|
"bin": {
|
|
7
|
-
"skill-linker": "
|
|
7
|
+
"skill-linker": "bin/cli.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"test": "echo \"No tests yet\""
|
|
@@ -25,16 +25,22 @@
|
|
|
25
25
|
],
|
|
26
26
|
"author": "",
|
|
27
27
|
"license": "MIT",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"chalk": "^4.1.2",
|
|
30
|
+
"commander": "^12.0.0",
|
|
31
|
+
"execa": "^5.1.1",
|
|
32
|
+
"prompts": "^2.4.2"
|
|
33
|
+
},
|
|
28
34
|
"repository": {
|
|
29
35
|
"type": "git",
|
|
30
|
-
"url": "git+https://github.com/raybird/skill-
|
|
36
|
+
"url": "git+https://github.com/raybird/skill-linker.git"
|
|
31
37
|
},
|
|
32
38
|
"engines": {
|
|
33
39
|
"node": ">=14.0.0"
|
|
34
40
|
},
|
|
35
41
|
"files": [
|
|
36
42
|
"bin/",
|
|
37
|
-
"
|
|
43
|
+
"src/",
|
|
38
44
|
"README.md"
|
|
39
45
|
]
|
|
40
|
-
}
|
|
46
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { program } = require('commander');
|
|
4
|
+
const chalk = require('chalk');
|
|
5
|
+
const install = require('./commands/install');
|
|
6
|
+
const list = require('./commands/list');
|
|
7
|
+
|
|
8
|
+
// Package info
|
|
9
|
+
const packageJson = require('../package.json');
|
|
10
|
+
|
|
11
|
+
program
|
|
12
|
+
.name('skill-linker')
|
|
13
|
+
.description('Interactive CLI to link AI Agent Skills to various agents')
|
|
14
|
+
.version(packageJson.version);
|
|
15
|
+
|
|
16
|
+
// Default command (install)
|
|
17
|
+
program
|
|
18
|
+
.argument('[skill-path]', 'Path to skill directory')
|
|
19
|
+
.option('--from <github-url>', 'Clone skill from GitHub URL first, then link')
|
|
20
|
+
.option('-l, --list', 'List available skills in library')
|
|
21
|
+
.action(async (skillPath, options) => {
|
|
22
|
+
// Handle --list flag
|
|
23
|
+
if (options.list) {
|
|
24
|
+
await list();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Run install command
|
|
29
|
+
await install({
|
|
30
|
+
skill: skillPath,
|
|
31
|
+
from: options.from
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Standalone list command
|
|
36
|
+
program
|
|
37
|
+
.command('list')
|
|
38
|
+
.description('List all available skills in the library')
|
|
39
|
+
.action(async () => {
|
|
40
|
+
await list();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Parse command line arguments
|
|
44
|
+
program.parse(process.argv);
|
|
45
|
+
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
const prompts = require('prompts');
|
|
2
|
+
const chalk = require('chalk');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { findSkills, findRepos, dirExists, ensureDir, createSymlink, listDirectories } = require('../utils/file-system');
|
|
5
|
+
const { getAllAgents, detectInstalledAgents } = require('../utils/agents');
|
|
6
|
+
const { DEFAULT_LIB_PATH, cloneOrUpdateRepo, pullRepo } = require('../utils/git');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Main install command
|
|
10
|
+
* @param {Object} options - Command options
|
|
11
|
+
*/
|
|
12
|
+
async function install(options) {
|
|
13
|
+
let skillPaths = [];
|
|
14
|
+
|
|
15
|
+
// Handle --from flag: Clone from GitHub
|
|
16
|
+
if (options.from) {
|
|
17
|
+
console.log(chalk.blue('[INFO]'), `Cloning from ${options.from}...`);
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const { skillPath: clonedPath, targetPath, needsUpdate, hasSubpath } = await cloneOrUpdateRepo(options.from);
|
|
21
|
+
|
|
22
|
+
if (needsUpdate) {
|
|
23
|
+
const { shouldUpdate } = await prompts({
|
|
24
|
+
type: 'confirm',
|
|
25
|
+
name: 'shouldUpdate',
|
|
26
|
+
message: `Repository already exists. Update with git pull?`,
|
|
27
|
+
initial: false
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (shouldUpdate) {
|
|
31
|
+
await pullRepo(targetPath);
|
|
32
|
+
console.log(chalk.green('[SUCCESS]'), 'Repository updated!');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// If no subpath, check for skills/ subdirectory
|
|
37
|
+
if (!hasSubpath && dirExists(path.join(targetPath, 'skills'))) {
|
|
38
|
+
const subSkills = listDirectories(path.join(targetPath, 'skills'));
|
|
39
|
+
|
|
40
|
+
if (subSkills.length > 0) {
|
|
41
|
+
const { selectedSkills } = await prompts({
|
|
42
|
+
type: 'multiselect',
|
|
43
|
+
name: 'selectedSkills',
|
|
44
|
+
message: 'This repo contains multiple skills. Select skills to install:',
|
|
45
|
+
choices: [
|
|
46
|
+
...subSkills.map(s => ({ title: s, value: path.join(targetPath, 'skills', s) })),
|
|
47
|
+
{ title: 'Link entire repo', value: targetPath }
|
|
48
|
+
],
|
|
49
|
+
hint: '- Space to select. Return to submit'
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (selectedSkills && selectedSkills.length > 0) {
|
|
53
|
+
skillPaths = selectedSkills;
|
|
54
|
+
} else {
|
|
55
|
+
// If nothing selected, maybe they just hit enter without selection? Default to repo path?
|
|
56
|
+
// Or better, error out if multiselect returns empty.
|
|
57
|
+
// Let's assume empty selection means exit or user made mistake.
|
|
58
|
+
// But to be safe, if they chose "Link entire repo" in multiselect (which is weird), handle it.
|
|
59
|
+
// Actually multiselect is better for picking subsets.
|
|
60
|
+
// If empty, let's fall back to entire repo (or maybe error).
|
|
61
|
+
// Let's error to be consistent with agent selection.
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
skillPaths = [targetPath];
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
skillPaths = [clonedPath];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
console.log(chalk.green('[SUCCESS]'), 'Clone completed!');
|
|
71
|
+
} catch (error) {
|
|
72
|
+
console.error(chalk.red('[ERROR]'), error.message);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// If no skill path provided, show library selection
|
|
78
|
+
if (skillPaths.length === 0 && options.skill) {
|
|
79
|
+
skillPaths = [options.skill];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (skillPaths.length === 0) {
|
|
83
|
+
if (!dirExists(DEFAULT_LIB_PATH)) {
|
|
84
|
+
console.error(chalk.red('[ERROR]'), `Skill library not found: ${DEFAULT_LIB_PATH}`);
|
|
85
|
+
console.log(chalk.blue('[INFO]'), 'Use --from <github_url> to clone skills first.');
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const repos = findRepos(DEFAULT_LIB_PATH);
|
|
90
|
+
|
|
91
|
+
if (repos.length === 0) {
|
|
92
|
+
console.error(chalk.red('[ERROR]'), `No repos found in ${DEFAULT_LIB_PATH}`);
|
|
93
|
+
console.log(chalk.blue('[INFO]'), 'Use --from <github_url> to clone skills first.');
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
console.log('');
|
|
98
|
+
|
|
99
|
+
// 1. Select Repository
|
|
100
|
+
const { selectedRepo } = await prompts({
|
|
101
|
+
type: 'autocomplete',
|
|
102
|
+
name: 'selectedRepo',
|
|
103
|
+
message: 'Select a repository:',
|
|
104
|
+
choices: repos.map(repo => ({
|
|
105
|
+
title: `${repo.name}${repo.hasSkillsDir ? chalk.dim(' (has skills/)') : ''}`,
|
|
106
|
+
value: repo
|
|
107
|
+
})),
|
|
108
|
+
suggest: (input, choices) => {
|
|
109
|
+
const inputLower = input.toLowerCase();
|
|
110
|
+
return Promise.resolve(
|
|
111
|
+
choices.filter(choice => choice.title.toLowerCase().includes(inputLower))
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (!selectedRepo) {
|
|
117
|
+
console.log(chalk.yellow('[WARNING]'), 'No repository selected. Exiting.');
|
|
118
|
+
process.exit(0);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 2. Select Sub-skills (if applicable)
|
|
122
|
+
if (selectedRepo.hasSkillsDir) {
|
|
123
|
+
const skillsDir = path.join(selectedRepo.path, 'skills');
|
|
124
|
+
const subSkills = listDirectories(skillsDir);
|
|
125
|
+
|
|
126
|
+
if (subSkills.length > 0) {
|
|
127
|
+
const { selectedSubSkills } = await prompts({
|
|
128
|
+
type: 'multiselect',
|
|
129
|
+
name: 'selectedSubSkills',
|
|
130
|
+
message: `Select skills from ${chalk.cyan(selectedRepo.name)} (Space to select):`,
|
|
131
|
+
choices: [
|
|
132
|
+
...subSkills.map(s => ({ title: s, value: path.join(skillsDir, s) })),
|
|
133
|
+
{ title: 'Link entire repo', value: selectedRepo.path }
|
|
134
|
+
],
|
|
135
|
+
hint: '- Space to select. Return to submit'
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (!selectedSubSkills || selectedSubSkills.length === 0) {
|
|
139
|
+
console.log(chalk.yellow('[WARNING]'), 'No skills selected. Exiting.');
|
|
140
|
+
process.exit(0);
|
|
141
|
+
}
|
|
142
|
+
skillPaths = selectedSubSkills;
|
|
143
|
+
} else {
|
|
144
|
+
skillPaths = [selectedRepo.path];
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
skillPaths = [selectedRepo.path];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Validate skill paths
|
|
152
|
+
for (const p of skillPaths) {
|
|
153
|
+
if (!dirExists(p)) {
|
|
154
|
+
console.error(chalk.red('[ERROR]'), `Skill directory not found: ${p}`);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (skillPaths.length > 1) {
|
|
160
|
+
console.log(chalk.blue('[INFO]'), `Selected ${skillPaths.length} skills`);
|
|
161
|
+
} else {
|
|
162
|
+
const skillName = path.basename(skillPaths[0]);
|
|
163
|
+
console.log(chalk.blue('[INFO]'), `Selected Skill: ${chalk.cyan(skillName)} (${skillPaths[0]})`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Agent selection
|
|
167
|
+
const agents = getAllAgents();
|
|
168
|
+
const installedIndices = detectInstalledAgents();
|
|
169
|
+
|
|
170
|
+
const { selectedAgents } = await prompts({
|
|
171
|
+
type: 'multiselect',
|
|
172
|
+
name: 'selectedAgents',
|
|
173
|
+
message: 'Select agents to install to (Space to select, Enter to confirm):',
|
|
174
|
+
choices: agents.map((agent, index) => ({
|
|
175
|
+
title: agent.name + (installedIndices.includes(index) ? chalk.green(' (Installed)') : ''),
|
|
176
|
+
value: index,
|
|
177
|
+
selected: installedIndices.includes(index)
|
|
178
|
+
})),
|
|
179
|
+
hint: '- Space to select. Return to submit'
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
if (!selectedAgents || selectedAgents.length === 0) {
|
|
183
|
+
console.log(chalk.yellow('[WARNING]'), 'No agents selected. Exiting.');
|
|
184
|
+
process.exit(0);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Process each selected agent
|
|
188
|
+
for (const agentIndex of selectedAgents) {
|
|
189
|
+
const agent = agents[agentIndex];
|
|
190
|
+
|
|
191
|
+
console.log('');
|
|
192
|
+
console.log(chalk.blue('[INFO]'), `Configuring for ${chalk.cyan(agent.name)}...`);
|
|
193
|
+
|
|
194
|
+
const { scope } = await prompts({
|
|
195
|
+
type: 'select',
|
|
196
|
+
name: 'scope',
|
|
197
|
+
message: 'Select scope:',
|
|
198
|
+
choices: [
|
|
199
|
+
{ title: `Project (${agent.projectDir})`, value: 'project' },
|
|
200
|
+
{ title: `Global (${agent.globalDir})`, value: 'global' },
|
|
201
|
+
{ title: 'Both', value: 'both' },
|
|
202
|
+
{ title: 'Skip', value: 'skip' }
|
|
203
|
+
]
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
if (scope === 'skip') continue;
|
|
207
|
+
|
|
208
|
+
const targets = [];
|
|
209
|
+
if (scope === 'project' || scope === 'both') {
|
|
210
|
+
targets.push(path.join(process.cwd(), agent.projectDir));
|
|
211
|
+
}
|
|
212
|
+
if (scope === 'global' || scope === 'both') {
|
|
213
|
+
targets.push(agent.globalDir);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const targetBase of targets) {
|
|
217
|
+
ensureDir(targetBase);
|
|
218
|
+
|
|
219
|
+
// Loop through all selected skills and link them
|
|
220
|
+
for (const sPath of skillPaths) {
|
|
221
|
+
const sName = path.basename(sPath);
|
|
222
|
+
const targetLink = path.join(targetBase, sName);
|
|
223
|
+
|
|
224
|
+
if (dirExists(targetLink)) {
|
|
225
|
+
// Check if already correct link to avoid prompt
|
|
226
|
+
// But for simplicity, we prompt or skip.
|
|
227
|
+
// To avoid spamming prompts for multiple skills, maybe auto-overwrite or ask once?
|
|
228
|
+
// Let's ask individually for safety for now, or maybe just log and skip if overwrite not confirmed.
|
|
229
|
+
// Actually, prompting for every file in a loop is annoying.
|
|
230
|
+
// Let's check overlap first? Or just try createSymlink which handles unlink.
|
|
231
|
+
|
|
232
|
+
// Let's prompt once per agent/target if any conflicts? Too complex.
|
|
233
|
+
// Simple approach: Prompt for each conflict.
|
|
234
|
+
const { overwrite } = await prompts({
|
|
235
|
+
type: 'confirm',
|
|
236
|
+
name: 'overwrite',
|
|
237
|
+
message: `${targetLink} already exists. Overwrite?`,
|
|
238
|
+
initial: false
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
if (!overwrite) {
|
|
242
|
+
console.log(chalk.blue('[INFO]'), `Skipping ${sName}...`);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (createSymlink(sPath, targetLink)) {
|
|
248
|
+
console.log(chalk.green('[SUCCESS]'), `Linked ${sName}`);
|
|
249
|
+
} else {
|
|
250
|
+
console.error(chalk.red('[ERROR]'), `Failed to link ${sName}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
console.log('');
|
|
257
|
+
console.log(chalk.green('[SUCCESS]'), 'All operations completed.');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
module.exports = install;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const prompts = require('prompts');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { findRepos, listDirectories, dirExists } = require('../utils/file-system');
|
|
5
|
+
const { DEFAULT_LIB_PATH } = require('../utils/git');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* List command - shows repos, then skills within selected repo
|
|
9
|
+
*/
|
|
10
|
+
async function list() {
|
|
11
|
+
if (!dirExists(DEFAULT_LIB_PATH)) {
|
|
12
|
+
console.error(chalk.red('[ERROR]'), `Skill library not found: ${DEFAULT_LIB_PATH}`);
|
|
13
|
+
console.log(chalk.blue('[INFO]'), 'Use --from <github_url> to clone skills first.');
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const repos = findRepos(DEFAULT_LIB_PATH);
|
|
18
|
+
|
|
19
|
+
if (repos.length === 0) {
|
|
20
|
+
console.log(chalk.yellow('[WARNING]'), `No repos found in ${DEFAULT_LIB_PATH}`);
|
|
21
|
+
console.log(chalk.blue('[INFO]'), 'Use --from <github_url> to clone skills first.');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
console.log('');
|
|
26
|
+
console.log(chalk.blue('[INFO]'), `Repositories in library (${DEFAULT_LIB_PATH}):`);
|
|
27
|
+
console.log('');
|
|
28
|
+
|
|
29
|
+
// Show repos with indication if they have skills/ directory
|
|
30
|
+
const { selectedRepo } = await prompts({
|
|
31
|
+
type: 'select',
|
|
32
|
+
name: 'selectedRepo',
|
|
33
|
+
message: 'Select a repository to view:',
|
|
34
|
+
choices: repos.map(repo => ({
|
|
35
|
+
title: `${repo.name}${repo.hasSkillsDir ? chalk.dim(' (has skills/)') : ''}`,
|
|
36
|
+
value: repo,
|
|
37
|
+
description: repo.path
|
|
38
|
+
}))
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (!selectedRepo) {
|
|
42
|
+
console.log(chalk.yellow('[INFO]'), 'No repository selected.');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
console.log('');
|
|
47
|
+
console.log(chalk.blue('[INFO]'), `Repository: ${chalk.cyan(selectedRepo.name)}`);
|
|
48
|
+
console.log(chalk.dim(`Path: ${selectedRepo.path}`));
|
|
49
|
+
console.log('');
|
|
50
|
+
|
|
51
|
+
// If repo has skills/ subdirectory, list the skills
|
|
52
|
+
if (selectedRepo.hasSkillsDir) {
|
|
53
|
+
const skillsDir = path.join(selectedRepo.path, 'skills');
|
|
54
|
+
const skills = listDirectories(skillsDir);
|
|
55
|
+
|
|
56
|
+
if (skills.length === 0) {
|
|
57
|
+
console.log(chalk.yellow('[WARNING]'), 'No skills found in skills/ directory');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log(chalk.blue('[INFO]'), 'Skills in this repository:');
|
|
62
|
+
console.log('');
|
|
63
|
+
|
|
64
|
+
skills.forEach((skill, index) => {
|
|
65
|
+
console.log(` ${index + 1}. ${chalk.cyan(skill)}`);
|
|
66
|
+
console.log(` ${chalk.dim(path.join(skillsDir, skill))}`);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
console.log('');
|
|
70
|
+
} else {
|
|
71
|
+
console.log(chalk.blue('[INFO]'), 'This is a single-skill repository (no skills/ subdirectory)');
|
|
72
|
+
console.log(chalk.dim('The entire repository acts as one skill'));
|
|
73
|
+
console.log('');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = list;
|
|
78
|
+
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Supported AI Agent configurations
|
|
7
|
+
* Format: { name, projectDir, globalDir }
|
|
8
|
+
*/
|
|
9
|
+
const AGENTS = [
|
|
10
|
+
{
|
|
11
|
+
name: 'Claude Code',
|
|
12
|
+
projectDir: '.claude/skills',
|
|
13
|
+
globalDir: path.join(os.homedir(), '.claude/skills')
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: 'GitHub Copilot',
|
|
17
|
+
projectDir: '.github/skills',
|
|
18
|
+
globalDir: path.join(os.homedir(), '.copilot/skills')
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: 'Google Antigravity',
|
|
22
|
+
projectDir: '.agent/skills',
|
|
23
|
+
globalDir: path.join(os.homedir(), '.gemini/antigravity/skills')
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: 'Cursor',
|
|
27
|
+
projectDir: '.cursor/skills',
|
|
28
|
+
globalDir: path.join(os.homedir(), '.cursor/skills')
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'OpenCode',
|
|
32
|
+
projectDir: '.opencode/skill',
|
|
33
|
+
globalDir: path.join(os.homedir(), '.config/opencode/skill')
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'OpenAI Codex',
|
|
37
|
+
projectDir: '.codex/skills',
|
|
38
|
+
globalDir: path.join(os.homedir(), '.codex/skills')
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'Gemini CLI',
|
|
42
|
+
projectDir: '.gemini/skills',
|
|
43
|
+
globalDir: path.join(os.homedir(), '.gemini/skills')
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'Windsurf',
|
|
47
|
+
projectDir: '.windsurf/skills',
|
|
48
|
+
globalDir: path.join(os.homedir(), '.codeium/windsurf/skills')
|
|
49
|
+
}
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Detect which agents are installed on the system
|
|
54
|
+
* @returns {Array} List of detected agent indices
|
|
55
|
+
*/
|
|
56
|
+
function detectInstalledAgents() {
|
|
57
|
+
const installed = [];
|
|
58
|
+
|
|
59
|
+
AGENTS.forEach((agent, index) => {
|
|
60
|
+
// Check if global directory exists
|
|
61
|
+
if (fs.existsSync(agent.globalDir)) {
|
|
62
|
+
installed.push(index);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return installed;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get agent configuration by index
|
|
71
|
+
* @param {number} index
|
|
72
|
+
* @returns {Object} Agent configuration
|
|
73
|
+
*/
|
|
74
|
+
function getAgent(index) {
|
|
75
|
+
return AGENTS[index];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get all agents
|
|
80
|
+
* @returns {Array} All agent configurations
|
|
81
|
+
*/
|
|
82
|
+
function getAllAgents() {
|
|
83
|
+
return AGENTS;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = {
|
|
87
|
+
AGENTS,
|
|
88
|
+
detectInstalledAgents,
|
|
89
|
+
getAgent,
|
|
90
|
+
getAllAgents
|
|
91
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Check if a directory exists
|
|
6
|
+
* @param {string} dirPath
|
|
7
|
+
* @returns {boolean}
|
|
8
|
+
*/
|
|
9
|
+
function dirExists(dirPath) {
|
|
10
|
+
try {
|
|
11
|
+
return fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory();
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Ensure directory exists (create if not)
|
|
19
|
+
* @param {string} dirPath
|
|
20
|
+
*/
|
|
21
|
+
function ensureDir(dirPath) {
|
|
22
|
+
if (!dirExists(dirPath)) {
|
|
23
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create a symbolic link
|
|
29
|
+
* @param {string} source - Source path
|
|
30
|
+
* @param {string} target - Target symlink path
|
|
31
|
+
* @returns {boolean} Success status
|
|
32
|
+
*/
|
|
33
|
+
function createSymlink(source, target) {
|
|
34
|
+
try {
|
|
35
|
+
// Remove existing link/file if present
|
|
36
|
+
if (fs.existsSync(target) || fs.lstatSync(target).isSymbolicLink()) {
|
|
37
|
+
fs.unlinkSync(target);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
fs.symlinkSync(source, target, 'dir');
|
|
41
|
+
return true;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error(`Failed to create symlink: ${error.message}`);
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* List directories in a path
|
|
50
|
+
* @param {string} dirPath
|
|
51
|
+
* @returns {Array<string>} List of directory names
|
|
52
|
+
*/
|
|
53
|
+
function listDirectories(dirPath) {
|
|
54
|
+
if (!dirExists(dirPath)) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
return fs.readdirSync(dirPath)
|
|
60
|
+
.filter(item => {
|
|
61
|
+
const fullPath = path.join(dirPath, item);
|
|
62
|
+
return fs.statSync(fullPath).isDirectory();
|
|
63
|
+
});
|
|
64
|
+
} catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Find all repositories in library
|
|
72
|
+
* @param {string} libPath - Library root path
|
|
73
|
+
* @returns {Array<{name: string, path: string, owner: string, repo: string, hasSkillsDir: boolean}>}
|
|
74
|
+
*/
|
|
75
|
+
function findRepos(libPath) {
|
|
76
|
+
if (!dirExists(libPath)) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const repos = [];
|
|
81
|
+
|
|
82
|
+
// Scan owner directories
|
|
83
|
+
const owners = listDirectories(libPath);
|
|
84
|
+
|
|
85
|
+
for (const owner of owners) {
|
|
86
|
+
const ownerPath = path.join(libPath, owner);
|
|
87
|
+
const repoList = listDirectories(ownerPath);
|
|
88
|
+
|
|
89
|
+
for (const repo of repoList) {
|
|
90
|
+
const repoPath = path.join(ownerPath, repo);
|
|
91
|
+
const skillsDir = path.join(repoPath, 'skills');
|
|
92
|
+
|
|
93
|
+
repos.push({
|
|
94
|
+
name: `${owner}/${repo}`,
|
|
95
|
+
path: repoPath,
|
|
96
|
+
owner,
|
|
97
|
+
repo,
|
|
98
|
+
hasSkillsDir: dirExists(skillsDir)
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return repos;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Find all skill directories in library
|
|
108
|
+
* @param {string} libPath - Library root path
|
|
109
|
+
* @returns {Array<{name: string, path: string, owner: string, repo: string}>}
|
|
110
|
+
*/
|
|
111
|
+
function findSkills(libPath) {
|
|
112
|
+
if (!dirExists(libPath)) {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const skills = [];
|
|
117
|
+
|
|
118
|
+
// Scan owner directories
|
|
119
|
+
const owners = listDirectories(libPath);
|
|
120
|
+
|
|
121
|
+
for (const owner of owners) {
|
|
122
|
+
const ownerPath = path.join(libPath, owner);
|
|
123
|
+
const repos = listDirectories(ownerPath);
|
|
124
|
+
|
|
125
|
+
for (const repo of repos) {
|
|
126
|
+
const repoPath = path.join(ownerPath, repo);
|
|
127
|
+
|
|
128
|
+
// Check if this repo has a skills/ subdirectory
|
|
129
|
+
const skillsDir = path.join(repoPath, 'skills');
|
|
130
|
+
|
|
131
|
+
if (dirExists(skillsDir)) {
|
|
132
|
+
// Multi-skill repo
|
|
133
|
+
const subSkills = listDirectories(skillsDir);
|
|
134
|
+
|
|
135
|
+
for (const skill of subSkills) {
|
|
136
|
+
skills.push({
|
|
137
|
+
name: `${owner}/${repo}/${skill}`,
|
|
138
|
+
path: path.join(skillsDir, skill),
|
|
139
|
+
owner,
|
|
140
|
+
repo,
|
|
141
|
+
skill
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
// Single skill repo
|
|
146
|
+
skills.push({
|
|
147
|
+
name: `${owner}/${repo}`,
|
|
148
|
+
path: repoPath,
|
|
149
|
+
owner,
|
|
150
|
+
repo
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return skills;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = {
|
|
160
|
+
dirExists,
|
|
161
|
+
ensureDir,
|
|
162
|
+
createSymlink,
|
|
163
|
+
listDirectories,
|
|
164
|
+
findRepos,
|
|
165
|
+
findSkills
|
|
166
|
+
};
|
package/src/utils/git.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
const execa = require('execa');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const { dirExists } = require('./file-system');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_LIB_PATH = path.join(os.homedir(), 'Documents/AgentSkills');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse GitHub URL to extract owner, repo, branch, and subpath
|
|
10
|
+
* @param {string} url - GitHub URL
|
|
11
|
+
* @returns {Object} Parsed components
|
|
12
|
+
*/
|
|
13
|
+
function parseGitHubUrl(url) {
|
|
14
|
+
let cleanUrl = url;
|
|
15
|
+
let subpath = '';
|
|
16
|
+
let branch = 'main';
|
|
17
|
+
|
|
18
|
+
// Check for /tree/branch/path format
|
|
19
|
+
const treeMatch = url.match(/(.+)\/tree\/([^/]+)\/(.+)$/);
|
|
20
|
+
if (treeMatch) {
|
|
21
|
+
cleanUrl = treeMatch[1];
|
|
22
|
+
branch = treeMatch[2];
|
|
23
|
+
subpath = treeMatch[3];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Extract owner/repo
|
|
27
|
+
const repoMatch = cleanUrl.match(/github\.com[/:]([^/]+)\/([^/]+?)(\.git)?$/);
|
|
28
|
+
|
|
29
|
+
if (!repoMatch) {
|
|
30
|
+
throw new Error('Invalid GitHub URL format');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
owner: repoMatch[1],
|
|
35
|
+
repo: repoMatch[2].replace('.git', ''),
|
|
36
|
+
branch,
|
|
37
|
+
subpath,
|
|
38
|
+
cleanUrl
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Clone a GitHub repository
|
|
44
|
+
* @param {string} url - GitHub URL
|
|
45
|
+
* @param {string} targetPath - Target directory
|
|
46
|
+
* @returns {Promise<void>}
|
|
47
|
+
*/
|
|
48
|
+
async function cloneRepo(url, targetPath) {
|
|
49
|
+
try {
|
|
50
|
+
await execa('git', ['clone', url, targetPath]);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw new Error(`Failed to clone repository: ${error.message}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Pull latest changes in a repository
|
|
58
|
+
* @param {string} repoPath - Path to repository
|
|
59
|
+
* @returns {Promise<void>}
|
|
60
|
+
*/
|
|
61
|
+
async function pullRepo(repoPath) {
|
|
62
|
+
try {
|
|
63
|
+
await execa('git', ['-C', repoPath, 'pull']);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw new Error(`Failed to pull repository: ${error.message}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Clone or update a GitHub repository
|
|
71
|
+
* @param {string} url - GitHub URL
|
|
72
|
+
* @returns {Promise<{skillPath: string, needsUpdate: boolean}>}
|
|
73
|
+
*/
|
|
74
|
+
async function cloneOrUpdateRepo(url) {
|
|
75
|
+
const parsed = parseGitHubUrl(url);
|
|
76
|
+
const targetPath = path.join(DEFAULT_LIB_PATH, parsed.owner, parsed.repo);
|
|
77
|
+
|
|
78
|
+
let needsUpdate = false;
|
|
79
|
+
|
|
80
|
+
if (dirExists(targetPath)) {
|
|
81
|
+
// Repo exists, ask if user wants to update
|
|
82
|
+
needsUpdate = true;
|
|
83
|
+
} else {
|
|
84
|
+
// Clone new repo
|
|
85
|
+
await cloneRepo(parsed.cleanUrl, targetPath);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Determine final skill path
|
|
89
|
+
let skillPath = targetPath;
|
|
90
|
+
if (parsed.subpath) {
|
|
91
|
+
skillPath = path.join(targetPath, parsed.subpath);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
skillPath,
|
|
96
|
+
targetPath,
|
|
97
|
+
needsUpdate,
|
|
98
|
+
hasSubpath: !!parsed.subpath
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
DEFAULT_LIB_PATH,
|
|
104
|
+
parseGitHubUrl,
|
|
105
|
+
cloneRepo,
|
|
106
|
+
pullRepo,
|
|
107
|
+
cloneOrUpdateRepo
|
|
108
|
+
};
|
package/link-skill.sh
DELETED
|
@@ -1,316 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
|
|
3
|
-
# link-skill.sh
|
|
4
|
-
# Interactive script to link AI Agent Skills to various AI agents
|
|
5
|
-
DEFAULT_LIB_PATH="$HOME/Documents/AgentSkills"
|
|
6
|
-
SKILL_PATH=""
|
|
7
|
-
FROM_URL=""
|
|
8
|
-
|
|
9
|
-
# Helper function for colored output
|
|
10
|
-
print_info() { echo -e "\033[1;34m[INFO]\033[0m $1"; }
|
|
11
|
-
print_success() { echo -e "\033[1;32m[SUCCESS]\033[0m $1"; }
|
|
12
|
-
print_warning() { echo -e "\033[1;33m[WARNING]\033[0m $1"; }
|
|
13
|
-
print_error() { echo -e "\033[1;31m[ERROR]\033[0m $1"; }
|
|
14
|
-
|
|
15
|
-
# Show help
|
|
16
|
-
show_help() {
|
|
17
|
-
echo "Usage: link-skill.sh [OPTIONS] [SKILL_PATH]"
|
|
18
|
-
echo ""
|
|
19
|
-
echo "Options:"
|
|
20
|
-
echo " --from <github_url> Clone skill from GitHub URL first, then link"
|
|
21
|
-
echo " --help Show this help message"
|
|
22
|
-
echo ""
|
|
23
|
-
echo "Examples:"
|
|
24
|
-
echo " ./link-skill.sh # Interactive selection from library"
|
|
25
|
-
echo " ./link-skill.sh /path/to/skill # Link specific local skill"
|
|
26
|
-
echo " ./link-skill.sh --from https://github.com/user/my-skill"
|
|
27
|
-
echo " ./link-skill.sh --from https://github.com/anthropics/skills/tree/main/skills/pdf"
|
|
28
|
-
echo ""
|
|
29
|
-
echo "Notes:"
|
|
30
|
-
echo " - If the cloned repo has a 'skills/' subdirectory, you can pick a specific skill"
|
|
31
|
-
echo " - GitHub URLs with /tree/branch/path are supported for direct subpath access"
|
|
32
|
-
echo ""
|
|
33
|
-
exit 0
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
# Parse arguments
|
|
37
|
-
while [[ $# -gt 0 ]]; do
|
|
38
|
-
case $1 in
|
|
39
|
-
--from)
|
|
40
|
-
FROM_URL="$2"
|
|
41
|
-
shift 2
|
|
42
|
-
;;
|
|
43
|
-
--help|-h)
|
|
44
|
-
show_help
|
|
45
|
-
;;
|
|
46
|
-
*)
|
|
47
|
-
SKILL_PATH="$1"
|
|
48
|
-
shift
|
|
49
|
-
;;
|
|
50
|
-
esac
|
|
51
|
-
done
|
|
52
|
-
|
|
53
|
-
# 1. Handle --from flag: Clone from GitHub
|
|
54
|
-
if [ -n "$FROM_URL" ]; then
|
|
55
|
-
# Parse GitHub URL - check for /tree/branch/path format
|
|
56
|
-
SUBPATH=""
|
|
57
|
-
CLEAN_URL="$FROM_URL"
|
|
58
|
-
|
|
59
|
-
if [[ "$FROM_URL" =~ (.+)/tree/([^/]+)/(.+)$ ]]; then
|
|
60
|
-
# URL contains /tree/branch/path - extract subpath
|
|
61
|
-
BASE_REPO="${BASH_REMATCH[1]}"
|
|
62
|
-
BRANCH="${BASH_REMATCH[2]}"
|
|
63
|
-
SUBPATH="${BASH_REMATCH[3]}"
|
|
64
|
-
CLEAN_URL="$BASE_REPO"
|
|
65
|
-
print_info "Detected subpath: $SUBPATH (branch: $BRANCH)"
|
|
66
|
-
fi
|
|
67
|
-
|
|
68
|
-
# Extract repo name from URL
|
|
69
|
-
REPO_NAME=$(basename "$CLEAN_URL" .git)
|
|
70
|
-
TARGET_CLONE_PATH="$DEFAULT_LIB_PATH/$REPO_NAME"
|
|
71
|
-
|
|
72
|
-
# Ensure library directory exists
|
|
73
|
-
mkdir -p "$DEFAULT_LIB_PATH"
|
|
74
|
-
|
|
75
|
-
if [ -d "$TARGET_CLONE_PATH" ]; then
|
|
76
|
-
print_warning "Directory already exists: $TARGET_CLONE_PATH"
|
|
77
|
-
read -p "Update with git pull? (y/N): " do_pull
|
|
78
|
-
if [[ "$do_pull" =~ ^[yY]$ ]]; then
|
|
79
|
-
print_info "Pulling latest changes..."
|
|
80
|
-
git -C "$TARGET_CLONE_PATH" pull
|
|
81
|
-
fi
|
|
82
|
-
else
|
|
83
|
-
print_info "Cloning $CLEAN_URL to $TARGET_CLONE_PATH..."
|
|
84
|
-
git clone "$CLEAN_URL" "$TARGET_CLONE_PATH"
|
|
85
|
-
if [ $? -ne 0 ]; then
|
|
86
|
-
print_error "Failed to clone repository"
|
|
87
|
-
exit 1
|
|
88
|
-
fi
|
|
89
|
-
print_success "Clone completed!"
|
|
90
|
-
fi
|
|
91
|
-
|
|
92
|
-
# If subpath was specified in URL, use it directly
|
|
93
|
-
if [ -n "$SUBPATH" ]; then
|
|
94
|
-
SKILL_PATH="$TARGET_CLONE_PATH/$SUBPATH"
|
|
95
|
-
if [ ! -d "$SKILL_PATH" ]; then
|
|
96
|
-
print_error "Subpath not found: $SKILL_PATH"
|
|
97
|
-
exit 1
|
|
98
|
-
fi
|
|
99
|
-
else
|
|
100
|
-
# Check if this is a multi-skill repo (has skills/ subdirectory)
|
|
101
|
-
SKILLS_DIR="$TARGET_CLONE_PATH/skills"
|
|
102
|
-
if [ -d "$SKILLS_DIR" ]; then
|
|
103
|
-
print_info "Detected multi-skill repository. Listing available skills..."
|
|
104
|
-
|
|
105
|
-
sub_skills=("$SKILLS_DIR"/*/)
|
|
106
|
-
if [ ${#sub_skills[@]} -eq 0 ]; then
|
|
107
|
-
print_warning "No skills found in $SKILLS_DIR, using repo root"
|
|
108
|
-
SKILL_PATH="$TARGET_CLONE_PATH"
|
|
109
|
-
else
|
|
110
|
-
# Extract skill names for display
|
|
111
|
-
sub_skill_names=()
|
|
112
|
-
for s in "${sub_skills[@]}"; do
|
|
113
|
-
sub_skill_names+=("$(basename "$s")")
|
|
114
|
-
done
|
|
115
|
-
|
|
116
|
-
echo ""
|
|
117
|
-
echo "Available Skills in this repo:"
|
|
118
|
-
select sub_skill_name in "${sub_skill_names[@]}" "Link entire repo"; do
|
|
119
|
-
if [ -n "$sub_skill_name" ]; then
|
|
120
|
-
if [ "$sub_skill_name" == "Link entire repo" ]; then
|
|
121
|
-
SKILL_PATH="$TARGET_CLONE_PATH"
|
|
122
|
-
else
|
|
123
|
-
SKILL_PATH="$SKILLS_DIR/$sub_skill_name"
|
|
124
|
-
fi
|
|
125
|
-
break
|
|
126
|
-
else
|
|
127
|
-
echo "Invalid selection. Please try again."
|
|
128
|
-
fi
|
|
129
|
-
done
|
|
130
|
-
fi
|
|
131
|
-
else
|
|
132
|
-
SKILL_PATH="$TARGET_CLONE_PATH"
|
|
133
|
-
fi
|
|
134
|
-
fi
|
|
135
|
-
fi
|
|
136
|
-
|
|
137
|
-
# 2. Determine Source Skill Path (if not already set by --from)
|
|
138
|
-
if [ -z "$SKILL_PATH" ]; then
|
|
139
|
-
# Check if default library exists
|
|
140
|
-
if [ -d "$DEFAULT_LIB_PATH" ]; then
|
|
141
|
-
print_info "No skill path provided. Checking default library: $DEFAULT_LIB_PATH"
|
|
142
|
-
skills=("$DEFAULT_LIB_PATH"/*/)
|
|
143
|
-
|
|
144
|
-
if [ ${#skills[@]} -eq 0 ]; then
|
|
145
|
-
print_error "No skills found in $DEFAULT_LIB_PATH"
|
|
146
|
-
print_info "Please provide a skill path: ./link-skill.sh <path_to_skill>"
|
|
147
|
-
exit 1
|
|
148
|
-
fi
|
|
149
|
-
|
|
150
|
-
# Extract skill names for display
|
|
151
|
-
skill_names=()
|
|
152
|
-
for s in "${skills[@]}"; do
|
|
153
|
-
skill_names+=("$(basename "$s")")
|
|
154
|
-
done
|
|
155
|
-
|
|
156
|
-
echo "Available Skills:"
|
|
157
|
-
select skill_name in "${skill_names[@]}"; do
|
|
158
|
-
if [ -n "$skill_name" ]; then
|
|
159
|
-
SKILL_PATH="${DEFAULT_LIB_PATH}/${skill_name}"
|
|
160
|
-
break
|
|
161
|
-
else
|
|
162
|
-
echo "Invalid selection. Please try again."
|
|
163
|
-
fi
|
|
164
|
-
done
|
|
165
|
-
else
|
|
166
|
-
# Fallback to current directory prompt
|
|
167
|
-
read -p "Enter path to skill directory (default: current dir): " input_path
|
|
168
|
-
input_path=${input_path:-.}
|
|
169
|
-
SKILL_PATH=$(realpath "$input_path")
|
|
170
|
-
fi
|
|
171
|
-
elif [ -n "$SKILL_PATH" ] && [ -z "$FROM_URL" ]; then
|
|
172
|
-
# Path provided directly as argument
|
|
173
|
-
SKILL_PATH=$(realpath "$SKILL_PATH")
|
|
174
|
-
fi
|
|
175
|
-
|
|
176
|
-
if [ ! -d "$SKILL_PATH" ]; then
|
|
177
|
-
print_error "Skill directory not found: $SKILL_PATH"
|
|
178
|
-
exit 1
|
|
179
|
-
fi
|
|
180
|
-
|
|
181
|
-
SKILL_NAME=$(basename "$SKILL_PATH")
|
|
182
|
-
print_info "Selected Skill: \033[1;36m$SKILL_NAME\033[0m ($SKILL_PATH)"
|
|
183
|
-
|
|
184
|
-
# 2. Define Supported Agents
|
|
185
|
-
# Format: "Name:ProjectDir:GlobalDir"
|
|
186
|
-
AGENTS=(
|
|
187
|
-
"Claude Code:.claude/skills:$HOME/.claude/skills"
|
|
188
|
-
"GitHub Copilot:.github/skills:$HOME/.copilot/skills"
|
|
189
|
-
"Google Antigravity:.agent/skills:$HOME/.gemini/antigravity/skills"
|
|
190
|
-
"Cursor:.cursor/skills:$HOME/.cursor/skills"
|
|
191
|
-
"OpenCode:.opencode/skill:$HOME/.config/opencode/skill"
|
|
192
|
-
"OpenAI Codex:.codex/skills:$HOME/.codex/skills"
|
|
193
|
-
"Gemini CLI:.gemini/skills:$HOME/.gemini/skills"
|
|
194
|
-
"Windsurf:.windsurf/skills:$HOME/.codeium/windsurf/skills"
|
|
195
|
-
)
|
|
196
|
-
|
|
197
|
-
# 3. Agent Selection
|
|
198
|
-
echo ""
|
|
199
|
-
echo "Select Agents to install to (Space to select, Enter to confirm):"
|
|
200
|
-
# Simple multi-select implementation using arrays
|
|
201
|
-
selected_indices=()
|
|
202
|
-
while true; do
|
|
203
|
-
for i in "${!AGENTS[@]}"; do
|
|
204
|
-
agent_info="${AGENTS[$i]}"
|
|
205
|
-
agent_name="${agent_info%%:*}"
|
|
206
|
-
|
|
207
|
-
# Check if selected
|
|
208
|
-
if [[ " ${selected_indices[*]} " =~ " $i " ]]; then
|
|
209
|
-
mark="[*]"
|
|
210
|
-
else
|
|
211
|
-
mark="[ ]"
|
|
212
|
-
fi
|
|
213
|
-
echo "$i) $mark $agent_name"
|
|
214
|
-
done
|
|
215
|
-
|
|
216
|
-
echo "a) Select All"
|
|
217
|
-
echo "d) Done"
|
|
218
|
-
read -p "Select option: " choice
|
|
219
|
-
|
|
220
|
-
if [[ "$choice" == "d" ]]; then
|
|
221
|
-
break
|
|
222
|
-
elif [[ "$choice" == "a" ]]; then
|
|
223
|
-
selected_indices=()
|
|
224
|
-
for i in "${!AGENTS[@]}"; do
|
|
225
|
-
selected_indices+=("$i")
|
|
226
|
-
done
|
|
227
|
-
elif [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 0 ] && [ "$choice" -lt "${#AGENTS[@]}" ]; then
|
|
228
|
-
if [[ " ${selected_indices[*]} " =~ " $choice " ]]; then
|
|
229
|
-
# Deselect
|
|
230
|
-
new_indices=()
|
|
231
|
-
for idx in "${selected_indices[@]}"; do
|
|
232
|
-
[[ "$idx" != "$choice" ]] && new_indices+=("$idx")
|
|
233
|
-
done
|
|
234
|
-
selected_indices=("${new_indices[@]}")
|
|
235
|
-
else
|
|
236
|
-
# Select
|
|
237
|
-
selected_indices+=("$choice")
|
|
238
|
-
fi
|
|
239
|
-
else
|
|
240
|
-
echo "Invalid choice."
|
|
241
|
-
fi
|
|
242
|
-
echo "------------------------"
|
|
243
|
-
done
|
|
244
|
-
|
|
245
|
-
if [ ${#selected_indices[@]} -eq 0 ]; then
|
|
246
|
-
print_warning "No agents selected. Exiting."
|
|
247
|
-
exit 0
|
|
248
|
-
fi
|
|
249
|
-
|
|
250
|
-
# 4. Process Each Selected Agent
|
|
251
|
-
for idx in "${selected_indices[@]}"; do
|
|
252
|
-
agent_raw="${AGENTS[$idx]}"
|
|
253
|
-
IFS=':' read -r agent_name project_dir global_dir <<< "$agent_raw"
|
|
254
|
-
|
|
255
|
-
echo ""
|
|
256
|
-
print_info "Configuring for \033[1;36m$agent_name\033[0m..."
|
|
257
|
-
|
|
258
|
-
# Scope Selection
|
|
259
|
-
echo "Select Scope:"
|
|
260
|
-
echo "1) Project ($project_dir)"
|
|
261
|
-
echo "2) Global ($global_dir)"
|
|
262
|
-
echo "3) Both"
|
|
263
|
-
echo "s) Skip"
|
|
264
|
-
read -p "Choice [1-3]: " scope_choice
|
|
265
|
-
|
|
266
|
-
targets=()
|
|
267
|
-
case $scope_choice in
|
|
268
|
-
1) targets+=("$project_dir") ;;
|
|
269
|
-
2) targets+=("$global_dir") ;;
|
|
270
|
-
3) targets+=("$project_dir" "$global_dir") ;;
|
|
271
|
-
s|S) continue ;;
|
|
272
|
-
*) print_warning "Invalid choice, skipping $agent_name"; continue ;;
|
|
273
|
-
esac
|
|
274
|
-
|
|
275
|
-
for target_base in "${targets[@]}"; do
|
|
276
|
-
# Resolve path expansion if needed (already expanded in definition for Global, Project is relative)
|
|
277
|
-
if [[ "$target_base" == /* ]]; then
|
|
278
|
-
# Absolute path (Global)
|
|
279
|
-
target_dir="$target_base"
|
|
280
|
-
else
|
|
281
|
-
# Relative path (Project) - assume current dir is project root
|
|
282
|
-
target_dir="$(pwd)/$target_base"
|
|
283
|
-
fi
|
|
284
|
-
|
|
285
|
-
# Ensure target parent directory exists
|
|
286
|
-
if [ ! -d "$target_dir" ]; then
|
|
287
|
-
print_info "Creating directory: $target_dir"
|
|
288
|
-
mkdir -p "$target_dir"
|
|
289
|
-
fi
|
|
290
|
-
|
|
291
|
-
target_link="$target_dir/$SKILL_NAME"
|
|
292
|
-
|
|
293
|
-
# Check for existing link or directory
|
|
294
|
-
if [ -e "$target_link" ] || [ -L "$target_link" ]; then
|
|
295
|
-
print_warning "$target_link already exists."
|
|
296
|
-
read -p "Overwrite? (y/N): " overwrite
|
|
297
|
-
if [[ "$overwrite" =~ ^[yY]$ ]]; then
|
|
298
|
-
rm -rf "$target_link"
|
|
299
|
-
else
|
|
300
|
-
print_info "Skipping..."
|
|
301
|
-
continue
|
|
302
|
-
fi
|
|
303
|
-
fi
|
|
304
|
-
|
|
305
|
-
# Create Symlink
|
|
306
|
-
ln -s "$SKILL_PATH" "$target_link"
|
|
307
|
-
if [ $? -eq 0 ]; then
|
|
308
|
-
print_success "Linked to $target_link"
|
|
309
|
-
else
|
|
310
|
-
print_error "Failed to link to $target_link"
|
|
311
|
-
fi
|
|
312
|
-
done
|
|
313
|
-
done
|
|
314
|
-
|
|
315
|
-
echo ""
|
|
316
|
-
print_success "All operations completed."
|