@scira/cli 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/agent/harness-agent.js +216 -0
  2. package/dist/agent/{research-agent.js → main-agent.js} +30 -10
  3. package/dist/cli/commands/init.js +7 -5
  4. package/dist/cli/index.js +75 -14
  5. package/dist/cli/shell/shell.js +4 -5
  6. package/dist/cli/shell/tui.js +7 -4
  7. package/dist/config/env-guide.js +24 -0
  8. package/dist/config/env-store.js +5 -3
  9. package/dist/config/load-config.js +9 -14
  10. package/dist/providers/harness/local-sandbox.js +143 -0
  11. package/dist/providers/llm/gateway.js +5 -2
  12. package/dist/providers/llm/models.js +18 -4
  13. package/dist/providers/llm/readiness.js +5 -1
  14. package/dist/providers/llm/registry.js +24 -3
  15. package/dist/storage/jsonl.js +2 -2
  16. package/dist/storage/run-store.js +22 -15
  17. package/dist/tools/agent-tools.js +7 -7
  18. package/dist/tools/background-tasks.js +4 -5
  19. package/dist/tools/mcp-oauth.js +29 -25
  20. package/dist/tools/open-url.js +1 -2
  21. package/dist/tools/todos.js +3 -3
  22. package/dist/tools/workspace.js +15 -0
  23. package/dist/types/index.js +13 -1
  24. package/dist/ui/ink/SciraApp.js +14 -10
  25. package/dist/ui/ink/components/home-screen.js +2 -2
  26. package/dist/ui/ink/components/overlays.js +78 -17
  27. package/dist/ui/ink/constants.js +26 -7
  28. package/dist/ui/ink/hooks/use-agent-turn.js +14 -5
  29. package/dist/ui/ink/hooks/use-feed-lines.js +31 -6
  30. package/dist/ui/ink/hooks/use-keyboard.js +28 -5
  31. package/dist/ui/ink/hooks/use-session.js +7 -5
  32. package/dist/ui/ink/hooks/use-settings.js +20 -0
  33. package/dist/ui/ink/hooks/use-submit.js +15 -8
  34. package/dist/ui/ink/lib/file-mentions.js +1 -2
  35. package/dist/ui/ink/lib/tool-result.js +219 -4
  36. package/dist/ui/ink/lib/utils.js +54 -28
  37. package/dist/ui/ink/theme.js +5 -10
  38. package/dist/utils/update-check.js +63 -0
  39. package/dist/watch/runner.js +2 -2
  40. package/package.json +13 -11
  41. package/dist/agent/background-tasks.js +0 -173
  42. package/dist/agent/todos.js +0 -140
  43. package/dist/agent/tools.js +0 -432
  44. package/dist/agent/tools.test.js +0 -60
  45. package/dist/agent/workspace.js +0 -85
  46. package/dist/config/env-guide.test.js +0 -18
  47. package/dist/config/env-store.test.js +0 -60
  48. package/dist/storage/jsonl.test.js +0 -38
  49. package/dist/storage/run-store.test.js +0 -65
  50. package/dist/tools/bash-policy.test.js +0 -38
  51. package/dist/tools/search-web.test.js +0 -24
  52. package/dist/tools/workspace.test.js +0 -75
  53. package/dist/types/schema.test.js +0 -61
  54. package/dist/ui/ink/hooks/use-feed-lines.test.js +0 -16
  55. package/dist/ui/ink/lib/tool-result.test.js +0 -60
  56. package/dist/ui/ink/lib/utils.test.js +0 -48
  57. package/dist/ui/ink/session-manager.test.js +0 -31
  58. package/dist/ui/ink/terminal-probe.test.js +0 -12
  59. package/dist/ui/ink/theme.test.js +0 -68
