@ldlework/workmark 1.3.1 → 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;
11
84
  }
12
85
 
13
- function isStatic(def: CommandDef): def is StaticCommandDef {
14
- return "handler" in def;
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);
15
89
  }
16
90
 
17
- /** Convert a single field (Zod or raw JSON Schema property) to a JSON Schema property. */
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;
117
+ }
118
+
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;
158
+ }
159
+
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,25 +166,68 @@ function fieldToJsonSchema(field: z.ZodType | Record<string, unknown>): Record<s
22
166
  return field;
23
167
  }
24
168
 
25
- /** A field is required unless it has a default or was wrapped with .optional(). */
26
169
  function isRequired(field: z.ZodType | Record<string, unknown>, schema: Record<string, unknown>): boolean {
27
170
  if ("default" in schema) return false;
28
171
  if (field instanceof z.ZodType && field.safeParse(undefined).success) return false;
29
172
  return true;
30
173
  }
31
174
 
32
- /** Merge args + flags into a single JSON Schema object and return positional names. */
33
- function mergeSchemas(
34
- args?: SchemaFields,
35
- flags?: SchemaFields,
36
- ): { inputSchema: Record<string, unknown>; positional: string[] } {
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 {
37
211
  const properties: Record<string, unknown> = {};
38
212
  const required: string[] = [];
39
213
  const positional: string[] = [];
214
+ let projectArgName: string | null = null;
40
215
 
41
- const collect = (source: SchemaFields | undefined, isPositional: boolean) => {
42
- if (!source) return;
43
- for (const [name, field] of Object.entries(source)) {
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
+ }
44
231
  const schema = fieldToJsonSchema(field);
45
232
  properties[name] = schema;
46
233
  if (isPositional) positional.push(name);
@@ -51,67 +238,376 @@ function mergeSchemas(
51
238
  collect(args, true);
52
239
  collect(flags, false);
53
240
 
54
- const inputSchema: Record<string, unknown> = {
55
- type: "object",
56
- properties,
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
+ );
336
+ }
337
+ throw new Error(
338
+ `Missing project argument — select "${select}" requires 1+ project names.\n needs: [${traitList}]\n eligible: ${eligibleList}`,
339
+ );
340
+ }
341
+
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
+ );
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);
359
+ }
360
+ return resolved;
361
+ }
362
+
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 };
57
379
  };
58
- if (required.length > 0) {
59
- 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);
60
403
  }
61
404
 
62
- return { inputSchema, positional };
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 ?? "");
409
+ }
410
+
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;
63
430
  }
64
431
 
65
- function resolve(def: CommandDef, workspace: IWorkspace, group: string, sourceFile: string): ResolvedCommand {
66
- let args: SchemaFields | undefined;
67
- let flags: SchemaFields | undefined;
68
- 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");
69
445
 
70
- if (isStatic(def)) {
71
- args = def.args;
72
- flags = def.flags;
73
- handler = def.handler;
74
- } else {
75
- const result = def.factory(workspace);
76
- args = result.args;
77
- flags = result.flags;
78
- handler = result.handler;
446
+ if (needs.length === 0 && select !== "one") {
447
+ throw new Error(`Command "${meta.name}": select requires needs (at ${sourceFile})`);
79
448
  }
80
449
 
81
- const { inputSchema, positional } = mergeSchemas(args, flags);
82
- return { name: def.name, label: def.label, group, description: def.description, inputSchema, positional, handler, sourceFile };
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
+ }
473
+ }
474
+
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;
83
533
  }
84
534
 
85
- 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[]> {
86
571
  const commandsDir = join(workspace.root, ".wm", "commands");
87
572
  if (!existsSync(commandsDir)) return [];
88
573
 
89
574
  const jiti = createJiti(commandsDir, jitiOptions());
90
575
  const commands: ResolvedCommand[] = [];
576
+ const byName = new Map<string, ResolvedCommand>();
91
577
 
92
- // Ungrouped (root-level) commands first
93
- for (const entry of readdirSync(commandsDir)) {
94
- const entryPath = join(commandsDir, entry);
95
- const stat = statSync(entryPath);
96
- if (!stat.isDirectory() && entry.endsWith(".ts") && !entry.endsWith(".d.ts")) {
97
- const mod = await jiti.import(entryPath) as { default: CommandDef };
98
- commands.push(resolve(mod.default, workspace, "", entryPath));
99
- }
100
- }
101
-
102
- // Grouped commands (subdirectories)
103
- for (const entry of readdirSync(commandsDir)) {
104
- const entryPath = join(commandsDir, entry);
105
- const stat = statSync(entryPath);
106
- if (stat.isDirectory()) {
107
- const group = titleCase(entry);
108
- for (const file of readdirSync(entryPath)) {
109
- if (!file.endsWith(".ts") || file.endsWith(".d.ts")) continue;
110
- const filePath = join(entryPath, file);
111
- const mod = await jiti.import(filePath) as { default: CommandDef };
112
- 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}`);
113
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}`);
114
608
  }
609
+ byName.set(cmd.name, cmd);
610
+ commands.push(cmd);
115
611
  }
116
612
 
117
613
  return commands;