@scira/cli 0.1.2 → 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.
- package/README.md +54 -10
- package/dist/agent/background-tasks.js +173 -0
- package/dist/agent/research-agent.js +95 -38
- package/dist/agent/todos.js +140 -0
- package/dist/agent/tools.js +146 -143
- package/dist/agent/tools.test.js +33 -0
- package/dist/agent/workspace.js +85 -0
- package/dist/cli/commands/init.js +51 -39
- package/dist/cli/index.js +30 -14
- package/dist/config/env-guide.js +151 -0
- package/dist/config/env-guide.test.js +18 -0
- package/dist/config/env-store.js +53 -0
- package/dist/config/env-store.test.js +60 -0
- package/dist/tools/agent-tools.js +621 -0
- package/dist/tools/background-tasks.js +261 -0
- package/dist/tools/bash-policy.test.js +38 -0
- package/dist/tools/file-tools.js +6 -1
- package/dist/tools/search-web.js +24 -6
- package/dist/tools/search-web.test.js +24 -0
- package/dist/tools/todos.js +140 -0
- package/dist/tools/workspace.js +91 -0
- package/dist/tools/workspace.test.js +75 -0
- package/dist/tools/x-search.js +142 -0
- package/dist/ui/ink/SciraApp.js +11 -8
- package/dist/ui/ink/components/overlays.js +4 -4
- package/dist/ui/ink/constants.js +11 -3
- package/dist/ui/ink/hooks/use-agent-turn.js +24 -5
- package/dist/ui/ink/hooks/use-keyboard.js +3 -0
- package/dist/ui/ink/hooks/use-session.js +5 -3
- package/dist/ui/ink/hooks/use-settings.js +10 -8
- package/dist/ui/ink/hooks/use-submit.js +13 -2
- package/dist/ui/ink/hooks/use-theme.js +1 -1
- package/dist/ui/ink/lib/tool-result.js +72 -5
- package/dist/ui/ink/lib/utils.js +40 -3
- package/dist/ui/ink/theme-context.js +29 -26
- package/dist/ui/ink/theme.js +36 -9
- package/dist/ui/ink/theme.test.js +32 -5
- package/package.json +5 -2
|
@@ -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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
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
|
-
|
|
15
|
-
//
|
|
16
|
-
|
|
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(
|
|
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
|
+
});
|
package/dist/config/env-store.js
CHANGED
|
@@ -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
|
+
});
|