shadow-claw 1.52.0 → 1.52.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/.well-known/mcp/server-card.json +1 -1
  2. package/.well-known/mcp.json +1 -1
  3. package/README.md +9 -9
  4. package/dist/cli/build/build.js +1 -1
  5. package/dist/cli/{chunk-DYq-orHK.js → chunk-BgTv9w0K.js} +1 -1
  6. package/dist/cli/{chunk-C6jii2Fl.js → chunk-d5hIhj8t.js} +1 -1
  7. package/dist/cli/cli.js +1 -1
  8. package/dist/lib/components/index.js +1 -1
  9. package/dist/lib/components/shadow-claw-pages.js +1 -1
  10. package/dist/lib/index.js +1 -1
  11. package/dist/lib/{shadow-claw-pages-kA2qa-4B.js → shadow-claw-pages-CjHS50HN.js} +2 -2
  12. package/dist/lib/{shadow-claw-pages-kA2qa-4B.js.map → shadow-claw-pages-CjHS50HN.js.map} +1 -1
  13. package/dist/public/.well-known/mcp/server-card.json +1 -1
  14. package/dist/public/.well-known/mcp.json +1 -1
  15. package/dist/public/AGENTS.md +1 -1
  16. package/dist/public/README.md +9 -9
  17. package/dist/public/docs/README.md +1 -1
  18. package/dist/public/docs/architecture/orchestrator.md +2 -2
  19. package/dist/public/docs/architecture/overview.md +3 -3
  20. package/dist/public/docs/architecture/worker-protocol.md +29 -29
  21. package/dist/public/docs/example/article/index.html +1 -1
  22. package/dist/public/docs/example/article.html +1 -1
  23. package/dist/public/docs/publishing/index.html +1 -1
  24. package/dist/public/docs/publishing.html +1 -1
  25. package/dist/public/docs/skill-creator/index.html +1 -1
  26. package/dist/public/docs/skill-creator.html +1 -1
  27. package/dist/public/docs/subsystems/local-models.md +1 -1
  28. package/dist/public/docs/subsystems/providers.md +11 -4
  29. package/dist/public/e2e/README.md +2 -2
  30. package/dist/public/index.html +1 -1
  31. package/dist/public/index.js +1 -1
  32. package/dist/public/llms.txt +1 -1
  33. package/dist/public/main/index.html +1 -1
  34. package/dist/public/main/memory/index.html +1 -1
  35. package/dist/public/main/memory.html +1 -1
  36. package/dist/public/main.html +1 -1
  37. package/dist/public/service-worker.js +1 -1
  38. package/dist/public/{shadow-claw-Ba2ZFE9m.js → shadow-claw-T9oPpqxT.js} +1 -1
  39. package/dist/public/{shadow-claw-pages-Cy6T0G9y.js → shadow-claw-pages-IW_LNEL_.js} +1 -1
  40. package/package.json +1 -1
  41. package/src/cli/build/build.test.ts +95 -0
  42. package/src/cli/build/build.ts +5 -1
  43. package/src/cli/commands/agent.test.ts +134 -0
  44. package/src/cli/commands/backup.test.ts +85 -0
  45. package/src/cli/commands/clients.test.ts +32 -0
  46. package/src/cli/commands/mcp.test.ts +260 -1
  47. package/src/cli/commands/send-file.test.ts +73 -0
  48. package/src/cli/commands/send.test.ts +58 -0
  49. package/src/cli/commands/tasks.test.ts +78 -0
  50. package/src/cli/commands/webrtc-listen.test.ts +74 -0
  51. package/src/cli/tools/assert-version-bump.test.ts +30 -1
  52. package/src/cli/tools/file-search-replace.test.ts +33 -1
  53. package/src/cli/tools/patch-service-worker-trusted-types.test.ts +24 -1
  54. package/src/cli/tools/touch-nojekyll.test.ts +12 -0
  55. package/src/cli/tools/version.test.ts +109 -5
  56. package/src/cli/utils/agent-core.test.ts +40 -0
  57. package/src/cli/utils/control-client.test.ts +239 -2
  58. package/src/cli/utils/load-workspace-config.test.ts +63 -0
  59. package/src/cli/utils/local-models.test.ts +98 -0
  60. package/src/cli/utils/progress-bar.test.ts +41 -2
  61. package/src/cli/utils/resolve-cache-dir.test.ts +61 -0
  62. package/src/cli/utils/resolve-project-root.test.ts +17 -0
  63. package/src/cli/utils/stdin.test.ts +38 -0
  64. package/src/cli/utils/stdio.test.ts +51 -1
  65. package/src/cli/utils/suppress-warnings.test.ts +9 -0
  66. package/src/cli/utils/webrtc-control-client.test.ts +569 -0
  67. package/src/common/utils/frontmatter.test.ts +117 -0
  68. package/src/components/common/help/providers.test.ts +67 -0
  69. package/src/components/common/shadow-claw-provider-model-picker/shadow-claw-provider-model-picker.test.ts +121 -0
  70. package/src/components/settings/shadow-claw-accounts/shadow-claw-accounts.test.ts +131 -0
  71. package/src/components/settings/shadow-claw-git/shadow-claw-git.test.ts +130 -0
  72. package/src/components/settings/shadow-claw-integrations/connection-test-auth.test.ts +12 -0
  73. package/src/components/settings/shadow-claw-integrations/shadow-claw-integrations.test.ts +328 -0
  74. package/src/components/settings/shadow-claw-llm/shadow-claw-llm.test.ts +530 -8
  75. package/src/components/settings/shadow-claw-mcp-remote/shadow-claw-mcp-remote.test.ts +459 -0
  76. package/src/components/settings/shadow-claw-networking/shadow-claw-networking.test.ts +88 -0
  77. package/src/components/settings/shadow-claw-notifications/shadow-claw-notifications.test.ts +92 -0
  78. package/src/components/settings/shadow-claw-peerjs/shadow-claw-peerjs.test.ts +127 -0
  79. package/src/components/settings/shadow-claw-storage/shadow-claw-storage.test.ts +173 -0
  80. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.test.ts +62 -0
  81. package/src/components/settings/shadow-claw-webvm/shadow-claw-webvm.test.ts +104 -0
  82. package/src/components/shadow-claw/shadow-claw.test.ts +112 -0
  83. package/src/components/shadow-claw-a2ui/shadow-claw-a2ui.test.ts +109 -0
  84. package/src/components/shadow-claw-conversations/shadow-claw-conversations.test.ts +361 -0
  85. package/src/components/shadow-claw-dialog/shadow-claw-dialog.test.ts +76 -0
  86. package/src/components/shadow-claw-file-viewer/shadow-claw-file-viewer.test.ts +307 -0
  87. package/src/components/shadow-claw-files/shadow-claw-files.test.ts +398 -1
  88. package/src/components/shadow-claw-pages/shadow-claw-pages.test.ts +85 -0
  89. package/src/components/shadow-claw-pages/shadow-claw-pages.ts +2 -1
  90. package/src/components/shadow-claw-pdf-viewer/shadow-claw-pdf-viewer.test.ts +229 -0
  91. package/src/components/shadow-claw-settings/shadow-claw-settings.test.ts +233 -0
  92. package/src/components/shadow-claw-tasks/shadow-claw-tasks.test.ts +223 -1
  93. package/src/components/shadow-claw-toast/shadow-claw-toast.test.ts +79 -0
  94. package/src/components/shadow-claw-tools/shadow-claw-tools.test.ts +229 -1
  95. package/src/server/logger.test.ts +57 -0
  96. package/src/server/peer.test.ts +47 -0
  97. package/src/server/routes/gemini.test.ts +244 -0
  98. package/src/server/server-peer.test.ts +245 -11
  99. package/src/server/services/llamafile-manager.test.ts +668 -0
  100. package/src/share-target/pending-shares.test.ts +90 -0
  101. package/src/shell/fs.test.ts +125 -0
  102. package/src/shell/shell.test.ts +8 -0
  103. package/src/subsystems/git/credentials.test.ts +260 -0
  104. package/src/subsystems/git/git.test.ts +64 -0
  105. package/src/subsystems/providers/executeNativeAiTask.test.ts +78 -0
  106. package/src/subsystems/providers/utils/parseLocalModelToolCall.test.ts +57 -0
  107. package/src/worker/tools/builtin-ai/builtin-ai.test.ts +88 -0
  108. package/src/worker/tools/email/email.test.ts +358 -0
  109. package/src/worker/tools/email/utils/resolveIntegrationEmailAuth.test.ts +120 -0
  110. package/src/worker/tools/email/utils/resolveUniqueAttachmentPath.test.ts +80 -0
  111. package/src/worker/tools/fetch-file/fetch-file.test.ts +111 -0
  112. package/src/worker/tools/fetch-url/fetch-url.test.ts +295 -0
