@zhin.js/command 1.1.10 → 1.1.11

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.
@@ -5,10 +5,12 @@ import {
5
5
  } from 'segment-matcher';
6
6
  import {
7
7
  htmlRendererToken,
8
+ resolveLocale,
8
9
  type CapabilityId,
9
10
  type CapabilitySlot,
10
11
  type PluginId,
11
12
  type RuntimeSnapshot,
13
+ type Locale,
12
14
  } from '@zhin.js/plugin-runtime';
13
15
  import type { UserInteractionFactory } from '@zhin.js/interaction';
14
16
  import { bindOperationInput } from '@zhin.js/feature-kit';
@@ -17,8 +19,10 @@ import {
17
19
  type PermissionHost,
18
20
  } from '@zhin.js/permission';
19
21
  import { commandAdapterMatches, toCommandPermissionSubject } from './permission-explanation.js';
20
- import { commandUsage, commandHelp, parseCommandArguments, requestsCommandHelp, type CommandValidationIssue } from './arguments.js';
21
- import { MENU_PAGE_SIZE, readMenuAppearance, renderCommandMenu, type CommandMenuSegment } from './command-menu.js';
22
+ import { commandUsage, commandHelp, parseCommandArguments, requestsCommandHelp, commandValidationIssue, type CommandValidationIssue } from './arguments.js';
23
+ import { commandDescription, commandMessages } from './messages.js';
24
+ import { MENU_PAGE_SIZE, readMenuAppearance, renderCommandMenu, type CommandMenuSegment, type CommandMenuEntry, type CommandMenuAppearance } from './command-menu.js';
25
+ export type { CommandMenuEntry, CommandMenuAppearance } from './command-menu.js';
22
26
  import { admitCommandExecution, admitDirectCommandExecution } from './execution-runtime.js';
23
27
  import { CommandExecutionLimitedError, normalizeExecutionPolicy, type CommandExecutionLimit } from './execution-policy.js';
24
28
  export { CommandExecutionLimitedError } from './execution-policy.js';
@@ -63,7 +67,7 @@ export interface CommandDispatchResult {
63
67
  readonly command?: string;
64
68
  readonly owner?: PluginId;
65
69
  readonly value?: unknown;
66
- readonly menu?: CommandMenuNavigation;
70
+ readonly menu?: CommandMenuPage;
67
71
  }
68
72
 
69
73
  /** Semantic menu navigation; the IM composition layer supplies platform controls. */
@@ -74,14 +78,28 @@ export interface CommandMenuNavigation {
74
78
  readonly pages: number;
75
79
  }
76
80
 
81
+ /** Immutable page data; presentation cannot change the catalog or execute commands. */
82
+ export interface CommandMenuPage extends CommandMenuNavigation {
83
+ readonly locale: Locale;
84
+ readonly title: string;
85
+ readonly commands: readonly CommandMenuEntry[];
86
+ readonly plugins: readonly CommandMenuEntry[];
87
+ readonly commandTotal: number;
88
+ readonly pluginTotal: number;
89
+ readonly hint?: string;
90
+ readonly text: string;
91
+ readonly appearance: CommandMenuAppearance;
92
+ }
93
+
77
94
  interface CommandMenuResult {
78
95
  readonly value: string | CommandMenuSegment;
79
- readonly menu?: CommandMenuNavigation;
96
+ readonly menu?: CommandMenuPage;
80
97
  }
81
98
 
82
99
  interface CommandRecord extends CommandDescriptor {
83
100
  readonly slot: Readonly<CapabilitySlot<CommandDefinition>>;
84
101
  readonly segments: readonly string[];
102
+ readonly preferredSegments: Readonly<Partial<Record<Locale, readonly string[]>>>;
85
103
  readonly parameter?: CommandParameterDefinition;
86
104
  }
87
105
 
@@ -121,7 +139,8 @@ const segmentFields = {
121
139
  };
122
140
 
123
141
  export interface CommandMenuConfig {
124
- readonly keyword: string;
142
+ /** Omission selects the built-in keyword for each operation's language. */
143
+ readonly keyword?: string;
125
144
  }
126
145
 
127
146
  export class CommandIndex {
@@ -198,6 +217,8 @@ export class CommandIndex {
198
217
  ...(shortcutKeys && shortcutKeys.length > 0 ? { shortcut: shortcutKeys } : {}),
199
218
  slot,
200
219
  segments: Object.freeze(primarySegments),
220
+ preferredSegments: Object.freeze(Object.fromEntries(Object.entries(slot.definition.preferredAliasI18n ?? {}).map(([locale, entry]) =>
221
+ [locale, Object.freeze([...namespace, ...aliasRuntimeSegments(entry, primarySegments.slice(namespace.length))])]))),
201
222
  parameter,
202
223
  });
203
224
 
@@ -251,8 +272,30 @@ export class CommandIndex {
251
272
  this.#shortcuts = shortcuts;
252
273
  }
253
274
 
