@sailresearch/code 0.1.0 → 0.1.2
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 +43 -4
- package/package.json +1 -1
- package/src/cli.js +26 -0
- package/src/constants.js +78 -2
- package/src/run.js +83 -2
- package/src/setup.js +231 -0
package/README.md
CHANGED
|
@@ -16,6 +16,21 @@ persist nothing.
|
|
|
16
16
|
|
|
17
17
|
Claude Code itself must be installed (`npm install -g @anthropic-ai/claude-code`).
|
|
18
18
|
|
|
19
|
+
## Trusted workspaces (no permission prompts)
|
|
20
|
+
|
|
21
|
+
For trusted repos, containers, or VMs where you want long-running agent tasks
|
|
22
|
+
without clicking through Claude Code permission prompts:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npx @sailresearch/code --trusted
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`--trusted` launches Claude Code in bypass-permissions mode. This avoids Claude
|
|
29
|
+
Code's auto-mode safety classifier path, so it also avoids failures like
|
|
30
|
+
`safety classifier temporarily unavailable` when Sail model requests are
|
|
31
|
+
otherwise healthy. Use it only where you are comfortable letting Claude Code run
|
|
32
|
+
tools without prompting.
|
|
33
|
+
|
|
19
34
|
## Persistent setup / VS Code
|
|
20
35
|
|
|
21
36
|
To make `claude` route to Sail without the launcher (for VS Code, or a bare
|
|
@@ -49,12 +64,36 @@ Notes:
|
|
|
49
64
|
## Choosing models
|
|
50
65
|
|
|
51
66
|
```sh
|
|
52
|
-
npx @sailresearch/code --model
|
|
67
|
+
npx @sailresearch/code --model moonshotai/Kimi-K2.6
|
|
53
68
|
```
|
|
54
69
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
70
|
+
GLM-5.2 is the default. The `/model` picker also exposes Kimi-K2.6 via Sail.
|
|
71
|
+
Use `--model` with any model from the [Sail catalog](https://docs.sailresearch.com/models)
|
|
72
|
+
to launch directly into that model.
|
|
73
|
+
|
|
74
|
+
The launcher and `setup` write Claude Code's `availableModels` setting so fresh
|
|
75
|
+
local sessions show only Sail-backed picker rows. Claude Code concatenates
|
|
76
|
+
non-managed `availableModels` from user, project, local, and temporary settings,
|
|
77
|
+
though, so rows from another settings scope may still appear. Rows that name
|
|
78
|
+
non-Sail models (for example `claude-*` IDs) still route to Sail and will not be
|
|
79
|
+
served there; remove them from the other settings file or enforce the picker
|
|
80
|
+
with managed settings. `sail-code` prints a warning when it can see those extra
|
|
81
|
+
rows.
|
|
82
|
+
|
|
83
|
+
The launcher does **not** force a completion window: Claude Code has no flag
|
|
84
|
+
for adding request metadata, so requests go out without `completion_window` and
|
|
85
|
+
Sail's API default applies. See [Completion windows](https://docs.sailresearch.com/completion-windows)
|
|
86
|
+
for the latency/price tradeoff and how to opt into another tier.
|
|
87
|
+
|
|
88
|
+
## Commit attribution
|
|
89
|
+
|
|
90
|
+
`setup` sets Claude Code's `attribution.commit` to a Sail co-author trailer
|
|
91
|
+
naming the resolved model (e.g.
|
|
92
|
+
`Co-Authored-By: GLM-5.2 via Sail <noreply@sailresearch.com>` — a `--model`
|
|
93
|
+
override names that model instead) so commits the agent makes are attributed to
|
|
94
|
+
the Sail-served model instead of Anthropic's default trailer; `attribution.pr`
|
|
95
|
+
is emptied. `setup --revert` restores your original `attribution` (from the
|
|
96
|
+
backup) or removes the trailer if the settings file was created by `sail-code`.
|
|
58
97
|
|
|
59
98
|
## Passing flags to claude
|
|
60
99
|
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -15,6 +15,12 @@ import { resolveCredentials, run } from "./run.js";
|
|
|
15
15
|
import { revert, setup } from "./setup.js";
|
|
16
16
|
|
|
17
17
|
const require = createRequire(import.meta.url);
|
|
18
|
+
const TRUSTED_CLAUDE_ARGS = ["--permission-mode", "bypassPermissions"];
|
|
19
|
+
const CLAUDE_PERMISSION_FLAGS = [
|
|
20
|
+
"--permission-mode",
|
|
21
|
+
"--dangerously-skip-permissions",
|
|
22
|
+
"--allow-dangerously-skip-permissions",
|
|
23
|
+
];
|
|
18
24
|
|
|
19
25
|
const HELP = `sail-code — run Claude Code on GLM-5.2 via Sail (no Anthropic account)
|
|
20
26
|
|
|
@@ -29,6 +35,7 @@ Flags:
|
|
|
29
35
|
--model <id> Main model (default ${DEFAULT_MODEL})
|
|
30
36
|
--background-model <id> Background/haiku-tier model (default: same as --model)
|
|
31
37
|
--mode <name> Sail environment: ${MODE_VALUES.join(" | ")}
|
|
38
|
+
--trusted Skip Claude Code permission prompts in trusted workspaces
|
|
32
39
|
-h, --help Show this help
|
|
33
40
|
-v, --version Show the version
|
|
34
41
|
|
|
@@ -44,6 +51,7 @@ export function parseArgs(argv) {
|
|
|
44
51
|
modeFlag: undefined,
|
|
45
52
|
scope: undefined,
|
|
46
53
|
revert: false,
|
|
54
|
+
trusted: false,
|
|
47
55
|
help: false,
|
|
48
56
|
version: false,
|
|
49
57
|
claudeArgs: [],
|
|
@@ -85,6 +93,7 @@ export function parseArgs(argv) {
|
|
|
85
93
|
} else if (arg === "--user") parsed.scope = "user";
|
|
86
94
|
else if (arg === "--project") parsed.scope = "project";
|
|
87
95
|
else if (arg === "--revert") parsed.revert = true;
|
|
96
|
+
else if (arg === "--trusted") parsed.trusted = true;
|
|
88
97
|
else if (arg === "-h" || arg === "--help") parsed.help = true;
|
|
89
98
|
else if (arg === "-v" || arg === "--version") parsed.version = true;
|
|
90
99
|
else
|
|
@@ -99,6 +108,22 @@ export function parseArgs(argv) {
|
|
|
99
108
|
if (parsed.scope && parsed.command !== "setup") {
|
|
100
109
|
throw new Error(`--${parsed.scope} only applies to setup`);
|
|
101
110
|
}
|
|
111
|
+
if (parsed.trusted && parsed.command !== "run") {
|
|
112
|
+
throw new Error("--trusted only applies when launching claude");
|
|
113
|
+
}
|
|
114
|
+
if (parsed.trusted) {
|
|
115
|
+
const conflict = parsed.claudeArgs.find((arg) =>
|
|
116
|
+
CLAUDE_PERMISSION_FLAGS.some(
|
|
117
|
+
(flag) => arg === flag || arg.startsWith(`${flag}=`),
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
if (conflict) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`--trusted conflicts with Claude permission flag "${conflict}" after --`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
parsed.claudeArgs = [...TRUSTED_CLAUDE_ARGS, ...parsed.claudeArgs];
|
|
126
|
+
}
|
|
102
127
|
return parsed;
|
|
103
128
|
}
|
|
104
129
|
|
|
@@ -159,6 +184,7 @@ export async function main() {
|
|
|
159
184
|
modeFlag: args.modeFlag,
|
|
160
185
|
model: args.model,
|
|
161
186
|
backgroundModel: args.backgroundModel,
|
|
187
|
+
trusted: args.trusted,
|
|
162
188
|
claudeArgs: args.claudeArgs,
|
|
163
189
|
});
|
|
164
190
|
}
|
package/src/constants.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
export const DEFAULT_MODEL = "zai-org/GLM-5.2-FP8";
|
|
11
|
+
export const KIMI_MODEL = "moonshotai/Kimi-K2.6";
|
|
11
12
|
|
|
12
13
|
/** Public API targets by mode name, mirroring the Sail CLI's mode map. */
|
|
13
14
|
export const MODE_API_URLS = {
|
|
@@ -44,6 +45,68 @@ export const DEFAULT_APP_URL = "https://app.sailresearch.com";
|
|
|
44
45
|
*/
|
|
45
46
|
export const DEFAULT_MODEL_CAPABILITIES = "effort,thinking";
|
|
46
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Display metadata for aliases pinned to the DEFAULT (GLM) model. Claude Code
|
|
50
|
+
* otherwise renders the pinned slots with built-in Anthropic-tier names and
|
|
51
|
+
* pricing (e.g. "Custom Opus model", "$5/$25 per Mtok"), which is wrong for a
|
|
52
|
+
* Sail-served GLM row. `_NAME` is the picker label; `_DESCRIPTION` is the
|
|
53
|
+
* per-row subtitle, where we surface Sail's standard-tier pricing for GLM-5.2
|
|
54
|
+
* (see config/models.json: $0.50 input / $2.50 output per 1M tokens). Like
|
|
55
|
+
* `_SUPPORTED_CAPABILITIES`, these are keyed to the ALIAS, so we only set them
|
|
56
|
+
* for aliases pinned to the known default model — a user-overridden --model
|
|
57
|
+
* gets no labels (built-in detection) rather than a mislabel.
|
|
58
|
+
*/
|
|
59
|
+
export const DEFAULT_MODEL_NAME = "GLM-5.2 via Sail";
|
|
60
|
+
export const DEFAULT_MODEL_DESCRIPTION =
|
|
61
|
+
"Sail standard pricing: $0.50 input / $2.50 output per 1M tokens";
|
|
62
|
+
export const KIMI_MODEL_NAME = "Kimi-K2.6 via Sail";
|
|
63
|
+
export const KIMI_MODEL_DESCRIPTION =
|
|
64
|
+
"Sail pricing: priority $0.45 input / $3.00 output, ASAP $1.00 input / $4.00 output per 1M tokens";
|
|
65
|
+
export const KIMI_MODEL_CAPABILITIES = "effort,thinking,xhigh_effort";
|
|
66
|
+
|
|
67
|
+
export function buildModelPickerSettings(
|
|
68
|
+
mainModel = DEFAULT_MODEL,
|
|
69
|
+
backgroundModel = mainModel,
|
|
70
|
+
) {
|
|
71
|
+
return {
|
|
72
|
+
availableModels: [
|
|
73
|
+
...new Set([
|
|
74
|
+
DEFAULT_MODEL,
|
|
75
|
+
mainModel || DEFAULT_MODEL,
|
|
76
|
+
backgroundModel || mainModel || DEFAULT_MODEL,
|
|
77
|
+
KIMI_MODEL,
|
|
78
|
+
]),
|
|
79
|
+
],
|
|
80
|
+
enforceAvailableModels: true,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function extraAvailableModels(settings, mainModel, backgroundModel) {
|
|
85
|
+
if (!Array.isArray(settings?.availableModels)) return [];
|
|
86
|
+
const allowed = new Set(
|
|
87
|
+
buildModelPickerSettings(mainModel, backgroundModel).availableModels,
|
|
88
|
+
);
|
|
89
|
+
return [
|
|
90
|
+
...new Set(
|
|
91
|
+
settings.availableModels.filter(
|
|
92
|
+
(model) => typeof model === "string" && !allowed.has(model),
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function formatExtraAvailableModelsWarning(source, extras) {
|
|
99
|
+
if (extras.length === 0) return null;
|
|
100
|
+
return (
|
|
101
|
+
`Warning: ${source} already allows model(s) outside Sail's picker list: ` +
|
|
102
|
+
`${extras.join(", ")}. Claude Code concatenates non-managed ` +
|
|
103
|
+
"`availableModels` across user/project/local settings, so those rows " +
|
|
104
|
+
"may remain selectable. Rows that name non-Sail models (for example " +
|
|
105
|
+
"`claude-*` IDs) still route to Sail and will not be served there. Remove " +
|
|
106
|
+
"them from that settings file or enforce the picker with managed settings."
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
47
110
|
export function buildClaudeEnv({ apiUrl, apiKey, model, backgroundModel }) {
|
|
48
111
|
const base = String(apiUrl ?? "").replace(/\/+$/, "");
|
|
49
112
|
const main = model || DEFAULT_MODEL;
|
|
@@ -63,6 +126,10 @@ export function buildClaudeEnv({ apiUrl, apiKey, model, backgroundModel }) {
|
|
|
63
126
|
if (m === DEFAULT_MODEL) {
|
|
64
127
|
capabilities[`ANTHROPIC_DEFAULT_${alias}_MODEL_SUPPORTED_CAPABILITIES`] =
|
|
65
128
|
DEFAULT_MODEL_CAPABILITIES;
|
|
129
|
+
capabilities[`ANTHROPIC_DEFAULT_${alias}_MODEL_NAME`] =
|
|
130
|
+
DEFAULT_MODEL_NAME;
|
|
131
|
+
capabilities[`ANTHROPIC_DEFAULT_${alias}_MODEL_DESCRIPTION`] =
|
|
132
|
+
DEFAULT_MODEL_DESCRIPTION;
|
|
66
133
|
}
|
|
67
134
|
}
|
|
68
135
|
return {
|
|
@@ -78,8 +145,17 @@ export function buildClaudeEnv({ apiUrl, apiKey, model, backgroundModel }) {
|
|
|
78
145
|
ANTHROPIC_DEFAULT_SONNET_MODEL: main,
|
|
79
146
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: background,
|
|
80
147
|
ANTHROPIC_DEFAULT_FABLE_MODEL: main,
|
|
81
|
-
//
|
|
82
|
-
//
|
|
148
|
+
// Add a single explicit Kimi row to the /model picker. Claude Code supports
|
|
149
|
+
// only one custom option this way; keep aliases pinned separately above so
|
|
150
|
+
// built-in Claude IDs cannot leak through to Sail.
|
|
151
|
+
ANTHROPIC_CUSTOM_MODEL_OPTION: KIMI_MODEL,
|
|
152
|
+
ANTHROPIC_CUSTOM_MODEL_OPTION_NAME: KIMI_MODEL_NAME,
|
|
153
|
+
ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION: KIMI_MODEL_DESCRIPTION,
|
|
154
|
+
ANTHROPIC_CUSTOM_MODEL_OPTION_SUPPORTED_CAPABILITIES:
|
|
155
|
+
KIMI_MODEL_CAPABILITIES,
|
|
156
|
+
// Declare effort/thinking and friendly display metadata for aliases pinned
|
|
157
|
+
// to the default GLM model (see DEFAULT_MODEL_CAPABILITIES / _NAME /
|
|
158
|
+
// _DESCRIPTION). Custom overrides get no declaration.
|
|
83
159
|
...capabilities,
|
|
84
160
|
// Pin subagents to the main Sail model. CLAUDE_CODE_SUBAGENT_MODEL is the
|
|
85
161
|
// highest-priority subagent model source — it outranks a subagent's
|
package/src/run.js
CHANGED
|
@@ -14,6 +14,9 @@ import {
|
|
|
14
14
|
HEADER_INJECTION_VARS,
|
|
15
15
|
MODEL_ALIAS_COMPANION_VARS,
|
|
16
16
|
buildClaudeEnv,
|
|
17
|
+
buildModelPickerSettings,
|
|
18
|
+
extraAvailableModels,
|
|
19
|
+
formatExtraAvailableModelsWarning,
|
|
17
20
|
} from "./constants.js";
|
|
18
21
|
import {
|
|
19
22
|
loadAuthKey,
|
|
@@ -203,6 +206,72 @@ export function buildSettingsEnv(sailEnv) {
|
|
|
203
206
|
return settingsEnv;
|
|
204
207
|
}
|
|
205
208
|
|
|
209
|
+
export function buildRuntimeSettings(sailEnv) {
|
|
210
|
+
return {
|
|
211
|
+
env: buildSettingsEnv(sailEnv),
|
|
212
|
+
...buildModelPickerSettings(
|
|
213
|
+
sailEnv.ANTHROPIC_MODEL,
|
|
214
|
+
sailEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL,
|
|
215
|
+
),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function userClaudeSettingsPath() {
|
|
220
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR?.trim();
|
|
221
|
+
return path.join(
|
|
222
|
+
configDir || path.join(os.homedir(), ".claude"),
|
|
223
|
+
"settings.json",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function projectClaudeSettingsPaths() {
|
|
228
|
+
const claudeDir = path.join(process.cwd(), ".claude");
|
|
229
|
+
return [
|
|
230
|
+
path.join(claudeDir, "settings.json"),
|
|
231
|
+
path.join(claudeDir, "settings.local.json"),
|
|
232
|
+
];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function readJSONFileBestEffort(filePath) {
|
|
236
|
+
try {
|
|
237
|
+
const text = fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, "");
|
|
238
|
+
const parsed = JSON.parse(text);
|
|
239
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
240
|
+
return parsed;
|
|
241
|
+
}
|
|
242
|
+
} catch {
|
|
243
|
+
// Runtime picker warnings are advisory; malformed or missing settings files
|
|
244
|
+
// should not block launching Claude Code.
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function lowerScopeAvailableModelWarnings(sailEnv, paths) {
|
|
250
|
+
const mainModel = sailEnv.ANTHROPIC_MODEL;
|
|
251
|
+
const backgroundModel = sailEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL;
|
|
252
|
+
return paths
|
|
253
|
+
.map((filePath) => {
|
|
254
|
+
const settings = readJSONFileBestEffort(filePath);
|
|
255
|
+
if (!settings) return null;
|
|
256
|
+
const extras = extraAvailableModels(settings, mainModel, backgroundModel);
|
|
257
|
+
return formatExtraAvailableModelsWarning(filePath, extras);
|
|
258
|
+
})
|
|
259
|
+
.filter(Boolean);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function runtimeAvailableModelWarnings(sailEnv) {
|
|
263
|
+
return lowerScopeAvailableModelWarnings(sailEnv, [
|
|
264
|
+
userClaudeSettingsPath(),
|
|
265
|
+
...projectClaudeSettingsPaths(),
|
|
266
|
+
]);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function warnRuntimeAvailableModels(sailEnv) {
|
|
270
|
+
for (const warning of runtimeAvailableModelWarnings(sailEnv)) {
|
|
271
|
+
console.error(warning);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
206
275
|
/**
|
|
207
276
|
* Spawn `claude` with the Sail env overlaid; resolves to its exit code.
|
|
208
277
|
*
|
|
@@ -226,6 +295,7 @@ export async function runClaude({ env, claudeArgs, spawn = spawnClaude }) {
|
|
|
226
295
|
"override it.",
|
|
227
296
|
);
|
|
228
297
|
} else {
|
|
298
|
+
warnRuntimeAvailableModels(env);
|
|
229
299
|
settingsFile = path.join(
|
|
230
300
|
os.tmpdir(),
|
|
231
301
|
`sail-code-${crypto.randomBytes(8).toString("hex")}.json`,
|
|
@@ -233,7 +303,7 @@ export async function runClaude({ env, claudeArgs, spawn = spawnClaude }) {
|
|
|
233
303
|
try {
|
|
234
304
|
fs.writeFileSync(
|
|
235
305
|
settingsFile,
|
|
236
|
-
JSON.stringify(
|
|
306
|
+
JSON.stringify(buildRuntimeSettings(env), null, 2),
|
|
237
307
|
{ mode: 0o600 },
|
|
238
308
|
);
|
|
239
309
|
} catch (err) {
|
|
@@ -317,9 +387,20 @@ export async function runClaude({ env, claudeArgs, spawn = spawnClaude }) {
|
|
|
317
387
|
}
|
|
318
388
|
|
|
319
389
|
/** The `run` command. */
|
|
320
|
-
export async function run({
|
|
390
|
+
export async function run({
|
|
391
|
+
modeFlag,
|
|
392
|
+
model,
|
|
393
|
+
backgroundModel,
|
|
394
|
+
trusted,
|
|
395
|
+
claudeArgs,
|
|
396
|
+
}) {
|
|
321
397
|
const { apiKey, apiUrl } = await resolveCredentials({ modeFlag });
|
|
322
398
|
const env = buildClaudeEnv({ apiUrl, apiKey, model, backgroundModel });
|
|
399
|
+
if (trusted) {
|
|
400
|
+
console.error(
|
|
401
|
+
"Trusted mode: skipping Claude Code permission prompts. Use only in trusted workspaces.",
|
|
402
|
+
);
|
|
403
|
+
}
|
|
323
404
|
console.error(
|
|
324
405
|
`Running Claude Code on ${env.ANTHROPIC_MODEL} via Sail (${apiUrl}) — ` +
|
|
325
406
|
"no Anthropic account used.",
|
package/src/setup.js
CHANGED
|
@@ -18,16 +18,192 @@ import path from "node:path";
|
|
|
18
18
|
|
|
19
19
|
import {
|
|
20
20
|
CONFLICTING_PROVIDER_VARS,
|
|
21
|
+
DEFAULT_MODEL,
|
|
21
22
|
HEADER_INJECTION_VARS,
|
|
22
23
|
MODEL_ALIAS_COMPANION_VARS,
|
|
23
24
|
OWNED_ENV_KEYS,
|
|
24
25
|
buildClaudeEnv,
|
|
26
|
+
buildModelPickerSettings,
|
|
27
|
+
extraAvailableModels,
|
|
28
|
+
formatExtraAvailableModelsWarning,
|
|
25
29
|
} from "./constants.js";
|
|
26
30
|
import { atomicWrite } from "./credentials.js";
|
|
27
31
|
import { resolveCredentials } from "./run.js";
|
|
28
32
|
|
|
29
33
|
export const BACKUP_SUFFIX = ".sail-backup";
|
|
30
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Build the Sail commit-attribution trailer for the resolved main model. Claude
|
|
37
|
+
* Code's top-level `attribution.commit` is the trailer appended to commits/PRs
|
|
38
|
+
* the agent makes; the default is a Claude/model-derived trailer. We replace it
|
|
39
|
+
* with a Sail trailer so commits made through the launcher are attributed to
|
|
40
|
+
* the Sail-served model, not Anthropic. The trailer names the model the session
|
|
41
|
+
* actually uses — for the default GLM model that's the friendly "GLM-5.2"; for
|
|
42
|
+
* a user-selected `--model` we use the model id's basename (e.g.
|
|
43
|
+
* `openai/gpt-oss-120b` → `gpt-oss-120b`) so a non-default setup isn't
|
|
44
|
+
* misattributed to GLM-5.2. `attribution.pr` is the PR-body attribution line;
|
|
45
|
+
* empty disables it (we don't add one).
|
|
46
|
+
*/
|
|
47
|
+
export function sailCommitTrailer(mainModel) {
|
|
48
|
+
const model = mainModel || DEFAULT_MODEL;
|
|
49
|
+
const label =
|
|
50
|
+
model === DEFAULT_MODEL ? "GLM-5.2" : model.split("/").pop() || model;
|
|
51
|
+
return `Co-Authored-By: ${label} via Sail <noreply@sailresearch.com>`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `attribution.pr` value sail-code writes (empty = disable PR attribution). */
|
|
55
|
+
export const SAIL_PR_ATTRIBUTION = "";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Keys of the `attribution` object this tool WRITES in setup. `sessionUrl` is
|
|
59
|
+
* left alone (unset unless we confirm Claude Code appends it in this path — we
|
|
60
|
+
* avoid changing more attribution behavior than requested). Note: revert's
|
|
61
|
+
* no-backup path strips only the distinctive `commit` trailer (see revert()),
|
|
62
|
+
* not `pr` — `pr: ""` is meaningful user config (Claude Code's documented way
|
|
63
|
+
* to disable PR attribution) and indistinguishable from what we write.
|
|
64
|
+
*/
|
|
65
|
+
export const OWNED_ATTRIBUTION_KEYS = ["commit", "pr"];
|
|
66
|
+
export const MANAGED_MODEL_PICKER_ENV_KEY = "SAIL_CODE_MANAGED_MODEL_PICKER";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Merge the Sail commit attribution into a settings object's top-level
|
|
70
|
+
* `attribution`. `mainModel` is the resolved startup model (the `main` from
|
|
71
|
+
* buildClaudeEnv) so the trailer names the model the session actually uses,
|
|
72
|
+
* including a user-selected `--model`. Preserves any unrelated attribution
|
|
73
|
+
* keys the user set (e.g. sessionUrl). Mutates `settings.attribution`.
|
|
74
|
+
* Exported so tests exercise the real merge, not a copy.
|
|
75
|
+
*/
|
|
76
|
+
export function mergeAttribution(settings, mainModel) {
|
|
77
|
+
settings.attribution = {
|
|
78
|
+
...(settings.attribution ?? {}),
|
|
79
|
+
commit: sailCommitTrailer(mainModel),
|
|
80
|
+
pr: SAIL_PR_ATTRIBUTION,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function mergeModelPickerSettings(settings, mainModel, backgroundModel) {
|
|
85
|
+
const pickerSettings = buildModelPickerSettings(mainModel, backgroundModel);
|
|
86
|
+
Object.assign(settings, pickerSettings);
|
|
87
|
+
// Record the exact non-secret picker list setup wrote. If the byte backup is
|
|
88
|
+
// lost, fallback revert can then strip custom-model picker settings without
|
|
89
|
+
// guessing whether an arbitrary row was Sail-generated or user-managed.
|
|
90
|
+
settings.env = {
|
|
91
|
+
...(settings.env ?? {}),
|
|
92
|
+
[MANAGED_MODEL_PICKER_ENV_KEY]: JSON.stringify(pickerSettings),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function sameStringArray(a, b) {
|
|
97
|
+
return (
|
|
98
|
+
Array.isArray(a) &&
|
|
99
|
+
Array.isArray(b) &&
|
|
100
|
+
a.length === b.length &&
|
|
101
|
+
a.every((value, i) => value === b[i])
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function sameModelPickerSettings(settings, pickerSettings) {
|
|
106
|
+
return (
|
|
107
|
+
pickerSettings?.enforceAvailableModels === true &&
|
|
108
|
+
settings?.enforceAvailableModels === true &&
|
|
109
|
+
sameStringArray(settings.availableModels, pickerSettings.availableModels)
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function managedModelPickerSettings(settings) {
|
|
114
|
+
const encoded = settings?.env?.[MANAGED_MODEL_PICKER_ENV_KEY];
|
|
115
|
+
if (typeof encoded !== "string") return null;
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(encoded);
|
|
118
|
+
if (
|
|
119
|
+
parsed &&
|
|
120
|
+
typeof parsed === "object" &&
|
|
121
|
+
!Array.isArray(parsed) &&
|
|
122
|
+
parsed.enforceAvailableModels === true &&
|
|
123
|
+
Array.isArray(parsed.availableModels) &&
|
|
124
|
+
parsed.availableModels.every((model) => typeof model === "string")
|
|
125
|
+
) {
|
|
126
|
+
return parsed;
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
// Malformed marker: ignore it and fall back to the exact default matcher.
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function isSailGeneratedModelPickerSettings(settings) {
|
|
135
|
+
const managedPicker = managedModelPickerSettings(settings);
|
|
136
|
+
if (managedPicker) {
|
|
137
|
+
return sameModelPickerSettings(settings, managedPicker);
|
|
138
|
+
}
|
|
139
|
+
return sameModelPickerSettings(
|
|
140
|
+
settings,
|
|
141
|
+
buildModelPickerSettings(DEFAULT_MODEL),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readSettingsIfPresent(filePath) {
|
|
146
|
+
try {
|
|
147
|
+
return readSettings(filePath);
|
|
148
|
+
} catch {
|
|
149
|
+
// Best-effort sibling-scope inspection for warnings only. The target
|
|
150
|
+
// settings file is parsed strictly by setup(); a sibling project
|
|
151
|
+
// settings.json should not block writing the local Sail file.
|
|
152
|
+
return { settings: {}, existed: false };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function projectSharedSettingsPath(localSettingsPath) {
|
|
157
|
+
return path.join(path.dirname(localSettingsPath), "settings.json");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function currentProjectSettingsPaths() {
|
|
161
|
+
const claudeDir = path.join(process.cwd(), ".claude");
|
|
162
|
+
return [
|
|
163
|
+
path.join(claudeDir, "settings.json"),
|
|
164
|
+
path.join(claudeDir, "settings.local.json"),
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function extraAvailableModelWarningSources({
|
|
169
|
+
scope,
|
|
170
|
+
filePath,
|
|
171
|
+
settings,
|
|
172
|
+
mainModel,
|
|
173
|
+
backgroundModel,
|
|
174
|
+
}) {
|
|
175
|
+
const background = backgroundModel || mainModel;
|
|
176
|
+
const seenPaths = new Set();
|
|
177
|
+
const sources = [];
|
|
178
|
+
|
|
179
|
+
const addSource = (source, sourceSettings) => {
|
|
180
|
+
const resolved = path.resolve(source);
|
|
181
|
+
if (seenPaths.has(resolved)) return;
|
|
182
|
+
seenPaths.add(resolved);
|
|
183
|
+
const extras = extraAvailableModels(sourceSettings, mainModel, background);
|
|
184
|
+
if (extras.length > 0) sources.push({ source, extras });
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
addSource(filePath, settings);
|
|
188
|
+
|
|
189
|
+
const extraPaths =
|
|
190
|
+
scope === "project"
|
|
191
|
+
? [projectSharedSettingsPath(filePath)]
|
|
192
|
+
: currentProjectSettingsPaths();
|
|
193
|
+
|
|
194
|
+
for (const source of extraPaths) {
|
|
195
|
+
const { settings: sourceSettings, existed } = readSettingsIfPresent(source);
|
|
196
|
+
if (existed) addSource(source, sourceSettings);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return sources;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function warnExtraAvailableModels(source, extras) {
|
|
203
|
+
const warning = formatExtraAvailableModelsWarning(source, extras);
|
|
204
|
+
if (warning) console.error(warning);
|
|
205
|
+
}
|
|
206
|
+
|
|
31
207
|
/**
|
|
32
208
|
* Backup sentinel recorded when the settings file did NOT exist before the
|
|
33
209
|
* first `setup`. A re-run then finds a backup already present (so it won't
|
|
@@ -338,8 +514,19 @@ export async function setup({
|
|
|
338
514
|
|
|
339
515
|
const { apiKey, apiUrl } = await resolveCredentials({ modeFlag });
|
|
340
516
|
const env = buildClaudeEnv({ apiUrl, apiKey, model, backgroundModel });
|
|
517
|
+
// The commit-attribution trailer names the resolved startup model (mirrors
|
|
518
|
+
// buildClaudeEnv's `main` resolution) so a non-default --model setup isn't
|
|
519
|
+
// misattributed to GLM-5.2.
|
|
520
|
+
const mainModel = model || DEFAULT_MODEL;
|
|
341
521
|
|
|
342
522
|
const { settings, existed } = readSettings(filePath);
|
|
523
|
+
const extraModelWarningSources = extraAvailableModelWarningSources({
|
|
524
|
+
scope,
|
|
525
|
+
filePath,
|
|
526
|
+
settings,
|
|
527
|
+
mainModel,
|
|
528
|
+
backgroundModel: backgroundModel || mainModel,
|
|
529
|
+
});
|
|
343
530
|
// Create the settings dir before writing the backup/sentinel. On a fresh
|
|
344
531
|
// machine (no ~/.claude, or a brand-new CLAUDE_CONFIG_DIR) the sentinel write
|
|
345
532
|
// below would otherwise ENOENT — after the key was already minted — since
|
|
@@ -371,6 +558,8 @@ export async function setup({
|
|
|
371
558
|
}
|
|
372
559
|
|
|
373
560
|
const removedKeys = mergeSailEnv(settings, env, { scope });
|
|
561
|
+
mergeAttribution(settings, mainModel);
|
|
562
|
+
mergeModelPickerSettings(settings, mainModel, backgroundModel || mainModel);
|
|
374
563
|
writeSettings(filePath, settings, { containsSecret: true });
|
|
375
564
|
|
|
376
565
|
if (removedKeys.length > 0) {
|
|
@@ -398,6 +587,9 @@ export async function setup({
|
|
|
398
587
|
"them in your shell profile.",
|
|
399
588
|
);
|
|
400
589
|
}
|
|
590
|
+
for (const { source, extras } of extraModelWarningSources) {
|
|
591
|
+
warnExtraAvailableModels(source, extras);
|
|
592
|
+
}
|
|
401
593
|
|
|
402
594
|
const revertCmd =
|
|
403
595
|
"sail-code setup --revert" + (scope === "project" ? " --project" : "");
|
|
@@ -424,6 +616,12 @@ export async function setup({
|
|
|
424
616
|
reach +
|
|
425
617
|
" Undo anytime with: " +
|
|
426
618
|
revertCmd +
|
|
619
|
+
"\n" +
|
|
620
|
+
"Also set `attribution.commit` to a Sail co-author trailer naming the " +
|
|
621
|
+
"resolved model (" +
|
|
622
|
+
sailCommitTrailer(mainModel) +
|
|
623
|
+
") so commits the agent makes are attributed to the Sail-served model " +
|
|
624
|
+
"(--revert restores your original attribution)." +
|
|
427
625
|
"\n\nVS Code note: the extension reads this file for its sessions, but " +
|
|
428
626
|
"its own pre-launch login check does not — if you have no saved " +
|
|
429
627
|
"Anthropic login, also add these to VS Code's user settings " +
|
|
@@ -462,6 +660,7 @@ export function revert({ scope = "user" }) {
|
|
|
462
660
|
console.error(`Nothing to revert: ${filePath} does not exist.`);
|
|
463
661
|
return 0;
|
|
464
662
|
}
|
|
663
|
+
const shouldStripModelPicker = isSailGeneratedModelPickerSettings(settings);
|
|
465
664
|
if (settings.env && typeof settings.env === "object") {
|
|
466
665
|
for (const key of OWNED_ENV_KEYS) delete settings.env[key];
|
|
467
666
|
// Clear the falsy overrides a project-scope setup wrote, but only when
|
|
@@ -472,8 +671,40 @@ export function revert({ scope = "user" }) {
|
|
|
472
671
|
]) {
|
|
473
672
|
if (settings.env[key] === "") delete settings.env[key];
|
|
474
673
|
}
|
|
674
|
+
delete settings.env[MANAGED_MODEL_PICKER_ENV_KEY];
|
|
475
675
|
if (Object.keys(settings.env).length === 0) delete settings.env;
|
|
476
676
|
}
|
|
677
|
+
// Remove the Sail commit attribution, but only where the value still matches
|
|
678
|
+
// a trailer we can recognize as ours — a value the user replaced after setup
|
|
679
|
+
// is theirs, not ours (mirrors the empty-override logic above). Drop the
|
|
680
|
+
// attribution block if it becomes empty. Unrelated attribution keys (e.g.
|
|
681
|
+
// sessionUrl) are preserved.
|
|
682
|
+
//
|
|
683
|
+
// We match only the DEFAULT-model trailer ("GLM-5.2 via Sail") here: revert
|
|
684
|
+
// doesn't know which model setup used (it has no --model flag and no record of
|
|
685
|
+
// the setup-time choice), so a custom-`--model` trailer can't be reliably
|
|
686
|
+
// reconstructed. Leaving a custom-model trailer in place on this fallback path
|
|
687
|
+
// is the conservative choice (don't clobber something we can't identify as
|
|
688
|
+
// ours); the byte-backup revert path restores the original verbatim, so this
|
|
689
|
+
// only matters when the backup is gone — re-run `setup` then `--revert` to
|
|
690
|
+
// clear a custom-model trailer.
|
|
691
|
+
//
|
|
692
|
+
// `pr` is intentionally NOT stripped on this no-backup path: Claude Code
|
|
693
|
+
// documents `pr: ""` as the normal setting for disabling PR attribution, so an
|
|
694
|
+
// empty `pr` is meaningful user configuration indistinguishable from what we
|
|
695
|
+
// write. The byte-backup revert path restores the original `pr` verbatim.
|
|
696
|
+
if (settings.attribution && typeof settings.attribution === "object") {
|
|
697
|
+
if (settings.attribution.commit === sailCommitTrailer(DEFAULT_MODEL)) {
|
|
698
|
+
delete settings.attribution.commit;
|
|
699
|
+
}
|
|
700
|
+
if (Object.keys(settings.attribution).length === 0) {
|
|
701
|
+
delete settings.attribution;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
if (shouldStripModelPicker) {
|
|
705
|
+
delete settings.availableModels;
|
|
706
|
+
delete settings.enforceAvailableModels;
|
|
707
|
+
}
|
|
477
708
|
writeSettings(filePath, settings);
|
|
478
709
|
console.error(
|
|
479
710
|
`Removed Sail routing keys from ${filePath} (no backup was present).`,
|