@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.
- package/dist/agent/harness-agent.js +216 -0
- package/dist/agent/{research-agent.js → main-agent.js} +30 -10
- package/dist/cli/commands/init.js +7 -5
- package/dist/cli/index.js +75 -14
- package/dist/cli/shell/shell.js +4 -5
- package/dist/cli/shell/tui.js +7 -4
- 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 +18 -4
- 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 +22 -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/tools/workspace.js +15 -0
- package/dist/types/index.js +13 -1
- package/dist/ui/ink/SciraApp.js +14 -10
- package/dist/ui/ink/components/home-screen.js +2 -2
- package/dist/ui/ink/components/overlays.js +78 -17
- package/dist/ui/ink/constants.js +26 -7
- package/dist/ui/ink/hooks/use-agent-turn.js +14 -5
- package/dist/ui/ink/hooks/use-feed-lines.js +31 -6
- 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 +219 -4
- package/dist/ui/ink/lib/utils.js +54 -28
- package/dist/ui/ink/theme.js +5 -10
- package/dist/utils/update-check.js +63 -0
- package/dist/watch/runner.js +2 -2
- package/package.json +13 -11
- 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,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
|
-
-
|
|
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
|
|
50
|
-
-
|
|
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 ?
|
|
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;
|
|
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
|
|
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";
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
434
|
-
|
|
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
|
-
|
|
439
|
-
|
|
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
|
|
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
|
+
}
|
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().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
|
|
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
|
-
|
|
5
|
-
|
|
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
|
}
|
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)",
|