rushangle-cli 0.1.5 → 0.2.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rushangle-cli",
3
- "version": "0.1.5",
3
+ "version": "0.2.1",
4
4
  "description": "SkillHub CLI - 数智凯航技能市场命令行工具",
5
5
  "bin": {
6
6
  "rushangle": "./bin/rushangle.js"
@@ -24,6 +24,7 @@
24
24
  "commander": "^12.0.0",
25
25
  "chalk": "^4.1.2",
26
26
  "open": "^10.0.0",
27
- "cli-table3": "^0.6.5"
27
+ "cli-table3": "^0.6.5",
28
+ "adm-zip": "^0.5.16"
28
29
  }
29
30
  }
package/src/api.js CHANGED
@@ -1,4 +1,6 @@
1
1
  const auth = require('./auth');
2
+ const fs = require('fs');
3
+ const path = require('path');
2
4
 
3
5
  const SITE_URL = process.env.RUSHANGLE_API || 'https://rushangle.cn';
4
6
 
@@ -90,4 +92,58 @@ module.exports = {
90
92
 
91
93
  // Categories (no auth required)
92
94
  getCategories() { return apiCall('GET', '/api/skills/categories'); },
95
+
96
+ // ─── File upload (multipart) ───
97
+ /**
98
+ * Upload skill files via multipart/form-data.
99
+ * @param {string} skillId
100
+ * @param {{fieldName: string, filePath: string}[]} files
101
+ */
102
+ async uploadSkillFiles(skillId, files) {
103
+ const token = auth.getToken();
104
+ const form = new FormData();
105
+ for (const { fieldName, filePath } of files) {
106
+ const content = fs.readFileSync(filePath);
107
+ const blob = new Blob([content], { type: 'application/octet-stream' });
108
+ form.append(fieldName, blob, path.basename(filePath));
109
+ }
110
+
111
+ const res = await fetch(`${SITE_URL}/api/skills/${encodeURIComponent(skillId)}/files`, {
112
+ method: 'POST',
113
+ headers: { 'Authorization': `Bearer ${token}` },
114
+ body: form,
115
+ });
116
+
117
+ const data = await res.json();
118
+ if (!res.ok) {
119
+ const err = new Error(data.error || 'Upload failed');
120
+ err.status = res.status;
121
+ err.data = data;
122
+ throw err;
123
+ }
124
+ return data;
125
+ },
126
+
127
+ /**
128
+ * Download skill files as a Buffer (zip).
129
+ * @param {string} skillId
130
+ * @returns {Promise<{buffer: Buffer, contentType: string}>}
131
+ */
132
+ async downloadSkillFiles(skillId) {
133
+ const token = auth.getToken();
134
+ const res = await fetch(`${SITE_URL}/api/skills/${encodeURIComponent(skillId)}/download`, {
135
+ headers: { 'Authorization': `Bearer ${token}` },
136
+ });
137
+
138
+ if (!res.ok) {
139
+ const data = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
140
+ const err = new Error(data.error || 'Download failed');
141
+ err.status = res.status;
142
+ throw err;
143
+ }
144
+
145
+ const contentType = res.headers.get('content-type') || '';
146
+ const arrayBuffer = await res.arrayBuffer();
147
+ return { buffer: Buffer.from(arrayBuffer), contentType };
148
+ },
93
149
  };
@@ -6,7 +6,7 @@ const os = require('os');
6
6
  const auth = require('../auth');
7
7
  const api = require('../api');
8
8
 
9
- const INSTALL_DIR = path.join(os.homedir(), '.rushangle', 'installed');
9
+ const INSTALL_DIR = path.join(os.homedir(), '.workbuddy', 'skills');
10
10
 
11
11
  module.exports = new Command('install')
12
12
  .description('安装技能/MCP/代码段')
@@ -43,7 +43,10 @@ module.exports = new Command('install')
43
43
  const match = listData.skills.find(
44
44
  s => s.name === nameOrId || s.id === nameOrId
45
45
  );
46
- if (match) item = match;
46
+ if (match) {
47
+ // Re-fetch full data (list data is sanitized)
48
+ item = await api.getSkill(match.id);
49
+ }
47
50
  }
48
51
  }
49
52
 
@@ -52,25 +55,60 @@ module.exports = new Command('install')
52
55
  return;
53
56
  }
54
57
 
55
- // Register install on server FIRST (survives EPERM in sandbox)
58
+ // Register install on server FIRST
56
59
  await api.installSkill(item.id);
57
60
 
