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
@@ -1,13 +1,23 @@
1
1
  import http from "node:http";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
2
5
  import express from "express";
3
- import { describe, it, expect, beforeEach, afterEach } from "@jest/globals";
6
+ import {
7
+ describe,
8
+ it,
9
+ expect,
10
+ beforeEach,
11
+ afterEach,
12
+ jest,
13
+ } from "@jest/globals";
4
14
  import {
5
15
  openClientStore,
6
16
  closeClientStore,
7
17
  } from "../../server/client-registry.js";
8
18
  import { createControlPlane } from "../../server/control-plane.js";
9
19
  import { registerBackupRoutes } from "../../server/routes/backup.js";
10
- import { CliControlClient } from "./control-client.js";
20
+ import { CliControlClient, resolveControlToken } from "./control-client.js";
11
21
 
12
22
  describe("src/cli/utils/control-client.ts", () => {
13
23
  let app: any;
@@ -39,6 +49,27 @@ describe("src/cli/utils/control-client.ts", () => {
39
49
  res.json({ sent: 1, failed: 0, received: req.body });
40
50
  });
41
51
 
52
+ app.post("/api/control/command", (req: any, res: any) => {
53
+ res.json({ ok: true, result: "command-executed", body: req.body });
54
+ });
55
+
56
+ app.delete("/api/backup/:id", (req: any, res: any) => {
57
+ res.json({ ok: true, deleted: req.params.id });
58
+ });
59
+
60
+ app.get("/push/subscriptions", (_req: any, res: any) => {
61
+ res.json([
62
+ { client_id: "client-sub-1", device_label: "Device 1" },
63
+ { client_id: "client-sub-2" },
64
+ ]);
65
+ });
66
+
67
+ app.get("/push/clients", (_req: any, res: any) => {
68
+ res.json({
69
+ clients: [{ clientId: "client-push-1", deviceLabel: "Push 1" }],
70
+ });
71
+ });
72
+
42
73
  await new Promise<void>((resolve) => {
43
74
  server.listen(0, "127.0.0.1", () => {
44
75
  port = server.address().port;
@@ -149,4 +180,210 @@ describe("src/cli/utils/control-client.ts", () => {
149
180
  expect(Array.isArray(clients)).toBe(true);
150
181
  expect(client.token).toBe(token);
151
182
  });
183
+
184
+ it("deletes a backup via deleteBackup", async () => {
185
+ const client = new CliControlClient({
186
+ host: "127.0.0.1",
187
+ port,
188
+ token,
189
+ });
190
+ const res = await client.deleteBackup("backup-123", "client-1");
191
+ expect(res).toEqual({ success: true, deleted: "backup-123" });
192
+ });
193
+
194
+ it("lists push subscriptions and clients", async () => {
195
+ const client = new CliControlClient({
196
+ host: "127.0.0.1",
197
+ port,
198
+ token,
199
+ });
200
+ const subs = await client.listPushSubscriptions();
201
+ expect(subs).toHaveLength(2);
202
+
203
+ const pushClients = await client.listPushClients();
204
+ expect(pushClients).toHaveLength(1);
205
+ expect(pushClients[0].clientId).toBe("client-push-1");
206
+ });
207
+
208
+ it("sends commands and files via sendCommand and sendFile", async () => {
209
+ const client = new CliControlClient({
210
+ host: "127.0.0.1",
211
+ port,
212
+ token,
213
+ });
214
+ await expect(
215
+ client.sendCommand("client-1", "ping", { foo: "bar" }),
216
+ ).rejects.toThrow("Client not connected: client-1");
217
+
218
+ // sendFile error: file not found
219
+ await expect(
220
+ client.sendFile("client-1", "/non/existent/file.txt"),
221
+ ).rejects.toThrow("File not found");
222
+
223
+ // sendFile error: directory
224
+ await expect(client.sendFile("client-1", os.tmpdir())).rejects.toThrow(
225
+ "Cannot send directory",
226
+ );
227
+
228
+ // sendFile success attempt
229
+ const tmpFile = path.join(os.tmpdir(), `test-file-${Date.now()}.txt`);
230
+ fs.writeFileSync(tmpFile, "hello shadow-claw", "utf8");
231
+ try {
232
+ await expect(
233
+ client.sendFile("client-1", tmpFile, { prompt: "Read this" }),
234
+ ).rejects.toThrow("Client not connected: client-1");
235
+ } finally {
236
+ fs.unlinkSync(tmpFile);
237
+ }
238
+ });
239
+
240
+ it("supports WebRTC transport delegation and fallbacks", async () => {
241
+ const client = new CliControlClient({
242
+ host: "127.0.0.1",
243
+ port,
244
+ token,
245
+ transport: "webrtc",
246
+ });
247
+ expect(client._webrtcClient).not.toBeNull();
248
+
249
+ // 1. listClients with webrtc clients
250
+ client._webrtcClient!.listClients = jest
251
+ .fn()
252
+ .mockResolvedValue([
253
+ { clientId: "client-peer-ok", peerId: "peer-ok-id" },
254
+ { clientId: "client-no-peer" },
255
+ ]);
256
+ client._webrtcClient!.sendCommand = jest
257
+ .fn()
258
+ .mockResolvedValue({ ok: true, webrtc: true });
259
+
260
+ const clients = await client.listClients();
261
+ expect(clients[0].clientId).toBe("client-peer-ok");
262
+
263
+ // 2. sendCommand with matching peerId
264
+ const cmdRes = await client.sendCommand(
265
+ "client-peer-ok",
266
+ "test-action",
267
+ {},
268
+ );
269
+ expect(cmdRes).toEqual({ ok: true, webrtc: true });
270
+ expect(client._webrtcClient!.sendCommand).toHaveBeenCalledWith(
271
+ "peer-ok-id",
272
+ "test-action",
273
+ {},
274
+ 30000,
275
+ );
276
+
277
+ // 3. sendCommand with matching client that has NO peerId (triggers warn)
278
+ const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
279
+ try {
280
+ await client.sendCommand("client-no-peer", "test-action", {});
281
+ expect(warnSpy).toHaveBeenCalledWith(
282
+ expect.stringContaining("has no PeerJS peer ID registered"),
283
+ );
284
+ } finally {
285
+ warnSpy.mockRestore();
286
+ }
287
+
288
+ // 4. listClients fallback to control plane when webrtc listClients returns empty
289
+ client._webrtcClient!.listClients = jest.fn().mockResolvedValue([]);
290
+ const cpClients = await client.listClients();
291
+ expect(Array.isArray(cpClients)).toBe(true);
292
+ });
293
+
294
+ it("resolves control token from cacheDir and custom options", () => {
295
+ expect(resolveControlToken("custom-tok-123")).toBe("custom-tok-123");
296
+
297
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ctrl-tok-test-"));
298
+ try {
299
+ const tokFile = path.join(tempDir, "control-token.json");
300
+ fs.writeFileSync(
301
+ tokFile,
302
+ JSON.stringify({ token: "cached-file-tok" }),
303
+ "utf8",
304
+ );
305
+ const resolved = resolveControlToken(undefined, tempDir);
306
+ expect(resolved).toBe("cached-file-tok");
307
+ } finally {
308
+ fs.rmSync(tempDir, { recursive: true, force: true });
309
+ }
310
+ });
311
+
312
+ it("falls back to listPushSubscriptions when /push/clients fails", async () => {
313
+ const client = new CliControlClient({
314
+ host: "127.0.0.1",
315
+ port,
316
+ token,
317
+ });
318
+ jest
319
+ .spyOn(client as any, "_request")
320
+ .mockImplementation(async (opts: any) => {
321
+ if (opts.path === "/push/clients") {
322
+ throw new Error("404 Not Found");
323
+ }
324
+ if (opts.path === "/push/subscriptions") {
325
+ return [
326
+ { client_id: "client-sub-1", device_label: "Device 1" },
327
+ { client_id: "client-sub-1", device_label: "Device 1 duplicate" },
328
+ { id: 42, device_label: "Device ID Only" },
329
+ ];
330
+ }
331
+ return {};
332
+ });
333
+
334
+ const pushClients = await client.listPushClients();
335
+ expect(pushClients).toHaveLength(2);
336
+ expect(pushClients[0]).toEqual({
337
+ clientId: "client-sub-1",
338
+ deviceLabel: "Device 1",
339
+ });
340
+ expect(pushClients[1]).toEqual({
341
+ clientId: "42",
342
+ deviceLabel: "Device ID Only",
343
+ });
344
+ });
345
+
346
+ it("falls back to sqlite push-subscriptions.db when both endpoints fail", async () => {
347
+ const tempDir = fs.mkdtempSync(
348
+ path.join(os.tmpdir(), "control-client-sqlite-"),
349
+ );
350
+ try {
351
+ const sqlite = (process as any).getBuiltinModule?.("node:sqlite");
352
+ if (sqlite && sqlite.DatabaseSync) {
353
+ const dbFile = path.join(tempDir, "push-subscriptions.db");
354
+ const db = new sqlite.DatabaseSync(dbFile);
355
+ db.exec(
356
+ "CREATE TABLE subscriptions (id INTEGER PRIMARY KEY, client_id TEXT, device_label TEXT, created_at INTEGER);",
357
+ );
358
+ db.prepare(
359
+ "INSERT INTO subscriptions (id, client_id, device_label, created_at) VALUES (?, ?, ?, ?)",
360
+ ).run(1, "sql-client-1", "Desk 1", Date.now());
361
+ db.prepare(
362
+ "INSERT INTO subscriptions (id, client_id, device_label, created_at) VALUES (?, ?, ?, ?)",
363
+ ).run(2, "", "No CID", Date.now());
364
+ db.close();
365
+
366
+ const client = new CliControlClient({
367
+ host: "127.0.0.1",
368
+ port,
369
+ token,
370
+ cacheDir: tempDir,
371
+ });
372
+ jest
373
+ .spyOn(client as any, "_request")
374
+ .mockRejectedValue(new Error("Network failure"));
375
+
376
+ const clients = await client.listPushClients();
377
+ expect(clients.length).toBe(2);
378
+ const c1 = clients.find((c) => c.clientId === "sql-client-1");
379
+ expect(c1).toBeDefined();
380
+ expect(c1?.deviceLabel).toBe("Desk 1");
381
+ const c2 = clients.find((c) => c.clientId === "2");
382
+ expect(c2).toBeDefined();
383
+ expect(c2?.deviceLabel).toBe("No CID");
384
+ }
385
+ } finally {
386
+ fs.rmSync(tempDir, { recursive: true, force: true });
387
+ }
388
+ });
152
389
  });
@@ -0,0 +1,63 @@
1
+ import { loadWorkspaceConfig } from "./load-workspace-config.js";
2
+ import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ describe("load-workspace-config", () => {
7
+ let tmpDir: string;
8
+
9
+ beforeEach(async () => {
10
+ tmpDir = await mkdtemp(path.join(os.tmpdir(), "sc-cfg-test-"));
11
+ });
12
+
13
+ afterEach(async () => {
14
+ await rm(tmpDir, { recursive: true, force: true });
15
+ });
16
+
17
+ it("returns null when no config exists", async () => {
18
+ const res = await loadWorkspaceConfig(tmpDir);
19
+ expect(res).toBeNull();
20
+ });
21
+
22
+ it("loads shadow-claw.config.json from workspace", async () => {
23
+ const cfgPath = path.join(tmpDir, "shadow-claw.config.json");
24
+ await writeFile(cfgPath, JSON.stringify({ model: "test-model" }), "utf8");
25
+
26
+ const res = await loadWorkspaceConfig(tmpDir);
27
+ expect(res).not.toBeNull();
28
+ expect(res?.configPath).toBe(cfgPath);
29
+ expect(res?.config.model).toBe("test-model");
30
+ });
31
+
32
+ it("loads config from contentRoot", async () => {
33
+ const contentDir = path.join(tmpDir, "content");
34
+ await mkdir(contentDir, { recursive: true });
35
+ const cfgPath = path.join(contentDir, "site-config.json");
36
+ await writeFile(cfgPath, JSON.stringify({ siteName: "My Site" }), "utf8");
37
+
38
+ const res = await loadWorkspaceConfig(undefined, contentDir);
39
+ expect(res).not.toBeNull();
40
+ expect(res?.configPath).toBe(cfgPath);
41
+ expect((res?.config as any).siteName).toBe("My Site");
42
+ });
43
+
44
+ it("checks parent directory when workspace is .cache", async () => {
45
+ const cacheDir = path.join(tmpDir, ".cache");
46
+ await mkdir(cacheDir, { recursive: true });
47
+ const cfgPath = path.join(tmpDir, "shadowclaw.config.json");
48
+ await writeFile(cfgPath, JSON.stringify({ provider: "local" }), "utf8");
49
+
50
+ const res = await loadWorkspaceConfig(cacheDir);
51
+ expect(res).not.toBeNull();
52
+ expect(res?.configPath).toBe(cfgPath);
53
+ expect(res?.config.provider).toBe("local");
54
+ });
55
+
56
+ it("handles malformed JSON gracefully", async () => {
57
+ const cfgPath = path.join(tmpDir, "shadow-claw.config.json");
58
+ await writeFile(cfgPath, "{ malformed json", "utf8");
59
+
60
+ const res = await loadWorkspaceConfig(tmpDir);
61
+ expect(res).toBeNull();
62
+ });
63
+ });
@@ -1242,5 +1242,103 @@ describe("local-models", () => {
1242
1242
  globalThis.fetch = originalFetch;
1243
1243
  }
1244
1244
  });
1245
+
1246
+ it("allows selecting llamafile options 5 through 10", async () => {
1247
+ for (const choice of ["5", "6", "7", "8", "9", "10"]) {
1248
+ const mockStdin = new PassThrough();
1249
+ const mockStdout = new Writable({
1250
+ write(chunk, _encoding, callback) {
1251
+ const str = chunk.toString();
1252
+ if (str.includes("Enter choice [1-12]")) {
1253
+ setImmediate(() => mockStdin.write(`${choice}\n`));
1254
+ } else if (str.includes("Download model weights now?")) {
1255
+ setImmediate(() => mockStdin.write("n\n"));
1256
+ }
1257
+ callback();
1258
+ },
1259
+ });
1260
+ const result = await promptForAgentModel({
1261
+ workspaceDir: tmpDir,
1262
+ contentRoot: tmpDir,
1263
+ cacheDir: tmpDir,
1264
+ stdin: mockStdin,
1265
+ stdout: mockStdout,
1266
+ isTTY: true,
1267
+ });
1268
+ expect(result.providerId).toBe("llamafile");
1269
+ }
1270
+ });
1271
+
1272
+ it("allows direct entry of agentfile, llamafile, openrouter, and onnx models at the main prompt", async () => {
1273
+ const inputs = [
1274
+ { in: "helper.agentfile", p: "agentfile", m: "helper.agentfile" },
1275
+ { in: "worker.llamafile", p: "llamafile", m: "worker.llamafile" },
1276
+ { in: "meta-llama/llama-3", p: "openrouter", m: "meta-llama/llama-3" },
1277
+ {
1278
+ in: "custom-onnx-model",
1279
+ p: "transformers_js_local",
1280
+ m: "custom-onnx-model",
1281
+ },
1282
+ ];
1283
+
1284
+ for (const item of inputs) {
1285
+ const mockStdin = new PassThrough();
1286
+ const mockStdout = new Writable({
1287
+ write(chunk, _encoding, callback) {
1288
+ const str = chunk.toString();
1289
+ if (str.includes("Enter choice [1-12]")) {
1290
+ setImmediate(() => mockStdin.write(`${item.in}\n`));
1291
+ } else if (str.includes("Download model weights now?")) {
1292
+ setImmediate(() => mockStdin.write("n\n"));
1293
+ }
1294
+ callback();
1295
+ },
1296
+ });
1297
+ const result = await promptForAgentModel({
1298
+ workspaceDir: tmpDir,
1299
+ contentRoot: tmpDir,
1300
+ cacheDir: tmpDir,
1301
+ stdin: mockStdin,
1302
+ stdout: mockStdout,
1303
+ isTTY: true,
1304
+ });
1305
+ expect(result.providerId).toBe(item.p);
1306
+ expect(result.model).toBe(item.m);
1307
+ }
1308
+ });
1309
+
1310
+ it("tests isLlamafileLocallyCached and isAgentfileLocallyCached paths", () => {
1311
+ const realFile = path.join(tmpDir, "model.llamafile");
1312
+ fs.writeFileSync(realFile, "binary content", "utf8");
1313
+
1314
+ expect(isLlamafileLocallyCached(realFile)).toBe(true);
1315
+ expect(isLlamafileLocallyCached(realFile, tmpDir)).toBe(true);
1316
+
1317
+ const agentFile = path.join(tmpDir, "model.agentfile");
1318
+ fs.writeFileSync(agentFile, "agent content", "utf8");
1319
+
1320
+ expect(isAgentfileLocallyCached(agentFile)).toBe(true);
1321
+ expect(isAgentfileLocallyCached(agentFile, tmpDir)).toBe(true);
1322
+
1323
+ // Part file returns false
1324
+ const cacheDir = getLlamafileCacheDir(tmpDir);
1325
+ fs.mkdirSync(cacheDir, { recursive: true });
1326
+ fs.writeFileSync(
1327
+ path.join(cacheDir, "test.llamafile.part"),
1328
+ "incomplete",
1329
+ "utf8",
1330
+ );
1331
+ expect(isLlamafileLocallyCached("test.llamafile", tmpDir)).toBe(false);
1332
+
1333
+ // Cache dirs with and without custom dir
1334
+ expect(getLlamafileCacheDir()).toContain("llamafile");
1335
+ expect(getAgentfileCacheDir()).toContain("agentfile");
1336
+ expect(getLlamafileCacheDir(tmpDir)).toBe(
1337
+ path.resolve(tmpDir, "llamafile"),
1338
+ );
1339
+ expect(getAgentfileCacheDir(tmpDir)).toBe(
1340
+ path.resolve(tmpDir, "agentfile"),
1341
+ );
1342
+ });
1245
1343
  });
1246
1344
  });