@@ -361,4 +361,99 @@ describe("build without and with pages", () => {
361
361
  await rm(tempConsumerRoot, { recursive: true, force: true });
362
362
  }
363
363
  });
364
+
365
+ it("builds an external consumer with copyAllAssets, pagesOrigin, scripts, and service worker", async () => {
366
+ const tempConsumerRoot = await mkdtemp(
367
+ path.join(os.tmpdir(), "shadow-claw-consumer-prod-"),
368
+ );
369
+ const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
370
+ try {
371
+ await mkdir(path.join(tempConsumerRoot, "assets/icons"), {
372
+ recursive: true,
373
+ });
374
+ await mkdir(path.join(tempConsumerRoot, "assets/fonts"), {
375
+ recursive: true,
376
+ });
377
+ await writeFile(
378
+ path.join(tempConsumerRoot, "assets/icons/icon.png"),
379
+ "icon",
380
+ );
381
+ await writeFile(
382
+ path.join(tempConsumerRoot, "assets/fonts/font.woff2"),
383
+ "font",
384
+ );
385
+ await mkdir(path.join(tempConsumerRoot, ".agents/scripts"), {
386
+ recursive: true,
387
+ });
388
+ await writeFile(
389
+ path.join(tempConsumerRoot, ".agents/scripts/run.sh"),
390
+ "echo hi",
391
+ );
392
+ await writeFile(
393
+ path.join(tempConsumerRoot, "manifest.json"),
394
+ JSON.stringify({ start_url: "/initial" }),
395
+ );
396
+
397
+ // Create a dummy service-worker.js in tempProjectRoot/dist/public if missing
398
+ const swPath = path.join(
399
+ tempProjectRoot,
400
+ "dist/public/service-worker.js",
401
+ );
402
+ if (!fs.existsSync(swPath)) {
403
+ await writeFile(swPath, "// sw dummy\nimportScripts();");
404
+ }
405
+
406
+ await runConsumerBuild({
407
+ contentRoot: tempConsumerRoot,
408
+ toolchainRoot: tempProjectRoot,
409
+ isProduction: true,
410
+ copyAllAssets: true,
411
+ pagesOrigin: "https://consumer.example.com",
412
+ quiet: false,
413
+ stdio: "pipe",
414
+ });
415
+
416
+ const distDir = path.join(tempConsumerRoot, "dist/public");
417
+ expect(fs.existsSync(path.join(distDir, ".nojekyll"))).toBe(true);
418
+ expect(fs.existsSync(path.join(distDir, ".agents/scripts/run.sh"))).toBe(
419
+ true,
420
+ );
421
+
422
+ const manifest = JSON.parse(
423
+ await readFile(path.join(distDir, "manifest.json"), "utf8"),
424
+ );
425
+ expect(manifest.start_url).toBe("https://consumer.example.com");
426
+ } finally {
427
+ logSpy.mockRestore();
428
+ await rm(tempConsumerRoot, { recursive: true, force: true });
429
+ }
430
+ });
431
+
432
+ it("builds in-repo with isProduction, copyAllAssets, and prerenderMainMemory false", async () => {
433
+ const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
434
+ try {
435
+ await runConsumerBuild({
436
+ contentRoot: projectRoot,
437
+ toolchainRoot: projectRoot,
438
+ isProduction: true,
439
+ copyAllAssets: true,
440
+ prerenderMainMemory: false,
441
+ pagesOrigin: "https://shadow-claw.example.com/",
442
+ basePath: "/shadow-claw/",
443
+ quiet: false,
444
+ stdio: "pipe",
445
+ });
446
+
447
+ const indexHtml = await readFile(
448
+ path.join(projectRoot, "dist/public/index.html"),
449
+ "utf8",
450
+ );
451
+ expect(indexHtml).toContain('<base href="/shadow-claw/"');
452
+ expect(
453
+ fs.existsSync(path.join(projectRoot, "dist/public/.nojekyll")),
454
+ ).toBe(true);
455
+ } finally {
456
+ logSpy.mockRestore();
457
+ }
458
+ });
364
459
  });
