@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.
- package/dist/agent/harness-agent.js +12 -2
- package/dist/agent/main-agent.js +12 -11
- package/dist/cli/index.js +24 -4
- package/dist/cli/shell/shell.js +1 -1
- package/dist/cli/shell/tui.js +4 -4
- package/dist/providers/llm/models.js +5 -4
- package/dist/storage/run-store.js +12 -5
- package/dist/tools/workspace.js +15 -0
- package/dist/ui/ink/SciraApp.js +5 -5
- package/dist/ui/ink/components/overlays.js +5 -2
- package/dist/ui/ink/constants.js +16 -0
- package/dist/ui/ink/lib/tool-result.js +18 -2
- package/dist/ui/ink/lib/utils.js +2 -0
- package/dist/utils/update-check.js +63 -0
- package/package.json +1 -1
|
@@ -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
|
|
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) {
|
package/dist/agent/main-agent.js
CHANGED
|
@@ -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
|
-
-
|
|
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
|
|
51
|
-
-
|
|
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 ?
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/cli/shell/shell.js
CHANGED
|
@@ -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);
|
package/dist/cli/shell/tui.js
CHANGED
|
@@ -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
|
-
//
|
|
46
|
-
|
|
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
|
}
|
package/dist/tools/workspace.js
CHANGED
|
@@ -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" };
|
package/dist/ui/ink/SciraApp.js
CHANGED
|
@@ -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 {
|
|
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 ?
|
|
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,
|
|
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
|
-
|
|
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();
|
package/dist/ui/ink/constants.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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);
|
package/dist/ui/ink/lib/utils.js
CHANGED
|
@@ -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
|
+
}
|