@zhin.js/command 1.1.0 → 1.1.2
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 +13 -13
- package/lib/command-index.js +31 -23
- package/lib/definition.d.ts +3 -3
- package/lib/provider.js +38 -42
- package/package.json +4 -4
- package/src/command-index.ts +36 -21
- package/src/definition.ts +3 -3
- package/src/provider.ts +41 -45
package/README.md
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
# @zhin.js/command
|
|
2
2
|
|
|
3
|
-
Zhin Plugin Runtime 的约定式 Command Feature。它发现 `commands
|
|
4
|
-
|
|
3
|
+
Zhin Plugin Runtime 的约定式 Command Feature。它发现 `commands/**/*/index.ts(x)`,只把文件
|
|
4
|
+
相对路径投影为用户路由,并用 `segment-matcher` 同时匹配纯文本和 canonical IM segments。
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
(`[name]
|
|
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
|
|
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'], //
|
|
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
|
|
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;拆成文件后
|
package/lib/command-index.js
CHANGED
|
@@ -35,7 +35,8 @@ export class CommandIndex {
|
|
|
35
35
|
occupancy.set(key, source);
|
|
36
36
|
};
|
|
37
37
|
for (const slot of slots) {
|
|
38
|
-
const
|
|
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(
|
|
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.
|
|
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
|
-
*
|
|
322
|
-
*
|
|
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(
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
const
|
|
331
|
-
|
|
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
|
|
346
|
+
* 用 alias 词序列替换全部本地静态段;动态段保留。
|
|
335
347
|
*/
|
|
336
|
-
function aliasRuntimeSegments(
|
|
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
|
-
|
|
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
|
|
420
|
-
+ `path, so a dynamic first segment is never reachable). `
|
|
428
|
+
+ `segment and be the final path segment. `
|
|
421
429
|
+ (parameter
|
|
422
|
-
? `Hint:
|
|
423
|
-
: 'Hint:
|
|
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;
|
package/lib/definition.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "Convention-based Command Feature for Zhin Plugin Runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -18,10 +18,10 @@
|
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"segment-matcher": "^1.0.5",
|
|
21
|
-
"@zhin.js/feature-kit": "1.1.
|
|
21
|
+
"@zhin.js/feature-kit": "1.1.2",
|
|
22
22
|
"@zhin.js/interaction": "1.1.0",
|
|
23
|
-
"@zhin.js/
|
|
24
|
-
"@zhin.js/
|
|
23
|
+
"@zhin.js/permission": "1.1.2",
|
|
24
|
+
"@zhin.js/plugin-runtime": "1.1.11"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/node": "^26.1.2",
|
package/src/command-index.ts
CHANGED
|
@@ -119,7 +119,8 @@ export class CommandIndex {
|
|
|
119
119
|
};
|
|
120
120
|
|
|
121
121
|
for (const slot of slots) {
|
|
122
|
-
const
|
|
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(
|
|
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.
|
|
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
|
-
*
|
|
460
|
-
*
|
|
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(
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
582
|
-
+ `path, so a dynamic first segment is never reachable). `
|
|
597
|
+
+ `segment and be the final path segment. `
|
|
583
598
|
+ (parameter
|
|
584
|
-
? `Hint:
|
|
585
|
-
: 'Hint:
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
49
|
-
|
|
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:
|
|
71
|
-
source: join(directory,
|
|
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
|
-
|
|
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
|
|
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
|
|
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';
|