@aalis/plugin-commands 0.13.1 → 0.15.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/dist/commands.d.ts +50 -13
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +170 -64
- package/dist/commands.js.map +1 -1
- package/dist/help.d.ts +16 -18
- package/dist/help.d.ts.map +1 -1
- package/dist/help.js +102 -129
- package/dist/help.js.map +1 -1
- package/dist/index.d.ts +16 -36
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +203 -270
- package/dist/index.js.map +1 -1
- package/dist/locales/en.d.ts +61 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/en.js +61 -0
- package/dist/locales/en.js.map +1 -0
- package/dist/texts.d.ts +121 -0
- package/dist/texts.d.ts.map +1 -0
- package/dist/texts.js +64 -0
- package/dist/texts.js.map +1 -0
- package/package.json +23 -15
package/dist/commands.d.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
import type { ExecutionGuard } from '@aalis/api-authority';
|
|
1
|
+
import type { AuthorityService, ExecutionGuard } from '@aalis/api-authority';
|
|
2
2
|
import type { Command, CommandBuilder, CommandService, ExecutionInput, InternalCommandMeta } from '@aalis/api-commands';
|
|
3
|
-
import type
|
|
3
|
+
import { type I18nService } from '@aalis/api-i18n';
|
|
4
|
+
import type { Logger, ServiceSelection } from '@aalis/core';
|
|
5
|
+
import { type TextRef } from '@aalis/schema-i18n';
|
|
6
|
+
/** 帮助按谁的等级过滤、按谁的语言渲染:执行时守卫裁决的同一个身份,与这条指令所在的会话 */
|
|
7
|
+
interface Caller {
|
|
8
|
+
sessionId: string;
|
|
9
|
+
platform: string;
|
|
10
|
+
userId?: string;
|
|
11
|
+
}
|
|
4
12
|
export declare class CommandRegistry implements CommandService {
|
|
5
13
|
/**
|
|
6
14
|
* 名字 → 声明栈。空栈 = 自动创建的分组节点。
|
|
@@ -12,11 +20,17 @@ export declare class CommandRegistry implements CommandService {
|
|
|
12
20
|
/** 别名映射:aliasName → realName */
|
|
13
21
|
private readonly aliases;
|
|
14
22
|
private readonly logger;
|
|
23
|
+
/** 权限服务:帮助按它的 authorize 判调用者能用哪些指令,与执行守卫的等级裁决同一份 */
|
|
24
|
+
private readonly authority;
|
|
25
|
+
/** 回复与帮助按读者的语言渲染;缺席时按原文 */
|
|
26
|
+
private readonly i18n;
|
|
27
|
+
/** i18n 渲染抛错时告警过了:之后再出错不再告警 */
|
|
28
|
+
private renderWarned;
|
|
15
29
|
private _guard?;
|
|
16
30
|
prefix: string;
|
|
17
|
-
constructor(logger: Logger);
|
|
18
|
-
setExecutionGuard(guard: ExecutionGuard): void;
|
|
19
|
-
command(rawName: string, description?: string, meta?: InternalCommandMeta): CommandBuilder;
|
|
31
|
+
constructor(logger: Logger, authority: Pick<ServiceSelection<AuthorityService>, 'present' | 'use'>, i18n: Pick<ServiceSelection<I18nService>, 'present' | 'use'>);
|
|
32
|
+
setExecutionGuard(guard: ExecutionGuard | undefined): void;
|
|
33
|
+
command(rawName: string, description?: string | TextRef, meta?: InternalCommandMeta): CommandBuilder;
|
|
20
34
|
/** builder 写入**自己那一层声明**(闭包持有,无需每次查表)。 */
|
|
21
35
|
private makeBuilder;
|
|
22
36
|
/** 自动创建祖先分组节点(不替换已存在节点) */
|
|
@@ -42,12 +56,10 @@ export declare class CommandRegistry implements CommandService {
|
|
|
42
56
|
* 分组节点由 `ensureGroups` 自动创建(空栈、无 pluginName),因此**任何按插件名的摘除都
|
|
43
57
|
* 匹配不到它们**——空栈上 `dropped.length === 0` 直接返回。
|
|
44
58
|
*
|
|
45
|
-
*
|
|
46
|
-
* `hasMatch('
|
|
47
|
-
* 回一段指向空节点的用法;`/help` 概览还会长出「`/relation` — 0 个子指令」。
|
|
48
|
-
* 而 `relation` 正是全仓唯一的占位分组,卸载该插件必然踩到。
|
|
59
|
+
* 后果是用户可见的:一个插件只登记了 `a.b.c`,卸载后自动建的 `a` / `a.b` 留着,
|
|
60
|
+
* `hasMatch('a')` 仍为真,于是指令相位**吞掉** `/a` 不放行给普通消息管道,回一段指向空节点的用法。
|
|
49
61
|
*
|
|
50
|
-
* 反复扫直到不动点:删掉 `
|
|
62
|
+
* 反复扫直到不动点:删掉 `a.b` 会让 `a` 也变成无子节点的空壳。
|
|
51
63
|
*/
|
|
52
64
|
private pruneEmptyGroups;
|
|
53
65
|
parseCommand(input: string): {
|
|
@@ -74,14 +86,39 @@ export declare class CommandRegistry implements CommandService {
|
|
|
74
86
|
*/
|
|
75
87
|
hasMatch(head: string, tokens?: string[]): boolean;
|
|
76
88
|
execute(name: string, input: ExecutionInput): Promise<string | undefined>;
|
|
89
|
+
/**
|
|
90
|
+
* 同 {@link execute},回复不渲染:文字对象原样交出。聊天回复用它,把文字对象交给网关按会话的语言渲染;
|
|
91
|
+
* 帮助(裸敲分组、-h)照旧是按调用者所在会话的语言渲染好的字符串
|
|
92
|
+
*/
|
|
93
|
+
run(name: string, input: ExecutionInput): Promise<string | TextRef | undefined>;
|
|
77
94
|
private materialize;
|
|
95
|
+
/** `/help` 的概览:调用者看得见的顶层指令(help 自己不列) */
|
|
96
|
+
overview(caller: Caller): string;
|
|
97
|
+
/**
|
|
98
|
+
* `/help <指令>`、`<指令> -h` 与裸敲分组共用的回答。`/help` 问调用者看不见的指令,与问不存在的指令回同一句。
|
|
99
|
+
* 指令登记了 usage 时整段用它。
|
|
100
|
+
*/
|
|
101
|
+
detail(path: string[], caller: Caller): string;
|
|
102
|
+
/** 用法(同帮助,按调用者所在会话的语言渲染好);给了 error 时报错在前,同一次渲染 */
|
|
103
|
+
private usageOf;
|
|
104
|
+
/** 报错后附上用法;调用者看不见这条指令时只回报错 */
|
|
105
|
+
private withUsage;
|
|
106
|
+
/**
|
|
107
|
+
* 按调用者所在会话的语言渲染(i18n 缺席时按原文)。帮助一次要渲染几十段,整份交给这里一次,语言链只算一次;
|
|
108
|
+
* i18n 渲染抛错时按原文,告警一次
|
|
109
|
+
*/
|
|
110
|
+
private localize;
|
|
111
|
+
private entries;
|
|
112
|
+
/** 帮助里看得见:调用者能用的指令,或底下有看得见的指令的分组 */
|
|
113
|
+
private visible;
|
|
78
114
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
115
|
+
* 调用者能不能用这条指令:有权限服务时按它的 authorize(执行守卫的等级裁决就是它,确认不算),
|
|
116
|
+
* 没有时按执行时同样的 fail-closed
|
|
81
117
|
*/
|
|
82
|
-
private
|
|
118
|
+
private permits;
|
|
83
119
|
private directChildren;
|
|
84
120
|
private parseArgs;
|
|
85
121
|
private defaultOptions;
|
|
86
122
|
}
|
|
123
|
+
export {};
|
|
87
124
|
//# sourceMappingURL=commands.d.ts.map
|
package/dist/commands.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../src/commands.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../src/commands.ts"],"names":[],"mappings":"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"}
|
package/dist/commands.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { capabilityMinLevel, DEFAULT_AUTHORITY, riskDefaults } from '@aalis/api-authority';
|
|
2
|
-
import {
|
|
2
|
+
import { localizeFor } from '@aalis/api-i18n';
|
|
3
|
+
import { isTextRef, TextError, textOf, toSource } from '@aalis/schema-i18n';
|
|
4
|
+
import { renderDetail, renderOverview } from './help.js';
|
|
5
|
+
import { t } from './texts.js';
|
|
3
6
|
// ============================================================================
|
|
4
7
|
// 命令注册表(v2 — 链式 builder)
|
|
5
8
|
//
|
|
@@ -17,7 +20,7 @@ const NAME_SEGMENT_RE = /^[a-z][a-z0-9-]*$/;
|
|
|
17
20
|
*
|
|
18
21
|
* 栈顶决定「跑谁的实现」,但不该决定「谁能跑」——否则后注册者只要压一层就能放宽权限。
|
|
19
22
|
*
|
|
20
|
-
*
|
|
23
|
+
* 注意:**必须整份取,不能逐轴取严**。visibility / confirm / risk 三轴各自独立取最严实测不单调:
|
|
21
24
|
* 真正的裁决函数里 **risk 一旦存在就完全遮蔽 visibility**
|
|
22
25
|
* (见 `capabilityMinLevel`),于是「未声明 risk」在合并时被当成最弱、在裁决时却代表更强的
|
|
23
26
|
* visibility 兜底。给 `{visibility:'restricted'}` 的 /shutdown 压一层 `{risk:'safe'}`,
|
|
@@ -77,10 +80,18 @@ export class CommandRegistry {
|
|
|
77
80
|
/** 别名映射:aliasName → realName */
|
|
78
81
|
aliases = new Map();
|
|
79
82
|
logger;
|
|
83
|
+
/** 权限服务:帮助按它的 authorize 判调用者能用哪些指令,与执行守卫的等级裁决同一份 */
|
|
84
|
+
authority;
|
|
85
|
+
/** 回复与帮助按读者的语言渲染;缺席时按原文 */
|
|
86
|
+
i18n;
|
|
87
|
+
/** i18n 渲染抛错时告警过了:之后再出错不再告警 */
|
|
88
|
+
renderWarned = false;
|
|
80
89
|
_guard;
|
|
81
90
|
prefix = '/';
|
|
82
|
-
constructor(logger) {
|
|
91
|
+
constructor(logger, authority, i18n) {
|
|
83
92
|
this.logger = logger.child('commands');
|
|
93
|
+
this.authority = authority;
|
|
94
|
+
this.i18n = i18n;
|
|
84
95
|
}
|
|
85
96
|
// ---- Guard ----
|
|
86
97
|
setExecutionGuard(guard) {
|
|
@@ -160,6 +171,13 @@ export class CommandRegistry {
|
|
|
160
171
|
decl.options.push(parseOptionSyntax(optName, syntax, opts));
|
|
161
172
|
return self;
|
|
162
173
|
},
|
|
174
|
+
argument: (argName, description) => {
|
|
175
|
+
const arg = decl.positionalArgs.find(a => a.name === argName);
|
|
176
|
+
if (!arg)
|
|
177
|
+
throw new Error(`指令 ${name} 没有位置参数 ${argName}`);
|
|
178
|
+
arg.description = description;
|
|
179
|
+
return self;
|
|
180
|
+
},
|
|
163
181
|
action: (handler) => {
|
|
164
182
|
decl.handler = handler;
|
|
165
183
|
return self;
|
|
@@ -234,12 +252,10 @@ export class CommandRegistry {
|
|
|
234
252
|
* 分组节点由 `ensureGroups` 自动创建(空栈、无 pluginName),因此**任何按插件名的摘除都
|
|
235
253
|
* 匹配不到它们**——空栈上 `dropped.length === 0` 直接返回。
|
|
236
254
|
*
|
|
237
|
-
*
|
|
238
|
-
* `hasMatch('
|
|
239
|
-
* 回一段指向空节点的用法;`/help` 概览还会长出「`/relation` — 0 个子指令」。
|
|
240
|
-
* 而 `relation` 正是全仓唯一的占位分组,卸载该插件必然踩到。
|
|
255
|
+
* 后果是用户可见的:一个插件只登记了 `a.b.c`,卸载后自动建的 `a` / `a.b` 留着,
|
|
256
|
+
* `hasMatch('a')` 仍为真,于是指令相位**吞掉** `/a` 不放行给普通消息管道,回一段指向空节点的用法。
|
|
241
257
|
*
|
|
242
|
-
* 反复扫直到不动点:删掉 `
|
|
258
|
+
* 反复扫直到不动点:删掉 `a.b` 会让 `a` 也变成无子节点的空壳。
|
|
243
259
|
*/
|
|
244
260
|
pruneEmptyGroups() {
|
|
245
261
|
let removed = true;
|
|
@@ -326,31 +342,26 @@ export class CommandRegistry {
|
|
|
326
342
|
return this.resolve(head, tokens) !== null;
|
|
327
343
|
}
|
|
328
344
|
async execute(name, input) {
|
|
345
|
+
return this.localize(await this.run(name, input), input);
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* 同 {@link execute},回复不渲染:文字对象原样交出。聊天回复用它,把文字对象交给网关按会话的语言渲染;
|
|
349
|
+
* 帮助(裸敲分组、-h)照旧是按调用者所在会话的语言渲染好的字符串
|
|
350
|
+
*/
|
|
351
|
+
async run(name, input) {
|
|
329
352
|
const resolved = this.resolve(name, input.args);
|
|
330
353
|
if (!resolved)
|
|
331
|
-
return
|
|
354
|
+
return t('unknownCommand', { command: `${this.prefix}${name}`, prefix: this.prefix });
|
|
332
355
|
const cmd = this.materialize(resolved.name);
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
// 公开(源码、文档、npm 包都写着)。而且只堵这里等于只堵一半:低权限用户直敲受限指令时,
|
|
340
|
-
// authority 的拒绝文案照样暴露存在性,要真做得连那条通用文案一起改,而它同时服务 tools
|
|
341
|
-
// 与 commands 两个注入点,牵动所有能力的拒绝语义,改动面大于收益。
|
|
342
|
-
// 真要做的话两条一起:注入与执行侧同源的 authorize 判定,只取等级轴,明确排除 confirm
|
|
343
|
-
// 与临时授予(前者是 async 且会真弹确认框、后者随会话漂移,都不适合用于列表渲染)。
|
|
344
|
-
// /help 的概览与详情(plugin-commands 的 renderOverview/renderDetail)是同族第三条
|
|
345
|
-
// 列举路径,同属本裁定——2026-08 审计曾把它当漏项翻出,特此留痕勿再重开。
|
|
346
|
-
if (!cmd.handler) {
|
|
347
|
-
return this.formatUsage(cmd);
|
|
348
|
-
}
|
|
349
|
-
const parsed = this.parseArgs(cmd, resolved.remaining);
|
|
350
|
-
if (typeof parsed === 'string')
|
|
356
|
+
// 裸敲分组与 -h、--help 回的都是 `/help <指令>` 那一段:调用者用不了的指令在帮助里当作不存在。
|
|
357
|
+
// 直接执行用不了的指令由守卫拒绝,拒绝文案照旧点名指令
|
|
358
|
+
if (!cmd.handler || asksHelp(resolved.remaining))
|
|
359
|
+
return this.detail(resolved.name.split('.'), input);
|
|
360
|
+
const parsed = this.parseArgs(cmd, resolved.remaining, input);
|
|
361
|
+
if (typeof parsed === 'string' || isTextRef(parsed))
|
|
351
362
|
return parsed;
|
|
352
363
|
if (this._guard) {
|
|
353
|
-
const
|
|
364
|
+
const verdict = await this._guard({
|
|
354
365
|
name: cmd.name,
|
|
355
366
|
type: 'command',
|
|
356
367
|
visibility: cmd.visibility,
|
|
@@ -359,15 +370,16 @@ export class CommandRegistry {
|
|
|
359
370
|
sessionId: input.sessionId,
|
|
360
371
|
platform: input.platform,
|
|
361
372
|
userId: input.userId,
|
|
373
|
+
// 参与者只有发出者:聊天入站已把授权身份并进 platform 与 userId,没有账号的是无主体
|
|
374
|
+
participants: [{ platform: input.platform, userId: input.userId ?? '' }],
|
|
362
375
|
skipConfirm: input.skipConfirm,
|
|
376
|
+
confirmIntent: input.confirmIntent,
|
|
363
377
|
});
|
|
364
|
-
if (
|
|
365
|
-
return
|
|
378
|
+
if (!verdict.ok)
|
|
379
|
+
return verdict.reason;
|
|
366
380
|
}
|
|
367
|
-
else if (
|
|
368
|
-
|
|
369
|
-
// 没有确认通道。需要更高等级或需要确认的指令一律拒绝,默认等级可用且无需确认的照常执行。
|
|
370
|
-
return `指令 ${this.prefix}${cmd.name} 需要权限校验或确认,但未安装权限插件 @aalis/plugin-authority,已拒绝执行`;
|
|
381
|
+
else if (needsAuthority(cmd)) {
|
|
382
|
+
return t('needsAuthority', { command: `${this.prefix}${cmd.name}` });
|
|
371
383
|
}
|
|
372
384
|
try {
|
|
373
385
|
const argv = {
|
|
@@ -384,9 +396,9 @@ export class CommandRegistry {
|
|
|
384
396
|
return result ?? undefined;
|
|
385
397
|
}
|
|
386
398
|
catch (err) {
|
|
387
|
-
const
|
|
388
|
-
this.logger.error(`指令 ${this.prefix}${cmd.name} 执行失败: ${
|
|
389
|
-
return
|
|
399
|
+
const error = textOf(err);
|
|
400
|
+
this.logger.error(`指令 ${this.prefix}${cmd.name} 执行失败: ${toSource(error)}`);
|
|
401
|
+
return t('failed', { error });
|
|
390
402
|
}
|
|
391
403
|
}
|
|
392
404
|
// ---- 内部:把 patches 合成有效 Command(含父级可见性 + 权限继承) ----
|
|
@@ -425,7 +437,7 @@ export class CommandRegistry {
|
|
|
425
437
|
name,
|
|
426
438
|
pluginName: top?.pluginName ?? 'unknown',
|
|
427
439
|
// 空栈 = 自动创建的分组节点,描述在此派生而非落字段(少存一份、不会陈旧)。
|
|
428
|
-
description: top?.description ??
|
|
440
|
+
description: top?.description ?? t('group', { name }),
|
|
429
441
|
visibility,
|
|
430
442
|
confirm,
|
|
431
443
|
risk,
|
|
@@ -444,15 +456,70 @@ export class CommandRegistry {
|
|
|
444
456
|
isGroup: stack.length === 0,
|
|
445
457
|
};
|
|
446
458
|
}
|
|
447
|
-
// ----
|
|
459
|
+
// ---- 帮助 ----
|
|
460
|
+
/** `/help` 的概览:调用者看得见的顶层指令(help 自己不列) */
|
|
461
|
+
overview(caller) {
|
|
462
|
+
const tops = [...this.nodes.keys()].filter(n => !n.includes('.') && n !== 'help').sort();
|
|
463
|
+
return renderOverview(this.entries(tops, caller), this.prefix, value => this.localize(value, caller));
|
|
464
|
+
}
|
|
448
465
|
/**
|
|
449
|
-
*
|
|
450
|
-
*
|
|
466
|
+
* `/help <指令>`、`<指令> -h` 与裸敲分组共用的回答。`/help` 问调用者看不见的指令,与问不存在的指令回同一句。
|
|
467
|
+
* 指令登记了 usage 时整段用它。
|
|
451
468
|
*/
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
469
|
+
detail(path, caller) {
|
|
470
|
+
const cmd = this.getNode(path);
|
|
471
|
+
if (!cmd || !this.visible(cmd.name, caller)) {
|
|
472
|
+
return this.localize(t('noCommand', { path: path.join(' '), prefix: this.prefix }), caller);
|
|
473
|
+
}
|
|
474
|
+
return this.usageOf(cmd, caller);
|
|
475
|
+
}
|
|
476
|
+
/** 用法(同帮助,按调用者所在会话的语言渲染好);给了 error 时报错在前,同一次渲染 */
|
|
477
|
+
usageOf(cmd, caller, error) {
|
|
478
|
+
const localize = value => this.localize(value, caller);
|
|
479
|
+
if (cmd.usage !== undefined)
|
|
480
|
+
return error ? `${localize(error)}\n\n${cmd.usage}` : cmd.usage;
|
|
481
|
+
return renderDetail(cmd, this.entries(this.directChildren(cmd.name), caller), this.prefix, localize, error);
|
|
482
|
+
}
|
|
483
|
+
/** 报错后附上用法;调用者看不见这条指令时只回报错 */
|
|
484
|
+
withUsage(error, cmd, caller) {
|
|
485
|
+
return this.visible(cmd.name, caller) ? this.usageOf(cmd, caller, error) : error;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* 按调用者所在会话的语言渲染(i18n 缺席时按原文)。帮助一次要渲染几十段,整份交给这里一次,语言链只算一次;
|
|
489
|
+
* i18n 渲染抛错时按原文,告警一次
|
|
490
|
+
*/
|
|
491
|
+
localize(value, caller) {
|
|
492
|
+
return localizeFor(this.i18n, value, { sessionId: caller.sessionId, platform: caller.platform }, {
|
|
493
|
+
onError: err => {
|
|
494
|
+
if (this.renderWarned)
|
|
495
|
+
return;
|
|
496
|
+
this.renderWarned = true;
|
|
497
|
+
this.logger.warn('按会话的语言渲染指令的回复与帮助出错,按原文交出(之后再出错不再告警):', err);
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
entries(names, caller) {
|
|
502
|
+
return names
|
|
503
|
+
.filter(n => this.visible(n, caller))
|
|
504
|
+
.map(n => ({
|
|
505
|
+
cmd: this.materialize(n),
|
|
506
|
+
children: this.directChildren(n).filter(c => this.visible(c, caller)).length,
|
|
507
|
+
}));
|
|
508
|
+
}
|
|
509
|
+
/** 帮助里看得见:调用者能用的指令,或底下有看得见的指令的分组 */
|
|
510
|
+
visible(name, caller) {
|
|
511
|
+
const cmd = this.materialize(name);
|
|
512
|
+
return cmd.handler ? this.permits(cmd, caller) : this.directChildren(name).some(c => this.visible(c, caller));
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* 调用者能不能用这条指令:有权限服务时按它的 authorize(执行守卫的等级裁决就是它,确认不算),
|
|
516
|
+
* 没有时按执行时同样的 fail-closed
|
|
517
|
+
*/
|
|
518
|
+
permits(cmd, caller) {
|
|
519
|
+
if (!this.authority.present)
|
|
520
|
+
return !needsAuthority(cmd);
|
|
521
|
+
const request = { capability: `command:${cmd.name}`, visibility: cmd.visibility, risk: cmd.risk };
|
|
522
|
+
return this.authority.use.authorize(caller, request) === null;
|
|
456
523
|
}
|
|
457
524
|
directChildren(parent) {
|
|
458
525
|
const prefix = `${parent}.`;
|
|
@@ -467,7 +534,7 @@ export class CommandRegistry {
|
|
|
467
534
|
return out.sort();
|
|
468
535
|
}
|
|
469
536
|
// ---- 选项 + 位置参数 解析 ----
|
|
470
|
-
parseArgs(cmd, rawArgs) {
|
|
537
|
+
parseArgs(cmd, rawArgs, caller) {
|
|
471
538
|
const options = this.defaultOptions(cmd.options);
|
|
472
539
|
const positionalTokens = [];
|
|
473
540
|
try {
|
|
@@ -480,11 +547,13 @@ export class CommandRegistry {
|
|
|
480
547
|
if (token.startsWith('--') && token.length > 2) {
|
|
481
548
|
const eq = token.indexOf('=');
|
|
482
549
|
const rawName = eq >= 0 ? token.slice(2, eq) : token.slice(2);
|
|
483
|
-
|
|
550
|
+
// 先按全名找:选项名本身可以以 no- 开头(--no-llm);找不到才当作反写(--no-cache 关掉 cache)
|
|
551
|
+
const exact = findOption(cmd.options, rawName);
|
|
552
|
+
const negated = !exact && rawName.startsWith('no-');
|
|
484
553
|
const optName = negated ? rawName.slice(3) : rawName;
|
|
485
|
-
const def = findOption(cmd.options, optName);
|
|
554
|
+
const def = exact ?? (negated ? findOption(cmd.options, optName) : undefined);
|
|
486
555
|
if (!def)
|
|
487
|
-
return
|
|
556
|
+
return this.withUsage(t('unknownOption', { option: `--${rawName}` }), cmd, caller);
|
|
488
557
|
let rawValue = eq >= 0 ? token.slice(eq + 1) : undefined;
|
|
489
558
|
if (def.type === 'boolean') {
|
|
490
559
|
options[def.name] = negated ? false : rawValue === undefined ? true : parseBoolean(rawValue);
|
|
@@ -499,7 +568,7 @@ export class CommandRegistry {
|
|
|
499
568
|
rawValue = rawArgs[i];
|
|
500
569
|
}
|
|
501
570
|
if (rawValue === undefined)
|
|
502
|
-
return
|
|
571
|
+
return t('missingValue', { option: `--${optName}` });
|
|
503
572
|
options[def.name] = parseOptionValue(def, rawValue, options[def.name]);
|
|
504
573
|
}
|
|
505
574
|
continue;
|
|
@@ -507,8 +576,13 @@ export class CommandRegistry {
|
|
|
507
576
|
if (token.startsWith('-') && token.length > 1) {
|
|
508
577
|
const alias = token.slice(1);
|
|
509
578
|
const def = findOption(cmd.options, alias);
|
|
579
|
+
// 负数没有同名选项时是位置参数(/level <目标> -1 封禁)
|
|
580
|
+
if (!def && /^\d/.test(alias)) {
|
|
581
|
+
positionalTokens.push(token);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
510
584
|
if (!def)
|
|
511
|
-
return
|
|
585
|
+
return this.withUsage(t('unknownOption', { option: `-${alias}` }), cmd, caller);
|
|
512
586
|
if (def.type === 'boolean') {
|
|
513
587
|
options[def.name] = true;
|
|
514
588
|
}
|
|
@@ -520,7 +594,7 @@ export class CommandRegistry {
|
|
|
520
594
|
options[def.name] = true;
|
|
521
595
|
continue;
|
|
522
596
|
}
|
|
523
|
-
return
|
|
597
|
+
return t('missingValue', { option: `-${alias}` });
|
|
524
598
|
}
|
|
525
599
|
options[def.name] = parseOptionValue(def, rawValue, options[def.name]);
|
|
526
600
|
}
|
|
@@ -530,13 +604,17 @@ export class CommandRegistry {
|
|
|
530
604
|
}
|
|
531
605
|
for (const o of cmd.options) {
|
|
532
606
|
if (o.required && options[o.name] === undefined)
|
|
533
|
-
return
|
|
607
|
+
return t('missingOption', { option: `--${o.name}` });
|
|
534
608
|
}
|
|
535
609
|
// 有子指令、自身不收位置参数的指令,多出来的词是写错或不存在的子指令名,或是写多了的选项值
|
|
536
610
|
// (`/clear -t context vector`):报错并指向 /help,不忽略后执行本指令(否则 `/clear alll`
|
|
537
611
|
// 会在确认后清掉当前会话)
|
|
538
612
|
if (cmd.positionalArgs.length === 0 && positionalTokens.length > 0 && this.directChildren(cmd.name).length > 0) {
|
|
539
|
-
return
|
|
613
|
+
return t('extraArgument', {
|
|
614
|
+
token: positionalTokens[0],
|
|
615
|
+
prefix: this.prefix,
|
|
616
|
+
command: cmd.name.replace(/\./g, ' '),
|
|
617
|
+
});
|
|
540
618
|
}
|
|
541
619
|
const positionals = [];
|
|
542
620
|
let cursor = 0;
|
|
@@ -544,7 +622,7 @@ export class CommandRegistry {
|
|
|
544
622
|
const values = def.type === 'text' ? positionalTokens.slice(cursor) : positionalTokens.slice(cursor, cursor + 1);
|
|
545
623
|
if (values.length === 0) {
|
|
546
624
|
if (def.required)
|
|
547
|
-
return
|
|
625
|
+
return t('missingArgument', { name: def.name });
|
|
548
626
|
positionals.push(undefined);
|
|
549
627
|
continue;
|
|
550
628
|
}
|
|
@@ -554,8 +632,8 @@ export class CommandRegistry {
|
|
|
554
632
|
return { positionals, options };
|
|
555
633
|
}
|
|
556
634
|
catch (err) {
|
|
557
|
-
// 取值解析错误(数字非法 / choices 越界)→
|
|
558
|
-
return
|
|
635
|
+
// 取值解析错误(数字非法 / choices 越界)→ 返回给人看的报错,而非抛出冒泡到命令管道。
|
|
636
|
+
return textOf(err);
|
|
559
637
|
}
|
|
560
638
|
}
|
|
561
639
|
defaultOptions(opts) {
|
|
@@ -569,9 +647,24 @@ export class CommandRegistry {
|
|
|
569
647
|
// ============================================================================
|
|
570
648
|
// 工具函数
|
|
571
649
|
// ============================================================================
|
|
650
|
+
/** 写了 -h 或 --help(`--` 之后的是位置参数,不算):要看用法,别的参数一概不管 */
|
|
651
|
+
function asksHelp(tokens) {
|
|
652
|
+
const end = tokens.indexOf('--');
|
|
653
|
+
return (end < 0 ? tokens : tokens.slice(0, end)).some(t => t === '-h' || t === '--help');
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* 没有权限插件时执行不了的指令:要比默认等级高,或要人确认。没有守卫时 fail-closed,口径与 plugin-tools 相同:
|
|
657
|
+
* 等同人人都是默认等级、没人能确认
|
|
658
|
+
*/
|
|
659
|
+
function needsAuthority(cmd) {
|
|
660
|
+
return (cmd.confirm !== undefined || capabilityMinLevel({ risk: cmd.risk, visibility: cmd.visibility }) > DEFAULT_AUTHORITY);
|
|
661
|
+
}
|
|
662
|
+
/** 按空白拆参数:引号里的整段是一个参数(空的引号是一个空参数),反斜杠转义下一个字符 */
|
|
572
663
|
function tokenize(input) {
|
|
573
664
|
const tokens = [];
|
|
574
665
|
let current = '';
|
|
666
|
+
/** 这个参数写过引号:空的也算一个 */
|
|
667
|
+
let quoted = false;
|
|
575
668
|
let quote;
|
|
576
669
|
let escaped = false;
|
|
577
670
|
for (const ch of input) {
|
|
@@ -593,18 +686,20 @@ function tokenize(input) {
|
|
|
593
686
|
}
|
|
594
687
|
if (ch === '"' || ch === "'") {
|
|
595
688
|
quote = ch;
|
|
689
|
+
quoted = true;
|
|
596
690
|
continue;
|
|
597
691
|
}
|
|
598
692
|
if (/\s/.test(ch)) {
|
|
599
|
-
if (current) {
|
|
693
|
+
if (current || quoted) {
|
|
600
694
|
tokens.push(current);
|
|
601
695
|
current = '';
|
|
696
|
+
quoted = false;
|
|
602
697
|
}
|
|
603
698
|
continue;
|
|
604
699
|
}
|
|
605
700
|
current += ch;
|
|
606
701
|
}
|
|
607
|
-
if (current)
|
|
702
|
+
if (current || quoted)
|
|
608
703
|
tokens.push(current);
|
|
609
704
|
return tokens;
|
|
610
705
|
}
|
|
@@ -691,6 +786,9 @@ function parseOptionSyntax(name, syntax, opts) {
|
|
|
691
786
|
}
|
|
692
787
|
}
|
|
693
788
|
}
|
|
789
|
+
if (name === 'help' || name === 'h' || aliases.includes('h') || aliases.includes('help')) {
|
|
790
|
+
throw new Error(`-h 与 --help 是保留选项(回这条指令的用法),选项 ${name} 不能占用`);
|
|
791
|
+
}
|
|
694
792
|
return {
|
|
695
793
|
name,
|
|
696
794
|
aliases,
|
|
@@ -700,9 +798,16 @@ function parseOptionSyntax(name, syntax, opts) {
|
|
|
700
798
|
description: opts?.description,
|
|
701
799
|
default: opts?.default,
|
|
702
800
|
required: opts?.required === true,
|
|
703
|
-
choices: opts?.choices,
|
|
801
|
+
choices: choicesOf(opts?.choices),
|
|
704
802
|
};
|
|
705
803
|
}
|
|
804
|
+
/** 登记写的取值收成每次现取的函数,只写值的一项补成 { value } */
|
|
805
|
+
function choicesOf(choices) {
|
|
806
|
+
if (!choices)
|
|
807
|
+
return undefined;
|
|
808
|
+
const list = typeof choices === 'function' ? choices : () => choices;
|
|
809
|
+
return () => list().map(c => (typeof c === 'string' ? { value: c } : c));
|
|
810
|
+
}
|
|
706
811
|
function findOption(defs, nameOrAlias) {
|
|
707
812
|
return defs.find(o => o.name === nameOrAlias || o.aliases.includes(nameOrAlias));
|
|
708
813
|
}
|
|
@@ -714,7 +819,7 @@ function parseOptionValue(def, rawValue, previous) {
|
|
|
714
819
|
if (def.type === 'number') {
|
|
715
820
|
const n = Number(rawValue);
|
|
716
821
|
if (Number.isNaN(n))
|
|
717
|
-
throw new
|
|
822
|
+
throw new TextError(t('optionNumber', { option: `--${def.name}`, value: rawValue }));
|
|
718
823
|
return n;
|
|
719
824
|
}
|
|
720
825
|
if (def.type === 'boolean')
|
|
@@ -730,8 +835,9 @@ function parseOptionValue(def, rawValue, previous) {
|
|
|
730
835
|
];
|
|
731
836
|
}
|
|
732
837
|
// string
|
|
733
|
-
|
|
734
|
-
|
|
838
|
+
const choices = def.choices?.();
|
|
839
|
+
if (choices && !choices.some(c => c.value === rawValue)) {
|
|
840
|
+
throw new TextError(t('optionChoices', { option: `--${def.name}`, choices: choices.map(c => c.value).join(', ') }));
|
|
735
841
|
}
|
|
736
842
|
return rawValue;
|
|
737
843
|
}
|
|
@@ -740,7 +846,7 @@ function parsePositionalValue(def, values) {
|
|
|
740
846
|
if (def.type === 'number') {
|
|
741
847
|
const n = Number(raw);
|
|
742
848
|
if (Number.isNaN(n))
|
|
743
|
-
throw new
|
|
849
|
+
throw new TextError(t('argumentNumber', { name: def.name, value: raw }));
|
|
744
850
|
return n;
|
|
745
851
|
}
|
|
746
852
|
if (def.type === 'boolean')
|