@scira/cli 0.1.6 → 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.
@@ -5,6 +5,10 @@ import { createClaudeCode } from "@ai-sdk/harness-claude-code";
5
5
  import { createCodex } from "@ai-sdk/harness-codex";
6
6
  import { createLocalSandbox } from "../providers/harness/local-sandbox.js";
7
7
  import { createResearchTools } from "../tools/agent-tools.js";
8
+ import { SKILLS } from "./skills.js";
9
+ // Scira's built-in skills surfaced to the harness runtime (the adapter
10
+ // materializes them natively — e.g. Claude Code writes them as SKILL.md files).
11
+ const HARNESS_SKILLS = SKILLS.map((s) => ({ name: s.name, description: s.summary, content: s.content }));
8
12
  /** Resolve a promise but never hang the caller longer than `ms`. Clears the timer when settled so it can't keep the event loop alive (e.g. delaying quit). */
9
13
  function withTimeout(p, ms) {
10
14
  let timer;
@@ -48,8 +52,14 @@ function buildAgent(provider, config, workspacePath, instructions, runPath) {
48
52
  // Cast bridges the project's `ai` Tool type to the harness's bundled-`ai`
49
53
  // ToolSet (same runtime shape, different package versions).
50
54
  const tools = { multiWebSearch: webSearch, readUrl };
55
+ // The harness CLI writes relative to its own working directory and has no
56
+ // knowledge of Scira's run layout, so pin the exact run directory and require
57
+ // absolute paths for artifacts — otherwise it guesses (e.g. ~/.scira/runs/).
58
+ const runDir = path.resolve(runPath);
59
+ const runDirSteer = `\n\nRUN DIRECTORY: Write every run artifact — plan.md, notes.md, sources.jsonl, claims.jsonl, report.md — to this exact absolute path, nowhere else:\n ${runDir}\nFor example, the report goes to ${runDir}/report.md. Always use the absolute path; do not use a bare "sources.jsonl" or a ".scira/runs/…" path. If a file already exists, Read it before writing (your Write tool requires that) — or edit it in place.`;
51
60
  // Positive, authoritative steering pointing at the unique tool name.
52
- const fullInstructions = `${instructions}\n\nWEB ACCESS: For any web search use the \`multiWebSearch\` tool and pass 3-5 query variations in a single call (it searches them in parallel). Use \`readUrl\` to read a specific page. These are your only web tools.`;
61
+ const webSteer = `\n\nWEB ACCESS: For any web search use the \`multiWebSearch\` tool and pass 3-5 query variations in a single call (it searches them in parallel). Use \`readUrl\` to read a specific page. These are your only web tools.`;
62
+ const fullInstructions = `${instructions}${runDirSteer}${webSteer}`;
53
63
  // No `auth`: the bundled CLI authenticates with the user's local login
54
64
  // (`claude login` → ~/.claude, `codex login` → ~/.codex). We never pass an
55
65
  // API key, so a Pro/Max/ChatGPT subscription session is used as-is.
@@ -65,7 +75,7 @@ function buildAgent(provider, config, workspacePath, instructions, runPath) {
65
75
  // Built-in harness web search is always disabled.
66
76
  webSearch: false
67
77
  });
68
- return new HarnessAgent({ harness, sandbox, permissionMode, instructions: fullInstructions, tools });
78
+ return new HarnessAgent({ harness, sandbox, permissionMode, instructions: fullInstructions, tools, skills: HARNESS_SKILLS });
69
79
  }
70
80
  /** Settings that, if changed, require rebuilding the session (not just the prompt). */
