cueloop 0.1.0-alpha.4 → 0.1.0-alpha.41

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.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Verb-first review-open resolution: turn a verb (plan/diff/review) plus an
3
+ * optional id-or-title selector into a single session to open, or a clear
4
+ * miss. Pure over a session list so it unit-tests without a daemon or a TUI -
5
+ * main.ts fetches the sessions and prints the miss messages.
6
+ *
7
+ * Rules, in order:
8
+ * - no selector opens the latest pending session in scope (most recent
9
+ * createdAt, verdict still open) - the `--latest` default;
10
+ * - a selector that exactly equals a session id opens that session, pending
11
+ * or resolved;
12
+ * - otherwise the selector matches session titles, case-insensitive: an
13
+ * exact title wins, else a unique substring match wins, else the substring
14
+ * candidates are reported as ambiguous.
15
+ * Every lookup is scoped by the caller's `match` predicate, so a verb only
16
+ * ever resolves to its own artifact kind.
17
+ */
18
+
19
+ import type { ReviewSession } from "@cueloop/schema";
20
+
21
+ /**
22
+ * The verb scopes, kept disjoint on purpose so a single session never resolves
23
+ * under two verbs. A PR review is a diff artifact that carries a `pr`
24
+ * reference (openReview maps a top-level `pr` into `meta.pr`), so the plain
25
+ * diff scope must exclude those - otherwise `cueloop diff` and `cueloop
26
+ * review` would both open the same pending PR review.
27
+ */
28
+ export function isPlanReview(session: ReviewSession): boolean {
29
+ return session.artifact.type === "plan";
30
+ }
31
+ export function isDiffReview(session: ReviewSession): boolean {
32
+ return session.artifact.type === "diff" && session.artifact.meta.pr === undefined;
33
+ }
34
+ export function isPrReview(session: ReviewSession): boolean {
35
+ return session.artifact.type === "diff" && session.artifact.meta.pr !== undefined;
36
+ }
37
+
38
+ /** Session ids are minted with this prefix; the id-vs-title split lives here. */
39
+ export function isSessionId(value: string): boolean {
40
+ return value.startsWith("ses_");
41
+ }
42
+
43
+ export interface OpenTargetQuery {
44
+ /** Only sessions this predicate accepts are eligible - the verb's artifact scope. */
45
+ match: (session: ReviewSession) => boolean;
46
+ /** Positional id-or-title selector; absent means "latest pending". */
47
+ selector?: string;
48
+ }
49
+
50
+ export type OpenTarget =
51
+ | { kind: "session"; sessionId: string }
52
+ | { kind: "no-pending" }
53
+ | { kind: "no-match"; selector: string }
54
+ | { kind: "ambiguous"; selector: string; titles: string[] };
55
+
56
+ /** Most-recent-first, so the head of a sorted list is always the latest. */
57
+ function newestFirst(left: ReviewSession, right: ReviewSession): number {
58
+ return right.createdAt.localeCompare(left.createdAt);
59
+ }
60
+
61
+ export function resolveOpenTarget(sessions: ReviewSession[], query: OpenTargetQuery): OpenTarget {
62
+ const scoped = sessions.filter(query.match);
63
+
64
+ if (query.selector === undefined) {
65
+ const latestPending = scoped
66
+ .filter((candidate) => candidate.status === "pending")
67
+ .sort(newestFirst)[0];
68
+ return latestPending
69
+ ? { kind: "session", sessionId: latestPending.id }
70
+ : { kind: "no-pending" };
71
+ }
72
+
73
+ const selector = query.selector;
74
+ const byId = scoped.find((candidate) => candidate.id === selector);
75
+ if (byId) return { kind: "session", sessionId: byId.id };
76
+
77
+ const loweredSelector = selector.toLowerCase();
78
+ const titled = scoped.filter((candidate) => (candidate.artifact.meta.title ?? "").length > 0);
79
+
80
+ const exactTitle = titled
81
+ .filter((candidate) => candidate.artifact.meta.title!.toLowerCase() === loweredSelector)
82
+ .sort(newestFirst)[0];
83
+ if (exactTitle) return { kind: "session", sessionId: exactTitle.id };
84
+
85
+ const substringMatches = titled
86
+ .filter((candidate) => candidate.artifact.meta.title!.toLowerCase().includes(loweredSelector))
87
+ .sort(newestFirst);
88
+ if (substringMatches.length === 1) return { kind: "session", sessionId: substringMatches[0]!.id };
89
+ if (substringMatches.length === 0) return { kind: "no-match", selector };
90
+ return {
91
+ kind: "ambiguous",
92
+ selector,
93
+ titles: substringMatches.map((candidate) => candidate.artifact.meta.title!),
94
+ };
95
+ }
96
+
97
+ /**
98
+ * The reviewer-facing line for a miss, in the "working tree is clean - nothing
99
+ * to review" tone. `label` is the human word for the verb's scope ("plan",
100
+ * "diff", "PR"). The "session" outcome has no message - the caller opens it.
101
+ */
102
+ export function openTargetMessage(
103
+ label: string,
104
+ target: Exclude<OpenTarget, { kind: "session" }>,
105
+ ): string {
106
+ switch (target.kind) {
107
+ case "no-pending":
108
+ return `no pending ${label} review - nothing to open`;
109
+ case "no-match":
110
+ return `no ${label} review matches "${target.selector}" - nothing to open`;
111
+ case "ambiguous":
112
+ return [
113
+ `"${target.selector}" matches several ${label} reviews - name one:`,
114
+ ...target.titles.map((title) => ` - ${title}`),
115
+ ].join("\n");
116
+ }
117
+ }
package/src/pr.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * PR review entry (#18): `cueloop review <pr>` fetches the PR diff through the
2
+ * PR review entry: `cueloop review <pr>` fetches the PR diff through the
3
3
  * user's `gh` CLI (auth fully delegated), opens a diff session, and posts the
4
4
  * verdict back to the PR as a real review when the session resolves.
5
5
  * `cueloop review-post <session-id> <pr>` is the non-interactive post-back
@@ -8,8 +8,8 @@
8
8
 
9
9
  import type { ReviewSession, VerdictKind } from "@cueloop/schema";
10
10
  import { DaemonClient } from "@cueloop/daemon/client";
11
+ import { openReview } from "@cueloop/daemon/review";
11
12
  import { parseArgs } from "./args";
12
- import { resolveWorkspace } from "./workspace";
13
13
 
14
14
  /** The gh binary is injectable so tests can stub it. */
15
15
  function ghBin(): string {
@@ -55,13 +55,16 @@ export async function reviewCommand(argv: string[]): Promise<number> {
55
55
  console.error(`PR ${pr} has an empty diff - nothing to review`);
56
56
  return 1;
57
57
  }
58
- const workspace = await resolveWorkspace();
59
58
  const client = await DaemonClient.connect({ autostart: true });
60
- const session = await client.sessionCreate(workspace, {
59
+ // A PR diff is a partial patch with no full file contents, so no `files` here;
60
+ // hunk curation stays disabled for PR reviews (see diff-hunk-curate.ts).
61
+ const review = await openReview(client, {
61
62
  type: "diff",
62
63
  content: diff.stdout,
63
- meta: { title: `PR ${pr}`, pr, cwd: process.cwd() },
64
+ title: `PR ${pr}`,
65
+ pr,
64
66
  });
67
+ const session = review.session;
65
68
  client.close();
66
69
 
67
70
  if (flags["no-tui"]) {
@@ -108,7 +111,14 @@ async function getSession(id: string): Promise<ReviewSession> {
108
111
  /** Post the resolved session's verdict to the PR: feedback.md is the review body. */
109
112
  async function postVerdict(session: ReviewSession, pr: string): Promise<number> {
110
113
  const verdict = session.verdict!;
111
- const result = await gh(["pr", "review", pr, VERDICT_FLAG[verdict.kind], "--body", verdict.feedback]);
114
+ const result = await gh([
115
+ "pr",
116
+ "review",
117
+ pr,
118
+ VERDICT_FLAG[verdict.kind],
119
+ "--body",
120
+ verdict.feedback,
121
+ ]);
112
122
  if (result.code !== 0) {
113
123
  console.error(result.stderr.trim() || `gh pr review ${pr} failed (exit ${result.code})`);
114
124
  return 1;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * `cueloop session *` - mirrors the daemon socket API 1:1. One surface,
3
+ * three consumers: agent adapters, the dev loop, and integrations. Output is
4
+ * JSON on stdout; exit code 0 unless the daemon returned an error.
5
+ */
6
+
7
+ import { newAnnotationId, type ArtifactType, type VerdictKind } from "@cueloop/schema";
8
+ import { DaemonClient } from "@cueloop/daemon/client";
9
+ import { openHerdrPaneForReview } from "@cueloop/daemon/herdr-pane";
10
+ import { openReview, verdictResponse, type ReviewNote } from "@cueloop/daemon/review";
11
+ import { loadConfig, quickActionBody, resolveQuickAction } from "@cueloop/client/config";
12
+ import { parseArgs, stringFlag } from "./args";
13
+
14
+ async function readStdin(): Promise<string> {
15
+ return await new Response(Bun.stdin.stream()).text();
16
+ }
17
+
18
+ function out(value: unknown): void {
19
+ console.log(JSON.stringify(value, null, 2));
20
+ }
21
+
22
+ export async function sessionCommand(argv: string[]): Promise<number> {
23
+ const { positional, flags } = parseArgs(argv);
24
+ const verb = positional[0];
25
+ // --role agent (or collaborator) caps this connection to read + annotate; a
26
+ // review-side agent passes it so the daemon rejects any escalation attempt.
27
+ const role = stringFlag(flags, "role") === "agent" ? "agent" : undefined;
28
+ const client = await DaemonClient.connect({ autostart: true, role });
29
+ try {
30
+ switch (verb) {
31
+ case "create": {
32
+ const contentFile = stringFlag(flags, "content-file");
33
+ const content = contentFile ? await Bun.file(contentFile).text() : await readStdin();
34
+ // per-file agent notes for diff sessions: a JSON array of { path, body }
35
+ const notesFile = stringFlag(flags, "notes-file");
36
+ const notes = notesFile
37
+ ? (JSON.parse(await Bun.file(notesFile).text()) as ReviewNote[])
38
+ : undefined;
39
+ const review = await openReview(client, {
40
+ type: (stringFlag(flags, "type") ?? "plan") as ArtifactType,
41
+ content,
42
+ cwd: stringFlag(flags, "cwd"),
43
+ agent: stringFlag(flags, "agent"),
44
+ agentSessionId: stringFlag(flags, "agent-session-id"),
45
+ planPath: stringFlag(flags, "plan-path"),
46
+ title: stringFlag(flags, "title"),
47
+ notes,
48
+ });
49
+ // herdr auto-open: render the review in a tab (no-op outside herdr).
50
+ await openHerdrPaneForReview(review.session, client);
51
+ out(review.session);
52
+ return 0;
53
+ }
54
+ case "get": {
55
+ out(await client.sessionGet(required(positional[1], "session id")));
56
+ return 0;
57
+ }
58
+ case "list": {
59
+ const status = stringFlag(flags, "status") as "pending" | "resolved" | undefined;
60
+ out(await client.sessionList(status ? { status } : undefined));
61
+ return 0;
62
+ }
63
+ case "wait": {
64
+ const id = required(positional[1], "session id");
65
+ const timeoutMs = Number(stringFlag(flags, "timeout-ms") ?? "60000");
66
+ const session = await client.sessionWait(id, timeoutMs);
67
+ if (session === null) {
68
+ out({ status: "pending" });
69
+ return 0;
70
+ }
71
+ const { allow, feedback } = verdictResponse(session);
72
+ out({ status: "resolved", allow, verdict: session.verdict!.kind, feedback });
73
+ return 0;
74
+ }
75
+ case "annotate": {
76
+ const id = required(positional[1], "session id");
77
+ const quote = required(stringFlag(flags, "quote"), "--quote");
78
+ const author = stringFlag(flags, "author");
79
+ const body = await annotateBody(flags, client, id);
80
+ out(
81
+ await client.sessionAnnotate(
82
+ id,
83
+ {
84
+ id: stringFlag(flags, "annotation-id") ?? newAnnotationId(),
85
+ kind: stringFlag(flags, "kind") ?? "comment",
86
+ anchor: {
87
+ quote,
88
+ prefix: stringFlag(flags, "prefix") ?? "",
89
+ suffix: stringFlag(flags, "suffix") ?? "",
90
+ },
91
+ body,
92
+ ...(author ? { author } : {}),
93
+ },
94
+ stringFlag(flags, "author-name"),
95
+ ),
96
+ );
97
+ return 0;
98
+ }
99
+ case "resolve": {
100
+ const id = required(positional[1], "session id");
101
+ const kind = required(stringFlag(flags, "verdict"), "--verdict") as VerdictKind;
102
+ out(await client.sessionResolve(id, kind, stringFlag(flags, "summary") ?? ""));
103
+ return 0;
104
+ }
105
+ case "submit-revision": {
106
+ const id = required(positional[1], "session id");
107
+ const contentFile = stringFlag(flags, "content-file");
108
+ const content = contentFile ? await Bun.file(contentFile).text() : await readStdin();
109
+ // --addressed a_1,a_2 - the annotation ids this revision acted on
110
+ const addressed = (stringFlag(flags, "addressed") ?? "")
111
+ .split(",")
112
+ .map((annotationId) => annotationId.trim())
113
+ .filter(Boolean);
114
+ out(await client.sessionSubmitRevision(id, content, addressed));
115
+ return 0;
116
+ }
117
+ default:
118
+ console.error(
119
+ "usage: cueloop session <create|get|list|wait|annotate|resolve|submit-revision> [flags]",
120
+ );
121
+ return 2;
122
+ }
123
+ } finally {
124
+ client.close();
125
+ }
126
+ }
127
+
128
+ function required<T>(value: T | undefined, description: string): T {
129
+ if (value === undefined) throw new Error(`missing ${description}`);
130
+ return value;
131
+ }
132
+
133
+ /**
134
+ * The annotation body: an explicit `--body`, else the `--action <index|name>`
135
+ * quick-action expanded through the shared vocabulary. The vocabulary is loaded
136
+ * from the reviewed session's own repo, not the caller's cwd, so an agent
137
+ * annotating from elsewhere still sees that session's actions.
138
+ */
139
+ async function annotateBody(
140
+ flags: Record<string, string | boolean>,
141
+ client: DaemonClient,
142
+ id: string,
143
+ ): Promise<string> {
144
+ const explicit = stringFlag(flags, "body");
145
+ if (explicit !== undefined) return explicit;
146
+ const actionRef = stringFlag(flags, "action");
147
+ if (actionRef === undefined) return "";
148
+ const session = await client.sessionGet(id);
149
+ const actions = loadConfig({ repoRoot: session.workspace.repoRoot }).actions;
150
+ const action = resolveQuickAction(actions, actionRef);
151
+ if (!action) throw new Error(`no quick action ${actionRef} - see: cueloop actions list`);
152
+ return quickActionBody(action);
153
+ }
@@ -0,0 +1,220 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import { SCHEMA_VERSION, type Annotation, type ReviewSession } from "@cueloop/schema";
3
+ import type { SessionClient } from "@cueloop/daemon/client";
4
+ import { pullSession, shareSession, type PullDeps, type ShareDeps } from "./share-command";
5
+
6
+ function sessionFixture(id: string, overrides: Partial<ReviewSession> = {}): 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
+ ...overrides,
18
+ };
19
+ }
20
+
21
+ function annotationFixture(id: string, author?: string): Annotation {
22
+ const base: Annotation = {
23
+ id,
24
+ kind: "comment",
25
+ anchor: { quote: "Plan", prefix: "", suffix: "" },
26
+ body: "note",
27
+ createdAt: "2026-01-01T00:00:00.000Z",
28
+ };
29
+ return author ? { ...base, author } : base;
30
+ }
31
+
32
+ /** A SessionClient that answers get/list from a fixed list and records the share/merge verbs. */
33
+ function fakeClient(sessions: ReviewSession[]): SessionClient {
34
+ return {
35
+ sessionGet: async (id: string) => sessions.find((session) => session.id === id)!,
36
+ sessionList: async () => sessions,
37
+ sessionSetShareId: mock(async (id: string, shareId: string) => {
38
+ const session = sessions.find((candidate) => candidate.id === id)!;
39
+ session.shareId = shareId;
40
+ return session;
41
+ }),
42
+ sessionMergeShared: mock(async (id: string, incoming: { annotations: Annotation[] }) => {
43
+ const session = sessions.find((candidate) => candidate.id === id)!;
44
+ const known = new Set(session.annotations.map((annotation) => annotation.id));
45
+ for (const annotation of incoming.annotations)
46
+ if (!known.has(annotation.id)) session.annotations.push(annotation);
47
+ return session;
48
+ }),
49
+ } as unknown as SessionClient;
50
+ }
51
+
52
+ function depsSpy(overrides: Partial<ShareDeps> = {}): ShareDeps & { lines: string[] } {
53
+ const lines: string[] = [];
54
+ return {
55
+ publish: mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: true })),
56
+ out: (message: string) => void lines.push(message),
57
+ lines,
58
+ ...overrides,
59
+ };
60
+ }
61
+
62
+ describe(shareSession, () => {
63
+ test("publishes the named session and reports the copied ssh line", async () => {
64
+ // Arrange
65
+ const publish = mock(
66
+ async (session: ReviewSession) => (
67
+ expect(session.id).toBe("ses_2"),
68
+ { line: "ssh p_abc123xy@cueloop.dev", copied: true }
69
+ ),
70
+ );
71
+ const deps = depsSpy({ publish });
72
+
73
+ // Act
74
+ const code = await shareSession(
75
+ fakeClient([sessionFixture("ses_1"), sessionFixture("ses_2")]),
76
+ { sessionId: "ses_2" },
77
+ deps,
78
+ );
79
+
80
+ // Assert
81
+ expect(code).toBe(0);
82
+ expect(deps.lines).toEqual(["share link copied - ssh p_abc123xy@cueloop.dev"]);
83
+ });
84
+
85
+ test("without an id, shares the most recent session", async () => {
86
+ // Arrange
87
+ const publish = mock(
88
+ async (session: ReviewSession) => (
89
+ expect(session.id).toBe("ses_2"),
90
+ { line: "ssh p_zzzzzzzz@cueloop.dev", copied: true }
91
+ ),
92
+ );
93
+ const deps = depsSpy({ publish });
94
+
95
+ // Act
96
+ await shareSession(fakeClient([sessionFixture("ses_1"), sessionFixture("ses_2")]), {}, deps);
97
+
98
+ // Assert
99
+ expect(deps.publish).toHaveBeenCalledTimes(1);
100
+ });
101
+
102
+ test("prints the bare line when the clipboard is unavailable", async () => {
103
+ // Arrange
104
+ const deps = depsSpy({
105
+ publish: mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: false })),
106
+ });
107
+
108
+ // Act
109
+ await shareSession(fakeClient([sessionFixture("ses_1")]), { sessionId: "ses_1" }, deps);
110
+
111
+ // Assert
112
+ expect(deps.lines).toEqual(["ssh p_abc123xy@cueloop.dev"]);
113
+ });
114
+
115
+ test("reports nothing to share when the inbox is empty", async () => {
116
+ // Arrange
117
+ const deps = depsSpy();
118
+
119
+ // Act
120
+ const code = await shareSession(fakeClient([]), {}, deps);
121
+
122
+ // Assert
123
+ expect(code).toBe(1);
124
+ expect(deps.lines[0]).toContain("no plan to share");
125
+ expect(deps.publish).not.toHaveBeenCalled();
126
+ });
127
+
128
+ test("stamps the share id on the session so a later pull can find it", async () => {
129
+ // Arrange
130
+ const session = sessionFixture("ses_1");
131
+ const client = fakeClient([session]);
132
+ const deps = depsSpy();
133
+
134
+ // Act
135
+ await shareSession(client, { sessionId: "ses_1" }, deps);
136
+
137
+ // Assert
138
+ expect(client.sessionSetShareId).toHaveBeenCalledWith("ses_1", "p_abc123xy");
139
+ });
140
+ });
141
+
142
+ function pullDepsSpy(remote: ReviewSession): PullDeps & { lines: string[] } {
143
+ const lines: string[] = [];
144
+ return {
145
+ pull: mock(async () => remote),
146
+ out: (message: string) => void lines.push(message),
147
+ lines,
148
+ };
149
+ }
150
+
151
+ describe(pullSession, () => {
152
+ test("unions collaborator notes in, ignoring the planner's own unauthored notes", async () => {
153
+ // Arrange - the planner's own note carries no author; the collaborator's does
154
+ const local = sessionFixture("ses_1", {
155
+ shareId: "p_abc123xy",
156
+ annotations: [annotationFixture("mine")],
157
+ });
158
+ const client = fakeClient([local]);
159
+ const remote = sessionFixture("ses_1", {
160
+ annotations: [annotationFixture("mine"), annotationFixture("theirs", "SHA256:mate")],
161
+ });
162
+ const deps = pullDepsSpy(remote);
163
+
164
+ // Act
165
+ const code = await pullSession(client, { sessionId: "ses_1" }, deps);
166
+
167
+ // Assert
168
+ expect(code).toBe(0);
169
+ expect(deps.pull).toHaveBeenCalledWith("p_abc123xy", { host: undefined, port: undefined });
170
+ expect(local.annotations.map((annotation) => annotation.id)).toEqual(["mine", "theirs"]);
171
+ expect(deps.lines).toEqual(["pulled 1 new annotation"]);
172
+ });
173
+
174
+ test("does not resurrect a note the planner deleted locally after sharing", async () => {
175
+ // Arrange - the planner deleted "mine" locally; the share blob still carries it, unauthored
176
+ const local = sessionFixture("ses_1", { shareId: "p_abc123xy", annotations: [] });
177
+ const deps = pullDepsSpy(sessionFixture("ses_1", { annotations: [annotationFixture("mine")] }));
178
+
179
+ // Act
180
+ await pullSession(fakeClient([local]), { sessionId: "ses_1" }, deps);
181
+
182
+ // Assert
183
+ expect(local.annotations).toEqual([]);
184
+ expect(deps.lines).toEqual(["no new annotations"]);
185
+ });
186
+
187
+ test("reports when nothing new came back", async () => {
188
+ // Arrange
189
+ const local = sessionFixture("ses_1", {
190
+ shareId: "p_abc123xy",
191
+ annotations: [annotationFixture("theirs", "SHA256:mate")],
192
+ });
193
+ const deps = pullDepsSpy(
194
+ sessionFixture("ses_1", { annotations: [annotationFixture("theirs", "SHA256:mate")] }),
195
+ );
196
+
197
+ // Act
198
+ await pullSession(fakeClient([local]), { sessionId: "ses_1" }, deps);
199
+
200
+ // Assert
201
+ expect(deps.lines).toEqual(["no new annotations"]);
202
+ });
203
+
204
+ test("refuses to pull a plan that was never shared", async () => {
205
+ // Arrange
206
+ const deps = pullDepsSpy(sessionFixture("ses_1"));
207
+
208
+ // Act
209
+ const code = await pullSession(
210
+ fakeClient([sessionFixture("ses_1")]),
211
+ { sessionId: "ses_1" },
212
+ deps,
213
+ );
214
+
215
+ // Assert
216
+ expect(code).toBe(1);
217
+ expect(deps.lines[0]).toContain("no shared plan");
218
+ expect(deps.pull).not.toHaveBeenCalled();
219
+ });
220
+ });
@@ -0,0 +1,133 @@
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 {
11
+ collaboratorAnnotations,
12
+ publishShare,
13
+ pullShare,
14
+ shareIdFromLine,
15
+ type ShareResult,
16
+ type ShareTarget,
17
+ } from "@cueloop/client";
18
+ import type { ReviewSession } from "@cueloop/schema";
19
+
20
+ export interface ShareParams {
21
+ sessionId?: string;
22
+ host?: string;
23
+ port?: number;
24
+ home?: string;
25
+ }
26
+
27
+ export interface ShareDeps {
28
+ publish: (session: ReviewSession, target: ShareTarget) => Promise<ShareResult>;
29
+ out: (message: string) => void;
30
+ }
31
+
32
+ export interface PullDeps {
33
+ pull: (shareId: string, target: ShareTarget) => Promise<ReviewSession>;
34
+ out: (message: string) => void;
35
+ }
36
+
37
+ const defaultDeps: ShareDeps = { publish: publishShare, out: (message) => console.log(message) };
38
+ const defaultPullDeps: PullDeps = { pull: pullShare, out: (message) => console.log(message) };
39
+
40
+ /** Connect to the local daemon, then share the chosen session. */
41
+ export async function shareCommand(
42
+ params: ShareParams,
43
+ deps: ShareDeps = defaultDeps,
44
+ ): Promise<number> {
45
+ const client = await DaemonClient.connect({ home: params.home, autostart: true });
46
+ try {
47
+ return await shareSession(client, params, deps);
48
+ } finally {
49
+ client.close();
50
+ }
51
+ }
52
+
53
+ /** Connect to the local daemon, then pull collaborator notes for the plan. */
54
+ export async function sharePullCommand(
55
+ params: ShareParams,
56
+ deps: PullDeps = defaultPullDeps,
57
+ ): Promise<number> {
58
+ const client = await DaemonClient.connect({ home: params.home, autostart: true });
59
+ try {
60
+ return await pullSession(client, params, deps);
61
+ } finally {
62
+ client.close();
63
+ }
64
+ }
65
+
66
+ /** The share orchestration, over any SessionClient - the seam the tests drive. */
67
+ export async function shareSession(
68
+ client: SessionClient,
69
+ params: ShareParams,
70
+ deps: ShareDeps,
71
+ ): Promise<number> {
72
+ const session = await pickSession(client, params.sessionId);
73
+ if (!session) {
74
+ deps.out("no plan to share - open a review first");
75
+ return 1;
76
+ }
77
+ const { line, copied } = await deps.publish(session, { host: params.host, port: params.port });
78
+ const shareId = shareIdFromLine(line);
79
+ if (shareId) await client.sessionSetShareId(session.id, shareId);
80
+ deps.out(copied ? `share link copied - ${line}` : line);
81
+ return 0;
82
+ }
83
+
84
+ /**
85
+ * Pull the share's current session and union its annotations into the local
86
+ * plan. Union-by-id keeps the planner's own notes; only collaborators' new
87
+ * ones land. The gateway lets only the fingerprint that shared it pull.
88
+ */
89
+ export async function pullSession(
90
+ client: SessionClient,
91
+ params: ShareParams,
92
+ deps: PullDeps,
93
+ ): Promise<number> {
94
+ const session = await pickSharedSession(client, params.sessionId);
95
+ if (!session) {
96
+ deps.out("no shared plan to pull - run `cueloop share` first");
97
+ return 1;
98
+ }
99
+ const remote = await deps.pull(session.shareId!, { host: params.host, port: params.port });
100
+ const before = session.annotations.length;
101
+ const merged = await client.sessionMergeShared(session.id, {
102
+ annotations: collaboratorAnnotations(remote),
103
+ participants: remote.participants,
104
+ });
105
+ const added = merged.annotations.length - before;
106
+ deps.out(
107
+ added > 0 ? `pulled ${added} new annotation${added === 1 ? "" : "s"}` : "no new annotations",
108
+ );
109
+ return 0;
110
+ }
111
+
112
+ /** The named session, or the most recent one when no id is given. */
113
+ async function pickSession(
114
+ client: SessionClient,
115
+ sessionId?: string,
116
+ ): Promise<ReviewSession | null> {
117
+ if (sessionId) return client.sessionGet(sessionId);
118
+ const sessions = await client.sessionList();
119
+ return sessions.at(-1) ?? null;
120
+ }
121
+
122
+ /** The named session (if shared), or the most recent shared one. */
123
+ async function pickSharedSession(
124
+ client: SessionClient,
125
+ sessionId?: string,
126
+ ): Promise<ReviewSession | null> {
127
+ if (sessionId) {
128
+ const session = await client.sessionGet(sessionId);
129
+ return session.shareId ? session : null;
130
+ }
131
+ const sessions = await client.sessionList();
132
+ return sessions.filter((session) => session.shareId).at(-1) ?? null;
133
+ }
package/src/version.ts ADDED
@@ -0,0 +1,5 @@
1
+ /** The published CLI version, read from the package manifest at build time. */
2
+
3
+ import manifest from "../package.json" with { type: "json" };
4
+
5
+ export const CLI_VERSION: string = manifest.version;