@tsconfigdotjson/vibe-os 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/README.md +19 -847
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/web/assets/index-82MhMOB4.css +1 -0
- package/dist/web/assets/index-82MhMOB4.css.br +0 -0
- package/dist/web/assets/index-82MhMOB4.css.gz +0 -0
- package/dist/web/assets/index-DCB2nO0u.js +36 -0
- package/dist/web/assets/index-DCB2nO0u.js.br +0 -0
- package/dist/web/assets/index-DCB2nO0u.js.gz +0 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/scripts/notices.ts +3 -1
- package/server/api.ts +505 -20
- package/server/assets.generated.ts +13 -13
- package/server/attach.ts +65 -16
- package/server/browser.ts +1 -65
- package/server/cli.ts +48 -150
- package/server/client.ts +440 -0
- package/server/config.ts +64 -2
- package/server/db.ts +19 -0
- package/server/doctor.ts +176 -123
- package/server/harness.ts +378 -590
- package/server/harnesses/claude.ts +248 -0
- package/server/harnesses/codex.ts +241 -0
- package/server/harnesses/cursor.ts +202 -0
- package/server/harnesses/hermes.ts +259 -0
- package/server/harnesses/util.ts +181 -0
- package/server/headless.ts +417 -0
- package/server/index.ts +47 -14
- package/server/memory.ts +418 -0
- package/server/profiles.ts +27 -15
- package/server/projects.ts +52 -3
- package/server/reaper.ts +5 -3
- package/server/screens.ts +392 -0
- package/server/session.ts +84 -95
- package/server/setup.ts +73 -7
- package/server/windows.ts +51 -1
- package/shared/blanks.ts +113 -0
- package/shared/harness.ts +375 -0
- package/shared/wire.ts +107 -94
- package/dist/web/assets/index-B77Qk8B5.js +0 -35
- package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
- package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css +0 -1
- package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// Claude Code.
|
|
2
|
+
//
|
|
3
|
+
// The editor offers models and permission modes as dropdowns, so something has
|
|
4
|
+
// to know what the valid values are. Hardcoding them would be wrong within a
|
|
5
|
+
// release or two, so they are read off the binary that is actually installed.
|
|
6
|
+
// Update Claude Code and the dropdowns follow.
|
|
7
|
+
|
|
8
|
+
import type { ChoiceOption } from "../../shared/harness.ts";
|
|
9
|
+
import { log } from "../log.ts";
|
|
10
|
+
import { type HarnessAdapter, looksLikeLadder, run } from "./util.ts";
|
|
11
|
+
|
|
12
|
+
export const CLAUDE_INSTALL = "curl -fsSL https://claude.ai/install.sh | bash";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Aliases the CLI documents but which do not appear as model ids.
|
|
16
|
+
*
|
|
17
|
+
* A floor rather than the source of truth: whatever is found in the binary is
|
|
18
|
+
* merged over the top, so a new tier appears on its own. These are the right
|
|
19
|
+
* default for a profile because they do not pin: a role called "Backend
|
|
20
|
+
* Manager" wants the best Opus, not the one that was current the day it was
|
|
21
|
+
* written.
|
|
22
|
+
*/
|
|
23
|
+
const KNOWN_ALIASES = ["opus", "sonnet", "haiku", "fable", "opusplan"];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The effort ladder as of writing, used only when the binary cannot be read.
|
|
27
|
+
*
|
|
28
|
+
* Not merged with what was discovered. Effort is ordinal, so a union of two
|
|
29
|
+
* sources would put a newly added level in the wrong place. Either the binary's
|
|
30
|
+
* order or this one, never a blend.
|
|
31
|
+
*/
|
|
32
|
+
const KNOWN_EFFORT = ["low", "medium", "high", "xhigh", "max"];
|
|
33
|
+
|
|
34
|
+
/** Readable names for the effort ladder. Unknown levels show raw. */
|
|
35
|
+
const EFFORT_LABELS: Record<string, string> = {
|
|
36
|
+
low: "Low — quick and cheap",
|
|
37
|
+
medium: "Medium",
|
|
38
|
+
high: "High",
|
|
39
|
+
xhigh: "Extra high",
|
|
40
|
+
max: "Maximum — slowest, most thorough",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Plain-English names for the modes the CLI reports. Unknown ones show raw. */
|
|
44
|
+
const MODE_LABELS: Record<string, string> = {
|
|
45
|
+
acceptEdits: "Accept edits automatically",
|
|
46
|
+
plan: "Plan first, then ask",
|
|
47
|
+
auto: "Decide automatically",
|
|
48
|
+
manual: "Ask every time",
|
|
49
|
+
dontAsk: "Never ask",
|
|
50
|
+
bypassPermissions: "Bypass permission checks",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const LATEST = "Latest of its tier";
|
|
54
|
+
const PINNED = "Pinned to one version";
|
|
55
|
+
|
|
56
|
+
const aliasOptions = (aliases: string[]): ChoiceOption[] =>
|
|
57
|
+
aliases
|
|
58
|
+
.filter((a) => a !== "default")
|
|
59
|
+
.map((a) => ({
|
|
60
|
+
value: a,
|
|
61
|
+
label: a[0].toUpperCase() + a.slice(1),
|
|
62
|
+
group: LATEST,
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
const effortOptions = (levels: string[]): ChoiceOption[] =>
|
|
66
|
+
levels.map((l) => ({ value: l, label: EFFORT_LABELS[l] ?? l }));
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Model ids embedded in the executable.
|
|
70
|
+
*
|
|
71
|
+
* grep rather than reading the file in-process: the binary is ~270MB and the
|
|
72
|
+
* answer is a few hundred bytes, so streaming it through a matcher costs
|
|
73
|
+
* nothing while `readFile` would cost the whole thing in memory.
|
|
74
|
+
*/
|
|
75
|
+
async function embeddedModels(binary: string): Promise<string[]> {
|
|
76
|
+
try {
|
|
77
|
+
const { stdout } = await run(
|
|
78
|
+
"grep",
|
|
79
|
+
["-aoE", "claude-(opus|sonnet|haiku|fable)-[0-9]+(-[0-9]+)?", binary],
|
|
80
|
+
{ timeout: 30_000, maxBuffer: 4 * 1024 * 1024 },
|
|
81
|
+
);
|
|
82
|
+
const seen = new Set(
|
|
83
|
+
stdout
|
|
84
|
+
.split("\n")
|
|
85
|
+
.map((l) => l.trim())
|
|
86
|
+
.filter(Boolean),
|
|
87
|
+
);
|
|
88
|
+
// Newest first: people reach for the latest far more often than a pin to
|
|
89
|
+
// something old.
|
|
90
|
+
return [...seen].sort((a, b) =>
|
|
91
|
+
b.localeCompare(a, "en", { numeric: true }),
|
|
92
|
+
);
|
|
93
|
+
} catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The choices `--permission-mode` lists in its own help output. */
|
|
99
|
+
export function permissionModes(help: string): string[] {
|
|
100
|
+
// Help text wraps, so the list is matched across newlines and whitespace.
|
|
101
|
+
const section =
|
|
102
|
+
/--permission-mode[\s\S]{0,400}?\(choices:([\s\S]{0,300}?)\)/.exec(help);
|
|
103
|
+
if (!section) return [];
|
|
104
|
+
return [...section[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The levels `--effort` lists in its own help output.
|
|
109
|
+
*
|
|
110
|
+
* `--effort` prints a bare `(low, medium, high, xhigh, max)`, so the only
|
|
111
|
+
* anchor is the first parenthesis after the flag. That is loose enough to catch
|
|
112
|
+
* a sentence in some future release, which is what the shape check is for.
|
|
113
|
+
*/
|
|
114
|
+
export function effortLevels(help: string): string[] {
|
|
115
|
+
const section = /--effort[\s\S]{0,400}?\(([^)]{0,200})\)/.exec(help);
|
|
116
|
+
if (!section) return [];
|
|
117
|
+
const levels = section[1].split(",").map((s) => s.trim());
|
|
118
|
+
return looksLikeLadder(levels) ? levels : [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const claude: HarnessAdapter = {
|
|
122
|
+
spec: {
|
|
123
|
+
id: "claude",
|
|
124
|
+
label: "Claude",
|
|
125
|
+
command: "claude",
|
|
126
|
+
paths: [
|
|
127
|
+
"~/.local/bin/claude",
|
|
128
|
+
"/usr/local/bin/claude",
|
|
129
|
+
"/opt/claude/.local/bin/claude",
|
|
130
|
+
],
|
|
131
|
+
resume: {
|
|
132
|
+
args: ["--continue"],
|
|
133
|
+
unless: ["--continue", "-c", "--resume", "-r"],
|
|
134
|
+
},
|
|
135
|
+
install: CLAUDE_INSTALL,
|
|
136
|
+
defaults: ["--dangerously-skip-permissions"],
|
|
137
|
+
fields: [
|
|
138
|
+
{
|
|
139
|
+
kind: "select",
|
|
140
|
+
key: "model",
|
|
141
|
+
label: "Model",
|
|
142
|
+
flag: "--model",
|
|
143
|
+
options: aliasOptions(KNOWN_ALIASES),
|
|
144
|
+
none: "Default — whatever Claude picks",
|
|
145
|
+
suffix: {
|
|
146
|
+
token: "[1m]",
|
|
147
|
+
label: "1M context",
|
|
148
|
+
hint: "Ask for the million-token context window",
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
kind: "select",
|
|
153
|
+
key: "effort",
|
|
154
|
+
label: "Thinking",
|
|
155
|
+
flag: "--effort",
|
|
156
|
+
options: effortOptions(KNOWN_EFFORT),
|
|
157
|
+
none: "Default — whatever the harness picks",
|
|
158
|
+
hint: "How long the session reasons before it acts. Higher is slower and costs more tokens; it is worth it for work where being wrong is expensive.",
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
kind: "select",
|
|
162
|
+
key: "permission",
|
|
163
|
+
label: "Permissions",
|
|
164
|
+
flag: "--permission-mode",
|
|
165
|
+
none: "Ask before each action",
|
|
166
|
+
specials: [
|
|
167
|
+
{
|
|
168
|
+
value: "skip",
|
|
169
|
+
label: "Skip every check — no prompts at all",
|
|
170
|
+
flag: "--dangerously-skip-permissions",
|
|
171
|
+
warn: "This session will not ask before editing, running or deleting anything. Reasonable on a box that is already a sandbox; think twice anywhere else.",
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
kind: "mcp",
|
|
177
|
+
key: "mcp",
|
|
178
|
+
label: "MCP servers",
|
|
179
|
+
strict: "--strict-mcp-config",
|
|
180
|
+
config: "--mcp-config",
|
|
181
|
+
emptyHint:
|
|
182
|
+
"Nothing configured yet. Add one on the box with `claude mcp add --scope user …` and it appears here. The scope matters: without it Claude files the server under whichever directory you ran the command in, and in a workspace that goes when the worktree does.",
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
kind: "toggle",
|
|
186
|
+
flag: "--remote-control",
|
|
187
|
+
label: "Remote control",
|
|
188
|
+
hint: "Drive this session from claude.ai. Needs nothing on the box beyond outbound network.",
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
kind: "toggle",
|
|
192
|
+
flag: "--chrome",
|
|
193
|
+
label: "Browser tools",
|
|
194
|
+
hint: "Claude in Chrome. Pairs with the extension on the machine you are browsing from.",
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
kind: "toggle",
|
|
198
|
+
flag: "--continue",
|
|
199
|
+
label: "Resume last conversation",
|
|
200
|
+
hint: "Pick up the most recent session in this directory.",
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
kind: "toggle",
|
|
204
|
+
flag: "--verbose",
|
|
205
|
+
label: "Verbose output",
|
|
206
|
+
hint: "Show full tool output rather than the collapsed form.",
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
valued: ["--agent", "--fallback-model", "--name"],
|
|
210
|
+
extraPlaceholder: '--append-system-prompt "…"',
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
async discover(binary) {
|
|
214
|
+
// Concurrently, because each `claude` invocation costs a couple of seconds
|
|
215
|
+
// of its own startup. `--help` is read once and answers two questions.
|
|
216
|
+
const [models, help] = await Promise.all([
|
|
217
|
+
embeddedModels(binary),
|
|
218
|
+
run(binary, ["--help"], { timeout: 15_000, maxBuffer: 4 * 1024 * 1024 })
|
|
219
|
+
.then(({ stdout }) => stdout)
|
|
220
|
+
.catch(() => ""),
|
|
221
|
+
]);
|
|
222
|
+
const modes = permissionModes(help);
|
|
223
|
+
const effort = effortLevels(help);
|
|
224
|
+
|
|
225
|
+
// A tier that shows up in the binary but not in the list above still gets
|
|
226
|
+
// an alias, because that is how Claude names them.
|
|
227
|
+
const tiers = models.map((m) => m.split("-")[1]).filter(Boolean);
|
|
228
|
+
const aliases = [...new Set([...KNOWN_ALIASES, ...tiers])];
|
|
229
|
+
|
|
230
|
+
log.debug(
|
|
231
|
+
`claude: ${models.length} models, ${modes.length} permission modes, ` +
|
|
232
|
+
`${effort.length > 0 ? effort.join("/") : "no"} effort levels`,
|
|
233
|
+
);
|
|
234
|
+
return {
|
|
235
|
+
options: {
|
|
236
|
+
model: [
|
|
237
|
+
...aliasOptions(aliases),
|
|
238
|
+
...models.map((m) => ({ value: m, group: PINNED })),
|
|
239
|
+
],
|
|
240
|
+
permission: modes.map((m) => ({
|
|
241
|
+
value: m,
|
|
242
|
+
label: MODE_LABELS[m] ?? m,
|
|
243
|
+
})),
|
|
244
|
+
...(effort.length > 0 ? { effort: effortOptions(effort) } : {}),
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
},
|
|
248
|
+
};
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// OpenAI's Codex CLI.
|
|
2
|
+
//
|
|
3
|
+
// The flag list is verbatim from `codex --help` on codex-cli 0.160.1. Models
|
|
4
|
+
// come from `codex debug models`, which renders the CLI's own catalog as JSON
|
|
5
|
+
// and works logged out and offline, so the dropdown is real on any box that
|
|
6
|
+
// has the binary.
|
|
7
|
+
//
|
|
8
|
+
// Codex sets its thinking level through a config override rather than a flag
|
|
9
|
+
// of its own, `-c model_reasoning_effort=high`, and resumes with a subcommand,
|
|
10
|
+
// `codex resume --last`, rather than a flag. The spec says both as data.
|
|
11
|
+
|
|
12
|
+
import { readFile } from "node:fs/promises";
|
|
13
|
+
import type { ChoiceOption } from "../../shared/harness.ts";
|
|
14
|
+
import { log } from "../log.ts";
|
|
15
|
+
import { type HarnessAdapter, home, looksLikeLadder, run } from "./util.ts";
|
|
16
|
+
|
|
17
|
+
export const CODEX_INSTALL = "npm install -g @openai/codex";
|
|
18
|
+
|
|
19
|
+
const EFFORT = "model_reasoning_effort=";
|
|
20
|
+
|
|
21
|
+
/** What every model in the catalog supported as of writing. */
|
|
22
|
+
const KNOWN_EFFORT = ["low", "medium", "high", "xhigh"];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Models out of `codex debug models`, in the CLI's own order.
|
|
26
|
+
*
|
|
27
|
+
* Only `visibility: "list"` entries: the hidden ones are internal (the
|
|
28
|
+
* auto-review model) or previews the picker in Codex itself does not show.
|
|
29
|
+
* Sorted by `priority`, which is the order Codex's own `/model` picker uses.
|
|
30
|
+
*
|
|
31
|
+
* The thinking ladder is the longest one any listed model supports. Every
|
|
32
|
+
* model's list is a prefix of the same scale, so the longest is the scale, in
|
|
33
|
+
* order; a union would have to guess where a new level goes.
|
|
34
|
+
*/
|
|
35
|
+
export function parseCodexModels(stdout: string): {
|
|
36
|
+
models: ChoiceOption[];
|
|
37
|
+
effort: string[];
|
|
38
|
+
} {
|
|
39
|
+
let parsed: unknown;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(stdout);
|
|
42
|
+
} catch {
|
|
43
|
+
return { models: [], effort: [] };
|
|
44
|
+
}
|
|
45
|
+
const list = (parsed as { models?: unknown })?.models;
|
|
46
|
+
if (!Array.isArray(list)) return { models: [], effort: [] };
|
|
47
|
+
|
|
48
|
+
const listed = list
|
|
49
|
+
.filter(
|
|
50
|
+
(m): m is Record<string, unknown> =>
|
|
51
|
+
Boolean(m) &&
|
|
52
|
+
typeof m === "object" &&
|
|
53
|
+
typeof (m as { slug?: unknown }).slug === "string" &&
|
|
54
|
+
(m as { visibility?: unknown }).visibility === "list",
|
|
55
|
+
)
|
|
56
|
+
.sort(
|
|
57
|
+
(a, b) =>
|
|
58
|
+
(typeof a.priority === "number" ? a.priority : 999) -
|
|
59
|
+
(typeof b.priority === "number" ? b.priority : 999),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
let effort: string[] = [];
|
|
63
|
+
for (const m of listed) {
|
|
64
|
+
const levels = Array.isArray(m.supported_reasoning_levels)
|
|
65
|
+
? m.supported_reasoning_levels
|
|
66
|
+
.map((l: unknown) => (l as { effort?: unknown })?.effort)
|
|
67
|
+
.filter((e: unknown): e is string => typeof e === "string")
|
|
68
|
+
: [];
|
|
69
|
+
if (levels.length > effort.length && looksLikeLadder(levels))
|
|
70
|
+
effort = levels;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
models: listed.map((m) => ({
|
|
75
|
+
value: m.slug as string,
|
|
76
|
+
label:
|
|
77
|
+
typeof m.display_name === "string" && m.display_name !== m.slug
|
|
78
|
+
? `${m.display_name} (${m.slug})`
|
|
79
|
+
: (m.slug as string),
|
|
80
|
+
})),
|
|
81
|
+
effort,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Top-level string settings from `config.toml`, the ones a profile's empty
|
|
87
|
+
* fields fall back to.
|
|
88
|
+
*
|
|
89
|
+
* Only keys above the first `[table]`: the same key inside `[profiles.x]` is
|
|
90
|
+
* not what a bare `codex` uses. Not a TOML parser, and does not need to be:
|
|
91
|
+
* a value this cannot read just means no "Default — …" label.
|
|
92
|
+
*/
|
|
93
|
+
export function codexDefaults(toml: string): Record<string, string> {
|
|
94
|
+
const out: Record<string, string> = {};
|
|
95
|
+
for (const line of toml.split("\n")) {
|
|
96
|
+
if (/^\s*\[/.test(line)) break;
|
|
97
|
+
const match = /^\s*(model|model_reasoning_effort)\s*=\s*"([^"]*)"/.exec(
|
|
98
|
+
line,
|
|
99
|
+
);
|
|
100
|
+
if (match?.[2]) out[match[1] === "model" ? "model" : "effort"] = match[2];
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const codex: HarnessAdapter = {
|
|
106
|
+
spec: {
|
|
107
|
+
id: "codex",
|
|
108
|
+
label: "Codex",
|
|
109
|
+
command: "codex",
|
|
110
|
+
// Where `npm install -g` lands for a user-level npm prefix, which is what a
|
|
111
|
+
// box without root has.
|
|
112
|
+
paths: [
|
|
113
|
+
"~/.local/bin/codex",
|
|
114
|
+
"~/.npm-global/bin/codex",
|
|
115
|
+
"/usr/local/bin/codex",
|
|
116
|
+
],
|
|
117
|
+
// `resume` takes the same options as the root command, so the profile's
|
|
118
|
+
// flags follow it unchanged.
|
|
119
|
+
resume: { args: ["resume", "--last"], subcommand: true },
|
|
120
|
+
install: CODEX_INSTALL,
|
|
121
|
+
// Device auth, because the box usually has no browser for the redirect.
|
|
122
|
+
login: { args: ["login", "status"], fix: "codex login --device-auth" },
|
|
123
|
+
defaults: ["--dangerously-bypass-approvals-and-sandbox"],
|
|
124
|
+
fields: [
|
|
125
|
+
{
|
|
126
|
+
kind: "select",
|
|
127
|
+
key: "model",
|
|
128
|
+
label: "Model",
|
|
129
|
+
flag: "--model",
|
|
130
|
+
aliases: ["-m"],
|
|
131
|
+
none: "Default — whatever Codex picks",
|
|
132
|
+
placeholder: "Default — whatever Codex picks",
|
|
133
|
+
hint: "Read off `codex debug models`, so the list follows the installed CLI.",
|
|
134
|
+
emptyHint:
|
|
135
|
+
"Nothing to offer — `codex debug models` could not be read. Typed ids still work.",
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
kind: "select",
|
|
139
|
+
key: "effort",
|
|
140
|
+
label: "Thinking",
|
|
141
|
+
flag: "-c",
|
|
142
|
+
aliases: ["--config"],
|
|
143
|
+
prefix: EFFORT,
|
|
144
|
+
options: KNOWN_EFFORT.map((value) => ({ value })),
|
|
145
|
+
none: "Default — whatever Codex picks",
|
|
146
|
+
hint: "Passed as `-c model_reasoning_effort=…`. Not every model takes every level.",
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
kind: "select",
|
|
150
|
+
key: "sandbox",
|
|
151
|
+
label: "Sandbox",
|
|
152
|
+
flag: "--sandbox",
|
|
153
|
+
aliases: ["-s"],
|
|
154
|
+
options: [
|
|
155
|
+
{ value: "read-only", label: "Read only" },
|
|
156
|
+
{ value: "workspace-write", label: "Write inside the worktree" },
|
|
157
|
+
{ value: "danger-full-access", label: "Full access — no sandbox" },
|
|
158
|
+
],
|
|
159
|
+
none: "Default — whatever Codex is set to",
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
kind: "select",
|
|
163
|
+
key: "approval",
|
|
164
|
+
label: "Approvals",
|
|
165
|
+
flag: "--ask-for-approval",
|
|
166
|
+
aliases: ["-a"],
|
|
167
|
+
options: [
|
|
168
|
+
{ value: "on-request", label: "When the model asks" },
|
|
169
|
+
{ value: "never", label: "Never ask" },
|
|
170
|
+
],
|
|
171
|
+
none: "Default — whatever Codex is set to",
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
kind: "toggle",
|
|
175
|
+
flag: "--dangerously-bypass-approvals-and-sandbox",
|
|
176
|
+
label: "Skip approvals and sandbox",
|
|
177
|
+
hint: "No prompts and no sandbox. Same trade as Claude's skip-every-check.",
|
|
178
|
+
warn: "This session will not ask before running anything, and runs it unsandboxed. Reasonable on a box that is already a sandbox; think twice anywhere else.",
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
kind: "toggle",
|
|
182
|
+
flag: "--approve-for-me",
|
|
183
|
+
label: "Auto-review approvals",
|
|
184
|
+
hint: "Route approval requests through Codex's automatic reviewer, inside the workspace-write sandbox.",
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
kind: "toggle",
|
|
188
|
+
flag: "--search",
|
|
189
|
+
label: "Web search",
|
|
190
|
+
hint: "Give the model live web search, with no per-call approval.",
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
kind: "toggle",
|
|
194
|
+
flag: "--no-alt-screen",
|
|
195
|
+
label: "Inline mode",
|
|
196
|
+
hint: "Run without the alternate screen, so the terminal keeps its scrollback.",
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
valued: [
|
|
200
|
+
"--enable",
|
|
201
|
+
"--disable",
|
|
202
|
+
"--remote",
|
|
203
|
+
"--remote-auth-token-env",
|
|
204
|
+
"-i",
|
|
205
|
+
"--image",
|
|
206
|
+
"--local-provider",
|
|
207
|
+
"-p",
|
|
208
|
+
"--profile",
|
|
209
|
+
"-C",
|
|
210
|
+
"--cd",
|
|
211
|
+
"--add-dir",
|
|
212
|
+
],
|
|
213
|
+
extraPlaceholder: "--profile work",
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
async discover(binary) {
|
|
217
|
+
const codexHome = process.env.CODEX_HOME || `${home}/.codex`;
|
|
218
|
+
const [catalog, toml] = await Promise.all([
|
|
219
|
+
run(binary, ["debug", "models"], {
|
|
220
|
+
timeout: 30_000,
|
|
221
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
222
|
+
})
|
|
223
|
+
.then(({ stdout }) => parseCodexModels(stdout))
|
|
224
|
+
.catch(() => ({ models: [], effort: [] })),
|
|
225
|
+
readFile(`${codexHome}/config.toml`, "utf8").catch(() => ""),
|
|
226
|
+
]);
|
|
227
|
+
log.debug(
|
|
228
|
+
`codex: ${catalog.models.length} models, ` +
|
|
229
|
+
`${catalog.effort.length > 0 ? catalog.effort.join("/") : "no"} effort levels`,
|
|
230
|
+
);
|
|
231
|
+
return {
|
|
232
|
+
options: {
|
|
233
|
+
model: catalog.models,
|
|
234
|
+
...(catalog.effort.length > 0
|
|
235
|
+
? { effort: catalog.effort.map((value) => ({ value })) }
|
|
236
|
+
: {}),
|
|
237
|
+
},
|
|
238
|
+
defaults: codexDefaults(toml),
|
|
239
|
+
};
|
|
240
|
+
},
|
|
241
|
+
};
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Cursor's CLI.
|
|
2
|
+
//
|
|
3
|
+
// `cursor-agent models` is a real listing mode, but it lists the account's
|
|
4
|
+
// models rather than the binary's: logged out, or offline, it cannot be read,
|
|
5
|
+
// so an empty list is "could not tell" and the field stays free text. No floor
|
|
6
|
+
// of well-known model names, because offering a model this account does not
|
|
7
|
+
// have would suggest a choice that can only fail after launch.
|
|
8
|
+
//
|
|
9
|
+
// The flag list is verbatim from `cursor-agent --help` on a real install.
|
|
10
|
+
|
|
11
|
+
import { log } from "../log.ts";
|
|
12
|
+
import { type Discovery, type HarnessAdapter, run } from "./util.ts";
|
|
13
|
+
|
|
14
|
+
export const CURSOR_INSTALL = "curl https://cursor.com/install -fsS | bash";
|
|
15
|
+
|
|
16
|
+
/** Single-quote for the shell the window's command runs in. */
|
|
17
|
+
const shellQuote = (value: string): string =>
|
|
18
|
+
`'${value.replaceAll("'", `'\\''`)}'`;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Model ids out of `cursor-agent models`.
|
|
22
|
+
*
|
|
23
|
+
* The format is verbatim from a real box: an `Available models` header, then
|
|
24
|
+
* one `id - Display Name` per line, then a `Tip:` sentence about `--model`. The
|
|
25
|
+
* default is marked inside the display half: `auto - Auto (default)`.
|
|
26
|
+
*
|
|
27
|
+
* A line has to open with an id-shaped token followed by the ` - ` separator
|
|
28
|
+
* (or nothing at all), which keeps the header and the tip out. Order is kept as
|
|
29
|
+
* printed: Cursor leads with `auto` and its recommendations, which is a better
|
|
30
|
+
* sort than alphabetical for a 200-entry list.
|
|
31
|
+
*/
|
|
32
|
+
export function parseCursorModels(stdout: string): {
|
|
33
|
+
models: string[];
|
|
34
|
+
defaultModel: string | null;
|
|
35
|
+
} {
|
|
36
|
+
const models: string[] = [];
|
|
37
|
+
let defaultModel: string | null = null;
|
|
38
|
+
for (const raw of stdout.split("\n")) {
|
|
39
|
+
const line = raw.trim();
|
|
40
|
+
if (!line) continue;
|
|
41
|
+
const match = /^([A-Za-z0-9][\w./-]*)(?:\s+-\s+(.*))?$/.exec(line);
|
|
42
|
+
if (!match) continue;
|
|
43
|
+
const [, id, label] = match;
|
|
44
|
+
if (!models.includes(id)) models.push(id);
|
|
45
|
+
// The marker word can share its parens: one account prints `(default)`,
|
|
46
|
+
// another `(current, default)`.
|
|
47
|
+
if (
|
|
48
|
+
label &&
|
|
49
|
+
/\([^)]*\b(default|current|selected)\b[^)]*\)/i.test(label) &&
|
|
50
|
+
!defaultModel
|
|
51
|
+
)
|
|
52
|
+
defaultModel = id;
|
|
53
|
+
}
|
|
54
|
+
return { models, defaultModel };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Cursor's name for a directory, as it spells it under `~/.cursor/projects`.
|
|
59
|
+
*
|
|
60
|
+
* Every run of anything that is not a letter or a digit becomes one dash, and
|
|
61
|
+
* the leading one that a path always starts with is dropped. Case survives.
|
|
62
|
+
* Read off a real install: `/home/ubuntu` is `home-ubuntu`, and
|
|
63
|
+
* `/tmp/Cursor.Test_1 space/sub` is `tmp-Cursor-Test-1-space-sub`.
|
|
64
|
+
*/
|
|
65
|
+
export function cursorProjectSlug(dir: string): string {
|
|
66
|
+
return dir.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Shell that points this worktree's Cursor project at the box's MCP credentials.
|
|
71
|
+
*
|
|
72
|
+
* Cursor keeps MCP OAuth tokens per working directory, in
|
|
73
|
+
* `~/.cursor/projects/<slug>/mcp-auth.json`, while the server list is global.
|
|
74
|
+
* Every workspace here is a fresh worktree at a path Cursor has never seen, so
|
|
75
|
+
* "log in once on this box" would mean "log in once per window".
|
|
76
|
+
*
|
|
77
|
+
* A symlink rather than a copy because Cursor writes through it: an access
|
|
78
|
+
* token lasts a day, and the refresh that happens in whichever window is open
|
|
79
|
+
* has to be the refresh every other window sees.
|
|
80
|
+
*
|
|
81
|
+
* The link is only created where there is nothing at all. An existing file is
|
|
82
|
+
* somebody's real per-directory login; an existing link is this, already done.
|
|
83
|
+
* The shared store may not exist yet: writing through a dangling symlink
|
|
84
|
+
* creates the target, so `cursor-agent mcp login linear` in any window is the
|
|
85
|
+
* one login the box needs.
|
|
86
|
+
*
|
|
87
|
+
* Failure is silent and inert. If Cursor changes how it names these
|
|
88
|
+
* directories, the link lands somewhere Cursor does not read and windows behave
|
|
89
|
+
* as they did before: a login prompt, not a broken window.
|
|
90
|
+
*/
|
|
91
|
+
export function cursorMcpLink(cwd: string, stateDir: string): string {
|
|
92
|
+
const store = `${stateDir}/cursor/mcp-auth.json`;
|
|
93
|
+
const storeDir = shellQuote(`${stateDir}/cursor`);
|
|
94
|
+
// The slug is letters, digits and dashes by construction, so it needs no
|
|
95
|
+
// quoting of its own; `$HOME` is left to the shell because the session runs
|
|
96
|
+
// as the user whose credentials these are.
|
|
97
|
+
const dir = `"$HOME/.cursor/projects/${cursorProjectSlug(cwd)}"`;
|
|
98
|
+
const link = `"$HOME/.cursor/projects/${cursorProjectSlug(cwd)}/mcp-auth.json"`;
|
|
99
|
+
return [
|
|
100
|
+
`mkdir -p ${dir} ${storeDir} 2>/dev/null`,
|
|
101
|
+
// Refresh tokens, so no wider than the state dir this sits in.
|
|
102
|
+
`chmod 700 ${storeDir} 2>/dev/null`,
|
|
103
|
+
`{ [ -e ${link} ] || [ -L ${link} ] || ln -s ${shellQuote(store)} ${link} 2>/dev/null; }`,
|
|
104
|
+
].join("; ");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const cursor: HarnessAdapter = {
|
|
108
|
+
spec: {
|
|
109
|
+
id: "cursor",
|
|
110
|
+
label: "Cursor",
|
|
111
|
+
// The unambiguous one of the two names its installer symlinks. `agent` is
|
|
112
|
+
// the other, and too generic to be the one a PATH lookup bets on.
|
|
113
|
+
command: "cursor-agent",
|
|
114
|
+
paths: ["~/.local/bin/cursor-agent", "/usr/local/bin/cursor-agent"],
|
|
115
|
+
resume: { args: ["--continue"], unless: ["--continue", "--resume"] },
|
|
116
|
+
install: CURSOR_INSTALL,
|
|
117
|
+
login: { args: ["status"], fix: "cursor-agent login" },
|
|
118
|
+
// --trust as well: every workspace is a fresh worktree, which to Cursor is
|
|
119
|
+
// an untrusted directory, and a role window should open on the
|
|
120
|
+
// conversation rather than on the trust prompt.
|
|
121
|
+
defaults: ["--force", "--trust"],
|
|
122
|
+
fields: [
|
|
123
|
+
{
|
|
124
|
+
kind: "select",
|
|
125
|
+
key: "model",
|
|
126
|
+
label: "Model",
|
|
127
|
+
flag: "--model",
|
|
128
|
+
aliases: ["-m"],
|
|
129
|
+
none: "Default — whatever Cursor picks",
|
|
130
|
+
placeholder: "Default — whatever Cursor picks",
|
|
131
|
+
hint: "Read off `cursor-agent models`, so the list follows your account.",
|
|
132
|
+
emptyHint:
|
|
133
|
+
"Nothing to offer — `cursor-agent models` could not be read, which usually means the box is not logged in. Typed ids still work.",
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
kind: "toggle",
|
|
137
|
+
flag: "--force",
|
|
138
|
+
// The CLI documents both as the same switch.
|
|
139
|
+
aliases: ["--yolo", "-f"],
|
|
140
|
+
label: "Skip approvals",
|
|
141
|
+
hint: "Edit files and run commands without asking. Same trade as Claude's skip-every-check.",
|
|
142
|
+
warn: "This session will not ask before editing or running anything. Reasonable on a box that is already a sandbox; think twice anywhere else.",
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
kind: "toggle",
|
|
146
|
+
flag: "--trust",
|
|
147
|
+
label: "Trust the worktree",
|
|
148
|
+
hint: "Skip the new-workspace trust prompt. Every vibe-os worktree is new to Cursor.",
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
kind: "toggle",
|
|
152
|
+
flag: "--approve-mcps",
|
|
153
|
+
label: "Approve MCP servers",
|
|
154
|
+
hint: "Skip the per-workspace approval prompt for the servers in ~/.cursor/mcp.json.",
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
kind: "toggle",
|
|
158
|
+
flag: "--continue",
|
|
159
|
+
label: "Resume last conversation",
|
|
160
|
+
hint: "Pick up the most recent session rather than starting a new one.",
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
valued: [
|
|
164
|
+
"--output-format",
|
|
165
|
+
"--workspace",
|
|
166
|
+
"--api-key",
|
|
167
|
+
"--mode",
|
|
168
|
+
"--sandbox",
|
|
169
|
+
"--header",
|
|
170
|
+
"-H",
|
|
171
|
+
"--endpoint",
|
|
172
|
+
"-e",
|
|
173
|
+
"--add-dir",
|
|
174
|
+
"--plugin-dir",
|
|
175
|
+
"--worktree-base",
|
|
176
|
+
],
|
|
177
|
+
// `--resume` takes an optional chat id and `-w`/`--worktree` an optional
|
|
178
|
+
// name. With a value they name one, which no control here can say.
|
|
179
|
+
optional: ["--resume", "--worktree", "-w"],
|
|
180
|
+
extraPlaceholder: '--resume "…"',
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
async discover(binary): Promise<Discovery> {
|
|
184
|
+
const listed = await run(binary, ["models"], {
|
|
185
|
+
timeout: 30_000,
|
|
186
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
187
|
+
})
|
|
188
|
+
.then(({ stdout }) => parseCursorModels(stdout))
|
|
189
|
+
// Logged out or offline it exits complaining; that is "could not read",
|
|
190
|
+
// which the empty list already says.
|
|
191
|
+
.catch(() => ({ models: [], defaultModel: null }));
|
|
192
|
+
log.debug(`cursor-agent: ${listed.models.length} models`);
|
|
193
|
+
return {
|
|
194
|
+
options: { model: listed.models.map((value) => ({ value })) },
|
|
195
|
+
defaults: listed.defaultModel
|
|
196
|
+
? { model: listed.defaultModel }
|
|
197
|
+
: ({} as Record<string, string>),
|
|
198
|
+
};
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
setup: ({ cwd, stateDir }) => cursorMcpLink(cwd, stateDir),
|
|
202
|
+
};
|