@scira/cli 0.1.5 → 0.1.6

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 (57) hide show
  1. package/dist/agent/harness-agent.js +206 -0
  2. package/dist/agent/{research-agent.js → main-agent.js} +20 -1
  3. package/dist/cli/commands/init.js +7 -5
  4. package/dist/cli/index.js +52 -11
  5. package/dist/cli/shell/shell.js +4 -5
  6. package/dist/cli/shell/tui.js +5 -2
  7. package/dist/config/env-guide.js +24 -0
  8. package/dist/config/env-store.js +5 -3
  9. package/dist/config/load-config.js +9 -14
  10. package/dist/providers/harness/local-sandbox.js +143 -0
  11. package/dist/providers/llm/gateway.js +5 -2
  12. package/dist/providers/llm/models.js +13 -0
  13. package/dist/providers/llm/readiness.js +5 -1
  14. package/dist/providers/llm/registry.js +24 -3
  15. package/dist/storage/jsonl.js +2 -2
  16. package/dist/storage/run-store.js +15 -15
  17. package/dist/tools/agent-tools.js +7 -7
  18. package/dist/tools/background-tasks.js +4 -5
  19. package/dist/tools/mcp-oauth.js +29 -25
  20. package/dist/tools/open-url.js +1 -2
  21. package/dist/tools/todos.js +3 -3
  22. package/dist/types/index.js +13 -1
  23. package/dist/ui/ink/SciraApp.js +10 -6
  24. package/dist/ui/ink/components/home-screen.js +2 -2
  25. package/dist/ui/ink/components/overlays.js +73 -15
  26. package/dist/ui/ink/constants.js +10 -7
  27. package/dist/ui/ink/hooks/use-agent-turn.js +14 -5
  28. package/dist/ui/ink/hooks/use-feed-lines.js +31 -6
  29. package/dist/ui/ink/hooks/use-keyboard.js +28 -5
  30. package/dist/ui/ink/hooks/use-session.js +7 -5
  31. package/dist/ui/ink/hooks/use-settings.js +20 -0
  32. package/dist/ui/ink/hooks/use-submit.js +15 -8
  33. package/dist/ui/ink/lib/file-mentions.js +1 -2
  34. package/dist/ui/ink/lib/tool-result.js +201 -2
  35. package/dist/ui/ink/lib/utils.js +52 -28
  36. package/dist/ui/ink/theme.js +5 -10
  37. package/dist/watch/runner.js +2 -2
  38. package/package.json +13 -11
  39. package/dist/agent/background-tasks.js +0 -173
  40. package/dist/agent/todos.js +0 -140
  41. package/dist/agent/tools.js +0 -432
  42. package/dist/agent/tools.test.js +0 -60
  43. package/dist/agent/workspace.js +0 -85
  44. package/dist/config/env-guide.test.js +0 -18
  45. package/dist/config/env-store.test.js +0 -60
  46. package/dist/storage/jsonl.test.js +0 -38
  47. package/dist/storage/run-store.test.js +0 -65
  48. package/dist/tools/bash-policy.test.js +0 -38
  49. package/dist/tools/search-web.test.js +0 -24
  50. package/dist/tools/workspace.test.js +0 -75
  51. package/dist/types/schema.test.js +0 -61
  52. package/dist/ui/ink/hooks/use-feed-lines.test.js +0 -16
  53. package/dist/ui/ink/lib/tool-result.test.js +0 -60
  54. package/dist/ui/ink/lib/utils.test.js +0 -48
  55. package/dist/ui/ink/session-manager.test.js +0 -31
  56. package/dist/ui/ink/terminal-probe.test.js +0 -12
  57. package/dist/ui/ink/theme.test.js +0 -68
