@zhin.js/command 1.0.5 → 1.0.7

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 CHANGED
@@ -15,16 +15,17 @@ export default defineCommand({
15
15
  });
16
16
  ```
17
17
 
18
- 最后一个文件名可以声明参数:
18
+ 最后一个文件名可以用 Next.js 风格方括号声明参数形态,类型与默认值在 `defineCommand({ params })` 中声明(`type` 必填,`default` 可选且有默认值时文件名必须用双方括号):
19
19
 
20
20
  ```text
21
- commands/gh/pr/[title:string=defaultTitle].ts -> gh pr [title]
22
- commands/upload/[asset:image].ts -> upload <asset>
21
+ commands/gh/pr/[[title]].ts -> gh pr [title] (params: { title: { type: 'string', default: 'defaultTitle' } })
22
+ commands/upload/[asset].ts -> upload <asset> (params: { asset: { type: 'image' } })
23
+ commands/search/[...kw].ts -> search <...kw> (params: { kw: { type: 'text' } },运行时 params.kw 为数组;元素粒度随类型:text 逐消息段,word/string 逐词,number/boolean 逐词转换)
23
24
  ```
24
25
 
25
26
  文本类型包括 `string`、`word`、`text`、`number`、`integer`、`float`、`boolean`;结构化
26
27
  类型包括 `mention`、`image`、`face`、`reply`、`forward`、`dice`、`rps`。结构化类型
27
- 直接从对应 segment 取值,不能声明文件名默认值。
28
+ 直接从对应 segment 取值,不能声明默认值。
28
29
 
29
30
  `execute()` 收到冻结的 `CommandContext`:
30
31
 
@@ -31,7 +31,7 @@ export class CommandIndex {
31
31
  source: slot.source,
32
32
  parameters: Object.freeze(parameter ? [{
33
33
  ...parameter,
34
- required: parameter.defaultValue === undefined,
34
+ required: isRequiredParameter(parameter),
35
35
  }] : []),
36
36
  slot,
37
37
  segments: Object.freeze(segments),
@@ -100,12 +100,29 @@ export class CommandIndex {
100
100
  const result = command.matcher.match(asMatcherSegments(segments));
101
101
  if (!result || !hasCommandBoundary(result.remaining))
102
102
  continue;
103
+ const parameter = command.parameter;
104
+ const params = { ...result.params };
105
+ if (parameter?.rest) {
106
+ const raw = result.params[parameter.name];
107
+ const coerced = coerceRestValues(parameter, Array.isArray(raw) ? raw : []);
108
+ // 必需 `[...name]` 捕获所有:零元素视为不匹配;标量逐词转换失败同样不匹配。
109
+ if (!coerced || (isRequiredParameter(parameter) && coerced.length === 0))
110
+ continue;
111
+ params[parameter.name] = coerced;
112
+ }
103
113
  const remaining = normalizeSegments(result.remaining);
104
114
  if (exact && remaining.length > 0)
105
115
  continue;
116
+ // `[[name]]` 无 default 且未命中时,matcher 对 text 回退 ''、其他类型回退 null;
117
+ // 按契约(省略 default 时未匹配为 undefined)删除该键。
118
+ if (parameter && !parameter.rest && parameter.optional === true
119
+ && parameter.defaultValue === undefined
120
+ && (params[parameter.name] === '' || params[parameter.name] === null)) {
121
+ delete params[parameter.name];
122
+ }
106
123
  return {
107
124
  command,
108
- params: Object.freeze({ ...result.params }),
125
+ params: Object.freeze(params),
109
126
  remaining,
110
127
  };
111
128
  }
@@ -115,7 +132,7 @@ export class CommandIndex {
115
132
  const words = splitCommand(name);
116
133
  for (const command of this.#commands) {
117
134
  const parameter = command.parameter;
118
- if (!parameter)
135
+ if (!parameter || parameter.rest)
119
136
  continue;
120
137
  const parameterIndex = command.segments.findIndex((segment) => segment.startsWith('$'));
121
138
  if (words.length !== command.segments.length)
@@ -154,15 +171,26 @@ function assertParameterSegment(segments, parameter, source) {
154
171
  && dynamicSegments[0] === `$${parameter.name}`
155
172
  && segments.at(-1) === dynamicSegments[0])
156
173
  return;
157
- throw new Error(`Broken dynamic Command identity for ${source}`);
174
+ const dynamic = dynamicSegments[0] ?? (parameter ? `$${parameter.name}` : '$?');
175
+ throw new Error(`Invalid Command path for ${source}: the dynamic segment "${dynamic}" must be the only dynamic `
176
+ + `segment and come after a static segment (child plugin commands are prefixed by the plugin `
177
+ + `path, so a dynamic first segment is never reachable). `
178
+ + (parameter
179
+ ? `Hint: move the file under a static directory, e.g. "commands/add/[${parameter.name}:${parameter.type}].ts".`
180
+ : 'Hint: put the file under a static directory, e.g. "commands/add/<file>.ts".'));
181
+ }
182
+ function isRequiredParameter(parameter) {
183
+ return parameter.optional === true ? false : parameter.defaultValue === undefined;
158
184
  }
159
185
  function displayName(segments, parameter) {
160
186
  return segments.map((segment) => {
161
187
  if (!segment.startsWith('$'))
162
188
  return segment;
163
- return parameter?.defaultValue === undefined
164
- ? `<${segment.slice(1)}>`
165
- : `[${segment.slice(1)}]`;
189
+ const label = segment.slice(1);
190
+ const required = !parameter || isRequiredParameter(parameter);
191
+ if (parameter?.rest)
192
+ return required ? `<...${label}>` : `[...${label}]`;
193
+ return required ? `<${label}>` : `[${label}]`;
166
194
  }).join(' ');
167
195
  }
168
196
  function matcherPattern(segments, parameter) {
@@ -172,26 +200,80 @@ function matcherPattern(segments, parameter) {
172
200
  if (!parameter)
173
201
  throw new Error(`Missing Command parameter metadata: ${segment}`);
174
202
  const type = matcherType(parameter.type);
203
+ // rest:结构化类型按消息段收集;标量类型先按 text 段收集,再在 #match 里逐词切分转换。
204
+ if (parameter.rest) {
205
+ return `[...${parameter.name}:${isStructuredRestType(parameter.type) ? type : 'text'}]`;
206
+ }
207
+ if (isRequiredParameter(parameter))
208
+ return `<${parameter.name}:${type}>`;
175
209
  return parameter.defaultValue === undefined
176
- ? `<${parameter.name}:${type}>`
210
+ ? `[${parameter.name}:${type}]`
177
211
  : `[${parameter.name}:${type}=${String(parameter.defaultValue)}]`;
178
212
  }).join(' ');
179
213
  }
180
214
  function matcherType(type) {
181
215
  return type === 'string' ? 'word' : type;
182
216
  }
217
+ /** rest 参数中按消息段收集(matcher 原生行为)的结构化类型。 */
218
+ function isStructuredRestType(type) {
219
+ return type === 'mention'
220
+ || type === 'image'
221
+ || type === 'face'
222
+ || type === 'reply'
223
+ || type === 'forward'
224
+ || type === 'dice'
225
+ || type === 'rps';
226
+ }
227
+ /**
228
+ * 捕获所有参数的取值粒度由类型决定:
229
+ * - `text` / 结构化类型:逐消息段(matcher 原生结果);
230
+ * - `word` / `string`:逐词(空白切分);
231
+ * - `number` / `integer` / `float` / `boolean`:逐词切分后逐个转换,任一失败返回 undefined(不匹配)。
232
+ */
233
+ function coerceRestValues(parameter, values) {
234
+ const type = parameter.type;
235
+ if (type === 'text' || isStructuredRestType(type)) {
236
+ // 逐消息段,保持 matcher 原生提取值(text 为 string,结构化类型可能是 number 等)。
237
+ return values;
238
+ }
239
+ const words = values.flatMap((value) => typeof value === 'string' ? value.split(/\s+/u).filter(Boolean) : []);
240
+ if (type === 'string' || type === 'word')
241
+ return words;
242
+ if (type === 'number' || type === 'integer' || type === 'float') {
243
+ const numbers = [];
244
+ for (const [index, word] of words.entries()) {
245
+ const number = Number(word);
246
+ if (!Number.isFinite(number)
247
+ || (type === 'integer' && !Number.isInteger(number))
248
+ || (type === 'float' && !word.includes('.')))
249
+ return undefined;
250
+ numbers[index] = number;
251
+ }
252
+ return numbers;
253
+ }
254
+ if (type === 'boolean') {
255
+ if (!words.every((word) => word === 'true' || word === 'false'))
256
+ return undefined;
257
+ return words.map((word) => word === 'true');
258
+ }
259
+ return undefined;
260
+ }
183
261
  function routeShape(segments) {
184
262
  return segments.map((segment) => segment.startsWith('$') ? '$' : segment).join(' ');
185
263
  }
186
264
  function compareCommands(left, right) {
187
- const leftDynamic = left.parameter ? 1 : 0;
188
- const rightDynamic = right.parameter ? 1 : 0;
189
- return leftDynamic - rightDynamic
265
+ return dynamicWeight(left) - dynamicWeight(right)
190
266
  || staticSegmentCount(right.segments) - staticSegmentCount(left.segments)
191
267
  || right.segments.length - left.segments.length
192
268
  || right.name.length - left.name.length
193
269
  || left.name.localeCompare(right.name);
194
270
  }
271
+ /** 静态 < 单参数 < 捕获所有:更具体的形状优先匹配。 */
272
+ function dynamicWeight(command) {
273
+ if (!command.parameter)
274
+ return 0;
275
+ return command.parameter.rest ? 2 : 1;
276
+ }
195
277
  function staticSegmentCount(segments) {
196
278
  return segments.filter((segment) => !segment.startsWith('$')).length;
197
279
  }
@@ -2,7 +2,18 @@ import type { PluginId, RuntimeSnapshot } from '@zhin.js/plugin-runtime';
2
2
  import { type CapabilityContext } from '@zhin.js/feature-kit';
3
3
  declare const commandBrand: "zhin.command/1";
4
4
  export type CommandParameterType = 'string' | 'number' | 'integer' | 'float' | 'boolean' | 'word' | 'text' | 'mention' | 'image' | 'face' | 'reply' | 'forward' | 'dice' | 'rps';
5
- export type CommandParameterValue = string | number | boolean | Readonly<Record<string, unknown>> | null;
5
+ export type CommandParameterValue = string | number | boolean | ReadonlyArray<string | number | boolean> | Readonly<Record<string, unknown>> | null;
6
+ export declare const commandParameterTypes: ReadonlySet<CommandParameterType>;
7
+ /**
8
+ * Next.js 风格参数声明(`defineCommand({ params: ... })`)。
9
+ * 文件名只声明参数形态(`[name]` / `[[name]]` / `[...name]` / `[[...name]]`),
10
+ * 类型与默认值统一在这里声明。
11
+ */
12
+ export interface CommandParamSchema {
13
+ readonly type: CommandParameterType;
14
+ readonly default?: CommandParameterValue;
15
+ readonly description?: string;
16
+ }
6
17
  /** Minimal structural contract shared with canonical IM segments. */
7
18
  export interface CommandSegment {
8
19
  readonly type: string | {
@@ -14,6 +25,11 @@ export interface CommandParameterDefinition {
14
25
  readonly name: string;
15
26
  readonly type: CommandParameterType;
16
27
  readonly defaultValue?: CommandParameterValue;
28
+ /** `[[name]]` / `[[...name]]` 可选段;缺省按 `defaultValue === undefined` 推断。 */
29
+ readonly optional?: boolean;
30
+ /** `[...name]` / `[[...name]]` 捕获所有段,运行时值为 `string[]`。 */
31
+ readonly rest?: boolean;
32
+ readonly description?: string;
17
33
  }
18
34
  /** 场景:群 / 私聊 / 频道等。 */
19
35
  export interface CommandScene {
@@ -30,6 +46,24 @@ export interface CommandSender {
30
46
  readonly name?: string;
31
47
  readonly role: readonly string[];
32
48
  }
49
+ /**
50
+ * 命令侧入站会话契约(结构对齐 `@zhin.js/im-contract` 的 ConversationRef;
51
+ * command 为 Feature 层,不能 import core / IM 契约包,故独立声明)。
52
+ */
53
+ export interface CommandConversation {
54
+ readonly endpoint: Readonly<{
55
+ readonly id: string;
56
+ /** 适配器插件 owner(PluginId),与 `snapshot.config.get(adapter)` 对齐。 */
57
+ readonly adapter: string;
58
+ }>;
59
+ readonly kind: 'private' | 'group' | 'channel';
60
+ readonly id: string;
61
+ readonly parent?: Readonly<{
62
+ readonly kind: 'group' | 'channel';
63
+ readonly id: string;
64
+ }>;
65
+ readonly threadId?: string;
66
+ }
33
67
  /**
34
68
  * 命令侧入站消息契约。
35
69
  *
@@ -37,8 +71,7 @@ export interface CommandSender {
37
71
  * 因架构分层(command 为 Feature 层,不能 import core),此处独立声明。
38
72
  */
39
73
  export interface CommandMessage {
40
- readonly adapter: string;
41
- readonly target: string;
74
+ readonly conversation: CommandConversation;
42
75
  readonly content: string;
43
76
  /** 发送者 id(扁平字段;结构化视图见 CommandContext.sender)。 */
44
77
  readonly sender?: string;
@@ -82,6 +115,11 @@ export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput
82
115
  readonly $feature: typeof commandBrand;
83
116
  readonly $parameter?: CommandParameterDefinition;
84
117
  readonly description?: string;
118
+ /**
119
+ * Next.js 风格参数声明:动态段文件名(`[name]` 等)的形态配合这里的
120
+ * 类型 / 默认值 / 描述使用。静态命令可忽略本字段。
121
+ */
122
+ readonly params?: Readonly<Record<string, CommandParamSchema>>;
85
123
  execute(context: CommandContext<TConfig, TInput>): TResult | Promise<TResult>;
86
124
  }
87
125
  declare module '@zhin.js/plugin-runtime' {
package/lib/definition.js CHANGED
@@ -1,5 +1,21 @@
1
1
  import { createCapabilityContext, } from '@zhin.js/feature-kit';
2
2
  const commandBrand = 'zhin.command/1';
3
+ export const commandParameterTypes = new Set([
4
+ 'string',
5
+ 'number',
6
+ 'integer',
7
+ 'float',
8
+ 'boolean',
9
+ 'word',
10
+ 'text',
11
+ 'mention',
12
+ 'image',
13
+ 'face',
14
+ 'reply',
15
+ 'forward',
16
+ 'dice',
17
+ 'rps',
18
+ ]);
3
19
  /**
4
20
  * 定义一个命令模块(`commands/` 约定目录下默认导出)。
5
21
  * @public 用户侧创作面,承诺 semver(见 docs/contributing/public-api-surface.md)。
@@ -8,6 +24,17 @@ export function defineCommand(definition) {
8
24
  if (typeof definition.execute !== 'function') {
9
25
  throw new TypeError('Command execute must be a function');
10
26
  }
27
+ if (definition.params !== undefined) {
28
+ if (!definition.params || typeof definition.params !== 'object') {
29
+ throw new TypeError('Command params must be a Record<string, CommandParamSchema>');
30
+ }
31
+ for (const [name, schema] of Object.entries(definition.params)) {
32
+ if (!schema || typeof schema !== 'object'
33
+ || !commandParameterTypes.has(schema.type)) {
34
+ throw new TypeError(`Command params.${name} requires a valid type`);
35
+ }
36
+ }
37
+ }
11
38
  return Object.freeze({ $feature: commandBrand, ...definition });
12
39
  }
13
40
  export function bindCommandParameter(definition, parameter) {
@@ -47,7 +74,7 @@ export function resolveCommandSession(input) {
47
74
  const metadata = input.metadata && typeof input.metadata === 'object'
48
75
  ? input.metadata
49
76
  : undefined;
50
- const adapter = input.adapter.split('\0')[0] || undefined;
77
+ const adapter = input.conversation.endpoint.adapter || undefined;
51
78
  const endpoint = typeof metadata?.endpoint === 'string' && metadata.endpoint
52
79
  ? metadata.endpoint
53
80
  : undefined;
@@ -64,8 +91,8 @@ function isCommandMessageLike(input) {
64
91
  if (!input || typeof input !== 'object')
65
92
  return false;
66
93
  const value = input;
67
- return typeof value.adapter === 'string'
68
- && typeof value.target === 'string'
94
+ return !!value.conversation
95
+ && typeof value.conversation === 'object'
69
96
  && typeof value.content === 'string';
70
97
  }
71
98
  function resolveScene(input, metadata) {
@@ -76,12 +103,12 @@ function resolveScene(input, metadata) {
76
103
  ...(input.scene.name !== undefined ? { name: input.scene.name } : {}),
77
104
  });
78
105
  }
79
- const parsed = parseTarget(input.target);
106
+ const conversation = input.conversation;
80
107
  const type = (typeof metadata?.channelType === 'string' && metadata.channelType)
81
108
  || (typeof metadata?.type === 'string' && metadata.type)
82
- || parsed?.type;
109
+ || conversation.kind;
83
110
  const id = (typeof metadata?.channelId === 'string' && metadata.channelId)
84
- || parsed?.id;
111
+ || conversation.id;
85
112
  if (!type || !id)
86
113
  return undefined;
87
114
  const name = firstString(metadata?.channelName, metadata?.group_name, metadata?.groupName, metadata?.sceneName);
@@ -136,24 +163,6 @@ function resolveRoles(metadata) {
136
163
  roles.push('user');
137
164
  return Object.freeze(roles);
138
165
  }
139
- function parseTarget(target) {
140
- const parts = target.split(':').filter(Boolean);
141
- if (parts.length < 2)
142
- return undefined;
143
- const [kind, ...rest] = parts;
144
- if (!kind)
145
- return undefined;
146
- const lastPart = parts.at(-1);
147
- if (!lastPart)
148
- return undefined;
149
- if (kind === 'channel' && parts.length >= 3) {
150
- return { type: 'channel', id: lastPart };
151
- }
152
- if (kind === 'temp' && parts.length >= 3) {
153
- return { type: 'private', id: lastPart };
154
- }
155
- return { type: kind, id: rest.join(':') };
156
- }
157
166
  function isCommandScene(value) {
158
167
  if (!value || typeof value !== 'object')
159
168
  return false;
package/lib/provider.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { CommandIndex } from './command-index.js';
2
+ import { type CommandDefinition } from './definition.js';
2
3
  export declare const commandFeatureId: import("@zhin.js/plugin-runtime").FeatureId;
3
4
  export declare class CommandPathSyntaxError extends TypeError {
4
5
  constructor(file: string, detail?: string);
5
6
  }
6
- declare const commandFeature: Readonly<import("@zhin.js/feature-kit").FeatureProvider<import("./definition.js").CommandDefinition<unknown, unknown, import("./definition.js").CommandMessage>, CommandIndex>>;
7
+ declare const commandFeature: Readonly<import("@zhin.js/feature-kit").FeatureProvider<CommandDefinition<unknown, unknown, import("./definition.js").CommandMessage>, CommandIndex>>;
7
8
  export default commandFeature;
package/lib/provider.js CHANGED
@@ -14,7 +14,7 @@ const commandFiles = {
14
14
  const module = await context.host.loadModule(source.source);
15
15
  const definition = parseCommandDefinition(module.default);
16
16
  const file = parseCommandFile(basename(source.source));
17
- return bindCommandParameter(definition, file?.parameter);
17
+ return bindCommandParameter(definition, resolveParameter(definition, file, source.source));
18
18
  },
19
19
  };
20
20
  async function* discoverCommandDirectory(context, directory, ancestors) {
@@ -59,45 +59,53 @@ async function* discoverCommandDirectory(context, directory, ancestors) {
59
59
  function isCommandSegment(value) {
60
60
  return /^[a-z0-9][a-z0-9-]*$/.test(value);
61
61
  }
62
- const dynamicCommandFilePattern = /^\[([a-z][a-zA-Z0-9]*):([a-z][a-z0-9-]*)(?:=([^\]]*))?\]\.(?:tsx?|[cm]?js)$/;
63
- const commandParameterTypes = new Set([
64
- 'string',
65
- 'number',
66
- 'integer',
67
- 'float',
68
- 'boolean',
69
- 'word',
70
- 'text',
71
- 'mention',
72
- 'image',
73
- 'face',
74
- 'reply',
75
- 'forward',
76
- 'dice',
77
- 'rps',
78
- ]);
62
+ const dynamicCommandFilePatterns = [
63
+ { pattern: /^\[\[\.\.\.([a-zA-Z][a-zA-Z0-9]*)\]\]\.(?:tsx?|[cm]?js)$/, optional: true, rest: true },
64
+ { pattern: /^\[\.\.\.([a-zA-Z][a-zA-Z0-9]*)\]\.(?:tsx?|[cm]?js)$/, optional: false, rest: true },
65
+ { pattern: /^\[\[([a-zA-Z][a-zA-Z0-9]*)\]\]\.(?:tsx?|[cm]?js)$/, optional: true, rest: false },
66
+ { pattern: /^\[([a-zA-Z][a-zA-Z0-9]*)\]\.(?:tsx?|[cm]?js)$/, optional: false, rest: false },
67
+ ];
79
68
  function parseCommandFile(value) {
80
69
  if (/^[a-z0-9][a-z0-9-]*\.(?:tsx?|[cm]?js)$/.test(value)) {
81
70
  return { localSegment: parse(value).name };
82
71
  }
83
- const match = dynamicCommandFilePattern.exec(value);
84
- if (match) {
85
- const [, name, rawType, rawDefault] = match;
86
- if (!name || !rawType || !commandParameterTypes.has(rawType)) {
87
- throw new CommandPathSyntaxError(value, `unsupported parameter type: ${rawType ?? ''}`);
88
- }
89
- const type = rawType;
72
+ for (const { pattern, optional, rest } of dynamicCommandFilePatterns) {
73
+ const match = pattern.exec(value);
74
+ if (!match || !match[1])
75
+ continue;
76
+ const name = match[1];
90
77
  // Metadata can change during HMR while $name keeps the Capability identity stable.
91
- const parameter = rawDefault === undefined
92
- ? { name, type }
93
- : { name, type, defaultValue: parseParameterValue(name, type, rawDefault, value) };
94
- return { localSegment: `$${name}`, parameter };
78
+ return {
79
+ localSegment: `$${name}`,
80
+ parameter: { name, optional, rest },
81
+ };
95
82
  }
96
83
  if (value.startsWith('[') || value.includes(']')) {
97
84
  throw new CommandPathSyntaxError(value);
98
85
  }
99
86
  return undefined;
100
87
  }
88
+ /** 把文件名形态与 `definition.params` 合并成完整参数定义。 */
89
+ function resolveParameter(definition, file, source) {
90
+ const hint = file?.parameter;
91
+ if (!hint)
92
+ return undefined;
93
+ const schema = definition.params?.[hint.name];
94
+ if (!schema) {
95
+ throw new CommandPathSyntaxError(source, `missing params.${hint.name} declaration in defineCommand({ params })`);
96
+ }
97
+ if (!hint.optional && schema.default !== undefined) {
98
+ throw new CommandPathSyntaxError(source, `params.${hint.name} has a default but the file is required: rename to [[${hint.name}]]`);
99
+ }
100
+ return {
101
+ name: hint.name,
102
+ type: schema.type,
103
+ ...(schema.default !== undefined ? { defaultValue: schema.default } : {}),
104
+ optional: hint.optional,
105
+ rest: hint.rest,
106
+ ...(schema.description !== undefined ? { description: schema.description } : {}),
107
+ };
108
+ }
101
109
  function commandFilePriority(value, preferJavaScript) {
102
110
  const extension = value.slice(value.lastIndexOf('.') + 1);
103
111
  const order = preferJavaScript
@@ -106,39 +114,8 @@ function commandFilePriority(value, preferJavaScript) {
106
114
  const priority = order.indexOf(extension);
107
115
  return priority < 0 ? Number.MAX_SAFE_INTEGER : priority;
108
116
  }
109
- function parseParameterValue(name, type, value, source) {
110
- if (type === 'string' || type === 'word' || type === 'text')
111
- return value;
112
- if (type === 'number' || type === 'integer' || type === 'float') {
113
- const number = Number(value);
114
- if (value.trim().length > 0
115
- && Number.isFinite(number)
116
- && (type !== 'integer' || Number.isInteger(number))
117
- && (type !== 'float' || value.includes('.')))
118
- return number;
119
- throw new CommandPathSyntaxError(source, `default for ${name}:${type} is invalid`);
120
- }
121
- if (type === 'boolean') {
122
- if (value === 'true' || value === 'false')
123
- return value === 'true';
124
- throw new CommandPathSyntaxError(source, `default for ${name}:${type} is invalid`);
125
- }
126
- if (isStructuredParameter(type)) {
127
- throw new CommandPathSyntaxError(source, `default for structured parameter ${name}:${type} is not supported`);
128
- }
129
- throw new CommandPathSyntaxError(source, `default for ${name}:${type} is invalid`);
130
- }
131
- function isStructuredParameter(type) {
132
- return type === 'mention'
133
- || type === 'image'
134
- || type === 'face'
135
- || type === 'reply'
136
- || type === 'forward'
137
- || type === 'dice'
138
- || type === 'rps';
139
- }
140
117
  export class CommandPathSyntaxError extends TypeError {
141
- constructor(file, detail = 'expected [name:type=default].ts(x)') {
118
+ constructor(file, detail = 'expected [name].ts(x), [[name]].ts(x), [...name].ts(x) or [[...name]].ts(x)') {
142
119
  super(`Invalid Command path ${file}: ${detail}`);
143
120
  this.name = 'CommandPathSyntaxError';
144
121
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/command",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Convention-based Command Feature for Zhin Plugin Runtime",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -18,8 +18,8 @@
18
18
  ],
19
19
  "dependencies": {
20
20
  "segment-matcher": "^1.0.5",
21
- "@zhin.js/feature-kit": "1.0.4",
22
- "@zhin.js/plugin-runtime": "1.1.1"
21
+ "@zhin.js/feature-kit": "1.0.6",
22
+ "@zhin.js/plugin-runtime": "1.1.3"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^26.1.2",
@@ -85,7 +85,7 @@ export class CommandIndex {
85
85
  source: slot.source,
86
86
  parameters: Object.freeze(parameter ? [{
87
87
  ...parameter,
88
- required: parameter.defaultValue === undefined,
88
+ required: isRequiredParameter(parameter),
89
89
  }] : []),
90
90
  slot,
91
91
  segments: Object.freeze(segments),
@@ -173,13 +173,27 @@ export class CommandIndex {
173
173
  for (const command of this.#commands) {
174
174
  const result = command.matcher.match(asMatcherSegments(segments));
175
175
  if (!result || !hasCommandBoundary(result.remaining)) continue;
176
+ const parameter = command.parameter;
177
+ const params: Record<string, CommandParameterValue> = { ...result.params };
178
+ if (parameter?.rest) {
179
+ const raw = result.params[parameter.name];
180
+ const coerced = coerceRestValues(parameter, Array.isArray(raw) ? raw : []);
181
+ // 必需 `[...name]` 捕获所有:零元素视为不匹配;标量逐词转换失败同样不匹配。
182
+ if (!coerced || (isRequiredParameter(parameter) && coerced.length === 0)) continue;
183
+ params[parameter.name] = coerced;
184
+ }
176
185
  const remaining = normalizeSegments(result.remaining);
177
186
  if (exact && remaining.length > 0) continue;
187
+ // `[[name]]` 无 default 且未命中时,matcher 对 text 回退 ''、其他类型回退 null;
188
+ // 按契约(省略 default 时未匹配为 undefined)删除该键。
189
+ if (parameter && !parameter.rest && parameter.optional === true
190
+ && parameter.defaultValue === undefined
191
+ && (params[parameter.name] === '' || params[parameter.name] === null)) {
192
+ delete params[parameter.name];
193
+ }
178
194
  return {
179
195
  command,
180
- params: Object.freeze({ ...result.params }) as Readonly<
181
- Record<string, CommandParameterValue>
182
- >,
196
+ params: Object.freeze(params),
183
197
  remaining,
184
198
  };
185
199
  }
@@ -190,7 +204,7 @@ export class CommandIndex {
190
204
  const words = splitCommand(name);
191
205
  for (const command of this.#commands) {
192
206
  const parameter = command.parameter;
193
- if (!parameter) continue;
207
+ if (!parameter || parameter.rest) continue;
194
208
  const parameterIndex = command.segments.findIndex((segment) => segment.startsWith('$'));
195
209
  if (words.length !== command.segments.length) continue;
196
210
  if (!command.segments.every((segment, index) =>
@@ -230,7 +244,19 @@ function assertParameterSegment(
230
244
  if (parameter && dynamicSegments.length === 1
231
245
  && dynamicSegments[0] === `$${parameter.name}`
232
246
  && segments.at(-1) === dynamicSegments[0]) return;
233
- throw new Error(`Broken dynamic Command identity for ${source}`);
247
+ const dynamic = dynamicSegments[0] ?? (parameter ? `$${parameter.name}` : '$?');
248
+ throw new Error(
249
+ `Invalid Command path for ${source}: the dynamic segment "${dynamic}" must be the only dynamic `
250
+ + `segment and come after a static segment (child plugin commands are prefixed by the plugin `
251
+ + `path, so a dynamic first segment is never reachable). `
252
+ + (parameter
253
+ ? `Hint: move the file under a static directory, e.g. "commands/add/[${parameter.name}:${parameter.type}].ts".`
254
+ : 'Hint: put the file under a static directory, e.g. "commands/add/<file>.ts".'),
255
+ );
256
+ }
257
+
258
+ function isRequiredParameter(parameter: CommandParameterDefinition): boolean {
259
+ return parameter.optional === true ? false : parameter.defaultValue === undefined;
234
260
  }
235
261
 
236
262
  function displayName(
@@ -239,9 +265,10 @@ function displayName(
239
265
  ): string {
240
266
  return segments.map((segment) => {
241
267
  if (!segment.startsWith('$')) return segment;
242
- return parameter?.defaultValue === undefined
243
- ? `<${segment.slice(1)}>`
244
- : `[${segment.slice(1)}]`;
268
+ const label = segment.slice(1);
269
+ const required = !parameter || isRequiredParameter(parameter);
270
+ if (parameter?.rest) return required ? `<...${label}>` : `[...${label}]`;
271
+ return required ? `<${label}>` : `[${label}]`;
245
272
  }).join(' ');
246
273
  }
247
274
 
@@ -253,8 +280,13 @@ function matcherPattern(
253
280
  if (!segment.startsWith('$')) return segment;
254
281
  if (!parameter) throw new Error(`Missing Command parameter metadata: ${segment}`);
255
282
  const type = matcherType(parameter.type);
283
+ // rest:结构化类型按消息段收集;标量类型先按 text 段收集,再在 #match 里逐词切分转换。
284
+ if (parameter.rest) {
285
+ return `[...${parameter.name}:${isStructuredRestType(parameter.type) ? type : 'text'}]`;
286
+ }
287
+ if (isRequiredParameter(parameter)) return `<${parameter.name}:${type}>`;
256
288
  return parameter.defaultValue === undefined
257
- ? `<${parameter.name}:${type}>`
289
+ ? `[${parameter.name}:${type}]`
258
290
  : `[${parameter.name}:${type}=${String(parameter.defaultValue)}]`;
259
291
  }).join(' ');
260
292
  }
@@ -263,20 +295,71 @@ function matcherType(type: CommandParameterType): string {
263
295
  return type === 'string' ? 'word' : type;
264
296
  }
265
297
 
298
+ /** rest 参数中按消息段收集(matcher 原生行为)的结构化类型。 */
299
+ function isStructuredRestType(type: CommandParameterType): boolean {
300
+ return type === 'mention'
301
+ || type === 'image'
302
+ || type === 'face'
303
+ || type === 'reply'
304
+ || type === 'forward'
305
+ || type === 'dice'
306
+ || type === 'rps';
307
+ }
308
+
309
+ /**
310
+ * 捕获所有参数的取值粒度由类型决定:
311
+ * - `text` / 结构化类型:逐消息段(matcher 原生结果);
312
+ * - `word` / `string`:逐词(空白切分);
313
+ * - `number` / `integer` / `float` / `boolean`:逐词切分后逐个转换,任一失败返回 undefined(不匹配)。
314
+ */
315
+ function coerceRestValues(
316
+ parameter: CommandParameterDefinition,
317
+ values: readonly unknown[],
318
+ ): readonly (string | number | boolean)[] | undefined {
319
+ const type = parameter.type;
320
+ if (type === 'text' || isStructuredRestType(type)) {
321
+ // 逐消息段,保持 matcher 原生提取值(text 为 string,结构化类型可能是 number 等)。
322
+ return values as readonly (string | number | boolean)[];
323
+ }
324
+ const words = values.flatMap((value) =>
325
+ typeof value === 'string' ? value.split(/\s+/u).filter(Boolean) : []);
326
+ if (type === 'string' || type === 'word') return words;
327
+ if (type === 'number' || type === 'integer' || type === 'float') {
328
+ const numbers: number[] = [];
329
+ for (const [index, word] of words.entries()) {
330
+ const number = Number(word);
331
+ if (!Number.isFinite(number)
332
+ || (type === 'integer' && !Number.isInteger(number))
333
+ || (type === 'float' && !word.includes('.'))) return undefined;
334
+ numbers[index] = number;
335
+ }
336
+ return numbers;
337
+ }
338
+ if (type === 'boolean') {
339
+ if (!words.every((word) => word === 'true' || word === 'false')) return undefined;
340
+ return words.map((word) => word === 'true');
341
+ }
342
+ return undefined;
343
+ }
344
+
266
345
  function routeShape(segments: readonly string[]): string {
267
346
  return segments.map((segment) => segment.startsWith('$') ? '$' : segment).join(' ');
268
347
  }
269
348
 
270
349
  function compareCommands(left: CommandRecord, right: CommandRecord): number {
271
- const leftDynamic = left.parameter ? 1 : 0;
272
- const rightDynamic = right.parameter ? 1 : 0;
273
- return leftDynamic - rightDynamic
350
+ return dynamicWeight(left) - dynamicWeight(right)
274
351
  || staticSegmentCount(right.segments) - staticSegmentCount(left.segments)
275
352
  || right.segments.length - left.segments.length
276
353
  || right.name.length - left.name.length
277
354
  || left.name.localeCompare(right.name);
278
355
  }
279
356
 
357
+ /** 静态 < 单参数 < 捕获所有:更具体的形状优先匹配。 */
358
+ function dynamicWeight(command: CommandRecord): number {
359
+ if (!command.parameter) return 0;
360
+ return command.parameter.rest ? 2 : 1;
361
+ }
362
+
280
363
  function staticSegmentCount(segments: readonly string[]): number {
281
364
  return segments.filter((segment) => !segment.startsWith('$')).length;
282
365
  }
package/src/definition.ts CHANGED
@@ -25,9 +25,38 @@ export type CommandParameterValue =
25
25
  | string
26
26
  | number
27
27
  | boolean
28
+ | ReadonlyArray<string | number | boolean>
28
29
  | Readonly<Record<string, unknown>>
29
30
  | null;
30
31
 
32
+ export const commandParameterTypes: ReadonlySet<CommandParameterType> = new Set([
33
+ 'string',
34
+ 'number',
35
+ 'integer',
36
+ 'float',
37
+ 'boolean',
38
+ 'word',
39
+ 'text',
40
+ 'mention',
41
+ 'image',
42
+ 'face',
43
+ 'reply',
44
+ 'forward',
45
+ 'dice',
46
+ 'rps',
47
+ ]);
48
+
49
+ /**
50
+ * Next.js 风格参数声明(`defineCommand({ params: ... })`)。
51
+ * 文件名只声明参数形态(`[name]` / `[[name]]` / `[...name]` / `[[...name]]`),
52
+ * 类型与默认值统一在这里声明。
53
+ */
54
+ export interface CommandParamSchema {
55
+ readonly type: CommandParameterType;
56
+ readonly default?: CommandParameterValue;
57
+ readonly description?: string;
58
+ }
59
+
31
60
  /** Minimal structural contract shared with canonical IM segments. */
32
61
  export interface CommandSegment {
33
62
  readonly type: string | { readonly name: string };
@@ -38,6 +67,11 @@ export interface CommandParameterDefinition {
38
67
  readonly name: string;
39
68
  readonly type: CommandParameterType;
40
69
  readonly defaultValue?: CommandParameterValue;
70
+ /** `[[name]]` / `[[...name]]` 可选段;缺省按 `defaultValue === undefined` 推断。 */
71
+ readonly optional?: boolean;
72
+ /** `[...name]` / `[[...name]]` 捕获所有段,运行时值为 `string[]`。 */
73
+ readonly rest?: boolean;
74
+ readonly description?: string;
41
75
  }
42
76
 
43
77
  /** 场景:群 / 私聊 / 频道等。 */
@@ -57,6 +91,25 @@ export interface CommandSender {
57
91
  readonly role: readonly string[];
58
92
  }
59
93
 
94
+ /**
95
+ * 命令侧入站会话契约(结构对齐 `@zhin.js/im-contract` 的 ConversationRef;
96
+ * command 为 Feature 层,不能 import core / IM 契约包,故独立声明)。
97
+ */
98
+ export interface CommandConversation {
99
+ readonly endpoint: Readonly<{
100
+ readonly id: string;
101
+ /** 适配器插件 owner(PluginId),与 `snapshot.config.get(adapter)` 对齐。 */
102
+ readonly adapter: string;
103
+ }>;
104
+ readonly kind: 'private' | 'group' | 'channel';
105
+ readonly id: string;
106
+ readonly parent?: Readonly<{
107
+ readonly kind: 'group' | 'channel';
108
+ readonly id: string;
109
+ }>;
110
+ readonly threadId?: string;
111
+ }
112
+
60
113
  /**
61
114
  * 命令侧入站消息契约。
62
115
  *
@@ -64,8 +117,7 @@ export interface CommandSender {
64
117
  * 因架构分层(command 为 Feature 层,不能 import core),此处独立声明。
65
118
  */
66
119
  export interface CommandMessage {
67
- readonly adapter: string;
68
- readonly target: string;
120
+ readonly conversation: CommandConversation;
69
121
  readonly content: string;
70
122
  /** 发送者 id(扁平字段;结构化视图见 CommandContext.sender)。 */
71
123
  readonly sender?: string;
@@ -119,6 +171,11 @@ export interface CommandDefinition<
119
171
  readonly $feature: typeof commandBrand;
120
172
  readonly $parameter?: CommandParameterDefinition;
121
173
  readonly description?: string;
174
+ /**
175
+ * Next.js 风格参数声明:动态段文件名(`[name]` 等)的形态配合这里的
176
+ * 类型 / 默认值 / 描述使用。静态命令可忽略本字段。
177
+ */
178
+ readonly params?: Readonly<Record<string, CommandParamSchema>>;
122
179
  execute(context: CommandContext<TConfig, TInput>): TResult | Promise<TResult>;
123
180
  }
124
181
 
@@ -145,6 +202,17 @@ export function defineCommand<
145
202
  if (typeof definition.execute !== 'function') {
146
203
  throw new TypeError('Command execute must be a function');
147
204
  }
205
+ if (definition.params !== undefined) {
206
+ if (!definition.params || typeof definition.params !== 'object') {
207
+ throw new TypeError('Command params must be a Record<string, CommandParamSchema>');
208
+ }
209
+ for (const [name, schema] of Object.entries(definition.params)) {
210
+ if (!schema || typeof schema !== 'object'
211
+ || !commandParameterTypes.has((schema as CommandParamSchema).type)) {
212
+ throw new TypeError(`Command params.${name} requires a valid type`);
213
+ }
214
+ }
215
+ }
148
216
  return Object.freeze({ $feature: commandBrand, ...definition });
149
217
  }
150
218
 
@@ -202,7 +270,7 @@ export function resolveCommandSession(input: unknown): CommandSession {
202
270
  ? input.metadata as Readonly<Record<string, unknown>>
203
271
  : undefined;
204
272
 
205
- const adapter = input.adapter.split('\0')[0] || undefined;
273
+ const adapter = input.conversation.endpoint.adapter || undefined;
206
274
  const endpoint = typeof metadata?.endpoint === 'string' && metadata.endpoint
207
275
  ? metadata.endpoint
208
276
  : undefined;
@@ -221,8 +289,8 @@ export function resolveCommandSession(input: unknown): CommandSession {
221
289
  function isCommandMessageLike(input: unknown): input is CommandMessage {
222
290
  if (!input || typeof input !== 'object') return false;
223
291
  const value = input as Partial<CommandMessage>;
224
- return typeof value.adapter === 'string'
225
- && typeof value.target === 'string'
292
+ return !!value.conversation
293
+ && typeof value.conversation === 'object'
226
294
  && typeof value.content === 'string';
227
295
  }
228
296
 
@@ -238,12 +306,12 @@ function resolveScene(
238
306
  });
239
307
  }
240
308
 
241
- const parsed = parseTarget(input.target);
309
+ const conversation = input.conversation;
242
310
  const type = (typeof metadata?.channelType === 'string' && metadata.channelType)
243
311
  || (typeof metadata?.type === 'string' && metadata.type)
244
- || parsed?.type;
312
+ || conversation.kind;
245
313
  const id = (typeof metadata?.channelId === 'string' && metadata.channelId)
246
- || parsed?.id;
314
+ || conversation.id;
247
315
  if (!type || !id) return undefined;
248
316
 
249
317
  const name = firstString(
@@ -309,22 +377,6 @@ function resolveRoles(
309
377
  return Object.freeze(roles);
310
378
  }
311
379
 
312
- function parseTarget(target: string): { readonly type: string; readonly id: string } | undefined {
313
- const parts = target.split(':').filter(Boolean);
314
- if (parts.length < 2) return undefined;
315
- const [kind, ...rest] = parts;
316
- if (!kind) return undefined;
317
- const lastPart = parts.at(-1);
318
- if (!lastPart) return undefined;
319
- if (kind === 'channel' && parts.length >= 3) {
320
- return { type: 'channel', id: lastPart };
321
- }
322
- if (kind === 'temp' && parts.length >= 3) {
323
- return { type: 'private', id: lastPart };
324
- }
325
- return { type: kind, id: rest.join(':') };
326
- }
327
-
328
380
  function isCommandScene(value: unknown): value is CommandScene {
329
381
  if (!value || typeof value !== 'object') return false;
330
382
  const scene = value as Partial<CommandScene>;
package/src/provider.ts CHANGED
@@ -10,9 +10,8 @@ import { CommandIndex } from './command-index.js';
10
10
  import {
11
11
  bindCommandParameter,
12
12
  parseCommandDefinition,
13
+ type CommandDefinition,
13
14
  type CommandParameterDefinition,
14
- type CommandParameterType,
15
- type CommandParameterValue,
16
15
  } from './definition.js';
17
16
 
18
17
  export const commandFeatureId = featureId('zhin.command');
@@ -27,7 +26,7 @@ const commandFiles: SourceConvention = {
27
26
  const module = await context.host.loadModule<{ default?: unknown }>(source.source);
28
27
  const definition = parseCommandDefinition(module.default);
29
28
  const file = parseCommandFile(basename(source.source));
30
- return bindCommandParameter(definition, file?.parameter);
29
+ return bindCommandParameter(definition, resolveParameter(definition, file, source.source));
31
30
  },
32
31
  };
33
32
 
@@ -81,45 +80,40 @@ function isCommandSegment(value: string): boolean {
81
80
 
82
81
  interface ParsedCommandFile {
83
82
  readonly localSegment: string;
84
- readonly parameter?: CommandParameterDefinition;
83
+ readonly parameter?: CommandParameterHint;
85
84
  }
86
85
 
87
- const dynamicCommandFilePattern =
88
- /^\[([a-z][a-zA-Z0-9]*):([a-z][a-z0-9-]*)(?:=([^\]]*))?\]\.(?:tsx?|[cm]?js)$/;
86
+ /** 文件名声明的参数形态;类型与默认值来自 `defineCommand({ params })`。 */
87
+ interface CommandParameterHint {
88
+ readonly name: string;
89
+ readonly optional: boolean;
90
+ readonly rest: boolean;
91
+ }
89
92
 
90
- const commandParameterTypes = new Set<CommandParameterType>([
91
- 'string',
92
- 'number',
93
- 'integer',
94
- 'float',
95
- 'boolean',
96
- 'word',
97
- 'text',
98
- 'mention',
99
- 'image',
100
- 'face',
101
- 'reply',
102
- 'forward',
103
- 'dice',
104
- 'rps',
105
- ]);
93
+ const dynamicCommandFilePatterns: ReadonlyArray<{
94
+ readonly pattern: RegExp;
95
+ readonly optional: boolean;
96
+ readonly rest: boolean;
97
+ }> = [
98
+ { pattern: /^\[\[\.\.\.([a-zA-Z][a-zA-Z0-9]*)\]\]\.(?:tsx?|[cm]?js)$/, optional: true, rest: true },
99
+ { pattern: /^\[\.\.\.([a-zA-Z][a-zA-Z0-9]*)\]\.(?:tsx?|[cm]?js)$/, optional: false, rest: true },
100
+ { pattern: /^\[\[([a-zA-Z][a-zA-Z0-9]*)\]\]\.(?:tsx?|[cm]?js)$/, optional: true, rest: false },
101
+ { pattern: /^\[([a-zA-Z][a-zA-Z0-9]*)\]\.(?:tsx?|[cm]?js)$/, optional: false, rest: false },
102
+ ];
106
103
 
107
104
  function parseCommandFile(value: string): ParsedCommandFile | undefined {
108
105
  if (/^[a-z0-9][a-z0-9-]*\.(?:tsx?|[cm]?js)$/.test(value)) {
109
106
  return { localSegment: parse(value).name };
110
107
  }
111
- const match = dynamicCommandFilePattern.exec(value);
112
- if (match) {
113
- const [, name, rawType, rawDefault] = match;
114
- if (!name || !rawType || !commandParameterTypes.has(rawType as CommandParameterType)) {
115
- throw new CommandPathSyntaxError(value, `unsupported parameter type: ${rawType ?? ''}`);
116
- }
117
- const type = rawType as CommandParameterType;
108
+ for (const { pattern, optional, rest } of dynamicCommandFilePatterns) {
109
+ const match = pattern.exec(value);
110
+ if (!match || !match[1]) continue;
111
+ const name = match[1];
118
112
  // Metadata can change during HMR while $name keeps the Capability identity stable.
119
- const parameter = rawDefault === undefined
120
- ? { name, type }
121
- : { name, type, defaultValue: parseParameterValue(name, type, rawDefault, value) };
122
- return { localSegment: `$${name}`, parameter };
113
+ return {
114
+ localSegment: `$${name}`,
115
+ parameter: { name, optional, rest },
116
+ };
123
117
  }
124
118
  if (value.startsWith('[') || value.includes(']')) {
125
119
  throw new CommandPathSyntaxError(value);
@@ -127,66 +121,51 @@ function parseCommandFile(value: string): ParsedCommandFile | undefined {
127
121
  return undefined;
128
122
  }
129
123
 
130
- function commandFilePriority(value: string, preferJavaScript: boolean): number {
131
- const extension = value.slice(value.lastIndexOf('.') + 1);
132
- const order = preferJavaScript
133
- ? ['js', 'mjs', 'cjs', 'ts', 'tsx']
134
- : ['ts', 'tsx', 'js', 'mjs', 'cjs'];
135
- const priority = order.indexOf(extension);
136
- return priority < 0 ? Number.MAX_SAFE_INTEGER : priority;
137
- }
138
-
139
- function parseParameterValue(
140
- name: string,
141
- type: CommandParameterType,
142
- value: string,
124
+ /** 把文件名形态与 `definition.params` 合并成完整参数定义。 */
125
+ function resolveParameter(
126
+ definition: CommandDefinition,
127
+ file: ParsedCommandFile | undefined,
143
128
  source: string,
144
- ): CommandParameterValue {
145
- if (type === 'string' || type === 'word' || type === 'text') return value;
146
- if (type === 'number' || type === 'integer' || type === 'float') {
147
- const number = Number(value);
148
- if (
149
- value.trim().length > 0
150
- && Number.isFinite(number)
151
- && (type !== 'integer' || Number.isInteger(number))
152
- && (type !== 'float' || value.includes('.'))
153
- ) return number;
129
+ ): CommandParameterDefinition | undefined {
130
+ const hint = file?.parameter;
131
+ if (!hint) return undefined;
132
+ const schema = definition.params?.[hint.name];
133
+ if (!schema) {
154
134
  throw new CommandPathSyntaxError(
155
135
  source,
156
- `default for ${name}:${type} is invalid`,
136
+ `missing params.${hint.name} declaration in defineCommand({ params })`,
157
137
  );
158
138
  }
159
- if (type === 'boolean') {
160
- if (value === 'true' || value === 'false') return value === 'true';
139
+ if (!hint.optional && schema.default !== undefined) {
161
140
  throw new CommandPathSyntaxError(
162
141
  source,
163
- `default for ${name}:${type} is invalid`,
142
+ `params.${hint.name} has a default but the file is required: rename to [[${hint.name}]]`,
164
143
  );
165
144
  }
166
- if (isStructuredParameter(type)) {
167
- throw new CommandPathSyntaxError(
168
- source,
169
- `default for structured parameter ${name}:${type} is not supported`,
170
- );
171
- }
172
- throw new CommandPathSyntaxError(
173
- source,
174
- `default for ${name}:${type} is invalid`,
175
- );
145
+ return {
146
+ name: hint.name,
147
+ type: schema.type,
148
+ ...(schema.default !== undefined ? { defaultValue: schema.default } : {}),
149
+ optional: hint.optional,
150
+ rest: hint.rest,
151
+ ...(schema.description !== undefined ? { description: schema.description } : {}),
152
+ };
176
153
  }
177
154
 
178
- function isStructuredParameter(type: CommandParameterType): boolean {
179
- return type === 'mention'
180
- || type === 'image'
181
- || type === 'face'
182
- || type === 'reply'
183
- || type === 'forward'
184
- || type === 'dice'
185
- || type === 'rps';
155
+ function commandFilePriority(value: string, preferJavaScript: boolean): number {
156
+ const extension = value.slice(value.lastIndexOf('.') + 1);
157
+ const order = preferJavaScript
158
+ ? ['js', 'mjs', 'cjs', 'ts', 'tsx']
159
+ : ['ts', 'tsx', 'js', 'mjs', 'cjs'];
160
+ const priority = order.indexOf(extension);
161
+ return priority < 0 ? Number.MAX_SAFE_INTEGER : priority;
186
162
  }
187
163
 
188
164
  export class CommandPathSyntaxError extends TypeError {
189
- constructor(file: string, detail = 'expected [name:type=default].ts(x)') {
165
+ constructor(
166
+ file: string,
167
+ detail = 'expected [name].ts(x), [[name]].ts(x), [...name].ts(x) or [[...name]].ts(x)',
168
+ ) {
190
169
  super(`Invalid Command path ${file}: ${detail}`);
191
170
  this.name = 'CommandPathSyntaxError';
192
171
  }