@ldlework/workmark 1.5.0 → 1.6.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/dist/cli.js CHANGED
@@ -78,6 +78,7 @@ function toCommandMeta(cmd) {
78
78
  inputSchema: cmd.inputSchema,
79
79
  positional: cmd.positional,
80
80
  sourceFile: cmd.sourceFile ?? null,
81
+ interactive: cmd.interactive,
81
82
  };
82
83
  }
83
84
  // ---------------------------------------------------------------------------
@@ -86,7 +87,7 @@ function toCommandMeta(cmd) {
86
87
  async function main() {
87
88
  const [command, ...rest] = process.argv.slice(2);
88
89
  const workspace = await loadWorkspace();
89
- const commands = await loadCommands(workspace);
90
+ const commands = await loadCommands(workspace, { surface: "cli" });
90
91
  if (command === "--introspect") {
91
92
  process.stdout.write(JSON.stringify(commands.map(toCommandMeta)) + "\n");
92
93
  return;
package/dist/index.js CHANGED
@@ -7,11 +7,14 @@ import { loadWorkspace } from "./lib/workspace.js";
7
7
  async function main() {
8
8
  const server = new Server({ name: "workmark", version: "1.0.0" }, { capabilities: { tools: {} } });
9
9
  const workspace = await loadWorkspace();
10
- const commands = await loadCommands(workspace);
11
- const handlers = new Map(commands.map((c) => [c.name, c.handler]));
12
- console.error(`Loaded ${commands.length} commands from ${workspace.projects.length} projects`);
10
+ const commands = await loadCommands(workspace, { surface: "mcp" });
11
+ // Interactive commands own a terminal an MCP client has none to give.
12
+ const exposed = commands.filter((c) => !c.interactive);
13
+ const handlers = new Map(exposed.map((c) => [c.name, c.handler]));
14
+ const interactiveNames = new Set(commands.filter((c) => c.interactive).map((c) => c.name));
15
+ console.error(`Loaded ${exposed.length} commands from ${workspace.projects.length} projects`);
13
16
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
14
- tools: commands.map(({ name, description, inputSchema }) => ({
17
+ tools: exposed.map(({ name, description, inputSchema }) => ({
15
18
  name,
16
19
  description,
17
20
  inputSchema,
@@ -20,6 +23,9 @@ async function main() {
20
23
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
21
24
  const handler = handlers.get(request.params.name);
22
25
  if (!handler) {
26
+ if (interactiveNames.has(request.params.name)) {
27
+ throw new McpError(ErrorCode.InvalidRequest, `"${request.params.name}" is an interactive command (long-running, terminal-owning) — run it from a terminal: wm ${request.params.name}`);
28
+ }
23
29
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
24
30
  }
25
31
  return handler(request.params.arguments ?? {});
@@ -20,6 +20,11 @@ export interface StaticCommandDef {
20
20
  select?: SelectMode;
21
21
  for?: string;
22
22
  run?: RunOptions;
23
+ /** Long-running command that owns the terminal (dev server, watcher).
24
+ * On the CLI, `ctx.sh` switches to inherited stdio with no timeout and
25
+ * resolves when the child exits. Not exposed over MCP. With `needs`,
26
+ * implies `select: "one"`. */
27
+ interactive?: boolean;
23
28
  args?: SchemaFields;
24
29
  flags?: SchemaFields;
25
30
  meta?: {
@@ -40,6 +45,7 @@ export declare function cmd<const N extends readonly Trait<string, unknown>[] =
40
45
  select?: SelectMode;
41
46
  for?: string;
42
47
  run?: RunOptions;
48
+ interactive?: boolean;
43
49
  args?: A;
44
50
  flags?: F;
45
51
  meta?: {
@@ -12,3 +12,12 @@ export declare function execAsyncRaw(command: string, opts: ExecOptions): Promis
12
12
  /** Run a sequence of shell commands. Fails fast; concatenates outputs. */
13
13
  export declare function shSeq(commands: readonly string[], opts: ExecOptions): Promise<CallToolResult>;
14
14
  export declare function execAsync(command: string, opts: ExecOptions): Promise<CallToolResult>;
15
+ /** Run a command with the terminal handed to the child: stdio inherited, no
16
+ * timeout, no output capture. Resolves when the child exits. Used for
17
+ * `interactive: true` commands — dev servers, watchers, REPLs.
18
+ *
19
+ * A null exit code (the child died to a signal, e.g. the user's Ctrl-C)
20
+ * counts as success: stopping a dev server is the normal way it ends. */
21
+ export declare function execInteractive(command: string, opts: Pick<ExecOptions, "cwd" | "env">): Promise<CallToolResult>;
22
+ /** Run a sequence of commands interactively. Fails fast. */
23
+ export declare function shSeqInteractive(commands: readonly string[], opts: Pick<ExecOptions, "cwd" | "env">): Promise<CallToolResult>;
@@ -1,4 +1,4 @@
1
- import { execSync, exec as cpExec } from "node:child_process";
1
+ import { execSync, exec as cpExec, spawn } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  // --- Response helpers ---
4
4
  export function ok(data) {
@@ -80,3 +80,42 @@ export async function execAsync(command, opts) {
80
80
  return fail(e);
81
81
  }
82
82
  }
83
+ // --- Interactive execution (CLI surface only) ----------------------------
84
+ /** Run a command with the terminal handed to the child: stdio inherited, no
85
+ * timeout, no output capture. Resolves when the child exits. Used for
86
+ * `interactive: true` commands — dev servers, watchers, REPLs.
87
+ *
88
+ * A null exit code (the child died to a signal, e.g. the user's Ctrl-C)
89
+ * counts as success: stopping a dev server is the normal way it ends. */
90
+ export function execInteractive(command, opts) {
91
+ return new Promise((resolve) => {
92
+ const child = spawn(command, {
93
+ cwd: opts.cwd,
94
+ shell: true,
95
+ stdio: "inherit",
96
+ env: opts.env ? { ...process.env, ...opts.env } : process.env,
97
+ });
98
+ // The child shares the terminal's foreground process group, so Ctrl-C
99
+ // reaches it directly. Ignore SIGINT in the parent while the child runs
100
+ // so wm survives long enough to report the child's exit.
101
+ const onSigint = () => { };
102
+ process.on("SIGINT", onSigint);
103
+ const done = (result) => {
104
+ process.off("SIGINT", onSigint);
105
+ resolve(result);
106
+ };
107
+ child.on("exit", (code) => {
108
+ done(code === 0 || code === null ? ok("") : fail(`exited with code ${code}`));
109
+ });
110
+ child.on("error", (e) => done(fail(e)));
111
+ });
112
+ }
113
+ /** Run a sequence of commands interactively. Fails fast. */
114
+ export async function shSeqInteractive(commands, opts) {
115
+ for (const cmd of commands) {
116
+ const res = await execInteractive(cmd, opts);
117
+ if (res.isError)
118
+ return res;
119
+ }
120
+ return ok("");
121
+ }
@@ -1,3 +1,9 @@
1
- import type { ResolvedCommand } from "./types.js";
1
+ import type { ResolvedCommand, Surface } from "./types.js";
2
2
  import type { Workspace } from "./workspace.js";
3
- export declare function loadCommands(workspace: Workspace): Promise<ResolvedCommand[]>;
3
+ export interface LoadCommandsOptions {
4
+ /** Which host is executing. Interactive commands hand the terminal to the
5
+ * child only on "cli"; elsewhere they run buffered (and MCP hides them).
6
+ * Defaults to "mcp" — the conservative surface. */
7
+ surface?: Surface;
8
+ }
9
+ export declare function loadCommands(workspace: Workspace, options?: LoadCommandsOptions): Promise<ResolvedCommand[]>;
package/dist/lib/load.js CHANGED
@@ -2,7 +2,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { basename, extname, join } from "node:path";
3
3
  import { createJiti } from "jiti";
4
4
  import { z } from "zod";
5
- import { execAsync, ok, fail, shSeq } from "./helpers.js";
5
+ import { execAsync, execInteractive, ok, fail, shSeq, shSeqInteractive } from "./helpers.js";
6
6
  import { FROM_ARGS, FROM_WORKSPACE } from "./types.js";
7
7
  import { jitiOptions } from "./jiti-options.js";
8
8
  // --- Metadata derivation -------------------------------------------------
@@ -195,13 +195,18 @@ function buildSchema(args, flags, projectArg) {
195
195
  inputSchema.required = required;
196
196
  return { inputSchema, positional, projectArgName };
197
197
  }
198
- function makeBaseCtx(ws, cwdResolver, host) {
198
+ function makeBaseCtx(ws, cwdResolver, host, interactive = false) {
199
199
  return {
200
200
  workspace: ws,
201
201
  ok,
202
202
  fail,
203
203
  sh: (cmd, opts) => {
204
204
  const options = { cwd: cwdResolver(), timeout: opts?.timeout, env: opts?.env };
205
+ if (interactive) {
206
+ return Array.isArray(cmd)
207
+ ? shSeqInteractive(cmd, options)
208
+ : execInteractive(cmd, options);
209
+ }
205
210
  return Array.isArray(cmd)
206
211
  ? shSeq(cmd, options)
207
212
  : execAsync(cmd, options);
@@ -210,12 +215,12 @@ function makeBaseCtx(ws, cwdResolver, host) {
210
215
  invoke: host.invoke,
211
216
  };
212
217
  }
213
- function makeNeedsCtx(ws, project, needs, host) {
218
+ function makeNeedsCtx(ws, project, needs, host, interactive = false) {
214
219
  const traits = {};
215
220
  for (const t of needs)
216
221
  traits[t.name] = project.trait(t);
217
222
  return {
218
- ...makeBaseCtx(ws, () => project.dir, host),
223
+ ...makeBaseCtx(ws, () => project.dir, host, interactive),
219
224
  project,
220
225
  traits,
221
226
  };
@@ -329,14 +334,21 @@ function collectFromArgsFields(fields) {
329
334
  }
330
335
  return out;
331
336
  }
332
- function resolve(def, workspace, group, sourceFile, host) {
337
+ function resolve(def, workspace, group, sourceFile, host, surface) {
333
338
  const meta = deriveMetadata(def, sourceFile);
334
339
  const rawNeeds = def.needs ?? [];
335
340
  const needs = rawNeeds.map((t) => typeof t === "string" ? workspace.traits.require(t) : t);
336
- const select = def.select ?? (needs.length > 0 ? "one-or-many" : "one");
341
+ const interactive = def.interactive === true;
342
+ // Interactive commands own the terminal — one child at a time, so with
343
+ // `needs` they default to (and require) single-project selection.
344
+ const select = def.select
345
+ ?? (needs.length > 0 ? (interactive ? "one" : "one-or-many") : "one");
337
346
  if (needs.length === 0 && select !== "one") {
338
347
  throw new Error(`Command "${meta.name}": select requires needs (at ${sourceFile})`);
339
348
  }
349
+ if (interactive && select !== "one") {
350
+ throw new Error(`Command "${meta.name}": interactive commands cannot use select "${select}" — the terminal can host one process at a time (at ${sourceFile})`);
351
+ }
340
352
  // `for` binds this command to a named project. No project arg is exposed on
341
353
  // any surface; ctx.project is the bound project.
342
354
  let boundProject = null;
@@ -366,19 +378,22 @@ function resolve(def, workspace, group, sourceFile, host) {
366
378
  // `for` suppresses the user-facing project arg.
367
379
  const projectArg = boundProject ? null : projectArgFor(needs, select, workspace);
368
380
  const { inputSchema, positional } = buildSchema(resolvedArgs, resolvedFlags, projectArg);
381
+ // The terminal handoff only exists on the CLI; other surfaces fall back to
382
+ // buffered execution (MCP additionally excludes interactive commands).
383
+ const interactiveExec = interactive && surface === "cli";
369
384
  const handler = async (args) => {
370
385
  const validated = validateFromArgsFields(args, fromArgsFields, workspace);
371
386
  if (needs.length === 0) {
372
- const ctx = makeBaseCtx(workspace, () => workspace.root, host);
387
+ const ctx = makeBaseCtx(workspace, () => workspace.root, host, interactiveExec);
373
388
  return runHandler(def.handler, validated, ctx);
374
389
  }
375
390
  if (boundProject) {
376
- const ctx = makeNeedsCtx(workspace, boundProject, needs, host);
391
+ const ctx = makeNeedsCtx(workspace, boundProject, needs, host, interactiveExec);
377
392
  return runHandler(def.handler, validated, ctx);
378
393
  }
379
394
  const projects = resolveProjects(needs, select, workspace, validated.project);
380
395
  if (select === "one") {
381
- const ctx = makeNeedsCtx(workspace, projects[0], needs, host);
396
+ const ctx = makeNeedsCtx(workspace, projects[0], needs, host, interactiveExec);
382
397
  return runHandler(def.handler, stripProjectArg(validated), ctx);
383
398
  }
384
399
  return runMultiple(def.handler, validated, projects, needs, workspace, def.run, host);
@@ -394,6 +409,7 @@ function resolve(def, workspace, group, sourceFile, host) {
394
409
  sourceFile,
395
410
  select,
396
411
  needs: needs.map((t) => t.name),
412
+ interactive,
397
413
  };
398
414
  }
399
415
  // --- Discovery -----------------------------------------------------------
@@ -430,7 +446,8 @@ function walkCommands(root) {
430
446
  walk(root, []);
431
447
  return out;
432
448
  }
433
- export async function loadCommands(workspace) {
449
+ export async function loadCommands(workspace, options) {
450
+ const surface = options?.surface ?? "mcp";
434
451
  const commandsDir = join(workspace.root, ".wm", "commands");
435
452
  if (!existsSync(commandsDir))
436
453
  return [];
@@ -462,7 +479,7 @@ export async function loadCommands(workspace) {
462
479
  const def = await importCommand(jiti, d.filePath);
463
480
  if (!def)
464
481
  continue;
465
- const cmd = resolve(def, workspace, d.group, d.filePath, host);
482
+ const cmd = resolve(def, workspace, d.group, d.filePath, host, surface);
466
483
  // Override resolved name from path-derived name (filename was the fallback in meta).
467
484
  // Meta.name wins if explicitly set; otherwise use the discovered name.
468
485
  const userProvidedName = def.meta?.name;
@@ -52,6 +52,10 @@ export interface IWorkspace {
52
52
  }
53
53
  /** How many projects a command runs against. */
54
54
  export type SelectMode = "one" | "one-or-many" | "all";
55
+ /** Which host is executing commands. Interactive commands hand the terminal
56
+ * to the child process — only the CLI can do that; the MCP server excludes
57
+ * them from its tool list. */
58
+ export type Surface = "cli" | "mcp";
55
59
  /** Framework-standard context passed to every handler. */
56
60
  export interface BaseCtx {
57
61
  workspace: IWorkspace;
@@ -91,6 +95,9 @@ export interface ResolvedCommand {
91
95
  select: SelectMode;
92
96
  /** Names of traits this command requires. Empty array = no needs. */
93
97
  needs: string[];
98
+ /** Long-running command that owns the terminal while it runs (dev server,
99
+ * watcher). CLI-only; the MCP server does not expose it. */
100
+ interactive: boolean;
94
101
  }
95
102
  export type ResolvedHandler = (args: Record<string, unknown>) => Promise<CallToolResult>;
96
103
  /** Per-project result shape for multi-target commands. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ldlework/workmark",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "type": "module",
5
5
  "main": "dist/lib/load.js",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -92,6 +92,9 @@ interface CommandMetaJson {
92
92
  inputSchema: unknown;
93
93
  positional: string[];
94
94
  sourceFile: string | null;
95
+ /** Long-running terminal-owning command — hosts should run it in a real
96
+ * terminal rather than capturing its output. */
97
+ interactive: boolean;
95
98
  }
96
99
 
97
100
  function toCommandMeta(cmd: ResolvedCommand): CommandMetaJson {
@@ -103,6 +106,7 @@ function toCommandMeta(cmd: ResolvedCommand): CommandMetaJson {
103
106
  inputSchema: cmd.inputSchema,
104
107
  positional: cmd.positional,
105
108
  sourceFile: cmd.sourceFile ?? null,
109
+ interactive: cmd.interactive,
106
110
  };
107
111
  }
108
112
 
@@ -114,7 +118,7 @@ async function main(): Promise<void> {
114
118
  const [command, ...rest] = process.argv.slice(2);
115
119
 
116
120
  const workspace = await loadWorkspace();
117
- const commands = await loadCommands(workspace);
121
+ const commands = await loadCommands(workspace, { surface: "cli" });
118
122
 
119
123
  if (command === "--introspect") {
120
124
  process.stdout.write(JSON.stringify(commands.map(toCommandMeta)) + "\n");
package/src/index.ts CHANGED
@@ -18,13 +18,18 @@ async function main(): Promise<void> {
18
18
  );
19
19
 
20
20
  const workspace = await loadWorkspace();
21
- const commands = await loadCommands(workspace);
22
- const handlers = new Map(commands.map((c) => [c.name, c.handler]));
21
+ const commands = await loadCommands(workspace, { surface: "mcp" });
22
+ // Interactive commands own a terminal an MCP client has none to give.
23
+ const exposed = commands.filter((c) => !c.interactive);
24
+ const handlers = new Map(exposed.map((c) => [c.name, c.handler]));
25
+ const interactiveNames = new Set(
26
+ commands.filter((c) => c.interactive).map((c) => c.name),
27
+ );
23
28
 
24
- console.error(`Loaded ${commands.length} commands from ${workspace.projects.length} projects`);
29
+ console.error(`Loaded ${exposed.length} commands from ${workspace.projects.length} projects`);
25
30
 
26
31
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
27
- tools: commands.map(({ name, description, inputSchema }) => ({
32
+ tools: exposed.map(({ name, description, inputSchema }) => ({
28
33
  name,
29
34
  description,
30
35
  inputSchema,
@@ -34,6 +39,12 @@ async function main(): Promise<void> {
34
39
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
35
40
  const handler = handlers.get(request.params.name);
36
41
  if (!handler) {
42
+ if (interactiveNames.has(request.params.name)) {
43
+ throw new McpError(
44
+ ErrorCode.InvalidRequest,
45
+ `"${request.params.name}" is an interactive command (long-running, terminal-owning) — run it from a terminal: wm ${request.params.name}`,
46
+ );
47
+ }
37
48
  throw new McpError(
38
49
  ErrorCode.MethodNotFound,
39
50
  `Unknown tool: ${request.params.name}`,
package/src/lib/define.ts CHANGED
@@ -63,6 +63,11 @@ export interface StaticCommandDef {
63
63
  select?: SelectMode;
64
64
  for?: string;
65
65
  run?: RunOptions;
66
+ /** Long-running command that owns the terminal (dev server, watcher).
67
+ * On the CLI, `ctx.sh` switches to inherited stdio with no timeout and
68
+ * resolves when the child exits. Not exposed over MCP. With `needs`,
69
+ * implies `select: "one"`. */
70
+ interactive?: boolean;
66
71
  args?: SchemaFields;
67
72
  flags?: SchemaFields;
68
73
  meta?: { name?: string; label?: string; description?: string };
@@ -84,6 +89,7 @@ export function cmd<
84
89
  select?: SelectMode;
85
90
  for?: string;
86
91
  run?: RunOptions;
92
+ interactive?: boolean;
87
93
  args?: A;
88
94
  flags?: F;
89
95
  meta?: { name?: string; label?: string; description?: string };
@@ -1,4 +1,4 @@
1
- import { execSync, exec as cpExec } from "node:child_process";
1
+ import { execSync, exec as cpExec, spawn } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
4
4
 
@@ -104,3 +104,50 @@ export async function execAsync(
104
104
  return fail(e);
105
105
  }
106
106
  }
107
+
108
+ // --- Interactive execution (CLI surface only) ----------------------------
109
+
110
+ /** Run a command with the terminal handed to the child: stdio inherited, no
111
+ * timeout, no output capture. Resolves when the child exits. Used for
112
+ * `interactive: true` commands — dev servers, watchers, REPLs.
113
+ *
114
+ * A null exit code (the child died to a signal, e.g. the user's Ctrl-C)
115
+ * counts as success: stopping a dev server is the normal way it ends. */
116
+ export function execInteractive(
117
+ command: string,
118
+ opts: Pick<ExecOptions, "cwd" | "env">,
119
+ ): Promise<CallToolResult> {
120
+ return new Promise((resolve) => {
121
+ const child = spawn(command, {
122
+ cwd: opts.cwd,
123
+ shell: true,
124
+ stdio: "inherit",
125
+ env: opts.env ? { ...process.env, ...opts.env } : process.env,
126
+ });
127
+ // The child shares the terminal's foreground process group, so Ctrl-C
128
+ // reaches it directly. Ignore SIGINT in the parent while the child runs
129
+ // so wm survives long enough to report the child's exit.
130
+ const onSigint = () => {};
131
+ process.on("SIGINT", onSigint);
132
+ const done = (result: CallToolResult) => {
133
+ process.off("SIGINT", onSigint);
134
+ resolve(result);
135
+ };
136
+ child.on("exit", (code) => {
137
+ done(code === 0 || code === null ? ok("") : fail(`exited with code ${code}`));
138
+ });
139
+ child.on("error", (e) => done(fail(e)));
140
+ });
141
+ }
142
+
143
+ /** Run a sequence of commands interactively. Fails fast. */
144
+ export async function shSeqInteractive(
145
+ commands: readonly string[],
146
+ opts: Pick<ExecOptions, "cwd" | "env">,
147
+ ): Promise<CallToolResult> {
148
+ for (const cmd of commands) {
149
+ const res = await execInteractive(cmd, opts);
150
+ if (res.isError) return res;
151
+ }
152
+ return ok("");
153
+ }
package/src/lib/load.ts CHANGED
@@ -2,7 +2,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { basename, extname, join } from "node:path";
3
3
  import { createJiti } from "jiti";
4
4
  import { z } from "zod";
5
- import { execAsync, ok, fail, shSeq } from "./helpers.js";
5
+ import { execAsync, execInteractive, ok, fail, shSeq, shSeqInteractive } from "./helpers.js";
6
6
  import type {
7
7
  BaseCtx,
8
8
  HandlerReturn,
@@ -15,6 +15,7 @@ import type {
15
15
  RunOptions,
16
16
  SchemaFields,
17
17
  SelectMode,
18
+ Surface,
18
19
  Trait,
19
20
  } from "./types.js";
20
21
  import { FROM_ARGS, FROM_WORKSPACE } from "./types.js";
@@ -254,6 +255,7 @@ function makeBaseCtx(
254
255
  ws: IWorkspace,
255
256
  cwdResolver: () => string,
256
257
  host: InvocationHost,
258
+ interactive = false,
257
259
  ): BaseCtx {
258
260
  return {
259
261
  workspace: ws,
@@ -261,6 +263,11 @@ function makeBaseCtx(
261
263
  fail,
262
264
  sh: (cmd, opts) => {
263
265
  const options = { cwd: cwdResolver(), timeout: opts?.timeout, env: opts?.env };
266
+ if (interactive) {
267
+ return Array.isArray(cmd)
268
+ ? shSeqInteractive(cmd, options)
269
+ : execInteractive(cmd as string, options);
270
+ }
264
271
  return Array.isArray(cmd)
265
272
  ? shSeq(cmd, options)
266
273
  : execAsync(cmd as string, options);
@@ -275,11 +282,12 @@ function makeNeedsCtx(
275
282
  project: IProject,
276
283
  needs: readonly Trait[],
277
284
  host: InvocationHost,
285
+ interactive = false,
278
286
  ): NeedsCtx<Record<string, unknown>> {
279
287
  const traits: Record<string, unknown> = {};
280
288
  for (const t of needs) traits[t.name] = project.trait(t as Trait<string, unknown>);
281
289
  return {
282
- ...makeBaseCtx(ws, () => project.dir, host),
290
+ ...makeBaseCtx(ws, () => project.dir, host, interactive),
283
291
  project,
284
292
  traits,
285
293
  };
@@ -435,17 +443,27 @@ function resolve(
435
443
  group: string,
436
444
  sourceFile: string,
437
445
  host: InvocationHost,
446
+ surface: Surface,
438
447
  ): ResolvedCommand {
439
448
  const meta = deriveMetadata(def, sourceFile);
440
449
  const rawNeeds = def.needs ?? [];
441
450
  const needs: Trait[] = rawNeeds.map((t) =>
442
451
  typeof t === "string" ? workspace.traits.require(t) : t as Trait,
443
452
  );
444
- const select: SelectMode = def.select ?? (needs.length > 0 ? "one-or-many" : "one");
453
+ const interactive = def.interactive === true;
454
+ // Interactive commands own the terminal — one child at a time, so with
455
+ // `needs` they default to (and require) single-project selection.
456
+ const select: SelectMode = def.select
457
+ ?? (needs.length > 0 ? (interactive ? "one" : "one-or-many") : "one");
445
458
 
446
459
  if (needs.length === 0 && select !== "one") {
447
460
  throw new Error(`Command "${meta.name}": select requires needs (at ${sourceFile})`);
448
461
  }
462
+ if (interactive && select !== "one") {
463
+ throw new Error(
464
+ `Command "${meta.name}": interactive commands cannot use select "${select}" — the terminal can host one process at a time (at ${sourceFile})`,
465
+ );
466
+ }
449
467
 
450
468
  // `for` binds this command to a named project. No project arg is exposed on
451
469
  // any surface; ctx.project is the bound project.
@@ -485,23 +503,27 @@ function resolve(
485
503
  const projectArg = boundProject ? null : projectArgFor(needs, select, workspace);
486
504
  const { inputSchema, positional } = buildSchema(resolvedArgs, resolvedFlags, projectArg);
487
505
 
506
+ // The terminal handoff only exists on the CLI; other surfaces fall back to
507
+ // buffered execution (MCP additionally excludes interactive commands).
508
+ const interactiveExec = interactive && surface === "cli";
509
+
488
510
  const handler: ResolvedHandler = async (args) => {
489
511
  const validated = validateFromArgsFields(args, fromArgsFields, workspace);
490
512
 
491
513
  if (needs.length === 0) {
492
- const ctx = makeBaseCtx(workspace, () => workspace.root, host);
514
+ const ctx = makeBaseCtx(workspace, () => workspace.root, host, interactiveExec);
493
515
  return runHandler(def.handler, validated, ctx);
494
516
  }
495
517
 
496
518
  if (boundProject) {
497
- const ctx = makeNeedsCtx(workspace, boundProject, needs, host);
519
+ const ctx = makeNeedsCtx(workspace, boundProject, needs, host, interactiveExec);
498
520
  return runHandler(def.handler, validated, ctx);
499
521
  }
500
522
 
501
523
  const projects = resolveProjects(needs, select, workspace, validated.project);
502
524
 
503
525
  if (select === "one") {
504
- const ctx = makeNeedsCtx(workspace, projects[0], needs, host);
526
+ const ctx = makeNeedsCtx(workspace, projects[0], needs, host, interactiveExec);
505
527
  return runHandler(def.handler, stripProjectArg(validated), ctx);
506
528
  }
507
529
 
@@ -519,6 +541,7 @@ function resolve(
519
541
  sourceFile,
520
542
  select,
521
543
  needs: needs.map((t) => t.name),
544
+ interactive,
522
545
  };
523
546
  }
524
547
 
@@ -567,7 +590,18 @@ function walkCommands(root: string): Discovered[] {
567
590
  return out;
568
591
  }
569
592
 
570
- export async function loadCommands(workspace: Workspace): Promise<ResolvedCommand[]> {
593
+ export interface LoadCommandsOptions {
594
+ /** Which host is executing. Interactive commands hand the terminal to the
595
+ * child only on "cli"; elsewhere they run buffered (and MCP hides them).
596
+ * Defaults to "mcp" — the conservative surface. */
597
+ surface?: Surface;
598
+ }
599
+
600
+ export async function loadCommands(
601
+ workspace: Workspace,
602
+ options?: LoadCommandsOptions,
603
+ ): Promise<ResolvedCommand[]> {
604
+ const surface: Surface = options?.surface ?? "mcp";
571
605
  const commandsDir = join(workspace.root, ".wm", "commands");
572
606
  if (!existsSync(commandsDir)) return [];
573
607
 
@@ -598,7 +632,7 @@ export async function loadCommands(workspace: Workspace): Promise<ResolvedComman
598
632
  for (const d of discovered) {
599
633
  const def = await importCommand(jiti, d.filePath);
600
634
  if (!def) continue;
601
- const cmd = resolve(def, workspace, d.group, d.filePath, host);
635
+ const cmd = resolve(def, workspace, d.group, d.filePath, host, surface);
602
636
  // Override resolved name from path-derived name (filename was the fallback in meta).
603
637
  // Meta.name wins if explicitly set; otherwise use the discovered name.
604
638
  const userProvidedName = def.meta?.name;
package/src/lib/types.ts CHANGED
@@ -70,6 +70,11 @@ export interface IWorkspace {
70
70
  /** How many projects a command runs against. */
71
71
  export type SelectMode = "one" | "one-or-many" | "all";
72
72
 
73
+ /** Which host is executing commands. Interactive commands hand the terminal
74
+ * to the child process — only the CLI can do that; the MCP server excludes
75
+ * them from its tool list. */
76
+ export type Surface = "cli" | "mcp";
77
+
73
78
  /** Framework-standard context passed to every handler. */
74
79
  export interface BaseCtx {
75
80
  workspace: IWorkspace;
@@ -105,6 +110,9 @@ export interface ResolvedCommand {
105
110
  select: SelectMode;
106
111
  /** Names of traits this command requires. Empty array = no needs. */
107
112
  needs: string[];
113
+ /** Long-running command that owns the terminal while it runs (dev server,
114
+ * watcher). CLI-only; the MCP server does not expose it. */
115
+ interactive: boolean;
108
116
  }
109
117
 
110
118
  export type ResolvedHandler = (