@levistudio/redline 0.2.0 → 0.4.0
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/AGENTS.md +227 -0
- package/CHANGELOG.md +22 -1
- package/CLAUDE.md +9 -0
- package/README.md +39 -21
- package/SECURITY.md +3 -3
- package/bin/redline.cjs +4 -1
- package/package.json +4 -1
- package/scripts/install-skill.sh +104 -39
- package/skills/redline-review/SKILL.md +9 -9
- package/src/agent.ts +19 -21
- package/src/agentProvider.ts +267 -0
- package/src/cli.ts +109 -36
- package/src/client/cards.ts +10 -1
- package/src/client/lib.ts +143 -78
- package/src/client/render.ts +17 -5
- package/src/client/selection.ts +1 -1
- package/src/client/sse.ts +34 -2
- package/src/client/state.ts +22 -0
- package/src/client/styles.css +27 -0
- package/src/parseReply.ts +9 -1
- package/src/pickModel.ts +6 -4
- package/src/promptEnvelope.ts +1 -1
- package/src/resolve.ts +134 -97
- package/src/reviewSummary.ts +93 -0
- package/src/server-page.ts +5 -3
- package/src/server.ts +50 -16
- package/src/sidecar.ts +5 -0
package/scripts/install-skill.sh
CHANGED
|
@@ -1,25 +1,66 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# Install the redline-review skill into
|
|
2
|
+
# Install the redline-review skill into a user's global agent skills directory
|
|
3
3
|
# so it's reachable from any project, not just this repo.
|
|
4
4
|
#
|
|
5
5
|
# This is a copy, not a symlink: the repo lives in paths that move (worktrees,
|
|
6
6
|
# renamed projects), and a stale symlink that vanishes is worse than a copy
|
|
7
7
|
# that needs an explicit refresh after pulling skill changes.
|
|
8
8
|
#
|
|
9
|
-
# We also generate a self-contained launcher next to the installed skill
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# session hit `env: bun: No such file or directory` because ~/.bun/bin isn't
|
|
15
|
-
# on $PATH in non-interactive Claude Bash subprocesses.)
|
|
9
|
+
# We also generate a self-contained launcher next to the installed skill.
|
|
10
|
+
# When invoked through the packaged `redline` binary, the launcher points back
|
|
11
|
+
# to that durable binary. When invoked from a checkout, it bakes in absolute
|
|
12
|
+
# paths to `bun` and `src/cli.ts`. Either way, the skill does not depend on
|
|
13
|
+
# `redline` being on PATH in non-interactive agent subprocesses.
|
|
16
14
|
|
|
17
15
|
set -euo pipefail
|
|
18
16
|
|
|
19
17
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
20
18
|
SRC="$REPO_ROOT/skills/redline-review"
|
|
21
|
-
DEST="${CLAUDE_HOME:-$HOME/.claude}/skills/redline-review"
|
|
22
19
|
PLACEHOLDER="__REDLINE_BIN__"
|
|
20
|
+
TARGET="both"
|
|
21
|
+
|
|
22
|
+
usage() {
|
|
23
|
+
cat >&2 <<'EOF'
|
|
24
|
+
Usage: redline install-skill [--agent claude|codex|both]
|
|
25
|
+
scripts/install-skill.sh [--agent claude|codex|both]
|
|
26
|
+
|
|
27
|
+
Installs the bundled redline-review skill into the selected agent environment:
|
|
28
|
+
claude -> $CLAUDE_HOME/skills/redline-review or ~/.claude/skills/redline-review
|
|
29
|
+
codex -> $CODEX_HOME/skills/redline-review or ~/.codex/skills/redline-review
|
|
30
|
+
both -> both locations (default)
|
|
31
|
+
EOF
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
while [ "$#" -gt 0 ]; do
|
|
35
|
+
case "$1" in
|
|
36
|
+
--agent)
|
|
37
|
+
TARGET="${2:-}"
|
|
38
|
+
shift 2
|
|
39
|
+
;;
|
|
40
|
+
--agent=*)
|
|
41
|
+
TARGET="${1#--agent=}"
|
|
42
|
+
shift
|
|
43
|
+
;;
|
|
44
|
+
-h|--help)
|
|
45
|
+
usage
|
|
46
|
+
exit 0
|
|
47
|
+
;;
|
|
48
|
+
*)
|
|
49
|
+
echo "error: unknown argument: $1" >&2
|
|
50
|
+
usage
|
|
51
|
+
exit 1
|
|
52
|
+
;;
|
|
53
|
+
esac
|
|
54
|
+
done
|
|
55
|
+
|
|
56
|
+
case "$TARGET" in
|
|
57
|
+
claude|codex|both) ;;
|
|
58
|
+
*)
|
|
59
|
+
echo "error: --agent must be claude, codex, or both" >&2
|
|
60
|
+
usage
|
|
61
|
+
exit 1
|
|
62
|
+
;;
|
|
63
|
+
esac
|
|
23
64
|
|
|
24
65
|
if [ ! -d "$SRC" ]; then
|
|
25
66
|
echo "error: skill source not found at $SRC" >&2
|
|
@@ -32,47 +73,71 @@ if [ ! -f "$CLI_ABS" ]; then
|
|
|
32
73
|
exit 1
|
|
33
74
|
fi
|
|
34
75
|
|
|
35
|
-
|
|
36
|
-
if
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
BUN_ABS="$HOME/.bun/bin/bun"
|
|
76
|
+
LAUNCH_CMD=""
|
|
77
|
+
if [ -n "${REDLINE_BIN_ABS:-}" ] && [ -f "$REDLINE_BIN_ABS" ]; then
|
|
78
|
+
LAUNCH_CMD="exec \"$REDLINE_BIN_ABS\" \"\$@\""
|
|
79
|
+
BUN_ABS=""
|
|
40
80
|
else
|
|
41
|
-
|
|
42
|
-
|
|
81
|
+
# Resolve bun. Prefer $PATH; fall back to ~/.bun/bin/bun.
|
|
82
|
+
if command -v bun >/dev/null 2>&1; then
|
|
83
|
+
BUN_ABS="$(command -v bun)"
|
|
84
|
+
elif [ -x "$HOME/.bun/bin/bun" ]; then
|
|
85
|
+
BUN_ABS="$HOME/.bun/bin/bun"
|
|
86
|
+
else
|
|
87
|
+
echo "error: bun not found in PATH or at ~/.bun/bin/bun (install: https://bun.sh)" >&2
|
|
88
|
+
exit 1
|
|
89
|
+
fi
|
|
90
|
+
LAUNCH_CMD="exec \"$BUN_ABS\" run \"$CLI_ABS\" \"\$@\""
|
|
43
91
|
fi
|
|
44
92
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
93
|
+
install_one() {
|
|
94
|
+
local label="$1"
|
|
95
|
+
local dest="$2"
|
|
96
|
+
|
|
97
|
+
mkdir -p "$(dirname "$dest")"
|
|
98
|
+
rm -rf "$dest"
|
|
99
|
+
cp -R "$SRC" "$dest"
|
|
48
100
|
|
|
49
|
-
# Generate a self-contained launcher
|
|
50
|
-
|
|
51
|
-
cat > "$
|
|
101
|
+
# Generate a self-contained launcher.
|
|
102
|
+
local launcher="$dest/redline"
|
|
103
|
+
cat > "$launcher" <<EOF
|
|
52
104
|
#!/bin/bash
|
|
53
105
|
# Auto-generated by scripts/install-skill.sh — re-run that script to refresh.
|
|
54
|
-
|
|
106
|
+
$LAUNCH_CMD
|
|
55
107
|
EOF
|
|
56
|
-
chmod +x "$
|
|
108
|
+
chmod +x "$launcher"
|
|
57
109
|
|
|
58
|
-
# Substitute the launcher path into the installed SKILL.md.
|
|
59
|
-
|
|
60
|
-
if ! grep -q "$PLACEHOLDER" "$
|
|
61
|
-
|
|
62
|
-
|
|
110
|
+
# Substitute the launcher path into the installed SKILL.md.
|
|
111
|
+
local skill_file="$dest/SKILL.md"
|
|
112
|
+
if ! grep -q "$PLACEHOLDER" "$skill_file"; then
|
|
113
|
+
echo "error: placeholder '$PLACEHOLDER' not found in $skill_file — source skill is out of sync" >&2
|
|
114
|
+
exit 1
|
|
115
|
+
fi
|
|
116
|
+
if [[ "$OSTYPE" == "darwin"* ]]; then
|
|
117
|
+
sed -i '' "s|$PLACEHOLDER|$launcher|g" "$skill_file"
|
|
118
|
+
else
|
|
119
|
+
sed -i "s|$PLACEHOLDER|$launcher|g" "$skill_file"
|
|
120
|
+
fi
|
|
121
|
+
|
|
122
|
+
echo "Installed redline-review for $label → $dest"
|
|
123
|
+
echo " launcher: $launcher"
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if [ "$TARGET" = "claude" ] || [ "$TARGET" = "both" ]; then
|
|
127
|
+
install_one "Claude Code" "${CLAUDE_HOME:-$HOME/.claude}/skills/redline-review"
|
|
63
128
|
fi
|
|
64
|
-
if [[ "$
|
|
65
|
-
|
|
66
|
-
else
|
|
67
|
-
sed -i "s|$PLACEHOLDER|$LAUNCHER|g" "$SKILL_FILE"
|
|
129
|
+
if [ "$TARGET" = "codex" ] || [ "$TARGET" = "both" ]; then
|
|
130
|
+
install_one "Codex" "${CODEX_HOME:-$HOME/.codex}/skills/redline-review"
|
|
68
131
|
fi
|
|
69
132
|
|
|
70
|
-
|
|
71
|
-
echo "
|
|
72
|
-
echo "
|
|
73
|
-
|
|
133
|
+
if [ -n "${BUN_ABS:-}" ]; then
|
|
134
|
+
echo " bun: $BUN_ABS"
|
|
135
|
+
echo " cli: $CLI_ABS"
|
|
136
|
+
else
|
|
137
|
+
echo " redline: $REDLINE_BIN_ABS"
|
|
138
|
+
fi
|
|
74
139
|
echo
|
|
75
|
-
echo "Your AI coding agents
|
|
140
|
+
echo "Your AI coding agents can now use the redline-review skill."
|
|
76
141
|
echo "Try it: ask your agent to redline a markdown file you'd like feedback on."
|
|
77
142
|
echo
|
|
78
|
-
echo "Re-run this
|
|
143
|
+
echo "Re-run this command after upgrading Redline if the skill instructions change."
|
|
@@ -9,9 +9,9 @@ When you've produced a markdown document that the human needs to read, comment o
|
|
|
9
9
|
|
|
10
10
|
## How to invoke it
|
|
11
11
|
|
|
12
|
-
The redline
|
|
12
|
+
The redline launcher lives at `__REDLINE_BIN__` (substituted at install time — if you see the literal placeholder string, the skill was installed incorrectly; tell the human to re-run `redline install-skill`). Always invoke it by this absolute path. Do not call bare `redline` and do not try to "fix" PATH issues by running `bun link` or guessing where the repo lives.
|
|
13
13
|
|
|
14
|
-
**Always background the launcher and poll.** Never run `__REDLINE_BIN__` as a foreground/blocking
|
|
14
|
+
**Always background the launcher and poll.** Never run `__REDLINE_BIN__` as a foreground/blocking shell call: agent shell tools often buffer stdout until the process exits, so you would never see the URL the human needs to click and your "I'll wait while you review" message would be a lie. Use this pattern:
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
17
|
FILE=/abs/path/to/file.md
|
|
@@ -34,7 +34,7 @@ PID=$(grep -o '"pid": *[0-9]*' "$STARTUP" | grep -o '[0-9]*')
|
|
|
34
34
|
echo "REDLINE_URL: $URL"
|
|
35
35
|
echo "REDLINE_PID: $PID"
|
|
36
36
|
|
|
37
|
-
# Step 2: surface the URL to the human (you do this after the
|
|
37
|
+
# Step 2: surface the URL to the human (you do this after the first shell call returns
|
|
38
38
|
# — see the next section), then wait for the redline process to exit. Watching
|
|
39
39
|
# the PID (essentially free) instead of polling for the result file means you
|
|
40
40
|
# wake up within ~0.5s of the human clicking Done, not up to 30s later.
|
|
@@ -44,12 +44,12 @@ cat "$RESULT"
|
|
|
44
44
|
|
|
45
45
|
The startup file at `.review/<basename>.startup.json` is written synchronously when the server begins listening; it contains `url`, `port`, `file`, `result_file`, `started_at`, `pid`. The result file at `.review/<basename>.result` is written when the session ends (approved, abandoned, or error).
|
|
46
46
|
|
|
47
|
-
In practice, run the script above as **two separate
|
|
47
|
+
In practice, run the script above as **two separate shell calls** so you can tell the human the URL between steps:
|
|
48
48
|
1. First call: everything through `echo "REDLINE_PID: $PID"`. Returns in ~1s with the URL and PID on stdout.
|
|
49
49
|
2. Surface the URL to the human in your reply text (see "Surfacing the URL" below).
|
|
50
50
|
3. Second call: just the `while kill -0` loop waiting for the PID, then `cat "$RESULT"`. Long timeout (`timeout: 1800000` = 30 min, or longer).
|
|
51
51
|
|
|
52
|
-
If invocation fails (binary missing, startup file never appears, etc.), surface the error verbatim and stop — do not try to recover. The human will re-run
|
|
52
|
+
If invocation fails (binary missing, startup file never appears, etc.), surface the error verbatim and stop — do not try to recover. The human will re-run `redline install-skill`.
|
|
53
53
|
|
|
54
54
|
### Pass context with `--context`
|
|
55
55
|
|
|
@@ -61,7 +61,7 @@ The context string is shown in the reader's header so the human knows what they'
|
|
|
61
61
|
|
|
62
62
|
### Surfacing the URL
|
|
63
63
|
|
|
64
|
-
After the first
|
|
64
|
+
After the first shell call returns with `REDLINE_URL: http://localhost:NNNN`, surface that URL in your reply text. The human has no other signal that something is waiting for them. One short sentence:
|
|
65
65
|
|
|
66
66
|
> "Opening this in Redline for review at http://localhost:NNNN — cmd-click to open. I'll continue once you click Done."
|
|
67
67
|
|
|
@@ -69,7 +69,7 @@ After the first Bash call returns with `REDLINE_URL: http://localhost:NNNN`, sur
|
|
|
69
69
|
|
|
70
70
|
## How to interpret the result
|
|
71
71
|
|
|
72
|
-
When the polling loop's
|
|
72
|
+
When the polling loop's shell call returns, the `cat "$RESULT"` at the end of it has printed the result JSON to stdout.
|
|
73
73
|
|
|
74
74
|
```json
|
|
75
75
|
{ "status": "approved", "file": "/abs/path/to/file.md", "rounds": 2, "comments": 5 }
|
|
@@ -87,9 +87,9 @@ The full loop, when you are the outer agent producing the doc:
|
|
|
87
87
|
|
|
88
88
|
1. Write the markdown file to disk at an absolute path.
|
|
89
89
|
2. Tell the human in one sentence what's about to happen.
|
|
90
|
-
3. First
|
|
90
|
+
3. First shell call: launch `__REDLINE_BIN__ <abs-path> --context "<one-liner>"` in the background and poll for `.startup.json`. Returns in ~1s with the URL.
|
|
91
91
|
4. Surface the URL to the human in your reply text so they can cmd-click to open.
|
|
92
|
-
5. Second
|
|
92
|
+
5. Second shell call: wait on the redline PID (`while kill -0 "$PID" 2>/dev/null; do sleep 0.5; done`) then `cat "$RESULT"`, with a long timeout (30+ min). While the session runs, you are idle — do not start unrelated work, do not run other tools.
|
|
93
93
|
6. On `approved`: re-read the file from disk (it may have been revised) and continue with whatever required sign-off.
|
|
94
94
|
7. On `abandoned` or `error`: stop and ask the human how to proceed; do not retry automatically.
|
|
95
95
|
|
package/src/agent.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { parseReply } from "./parseReply";
|
|
|
7
7
|
import { newEnvelope } from "./promptEnvelope";
|
|
8
8
|
import { contextBlock } from "./contextBlock";
|
|
9
9
|
import { loadSidecar } from "./sidecar";
|
|
10
|
+
import { getAgentProvider, resolveProviderId } from "./agentProvider";
|
|
10
11
|
|
|
11
12
|
const filePath = process.argv[2];
|
|
12
13
|
if (!filePath) {
|
|
@@ -42,6 +43,7 @@ if (process.env.REDLINE_AGENT_CRASH_ALWAYS === "1") {
|
|
|
42
43
|
|
|
43
44
|
const BASE_URL = `http://localhost:${process.env.REDLINE_PORT ?? "3000"}`;
|
|
44
45
|
const CSRF_TOKEN = process.env.REDLINE_TOKEN ?? "";
|
|
46
|
+
const provider = getAgentProvider(resolveProviderId());
|
|
45
47
|
|
|
46
48
|
// Inject `X-Redline-Token` on every mutating call back to the server. The
|
|
47
49
|
// server rejects unauthenticated POST/DELETE/PUT/PATCH on /api/*; without
|
|
@@ -89,8 +91,11 @@ const REPLY_SYSTEM_PROMPT_BODY =
|
|
|
89
91
|
"Good: message: \"Got it.\" reason: \"Add a third line to the hard line breaks example\"\n" +
|
|
90
92
|
"When requires_revision is false, leave reason empty (or a very short note about why no edit). The reply IS the answer.\n" +
|
|
91
93
|
"\n" +
|
|
94
|
+
"Separately, decide whether this comment needs the agent that LAUNCHED this review rather than you. That outer agent has the project context, tools, and authority you don't — e.g. an external style guide or canon you can't see, a spec to check the document against, or a decision that depends on the wider project. When the comment asks for something like that, set ESCALATE: true and briefly tell the reviewer in your message that you've routed it to the launching agent. Otherwise ESCALATE: false. Escalating does not change requires_revision — judge that on its own.\n" +
|
|
95
|
+
"\n" +
|
|
92
96
|
"Output exactly this format and nothing else (no prose before/after, no code fences). Use these literal markers — do not escape quotes inside the message:\n" +
|
|
93
97
|
"REQUIRES_REVISION: <true|false>\n" +
|
|
98
|
+
"ESCALATE: <true|false>\n" +
|
|
94
99
|
"REASON: <one short sentence describing the edit, or empty>\n" +
|
|
95
100
|
"---MESSAGE---\n" +
|
|
96
101
|
"<your reply text — quotes, punctuation, anything>\n" +
|
|
@@ -114,12 +119,13 @@ async function postReply(
|
|
|
114
119
|
commentId: string,
|
|
115
120
|
message: string,
|
|
116
121
|
requires_revision: boolean,
|
|
117
|
-
revision_reason: string
|
|
122
|
+
revision_reason: string,
|
|
123
|
+
escalate: boolean
|
|
118
124
|
) {
|
|
119
125
|
await fetch(`${BASE_URL}/api/comment/${commentId}/reply`, {
|
|
120
126
|
method: "POST",
|
|
121
127
|
headers: { "Content-Type": "application/json", ...CSRF_HEADER },
|
|
122
|
-
body: JSON.stringify({ role: "agent", name:
|
|
128
|
+
body: JSON.stringify({ role: "agent", name: provider.displayName, message, requires_revision, revision_reason, escalate }),
|
|
123
129
|
});
|
|
124
130
|
}
|
|
125
131
|
|
|
@@ -164,29 +170,21 @@ async function handleComment(commentId: string) {
|
|
|
164
170
|
`Thread:\n${env.wrap("thread", threadText)}`;
|
|
165
171
|
|
|
166
172
|
const lastMessage = thread[thread.length - 1].message as string;
|
|
167
|
-
const
|
|
168
|
-
|
|
173
|
+
const tier = pickReplyModel(lastMessage);
|
|
174
|
+
const model = provider.modelForTier(tier);
|
|
175
|
+
console.log(`[agent] replying to ${commentId} with ${provider.id}/${model}`);
|
|
169
176
|
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
proc.stdin.end();
|
|
177
|
-
|
|
178
|
-
let reply = "";
|
|
179
|
-
const reader = proc.stdout.getReader();
|
|
180
|
-
while (true) {
|
|
181
|
-
const { done, value } = await reader.read();
|
|
182
|
-
if (done) break;
|
|
183
|
-
reply += new TextDecoder().decode(value);
|
|
184
|
-
}
|
|
177
|
+
const reply = await provider.runReply({
|
|
178
|
+
systemPrompt: replySystemPrompt(env.systemPromptHint()),
|
|
179
|
+
userMessage,
|
|
180
|
+
model,
|
|
181
|
+
cwd: process.cwd(),
|
|
182
|
+
});
|
|
185
183
|
|
|
186
184
|
if (reply.trim()) {
|
|
187
185
|
const parsed = parseReply(reply);
|
|
188
|
-
console.log(`[agent] verdict for ${commentId}: requires_revision=${parsed.requires_revision}`);
|
|
189
|
-
await postReply(commentId, parsed.message, parsed.requires_revision, parsed.reason);
|
|
186
|
+
console.log(`[agent] verdict for ${commentId}: requires_revision=${parsed.requires_revision} escalate=${parsed.escalate}`);
|
|
187
|
+
await postReply(commentId, parsed.message, parsed.requires_revision, parsed.reason, parsed.escalate);
|
|
190
188
|
}
|
|
191
189
|
} catch (err) {
|
|
192
190
|
await logReplyFailure(commentId, err);
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { mkdtemp, readFile, rm } from "fs/promises";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import type { ModelTier } from "./pickModel";
|
|
6
|
+
|
|
7
|
+
export type AgentProviderId = "claude" | "codex";
|
|
8
|
+
|
|
9
|
+
export type RevisionChunkKind = "thinking" | "text";
|
|
10
|
+
|
|
11
|
+
export interface RevisionRunResult {
|
|
12
|
+
revised: string;
|
|
13
|
+
stderr: string;
|
|
14
|
+
exitCode: number;
|
|
15
|
+
durationMs: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AgentProvider {
|
|
19
|
+
id: AgentProviderId;
|
|
20
|
+
displayName: string;
|
|
21
|
+
executable(): string;
|
|
22
|
+
preflight(): void;
|
|
23
|
+
modelForTier(tier: ModelTier): string;
|
|
24
|
+
runReply(input: {
|
|
25
|
+
systemPrompt: string;
|
|
26
|
+
userMessage: string;
|
|
27
|
+
model: string;
|
|
28
|
+
cwd: string;
|
|
29
|
+
}): Promise<string>;
|
|
30
|
+
runRevision(input: {
|
|
31
|
+
systemPrompt: string;
|
|
32
|
+
userMessage: string;
|
|
33
|
+
model: string;
|
|
34
|
+
cwd: string;
|
|
35
|
+
onChunk?: (text: string, kind: RevisionChunkKind) => void;
|
|
36
|
+
}): Promise<RevisionRunResult>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const PROVIDER_IDS: AgentProviderId[] = ["claude", "codex"];
|
|
40
|
+
|
|
41
|
+
export function parseAgentProviderId(value: string | undefined): AgentProviderId | null {
|
|
42
|
+
if (!value) return null;
|
|
43
|
+
return PROVIDER_IDS.includes(value as AgentProviderId) ? value as AgentProviderId : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolveProviderId(raw?: string): AgentProviderId {
|
|
47
|
+
const explicit = parseAgentProviderId(raw ?? process.env.REDLINE_AGENT);
|
|
48
|
+
if (explicit) return explicit;
|
|
49
|
+
|
|
50
|
+
if (process.env.CLAUDE_CODE_EXECPATH && existsSync(process.env.CLAUDE_CODE_EXECPATH)) return "claude";
|
|
51
|
+
if (Bun.which("claude")) return "claude";
|
|
52
|
+
if (process.env.CODEX_EXECPATH && existsSync(process.env.CODEX_EXECPATH)) return "codex";
|
|
53
|
+
if (Bun.which("codex")) return "codex";
|
|
54
|
+
// Preserve the published behavior: if nothing can be detected, the
|
|
55
|
+
// preflight on the default provider prints the actionable install message.
|
|
56
|
+
return "claude";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function invalidProviderMessage(value: string): string {
|
|
60
|
+
return `[redline] Unknown agent provider "${value}". Supported providers: ${PROVIDER_IDS.join(", ")}.`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function requireExecutable(provider: AgentProviderId, envPath: string | undefined, bin: string): string {
|
|
64
|
+
if (envPath && existsSync(envPath)) return envPath;
|
|
65
|
+
const found = Bun.which(bin);
|
|
66
|
+
if (found) return found;
|
|
67
|
+
const install =
|
|
68
|
+
provider === "claude"
|
|
69
|
+
? "Install Claude Code from https://claude.com/claude-code and re-run."
|
|
70
|
+
: "Install Codex and make sure `codex` is on PATH, or set CODEX_EXECPATH.";
|
|
71
|
+
throw new Error(
|
|
72
|
+
`\n[redline] Could not find the ${provider} CLI.\n` +
|
|
73
|
+
`Redline shells out to a local ${provider} agent for replies and revisions.\n` +
|
|
74
|
+
`${install}\n`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function drainStderr(stream: ReadableStream<Uint8Array>): Promise<string> {
|
|
79
|
+
return (async () => {
|
|
80
|
+
let out = "";
|
|
81
|
+
const r = stream.getReader();
|
|
82
|
+
const dec = new TextDecoder();
|
|
83
|
+
while (true) {
|
|
84
|
+
const { done, value } = await r.read();
|
|
85
|
+
if (done) break;
|
|
86
|
+
const chunk = dec.decode(value);
|
|
87
|
+
out += chunk;
|
|
88
|
+
process.stderr.write(chunk);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
})();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function readAll(stream: ReadableStream<Uint8Array>): Promise<string> {
|
|
95
|
+
let out = "";
|
|
96
|
+
const r = stream.getReader();
|
|
97
|
+
const dec = new TextDecoder();
|
|
98
|
+
while (true) {
|
|
99
|
+
const { done, value } = await r.read();
|
|
100
|
+
if (done) break;
|
|
101
|
+
out += dec.decode(value);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function combinedPrompt(systemPrompt: string, userMessage: string): string {
|
|
107
|
+
return `${systemPrompt}\n\n---\n\n${userMessage}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function getAgentProvider(id: AgentProviderId): AgentProvider {
|
|
111
|
+
if (id === "codex") return codexProvider;
|
|
112
|
+
return claudeProvider;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const claudeProvider: AgentProvider = {
|
|
116
|
+
id: "claude",
|
|
117
|
+
displayName: "Claude",
|
|
118
|
+
executable() {
|
|
119
|
+
return requireExecutable("claude", process.env.CLAUDE_CODE_EXECPATH, "claude");
|
|
120
|
+
},
|
|
121
|
+
preflight() {
|
|
122
|
+
this.executable();
|
|
123
|
+
},
|
|
124
|
+
modelForTier(tier) {
|
|
125
|
+
return tier === "smart" ? "claude-sonnet-4-6" : "claude-haiku-4-5-20251001";
|
|
126
|
+
},
|
|
127
|
+
async runReply(input) {
|
|
128
|
+
const proc = Bun.spawn(
|
|
129
|
+
[this.executable(), "-p", "--system-prompt", input.systemPrompt, "--model", input.model],
|
|
130
|
+
{ stdin: "pipe", stdout: "pipe", stderr: "inherit", cwd: input.cwd }
|
|
131
|
+
);
|
|
132
|
+
proc.stdin.write(input.userMessage);
|
|
133
|
+
proc.stdin.end();
|
|
134
|
+
const reply = await readAll(proc.stdout);
|
|
135
|
+
const exitCode = await proc.exited;
|
|
136
|
+
if (exitCode !== 0) throw new Error(`claude CLI exited with code ${exitCode}`);
|
|
137
|
+
return reply;
|
|
138
|
+
},
|
|
139
|
+
async runRevision(input) {
|
|
140
|
+
const startedAt = Date.now();
|
|
141
|
+
const proc = Bun.spawn(
|
|
142
|
+
[this.executable(), "-p", "--system-prompt", input.systemPrompt, "--model", input.model,
|
|
143
|
+
"--output-format", "stream-json", "--include-partial-messages", "--verbose"],
|
|
144
|
+
{ stdin: "pipe", stdout: "pipe", stderr: "pipe", cwd: input.cwd }
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
proc.stdin.write(input.userMessage);
|
|
148
|
+
proc.stdin.end();
|
|
149
|
+
|
|
150
|
+
const stderrDone = drainStderr(proc.stderr);
|
|
151
|
+
let revised = "";
|
|
152
|
+
let buffer = "";
|
|
153
|
+
const reader = proc.stdout.getReader();
|
|
154
|
+
while (true) {
|
|
155
|
+
const { done, value } = await reader.read();
|
|
156
|
+
if (done) break;
|
|
157
|
+
buffer += new TextDecoder().decode(value);
|
|
158
|
+
const lines = buffer.split("\n");
|
|
159
|
+
buffer = lines.pop() ?? "";
|
|
160
|
+
for (const line of lines) {
|
|
161
|
+
if (!line.trim()) continue;
|
|
162
|
+
try {
|
|
163
|
+
const obj = JSON.parse(line);
|
|
164
|
+
if (obj.type === "stream_event" && obj.event?.type === "content_block_delta") {
|
|
165
|
+
const delta = obj.event.delta;
|
|
166
|
+
if (delta?.type === "text_delta" && delta.text) {
|
|
167
|
+
revised += delta.text;
|
|
168
|
+
process.stdout.write(delta.text);
|
|
169
|
+
input.onChunk?.(delta.text, "text");
|
|
170
|
+
} else if (delta?.type === "thinking_delta" && delta.thinking) {
|
|
171
|
+
input.onChunk?.(delta.thinking, "thinking");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} catch { /* malformed JSON line, skip */ }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const exitCode = await proc.exited;
|
|
178
|
+
const stderr = await stderrDone;
|
|
179
|
+
return { revised, stderr, exitCode, durationMs: Date.now() - startedAt };
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export const codexProvider: AgentProvider = {
|
|
184
|
+
id: "codex",
|
|
185
|
+
displayName: "Codex",
|
|
186
|
+
executable() {
|
|
187
|
+
return requireExecutable("codex", process.env.CODEX_EXECPATH, "codex");
|
|
188
|
+
},
|
|
189
|
+
preflight() {
|
|
190
|
+
this.executable();
|
|
191
|
+
},
|
|
192
|
+
modelForTier(tier) {
|
|
193
|
+
return tier === "smart"
|
|
194
|
+
? (process.env.REDLINE_CODEX_SMART_MODEL ?? "gpt-5.4")
|
|
195
|
+
: (process.env.REDLINE_CODEX_FAST_MODEL ?? "gpt-5.4-mini");
|
|
196
|
+
},
|
|
197
|
+
async runReply(input) {
|
|
198
|
+
const tmpDir = await mkdtemp(path.join(os.tmpdir(), "redline-codex-"));
|
|
199
|
+
const lastMessagePath = path.join(tmpDir, "last-message.md");
|
|
200
|
+
const proc = Bun.spawn(
|
|
201
|
+
[
|
|
202
|
+
this.executable(), "--ask-for-approval", "never", "exec",
|
|
203
|
+
"--model", input.model,
|
|
204
|
+
"--cd", input.cwd,
|
|
205
|
+
"--sandbox", "read-only",
|
|
206
|
+
"--skip-git-repo-check",
|
|
207
|
+
"--ephemeral",
|
|
208
|
+
"--color", "never",
|
|
209
|
+
"--output-last-message", lastMessagePath,
|
|
210
|
+
"-",
|
|
211
|
+
],
|
|
212
|
+
{ stdin: "pipe", stdout: "pipe", stderr: "inherit", cwd: input.cwd }
|
|
213
|
+
);
|
|
214
|
+
proc.stdin.write(combinedPrompt(input.systemPrompt, input.userMessage));
|
|
215
|
+
proc.stdin.end();
|
|
216
|
+
const stdout = await readAll(proc.stdout);
|
|
217
|
+
const exitCode = await proc.exited;
|
|
218
|
+
let reply = stdout;
|
|
219
|
+
try {
|
|
220
|
+
const fromFile = await readFile(lastMessagePath, "utf-8");
|
|
221
|
+
if (fromFile.trim()) reply = fromFile;
|
|
222
|
+
} catch { /* fall back to stdout */ }
|
|
223
|
+
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
224
|
+
if (exitCode !== 0) throw new Error(`codex CLI exited with code ${exitCode}`);
|
|
225
|
+
return reply;
|
|
226
|
+
},
|
|
227
|
+
async runRevision(input) {
|
|
228
|
+
const startedAt = Date.now();
|
|
229
|
+
const tmpDir = await mkdtemp(path.join(os.tmpdir(), "redline-codex-"));
|
|
230
|
+
const lastMessagePath = path.join(tmpDir, "last-message.md");
|
|
231
|
+
const proc = Bun.spawn(
|
|
232
|
+
[
|
|
233
|
+
this.executable(), "--ask-for-approval", "never", "exec",
|
|
234
|
+
"--model", input.model,
|
|
235
|
+
"--cd", input.cwd,
|
|
236
|
+
"--sandbox", "read-only",
|
|
237
|
+
"--skip-git-repo-check",
|
|
238
|
+
"--ephemeral",
|
|
239
|
+
"--color", "never",
|
|
240
|
+
"--output-last-message", lastMessagePath,
|
|
241
|
+
"-",
|
|
242
|
+
],
|
|
243
|
+
{ stdin: "pipe", stdout: "pipe", stderr: "pipe", cwd: input.cwd }
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
proc.stdin.write(combinedPrompt(input.systemPrompt, input.userMessage));
|
|
247
|
+
proc.stdin.end();
|
|
248
|
+
|
|
249
|
+
const stderrDone = drainStderr(proc.stderr);
|
|
250
|
+
const stdoutDone = readAll(proc.stdout);
|
|
251
|
+
const exitCode = await proc.exited;
|
|
252
|
+
const [stderr, stdout] = await Promise.all([stderrDone, stdoutDone]);
|
|
253
|
+
|
|
254
|
+
let revised = stdout;
|
|
255
|
+
try {
|
|
256
|
+
const fromFile = await readFile(lastMessagePath, "utf-8");
|
|
257
|
+
if (fromFile.trim()) revised = fromFile;
|
|
258
|
+
} catch { /* fall back to stdout */ }
|
|
259
|
+
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
260
|
+
|
|
261
|
+
if (revised) {
|
|
262
|
+
process.stdout.write(revised);
|
|
263
|
+
input.onChunk?.(revised, "text");
|
|
264
|
+
}
|
|
265
|
+
return { revised, stderr, exitCode, durationMs: Date.now() - startedAt };
|
|
266
|
+
},
|
|
267
|
+
};
|