254
- list(): readonly CommandDescriptor[] {
255
- return this.#commands.map(toDescriptor);
275
+ list(locale: Locale = 'zh-CN'): readonly CommandDescriptor[] {
276
+ return this.#commands.map(record => ({ ...toDescriptor(record),
277
+ name: commandDisplayName(record, locale), usage: commandUsage(commandDisplayName(record, locale), record.slot.definition),
278
+ description: commandDescription(record.slot.definition, locale),
279
+ parameters: record.parameters.map(parameter => ({ ...parameter, description: commandDescription(parameter, locale) })),
280
+ options: Object.freeze(Object.fromEntries(Object.entries(record.options).map(([key, schema]) =>
281
+ [key, { ...schema, description: commandDescription(schema, locale) }]))),
282
+ }));
283
+ }
284
+
285
+ /** Resolve a local static route without executing parameters, permission checks or commands. */
286
+ formatCommand(owner: PluginId, localPath: string, prefix: string | undefined, locale: Locale = 'zh-CN'): string | undefined {
287
+ if (prefix === undefined || typeof localPath !== 'string') return undefined;
288
+ const path = localPath.trim().split(/\s+/u).filter(Boolean).join(' ');
289
+ if (!path) return undefined;
290
+ const command = this.#commands.find(record => record.owner === owner
291
+ && record.slot.localName.split('/').filter(segment => !segment.startsWith('$')).join(' ') === path);
292
+ return command ? prefix + commandDisplaySegments(command, locale).filter(segment => !segment.startsWith('$')).join(' ') : undefined;
293
+ }
294
+
295
+ /** Return the configured menu entry only when the calling environment is known. */
296
+ formatMenu(prefix: string | undefined, locale: Locale): string | undefined {
297
+ if (prefix === undefined || !this.#menu) return undefined;
298
+ return prefix + (this.#menu.keyword ?? commandMessages(locale, 'menuKeyword'));
256
299
  }
257
300
 
258
301
  has(name: string): boolean {
@@ -265,6 +308,7 @@ export class CommandIndex {
265
308
  }
266
309
 
267
310
  async execute(name: string, args: readonly string[] = [], operationSnapshot: RuntimeSnapshot = this.snapshot): Promise<unknown> {
311
+ const locale = commandLocale(undefined, operationSnapshot);
268
312
  const match = this.#match(name, false);
269
313
  if (!match) {
270
314
  this.#diagnoseParameter(name);
@@ -273,14 +317,14 @@ export class CommandIndex {
273
317
  if (!(await this.#permitAllows(match.command, undefined, operationSnapshot))) throw new Error(`Permission denied for Command: ${match.command.name}`);
274
318
  const definition = match.command.slot.definition;
275
319
  const supplied = [...match.remaining, ...(args.length > 0 ? [{ type: 'text', data: { text: args.join(' ') } }] : [])];
276
- const parsed = parseCommandArguments(definition, supplied, resolveDynamicParams(match.params, undefined), {});
277
- if (parsed.issues.length > 0) throw new CommandArgumentValidationError(parsed.issues, commandHelp(match.command.name, definition));
320
+ const parsed = parseCommandArguments(definition, supplied, resolveDynamicParams(match.params, undefined), {}, locale);
321
+ if (parsed.issues.length > 0) throw new CommandArgumentValidationError(parsed.issues, commandHelp(commandDisplayName(match.command, locale), definition, '', locale));
278
322
  // No inbound source: execution policy applies per command, never as a blanket refusal.
279
323
  const admission = admitDirectCommandExecution(match.command.slot.id, match.command.execution, operationSnapshot);
280
324
  if (admission.limit) throw new CommandExecutionLimitedError(admission.limit);
281
325
  try {
282
326
  return await definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner,
283
- textArgs(parsed.remaining), parsed.params, undefined, parsed.remaining, undefined, definition.adapter, parsed.options));
327
+ textArgs(parsed.remaining), parsed.params, undefined, parsed.remaining, undefined, definition.adapter, parsed.options, { locale }));
284
328
  } finally {
285
329
  admission.release();
286
330
  }
@@ -290,16 +334,20 @@ export class CommandIndex {
290
334
  input: CommandMatchInput,
291
335
  source: unknown = undefined,
292
336
  interactionFactory?: UserInteractionFactory,
293
- commandPrefix = '',
337
+ commandPrefix?: string,
294
338
  operationSnapshot: RuntimeSnapshot = this.snapshot,
339
+ localeOverride?: Locale,
295
340
  ): Promise<CommandDispatchResult> {
341
+ const locale = localeOverride ?? commandLocale(source, operationSnapshot);
342
+ const prefix = commandPrefix ?? '';
296
343
  if (this.#menu) {
297
- const menuResult = this.#dispatchMenu(input, commandPrefix, operationSnapshot);
344
+ const keyword = this.#menu.keyword ?? commandMessages(locale, 'menuKeyword');
345
+ const menuResult = this.#dispatchMenu(input, prefix, operationSnapshot, locale, keyword);
298
346
  if (menuResult !== undefined) {
299
347
  return Object.freeze({
300
348
  matched: true,
301
349
  status: 'executed',
302
- command: this.#menu.keyword,
350
+ command: keyword,
303
351
  owner: operationSnapshot.root,
304
352
  ...menuResult,
305
353
  });
@@ -314,27 +362,29 @@ export class CommandIndex {
314
362
  if (!candidate) return Object.freeze({ matched: false, status: 'unmatched' });
315
363
  if (!commandAdapterMatches(candidate, source)) return Object.freeze({ matched: false, status: 'unmatched' });
316
364
  if (!(await this.#permitAllows(candidate, source, operationSnapshot))) return this.#denied(candidate);
317
- const help = commandHelp(candidate.name, candidate.slot.definition, commandPrefix);
365
+ const help = commandHelp(commandDisplayName(candidate, locale), candidate.slot.definition, prefix, locale);
318
366
  const segments = normalizeSegments(typeof input === 'string' ? [{ type: 'text', data: { text: input } }] : input);
319
367
  if (requestsCommandHelp(segments)) return Object.freeze({ matched: true, status: 'executed', command: candidate.name, owner: candidate.slot.owner, value: help });
320
368
  return Object.freeze({ matched: true, status: 'invalid', command: candidate.name, owner: candidate.slot.owner,
321
- issues: Object.freeze([{ field: candidate.parameter?.name ?? 'arguments', message: 'Missing or invalid command parameter' }]), help });
369
+ issues: Object.freeze([commandValidationIssue('invalidParameter', candidate.parameter?.name ?? 'arguments', locale)]), help });
322
370
  }
323
371
  if (!commandAdapterMatches(match.command, source)) return Object.freeze({ matched: false, status: 'unmatched' });
324
372
  if (!(await this.#permitAllows(match.command, source, operationSnapshot))) return this.#denied(match.command);
325
373
  const definition = match.command.slot.definition;
326
374
  const authorSource = bindOperationInput(source, match.command.slot.owner, operationSnapshot);
327
- const help = commandHelp(match.command.name, definition, commandPrefix);
375
+ const help = commandHelp(commandDisplayName(match.command, locale), definition, prefix, locale);
328
376
  if (match.helpRequested) {
329
377
  return Object.freeze({ matched: true, status: 'executed', command: match.command.name, owner: match.command.slot.owner, value: help });
330
378
  }
331
- const parsed = parseCommandArguments(definition, match.remaining, resolveDynamicParams(match.params, authorSource), resolveCommandSession(authorSource));
379
+ const parsed = parseCommandArguments(definition, match.remaining, resolveDynamicParams(match.params, authorSource), resolveCommandSession(authorSource), locale);
332
380
  if (parsed.issues.length > 0) return Object.freeze({ matched: true, status: 'invalid', command: match.command.name,
333
381
  owner: match.command.slot.owner, issues: parsed.issues, help });
334
382
  const limit = admitCommandExecution(authorSource, match.command.slot.id, match.command.slot.owner, match.command.execution, operationSnapshot);
335
383
  if (limit) return Object.freeze({ matched: true, status: 'limited', command: match.command.name, owner: match.command.slot.owner, limit });
336
384
  const value = await definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner,
337
- textArgs(parsed.remaining), parsed.params, authorSource, parsed.remaining, interactionFactory?.(authorSource), definition.adapter, parsed.options));
385
+ textArgs(parsed.remaining), parsed.params, authorSource, parsed.remaining, interactionFactory?.(authorSource), definition.adapter, parsed.options,
386
+ { locale, formatCommand: (localPath, owner = match.command.owner) => this.formatCommand(owner, localPath,
387
+ source && typeof source === 'object' && 'conversation' in source ? commandPrefix : undefined, locale) }));
338
388
  return Object.freeze({ matched: true, status: 'executed', command: match.command.name, owner: match.command.slot.owner, value });
339
389
  }
340
390
 
@@ -446,13 +496,10 @@ export class CommandIndex {
446
496
  // 内置菜单命令
447
497
  // ==========================================================================
448
498
 
449
- #dispatchMenu(input: CommandMatchInput, commandPrefix: string, snapshot: RuntimeSnapshot): CommandMenuResult | undefined {
499
+ #dispatchMenu(input: CommandMatchInput, commandPrefix: string, snapshot: RuntimeSnapshot, locale: Locale, keyword: string): CommandMenuResult | undefined {
450
500
  const text = typeof input === 'string' ? input.trim() : exactMessageText(input);
451
501
  if (text === undefined) return undefined;
452
- const menu = this.#menu;
453
- if (!menu) return undefined;
454
- const keyword = menu.keyword;
455
- if (text === keyword) return this.#buildMenu(undefined, commandPrefix, keyword, snapshot);
502
+ if (text === keyword) return this.#buildMenu(undefined, commandPrefix, keyword, snapshot, locale);
456
503
  if (text.startsWith(keyword + ' ')) {
457
504
  const key = text.slice(keyword.length + 1).trim();
458
505
  const args = key.split(/\s+/u);
@@ -461,24 +508,24 @@ export class CommandIndex {
461
508
  const pageText = args[flag + 1] ?? '';
462
509
  if (flag > 1 || args.length !== flag + 2 || !/^[1-9]\d*$/u.test(pageText)
463
510
  || !Number.isSafeInteger(Number(pageText))) {
464
- return { value: `页码必须是正整数。用法:${commandPrefix}${keyword} [插件名] --page <页码>` };
511
+ return { value: commandMessages(locale, 'invalidPage', { command: `${commandPrefix}${keyword}` }) };
465
512
  }
466
- return this.#buildMenu(flag === 1 ? args[0] : undefined, commandPrefix, keyword, snapshot, Number(pageText));
513
+ return this.#buildMenu(flag === 1 ? args[0] : undefined, commandPrefix, keyword, snapshot, locale, Number(pageText));
467
514
  }
468
- if (key) return this.#buildMenu(key, commandPrefix, keyword, snapshot);
469
- return this.#buildMenu(undefined, commandPrefix, keyword, snapshot);
515
+ if (key) return this.#buildMenu(key, commandPrefix, keyword, snapshot, locale);
516
+ return this.#buildMenu(undefined, commandPrefix, keyword, snapshot, locale);
470
517
  }
471
518
  return undefined;
472
519
  }
473
520
 
474
- #buildMenu(pluginKey: string | undefined, commandPrefix: string, keyword: string, snapshot: RuntimeSnapshot, page = 1): CommandMenuResult {
521
+ #buildMenu(pluginKey: string | undefined, commandPrefix: string, keyword: string, snapshot: RuntimeSnapshot, locale: Locale, page = 1): CommandMenuResult {
475
522
  const root = snapshot.root;
476
523
  const targetId = pluginKey
477
524
  ? `${root}/${pluginKey.split('.').join('/')}` as PluginId
478
525
  : root;
479
526
 
480
527
  const node = snapshot.tree.get(targetId);
481
- if (!node) return { value: `未找到插件: ${pluginKey}` };
528
+ if (!node) return { value: commandMessages(locale, 'missingPlugin', { plugin: pluginKey ?? '' }) };
482
529
 
483
530
  const directCommands = this.#commands.filter((cmd) => cmd.slot.owner === targetId);
484
531
  const children = node.children
@@ -487,26 +534,27 @@ export class CommandIndex {
487
534
 
488
535
  const pages = Math.max(1, Math.ceil((directCommands.length + children.length) / MENU_PAGE_SIZE));
489
536
  const invocation = `${commandPrefix}${keyword}${pluginKey ? ` ${pluginKey}` : ''}`;
490
- if (page > pages) return { value: `页码超出范围:共 ${pages} 页。请使用「${invocation} --page <页码>」。` };
537
+ if (page > pages) return { value: commandMessages(locale, 'pageOutOfRange', { pages, command: invocation }) };
491
538
  const offset = (page - 1) * MENU_PAGE_SIZE;
492
539
  const pageCommands = directCommands.slice(offset, offset + MENU_PAGE_SIZE);
493
540
  const pageChildren = children.slice(Math.max(0, offset - directCommands.length), Math.max(0, offset + MENU_PAGE_SIZE - directCommands.length));
494
541
 
495
542
  const displayKey = pluginKey ?? node.instanceKey;
496
- const lines: string[] = [`=== ${displayKey} 指令菜单 ===`];
497
- if (pages > 1) lines.push(`第 ${page}/${pages} 页 · ${directCommands.length} 条指令 · ${children.length} 个子插件`);
543
+ const lines: string[] = [commandMessages(locale, 'menuTitle', { plugin: displayKey })];
544
+ if (pages > 1) lines.push(commandMessages(locale, 'menuSummary', { page, pages, commands: directCommands.length, plugins: children.length }));
498
545
 
499
546
  if (pageCommands.length > 0) {
500
547
  lines.push('');
501
548
  for (const cmd of pageCommands) {
502
- const desc = cmd.description ? ` - ${cmd.description}` : '';
503
- lines.push(` ${commandPrefix}${cmd.name}${desc}`);
549
+ const description = commandDescription(cmd.slot.definition, locale);
550
+ const desc = description ? ` - ${description}` : '';
551
+ lines.push(` ${commandPrefix}${commandDisplayName(cmd, locale)}${desc}`);
504
552
  }
505
553
  }
506
554
 
507
555
  if (pageChildren.length > 0) {
508
556
  lines.push('');
509
- lines.push('子插件:');
557
+ lines.push(commandMessages(locale, 'childPlugins'));
510
558
  for (const child of pageChildren) {
511
559
  const childKey = pluginKey ? `${pluginKey}.${child.instanceKey}` : child.instanceKey;
512
560
  const label = child.metadata?.displayName ?? child.instanceKey;
@@ -516,13 +564,13 @@ export class CommandIndex {
516
564
 
517
565
  if (directCommands.length === 0 && children.length === 0) {
518
566
  lines.push('');
519
- lines.push('(暂无指令和子插件)');
567
+ lines.push(commandMessages(locale, 'emptyMenu'));
520
568
  }
521
569
 
522
570
  const hints: string[] = [];
523
- if (children.length > 0) hints.push(`提示:使用「${commandPrefix}${keyword} <插件名>」查看子插件的指令`);
524
- if (page > 1) hints.push(`上一页:${invocation} --page ${page - 1}`);
525
- if (page < pages) hints.push(`下一页:${invocation} --page ${page + 1}`);
571
+ if (children.length > 0) hints.push(commandMessages(locale, 'pluginHint', { command: `${commandPrefix}${keyword}` }));
572
+ if (page > 1) hints.push(commandMessages(locale, 'previousPage', { command: `${invocation} --page ${page - 1}` }));
573
+ if (page < pages) hints.push(commandMessages(locale, 'nextPage', { command: `${invocation} --page ${page + 1}` }));
526
574
  const hint = hints.length ? hints.join('\n') : undefined;
527
575
  if (hint) {
528
576
  lines.push('');
@@ -530,19 +578,39 @@ export class CommandIndex {
530
578
  }
531
579
 
532
580
  const text = lines.join('\n');
533
- const menu = Object.freeze({ keyword, ...(pluginKey ? { pluginKey } : {}), page, pages });
534
- if (!snapshot.resources.get(root)?.has(htmlRendererToken.id)) return { value: text, menu };
535
581
  const config = snapshot.config.get(root) as Record<string, unknown> | undefined;
536
- return { menu, value: renderCommandMenu(displayKey,
537
- pageCommands.map(cmd => ({ name: `${commandPrefix}${cmd.name}`, description: cmd.description })),
538
- pageChildren.map(child => ({
582
+ const menu: CommandMenuPage = Object.freeze({
583
+ keyword, ...(pluginKey ? { pluginKey } : {}), page, pages, locale, title: displayKey,
584
+ commands: Object.freeze(pageCommands.map(cmd => Object.freeze({
585
+ name: `${commandPrefix}${commandDisplayName(cmd, locale)}`, description: commandDescription(cmd.slot.definition, locale),
586
+ }))),
587
+ plugins: Object.freeze(pageChildren.map(child => Object.freeze({
539
588
  name: pluginKey ? `${pluginKey}.${child.instanceKey}` : child.instanceKey,
540
589
  description: child.metadata?.displayName ?? child.instanceKey,
541
- })), hint, text, { page, pages, commandTotal: directCommands.length, pluginTotal: children.length,
542
- appearance: readMenuAppearance(config?.menuStyle) }) };
590
+ }))),
591
+ commandTotal: directCommands.length, pluginTotal: children.length, hint, text,
592
+ appearance: Object.freeze(readMenuAppearance(config?.menuStyle)),
593
+ });
594
+ if (!snapshot.resources.get(root)?.has(htmlRendererToken.id)) return { value: text, menu };
595
+ return { menu, value: renderCommandMenu(displayKey, menu.commands, menu.plugins, hint, text, {
596
+ page, pages, locale, commandTotal: menu.commandTotal, pluginTotal: menu.pluginTotal, appearance: menu.appearance,
597
+ }) };
543
598
  }
544
599
  }
545
600
 
601
+ function commandDisplaySegments(record: CommandRecord, locale: Locale): readonly string[] {
602
+ return record.preferredSegments[locale] ?? record.segments;
603
+ }
604
+
605
+ function commandDisplayName(record: CommandRecord, locale: Locale): string {
606
+ return displayName(commandDisplaySegments(record, locale), record.parameter);
607
+ }
608
+
609
+ function commandLocale(source: unknown, snapshot: RuntimeSnapshot): Locale {
610
+ const locale = source && typeof source === 'object' && 'locale' in source ? source.locale : undefined;
611
+ return resolveLocale(locale ?? (snapshot.config.get(snapshot.root) as { locale?: unknown } | undefined)?.locale);
612
+ }
613
+
546
614
  export function isCommandIndex(value: unknown): value is CommandIndex {
547
615
  return !!value && typeof value === 'object'
548
616
  && (value as { readonly $projection?: unknown }).$projection === 'zhin.command-index/1';
@@ -1,9 +1,12 @@
1
- interface MenuEntry {
1
+ import type { Locale } from '@zhin.js/plugin-runtime';
2
+ import { commandMessages } from './messages.js';
3
+
4
+ export interface CommandMenuEntry {
2
5
  readonly name: string;
3
6
  readonly description?: string;
4
7
  }
5
8
 
6
- interface MenuAppearance {
9
+ export interface CommandMenuAppearance {
7
10
  readonly title?: string;
8
11
  readonly subtitle?: string;
9
12
  readonly accentColor?: string;
@@ -27,7 +30,7 @@ const escape = (value: string) => value.replace(/[&<>"']/gu, char => ({
27
30
  })[char] ?? char);
28
31
 
29
32
  /** Configuration is text/data, never an interpolated CSS or HTML fragment. */
30
- export function readMenuAppearance(value: unknown): MenuAppearance {
33
+ export function readMenuAppearance(value: unknown): CommandMenuAppearance {
31
34
  if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
32
35
  const input = value as Record<string, unknown>;
33
36
  const result: { title?: string; subtitle?: string; accentColor?: string; backgroundImage?: string } = {};
@@ -69,20 +72,21 @@ function icon(name: string, plugin = false): string {
69
72
  /** Presentation only: the unified outbound pipeline owns image rendering and fallback. */
70
73
  export function renderCommandMenu(
71
74
  title: string,
72
- commands: readonly MenuEntry[],
73
- children: readonly MenuEntry[],
75
+ commands: readonly CommandMenuEntry[],
76
+ children: readonly CommandMenuEntry[],
74
77
  hint: string | undefined,
75
78
  text: string,
76
- options: { readonly page?: number; readonly pages?: number; readonly commandTotal?: number; readonly pluginTotal?: number; readonly appearance?: MenuAppearance } = {},
79
+ options: { readonly page?: number; readonly pages?: number; readonly locale?: Locale; readonly commandTotal?: number; readonly pluginTotal?: number; readonly appearance?: CommandMenuAppearance } = {},
77
80
  ): CommandMenuSegment {
81
+ const locale = options.locale ?? 'zh-CN';
78
82
  const appearance = options.appearance ?? {};
79
83
  const background = appearance.backgroundImage ?? `data:image/svg+xml,${encodeURIComponent(landscape)}`;
80
84
  // CSS string escaping additionally prevents an artwork URL from closing <style>.
81
85
  const cssUrl = JSON.stringify(background).replace(/</gu, '\\3c ').replace(/>/gu, '\\3e ');
82
- const entries = (items: readonly MenuEntry[], plugin: boolean) => items.map(item =>
86
+ const entries = (items: readonly CommandMenuEntry[], plugin: boolean) => items.map(item =>
83
87
  `<div class="entry"><div class="icon">${icon(item.name, plugin)}</div><div class="copy"><div class="name">${escape(item.name)}</div>${item.description
84
88
  ? `<div class="description">${escape(item.description)}</div>` : ''}</div></div>`).join('');
85
- const section = (label: string, items: readonly MenuEntry[], total: number, plugin = false) => items.length === 0 ? ''
89
+ const section = (label: string, items: readonly CommandMenuEntry[], total: number, plugin = false) => items.length === 0 ? ''
86
90
  : `<section><div class="section-head"><h2>${label}</h2><span>${items.length} / ${total}</span></div><div class="grid">${entries(items, plugin)}</div></section>`;
87
91
  const page = options.page ?? 1;
88
92
  const pages = options.pages ?? 1;
@@ -91,7 +95,7 @@ export function renderCommandMenu(
91
95
  type: 'html',
92
96
  data: {
93
97
  width: 1080, fileName: 'command-menu.png', text,
94
- html: `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><style>
98
+ html: `<!doctype html><html lang="${locale}"><head><meta charset="utf-8"><style>
95
99
  *{box-sizing:border-box}body{margin:0;background:#253b46;color:#f4f0e6;font-family:"PingFang SC","Microsoft YaHei",sans-serif;font-size:18px;line-height:1.5;--accent:${appearance.accentColor ?? '#e7c993'}}
96
100
  main{padding:42px 28px 24px;background-image:linear-gradient(180deg,#0c202a0a,#0c202a55),url(${cssUrl});background-size:cover;background-position:center top}
97
101
  header{min-height:210px;padding:10px 22px 30px;position:relative}.eyebrow{font-size:12px;letter-spacing:5px;color:#f4e4c4;margin:0 0 17px}
@@ -102,11 +106,11 @@ h2{margin:0;font-size:20px;font-weight:700;letter-spacing:1px;color:var(--accent
102
106
  .grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));background:#20363fdb;border-radius:0 0 13px 13px;overflow:hidden}
103
107
  .entry{display:flex;align-items:center;gap:12px;padding:19px 17px;min-width:0;min-height:106px;border-top:1px solid #e7e6d614;border-right:1px solid #e7e6d614;background:linear-gradient(120deg,#ffffff08,#ffffff00)}.entry:nth-child(3n){border-right:0}
104
108
  .icon{width:35px;height:35px;flex:0 0 35px;color:var(--accent);filter:drop-shadow(0 1px 6px #d9ba7c22)}.icon svg{display:block;width:100%;height:100%}.copy{min-width:0}.name{font-size:18px;font-weight:700;color:var(--accent)}.description{margin:4px 0 0;color:#e1e8e5;font-size:15px;line-height:1.45}
105
- .name,.description,h1{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere;white-space:pre-wrap}
109
+ .name{overflow-wrap:anywhere;white-space:pre-wrap}.description,h1{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere;white-space:pre-wrap}
106
110
  footer{margin:6px 5px 0;padding:15px 18px;display:flex;align-items:center;justify-content:space-between;gap:22px;background:#142a34da;border:1px solid #f0e2c71c;border-radius:13px}.hint{font-size:14px;color:#cfdbd7;white-space:pre-wrap;overflow-wrap:anywhere}.page{font-size:20px;color:var(--accent);white-space:nowrap;font-family:Georgia,serif}.signature{margin:16px 0 0;text-align:center;font-size:10px;letter-spacing:4px;color:#c4d0c899}.empty{margin:0 0 20px;padding:28px;background:#142a34da;border-radius:16px;color:#dbe4de}
107
- </style></head><body><main><header><div class="eyebrow">ZHIN / COMMAND ATLAS</div><h1>${escape(appearance.title || `${title} · 指令指南`)}</h1><div class="subtitle">${escape(appearance.subtitle ?? '每一个灵感,都有回应。')}</div><div class="summary"><i></i>${options.commandTotal ?? commands.length} 条指令<span>·</span>${options.pluginTotal ?? children.length} 个子插件<span>·</span>${escape(title)}</div></header>
108
- ${section('指令索引', commands, options.commandTotal ?? commands.length)}${section('插件导航', children, options.pluginTotal ?? children.length, true)}${commands.length || children.length ? '' : '<p class="empty">暂无指令和子插件</p>'}
109
- <footer><div class="hint">${escape(hint ?? '查看参数与用法:指令名 --help')}</div><div class="page">${String(page).padStart(2, '0')} / ${String(pages).padStart(2, '0')}</div></footer><div class="signature">ZHIN · MAKE EVERY MESSAGE MATTER</div></main></body></html>`,
111
+ </style></head><body><main><header><div class="eyebrow">ZHIN / COMMAND ATLAS</div><h1>${escape(appearance.title || commandMessages(locale, 'guideTitle', { title }))}</h1><div class="subtitle">${escape(appearance.subtitle ?? commandMessages(locale, 'guideSubtitle'))}</div><div class="summary"><i></i>${escape(commandMessages(locale, 'commandCount', { count: options.commandTotal ?? commands.length }))}<span>·</span>${escape(commandMessages(locale, 'pluginCount', { count: options.pluginTotal ?? children.length }))}<span>·</span>${escape(title)}</div></header>
112
+ ${section(commandMessages(locale, 'commandIndex'), commands, options.commandTotal ?? commands.length)}${section(commandMessages(locale, 'pluginIndex'), children, options.pluginTotal ?? children.length, true)}${commands.length || children.length ? '' : `<p class="empty">${commandMessages(locale, 'guideEmpty')}</p>`}
113
+ <footer><div class="hint">${escape(hint ?? commandMessages(locale, 'genericHelpHint'))}</div><div class="page">${String(page).padStart(2, '0')} / ${String(pages).padStart(2, '0')}</div></footer><div class="signature">ZHIN · MAKE EVERY MESSAGE MATTER</div></main></body></html>`,
110
114
  },
111
115
  };
112
116
  }
package/src/definition.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Command authoring API consumed from `zhin.js/command`.
3
3
  * @module zhin.js/command
4
4
  */
5
- import type { PluginId, RuntimeSnapshot } from '@zhin.js/plugin-runtime';
5
+ import { resolveLocale, type Locale, type PluginId, type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
6
6
  import {
7
7
  createCapabilityContext,
8
8
  readOperationClient,
@@ -92,6 +92,8 @@ export interface CommandParamSchema {
92
92
  readonly type: CommandParameterType;
93
93
  readonly default?: CommandDynamicValue;
94
94
  readonly description?: string;
95
+ /** Optional translated display text; machine keys and values remain unchanged. */
96
+ readonly descriptionI18n?: Readonly<Partial<Record<Locale, string>>>;
95
97
  readonly optional?: boolean;
96
98
  readonly rest?: boolean;
97
99
  readonly min?: number;
@@ -145,6 +147,7 @@ export interface CommandParameterDefinition {
145
147
  /** `[...name]` / `[[...name]]` 捕获所有段,运行时值为 `string[]`。 */
146
148
  readonly rest?: boolean;
147
149
  readonly description?: string;
150
+ readonly descriptionI18n?: Readonly<Partial<Record<Locale, string>>>;
148
151
  }
149
152
 
150
153
  /** 场景:群 / 私聊 / 频道等。 */
@@ -192,6 +195,8 @@ export interface CommandConversation {
192
195
  export interface CommandMessage {
193
196
  readonly conversation: CommandConversation;
194
197
  readonly content: string;
198
+ /** Display preference resolved by the IM runtime, when this is an IM operation. */
199
+ readonly locale?: Locale;
195
200
  /** 发送者(结构化视图见 CommandContext.sender)。 */
196
201
  readonly sender?: { readonly id: string; readonly name?: string; readonly roles?: readonly string[] };
197
202
  readonly id?: string;
@@ -264,6 +269,16 @@ export interface CommandContext<
264
269
  TInput extends CommandMessage = CommandMessage,
265
270
  TAdapter extends string | undefined = undefined,
266
271
  > extends CapabilityContext<TConfig>, CommandSession {
272
+ /** Language resolved once for the current operation. */
273
+ readonly locale: Locale;
274
+ /**
275
+ * Resolve a static local command path to its current chat invocation. Defaults to this
276
+ * plugin; an explicit owner may reference another indexed plugin.
277
+ * Includes the actual Endpoint prefix and configured commandNamespace. Arguments
278
+ * are appended explicitly by the caller. Returns undefined for unknown/disabled
279
+ * paths or calls without a known chat command environment.
280
+ */
281
+ formatCommand(localPath: string, owner?: PluginId): string | undefined;
267
282
  readonly args: readonly string[];
268
283
  readonly params: Readonly<Record<string, CommandParameterValue>>;
269
284
  readonly options: Readonly<Record<string, CommandParameterValue>>;
@@ -294,6 +309,7 @@ export interface CommandDefinition<
294
309
  /** @internal Convention-derived parameter metadata. */
295
310
  readonly $parameter?: CommandParameterDefinition;
296
311
  readonly description?: string;
312
+ readonly descriptionI18n?: Readonly<Partial<Record<Locale, string>>>;
297
313
  /** Restrict this command to one adapter and infer `context.$client`. */
298
314
  readonly adapter?: TAdapter;
299
315
  /**
@@ -307,6 +323,8 @@ export interface CommandDefinition<
307
323
  * 用户路由;alias 只替换文件路径提供的静态段。
308
324
  */
309
325
  readonly alias?: readonly string[];
326
+ /** Display an explicitly declared alias for this locale; every value must also occur in alias. */
327
+ readonly preferredAliasI18n?: Readonly<Partial<Record<Locale, string>>>;
310
328
  /**
311
329
  * 内置 permit DSL(AND)。单项内逗号为 OR。
312
330
  * 例:`adapter(icqq)`、`role(master)`、`group(123,456)`。
@@ -398,7 +416,9 @@ export function defineCommand<
398
416
  }
399
417
  }
400
418
  validateArgumentSchemas(definition.params, definition.options);
419
+ validateDescriptionI18n(definition.descriptionI18n);
401
420
  validateCommandAlias(definition.alias);
421
+ validatePreferredAliases(definition);
402
422
  validateCommandPermit(definition.permit);
403
423
  validateCommandShortcutShape(definition.shortcut);
404
424
  const execution = normalizeExecutionPolicy(definition.execution);
@@ -407,6 +427,15 @@ export function defineCommand<
407
427
  >;
408
428
  }
409
429
 
430
+ /** @internal Validate display-only translations without changing their fallback text. */
431
+ export function validateDescriptionI18n(value: CommandDefinition['descriptionI18n']): void {
432
+ if (value === undefined) return;
433
+ if (!value || typeof value !== 'object' || Array.isArray(value)
434
+ || Object.entries(value).some(([locale, text]) => !['zh-CN', 'en'].includes(locale) || typeof text !== 'string')) {
435
+ throw new TypeError('Command descriptionI18n must map zh-CN/en to strings');
436
+ }
437
+ }
438
+
410
439
  /**
411
440
  * 先固定插件 config 类型,再定义命令;params / options / 返回值仍从定义推断。
412
441
  *
@@ -435,6 +464,19 @@ function validateAdapterName(adapter: string | undefined): void {
435
464
  }
436
465
  }
437
466
 
467
+ function validatePreferredAliases(definition: Pick<CommandDefinition, 'alias' | 'preferredAliasI18n'>): void {
468
+ const preferred = definition.preferredAliasI18n;
469
+ if (preferred === undefined) return;
470
+ validateDescriptionI18n(preferred);
471
+ const normalize = (value: string) => value.trim().split(/\s+/u).filter(Boolean).join(' ');
472
+ const aliases = new Set(definition.alias?.map(normalize));
473
+ for (const [locale, alias] of Object.entries(preferred)) {
474
+ if (!aliases.has(normalize(alias))) {
475
+ throw new TypeError(`Command preferredAliasI18n.${locale} must reference a declared alias`);
476
+ }
477
+ }
478
+ }
479
+
438
480
  function validateCommandAlias(alias: readonly string[] | undefined): void {
439
481
  if (alias === undefined) return;
440
482
  if (!Array.isArray(alias)) {
@@ -506,6 +548,8 @@ export function parseCommandDefinition(value: unknown): CommandDefinition {
506
548
  }
507
549
  validateAdapterName(definition.adapter);
508
550
  normalizeExecutionPolicy(definition.execution);
551
+ validateCommandAlias(definition.alias);
552
+ validatePreferredAliases(definition);
509
553
  return definition as CommandDefinition;
510
554
  }
511
555
 
@@ -537,6 +581,7 @@ export function createCommandContext(
537
581
  interaction?: UserInteraction,
538
582
  adapter?: string,
539
583
  options: Readonly<Record<string, CommandParameterValue>> = Object.freeze({}),
584
+ presentation?: Readonly<{ locale: Locale; formatCommand?: (localPath: string, owner?: PluginId) => string | undefined }>,
540
585
  ): CommandContext {
541
586
  const context = createCapabilityContext(snapshot, ownerId, undefined, readOperationInputSignal(input));
542
587
  const session = resolveCommandSession(input);
@@ -545,6 +590,8 @@ export function createCommandContext(
545
590
  const result = {
546
591
  ...context,
547
592
  ...session,
593
+ locale: presentation?.locale ?? resolveLocale((snapshot.config.get(snapshot.root) as { locale?: unknown } | undefined)?.locale),
594
+ formatCommand: presentation?.formatCommand ?? (() => undefined),
548
595
  args: Object.freeze([...args]),
549
596
  params: Object.freeze(parameterValues),
550
597
  options: Object.freeze(optionValues),
package/src/index.ts CHANGED
@@ -28,4 +28,4 @@ export {
28
28
  } from './provider.js';
29
29
  export { default } from './provider.js';
30
30
 
31
- export type { CommandValidationIssue } from './arguments.js';
31
+ export type { CommandValidationIssue, CommandValidationIssueCode } from './arguments.js';
@@ -0,0 +1,62 @@
1
+ import { createMessages, type Locale } from '@zhin.js/plugin-runtime';
2
+
3
+ /** Translates only display metadata explicitly supplied by its author. */
4
+ export function commandDescription(
5
+ value: Readonly<{ description?: string; descriptionI18n?: Readonly<Partial<Record<Locale, string>>> }>,
6
+ locale: Locale,
7
+ ): string | undefined {
8
+ return value.descriptionI18n?.[locale] ?? value.description;
9
+ }
10
+
11
+ /** Static dictionaries; the caller supplies the operation's language. */
12
+ export const commandMessages = createMessages({
13
+ menuKeyword: { 'zh-CN': '菜单', en: 'menu' },
14
+ invalidPage: { 'zh-CN': '页码必须是正整数。用法:{command} [插件名] --page <页码>', en: 'The page must be a positive integer. Usage: {command} [plugin] --page <page>' },
15
+ missingPlugin: { 'zh-CN': '未找到插件: {plugin}', en: 'Plugin not found: {plugin}' },
16
+ pageOutOfRange: { 'zh-CN': '页码超出范围:共 {pages} 页。请使用「{command} --page <页码>」。', en: 'Page out of range: {pages} pages available. Use {command} --page <page>.' },
17
+ menuTitle: { 'zh-CN': '=== {plugin} 指令菜单 ===', en: '=== {plugin} command menu ===' },
18
+ menuSummary: { 'zh-CN': '第 {page}/{pages} 页 · {commands} 条指令 · {plugins} 个子插件', en: 'Page {page}/{pages} · Commands: {commands} · Plugins: {plugins}' },
19
+ childPlugins: { 'zh-CN': '子插件:', en: 'Plugins:' },
20
+ emptyMenu: { 'zh-CN': '(暂无指令和子插件)', en: '(No commands or plugins)' },
21
+ pluginHint: { 'zh-CN': '提示:使用「{command} <插件名>」查看子插件的指令', en: 'Use {command} <plugin> to view a plugin’s commands.' },
22
+ previousPage: { 'zh-CN': '上一页:{command}', en: 'Previous page: {command}' },
23
+ nextPage: { 'zh-CN': '下一页:{command}', en: 'Next page: {command}' },
24
+ guideTitle: { 'zh-CN': '{title} · 指令指南', en: '{title} · Command guide' },
25
+ guideSubtitle: { 'zh-CN': '每一个灵感,都有回应。', en: 'An answer to every idea.' },
26
+ commandCount: { 'zh-CN': '{count} 条指令', en: 'Commands: {count}' },
27
+ pluginCount: { 'zh-CN': '{count} 个子插件', en: 'Plugins: {count}' },
28
+ commandIndex: { 'zh-CN': '指令索引', en: 'Commands' },
29
+ pluginIndex: { 'zh-CN': '插件导航', en: 'Plugins' },
30
+ guideEmpty: { 'zh-CN': '暂无指令和子插件', en: 'No commands or plugins' },
31
+ helpHint: { 'zh-CN': '查看参数与用法:{command} --help', en: 'View arguments and usage: {command} --help' },
32
+ genericHelpHint: { 'zh-CN': '在当前环境的指令后追加 --help 查看用法', en: 'Append --help to a command in this environment to view its usage' },
33
+ expectedType: { 'zh-CN': '{field}:应为 {type}', en: '{field}: expected {type}' },
34
+ expectedArray: { 'zh-CN': '{field}:应为 {type} 数组', en: '{field}: expected an array of {type}' },
35
+ minimum: { 'zh-CN': '{field}:最小值为 {minimum}', en: '{field}: minimum {minimum}' },
36
+ maximum: { 'zh-CN': '{field}:最大值为 {maximum}', en: '{field}: maximum {maximum}' },
37
+ choices: { 'zh-CN': '{field}:可选值为 {choices}', en: '{field}: expected one of {choices}' },
38
+ invalidFormat: { 'zh-CN': '{field}:格式不正确', en: '{field}: invalid format' },
39
+ invalidValue: { 'zh-CN': '{field}:值不正确', en: '{field}: invalid value' },
40
+ unknownOption: { 'zh-CN': '未知选项 {field}', en: 'Unknown option {field}' },
41
+ duplicateOption: { 'zh-CN': '重复选项 --{field}', en: 'Duplicate option --{field}' },
42
+ defaultUndefined: { 'zh-CN': '{field}:默认值返回了 undefined', en: '{field}: default returned undefined' },
43
+ parameterUndefined: { 'zh-CN': '{field}:默认值或快捷指令返回了 undefined', en: '{field}: default or shortcut returned undefined' },
44
+ missingOption: { 'zh-CN': '缺少选项 --{field}', en: 'Missing option --{field}' },
45
+ unexpectedArguments: { 'zh-CN': '存在多余参数', en: 'Unexpected arguments' },
46
+ invalidParameter: { 'zh-CN': '缺少指令参数或参数不正确', en: 'Missing or invalid command parameter' },
47
+ defaultValue: { 'zh-CN': '默认值:{value}', en: 'default: {value}' },
48
+ dynamicValue: { 'zh-CN': '动态计算', en: 'dynamic' },
49
+ noAdapter: { 'zh-CN': '命令未声明 adapter 约束', en: 'The command does not declare an adapter restriction' },
50
+ missingEndpoint: { 'zh-CN': '未选择当前 Endpoint,无法判断命令的精确 adapter 约束', en: 'Select an Endpoint to evaluate the command’s exact adapter restriction' },
51
+ adapterComparison: { 'zh-CN': '命令 adapter 与当前 Endpoint 声明的原生适配器精确比较;未探测在线状态', en: 'Exact comparison with the Endpoint’s declared native adapter; online status was not checked' },
52
+ conditionMismatch: { 'zh-CN': '至少一个声明条件与假设输入不符;这不是实际准入检查', en: 'At least one declared condition does not match the hypothetical input; this is not a live admission check' },
53
+ missingPermissionHost: { 'zh-CN': 'PermissionHost 缺失或无法确认;不能推断命令可执行', en: 'PermissionHost is missing or unverified; command execution cannot be inferred' },
54
+ missingEvidence: { 'zh-CN': '存在缺少依据的条件,不能推断命令可执行', en: 'Some conditions lack evidence; command execution cannot be inferred' },
55
+ noPermit: { 'zh-CN': '命令未声明 permit 限制;这不表示已获得执行权限', en: 'The command does not declare permit restrictions; this does not establish authorization' },
56
+ uncheckedConditions: { 'zh-CN': '存在未执行的平台或自定义条件,不能推断命令可执行', en: 'Platform or custom conditions have not been evaluated; command execution cannot be inferred' },
57
+ conditionsMatch: { 'zh-CN': '已解释的 builtin 条件与假设输入匹配;不代表真实授权或命令可执行', en: 'The explained builtin conditions match the hypothetical input; this does not establish authorization or executability' },
58
+ unconstrained: { 'zh-CN': '已解释的 builtin 规则不检查具体 Endpoint、parent 或 thread;保留完整目标不等于这些维度受规则限制', en: 'The explained builtin rules do not constrain a specific Endpoint, parent or thread; retaining the full target does not add such restrictions' },
59
+ simulatedIdentity: { 'zh-CN': '身份与角色仅用于模拟,未向平台核实', en: 'Identity and roles are hypothetical and have not been verified with the platform' },
60
+ unevaluatedCode: { 'zh-CN': '未执行命令、动态默认值、平台或自定义 checker', en: 'Commands, dynamic defaults, platform checks and custom checkers have not been executed' },
61
+ defaultedRole: { 'zh-CN': 'canonical 输入未附角色时按命令运行时默认 user 解释;这不是已核验的平台角色', en: 'Canonical input without roles uses the command runtime’s default user role; this is not a verified platform role' },
62
+ });