@zhin.js/command 1.1.10 → 1.1.12

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,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,10 +2,12 @@
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,
9
+ readOperationSession,
10
+ type OperationSession,
9
11
  readOperationInputSignal,
10
12
  type AdapterClient,
11
13
  type RegisteredAdapterName,
@@ -92,6 +94,8 @@ export interface CommandParamSchema {
92
94
  readonly type: CommandParameterType;
93
95
  readonly default?: CommandDynamicValue;
94
96
  readonly description?: string;
97
+ /** Optional translated display text; machine keys and values remain unchanged. */
98
+ readonly descriptionI18n?: Readonly<Partial<Record<Locale, string>>>;
95
99
  readonly optional?: boolean;
96
100
  readonly rest?: boolean;
97
101
  readonly min?: number;
@@ -145,6 +149,7 @@ export interface CommandParameterDefinition {
145
149
  /** `[...name]` / `[[...name]]` 捕获所有段,运行时值为 `string[]`。 */
146
150
  readonly rest?: boolean;
147
151
  readonly description?: string;
152
+ readonly descriptionI18n?: Readonly<Partial<Record<Locale, string>>>;
148
153
  }
149
154
 
150
155
  /** 场景:群 / 私聊 / 频道等。 */
@@ -192,6 +197,8 @@ export interface CommandConversation {
192
197
  export interface CommandMessage {
193
198
  readonly conversation: CommandConversation;
194
199
  readonly content: string;
200
+ /** Display preference resolved by the IM runtime, when this is an IM operation. */
201
+ readonly locale?: Locale;
195
202
  /** 发送者(结构化视图见 CommandContext.sender)。 */
196
203
  readonly sender?: { readonly id: string; readonly name?: string; readonly roles?: readonly string[] };
197
204
  readonly id?: string;
@@ -264,6 +271,16 @@ export interface CommandContext<
264
271
  TInput extends CommandMessage = CommandMessage,
265
272
  TAdapter extends string | undefined = undefined,
266
273
  > extends CapabilityContext<TConfig>, CommandSession {
274
+ /** Language resolved once for the current operation. */
275
+ readonly locale: Locale;
276
+ /**
277
+ * Resolve a static local command path to its current chat invocation. Defaults to this
278
+ * plugin; an explicit owner may reference another indexed plugin.
279
+ * Includes the actual Endpoint prefix and configured commandNamespace. Arguments
280
+ * are appended explicitly by the caller. Returns undefined for unknown/disabled
281
+ * paths or calls without a known chat command environment.
282
+ */
283
+ formatCommand(localPath: string, owner?: PluginId): string | undefined;
267
284
  readonly args: readonly string[];
268
285
  readonly params: Readonly<Record<string, CommandParameterValue>>;
269
286
  readonly options: Readonly<Record<string, CommandParameterValue>>;
@@ -276,6 +293,8 @@ export interface CommandContext<
276
293
  readonly input?: TInput;
277
294
  /** Lazily resolved native Client. Without `adapter`, its static type is `unknown`. */
278
295
  readonly $client: AdapterClient<TAdapter>;
296
+ /** Bound by the actual IM operation; direct CommandIndex.execute calls may have no Session. */
297
+ readonly session: OperationSession<TAdapter> | undefined;
279
298
  /**
280
299
  * 对话式交互输入。IM 派发时自动注入;无消息来源时为 `undefined`。
281
300
  */
@@ -294,6 +313,7 @@ export interface CommandDefinition<
294
313
  /** @internal Convention-derived parameter metadata. */
295
314
  readonly $parameter?: CommandParameterDefinition;
296
315
  readonly description?: string;
316
+ readonly descriptionI18n?: Readonly<Partial<Record<Locale, string>>>;
297
317
  /** Restrict this command to one adapter and infer `context.$client`. */
298
318
  readonly adapter?: TAdapter;
299
319
  /**
@@ -307,6 +327,8 @@ export interface CommandDefinition<
307
327
  * 用户路由;alias 只替换文件路径提供的静态段。
308
328
  */
309
329
  readonly alias?: readonly string[];
330
+ /** Display an explicitly declared alias for this locale; every value must also occur in alias. */
331
+ readonly preferredAliasI18n?: Readonly<Partial<Record<Locale, string>>>;
310
332
  /**
311
333
  * 内置 permit DSL(AND)。单项内逗号为 OR。
312
334
  * 例:`adapter(icqq)`、`role(master)`、`group(123,456)`。
@@ -360,28 +382,40 @@ type TypedCommandContext<TConfig, TInput extends CommandMessage, TAdapter extend
360
382
  readonly options: InferCommandOptions<O>;
361
383
  };
362
384
 
385
+ /** The returned declaration retains its platform, parameter schemas and callback result. */
386
+ type DeclaredCommandAdapter<D> = D extends { readonly adapter: infer A extends string } ? A
387
+ : D extends { readonly adapter?: infer A extends string | undefined } ? A : undefined;
388
+ export type TypedCommandDefinition<TConfig, TResult, TInput extends CommandMessage, TAdapter extends string | undefined, P extends CommandParams, O extends CommandOptions> =
389
+ Omit<CommandDefinition<TConfig, TResult, TInput, TAdapter>, 'params' | 'options' | 'execute'> & {
390
+ readonly params?: P;
391
+ readonly options?: O;
392
+ execute(context: TypedCommandContext<TConfig, TInput, TAdapter, P, O>): TResult | Promise<TResult>;
393
+ };
394
+
363
395
  /**
364
396
  * `defineCommand.withConfig<Config>()` 返回的定义函数:config 类型已固定,params / options / 返回值
365
397
  * 仍从定义推断。
366
398
  * @public
367
399
  */
368
400
  export type CommandDefiner<TConfig, TInput extends CommandMessage = CommandMessage> = <
369
- TResult = unknown,
401
+ const TResult = unknown,
370
402
  const P extends CommandParams = {},
371
403
  const O extends CommandOptions = {},
404
+ const D extends CommandAuthoringDefinition<TConfig, TResult, TInput, P, O> = CommandAuthoringDefinition<TConfig, TResult, TInput, P, O>,
372
405
  >(
373
- definition: CommandAuthoringDefinition<TConfig, TResult, TInput, P, O>,
374
- ) => Readonly<CommandDefinition<TConfig, TResult, TInput, string | undefined>>;
406
+ definition: CommandAuthoringDefinition<TConfig, TResult, TInput, P, O> & D,
407
+ ) => Readonly<TypedCommandDefinition<TConfig, TResult, TInput, DeclaredCommandAdapter<D>, P, O> & Pick<D, keyof D & 'adapter'>>;
375
408
 
376
409
  export function defineCommand<
377
410
  TConfig = unknown,
378
- TResult = unknown,
411
+ const TResult = unknown,
379
412
  TInput extends CommandMessage = CommandMessage,
380
413
  const P extends CommandParams = {},
381
414
  const O extends CommandOptions = {},
415
+ const D extends CommandAuthoringDefinition<TConfig, TResult, TInput, P, O> = CommandAuthoringDefinition<TConfig, TResult, TInput, P, O>,
382
416
  >(
383
- definition: CommandAuthoringDefinition<TConfig, TResult, TInput, P, O>,
384
- ): Readonly<CommandDefinition<TConfig, TResult, TInput, string | undefined>> {
417
+ definition: CommandAuthoringDefinition<TConfig, TResult, TInput, P, O> & D,
418
+ ): Readonly<TypedCommandDefinition<TConfig, TResult, TInput, DeclaredCommandAdapter<D>, P, O> & Pick<D, keyof D & 'adapter'>> {
385
419
  if (typeof definition.execute !== 'function') {
386
420
  throw new TypeError('Command execute must be a function');
387
421
  }
@@ -398,15 +432,26 @@ export function defineCommand<
398
432
  }
399
433
  }
400
434
  validateArgumentSchemas(definition.params, definition.options);
435
+ validateDescriptionI18n(definition.descriptionI18n);
401
436
  validateCommandAlias(definition.alias);
437
+ validatePreferredAliases(definition);
402
438
  validateCommandPermit(definition.permit);
403
439
  validateCommandShortcutShape(definition.shortcut);
404
440
  const execution = normalizeExecutionPolicy(definition.execution);
405
441
  return Object.freeze({ $feature: commandBrand, ...definition, ...(execution ? { execution } : {}) }) as Readonly<
406
- CommandDefinition<TConfig, TResult, TInput, string | undefined>
442
+ TypedCommandDefinition<TConfig, TResult, TInput, DeclaredCommandAdapter<D>, P, O> & Pick<D, keyof D & 'adapter'>
407
443
  >;
408
444
  }
409
445
 
446
+ /** @internal Validate display-only translations without changing their fallback text. */
447
+ export function validateDescriptionI18n(value: CommandDefinition['descriptionI18n']): void {
448
+ if (value === undefined) return;
449
+ if (!value || typeof value !== 'object' || Array.isArray(value)
450
+ || Object.entries(value).some(([locale, text]) => !['zh-CN', 'en'].includes(locale) || typeof text !== 'string')) {
451
+ throw new TypeError('Command descriptionI18n must map zh-CN/en to strings');
452
+ }
453
+ }
454
+
410
455
  /**
411
456
  * 先固定插件 config 类型,再定义命令;params / options / 返回值仍从定义推断。
412
457
  *
@@ -435,6 +480,19 @@ function validateAdapterName(adapter: string | undefined): void {
435
480
  }
436
481
  }
437
482
 
483
+ function validatePreferredAliases(definition: Pick<CommandDefinition, 'alias' | 'preferredAliasI18n'>): void {
484
+ const preferred = definition.preferredAliasI18n;
485
+ if (preferred === undefined) return;
486
+ validateDescriptionI18n(preferred);
487
+ const normalize = (value: string) => value.trim().split(/\s+/u).filter(Boolean).join(' ');
488
+ const aliases = new Set(definition.alias?.map(normalize));
489
+ for (const [locale, alias] of Object.entries(preferred)) {
490
+ if (!aliases.has(normalize(alias))) {
491
+ throw new TypeError(`Command preferredAliasI18n.${locale} must reference a declared alias`);
492
+ }
493
+ }
494
+ }
495
+
438
496
  function validateCommandAlias(alias: readonly string[] | undefined): void {
439
497
  if (alias === undefined) return;
440
498
  if (!Array.isArray(alias)) {
@@ -506,6 +564,8 @@ export function parseCommandDefinition(value: unknown): CommandDefinition {
506
564
  }
507
565
  validateAdapterName(definition.adapter);
508
566
  normalizeExecutionPolicy(definition.execution);
567
+ validateCommandAlias(definition.alias);
568
+ validatePreferredAliases(definition);
509
569
  return definition as CommandDefinition;
510
570
  }
511
571
 
@@ -526,18 +586,31 @@ export function resolveDynamicParams(
526
586
  return Object.freeze(resolved);
527
587
  }
528
588
 
529
- /** @internal Command dispatcher assembly helper. */
530
- export function createCommandContext(
531
- snapshot: RuntimeSnapshot,
532
- ownerId: PluginId,
533
- args: readonly string[],
534
- params: Readonly<Record<string, CommandParameterValue>> = Object.freeze({}),
535
- input: unknown = undefined,
536
- segments: readonly Readonly<CommandSegment>[] = Object.freeze([]),
537
- interaction?: UserInteraction,
538
- adapter?: string,
539
- options: Readonly<Record<string, CommandParameterValue>> = Object.freeze({}),
540
- ): CommandContext {
589
+ /** Parsed invocation values, independent of the caller's transport and lifetime. */
590
+ export interface CommandInvocationArguments {
591
+ readonly args: readonly string[];
592
+ readonly params?: Readonly<Record<string, CommandParameterValue>>;
593
+ readonly options?: Readonly<Record<string, CommandParameterValue>>;
594
+ readonly segments?: readonly Readonly<CommandSegment>[];
595
+ }
596
+
597
+ /** Author and lifetime authority captured by the dispatcher for this invocation. */
598
+ export interface CommandInvocationOperation {
599
+ readonly snapshot: RuntimeSnapshot;
600
+ readonly ownerId: PluginId;
601
+ readonly input?: unknown;
602
+ readonly interaction?: UserInteraction;
603
+ readonly adapter?: string;
604
+ readonly presentation?: Readonly<{
605
+ locale: Locale;
606
+ formatCommand?: (localPath: string, owner?: PluginId) => string | undefined;
607
+ }>;
608
+ }
609
+
610
+ /** @internal Command dispatcher assembly helper; each value has one owner. */
611
+ export function createCommandContext(operation: CommandInvocationOperation, invocation: CommandInvocationArguments): CommandContext {
612
+ const { snapshot, ownerId, input, interaction, adapter, presentation } = operation;
613
+ const { args, params = {}, options = {}, segments = [] } = invocation;
541
614
  const context = createCapabilityContext(snapshot, ownerId, undefined, readOperationInputSignal(input));
542
615
  const session = resolveCommandSession(input);
543
616
  const parameterValues: Record<string, CommandParameterValue> = { __proto__: null, ...params };
@@ -545,6 +618,8 @@ export function createCommandContext(
545
618
  const result = {
546
619
  ...context,
547
620
  ...session,
621
+ locale: presentation?.locale ?? resolveLocale((snapshot.config.get(snapshot.root) as { locale?: unknown } | undefined)?.locale),
622
+ formatCommand: presentation?.formatCommand ?? (() => undefined),
548
623
  args: Object.freeze([...args]),
549
624
  params: Object.freeze(parameterValues),
550
625
  options: Object.freeze(optionValues),
@@ -552,6 +627,10 @@ export function createCommandContext(
552
627
  ...(input !== undefined ? { input: input as CommandMessage } : {}),
553
628
  ...(interaction !== undefined ? { interaction } : {}),
554
629
  } as CommandContext;
630
+ Object.defineProperty(result, 'session', {
631
+ enumerable: true,
632
+ get: () => readOperationSession(input, adapter),
633
+ });
555
634
  Object.defineProperty(result, '$client', {
556
635
  enumerable: true,
557
636
  get: () => readOperationClient(input, adapter),
@@ -565,6 +644,20 @@ export function createCommandContext(
565
644
  * @internal Command dispatcher projection helper.
566
645
  */
567
646
  export function resolveCommandSession(input: unknown): CommandSession {
647
+ const operation = readOperationSession(input);
648
+ if (operation) {
649
+ const endpoint = input && typeof input === 'object' ? (input as { readonly endpointId?: unknown }).endpointId : undefined;
650
+ const sender = operation.sender;
651
+ const roles = [...new Set([...sender?.authorizationRoles ?? [], ...sender?.platformRoles ?? []])];
652
+ return Object.freeze({
653
+ adapter: operation.scene.endpoint.adapter,
654
+ ...(typeof endpoint === 'string' && endpoint ? { endpoint } : {}),
655
+ scene: Object.freeze({ id: operation.scene.id, type: operation.scene.kind,
656
+ ...(operation.scene.name !== undefined ? { name: operation.scene.name } : {}) }),
657
+ ...(sender ? { sender: Object.freeze({ id: sender.id, ...(sender.name !== undefined ? { name: sender.name } : {}),
658
+ role: Object.freeze(roles.length ? roles : ['user']) }) } : {}),
659
+ });
660
+ }
568
661
  if (!isCommandMessageLike(input)) return Object.freeze({});
569
662
 
570
663
  const metadata = input.metadata && typeof input.metadata === 'object'
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
+ });
@@ -2,6 +2,8 @@ import { operationClientAdapter } from '@zhin.js/feature-kit';
2
2
  import { explainPermitRules, toPermissionSubject, type PermissionSubject, type PermissionConditionExplanation } from '@zhin.js/permission';
3
3
  import { resolveCommandSender, resolveCommandSession, type CommandMessage } from './definition.js';
4
4
  import type { CommandDescriptor } from './command-index.js';
5
+ import { resolveLocale, type Locale } from '@zhin.js/plugin-runtime';
6
+ import { commandMessages } from './messages.js';
5
7
 
6
8
  /** The exact subject projection used by the real command permit gate. No factories or checkers run. */
7
9
  export function toCommandPermissionSubject(source: unknown): PermissionSubject {
@@ -20,8 +22,9 @@ export type CommandPermissionHostStatus = 'present' | 'missing' | 'unverified';
20
22
  /** Read-only hypothetical conditions. A match is not permission to execute the command. */
21
23
  export function explainCommandPermissions(
22
24
  command: Pick<CommandDescriptor, 'adapter' | 'permit'>,
23
- options: Readonly<{ source?: unknown; rolesKnown?: boolean; permissionHost: CommandPermissionHostStatus }>,
25
+ options: Readonly<{ source?: unknown; rolesKnown?: boolean; permissionHost: CommandPermissionHostStatus; locale?: Locale }>,
24
26
  ) {
27
+ const locale = resolveLocale(options.locale);
25
28
  const projected = toCommandPermissionSubject(options.source);
26
29
  const inputSender = options.source && typeof options.source === 'object'
27
30
  ? (options.source as Pick<CommandMessage, 'sender'>).sender : undefined;
@@ -36,23 +39,23 @@ export function explainCommandPermissions(
36
39
  && subject.sender?.role.length === 1 && subject.sender.role[0] === 'user';
37
40
  const actualAdapter = operationClientAdapter(options.source);
38
41
  const adapter: PermissionConditionExplanation = !command.adapter
39
- ? { outcome: 'not_evaluated', reason: '命令未声明 adapter 约束' }
40
- : !actualAdapter ? { outcome: 'unknown', reason: '未选择当前 Endpoint,无法判断命令的精确 adapter 约束' }
41
- : { outcome: commandAdapterMatches(command, options.source) ? 'match' : 'mismatch', reason: '命令 adapter 与当前 Endpoint 声明的原生适配器精确比较;未探测在线状态' };
42
- const rules = explainPermitRules(command.permit ?? [], subject, { rolesKnown: options.rolesKnown });
42
+ ? { outcome: 'not_evaluated', reason: commandMessages(locale, 'noAdapter') }
43
+ : !actualAdapter ? { outcome: 'unknown', reason: commandMessages(locale, 'missingEndpoint') }
44
+ : { outcome: commandAdapterMatches(command, options.source) ? 'match' : 'mismatch', reason: commandMessages(locale, 'adapterComparison') };
45
+ const rules = explainPermitRules(command.permit ?? [], subject, { rolesKnown: options.rolesKnown, locale });
43
46
  const conditions = [...(command.adapter ? [adapter] : []), ...rules];
44
47
  let summary: PermissionConditionExplanation;
45
- if (conditions.some(item => item.outcome === 'mismatch')) summary = { outcome: 'mismatch', reason: '至少一个声明条件与假设输入不符;这不是实际准入检查' };
48
+ if (conditions.some(item => item.outcome === 'mismatch')) summary = { outcome: 'mismatch', reason: commandMessages(locale, 'conditionMismatch') };
46
49
  else if ((rules.length > 0 && options.permissionHost !== 'present') || conditions.some(item => item.outcome === 'unknown')) {
47
50
  summary = { outcome: 'unknown', reason: rules.length > 0 && options.permissionHost !== 'present'
48
- ? 'PermissionHost 缺失或无法确认;不能推断命令可执行' : '存在缺少依据的条件,不能推断命令可执行' };
49
- } else if (rules.length === 0) summary = { outcome: 'not_evaluated', reason: '命令未声明 permit 限制;这不表示已获得执行权限' };
50
- else if (conditions.some(item => item.outcome === 'not_evaluated')) summary = { outcome: 'not_evaluated', reason: '存在未执行的平台或自定义条件,不能推断命令可执行' };
51
- else summary = { outcome: 'match', reason: '已解释的 builtin 条件与假设输入匹配;不代表真实授权或命令可执行' };
51
+ ? commandMessages(locale, 'missingPermissionHost') : commandMessages(locale, 'missingEvidence') };
52
+ } else if (rules.length === 0) summary = { outcome: 'not_evaluated', reason: commandMessages(locale, 'noPermit') };
53
+ else if (conditions.some(item => item.outcome === 'not_evaluated')) summary = { outcome: 'not_evaluated', reason: commandMessages(locale, 'uncheckedConditions') };
54
+ else summary = { outcome: 'match', reason: commandMessages(locale, 'conditionsMatch') };
52
55
  return Object.freeze({ permissionHost: options.permissionHost, adapter: Object.freeze(adapter), rules, summary: Object.freeze(summary),
53
- unconstrained: Object.freeze(['已解释的 builtin 规则不检查具体 Endpoint、parent 或 thread;保留完整目标不等于这些维度受规则限制']),
54
- assumptions: Object.freeze(['身份与角色仅用于模拟,未向平台核实', '未执行命令、动态默认值、平台或自定义 checker',
56
+ unconstrained: Object.freeze([commandMessages(locale, 'unconstrained')]),
57
+ assumptions: Object.freeze([commandMessages(locale, 'simulatedIdentity'), commandMessages(locale, 'unevaluatedCode'),
55
58
  ...(defaultedRoles
56
- ? ['canonical 输入未附角色时按命令运行时默认 user 解释;这不是已核验的平台角色'] : [])]),
59
+ ? [commandMessages(locale, 'defaultedRole')] : [])]),
57
60
  });
58
61
  }
package/src/provider.ts CHANGED
@@ -311,7 +311,7 @@ const commandFeature = defineFeatureProvider({
311
311
  const rawKeyword = rootConfig?.menuKeyword;
312
312
  const menu = rawKeyword === false || rawKeyword === ''
313
313
  ? undefined
314
- : { keyword: typeof rawKeyword === 'string' ? rawKeyword : '菜单' };
314
+ : typeof rawKeyword === 'string' ? { keyword: rawKeyword } : {};
315
315
  return { value: new CommandIndex(slots, context.snapshot, menu) };
316
316
  },
317
317
  },