@zhin.js/command 1.1.4 → 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.
@@ -9,12 +9,13 @@ import type {
9
9
  RuntimeSnapshot,
10
10
  } from '@zhin.js/plugin-runtime';
11
11
  import type { UserInteractionFactory } from '@zhin.js/interaction';
12
- import { operationClientAdapter } from '@zhin.js/feature-kit';
12
+ import { bindOperationInput, operationClientAdapter } from '@zhin.js/feature-kit';
13
13
  import {
14
14
  permissionHostToken,
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,77 +256,69 @@ 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,
249
263
  });
250
264
  }
251
265
  }
252
- const interaction = interactionFactory?.(source);
253
266
  const shortcut = this.#matchShortcut(input);
254
- if (shortcut) {
255
- if (!commandAdapterMatches(shortcut.record, source)) {
256
- return Object.freeze({ matched: false });
257
- }
258
- if (!(await this.#permitAllows(shortcut.record, source, operationSnapshot))) {
259
- return Object.freeze({ matched: false });
260
- }
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, source),
267
- source,
268
- Object.freeze([]),
269
- interaction,
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 value = await match.command.slot.definition.execute(
291
- createCommandContext(
292
- operationSnapshot,
293
- match.command.slot.owner,
294
- args,
295
- resolveDynamicParams(match.params, source),
296
- source,
297
- match.remaining,
298
- interaction,
299
- match.command.slot.definition.adapter,
300
- ),
301
- );
302
- return Object.freeze({
303
- matched: true,
304
- command: match.command.name,
305
- owner: match.command.slot.owner,
306
- value,
307
- });
309
+ return undefined;
308
310
  }
309
311
 
310
312
  async #permitAllows(record: CommandRecord, source: unknown, operationSnapshot: RuntimeSnapshot): Promise<boolean> {
311
313
  const permits = record.permit;
312
314
  if (!permits || permits.length === 0) return true;
313
- if (!hasImSession(source)) return true;
315
+ if (!source || typeof source !== 'object') return false;
314
316
  const host = this.#resolveHost(operationSnapshot);
315
317
  if (!host) return false;
316
- 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;
317
322
  return host.checkAll(permits, subject);
318
323
  }
319
324
 
@@ -347,11 +352,17 @@ export class CommandIndex {
347
352
  if (segments.length === 0) return undefined;
348
353
 
349
354
  for (const route of this.#routes) {
350
- 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));
351
362
  if (!result || !hasCommandBoundary(result.remaining)) continue;
352
363
  const parameter = route.record.parameter;
353
364
  const params: Record<string, CommandDynamicValue> = { ...result.params };
354
- if (parameter?.rest) {
365
+ if (parameter?.rest && !parseDeclaredArguments) {
355
366
  const raw = result.params[parameter.name];
356
367
  const coerced = coerceRestValues(parameter, Array.isArray(raw) ? raw : []);
357
368
  if (!coerced || (isRequiredParameter(parameter) && coerced.length === 0)) continue;
@@ -359,15 +370,16 @@ export class CommandIndex {
359
370
  }
360
371
  const remaining = normalizeSegments(result.remaining);
361
372
  if (exact && remaining.length > 0) continue;
362
- if (parameter && !parameter.rest && parameter.optional === true
363
- && parameter.defaultValue === undefined
364
- && (params[parameter.name] === '' || params[parameter.name] === null)) {
365
- 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];
366
377
  }
367
378
  return {
368
379
  command: route.record,
369
380
  params: Object.freeze(params),
370
381
  remaining,
382
+ helpRequested: literalResult ? !!helpRequested : requestsCommandHelp(remaining),
371
383
  };
372
384
  }
373
385
  return undefined;
@@ -395,17 +407,19 @@ export class CommandIndex {
395
407
  #dispatchMenu(input: CommandMatchInput, commandPrefix = ''): string | undefined {
396
408
  const text = typeof input === 'string' ? input.trim() : exactMessageText(input);
397
409
  if (text === undefined) return undefined;
398
- const keyword = this.#menu!.keyword;
399
- 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);
400
414
  if (text.startsWith(keyword + ' ')) {
401
415
  const key = text.slice(keyword.length + 1).trim();
402
- if (key) return this.#buildMenu(key, commandPrefix);
403
- return this.#buildMenu(undefined, commandPrefix);
416
+ if (key) return this.#buildMenu(key, commandPrefix, keyword);
417
+ return this.#buildMenu(undefined, commandPrefix, keyword);
404
418
  }
405
419
  return undefined;
406
420
  }
407
421
 
408
- #buildMenu(pluginKey?: string, commandPrefix = ''): string {
422
+ #buildMenu(pluginKey: string | undefined, commandPrefix: string, keyword: string): string {
409
423
  const root = this.snapshot.root;
410
424
  const targetId = pluginKey
411
425
  ? `${root}/${pluginKey.split('.').join('/')}` as PluginId
@@ -419,7 +433,6 @@ export class CommandIndex {
419
433
  .map((childId) => this.snapshot.tree.get(childId))
420
434
  .filter((child): child is NonNullable<typeof child> => !!child);
421
435
 
422
- const keyword = this.#menu!.keyword;
423
436
  const displayKey = pluginKey ?? node.instanceKey;
424
437
  const lines: string[] = [`=== ${displayKey} 指令菜单 ===`];
425
438
 
@@ -507,6 +520,48 @@ function occupancyKey(
507
520
  return parameter ? routeShape(segments) : segments.join(' ');
508
521
  }
509
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
+
510
565
  function normalizeAliasList(
511
566
  alias: readonly string[] | undefined,
512
567
  ): readonly string[] | undefined {
@@ -519,6 +574,11 @@ function resolveShortcutParams(
519
574
  prefill: Readonly<Record<string, CommandDynamicValue>>,
520
575
  source: string,
521
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
+ }
522
582
  const allowed = new Set<string>();
523
583
  const parameter = definition.$parameter;
524
584
  if (parameter) allowed.add(parameter.name);
@@ -563,12 +623,6 @@ function resolveShortcutParams(
563
623
  return result;
564
624
  }
565
625
 
566
- function hasImSession(source: unknown): boolean {
567
- if (!source || typeof source !== 'object') return false;
568
- const conversation = (source as { conversation?: unknown }).conversation;
569
- return !!conversation && typeof conversation === 'object';
570
- }
571
-
572
626
  function exactMessageText(input: CommandMatchInput): string | undefined {
573
627
  if (typeof input === 'string') {
574
628
  const trimmed = input.trim();
@@ -619,6 +673,12 @@ function displayName(
619
673
  }).join(' ');
620
674
  }
621
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
+
622
682
  function matcherPattern(
623
683
  segments: readonly string[],
624
684
  parameter: CommandParameterDefinition | undefined,
@@ -631,9 +691,7 @@ function matcherPattern(
631
691
  return `[...${parameter.name}:${isStructuredRestType(parameter.type) ? type : 'text'}]`;
632
692
  }
633
693
  if (isRequiredParameter(parameter)) return `<${parameter.name}:${type}>`;
634
- return parameter.defaultValue === undefined
635
- ? `[${parameter.name}:${type}]`
636
- : `[${parameter.name}:${type}=${typeof parameter.defaultValue === 'function' ? '<dynamic>' : String(parameter.defaultValue)}]`;
694
+ return `[${parameter.name}:${type}]`;
637
695
  }).join(' ');
638
696
  }
639
697
 
@@ -791,3 +849,10 @@ export class CommandParameterValueError extends TypeError {
791
849
  this.name = 'CommandParameterValueError';
792
850
  }
793
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,