@ldlework/workmark 1.3.0 → 1.4.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/src/lib/load.ts CHANGED
@@ -1,20 +1,164 @@
1
- import { existsSync, readdirSync, statSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { basename, extname, join } from "node:path";
3
3
  import { createJiti } from "jiti";
4
4
  import { z } from "zod";
5
- import type { CommandDef, IWorkspace, ResolvedCommand, SchemaFields, StaticCommandDef } from "./types.js";
5
+ import { execAsync, ok, fail, shSeq } from "./helpers.js";
6
+ import type {
7
+ BaseCtx,
8
+ HandlerReturn,
9
+ IProject,
10
+ IWorkspace,
11
+ NeedsCtx,
12
+ ProjectResult,
13
+ ResolvedCommand,
14
+ ResolvedHandler,
15
+ RunOptions,
16
+ SchemaFields,
17
+ SelectMode,
18
+ Trait,
19
+ } from "./types.js";
20
+ import { FROM_ARGS, FROM_WORKSPACE } from "./types.js";
21
+ import type { StaticCommandDef } from "./define.js";
22
+ import type { Workspace } from "./workspace.js";
23
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
6
24
  import { jitiOptions } from "./jiti-options.js";
7
25
 
8
- /** Title-case a folder name: "docker" → "Docker" */
26
+ // --- Metadata derivation -------------------------------------------------
27
+
9
28
  function titleCase(s: string): string {
10
- return s.charAt(0).toUpperCase() + s.slice(1);
29
+ return s
30
+ .split(/[_\s-]+/)
31
+ .filter(Boolean)
32
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
33
+ .join(" ");
34
+ }
35
+
36
+ function extractLeadingJsDoc(source: string): string | undefined {
37
+ const exportIdx = source.indexOf("export default");
38
+ const before = exportIdx < 0 ? source : source.slice(0, exportIdx);
39
+ const blocks = [...before.matchAll(/\/\*\*([\s\S]*?)\*\//g)];
40
+ if (blocks.length === 0) return undefined;
41
+ const last = blocks[blocks.length - 1][1];
42
+ return last
43
+ .split("\n")
44
+ .map((line) => line.replace(/^\s*\*\s?/, "").trimEnd())
45
+ .join(" ")
46
+ .trim() || undefined;
47
+ }
48
+
49
+ interface DerivedMeta {
50
+ name: string;
51
+ label: string;
52
+ description: string;
53
+ }
54
+
55
+ function deriveMetadata(def: StaticCommandDef, sourceFile: string): DerivedMeta {
56
+ const filenameName = basename(sourceFile, extname(sourceFile));
57
+ const meta = def.meta ?? {};
58
+ const name = meta.name ?? filenameName;
59
+ const label = meta.label ?? titleCase(name);
60
+ const description =
61
+ meta.description ?? extractLeadingJsDoc(readFileSync(sourceFile, "utf-8")) ?? "";
62
+ return { name, label, description };
63
+ }
64
+
65
+ // --- fromWorkspace / fromArgs resolution --------------------------------
66
+
67
+ type FromWsMarker = { [FROM_WORKSPACE]?: (ws: IWorkspace) => z.ZodType };
68
+ type FromArgsMarker = { [FROM_ARGS]?: (ws: IWorkspace, args: Record<string, unknown>) => z.ZodType };
69
+
70
+ /** Detect whether a schema (or any of its wrappers) carries a FROM_ARGS marker.
71
+ * Such fields are resolved at invocation time, not load time. */
72
+ function hasFromArgs(field: z.ZodType | Record<string, unknown>): boolean {
73
+ if (!(field instanceof z.ZodType)) return false;
74
+ return walkForFromArgs(field);
75
+ }
76
+
77
+ function walkForFromArgs(node: z.ZodType): boolean {
78
+ if ((node as unknown as FromArgsMarker)[FROM_ARGS]) return true;
79
+ if (node instanceof z.ZodOptional) return walkForFromArgs(node.unwrap() as unknown as z.ZodType);
80
+ if (node instanceof z.ZodNullable) return walkForFromArgs(node.unwrap() as unknown as z.ZodType);
81
+ if (node instanceof z.ZodDefault) return walkForFromArgs(node.unwrap() as unknown as z.ZodType);
82
+ if (node instanceof z.ZodArray) return walkForFromArgs(node.element as unknown as z.ZodType);
83
+ return false;
84
+ }
85
+
86
+ function resolveFromWorkspace(field: z.ZodType | Record<string, unknown>, ws: IWorkspace): z.ZodType | Record<string, unknown> {
87
+ if (!(field instanceof z.ZodType)) return field;
88
+ return walk(field, ws);
89
+ }
90
+
91
+ function walk(node: z.ZodType, ws: IWorkspace): z.ZodType {
92
+ // Leave FROM_ARGS markers alone — they resolve per-invocation.
93
+ if ((node as unknown as FromArgsMarker)[FROM_ARGS]) return node;
94
+
95
+ const marked = (node as unknown as FromWsMarker)[FROM_WORKSPACE];
96
+ if (marked) return marked(ws);
97
+
98
+ if (node instanceof z.ZodOptional) return walk(node.unwrap() as unknown as z.ZodType, ws).optional();
99
+ if (node instanceof z.ZodNullable) return walk(node.unwrap() as unknown as z.ZodType, ws).nullable();
100
+ if (node instanceof z.ZodDefault) {
101
+ const def = (node._def as { defaultValue: unknown }).defaultValue;
102
+ return walk(node.unwrap() as unknown as z.ZodType, ws).default(def as never);
103
+ }
104
+ if (node instanceof z.ZodArray) {
105
+ return z.array(walk(node.element as unknown as z.ZodType, ws));
106
+ }
107
+ return node;
108
+ }
109
+
110
+ function resolveFields(fields: SchemaFields | undefined, ws: IWorkspace): SchemaFields | undefined {
111
+ if (!fields) return fields;
112
+ const out: SchemaFields = {};
113
+ for (const [k, v] of Object.entries(fields)) {
114
+ out[k] = resolveFromWorkspace(v, ws);
115
+ }
116
+ return out;
11
117
  }
12
118
 
13
- function isStatic(def: CommandDef): def is StaticCommandDef {
14
- return "handler" in def;
119
+ /** Resolve FROM_ARGS markers in a walked schema tree against a specific args object. */
120
+ function walkWithArgs(node: z.ZodType, ws: IWorkspace, args: Record<string, unknown>): z.ZodType {
121
+ const marked = (node as unknown as FromArgsMarker)[FROM_ARGS];
122
+ if (marked) return marked(ws, args);
123
+ if (node instanceof z.ZodOptional) return walkWithArgs(node.unwrap() as unknown as z.ZodType, ws, args).optional();
124
+ if (node instanceof z.ZodNullable) return walkWithArgs(node.unwrap() as unknown as z.ZodType, ws, args).nullable();
125
+ if (node instanceof z.ZodDefault) {
126
+ const def = (node._def as { defaultValue: unknown }).defaultValue;
127
+ return walkWithArgs(node.unwrap() as unknown as z.ZodType, ws, args).default(def as never);
128
+ }
129
+ if (node instanceof z.ZodArray) {
130
+ return z.array(walkWithArgs(node.element as unknown as z.ZodType, ws, args));
131
+ }
132
+ return node;
133
+ }
134
+
135
+ /** Per-invocation: validate any FROM_ARGS fields against the resolved schema.
136
+ * Returns the parsed args (with fromArgs fields replaced by validated values). */
137
+ function validateFromArgsFields(
138
+ args: Record<string, unknown>,
139
+ fromArgsFields: Array<{ name: string; schema: z.ZodType }>,
140
+ ws: IWorkspace,
141
+ ): Record<string, unknown> {
142
+ if (fromArgsFields.length === 0) return args;
143
+ const out = { ...args };
144
+ for (const { name, schema } of fromArgsFields) {
145
+ const resolved = walkWithArgs(schema, ws, args);
146
+ const raw = args[name];
147
+ if (raw === undefined) continue;
148
+ const result = resolved.safeParse(raw);
149
+ if (!result.success) {
150
+ const issues = result.error.issues
151
+ .map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`)
152
+ .join("\n");
153
+ throw new Error(`Field "${name}" failed validation:\n${issues}`);
154
+ }
155
+ out[name] = result.data;
156
+ }
157
+ return out;
15
158
  }
16
159
 
17
- /** Convert a single field (Zod or raw JSON Schema property) to a JSON Schema property. */
160
+ // --- Schema merging + project-arg synthesis ------------------------------
161
+
18
162
  function fieldToJsonSchema(field: z.ZodType | Record<string, unknown>): Record<string, unknown> {
19
163
  if (field instanceof z.ZodType) {
20
164
  return z.toJSONSchema(field) as Record<string, unknown>;
@@ -22,100 +166,448 @@ function fieldToJsonSchema(field: z.ZodType | Record<string, unknown>): Record<s
22
166
  return field;
23
167
  }
24
168
 
25
- /** Merge args + flags into a single JSON Schema object and return positional names. */
26
- function mergeSchemas(
27
- args?: SchemaFields,
28
- flags?: SchemaFields,
29
- ): { inputSchema: Record<string, unknown>; positional: string[] } {
169
+ function isRequired(field: z.ZodType | Record<string, unknown>, schema: Record<string, unknown>): boolean {
170
+ if ("default" in schema) return false;
171
+ if (field instanceof z.ZodType && field.safeParse(undefined).success) return false;
172
+ return true;
173
+ }
174
+
175
+ interface BuiltSchema {
176
+ inputSchema: Record<string, unknown>;
177
+ positional: string[];
178
+ projectArgName: string | null;
179
+ }
180
+
181
+ function projectArgFor(
182
+ needs: readonly Trait[],
183
+ select: SelectMode,
184
+ workspace: IWorkspace,
185
+ ): { schema: z.ZodType; required: boolean; eligible: string[] } | null {
186
+ if (needs.length === 0) return null;
187
+ if (select === "all") return null; // framework selects; no user input
188
+
189
+ const eligible = workspace.projects
190
+ .filter((p) => needs.every((t) => p.hasTrait(t)))
191
+ .map((p) => p.name);
192
+ if (eligible.length === 0) {
193
+ const traits = needs.map((t) => t.name).join(", ");
194
+ throw new Error(
195
+ `Command requires needs [${traits}] but no projects fulfill all of them`,
196
+ );
197
+ }
198
+ const enum_ = z.enum(eligible as [string, ...string[]]);
199
+ if (select === "one-or-many") {
200
+ // accept string or array; we'll coerce at parse time
201
+ return { schema: z.union([enum_, z.array(enum_)]), required: false, eligible };
202
+ }
203
+ return { schema: enum_, required: true, eligible };
204
+ }
205
+
206
+ function buildSchema(
207
+ args: SchemaFields | undefined,
208
+ flags: SchemaFields | undefined,
209
+ projectArg: { schema: z.ZodType; required: boolean } | null,
210
+ ): BuiltSchema {
30
211
  const properties: Record<string, unknown> = {};
31
212
  const required: string[] = [];
32
213
  const positional: string[] = [];
214
+ let projectArgName: string | null = null;
33
215
 
34
- // Args are positional, in key order
35
- if (args) {
36
- for (const [name, field] of Object.entries(args)) {
216
+ if (projectArg) {
217
+ projectArgName = "project";
218
+ properties.project = fieldToJsonSchema(projectArg.schema);
219
+ positional.push("project");
220
+ if (projectArg.required) required.push("project");
221
+ }
222
+
223
+ const collect = (src: SchemaFields | undefined, isPositional: boolean) => {
224
+ if (!src) return;
225
+ for (const [name, field] of Object.entries(src)) {
226
+ if (name === "project" && projectArgName) {
227
+ throw new Error(
228
+ `Field "project" collides with framework-injected ctx.project.\n hint: rename the field; ctx.project is always present under needs`,
229
+ );
230
+ }
37
231
  const schema = fieldToJsonSchema(field);
38
232
  properties[name] = schema;
39
- positional.push(name);
40
- // If no default and not explicitly optional, it's required
41
- if (!("default" in schema)) {
42
- required.push(name);
43
- }
233
+ if (isPositional) positional.push(name);
234
+ if (isRequired(field, schema)) required.push(name);
235
+ }
236
+ };
237
+
238
+ collect(args, true);
239
+ collect(flags, false);
240
+
241
+ const inputSchema: Record<string, unknown> = { type: "object", properties };
242
+ if (required.length > 0) inputSchema.required = required;
243
+ return { inputSchema, positional, projectArgName };
244
+ }
245
+
246
+ // --- Context construction ------------------------------------------------
247
+
248
+ /** Invocation host: looks up commands for ctx.invoke. Set during loadCommands. */
249
+ interface InvocationHost {
250
+ invoke: (name: string, args: Record<string, unknown>) => Promise<CallToolResult>;
251
+ }
252
+
253
+ function makeBaseCtx(
254
+ ws: IWorkspace,
255
+ cwdResolver: () => string,
256
+ host: InvocationHost,
257
+ ): BaseCtx {
258
+ return {
259
+ workspace: ws,
260
+ ok,
261
+ fail,
262
+ sh: (cmd, opts) => {
263
+ const options = { cwd: cwdResolver(), timeout: opts?.timeout, env: opts?.env };
264
+ return Array.isArray(cmd)
265
+ ? shSeq(cmd, options)
266
+ : execAsync(cmd as string, options);
267
+ },
268
+ exec: (cmd, opts) => execAsync(cmd, opts),
269
+ invoke: host.invoke,
270
+ };
271
+ }
272
+
273
+ function makeNeedsCtx(
274
+ ws: IWorkspace,
275
+ project: IProject,
276
+ needs: readonly Trait[],
277
+ host: InvocationHost,
278
+ ): NeedsCtx<Record<string, unknown>> {
279
+ const traits: Record<string, unknown> = {};
280
+ for (const t of needs) traits[t.name] = project.trait(t as Trait<string, unknown>);
281
+ return {
282
+ ...makeBaseCtx(ws, () => project.dir, host),
283
+ project,
284
+ traits,
285
+ };
286
+ }
287
+
288
+ // --- Handler wrapping ----------------------------------------------------
289
+
290
+ function stripProjectArg(args: Record<string, unknown>): Record<string, unknown> {
291
+ const { project: _drop, ...rest } = args;
292
+ return rest;
293
+ }
294
+
295
+ function isCallToolResult(v: unknown): v is CallToolResult {
296
+ return typeof v === "object" && v !== null && "content" in (v as object);
297
+ }
298
+
299
+ async function runHandler(
300
+ raw: StaticCommandDef["handler"],
301
+ args: Record<string, unknown>,
302
+ ctx: BaseCtx | NeedsCtx<Record<string, unknown>>,
303
+ ): Promise<CallToolResult> {
304
+ try {
305
+ const out = await raw(args, ctx);
306
+ if (isCallToolResult(out)) return out;
307
+ return { content: [{ type: "text", text: String(out) }] };
308
+ } catch (e) {
309
+ return fail(e);
310
+ }
311
+ }
312
+
313
+ function resolveProjects(
314
+ needs: readonly Trait[],
315
+ select: SelectMode,
316
+ workspace: IWorkspace,
317
+ argsValue: unknown,
318
+ ): IProject[] {
319
+ const eligible = workspace.projects.filter((p) => needs.every((t) => p.hasTrait(t)));
320
+ const eligibleList = eligible.map((p) => p.name).join(", ");
321
+ const traitList = needs.map((t) => t.name).join(", ");
322
+
323
+ if (select === "all") return eligible;
324
+
325
+ const names: string[] = Array.isArray(argsValue)
326
+ ? (argsValue as string[])
327
+ : typeof argsValue === "string"
328
+ ? [argsValue]
329
+ : [];
330
+
331
+ if (names.length === 0) {
332
+ if (select === "one") {
333
+ throw new Error(
334
+ `Missing project argument.\n needs: [${traitList}]\n eligible: ${eligibleList}`,
335
+ );
44
336
  }
337
+ throw new Error(
338
+ `Missing project argument — select "${select}" requires 1+ project names.\n needs: [${traitList}]\n eligible: ${eligibleList}`,
339
+ );
45
340
  }
46
341
 
47
- // Flags are named (--key value)
48
- if (flags) {
49
- for (const [name, field] of Object.entries(flags)) {
50
- const schema = fieldToJsonSchema(field);
51
- properties[name] = schema;
52
- if (!("default" in schema)) {
53
- required.push(name);
54
- }
342
+ const resolved: IProject[] = [];
343
+ for (const n of names) {
344
+ let p: IProject;
345
+ try {
346
+ p = workspace.get(n);
347
+ } catch {
348
+ throw new Error(
349
+ `Unknown project "${n}".\n available: ${workspace.projects.map((p) => p.name).join(", ")}\n eligible for this command: ${eligibleList}`,
350
+ );
55
351
  }
352
+ const missing = needs.filter((t) => !p.hasTrait(t)).map((t) => t.name);
353
+ if (missing.length > 0) {
354
+ throw new Error(
355
+ `Project "${p.name}" does not fulfill required trait(s) [${missing.join(", ")}].\n eligible projects: ${eligibleList}`,
356
+ );
357
+ }
358
+ resolved.push(p);
56
359
  }
360
+ return resolved;
361
+ }
57
362
 
58
- const inputSchema: Record<string, unknown> = {
59
- type: "object",
60
- properties,
363
+ async function runMultiple(
364
+ raw: StaticCommandDef["handler"],
365
+ args: Record<string, unknown>,
366
+ projects: IProject[],
367
+ needs: readonly Trait[],
368
+ workspace: IWorkspace,
369
+ run: RunOptions | undefined,
370
+ host: InvocationHost,
371
+ ): Promise<CallToolResult> {
372
+ const callerArgs = stripProjectArg(args);
373
+
374
+ const runOne = async (p: IProject): Promise<ProjectResult> => {
375
+ const ctx = makeNeedsCtx(workspace, p, needs, host);
376
+ const res = await runHandler(raw, callerArgs, ctx);
377
+ const text = res.content.map((c) => (c.type === "text" ? c.text : "")).join("\n");
378
+ return { project: p.name, ok: !res.isError, output: text, error: res.isError ? text : undefined };
61
379
  };
62
- if (required.length > 0) {
63
- inputSchema.required = required;
380
+
381
+ // serial when requested; otherwise parallel with concurrency cap
382
+ const serial = run?.order === "serial";
383
+ const concurrency = serial ? 1 : (run?.concurrency ?? 4);
384
+ const results: ProjectResult[] = new Array(projects.length);
385
+ let idx = 0;
386
+ const workers = Array.from({ length: Math.min(concurrency, projects.length) }, async () => {
387
+ while (true) {
388
+ const i = idx++;
389
+ if (i >= projects.length) return;
390
+ results[i] = await runOne(projects[i]);
391
+ if (run?.stopOnFailure && !results[i].ok) {
392
+ idx = projects.length;
393
+ return;
394
+ }
395
+ }
396
+ });
397
+ await Promise.all(workers);
398
+
399
+ const realResults = results.filter(Boolean);
400
+
401
+ if (run?.reduce) {
402
+ return run.reduce(realResults);
403
+ }
404
+
405
+ // default aggregation — if only one ran, return its output directly
406
+ if (realResults.length === 1) {
407
+ const r = realResults[0];
408
+ return r.ok ? ok(r.output ?? "") : fail(r.error ?? r.output ?? "");
64
409
  }
65
410
 
66
- return { inputSchema, positional };
411
+ const parts: string[] = [];
412
+ let anyError = false;
413
+ for (const r of realResults) {
414
+ parts.push(`--- ${r.project} ---\n${r.output ?? ""}`);
415
+ if (!r.ok) anyError = true;
416
+ }
417
+ const text = parts.join("\n\n");
418
+ return anyError ? fail(text) : ok(text);
419
+ }
420
+
421
+ // --- Main resolve --------------------------------------------------------
422
+
423
+ function collectFromArgsFields(fields: SchemaFields | undefined): Array<{ name: string; schema: z.ZodType }> {
424
+ const out: Array<{ name: string; schema: z.ZodType }> = [];
425
+ if (!fields) return out;
426
+ for (const [name, field] of Object.entries(fields)) {
427
+ if (field instanceof z.ZodType && hasFromArgs(field)) out.push({ name, schema: field });
428
+ }
429
+ return out;
67
430
  }
68
431
 
69
- function resolve(def: CommandDef, workspace: IWorkspace, group: string, sourceFile: string): ResolvedCommand {
70
- let args: SchemaFields | undefined;
71
- let flags: SchemaFields | undefined;
72
- let handler;
432
+ function resolve(
433
+ def: StaticCommandDef,
434
+ workspace: Workspace,
435
+ group: string,
436
+ sourceFile: string,
437
+ host: InvocationHost,
438
+ ): ResolvedCommand {
439
+ const meta = deriveMetadata(def, sourceFile);
440
+ const rawNeeds = def.needs ?? [];
441
+ const needs: Trait[] = rawNeeds.map((t) =>
442
+ typeof t === "string" ? workspace.traits.require(t) : t as Trait,
443
+ );
444
+ const select: SelectMode = def.select ?? (needs.length > 0 ? "one-or-many" : "one");
445
+
446
+ if (needs.length === 0 && select !== "one") {
447
+ throw new Error(`Command "${meta.name}": select requires needs (at ${sourceFile})`);
448
+ }
73
449
 
74
- if (isStatic(def)) {
75
- args = def.args;
76
- flags = def.flags;
77
- handler = def.handler;
78
- } else {
79
- const result = def.factory(workspace);
80
- args = result.args;
81
- flags = result.flags;
82
- handler = result.handler;
450
+ // `for` binds this command to a named project. No project arg is exposed on
451
+ // any surface; ctx.project is the bound project.
452
+ let boundProject: IProject | null = null;
453
+ if (def.for !== undefined) {
454
+ if (needs.length === 0) {
455
+ throw new Error(
456
+ `Command "${meta.name}": \`for\` requires \`needs\` (a fixed project without required traits is meaningless)\n at ${sourceFile}`,
457
+ );
458
+ }
459
+ try {
460
+ boundProject = workspace.get(def.for);
461
+ } catch {
462
+ throw new Error(
463
+ `Command "${meta.name}": \`for: "${def.for}"\` — project not found\n at ${sourceFile}\n available: ${workspace.projects.map(p => p.name).join(", ")}`,
464
+ );
465
+ }
466
+ for (const t of needs) {
467
+ if (!boundProject.hasTrait(t)) {
468
+ throw new Error(
469
+ `Command "${meta.name}": bound project "${def.for}" does not fulfill trait "${t.name}"\n at ${sourceFile}`,
470
+ );
471
+ }
472
+ }
83
473
  }
84
474
 
85
- const { inputSchema, positional } = mergeSchemas(args, flags);
86
- return { name: def.name, label: def.label, group, description: def.description, inputSchema, positional, handler, sourceFile };
475
+ const resolvedArgs = resolveFields(def.args, workspace);
476
+ const resolvedFlags = resolveFields(def.flags, workspace);
477
+
478
+ // Collect invocation-time-resolved fields for per-call validation.
479
+ const fromArgsFields = [
480
+ ...collectFromArgsFields(resolvedArgs),
481
+ ...collectFromArgsFields(resolvedFlags),
482
+ ];
483
+
484
+ // `for` suppresses the user-facing project arg.
485
+ const projectArg = boundProject ? null : projectArgFor(needs, select, workspace);
486
+ const { inputSchema, positional } = buildSchema(resolvedArgs, resolvedFlags, projectArg);
487
+
488
+ const handler: ResolvedHandler = async (args) => {
489
+ const validated = validateFromArgsFields(args, fromArgsFields, workspace);
490
+
491
+ if (needs.length === 0) {
492
+ const ctx = makeBaseCtx(workspace, () => workspace.root, host);
493
+ return runHandler(def.handler, validated, ctx);
494
+ }
495
+
496
+ if (boundProject) {
497
+ const ctx = makeNeedsCtx(workspace, boundProject, needs, host);
498
+ return runHandler(def.handler, validated, ctx);
499
+ }
500
+
501
+ const projects = resolveProjects(needs, select, workspace, validated.project);
502
+
503
+ if (select === "one") {
504
+ const ctx = makeNeedsCtx(workspace, projects[0], needs, host);
505
+ return runHandler(def.handler, stripProjectArg(validated), ctx);
506
+ }
507
+
508
+ return runMultiple(def.handler, validated, projects, needs, workspace, def.run, host);
509
+ };
510
+
511
+ return {
512
+ name: meta.name,
513
+ label: meta.label,
514
+ group,
515
+ description: meta.description,
516
+ inputSchema,
517
+ positional,
518
+ handler,
519
+ sourceFile,
520
+ select,
521
+ needs: needs.map((t) => t.name),
522
+ };
523
+ }
524
+
525
+ // --- Discovery -----------------------------------------------------------
526
+
527
+ async function importCommand(
528
+ jiti: ReturnType<typeof createJiti>,
529
+ filePath: string,
530
+ ): Promise<StaticCommandDef | undefined> {
531
+ const mod = (await jiti.import(filePath)) as { default?: StaticCommandDef };
532
+ return mod.default;
87
533
  }
88
534
 
89
- export async function loadCommands(workspace: IWorkspace): Promise<ResolvedCommand[]> {
535
+ function isCommandFile(name: string): boolean {
536
+ return name.endsWith(".ts") && !name.endsWith(".d.ts");
537
+ }
538
+
539
+ interface Discovered {
540
+ filePath: string;
541
+ resolvedName: string; // colon-joined path relative to commands root (minus extension)
542
+ group: string;
543
+ }
544
+
545
+ /** Walk the commands dir recursively. Maps paths to colon-joined command names. */
546
+ function walkCommands(root: string): Discovered[] {
547
+ const out: Discovered[] = [];
548
+ function walk(dir: string, prefix: string[]): void {
549
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
550
+ const full = join(dir, entry.name);
551
+ if (entry.isDirectory()) {
552
+ walk(full, [...prefix, entry.name]);
553
+ } else if (isCommandFile(entry.name)) {
554
+ const base = entry.name.replace(/\.ts$/, "");
555
+ if (base.includes(":")) {
556
+ throw new Error(`Command filename contains ':' which is reserved: ${full}`);
557
+ }
558
+ const parts = base === "index" ? prefix : [...prefix, base];
559
+ if (parts.length === 0) continue; // bare root index.ts not allowed
560
+ const resolvedName = parts.join(":");
561
+ const group = prefix.length > 0 ? titleCase(prefix[0]) : "";
562
+ out.push({ filePath: full, resolvedName, group });
563
+ }
564
+ }
565
+ }
566
+ walk(root, []);
567
+ return out;
568
+ }
569
+
570
+ export async function loadCommands(workspace: Workspace): Promise<ResolvedCommand[]> {
90
571
  const commandsDir = join(workspace.root, ".wm", "commands");
91
572
  if (!existsSync(commandsDir)) return [];
92
573
 
93
574
  const jiti = createJiti(commandsDir, jitiOptions());
94
575
  const commands: ResolvedCommand[] = [];
576
+ const byName = new Map<string, ResolvedCommand>();
95
577
 
96
- // Ungrouped (root-level) commands first
97
- for (const entry of readdirSync(commandsDir)) {
98
- const entryPath = join(commandsDir, entry);
99
- const stat = statSync(entryPath);
100
- if (!stat.isDirectory() && entry.endsWith(".ts") && !entry.endsWith(".d.ts")) {
101
- const mod = await jiti.import(entryPath) as { default: CommandDef };
102
- commands.push(resolve(mod.default, workspace, "", entryPath));
103
- }
104
- }
105
-
106
- // Grouped commands (subdirectories)
107
- for (const entry of readdirSync(commandsDir)) {
108
- const entryPath = join(commandsDir, entry);
109
- const stat = statSync(entryPath);
110
- if (stat.isDirectory()) {
111
- const group = titleCase(entry);
112
- for (const file of readdirSync(entryPath)) {
113
- if (!file.endsWith(".ts") || file.endsWith(".d.ts")) continue;
114
- const filePath = join(entryPath, file);
115
- const mod = await jiti.import(filePath) as { default: CommandDef };
116
- commands.push(resolve(mod.default, workspace, group, filePath));
578
+ // Invocation host: commands map is populated progressively; cycle-detected per-call.
579
+ const activeStack = new Set<string>();
580
+ const host: InvocationHost = {
581
+ invoke: async (name, args) => {
582
+ if (activeStack.has(name)) {
583
+ const trace = [...activeStack, name].join(" ");
584
+ return fail(`Cyclic invocation: ${trace}`);
117
585
  }
586
+ const target = byName.get(name);
587
+ if (!target) return fail(`Unknown command: "${name}"`);
588
+ activeStack.add(name);
589
+ try {
590
+ return await target.handler(args);
591
+ } finally {
592
+ activeStack.delete(name);
593
+ }
594
+ },
595
+ };
596
+
597
+ const discovered = walkCommands(commandsDir);
598
+ for (const d of discovered) {
599
+ const def = await importCommand(jiti, d.filePath);
600
+ if (!def) continue;
601
+ const cmd = resolve(def, workspace, d.group, d.filePath, host);
602
+ // Override resolved name from path-derived name (filename was the fallback in meta).
603
+ // Meta.name wins if explicitly set; otherwise use the discovered name.
604
+ const userProvidedName = def.meta?.name;
605
+ if (!userProvidedName) cmd.name = d.resolvedName;
606
+ if (byName.has(cmd.name)) {
607
+ throw new Error(`Duplicate command name "${cmd.name}": ${d.filePath} collides with ${byName.get(cmd.name)!.sourceFile}`);
118
608
  }
609
+ byName.set(cmd.name, cmd);
610
+ commands.push(cmd);
119
611
  }
120
612
 
121
613
  return commands;