71
81
  function settingsFingerprint(provider, config) {
@@ -26,7 +26,7 @@ You are in plan mode. Explore and plan before making changes.
26
26
  - Read-only bash is OK: ls, cat, git status, git log, git diff, find, grep (workspace-relative paths only)
27
27
  - When the plan is ready, summarize it and tell the user to type /plan to exit plan mode and begin execution`;
28
28
  }
29
- function instructions(goal, config, options = {}) {
29
+ function instructions(goal, config, options = {}, runPath) {
30
30
  const { workspacePath } = options;
31
31
  const planMode = resolvePlanMode(options);
32
32
  const now = new Date();
@@ -43,12 +43,13 @@ function instructions(goal, config, options = {}) {
43
43
 
44
44
  PROJECT LAYOUT:
45
45
  - Project root (codebase): ${workspacePath}
46
- - Run harness (.scira/runs/…): plan.md, notes.md, report.md, sources.jsonl, claims.jsonl, todos.json
46
+ - This run's directory: ${runPath ?? "(the active run)"} ← artifacts live here
47
+ - Run artifacts: plan.md, notes.md, report.md, sources.jsonl, claims.jsonl, todos.json — pass the bare filename (e.g. \`report.md\`) or the absolute path above. Don't guess a \`.scira/runs/…\` path.
47
48
 
48
49
  FILE TOOLS:
49
50
  - readFile / writeFile / editFile route automatically:
50
- - Harness files by bare name: plan.md, notes.md, report.md, sources.jsonl stored under .scira/runs/
51
- - Everything else (src/…, package.json, …) project root
51
+ - Harness files (plan.md, notes.md, report.md, sources.jsonl, claims.jsonl): pass the BARE filename only — e.g. \`plan.md\`. The tool puts it in this run's directory for you. Never prefix it with a directory, \`.scira\`, \`runs\`, or \`latest\`; those paths are rejected.
52
+ - Source code / project files (src/…, package.json, …): relative to the project root.
52
53
  - Never write source code under .scira. Never put harness files at the project root.
53
54
 
54
55
  CODING TOOLS:
@@ -63,7 +64,7 @@ When the task involves code:
63
64
  - Use editFile for precise changes, writeFile for new source files (paths like src/foo.ts)
64
65
  - Run tests/builds with bash; use bash action=background for servers then action=output to check logs
65
66
  - Match existing code style and patterns` : "";
66
- return `You are Scira AI CLI, made by Zaid Mukaddam, an autonomous research ${workspacePath ? "and coding " : ""}agent.${workspacePath ? " Source code lives at the project root; harness artifacts live under .scira/runs/." : " You operate inside a single run directory on the user's machine."}
67
+ return `You are Scira AI CLI, made by Zaid Mukaddam, an autonomous research ${workspacePath ? "and coding " : ""}agent.${workspacePath ? ` Source code lives at the project root; run artifacts live in this run's directory${runPath ? ` (${runPath})` : ""}.` : ` You operate inside a single run directory${runPath ? ` (${runPath})` : ""} on the user's machine.`}
67
68
 
68
69
  Your goal:
69
70
  ${goal}
@@ -136,7 +137,7 @@ export async function createResearchAgent(runPath, goal, config, onApprovalRequi
136
137
  provider: config.llmProvider,
137
138
  config,
138
139
  workspacePath: options.workspacePath ?? process.cwd(),
139
- instructions: instructions(goal, config, options)
140
+ instructions: instructions(goal, config, options, runPath)
140
141
  });
141
142
  }
142
143
  const bridge = await createMcpBridge(config);
@@ -149,18 +150,18 @@ export async function createResearchAgent(runPath, goal, config, onApprovalRequi
149
150
  const bgContext = options.backgroundTasks ? await options.backgroundTasks.formatContextForAgent() : "";
150
151
  const agent = new ToolLoopAgent({
151
152
  model: getLanguageModel(config),
152
- instructions: instructions(goal, config, options) + bgContext + devtoolsInstructionsBlock(bridge.toolNames),
153
+ instructions: instructions(goal, config, options, runPath) + bgContext + devtoolsInstructionsBlock(bridge.toolNames),
153
154
  tools,
154
155
  stopWhen: isLoopFinished()
155
156
  });
156
157
  return { agent, close: bridge.close };
157
158
  }
158
- function oneShotInstructions(goal, hasDevtools, options = {}) {
159
+ function oneShotInstructions(goal, hasDevtools, options = {}, runPath) {
159
160
  const { workspacePath } = options;
160
161
  const planMode = resolvePlanMode(options);
161
162
  const codingHint = workspacePath ? `
162
163
 
163
- Project root: ${workspacePath}. readFile/writeFile/editFile route code paths to the project root; harness files (plan.md, notes.md, …) stay under .scira/runs/.
164
+ Project root: ${workspacePath}. readFile/writeFile/editFile route code paths to the project root; run artifacts (plan.md, notes.md, …) go in this run's directory${runPath ? ` (${runPath})` : ""} — pass the bare filename or that absolute path.
164
165
  - listWorkspaceDir, grepWorkspace, bash (with background tasks), todo
165
166
  Use them for code questions, debugging, and implementation tasks.` : "";
166
167
  const now = new Date();
@@ -210,7 +211,7 @@ export async function createOneShotAgent(runPath, goal, config, onApprovalRequir
210
211
  provider: config.llmProvider,
211
212
  config,
212
213
  workspacePath: options.workspacePath ?? process.cwd(),
213
- instructions: oneShotInstructions(goal, false, options)
214
+ instructions: oneShotInstructions(goal, false, options, runPath)
214
215
  });
215
216
  }
216
217
  const bridge = await createMcpBridge(config);
