@standardagents/code 0.0.0-dev.fffff → 0.0.2-dev.517db40

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@standardagents/code",
3
- "version": "0.0.0-dev.fffff",
3
+ "version": "0.0.2-dev.517db40",
4
4
  "description": "Standard Code — a terminal coding agent whose LLM loop runs on a Standard Agents instance while its tools execute on your machine via this CLI.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -14,15 +14,15 @@
14
14
  "registry": "https://registry.npmjs.org/"
15
15
  },
16
16
  "bin": {
17
- "standardcode": "./bin/standardcode.mjs"
17
+ "standardcode": "dist/index.js"
18
18
  },
19
19
  "files": [
20
- "bin",
21
- "src"
20
+ "dist"
22
21
  ],
23
22
  "scripts": {
24
23
  "start": "tsx src/index.ts",
25
24
  "code": "tsx src/index.ts",
25
+ "build": "tsup",
26
26
  "release": "node scripts/release.mjs",
27
27
  "release:next": "node scripts/release.mjs --tag=next",
28
28
  "release:dev": "node scripts/release.mjs --tag=dev",
@@ -32,6 +32,7 @@
32
32
  "devDependencies": {
33
33
  "@clack/prompts": "^1.6.0",
34
34
  "@types/node": "^22.10.0",
35
+ "tsup": "^8.5.1",
35
36
  "tsx": "^4.20.6",
36
37
  "typescript": "^5.9.3"
37
38
  }
@@ -1,25 +0,0 @@
1
- #!/usr/bin/env node
2
- // Launcher for the Standard Code CLI. Runs the TypeScript entry through tsx so
3
- // it works without a separate build step during local development.
4
- import { spawn } from "node:child_process";
5
- import { fileURLToPath } from "node:url";
6
- import { createRequire } from "node:module";
7
- import path from "node:path";
8
-
9
- const here = path.dirname(fileURLToPath(import.meta.url));
10
- const entry = path.resolve(here, "../src/index.ts");
11
- const require = createRequire(import.meta.url);
12
-
13
- let tsxBin;
14
- try {
15
- // Resolve tsx's CLI entry from wherever it is installed in the workspace.
16
- tsxBin = require.resolve("tsx/cli");
17
- } catch {
18
- console.error("Could not find 'tsx'. Run `pnpm install` in the workspace.");
19
- process.exit(1);
20
- }
21
-
22
- const child = spawn(process.execPath, [tsxBin, entry, ...process.argv.slice(2)], {
23
- stdio: "inherit",
24
- });
25
- child.on("exit", (code) => process.exit(code ?? 0));
package/src/api.ts DELETED
@@ -1,169 +0,0 @@
1
- /** Minimal REST client for the Standard Agents instance. */
2
- import type { ThreadSummary } from "./types.ts";
3
-
4
- export class ApiClient {
5
- constructor(
6
- private endpoint: string,
7
- private token: string
8
- ) {}
9
-
10
- get wsEndpoint(): string {
11
- return this.endpoint.replace(/^http/, "ws");
12
- }
13
-
14
- /** The instance origin (used to build admin-UI links). */
15
- get origin(): string {
16
- return this.endpoint;
17
- }
18
-
19
- get bearer(): string {
20
- return this.token;
21
- }
22
-
23
- private async json<T>(pathname: string, init?: RequestInit): Promise<T> {
24
- const res = await fetch(`${this.endpoint}${pathname}`, {
25
- ...init,
26
- headers: {
27
- "Content-Type": "application/json",
28
- Authorization: `Bearer ${this.token}`,
29
- ...(init?.headers || {}),
30
- },
31
- });
32
- const text = await res.text();
33
- if (!res.ok) {
34
- throw new Error(`${init?.method || "GET"} ${pathname} -> ${res.status}: ${text.slice(0, 300)}`);
35
- }
36
- try {
37
- return JSON.parse(text) as T;
38
- } catch {
39
- return text as unknown as T;
40
- }
41
- }
42
-
43
- async verify(): Promise<boolean> {
44
- try {
45
- await this.json("/api/auth/me");
46
- return true;
47
- } catch {
48
- return false;
49
- }
50
- }
51
-
52
- /** Agent name → display title map (best effort; for labeling subagents). */
53
- async listAgents(): Promise<{ name: string; title: string }[]> {
54
- const res = await this.json<{ agents?: any[] } | any[]>("/api/agents");
55
- const arr = Array.isArray(res) ? res : res.agents || [];
56
- return arr
57
- .filter((a) => a && typeof a.name === "string")
58
- .map((a) => ({ name: a.name, title: typeof a.title === "string" ? a.title : a.name }));
59
- }
60
-
61
- async createThread(agentId: string, tags: string[]): Promise<string> {
62
- const res = await this.json<{ threadId?: string; id?: string }>("/api/threads", {
63
- method: "POST",
64
- body: JSON.stringify({ agent_id: agentId, tags }),
65
- });
66
- const id = res.threadId || res.id;
67
- if (!id) throw new Error("Thread create returned no id");
68
- return id;
69
- }
70
-
71
- /** List threads for an agent, optionally filtering to those carrying all given tags. */
72
- async listThreads(agentId: string, requireTags: string[]): Promise<ThreadSummary[]> {
73
- const res = await this.json<{ threads?: any[] } | any[]>(
74
- `/api/threads?agent_id=${encodeURIComponent(agentId)}&limit=100`
75
- );
76
- const arr = Array.isArray(res) ? res : res.threads || [];
77
- return arr
78
- .map((t) => ({
79
- id: t.id,
80
- tags: Array.isArray(t.tags) ? t.tags : [],
81
- created_at: t.created_at,
82
- title: t.title,
83
- preview: t.preview || t.last_message,
84
- }))
85
- .filter((t: ThreadSummary) => requireTags.every((tag) => t.tags.includes(tag)));
86
- }
87
-
88
- async sendMessage(threadId: string, content: string): Promise<void> {
89
- await this.json(`/api/threads/${threadId}/messages`, {
90
- method: "POST",
91
- body: JSON.stringify({ role: "user", content }),
92
- });
93
- }
94
-
95
- async getMessages(threadId: string, limit = 50): Promise<any[]> {
96
- const res = await this.json<{ messages?: any[] } | any[]>(
97
- `/api/threads/${threadId}/messages?limit=${limit}`
98
- );
99
- return Array.isArray(res) ? res : res.messages || [];
100
- }
101
-
102
- async getLogs(threadId: string, limit = 100): Promise<any[]> {
103
- const res = await this.json<{ logs?: any[] } | any[]>(
104
- `/api/threads/${threadId}/logs?limit=${limit}&order=desc`
105
- );
106
- return Array.isArray(res) ? res : res.logs || [];
107
- }
108
-
109
- /**
110
- * Deliver a durable forwarded tool result to the thread, resuming the turn.
111
- * Retries with backoff — this is the durable delivery path, so it must land
112
- * even if the connection is briefly flaky after a permission wait.
113
- */
114
- async postToolResult(
115
- threadId: string,
116
- toolCallId: string,
117
- ok: boolean,
118
- result?: string,
119
- error?: string
120
- ): Promise<boolean> {
121
- const body = JSON.stringify({ tool_call_id: toolCallId, ok, result, error });
122
- for (let attempt = 0; attempt < 6; attempt++) {
123
- try {
124
- await this.json(`/api/threads/${threadId}/tool-result`, {
125
- method: "POST",
126
- headers: { "Content-Type": "application/json" },
127
- body,
128
- });
129
- return true;
130
- } catch {
131
- await new Promise((r) => setTimeout(r, Math.min(500 * 2 ** attempt, 8000)));
132
- }
133
- }
134
- return false;
135
- }
136
-
137
- /** Read a value from the thread's durable KV store (null if absent). */
138
- async kvGet(threadId: string, key: string): Promise<unknown> {
139
- try {
140
- const res = await this.json<{ value?: unknown }>(
141
- `/api/threads/${threadId}/kv?key=${encodeURIComponent(key)}`
142
- );
143
- return res?.value ?? null;
144
- } catch {
145
- return null; // 404 = key not yet set
146
- }
147
- }
148
-
149
- /** Write a value to the thread's durable KV store. */
150
- async kvSet(threadId: string, key: string, value: unknown): Promise<void> {
151
- try {
152
- await this.json(`/api/threads/${threadId}/kv`, {
153
- method: "POST",
154
- headers: { "Content-Type": "application/json" },
155
- body: JSON.stringify({ key, value }),
156
- });
157
- } catch {
158
- // best effort
159
- }
160
- }
161
-
162
- async stop(threadId: string): Promise<void> {
163
- try {
164
- await this.json(`/api/threads/${threadId}/stop`, { method: "POST" });
165
- } catch {
166
- // best effort
167
- }
168
- }
169
- }
package/src/approvals.ts DELETED
@@ -1,42 +0,0 @@
1
- /**
2
- * Session approvals — the permission mode plus the tools and risk levels the
3
- * user has pre-approved — persisted in the THREAD's KV store (server-side), so
4
- * they resume with the session and a server hook can show the agent what's
5
- * already approved. Nothing is stored on the client.
6
- */
7
- import type { ApiClient } from "./api.ts";
8
- import type { PermissionState } from "./permissions.ts";
9
- import type { Level } from "./types.ts";
10
-
11
- const KEY = "approvals";
12
-
13
- export interface ApprovalsKV {
14
- level?: Level;
15
- allowTools: string[];
16
- allowRisk: number[];
17
- }
18
-
19
- export async function loadApprovals(api: ApiClient, threadId: string): Promise<ApprovalsKV> {
20
- const v = await api.kvGet(threadId, KEY);
21
- if (v && typeof v === "object" && !Array.isArray(v)) {
22
- const o = v as Record<string, unknown>;
23
- const n = Number(o.level);
24
- const level = n >= 1 && n <= 5 ? (n as Level) : undefined;
25
- return {
26
- level,
27
- allowTools: Array.isArray(o.allowTools) ? (o.allowTools as string[]) : [],
28
- allowRisk: Array.isArray(o.allowRisk) ? (o.allowRisk as number[]) : [],
29
- };
30
- }
31
- return { allowTools: [], allowRisk: [] };
32
- }
33
-
34
- /** Persist the current approvals to the thread KV (fire-and-forget). */
35
- export function saveApprovals(api: ApiClient, threadId: string, perm: PermissionState): void {
36
- const payload: ApprovalsKV = {
37
- level: perm.level,
38
- allowTools: Array.from(perm.alwaysAllow).sort(),
39
- allowRisk: Array.from(perm.allowRisk).sort((a, b) => a - b),
40
- };
41
- void api.kvSet(threadId, KEY, payload);
42
- }
package/src/bridge.ts DELETED
@@ -1,303 +0,0 @@
1
- /**
2
- * The client-tool bridge: holds a WebSocket open to the instance, receives
3
- * forwarded tool requests, runs them through the permission engine + safety
4
- * guard, executes the approved ones on the host, and returns results.
5
- */
6
- import type { ApiClient } from "./api.ts";
7
- import type { HostTools } from "./host-tools.ts";
8
- import { decide, isCatastrophic, type PermissionState } from "./permissions.ts";
9
- import { saveApprovals } from "./approvals.ts";
10
- import type { ToolRequest } from "./types.ts";
11
-
12
- export type ApprovalChoice = "allow" | "deny" | "always" | "always_risk";
13
-
14
- export type ConnectionState = "connected" | "reconnecting" | "reconnected";
15
-
16
- export interface BridgeHooks {
17
- /** Print a permanent line (a finished/denied/blocked tool). */
18
- onActivity(line: string): void;
19
- /** Update the live working status (the currently-running tool, or null = idle). */
20
- onStatus?(label: string | null): void;
21
- /** Report connection lifecycle so the UI can show disconnect/reconnect state. */
22
- onConnection?(state: ConnectionState, attempt: number): void;
23
- /** Ask the user to approve a tool; resolve with their choice. */
24
- requestApproval(req: ToolRequest, summary: string, effectiveRisk: number): Promise<ApprovalChoice>;
25
- }
26
-
27
- /** Tools that mutate or execute (used to deny in plan mode cleanly). */
28
- const PATH_ARG_TOOLS = new Set(["read_file", "list_dir", "grep", "glob", "write_file", "edit_file", "delete"]);
29
-
30
- export class Bridge {
31
- private ws: WebSocket | null = null;
32
- private closed = false;
33
- private heartbeat: ReturnType<typeof setInterval> | null = null;
34
- private reconnectAttempt = 0;
35
- private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
36
- private resolveConnected: (() => void) | null = null;
37
- // Durable forwarded calls we've started handling, so a server re-send (after a
38
- // reconnect) doesn't prompt or run them twice.
39
- private handledDurable = new Set<string>();
40
-
41
- constructor(
42
- private api: ApiClient,
43
- private threadId: string,
44
- private host: HostTools,
45
- private perm: PermissionState,
46
- private hooks: BridgeHooks
47
- ) {}
48
-
49
- /**
50
- * Connect and keep the bridge connected. Resolves on the first successful
51
- * open; thereafter any drop is reconnected automatically with exponential
52
- * backoff (disconnections are expected — e.g. a dev-server reload — so this
53
- * must be rock solid). A short safety timeout resolves startup even if the
54
- * very first attempt is slow, since reconnection continues in the background.
55
- */
56
- connect(): Promise<void> {
57
- return new Promise((resolve) => {
58
- let settled = false;
59
- this.resolveConnected = () => {
60
- if (!settled) {
61
- settled = true;
62
- resolve();
63
- }
64
- };
65
- // Don't hang startup forever if the first attempt is slow.
66
- setTimeout(() => this.resolveConnected?.(), 8000);
67
- this.openSocket();
68
- });
69
- }
70
-
71
- private openSocket(): void {
72
- if (this.closed) return;
73
- const url = `${this.api.wsEndpoint}/api/threads/${this.threadId}/bridge?token=${encodeURIComponent(this.api.bearer)}`;
74
- let ws: WebSocket;
75
- try {
76
- ws = new WebSocket(url);
77
- } catch {
78
- this.scheduleReconnect();
79
- return;
80
- }
81
- this.ws = ws;
82
-
83
- ws.addEventListener("open", () => {
84
- const wasReconnecting = this.reconnectAttempt > 0;
85
- this.reconnectAttempt = 0;
86
- this.startHeartbeat(ws);
87
- this.hooks.onConnection?.(wasReconnecting ? "reconnected" : "connected", 0);
88
- this.resolveConnected?.();
89
- });
90
-
91
- ws.addEventListener("message", (ev) => this.onMessage(String((ev as MessageEvent).data)));
92
-
93
- // A socket that fails to open fires 'error' but may never fire 'close', so
94
- // both must drive reconnection. handleDrop dedupes via the current-socket check.
95
- ws.addEventListener("error", () => this.handleDrop(ws));
96
- ws.addEventListener("close", () => this.handleDrop(ws));
97
- }
98
-
99
- private handleDrop(ws: WebSocket): void {
100
- if (this.ws !== ws) return; // stale event from a superseded socket
101
- this.ws = null;
102
- this.stopHeartbeat();
103
- this.scheduleReconnect();
104
- }
105
-
106
- private scheduleReconnect(): void {
107
- if (this.closed || this.reconnectTimer) return;
108
- this.reconnectAttempt++;
109
- this.hooks.onConnection?.("reconnecting", this.reconnectAttempt);
110
- // Exponential backoff with jitter, capped — keep trying indefinitely.
111
- const base = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15000);
112
- const delay = base + Math.floor(Math.random() * 400);
113
- this.reconnectTimer = setTimeout(() => {
114
- this.reconnectTimer = null;
115
- this.openSocket();
116
- }, delay);
117
- }
118
-
119
- private startHeartbeat(ws: WebSocket): void {
120
- this.stopHeartbeat();
121
- // Heartbeat so the server can tell a live client (mid-build or awaiting an
122
- // approval) from a dead one and never freeze a thread on us.
123
- this.heartbeat = setInterval(() => {
124
- try {
125
- if (ws.readyState === WebSocket.OPEN) ws.send("ping");
126
- } catch {
127
- // ignore
128
- }
129
- }, 5000);
130
- }
131
-
132
- private stopHeartbeat(): void {
133
- if (this.heartbeat) {
134
- clearInterval(this.heartbeat);
135
- this.heartbeat = null;
136
- }
137
- }
138
-
139
- close(): void {
140
- this.closed = true;
141
- this.stopHeartbeat();
142
- if (this.reconnectTimer) {
143
- clearTimeout(this.reconnectTimer);
144
- this.reconnectTimer = null;
145
- }
146
- this.ws?.close();
147
- }
148
-
149
- private send(payload: object): void {
150
- try {
151
- this.ws?.send(JSON.stringify(payload));
152
- } catch {
153
- // socket gone; the server will time the call out
154
- }
155
- }
156
-
157
- private async onMessage(raw: string): Promise<void> {
158
- let msg: any;
159
- try {
160
- msg = JSON.parse(raw);
161
- } catch {
162
- return;
163
- }
164
- if (msg.type !== "tool_request") return;
165
- const req = msg as ToolRequest;
166
- await this.handleToolRequest(req);
167
- }
168
-
169
- /**
170
- * Reply to a tool request. Durable calls (the agent parked them) deliver the
171
- * result over HTTP so it lands even if this socket later drops; legacy calls
172
- * reply over the WebSocket.
173
- */
174
- private respond(req: ToolRequest, ok: boolean, result?: string, error?: string): void {
175
- if (req.durable && req.toolCallId) {
176
- void this.api.postToolResult(this.threadId, req.toolCallId, ok, result, error);
177
- } else {
178
- this.send({ type: "tool_response", id: req.id, ok, result, error });
179
- }
180
- }
181
-
182
- private async handleToolRequest(req: ToolRequest): Promise<void> {
183
- // Durable calls may be re-sent by the server after a reconnect — only handle
184
- // each once (prompt + run), so the user isn't asked twice.
185
- if (req.durable && req.toolCallId) {
186
- if (this.handledDurable.has(req.toolCallId)) return;
187
- this.handledDurable.add(req.toolCallId);
188
- }
189
-
190
- const summary = describe(req);
191
-
192
- // Compute an effective risk, escalating anything that touches paths outside
193
- // the project directory (the agent is told to stay inside it).
194
- let effectiveRisk = typeof req.risk === "number" ? req.risk : req.requestPermission ? 3 : 1;
195
- if (PATH_ARG_TOOLS.has(req.tool) && this.host.isOutsideProject(req.args.path as string | undefined)) {
196
- effectiveRisk = Math.max(effectiveRisk, 4);
197
- }
198
-
199
- // Hard safety: never run catastrophic shell commands, even in bypass mode.
200
- if (req.tool === "bash" && isCatastrophic(String(req.args.command || ""))) {
201
- this.hooks.onActivity(`⛔ blocked dangerous command: ${summary}`);
202
- this.respond(req, false, undefined, "Blocked: this command is considered catastrophic and was refused by the client safety guard.");
203
- return;
204
- }
205
-
206
- // Permission key: normally the tool name, but MCP calls are gated at the
207
- // finer `mcp:<server>/<tool>` granularity so "always allow this tool" approves
208
- // one server's tool rather than every MCP call.
209
- const permKey = permissionKey(req);
210
- const decision = decide(this.perm, permKey, effectiveRisk, !!req.requestPermission);
211
-
212
- if (decision === "deny") {
213
- this.hooks.onActivity(`⛔ ${summary} — blocked (risk ${effectiveRisk})`);
214
- this.respond(req, false, undefined, `Denied by policy (risk ${effectiveRisk}).`);
215
- return;
216
- }
217
-
218
- if (decision === "ask") {
219
- const choice = await this.hooks.requestApproval(req, summary, effectiveRisk);
220
- if (choice === "deny") {
221
- this.hooks.onActivity(`⛔ ${summary} — you declined`);
222
- this.respond(req, false, undefined, "The user declined to run this operation.");
223
- return;
224
- }
225
- if (choice === "always") this.perm.alwaysAllow.add(permKey);
226
- if (choice === "always_risk") this.perm.allowRisk.add(effectiveRisk);
227
- if (choice === "always" || choice === "always_risk") {
228
- saveApprovals(this.api, this.threadId, this.perm);
229
- }
230
- }
231
-
232
- // Show the running tool in the live status, then leave a permanent line.
233
- this.hooks.onStatus?.(summary);
234
- const result = await this.host.execute(req.tool, req.args);
235
- this.hooks.onStatus?.(null);
236
- if (result.ok) {
237
- this.hooks.onActivity(`✓ ${summary}${detailSuffix(req.tool, result.result)}`);
238
- this.respond(req, true, result.result ?? "");
239
- } else {
240
- this.hooks.onActivity(`✗ ${summary} — ${result.error}`);
241
- this.respond(req, false, undefined, result.error);
242
- }
243
- }
244
- }
245
-
246
- /**
247
- * Stable permission key for a request. For MCP, gate on the specific server +
248
- * tool/resource so approvals are fine-grained; for everything else, the tool name.
249
- */
250
- export function permissionKey(req: ToolRequest): string {
251
- if (req.tool !== "mcp") return req.tool;
252
- const a = req.args;
253
- const server = String(a.server || "?");
254
- const action = String(a.action || "call");
255
- if (action === "read_resource") return `mcp:${server}/resource`;
256
- if (action === "list_tools") return `mcp:${server}/list`;
257
- return `mcp:${server}/${String(a.tool || "?")}`;
258
- }
259
-
260
- /** One-line human summary of a tool request for the activity feed. */
261
- export function describe(req: ToolRequest): string {
262
- const a = req.args;
263
- switch (req.tool) {
264
- case "mcp": {
265
- const server = String(a.server || "?");
266
- const action = String(a.action || "call");
267
- if (action === "list_tools") return `mcp ${server}: list tools`;
268
- if (action === "read_resource") return `mcp ${server}: read ${a.uri}`;
269
- return `mcp ${server}: ${a.tool}`;
270
- }
271
- case "read_file":
272
- return `read ${a.path}`;
273
- case "list_dir":
274
- return `list ${a.path || "."}`;
275
- case "grep":
276
- return `grep "${a.pattern}"${a.glob ? ` in ${a.glob}` : ""}`;
277
- case "glob":
278
- return `find ${a.pattern}`;
279
- case "write_file":
280
- return `write ${a.path}`;
281
- case "edit_file":
282
- return `edit ${a.path}`;
283
- case "delete":
284
- return `delete ${a.path}`;
285
- case "bash":
286
- return `bash: ${String(a.command).slice(0, 80)}`;
287
- default:
288
- return `${req.tool} ${JSON.stringify(a).slice(0, 80)}`;
289
- }
290
- }
291
-
292
- /** A short parenthetical hint appended to a finished read-style tool line. */
293
- function detailSuffix(tool: string, result?: string): string {
294
- if (!result) return "";
295
- // Mutations already read clearly from their summary; don't pile on.
296
- if (tool === "write_file" || tool === "edit_file" || tool === "delete") return "";
297
- if (tool === "bash") {
298
- const m = result.match(/\[exit code (\d+)\]\s*$/);
299
- return m ? ` (exit ${m[1]})` : "";
300
- }
301
- const lines = result.split("\n").length;
302
- return ` (${lines} line${lines === 1 ? "" : "s"})`;
303
- }
@@ -1,49 +0,0 @@
1
- /** Read/write ~/.standardagents/credentials. */
2
- import fs from "node:fs";
3
- import path from "node:path";
4
- import os from "node:os";
5
- import type { CredentialsFile, InstanceCredential } from "./types.ts";
6
-
7
- const DIR = path.join(os.homedir(), ".standardagents");
8
- const FILE = path.join(DIR, "credentials");
9
-
10
- export function normalizeEndpoint(endpoint: string): string {
11
- let e = endpoint.trim();
12
- if (!/^https?:\/\//i.test(e)) e = "http://" + e;
13
- return e.replace(/\/+$/, "");
14
- }
15
-
16
- export function loadCredentials(): CredentialsFile {
17
- try {
18
- const raw = fs.readFileSync(FILE, "utf8");
19
- const parsed = JSON.parse(raw) as CredentialsFile;
20
- if (!parsed.instances) parsed.instances = {};
21
- return parsed;
22
- } catch {
23
- return { instances: {} };
24
- }
25
- }
26
-
27
- export function getCredential(endpoint: string): InstanceCredential | null {
28
- const creds = loadCredentials();
29
- return creds.instances[normalizeEndpoint(endpoint)] ?? null;
30
- }
31
-
32
- export function saveCredential(cred: InstanceCredential): void {
33
- const creds = loadCredentials();
34
- const endpoint = normalizeEndpoint(cred.endpoint);
35
- creds.instances[endpoint] = { ...cred, endpoint };
36
- creds.default_endpoint = endpoint;
37
- fs.mkdirSync(DIR, { recursive: true });
38
- // Credentials contain a bearer token — keep them private.
39
- fs.writeFileSync(FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
40
- try {
41
- fs.chmodSync(FILE, 0o600);
42
- } catch {
43
- // best effort on platforms without chmod
44
- }
45
- }
46
-
47
- export function defaultEndpoint(): string | null {
48
- return loadCredentials().default_endpoint ?? null;
49
- }