@@ -341,7 +341,11 @@ export async function runBuild(options: RunBuildOptions = {}): Promise<void> {
341
341
  src.includes("/assets/cache/") ||
342
342
  src.includes("\\assets\\cache\\") ||
343
343
  src.includes("/.cache/") ||
344
- src.includes("\\.cache\\");
344
+ src.includes("\\.cache\\") ||
345
+ src.includes("/cache/") ||
346
+ src.includes("\\cache\\") ||
347
+ src.endsWith("/cache") ||
348
+ src.endsWith("\\cache");
345
349
  return !isCache;
346
350
  },
347
351
  });
@@ -3267,4 +3267,138 @@ Fetch today's weather.
3267
3267
  expect(calledOpts.model).toBe("test-model");
3268
3268
  expect(calledOpts.agent).toBe(true);
3269
3269
  });
3270
+
3271
+ it("prints usage when unknown subcommand passed to runAgentCommand", async () => {
3272
+ const { runAgentCommand } = await import("./agent.js");
3273
+ const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
3274
+
3275
+ await runAgentCommand("unknown-command", [], {});
3276
+
3277
+ expect(logSpy).toHaveBeenCalledWith(
3278
+ expect.stringContaining("Usage: shadow-claw agent"),
3279
+ );
3280
+ logSpy.mockRestore();
3281
+ });
3282
+
3283
+ it("handles skill subcommand without name", async () => {
3284
+ const { runAgentCommand } = await import("./agent.js");
3285
+ const errSpy = jest.spyOn(console, "error").mockImplementation(() => {});
3286
+
3287
+ await runAgentCommand("skill", [], {});
3288
+
3289
+ expect(errSpy).toHaveBeenCalledWith(
3290
+ expect.stringContaining("Skill name required"),
3291
+ );
3292
+ expect(process.exitCode).toBe(1);
3293
+ errSpy.mockRestore();
3294
+ });
3295
+
3296
+ it("executes default listenFn dynamically importing webrtc-listen", async () => {
3297
+ const { runAgentCommand } = await import("./agent.js");
3298
+ const { CliWebRtcListener } =
3299
+ await import("../utils/webrtc-control-client.js");
3300
+ jest
3301
+ .spyOn(CliWebRtcListener.prototype, "start")
3302
+ .mockRejectedValueOnce(new Error("Listen stop"));
3303
+ const errSpy = jest.spyOn(console, "error").mockImplementation(() => {});
3304
+
3305
+ await runAgentCommand("listen", [], { workspace: tmpDir });
3306
+
3307
+ expect(errSpy).toHaveBeenCalledWith(
3308
+ expect.stringContaining("Error starting WebRTC listener: Listen stop"),
3309
+ );
3310
+ errSpy.mockRestore();
3311
+ });
3312
+
3313
+ it("dispatches init, skills, tools, tool, clear, and models via runAgentCommand", async () => {
3314
+ const { runAgentCommand } = await import("./agent.js");
3315
+ const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
3316
+ const errSpy = jest.spyOn(console, "error").mockImplementation(() => {});
3317
+
3318
+ // init
3319
+ const initRes = await runAgentCommand("init", [], { workspace: tmpDir });
3320
+ expect(initRes.success).toBe(true);
3321
+
3322
+ // skills (with a skill present)
3323
+ const dummySkillDir = path.join(tmpDir, ".agents/skills/dummy");
3324
+ await mkdir(dummySkillDir, { recursive: true });
3325
+ await writeFile(
3326
+ path.join(dummySkillDir, "SKILL.md"),
3327
+ "---\nname: dummy\ndescription: Test skill description\n---\nBody",
3328
+ "utf8",
3329
+ );
3330
+ const skillsRes = await runAgentCommand("skills", [], {
3331
+ workspace: tmpDir,
3332
+ });
3333
+ expect(skillsRes.skills.length).toBeGreaterThan(0);
3334
+
3335
+ // tools (list)
3336
+ const toolsRes = await runAgentCommand("tools", [], { workspace: tmpDir });
3337
+ expect(toolsRes.tools).toBeDefined();
3338
+
3339
+ // tool (missing name)
3340
+ await runAgentCommand("tool", [], { workspace: tmpDir });
3341
+ expect(errSpy).toHaveBeenCalledWith(
3342
+ expect.stringContaining("Tool name required"),
3343
+ );
3344
+
3345
+ // clear
3346
+ const clearRes = await runAgentCommand("clear", [], { workspace: tmpDir });
3347
+ expect(clearRes.success).toBe(true);
3348
+
3349
+ // model list
3350
+ const modelRes = await runAgentCommand("models", ["list"], {
3351
+ workspace: tmpDir,
3352
+ quiet: true,
3353
+ });
3354
+ expect(modelRes.success).toBe(true);
3355
+
3356
+ // tools with toolName
3357
+ const toolCallRes = await runAgentCommand(
3358
+ "tools",
3359
+ ["bash", '{"command":"echo hi"}'],
3360
+ { workspace: tmpDir, quiet: true },
3361
+ );
3362
+ expect(toolCallRes).toBeDefined();
3363
+
3364
+ // skill with skillName
3365
+ await expect(
3366
+ runAgentCommand("skill", ["non-existent-skill"], { workspace: tmpDir }),
3367
+ ).rejects.toThrow("not found in workspace");
3368
+
3369
+ // run with prompt
3370
+ const { getAgentCore } = await import("../utils/agent-core.js");
3371
+ const core = await getAgentCore();
3372
+ const runRes = await runAgentCommand("run", ["Hello agent"], {
3373
+ workspace: tmpDir,
3374
+ provider: "openrouter",
3375
+ apiKey: "sk-test",
3376
+ invokeHandler: async (_db, payload) => {
3377
+ core.post({
3378
+ type: "response",
3379
+ payload: { groupId: payload.groupId, text: "Ready." },
3380
+ });
3381
+ },
3382
+ quiet: true,
3383
+ });
3384
+ expect(runRes.success).toBe(true);
3385
+
3386
+ // run with empty prompt
3387
+ const runEmptyRes = await runAgentCommand("run", [], {
3388
+ workspace: tmpDir,
3389
+ provider: "openrouter",
3390
+ apiKey: "sk-test",
3391
+ invokeHandler: async (_db, payload) => {
3392
+ core.post({
3393
+ type: "response",
3394
+ payload: { groupId: payload.groupId, text: "Ready." },
3395
+ });
3396
+ },
3397
+ quiet: true,
3398
+ });
3399
+ expect(runEmptyRes.success).toBe(false);
3400
+
3401
+ logSpy.mockRestore();
3402
+ errSpy.mockRestore();
3403
+ });
3270
3404
  });
