@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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +128 -0
  3. package/dist/agent/research-agent.js +253 -0
  4. package/dist/agent/skills.js +265 -0
  5. package/dist/agent/tools.js +429 -0
  6. package/dist/agent/tools.test.js +27 -0
  7. package/dist/cli/commands/init.js +370 -0
  8. package/dist/cli/index.js +445 -0
  9. package/dist/cli/shell/shell.js +76 -0
  10. package/dist/cli/shell/tui.js +11 -0
  11. package/dist/config/env-store.js +47 -0
  12. package/dist/config/load-config.js +58 -0
  13. package/dist/export/formatters.js +37 -0
  14. package/dist/providers/llm/gateway.js +64 -0
  15. package/dist/providers/llm/huggingface.js +33 -0
  16. package/dist/providers/llm/models.js +97 -0
  17. package/dist/providers/llm/readiness.js +50 -0
  18. package/dist/providers/llm/registry.js +56 -0
  19. package/dist/storage/jsonl.js +29 -0
  20. package/dist/storage/jsonl.test.js +38 -0
  21. package/dist/storage/run-store.js +134 -0
  22. package/dist/storage/run-store.test.js +65 -0
  23. package/dist/tools/chrome-devtools-mcp.js +61 -0
  24. package/dist/tools/file-tools.js +128 -0
  25. package/dist/tools/mcp-bridge.js +118 -0
  26. package/dist/tools/mcp-oauth.js +276 -0
  27. package/dist/tools/open-url.js +99 -0
  28. package/dist/tools/search-web.js +153 -0
  29. package/dist/types/index.js +91 -0
  30. package/dist/types/schema.test.js +60 -0
  31. package/dist/ui/ink/SciraApp.js +274 -0
  32. package/dist/ui/ink/components/effects.js +44 -0
  33. package/dist/ui/ink/components/home-screen.js +69 -0
  34. package/dist/ui/ink/components/overlays.js +111 -0
  35. package/dist/ui/ink/constants.js +56 -0
  36. package/dist/ui/ink/hooks/use-agent-turn.js +186 -0
  37. package/dist/ui/ink/hooks/use-feed-lines.js +186 -0
  38. package/dist/ui/ink/hooks/use-feed.js +69 -0
  39. package/dist/ui/ink/hooks/use-keyboard.js +315 -0
  40. package/dist/ui/ink/hooks/use-mouse.js +31 -0
  41. package/dist/ui/ink/hooks/use-session.js +103 -0
  42. package/dist/ui/ink/hooks/use-settings.js +155 -0
  43. package/dist/ui/ink/hooks/use-submit.js +366 -0
  44. package/dist/ui/ink/hooks/use-suggestions.js +91 -0
  45. package/dist/ui/ink/lib/file-mentions.js +71 -0
  46. package/dist/ui/ink/lib/markdown.js +245 -0
  47. package/dist/ui/ink/lib/utils.js +224 -0
  48. package/dist/ui/ink/session-manager.js +160 -0
  49. package/dist/ui/ink/types.js +1 -0
  50. package/dist/utils/ids.js +15 -0
  51. package/dist/utils/markdown-joiner.js +249 -0
  52. package/dist/watch/runner.js +65 -0
  53. package/package.json +74 -0
