@zhin.js/command 1.1.5 → 1.1.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.
@@ -15,6 +15,7 @@ import {
15
15
  toPermissionSubject,
16
16
  type PermissionHost,
17
17
  } from '@zhin.js/permission';
18
+ import { commandUsage, commandHelp, parseCommandArguments, requestsCommandHelp, type CommandValidationIssue } from './arguments.js';
18
19
  import {
19
20
  createCommandContext,
20
21
  resolveCommandSession,
@@ -36,6 +37,8 @@ export interface CommandDescriptor {
36
37
  readonly adapter?: string;
37
38
  readonly source: string;
38
39
  readonly parameters: readonly CommandParameterDescriptor[];
40
+ readonly usage: string;
41
+ readonly options: NonNullable<CommandDefinition['options']>;
39
42
  readonly alias?: readonly string[];
40
43
  readonly permit?: readonly string[];
41
44
  /** shortcut 触发键列表(不含预填 params)。 */
@@ -44,6 +47,9 @@ export interface CommandDescriptor {
44
47
 
45
48
  export interface CommandDispatchResult {
46
49
  readonly matched: boolean;
50
+ readonly status: 'unmatched' | 'invalid' | 'denied' | 'executed';
51
+ readonly issues?: readonly CommandValidationIssue[];
52
+ readonly help?: string;
47
53
  readonly command?: string;
48
54
  readonly owner?: PluginId;
49
55
  readonly value?: unknown;
@@ -59,6 +65,7 @@ interface CommandRoute {
59
65
  readonly record: CommandRecord;
60
66
  readonly segments: readonly string[];
61
67
  readonly matcher: SegmentMatcher;
68
+ readonly literalMatcher?: SegmentMatcher;
62
69
  readonly kind: 'primary' | 'alias';
63
70
  }
64
71
 
@@ -71,6 +78,7 @@ interface CommandMatch {
71
78
  readonly command: CommandRecord;
72
79
  readonly params: Readonly<Record<string, CommandDynamicValue>>;
73
80
  readonly remaining: readonly Readonly<CommandSegment>[];
81
+ readonly helpRequested: boolean;
74
82
  }
75
83
 
76
84
  export type CommandMatchInput = string | readonly Readonly<CommandSegment>[];
@@ -110,12 +118,15 @@ export class CommandIndex {
110
118
  const occupancy = new Map<string, string>();
111
119
  const shortcuts = new Map<string, ShortcutEntry>();
112
120
 
113
- const claim = (key: string, source: string): void => {
114
- const existing = occupancy.get(key);
115
- if (existing !== undefined) {
116
- throw new Error(`Duplicate Command route "${key}" (${source} vs ${existing})`);
121
+ const claim = (keys: readonly string[], source: string): void => {
122
+ for (const key of keys) {
123
+ for (const [occupied, existing] of occupancy) {
124
+ if (routeShapesOverlap(key, occupied)) {
125
+ throw new Error(`Duplicate Command route "${key}" (${source} vs ${existing})`);
126
+ }
127
+ }
117
128
  }
118
- occupancy.set(key, source);
129
+ for (const key of keys) occupancy.set(key, source);
119
130
  };
120
131
 
121
132
  for (const slot of slots) {
@@ -137,10 +148,16 @@ export class CommandIndex {
137
148
  description: slot.definition.description,
138
149
  ...(slot.definition.adapter ? { adapter: slot.definition.adapter } : {}),
139
150
  source: slot.source,
140
- parameters: Object.freeze(parameter ? [{
141
- ...parameter,
142
- required: isRequiredParameter(parameter),
143
- }] : []),
151
+ usage: commandUsage(name, slot.definition),
152
+ options: Object.freeze({ ...slot.definition.options }),
153
+ parameters: Object.freeze([
154
+ ...Object.entries(slot.definition.params ?? {}).map(([key, schema]) => ({
155
+ name: key, ...schema, ...(parameter?.name === key ? parameter : {}), defaultValue: schema.default,
156
+ required: parameter?.name === key ? isRequiredParameter(parameter) : !schema.optional && schema.default === undefined,
157
+ })),
158
+ ...(parameter && !Object.hasOwn(slot.definition.params ?? {}, parameter.name)
159
+ ? [{ ...parameter, required: isRequiredParameter(parameter) }] : []),
160
+ ]),
144
161
  ...(alias ? { alias } : {}),
145
162
  ...(permit ? { permit } : {}),
146
163
  ...(shortcutKeys && shortcutKeys.length > 0 ? { shortcut: shortcutKeys } : {}),
@@ -149,11 +166,12 @@ export class CommandIndex {
149
166
  parameter,
150
167
  });
151
168
 
152
- claim(occupancyKey(primarySegments, parameter), slot.source);
169
+ claim([...new Set([occupancyKey(primarySegments, parameter), ...declaredRouteShapes(primarySegments, slot.definition)])], slot.source);
153
170
  routes.push({
154
171
  record,
155
172
  segments: primarySegments,
156
173
  matcher: new SegmentMatcher(matcherPattern(primarySegments, parameter), segmentFields),
174
+ literalMatcher: compileLiteralMatcher(primarySegments, parameter),
157
175
  kind: 'primary',
158
176
  });
159
177
 
@@ -164,11 +182,12 @@ export class CommandIndex {
164
182
  primarySegments.slice(namespace.length),
165
183
  )];
166
184
  assertParameterSegment(aliasSegments, parameter, `${slot.source} alias ${JSON.stringify(entry)}`);
167
- claim(occupancyKey(aliasSegments, parameter), `${slot.source} alias ${JSON.stringify(entry)}`);
185
+ claim([...new Set([occupancyKey(aliasSegments, parameter), ...declaredRouteShapes(aliasSegments, slot.definition)])], `${slot.source} alias ${JSON.stringify(entry)}`);
168
186
  routes.push({
169
187
  record,
170
188
  segments: Object.freeze(aliasSegments),
171
189
  matcher: new SegmentMatcher(matcherPattern(aliasSegments, parameter), segmentFields),
190
+ literalMatcher: compileLiteralMatcher(aliasSegments, parameter),
172
191
  kind: 'alias',
173
192
  });
174
193
  }
@@ -177,7 +196,7 @@ export class CommandIndex {
177
196
  if (slot.definition.shortcut) {
178
197
  for (const [rawTrigger, prefill] of Object.entries(slot.definition.shortcut)) {
179
198
  const trigger = [...namespace, ...splitCommand(rawTrigger)].join(' ');
180
- claim(trigger, `${slot.source} shortcut ${JSON.stringify(trigger)}`);
199
+ claim([trigger], `${slot.source} shortcut ${JSON.stringify(trigger)}`);
181
200
  shortcuts.set(trigger, {
182
201
  record,
183
202
  params: Object.freeze(resolveShortcutParams(
@@ -211,24 +230,18 @@ export class CommandIndex {
211
230
  }
212
231
 
213
232
  async execute(name: string, args: readonly string[] = [], operationSnapshot: RuntimeSnapshot = this.snapshot): Promise<unknown> {
214
- const match = this.#match(name, true);
233
+ const match = this.#match(name, false);
215
234
  if (!match) {
216
235
  this.#diagnoseParameter(name);
217
236
  throw new Error(`Unknown Command: ${name}`);
218
237
  }
219
- // Host / 无 session:跳过 permit;无 source 时函数默认值得到空 session。
220
- return match.command.slot.definition.execute(
221
- createCommandContext(
222
- operationSnapshot,
223
- match.command.slot.owner,
224
- args,
225
- resolveDynamicParams(match.params, undefined),
226
- undefined,
227
- Object.freeze([]),
228
- undefined,
229
- match.command.slot.definition.adapter,
230
- ),
231
- );
238
+ if (!(await this.#permitAllows(match.command, undefined, operationSnapshot))) throw new Error(`Permission denied for Command: ${match.command.name}`);
239
+ const definition = match.command.slot.definition;
240
+ const supplied = [...match.remaining, ...(args.length > 0 ? [{ type: 'text', data: { text: args.join(' ') } }] : [])];
241
+ const parsed = parseCommandArguments(definition, supplied, resolveDynamicParams(match.params, undefined), {});
242
+ if (parsed.issues.length > 0) throw new CommandArgumentValidationError(parsed.issues, commandHelp(match.command.name, definition));
243
+ return definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner,
244
+ textArgs(parsed.remaining), parsed.params, undefined, parsed.remaining, undefined, definition.adapter, parsed.options));
232
245
  }
233
246
 
234
247
  async dispatch(
@@ -243,6 +256,7 @@ export class CommandIndex {
243
256
  if (menuValue !== undefined) {
244
257
  return Object.freeze({
245
258
  matched: true,
259
+ status: 'executed',
246
260
  command: this.#menu.keyword,
247
261
  owner: operationSnapshot.root,
248
262
  value: menuValue,
@@ -250,71 +264,61 @@ export class CommandIndex {
250
264
  }
251
265
  }
252
266
  const shortcut = this.#matchShortcut(input);
253
- if (shortcut) {
254
- if (!commandAdapterMatches(shortcut.record, source)) {
255
- return Object.freeze({ matched: false });
256
- }
257
- if (!(await this.#permitAllows(shortcut.record, source, operationSnapshot))) {
258
- return Object.freeze({ matched: false });
259
- }
260
- const authorSource = bindOperationInput(source, shortcut.record.slot.owner, operationSnapshot);
261
- const value = await shortcut.record.slot.definition.execute(
262
- createCommandContext(
263
- operationSnapshot,
264
- shortcut.record.slot.owner,
265
- Object.freeze([]),
266
- resolveDynamicParams(shortcut.params, authorSource),
267
- authorSource,
268
- Object.freeze([]),
269
- interactionFactory?.(authorSource),
270
- shortcut.record.slot.definition.adapter,
271
- ),
272
- );
273
- return Object.freeze({
274
- matched: true,
275
- command: shortcut.record.name,
276
- owner: shortcut.record.slot.owner,
277
- value,
278
- });
267
+ const match = shortcut
268
+ ? { command: shortcut.record, params: shortcut.params, remaining: Object.freeze([]), helpRequested: false }
269
+ : this.#match(input, false);
270
+ if (!match) {
271
+ const candidate = this.#invalidRoute(input);
272
+ if (!candidate) return Object.freeze({ matched: false, status: 'unmatched' });
273
+ if (!commandAdapterMatches(candidate, source)) return Object.freeze({ matched: false, status: 'unmatched' });
274
+ if (!(await this.#permitAllows(candidate, source, operationSnapshot))) return this.#denied(candidate);
275
+ const help = commandHelp(candidate.name, candidate.slot.definition, commandPrefix);
276
+ const segments = normalizeSegments(typeof input === 'string' ? [{ type: 'text', data: { text: input } }] : input);
277
+ if (requestsCommandHelp(segments)) return Object.freeze({ matched: true, status: 'executed', command: candidate.name, owner: candidate.slot.owner, value: help });
278
+ return Object.freeze({ matched: true, status: 'invalid', command: candidate.name, owner: candidate.slot.owner,
279
+ issues: Object.freeze([{ field: candidate.parameter?.name ?? 'arguments', message: 'Missing or invalid command parameter' }]), help });
279
280
  }
280
-
281
- const match = this.#match(input, false);
282
- if (!match) return Object.freeze({ matched: false });
283
- if (!commandAdapterMatches(match.command, source)) {
284
- return Object.freeze({ matched: false });
281
+ if (!commandAdapterMatches(match.command, source)) return Object.freeze({ matched: false, status: 'unmatched' });
282
+ if (!(await this.#permitAllows(match.command, source, operationSnapshot))) return this.#denied(match.command);
283
+ const definition = match.command.slot.definition;
284
+ const authorSource = bindOperationInput(source, match.command.slot.owner, operationSnapshot);
285
+ const help = commandHelp(match.command.name, definition, commandPrefix);
286
+ if (match.helpRequested) {
287
+ return Object.freeze({ matched: true, status: 'executed', command: match.command.name, owner: match.command.slot.owner, value: help });
285
288
  }
286
- if (!(await this.#permitAllows(match.command, source, operationSnapshot))) {
287
- return Object.freeze({ matched: false });
289
+ const parsed = parseCommandArguments(definition, match.remaining, resolveDynamicParams(match.params, authorSource), resolveCommandSession(authorSource));
290
+ if (parsed.issues.length > 0) return Object.freeze({ matched: true, status: 'invalid', command: match.command.name,
291
+ owner: match.command.slot.owner, issues: parsed.issues, help });
292
+ const value = await definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner,
293
+ textArgs(parsed.remaining), parsed.params, authorSource, parsed.remaining, interactionFactory?.(authorSource), definition.adapter, parsed.options));
294
+ return Object.freeze({ matched: true, status: 'executed', command: match.command.name, owner: match.command.slot.owner, value });
295
+ }
296
+
297
+ #denied(record: CommandRecord): CommandDispatchResult {
298
+ return Object.freeze({ matched: true, status: 'denied', command: record.name, owner: record.slot.owner });
299
+ }
300
+
301
+ /** Diagnose only a reliable static route prefix; fully dynamic routes are ordinary chat candidates. */
302
+ #invalidRoute(input: CommandMatchInput): CommandRecord | undefined {
303
+ const segments = normalizeSegments(typeof input === 'string' ? [{ type: 'text', data: { text: input } }] : input);
304
+ for (const route of this.#routes) {
305
+ if (!route.record.parameter) continue;
306
+ const result = route.literalMatcher?.match(asMatcherSegments(segments));
307
+ if (result && hasCommandBoundary(result.remaining)) return route.record;
288
308
  }
289
- const args = textArgs(match.remaining);
290
- const authorSource = bindOperationInput(source, match.command.slot.owner, operationSnapshot);
291
- const value = await match.command.slot.definition.execute(
292
- createCommandContext(
293
- operationSnapshot,
294
- match.command.slot.owner,
295
- args,
296
- resolveDynamicParams(match.params, authorSource),
297
- authorSource,
298
- match.remaining,
299
- interactionFactory?.(authorSource),
300
- match.command.slot.definition.adapter,
301
- ),
302
- );
303
- return Object.freeze({
304
- matched: true,
305
- command: match.command.name,
306
- owner: match.command.slot.owner,
307
- value,
308
- });
309
+ return undefined;
309
310
  }
310
311
 
311
312
  async #permitAllows(record: CommandRecord, source: unknown, operationSnapshot: RuntimeSnapshot): Promise<boolean> {
312
313
  const permits = record.permit;
313
314
  if (!permits || permits.length === 0) return true;
314
- if (!hasImSession(source)) return true;
315
+ if (!source || typeof source !== 'object') return false;
315
316
  const host = this.#resolveHost(operationSnapshot);
316
317
  if (!host) return false;
317
- const subject = toPermissionSubject(resolveCommandSession(source));
318
+ const projected = toPermissionSubject(source);
319
+ const session = resolveCommandSession(source);
320
+ const subject = { ...projected, ...session, ...(session.sender ? { sender: { ...projected.sender, ...session.sender } } : {}) };
321
+ if (!subject.sender?.id && !subject.scene?.id && !subject.adapter && !subject.endpoint) return false;
318
322
  return host.checkAll(permits, subject);
319
323
  }
320
324
 
@@ -348,11 +352,17 @@ export class CommandIndex {
348
352
  if (segments.length === 0) return undefined;
349
353
 
350
354
  for (const route of this.#routes) {
351
- const result = route.matcher.match(asMatcherSegments(segments));
355
+ // Options may appear between the literal route and a directory-bound parameter.
356
+ const literalResult = !exact ? route.literalMatcher?.match(asMatcherSegments(segments)) : undefined;
357
+ const helpRequested = literalResult && hasCommandBoundary(literalResult.remaining)
358
+ && requestsCommandHelp(normalizeSegments(literalResult.remaining));
359
+ const parseDeclaredArguments = !exact && route.record.parameter && route.literalMatcher !== undefined
360
+ && (Object.hasOwn(route.record.slot.definition.params ?? {}, route.record.parameter.name) || helpRequested);
361
+ const result = parseDeclaredArguments ? literalResult : route.matcher.match(asMatcherSegments(segments));
352
362
  if (!result || !hasCommandBoundary(result.remaining)) continue;
353
363
  const parameter = route.record.parameter;
354
364
  const params: Record<string, CommandDynamicValue> = { ...result.params };
355
- if (parameter?.rest) {
365
+ if (parameter?.rest && !parseDeclaredArguments) {
356
366
  const raw = result.params[parameter.name];
357
367
  const coerced = coerceRestValues(parameter, Array.isArray(raw) ? raw : []);
358
368
  if (!coerced || (isRequiredParameter(parameter) && coerced.length === 0)) continue;
@@ -360,15 +370,16 @@ export class CommandIndex {
360
370
  }
361
371
  const remaining = normalizeSegments(result.remaining);
362
372
  if (exact && remaining.length > 0) continue;
363
- if (parameter && !parameter.rest && parameter.optional === true
364
- && parameter.defaultValue === undefined
365
- && (params[parameter.name] === '' || params[parameter.name] === null)) {
366
- delete params[parameter.name];
373
+ if (parameter && !parseDeclaredArguments && !parameter.rest && !isRequiredParameter(parameter)
374
+ && (params[parameter.name] === '' || params[parameter.name] === null || params[parameter.name] === undefined)) {
375
+ if (parameter.defaultValue !== undefined) params[parameter.name] = parameter.defaultValue;
376
+ else delete params[parameter.name];
367
377
  }
368
378
  return {
369
379
  command: route.record,
370
380
  params: Object.freeze(params),
371
381
  remaining,
382
+ helpRequested: literalResult ? !!helpRequested : requestsCommandHelp(remaining),
372
383
  };
373
384
  }
374
385
  return undefined;
@@ -396,17 +407,19 @@ export class CommandIndex {
396
407
  #dispatchMenu(input: CommandMatchInput, commandPrefix = ''): string | undefined {
397
408
  const text = typeof input === 'string' ? input.trim() : exactMessageText(input);
398
409
  if (text === undefined) return undefined;
399
- const keyword = this.#menu!.keyword;
400
- if (text === keyword) return this.#buildMenu(undefined, commandPrefix);
410
+ const menu = this.#menu;
411
+ if (!menu) return undefined;
412
+ const keyword = menu.keyword;
413
+ if (text === keyword) return this.#buildMenu(undefined, commandPrefix, keyword);
401
414
  if (text.startsWith(keyword + ' ')) {
402
415
  const key = text.slice(keyword.length + 1).trim();
403
- if (key) return this.#buildMenu(key, commandPrefix);
404
- return this.#buildMenu(undefined, commandPrefix);
416
+ if (key) return this.#buildMenu(key, commandPrefix, keyword);
417
+ return this.#buildMenu(undefined, commandPrefix, keyword);
405
418
  }
406
419
  return undefined;
407
420
  }
408
421
 
409
- #buildMenu(pluginKey?: string, commandPrefix = ''): string {
422
+ #buildMenu(pluginKey: string | undefined, commandPrefix: string, keyword: string): string {
410
423
  const root = this.snapshot.root;
411
424
  const targetId = pluginKey
412
425
  ? `${root}/${pluginKey.split('.').join('/')}` as PluginId
@@ -420,7 +433,6 @@ export class CommandIndex {
420
433
  .map((childId) => this.snapshot.tree.get(childId))
421
434
  .filter((child): child is NonNullable<typeof child> => !!child);
422
435
 
423
- const keyword = this.#menu!.keyword;
424
436
  const displayKey = pluginKey ?? node.instanceKey;
425
437
  const lines: string[] = [`=== ${displayKey} 指令菜单 ===`];
426
438
 
@@ -508,6 +520,48 @@ function occupancyKey(
508
520
  return parameter ? routeShape(segments) : segments.join(' ');
509
521
  }
510
522
 
523
+ /** Accepted token arities, including optional tails and an unbounded rest tail. */
524
+ function declaredRouteShapes(segments: readonly string[], definition: CommandDefinition): readonly string[] {
525
+ let variants: string[][] = [[]];
526
+ const append = (token: string, optional = false): void => {
527
+ const withToken = variants.map((variant) => [...variant, token]);
528
+ variants = [...new Map((optional ? [...variants, ...withToken] : withToken)
529
+ .map((variant) => [variant.join(' '), variant])).values()];
530
+ };
531
+ for (const segment of segments) {
532
+ if (!segment.startsWith('$')) { append(segment); continue; }
533
+ const parameter = definition.$parameter;
534
+ if (!parameter) continue;
535
+ if (parameter.rest) append(isRequiredParameter(parameter) ? '$+' : '$*');
536
+ else append('$', !isRequiredParameter(parameter));
537
+ }
538
+ for (const [name, schema] of Object.entries(definition.params ?? {})) {
539
+ if (name === definition.$parameter?.name) continue;
540
+ const optional = schema.optional === true || schema.default !== undefined;
541
+ if (schema.rest) append(optional ? '$*' : '$+');
542
+ else append('$', optional);
543
+ }
544
+ return [...new Set(variants.map((variant) => variant.join(' ')))];
545
+ }
546
+
547
+ function routeShapesOverlap(left: string, right: string): boolean {
548
+ const shape = (key: string) => {
549
+ const tokens = key.split(' ');
550
+ const repeat = tokens.at(-1);
551
+ const rest = repeat === '$*' || repeat === '$+';
552
+ const prefix = rest ? tokens.slice(0, -1) : tokens;
553
+ return { prefix, min: prefix.length + (repeat === '$+' ? 1 : 0), max: rest ? Infinity : prefix.length };
554
+ };
555
+ const a = shape(left);
556
+ const b = shape(right);
557
+ const length = Math.max(a.min, b.min);
558
+ if (length > Math.min(a.max, b.max)) return false;
559
+ for (let index = 0; index < length; index++) {
560
+ if ((a.prefix[index] ?? '$') !== (b.prefix[index] ?? '$')) return false;
561
+ }
562
+ return true;
563
+ }
564
+
511
565
  function normalizeAliasList(
512
566
  alias: readonly string[] | undefined,
513
567
  ): readonly string[] | undefined {
@@ -520,6 +574,11 @@ function resolveShortcutParams(
520
574
  prefill: Readonly<Record<string, CommandDynamicValue>>,
521
575
  source: string,
522
576
  ): Record<string, CommandDynamicValue> {
577
+ for (const [name, schema] of Object.entries(definition.options ?? {})) {
578
+ if (schema.optional === false && schema.default === undefined) {
579
+ throw new TypeError(`Invalid shortcut for ${source}: cannot supply required option --${name}`);
580
+ }
581
+ }
523
582
  const allowed = new Set<string>();
524
583
  const parameter = definition.$parameter;
525
584
  if (parameter) allowed.add(parameter.name);
@@ -564,12 +623,6 @@ function resolveShortcutParams(
564
623
  return result;
565
624
  }
566
625
 
567
- function hasImSession(source: unknown): boolean {
568
- if (!source || typeof source !== 'object') return false;
569
- const conversation = (source as { conversation?: unknown }).conversation;
570
- return !!conversation && typeof conversation === 'object';
571
- }
572
-
573
626
  function exactMessageText(input: CommandMatchInput): string | undefined {
574
627
  if (typeof input === 'string') {
575
628
  const trimmed = input.trim();
@@ -620,6 +673,12 @@ function displayName(
620
673
  }).join(' ');
621
674
  }
622
675
 
676
+ function compileLiteralMatcher(segments: readonly string[], parameter: CommandParameterDefinition | undefined): SegmentMatcher | undefined {
677
+ if (!parameter) return undefined;
678
+ const literals = segments.filter((segment) => !segment.startsWith('$'));
679
+ return literals.length > 0 ? new SegmentMatcher(literals.join(' '), segmentFields) : undefined;
680
+ }
681
+
623
682
  function matcherPattern(
624
683
  segments: readonly string[],
625
684
  parameter: CommandParameterDefinition | undefined,
@@ -632,9 +691,7 @@ function matcherPattern(
632
691
  return `[...${parameter.name}:${isStructuredRestType(parameter.type) ? type : 'text'}]`;
633
692
  }
634
693
  if (isRequiredParameter(parameter)) return `<${parameter.name}:${type}>`;
635
- return parameter.defaultValue === undefined
636
- ? `[${parameter.name}:${type}]`
637
- : `[${parameter.name}:${type}=${typeof parameter.defaultValue === 'function' ? '<dynamic>' : String(parameter.defaultValue)}]`;
694
+ return `[${parameter.name}:${type}]`;
638
695
  }).join(' ');
639
696
  }
640
697
 
@@ -792,3 +849,10 @@ export class CommandParameterValueError extends TypeError {
792
849
  this.name = 'CommandParameterValueError';
793
850
  }
794
851
  }
852
+
853
+ export class CommandArgumentValidationError extends TypeError {
854
+ constructor(readonly issues: readonly CommandValidationIssue[], readonly help: string) {
855
+ super(issues.map((issue) => issue.message).join('\n') + '\n' + help);
856
+ this.name = 'CommandArgumentValidationError';
857
+ }
858
+ }
package/src/definition.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  type CapabilityContext,
12
12
  } from '@zhin.js/feature-kit';
13
13
  import { assertPermitSyntax } from '@zhin.js/permission';
14
+ import { validateArgumentSchemas } from './arguments.js';
14
15
  import type { UserInteraction } from '@zhin.js/interaction';
15
16
 
16
17
  const commandBrand = 'zhin.command/1' as const;
@@ -34,7 +35,7 @@ export type CommandParameterValue =
34
35
  | string
35
36
  | number
36
37
  | boolean
37
- | ReadonlyArray<string | number | boolean>
38
+ | ReadonlyArray<string | number | boolean | Readonly<Record<string, unknown>>>
38
39
  | Readonly<Record<string, unknown>>
39
40
  | null;
40
41
 
@@ -88,8 +89,44 @@ export interface CommandParamSchema {
88
89
  readonly type: CommandParameterType;
89
90
  readonly default?: CommandDynamicValue;
90
91
  readonly description?: string;
92
+ readonly optional?: boolean;
93
+ readonly rest?: boolean;
94
+ readonly min?: number;
95
+ readonly max?: number;
96
+ readonly choices?: readonly CommandParameterValue[];
97
+ readonly pattern?: RegExp;
98
+ readonly validate?: (value: CommandParameterValue, session: CommandSession) => boolean | string;
99
+ }
100
+
101
+ export interface CommandOptionSchema extends Omit<CommandParamSchema, 'rest'> {
102
+ readonly alias?: string;
91
103
  }
92
104
 
105
+ export type CommandParams = Readonly<Record<string, CommandParamSchema>>;
106
+ export type CommandOptions = Readonly<Record<string, CommandOptionSchema>>;
107
+ type ParameterScalar<S extends CommandParamSchema> = S['type'] extends 'number' | 'integer' | 'float' | 'dice' | 'rps'
108
+ ? number : S['type'] extends 'boolean' ? boolean
109
+ : S['type'] extends 'image' ? Readonly<Record<string, unknown>> | string
110
+ : S['type'] extends 'mention' | 'face' | 'reply' | 'forward' ? string | number : string;
111
+ type ParameterResult<S extends CommandParamSchema> = S extends { readonly rest: true }
112
+ ? readonly ParameterScalar<S>[] : ParameterScalar<S>;
113
+ type HasDefault<S extends CommandParamSchema> = S extends { readonly default: infer D }
114
+ ? undefined extends D ? false : true : false;
115
+ type OptionalParam<S extends CommandParamSchema> = S extends { readonly rest: true } ? false
116
+ : HasDefault<S> extends true ? false : S extends { readonly optional: true } ? true : false;
117
+ type OptionalOption<S extends CommandOptionSchema> = HasDefault<S> extends true ? false
118
+ : S extends { readonly optional: false } ? false : true;
119
+ export type InferCommandParams<P extends CommandParams> = {
120
+ readonly [K in keyof P as OptionalParam<P[K]> extends true ? never : K]: ParameterResult<P[K]>;
121
+ } & {
122
+ readonly [K in keyof P as OptionalParam<P[K]> extends true ? K : never]?: ParameterResult<P[K]>;
123
+ };
124
+ export type InferCommandOptions<O extends CommandOptions> = {
125
+ readonly [K in keyof O as OptionalOption<O[K]> extends true ? never : K]: ParameterResult<O[K]>;
126
+ } & {
127
+ readonly [K in keyof O as OptionalOption<O[K]> extends true ? K : never]?: ParameterResult<O[K]>;
128
+ };
129
+
93
130
  /** Minimal structural contract shared with canonical IM segments. */
94
131
  export interface CommandSegment {
95
132
  readonly type: string | { readonly name: string };
@@ -226,6 +263,7 @@ export interface CommandContext<
226
263
  > extends CapabilityContext<TConfig>, CommandSession {
227
264
  readonly args: readonly string[];
228
265
  readonly params: Readonly<Record<string, CommandParameterValue>>;
266
+ readonly options: Readonly<Record<string, CommandParameterValue>>;
229
267
  /** Structured arguments left after the command pattern was consumed. */
230
268
  readonly segments: readonly Readonly<CommandSegment>[];
231
269
  /**
@@ -259,7 +297,8 @@ export interface CommandDefinition<
259
297
  * Next.js 风格参数声明:动态段文件名(`[name]` 等)的形态配合这里的
260
298
  * 类型 / 默认值 / 描述使用。静态命令可忽略本字段。
261
299
  */
262
- readonly params?: Readonly<Record<string, CommandParamSchema>>;
300
+ readonly params?: CommandParams;
301
+ readonly options?: CommandOptions;
263
302
  /**
264
303
  * 本地静态段别名(可多词,如 `'gh issue'`)。Capability owner 不参与
265
304
  * 用户路由;alias 只替换文件路径提供的静态段。
@@ -299,21 +338,31 @@ type CommandAuthoringDefinition<
299
338
  TConfig,
300
339
  TResult,
301
340
  TInput extends CommandMessage,
341
+ P extends CommandParams,
342
+ O extends CommandOptions,
302
343
  > =
303
- | Omit<CommandDefinition<TConfig, TResult, TInput, undefined>, '$feature' | '$parameter'>
344
+ | Omit<CommandDefinition<TConfig, TResult, TInput, undefined>, '$feature' | '$parameter' | 'params' | 'options' | 'execute'> & { readonly params?: P; readonly options?: O; execute(context: TypedCommandContext<TConfig, TInput, undefined, P, O>): TResult | Promise<TResult> }
304
345
  | {
305
346
  [TAdapter in RegisteredAdapterName]: Omit<
306
347
  CommandDefinition<TConfig, TResult, TInput, TAdapter>,
307
- '$feature' | '$parameter'
308
- > & { readonly adapter: TAdapter }
348
+ '$feature' | '$parameter' | 'params' | 'options' | 'execute'
349
+ > & { readonly adapter: TAdapter; readonly params?: P; readonly options?: O; execute(context: TypedCommandContext<TConfig, TInput, TAdapter, P, O>): TResult | Promise<TResult> }
309
350
  }[RegisteredAdapterName];
310
351
 
352
+ type TypedCommandContext<TConfig, TInput extends CommandMessage, TAdapter extends string | undefined, P extends CommandParams, O extends CommandOptions> =
353
+ Omit<CommandContext<TConfig, TInput, TAdapter>, 'params' | 'options'> & {
354
+ readonly params: keyof P extends never ? Readonly<Record<string, CommandParameterValue>> : InferCommandParams<P>;
355
+ readonly options: InferCommandOptions<O>;
356
+ };
357
+
311
358
  export function defineCommand<
312
359
  TConfig = unknown,
313
360
  TResult = unknown,
314
361
  TInput extends CommandMessage = CommandMessage,
362
+ const P extends CommandParams = {},
363
+ const O extends CommandOptions = {},
315
364
  >(
316
- definition: CommandAuthoringDefinition<TConfig, TResult, TInput>,
365
+ definition: CommandAuthoringDefinition<TConfig, TResult, TInput, P, O>,
317
366
  ): Readonly<CommandDefinition<TConfig, TResult, TInput, string | undefined>> {
318
367
  if (typeof definition.execute !== 'function') {
319
368
  throw new TypeError('Command execute must be a function');
@@ -330,6 +379,7 @@ export function defineCommand<
330
379
  }
331
380
  }
332
381
  }
382
+ validateArgumentSchemas(definition.params, definition.options);
333
383
  validateCommandAlias(definition.alias);
334
384
  validateCommandPermit(definition.permit);
335
385
  validateCommandShortcutShape(definition.shortcut);
@@ -444,14 +494,18 @@ export function createCommandContext(
444
494
  segments: readonly Readonly<CommandSegment>[] = Object.freeze([]),
445
495
  interaction?: UserInteraction,
446
496
  adapter?: string,
497
+ options: Readonly<Record<string, CommandParameterValue>> = Object.freeze({}),
447
498
  ): CommandContext {
448
499
  const context = createCapabilityContext(snapshot, ownerId);
449
500
  const session = resolveCommandSession(input);
501
+ const parameterValues: Record<string, CommandParameterValue> = { __proto__: null, ...params };
502
+ const optionValues: Record<string, CommandParameterValue> = { __proto__: null, ...options };
450
503
  const result = {
451
504
  ...context,
452
505
  ...session,
453
506
  args: Object.freeze([...args]),
454
- params: Object.freeze({ ...params }),
507
+ params: Object.freeze(parameterValues),
508
+ options: Object.freeze(optionValues),
455
509
  segments: freezeSegments(segments),
456
510
  ...(input !== undefined ? { input: input as CommandMessage } : {}),
457
511
  ...(interaction !== undefined ? { interaction } : {}),
package/src/index.ts CHANGED
@@ -22,3 +22,5 @@ export {
22
22
  default as commandFeature,
23
23
  } from './provider.js';
24
24
  export { default } from './provider.js';
25
+
26
+ export type { CommandValidationIssue } from './arguments.js';
package/src/provider.ts CHANGED
@@ -124,6 +124,13 @@ function resolveParameter(
124
124
  `missing params.${hint.name} declaration in defineCommand({ params })`,
125
125
  );
126
126
  }
127
+ if (Object.keys(definition.params ?? {})[0] !== hint.name) {
128
+ throw new CommandPathSyntaxError(source, `params.${hint.name} must be the first parameter declaration`);
129
+ }
130
+ if (schema.rest !== undefined && schema.rest !== hint.rest
131
+ || schema.optional !== undefined && schema.optional !== hint.optional) {
132
+ throw new CommandPathSyntaxError(source, `params.${hint.name} optional/rest declaration conflicts with the directory shape`);
133
+ }
127
134
  if (!hint.optional && schema.default !== undefined) {
128
135
  throw new CommandPathSyntaxError(
129
136
  source,