@@ -22,6 +22,7 @@ describe("runBackupCommand", () => {
22
22
  afterEach(() => {
23
23
  logSpy.mockRestore();
24
24
  errorSpy.mockRestore();
25
+ jest.restoreAllMocks();
25
26
  process.exitCode = 0;
26
27
  });
27
28
 
@@ -64,4 +65,88 @@ describe("runBackupCommand", () => {
64
65
  );
65
66
  expect(process.exitCode).toBe(1);
66
67
  });
68
+
69
+ it("deletes backup when backupId is provided", async () => {
70
+ const deleteSpy = jest
71
+ .spyOn(CliControlClient.prototype, "deleteBackup")
72
+ .mockResolvedValueOnce(undefined as any);
73
+
74
+ await runBackupCommand("delete", {
75
+ backupId: "bk-123",
76
+ client: "client-1",
77
+ });
78
+
79
+ expect(deleteSpy).toHaveBeenCalledWith("bk-123", "client-1");
80
+ expect(logSpy).toHaveBeenCalledWith("Backup bk-123 deleted.");
81
+ });
82
+
83
+ it("triggers backup with provided client successfully", async () => {
84
+ const sendSpy = jest
85
+ .spyOn(CliControlClient.prototype, "sendCommand")
86
+ .mockResolvedValueOnce({ success: true, data: { fileCount: 10 } });
87
+
88
+ await runBackupCommand("trigger", {
89
+ client: "client-target",
90
+ group: "grp-1",
91
+ });
92
+
93
+ expect(sendSpy).toHaveBeenCalledWith("client-target", "trigger-backup", {
94
+ groupId: "grp-1",
95
+ token: expect.anything(),
96
+ });
97
+ expect(logSpy).toHaveBeenCalledWith("Backup completed successfully.");
98
+ });
99
+
100
+ it("triggers backup falling back to first client from listClients", async () => {
101
+ jest
102
+ .spyOn(CliControlClient.prototype, "listClients")
103
+ .mockResolvedValueOnce([{ clientId: "auto-client" }]);
104
+ const sendSpy = jest
105
+ .spyOn(CliControlClient.prototype, "sendCommand")
106
+ .mockResolvedValueOnce({ success: true, data: {} });
107
+
108
+ await runBackupCommand("trigger", {});
109
+
110
+ expect(sendSpy).toHaveBeenCalledWith("auto-client", "trigger-backup", {
111
+ groupId: undefined,
112
+ token: expect.anything(),
113
+ });
114
+ });
115
+
116
+ it("errors when triggering backup and no clients are connected", async () => {
117
+ jest
118
+ .spyOn(CliControlClient.prototype, "listClients")
119
+ .mockResolvedValueOnce([]);
120
+
121
+ await runBackupCommand("trigger", {});
122
+
123
+ expect(errorSpy).toHaveBeenCalledWith(
124
+ "Error: No clients are connected to back up.",
125
+ );
126
+ expect(process.exitCode).toBe(1);
127
+ });
128
+
129
+ it("handles backup failure response", async () => {
130
+ jest
131
+ .spyOn(CliControlClient.prototype, "sendCommand")
132
+ .mockResolvedValueOnce({ success: false, error: "Disk full" });
133
+
134
+ await runBackupCommand("trigger", { client: "client-fail" });
135
+
136
+ expect(errorSpy).toHaveBeenCalledWith("Backup failed: Disk full");
137
+ expect(process.exitCode).toBe(1);
138
+ });
139
+
140
+ it("handles exception thrown during execution", async () => {
141
+ jest
142
+ .spyOn(CliControlClient.prototype, "listBackups")
143
+ .mockRejectedValueOnce(new Error("Network timeout"));
144
+
145
+ await runBackupCommand("list", {});
146
+
147
+ expect(errorSpy).toHaveBeenCalledWith(
148
+ "Error executing backup command: Network timeout",
149
+ );
150
+ expect(process.exitCode).toBe(1);
151
+ });
67
152
  });
