@xinhe-oa/cli 0.0.0-stage → 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/README.md +159 -2
- package/bin/xinhe-oa-cli.js +13 -0
- package/package.json +40 -4
- package/src/commands/application.js +244 -0
- package/src/commands/approval.js +275 -0
- package/src/commands/attachment.js +202 -0
- package/src/commands/bitable.js +3040 -0
- package/src/commands/config.js +186 -0
- package/src/commands/dashboard.js +132 -0
- package/src/commands/doctor.js +975 -0
- package/src/commands/event.js +151 -0
- package/src/commands/file.js +1474 -0
- package/src/commands/index.js +78 -0
- package/src/commands/investor.js +179 -0
- package/src/commands/member.js +1341 -0
- package/src/commands/org.js +399 -0
- package/src/commands/report.js +908 -0
- package/src/commands/schema.js +75 -0
- package/src/commands/session.js +516 -0
- package/src/commands/skills.js +1117 -0
- package/src/commands/tenant.js +202 -0
- package/src/commands/workflow.js +176 -0
- package/src/core/args.js +901 -0
- package/src/core/auth.js +349 -0
- package/src/core/checkpoint.js +226 -0
- package/src/core/config.js +407 -0
- package/src/core/error.js +217 -0
- package/src/core/http.js +903 -0
- package/src/core/import.js +753 -0
- package/src/core/output.js +300 -0
- package/src/core/runtime.js +302 -0
- package/src/core/util.js +195 -0
- package/src/core/values.js +278 -0
- package/src/core/xlsx.js +321 -0
- package/src/main.js +205 -0
- package/src/spec/agent_guide.md +55 -0
- package/src/spec/command_surface.json +27073 -0
- package/src/spec/embedded_skills.json +19 -0
- package/src/version.js +16 -0
|
@@ -0,0 +1,1117 @@
|
|
|
1
|
+
// 技能:`skills` 域(契约 §2 + docs/34 §5)。
|
|
2
|
+
//
|
|
3
|
+
// 两条腿,刻意分清楚:
|
|
4
|
+
//
|
|
5
|
+
// - **老四条**(`--list` / `--read` / `--install` / `--update --dest`):
|
|
6
|
+
// `--list` / `--read` 先看**内嵌**技能(`src/spec/embedded_skills.json`,由
|
|
7
|
+
// `scripts/gen-embedded-skills.py` 从仓库 `skills/*/SKILL.md` 生成)—— **离线可用**;
|
|
8
|
+
// `--install` / `--update --dest` 从服务端拉(`GET /skills` + `GET /skills/{name}/bundle`),
|
|
9
|
+
// **内容寻址 + sha256 校验**:拉下来的每个文件都对着服务端给的 sha256 验一遍,
|
|
10
|
+
// 不一致就一个字节都不落盘。半份技能比没有技能更难查 —— 目录里躺着"一半新一半旧"的
|
|
11
|
+
// SKILL.md 时,人只会以为技能本身写错了。
|
|
12
|
+
// - **新八条**(`--create` / `--update <SKILL>` / `--delete` / `--mine` / `--share` /
|
|
13
|
+
// `--unshare` / `--conflicts` / `--publish`):技能即应用,人人可造 —— 只自己用、
|
|
14
|
+
// 分享给指定的几个人、或企业内共享(**最后一种要企业负责人审批**)。
|
|
15
|
+
//
|
|
16
|
+
// ## 本文件里最要紧的一条纪律
|
|
17
|
+
//
|
|
18
|
+
// **冲突绝不自动选**(docs/34 §3 第二步)。`--conflicts` 扫出冲突、`--publish` 撞上冲突,
|
|
19
|
+
// 都只做一件事:把冲突项的 key / name / 创建人 / 更新时间 / **撞的是 key 还是 name**
|
|
20
|
+
// 一条条摆出来,然后退出码 `5`,并给出两条出路(`--on-conflict rename --new-key <k>` 或
|
|
21
|
+
// `--on-conflict update`)。CLI 不猜、不默认、不"帮你去重"—— 选哪条必须由人来表:
|
|
22
|
+
// 覆盖别人的技能和换个名字重发,后果完全不同,机器替人选的那个一定有一半是错的。
|
|
23
|
+
//
|
|
24
|
+
// 内置名(`xo-core` / `xo-admin`)是**硬错误**(docs/34 §0 第 1 条),退出码 `5`:
|
|
25
|
+
// 静默改名会让"我装的技能到底是谁的"说不清。
|
|
26
|
+
|
|
27
|
+
import crypto from 'node:crypto';
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
|
|
31
|
+
import { CliError, Kind, exactlyOne } from '../core/error.js';
|
|
32
|
+
import { flagList, flagOn, flagValue } from '../core/args.js';
|
|
33
|
+
import { createVisibility, onConflictPlan } from '../core/values.js';
|
|
34
|
+
import { dataOf, requestSpec } from '../core/runtime.js';
|
|
35
|
+
import { rustIoErrorText } from '../core/util.js';
|
|
36
|
+
import { warn } from '../core/output.js';
|
|
37
|
+
import embedded from '../spec/embedded_skills.json' with { type: 'json' };
|
|
38
|
+
|
|
39
|
+
/** 内嵌技能(生成产物)。**离线可读**:这里一个网络请求都不发。 */
|
|
40
|
+
const EMBEDDED = embedded.skills ?? [];
|
|
41
|
+
|
|
42
|
+
/** 内置技能名(docs/34 §0 第 1 条)。服务端没告诉我们时用它兜底,好让 hint 说得出话。 */
|
|
43
|
+
const KNOWN_BUILTINS = ['xo-core', 'xo-admin'];
|
|
44
|
+
|
|
45
|
+
/** 两条出路(**必须显式选一条**)。只在错误的 `hint` 里说一遍,不在冲突清单里重复。 */
|
|
46
|
+
const TWO_WAYS_OUT =
|
|
47
|
+
'两条出路(**必须显式选一条,CLI 绝不替你选**):' +
|
|
48
|
+
'① 改名后分享:--on-conflict rename --new-key <新KEY> [--new-name <新名字>];' +
|
|
49
|
+
'② 覆盖已有的那个(只有原创建者或企业负责人能选):--on-conflict update';
|
|
50
|
+
|
|
51
|
+
/** 合并视图里"有就带上、没有就不编"的字段(docs/34 §4 的 `GET /skills`)。 */
|
|
52
|
+
const MERGED_FIELDS = ['source', 'visibility', 'status', 'owner', 'owner_name', 'kind'];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `skills` 的分发。
|
|
56
|
+
*
|
|
57
|
+
* 动作按**旗标**判定,不按 `command.name`:`skills --list` 与 `skills --mine` 在规格里
|
|
58
|
+
* 是两条命令,但两条的参数面都能容下对方(`--list` / `--mine` 都是"源码里有、schema 没登记"
|
|
59
|
+
* 的布尔旗标),命令行解析器可能把 `skills --mine` 归到 `skills --list` 名下。
|
|
60
|
+
* 用旗标判定,两条实现的动作面才是同一份。
|
|
61
|
+
*/
|
|
62
|
+
export async function handleSkills({ args, positional, runtime }) {
|
|
63
|
+
const action = exactlyOne(
|
|
64
|
+
[
|
|
65
|
+
['--list', flagOn(args, '--list')],
|
|
66
|
+
['--read', flagOn(args, '--read')],
|
|
67
|
+
['--install', flagOn(args, '--install')],
|
|
68
|
+
['--update', flagOn(args, '--update')],
|
|
69
|
+
['--create', flagOn(args, '--create')],
|
|
70
|
+
['--delete', flagOn(args, '--delete')],
|
|
71
|
+
['--mine', flagOn(args, '--mine')],
|
|
72
|
+
['--share', flagOn(args, '--share')],
|
|
73
|
+
['--unshare', flagOn(args, '--unshare')],
|
|
74
|
+
['--conflicts', flagOn(args, '--conflicts')],
|
|
75
|
+
['--publish', flagOn(args, '--publish')],
|
|
76
|
+
],
|
|
77
|
+
'skills',
|
|
78
|
+
'例:xinhe-oa-cli skills --list --json;造一个:skills --create --name 财务月结 --readme ./SKILL.md',
|
|
79
|
+
);
|
|
80
|
+
switch (action) {
|
|
81
|
+
case '--list':
|
|
82
|
+
return list(runtime);
|
|
83
|
+
case '--read':
|
|
84
|
+
return read(runtime, requiredValue(flagValue(args, '--read'), '--read <NAME>'));
|
|
85
|
+
case '--install':
|
|
86
|
+
return install(runtime, args, false);
|
|
87
|
+
case '--update':
|
|
88
|
+
return updateAction(runtime, args, positional);
|
|
89
|
+
case '--create':
|
|
90
|
+
return create(runtime, args);
|
|
91
|
+
case '--delete':
|
|
92
|
+
return remove(runtime, args, requiredValue(flagValue(args, '--delete'), '--delete <SKILL>'));
|
|
93
|
+
case '--mine':
|
|
94
|
+
return mine(runtime);
|
|
95
|
+
case '--share':
|
|
96
|
+
return share(runtime, args, requiredValue(flagValue(args, '--share'), '--share <SKILL>'));
|
|
97
|
+
case '--unshare':
|
|
98
|
+
return unshare(runtime, args, requiredValue(flagValue(args, '--unshare'), '--unshare <SKILL>'));
|
|
99
|
+
case '--conflicts':
|
|
100
|
+
return conflicts(runtime, args);
|
|
101
|
+
default:
|
|
102
|
+
return publish(runtime, args, requiredValue(flagValue(args, '--publish'), '--publish <SKILL>'));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// 老四条:内嵌清单 / 读 / 装 / 更新本地
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
/** `skills --list`:列**内嵌**技能(纯本地,离线可用)。 */
|
|
110
|
+
function list(runtime) {
|
|
111
|
+
const items = EMBEDDED.map((entry) => ({
|
|
112
|
+
name: entry.name,
|
|
113
|
+
path: entry.path,
|
|
114
|
+
// 字节数而不是字符数:正文是中文,字符数与字节数能差三倍,"这个技能多大"问的是字节。
|
|
115
|
+
bytes: Buffer.byteLength(entry.content, 'utf8'),
|
|
116
|
+
source: 'embedded',
|
|
117
|
+
when_to_use: firstParagraph(entry.content),
|
|
118
|
+
}));
|
|
119
|
+
const count = items.length;
|
|
120
|
+
// 空清单**必须说清楚**:静默回一个空数组会让人以为"服务端没有技能",
|
|
121
|
+
// 而真相反而是"包里没内嵌,服务端那份要用 --install 拉"。
|
|
122
|
+
const note =
|
|
123
|
+
count === 0
|
|
124
|
+
? '本二进制**未内嵌**任何技能(构建时仓库 skills/ 目录为空或不存在)。' +
|
|
125
|
+
'技能本身在服务端有发布源,用下面的命令拉:'
|
|
126
|
+
: '内嵌技能 = 随二进制分发的那一份(离线可读);服务端还有一份发布源可用 --install 拉取';
|
|
127
|
+
const nextStep =
|
|
128
|
+
count === 0
|
|
129
|
+
? '拉取技能:xinhe-oa-cli skills --install --dest ./skills'
|
|
130
|
+
: '读一个:xinhe-oa-cli skills --read <名字>;我造的技能:xinhe-oa-cli skills --mine';
|
|
131
|
+
runtime.finish({ embedded: items, total: count, note, next_step: nextStep });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* `skills --read <NAME>`:**内嵌优先**(离线可用),不是内嵌的才去服务端读。
|
|
136
|
+
*
|
|
137
|
+
* 服务端读不到时原样返回 404 —— "看不到的技能,连'存在'都不告诉"(docs/34 §0 第 3 条)。
|
|
138
|
+
*/
|
|
139
|
+
async function read(runtime, name) {
|
|
140
|
+
const found = EMBEDDED.find((entry) => entry.name === name || entry.path.includes(name));
|
|
141
|
+
if (found) {
|
|
142
|
+
runtime.finish({
|
|
143
|
+
name: found.name,
|
|
144
|
+
path: found.path,
|
|
145
|
+
source: 'embedded',
|
|
146
|
+
content: found.content,
|
|
147
|
+
});
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const target = `/skills/${name}`;
|
|
151
|
+
const spec = requestSpec();
|
|
152
|
+
if (runtime.dryRun) {
|
|
153
|
+
runtime.dryRunReport('GET', target, spec);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const response = await runtime.sendAuthed('GET', target, spec);
|
|
157
|
+
const data = dataOf(response);
|
|
158
|
+
if (isPlainObject(data)) {
|
|
159
|
+
data.source = 'platform';
|
|
160
|
+
data.next_step = '改正文:xinhe-oa-cli skills --update <SKILL> --readme ./SKILL.md --json';
|
|
161
|
+
}
|
|
162
|
+
runtime.finishFrom(response, data);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** `--update` 的两种读法:给了 `<SKILL>` 改服务端技能;只给 `--dest` 更新本地已装技能。 */
|
|
166
|
+
async function updateAction(runtime, args, positional) {
|
|
167
|
+
let skill = nonEmpty(flagValue(args, '--update')) ?? '';
|
|
168
|
+
if (skill.length === 0 && !flagOn(args, '--dest')) {
|
|
169
|
+
// 命令行层的已知缺陷(core/args.js,本文件不便改):`consumeCommandPrefix()` 中途
|
|
170
|
+
// `break` 时只回 `{consumed, actions}`,把已经记下的取值丢了,于是
|
|
171
|
+
// `--update <SKILL>` 的 SKILL 到了这里变成 `true`(技能名整条消失)。
|
|
172
|
+
// 命令层拿不到原始 argv,只能从 `process.argv` 把紧跟 `--update` 的那个值捡回来。
|
|
173
|
+
// **这条兜底会自己失效**:core 一旦把取值传下来(`args['--update']` 是字符串),
|
|
174
|
+
// 整个分支就不会被走到,可以删掉。
|
|
175
|
+
skill = updateTargetFromArgv() ?? '';
|
|
176
|
+
}
|
|
177
|
+
const dest = nonEmpty(flagValue(args, '--dest'));
|
|
178
|
+
if (skill.length === 0) {
|
|
179
|
+
if (dest === null) {
|
|
180
|
+
throw CliError.usage(
|
|
181
|
+
'`skills --update` 要么给 <SKILL>(改服务端技能),要么给 --dest <目录>(更新已装技能)',
|
|
182
|
+
).withHint(
|
|
183
|
+
'改正文:xinhe-oa-cli skills --update 财务月结 --readme ./SKILL.md --json;' +
|
|
184
|
+
'更新本地:xinhe-oa-cli skills --update --dest ./skills',
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
if (nonEmpty(flagValue(args, '--readme')) !== null || nonEmpty(flagValue(args, '--description')) !== null) {
|
|
188
|
+
throw CliError.usage(
|
|
189
|
+
'`--update --dest <目录>` 是从服务端重新拉取,不接受 --readme / --description',
|
|
190
|
+
).withHint('改服务端技能的正文用:skills --update <SKILL> --readme ./SKILL.md');
|
|
191
|
+
}
|
|
192
|
+
return install(runtime, args, true);
|
|
193
|
+
}
|
|
194
|
+
if (dest !== null) {
|
|
195
|
+
throw CliError.usage(
|
|
196
|
+
'`skills --update <SKILL>`(改服务端技能)与 `--dest`(更新本地已装技能)不能同时给',
|
|
197
|
+
).withHint(
|
|
198
|
+
'改服务端技能:xinhe-oa-cli skills --update 财务月结 --readme ./SKILL.md;' +
|
|
199
|
+
'更新本地:xinhe-oa-cli skills --update --dest ./skills',
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (nonEmpty(flagValue(args, '--readme')) === null && nonEmpty(flagValue(args, '--description')) === null) {
|
|
203
|
+
throw CliError.usage(
|
|
204
|
+
'`skills --update <SKILL>` 至少要给 --readme <文件> 或 --description <一句话>',
|
|
205
|
+
).withHint('例:xinhe-oa-cli skills --update 财务月结 --description "月度结账"');
|
|
206
|
+
}
|
|
207
|
+
return updateRemote(runtime, args, skill, positional);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* `skills --install` / `--update --dest`:从服务端拉技能落盘。
|
|
212
|
+
*
|
|
213
|
+
* `update=true` 时多一步"已经是最新就跳过"(读目录里的 `.xinhe-content-hash` 戳)。
|
|
214
|
+
*/
|
|
215
|
+
async function install(runtime, args, update) {
|
|
216
|
+
const dest = nonEmpty(flagValue(args, '--dest'));
|
|
217
|
+
if (dest === null) {
|
|
218
|
+
throw CliError.usage('`skills --install` 需要 --dest <目录>').withHint(
|
|
219
|
+
'例:xinhe-oa-cli skills --install --dest ./skills',
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
const destPath = path.resolve(dest);
|
|
223
|
+
const names = flagList(args, '--name')
|
|
224
|
+
.map((name) => String(name).trim())
|
|
225
|
+
.filter((name) => name.length > 0);
|
|
226
|
+
|
|
227
|
+
// ① 清单(服务端按令牌给**合并视图**:内置 + 我可得的用户技能,docs/34 §4)
|
|
228
|
+
const index = dataOf(await runtime.sendAuthed('GET', '/skills', requestSpec()));
|
|
229
|
+
const skills = Array.isArray(index?.skills) ? index.skills : [];
|
|
230
|
+
if (skills.length === 0) warn('服务端没有发布任何 skill');
|
|
231
|
+
const registryVersion =
|
|
232
|
+
typeof index?.version === 'string' ? index.version : 'unknown';
|
|
233
|
+
|
|
234
|
+
const wanted = skills.filter((item) => {
|
|
235
|
+
if (names.length === 0) return true;
|
|
236
|
+
return names.includes(item?.name);
|
|
237
|
+
});
|
|
238
|
+
if (wanted.length === 0 && names.length > 0) {
|
|
239
|
+
throw CliError.usage('--name 点的技能服务端一个都没有')
|
|
240
|
+
.withDetails({ requested: names })
|
|
241
|
+
.withHint('先看:xinhe-oa-cli skills --install --dest <目录>(不带 --name 装全部)');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (runtime.dryRun) {
|
|
245
|
+
const plan = wanted.map((item) => {
|
|
246
|
+
const name = typeof item?.name === 'string' ? item.name : '?';
|
|
247
|
+
return {
|
|
248
|
+
name,
|
|
249
|
+
content_hash: item?.content_hash ?? null,
|
|
250
|
+
dest: rustJoin(dest, name),
|
|
251
|
+
...mergedFields(item),
|
|
252
|
+
};
|
|
253
|
+
});
|
|
254
|
+
runtime.finish({
|
|
255
|
+
dry_run: true,
|
|
256
|
+
registry_version: registryVersion,
|
|
257
|
+
would_install: plan,
|
|
258
|
+
note: '每个 skill 走 GET /skills/{name}/bundle?format=json,逐文件校验 sha256 后再落盘',
|
|
259
|
+
});
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
mkdirp(destPath, '建不了目录');
|
|
264
|
+
|
|
265
|
+
const installed = [];
|
|
266
|
+
const skipped = [];
|
|
267
|
+
const failed = [];
|
|
268
|
+
for (const item of wanted) {
|
|
269
|
+
const name = typeof item?.name === 'string' ? item.name : '';
|
|
270
|
+
const remoteHash = typeof item?.content_hash === 'string' ? item.content_hash : '';
|
|
271
|
+
const skillDir = path.join(destPath, name);
|
|
272
|
+
let bundle;
|
|
273
|
+
try {
|
|
274
|
+
bundle = await fetchBundle(runtime, name);
|
|
275
|
+
} catch (err) {
|
|
276
|
+
failed.push({
|
|
277
|
+
name,
|
|
278
|
+
error: { code: err.code ?? 'cli.error', message: err.message, details: err.details ?? {} },
|
|
279
|
+
});
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const files = isPlainObject(bundle?.files) ? bundle.files : {};
|
|
284
|
+
// ② 先把全部文件在内存里算完哈希(顺带挡下 `..` / 绝对路径),全对才落盘。
|
|
285
|
+
const verified = [];
|
|
286
|
+
for (const relative of Object.keys(files).sort()) {
|
|
287
|
+
const bytes = Buffer.from(
|
|
288
|
+
files[relative] === undefined || files[relative] === null ? '' : String(files[relative]),
|
|
289
|
+
'utf8',
|
|
290
|
+
);
|
|
291
|
+
verified.push({ target: safeJoin(skillDir, relative), bytes, hash: sha256Hex(bytes) });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ③ 与服务端 `GET /skills/{name}` 的逐文件 sha256 对一遍(有就去对)。
|
|
295
|
+
// 详情取不到不算失败:校验是"能对就一定要对",不是"对不上就装不了"。
|
|
296
|
+
const mismatches = [];
|
|
297
|
+
let detail = null;
|
|
298
|
+
try {
|
|
299
|
+
detail = await fetchDetail(runtime, name);
|
|
300
|
+
} catch {
|
|
301
|
+
detail = null;
|
|
302
|
+
}
|
|
303
|
+
if (Array.isArray(detail?.files)) {
|
|
304
|
+
for (const row of detail.files) {
|
|
305
|
+
const relative = typeof row?.path === 'string' ? row.path : '';
|
|
306
|
+
const expected = typeof row?.sha256 === 'string' ? row.sha256 : '';
|
|
307
|
+
const hit = verified.find((entry) => pathEndsWith(entry.target, relative));
|
|
308
|
+
if (hit && expected.length > 0 && expected !== hit.hash) {
|
|
309
|
+
mismatches.push({ path: relative, expected, actual: hit.hash });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (mismatches.length > 0) {
|
|
314
|
+
failed.push({
|
|
315
|
+
name,
|
|
316
|
+
error: 'sha256 校验失败:一个字节都没落盘',
|
|
317
|
+
mismatches,
|
|
318
|
+
});
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (update) {
|
|
323
|
+
// 已经是最新(目录里有 .xinhe-content-hash 且相同)→ 跳过。
|
|
324
|
+
const stamp = path.join(skillDir, '.xinhe-content-hash');
|
|
325
|
+
const existing = readTextIfExists(stamp);
|
|
326
|
+
if (existing !== null && existing.trim() === remoteHash && remoteHash.length > 0) {
|
|
327
|
+
skipped.push({
|
|
328
|
+
name,
|
|
329
|
+
reason: '已经是最新',
|
|
330
|
+
content_hash: remoteHash,
|
|
331
|
+
...mergedFields(item),
|
|
332
|
+
});
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let written = 0;
|
|
338
|
+
for (const entry of verified) {
|
|
339
|
+
mkdirp(path.dirname(entry.target), '建不了目录');
|
|
340
|
+
try {
|
|
341
|
+
fs.writeFileSync(entry.target, entry.bytes);
|
|
342
|
+
} catch (err) {
|
|
343
|
+
throw CliError.usage(`写不了 ${entry.target}:${err.message}`);
|
|
344
|
+
}
|
|
345
|
+
written += 1;
|
|
346
|
+
}
|
|
347
|
+
if (remoteHash.length > 0) {
|
|
348
|
+
try {
|
|
349
|
+
fs.writeFileSync(path.join(skillDir, '.xinhe-content-hash'), remoteHash);
|
|
350
|
+
} catch {
|
|
351
|
+
/* 戳写不上不影响技能本体,下次 --update 会当作没装过 */
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
installed.push({
|
|
355
|
+
name,
|
|
356
|
+
files: written,
|
|
357
|
+
content_hash: remoteHash,
|
|
358
|
+
dir: rustJoin(dest, name),
|
|
359
|
+
verified: true,
|
|
360
|
+
...mergedFields(item),
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
runtime.finish({
|
|
365
|
+
dest,
|
|
366
|
+
registry_version: registryVersion,
|
|
367
|
+
installed,
|
|
368
|
+
skipped,
|
|
369
|
+
failed,
|
|
370
|
+
mode: update ? 'update' : 'install',
|
|
371
|
+
note: '内容寻址 + sha256 校验:校验不过就一个字节都不落盘',
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
// 造与改(docs/34 §5)
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
/** `skills --create`:`POST /skills`。**默认 private**(fail-closed,docs/34 §0 第 2 条)。 */
|
|
379
|
+
async function create(runtime, args) {
|
|
380
|
+
const name = singleName(args, 'skills --create');
|
|
381
|
+
const readmePath = nonEmpty(flagValue(args, '--readme'));
|
|
382
|
+
if (readmePath === null) {
|
|
383
|
+
throw CliError.usage(
|
|
384
|
+
'`skills --create` 需要 --readme <文件>(SKILL.md 正文,给 LLM 读的提示词资产)',
|
|
385
|
+
).withHint('例:xinhe-oa-cli skills --create --name 财务月结 --readme ./SKILL.md --json');
|
|
386
|
+
}
|
|
387
|
+
const readme = readText(readmePath);
|
|
388
|
+
// 不传 --visibility 就是 private:默认值必须是**最窄**的那一档,
|
|
389
|
+
// 否则"我随手建了个技能"会变成"全企业都看得见"。
|
|
390
|
+
const visibility = createVisibility(flagValue(args, '--visibility'));
|
|
391
|
+
|
|
392
|
+
const body = { name, readme, visibility };
|
|
393
|
+
const key = nonEmpty(flagValue(args, '--key'));
|
|
394
|
+
if (key !== null) body.key = key;
|
|
395
|
+
const description = nonEmpty(flagValue(args, '--description'));
|
|
396
|
+
if (description !== null) body.description = description;
|
|
397
|
+
const appId = nonEmpty(flagValue(args, '--from-application'));
|
|
398
|
+
// docs/34 §1.1:`--from-application` 写进 `app_id`(技能与它引用的应用要一起提交)。
|
|
399
|
+
if (appId !== null) body.app_id = appId;
|
|
400
|
+
|
|
401
|
+
const spec = requestSpec({ body: sortKeys(body) });
|
|
402
|
+
if (runtime.dryRun) {
|
|
403
|
+
runtime.dryRunReport('POST', '/skills', spec);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const response = await runtime
|
|
407
|
+
.sendAuthed('POST', '/skills', spec)
|
|
408
|
+
.catch((err) => {
|
|
409
|
+
throw enrichSkillError(err);
|
|
410
|
+
});
|
|
411
|
+
const out = unwrapSkill(dataOf(response));
|
|
412
|
+
if (isPlainObject(out)) {
|
|
413
|
+
out.reserved = false;
|
|
414
|
+
out.next_step =
|
|
415
|
+
'分享给同事:xinhe-oa-cli skills --share <SKILL> --to-user <USER_ID>;' +
|
|
416
|
+
'分享到企业前先扫冲突:xinhe-oa-cli skills --conflicts --name <名字>';
|
|
417
|
+
out.note = '默认 private(fail-closed):除你之外谁都看不到,连「存在」都不告诉';
|
|
418
|
+
}
|
|
419
|
+
runtime.finishFrom(response, out);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** `skills --update <SKILL>`:`PATCH /skills/{id}`(乐观锁 `version`)。 */
|
|
423
|
+
async function updateRemote(runtime, args, skill, positional = []) {
|
|
424
|
+
const body = {};
|
|
425
|
+
const readmePath = nonEmpty(flagValue(args, '--readme'));
|
|
426
|
+
if (readmePath !== null) body.readme = readText(readmePath);
|
|
427
|
+
const description = nonEmpty(flagValue(args, '--description'));
|
|
428
|
+
if (description !== null) body.description = description;
|
|
429
|
+
const version = versionOf(args, positional);
|
|
430
|
+
// docs/34 §1.1 的乐观锁:带上它,别人那一版就不会被自己这一版悄悄覆盖掉。
|
|
431
|
+
if (version !== null) body.version = version;
|
|
432
|
+
|
|
433
|
+
const spec = requestSpec({ body: sortKeys(body) });
|
|
434
|
+
const target = `/skills/${skill}`;
|
|
435
|
+
if (runtime.dryRun) {
|
|
436
|
+
runtime.dryRunReport('PATCH', target, spec);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const response = await runtime
|
|
440
|
+
.sendAuthed('PATCH', target, spec)
|
|
441
|
+
.catch((err) => {
|
|
442
|
+
throw enrichSkillError(err);
|
|
443
|
+
});
|
|
444
|
+
const out = unwrapSkill(dataOf(response));
|
|
445
|
+
if (isPlainObject(out)) {
|
|
446
|
+
out.next_step = '看这一份:xinhe-oa-cli skills --read <SKILL> --json';
|
|
447
|
+
if (version === null) {
|
|
448
|
+
out.note =
|
|
449
|
+
'这次没带 --version(不做乐观锁校验);并发改同一份时服务端可能报 write.version_conflict';
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
runtime.finishFrom(response, out);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* `skills --delete <SKILL>`:`DELETE /skills/{id}`(软删)。
|
|
457
|
+
*
|
|
458
|
+
* **高风险写操作**:docs/34 §5 没写它的风险级别,但 CLI 里 `record --delete` /
|
|
459
|
+
* `attachment --delete` 一律是 `high-risk-write`,删除技能与之同级,所以同样要 `--yes`。
|
|
460
|
+
*/
|
|
461
|
+
async function remove(runtime, args, skill) {
|
|
462
|
+
runtime.requireYes(
|
|
463
|
+
'skills --delete(技能会被软删,别人再也读不到)',
|
|
464
|
+
`xinhe-oa-cli skills --delete ${skill}`,
|
|
465
|
+
);
|
|
466
|
+
const target = `/skills/${skill}`;
|
|
467
|
+
const spec = requestSpec();
|
|
468
|
+
if (runtime.dryRun) {
|
|
469
|
+
runtime.dryRunReport('DELETE', target, spec);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const response = await runtime
|
|
473
|
+
.sendAuthed('DELETE', target, spec)
|
|
474
|
+
.catch((err) => {
|
|
475
|
+
throw enrichSkillError(err);
|
|
476
|
+
});
|
|
477
|
+
const out = dataOf(response);
|
|
478
|
+
if (isPlainObject(out)) {
|
|
479
|
+
out.skill = skill;
|
|
480
|
+
out.deleted = true;
|
|
481
|
+
out.note = '软删:服务端先断开所有点对点分享;待审的审批也随之失效';
|
|
482
|
+
}
|
|
483
|
+
runtime.finishFrom(response, out);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** `skills --mine`:我造的(含 draft / pending / rejected)。 */
|
|
487
|
+
async function mine(runtime) {
|
|
488
|
+
const spec = requestSpec();
|
|
489
|
+
if (runtime.dryRun) {
|
|
490
|
+
runtime.dryRunReport('GET', '/skills/mine', spec);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const response = await runtime.sendAuthed('GET', '/skills/mine', spec);
|
|
494
|
+
const data = dataOf(response);
|
|
495
|
+
const skills = Array.isArray(data?.skills) ? data.skills : Array.isArray(data?.items) ? data.items : [];
|
|
496
|
+
const total = data?.total ?? skills.length;
|
|
497
|
+
runtime.finishFrom(response, {
|
|
498
|
+
skills,
|
|
499
|
+
total,
|
|
500
|
+
next_step:
|
|
501
|
+
'改正文:xinhe-oa-cli skills --update <SKILL> --readme ./SKILL.md;' +
|
|
502
|
+
'申请企业共享:xinhe-oa-cli skills --publish <SKILL> --visibility tenant',
|
|
503
|
+
note: '这里连草稿 / 待审 / 被拒的都在(别人看不到未发布的技能)',
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ---------------------------------------------------------------------------
|
|
508
|
+
// 分享(点对点 / 企业)
|
|
509
|
+
// ---------------------------------------------------------------------------
|
|
510
|
+
/** `skills --share <SKILL> --to-user A [--to-user B]`:点对点,**直接生效、不需要审批**。 */
|
|
511
|
+
async function share(runtime, args, skill) {
|
|
512
|
+
const users = flagList(args, '--to-user')
|
|
513
|
+
.map((user) => String(user).trim())
|
|
514
|
+
.filter((user) => user.length > 0);
|
|
515
|
+
if (users.length === 0) {
|
|
516
|
+
throw CliError.usage('`skills --share` 需要至少一个 --to-user <USER_ID>').withHint(
|
|
517
|
+
'看企业里有哪些人:xinhe-oa-cli member --list --json',
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
const target = `/skills/${skill}/share`;
|
|
521
|
+
|
|
522
|
+
if (runtime.dryRun) {
|
|
523
|
+
const requests = users.map((user) =>
|
|
524
|
+
runtime.dryRunRequest('POST', target, requestSpec({ body: { user_id: user } })),
|
|
525
|
+
);
|
|
526
|
+
runtime.finish({
|
|
527
|
+
dry_run: true,
|
|
528
|
+
skill,
|
|
529
|
+
requests,
|
|
530
|
+
note: `--dry-run:没有发出任何请求。真跑会按 --to-user 逐个发 ${users.length} 次 POST ${target}`,
|
|
531
|
+
});
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const results = [];
|
|
536
|
+
let firstError = null;
|
|
537
|
+
for (const user of users) {
|
|
538
|
+
try {
|
|
539
|
+
const response = await runtime.sendAuthed(
|
|
540
|
+
'POST',
|
|
541
|
+
target,
|
|
542
|
+
requestSpec({ body: { user_id: user } }),
|
|
543
|
+
);
|
|
544
|
+
results.push({ user_id: user, granted: true, data: dataOf(response) });
|
|
545
|
+
} catch (err) {
|
|
546
|
+
results.push({
|
|
547
|
+
user_id: user,
|
|
548
|
+
granted: false,
|
|
549
|
+
error: { code: err.code ?? 'cli.error', message: err.message },
|
|
550
|
+
});
|
|
551
|
+
if (firstError === null) firstError = err;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (firstError !== null) {
|
|
556
|
+
// 部分成功也要如实说:谁成了、谁没成,一个都不许糊过去 ——
|
|
557
|
+
// "分享给了 5 个人"里有 2 个没成,不说清楚就会变成"他明明说分享过了"。
|
|
558
|
+
const granted = results.filter((row) => row.granted === true).length;
|
|
559
|
+
const hint = `共 ${results.length} 人:成功 ${granted},失败 ${results.length - granted};先把失败的那几个人解决掉(--to-user 可以逐个重试)`;
|
|
560
|
+
const details = isPlainObject(firstError.details) ? { ...firstError.details } : {};
|
|
561
|
+
details.results = results;
|
|
562
|
+
details.skill = skill;
|
|
563
|
+
throw firstError.withDetails(details).withHint(hint);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
runtime.finish({
|
|
567
|
+
skill,
|
|
568
|
+
results,
|
|
569
|
+
shared_count: users.length,
|
|
570
|
+
next_step: '对方现在就能读到:xinhe-oa-cli skills --read <SKILL> --json(在他自己的机器上)',
|
|
571
|
+
note: '点对点分享直接生效,不需要审批;要收回用 skills --unshare <SKILL> --from-user <USER_ID>',
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** `skills --unshare <SKILL> --from-user <USER_ID>`:收回点对点分享。 */
|
|
576
|
+
async function unshare(runtime, args, skill) {
|
|
577
|
+
const user = nonEmpty(flagValue(args, '--from-user'));
|
|
578
|
+
if (user === null) {
|
|
579
|
+
throw CliError.usage('`skills --unshare` 需要 --from-user <USER_ID>').withHint(
|
|
580
|
+
'看分享给了谁:xinhe-oa-cli skills --read <SKILL> --json',
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
const target = `/skills/${skill}/unshare`;
|
|
584
|
+
const spec = requestSpec({ body: { user_id: user } });
|
|
585
|
+
if (runtime.dryRun) {
|
|
586
|
+
runtime.dryRunReport('POST', target, spec);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const response = await runtime
|
|
590
|
+
.sendAuthed('POST', target, spec)
|
|
591
|
+
.catch((err) => {
|
|
592
|
+
throw enrichSkillError(err);
|
|
593
|
+
});
|
|
594
|
+
const out = dataOf(response);
|
|
595
|
+
if (isPlainObject(out)) {
|
|
596
|
+
out.skill = skill;
|
|
597
|
+
out.user_id = user;
|
|
598
|
+
out.revoked = true;
|
|
599
|
+
}
|
|
600
|
+
runtime.finishFrom(response, out);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* `skills --conflicts --name <n> [--key <k>]`:分享前的冲突扫描(docs/34 §3 第一步)。
|
|
605
|
+
*
|
|
606
|
+
* **发现冲突就是退出码 5**:扫描命令的结论是"你得先做个决定",脚本据此分支;
|
|
607
|
+
* 调用方要的细节(key/name/创建人/时间/撞的是哪个)全在 `error.details.conflicts` 里。
|
|
608
|
+
* 扫描本身是只读的,但"扫出冲突"不是成功 —— 成功意味着"可以往下走"。
|
|
609
|
+
*/
|
|
610
|
+
async function conflicts(runtime, args) {
|
|
611
|
+
const nameList = flagList(args, '--name');
|
|
612
|
+
const name = nameList.length === 0 ? null : singleName(args, 'skills --conflicts');
|
|
613
|
+
const key = nonEmpty(flagValue(args, '--key'));
|
|
614
|
+
if (name === null && key === null) {
|
|
615
|
+
throw CliError.usage('`skills --conflicts` 至少要给 --name <名字> 或 --key <机器名>').withHint(
|
|
616
|
+
'例:xinhe-oa-cli skills --conflicts --name 财务月结 --key finance_monthly --json',
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
const query = [];
|
|
620
|
+
if (name !== null) query.push(['name', name]);
|
|
621
|
+
if (key !== null) query.push(['key', key]);
|
|
622
|
+
const spec = requestSpec({ query });
|
|
623
|
+
if (runtime.dryRun) {
|
|
624
|
+
runtime.dryRunReport('GET', '/skills/conflicts', spec);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const response = await runtime.sendAuthed('GET', '/skills/conflicts', spec);
|
|
628
|
+
const scan = dataOf(response);
|
|
629
|
+
|
|
630
|
+
// 撞内置名 = 硬错误(docs/34 §0 第 1 条):连"改名后重来"都得人先看见。
|
|
631
|
+
if (isReserved(scan)) {
|
|
632
|
+
throw reservedError(scan, name, key).withHumanDetails(describeConflicts(scan));
|
|
633
|
+
}
|
|
634
|
+
const found = conflictRows(scan);
|
|
635
|
+
if (found.length > 0) {
|
|
636
|
+
throw conflictError(scan, '<SKILL>').withHumanDetails(describeConflicts(scan));
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
runtime.finishFrom(response, {
|
|
640
|
+
reserved: false,
|
|
641
|
+
conflicts: found,
|
|
642
|
+
builtins: Array.isArray(scan?.builtins) ? scan.builtins : KNOWN_BUILTINS,
|
|
643
|
+
scanned: { name, key },
|
|
644
|
+
next_step: '没有冲突,可以直接提交:xinhe-oa-cli skills --publish <SKILL> --visibility tenant --json',
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* `skills --publish <SKILL> --visibility tenant|private [--on-conflict ...]`。
|
|
650
|
+
*
|
|
651
|
+
* 冲突时不自动选:服务端把冲突清单还回来,CLI 把它摆成人能读的样子,退出码 `5`。
|
|
652
|
+
*/
|
|
653
|
+
async function publish(runtime, args, skill) {
|
|
654
|
+
// `--publish` 的可见性是**必填**:分享到哪里必须显式表态(与 `--create` 的 fail-closed 不同 ——
|
|
655
|
+
// 那条是"不表态就最窄",这条是"不表态就没法执行")。
|
|
656
|
+
const raw = nonEmpty(flagValue(args, '--visibility'));
|
|
657
|
+
if (raw === null) {
|
|
658
|
+
throw CliError.usage('`skills --publish` 需要 --visibility tenant|private').withHint(
|
|
659
|
+
'分享给全企业:--visibility tenant;撤回成只有自己:--visibility private',
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
const visibility = createVisibility(raw);
|
|
663
|
+
const plan = onConflictPlan(
|
|
664
|
+
flagValue(args, '--on-conflict'),
|
|
665
|
+
flagValue(args, '--new-key'),
|
|
666
|
+
flagValue(args, '--new-name'),
|
|
667
|
+
);
|
|
668
|
+
const body = { visibility };
|
|
669
|
+
if (plan !== null && plan.kind === 'rename') {
|
|
670
|
+
body.on_conflict = 'rename';
|
|
671
|
+
body.new_key = plan.newKey;
|
|
672
|
+
if (plan.newName !== null && plan.newName !== undefined) body.new_name = plan.newName;
|
|
673
|
+
} else if (plan !== null) {
|
|
674
|
+
body.on_conflict = 'update';
|
|
675
|
+
}
|
|
676
|
+
// 不表态:把请求原样发出去,让服务端回"有冲突"(CLI 不替服务端猜有没有冲突)。
|
|
677
|
+
const spec = requestSpec({ body: sortKeys(body) });
|
|
678
|
+
const target = `/skills/${skill}/publish`;
|
|
679
|
+
if (runtime.dryRun) {
|
|
680
|
+
runtime.dryRunReport('POST', target, spec);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
let response;
|
|
684
|
+
try {
|
|
685
|
+
response = await runtime.sendAuthed('POST', target, spec);
|
|
686
|
+
} catch (rawError) {
|
|
687
|
+
const err = enrichSkillError(rawError);
|
|
688
|
+
if (isConflictPayload(err.details)) {
|
|
689
|
+
// 冲突清单要给人看:摆出来,再以 5 退出。
|
|
690
|
+
const hint = err.hint ? `${err.hint};${TWO_WAYS_OUT}` : TWO_WAYS_OUT;
|
|
691
|
+
throw err.withHint(hint).withHumanDetails(describeConflicts(err.details));
|
|
692
|
+
}
|
|
693
|
+
throw err;
|
|
694
|
+
}
|
|
695
|
+
const data = dataOf(response);
|
|
696
|
+
runtime.finishFrom(response, publishOutcome(data, skill, visibility, plan));
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* `--publish` 成功后的输出:**提交人要看到下一步**(docs/34 §5 / §3 第三步)。
|
|
701
|
+
*
|
|
702
|
+
* 带了审批 id 时 `next_step` 写死 `xinhe-oa-cli approval --list --to-review` —— 那是
|
|
703
|
+
* **企业负责人**要看的清单;提交人自己看 `approval --mine`。
|
|
704
|
+
*/
|
|
705
|
+
function publishOutcome(data, skill, visibility, plan) {
|
|
706
|
+
const approvalId = approvalIdOf(data);
|
|
707
|
+
const onConflict =
|
|
708
|
+
plan === null
|
|
709
|
+
? null
|
|
710
|
+
: plan.kind === 'rename'
|
|
711
|
+
? { strategy: 'rename', new_key: plan.newKey }
|
|
712
|
+
: { strategy: 'update' };
|
|
713
|
+
const out = {
|
|
714
|
+
skill,
|
|
715
|
+
visibility,
|
|
716
|
+
on_conflict: onConflict,
|
|
717
|
+
approval_id: approvalId,
|
|
718
|
+
data,
|
|
719
|
+
};
|
|
720
|
+
if (approvalId !== null) {
|
|
721
|
+
out.next_step = 'xinhe-oa-cli approval --list --to-review';
|
|
722
|
+
out.note =
|
|
723
|
+
'已提交企业负责人审批:通过之前技能对企业其他人**不可见**(status=pending);' +
|
|
724
|
+
'看进度:xinhe-oa-cli approval --show <APPROVAL_ID>;撤回:xinhe-oa-cli approval --cancel <APPROVAL_ID>';
|
|
725
|
+
} else {
|
|
726
|
+
out.next_step = 'xinhe-oa-cli skills --mine';
|
|
727
|
+
out.note = '这次没有产生审批(visibility=private 时是直接生效/撤回)';
|
|
728
|
+
}
|
|
729
|
+
return out;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// ---------------------------------------------------------------------------
|
|
733
|
+
// 冲突的呈现与判定(纯函数,便于单测)
|
|
734
|
+
// ---------------------------------------------------------------------------
|
|
735
|
+
/** 冲突项清单(`conflicts` 数组)。 */
|
|
736
|
+
export function conflictRows(scan) {
|
|
737
|
+
return Array.isArray(scan?.conflicts) ? scan.conflicts : [];
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** 是否撞了内置名(`reserved: true`)。 */
|
|
741
|
+
export function isReserved(scan) {
|
|
742
|
+
return scan?.reserved === true;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** 错误详情里是否带着一份冲突清单(服务端在 `--publish` 上的拒绝形状)。 */
|
|
746
|
+
export function isConflictPayload(details) {
|
|
747
|
+
return conflictRows(details).length > 0 || isReserved(details);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* 把冲突扫描结果渲染成人能读的一段话(key / name / 创建人 / 更新时间 / **撞的是哪个**)。
|
|
752
|
+
*
|
|
753
|
+
* 只负责"有哪些冲突";两条出路在错误的 `hint` 里(`TWO_WAYS_OUT`)——
|
|
754
|
+
* 两边都印一遍只会让最要紧的那句话被淹掉。
|
|
755
|
+
*/
|
|
756
|
+
export function describeConflicts(scan) {
|
|
757
|
+
let out = '';
|
|
758
|
+
if (isReserved(scan)) {
|
|
759
|
+
out += `✗ 撞了内置技能名:内置技能优先级最高、不可覆盖,请换一个名字(内置:${builtinNames(scan).join(' / ')})\n`;
|
|
760
|
+
}
|
|
761
|
+
const rows = conflictRows(scan);
|
|
762
|
+
if (rows.length > 0) {
|
|
763
|
+
out += `✗ 企业内已有 ${rows.length} 项同名/同 key 的已发布技能:\n`;
|
|
764
|
+
rows.forEach((row, index) => {
|
|
765
|
+
const key = textOf(row, 'key');
|
|
766
|
+
const name = textOf(row, 'name');
|
|
767
|
+
const same =
|
|
768
|
+
(Array.isArray(row?.same) ? row.same.filter((item) => typeof item === 'string') : []).join(',') ||
|
|
769
|
+
'?';
|
|
770
|
+
const ownerName = textOf(row, 'owner_name');
|
|
771
|
+
const ownerId = textOf(row, 'owner_user_id');
|
|
772
|
+
const owner =
|
|
773
|
+
ownerName.length > 0 && ownerId.length > 0
|
|
774
|
+
? `${ownerName}(${ownerId})`
|
|
775
|
+
: ownerName.length > 0
|
|
776
|
+
? ownerName
|
|
777
|
+
: ownerId.length > 0
|
|
778
|
+
? ownerId
|
|
779
|
+
: '?';
|
|
780
|
+
const updated = textOf(row, 'updated_at') || '?';
|
|
781
|
+
const hash = textOf(row, 'content_hash');
|
|
782
|
+
out +=
|
|
783
|
+
` ${index + 1}. key=${key.length > 0 ? key : '?'} name=${name.length > 0 ? name : '?'}` +
|
|
784
|
+
` 撞的是: ${same} 创建人: ${owner} 更新时间: ${updated}` +
|
|
785
|
+
`${hash.length > 0 ? ` content_hash=${shortHash(hash)}` : ''}\n`;
|
|
786
|
+
});
|
|
787
|
+
out += '(撞了就该有人做个决定:改名 rename 还是覆盖 update —— 见下方「下一步」)\n';
|
|
788
|
+
}
|
|
789
|
+
return out;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** 冲突 → 退出码 5 的错误(**不新造服务端码**:这是 CLI 自己的判定,用 `cli.*`)。 */
|
|
793
|
+
export function conflictError(scan, skill) {
|
|
794
|
+
const rows = conflictRows(scan);
|
|
795
|
+
const count = rows.length;
|
|
796
|
+
const options = [
|
|
797
|
+
{
|
|
798
|
+
on_conflict: 'rename',
|
|
799
|
+
args: ['--on-conflict', 'rename', '--new-key', '<新KEY>'],
|
|
800
|
+
说明: '换一个机器名(可再给 --new-name 换显示名)后重新提交',
|
|
801
|
+
example: `xinhe-oa-cli skills --publish ${skill} --visibility tenant --on-conflict rename --new-key <新KEY> --json`,
|
|
802
|
+
},
|
|
803
|
+
{
|
|
804
|
+
on_conflict: 'update',
|
|
805
|
+
args: ['--on-conflict', 'update'],
|
|
806
|
+
说明: '覆盖已有的那个企业级技能(发布新版本,version 递增);只有原创建者或企业负责人能选',
|
|
807
|
+
example: `xinhe-oa-cli skills --publish ${skill} --visibility tenant --on-conflict update --json`,
|
|
808
|
+
},
|
|
809
|
+
];
|
|
810
|
+
return new CliError(
|
|
811
|
+
Kind.Conflict,
|
|
812
|
+
'cli.skill_conflict',
|
|
813
|
+
`企业内已有 ${count} 项同名/同 key 的技能,你没有表态怎么处理`,
|
|
814
|
+
)
|
|
815
|
+
.withDetails({
|
|
816
|
+
reserved: isReserved(scan),
|
|
817
|
+
conflict_count: count,
|
|
818
|
+
conflicts: rows,
|
|
819
|
+
builtins: Array.isArray(scan?.builtins) ? scan.builtins : KNOWN_BUILTINS,
|
|
820
|
+
options,
|
|
821
|
+
next_step: `xinhe-oa-cli skills --publish ${skill} --visibility tenant --on-conflict rename --new-key <新KEY>`,
|
|
822
|
+
})
|
|
823
|
+
.withHint(TWO_WAYS_OUT);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* 撞内置名 → 硬错误。退出码 **5**(理由见 `core/error.js` 的 `classify`:这个码只可能从服务端
|
|
828
|
+
* 回来,退出码 2 的判据写死了"本地判定、不发请求";而出路与 `--on-conflict rename` 同类)。
|
|
829
|
+
*/
|
|
830
|
+
export function reservedError(scan, name, key) {
|
|
831
|
+
const builtins = builtinNames(scan);
|
|
832
|
+
const target = name ?? key ?? '<这个名字>';
|
|
833
|
+
return new CliError(
|
|
834
|
+
Kind.Conflict,
|
|
835
|
+
'skill.name_reserved',
|
|
836
|
+
`${target} 是内置技能名,不可覆盖`,
|
|
837
|
+
)
|
|
838
|
+
.withDetails({
|
|
839
|
+
reserved: true,
|
|
840
|
+
builtins,
|
|
841
|
+
name: name ?? null,
|
|
842
|
+
key: key ?? null,
|
|
843
|
+
conflicts: conflictRows(scan),
|
|
844
|
+
})
|
|
845
|
+
.withHint(
|
|
846
|
+
`内置技能优先级最高、不可被覆盖,请换个名字(内置:${builtins.join(' / ')}):` +
|
|
847
|
+
'还没建就在 `skills --create --name <别的名字>` 换掉;' +
|
|
848
|
+
'已经建好要分享到企业就在 `skills --publish ... --on-conflict rename --new-key <新KEY>` 换掉',
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** 服务端返回的技能专属错误 → 把 hint 补成人能照做的下一步。 */
|
|
853
|
+
export function enrichSkillError(err) {
|
|
854
|
+
if (err.code === 'skill.name_reserved') {
|
|
855
|
+
const details = isPlainObject(err.details) ? err.details : {};
|
|
856
|
+
const list = Array.isArray(details.builtins)
|
|
857
|
+
? details.builtins.filter((name) => typeof name === 'string')
|
|
858
|
+
: [];
|
|
859
|
+
const builtins = list.length > 0 ? list.join(' / ') : KNOWN_BUILTINS.join(' / ');
|
|
860
|
+
const ours =
|
|
861
|
+
`内置技能不可覆盖,请换个名字(内置:${builtins});换个名字重来:` +
|
|
862
|
+
'`skills --create --name <别的名字>`,或分享时 `--publish ... --on-conflict rename --new-key <新KEY>`' +
|
|
863
|
+
'(内置技能本身没有任何改动)';
|
|
864
|
+
return err.withHint(err.hint ? `${ours};服务端说法:${err.hint}` : ours);
|
|
865
|
+
}
|
|
866
|
+
if (err.code === 'skill.not_owner') {
|
|
867
|
+
// `--on-conflict update` 是覆盖别人的东西:能做的只有原创建者或企业负责人。
|
|
868
|
+
return err.withHint(
|
|
869
|
+
'`--on-conflict update` 是**覆盖已有的那个技能**,只有它的原创建者或该企业负责人能选;' +
|
|
870
|
+
'你没有这个权力 —— 要么找原创建者/企业负责人来做,要么改用 ' +
|
|
871
|
+
'`--on-conflict rename --new-key <新KEY>`',
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
return err;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// ---------------------------------------------------------------------------
|
|
878
|
+
// 工具(都对照 Rust 的同名函数逐条对齐)
|
|
879
|
+
// ---------------------------------------------------------------------------
|
|
880
|
+
function builtinNames(scan) {
|
|
881
|
+
const list = Array.isArray(scan?.builtins)
|
|
882
|
+
? scan.builtins.filter((name) => typeof name === 'string')
|
|
883
|
+
: [];
|
|
884
|
+
return list.length > 0 ? list : [...KNOWN_BUILTINS];
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function textOf(row, key) {
|
|
888
|
+
return typeof row?.[key] === 'string' ? row[key] : '';
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function shortHash(hash) {
|
|
892
|
+
return Array.from(hash).slice(0, 12).join('');
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/** 服务端的技能对象可能在 `skill` 里,也可能就是 `data` 本身。 */
|
|
896
|
+
function unwrapSkill(data) {
|
|
897
|
+
if (isPlainObject(data) && isPlainObject(data.skill)) return data.skill;
|
|
898
|
+
return data;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** 审批 id 可能在四处(服务端两种信封 × 有没有 `skill`)。 */
|
|
902
|
+
function approvalIdOf(data) {
|
|
903
|
+
for (const pointer of ['/approval_id', '/approval/id', '/data/approval_id', '/data/approval/id']) {
|
|
904
|
+
const value = pointerOf(data, pointer);
|
|
905
|
+
if (typeof value === 'string' && value.trim().length > 0) return value;
|
|
906
|
+
}
|
|
907
|
+
return null;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function pointerOf(value, pointer) {
|
|
911
|
+
let current = value;
|
|
912
|
+
for (const piece of pointer.split('/').slice(1)) {
|
|
913
|
+
if (!isPlainObject(current) && !Array.isArray(current)) return undefined;
|
|
914
|
+
current = current[piece];
|
|
915
|
+
}
|
|
916
|
+
return current;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** 服务端合并视图的新字段:**有就带上,没有就不编**(老服务端照样能跑)。 */
|
|
920
|
+
function mergedFields(item) {
|
|
921
|
+
const out = {};
|
|
922
|
+
for (const key of MERGED_FIELDS) {
|
|
923
|
+
if (isPlainObject(item) && item[key] !== undefined && item[key] !== null) out[key] = item[key];
|
|
924
|
+
}
|
|
925
|
+
return out;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function singleName(args, what) {
|
|
929
|
+
const names = flagList(args, '--name')
|
|
930
|
+
.map((name) => String(name).trim())
|
|
931
|
+
.filter((name) => name.length > 0);
|
|
932
|
+
if (names.length === 1) return names[0];
|
|
933
|
+
if (names.length === 0) {
|
|
934
|
+
throw CliError.usage(`\`${what}\` 需要 --name <名字>`).withHint(
|
|
935
|
+
'例:xinhe-oa-cli skills --create --name 财务月结 --readme ./SKILL.md',
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
throw CliError.usage(
|
|
939
|
+
`\`${what}\` 一次只能给一个 --name,收到 ${names.length} 个:${names.join(' / ')}`,
|
|
940
|
+
).withHint('要一次装多个内置技能用:skills --install --dest <目录> --name A --name B');
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function requiredValue(value, what) {
|
|
944
|
+
const text = nonEmpty(value);
|
|
945
|
+
// 与 Rust `required_value` 同一句话:`--read` 写成裸旗标时走的就是这里。
|
|
946
|
+
if (text === null) throw CliError.usage(`${what} 的值不能为空`);
|
|
947
|
+
return text;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* `skills --update <SKILL> --version <N>` 的版本号。
|
|
952
|
+
*
|
|
953
|
+
* 为什么要看 `positional`:`--version` 同时是**全局旗标**(`-V/--version` 打印版本号),
|
|
954
|
+
* 命令行层会把它整个摘走,`4` 于是落进位置参数里(`args['--version']` 永远是 undefined)。
|
|
955
|
+
* Rust 侧两者不冲突(顶层 `--version` 与 `SkillsArgs::version` 是两个作用域),
|
|
956
|
+
* 所以这里按 Rust 的口径把那个整数捡回来;命令行层修好后 `args['--version']` 会先生效,这段可以删。
|
|
957
|
+
*/
|
|
958
|
+
function versionOf(args, positional) {
|
|
959
|
+
const direct = flagValue(args, '--version');
|
|
960
|
+
if (direct !== null && direct !== undefined && String(direct).trim().length > 0) {
|
|
961
|
+
const text = String(direct).trim();
|
|
962
|
+
if (!/^-?\d+$/.test(text)) {
|
|
963
|
+
throw CliError.usage(`\`--version\` 要一个整数,收到 ${JSON.stringify(direct)}`);
|
|
964
|
+
}
|
|
965
|
+
return Number.parseInt(text, 10);
|
|
966
|
+
}
|
|
967
|
+
const strays = positional.filter((token) => /^-?\d+$/.test(String(token).trim()));
|
|
968
|
+
if (strays.length === 1) return Number.parseInt(String(strays[0]).trim(), 10);
|
|
969
|
+
return null;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function updateTargetFromArgv() {
|
|
973
|
+
const argv = process.argv.slice(2);
|
|
974
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
975
|
+
const token = argv[index];
|
|
976
|
+
if (token.startsWith('--update=')) return nonEmpty(token.slice('--update='.length));
|
|
977
|
+
if (token !== '--update') continue;
|
|
978
|
+
const next = argv[index + 1];
|
|
979
|
+
return next !== undefined && !next.startsWith('-') ? next : null;
|
|
980
|
+
}
|
|
981
|
+
return null;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function nonEmpty(raw) {
|
|
985
|
+
if (raw === null || raw === undefined) return null;
|
|
986
|
+
const text = String(raw).trim();
|
|
987
|
+
return text.length > 0 ? text : null;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function readText(file) {
|
|
991
|
+
let bytes;
|
|
992
|
+
try {
|
|
993
|
+
bytes = fs.readFileSync(file);
|
|
994
|
+
} catch (err) {
|
|
995
|
+
throw CliError.usage(`读不了文件 ${file}:${rustIoErrorText(err)}`);
|
|
996
|
+
}
|
|
997
|
+
try {
|
|
998
|
+
// 先解一遍再当字符串用:SKILL.md 必须是文本,二进制混进来要在这里就说清楚。
|
|
999
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
1000
|
+
} catch (err) {
|
|
1001
|
+
throw CliError.usage(`${file} 不是 UTF-8 文本(SKILL.md 必须是文本):${err.message}`);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function readTextIfExists(file) {
|
|
1006
|
+
try {
|
|
1007
|
+
return fs.readFileSync(file, 'utf8');
|
|
1008
|
+
} catch {
|
|
1009
|
+
return null;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function mkdirp(directory, what) {
|
|
1014
|
+
try {
|
|
1015
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
1016
|
+
} catch (err) {
|
|
1017
|
+
throw CliError.usage(`${what} ${directory}:${err.message}`);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
async function fetchBundle(runtime, name) {
|
|
1022
|
+
// `format=json`:不引入 tar/gzip 依赖也能逐文件校验(客户端只用标准库)。
|
|
1023
|
+
const spec = requestSpec({ query: [['format', 'json']] });
|
|
1024
|
+
const response = await runtime.sendAuthed('GET', `/skills/${name}/bundle`, spec);
|
|
1025
|
+
return dataOf(response);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
async function fetchDetail(runtime, name) {
|
|
1029
|
+
const response = await runtime.sendAuthed('GET', `/skills/${name}`, requestSpec());
|
|
1030
|
+
return dataOf(response);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* 防目录穿越:bundle 里的相对路径不许 `..` 或绝对路径。
|
|
1035
|
+
*
|
|
1036
|
+
* 服务端发布源是**半可信**的输入:它能决定落盘的相对路径。少了这一条,
|
|
1037
|
+
* 一个被投毒的技能就能往 `~/.ssh/authorized_keys` 写东西。
|
|
1038
|
+
*/
|
|
1039
|
+
function safeJoin(base, relative) {
|
|
1040
|
+
const raw = String(relative ?? '');
|
|
1041
|
+
const pieces = raw.split(/[/\\]/);
|
|
1042
|
+
if (path.isAbsolute(raw) || pieces.includes('..')) {
|
|
1043
|
+
throw CliError.usage(`skill bundle 里的路径不安全(${raw}):拒绝落盘`).withHint(
|
|
1044
|
+
'这是服务端发布源的问题,报给运维',
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
return path.join(base, raw);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/** `candidate` 的**路径分量尾部**是不是 `relative`(对齐 Rust `Path::ends_with`)。 */
|
|
1051
|
+
function pathEndsWith(candidate, relative) {
|
|
1052
|
+
const wanted = String(relative ?? '')
|
|
1053
|
+
.split(/[/\\]/)
|
|
1054
|
+
.filter((piece) => piece.length > 0 && piece !== '.');
|
|
1055
|
+
if (wanted.length === 0) return false;
|
|
1056
|
+
const parts = String(candidate).split(path.sep).filter((piece) => piece.length > 0);
|
|
1057
|
+
if (wanted.length > parts.length) return false;
|
|
1058
|
+
const tail = parts.slice(parts.length - wanted.length);
|
|
1059
|
+
return wanted.every((piece, index) => tail[index] === piece);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function sha256Hex(bytes) {
|
|
1063
|
+
return crypto.createHash('sha256').update(bytes).digest('hex');
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* 拼一个"给人看的"路径(`dir` / `dest` 字段),与 Rust `PathBuf::display()` 同形:
|
|
1068
|
+
* 保留调用方写的相对写法(`./skills/x` 不会变成 `skills/x`)。
|
|
1069
|
+
*/
|
|
1070
|
+
function rustJoin(dest, name) {
|
|
1071
|
+
return `${String(dest).replace(/[/\\]+$/, '')}${path.sep}${name}`;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function isPlainObject(value) {
|
|
1075
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/** 请求体的键按字典序(serde_json 的 Map 是 BTreeMap,请求体字节也照抄这个顺序)。 */
|
|
1079
|
+
function sortKeys(object) {
|
|
1080
|
+
const out = {};
|
|
1081
|
+
for (const key of Object.keys(object).sort()) out[key] = object[key];
|
|
1082
|
+
return out;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* 取 SKILL.md 的第一段(front-matter 与标题之后的描述)当"什么时候用"。
|
|
1087
|
+
*
|
|
1088
|
+
* 与 Rust `first_paragraph` 逐条同义:跳过 `---` 包起来的 front-matter,
|
|
1089
|
+
* 跳过标题行,遇到空行收尾,按 160 **字符**(不是字节)截断并补省略号。
|
|
1090
|
+
*/
|
|
1091
|
+
export function firstParagraph(content) {
|
|
1092
|
+
const lines = String(content ?? '').split('\n');
|
|
1093
|
+
let index = 0;
|
|
1094
|
+
if ((lines[0] ?? '').trim() === '---') {
|
|
1095
|
+
index = 1;
|
|
1096
|
+
while (index < lines.length && lines[index].trim() !== '---') index += 1;
|
|
1097
|
+
index += 1;
|
|
1098
|
+
}
|
|
1099
|
+
let out = '';
|
|
1100
|
+
for (; index < lines.length; index += 1) {
|
|
1101
|
+
const trimmed = lines[index].trim();
|
|
1102
|
+
if (trimmed.length === 0) {
|
|
1103
|
+
if (out.length > 0) break;
|
|
1104
|
+
continue;
|
|
1105
|
+
}
|
|
1106
|
+
if (trimmed.startsWith('#')) {
|
|
1107
|
+
if (out.length > 0) break;
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
if (out.length > 0) out += ' ';
|
|
1111
|
+
out += trimmed;
|
|
1112
|
+
if (Array.from(out).length > 160) break;
|
|
1113
|
+
}
|
|
1114
|
+
const chars = Array.from(out);
|
|
1115
|
+
const result = chars.slice(0, 160).join('');
|
|
1116
|
+
return chars.length > 160 ? `${result}…` : result;
|
|
1117
|
+
}
|