@scira/cli 0.1.1 → 0.1.3

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 (38) hide show
  1. package/README.md +54 -10
  2. package/dist/agent/background-tasks.js +173 -0
  3. package/dist/agent/research-agent.js +95 -38
  4. package/dist/agent/todos.js +140 -0
  5. package/dist/agent/tools.js +146 -143
  6. package/dist/agent/tools.test.js +33 -0
  7. package/dist/agent/workspace.js +85 -0
  8. package/dist/cli/commands/init.js +51 -39
  9. package/dist/cli/index.js +30 -14
  10. package/dist/config/env-guide.js +151 -0
  11. package/dist/config/env-guide.test.js +18 -0
  12. package/dist/config/env-store.js +53 -0
  13. package/dist/config/env-store.test.js +60 -0
  14. package/dist/tools/agent-tools.js +621 -0
  15. package/dist/tools/background-tasks.js +261 -0
  16. package/dist/tools/bash-policy.test.js +38 -0
  17. package/dist/tools/file-tools.js +6 -1
  18. package/dist/tools/search-web.js +24 -6
  19. package/dist/tools/search-web.test.js +24 -0
  20. package/dist/tools/todos.js +140 -0
  21. package/dist/tools/workspace.js +91 -0
  22. package/dist/tools/workspace.test.js +75 -0
  23. package/dist/tools/x-search.js +142 -0
  24. package/dist/ui/ink/SciraApp.js +11 -8
  25. package/dist/ui/ink/components/overlays.js +4 -4
  26. package/dist/ui/ink/constants.js +11 -3
  27. package/dist/ui/ink/hooks/use-agent-turn.js +24 -5
  28. package/dist/ui/ink/hooks/use-keyboard.js +3 -0
  29. package/dist/ui/ink/hooks/use-session.js +5 -3
  30. package/dist/ui/ink/hooks/use-settings.js +10 -8
  31. package/dist/ui/ink/hooks/use-submit.js +13 -2
  32. package/dist/ui/ink/hooks/use-theme.js +1 -1
  33. package/dist/ui/ink/lib/tool-result.js +72 -5
  34. package/dist/ui/ink/lib/utils.js +40 -3
  35. package/dist/ui/ink/theme-context.js +29 -26
  36. package/dist/ui/ink/theme.js +36 -9
  37. package/dist/ui/ink/theme.test.js +32 -5
  38. package/package.json +9 -6
@@ -8,7 +8,9 @@ export const DEFAULT_COLLAPSED_TOOLS = new Set([
8
8
  "readSkill",
9
9
  "bash",
10
10
  "runWorkspaceCommand",
11
+ "todo",
11
12
  "grepWorkspace",
13
+ "xSearch",
12
14
  ]);
13
15
  export function feedToolItemId(feedIndex, toolCallId) {
14
16
  return toolCallId ?? `feed-${feedIndex}`;
@@ -80,9 +82,15 @@ function searchHitToMarkdown(hit) {
80
82
  }
81
83
  function webSearchQueriesMarkdown(groups) {
82
84
  const queries = groups.map((g) => g.query?.trim()).filter((q) => Boolean(q));
83
- if (queries.length === 0)
84
- return "";
85
- return `## Queries\n\n${queries.map((q, i) => `${i + 1}. ${q}`).join("\n")}`;
85
+ const errors = groups.map((g) => g.error?.trim()).filter((e) => Boolean(e));
86
+ const parts = [];
87
+ if (queries.length > 0) {
88
+ parts.push(`## Queries\n\n${queries.map((q, i) => `${i + 1}. ${q}`).join("\n")}`);
89
+ }
90
+ if (errors.length > 0) {
91
+ parts.push(`## Errors\n\n${errors.map((e, i) => `${i + 1}. ${e}`).join("\n")}`);
92
+ }
93
+ return parts.join("\n\n");
86
94
  }
87
95
  function webSearchSourcesMarkdown(hits) {
88
96
  if (hits.length === 0)
@@ -199,10 +207,57 @@ function formatGrep(result, width, theme) {
199
207
  return plainLines(row, width, { color: theme.textDim });
200
208
  });
201
209
  }
