cueloop 0.1.0-alpha.67 → 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.67",
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.67",
34
- "@cueloop/client": "0.1.0-alpha.67",
35
- "@cueloop/daemon": "0.1.0-alpha.67",
36
- "@cueloop/schema": "0.1.0-alpha.67",
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,6 +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 (rest) => (await import("./update-command")).updateCommand(rest),
117
118
  review: (rest) => reviewEntry(rest),
118
119
  "review-post": async (rest) => (await import("./pr")).reviewPostCommand(rest),
119
120
  dev: async () => (await import("./dev-command")).devCommand(),
@@ -202,26 +203,30 @@ async function replyCommand(argv: string[]): Promise<number> {
202
203
  }
203
204
 
204
205
  /**
205
- * `cueloop prototype <file.html>` creates a review of a rendered HTML file;
206
- * a selector or `--open`/`--latest` (or a bare call with no file) opens the
207
- * 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.
208
210
  */
209
211
  async function prototypeCommand(argv: string[]): Promise<number> {
210
212
  const parsed = parseArgs(argv);
211
213
  const selector = openSelector(parsed);
212
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;
213
218
  const looksLikeFile =
214
- selector !== undefined && !isSessionId(selector) && selector.endsWith(".html");
219
+ selector !== undefined && !isSessionId(selector) && (isHtmlPrototype || isMarkdownPrototype);
215
220
 
216
221
  if (wantsOpen || !looksLikeFile)
217
222
  return openReviewOfKind(isPrototypeReview, "prototype", selector);
218
223
 
219
224
  const path = resolve(selector);
220
- const html = await Bun.file(path)
225
+ const content = await Bun.file(path)
221
226
  .text()
222
227
  .catch(() => undefined);
223
228
 
224
- if (html === undefined) {
229
+ if (content === undefined) {
225
230
  console.error(`prototype: cannot read ${path}`);
226
231
 
227
232
  return 1;
@@ -229,8 +234,9 @@ async function prototypeCommand(argv: string[]): Promise<number> {
229
234
  const client = await DaemonClient.connect({ autostart: true });
230
235
  const review = await openReview(client, {
231
236
  type: "prototype",
232
- content: html,
233
- prototypePath: path,
237
+ content,
238
+ // only an HTML entry carries a prototypePath, the signal for the experimental pixel mode
239
+ prototypePath: isHtmlPrototype ? path : undefined,
234
240
  title: basename(path),
235
241
  });
236
242
 
@@ -325,7 +331,7 @@ function printHelp(): void {
325
331
  " cueloop diff [id|title] review your working tree (untracked files included);",
326
332
  " with a clean tree, open the latest pending diff review",
327
333
  " cueloop review <pr> review a pull request (--no-tui prints the session)",
328
- " 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)",
329
335
  "",
330
336
  "share:",
331
337
  " cueloop serve [session-id] share over ssh: observers are read-only,",
@@ -344,6 +350,7 @@ function printHelp(): void {
344
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)",
345
351
  " cueloop actions list list the quick-action vocabulary (for annotate --action)",
346
352
  " cueloop refine mine past reviews into a markdown report + writeback proposals",
353
+ " cueloop update [--dry-run] update the installed cueloop binary (--dry-run reports the target only)",
347
354
  " cueloop wake <id> [--harness codex --thread <id>] resume the agent with the verdict (spawn detached)",
348
355
  " cueloop review-post <id> <pr> post a resolved session's verdict back to the PR",
349
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
+ });
@@ -0,0 +1,177 @@
1
+ import { basename, dirname, isAbsolute, resolve } from "node:path";
2
+ import * as v from "valibot";
3
+ import { CLI_VERSION } from "./version";
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
+
9
+ const InstallerScriptSchema = v.pipe(
10
+ v.string(),
11
+ v.minLength(1),
12
+ v.check(
13
+ (script) => script.includes("CUELOOP_INSTALL_DIR"),
14
+ "installer is not a cueloop installer",
15
+ ),
16
+ );
17
+
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;
22
+
23
+ return `${env.HOME}/.local/bin`;
24
+ }
25
+
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;
35
+
36
+ const dir = dirname(isAbsolute(execPath) ? execPath : resolve(execPath));
37
+
38
+ if (dir === "/$bunfs" || dir.startsWith("/$bunfs/")) return undefined;
39
+
40
+ return dir;
41
+ }
42
+
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);
46
+ }
47
+
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());
55
+
56
+ if (!parsed.success) return undefined;
57
+
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;
67
+ }
68
+ }
69
+
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;
76
+ }
77
+ }
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> {
81
+ try {
82
+ const response = await fetch(INSTALL_URL);
83
+
84
+ if (!response.ok) {
85
+ console.error(`cueloop update: failed to fetch installer (${response.status})`);
86
+
87
+ return 1;
88
+ }
89
+ const parsed = v.safeParse(InstallerScriptSchema, await response.text());
90
+
91
+ if (!parsed.success) {
92
+ console.error("cueloop update: fetched installer failed validation");
93
+
94
+ return 1;
95
+ }
96
+ const child = Bun.spawn(["sh"], {
97
+ stdin: "pipe",
98
+ stdout: "inherit",
99
+ stderr: "inherit",
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" },
102
+ });
103
+
104
+ child.stdin.write(parsed.output);
105
+ child.stdin.end();
106
+
107
+ return child.exited;
108
+ } catch (error) {
109
+ console.error(`cueloop update: ${error instanceof Error ? error.message : String(error)}`);
110
+
111
+ return 1;
112
+ }
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
+ }