@scira/cli 0.1.5 → 0.1.7

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 (59) hide show
  1. package/dist/agent/harness-agent.js +216 -0
  2. package/dist/agent/{research-agent.js → main-agent.js} +30 -10
  3. package/dist/cli/commands/init.js +7 -5
  4. package/dist/cli/index.js +75 -14
  5. package/dist/cli/shell/shell.js +4 -5
  6. package/dist/cli/shell/tui.js +7 -4
  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 +18 -4
  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 +22 -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/tools/workspace.js +15 -0
  23. package/dist/types/index.js +13 -1
  24. package/dist/ui/ink/SciraApp.js +14 -10
  25. package/dist/ui/ink/components/home-screen.js +2 -2
  26. package/dist/ui/ink/components/overlays.js +78 -17
  27. package/dist/ui/ink/constants.js +26 -7
  28. package/dist/ui/ink/hooks/use-agent-turn.js +14 -5
  29. package/dist/ui/ink/hooks/use-feed-lines.js +31 -6
  30. package/dist/ui/ink/hooks/use-keyboard.js +28 -5
  31. package/dist/ui/ink/hooks/use-session.js +7 -5
  32. package/dist/ui/ink/hooks/use-settings.js +20 -0
  33. package/dist/ui/ink/hooks/use-submit.js +15 -8
  34. package/dist/ui/ink/lib/file-mentions.js +1 -2
  35. package/dist/ui/ink/lib/tool-result.js +219 -4
  36. package/dist/ui/ink/lib/utils.js +54 -28
  37. package/dist/ui/ink/theme.js +5 -10
  38. package/dist/utils/update-check.js +63 -0
  39. package/dist/watch/runner.js +2 -2
  40. package/package.json +13 -11
  41. package/dist/agent/background-tasks.js +0 -173
  42. package/dist/agent/todos.js +0 -140
  43. package/dist/agent/tools.js +0 -432
  44. package/dist/agent/tools.test.js +0 -60
  45. package/dist/agent/workspace.js +0 -85
  46. package/dist/config/env-guide.test.js +0 -18
  47. package/dist/config/env-store.test.js +0 -60
  48. package/dist/storage/jsonl.test.js +0 -38
  49. package/dist/storage/run-store.test.js +0 -65
  50. package/dist/tools/bash-policy.test.js +0 -38
  51. package/dist/tools/search-web.test.js +0 -24
  52. package/dist/tools/workspace.test.js +0 -75
  53. package/dist/types/schema.test.js +0 -61
  54. package/dist/ui/ink/hooks/use-feed-lines.test.js +0 -16
  55. package/dist/ui/ink/lib/tool-result.test.js +0 -60
  56. package/dist/ui/ink/lib/utils.test.js +0 -48
  57. package/dist/ui/ink/session-manager.test.js +0 -31
  58. package/dist/ui/ink/terminal-probe.test.js +0 -12
  59. package/dist/ui/ink/theme.test.js +0 -68