@@ -0,0 +1,206 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { HarnessAgent } from "@ai-sdk/harness/agent";
4
+ import { createClaudeCode } from "@ai-sdk/harness-claude-code";
5
+ import { createCodex } from "@ai-sdk/harness-codex";
6
+ import { createLocalSandbox } from "../providers/harness/local-sandbox.js";
7
+ import { createResearchTools } from "../tools/agent-tools.js";
8
+ /** Resolve a promise but never hang the caller longer than `ms`. Clears the timer when settled so it can't keep the event loop alive (e.g. delaying quit). */
9
+ function withTimeout(p, ms) {
10
+ let timer;
11
+ const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(undefined), ms); });
12
+ return Promise.race([
13
+ Promise.resolve(p).finally(() => clearTimeout(timer)),
14
+ timeout,
15
+ ]);
16
+ }
17
+ function permissionModeFor(provider, config) {
18
+ // Codex has no built-in tool-approval flow; it only runs under allow-all.
19
+ if (provider === "codex")
20
+ return "allow-all";
21
+ switch (config.approvalMode) {
22
+ case "manual": return "allow-reads";
23
+ case "auto": return "allow-all";
24
+ default: return "allow-edits"; // "suggest"
25
+ }
26
+ }
27
+ // With no explicit `auth`, the adapters auto-detect credentials from the host
28
+ // env (gateway key first, then provider key / base URL / org). To force the
29
+ // bundled CLI onto the user's local OAuth login instead, we strip every env var
30
+ // those resolvers read or emit — covering both the host-inherited copy and the
31
+ // adapter-injected copy (the local sandbox strips after merging the spawn env).
32
+ const GATEWAY_ENV = ["AI_GATEWAY_API_KEY", "AI_GATEWAY_BASE_URL"];
33
+ const STRIP_ENV = {
34
+ "claude-code": [...GATEWAY_ENV, "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"],
35
+ codex: [...GATEWAY_ENV, "OPENAI_API_KEY", "CODEX_API_KEY", "OPENAI_BASE_URL", "OPENAI_ORGANIZATION", "OPENAI_PROJECT"]
36
+ };
37
+ function buildAgent(provider, config, workspacePath, instructions, runPath) {
38
+ const sandbox = createLocalSandbox({ rootDir: workspacePath, stripEnv: STRIP_ENV[provider] });
39
+ const permissionMode = permissionModeFor(provider, config);
40
+ const model = config.model.trim() || undefined; // empty = CLI default
41
+ // Give the harness Scira's own multi-query web search + page reader as host
42
+ // tools, so it grounds answers through our search pipeline instead of the
43
+ // CLI's built-in web tools.
44
+ const { webSearch, readUrl } = createResearchTools(runPath, config, undefined, workspacePath, () => false);
45
+ // Use a non-colliding name. Claude Code ships a built-in `webSearch` (single
46
+ // query); naming ours the same lets the built-in shadow it, so the model ends
47
+ // up doing single-query searches. `multiWebSearch` can't be shadowed.
48
+ // Cast bridges the project's `ai` Tool type to the harness's bundled-`ai`
49
+ // ToolSet (same runtime shape, different package versions).
50
+ const tools = { multiWebSearch: webSearch, readUrl };
51
+ // Positive, authoritative steering pointing at the unique tool name.
52
+ const fullInstructions = `${instructions}\n\nWEB ACCESS: For any web search use the \`multiWebSearch\` tool and pass 3-5 query variations in a single call (it searches them in parallel). Use \`readUrl\` to read a specific page. These are your only web tools.`;
53
+ // No `auth`: the bundled CLI authenticates with the user's local login
54
+ // (`claude login` → ~/.claude, `codex login` → ~/.codex). We never pass an
55
+ // API key, so a Pro/Max/ChatGPT subscription session is used as-is.
56
+ const harness = provider === "claude-code"
57
+ ? createClaudeCode({
58
+ model,
59
+ thinking: config.harness.thinking,
60
+ maxTurns: config.harness.maxTurns
61
+ })
62
+ : createCodex({
63
+ model,
64
+ reasoningEffort: config.harness.reasoningEffort,
65
+ // Built-in harness web search is always disabled.
66
+ webSearch: false
67
+ });
68
+ return new HarnessAgent({ harness, sandbox, permissionMode, instructions: fullInstructions, tools });
69
+ }
70
+ /** Settings that, if changed, require rebuilding the session (not just the prompt). */
71
+ function settingsFingerprint(provider, config) {
72
+ return JSON.stringify([provider, config.model, config.approvalMode, config.harness]);
73
+ }
74
+ // One live harness session per run directory, reused across turns so the
75
+ // underlying CLI keeps its native conversation/workspace state.
76
+ const sessions = new Map();
77
+ function messageText(content) {
78
+ if (typeof content === "string")
79
+ return content;
80
+ return content
81
+ .map((part) => (part.type === "text" ? part.text : ""))
82
+ .join("");
83
+ }
84
+ function lastUserText(args) {
85
+ const { prompt, messages } = args;
86
+ if (typeof prompt === "string")
87
+ return prompt;
88
+ if (Array.isArray(messages)) {
89
+ for (let i = messages.length - 1; i >= 0; i--) {
90
+ if (messages[i].role === "user")
91
+ return messageText(messages[i].content);
92
+ }
93
+ }
94
+ return "";
95
+ }
96
+ function stateFile(runPath) {
97
+ return path.join(runPath, "harness-state.json");
98
+ }
99
+ async function readPersistedState(runPath) {
100
+ try {
101
+ return JSON.parse(await fs.readFile(stateFile(runPath), "utf8"));
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
107
+ async function clearPersistedState(runPath) {
108
+ await fs.rm(stateFile(runPath), { force: true }).catch(() => { });
109
+ }
110
+ async function acquireSession(runPath, provider, config, workspacePath, instructions, abortSignal) {
111
+ const fingerprint = settingsFingerprint(provider, config);
112
+ const existing = sessions.get(runPath);
113
+ // Reuse only if provider/model/approval/harness settings are unchanged.
114
+ if (existing && existing.fingerprint === fingerprint)
115
+ return existing;
116
+ if (existing) {
117
+ await withTimeout(existing.session.destroy(), 5000).catch(() => { });
118
+ sessions.delete(runPath);
119
+ }
120
+ const agent = buildAgent(provider, config, workspacePath, instructions, runPath);
121
+ // Try to resume the run's prior harness session (across process restarts).
122
+ // Only when the persisted settings match; any failure falls back to fresh.
123
+ let session;
124
+ const persisted = await readPersistedState(runPath);
125
+ if (persisted && persisted.provider === provider && persisted.fingerprint === fingerprint) {
126
+ try {
127
+ session = await agent.createSession({ sessionId: runPath, resumeFrom: persisted.state, abortSignal });
128
+ }
129
+ catch {
130
+ await clearPersistedState(runPath);
131
+ session = undefined;
132
+ }
133
+ }
134
+ if (!session)
135
+ session = await agent.createSession({ sessionId: runPath, abortSignal });
136
+ const entry = { agent, session, provider, fingerprint };
137
+ sessions.set(runPath, entry);
138
+ return entry;
139
+ }
140
+ /**
141
+ * Stop a live session, persisting its resume state so the run can continue in a
142
+ * future process. Falls back to destroy() (and clears stale state) on failure.
143
+ */
144
+ async function stopAndPersist(runPath, entry) {
145
+ try {
146
+ // Bounded so a wedged bridge can't hang TUI teardown.
147
+ const state = await withTimeout(entry.session.stop(), 5000);
148
+ if (state === undefined)
149
+ throw new Error("stop() timed out");
150
+ const payload = { provider: entry.provider, fingerprint: entry.fingerprint, state };
151
+ await fs.writeFile(stateFile(runPath), JSON.stringify(payload), "utf8");
152
+ }
153
+ catch {
154
+ await withTimeout(entry.session.destroy(), 5000).catch(() => { });
155
+ await clearPersistedState(runPath);
156
+ }
157
+ }
158
+ /**
159
+ * A ToolLoopAgent-shaped wrapper around a local harness session. `stream()`
160
+ * matches the subset the TUI's `consume()` loop uses, so harness providers drop
161
+ * into the same turn pipeline as the LLM providers.
162
+ */
163
+ class HarnessChatAgent {
164
+ runPath;
165
+ provider;
166
+ config;
167
+ workspacePath;
168
+ instructions;
169
+ constructor(runPath, provider, config, workspacePath, instructions) {
170
+ this.runPath = runPath;
171
+ this.provider = provider;
172
+ this.config = config;
173
+ this.workspacePath = workspacePath;
174
+ this.instructions = instructions;
175
+ }
176
+ async stream(options) {
177
+ const abortSignal = options.abortSignal;
178
+ const { agent, session } = await acquireSession(this.runPath, this.provider, this.config, this.workspacePath, this.instructions, abortSignal);
179
+ const prompt = lastUserText(options);
180
+ const result = await agent.stream({ session, prompt, abortSignal });
181
+ return result;
182
+ }
183
+ }
184
+ /**
185
+ * Build a harness-backed chat bundle for `config.llmProvider` (claude-code /
186
+ * codex). The session persists across turns; `close()` is a no-op so the native
187
+ * CLI state survives. Use {@link closeHarnessSession} on `/new` or teardown.
188
+ */
189
+ export function createHarnessBundle(opts) {
190
+ const agent = new HarnessChatAgent(opts.runPath, opts.provider, opts.config, opts.workspacePath, opts.instructions);
191
+ return { agent, close: async () => { } };
192
+ }
193
+ /** Stop a run's harness session and persist its resume state. No-op if none. */
194
+ export async function closeHarnessSession(runPath) {
195
+ const entry = sessions.get(runPath);
196
+ if (!entry)
197
+ return;
198
+ sessions.delete(runPath);
199
+ await stopAndPersist(runPath, entry);
200
+ }
201
+ /** Stop every live harness session, persisting resume state. Intended for process shutdown. */
202
+ export async function closeAllHarnessSessions() {
203
+ const entries = [...sessions.entries()];
204
+ sessions.clear();
205
+ await Promise.all(entries.map(([runPath, entry]) => stopAndPersist(runPath, entry)));
206
+ }
@@ -2,7 +2,8 @@ import { createInterface } from "node:readline/promises";
2
2
  import { stdin, stdout } from "node:process";
3
3
  import { ToolLoopAgent, isLoopFinished } from "ai";
4
4
  import { Spinner } from "picospinner";
5
- import { getLanguageModel, requireLlmKeys } from "../providers/llm/registry.js";
5
+ import { getLanguageModel, requireLlmKeys, isHarnessProvider } from "../providers/llm/registry.js";
6
+ import { createHarnessBundle } from "./harness-agent.js";
6
7
  import { createResearchTools, createOneShotTools, createCodingTools, wrapToolsForPlanMode } from "../tools/agent-tools.js";
7
8
  import { SKILL_CATALOG } from "./skills.js";
8
9
  import { createMcpBridge } from "../tools/mcp-bridge.js";
@@ -129,6 +130,15 @@ Rules for browser tools:
129
130
  }
130
131
  export async function createResearchAgent(runPath, goal, config, onApprovalRequired, options = {}) {
131
132
  requireLlmKeys(config);
133
+ if (isHarnessProvider(config.llmProvider)) {
134
+ return createHarnessBundle({
135
+ runPath,
136
+ provider: config.llmProvider,
137
+ config,
138
+ workspacePath: options.workspacePath ?? process.cwd(),
139
+ instructions: instructions(goal, config, options)
140
+ });
141
+ }
132
142
  const bridge = await createMcpBridge(config);
133
143
  const getPlanMode = options.getPlanMode ?? (() => options.planMode ?? false);
134
144
  const researchTools = createResearchTools(runPath, config, onApprovalRequired, options.workspacePath, getPlanMode);
@@ -194,6 +204,15 @@ Step 2 — If you decide to answer directly:
194
204
  }
195
205
  export async function createOneShotAgent(runPath, goal, config, onApprovalRequired, onEscalate, options = {}) {
196
206
  requireLlmKeys(config);
207
+ if (isHarnessProvider(config.llmProvider)) {
208
+ return createHarnessBundle({
209
+ runPath,
210
+ provider: config.llmProvider,
211
+ config,
212
+ workspacePath: options.workspacePath ?? process.cwd(),
213
+ instructions: oneShotInstructions(goal, false, options)
214
+ });
215
+ }
197
216
  const bridge = await createMcpBridge(config);
198
217
  const getPlanMode = options.getPlanMode ?? (() => options.planMode ?? false);
199
218
  const tools = {
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- import { mkdir, writeFile, readFile } from "node:fs/promises";
2
+ import { mkdir } from "node:fs/promises";
3
3
  import { existsSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
@@ -33,8 +33,8 @@ export async function initCommand() {
33
33
  // Create .scira directory if it doesn't exist
34
34
  await mkdir(SCIRA_DIR, { recursive: true });
35
35
  // Read existing .env and config
36
- const existingEnv = existsSync(ENV_FILE) ? parseEnvFile(await readFile(ENV_FILE, "utf8")) : {};
37
- const existingConfig = existsSync(CONFIG_FILE) ? JSON.parse(await readFile(CONFIG_FILE, "utf8")) : null;
36
+ const existingEnv = existsSync(ENV_FILE) ? parseEnvFile(await Bun.file(ENV_FILE).text()) : {};
37
+ const existingConfig = existsSync(CONFIG_FILE) ? (await Bun.file(CONFIG_FILE).json()) : null;
38
38
  // Ask if user wants to reconfigure
39
39
  const shouldReconfigure = existingConfig ? await p.confirm({
40
40
  message: "Configuration already exists. Reconfigure?",
@@ -247,7 +247,7 @@ export async function initCommand() {
247
247
  const envContent = Object.entries(envKeys)
248
248
  .map(([key, value]) => `${key}=${value}`)
249
249
  .join("\n");
250
- await writeFile(ENV_FILE, envContent, "utf8");
250
+ await Bun.write(ENV_FILE, envContent);
251
251
  p.log.success("API keys saved to ~/.scira/.env");
252
252
  // Step 3: Model Selection
253
253
  p.note("Select your AI model", "Model");
@@ -261,6 +261,7 @@ export async function initCommand() {
261
261
  model: defaultModelFor(llmProvider),
262
262
  lastModels: {},
263
263
  approvalMode: "suggest",
264
+ harness: { thinking: "adaptive", reasoningEffort: "medium" },
264
265
  alwaysAllowLinks: false,
265
266
  runDirectory: ".scira/runs",
266
267
  maxSources: 20,
@@ -346,6 +347,7 @@ export async function initCommand() {
346
347
  model,
347
348
  lastModels: { [llmProvider]: model, ...(existingConfig?.lastModels || {}) },
348
349
  approvalMode: approvalMode,
350
+ harness: existingConfig?.harness ?? { thinking: "adaptive", reasoningEffort: "medium" },
349
351
  alwaysAllowLinks: existingConfig?.alwaysAllowLinks ?? false,
350
352
  search: {
351
353
  provider: searchProvider,
@@ -366,7 +368,7 @@ export async function initCommand() {
366
368
  servers: [],
367
369
  },
368
370
  };
369
- await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), "utf8");
371
+ await Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2));
370
372
  p.log.success("Configuration saved to ~/.scira/config.json");
371
373
  // Step 5: Verify
372
374
  p.note("Verifying your setup...", "Verification");
package/dist/cli/index.js CHANGED
@@ -4,8 +4,9 @@ if (typeof Bun === "undefined") {
4
4
  console.error("scira requires Bun. Install it from https://bun.sh and run: bun run dist/cli/index.js");
5
5
  process.exit(1);
6
6
  }
7
- import { readFileSync } from "node:fs";
7
+ import { readFileSync, existsSync } from "node:fs";
8
8
  import { readFile } from "node:fs/promises";
9
+ import { homedir } from "node:os";
9
10
  import { dirname, join } from "node:path";
10
11
  import { fileURLToPath } from "node:url";
11
12
  import sade from "sade";
@@ -16,7 +17,7 @@ loadSciraEnv(process.cwd());
16
17
  import { loadConfig } from "../config/load-config.js";
17
18
  import { createRun, findRun, listRuns, summarizeRun, verificationReport, getRunPaths } from "../storage/run-store.js";
18
19
  import { readJsonl } from "../storage/jsonl.js";
19
- import { runResearchAgent } from "../agent/research-agent.js";
20
+ import { runResearchAgent } from "../agent/main-agent.js";
20
21
  import { openShell } from "./shell/shell.js";
21
22
  import { openTui, openTuiHome } from "./shell/tui.js";
22
23
  import { detectEnv } from "../providers/llm/readiness.js";
@@ -131,7 +132,7 @@ prog
131
132
  const runPath = await findRun(runId, config);
132
133
  let output;
133
134
  if (fmt === "md") {
134
- output = await readFile(`${runPath}/report.md`, "utf8");
135
+ output = await Bun.file(`${runPath}/report.md`).text();
135
136
  }
136
137
  else {
137
138
  const { toJson, toCsv } = await import("../export/formatters.js");
@@ -148,7 +149,7 @@ prog
148
149
  const { writeFile, mkdir } = await import("node:fs/promises");
149
150
  const { dirname } = await import("node:path");
150
151
  await mkdir(dirname(opts.output), { recursive: true });
151
- await writeFile(opts.output, output, "utf8");
152
+ await Bun.write(opts.output, output);
152
153
  console.log(`Exported to ${opts.output}`);
153
154
  }
154
155
  else {
@@ -331,6 +332,9 @@ prog
331
332
  const nodeCheck = checkNodeVersion(20);
332
333
  const nodeStatus = nodeCheck.ok ? "ok" : "fail";
333
334
  console.log(`Node: ${process.version} (${nodeStatus}, requires >=${nodeCheck.required})`);
335
+ const bunVersion = await getBunVersion();
336
+ const bunOk = bunVersion !== null && versionAtLeast(bunVersion, "1.2.0");
337
+ console.log(`Bun: ${bunVersion ?? "not found"} (${bunOk ? "ok" : "fail"}, requires >=1.2.0)`);
334
338
  console.log(`LLM provider: ${config.llmProvider}`);
335
339
  console.log(`Model: ${config.model}`);
336
340
  console.log(`Search provider: ${config.search.provider}`);
@@ -343,6 +347,24 @@ prog
343
347
  console.log(` ${status} ${check.name}${tag} - ${check.purpose}`);
344
348
  }
345
349
  console.log("");
350
+ console.log("Local agent runtimes (claude-code / codex providers):");
351
+ // Claude Code's token lives in the Keychain on macOS, but the logged-in
352
+ // account metadata is mirrored in ~/.claude.json (oauthAccount), so that's a
353
+ // reliable, cross-platform login check. Codex stores a readable auth file.
354
+ const claudeOnPath = await commandResolves("claude");
355
+ const claudeAccount = readClaudeAccount();
356
+ const claudeStatus = !claudeOnPath ? "missing" : claudeAccount ? "ok " : "no auth";
357
+ const claudeWho = claudeAccount ? ` (logged in as ${claudeAccount})` : "";
358
+ console.log(` ${claudeStatus} claude-code - "claude" ${claudeOnPath ? "on PATH" : "not on PATH (install Claude Code)"}, login ${claudeAccount ? "found" : "not found"}${claudeWho}`);
359
+ if (claudeOnPath && !claudeAccount)
360
+ console.log(` Tip: run "claude" and use /login (or "claude doctor" to check) — no API key needed.`);
361
+ const codexOnPath = await commandResolves("codex");
362
+ const codexLoggedIn = existsSync(join(homedir(), ".codex", "auth.json"));
363
+ const codexStatus = !codexOnPath ? "missing" : codexLoggedIn ? "ok " : "no auth";
364
+ console.log(` ${codexStatus} codex - "codex" ${codexOnPath ? "on PATH" : "not on PATH (install Codex)"}, login ${codexLoggedIn ? "found" : "not found"}`);
365
+ if (codexOnPath && !codexLoggedIn)
366
+ console.log(` Tip: run "codex login" to authenticate without an API key.`);
367
+ console.log("");
346
368
  console.log("MCP servers:");
347
369
  const dt = config.mcp.chromeDevtools;
348
370
  const userServers = config.mcp.servers;
@@ -392,6 +414,8 @@ prog
392
414
  const blockers = [];
393
415
  if (!nodeCheck.ok)
394
416
  blockers.push(`upgrade Node to >=${nodeCheck.required}`);
417
+ if (!bunOk)
418
+ blockers.push(bunVersion ? "upgrade Bun to >=1.2.0" : "install Bun (https://bun.sh) — Scira runs on the Bun runtime");
395
419
  if (missingRequired.length > 0) {
396
420
  blockers.push(`set ${missingRequired.map((c) => c.name).join(", ")} in ~/.scira/.env or .scira/.env in your project`);
397
421
  }
@@ -430,18 +454,35 @@ function checkNodeVersion(required) {
430
454
  const current = m ? Number(m[1]) : 0;
431
455
  return { ok: current >= required, required, current };
432
456
  }
433
- async function commandResolves(command) {
434
- const { exec } = await import("node:child_process");
435
- const { promisify } = await import("node:util");
436
- const which = process.platform === "win32" ? "where" : "command -v";
457
+ /** The logged-in Claude Code account email from ~/.claude.json, or null if not logged in. */
458
+ function readClaudeAccount() {
437
459
  try {
438
- await promisify(exec)(`${which} ${command}`);
439
- return true;
460
+ const raw = readFileSync(join(homedir(), ".claude.json"), "utf8");
461
+ const account = JSON.parse(raw).oauthAccount;
462
+ return account?.emailAddress ?? null;
440
463
  }
441
464
  catch {
442
- return false;
465
+ return null;
443
466
  }
444
467
  }
468
+ /** The running Bun version (e.g. "1.3.14"), or null if not running under Bun. */
469
+ function getBunVersion() {
470
+ return typeof Bun !== "undefined" ? Bun.version : null;
471
+ }
472
+ /** True when `version` (semver) is >= `min` (semver). */
473
+ function versionAtLeast(version, min) {
474
+ const a = version.split(".").map((n) => Number.parseInt(n, 10) || 0);
475
+ const b = min.split(".").map((n) => Number.parseInt(n, 10) || 0);
476
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
477
+ const x = a[i] ?? 0, y = b[i] ?? 0;
478
+ if (x !== y)
479
+ return x > y;
480
+ }
481
+ return true;
482
+ }
483
+ function commandResolves(command) {
484
+ return Bun.which(command) !== null;
485
+ }
445
486
  try {
446
487
  const parsed = prog.parse(process.argv, { lazy: true });
447
488
  if (parsed?.handler) {
@@ -1,8 +1,7 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { stdin as input, stdout as output } from "node:process";
3
- import { readFile } from "node:fs/promises";
4
3
  import { getRunPaths, summarizeRun, verificationReport } from "../../storage/run-store.js";
5
- import { runResearchAgent } from "../../agent/research-agent.js";
4
+ import { runResearchAgent } from "../../agent/main-agent.js";
6
5
  import { readJsonl } from "../../storage/jsonl.js";
7
6
  export async function openShell(runPath, config) {
8
7
  const rl = createInterface({ input, output });
@@ -20,7 +19,7 @@ export async function openShell(runPath, config) {
20
19
  console.log(await renderStatus(runPath));
21
20
  break;
22
21
  case "/plan":
23
- console.log(await readFile(getRunPaths(runPath).plan, "utf8"));
22
+ console.log(await Bun.file(getRunPaths(runPath).plan).text());
24
23
  break;
25
24
  case "/run":
26
25
  await runResearchAgent(runPath, state.goal, config);
@@ -38,10 +37,10 @@ export async function openShell(runPath, config) {
38
37
  console.log(await verificationReport(runPath));
39
38
  break;
40
39
  case "/report":
41
- console.log(await readFile(getRunPaths(runPath).report, "utf8").catch(() => "No report.md yet."));
40
+ console.log(await Bun.file(getRunPaths(runPath).report).text().catch(() => "No report.md yet."));
42
41
  break;
43
42
  case "/handoff":
44
- console.log(await readFile(getRunPaths(runPath).handoff, "utf8"));
43
+ console.log(await Bun.file(getRunPaths(runPath).handoff).text());
45
44
  break;
46
45
  case "/close":
47
46
  case "exit":
@@ -1,11 +1,14 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { render } from "ink";
3
3
  import { SciraApp } from "../../ui/ink/SciraApp.js";
4
+ import { closeAllHarnessSessions } from "../../agent/harness-agent.js";
4
5
  export async function openTuiHome(config) {
5
- const instance = render(_jsx(SciraApp, { config: config }), { alternateScreen: true, maxFps: 20 });
6
+ const instance = render(_jsx(SciraApp, { config: config }), { alternateScreen: true, maxFps: 20, exitOnCtrlC: false });
6
7
  await instance.waitUntilExit();
8
+ await closeAllHarnessSessions();
7
9
  }
8
10
  export async function openTui(runPath, config) {
9
- const instance = render(_jsx(SciraApp, { runPath: runPath, config: config }), { alternateScreen: true, maxFps: 20 });
11
+ const instance = render(_jsx(SciraApp, { runPath: runPath, config: config }), { alternateScreen: true, maxFps: 20, exitOnCtrlC: false });
10
12
  await instance.waitUntilExit();
13
+ await closeAllHarnessSessions();
11
14
  }
@@ -11,6 +11,30 @@ export const ENV_KEY_GUIDES = {
11
11
  "Create a key and paste it here (starts with vc_)."
12
12
  ]
13
13
  },
14
+ ANTHROPIC_API_KEY: {
15
+ name: "ANTHROPIC_API_KEY",
16
+ label: "Anthropic (Claude Code)",
17
+ signupUrl: "https://console.anthropic.com/",
18
+ docsUrl: "https://docs.anthropic.com/en/api/overview",
19
+ placeholder: "sk-ant-...",
20
+ steps: [
21
+ "Create an account at console.anthropic.com.",
22
+ "Open Settings → API Keys → Create Key.",
23
+ "Paste the key here (starts with sk-ant-). Powers the local Claude Code harness."
24
+ ]
25
+ },
26
+ OPENAI_API_KEY: {
27
+ name: "OPENAI_API_KEY",
28
+ label: "OpenAI (Codex)",
29
+ signupUrl: "https://platform.openai.com/api-keys",
30
+ docsUrl: "https://platform.openai.com/docs/api-reference",
31
+ placeholder: "sk-...",
32
+ steps: [
33
+ "Create an account at platform.openai.com.",
34
+ "Open API keys → Create new secret key.",
35
+ "Paste the key here (starts with sk-). Powers the local Codex harness."
36
+ ]
37
+ },
14
38
  XAI_API_KEY: {
15
39
  name: "XAI_API_KEY",
16
40
  label: "xAI (Grok)",
@@ -1,10 +1,12 @@
1
1
  import process from "node:process";
2
2
  import { readFileSync } from "node:fs";
3
- import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { mkdir } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  export const MANAGED_ENV_KEYS = [
7
7
  "AI_GATEWAY_API_KEY",
8
+ "ANTHROPIC_API_KEY",
9
+ "OPENAI_API_KEY",
8
10
  "XAI_API_KEY",
9
11
  "CLOUDFLARE_ACCOUNT_ID",
10
12
  "CLOUDFLARE_API_TOKEN",
@@ -79,7 +81,7 @@ export async function setEnvKey(name, value) {
79
81
  await mkdir(join(homedir(), ".scira"), { recursive: true });
80
82
  let content = "";
81
83
  try {
82
- content = await readFile(path, "utf8");
84
+ content = await Bun.file(path).text();
83
85
  }
84
86
  catch {
85
87
  content = "";
@@ -95,6 +97,6 @@ export async function setEnvKey(name, value) {
95
97
  lines.pop();
96
98
  lines.push(entry);
97
99
  }
98
- await writeFile(path, `${lines.join("\n")}\n`);
100
+ await Bun.write(path, `${lines.join("\n")}\n`);
99
101
  process.env[name] = value;
100
102
  }
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { mkdir } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { SciraConfigSchema } from "../types/index.js";
@@ -25,34 +25,29 @@ export async function loadConfig(projectRoot = process.cwd()) {
25
25
  }
26
26
  export async function saveGlobalConfig(config) {
27
27
  await mkdir(dirname(globalConfigPath), { recursive: true });
28
- await writeFile(globalConfigPath, `${JSON.stringify(config, null, 2)}\n`);
28
+ await Bun.write(globalConfigPath, `${JSON.stringify(config, null, 2)}\n`);
29
29
  }
30
30
  export async function saveGlobalMcpConfig(config) {
31
31
  const globalConfig = await readConfigFile(globalConfigPath);
32
32
  const next = { ...globalConfig, mcp: config };
33
33
  await mkdir(dirname(globalConfigPath), { recursive: true });
34
- await writeFile(globalConfigPath, `${JSON.stringify(next, null, 2)}\n`);
34
+ await Bun.write(globalConfigPath, `${JSON.stringify(next, null, 2)}\n`);
35
35
  }
36
36
  export async function saveProjectConfig(config, projectRoot = process.cwd()) {
37
37
  const projectConfigPath = join(projectRoot, ".scira", "config.json");
38
38
  await mkdir(dirname(projectConfigPath), { recursive: true });
39
- await writeFile(projectConfigPath, `${JSON.stringify(config, null, 2)}\n`);
39
+ await Bun.write(projectConfigPath, `${JSON.stringify(config, null, 2)}\n`);
40
40
  }
41
41
  export async function saveProjectMcpConfig(config, projectRoot = process.cwd()) {
42
42
  const projectConfigPath = join(projectRoot, ".scira", "config.json");
43
43
  const projectConfig = await readConfigFile(projectConfigPath);
44
44
  const next = { ...projectConfig, mcp: config };
45
45
  await mkdir(dirname(projectConfigPath), { recursive: true });
46
- await writeFile(projectConfigPath, `${JSON.stringify(next, null, 2)}\n`);
46
+ await Bun.write(projectConfigPath, `${JSON.stringify(next, null, 2)}\n`);
47
47
  }
48
48
  async function readConfigFile(path) {
49
- try {
50
- return JSON.parse(await readFile(path, "utf8"));
51
- }
52
- catch (error) {
53
- if (error.code === "ENOENT") {
54
- return {};
55
- }
56
- throw error;
57
- }
49
+ const file = Bun.file(path);
50
+ if (!(await file.exists()))
51
+ return {};
52
+ return (await file.json());
58
53
  }