@scira/cli 0.1.0
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/LICENSE +21 -0
- package/README.md +128 -0
- package/dist/agent/research-agent.js +253 -0
- package/dist/agent/skills.js +265 -0
- package/dist/agent/tools.js +429 -0
- package/dist/agent/tools.test.js +27 -0
- package/dist/cli/commands/init.js +370 -0
- package/dist/cli/index.js +445 -0
- package/dist/cli/shell/shell.js +76 -0
- package/dist/cli/shell/tui.js +11 -0
- package/dist/config/env-store.js +47 -0
- package/dist/config/load-config.js +58 -0
- package/dist/export/formatters.js +37 -0
- package/dist/providers/llm/gateway.js +64 -0
- package/dist/providers/llm/huggingface.js +33 -0
- package/dist/providers/llm/models.js +97 -0
- package/dist/providers/llm/readiness.js +50 -0
- package/dist/providers/llm/registry.js +56 -0
- package/dist/storage/jsonl.js +29 -0
- package/dist/storage/jsonl.test.js +38 -0
- package/dist/storage/run-store.js +134 -0
- package/dist/storage/run-store.test.js +65 -0
- package/dist/tools/chrome-devtools-mcp.js +61 -0
- package/dist/tools/file-tools.js +128 -0
- package/dist/tools/mcp-bridge.js +118 -0
- package/dist/tools/mcp-oauth.js +276 -0
- package/dist/tools/open-url.js +99 -0
- package/dist/tools/search-web.js +153 -0
- package/dist/types/index.js +91 -0
- package/dist/types/schema.test.js +60 -0
- package/dist/ui/ink/SciraApp.js +274 -0
- package/dist/ui/ink/components/effects.js +44 -0
- package/dist/ui/ink/components/home-screen.js +69 -0
- package/dist/ui/ink/components/overlays.js +111 -0
- package/dist/ui/ink/constants.js +56 -0
- package/dist/ui/ink/hooks/use-agent-turn.js +186 -0
- package/dist/ui/ink/hooks/use-feed-lines.js +186 -0
- package/dist/ui/ink/hooks/use-feed.js +69 -0
- package/dist/ui/ink/hooks/use-keyboard.js +315 -0
- package/dist/ui/ink/hooks/use-mouse.js +31 -0
- package/dist/ui/ink/hooks/use-session.js +103 -0
- package/dist/ui/ink/hooks/use-settings.js +155 -0
- package/dist/ui/ink/hooks/use-submit.js +366 -0
- package/dist/ui/ink/hooks/use-suggestions.js +91 -0
- package/dist/ui/ink/lib/file-mentions.js +71 -0
- package/dist/ui/ink/lib/markdown.js +245 -0
- package/dist/ui/ink/lib/utils.js +224 -0
- package/dist/ui/ink/session-manager.js +160 -0
- package/dist/ui/ink/types.js +1 -0
- package/dist/utils/ids.js +15 -0
- package/dist/utils/markdown-joiner.js +249 -0
- package/dist/watch/runner.js +65 -0
- package/package.json +74 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { Files } from "files-sdk";
|
|
4
|
+
import { fs } from "files-sdk/fs";
|
|
5
|
+
import { logEvent } from "../storage/run-store.js";
|
|
6
|
+
const MAX_CONTENT = 8000;
|
|
7
|
+
function truncate(text, max = MAX_CONTENT) {
|
|
8
|
+
if (text.length <= max)
|
|
9
|
+
return text;
|
|
10
|
+
return `${text.slice(0, max)}\n…[truncated ${text.length - max} chars]`;
|
|
11
|
+
}
|
|
12
|
+
async function takeAsync(iter, max) {
|
|
13
|
+
const results = [];
|
|
14
|
+
for await (const item of iter) {
|
|
15
|
+
results.push(item);
|
|
16
|
+
if (results.length >= max)
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
return results;
|
|
20
|
+
}
|
|
21
|
+
export function createFileTools(runPath, config, onApprovalRequired) {
|
|
22
|
+
const dir = config.files.dir;
|
|
23
|
+
const files = new Files({ adapter: fs({ root: dir }) });
|
|
24
|
+
async function gate(toolName, description) {
|
|
25
|
+
if (config.approvalMode === "auto" || !onApprovalRequired)
|
|
26
|
+
return true;
|
|
27
|
+
return onApprovalRequired(toolName, description);
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
listFiles: tool({
|
|
31
|
+
description: "List files in the configured files directory. Use to enumerate available documents before reading them.",
|
|
32
|
+
inputSchema: z.object({
|
|
33
|
+
prefix: z.string().optional().describe("Key prefix to filter results (e.g. 'reports/')."),
|
|
34
|
+
maxResults: z.number().int().min(1).max(200).optional().describe("Max files to return (default 50).")
|
|
35
|
+
}),
|
|
36
|
+
execute: async ({ prefix, maxResults = 50 }) => {
|
|
37
|
+
const items = await takeAsync(files.listAll({ prefix }), maxResults);
|
|
38
|
+
await logEvent(runPath, "file.list", { prefix, count: items.length });
|
|
39
|
+
return JSON.stringify(items.map((f) => ({ key: f.key, size: f.size, lastModified: f.lastModified })), null, 2);
|
|
40
|
+
}
|
|
41
|
+
}),
|
|
42
|
+
searchFiles: tool({
|
|
43
|
+
description: "Search the files directory by glob pattern, substring, or /regex/ string. " +
|
|
44
|
+
"Glob examples: '**/*.pdf', 'reports/*.md'. Wrap in slashes for regex: '/error|panic/'.",
|
|
45
|
+
inputSchema: z.object({
|
|
46
|
+
pattern: z.string().describe("Glob (default), substring, or /regex/ string to match against file keys."),
|
|
47
|
+
prefix: z.string().optional().describe("Limit the search to keys with this prefix."),
|
|
48
|
+
maxResults: z.number().int().min(1).max(100).optional().describe("Max results (default 20).")
|
|
49
|
+
}),
|
|
50
|
+
execute: async ({ pattern, prefix, maxResults = 20 }) => {
|
|
51
|
+
const patternArg = pattern.startsWith("/") && pattern.endsWith("/") && pattern.length > 2
|
|
52
|
+
? new RegExp(pattern.slice(1, -1))
|
|
53
|
+
: pattern;
|
|
54
|
+
const items = await takeAsync(files.search(patternArg, { prefix, maxResults }), maxResults);
|
|
55
|
+
await logEvent(runPath, "file.search", { pattern, count: items.length });
|
|
56
|
+
return JSON.stringify(items.map((f) => ({ key: f.key, size: f.size, lastModified: f.lastModified })), null, 2);
|
|
57
|
+
}
|
|
58
|
+
}),
|
|
59
|
+
getFile: tool({
|
|
60
|
+
description: "Read the text content of a file from the files directory. " +
|
|
61
|
+
"Returns the file content (truncated if large). Binary files return a content-type notice.",
|
|
62
|
+
inputSchema: z.object({
|
|
63
|
+
key: z.string().describe("File key — path relative to the files directory root.")
|
|
64
|
+
}),
|
|
65
|
+
execute: async ({ key }) => {
|
|
66
|
+
let stored;
|
|
67
|
+
try {
|
|
68
|
+
stored = await files.download(key);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
return `Could not read "${key}": ${error.message}`;
|
|
72
|
+
}
|
|
73
|
+
const contentType = stored.type ?? "application/octet-stream";
|
|
74
|
+
if (contentType.startsWith("image/") ||
|
|
75
|
+
contentType.startsWith("video/") ||
|
|
76
|
+
contentType.startsWith("audio/") ||
|
|
77
|
+
contentType === "application/octet-stream") {
|
|
78
|
+
await logEvent(runPath, "file.read", { key, binary: true, contentType });
|
|
79
|
+
return `Binary file (${contentType}) — cannot display as text.`;
|
|
80
|
+
}
|
|
81
|
+
const text = await stored.text();
|
|
82
|
+
await logEvent(runPath, "file.read", { key, chars: text.length });
|
|
83
|
+
return truncate(text);
|
|
84
|
+
}
|
|
85
|
+
}),
|
|
86
|
+
fileExists: tool({
|
|
87
|
+
description: "Check whether a file exists in the files directory.",
|
|
88
|
+
inputSchema: z.object({
|
|
89
|
+
key: z.string().describe("File key to check.")
|
|
90
|
+
}),
|
|
91
|
+
execute: async ({ key }) => {
|
|
92
|
+
const exists = await files.exists(key);
|
|
93
|
+
return exists
|
|
94
|
+
? `"${key}" exists.`
|
|
95
|
+
: `"${key}" does not exist.`;
|
|
96
|
+
}
|
|
97
|
+
}),
|
|
98
|
+
moveFile: tool({
|
|
99
|
+
description: "Move (rename) a file within the files directory. Requires user approval.",
|
|
100
|
+
inputSchema: z.object({
|
|
101
|
+
source: z.string().describe("Current file key."),
|
|
102
|
+
destination: z.string().describe("Target file key.")
|
|
103
|
+
}),
|
|
104
|
+
execute: async ({ source, destination }) => {
|
|
105
|
+
if (!await gate("moveFile", `Move file:\n ${source} → ${destination}`)) {
|
|
106
|
+
return "Move rejected by user.";
|
|
107
|
+
}
|
|
108
|
+
await files.move(source, destination);
|
|
109
|
+
await logEvent(runPath, "file.move", { source, destination });
|
|
110
|
+
return `Moved "${source}" → "${destination}"`;
|
|
111
|
+
}
|
|
112
|
+
}),
|
|
113
|
+
deleteFile: tool({
|
|
114
|
+
description: "Delete a file from the files directory. Requires user approval.",
|
|
115
|
+
inputSchema: z.object({
|
|
116
|
+
key: z.string().describe("File key to delete.")
|
|
117
|
+
}),
|
|
118
|
+
execute: async ({ key }) => {
|
|
119
|
+
if (!await gate("deleteFile", `Delete file: "${key}"`)) {
|
|
120
|
+
return "Delete rejected by user.";
|
|
121
|
+
}
|
|
122
|
+
await files.delete(key);
|
|
123
|
+
await logEvent(runPath, "file.delete", { key });
|
|
124
|
+
return `Deleted "${key}".`;
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createMCPClient } from "@ai-sdk/mcp";
|
|
2
|
+
import { Experimental_StdioMCPTransport } from "@ai-sdk/mcp/mcp-stdio";
|
|
3
|
+
import { resolveOAuthToken } from "./mcp-oauth.js";
|
|
4
|
+
const NOOP_BRIDGE = {
|
|
5
|
+
tools: {},
|
|
6
|
+
close: async () => { },
|
|
7
|
+
toolNames: []
|
|
8
|
+
};
|
|
9
|
+
function resolveMcpHeaders(srv, oauthToken) {
|
|
10
|
+
if (srv.authType === "oauth" && oauthToken) {
|
|
11
|
+
return { Authorization: `Bearer ${oauthToken}` };
|
|
12
|
+
}
|
|
13
|
+
if (srv.authType === "bearer" && srv.bearerToken) {
|
|
14
|
+
return { Authorization: `Bearer ${srv.bearerToken}` };
|
|
15
|
+
}
|
|
16
|
+
if (srv.authType === "header" && srv.headerName && srv.headerValue) {
|
|
17
|
+
return { [srv.headerName]: srv.headerValue };
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
async function connectServer(srv, config) {
|
|
22
|
+
let client;
|
|
23
|
+
try {
|
|
24
|
+
let transport;
|
|
25
|
+
if (srv.transport === "stdio") {
|
|
26
|
+
if (!srv.command)
|
|
27
|
+
throw new Error(`MCP server "${srv.name}" is missing required "command" for stdio transport.`);
|
|
28
|
+
const cleanEnv = srv.env && Object.keys(srv.env).length > 0
|
|
29
|
+
? Object.fromEntries(Object.entries({ ...process.env, ...srv.env }).filter((e) => e[1] !== undefined))
|
|
30
|
+
: undefined;
|
|
31
|
+
transport = new Experimental_StdioMCPTransport({
|
|
32
|
+
command: srv.command,
|
|
33
|
+
args: srv.args ?? [],
|
|
34
|
+
stderr: "pipe",
|
|
35
|
+
...(cleanEnv ? { env: cleanEnv } : {}),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
else if (srv.transport === "sse") {
|
|
39
|
+
if (!srv.url)
|
|
40
|
+
throw new Error(`MCP server "${srv.name}" is missing required "url" for sse transport.`);
|
|
41
|
+
const oauthToken = (srv.authType === "oauth" && config)
|
|
42
|
+
? await resolveOAuthToken(srv, config)
|
|
43
|
+
: undefined;
|
|
44
|
+
const headers = resolveMcpHeaders(srv, oauthToken);
|
|
45
|
+
transport = { type: "sse", url: srv.url, ...(headers ? { headers } : {}) };
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
if (!srv.url)
|
|
49
|
+
throw new Error(`MCP server "${srv.name}" is missing required "url" for http transport.`);
|
|
50
|
+
const oauthToken = (srv.authType === "oauth" && config)
|
|
51
|
+
? await resolveOAuthToken(srv, config)
|
|
52
|
+
: undefined;
|
|
53
|
+
const headers = resolveMcpHeaders(srv, oauthToken);
|
|
54
|
+
transport = { type: "http", url: srv.url, ...(headers ? { headers } : {}) };
|
|
55
|
+
}
|
|
56
|
+
client = await createMCPClient({ transport, clientName: "scira-cli" });
|
|
57
|
+
const raw = await client.tools();
|
|
58
|
+
const prefix = srv.toolPrefix ?? "";
|
|
59
|
+
const prefixed = {};
|
|
60
|
+
const toolNames = [];
|
|
61
|
+
for (const [toolName, tool] of Object.entries(raw)) {
|
|
62
|
+
const finalName = prefix ? `${prefix}${toolName}` : toolName;
|
|
63
|
+
prefixed[finalName] = tool;
|
|
64
|
+
toolNames.push(finalName);
|
|
65
|
+
}
|
|
66
|
+
const owned = client;
|
|
67
|
+
return {
|
|
68
|
+
tools: prefixed,
|
|
69
|
+
toolNames,
|
|
70
|
+
close: async () => { try {
|
|
71
|
+
await owned.close();
|
|
72
|
+
}
|
|
73
|
+
catch { /* ignore */ } }
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (client) {
|
|
78
|
+
try {
|
|
79
|
+
await client.close();
|
|
80
|
+
}
|
|
81
|
+
catch { /* ignore */ }
|
|
82
|
+
}
|
|
83
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
84
|
+
process.stderr.write(`\n[scira] MCP server "${srv.name}" unavailable: ${message}\n`);
|
|
85
|
+
return NOOP_BRIDGE;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Connect to all enabled MCP servers in config (chromeDevtools + mcp.servers)
|
|
90
|
+
* and merge their tools into a single bridge.
|
|
91
|
+
*/
|
|
92
|
+
export async function createMcpBridge(config) {
|
|
93
|
+
const tasks = [];
|
|
94
|
+
const dt = config.mcp.chromeDevtools;
|
|
95
|
+
if (dt.enabled) {
|
|
96
|
+
tasks.push(connectServer({ name: "chromeDevtools", transport: "stdio", command: dt.command, args: dt.args, toolPrefix: dt.toolPrefix }));
|
|
97
|
+
}
|
|
98
|
+
for (const srv of config.mcp.servers) {
|
|
99
|
+
if (srv.enabled)
|
|
100
|
+
tasks.push(connectServer(srv, config));
|
|
101
|
+
}
|
|
102
|
+
if (tasks.length === 0)
|
|
103
|
+
return NOOP_BRIDGE;
|
|
104
|
+
const bridges = await Promise.all(tasks);
|
|
105
|
+
const mergedTools = {};
|
|
106
|
+
const mergedNames = [];
|
|
107
|
+
const closeFns = [];
|
|
108
|
+
for (const b of bridges) {
|
|
109
|
+
Object.assign(mergedTools, b.tools);
|
|
110
|
+
mergedNames.push(...b.toolNames);
|
|
111
|
+
closeFns.push(b.close);
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
tools: mergedTools,
|
|
115
|
+
toolNames: mergedNames,
|
|
116
|
+
close: async () => { await Promise.all(closeFns.map((fn) => fn())); }
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { exec } from "node:child_process";
|
|
4
|
+
import { saveGlobalMcpConfig } from "../config/load-config.js";
|
|
5
|
+
function toBase64Url(buf) {
|
|
6
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
7
|
+
}
|
|
8
|
+
function createVerifier() {
|
|
9
|
+
return toBase64Url(randomBytes(48));
|
|
10
|
+
}
|
|
11
|
+
function createChallenge(verifier) {
|
|
12
|
+
return toBase64Url(createHash("sha256").update(verifier).digest());
|
|
13
|
+
}
|
|
14
|
+
async function fetchJson(url) {
|
|
15
|
+
try {
|
|
16
|
+
const res = await fetch(url, { headers: { Accept: "application/json" }, redirect: "error" });
|
|
17
|
+
if (!res.ok)
|
|
18
|
+
return null;
|
|
19
|
+
return JSON.parse(await res.text());
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function tryDiscoverFromIssuer(issuer) {
|
|
26
|
+
for (const path of ["/.well-known/oauth-authorization-server", "/.well-known/openid-configuration"]) {
|
|
27
|
+
const meta = await fetchJson(issuer.replace(/\/+$/, "") + path);
|
|
28
|
+
if (meta?.authorization_endpoint && meta?.token_endpoint) {
|
|
29
|
+
return {
|
|
30
|
+
authorizationUrl: meta.authorization_endpoint,
|
|
31
|
+
tokenUrl: meta.token_endpoint,
|
|
32
|
+
registrationUrl: meta.registration_endpoint ?? null,
|
|
33
|
+
suggestedScope: meta.scopes_supported?.join(" ") ?? null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
function parseBearerResourceMetadata(wwwAuthenticate) {
|
|
40
|
+
if (!wwwAuthenticate)
|
|
41
|
+
return { resourceMetadataUrl: null, scope: null };
|
|
42
|
+
const bearerMatch = wwwAuthenticate.match(/Bearer\s+(.+)/i);
|
|
43
|
+
if (!bearerMatch?.[1])
|
|
44
|
+
return { resourceMetadataUrl: null, scope: null };
|
|
45
|
+
const params = {};
|
|
46
|
+
const pairs = bearerMatch[1].match(/([a-zA-Z_]+)\s*=\s*("[^"]*"|[^,\s]+)/g) ?? [];
|
|
47
|
+
for (const pair of pairs) {
|
|
48
|
+
const eqIdx = pair.indexOf("=");
|
|
49
|
+
const key = pair.slice(0, eqIdx).trim().toLowerCase();
|
|
50
|
+
const val = pair.slice(eqIdx + 1).trim().replace(/^"|"$/g, "");
|
|
51
|
+
params[key] = val;
|
|
52
|
+
}
|
|
53
|
+
return { resourceMetadataUrl: params["resource_metadata"] ?? null, scope: params["scope"] ?? null };
|
|
54
|
+
}
|
|
55
|
+
async function probeServerChallenge(url) {
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetch(url, { method: "GET", headers: { Accept: "application/json" }, redirect: "error" }).catch(() => null);
|
|
58
|
+
return parseBearerResourceMetadata(res?.headers.get("www-authenticate") ?? null);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return { resourceMetadataUrl: null, scope: null };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function tryResourceMetadata(url) {
|
|
65
|
+
const rm = await fetchJson(url);
|
|
66
|
+
return rm?.authorization_servers?.[0] ?? null;
|
|
67
|
+
}
|
|
68
|
+
export async function discoverOAuthEndpoints(srv) {
|
|
69
|
+
if (srv.oauthAuthorizationUrl && srv.oauthTokenUrl) {
|
|
70
|
+
return { authorizationUrl: srv.oauthAuthorizationUrl, tokenUrl: srv.oauthTokenUrl, registrationUrl: null, suggestedScope: null };
|
|
71
|
+
}
|
|
72
|
+
const issuer = srv.oauthIssuerUrl ?? null;
|
|
73
|
+
if (issuer) {
|
|
74
|
+
const found = await tryDiscoverFromIssuer(issuer);
|
|
75
|
+
if (found)
|
|
76
|
+
return found;
|
|
77
|
+
}
|
|
78
|
+
if (srv.url) {
|
|
79
|
+
const parsed = new URL(srv.url);
|
|
80
|
+
const origin = parsed.origin;
|
|
81
|
+
const path = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, "");
|
|
82
|
+
// Step 1: probe the MCP server URL for WWW-Authenticate: Bearer resource_metadata=...
|
|
83
|
+
const challenge = await probeServerChallenge(srv.url);
|
|
84
|
+
const rmCandidates = [
|
|
85
|
+
...(challenge.resourceMetadataUrl ? [challenge.resourceMetadataUrl] : []),
|
|
86
|
+
...(path ? [`${origin}/.well-known/oauth-protected-resource${path}`] : []),
|
|
87
|
+
`${origin}/.well-known/oauth-protected-resource`,
|
|
88
|
+
];
|
|
89
|
+
for (const rmUrl of rmCandidates) {
|
|
90
|
+
const asIssuer = await tryResourceMetadata(rmUrl);
|
|
91
|
+
if (asIssuer) {
|
|
92
|
+
const found = await tryDiscoverFromIssuer(asIssuer);
|
|
93
|
+
if (found)
|
|
94
|
+
return { ...found, suggestedScope: found.suggestedScope ?? challenge.scope };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Step 2: try AS discovery directly on origin
|
|
98
|
+
const fromOrigin = await tryDiscoverFromIssuer(origin);
|
|
99
|
+
if (fromOrigin)
|
|
100
|
+
return fromOrigin;
|
|
101
|
+
}
|
|
102
|
+
throw new Error(`Could not discover OAuth endpoints for "${srv.name}". ` +
|
|
103
|
+
"Provide --oauth-issuer <url> or --oauth-auth-url + --oauth-token-url when adding.");
|
|
104
|
+
}
|
|
105
|
+
async function registerDynamicClient(registrationUrl, redirectUri) {
|
|
106
|
+
const res = await fetch(registrationUrl, {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
109
|
+
body: JSON.stringify({
|
|
110
|
+
client_name: "Scira CLI",
|
|
111
|
+
redirect_uris: [redirectUri],
|
|
112
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
113
|
+
response_types: ["code"],
|
|
114
|
+
token_endpoint_auth_method: "none",
|
|
115
|
+
}),
|
|
116
|
+
});
|
|
117
|
+
if (!res.ok)
|
|
118
|
+
throw new Error("Dynamic client registration failed — provide --oauth-client-id manually");
|
|
119
|
+
const data = JSON.parse(await res.text());
|
|
120
|
+
if (!data.client_id)
|
|
121
|
+
throw new Error("Dynamic registration did not return client_id — provide --oauth-client-id manually");
|
|
122
|
+
return { clientId: data.client_id, clientSecret: data.client_secret };
|
|
123
|
+
}
|
|
124
|
+
function patchClientId(mcp, name, clientId, clientSecret) {
|
|
125
|
+
return {
|
|
126
|
+
...mcp,
|
|
127
|
+
servers: mcp.servers.map((s) => s.name === name ? { ...s, oauthClientId: clientId, ...(clientSecret ? { oauthClientSecret: clientSecret } : {}) } : s),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function openBrowser(url) {
|
|
131
|
+
const cmd = process.platform === "darwin" ? `open "${url}"` :
|
|
132
|
+
process.platform === "win32" ? `start "" "${url}"` :
|
|
133
|
+
`xdg-open "${url}"`;
|
|
134
|
+
exec(cmd, () => { });
|
|
135
|
+
}
|
|
136
|
+
function waitForCallback(port, timeoutMs = 120_000) {
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
const server = createServer((req, res) => {
|
|
139
|
+
const u = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
140
|
+
const code = u.searchParams.get("code");
|
|
141
|
+
const state = u.searchParams.get("state");
|
|
142
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
143
|
+
res.end("<html><body><h2>OAuth connected — you can close this tab.</h2></body></html>");
|
|
144
|
+
server.close();
|
|
145
|
+
if (code && state)
|
|
146
|
+
resolve({ code, state });
|
|
147
|
+
else
|
|
148
|
+
reject(new Error("OAuth callback missing code or state"));
|
|
149
|
+
});
|
|
150
|
+
server.listen(port, "127.0.0.1");
|
|
151
|
+
const timer = setTimeout(() => {
|
|
152
|
+
server.close();
|
|
153
|
+
reject(new Error("OAuth flow timed out (2 min). Run `scira mcp oauth <name>` to retry."));
|
|
154
|
+
}, timeoutMs);
|
|
155
|
+
server.once("close", () => clearTimeout(timer));
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
async function exchangeCode(opts) {
|
|
159
|
+
const body = new URLSearchParams({
|
|
160
|
+
grant_type: "authorization_code",
|
|
161
|
+
code: opts.code,
|
|
162
|
+
redirect_uri: opts.redirectUri,
|
|
163
|
+
client_id: opts.clientId,
|
|
164
|
+
code_verifier: opts.verifier,
|
|
165
|
+
});
|
|
166
|
+
if (opts.clientSecret)
|
|
167
|
+
body.set("client_secret", opts.clientSecret);
|
|
168
|
+
const res = await fetch(opts.tokenUrl, {
|
|
169
|
+
method: "POST",
|
|
170
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
171
|
+
body,
|
|
172
|
+
});
|
|
173
|
+
const data = JSON.parse(await res.text());
|
|
174
|
+
if (!res.ok || !data.access_token) {
|
|
175
|
+
throw new Error(data.error_description ?? data.error ?? "Token exchange failed");
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
accessToken: data.access_token,
|
|
179
|
+
refreshToken: data.refresh_token,
|
|
180
|
+
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
async function refreshToken(opts) {
|
|
184
|
+
const body = new URLSearchParams({
|
|
185
|
+
grant_type: "refresh_token",
|
|
186
|
+
refresh_token: opts.refreshToken,
|
|
187
|
+
client_id: opts.clientId,
|
|
188
|
+
});
|
|
189
|
+
if (opts.clientSecret)
|
|
190
|
+
body.set("client_secret", opts.clientSecret);
|
|
191
|
+
const res = await fetch(opts.tokenUrl, {
|
|
192
|
+
method: "POST",
|
|
193
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
194
|
+
body,
|
|
195
|
+
});
|
|
196
|
+
const data = JSON.parse(await res.text());
|
|
197
|
+
if (!res.ok || !data.access_token) {
|
|
198
|
+
throw new Error(data.error_description ?? data.error ?? "Token refresh failed");
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
accessToken: data.access_token,
|
|
202
|
+
refreshToken: data.refresh_token ?? opts.refreshToken,
|
|
203
|
+
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function patchTokens(mcp, name, tokens) {
|
|
207
|
+
return {
|
|
208
|
+
...mcp,
|
|
209
|
+
servers: mcp.servers.map((s) => s.name === name
|
|
210
|
+
? { ...s, oauthAccessToken: tokens.accessToken, oauthRefreshToken: tokens.refreshToken, oauthTokenExpiresAt: tokens.expiresAt }
|
|
211
|
+
: s),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
export async function runOAuthFlow(srv, config) {
|
|
215
|
+
const port = 49152 + Math.floor(Math.random() * 16383);
|
|
216
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
217
|
+
const endpoints = await discoverOAuthEndpoints(srv);
|
|
218
|
+
const { authorizationUrl, tokenUrl } = endpoints;
|
|
219
|
+
let clientId = srv.oauthClientId;
|
|
220
|
+
let clientSecret = srv.oauthClientSecret;
|
|
221
|
+
let updatedConfig = config;
|
|
222
|
+
if (!clientId) {
|
|
223
|
+
if (!endpoints.registrationUrl) {
|
|
224
|
+
throw new Error(`"${srv.name}" does not support dynamic client registration. ` +
|
|
225
|
+
"Re-add with --oauth-client-id <id> (see the provider's app/developer settings).");
|
|
226
|
+
}
|
|
227
|
+
process.stdout.write(`Registering Scira CLI with ${srv.name} OAuth server…\n`);
|
|
228
|
+
const reg = await registerDynamicClient(endpoints.registrationUrl, redirectUri);
|
|
229
|
+
clientId = reg.clientId;
|
|
230
|
+
clientSecret = reg.clientSecret ?? clientSecret;
|
|
231
|
+
const patchedMcp = patchClientId(config.mcp, srv.name, clientId, clientSecret);
|
|
232
|
+
await saveGlobalMcpConfig(patchedMcp);
|
|
233
|
+
updatedConfig = { ...config, mcp: patchedMcp };
|
|
234
|
+
process.stdout.write(`Registered client_id: ${clientId}\n`);
|
|
235
|
+
}
|
|
236
|
+
const verifier = createVerifier();
|
|
237
|
+
const challenge = createChallenge(verifier);
|
|
238
|
+
const state = toBase64Url(randomBytes(16));
|
|
239
|
+
const scope = srv.oauthScopes ?? endpoints.suggestedScope ?? undefined;
|
|
240
|
+
const params = new URLSearchParams({
|
|
241
|
+
response_type: "code",
|
|
242
|
+
client_id: clientId,
|
|
243
|
+
redirect_uri: redirectUri,
|
|
244
|
+
code_challenge: challenge,
|
|
245
|
+
code_challenge_method: "S256",
|
|
246
|
+
state,
|
|
247
|
+
});
|
|
248
|
+
if (scope)
|
|
249
|
+
params.set("scope", scope);
|
|
250
|
+
const fullUrl = `${authorizationUrl}?${params}`;
|
|
251
|
+
process.stdout.write(`\nOpening browser for OAuth…\nIf it didn't open, paste this URL:\n${fullUrl}\n\nWaiting for callback on ${redirectUri} …\n`);
|
|
252
|
+
openBrowser(fullUrl);
|
|
253
|
+
const { code, state: returnedState } = await waitForCallback(port);
|
|
254
|
+
if (returnedState !== state)
|
|
255
|
+
throw new Error("OAuth state mismatch — possible CSRF");
|
|
256
|
+
const tokens = await exchangeCode({ tokenUrl, code, verifier, redirectUri, clientId, clientSecret });
|
|
257
|
+
await saveGlobalMcpConfig(patchTokens(updatedConfig.mcp, srv.name, tokens));
|
|
258
|
+
process.stdout.write(`\nOAuth connected for "${srv.name}". Token saved to ~/.scira/config.json.\n`);
|
|
259
|
+
}
|
|
260
|
+
export async function resolveOAuthToken(srv, config) {
|
|
261
|
+
if (srv.oauthAccessToken && (!srv.oauthTokenExpiresAt || srv.oauthTokenExpiresAt - Date.now() > 60_000)) {
|
|
262
|
+
return srv.oauthAccessToken;
|
|
263
|
+
}
|
|
264
|
+
if (srv.oauthRefreshToken && srv.oauthClientId) {
|
|
265
|
+
const { tokenUrl } = await discoverOAuthEndpoints(srv);
|
|
266
|
+
const tokens = await refreshToken({
|
|
267
|
+
tokenUrl,
|
|
268
|
+
refreshToken: srv.oauthRefreshToken,
|
|
269
|
+
clientId: srv.oauthClientId,
|
|
270
|
+
clientSecret: srv.oauthClientSecret,
|
|
271
|
+
});
|
|
272
|
+
await saveGlobalMcpConfig(patchTokens(config.mcp, srv.name, tokens));
|
|
273
|
+
return tokens.accessToken;
|
|
274
|
+
}
|
|
275
|
+
throw new Error(`OAuth session expired for "${srv.name}". Run: scira mcp oauth ${srv.name}`);
|
|
276
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { Readability } from "@mozilla/readability";
|
|
5
|
+
import { JSDOM } from "jsdom";
|
|
6
|
+
import { Exa } from "exa-js";
|
|
7
|
+
import Parallel from "parallel-web";
|
|
8
|
+
import { Firecrawl } from "@mendable/firecrawl-js";
|
|
9
|
+
import { requireSearchProvider } from "../providers/llm/readiness.js";
|
|
10
|
+
// ── lazy singleton clients ──
|
|
11
|
+
let _exa;
|
|
12
|
+
let _parallel;
|
|
13
|
+
let _firecrawl;
|
|
14
|
+
function getExa() {
|
|
15
|
+
requireSearchProvider("exa");
|
|
16
|
+
if (!_exa)
|
|
17
|
+
_exa = new Exa(process.env.EXA_API_KEY);
|
|
18
|
+
return _exa;
|
|
19
|
+
}
|
|
20
|
+
function getParallel() {
|
|
21
|
+
requireSearchProvider("parallel");
|
|
22
|
+
if (!_parallel)
|
|
23
|
+
_parallel = new Parallel({ apiKey: process.env.PARALLEL_API_KEY });
|
|
24
|
+
return _parallel;
|
|
25
|
+
}
|
|
26
|
+
function getFirecrawl() {
|
|
27
|
+
requireSearchProvider("firecrawl");
|
|
28
|
+
if (!_firecrawl)
|
|
29
|
+
_firecrawl = new Firecrawl({ apiKey: process.env.FIRECRAWL_API_KEY });
|
|
30
|
+
return _firecrawl;
|
|
31
|
+
}
|
|
32
|
+
// ── provider extractors ──
|
|
33
|
+
async function parallelExtract(url, config) {
|
|
34
|
+
const parallel = getParallel();
|
|
35
|
+
const response = await parallel.extract({
|
|
36
|
+
urls: [url],
|
|
37
|
+
max_chars_total: config.search.maxCharsTotal ?? 10000,
|
|
38
|
+
advanced_settings: { full_content: true }
|
|
39
|
+
});
|
|
40
|
+
const result = response.results?.[0];
|
|
41
|
+
if (!result)
|
|
42
|
+
return undefined;
|
|
43
|
+
const text = (result.full_content ?? "") || (result.excerpts ?? []).join("\n\n");
|
|
44
|
+
if (!text.trim())
|
|
45
|
+
return undefined;
|
|
46
|
+
return { title: result.title ?? url, text: text.trim() };
|
|
47
|
+
}
|
|
48
|
+
async function exaExtract(url) {
|
|
49
|
+
const exa = getExa();
|
|
50
|
+
const response = await exa.getContents([url], { text: true });
|
|
51
|
+
const result = response.results?.[0];
|
|
52
|
+
if (!result?.text?.trim())
|
|
53
|
+
return undefined;
|
|
54
|
+
return { title: result.title ?? url, text: result.text.trim() };
|
|
55
|
+
}
|
|
56
|
+
async function firecrawlScrape(url) {
|
|
57
|
+
const firecrawl = getFirecrawl();
|
|
58
|
+
const doc = await firecrawl.scrape(url, { formats: ["markdown"] });
|
|
59
|
+
const text = doc.markdown ?? doc.summary ?? "";
|
|
60
|
+
if (!text.trim())
|
|
61
|
+
return undefined;
|
|
62
|
+
return { title: doc.metadata?.title ?? url, text: text.trim() };
|
|
63
|
+
}
|
|
64
|
+
const EXTRACTORS = {
|
|
65
|
+
parallel: (url, config) => parallelExtract(url, config),
|
|
66
|
+
exa: (url) => exaExtract(url),
|
|
67
|
+
firecrawl: (url) => firecrawlScrape(url)
|
|
68
|
+
};
|
|
69
|
+
// ── raw fetch + readability fallback ──
|
|
70
|
+
async function rawFetch(url) {
|
|
71
|
+
const response = await fetch(url, { headers: { "user-agent": "scira-cli/0.1" } });
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
|
|
74
|
+
}
|
|
75
|
+
const html = await response.text();
|
|
76
|
+
const dom = new JSDOM(html, { url });
|
|
77
|
+
const article = new Readability(dom.window.document).parse();
|
|
78
|
+
return {
|
|
79
|
+
title: article?.title ?? dom.window.document.title ?? url,
|
|
80
|
+
text: article?.textContent?.trim() || dom.window.document.body.textContent?.trim() || ""
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export async function openUrl(url, config) {
|
|
84
|
+
const provider = config.search.provider;
|
|
85
|
+
try {
|
|
86
|
+
const page = await EXTRACTORS[provider](url, config);
|
|
87
|
+
if (page)
|
|
88
|
+
return page;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// fall through to raw fetch
|
|
92
|
+
}
|
|
93
|
+
return rawFetch(url);
|
|
94
|
+
}
|
|
95
|
+
export async function writeSnapshot(snapshotsDir, sourceId, page) {
|
|
96
|
+
const path = join(snapshotsDir, `${sourceId}.md`);
|
|
97
|
+
await writeFile(path, `# ${page.title}\n\n${page.text}\n`);
|
|
98
|
+
return path;
|
|
99
|
+
}
|