@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,141 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { reduceRunState } from "../src/commands/converge.js";
3
+ import type { CommonsLogEvent, RunWorkspace } from "../src/converge/types.js";
4
+
5
+ const FAKE_WS: RunWorkspace = {
6
+ runId: "clg_run00000000",
7
+ sourceDebateId: "dbt_source001",
8
+ paAgentId: "agt_pa001",
9
+ targetCorpus: "/corpus",
10
+ stagingDir: "/corpus/converged/staging/clg_run00000000",
11
+ };
12
+
13
+ const SAMPLE_EVENTS: CommonsLogEvent[] = [
14
+ {
15
+ seq: 1,
16
+ event_type: "run_started",
17
+ payload: {},
18
+ appended_at: "2026-04-30T10:00:00Z",
19
+ signed_by: "sys",
20
+ },
21
+ {
22
+ seq: 2,
23
+ event_type: "sub_debate_dispatched",
24
+ payload: { crux_id: "crux_001", sub_debate_id: "dbt_sub001" },
25
+ appended_at: "2026-04-30T10:01:00Z",
26
+ signed_by: "sys",
27
+ },
28
+ {
29
+ seq: 3,
30
+ event_type: "sub_debate_dispatched",
31
+ payload: { crux_id: "crux_002", sub_debate_id: "dbt_sub002" },
32
+ appended_at: "2026-04-30T10:02:00Z",
33
+ signed_by: "sys",
34
+ },
35
+ {
36
+ seq: 4,
37
+ event_type: "sub_debate_outcome_observed",
38
+ payload: { crux_id: "crux_001", outcome: "already_aligned", bilateral_mandate_intact: true },
39
+ appended_at: "2026-04-30T10:05:00Z",
40
+ signed_by: "sys",
41
+ },
42
+ ];
43
+
44
+ describe("reduceRunState", () => {
45
+ it("extracts started_at from run_started event", () => {
46
+ const summary = reduceRunState(FAKE_WS, SAMPLE_EVENTS);
47
+ expect(summary.started_at).toBe("2026-04-30T10:00:00Z");
48
+ });
49
+
50
+ it("owner_b_accepted is false when no event emitted", () => {
51
+ const summary = reduceRunState(FAKE_WS, SAMPLE_EVENTS);
52
+ expect(summary.owner_b_accepted).toBe(false);
53
+ });
54
+
55
+ it("owner_b_accepted flips to true when event present", () => {
56
+ const events: CommonsLogEvent[] = [
57
+ ...SAMPLE_EVENTS,
58
+ {
59
+ seq: 5,
60
+ event_type: "owner_b_accepted",
61
+ payload: {},
62
+ appended_at: "2026-04-30T10:06:00Z",
63
+ signed_by: "owner_b",
64
+ },
65
+ ];
66
+ const summary = reduceRunState(FAKE_WS, events);
67
+ expect(summary.owner_b_accepted).toBe(true);
68
+ });
69
+
70
+ it("builds two crux entries from dispatched events", () => {
71
+ const summary = reduceRunState(FAKE_WS, SAMPLE_EVENTS);
72
+ expect(summary.cruxes).toHaveLength(2);
73
+ });
74
+
75
+ it("crux_001 has outcome=already_aligned and bilateral_mandate_intact=true", () => {
76
+ const summary = reduceRunState(FAKE_WS, SAMPLE_EVENTS);
77
+ const c1 = summary.cruxes.find((c) => c.crux_id === "crux_001");
78
+ expect(c1).toBeDefined();
79
+ expect(c1!.outcome).toBe("already_aligned");
80
+ expect(c1!.bilateral_mandate_intact).toBe(true);
81
+ expect(c1!.latest_sub_debate_id).toBe("dbt_sub001");
82
+ });
83
+
84
+ it("crux_002 has null outcome (in-progress)", () => {
85
+ const summary = reduceRunState(FAKE_WS, SAMPLE_EVENTS);
86
+ const c2 = summary.cruxes.find((c) => c.crux_id === "crux_002");
87
+ expect(c2).toBeDefined();
88
+ expect(c2!.outcome).toBeNull();
89
+ expect(c2!.bilateral_mandate_intact).toBeNull();
90
+ });
91
+
92
+ it("terminal_emitted is false without terminal event", () => {
93
+ const summary = reduceRunState(FAKE_WS, SAMPLE_EVENTS);
94
+ expect(summary.terminal_emitted).toBe(false);
95
+ });
96
+
97
+ it("terminal_emitted is true on convergence_map event", () => {
98
+ const events: CommonsLogEvent[] = [
99
+ ...SAMPLE_EVENTS,
100
+ {
101
+ seq: 6,
102
+ event_type: "convergence_map",
103
+ payload: {},
104
+ appended_at: "2026-04-30T11:00:00Z",
105
+ signed_by: "sys",
106
+ },
107
+ ];
108
+ const summary = reduceRunState(FAKE_WS, events);
109
+ expect(summary.terminal_emitted).toBe(true);
110
+ });
111
+
112
+ it("re-dispatch appends to sub_debate_chain and updates latest_sub_debate_id", () => {
113
+ const events: CommonsLogEvent[] = [
114
+ ...SAMPLE_EVENTS,
115
+ {
116
+ seq: 5,
117
+ event_type: "sub_debate_dispatched",
118
+ payload: { crux_id: "crux_001", sub_debate_id: "dbt_sub001b" },
119
+ appended_at: "2026-04-30T10:07:00Z",
120
+ signed_by: "sys",
121
+ },
122
+ ];
123
+ const summary = reduceRunState(FAKE_WS, events);
124
+ const c1 = summary.cruxes.find((c) => c.crux_id === "crux_001")!;
125
+ expect(c1.latest_sub_debate_id).toBe("dbt_sub001b");
126
+ expect(c1.sub_debate_chain).toEqual(["dbt_sub001", "dbt_sub001b"]);
127
+ });
128
+
129
+ it("empty event list yields empty cruxes and null started_at", () => {
130
+ const summary = reduceRunState(FAKE_WS, []);
131
+ expect(summary.cruxes).toHaveLength(0);
132
+ expect(summary.started_at).toBeNull();
133
+ expect(summary.terminal_emitted).toBe(false);
134
+ });
135
+
136
+ it("run_id and source_debate_id come from workspace", () => {
137
+ const summary = reduceRunState(FAKE_WS, SAMPLE_EVENTS);
138
+ expect(summary.run_id).toBe(FAKE_WS.runId);
139
+ expect(summary.source_debate_id).toBe(FAKE_WS.sourceDebateId);
140
+ });
141
+ });
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, rmSync, mkdirSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { resolveWorkspace, writeRunMeta, readRunMeta } from "../src/converge/workspace.js";
6
+ import type { RunMeta } from "../src/converge/types.js";
7
+
8
+ const SAMPLE_META: RunMeta = {
9
+ run_id: "clg_aabbccdd11223344",
10
+ source_debate_id: "dbt_001",
11
+ pa_agent_id: "agt_pa001",
12
+ target_corpus: "/corpus/root",
13
+ owner_role: "a",
14
+ };
15
+
16
+ let tmp: string;
17
+
18
+ beforeEach(() => {
19
+ tmp = mkdtempSync(join(tmpdir(), "lc-ws-test-"));
20
+ });
21
+
22
+ afterEach(() => {
23
+ rmSync(tmp, { recursive: true, force: true });
24
+ });
25
+
26
+ describe("resolveWorkspace", () => {
27
+ it("returns workspace from --staging-dir with .run-meta.yaml", async () => {
28
+ writeRunMeta(tmp, SAMPLE_META);
29
+ const ws = await resolveWorkspace({ stagingDir: tmp });
30
+ expect(ws.runId).toBe(SAMPLE_META.run_id);
31
+ expect(ws.sourceDebateId).toBe(SAMPLE_META.source_debate_id);
32
+ expect(ws.stagingDir).toBe(resolve(tmp));
33
+ });
34
+
35
+ it("searches upward from cwd and finds meta", async () => {
36
+ writeRunMeta(tmp, SAMPLE_META);
37
+ const subdir = join(tmp, "sub", "dir");
38
+ mkdirSync(subdir, { recursive: true });
39
+ const ws = await resolveWorkspace({ cwd: subdir });
40
+ expect(ws.runId).toBe(SAMPLE_META.run_id);
41
+ expect(ws.stagingDir).toBe(tmp);
42
+ });
43
+
44
+ it("throws run_id_mismatch when --run-id conflicts with meta", async () => {
45
+ writeRunMeta(tmp, SAMPLE_META);
46
+ await expect(
47
+ resolveWorkspace({ stagingDir: tmp, runId: "clg_different00000000" }),
48
+ ).rejects.toMatchObject({ code: "run_id_mismatch" });
49
+ });
50
+
51
+ it("succeeds when --run-id matches meta", async () => {
52
+ writeRunMeta(tmp, SAMPLE_META);
53
+ const ws = await resolveWorkspace({ stagingDir: tmp, runId: SAMPLE_META.run_id });
54
+ expect(ws.runId).toBe(SAMPLE_META.run_id);
55
+ });
56
+
57
+ it("throws meta_not_found when staging-dir has no meta", async () => {
58
+ await expect(resolveWorkspace({ stagingDir: tmp })).rejects.toMatchObject({
59
+ code: "meta_not_found",
60
+ });
61
+ });
62
+
63
+ it("throws meta_not_found with guidance when --run-id given but no meta found", async () => {
64
+ const empty = mkdtempSync(join(tmpdir(), "lc-empty-"));
65
+ try {
66
+ await expect(
67
+ resolveWorkspace({ runId: "clg_xyz", cwd: empty }),
68
+ ).rejects.toMatchObject({ code: "meta_not_found" });
69
+ } finally {
70
+ rmSync(empty, { recursive: true, force: true });
71
+ }
72
+ });
73
+
74
+ it("returns absolute paths even when stagingDir is relative concept (resolved from cwd)", async () => {
75
+ writeRunMeta(tmp, SAMPLE_META);
76
+ const ws = await resolveWorkspace({ stagingDir: tmp });
77
+ expect(ws.stagingDir).toMatch(/^\//);
78
+ });
79
+ });
80
+
81
+ describe("readRunMeta / writeRunMeta roundtrip", () => {
82
+ it("roundtrips all fields correctly", () => {
83
+ writeRunMeta(tmp, SAMPLE_META);
84
+ const loaded = readRunMeta(tmp);
85
+ expect(loaded).toEqual(SAMPLE_META);
86
+ });
87
+
88
+ it("returns null when file does not exist", () => {
89
+ const result = readRunMeta(join(tmp, "nonexistent"));
90
+ expect(result).toBeNull();
91
+ });
92
+ });
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+
3
+ const sendSpy = vi.fn().mockResolvedValue({ ok: true });
4
+ const eventsSpy = vi.fn().mockResolvedValue({ events: [{}, {}] });
5
+ const hireSpy = vi.fn().mockResolvedValue({ session: { session_id: "s1" }, activated: true });
6
+ const endSessionSpy = vi.fn().mockResolvedValue({ ok: true });
7
+
8
+ vi.mock("../src/context.js", () => ({
9
+ buildContext: () => ({
10
+ cfg: { apiKey: "k", cloudUrl: "http://x" },
11
+ requesterFlows: { hire: hireSpy, send: sendSpy, search: vi.fn(), invoke: vi.fn() },
12
+ consumer: { getSessionEvents: eventsSpy, endSession: endSessionSpy, invoke: vi.fn() },
13
+ providerClient: {},
14
+ }),
15
+ }));
16
+
17
+ vi.mock("../src/output.js", () => ({
18
+ runCommand: async (fn: () => Promise<unknown>) => fn(),
19
+ readStdin: async () => "",
20
+ printError: () => {},
21
+ printResult: () => {},
22
+ }));
23
+
24
+ describe("hire --message", () => {
25
+ it("calls requesterFlows.send once with message and seq=1", async () => {
26
+ const { Command } = await import("commander");
27
+ const program = new Command();
28
+ program.exitOverride();
29
+ const { registerRequesterCommands } = await import("../src/commands/requester.js");
30
+ registerRequesterCommands(program);
31
+
32
+ await program.parseAsync([
33
+ "node", "cli", "hire", "agent-1",
34
+ "--capability", "test-cap",
35
+ "--message", "hello",
36
+ ]);
37
+
38
+ expect(hireSpy).toHaveBeenCalled();
39
+ expect(sendSpy).toHaveBeenCalledWith("s1", "hello", 1);
40
+ });
41
+ });
42
+
43
+ describe("hire --interactive flag", () => {
44
+ it("--interactive option is registered on the hire command", async () => {
45
+ const { Command } = await import("commander");
46
+ const program = new Command();
47
+ const { registerRequesterCommands } = await import("../src/commands/requester.js");
48
+ registerRequesterCommands(program);
49
+
50
+ const hireCmd = program.commands.find((c) => c.name() === "hire");
51
+ expect(hireCmd).toBeDefined();
52
+ const interactiveOpt = hireCmd!.options.find((o) => o.long === "--interactive");
53
+ expect(interactiveOpt).toBeDefined();
54
+ });
55
+ });
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ const eventsSpy = vi.fn();
4
+
5
+ vi.mock("../src/context.js", () => ({
6
+ buildContext: () => ({
7
+ cfg: { apiKey: "k", cloudUrl: "http://x" },
8
+ requesterFlows: { hire: vi.fn(), send: vi.fn(), search: vi.fn(), invoke: vi.fn() },
9
+ consumer: { getSessionEvents: eventsSpy, endSession: vi.fn(), invoke: vi.fn() },
10
+ providerClient: {},
11
+ }),
12
+ }));
13
+
14
+ vi.mock("../src/output.js", () => ({
15
+ runCommand: async (fn: () => Promise<unknown>) => fn(),
16
+ readStdin: async () => "",
17
+ printError: () => {},
18
+ printResult: () => {},
19
+ }));
20
+
21
+ describe("recv subcommand", () => {
22
+ it("registers with --since, --wait, --human options", async () => {
23
+ const { Command } = await import("commander");
24
+ const program = new Command();
25
+ const { registerRequesterCommands } = await import("../src/commands/requester.js");
26
+ registerRequesterCommands(program);
27
+
28
+ const recvCmd = program.commands.find((c) => c.name() === "recv");
29
+ expect(recvCmd).toBeDefined();
30
+ expect(recvCmd!.options.find((o) => o.long === "--since")).toBeDefined();
31
+ expect(recvCmd!.options.find((o) => o.long === "--wait")).toBeDefined();
32
+ expect(recvCmd!.options.find((o) => o.long === "--human")).toBeDefined();
33
+ });
34
+
35
+ it("calls getSessionEvents with offset=0 by default", async () => {
36
+ eventsSpy.mockResolvedValueOnce({ events: [{ event_seq: 1 }], next_offset: 1 });
37
+ const { Command } = await import("commander");
38
+ const program = new Command();
39
+ program.exitOverride();
40
+ const { registerRequesterCommands } = await import("../src/commands/requester.js");
41
+ registerRequesterCommands(program);
42
+
43
+ await program.parseAsync(["node", "cli", "recv", "ses_1"]);
44
+
45
+ expect(eventsSpy).toHaveBeenCalledWith("ses_1", { offset: 0 });
46
+ });
47
+
48
+ it("forwards --since as offset", async () => {
49
+ eventsSpy.mockResolvedValueOnce({ events: [{ event_seq: 7 }], next_offset: 7 });
50
+ const { Command } = await import("commander");
51
+ const program = new Command();
52
+ program.exitOverride();
53
+ const { registerRequesterCommands } = await import("../src/commands/requester.js");
54
+ registerRequesterCommands(program);
55
+
56
+ await program.parseAsync(["node", "cli", "recv", "ses_2", "--since", "5"]);
57
+
58
+ expect(eventsSpy).toHaveBeenCalledWith("ses_2", { offset: 5 });
59
+ });
60
+
61
+ it("polls repeatedly under --wait until events arrive", async () => {
62
+ eventsSpy.mockReset();
63
+ eventsSpy
64
+ .mockResolvedValueOnce({ events: [], next_offset: 0 })
65
+ .mockResolvedValueOnce({ events: [], next_offset: 0 })
66
+ .mockResolvedValueOnce({ events: [{ event_seq: 1 }], next_offset: 1 });
67
+
68
+ vi.useFakeTimers();
69
+ const { Command } = await import("commander");
70
+ const program = new Command();
71
+ program.exitOverride();
72
+ const { registerRequesterCommands } = await import("../src/commands/requester.js");
73
+ registerRequesterCommands(program);
74
+
75
+ const promise = program.parseAsync(["node", "cli", "recv", "ses_3", "--wait", "10"]);
76
+ await vi.advanceTimersByTimeAsync(1500);
77
+ await vi.advanceTimersByTimeAsync(1500);
78
+ await promise;
79
+ vi.useRealTimers();
80
+
81
+ expect(eventsSpy).toHaveBeenCalledTimes(3);
82
+ });
83
+ });
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+
3
+ const writeSpy = vi.fn();
4
+ const readFileSpy = vi.fn().mockReturnValue({});
5
+
6
+ vi.mock("../src/config.js", () => ({
7
+ configPath: () => "/tmp/cli-test-config.yaml",
8
+ configDir: () => "/tmp",
9
+ readFileConfig: readFileSpy,
10
+ writeFileConfig: writeSpy,
11
+ resolveConfig: () => ({ apiKey: "", cloudUrl: "http://localhost:17221" }),
12
+ }));
13
+
14
+ vi.mock("../src/output.js", () => ({
15
+ runCommand: async (fn: () => Promise<unknown>) => fn(),
16
+ readLine: async () => "test-api-key-abc123",
17
+ readStdin: async () => "test-api-key-abc123",
18
+ printError: () => {},
19
+ printResult: () => {},
20
+ }));
21
+
22
+ const openSpy = vi.fn().mockResolvedValue(undefined);
23
+ vi.mock("open", () => ({ default: openSpy }));
24
+
25
+ describe("register --browser", () => {
26
+ it("opens portal URL and persists pasted key via writeFileConfig", async () => {
27
+ openSpy.mockResolvedValueOnce(undefined);
28
+
29
+ const { Command } = await import("commander");
30
+ const program = new Command();
31
+ program.exitOverride();
32
+ const { registerAuthCommands } = await import("../src/commands/auth.js");
33
+ registerAuthCommands(program);
34
+
35
+ await program.parseAsync(["node", "cli", "register", "--cloud-url", "http://test:17221"]);
36
+
37
+ expect(openSpy).toHaveBeenCalledWith("http://test:17221/register");
38
+ expect(writeSpy).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "test-api-key-abc123" }));
39
+ });
40
+
41
+ it("falls through to URL print when open() throws (headless)", async () => {
42
+ openSpy.mockRejectedValueOnce(new Error("no DISPLAY"));
43
+ writeSpy.mockClear();
44
+
45
+ const { Command } = await import("commander");
46
+ const program = new Command();
47
+ program.exitOverride();
48
+ const { registerAuthCommands } = await import("../src/commands/auth.js");
49
+ registerAuthCommands(program);
50
+
51
+ await program.parseAsync(["node", "cli", "register", "--cloud-url", "http://test:17221"]);
52
+
53
+ expect(writeSpy).toHaveBeenCalled();
54
+ });
55
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "rootDir": "src",
7
+ "outDir": "dist",
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "types": ["node"]
11
+ },
12
+ "include": ["src/**/*"],
13
+ "exclude": ["dist", "node_modules", "test"]
14
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { defineConfig } from "tsup";
2
+
3
+ export default defineConfig({
4
+ entry: ["src/bin.ts"],
5
+ format: ["esm"],
6
+ platform: "node",
7
+ dts: false,
8
+ sourcemap: true,
9
+ clean: true,
10
+ target: "node20",
11
+ splitting: false,
12
+ shims: false,
13
+ banner: { js: "#!/usr/bin/env node" },
14
+ // Bundle the four SDK packages so `npm install -g @linkedclaw/cli` works without
15
+ // separate installs.
16
+ noExternal: [
17
+ "@linkedclaw/consumer",
18
+ "@linkedclaw/consumer-runtime",
19
+ "@linkedclaw/provider",
20
+ "@linkedclaw/provider-runtime",
21
+ ],
22
+ // ws is CJS and requires Node built-ins dynamically; keep it external so Node
23
+ // resolves it natively at runtime (ws is a direct dep for this reason).
24
+ external: ["ws"],
25
+ });
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ["test/**/*.test.ts"],
6
+ environment: "node",
7
+ },
8
+ });