oakode 0.0.0-stage → 0.5.1

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE +9 -0
  3. package/README.md +245 -2
  4. package/SECURITY.md +13 -0
  5. package/dist/account.js +34 -0
  6. package/dist/agent.js +584 -0
  7. package/dist/agents/approval.js +116 -0
  8. package/dist/agents/budget.js +293 -0
  9. package/dist/agents/child.js +103 -0
  10. package/dist/agents/host.js +289 -0
  11. package/dist/agents/orchestrator.js +286 -0
  12. package/dist/agents/plan.js +143 -0
  13. package/dist/agents/types.js +20 -0
  14. package/dist/agents/verify.js +94 -0
  15. package/dist/app.js +619 -0
  16. package/dist/auth.js +456 -0
  17. package/dist/brief.js +97 -0
  18. package/dist/commands.js +588 -0
  19. package/dist/compact.js +231 -0
  20. package/dist/config.js +87 -0
  21. package/dist/diff.js +98 -0
  22. package/dist/doctor.js +106 -0
  23. package/dist/errors.js +15 -0
  24. package/dist/fileio.js +688 -0
  25. package/dist/input.js +244 -0
  26. package/dist/jobs.js +262 -0
  27. package/dist/main.js +560 -0
  28. package/dist/mask.js +63 -0
  29. package/dist/mcp.js +721 -0
  30. package/dist/models.js +106 -0
  31. package/dist/modes.js +114 -0
  32. package/dist/oicki.js +475 -0
  33. package/dist/output.js +101 -0
  34. package/dist/permissions.js +389 -0
  35. package/dist/permissions_ui.js +134 -0
  36. package/dist/print.js +76 -0
  37. package/dist/proc.js +63 -0
  38. package/dist/runners.js +264 -0
  39. package/dist/serial.js +9 -0
  40. package/dist/session.js +452 -0
  41. package/dist/shell.js +40 -0
  42. package/dist/skills.js +335 -0
  43. package/dist/syntax.js +80 -0
  44. package/dist/tools.js +480 -0
  45. package/dist/tui/editor.js +279 -0
  46. package/dist/tui/highlight.js +78 -0
  47. package/dist/tui/markdown.js +254 -0
  48. package/dist/tui/screen.js +146 -0
  49. package/dist/tui/tui.js +505 -0
  50. package/dist/tui/view.js +337 -0
  51. package/dist/ui.js +105 -0
  52. package/dist/update.js +64 -0
  53. package/package.json +57 -4
