@zhin.js/command 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 凉菜
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,20 @@
1
+ # @zhin.js/command
2
+
3
+ Zhin Plugin Runtime 的 Command Feature。它把 `commands/**/*.ts` 的目录层级映射为命令
4
+ 路径,并支持 `[name:type=default]` 参数文件名、owner-scoped Config/Resource 与确定性匹配。
5
+
6
+ ```ts
7
+ import { defineCommand } from '@zhin.js/command';
8
+
9
+ export default defineCommand({
10
+ description: 'Show runtime status',
11
+ execute: ({ args }) => args.join(' '),
12
+ });
13
+ ```
14
+
15
+ definition 在 import 时不注册全局状态;Feature provider 在 generation prepare 阶段发现、
16
+ 校验并投影 `CommandIndex`。生产 manifest 指向 `lib/provider.js`。
17
+
18
+ 验证:`pnpm --filter @zhin.js/command test && pnpm --filter @zhin.js/command build`。
19
+
20
+ 命令目录契约见 [Plugin Runtime 原位迁移](../../../docs/architecture/target-implementation/in-place-migration.md)。
@@ -0,0 +1,31 @@
1
+ import type { CapabilitySlot, PluginId, RuntimeSnapshot } from '@zhin.js/plugin-runtime';
2
+ import { type CommandDefinition, type CommandParameterDefinition, type CommandParameterType } from './definition.js';
3
+ export interface CommandParameterDescriptor extends CommandParameterDefinition {
4
+ readonly required: boolean;
5
+ }
6
+ export interface CommandDescriptor {
7
+ readonly name: string;
8
+ readonly description?: string;
9
+ readonly source: string;
10
+ readonly parameters: readonly CommandParameterDescriptor[];
11
+ }
12
+ export interface CommandDispatchResult {
13
+ readonly matched: boolean;
14
+ readonly command?: string;
15
+ readonly owner?: PluginId;
16
+ readonly value?: unknown;
17
+ }
18
+ export declare class CommandIndex {
19
+ #private;
20
+ private readonly snapshot;
21
+ readonly $projection: "zhin.command-index/1";
22
+ constructor(slots: readonly Readonly<CapabilitySlot<CommandDefinition>>[], snapshot: RuntimeSnapshot);
23
+ list(): readonly CommandDescriptor[];
24
+ has(name: string): boolean;
25
+ execute(name: string, args?: readonly string[]): Promise<unknown>;
26
+ dispatch(input: string, source?: unknown): Promise<CommandDispatchResult>;
27
+ }
28
+ export declare function isCommandIndex(value: unknown): value is CommandIndex;
29
+ export declare class CommandParameterValueError extends TypeError {
30
+ constructor(name: string, type: CommandParameterType, value: string);
31
+ }
@@ -0,0 +1,167 @@
1
+ import { createCommandContext, } from './definition.js';
2
+ export class CommandIndex {
3
+ snapshot;
4
+ $projection = 'zhin.command-index/1';
5
+ #commands;
6
+ #staticCommands = new Map();
7
+ #dynamicCommands = new Map();
8
+ constructor(slots, snapshot) {
9
+ this.snapshot = snapshot;
10
+ const commands = [];
11
+ for (const slot of slots) {
12
+ const segments = runtimeSegments(slot.owner, slot.localName);
13
+ const parameter = slot.definition.$parameter;
14
+ assertParameterSegment(segments, parameter, slot.source);
15
+ const name = displayName(segments, parameter);
16
+ const record = Object.freeze({
17
+ name,
18
+ description: slot.definition.description,
19
+ source: slot.source,
20
+ parameters: Object.freeze(parameter ? [{
21
+ ...parameter,
22
+ required: parameter.defaultValue === undefined,
23
+ }] : []),
24
+ slot,
25
+ segments: Object.freeze(segments),
26
+ parameter,
27
+ });
28
+ if (!parameter) {
29
+ const key = segments.join(' ');
30
+ if (this.#staticCommands.has(key))
31
+ throw duplicateCommand(key);
32
+ this.#staticCommands.set(key, record);
33
+ }
34
+ else {
35
+ const shape = routeShape(segments);
36
+ if (this.#dynamicCommands.has(shape))
37
+ throw duplicateCommand(name);
38
+ this.#dynamicCommands.set(shape, record);
39
+ }
40
+ commands.push(record);
41
+ }
42
+ this.#commands = Object.freeze(commands);
43
+ }
44
+ list() {
45
+ return this.#commands.map(toDescriptor);
46
+ }
47
+ has(name) {
48
+ try {
49
+ return this.#match(name) !== undefined;
50
+ }
51
+ catch (error) {
52
+ if (error instanceof CommandParameterValueError)
53
+ return false;
54
+ throw error;
55
+ }
56
+ }
57
+ async execute(name, args = []) {
58
+ const match = this.#match(name);
59
+ if (!match)
60
+ throw new Error(`Unknown Command: ${name}`);
61
+ return match.command.slot.definition.execute(createCommandContext(this.snapshot, match.command.slot.owner, args, match.params));
62
+ }
63
+ async dispatch(input, source = undefined) {
64
+ const words = splitCommand(input);
65
+ for (let consumed = words.length; consumed > 0; consumed -= 1) {
66
+ const match = this.#match(words.slice(0, consumed).join(' '));
67
+ if (!match)
68
+ continue;
69
+ const args = words.slice(consumed);
70
+ const value = await match.command.slot.definition.execute(createCommandContext(this.snapshot, match.command.slot.owner, args, match.params, source));
71
+ return Object.freeze({
72
+ matched: true,
73
+ command: match.command.name,
74
+ owner: match.command.slot.owner,
75
+ value,
76
+ });
77
+ }
78
+ return Object.freeze({ matched: false });
79
+ }
80
+ #match(name) {
81
+ const words = splitCommand(name);
82
+ // A literal file such as list.ts always wins over [name:string].ts.
83
+ const staticCommand = this.#staticCommands.get(words.join(' '));
84
+ if (staticCommand)
85
+ return { command: staticCommand, params: Object.freeze({}) };
86
+ for (const command of this.#dynamicCommands.values()) {
87
+ const parameter = command.parameter;
88
+ const optional = parameter.defaultValue !== undefined;
89
+ if (words.length !== command.segments.length &&
90
+ !(optional && words.length === command.segments.length - 1))
91
+ continue;
92
+ const parameterIndex = command.segments.findIndex((segment) => segment.startsWith('$'));
93
+ if (!command.segments.every((segment, index) => index === parameterIndex || segment === words[index]))
94
+ continue;
95
+ const rawValue = words[parameterIndex];
96
+ const value = rawValue === undefined
97
+ ? parameter.defaultValue
98
+ : parseRuntimeValue(parameter, rawValue);
99
+ return {
100
+ command,
101
+ params: Object.freeze({ [parameter.name]: value }),
102
+ };
103
+ }
104
+ return undefined;
105
+ }
106
+ }
107
+ export function isCommandIndex(value) {
108
+ return !!value && typeof value === 'object'
109
+ && value.$projection === 'zhin.command-index/1';
110
+ }
111
+ function runtimeSegments(owner, localName) {
112
+ const ownerSegments = owner === 'root'
113
+ ? []
114
+ : owner.slice('root/'.length).split('/');
115
+ return [...ownerSegments, ...localName.split('/')];
116
+ }
117
+ function assertParameterSegment(segments, parameter, source) {
118
+ const dynamicSegments = segments.filter((segment) => segment.startsWith('$'));
119
+ if (!parameter && dynamicSegments.length === 0)
120
+ return;
121
+ if (parameter && dynamicSegments.length === 1 &&
122
+ dynamicSegments[0] === `$${parameter.name}` &&
123
+ segments.at(-1) === dynamicSegments[0])
124
+ return;
125
+ throw new Error(`Broken dynamic Command identity for ${source}`);
126
+ }
127
+ function displayName(segments, parameter) {
128
+ return segments.map((segment) => {
129
+ if (!segment.startsWith('$'))
130
+ return segment;
131
+ return parameter?.defaultValue === undefined
132
+ ? `<${segment.slice(1)}>`
133
+ : `[${segment.slice(1)}]`;
134
+ }).join(' ');
135
+ }
136
+ function routeShape(segments) {
137
+ return segments.map((segment) => segment.startsWith('$') ? '$' : segment).join(' ');
138
+ }
139
+ function splitCommand(value) {
140
+ const normalized = value.trim();
141
+ return normalized ? normalized.split(/\s+/) : [];
142
+ }
143
+ function parseRuntimeValue(parameter, value) {
144
+ if (parameter.type === 'string')
145
+ return value;
146
+ if (parameter.type === 'number') {
147
+ const number = Number(value);
148
+ if (value.trim().length > 0 && Number.isFinite(number))
149
+ return number;
150
+ }
151
+ else if (value === 'true' || value === 'false') {
152
+ return value === 'true';
153
+ }
154
+ throw new CommandParameterValueError(parameter.name, parameter.type, value);
155
+ }
156
+ function toDescriptor({ slot: _slot, segments: _segments, parameter: _parameter, ...descriptor }) {
157
+ return descriptor;
158
+ }
159
+ function duplicateCommand(name) {
160
+ return new Error(`Duplicate runtime Command: ${name}`);
161
+ }
162
+ export class CommandParameterValueError extends TypeError {
163
+ constructor(name, type, value) {
164
+ super(`Invalid value for Command parameter ${name}:${type}: ${value}`);
165
+ this.name = 'CommandParameterValueError';
166
+ }
167
+ }
@@ -0,0 +1,26 @@
1
+ import type { PluginId, RuntimeSnapshot } from '@zhin.js/plugin-runtime';
2
+ import { type CapabilityContext } from '@zhin.js/feature-kit';
3
+ declare const commandBrand: "zhin.command/1";
4
+ export type CommandParameterType = 'string' | 'number' | 'boolean';
5
+ export type CommandParameterValue = string | number | boolean;
6
+ export interface CommandParameterDefinition {
7
+ readonly name: string;
8
+ readonly type: CommandParameterType;
9
+ readonly defaultValue?: CommandParameterValue;
10
+ }
11
+ export interface CommandContext<TConfig = unknown, TInput = unknown> extends CapabilityContext<TConfig> {
12
+ readonly args: readonly string[];
13
+ readonly params: Readonly<Record<string, CommandParameterValue>>;
14
+ readonly input: TInput;
15
+ }
16
+ export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput = unknown> {
17
+ readonly $feature: typeof commandBrand;
18
+ readonly $parameter?: CommandParameterDefinition;
19
+ readonly description?: string;
20
+ execute(context: CommandContext<TConfig, TInput>): TResult | Promise<TResult>;
21
+ }
22
+ export declare function defineCommand<TConfig = unknown, TResult = unknown, TInput = unknown>(definition: Omit<CommandDefinition<TConfig, TResult, TInput>, '$feature' | '$parameter'>): Readonly<CommandDefinition<TConfig, TResult, TInput>>;
23
+ export declare function bindCommandParameter<TConfig, TResult, TInput>(definition: CommandDefinition<TConfig, TResult, TInput>, parameter: CommandParameterDefinition | undefined): Readonly<CommandDefinition<TConfig, TResult, TInput>>;
24
+ export declare function parseCommandDefinition(value: unknown): CommandDefinition;
25
+ export declare function createCommandContext(snapshot: RuntimeSnapshot, ownerId: PluginId, args: readonly string[], params?: Readonly<Record<string, CommandParameterValue>>, input?: unknown): CommandContext;
26
+ export {};
@@ -0,0 +1,32 @@
1
+ import { createCapabilityContext, } from '@zhin.js/feature-kit';
2
+ const commandBrand = 'zhin.command/1';
3
+ export function defineCommand(definition) {
4
+ if (typeof definition.execute !== 'function') {
5
+ throw new TypeError('Command execute must be a function');
6
+ }
7
+ return Object.freeze({ $feature: commandBrand, ...definition });
8
+ }
9
+ export function bindCommandParameter(definition, parameter) {
10
+ if (!parameter)
11
+ return definition;
12
+ return Object.freeze({ ...definition, $parameter: Object.freeze({ ...parameter }) });
13
+ }
14
+ export function parseCommandDefinition(value) {
15
+ if (!value || typeof value !== 'object') {
16
+ throw new TypeError('Command module must default-export defineCommand(...)');
17
+ }
18
+ const definition = value;
19
+ if (definition.$feature !== commandBrand || typeof definition.execute !== 'function') {
20
+ throw new TypeError('Command module must default-export defineCommand(...)');
21
+ }
22
+ return definition;
23
+ }
24
+ export function createCommandContext(snapshot, ownerId, args, params = Object.freeze({}), input = undefined) {
25
+ const context = createCapabilityContext(snapshot, ownerId);
26
+ return Object.freeze({
27
+ ...context,
28
+ args: Object.freeze([...args]),
29
+ params: Object.freeze({ ...params }),
30
+ input,
31
+ });
32
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from './command-index.js';
2
+ export * from './definition.js';
3
+ export { CommandPathSyntaxError, commandFeatureId, default as commandFeature, } from './provider.js';
4
+ export { default } from './provider.js';
package/lib/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export * from './command-index.js';
2
+ export * from './definition.js';
3
+ export { CommandPathSyntaxError, commandFeatureId, default as commandFeature, } from './provider.js';
4
+ export { default } from './provider.js';
@@ -0,0 +1,7 @@
1
+ import { CommandIndex } from './command-index.js';
2
+ export declare const commandFeatureId: import("@zhin.js/plugin-runtime").FeatureId;
3
+ export declare class CommandPathSyntaxError extends TypeError {
4
+ constructor(file: string, detail?: string);
5
+ }
6
+ declare const commandFeature: Readonly<import("@zhin.js/feature-kit").FeatureProvider<import("./definition.js").CommandDefinition<unknown, unknown, unknown>, CommandIndex>>;
7
+ export default commandFeature;
@@ -0,0 +1,94 @@
1
+ import { basename, join, parse } from 'node:path';
2
+ import { featureId } from '@zhin.js/plugin-runtime';
3
+ import { defineFeatureProvider, } from '@zhin.js/feature-kit';
4
+ import { CommandIndex } from './command-index.js';
5
+ import { bindCommandParameter, parseCommandDefinition, } from './definition.js';
6
+ export const commandFeatureId = featureId('zhin.command');
7
+ const commandFiles = {
8
+ id: 'commands-ts',
9
+ async *discover(context) {
10
+ const directory = join(context.packageRoot, 'commands');
11
+ yield* discoverCommandDirectory(context, directory, []);
12
+ },
13
+ async load(source, context) {
14
+ const module = await context.host.loadModule(source.source);
15
+ const definition = parseCommandDefinition(module.default);
16
+ const file = parseCommandFile(basename(source.source));
17
+ return bindCommandParameter(definition, file?.parameter);
18
+ },
19
+ };
20
+ async function* discoverCommandDirectory(context, directory, ancestors) {
21
+ const entries = [...await context.host.list(directory)]
22
+ .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
23
+ for (const entry of entries) {
24
+ if (entry.kind === 'directory' && isCommandSegment(entry.name)) {
25
+ yield* discoverCommandDirectory(context, join(directory, entry.name), [...ancestors, entry.name]);
26
+ continue;
27
+ }
28
+ if (entry.kind !== 'file')
29
+ continue;
30
+ const file = parseCommandFile(entry.name);
31
+ if (!file)
32
+ continue;
33
+ yield {
34
+ localName: [...ancestors, file.localSegment].join('/'),
35
+ source: join(directory, entry.name),
36
+ target: 'server',
37
+ };
38
+ }
39
+ }
40
+ function isCommandSegment(value) {
41
+ return /^[a-z0-9][a-z0-9-]*$/.test(value);
42
+ }
43
+ const dynamicCommandFilePattern = /^\[([a-z][a-zA-Z0-9]*):(string|number|boolean)(?:=([^\]]*))?\]\.tsx?$/;
44
+ function parseCommandFile(value) {
45
+ if (/^[a-z0-9][a-z0-9-]*\.tsx?$/.test(value)) {
46
+ return { localSegment: parse(value).name };
47
+ }
48
+ const match = dynamicCommandFilePattern.exec(value);
49
+ if (match) {
50
+ const [, name, type, rawDefault] = match;
51
+ // Metadata can change during HMR while $name keeps the Capability identity stable.
52
+ const parameter = rawDefault === undefined
53
+ ? { name, type }
54
+ : { name, type, defaultValue: parseParameterValue(name, type, rawDefault, value) };
55
+ return { localSegment: `$${name}`, parameter };
56
+ }
57
+ if (value.startsWith('[') || value.includes(']')) {
58
+ throw new CommandPathSyntaxError(value);
59
+ }
60
+ return undefined;
61
+ }
62
+ function parseParameterValue(name, type, value, source) {
63
+ if (type === 'string')
64
+ return value;
65
+ if (type === 'number') {
66
+ const number = Number(value);
67
+ if (value.trim().length > 0 && Number.isFinite(number))
68
+ return number;
69
+ }
70
+ else if (value === 'true' || value === 'false') {
71
+ return value === 'true';
72
+ }
73
+ throw new CommandPathSyntaxError(source, `default for ${name}:${type} is invalid`);
74
+ }
75
+ export class CommandPathSyntaxError extends TypeError {
76
+ constructor(file, detail = 'expected [name:string|number|boolean=default].ts(x)') {
77
+ super(`Invalid Command path ${file}: ${detail}`);
78
+ this.name = 'CommandPathSyntaxError';
79
+ }
80
+ }
81
+ const commandFeature = defineFeatureProvider({
82
+ protocol: 1,
83
+ id: commandFeatureId,
84
+ authoring: {
85
+ conventions: [commandFiles],
86
+ validate: parseCommandDefinition,
87
+ },
88
+ runtime: {
89
+ project(slots, context) {
90
+ return { value: new CommandIndex(slots, context.snapshot) };
91
+ },
92
+ },
93
+ });
94
+ export default commandFeature;
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@zhin.js/command",
3
+ "version": "1.0.0",
4
+ "description": "Convention-based Command Feature for Zhin Plugin Runtime",
5
+ "type": "module",
6
+ "main": "./lib/index.js",
7
+ "types": "./lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "development": "./src/index.ts",
12
+ "import": "./lib/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "lib",
17
+ "src"
18
+ ],
19
+ "dependencies": {
20
+ "@zhin.js/feature-kit": "1.0.0",
21
+ "@zhin.js/plugin-runtime": "1.0.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^26.1.0",
25
+ "typescript": "^6.0.3"
26
+ },
27
+ "zhin": {
28
+ "protocol": 1,
29
+ "type": "feature",
30
+ "entry": "./lib/provider.js",
31
+ "engine": "^1.0.0",
32
+ "featureApi": "1.0.0"
33
+ },
34
+ "engines": {
35
+ "node": "^20.19.0 || >=22.12.0"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/zhinjs/zhin.git",
40
+ "directory": "packages/im/command"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public",
44
+ "registry": "https://registry.npmjs.org"
45
+ },
46
+ "license": "MIT",
47
+ "private": false,
48
+ "scripts": {
49
+ "build": "tsc",
50
+ "clean": "rimraf lib",
51
+ "test": "vitest run --root ../../.. packages/im/command/tests"
52
+ }
53
+ }
@@ -0,0 +1,241 @@
1
+ import type {
2
+ CapabilitySlot,
3
+ PluginId,
4
+ RuntimeSnapshot,
5
+ } from '@zhin.js/plugin-runtime';
6
+ import {
7
+ createCommandContext,
8
+ type CommandDefinition,
9
+ type CommandParameterDefinition,
10
+ type CommandParameterType,
11
+ type CommandParameterValue,
12
+ } from './definition.js';
13
+
14
+ export interface CommandParameterDescriptor extends CommandParameterDefinition {
15
+ readonly required: boolean;
16
+ }
17
+
18
+ export interface CommandDescriptor {
19
+ readonly name: string;
20
+ readonly description?: string;
21
+ readonly source: string;
22
+ readonly parameters: readonly CommandParameterDescriptor[];
23
+ }
24
+
25
+ export interface CommandDispatchResult {
26
+ readonly matched: boolean;
27
+ readonly command?: string;
28
+ readonly owner?: PluginId;
29
+ readonly value?: unknown;
30
+ }
31
+
32
+ interface CommandRecord extends CommandDescriptor {
33
+ readonly slot: Readonly<CapabilitySlot<CommandDefinition>>;
34
+ readonly segments: readonly string[];
35
+ readonly parameter?: CommandParameterDefinition;
36
+ }
37
+
38
+ interface CommandMatch {
39
+ readonly command: CommandRecord;
40
+ readonly params: Readonly<Record<string, CommandParameterValue>>;
41
+ }
42
+
43
+ export class CommandIndex {
44
+ readonly $projection = 'zhin.command-index/1' as const;
45
+ readonly #commands: readonly CommandRecord[];
46
+ readonly #staticCommands = new Map<string, CommandRecord>();
47
+ readonly #dynamicCommands = new Map<string, CommandRecord>();
48
+
49
+ constructor(
50
+ slots: readonly Readonly<CapabilitySlot<CommandDefinition>>[],
51
+ private readonly snapshot: RuntimeSnapshot,
52
+ ) {
53
+ const commands: CommandRecord[] = [];
54
+ for (const slot of slots) {
55
+ const segments = runtimeSegments(slot.owner, slot.localName);
56
+ const parameter = slot.definition.$parameter;
57
+ assertParameterSegment(segments, parameter, slot.source);
58
+ const name = displayName(segments, parameter);
59
+ const record = Object.freeze({
60
+ name,
61
+ description: slot.definition.description,
62
+ source: slot.source,
63
+ parameters: Object.freeze(parameter ? [{
64
+ ...parameter,
65
+ required: parameter.defaultValue === undefined,
66
+ }] : []),
67
+ slot,
68
+ segments: Object.freeze(segments),
69
+ parameter,
70
+ });
71
+ if (!parameter) {
72
+ const key = segments.join(' ');
73
+ if (this.#staticCommands.has(key)) throw duplicateCommand(key);
74
+ this.#staticCommands.set(key, record);
75
+ } else {
76
+ const shape = routeShape(segments);
77
+ if (this.#dynamicCommands.has(shape)) throw duplicateCommand(name);
78
+ this.#dynamicCommands.set(shape, record);
79
+ }
80
+ commands.push(record);
81
+ }
82
+ this.#commands = Object.freeze(commands);
83
+ }
84
+
85
+ list(): readonly CommandDescriptor[] {
86
+ return this.#commands.map(toDescriptor);
87
+ }
88
+
89
+ has(name: string): boolean {
90
+ try {
91
+ return this.#match(name) !== undefined;
92
+ } catch (error) {
93
+ if (error instanceof CommandParameterValueError) return false;
94
+ throw error;
95
+ }
96
+ }
97
+
98
+ async execute(name: string, args: readonly string[] = []): Promise<unknown> {
99
+ const match = this.#match(name);
100
+ if (!match) throw new Error(`Unknown Command: ${name}`);
101
+ return match.command.slot.definition.execute(
102
+ createCommandContext(
103
+ this.snapshot,
104
+ match.command.slot.owner,
105
+ args,
106
+ match.params,
107
+ ),
108
+ );
109
+ }
110
+
111
+ async dispatch(input: string, source: unknown = undefined): Promise<CommandDispatchResult> {
112
+ const words = splitCommand(input);
113
+ for (let consumed = words.length; consumed > 0; consumed -= 1) {
114
+ const match = this.#match(words.slice(0, consumed).join(' '));
115
+ if (!match) continue;
116
+ const args = words.slice(consumed);
117
+ const value = await match.command.slot.definition.execute(
118
+ createCommandContext(
119
+ this.snapshot,
120
+ match.command.slot.owner,
121
+ args,
122
+ match.params,
123
+ source,
124
+ ),
125
+ );
126
+ return Object.freeze({
127
+ matched: true,
128
+ command: match.command.name,
129
+ owner: match.command.slot.owner,
130
+ value,
131
+ });
132
+ }
133
+ return Object.freeze({ matched: false });
134
+ }
135
+
136
+ #match(name: string): CommandMatch | undefined {
137
+ const words = splitCommand(name);
138
+ // A literal file such as list.ts always wins over [name:string].ts.
139
+ const staticCommand = this.#staticCommands.get(words.join(' '));
140
+ if (staticCommand) return { command: staticCommand, params: Object.freeze({}) };
141
+
142
+ for (const command of this.#dynamicCommands.values()) {
143
+ const parameter = command.parameter as CommandParameterDefinition;
144
+ const optional = parameter.defaultValue !== undefined;
145
+ if (words.length !== command.segments.length &&
146
+ !(optional && words.length === command.segments.length - 1)) continue;
147
+ const parameterIndex = command.segments.findIndex((segment) => segment.startsWith('$'));
148
+ if (!command.segments.every((segment, index) =>
149
+ index === parameterIndex || segment === words[index])) continue;
150
+ const rawValue = words[parameterIndex];
151
+ const value = rawValue === undefined
152
+ ? parameter.defaultValue as CommandParameterValue
153
+ : parseRuntimeValue(parameter, rawValue);
154
+ return {
155
+ command,
156
+ params: Object.freeze({ [parameter.name]: value }),
157
+ };
158
+ }
159
+ return undefined;
160
+ }
161
+ }
162
+
163
+ export function isCommandIndex(value: unknown): value is CommandIndex {
164
+ return !!value && typeof value === 'object'
165
+ && (value as { readonly $projection?: unknown }).$projection === 'zhin.command-index/1';
166
+ }
167
+
168
+ function runtimeSegments(owner: string, localName: string): string[] {
169
+ const ownerSegments = owner === 'root'
170
+ ? []
171
+ : owner.slice('root/'.length).split('/');
172
+ return [...ownerSegments, ...localName.split('/')];
173
+ }
174
+
175
+ function assertParameterSegment(
176
+ segments: readonly string[],
177
+ parameter: CommandParameterDefinition | undefined,
178
+ source: string,
179
+ ): void {
180
+ const dynamicSegments = segments.filter((segment) => segment.startsWith('$'));
181
+ if (!parameter && dynamicSegments.length === 0) return;
182
+ if (parameter && dynamicSegments.length === 1 &&
183
+ dynamicSegments[0] === `$${parameter.name}` &&
184
+ segments.at(-1) === dynamicSegments[0]) return;
185
+ throw new Error(`Broken dynamic Command identity for ${source}`);
186
+ }
187
+
188
+ function displayName(
189
+ segments: readonly string[],
190
+ parameter: CommandParameterDefinition | undefined,
191
+ ): string {
192
+ return segments.map((segment) => {
193
+ if (!segment.startsWith('$')) return segment;
194
+ return parameter?.defaultValue === undefined
195
+ ? `<${segment.slice(1)}>`
196
+ : `[${segment.slice(1)}]`;
197
+ }).join(' ');
198
+ }
199
+
200
+ function routeShape(segments: readonly string[]): string {
201
+ return segments.map((segment) => segment.startsWith('$') ? '$' : segment).join(' ');
202
+ }
203
+
204
+ function splitCommand(value: string): readonly string[] {
205
+ const normalized = value.trim();
206
+ return normalized ? normalized.split(/\s+/) : [];
207
+ }
208
+
209
+ function parseRuntimeValue(
210
+ parameter: CommandParameterDefinition,
211
+ value: string,
212
+ ): CommandParameterValue {
213
+ if (parameter.type === 'string') return value;
214
+ if (parameter.type === 'number') {
215
+ const number = Number(value);
216
+ if (value.trim().length > 0 && Number.isFinite(number)) return number;
217
+ } else if (value === 'true' || value === 'false') {
218
+ return value === 'true';
219
+ }
220
+ throw new CommandParameterValueError(parameter.name, parameter.type, value);
221
+ }
222
+
223
+ function toDescriptor({
224
+ slot: _slot,
225
+ segments: _segments,
226
+ parameter: _parameter,
227
+ ...descriptor
228
+ }: CommandRecord): CommandDescriptor {
229
+ return descriptor;
230
+ }
231
+
232
+ function duplicateCommand(name: string): Error {
233
+ return new Error(`Duplicate runtime Command: ${name}`);
234
+ }
235
+
236
+ export class CommandParameterValueError extends TypeError {
237
+ constructor(name: string, type: CommandParameterType, value: string) {
238
+ super(`Invalid value for Command parameter ${name}:${type}: ${value}`);
239
+ this.name = 'CommandParameterValueError';
240
+ }
241
+ }
@@ -0,0 +1,74 @@
1
+ import type { PluginId, RuntimeSnapshot } from '@zhin.js/plugin-runtime';
2
+ import {
3
+ createCapabilityContext,
4
+ type CapabilityContext,
5
+ } from '@zhin.js/feature-kit';
6
+
7
+ const commandBrand = 'zhin.command/1' as const;
8
+
9
+ export type CommandParameterType = 'string' | 'number' | 'boolean';
10
+ export type CommandParameterValue = string | number | boolean;
11
+
12
+ export interface CommandParameterDefinition {
13
+ readonly name: string;
14
+ readonly type: CommandParameterType;
15
+ readonly defaultValue?: CommandParameterValue;
16
+ }
17
+
18
+ export interface CommandContext<TConfig = unknown, TInput = unknown>
19
+ extends CapabilityContext<TConfig> {
20
+ readonly args: readonly string[];
21
+ readonly params: Readonly<Record<string, CommandParameterValue>>;
22
+ readonly input: TInput;
23
+ }
24
+
25
+ export interface CommandDefinition<TConfig = unknown, TResult = unknown, TInput = unknown> {
26
+ readonly $feature: typeof commandBrand;
27
+ readonly $parameter?: CommandParameterDefinition;
28
+ readonly description?: string;
29
+ execute(context: CommandContext<TConfig, TInput>): TResult | Promise<TResult>;
30
+ }
31
+
32
+ export function defineCommand<TConfig = unknown, TResult = unknown, TInput = unknown>(
33
+ definition: Omit<CommandDefinition<TConfig, TResult, TInput>, '$feature' | '$parameter'>,
34
+ ): Readonly<CommandDefinition<TConfig, TResult, TInput>> {
35
+ if (typeof definition.execute !== 'function') {
36
+ throw new TypeError('Command execute must be a function');
37
+ }
38
+ return Object.freeze({ $feature: commandBrand, ...definition });
39
+ }
40
+
41
+ export function bindCommandParameter<TConfig, TResult, TInput>(
42
+ definition: CommandDefinition<TConfig, TResult, TInput>,
43
+ parameter: CommandParameterDefinition | undefined,
44
+ ): Readonly<CommandDefinition<TConfig, TResult, TInput>> {
45
+ if (!parameter) return definition;
46
+ return Object.freeze({ ...definition, $parameter: Object.freeze({ ...parameter }) });
47
+ }
48
+
49
+ export function parseCommandDefinition(value: unknown): CommandDefinition {
50
+ if (!value || typeof value !== 'object') {
51
+ throw new TypeError('Command module must default-export defineCommand(...)');
52
+ }
53
+ const definition = value as Partial<CommandDefinition>;
54
+ if (definition.$feature !== commandBrand || typeof definition.execute !== 'function') {
55
+ throw new TypeError('Command module must default-export defineCommand(...)');
56
+ }
57
+ return definition as CommandDefinition;
58
+ }
59
+
60
+ export function createCommandContext(
61
+ snapshot: RuntimeSnapshot,
62
+ ownerId: PluginId,
63
+ args: readonly string[],
64
+ params: Readonly<Record<string, CommandParameterValue>> = Object.freeze({}),
65
+ input: unknown = undefined,
66
+ ): CommandContext {
67
+ const context = createCapabilityContext(snapshot, ownerId);
68
+ return Object.freeze({
69
+ ...context,
70
+ args: Object.freeze([...args]),
71
+ params: Object.freeze({ ...params }),
72
+ input,
73
+ });
74
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export * from './command-index.js';
2
+ export * from './definition.js';
3
+ export {
4
+ CommandPathSyntaxError,
5
+ commandFeatureId,
6
+ default as commandFeature,
7
+ } from './provider.js';
8
+ export { default } from './provider.js';
@@ -0,0 +1,135 @@
1
+ import { basename, join, parse } from 'node:path';
2
+ import { featureId } from '@zhin.js/plugin-runtime';
3
+ import {
4
+ defineFeatureProvider,
5
+ type DiscoveryContext,
6
+ type DiscoveredSource,
7
+ type SourceConvention,
8
+ } from '@zhin.js/feature-kit';
9
+ import { CommandIndex } from './command-index.js';
10
+ import {
11
+ bindCommandParameter,
12
+ parseCommandDefinition,
13
+ type CommandParameterDefinition,
14
+ type CommandParameterType,
15
+ type CommandParameterValue,
16
+ } from './definition.js';
17
+
18
+ export const commandFeatureId = featureId('zhin.command');
19
+
20
+ const commandFiles: SourceConvention = {
21
+ id: 'commands-ts',
22
+ async *discover(context) {
23
+ const directory = join(context.packageRoot, 'commands');
24
+ yield* discoverCommandDirectory(context, directory, []);
25
+ },
26
+ async load(source, context) {
27
+ const module = await context.host.loadModule<{ default?: unknown }>(source.source);
28
+ const definition = parseCommandDefinition(module.default);
29
+ const file = parseCommandFile(basename(source.source));
30
+ return bindCommandParameter(definition, file?.parameter);
31
+ },
32
+ };
33
+
34
+ async function* discoverCommandDirectory(
35
+ context: DiscoveryContext,
36
+ directory: string,
37
+ ancestors: readonly string[],
38
+ ): AsyncIterable<DiscoveredSource> {
39
+ const entries = [...await context.host.list(directory)]
40
+ .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
41
+ for (const entry of entries) {
42
+ if (entry.kind === 'directory' && isCommandSegment(entry.name)) {
43
+ yield* discoverCommandDirectory(
44
+ context,
45
+ join(directory, entry.name),
46
+ [...ancestors, entry.name],
47
+ );
48
+ continue;
49
+ }
50
+ if (entry.kind !== 'file') continue;
51
+ const file = parseCommandFile(entry.name);
52
+ if (!file) continue;
53
+ yield {
54
+ localName: [...ancestors, file.localSegment].join('/'),
55
+ source: join(directory, entry.name),
56
+ target: 'server',
57
+ };
58
+ }
59
+ }
60
+
61
+ function isCommandSegment(value: string): boolean {
62
+ return /^[a-z0-9][a-z0-9-]*$/.test(value);
63
+ }
64
+
65
+ interface ParsedCommandFile {
66
+ readonly localSegment: string;
67
+ readonly parameter?: CommandParameterDefinition;
68
+ }
69
+
70
+ const dynamicCommandFilePattern =
71
+ /^\[([a-z][a-zA-Z0-9]*):(string|number|boolean)(?:=([^\]]*))?\]\.tsx?$/;
72
+
73
+ function parseCommandFile(value: string): ParsedCommandFile | undefined {
74
+ if (/^[a-z0-9][a-z0-9-]*\.tsx?$/.test(value)) {
75
+ return { localSegment: parse(value).name };
76
+ }
77
+ const match = dynamicCommandFilePattern.exec(value);
78
+ if (match) {
79
+ const [, name, type, rawDefault] = match as RegExpExecArray & {
80
+ readonly 1: string;
81
+ readonly 2: CommandParameterType;
82
+ };
83
+ // Metadata can change during HMR while $name keeps the Capability identity stable.
84
+ const parameter = rawDefault === undefined
85
+ ? { name, type }
86
+ : { name, type, defaultValue: parseParameterValue(name, type, rawDefault, value) };
87
+ return { localSegment: `$${name}`, parameter };
88
+ }
89
+ if (value.startsWith('[') || value.includes(']')) {
90
+ throw new CommandPathSyntaxError(value);
91
+ }
92
+ return undefined;
93
+ }
94
+
95
+ function parseParameterValue(
96
+ name: string,
97
+ type: CommandParameterType,
98
+ value: string,
99
+ source: string,
100
+ ): CommandParameterValue {
101
+ if (type === 'string') return value;
102
+ if (type === 'number') {
103
+ const number = Number(value);
104
+ if (value.trim().length > 0 && Number.isFinite(number)) return number;
105
+ } else if (value === 'true' || value === 'false') {
106
+ return value === 'true';
107
+ }
108
+ throw new CommandPathSyntaxError(
109
+ source,
110
+ `default for ${name}:${type} is invalid`,
111
+ );
112
+ }
113
+
114
+ export class CommandPathSyntaxError extends TypeError {
115
+ constructor(file: string, detail = 'expected [name:string|number|boolean=default].ts(x)') {
116
+ super(`Invalid Command path ${file}: ${detail}`);
117
+ this.name = 'CommandPathSyntaxError';
118
+ }
119
+ }
120
+
121
+ const commandFeature = defineFeatureProvider({
122
+ protocol: 1,
123
+ id: commandFeatureId,
124
+ authoring: {
125
+ conventions: [commandFiles],
126
+ validate: parseCommandDefinition,
127
+ },
128
+ runtime: {
129
+ project(slots, context) {
130
+ return { value: new CommandIndex(slots, context.snapshot) };
131
+ },
132
+ },
133
+ });
134
+
135
+ export default commandFeature;