@agent-wall/cli 0.1.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.
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { validateCommand } from "./validate.js";
3
+
4
+ const mockConfig = {
5
+ version: 1,
6
+ defaultAction: "deny" as const,
7
+ rules: [
8
+ { name: "block-ssh", tool: "read_file", action: "deny" as const },
9
+ { name: "allow-project", tool: "list_directory", action: "allow" as const },
10
+ ],
11
+ };
12
+
13
+ vi.mock("@agent-wall/core", () => {
14
+ class MockPolicyEngine {
15
+ constructor(_cfg: any) {}
16
+ }
17
+
18
+ return {
19
+ loadPolicy: (configPath?: string) => ({
20
+ config: { ...mockConfig, rules: [...mockConfig.rules] },
21
+ filePath: configPath ?? "agent-wall.yaml",
22
+ }),
23
+ PolicyEngine: MockPolicyEngine,
24
+ };
25
+ });
26
+
27
+ describe("validateCommand", () => {
28
+ let exitSpy: any;
29
+ let stderrSpy: any;
30
+
31
+ beforeEach(() => {
32
+ exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
33
+ throw new Error("process.exit");
34
+ });
35
+ stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true);
36
+ });
37
+
38
+ afterEach(() => {
39
+ vi.restoreAllMocks();
40
+ });
41
+
42
+ it("validates a correct config successfully", () => {
43
+ validateCommand({});
44
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
45
+ expect(allOutput).toContain("Config loaded");
46
+ expect(allOutput).toContain("Version: 1");
47
+ expect(allOutput).toContain("Default action: deny");
48
+ expect(allOutput).toContain("Rules: 2 loaded");
49
+ expect(allOutput).toContain("Policy engine: OK");
50
+ });
51
+
52
+ it("shows the config file path", () => {
53
+ validateCommand({});
54
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
55
+ expect(allOutput).toContain("agent-wall.yaml");
56
+ });
57
+
58
+ it("accepts custom config path", () => {
59
+ validateCommand({ config: "./custom-policy.yaml" });
60
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
61
+ expect(allOutput).toContain("custom-policy.yaml");
62
+ });
63
+
64
+ it("shows rule count", () => {
65
+ validateCommand({});
66
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
67
+ expect(allOutput).toContain("Rules: 2 loaded");
68
+ });
69
+
70
+ it("displays validation header", () => {
71
+ validateCommand({});
72
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
73
+ expect(allOutput).toContain("Config Validation");
74
+ });
75
+ });
76
+
77
+ describe("validateCommand — error scenarios", () => {
78
+ let exitSpy: any;
79
+ let stderrSpy: any;
80
+
81
+ beforeEach(() => {
82
+ exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
83
+ throw new Error("process.exit");
84
+ });
85
+ stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true);
86
+ });
87
+
88
+ afterEach(() => {
89
+ vi.restoreAllMocks();
90
+ });
91
+
92
+ // Note: Testing error branches that require different mock behavior
93
+ // would need vi.mocked module reassignment per test. These tests
94
+ // validate the happy path since the mock is fixed at module level.
95
+ // Integration tests handle error cases more naturally.
96
+
97
+ it("reports valid config with no errors or warnings", () => {
98
+ validateCommand({});
99
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
100
+ // Should not exit with error
101
+ expect(exitSpy).not.toHaveBeenCalled();
102
+ expect(allOutput).toContain("valid");
103
+ });
104
+ });
@@ -0,0 +1,181 @@
1
+ /**
2
+ * agent-wall validate — Validate a policy configuration file.
3
+ *
4
+ * Checks the YAML syntax, Zod schema validation, and reports
5
+ * any issues with your policy rules.
6
+ *
7
+ * Usage:
8
+ * agent-wall validate
9
+ * agent-wall validate --config ./custom-policy.yaml
10
+ */
11
+
12
+ import { loadPolicy, PolicyEngine } from "@agent-wall/core";
13
+ import chalk from "chalk";
14
+
15
+ export interface ValidateOptions {
16
+ config?: string;
17
+ }
18
+
19
+ export function validateCommand(options: ValidateOptions): void {
20
+ process.stderr.write("\n");
21
+ process.stderr.write(
22
+ chalk.cyan("─── Agent Wall Config Validation ─────────────────\n\n")
23
+ );
24
+
25
+ let config;
26
+ let filePath: string | null | undefined;
27
+
28
+ try {
29
+ const result = loadPolicy(options.config);
30
+ config = result.config;
31
+ filePath = result.filePath;
32
+ } catch (error: any) {
33
+ process.stderr.write(
34
+ chalk.red(" ✗ ") + chalk.red(`Failed to load config: ${error.message}\n\n`)
35
+ );
36
+ process.exit(1);
37
+ }
38
+
39
+ process.stderr.write(
40
+ chalk.green(" ✓ ") +
41
+ chalk.white("Config loaded: ") +
42
+ chalk.gray(filePath ?? "built-in defaults") +
43
+ "\n"
44
+ );
45
+
46
+ // Validate version
47
+ if (config.version !== 1) {
48
+ process.stderr.write(
49
+ chalk.yellow(" ⚠ ") +
50
+ chalk.yellow(`Unknown config version: ${config.version} (expected 1)\n`)
51
+ );
52
+ } else {
53
+ process.stderr.write(
54
+ chalk.green(" ✓ ") + chalk.white("Version: 1\n")
55
+ );
56
+ }
57
+
58
+ // Validate default action
59
+ const validActions = ["allow", "deny", "prompt"];
60
+ if (!config.defaultAction || !validActions.includes(config.defaultAction)) {
61
+ process.stderr.write(
62
+ chalk.red(" ✗ ") +
63
+ chalk.red(`Invalid defaultAction: "${config.defaultAction}" (expected: allow, deny, prompt)\n`)
64
+ );
65
+ } else {
66
+ process.stderr.write(
67
+ chalk.green(" ✓ ") +
68
+ chalk.white(`Default action: ${config.defaultAction}\n`)
69
+ );
70
+ }
71
+
72
+ // Validate global rate limit
73
+ if (config.globalRateLimit) {
74
+ if (config.globalRateLimit.maxCalls <= 0) {
75
+ process.stderr.write(
76
+ chalk.yellow(" ⚠ ") +
77
+ chalk.yellow("Global rate limit maxCalls should be > 0\n")
78
+ );
79
+ } else {
80
+ process.stderr.write(
81
+ chalk.green(" ✓ ") +
82
+ chalk.white(
83
+ `Global rate limit: ${config.globalRateLimit.maxCalls} calls / ${config.globalRateLimit.windowSeconds}s\n`
84
+ )
85
+ );
86
+ }
87
+ }
88
+
89
+ // Validate rules
90
+ process.stderr.write(
91
+ chalk.green(" ✓ ") +
92
+ chalk.white(`Rules: ${config.rules.length} loaded\n`)
93
+ );
94
+
95
+ let warnings = 0;
96
+ let errors = 0;
97
+ const ruleNames = new Set<string>();
98
+
99
+ for (let i = 0; i < config.rules.length; i++) {
100
+ const rule = config.rules[i];
101
+ const label = rule.name ?? `rule[${i}]`;
102
+
103
+ // Check for duplicate names
104
+ if (rule.name) {
105
+ if (ruleNames.has(rule.name)) {
106
+ process.stderr.write(
107
+ chalk.yellow(" ⚠ ") +
108
+ chalk.yellow(`Duplicate rule name: "${rule.name}"\n`)
109
+ );
110
+ warnings++;
111
+ }
112
+ ruleNames.add(rule.name);
113
+ } else {
114
+ process.stderr.write(
115
+ chalk.yellow(" ⚠ ") +
116
+ chalk.yellow(`Rule at index ${i} has no name (recommended for audit logs)\n`)
117
+ );
118
+ warnings++;
119
+ }
120
+
121
+ // Check action validity
122
+ if (!validActions.includes(rule.action)) {
123
+ process.stderr.write(
124
+ chalk.red(" ✗ ") +
125
+ chalk.red(`${label}: invalid action "${rule.action}"\n`)
126
+ );
127
+ errors++;
128
+ }
129
+
130
+ // Check tool pattern syntax (basic validation)
131
+ if (rule.tool && rule.tool.includes(" ")) {
132
+ process.stderr.write(
133
+ chalk.yellow(" ⚠ ") +
134
+ chalk.yellow(`${label}: tool pattern contains spaces — use "|" to separate alternatives\n`)
135
+ );
136
+ warnings++;
137
+ }
138
+
139
+ // Check rate limit
140
+ if (rule.rateLimit) {
141
+ if (rule.rateLimit.maxCalls <= 0 || rule.rateLimit.windowSeconds <= 0) {
142
+ process.stderr.write(
143
+ chalk.yellow(" ⚠ ") +
144
+ chalk.yellow(`${label}: rate limit values should be > 0\n`)
145
+ );
146
+ warnings++;
147
+ }
148
+ }
149
+ }
150
+
151
+ // Test that the engine initializes correctly
152
+ try {
153
+ new PolicyEngine(config);
154
+ process.stderr.write(
155
+ chalk.green(" ✓ ") + chalk.white("Policy engine: OK\n")
156
+ );
157
+ } catch (error: any) {
158
+ process.stderr.write(
159
+ chalk.red(" ✗ ") +
160
+ chalk.red(`Policy engine failed: ${error.message}\n`)
161
+ );
162
+ errors++;
163
+ }
164
+
165
+ // Summary
166
+ process.stderr.write("\n");
167
+ if (errors > 0) {
168
+ process.stderr.write(
169
+ chalk.red(` ${errors} error(s), ${warnings} warning(s)\n\n`)
170
+ );
171
+ process.exit(1);
172
+ } else if (warnings > 0) {
173
+ process.stderr.write(
174
+ chalk.yellow(` ${warnings} warning(s), 0 errors — config is valid\n\n`)
175
+ );
176
+ } else {
177
+ process.stderr.write(
178
+ chalk.green(" ✓ Config is valid — no issues found\n\n")
179
+ );
180
+ }
181
+ }
@@ -0,0 +1,420 @@
1
+ /**
2
+ * agent-wall wrap — The main command.
3
+ *
4
+ * Wraps an MCP server command, intercepting all tool calls
5
+ * through the Agent Wall policy engine.
6
+ *
7
+ * Usage:
8
+ * agent-wall wrap -- npx @modelcontextprotocol/server-filesystem /path
9
+ * agent-wall wrap -c agent-wall.yaml -- node my-mcp-server.js
10
+ */
11
+
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import { createRequire } from "node:module";
15
+ import {
16
+ StdioProxy,
17
+ PolicyEngine,
18
+ AuditLogger,
19
+ ResponseScanner,
20
+ InjectionDetector,
21
+ EgressControl,
22
+ KillSwitch,
23
+ ChainDetector,
24
+ DashboardServer,
25
+ loadPolicy,
26
+ createTerminalPromptHandler,
27
+ } from "@agent-wall/core";
28
+ import chalk from "chalk";
29
+
30
+ export interface WrapOptions {
31
+ config?: string;
32
+ logFile?: string;
33
+ silent?: boolean;
34
+ dryRun?: boolean;
35
+ dashboard?: boolean;
36
+ dashboardPort?: number;
37
+ }
38
+
39
+ export async function wrapCommand(
40
+ serverArgs: string[],
41
+ options: WrapOptions
42
+ ): Promise<void> {
43
+ if (serverArgs.length === 0) {
44
+ process.stderr.write(
45
+ chalk.red(
46
+ "Error: No server command specified.\n" +
47
+ "Usage: agent-wall wrap -- <command> [args...]\n\n" +
48
+ "Example:\n" +
49
+ " agent-wall wrap -- npx @modelcontextprotocol/server-filesystem /home/user\n"
50
+ )
51
+ );
52
+ process.exit(1);
53
+ }
54
+
55
+ const command = serverArgs[0];
56
+ const args = serverArgs.slice(1);
57
+
58
+ // Load policy
59
+ const { config, filePath } = loadPolicy(options.config);
60
+
61
+ if (!options.silent) {
62
+ process.stderr.write(
63
+ chalk.cyan("╔══════════════════════════════════════════════════╗\n")
64
+ );
65
+ process.stderr.write(
66
+ chalk.cyan("║ ") +
67
+ chalk.bold.white("Agent Wall") +
68
+ chalk.cyan(" — Security Firewall for AI Agents ║\n")
69
+ );
70
+ process.stderr.write(
71
+ chalk.cyan("╠══════════════════════════════════════════════════╣\n")
72
+ );
73
+ process.stderr.write(
74
+ chalk.cyan("║ ") +
75
+ chalk.gray("Policy: ") +
76
+ chalk.white(filePath ?? "built-in defaults") +
77
+ "\n"
78
+ );
79
+ process.stderr.write(
80
+ chalk.cyan("║ ") +
81
+ chalk.gray("Rules: ") +
82
+ chalk.white(`${config.rules.length} loaded`) +
83
+ "\n"
84
+ );
85
+ const rspScan = config.responseScanning;
86
+ if (rspScan?.enabled !== false) {
87
+ process.stderr.write(
88
+ chalk.cyan("║ ") +
89
+ chalk.gray("Scanner:") +
90
+ chalk.white(" response scanning ON") +
91
+ (rspScan?.detectPII ? chalk.yellow(" +PII") : "") +
92
+ "\n"
93
+ );
94
+ }
95
+ process.stderr.write(
96
+ chalk.cyan("║ ") +
97
+ chalk.gray("Server: ") +
98
+ chalk.white(`${command} ${args.join(" ")}`) +
99
+ "\n"
100
+ );
101
+ process.stderr.write(
102
+ chalk.cyan("╚══════════════════════════════════════════════════╝\n\n")
103
+ );
104
+ }
105
+
106
+ // Dry-run mode: just show info and exit
107
+ if (options.dryRun) {
108
+ process.stderr.write(
109
+ chalk.yellow(" --dry-run mode: preview only, server will not start\n\n")
110
+ );
111
+ process.stderr.write(
112
+ chalk.gray(" Default action: ") +
113
+ chalk.white(config.defaultAction) +
114
+ "\n"
115
+ );
116
+ if (config.globalRateLimit) {
117
+ process.stderr.write(
118
+ chalk.gray(" Rate limit: ") +
119
+ chalk.white(
120
+ `${config.globalRateLimit.maxCalls} calls / ${config.globalRateLimit.windowSeconds}s`
121
+ ) +
122
+ "\n"
123
+ );
124
+ }
125
+ process.stderr.write(
126
+ chalk.gray(" Rules loaded: ") +
127
+ chalk.white(String(config.rules.length)) +
128
+ "\n\n"
129
+ );
130
+
131
+ for (let i = 0; i < config.rules.length; i++) {
132
+ const rule = config.rules[i];
133
+ const icon =
134
+ rule.action === "deny"
135
+ ? chalk.red("✗")
136
+ : rule.action === "allow"
137
+ ? chalk.green("✓")
138
+ : chalk.yellow("?");
139
+ process.stderr.write(
140
+ ` ${icon} ${chalk.bold(rule.name ?? `rule[${i}]`)}` +
141
+ chalk.gray(` → ${rule.action}`) +
142
+ chalk.gray(` (tool: ${rule.tool})`) +
143
+ "\n"
144
+ );
145
+ }
146
+ process.stderr.write("\n");
147
+ process.exit(0);
148
+ }
149
+
150
+ // Create engine + logger + response scanner
151
+ const policyEngine = new PolicyEngine(config);
152
+ const securityConfig = config.security;
153
+ const logger = new AuditLogger({
154
+ stdout: !options.silent,
155
+ filePath: options.logFile,
156
+ redact: true,
157
+ signing: securityConfig?.signing ?? false,
158
+ signingKey: securityConfig?.signingKey,
159
+ });
160
+
161
+ // Create response scanner from policy config
162
+ const responseScanner = config.responseScanning?.enabled !== false
163
+ ? new ResponseScanner({
164
+ enabled: true,
165
+ maxResponseSize: config.responseScanning?.maxResponseSize,
166
+ oversizeAction: config.responseScanning?.oversizeAction,
167
+ detectSecrets: config.responseScanning?.detectSecrets ?? true,
168
+ detectPII: config.responseScanning?.detectPII ?? false,
169
+ base64Action: config.responseScanning?.base64Action,
170
+ maxPatterns: config.responseScanning?.maxPatterns,
171
+ patterns: config.responseScanning?.patterns,
172
+ })
173
+ : undefined;
174
+
175
+ // Create security modules from config
176
+ const injectionDetector = new InjectionDetector(securityConfig?.injectionDetection);
177
+ const egressControl = new EgressControl(securityConfig?.egressControl);
178
+ const killSwitch = new KillSwitch({
179
+ ...securityConfig?.killSwitch,
180
+ registerSignal: true,
181
+ });
182
+ const chainDetector = new ChainDetector(securityConfig?.chainDetection);
183
+
184
+ // Show security module status
185
+ if (!options.silent) {
186
+ const modules: string[] = [];
187
+ if (securityConfig?.injectionDetection?.enabled !== false) modules.push("injection");
188
+ if (securityConfig?.egressControl?.enabled !== false) modules.push("egress");
189
+ if (securityConfig?.killSwitch?.enabled !== false) modules.push("kill-switch");
190
+ if (securityConfig?.chainDetection?.enabled !== false) modules.push("chain");
191
+ if (securityConfig?.signing) modules.push("signing");
192
+ if (modules.length > 0) {
193
+ process.stderr.write(
194
+ chalk.cyan("║ ") +
195
+ chalk.gray("Security:") +
196
+ chalk.white(` ${modules.join(", ")}`) +
197
+ "\n"
198
+ );
199
+ }
200
+ }
201
+
202
+ // Create proxy
203
+ const proxy = new StdioProxy({
204
+ command,
205
+ args,
206
+ policyEngine,
207
+ responseScanner,
208
+ logger,
209
+ injectionDetector,
210
+ egressControl,
211
+ killSwitch,
212
+ chainDetector,
213
+ onPrompt: createTerminalPromptHandler(),
214
+ onReady: () => {
215
+ if (!options.silent) {
216
+ process.stderr.write(
217
+ chalk.green("✓ ") +
218
+ chalk.gray("MCP server started. Agent Wall is protecting.\n\n")
219
+ );
220
+ }
221
+ },
222
+ onExit: (code) => {
223
+ if (!options.silent) {
224
+ const stats = proxy.getStats();
225
+ process.stderr.write("\n");
226
+ process.stderr.write(
227
+ chalk.cyan("─── Agent Wall Session Summary ────────────────────\n")
228
+ );
229
+ process.stderr.write(
230
+ chalk.gray(" Total calls: ") +
231
+ chalk.white(String(stats.total)) +
232
+ "\n"
233
+ );
234
+ process.stderr.write(
235
+ chalk.gray(" Forwarded: ") +
236
+ chalk.green(String(stats.forwarded)) +
237
+ "\n"
238
+ );
239
+ process.stderr.write(
240
+ chalk.gray(" Denied: ") +
241
+ chalk.red(String(stats.denied)) +
242
+ "\n"
243
+ );
244
+ process.stderr.write(
245
+ chalk.gray(" Prompted: ") +
246
+ chalk.yellow(String(stats.prompted)) +
247
+ "\n"
248
+ );
249
+ if (stats.scanned > 0) {
250
+ process.stderr.write(
251
+ chalk.gray(" Responses scanned: ") +
252
+ chalk.white(String(stats.scanned)) +
253
+ "\n"
254
+ );
255
+ if (stats.responseBlocked > 0) {
256
+ process.stderr.write(
257
+ chalk.gray(" Resp. blocked: ") +
258
+ chalk.red(String(stats.responseBlocked)) +
259
+ "\n"
260
+ );
261
+ }
262
+ if (stats.responseRedacted > 0) {
263
+ process.stderr.write(
264
+ chalk.gray(" Resp. redacted: ") +
265
+ chalk.yellow(String(stats.responseRedacted)) +
266
+ "\n"
267
+ );
268
+ }
269
+ }
270
+ process.stderr.write(
271
+ chalk.cyan("─────────────────────────────────────────────────\n")
272
+ );
273
+ }
274
+ process.exit(code ?? 0);
275
+ },
276
+ onError: (error) => {
277
+ process.stderr.write(
278
+ chalk.red(`\nAgent Wall Error: ${error.message}\n`)
279
+ );
280
+ },
281
+ });
282
+
283
+ // ── Policy hot-reload ────────────────────────────────────────────
284
+ // Watch the policy file for changes and reload without restarting.
285
+ let policyWatcher: fs.FSWatcher | null = null;
286
+ if (filePath) {
287
+ try {
288
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
289
+ policyWatcher = fs.watch(filePath, (eventType) => {
290
+ if (eventType !== "change") return;
291
+ if (debounceTimer) clearTimeout(debounceTimer);
292
+ debounceTimer = setTimeout(() => {
293
+ try {
294
+ const { config: newConfig } = loadPolicy(filePath);
295
+ policyEngine.updateConfig(newConfig);
296
+ if (responseScanner && newConfig.responseScanning) {
297
+ responseScanner.updateConfig(newConfig.responseScanning);
298
+ }
299
+ if (!options.silent) {
300
+ process.stderr.write(
301
+ chalk.green("✓ ") +
302
+ chalk.gray("Policy reloaded from ") +
303
+ chalk.white(filePath) +
304
+ chalk.gray(` (${newConfig.rules.length} rules)\n`)
305
+ );
306
+ }
307
+ } catch (err: unknown) {
308
+ if (!options.silent) {
309
+ const msg = err instanceof Error ? err.message : String(err);
310
+ process.stderr.write(
311
+ chalk.yellow(`⚠ Policy reload failed: ${msg}\n`)
312
+ );
313
+ }
314
+ }
315
+ }, 300); // Debounce: 300ms to handle rapid file saves
316
+ });
317
+ } catch {
318
+ // File watching not available — continue without hot-reload
319
+ }
320
+ }
321
+
322
+ // ── Dashboard ─────────────────────────────────────────────────────
323
+ let dashboardServer: DashboardServer | null = null;
324
+ if (options.dashboard || options.dashboardPort) {
325
+ const dashboardPort = options.dashboardPort ?? 61100;
326
+ const staticDir = resolveDashboardAssets();
327
+
328
+ dashboardServer = new DashboardServer({
329
+ port: dashboardPort,
330
+ proxy,
331
+ killSwitch,
332
+ policyEngine,
333
+ logger,
334
+ staticDir,
335
+ });
336
+
337
+ // Wire audit logger to push entries to dashboard
338
+ logger.setOnEntry((entry) => {
339
+ dashboardServer!.handleAuditEntry(entry);
340
+ });
341
+
342
+ try {
343
+ await dashboardServer.start();
344
+ if (!options.silent) {
345
+ process.stderr.write(
346
+ chalk.cyan("║ ") +
347
+ chalk.gray("Dashboard:") +
348
+ chalk.white(` http://localhost:${dashboardPort}`) +
349
+ "\n"
350
+ );
351
+ }
352
+ } catch (err: unknown) {
353
+ const msg = err instanceof Error ? err.message : String(err);
354
+ process.stderr.write(
355
+ chalk.yellow(`⚠ Dashboard failed to start: ${msg}\n`)
356
+ );
357
+ dashboardServer = null;
358
+ }
359
+ }
360
+
361
+ // Handle process signals
362
+ const shutdown = () => {
363
+ if (policyWatcher) {
364
+ policyWatcher.close();
365
+ policyWatcher = null;
366
+ }
367
+ dashboardServer?.stop();
368
+ proxy.stop();
369
+ };
370
+ process.on("SIGINT", shutdown);
371
+ process.on("SIGTERM", shutdown);
372
+
373
+ // Start the proxy
374
+ try {
375
+ await proxy.start();
376
+ } catch (error: unknown) {
377
+ const msg = error instanceof Error ? error.message : String(error);
378
+ process.stderr.write(
379
+ chalk.red(
380
+ `\nFailed to start MCP server: ${msg}\n\n` +
381
+ "Make sure the server command is correct:\n" +
382
+ ` ${command} ${args.join(" ")}\n`
383
+ )
384
+ );
385
+ process.exit(1);
386
+ }
387
+ }
388
+
389
+ // ── Dashboard Asset Resolution ──────────────────────────────────────
390
+
391
+ function resolveDashboardAssets(): string | undefined {
392
+ // 1. Check for bundled assets (shipped with npm package)
393
+ const bundledDir = path.join(path.dirname(new URL(import.meta.url).pathname), "dashboard");
394
+ if (fs.existsSync(path.join(bundledDir, "index.html"))) {
395
+ return bundledDir;
396
+ }
397
+
398
+ // 2. Try to find @agent-wall/dashboard's built assets via require.resolve
399
+ try {
400
+ const require = createRequire(import.meta.url);
401
+ const pkgPath = require.resolve("@agent-wall/dashboard/package.json");
402
+ const distDir = path.join(path.dirname(pkgPath), "dist");
403
+ if (fs.existsSync(path.join(distDir, "index.html"))) {
404
+ return distDir;
405
+ }
406
+ } catch {
407
+ // Not installed or not built
408
+ }
409
+
410
+ // 3. Fallback: check monorepo path
411
+ const monorepoDist = path.resolve(
412
+ path.dirname(new URL(import.meta.url).pathname),
413
+ "../../dashboard/dist"
414
+ );
415
+ if (fs.existsSync(path.join(monorepoDist, "index.html"))) {
416
+ return monorepoDist;
417
+ }
418
+
419
+ return undefined;
420
+ }