@levistudio/redline 0.3.0 → 0.4.1

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/src/cli.ts CHANGED
@@ -45,24 +45,25 @@ preflightDependencies();
45
45
  // Dynamic imports so preflight runs before module resolution pulls in third-party deps.
46
46
  const { createServer } = await import("./server");
47
47
  const { resolve } = await import("./resolve");
48
+ const {
49
+ getAgentProvider,
50
+ invalidProviderMessage,
51
+ parseAgentProviderId,
52
+ resolveProviderId,
53
+ } = await import("./agentProvider");
48
54
 
49
- // Verify the `claude` CLI is reachable before we start, so first-run users
50
- // without Claude Code installed get a clear message instead of a silent agent
51
- // crash later (the agent process shells out to `claude -p`; without it, replies
52
- // fail and errors land in `.review/errors.log` where nobody looks).
53
- //
54
- // Resolution mirrors the runtime: prefer CLAUDE_CODE_EXECPATH (used by tests
55
- // and advanced setups), then look for `claude` on PATH.
56
- function preflightClaudeCli() {
57
- const exec = process.env.CLAUDE_CODE_EXECPATH;
58
- if (exec && existsSync(exec)) return;
59
- if (Bun.which("claude")) return;
60
- console.error(
61
- "\n[redline] Could not find the `claude` CLI on PATH.\n" +
62
- "Redline shells out to Claude Code for agent replies and revisions.\n" +
63
- "Install it from https://claude.com/claude-code and re-run.\n"
64
- );
65
- process.exit(1);
55
+ function argValue(args: string[], flag: string): string | undefined {
56
+ const idx = args.indexOf(flag);
57
+ return idx !== -1 ? args[idx + 1] : undefined;
58
+ }
59
+
60
+ function selectProvider(args: string[]) {
61
+ const raw = argValue(args, "--agent") ?? process.env.REDLINE_AGENT;
62
+ if (raw && !parseAgentProviderId(raw)) {
63
+ console.error(invalidProviderMessage(raw));
64
+ process.exit(1);
65
+ }
66
+ return getAgentProvider(resolveProviderId(raw));
66
67
  }
67
68
 
68
69
  // Walk up from `start` looking for a git root (a `.git` directory or file —
@@ -100,11 +101,33 @@ function maybePrintGitignoreHint(filePath: string) {
100
101
 
101
102
  const args = process.argv.slice(2);
102
103
 
104
+ if (args[0] === "--version" || args[0] === "-v") {
105
+ const pkgPath = path.resolve(import.meta.dir, "..", "package.json");
106
+ try {
107
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { version?: string };
108
+ console.log(pkg.version ?? "unknown");
109
+ } catch {
110
+ console.log("unknown");
111
+ }
112
+ process.exit(0);
113
+ }
114
+
115
+ // redline install-skill [--agent claude|codex|both]
116
+ if (args[0] === "install-skill") {
117
+ const script = path.resolve(import.meta.dir, "..", "scripts", "install-skill.sh");
118
+ if (!existsSync(script)) {
119
+ console.error(`Install script not found: ${script}`);
120
+ process.exit(1);
121
+ }
122
+ const result = spawnSync("bash", [script, ...args.slice(1)], { stdio: "inherit" });
123
+ process.exit(result.status ?? 1);
124
+ }
125
+
103
126
  // redline resolve <file> [--model <id>]
104
127
  if (args[0] === "resolve") {
105
128
  const filePath = args[1];
106
129
  if (!filePath) {
107
- console.error("Usage: redline resolve <file.md> [--model <model-id>]");
130
+ console.error("Usage: redline resolve <file.md> [--model <model-id>] [--agent claude|codex]");
108
131
  process.exit(1);
109
132
  }
110
133
  const resolved = path.resolve(filePath);
@@ -112,15 +135,20 @@ if (args[0] === "resolve") {
112
135
  console.error(`File not found: ${resolved}`);
113
136
  process.exit(1);
114
137
  }
115
- const modelFlag = args.indexOf("--model");
116
- const model = modelFlag !== -1 ? args[modelFlag + 1] : undefined;
117
- preflightClaudeCli();
118
- resolve(resolved, { model });
138
+ const model = argValue(args, "--model");
139
+ const provider = selectProvider(args);
140
+ try {
141
+ provider.preflight();
142
+ } catch (e) {
143
+ console.error(e instanceof Error ? e.message : String(e));
144
+ process.exit(1);
145
+ }
146
+ resolve(resolved, { model, agentProvider: provider.id });
119
147
  } else {
120
148
  // redline <file> — open review reader
121
149
  const filePath = args[0];
122
150
  if (!filePath) {
123
- console.error("Usage: redline <file.md>");
151
+ console.error("Usage: redline <file.md>\n redline resolve <file.md> [--model <model-id>] [--agent claude|codex]\n redline install-skill [--agent claude|codex|both]");
124
152
  process.exit(1);
125
153
  }
126
154
  const resolved = path.resolve(filePath);
@@ -129,14 +157,21 @@ if (args[0] === "resolve") {
129
157
  process.exit(1);
130
158
  }
131
159
  const noAgent = args.includes("--no-agent");
160
+ const provider = selectProvider(args);
132
161
 
133
162
  // Manual annotation mode skips both the preflight and the agent spawn —
134
- // the user just wants inline comments without a Claude conversation, so
135
- // requiring claude on PATH would be a hostile gate.
136
- if (!noAgent) preflightClaudeCli();
163
+ // the user just wants inline comments without an agent conversation, so
164
+ // requiring a provider CLI on PATH would be a hostile gate.
165
+ if (!noAgent) {
166
+ try {
167
+ provider.preflight();
168
+ } catch (e) {
169
+ console.error(e instanceof Error ? e.message : String(e));
170
+ process.exit(1);
171
+ }
172
+ }
137
173
 
138
- const contextFlag = args.indexOf("--context");
139
- const context = contextFlag !== -1 ? args[contextFlag + 1] : undefined;
174
+ const context = argValue(args, "--context");
140
175
  const autoOpen = args.includes("--open");
141
176
 
142
177
  const resultFile = path.join(path.dirname(resolved), ".review", path.basename(resolved) + ".result");
@@ -164,7 +199,7 @@ if (args[0] === "resolve") {
164
199
  // caller can pin the token if it needs to.
165
200
  const csrfToken = process.env.REDLINE_TOKEN ?? crypto.randomUUID();
166
201
 
167
- const app = createServer(resolved, { context, csrfToken, noAgent });
202
+ const app = createServer(resolved, { context, csrfToken, noAgent, agentName: provider.displayName });
168
203
  const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: app.fetch, idleTimeout: 0 });
169
204
  const url = `http://localhost:${server.port}`;
170
205
 
@@ -182,6 +217,7 @@ if (args[0] === "resolve") {
182
217
  started_at: new Date().toISOString(),
183
218
  pid: process.pid,
184
219
  csrf_token: csrfToken,
220
+ agent_provider: provider.id,
185
221
  }, null, 2));
