@zhin.js/command 1.1.5 → 1.1.6
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 +48 -3
- package/lib/arguments.d.ts +19 -0
- package/lib/arguments.js +321 -0
- package/lib/command-index.d.ts +11 -0
- package/lib/command-index.js +184 -70
- package/lib/definition.d.ts +56 -6
- package/lib/definition.js +7 -2
- package/lib/index.d.ts +1 -0
- package/lib/provider.js +7 -0
- package/package.json +5 -5
- package/src/arguments.ts +280 -0
- package/src/command-index.ts +166 -102
- package/src/definition.ts +61 -7
- package/src/index.ts +2 -0
- package/src/provider.ts +7 -0
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ import { defineCommand } from 'zhin.js/command';
|
|
|
16
16
|
export default defineCommand({
|
|
17
17
|
description: 'List GitHub issues',
|
|
18
18
|
alias: ['issues'], // 可多词;owner 不参与用户路由
|
|
19
|
-
permit: ['adapter(icqq)', 'role(master)'], // 数组 AND
|
|
19
|
+
permit: ['adapter(icqq)', 'role(master)'], // 数组 AND;失败返回 denied
|
|
20
20
|
// shortcut: { '列 issue': {} }, // 全局整句
|
|
21
21
|
execute: ({ args }) => `issues:${args.join(',')}`,
|
|
22
22
|
});
|
|
@@ -27,7 +27,7 @@ export default defineCommand({
|
|
|
27
27
|
```text
|
|
28
28
|
commands/gh/pr/[[title]]/index.ts -> gh pr [title] (params: { title: { type: 'string', default: 'defaultTitle' } })
|
|
29
29
|
commands/upload/[asset]/index.ts -> upload <asset> (params: { asset: { type: 'image' } })
|
|
30
|
-
commands/search/[...kw]/index.ts -> search <...kw> (params: { kw: { type: 'text' } },运行时 params.kw 为数组;元素粒度随类型:text 逐消息段,word/string 逐词,number/boolean 逐词转换)
|
|
30
|
+
commands/search/[...kw]/index.ts -> search <...kw> (params: { kw: { type: 'text', rest: true } },运行时 params.kw 为数组;元素粒度随类型:text 逐消息段,word/string 逐词,number/boolean 逐词转换)
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
文本类型包括 `string`、`word`、`text`、`number`、`integer`、`float`、`boolean`;结构化
|
|
@@ -50,13 +50,58 @@ commands/search/[...kw]/index.ts -> search <...kw> (params: { kw: { type:
|
|
|
50
50
|
可选声明字段:
|
|
51
51
|
|
|
52
52
|
- `alias`:替换全部本地静态段;owner 不参与用户路由。
|
|
53
|
-
- `permit`:内置 DSL(`adapter|group|private|channel|user|role
|
|
53
|
+
- `permit`:内置 DSL(`adapter|group|private|channel|user|role`);失败为 denied,委托 PermissionHost。
|
|
54
54
|
- `shortcut`:全局整句精确匹配 → 预填 `params`。
|
|
55
55
|
|
|
56
56
|
单文件插件可在 `setup({ addCommand })` 中调用
|
|
57
57
|
`addCommand('hello', defineCommand(...))`。它与目录发现共用 CommandIndex;拆成文件后
|
|
58
58
|
可获得单命令 HMR。
|
|
59
59
|
|
|
60
|
+
|
|
61
|
+
## 集中声明参数、选项与帮助
|
|
62
|
+
|
|
63
|
+
目录提供静态命令路径。静态 `commands/remind/index.ts` 可集中声明任意数量的位置参数;
|
|
64
|
+
现有 `commands/foo/[id]/index.ts` 仍绑定同名 `params.id`,后面的参数无需额外动态目录。
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
export default defineCommand({
|
|
68
|
+
description: '创建提醒',
|
|
69
|
+
params: {
|
|
70
|
+
minutes: { type: 'integer', min: 1, max: 1440, description: '分钟数' },
|
|
71
|
+
note: { type: 'text', default: '休息一下', description: '提醒内容' },
|
|
72
|
+
},
|
|
73
|
+
options: {
|
|
74
|
+
count: { type: 'integer', alias: 'c', default: 1, min: 1, max: 10 },
|
|
75
|
+
quiet: { type: 'boolean', default: false },
|
|
76
|
+
},
|
|
77
|
+
execute: ({ params, options }) => `${params.minutes}:${params.note}:${options.count}`,
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`params` 按声明顺序消费输入;参数类型和 `options` 类型自动推导到 `execute`。
|
|
82
|
+
`optional: true` 无默认值时为 `T | undefined`;`rest: true` 为只读数组且必须最后声明。
|
|
83
|
+
`type: 'text'` 保留文本中的引号、空格和换行,消费连续文本;它应放在其他标量参数之后。
|
|
84
|
+
`min` / `max` 表示数值范围或文本长度;`choices`、`pattern` 与同步 `validate(value, session)`
|
|
85
|
+
提供约束。动态默认值与 shortcut 预填也经过同样校验。非法声明在加载前拒绝。
|
|
86
|
+
|
|
87
|
+
选项支持 `--count 2`、`--count=2`、`-c 2`;布尔 `--quiet` 为 true,
|
|
88
|
+
`--quiet=false` 为 false。选项可在参数之间出现;负数不是选项。`--` 结束选项解析,
|
|
89
|
+
后面的内容作为参数。未知/重复选项拒绝。`remind --help` 从同一声明生成帮助,包含
|
|
90
|
+
Endpoint 前缀及显式插件命名空间,不执行命令;同一权限声明仍约束帮助读取。
|
|
91
|
+
|
|
92
|
+
`dispatch()` 返回 `status`:`unmatched`(继续普通消息处理)、`invalid`(`issues` +
|
|
93
|
+
`help`)、`denied`(权限拒绝)、`executed`。后三者 `matched: true`,只有 `executed`
|
|
94
|
+
包含业务结果。可靠静态路径匹配后才提示错误;纯动态路由的模糊聊天保持未匹配。
|
|
95
|
+
受限命令没有可鉴权主体时拒绝,Host 可通过 `dispatch` 提供真实 `PermissionSubject`;
|
|
96
|
+
`execute(name)` 无主体不能绕过权限。审批与命令准入独立。
|
|
97
|
+
|
|
98
|
+
真实消费者:[二维码](../../../plugins/utils/qrcode/README.md)、
|
|
99
|
+
[代码运行](../../../plugins/utils/code-runner/README.md)。
|
|
100
|
+
|
|
101
|
+
动态目录的路径形态无法被 TypeScript 读取。使用无默认值的 `[[id]]` 时,声明
|
|
102
|
+
`params.id.optional: true`;使用 `[...items]` 时声明 `params.items.rest: true`,
|
|
103
|
+
让执行参数的可选性、数组类型与运行时一致。目录绑定的参数应放在 `params` 第一项。
|
|
104
|
+
|
|
60
105
|
## Runtime
|
|
61
106
|
|
|
62
107
|
definition 在 import 时不注册全局状态。Feature provider 在 generation prepare 阶段完成
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CommandDefinition, CommandParams, CommandOptions, CommandParameterValue, CommandSegment, CommandSession } from './definition.js';
|
|
2
|
+
export interface CommandValidationIssue {
|
|
3
|
+
readonly field: string;
|
|
4
|
+
readonly message: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ParsedCommandArguments {
|
|
7
|
+
readonly params: Readonly<Record<string, CommandParameterValue>>;
|
|
8
|
+
readonly options: Readonly<Record<string, CommandParameterValue>>;
|
|
9
|
+
readonly remaining: readonly Readonly<CommandSegment>[];
|
|
10
|
+
readonly issues: readonly CommandValidationIssue[];
|
|
11
|
+
}
|
|
12
|
+
/** Validate the single declaration used by parsing, help and execution. */
|
|
13
|
+
export declare function validateArgumentSchemas(params?: CommandParams, options?: CommandOptions): void;
|
|
14
|
+
/** Reserved flags use the same quoted-token and option-terminator rules as arguments. */
|
|
15
|
+
export declare function requestsCommandHelp(segments: readonly Readonly<CommandSegment>[]): boolean;
|
|
16
|
+
/** Parse arguments after a route match; the CommandIndex remains the only dispatcher. */
|
|
17
|
+
export declare function parseCommandArguments(definition: CommandDefinition, remaining: readonly Readonly<CommandSegment>[], initial: Readonly<Record<string, CommandParameterValue>>, session: CommandSession): ParsedCommandArguments;
|
|
18
|
+
export declare function commandUsage(name: string, definition: CommandDefinition): string;
|
|
19
|
+
export declare function commandHelp(name: string, definition: CommandDefinition, prefix?: string): string;
|
package/lib/arguments.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
const scalarTypes = new Set(['string', 'word', 'text', 'number', 'integer', 'float', 'boolean']);
|
|
2
|
+
/** Validate the single declaration used by parsing, help and execution. */
|
|
3
|
+
export function validateArgumentSchemas(params, options) {
|
|
4
|
+
let optional = false;
|
|
5
|
+
let rest = false;
|
|
6
|
+
for (const [name, schema] of Object.entries(params ?? {})) {
|
|
7
|
+
validateSchema(name, schema);
|
|
8
|
+
if (rest)
|
|
9
|
+
throw new TypeError('Command rest parameter must be last');
|
|
10
|
+
if (optional && !schema.optional && schema.default === undefined) {
|
|
11
|
+
throw new TypeError(`Required Command parameter ${name} cannot follow an optional parameter`);
|
|
12
|
+
}
|
|
13
|
+
optional ||= schema.optional === true || schema.default !== undefined;
|
|
14
|
+
rest = schema.rest === true;
|
|
15
|
+
}
|
|
16
|
+
if (options !== undefined && (!options || typeof options !== 'object' || Array.isArray(options))) {
|
|
17
|
+
throw new TypeError('Command options must be a record');
|
|
18
|
+
}
|
|
19
|
+
const aliases = new Set();
|
|
20
|
+
for (const [name, schema] of Object.entries(options ?? {})) {
|
|
21
|
+
validateSchema(name, schema);
|
|
22
|
+
if (!scalarTypes.has(schema.type) || schema.type === 'text') {
|
|
23
|
+
throw new TypeError(`Command option ${name} requires a scalar type`);
|
|
24
|
+
}
|
|
25
|
+
if (name === 'help')
|
|
26
|
+
throw new TypeError('Command option help is reserved');
|
|
27
|
+
if (schema.alias !== undefined) {
|
|
28
|
+
if (!/^[a-zA-Z]$/u.test(schema.alias) || schema.alias === 'h' || aliases.has(schema.alias)) {
|
|
29
|
+
throw new TypeError(`Invalid or duplicate Command option alias ${schema.alias}`);
|
|
30
|
+
}
|
|
31
|
+
aliases.add(schema.alias);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function validateSchema(name, schema) {
|
|
36
|
+
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/u.test(name))
|
|
37
|
+
throw new TypeError(`Invalid Command argument name ${name}`);
|
|
38
|
+
if (!schema || typeof schema !== 'object')
|
|
39
|
+
throw new TypeError(`Invalid Command argument ${name}`);
|
|
40
|
+
if (schema.min !== undefined && !Number.isFinite(schema.min)
|
|
41
|
+
|| schema.max !== undefined && !Number.isFinite(schema.max)
|
|
42
|
+
|| schema.min !== undefined && schema.max !== undefined && schema.min > schema.max) {
|
|
43
|
+
throw new TypeError(`Invalid Command argument bounds for ${name}`);
|
|
44
|
+
}
|
|
45
|
+
if (schema.pattern !== undefined && !(schema.pattern instanceof RegExp)) {
|
|
46
|
+
throw new TypeError(`Command argument ${name}.pattern must be a RegExp`);
|
|
47
|
+
}
|
|
48
|
+
if (schema.validate !== undefined && typeof schema.validate !== 'function') {
|
|
49
|
+
throw new TypeError(`Command argument ${name}.validate must be a function`);
|
|
50
|
+
}
|
|
51
|
+
if (schema.choices !== undefined && !Array.isArray(schema.choices)) {
|
|
52
|
+
throw new TypeError(`Command argument ${name}.choices must be an array`);
|
|
53
|
+
}
|
|
54
|
+
if (schema.default !== undefined && !scalarTypes.has(schema.type))
|
|
55
|
+
throw new TypeError(`Structured Command argument ${name} cannot declare a default`);
|
|
56
|
+
if (schema.default !== undefined && typeof schema.default !== 'function') {
|
|
57
|
+
if (Array.isArray(schema.default) && !schema.rest)
|
|
58
|
+
throw new TypeError(`Command argument ${name} requires rest: true for an array default`);
|
|
59
|
+
const values = schema.rest ? schema.default : [schema.default];
|
|
60
|
+
if (!Array.isArray(values) || values.some((value) => !valueMatches(schema, value))) {
|
|
61
|
+
throw new TypeError(`Invalid Command argument default for ${name}`);
|
|
62
|
+
}
|
|
63
|
+
const defaultSchema = { ...schema, validate: undefined };
|
|
64
|
+
const issue = validateValue(name, defaultSchema, schema.default, {});
|
|
65
|
+
if (issue)
|
|
66
|
+
throw new TypeError(`Invalid Command argument default for ${name}: ${issue.message}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function isTextToken(value) {
|
|
70
|
+
return value !== undefined && 'value' in value;
|
|
71
|
+
}
|
|
72
|
+
function tokenize(segments) {
|
|
73
|
+
return segments.flatMap((segment, segmentIndex) => {
|
|
74
|
+
if (segment.type !== 'text' || typeof segment.data.text !== 'string')
|
|
75
|
+
return [segment];
|
|
76
|
+
const tokens = [];
|
|
77
|
+
const pattern = /(--?[a-zA-Z][a-zA-Z0-9_-]*=)(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)')|"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\S+)/gu;
|
|
78
|
+
let end = 0;
|
|
79
|
+
for (const match of segment.data.text.matchAll(pattern)) {
|
|
80
|
+
const value = match[1] !== undefined ? match[1] + (match[2] ?? match[3] ?? '') : match[4] ?? match[5] ?? match[6] ?? '';
|
|
81
|
+
tokens.push({ value: value.replace(/\\(["'\\])/gu, '$1'), quoted: match[1] === undefined && match[6] === undefined, raw: match[0], before: segment.data.text.slice(end, match.index), first: tokens.length === 0, segment: segmentIndex });
|
|
82
|
+
end = (match.index ?? 0) + match[0].length;
|
|
83
|
+
}
|
|
84
|
+
const last = tokens.at(-1);
|
|
85
|
+
if (isTextToken(last))
|
|
86
|
+
tokens[tokens.length - 1] = { ...last, after: segment.data.text.slice(end) };
|
|
87
|
+
return tokens;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/** Reserved flags use the same quoted-token and option-terminator rules as arguments. */
|
|
91
|
+
export function requestsCommandHelp(segments) {
|
|
92
|
+
for (const token of tokenize(segments)) {
|
|
93
|
+
if (!isTextToken(token) || token.quoted)
|
|
94
|
+
continue;
|
|
95
|
+
if (token.value === '--')
|
|
96
|
+
return false;
|
|
97
|
+
if (token.value === '--help')
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
function segmentsFromTokens(tokens) {
|
|
103
|
+
const result = [];
|
|
104
|
+
let lastTextSegment;
|
|
105
|
+
for (const token of tokens) {
|
|
106
|
+
if (isTextToken(token)) {
|
|
107
|
+
const last = result.at(-1);
|
|
108
|
+
const text = (token.raw ?? token.value) + (token.after ?? '');
|
|
109
|
+
if (last?.type === 'text' && lastTextSegment === token.segment)
|
|
110
|
+
result[result.length - 1] = { type: 'text', data: { text: `${last.data.text}${token.before || ' '}${text}` } };
|
|
111
|
+
else
|
|
112
|
+
result.push({ type: 'text', data: { text: (token.first ? token.before ?? '' : '') + text } });
|
|
113
|
+
lastTextSegment = token.segment;
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
result.push(token);
|
|
117
|
+
lastTextSegment = undefined;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return Object.freeze(result);
|
|
121
|
+
}
|
|
122
|
+
function convert(schema, token) {
|
|
123
|
+
if (!token)
|
|
124
|
+
return undefined;
|
|
125
|
+
if (isTextToken(token)) {
|
|
126
|
+
const text = token.value;
|
|
127
|
+
if (schema.type === 'string' || schema.type === 'word' || schema.type === 'text')
|
|
128
|
+
return text;
|
|
129
|
+
if (schema.type === 'boolean')
|
|
130
|
+
return text === 'true' ? true : text === 'false' ? false : undefined;
|
|
131
|
+
if (schema.type === 'number' || schema.type === 'integer' || schema.type === 'float') {
|
|
132
|
+
const value = Number(text);
|
|
133
|
+
return text !== '' && Number.isFinite(value) && (schema.type !== 'integer' || Number.isInteger(value))
|
|
134
|
+
&& (schema.type !== 'float' || text.includes('.')) ? value : undefined;
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
const type = typeof token.type === 'string' ? token.type : token.type.name;
|
|
139
|
+
if (type !== schema.type && !(schema.type === 'mention' && type === 'at'))
|
|
140
|
+
return undefined;
|
|
141
|
+
const data = token.data;
|
|
142
|
+
const value = schema.type === 'image' ? data.media ?? data.file ?? data.url ?? data.src
|
|
143
|
+
: schema.type === 'mention' ? data.target ?? data.user_id ?? data.qq
|
|
144
|
+
: schema.type === 'reply' ? data.message_id ?? data.reply_id ?? data.id
|
|
145
|
+
: schema.type === 'forward' ? data.forward_id ?? data.res_id ?? data.message_id ?? data.id
|
|
146
|
+
: schema.type === 'dice' || schema.type === 'rps' ? data.result : data.id;
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
function valueMatches(schema, value) {
|
|
150
|
+
if (schema.type === 'number' || schema.type === 'integer' || schema.type === 'float' || schema.type === 'dice' || schema.type === 'rps') {
|
|
151
|
+
return typeof value === 'number' && Number.isFinite(value) && (schema.type !== 'integer' || Number.isInteger(value));
|
|
152
|
+
}
|
|
153
|
+
if (schema.type === 'mention' || schema.type === 'face' || schema.type === 'reply' || schema.type === 'forward')
|
|
154
|
+
return typeof value === 'string' || typeof value === 'number' && Number.isFinite(value);
|
|
155
|
+
if (schema.type === 'boolean')
|
|
156
|
+
return typeof value === 'boolean';
|
|
157
|
+
if (schema.type === 'image')
|
|
158
|
+
return typeof value === 'string' || !!value && typeof value === 'object';
|
|
159
|
+
return typeof value === 'string';
|
|
160
|
+
}
|
|
161
|
+
function validateValue(field, schema, value, session) {
|
|
162
|
+
if (schema.rest && !Array.isArray(value))
|
|
163
|
+
return { field, message: `${field}: expected an array of ${schema.type}` };
|
|
164
|
+
const values = schema.rest ? (Array.isArray(value) ? value : [value]) : [value];
|
|
165
|
+
for (const item of values) {
|
|
166
|
+
if (!valueMatches(schema, item))
|
|
167
|
+
return { field, message: `${field}: expected ${schema.type}` };
|
|
168
|
+
const size = typeof item === 'number' ? item : typeof item === 'string' ? item.length : undefined;
|
|
169
|
+
if (schema.min !== undefined && (size === undefined || size < schema.min))
|
|
170
|
+
return { field, message: `${field}: minimum ${schema.min}` };
|
|
171
|
+
if (schema.max !== undefined && (size === undefined || size > schema.max))
|
|
172
|
+
return { field, message: `${field}: maximum ${schema.max}` };
|
|
173
|
+
if (schema.choices && !schema.choices.includes(item))
|
|
174
|
+
return { field, message: `${field}: expected one of ${schema.choices.join(', ')}` };
|
|
175
|
+
if (schema.pattern) {
|
|
176
|
+
const pattern = new RegExp(schema.pattern.source, schema.pattern.flags.replace(/[gy]/gu, ''));
|
|
177
|
+
if (typeof item !== 'string' || !pattern.test(item))
|
|
178
|
+
return { field, message: `${field}: invalid format` };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const result = schema.validate?.(value, session);
|
|
182
|
+
return result === false || typeof result === 'string' ? { field, message: typeof result === 'string' ? result : `${field}: invalid value` } : undefined;
|
|
183
|
+
}
|
|
184
|
+
/** Parse arguments after a route match; the CommandIndex remains the only dispatcher. */
|
|
185
|
+
export function parseCommandArguments(definition, remaining, initial, session) {
|
|
186
|
+
const params = { __proto__: null, ...initial };
|
|
187
|
+
const options = { __proto__: null };
|
|
188
|
+
const issues = [];
|
|
189
|
+
const tokens = tokenize(remaining);
|
|
190
|
+
const positional = [];
|
|
191
|
+
const schemas = definition.options ?? (definition.params === undefined ? undefined : {});
|
|
192
|
+
const optionNames = new Map(Object.entries(schemas ?? {}).flatMap(([name, schema]) => [[`--${name}`, name], ...(schema.alias ? [[`-${schema.alias}`, name]] : [])]));
|
|
193
|
+
let terminated = false;
|
|
194
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
195
|
+
const token = tokens[index];
|
|
196
|
+
if (!token)
|
|
197
|
+
continue;
|
|
198
|
+
if (!isTextToken(token) || token.quoted || terminated || schemas === undefined) {
|
|
199
|
+
positional.push(token);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (token.value === '--') {
|
|
203
|
+
terminated = true;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (!/^--?[a-zA-Z]/u.test(token.value)) {
|
|
207
|
+
positional.push(token);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const [rawFlag, inline] = token.value.split(/=(.*)/u);
|
|
211
|
+
const flag = rawFlag ?? token.value;
|
|
212
|
+
const name = optionNames.get(flag);
|
|
213
|
+
if (!name) {
|
|
214
|
+
issues.push({ field: flag, message: `Unknown option ${flag}` });
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const schema = schemas[name];
|
|
218
|
+
if (!schema)
|
|
219
|
+
continue;
|
|
220
|
+
if (Object.hasOwn(options, name)) {
|
|
221
|
+
issues.push({ field: name, message: `Duplicate option --${name}` });
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
let value;
|
|
225
|
+
if (inline !== undefined)
|
|
226
|
+
value = convert(schema, { value: inline, quoted: false });
|
|
227
|
+
else if (schema.type === 'boolean')
|
|
228
|
+
value = true;
|
|
229
|
+
else {
|
|
230
|
+
const next = tokens[index + 1];
|
|
231
|
+
if (isTextToken(next) && !(!next.quoted && /^--?[a-zA-Z]/u.test(next.value))) {
|
|
232
|
+
index++;
|
|
233
|
+
value = convert(schema, next);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (value === undefined)
|
|
237
|
+
issues.push({ field: name, message: `--${name}: expected ${schema.type}` });
|
|
238
|
+
else
|
|
239
|
+
options[name] = value;
|
|
240
|
+
}
|
|
241
|
+
for (const [name, schema] of Object.entries(schemas ?? {})) {
|
|
242
|
+
if (!Object.hasOwn(options, name) && schema.default !== undefined)
|
|
243
|
+
options[name] = typeof schema.default === 'function' ? schema.default(session) : schema.default;
|
|
244
|
+
if (Object.hasOwn(options, name) && options[name] === undefined)
|
|
245
|
+
issues.push({ field: name, message: `--${name}: default returned undefined` });
|
|
246
|
+
else if (options[name] !== undefined) {
|
|
247
|
+
const issue = validateValue(`--${name}`, schema, options[name], session);
|
|
248
|
+
if (issue)
|
|
249
|
+
issues.push(issue);
|
|
250
|
+
}
|
|
251
|
+
else if (schema.optional === false)
|
|
252
|
+
issues.push({ field: name, message: `Missing option --${name}` });
|
|
253
|
+
}
|
|
254
|
+
const declared = Object.entries(definition.params ?? {});
|
|
255
|
+
for (const [name, declaredSchema] of declared) {
|
|
256
|
+
const schema = definition.$parameter?.name === name ? { ...declaredSchema, optional: definition.$parameter.optional ?? declaredSchema.optional, rest: definition.$parameter.rest ?? declaredSchema.rest } : declaredSchema;
|
|
257
|
+
if (!Object.hasOwn(params, name)) {
|
|
258
|
+
let value;
|
|
259
|
+
if (schema.rest) {
|
|
260
|
+
const raw = positional.splice(0);
|
|
261
|
+
const converted = schema.type === 'text'
|
|
262
|
+
? segmentsFromTokens(raw).map((segment) => segment.type === 'text' && typeof segment.data.text === 'string' ? segment.data.text : undefined)
|
|
263
|
+
: raw.map((token) => convert(schema, token));
|
|
264
|
+
if (converted.some((item) => item === undefined))
|
|
265
|
+
issues.push({ field: name, message: `${name}: expected ${schema.type}` });
|
|
266
|
+
else if (converted.length > 0 || schema.optional)
|
|
267
|
+
value = converted;
|
|
268
|
+
}
|
|
269
|
+
else if (schema.type === 'text') {
|
|
270
|
+
const raw = [];
|
|
271
|
+
while (isTextToken(positional[0])) {
|
|
272
|
+
const token = positional.shift();
|
|
273
|
+
raw.push(`${raw.length > 0 ? token.before || ' ' : ''}${token.raw ?? token.value}${token.after ?? ''}`);
|
|
274
|
+
}
|
|
275
|
+
if (raw.length > 0)
|
|
276
|
+
value = raw.join('');
|
|
277
|
+
}
|
|
278
|
+
else if (positional.length > 0) {
|
|
279
|
+
value = convert(schema, positional.shift());
|
|
280
|
+
// Defaults apply to omitted input, never to a supplied value of the wrong type.
|
|
281
|
+
if (value === undefined) {
|
|
282
|
+
issues.push({ field: name, message: `${name}: expected ${schema.type}` });
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (value !== undefined)
|
|
287
|
+
params[name] = value;
|
|
288
|
+
else if (schema.default !== undefined)
|
|
289
|
+
params[name] = typeof schema.default === 'function' ? schema.default(session) : schema.default;
|
|
290
|
+
else if (!schema.optional && !(definition.$parameter?.name === name && definition.$parameter.optional)) {
|
|
291
|
+
issues.push({ field: name, message: `${name}: expected ${schema.type}` });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (Object.hasOwn(params, name) && params[name] === undefined)
|
|
295
|
+
issues.push({ field: name, message: `${name}: default or shortcut returned undefined` });
|
|
296
|
+
else if (params[name] !== undefined) {
|
|
297
|
+
const effective = definition.$parameter?.name === name && definition.$parameter.rest ? { ...schema, rest: true } : schema;
|
|
298
|
+
const issue = validateValue(name, effective, params[name], session);
|
|
299
|
+
if (issue)
|
|
300
|
+
issues.push(issue);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (declared.length > 0 && !definition.$parameter && positional.length > 0)
|
|
304
|
+
issues.push({ field: 'arguments', message: 'Unexpected arguments' });
|
|
305
|
+
return Object.freeze({ params: Object.freeze(params), options: Object.freeze(options), remaining: schemas === undefined && declared.every(([name]) => Object.hasOwn(initial, name)) ? remaining : segmentsFromTokens(positional), issues: Object.freeze(issues) });
|
|
306
|
+
}
|
|
307
|
+
export function commandUsage(name, definition) {
|
|
308
|
+
const parameters = Object.entries(definition.params ?? {}).filter(([key]) => key !== definition.$parameter?.name)
|
|
309
|
+
.map(([key, schema]) => `${schema.optional || schema.default !== undefined ? '[' : '<'}${schema.rest ? '...' : ''}${key}${schema.optional || schema.default !== undefined ? ']' : '>'}`);
|
|
310
|
+
const options = Object.entries(definition.options ?? {}).map(([key, schema]) => `[--${key}${schema.type === 'boolean' ? '' : ` <${schema.type}>`}]`);
|
|
311
|
+
return [name, ...parameters, ...options].join(' ');
|
|
312
|
+
}
|
|
313
|
+
export function commandHelp(name, definition, prefix = '') {
|
|
314
|
+
return [prefix + commandUsage(name, definition), definition.description,
|
|
315
|
+
...Object.entries(definition.params ?? {}).map(([key, schema]) => argumentHelp(key, schema)),
|
|
316
|
+
...Object.entries(definition.options ?? {}).map(([key, schema]) => argumentHelp(`--${key}${schema.alias ? ` (-${schema.alias})` : ''}`, schema)),
|
|
317
|
+
].filter(Boolean).join('\n');
|
|
318
|
+
}
|
|
319
|
+
function argumentHelp(name, schema) {
|
|
320
|
+
return ` ${name}: ${schema.type}${schema.description ? ` — ${schema.description}` : ''}${schema.default !== undefined ? ` (default: ${typeof schema.default === 'function' ? 'dynamic' : JSON.stringify(schema.default)})` : ''}${schema.choices ? ` (${schema.choices.join(', ')})` : ''}${schema.min !== undefined ? ` min=${schema.min}` : ''}${schema.max !== undefined ? ` max=${schema.max}` : ''}`;
|
|
321
|
+
}
|
package/lib/command-index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CapabilitySlot, PluginId, RuntimeSnapshot } from '@zhin.js/plugin-runtime';
|
|
2
2
|
import type { UserInteractionFactory } from '@zhin.js/interaction';
|
|
3
|
+
import { type CommandValidationIssue } from './arguments.js';
|
|
3
4
|
import { type CommandDefinition, type CommandParameterDefinition, type CommandParameterType, type CommandSegment } from './definition.js';
|
|
4
5
|
export interface CommandParameterDescriptor extends CommandParameterDefinition {
|
|
5
6
|
readonly required: boolean;
|
|
@@ -10,6 +11,8 @@ export interface CommandDescriptor {
|
|
|
10
11
|
readonly adapter?: string;
|
|
11
12
|
readonly source: string;
|
|
12
13
|
readonly parameters: readonly CommandParameterDescriptor[];
|
|
14
|
+
readonly usage: string;
|
|
15
|
+
readonly options: NonNullable<CommandDefinition['options']>;
|
|
13
16
|
readonly alias?: readonly string[];
|
|
14
17
|
readonly permit?: readonly string[];
|
|
15
18
|
/** shortcut 触发键列表(不含预填 params)。 */
|
|
@@ -17,6 +20,9 @@ export interface CommandDescriptor {
|
|
|
17
20
|
}
|
|
18
21
|
export interface CommandDispatchResult {
|
|
19
22
|
readonly matched: boolean;
|
|
23
|
+
readonly status: 'unmatched' | 'invalid' | 'denied' | 'executed';
|
|
24
|
+
readonly issues?: readonly CommandValidationIssue[];
|
|
25
|
+
readonly help?: string;
|
|
20
26
|
readonly command?: string;
|
|
21
27
|
readonly owner?: PluginId;
|
|
22
28
|
readonly value?: unknown;
|
|
@@ -39,3 +45,8 @@ export declare function isCommandIndex(value: unknown): value is CommandIndex;
|
|
|
39
45
|
export declare class CommandParameterValueError extends TypeError {
|
|
40
46
|
constructor(name: string, type: CommandParameterType, value: string);
|
|
41
47
|
}
|
|
48
|
+
export declare class CommandArgumentValidationError extends TypeError {
|
|
49
|
+
readonly issues: readonly CommandValidationIssue[];
|
|
50
|
+
readonly help: string;
|
|
51
|
+
constructor(issues: readonly CommandValidationIssue[], help: string);
|
|
52
|
+
}
|