@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
@@ -5,6 +5,7 @@ import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import * as p from "@clack/prompts";
7
7
  import { detectEnv } from "../../providers/llm/readiness.js";
8
+ import { ENV_KEY_GUIDES, formatKeyGuide } from "../../config/env-guide.js";
8
9
  import { listModels } from "../../providers/llm/models.js";
9
10
  import { LLM_PROVIDERS, LLM_PROVIDER_LABELS, defaultModelFor } from "../../providers/llm/registry.js";
10
11
  const SCIRA_DIR = join(homedir(), ".scira");
@@ -80,13 +81,14 @@ export async function initCommand() {
80
81
  process.exit(0);
81
82
  }
82
83
  // Step 2: API Keys based on provider
83
- p.note("Scira requires API keys to function. Let's set them up.", "API Keys");
84
+ p.note("Scira needs API keys for your LLM and search providers.\nKeys are saved to ~/.scira/.env (or copy .env.example to <project>/.scira/.env).\nRun scira doctor or scira keys anytime to verify.", "API Keys");
84
85
  const envKeys = { ...existingEnv };
85
86
  if (llmProvider === "gateway") {
86
87
  if (!envKeys.AI_GATEWAY_API_KEY || shouldReconfigure) {
88
+ p.note(formatKeyGuide("AI_GATEWAY_API_KEY"), "How to get AI_GATEWAY_API_KEY");
87
89
  const aiGatewayKey = await p.text({
88
90
  message: envKeys.AI_GATEWAY_API_KEY ? "Enter your Vercel AI Gateway API key (current: *****)" : "Enter your Vercel AI Gateway API key",
89
- placeholder: "sk-...",
91
+ placeholder: ENV_KEY_GUIDES.AI_GATEWAY_API_KEY.placeholder,
90
92
  defaultValue: envKeys.AI_GATEWAY_API_KEY,
91
93
  validate: (value) => {
92
94
  if (!value)
@@ -107,9 +109,10 @@ export async function initCommand() {
107
109
  }
108
110
  else if (llmProvider === "xai") {
109
111
  if (!envKeys.XAI_API_KEY || shouldReconfigure) {
112
+ p.note(formatKeyGuide("XAI_API_KEY"), "How to get XAI_API_KEY");
110
113
  const xaiKey = await p.text({
111
114
  message: envKeys.XAI_API_KEY ? "Enter your xAI API key (current: *****)" : "Enter your xAI API key",
112
- placeholder: "sk-...",
115
+ placeholder: ENV_KEY_GUIDES.XAI_API_KEY.placeholder,
113
116
  defaultValue: envKeys.XAI_API_KEY,
114
117
  validate: (value) => {
115
118
  if (!value)
@@ -128,6 +131,7 @@ export async function initCommand() {
128
131
  }
129
132
  else if (llmProvider === "workers-ai") {
130
133
  if (!envKeys.CLOUDFLARE_ACCOUNT_ID || !envKeys.CLOUDFLARE_API_TOKEN || shouldReconfigure) {
134
+ p.note(formatKeyGuide("CLOUDFLARE_ACCOUNT_ID"), "How to get CLOUDFLARE_ACCOUNT_ID");
131
135
  const accountId = await p.text({
132
136
  message: envKeys.CLOUDFLARE_ACCOUNT_ID ? "Enter your Cloudflare Account ID (current: *****)" : "Enter your Cloudflare Account ID",
133
137
  placeholder: "your-account-id",
@@ -141,9 +145,10 @@ export async function initCommand() {
141
145
  p.cancel("Setup cancelled.");
142
146
  process.exit(0);
143
147
  }
148
+ p.note(formatKeyGuide("CLOUDFLARE_API_TOKEN"), "How to get CLOUDFLARE_API_TOKEN");
144
149
  const apiToken = await p.text({
145
150
  message: envKeys.CLOUDFLARE_API_TOKEN ? "Enter your Cloudflare API Token (current: *****)" : "Enter your Cloudflare API Token",
146
- placeholder: "your-api-token",
151
+ placeholder: ENV_KEY_GUIDES.CLOUDFLARE_API_TOKEN.placeholder,
147
152
  defaultValue: envKeys.CLOUDFLARE_API_TOKEN,
148
153
  validate: (value) => {
149
154
  if (!value)
@@ -163,9 +168,10 @@ export async function initCommand() {
163
168
  }
164
169
  else if (llmProvider === "huggingface") {
165
170
  if (!envKeys.HF_API_KEY || shouldReconfigure) {
171
+ p.note(formatKeyGuide("HF_API_KEY"), "How to get HF_API_KEY");
166
172
  const hfKey = await p.text({
167
173
  message: envKeys.HF_API_KEY ? "Enter your HuggingFace API key (current: *****)" : "Enter your HuggingFace API key",
168
- placeholder: "hf_...",
174
+ placeholder: ENV_KEY_GUIDES.HF_API_KEY.placeholder,
169
175
  defaultValue: envKeys.HF_API_KEY,
170
176
  validate: (value) => {
171
177
  if (!value)
@@ -182,7 +188,6 @@ export async function initCommand() {
182
188
  p.log.success("HuggingFace API key already set");
183
189
  }
184
190
  }
185
- // Optional search provider keys
186
191
  const searchProvider = await p.select({
187
192
  message: "Select search provider",
188
193
  options: SEARCH_PROVIDERS.map((provider) => ({
@@ -195,41 +200,48 @@ export async function initCommand() {
195
200
  p.cancel("Setup cancelled.");
196
201
  process.exit(0);
197
202
  }
198
- if (searchProvider === "exa") {
199
- if (!envKeys.EXA_API_KEY || shouldReconfigure) {
200
- const exaKey = await p.text({
201
- message: envKeys.EXA_API_KEY ? "Enter your Exa API key (optional, current: *****)" : "Enter your Exa API key (optional, press Enter to skip)",
202
- placeholder: "exa_...",
203
- defaultValue: envKeys.EXA_API_KEY,
204
- });
205
- if (p.isCancel(exaKey)) {
206
- p.cancel("Setup cancelled.");
207
- process.exit(0);
208
- }
209
- if (exaKey)
210
- envKeys.EXA_API_KEY = exaKey;
211
- }
212
- else {
213
- p.log.success("Exa API key already set");
203
+ const searchKeyName = searchProvider === "exa"
204
+ ? "EXA_API_KEY"
205
+ : searchProvider === "firecrawl"
206
+ ? "FIRECRAWL_API_KEY"
207
+ : "PARALLEL_API_KEY";
208
+ if (!envKeys[searchKeyName] || shouldReconfigure) {
209
+ p.note(formatKeyGuide(searchKeyName), `How to get ${searchKeyName}`);
210
+ const searchKey = await p.text({
211
+ message: envKeys[searchKeyName]
212
+ ? `Enter your ${searchProvider} API key (current: *****)`
213
+ : `Enter your ${searchProvider} API key (required for ${searchProvider} search)`,
214
+ placeholder: ENV_KEY_GUIDES[searchKeyName].placeholder,
215
+ defaultValue: envKeys[searchKeyName],
216
+ validate: (value) => {
217
+ if (!value?.trim())
218
+ return `${searchKeyName} is required when search.provider is ${searchProvider}`;
219
+ },
220
+ });
221
+ if (p.isCancel(searchKey)) {
222
+ p.cancel("Setup cancelled.");
223
+ process.exit(0);
214
224
  }
225
+ envKeys[searchKeyName] = searchKey.trim();
215
226
  }
216
- else if (searchProvider === "firecrawl") {
217
- if (!envKeys.FIRECRAWL_API_KEY || shouldReconfigure) {
218
- const firecrawlKey = await p.text({
219
- message: envKeys.FIRECRAWL_API_KEY ? "Enter your Firecrawl API key (optional, current: *****)" : "Enter your Firecrawl API key (optional, press Enter to skip)",
220
- placeholder: "fc-...",
221
- defaultValue: envKeys.FIRECRAWL_API_KEY,
222
- });
223
- if (p.isCancel(firecrawlKey)) {
224
- p.cancel("Setup cancelled.");
225
- process.exit(0);
226
- }
227
- if (firecrawlKey)
228
- envKeys.FIRECRAWL_API_KEY = firecrawlKey;
229
- }
230
- else {
231
- p.log.success("Firecrawl API key already set");
227
+ else {
228
+ p.log.success(`${searchKeyName} already set`);
229
+ }
230
+ if (searchProvider !== "firecrawl" && (!envKeys.FIRECRAWL_API_KEY || shouldReconfigure)) {
231
+ p.note(`${formatKeyGuide("FIRECRAWL_API_KEY")}\n\nOptional but recommended: used as automatic fallback if ${searchProvider} search fails.`, "Firecrawl fallback (optional)");
232
+ const firecrawlKey = await p.text({
233
+ message: envKeys.FIRECRAWL_API_KEY
234
+ ? "Enter your Firecrawl API key for fallback (current: *****, Enter to keep)"
235
+ : "Enter your Firecrawl API key for fallback (optional, Enter to skip)",
236
+ placeholder: ENV_KEY_GUIDES.FIRECRAWL_API_KEY.placeholder,
237
+ defaultValue: envKeys.FIRECRAWL_API_KEY,
238
+ });
239
+ if (p.isCancel(firecrawlKey)) {
240
+ p.cancel("Setup cancelled.");
241
+ process.exit(0);
232
242
  }
243
+ if (firecrawlKey?.trim())
244
+ envKeys.FIRECRAWL_API_KEY = firecrawlKey.trim();
233
245
  }
234
246
  // Write .env file
235
247
  const envContent = Object.entries(envKeys)
@@ -368,5 +380,5 @@ export async function initCommand() {
368
380
  s2.stop(`Missing: ${missingRequired.map((c) => c.name).join(", ")}`);
369
381
  p.note("Some required credentials are missing. Please run `scira init` again.", "Warning");
370
382
  }
371
- p.outro("Setup complete! Run `scira doctor` to verify your configuration.");
383
+ p.outro("Setup complete! Run `scira doctor` to verify, or `scira keys` for signup links.");
372
384
  }
package/dist/cli/index.js CHANGED
@@ -7,18 +7,12 @@ if (typeof Bun === "undefined") {
7
7
  import { readFileSync } from "node:fs";
8
8
  import { readFile } from "node:fs/promises";
9
9
  import { dirname, join } from "node:path";
10
- import { homedir } from "node:os";
11
10
  import { fileURLToPath } from "node:url";
12
11
  import sade from "sade";
13
12
  const { version: pkgVersion } = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../../package.json"), "utf8"));
14
- // Load keys from the global config dir (~/.scira/.env) so they work regardless
15
- // of where the CLI is invoked from (e.g. after pnpm link / global install).
16
- try {
17
- process.loadEnvFile(join(homedir(), ".scira", ".env"));
18
- }
19
- catch {
20
- // no ~/.scira/.env present; rely on the ambient environment
21
- }
13
+ import { loadSciraEnv } from "../config/env-store.js";
14
+ // Shell env wins, then ~/.scira/.env, then <cwd>/.scira/.env (project overrides global).
15
+ loadSciraEnv(process.cwd());
22
16
  import { loadConfig } from "../config/load-config.js";
23
17
  import { createRun, findRun, listRuns, summarizeRun, verificationReport, getRunPaths } from "../storage/run-store.js";
24
18
  import { readJsonl } from "../storage/jsonl.js";
@@ -26,6 +20,7 @@ import { runResearchAgent } from "../agent/research-agent.js";
26
20
  import { openShell } from "./shell/shell.js";
27
21
  import { openTui, openTuiHome } from "./shell/tui.js";
28
22
  import { detectEnv } from "../providers/llm/readiness.js";
23
+ import { envFileSetupInstructions, formatMissingKeysHelp } from "../config/env-guide.js";
29
24
  import { requireLlmKeys } from "../providers/llm/registry.js";
30
25
  import { listModels } from "../providers/llm/models.js";
31
26
  import { listGatewayModels } from "../providers/llm/gateway.js";
@@ -50,9 +45,8 @@ prog
50
45
  requireLlmKeys(config);
51
46
  const run = await createRun(question, config);
52
47
  console.log(`Run: ${run.path}`);
53
- if (opts.workspace) {
48
+ if (opts.workspace)
54
49
  console.log(`Workspace: ${opts.workspace}`);
55
- }
56
50
  console.log("");
57
51
  await runResearchAgent(run.path, question, config, opts.workspace);
58
52
  console.log(`\nRun complete: ${run.path}`);
@@ -398,17 +392,39 @@ prog
398
392
  const blockers = [];
399
393
  if (!nodeCheck.ok)
400
394
  blockers.push(`upgrade Node to >=${nodeCheck.required}`);
401
- if (missingRequired.length > 0)
402
- blockers.push(`set ${missingRequired.map((c) => c.name).join(", ")} in ~/.scira/.env`);
395
+ if (missingRequired.length > 0) {
396
+ blockers.push(`set ${missingRequired.map((c) => c.name).join(", ")} in ~/.scira/.env or .scira/.env in your project`);
397
+ }
403
398
  console.log("");
404
399
  if (blockers.length > 0) {
405
400
  console.log(`Action needed: ${blockers.join("; ")} to enable research runs.`);
406
- console.log(` Tip: cp .env.example ~/.scira/.env then fill in your keys.`);
401
+ console.log("");
402
+ const help = formatMissingKeysHelp(checks);
403
+ if (help)
404
+ console.log(help);
407
405
  }
408
406
  else {
409
407
  console.log("All required credentials present. Ready to run.");
410
408
  }
411
409
  });
410
+ prog
411
+ .command("keys", "show how to get and set API keys")
412
+ .action(async () => {
413
+ const config = await loadConfig();
414
+ const checks = detectEnv(config.search.provider, config.llmProvider);
415
+ console.log(`LLM provider: ${config.llmProvider}`);
416
+ console.log(`Search provider: ${config.search.provider}`);
417
+ console.log("");
418
+ const help = formatMissingKeysHelp(checks);
419
+ if (help) {
420
+ console.log(help);
421
+ }
422
+ else {
423
+ console.log("All required keys for your current config are set.");
424
+ console.log("");
425
+ console.log(envFileSetupInstructions());
426
+ }
427
+ });
412
428
  function checkNodeVersion(required) {
413
429
  const m = /^v(\d+)/u.exec(process.version);
414
430
  const current = m ? Number(m[1]) : 0;
@@ -0,0 +1,151 @@
1
+ export const ENV_KEY_GUIDES = {
2
+ AI_GATEWAY_API_KEY: {
3
+ name: "AI_GATEWAY_API_KEY",
4
+ label: "Vercel AI Gateway",
5
+ signupUrl: "https://vercel.com/docs/ai-gateway",
6
+ docsUrl: "https://vercel.com/docs/ai-gateway/authentication",
7
+ placeholder: "sk-...",
8
+ steps: [
9
+ "Sign in at vercel.com (free tier works).",
10
+ "Open your team dashboard → AI Gateway → API Keys.",
11
+ "Create a key and paste it here (starts with vc_)."
12
+ ]
13
+ },
14
+ XAI_API_KEY: {
15
+ name: "XAI_API_KEY",
16
+ label: "xAI (Grok)",
17
+ signupUrl: "https://console.x.ai/",
18
+ docsUrl: "https://docs.x.ai/docs/overview",
19
+ placeholder: "xai-...",
20
+ steps: [
21
+ "Create an account at console.x.ai.",
22
+ "Open API Keys in the sidebar.",
23
+ "Create a key and paste it here."
24
+ ]
25
+ },
26
+ CLOUDFLARE_ACCOUNT_ID: {
27
+ name: "CLOUDFLARE_ACCOUNT_ID",
28
+ label: "Cloudflare account ID",
29
+ signupUrl: "https://dash.cloudflare.com/",
30
+ docsUrl: "https://developers.cloudflare.com/workers-ai/get-started/rest-api/",
31
+ placeholder: "32-character account id",
32
+ steps: [
33
+ "Sign in at dash.cloudflare.com.",
34
+ "Copy Account ID from the Workers & Pages overview (right-hand sidebar)."
35
+ ]
36
+ },
37
+ CLOUDFLARE_API_TOKEN: {
38
+ name: "CLOUDFLARE_API_TOKEN",
39
+ label: "Cloudflare API token",
40
+ signupUrl: "https://dash.cloudflare.com/profile/api-tokens",
41
+ docsUrl: "https://developers.cloudflare.com/workers-ai/get-started/rest-api/",
42
+ placeholder: "cloudflare api token",
43
+ steps: [
44
+ "Go to My Profile → API Tokens → Create Token.",
45
+ "Use the \"Edit Cloudflare Workers AI\" template (or Workers AI Read).",
46
+ "Create the token and paste it here."
47
+ ]
48
+ },
49
+ HF_API_KEY: {
50
+ name: "HF_API_KEY",
51
+ label: "Hugging Face Inference",
52
+ signupUrl: "https://huggingface.co/settings/tokens",
53
+ docsUrl: "https://huggingface.co/docs/inference-providers/index",
54
+ placeholder: "hf_...",
55
+ steps: [
56
+ "Create a Hugging Face account.",
57
+ "Open Settings → Access Tokens → Create new token.",
58
+ "Choose a token with Inference permissions and paste it here."
59
+ ]
60
+ },
61
+ EXA_API_KEY: {
62
+ name: "EXA_API_KEY",
63
+ label: "Exa search",
64
+ signupUrl: "https://dashboard.exa.ai/api-keys",
65
+ docsUrl: "https://docs.exa.ai/reference/getting-started",
66
+ placeholder: "exa_...",
67
+ steps: [
68
+ "Sign up at dashboard.exa.ai.",
69
+ "Open API Keys and create a key.",
70
+ "Paste the key here (starts with exa_)."
71
+ ]
72
+ },
73
+ FIRECRAWL_API_KEY: {
74
+ name: "FIRECRAWL_API_KEY",
75
+ label: "Firecrawl search + scrape",
76
+ signupUrl: "https://www.firecrawl.dev/app/api-keys",
77
+ docsUrl: "https://docs.firecrawl.dev/introduction",
78
+ placeholder: "fc-...",
79
+ steps: [
80
+ "Sign up at firecrawl.dev.",
81
+ "Open the dashboard → API Keys.",
82
+ "Create a key and paste it here."
83
+ ]
84
+ },
85
+ PARALLEL_API_KEY: {
86
+ name: "PARALLEL_API_KEY",
87
+ label: "Parallel search",
88
+ signupUrl: "https://platform.parallel.ai/",
89
+ docsUrl: "https://docs.parallel.ai/search/search-quickstart",
90
+ placeholder: "parallel api key",
91
+ steps: [
92
+ "Sign up at platform.parallel.ai.",
93
+ "Open API Keys in the dashboard.",
94
+ "Create a key and paste it here."
95
+ ]
96
+ }
97
+ };
98
+ export function envFileSetupInstructions() {
99
+ return [
100
+ "Where to save keys (pick one):",
101
+ " ~/.scira/.env global — works from any directory",
102
+ " <project>/.scira/.env project — overrides global when run from that folder",
103
+ "",
104
+ "Quick setup:",
105
+ " scira init interactive wizard (saves to ~/.scira/.env)",
106
+ " cp .env.example ~/.scira/.env then edit the file",
107
+ " scira doctor verify keys are detected"
108
+ ].join("\n");
109
+ }
110
+ export function formatKeyGuide(name) {
111
+ const guide = ENV_KEY_GUIDES[name];
112
+ const lines = [
113
+ `${guide.label} (${guide.name})`,
114
+ `Get a key: ${guide.signupUrl}`,
115
+ ...guide.steps.map((step, i) => ` ${i + 1}. ${step}`)
116
+ ];
117
+ if (guide.docsUrl)
118
+ lines.push(`Docs: ${guide.docsUrl}`);
119
+ return lines.join("\n");
120
+ }
121
+ export function isManagedEnvKeyName(name) {
122
+ return name in ENV_KEY_GUIDES;
123
+ }
124
+ export function formatMissingKeysHelp(checks) {
125
+ const missing = checks.filter((c) => c.required && !c.present);
126
+ if (missing.length === 0)
127
+ return "";
128
+ const blocks = ["Missing required keys:", ""];
129
+ for (const check of missing) {
130
+ if (!isManagedEnvKeyName(check.name)) {
131
+ blocks.push(`${check.name} — ${check.purpose}`);
132
+ continue;
133
+ }
134
+ blocks.push(formatKeyGuide(check.name));
135
+ blocks.push("");
136
+ }
137
+ blocks.push(envFileSetupInstructions());
138
+ return blocks.join("\n");
139
+ }
140
+ export function formatKeysStatus(checks) {
141
+ const lines = checks.map((c) => {
142
+ const status = c.present ? "set " : "missing";
143
+ const tag = c.required ? " (required)" : " (optional)";
144
+ let line = `${status} ${c.name}${tag} — ${c.purpose}`;
145
+ if (!c.present && isManagedEnvKeyName(c.name)) {
146
+ line += `\n ${ENV_KEY_GUIDES[c.name].signupUrl}`;
147
+ }
148
+ return line;
149
+ });
150
+ return `${lines.join("\n")}\n\n${envFileSetupInstructions()}`;
151
+ }
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { formatKeyGuide, formatMissingKeysHelp } from "./env-guide.js";
3
+ describe("env-guide", () => {
4
+ it("includes signup URL and steps for a known key", () => {
5
+ const text = formatKeyGuide("EXA_API_KEY");
6
+ expect(text).toContain("EXA_API_KEY");
7
+ expect(text).toContain("dashboard.exa.ai");
8
+ expect(text).toContain("1.");
9
+ });
10
+ it("builds missing-key help from env checks", () => {
11
+ const help = formatMissingKeysHelp([
12
+ { name: "EXA_API_KEY", present: false, purpose: "exa web search", required: true }
13
+ ]);
14
+ expect(help).toContain("Missing required keys");
15
+ expect(help).toContain("~/.scira/.env");
16
+ expect(help).toContain("scira init");
17
+ });
18
+ });
@@ -1,4 +1,5 @@
1
1
  import process from "node:process";
2
+ import { readFileSync } from "node:fs";
2
3
  import { mkdir, readFile, writeFile } from "node:fs/promises";
3
4
  import { homedir } from "node:os";
4
5
  import { join } from "node:path";
@@ -17,6 +18,58 @@ export function isManagedEnvKey(name) {
17
18
  }
18
19
  /** Path to the global env file that the CLI loads on startup. */
19
20
  export const globalEnvPath = join(homedir(), ".scira", ".env");
21
+ /** Per-project env file: `<projectRoot>/.scira/.env` (overrides global keys). */
22
+ export function projectEnvPath(projectRoot = process.cwd()) {
23
+ return join(projectRoot, ".scira", ".env");
24
+ }
25
+ /** Parse simple KEY=VALUE lines from a dotenv file. */
26
+ export function parseEnvFile(content) {
27
+ const out = [];
28
+ for (const rawLine of content.split("\n")) {
29
+ const line = rawLine.trim();
30
+ if (!line || line.startsWith("#"))
31
+ continue;
32
+ const normalized = line.replace(/^export\s+/u, "");
33
+ const eq = normalized.indexOf("=");
34
+ if (eq <= 0)
35
+ continue;
36
+ const key = normalized.slice(0, eq).trim();
37
+ if (!key)
38
+ continue;
39
+ let value = normalized.slice(eq + 1).trim();
40
+ if ((value.startsWith("\"") && value.endsWith("\""))
41
+ || (value.startsWith("'") && value.endsWith("'"))) {
42
+ value = value.slice(1, -1);
43
+ }
44
+ out.push([key, value]);
45
+ }
46
+ return out;
47
+ }
48
+ function applyEnvFile(path, opts) {
49
+ let content;
50
+ try {
51
+ content = readFileSync(path, "utf8");
52
+ }
53
+ catch {
54
+ return;
55
+ }
56
+ for (const [key, value] of parseEnvFile(content)) {
57
+ if (opts.skipKeys.has(key))
58
+ continue;
59
+ if (!opts.overrideExisting && process.env[key] !== undefined)
60
+ continue;
61
+ process.env[key] = value;
62
+ }
63
+ }
64
+ /**
65
+ * Load API keys for the current process.
66
+ * Precedence (highest first): shell env, project `.scira/.env`, `~/.scira/.env`.
67
+ */
68
+ export function loadSciraEnv(projectRoot = process.cwd()) {
69
+ const shellKeys = new Set(Object.keys(process.env));
70
+ applyEnvFile(globalEnvPath, { skipKeys: shellKeys, overrideExisting: false });
71
+ applyEnvFile(projectEnvPath(projectRoot), { skipKeys: shellKeys, overrideExisting: true });
72
+ }
20
73
  /**
21
74
  * Persist an environment key to ~/.scira/.env and apply it to the current
22
75
  * process so it takes effect immediately without a restart.
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { loadSciraEnv, parseEnvFile, projectEnvPath } from "./env-store.js";
6
+ describe("parseEnvFile", () => {
7
+ it("parses comments, export prefix, and quoted values", () => {
8
+ const entries = parseEnvFile(`
9
+ # comment
10
+ export EXA_API_KEY=global
11
+ FIRECRAWL_API_KEY="quoted"
12
+ PARALLEL_API_KEY='single'
13
+ `);
14
+ expect(entries).toEqual([
15
+ ["EXA_API_KEY", "global"],
16
+ ["FIRECRAWL_API_KEY", "quoted"],
17
+ ["PARALLEL_API_KEY", "single"]
18
+ ]);
19
+ });
20
+ });
21
+ describe("loadSciraEnv", () => {
22
+ it("loads project .scira/.env over global values", () => {
23
+ const projectRoot = mkdtempSync(join(tmpdir(), "scira-env-"));
24
+ const projectEnv = projectEnvPath(projectRoot);
25
+ mkdirSync(join(projectRoot, ".scira"), { recursive: true });
26
+ writeFileSync(projectEnv, "EXA_API_KEY=from-project\n");
27
+ const shellValue = process.env.EXA_API_KEY;
28
+ delete process.env.EXA_API_KEY;
29
+ process.env.SCIRA_TEST_GLOBAL_ENV = "1";
30
+ process.env.EXA_API_KEY = "from-shell";
31
+ try {
32
+ loadSciraEnv(projectRoot);
33
+ expect(process.env.EXA_API_KEY).toBe("from-shell");
34
+ }
35
+ finally {
36
+ delete process.env.SCIRA_TEST_GLOBAL_ENV;
37
+ if (shellValue === undefined)
38
+ delete process.env.EXA_API_KEY;
39
+ else
40
+ process.env.EXA_API_KEY = shellValue;
41
+ }
42
+ });
43
+ it("applies project keys when not set in shell", () => {
44
+ const projectRoot = mkdtempSync(join(tmpdir(), "scira-env-"));
45
+ mkdirSync(join(projectRoot, ".scira"), { recursive: true });
46
+ writeFileSync(projectEnvPath(projectRoot), "EXA_API_KEY=from-project\n");
47
+ const shellValue = process.env.EXA_API_KEY;
48
+ delete process.env.EXA_API_KEY;
49
+ try {
50
+ loadSciraEnv(projectRoot);
51
+ expect(process.env.EXA_API_KEY).toBe("from-project");
52
+ }
53
+ finally {
54
+ if (shellValue === undefined)
55
+ delete process.env.EXA_API_KEY;
56
+ else
57
+ process.env.EXA_API_KEY = shellValue;
58
+ }
59
+ });
60
+ });