@@ -0,0 +1,216 @@
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
+ import { SKILLS } from "./skills.js";
9
+ // Scira's built-in skills surfaced to the harness runtime (the adapter
10
+ // materializes them natively — e.g. Claude Code writes them as SKILL.md files).
11
+ const HARNESS_SKILLS = SKILLS.map((s) => ({ name: s.name, description: s.summary, content: s.content }));
12
+ /** 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). */
13
+ function withTimeout(p, ms) {
14
+ let timer;
15
+ const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(undefined), ms); });
16
+ return Promise.race([
17
+ Promise.resolve(p).finally(() => clearTimeout(timer)),
18
+ timeout,
19
+ ]);
20
+ }
21
+ function permissionModeFor(provider, config) {
22
+ // Codex has no built-in tool-approval flow; it only runs under allow-all.
23
+ if (provider === "codex")
24
+ return "allow-all";
25
+ switch (config.approvalMode) {
26
+ case "manual": return "allow-reads";
27
+ case "auto": return "allow-all";
28
+ default: return "allow-edits"; // "suggest"
29
+ }
30
+ }
31
+ // With no explicit `auth`, the adapters auto-detect credentials from the host
32
+ // env (gateway key first, then provider key / base URL / org). To force the
33
+ // bundled CLI onto the user's local OAuth login instead, we strip every env var
34
+ // those resolvers read or emit — covering both the host-inherited copy and the
35
+ // adapter-injected copy (the local sandbox strips after merging the spawn env).
36
+ const GATEWAY_ENV = ["AI_GATEWAY_API_KEY", "AI_GATEWAY_BASE_URL"];
37
+ const STRIP_ENV = {
38
+ "claude-code": [...GATEWAY_ENV, "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"],
39
+ codex: [...GATEWAY_ENV, "OPENAI_API_KEY", "CODEX_API_KEY", "OPENAI_BASE_URL", "OPENAI_ORGANIZATION", "OPENAI_PROJECT"]
40
+ };
41
+ function buildAgent(provider, config, workspacePath, instructions, runPath) {
42
+ const sandbox = createLocalSandbox({ rootDir: workspacePath, stripEnv: STRIP_ENV[provider] });
43
+ const permissionMode = permissionModeFor(provider, config);
44
+ const model = config.model.trim() || undefined; // empty = CLI default
45
+ // Give the harness Scira's own multi-query web search + page reader as host
46
+ // tools, so it grounds answers through our search pipeline instead of the
47
+ // CLI's built-in web tools.
48
+ const { webSearch, readUrl } = createResearchTools(runPath, config, undefined, workspacePath, () => false);
49
+ // Use a non-colliding name. Claude Code ships a built-in `webSearch` (single
50
+ // query); naming ours the same lets the built-in shadow it, so the model ends
51
+ // up doing single-query searches. `multiWebSearch` can't be shadowed.
52
+ // Cast bridges the project's `ai` Tool type to the harness's bundled-`ai`
53
+ // ToolSet (same runtime shape, different package versions).
54
+ const tools = { multiWebSearch: webSearch, readUrl };
55
+ // The harness CLI writes relative to its own working directory and has no
56
+ // knowledge of Scira's run layout, so pin the exact run directory and require
57
+ // absolute paths for artifacts — otherwise it guesses (e.g. ~/.scira/runs/).
58
+ const runDir = path.resolve(runPath);
59
+ const runDirSteer = `\n\nRUN DIRECTORY: Write every run artifact — plan.md, notes.md, sources.jsonl, claims.jsonl, report.md — to this exact absolute path, nowhere else:\n ${runDir}\nFor example, the report goes to ${runDir}/report.md. Always use the absolute path; do not use a bare "sources.jsonl" or a ".scira/runs/…" path. If a file already exists, Read it before writing (your Write tool requires that) — or edit it in place.`;
60
+ // Positive, authoritative steering pointing at the unique tool name.
61
+ const webSteer = `\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.`;
62
+ const fullInstructions = `${instructions}${runDirSteer}${webSteer}`;
63
+ // No `auth`: the bundled CLI authenticates with the user's local login
64
+ // (`claude login` → ~/.claude, `codex login` → ~/.codex). We never pass an
65
+ // API key, so a Pro/Max/ChatGPT subscription session is used as-is.
66
+ const harness = provider === "claude-code"
67
+ ? createClaudeCode({
68
+ model,
69
+ thinking: config.harness.thinking,
70
+ maxTurns: config.harness.maxTurns
71
+ })
72
+ : createCodex({
73
+ model,
74
+ reasoningEffort: config.harness.reasoningEffort,
75
+ // Built-in harness web search is always disabled.
76
+ webSearch: false
77
+ });
78
+ return new HarnessAgent({ harness, sandbox, permissionMode, instructions: fullInstructions, tools, skills: HARNESS_SKILLS });
79
+ }
80
+ /** Settings that, if changed, require rebuilding the session (not just the prompt). */
81
+ function settingsFingerprint(provider, config) {
82
+ return JSON.stringify([provider, config.model, config.approvalMode, config.harness]);
83
+ }
84
+ // One live harness session per run directory, reused across turns so the
85
+ // underlying CLI keeps its native conversation/workspace state.
86
+ const sessions = new Map();
87
+ function messageText(content) {
88
+ if (typeof content === "string")
89
+ return content;
90
+ return content
91
+ .map((part) => (part.type === "text" ? part.text : ""))
92
+ .join("");
93
+ }
94
+ function lastUserText(args) {
95
+ const { prompt, messages } = args;
96
+ if (typeof prompt === "string")
97
+ return prompt;
98
+ if (Array.isArray(messages)) {
99
+ for (let i = messages.length - 1; i >= 0; i--) {
100
+ if (messages[i].role === "user")
101
+ return messageText(messages[i].content);
102
+ }
103
+ }
104
+ return "";
105
+ }
106
+ function stateFile(runPath) {
107
+ return path.join(runPath, "harness-state.json");
108
+ }
109
+ async function readPersistedState(runPath) {
110
+ try {
111
+ return JSON.parse(await fs.readFile(stateFile(runPath), "utf8"));
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ }
117
+ async function clearPersistedState(runPath) {
118
+ await fs.rm(stateFile(runPath), { force: true }).catch(() => { });
119
+ }
120
+ async function acquireSession(runPath, provider, config, workspacePath, instructions, abortSignal) {
121
+ const fingerprint = settingsFingerprint(provider, config);
122
+ const existing = sessions.get(runPath);
123
+ // Reuse only if provider/model/approval/harness settings are unchanged.
124
+ if (existing && existing.fingerprint === fingerprint)
125
+ return existing;
126
+ if (existing) {
127
+ await withTimeout(existing.session.destroy(), 5000).catch(() => { });
128
+ sessions.delete(runPath);
129
+ }
130
+ const agent = buildAgent(provider, config, workspacePath, instructions, runPath);
131
+ // Try to resume the run's prior harness session (across process restarts).
132
+ // Only when the persisted settings match; any failure falls back to fresh.
133
+ let session;
134
+ const persisted = await readPersistedState(runPath);
135
+ if (persisted && persisted.provider === provider && persisted.fingerprint === fingerprint) {
136
+ try {
137
+ session = await agent.createSession({ sessionId: runPath, resumeFrom: persisted.state, abortSignal });
138
+ }
139
+ catch {
140
+ await clearPersistedState(runPath);
141
+ session = undefined;
142
+ }
143
+ }
144
+ if (!session)
145
+ session = await agent.createSession({ sessionId: runPath, abortSignal });
146
+ const entry = { agent, session, provider, fingerprint };
147
+ sessions.set(runPath, entry);
148
+ return entry;
149
+ }
150
+ /**
151
+ * Stop a live session, persisting its resume state so the run can continue in a
152
+ * future process. Falls back to destroy() (and clears stale state) on failure.
153
+ */
154
+ async function stopAndPersist(runPath, entry) {
155
+ try {
156
+ // Bounded so a wedged bridge can't hang TUI teardown.
157
+ const state = await withTimeout(entry.session.stop(), 5000);
158
+ if (state === undefined)
159
+ throw new Error("stop() timed out");
160
+ const payload = { provider: entry.provider, fingerprint: entry.fingerprint, state };
161
+ await fs.writeFile(stateFile(runPath), JSON.stringify(payload), "utf8");
162
+ }
163
+ catch {
164
+ await withTimeout(entry.session.destroy(), 5000).catch(() => { });
165
+ await clearPersistedState(runPath);
166
+ }
167
+ }
168
+ /**
169
+ * A ToolLoopAgent-shaped wrapper around a local harness session. `stream()`
170
+ * matches the subset the TUI's `consume()` loop uses, so harness providers drop
171
+ * into the same turn pipeline as the LLM providers.
172
+ */
173
+ class HarnessChatAgent {
174
+ runPath;
175
+ provider;
176
+ config;
177
+ workspacePath;
178
+ instructions;
179
+ constructor(runPath, provider, config, workspacePath, instructions) {
180
+ this.runPath = runPath;
181
+ this.provider = provider;
182
+ this.config = config;
183
+ this.workspacePath = workspacePath;
184
+ this.instructions = instructions;
185
+ }
186
+ async stream(options) {
187
+ const abortSignal = options.abortSignal;
188
+ const { agent, session } = await acquireSession(this.runPath, this.provider, this.config, this.workspacePath, this.instructions, abortSignal);
189
+ const prompt = lastUserText(options);
190
+ const result = await agent.stream({ session, prompt, abortSignal });
191
+ return result;
192
+ }
193
+ }
194
+ /**
195
+ * Build a harness-backed chat bundle for `config.llmProvider` (claude-code /
196
+ * codex). The session persists across turns; `close()` is a no-op so the native
197
+ * CLI state survives. Use {@link closeHarnessSession} on `/new` or teardown.
198
+ */
199
+ export function createHarnessBundle(opts) {
200
+ const agent = new HarnessChatAgent(opts.runPath, opts.provider, opts.config, opts.workspacePath, opts.instructions);
201
+ return { agent, close: async () => { } };
202
+ }
203
+ /** Stop a run's harness session and persist its resume state. No-op if none. */
204
+ export async function closeHarnessSession(runPath) {
205
+ const entry = sessions.get(runPath);
206
+ if (!entry)
207
+ return;
208
+ sessions.delete(runPath);
209
+ await stopAndPersist(runPath, entry);
210
+ }
211
+ /** Stop every live harness session, persisting resume state. Intended for process shutdown. */
212
+ export async function closeAllHarnessSessions() {
213
+ const entries = [...sessions.entries()];
214
+ sessions.clear();
215
+ await Promise.all(entries.map(([runPath, entry]) => stopAndPersist(runPath, entry)));
216
+ }
@@ -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";
@@ -25,7 +26,7 @@ You are in plan mode. Explore and plan before making changes.
25
26
  - Read-only bash is OK: ls, cat, git status, git log, git diff, find, grep (workspace-relative paths only)
26
27
  - When the plan is ready, summarize it and tell the user to type /plan to exit plan mode and begin execution`;
27
28
  }
28
- function instructions(goal, config, options = {}) {
29
+ function instructions(goal, config, options = {}, runPath) {
29
30
  const { workspacePath } = options;
30
31
  const planMode = resolvePlanMode(options);
31
32
  const now = new Date();
@@ -42,12 +43,13 @@ function instructions(goal, config, options = {}) {
42
43
 
43
44
  PROJECT LAYOUT:
44
45
  - Project root (codebase): ${workspacePath}
45
- - Run harness (.scira/runs/…): plan.md, notes.md, report.md, sources.jsonl, claims.jsonl, todos.json
46
+ - This run's directory: ${runPath ?? "(the active run)"} ← artifacts live here
47
+ - Run artifacts: plan.md, notes.md, report.md, sources.jsonl, claims.jsonl, todos.json — pass the bare filename (e.g. \`report.md\`) or the absolute path above. Don't guess a \`.scira/runs/…\` path.
46
48
 
47
49
  FILE TOOLS:
48
50
  - readFile / writeFile / editFile route automatically:
49
- - Harness files by bare name: plan.md, notes.md, report.md, sources.jsonl stored under .scira/runs/
50
- - Everything else (src/…, package.json, …) project root
51
+ - Harness files (plan.md, notes.md, report.md, sources.jsonl, claims.jsonl): pass the BARE filename only — e.g. \`plan.md\`. The tool puts it in this run's directory for you. Never prefix it with a directory, \`.scira\`, \`runs\`, or \`latest\`; those paths are rejected.
52
+ - Source code / project files (src/…, package.json, …): relative to the project root.
51
53
  - Never write source code under .scira. Never put harness files at the project root.
52
54
 
53
55
  CODING TOOLS:
@@ -62,7 +64,7 @@ When the task involves code:
62
64
  - Use editFile for precise changes, writeFile for new source files (paths like src/foo.ts)
63
65
  - Run tests/builds with bash; use bash action=background for servers then action=output to check logs
64
66
  - Match existing code style and patterns` : "";
65
- return `You are Scira AI CLI, made by Zaid Mukaddam, an autonomous research ${workspacePath ? "and coding " : ""}agent.${workspacePath ? " Source code lives at the project root; harness artifacts live under .scira/runs/." : " You operate inside a single run directory on the user's machine."}
67
+ return `You are Scira AI CLI, made by Zaid Mukaddam, an autonomous research ${workspacePath ? "and coding " : ""}agent.${workspacePath ? ` Source code lives at the project root; run artifacts live in this run's directory${runPath ? ` (${runPath})` : ""}.` : ` You operate inside a single run directory${runPath ? ` (${runPath})` : ""} on the user's machine.`}
66
68
 
67
69
  Your goal:
68
70
  ${goal}
@@ -129,6 +131,15 @@ Rules for browser tools:
129
131
  }
