skill-base-cli 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 +86 -0
- package/bin/skb.js +53 -0
- package/lib/api.js +89 -0
- package/lib/auth.js +35 -0
- package/lib/commands/install.js +82 -0
- package/lib/commands/login.js +54 -0
- package/lib/commands/logout.js +7 -0
- package/lib/commands/publish.js +151 -0
- package/lib/commands/search.js +63 -0
- package/lib/commands/update.js +31 -0
- package/lib/config.js +17 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Skill Base CLI
|
|
2
|
+
|
|
3
|
+
命令行工具,用于搜索、安装、更新和发布 AI Agent Skills。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g skill-base-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
或使用 npx 直接运行:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx skill-base-cli <command>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## 环境配置
|
|
18
|
+
|
|
19
|
+
CLI 默认连接 `http://localhost:8000`,可通过环境变量修改:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
export SKB_BASE_URL=https://your-skill-base-server.com
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## 命令
|
|
26
|
+
|
|
27
|
+
### 认证
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# 登录
|
|
31
|
+
skb login
|
|
32
|
+
|
|
33
|
+
# 登出
|
|
34
|
+
skb logout
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Skill 管理
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# 搜索 Skill
|
|
41
|
+
skb search <keyword>
|
|
42
|
+
|
|
43
|
+
# 安装 Skill
|
|
44
|
+
skb install <skill_id>
|
|
45
|
+
skb install <skill_id>@<version>
|
|
46
|
+
skb install <skill_id> -d ./target-dir
|
|
47
|
+
|
|
48
|
+
# 更新 Skill
|
|
49
|
+
skb update <skill_id>
|
|
50
|
+
skb update <skill_id> -d ./target-dir
|
|
51
|
+
|
|
52
|
+
# 发布 Skill
|
|
53
|
+
skb publish <directory>
|
|
54
|
+
skb publish <directory> --name "Skill Name" --description "Description"
|
|
55
|
+
skb publish <directory> --changelog "版本说明"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## 快速开始
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# 1. 登录
|
|
62
|
+
skb login
|
|
63
|
+
|
|
64
|
+
# 2. 搜索
|
|
65
|
+
skb search vue
|
|
66
|
+
|
|
67
|
+
# 3. 安装
|
|
68
|
+
skb install vue-best-practices
|
|
69
|
+
|
|
70
|
+
# 4. 发布自己的 Skill
|
|
71
|
+
skb publish ./my-skill --changelog "初始版本"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## 发布要求
|
|
75
|
+
|
|
76
|
+
- 目录必须包含 `SKILL.md` 文件
|
|
77
|
+
- 文件夹名称将作为 `skill_id`
|
|
78
|
+
- `name` 和 `description` 可从 SKILL.md 自动提取
|
|
79
|
+
|
|
80
|
+
## 系统要求
|
|
81
|
+
|
|
82
|
+
- Node.js >= 18.0.0
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
package/bin/skb.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import login from '../lib/commands/login.js';
|
|
5
|
+
import logout from '../lib/commands/logout.js';
|
|
6
|
+
import search from '../lib/commands/search.js';
|
|
7
|
+
import install from '../lib/commands/install.js';
|
|
8
|
+
import update from '../lib/commands/update.js';
|
|
9
|
+
import publish from '../lib/commands/publish.js';
|
|
10
|
+
|
|
11
|
+
const program = new Command();
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.name('skb')
|
|
15
|
+
.description('Skill Base CLI - 命令行管理工具')
|
|
16
|
+
.version('1.0.0');
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.command('login')
|
|
20
|
+
.description('登录获取访问令牌')
|
|
21
|
+
.action(login);
|
|
22
|
+
|
|
23
|
+
program
|
|
24
|
+
.command('logout')
|
|
25
|
+
.description('登出并清除本地凭证')
|
|
26
|
+
.action(logout);
|
|
27
|
+
|
|
28
|
+
program
|
|
29
|
+
.command('search <keyword>')
|
|
30
|
+
.description('搜索 Skill')
|
|
31
|
+
.action(search);
|
|
32
|
+
|
|
33
|
+
program
|
|
34
|
+
.command('install <target>')
|
|
35
|
+
.description('安装 Skill(支持 name@version 格式)')
|
|
36
|
+
.option('-d, --dir <directory>', '指定解压目标目录', process.cwd())
|
|
37
|
+
.action(install);
|
|
38
|
+
|
|
39
|
+
program
|
|
40
|
+
.command('update <skill_id>')
|
|
41
|
+
.description('更新 Skill 到最新版本')
|
|
42
|
+
.option('-d, --dir <directory>', '指定解压目标目录', process.cwd())
|
|
43
|
+
.action(update);
|
|
44
|
+
|
|
45
|
+
program
|
|
46
|
+
.command('publish <directory>')
|
|
47
|
+
.description('发布新版本(指定 Skill 文件夹)')
|
|
48
|
+
.option('--name <name>', 'Skill 名称(默认从 SKILL.md 提取)')
|
|
49
|
+
.option('--description <desc>', 'Skill 描述(默认从 SKILL.md 提取)')
|
|
50
|
+
.option('--changelog <log>', '版本变更日志', '更新版本')
|
|
51
|
+
.action(publish);
|
|
52
|
+
|
|
53
|
+
program.parse();
|
package/lib/api.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { getConfig } from './config.js';
|
|
2
|
+
import { loadCredentials } from './auth.js';
|
|
3
|
+
|
|
4
|
+
async function handleResponse(response) {
|
|
5
|
+
if (!response.ok) {
|
|
6
|
+
let message = `HTTP ${response.status}`;
|
|
7
|
+
try {
|
|
8
|
+
const data = await response.json();
|
|
9
|
+
if (data.detail) {
|
|
10
|
+
message = data.detail;
|
|
11
|
+
}
|
|
12
|
+
} catch (e) {
|
|
13
|
+
// 无法解析 JSON,使用默认消息
|
|
14
|
+
}
|
|
15
|
+
throw new Error(message);
|
|
16
|
+
}
|
|
17
|
+
return response.json();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getAuthHeaders() {
|
|
21
|
+
const credentials = loadCredentials();
|
|
22
|
+
const headers = {};
|
|
23
|
+
if (credentials?.token) {
|
|
24
|
+
headers['Authorization'] = `Bearer ${credentials.token}`;
|
|
25
|
+
}
|
|
26
|
+
return headers;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createClient() {
|
|
30
|
+
const { apiUrl } = getConfig();
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
async get(path) {
|
|
34
|
+
const response = await fetch(`${apiUrl}${path}`, {
|
|
35
|
+
method: 'GET',
|
|
36
|
+
headers: {
|
|
37
|
+
...getAuthHeaders()
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
return handleResponse(response);
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
async post(path, body) {
|
|
44
|
+
const response = await fetch(`${apiUrl}${path}`, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: {
|
|
47
|
+
'Content-Type': 'application/json',
|
|
48
|
+
...getAuthHeaders()
|
|
49
|
+
},
|
|
50
|
+
body: JSON.stringify(body)
|
|
51
|
+
});
|
|
52
|
+
return handleResponse(response);
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async postForm(path, formData) {
|
|
56
|
+
// 使用原生 FormData,让 fetch 自动设置 Content-Type(含 boundary)
|
|
57
|
+
const response = await fetch(`${apiUrl}${path}`, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: {
|
|
60
|
+
...getAuthHeaders()
|
|
61
|
+
},
|
|
62
|
+
body: formData
|
|
63
|
+
});
|
|
64
|
+
return handleResponse(response);
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
async download(path) {
|
|
68
|
+
const response = await fetch(`${apiUrl}${path}`, {
|
|
69
|
+
method: 'GET',
|
|
70
|
+
headers: {
|
|
71
|
+
...getAuthHeaders()
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
let message = `HTTP ${response.status}`;
|
|
76
|
+
try {
|
|
77
|
+
const data = await response.json();
|
|
78
|
+
if (data.detail) {
|
|
79
|
+
message = data.detail;
|
|
80
|
+
}
|
|
81
|
+
} catch (e) {
|
|
82
|
+
// 无法解析 JSON,使用默认消息
|
|
83
|
+
}
|
|
84
|
+
throw new Error(message);
|
|
85
|
+
}
|
|
86
|
+
return response;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
package/lib/auth.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getConfig } from './config.js';
|
|
4
|
+
|
|
5
|
+
export function loadCredentials() {
|
|
6
|
+
const { credentialsPath } = getConfig();
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
const content = fs.readFileSync(credentialsPath, 'utf-8');
|
|
10
|
+
return JSON.parse(content);
|
|
11
|
+
} catch (err) {
|
|
12
|
+
// 文件不存在时返回 null
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function saveCredentials({ token, username }) {
|
|
18
|
+
const { credentialsDir, credentialsPath } = getConfig();
|
|
19
|
+
|
|
20
|
+
// 创建目录(recursive)
|
|
21
|
+
fs.mkdirSync(credentialsDir, { recursive: true });
|
|
22
|
+
|
|
23
|
+
// 写入 JSON
|
|
24
|
+
fs.writeFileSync(credentialsPath, JSON.stringify({ token, username }, null, 2));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function removeCredentials() {
|
|
28
|
+
const { credentialsPath } = getConfig();
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
fs.unlinkSync(credentialsPath);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
// 不存在时静默忽略
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import ora from 'ora';
|
|
6
|
+
import extract from 'extract-zip';
|
|
7
|
+
import { createClient } from '../api.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 下载并解压 Skill 到指定目录
|
|
11
|
+
* @param {string} skillId - Skill ID
|
|
12
|
+
* @param {string} version - 版本号(或 'latest')
|
|
13
|
+
* @param {string} targetDir - 目标目录
|
|
14
|
+
* @returns {Promise<{skillId: string, version: string, targetDir: string}>}
|
|
15
|
+
*/
|
|
16
|
+
export async function downloadAndExtract(skillId, version, targetDir) {
|
|
17
|
+
const client = createClient();
|
|
18
|
+
|
|
19
|
+
// 先获取 Skill 信息确认存在
|
|
20
|
+
const skillInfo = await client.get(`/skills/${encodeURIComponent(skillId)}`);
|
|
21
|
+
|
|
22
|
+
// 如果是 latest,使用实际版本号
|
|
23
|
+
const actualVersion = version === 'latest' ? skillInfo.latest_version : version;
|
|
24
|
+
|
|
25
|
+
if (!actualVersion) {
|
|
26
|
+
throw new Error(`Skill ${skillId} 没有可用版本`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 下载 zip
|
|
30
|
+
const response = await client.download(
|
|
31
|
+
`/skills/${encodeURIComponent(skillId)}/versions/${encodeURIComponent(actualVersion)}/download`
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// 获取 ArrayBuffer 并写入临时文件
|
|
35
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
36
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
37
|
+
|
|
38
|
+
const tmpZip = path.join(os.tmpdir(), `skb-${skillId}-${actualVersion}-${Date.now()}.zip`);
|
|
39
|
+
fs.writeFileSync(tmpZip, buffer);
|
|
40
|
+
|
|
41
|
+
// 确保目标目录存在
|
|
42
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
43
|
+
|
|
44
|
+
// 解压
|
|
45
|
+
await extract(tmpZip, { dir: path.resolve(targetDir) });
|
|
46
|
+
|
|
47
|
+
// 清理临时文件
|
|
48
|
+
try {
|
|
49
|
+
fs.unlinkSync(tmpZip);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
// 忽略清理失败
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { skillId, version: actualVersion, targetDir };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default async function install(target, options) {
|
|
58
|
+
// 解析 target: skillId@version 或 skillId
|
|
59
|
+
let skillId, version;
|
|
60
|
+
if (target.includes('@')) {
|
|
61
|
+
const parts = target.split('@');
|
|
62
|
+
skillId = parts[0];
|
|
63
|
+
version = parts[1];
|
|
64
|
+
} else {
|
|
65
|
+
skillId = target;
|
|
66
|
+
version = 'latest';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 目标目录:选项指定或当前目录
|
|
70
|
+
// 由于 zip 包内已包含 skillId 文件夹,我们解压到当前目录即可
|
|
71
|
+
const targetDir = options?.dir || process.cwd();
|
|
72
|
+
|
|
73
|
+
const spinner = ora(`正在下载 ${skillId}${version !== 'latest' ? '@' + version : ''}...`).start();
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const result = await downloadAndExtract(skillId, version, targetDir);
|
|
77
|
+
spinner.succeed(chalk.green(`已安装 ${result.skillId} ${result.version} 到 ${path.join(result.targetDir, skillId)}`));
|
|
78
|
+
} catch (err) {
|
|
79
|
+
spinner.fail(chalk.red(`安装失败:${err.message}`));
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import prompts from 'prompts';
|
|
4
|
+
import { getConfig } from '../config.js';
|
|
5
|
+
import { saveCredentials } from '../auth.js';
|
|
6
|
+
import { createClient } from '../api.js';
|
|
7
|
+
|
|
8
|
+
export default async function login() {
|
|
9
|
+
const { baseUrl } = getConfig();
|
|
10
|
+
const client = createClient();
|
|
11
|
+
|
|
12
|
+
console.log(chalk.cyan('\n📋 登录指引:'));
|
|
13
|
+
console.log(` 1. 在浏览器中打开 ${chalk.underline(baseUrl + '/login?from=cli')}`);
|
|
14
|
+
console.log(' 2. 登录后获取 CLI 验证码');
|
|
15
|
+
console.log(' 3. 在下方输入验证码\n');
|
|
16
|
+
|
|
17
|
+
const response = await prompts({
|
|
18
|
+
type: 'text',
|
|
19
|
+
name: 'code',
|
|
20
|
+
message: '请输入 8 位验证码(如 8A2B-9C4F):',
|
|
21
|
+
validate: value => {
|
|
22
|
+
const pattern = /^[A-Z0-9]{4}-[A-Z0-9]{4}$/i;
|
|
23
|
+
return pattern.test(value) ? true : '验证码格式错误,请输入 8 位验证码(如 8A2B-9C4F)';
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// 用户取消输入
|
|
28
|
+
if (!response.code) {
|
|
29
|
+
console.log(chalk.yellow('\n已取消登录'));
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const spinner = ora('正在验证...').start();
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const result = await client.post('/auth/cli-code/verify', {
|
|
37
|
+
code: response.code.toUpperCase()
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (result.ok && result.token && result.user) {
|
|
41
|
+
saveCredentials({
|
|
42
|
+
token: result.token,
|
|
43
|
+
username: result.user.username
|
|
44
|
+
});
|
|
45
|
+
spinner.succeed(chalk.green(`登录成功,当前账号:${result.user.username}`));
|
|
46
|
+
} else {
|
|
47
|
+
spinner.fail(chalk.red('验证失败,请检查验证码是否正确'));
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
spinner.fail(chalk.red(`登录失败:${err.message}`));
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import ora from 'ora';
|
|
7
|
+
import archiver from 'archiver';
|
|
8
|
+
import { loadCredentials } from '../auth.js';
|
|
9
|
+
import { createClient } from '../api.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 从 SKILL.md 提取 name 和 description
|
|
13
|
+
* - name: 取第一个 # 标题
|
|
14
|
+
* - description: 取标题后的第一段非空文本(前 200 字符)
|
|
15
|
+
*/
|
|
16
|
+
function parseSkillMd(content) {
|
|
17
|
+
const lines = content.split('\n');
|
|
18
|
+
let name = null;
|
|
19
|
+
let description = null;
|
|
20
|
+
let foundTitle = false;
|
|
21
|
+
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
const trimmed = line.trim();
|
|
24
|
+
|
|
25
|
+
// 匹配 # 标题
|
|
26
|
+
if (!foundTitle && trimmed.startsWith('# ')) {
|
|
27
|
+
name = trimmed.slice(2).trim();
|
|
28
|
+
foundTitle = true;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 找标题后的第一段非空文本
|
|
33
|
+
if (foundTitle && trimmed && !trimmed.startsWith('#')) {
|
|
34
|
+
description = trimmed.slice(0, 200);
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { name, description };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 将文件夹打包为 zip
|
|
44
|
+
* @param {string} dirPath - 要打包的目录路径
|
|
45
|
+
* @param {string} outputPath - 输出的 zip 文件路径
|
|
46
|
+
* @param {string} dirName - zip 包内的目录名称(顶层文件夹)
|
|
47
|
+
*/
|
|
48
|
+
function zipDirectory(dirPath, outputPath, dirName) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const output = fs.createWriteStream(outputPath);
|
|
51
|
+
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
52
|
+
|
|
53
|
+
output.on('close', () => resolve(archive.pointer()));
|
|
54
|
+
archive.on('error', reject);
|
|
55
|
+
|
|
56
|
+
archive.pipe(output);
|
|
57
|
+
// 使用 dirName 作为 zip 包内的顶层文件夹名称
|
|
58
|
+
archive.directory(dirPath, dirName);
|
|
59
|
+
archive.finalize();
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default async function publish(directory, options) {
|
|
64
|
+
// 1. 验证凭证
|
|
65
|
+
const credentials = loadCredentials();
|
|
66
|
+
if (!credentials?.token) {
|
|
67
|
+
console.log(chalk.red('❌ 请先登录:skb login'));
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 2. 验证目录存在
|
|
72
|
+
const resolvedDir = path.resolve(directory);
|
|
73
|
+
if (!fs.existsSync(resolvedDir)) {
|
|
74
|
+
console.log(chalk.red(`❌ 目录不存在:${resolvedDir}`));
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const stat = fs.statSync(resolvedDir);
|
|
79
|
+
if (!stat.isDirectory()) {
|
|
80
|
+
console.log(chalk.red(`❌ 路径不是目录:${resolvedDir}`));
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 3. 验证 SKILL.md 存在
|
|
85
|
+
const skillMdPath = path.join(resolvedDir, 'SKILL.md');
|
|
86
|
+
if (!fs.existsSync(skillMdPath)) {
|
|
87
|
+
console.log(chalk.red(`❌ 目录内缺少 SKILL.md 文件:${skillMdPath}`));
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 4. 从文件夹名提取 skill_id
|
|
92
|
+
const skillId = path.basename(resolvedDir);
|
|
93
|
+
|
|
94
|
+
// 5. 从 SKILL.md 提取 name 和 description
|
|
95
|
+
const skillMdContent = fs.readFileSync(skillMdPath, 'utf-8');
|
|
96
|
+
const parsed = parseSkillMd(skillMdContent);
|
|
97
|
+
|
|
98
|
+
const name = options.name || parsed.name || skillId;
|
|
99
|
+
const description = options.description || parsed.description || '';
|
|
100
|
+
const changelog = options.changelog;
|
|
101
|
+
|
|
102
|
+
console.log(chalk.cyan(`📦 准备发布 Skill: ${skillId}`));
|
|
103
|
+
console.log(chalk.gray(` 名称: ${name}`));
|
|
104
|
+
console.log(chalk.gray(` 描述: ${description || '(无)'}`));
|
|
105
|
+
console.log(chalk.gray(` 更新说明: ${changelog}`));
|
|
106
|
+
|
|
107
|
+
// 6. 打包为 zip
|
|
108
|
+
const spinner = ora('正在打包...').start();
|
|
109
|
+
const tmpZipPath = path.join(os.tmpdir(), `skb-${randomBytes(8).toString('hex')}.zip`);
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const size = await zipDirectory(resolvedDir, tmpZipPath, skillId);
|
|
113
|
+
spinner.text = `打包完成 (${(size / 1024).toFixed(1)} KB),正在上传...`;
|
|
114
|
+
|
|
115
|
+
// 7. 上传
|
|
116
|
+
const client = createClient();
|
|
117
|
+
|
|
118
|
+
// 读取 zip 文件内容
|
|
119
|
+
const zipBuffer = fs.readFileSync(tmpZipPath);
|
|
120
|
+
const zipBlob = new Blob([zipBuffer], { type: 'application/zip' });
|
|
121
|
+
|
|
122
|
+
// 使用原生 FormData
|
|
123
|
+
const formData = new FormData();
|
|
124
|
+
formData.append('zip_file', zipBlob, 'skill.zip');
|
|
125
|
+
formData.append('skill_id', skillId);
|
|
126
|
+
formData.append('name', name);
|
|
127
|
+
formData.append('description', description);
|
|
128
|
+
formData.append('changelog', changelog || '');
|
|
129
|
+
|
|
130
|
+
const result = await client.postForm('/skills/publish', formData);
|
|
131
|
+
|
|
132
|
+
if (result.ok) {
|
|
133
|
+
spinner.succeed(chalk.green(`发布成功!Skill: ${result.skill_id}, 版本: ${result.version}`));
|
|
134
|
+
} else {
|
|
135
|
+
spinner.fail(chalk.red('发布失败'));
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
} catch (err) {
|
|
139
|
+
spinner.fail(chalk.red(`发布失败:${err.message}`));
|
|
140
|
+
process.exit(1);
|
|
141
|
+
} finally {
|
|
142
|
+
// 8. 清理临时文件
|
|
143
|
+
try {
|
|
144
|
+
if (fs.existsSync(tmpZipPath)) {
|
|
145
|
+
fs.unlinkSync(tmpZipPath);
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
// 忽略清理错误
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import { createClient } from '../api.js';
|
|
4
|
+
|
|
5
|
+
export default async function search(keyword) {
|
|
6
|
+
const client = createClient();
|
|
7
|
+
const spinner = ora('正在搜索...').start();
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
const result = await client.get(`/skills?q=${encodeURIComponent(keyword)}`);
|
|
11
|
+
spinner.stop();
|
|
12
|
+
|
|
13
|
+
if (!result.skills || result.skills.length === 0) {
|
|
14
|
+
console.log(chalk.yellow('未找到匹配的 Skill'));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 表格展示
|
|
19
|
+
const skills = result.skills;
|
|
20
|
+
|
|
21
|
+
// 计算列宽
|
|
22
|
+
const idWidth = Math.max(4, ...skills.map(s => s.id.length));
|
|
23
|
+
const nameWidth = Math.max(6, ...skills.map(s => s.name.length));
|
|
24
|
+
const versionWidth = Math.max(8, ...skills.map(s => (s.latest_version || '-').length));
|
|
25
|
+
const descWidth = 40;
|
|
26
|
+
|
|
27
|
+
// 表头
|
|
28
|
+
const header = [
|
|
29
|
+
'ID'.padEnd(idWidth),
|
|
30
|
+
'名称'.padEnd(nameWidth),
|
|
31
|
+
'最新版本'.padEnd(versionWidth),
|
|
32
|
+
'描述'
|
|
33
|
+
].join(' ');
|
|
34
|
+
|
|
35
|
+
const separator = '-'.repeat(idWidth + nameWidth + versionWidth + descWidth + 6);
|
|
36
|
+
|
|
37
|
+
console.log(chalk.cyan(`\n找到 ${result.total || skills.length} 个 Skill:\n`));
|
|
38
|
+
console.log(chalk.bold(header));
|
|
39
|
+
console.log(separator);
|
|
40
|
+
|
|
41
|
+
for (const skill of skills) {
|
|
42
|
+
// 截断描述到 40 字符
|
|
43
|
+
let desc = skill.description || '';
|
|
44
|
+
if (desc.length > descWidth) {
|
|
45
|
+
desc = desc.slice(0, descWidth - 3) + '...';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const row = [
|
|
49
|
+
skill.id.padEnd(idWidth),
|
|
50
|
+
skill.name.padEnd(nameWidth),
|
|
51
|
+
(skill.latest_version || '-').padEnd(versionWidth),
|
|
52
|
+
desc
|
|
53
|
+
].join(' ');
|
|
54
|
+
|
|
55
|
+
console.log(row);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log('');
|
|
59
|
+
} catch (err) {
|
|
60
|
+
spinner.fail(chalk.red(`搜索失败:${err.message}`));
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import { createClient } from '../api.js';
|
|
4
|
+
import { downloadAndExtract } from './install.js';
|
|
5
|
+
|
|
6
|
+
export default async function update(skillId, options) {
|
|
7
|
+
const client = createClient();
|
|
8
|
+
const targetDir = options?.dir || process.cwd();
|
|
9
|
+
|
|
10
|
+
const spinner = ora(`正在检查 ${skillId} 的最新版本...`).start();
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
// 获取 Skill 信息
|
|
14
|
+
const skillInfo = await client.get(`/skills/${encodeURIComponent(skillId)}`);
|
|
15
|
+
const latestVersion = skillInfo.latest_version;
|
|
16
|
+
|
|
17
|
+
if (!latestVersion) {
|
|
18
|
+
spinner.fail(chalk.red(`Skill ${skillId} 没有可用版本`));
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
spinner.text = `正在下载 ${skillId}@${latestVersion}...`;
|
|
23
|
+
|
|
24
|
+
// 直接下载最新版本(由于没有本地版本跟踪,每次都执行更新)
|
|
25
|
+
const result = await downloadAndExtract(skillId, latestVersion, targetDir);
|
|
26
|
+
spinner.succeed(chalk.green(`已更新 ${result.skillId} 到 ${result.version}`));
|
|
27
|
+
} catch (err) {
|
|
28
|
+
spinner.fail(chalk.red(`更新失败:${err.message}`));
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function getConfig() {
|
|
5
|
+
let baseUrl = process.env.SKB_BASE_URL || 'http://localhost:8000';
|
|
6
|
+
// 去除末尾斜杠
|
|
7
|
+
baseUrl = baseUrl.replace(/\/+$/, '');
|
|
8
|
+
|
|
9
|
+
const credentialsDir = path.join(os.homedir(), '.skill-base');
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
baseUrl,
|
|
13
|
+
apiUrl: `${baseUrl}/api/v1`,
|
|
14
|
+
credentialsDir,
|
|
15
|
+
credentialsPath: path.join(credentialsDir, 'credentials.json')
|
|
16
|
+
};
|
|
17
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "skill-base-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Skill Base CLI - 命令行工具,用于搜索、安装、更新和发布 AI Agent Skills",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"skb": "./bin/skb.js"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"skill",
|
|
11
|
+
"agent",
|
|
12
|
+
"ai",
|
|
13
|
+
"cli",
|
|
14
|
+
"skill-base"
|
|
15
|
+
],
|
|
16
|
+
"author": "",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": ""
|
|
21
|
+
},
|
|
22
|
+
"homepage": "",
|
|
23
|
+
"files": [
|
|
24
|
+
"bin",
|
|
25
|
+
"lib"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"archiver": "^7.0.0",
|
|
29
|
+
"commander": "^12.1.0",
|
|
30
|
+
"chalk": "^5.3.0",
|
|
31
|
+
"ora": "^8.0.1",
|
|
32
|
+
"prompts": "^2.4.2",
|
|
33
|
+
"form-data": "^4.0.0",
|
|
34
|
+
"extract-zip": "^2.0.1"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18.0.0"
|
|
38
|
+
}
|
|
39
|
+
}
|