istarshine 1.0.3 → 1.2.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/bin/cli.js +15 -1
- package/lib/api.js +34 -11
- package/lib/config.js +172 -0
- package/lib/deps.js +127 -0
- package/lib/install.js +252 -35
- package/lib/update_check.js +0 -0
- package/package.json +1 -1
package/bin/cli.js
CHANGED
|
@@ -10,13 +10,14 @@ import { install_skill } from '../lib/install.js';
|
|
|
10
10
|
import { search_skills } from '../lib/search.js';
|
|
11
11
|
import { list_skills } from '../lib/list.js';
|
|
12
12
|
import { find_skills } from '../lib/find.js';
|
|
13
|
+
import { config_action } from '../lib/config.js';
|
|
13
14
|
|
|
14
15
|
const program = new Command();
|
|
15
16
|
|
|
16
17
|
program
|
|
17
18
|
.name('istarshine')
|
|
18
19
|
.description('iStarshine Skills Hub CLI - 一键安装和管理 Skills')
|
|
19
|
-
.version('1.0
|
|
20
|
+
.version('1.2.0');
|
|
20
21
|
|
|
21
22
|
// install 子命令
|
|
22
23
|
program
|
|
@@ -24,6 +25,8 @@ program
|
|
|
24
25
|
.description('安装指定名称的 Skill 到当前项目')
|
|
25
26
|
.option('-d, --dir <path>', '安装目标目录', './skills')
|
|
26
27
|
.option('--server <url>', 'Skills Hub 服务器地址', 'https://skills.istarshine.com')
|
|
28
|
+
.option('--api-key <value>', '指定 API Key(覆盖环境变量和配置文件)')
|
|
29
|
+
.option('--force', '强制重新安装所有依赖', false)
|
|
27
30
|
.action(async (skill_name, options) => {
|
|
28
31
|
await install_skill(skill_name, options);
|
|
29
32
|
});
|
|
@@ -56,4 +59,15 @@ program
|
|
|
56
59
|
await find_skills(keyword, options);
|
|
57
60
|
});
|
|
58
61
|
|
|
62
|
+
// config 子命令
|
|
63
|
+
program
|
|
64
|
+
.command('config')
|
|
65
|
+
.description('管理 CLI 配置(API Key 等)')
|
|
66
|
+
.option('--api-key <value>', '设置 API Key')
|
|
67
|
+
.option('--show', '查看当前配置')
|
|
68
|
+
.option('--clear', '清除配置文件')
|
|
69
|
+
.action(async (options) => {
|
|
70
|
+
await config_action(options);
|
|
71
|
+
});
|
|
72
|
+
|
|
59
73
|
program.parse();
|
package/lib/api.js
CHANGED
|
@@ -9,17 +9,27 @@ import { request as http_request } from 'http';
|
|
|
9
9
|
/**
|
|
10
10
|
* 发送 GET 请求,返回 JSON
|
|
11
11
|
* @param {string} url - 完整请求 URL
|
|
12
|
+
* @param {object} headers - 可选的自定义请求头
|
|
12
13
|
* @returns {Promise<object>} 解析后的 JSON 对象
|
|
13
14
|
*/
|
|
14
|
-
export function fetch_json(url) {
|
|
15
|
+
export function fetch_json(url, headers = {}) {
|
|
15
16
|
return new Promise((resolve, reject) => {
|
|
16
|
-
const
|
|
17
|
+
const parsed = new URL(url);
|
|
18
|
+
const is_https = parsed.protocol === 'https:';
|
|
17
19
|
const do_request = is_https ? request : http_request;
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
const options = {
|
|
22
|
+
hostname: parsed.hostname,
|
|
23
|
+
port: parsed.port || (is_https ? 443 : 80),
|
|
24
|
+
path: parsed.pathname + parsed.search,
|
|
25
|
+
method: 'GET',
|
|
26
|
+
headers: { ...headers },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
do_request(options, (res) => {
|
|
20
30
|
// 处理重定向
|
|
21
31
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
22
|
-
return fetch_json(res.headers.location).then(resolve).catch(reject);
|
|
32
|
+
return fetch_json(res.headers.location, headers).then(resolve).catch(reject);
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
if (res.statusCode !== 200) {
|
|
@@ -41,19 +51,29 @@ export function fetch_json(url) {
|
|
|
41
51
|
}
|
|
42
52
|
|
|
43
53
|
/**
|
|
44
|
-
*
|
|
54
|
+
* 下载二进制文件,返回带 headers 属性的 Buffer
|
|
45
55
|
* @param {string} url - 完整请求 URL
|
|
46
|
-
* @
|
|
56
|
+
* @param {object} headers - 可选的自定义请求头
|
|
57
|
+
* @returns {Promise<Buffer>} 文件二进制数据,附带 .headers 属性(响应头)
|
|
47
58
|
*/
|
|
48
|
-
export function download_file(url) {
|
|
59
|
+
export function download_file(url, headers = {}) {
|
|
49
60
|
return new Promise((resolve, reject) => {
|
|
50
|
-
const
|
|
61
|
+
const parsed = new URL(url);
|
|
62
|
+
const is_https = parsed.protocol === 'https:';
|
|
51
63
|
const do_request = is_https ? request : http_request;
|
|
52
64
|
|
|
53
|
-
|
|
65
|
+
const options = {
|
|
66
|
+
hostname: parsed.hostname,
|
|
67
|
+
port: parsed.port || (is_https ? 443 : 80),
|
|
68
|
+
path: parsed.pathname + parsed.search,
|
|
69
|
+
method: 'GET',
|
|
70
|
+
headers: { ...headers },
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
do_request(options, (res) => {
|
|
54
74
|
// 处理重定向
|
|
55
75
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
56
|
-
return download_file(res.headers.location).then(resolve).catch(reject);
|
|
76
|
+
return download_file(res.headers.location, headers).then(resolve).catch(reject);
|
|
57
77
|
}
|
|
58
78
|
|
|
59
79
|
if (res.statusCode !== 200) {
|
|
@@ -64,7 +84,10 @@ export function download_file(url) {
|
|
|
64
84
|
const chunks = [];
|
|
65
85
|
res.on('data', (chunk) => { chunks.push(chunk); });
|
|
66
86
|
res.on('end', () => {
|
|
67
|
-
|
|
87
|
+
// 返回 Buffer 并附加响应头属性,便于调用方读取 X-Auth-Warning 等
|
|
88
|
+
const buffer = Buffer.concat(chunks);
|
|
89
|
+
Object.assign(buffer, { headers: res.headers });
|
|
90
|
+
resolve(buffer);
|
|
68
91
|
});
|
|
69
92
|
}).on('error', reject).end();
|
|
70
93
|
});
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置管理模块
|
|
3
|
+
* 负责 Config_File 的读写和 API Key 多来源优先级解析
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync, chmodSync, statSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import { homedir } from 'os';
|
|
9
|
+
|
|
10
|
+
// 配置目录和文件路径
|
|
11
|
+
const CONFIG_DIR = join(homedir(), '.istarshine');
|
|
12
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 读取配置文件,返回解析后的对象
|
|
16
|
+
* 文件不存在或 JSON 无效时返回 null
|
|
17
|
+
* @returns {object|null}
|
|
18
|
+
*/
|
|
19
|
+
export function read_config() {
|
|
20
|
+
try {
|
|
21
|
+
if (!existsSync(CONFIG_FILE)) return null;
|
|
22
|
+
const content = readFileSync(CONFIG_FILE, 'utf8');
|
|
23
|
+
return JSON.parse(content);
|
|
24
|
+
} catch {
|
|
25
|
+
// JSON 解析失败或读取失败,返回 null
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 写入配置文件(合并模式,保留已有字段)
|
|
32
|
+
* 自动创建 CONFIG_DIR,文件权限设为 0o600
|
|
33
|
+
* @param {object} updates - 要更新的字段
|
|
34
|
+
*/
|
|
35
|
+
export function write_config(updates) {
|
|
36
|
+
// 确保目录存在
|
|
37
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
38
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
// 读取已有配置并合并
|
|
41
|
+
const existing = read_config() || {};
|
|
42
|
+
const merged = { ...existing, ...updates };
|
|
43
|
+
// 写入文件,2 空格缩进
|
|
44
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2), 'utf8');
|
|
45
|
+
// 设置文件权限为仅当前用户可读写
|
|
46
|
+
chmodSync(CONFIG_FILE, 0o600);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 删除配置文件
|
|
51
|
+
* @returns {boolean} 是否成功删除(文件不存在返回 false)
|
|
52
|
+
*/
|
|
53
|
+
export function clear_config() {
|
|
54
|
+
try {
|
|
55
|
+
if (!existsSync(CONFIG_FILE)) return false;
|
|
56
|
+
unlinkSync(CONFIG_FILE);
|
|
57
|
+
return true;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 按优先级解析 API Key
|
|
65
|
+
* 1. cli_key(命令行 --api-key 参数)
|
|
66
|
+
* 2. 环境变量 ISTARSHINE_API_KEY
|
|
67
|
+
* 3. Config_File 中的 api_key 字段
|
|
68
|
+
* @param {string|undefined} cli_key - 命令行传入的 key
|
|
69
|
+
* @returns {string|null}
|
|
70
|
+
*/
|
|
71
|
+
export function resolve_api_key(cli_key) {
|
|
72
|
+
// 优先级 1:命令行参数
|
|
73
|
+
if (cli_key) return cli_key;
|
|
74
|
+
// 优先级 2:环境变量
|
|
75
|
+
const env_key = process.env.ISTARSHINE_API_KEY;
|
|
76
|
+
if (env_key) return env_key;
|
|
77
|
+
// 优先级 3:配置文件
|
|
78
|
+
const cfg = read_config();
|
|
79
|
+
if (cfg && cfg.api_key) return cfg.api_key;
|
|
80
|
+
// 所有来源均无 Key
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 将 API Key 脱敏为前缀形式
|
|
86
|
+
* 如 "sk-acme-a1b2c3d4..." → "sk-acme-a1b2..."
|
|
87
|
+
* @param {string} key
|
|
88
|
+
* @returns {string}
|
|
89
|
+
*/
|
|
90
|
+
export function mask_api_key(key) {
|
|
91
|
+
if (!key) return '';
|
|
92
|
+
// 格式: sk-{prefix}-{32hex}
|
|
93
|
+
// 找到最后一个 '-' 的位置,取前4位hex + '...'
|
|
94
|
+
const last_dash = key.lastIndexOf('-');
|
|
95
|
+
if (last_dash === -1) {
|
|
96
|
+
// 非标准格式,只显示前8字符
|
|
97
|
+
return key.slice(0, 8) + '...';
|
|
98
|
+
}
|
|
99
|
+
const before_hex = key.slice(0, last_dash + 1);
|
|
100
|
+
const hex_part = key.slice(last_dash + 1);
|
|
101
|
+
return before_hex + hex_part.slice(0, 4) + '...';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 导出路径常量,供测试使用
|
|
105
|
+
export { CONFIG_DIR, CONFIG_FILE };
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* config 子命令处理函数
|
|
109
|
+
* 支持 --api-key、--show、--clear 三个选项
|
|
110
|
+
* @param {object} options - 命令行选项
|
|
111
|
+
*/
|
|
112
|
+
export async function config_action(options) {
|
|
113
|
+
const chalk = (await import('chalk')).default;
|
|
114
|
+
|
|
115
|
+
// --clear:清除配置文件
|
|
116
|
+
if (options.clear) {
|
|
117
|
+
const removed = clear_config();
|
|
118
|
+
if (removed) {
|
|
119
|
+
console.log(chalk.green('✔ 配置文件已清除'));
|
|
120
|
+
} else {
|
|
121
|
+
console.log(chalk.yellow('配置文件不存在,无需清除'));
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --api-key:写入 API Key
|
|
127
|
+
if (options.apiKey) {
|
|
128
|
+
try {
|
|
129
|
+
write_config({ api_key: options.apiKey });
|
|
130
|
+
const masked = mask_api_key(options.apiKey);
|
|
131
|
+
console.log(chalk.green(`✔ API Key 已保存: ${masked}`));
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.log(chalk.red(`✖ 写入配置失败: ${err.message}`));
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --show:展示当前配置
|
|
140
|
+
if (options.show) {
|
|
141
|
+
const cfg = read_config();
|
|
142
|
+
const env_key = process.env.ISTARSHINE_API_KEY;
|
|
143
|
+
|
|
144
|
+
if (!cfg && !env_key) {
|
|
145
|
+
console.log(chalk.yellow('尚未配置。使用 istarshine config --api-key <value> 设置 API Key'));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
console.log(chalk.cyan('当前配置:'));
|
|
150
|
+
if (cfg && cfg.api_key) {
|
|
151
|
+
console.log(` API Key (配置文件): ${mask_api_key(cfg.api_key)}`);
|
|
152
|
+
}
|
|
153
|
+
if (env_key) {
|
|
154
|
+
console.log(` API Key (环境变量 ISTARSHINE_API_KEY): ${mask_api_key(env_key)}`);
|
|
155
|
+
}
|
|
156
|
+
if (cfg) {
|
|
157
|
+
// 展示其他非 api_key 字段
|
|
158
|
+
for (const [k, v] of Object.entries(cfg)) {
|
|
159
|
+
if (k !== 'api_key') {
|
|
160
|
+
console.log(` ${k}: ${v}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 无选项时显示帮助
|
|
168
|
+
console.log(chalk.yellow('请指定选项。用法:'));
|
|
169
|
+
console.log(' istarshine config --api-key <value> 设置 API Key');
|
|
170
|
+
console.log(' istarshine config --show 查看当前配置');
|
|
171
|
+
console.log(' istarshine config --clear 清除配置文件');
|
|
172
|
+
}
|
package/lib/deps.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 依赖解析模块
|
|
3
|
+
* 负责从后端 API 获取依赖信息,构建安装队列
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { fetch_json } from './api.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 解析指定 skill 的完整依赖,生成安装队列
|
|
10
|
+
* 优先使用后端返回的 resolved_dependencies(已扁平化的完整传递依赖列表)
|
|
11
|
+
* 安装队列 = resolved_dependencies + 目标 skill 本身
|
|
12
|
+
*
|
|
13
|
+
* @param {string} skill_name - 目标 skill 名称
|
|
14
|
+
* @param {string} server - Skills Hub 服务器地址
|
|
15
|
+
* @returns {Promise<string[]>} 安装队列(依赖在前,目标在后)
|
|
16
|
+
*/
|
|
17
|
+
export async function resolve_install_queue(skill_name, server) {
|
|
18
|
+
try {
|
|
19
|
+
// 调用后端依赖查询 API
|
|
20
|
+
const url = `${server}/api/skills/by-name/${encodeURIComponent(skill_name)}/dependencies`;
|
|
21
|
+
const data = await fetch_json(url);
|
|
22
|
+
|
|
23
|
+
// 优先使用后端已解析的完整传递依赖列表
|
|
24
|
+
if (data.resolved_dependencies && data.resolved_dependencies.length > 0) {
|
|
25
|
+
return [...data.resolved_dependencies, skill_name];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// resolved_dependencies 为空但 dependencies 非空,回退到 CLI 侧递归解析(任务 4.2 实现)
|
|
29
|
+
if (data.dependencies && data.dependencies.length > 0) {
|
|
30
|
+
return await resolve_deps_recursive(skill_name, server, new Set());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 无依赖,仅安装目标 skill
|
|
34
|
+
return [skill_name];
|
|
35
|
+
} catch {
|
|
36
|
+
// API 调用失败(网络错误、404 等),回退到仅安装目标 skill
|
|
37
|
+
return [skill_name];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 最大递归深度,超过时终止并报错
|
|
42
|
+
const MAX_RECURSION_DEPTH = 10;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* CLI 侧回退递归解析
|
|
46
|
+
* 当后端 resolved_dependencies 不可用时,CLI 自行递归解析依赖图
|
|
47
|
+
* 深度优先后序收集,最终去重
|
|
48
|
+
*
|
|
49
|
+
* @param {string} skill_name - 目标 skill 名称
|
|
50
|
+
* @param {string} server - Skills Hub 服务器地址
|
|
51
|
+
* @param {Set<string>} [visited] - 已访问集合,用于检测循环依赖
|
|
52
|
+
* @param {number} [depth=0] - 当前递归深度
|
|
53
|
+
* @returns {Promise<string[]>} 安装队列(依赖在前,目标在后,已去重)
|
|
54
|
+
*/
|
|
55
|
+
export async function resolve_deps_recursive(skill_name, server, visited, depth = 0) {
|
|
56
|
+
// 初始调用时创建 visited 集合
|
|
57
|
+
if (!visited) {
|
|
58
|
+
visited = new Set();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 超过最大递归深度,终止并报错
|
|
62
|
+
if (depth > MAX_RECURSION_DEPTH) {
|
|
63
|
+
throw new Error(`依赖解析超过最大递归深度 ${MAX_RECURSION_DEPTH} 层,可能存在异常依赖链`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 循环检测:已访问过则返回空数组
|
|
67
|
+
if (visited.has(skill_name)) {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 标记为已访问
|
|
72
|
+
visited.add(skill_name);
|
|
73
|
+
|
|
74
|
+
// 后序收集结果
|
|
75
|
+
const result = [];
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
// 调用 API 获取当前 skill 的直接依赖
|
|
79
|
+
const url = `${server}/api/skills/by-name/${encodeURIComponent(skill_name)}/dependencies`;
|
|
80
|
+
const data = await fetch_json(url);
|
|
81
|
+
|
|
82
|
+
const deps = data.dependencies;
|
|
83
|
+
if (Array.isArray(deps) && deps.length > 0) {
|
|
84
|
+
// 对每个直接依赖递归解析
|
|
85
|
+
for (const dep of deps) {
|
|
86
|
+
if (typeof dep !== 'string') continue;
|
|
87
|
+
const sub_result = await resolve_deps_recursive(dep, server, visited, depth + 1);
|
|
88
|
+
// 收集子依赖的结果
|
|
89
|
+
for (const item of sub_result) {
|
|
90
|
+
result.push(item);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
// API 调用失败时(网络错误、404 等),将当前 skill 加入结果并继续
|
|
96
|
+
// 不阻塞整体解析流程
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 后序插入:先插入子依赖,再插入当前 skill
|
|
100
|
+
result.push(skill_name);
|
|
101
|
+
|
|
102
|
+
// 去重(保持顺序,首次出现的保留)
|
|
103
|
+
const seen = new Set();
|
|
104
|
+
const deduped = [];
|
|
105
|
+
for (const item of result) {
|
|
106
|
+
if (!seen.has(item)) {
|
|
107
|
+
seen.add(item);
|
|
108
|
+
deduped.push(item);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return deduped;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 校验 Skill 名称是否合法
|
|
117
|
+
* 规则:由小写字母、数字和横杠组成,不以横杠开头或结尾
|
|
118
|
+
*
|
|
119
|
+
* @param {string} name - 待校验的 Skill 名称
|
|
120
|
+
* @returns {boolean} 合法返回 true,否则返回 false
|
|
121
|
+
*/
|
|
122
|
+
export function validate_skill_name(name) {
|
|
123
|
+
if (typeof name !== 'string' || name === '') {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name);
|
|
127
|
+
}
|
package/lib/install.js
CHANGED
|
@@ -1,65 +1,282 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* install 命令实现
|
|
3
3
|
* 根据 skill 名称搜索、下载 zip 并解压到本地目录
|
|
4
|
+
* 支持自动携带 API Key 认证和 SKILL.md 声明检测
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import { fetch_json, download_file } from './api.js';
|
|
7
|
-
import {
|
|
8
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'fs';
|
|
8
9
|
import { join, resolve } from 'path';
|
|
10
|
+
import { homedir } from 'os';
|
|
9
11
|
import { extract_zip } from './zip.js';
|
|
12
|
+
import { resolve_api_key } from './config.js';
|
|
13
|
+
import { resolve_install_queue } from './deps.js';
|
|
10
14
|
|
|
11
15
|
/**
|
|
12
|
-
*
|
|
13
|
-
* 流程: 按名称搜索 ->
|
|
16
|
+
* 安装单个 skill 的核心逻辑(模块内部函数)
|
|
17
|
+
* 流程: 按名称搜索 -> 精确匹配 -> 下载 zip -> 解压 -> 检查 SKILL.md 声明
|
|
18
|
+
*
|
|
14
19
|
* @param {string} skill_name - skill 名称
|
|
15
20
|
* @param {object} options - 命令行选项 { dir, server }
|
|
21
|
+
* @param {object} auth_headers - 认证请求头(用于下载接口)
|
|
22
|
+
* @param {object} chalk - chalk 实例
|
|
23
|
+
* @param {number} index - 当前安装序号(从 1 开始)
|
|
24
|
+
* @param {number} total - 安装队列总数
|
|
16
25
|
*/
|
|
17
|
-
|
|
26
|
+
async function install_single_skill(skill_name, options, auth_headers, chalk, index, total) {
|
|
18
27
|
const { dir, server } = options;
|
|
19
|
-
// 动态导入 chalk 和 ora(ESM 模块)
|
|
20
|
-
const chalk = (await import('chalk')).default;
|
|
21
28
|
const ora = (await import('ora')).default;
|
|
22
29
|
|
|
23
|
-
|
|
30
|
+
// 进度前缀,例如 [1/3]
|
|
31
|
+
const progress_prefix = `[${index}/${total}]`;
|
|
32
|
+
|
|
33
|
+
const spinner = ora(`${progress_prefix} 正在搜索 Skill: ${skill_name}`).start();
|
|
34
|
+
|
|
35
|
+
// 解析 API Key,用于后续认证声明检查
|
|
36
|
+
const api_key = resolve_api_key(options.apiKey);
|
|
37
|
+
|
|
38
|
+
// 1. 按名称搜索 skill(搜索接口不携带 auth header)
|
|
39
|
+
const search_url = `${server}/api/skills/search?keyword=${encodeURIComponent(skill_name)}`;
|
|
40
|
+
const results = await fetch_json(search_url);
|
|
41
|
+
|
|
42
|
+
// 精确匹配名称
|
|
43
|
+
const skill = results.find(s => s.name === skill_name);
|
|
44
|
+
if (!skill) {
|
|
45
|
+
spinner.fail(chalk.red(`${progress_prefix} 未找到名为 "${skill_name}" 的 Skill`));
|
|
46
|
+
if (results.length > 0) {
|
|
47
|
+
console.log(chalk.yellow('\n你是不是想找:'));
|
|
48
|
+
results.slice(0, 5).forEach(s => {
|
|
49
|
+
console.log(` ${chalk.cyan(s.name)} ${s.display_name || ''}`);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`未找到名为 "${skill_name}" 的 Skill`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 2. 下载 zip 压缩包(下载接口携带 auth header)
|
|
56
|
+
spinner.text = `${progress_prefix} 正在下载 ${skill_name}@${skill.version || 'latest'}...`;
|
|
57
|
+
const download_url = `${server}/api/skills/${skill.id}/download`;
|
|
58
|
+
const zip_buffer = await download_file(download_url, auth_headers);
|
|
59
|
+
|
|
60
|
+
// 检查响应头 X-Auth-Warning
|
|
61
|
+
let auth_warning;
|
|
62
|
+
if (zip_buffer.headers && zip_buffer.headers['x-auth-warning']) {
|
|
63
|
+
auth_warning = zip_buffer.headers['x-auth-warning'];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 3. 解压到目标目录
|
|
67
|
+
const target_dir = resolve(process.cwd(), dir, skill_name);
|
|
68
|
+
spinner.text = `${progress_prefix} 正在解压到 ${target_dir}...`;
|
|
69
|
+
|
|
70
|
+
if (!existsSync(target_dir)) {
|
|
71
|
+
mkdirSync(target_dir, { recursive: true });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await extract_zip(zip_buffer, target_dir);
|
|
75
|
+
|
|
76
|
+
spinner.succeed(chalk.green(`${progress_prefix} ${skill_name}@${skill.version || 'latest'} 安装成功`));
|
|
77
|
+
console.log(chalk.gray(` 安装路径: ${target_dir}`));
|
|
78
|
+
|
|
79
|
+
// 安装成功后输出 X-Auth-Warning 警告
|
|
80
|
+
if (auth_warning) {
|
|
81
|
+
console.log(chalk.yellow(` ⚠ ${auth_warning}`));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 5. 自动配置 OpenClaw SecretRef(如果提供了 API Key)
|
|
85
|
+
if (api_key) {
|
|
86
|
+
setup_openclaw_secretref(skill_name, api_key, chalk);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 6. 检查 SKILL.md 中的认证声明
|
|
90
|
+
check_skill_auth_declaration(target_dir, api_key, chalk);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 安装指定名称的 skill(支持依赖自动解析)
|
|
95
|
+
* 流程: 解析依赖队列 -> 遍历队列 -> 跳过已安装 -> 逐个安装 -> 输出汇总
|
|
96
|
+
* @param {string} skill_name - skill 名称
|
|
97
|
+
* @param {object} options - 命令行选项 { dir, server, apiKey, force }
|
|
98
|
+
*/
|
|
99
|
+
export async function install_skill(skill_name, options) {
|
|
100
|
+
// 动态导入 chalk(ESM 模块)
|
|
101
|
+
const chalk = (await import('chalk')).default;
|
|
24
102
|
|
|
25
103
|
try {
|
|
26
|
-
//
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
104
|
+
// 解析 API Key(命令行 > 环境变量 > 配置文件)
|
|
105
|
+
const api_key = resolve_api_key(options.apiKey);
|
|
106
|
+
// 构建 auth headers,仅下载接口使用
|
|
107
|
+
const auth_headers = api_key
|
|
108
|
+
? { 'Authorization': `Bearer ${api_key}` }
|
|
109
|
+
: {};
|
|
110
|
+
|
|
111
|
+
// 1. 调用 resolve_install_queue 获取安装队列
|
|
112
|
+
// 如果依赖解析本身抛出异常,回退到仅安装目标 skill
|
|
113
|
+
let install_queue;
|
|
114
|
+
try {
|
|
115
|
+
install_queue = await resolve_install_queue(skill_name, options.server);
|
|
116
|
+
} catch (resolve_err) {
|
|
117
|
+
console.log(chalk.yellow(`⚠ 依赖解析失败,仅安装目标 Skill: ${resolve_err.message}`));
|
|
118
|
+
install_queue = [skill_name];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 2. 如果队列长度 > 1,输出待安装列表(包含依赖数量)
|
|
122
|
+
if (install_queue.length > 1) {
|
|
123
|
+
const dep_count = install_queue.length - 1;
|
|
124
|
+
console.log(chalk.cyan(`\n📦 检测到 ${dep_count} 个依赖,共需安装 ${install_queue.length} 个 Skill:`));
|
|
125
|
+
install_queue.forEach((name, i) => {
|
|
126
|
+
const label = name === skill_name ? chalk.bold(name) + ' (目标)' : name;
|
|
127
|
+
console.log(chalk.gray(` ${i + 1}. ${label}`));
|
|
128
|
+
});
|
|
129
|
+
console.log('');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 3. 遍历队列,逐个安装
|
|
133
|
+
const total = install_queue.length;
|
|
134
|
+
let installed_count = 0;
|
|
135
|
+
let skipped_count = 0;
|
|
136
|
+
|
|
137
|
+
for (let i = 0; i < total; i++) {
|
|
138
|
+
const current_name = install_queue[i];
|
|
139
|
+
const target_dir = resolve(process.cwd(), options.dir, current_name);
|
|
140
|
+
|
|
141
|
+
// 检查本地是否已安装(除非 --force)
|
|
142
|
+
if (existsSync(target_dir) && !options.force) {
|
|
143
|
+
skipped_count++;
|
|
144
|
+
console.log(chalk.yellow(`[${i + 1}/${total}] ${current_name} 已安装,跳过 (使用 --force 强制重新安装)`));
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 未安装或 --force,调用 install_single_skill 执行安装
|
|
149
|
+
try {
|
|
150
|
+
await install_single_skill(current_name, options, auth_headers, chalk, i + 1, total);
|
|
151
|
+
installed_count++;
|
|
152
|
+
} catch (install_err) {
|
|
153
|
+
// 输出失败的 skill 名称和错误原因
|
|
154
|
+
console.log(chalk.red(`\n✖ Skill "${current_name}" 安装失败: ${install_err.message}`));
|
|
155
|
+
// 如果还有后续 skill 未安装,输出终止提示
|
|
156
|
+
if (i < total - 1) {
|
|
157
|
+
console.log(chalk.yellow(`⚠ 终止后续安装,剩余 ${total - i - 1} 个 Skill 未安装`));
|
|
158
|
+
}
|
|
159
|
+
process.exit(1);
|
|
39
160
|
}
|
|
40
|
-
process.exit(1);
|
|
41
161
|
}
|
|
42
162
|
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
163
|
+
// 4. 安装完成后输出汇总信息
|
|
164
|
+
if (install_queue.length > 1) {
|
|
165
|
+
console.log('');
|
|
166
|
+
const parts = [];
|
|
167
|
+
if (installed_count > 0) {
|
|
168
|
+
parts.push(`成功安装了 ${installed_count} 个 Skill`);
|
|
169
|
+
}
|
|
170
|
+
if (skipped_count > 0) {
|
|
171
|
+
parts.push(`跳过了 ${skipped_count} 个已安装的 Skill`);
|
|
172
|
+
}
|
|
173
|
+
console.log(chalk.green(`✔ ${parts.join(',')}`));
|
|
174
|
+
}
|
|
47
175
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
176
|
+
} catch (err) {
|
|
177
|
+
// 处理其他未预期的错误(依赖安装错误已在循环内处理)
|
|
178
|
+
console.log(chalk.red(`安装失败: ${err.message}`));
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 将 API Key 写入 OpenClaw SecretRef 体系
|
|
185
|
+
* 1. 写入 ~/.openclaw/secrets.json(密钥存储)
|
|
186
|
+
* 2. 在 ~/.openclaw/openclaw.json 中配置 provider + skill apiKey SecretRef 引用
|
|
187
|
+
*
|
|
188
|
+
* @param {string} skill_name - skill 名称
|
|
189
|
+
* @param {string} api_key - API Key 明文
|
|
190
|
+
* @param {object} chalk - chalk 实例
|
|
191
|
+
*/
|
|
192
|
+
function setup_openclaw_secretref(skill_name, api_key, chalk) {
|
|
193
|
+
const openclaw_dir = join(homedir(), '.openclaw');
|
|
194
|
+
const secrets_path = join(openclaw_dir, 'secrets.json');
|
|
195
|
+
const config_path = join(openclaw_dir, 'openclaw.json');
|
|
196
|
+
|
|
197
|
+
// 未安装 openclaw,跳过
|
|
198
|
+
if (!existsSync(openclaw_dir)) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
51
201
|
|
|
52
|
-
|
|
53
|
-
|
|
202
|
+
try {
|
|
203
|
+
// ---- 步骤 1: 写入 secrets.json ----
|
|
204
|
+
let secrets = {};
|
|
205
|
+
if (existsSync(secrets_path)) {
|
|
206
|
+
secrets = JSON.parse(readFileSync(secrets_path, 'utf8'));
|
|
54
207
|
}
|
|
208
|
+
// 按供应商分组存储,同一供应商的 skill 共用一个 key
|
|
209
|
+
if (!secrets.skills) secrets.skills = {};
|
|
210
|
+
if (!secrets.skills.istarshine) secrets.skills.istarshine = {};
|
|
211
|
+
secrets.skills.istarshine.apiKey = api_key;
|
|
55
212
|
|
|
56
|
-
|
|
213
|
+
writeFileSync(secrets_path, JSON.stringify(secrets, null, 2), 'utf8');
|
|
214
|
+
chmodSync(secrets_path, 0o600);
|
|
57
215
|
|
|
58
|
-
|
|
59
|
-
|
|
216
|
+
// ---- 步骤 2: 配置 openclaw.json ----
|
|
217
|
+
if (!existsSync(config_path)) {
|
|
218
|
+
return; // 无 openclaw 配置文件,跳过
|
|
219
|
+
}
|
|
220
|
+
const config = JSON.parse(readFileSync(config_path, 'utf8'));
|
|
221
|
+
|
|
222
|
+
// 2a. 确保 secrets.providers.filemain 存在
|
|
223
|
+
if (!config.secrets) config.secrets = {};
|
|
224
|
+
if (!config.secrets.providers) config.secrets.providers = {};
|
|
225
|
+
if (!config.secrets.providers.filemain) {
|
|
226
|
+
config.secrets.providers.filemain = {
|
|
227
|
+
source: 'file',
|
|
228
|
+
path: '~/.openclaw/secrets.json',
|
|
229
|
+
mode: 'json'
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 2b. 配置 skill 的 apiKey SecretRef 引用
|
|
234
|
+
if (!config.skills) config.skills = {};
|
|
235
|
+
if (!config.skills.entries) config.skills.entries = {};
|
|
236
|
+
if (!config.skills.entries[skill_name]) config.skills.entries[skill_name] = {};
|
|
237
|
+
config.skills.entries[skill_name].apiKey = {
|
|
238
|
+
source: 'file',
|
|
239
|
+
provider: 'filemain',
|
|
240
|
+
id: '/skills/istarshine/apiKey'
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
writeFileSync(config_path, JSON.stringify(config, null, 2), 'utf8');
|
|
244
|
+
|
|
245
|
+
console.log(chalk.green(' ✔ API Key 已配置到 OpenClaw SecretRef'));
|
|
246
|
+
console.log(chalk.gray(' 密钥文件: ~/.openclaw/secrets.json'));
|
|
247
|
+
console.log(chalk.gray(' 配置引用: skills.entries.' + skill_name + '.apiKey → SecretRef'));
|
|
60
248
|
|
|
61
249
|
} catch (err) {
|
|
62
|
-
|
|
63
|
-
|
|
250
|
+
// SecretRef 配置失败不阻断安装流程
|
|
251
|
+
console.log(chalk.yellow(` ⚠ OpenClaw SecretRef 自动配置失败: ${err.message}`));
|
|
252
|
+
console.log(chalk.gray(' 可手动配置,参考: openclaw secrets configure'));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 检查解压后的 SKILL.md 是否包含 api.auth 或 metadata.openclaw 声明
|
|
258
|
+
* 如果包含且未配置 API Key,输出配置引导提示
|
|
259
|
+
* @param {string} target_dir - 解压目标目录
|
|
260
|
+
* @param {string|null} api_key - 当前解析到的 API Key
|
|
261
|
+
* @param {object} chalk - chalk 实例
|
|
262
|
+
*/
|
|
263
|
+
function check_skill_auth_declaration(target_dir, api_key, chalk) {
|
|
264
|
+
const skill_md_path = join(target_dir, 'SKILL.md');
|
|
265
|
+
if (!existsSync(skill_md_path)) return;
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
const content = readFileSync(skill_md_path, 'utf8');
|
|
269
|
+
// 检查是否包含认证声明
|
|
270
|
+
const has_api_auth = content.includes('api.auth') || content.includes('auth:');
|
|
271
|
+
const has_openclaw = content.includes('metadata.openclaw') || content.includes('primaryEnv');
|
|
272
|
+
|
|
273
|
+
if ((has_api_auth || has_openclaw) && !api_key) {
|
|
274
|
+
console.log('');
|
|
275
|
+
console.log(chalk.cyan(' 💡 该 Skill 需要 API Key 认证,请配置:'));
|
|
276
|
+
console.log(chalk.gray(' 方式一: npx istarshine config --api-key <your-key>'));
|
|
277
|
+
console.log(chalk.gray(' 方式二: openclaw config set skills.entries.<skill>.apiKey "<your-key>"'));
|
|
278
|
+
}
|
|
279
|
+
} catch {
|
|
280
|
+
// 读取 SKILL.md 失败不影响安装流程
|
|
64
281
|
}
|
|
65
282
|
}
|
|
File without changes
|