rushangle-cli 0.1.5 → 0.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/package.json +3 -2
- package/src/api.js +56 -0
- package/src/commands/install.js +53 -15
- package/src/commands/publish.js +35 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rushangle-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
};
|
package/src/commands/install.js
CHANGED
|
@@ -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(), '.
|
|
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)
|
|
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
|
|
58
|
+
// Register install on server FIRST
|
|
56
59
|
await api.installSkill(item.id);
|
|
57
60
|
|
|
58
|
-
//
|
|
61
|
+
// Download and extract skill files to local skills directory
|
|
59
62
|
if (!opts.registerOnly) {
|
|
60
63
|
try {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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') {
|
package/src/commands/publish.js
CHANGED
|
@@ -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;
|