@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/README.md +87 -0
- package/bin/sail-code.js +7 -0
- package/package.json +30 -0
- package/src/cli.js +166 -0
- package/src/constants.js +177 -0
- package/src/credentials.js +283 -0
- package/src/login.js +453 -0
- package/src/run.js +328 -0
- package/src/setup.js +482 -0
- package/src/toml.js +143 -0
package/src/setup.js
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sail-code setup` — persist the Sail env block into Claude Code's
|
|
3
|
+
* settings.json so a bare `claude` (launched from anywhere) routes to Sail.
|
|
4
|
+
* VS Code extension sessions read this env block too, but the extension's
|
|
5
|
+
* own pre-launch login check does not — account-less users additionally
|
|
6
|
+
* need `claudeCode.environmentVariables` in VS Code's settings (the setup
|
|
7
|
+
* output prints the snippet).
|
|
8
|
+
*
|
|
9
|
+
* The write is reversible: the original file is backed up to
|
|
10
|
+
* `settings.json.sail-backup` before the first change, and `--revert`
|
|
11
|
+
* restores it (or, without a backup, removes exactly the keys we own).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
CONFLICTING_PROVIDER_VARS,
|
|
21
|
+
HEADER_INJECTION_VARS,
|
|
22
|
+
MODEL_ALIAS_COMPANION_VARS,
|
|
23
|
+
OWNED_ENV_KEYS,
|
|
24
|
+
buildClaudeEnv,
|
|
25
|
+
} from "./constants.js";
|
|
26
|
+
import { atomicWrite } from "./credentials.js";
|
|
27
|
+
import { resolveCredentials } from "./run.js";
|
|
28
|
+
|
|
29
|
+
export const BACKUP_SUFFIX = ".sail-backup";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Backup sentinel recorded when the settings file did NOT exist before the
|
|
33
|
+
* first `setup`. A re-run then finds a backup already present (so it won't
|
|
34
|
+
* capture the Sail-injected file as if it were the user's original), and
|
|
35
|
+
* `--revert` sees the sentinel and DELETES the file we created rather than
|
|
36
|
+
* "restoring" a token-bearing file. Deliberately not valid JSON so it can
|
|
37
|
+
* never be mistaken for a real settings backup.
|
|
38
|
+
*/
|
|
39
|
+
export const ABSENT_SENTINEL = "SAIL_ORIGINAL_ABSENT\n";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Merge the Sail env block into a settings object and neutralize any inherited
|
|
43
|
+
* keys that would outrank or mutate our routing once Claude Code reads the
|
|
44
|
+
* settings-file `env` at startup: provider selectors (`CLAUDE_CODE_USE_*`,
|
|
45
|
+
* which precede `ANTHROPIC_AUTH_TOKEN` in the auth precedence), header
|
|
46
|
+
* injectors (`ANTHROPIC_CUSTOM_HEADERS`), and alias companion vars
|
|
47
|
+
* (`ANTHROPIC_DEFAULT_<ALIAS>_MODEL_{NAME,DESCRIPTION,SUPPORTED_CAPABILITIES}`,
|
|
48
|
+
* which would mis-describe the GLM model the alias pins point at). Mirrors the
|
|
49
|
+
* child-env scrub on the `run` path so bare `claude`/VS Code can't silently
|
|
50
|
+
* bypass Sail or mis-advertise GLM's capabilities after `setup`.
|
|
51
|
+
*
|
|
52
|
+
* Deleting a key only clears it when the file we write is the one that holds
|
|
53
|
+
* it — but a conflict can also come from a place our write can't reach by
|
|
54
|
+
* omission, so for those we set a falsy override (like the temp `--settings`
|
|
55
|
+
* layer) instead of deleting:
|
|
56
|
+
* - a sibling `settings.json` that the higher-precedence project
|
|
57
|
+
* `settings.local.json` MERGES over (project scope; we can't see it from
|
|
58
|
+
* here, so we override unconditionally); and
|
|
59
|
+
* - the current shell/profile: a settings-file `env` value overrides the
|
|
60
|
+
* inherited process env, so a shell-exported `CLAUDE_CODE_USE_*` /
|
|
61
|
+
* `ANTHROPIC_CUSTOM_HEADERS` survives a delete-from-file (there's nothing
|
|
62
|
+
* in the file to delete) and would still outrank Sail. We override the
|
|
63
|
+
* keys actually present in `shellEnv` — for either scope — so a clean
|
|
64
|
+
* settings file isn't littered with empty markers.
|
|
65
|
+
* Companion vars stay deleted even then: empty string isn't a documented
|
|
66
|
+
* "unset" for `_SUPPORTED_CAPABILITIES` (it could read as "no capabilities"),
|
|
67
|
+
* so a companion var in a repo-shared `settings.json` is a residual gap.
|
|
68
|
+
*
|
|
69
|
+
* Mutates `settings.env`; returns the keys that were actually removed (so the
|
|
70
|
+
* caller can surface them). `--revert` restores them from the byte backup, or
|
|
71
|
+
* clears the falsy overrides we wrote. Exported so tests exercise the real
|
|
72
|
+
* merge, not a copy.
|
|
73
|
+
*/
|
|
74
|
+
export function mergeSailEnv(
|
|
75
|
+
settings,
|
|
76
|
+
env,
|
|
77
|
+
{ scope = "user", shellEnv = process.env } = {},
|
|
78
|
+
) {
|
|
79
|
+
const merged = { ...(settings.env ?? {}), ...env };
|
|
80
|
+
const removed = [];
|
|
81
|
+
// Companion vars: delete inherited ones (empty string is unsafe for these),
|
|
82
|
+
// but keep the values OUR env block sets — buildClaudeEnv declares
|
|
83
|
+
// `_SUPPORTED_CAPABILITIES` for aliases pinned to the default GLM model so
|
|
84
|
+
// effort/thinking aren't dropped for the custom model ID.
|
|
85
|
+
for (const key of MODEL_ALIAS_COMPANION_VARS) {
|
|
86
|
+
if (key in env) continue; // ours — already overlaid by the spread above
|
|
87
|
+
if (key in merged) removed.push(key);
|
|
88
|
+
delete merged[key];
|
|
89
|
+
}
|
|
90
|
+
for (const key of [...CONFLICTING_PROVIDER_VARS, ...HEADER_INJECTION_VARS]) {
|
|
91
|
+
if (scope === "project" || key in shellEnv) {
|
|
92
|
+
// Falsy override beats a conflict we can't clear by omission: a sibling
|
|
93
|
+
// settings.json this local file merges over (project scope), or a shell-
|
|
94
|
+
// exported var (either scope) that a settings-file env value outranks.
|
|
95
|
+
// revert clears these empty markers.
|
|
96
|
+
merged[key] = "";
|
|
97
|
+
} else if (key in merged) {
|
|
98
|
+
// Our env block never sets these keys, so presence == inherited in the
|
|
99
|
+
// file; user scope is lowest-precedence, so deleting fully clears it.
|
|
100
|
+
removed.push(key);
|
|
101
|
+
delete merged[key];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
settings.env = merged;
|
|
105
|
+
return removed;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function settingsPath(scope) {
|
|
109
|
+
if (scope === "project") {
|
|
110
|
+
// The project scope writes the *local* settings file, not the shared
|
|
111
|
+
// `settings.json`: it carries the API key, and `.claude/settings.local
|
|
112
|
+
// .json` is Claude Code's documented personal/not-committed file (higher
|
|
113
|
+
// precedence than `settings.json`, still below our temp `--settings`). We
|
|
114
|
+
// gitignore it on write since Claude Code only auto-ignores files it
|
|
115
|
+
// creates itself.
|
|
116
|
+
return path.join(process.cwd(), ".claude", "settings.local.json");
|
|
117
|
+
}
|
|
118
|
+
// Honor CLAUDE_CONFIG_DIR: when it's set, Claude Code reads user config from
|
|
119
|
+
// that directory instead of ~/.claude (credentials live at
|
|
120
|
+
// `$CLAUDE_CONFIG_DIR/.credentials.json`, settings at
|
|
121
|
+
// `$CLAUDE_CONFIG_DIR/settings.json`). Writing ~/.claude/settings.json there
|
|
122
|
+
// would report success while the launched session reads the relocated file
|
|
123
|
+
// and ignores the Sail env. Only the user scope relocates; project settings
|
|
124
|
+
// stay in the repo's `.claude/`.
|
|
125
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR?.trim();
|
|
126
|
+
if (configDir) {
|
|
127
|
+
return path.join(configDir, "settings.json");
|
|
128
|
+
}
|
|
129
|
+
return path.join(os.homedir(), ".claude", "settings.json");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Ensure `.claude/settings.local.json` (and its `.sail-backup`) are gitignored,
|
|
134
|
+
* so a project-scoped setup can't leak the API key into a commit. Claude Code
|
|
135
|
+
* auto-ignores this file only when *it* creates it; we create it, so we write a
|
|
136
|
+
* scoped `.claude/.gitignore`. Best-effort: a failure here never blocks setup.
|
|
137
|
+
* Exported for tests.
|
|
138
|
+
*/
|
|
139
|
+
export function ensureLocalSettingsIgnored(filePath) {
|
|
140
|
+
try {
|
|
141
|
+
const claudeDir = path.dirname(filePath);
|
|
142
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
143
|
+
const ignoreFile = path.join(claudeDir, ".gitignore");
|
|
144
|
+
const wanted = ["settings.local.json", "settings.local.json.sail-backup"];
|
|
145
|
+
let lines = [];
|
|
146
|
+
if (fs.existsSync(ignoreFile)) {
|
|
147
|
+
lines = fs.readFileSync(ignoreFile, "utf8").split(/\r?\n/);
|
|
148
|
+
}
|
|
149
|
+
const have = new Set(lines.map((l) => l.trim()));
|
|
150
|
+
const missing = wanted.filter((w) => !have.has(w));
|
|
151
|
+
if (missing.length === 0) return;
|
|
152
|
+
const prefix = lines.length && lines[lines.length - 1] !== "" ? "\n" : "";
|
|
153
|
+
fs.appendFileSync(ignoreFile, prefix + missing.join("\n") + "\n");
|
|
154
|
+
} catch {
|
|
155
|
+
// A missing/unwritable .claude dir surfaces on the settings write instead;
|
|
156
|
+
// never fail setup just because we couldn't touch .gitignore.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The nearest ancestor of `dir` that exists on disk (walks up to the root). */
|
|
161
|
+
function firstExistingDir(dir) {
|
|
162
|
+
let cur = path.resolve(dir);
|
|
163
|
+
while (!fs.existsSync(cur)) {
|
|
164
|
+
const parent = path.dirname(cur);
|
|
165
|
+
if (parent === cur) break; // reached the filesystem root
|
|
166
|
+
cur = parent;
|
|
167
|
+
}
|
|
168
|
+
return cur;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Whether `filePath` is tracked in git. `git ls-files` reads the INDEX, so a
|
|
173
|
+
* file tracked but absent from the working tree (deleted locally, sparse
|
|
174
|
+
* checkout) still counts — but we must run git from a directory that EXISTS,
|
|
175
|
+
* or spawn fails with ENOENT and we'd wrongly read "not tracked". Run from the
|
|
176
|
+
* nearest existing ancestor of the file. Only `true` when git confirms it — a
|
|
177
|
+
* non-repo, a missing git binary, or an untracked file all read as `false`
|
|
178
|
+
* (we refuse only when we're certain, never on a can't-tell). Exported for
|
|
179
|
+
* tests.
|
|
180
|
+
*/
|
|
181
|
+
export function isGitTracked(filePath) {
|
|
182
|
+
// Resolve to an absolute path: a relative filePath (e.g. a relative
|
|
183
|
+
// CLAUDE_CONFIG_DIR like `.claude`) would be interpreted relative to git's
|
|
184
|
+
// cwd (the file's own dir), so the pathspec would miss the real file and
|
|
185
|
+
// wrongly read "not tracked". An absolute pathspec resolves correctly.
|
|
186
|
+
const abs = path.resolve(filePath);
|
|
187
|
+
try {
|
|
188
|
+
execFileSync("git", ["ls-files", "--error-unmatch", "--", abs], {
|
|
189
|
+
cwd: firstExistingDir(path.dirname(abs)),
|
|
190
|
+
stdio: "ignore",
|
|
191
|
+
timeout: 5000,
|
|
192
|
+
});
|
|
193
|
+
return true; // exit 0 → tracked
|
|
194
|
+
} catch {
|
|
195
|
+
return false; // non-zero: untracked, not a repo, or git unavailable
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Whether git considers `filePath` ignored. `git check-ignore` evaluates the
|
|
201
|
+
* effective ignore rules (including parent `.gitignore`s and negations), not
|
|
202
|
+
* just our scoped file, and works on a path that doesn't exist yet.
|
|
203
|
+
* Returns "ignored" (exit 0), "not-ignored" (exit 1 — a real repo where it
|
|
204
|
+
* isn't ignored), or "unknown" (exit 128 / no repo / git missing — can't tell,
|
|
205
|
+
* so no `git add` risk we can confirm). Exported for tests.
|
|
206
|
+
*/
|
|
207
|
+
export function gitIgnoreState(filePath) {
|
|
208
|
+
// Absolute pathspec (see isGitTracked) so a relative filePath can't be
|
|
209
|
+
// misresolved against git's cwd.
|
|
210
|
+
const abs = path.resolve(filePath);
|
|
211
|
+
try {
|
|
212
|
+
execFileSync("git", ["check-ignore", "-q", "--", abs], {
|
|
213
|
+
cwd: firstExistingDir(path.dirname(abs)),
|
|
214
|
+
stdio: "ignore",
|
|
215
|
+
timeout: 5000,
|
|
216
|
+
});
|
|
217
|
+
return "ignored"; // exit 0
|
|
218
|
+
} catch (err) {
|
|
219
|
+
return err && err.status === 1 ? "not-ignored" : "unknown";
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Read settings.json as an object; missing file → {}; malformed → error. */
|
|
224
|
+
function readSettings(filePath) {
|
|
225
|
+
let text;
|
|
226
|
+
try {
|
|
227
|
+
text = fs.readFileSync(filePath, "utf8");
|
|
228
|
+
} catch (err) {
|
|
229
|
+
if (err.code === "ENOENT") return { settings: {}, existed: false };
|
|
230
|
+
throw new Error(`could not read ${filePath}: ${err.message}`);
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
// Strip a UTF-8 BOM (some Windows editors add one; JSON.parse rejects it).
|
|
234
|
+
const settings = JSON.parse(text.replace(/^\uFEFF/, ""));
|
|
235
|
+
if (
|
|
236
|
+
settings === null ||
|
|
237
|
+
typeof settings !== "object" ||
|
|
238
|
+
Array.isArray(settings)
|
|
239
|
+
) {
|
|
240
|
+
throw new Error("top level is not an object");
|
|
241
|
+
}
|
|
242
|
+
return { settings, existed: true, originalText: text };
|
|
243
|
+
} catch (err) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`${filePath} is not valid JSON (${err.message}) — fix it before running setup`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function writeSettings(filePath, settings, { containsSecret = false } = {}) {
|
|
251
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
252
|
+
// Preserve an existing file's permissions in general, but once the content
|
|
253
|
+
// includes the API key, mask group/other bits (0644 → 0600) so the token
|
|
254
|
+
// isn't readable by other local users. Fresh files get 0600. A --revert
|
|
255
|
+
// restores the original file (and its original mode) from the backup.
|
|
256
|
+
atomicWrite(filePath, JSON.stringify(settings, null, 2) + "\n", {
|
|
257
|
+
preserveMode: true,
|
|
258
|
+
tighten: containsSecret,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Refuse to write a secret into a git-tracked settings file (or its backup):
|
|
264
|
+
* `.gitignore` can't untrack an already-tracked file, so the token would show
|
|
265
|
+
* in `git diff`/`status` and could be committed. Applies to BOTH scopes — user
|
|
266
|
+
* settings can live in a tracked `~/.claude` dotfiles checkout or a
|
|
267
|
+
* `CLAUDE_CONFIG_DIR` inside a repo, not just project settings. Throws before
|
|
268
|
+
* any key is minted or written (resolveCredentials can trigger a browser login).
|
|
269
|
+
*/
|
|
270
|
+
function assertSettingsNotGitTracked(filePath) {
|
|
271
|
+
for (const p of [filePath, filePath + BACKUP_SUFFIX]) {
|
|
272
|
+
if (isGitTracked(p)) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
`${p} is tracked in git — setup won't write a secret into a tracked ` +
|
|
275
|
+
"file (a .gitignore entry won't untrack it). Untrack it with `git rm " +
|
|
276
|
+
"--cached`, or set SAIL_API_KEY in your environment instead (nothing " +
|
|
277
|
+
"is persisted then).",
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Apply the Sail env block to settings.json (backing up the original). */
|
|
284
|
+
export async function setup({
|
|
285
|
+
scope = "user",
|
|
286
|
+
modeFlag,
|
|
287
|
+
model,
|
|
288
|
+
backgroundModel,
|
|
289
|
+
}) {
|
|
290
|
+
const filePath = settingsPath(scope);
|
|
291
|
+
const backupPath = filePath + BACKUP_SUFFIX;
|
|
292
|
+
// Never write a secret into a git-tracked file — for EITHER scope. Project
|
|
293
|
+
// settings live in the repo, but user settings can too (a tracked ~/.claude
|
|
294
|
+
// dotfiles checkout, or CLAUDE_CONFIG_DIR pointing inside a repo). Refuse
|
|
295
|
+
// before a key is minted/written rather than leak it.
|
|
296
|
+
assertSettingsNotGitTracked(filePath);
|
|
297
|
+
|
|
298
|
+
if (scope === "project") {
|
|
299
|
+
// Ensure both the file and its backup are git-ignored BEFORE we resolve/
|
|
300
|
+
// mint a key or write the token. A read-only or locked `.claude/.gitignore`
|
|
301
|
+
// makes the append fail silently, which would leave the secret stageable by
|
|
302
|
+
// `git add .` despite the "added ignore rules" message. Append, then verify
|
|
303
|
+
// with `git check-ignore`; refuse if a real repo confirms either still
|
|
304
|
+
// isn't ignored (no key minted, no token written yet).
|
|
305
|
+
ensureLocalSettingsIgnored(filePath);
|
|
306
|
+
for (const p of [filePath, backupPath]) {
|
|
307
|
+
if (gitIgnoreState(p) === "not-ignored") {
|
|
308
|
+
throw new Error(
|
|
309
|
+
`couldn't ensure ${p} is git-ignored (is .claude/.gitignore ` +
|
|
310
|
+
"writable?) — refusing to write a secret where `git add` could " +
|
|
311
|
+
"stage it. Fix that, or use `--user` instead.",
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
console.error(
|
|
316
|
+
`Note: this writes your Sail API key into ${filePath} (Claude Code's ` +
|
|
317
|
+
"personal, not-shared project settings), now git-ignored via " +
|
|
318
|
+
"`.claude/.gitignore`. Double-check it stays out of commits, or use " +
|
|
319
|
+
"`--user` instead.",
|
|
320
|
+
);
|
|
321
|
+
} else if (
|
|
322
|
+
gitIgnoreState(filePath) === "not-ignored" ||
|
|
323
|
+
gitIgnoreState(backupPath) === "not-ignored"
|
|
324
|
+
) {
|
|
325
|
+
// User settings resolved INSIDE a git repo (dotfiles / CLAUDE_CONFIG_DIR)
|
|
326
|
+
// and aren't ignored: not tracked yet, but a `git add .` there could stage
|
|
327
|
+
// the token. Fail closed like the tracked-file and project-scope guards —
|
|
328
|
+
// a warning scrolls past and the key still lands where git can stage it.
|
|
329
|
+
// We don't edit the user's own dotfiles-repo .gitignore for them; the
|
|
330
|
+
// error says how to fix it. Throws before any key is minted/written.
|
|
331
|
+
throw new Error(
|
|
332
|
+
`${filePath} is inside a git repo and isn't git-ignored — a ` +
|
|
333
|
+
"`git add .` there could stage your Sail API key. Add it (and its " +
|
|
334
|
+
".sail-backup) to that repo's .gitignore, or set SAIL_API_KEY in " +
|
|
335
|
+
"your environment instead (nothing is persisted then).",
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const { apiKey, apiUrl } = await resolveCredentials({ modeFlag });
|
|
340
|
+
const env = buildClaudeEnv({ apiUrl, apiKey, model, backgroundModel });
|
|
341
|
+
|
|
342
|
+
const { settings, existed } = readSettings(filePath);
|
|
343
|
+
// Create the settings dir before writing the backup/sentinel. On a fresh
|
|
344
|
+
// machine (no ~/.claude, or a brand-new CLAUDE_CONFIG_DIR) the sentinel write
|
|
345
|
+
// below would otherwise ENOENT — after the key was already minted — since
|
|
346
|
+
// writeSettings only creates the parent later. mkdir is a no-op when it
|
|
347
|
+
// exists (and project scope already made it via ensureLocalSettingsIgnored).
|
|
348
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
349
|
+
// The FIRST setup records the true pre-Sail state so --revert restores it;
|
|
350
|
+
// a re-run must never overwrite that record with an already-Sail-injected
|
|
351
|
+
// file. When the file existed, back up its bytes (carrying its mode). When
|
|
352
|
+
// it did NOT, write the absent-sentinel so a re-run (file now present)
|
|
353
|
+
// doesn't capture the Sail file as the "original", and --revert deletes
|
|
354
|
+
// what we created instead of leaving the API key behind. Both writes are
|
|
355
|
+
// create-exclusive (COPYFILE_EXCL / "wx"): a plain existsSync check would
|
|
356
|
+
// race two concurrent setups, letting the second clobber the first's backup
|
|
357
|
+
// — which by then may already hold the Sail-injected file — and lose the
|
|
358
|
+
// real original. An atomic create fails with EEXIST instead; that just means
|
|
359
|
+
// a prior/concurrent setup already recorded the state, so it's expected.
|
|
360
|
+
try {
|
|
361
|
+
if (existed) {
|
|
362
|
+
fs.copyFileSync(filePath, backupPath, fs.constants.COPYFILE_EXCL);
|
|
363
|
+
} else {
|
|
364
|
+
fs.writeFileSync(backupPath, ABSENT_SENTINEL, {
|
|
365
|
+
mode: 0o600,
|
|
366
|
+
flag: "wx",
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
} catch (err) {
|
|
370
|
+
if (err.code !== "EEXIST") throw err;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const removedKeys = mergeSailEnv(settings, env, { scope });
|
|
374
|
+
writeSettings(filePath, settings, { containsSecret: true });
|
|
375
|
+
|
|
376
|
+
if (removedKeys.length > 0) {
|
|
377
|
+
console.error(
|
|
378
|
+
`Removed conflicting env ${removedKeys.join(", ")} from ${filePath} so ` +
|
|
379
|
+
"it can't outrank or mutate Sail routing (--revert restores them).",
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
if (scope === "project") {
|
|
383
|
+
console.error(
|
|
384
|
+
"Set empty overrides for provider/header env in the local file so they " +
|
|
385
|
+
"beat any conflicting value in a repo-shared `.claude/settings.json` " +
|
|
386
|
+
"(--revert clears them).",
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
const shellConflicts = [
|
|
390
|
+
...CONFLICTING_PROVIDER_VARS,
|
|
391
|
+
...HEADER_INJECTION_VARS,
|
|
392
|
+
].filter((key) => key in process.env);
|
|
393
|
+
if (shellConflicts.length > 0) {
|
|
394
|
+
console.error(
|
|
395
|
+
`Your shell exports ${shellConflicts.join(", ")} — wrote empty overrides ` +
|
|
396
|
+
`in ${filePath} so bare \`claude\` still routes to Sail (a settings-file ` +
|
|
397
|
+
"env value outranks the shell). --revert clears them; you can also unset " +
|
|
398
|
+
"them in your shell profile.",
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const revertCmd =
|
|
403
|
+
"sail-code setup --revert" + (scope === "project" ? " --project" : "");
|
|
404
|
+
// User settings are the LOWEST-precedence settings source, so a repo that
|
|
405
|
+
// already carries `.claude/settings.json` (or settings.local.json) with its
|
|
406
|
+
// own ANTHROPIC_BASE_URL / provider selector overrides this write inside
|
|
407
|
+
// that repo. Don't claim it applies everywhere — point those repos at
|
|
408
|
+
// `--project`. Project scope writes the near-top local file, so it does win.
|
|
409
|
+
const reach =
|
|
410
|
+
scope === "project"
|
|
411
|
+
? "This applies to `claude` launched from THIS directory — Claude Code " +
|
|
412
|
+
"reads project settings from the launch directory (verified " +
|
|
413
|
+
"empirically; not the git root), so run setup where you run `claude`. " +
|
|
414
|
+
"Project settings outrank user settings."
|
|
415
|
+
: "This applies to `claude` launched from anywhere, EXCEPT a repo whose " +
|
|
416
|
+
"own `.claude/settings.json` sets a conflicting ANTHROPIC_BASE_URL / " +
|
|
417
|
+
"provider var — those outrank user settings; run `setup --project` " +
|
|
418
|
+
"there.";
|
|
419
|
+
console.error(
|
|
420
|
+
`Wrote Sail routing to ${filePath}` +
|
|
421
|
+
(existed ? ` (original backed up to ${backupPath})` : "") +
|
|
422
|
+
" — permissions set to owner-only since the file now holds your API key." +
|
|
423
|
+
"\n" +
|
|
424
|
+
reach +
|
|
425
|
+
" Undo anytime with: " +
|
|
426
|
+
revertCmd +
|
|
427
|
+
"\n\nVS Code note: the extension reads this file for its sessions, but " +
|
|
428
|
+
"its own pre-launch login check does not — if you have no saved " +
|
|
429
|
+
"Anthropic login, also add these to VS Code's user settings " +
|
|
430
|
+
"(Preferences: Open User Settings (JSON)), then reload the window:\n" +
|
|
431
|
+
` "claudeCode.disableLoginPrompt": true,\n` +
|
|
432
|
+
` "claudeCode.environmentVariables": [\n` +
|
|
433
|
+
` { "name": "ANTHROPIC_BASE_URL", "value": "${apiUrl}" },\n` +
|
|
434
|
+
` { "name": "ANTHROPIC_AUTH_TOKEN", "value": "<your SAIL_API_KEY>" }\n` +
|
|
435
|
+
` ]`,
|
|
436
|
+
);
|
|
437
|
+
return 0;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Restore the pre-setup settings.json, or strip exactly our keys. */
|
|
441
|
+
export function revert({ scope = "user" }) {
|
|
442
|
+
const filePath = settingsPath(scope);
|
|
443
|
+
const backupPath = filePath + BACKUP_SUFFIX;
|
|
444
|
+
|
|
445
|
+
if (fs.existsSync(backupPath)) {
|
|
446
|
+
if (fs.readFileSync(backupPath, "utf8") === ABSENT_SENTINEL) {
|
|
447
|
+
// setup created this file (nothing existed before). Remove what we made
|
|
448
|
+
// — deleting the API key — rather than "restoring" the Sail-injected
|
|
449
|
+
// file. Clear the sentinel too so the slate is truly clean.
|
|
450
|
+
fs.rmSync(filePath, { force: true });
|
|
451
|
+
fs.rmSync(backupPath, { force: true });
|
|
452
|
+
console.error(`Removed ${filePath} (it did not exist before setup).`);
|
|
453
|
+
return 0;
|
|
454
|
+
}
|
|
455
|
+
fs.renameSync(backupPath, filePath);
|
|
456
|
+
console.error(`Restored ${filePath} from ${backupPath}.`);
|
|
457
|
+
return 0;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const { settings, existed } = readSettings(filePath);
|
|
461
|
+
if (!existed) {
|
|
462
|
+
console.error(`Nothing to revert: ${filePath} does not exist.`);
|
|
463
|
+
return 0;
|
|
464
|
+
}
|
|
465
|
+
if (settings.env && typeof settings.env === "object") {
|
|
466
|
+
for (const key of OWNED_ENV_KEYS) delete settings.env[key];
|
|
467
|
+
// Clear the falsy overrides a project-scope setup wrote, but only when
|
|
468
|
+
// still empty — a real value re-added after setup is the user's, not ours.
|
|
469
|
+
for (const key of [
|
|
470
|
+
...CONFLICTING_PROVIDER_VARS,
|
|
471
|
+
...HEADER_INJECTION_VARS,
|
|
472
|
+
]) {
|
|
473
|
+
if (settings.env[key] === "") delete settings.env[key];
|
|
474
|
+
}
|
|
475
|
+
if (Object.keys(settings.env).length === 0) delete settings.env;
|
|
476
|
+
}
|
|
477
|
+
writeSettings(filePath, settings);
|
|
478
|
+
console.error(
|
|
479
|
+
`Removed Sail routing keys from ${filePath} (no backup was present).`,
|
|
480
|
+
);
|
|
481
|
+
return 0;
|
|
482
|
+
}
|
package/src/toml.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal TOML subset shared with the Sail CLI's `~/.sail` files: flat
|
|
3
|
+
* `key = "value"` tables with comments. This is not a general TOML parser —
|
|
4
|
+
* it reads exactly the shape `sail auth` / `sail config` write (plus bare
|
|
5
|
+
* unquoted values from hand edits) and fails loudly on anything else, the
|
|
6
|
+
* same posture as the Rust CLI.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Parse a flat TOML table into a plain object of string values. */
|
|
10
|
+
export function parseTomlTable(text, sourcePath) {
|
|
11
|
+
const values = {};
|
|
12
|
+
const lines = text.split(/\r?\n/);
|
|
13
|
+
for (let i = 0; i < lines.length; i++) {
|
|
14
|
+
const line = lines[i].trim();
|
|
15
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
16
|
+
const eq = line.indexOf("=");
|
|
17
|
+
if (eq === -1) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`could not parse ${sourcePath} (line ${i + 1}: expected "key = value"). ` +
|
|
20
|
+
`Edit the file to fix it, or delete it and log in again.`,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
const key = line.slice(0, eq).trim();
|
|
24
|
+
if (!/^[A-Za-z0-9_-]+$/.test(key)) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`could not parse ${sourcePath} (line ${i + 1}: invalid key "${key}"). ` +
|
|
27
|
+
`Edit the file to fix it, or delete it and log in again.`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const rawValue = line.slice(eq + 1).trim();
|
|
31
|
+
values[key] = parseTomlValue(rawValue, sourcePath, i + 1);
|
|
32
|
+
}
|
|
33
|
+
return values;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseTomlValue(raw, sourcePath, lineNo) {
|
|
37
|
+
if (raw.startsWith('"')) {
|
|
38
|
+
return parseBasicString(raw, sourcePath, lineNo);
|
|
39
|
+
}
|
|
40
|
+
if (raw.startsWith("'")) {
|
|
41
|
+
// TOML literal string: verbatim content, no escapes.
|
|
42
|
+
const end = raw.indexOf("'", 1);
|
|
43
|
+
if (end === -1) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`could not parse ${sourcePath} (line ${lineNo}: unterminated string).`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const rest = raw.slice(end + 1).trim();
|
|
49
|
+
if (rest !== "" && !rest.startsWith("#")) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`could not parse ${sourcePath} (line ${lineNo}: trailing content after string).`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return raw.slice(1, end);
|
|
55
|
+
}
|
|
56
|
+
// Bare value (hand-edited file): take up to a trailing comment.
|
|
57
|
+
const hash = raw.indexOf("#");
|
|
58
|
+
const bare = (hash === -1 ? raw : raw.slice(0, hash)).trim();
|
|
59
|
+
if (bare === "") {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`could not parse ${sourcePath} (line ${lineNo}: empty value). ` +
|
|
62
|
+
`Edit the file to fix it, or delete it and log in again.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return bare;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseBasicString(raw, sourcePath, lineNo) {
|
|
69
|
+
let out = "";
|
|
70
|
+
let i = 1;
|
|
71
|
+
while (i < raw.length) {
|
|
72
|
+
const ch = raw[i];
|
|
73
|
+
if (ch === '"') {
|
|
74
|
+
const rest = raw.slice(i + 1).trim();
|
|
75
|
+
if (rest !== "" && !rest.startsWith("#")) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`could not parse ${sourcePath} (line ${lineNo}: trailing content after string).`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
if (ch === "\\") {
|
|
83
|
+
const esc = raw[i + 1];
|
|
84
|
+
const simple = {
|
|
85
|
+
'"': '"',
|
|
86
|
+
"\\": "\\",
|
|
87
|
+
b: "\b",
|
|
88
|
+
t: "\t",
|
|
89
|
+
n: "\n",
|
|
90
|
+
f: "\f",
|
|
91
|
+
r: "\r",
|
|
92
|
+
};
|
|
93
|
+
if (esc in simple) {
|
|
94
|
+
out += simple[esc];
|
|
95
|
+
i += 2;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (esc === "u" || esc === "U") {
|
|
99
|
+
const width = esc === "u" ? 4 : 8;
|
|
100
|
+
const hex = raw.slice(i + 2, i + 2 + width);
|
|
101
|
+
if (!new RegExp(`^[0-9A-Fa-f]{${width}}$`).test(hex)) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`could not parse ${sourcePath} (line ${lineNo}: bad \\${esc} escape).`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
out += String.fromCodePoint(parseInt(hex, 16));
|
|
107
|
+
i += 2 + width;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
throw new Error(
|
|
111
|
+
`could not parse ${sourcePath} (line ${lineNo}: unsupported escape \\${esc ?? ""}).`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
out += ch;
|
|
115
|
+
i += 1;
|
|
116
|
+
}
|
|
117
|
+
throw new Error(
|
|
118
|
+
`could not parse ${sourcePath} (line ${lineNo}: unterminated string).`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Render a value as a TOML basic string with control characters escaped,
|
|
124
|
+
* byte-identical to the Rust CLI's `toml_basic_string`, so files written by
|
|
125
|
+
* either tool round-trip through the other.
|
|
126
|
+
*/
|
|
127
|
+
export function tomlBasicString(value) {
|
|
128
|
+
let out = '"';
|
|
129
|
+
for (const ch of String(value)) {
|
|
130
|
+
const code = ch.codePointAt(0);
|
|
131
|
+
if (ch === '"') out += '\\"';
|
|
132
|
+
else if (ch === "\\") out += "\\\\";
|
|
133
|
+
else if (ch === "\b") out += "\\b";
|
|
134
|
+
else if (ch === "\t") out += "\\t";
|
|
135
|
+
else if (ch === "\n") out += "\\n";
|
|
136
|
+
else if (ch === "\f") out += "\\f";
|
|
137
|
+
else if (ch === "\r") out += "\\r";
|
|
138
|
+
else if (code < 0x20 || code === 0x7f) {
|
|
139
|
+
out += `\\u${code.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
140
|
+
} else out += ch;
|
|
141
|
+
}
|
|
142
|
+
return out + '"';
|
|
143
|
+
}
|