zidane 5.13.0 → 5.13.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 +15 -0
- package/dist/acp-BqIU2mo-.js +1410 -0
- package/dist/acp-BqIU2mo-.js.map +1 -0
- package/dist/acp-cli.d.ts +1 -0
- package/dist/acp-cli.js +713 -0
- package/dist/acp-cli.js.map +1 -0
- package/dist/acp.d.ts +655 -0
- package/dist/acp.d.ts.map +1 -0
- package/dist/acp.js +2 -0
- package/dist/{agent-Db4ojCSV.d.ts → agent-D7ZL8B2X.d.ts} +2 -2
- package/dist/{agent-Db4ojCSV.d.ts.map → agent-D7ZL8B2X.d.ts.map} +1 -1
- package/dist/chat/pure.d.ts +3 -3
- package/dist/chat.d.ts +6 -6
- package/dist/chat.js +3 -2
- package/dist/chat.js.map +1 -1
- package/dist/contexts/daytona.d.ts +3 -3
- package/dist/contexts/docker.d.ts +1 -1
- package/dist/contexts/docker.d.ts.map +1 -1
- package/dist/contexts/docker.js +4 -1
- package/dist/contexts/docker.js.map +1 -1
- package/dist/contexts/e2b.d.ts +2 -2
- package/dist/{contexts-VhV4Af8x.js → contexts-DHi8LPCp.js} +25 -9
- package/dist/contexts-DHi8LPCp.js.map +1 -0
- package/dist/contexts.d.ts +3 -3
- package/dist/contexts.js +1 -1
- package/dist/eval.d.ts +1 -1
- package/dist/eval.js +3 -3
- package/dist/glob-DCWXy_tr.js +128 -0
- package/dist/glob-DCWXy_tr.js.map +1 -0
- package/dist/{headless-tVN-g6IR.js → headless-0O6HMNBQ.js} +6 -6
- package/dist/{headless-tVN-g6IR.js.map → headless-0O6HMNBQ.js.map} +1 -1
- package/dist/headless.d.ts +1 -1
- package/dist/headless.js +1 -1
- package/dist/{index-BEblm0Hu.d.ts → index-BsyPeCSL.d.ts} +3 -3
- package/dist/{index-BEblm0Hu.d.ts.map → index-BsyPeCSL.d.ts.map} +1 -1
- package/dist/{index-CJ-2g7bY.d.ts → index-CDcQW-2S.d.ts} +3 -3
- package/dist/index-CDcQW-2S.d.ts.map +1 -0
- package/dist/{index-CrMb8jCE.d.ts → index-CF15aqlk.d.ts} +3 -3
- package/dist/{index-CrMb8jCE.d.ts.map → index-CF15aqlk.d.ts.map} +1 -1
- package/dist/index.d.ts +7 -7
- package/dist/index.js +7 -7
- package/dist/lazy-DLOurOC_.js +20 -0
- package/dist/lazy-DLOurOC_.js.map +1 -0
- package/dist/{logger-Dcrj48qY.d.ts → logger-DItaCwPw.d.ts} +2 -2
- package/dist/{logger-Dcrj48qY.d.ts.map → logger-DItaCwPw.d.ts.map} +1 -1
- package/dist/mcp.d.ts +1 -1
- package/dist/{messages-CGazSyTL.js → messages-DEsLGBB9.js} +2 -2
- package/dist/{messages-CGazSyTL.js.map → messages-DEsLGBB9.js.map} +1 -1
- package/dist/output/stream-json.d.ts +2 -2
- package/dist/output/stream-json.js +1 -1
- package/dist/output/terminal.d.ts +2 -2
- package/dist/{presets-kPEMOCmE.js → presets-HDIxliiq.js} +2 -2
- package/dist/{presets-kPEMOCmE.js.map → presets-HDIxliiq.js.map} +1 -1
- package/dist/presets.d.ts +2 -2
- package/dist/presets.js +1 -1
- package/dist/{providers-Bo2biCyT.js → providers-Cz-RNYZO.js} +7 -13
- package/dist/providers-Cz-RNYZO.js.map +1 -0
- package/dist/providers.d.ts +1 -1
- package/dist/providers.js +2 -2
- package/dist/restate.d.ts +2 -2
- package/dist/session/sqlite.d.ts +1 -1
- package/dist/{session-B69BQSn1.js → session-BDWZZaYa.js} +2 -2
- package/dist/{session-B69BQSn1.js.map → session-BDWZZaYa.js.map} +1 -1
- package/dist/session.d.ts +1 -1
- package/dist/session.js +2 -2
- package/dist/skills.d.ts +2 -2
- package/dist/{tool-formatters-CkqBgPH4.d.ts → tool-formatters-CNSMadtp.d.ts} +2 -2
- package/dist/{tool-formatters-CkqBgPH4.d.ts.map → tool-formatters-CNSMadtp.d.ts.map} +1 -1
- package/dist/tools/fetch-url.d.ts +1 -1
- package/dist/tools/web-search.d.ts +1 -1
- package/dist/{tools-5Bnlq68O.js → tools-DhzKzB1y.js} +39 -56
- package/dist/tools-DhzKzB1y.js.map +1 -0
- package/dist/tools.d.ts +2 -2
- package/dist/tools.js +1 -1
- package/dist/{transcript-anchors-D4PwUMyO.js → transcript-anchors-Cq-8gx8u.js} +9 -1417
- package/dist/transcript-anchors-Cq-8gx8u.js.map +1 -0
- package/dist/{transcript-anchors-BnLZmASt.d.ts → transcript-anchors-EG-SmZRu.d.ts} +4 -4
- package/dist/{transcript-anchors-BnLZmASt.d.ts.map → transcript-anchors-EG-SmZRu.d.ts.map} +1 -1
- package/dist/tui.d.ts +3 -3
- package/dist/tui.js +7 -6
- package/dist/tui.js.map +1 -1
- package/dist/{turn-operations-B6FaQAZN.d.ts → turn-operations-DwtWRYr1.d.ts} +3 -3
- package/dist/{turn-operations-B6FaQAZN.d.ts.map → turn-operations-DwtWRYr1.d.ts.map} +1 -1
- package/dist/{types-B39tBba1.d.ts → types-Bs2oY7Ux.d.ts} +27 -4
- package/dist/types-Bs2oY7Ux.d.ts.map +1 -0
- package/dist/types.d.ts +4 -4
- package/dist/xdg-zlSeVBhQ.js +1417 -0
- package/dist/xdg-zlSeVBhQ.js.map +1 -0
- package/docs/ACP.md +221 -0
- package/package.json +11 -1
- package/dist/contexts-VhV4Af8x.js.map +0 -1
- package/dist/index-CJ-2g7bY.d.ts.map +0 -1
- package/dist/providers-Bo2biCyT.js.map +0 -1
- package/dist/tools-5Bnlq68O.js.map +0 -1
- package/dist/transcript-anchors-D4PwUMyO.js.map +0 -1
- package/dist/types-B39tBba1.d.ts.map +0 -1
|
@@ -0,0 +1,1417 @@
|
|
|
1
|
+
import { t as writeFileAtomic } from "./atomic-write-Bgtr5JPu.js";
|
|
2
|
+
import { a as local, c as generatePkce, f as arcee, g as FAST_MODE_OPTIONS, h as ANTHROPIC_EXTRA_MODELS, i as openai, l as cerebras, n as createXaiOAuthProvider, p as anthropic, r as openrouter, s as createCursorOAuthProvider, t as xai, u as baseten } from "./providers-Cz-RNYZO.js";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { getModel, getModels } from "@earendil-works/pi-ai";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { createServer } from "node:http";
|
|
9
|
+
import { refreshAnthropicToken, refreshOpenAICodexToken, registerOAuthProvider } from "@earendil-works/pi-ai/oauth";
|
|
10
|
+
//#region src/chat/oauth-page/server.ts
|
|
11
|
+
const CALLBACK_HOST = process.env.PI_OAUTH_CALLBACK_HOST || "127.0.0.1";
|
|
12
|
+
function buildRequestHandler(opts, pending) {
|
|
13
|
+
return (req, res) => {
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(req.url || "", "http://localhost");
|
|
16
|
+
if (url.pathname !== opts.path) {
|
|
17
|
+
respondError(res, 404, opts, "Callback route not found.");
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const error = url.searchParams.get("error");
|
|
21
|
+
if (error) {
|
|
22
|
+
respondError(res, 400, opts, `${opts.providerName} authentication did not complete.`, `Error: ${error}`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const code = url.searchParams.get("code");
|
|
26
|
+
const state = url.searchParams.get("state");
|
|
27
|
+
if (!code || !state) {
|
|
28
|
+
respondError(res, 400, opts, "Missing code or state parameter.");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (state !== opts.expectedState) {
|
|
32
|
+
respondError(res, 400, opts, "State mismatch.");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
36
|
+
res.end(opts.renderPage({
|
|
37
|
+
kind: "success",
|
|
38
|
+
provider: opts.providerName,
|
|
39
|
+
message: opts.successMessage
|
|
40
|
+
}));
|
|
41
|
+
pending.settle({
|
|
42
|
+
code,
|
|
43
|
+
state
|
|
44
|
+
});
|
|
45
|
+
} catch {
|
|
46
|
+
respondError(res, 500, opts, "Internal error while processing the callback.");
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function respondError(res, status, opts, message, details) {
|
|
51
|
+
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
|
|
52
|
+
res.end(opts.renderPage({
|
|
53
|
+
kind: "error",
|
|
54
|
+
provider: opts.providerName,
|
|
55
|
+
message,
|
|
56
|
+
details
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
function buildStubHandle(redirectUri, server) {
|
|
60
|
+
return {
|
|
61
|
+
redirectUri,
|
|
62
|
+
waitForCode: async () => null,
|
|
63
|
+
cancelWait: () => {},
|
|
64
|
+
close: () => {
|
|
65
|
+
try {
|
|
66
|
+
server?.close();
|
|
67
|
+
} catch {}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Start the loopback HTTP callback server. The returned handle is the
|
|
73
|
+
* caller's contract — they `await waitForCode()`, race it against manual
|
|
74
|
+
* paste, then `close()` in a `finally`.
|
|
75
|
+
*/
|
|
76
|
+
async function startCallbackServer(opts) {
|
|
77
|
+
const onListenError = opts.onListenError ?? "reject";
|
|
78
|
+
const redirectUri = `http://localhost:${opts.port}${opts.path}`;
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
let settled = false;
|
|
81
|
+
const pending = { settle: () => {} };
|
|
82
|
+
const waitPromise = new Promise((resolveWait) => {
|
|
83
|
+
pending.settle = (value) => {
|
|
84
|
+
if (settled) return;
|
|
85
|
+
settled = true;
|
|
86
|
+
resolveWait(value);
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
const server = createServer(buildRequestHandler(opts, pending));
|
|
90
|
+
server.on("error", (err) => {
|
|
91
|
+
if (onListenError === "resolveWithStub") {
|
|
92
|
+
pending.settle(null);
|
|
93
|
+
resolve(buildStubHandle(redirectUri, server));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
reject(err);
|
|
97
|
+
});
|
|
98
|
+
server.listen(opts.port, CALLBACK_HOST, () => {
|
|
99
|
+
resolve({
|
|
100
|
+
redirectUri,
|
|
101
|
+
waitForCode: () => waitPromise,
|
|
102
|
+
cancelWait: () => pending.settle(null),
|
|
103
|
+
close: () => {
|
|
104
|
+
try {
|
|
105
|
+
server.close();
|
|
106
|
+
} catch {}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
//#endregion
|
|
113
|
+
//#region src/chat/oauth-page/anthropic.ts
|
|
114
|
+
const CLIENT_ID$1 = atob("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
|
|
115
|
+
const AUTHORIZE_URL$1 = "https://claude.ai/oauth/authorize";
|
|
116
|
+
const TOKEN_URL$1 = "https://platform.claude.com/v1/oauth/token";
|
|
117
|
+
const CALLBACK_PORT$1 = 53692;
|
|
118
|
+
const CALLBACK_PATH$1 = "/callback";
|
|
119
|
+
const SCOPES = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
|
|
120
|
+
const PROVIDER_NAME$1 = "Anthropic";
|
|
121
|
+
/**
|
|
122
|
+
* Parse what the user pasted into the manual-code prompt. Accepts:
|
|
123
|
+
* - the bare authorization code
|
|
124
|
+
* - the full `redirect_uri?code=...&state=...` URL
|
|
125
|
+
* - the `code#state` shorthand Anthropic surfaces on the page
|
|
126
|
+
* - a raw query string with `code=...&state=...`
|
|
127
|
+
*
|
|
128
|
+
* Matches pi-ai's parse table so an existing user's muscle memory still works.
|
|
129
|
+
*/
|
|
130
|
+
function parseAuthorizationInput$1(input) {
|
|
131
|
+
const value = input.trim();
|
|
132
|
+
if (!value) return {};
|
|
133
|
+
try {
|
|
134
|
+
const url = new URL(value);
|
|
135
|
+
return {
|
|
136
|
+
code: url.searchParams.get("code") ?? void 0,
|
|
137
|
+
state: url.searchParams.get("state") ?? void 0
|
|
138
|
+
};
|
|
139
|
+
} catch {}
|
|
140
|
+
if (value.includes("#")) {
|
|
141
|
+
const [code, state] = value.split("#", 2);
|
|
142
|
+
return {
|
|
143
|
+
code,
|
|
144
|
+
state
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
if (value.includes("code=")) {
|
|
148
|
+
const params = new URLSearchParams(value);
|
|
149
|
+
return {
|
|
150
|
+
code: params.get("code") ?? void 0,
|
|
151
|
+
state: params.get("state") ?? void 0
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return { code: value };
|
|
155
|
+
}
|
|
156
|
+
async function postJson(url, body) {
|
|
157
|
+
const response = await fetch(url, {
|
|
158
|
+
method: "POST",
|
|
159
|
+
headers: {
|
|
160
|
+
"Content-Type": "application/json",
|
|
161
|
+
"Accept": "application/json"
|
|
162
|
+
},
|
|
163
|
+
body: JSON.stringify(body),
|
|
164
|
+
signal: AbortSignal.timeout(3e4)
|
|
165
|
+
});
|
|
166
|
+
const responseBody = await response.text();
|
|
167
|
+
if (!response.ok) throw new Error(`HTTP request failed. status=${response.status}; url=${url}; body=${responseBody}`);
|
|
168
|
+
return responseBody;
|
|
169
|
+
}
|
|
170
|
+
async function exchangeAuthorizationCode$1(code, state, verifier, redirectUri) {
|
|
171
|
+
const responseBody = await postJson(TOKEN_URL$1, {
|
|
172
|
+
grant_type: "authorization_code",
|
|
173
|
+
client_id: CLIENT_ID$1,
|
|
174
|
+
code,
|
|
175
|
+
state,
|
|
176
|
+
redirect_uri: redirectUri,
|
|
177
|
+
code_verifier: verifier
|
|
178
|
+
});
|
|
179
|
+
const tokenData = JSON.parse(responseBody);
|
|
180
|
+
return {
|
|
181
|
+
refresh: tokenData.refresh_token,
|
|
182
|
+
access: tokenData.access_token,
|
|
183
|
+
expires: Date.now() + tokenData.expires_in * 1e3 - 300 * 1e3
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
async function loginAnthropicWithCustomPage(options) {
|
|
187
|
+
const { verifier, challenge } = await generatePkce();
|
|
188
|
+
const server = await startCallbackServer({
|
|
189
|
+
port: CALLBACK_PORT$1,
|
|
190
|
+
path: CALLBACK_PATH$1,
|
|
191
|
+
expectedState: verifier,
|
|
192
|
+
providerName: PROVIDER_NAME$1,
|
|
193
|
+
renderPage: options.renderPage,
|
|
194
|
+
successMessage: "Anthropic authentication completed. You can close this window.",
|
|
195
|
+
onListenError: "reject"
|
|
196
|
+
});
|
|
197
|
+
let code;
|
|
198
|
+
let state;
|
|
199
|
+
try {
|
|
200
|
+
const authParams = new URLSearchParams({
|
|
201
|
+
code: "true",
|
|
202
|
+
client_id: CLIENT_ID$1,
|
|
203
|
+
response_type: "code",
|
|
204
|
+
redirect_uri: server.redirectUri,
|
|
205
|
+
scope: SCOPES,
|
|
206
|
+
code_challenge: challenge,
|
|
207
|
+
code_challenge_method: "S256",
|
|
208
|
+
state: verifier
|
|
209
|
+
});
|
|
210
|
+
options.onAuth({
|
|
211
|
+
url: `${AUTHORIZE_URL$1}?${authParams.toString()}`,
|
|
212
|
+
instructions: "Complete login in your browser. If the browser is on another machine, paste the final redirect URL here."
|
|
213
|
+
});
|
|
214
|
+
if (options.onManualCodeInput) {
|
|
215
|
+
let manualInput;
|
|
216
|
+
let manualError;
|
|
217
|
+
const manualPromise = options.onManualCodeInput().then((input) => {
|
|
218
|
+
manualInput = input;
|
|
219
|
+
server.cancelWait();
|
|
220
|
+
}).catch((err) => {
|
|
221
|
+
manualError = err instanceof Error ? err : new Error(String(err));
|
|
222
|
+
server.cancelWait();
|
|
223
|
+
});
|
|
224
|
+
const result = await server.waitForCode();
|
|
225
|
+
if (manualError) throw manualError;
|
|
226
|
+
if (result?.code) {
|
|
227
|
+
code = result.code;
|
|
228
|
+
state = result.state;
|
|
229
|
+
} else if (manualInput) {
|
|
230
|
+
const parsed = parseAuthorizationInput$1(manualInput);
|
|
231
|
+
if (parsed.state && parsed.state !== verifier) throw new Error("OAuth state mismatch");
|
|
232
|
+
code = parsed.code;
|
|
233
|
+
state = parsed.state ?? verifier;
|
|
234
|
+
}
|
|
235
|
+
if (!code) {
|
|
236
|
+
await manualPromise;
|
|
237
|
+
if (manualError) throw manualError;
|
|
238
|
+
if (manualInput) {
|
|
239
|
+
const parsed = parseAuthorizationInput$1(manualInput);
|
|
240
|
+
if (parsed.state && parsed.state !== verifier) throw new Error("OAuth state mismatch");
|
|
241
|
+
code = parsed.code;
|
|
242
|
+
state = parsed.state ?? verifier;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
} else {
|
|
246
|
+
const result = await server.waitForCode();
|
|
247
|
+
if (result?.code) {
|
|
248
|
+
code = result.code;
|
|
249
|
+
state = result.state;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (!code) {
|
|
253
|
+
const parsed = parseAuthorizationInput$1(await options.onPrompt({
|
|
254
|
+
message: "Paste the authorization code or full redirect URL:",
|
|
255
|
+
placeholder: server.redirectUri
|
|
256
|
+
}));
|
|
257
|
+
if (parsed.state && parsed.state !== verifier) throw new Error("OAuth state mismatch");
|
|
258
|
+
code = parsed.code;
|
|
259
|
+
state = parsed.state ?? verifier;
|
|
260
|
+
}
|
|
261
|
+
if (!code) throw new Error("Missing authorization code");
|
|
262
|
+
if (!state) throw new Error("Missing OAuth state");
|
|
263
|
+
options.onProgress?.("Exchanging authorization code for tokens...");
|
|
264
|
+
return await exchangeAuthorizationCode$1(code, state, verifier, server.redirectUri);
|
|
265
|
+
} finally {
|
|
266
|
+
server.close();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Build an `OAuthProviderInterface` that behaves identically to pi-ai's
|
|
271
|
+
* `anthropicOAuthProvider` except for the callback page HTML. Drop this
|
|
272
|
+
* onto `ProviderDescriptor.oauthProvider` to override.
|
|
273
|
+
*/
|
|
274
|
+
function createAnthropicOAuthProviderWithCustomPage(renderPage) {
|
|
275
|
+
return {
|
|
276
|
+
id: "anthropic",
|
|
277
|
+
name: "Anthropic (Claude Pro/Max)",
|
|
278
|
+
usesCallbackServer: true,
|
|
279
|
+
async login(callbacks) {
|
|
280
|
+
return loginAnthropicWithCustomPage({
|
|
281
|
+
renderPage,
|
|
282
|
+
onAuth: callbacks.onAuth,
|
|
283
|
+
onPrompt: callbacks.onPrompt,
|
|
284
|
+
onProgress: callbacks.onProgress,
|
|
285
|
+
onManualCodeInput: callbacks.onManualCodeInput
|
|
286
|
+
});
|
|
287
|
+
},
|
|
288
|
+
async refreshToken(credentials) {
|
|
289
|
+
return refreshAnthropicToken(credentials.refresh);
|
|
290
|
+
},
|
|
291
|
+
getApiKey(credentials) {
|
|
292
|
+
return credentials.access;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
//#endregion
|
|
297
|
+
//#region src/chat/oauth-page/openai-codex.ts
|
|
298
|
+
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
299
|
+
const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
|
|
300
|
+
const TOKEN_URL = "https://auth.openai.com/oauth/token";
|
|
301
|
+
const CALLBACK_PORT = 1455;
|
|
302
|
+
const CALLBACK_PATH = "/auth/callback";
|
|
303
|
+
const SCOPE = "openid profile email offline_access";
|
|
304
|
+
const JWT_CLAIM_PATH = "https://api.openai.com/auth";
|
|
305
|
+
const PROVIDER_NAME = "OpenAI";
|
|
306
|
+
function createState() {
|
|
307
|
+
return randomBytes(16).toString("hex");
|
|
308
|
+
}
|
|
309
|
+
function parseAuthorizationInput(input) {
|
|
310
|
+
const value = input.trim();
|
|
311
|
+
if (!value) return {};
|
|
312
|
+
try {
|
|
313
|
+
const url = new URL(value);
|
|
314
|
+
return {
|
|
315
|
+
code: url.searchParams.get("code") ?? void 0,
|
|
316
|
+
state: url.searchParams.get("state") ?? void 0
|
|
317
|
+
};
|
|
318
|
+
} catch {}
|
|
319
|
+
if (value.includes("#")) {
|
|
320
|
+
const [code, state] = value.split("#", 2);
|
|
321
|
+
return {
|
|
322
|
+
code,
|
|
323
|
+
state
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
if (value.includes("code=")) {
|
|
327
|
+
const params = new URLSearchParams(value);
|
|
328
|
+
return {
|
|
329
|
+
code: params.get("code") ?? void 0,
|
|
330
|
+
state: params.get("state") ?? void 0
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
return { code: value };
|
|
334
|
+
}
|
|
335
|
+
function decodeJwt(token) {
|
|
336
|
+
try {
|
|
337
|
+
const parts = token.split(".");
|
|
338
|
+
if (parts.length !== 3) return null;
|
|
339
|
+
const payload = parts[1] ?? "";
|
|
340
|
+
const decoded = atob(payload);
|
|
341
|
+
return JSON.parse(decoded);
|
|
342
|
+
} catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function getAccountId(accessToken) {
|
|
347
|
+
const accountId = (decodeJwt(accessToken)?.[JWT_CLAIM_PATH])?.chatgpt_account_id;
|
|
348
|
+
return typeof accountId === "string" && accountId.length > 0 ? accountId : null;
|
|
349
|
+
}
|
|
350
|
+
async function exchangeAuthorizationCode(code, verifier, redirectUri) {
|
|
351
|
+
const response = await fetch(TOKEN_URL, {
|
|
352
|
+
method: "POST",
|
|
353
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
354
|
+
body: new URLSearchParams({
|
|
355
|
+
grant_type: "authorization_code",
|
|
356
|
+
client_id: CLIENT_ID,
|
|
357
|
+
code,
|
|
358
|
+
code_verifier: verifier,
|
|
359
|
+
redirect_uri: redirectUri
|
|
360
|
+
})
|
|
361
|
+
});
|
|
362
|
+
if (!response.ok) {
|
|
363
|
+
const text = await response.text().catch(() => "");
|
|
364
|
+
return {
|
|
365
|
+
type: "failed",
|
|
366
|
+
message: `OpenAI Codex token exchange failed (${response.status}): ${text || response.statusText}`
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
const json = await response.json();
|
|
370
|
+
if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") return {
|
|
371
|
+
type: "failed",
|
|
372
|
+
message: `OpenAI Codex token exchange response missing fields: ${JSON.stringify(json)}`
|
|
373
|
+
};
|
|
374
|
+
return {
|
|
375
|
+
type: "success",
|
|
376
|
+
access: json.access_token,
|
|
377
|
+
refresh: json.refresh_token,
|
|
378
|
+
expires: Date.now() + json.expires_in * 1e3
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
async function loginOpenAICodexWithCustomPage(options) {
|
|
382
|
+
const { verifier, challenge } = await generatePkce();
|
|
383
|
+
const state = createState();
|
|
384
|
+
const originator = options.originator ?? "pi";
|
|
385
|
+
const server = await startCallbackServer({
|
|
386
|
+
port: CALLBACK_PORT,
|
|
387
|
+
path: CALLBACK_PATH,
|
|
388
|
+
expectedState: state,
|
|
389
|
+
providerName: PROVIDER_NAME,
|
|
390
|
+
renderPage: options.renderPage,
|
|
391
|
+
successMessage: "OpenAI authentication completed. You can close this window.",
|
|
392
|
+
onListenError: "resolveWithStub"
|
|
393
|
+
});
|
|
394
|
+
const authUrl = new URL(AUTHORIZE_URL);
|
|
395
|
+
authUrl.searchParams.set("response_type", "code");
|
|
396
|
+
authUrl.searchParams.set("client_id", CLIENT_ID);
|
|
397
|
+
authUrl.searchParams.set("redirect_uri", server.redirectUri);
|
|
398
|
+
authUrl.searchParams.set("scope", SCOPE);
|
|
399
|
+
authUrl.searchParams.set("code_challenge", challenge);
|
|
400
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
401
|
+
authUrl.searchParams.set("state", state);
|
|
402
|
+
authUrl.searchParams.set("id_token_add_organizations", "true");
|
|
403
|
+
authUrl.searchParams.set("codex_cli_simplified_flow", "true");
|
|
404
|
+
authUrl.searchParams.set("originator", originator);
|
|
405
|
+
options.onAuth({
|
|
406
|
+
url: authUrl.toString(),
|
|
407
|
+
instructions: "A browser window should open. Complete login to finish."
|
|
408
|
+
});
|
|
409
|
+
let code;
|
|
410
|
+
try {
|
|
411
|
+
if (options.onManualCodeInput) {
|
|
412
|
+
let manualCode;
|
|
413
|
+
let manualError;
|
|
414
|
+
const manualPromise = options.onManualCodeInput().then((input) => {
|
|
415
|
+
manualCode = input;
|
|
416
|
+
server.cancelWait();
|
|
417
|
+
}).catch((err) => {
|
|
418
|
+
manualError = err instanceof Error ? err : new Error(String(err));
|
|
419
|
+
server.cancelWait();
|
|
420
|
+
});
|
|
421
|
+
const result = await server.waitForCode();
|
|
422
|
+
if (manualError) throw manualError;
|
|
423
|
+
if (result?.code) code = result.code;
|
|
424
|
+
else if (manualCode) {
|
|
425
|
+
const parsed = parseAuthorizationInput(manualCode);
|
|
426
|
+
if (parsed.state && parsed.state !== state) throw new Error("State mismatch");
|
|
427
|
+
code = parsed.code;
|
|
428
|
+
}
|
|
429
|
+
if (!code) {
|
|
430
|
+
await manualPromise;
|
|
431
|
+
if (manualError) throw manualError;
|
|
432
|
+
if (manualCode) {
|
|
433
|
+
const parsed = parseAuthorizationInput(manualCode);
|
|
434
|
+
if (parsed.state && parsed.state !== state) throw new Error("State mismatch");
|
|
435
|
+
code = parsed.code;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
} else {
|
|
439
|
+
const result = await server.waitForCode();
|
|
440
|
+
if (result?.code) code = result.code;
|
|
441
|
+
}
|
|
442
|
+
if (!code) {
|
|
443
|
+
const parsed = parseAuthorizationInput(await options.onPrompt({ message: "Paste the authorization code (or full redirect URL):" }));
|
|
444
|
+
if (parsed.state && parsed.state !== state) throw new Error("State mismatch");
|
|
445
|
+
code = parsed.code;
|
|
446
|
+
}
|
|
447
|
+
if (!code) throw new Error("Missing authorization code");
|
|
448
|
+
const tokenResult = await exchangeAuthorizationCode(code, verifier, server.redirectUri);
|
|
449
|
+
if (tokenResult.type !== "success") throw new Error(tokenResult.message);
|
|
450
|
+
const accountId = getAccountId(tokenResult.access);
|
|
451
|
+
if (!accountId) throw new Error("Failed to extract accountId from token");
|
|
452
|
+
return {
|
|
453
|
+
access: tokenResult.access,
|
|
454
|
+
refresh: tokenResult.refresh,
|
|
455
|
+
expires: tokenResult.expires,
|
|
456
|
+
accountId
|
|
457
|
+
};
|
|
458
|
+
} finally {
|
|
459
|
+
server.close();
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Build an `OAuthProviderInterface` that behaves identically to pi-ai's
|
|
464
|
+
* `openaiCodexOAuthProvider` except for the callback page HTML.
|
|
465
|
+
*/
|
|
466
|
+
function createOpenAICodexOAuthProviderWithCustomPage(renderPage) {
|
|
467
|
+
return {
|
|
468
|
+
id: "openai-codex",
|
|
469
|
+
name: "ChatGPT Plus/Pro (Codex Subscription)",
|
|
470
|
+
usesCallbackServer: true,
|
|
471
|
+
async login(callbacks) {
|
|
472
|
+
return loginOpenAICodexWithCustomPage({
|
|
473
|
+
renderPage,
|
|
474
|
+
onAuth: callbacks.onAuth,
|
|
475
|
+
onPrompt: callbacks.onPrompt,
|
|
476
|
+
onProgress: callbacks.onProgress,
|
|
477
|
+
onManualCodeInput: callbacks.onManualCodeInput
|
|
478
|
+
});
|
|
479
|
+
},
|
|
480
|
+
async refreshToken(credentials) {
|
|
481
|
+
return refreshOpenAICodexToken(credentials.refresh);
|
|
482
|
+
},
|
|
483
|
+
getApiKey(credentials) {
|
|
484
|
+
return credentials.access;
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
//#endregion
|
|
489
|
+
//#region src/chat/oauth-page/render.ts
|
|
490
|
+
function escapeHtml(value) {
|
|
491
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Default zidane-themed page. Visually neutral but distinguishable from
|
|
495
|
+
* pi-ai's stock page — dark background, mono headings, no logo (host can
|
|
496
|
+
* pass a custom renderer to add one).
|
|
497
|
+
*/
|
|
498
|
+
const renderDefaultCallbackPage = (page) => {
|
|
499
|
+
const heading = escapeHtml(page.kind === "success" ? `Signed in to ${page.provider}` : `Could not sign in to ${page.provider}`);
|
|
500
|
+
const message = escapeHtml(page.message);
|
|
501
|
+
const details = page.details ? escapeHtml(page.details) : void 0;
|
|
502
|
+
return `<!doctype html>
|
|
503
|
+
<html lang="en">
|
|
504
|
+
<head>
|
|
505
|
+
<meta charset="utf-8" />
|
|
506
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
507
|
+
<title>${heading}</title>
|
|
508
|
+
<style>
|
|
509
|
+
:root {
|
|
510
|
+
--text: #f4f4f5;
|
|
511
|
+
--text-dim: #a1a1aa;
|
|
512
|
+
--accent: ${page.kind === "success" ? "#22d3ee" : "#f87171"};
|
|
513
|
+
--page-bg: #0a0a0a;
|
|
514
|
+
--panel-bg: #131316;
|
|
515
|
+
--border: #27272a;
|
|
516
|
+
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
517
|
+
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
518
|
+
}
|
|
519
|
+
* { box-sizing: border-box; }
|
|
520
|
+
html { color-scheme: dark; }
|
|
521
|
+
body {
|
|
522
|
+
margin: 0;
|
|
523
|
+
min-height: 100vh;
|
|
524
|
+
display: flex;
|
|
525
|
+
align-items: center;
|
|
526
|
+
justify-content: center;
|
|
527
|
+
padding: 24px;
|
|
528
|
+
background: var(--page-bg);
|
|
529
|
+
color: var(--text);
|
|
530
|
+
font-family: var(--font-sans);
|
|
531
|
+
}
|
|
532
|
+
main {
|
|
533
|
+
width: 100%;
|
|
534
|
+
max-width: 520px;
|
|
535
|
+
padding: 32px;
|
|
536
|
+
background: var(--panel-bg);
|
|
537
|
+
border: 1px solid var(--border);
|
|
538
|
+
border-radius: 12px;
|
|
539
|
+
}
|
|
540
|
+
.label {
|
|
541
|
+
font-family: var(--font-mono);
|
|
542
|
+
font-size: 12px;
|
|
543
|
+
letter-spacing: 0.08em;
|
|
544
|
+
text-transform: uppercase;
|
|
545
|
+
color: var(--accent);
|
|
546
|
+
margin-bottom: 12px;
|
|
547
|
+
}
|
|
548
|
+
h1 {
|
|
549
|
+
margin: 0 0 12px;
|
|
550
|
+
font-size: 22px;
|
|
551
|
+
font-weight: 600;
|
|
552
|
+
letter-spacing: -0.01em;
|
|
553
|
+
color: var(--text);
|
|
554
|
+
}
|
|
555
|
+
p {
|
|
556
|
+
margin: 0;
|
|
557
|
+
line-height: 1.6;
|
|
558
|
+
color: var(--text-dim);
|
|
559
|
+
font-size: 14px;
|
|
560
|
+
}
|
|
561
|
+
.details {
|
|
562
|
+
margin-top: 18px;
|
|
563
|
+
padding: 12px 14px;
|
|
564
|
+
background: var(--page-bg);
|
|
565
|
+
border: 1px solid var(--border);
|
|
566
|
+
border-radius: 8px;
|
|
567
|
+
font-family: var(--font-mono);
|
|
568
|
+
font-size: 12px;
|
|
569
|
+
color: var(--text-dim);
|
|
570
|
+
white-space: pre-wrap;
|
|
571
|
+
word-break: break-word;
|
|
572
|
+
}
|
|
573
|
+
</style>
|
|
574
|
+
</head>
|
|
575
|
+
<body>
|
|
576
|
+
<main>
|
|
577
|
+
<div class="label">zidane · OAuth · ${escapeHtml(page.provider)}</div>
|
|
578
|
+
<h1>${heading}</h1>
|
|
579
|
+
<p>${message}</p>
|
|
580
|
+
${details ? `<div class="details">${details}</div>` : ""}
|
|
581
|
+
</main>
|
|
582
|
+
</body>
|
|
583
|
+
</html>`;
|
|
584
|
+
};
|
|
585
|
+
//#endregion
|
|
586
|
+
//#region src/chat/oauth-page/index.ts
|
|
587
|
+
/**
|
|
588
|
+
* Bundle helper — returns both Anthropic + OpenAI Codex providers wired
|
|
589
|
+
* with the same renderer. Hosts that want different renderers per provider
|
|
590
|
+
* should call the individual `create…WithCustomPage` factories instead.
|
|
591
|
+
*/
|
|
592
|
+
function createCustomCallbackOAuthProviders(renderPage) {
|
|
593
|
+
return {
|
|
594
|
+
anthropic: createAnthropicOAuthProviderWithCustomPage(renderPage),
|
|
595
|
+
openaiCodex: createOpenAICodexOAuthProviderWithCustomPage(renderPage)
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
let cursorRegistered = false;
|
|
599
|
+
/**
|
|
600
|
+
* Register Cursor with pi-ai's OAuth registry.
|
|
601
|
+
*
|
|
602
|
+
* Unlike Anthropic / Codex, Cursor is **not** built into pi-ai, so
|
|
603
|
+
* `getOAuthApiKey('cursor', …)` (used by `resolveOAuthApiKey` for lazy
|
|
604
|
+
* token refresh) would throw "Unknown OAuth provider" until we register it.
|
|
605
|
+
* Call once at startup from both the CLI auth path and the TUI. Idempotent.
|
|
606
|
+
*/
|
|
607
|
+
function registerCursorOAuthProvider() {
|
|
608
|
+
const provider = createCursorOAuthProvider();
|
|
609
|
+
if (!cursorRegistered) {
|
|
610
|
+
registerOAuthProvider(provider);
|
|
611
|
+
cursorRegistered = true;
|
|
612
|
+
}
|
|
613
|
+
return provider;
|
|
614
|
+
}
|
|
615
|
+
let xaiRegistered = false;
|
|
616
|
+
/**
|
|
617
|
+
* Register xAI Grok with pi-ai's OAuth registry.
|
|
618
|
+
*
|
|
619
|
+
* pi-ai ships an xAI *API-key* provider, but no OAuth one — so
|
|
620
|
+
* `getOAuthApiKey('xai-oauth', …)` (lazy refresh in `resolveOAuthApiKey`)
|
|
621
|
+
* would throw "Unknown OAuth provider" until we register our flow. The
|
|
622
|
+
* callback page routes through the supplied renderer so the post-redirect
|
|
623
|
+
* HTML matches zidane's theme. Call once at startup; idempotent.
|
|
624
|
+
*/
|
|
625
|
+
function registerXaiOAuthProvider(renderPage) {
|
|
626
|
+
const provider = createXaiOAuthProvider(renderPage);
|
|
627
|
+
if (!xaiRegistered) {
|
|
628
|
+
registerOAuthProvider(provider);
|
|
629
|
+
xaiRegistered = true;
|
|
630
|
+
}
|
|
631
|
+
return provider;
|
|
632
|
+
}
|
|
633
|
+
//#endregion
|
|
634
|
+
//#region src/chat/providers.ts
|
|
635
|
+
/**
|
|
636
|
+
* pi-ai's stock Anthropic + Codex OAuth providers bake their own callback
|
|
637
|
+
* HTML; we route both through `renderDefaultCallbackPage` so the post-redirect
|
|
638
|
+
* page matches zidane's theme. The override lives in `src/chat/oauth-page/`
|
|
639
|
+
* — see that folder's `index.ts` for the deletion path once pi-ai exposes
|
|
640
|
+
* a `renderCallbackPage` hook upstream.
|
|
641
|
+
*/
|
|
642
|
+
const { anthropic: anthropicOAuthProvider, openaiCodex: openaiCodexOAuthProvider } = createCustomCallbackOAuthProviders(renderDefaultCallbackPage);
|
|
643
|
+
registerCursorOAuthProvider();
|
|
644
|
+
/**
|
|
645
|
+
* xAI Grok isn't built into pi-ai's OAuth registry (only its API-key provider
|
|
646
|
+
* is), so register our loopback-PKCE flow — themed via `renderDefaultCallbackPage`
|
|
647
|
+
* — so lazy token refresh resolves through it.
|
|
648
|
+
*/
|
|
649
|
+
const xaiOAuthProvider = registerXaiOAuthProvider(renderDefaultCallbackPage);
|
|
650
|
+
/** Convenience accessor — returns `credentialFileKey ?? key`. */
|
|
651
|
+
function credKeyOf(desc) {
|
|
652
|
+
return desc.credentialFileKey ?? desc.key;
|
|
653
|
+
}
|
|
654
|
+
/** Convenience accessor — returns `piProviderId ?? key`. */
|
|
655
|
+
function piIdOf(desc) {
|
|
656
|
+
return desc.piProviderId ?? desc.key;
|
|
657
|
+
}
|
|
658
|
+
const anthropicDescriptor = {
|
|
659
|
+
key: "anthropic",
|
|
660
|
+
label: "Anthropic",
|
|
661
|
+
factory: anthropic,
|
|
662
|
+
defaultModel: "claude-opus-4-8",
|
|
663
|
+
envKey: "ANTHROPIC_API_KEY",
|
|
664
|
+
apiKeyPlaceholder: "sk-ant-…",
|
|
665
|
+
oauthProvider: anthropicOAuthProvider,
|
|
666
|
+
oauthHint: "Claude Pro/Max subscription",
|
|
667
|
+
extraModels: ANTHROPIC_EXTRA_MODELS,
|
|
668
|
+
optionsFor: (id) => FAST_MODE_OPTIONS[id] ? [FAST_MODE_OPTIONS[id]] : void 0
|
|
669
|
+
};
|
|
670
|
+
const openaiDescriptor = {
|
|
671
|
+
key: "openai",
|
|
672
|
+
label: "OpenAI",
|
|
673
|
+
factory: openai,
|
|
674
|
+
defaultModel: "gpt-5.5",
|
|
675
|
+
envKey: "OPENAI_CODEX_API_KEY",
|
|
676
|
+
credentialFileKey: "openai-codex",
|
|
677
|
+
piProviderId: "openai-codex",
|
|
678
|
+
apiKeyPlaceholder: "sk-… or eyJ… (Codex)",
|
|
679
|
+
oauthProvider: openaiCodexOAuthProvider
|
|
680
|
+
};
|
|
681
|
+
const openrouterDescriptor = {
|
|
682
|
+
key: "openrouter",
|
|
683
|
+
label: "OpenRouter",
|
|
684
|
+
factory: openrouter,
|
|
685
|
+
defaultModel: "anthropic/claude-sonnet-4-6",
|
|
686
|
+
envKey: "OPENROUTER_API_KEY",
|
|
687
|
+
apiKeyPlaceholder: "sk-or-…"
|
|
688
|
+
};
|
|
689
|
+
const cerebrasDescriptor = {
|
|
690
|
+
key: "cerebras",
|
|
691
|
+
label: "Cerebras",
|
|
692
|
+
factory: cerebras,
|
|
693
|
+
defaultModel: "zai-glm-4.7",
|
|
694
|
+
envKey: "CEREBRAS_API_KEY",
|
|
695
|
+
apiKeyPlaceholder: "csk-…"
|
|
696
|
+
};
|
|
697
|
+
/**
|
|
698
|
+
* xAI Grok. Supports BOTH a static API key (`XAI_API_KEY`) and the
|
|
699
|
+
* SuperGrok / X Premium+ OAuth subscription, so the wizard offers both methods.
|
|
700
|
+
* Models come from pi-ai's `xai` registry (OpenAI-compatible endpoint), shared
|
|
701
|
+
* by both auth modes.
|
|
702
|
+
*/
|
|
703
|
+
const xaiDescriptor = {
|
|
704
|
+
key: "xai",
|
|
705
|
+
label: "xAI Grok",
|
|
706
|
+
factory: xai,
|
|
707
|
+
defaultModel: "grok-4.3",
|
|
708
|
+
envKey: "XAI_API_KEY",
|
|
709
|
+
apiKeyPlaceholder: "xai-…",
|
|
710
|
+
oauthProvider: xaiOAuthProvider,
|
|
711
|
+
oauthHint: "SuperGrok / X Premium+ subscription",
|
|
712
|
+
credentialFileKey: "xai-oauth",
|
|
713
|
+
piProviderId: "xai"
|
|
714
|
+
};
|
|
715
|
+
const arceeDescriptor = {
|
|
716
|
+
key: "arcee",
|
|
717
|
+
label: "Arcee",
|
|
718
|
+
factory: arcee,
|
|
719
|
+
defaultModel: "trinity-large-thinking",
|
|
720
|
+
envKey: "ARCEE_API_KEY",
|
|
721
|
+
apiKeyPlaceholder: "arcee-…",
|
|
722
|
+
models: [
|
|
723
|
+
{
|
|
724
|
+
id: "trinity-large-thinking",
|
|
725
|
+
name: "Trinity Large (Thinking)",
|
|
726
|
+
contextWindow: 256e3,
|
|
727
|
+
reasoning: true,
|
|
728
|
+
input: ["text"],
|
|
729
|
+
cost: {
|
|
730
|
+
input: .25,
|
|
731
|
+
output: .8
|
|
732
|
+
}
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
id: "trinity-large-preview",
|
|
736
|
+
name: "Trinity Large (Preview)",
|
|
737
|
+
contextWindow: 128e3,
|
|
738
|
+
input: ["text"],
|
|
739
|
+
cost: {
|
|
740
|
+
input: .45,
|
|
741
|
+
output: .15
|
|
742
|
+
}
|
|
743
|
+
},
|
|
744
|
+
{
|
|
745
|
+
id: "trinity-mini",
|
|
746
|
+
name: "Trinity Mini",
|
|
747
|
+
contextWindow: 128e3,
|
|
748
|
+
input: ["text"],
|
|
749
|
+
cost: {
|
|
750
|
+
input: .045,
|
|
751
|
+
output: .15
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
]
|
|
755
|
+
};
|
|
756
|
+
const basetenDescriptor = {
|
|
757
|
+
key: "baseten",
|
|
758
|
+
label: "Baseten",
|
|
759
|
+
factory: baseten,
|
|
760
|
+
defaultModel: "zai-org/GLM-5.1",
|
|
761
|
+
envKey: "BASETEN_API_KEY",
|
|
762
|
+
apiKeyPlaceholder: "baseten-api-key…",
|
|
763
|
+
models: [
|
|
764
|
+
{
|
|
765
|
+
id: "zai-org/GLM-5.2",
|
|
766
|
+
name: "GLM 5.2",
|
|
767
|
+
contextWindow: 1e6,
|
|
768
|
+
reasoning: true,
|
|
769
|
+
input: ["text"]
|
|
770
|
+
},
|
|
771
|
+
{
|
|
772
|
+
id: "zai-org/GLM-5.1",
|
|
773
|
+
name: "GLM 5.1",
|
|
774
|
+
contextWindow: 2e5,
|
|
775
|
+
reasoning: true,
|
|
776
|
+
input: ["text"]
|
|
777
|
+
},
|
|
778
|
+
{
|
|
779
|
+
id: "zai-org/GLM-5",
|
|
780
|
+
name: "GLM 5",
|
|
781
|
+
contextWindow: 2e5,
|
|
782
|
+
reasoning: true,
|
|
783
|
+
input: ["text"],
|
|
784
|
+
cost: {
|
|
785
|
+
input: .95,
|
|
786
|
+
output: 3.15,
|
|
787
|
+
cacheRead: .2
|
|
788
|
+
}
|
|
789
|
+
},
|
|
790
|
+
{
|
|
791
|
+
id: "zai-org/GLM-4.7",
|
|
792
|
+
name: "GLM 4.7",
|
|
793
|
+
contextWindow: 2e5,
|
|
794
|
+
reasoning: true,
|
|
795
|
+
input: ["text"],
|
|
796
|
+
cost: {
|
|
797
|
+
input: .6,
|
|
798
|
+
output: 2.2,
|
|
799
|
+
cacheRead: .12
|
|
800
|
+
}
|
|
801
|
+
},
|
|
802
|
+
{
|
|
803
|
+
id: "moonshotai/Kimi-K2.6",
|
|
804
|
+
name: "Kimi K2.6",
|
|
805
|
+
contextWindow: 256e3,
|
|
806
|
+
reasoning: true,
|
|
807
|
+
input: ["text", "image"],
|
|
808
|
+
cost: {
|
|
809
|
+
input: 1,
|
|
810
|
+
output: 3.9,
|
|
811
|
+
cacheRead: .2
|
|
812
|
+
}
|
|
813
|
+
},
|
|
814
|
+
{
|
|
815
|
+
id: "moonshotai/Kimi-K2.5",
|
|
816
|
+
name: "Kimi K2.5",
|
|
817
|
+
contextWindow: 256e3,
|
|
818
|
+
reasoning: true,
|
|
819
|
+
input: ["text", "image"],
|
|
820
|
+
cost: {
|
|
821
|
+
input: .6,
|
|
822
|
+
output: 3,
|
|
823
|
+
cacheRead: .12
|
|
824
|
+
}
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
id: "deepseek-ai/DeepSeek-V4-Pro",
|
|
828
|
+
name: "DeepSeek V4 Pro",
|
|
829
|
+
contextWindow: 131e3,
|
|
830
|
+
reasoning: true,
|
|
831
|
+
input: ["text"],
|
|
832
|
+
cost: {
|
|
833
|
+
input: 1.74,
|
|
834
|
+
output: 3.48,
|
|
835
|
+
cacheRead: .145
|
|
836
|
+
}
|
|
837
|
+
},
|
|
838
|
+
{
|
|
839
|
+
id: "nvidia/Nemotron-120B-A12B",
|
|
840
|
+
name: "Nemotron Super",
|
|
841
|
+
contextWindow: 2e5,
|
|
842
|
+
reasoning: true,
|
|
843
|
+
input: ["text"],
|
|
844
|
+
cost: {
|
|
845
|
+
input: .3,
|
|
846
|
+
output: .75,
|
|
847
|
+
cacheRead: .06
|
|
848
|
+
}
|
|
849
|
+
},
|
|
850
|
+
{
|
|
851
|
+
id: "nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B",
|
|
852
|
+
name: "Nemotron Ultra",
|
|
853
|
+
contextWindow: 2e5,
|
|
854
|
+
reasoning: true,
|
|
855
|
+
input: ["text"]
|
|
856
|
+
},
|
|
857
|
+
{
|
|
858
|
+
id: "openai/gpt-oss-120b",
|
|
859
|
+
name: "GPT-OSS 120B",
|
|
860
|
+
contextWindow: 128e3,
|
|
861
|
+
reasoning: true,
|
|
862
|
+
input: ["text"],
|
|
863
|
+
cost: {
|
|
864
|
+
input: .1,
|
|
865
|
+
output: .5
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
]
|
|
869
|
+
};
|
|
870
|
+
/**
|
|
871
|
+
* Conservative context-window assumption for a user-configured local model.
|
|
872
|
+
* Local runtimes expose no reliable per-model metadata over the OpenAI-compat
|
|
873
|
+
* `/v1/models` endpoint (it returns ids, not context sizes), so we pick a
|
|
874
|
+
* floor that fits the smallest mainstream OSS models (Llama 3.x 8B, Qwen2.5)
|
|
875
|
+
* without over-promising. The footer's context indicator and auto-compaction
|
|
876
|
+
* lean on this — under-estimating is the safe direction (compact early rather
|
|
877
|
+
* than overflow the server's real window).
|
|
878
|
+
*
|
|
879
|
+
* Users running larger-context models (e.g. a 128K Qwen) can raise it via the
|
|
880
|
+
* `LOCAL_LLM_CONTEXT_WINDOW` env var / customField, which supports a single
|
|
881
|
+
* value or a per-model map (resolved by {@link resolveContextWindow}).
|
|
882
|
+
*/
|
|
883
|
+
const LOCAL_DEFAULT_CONTEXT_WINDOW = 8192;
|
|
884
|
+
/**
|
|
885
|
+
* Local OpenAI-compatible LLM (Ollama, vLLM, LM Studio, Lemonade, llama.cpp).
|
|
886
|
+
*
|
|
887
|
+
* No fixed base URL or model catalogue — both come from the user via
|
|
888
|
+
* `customFields`. No `envKey`, so the wizard skips the API-key prompt and
|
|
889
|
+
* treats the customFields-only credential as the auth signal (see
|
|
890
|
+
* `applyApiKeyEnv` + `detectAuth`).
|
|
891
|
+
*
|
|
892
|
+
* Vision / cache / reasoning are off by default in the factory — flip them
|
|
893
|
+
* by passing a custom descriptor that calls `local({ capabilities })`.
|
|
894
|
+
*
|
|
895
|
+
* `models` is a getter, not a static list: there's no fixed catalogue to ship,
|
|
896
|
+
* but once the user has configured a default model (mirrored into
|
|
897
|
+
* `LOCAL_LLM_DEFAULT_MODEL` by `applyApiKeyEnv`), we surface it as a
|
|
898
|
+
* single-entry list so the model picker + footer aren't empty. Resolved at
|
|
899
|
+
* access time because the env var is populated at TUI launch, after this
|
|
900
|
+
* module loads. Empty list when unconfigured — the picker hides, the user
|
|
901
|
+
* types a slug at the prompt as before.
|
|
902
|
+
*/
|
|
903
|
+
const localDescriptor = {
|
|
904
|
+
key: "local",
|
|
905
|
+
label: "Local LLM",
|
|
906
|
+
factory: local,
|
|
907
|
+
get models() {
|
|
908
|
+
const configured = process.env.LOCAL_LLM_DEFAULT_MODEL?.trim();
|
|
909
|
+
if (!configured) return [];
|
|
910
|
+
return [{
|
|
911
|
+
id: configured,
|
|
912
|
+
name: configured,
|
|
913
|
+
contextWindow: resolveContextWindow(process.env.LOCAL_LLM_CONTEXT_WINDOW, configured) ?? LOCAL_DEFAULT_CONTEXT_WINDOW,
|
|
914
|
+
input: ["text"]
|
|
915
|
+
}];
|
|
916
|
+
},
|
|
917
|
+
customFields: [
|
|
918
|
+
{
|
|
919
|
+
key: "baseURL",
|
|
920
|
+
label: "Base URL",
|
|
921
|
+
envVar: "LOCAL_LLM_BASE_URL",
|
|
922
|
+
placeholder: "http://localhost:11434/v1",
|
|
923
|
+
hint: "Where your local LLM server is reachable. Examples: Ollama → http://localhost:11434/v1, vLLM → http://localhost:8000/v1, LM Studio → http://localhost:1234/v1.",
|
|
924
|
+
required: true
|
|
925
|
+
},
|
|
926
|
+
{
|
|
927
|
+
key: "apiKey",
|
|
928
|
+
label: "API key",
|
|
929
|
+
envVar: "LOCAL_LLM_API_KEY",
|
|
930
|
+
placeholder: "leave blank if your server is unauthenticated"
|
|
931
|
+
},
|
|
932
|
+
{
|
|
933
|
+
key: "model",
|
|
934
|
+
label: "Default model",
|
|
935
|
+
envVar: "LOCAL_LLM_DEFAULT_MODEL",
|
|
936
|
+
placeholder: "e.g. llama3.1:8b, qwen2.5-coder, mistral-small",
|
|
937
|
+
hint: "Model id your server exposes. Leave blank to pick later from the model picker."
|
|
938
|
+
},
|
|
939
|
+
{
|
|
940
|
+
key: "contextWindow",
|
|
941
|
+
label: "Context window (tokens)",
|
|
942
|
+
envVar: "LOCAL_LLM_CONTEXT_WINDOW",
|
|
943
|
+
placeholder: "e.g. 32768 · or per-model: llama3.1:8b=32768, qwen2.5-coder=128k, 64k",
|
|
944
|
+
hint: "Context length(s) for your local model(s) — local runtimes don't advertise this, so set it here to enable the context indicator and auto-compaction. Use a single value (32768, 128k) for all models, or a comma-separated map of `model=window` with an optional bare value as the fallback. Press"
|
|
945
|
+
}
|
|
946
|
+
],
|
|
947
|
+
contextWindowEnvVar: "LOCAL_LLM_CONTEXT_WINDOW"
|
|
948
|
+
};
|
|
949
|
+
/**
|
|
950
|
+
* Default provider registry. Passed verbatim when `runTui` is invoked without
|
|
951
|
+
* an explicit `providers` option. Hosts that want to override per-provider
|
|
952
|
+
* metadata can spread this and replace specific entries:
|
|
953
|
+
*
|
|
954
|
+
* ```ts
|
|
955
|
+
* runTui({ providers: { ...BUILTIN_PROVIDERS, anthropic: myOwnAnthropicDescriptor } })
|
|
956
|
+
* ```
|
|
957
|
+
*
|
|
958
|
+
* `cursor` is intentionally NOT registered here: OAuth works, but inference
|
|
959
|
+
* isn't wired (`cursor.stream()` throws — Cursor speaks a protobuf/HTTP-2
|
|
960
|
+
* agent protocol, not OpenAI-compat). Shipping it in the picker only lets users
|
|
961
|
+
* select a model that errors every turn. The descriptor stays exported so a
|
|
962
|
+
* host can opt in (`{ ...BUILTIN_PROVIDERS, cursor: cursorDescriptor }`) once
|
|
963
|
+
* the transport lands — re-add it here at that point.
|
|
964
|
+
*/
|
|
965
|
+
const BUILTIN_PROVIDERS = {
|
|
966
|
+
anthropic: anthropicDescriptor,
|
|
967
|
+
openai: openaiDescriptor,
|
|
968
|
+
openrouter: openrouterDescriptor,
|
|
969
|
+
cerebras: cerebrasDescriptor,
|
|
970
|
+
xai: xaiDescriptor,
|
|
971
|
+
arcee: arceeDescriptor,
|
|
972
|
+
baseten: basetenDescriptor,
|
|
973
|
+
local: localDescriptor
|
|
974
|
+
};
|
|
975
|
+
/**
|
|
976
|
+
* Resolve the model list for a given provider. Honors `descriptor.models`
|
|
977
|
+
* when set; otherwise queries pi-ai via `descriptor.piProviderId`. Returns
|
|
978
|
+
* `[]` for descriptors with no known mapping (custom providers without a
|
|
979
|
+
* model list) — callers should hide the model picker in that case.
|
|
980
|
+
*/
|
|
981
|
+
function modelsForDescriptor(descriptor) {
|
|
982
|
+
if (descriptor.models) return descriptor.models;
|
|
983
|
+
let bundled;
|
|
984
|
+
try {
|
|
985
|
+
bundled = getModels(piIdOf(descriptor));
|
|
986
|
+
} catch {
|
|
987
|
+
bundled = [];
|
|
988
|
+
}
|
|
989
|
+
if (descriptor.extraModels?.length) {
|
|
990
|
+
const bundledIds = new Set(bundled.map((m) => m.id));
|
|
991
|
+
bundled = [...descriptor.extraModels.filter((m) => !bundledIds.has(m.id)), ...bundled];
|
|
992
|
+
}
|
|
993
|
+
return descriptor.optionsFor ? bundled.map((m) => decorateOptions(m, descriptor)) : bundled;
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Attach descriptor-resolved {@link ModelOption}s to a model that doesn't
|
|
997
|
+
* already declare its own. Pure / non-mutating — returns the input untouched
|
|
998
|
+
* when the model has options already or the descriptor resolves none.
|
|
999
|
+
*/
|
|
1000
|
+
function decorateOptions(model, descriptor) {
|
|
1001
|
+
if (model.options?.length || !descriptor.optionsFor) return model;
|
|
1002
|
+
const options = descriptor.optionsFor(model.id);
|
|
1003
|
+
return options?.length ? {
|
|
1004
|
+
...model,
|
|
1005
|
+
options
|
|
1006
|
+
} : model;
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Resolve a single model's metadata via the descriptor's model source.
|
|
1010
|
+
* Mirrors {@link modelsForDescriptor} routing: descriptor's own list wins,
|
|
1011
|
+
* pi-ai's registry is the fallback. Returns `null` when the model isn't
|
|
1012
|
+
* known.
|
|
1013
|
+
*/
|
|
1014
|
+
function getModelInfo(descriptor, modelId) {
|
|
1015
|
+
if (descriptor.models) return descriptor.models.find((m) => m.id === modelId) ?? null;
|
|
1016
|
+
try {
|
|
1017
|
+
const bundled = getModel(piIdOf(descriptor), modelId);
|
|
1018
|
+
if (bundled) return decorateOptions(bundled, descriptor);
|
|
1019
|
+
} catch {}
|
|
1020
|
+
const extra = descriptor.extraModels?.find((m) => m.id === modelId);
|
|
1021
|
+
return extra ? decorateOptions(extra, descriptor) : null;
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Parse a user-supplied context-window string into a token count.
|
|
1025
|
+
*
|
|
1026
|
+
* Accepts a plain integer (`"32768"`), or a number with a `k`/`m` suffix
|
|
1027
|
+
* (×1_000 / ×1_000_000, case-insensitive, optional space) — so `"128k"`
|
|
1028
|
+
* yields `128_000`, matching how the footer renders windows (`fmtTokens`).
|
|
1029
|
+
* Fractional values are floored (`"1.5k"` → `1500`). Returns `null` for
|
|
1030
|
+
* empty, malformed, zero, or negative input so callers fall through to
|
|
1031
|
+
* "window unknown" rather than a bogus ceiling.
|
|
1032
|
+
*/
|
|
1033
|
+
function parseContextWindow(raw) {
|
|
1034
|
+
if (raw == null) return null;
|
|
1035
|
+
const trimmed = raw.trim();
|
|
1036
|
+
if (trimmed.length === 0) return null;
|
|
1037
|
+
const match = /^(\d+(?:\.\d+)?)\s*([km])?$/i.exec(trimmed);
|
|
1038
|
+
if (!match) return null;
|
|
1039
|
+
const suffix = match[2]?.toLowerCase();
|
|
1040
|
+
const mult = suffix === "k" ? 1e3 : suffix === "m" ? 1e6 : 1;
|
|
1041
|
+
const value = Math.floor(Number.parseFloat(match[1]) * mult);
|
|
1042
|
+
return value >= 1 ? value : null;
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Resolve a per-model context window from a user-supplied spec string.
|
|
1046
|
+
*
|
|
1047
|
+
* A spec is a comma-separated list of entries. An entry is either:
|
|
1048
|
+
* - `model=window` — a per-model override (split on the **first** `=`, so
|
|
1049
|
+
* model ids containing `:` or `/` survive), or
|
|
1050
|
+
* - a bare `window` — the fallback applied to any model not named above.
|
|
1051
|
+
*
|
|
1052
|
+
* Windows are parsed by {@link parseContextWindow} (plain int or k/m suffix).
|
|
1053
|
+
* Whitespace around entries and the `=` is ignored; malformed entries are
|
|
1054
|
+
* skipped rather than poisoning the whole spec; a later duplicate key wins.
|
|
1055
|
+
*
|
|
1056
|
+
* Lookup order for `modelId`: exact map entry → bare fallback → `null`. A
|
|
1057
|
+
* lone bare value (`"32768"`) therefore behaves as a single global window,
|
|
1058
|
+
* keeping the simple single-model config working.
|
|
1059
|
+
*
|
|
1060
|
+
* @example resolveContextWindow('llama3.1:8b=32768, qwen=128k, 64k', 'qwen') // 128000
|
|
1061
|
+
*/
|
|
1062
|
+
function resolveContextWindow(spec, modelId) {
|
|
1063
|
+
if (spec == null) return null;
|
|
1064
|
+
const overrides = /* @__PURE__ */ new Map();
|
|
1065
|
+
let fallback = null;
|
|
1066
|
+
for (const entry of spec.split(",")) {
|
|
1067
|
+
const eq = entry.indexOf("=");
|
|
1068
|
+
if (eq === -1) {
|
|
1069
|
+
const bare = parseContextWindow(entry);
|
|
1070
|
+
if (bare != null) fallback = bare;
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
1073
|
+
const key = entry.slice(0, eq).trim();
|
|
1074
|
+
const window = parseContextWindow(entry.slice(eq + 1));
|
|
1075
|
+
if (key.length > 0 && window != null) overrides.set(key, window);
|
|
1076
|
+
}
|
|
1077
|
+
return overrides.get(modelId) ?? fallback;
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Look up the model's max context window via the descriptor's model source.
|
|
1081
|
+
* Falls back to {@link ProviderDescriptor.contextWindowEnvVar} (parsed via
|
|
1082
|
+
* {@link resolveContextWindow}, which supports a per-model map) when the
|
|
1083
|
+
* registry has nothing — this is how local LLMs, whose runtimes don't
|
|
1084
|
+
* advertise a window, get one. Returns `null` when neither source resolves a
|
|
1085
|
+
* value (custom slugs, providers without a registry or a configured window);
|
|
1086
|
+
* callers should hide the context indicator and skip auto-compaction then.
|
|
1087
|
+
*/
|
|
1088
|
+
function getContextWindow(descriptor, modelId) {
|
|
1089
|
+
const fromRegistry = getModelInfo(descriptor, modelId)?.contextWindow;
|
|
1090
|
+
if (fromRegistry != null) return fromRegistry;
|
|
1091
|
+
if (descriptor.contextWindowEnvVar) return resolveContextWindow(process.env[descriptor.contextWindowEnvVar], modelId);
|
|
1092
|
+
return null;
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* Whether the given model exposes a reasoning / extended-thinking knob.
|
|
1096
|
+
* Drives the TUI's effort picker visibility — the `ctrl+n` shortcut and
|
|
1097
|
+
* the bottom-bar `effortName` segment only surface when this is `true`.
|
|
1098
|
+
* Returns `false` for unknown models (no registry entry → no advertised
|
|
1099
|
+
* capability).
|
|
1100
|
+
*/
|
|
1101
|
+
function modelSupportsReasoning(descriptor, modelId) {
|
|
1102
|
+
return getModelInfo(descriptor, modelId)?.reasoning === true;
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Custom {@link ModelOption}s the given model supports (e.g. fast mode), or `[]`
|
|
1106
|
+
* when none. Drives the TUI/GUI options picker visibility — surfaces only when
|
|
1107
|
+
* non-empty.
|
|
1108
|
+
*/
|
|
1109
|
+
function modelOptionsFor(descriptor, modelId) {
|
|
1110
|
+
return getModelInfo(descriptor, modelId)?.options ?? [];
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Normalize a model-options blob to an enabled-only `{ id: true }` map.
|
|
1114
|
+
*
|
|
1115
|
+
* Single source of truth shared by every surface that reads or persists model
|
|
1116
|
+
* options — the TUI run path, the GUI engine reader, and the GUI IPC handlers.
|
|
1117
|
+
* Tolerant of arbitrary input (persisted JSON metadata may be anything): a
|
|
1118
|
+
* non-object → `{}`, and only entries strictly equal to `true` survive. This
|
|
1119
|
+
* keeps the persisted shape minimal (no `{ fast: false }` noise) and means
|
|
1120
|
+
* callers never forward a disabled option to `agent.run`.
|
|
1121
|
+
*/
|
|
1122
|
+
function enabledModelOptions(raw) {
|
|
1123
|
+
if (!raw || typeof raw !== "object") return {};
|
|
1124
|
+
const out = {};
|
|
1125
|
+
for (const [id, on] of Object.entries(raw)) if (on === true) out[id] = true;
|
|
1126
|
+
return out;
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Resolve a model's remembered options for a (re)pick: keep only options the
|
|
1130
|
+
* model still declares AND that are enabled, dropping any that no longer apply
|
|
1131
|
+
* (e.g. switching away from a model that supported `fast`). Returns `undefined`
|
|
1132
|
+
* when nothing applies so callers can omit the field entirely.
|
|
1133
|
+
*
|
|
1134
|
+
* Shared by the TUI pick path and the launch-time resume path so "which options
|
|
1135
|
+
* survive a model switch / relaunch" is decided in exactly one place.
|
|
1136
|
+
*/
|
|
1137
|
+
function restoreModelOptions(descriptor, modelId, remembered) {
|
|
1138
|
+
const validIds = new Set(modelOptionsFor(descriptor, modelId).map((o) => o.id));
|
|
1139
|
+
if (validIds.size === 0) return void 0;
|
|
1140
|
+
const enabled = enabledModelOptions(remembered?.[modelId]);
|
|
1141
|
+
const filtered = {};
|
|
1142
|
+
for (const id of Object.keys(enabled)) if (validIds.has(id)) filtered[id] = true;
|
|
1143
|
+
return Object.keys(filtered).length > 0 ? filtered : void 0;
|
|
1144
|
+
}
|
|
1145
|
+
//#endregion
|
|
1146
|
+
//#region src/chat/credentials.ts
|
|
1147
|
+
/** POSIX mode for the credentials file. Ignored on Windows. */
|
|
1148
|
+
const FILE_MODE = 384;
|
|
1149
|
+
/**
|
|
1150
|
+
* Resolve the credentials file path given the resolved TUI data directory
|
|
1151
|
+
* (typically `~/.zidane`, i.e. `config.paths.dir`).
|
|
1152
|
+
*
|
|
1153
|
+
* Matches the convention used elsewhere in the TUI (sessions.db, state.json)
|
|
1154
|
+
* so a single `ZIDANE_STORAGE_DIR` override moves the entire data root.
|
|
1155
|
+
*/
|
|
1156
|
+
function credentialsPath(dataDir) {
|
|
1157
|
+
return resolve(dataDir, "credentials.json");
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Read credentials from disk.
|
|
1161
|
+
*
|
|
1162
|
+
* Returns `{}` when the file is missing or corrupt (last-ditch tolerance —
|
|
1163
|
+
* a hand-edit gone wrong shouldn't lock the user out of re-authing). On first
|
|
1164
|
+
* call with no file present, attempts a migration from `cwd/.credentials.json`
|
|
1165
|
+
* (the legacy location used by `bun run auth`).
|
|
1166
|
+
*/
|
|
1167
|
+
function readCredentials(dataDir) {
|
|
1168
|
+
const path = credentialsPath(dataDir);
|
|
1169
|
+
if (!existsSync(path)) {
|
|
1170
|
+
const migrated = migrateLegacyFile(path);
|
|
1171
|
+
if (migrated) return migrated;
|
|
1172
|
+
return {};
|
|
1173
|
+
}
|
|
1174
|
+
try {
|
|
1175
|
+
const raw = readFileSync(path, "utf-8");
|
|
1176
|
+
const parsed = JSON.parse(raw);
|
|
1177
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
1178
|
+
return parsed;
|
|
1179
|
+
} catch {
|
|
1180
|
+
return {};
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
/** Read a single provider's credential (translating via the descriptor). */
|
|
1184
|
+
function readProviderCredential(dataDir, descriptor) {
|
|
1185
|
+
return readCredentials(dataDir)[credKeyOf(descriptor)];
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Write credentials atomically (write-then-rename) with mode 0o600.
|
|
1189
|
+
*
|
|
1190
|
+
* Atomic on the same filesystem — readers either see the previous file or the
|
|
1191
|
+
* new one, never a half-written intermediate. Creates the parent dir if needed
|
|
1192
|
+
* (first launch on a fresh machine: `~/.zidane/` may not exist yet).
|
|
1193
|
+
*/
|
|
1194
|
+
function writeCredentials(dataDir, creds) {
|
|
1195
|
+
writeFileAtomic(credentialsPath(dataDir), `${JSON.stringify(creds, null, 2)}\n`, {
|
|
1196
|
+
ensureDir: true,
|
|
1197
|
+
mode: FILE_MODE
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
function setProviderCredential(dataDir, descriptor, cred) {
|
|
1201
|
+
const all = readCredentials(dataDir);
|
|
1202
|
+
all[credKeyOf(descriptor)] = cred;
|
|
1203
|
+
writeCredentials(dataDir, all);
|
|
1204
|
+
}
|
|
1205
|
+
function removeProviderCredential(dataDir, descriptor) {
|
|
1206
|
+
const all = readCredentials(dataDir);
|
|
1207
|
+
const fileKey = credKeyOf(descriptor);
|
|
1208
|
+
if (!(fileKey in all)) return;
|
|
1209
|
+
delete all[fileKey];
|
|
1210
|
+
writeCredentials(dataDir, all);
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* Reconcile `process.env` with the stored credentials so the harness providers
|
|
1214
|
+
* pick up the wizard's output via their existing env-var resolution. Called
|
|
1215
|
+
* once at TUI launch after the credentials file has been resolved.
|
|
1216
|
+
*
|
|
1217
|
+
* **Precedence: stored credential > ambient env.** When the user has run the
|
|
1218
|
+
* auth wizard, that's an explicit, recent action — it MUST win over whatever
|
|
1219
|
+
* ambient value is already in `process.env`. Otherwise an upgrade reliably
|
|
1220
|
+
* breaks for anyone whose cwd happens to contain a stale `.env`: Bun
|
|
1221
|
+
* auto-loads `.env` from cwd into `process.env` before our code runs, the
|
|
1222
|
+
* legacy `bun run auth` used to upsert tokens into `cwd/.env`, and even a
|
|
1223
|
+
* one-off `export ANTHROPIC_API_KEY=…` from months ago in the user's shell rc
|
|
1224
|
+
* can outlive its usefulness. None of those should override a key the user
|
|
1225
|
+
* just typed into the wizard.
|
|
1226
|
+
*
|
|
1227
|
+
* Three cases per registered provider with an `envKey`:
|
|
1228
|
+
*
|
|
1229
|
+
* 1. Stored `kind: 'apikey'` → overwrite env with the stored value. Wizard
|
|
1230
|
+
* wins. A debug log fires when this clobbers a different ambient value.
|
|
1231
|
+
* 2. Stored `kind: 'oauth'` → DELETE env, so {@link resolveOAuthApiKey}
|
|
1232
|
+
* falls through to the file-read + refresh path. A stale OAuth token
|
|
1233
|
+
* in env would otherwise short-circuit refresh and get rejected.
|
|
1234
|
+
* 3. No stored entry → leave env alone. Env-only setups (no wizard run yet,
|
|
1235
|
+
* hosts driving zidane purely from secrets management) keep working.
|
|
1236
|
+
*
|
|
1237
|
+
* To opt out and force ambient env to win again, the user signs the provider
|
|
1238
|
+
* out via the wizard (which deletes the stored entry → case 3 above).
|
|
1239
|
+
*
|
|
1240
|
+
* Descriptors without an `envKey` (OAuth-only providers, custom providers
|
|
1241
|
+
* that bypass env-var resolution) are skipped silently.
|
|
1242
|
+
*/
|
|
1243
|
+
function applyApiKeyEnv(dataDir, registry) {
|
|
1244
|
+
const creds = readCredentials(dataDir);
|
|
1245
|
+
for (const descriptor of Object.values(registry)) {
|
|
1246
|
+
const cred = creds[credKeyOf(descriptor)];
|
|
1247
|
+
if (cred?.kind === "apikey" && descriptor.customFields) {
|
|
1248
|
+
const stored = cred.customFields ?? {};
|
|
1249
|
+
for (const field of descriptor.customFields) {
|
|
1250
|
+
const value = stored[field.key];
|
|
1251
|
+
if (typeof value === "string" && value.length > 0) {
|
|
1252
|
+
const ambient = process.env[field.envVar];
|
|
1253
|
+
if (ambient && ambient !== value && process.env.ZIDANE_DEBUG) process.stderr.write(`[zidane/chat] applyApiKeyEnv: overriding ambient \`${field.envVar}\` with stored value from credentials.json (provider=${descriptor.key}, field=${field.key}).\n`);
|
|
1254
|
+
process.env[field.envVar] = value;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
if (!descriptor.envKey) continue;
|
|
1259
|
+
const envKey = descriptor.envKey;
|
|
1260
|
+
const ambient = process.env[envKey];
|
|
1261
|
+
if (cred?.kind === "apikey" && cred.value) {
|
|
1262
|
+
if (ambient && ambient !== cred.value && process.env.ZIDANE_DEBUG) process.stderr.write(`[zidane/chat] applyApiKeyEnv: overriding ambient \`${envKey}\` with stored API key from credentials.json (provider=${descriptor.key}). Sign out via the auth wizard if the ambient value was intended to win.\n`);
|
|
1263
|
+
process.env[envKey] = cred.value;
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1266
|
+
if (cred?.kind === "oauth" && cred.access) {
|
|
1267
|
+
if (ambient !== void 0 && process.env.ZIDANE_DEBUG) process.stderr.write(`[zidane/chat] applyApiKeyEnv: clearing ambient \`${envKey}\` because credentials.json has stored OAuth for ${descriptor.key} — refresh path needs the file.\n`);
|
|
1268
|
+
delete process.env[envKey];
|
|
1269
|
+
continue;
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* `bun run auth` (pre-TUI) wrote `cwd/.credentials.json` with an entry per
|
|
1275
|
+
* provider mapping directly to an OAuthCredentials payload, e.g.:
|
|
1276
|
+
*
|
|
1277
|
+
* {
|
|
1278
|
+
* "anthropic": { "access": "...", "refresh": "...", "expires": 123 },
|
|
1279
|
+
* "openai-codex": { "access": "...", "refresh": "...", "expires": 123, "accountId": "..." }
|
|
1280
|
+
* }
|
|
1281
|
+
*
|
|
1282
|
+
* We don't delete the legacy file — it might still be used by a host that
|
|
1283
|
+
* imports the harness directly. We just copy its contents into the new
|
|
1284
|
+
* location under the kind-tagged shape so the TUI picks them up.
|
|
1285
|
+
*
|
|
1286
|
+
* Migration is provider-agnostic: any top-level entry with an `access` field
|
|
1287
|
+
* is preserved verbatim (extras included), under the same key. The TUI's
|
|
1288
|
+
* detection then looks them up via the matching descriptor's `credentialFileKey`.
|
|
1289
|
+
*
|
|
1290
|
+
* Returns the migrated credentials when the migration ran, or `null` when
|
|
1291
|
+
* there's no legacy file to migrate.
|
|
1292
|
+
*/
|
|
1293
|
+
function migrateLegacyFile(targetPath) {
|
|
1294
|
+
const legacyPath = resolve(process.cwd(), ".credentials.json");
|
|
1295
|
+
const real = (p) => {
|
|
1296
|
+
try {
|
|
1297
|
+
return realpathSync(p);
|
|
1298
|
+
} catch {
|
|
1299
|
+
return p;
|
|
1300
|
+
}
|
|
1301
|
+
};
|
|
1302
|
+
const legacyDir = real(dirname(legacyPath));
|
|
1303
|
+
if (legacyDir !== real(homedir()) && legacyDir !== real(dirname(resolve(targetPath)))) return null;
|
|
1304
|
+
if (!existsSync(legacyPath)) return null;
|
|
1305
|
+
let legacy;
|
|
1306
|
+
try {
|
|
1307
|
+
legacy = JSON.parse(readFileSync(legacyPath, "utf-8"));
|
|
1308
|
+
} catch {
|
|
1309
|
+
return null;
|
|
1310
|
+
}
|
|
1311
|
+
if (!legacy || typeof legacy !== "object" || Array.isArray(legacy)) return null;
|
|
1312
|
+
const migrated = {};
|
|
1313
|
+
for (const [fileKey, value] of Object.entries(legacy)) {
|
|
1314
|
+
if (!isOAuthLegacy(value)) continue;
|
|
1315
|
+
const { access, refresh, expires, ...extras } = value;
|
|
1316
|
+
migrated[fileKey] = {
|
|
1317
|
+
kind: "oauth",
|
|
1318
|
+
access,
|
|
1319
|
+
...typeof refresh === "string" ? { refresh } : {},
|
|
1320
|
+
...typeof expires === "number" ? { expires } : {},
|
|
1321
|
+
...extras
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
if (Object.keys(migrated).length === 0) return null;
|
|
1325
|
+
writeFileAtomic(targetPath, `${JSON.stringify(migrated, null, 2)}\n`, {
|
|
1326
|
+
ensureDir: true,
|
|
1327
|
+
mode: FILE_MODE
|
|
1328
|
+
});
|
|
1329
|
+
return migrated;
|
|
1330
|
+
}
|
|
1331
|
+
function isOAuthLegacy(value) {
|
|
1332
|
+
return typeof value === "object" && value !== null && "access" in value && typeof value.access === "string";
|
|
1333
|
+
}
|
|
1334
|
+
//#endregion
|
|
1335
|
+
//#region src/chat/xdg.ts
|
|
1336
|
+
/**
|
|
1337
|
+
* XDG Base Directory resolution for zidane.
|
|
1338
|
+
*
|
|
1339
|
+
* The user's storage spreads across four logical slots:
|
|
1340
|
+
*
|
|
1341
|
+
* - **config** — credentials, mcp-credentials, keybindings, config.json,
|
|
1342
|
+
* mcps.json, skills (user-editable surface).
|
|
1343
|
+
* - **data** — sessions.db (long-lived chat history).
|
|
1344
|
+
* - **state** — state.json (per-machine UI bookkeeping: last provider,
|
|
1345
|
+
* last model, last agent, settings).
|
|
1346
|
+
* - **cache** — persisted tool results, background-task logs,
|
|
1347
|
+
* auto-update registry cache (regenerable, safe to wipe).
|
|
1348
|
+
*
|
|
1349
|
+
* Historically all four collapsed to `~/.zidane/`. This module adds
|
|
1350
|
+
* XDG-Base-Directory-aware defaults so a Linux user with no existing
|
|
1351
|
+
* `~/.zidane/` lands in the conventional `~/.config/zidane/`,
|
|
1352
|
+
* `~/.local/share/zidane/`, `~/.local/state/zidane/`, `~/.cache/zidane/`
|
|
1353
|
+
* trio. Existing setups (anyone with a `~/.zidane/` directory or
|
|
1354
|
+
* `ZIDANE_STORAGE_DIR` set) keep the single-dir layout unchanged.
|
|
1355
|
+
*
|
|
1356
|
+
* Resolution flow — applied in this order:
|
|
1357
|
+
*
|
|
1358
|
+
* 1. Caller passed an explicit `storageDir` (`ChatOptions.storageDir`)
|
|
1359
|
+
* OR `ZIDANE_STORAGE_DIR` is set → **legacy-explicit** single-dir
|
|
1360
|
+
* layout under `<storageDir>/<prefix>`. The four slots collapse to
|
|
1361
|
+
* the same directory.
|
|
1362
|
+
* 2. `~/<prefix>/` already exists on disk → **legacy-existing**
|
|
1363
|
+
* single-dir layout under that directory. Upgrades never silently
|
|
1364
|
+
* shift files around.
|
|
1365
|
+
* 3. Any `XDG_*_HOME` env var is set OR `process.platform === 'linux'`
|
|
1366
|
+
* → **XDG split**. Each slot lands in its own dir. Per-slot
|
|
1367
|
+
* `ZIDANE_{CONFIG,DATA,CACHE,STATE}_DIR` overrides beat the XDG
|
|
1368
|
+
* vars for surgical tweaks.
|
|
1369
|
+
* 4. Otherwise (macOS/Windows, no XDG signal, no existing dir) →
|
|
1370
|
+
* **default** single-dir layout at `~/<prefix>/`. Day-one behavior
|
|
1371
|
+
* on these platforms is unchanged.
|
|
1372
|
+
*
|
|
1373
|
+
* The `userDir` legacy alias always equals `configDir` so existing code
|
|
1374
|
+
* that still references `paths.userDir` keeps reading credentials, mcps,
|
|
1375
|
+
* etc. from the right place.
|
|
1376
|
+
*/
|
|
1377
|
+
/**
|
|
1378
|
+
* Resolve the four storage slots according to the precedence above.
|
|
1379
|
+
*
|
|
1380
|
+
* Pure aside from one filesystem probe (`existsSync(home/<prefix>)`)
|
|
1381
|
+
* — the same probe `resolveStoragePaths` would do anyway when
|
|
1382
|
+
* computing `paths.userDir`. No directories are created here; the
|
|
1383
|
+
* relevant write paths (state store, credentials writer, persist
|
|
1384
|
+
* dir) handle their own lazy `mkdirSync` already.
|
|
1385
|
+
*/
|
|
1386
|
+
function resolveStorageDirs(opts = {}) {
|
|
1387
|
+
const env = opts.env ?? process.env;
|
|
1388
|
+
const home = opts.home ?? homedir();
|
|
1389
|
+
const platform = opts.platform ?? process.platform;
|
|
1390
|
+
const rawPrefix = opts.prefix ?? ".zidane";
|
|
1391
|
+
const prefixBare = rawPrefix.replace(/^\./, "");
|
|
1392
|
+
const explicitStorageDir = opts.storageDir ?? env.ZIDANE_STORAGE_DIR;
|
|
1393
|
+
if (explicitStorageDir) return single(resolve(explicitStorageDir, rawPrefix), "legacy-explicit");
|
|
1394
|
+
const legacyHomeDir = resolve(home, rawPrefix);
|
|
1395
|
+
if (existsSync(legacyHomeDir)) return single(legacyHomeDir, "legacy-existing");
|
|
1396
|
+
if (platform === "linux" || !!env.XDG_CONFIG_HOME || !!env.XDG_DATA_HOME || !!env.XDG_CACHE_HOME || !!env.XDG_STATE_HOME || !!env.ZIDANE_CONFIG_DIR || !!env.ZIDANE_DATA_DIR || !!env.ZIDANE_STATE_DIR || !!env.ZIDANE_CACHE_DIR) return {
|
|
1397
|
+
configDir: env.ZIDANE_CONFIG_DIR ?? resolve(env.XDG_CONFIG_HOME || resolve(home, ".config"), prefixBare),
|
|
1398
|
+
dataDir: env.ZIDANE_DATA_DIR ?? resolve(env.XDG_DATA_HOME || resolve(home, ".local", "share"), prefixBare),
|
|
1399
|
+
stateDir: env.ZIDANE_STATE_DIR ?? resolve(env.XDG_STATE_HOME || resolve(home, ".local", "state"), prefixBare),
|
|
1400
|
+
cacheDir: env.ZIDANE_CACHE_DIR ?? resolve(env.XDG_CACHE_HOME || resolve(home, ".cache"), prefixBare),
|
|
1401
|
+
mode: "xdg"
|
|
1402
|
+
};
|
|
1403
|
+
return single(legacyHomeDir, "default");
|
|
1404
|
+
}
|
|
1405
|
+
function single(base, mode) {
|
|
1406
|
+
return {
|
|
1407
|
+
configDir: base,
|
|
1408
|
+
dataDir: base,
|
|
1409
|
+
stateDir: base,
|
|
1410
|
+
cacheDir: base,
|
|
1411
|
+
mode
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
//#endregion
|
|
1415
|
+
export { restoreModelOptions as C, piIdOf as S, modelOptionsFor as _, readProviderCredential as a, openaiDescriptor as b, writeCredentials as c, cerebrasDescriptor as d, credKeyOf as f, localDescriptor as g, getModelInfo as h, readCredentials as i, BUILTIN_PROVIDERS as l, getContextWindow as m, applyApiKeyEnv as n, removeProviderCredential as o, enabledModelOptions as p, credentialsPath as r, setProviderCredential as s, resolveStorageDirs as t, anthropicDescriptor as u, modelSupportsReasoning as v, openrouterDescriptor as x, modelsForDescriptor as y };
|
|
1416
|
+
|
|
1417
|
+
//# sourceMappingURL=xdg-zlSeVBhQ.js.map
|