cueloop 0.1.0-alpha.68 → 0.1.0-alpha.69

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.68",
3
+ "version": "0.1.0-alpha.69",
4
4
  "description": "Terminal review surface for coding agents: annotate plans and diffs, return a verdict, send structured feedback back",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -30,10 +30,10 @@
30
30
  "build:binary": "bun build --compile --external cpu-features ./src/main.ts --outfile dist/cueloop"
31
31
  },
32
32
  "dependencies": {
33
- "@cueloop/adapters": "0.1.0-alpha.68",
34
- "@cueloop/client": "0.1.0-alpha.68",
35
- "@cueloop/daemon": "0.1.0-alpha.68",
36
- "@cueloop/schema": "0.1.0-alpha.68",
33
+ "@cueloop/adapters": "0.1.0-alpha.69",
34
+ "@cueloop/client": "0.1.0-alpha.69",
35
+ "@cueloop/daemon": "0.1.0-alpha.69",
36
+ "@cueloop/schema": "0.1.0-alpha.69",
37
37
  "valibot": "^1.4.2"
38
38
  },
39
39
  "engines": {
package/src/main.ts CHANGED
@@ -114,7 +114,7 @@ const commandHandlers: CommandHandlers = {
114
114
  wake: async (rest) => (await import("./wake-command")).wakeCommand(rest),
115
115
  actions: async (rest) => (await import("./actions-command")).actionsCommand(rest),
116
116
  refine: async (rest) => (await import("./refine-command")).refineCommand(rest),
117
- update: async () => (await import("./update-command")).updateCommand(),
117
+ update: async (rest) => (await import("./update-command")).updateCommand(rest),
118
118
  review: (rest) => reviewEntry(rest),
119
119
  "review-post": async (rest) => (await import("./pr")).reviewPostCommand(rest),
120
120
  dev: async () => (await import("./dev-command")).devCommand(),
@@ -203,26 +203,30 @@ async function replyCommand(argv: string[]): Promise<number> {
203
203
  }
204
204
 
205
205
  /**
206
- * `cueloop prototype <file.html>` creates a review of a rendered HTML file;
207
- * a selector or `--open`/`--latest` (or a bare call with no file) opens the
208
- * latest pending prototype review instead.
206
+ * `cueloop prototype <file.md>` creates a review of a component design doc
207
+ * (API / Composition / Callstack); an `.html` file instead creates the opt-in
208
+ * experimental pixel mockup. A selector or `--open`/`--latest` (or a bare call
209
+ * with no file) opens the latest pending prototype review instead.
209
210
  */
210
211
  async function prototypeCommand(argv: string[]): Promise<number> {
211
212
  const parsed = parseArgs(argv);
212
213
  const selector = openSelector(parsed);
213
214
  const wantsOpen = "open" in parsed.flags || "latest" in parsed.flags;
215
+ // a markdown design doc is the default; an .html entry is the opt-in pixel mockup
216
+ const isHtmlPrototype = selector?.endsWith(".html") ?? false;
217
+ const isMarkdownPrototype = selector?.endsWith(".md") || selector?.endsWith(".markdown") || false;
214
218
  const looksLikeFile =
215
- selector !== undefined && !isSessionId(selector) && selector.endsWith(".html");
219
+ selector !== undefined && !isSessionId(selector) && (isHtmlPrototype || isMarkdownPrototype);
216
220
 
217
221
  if (wantsOpen || !looksLikeFile)
218
222
  return openReviewOfKind(isPrototypeReview, "prototype", selector);
219
223
 
220
224
  const path = resolve(selector);
221
- const html = await Bun.file(path)
225
+ const content = await Bun.file(path)
222
226
  .text()
223
227
  .catch(() => undefined);
224
228
 
225
- if (html === undefined) {
229
+ if (content === undefined) {
226
230
  console.error(`prototype: cannot read ${path}`);
227
231
 
228
232
  return 1;
@@ -230,8 +234,9 @@ async function prototypeCommand(argv: string[]): Promise<number> {
230
234
  const client = await DaemonClient.connect({ autostart: true });
231
235
  const review = await openReview(client, {
232
236
  type: "prototype",
233
- content: html,
234
- prototypePath: path,
237
+ content,
238
+ // only an HTML entry carries a prototypePath, the signal for the experimental pixel mode
239
+ prototypePath: isHtmlPrototype ? path : undefined,
235
240
  title: basename(path),
236
241
  });
237
242
 
@@ -326,7 +331,7 @@ function printHelp(): void {
326
331
  " cueloop diff [id|title] review your working tree (untracked files included);",
327
332
  " with a clean tree, open the latest pending diff review",
328
333
  " cueloop review <pr> review a pull request (--no-tui prints the session)",
329
- " cueloop prototype <file.html> review a rendered HTML prototype (or open the latest by id/title)",
334
+ " cueloop prototype <file.md> review a component design doc (or open the latest by id/title)",
330
335
  "",
331
336
  "share:",
332
337
  " cueloop serve [session-id] share over ssh: observers are read-only,",
@@ -345,7 +350,7 @@ function printHelp(): void {
345
350
  " cueloop session <primitive> [flags] script the daemon (create|get|list|wait|annotate|remove|cut|restore|curate|set-viewed|navigate|branch|switch|label|fork|name-self|events|resolve|submit-revision)",
346
351
  " cueloop actions list list the quick-action vocabulary (for annotate --action)",
347
352
  " cueloop refine mine past reviews into a markdown report + writeback proposals",
348
- " cueloop update update the installed cueloop binary",
353
+ " cueloop update [--dry-run] update the installed cueloop binary (--dry-run reports the target only)",
349
354
  " cueloop wake <id> [--harness codex --thread <id>] resume the agent with the verdict (spawn detached)",
350
355
  " cueloop review-post <id> <pr> post a resolved session's verdict back to the PR",
351
356
  " cueloop daemon run the daemon in the foreground",
@@ -16,6 +16,7 @@ import { DaemonClient } from "@cueloop/daemon/client";
16
16
  import { openHerdrPaneForReview } from "@cueloop/daemon/herdr-pane";
17
17
  import { openReview, verdictResponse } from "@cueloop/daemon/review";
18
18
  import { loadConfig, quickActionBody, resolveQuickAction } from "@cueloop/client/config";
19
+ import { slashItemsFrom } from "@cueloop/client/slash-palette";
19
20
  import { parseArgs, stringFlag } from "./args";
20
21
 
21
22
  async function readStdin(): Promise<string> {
@@ -342,8 +343,15 @@ async function sessionResolveCommand({
342
343
  }: SessionContext): Promise<number> {
343
344
  const id = required(positional[1], "session id");
344
345
  const kind = v.parse(VerdictKindSchema, required(stringFlag(flags, "verdict"), "--verdict"));
346
+ // expand /name quick-action references in TUI-authored comments, so a CLI resolve sends the agent
347
+ // the action bodies too, not the bare references
348
+ const session = await client.sessionGet(id);
349
+ const actions = loadConfig({ repoRoot: session.workspace.repoRoot }).actions;
350
+ const actionBodies = Object.fromEntries(
351
+ slashItemsFrom(actions).map((item) => [item.name, item.body]),
352
+ );
345
353
 
346
- out(await client.sessionResolve(id, kind, stringFlag(flags, "summary") ?? ""));
354
+ out(await client.sessionResolve(id, kind, stringFlag(flags, "summary") ?? "", actionBodies));
347
355
 
348
356
  return 0;
349
357
  }
@@ -0,0 +1,163 @@
1
+ /** update install-dir resolution and the update flow over injected deps: never the Bun `/$bunfs` path, an up-to-date short-circuit, and a restart notice on success. */
2
+
3
+ import { describe, expect, mock, test } from "bun:test";
4
+ import { resolveInstallDir, runUpdate, type UpdateDeps } from "./update-command";
5
+
6
+ function depsSpy(overrides: Partial<UpdateDeps> = {}): UpdateDeps & { lines: string[] } {
7
+ const lines: string[] = [];
8
+
9
+ return {
10
+ currentVersion: "0.1.0-alpha.68",
11
+ installDir: () => "/Users/dev/.local/bin",
12
+ fetchLatestVersion: mock(async () => "0.1.0-alpha.68"),
13
+ runInstaller: mock(async () => 0),
14
+ out: (message: string) => void lines.push(message),
15
+ lines,
16
+ ...overrides,
17
+ };
18
+ }
19
+
20
+ describe("resolveInstallDir", () => {
21
+ test("rejects the Bun single-file virtual path and falls back to the per-user bin dir", () => {
22
+ // Arrange - a compiled binary that mistakenly reports its bunfs path
23
+ const env: NodeJS.ProcessEnv = { HOME: "/Users/dev" };
24
+
25
+ // Act
26
+ const dir = resolveInstallDir("/$bunfs/root/cueloop", env);
27
+
28
+ // Assert
29
+ expect(dir).toBe("/Users/dev/.local/bin");
30
+ });
31
+
32
+ test("uses the real on-disk directory of the invoked binary", () => {
33
+ // Arrange
34
+ const env: NodeJS.ProcessEnv = { HOME: "/Users/dev" };
35
+
36
+ // Act
37
+ const dir = resolveInstallDir("/usr/local/bin/cueloop", env);
38
+
39
+ // Assert
40
+ expect(dir).toBe("/usr/local/bin");
41
+ });
42
+
43
+ test("falls back to the per-user bin dir when not running as the compiled cueloop", () => {
44
+ // Arrange - execPath is the bun runtime, not cueloop
45
+ const env: NodeJS.ProcessEnv = { HOME: "/Users/dev" };
46
+
47
+ // Act
48
+ const dir = resolveInstallDir("/Users/dev/.bun/bin/bun", env);
49
+
50
+ // Assert
51
+ expect(dir).toBe("/Users/dev/.local/bin");
52
+ });
53
+
54
+ test("an explicit CUELOOP_INSTALL_DIR overrides everything", () => {
55
+ // Arrange
56
+ const env: NodeJS.ProcessEnv = { HOME: "/Users/dev", CUELOOP_INSTALL_DIR: "/opt/bin" };
57
+
58
+ // Act
59
+ const dir = resolveInstallDir("/$bunfs/root/cueloop", env);
60
+
61
+ // Assert
62
+ expect(dir).toBe("/opt/bin");
63
+ });
64
+ });
65
+
66
+ describe(runUpdate, () => {
67
+ test("--dry-run reports the target without any network or install work", async () => {
68
+ // Arrange
69
+ const deps = depsSpy();
70
+
71
+ // Act
72
+ const code = await runUpdate(deps, true);
73
+
74
+ // Assert
75
+ expect(code).toBe(0);
76
+ expect(deps.lines).toEqual([
77
+ "Current version: 0.1.0-alpha.68",
78
+ "cueloop would update in /Users/dev/.local/bin",
79
+ ]);
80
+ expect(deps.fetchLatestVersion).not.toHaveBeenCalled();
81
+ expect(deps.runInstaller).not.toHaveBeenCalled();
82
+ });
83
+
84
+ test("reports up to date and skips the installer when no newer version exists", async () => {
85
+ // Arrange - latest matches the current version
86
+ const deps = depsSpy({ fetchLatestVersion: mock(async () => "0.1.0-alpha.68") });
87
+
88
+ // Act
89
+ const code = await runUpdate(deps, false);
90
+
91
+ // Assert
92
+ expect(code).toBe(0);
93
+ expect(deps.lines).toContain("cueloop is up to date (0.1.0-alpha.68)");
94
+ expect(deps.runInstaller).not.toHaveBeenCalled();
95
+ });
96
+
97
+ test("does not downgrade when the newest release is older than the current build", async () => {
98
+ // Arrange - a dev build ahead of the published release
99
+ const deps = depsSpy({
100
+ currentVersion: "0.1.0-alpha.69",
101
+ fetchLatestVersion: mock(async () => "0.1.0-alpha.68"),
102
+ });
103
+
104
+ // Act
105
+ const code = await runUpdate(deps, false);
106
+
107
+ // Assert
108
+ expect(code).toBe(0);
109
+ expect(deps.runInstaller).not.toHaveBeenCalled();
110
+ });
111
+
112
+ test("runs the installer and prints a restart notice when a newer version exists", async () => {
113
+ // Arrange
114
+ const deps = depsSpy({ fetchLatestVersion: mock(async () => "0.1.0-alpha.99") });
115
+
116
+ // Act
117
+ const code = await runUpdate(deps, false);
118
+
119
+ // Assert
120
+ expect(code).toBe(0);
121
+ expect(deps.runInstaller).toHaveBeenCalledWith("/Users/dev/.local/bin");
122
+ expect(deps.lines).toContain("Update ran successfully! Please restart cueloop.");
123
+ });
124
+
125
+ test("proceeds with the update when the latest version cannot be determined", async () => {
126
+ // Arrange - releases API unreachable
127
+ const deps = depsSpy({ fetchLatestVersion: mock(async () => undefined) });
128
+
129
+ // Act
130
+ await runUpdate(deps, false);
131
+
132
+ // Assert
133
+ expect(deps.runInstaller).toHaveBeenCalledTimes(1);
134
+ });
135
+
136
+ test("does not print the restart notice when the installer fails", async () => {
137
+ // Arrange
138
+ const deps = depsSpy({
139
+ fetchLatestVersion: mock(async () => "0.1.0-alpha.99"),
140
+ runInstaller: mock(async () => 1),
141
+ });
142
+
143
+ // Act
144
+ const code = await runUpdate(deps, false);
145
+
146
+ // Assert
147
+ expect(code).toBe(1);
148
+ expect(deps.lines).not.toContain("Update ran successfully! Please restart cueloop.");
149
+ });
150
+
151
+ test("rejects a relative install dir before doing any work", async () => {
152
+ // Arrange
153
+ const deps = depsSpy({ installDir: () => "relative/dir" });
154
+
155
+ // Act
156
+ const code = await runUpdate(deps, false);
157
+
158
+ // Assert
159
+ expect(code).toBe(1);
160
+ expect(deps.lines[0]).toContain("must be an absolute path");
161
+ expect(deps.runInstaller).not.toHaveBeenCalled();
162
+ });
163
+ });
@@ -1,7 +1,11 @@
1
1
  import { basename, dirname, isAbsolute, resolve } from "node:path";
2
2
  import * as v from "valibot";
3
+ import { CLI_VERSION } from "./version";
3
4
 
4
5
  const INSTALL_URL = "https://cueloop.dev/install.sh";
6
+ const RELEASES_URL = "https://api.github.com/repos/mmurakaru/cueloop/releases?per_page=100";
7
+ const RELEASE_TAG_PREFIX = "cueloop@";
8
+
5
9
  const InstallerScriptSchema = v.pipe(
6
10
  v.string(),
7
11
  v.minLength(1),
@@ -11,44 +15,70 @@ const InstallerScriptSchema = v.pipe(
11
15
  ),
12
16
  );
13
17
 
14
- function userBinInstallDir(): string | undefined {
15
- if (process.env.HOME === undefined) return undefined;
18
+ const ReleasesSchema = v.array(v.object({ tag_name: v.nullish(v.string()) }));
19
+
20
+ function userBinInstallDir(env: NodeJS.ProcessEnv): string | undefined {
21
+ if (env.HOME === undefined) return undefined;
16
22
 
17
- return `${process.env.HOME}/.local/bin`;
23
+ return `${env.HOME}/.local/bin`;
18
24
  }
19
25
 
20
- function invokedBinaryInstallDir(): string | undefined {
21
- const invokedPath = process.argv[1];
22
- if (invokedPath === undefined || basename(invokedPath) !== "cueloop") return undefined;
26
+ /**
27
+ * The directory that holds the running binary, or undefined when we are not the
28
+ * compiled cueloop. Reads `execPath` (the real on-disk executable), not
29
+ * `argv[1]`: a Bun single-file executable reports its virtual filesystem path
30
+ * (`/$bunfs/root/cueloop`) in `argv[1]`, and installing there fails with a
31
+ * read-only filesystem error. The `/$bunfs` guard is belt-and-braces.
32
+ */
33
+ function invokedBinaryInstallDir(execPath: string): string | undefined {
34
+ if (basename(execPath) !== "cueloop") return undefined;
23
35
 
24
- return dirname(isAbsolute(invokedPath) ? invokedPath : resolve(invokedPath));
25
- }
36
+ const dir = dirname(isAbsolute(execPath) ? execPath : resolve(execPath));
26
37
 
27
- function defaultInstallDir(): string | undefined {
28
- return invokedBinaryInstallDir() ?? userBinInstallDir();
38
+ if (dir === "/$bunfs" || dir.startsWith("/$bunfs/")) return undefined;
39
+
40
+ return dir;
29
41
  }
30
42
 
31
- function installDir(): string | undefined {
32
- return process.env.CUELOOP_INSTALL_DIR || defaultInstallDir();
43
+ /** Where an update should land: an explicit override, else the binary's own directory, else the per-user bin dir. */
44
+ export function resolveInstallDir(execPath: string, env: NodeJS.ProcessEnv): string | undefined {
45
+ return env.CUELOOP_INSTALL_DIR || invokedBinaryInstallDir(execPath) || userBinInstallDir(env);
33
46
  }
34
47
 
35
- /** Re-run the published installer into the directory that holds this binary. */
36
- export async function updateCommand(): Promise<number> {
37
- const targetInstallDir = installDir();
48
+ /** The newest published cueloop version, or undefined when the releases API is unreachable or empty. */
49
+ async function fetchLatestVersion(): Promise<string | undefined> {
50
+ try {
51
+ const response = await fetch(RELEASES_URL, { headers: { "user-agent": "cueloop-update" } });
52
+
53
+ if (!response.ok) return undefined;
54
+ const parsed = v.safeParse(ReleasesSchema, await response.json());
38
55
 
39
- if (targetInstallDir === undefined) {
40
- console.error("cueloop update: HOME is not set and the current binary path is unknown");
56
+ if (!parsed.success) return undefined;
41
57
 
42
- return 1;
58
+ for (const release of parsed.output) {
59
+ if (release.tag_name?.startsWith(RELEASE_TAG_PREFIX)) {
60
+ return release.tag_name.slice(RELEASE_TAG_PREFIX.length);
61
+ }
62
+ }
63
+
64
+ return undefined;
65
+ } catch {
66
+ return undefined;
43
67
  }
44
- if (!targetInstallDir.startsWith("/")) {
45
- console.error("cueloop update: CUELOOP_INSTALL_DIR must be an absolute path");
68
+ }
46
69
 
47
- return 1;
70
+ /** True when `latest` is strictly newer than `current`; unparseable versions fall through to attempting the update. */
71
+ function isNewer(latest: string, current: string): boolean {
72
+ try {
73
+ return Bun.semver.order(latest, current) === 1;
74
+ } catch {
75
+ return true;
48
76
  }
77
+ }
49
78
 
79
+ /** Fetch the published installer and run it, targeting `targetInstallDir`; returns the installer's exit code. */
80
+ async function runInstaller(targetInstallDir: string): Promise<number> {
50
81
  try {
51
- console.error(`updating cueloop in ${targetInstallDir}...`);
52
82
  const response = await fetch(INSTALL_URL);
53
83
 
54
84
  if (!response.ok) {
@@ -67,7 +97,8 @@ export async function updateCommand(): Promise<number> {
67
97
  stdin: "pipe",
68
98
  stdout: "inherit",
69
99
  stderr: "inherit",
70
- env: { ...process.env, CUELOOP_INSTALL_DIR: targetInstallDir },
100
+ // an update replaces the binary in place; it never edits the user's shell rc
101
+ env: { ...process.env, CUELOOP_INSTALL_DIR: targetInstallDir, CUELOOP_NO_MODIFY_PATH: "1" },
71
102
  });
72
103
 
73
104
  child.stdin.write(parsed.output);
@@ -80,3 +111,67 @@ export async function updateCommand(): Promise<number> {
80
111
  return 1;
81
112
  }
82
113
  }
114
+
115
+ export interface UpdateDeps {
116
+ currentVersion: string;
117
+ installDir: () => string | undefined;
118
+ fetchLatestVersion: () => Promise<string | undefined>;
119
+ runInstaller: (targetInstallDir: string) => Promise<number>;
120
+ out: (message: string) => void;
121
+ }
122
+
123
+ /**
124
+ * The update flow, over injectable dependencies: resolve the target directory,
125
+ * report the current version, and either report that we are up to date or re-run
126
+ * the installer. `--dry-run` resolves and reports the target without any network
127
+ * work, which is how the compiled-binary path resolution gets tested.
128
+ */
129
+ export async function runUpdate(deps: UpdateDeps, dryRun: boolean): Promise<number> {
130
+ const targetInstallDir = deps.installDir();
131
+
132
+ if (targetInstallDir === undefined) {
133
+ deps.out("cueloop update: HOME is not set and the current binary path is unknown");
134
+
135
+ return 1;
136
+ }
137
+ if (!targetInstallDir.startsWith("/")) {
138
+ deps.out("cueloop update: CUELOOP_INSTALL_DIR must be an absolute path");
139
+
140
+ return 1;
141
+ }
142
+ deps.out(`Current version: ${deps.currentVersion}`);
143
+
144
+ if (dryRun) {
145
+ deps.out(`cueloop would update in ${targetInstallDir}`);
146
+
147
+ return 0;
148
+ }
149
+ deps.out("Checking for updates to latest version...");
150
+ const latest = await deps.fetchLatestVersion();
151
+
152
+ if (latest !== undefined && !isNewer(latest, deps.currentVersion)) {
153
+ deps.out(`cueloop is up to date (${deps.currentVersion})`);
154
+
155
+ return 0;
156
+ }
157
+ deps.out(`updating cueloop in ${targetInstallDir}...`);
158
+ const exitCode = await deps.runInstaller(targetInstallDir);
159
+
160
+ if (exitCode === 0) deps.out("Update ran successfully! Please restart cueloop.");
161
+
162
+ return exitCode;
163
+ }
164
+
165
+ /** `cueloop update [--dry-run]` - update the installed cueloop binary in place. */
166
+ export async function updateCommand(argv: string[] = []): Promise<number> {
167
+ return runUpdate(
168
+ {
169
+ currentVersion: CLI_VERSION,
170
+ installDir: () => resolveInstallDir(process.execPath, process.env),
171
+ fetchLatestVersion,
172
+ runInstaller,
173
+ out: (message) => console.error(message),
174
+ },
175
+ argv.includes("--dry-run"),
176
+ );
177
+ }