210
+ function xPostToMarkdown(p) {
211
+ const label = p.handle ? `@${p.handle}` : p.url;
212
+ let line = `- [${label}](${p.url})`;
213
+ if (p.text) {
214
+ const snippet = p.text.replace(/\s+/gu, " ").trim();
215
+ if (snippet)
216
+ line += `\n *${snippet}*`;
217
+ }
218
+ return line;
219
+ }
220
+ function xSearchPostsMarkdown(groups) {
221
+ const queries = groups.map((g) => g.query?.trim()).filter((q) => Boolean(q));
222
+ const errors = groups.map((g) => g.error?.trim()).filter((e) => Boolean(e));
223
+ const allPosts = groups.flatMap((g) => g.posts ?? []);
224
+ const dateRange = groups[0]?.dateRange;
225
+ const parts = [];
226
+ if (queries.length > 0) {
227
+ parts.push(`## Queries\n\n${queries.map((q, i) => `${i + 1}. ${q}`).join("\n")}`);
228
+ }
229
+ if (dateRange) {
230
+ parts.push(`*${dateRange}*`);
231
+ }
232
+ if (errors.length > 0) {
233
+ parts.push(`## Errors\n\n${errors.map((e, i) => `${i + 1}. ${e}`).join("\n")}`);
234
+ }
235
+ if (allPosts.length > 0) {
236
+ const postLines = allPosts.map(xPostToMarkdown).join("\n\n");
237
+ parts.push(`## Posts (${allPosts.length})\n\n${postLines}`);
238
+ }
239
+ return parts.join("\n\n");
240
+ }
241
+ function formatXSearch(result, width, theme) {
242
+ try {
243
+ const groups = JSON.parse(result);
244
+ if (!Array.isArray(groups))
245
+ return plainLines(result, width, { color: theme.textDim });
246
+ const md = xSearchPostsMarkdown(groups);
247
+ if (!md.trim())
248
+ return plainLines(result, width, { color: theme.textDim });
249
+ return markdownToSegLines(md, width, theme);
250
+ }
251
+ catch {
252
+ return plainLines(result, width, { color: theme.textDim });
253
+ }
254
+ }
202
255
  function formatBody(name, result, width, theme) {
203
256
  switch (name) {
204
257
  case "webSearch":
205
258
  return formatWebSearch(result, width, theme);
259
+ case "xSearch":
260
+ return formatXSearch(result, width, theme);
206
261
  case "readUrl":
207
262
  return formatReadUrl(result, width, theme);
208
263
  case "listSkills":
@@ -262,6 +317,18 @@ export function formatToolResultPreview(name, inputSummary, result, status) {
262
317
  }
263
318
  catch { /* fall through */ }
264
319
  }
320
+ if (name === "xSearch") {
321
+ try {
322
+ const groups = JSON.parse(result);
323
+ if (Array.isArray(groups)) {
324
+ const queries = groups.map((g) => g.query?.trim()).filter(Boolean);
325
+ const total = groups.reduce((n, g) => n + (g.posts?.length ?? 0), 0);
326
+ const q = queries.length > 0 ? queries.slice(0, 2).join(" · ") + (queries.length > 2 ? ` +${queries.length - 2}` : "") : input;
327
+ return q ? `${q} · ${total} posts` : `${total} posts`;
328
+ }
329
+ }
330
+ catch { /* fall through */ }
331
+ }
265
332
  if (name === "readFile" || name === "readWorkspaceFile") {
266
333
  const lines = result.split("\n").length;
267
334
  return input ? `${input} · ${lines} lines` : `${lines} lines`;
@@ -280,12 +347,12 @@ export function formatToolResultLines(name, inputSummary, result, status, conten
280
347
  const width = Math.max(16, contentWidth);
281
348
  const lines = [];
282
349
  const input = inputSummary.replace(/\s+/gu, " ").trim();
283
- const skipInput = name === "webSearch" && status === "done" && Boolean(result?.trim());
350
+ const skipInput = (name === "webSearch" || name === "xSearch") && status === "done" && Boolean(result?.trim());
284
351
  if (input && !skipInput) {
285
352
  if (name === "bash" || name === "runWorkspaceCommand") {
286
353
  lines.push([seg("$ ", { color: theme.accent }), seg(input, { color: theme.text })]);
287
354
  }
288
- else if (name === "webSearch") {
355
+ else if (name === "webSearch" || name === "xSearch") {
289
356
  lines.push(...markdownToSegLines(webSearchRunningMarkdown(input), width, theme));
290
357
  }
291
358
  else if (name === "readUrl") {
@@ -219,9 +219,15 @@ function toolOutputText(output) {
219
219
  }
220
220
  export function summarizeToolInput(name, input) {
221
221
  const obj = (input ?? {});
222
- if (name === "bash" || name === "runWorkspaceCommand")
222
+ if (name === "bash" || name === "runBash" || name === "runWorkspaceCommand") {
223
+ const action = obj.action;
224
+ if (action && action !== "run")
225
+ return `${action}${obj.taskId ? ` ${obj.taskId}` : ""}`;
223
226
  return String(obj.command ?? "");
224
- if (name === "webSearch") {
227
+ }
228
+ if (name === "todo")
229
+ return `${String(obj.action ?? "list")}${obj.id ? ` ${obj.id}` : ""}`;
230
+ if (name === "webSearch" || name === "xSearch") {
225
231
  const queries = Array.isArray(obj.queries) ? obj.queries : [];
226
232
  return queries.length > 0 ? queries.slice(0, 2).join(" · ") + (queries.length > 2 ? ` +${queries.length - 2}` : "") : String(obj.query ?? "");
227
233
  }
@@ -260,6 +266,25 @@ export function summarizeToolOutput(name, output) {
260
266
  }
261
267
  catch { /* fall through */ }
262
268
  }
269
+ if (name === "xSearch") {
270
+ try {
271
+ const parsed = JSON.parse(text);
272
+ if (Array.isArray(parsed)) {
273
+ const posts = parsed.flatMap((s) => s.posts ?? []);
274
+ const total = posts.length;
275
+ if (total === 0)
276
+ return "no posts";
277
+ const handles = posts
278
+ .map((p) => p.handle)
279
+ .filter((h) => Boolean(h))
280
+ .slice(0, 3)
281
+ .map((h) => `@${h}`);
282
+ const head = `${total} post${total === 1 ? "" : "s"}`;
283
+ return handles.length > 0 ? `${head} · ${handles.join(", ")}` : head;
284
+ }
285
+ }
286
+ catch { /* fall through */ }
287
+ }
263
288
  if (name === "readUrl") {
264
289
  const titleMatch = text.match(/^#\s+(.+)/m);
265
290
  if (titleMatch?.[1])
@@ -284,12 +309,24 @@ export function summarizeToolOutput(name, output) {
284
309
  if (name === "writeFile" || name === "writeWorkspaceFile" || name === "editFile" || name === "editWorkspaceFile") {
285
310
  return oneLine(text, 120) || "ok";
286
311
  }
287
- if (name === "bash" || name === "runWorkspaceCommand") {
312
+ if (name === "bash" || name === "runBash" || name === "runWorkspaceCommand") {
313
+ if (text.startsWith("Started background task"))
314
+ return oneLine(text, 120);
315
+ if (text.startsWith("No background tasks") || text.includes("[running]") || text.includes("[exited]")) {
316
+ return oneLine(text.split("\n")[0] ?? text, 120);
317
+ }
288
318
  const lines = text.split("\n").filter((l) => l.trim());
289
319
  if (lines.length === 0)
290
320
  return "done";
291
321
  return lines.slice(-3).map((l) => oneLine(l, 80)).join(" · ").slice(0, 200);
292
322
  }
323
+ if (name === "todo") {
324
+ const lines = text.split("\n").filter((l) => l.trim());
325
+ if (lines.length === 0)
326
+ return "no todos";
327
+ const active = lines.filter((l) => l.includes("[ ]") || l.includes("[~]")).length;
328
+ return `${lines.length} todo${lines.length === 1 ? "" : "s"}${active > 0 ? ` · ${active} open` : ""}`;
329
+ }
293
330
  if (name === "listWorkspaceDir") {
294
331
  const lines = text.split("\n").filter((l) => l.trim());
295
332
  const preview = lines.slice(0, 3).map((l) => oneLine(l, 40)).join(", ");
@@ -1,33 +1,36 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import React, { createContext, useContext, useMemo, useState } from "react";
3
- import { probeTerminalTheme } from "./terminal-probe.js";
4
- import { detectTerminalTheme, getTheme, getThemeFromResolved, watchAutoThemeChanges, } from "./theme.js";
5
- const ThemeCtx = createContext(getTheme("auto"));
6
- export function ThemeProvider({ config, stdin, stdout, children }) {
7
- const [autoResolved, setAutoResolved] = useState(detectTerminalTheme);
8
- const [probed, setProbed] = useState(undefined);
3
+ import { detectTerminalTheme, getThemeFromResolved, resolveRenderingAppearance, watchAutoThemeChanges, } from "./theme.js";
4
+ const initialTerminal = detectTerminalTheme();
5
+ const ThemeCtx = createContext({
6
+ colors: getThemeFromResolved(initialTerminal),
7
+ terminalAppearance: initialTerminal,
8
+ renderingAppearance: initialTerminal,
9
+ });
10
+ export function ThemeProvider({ config, children }) {
11
+ const [terminalAppearance, setTerminalAppearance] = useState(detectTerminalTheme);
9
12
  React.useEffect(() => {
10
- if (config.theme !== "auto") {
11
- setProbed(undefined);
12
- return;
13
- }
14
- const sync = () => {
15
- void (async () => {
16
- const live = stdin && stdout ? await probeTerminalTheme(stdin, stdout) : undefined;
17
- setProbed((prev) => (prev === live ? prev : live));
18
- const next = live ?? detectTerminalTheme();
19
- setAutoResolved((prev) => (prev === next ? prev : next));
20
- })();
13
+ return watchAutoThemeChanges(() => {
14
+ const next = detectTerminalTheme();
15
+ setTerminalAppearance((prev) => (prev === next ? prev : next));
16
+ });
17
+ }, []);
18
+ const value = useMemo(() => {
19
+ const renderingAppearance = resolveRenderingAppearance(config.theme, terminalAppearance);
20
+ return {
21
+ colors: getThemeFromResolved(renderingAppearance),
22
+ terminalAppearance,
23
+ renderingAppearance,
21
24
  };
22
- return watchAutoThemeChanges(sync);
23
- }, [config.theme, stdin, stdout]);
24
- const colors = useMemo(() => {
25
- if (config.theme !== "auto")
26
- return getTheme(config.theme);
27
- return getThemeFromResolved(probed ?? autoResolved);
28
- }, [config.theme, autoResolved, probed]);
29
- return _jsx(ThemeCtx.Provider, { value: colors, children: children });
25
+ }, [config.theme, terminalAppearance]);
26
+ return _jsx(ThemeCtx.Provider, { value: value, children: children });
30
27
  }
31
28
  export function useTheme() {
32
- return useContext(ThemeCtx);
29
+ return useContext(ThemeCtx).colors;
30
+ }
31
+ export function useTerminalAppearance() {
32
+ return useContext(ThemeCtx).terminalAppearance;
33
+ }
34
+ export function useRenderingAppearance() {
35
+ return useContext(ThemeCtx).renderingAppearance;
33
36
  }
@@ -7,10 +7,10 @@ export const DARK_THEME = {
7
7
  accentDim: "#CFB59D",
8
8
  background: "",
9
9
  border: "#FFE0C2",
10
- text: "white",
10
+ text: "ansi256(15)",
11
11
  textDim: "ansi256(245)",
12
- textInverse: "black",
13
- inputText: "#ffffff",
12
+ textInverse: "ansi256(0)",
13
+ inputText: "ansi256(15)",
14
14
  cursorBackground: "#FFE0C2",
15
15
  cursorForeground: "#000000",
16
16
  success: "green",
@@ -24,10 +24,10 @@ export const LIGHT_THEME = {
24
24
  accentDim: "#CFB59D",
25
25
  background: "",
26
26
  border: "#FFE0C2",
27
- text: "black",
28
- textDim: "gray",
29
- textInverse: "white",
30
- inputText: "#000000",
27
+ text: "ansi256(0)",
28
+ textDim: "ansi256(242)",
29
+ textInverse: "ansi256(15)",
30
+ inputText: "ansi256(0)",
31
31
  cursorBackground: "#CFB59D",
32
32
  cursorForeground: "#000000",
33
33
  success: "green",
@@ -62,7 +62,7 @@ function readColorFgbg() {
62
62
  return undefined;
63
63
  }
64
64
  function readTerminalProfile() {
65
- const profile = process.env.TERM_PROFILE || process.env.ITERM_PROFILE || "";
65
+ const profile = process.env.TERM_PROFILE || process.env.ITERM_PROFILE || process.env.WARP_BOOTSTRAPPED || "";
66
66
  if (!profile)
67
67
  return undefined;
68
68
  if (/light|day|solar/i.test(profile))
@@ -71,6 +71,17 @@ function readTerminalProfile() {
71
71
  return "dark";
72
72
  return undefined;
73
73
  }
74
+ /** Common standalone terminals that default to dark profiles when unset. */
75
+ function readTermProgram() {
76
+ const program = (process.env.TERM_PROGRAM ?? "").toLowerCase();
77
+ if (!program)
78
+ return undefined;
79
+ if (/warp|ghostty|alacritty|kitty|hyper|wezterm|tabby/.test(program))
80
+ return "dark";
81
+ if (program === "apple_terminal")
82
+ return "dark";
83
+ return undefined;
84
+ }
74
85
  function editorSettingsPaths() {
75
86
  const home = homedir();
76
87
  if (process.platform === "darwin") {
@@ -146,9 +157,25 @@ function readSystemAppearance() {
146
157
  export function detectTerminalTheme() {
147
158
  return readColorFgbg()
148
159
  ?? readTerminalProfile()
160
+ ?? readTermProgram()
149
161
  ?? readEditorColorTheme()
150
162
  ?? readSystemAppearance()
151
- ?? "light";
163
+ ?? "dark";
164
+ }
165
+ /** Input foreground matched to terminal appearance. */
166
+ export function inputForegroundForAppearance(appearance) {
167
+ return appearance === "dark" ? "ansi256(15)" : "ansi256(0)";
168
+ }
169
+ /**
170
+ * Pick colors for rendering. When a locked theme disagrees with the terminal
171
+ * background, follow the terminal so text stays readable.
172
+ */
173
+ export function resolveRenderingAppearance(configTheme, terminalAppearance) {
174
+ if (configTheme === "auto")
175
+ return terminalAppearance;
176
+ if (configTheme !== terminalAppearance)
177
+ return terminalAppearance;
178
+ return configTheme;
152
179
  }
153
180
  export function getThemeFromResolved(resolved) {
154
181
  return resolved === "light" ? LIGHT_THEME : DARK_THEME;
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
- import { detectTerminalTheme, getTheme, watchAutoThemeChanges } from "./theme.js";
2
+ import { detectTerminalTheme, getTheme, inputForegroundForAppearance, resolveRenderingAppearance, watchAutoThemeChanges, } from "./theme.js";
3
3
  const env = process.env;
4
4
  afterEach(() => {
5
5
  process.env = { ...env };
@@ -18,14 +18,41 @@ describe("detectTerminalTheme", () => {
18
18
  });
19
19
  it("resolves auto theme colors from detected appearance", () => {
20
20
  process.env.COLORFGBG = "0;15";
21
- expect(getTheme("auto").text).toBe("black");
22
- expect(getTheme("auto").inputText).toBe("#000000");
21
+ expect(getTheme("auto").text).toBe("ansi256(0)");
22
+ expect(getTheme("auto").inputText).toBe("ansi256(0)");
23
23
  expect(getTheme("auto").userBandBackground).toBe("#f0f0f0");
24
24
  process.env.COLORFGBG = "15;0";
25
- expect(getTheme("auto").text).toBe("white");
26
- expect(getTheme("auto").inputText).toBe("#ffffff");
25
+ expect(getTheme("auto").text).toBe("ansi256(15)");
26
+ expect(getTheme("auto").inputText).toBe("ansi256(15)");
27
27
  expect(getTheme("auto").userBandBackground).toBe("ansi256(238)");
28
28
  });
29
+ it("detects Warp and Apple Terminal as dark when unset", () => {
30
+ delete process.env.COLORFGBG;
31
+ delete process.env.TERM_PROFILE;
32
+ delete process.env.ITERM_PROFILE;
33
+ process.env.TERM_PROGRAM = "WarpTerminal";
34
+ expect(detectTerminalTheme()).toBe("dark");
35
+ process.env.TERM_PROGRAM = "Apple_Terminal";
36
+ expect(detectTerminalTheme()).toBe("dark");
37
+ });
38
+ it("defaults to dark when no signals are present", () => {
39
+ delete process.env.COLORFGBG;
40
+ delete process.env.TERM_PROFILE;
41
+ delete process.env.ITERM_PROFILE;
42
+ delete process.env.TERM_PROGRAM;
43
+ delete process.env.TERM_PROGRAM;
44
+ expect(detectTerminalTheme()).toBe("dark");
45
+ });
46
+ it("maps terminal appearance to ansi256 input foreground", () => {
47
+ expect(inputForegroundForAppearance("dark")).toBe("ansi256(15)");
48
+ expect(inputForegroundForAppearance("light")).toBe("ansi256(0)");
49
+ });
50
+ it("overrides a mismatched locked theme to match the terminal", () => {
51
+ expect(resolveRenderingAppearance("light", "dark")).toBe("dark");
52
+ expect(resolveRenderingAppearance("dark", "light")).toBe("light");
53
+ expect(resolveRenderingAppearance("dark", "dark")).toBe("dark");
54
+ expect(resolveRenderingAppearance("auto", "dark")).toBe("dark");
55
+ });
29
56
  it("watchAutoThemeChanges fires immediately and on interval", () => {
30
57
  vi.useFakeTimers();
31
58
  const onChange = vi.fn();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scira/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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",
@@ -32,7 +32,9 @@
32
32
  "build": "tsc -p tsconfig.json",
33
33
  "typecheck": "tsc -p tsconfig.json --noEmit",
34
34
  "prepublishOnly": "bun run build",
35
- "dev": "tsx src/cli/index.ts",
35
+ "dev": "bun src/cli/index.ts",
36
+ "docs:dev": "bun run --cwd docs dev",
37
+ "docs:build": "NODE_ENV=production bun run --cwd docs build",
36
38
  "test": "vitest run",
37
39
  "test:watch": "vitest"
38
40
  },
@@ -45,20 +47,21 @@
45
47
  "@modelcontextprotocol/sdk": "^1.29.0",
46
48
  "@mozilla/readability": "^0.6.0",
47
49
  "ai": "^6.0.202",
48
- "commander": "^15.0.0",
49
50
  "diff": "^9.0.0",
50
51
  "exa-js": "^2.13.0",
51
52
  "files-sdk": "^1.8.0",
52
53
  "ink": "^7.0.5",
53
54
  "jsdom": "^29.1.1",
54
- "ora": "^9.4.0",
55
55
  "parallel-web": "^1.1.0",
56
+ "picospinner": "^3.0.0",
56
57
  "react": "^19.2.7",
57
- "string-width": "8.2.1",
58
+ "react-tweet": "^3.3.1",
59
+ "sade": "^1.8.1",
58
60
  "workers-ai-provider": "^3.1.14",
59
61
  "zod": "^4.4.3"
60
62
  },
61
63
  "devDependencies": {
64
+ "bun-types": "^1.3.14",
62
65
  "@types/jsdom": "^28.0.3",
63
66
  "@types/node": "^25.9.3",
64
67
  "@types/react": "^19.2.17",
@@ -68,6 +71,6 @@
68
71
  "vitest": "^4.1.8"
69
72
  },
70
73
  "engines": {
71
- "node": ">=20"
74
+ "bun": ">=1.2.0"
72
75
  }
73
76
  }