130
132
  export async function createResearchAgent(runPath, goal, config, onApprovalRequired, options = {}) {
131
133
  requireLlmKeys(config);
134
+ if (isHarnessProvider(config.llmProvider)) {
135
+ return createHarnessBundle({
136
+ runPath,
137
+ provider: config.llmProvider,
138
+ config,
139
+ workspacePath: options.workspacePath ?? process.cwd(),
140
+ instructions: instructions(goal, config, options, runPath)
141
+ });
142
+ }
132
143
  const bridge = await createMcpBridge(config);
133
144
  const getPlanMode = options.getPlanMode ?? (() => options.planMode ?? false);
134
145
  const researchTools = createResearchTools(runPath, config, onApprovalRequired, options.workspacePath, getPlanMode);
@@ -139,18 +150,18 @@ export async function createResearchAgent(runPath, goal, config, onApprovalRequi
139
150
  const bgContext = options.backgroundTasks ? await options.backgroundTasks.formatContextForAgent() : "";
140
151
  const agent = new ToolLoopAgent({
141
152
  model: getLanguageModel(config),
142
- instructions: instructions(goal, config, options) + bgContext + devtoolsInstructionsBlock(bridge.toolNames),
153
+ instructions: instructions(goal, config, options, runPath) + bgContext + devtoolsInstructionsBlock(bridge.toolNames),
143
154
  tools,
144
155
  stopWhen: isLoopFinished()
145
156
  });
146
157
  return { agent, close: bridge.close };
147
158
  }
148
- function oneShotInstructions(goal, hasDevtools, options = {}) {
159
+ function oneShotInstructions(goal, hasDevtools, options = {}, runPath) {
149
160
  const { workspacePath } = options;
150
161
  const planMode = resolvePlanMode(options);
151
162
  const codingHint = workspacePath ? `
152
163
 
153
- Project root: ${workspacePath}. readFile/writeFile/editFile route code paths to the project root; harness files (plan.md, notes.md, …) stay under .scira/runs/.
164
+ Project root: ${workspacePath}. readFile/writeFile/editFile route code paths to the project root; run artifacts (plan.md, notes.md, …) go in this run's directory${runPath ? ` (${runPath})` : ""} — pass the bare filename or that absolute path.
154
165
  - listWorkspaceDir, grepWorkspace, bash (with background tasks), todo
155
166
  Use them for code questions, debugging, and implementation tasks.` : "";
156
167
  const now = new Date();
@@ -194,6 +205,15 @@ Step 2 — If you decide to answer directly:
194
205
  }
195
206
  export async function createOneShotAgent(runPath, goal, config, onApprovalRequired, onEscalate, options = {}) {
196
207
  requireLlmKeys(config);
208
+ if (isHarnessProvider(config.llmProvider)) {
209
+ return createHarnessBundle({
210
+ runPath,
211
+ provider: config.llmProvider,
212
+ config,
213
+ workspacePath: options.workspacePath ?? process.cwd(),
214
+ instructions: oneShotInstructions(goal, false, options, runPath)
215
+ });
216
+ }
197
217
  const bridge = await createMcpBridge(config);
198
218
  const getPlanMode = options.getPlanMode ?? (() => options.planMode ?? false);
199
219
  const tools = {
@@ -203,7 +223,7 @@ export async function createOneShotAgent(runPath, goal, config, onApprovalRequir
203
223
  const bgContext = options.backgroundTasks ? await options.backgroundTasks.formatContextForAgent() : "";
204
224
  const agent = new ToolLoopAgent({
205
225
  model: getLanguageModel(config),
206
- instructions: oneShotInstructions(goal, bridge.toolNames.length > 0, options) + bgContext + devtoolsInstructionsBlock(bridge.toolNames),
226
+ instructions: oneShotInstructions(goal, bridge.toolNames.length > 0, options, runPath) + bgContext + devtoolsInstructionsBlock(bridge.toolNames),
207
227
  tools,
208
228
  stopWhen: isLoopFinished()
209
229
  });
@@ -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";
@@ -28,6 +29,17 @@ import { createMcpBridge } from "../tools/mcp-bridge.js";
28
29
  import { saveGlobalMcpConfig } from "../config/load-config.js";
29
30
  import { runOAuthFlow } from "../tools/mcp-oauth.js";
30
31
  import { initCommand } from "./commands/init.js";
32
+ import { checkForUpdate, formatUpdateNotice } from "../utils/update-check.js";
33
+ // Once per invocation (throttled to a real npm check at most daily): surface an
34
+ // available update. The TUI shows it as an in-app notice; CLI commands print it.
35
+ // Skip for --version/--help so those stay instant (the daily check can spend up
36
+ // to ~3s on the network, and sade prints+exits for these before any command).
37
+ const argv = process.argv.slice(2);
38
+ const wantsUpdateCheck = !argv.some((a) => ["-v", "--version", "-h", "--help"].includes(a));
39
+ const update = wantsUpdateCheck ? await checkForUpdate(pkgVersion) : null;
40
+ const updateNotice = update ? formatUpdateNotice(update) : undefined;
41
+ // The TUI renders the notice in-app, so the finally banner would double it up.
42
+ let noticeShownInApp = false;
31
43
  const prog = sade("scira");
32
44
  prog
33
45
  .version(pkgVersion)
@@ -39,7 +51,8 @@ prog
39
51
  const question = opts._.length > 0 ? opts._.join(" ") : undefined;
40
52
  const config = await loadConfig();
41
53
  if (!question) {
42
- await openTuiHome(config);
54
+ noticeShownInApp = !!updateNotice;
55
+ await openTuiHome(config, updateNotice);
43
56
  return;
44
57
  }
45
58
  requireLlmKeys(config);
@@ -65,7 +78,8 @@ prog
65
78
  const config = await loadConfig();
66
79
  const run = await createRun(question, config);
67
80
  if (opts.tui) {
68
- await openTui(run.path, config);
81
+ noticeShownInApp = !!updateNotice;
82
+ await openTui(run.path, config, updateNotice);
69
83
  }
70
84
  else if (opts.shell) {
71
85
  await openShell(run.path, config);
@@ -86,7 +100,8 @@ prog
86
100
  await openShell(runPath, config);
87
101
  }
88
102
  else {
89
- await openTui(runPath, config);
103
+ noticeShownInApp = !!updateNotice;
104
+ await openTui(runPath, config, updateNotice);
90
105
  }
91
106
  });
92
107
  prog
@@ -131,7 +146,7 @@ prog
131
146
  const runPath = await findRun(runId, config);
132
147
  let output;
133
148
  if (fmt === "md") {
134
- output = await readFile(`${runPath}/report.md`, "utf8");
149
+ output = await Bun.file(`${runPath}/report.md`).text().catch(() => "");
135
150
  }
136
151
  else {
137
152
  const { toJson, toCsv } = await import("../export/formatters.js");
@@ -148,7 +163,7 @@ prog
148
163
  const { writeFile, mkdir } = await import("node:fs/promises");
149
164
  const { dirname } = await import("node:path");
150
165
  await mkdir(dirname(opts.output), { recursive: true });
151
- await writeFile(opts.output, output, "utf8");
166
+ await Bun.write(opts.output, output);
152
167
  console.log(`Exported to ${opts.output}`);
153
168
  }
154
169
  else {
@@ -331,6 +346,9 @@ prog
331
346
  const nodeCheck = checkNodeVersion(20);
332
347
  const nodeStatus = nodeCheck.ok ? "ok" : "fail";
333
348
  console.log(`Node: ${process.version} (${nodeStatus}, requires >=${nodeCheck.required})`);
349
+ const bunVersion = await getBunVersion();
350
+ const bunOk = bunVersion !== null && versionAtLeast(bunVersion, "1.2.0");
351
+ console.log(`Bun: ${bunVersion ?? "not found"} (${bunOk ? "ok" : "fail"}, requires >=1.2.0)`);
334
352
  console.log(`LLM provider: ${config.llmProvider}`);
335
353
  console.log(`Model: ${config.model}`);
336
354
  console.log(`Search provider: ${config.search.provider}`);
@@ -343,6 +361,24 @@ prog
343
361
  console.log(` ${status} ${check.name}${tag} - ${check.purpose}`);
344
362
  }
345
363
  console.log("");
364
+ console.log("Local agent runtimes (claude-code / codex providers):");
365
+ // Claude Code's token lives in the Keychain on macOS, but the logged-in
366
+ // account metadata is mirrored in ~/.claude.json (oauthAccount), so that's a
367
+ // reliable, cross-platform login check. Codex stores a readable auth file.
368
+ const claudeOnPath = await commandResolves("claude");
369
+ const claudeAccount = readClaudeAccount();
370
+ const claudeStatus = !claudeOnPath ? "missing" : claudeAccount ? "ok " : "no auth";
371
+ const claudeWho = claudeAccount ? ` (logged in as ${claudeAccount})` : "";
372
+ console.log(` ${claudeStatus} claude-code - "claude" ${claudeOnPath ? "on PATH" : "not on PATH (install Claude Code)"}, login ${claudeAccount ? "found" : "not found"}${claudeWho}`);
373
+ if (claudeOnPath && !claudeAccount)
374
+ console.log(` Tip: run "claude" and use /login (or "claude doctor" to check) — no API key needed.`);
375
+ const codexOnPath = await commandResolves("codex");
376
+ const codexLoggedIn = existsSync(join(homedir(), ".codex", "auth.json"));
377
+ const codexStatus = !codexOnPath ? "missing" : codexLoggedIn ? "ok " : "no auth";
378
+ console.log(` ${codexStatus} codex - "codex" ${codexOnPath ? "on PATH" : "not on PATH (install Codex)"}, login ${codexLoggedIn ? "found" : "not found"}`);
379
+ if (codexOnPath && !codexLoggedIn)
380
+ console.log(` Tip: run "codex login" to authenticate without an API key.`);
381
+ console.log("");
346
382
  console.log("MCP servers:");
347
383
  const dt = config.mcp.chromeDevtools;
348
384
  const userServers = config.mcp.servers;
@@ -392,6 +428,8 @@ prog
392
428
  const blockers = [];
393
429
  if (!nodeCheck.ok)
394
430
  blockers.push(`upgrade Node to >=${nodeCheck.required}`);
431
+ if (!bunOk)
432
+ blockers.push(bunVersion ? "upgrade Bun to >=1.2.0" : "install Bun (https://bun.sh) — Scira runs on the Bun runtime");
395
433
  if (missingRequired.length > 0) {
396
434
  blockers.push(`set ${missingRequired.map((c) => c.name).join(", ")} in ~/.scira/.env or .scira/.env in your project`);
397
435
  }
@@ -430,18 +468,35 @@ function checkNodeVersion(required) {
430
468
  const current = m ? Number(m[1]) : 0;
431
469
  return { ok: current >= required, required, current };
432
470
  }
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";
471
+ /** The logged-in Claude Code account email from ~/.claude.json, or null if not logged in. */
472
+ function readClaudeAccount() {
437
473
  try {
438
- await promisify(exec)(`${which} ${command}`);
439
- return true;
474
+ const raw = readFileSync(join(homedir(), ".claude.json"), "utf8");
475
+ const account = JSON.parse(raw).oauthAccount;
476
+ return account?.emailAddress ?? null;
440
477
  }
441
478
  catch {
442
- return false;
479
+ return null;
443
480
  }
444
481
  }
482
+ /** The running Bun version (e.g. "1.3.14"), or null if not running under Bun. */
483
+ function getBunVersion() {
484
+ return typeof Bun !== "undefined" ? Bun.version : null;
485
+ }
486
+ /** True when `version` (semver) is >= `min` (semver). */
487
+ function versionAtLeast(version, min) {
488
+ const a = version.split(".").map((n) => Number.parseInt(n, 10) || 0);
489
+ const b = min.split(".").map((n) => Number.parseInt(n, 10) || 0);
490
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
491
+ const x = a[i] ?? 0, y = b[i] ?? 0;
492
+ if (x !== y)
493
+ return x > y;
494
+ }
495
+ return true;
496
+ }
497
+ function commandResolves(command) {
498
+ return Bun.which(command) !== null;
499
+ }
445
500
  try {
446
501
  const parsed = prog.parse(process.argv, { lazy: true });
447
502
  if (parsed?.handler) {
@@ -452,3 +507,9 @@ catch (error) {
452
507
  console.error(error instanceof Error ? error.message : String(error));
453
508
  process.exitCode = 1;
454
509
  }
510
+ finally {
511
+ // Reminder after a CLI command finishes. Skipped when the TUI already
512
+ // rendered the notice in-app, so we don't show it twice.
513
+ if (updateNotice && !noticeShownInApp)
514
+ process.stderr.write(`\n\x1b[2m${updateNotice}\x1b[0m\n`);
515
+ }
@@ -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().catch(() => "No plan.md yet."));
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
- export async function openTuiHome(config) {
5
- const instance = render(_jsx(SciraApp, { config: config }), { alternateScreen: true, maxFps: 20 });
4
+ import { closeAllHarnessSessions } from "../../agent/harness-agent.js";
5
+ export async function openTuiHome(config, updateNotice) {
6
+ const instance = render(_jsx(SciraApp, { config: config, updateNotice: updateNotice }), { alternateScreen: true, maxFps: 20, exitOnCtrlC: false });
6
7
  await instance.waitUntilExit();
8
+ await closeAllHarnessSessions();
7
9
  }
8
- export async function openTui(runPath, config) {
9
- const instance = render(_jsx(SciraApp, { runPath: runPath, config: config }), { alternateScreen: true, maxFps: 20 });
10
+ export async function openTui(runPath, config, updateNotice) {
11
+ const instance = render(_jsx(SciraApp, { runPath: runPath, config: config, updateNotice: updateNotice }), { 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)",