@@ -222,7 +223,7 @@ export async function createOneShotAgent(runPath, goal, config, onApprovalRequir
222
223
  const bgContext = options.backgroundTasks ? await options.backgroundTasks.formatContextForAgent() : "";
223
224
  const agent = new ToolLoopAgent({
224
225
  model: getLanguageModel(config),
225
- instructions: oneShotInstructions(goal, bridge.toolNames.length > 0, options) + bgContext + devtoolsInstructionsBlock(bridge.toolNames),
226
+ instructions: oneShotInstructions(goal, bridge.toolNames.length > 0, options, runPath) + bgContext + devtoolsInstructionsBlock(bridge.toolNames),
226
227
  tools,
227
228
  stopWhen: isLoopFinished()
228
229
  });
package/dist/cli/index.js CHANGED
@@ -29,6 +29,17 @@ import { createMcpBridge } from "../tools/mcp-bridge.js";
29
29
  import { saveGlobalMcpConfig } from "../config/load-config.js";
30
30
  import { runOAuthFlow } from "../tools/mcp-oauth.js";
31
31
  import { initCommand } from "./commands/init.js";
32
+ import { checkForUpdate, formatUpdateNotice } from "../utils/update-check.js";
33
+ // Once per invocation (throttled to a real npm check at most daily): surface an
34
+ // available update. The TUI shows it as an in-app notice; CLI commands print it.
35
+ // Skip for --version/--help so those stay instant (the daily check can spend up
36
+ // to ~3s on the network, and sade prints+exits for these before any command).
37
+ const argv = process.argv.slice(2);
38
+ const wantsUpdateCheck = !argv.some((a) => ["-v", "--version", "-h", "--help"].includes(a));
39
+ const update = wantsUpdateCheck ? await checkForUpdate(pkgVersion) : null;
40
+ const updateNotice = update ? formatUpdateNotice(update) : undefined;
41
+ // The TUI renders the notice in-app, so the finally banner would double it up.
42
+ let noticeShownInApp = false;
32
43
  const prog = sade("scira");
33
44
  prog
34
45
  .version(pkgVersion)
@@ -40,7 +51,8 @@ prog
40
51
  const question = opts._.length > 0 ? opts._.join(" ") : undefined;
41
52
  const config = await loadConfig();
42
53
  if (!question) {
43
- await openTuiHome(config);
54
+ noticeShownInApp = !!updateNotice;
55
+ await openTuiHome(config, updateNotice);
44
56
  return;
45
57
  }
46
58
  requireLlmKeys(config);
@@ -66,7 +78,8 @@ prog
66
78
  const config = await loadConfig();
67
79
  const run = await createRun(question, config);
68
80
  if (opts.tui) {
69
- await openTui(run.path, config);
81
+ noticeShownInApp = !!updateNotice;
82
+ await openTui(run.path, config, updateNotice);
70
83
  }
71
84
  else if (opts.shell) {
72
85
  await openShell(run.path, config);
@@ -87,7 +100,8 @@ prog
87
100
  await openShell(runPath, config);
88
101
  }
89
102
  else {
90
- await openTui(runPath, config);
103
+ noticeShownInApp = !!updateNotice;
104
+ await openTui(runPath, config, updateNotice);
91
105
  }
92
106
  });
93
107
  prog
@@ -132,7 +146,7 @@ prog
132
146
  const runPath = await findRun(runId, config);
133
147
  let output;
134
148
  if (fmt === "md") {
135
- output = await Bun.file(`${runPath}/report.md`).text();
149
+ output = await Bun.file(`${runPath}/report.md`).text().catch(() => "");
136
150
  }
