@scira/cli 0.1.4 → 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.
- package/dist/agent/harness-agent.js +206 -0
- package/dist/agent/{research-agent.js → main-agent.js} +20 -1
- package/dist/cli/commands/init.js +7 -5
- package/dist/cli/index.js +52 -11
- package/dist/cli/shell/shell.js +4 -5
- package/dist/cli/shell/tui.js +5 -2
- package/dist/config/env-guide.js +24 -0
- package/dist/config/env-store.js +5 -3
- package/dist/config/load-config.js +9 -14
- package/dist/providers/harness/local-sandbox.js +143 -0
- package/dist/providers/llm/gateway.js +5 -2
- package/dist/providers/llm/models.js +13 -0
- package/dist/providers/llm/readiness.js +5 -1
- package/dist/providers/llm/registry.js +24 -3
- package/dist/storage/jsonl.js +2 -2
- package/dist/storage/run-store.js +15 -15
- package/dist/tools/agent-tools.js +7 -7
- package/dist/tools/background-tasks.js +4 -5
- package/dist/tools/mcp-oauth.js +29 -25
- package/dist/tools/open-url.js +1 -2
- package/dist/tools/todos.js +3 -3
- package/dist/types/index.js +13 -1
- package/dist/ui/ink/SciraApp.js +53 -12
- package/dist/ui/ink/components/home-screen.js +2 -2
- package/dist/ui/ink/components/overlays.js +73 -15
- package/dist/ui/ink/constants.js +37 -7
- package/dist/ui/ink/hooks/use-agent-turn.js +17 -6
- package/dist/ui/ink/hooks/use-feed-lines.js +34 -7
- package/dist/ui/ink/hooks/use-keyboard.js +28 -5
- package/dist/ui/ink/hooks/use-session.js +7 -5
- package/dist/ui/ink/hooks/use-settings.js +20 -0
- package/dist/ui/ink/hooks/use-submit.js +15 -8
- package/dist/ui/ink/lib/file-mentions.js +1 -2
- package/dist/ui/ink/lib/tool-result.js +205 -2
- package/dist/ui/ink/lib/utils.js +52 -28
- package/dist/ui/ink/theme.js +5 -10
- package/dist/watch/runner.js +2 -2
- package/package.json +15 -13
- package/dist/agent/background-tasks.js +0 -173
- package/dist/agent/todos.js +0 -140
- package/dist/agent/tools.js +0 -432
- package/dist/agent/tools.test.js +0 -60
- package/dist/agent/workspace.js +0 -85
- package/dist/config/env-guide.test.js +0 -18
- package/dist/config/env-store.test.js +0 -60
- package/dist/storage/jsonl.test.js +0 -38
- package/dist/storage/run-store.test.js +0 -65
- package/dist/tools/bash-policy.test.js +0 -38
- package/dist/tools/search-web.test.js +0 -24
- package/dist/tools/workspace.test.js +0 -75
- package/dist/types/schema.test.js +0 -61
- package/dist/ui/ink/hooks/use-feed-lines.test.js +0 -16
- package/dist/ui/ink/lib/tool-result.test.js +0 -60
- package/dist/ui/ink/lib/utils.test.js +0 -48
- package/dist/ui/ink/session-manager.test.js +0 -31
- package/dist/ui/ink/terminal-probe.test.js +0 -12
- 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
|
|
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
|
|
37
|
-
const existingConfig = existsSync(CONFIG_FILE) ?
|
|
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
|
|
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
|
|
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/
|
|
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
|
|
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
|
|
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
|
-
|
|
434
|
-
|
|
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
|
-
|
|
439
|
-
|
|
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
|
|
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) {
|
package/dist/cli/shell/shell.js
CHANGED
|
@@ -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/
|
|
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
|
|
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
|
|
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
|
|
43
|
+
console.log(await Bun.file(getRunPaths(runPath).handoff).text());
|
|
45
44
|
break;
|
|
46
45
|
case "/close":
|
|
47
46
|
case "exit":
|
package/dist/cli/shell/tui.js
CHANGED
|
@@ -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
|
}
|
package/dist/config/env-guide.js
CHANGED
|
@@ -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)",
|
package/dist/config/env-store.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import process from "node:process";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import { mkdir
|
|
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
|
|
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
|
|
100
|
+
await Bun.write(path, `${lines.join("\n")}\n`);
|
|
99
101
|
process.env[name] = value;
|
|
100
102
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir
|
|
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
|
|
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
|
|
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
|
|
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
|
|
46
|
+
await Bun.write(projectConfigPath, `${JSON.stringify(next, null, 2)}\n`);
|
|
47
47
|
}
|
|
48
48
|
async function readConfigFile(path) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
}
|