cueloop 0.1.0-alpha.21 → 0.1.0-alpha.23

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cueloop",
3
- "version": "0.1.0-alpha.21",
3
+ "version": "0.1.0-alpha.23",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,9 +10,9 @@
10
10
  ".": "./src/main.ts"
11
11
  },
12
12
  "dependencies": {
13
- "@cueloop/schema": "0.1.0-alpha.21",
14
- "@cueloop/daemon": "0.1.0-alpha.21",
15
- "@cueloop/client": "0.1.0-alpha.21"
13
+ "@cueloop/schema": "0.1.0-alpha.23",
14
+ "@cueloop/daemon": "0.1.0-alpha.23",
15
+ "@cueloop/client": "0.1.0-alpha.23"
16
16
  },
17
17
  "publishConfig": {
18
18
  "access": "public"
package/src/main.ts CHANGED
@@ -72,6 +72,16 @@ async function main(): Promise<number> {
72
72
  await new Promise(() => {}); // serve until signalled
73
73
  return 0;
74
74
  }
75
+ case "share": {
76
+ const { positional, flags } = parseArgs(argv.slice(1));
77
+ const { shareCommand } = await import("./share-command");
78
+ const port = stringFlag(flags, "port");
79
+ return shareCommand({
80
+ sessionId: positional[0],
81
+ host: stringFlag(flags, "host"),
82
+ port: port !== undefined ? Number(port) : undefined,
83
+ });
84
+ }
75
85
  case "review":
76
86
  return reviewEntry(argv.slice(1));
77
87
  case "review-post": {
@@ -213,6 +223,8 @@ function printHelp(): void {
213
223
  " cueloop serve [session-id] share over ssh: observers are read-only,",
214
224
  " you stay the controller (--port 2222, --host 127.0.0.1;",
215
225
  " password-less - share the address deliberately)",
226
+ " cueloop share [session-id] hand a plan to a teammate: copies one",
227
+ " ssh line (--host cueloop.dev, --port 22)",
216
228
  " cueloop session <verb> [flags] script the daemon (create|get|list|wait|annotate|resolve|submit-revision)",
217
229
  " cueloop daemon run the daemon in the foreground",
218
230
  ].join("\n"),
@@ -1,8 +1,4 @@
1
- /**
2
- * Verb-first open resolution over a plain session list: latest-pending default,
3
- * by-id, and fuzzy-by-title (exact wins, unique substring wins, else
4
- * ambiguous). No daemon and no TUI - the resolver is pure over the array.
5
- */
1
+ /** Verb-first open resolution over a plain session list: latest-pending default, by-id, and fuzzy-by-title (exact wins, unique substring wins, else ambiguous). Pure over the array - no daemon, no TUI. */
6
2
 
7
3
  import { describe, expect, test } from "bun:test";
8
4
  import type { ArtifactType, ReviewSession } from "@cueloop/schema";
@@ -61,7 +57,10 @@ describe("verb scope predicates are disjoint", () => {
61
57
  });
62
58
 
63
59
  test("a PR review resolves under exactly one scope - never both diff and review", () => {
60
+ // Arrange
64
61
  const prReview = session({ id: "ses_pr", type: "diff", pr: "42" });
62
+
63
+ // Assert
65
64
  expect(isDiffReview(prReview)).toBe(false);
66
65
  expect(isPrReview(prReview)).toBe(true);
67
66
  });
@@ -77,35 +76,47 @@ describe("isSessionId", () => {
77
76
 
78
77
  describe("resolveOpenTarget: latest pending default", () => {
79
78
  test("picks the most recent pending session in scope", () => {
79
+ // Arrange
80
80
  const sessions = [
81
81
  session({ id: "ses_old", createdAt: "2026-01-01T00:00:00.000Z" }),
82
82
  session({ id: "ses_new", createdAt: "2026-03-01T00:00:00.000Z" }),
83
83
  session({ id: "ses_mid", createdAt: "2026-02-01T00:00:00.000Z" }),
84
84
  ];
85
+
86
+ // Assert
85
87
  expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({ kind: "session", sessionId: "ses_new" });
86
88
  });
87
89
 
88
90
  test("ignores resolved sessions and other artifact types", () => {
91
+ // Arrange
89
92
  const sessions = [
90
93
  session({ id: "ses_resolved", createdAt: "2026-05-01T00:00:00.000Z", status: "resolved" }),
91
94
  session({ id: "ses_diff", type: "diff", createdAt: "2026-04-01T00:00:00.000Z" }),
92
95
  session({ id: "ses_plan", createdAt: "2026-02-01T00:00:00.000Z" }),
93
96
  ];
97
+
98
+ // Assert
94
99
  expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({ kind: "session", sessionId: "ses_plan" });
95
100
  });
96
101
 
97
102
  test("no pending session in scope reports no-pending", () => {
103
+ // Arrange
98
104
  const sessions = [session({ id: "ses_done", status: "resolved" })];
105
+
106
+ // Assert
99
107
  expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({ kind: "no-pending" });
100
108
  });
101
109
  });
102
110
 
103
111
  describe("resolveOpenTarget: by id", () => {
104
112
  test("an exact session id in scope opens that session, pending or not", () => {
113
+ // Arrange
105
114
  const sessions = [
106
115
  session({ id: "ses_a" }),
107
116
  session({ id: "ses_b", status: "resolved" }),
108
117
  ];
118
+
119
+ // Assert
109
120
  expect(resolveOpenTarget(sessions, { match: isPlan, selector: "ses_b" })).toEqual({
110
121
  kind: "session",
111
122
  sessionId: "ses_b",
@@ -113,7 +124,10 @@ describe("resolveOpenTarget: by id", () => {
113
124
  });
114
125
 
115
126
  test("an id outside the scope does not match", () => {
127
+ // Arrange
116
128
  const sessions = [session({ id: "ses_diff", type: "diff" })];
129
+
130
+ // Assert
117
131
  expect(resolveOpenTarget(sessions, { match: isPlan, selector: "ses_diff" })).toEqual({
118
132
  kind: "no-match",
119
133
  selector: "ses_diff",
@@ -123,10 +137,13 @@ describe("resolveOpenTarget: by id", () => {
123
137
 
124
138
  describe("resolveOpenTarget: fuzzy by title", () => {
125
139
  test("case-insensitive exact title wins over substring rivals", () => {
140
+ // Arrange
126
141
  const sessions = [
127
142
  session({ id: "ses_exact", title: "Rollout" }),
128
143
  session({ id: "ses_sub", title: "Rollout plan v2" }),
129
144
  ];
145
+
146
+ // Assert
130
147
  expect(resolveOpenTarget(sessions, { match: isPlan, selector: "rollout" })).toEqual({
131
148
  kind: "session",
132
149
  sessionId: "ses_exact",
@@ -134,10 +151,13 @@ describe("resolveOpenTarget: fuzzy by title", () => {
134
151
  });
135
152
 
136
153
  test("a unique case-insensitive substring match wins", () => {
154
+ // Arrange
137
155
  const sessions = [
138
156
  session({ id: "ses_auth", title: "Auth rewrite" }),
139
157
  session({ id: "ses_cache", title: "Cache warmup" }),
140
158
  ];
159
+
160
+ // Assert
141
161
  expect(resolveOpenTarget(sessions, { match: isPlan, selector: "AUTH" })).toEqual({
142
162
  kind: "session",
143
163
  sessionId: "ses_auth",
@@ -145,10 +165,13 @@ describe("resolveOpenTarget: fuzzy by title", () => {
145
165
  });
146
166
 
147
167
  test("several substring matches are ambiguous and list the candidate titles", () => {
168
+ // Arrange
148
169
  const sessions = [
149
170
  session({ id: "ses_1", title: "Payments rollout" }),
150
171
  session({ id: "ses_2", title: "Rollout of search" }),
151
172
  ];
173
+
174
+ // Assert
152
175
  expect(resolveOpenTarget(sessions, { match: isPlan, selector: "rollout" })).toEqual({
153
176
  kind: "ambiguous",
154
177
  selector: "rollout",
@@ -157,7 +180,10 @@ describe("resolveOpenTarget: fuzzy by title", () => {
157
180
  });
158
181
 
159
182
  test("no title match reports no-match", () => {
183
+ // Arrange
160
184
  const sessions = [session({ id: "ses_1", title: "Auth rewrite" })];
185
+
186
+ // Assert
161
187
  expect(resolveOpenTarget(sessions, { match: isPlan, selector: "billing" })).toEqual({
162
188
  kind: "no-match",
163
189
  selector: "billing",
@@ -177,7 +203,10 @@ describe("openTargetMessage", () => {
177
203
  });
178
204
 
179
205
  test("ambiguous lists the candidate titles", () => {
206
+ // Act
180
207
  const message = openTargetMessage("plan", { kind: "ambiguous", selector: "rollout", titles: ["A", "B"] });
208
+
209
+ // Assert
181
210
  expect(message).toContain('"rollout" matches several plan reviews');
182
211
  expect(message).toContain(" - A");
183
212
  expect(message).toContain(" - B");
@@ -96,7 +96,12 @@ export async function sessionCommand(argv: string[]): Promise<number> {
96
96
  const id = required(positional[1], "session id");
97
97
  const contentFile = stringFlag(flags, "content-file");
98
98
  const content = contentFile ? await Bun.file(contentFile).text() : await readStdin();
99
- out(await client.sessionSubmitRevision(id, content));
99
+ // --addressed a_1,a_2 - the annotation ids this revision acted on
100
+ const addressed = (stringFlag(flags, "addressed") ?? "")
101
+ .split(",")
102
+ .map((annotationId) => annotationId.trim())
103
+ .filter(Boolean);
104
+ out(await client.sessionSubmitRevision(id, content, addressed));
100
105
  return 0;
101
106
  }
102
107
  default:
@@ -0,0 +1,87 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import { SCHEMA_VERSION, type ReviewSession } from "@cueloop/schema";
3
+ import type { SessionClient } from "@cueloop/daemon/client";
4
+ import { shareSession, type ShareDeps } from "./share-command";
5
+
6
+ function sessionFixture(id: string): ReviewSession {
7
+ return {
8
+ schemaVersion: SCHEMA_VERSION,
9
+ id,
10
+ workspace: { repoRoot: "/repo", branch: "main" },
11
+ artifact: { type: "plan", content: "# Plan\n", meta: {} },
12
+ revisions: [{ revision: 1, content: "# Plan\n", submittedAt: "2026-01-01T00:00:00.000Z" }],
13
+ annotations: [],
14
+ verdict: null,
15
+ status: "pending",
16
+ createdAt: "2026-01-01T00:00:00.000Z",
17
+ };
18
+ }
19
+
20
+ /** A SessionClient that answers get/list from a fixed list. */
21
+ function fakeClient(sessions: ReviewSession[]): SessionClient {
22
+ return {
23
+ sessionGet: async (id: string) => sessions.find((session) => session.id === id)!,
24
+ sessionList: async () => sessions,
25
+ } as unknown as SessionClient;
26
+ }
27
+
28
+ function depsSpy(overrides: Partial<ShareDeps> = {}): ShareDeps & { lines: string[] } {
29
+ const lines: string[] = [];
30
+ return {
31
+ publish: mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: true })),
32
+ out: (message: string) => void lines.push(message),
33
+ lines,
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ describe(shareSession, () => {
39
+ test("publishes the named session and reports the copied ssh line", async () => {
40
+ // Arrange
41
+ const publish = mock(async (session: ReviewSession) => (expect(session.id).toBe("ses_2"), { line: "ssh p_abc123xy@cueloop.dev", copied: true }));
42
+ const deps = depsSpy({ publish });
43
+
44
+ // Act
45
+ const code = await shareSession(fakeClient([sessionFixture("ses_1"), sessionFixture("ses_2")]), { sessionId: "ses_2" }, deps);
46
+
47
+ // Assert
48
+ expect(code).toBe(0);
49
+ expect(deps.lines).toEqual(["share link copied - ssh p_abc123xy@cueloop.dev"]);
50
+ });
51
+
52
+ test("without an id, shares the most recent session", async () => {
53
+ // Arrange
54
+ const publish = mock(async (session: ReviewSession) => (expect(session.id).toBe("ses_2"), { line: "ssh p_zzzzzzzz@cueloop.dev", copied: true }));
55
+ const deps = depsSpy({ publish });
56
+
57
+ // Act
58
+ await shareSession(fakeClient([sessionFixture("ses_1"), sessionFixture("ses_2")]), {}, deps);
59
+
60
+ // Assert
61
+ expect(deps.publish).toHaveBeenCalledTimes(1);
62
+ });
63
+
64
+ test("prints the bare line when the clipboard is unavailable", async () => {
65
+ // Arrange
66
+ const deps = depsSpy({ publish: mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: false })) });
67
+
68
+ // Act
69
+ await shareSession(fakeClient([sessionFixture("ses_1")]), { sessionId: "ses_1" }, deps);
70
+
71
+ // Assert
72
+ expect(deps.lines).toEqual(["ssh p_abc123xy@cueloop.dev"]);
73
+ });
74
+
75
+ test("reports nothing to share when the inbox is empty", async () => {
76
+ // Arrange
77
+ const deps = depsSpy();
78
+
79
+ // Act
80
+ const code = await shareSession(fakeClient([]), {}, deps);
81
+
82
+ // Assert
83
+ expect(code).toBe(1);
84
+ expect(deps.lines[0]).toContain("no plan to share");
85
+ expect(deps.publish).not.toHaveBeenCalled();
86
+ });
87
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `cueloop share [session-id]` - hand a plan to a teammate as one SSH line.
3
+ * This side just picks the session and delegates to the shared `publishShare`
4
+ * transport (the same one the in-TUI Share button uses); the gateway seals and
5
+ * stores. The transport and output are injected so the orchestration is
6
+ * testable without a live gateway.
7
+ */
8
+
9
+ import { DaemonClient, type SessionClient } from "@cueloop/daemon/client";
10
+ import { publishShare, type ShareResult, type ShareTarget } from "@cueloop/client";
11
+ import type { ReviewSession } from "@cueloop/schema";
12
+
13
+ export interface ShareParams {
14
+ sessionId?: string;
15
+ host?: string;
16
+ port?: number;
17
+ home?: string;
18
+ }
19
+
20
+ export interface ShareDeps {
21
+ publish: (session: ReviewSession, target: ShareTarget) => Promise<ShareResult>;
22
+ out: (message: string) => void;
23
+ }
24
+
25
+ const defaultDeps: ShareDeps = { publish: publishShare, out: (message) => console.log(message) };
26
+
27
+ /** Connect to the local daemon, then share the chosen session. */
28
+ export async function shareCommand(params: ShareParams, deps: ShareDeps = defaultDeps): Promise<number> {
29
+ const client = await DaemonClient.connect({ home: params.home, autostart: true });
30
+ try {
31
+ return await shareSession(client, params, deps);
32
+ } finally {
33
+ client.close();
34
+ }
35
+ }
36
+
37
+ /** The orchestration, over any SessionClient - the seam the tests drive. */
38
+ export async function shareSession(client: SessionClient, params: ShareParams, deps: ShareDeps): Promise<number> {
39
+ const session = await pickSession(client, params.sessionId);
40
+ if (!session) {
41
+ deps.out("no plan to share - open a review first");
42
+ return 1;
43
+ }
44
+ const { line, copied } = await deps.publish(session, { host: params.host, port: params.port });
45
+ deps.out(copied ? `share link copied - ${line}` : line);
46
+ return 0;
47
+ }
48
+
49
+ /** The named session, or the most recent one when no id is given. */
50
+ async function pickSession(client: SessionClient, sessionId?: string): Promise<ReviewSession | null> {
51
+ if (sessionId) return client.sessionGet(sessionId);
52
+ const sessions = await client.sessionList();
53
+ return sessions.at(-1) ?? null;
54
+ }