137
151
  else {
138
152
  const { toJson, toCsv } = await import("../export/formatters.js");
@@ -493,3 +507,9 @@ catch (error) {
493
507
  console.error(error instanceof Error ? error.message : String(error));
494
508
  process.exitCode = 1;
495
509
  }
510
+ finally {
511
+ // Reminder after a CLI command finishes. Skipped when the TUI already
512
+ // rendered the notice in-app, so we don't show it twice.
513
+ if (updateNotice && !noticeShownInApp)
514
+ process.stderr.write(`\n\x1b[2m${updateNotice}\x1b[0m\n`);
515
+ }
@@ -19,7 +19,7 @@ export async function openShell(runPath, config) {
19
19
  console.log(await renderStatus(runPath));
20
20
  break;
21
21
  case "/plan":
22
- console.log(await Bun.file(getRunPaths(runPath).plan).text());
22
+ console.log(await Bun.file(getRunPaths(runPath).plan).text().catch(() => "No plan.md yet."));
23
23
  break;
24
24
  case "/run":
25
25
  await runResearchAgent(runPath, state.goal, config);
@@ -2,13 +2,13 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { render } from "ink";
3
3
  import { SciraApp } from "../../ui/ink/SciraApp.js";
4
4
  import { closeAllHarnessSessions } from "../../agent/harness-agent.js";
5
- export async function openTuiHome(config) {
6
- const instance = render(_jsx(SciraApp, { config: config }), { alternateScreen: true, maxFps: 20, exitOnCtrlC: false });
5
+ export async function openTuiHome(config, updateNotice) {
6
+ const instance = render(_jsx(SciraApp, { config: config, updateNotice: updateNotice }), { alternateScreen: true, maxFps: 20, exitOnCtrlC: false });
7
7
  await instance.waitUntilExit();
8
8
  await closeAllHarnessSessions();
9
9
  }
10
- export async function openTui(runPath, config) {
11
- const instance = render(_jsx(SciraApp, { runPath: runPath, config: config }), { alternateScreen: true, maxFps: 20, exitOnCtrlC: false });
10
+ export async function openTui(runPath, config, updateNotice) {
11
+ const instance = render(_jsx(SciraApp, { runPath: runPath, config: config, updateNotice: updateNotice }), { alternateScreen: true, maxFps: 20, exitOnCtrlC: false });
12
12
  await instance.waitUntilExit();
13
13
  await closeAllHarnessSessions();
14
14
  }
@@ -42,15 +42,16 @@ async function listXaiModels() {
42
42
  if (!key)
43
43
  return STATIC_MODELS.xai;
44
44
  try {
45
- // OpenAI-compatible models endpoint
46
- const response = await fetch("https://api.x.ai/v1/models", {
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", {
47
48
  headers: { Authorization: `Bearer ${key}` },
48
49
  signal: AbortSignal.timeout(15000)
49
50
  });
50
51
  if (!response.ok)
51
- throw new Error(`xAI models endpoint returned ${response.status}`);
52
+ throw new Error(`xAI language-models endpoint returned ${response.status}`);
52
53
  const payload = await response.json();
53
- const models = (payload.data ?? []).map((m) => ({ id: m.id }));
54
+ const models = (payload.models ?? payload.data ?? []).map((m) => ({ id: m.id }));
54
55
  return models.length > 0 ? models : STATIC_MODELS.xai;
55
56
  }
56
57
  catch {
@@ -2,6 +2,7 @@ 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,
@@ -27,15 +28,21 @@ export async function createRun(goal, config, projectRoot = process.cwd()) {
27
28
  await mkdir(paths.artifacts, { recursive: true });
28
29
  await mkdir(paths.snapshots, { recursive: true });
29
30
  await Bun.write(paths.goal, `# Goal\n\n${goal}\n`);
30
- await Bun.write(paths.plan, "# Research Plan\n\nPending plan generation.\n");
31
31
  await Bun.write(paths.research, researchInstructions());
32
32
  await Bun.write(paths.scope, `${JSON.stringify({ goal, maxSources: config.maxSources, citationPolicy: config.citationPolicy }, null, 2)}\n`);
33
33
  await Bun.write(paths.progress, progressText("created", "Generate and approve research plan."));
34
- await Bun.write(paths.sources, "");
35
- await Bun.write(paths.claims, "");
36
- await Bun.write(paths.notes, "# Notes\n\n");
37
- await Bun.write(paths.report, "# Report\n\nDraft not generated yet.\n");
38
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
  }
@@ -55,6 +55,14 @@ function projectRootFromPath(absPath) {
55
55
  return normalized.slice(0, -"/.scira".length) || "/";
56
56
  return undefined;
57
57
  }
58
+ /** Absolute form of `candidate` if it lands inside the run directory, else null. */
59
+ function absInsideRun(runPath, workspacePath, raw) {
60
+ if (!isAbsolute(raw) && !workspacePath)
61
+ return null; // can't resolve a relative path without a root
62
+ const abs = isAbsolute(raw) ? resolve(raw) : resolve(workspacePath, raw);
63
+ const rel = relative(resolve(runPath), abs);
64
+ return !rel.startsWith("..") && !isAbsolute(rel) ? abs : null;
65
+ }
58
66
  export function resolveToolPath(runPath, workspacePath, candidate) {
59
67
  const raw = candidate.trim();
60
68
  if (raw.startsWith("run:")) {
@@ -62,6 +70,13 @@ export function resolveToolPath(runPath, workspacePath, candidate) {
62
70
  const abs = resolveInsideRun(runPath, inner);
63
71
  return { abs, displayPath: inner, scope: "run" };
64
72
  }
73
+ // A full/absolute path that points into the run directory routes to the run —
74
+ // so the model can use the run dir path we hand it, not only bare names.
75
+ const runAbs = absInsideRun(runPath, workspacePath, raw);
76
+ if (runAbs) {
77
+ const display = relative(resolve(runPath), runAbs) || ".";
78
+ return { abs: runAbs, displayPath: display, scope: "run" };
79
+ }
65
80
  if (workspacePath && !isRunArtifactPath(raw)) {
66
81
  const abs = resolveInsideWorkspace(workspacePath, raw);
67
82
  return { abs, displayPath: raw, scope: "workspace" };
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { useCallback, useMemo, useRef, useState } from "react";
3
3
  import { Box, Text, useApp, useStdout, useStdin } from "ink";
4
- import { CHAT_COMMANDS, MENU_VISIBLE, SPINNER_FRAMES, LOADING_PHRASES } from "./constants.js";
4
+ import { COMMAND_GROUPS, KEY_HINTS, MENU_VISIBLE, SPINNER_FRAMES, LOADING_PHRASES } from "./constants.js";
5
5
  import { CWD_DISPLAY, wrapText, wrapInputWithCursor, loadInputHistory, saveInputHistory, linkAtMouseColumn, openExternalUrl } from "./lib/utils.js";
6
6
  import { deleteRun } from "../../storage/run-store.js";
7
7
  import { saveGlobalConfig } from "../../config/load-config.js";
@@ -20,7 +20,7 @@ import { useSubmit } from "./hooks/use-submit.js";
20
20
  import { useSession } from "./hooks/use-session.js";
21
21
  import { useMouse } from "./hooks/use-mouse.js";
22
22
  import { ThemeProvider, useTheme } from "./hooks/use-theme.js";
23
- export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
23
+ export function SciraApp({ runPath: initialRunPath, config: initialConfig, updateNotice }) {
24
24
  const { exit } = useApp();
25
25
  const { stdout } = useStdout();
26
26
  const { stdin } = useStdin();
@@ -37,7 +37,7 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
37
37
  const [screen, setScreen] = useState(initialRunPath ? "chat" : "home");
38
38
  const [currentRunPath, setCurrentRunPath] = useState(initialRunPath);
39
39
  const [config, setConfig] = useState(initialConfig);
40
- const [notice, setNotice] = useState("");
40
+ const [notice, setNotice] = useState(updateNotice ?? "");
41
41
  const [pendingRerun, setPendingRerun] = useState(false);
42
42
  const [mcpOpen, setMcpOpen] = useState(false);
43
43
  const [mcpRowIdx, setMcpRowIdx] = useState(0);
@@ -338,7 +338,7 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
338
338
  const caret = Math.max(0, Math.min(cursorPos, inputText.length));
339
339
  const { lines: inputLines, cursorLine, cursorCol } = wrapInputWithCursor(rawInputText, textWidth, showCursor ? caret : -1);
340
340
  const commandMenuHeight = activeSuggestions.length > 0 ? Math.min(MENU_VISIBLE, activeSuggestions.length) + 3 : 0;
341
- const helpHeight = helpOpen ? Math.min(14, CHAT_COMMANDS.length + 4) : 0;
341
+ const helpHeight = helpOpen ? KEY_HINTS.length + COMMAND_GROUPS.length + 7 : 0;
342
342
  const approvalPreviewLines = approvalPending
343
343
  ? Math.min(5, wrapText(approvalPending.description, Math.max(10, innerWidth - 4)).length)
344
344
  : 0;
@@ -447,7 +447,7 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig }) {
447
447
  const activeUsage = usage[config.model];
448
448
  const themed = (node) => (_jsx(ThemeProvider, { config: config, children: node }));
449
449
  if (screen === "home") {
450
- return themed(_jsxs(Box, { flexDirection: "column", width: cols, height: rows, paddingX: 2, children: [_jsx(TipCycler, { setTipIndex: setTipIndex }), (!sessionsModalOpen || mcpOpen) && stdout !== undefined && stdin !== undefined && (_jsx(MouseTracker, { stdout: stdout, stdin: stdin, onData: handleMouseData, onUnmount: () => setHoveredIdx(null) })), busy && _jsx(AnimationTick, { setBlink: setBlink, setFrame: setFrame, setReasoningTick: setReasoningTick }), _jsx(TopBar, { screen: screen, runState: runState, fullMode: fullMode, planMode: planMode, activeUsage: activeUsage, busy: busy, frame: frame, cwdDisplay: CWD_DISPLAY, config: config }), _jsx(HomeScreen, { cols: cols, rows: rows, sessions: sessions, selectedIdx: selectedIdx, hoveredIdx: hoveredIdx, heroHidden: heroHidden, notice: notice, tipIndex: tipIndex, commandMenuHeight: commandMenuHeight, mcpOpen: mcpOpen, sessionsModalOpen: sessionsModalOpen, sessionsModalIdx: sessionsModalIdx, inputText: inputText, config: config, modelName: modelName, clickMapRef: clickMapRef, hoverMapRef: hoverMapRef, setSelectedIdx: setSelectedIdx, setSessionsModalOpen: setSessionsModalOpen, setSessionsModalIdx: setSessionsModalIdx, setNotice: setNotice, openRun: openRun, submitHome: submitHome, exit: exit }), _jsxs(Box, { flexDirection: "column", paddingBottom: 1, children: [_jsx(CommandMenuBox, { activeSuggestions: activeSuggestions, activeSuggestionKind: activeSuggestionKind, commandMenuIndex: commandMenuIndex, innerWidth: innerWidth, sessions: sessions, config: config }), _jsx(InputBar, { inputLines: inputLines, cursorLine: cursorLine, cursorCol: cursorCol, showCursor: showCursor, approvalPending: inputBlocked, busy: busy, frame: frame, boxWidth: boxWidth, modelName: modelName, planMode: planMode, config: config }), _jsx(HintLine, { screen: screen, busy: busy, modeLabel: pendingPlanMode ? "PLAN MODE" : "", modeColor: "cyan", config: config })] }), _jsx(MenuDialog, { menu: menu, cols: cols, rows: rows, config: config }), _jsx(McpDialog, { open: mcpOpen, config: config, cols: cols, rows: rows, selectedIdx: mcpRowIdx, hoveredIdx: hoveredIdx, onToggle: handleMcpToggle, onRemove: handleMcpRemove, clickMapRef: clickMapRef, hoverMapRef: hoverMapRef })] }));
450
+ return themed(_jsxs(Box, { flexDirection: "column", width: cols, height: rows, paddingX: 2, children: [_jsx(TipCycler, { setTipIndex: setTipIndex }), (!sessionsModalOpen || mcpOpen) && stdout !== undefined && stdin !== undefined && (_jsx(MouseTracker, { stdout: stdout, stdin: stdin, onData: handleMouseData, onUnmount: () => setHoveredIdx(null) })), busy && _jsx(AnimationTick, { setBlink: setBlink, setFrame: setFrame, setReasoningTick: setReasoningTick }), _jsx(TopBar, { screen: screen, runState: runState, fullMode: fullMode, planMode: planMode, activeUsage: activeUsage, busy: busy, frame: frame, cwdDisplay: CWD_DISPLAY, config: config }), _jsx(HomeScreen, { cols: cols, rows: rows, sessions: sessions, selectedIdx: selectedIdx, hoveredIdx: hoveredIdx, heroHidden: heroHidden, notice: notice, tipIndex: tipIndex, commandMenuHeight: commandMenuHeight + helpHeight, mcpOpen: mcpOpen, sessionsModalOpen: sessionsModalOpen, sessionsModalIdx: sessionsModalIdx, inputText: inputText, config: config, modelName: modelName, clickMapRef: clickMapRef, hoverMapRef: hoverMapRef, setSelectedIdx: setSelectedIdx, setSessionsModalOpen: setSessionsModalOpen, setSessionsModalIdx: setSessionsModalIdx, setNotice: setNotice, openRun: openRun, submitHome: submitHome, exit: exit }), _jsxs(Box, { flexDirection: "column", paddingBottom: 1, children: [_jsx(CommandMenuBox, { activeSuggestions: activeSuggestions, activeSuggestionKind: activeSuggestionKind, commandMenuIndex: commandMenuIndex, innerWidth: innerWidth, sessions: sessions, config: config }), _jsx(HelpBox, { open: helpOpen, innerWidth: innerWidth, config: config }), _jsx(InputBar, { inputLines: inputLines, cursorLine: cursorLine, cursorCol: cursorCol, showCursor: showCursor, approvalPending: inputBlocked, busy: busy, frame: frame, boxWidth: boxWidth, modelName: modelName, planMode: planMode, config: config }), _jsx(HintLine, { screen: screen, busy: busy, modeLabel: pendingPlanMode ? "PLAN MODE" : "", modeColor: "cyan", config: config })] }), _jsx(MenuDialog, { menu: menu, cols: cols, rows: rows, config: config }), _jsx(McpDialog, { open: mcpOpen, config: config, cols: cols, rows: rows, selectedIdx: mcpRowIdx, hoveredIdx: hoveredIdx, onToggle: handleMcpToggle, onRemove: handleMcpRemove, clickMapRef: clickMapRef, hoverMapRef: hoverMapRef })] }));
451
451
  }
452
452
  return themed(_jsxs(Box, { flexDirection: "column", width: cols, height: rows, paddingX: 2, children: [stdout !== undefined && stdin !== undefined && (_jsx(MouseTracker, { stdout: stdout, stdin: stdin, onData: handleMouseData, onUnmount: () => setHoveredIdx(null) })), busy && _jsx(AnimationTick, { setBlink: setBlink, setFrame: setFrame, setReasoningTick: setReasoningTick }), _jsx(TopBar, { screen: screen, runState: runState, fullMode: fullMode, planMode: planMode, activeUsage: activeUsage, busy: busy, frame: frame, cwdDisplay: CWD_DISPLAY, config: config }), _jsx(Box, { flexDirection: "column", height: contentRows, flexShrink: 0, justifyContent: feedLines.length > contentRows ? "flex-end" : "flex-start", paddingTop: 1, overflow: "hidden", children: visibleLines }), _jsxs(ChatInputChrome, { children: [_jsx(CommandMenuBox, { activeSuggestions: activeSuggestions, activeSuggestionKind: activeSuggestionKind, commandMenuIndex: commandMenuIndex, innerWidth: innerWidth, sessions: sessions, config: config }), _jsx(HelpBox, { open: helpOpen, innerWidth: innerWidth, config: config }), approvalPending && _jsx(ApprovalBox, { toolName: approvalPending.toolName, description: approvalPending.description, innerWidth: innerWidth, config: config }), linkPending && _jsx(LinkOpenBox, { url: linkPending.url, innerWidth: innerWidth, config: config }), _jsx(InputBar, { inputLines: inputLines, cursorLine: cursorLine, cursorCol: cursorCol, showCursor: showCursor, approvalPending: inputBlocked, busy: busy, frame: frame, boxWidth: boxWidth, modelName: modelName, planMode: planMode, config: config }), _jsx(HintLine, { screen: screen, busy: busy, modeLabel: fullMode ? "FULL RESEARCH" : planMode ? "PLAN MODE" : "", modeColor: fullMode ? "magenta" : "cyan", scrollLabel: scrollLabel, hasDoneGroups: doneGroupKeys.length > 0, hasFocusedGroup: focusedGroupKey !== null, hasLinkHover: hasLinkHover || !!linkPending, alwaysAllowLinks: config.alwaysAllowLinks, config: config })] }), _jsx(MenuDialog, { menu: menu, cols: cols, rows: rows, config: config }), _jsx(McpDialog, { open: mcpOpen, config: config, cols: cols, rows: rows, selectedIdx: mcpRowIdx, hoveredIdx: hoveredIdx, onToggle: handleMcpToggle, onRemove: handleMcpRemove, clickMapRef: clickMapRef, hoverMapRef: hoverMapRef })] }));
453
453
  }
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- import { SPINNER_FRAMES, CHAT_COMMANDS, COMMAND_DESCRIPTIONS, MENU_VISIBLE } from "../constants.js";
3
+ import { SPINNER_FRAMES, COMMAND_DESCRIPTIONS, COMMAND_GROUPS, KEY_HINTS, MENU_VISIBLE } from "../constants.js";
4
4
  import { fmtTokens, wrapText, displayWidth } from "../lib/utils.js";
5
5
  import { LLM_PROVIDER_LABELS } from "../../../providers/llm/registry.js";
6
6
  import { useTheme } from "../hooks/use-theme.js";
@@ -78,7 +78,10 @@ export function HelpBox({ open, innerWidth, config }) {
78
78
  const theme = useTheme();
79
79
  if (!open)
80
80
  return null;
81
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border, paddingX: 1, children: [_jsxs(Text, { bold: true, color: theme.text, children: ["help ", _jsx(Text, { color: theme.textDim, children: "esc close" })] }), _jsx(Text, { color: theme.textDim, children: "─".repeat(Math.max(10, innerWidth - 6)) }), _jsx(Text, { color: theme.textDim, children: "scroll \u2191/\u2193 k/j u/d pgup/pgdn" }), _jsx(Text, { color: theme.textDim, children: "autocomplete / commands \u00B7 @ files \u00B7 # sessions" }), _jsx(Text, { color: theme.textDim, children: "─".repeat(Math.max(10, innerWidth - 6)) }), CHAT_COMMANDS.map((cmd) => (_jsxs(Box, { gap: 2, children: [_jsx(Text, { color: theme.accent, children: cmd }), _jsx(Text, { color: theme.textDim, children: COMMAND_DESCRIPTIONS[cmd] })] }, cmd)))] }));
81
+ const rule = "─".repeat(Math.max(10, innerWidth - 6));
82
+ const keyW = Math.max(...KEY_HINTS.map((h) => h.keys.length));
83
+ const labelW = Math.max(...COMMAND_GROUPS.map((g) => g.label.length));
84
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border, paddingX: 1, children: [_jsxs(Text, { bold: true, color: theme.text, children: ["help ", _jsx(Text, { color: theme.textDim, children: "\u00B7 esc to close \u00B7 type / to search a command" })] }), _jsx(Text, { color: theme.textDim, children: rule }), _jsx(Text, { bold: true, color: theme.textDim, children: "keys" }), KEY_HINTS.map((h) => (_jsxs(Box, { gap: 2, children: [_jsx(Text, { color: theme.accent, children: h.keys.padEnd(keyW) }), _jsx(Text, { color: theme.textDim, children: h.action })] }, h.keys))), _jsx(Text, { color: theme.textDim, children: rule }), _jsx(Text, { bold: true, color: theme.textDim, children: "commands" }), COMMAND_GROUPS.map((g) => (_jsxs(Box, { gap: 2, children: [_jsx(Text, { color: theme.textDim, children: g.label.padEnd(labelW) }), _jsx(Text, { color: theme.accent, wrap: "truncate", children: g.commands.join(" ") })] }, g.label)))] }));
82
85
  }
83
86
  export function LinkOpenBox({ url, innerWidth, config }) {
84
87
  const theme = useTheme();
@@ -4,6 +4,21 @@ export const FILE_MENTION_MAX_CHARS = 20000;
4
4
  export const FILE_MENTION_SKIP = new Set([".git", "node_modules", "dist", ".scira"]);
5
5
  export const PROVIDERS = ["parallel", "exa", "firecrawl"];
6
6
  export const CHAT_COMMANDS = ["/help", "/home", "/new", "/plan", "/rerun", "/report", "/sources", "/claims", "/why", "/mcp", "/copy", "/usage", "/rename", "/model", "/llm", "/provider", "/thinking", "/reasoning", "/theme", "/links", "/key", "/keys", "/stop", "/back", "/quit"];
7
+ /** Commands grouped by purpose, for the /help reference. */
8
+ export const COMMAND_GROUPS = [
9
+ { label: "Model", commands: ["/llm", "/model", "/provider", "/plan", "/thinking", "/reasoning"] },
10
+ { label: "Session", commands: ["/new", "/rerun", "/rename", "/report", "/sources", "/claims", "/why", "/copy", "/usage"] },
11
+ { label: "Setup", commands: ["/key", "/keys", "/mcp", "/theme", "/links"] },
12
+ { label: "Go", commands: ["/home", "/back", "/stop", "/quit"] },
13
+ ];
14
+ /** Keyboard shortcuts shown in /help (not discoverable via the `/` autocomplete). */
15
+ export const KEY_HINTS = [
16
+ { keys: "↑↓ jk u/d pgup/dn", action: "scroll" },
17
+ { keys: "^C ^C / ^D", action: "quit" },
18
+ { keys: "esc", action: "clear input / close" },
19
+ { keys: "/ @ #", action: "commands · files · sessions" },
20
+ { keys: "[ ] C", action: "navigate / toggle tool groups" },
21
+ ];
7
22
  /** Slash commands that take an argument; ⏎ from the menu appends a space instead of running. */
8
23
  export const COMMANDS_NEEDING_ARGS = new Set(["/theme", "/key", "/rename", "/why", "/links"]);
9
24
  export const COMMAND_DESCRIPTIONS = {
@@ -43,6 +58,7 @@ export const TOOL_ICONS = {
43
58
  verifyClaim: "✓",
44
59
  webSearch: "⌕",
45
60
  multiWebSearch: "⌕",
61
+ fileChange: "✎",
46
62
  readUrl: "↗",
47
63
  listSkills: "★",
48
64
  readSkill: "★",
@@ -222,9 +222,15 @@ function formatListSkills(result, width, theme) {
222
222
  });
223
223
  }
224
224
  function formatShellOutput(result, width, theme) {
225
- if (!result.trim())
225
+ // Codex returns `{ exitCode, output }`; Claude returns the output string directly.
226
+ let text = result;
227
+ const obj = parseObj(result);
228
+ if (obj && typeof obj.output === "string") {
229
+ text = typeof obj.exitCode === "number" && obj.exitCode !== 0 ? `[exit ${obj.exitCode}]\n${obj.output}` : obj.output;
230
+ }
231
+ if (!text.trim())
226
232
  return [[seg("(no output)", { dim: true, color: theme.textDim })]];
227
- return result.split("\n").flatMap((line) => plainLines(line, width, { color: theme.textDim }));
233
+ return text.split("\n").flatMap((line) => plainLines(line, width, { color: theme.textDim }));
228
234
  }
229
235
  function formatFileContent(result, width, theme) {
230
236
  const rows = result.split("\n");
@@ -521,10 +527,20 @@ function formatBuiltinBody(real, rawInput, result, width, theme) {
521
527
  return formatSubagentBody(input, result, width, theme);
522
528
  case "ToolSearch":
523
529
  return formatToolSearchBody(input, result, width, theme);
530
+ case "fileChange":
531
+ return formatFileChangeBody(input ?? parseObj(result), theme);
524
532
  default:
525
533
  return null;
526
534
  }
527
535
  }
536
+ /** Codex/Claude file mutation event → a single colored "<event> <path>" line. */
537
+ function formatFileChangeBody(fc, theme) {
538
+ if (!fc)
539
+ return null;
540
+ const event = String(fc.event ?? "change");
541
+ const color = event === "delete" ? theme.error : event === "create" ? theme.success : theme.accent;
542
+ return [[seg(`${event} `, { color }), seg(String(fc.path ?? ""), { color: theme.text })]];
543
+ }
528
544
  /** Multi-line formatted tool output for the feed panel. */
529
545
  export function formatToolResultLines(rawName, inputSummary, rawResult, status, contentWidth, theme, expanded = true, rawInput) {
530
546
  const name = canonicalToolName(rawName);
@@ -324,6 +324,8 @@ export function summarizeToolInput(rawName, input) {
324
324
  return String(obj.name ?? obj.skill ?? "");
325
325
  if (name === "createClaim" || name === "verifyClaim")
326
326
  return String(obj.id ?? "");
327
+ if (name === "fileChange")
328
+ return `${String(obj.event ?? "change")} ${String(obj.path ?? "")}`.trim();
327
329
  if (stripped === "ToolSearch")
328
330
  return String(obj.query ?? "");
329
331
  if (stripped === "Task" || stripped === "Agent")
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scira/cli",
3
- "version": "0.1.6",
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",