@aiform/cli-platform 0.1.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 ADDED
@@ -0,0 +1,64 @@
1
+ # @aiform/cli-platform
2
+
3
+ Hive 平台维护 CLI(`hive-platform`)—— 通过 hive **platform** 服务的 HTTP API 管理租户、平台管理员、岗位、技能、new-api 同步队列。不直连数据库。
4
+
5
+ 租户管理员视角的账号/节点动作在另一个包:[`@aiform/cli-workspace`](https://www.npmjs.com/package/@aiform/cli-workspace)。
6
+
7
+ ## 安装
8
+
9
+ ```bash
10
+ npm i -g @aiform/cli-platform # 或 pnpm add -g / npx @aiform/cli-platform
11
+ ```
12
+
13
+ ## 配置
14
+
15
+ | 选项 | 环境变量 | 默认 | 用途 |
16
+ |---|---|---|---|
17
+ | `--url <url>` | `PLATFORM_URL` | `http://localhost:3100`(本地 dev) | platform base URL |
18
+ | `--email <email>` | `HIVE_ADMIN_EMAIL` | —(多数命令必填) | 登录用 platform admin email |
19
+ | `--password <pw>` | `HIVE_ADMIN_PASSWORD` | 无则交互输入 | 登录密码 |
20
+ | `--bootstrap-token <t>` | `PLATFORM_BOOTSTRAP_TOKEN` | — | **仅** `platform admin add` 用(首位 admin 还不存在、无 session 时) |
21
+
22
+ 线上 platform:`https://platform.hiv.aiform.com`(对应 workspace:`https://app.hiv.aiform.com`)。
23
+ `PLATFORM_BOOTSTRAP_TOKEN` 也要配在 platform 服务端(启用 `POST /api/platform-admins`)。
24
+
25
+ ## 子命令
26
+
27
+ ```bash
28
+ export PLATFORM_URL=https://platform.hiv.aiform.com # 本地 dev 省略,默认 http://localhost:3100
29
+
30
+ # 引导首位 platform 管理员(不需要登录,用 bootstrap token)
31
+ PLATFORM_BOOTSTRAP_TOKEN=... hive-platform platform admin add admin@aiform.com
32
+
33
+ # 之后用该 admin 登录做其它操作
34
+ export HIVE_ADMIN_EMAIL=admin@aiform.com HIVE_ADMIN_PASSWORD=...
35
+
36
+ # 租户(POST/GET /api/tenants)
37
+ hive-platform tenant add 'Acme Intelligence' --handle acme.co --plan pro --owner maya@acme.co --seats 50 --logo https://cdn.acme.co/logo.png
38
+ hive-platform tenant list
39
+
40
+ # 代某租户管理 workspace 成员(POST/GET /api/tenants/:id/users)——引导租户首位成员等
41
+ hive-platform tenant user add maya@acme.co --tenant acme.co --password 'init-pw-123'
42
+ hive-platform tenant user list --tenant acme.co
43
+
44
+ # new-api 同步队列(POST /api/newapi-outbox/drain、GET /api/newapi-outbox、POST /api/newapi-outbox/:id/requeue)
45
+ hive-platform outbox drain
46
+ hive-platform outbox list --pending
47
+ hive-platform outbox list --dead
48
+ hive-platform outbox requeue <id>
49
+
50
+ # 岗位(/api/positions*)
51
+ hive-platform position add 'Data Analyst' --code DATA_ANALYST --capability sql --skill <skillId> --publish
52
+ hive-platform position list --status published
53
+ hive-platform position publish <positionId>
54
+ hive-platform position archive <positionId>
55
+ hive-platform position enable <positionId> --tenant acme.co
56
+ hive-platform position disable <positionId> --tenant acme.co
57
+
58
+ # 技能发布(多文件文件夹 → POST /api/skills + /versions)
59
+ hive-platform skill publish ./my-skill-folder --name my_skill --version 1.2.0 --label 'My Skill'
60
+ ```
61
+
62
+ ## 退码
63
+
64
+ `0` 成功;`1` API/网络错误;`2` 参数错误;`130` 交互输入被 Ctrl-C 取消。
@@ -0,0 +1,562 @@
1
+ #!/usr/bin/env node
2
+ // hive-platform — 平台维护人员 CLI(租户 / 平台管理员 / 岗位 / 技能 / new-api 同步)
3
+ //
4
+ // 通过 platform 的 HTTP API 操作(不直连 DB)。多数命令以 platform admin 身份登录;
5
+ // `platform admin add` 例外 —— 走 PLATFORM_BOOTSTRAP_TOKEN(首位 admin 还不存在时无 session 可用)。
6
+ //
7
+ // 全局选项(root 上):
8
+ // --url <url> platform base URL(默认 PLATFORM_URL,否则 http://localhost:3100)
9
+ // --email <email> 登录 email(默认 HIVE_ADMIN_EMAIL)
10
+ // --password <pw> 登录密码(默认 HIVE_ADMIN_PASSWORD,否则交互输入)
11
+ // --bootstrap-token <t> platform admin add 用(默认 PLATFORM_BOOTSTRAP_TOKEN)
12
+ //
13
+ // 子命令:
14
+ // tenant add <name> --handle <h> [--plan ..] [--status ..] [--owner ..] [--seats N] ...
15
+ // tenant list
16
+ // tenant topup <handle> --credits N --reason prepaid|renewal|gift|correction [--note ..]
17
+ // platform admin add <email> [--password <pw>] [--name <displayName>]
18
+ // outbox drain | outbox list [--limit N] [--pending] [--dead] | outbox requeue <id>
19
+ // position add <name> --code <code> [--emoji] [--color] [--summary] [--category]
20
+ // [--base-model] [--rubric ..]* [--capability ..]* [--skill ..]* [--publish]
21
+ // position list [--status draft|published|archived]
22
+ // position publish <id> | position archive <id>
23
+ // position enable|disable <positionId> --tenant <handle>
24
+ // skill publish <folder> --version <semver> [--name <name>] [--label] [--description] ...
25
+ // (name 默认从 <folder>/SKILL.md frontmatter 解析;--name 为可选覆盖,不一致会报错)
26
+ //
27
+ // 租户管理员动作(建账号 / 节点)见 `hive-workspace`。
28
+
29
+ import { promises as fsp } from 'node:fs';
30
+ import path from 'node:path';
31
+ import { Command } from 'commander';
32
+ import { ApiClient, resolveBaseUrl, resolveCreds, runAction, promptHidden, die } from '@aiform/cli-core';
33
+
34
+ const TAG = 'hive-platform';
35
+
36
+ const program = new Command();
37
+ program
38
+ .name('hive-platform')
39
+ .description('hive 平台维护 CLI(租户 / 平台管理员 / 岗位 / 技能 / new-api 同步)— 通过 platform HTTP API 操作')
40
+ .option('--url <url>', 'platform base URL(默认 PLATFORM_URL env 或 http://localhost:3100)')
41
+ .option('--email <email>', '登录 email(默认 HIVE_ADMIN_EMAIL env)')
42
+ .option('--password <pw>', '登录密码(默认 HIVE_ADMIN_PASSWORD env,否则交互输入)')
43
+ .option('--bootstrap-token <token>', 'platform admin add 用的 bootstrap token(默认 PLATFORM_BOOTSTRAP_TOKEN env)')
44
+ // 用 --cli-version 而不是 --version:让 `skill publish --version <semver>` 不被全局版本标记吞掉。
45
+ .version('0.1.0', '-V, --cli-version', '显示 CLI 版本');
46
+
47
+ function baseUrl() {
48
+ return resolveBaseUrl(program.opts().url, 'PLATFORM_URL', 'http://localhost:3100');
49
+ }
50
+
51
+ /** 取一个已登录的 ApiClient(platform admin session)。 */
52
+ async function client() {
53
+ const creds = await resolveCreds(program.opts(), {
54
+ tag: TAG,
55
+ emailEnv: 'HIVE_ADMIN_EMAIL',
56
+ passwordEnv: 'HIVE_ADMIN_PASSWORD',
57
+ });
58
+ const c = new ApiClient(baseUrl());
59
+ await c.login(creds);
60
+ return c;
61
+ }
62
+
63
+ function parseRepeat(value, prev = []) {
64
+ return prev.concat([value]);
65
+ }
66
+
67
+ // ── 租户 ─────────────────────────────────────────────────────────────
68
+
69
+ const tenantCmd = program.command('tenant').description('租户管理');
70
+
71
+ tenantCmd
72
+ .command('add <name>')
73
+ .description('创建租户(= platform UI /tenants/new;同步触发 new-api provision)')
74
+ .requiredOption('--handle <handle>', '租户 handle,如 acme.co')
75
+ .option('--plan <plan>', '套餐:enterprise|pro|standard|trial', 'standard')
76
+ .option('--status <status>', '初始状态:active|trial|suspended', 'active')
77
+ .option('--seats <n>', '席位上限', '0')
78
+ .option('--spend-limit <n>', '月度花费上限(¥)', '0')
79
+ .option('--token-cap <n>', 'Token 上限(M)', '0')
80
+ .option('--owner <email>', '主管理员邮箱')
81
+ .option('--notes <text>', '备注')
82
+ .option('--logo <url>', '徽标 URL(http/https 直链)')
83
+ .action(runAction(TAG, async (name, opts) => {
84
+ const c = await client();
85
+ const r = await c.post('/api/tenants', {
86
+ name,
87
+ handle: opts.handle,
88
+ plan: opts.plan,
89
+ status: opts.status,
90
+ seats: Number.parseInt(opts.seats, 10) || 0,
91
+ spendLimit: Number.parseFloat(opts.spendLimit) || 0,
92
+ tokenCap: Number.parseFloat(opts.tokenCap) || 0,
93
+ ownerEmail: opts.owner || null,
94
+ adminEmails: opts.owner ? [opts.owner] : [],
95
+ notes: opts.notes || null,
96
+ logoUrl: opts.logo || null,
97
+ });
98
+ const t = r.tenant;
99
+ console.log(`[${TAG}] tenant added id=${t.id} handle=${t.handle} plan=${t.plan} status=${t.status}`);
100
+ const p = r.provisioning;
101
+ if (p) {
102
+ if (p.status === 'ok') {
103
+ console.log(`[${TAG}] new-api 子账号已创建 (newapi_user_id=${p.newapiUserId ?? '?'})`);
104
+ } else {
105
+ console.warn(`[${TAG}] new-api provision ${p.status}: ${p.errorCode ?? ''} — ${p.errorMessage ?? ''}`);
106
+ console.warn(`[${TAG}] tenant 行已落库;修好后 \`hive-platform outbox drain\` 重试,或在 platform UI 详情页"立即 provision"`);
107
+ }
108
+ }
109
+ }));
110
+
111
+ tenantCmd
112
+ .command('list')
113
+ .description('列出所有租户')
114
+ .action(runAction(TAG, async () => {
115
+ const c = await client();
116
+ const r = await c.get('/api/tenants');
117
+ console.table(r.tenants);
118
+ }));
119
+
120
+ tenantCmd
121
+ .command('topup <handle>')
122
+ .description('给租户充值 new-api 额度(= spend_limit 增量;提交后同步 quota)')
123
+ .requiredOption('--credits <n>', '充值 credits(正整数)')
124
+ .requiredOption('--reason <reason>', '原因:prepaid|renewal|gift|correction')
125
+ .option('--note <note>', '备注(选填)')
126
+ .action(runAction(TAG, async (handle, opts) => {
127
+ // 本地早校验(client() 之前,无需登录即可拦明显错误);端点 Zod 才是权威。
128
+ const credits = Number.parseInt(opts.credits, 10);
129
+ if (!Number.isInteger(credits) || credits <= 0) die(TAG, 'credits 须为正整数', 2);
130
+ const REASONS = ['prepaid', 'renewal', 'gift', 'correction'];
131
+ if (!REASONS.includes(opts.reason)) die(TAG, `reason 须为 ${REASONS.join('|')}`, 2);
132
+ const c = await client();
133
+ const tenantId = await resolveTenantId(c, handle);
134
+ const r = await c.post(`/api/tenants/${encodeURIComponent(tenantId)}/newapi/topup`, {
135
+ credits,
136
+ reason: opts.reason,
137
+ note: opts.note ?? null,
138
+ });
139
+ const usd = typeof r.deltaUsd === 'number' ? r.deltaUsd.toFixed(2) : r.deltaUsd;
140
+ console.log(
141
+ `[${TAG}] topup ok: +${credits} credits(≈$${usd})新总额 $${r.spendLimitAfter} synced=${r.synced} ledger=${r.ledgerId}` +
142
+ (r.message ? ` — ${r.message}` : ''),
143
+ );
144
+ }));
145
+
146
+ // 平台代某租户管理 workspace 成员(POST/GET /api/tenants/[id]/users)。
147
+ // 引导租户首位成员、运营帮租户加人时用;租户管理员自助加人走 `hive-workspace user add`。
148
+ const tenantUserCmd = tenantCmd.command('user').description('租户成员(平台代操作)');
149
+
150
+ tenantUserCmd
151
+ .command('add <email>')
152
+ .description('在指定租户下新增 workspace 成员(平台代建)')
153
+ .requiredOption('--tenant <handle>', '目标租户的 handle')
154
+ .option('--password <pw>', '初始密码(≥8 位;不传则交互输入)')
155
+ .option('--slug <slug>', 'user_slug(默认按 email 自动生成并去重)')
156
+ .action(runAction(TAG, async (email, opts) => {
157
+ let pw = opts.password;
158
+ if (!pw) {
159
+ try {
160
+ pw = await promptHidden('member password: ');
161
+ } catch {
162
+ die(TAG, 'aborted', 130);
163
+ }
164
+ }
165
+ if (!pw) die(TAG, 'password 不能为空', 2);
166
+ const c = await client();
167
+ const tenantId = await resolveTenantId(c, opts.tenant);
168
+ const r = await c.post(`/api/tenants/${encodeURIComponent(tenantId)}/users`, {
169
+ email,
170
+ password: pw,
171
+ slug: opts.slug,
172
+ });
173
+ const u = r.user;
174
+ console.log(`[${TAG}] member added id=${u.id} email=${u.email} slug=${u.userSlug} tenant=${String(opts.tenant).toLowerCase()}`);
175
+ }));
176
+
177
+ tenantUserCmd
178
+ .command('list')
179
+ .description('列出指定租户的成员')
180
+ .requiredOption('--tenant <handle>', '目标租户的 handle')
181
+ .action(runAction(TAG, async (opts) => {
182
+ const c = await client();
183
+ const tenantId = await resolveTenantId(c, opts.tenant);
184
+ const r = await c.get(`/api/tenants/${encodeURIComponent(tenantId)}/users`);
185
+ console.table(r.users);
186
+ }));
187
+
188
+ // ── Platform 管理员(bootstrap token 通道)──────────────────────────
189
+
190
+ const platformCmd = program.command('platform').description('Platform 管控台运维');
191
+ const platformAdminCmd = platformCmd.command('admin').description('Platform 管理员');
192
+
193
+ platformAdminCmd
194
+ .command('add <email>')
195
+ .description('引导新建 platform 管理员(用 --bootstrap-token / PLATFORM_BOOTSTRAP_TOKEN,不需要登录)')
196
+ .option('--password <pw>', '密码(≥8 位;不传则交互输入)')
197
+ .option('--name <displayName>', '展示名')
198
+ .action(runAction(TAG, async (email, opts) => {
199
+ const token = program.opts().bootstrapToken || process.env.PLATFORM_BOOTSTRAP_TOKEN;
200
+ if (!token) die(TAG, '--bootstrap-token 或环境变量 PLATFORM_BOOTSTRAP_TOKEN 必填', 2);
201
+ let pw = opts.password;
202
+ if (!pw) {
203
+ try {
204
+ pw = await promptHidden('platform admin password: ');
205
+ } catch {
206
+ die(TAG, 'aborted', 130);
207
+ }
208
+ }
209
+ if (!pw) die(TAG, 'password 不能为空', 2);
210
+
211
+ const c = new ApiClient(baseUrl());
212
+ const r = await c.post(
213
+ '/api/platform-admins',
214
+ { email, password: pw, displayName: opts.name || null },
215
+ { headers: { Authorization: `Bearer ${token}` } },
216
+ );
217
+ const a = r.admin;
218
+ console.log(`[${TAG}] platform admin added id=${a.id} email=${a.email}`);
219
+ }));
220
+
221
+ // ── new-api outbox ──────────────────────────────────────────────────
222
+
223
+ const outboxCmd = program.command('outbox').description('new-api 同步队列');
224
+
225
+ outboxCmd
226
+ .command('drain')
227
+ .description('手动 drain 一批 outbox 行')
228
+ .action(runAction(TAG, async () => {
229
+ const c = await client();
230
+ const r = await c.post('/api/newapi-outbox/drain');
231
+ const s = r.stats || {};
232
+ console.log(`[${TAG}] outbox drain: picked=${s.picked ?? '?'} succeeded=${s.succeeded ?? '?'} failed=${s.failed ?? '?'} dead=${s.dead ?? '?'}`);
233
+ if (s.dead > 0) console.log(`[${TAG}] note: ${s.dead} 条进死信;\`outbox list --dead\` 查看,\`outbox requeue <id>\` 复活`);
234
+ }));
235
+
236
+ outboxCmd
237
+ .command('list')
238
+ .description('列出最近 outbox 行')
239
+ .option('--limit <n>', '行数上限', '50')
240
+ .option('--pending', '仅未完成')
241
+ .option('--dead', '仅死信(attempts >= 12 且 done_at 已设)')
242
+ .action(runAction(TAG, async (opts) => {
243
+ const c = await client();
244
+ const r = await c.get('/api/newapi-outbox', {
245
+ query: { limit: opts.limit, pending: opts.pending, dead: opts.dead },
246
+ });
247
+ console.table(r.rows);
248
+ }));
249
+
250
+ outboxCmd
251
+ .command('requeue <id>')
252
+ .description('把指定死信行重置回待办')
253
+ .action(runAction(TAG, async (id) => {
254
+ const numId = Number(id);
255
+ if (!Number.isInteger(numId) || numId <= 0) die(TAG, `id 必须为正整数: ${id}`, 2);
256
+ const c = await client();
257
+ await c.post(`/api/newapi-outbox/${numId}/requeue`);
258
+ console.log(`[${TAG}] outbox row ${numId} requeued`);
259
+ }));
260
+
261
+ // ── 岗位 ─────────────────────────────────────────────────────────────
262
+
263
+ const positionCmd = program.command('position').description('岗位管理');
264
+
265
+ positionCmd
266
+ .command('add <name>')
267
+ .description('创建岗位草稿(--publish 则创建后立即发布)')
268
+ .requiredOption('--code <code>', '岗位代码(A-Z 0-9 _ -),全平台唯一')
269
+ .option('--emoji <emoji>', 'emoji 标识')
270
+ .option('--color <color>', 'hex 颜色,如 #2a42ff')
271
+ .option('--summary <text>', '简介')
272
+ .option('--category <cat>', '分类')
273
+ .option('--base-model <model>', '默认模型')
274
+ .option('--rubric <text>', '交付准则(可重复)', parseRepeat, [])
275
+ .option('--capability <tag>', '能力 tag(可重复)', parseRepeat, [])
276
+ .option('--skill <id>', '挂载的 skill id(可重复)', parseRepeat, [])
277
+ .option('--publish', '创建后立即发布', false)
278
+ .action(runAction(TAG, async (name, opts) => {
279
+ const c = await client();
280
+ const r = await c.post('/api/positions', {
281
+ name,
282
+ code: opts.code,
283
+ emoji: opts.emoji || null,
284
+ color: opts.color || null,
285
+ summary: opts.summary || null,
286
+ category: opts.category || null,
287
+ baseModel: opts.baseModel || null,
288
+ rubric: opts.rubric,
289
+ capabilities: opts.capability,
290
+ skillIds: opts.skill,
291
+ });
292
+ const p = r.position;
293
+ let status = p.status;
294
+ if (opts.publish) {
295
+ await c.post(`/api/positions/${encodeURIComponent(p.id)}/publish`);
296
+ status = 'published';
297
+ }
298
+ console.log(`[${TAG}] position added id=${p.id} code=${p.code} status=${status}`);
299
+ }));
300
+
301
+ positionCmd
302
+ .command('list')
303
+ .description('列出岗位')
304
+ .option('--status <status>', '过滤 draft|published|archived')
305
+ .action(runAction(TAG, async (opts) => {
306
+ if (opts.status && !['draft', 'published', 'archived'].includes(opts.status)) {
307
+ die(TAG, `status 不合法: ${opts.status}`, 2);
308
+ }
309
+ const c = await client();
310
+ const r = await c.get('/api/positions');
311
+ const rows = opts.status ? r.positions.filter((p) => p.status === opts.status) : r.positions;
312
+ console.table(rows);
313
+ }));
314
+
315
+ positionCmd
316
+ .command('publish <id>')
317
+ .description('草稿 → 已发布')
318
+ .action(runAction(TAG, async (id) => {
319
+ const c = await client();
320
+ const r = await c.post(`/api/positions/${encodeURIComponent(id)}/publish`);
321
+ console.log(`[${TAG}] published ${id}${r.position?.code ? ` (code=${r.position.code})` : ''}`);
322
+ }));
323
+
324
+ positionCmd
325
+ .command('archive <id>')
326
+ .description('任意状态 → 已归档(级联取消所有租户启用)')
327
+ .action(runAction(TAG, async (id) => {
328
+ const c = await client();
329
+ const r = await c.post(`/api/positions/${encodeURIComponent(id)}/archive`);
330
+ console.log(`[${TAG}] archived ${id}${r.position?.code ? ` (code=${r.position.code})` : ''}`);
331
+ }));
332
+
333
+ /** handle → tenantId(用 GET /api/tenants)。 */
334
+ async function resolveTenantId(c, handle) {
335
+ const r = await c.get('/api/tenants');
336
+ const t = (r.tenants || []).find((x) => x.handle === String(handle).toLowerCase());
337
+ if (!t) die(TAG, `tenant not found by handle: ${handle}`, 2);
338
+ return t.id;
339
+ }
340
+
341
+ positionCmd
342
+ .command('enable <positionId>')
343
+ .description('代某租户启用此岗位')
344
+ .requiredOption('--tenant <handle>', '目标租户的 handle')
345
+ .action(runAction(TAG, async (positionId, opts) => {
346
+ const c = await client();
347
+ const tenantId = await resolveTenantId(c, opts.tenant);
348
+ await c.post(`/api/positions/${encodeURIComponent(positionId)}/tenants`, { tenantId, enabled: true });
349
+ console.log(`[${TAG}] enabled position=${positionId} for tenant=${String(opts.tenant).toLowerCase()}`);
350
+ }));
351
+
352
+ positionCmd
353
+ .command('disable <positionId>')
354
+ .description('代某租户关闭此岗位')
355
+ .requiredOption('--tenant <handle>', '目标租户的 handle')
356
+ .action(runAction(TAG, async (positionId, opts) => {
357
+ const c = await client();
358
+ const tenantId = await resolveTenantId(c, opts.tenant);
359
+ await c.post(`/api/positions/${encodeURIComponent(positionId)}/tenants`, { tenantId, enabled: false });
360
+ console.log(`[${TAG}] disabled position=${positionId} for tenant=${String(opts.tenant).toLowerCase()}`);
361
+ }));
362
+
363
+ // ── 技能发布(多文件 → POST /api/skills + /versions)────────────────
364
+
365
+ const skillCmd = program.command('skill').description('平台技能管理');
366
+
367
+ const SKILL_IGNORE = new Set(['.DS_Store', '.git', '.svn', '.hg', 'node_modules', '.idea', '.vscode', 'Thumbs.db']);
368
+ const SKILL_FILE_MAX_BYTES = 262_144; // 256 KB
369
+ const SKILL_VERSION_MAX_FILES = 50;
370
+ // 段内禁字符:NUL+控制字符 + Windows 保留 (<>:"\|?*)。`:` 顺带挡掉 C: 盘符。
371
+ const FORBIDDEN_SKILL_PATH_SEG_CHARS = /[<>:"\\|?*\x00-\x1f\x7f]/;
372
+ const SEMVER_RE =
373
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
374
+
375
+ /** 与 platform/src/lib/server/skills-validation.ts:isValidSkillFilePath 同步。 */
376
+ function isValidSkillFilePath(s) {
377
+ if (typeof s !== 'string') return false;
378
+ if (s.length === 0 || s.length > 200) return false;
379
+ if (s.startsWith('/')) return false;
380
+ for (const seg of s.split('/')) {
381
+ if (seg.length === 0) return false;
382
+ if (seg === '.' || seg === '..') return false;
383
+ if (seg !== seg.trim()) return false;
384
+ if (FORBIDDEN_SKILL_PATH_SEG_CHARS.test(seg)) return false;
385
+ }
386
+ return true;
387
+ }
388
+
389
+ /** raw Buffer 当文本读:含 NUL 或非法 UTF-8 → null(视为二进制,跳过)。 */
390
+ function decodeAsText(buf) {
391
+ if (buf.includes(0)) return null;
392
+ try {
393
+ return new TextDecoder('utf-8', { fatal: true }).decode(buf);
394
+ } catch {
395
+ return null;
396
+ }
397
+ }
398
+
399
+ function fmtBytes(n) {
400
+ if (n < 1024) return `${n}B`;
401
+ return `${(n / 1024).toFixed(1)}KB`;
402
+ }
403
+
404
+ /** 与 platform/src/lib/server/skills-validation.ts:SKILL_NAME_RE 同步(kebab-case)。 */
405
+ const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
406
+
407
+ /** 解析 SKILL.md 顶部 YAML frontmatter 的顶层标量键(name/description 等)。
408
+ * 极简零依赖实现:只取首个 `---`…`---` 块内的 `key: value`,去引号;缩进/列表等非标量行忽略。
409
+ * 无 frontmatter → 返回 {}。 */
410
+ function parseSkillFrontmatter(text) {
411
+ if (typeof text !== 'string') return {};
412
+ const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; // 剥前导 BOM
413
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/.exec(body);
414
+ if (!m) return {};
415
+ const out = {};
416
+ for (const line of m[1].split(/\r?\n/)) {
417
+ if (!line.trim() || line.trimStart().startsWith('#')) continue;
418
+ if (/^\s/.test(line) || line.trimStart().startsWith('-')) continue; // 跳过嵌套 / 列表项
419
+ const kv = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line);
420
+ if (!kv) continue;
421
+ let val = kv[2].trim();
422
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
423
+ val = val.slice(1, -1);
424
+ }
425
+ out[kv[1]] = val;
426
+ }
427
+ return out;
428
+ }
429
+
430
+ /** 递归读 folder → { files: [{path, content_text, byte_size}], manifest, skipped }。 */
431
+ async function readSkillFolder(folder) {
432
+ const stat = await fsp.stat(folder).catch(() => null);
433
+ if (!stat || !stat.isDirectory()) throw new Error(`folder not found or not a directory: ${folder}`);
434
+ const entries = await fsp.readdir(folder, { recursive: true, withFileTypes: true });
435
+ const files = [];
436
+ const skipped = [];
437
+ let manifest = null;
438
+ for (const ent of entries) {
439
+ if (!ent.isFile()) continue;
440
+ if (SKILL_IGNORE.has(ent.name)) continue;
441
+ const parent = ent.parentPath ?? ent.path ?? folder;
442
+ const full = path.join(parent, ent.name);
443
+ const rel = path.relative(folder, full).split(path.sep).join('/');
444
+ if (rel.split('/').some((seg) => SKILL_IGNORE.has(seg))) continue;
445
+
446
+ const buf = await fsp.readFile(full);
447
+ const byteSize = buf.byteLength;
448
+ const content = decodeAsText(buf);
449
+
450
+ if (rel === 'manifest.json') {
451
+ if (content === null) throw new Error('manifest.json is not valid UTF-8 text');
452
+ try {
453
+ manifest = JSON.parse(content);
454
+ if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) {
455
+ throw new Error('manifest.json must be a JSON object');
456
+ }
457
+ } catch (err) {
458
+ throw new Error(`manifest.json parse error: ${err.message}`);
459
+ }
460
+ continue;
461
+ }
462
+
463
+ if (!isValidSkillFilePath(rel)) {
464
+ throw new Error(`invalid file path '${rel}' (绝对路径、空段、./.. 段、控制字符或 Windows 保留字符)`);
465
+ }
466
+ if (content === null) {
467
+ skipped.push({ path: rel, reason: 'binary', bytes: byteSize });
468
+ continue;
469
+ }
470
+ if (byteSize > SKILL_FILE_MAX_BYTES) {
471
+ skipped.push({ path: rel, reason: 'oversize', bytes: byteSize });
472
+ continue;
473
+ }
474
+ files.push({ path: rel, content_text: content, byte_size: byteSize });
475
+ }
476
+
477
+ if (files.length === 0) throw new Error(`no eligible files in ${folder}`);
478
+ if (files.length > SKILL_VERSION_MAX_FILES) throw new Error(`${files.length} files exceeds limit ${SKILL_VERSION_MAX_FILES}`);
479
+ return { files, manifest: manifest ?? {}, skipped };
480
+ }
481
+
482
+ skillCmd
483
+ .command('publish <folder>')
484
+ .description('把文件夹作为新版本发布到 platform')
485
+ .option('--name <name>', '技能 name(默认从 SKILL.md frontmatter 的 name 解析;kebab-case,全局唯一;与 frontmatter 不一致会报错)')
486
+ .requiredOption('--version <semver>', '版本号(合法 SemVer 2.0)')
487
+ .option('--label <text>', '展示名(仅新建时生效)')
488
+ .option('--description <text>', '简介(仅新建时生效)')
489
+ .option('--category <cat>', '分类(仅新建时生效)', 'core')
490
+ .option('--cert <level>', '认证等级(official|certified|community|deprecated;仅新建时生效)', 'official')
491
+ .action(runAction(TAG, async (folder, opts) => {
492
+ if (!SEMVER_RE.test(opts.version)) die(TAG, `invalid semver: ${opts.version}`, 2);
493
+
494
+ const url = baseUrl();
495
+ console.log(`[${TAG}] platform = ${url}`);
496
+ console.log(`[${TAG}] reading folder ${folder}`);
497
+ const { files, manifest, skipped } = await readSkillFolder(folder);
498
+
499
+ if (skipped.length > 0) {
500
+ console.warn(`[${TAG}] skipped ${skipped.length} file(s) — platform 只接受 UTF-8 文本,单文件 ≤ ${fmtBytes(SKILL_FILE_MAX_BYTES)}:`);
501
+ for (const s of skipped) {
502
+ const why = s.reason === 'binary' ? 'binary (NUL/非 UTF-8)' : `oversize (${fmtBytes(s.bytes)})`;
503
+ console.warn(` - ${s.path} [${why}]`);
504
+ }
505
+ }
506
+ const totalBytes = files.reduce((a, f) => a + f.byte_size, 0);
507
+ console.log(`[${TAG}] found ${files.length} files: ${files.map((f) => `${f.path} (${fmtBytes(f.byte_size)})`).join(', ')}`);
508
+ if (manifest && Object.keys(manifest).length > 0) console.log(`[${TAG}] manifest.json found, will use as version manifest`);
509
+
510
+ // Agent Skills 规范(https://agentskills.io/specification):技能根目录必须有 SKILL.md,
511
+ // frontmatter 必填 name(与技能名一致)+ description;--name 为可选覆盖,不一致则报错。
512
+ const skillMd = files.find((f) => f.path === 'SKILL.md');
513
+ if (!skillMd) die(TAG, '缺少 SKILL.md:规范要求技能根目录必须有 SKILL.md(见 https://agentskills.io/specification)', 2);
514
+ const fm = parseSkillFrontmatter(skillMd.content_text);
515
+ const fmName = typeof fm.name === 'string' ? fm.name.trim().toLowerCase() : '';
516
+ const cliName = opts.name ? String(opts.name).trim().toLowerCase() : '';
517
+ if (!fmName) die(TAG, 'SKILL.md frontmatter 缺少 name', 2);
518
+ if (cliName && fmName !== cliName) {
519
+ die(TAG, `name 冲突:SKILL.md frontmatter name='${fmName}' 与 --name='${cliName}' 不一致,请删掉 --name 或改成一致`, 2);
520
+ }
521
+ const lookupName = fmName;
522
+ if (lookupName.length > 64 || !SKILL_NAME_RE.test(lookupName)) {
523
+ die(TAG, `invalid name '${lookupName}':需 1–64 kebab-case(小写字母数字连字符,不能前导/末尾/连续连字符)`, 2);
524
+ }
525
+ const fmDesc = typeof fm.description === 'string' ? fm.description.trim() : '';
526
+ if (!fmDesc) die(TAG, 'SKILL.md frontmatter 缺少 description(规范要求必填、非空)', 2);
527
+ if (fmDesc.length > 1024) die(TAG, `description 超长(${fmDesc.length} > 1024)`, 2);
528
+ console.log(`[${TAG}] skill name = ${lookupName} (来源:SKILL.md frontmatter)`);
529
+
530
+ const c = await client();
531
+ console.log(`[${TAG}] login OK`);
532
+
533
+ const listed = await c.get('/api/skills');
534
+ let skill = (listed.skills || []).find((s) => s.name === lookupName) ?? null;
535
+ if (!skill) {
536
+ console.log(`[${TAG}] skill '${lookupName}' not found, creating ...`);
537
+ const created = await c.post('/api/skills', {
538
+ name: lookupName,
539
+ label: opts.label || lookupName,
540
+ description: opts.description || fmDesc || null,
541
+ category: opts.category,
542
+ cert: opts.cert,
543
+ });
544
+ skill = created.skill;
545
+ console.log(`[${TAG}] skill created (id=${skill.id})`);
546
+ } else {
547
+ console.log(`[${TAG}] skill '${lookupName}' exists (id=${skill.id}), reusing`);
548
+ }
549
+
550
+ console.log(`[${TAG}] publishing version ${opts.version} (${files.length} files, ${fmtBytes(totalBytes)} total) ...`);
551
+ const result = await c.post(`/api/skills/${encodeURIComponent(skill.id)}/versions`, {
552
+ version: opts.version,
553
+ files: files.map((f) => ({ path: f.path, content_text: f.content_text })),
554
+ manifest,
555
+ });
556
+ console.log(`[${TAG}] version published ✅`);
557
+ console.log(` version_id: ${result.version.id}`);
558
+ console.log(` content_hash: ${result.version.contentHash}`);
559
+ console.log(` beta_pointer_updated: ${result.betaPointerUpdated}`);
560
+ }));
561
+
562
+ program.parseAsync(process.argv);
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@aiform/cli-platform",
3
+ "version": "0.1.0",
4
+ "description": "hive 平台维护 CLI — 通过 platform HTTP API 管理租户/平台管理员/岗位/技能/new-api 同步",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "hive-platform": "./bin/hive-platform.mjs"
9
+ },
10
+ "files": [
11
+ "bin"
12
+ ],
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "dependencies": {
20
+ "commander": "^14.0.3",
21
+ "@aiform/cli-core": "^0.1.0"
22
+ }
23
+ }