@sailresearch/code 0.1.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/src/run.js ADDED
@@ -0,0 +1,328 @@
1
+ /**
2
+ * The default command: resolve a Sail credential, overlay the Claude Code
3
+ * environment block, and hand the terminal to `claude`.
4
+ */
5
+
6
+ import crypto from "node:crypto";
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { spawn } from "node:child_process";
11
+
12
+ import {
13
+ CONFLICTING_PROVIDER_VARS,
14
+ HEADER_INJECTION_VARS,
15
+ MODEL_ALIAS_COMPANION_VARS,
16
+ buildClaudeEnv,
17
+ } from "./constants.js";
18
+ import {
19
+ loadAuthKey,
20
+ loadSettingsBestEffort,
21
+ resolveTarget,
22
+ storedKeyMatchesTarget,
23
+ } from "./credentials.js";
24
+ import { browserLogin } from "./login.js";
25
+
26
+ /**
27
+ * Resolve the credential + target. Precedence: SAIL_API_KEY env (always
28
+ * wins), then the stored `~/.sail` key when its tagged target matches the
29
+ * active one, then the interactive browser login.
30
+ */
31
+ export async function resolveCredentials({ modeFlag } = {}) {
32
+ const target = resolveTarget({ modeFlag });
33
+ const envKey = process.env.SAIL_API_KEY?.trim();
34
+ if (envKey) {
35
+ return { apiKey: envKey, apiUrl: target.apiUrl, source: "SAIL_API_KEY" };
36
+ }
37
+
38
+ const storedKey = loadAuthKey();
39
+ if (storedKey) {
40
+ const settings = loadSettingsBestEffort();
41
+ if (storedKeyMatchesTarget(settings, target.apiUrl)) {
42
+ return { apiKey: storedKey, apiUrl: target.apiUrl, source: "~/.sail" };
43
+ }
44
+ console.error(
45
+ `The stored Sail key is for a different environment than ${target.apiUrl} ` +
46
+ `(selected via ${target.source}); logging in again.`,
47
+ );
48
+ }
49
+
50
+ if (!process.stdout.isTTY && !process.stdin.isTTY) {
51
+ throw new Error(
52
+ "no Sail credential found — set SAIL_API_KEY, or run `sail-code login` in a terminal",
53
+ );
54
+ }
55
+ // Pin the login to the environment this invocation resolved (login rejects
56
+ // a callback for a different one), so e.g. SAIL_API_URL=staging can never
57
+ // be silently satisfied by a prod browser login.
58
+ const login = await browserLogin({
59
+ expectedTarget: target.apiUrl,
60
+ expectedSource: target.source,
61
+ });
62
+ return { apiKey: login.apiKey, apiUrl: login.apiUrl, source: "login" };
63
+ }
64
+
65
+ // --- Windows spawn support -------------------------------------------------
66
+ //
67
+ // `npm install -g @anthropic-ai/claude-code` puts `claude.cmd`/`claude.ps1`
68
+ // shims on PATH on Windows — no claude.exe. CreateProcess only auto-appends
69
+ // .exe, and Node (post CVE-2024-27980) refuses to spawn .cmd/.bat without a
70
+ // shell, so a bare spawn("claude") can never work there. We resolve the real
71
+ // target on PATH ourselves: .exe/.com spawn directly; .cmd/.bat go through
72
+ // cmd.exe with cross-spawn's escaping (shell:true would join args unescaped).
73
+
74
+ function resolveWindowsCommand(name) {
75
+ const exts = (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD")
76
+ .split(";")
77
+ .filter(Boolean);
78
+ const dirs = (process.env.PATH || "").split(path.delimiter).filter(Boolean);
79
+ for (const dir of dirs) {
80
+ for (const ext of exts) {
81
+ const candidate = path.join(dir, name + ext.toLowerCase());
82
+ if (fs.existsSync(candidate)) return candidate;
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+
88
+ // cross-spawn's escape.js, verbatim semantics. Exported for tests.
89
+ const CMD_META = /([()\][%!^"`<>&|;, *?])/g;
90
+
91
+ export function escapeCmdCommand(command) {
92
+ return command.replace(CMD_META, "^$1");
93
+ }
94
+
95
+ export function escapeCmdArgument(arg, doubleEscape) {
96
+ let escaped = String(arg)
97
+ .replace(/(\\*)"/g, '$1$1\\"')
98
+ .replace(/(\\*)$/, "$1$1");
99
+ escaped = `"${escaped}"`.replace(CMD_META, "^$1");
100
+ if (doubleEscape) escaped = escaped.replace(CMD_META, "^$1");
101
+ return escaped;
102
+ }
103
+
104
+ function spawnClaude(claudeArgs, options) {
105
+ if (process.platform !== "win32") {
106
+ return spawn("claude", claudeArgs, options);
107
+ }
108
+ const resolved = resolveWindowsCommand("claude");
109
+ if (resolved && /\.(exe|com)$/i.test(resolved)) {
110
+ return spawn(resolved, claudeArgs, options);
111
+ }
112
+ if (resolved && /\.(cmd|bat)$/i.test(resolved)) {
113
+ // npm's .cmd shim re-parses the line when expanding %*, consuming one
114
+ // caret level — hence the double escape on arguments.
115
+ const line = [
116
+ escapeCmdCommand(path.normalize(resolved)),
117
+ ...claudeArgs.map((a) => escapeCmdArgument(a, true)),
118
+ ].join(" ");
119
+ return spawn(
120
+ process.env.comspec || "cmd.exe",
121
+ ["/d", "/s", "/c", `"${line}"`],
122
+ {
123
+ ...options,
124
+ windowsVerbatimArguments: true,
125
+ },
126
+ );
127
+ }
128
+ // Nothing resolved: fall through so the ENOENT handler explains.
129
+ return spawn("claude", claudeArgs, options);
130
+ }
131
+
132
+ function claudeNotFoundError() {
133
+ const hint =
134
+ process.platform === "win32"
135
+ ? " npm install -g @anthropic-ai/claude-code\n" +
136
+ "(npm installs a claude.cmd shim — open a new terminal after installing " +
137
+ "so PATH refreshes)"
138
+ : " npm install -g @anthropic-ai/claude-code";
139
+ return new Error(
140
+ `\`claude\` was not found on PATH. Install Claude Code first:\n${hint}`,
141
+ );
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+
146
+ /** Exit status for a signal death, per the shell convention (128 + number). */
147
+ function signalExitCode(signal) {
148
+ return 128 + (os.constants.signals[signal] ?? 1);
149
+ }
150
+
151
+ /**
152
+ * Whether the parent should re-send `signal` to the spawned `claude`.
153
+ *
154
+ * SIGTERM is never terminal-generated, so it only reaches the parent (a
155
+ * programmatic `kill`) and must be forwarded to tear the child down. SIGINT
156
+ * from an interactive TTY was already delivered by the kernel to the whole
157
+ * foreground group (child included), so forwarding it would double-interrupt;
158
+ * only forward SIGINT when we are NOT an interactive TTY (it then reached the
159
+ * parent alone). Exported for tests.
160
+ */
161
+ export function shouldForwardSignal(signal, { isTTY } = {}) {
162
+ if (signal === "SIGINT") return !isTTY;
163
+ return true;
164
+ }
165
+
166
+ /**
167
+ * The env for the spawned `claude`: the parent env with provider-selection
168
+ * vars stripped (they outrank our bearer token in Claude Code's auth
169
+ * precedence), header-injection vars stripped (they would send stale
170
+ * third-party gateway headers to Sail), and alias companion vars stripped
171
+ * (a stale `_SUPPORTED_CAPABILITIES`/`_NAME`/`_DESCRIPTION` would mis-describe
172
+ * the GLM model we repoint the aliases to), then the Sail routing block
173
+ * overlaid. Exported for tests.
174
+ */
175
+ export function buildChildEnv(baseEnv, sailEnv) {
176
+ const childEnv = { ...baseEnv };
177
+ for (const key of [
178
+ ...CONFLICTING_PROVIDER_VARS,
179
+ ...HEADER_INJECTION_VARS,
180
+ ...MODEL_ALIAS_COMPANION_VARS,
181
+ ]) {
182
+ delete childEnv[key];
183
+ }
184
+ return { ...childEnv, ...sailEnv };
185
+ }
186
+
187
+ /**
188
+ * The env block for the temp `--settings` file. Claude Code gives that file
189
+ * the highest settings precedence, but it *merges* with `~/.claude/settings
190
+ * .json` and a project `.claude/settings.json` rather than replacing them, and
191
+ * settings-file env outranks process env. So a conflicting key checked into
192
+ * one of those files (a `CLAUDE_CODE_USE_*` provider selector or
193
+ * `ANTHROPIC_CUSTOM_HEADERS`) would survive `buildChildEnv`'s process-env
194
+ * scrub and still outrank Sail. Neutralize them to empty here so our
195
+ * highest-precedence layer overrides the file value with a falsy one.
196
+ * Exported for tests.
197
+ */
198
+ export function buildSettingsEnv(sailEnv) {
199
+ const settingsEnv = { ...sailEnv };
200
+ for (const key of [...CONFLICTING_PROVIDER_VARS, ...HEADER_INJECTION_VARS]) {
201
+ settingsEnv[key] = "";
202
+ }
203
+ return settingsEnv;
204
+ }
205
+
206
+ /**
207
+ * Spawn `claude` with the Sail env overlaid; resolves to its exit code.
208
+ *
209
+ * Claude Code gives a settings-file `env` block precedence over process
210
+ * environment variables, so a previously persisted `sail-code setup` (or a
211
+ * stale project settings file) would silently override this invocation's
212
+ * flags. Command-line settings outrank both, so the env block also rides a
213
+ * `--settings` temp file (0600 — keeps the token out of argv) unless the
214
+ * user passed their own `--settings` after `--`.
215
+ */
216
+ export async function runClaude({ env, claudeArgs, spawn = spawnClaude }) {
217
+ const userPassedSettings = claudeArgs.some(
218
+ (a) => a === "--settings" || a.startsWith("--settings="),
219
+ );
220
+ let settingsFile = null;
221
+ let args = claudeArgs;
222
+ if (userPassedSettings) {
223
+ console.error(
224
+ "Note: you passed --settings, so Sail routing rides only the process " +
225
+ "env; a settings-file env block (e.g. from `sail-code setup`) will " +
226
+ "override it.",
227
+ );
228
+ } else {
229
+ settingsFile = path.join(
230
+ os.tmpdir(),
231
+ `sail-code-${crypto.randomBytes(8).toString("hex")}.json`,
232
+ );
233
+ try {
234
+ fs.writeFileSync(
235
+ settingsFile,
236
+ JSON.stringify({ env: buildSettingsEnv(env) }, null, 2),
237
+ { mode: 0o600 },
238
+ );
239
+ } catch (err) {
240
+ // writeFileSync opens (creating the file) then writes: a failure during
241
+ // the write (e.g. ENOSPC) leaves a partial temp file that the spawn
242
+ // try/finally below never reaches, since we throw before entering it.
243
+ // Remove it before propagating. (An open failure created nothing, so
244
+ // the unlink just no-ops.)
245
+ try {
246
+ fs.unlinkSync(settingsFile);
247
+ } catch {
248
+ // Never created, or already gone — nothing to clean up.
249
+ }
250
+ settingsFile = null;
251
+ throw err;
252
+ }
253
+ args = ["--settings", settingsFile, ...claudeArgs];
254
+ }
255
+
256
+ const cleanup = () => {
257
+ if (!settingsFile) return;
258
+ try {
259
+ fs.unlinkSync(settingsFile);
260
+ } catch {
261
+ // Already gone, or tmpdir cleanup will get it.
262
+ }
263
+ settingsFile = null;
264
+ };
265
+
266
+ const childEnv = buildChildEnv(process.env, env);
267
+
268
+ try {
269
+ return await new Promise((resolve, reject) => {
270
+ const child = spawn(args, {
271
+ stdio: "inherit",
272
+ env: childEnv,
273
+ });
274
+
275
+ // Ctrl+C in an interactive terminal is delivered by the kernel to the
276
+ // whole foreground process group, so `claude` already receives it
277
+ // directly; forwarding a second SIGINT would make one keypress read as
278
+ // two interrupts (Claude exits instead of just cancelling). Keep the
279
+ // handler regardless — it stops that same SIGINT from killing the parent,
280
+ // which must survive to propagate the child's exit code and clean up —
281
+ // but only re-send per shouldForwardSignal.
282
+ const onSignal = (signal) => {
283
+ if (
284
+ shouldForwardSignal(signal, {
285
+ isTTY: Boolean(process.stdin.isTTY),
286
+ }) &&
287
+ !child.killed
288
+ ) {
289
+ child.kill(signal);
290
+ }
291
+ };
292
+ process.on("SIGINT", onSignal);
293
+ process.on("SIGTERM", onSignal);
294
+ // Detach both handlers on the FIRST of error/exit. A failed spawn
295
+ // (e.g. ENOENT when claude isn't installed) fires `error` and never
296
+ // `exit`, so removing them only in the exit path would leak a SIGINT/
297
+ // SIGTERM listener per call. removeListener is idempotent, so the rare
298
+ // error-then-exit double-fire is harmless.
299
+ const detachSignals = () => {
300
+ process.removeListener("SIGINT", onSignal);
301
+ process.removeListener("SIGTERM", onSignal);
302
+ };
303
+
304
+ child.on("error", (err) => {
305
+ detachSignals();
306
+ reject(err.code === "ENOENT" ? claudeNotFoundError() : err);
307
+ });
308
+
309
+ child.on("exit", (code, signal) => {
310
+ detachSignals();
311
+ resolve(signal ? signalExitCode(signal) : (code ?? 1));
312
+ });
313
+ });
314
+ } finally {
315
+ cleanup();
316
+ }
317
+ }
318
+
319
+ /** The `run` command. */
320
+ export async function run({ modeFlag, model, backgroundModel, claudeArgs }) {
321
+ const { apiKey, apiUrl } = await resolveCredentials({ modeFlag });
322
+ const env = buildClaudeEnv({ apiUrl, apiKey, model, backgroundModel });
323
+ console.error(
324
+ `Running Claude Code on ${env.ANTHROPIC_MODEL} via Sail (${apiUrl}) — ` +
325
+ "no Anthropic account used.",
326
+ );
327
+ return runClaude({ env, claudeArgs });
328
+ }