@zhin.js/command 1.0.16 → 1.1.1

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.
package/README.md CHANGED
@@ -1,23 +1,23 @@
1
1
  # @zhin.js/command
2
2
 
3
- Zhin Plugin Runtime 的约定式 Command Feature。它发现 `commands/**/*.ts(x)`,将插件树
4
- 路径与文件路径投影为命令,并用 `segment-matcher` 同时匹配纯文本和 canonical IM segments。
3
+ Zhin Plugin Runtime 的约定式 Command Feature。它发现 `commands/**/*/index.ts(x)`,只把文件
4
+ 相对路径投影为用户路由,并用 `segment-matcher` 同时匹配纯文本和 canonical IM segments。
5
5
 
6
- 静态命令文件名可为 ASCII kebab(`hello.ts`)或 Unicode 名(`赞我.ts`);动态参数文件
7
- (`[name].ts` 等)仍限 ASCII。详见 [命令创作指南](../../../docs/authoring/commands.md)。
6
+ 静态命令目录可为 ASCII kebab(`hello/`)或 Unicode 名(`赞我/`);动态参数目录
7
+ (`[name]/` 等)仍限 ASCII。每个路由目录只识别 `index.ts(x)`,同目录其他文件是 helper。详见[命令创作指南](../../../docs/authoring/commands.md)。
8
8
 
9
9
  ## Authoring
10
10
 
11
11
  ```ts
12
- // commands/gh/issue/list.ts -> gh issue list
13
- // commands/赞我.ts -> 赞我
12
+ // commands/gh/issue/list/index.ts -> gh issue list
13
+ // commands/赞我/index.ts -> 赞我
14
14
  import { defineCommand } from 'zhin.js/command';
15
15
 
16
16
  export default defineCommand({
17
17
  description: 'List GitHub issues',
18
- alias: ['issues'], // 可多词;子插件仍保留 owner 前缀
18
+ alias: ['issues'], // 可多词;owner 不参与用户路由
19
19
  permit: ['adapter(icqq)', 'role(master)'], // 数组 AND;未过则静默未命中
20
- // shortcut: { '列 issue': {} }, // 全局整句,可打破命名空间
20
+ // shortcut: { '列 issue': {} }, // 全局整句
21
21
  execute: ({ args }) => `issues:${args.join(',')}`,
22
22
  });
23
23
  ```
