istarshine 1.1.0 → 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 CHANGED
@@ -17,7 +17,7 @@ const program = new Command();
17
17
  program
18
18
  .name('istarshine')
19
19
  .description('iStarshine Skills Hub CLI - 一键安装和管理 Skills')
20
- .version('1.0.2');
20
+ .version('1.2.0');
21
21
 
22
22
  // install 子命令
23
23
  program
@@ -26,6 +26,7 @@ program
26
26
  .option('-d, --dir <path>', '安装目标目录', './skills')
27
27
  .option('--server <url>', 'Skills Hub 服务器地址', 'https://skills.istarshine.com')
28
28
  .option('--api-key <value>', '指定 API Key(覆盖环境变量和配置文件)')
29
+ .option('--force', '强制重新安装所有依赖', false)
29
30
  .action(async (skill_name, options) => {
30
31
  await install_skill(skill_name, options);
31
32
  });
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
@@ -5,24 +5,100 @@
5
5
  */
6
6
 
7
7
  import { fetch_json, download_file } from './api.js';
8
- import { readFileSync, mkdirSync, existsSync } from 'fs';
8
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'fs';
9
9
  import { join, resolve } from 'path';
10
+ import { homedir } from 'os';
10
11
  import { extract_zip } from './zip.js';
11
- import { resolve_api_key, mask_api_key } from './config.js';
12
+ import { resolve_api_key } from './config.js';
13
+ import { resolve_install_queue } from './deps.js';
12
14
 
13
15
  /**
14
- * 安装指定名称的 skill
15
- * 流程: 按名称搜索 -> 找到精确匹配 -> 下载 zip(携带 API Key)-> 解压 -> 检查 SKILL.md 声明
16
+ * 安装单个 skill 的核心逻辑(模块内部函数)
17
+ * 流程: 按名称搜索 -> 精确匹配 -> 下载 zip -> 解压 -> 检查 SKILL.md 声明
18
+ *
16
19
  * @param {string} skill_name - skill 名称
17
- * @param {object} options - 命令行选项 { dir, server, apiKey }
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 - 安装队列总数
18
25
  */
19
- export async function install_skill(skill_name, options) {
26
+ async function install_single_skill(skill_name, options, auth_headers, chalk, index, total) {
20
27
  const { dir, server } = options;
21
- // 动态导入 chalk 和 ora(ESM 模块)
22
- const chalk = (await import('chalk')).default;
23
28
  const ora = (await import('ora')).default;
24
29
 
25
- const spinner = ora(`正在搜索 Skill: ${skill_name}`).start();
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;
26
102
 
27
103
  try {
28
104
  // 解析 API Key(命令行 > 环境变量 > 配置文件)
@@ -32,58 +108,148 @@ export async function install_skill(skill_name, options) {
32
108
  ? { 'Authorization': `Bearer ${api_key}` }
33
109
  : {};
34
110
 
35
- // 1. 按名称搜索 skill(搜索接口不携带 auth header)
36
- const search_url = `${server}/api/skills/search?keyword=${encodeURIComponent(skill_name)}`;
37
- const results = await fetch_json(search_url);
38
-
39
- // 精确匹配名称
40
- const skill = results.find(s => s.name === skill_name);
41
- if (!skill) {
42
- spinner.fail(chalk.red(`未找到名为 "${skill_name}" 的 Skill`));
43
- if (results.length > 0) {
44
- console.log(chalk.yellow('\n你是不是想找:'));
45
- results.slice(0, 5).forEach(s => {
46
- console.log(` ${chalk.cyan(s.name)} ${s.display_name || ''}`);
47
- });
48
- }
49
- process.exit(1);
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('');
50
130
  }
51
131
 
52
- // 2. 下载 zip 压缩包(下载接口携带 auth header)
53
- spinner.text = `正在下载 ${skill_name}@${skill.version || 'latest'}...`;
54
- const download_url = `${server}/api/skills/${skill.id}/download`;
55
- const zip_buffer = await download_file(download_url, auth_headers);
132
+ // 3. 遍历队列,逐个安装
133
+ const total = install_queue.length;
134
+ let installed_count = 0;
135
+ let skipped_count = 0;
56
136
 
57
- // 检查响应头 X-Auth-Warning
58
- if (zip_buffer.headers && zip_buffer.headers['x-auth-warning']) {
59
- // 先不输出,等安装成功后再显示
60
- var auth_warning = zip_buffer.headers['x-auth-warning'];
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);
160
+ }
61
161
  }
62
162
 
63
- // 3. 解压到目标目录
64
- const target_dir = resolve(process.cwd(), dir, skill_name);
65
- spinner.text = `正在解压到 ${target_dir}...`;
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
+ }
66
175
 
67
- if (!existsSync(target_dir)) {
68
- mkdirSync(target_dir, { recursive: true });
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
+ }
201
+
202
+ try {
203
+ // ---- 步骤 1: 写入 secrets.json ----
204
+ let secrets = {};
205
+ if (existsSync(secrets_path)) {
206
+ secrets = JSON.parse(readFileSync(secrets_path, 'utf8'));
69
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;
70
212
 
71
- await extract_zip(zip_buffer, target_dir);
213
+ writeFileSync(secrets_path, JSON.stringify(secrets, null, 2), 'utf8');
214
+ chmodSync(secrets_path, 0o600);
72
215
 
73
- spinner.succeed(chalk.green(`${skill_name}@${skill.version || 'latest'} 安装成功`));
74
- console.log(chalk.gray(` 安装路径: ${target_dir}`));
216
+ // ---- 步骤 2: 配置 openclaw.json ----
217
+ if (!existsSync(config_path)) {
218
+ return; // 无 openclaw 配置文件,跳过
219
+ }
220
+ const config = JSON.parse(readFileSync(config_path, 'utf8'));
75
221
 
76
- // 安装成功后输出 X-Auth-Warning 警告
77
- if (auth_warning) {
78
- console.log(chalk.yellow(` ⚠ ${auth_warning}`));
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
+ };
79
231
  }
80
232
 
81
- // 4. 检查 SKILL.md 中的认证声明
82
- check_skill_auth_declaration(target_dir, api_key, chalk);
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'));
83
248
 
84
249
  } catch (err) {
85
- spinner.fail(chalk.red(`安装失败: ${err.message}`));
86
- process.exit(1);
250
+ // SecretRef 配置失败不阻断安装流程
251
+ console.log(chalk.yellow(` ⚠ OpenClaw SecretRef 自动配置失败: ${err.message}`));
252
+ console.log(chalk.gray(' 可手动配置,参考: openclaw secrets configure'));
87
253
  }
88
254
  }
89
255
 
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "istarshine",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "iStarshine Skills Hub CLI - 一键安装和管理 Skills",
5
5
  "type": "module",
6
6
  "main": "index.js",