@@ -205,9 +205,48 @@ describe("progress-bar", () => {
205
205
  total: 1000,
206
206
  });
207
207
  bar.update({ status: "done", file: "model.onnx" });
208
- bar.finish();
209
-
210
208
  expect(output).toBe("");
211
209
  });
210
+
211
+ it("handles failure message with prior files in TTY mode", () => {
212
+ let output = "";
213
+ const mockStream = {
214
+ isTTY: true,
215
+ write: (str: string) => {
216
+ output += str;
217
+ },
218
+ };
219
+
220
+ const bar = createCliProgressBar({
221
+ modelId: "bad-model",
222
+ stream: mockStream,
223
+ isTTY: true,
224
+ });
225
+
226
+ bar.update({ status: "progress", file: "part.bin", progress: 10 });
227
+ bar.fail("Custom failure occurred");
228
+ expect(output).toContain("Custom failure occurred");
229
+ });
230
+
231
+ it("handles done status directly in non-TTY mode without prior entry and formatBytes negative decimals", () => {
232
+ let output = "";
233
+ const mockStream = {
234
+ isTTY: false,
235
+ write: (str: string) => {
236
+ output += str;
237
+ },
238
+ };
239
+
240
+ const bar = createCliProgressBar({
241
+ modelId: "quick-model",
242
+ stream: mockStream,
243
+ isTTY: false,
244
+ });
245
+
246
+ bar.update({ status: "done", file: "direct-done.bin" });
247
+ expect(output).toContain("[Download] Finished file: direct-done.bin");
248
+
249
+ expect(formatBytes(1536, -1)).toBe("2 KB");
250
+ });
212
251
  });