@@ -0,0 +1,63 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ const PKG = "@scira/cli";
5
+ const CACHE_FILE = join(homedir(), ".scira", "update-check.json");
6
+ const THROTTLE_MS = 24 * 60 * 60 * 1000; // check npm at most once a day
7
+ /** `true` when `latest` is a higher semver than `current` (pre-release suffixes ignored). */
8
+ function isNewer(latest, current) {
9
+ const parse = (v) => v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
10
+ const a = parse(latest), b = parse(current);
11
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
12
+ const x = a[i] ?? 0, y = b[i] ?? 0;
13
+ if (x !== y)
14
+ return x > y;
15
+ }
16
+ return false;
17
+ }
18
+ async function fetchLatest() {
19
+ try {
20
+ const res = await fetch(`https://registry.npmjs.org/${PKG}/latest`, {
21
+ headers: { Accept: "application/json" },
22
+ signal: AbortSignal.timeout(3000),
23
+ });
24
+ if (!res.ok)
25
+ return null;
26
+ const data = (await res.json());
27
+ return data.version ?? null;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ /**
34
+ * Returns the available update (or null), checking npm at most once per day.
35
+ * Network/parse failures resolve to null and are swallowed — this never throws
36
+ * and never blocks for more than ~3s (and only that once a day).
37
+ */
38
+ export async function checkForUpdate(current) {
39
+ let cache = null;
40
+ try {
41
+ cache = (await Bun.file(CACHE_FILE).json());
42
+ }
43
+ catch { /* no/invalid cache */ }
44
+ const now = Date.now();
45
+ let latest = cache?.latest ?? null;
46
+ if (!cache || now - cache.checkedAt > THROTTLE_MS) {
47
+ // Keep the previously-known version on a failed fetch — a transient network
48
+ // error shouldn't discard an update we already knew about (and then suppress
49
+ // it for the rest of the throttle window).
50
+ latest = (await fetchLatest()) ?? latest;
51
+ // Persist (even on failure) so we don't re-check on every command today.
52
+ try {
53
+ await mkdir(join(homedir(), ".scira"), { recursive: true });
54
+ await Bun.write(CACHE_FILE, JSON.stringify({ checkedAt: now, latest }));
55
+ }
56
+ catch { /* best-effort */ }
57
+ }
58
+ return latest && isNewer(latest, current) ? { current, latest } : null;
59
+ }
60
+ /** One-line, human-facing update message. */
61
+ export function formatUpdateNotice(u) {
62
+ return `Update available: ${u.current} → ${u.latest} · run "bun add -g ${PKG}"`;
63
+ }
@@ -1,7 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { diffLines } from "diff";
3
3
  import { createRun, listRuns, getRunPaths } from "../storage/run-store.js";
4
- import { runResearchAgent } from "../agent/research-agent.js";
4
+ import { runResearchAgent } from "../agent/main-agent.js";
5
5
  /** Compare two report.md texts and return a human-readable diff summary. */
6
6
  export function diffReports(prev, next) {
7
7
  const changes = diffLines(prev, next);
@@ -47,7 +47,7 @@ export async function watchLoop(opts, signal) {
47
47
  opts.onRunStart?.(runPath, tick);
48
48
  try {
49
49
  await runResearchAgent(runPath, goal, config);
50
- const nextReport = await readFile(getRunPaths(runPath).report, "utf8").catch(() => "");
50
+ const nextReport = await Bun.file(getRunPaths(runPath).report).text().catch(() => "");
51
51
  const diffText = diffReports(prevReport, nextReport);
52
52
  opts.onRunComplete?.(runPath, diffText, tick);
53
53
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scira/cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Scira — terminal-native AI research agent with grounded sources, verified claims, and local run storage.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -35,22 +35,26 @@
35
35
  "dev": "bun src/cli/index.ts",
36
36
  "docs:dev": "bun run --cwd docs dev",
37
37
  "docs:build": "NODE_ENV=production bun run --cwd docs build",
38
- "test": "vitest run",
39
- "test:watch": "vitest"
38
+ "test": "bun test src",
39
+ "test:watch": "bun test --watch src"
40
40
  },
41
41
  "dependencies": {
42
- "@ai-sdk/mcp": "^1.0.49",
42
+ "@ai-sdk/harness": "^1.0.0-canary.9",
43
+ "@ai-sdk/harness-claude-code": "^1.0.0-canary.5",
44
+ "@ai-sdk/harness-codex": "^1.0.0-canary.5",
45
+ "@ai-sdk/mcp": "^1.0.50",
43
46
  "@ai-sdk/openai-compatible": "^2.0.50",
44
47
  "@ai-sdk/xai": "^3.0.95",
45
48
  "@clack/prompts": "^1.5.1",
46
- "@mendable/firecrawl-js": "^4.25.3",
49
+ "@mendable/firecrawl-js": "^4.25.4",
47
50
  "@modelcontextprotocol/sdk": "^1.29.0",
48
51
  "@mozilla/readability": "^0.6.0",
49
- "ai": "^6.0.203",
52
+ "ai": "^6.0.204",
53
+ "bun": "^1.3.14",
50
54
  "diff": "^9.0.0",
51
55
  "exa-js": "^2.13.0",
52
56
  "files-sdk": "^1.8.0",
53
- "ink": "^7.0.5",
57
+ "ink": "^7.0.6",
54
58
  "ink-link": "^5.0.0",
55
59
  "jsdom": "^29.1.1",
56
60
  "parallel-web": "^1.1.0",
@@ -62,14 +66,12 @@
62
66
  "zod": "^4.4.3"
63
67
  },
64
68
  "devDependencies": {
65
- "bun-types": "^1.3.14",
69
+ "@types/bun": "^1.3.14",
66
70
  "@types/jsdom": "^28.0.3",
67
71
  "@types/node": "^25.9.3",
68
72
  "@types/react": "^19.2.17",
69
- "@vitest/coverage-v8": "^4.1.8",
70
73
  "tsx": "^4.22.4",
71
- "typescript": "^6.0.3",
72
- "vitest": "^4.1.8"
74
+ "typescript": "^6.0.3"
73
75
  },
74
76
  "engines": {
75
77
  "bun": ">=1.2.0"
@@ -1,173 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { readFile, writeFile, mkdir } from "node:fs/promises";
3
- import { dirname, join } from "node:path";
4
- const MAX_OUTPUT_LINES = 500;
5
- const MAX_TAIL_CHARS = 4000;
6
- function nextTaskId(existing) {
7
- const nums = existing
8
- .map((t) => /^task_(\d+)$/u.exec(t.id)?.[1])
9
- .filter((n) => Boolean(n))
10
- .map((n) => Number.parseInt(n, 10));
11
- const next = nums.length > 0 ? Math.max(...nums) + 1 : 1;
12
- return `task_${String(next).padStart(3, "0")}`;
13
- }
14
- function tailText(lines, maxChars = MAX_TAIL_CHARS) {
15
- const joined = lines.join("\n");
16
- if (joined.length <= maxChars)
17
- return joined;
18
- return `…[truncated]\n${joined.slice(-maxChars)}`;
19
- }
20
- export class BackgroundTaskManager {
21
- persistPath;
22
- defaultCwd;
23
- runtime = new Map();
24
- records = [];
25
- loaded = false;
26
- constructor(persistPath, defaultCwd) {
27
- this.persistPath = persistPath;
28
- this.defaultCwd = defaultCwd;
29
- }
30
- async ensureLoaded() {
31
- if (this.loaded)
32
- return;
33
- this.loaded = true;
34
- try {
35
- const raw = await readFile(this.persistPath, "utf8");
36
- const parsed = JSON.parse(raw);
37
- if (Array.isArray(parsed)) {
38
- this.records = parsed.filter((t) => typeof t === "object" && t !== null && typeof t.id === "string");
39
- }
40
- }
41
- catch {
42
- this.records = [];
43
- }
44
- }
45
- async persist() {
46
- await mkdir(dirname(this.persistPath), { recursive: true });
47
- await writeFile(this.persistPath, JSON.stringify(this.records, null, 2) + "\n");
48
- }
49
- syncRecord(task) {
50
- const idx = this.records.findIndex((r) => r.id === task.record.id);
51
- task.record.outputTail = tailText(task.output);
52
- if (idx === -1)
53
- this.records.push({ ...task.record });
54
- else
55
- this.records[idx] = { ...task.record };
56
- }
57
- async spawn(command, cwd) {
58
- await this.ensureLoaded();
59
- const id = nextTaskId(this.records);
60
- const workDir = cwd ?? this.defaultCwd;
61
- const proc = spawn(command, {
62
- cwd: workDir,
63
- shell: "/bin/bash",
64
- env: process.env,
65
- detached: false,
66
- stdio: ["ignore", "pipe", "pipe"]
67
- });
68
- const record = {
69
- id,
70
- command,
71
- cwd: workDir,
72
- pid: proc.pid ?? 0,
73
- startedAt: new Date().toISOString(),
74
- status: "running",
75
- exitCode: null,
76
- outputTail: ""
77
- };
78
- const output = [];
79
- const append = (chunk) => {
80
- const text = chunk.toString();
81
- for (const line of text.split("\n")) {
82
- if (line.length > 0)
83
- output.push(line);
84
- }
85
- while (output.length > MAX_OUTPUT_LINES)
86
- output.shift();
87
- const rt = this.runtime.get(id);
88
- if (rt) {
89
- rt.output = output;
90
- rt.record.outputTail = tailText(output);
91
- }
92
- };
93
- proc.stdout?.on("data", append);
94
- proc.stderr?.on("data", append);
95
- const runtime = { record, proc, output };
96
- this.runtime.set(id, runtime);
97
- this.records.push({ ...record });
98
- await this.persist();
99
- proc.on("close", (code) => {
100
- record.status = "exited";
101
- record.exitCode = code;
102
- record.outputTail = tailText(output);
103
- this.syncRecord(runtime);
104
- void this.persist();
105
- this.runtime.delete(id);
106
- });
107
- proc.on("error", (err) => {
108
- output.push(`[spawn error] ${err.message}`);
109
- record.status = "exited";
110
- record.exitCode = 1;
111
- record.outputTail = tailText(output);
112
- this.syncRecord(runtime);
113
- void this.persist();
114
- this.runtime.delete(id);
115
- });
116
- return { ...record };
117
- }
118
- async list() {
119
- await this.ensureLoaded();
120
- for (const rt of this.runtime.values()) {
121
- rt.record.outputTail = tailText(rt.output);
122
- this.syncRecord(rt);
123
- }
124
- return this.records.map((r) => {
125
- const live = this.runtime.get(r.id);
126
- return live ? { ...live.record } : { ...r };
127
- });
128
- }
129
- async getOutput(taskId, tailLines = 50) {
130
- await this.ensureLoaded();
131
- const live = this.runtime.get(taskId);
132
- if (live) {
133
- const lines = live.output.slice(-tailLines);
134
- return lines.length > 0 ? lines.join("\n") : "(no output yet)";
135
- }
136
- const rec = this.records.find((r) => r.id === taskId);
137
- if (!rec)
138
- return `Task "${taskId}" not found.`;
139
- const lines = rec.outputTail.split("\n").slice(-tailLines);
140
- return lines.length > 0 ? lines.join("\n") : "(no output)";
141
- }
142
- async kill(taskId) {
143
- await this.ensureLoaded();
144
- const live = this.runtime.get(taskId);
145
- if (live) {
146
- live.proc.kill("SIGTERM");
147
- live.record.status = "killed";
148
- live.record.exitCode = live.record.exitCode ?? 143;
149
- this.syncRecord(live);
150
- await this.persist();
151
- return `Killed ${taskId} (pid ${live.record.pid}).`;
152
- }
153
- const rec = this.records.find((r) => r.id === taskId);
154
- if (!rec)
155
- return `Task "${taskId}" not found.`;
156
- if (rec.status !== "running")
157
- return `${taskId} is already ${rec.status}.`;
158
- rec.status = "killed";
159
- await this.persist();
160
- return `Marked ${taskId} as killed (process not tracked in this session).`;
161
- }
162
- async formatContextForAgent() {
163
- const tasks = await this.list();
164
- const active = tasks.filter((t) => t.status === "running");
165
- if (active.length === 0)
166
- return "";
167
- const lines = active.map((t) => ` - ${t.id}: [running pid ${t.pid}] ${t.command} (cwd: ${t.cwd})`);
168
- return `\nActive background tasks:\n${lines.join("\n")}\nUse bash with action "output" and taskId to read logs, or action "kill" to stop a task.\n`;
169
- }
170
- }
171
- export function createBackgroundTaskManager(runPath, workspacePath) {
172
- return new BackgroundTaskManager(join(runPath, "background-tasks.json"), workspacePath);
173
- }
@@ -1,140 +0,0 @@
1
- import { readFile, writeFile, mkdir } from "node:fs/promises";
2
- import { dirname, join } from "node:path";
3
- import { tool } from "ai";
4
- import { z } from "zod";
5
- import { logEvent } from "../storage/run-store.js";
6
- const TodoStatusSchema = z.enum(["pending", "in_progress", "completed", "cancelled"]);
7
- function nextTodoId(existing) {
8
- const nums = existing
9
- .map((t) => /^todo_(\d+)$/u.exec(t.id)?.[1])
10
- .filter((n) => Boolean(n))
11
- .map((n) => Number.parseInt(n, 10));
12
- const next = nums.length > 0 ? Math.max(...nums) + 1 : 1;
13
- return `todo_${String(next).padStart(3, "0")}`;
14
- }
15
- async function loadTodos(path) {
16
- try {
17
- const raw = await readFile(path, "utf8");
18
- const parsed = JSON.parse(raw);
19
- if (!Array.isArray(parsed))
20
- return [];
21
- return parsed.filter((t) => typeof t === "object" && t !== null && typeof t.id === "string");
22
- }
23
- catch {
24
- return [];
25
- }
26
- }
27
- async function saveTodos(path, items) {
28
- await mkdir(dirname(path), { recursive: true });
29
- await writeFile(path, JSON.stringify(items, null, 2) + "\n");
30
- }
31
- function formatTodoList(items) {
32
- if (items.length === 0)
33
- return "No todos.";
34
- const icon = {
35
- pending: "[ ]",
36
- in_progress: "[~]",
37
- completed: "[x]",
38
- cancelled: "[-]"
39
- };
40
- return items
41
- .map((t) => `${icon[t.status]} ${t.id}: ${t.content} (${t.status})`)
42
- .join("\n");
43
- }
44
- export function createTodoTool(runPath) {
45
- const todosPath = join(runPath, "todos.json");
46
- return tool({
47
- description: "Manage structured task todos for the current session. " +
48
- "Actions: create (add items), edit (change content), mark (set status), remove (delete one), rewrite (replace entire list), list (show all). " +
49
- "Statuses: pending, in_progress, completed, cancelled.",
50
- inputSchema: z.object({
51
- action: z.enum(["create", "edit", "mark", "remove", "rewrite", "list"]),
52
- id: z.string().optional().describe("Todo id for edit, mark, or remove."),
53
- content: z.string().optional().describe("Todo text for create, edit, or rewrite items."),
54
- status: TodoStatusSchema.optional().describe("Status for mark action or rewrite items."),
55
- items: z
56
- .array(z.object({
57
- id: z.string().optional(),
58
- content: z.string(),
59
- status: TodoStatusSchema.optional()
60
- }))
61
- .optional()
62
- .describe("Items for create or rewrite.")
63
- }),
64
- execute: async ({ action, id, content, status, items }) => {
65
- const now = new Date().toISOString();
66
- let todos = await loadTodos(todosPath);
67
- switch (action) {
68
- case "list":
69
- return formatTodoList(todos);
70
- case "create": {
71
- const toAdd = items ?? (content ? [{ content, status: status ?? "pending" }] : []);
72
- if (toAdd.length === 0)
73
- return "create requires content or items.";
74
- for (const item of toAdd) {
75
- const todoId = item.id ?? nextTodoId(todos);
76
- todos.push({
77
- id: todoId,
78
- content: item.content,
79
- status: item.status ?? "pending",
80
- createdAt: now,
81
- updatedAt: now
82
- });
83
- }
84
- await saveTodos(todosPath, todos);
85
- await logEvent(runPath, "todo.created", { count: toAdd.length });
86
- return `Created ${toAdd.length} todo(s).\n\n${formatTodoList(todos)}`;
87
- }
88
- case "edit": {
89
- if (!id || !content)
90
- return "edit requires id and content.";
91
- const idx = todos.findIndex((t) => t.id === id);
92
- if (idx === -1)
93
- return `Todo "${id}" not found.`;
94
- todos[idx] = { ...todos[idx], content, updatedAt: now };
95
- await saveTodos(todosPath, todos);
96
- await logEvent(runPath, "todo.edited", { id });
97
- return `Updated ${id}.\n\n${formatTodoList(todos)}`;
98
- }
99
- case "mark": {
100
- if (!id || !status)
101
- return "mark requires id and status.";
102
- const idx = todos.findIndex((t) => t.id === id);
103
- if (idx === -1)
104
- return `Todo "${id}" not found.`;
105
- todos[idx] = { ...todos[idx], status, updatedAt: now };
106
- await saveTodos(todosPath, todos);
107
- await logEvent(runPath, "todo.marked", { id, status });
108
- return `Marked ${id} as ${status}.\n\n${formatTodoList(todos)}`;
109
- }
110
- case "remove": {
111
- if (!id)
112
- return "remove requires id.";
113
- const before = todos.length;
114
- todos = todos.filter((t) => t.id !== id);
115
- if (todos.length === before)
116
- return `Todo "${id}" not found.`;
117
- await saveTodos(todosPath, todos);
118
- await logEvent(runPath, "todo.removed", { id });
119
- return `Removed ${id}.\n\n${formatTodoList(todos)}`;
120
- }
121
- case "rewrite": {
122
- if (!items || items.length === 0)
123
- return "rewrite requires a non-empty items array.";
124
- todos = items.map((item, i) => ({
125
- id: item.id ?? `todo_${String(i + 1).padStart(3, "0")}`,
126
- content: item.content,
127
- status: item.status ?? "pending",
128
- createdAt: now,
129
- updatedAt: now
130
- }));
131
- await saveTodos(todosPath, todos);
132
- await logEvent(runPath, "todo.rewritten", { count: todos.length });
133
- return `Rewrote todo list (${todos.length} items).\n\n${formatTodoList(todos)}`;
134
- }
135
- default:
136
- return `Unknown action: ${action}`;
137
- }
138
- }
139
- });
140
- }