@zhin.js/command 1.1.5 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  import { SegmentMatcher, TypeMatcherRegistry, } from 'segment-matcher';
2
2
  import { bindOperationInput, operationClientAdapter } from '@zhin.js/feature-kit';
3
3
  import { permissionHostToken, toPermissionSubject, } from '@zhin.js/permission';
4
+ import { commandUsage, commandHelp, parseCommandArguments, requestsCommandHelp } from './arguments.js';
4
5
  import { createCommandContext, resolveCommandSession, resolveDynamicParams, } from './definition.js';
5
6
  const segmentFields = {
6
7
  text: 'text',
@@ -27,12 +28,16 @@ export class CommandIndex {
27
28
  const routes = [];
28
29
  const occupancy = new Map();
29
30
  const shortcuts = new Map();
30
- const claim = (key, source) => {
31
- const existing = occupancy.get(key);
32
- if (existing !== undefined) {
33
- throw new Error(`Duplicate Command route "${key}" (${source} vs ${existing})`);
31
+ const claim = (keys, source) => {
32
+ for (const key of keys) {
33
+ for (const [occupied, existing] of occupancy) {
34
+ if (routeShapesOverlap(key, occupied)) {
35
+ throw new Error(`Duplicate Command route "${key}" (${source} vs ${existing})`);
36
+ }
37
+ }
34
38
  }
35
- occupancy.set(key, source);
39
+ for (const key of keys)
40
+ occupancy.set(key, source);
36
41
  };
37
42
  for (const slot of slots) {
38
43
  const namespace = commandNamespace(this.snapshot, slot.owner);
@@ -52,10 +57,16 @@ export class CommandIndex {
52
57
  description: slot.definition.description,
53
58
  ...(slot.definition.adapter ? { adapter: slot.definition.adapter } : {}),
54
59
  source: slot.source,
55
- parameters: Object.freeze(parameter ? [{
56
- ...parameter,
57
- required: isRequiredParameter(parameter),
58
- }] : []),
60
+ usage: commandUsage(name, slot.definition),
61
+ options: Object.freeze({ ...slot.definition.options }),
62
+ parameters: Object.freeze([
63
+ ...Object.entries(slot.definition.params ?? {}).map(([key, schema]) => ({
64
+ name: key, ...schema, ...(parameter?.name === key ? parameter : {}), defaultValue: schema.default,
65
+ required: parameter?.name === key ? isRequiredParameter(parameter) : !schema.optional && schema.default === undefined,
66
+ })),
67
+ ...(parameter && !Object.hasOwn(slot.definition.params ?? {}, parameter.name)
68
+ ? [{ ...parameter, required: isRequiredParameter(parameter) }] : []),
69
+ ]),
59
70
  ...(alias ? { alias } : {}),
60
71
  ...(permit ? { permit } : {}),
61
72
  ...(shortcutKeys && shortcutKeys.length > 0 ? { shortcut: shortcutKeys } : {}),
@@ -63,22 +74,24 @@ export class CommandIndex {
63
74
  segments: Object.freeze(primarySegments),
64
75
  parameter,
65
76
  });
66
- claim(occupancyKey(primarySegments, parameter), slot.source);
77
+ claim([...new Set([occupancyKey(primarySegments, parameter), ...declaredRouteShapes(primarySegments, slot.definition)])], slot.source);
67
78
  routes.push({
68
79
  record,
69
80
  segments: primarySegments,
70
81
  matcher: new SegmentMatcher(matcherPattern(primarySegments, parameter), segmentFields),
82
+ literalMatcher: compileLiteralMatcher(primarySegments, parameter),
71
83
  kind: 'primary',
72
84
  });
73
85
  if (alias) {
74
86
  for (const entry of alias) {
75
87
  const aliasSegments = [...namespace, ...aliasRuntimeSegments(entry, primarySegments.slice(namespace.length))];
76
88
  assertParameterSegment(aliasSegments, parameter, `${slot.source} alias ${JSON.stringify(entry)}`);
77
- claim(occupancyKey(aliasSegments, parameter), `${slot.source} alias ${JSON.stringify(entry)}`);
89
+ claim([...new Set([occupancyKey(aliasSegments, parameter), ...declaredRouteShapes(aliasSegments, slot.definition)])], `${slot.source} alias ${JSON.stringify(entry)}`);
78
90
  routes.push({
79
91
  record,
80
92
  segments: Object.freeze(aliasSegments),
81
93
  matcher: new SegmentMatcher(matcherPattern(aliasSegments, parameter), segmentFields),
94
+ literalMatcher: compileLiteralMatcher(aliasSegments, parameter),
82
95
  kind: 'alias',
83
96
  });
84
97
  }
@@ -86,7 +99,7 @@ export class CommandIndex {
86
99
  if (slot.definition.shortcut) {
87
100
  for (const [rawTrigger, prefill] of Object.entries(slot.definition.shortcut)) {
88
101
  const trigger = [...namespace, ...splitCommand(rawTrigger)].join(' ');
89
- claim(trigger, `${slot.source} shortcut ${JSON.stringify(trigger)}`);
102
+ claim([trigger], `${slot.source} shortcut ${JSON.stringify(trigger)}`);
90
103
  shortcuts.set(trigger, {
91
104
  record,
92
105
  params: Object.freeze(resolveShortcutParams(slot.definition, prefill, `${slot.source} shortcut ${JSON.stringify(trigger)}`)),
@@ -113,13 +126,19 @@ export class CommandIndex {
113
126
  }
114
127
  }
115
128
  async execute(name, args = [], operationSnapshot = this.snapshot) {
116
- const match = this.#match(name, true);
129
+ const match = this.#match(name, false);
117
130
  if (!match) {
118
131
  this.#diagnoseParameter(name);
119
132
  throw new Error(`Unknown Command: ${name}`);
120
133
  }
121
- // Host / 无 session:跳过 permit;无 source 时函数默认值得到空 session。
122
- return match.command.slot.definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner, args, resolveDynamicParams(match.params, undefined), undefined, Object.freeze([]), undefined, match.command.slot.definition.adapter));
134
+ if (!(await this.#permitAllows(match.command, undefined, operationSnapshot)))
135
+ throw new Error(`Permission denied for Command: ${match.command.name}`);
136
+ const definition = match.command.slot.definition;
137
+ const supplied = [...match.remaining, ...(args.length > 0 ? [{ type: 'text', data: { text: args.join(' ') } }] : [])];
138
+ const parsed = parseCommandArguments(definition, supplied, resolveDynamicParams(match.params, undefined), {});
139
+ if (parsed.issues.length > 0)
140
+ throw new CommandArgumentValidationError(parsed.issues, commandHelp(match.command.name, definition));
141
+ return definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner, textArgs(parsed.remaining), parsed.params, undefined, parsed.remaining, undefined, definition.adapter, parsed.options));
123
142
  }
124
143
  async dispatch(input, source = undefined, interactionFactory, commandPrefix = '', operationSnapshot = this.snapshot) {
125
144
  if (this.#menu) {
@@ -127,6 +146,7 @@ export class CommandIndex {
127
146
  if (menuValue !== undefined) {
128
147
  return Object.freeze({
129
148
  matched: true,
149
+ status: 'executed',
130
150
  command: this.#menu.keyword,
131
151
  owner: operationSnapshot.root,
132
152
  value: menuValue,
@@ -134,51 +154,70 @@ export class CommandIndex {
134
154
  }
135
155
  }
136
156
  const shortcut = this.#matchShortcut(input);
137
- if (shortcut) {
138
- if (!commandAdapterMatches(shortcut.record, source)) {
139
- return Object.freeze({ matched: false });
140
- }
141
- if (!(await this.#permitAllows(shortcut.record, source, operationSnapshot))) {
142
- return Object.freeze({ matched: false });
143
- }
144
- const authorSource = bindOperationInput(source, shortcut.record.slot.owner, operationSnapshot);
145
- const value = await shortcut.record.slot.definition.execute(createCommandContext(operationSnapshot, shortcut.record.slot.owner, Object.freeze([]), resolveDynamicParams(shortcut.params, authorSource), authorSource, Object.freeze([]), interactionFactory?.(authorSource), shortcut.record.slot.definition.adapter));
146
- return Object.freeze({
147
- matched: true,
148
- command: shortcut.record.name,
149
- owner: shortcut.record.slot.owner,
150
- value,
151
- });
157
+ const match = shortcut
158
+ ? { command: shortcut.record, params: shortcut.params, remaining: Object.freeze([]), helpRequested: false }
159
+ : this.#match(input, false);
160
+ if (!match) {
161
+ const candidate = this.#invalidRoute(input);
162
+ if (!candidate)
163
+ return Object.freeze({ matched: false, status: 'unmatched' });
164
+ if (!commandAdapterMatches(candidate, source))
165
+ return Object.freeze({ matched: false, status: 'unmatched' });
166
+ if (!(await this.#permitAllows(candidate, source, operationSnapshot)))
167
+ return this.#denied(candidate);
168
+ const help = commandHelp(candidate.name, candidate.slot.definition, commandPrefix);
169
+ const segments = normalizeSegments(typeof input === 'string' ? [{ type: 'text', data: { text: input } }] : input);
170
+ if (requestsCommandHelp(segments))
171
+ return Object.freeze({ matched: true, status: 'executed', command: candidate.name, owner: candidate.slot.owner, value: help });
172
+ return Object.freeze({ matched: true, status: 'invalid', command: candidate.name, owner: candidate.slot.owner,
173
+ issues: Object.freeze([{ field: candidate.parameter?.name ?? 'arguments', message: 'Missing or invalid command parameter' }]), help });
152
174
  }
153
- const match = this.#match(input, false);
154
- if (!match)
155
- return Object.freeze({ matched: false });
156
- if (!commandAdapterMatches(match.command, source)) {
157
- return Object.freeze({ matched: false });
175
+ if (!commandAdapterMatches(match.command, source))
176
+ return Object.freeze({ matched: false, status: 'unmatched' });
177
+ if (!(await this.#permitAllows(match.command, source, operationSnapshot)))
178
+ return this.#denied(match.command);
179
+ const definition = match.command.slot.definition;
180
+ const authorSource = bindOperationInput(source, match.command.slot.owner, operationSnapshot);
181
+ const help = commandHelp(match.command.name, definition, commandPrefix);
182
+ if (match.helpRequested) {
183
+ return Object.freeze({ matched: true, status: 'executed', command: match.command.name, owner: match.command.slot.owner, value: help });
158
184
  }
159
- if (!(await this.#permitAllows(match.command, source, operationSnapshot))) {
160
- return Object.freeze({ matched: false });
185
+ const parsed = parseCommandArguments(definition, match.remaining, resolveDynamicParams(match.params, authorSource), resolveCommandSession(authorSource));
186
+ if (parsed.issues.length > 0)
187
+ return Object.freeze({ matched: true, status: 'invalid', command: match.command.name,
188
+ owner: match.command.slot.owner, issues: parsed.issues, help });
189
+ const value = await definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner, textArgs(parsed.remaining), parsed.params, authorSource, parsed.remaining, interactionFactory?.(authorSource), definition.adapter, parsed.options));
190
+ return Object.freeze({ matched: true, status: 'executed', command: match.command.name, owner: match.command.slot.owner, value });
191
+ }
192
+ #denied(record) {
193
+ return Object.freeze({ matched: true, status: 'denied', command: record.name, owner: record.slot.owner });
194
+ }
195
+ /** Diagnose only a reliable static route prefix; fully dynamic routes are ordinary chat candidates. */
196
+ #invalidRoute(input) {
197
+ const segments = normalizeSegments(typeof input === 'string' ? [{ type: 'text', data: { text: input } }] : input);
198
+ for (const route of this.#routes) {
199
+ if (!route.record.parameter)
200
+ continue;
201
+ const result = route.literalMatcher?.match(asMatcherSegments(segments));
202
+ if (result && hasCommandBoundary(result.remaining))
203
+ return route.record;
161
204
  }
162
- const args = textArgs(match.remaining);
163
- const authorSource = bindOperationInput(source, match.command.slot.owner, operationSnapshot);
164
- const value = await match.command.slot.definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner, args, resolveDynamicParams(match.params, authorSource), authorSource, match.remaining, interactionFactory?.(authorSource), match.command.slot.definition.adapter));
165
- return Object.freeze({
166
- matched: true,
167
- command: match.command.name,
168
- owner: match.command.slot.owner,
169
- value,
170
- });
205
+ return undefined;
171
206
  }
172
207
  async #permitAllows(record, source, operationSnapshot) {
173
208
  const permits = record.permit;
174
209
  if (!permits || permits.length === 0)
175
210
  return true;
176
- if (!hasImSession(source))
177
- return true;
211
+ if (!source || typeof source !== 'object')
212
+ return false;
178
213
  const host = this.#resolveHost(operationSnapshot);
179
214
  if (!host)
180
215
  return false;
181
- const subject = toPermissionSubject(resolveCommandSession(source));
216
+ const projected = toPermissionSubject(source);
217
+ const session = resolveCommandSession(source);
218
+ const subject = { ...projected, ...session, ...(session.sender ? { sender: { ...projected.sender, ...session.sender } } : {}) };
219
+ if (!subject.sender?.id && !subject.scene?.id && !subject.adapter && !subject.endpoint)
220
+ return false;
182
221
  return host.checkAll(permits, subject);
183
222
  }
184
223
  #resolveHost(operationSnapshot) {
@@ -210,12 +249,18 @@ export class CommandIndex {
210
249
  if (segments.length === 0)
211
250
  return undefined;
212
251
  for (const route of this.#routes) {
213
- const result = route.matcher.match(asMatcherSegments(segments));
252
+ // Options may appear between the literal route and a directory-bound parameter.
253
+ const literalResult = !exact ? route.literalMatcher?.match(asMatcherSegments(segments)) : undefined;
254
+ const helpRequested = literalResult && hasCommandBoundary(literalResult.remaining)
255
+ && requestsCommandHelp(normalizeSegments(literalResult.remaining));
256
+ const parseDeclaredArguments = !exact && route.record.parameter && route.literalMatcher !== undefined
257
+ && (Object.hasOwn(route.record.slot.definition.params ?? {}, route.record.parameter.name) || helpRequested);
258
+ const result = parseDeclaredArguments ? literalResult : route.matcher.match(asMatcherSegments(segments));
214
259
  if (!result || !hasCommandBoundary(result.remaining))
215
260
  continue;
216
261
  const parameter = route.record.parameter;
217
262
  const params = { ...result.params };
218
- if (parameter?.rest) {
263
+ if (parameter?.rest && !parseDeclaredArguments) {
219
264
  const raw = result.params[parameter.name];
220
265
  const coerced = coerceRestValues(parameter, Array.isArray(raw) ? raw : []);
221
266
  if (!coerced || (isRequiredParameter(parameter) && coerced.length === 0))
@@ -225,15 +270,18 @@ export class CommandIndex {
225
270
  const remaining = normalizeSegments(result.remaining);
226
271
  if (exact && remaining.length > 0)
227
272
  continue;
228
- if (parameter && !parameter.rest && parameter.optional === true
229
- && parameter.defaultValue === undefined
230
- && (params[parameter.name] === '' || params[parameter.name] === null)) {
231
- delete params[parameter.name];
273
+ if (parameter && !parseDeclaredArguments && !parameter.rest && !isRequiredParameter(parameter)
274
+ && (params[parameter.name] === '' || params[parameter.name] === null || params[parameter.name] === undefined)) {
275
+ if (parameter.defaultValue !== undefined)
276
+ params[parameter.name] = parameter.defaultValue;
277
+ else
278
+ delete params[parameter.name];
232
279
  }
233
280
  return {
234
281
  command: route.record,
235
282
  params: Object.freeze(params),
236
283
  remaining,
284
+ helpRequested: literalResult ? !!helpRequested : requestsCommandHelp(remaining),
237
285
  };
238
286
  }
239
287
  return undefined;
@@ -262,18 +310,21 @@ export class CommandIndex {
262
310
  const text = typeof input === 'string' ? input.trim() : exactMessageText(input);
263
311
  if (text === undefined)
264
312
  return undefined;
265
- const keyword = this.#menu.keyword;
313
+ const menu = this.#menu;
314
+ if (!menu)
315
+ return undefined;
316
+ const keyword = menu.keyword;
266
317
  if (text === keyword)
267
- return this.#buildMenu(undefined, commandPrefix);
318
+ return this.#buildMenu(undefined, commandPrefix, keyword);
268
319
  if (text.startsWith(keyword + ' ')) {
269
320
  const key = text.slice(keyword.length + 1).trim();
270
321
  if (key)
271
- return this.#buildMenu(key, commandPrefix);
272
- return this.#buildMenu(undefined, commandPrefix);
322
+ return this.#buildMenu(key, commandPrefix, keyword);
323
+ return this.#buildMenu(undefined, commandPrefix, keyword);
273
324
  }
274
325
  return undefined;
275
326
  }
276
- #buildMenu(pluginKey, commandPrefix = '') {
327
+ #buildMenu(pluginKey, commandPrefix, keyword) {
277
328
  const root = this.snapshot.root;
278
329
  const targetId = pluginKey
279
330
  ? `${root}/${pluginKey.split('.').join('/')}`
@@ -285,7 +336,6 @@ export class CommandIndex {
285
336
  const children = node.children
286
337
  .map((childId) => this.snapshot.tree.get(childId))
287
338
  .filter((child) => !!child);
288
- const keyword = this.#menu.keyword;
289
339
  const displayKey = pluginKey ?? node.instanceKey;
290
340
  const lines = [`=== ${displayKey} 指令菜单 ===`];
291
341
  if (directCommands.length > 0) {
@@ -354,12 +404,68 @@ function aliasRuntimeSegments(alias, primarySegments) {
354
404
  function occupancyKey(segments, parameter) {
355
405
  return parameter ? routeShape(segments) : segments.join(' ');
356
406
  }
407
+ /** Accepted token arities, including optional tails and an unbounded rest tail. */
408
+ function declaredRouteShapes(segments, definition) {
409
+ let variants = [[]];
410
+ const append = (token, optional = false) => {
411
+ const withToken = variants.map((variant) => [...variant, token]);
412
+ variants = [...new Map((optional ? [...variants, ...withToken] : withToken)
413
+ .map((variant) => [variant.join(' '), variant])).values()];
414
+ };
415
+ for (const segment of segments) {
416
+ if (!segment.startsWith('$')) {
417
+ append(segment);
418
+ continue;
419
+ }
420
+ const parameter = definition.$parameter;
421
+ if (!parameter)
422
+ continue;
423
+ if (parameter.rest)
424
+ append(isRequiredParameter(parameter) ? '$+' : '$*');
425
+ else
426
+ append('$', !isRequiredParameter(parameter));
427
+ }
428
+ for (const [name, schema] of Object.entries(definition.params ?? {})) {
429
+ if (name === definition.$parameter?.name)
430
+ continue;
431
+ const optional = schema.optional === true || schema.default !== undefined;
432
+ if (schema.rest)
433
+ append(optional ? '$*' : '$+');
434
+ else
435
+ append('$', optional);
436
+ }
437
+ return [...new Set(variants.map((variant) => variant.join(' ')))];
438
+ }
439
+ function routeShapesOverlap(left, right) {
440
+ const shape = (key) => {
441
+ const tokens = key.split(' ');
442
+ const repeat = tokens.at(-1);
443
+ const rest = repeat === '$*' || repeat === '$+';
444
+ const prefix = rest ? tokens.slice(0, -1) : tokens;
445
+ return { prefix, min: prefix.length + (repeat === '$+' ? 1 : 0), max: rest ? Infinity : prefix.length };
446
+ };
447
+ const a = shape(left);
448
+ const b = shape(right);
449
+ const length = Math.max(a.min, b.min);
450
+ if (length > Math.min(a.max, b.max))
451
+ return false;
452
+ for (let index = 0; index < length; index++) {
453
+ if ((a.prefix[index] ?? '$') !== (b.prefix[index] ?? '$'))
454
+ return false;
455
+ }
456
+ return true;
457
+ }
357
458
  function normalizeAliasList(alias) {
358
459
  if (!alias || alias.length === 0)
359
460
  return undefined;
360
461
  return Object.freeze(alias.map((entry) => entry.trim().split(/\s+/u).filter(Boolean).join(' ')));
361
462
  }
362
463
  function resolveShortcutParams(definition, prefill, source) {
464
+ for (const [name, schema] of Object.entries(definition.options ?? {})) {
465
+ if (schema.optional === false && schema.default === undefined) {
466
+ throw new TypeError(`Invalid shortcut for ${source}: cannot supply required option --${name}`);
467
+ }
468
+ }
363
469
  const allowed = new Set();
364
470
  const parameter = definition.$parameter;
365
471
  if (parameter)
@@ -396,12 +502,6 @@ function resolveShortcutParams(definition, prefill, source) {
396
502
  }
397
503
  return result;
398
504
  }
399
- function hasImSession(source) {
400
- if (!source || typeof source !== 'object')
401
- return false;
402
- const conversation = source.conversation;
403
- return !!conversation && typeof conversation === 'object';
404
- }
405
505
  function exactMessageText(input) {
406
506
  if (typeof input === 'string') {
407
507
  const trimmed = input.trim();
@@ -445,6 +545,12 @@ function displayName(segments, parameter) {
445
545
  return required ? `<${label}>` : `[${label}]`;
446
546
  }).join(' ');
447
547
  }
548
+ function compileLiteralMatcher(segments, parameter) {
549
+ if (!parameter)
550
+ return undefined;
551
+ const literals = segments.filter((segment) => !segment.startsWith('$'));
552
+ return literals.length > 0 ? new SegmentMatcher(literals.join(' '), segmentFields) : undefined;
553
+ }
448
554
  function matcherPattern(segments, parameter) {
449
555
  return segments.map((segment) => {
450
556
  if (!segment.startsWith('$'))
@@ -457,9 +563,7 @@ function matcherPattern(segments, parameter) {
457
563
  }
458
564
  if (isRequiredParameter(parameter))
459
565
  return `<${parameter.name}:${type}>`;
460
- return parameter.defaultValue === undefined
461
- ? `[${parameter.name}:${type}]`
462
- : `[${parameter.name}:${type}=${typeof parameter.defaultValue === 'function' ? '<dynamic>' : String(parameter.defaultValue)}]`;
566
+ return `[${parameter.name}:${type}]`;
463
567
  }).join(' ');
464
568
  }
465
569
  function matcherType(type) {
@@ -590,3 +694,13 @@ export class CommandParameterValueError extends TypeError {
590
694
  this.name = 'CommandParameterValueError';
591
695
  }
592
696
  }
697
+ export class CommandArgumentValidationError extends TypeError {
698
+ issues;
699
+ help;
700
+ constructor(issues, help) {
701
+ super(issues.map((issue) => issue.message).join('\n') + '\n' + help);
702
+ this.issues = issues;
703
+ this.help = help;
704
+ this.name = 'CommandArgumentValidationError';
705
+ }
706
+ }
@@ -7,7 +7,7 @@ import { type AdapterClient, type RegisteredAdapterName, type CapabilityContext
7
7
  import type { UserInteraction } from '@zhin.js/interaction';
8
8
  declare const commandBrand: "zhin.command/1";
9
9
  export type CommandParameterType = 'string' | 'number' | 'integer' | 'float' | 'boolean' | 'word' | 'text' | 'mention' | 'image' | 'face' | 'reply' | 'forward' | 'dice' | 'rps';
10
- export type CommandParameterValue = string | number | boolean | ReadonlyArray<string | number | boolean> | Readonly<Record<string, unknown>> | null;
10
+ export type CommandParameterValue = string | number | boolean | ReadonlyArray<string | number | boolean | Readonly<Record<string, unknown>>> | Readonly<Record<string, unknown>> | null;
11
11
  /**
12
12
  * 可从运行时会话上下文动态解析的参数值。
13
13
  *
@@ -39,7 +39,44 @@ export interface CommandParamSchema {
39
39
  readonly type: CommandParameterType;
40
40
  readonly default?: CommandDynamicValue;
41
41
  readonly description?: string;
42
+ readonly optional?: boolean;
43
+ readonly rest?: boolean;
44
+ readonly min?: number;
45
+ readonly max?: number;
46
+ readonly choices?: readonly CommandParameterValue[];
47
+ readonly pattern?: RegExp;
48
+ readonly validate?: (value: CommandParameterValue, session: CommandSession) => boolean | string;
49
+ }
50
+ export interface CommandOptionSchema extends Omit<CommandParamSchema, 'rest'> {
51
+ readonly alias?: string;
42
52
  }
53
+ export type CommandParams = Readonly<Record<string, CommandParamSchema>>;
54
+ export type CommandOptions = Readonly<Record<string, CommandOptionSchema>>;
55
+ type ParameterScalar<S extends CommandParamSchema> = S['type'] extends 'number' | 'integer' | 'float' | 'dice' | 'rps' ? number : S['type'] extends 'boolean' ? boolean : S['type'] extends 'image' ? Readonly<Record<string, unknown>> | string : S['type'] extends 'mention' | 'face' | 'reply' | 'forward' ? string | number : string;
56
+ type ParameterResult<S extends CommandParamSchema> = S extends {
57
+ readonly rest: true;
58
+ } ? readonly ParameterScalar<S>[] : ParameterScalar<S>;
59
+ type HasDefault<S extends CommandParamSchema> = S extends {
60
+ readonly default: infer D;
61
+ } ? undefined extends D ? false : true : false;
62
+ type OptionalParam<S extends CommandParamSchema> = S extends {
63
+ readonly rest: true;
64
+ } ? false : HasDefault<S> extends true ? false : S extends {
65
+ readonly optional: true;
66
+ } ? true : false;
67
+ type OptionalOption<S extends CommandOptionSchema> = HasDefault<S> extends true ? false : S extends {
68
+ readonly optional: false;
69
+ } ? false : true;
70
+ export type InferCommandParams<P extends CommandParams> = {
71
+ readonly [K in keyof P as OptionalParam<P[K]> extends true ? never : K]: ParameterResult<P[K]>;
72
+ } & {
73
+ readonly [K in keyof P as OptionalParam<P[K]> extends true ? K : never]?: ParameterResult<P[K]>;
74
+ };
75
+ export type InferCommandOptions<O extends CommandOptions> = {
76
+ readonly [K in keyof O as OptionalOption<O[K]> extends true ? never : K]: ParameterResult<O[K]>;
77
+ } & {
78
+ readonly [K in keyof O as OptionalOption<O[K]> extends true ? K : never]?: ParameterResult<O[K]>;
79
+ };
43
80
  /** Minimal structural contract shared with canonical IM segments. */
44
81
  export interface CommandSegment {
45
82
  readonly type: string | {
@@ -169,6 +206,7 @@ export interface CommandSession {
169
206
  export interface CommandContext<TConfig = unknown, TInput extends CommandMessage = CommandMessage, TAdapter extends string | undefined = undefined> extends CapabilityContext<TConfig>, CommandSession {
170
207
  readonly args: readonly string[];
171
208
  readonly params: Readonly<Record<string, CommandParameterValue>>;
209
+ readonly options: Readonly<Record<string, CommandParameterValue>>;
172
210
  /** Structured arguments left after the command pattern was consumed. */
173
211
  readonly segments: readonly Readonly<CommandSegment>[];
174
212
  /**
@@ -196,7 +234,8 @@ export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput
196
234
  * Next.js 风格参数声明:动态段文件名(`[name]` 等)的形态配合这里的
197
235
  * 类型 / 默认值 / 描述使用。静态命令可忽略本字段。
198
236
  */
199
- readonly params?: Readonly<Record<string, CommandParamSchema>>;
237
+ readonly params?: CommandParams;
238
+ readonly options?: CommandOptions;
200
239
  /**
201
240
  * 本地静态段别名(可多词,如 `'gh issue'`)。Capability owner 不参与
202
241
  * 用户路由;alias 只替换文件路径提供的静态段。
@@ -223,12 +262,23 @@ declare module '@zhin.js/plugin-runtime' {
223
262
  * 定义一个命令模块(`commands/` 约定目录下默认导出)。
224
263
  * @public 用户侧创作面,承诺 semver(见 docs/contributing/public-api-surface.md)。
225
264
  */
226
- type CommandAuthoringDefinition<TConfig, TResult, TInput extends CommandMessage> = Omit<CommandDefinition<TConfig, TResult, TInput, undefined>, '$feature' | '$parameter'> | {
227
- [TAdapter in RegisteredAdapterName]: Omit<CommandDefinition<TConfig, TResult, TInput, TAdapter>, '$feature' | '$parameter'> & {
265
+ type CommandAuthoringDefinition<TConfig, TResult, TInput extends CommandMessage, P extends CommandParams, O extends CommandOptions> = (Omit<CommandDefinition<TConfig, TResult, TInput, undefined>, '$feature' | '$parameter' | 'params' | 'options' | 'execute'> & {
266
+ readonly params?: P;
267
+ readonly options?: O;
268
+ execute(context: TypedCommandContext<TConfig, TInput, undefined, P, O>): TResult | Promise<TResult>;
269
+ }) | {
270
+ [TAdapter in RegisteredAdapterName]: Omit<CommandDefinition<TConfig, TResult, TInput, TAdapter>, '$feature' | '$parameter' | 'params' | 'options' | 'execute'> & {
228
271
  readonly adapter: TAdapter;
272
+ readonly params?: P;
273
+ readonly options?: O;
274
+ execute(context: TypedCommandContext<TConfig, TInput, TAdapter, P, O>): TResult | Promise<TResult>;
229
275
  };
230
276
  }[RegisteredAdapterName];
231
- export declare function defineCommand<TConfig = unknown, TResult = unknown, TInput extends CommandMessage = CommandMessage>(definition: CommandAuthoringDefinition<TConfig, TResult, TInput>): Readonly<CommandDefinition<TConfig, TResult, TInput, string | undefined>>;
277
+ type TypedCommandContext<TConfig, TInput extends CommandMessage, TAdapter extends string | undefined, P extends CommandParams, O extends CommandOptions> = Omit<CommandContext<TConfig, TInput, TAdapter>, 'params' | 'options'> & {
278
+ readonly params: keyof P extends never ? Readonly<Record<string, CommandParameterValue>> : InferCommandParams<P>;
279
+ readonly options: InferCommandOptions<O>;
280
+ };
281
+ export declare function defineCommand<TConfig = unknown, TResult = unknown, TInput extends CommandMessage = CommandMessage, const P extends CommandParams = {}, const O extends CommandOptions = {}>(definition: CommandAuthoringDefinition<TConfig, TResult, TInput, P, O>): Readonly<CommandDefinition<TConfig, TResult, TInput, string | undefined>>;
232
282
  /** @internal Convention-loader assembly helper. */
233
283
  export declare function bindCommandParameter<TConfig, TResult, TInput extends CommandMessage, TAdapter extends string | undefined>(definition: CommandDefinition<TConfig, TResult, TInput, TAdapter>, parameter: CommandParameterDefinition | undefined): Readonly<CommandDefinition<TConfig, TResult, TInput, TAdapter>>;
234
284
  /** @internal Runtime validation for convention-discovered modules. */
@@ -240,7 +290,7 @@ export declare function parseCommandDefinition(value: unknown): CommandDefinitio
240
290
  */
241
291
  export declare function resolveDynamicParams(params: Readonly<Record<string, CommandDynamicValue>>, source: unknown): Readonly<Record<string, CommandParameterValue>>;
242
292
  /** @internal Command dispatcher assembly helper. */
243
- export declare function createCommandContext(snapshot: RuntimeSnapshot, ownerId: PluginId, args: readonly string[], params?: Readonly<Record<string, CommandParameterValue>>, input?: unknown, segments?: readonly Readonly<CommandSegment>[], interaction?: UserInteraction, adapter?: string): CommandContext;
293
+ export declare function createCommandContext(snapshot: RuntimeSnapshot, ownerId: PluginId, args: readonly string[], params?: Readonly<Record<string, CommandParameterValue>>, input?: unknown, segments?: readonly Readonly<CommandSegment>[], interaction?: UserInteraction, adapter?: string, options?: Readonly<Record<string, CommandParameterValue>>): CommandContext;
244
294
  /**
245
295
  * 从派发来源(通常是 Runtime `Message`)解析入站快捷字段。
246
296
  * 不依赖 `@zhin.js/core`,按 {@link CommandMessage} 结构鸭式识别。
package/lib/definition.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createCapabilityContext, readOperationClient, } from '@zhin.js/feature-kit';
2
2
  import { assertPermitSyntax } from '@zhin.js/permission';
3
+ import { validateArgumentSchemas } from './arguments.js';
3
4
  const commandBrand = 'zhin.command/1';
4
5
  /** @internal Runtime validator lookup. */
5
6
  export const commandParameterTypes = new Set([
@@ -34,6 +35,7 @@ export function defineCommand(definition) {
34
35
  }
35
36
  }
36
37
  }
38
+ validateArgumentSchemas(definition.params, definition.options);
37
39
  validateCommandAlias(definition.alias);
38
40
  validateCommandPermit(definition.permit);
39
41
  validateCommandShortcutShape(definition.shortcut);
@@ -120,14 +122,17 @@ export function resolveDynamicParams(params, source) {
120
122
  return Object.freeze(resolved);
121
123
  }
122
124
  /** @internal Command dispatcher assembly helper. */
123
- export function createCommandContext(snapshot, ownerId, args, params = Object.freeze({}), input = undefined, segments = Object.freeze([]), interaction, adapter) {
125
+ export function createCommandContext(snapshot, ownerId, args, params = Object.freeze({}), input = undefined, segments = Object.freeze([]), interaction, adapter, options = Object.freeze({})) {
124
126
  const context = createCapabilityContext(snapshot, ownerId);
125
127
  const session = resolveCommandSession(input);
128
+ const parameterValues = { __proto__: null, ...params };
129
+ const optionValues = { __proto__: null, ...options };
126
130
  const result = {
127
131
  ...context,
128
132
  ...session,
129
133
  args: Object.freeze([...args]),
130
- params: Object.freeze({ ...params }),
134
+ params: Object.freeze(parameterValues),
135
+ options: Object.freeze(optionValues),
131
136
  segments: freezeSegments(segments),
132
137
  ...(input !== undefined ? { input: input } : {}),
133
138
  ...(interaction !== undefined ? { interaction } : {}),
package/lib/index.d.ts CHANGED
@@ -9,3 +9,4 @@ export * from './definition.js';
9
9
  export { assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, isPlatformPermit, parsePermitName, type ParsedPermit, type PermitKind, } from './permit.js';
10
10
  export { CommandPathSyntaxError, commandFeatureId, default as commandFeature, } from './provider.js';
11
11
  export { default } from './provider.js';
12
+ export type { CommandValidationIssue } from './arguments.js';
package/lib/provider.js CHANGED
@@ -86,6 +86,13 @@ function resolveParameter(definition, file, source) {
86
86
  if (!schema) {
87
87
  throw new CommandPathSyntaxError(source, `missing params.${hint.name} declaration in defineCommand({ params })`);
88
88
  }
89
+ if (Object.keys(definition.params ?? {})[0] !== hint.name) {
90
+ throw new CommandPathSyntaxError(source, `params.${hint.name} must be the first parameter declaration`);
91
+ }
92
+ if (schema.rest !== undefined && schema.rest !== hint.rest
93
+ || schema.optional !== undefined && schema.optional !== hint.optional) {
94
+ throw new CommandPathSyntaxError(source, `params.${hint.name} optional/rest declaration conflicts with the directory shape`);
95
+ }
89
96
  if (!hint.optional && schema.default !== undefined) {
90
97
  throw new CommandPathSyntaxError(source, `params.${hint.name} has a default but the directory is required: rename it to [[${hint.name}]]`);
91
98
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/command",
3
- "version": "1.1.5",
3
+ "version": "1.1.6",
4
4
  "description": "Convention-based Command Feature for Zhin Plugin Runtime",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -18,10 +18,10 @@
18
18
  ],
19
19
  "dependencies": {
20
20
  "segment-matcher": "^1.0.5",
21
- "@zhin.js/feature-kit": "1.1.3",
22
- "@zhin.js/interaction": "1.1.0",
23
- "@zhin.js/permission": "1.1.4",
24
- "@zhin.js/plugin-runtime": "1.1.12"
21
+ "@zhin.js/feature-kit": "1.1.4",
22
+ "@zhin.js/interaction": "1.1.1",
23
+ "@zhin.js/permission": "1.1.5",
24
+ "@zhin.js/plugin-runtime": "1.1.13"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^26.1.2",