@@ -25,9 +25,9 @@ export default defineCommand({
25
25
  最后一个文件名可以用 Next.js 风格方括号声明参数形态,类型与默认值在 `defineCommand({ params })` 中声明(`type` 必填,`default` 可选且有默认值时文件名必须用双方括号):
26
26
 
27
27
  ```text
28
- commands/gh/pr/[[title]].ts -> gh pr [title] (params: { title: { type: 'string', default: 'defaultTitle' } })
29
- commands/upload/[asset].ts -> upload <asset> (params: { asset: { type: 'image' } })
30
- commands/search/[...kw].ts -> search <...kw> (params: { kw: { type: 'text' } },运行时 params.kw 为数组;元素粒度随类型:text 逐消息段,word/string 逐词,number/boolean 逐词转换)
28
+ commands/gh/pr/[[title]]/index.ts -> gh pr [title] (params: { title: { type: 'string', default: 'defaultTitle' } })
29
+ commands/upload/[asset]/index.ts -> upload <asset> (params: { asset: { type: 'image' } })
30
+ commands/search/[...kw]/index.ts -> search <...kw> (params: { kw: { type: 'text' } },运行时 params.kw 为数组;元素粒度随类型:text 逐消息段,word/string 逐词,number/boolean 逐词转换)
31
31
  ```
32
32
 
33
33
  文本类型包括 `string`、`word`、`text`、`number`、`integer`、`float`、`boolean`;结构化
@@ -49,9 +49,9 @@ commands/search/[...kw].ts -> search <...kw> (params: { kw: { type: 'text
49
49
 
50
50
  可选声明字段:
51
51
 
52
- - `alias`:替换全部本地静态段并重挂 owner 前缀(不打破子插件命名空间)。
52
+ - `alias`:替换全部本地静态段;owner 不参与用户路由。
53
53
  - `permit`:内置 DSL(`adapter|group|private|channel|user|role`);失败为静默未命中。
54
- - `shortcut`:全局整句精确匹配 → 预填 `params`(可打破命名空间)。
54
+ - `shortcut`:全局整句精确匹配 → 预填 `params`。
55
55
 
56
56
  单文件插件可在 `setup({ addCommand })` 中调用
57
57
  `addCommand('hello', defineCommand(...))`。它与目录发现共用 CommandIndex;拆成文件后
@@ -35,7 +35,8 @@ export class CommandIndex {
35
35
  occupancy.set(key, source);
36
36
  };
37
37
  for (const slot of slots) {
38
- const primarySegments = runtimeSegments(slot.owner, slot.localName);
38
+ const namespace = commandNamespace(this.snapshot, slot.owner);
39
+ const primarySegments = [...namespace, ...runtimeSegments(slot.localName)];
39
40
  const parameter = slot.definition.$parameter;
40
41
  assertParameterSegment(primarySegments, parameter, slot.source);
41
42
  const name = displayName(primarySegments, parameter);
@@ -71,7 +72,7 @@ export class CommandIndex {
71
72
  });
72
73
  if (alias) {
73
74
  for (const entry of alias) {
74
- const aliasSegments = aliasRuntimeSegments(slot.owner, entry, primarySegments);
75
+ const aliasSegments = [...namespace, ...aliasRuntimeSegments(entry, primarySegments.slice(namespace.length))];
75
76
  assertParameterSegment(aliasSegments, parameter, `${slot.source} alias ${JSON.stringify(entry)}`);
76
77
  claim(occupancyKey(aliasSegments, parameter), `${slot.source} alias ${JSON.stringify(entry)}`);
77
78
  routes.push({
@@ -84,7 +85,7 @@ export class CommandIndex {
84
85
  }
85
86
  if (slot.definition.shortcut) {
86
87
  for (const [rawTrigger, prefill] of Object.entries(slot.definition.shortcut)) {
87
- const trigger = rawTrigger.trim();
88
+ const trigger = [...namespace, ...splitCommand(rawTrigger)].join(' ');
88
89
  claim(trigger, `${slot.source} shortcut ${JSON.stringify(trigger)}`);
89
90
  shortcuts.set(trigger, {
90
91
  record,
@@ -318,28 +319,36 @@ export function isCommandIndex(value) {
318
319
  && value.$projection === 'zhin.command-index/1';
319
320
  }
320
321
  /**
321
- * 命令运行时名 = 插件树路径段(instanceKey,去掉 root)以 `.` 连接后,再与命令
322
- * 文件路径首段以 `.` 连接;命令内部嵌套段仍为空格分隔。Root 插件无前缀。
323
- * 例:`root/qq` + `endpoint/list` → `qq.endpoint list`;
324
- * `root/b/a` + `foo` → `b.a.foo`;root + `foo` → `foo`。
322
+ * 用户路由来自命令的本地能力路径;只有插件配置显式声明 commandNamespace
323
+ * 时才在其前面增加命名空间。owner 始终只属于 CapabilityId。
325
324
  */
326
- function runtimeSegments(owner, localName) {
327
- const localSegments = localName.split('/');
328
- if (owner === 'root')
329
- return localSegments;
330
- const prefix = owner.slice('root/'.length).split('/').join('.');
331
- return [`${prefix}.${localSegments[0]}`, ...localSegments.slice(1)];
325
+ function runtimeSegments(localName) {
326
+ return localName.split('/');
327
+ }
328
+ function commandNamespace(snapshot, owner) {
329
+ const config = snapshot.config.get(owner);
330
+ const value = config?.commandNamespace;
331
+ if (value === undefined || value === '')
332
+ return Object.freeze([]);
333
+ if (typeof value !== 'string') {
334
+ throw new TypeError(`Invalid commandNamespace for ${owner}: expected a string`);
335
+ }
336
+ const segments = splitCommand(value);
337
+ if (segments.length === 0 || segments.some((segment) => !isCommandNamespaceSegment(segment))) {
338
+ throw new TypeError(`Invalid commandNamespace for ${owner}: expected space-separated command segments`);
339
+ }
340
+ return Object.freeze([...segments]);
341
+ }
342
+ function isCommandNamespaceSegment(value) {
343
+ return /^[\p{L}\p{N}][\p{L}\p{N}_-]*$/u.test(value);
332
344
  }
333
345
  /**
334
- * 用 alias 词序列替换全部本地静态段,再按 owner 规则重挂前缀;动态段保留。
346
+ * 用 alias 词序列替换全部本地静态段;动态段保留。
335
347
  */
336
- function aliasRuntimeSegments(owner, alias, primarySegments) {
348
+ function aliasRuntimeSegments(alias, primarySegments) {
337
349
  const aliasTokens = alias.trim().split(/\s+/u).filter(Boolean);
338
350
  const dynamicTail = primarySegments.filter((segment) => segment.startsWith('$'));
339
- if (owner === 'root')
340
- return [...aliasTokens, ...dynamicTail];
341
- const prefix = owner.slice('root/'.length).split('/').join('.');
342
- return [`${prefix}.${aliasTokens[0]}`, ...aliasTokens.slice(1), ...dynamicTail];
351
+ return [...aliasTokens, ...dynamicTail];
343
352
  }
344
353
  function occupancyKey(segments, parameter) {
345
354
  return parameter ? routeShape(segments) : segments.join(' ');
@@ -416,11 +425,10 @@ function assertParameterSegment(segments, parameter, source) {
416
425
  return;
417
426
  const dynamic = dynamicSegments[0] ?? (parameter ? `$${parameter.name}` : '$?');
418
427
  throw new Error(`Invalid Command path for ${source}: the dynamic segment "${dynamic}" must be the only dynamic `
419
- + `segment and come after a static segment (child plugin commands are prefixed by the plugin `
420
- + `path, so a dynamic first segment is never reachable). `
428
+ + `segment and be the final path segment. `
421
429
  + (parameter
422
- ? `Hint: move the file under a static directory, e.g. "commands/add/[${parameter.name}:${parameter.type}].ts".`
423
- : 'Hint: put the file under a static directory, e.g. "commands/add/<file>.ts".'));
430
+ ? `Hint: keep only one dynamic entry, e.g. "commands/add/[${parameter.name}]/index.ts".`
431
+ : 'Hint: keep only one dynamic entry at the end of the command path.'));
424
432
  }
425
433
  function isRequiredParameter(parameter) {
426
434
  return parameter.optional === true ? false : parameter.defaultValue === undefined;
@@ -198,8 +198,8 @@ export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput
198
198
  */
199
199
  readonly params?: Readonly<Record<string, CommandParamSchema>>;
200
200
  /**
201
- * 本地静态段别名(可多词,如 `'gh issue'`)。替换全部本地静态段后仍挂
202
- * owner 前缀;不打破子插件命名空间。
201
+ * 本地静态段别名(可多词,如 `'gh issue'`)。Capability owner 不参与
202
+ * 用户路由;alias 只替换文件路径提供的静态段。
203
203
  */
204
204
  readonly alias?: readonly string[];
205
205
  /**
@@ -209,7 +209,7 @@ export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput
209
209
  readonly permit?: readonly string[];
210
210
  /**
211
211
  * 全局整句快捷方式:触发串(trim 后全文相等)→ 预填 params。
212
- * 可打破 owner 命名空间。
212
+ * 与普通路由一样是全局用户输入,冲突会在 generation 构建时拒绝。
213
213
  */
214
214
  readonly shortcut?: Readonly<Record<string, Readonly<Record<string, CommandDynamicValue>>>>;
215
215
  execute(context: CommandContext<TConfig, TInput, TAdapter>): TResult | Promise<TResult>;
package/lib/provider.js CHANGED
@@ -1,4 +1,4 @@
1
- import { basename, join, parse, sep } from 'node:path';
1
+ import { basename, dirname, join, sep } from 'node:path';
2
2
  import { featureId, isCapabilityLocalSegment } from '@zhin.js/plugin-runtime';
3
3
  import { defineFeatureProvider, } from '@zhin.js/feature-kit';
4
4
  import { CommandIndex } from './command-index.js';
@@ -8,52 +8,47 @@ const commandFiles = {
8
8
  id: 'commands-ts',
9
9
  async *discover(context) {
10
10
  const directory = join(context.packageRoot, 'commands');
11
- yield* discoverCommandDirectory(context, directory, []);
11
+ const entries = [...await context.host.list(directory)]
12
+ .filter((entry) => entry.kind === 'directory')
13
+ .sort((left, right) => left.name.localeCompare(right.name));
14
+ for (const entry of entries) {
15
+ const segment = parseCommandDirectory(entry.name);
16
+ if (!segment)
17
+ continue;
18
+ yield* discoverCommandDirectory(context, join(directory, entry.name), [segment]);
19
+ }
12
20
  },
13
21
  async load(source, context) {
14
22
  const module = await context.host.loadModule(source.source);
15
23
  const definition = parseCommandDefinition(module.default);
16
- const file = parseCommandFile(basename(source.source));
24
+ const file = parseCommandDirectory(basename(dirname(source.source)));
17
25
  return bindCommandParameter(definition, resolveParameter(definition, file, source.source));
18
26
  },
19
27
  };
20
28
  async function* discoverCommandDirectory(context, directory, ancestors) {
21
29
  const entries = [...await context.host.list(directory)]
22
30
  .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
23
- const files = entries.flatMap((entry) => {
24
- if (entry.kind !== 'file')
25
- return [];
26
- const parsed = parseCommandFile(entry.name);
27
- return parsed ? [{ entry, parsed }] : [];
28
- });
29
31
  const preferJavaScript = context.packageRoot
30
32
  .split(sep)
31
33
  .includes('node_modules');
32
- const preferredFiles = new Map();
33
- for (const { entry, parsed } of files) {
34
- const current = preferredFiles.get(parsed.localSegment);
35
- if (!current || commandFilePriority(entry.name, preferJavaScript)
36
- < commandFilePriority(current, preferJavaScript)) {
37
- preferredFiles.set(parsed.localSegment, entry.name);
38
- }
34
+ const index = preferredCommandIndex(entries, preferJavaScript);
35
+ if (index) {
36
+ yield {
37
+ localName: ancestors.map((segment) => segment.localSegment).join('/'),
38
+ source: join(directory, index),
39
+ relatedSources: Object.freeze(entries
40
+ .filter((entry) => entry.kind === 'file' && entry.name !== index)
41
+ .map((entry) => join(directory, entry.name))),
42
+ target: 'server',
43
+ };
39
44
  }
40
45
  for (const entry of entries) {
41
- if (entry.kind === 'directory' && isCapabilityLocalSegment(entry.name)) {
42
- yield* discoverCommandDirectory(context, join(directory, entry.name), [...ancestors, entry.name]);
43
- continue;
44
- }
45
- if (entry.kind !== 'file')
46
- continue;
47
- const file = parseCommandFile(entry.name);
48
- if (!file)
46
+ if (entry.kind !== 'directory')
49
47
  continue;
50
- if (preferredFiles.get(file.localSegment) !== entry.name)
48
+ const segment = parseCommandDirectory(entry.name);
49
+ if (!segment)
51
50
  continue;
52
- yield {
53
- localName: [...ancestors, file.localSegment].join('/'),
54
- source: join(directory, entry.name),
55
- target: 'server',
56
- };
51
+ yield* discoverCommandDirectory(context, join(directory, entry.name), [...ancestors, segment]);
57
52
  }
58
53
  }
59
54
  const dynamicCommandFilePatterns = [
@@ -62,17 +57,9 @@ const dynamicCommandFilePatterns = [
62
57
  { pattern: /^\[\[([a-zA-Z][a-zA-Z0-9]*)\]\]\.(?:tsx?|[cm]?js)$/, optional: true, rest: false },
63
58
  { pattern: /^\[([a-zA-Z][a-zA-Z0-9]*)\]\.(?:tsx?|[cm]?js)$/, optional: false, rest: false },
64
59
  ];
65
- const commandModuleExtension = /\.(?:tsx?|[cm]?js)$/u;
66
- function parseCommandFile(value) {
67
- // 静态段:ASCII kebab(hello.ts)或 Unicode 名(赞我.ts);与 isCapabilityLocalSegment 对齐。
68
- if (commandModuleExtension.test(value)) {
69
- const localSegment = parse(value).name;
70
- if (isCapabilityLocalSegment(localSegment)) {
71
- return { localSegment };
72
- }
73
- }
60
+ function parseCommandDirectory(value) {
74
61
  for (const { pattern, optional, rest } of dynamicCommandFilePatterns) {
75
- const match = pattern.exec(value);
62
+ const match = pattern.exec(`${value}.ts`);
76
63
  if (!match || !match[1])
77
64
  continue;
78
65
  const name = match[1];
@@ -82,6 +69,9 @@ function parseCommandFile(value) {
82
69
  parameter: { name, optional, rest },
83
70
  };
84
71
  }
72
+ if (isCapabilityLocalSegment(value)) {
73
+ return { localSegment: value };
74
+ }
85
75
  if (value.startsWith('[') || value.includes(']')) {
86
76
  throw new CommandPathSyntaxError(value);
87
77
  }
@@ -97,7 +87,7 @@ function resolveParameter(definition, file, source) {
97
87
  throw new CommandPathSyntaxError(source, `missing params.${hint.name} declaration in defineCommand({ params })`);
98
88
  }
99
89
  if (!hint.optional && schema.default !== undefined) {
100
- throw new CommandPathSyntaxError(source, `params.${hint.name} has a default but the file is required: rename to [[${hint.name}]]`);
90
+ throw new CommandPathSyntaxError(source, `params.${hint.name} has a default but the directory is required: rename it to [[${hint.name}]]`);
101
91
  }
102
92
  return {
103
93
  name: hint.name,
@@ -116,8 +106,14 @@ function commandFilePriority(value, preferJavaScript) {
116
106
  const priority = order.indexOf(extension);
117
107
  return priority < 0 ? Number.MAX_SAFE_INTEGER : priority;
118
108
  }
109
+ function preferredCommandIndex(entries, preferJavaScript) {
110
+ return entries
111
+ .filter((entry) => entry.kind === 'file' && /^index\.(?:tsx?|[cm]?js)$/u.test(entry.name))
112
+ .sort((left, right) => commandFilePriority(left.name, preferJavaScript)
113
+ - commandFilePriority(right.name, preferJavaScript))[0]?.name;
114
+ }
119
115
  export class CommandPathSyntaxError extends TypeError {
120
- constructor(file, detail = 'expected [name].ts(x), [[name]].ts(x), [...name].ts(x) or [[...name]].ts(x)') {
116
+ constructor(file, detail = 'expected commands/foo/index.ts or commands/foo/[name]/index.ts') {
121
117
  super(`Invalid Command path ${file}: ${detail}`);
122
118
  this.name = 'CommandPathSyntaxError';
123
119
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/command",
3
- "version": "1.0.16",
3
+ "version": "1.1.1",
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.0.13",
22
- "@zhin.js/interaction": "1.0.1",
23
- "@zhin.js/permission": "1.0.4",
24
- "@zhin.js/plugin-runtime": "1.1.8"
21
+ "@zhin.js/feature-kit": "1.1.1",
22
+ "@zhin.js/interaction": "1.1.0",
23
+ "@zhin.js/permission": "1.1.1",
24
+ "@zhin.js/plugin-runtime": "1.1.10"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^26.1.2",
@@ -119,7 +119,8 @@ export class CommandIndex {
119
119
  };
120
120
 
121
121
  for (const slot of slots) {
122
- const primarySegments = runtimeSegments(slot.owner, slot.localName);
122
+ const namespace = commandNamespace(this.snapshot, slot.owner);
123
+ const primarySegments = [...namespace, ...runtimeSegments(slot.localName)];
123
124
  const parameter = slot.definition.$parameter;
124
125
  assertParameterSegment(primarySegments, parameter, slot.source);
125
126
  const name = displayName(primarySegments, parameter);
@@ -158,7 +159,10 @@ export class CommandIndex {
158
159
 
159
160
  if (alias) {
160
161
  for (const entry of alias) {
161
- const aliasSegments = aliasRuntimeSegments(slot.owner, entry, primarySegments);
162
+ const aliasSegments = [...namespace, ...aliasRuntimeSegments(
163
+ entry,
164
+ primarySegments.slice(namespace.length),
165
+ )];
162
166
  assertParameterSegment(aliasSegments, parameter, `${slot.source} alias ${JSON.stringify(entry)}`);
163
167
  claim(occupancyKey(aliasSegments, parameter), `${slot.source} alias ${JSON.stringify(entry)}`);
164
168
  routes.push({
@@ -172,7 +176,7 @@ export class CommandIndex {
172
176
 
173
177
  if (slot.definition.shortcut) {
174
178
  for (const [rawTrigger, prefill] of Object.entries(slot.definition.shortcut)) {
175
- const trigger = rawTrigger.trim();
179
+ const trigger = [...namespace, ...splitCommand(rawTrigger)].join(' ');
176
180
  claim(trigger, `${slot.source} shortcut ${JSON.stringify(trigger)}`);
177
181
  shortcuts.set(trigger, {
178
182
  record,
@@ -456,31 +460,43 @@ export function isCommandIndex(value: unknown): value is CommandIndex {
456
460
  }
457
461
 
458
462
  /**
459
- * 命令运行时名 = 插件树路径段(instanceKey,去掉 root)以 `.` 连接后,再与命令
460
- * 文件路径首段以 `.` 连接;命令内部嵌套段仍为空格分隔。Root 插件无前缀。
461
- * 例:`root/qq` + `endpoint/list` → `qq.endpoint list`;
462
- * `root/b/a` + `foo` → `b.a.foo`;root + `foo` → `foo`。
463
+ * 用户路由来自命令的本地能力路径;只有插件配置显式声明 commandNamespace
464
+ * 时才在其前面增加命名空间。owner 始终只属于 CapabilityId。
463
465
  */
464
- function runtimeSegments(owner: string, localName: string): string[] {
465
- const localSegments = localName.split('/');
466
- if (owner === 'root') return localSegments;
467
- const prefix = owner.slice('root/'.length).split('/').join('.');
468
- return [`${prefix}.${localSegments[0]}`, ...localSegments.slice(1)];
466
+ function runtimeSegments(localName: string): string[] {
467
+ return localName.split('/');
468
+ }
469
+
470
+ function commandNamespace(snapshot: RuntimeSnapshot, owner: PluginId): readonly string[] {
471
+ const config = snapshot.config.get(owner) as Readonly<Record<string, unknown>> | undefined;
472
+ const value = config?.commandNamespace;
473
+ if (value === undefined || value === '') return Object.freeze([]);
474
+ if (typeof value !== 'string') {
475
+ throw new TypeError(`Invalid commandNamespace for ${owner}: expected a string`);
476
+ }
477
+ const segments = splitCommand(value);
478
+ if (segments.length === 0 || segments.some((segment) => !isCommandNamespaceSegment(segment))) {
479
+ throw new TypeError(
480
+ `Invalid commandNamespace for ${owner}: expected space-separated command segments`,
481
+ );
482
+ }
483
+ return Object.freeze([...segments]);
484
+ }
485
+
486
+ function isCommandNamespaceSegment(value: string): boolean {
487
+ return /^[\p{L}\p{N}][\p{L}\p{N}_-]*$/u.test(value);
469
488
  }
470
489
 
471
490
  /**
472
- * 用 alias 词序列替换全部本地静态段,再按 owner 规则重挂前缀;动态段保留。
491
+ * 用 alias 词序列替换全部本地静态段;动态段保留。
473
492
  */
474
493
  function aliasRuntimeSegments(
475
- owner: string,
476
494
  alias: string,
477
495
  primarySegments: readonly string[],
478
496
  ): string[] {
479
497
  const aliasTokens = alias.trim().split(/\s+/u).filter(Boolean);
480
498
  const dynamicTail = primarySegments.filter((segment) => segment.startsWith('$'));
481
- if (owner === 'root') return [...aliasTokens, ...dynamicTail];
482
- const prefix = owner.slice('root/'.length).split('/').join('.');
483
- return [`${prefix}.${aliasTokens[0]}`, ...aliasTokens.slice(1), ...dynamicTail];
499
+ return [...aliasTokens, ...dynamicTail];
484
500
  }
485
501
 
486
502
  function occupancyKey(
@@ -578,11 +594,10 @@ function assertParameterSegment(
578
594
  const dynamic = dynamicSegments[0] ?? (parameter ? `$${parameter.name}` : '$?');
579
595
  throw new Error(
580
596
  `Invalid Command path for ${source}: the dynamic segment "${dynamic}" must be the only dynamic `
581
- + `segment and come after a static segment (child plugin commands are prefixed by the plugin `
582
- + `path, so a dynamic first segment is never reachable). `
597
+ + `segment and be the final path segment. `
583
598
  + (parameter
584
- ? `Hint: move the file under a static directory, e.g. "commands/add/[${parameter.name}:${parameter.type}].ts".`
585
- : 'Hint: put the file under a static directory, e.g. "commands/add/<file>.ts".'),
599
+ ? `Hint: keep only one dynamic entry, e.g. "commands/add/[${parameter.name}]/index.ts".`
600
+ : 'Hint: keep only one dynamic entry at the end of the command path.'),
586
601
  );
587
602
  }
588
603
 
package/src/definition.ts CHANGED
@@ -261,8 +261,8 @@ export interface CommandDefinition<
261
261
  */
262
262
  readonly params?: Readonly<Record<string, CommandParamSchema>>;
263
263
  /**
264
- * 本地静态段别名(可多词,如 `'gh issue'`)。替换全部本地静态段后仍挂
265
- * owner 前缀;不打破子插件命名空间。
264
+ * 本地静态段别名(可多词,如 `'gh issue'`)。Capability owner 不参与
265
+ * 用户路由;alias 只替换文件路径提供的静态段。
266
266
  */
267
267
  readonly alias?: readonly string[];
268
268
  /**
@@ -272,7 +272,7 @@ export interface CommandDefinition<
272
272
  readonly permit?: readonly string[];
273
273
  /**
274
274
  * 全局整句快捷方式:触发串(trim 后全文相等)→ 预填 params。
275
- * 可打破 owner 命名空间。
275
+ * 与普通路由一样是全局用户输入,冲突会在 generation 构建时拒绝。
276
276
  */
277
277
  readonly shortcut?: Readonly<Record<string, Readonly<Record<string, CommandDynamicValue>>>>;
278
278
  execute(context: CommandContext<TConfig, TInput, TAdapter>): TResult | Promise<TResult>;
package/src/provider.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { basename, join, parse, sep } from 'node:path';
1
+ import { basename, dirname, join, sep } from 'node:path';
2
2
  import { featureId, isCapabilityLocalSegment } from '@zhin.js/plugin-runtime';
3
3
  import {
4
4
  defineFeatureProvider,
@@ -20,12 +20,19 @@ const commandFiles: SourceConvention = {
20
20
  id: 'commands-ts',
21
21
  async *discover(context) {
22
22
  const directory = join(context.packageRoot, 'commands');
23
- yield* discoverCommandDirectory(context, directory, []);
23
+ const entries = [...await context.host.list(directory)]
24
+ .filter((entry) => entry.kind === 'directory')
25
+ .sort((left, right) => left.name.localeCompare(right.name));
26
+ for (const entry of entries) {
27
+ const segment = parseCommandDirectory(entry.name);
28
+ if (!segment) continue;
29
+ yield* discoverCommandDirectory(context, join(directory, entry.name), [segment]);
30
+ }
24
31
  },
25
32
  async load(source, context) {
26
33
  const module = await context.host.loadModule<{ default?: unknown }>(source.source);
27
34
  const definition = parseCommandDefinition(module.default);
28
- const file = parseCommandFile(basename(source.source));
35
+ const file = parseCommandDirectory(basename(dirname(source.source)));
29
36
  return bindCommandParameter(definition, resolveParameter(definition, file, source.source));
30
37
  },
31
38
  };
@@ -33,45 +40,30 @@ const commandFiles: SourceConvention = {
33
40
  async function* discoverCommandDirectory(
34
41
  context: DiscoveryContext,
35
42
  directory: string,
36
- ancestors: readonly string[],
43
+ ancestors: readonly ParsedCommandFile[],
37
44
  ): AsyncIterable<DiscoveredSource> {
38
45
  const entries = [...await context.host.list(directory)]
39
46
  .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
40
- const files = entries.flatMap((entry) => {
41
- if (entry.kind !== 'file') return [];
42
- const parsed = parseCommandFile(entry.name);
43
- return parsed ? [{ entry, parsed }] : [];
44
- });
45
47
  const preferJavaScript = context.packageRoot
46
48
  .split(sep)
47
49
  .includes('node_modules');
48
- const preferredFiles = new Map<string, string>();
49
- for (const { entry, parsed } of files) {
50
- const current = preferredFiles.get(parsed.localSegment);
51
- if (!current || commandFilePriority(entry.name, preferJavaScript)
52
- < commandFilePriority(current, preferJavaScript)) {
53
- preferredFiles.set(parsed.localSegment, entry.name);
54
- }
55
- }
56
- for (const entry of entries) {
57
- if (entry.kind === 'directory' && isCapabilityLocalSegment(entry.name)) {
58
- yield* discoverCommandDirectory(
59
- context,
60
- join(directory, entry.name),
61
- [...ancestors, entry.name],
62
- );
63
- continue;
64
- }
65
- if (entry.kind !== 'file') continue;
66
- const file = parseCommandFile(entry.name);
67
- if (!file) continue;
68
- if (preferredFiles.get(file.localSegment) !== entry.name) continue;
50
+ const index = preferredCommandIndex(entries, preferJavaScript);
51
+ if (index) {
69
52
  yield {
70
- localName: [...ancestors, file.localSegment].join('/'),
71
- source: join(directory, entry.name),
53
+ localName: ancestors.map((segment) => segment.localSegment).join('/'),
54
+ source: join(directory, index),
55
+ relatedSources: Object.freeze(entries
56
+ .filter((entry) => entry.kind === 'file' && entry.name !== index)
57
+ .map((entry) => join(directory, entry.name))),
72
58
  target: 'server',
73
59
  };
74
60
  }
61
+ for (const entry of entries) {
62
+ if (entry.kind !== 'directory') continue;
63
+ const segment = parseCommandDirectory(entry.name);
64
+ if (!segment) continue;
65
+ yield* discoverCommandDirectory(context, join(directory, entry.name), [...ancestors, segment]);
66
+ }
75
67
  }
76
68
 
77
69
  interface ParsedCommandFile {
@@ -97,18 +89,9 @@ const dynamicCommandFilePatterns: ReadonlyArray<{
97
89
  { pattern: /^\[([a-zA-Z][a-zA-Z0-9]*)\]\.(?:tsx?|[cm]?js)$/, optional: false, rest: false },
98
90
  ];
99
91
 
100
- const commandModuleExtension = /\.(?:tsx?|[cm]?js)$/u;
101
-
102
- function parseCommandFile(value: string): ParsedCommandFile | undefined {
103
- // 静态段:ASCII kebab(hello.ts)或 Unicode 名(赞我.ts);与 isCapabilityLocalSegment 对齐。
104
- if (commandModuleExtension.test(value)) {
105
- const localSegment = parse(value).name;
106
- if (isCapabilityLocalSegment(localSegment)) {
107
- return { localSegment };
108
- }
109
- }
92
+ function parseCommandDirectory(value: string): ParsedCommandFile | undefined {
110
93
  for (const { pattern, optional, rest } of dynamicCommandFilePatterns) {
111
- const match = pattern.exec(value);
94
+ const match = pattern.exec(`${value}.ts`);
112
95
  if (!match || !match[1]) continue;
113
96
  const name = match[1];
114
97
  // Metadata can change during HMR while $name keeps the Capability identity stable.
@@ -117,6 +100,9 @@ function parseCommandFile(value: string): ParsedCommandFile | undefined {
117
100
  parameter: { name, optional, rest },
118
101
  };
119
102
  }
103
+ if (isCapabilityLocalSegment(value)) {
104
+ return { localSegment: value };
105
+ }
120
106
  if (value.startsWith('[') || value.includes(']')) {
121
107
  throw new CommandPathSyntaxError(value);
122
108
  }
@@ -141,7 +127,7 @@ function resolveParameter(
141
127
  if (!hint.optional && schema.default !== undefined) {
142
128
  throw new CommandPathSyntaxError(
143
129
  source,
144
- `params.${hint.name} has a default but the file is required: rename to [[${hint.name}]]`,
130
+ `params.${hint.name} has a default but the directory is required: rename it to [[${hint.name}]]`,
145
131
  );
146
132
  }
147
133
  return {
@@ -163,10 +149,20 @@ function commandFilePriority(value: string, preferJavaScript: boolean): number {
163
149
  return priority < 0 ? Number.MAX_SAFE_INTEGER : priority;
164
150
  }
165
151
 
152
+ function preferredCommandIndex(
153
+ entries: readonly { readonly name: string; readonly kind: 'file' | 'directory' }[],
154
+ preferJavaScript: boolean,
155
+ ): string | undefined {
156
+ return entries
157
+ .filter((entry) => entry.kind === 'file' && /^index\.(?:tsx?|[cm]?js)$/u.test(entry.name))
158
+ .sort((left, right) => commandFilePriority(left.name, preferJavaScript)
159
+ - commandFilePriority(right.name, preferJavaScript))[0]?.name;
160
+ }
161
+
166
162
  export class CommandPathSyntaxError extends TypeError {
167
163
  constructor(
168
164
  file: string,
169
- detail = 'expected [name].ts(x), [[name]].ts(x), [...name].ts(x) or [[...name]].ts(x)',
165
+ detail = 'expected commands/foo/index.ts or commands/foo/[name]/index.ts',
170
166
  ) {
171
167
  super(`Invalid Command path ${file}: ${detail}`);
172
168
  this.name = 'CommandPathSyntaxError';