@mandujs/mcp 0.18.9 → 0.19.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.
@@ -1,382 +1,464 @@
1
- /**
2
- * Mandu MCP - Project Tools
3
- *
4
- * - mandu_init: Create a new Mandu project (init + optional install)
5
- * - mandu_dev_start: Start dev server
6
- * - mandu_dev_stop: Stop dev server
7
- */
8
-
9
- import type { Tool } from "@modelcontextprotocol/sdk/types.js";
10
- import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
11
- import type { ActivityMonitor } from "../activity-monitor.js";
12
- import { spawn, type Subprocess } from "bun";
13
- import path from "path";
14
- import fs from "fs/promises";
15
-
16
- type DevServerState = {
17
- process: Subprocess;
18
- cwd: string;
19
- startedAt: Date;
20
- output: string[];
21
- maxLines: number;
22
- };
23
-
24
- let devServerState: DevServerState | null = null;
25
- let devServerStarting = false;
26
-
27
- function trimOutput(text: string, maxChars: number = 4000): string {
28
- if (text.length <= maxChars) return text;
29
- return text.slice(-maxChars);
30
- }
31
-
32
- const COMMAND_TIMEOUT_MS = 120_000; // 2 minutes
33
-
34
- async function runCommand(cmd: string[], cwd: string, timeoutMs: number = COMMAND_TIMEOUT_MS) {
35
- const proc = spawn(cmd, {
36
- cwd,
37
- stdout: "pipe",
38
- stderr: "pipe",
39
- });
40
-
41
- const timeoutPromise = new Promise<never>((_, reject) =>
42
- setTimeout(() => {
43
- proc.kill();
44
- reject(new Error(`Command timed out after ${timeoutMs}ms: ${cmd.join(" ")}`));
45
- }, timeoutMs)
46
- );
47
-
48
- const [stdout, stderr, exitCode] = await Promise.race([
49
- Promise.all([
50
- new Response(proc.stdout).text(),
51
- new Response(proc.stderr).text(),
52
- proc.exited,
53
- ]),
54
- timeoutPromise,
55
- ]);
56
-
57
- return {
58
- exitCode,
59
- stdout,
60
- stderr,
61
- };
62
- }
63
-
64
- async function consumeStream(
65
- stream: ReadableStream<Uint8Array> | null,
66
- state: DevServerState,
67
- label: "stdout" | "stderr",
68
- server?: Server
69
- ) {
70
- if (!stream) return;
71
- const reader = stream.getReader();
72
- const decoder = new TextDecoder();
73
- let buffer = "";
74
-
75
- while (true) {
76
- const { done, value } = await reader.read();
77
- if (done) break;
78
- buffer += decoder.decode(value, { stream: true });
79
- const lines = buffer.split(/\r?\n/);
80
- buffer = lines.pop() ?? "";
81
-
82
- for (const line of lines) {
83
- const text = line.trim();
84
- if (!text) continue;
85
- const entry = `[${label}] ${text}`;
86
- state.output.push(entry);
87
- if (state.output.length > state.maxLines) {
88
- state.output.shift();
89
- }
90
-
91
- if (server) {
92
- server.sendLoggingMessage({
93
- level: label === "stderr" ? "warning" : "info",
94
- logger: "mandu-dev",
95
- data: {
96
- type: "dev_log",
97
- stream: label,
98
- message: text,
99
- },
100
- }).catch(() => {});
101
- }
102
- }
103
- }
104
-
105
- if (buffer.trim()) {
106
- const entry = `[${label}] ${buffer.trim()}`;
107
- state.output.push(entry);
108
- if (state.output.length > state.maxLines) {
109
- state.output.shift();
110
- }
111
- }
112
- }
113
-
114
- export const projectToolDefinitions: Tool[] = [
115
- {
116
- name: "mandu_init",
117
- description:
118
- "Initialize a new Mandu project (runs `mandu init` and optionally `bun install`).",
119
- inputSchema: {
120
- type: "object",
121
- properties: {
122
- name: {
123
- type: "string",
124
- description: "Project name (directory name)",
125
- },
126
- parentDir: {
127
- type: "string",
128
- description: "Parent directory to create the project in (default: cwd)",
129
- },
130
- css: {
131
- type: "string",
132
- enum: ["tailwind", "panda", "none"],
133
- description: "CSS framework (default: tailwind)",
134
- },
135
- ui: {
136
- type: "string",
137
- enum: ["shadcn", "ark", "none"],
138
- description: "UI library (default: shadcn)",
139
- },
140
- theme: {
141
- type: "boolean",
142
- description: "Enable dark mode theme system",
143
- },
144
- minimal: {
145
- type: "boolean",
146
- description: "Create minimal template (no CSS/UI)",
147
- },
148
- install: {
149
- type: "boolean",
150
- description: "Run bun install after init (default: true)",
151
- },
152
- },
153
- required: ["name"],
154
- },
155
- },
156
- {
157
- name: "mandu_dev_start",
158
- description: "Start Mandu dev server (bun run dev).",
159
- inputSchema: {
160
- type: "object",
161
- properties: {
162
- cwd: {
163
- type: "string",
164
- description: "Project directory to run dev server in (default: current project)",
165
- },
166
- },
167
- required: [],
168
- },
169
- },
170
- {
171
- name: "mandu_dev_stop",
172
- description: "Stop Mandu dev server if running.",
173
- inputSchema: {
174
- type: "object",
175
- properties: {},
176
- required: [],
177
- },
178
- },
179
- ];
180
-
181
- export function projectTools(projectRoot: string, server?: Server, monitor?: ActivityMonitor) {
182
- return {
183
- mandu_init: async (args: Record<string, unknown>) => {
184
- const {
185
- name,
186
- parentDir,
187
- css,
188
- ui,
189
- theme,
190
- minimal,
191
- install = true,
192
- } = args as {
193
- name: string;
194
- parentDir?: string;
195
- css?: "tailwind" | "panda" | "none";
196
- ui?: "shadcn" | "ark" | "none";
197
- theme?: boolean;
198
- minimal?: boolean;
199
- install?: boolean;
200
- };
201
-
202
- const baseDir = parentDir
203
- ? path.resolve(projectRoot, parentDir)
204
- : projectRoot;
205
-
206
- await fs.mkdir(baseDir, { recursive: true });
207
-
208
- // Runtime whitelist validation for spawn arguments
209
- const VALID_CSS = ["tailwind", "panda", "none"];
210
- const VALID_UI = ["shadcn", "ark", "none"];
211
- if (css !== undefined && !VALID_CSS.includes(css)) {
212
- return { success: false, error: `Invalid css value: ${css}. Must be one of: ${VALID_CSS.join(", ")}` };
213
- }
214
- if (ui !== undefined && !VALID_UI.includes(ui)) {
215
- return { success: false, error: `Invalid ui value: ${ui}. Must be one of: ${VALID_UI.join(", ")}` };
216
- }
217
-
218
- const initArgs = ["@mandujs/cli", "init", name];
219
- if (minimal) {
220
- initArgs.push("--minimal");
221
- } else {
222
- if (css) initArgs.push("--css", css);
223
- if (ui) initArgs.push("--ui", ui);
224
- if (theme) initArgs.push("--theme");
225
- }
226
-
227
- let initResult: { exitCode: number | null; stdout: string; stderr: string };
228
- try {
229
- initResult = await runCommand(["bunx", ...initArgs], baseDir);
230
- } catch (err) {
231
- return {
232
- success: false,
233
- step: "init",
234
- error: err instanceof Error ? err.message : String(err),
235
- };
236
- }
237
- if (initResult.exitCode !== 0) {
238
- return {
239
- success: false,
240
- step: "init",
241
- exitCode: initResult.exitCode,
242
- stdout: trimOutput(initResult.stdout),
243
- stderr: trimOutput(initResult.stderr),
244
- };
245
- }
246
-
247
- const projectDir = path.join(baseDir, name);
248
-
249
- let installResult: { exitCode: number | null; stdout: string; stderr: string } | null = null;
250
- if (install !== false) {
251
- try {
252
- installResult = await runCommand(["bun", "install"], projectDir);
253
- } catch (err) {
254
- return {
255
- success: false,
256
- step: "install",
257
- projectDir,
258
- error: err instanceof Error ? err.message : String(err),
259
- };
260
- }
261
- if (installResult.exitCode !== 0) {
262
- return {
263
- success: false,
264
- step: "install",
265
- projectDir,
266
- exitCode: installResult.exitCode,
267
- stdout: trimOutput(installResult.stdout),
268
- stderr: trimOutput(installResult.stderr),
269
- };
270
- }
271
- }
272
-
273
- return {
274
- success: true,
275
- projectDir,
276
- installed: install !== false,
277
- init: {
278
- exitCode: initResult.exitCode,
279
- stdout: trimOutput(initResult.stdout),
280
- stderr: trimOutput(initResult.stderr),
281
- },
282
- install: installResult
283
- ? {
284
- exitCode: installResult.exitCode,
285
- stdout: trimOutput(installResult.stdout),
286
- stderr: trimOutput(installResult.stderr),
287
- }
288
- : null,
289
- next: install !== false
290
- ? ["cd " + name, "bun run dev"]
291
- : ["cd " + name, "bun install", "bun run dev"],
292
- };
293
- },
294
-
295
- mandu_dev_start: async (args: Record<string, unknown>) => {
296
- const { cwd } = args as { cwd?: string };
297
- if (devServerState || devServerStarting) {
298
- return {
299
- success: false,
300
- message: devServerStarting
301
- ? "Dev server is starting up, please wait"
302
- : "Dev server is already running",
303
- pid: devServerState?.process.pid,
304
- cwd: devServerState?.cwd,
305
- };
306
- }
307
-
308
- devServerStarting = true;
309
- try {
310
- const targetDir = cwd ? path.resolve(projectRoot, cwd) : projectRoot;
311
-
312
- const proc = spawn(["bun", "run", "dev"], {
313
- cwd: targetDir,
314
- stdout: "pipe",
315
- stderr: "pipe",
316
- stdin: "ignore",
317
- });
318
-
319
- const state: DevServerState = {
320
- process: proc,
321
- cwd: targetDir,
322
- startedAt: new Date(),
323
- output: [],
324
- maxLines: 50,
325
- };
326
- devServerState = state;
327
-
328
- consumeStream(proc.stdout, state, "stdout", server).catch(() => {});
329
- consumeStream(proc.stderr, state, "stderr", server).catch(() => {});
330
-
331
- proc.exited.then(() => {
332
- if (devServerState?.process === proc) {
333
- devServerState = null;
334
- }
335
- }).catch(() => {});
336
-
337
- if (monitor) {
338
- monitor.logEvent("dev", `Dev server started (${targetDir})`);
339
- }
340
-
341
- return {
342
- success: true,
343
- pid: proc.pid,
344
- cwd: targetDir,
345
- startedAt: state.startedAt.toISOString(),
346
- message: "Dev server started",
347
- };
348
- } finally {
349
- devServerStarting = false;
350
- }
351
- },
352
-
353
- mandu_dev_stop: async () => {
354
- if (!devServerState) {
355
- return {
356
- success: false,
357
- message: "Dev server is not running",
358
- };
359
- }
360
-
361
- const { process: proc, cwd, output } = devServerState;
362
- devServerState = null;
363
-
364
- try {
365
- proc.kill();
366
- } catch {
367
- // ignore
368
- }
369
-
370
- if (monitor) {
371
- monitor.logEvent("dev", `Dev server stopped (${cwd})`);
372
- }
373
-
374
- return {
375
- success: true,
376
- message: "Dev server stopped",
377
- cwd,
378
- tail: output.slice(-10),
379
- };
380
- },
381
- };
382
- }
1
+ /**
2
+ * Mandu MCP - Project Tools
3
+ *
4
+ * - mandu.project.init: Create a new Mandu project (init + optional install)
5
+ * - mandu.dev.start: Start dev server
6
+ * - mandu.dev.stop: Stop dev server
7
+ */
8
+
9
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
10
+ import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
11
+ import type { ActivityMonitor } from "../activity-monitor.js";
12
+ import { spawn, type Subprocess } from "bun";
13
+ import { execSync } from "child_process";
14
+ import path from "path";
15
+ import fs from "fs/promises";
16
+
17
+ type DevServerState = {
18
+ process: Subprocess;
19
+ cwd: string;
20
+ startedAt: Date;
21
+ output: string[];
22
+ maxLines: number;
23
+ };
24
+
25
+ let devServerState: DevServerState | null = null;
26
+ let devServerStarting = false;
27
+
28
+ /**
29
+ * Get the current dev server state.
30
+ * Used by other tools (e.g. kitchen) to discover the running server's port/output.
31
+ */
32
+ export function getDevServerState(): { cwd: string; output: string[]; startedAt: Date } | null {
33
+ if (!devServerState) return null;
34
+ return {
35
+ cwd: devServerState.cwd,
36
+ output: [...devServerState.output],
37
+ startedAt: devServerState.startedAt,
38
+ };
39
+ }
40
+
41
+ function trimOutput(text: string, maxChars: number = 4000): string {
42
+ if (text.length <= maxChars) return text;
43
+ return text.slice(-maxChars);
44
+ }
45
+
46
+ const COMMAND_TIMEOUT_MS = 120_000; // 2 minutes
47
+
48
+ async function runCommand(cmd: string[], cwd: string, timeoutMs: number = COMMAND_TIMEOUT_MS) {
49
+ const proc = spawn(cmd, {
50
+ cwd,
51
+ stdout: "pipe",
52
+ stderr: "pipe",
53
+ });
54
+
55
+ const timeoutPromise = new Promise<never>((_, reject) =>
56
+ setTimeout(() => {
57
+ proc.kill();
58
+ reject(new Error(`Command timed out after ${timeoutMs}ms: ${cmd.join(" ")}`));
59
+ }, timeoutMs)
60
+ );
61
+
62
+ const [stdout, stderr, exitCode] = await Promise.race([
63
+ Promise.all([
64
+ new Response(proc.stdout).text(),
65
+ new Response(proc.stderr).text(),
66
+ proc.exited,
67
+ ]),
68
+ timeoutPromise,
69
+ ]);
70
+
71
+ return {
72
+ exitCode,
73
+ stdout,
74
+ stderr,
75
+ };
76
+ }
77
+
78
+ async function consumeStream(
79
+ stream: ReadableStream<Uint8Array> | null,
80
+ state: DevServerState,
81
+ label: "stdout" | "stderr",
82
+ server?: Server
83
+ ) {
84
+ if (!stream) return;
85
+ const reader = stream.getReader();
86
+ const decoder = new TextDecoder();
87
+ let buffer = "";
88
+
89
+ while (true) {
90
+ const { done, value } = await reader.read();
91
+ if (done) break;
92
+ buffer += decoder.decode(value, { stream: true });
93
+ const lines = buffer.split(/\r?\n/);
94
+ buffer = lines.pop() ?? "";
95
+
96
+ for (const line of lines) {
97
+ const text = line.trim();
98
+ if (!text) continue;
99
+ const entry = `[${label}] ${text}`;
100
+ state.output.push(entry);
101
+ if (state.output.length > state.maxLines) {
102
+ state.output.shift();
103
+ }
104
+
105
+ if (server) {
106
+ server.sendLoggingMessage({
107
+ level: label === "stderr" ? "warning" : "info",
108
+ logger: "mandu-dev",
109
+ data: {
110
+ type: "dev_log",
111
+ stream: label,
112
+ message: text,
113
+ },
114
+ }).catch(() => {});
115
+ }
116
+ }
117
+ }
118
+
119
+ if (buffer.trim()) {
120
+ const entry = `[${label}] ${buffer.trim()}`;
121
+ state.output.push(entry);
122
+ if (state.output.length > state.maxLines) {
123
+ state.output.shift();
124
+ }
125
+ }
126
+ }
127
+
128
+ export const projectToolDefinitions: Tool[] = [
129
+ {
130
+ name: "mandu.project.init",
131
+ description:
132
+ "Initialize a new Mandu project (runs `mandu init` and optionally `bun install`).",
133
+ annotations: {
134
+ destructiveHint: true,
135
+ readOnlyHint: false,
136
+ },
137
+ inputSchema: {
138
+ type: "object",
139
+ properties: {
140
+ name: {
141
+ type: "string",
142
+ description: "Project name (directory name)",
143
+ },
144
+ parentDir: {
145
+ type: "string",
146
+ description: "Parent directory to create the project in (default: cwd)",
147
+ },
148
+ css: {
149
+ type: "string",
150
+ enum: ["tailwind", "panda", "none"],
151
+ description: "CSS framework (default: tailwind)",
152
+ },
153
+ ui: {
154
+ type: "string",
155
+ enum: ["shadcn", "ark", "none"],
156
+ description: "UI library (default: shadcn)",
157
+ },
158
+ theme: {
159
+ type: "boolean",
160
+ description: "Enable dark mode theme system",
161
+ },
162
+ minimal: {
163
+ type: "boolean",
164
+ description: "Create minimal template (no CSS/UI)",
165
+ },
166
+ install: {
167
+ type: "boolean",
168
+ description: "Run bun install after init (default: true)",
169
+ },
170
+ },
171
+ required: ["name"],
172
+ },
173
+ },
174
+ {
175
+ name: "mandu.dev.start",
176
+ description: "Start Mandu dev server (bun run dev).",
177
+ annotations: {
178
+ readOnlyHint: false,
179
+ },
180
+ inputSchema: {
181
+ type: "object",
182
+ properties: {
183
+ cwd: {
184
+ type: "string",
185
+ description: "Project directory to run dev server in (default: current project)",
186
+ },
187
+ },
188
+ required: [],
189
+ },
190
+ },
191
+ {
192
+ name: "mandu.dev.stop",
193
+ description: "Stop Mandu dev server if running.",
194
+ annotations: {
195
+ destructiveHint: true,
196
+ readOnlyHint: false,
197
+ },
198
+ inputSchema: {
199
+ type: "object",
200
+ properties: {},
201
+ required: [],
202
+ },
203
+ },
204
+ ];
205
+
206
+ export function projectTools(projectRoot: string, server?: Server, monitor?: ActivityMonitor) {
207
+ const handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>> = {
208
+ "mandu.project.init": async (args: Record<string, unknown>) => {
209
+ const {
210
+ name,
211
+ parentDir,
212
+ css,
213
+ ui,
214
+ theme,
215
+ minimal,
216
+ install = true,
217
+ } = args as {
218
+ name: string;
219
+ parentDir?: string;
220
+ css?: "tailwind" | "panda" | "none";
221
+ ui?: "shadcn" | "ark" | "none";
222
+ theme?: boolean;
223
+ minimal?: boolean;
224
+ install?: boolean;
225
+ };
226
+
227
+ const baseDir = parentDir
228
+ ? path.resolve(projectRoot, parentDir)
229
+ : projectRoot;
230
+
231
+ await fs.mkdir(baseDir, { recursive: true });
232
+
233
+ // Runtime whitelist validation for spawn arguments
234
+ const VALID_CSS = ["tailwind", "panda", "none"];
235
+ const VALID_UI = ["shadcn", "ark", "none"];
236
+ if (css !== undefined && !VALID_CSS.includes(css)) {
237
+ return { success: false, error: `Invalid css value: ${css}. Must be one of: ${VALID_CSS.join(", ")}` };
238
+ }
239
+ if (ui !== undefined && !VALID_UI.includes(ui)) {
240
+ return { success: false, error: `Invalid ui value: ${ui}. Must be one of: ${VALID_UI.join(", ")}` };
241
+ }
242
+
243
+ const initArgs = ["@mandujs/cli", "init", name];
244
+ if (minimal) {
245
+ initArgs.push("--minimal");
246
+ } else {
247
+ if (css) initArgs.push("--css", css);
248
+ if (ui) initArgs.push("--ui", ui);
249
+ if (theme) initArgs.push("--theme");
250
+ }
251
+
252
+ let initResult: { exitCode: number | null; stdout: string; stderr: string };
253
+ try {
254
+ initResult = await runCommand(["bunx", ...initArgs], baseDir);
255
+ } catch (err) {
256
+ return {
257
+ success: false,
258
+ step: "init",
259
+ error: err instanceof Error ? err.message : String(err),
260
+ };
261
+ }
262
+ if (initResult.exitCode !== 0) {
263
+ return {
264
+ success: false,
265
+ step: "init",
266
+ exitCode: initResult.exitCode,
267
+ stdout: trimOutput(initResult.stdout),
268
+ stderr: trimOutput(initResult.stderr),
269
+ };
270
+ }
271
+
272
+ const projectDir = path.join(baseDir, name);
273
+
274
+ let installResult: { exitCode: number | null; stdout: string; stderr: string } | null = null;
275
+ if (install !== false) {
276
+ try {
277
+ installResult = await runCommand(["bun", "install"], projectDir);
278
+ } catch (err) {
279
+ return {
280
+ success: false,
281
+ step: "install",
282
+ projectDir,
283
+ error: err instanceof Error ? err.message : String(err),
284
+ };
285
+ }
286
+ if (installResult.exitCode !== 0) {
287
+ return {
288
+ success: false,
289
+ step: "install",
290
+ projectDir,
291
+ exitCode: installResult.exitCode,
292
+ stdout: trimOutput(installResult.stdout),
293
+ stderr: trimOutput(installResult.stderr),
294
+ };
295
+ }
296
+ }
297
+
298
+ return {
299
+ success: true,
300
+ projectDir,
301
+ installed: install !== false,
302
+ init: {
303
+ exitCode: initResult.exitCode,
304
+ stdout: trimOutput(initResult.stdout),
305
+ stderr: trimOutput(initResult.stderr),
306
+ },
307
+ install: installResult
308
+ ? {
309
+ exitCode: installResult.exitCode,
310
+ stdout: trimOutput(installResult.stdout),
311
+ stderr: trimOutput(installResult.stderr),
312
+ }
313
+ : null,
314
+ next: install !== false
315
+ ? ["cd " + name, "bun run dev"]
316
+ : ["cd " + name, "bun install", "bun run dev"],
317
+ };
318
+ },
319
+
320
+ "mandu.dev.start": async (args: Record<string, unknown>) => {
321
+ const { cwd } = args as { cwd?: string };
322
+ if (devServerState || devServerStarting) {
323
+ return {
324
+ success: false,
325
+ message: devServerStarting
326
+ ? "Dev server is starting up, please wait"
327
+ : "Dev server is already running",
328
+ pid: devServerState?.process.pid,
329
+ cwd: devServerState?.cwd,
330
+ };
331
+ }
332
+
333
+ devServerStarting = true;
334
+ try {
335
+ const targetDir = cwd ? path.resolve(projectRoot, cwd) : projectRoot;
336
+
337
+ const proc = spawn(["bun", "run", "dev"], {
338
+ cwd: targetDir,
339
+ stdout: "pipe",
340
+ stderr: "pipe",
341
+ stdin: "ignore",
342
+ });
343
+
344
+ const state: DevServerState = {
345
+ process: proc,
346
+ cwd: targetDir,
347
+ startedAt: new Date(),
348
+ output: [],
349
+ maxLines: 50,
350
+ };
351
+ devServerState = state;
352
+
353
+ // Wait for the server to output its port before returning
354
+ const portPromise = new Promise<{ port: number; url: string } | null>((resolve) => {
355
+ const PORT_DETECT_TIMEOUT_MS = 15_000;
356
+ const timeout = setTimeout(() => resolve(null), PORT_DETECT_TIMEOUT_MS);
357
+ const portPattern = /https?:\/\/[^:\s]+:(\d+)/;
358
+
359
+ const originalPush = state.output.push.bind(state.output);
360
+ state.output.push = (...items: string[]) => {
361
+ const result = originalPush(...items);
362
+ for (const item of items) {
363
+ const match = item.match(portPattern);
364
+ if (match) {
365
+ const detectedPort = parseInt(match[1], 10);
366
+ clearTimeout(timeout);
367
+ state.output.push = originalPush;
368
+ resolve({
369
+ port: detectedPort,
370
+ url: match[0],
371
+ });
372
+ break;
373
+ }
374
+ }
375
+ return result;
376
+ };
377
+ });
378
+
379
+ consumeStream(proc.stdout, state, "stdout", server).catch(() => {});
380
+ consumeStream(proc.stderr, state, "stderr", server).catch(() => {});
381
+
382
+ proc.exited.then(() => {
383
+ if (devServerState?.process === proc) {
384
+ devServerState = null;
385
+ }
386
+ }).catch(() => {});
387
+
388
+ if (monitor) {
389
+ monitor.logEvent("dev", `Dev server started (${targetDir})`);
390
+ }
391
+
392
+ // Wait for port detection (with timeout fallback)
393
+ const detected = await portPromise;
394
+
395
+ return {
396
+ success: true,
397
+ pid: proc.pid,
398
+ port: detected?.port ?? null,
399
+ url: detected?.url ?? null,
400
+ cwd: targetDir,
401
+ startedAt: state.startedAt.toISOString(),
402
+ message: detected
403
+ ? `Dev server started on port ${detected.port}`
404
+ : "Dev server started (port detection timed out)",
405
+ };
406
+ } finally {
407
+ devServerStarting = false;
408
+ }
409
+ },
410
+
411
+ "mandu.dev.stop": async () => {
412
+ if (!devServerState) {
413
+ return {
414
+ success: false,
415
+ message: "Dev server is not running",
416
+ };
417
+ }
418
+
419
+ const { process: proc, cwd, output } = devServerState;
420
+ const pid = proc.pid;
421
+ devServerState = null;
422
+
423
+ // Kill the entire process tree to prevent zombie processes
424
+ try {
425
+ if (pid) {
426
+ if (process.platform === "win32") {
427
+ // Windows: taskkill /T kills the process tree, /F forces termination
428
+ execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore" });
429
+ } else {
430
+ // Unix: kill the entire process group (negative PID)
431
+ try {
432
+ process.kill(-pid, "SIGKILL");
433
+ } catch {
434
+ // Fallback: kill just the process if process group kill fails
435
+ proc.kill("SIGKILL");
436
+ }
437
+ }
438
+ } else {
439
+ proc.kill();
440
+ }
441
+ } catch {
442
+ // Process may have already exited
443
+ }
444
+
445
+ if (monitor) {
446
+ monitor.logEvent("dev", `Dev server stopped (${cwd})`);
447
+ }
448
+
449
+ return {
450
+ success: true,
451
+ message: "Dev server stopped",
452
+ cwd,
453
+ tail: output.slice(-10),
454
+ };
455
+ },
456
+ };
457
+
458
+ // Backward-compatible aliases (deprecated)
459
+ handlers["mandu_init"] = handlers["mandu.project.init"];
460
+ handlers["mandu_dev_start"] = handlers["mandu.dev.start"];
461
+ handlers["mandu_dev_stop"] = handlers["mandu.dev.stop"];
462
+
463
+ return handlers;
464
+ }