@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 +2 -1
- package/dist/index.js +10 -4
- package/dist/lib/define.d.ts +6 -0
- package/dist/lib/helpers.d.ts +9 -0
- package/dist/lib/helpers.js +40 -1
- package/dist/lib/load.d.ts +8 -2
- package/dist/lib/load.js +28 -11
- package/dist/lib/types.d.ts +7 -0
- package/package.json +1 -1
- package/src/cli.ts +5 -1
- package/src/index.ts +15 -4
- package/src/lib/define.ts +6 -0
- package/src/lib/helpers.ts +48 -1
- package/src/lib/load.ts +42 -8
- package/src/lib/types.ts +8 -0
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
|
-
|
|
12
|
-
|
|
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:
|
|
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 ?? {});
|
package/dist/lib/define.d.ts
CHANGED
|
@@ -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?: {
|
package/dist/lib/helpers.d.ts
CHANGED
|
@@ -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>;
|
package/dist/lib/helpers.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/lib/load.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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;
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -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
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
|
-
|
|
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 ${
|
|
29
|
+
console.error(`Loaded ${exposed.length} commands from ${workspace.projects.length} projects`);
|
|
25
30
|
|
|
26
31
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
27
|
-
tools:
|
|
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 };
|
package/src/lib/helpers.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 = (
|