@linkedclaw/cli 0.1.2 → 0.1.5

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 (47) hide show
  1. package/README.md +248 -48
  2. package/dist/bin.js +8099 -4778
  3. package/dist/bin.js.map +1 -1
  4. package/package.json +17 -32
  5. package/src/arena/api.ts +154 -0
  6. package/src/arena/hash.ts +15 -0
  7. package/src/arena/types.ts +106 -0
  8. package/src/bin.ts +33 -0
  9. package/src/commands/agent.ts +264 -0
  10. package/src/commands/arena.ts +393 -0
  11. package/src/commands/auth.ts +116 -0
  12. package/src/commands/converge.ts +969 -0
  13. package/src/commands/provider.ts +245 -0
  14. package/src/commands/requester.ts +479 -0
  15. package/src/config.ts +85 -0
  16. package/src/context.ts +27 -0
  17. package/src/converge/api.ts +213 -0
  18. package/src/converge/hash.ts +35 -0
  19. package/src/converge/lock.ts +30 -0
  20. package/src/converge/staging.ts +83 -0
  21. package/src/converge/types.ts +91 -0
  22. package/src/converge/workspace.ts +92 -0
  23. package/src/errors.ts +41 -0
  24. package/src/handlers/subprocess.ts +185 -0
  25. package/src/output.ts +57 -0
  26. package/src/types.ts +90 -0
  27. package/test/agent-help.test.ts +207 -0
  28. package/test/arena-api.test.ts +211 -0
  29. package/test/arena-commands.test.ts +559 -0
  30. package/test/arena-hash.test.ts +33 -0
  31. package/test/cli-help.test.ts +82 -0
  32. package/test/converge-accept.test.ts +206 -0
  33. package/test/converge-decision.test.ts +274 -0
  34. package/test/converge-hash.test.ts +58 -0
  35. package/test/converge-help.test.ts +58 -0
  36. package/test/converge-lock.test.ts +48 -0
  37. package/test/converge-review.test.ts +135 -0
  38. package/test/converge-run.test.ts +286 -0
  39. package/test/converge-staging.test.ts +161 -0
  40. package/test/converge-status.test.ts +141 -0
  41. package/test/converge-workspace.test.ts +92 -0
  42. package/test/hire-flags.test.ts +55 -0
  43. package/test/recv-flags.test.ts +83 -0
  44. package/test/register-browser.test.ts +55 -0
  45. package/tsconfig.json +14 -0
  46. package/tsup.config.ts +25 -0
  47. package/vitest.config.ts +8 -0