@@ -0,0 +1,429 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
4
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
5
+ import { tool } from "ai";
6
+ import { z } from "zod";
7
+ import { multiSearchWeb } from "../tools/search-web.js";
8
+ import { openUrl, writeSnapshot } from "../tools/open-url.js";
9
+ import { appendJsonl, readJsonl } from "../storage/jsonl.js";
10
+ import { logEvent } from "../storage/run-store.js";
11
+ import { diffLines } from "diff";
12
+ import { SKILL_NAMES, getSkill } from "./skills.js";
13
+ import { createFileTools } from "../tools/file-tools.js";
14
+ const execAsync = promisify(exec);
15
+ const MAX_OUTPUT = 8000;
16
+ function truncate(text, max = MAX_OUTPUT) {
17
+ if (text.length <= max)
18
+ return text;
19
+ return `${text.slice(0, max)}\n…[truncated ${text.length - max} chars]`;
20
+ }
21
+ /** Derive a stable, filesystem-safe snapshot filename slug from a URL. */
22
+ function snapshotSlug(url) {
23
+ return url.replace(/^https?:\/\//u, "").replace(/[^a-zA-Z0-9]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 80) || "page";
24
+ }
25
+ /**
26
+ * Resolve a model-provided path against the run directory and refuse to escape it.
27
+ */
28
+ export function resolveInsideRun(runPath, candidate) {
29
+ const abs = isAbsolute(candidate) ? candidate : resolve(runPath, candidate);
30
+ const rel = relative(runPath, abs);
31
+ if (rel.startsWith("..") || isAbsolute(rel)) {
32
+ throw new Error(`Path "${candidate}" is outside the run directory and is not allowed.`);
33
+ }
34
+ return abs;
35
+ }
36
+ export function createResearchTools(runPath, config, onApprovalRequired) {
37
+ /** Gate a tool behind user approval unless approvalMode is "auto". */
38
+ async function gate(toolName, description) {
39
+ if (config.approvalMode === "auto" || !onApprovalRequired)
40
+ return true;
41
+ return onApprovalRequired(toolName, description);
42
+ }
43
+ const claimsPath = join(runPath, "claims.jsonl");
44
+ return {
45
+ bash: tool({
46
+ description: "Run a shell command inside the run directory. Use for listing files, grepping notes, running scripts, git, etc. Returns combined stdout/stderr.",
47
+ inputSchema: z.object({
48
+ command: z.string().describe("The shell command to execute."),
49
+ timeoutMs: z.number().int().positive().max(120000).optional().describe("Optional timeout in ms (default 60000).")
50
+ }),
51
+ execute: async ({ command, timeoutMs }) => {
52
+ if (!await gate("bash", command))
53
+ return "Command rejected by user.";
54
+ // Block commands that attempt to leave the run directory.
55
+ const escapesRunDir = /(?:^|[;&|`\n])\s*cd\s+[^.]/u.test(command)
56
+ || /\.\.\/\.\.\//u.test(command)
57
+ || /~[/\\]/u.test(command)
58
+ || /\$HOME/u.test(command);
59
+ if (escapesRunDir) {
60
+ return "Command rejected: navigating outside the run directory is not allowed.";
61
+ }
62
+ await logEvent(runPath, "tool.bash", { command });
63
+ try {
64
+ const { stdout, stderr } = await execAsync(command, {
65
+ cwd: runPath,
66
+ timeout: timeoutMs ?? 60000,
67
+ maxBuffer: 10 * 1024 * 1024,
68
+ shell: "/bin/bash"
69
+ });
70
+ const out = [stdout, stderr].filter(Boolean).join("\n").trim();
71
+ return truncate(out || "(no output)");
72
+ }
73
+ catch (error) {
74
+ const err = error;
75
+ const out = [err.stdout, err.stderr, err.message].filter(Boolean).join("\n").trim();
76
+ return truncate(`Command failed:\n${out}`);
77
+ }
78
+ }
79
+ }),
80
+ writeFile: tool({
81
+ description: "Create or overwrite a file inside the run directory (e.g. plan.md, notes.md, report.md, sources.jsonl). Paths are relative to the run directory.",
82
+ inputSchema: z.object({
83
+ path: z.string().describe("File path relative to the run directory."),
84
+ content: z.string().describe("Full file content to write.")
85
+ }),
86
+ execute: async ({ path, content }) => {
87
+ const isPlanOrReport = path === "plan.md" || path === "report.md";
88
+ if (isPlanOrReport) {
89
+ let description;
90
+ if (path === "report.md") {
91
+ const abs = resolveInsideRun(runPath, path);
92
+ const existing = await readFile(abs, "utf8").catch(() => "");
93
+ const parts = diffLines(existing, content);
94
+ const added = parts.filter((p) => p.added).reduce((n, p) => n + (p.count ?? 0), 0);
95
+ const removed = parts.filter((p) => p.removed).reduce((n, p) => n + (p.count ?? 0), 0);
96
+ const diffPreview = parts
97
+ .filter((p) => p.added || p.removed)
98
+ .flatMap((p) => p.value.split("\n").filter(Boolean).map((l) => `${p.added ? "+" : "-"} ${l}`))
99
+ .slice(0, 20)
100
+ .join("\n");
101
+ description = `report.md (+${added} / -${removed} lines)\n\n${diffPreview}`;
102
+ }
103
+ else {
104
+ description = `${path}\n\n${content.length > 600 ? `${content.slice(0, 600)}\n…` : content}`;
105
+ }
106
+ if (!await gate("writeFile", description))
107
+ return `Write to ${path} rejected by user.`;
108
+ }
109
+ const abs = resolveInsideRun(runPath, path);
110
+ await mkdir(dirname(abs), { recursive: true });
111
+ await writeFile(abs, content);
112
+ const eventType = path === "report.md" ? "report.updated" : path === "plan.md" ? "plan.updated" : "file.written";
113
+ await logEvent(runPath, eventType, { path, chars: content.length });
114
+ return `Wrote ${content.length} chars to ${path}`;
115
+ }
116
+ }),
117
+ editFile: tool({
118
+ description: "Replace an exact string in an existing file inside the run directory. The oldString must match exactly and be unique.",
119
+ inputSchema: z.object({
120
+ path: z.string().describe("File path relative to the run directory."),
121
+ oldString: z.string().describe("Exact text to replace."),
122
+ newString: z.string().describe("Replacement text.")
123
+ }),
124
+ execute: async ({ path, oldString, newString }) => {
125
+ const abs = resolveInsideRun(runPath, path);
126
+ const current = await readFile(abs, "utf8");
127
+ const occurrences = current.split(oldString).length - 1;
128
+ if (occurrences === 0) {
129
+ return `No match for the given oldString in ${path}. No changes made.`;
130
+ }
131
+ if (occurrences > 1) {
132
+ return `oldString matched ${occurrences} times in ${path}; provide more context to make it unique. No changes made.`;
133
+ }
134
+ await writeFile(abs, current.replace(oldString, newString));
135
+ await logEvent(runPath, "file.edited", { path });
136
+ return `Edited ${path}`;
137
+ }
138
+ }),
139
+ createClaim: tool({
140
+ description: "Record a structured research claim into claims.jsonl. Call after reading a source to track a significant finding.",
141
+ inputSchema: z.object({
142
+ id: z.string().describe("Unique claim ID, e.g. claim_001."),
143
+ text: z.string().describe("The claim statement."),
144
+ confidence: z.enum(["low", "medium", "high"]).describe("Confidence level."),
145
+ sourceIds: z.array(z.string()).describe("Source IDs supporting this claim."),
146
+ reason: z.string().describe("Why these sources support the claim.")
147
+ }),
148
+ execute: async ({ id, text, confidence, sourceIds, reason }) => {
149
+ const claim = { id, text, confidence, status: "draft", sourceIds, reason, createdAt: new Date().toISOString() };
150
+ await appendJsonl(claimsPath, claim);
151
+ await logEvent(runPath, "claim.created", { id, confidence, sourceIds });
152
+ return `Claim ${id} recorded.`;
153
+ }
154
+ }),
155
+ verifyClaim: tool({
156
+ description: "Update a claim's verification status after checking its evidence. Call after confirming or questioning a claim.",
157
+ inputSchema: z.object({
158
+ id: z.string().describe("Claim ID to update."),
159
+ status: z.enum(["verified", "weak", "contradicted", "needs_review"]).describe("New verification status."),
160
+ reason: z.string().describe("Explanation of the verification result.")
161
+ }),
162
+ execute: async ({ id, status, reason }) => {
163
+ const claims = await readJsonl(claimsPath);
164
+ const idx = claims.findIndex((c) => c.id === id);
165
+ if (idx === -1) {
166
+ return `Claim "${id}" not found. Present IDs: ${claims.map((c) => c.id).join(", ") || "none"}`;
167
+ }
168
+ claims[idx] = { ...claims[idx], status, reason };
169
+ await mkdir(dirname(claimsPath), { recursive: true });
170
+ await writeFile(claimsPath, claims.map((c) => JSON.stringify(c)).join("\n") + "\n");
171
+ await logEvent(runPath, "claim.verified", { id, status });
172
+ return `Claim ${id} → ${status}`;
173
+ }
174
+ }),
175
+ readFile: tool({
176
+ description: "Read a file inside the run directory.",
177
+ inputSchema: z.object({
178
+ path: z.string().describe("File path relative to the run directory.")
179
+ }),
180
+ execute: async ({ path }) => {
181
+ const abs = resolveInsideRun(runPath, path);
182
+ return truncate(await readFile(abs, "utf8"));
183
+ }
184
+ }),
185
+ webSearch: tool({
186
+ description: `Search the web with multiple parallel queries. Always use 3-5 queries per call to cover the topic from different angles.
187
+ - Include date context in queries: "${new Date().getFullYear()}", "latest", "recent".
188
+ - Use topic:"news" for breaking events, quality:"best" only when depth is essential.
189
+ - Never invent sources — only cite URLs returned by this tool.`,
190
+ inputSchema: z.object({
191
+ queries: z.array(z.string()).min(3).max(10).describe("3-5 search queries covering different angles of the topic."),
192
+ maxResults: z.array(z.number().int().min(1).max(20)).max(10).optional().describe("Max results per query (default 10)."),
193
+ topics: z.array(z.enum(["general", "news"])).optional().describe("Topic type per query. Default: general."),
194
+ quality: z.array(z.enum(["default", "best"])).optional().describe("Search quality per query. Use best sparingly."),
195
+ startDates: z.array(z.string().nullable().optional()).optional().describe("ISO date filter per query (YYYY-MM-DD). Omit for no filter.")
196
+ }),
197
+ execute: async ({ queries, maxResults, topics, quality, startDates }) => {
198
+ const perQuery = queries.map((_, i) => ({
199
+ maxResults: maxResults?.[i] ?? 10,
200
+ topic: (topics?.[i] ?? "general"),
201
+ quality: (quality?.[i] ?? "default"),
202
+ startDate: startDates?.[i] ?? null
203
+ }));
204
+ const searches = await multiSearchWeb(queries, perQuery, config);
205
+ await logEvent(runPath, "tool.search", { queries, resultCount: searches.reduce((n, s) => n + s.results.length, 0) });
206
+ return JSON.stringify(searches.map((s) => ({
207
+ query: s.query,
208
+ results: s.results.map((r) => ({ title: r.title, url: r.url, snippet: r.snippet, publishedDate: r.publishedDate }))
209
+ })), null, 2);
210
+ }
211
+ }),
212
+ readUrl: tool({
213
+ description: "Fetch and extract the readable content of a web page so you can read and cite it.",
214
+ inputSchema: z.object({
215
+ url: z.string().describe("The URL to open and extract.")
216
+ }),
217
+ execute: async ({ url }) => {
218
+ const page = await openUrl(url, config);
219
+ const snapshotPath = await writeSnapshot(join(runPath, "snapshots"), snapshotSlug(url), page);
220
+ const snapshotRel = relative(runPath, snapshotPath);
221
+ await logEvent(runPath, "tool.open_url", { url, title: page.title, snapshot: snapshotRel });
222
+ return truncate(`# ${page.title}\n(snapshot saved to ${snapshotRel} — record this as snapshotPath in sources.jsonl)\n\n${page.text}`);
223
+ }
224
+ }),
225
+ listSkills: tool({
226
+ description: "List the names and one-line summaries of all built-in research skills.",
227
+ inputSchema: z.object({}),
228
+ execute: async () => {
229
+ return SKILL_NAMES.map((n) => {
230
+ const s = getSkill(n);
231
+ return `${n}: ${s?.summary ?? ""}`;
232
+ }).join("\n");
233
+ }
234
+ }),
235
+ readSkill: tool({
236
+ description: "Read the full content of a built-in research skill by name. " +
237
+ "The available skill names are listed in your instructions.",
238
+ inputSchema: z.object({
239
+ name: z.string().describe("Skill name exactly as listed in the instructions.")
240
+ }),
241
+ execute: async ({ name }) => {
242
+ const skill = getSkill(name);
243
+ if (!skill) {
244
+ return `Unknown skill "${name}". Available: ${SKILL_NAMES.join(", ")}`;
245
+ }
246
+ return skill.content;
247
+ }
248
+ }),
249
+ ...(config.files ? createFileTools(runPath, config, onApprovalRequired) : {})
250
+ };
251
+ }
252
+ /**
253
+ * Lightweight toolset for quick one-shot answers: web search + page reading,
254
+ * plus a single escalation tool the model can call (with user approval) to
255
+ * switch into the full research harness.
256
+ */
257
+ export function createOneShotTools(runPath, config, onApprovalRequired, onEscalate) {
258
+ const all = createResearchTools(runPath, config, onApprovalRequired);
259
+ async function gate(toolName, description) {
260
+ if (config.approvalMode === "auto" || !onApprovalRequired)
261
+ return true;
262
+ return onApprovalRequired(toolName, description);
263
+ }
264
+ return {
265
+ webSearch: all.webSearch,
266
+ readUrl: all.readUrl,
267
+ requestFullResearch: tool({
268
+ description: "Escalate from quick one-shot mode to the FULL research harness (skills, plan.md, claims, verification, sources.jsonl, report.md). " +
269
+ "You MUST call this if the user's goal involves research, deep dives, analysis, comparisons, history, or any topic that would benefit from structured multi-source research. " +
270
+ "For simple factual questions (e.g. 'what is the capital of France?') do NOT call this — just answer directly.",
271
+ inputSchema: z.object({
272
+ reason: z.string().describe("One sentence on why full research is warranted.")
273
+ }),
274
+ execute: async ({ reason }) => {
275
+ const approved = await gate("requestFullResearch", `Escalate to the full research harness?\n\nReason: ${reason}`);
276
+ if (!approved) {
277
+ return "User declined escalation. Answer the question concisely now using webSearch and readUrl only — do not ask again.";
278
+ }
279
+ onEscalate?.();
280
+ return "Approved. Stop now and do not call more tools — the full research harness will take over and complete the work.";
281
+ }
282
+ })
283
+ };
284
+ }
285
+ /**
286
+ * Workspace-aware tools for coding agent capabilities.
287
+ * Unlike research tools, these operate on the full workspace, not just the run directory.
288
+ */
289
+ export function createCodingTools(workspacePath, config, onApprovalRequired) {
290
+ async function gate(toolName, description) {
291
+ if (config.approvalMode === "auto" || !onApprovalRequired)
292
+ return true;
293
+ return onApprovalRequired(toolName, description);
294
+ }
295
+ function resolveWorkspacePath(candidate) {
296
+ return isAbsolute(candidate) ? candidate : resolve(workspacePath, candidate);
297
+ }
298
+ return {
299
+ readWorkspaceFile: tool({
300
+ description: "Read a file from the workspace. Use for examining code, configs, or any workspace file.",
301
+ inputSchema: z.object({
302
+ path: z.string().describe("File path (absolute or relative to workspace root).")
303
+ }),
304
+ execute: async ({ path }) => {
305
+ const abs = resolveWorkspacePath(path);
306
+ const content = await readFile(abs, "utf8");
307
+ return truncate(content);
308
+ }
309
+ }),
310
+ writeWorkspaceFile: tool({
311
+ description: "Create or overwrite a file in the workspace. Requires approval for safety.",
312
+ inputSchema: z.object({
313
+ path: z.string().describe("File path (absolute or relative to workspace root)."),
314
+ content: z.string().describe("Full file content to write.")
315
+ }),
316
+ execute: async ({ path, content }) => {
317
+ const abs = resolveWorkspacePath(path);
318
+ const preview = content.length > 800 ? `${content.slice(0, 800)}\n…[${content.length} total chars]` : content;
319
+ if (!await gate("writeWorkspaceFile", `Write to ${path}:\n\n${preview}`)) {
320
+ return `Write to ${path} rejected by user.`;
321
+ }
322
+ await mkdir(dirname(abs), { recursive: true });
323
+ await writeFile(abs, content);
324
+ return `Wrote ${content.length} chars to ${path}`;
325
+ }
326
+ }),
327
+ editWorkspaceFile: tool({
328
+ description: "Replace an exact string in a workspace file. The oldString must match exactly and be unique.",
329
+ inputSchema: z.object({
330
+ path: z.string().describe("File path (absolute or relative to workspace root)."),
331
+ oldString: z.string().describe("Exact text to replace."),
332
+ newString: z.string().describe("Replacement text.")
333
+ }),
334
+ execute: async ({ path, oldString, newString }) => {
335
+ const abs = resolveWorkspacePath(path);
336
+ const current = await readFile(abs, "utf8");
337
+ const occurrences = current.split(oldString).length - 1;
338
+ if (occurrences === 0) {
339
+ return `No match for the given oldString in ${path}. No changes made.`;
340
+ }
341
+ if (occurrences > 1) {
342
+ return `oldString matched ${occurrences} times in ${path}; provide more context to make it unique. No changes made.`;
343
+ }
344
+ const diff = diffLines(current, current.replace(oldString, newString));
345
+ const preview = diff
346
+ .filter((p) => p.added || p.removed)
347
+ .flatMap((p) => p.value.split("\n").filter(Boolean).map((l) => `${p.added ? "+" : "-"} ${l}`))
348
+ .slice(0, 15)
349
+ .join("\n");
350
+ if (!await gate("editWorkspaceFile", `Edit ${path}:\n\n${preview}`)) {
351
+ return `Edit to ${path} rejected by user.`;
352
+ }
353
+ await writeFile(abs, current.replace(oldString, newString));
354
+ return `Edited ${path}`;
355
+ }
356
+ }),
357
+ listWorkspaceDir: tool({
358
+ description: "List files and directories in a workspace directory.",
359
+ inputSchema: z.object({
360
+ path: z.string().describe("Directory path (absolute or relative to workspace root)."),
361
+ recursive: z.boolean().optional().describe("Recursively list subdirectories (default false).")
362
+ }),
363
+ execute: async ({ path, recursive }) => {
364
+ const abs = resolveWorkspacePath(path);
365
+ const cmd = recursive ? `find "${abs}" -type f -o -type d | head -200` : `ls -lah "${abs}"`;
366
+ try {
367
+ const { stdout } = await execAsync(cmd, { maxBuffer: 5 * 1024 * 1024 });
368
+ return truncate(stdout.trim());
369
+ }
370
+ catch (error) {
371
+ const err = error;
372
+ return `Failed to list ${path}: ${err.stderr ?? err.message}`;
373
+ }
374
+ }
375
+ }),
376
+ grepWorkspace: tool({
377
+ description: "Search for a pattern across workspace files using grep. Essential for finding code references.",
378
+ inputSchema: z.object({
379
+ pattern: z.string().describe("Search pattern (regex supported)."),
380
+ path: z.string().optional().describe("Directory to search (default: workspace root)."),
381
+ filePattern: z.string().optional().describe("File pattern to include (e.g., '*.ts', '*.{js,jsx}').")
382
+ }),
383
+ execute: async ({ pattern, path, filePattern }) => {
384
+ const searchPath = path ? resolveWorkspacePath(path) : workspacePath;
385
+ const fileArg = filePattern ? `--include="${filePattern}"` : "";
386
+ const cmd = `grep -rn ${fileArg} -E "${pattern.replace(/"/g, '\\"')}" "${searchPath}" 2>/dev/null | head -100`;
387
+ try {
388
+ const { stdout } = await execAsync(cmd, { maxBuffer: 5 * 1024 * 1024 });
389
+ const result = stdout.trim() || `No matches found for pattern: ${pattern}`;
390
+ return truncate(result);
391
+ }
392
+ catch (error) {
393
+ const err = error;
394
+ const result = err.stdout?.trim() || `No matches found for pattern: ${pattern}`;
395
+ return truncate(result);
396
+ }
397
+ }
398
+ }),
399
+ runWorkspaceCommand: tool({
400
+ description: "Execute a shell command in the workspace. Use for builds, tests, package installs, git, etc. Requires approval.",
401
+ inputSchema: z.object({
402
+ command: z.string().describe("The shell command to execute."),
403
+ cwd: z.string().optional().describe("Working directory (default: workspace root)."),
404
+ timeoutMs: z.number().int().positive().max(300000).optional().describe("Timeout in ms (default 60000, max 300000).")
405
+ }),
406
+ execute: async ({ command, cwd, timeoutMs }) => {
407
+ const workDir = cwd ? resolveWorkspacePath(cwd) : workspacePath;
408
+ if (!await gate("runWorkspaceCommand", `Run command in ${relative(workspacePath, workDir) || "."}:\n\n${command}`)) {
409
+ return "Command rejected by user.";
410
+ }
411
+ try {
412
+ const { stdout, stderr } = await execAsync(command, {
413
+ cwd: workDir,
414
+ timeout: timeoutMs ?? 60000,
415
+ maxBuffer: 10 * 1024 * 1024,
416
+ shell: "/bin/bash"
417
+ });
418
+ const out = [stdout, stderr].filter(Boolean).join("\n").trim();
419
+ return truncate(out || "(no output)");
420
+ }
421
+ catch (error) {
422
+ const err = error;
423
+ const out = [err.stdout, err.stderr, err.message].filter(Boolean).join("\n").trim();
424
+ return truncate(`Command failed:\n${out}`);
425
+ }
426
+ }
427
+ })
428
+ };
429
+ }
@@ -0,0 +1,27 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { resolveInsideRun } from "./tools.js";
3
+ const RUN = "/tmp/scira-test-run";
4
+ describe("resolveInsideRun", () => {
5
+ it("resolves a relative path inside the run dir", () => {
6
+ expect(resolveInsideRun(RUN, "notes.md")).toBe(`${RUN}/notes.md`);
7
+ });
8
+ it("resolves a nested relative path inside the run dir", () => {
9
+ expect(resolveInsideRun(RUN, "artifacts/output.txt")).toBe(`${RUN}/artifacts/output.txt`);
10
+ });
11
+ it("resolves an absolute path that is inside the run dir", () => {
12
+ expect(resolveInsideRun(RUN, `${RUN}/plan.md`)).toBe(`${RUN}/plan.md`);
13
+ });
14
+ it("throws for a path that escapes with ../", () => {
15
+ expect(() => resolveInsideRun(RUN, "../outside.txt")).toThrow("outside the run directory");
16
+ });
17
+ it("throws for a deep escape path", () => {
18
+ expect(() => resolveInsideRun(RUN, "a/../../outside.txt")).toThrow("outside the run directory");
19
+ });
20
+ it("throws for an absolute path outside the run dir", () => {
21
+ expect(() => resolveInsideRun(RUN, "/etc/passwd")).toThrow("outside the run directory");
22
+ });
23
+ it("throws for a home-dir escape", () => {
24
+ const home = `${process.env.HOME ?? "/root"}/evil.sh`;
25
+ expect(() => resolveInsideRun(RUN, home)).toThrow("outside the run directory");
26
+ });
27
+ });