58
- // Local file install (best-effort, skip in restricted environments)
61
+ // Download and extract skill files to local skills directory
59
62
  if (!opts.registerOnly) {
60
63
  try {
61
- const installDir = path.join(INSTALL_DIR, 'skills', item.id);
62
- fs.mkdirSync(installDir, { recursive: true });
63
- fs.writeFileSync(
64
- path.join(installDir, 'rushangle.json'),
65
- JSON.stringify({ type: 'skill', ...item, installedAt: new Date().toISOString() }, null, 2)
66
- );
67
- if (item.readme) {
68
- fs.writeFileSync(path.join(installDir, 'README.md'), item.readme);
64
+ // Try to download skill package from server
65
+ let extracted = false;
66
+ try {
67
+ const { buffer } = await api.downloadSkillFiles(item.id);
68
+ const AdmZip = require('adm-zip');
69
+ const zip = new AdmZip(buffer);
70
+ const skillDir = path.join(INSTALL_DIR, item.name);
71
+ zip.extractAllTo(skillDir, true);
72
+ console.log(chalk.green(`✓ 安装成功: ${item.name}@${item.version}`));
73
+ console.log(chalk.gray(` 本地目录: ${skillDir}`));
74
+ extracted = true;
75
+ } catch (downloadErr) {
76
+ // Download failed — fall back to metadata-only install
77
+ if (downloadErr.status === 404) {
78
+ console.log(chalk.yellow(` 该技能暂无文件包,使用元数据模式`));
79
+ } else {
80
+ console.log(chalk.yellow(` 下载文件包失败 (${downloadErr.message}),使用元数据模式`));
81
+ }
82
+ }
83
+
84
+ // Fallback: create SKILL.md from metadata if no files were downloaded
85
+ if (!extracted) {
86
+ const skillDir = path.join(INSTALL_DIR, item.name);
87
+ fs.mkdirSync(skillDir, { recursive: true });
88
+
89
+ // Generate SKILL.md with frontmatter from server metadata
90
+ const frontmatter = [
91
+ '---',
92
+ `name: ${item.name}`,
93
+ `version: ${item.version}`,
94
+ `description: ${item.description || ''}`,
95
+ `category: ${item.category || '其他'}`,
96
+ `tags: [${(item.tags || []).join(', ')}]`,
97
+ `author: ${item.authorName || ''}`,
98
+ `source: ${api.SITE_URL}/skills/${item.id}`,
99
+ '---',
100
+ '',
101
+ item.readme || `# ${item.name}\n\n${item.description || ''}`,
102
+ ].join('\n');
103
+ fs.writeFileSync(path.join(skillDir, 'SKILL.md'), frontmatter);
104
+ fs.writeFileSync(
105
+ path.join(skillDir, 'skill.json'),
106
+ JSON.stringify({ ...item, installedAt: new Date().toISOString() }, null, 2)
107
+ );
108
+ console.log(chalk.green(`✓ 安装成功: ${item.name}@${item.version}`));
109
+ console.log(chalk.gray(` 本地目录: ${skillDir}`));
69
110
  }
70
- console.log(chalk.green(`✓ 安装成功: ${item.name}@${item.version}`));
71
- console.log(chalk.gray(` 本地目录: ${installDir}`));
72
111
  } catch (fileErr) {
73
- // EPERM or other file errors in sandbox — server registration already succeeded
74
112
  console.log(chalk.green(`✓ 服务器注册成功: ${item.name}@${item.version}`));
75
113
  console.log(chalk.yellow(` 本地文件写入跳过(${fileErr.code || fileErr.message})`));
76
114
  if (fileErr.code === 'EPERM' || fileErr.code === 'EACCES') {
@@ -91,7 +129,10 @@ module.exports = new Command('install')
91
129
  const match = listData.items.find(
92
130
  s => s.name === nameOrId || s.id === nameOrId
93
131
  );
94
- if (match) item = match;
132
+ if (match) {
133
+ // Re-fetch full data (list data is sanitized)
134
+ item = await api.getMcp(match.id);
135
+ }
95
136
  }
96
137
  }
97
138
 
@@ -145,7 +186,10 @@ module.exports = new Command('install')
145
186
  const match = listData.items.find(
146
187
  s => s.name === nameOrId || s.id === nameOrId
147
188
  );
148
- if (match) item = match;
189
+ if (match) {
190
+ // Re-fetch full data (list data is sanitized)
191
+ item = await api.getCode(match.id);
192
+ }
149
193
  }
150
194
  }
151
195
 
@@ -133,6 +133,41 @@ module.exports = new Command('publish')
133
133
  result = await api.publishSkill({
134
134
  name, version, description, category, tags, readme,
135
135
  });
136
+
137
+ // Upload skill files (SKILL.md + scripts)
138
+ try {
139
+ const uploadFiles = [];
140
+
141
+ // Look for SKILL.md
142
+ for (const f of ['SKILL.md', 'skill.md', 'Skill.md']) {
143
+ const fp = path.join(absDir, f);
144
+ if (fs.existsSync(fp)) {
145
+ uploadFiles.push({ fieldName: 'skill_md', filePath: fp });
146
+ break;
147
+ }
148
+ }
149
+
150
+ // Look for script files in scripts/ directory
151
+ const scriptsDir = path.join(absDir, 'scripts');
152
+ if (fs.existsSync(scriptsDir) && fs.statSync(scriptsDir).isDirectory()) {
153
+ const scriptEntries = fs.readdirSync(scriptsDir);
154
+ for (const entry of scriptEntries) {
155
+ const fp = path.join(scriptsDir, entry);
156
+ if (fs.statSync(fp).isFile()) {
157
+ uploadFiles.push({ fieldName: 'scripts', filePath: fp });
158
+ }
159
+ }
160
+ }
161
+
162
+ if (uploadFiles.length > 0) {
163
+ console.log(chalk.gray(` 上传 ${uploadFiles.length} 个文件...`));
164
+ await api.uploadSkillFiles(result.id, uploadFiles);
165
+ console.log(chalk.green(` ✓ 文件上传完成`));
166
+ }
167
+ } catch (uploadErr) {
168
+ console.log(chalk.yellow(` ⚠ 文件上传失败: ${uploadErr.message}`));
169
+ console.log(chalk.gray(` 技能元数据已发布,可通过 rushangle publish --skip-files 跳过文件上传`));
170
+ }
136
171
  } else if (type === 'mcp') {
137
172
  let code = '';
138
173
  let config = null;