@@ -57,4 +57,36 @@ describe("runClientsCommand", () => {
57
57
  "No clients currently registered or connected.",
58
58
  );
59
59
  });
60
+
61
+ it("formats client without peerId, empty capabilities, and older lastSeen", async () => {
62
+ jest
63
+ .spyOn(CliControlClient.prototype, "listClients")
64
+ .mockResolvedValueOnce([
65
+ {
66
+ clientId: "c-old",
67
+ deviceLabel: "Headless Agent",
68
+ capabilities: [],
69
+ version: "2.0.0",
70
+ lastSeen: Date.now() - 125000,
71
+ },
72
+ ]);
73
+
74
+ await runClientsCommand({});
75
+
76
+ expect(logSpy).toHaveBeenCalledWith(" Capabilities: none");
77
+ expect(logSpy).toHaveBeenCalledWith(" Last seen: 2m ago\n");
78
+ });
79
+
80
+ it("handles exception during fetch", async () => {
81
+ jest
82
+ .spyOn(CliControlClient.prototype, "listClients")
83
+ .mockRejectedValueOnce(new Error("Connection refused"));
84
+
85
+ await runClientsCommand({});
86
+
87
+ expect(errorSpy).toHaveBeenCalledWith(
88
+ "Error fetching clients: Connection refused",
89
+ );
90
+ expect(process.exitCode).toBe(1);
91
+ });
60
92
  });