213
252
  });
@@ -96,6 +96,39 @@ describe("src/cli/utils/resolve-cache-dir.ts", () => {
96
96
  expect(resolved).toBe(path.resolve(tempDir, ".legacy-cache"));
97
97
  });
98
98
 
99
+ it("checks pages/site-config.json and shadowclaw.config.json variants and handles malformed JSON", async () => {
100
+ // Malformed JSON should be ignored
101
+ const badConfigPath = path.join(tempDir, "shadow-claw-config.json");
102
+ await fs.writeFile(badConfigPath, "{ bad json", "utf8");
103
+ expect(readConfigFileCacheDir(tempDir)).toBeNull();
104
+ await fs.unlink(badConfigPath);
105
+
106
+ // shadowclaw.config.json variant
107
+ const shadowclawPath = path.join(tempDir, "shadowclaw.config.json");
108
+ await fs.writeFile(
109
+ shadowclawPath,
110
+ JSON.stringify({ cacheDir: "sc-variant-cache" }),
111
+ "utf8",
112
+ );
113
+ expect(readConfigFileCacheDir(tempDir)).toBe(
114
+ path.resolve(tempDir, "sc-variant-cache"),
115
+ );
116
+ await fs.unlink(shadowclawPath);
117
+
118
+ // pages/site-config.json variant
119
+ const pagesDir = path.join(tempDir, "pages");
120
+ await fs.mkdir(pagesDir, { recursive: true });
121
+ const pagesSiteConfig = path.join(pagesDir, "site-config.json");
122
+ await fs.writeFile(
123
+ pagesSiteConfig,
124
+ JSON.stringify({ cacheDir: "pages-sub-cache" }),
125
+ "utf8",
126
+ );
127
+ expect(readConfigFileCacheDir(tempDir)).toBe(
128
+ path.resolve(tempDir, "pages-sub-cache"),
129
+ );
130
+ });
131
+
99
132
  it("respects explicit --tmp option", async () => {
100
133
  const result = await resolveCacheDir({
101
134
  contentRoot: tempDir,
@@ -366,4 +399,32 @@ describe("src/cli/utils/resolve-cache-dir.ts", () => {
366
399
  expect(exitCode).toBe(130);
367
400
  expect(output).toContain("Operation cancelled.");
368
401
  });
402
+
403
+ it("handles explicit databaseDir ending in /database and non-/database", async () => {
404
+ const res1 = await resolveCacheDir({
405
+ contentRoot: tempDir,
406
+ databaseDir: "my-cache/database",
407
+ });
408
+ expect(res1.databaseDir).toBe(path.resolve(tempDir, "my-cache/database"));
409
+ expect(res1.cacheDir).toBe(path.resolve(tempDir, "my-cache"));
410
+
411
+ const res2 = await resolveCacheDir({
412
+ contentRoot: tempDir,
413
+ databaseDir: "custom-db-dir",
414
+ });
415
+ expect(res2.databaseDir).toBe(path.resolve(tempDir, "custom-db-dir"));
416
+ expect(res2.cacheDir).toBe(path.resolve(tempDir));
417
+ });
418
+
419
+ it("reads server.cacheDir from site-config.json", async () => {
420
+ const cfgPath = path.join(tempDir, "site-config.json");
421
+ await fs.writeFile(
422
+ cfgPath,
423
+ JSON.stringify({ server: { cacheDir: "server-cache" } }),
424
+ "utf8",
425
+ );
426
+
427
+ const res = await resolveCacheDir({ contentRoot: tempDir });
428
+ expect(res.cacheDir).toBe(path.resolve(tempDir, "server-cache"));
429
+ });
369
430
  });
@@ -0,0 +1,17 @@
1
+ import { getProjectRoot } from "./resolve-project-root.js";
2
+ import { pathToFileURL } from "node:url";
3
+
4
+ describe("resolve-project-root", () => {
5
+ it("resolves the shadow-claw project root from current module url", () => {
6
+ const root = getProjectRoot();
7
+ expect(root).toContain("shadow-claw");
8
+ });
9
+
10
+ it("throws when shadow-claw package.json cannot be found", () => {
11
+ expect(() => {
12
+ getProjectRoot(pathToFileURL("/").href);
13
+ }).toThrow(
14
+ "Could not find shadow-claw project root containing package.json",
15
+ );
16
+ });
17
+ });
@@ -94,6 +94,44 @@ describe("src/cli/utils/stdin.ts - readStdin", () => {
94
94
  expect(result).toBe(json);
95
95
  expect(JSON.parse(result!)).toEqual({ text: "hello", tone: "happy" });
96
96
  });
97
+
98
+ it("resolves null on timeoutMs expiration and destroys stream", async () => {
99
+ const stream = new Readable({ read() {} });
100
+ const result = await readStdin({ stdin: stream, timeoutMs: 50 });
101
+ expect(result).toBeNull();
102
+ });
103
+
104
+ it("handles Buffer chunks, timer cleanup on end or error, and default process.stdin", async () => {
105
+ const stream = new Readable({
106
+ read() {
107
+ this.push(Buffer.from("buffer data"));
108
+ this.push(null);
109
+ },
110
+ });
111
+ (stream as any).setEncoding = null;
112
+ (stream as any).isTTY = false;
113
+ const res = await readStdin({ stdin: stream, timeoutMs: 5000 });
114
+ expect(res).toBe("buffer data");
115
+
116
+ const errStream = new Readable({
117
+ read() {
118
+ this.emit("error", new Error("with timer error"));
119
+ },
120
+ });
121
+ (errStream as any).isTTY = false;
122
+ await expect(
123
+ readStdin({ stdin: errStream, timeoutMs: 5000 }),
124
+ ).rejects.toThrow("with timer error");
125
+
126
+ const origIsTTY = process.stdin.isTTY;
127
+ try {
128
+ (process.stdin as any).isTTY = true;
129
+ const defRes = await readStdin();
130
+ expect(defRes).toBeNull();
131
+ } finally {
132
+ (process.stdin as any).isTTY = origIsTTY;
133
+ }
134
+ });
97
135
  });