package/dist/auth.js ADDED
@@ -0,0 +1,456 @@
1
+ /**
2
+ * Credentials: "Sign in with OICKI" (OAuth 2 authorization code + PKCE, public client) or an API key. Never logs secrets.
3
+ *
4
+ * OICKI rotates refresh tokens and treats a reused one as leaked, revoking the whole grant. Several OAKODE processes
5
+ * share one credentials file, so every refresh happens under a lock file and starts by re-reading the file: a process
6
+ * whose copy was already rotated by another simply adopts the new tokens instead of sending a stale refresh token.
7
+ */
8
+ import { createHash, randomBytes } from "node:crypto";
9
+ import { createServer } from "node:http";
10
+ import { spawn } from "node:child_process";
11
+ import { join } from "node:path";
12
+ import { chmodSync, closeSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync } from "node:fs";
13
+ import { OickiError } from "./oicki.js";
14
+ import { DEFAULT_CLIENT_ID, DEFAULT_REDIRECT_PORT, VERSION, baseUrl, home, loadConfig, readJson, writeJson } from "./config.js";
15
+ /**
16
+ * Where the credentials and their lock live: the data directory, or OAKODE_AUTH_DIR. Evaluations set it so that runs with
17
+ * their own temporary data directories share ONE sign-in file and lock (rotating refresh tokens stay coordinated);
18
+ * a sign-in is never copied.
19
+ */
20
+ const authDir = () => process.env.OAKODE_AUTH_DIR || home();
21
+ const authFile = () => join(authDir(), "auth.json");
22
+ const lockFile = () => join(authDir(), "auth.lock");
23
+ const b64url = (b) => b.toString("base64url");
24
+ /** Refresh this long before expiry, so a request never starts with a token about to lapse. */
25
+ const EARLY_MS = 60_000;
26
+ const expiring = (s) => s.expiresAt - Date.now() < EARLY_MS;
27
+ /** Ports tried in order for the sign-in callback; each must be registered as a redirect URI of the OAKODE service. */
28
+ export const REDIRECT_PORTS = [DEFAULT_REDIRECT_PORT, DEFAULT_REDIRECT_PORT + 1, DEFAULT_REDIRECT_PORT + 2];
29
+ /** OICKI rewrites loopback redirects to `localhost` when it sends the browser back, and the code is bound to that form. */
30
+ export const redirectUri = (port) => `http://localhost:${port}/callback`;
31
+ export const clientIdSetting = () => process.env.OAKODE_CLIENT_ID || loadConfig().clientId || DEFAULT_CLIENT_ID;
32
+ function load() {
33
+ const s = readJson(authFile());
34
+ if (!s || (s.type !== "oauth" && s.type !== "apikey"))
35
+ return undefined;
36
+ return s;
37
+ }
38
+ export class Auth {
39
+ stored = load();
40
+ inflight;
41
+ /** Called when the credentials on disk turn out to be another sign-in (`oakode login` in another terminal). */
42
+ onSwitch;
43
+ constructor() {
44
+ // A credentials file readable by others (copied, restored from backup, wrong umask) is tightened, not trusted.
45
+ try {
46
+ if (statSync(authFile()).mode & 0o077)
47
+ chmodSync(authFile(), 0o600);
48
+ }
49
+ catch { }
50
+ }
51
+ /** Where the credentials in use come from. */
52
+ source() {
53
+ if (process.env.OICKI_API_KEY?.trim())
54
+ return "env";
55
+ return this.stored?.type === "apikey" ? "stored-apikey" : this.stored ? "oauth" : "none";
56
+ }
57
+ expiresAt() {
58
+ return this.stored?.type === "oauth" ? this.stored.expiresAt : undefined;
59
+ }
60
+ kind() {
61
+ if (process.env.OICKI_API_KEY?.trim() || this.stored?.type === "apikey")
62
+ return "apikey";
63
+ return this.stored ? "oauth" : "none";
64
+ }
65
+ scopes() {
66
+ return this.stored?.type === "oauth" ? (this.stored.scope ?? "").split(" ").filter(Boolean) : [];
67
+ }
68
+ /** The server the stored credentials belong to (older files did not record it: then the default server). */
69
+ storedServer() {
70
+ return this.stored ? (this.stored.server ?? "https://oicki.vercel.app") : undefined;
71
+ }
72
+ async token() {
73
+ if (process.env.OICKI_API_KEY?.trim())
74
+ return process.env.OICKI_API_KEY.trim();
75
+ let s = checked(this.stored);
76
+ if (s.type === "apikey")
77
+ return s.apiKey;
78
+ if (expiring(s)) {
79
+ s = checked(this.reload()); // another process may have refreshed (or signed out, or signed in elsewhere)
80
+ if (s.type === "oauth" && expiring(s))
81
+ await this.refresh();
82
+ s = checked(this.stored);
83
+ }
84
+ return s.type === "apikey" ? s.apiKey : s.accessToken;
85
+ }
86
+ /** Re-reads the credentials file (another OAKODE process may have rotated or removed the tokens). */
87
+ reload() {
88
+ const before = this.stored;
89
+ this.stored = load();
90
+ // `signIn` is a random id made at `oakode login` and kept through token refreshes (files from before it have none)
91
+ if (before && this.stored && (before.type !== this.stored.type || (before.signIn && this.stored.signIn && before.signIn !== this.stored.signIn)))
92
+ this.onSwitch?.("The OICKI credentials changed on disk (a new `oakode login`, maybe in another terminal). This session now uses them, so requests from here on are billed to that sign-in. `/status` shows its plan and credits.");
93
+ return this.stored;
94
+ }
95
+ /**
96
+ * Gets a fresh access token. `rejected` is the token a request was refused with (401): if another process has
97
+ * replaced it meanwhile, that one is adopted and nothing is sent to OICKI. Single-flight within the process and
98
+ * locked across processes.
99
+ */
100
+ refresh(rejected) {
101
+ if (this.stored?.type !== "oauth" || process.env.OICKI_API_KEY?.trim())
102
+ return Promise.resolve(false);
103
+ return (this.inflight ??= withLock(() => this.doRefresh(rejected)).finally(() => (this.inflight = undefined)));
104
+ }
105
+ async doRefresh(rejected) {
106
+ const r = this.reload();
107
+ if (r?.type !== "oauth")
108
+ return false; // signed out elsewhere
109
+ const s = checked(r); // a refresh token only ever goes to the server that issued it
110
+ if (!expiring(s) && s.accessToken !== rejected)
111
+ return true; // already rotated by another process
112
+ let tok;
113
+ try {
114
+ tok = await tokenRequest({ grant_type: "refresh_token", client_id: s.clientId, refresh_token: s.refreshToken });
115
+ }
116
+ catch (e) {
117
+ if (e instanceof OAuthError && (e.code === "invalid_grant" || e.code === "unauthorized_client" || e.code === "invalid_client")) {
118
+ // The grant is gone (expired after 30 idle days, revoked, or a reused token was detected). Forget the dead
119
+ // tokens so no process tries them again, and say what to do.
120
+ this.clear();
121
+ throw new OickiError(401, "session_expired", "Your OICKI sign-in has expired or was revoked.");
122
+ }
123
+ throw e;
124
+ }
125
+ try {
126
+ this.save({ ...s, ...toStored(tok), scope: tok.scope ?? s.scope });
127
+ }
128
+ catch (e) {
129
+ // OICKI has rotated the tokens but the new pair cannot be stored: the old refresh token on disk is now dead and
130
+ // would get the grant revoked if another process used it. Remove it; signing in again is the safe way back.
131
+ this.stored = undefined;
132
+ try {
133
+ rmSync(authFile(), { force: true });
134
+ }
135
+ catch { }
136
+ throw new OickiError(401, "session_expired", `The renewed sign-in could not be saved (${e.message}).`);
137
+ }
138
+ return true;
139
+ }
140
+ save(s) {
141
+ this.stored = { ...s, server: baseUrl() };
142
+ writeJson(authFile(), this.stored, true); // persisted before the new tokens are used
143
+ }
144
+ setApiKey(apiKey) {
145
+ this.save({ type: "apikey", apiKey, signIn: randomBytes(8).toString("hex") });
146
+ }
147
+ /**
148
+ * Ends the sign-in on OICKI (POST /oauth/revoke with the refresh token, which revokes both tokens of this sign-in only)
149
+ * and removes the credentials stored on this computer, whatever OICKI answered. Under the lock, so a refresh in flight
150
+ * cannot write them back. True when OICKI confirmed the revocation.
151
+ */
152
+ logout() {
153
+ return withLock(async () => {
154
+ this.reload();
155
+ const s = this.stored;
156
+ let revoked = false;
157
+ if (s?.type === "oauth") {
158
+ try {
159
+ // the server that issued the tokens, never another one
160
+ const res = await fetch(this.storedServer() + "/oauth/revoke", {
161
+ method: "POST",
162
+ headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": `oakode/${VERSION}` },
163
+ body: new URLSearchParams({ token: s.refreshToken, token_type_hint: "refresh_token", client_id: s.clientId }),
164
+ signal: AbortSignal.timeout(5000),
165
+ });
166
+ revoked = res.ok;
167
+ }
168
+ catch { }
169
+ }
170
+ this.clear();
171
+ return revoked;
172
+ });
173
+ }
174
+ clear() {
175
+ this.stored = undefined;
176
+ rmSync(authFile(), { force: true });
177
+ }
178
+ async login(opts) {
179
+ const clientId = opts.clientId || clientIdSetting();
180
+ if (!clientId)
181
+ throw new Error("This build of OAKODE has no OICKI client id configured. Use `oakode login --api-key`, or pass `--client-id <your service id>`.");
182
+ const scope = ["ai:use", "billing:read", ...(opts.expensive ? ["models:expensive"] : [])].join(" ");
183
+ const verifier = b64url(randomBytes(32));
184
+ const state = b64url(randomBytes(16));
185
+ const ports = process.env.OAKODE_REDIRECT_PORT ? [Number(process.env.OAKODE_REDIRECT_PORT)] : REDIRECT_PORTS;
186
+ const cb = await listenForCallback(ports, state, opts.signal);
187
+ try {
188
+ const url = new URL(baseUrl() + "/oauth/authorize");
189
+ url.search = new URLSearchParams({
190
+ response_type: "code",
191
+ client_id: clientId,
192
+ redirect_uri: redirectUri(cb.port),
193
+ scope,
194
+ required_scope: "ai:use",
195
+ state,
196
+ code_challenge: b64url(createHash("sha256").update(verifier).digest()),
197
+ code_challenge_method: "S256",
198
+ }).toString();
199
+ const remote = opts.noBrowser || isRemoteSession();
200
+ if (remote) {
201
+ opts.log(`Open this link in a browser to sign in:\n\n ${url}\n\n` +
202
+ (isRemoteSession()
203
+ ? `This looks like a remote session: the sign-in page sends your browser back to localhost:${cb.port} on the machine running the browser. ` +
204
+ `Forward that port first, from your own computer:\n\n ssh -L ${cb.port}:localhost:${cb.port} <this host>\n\n` +
205
+ `Or use an API key instead: oakode login --api-key\n`
206
+ : "") +
207
+ `Waiting for the browser (Ctrl+C to cancel)…`);
208
+ }
209
+ else {
210
+ opts.log(`Opening your browser to sign in with OICKI. If it does not open, visit:\n\n ${url}\n\nWaiting for the browser (Ctrl+C to cancel)…`);
211
+ openBrowser(url.toString());
212
+ }
213
+ const code = await cb.code;
214
+ try {
215
+ const tok = await tokenRequest({ grant_type: "authorization_code", client_id: clientId, code, redirect_uri: redirectUri(cb.port), code_verifier: verifier });
216
+ const granted = (tok.scope ?? scope).split(" ").filter(Boolean);
217
+ if (!granted.includes("ai:use"))
218
+ throw new Error("OICKI did not grant `ai:use`, which OAKODE needs. Nothing was saved.");
219
+ await withLock(async () => this.save({ ...toStored(tok), clientId, scope: granted.join(" "), signIn: randomBytes(8).toString("hex") }));
220
+ cb.respond(true);
221
+ return { scopes: granted };
222
+ }
223
+ catch (e) {
224
+ cb.respond(false, e.message);
225
+ throw e;
226
+ }
227
+ }
228
+ finally {
229
+ cb.close();
230
+ }
231
+ }
232
+ }
233
+ /** A terminal on another machine (SSH) or without a display: opening a browser here would not reach the user. */
234
+ export function isRemoteSession(env = process.env, platform = process.platform) {
235
+ if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY)
236
+ return true;
237
+ return platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY;
238
+ }
239
+ /**
240
+ * Cross-process mutual exclusion on the credentials: an exclusive-create lock file. A lock older than 30 s belongs to
241
+ * a process that died while holding it and is taken over.
242
+ */
243
+ export async function withLock(f) {
244
+ const file = lockFile();
245
+ const stamp = `${process.pid} ${Date.now()} ${randomBytes(6).toString("hex")}`;
246
+ const deadline = Date.now() + 150_000;
247
+ for (;;) {
248
+ try {
249
+ const fd = openSync(file, "wx", 0o600);
250
+ writeSync(fd, stamp);
251
+ closeSync(fd);
252
+ break;
253
+ }
254
+ catch (e) {
255
+ const code = e.code;
256
+ if (code === "ENOENT") {
257
+ mkdirSync(home(), { recursive: true, mode: 0o700 });
258
+ continue;
259
+ }
260
+ if (code !== "EEXIST")
261
+ throw e;
262
+ takeOverIfStale(file);
263
+ if (Date.now() > deadline)
264
+ throw new Error(`Timed out waiting for another OAKODE process to finish renewing the sign-in (${file}).`);
265
+ await new Promise((r) => setTimeout(r, 100));
266
+ }
267
+ }
268
+ try {
269
+ return await f();
270
+ }
271
+ finally {
272
+ // only our own lock: if ours was taken over as stale, the file now belongs to someone else
273
+ try {
274
+ if (readFileSync(file, "utf8") === stamp)
275
+ rmSync(file, { force: true });
276
+ }
277
+ catch { }
278
+ }
279
+ }
280
+ /** Longer than any token request (30 s timeout), so a slow refresh is never mistaken for a dead one. */
281
+ const LOCK_STALE_MS = 120_000;
282
+ /**
283
+ * Removes the lock if its owner is gone: the process is dead, or the lock is older than any refresh can take. The lock
284
+ * is moved aside atomically and checked to be the one judged stale; if another process replaced it in between, it
285
+ * is put back (link() does not overwrite).
286
+ */
287
+ function takeOverIfStale(file) {
288
+ let seen, mtime;
289
+ try {
290
+ seen = readFileSync(file, "utf8");
291
+ mtime = statSync(file).mtimeMs;
292
+ }
293
+ catch {
294
+ return;
295
+ }
296
+ const pid = Number(seen.split(" ")[0]);
297
+ if (!(Date.now() - mtime > LOCK_STALE_MS || (pid > 0 && !alive(pid))))
298
+ return;
299
+ const aside = `${file}.${randomBytes(6).toString("hex")}`;
300
+ try {
301
+ renameSync(file, aside);
302
+ }
303
+ catch {
304
+ return; // someone else moved it first
305
+ }
306
+ try {
307
+ if (readFileSync(aside, "utf8") !== seen)
308
+ linkSync(aside, file); // not the stale one: restore it
309
+ }
310
+ catch { }
311
+ rmSync(aside, { force: true });
312
+ }
313
+ function alive(pid) {
314
+ try {
315
+ process.kill(pid, 0);
316
+ return true;
317
+ }
318
+ catch (e) {
319
+ return e.code === "EPERM"; // exists, owned by someone else
320
+ }
321
+ }
322
+ /** The stored credentials, if there are any and they belong to the server in use. */
323
+ function checked(s) {
324
+ if (!s)
325
+ throw new OickiError(401, "not_logged_in", "Not signed in.");
326
+ const server = (s.server ?? "https://oicki.vercel.app").replace(/\/+$/, "");
327
+ if (server !== baseUrl())
328
+ throw new OickiError(401, "wrong_server", `The saved sign-in is for ${server}, but OAKODE is using ${baseUrl()}. Sign in again for this server, or unset OAKODE_BASE_URL.`);
329
+ return s;
330
+ }
331
+ const toStored = (t) => ({
332
+ type: "oauth",
333
+ accessToken: t.access_token,
334
+ refreshToken: t.refresh_token,
335
+ expiresAt: Date.now() + t.expires_in * 1000,
336
+ });
337
+ class OAuthError extends Error {
338
+ code;
339
+ constructor(code, message) {
340
+ super(message);
341
+ this.code = code;
342
+ }
343
+ }
344
+ async function tokenRequest(params) {
345
+ let res;
346
+ try {
347
+ res = await fetch(baseUrl() + "/oauth/token", {
348
+ method: "POST",
349
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", "User-Agent": `oakode/${VERSION}` },
350
+ body: new URLSearchParams(params),
351
+ signal: AbortSignal.timeout(30_000),
352
+ });
353
+ }
354
+ catch (e) {
355
+ throw new OickiError(0, "network_error", `Could not reach OICKI to sign in: ${e.message}`);
356
+ }
357
+ const body = (await res.json().catch(() => ({})));
358
+ if (!res.ok || typeof body.access_token !== "string" || typeof body.refresh_token !== "string") {
359
+ const code = String(body.error ?? `http_${res.status}`);
360
+ throw new OAuthError(code, `OICKI refused the sign-in (${code}${body.error_description ? `: ${body.error_description}` : ""}).`);
361
+ }
362
+ return body;
363
+ }
364
+ const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
365
+ const page = (title, body) => `<!doctype html><html><head><meta charset="utf-8"><title>OAKODE</title></head><body style="font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;line-height:1.5"><h2>${escapeHtml(title)}</h2><p>${escapeHtml(body)}</p></body></html>`;
366
+ /**
367
+ * Listens on the first port of `ports` that is free on the loopback interface, for the browser's return. OICKI sends
368
+ * the browser to `localhost`, which a browser may resolve to 127.0.0.1 or ::1, so both are bound (::1 only where the
369
+ * machine has IPv6). The browser's request is answered only once the sign-in has actually finished (`respond`), so
370
+ * the page never says "signed in" when the token exchange failed.
371
+ */
372
+ async function listenForCallback(ports, state, signal) {
373
+ let settle;
374
+ const code = new Promise((resolve, reject) => (settle = (e, c) => (e ? reject(e) : resolve(c))));
375
+ code.catch(() => { });
376
+ let done = false;
377
+ const finish = (e, c) => {
378
+ if (done)
379
+ return;
380
+ done = true;
381
+ clearTimeout(timer);
382
+ settle(e, c);
383
+ };
384
+ const timer = setTimeout(() => finish(new Error("Sign-in timed out after 5 minutes. Run `oakode login` to try again.")), 300_000);
385
+ timer.unref?.();
386
+ signal?.addEventListener("abort", () => finish(new Error("Sign-in cancelled.")), { once: true });
387
+ let waiting; // the browser's request, answered by respond()
388
+ const html = (res, status, title, body) => res.writeHead(status, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }).end(page(title, body));
389
+ const handler = (req, res) => {
390
+ const u = new URL(req.url ?? "/", "http://localhost");
391
+ if (u.pathname !== "/callback")
392
+ return void res.writeHead(404).end();
393
+ const err = u.searchParams.get("error");
394
+ const c = u.searchParams.get("code");
395
+ if (u.searchParams.get("state") !== state || done) {
396
+ // not our request (a stale tab, or someone else's page): refuse without ending the wait
397
+ return html(res, 400, "This sign-in link is not the current one.", "Go back to the terminal and run `oakode login` again if needed.");
398
+ }
399
+ if (err) {
400
+ const why = u.searchParams.get("error_description") ?? err;
401
+ html(res, 400, "Sign-in was not completed.", `${why}. You can close this tab.`);
402
+ return finish(new Error(err === "access_denied" ? "Sign-in was declined in the browser." : `Sign-in failed: ${why}`));
403
+ }
404
+ if (!c)
405
+ return html(res, 400, "Sign-in failed.", "The response had no code.");
406
+ waiting = res;
407
+ finish(undefined, c);
408
+ };
409
+ const respond = (ok, detail) => {
410
+ const res = waiting;
411
+ waiting = undefined;
412
+ if (!res)
413
+ return;
414
+ if (ok)
415
+ html(res, 200, "Signed in to OAKODE.", "You can close this tab and go back to the terminal.");
416
+ else
417
+ html(res, 400, "Sign-in failed.", `${detail ?? "See the terminal for details."} You can close this tab.`);
418
+ };
419
+ const listen = (server, port, host) => new Promise((resolve) => {
420
+ server.once("error", (e) => resolve(e));
421
+ server.listen(port, host, () => resolve(undefined));
422
+ });
423
+ for (const port of ports) {
424
+ const v4 = createServer(handler);
425
+ if (await listen(v4, port, "127.0.0.1"))
426
+ continue;
427
+ const v6 = createServer(handler);
428
+ const e6 = await listen(v6, port, "::1");
429
+ // no IPv6 on this machine is fine; ::1 taken by another program is not (the browser might go there)
430
+ if (e6 && e6.code !== "EADDRNOTAVAIL" && e6.code !== "EAFNOSUPPORT") {
431
+ v4.close();
432
+ continue;
433
+ }
434
+ const servers = e6 ? [v4] : [v4, v6];
435
+ const close = () => {
436
+ respond(false);
437
+ for (const s of servers) {
438
+ s.close();
439
+ s.closeAllConnections?.();
440
+ }
441
+ };
442
+ return { port, code, respond, close };
443
+ }
444
+ clearTimeout(timer);
445
+ throw new Error(`Ports ${ports.join(", ")} on localhost are all in use, so the browser cannot return to OAKODE. Close the program using them, or use \`oakode login --api-key\`.`);
446
+ }
447
+ function openBrowser(url) {
448
+ if (process.env.OAKODE_NO_BROWSER)
449
+ return;
450
+ // the URL is passed as a single argument, never through a shell, so nothing in it is interpreted
451
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
452
+ try {
453
+ spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => { }).unref();
454
+ }
455
+ catch { }
456
+ }
package/dist/brief.js ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * A short project brief for the first request: layout, branch and the commands that build and test the project, so
3
+ * the model does not spend its first replies running ls and cat package.json. Built once per session (it is part of
4
+ * the cached prompt prefix) and never runs anything a repository could configure: the branch comes from .git/HEAD,
5
+ * the file list from the same `git ls-files` the search tools use.
6
+ */
7
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
8
+ import { join, relative, sep } from "node:path";
9
+ import { workspaceFiles } from "./fileio.js";
10
+ const MAX_ENTRIES = 40;
11
+ /** A small regular file's text: never a FIFO or device (which would block), a symlink, or something huge. */
12
+ function readSmall(p) {
13
+ try {
14
+ const st = lstatSync(p);
15
+ return st.isFile() && st.size < 1024 * 1024 ? readFileSync(p, "utf8") : undefined;
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ }
21
+ function readJson(p) {
22
+ try {
23
+ return JSON.parse(readSmall(p) ?? "");
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ }
29
+ function branch(cwd) {
30
+ try {
31
+ const head = readSmall(join(cwd, ".git", "HEAD"))?.trim();
32
+ if (!head)
33
+ return undefined;
34
+ if (head.startsWith("ref: refs/heads/"))
35
+ return head.slice(16).replace(/[^\w./-]/g, "?").slice(0, 100);
36
+ return /^[0-9a-f]{40,64}$/.test(head) ? `detached at ${head.slice(0, 10)}` : undefined;
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ /** How this project is built and tested, from its manifest files. */
43
+ export function projectCommands(cwd) {
44
+ const out = [];
45
+ const pkg = readJson(join(cwd, "package.json"));
46
+ if (pkg) {
47
+ const pm = existsSync(join(cwd, "pnpm-lock.yaml")) ? "pnpm" : existsSync(join(cwd, "yarn.lock")) ? "yarn" : existsSync(join(cwd, "bun.lockb")) || existsSync(join(cwd, "bun.lock")) ? "bun" : "npm";
48
+ const scripts = Object.entries(pkg.scripts ?? {}).filter(([k]) => /^(test|build|lint|typecheck|check|dev|start|format)(:|$)/.test(k));
49
+ for (const [k, v] of scripts.slice(0, 10))
50
+ out.push(`${pm} run ${k} # ${String(v).slice(0, 100)}`);
51
+ if (!scripts.length)
52
+ out.push(`${pm} (package.json has no test/build scripts)`);
53
+ }
54
+ if (existsSync(join(cwd, "pyproject.toml")) || existsSync(join(cwd, "setup.py")))
55
+ out.push(existsSync(join(cwd, "uv.lock")) ? "uv run pytest" : "python -m pytest # Python project");
56
+ if (existsSync(join(cwd, "Cargo.toml")))
57
+ out.push("cargo build / cargo test");
58
+ if (existsSync(join(cwd, "go.mod")))
59
+ out.push("go build ./... / go test ./...");
60
+ const makefile = readSmall(join(cwd, "Makefile"));
61
+ if (makefile) {
62
+ const targets = [...makefile.matchAll(/^([A-Za-z][\w-]*):/gm)].map((m) => m[1]).filter((t) => /^(all|build|test|check|lint|run)$/.test(t));
63
+ if (targets.length)
64
+ out.push(`make ${targets.join(" | make ")}`);
65
+ }
66
+ return out;
67
+ }
68
+ export async function projectBrief(cwd, signal) {
69
+ const lines = [];
70
+ const b = branch(cwd);
71
+ if (b)
72
+ lines.push(`Git branch: ${b}`);
73
+ let files = [];
74
+ let gitignore = false;
75
+ try {
76
+ ({ files, gitignore } = await workspaceFiles(cwd, signal));
77
+ }
78
+ catch { }
79
+ if (files.length) {
80
+ // top-level entries with how many files each holds
81
+ const counts = new Map();
82
+ for (const f of files) {
83
+ const parts = relative(cwd, f).split(sep);
84
+ const key = parts.length > 1 ? `${parts[0]}/` : parts[0];
85
+ counts.set(key, (counts.get(key) ?? 0) + 1);
86
+ }
87
+ const entries = [...counts].sort(([a], [b]) => Number(b.endsWith("/")) - Number(a.endsWith("/")) || a.localeCompare(b));
88
+ const shown = entries.slice(0, MAX_ENTRIES).map(([k, n]) => (k.endsWith("/") ? `${k} (${n} file${n > 1 ? "s" : ""})` : k));
89
+ lines.push(`Top level (${files.length} files${gitignore ? ", .gitignore applied" : ""}): ${shown.join(", ")}${entries.length > MAX_ENTRIES ? `, … ${entries.length - MAX_ENTRIES} more` : ""}`);
90
+ }
91
+ else
92
+ lines.push("The working directory is empty.");
93
+ const cmds = projectCommands(cwd);
94
+ if (cmds.length)
95
+ lines.push("Build/test commands:", ...cmds.map((c) => ` ${c}`));
96
+ return lines.join("\n");
97
+ }