@@ -11,7 +11,9 @@ import {
11
11
  beforeEach,
12
12
  afterEach,
13
13
  } from "@jest/globals";
14
- import { createCliMcpEngine } from "./mcp.js";
14
+ import http from "node:http";
15
+ import readline from "node:readline";
16
+ import { createCliMcpEngine, runMcpCommand } from "./mcp.js";
15
17
 
16
18
  const pkg = JSON.parse(
17
19
  fs.readFileSync(
@@ -1086,5 +1088,262 @@ describe("CLI MCP Engine (createCliMcpEngine)", () => {
1086
1088
  expect(toolNames).not.toContain("shadowclaw_local_write_file");
1087
1089
  expect(toolNames).not.toContain("shadowclaw_local_bash");
1088
1090
  });
1091
+
1092
+ it("supports setting and getting activeClientId", () => {
1093
+ const engine = createCliMcpEngine({});
1094
+ expect(engine.getActiveClientId()).toBe("");
1095
+ engine.setActiveClientId?.("client-custom-1");
1096
+ expect(engine.getActiveClientId()).toBe("client-custom-1");
1097
+ });
1098
+
1099
+ it("handles tasks/get method", async () => {
1100
+ const engine = createCliMcpEngine({});
1101
+ const res = await engine.handleMessage({
1102
+ jsonrpc: "2.0",
1103
+ id: 50,
1104
+ method: "tasks/get",
1105
+ params: { taskId: "task-test-99" },
1106
+ });
1107
+ expect(res.result.task.taskId).toBe("task-test-99");
1108
+ expect(res.result.task.status).toBe("completed");
1109
+ });
1110
+
1111
+ it("handles unknown method returning error or null for notification", async () => {
1112
+ const engine = createCliMcpEngine({});
1113
+ const errRes = await engine.handleMessage({
1114
+ jsonrpc: "2.0",
1115
+ id: 51,
1116
+ method: "unknown/method",
1117
+ });
1118
+ expect(errRes.error.code).toBe(-32601);
1119
+
1120
+ const notifRes = await engine.handleMessage({
1121
+ jsonrpc: "2.0",
1122
+ method: "unknown/notification",
1123
+ });
1124
+ expect(notifRes).toBeNull();
1125
+ });
1126
+
1127
+ it("handles tools/call when no client is available", async () => {
1128
+ const engine = createCliMcpEngine({
1129
+ client: { listClients: async () => [] },
1130
+ });
1131
+ const res = await engine.handleMessage({
1132
+ jsonrpc: "2.0",
1133
+ id: 52,
1134
+ method: "tools/call",
1135
+ params: {
1136
+ name: "remote_tool_1",
1137
+ arguments: {},
1138
+ },
1139
+ });
1140
+ expect(res.result.isError).toBe(true);
1141
+ expect(res.result.content[0].text).toContain(
1142
+ "No connected client available",
1143
+ );
1144
+ });
1145
+
1146
+ it("closes sqlite database when engine.close is invoked after headless context creation", async () => {
1147
+ const tmpDir = await mkdtemp(path.join(tmpdir(), "sc-mcp-close-test-"));
1148
+ const engine = createCliMcpEngine({
1149
+ workspace: tmpDir,
1150
+ localTools: true,
1151
+ });
1152
+
1153
+ // Invoke local tool to initialize headlessContextPromise
1154
+ await engine.handleMessage({
1155
+ jsonrpc: "2.0",
1156
+ id: 53,
1157
+ method: "tools/call",
1158
+ params: {
1159
+ name: "shadowclaw_local_list_files",
1160
+ arguments: {},
1161
+ },
1162
+ });
1163
+
1164
+ // Call close
1165
+ await engine.close?.();
1166
+ await rm(tmpDir, { recursive: true, force: true });
1167
+ });
1168
+ });
1169
+
1170
+ describe("runMcpCommand", () => {
1171
+ let tmpWorkspace: string;
1172
+
1173
+ beforeEach(async () => {
1174
+ tmpWorkspace = await mkdtemp(path.join(tmpdir(), "sc-mcp-cmd-test-"));
1175
+ });
1176
+
1177
+ afterEach(async () => {
1178
+ try {
1179
+ await rm(tmpWorkspace, { recursive: true, force: true });
1180
+ } catch (_) {}
1181
+ });
1182
+
1183
+ it("handles HTTP transport requests including 404, parse error, success, notification, and 500", async () => {
1184
+ let requestHandler: any;
1185
+ const mockServer = {
1186
+ listen: jest.fn((_port: any, _host: any, cb: any) => cb?.()),
1187
+ close: jest.fn(),
1188
+ };
1189
+ jest.spyOn(http, "createServer").mockImplementation((handler: any) => {
1190
+ requestHandler = handler;
1191
+ return mockServer as any;
1192
+ });
1193
+
1194
+ await runMcpCommand({
1195
+ mcpTransport: "http",
1196
+ port: 9999,
1197
+ host: "127.0.0.1",
1198
+ workspace: tmpWorkspace,
1199
+ });
1200
+
1201
+ expect(http.createServer).toHaveBeenCalled();
1202
+ expect(mockServer.listen).toHaveBeenCalledWith(
1203
+ 9999,
1204
+ "127.0.0.1",
1205
+ expect.any(Function),
1206
+ );
1207
+
1208
+ // 1. Non-POST or wrong URL -> 404
1209
+ const res404 = {
1210
+ writeHead: jest.fn(),
1211
+ end: jest.fn(),
1212
+ };
1213
+ await requestHandler({ method: "GET", url: "/mcp" }, res404);
1214
+ expect(res404.writeHead).toHaveBeenCalledWith(404, expect.any(Object));
1215
+
1216
+ // Helper to simulate request events
1217
+ const invokeHandler = (reqMeta: any, rawData: string) => {
1218
+ return new Promise<any>((resolve) => {
1219
+ const res = {
1220
+ writeHead: jest.fn(),
1221
+ end: jest.fn(() => resolve(res)),
1222
+ };
1223
+ const handlers: Record<string, Function> = {};
1224
+ const req: any = {
1225
+ method: "POST",
1226
+ url: "/mcp",
1227
+ headers: {},
1228
+ ...reqMeta,
1229
+ on: (event: string, cb: any) => {
1230
+ handlers[event] = cb;
1231
+ },
1232
+ };
1233
+ Promise.resolve(requestHandler(req, res)).then(() => {
1234
+ handlers["data"]?.(rawData);
1235
+ handlers["end"]?.();
1236
+ });
1237
+ });
1238
+ };
1239
+
1240
+ // 2. Parse error -> 400
1241
+ const res400 = await invokeHandler({}, "not-valid-json");
1242
+ expect(res400.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
1243
+
1244
+ // 3. Valid JSON-RPC method -> 200
1245
+ const validReq = JSON.stringify({
1246
+ jsonrpc: "2.0",
1247
+ id: 1,
1248
+ method: "tools/list",
1249
+ });
1250
+ const res200 = await invokeHandler(
1251
+ {
1252
+ headers: {
1253
+ "mcp-protocol-version": "2026-07-28",
1254
+ "mcp-method": "tools/list",
1255
+ "mcp-name": "test-client",
1256
+ },
1257
+ },
1258
+ validReq,
1259
+ );
1260
+ expect(res200.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
1261
+
1262
+ // 4. Notification (reply === null) -> 202
1263
+ const notifReq = JSON.stringify({
1264
+ jsonrpc: "2.0",
1265
+ method: "notifications/initialized",
1266
+ });
1267
+ const res202 = await invokeHandler({}, notifReq);
1268
+ expect(res202.writeHead).toHaveBeenCalledWith(202);
1269
+
1270
+ // 5. Error during handling -> 500
1271
+ const throwingHeaders = {
1272
+ get ["mcp-protocol-version"]() {
1273
+ throw new Error("Boom headers");
1274
+ },
1275
+ };
1276
+ const res500 = await invokeHandler(
1277
+ { headers: throwingHeaders },
1278
+ validReq,
1279
+ );
1280
+ expect(res500.writeHead).toHaveBeenCalledWith(500, expect.any(Object));
1281
+
1282
+ // Restore
1283
+ jest.spyOn(http, "createServer").mockRestore();
1284
+ });
1285
+
1286
+ it("handles STDIO transport lines including parse error, valid request, notification, and close", async () => {
1287
+ let lineHandler: any;
1288
+ let closeHandler: any;
1289
+ const mockRl = {
1290
+ on: jest.fn((event: string, cb: any) => {
1291
+ if (event === "line") lineHandler = cb;
1292
+ if (event === "close") closeHandler = cb;
1293
+ return mockRl;
1294
+ }),
1295
+ };
1296
+ jest.spyOn(readline, "createInterface").mockReturnValue(mockRl as any);
1297
+ const writeSpy = jest
1298
+ .spyOn(process.stdout, "write")
1299
+ .mockImplementation(() => true);
1300
+
1301
+ await runMcpCommand({
1302
+ mcpTransport: "stdio",
1303
+ workspace: tmpWorkspace,
1304
+ });
1305
+
1306
+ expect(readline.createInterface).toHaveBeenCalled();
1307
+
1308
+ // Empty line ignored
1309
+ await lineHandler(" ");
1310
+ expect(writeSpy).not.toHaveBeenCalled();
1311
+
1312
+ // Parse error
1313
+ await lineHandler("bad-json");
1314
+ expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining("-32700"));
1315
+ writeSpy.mockClear();
1316
+
1317
+ // Valid tools/list
1318
+ await lineHandler(
1319
+ JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }),
1320
+ );
1321
+ expect(writeSpy).toHaveBeenCalledWith(
1322
+ expect.stringContaining('"result"'),
1323
+ );
1324
+ writeSpy.mockClear();
1325
+
1326
+ // Exception during write triggering catch block (lines 1613-1620)
1327
+ writeSpy.mockImplementationOnce(() => {
1328
+ throw new Error("Stdout write failure");
1329
+ });
1330
+ await lineHandler(
1331
+ JSON.stringify({ jsonrpc: "2.0", id: 3, method: "tools/list" }),
1332
+ );
1333
+ expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining("-32603"));
1334
+ writeSpy.mockClear();
1335
+
1336
+ // Notification
1337
+ await lineHandler(
1338
+ JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
1339
+ );
1340
+ expect(writeSpy).not.toHaveBeenCalled();
1341
+
1342
+ // Close handler
1343
+ await closeHandler();
1344
+
1345
+ writeSpy.mockRestore();
1346
+ jest.spyOn(readline, "createInterface").mockRestore();
1347
+ });
1089
1348
  });
1090
1349
  });