98
136
 
99
137
  describe("src/cli/utils/stdin.ts - mapTextToToolInput", () => {
@@ -1,4 +1,11 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "@jest/globals";
1
+ import {
2
+ describe,
3
+ it,
4
+ expect,
5
+ beforeEach,
6
+ afterEach,
7
+ jest,
8
+ } from "@jest/globals";
2
9
  import { Readable } from "node:stream";
3
10
  import { mkdtemp, readFile, rm } from "node:fs/promises";
4
11
  import { tmpdir } from "node:os";
@@ -55,6 +62,29 @@ describe("src/cli/utils/stdio.ts", () => {
55
62
  const result = await readStdin({ stdin: mockStdin, force: true });
56
63
  expect(result).toBe("forced stream content");
57
64
  });
65
+
66
+ it("returns null on stream error", async () => {
67
+ const mockStdin = new Readable({
68
+ read() {
69
+ this.emit("error", new Error("stream read failure"));
70
+ },
71
+ });
72
+ (mockStdin as any).isTTY = false;
73
+ const result = await readStdin({ stdin: mockStdin });
74
+ expect(result).toBeNull();
75
+ });
76
+
77
+ it("resolves immediately when stream readableEnded is already true", async () => {
78
+ const mockStdin = new Readable({
79
+ read() {
80
+ this.push(null);
81
+ },
82
+ });
83
+ (mockStdin as any).isTTY = false;
84
+ Object.defineProperty(mockStdin, "readableEnded", { get: () => true });
85
+ const result = await readStdin({ stdin: mockStdin });
86
+ expect(result).toBe("");
87
+ });
58
88
  });
59
89
 
60
90
  describe("readInputOrStdin", () => {
@@ -130,5 +160,25 @@ describe("src/cli/utils/stdio.ts", () => {
130
160
  const content = await readFile(targetFile, "utf8");
131
161
  expect(JSON.parse(content)).toEqual({ success: true, count: 42 });
132
162
  });
163
+
164
+ it("logs string and JSON to console when output is not specified and quiet is false", async () => {
165
+ const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
166
+ try {
167
+ await writeOutput("console text output");
168
+ expect(logSpy).toHaveBeenCalledWith("console text output");
169
+
170
+ await writeOutput({ count: 1 });
171
+ expect(logSpy).toHaveBeenCalledWith(
172
+ JSON.stringify({ count: 1 }, null, 2),
173
+ );
174
+
175
+ // When quiet is true, does not log
176
+ logSpy.mockClear();
177
+ await writeOutput("quiet text", { quiet: true });
178
+ expect(logSpy).not.toHaveBeenCalled();
179
+ } finally {
180
+ logSpy.mockRestore();
181
+ }
182
+ });
133
183
  });
134
184
  });