@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
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
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/** Ask the OS for a free TCP port by binding to :0, then release it for the bridge to claim. */
|
|
5
|
+
function reserveFreePort() {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const srv = net.createServer();
|
|
8
|
+
srv.once("error", reject);
|
|
9
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
10
|
+
const address = srv.address();
|
|
11
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
12
|
+
srv.close(() => (port ? resolve(port) : reject(new Error("Could not reserve a free port"))));
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
function spawnProcess(command, opts, rootDir, baseEnv, stripEnv) {
|
|
17
|
+
const env = { ...process.env, ...baseEnv, ...opts.env };
|
|
18
|
+
for (const name of stripEnv)
|
|
19
|
+
delete env[name];
|
|
20
|
+
// Bun.spawn takes an argv array, not a shell string — wrap in the platform shell.
|
|
21
|
+
const argv = process.platform === "win32" ? ["cmd", "/c", command] : ["sh", "-c", command];
|
|
22
|
+
const proc = Bun.spawn(argv, {
|
|
23
|
+
cwd: opts.workingDirectory ?? rootDir,
|
|
24
|
+
env,
|
|
25
|
+
stdout: "pipe",
|
|
26
|
+
stderr: "pipe",
|
|
27
|
+
...(opts.abortSignal ? { signal: opts.abortSignal } : {}),
|
|
28
|
+
});
|
|
29
|
+
return {
|
|
30
|
+
pid: proc.pid,
|
|
31
|
+
// Bun.spawn pipes are already web ReadableStreams — no node:stream bridging.
|
|
32
|
+
stdout: proc.stdout,
|
|
33
|
+
stderr: proc.stderr,
|
|
34
|
+
wait: () => proc.exited.then((exitCode) => ({ exitCode })),
|
|
35
|
+
kill: async () => {
|
|
36
|
+
proc.kill();
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function createLocalSession(rootDir, baseEnv, stripEnv) {
|
|
41
|
+
const resolve = (p) => (path.isAbsolute(p) ? p : path.join(rootDir, p));
|
|
42
|
+
return {
|
|
43
|
+
description: `Local machine sandbox. Root directory: ${rootDir}. Commands run as the ` +
|
|
44
|
+
`current OS user with full local filesystem and network access.`,
|
|
45
|
+
readFile: async ({ path: p }) => {
|
|
46
|
+
const file = Bun.file(resolve(p));
|
|
47
|
+
return (await file.exists()) ? file.stream() : null;
|
|
48
|
+
},
|
|
49
|
+
readBinaryFile: async ({ path: p }) => {
|
|
50
|
+
try {
|
|
51
|
+
return new Uint8Array(await fs.readFile(resolve(p)));
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
if (e.code === "ENOENT")
|
|
55
|
+
return null;
|
|
56
|
+
throw e;
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
readTextFile: async ({ path: p, encoding = "utf-8", startLine, endLine }) => {
|
|
60
|
+
let text;
|
|
61
|
+
try {
|
|
62
|
+
text = await fs.readFile(resolve(p), { encoding: encoding });
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
if (e.code === "ENOENT")
|
|
66
|
+
return null;
|
|
67
|
+
throw e;
|
|
68
|
+
}
|
|
69
|
+
if (startLine === undefined && endLine === undefined)
|
|
70
|
+
return text;
|
|
71
|
+
const lines = text.split("\n");
|
|
72
|
+
return lines.slice((startLine ?? 1) - 1, endLine ?? lines.length).join("\n");
|
|
73
|
+
},
|
|
74
|
+
writeFile: async ({ path: p, content }) => {
|
|
75
|
+
await fs.mkdir(path.dirname(resolve(p)), { recursive: true });
|
|
76
|
+
const chunks = [];
|
|
77
|
+
for await (const chunk of content) {
|
|
78
|
+
chunks.push(Buffer.from(chunk));
|
|
79
|
+
}
|
|
80
|
+
await fs.writeFile(resolve(p), Buffer.concat(chunks));
|
|
81
|
+
},
|
|
82
|
+
writeBinaryFile: async ({ path: p, content }) => {
|
|
83
|
+
await fs.mkdir(path.dirname(resolve(p)), { recursive: true });
|
|
84
|
+
await fs.writeFile(resolve(p), content);
|
|
85
|
+
},
|
|
86
|
+
writeTextFile: async ({ path: p, content, encoding = "utf-8" }) => {
|
|
87
|
+
await fs.mkdir(path.dirname(resolve(p)), { recursive: true });
|
|
88
|
+
await fs.writeFile(resolve(p), content, { encoding: encoding });
|
|
89
|
+
},
|
|
90
|
+
spawn: async (options) => spawnProcess(options.command, options, rootDir, baseEnv, stripEnv),
|
|
91
|
+
run: async (options) => {
|
|
92
|
+
const proc = spawnProcess(options.command, options, rootDir, baseEnv, stripEnv);
|
|
93
|
+
const [stdout, stderr, { exitCode }] = await Promise.all([
|
|
94
|
+
new Response(proc.stdout).text(),
|
|
95
|
+
new Response(proc.stderr).text(),
|
|
96
|
+
proc.wait(),
|
|
97
|
+
]);
|
|
98
|
+
return { exitCode, stdout, stderr };
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* A {@link HarnessV1SandboxProvider} that runs everything on the local machine.
|
|
104
|
+
* Pair it with `claudeCode`/`codex` from the harness adapters.
|
|
105
|
+
*/
|
|
106
|
+
export function createLocalSandbox(options = {}) {
|
|
107
|
+
const rootDir = path.resolve(options.rootDir ?? process.cwd());
|
|
108
|
+
const baseEnv = options.env ?? {};
|
|
109
|
+
const stripEnv = options.stripEnv ?? [];
|
|
110
|
+
// A local "sandbox" has no durable remote resource — the bridge process is
|
|
111
|
+
// the only state, and it dies with this process. So create and resume are the
|
|
112
|
+
// same operation: a fresh bridge bound to the same rootDir. Cross-process
|
|
113
|
+
// continuity comes from the adapter's resume state (the CLI's native session
|
|
114
|
+
// id) replaying conversation history from ~/.claude / ~/.codex, plus the files
|
|
115
|
+
// persisting on disk.
|
|
116
|
+
const buildSession = async () => {
|
|
117
|
+
await fs.mkdir(rootDir, { recursive: true });
|
|
118
|
+
const session = createLocalSession(rootDir, baseEnv, stripEnv);
|
|
119
|
+
// The bridge binds to a sandbox-declared TCP port. On a local sandbox we
|
|
120
|
+
// reserve a free loopback port and advertise it; the bridge listens there
|
|
121
|
+
// and we reach it over 127.0.0.1.
|
|
122
|
+
const bridgePort = await reserveFreePort();
|
|
123
|
+
return {
|
|
124
|
+
...session,
|
|
125
|
+
id: `local-${path.basename(rootDir)}`,
|
|
126
|
+
defaultWorkingDirectory: rootDir,
|
|
127
|
+
ports: [bridgePort],
|
|
128
|
+
getPortUrl: async ({ port, protocol = "http" }) => {
|
|
129
|
+
const scheme = protocol === "ws" ? "ws" : protocol;
|
|
130
|
+
return `${scheme}://127.0.0.1:${port}`;
|
|
131
|
+
},
|
|
132
|
+
stop: async () => { },
|
|
133
|
+
destroy: async () => { },
|
|
134
|
+
restricted: () => session,
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
return {
|
|
138
|
+
specificationVersion: "harness-sandbox-v1",
|
|
139
|
+
providerId: "local-fs",
|
|
140
|
+
createSession: buildSession,
|
|
141
|
+
resumeSession: buildSession,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { generateText, gateway } from "ai";
|
|
2
|
-
import { getLanguageModel, requireLlmKeys, defaultModelFor } from "./registry.js";
|
|
2
|
+
import { getLanguageModel, requireLlmKeys, defaultModelFor, isHarnessProvider } from "./registry.js";
|
|
3
3
|
async function fetchModelsRaw() {
|
|
4
4
|
const headers = {};
|
|
5
5
|
if (process.env.AI_GATEWAY_API_KEY) {
|
|
@@ -55,8 +55,11 @@ export async function chooseConfiguredModel(config) {
|
|
|
55
55
|
return config.model || defaultModelFor(config.llmProvider);
|
|
56
56
|
}
|
|
57
57
|
export async function generateWithGateway(config, prompt, system) {
|
|
58
|
+
// Harness providers aren't language models; use the AI Gateway directly for
|
|
59
|
+
// these utility generations (e.g. run titles) when a gateway key is present.
|
|
60
|
+
const model = isHarnessProvider(config.llmProvider) ? gateway(DEFAULT_MODEL) : getLanguageModel(config);
|
|
58
61
|
const result = await generateText({
|
|
59
|
-
model
|
|
62
|
+
model,
|
|
60
63
|
system,
|
|
61
64
|
prompt
|
|
62
65
|
});
|
|
@@ -24,6 +24,17 @@ const STATIC_MODELS = {
|
|
|
24
24
|
{ id: "Qwen/Qwen2.5-72B-Instruct", name: "Qwen 2.5 72B Instruct" },
|
|
25
25
|
{ id: "mistralai/Mistral-7B-Instruct-v0.3", name: "Mistral 7B Instruct v0.3" },
|
|
26
26
|
{ id: "deepseek-ai/DeepSeek-V3", name: "DeepSeek V3" }
|
|
27
|
+
],
|
|
28
|
+
"claude-code": [
|
|
29
|
+
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
|
30
|
+
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
|
|
31
|
+
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5" }
|
|
32
|
+
],
|
|
33
|
+
codex: [
|
|
34
|
+
{ id: "gpt-5.5", name: "GPT-5.5" },
|
|
35
|
+
{ id: "gpt-5.4", name: "GPT-5.4" },
|
|
36
|
+
{ id: "gpt-5.4-mini", name: "GPT-5.4-Mini" },
|
|
37
|
+
{ id: "gpt-5.3-codex-spark", name: "GPT-5.3-Codex-Spark" }
|
|
27
38
|
]
|
|
28
39
|
};
|
|
29
40
|
async function listXaiModels() {
|
|
@@ -31,15 +42,16 @@ async function listXaiModels() {
|
|
|
31
42
|
if (!key)
|
|
32
43
|
return STATIC_MODELS.xai;
|
|
33
44
|
try {
|
|
34
|
-
//
|
|
35
|
-
|
|
45
|
+
// The language-models endpoint returns only text/chat models (it excludes
|
|
46
|
+
// the image/video/embedding models that /v1/models lists).
|
|
47
|
+
const response = await fetch("https://api.x.ai/v1/language-models", {
|
|
36
48
|
headers: { Authorization: `Bearer ${key}` },
|
|
37
49
|
signal: AbortSignal.timeout(15000)
|
|
38
50
|
});
|
|
39
51
|
if (!response.ok)
|
|
40
|
-
throw new Error(`xAI models endpoint returned ${response.status}`);
|
|
52
|
+
throw new Error(`xAI language-models endpoint returned ${response.status}`);
|
|
41
53
|
const payload = await response.json();
|
|
42
|
-
const models = (payload.data ?? []).map((m) => ({ id: m.id }));
|
|
54
|
+
const models = (payload.models ?? payload.data ?? []).map((m) => ({ id: m.id }));
|
|
43
55
|
return models.length > 0 ? models : STATIC_MODELS.xai;
|
|
44
56
|
}
|
|
45
57
|
catch {
|
|
@@ -92,6 +104,8 @@ export async function listModels(config) {
|
|
|
92
104
|
case "xai": return listXaiModels();
|
|
93
105
|
case "workers-ai": return listWorkersAiModels();
|
|
94
106
|
case "huggingface": return listHuggingFaceModelsWrapper();
|
|
107
|
+
case "claude-code": return STATIC_MODELS["claude-code"];
|
|
108
|
+
case "codex": return STATIC_MODELS.codex;
|
|
95
109
|
default: return (await listToolUseModels()).map((m) => ({ id: m.id, name: m.name }));
|
|
96
110
|
}
|
|
97
111
|
}
|
|
@@ -25,17 +25,21 @@ export function requireSearchProvider(provider) {
|
|
|
25
25
|
}
|
|
26
26
|
const LLM_ENV_CHECKS = [
|
|
27
27
|
{ name: AI_GATEWAY_ENV, provider: "gateway", purpose: "Vercel AI Gateway LLM access" },
|
|
28
|
+
{ name: "ANTHROPIC_API_KEY", provider: "claude-code", purpose: "Claude Code (local harness) access" },
|
|
29
|
+
{ name: "OPENAI_API_KEY", provider: "codex", purpose: "Codex (local harness) access" },
|
|
28
30
|
{ name: "XAI_API_KEY", provider: "xai", purpose: "xAI (Grok) LLM access" },
|
|
29
31
|
{ name: "CLOUDFLARE_ACCOUNT_ID", provider: "workers-ai", purpose: "Cloudflare Workers AI account" },
|
|
30
32
|
{ name: "CLOUDFLARE_API_TOKEN", provider: "workers-ai", purpose: "Cloudflare Workers AI LLM access" },
|
|
31
33
|
{ name: "HF_API_KEY", provider: "huggingface", purpose: "HuggingFace Inference API access" }
|
|
32
34
|
];
|
|
33
35
|
export function detectEnv(provider, llmProvider = "gateway") {
|
|
36
|
+
// Local harness providers authenticate via the CLI login, so their API key is optional.
|
|
37
|
+
const harnessActive = llmProvider === "claude-code" || llmProvider === "codex";
|
|
34
38
|
const checks = LLM_ENV_CHECKS.map((c) => ({
|
|
35
39
|
name: c.name,
|
|
36
40
|
present: hasEnv(c.name),
|
|
37
41
|
purpose: c.purpose,
|
|
38
|
-
required: c.provider === llmProvider
|
|
42
|
+
required: c.provider === llmProvider && !harnessActive
|
|
39
43
|
}));
|
|
40
44
|
for (const key of Object.keys(PROVIDER_ENV)) {
|
|
41
45
|
const name = PROVIDER_ENV[key];
|
|
@@ -3,31 +3,49 @@ import { createXai } from "@ai-sdk/xai";
|
|
|
3
3
|
import { createWorkersAI } from "workers-ai-provider";
|
|
4
4
|
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
5
5
|
import { hasEnv } from "./readiness.js";
|
|
6
|
-
|
|
6
|
+
/** Providers that run a local agent harness (their own tool loop + CLI) rather than a bare language model. */
|
|
7
|
+
export const HARNESS_PROVIDERS = ["claude-code", "codex"];
|
|
8
|
+
export function isHarnessProvider(provider) {
|
|
9
|
+
return HARNESS_PROVIDERS.includes(provider);
|
|
10
|
+
}
|
|
11
|
+
export const LLM_PROVIDERS = ["gateway", "xai", "workers-ai", "huggingface", "claude-code", "codex"];
|
|
7
12
|
/** Human-readable names for the provider picker and status messages. */
|
|
8
13
|
export const LLM_PROVIDER_LABELS = {
|
|
9
14
|
gateway: "Vercel AI Gateway",
|
|
10
15
|
xai: "xAI",
|
|
11
16
|
"workers-ai": "Cloudflare Workers AI",
|
|
12
|
-
huggingface: "HuggingFace"
|
|
17
|
+
huggingface: "HuggingFace",
|
|
18
|
+
"claude-code": "Claude Code (local)",
|
|
19
|
+
codex: "Codex (local)"
|
|
13
20
|
};
|
|
14
21
|
/** Env vars each LLM provider needs before it can generate. */
|
|
15
22
|
export const LLM_PROVIDER_ENV = {
|
|
16
23
|
gateway: ["AI_GATEWAY_API_KEY"],
|
|
17
24
|
xai: ["XAI_API_KEY"],
|
|
18
25
|
"workers-ai": ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"],
|
|
19
|
-
huggingface: ["HF_API_KEY"]
|
|
26
|
+
huggingface: ["HF_API_KEY"],
|
|
27
|
+
// Harness providers accept their native key OR the AI Gateway key (OR-checked
|
|
28
|
+
// in requireLlmKeys, not the default AND-of-all logic).
|
|
29
|
+
"claude-code": ["ANTHROPIC_API_KEY"],
|
|
30
|
+
codex: ["OPENAI_API_KEY"]
|
|
20
31
|
};
|
|
21
32
|
export function defaultModelFor(provider) {
|
|
22
33
|
switch (provider) {
|
|
23
34
|
case "xai": return "grok-build-0.1";
|
|
24
35
|
case "workers-ai": return "@cf/moonshotai/kimi-k2.6";
|
|
25
36
|
case "huggingface": return "meta-llama/Llama-3.3-70B-Instruct";
|
|
37
|
+
case "claude-code": return "claude-sonnet-4-6";
|
|
38
|
+
case "codex": return "gpt-5.5";
|
|
26
39
|
default: return "deepseek/deepseek-v4-flash";
|
|
27
40
|
}
|
|
28
41
|
}
|
|
29
42
|
/** Throw a setup-oriented error when the active LLM provider's env keys are missing. */
|
|
30
43
|
export function requireLlmKeys(config) {
|
|
44
|
+
if (isHarnessProvider(config.llmProvider)) {
|
|
45
|
+
// No key required: the local harness uses the user's CLI login
|
|
46
|
+
// (claude login / codex login). An explicit API key is optional.
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
31
49
|
const missing = LLM_PROVIDER_ENV[config.llmProvider].filter((name) => !hasEnv(name));
|
|
32
50
|
if (missing.length > 0) {
|
|
33
51
|
throw new Error(`${missing.join(" and ")} ${missing.length === 1 ? "is" : "are"} required for the "${config.llmProvider}" LLM provider. Set ${missing.length === 1 ? "it" : "them"} with /key or in your environment.`);
|
|
@@ -36,6 +54,9 @@ export function requireLlmKeys(config) {
|
|
|
36
54
|
/** Build the AI SDK language model for the configured provider + model id. */
|
|
37
55
|
export function getLanguageModel(config) {
|
|
38
56
|
requireLlmKeys(config);
|
|
57
|
+
if (isHarnessProvider(config.llmProvider)) {
|
|
58
|
+
throw new Error(`The "${config.llmProvider}" provider runs as a local agent harness, not a language model — drive it through the harness agent path, not getLanguageModel().`);
|
|
59
|
+
}
|
|
39
60
|
switch (config.llmProvider) {
|
|
40
61
|
case "xai":
|
|
41
62
|
return createXai({ apiKey: process.env.XAI_API_KEY })(config.model);
|
package/dist/storage/jsonl.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir,
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
export async function appendJsonl(path, value) {
|
|
4
4
|
await mkdir(dirname(path), { recursive: true });
|
|
@@ -6,7 +6,7 @@ export async function appendJsonl(path, value) {
|
|
|
6
6
|
}
|
|
7
7
|
export async function readJsonl(path) {
|
|
8
8
|
try {
|
|
9
|
-
const text = await
|
|
9
|
+
const text = await Bun.file(path).text();
|
|
10
10
|
const results = [];
|
|
11
11
|
for (const line of text.split("\n")) {
|
|
12
12
|
if (!line.trim())
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { mkdir,
|
|
1
|
+
import { mkdir, readdir, rm, stat } from "node:fs/promises";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { createRunId } from "../utils/ids.js";
|
|
4
4
|
import { appendJsonl, readJsonl } from "./jsonl.js";
|
|
5
|
+
import { isHarnessProvider } from "../providers/llm/registry.js";
|
|
5
6
|
export function getRunPaths(runPath) {
|
|
6
7
|
return {
|
|
7
8
|
root: runPath,
|
|
@@ -26,26 +27,32 @@ export async function createRun(goal, config, projectRoot = process.cwd()) {
|
|
|
26
27
|
const paths = getRunPaths(runPath);
|
|
27
28
|
await mkdir(paths.artifacts, { recursive: true });
|
|
28
29
|
await mkdir(paths.snapshots, { recursive: true });
|
|
29
|
-
await
|
|
30
|
-
await
|
|
31
|
-
await
|
|
32
|
-
await
|
|
33
|
-
await
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
30
|
+
await Bun.write(paths.goal, `# Goal\n\n${goal}\n`);
|
|
31
|
+
await Bun.write(paths.research, researchInstructions());
|
|
32
|
+
await Bun.write(paths.scope, `${JSON.stringify({ goal, maxSources: config.maxSources, citationPolicy: config.citationPolicy }, null, 2)}\n`);
|
|
33
|
+
await Bun.write(paths.progress, progressText("created", "Generate and approve research plan."));
|
|
34
|
+
await Bun.write(paths.handoff, handoffText(goal, "created"));
|
|
35
|
+
// The local-harness providers (claude-code / codex) run their own CLI, whose
|
|
36
|
+
// Write tool refuses to overwrite a file it hasn't Read first. Pre-seeding the
|
|
37
|
+
// agent-written artifacts would block it, so leave those for the agent to
|
|
38
|
+
// create fresh. summarizeRun tolerates the missing files.
|
|
39
|
+
if (!isHarnessProvider(config.llmProvider)) {
|
|
40
|
+
await Bun.write(paths.plan, "# Research Plan\n\nPending plan generation.\n");
|
|
41
|
+
await Bun.write(paths.sources, "");
|
|
42
|
+
await Bun.write(paths.claims, "");
|
|
43
|
+
await Bun.write(paths.notes, "# Notes\n\n");
|
|
44
|
+
await Bun.write(paths.report, "# Report\n\nDraft not generated yet.\n");
|
|
45
|
+
}
|
|
39
46
|
await logEvent(paths.root, "run.created", { goal });
|
|
40
47
|
return summarizeRun(paths.root);
|
|
41
48
|
}
|
|
42
49
|
export async function summarizeRun(runPath) {
|
|
43
50
|
const paths = getRunPaths(runPath);
|
|
44
|
-
const goal = (await
|
|
45
|
-
const title = (await
|
|
51
|
+
const goal = (await Bun.file(paths.goal).text().catch(() => "")).replace(/^# Goal\s*/u, "").trim();
|
|
52
|
+
const title = (await Bun.file(join(runPath, "title.md")).text().catch(() => "")).trim() || undefined;
|
|
46
53
|
const sources = await readJsonl(paths.sources);
|
|
47
54
|
const claims = await readJsonl(paths.claims);
|
|
48
|
-
const report = await
|
|
55
|
+
const report = await Bun.file(paths.report).text().catch(() => "");
|
|
49
56
|
// last activity = newest mtime among the files that change as a run progresses
|
|
50
57
|
const mtimes = await Promise.all([join(runPath, "convo.json"), paths.report, runPath].map((p) => stat(p).then((s) => s.mtimeMs).catch(() => 0)));
|
|
51
58
|
const updatedAt = Math.max(0, ...mtimes);
|
|
@@ -63,7 +70,7 @@ export async function summarizeRun(runPath) {
|
|
|
63
70
|
};
|
|
64
71
|
}
|
|
65
72
|
export async function setRunTitle(runPath, title) {
|
|
66
|
-
await
|
|
73
|
+
await Bun.write(join(runPath, "title.md"), title.trim());
|
|
67
74
|
}
|
|
68
75
|
export async function deleteRun(runPath) {
|
|
69
76
|
await rm(runPath, { recursive: true, force: true });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { exec, execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
-
import {
|
|
3
|
+
import { mkdir, readdir } from "node:fs/promises";
|
|
4
4
|
import { dirname, join, relative } from "node:path";
|
|
5
5
|
import { tool } from "ai";
|
|
6
6
|
import { z } from "zod";
|
|
@@ -226,7 +226,7 @@ export function createResearchTools(runPath, config, onApprovalRequired, workspa
|
|
|
226
226
|
if (needsApproval) {
|
|
227
227
|
let description;
|
|
228
228
|
if (harnessName === "report.md" && resolved.scope === "run") {
|
|
229
|
-
const existing = await
|
|
229
|
+
const existing = await Bun.file(resolved.abs).text().catch(() => "");
|
|
230
230
|
const parts = diffLines(existing, content);
|
|
231
231
|
const added = parts.filter((p) => p.added).reduce((n, p) => n + (p.count ?? 0), 0);
|
|
232
232
|
const removed = parts.filter((p) => p.removed).reduce((n, p) => n + (p.count ?? 0), 0);
|
|
@@ -248,7 +248,7 @@ export function createResearchTools(runPath, config, onApprovalRequired, workspa
|
|
|
248
248
|
return `Write to ${resolved.displayPath} rejected by user.`;
|
|
249
249
|
}
|
|
250
250
|
await mkdir(dirname(resolved.abs), { recursive: true });
|
|
251
|
-
await
|
|
251
|
+
await Bun.write(resolved.abs, content);
|
|
252
252
|
const eventType = harnessName === "report.md" ? "report.updated" : harnessName === "plan.md" ? "plan.updated" : "file.written";
|
|
253
253
|
await logEvent(runPath, eventType, { path: resolved.displayPath, scope: resolved.scope, chars: content.length });
|
|
254
254
|
const where = resolved.scope === "workspace" ? "workspace" : "run";
|
|
@@ -268,7 +268,7 @@ export function createResearchTools(runPath, config, onApprovalRequired, workspa
|
|
|
268
268
|
const blocked = planModeBlocksEdit(getPlanMode, resolved.scope, harnessName);
|
|
269
269
|
if (blocked)
|
|
270
270
|
return blocked;
|
|
271
|
-
const current = await
|
|
271
|
+
const current = await Bun.file(resolved.abs).text();
|
|
272
272
|
const occurrences = current.split(oldString).length - 1;
|
|
273
273
|
if (occurrences === 0) {
|
|
274
274
|
return `No match for the given oldString in ${resolved.displayPath}. No changes made.`;
|
|
@@ -287,7 +287,7 @@ export function createResearchTools(runPath, config, onApprovalRequired, workspa
|
|
|
287
287
|
return `Edit to ${resolved.displayPath} rejected by user.`;
|
|
288
288
|
}
|
|
289
289
|
}
|
|
290
|
-
await
|
|
290
|
+
await Bun.write(resolved.abs, current.replace(oldString, newString));
|
|
291
291
|
await logEvent(runPath, "file.edited", { path: resolved.displayPath, scope: resolved.scope });
|
|
292
292
|
return `Edited ${resolved.displayPath}`;
|
|
293
293
|
}
|
|
@@ -327,7 +327,7 @@ export function createResearchTools(runPath, config, onApprovalRequired, workspa
|
|
|
327
327
|
}
|
|
328
328
|
claims[idx] = { ...claims[idx], status, reason };
|
|
329
329
|
await mkdir(dirname(claimsPath), { recursive: true });
|
|
330
|
-
await
|
|
330
|
+
await Bun.write(claimsPath, claims.map((c) => JSON.stringify(c)).join("\n") + "\n");
|
|
331
331
|
await logEvent(runPath, "claim.verified", { id, status });
|
|
332
332
|
return `Claim ${id} → ${status}`;
|
|
333
333
|
}
|
|
@@ -339,7 +339,7 @@ export function createResearchTools(runPath, config, onApprovalRequired, workspa
|
|
|
339
339
|
}),
|
|
340
340
|
execute: async ({ path }) => {
|
|
341
341
|
const resolved = resolveToolPath(runPath, workspacePath, path);
|
|
342
|
-
return truncate(await
|
|
342
|
+
return truncate(await Bun.file(resolved.abs).text());
|
|
343
343
|
}
|
|
344
344
|
}),
|
|
345
345
|
webSearch: tool({
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
1
|
import { spawn } from "node:child_process";
|
|
3
|
-
import {
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
4
3
|
import { dirname, join } from "node:path";
|
|
5
4
|
const MAX_OUTPUT_LINES = 500;
|
|
6
5
|
const MAX_TAIL_CHARS = 4000;
|
|
@@ -48,7 +47,7 @@ export class BackgroundTaskManager {
|
|
|
48
47
|
runtime = new Map();
|
|
49
48
|
records = [];
|
|
50
49
|
loaded = false;
|
|
51
|
-
sessionToken = randomUUID();
|
|
50
|
+
sessionToken = crypto.randomUUID();
|
|
52
51
|
constructor(persistPath, defaultCwd) {
|
|
53
52
|
this.persistPath = persistPath;
|
|
54
53
|
this.defaultCwd = defaultCwd;
|
|
@@ -58,7 +57,7 @@ export class BackgroundTaskManager {
|
|
|
58
57
|
return;
|
|
59
58
|
this.loaded = true;
|
|
60
59
|
try {
|
|
61
|
-
const raw = await
|
|
60
|
+
const raw = await Bun.file(this.persistPath).text();
|
|
62
61
|
const parsed = JSON.parse(raw);
|
|
63
62
|
if (Array.isArray(parsed)) {
|
|
64
63
|
this.records = parsed.filter(isValidRecord);
|
|
@@ -94,7 +93,7 @@ export class BackgroundTaskManager {
|
|
|
94
93
|
}
|
|
95
94
|
async persist() {
|
|
96
95
|
await mkdir(dirname(this.persistPath), { recursive: true });
|
|
97
|
-
await
|
|
96
|
+
await Bun.write(this.persistPath, JSON.stringify(this.records, null, 2) + "\n");
|
|
98
97
|
}
|
|
99
98
|
syncRecord(task) {
|
|
100
99
|
const idx = this.records.findIndex((r) => r.id === task.record.id);
|
package/dist/tools/mcp-oauth.js
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
-
import { createServer } from "node:http";
|
|
3
|
-
import { exec } from "node:child_process";
|
|
4
1
|
import { saveGlobalMcpConfig } from "../config/load-config.js";
|
|
5
2
|
function toBase64Url(buf) {
|
|
6
|
-
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
3
|
+
return Buffer.from(buf).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
7
4
|
}
|
|
8
5
|
function createVerifier() {
|
|
9
|
-
return toBase64Url(
|
|
6
|
+
return toBase64Url(crypto.getRandomValues(new Uint8Array(48)));
|
|
10
7
|
}
|
|
11
8
|
function createChallenge(verifier) {
|
|
12
|
-
return toBase64Url(
|
|
9
|
+
return toBase64Url(new Bun.CryptoHasher("sha256").update(verifier).digest());
|
|
13
10
|
}
|
|
14
11
|
async function fetchJson(url) {
|
|
15
12
|
try {
|
|
@@ -128,31 +125,38 @@ function patchClientId(mcp, name, clientId, clientSecret) {
|
|
|
128
125
|
};
|
|
129
126
|
}
|
|
130
127
|
function openBrowser(url) {
|
|
131
|
-
const
|
|
132
|
-
process.platform === "win32" ?
|
|
133
|
-
|
|
134
|
-
|
|
128
|
+
const argv = process.platform === "darwin" ? ["open", url] :
|
|
129
|
+
process.platform === "win32" ? ["cmd", "/c", "start", "", url] :
|
|
130
|
+
["xdg-open", url];
|
|
131
|
+
Bun.spawn(argv, { stdout: "ignore", stderr: "ignore" });
|
|
135
132
|
}
|
|
136
133
|
function waitForCallback(port, timeoutMs = 120_000) {
|
|
137
134
|
return new Promise((resolve, reject) => {
|
|
138
|
-
const server =
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
135
|
+
const server = Bun.serve({
|
|
136
|
+
port,
|
|
137
|
+
hostname: "127.0.0.1",
|
|
138
|
+
fetch(req) {
|
|
139
|
+
const u = new URL(req.url);
|
|
140
|
+
const code = u.searchParams.get("code");
|
|
141
|
+
const state = u.searchParams.get("state");
|
|
142
|
+
queueMicrotask(() => {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
// Graceful stop: let the response flush to the browser before closing.
|
|
145
|
+
void server.stop();
|
|
146
|
+
if (code && state)
|
|
147
|
+
resolve({ code, state });
|
|
148
|
+
else
|
|
149
|
+
reject(new Error("OAuth callback missing code or state"));
|
|
150
|
+
});
|
|
151
|
+
return new Response("<html><body><h2>OAuth connected — you can close this tab.</h2></body></html>", {
|
|
152
|
+
headers: { "Content-Type": "text/html" },
|
|
153
|
+
});
|
|
154
|
+
},
|
|
149
155
|
});
|
|
150
|
-
server.listen(port, "127.0.0.1");
|
|
151
156
|
const timer = setTimeout(() => {
|
|
152
|
-
server.
|
|
157
|
+
void server.stop(true);
|
|
153
158
|
reject(new Error("OAuth flow timed out (2 min). Run `scira mcp oauth <name>` to retry."));
|
|
154
159
|
}, timeoutMs);
|
|
155
|
-
server.once("close", () => clearTimeout(timer));
|
|
156
160
|
});
|
|
157
161
|
}
|
|
158
162
|
async function exchangeCode(opts) {
|
|
@@ -235,7 +239,7 @@ export async function runOAuthFlow(srv, config) {
|
|
|
235
239
|
}
|
|
236
240
|
const verifier = createVerifier();
|
|
237
241
|
const challenge = createChallenge(verifier);
|
|
238
|
-
const state = toBase64Url(
|
|
242
|
+
const state = toBase64Url(crypto.getRandomValues(new Uint8Array(16)));
|
|
239
243
|
const scope = srv.oauthScopes ?? endpoints.suggestedScope ?? undefined;
|
|
240
244
|
const params = new URLSearchParams({
|
|
241
245
|
response_type: "code",
|