186
222
  } catch (e) {
187
223
  console.error("[redline] Failed to write startup file:", e);
@@ -194,14 +230,14 @@ if (args[0] === "resolve") {
194
230
  console.log(` URL: ${url}`);
195
231
  console.log(` Result: ${resultFile}`);
196
232
  console.log(`${bar}`);
197
- if (noAgent) console.log(` Mode: manual annotation (--no-agent — no Claude replies, no revision pass)`);
233
+ if (noAgent) console.log(` Mode: manual annotation (--no-agent — no ${provider.displayName} replies, no revision pass)`);
198
234
  if (!autoOpen) console.log(`\n → cmd-click the URL when you're ready to review\n`);
199
235
  else console.log("");
200
236
 
201
237
  maybePrintGitignoreHint(resolved);
202
238
 
203
239
  // Auto-restart the agent if it dies unexpectedly (harness reaping, OOM,
204
- // a transient claude-CLI auth blip, etc). Capped to MAX_RESTARTS within
240
+ // a transient provider-CLI auth blip, etc). Capped to MAX_RESTARTS within
205
241
  // RESTART_WINDOW_MS so a permanently-broken environment doesn't loop forever.
206
242
  const RESTART_WINDOW_MS = 60_000;
207
243
  // Cap is overrideable via env so integration tests can exercise the
@@ -216,7 +252,7 @@ if (args[0] === "resolve") {
216
252
  [process.execPath, "run", path.join(import.meta.dir, "agent.ts"), resolved],
217
253
  {
218
254
  stdout: "inherit", stderr: "inherit", stdin: "ignore",
219
- env: { ...process.env, REDLINE_PORT: String(server.port), REDLINE_TOKEN: csrfToken },
255
+ env: { ...process.env, REDLINE_PORT: String(server.port), REDLINE_TOKEN: csrfToken, REDLINE_AGENT: provider.id },
220
256
  }
221
257
  );
222
258
  agentProc = proc;
package/src/client/sse.ts CHANGED
@@ -192,7 +192,7 @@ export function initSSE(): void {
192
192
  });
193
193
  on("finished", () => {
194
194
  document.body.innerHTML =
195
- '<div style="display:flex;align-items:center;justify-content:center;height:100vh;font-family:system-ui;flex-direction:column;gap:16px;color:#374151"><div style="font-size:48px">\u2713</div><div style="font-size:20px;font-weight:600">Review complete</div><div style="color:#6b7280">You can close this tab and continue in Claude Code.</div></div>';
195
+ '<div style="display:flex;align-items:center;justify-content:center;height:100vh;font-family:system-ui;flex-direction:column;gap:16px;color:#374151"><div style="font-size:48px">\u2713</div><div style="font-size:20px;font-weight:600">Review complete</div><div style="color:#6b7280">You can close this tab and continue in your agent environment.</div></div>';
196
196
  });
197
197
  es.onerror = () => {
198
198
  es.close();
package/src/pickModel.ts CHANGED
@@ -1,12 +1,14 @@
1
- // Heuristic for picking which Claude model to use based on the human's message.
2
- // Short / simple Haiku. Long, question, or "involved" keyword → Sonnet.
1
+ // Heuristic for picking how much model capacity to use based on the human's
2
+ // message. Providers map these tiers to concrete model ids.
3
3
  //
4
4
  // Used in two places:
5
5
  // - agent.ts picks per-reply, looking at the last human message in the thread
6
6
  // - resolve.ts picks per-revision, scanning every human message across settled comments
7
7
 
8
- export const FAST_MODEL = "claude-haiku-4-5-20251001";
9
- export const SMART_MODEL = "claude-sonnet-4-6";
8
+ export type ModelTier = "fast" | "smart";
9
+
10
+ export const FAST_MODEL: ModelTier = "fast";
11
+ export const SMART_MODEL: ModelTier = "smart";
10
12
 
11
13
  export const REPLY_INVOLVED_PATTERNS =
12
14
  /\b(what|why|how|which|who|suggest|suggestion|alternative|option|idea|propose|explain|think|consider|recommend|help|could|would|should)\b/i;
@@ -1,5 +1,5 @@
1
1
  // Per-prompt envelope around user-controlled strings before they reach
2
- // `claude -p`. Same delimiter-over-JSON principle the agent reply path
2
+ // the selected local agent provider. Same delimiter-over-JSON principle the agent reply path
3
3
  // already uses (see retro entry 2026-05-07 — "delimiter envelope for agent
4
4
  // replies"), applied to the *input* side: comment text, document body,
5
5
  // thread messages.
package/src/resolve.ts CHANGED
@@ -5,11 +5,13 @@ import type { Round, Comment } from "./sidecar";
5
5
  import { pickRevisionModel } from "./pickModel";
6
6
  import { newEnvelope } from "./promptEnvelope";
7
7
  import { contextBlock } from "./contextBlock";
8
+ import { getAgentProvider, resolveProviderId, type AgentProviderId } from "./agentProvider";
8
9
 
9
10
  const serverBase = () => `http://localhost:${process.env.REDLINE_PORT ?? "3000"}`;
10
11
  const csrfHeader = (): Record<string, string> => ({ "X-Redline-Token": process.env.REDLINE_TOKEN ?? "" });
11
12
 
12
- export async function resolve(filePath: string, options: { model?: string } = {}) {
13
+ export async function resolve(filePath: string, options: { model?: string; agentProvider?: AgentProviderId } = {}) {
14
+ const provider = getAgentProvider(options.agentProvider ?? resolveProviderId());
13
15
  const model = options.model ?? null;
14
16
  const sidecar = await loadSidecar(filePath);
15
17
 
@@ -20,7 +22,7 @@ export async function resolve(filePath: string, options: { model?: string } = {}
20
22
  }
21
23
  const round: Round = resolvedRounds[resolvedRounds.length - 1];
22
24
  const settled = round.comments.filter((c) => c.resolved);
23
- const chosenModel = model ?? pickRevisionModel(settled);
25
+ const chosenModel = model ?? provider.modelForTier(pickRevisionModel(settled));
24
26
 
25
27
  const docText = await readFile(filePath, "utf-8");
26
28
 
@@ -89,9 +91,6 @@ export async function resolve(filePath: string, options: { model?: string } = {}
89
91
  contextBlock(sidecar.context, env) +
90
92
  `<comments-to-apply>\n${commentsBlock}\n</comments-to-apply>${priorChangesBlock}\n\n<document>\n${env.wrap("document", docText)}\n</document>`;
91
93
 
92
- // Call the claude CLI (inherits auth from the user's Claude Code session — no API key needed)
93
- const cliBin = process.env.CLAUDE_CODE_EXECPATH ?? "claude";
94
-
95
94
  const broadcastChunk = (text: string, kind: "thinking" | "text") => {
96
95
  fetch(`${serverBase()}/api/revision-chunk`, {
97
96
  method: "POST",
@@ -109,7 +108,7 @@ export async function resolve(filePath: string, options: { model?: string } = {}
109
108
  const fail = async (reason: string) => {
110
109
  await logRevisionFailure(filePath, {
111
110
  reason,
112
- model: chosenModel,
111
+ model: `${provider.id}/${chosenModel}`,
113
112
  exitCode,
114
113
  stderr: stderrText.trim(),
115
114
  stdoutSample: revised.slice(0, 2000),
@@ -128,72 +127,27 @@ export async function resolve(filePath: string, options: { model?: string } = {}
128
127
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
129
128
  console.log(
130
129
  attempt === 1
131
- ? `Revising with ${chosenModel}...\n`
132
- : `\nRetrying revision with ${chosenModel} (attempt ${attempt}/${MAX_ATTEMPTS})...\n`
130
+ ? `Revising with ${provider.id}/${chosenModel}...\n`
131
+ : `\nRetrying revision with ${provider.id}/${chosenModel} (attempt ${attempt}/${MAX_ATTEMPTS})...\n`
133
132
  );
134
133
  console.log("─".repeat(60));
135
- const revisionStartedAt = Date.now();
136
-
137
- const proc = Bun.spawn(
138
- [cliBin, "-p", "--system-prompt", systemPrompt, "--model", chosenModel,
139
- "--output-format", "stream-json", "--include-partial-messages", "--verbose"],
140
- { stdin: "pipe", stdout: "pipe", stderr: "pipe" }
141
- );
142
-
143
- proc.stdin.write(userMessage);
144
- proc.stdin.end();
145
-
146
- // Drain stderr concurrently so we can include it in any error report.
147
- stderrText = "";
148
- const stderrDone = (async () => {
149
- const r = proc.stderr.getReader();
150
- const dec = new TextDecoder();
151
- while (true) {
152
- const { done, value } = await r.read();
153
- if (done) break;
154
- const chunk = dec.decode(value);
155
- stderrText += chunk;
156
- process.stderr.write(chunk);
157
- }
158
- })();
159
-
160
- revised = "";
161
- let buffer = "";
162
- const reader = proc.stdout.getReader();
163
- while (true) {
164
- const { done, value } = await reader.read();
165
- if (done) break;
166
- buffer += new TextDecoder().decode(value);
167
- const lines = buffer.split("\n");
168
- buffer = lines.pop() ?? "";
169
- for (const line of lines) {
170
- if (!line.trim()) continue;
171
- try {
172
- const obj = JSON.parse(line);
173
- if (obj.type === "stream_event" && obj.event?.type === "content_block_delta") {
174
- const delta = obj.event.delta;
175
- if (delta?.type === "text_delta" && delta.text) {
176
- revised += delta.text;
177
- process.stdout.write(delta.text);
178
- broadcastChunk(delta.text, "text");
179
- } else if (delta?.type === "thinking_delta" && delta.thinking) {
180
- broadcastChunk(delta.thinking, "thinking");
181
- }
182
- }
183
- } catch { /* malformed JSON line, skip */ }
184
- }
185
- }
186
-
187
- exitCode = await proc.exited;
188
- await stderrDone;
189
- const revisionDurationMs = Date.now() - revisionStartedAt;
134
+ const run = await provider.runRevision({
135
+ systemPrompt,
136
+ userMessage,
137
+ model: chosenModel,
138
+ cwd: process.cwd(),
139
+ onChunk: broadcastChunk,
140
+ });
141
+ revised = run.revised;
142
+ stderrText = run.stderr;
143
+ exitCode = run.exitCode;
190
144
  console.log("\n" + "─".repeat(60));
191
- console.log(`Model: ${chosenModel} · Duration: ${(revisionDurationMs / 1000).toFixed(1)}s · Exit: ${exitCode}`);
145
+ console.log(`Model: ${provider.id}/${chosenModel} · Duration: ${(run.durationMs / 1000).toFixed(1)}s · Exit: ${exitCode}`);
192
146
  console.log("─".repeat(60) + "\n");
193
147
 
194
148
  // A CLI crash is not retryable — fail immediately.
195
149
  if (exitCode !== 0) {
196
- await fail(`claude CLI exited with code ${exitCode}${stderrText.trim() ? ` — ${stderrText.trim().split("\n").slice(-3).join(" | ")}` : ""}`);
150
+ await fail(`${provider.id} CLI exited with code ${exitCode}${stderrText.trim() ? ` — ${stderrText.trim().split("\n").slice(-3).join(" | ")}` : ""}`);
197
151
  }
198
152
 
199
153
  const result = validateRevision(revised, docText, settled);
@@ -17,7 +17,8 @@ function pageTemplate(
17
17
  context?: string,
18
18
  readOnly = false,
19
19
  csrfToken = "",
20
- noAgent = false
20
+ noAgent = false,
21
+ agentName = "selected local"
21
22
  ): string {
22
23
  const commentsJson = JSON.stringify(comments);
23
24
 
@@ -51,7 +52,7 @@ function pageTemplate(
51
52
  </span>
52
53
  <div class="header-actions">
53
54
  <span id="agent-status" class="agent-status" hidden></span>
54
- ${noAgent ? `<span class="manual-mode-pill" title="Started with --no-agent. No Claude replies, no revision pass.">Manual mode</span>` : ''}
55
+ ${noAgent ? `<span class="manual-mode-pill" title="Started with --no-agent. No ${escapeHtml(agentName)} replies, no revision pass.">Manual mode</span>` : ''}
55
56
  ${!readOnly && totalRounds > 1 ? `<button class="btn-toggle-diff" id="btn-toggle-diff" type="button" aria-pressed="false">Show changes</button>` : ''}
56
57
  ${readOnly
57
58
  ? `<span style="font-size:13px;color:var(--text-muted);font-style:italic">Read-only — <a href="/" style="color:var(--accent)">back to current</a></span>`
@@ -65,7 +66,7 @@ function pageTemplate(
65
66
  </div>` : ''}
66
67
  ${!readOnly ? `<div class="first-run-banner" id="first-run-banner" hidden>
67
68
  <span class="first-run-icon" aria-hidden="true">⚠</span>
68
- <span class="first-run-text">Redline sends document and comment text to your local Claude Code agent. Use trusted docs.</span>
69
+ <span class="first-run-text">Redline sends document and comment text to your ${escapeHtml(agentName)} agent. Use trusted docs.</span>
69
70
  <button class="first-run-dismiss" id="first-run-dismiss" aria-label="Dismiss">Got it</button>
70
71
  </div>` : ''}
71
72
  <article class="prose" id="prose">
@@ -97,7 +98,7 @@ function pageTemplate(
97
98
  </div>
98
99
  </div>
99
100
  <div id="error-banner" style="display:none;position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#b71c1c;color:white;padding:12px 24px;border-radius:6px;font-size:14px;font-weight:500;box-shadow:0 1px 4px rgba(0,0,0,0.08);z-index:999;white-space:nowrap;"></div>
100
- <div id="session-ended-banner" style="display:none;position:fixed;top:0;left:0;right:0;background:#92400e;color:white;padding:10px 24px;font-size:14px;font-weight:500;text-align:center;z-index:1000;box-shadow:0 1px 4px rgba(0,0,0,0.15);">Review session ended — the redline server is no longer running. Your changes up to this point are saved; close this tab and continue in Claude Code.</div>
101
+ <div id="session-ended-banner" style="display:none;position:fixed;top:0;left:0;right:0;background:#92400e;color:white;padding:10px 24px;font-size:14px;font-weight:500;text-align:center;z-index:1000;box-shadow:0 1px 4px rgba(0,0,0,0.15);">Review session ended — the redline server is no longer running. Your changes up to this point are saved; close this tab and continue in your agent environment.</div>
101
102
 
102
103
  <script>
103
104
  window.__REDLINE__ = {
package/src/server.ts CHANGED
@@ -50,7 +50,7 @@ function getClientBundle(): Promise<string> {
50
50
 
51
51
  export function createServer(
52
52
  filePath: string,
53
- opts: { context?: string; csrfToken?: string; noAgent?: boolean } = {}
53
+ opts: { context?: string; csrfToken?: string; noAgent?: boolean; agentName?: string } = {}
54
54
  ) {
55
55
  const app = new Hono();
56
56
  const fileName = path.basename(filePath);
@@ -275,7 +275,7 @@ export function createServer(
275
275
  const agentRepliedAt = latestRound?.agent_replied_at ?? null;
276
276
  const roundNumber = latestRound?.round ?? 1;
277
277
  const totalRounds = sidecar.rounds.length;
278
- return c.html(pageTemplate(fileName, html, serializeCommentsForClient(comments), roundResolved, agentRepliedAt, roundNumber, totalRounds, sidecar.context, false, csrfToken, opts.noAgent ?? false));
278
+ return c.html(pageTemplate(fileName, html, serializeCommentsForClient(comments), roundResolved, agentRepliedAt, roundNumber, totalRounds, sidecar.context, false, csrfToken, opts.noAgent ?? false, opts.agentName));
279
279
  });
280
280
 
281
281
  // Add a comment to the active round
@@ -430,7 +430,7 @@ export function createServer(
430
430
  });
431
431
 
432
432
  // CLI signals the agent subprocess is gone for good (restart cap exhausted,
433
- // missing claude CLI, etc). Surfaces a small persistent indicator in the
433
+ // missing provider CLI, etc). Surfaces a small persistent indicator in the
434
434
  // header so the user knows replies aren't coming and can restart redline.
435
435
  // No paired "agent-available" event — recovery requires a restart, so the
436
436
  // indicator stays until the page reloads.
@@ -588,7 +588,8 @@ export function createServer(
588
588
  sidecar.context,
589
589
  true, // readOnly
590
590
  csrfToken,
591
- opts.noAgent ?? false
591
+ opts.noAgent ?? false,
592
+ opts.agentName
592
593
  ));
593
594
  });
594
595
 
@@ -681,5 +682,3 @@ export function createServer(
681
682
  onRevisionRecovered(cb: () => void) { onRevisionRecoveredCallback = cb; },
682
683
  };
683
684
  }
684
-
685
-