@scira/cli 0.1.0
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/LICENSE +21 -0
- package/README.md +128 -0
- package/dist/agent/research-agent.js +253 -0
- package/dist/agent/skills.js +265 -0
- package/dist/agent/tools.js +429 -0
- package/dist/agent/tools.test.js +27 -0
- package/dist/cli/commands/init.js +370 -0
- package/dist/cli/index.js +445 -0
- package/dist/cli/shell/shell.js +76 -0
- package/dist/cli/shell/tui.js +11 -0
- package/dist/config/env-store.js +47 -0
- package/dist/config/load-config.js +58 -0
- package/dist/export/formatters.js +37 -0
- package/dist/providers/llm/gateway.js +64 -0
- package/dist/providers/llm/huggingface.js +33 -0
- package/dist/providers/llm/models.js +97 -0
- package/dist/providers/llm/readiness.js +50 -0
- package/dist/providers/llm/registry.js +56 -0
- package/dist/storage/jsonl.js +29 -0
- package/dist/storage/jsonl.test.js +38 -0
- package/dist/storage/run-store.js +134 -0
- package/dist/storage/run-store.test.js +65 -0
- package/dist/tools/chrome-devtools-mcp.js +61 -0
- package/dist/tools/file-tools.js +128 -0
- package/dist/tools/mcp-bridge.js +118 -0
- package/dist/tools/mcp-oauth.js +276 -0
- package/dist/tools/open-url.js +99 -0
- package/dist/tools/search-web.js +153 -0
- package/dist/types/index.js +91 -0
- package/dist/types/schema.test.js +60 -0
- package/dist/ui/ink/SciraApp.js +274 -0
- package/dist/ui/ink/components/effects.js +44 -0
- package/dist/ui/ink/components/home-screen.js +69 -0
- package/dist/ui/ink/components/overlays.js +111 -0
- package/dist/ui/ink/constants.js +56 -0
- package/dist/ui/ink/hooks/use-agent-turn.js +186 -0
- package/dist/ui/ink/hooks/use-feed-lines.js +186 -0
- package/dist/ui/ink/hooks/use-feed.js +69 -0
- package/dist/ui/ink/hooks/use-keyboard.js +315 -0
- package/dist/ui/ink/hooks/use-mouse.js +31 -0
- package/dist/ui/ink/hooks/use-session.js +103 -0
- package/dist/ui/ink/hooks/use-settings.js +155 -0
- package/dist/ui/ink/hooks/use-submit.js +366 -0
- package/dist/ui/ink/hooks/use-suggestions.js +91 -0
- package/dist/ui/ink/lib/file-mentions.js +71 -0
- package/dist/ui/ink/lib/markdown.js +245 -0
- package/dist/ui/ink/lib/utils.js +224 -0
- package/dist/ui/ink/session-manager.js +160 -0
- package/dist/ui/ink/types.js +1 -0
- package/dist/utils/ids.js +15 -0
- package/dist/utils/markdown-joiner.js +249 -0
- package/dist/watch/runner.js +65 -0
- package/package.json +74 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
const { version: pkgVersion } = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../../package.json"), "utf8"));
|
|
10
|
+
// Load keys from the global config dir (~/.scira/.env) so they work regardless
|
|
11
|
+
// of where the CLI is invoked from (e.g. after pnpm link / global install).
|
|
12
|
+
try {
|
|
13
|
+
process.loadEnvFile(join(homedir(), ".scira", ".env"));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// no ~/.scira/.env present; rely on the ambient environment
|
|
17
|
+
}
|
|
18
|
+
import { loadConfig } from "../config/load-config.js";
|
|
19
|
+
import { createRun, findRun, listRuns, summarizeRun, verificationReport, getRunPaths } from "../storage/run-store.js";
|
|
20
|
+
import { readJsonl } from "../storage/jsonl.js";
|
|
21
|
+
import { runResearchAgent } from "../agent/research-agent.js";
|
|
22
|
+
import { openShell } from "./shell/shell.js";
|
|
23
|
+
import { openTui, openTuiHome } from "./shell/tui.js";
|
|
24
|
+
import { detectEnv } from "../providers/llm/readiness.js";
|
|
25
|
+
import { requireLlmKeys } from "../providers/llm/registry.js";
|
|
26
|
+
import { listModels } from "../providers/llm/models.js";
|
|
27
|
+
import { listGatewayModels } from "../providers/llm/gateway.js";
|
|
28
|
+
import { createMcpBridge } from "../tools/mcp-bridge.js";
|
|
29
|
+
import { saveGlobalMcpConfig } from "../config/load-config.js";
|
|
30
|
+
import { runOAuthFlow } from "../tools/mcp-oauth.js";
|
|
31
|
+
import { initCommand } from "./commands/init.js";
|
|
32
|
+
const program = new Command();
|
|
33
|
+
program
|
|
34
|
+
.name("scira")
|
|
35
|
+
.description("Terminal-native AI research agent.")
|
|
36
|
+
.version(pkgVersion);
|
|
37
|
+
program
|
|
38
|
+
.argument("[question]", "research question or coding task")
|
|
39
|
+
.option("--workspace <path>", "enable coding tools for this workspace directory")
|
|
40
|
+
.action(async (question, options) => {
|
|
41
|
+
const config = await loadConfig();
|
|
42
|
+
if (!question) {
|
|
43
|
+
await openTuiHome(config);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
requireLlmKeys(config);
|
|
47
|
+
const run = await createRun(question, config);
|
|
48
|
+
console.log(`Run: ${run.path}`);
|
|
49
|
+
if (options?.workspace) {
|
|
50
|
+
console.log(`Workspace: ${options.workspace}`);
|
|
51
|
+
}
|
|
52
|
+
console.log("");
|
|
53
|
+
await runResearchAgent(run.path, question, config, options?.workspace);
|
|
54
|
+
console.log(`\nRun complete: ${run.path}`);
|
|
55
|
+
});
|
|
56
|
+
program.command("init")
|
|
57
|
+
.description("initialize Scira with API keys and configuration")
|
|
58
|
+
.action(async () => {
|
|
59
|
+
await initCommand();
|
|
60
|
+
});
|
|
61
|
+
program.command("new")
|
|
62
|
+
.argument("<question>")
|
|
63
|
+
.description("create a new interactive research run")
|
|
64
|
+
.option("--no-shell", "create the run without opening the interactive shell")
|
|
65
|
+
.option("--tui", "open the Ink TUI after creating the run")
|
|
66
|
+
.option("--shell", "open the classic readline shell after creating the run")
|
|
67
|
+
.action(async (question, options) => {
|
|
68
|
+
const config = await loadConfig();
|
|
69
|
+
const run = await createRun(question, config);
|
|
70
|
+
if (options.tui) {
|
|
71
|
+
await openTui(run.path, config);
|
|
72
|
+
}
|
|
73
|
+
else if (options.shell) {
|
|
74
|
+
await openShell(run.path, config);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
console.log(`Created: ${run.path}`);
|
|
78
|
+
console.log(`Open TUI: scira resume --tui ${run.id}`);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
program.command("resume")
|
|
82
|
+
.argument("<run-id>")
|
|
83
|
+
.description("resume an existing run")
|
|
84
|
+
.option("--shell", "resume in the classic readline shell")
|
|
85
|
+
.option("--tui", "resume in the Ink TUI")
|
|
86
|
+
.action(async (runId, options) => {
|
|
87
|
+
const config = await loadConfig();
|
|
88
|
+
const runPath = await findRun(runId, config);
|
|
89
|
+
if (options.shell) {
|
|
90
|
+
await openShell(runPath, config);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
await openTui(runPath, config);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
program.command("list")
|
|
97
|
+
.description("list runs")
|
|
98
|
+
.action(async () => {
|
|
99
|
+
const config = await loadConfig();
|
|
100
|
+
console.table(await listRuns(config));
|
|
101
|
+
});
|
|
102
|
+
program.command("show")
|
|
103
|
+
.argument("<run-id>")
|
|
104
|
+
.description("show run status")
|
|
105
|
+
.action(async (runId) => {
|
|
106
|
+
const config = await loadConfig();
|
|
107
|
+
console.log(await summarizeRun(await findRun(runId, config)));
|
|
108
|
+
});
|
|
109
|
+
program.command("run")
|
|
110
|
+
.argument("<run-id>")
|
|
111
|
+
.description("run (or re-run) the research agent on an existing run")
|
|
112
|
+
.action(async (runId) => {
|
|
113
|
+
const config = await loadConfig();
|
|
114
|
+
requireLlmKeys(config);
|
|
115
|
+
const runPath = await findRun(runId, config);
|
|
116
|
+
const goal = (await summarizeRun(runPath)).goal;
|
|
117
|
+
await runResearchAgent(runPath, goal, config);
|
|
118
|
+
console.log(`\nRun complete: ${runPath}`);
|
|
119
|
+
});
|
|
120
|
+
program.command("verify")
|
|
121
|
+
.argument("<run-id>")
|
|
122
|
+
.description("show the verification report for a run's claims")
|
|
123
|
+
.action(async (runId) => {
|
|
124
|
+
const config = await loadConfig();
|
|
125
|
+
const runPath = await findRun(runId, config);
|
|
126
|
+
console.log(await verificationReport(runPath));
|
|
127
|
+
});
|
|
128
|
+
program.command("export")
|
|
129
|
+
.argument("<run-id>")
|
|
130
|
+
.option("--format <format>", "export format: md | json | csv", "md")
|
|
131
|
+
.option("--output <file>", "write to file instead of stdout")
|
|
132
|
+
.description("export run report (md, json, or csv)")
|
|
133
|
+
.action(async (runId, options) => {
|
|
134
|
+
const fmt = options.format.toLowerCase();
|
|
135
|
+
if (!["md", "json", "csv"].includes(fmt)) {
|
|
136
|
+
throw new Error(`Unknown format "${options.format}". Supported: md, json, csv.`);
|
|
137
|
+
}
|
|
138
|
+
const config = await loadConfig();
|
|
139
|
+
const runPath = await findRun(runId, config);
|
|
140
|
+
let output;
|
|
141
|
+
if (fmt === "md") {
|
|
142
|
+
output = await readFile(`${runPath}/report.md`, "utf8");
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
const { toJson, toCsv } = await import("../export/formatters.js");
|
|
146
|
+
const paths = getRunPaths(runPath);
|
|
147
|
+
const [sources, claims, goal] = await Promise.all([
|
|
148
|
+
readJsonl(paths.sources),
|
|
149
|
+
readJsonl(paths.claims),
|
|
150
|
+
readFile(paths.goal, "utf8").then((t) => t.replace(/^# Goal\s*/u, "").trim()),
|
|
151
|
+
]);
|
|
152
|
+
const bundle = { runId, goal, sources, claims };
|
|
153
|
+
output = fmt === "json" ? toJson(bundle) : toCsv(bundle);
|
|
154
|
+
}
|
|
155
|
+
if (options.output) {
|
|
156
|
+
const { writeFile, mkdir } = await import("node:fs/promises");
|
|
157
|
+
const { dirname } = await import("node:path");
|
|
158
|
+
await mkdir(dirname(options.output), { recursive: true });
|
|
159
|
+
await writeFile(options.output, output, "utf8");
|
|
160
|
+
console.log(`Exported to ${options.output}`);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
console.log(output);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
const mcp = program.command("mcp").description("manage MCP servers in .scira/config.json");
|
|
167
|
+
mcp.command("list")
|
|
168
|
+
.description("list configured MCP servers")
|
|
169
|
+
.action(async () => {
|
|
170
|
+
const config = await loadConfig();
|
|
171
|
+
const dt = config.mcp.chromeDevtools;
|
|
172
|
+
console.log(`chromeDevtools [stdio] ${dt.enabled ? "enabled" : "disabled"} ${[dt.command, ...dt.args].join(" ")}`);
|
|
173
|
+
for (const s of config.mcp.servers) {
|
|
174
|
+
const loc = s.transport === "stdio" ? [s.command, ...s.args].filter(Boolean).join(" ") : s.url ?? "";
|
|
175
|
+
const authLabel = s.authType && s.authType !== "none" ? ` auth:${s.authType}` : "";
|
|
176
|
+
const oauthStatus = s.authType === "oauth" ? (s.oauthAccessToken ? " [connected]" : " [not connected]") : "";
|
|
177
|
+
console.log(`${s.name} [${s.transport}]${authLabel}${oauthStatus} ${s.enabled ? "enabled" : "disabled"} ${loc}`);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
mcp.command("add")
|
|
181
|
+
.argument("<transport>", "stdio | sse | http")
|
|
182
|
+
.argument("<name>")
|
|
183
|
+
.argument("<target>")
|
|
184
|
+
.argument("[args...]")
|
|
185
|
+
.option("--bearer <token>", "bearer token for Authorization header")
|
|
186
|
+
.option("--header <name:value>", "custom header in name:value format")
|
|
187
|
+
.option("--oauth", "use OAuth PKCE flow (requires --oauth-client-id)")
|
|
188
|
+
.option("--oauth-client-id <id>", "OAuth client ID")
|
|
189
|
+
.option("--oauth-client-secret <secret>", "OAuth client secret (optional for PKCE)")
|
|
190
|
+
.option("--oauth-issuer <url>", "OAuth issuer URL for auto-discovery")
|
|
191
|
+
.option("--oauth-auth-url <url>", "OAuth authorization endpoint URL")
|
|
192
|
+
.option("--oauth-token-url <url>", "OAuth token endpoint URL")
|
|
193
|
+
.option("--oauth-scopes <scopes>", "OAuth scopes (space-separated)")
|
|
194
|
+
.description("add an MCP server")
|
|
195
|
+
.action(async (transport, name, target, args, opts) => {
|
|
196
|
+
if (!["stdio", "sse", "http"].includes(transport)) {
|
|
197
|
+
throw new Error("transport must be one of: stdio, sse, http");
|
|
198
|
+
}
|
|
199
|
+
const config = await loadConfig();
|
|
200
|
+
if (config.mcp.servers.some((s) => s.name === name)) {
|
|
201
|
+
throw new Error(`MCP server "${name}" already exists.`);
|
|
202
|
+
}
|
|
203
|
+
let authType = "none";
|
|
204
|
+
let bearerToken;
|
|
205
|
+
let headerName;
|
|
206
|
+
let headerValue;
|
|
207
|
+
if (opts.oauth || opts.oauthClientId) {
|
|
208
|
+
authType = "oauth";
|
|
209
|
+
}
|
|
210
|
+
else if (opts.bearer) {
|
|
211
|
+
authType = "bearer";
|
|
212
|
+
bearerToken = opts.bearer;
|
|
213
|
+
}
|
|
214
|
+
else if (opts.header) {
|
|
215
|
+
const colonIdx = opts.header.indexOf(":");
|
|
216
|
+
if (colonIdx === -1)
|
|
217
|
+
throw new Error("--header must be in name:value format");
|
|
218
|
+
authType = "header";
|
|
219
|
+
headerName = opts.header.slice(0, colonIdx).trim();
|
|
220
|
+
headerValue = opts.header.slice(colonIdx + 1).trim();
|
|
221
|
+
}
|
|
222
|
+
const base = {
|
|
223
|
+
name, toolPrefix: "", env: {}, enabled: true, authType,
|
|
224
|
+
bearerToken, headerName, headerValue,
|
|
225
|
+
oauthClientId: opts.oauthClientId,
|
|
226
|
+
oauthClientSecret: opts.oauthClientSecret,
|
|
227
|
+
oauthIssuerUrl: opts.oauthIssuer,
|
|
228
|
+
oauthAuthorizationUrl: opts.oauthAuthUrl,
|
|
229
|
+
oauthTokenUrl: opts.oauthTokenUrl,
|
|
230
|
+
oauthScopes: opts.oauthScopes,
|
|
231
|
+
};
|
|
232
|
+
const entry = transport === "stdio"
|
|
233
|
+
? { ...base, transport: "stdio", command: target, args }
|
|
234
|
+
: { ...base, transport: transport, url: target, args: [] };
|
|
235
|
+
const nextMcp = { ...config.mcp, servers: [...config.mcp.servers, entry] };
|
|
236
|
+
await saveGlobalMcpConfig(nextMcp);
|
|
237
|
+
console.log(`Added MCP server "${name}" to ~/.scira/config.json (auth: ${authType})`);
|
|
238
|
+
if (authType === "oauth") {
|
|
239
|
+
console.log(`Run: scira mcp oauth ${name} to authenticate`);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
mcp.command("oauth")
|
|
243
|
+
.argument("<name>", "name of the OAuth MCP server to authenticate")
|
|
244
|
+
.description("run OAuth PKCE flow for an MCP server")
|
|
245
|
+
.action(async (name) => {
|
|
246
|
+
const config = await loadConfig();
|
|
247
|
+
const srv = config.mcp.servers.find((s) => s.name === name);
|
|
248
|
+
if (!srv)
|
|
249
|
+
throw new Error(`MCP server "${name}" not found. Add it first with: scira mcp add`);
|
|
250
|
+
if (srv.authType !== "oauth")
|
|
251
|
+
throw new Error(`"${name}" is not an OAuth server (authType: ${srv.authType})`);
|
|
252
|
+
await runOAuthFlow(srv, config);
|
|
253
|
+
});
|
|
254
|
+
mcp.command("enable")
|
|
255
|
+
.argument("<name>")
|
|
256
|
+
.description("enable an MCP server")
|
|
257
|
+
.action(async (name) => {
|
|
258
|
+
const config = await loadConfig();
|
|
259
|
+
const nextMcp = name === "chromeDevtools" || name === "devtools"
|
|
260
|
+
? { ...config.mcp, chromeDevtools: { ...config.mcp.chromeDevtools, enabled: true } }
|
|
261
|
+
: { ...config.mcp, servers: config.mcp.servers.map((s) => s.name === name ? { ...s, enabled: true } : s) };
|
|
262
|
+
await saveGlobalMcpConfig(nextMcp);
|
|
263
|
+
console.log(`Enabled MCP server "${name}"`);
|
|
264
|
+
});
|
|
265
|
+
mcp.command("disable")
|
|
266
|
+
.argument("<name>")
|
|
267
|
+
.description("disable an MCP server")
|
|
268
|
+
.action(async (name) => {
|
|
269
|
+
const config = await loadConfig();
|
|
270
|
+
const nextMcp = name === "chromeDevtools" || name === "devtools"
|
|
271
|
+
? { ...config.mcp, chromeDevtools: { ...config.mcp.chromeDevtools, enabled: false } }
|
|
272
|
+
: { ...config.mcp, servers: config.mcp.servers.map((s) => s.name === name ? { ...s, enabled: false } : s) };
|
|
273
|
+
await saveGlobalMcpConfig(nextMcp);
|
|
274
|
+
console.log(`Disabled MCP server "${name}"`);
|
|
275
|
+
});
|
|
276
|
+
mcp.command("remove")
|
|
277
|
+
.argument("<name>")
|
|
278
|
+
.description("remove an MCP server from config")
|
|
279
|
+
.action(async (name) => {
|
|
280
|
+
const config = await loadConfig();
|
|
281
|
+
if (!config.mcp.servers.some((s) => s.name === name)) {
|
|
282
|
+
throw new Error(`MCP server "${name}" not found.`);
|
|
283
|
+
}
|
|
284
|
+
const nextMcp = { ...config.mcp, servers: config.mcp.servers.filter((s) => s.name !== name) };
|
|
285
|
+
await saveGlobalMcpConfig(nextMcp);
|
|
286
|
+
console.log(`Removed MCP server "${name}" from ~/.scira/config.json`);
|
|
287
|
+
});
|
|
288
|
+
program.command("watch")
|
|
289
|
+
.argument("<goal>", "research goal to monitor, e.g. \"AI search market\"")
|
|
290
|
+
.option("--daily", "run once per day (default)")
|
|
291
|
+
.option("--hourly", "run once per hour")
|
|
292
|
+
.option("--weekly", "run once per week")
|
|
293
|
+
.option("--interval <ms>", "custom interval in milliseconds")
|
|
294
|
+
.option("--runs <n>", "stop after N runs (default: run forever)", (v) => parseInt(v, 10))
|
|
295
|
+
.description("monitor a topic by running research on a schedule and diffing reports")
|
|
296
|
+
.action(async (goal, options) => {
|
|
297
|
+
const config = await loadConfig();
|
|
298
|
+
const INTERVALS = {
|
|
299
|
+
hourly: 60 * 60 * 1000,
|
|
300
|
+
daily: 24 * 60 * 60 * 1000,
|
|
301
|
+
weekly: 7 * 24 * 60 * 60 * 1000,
|
|
302
|
+
};
|
|
303
|
+
const intervalMs = options.interval
|
|
304
|
+
? parseInt(options.interval, 10)
|
|
305
|
+
: options.hourly ? INTERVALS.hourly
|
|
306
|
+
: options.weekly ? INTERVALS.weekly
|
|
307
|
+
: INTERVALS.daily;
|
|
308
|
+
if (Number.isNaN(intervalMs) || intervalMs < 1000) {
|
|
309
|
+
throw new Error("Interval must be at least 1000 ms.");
|
|
310
|
+
}
|
|
311
|
+
const { watchLoop } = await import("../watch/runner.js");
|
|
312
|
+
const controller = new AbortController();
|
|
313
|
+
process.on("SIGINT", () => { console.log("\nStopping watch…"); controller.abort(); });
|
|
314
|
+
process.on("SIGTERM", () => { controller.abort(); });
|
|
315
|
+
console.log(`Watching: "${goal}"`);
|
|
316
|
+
console.log(`Interval: ${intervalMs / 1000}s${options.runs ? ` · max ${options.runs} runs` : ""}`);
|
|
317
|
+
console.log("Press Ctrl-C to stop.\n");
|
|
318
|
+
await watchLoop({
|
|
319
|
+
goal, intervalMs, maxRuns: options.runs, config,
|
|
320
|
+
onRunStart: (runPath, tick) => { console.log(`\n[tick ${tick + 1}] Starting run → ${runPath}`); },
|
|
321
|
+
onRunComplete: (runPath, diffText, tick) => { console.log(`[tick ${tick + 1}] Done. Diff:\n${diffText}`); },
|
|
322
|
+
onError: (err, tick) => { console.error(`[tick ${tick + 1}] Error: ${err.message}`); },
|
|
323
|
+
}, controller.signal);
|
|
324
|
+
console.log("Watch finished.");
|
|
325
|
+
});
|
|
326
|
+
program.command("models")
|
|
327
|
+
.option("--provider <provider>", "gateway only: filter by model prefix such as anthropic, openai, or google")
|
|
328
|
+
.description("list models for the configured LLM provider")
|
|
329
|
+
.action(async (options) => {
|
|
330
|
+
const config = await loadConfig();
|
|
331
|
+
const models = config.llmProvider === "gateway" && options.provider
|
|
332
|
+
? await listGatewayModels(options.provider)
|
|
333
|
+
: await listModels(config);
|
|
334
|
+
for (const model of models) {
|
|
335
|
+
console.log(model.id);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
program.command("config")
|
|
339
|
+
.description("print resolved config")
|
|
340
|
+
.action(async () => {
|
|
341
|
+
console.log(JSON.stringify(await loadConfig(), null, 2));
|
|
342
|
+
});
|
|
343
|
+
program.command("doctor")
|
|
344
|
+
.description("check local setup")
|
|
345
|
+
.action(async () => {
|
|
346
|
+
const config = await loadConfig();
|
|
347
|
+
const nodeCheck = checkNodeVersion(20);
|
|
348
|
+
const nodeStatus = nodeCheck.ok ? "ok" : "fail";
|
|
349
|
+
console.log(`Node: ${process.version} (${nodeStatus}, requires >=${nodeCheck.required})`);
|
|
350
|
+
console.log(`LLM provider: ${config.llmProvider}`);
|
|
351
|
+
console.log(`Model: ${config.model}`);
|
|
352
|
+
console.log(`Search provider: ${config.search.provider}`);
|
|
353
|
+
console.log("");
|
|
354
|
+
console.log("Environment:");
|
|
355
|
+
const checks = detectEnv(config.search.provider, config.llmProvider);
|
|
356
|
+
for (const check of checks) {
|
|
357
|
+
const status = check.present ? "set " : "missing";
|
|
358
|
+
const tag = check.required ? " (required)" : "";
|
|
359
|
+
console.log(` ${status} ${check.name}${tag} - ${check.purpose}`);
|
|
360
|
+
}
|
|
361
|
+
console.log("");
|
|
362
|
+
console.log("MCP servers:");
|
|
363
|
+
const dt = config.mcp.chromeDevtools;
|
|
364
|
+
const userServers = config.mcp.servers;
|
|
365
|
+
const anyEnabled = dt.enabled || userServers.some((s) => s.enabled);
|
|
366
|
+
if (!anyEnabled) {
|
|
367
|
+
console.log(" none enabled");
|
|
368
|
+
console.log(" Tip: add entries to mcp.servers in config, or set mcp.chromeDevtools.enabled=true");
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
if (dt.enabled) {
|
|
372
|
+
console.log(` chromeDevtools [stdio] ${[dt.command, ...dt.args].join(" ")}`);
|
|
373
|
+
const ok = await commandResolves(dt.command);
|
|
374
|
+
console.log(` ${ok ? "ok " : "missing"} executable "${dt.command}" on PATH`);
|
|
375
|
+
}
|
|
376
|
+
for (const srv of userServers) {
|
|
377
|
+
const tag = srv.enabled ? "" : " (disabled)";
|
|
378
|
+
if (srv.transport === "stdio") {
|
|
379
|
+
console.log(` ${srv.name} [stdio]${tag} ${[srv.command, ...(srv.args ?? [])].join(" ")}`);
|
|
380
|
+
if (srv.command) {
|
|
381
|
+
const ok = await commandResolves(srv.command);
|
|
382
|
+
console.log(` ${ok ? "ok " : "missing"} executable "${srv.command}" on PATH`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
console.log(` ${srv.name} [${srv.transport}]${tag} ${srv.url ?? "(no url)"}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
console.log(" … attempting live connection");
|
|
390
|
+
const started = Date.now();
|
|
391
|
+
const bridge = await createMcpBridge(config);
|
|
392
|
+
const elapsedMs = Date.now() - started;
|
|
393
|
+
try {
|
|
394
|
+
if (bridge.toolNames.length === 0) {
|
|
395
|
+
console.log(` fail no MCP tools loaded after ${elapsedMs}ms (see stderr above)`);
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
console.log(` ok connected in ${elapsedMs}ms, ${bridge.toolNames.length} tool(s):`);
|
|
399
|
+
for (const name of bridge.toolNames)
|
|
400
|
+
console.log(` - ${name}`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
finally {
|
|
404
|
+
await bridge.close();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const missingRequired = checks.filter((c) => c.required && !c.present);
|
|
408
|
+
const blockers = [];
|
|
409
|
+
if (!nodeCheck.ok)
|
|
410
|
+
blockers.push(`upgrade Node to >=${nodeCheck.required}`);
|
|
411
|
+
if (missingRequired.length > 0)
|
|
412
|
+
blockers.push(`set ${missingRequired.map((c) => c.name).join(", ")} in ~/.scira/.env`);
|
|
413
|
+
console.log("");
|
|
414
|
+
if (blockers.length > 0) {
|
|
415
|
+
console.log(`Action needed: ${blockers.join("; ")} to enable research runs.`);
|
|
416
|
+
console.log(` Tip: cp .env.example ~/.scira/.env then fill in your keys.`);
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
console.log("All required credentials present. Ready to run.");
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
function checkNodeVersion(required) {
|
|
423
|
+
const m = /^v(\d+)/u.exec(process.version);
|
|
424
|
+
const current = m ? Number(m[1]) : 0;
|
|
425
|
+
return { ok: current >= required, required, current };
|
|
426
|
+
}
|
|
427
|
+
async function commandResolves(command) {
|
|
428
|
+
const { exec } = await import("node:child_process");
|
|
429
|
+
const { promisify } = await import("node:util");
|
|
430
|
+
const which = process.platform === "win32" ? "where" : "command -v";
|
|
431
|
+
try {
|
|
432
|
+
await promisify(exec)(`${which} ${command}`);
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
await program.parseAsync(process.argv);
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
444
|
+
process.exitCode = 1;
|
|
445
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { getRunPaths, summarizeRun, verificationReport } from "../../storage/run-store.js";
|
|
5
|
+
import { runResearchAgent } from "../../agent/research-agent.js";
|
|
6
|
+
import { readJsonl } from "../../storage/jsonl.js";
|
|
7
|
+
export async function openShell(runPath, config) {
|
|
8
|
+
const rl = createInterface({ input, output });
|
|
9
|
+
try {
|
|
10
|
+
let active = true;
|
|
11
|
+
while (active) {
|
|
12
|
+
const state = await summarizeRun(runPath);
|
|
13
|
+
const line = await rl.question(`scira ${state.id} sources:${state.sourceCount} report:${state.reportDirty ? "dirty" : "clean"} > `);
|
|
14
|
+
const [command, ...args] = line.trim().split(/\s+/u);
|
|
15
|
+
switch (command) {
|
|
16
|
+
case "/help":
|
|
17
|
+
console.log("/status /plan /run /sources /claims /why <claim-id> /verify /report /handoff /close");
|
|
18
|
+
break;
|
|
19
|
+
case "/status":
|
|
20
|
+
console.log(await renderStatus(runPath));
|
|
21
|
+
break;
|
|
22
|
+
case "/plan":
|
|
23
|
+
console.log(await readFile(getRunPaths(runPath).plan, "utf8"));
|
|
24
|
+
break;
|
|
25
|
+
case "/run":
|
|
26
|
+
await runResearchAgent(runPath, state.goal, config);
|
|
27
|
+
break;
|
|
28
|
+
case "/sources":
|
|
29
|
+
console.table(await readJsonl(getRunPaths(runPath).sources));
|
|
30
|
+
break;
|
|
31
|
+
case "/claims":
|
|
32
|
+
console.table(await readJsonl(getRunPaths(runPath).claims));
|
|
33
|
+
break;
|
|
34
|
+
case "/why":
|
|
35
|
+
await explainClaim(runPath, args[0]);
|
|
36
|
+
break;
|
|
37
|
+
case "/verify":
|
|
38
|
+
console.log(await verificationReport(runPath));
|
|
39
|
+
break;
|
|
40
|
+
case "/report":
|
|
41
|
+
console.log(await readFile(getRunPaths(runPath).report, "utf8").catch(() => "No report.md yet."));
|
|
42
|
+
break;
|
|
43
|
+
case "/handoff":
|
|
44
|
+
console.log(await readFile(getRunPaths(runPath).handoff, "utf8"));
|
|
45
|
+
break;
|
|
46
|
+
case "/close":
|
|
47
|
+
case "exit":
|
|
48
|
+
active = false;
|
|
49
|
+
break;
|
|
50
|
+
case "":
|
|
51
|
+
break;
|
|
52
|
+
default:
|
|
53
|
+
console.log("Unknown command. Run /help.");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
rl.close();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function renderStatus(runPath) {
|
|
62
|
+
const state = await summarizeRun(runPath);
|
|
63
|
+
return `Goal:\n ${state.goal}\n\nProgress:\n Sources collected: ${state.sourceCount}\n Report status: ${state.reportDirty ? "dirty" : "clean"}\n\nNext:\n /run to research, /report to view results`;
|
|
64
|
+
}
|
|
65
|
+
async function explainClaim(runPath, claimId) {
|
|
66
|
+
if (!claimId) {
|
|
67
|
+
console.log("Usage: /why <claim-id>");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const claim = (await readJsonl(getRunPaths(runPath).claims)).find((item) => item.id === claimId);
|
|
71
|
+
if (!claim) {
|
|
72
|
+
console.log(`Claim not found: ${claimId}`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
console.log(`${claim.text}\n\nSources: ${claim.sourceIds.join(", ") || "none"}\nConfidence: ${claim.confidence}\nStatus: ${claim.status}\nReason: ${claim.reason}`);
|
|
76
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render } from "ink";
|
|
3
|
+
import { SciraApp } from "../../ui/ink/SciraApp.js";
|
|
4
|
+
export async function openTuiHome(config) {
|
|
5
|
+
const instance = render(_jsx(SciraApp, { config: config }), { alternateScreen: true, maxFps: 20 });
|
|
6
|
+
await instance.waitUntilExit();
|
|
7
|
+
}
|
|
8
|
+
export async function openTui(runPath, config) {
|
|
9
|
+
const instance = render(_jsx(SciraApp, { runPath: runPath, config: config }), { alternateScreen: true, maxFps: 20 });
|
|
10
|
+
await instance.waitUntilExit();
|
|
11
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
export const MANAGED_ENV_KEYS = [
|
|
6
|
+
"AI_GATEWAY_API_KEY",
|
|
7
|
+
"XAI_API_KEY",
|
|
8
|
+
"CLOUDFLARE_ACCOUNT_ID",
|
|
9
|
+
"CLOUDFLARE_API_TOKEN",
|
|
10
|
+
"HF_API_KEY",
|
|
11
|
+
"PARALLEL_API_KEY",
|
|
12
|
+
"EXA_API_KEY",
|
|
13
|
+
"FIRECRAWL_API_KEY"
|
|
14
|
+
];
|
|
15
|
+
export function isManagedEnvKey(name) {
|
|
16
|
+
return MANAGED_ENV_KEYS.includes(name);
|
|
17
|
+
}
|
|
18
|
+
/** Path to the global env file that the CLI loads on startup. */
|
|
19
|
+
export const globalEnvPath = join(homedir(), ".scira", ".env");
|
|
20
|
+
/**
|
|
21
|
+
* Persist an environment key to ~/.scira/.env and apply it to the current
|
|
22
|
+
* process so it takes effect immediately without a restart.
|
|
23
|
+
*/
|
|
24
|
+
export async function setEnvKey(name, value) {
|
|
25
|
+
const path = globalEnvPath;
|
|
26
|
+
await mkdir(join(homedir(), ".scira"), { recursive: true });
|
|
27
|
+
let content = "";
|
|
28
|
+
try {
|
|
29
|
+
content = await readFile(path, "utf8");
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
content = "";
|
|
33
|
+
}
|
|
34
|
+
const lines = content.length ? content.split("\n") : [];
|
|
35
|
+
const matchIndex = lines.findIndex((line) => line.replace(/^export\s+/u, "").startsWith(`${name}=`));
|
|
36
|
+
const entry = `${name}=${value}`;
|
|
37
|
+
if (matchIndex >= 0) {
|
|
38
|
+
lines[matchIndex] = entry;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
if (lines.length && lines[lines.length - 1] === "")
|
|
42
|
+
lines.pop();
|
|
43
|
+
lines.push(entry);
|
|
44
|
+
}
|
|
45
|
+
await writeFile(path, `${lines.join("\n")}\n`);
|
|
46
|
+
process.env[name] = value;
|
|
47
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { SciraConfigSchema } from "../types/index.js";
|
|
5
|
+
export const globalConfigPath = join(homedir(), ".scira", "config.json");
|
|
6
|
+
export async function loadConfig(projectRoot = process.cwd()) {
|
|
7
|
+
const projectConfigPath = join(projectRoot, ".scira", "config.json");
|
|
8
|
+
const globalConfig = await readConfigFile(globalConfigPath);
|
|
9
|
+
const projectConfig = await readConfigFile(projectConfigPath);
|
|
10
|
+
const merged = { ...globalConfig, ...projectConfig };
|
|
11
|
+
// Deep-merge nested objects so project keys win without clobbering sibling keys.
|
|
12
|
+
for (const key of ["mcp", "search", "lastModels"]) {
|
|
13
|
+
if (globalConfig[key] && projectConfig[key] && typeof globalConfig[key] === "object" && typeof projectConfig[key] === "object") {
|
|
14
|
+
merged[key] = { ...globalConfig[key], ...projectConfig[key] };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
// Prefer global config for llmProvider and model to persist user selections across projects
|
|
18
|
+
if (globalConfig.llmProvider && !projectConfig.llmProvider) {
|
|
19
|
+
merged.llmProvider = globalConfig.llmProvider;
|
|
20
|
+
}
|
|
21
|
+
if (globalConfig.model && !projectConfig.model) {
|
|
22
|
+
merged.model = globalConfig.model;
|
|
23
|
+
}
|
|
24
|
+
return SciraConfigSchema.parse(merged);
|
|
25
|
+
}
|
|
26
|
+
export async function saveGlobalConfig(config) {
|
|
27
|
+
await mkdir(dirname(globalConfigPath), { recursive: true });
|
|
28
|
+
await writeFile(globalConfigPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
29
|
+
}
|
|
30
|
+
export async function saveGlobalMcpConfig(config) {
|
|
31
|
+
const globalConfig = await readConfigFile(globalConfigPath);
|
|
32
|
+
const next = { ...globalConfig, mcp: config };
|
|
33
|
+
await mkdir(dirname(globalConfigPath), { recursive: true });
|
|
34
|
+
await writeFile(globalConfigPath, `${JSON.stringify(next, null, 2)}\n`);
|
|
35
|
+
}
|
|
36
|
+
export async function saveProjectConfig(config, projectRoot = process.cwd()) {
|
|
37
|
+
const projectConfigPath = join(projectRoot, ".scira", "config.json");
|
|
38
|
+
await mkdir(dirname(projectConfigPath), { recursive: true });
|
|
39
|
+
await writeFile(projectConfigPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
40
|
+
}
|
|
41
|
+
export async function saveProjectMcpConfig(config, projectRoot = process.cwd()) {
|
|
42
|
+
const projectConfigPath = join(projectRoot, ".scira", "config.json");
|
|
43
|
+
const projectConfig = await readConfigFile(projectConfigPath);
|
|
44
|
+
const next = { ...projectConfig, mcp: config };
|
|
45
|
+
await mkdir(dirname(projectConfigPath), { recursive: true });
|
|
46
|
+
await writeFile(projectConfigPath, `${JSON.stringify(next, null, 2)}\n`);
|
|
47
|
+
}
|
|
48
|
+
async function readConfigFile(path) {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error.code === "ENOENT") {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** JSON export: structured object with all run data. */
|
|
2
|
+
export function toJson(bundle) {
|
|
3
|
+
return JSON.stringify({
|
|
4
|
+
runId: bundle.runId,
|
|
5
|
+
goal: bundle.goal,
|
|
6
|
+
exportedAt: new Date().toISOString(),
|
|
7
|
+
sources: bundle.sources,
|
|
8
|
+
claims: bundle.claims,
|
|
9
|
+
}, null, 2);
|
|
10
|
+
}
|
|
11
|
+
/** Escape a CSV cell value per RFC 4180. */
|
|
12
|
+
function csvCell(value) {
|
|
13
|
+
const s = String(value ?? "");
|
|
14
|
+
if (s.includes(",") || s.includes('"') || s.includes("\n") || s.includes("\r")) {
|
|
15
|
+
return `"${s.replace(/"/gu, '""')}"`;
|
|
16
|
+
}
|
|
17
|
+
return s;
|
|
18
|
+
}
|
|
19
|
+
/** CSV export: sources section then claims section, separated by a blank line. */
|
|
20
|
+
export function toCsv(bundle) {
|
|
21
|
+
const sections = [];
|
|
22
|
+
if (bundle.sources.length > 0) {
|
|
23
|
+
const header = ["id", "title", "url", "kind", "summary", "createdAt"].join(",");
|
|
24
|
+
const rows = bundle.sources.map((s) => [s.id, s.title, s.url, s.kind, s.summary, s.createdAt].map(csvCell).join(","));
|
|
25
|
+
sections.push([header, ...rows].join("\n"));
|
|
26
|
+
}
|
|
27
|
+
if (bundle.claims.length > 0) {
|
|
28
|
+
const header = ["id", "text", "confidence", "status", "sourceIds", "reason", "createdAt"].join(",");
|
|
29
|
+
const rows = bundle.claims.map((c) => [c.id, c.text, c.confidence, c.status, c.sourceIds.join(";"), c.reason, c.createdAt]
|
|
30
|
+
.map(csvCell).join(","));
|
|
31
|
+
sections.push([header, ...rows].join("\n"));
|
|
32
|
+
}
|
|
33
|
+
if (sections.length === 0) {
|
|
34
|
+
return "# No sources or claims recorded for this run.\n";
|
|
35
|
+
}
|
|
36
|
+
return sections.join("\n\n");
|
|
37
|
+
}
|