@@ -0,0 +1,559 @@
1
+ import { Command } from "commander";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { registerArenaCommands } from "../src/commands/arena.js";
7
+ import { sha256Digest } from "../src/arena/hash.js";
8
+
9
+ const cfgRef = vi.hoisted(() => ({
10
+ current: {
11
+ apiKey: "k",
12
+ cloudUrl: "http://cloud.test",
13
+ servicesHostUrl: "http://services.test",
14
+ } as Record<string, string | undefined>,
15
+ }));
16
+
17
+ const consumerRef = vi.hoisted(() => ({
18
+ current: {
19
+ getAgent: vi.fn(),
20
+ resolveAgentHandle: vi.fn(),
21
+ },
22
+ }));
23
+ const apiRef = vi.hoisted(() => ({ current: {} as any }));
24
+ const makeArenaApiMock = vi.hoisted(() => vi.fn(() => apiRef.current));
25
+ const originalServicesHostEnv = process.env.LINKEDCLAW_SERVICES_HOST_URL;
26
+
27
+ vi.mock("../src/context.js", () => ({
28
+ buildContext: () => ({ cfg: cfgRef.current, consumer: consumerRef.current }),
29
+ }));
30
+
31
+ vi.mock("../src/arena/api.js", () => ({
32
+ makeArenaApi: makeArenaApiMock,
33
+ }));
34
+
35
+ function makeProgram(): Command {
36
+ const program = new Command();
37
+ program.exitOverride();
38
+ registerArenaCommands(program);
39
+ return program;
40
+ }
41
+
42
+ async function run(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number | undefined }> {
43
+ let stdout = "";
44
+ let stderr = "";
45
+ const out = vi.spyOn(process.stdout, "write").mockImplementation((chunk: any) => {
46
+ stdout += String(chunk);
47
+ return true;
48
+ });
49
+ const err = vi.spyOn(process.stderr, "write").mockImplementation((chunk: any) => {
50
+ stderr += String(chunk);
51
+ return true;
52
+ });
53
+ const prevExitCode = process.exitCode;
54
+ process.exitCode = undefined;
55
+ try {
56
+ await makeProgram().parseAsync(["node", "test", ...args], { from: "node" });
57
+ return { stdout, stderr, exitCode: process.exitCode as number | undefined };
58
+ } finally {
59
+ process.exitCode = prevExitCode;
60
+ out.mockRestore();
61
+ err.mockRestore();
62
+ }
63
+ }
64
+
65
+ beforeEach(() => {
66
+ delete process.env.LINKEDCLAW_SERVICES_HOST_URL;
67
+ cfgRef.current = {
68
+ apiKey: "k",
69
+ cloudUrl: "http://cloud.test",
70
+ servicesHostUrl: "http://services.test",
71
+ };
72
+ apiRef.current = {
73
+ register: vi.fn(async () => ({ registration: { registration_id: "areg_1" } })),
74
+ listOffers: vi.fn(async () => ({ offers: [] })),
75
+ acceptOffer: vi.fn(async () => ({ offer: { offer_id: "aoff_123" } })),
76
+ createTournamentArena: vi.fn(async () => ({ arena: { arena_id: "arn_tournament" }, replayed: false })),
77
+ submit: vi.fn(async () => ({ submission: { submission_id: "asub_1" } })),
78
+ commitJuror: vi.fn(async () => ({ juror: { juror_id: "ajur_1" } })),
79
+ voteTask: vi.fn(async () => ({ vote: { vote_id: "ajv_1" } })),
80
+ voteMatch: vi.fn(async () => ({ vote: { vote_id: "amjv_1" } })),
81
+ listArenas: vi.fn(async () => ({ arenas: [] })),
82
+ getLeaderboard: vi.fn(async () => ({ arena_id: "arn_123", leaderboard: [] })),
83
+ getCategoryLeaderboard: vi.fn(async () => ({
84
+ category: { topic: "coding", subtopic: "patches" },
85
+ mode: "match",
86
+ leaderboard: [],
87
+ })),
88
+ };
89
+ consumerRef.current.getAgent.mockReset();
90
+ consumerRef.current.resolveAgentHandle.mockReset();
91
+ consumerRef.current.getAgent.mockImplementation(async (agentId: string) => ({
92
+ agent_id: agentId,
93
+ capabilities: ["arena.v1"],
94
+ external_endpoint: "https://third-pa.test",
95
+ }));
96
+ consumerRef.current.resolveAgentHandle.mockImplementation(async () => ({
97
+ agent_id: "agt_first_party_arena",
98
+ capabilities: ["arena.v1", "arena_entry"],
99
+ }));
100
+ makeArenaApiMock.mockClear();
101
+ });
102
+
103
+ afterEach(() => {
104
+ if (originalServicesHostEnv === undefined) {
105
+ delete process.env.LINKEDCLAW_SERVICES_HOST_URL;
106
+ } else {
107
+ process.env.LINKEDCLAW_SERVICES_HOST_URL = originalServicesHostEnv;
108
+ }
109
+ vi.restoreAllMocks();
110
+ });
111
+
112
+ describe("arena commands", () => {
113
+ it("register builds contestant, mandate, and two-level category", async () => {
114
+ await run([
115
+ "arena",
116
+ "register",
117
+ "--agent-id",
118
+ "agt_1",
119
+ "--mandate-id",
120
+ "mand_1",
121
+ "--category-topic",
122
+ "code",
123
+ "--category-subtopic",
124
+ "rust",
125
+ ]);
126
+ expect(apiRef.current.register).toHaveBeenCalledWith({
127
+ contestant_agent_id: "agt_1",
128
+ mandate_id: "mand_1",
129
+ category: { topic: "code", subtopic: "rust" },
130
+ });
131
+ });
132
+
133
+ it("offers calls listOffers", async () => {
134
+ await run(["arena", "offers"]);
135
+ expect(apiRef.current.listOffers).toHaveBeenCalledOnce();
136
+ });
137
+
138
+ it("accept calls acceptOffer", async () => {
139
+ await run(["arena", "accept", "aoff_123"]);
140
+ expect(apiRef.current.acceptOffer).toHaveBeenCalledWith("aoff_123");
141
+ });
142
+
143
+ it("tournament create passes through an exact task-child JSON manifest", async () => {
144
+ const tmp = mkdtempSync(join(tmpdir(), "lc-arena-tournament-"));
145
+ try {
146
+ const path = join(tmp, "tournament.json");
147
+ const manifest = {
148
+ mode: "tournament",
149
+ category: { topic: "code", subtopic: "typescript" },
150
+ config: {
151
+ bracket_shape: "single_elim",
152
+ child_mode: "task_submission",
153
+ advancement_rule: "top_k(1)",
154
+ child_config: { prompt: "implement it" },
155
+ bracket_size: 4,
156
+ seeding: "unseeded",
157
+ },
158
+ };
159
+ writeFileSync(path, JSON.stringify(manifest), "utf8");
160
+ await run(["arena", "tournament", "create", path, "--idempotency-key", "idem_task"]);
161
+ expect(apiRef.current.createTournamentArena).toHaveBeenCalledWith(manifest, {
162
+ idempotencyKey: "idem_task",
163
+ });
164
+ } finally {
165
+ rmSync(tmp, { recursive: true, force: true });
166
+ }
167
+ });
168
+
169
+ it("tournament create preserves match-child seeded and remote dispatch config", async () => {
170
+ const tmp = mkdtempSync(join(tmpdir(), "lc-arena-tournament-"));
171
+ try {
172
+ const path = join(tmp, "tournament.json");
173
+ const manifest = {
174
+ mode: "tournament",
175
+ category: { topic: "debate", subtopic: "crux" },
176
+ config: {
177
+ bracket_shape: "single_elim",
178
+ child_mode: "match",
179
+ advancement_rule: "head_to_head_winner",
180
+ child_config: { prompt_source: { kind: "poster_set", prompts: ["a", "b"] } },
181
+ bracket_size: 8,
182
+ seeding: "poster_set",
183
+ seed_order: ["usr_a", "usr_b", "usr_c"],
184
+ child_dispatch: { kind: "remote_pa", agent_id: "agt_remote_arena" },
185
+ settlement: { prize_credits: 100 },
186
+ },
187
+ };
188
+ writeFileSync(path, JSON.stringify(manifest), "utf8");
189
+ await run(["arena", "tournament", "create", path, "--idempotency-key", "idem_match"]);
190
+ expect(apiRef.current.createTournamentArena).toHaveBeenCalledWith(manifest, {
191
+ idempotencyKey: "idem_match",
192
+ });
193
+ } finally {
194
+ rmSync(tmp, { recursive: true, force: true });
195
+ }
196
+ });
197
+
198
+ it("tournament create reuses --target Arena PA resolution", async () => {
199
+ const tmp = mkdtempSync(join(tmpdir(), "lc-arena-tournament-"));
200
+ try {
201
+ const path = join(tmp, "tournament.json");
202
+ writeFileSync(path, JSON.stringify({
203
+ mode: "tournament",
204
+ category: { topic: "code", subtopic: "rust" },
205
+ config: {},
206
+ }), "utf8");
207
+ await run([
208
+ "arena",
209
+ "tournament",
210
+ "create",
211
+ path,
212
+ "--idempotency-key",
213
+ "idem_target",
214
+ "--target",
215
+ "agt_third",
216
+ ]);
217
+ expect(consumerRef.current.getAgent).toHaveBeenCalledWith("agt_third");
218
+ expect(makeArenaApiMock).toHaveBeenCalledWith("https://third-pa.test", "k");
219
+ } finally {
220
+ rmSync(tmp, { recursive: true, force: true });
221
+ }
222
+ });
223
+
224
+ it("tournament create rejects missing or non-tournament mode", async () => {
225
+ const tmp = mkdtempSync(join(tmpdir(), "lc-arena-tournament-"));
226
+ try {
227
+ const path = join(tmp, "not-tournament.json");
228
+ writeFileSync(path, JSON.stringify({
229
+ mode: "match",
230
+ category: { topic: "code", subtopic: "rust" },
231
+ config: {},
232
+ }), "utf8");
233
+ const result = await run(["arena", "tournament", "create", path, "--idempotency-key", "idem_bad"]);
234
+ expect(result.exitCode).toBe(1);
235
+ expect(result.stderr).toContain("arena_tournament_manifest_mode_invalid");
236
+ expect(apiRef.current.createTournamentArena).not.toHaveBeenCalled();
237
+ } finally {
238
+ rmSync(tmp, { recursive: true, force: true });
239
+ }
240
+ });
241
+
242
+ it("tournament create rejects blank idempotency keys before API calls", async () => {
243
+ const tmp = mkdtempSync(join(tmpdir(), "lc-arena-tournament-"));
244
+ try {
245
+ const path = join(tmp, "tournament.json");
246
+ writeFileSync(path, JSON.stringify({
247
+ mode: "tournament",
248
+ category: { topic: "code", subtopic: "rust" },
249
+ config: {},
250
+ }), "utf8");
251
+ const result = await run(["arena", "tournament", "create", path, "--idempotency-key", " "]);
252
+ expect(result.exitCode).toBe(1);
253
+ expect(result.stderr).toContain("arena_idempotency_key_required");
254
+ expect(apiRef.current.createTournamentArena).not.toHaveBeenCalled();
255
+ } finally {
256
+ rmSync(tmp, { recursive: true, force: true });
257
+ }
258
+ });
259
+
260
+ it("tournament create rejects missing idempotency keys before API calls", async () => {
261
+ const tmp = mkdtempSync(join(tmpdir(), "lc-arena-tournament-"));
262
+ try {
263
+ const path = join(tmp, "tournament.json");
264
+ writeFileSync(path, JSON.stringify({
265
+ mode: "tournament",
266
+ category: { topic: "code", subtopic: "rust" },
267
+ config: {},
268
+ }), "utf8");
269
+ const result = await run(["arena", "tournament", "create", path]);
270
+ expect(result.exitCode).toBe(1);
271
+ expect(result.stderr).toContain("arena_idempotency_key_required");
272
+ expect(apiRef.current.createTournamentArena).not.toHaveBeenCalled();
273
+ } finally {
274
+ rmSync(tmp, { recursive: true, force: true });
275
+ }
276
+ });
277
+
278
+ it("tournament create rejects idempotency keys with embedded newlines", async () => {
279
+ const tmp = mkdtempSync(join(tmpdir(), "lc-arena-tournament-"));
280
+ try {
281
+ const path = join(tmp, "tournament.json");
282
+ writeFileSync(path, JSON.stringify({
283
+ mode: "tournament",
284
+ category: { topic: "code", subtopic: "rust" },
285
+ config: {},
286
+ }), "utf8");
287
+ const result = await run([
288
+ "arena",
289
+ "tournament",
290
+ "create",
291
+ path,
292
+ "--idempotency-key",
293
+ "idem_1\r\nX-Evil: 1",
294
+ ]);
295
+ expect(result.exitCode).toBe(1);
296
+ expect(result.stderr).toContain("arena_idempotency_key_invalid");
297
+ expect(apiRef.current.createTournamentArena).not.toHaveBeenCalled();
298
+ } finally {
299
+ rmSync(tmp, { recursive: true, force: true });
300
+ }
301
+ });
302
+
303
+ it("tournament create rejects stdin '-' when stdin is a TTY", async () => {
304
+ const original = process.stdin.isTTY;
305
+ Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true });
306
+ try {
307
+ const result = await run([
308
+ "arena",
309
+ "tournament",
310
+ "create",
311
+ "-",
312
+ "--idempotency-key",
313
+ "idem_tty",
314
+ ]);
315
+ expect(result.exitCode).toBe(1);
316
+ expect(result.stderr).toContain("arena_tournament_manifest_stdin_tty");
317
+ expect(apiRef.current.createTournamentArena).not.toHaveBeenCalled();
318
+ } finally {
319
+ Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: original });
320
+ }
321
+ });
322
+
323
+ it("submit with body computes the expected hash and calls submit", async () => {
324
+ const result = await run(["arena", "submit", "arn_123", "--offer-id", "aoff_123", "--body", "hello"]);
325
+ const request = apiRef.current.submit.mock.calls[0][1];
326
+ expect(apiRef.current.submit).toHaveBeenCalledWith("arn_123", expect.any(Object));
327
+ expect(request).toMatchObject({
328
+ offer_id: "aoff_123",
329
+ raw_content: "hello",
330
+ seq: 1,
331
+ submission_hash: sha256Digest(Buffer.from("hello", "utf8")),
332
+ });
333
+ expect(JSON.parse(result.stdout).submission.submission_hash).toBe(request.submission_hash);
334
+ });
335
+
336
+ it("submit passes match_id for match-mode submissions", async () => {
337
+ await run([
338
+ "arena",
339
+ "submit",
340
+ "arn_123",
341
+ "--offer-id",
342
+ "aoff_123",
343
+ "--match-id",
344
+ "amch_1234567890abcdef",
345
+ "--body",
346
+ "hello",
347
+ ]);
348
+ expect(apiRef.current.submit).toHaveBeenCalledWith(
349
+ "arn_123",
350
+ expect.objectContaining({ match_id: "amch_1234567890abcdef" }),
351
+ );
352
+ });
353
+
354
+
355
+ it("submit with file reads file content and defaults content_ref to the path", async () => {
356
+ const tmp = mkdtempSync(join(tmpdir(), "lc-arena-command-"));
357
+ try {
358
+ const path = join(tmp, "answer.txt");
359
+ writeFileSync(path, "file answer", "utf8");
360
+ await run(["arena", "submit", "arn_123", "--offer-id", "aoff_123", "--file", path]);
361
+ expect(apiRef.current.submit.mock.calls[0][1]).toMatchObject({
362
+ raw_content: "file answer",
363
+ content_ref: path,
364
+ submission_hash: sha256Digest(Buffer.from("file answer", "utf8")),
365
+ });
366
+ } finally {
367
+ rmSync(tmp, { recursive: true, force: true });
368
+ }
369
+ });
370
+
371
+ it("submit rejects both file and body", async () => {
372
+ const result = await run([
373
+ "arena",
374
+ "submit",
375
+ "arn_123",
376
+ "--offer-id",
377
+ "aoff_123",
378
+ "--file",
379
+ "answer.txt",
380
+ "--body",
381
+ "hello",
382
+ ]);
383
+ expect(result.exitCode).toBe(1);
384
+ expect(result.stderr).toContain("submission_source_conflict");
385
+ expect(apiRef.current.submit).not.toHaveBeenCalled();
386
+ });
387
+
388
+ it("submit rejects missing file and body", async () => {
389
+ const result = await run(["arena", "submit", "arn_123", "--offer-id", "aoff_123"]);
390
+ expect(result.exitCode).toBe(1);
391
+ expect(result.stderr).toContain("submission_source_required");
392
+ expect(apiRef.current.submit).not.toHaveBeenCalled();
393
+ });
394
+
395
+ it("vote task submits a numeric score with rationale reference", async () => {
396
+ await run([
397
+ "arena",
398
+ "vote",
399
+ "task",
400
+ "arn_123",
401
+ "asub_123",
402
+ "0.75",
403
+ "--rationale-ref",
404
+ "note://1",
405
+ ]);
406
+ expect(apiRef.current.voteTask).toHaveBeenCalledWith("arn_123", {
407
+ submission_id: "asub_123",
408
+ score: 0.75,
409
+ rationale_ref: "note://1",
410
+ });
411
+ });
412
+
413
+ it("vote task rejects invalid scores", async () => {
414
+ const result = await run(["arena", "vote", "task", "arn_123", "asub_123", "1.2"]);
415
+ expect(result.exitCode).toBe(1);
416
+ expect(result.stderr).toContain("invalid_juror_score");
417
+ expect(apiRef.current.voteTask).not.toHaveBeenCalled();
418
+ });
419
+
420
+ it("vote task rejects non-numeric scores", async () => {
421
+ const result = await run(["arena", "vote", "task", "arn_123", "asub_123", "nope"]);
422
+ expect(result.exitCode).toBe(1);
423
+ expect(result.stderr).toContain("invalid_juror_score");
424
+ expect(apiRef.current.voteTask).not.toHaveBeenCalled();
425
+ });
426
+
427
+ it("vote match submits an outcome with rationale reference", async () => {
428
+ await run([
429
+ "arena",
430
+ "vote",
431
+ "match",
432
+ "arn_123",
433
+ "amch_123",
434
+ "a",
435
+ "--rationale-ref",
436
+ "note://2",
437
+ ]);
438
+ expect(apiRef.current.voteMatch).toHaveBeenCalledWith("arn_123", "amch_123", {
439
+ outcome: "a",
440
+ rationale_ref: "note://2",
441
+ });
442
+ });
443
+
444
+ it("vote match rejects invalid outcomes", async () => {
445
+ const result = await run(["arena", "vote", "match", "arn_123", "amch_123", "winner"]);
446
+ expect(result.exitCode).toBe(1);
447
+ expect(result.stderr).toContain("invalid_juror_outcome");
448
+ expect(apiRef.current.voteMatch).not.toHaveBeenCalled();
449
+ });
450
+
451
+ it("list --registered passes registered true", async () => {
452
+ await run(["arena", "list", "--registered"]);
453
+ expect(apiRef.current.listArenas).toHaveBeenCalledWith({ registered: true });
454
+ });
455
+
456
+ it("leaderboard with arena id calls getLeaderboard", async () => {
457
+ await run(["arena", "leaderboard", "arn_123"]);
458
+ expect(apiRef.current.getLeaderboard).toHaveBeenCalledWith("arn_123");
459
+ });
460
+
461
+ it("leaderboard without arena id calls getCategoryLeaderboard", async () => {
462
+ await run([
463
+ "arena",
464
+ "leaderboard",
465
+ "--category-topic",
466
+ "coding",
467
+ "--category-subtopic",
468
+ "patches",
469
+ "--mode",
470
+ "match",
471
+ ]);
472
+ expect(apiRef.current.getCategoryLeaderboard).toHaveBeenCalledWith(
473
+ { topic: "coding", subtopic: "patches" },
474
+ "match",
475
+ );
476
+ });
477
+
478
+ it("leaderboard without arena id requires category topic", async () => {
479
+ const result = await run([
480
+ "arena",
481
+ "leaderboard",
482
+ "--category-subtopic",
483
+ "patches",
484
+ "--mode",
485
+ "match",
486
+ ]);
487
+ expect(result.exitCode).toBe(1);
488
+ expect(result.stderr).toContain("missing_category_topic");
489
+ expect(apiRef.current.getCategoryLeaderboard).not.toHaveBeenCalled();
490
+ });
491
+
492
+ it("leaderboard without arena id requires category subtopic", async () => {
493
+ const result = await run([
494
+ "arena",
495
+ "leaderboard",
496
+ "--category-topic",
497
+ "coding",
498
+ "--mode",
499
+ "match",
500
+ ]);
501
+ expect(result.exitCode).toBe(1);
502
+ expect(result.stderr).toContain("missing_category_subtopic");
503
+ expect(apiRef.current.getCategoryLeaderboard).not.toHaveBeenCalled();
504
+ });
505
+
506
+ it("leaderboard without arena id rejects unsupported mode", async () => {
507
+ const result = await run([
508
+ "arena",
509
+ "leaderboard",
510
+ "--category-topic",
511
+ "coding",
512
+ "--category-subtopic",
513
+ "patches",
514
+ "--mode",
515
+ "task_submission",
516
+ ]);
517
+ expect(result.exitCode).toBe(1);
518
+ expect(result.stderr).toContain("unsupported_arena_leaderboard_mode");
519
+ expect(apiRef.current.getCategoryLeaderboard).not.toHaveBeenCalled();
520
+ });
521
+
522
+ it("--target resolves an Arena PA agent id and uses its external endpoint", async () => {
523
+ await run(["arena", "offers", "--target", "agt_third"]);
524
+ expect(consumerRef.current.getAgent).toHaveBeenCalledWith("agt_third");
525
+ expect(makeArenaApiMock).toHaveBeenCalledWith("https://third-pa.test", "k");
526
+ });
527
+
528
+ it("--target rejects raw URLs", async () => {
529
+ const result = await run(["arena", "offers", "--target", "http://raw-pa.test"]);
530
+ expect(result.exitCode).toBe(1);
531
+ expect(result.stderr).toContain("arena_target_must_be_agent_id");
532
+ expect(makeArenaApiMock).not.toHaveBeenCalled();
533
+ });
534
+
535
+ it("discovers the first-party Arena PA by default", async () => {
536
+ await run(["arena", "offers"]);
537
+ expect(consumerRef.current.resolveAgentHandle).toHaveBeenCalledWith("gig-pa-operator", "arena-v1");
538
+ expect(makeArenaApiMock).toHaveBeenCalledWith("http://services.test", "k");
539
+ });
540
+
541
+ it("rejects targets that do not advertise arena.v1", async () => {
542
+ consumerRef.current.getAgent.mockResolvedValueOnce({ agent_id: "agt_not_arena", capabilities: ["echo"] });
543
+ const result = await run(["arena", "offers", "--target", "agt_not_arena"]);
544
+ expect(result.exitCode).toBe(1);
545
+ expect(result.stderr).toContain("arena_target_not_arena_pa");
546
+ expect(makeArenaApiMock).not.toHaveBeenCalled();
547
+ });
548
+
549
+ it("uses servicesHostUrl by default", async () => {
550
+ await run(["arena", "offers"]);
551
+ expect(makeArenaApiMock).toHaveBeenCalledWith("http://services.test", "k");
552
+ });
553
+
554
+ it("falls back to cloudUrl without servicesHostUrl", async () => {
555
+ cfgRef.current = { apiKey: "k", cloudUrl: "http://cloud.test", servicesHostUrl: undefined };
556
+ await run(["arena", "offers"]);
557
+ expect(makeArenaApiMock).toHaveBeenCalledWith("http://cloud.test", "k");
558
+ });
559
+ });
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { hashFile, sha256Digest } from "../src/arena/hash.js";
6
+
7
+ describe("arena hash helpers", () => {
8
+ it("hashes a known string", () => {
9
+ expect(sha256Digest("hello")).toBe(
10
+ "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
11
+ );
12
+ });
13
+
14
+ it("hashFile matches hashing the same bytes", () => {
15
+ const tmp = mkdtempSync(join(tmpdir(), "lc-arena-hash-"));
16
+ try {
17
+ const path = join(tmp, "answer.txt");
18
+ const bytes = Buffer.from("answer\n", "utf8");
19
+ writeFileSync(path, bytes);
20
+ const result = hashFile(path);
21
+ expect(result.bytes.equals(bytes)).toBe(true);
22
+ expect(result.digest).toBe(sha256Digest(bytes));
23
+ } finally {
24
+ rmSync(tmp, { recursive: true, force: true });
25
+ }
26
+ });
27
+
28
+ it("hashes non-ASCII bytes without text normalization", () => {
29
+ const composed = Buffer.from("café", "utf8");
30
+ const decomposed = Buffer.from("cafe\u0301", "utf8");
31
+ expect(sha256Digest(composed)).not.toBe(sha256Digest(decomposed));
32
+ });
33
+ });
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect, beforeAll } from "vitest";
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const BIN = path.resolve(__dirname, "../dist/bin.js");
9
+
10
+ beforeAll(() => {
11
+ if (!existsSync(BIN)) {
12
+ throw new Error(`bin not built — run 'pnpm -r --filter @linkedclaw/cli build' first (${BIN})`);
13
+ }
14
+ });
15
+
16
+ function run(args: string[]): { code: number | null; stdout: string; stderr: string } {
17
+ const r = spawnSync("node", [BIN, ...args], { encoding: "utf8" });
18
+ return { code: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
19
+ }
20
+
21
+ describe("cli help", () => {
22
+ it("--help lists every top-level verb from §3.9", () => {
23
+ const { code, stdout } = run(["--help"]);
24
+ expect(code).toBe(0);
25
+ for (const verb of [
26
+ "provider", "gig-task", "search", "hire", "send", "end",
27
+ "invoke", "receipt", "trust", "credits", "register", "login", "whoami",
28
+ "agent", "arena",
29
+ ]) {
30
+ expect(stdout).toContain(verb);
31
+ }
32
+ });
33
+
34
+ it("hire --help shows --message and --interactive", () => {
35
+ const { code, stdout } = run(["hire", "--help"]);
36
+ expect(code).toBe(0);
37
+ expect(stdout).toContain("--message");
38
+ expect(stdout).toContain("--interactive");
39
+ });
40
+
41
+ it("register --help shows --no-browser and --cloud-url", () => {
42
+ const { code, stdout } = run(["register", "--help"]);
43
+ expect(code).toBe(0);
44
+ expect(stdout).toContain("--no-browser");
45
+ expect(stdout).toContain("--cloud-url");
46
+ });
47
+
48
+ it("provider --help shows all six subcommands", () => {
49
+ const { code, stdout } = run(["provider", "--help"]);
50
+ expect(code).toBe(0);
51
+ for (const sub of ["register", "update", "listings", "run", "pick", "submit"]) {
52
+ expect(stdout).toContain(sub);
53
+ }
54
+ });
55
+
56
+ it("gig-task --help shows all six subcommands", () => {
57
+ const { code, stdout } = run(["gig-task", "--help"]);
58
+ expect(code).toBe(0);
59
+ for (const sub of ["create", "get", "list", "available", "accept", "submit"]) {
60
+ expect(stdout).toContain(sub);
61
+ }
62
+ });
63
+
64
+ it("arena --help shows all subcommands", () => {
65
+ const { code, stdout } = run(["arena", "--help"]);
66
+ expect(code).toBe(0);
67
+ for (const sub of ["tournament", "register", "offers", "accept", "submit", "list", "leaderboard"]) {
68
+ expect(stdout).toContain(sub);
69
+ }
70
+ });
71
+
72
+ it("arena tournament help shows create and idempotency key", () => {
73
+ const tournament = run(["arena", "tournament", "--help"]);
74
+ expect(tournament.code).toBe(0);
75
+ expect(tournament.stdout).toContain("create");
76
+
77
+ const create = run(["arena", "tournament", "create", "--help"]);
78
+ expect(create.code).toBe(0);
79
+ expect(create.stdout).toContain("--idempotency-key");
80
+ expect(create.stdout).toContain("--target");
81
+ });
82
+ });