@getopencrawl/cli 0.1.1 → 0.2.1
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 +75 -298
- package/bin/opencrawl.cjs +168 -0
- package/package.json +20 -14
- package/bin/opencrawl.mjs +0 -553
- package/browser.mjs +0 -166
- package/src/accounts.test.mjs +0 -223
- package/src/agent.mjs +0 -95
- package/src/agent.test.mjs +0 -58
- package/src/answer-contract.mjs +0 -112
- package/src/answer-view.mjs +0 -118
- package/src/browser.test.mjs +0 -72
- package/src/capture.mjs +0 -273
- package/src/capture.test.mjs +0 -171
- package/src/client.mjs +0 -183
- package/src/client.test.mjs +0 -34
- package/src/connect.mjs +0 -230
- package/src/connect.test.mjs +0 -131
- package/src/context.mjs +0 -34
- package/src/context.test.mjs +0 -32
- package/src/display.mjs +0 -4
- package/src/fixtures/letter-connections.json +0 -59
- package/src/fixtures/product-monitor-contract.json +0 -312
- package/src/fixtures/research-completion-hosted.json +0 -502
- package/src/fixtures/research-completion-v1.json +0 -58
- package/src/fixtures/research-completion-v2.json +0 -33
- package/src/fixtures/research-presentation-hosted.json +0 -720
- package/src/jobs.mjs +0 -117
- package/src/jobs.test.mjs +0 -67
- package/src/mcp-conversations.test.mjs +0 -80
- package/src/monitors.mjs +0 -146
- package/src/monitors.test.mjs +0 -62
- package/src/product-letter-capabilities.mjs +0 -71
- package/src/product-letter-read.mjs +0 -178
- package/src/product-monitor-login.test.mjs +0 -140
- package/src/product-monitors.mjs +0 -321
- package/src/product-monitors.test.mjs +0 -69
- package/src/research-agent-errors.test.mjs +0 -110
- package/src/research-agent-feedback.test.mjs +0 -28
- package/src/research-agent.mjs +0 -164
- package/src/research-agent.test.mjs +0 -231
- package/src/research-answer-contract.mjs +0 -3
- package/src/research-answer-contract.test.mjs +0 -14
- package/src/research-answer-view.mjs +0 -7
- package/src/research-answer-view.test.mjs +0 -27
- package/src/research-completion.mjs +0 -94
- package/src/research-completion.test.mjs +0 -110
- package/src/research-event.mjs +0 -39
- package/src/research-event.test.mjs +0 -57
- package/src/research-presentation.test.mjs +0 -56
- package/src/research.mjs +0 -141
- package/src/research.test.mjs +0 -97
- package/src/sessions.mjs +0 -165
- package/src/sessions.test.mjs +0 -151
- package/src/tasks.mjs +0 -115
- package/src/tasks.test.mjs +0 -83
- package/src/upload-commands.test.mjs +0 -140
- package/src/uploads.mjs +0 -141
- package/src/uploads.test.mjs +0 -64
package/package.json
CHANGED
|
@@ -1,27 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getopencrawl/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "OpenCrawl CLI: sign in now, query the index as features become available",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"license": "UNLICENSED",
|
|
6
7
|
"bin": {
|
|
7
|
-
"opencrawl": "bin/opencrawl.
|
|
8
|
+
"opencrawl": "bin/opencrawl.cjs"
|
|
8
9
|
},
|
|
9
|
-
"exports": "./src/client.mjs",
|
|
10
10
|
"files": [
|
|
11
|
-
"bin",
|
|
12
|
-
"
|
|
13
|
-
"README.md",
|
|
14
|
-
"browser.mjs"
|
|
11
|
+
"bin/opencrawl.cjs",
|
|
12
|
+
"README.md"
|
|
15
13
|
],
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/1patch/opencrawl-core.git",
|
|
17
|
+
"directory": "cli"
|
|
19
18
|
},
|
|
20
19
|
"engines": {
|
|
21
|
-
"node": ">=
|
|
20
|
+
"node": ">=22"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public",
|
|
24
|
+
"registry": "https://registry.npmjs.org"
|
|
22
25
|
},
|
|
23
|
-
"license": "UNLICENSED",
|
|
24
26
|
"optionalDependencies": {
|
|
25
|
-
"
|
|
27
|
+
"@getopencrawl/cli-darwin-arm64": "0.2.1",
|
|
28
|
+
"@getopencrawl/cli-darwin-x64": "0.2.1",
|
|
29
|
+
"@getopencrawl/cli-linux-arm64": "0.2.1",
|
|
30
|
+
"@getopencrawl/cli-linux-x64": "0.2.1",
|
|
31
|
+
"@getopencrawl/cli-win32-x64": "0.2.1"
|
|
26
32
|
}
|
|
27
33
|
}
|
package/bin/opencrawl.mjs
DELETED
|
@@ -1,553 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import fs from 'node:fs/promises';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import crypto from 'node:crypto';
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import readline from 'node:readline';
|
|
7
|
-
import { createClient, readConfig, writeConfig, validateBaseUrl, configDir, loggedInConfig, loggedOutConfig, profileSummaries, resolveCredentials } from '../src/client.mjs';
|
|
8
|
-
import { SESSIONS_HELP, sessionsCommand } from '../src/sessions.mjs';
|
|
9
|
-
import { RESEARCH_HELP, researchCommand } from '../src/research.mjs';
|
|
10
|
-
import { TASKS_HELP, tasksCommand } from '../src/tasks.mjs';
|
|
11
|
-
import { MONITORS_HELP, monitorsCommand } from '../src/monitors.mjs';
|
|
12
|
-
import { PRODUCT_MONITORS_HELP, productMonitorsCommand } from '../src/product-monitors.mjs';
|
|
13
|
-
import { CONNECT_HELP, connectCommand } from '../src/connect.mjs';
|
|
14
|
-
import { displayText, displayLine } from '../src/display.mjs';
|
|
15
|
-
import { JOBS_HELP, jobsCommand, allowedOptions, waitOptions, withInterrupt, requestFailure } from '../src/jobs.mjs';
|
|
16
|
-
import { UPLOADS_HELP, PROVIDERS, submitUpload, finishUpload, lookupReceipt, uploadsCommand } from '../src/uploads.mjs';
|
|
17
|
-
import { coverageWarnings, hookContext, HOOK_RETRIEVAL_BUDGET, utf8Prefix } from '../src/context.mjs';
|
|
18
|
-
import { CAPTURE_HELP, AGENT_OPTIONS, agentClient, verifyAgentIdentity, configureCapture, assertCaptureHook, captureHook, captureCommand } from '../src/capture.mjs';
|
|
19
|
-
|
|
20
|
-
const SCRIPT = fileURLToPath(import.meta.url);
|
|
21
|
-
let productMonitorJson = false;
|
|
22
|
-
const WATCH_HELP = `OpenCrawl · watch an explicit export folder
|
|
23
|
-
|
|
24
|
-
opencrawl watch DIRECTORY --provider P [--once] [--json]
|
|
25
|
-
[--source ID] [--visibility private|workspace]
|
|
26
|
-
|
|
27
|
-
Providers: auto, chatgpt, claude, claude-code, codex, generic.
|
|
28
|
-
Only JSON/JSONL files are watched. Hidden paths and symbolic links are skipped.
|
|
29
|
-
--once makes one pass; otherwise this runs in the foreground until Ctrl+C.
|
|
30
|
-
Pending request keys are saved before sending and looked up after restart.
|
|
31
|
-
Unconfirmed, prepared, retryable, or expired requests are never resent automatically.
|
|
32
|
-
Use uploads lookup and explicit import --request-key with the original scope.
|
|
33
|
-
Global options: --account PROFILE_ID --server URL --config-dir DIRECTORY --json
|
|
34
|
-
`;
|
|
35
|
-
const HELP = `OpenCrawl · knowledge that follows your work
|
|
36
|
-
|
|
37
|
-
New here? Start free, no card or browser needed:
|
|
38
|
-
opencrawl agent signup --organization NAME --email EMAIL Create a free organization; its key is saved
|
|
39
|
-
opencrawl agent "what data you need" The OpenCrawl agent plans it; then --session ID --build
|
|
40
|
-
Guide: https://app.getopencrawl.com/start.md
|
|
41
|
-
|
|
42
|
-
opencrawl login [--server URL] Save a workspace API key securely
|
|
43
|
-
opencrawl configure --server URL Change the server
|
|
44
|
-
opencrawl profiles List saved workspace account profiles
|
|
45
|
-
opencrawl agent --help Ask the OpenCrawl agent to build a data project
|
|
46
|
-
opencrawl search "question" [--json] Search connected knowledge
|
|
47
|
-
opencrawl context "task" [--budget N] Get a compact, cited context pack
|
|
48
|
-
opencrawl research --help Collect and search public-source research
|
|
49
|
-
opencrawl tasks --help Run saved questions against current evidence
|
|
50
|
-
opencrawl monitors --help Inspect selected public-page changes
|
|
51
|
-
opencrawl sessions --help Read and export imported conversation ranges
|
|
52
|
-
opencrawl import FILE [--provider P] Import a JSON/ZIP chat export or text
|
|
53
|
-
opencrawl ingest FILE [--wait] Upload a document, image, audio, or video
|
|
54
|
-
opencrawl jobs --help List, inspect, or wait for a background job
|
|
55
|
-
opencrawl uploads --help Recover an upload using its saved receipt
|
|
56
|
-
opencrawl watch DIRECTORY --provider P Watch an explicitly selected export folder
|
|
57
|
-
opencrawl connect --help Authorize, configure and sync an app
|
|
58
|
-
opencrawl status Show live workspace usage
|
|
59
|
-
opencrawl doctor Check local setup and server health
|
|
60
|
-
opencrawl mcp Run the MCP stdio bridge
|
|
61
|
-
opencrawl install claude-code Add local MCP + prompt context hook
|
|
62
|
-
opencrawl install cursor|codex Add project-local MCP configuration
|
|
63
|
-
opencrawl install claude-code|codex --capture Also capture new private messages
|
|
64
|
-
opencrawl capture --help Inspect or flush project capture
|
|
65
|
-
opencrawl browser monitor --help Capture and compare private product screens
|
|
66
|
-
opencrawl browser login URL Sign in to an isolated browser profile
|
|
67
|
-
opencrawl browser capture URL Index an explicitly selected page
|
|
68
|
-
opencrawl browser crawl URL Crawl selected origin and path scope
|
|
69
|
-
opencrawl hook Run Claude Code's UserPromptSubmit hook
|
|
70
|
-
opencrawl logout Remove the locally saved API key
|
|
71
|
-
|
|
72
|
-
Options: --server URL --account PROFILE_ID --json --limit N --source ID --customer NAME (search/context: one customer's index) --once (watch)
|
|
73
|
-
Providers: auto, chatgpt, claude, claude-code, codex, generic
|
|
74
|
-
Credentials: OPENCRAWL_API_KEY, OPENCRAWL_URL. Node.js 20+.
|
|
75
|
-
Hosted ChatGPT/Claude history is imported from an export; it is not silently read.
|
|
76
|
-
`;
|
|
77
|
-
|
|
78
|
-
function parseArgs(args) {
|
|
79
|
-
const positional = [], options = {}, duplicates = [], valuedFlags = [];
|
|
80
|
-
for (let index = 0; index < args.length; index++) {
|
|
81
|
-
const arg = args[index];
|
|
82
|
-
if (!arg.startsWith('--')) { positional.push(arg); continue; }
|
|
83
|
-
const [key, inline] = arg.slice(2).split(/=(.*)/s);
|
|
84
|
-
if (key !== 'selection' && Object.hasOwn(options,key)) duplicates.push(key);
|
|
85
|
-
if (['json','help','version','wait','once','acknowledge-unknown','capture','content-only','build','check','new-key','create-new','print-keys'].includes(key) && inline !== undefined) valuedFlags.push(key);
|
|
86
|
-
if (['json', 'once', 'help', 'version', 'headless', 'interactive', 'ocr', 'wait','acknowledge-unknown','capture','content-only','build','check','new-key','create-new','print-keys'].includes(key)) options[key] = true;
|
|
87
|
-
else {
|
|
88
|
-
const value = inline ?? args[++index];
|
|
89
|
-
if (value === undefined || value.startsWith('--')) throw new Error(`Missing value for --${key}`);
|
|
90
|
-
if (key === 'selection') (options.selection ||= []).push(value);
|
|
91
|
-
else options[key] = value;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
if (['research','sessions','tasks','monitors','jobs','uploads','import','ingest','watch','connect','capture','capture-hook','install','browser'].includes(positional[0]) && duplicates.length) throw new Error(`Duplicate option: --${duplicates[0]}`);
|
|
95
|
-
if (['research','sessions','tasks','monitors','jobs','uploads','import','ingest','watch','connect','capture','capture-hook','install','browser'].includes(positional[0]) && valuedFlags.length) throw new Error(`--${valuedFlags[0]} does not take a value.`);
|
|
96
|
-
return { positional, options };
|
|
97
|
-
}
|
|
98
|
-
function integer(value, fallback, minimum = 1, maximum = 16000) {
|
|
99
|
-
if (value === undefined) return fallback;
|
|
100
|
-
const number = Number(value);
|
|
101
|
-
if (!Number.isInteger(number) || number < minimum || number > maximum) throw new Error(`Expected a number between ${minimum} and ${maximum}.`);
|
|
102
|
-
return number;
|
|
103
|
-
}
|
|
104
|
-
function printRpc(value) { process.stdout.write(JSON.stringify(value) + '\n'); }
|
|
105
|
-
function print(value) { process.stdout.write(typeof value === 'string' ? value + '\n' : JSON.stringify(value, null, 2) + '\n'); }
|
|
106
|
-
function shellQuote(value) { return "'" + value.replaceAll("'", "'\\''") + "'"; }
|
|
107
|
-
async function readStdin(max = 2_000_000) {
|
|
108
|
-
let text = '';
|
|
109
|
-
for await (const chunk of process.stdin) {
|
|
110
|
-
text += chunk;
|
|
111
|
-
if (text.length > max) throw new Error('Input exceeds the size limit.');
|
|
112
|
-
}
|
|
113
|
-
return text;
|
|
114
|
-
}
|
|
115
|
-
async function askSecret(prompt) {
|
|
116
|
-
if (!process.stdin.isTTY) return (await readStdin(10000)).trim();
|
|
117
|
-
process.stderr.write(prompt);
|
|
118
|
-
process.stdin.setRawMode(true); process.stdin.resume();
|
|
119
|
-
return new Promise((resolve, reject) => {
|
|
120
|
-
let text = '';
|
|
121
|
-
function finish(error) {
|
|
122
|
-
process.stdin.setRawMode(false); process.stdin.pause(); process.stdin.off('data', onData); process.stderr.write('\n');
|
|
123
|
-
error ? reject(error) : resolve(text);
|
|
124
|
-
}
|
|
125
|
-
function onData(chunk) {
|
|
126
|
-
for (const char of chunk.toString()) {
|
|
127
|
-
if (char === '\u0003') return finish(new Error('Cancelled.'));
|
|
128
|
-
if (char === '\r' || char === '\n') return finish();
|
|
129
|
-
if (char === '\u007f' || char === '\b') text = text.slice(0, -1);
|
|
130
|
-
else if (char >= ' ') text += char;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
process.stdin.on('data', onData);
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
async function backupWrite(file, text) {
|
|
137
|
-
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
138
|
-
try {
|
|
139
|
-
const current = await fs.readFile(file, 'utf8');
|
|
140
|
-
if (current === text) return false;
|
|
141
|
-
await fs.copyFile(file, `${file}.opencrawl-backup-${Date.now()}`);
|
|
142
|
-
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
143
|
-
const temp = `${file}.${process.pid}.tmp`;
|
|
144
|
-
await fs.writeFile(temp, text, { mode: 0o600 });
|
|
145
|
-
await fs.rename(temp, file);
|
|
146
|
-
return true;
|
|
147
|
-
}
|
|
148
|
-
async function readJsonIfPresent(file) {
|
|
149
|
-
try { return JSON.parse(await fs.readFile(file, 'utf8')); }
|
|
150
|
-
catch (error) { if (error.code === 'ENOENT') return {}; throw new Error(`Cannot parse ${file}; no settings were changed.`); }
|
|
151
|
-
}
|
|
152
|
-
async function install(clientName, options) {
|
|
153
|
-
if (!['claude-code', 'cursor', 'codex'].includes(clientName)) throw new Error('Install targets: claude-code, cursor, codex');
|
|
154
|
-
allowedOptions(options,['capture','credential-env'],'install');
|
|
155
|
-
if(options.capture && clientName==='cursor')throw new Error('New-message capture supports Claude Code and Codex. Cursor installation provides MCP retrieval.');
|
|
156
|
-
const client = await agentClient(options,{installing:true});
|
|
157
|
-
if (!client.configured || client.credentialSource !== 'profile' && !options['credential-env'])
|
|
158
|
-
throw new Error('Sign in to save a verified workspace account before installing. Use --account PROFILE_ID to select a saved account when shell credentials are set.');
|
|
159
|
-
const root = await fs.realpath(process.cwd());
|
|
160
|
-
const identity=options.capture||options['credential-env']?await verifyAgentIdentity(client):null;
|
|
161
|
-
if(options['credential-env'])client.expectedIdentity={user_id:identity.user_id,workspace_id:identity.workspace_id};
|
|
162
|
-
const bound = [...(options['credential-env']?['--credential-env',options['credential-env'],'--expect-user',identity.user_id,'--expect-workspace',identity.workspace_id]:['--account', client.profile.id]), '--server', client.baseUrl, '--config-dir', path.resolve(configDir())];
|
|
163
|
-
const server = { command: process.execPath, args: [SCRIPT, 'mcp', ...bound] };
|
|
164
|
-
const captureMarker='# opencrawl-managed-capture';
|
|
165
|
-
const captureCommandText=[process.execPath,SCRIPT,'capture-hook','--provider',clientName,'--project',root,...bound].map(shellQuote).join(' ')+' '+captureMarker;
|
|
166
|
-
function captureSettings(settings) {
|
|
167
|
-
settings.hooks ||= {};
|
|
168
|
-
for(const event of ['UserPromptSubmit','Stop']) {
|
|
169
|
-
const groups=settings.hooks[event]||[];
|
|
170
|
-
if(!Array.isArray(groups)||groups.some(group=>!group||!Array.isArray(group.hooks)))throw new Error('Existing hooks are malformed; no settings were changed.');
|
|
171
|
-
const retained=groups.map(group=>({...group,hooks:group.hooks.filter(hook=>!hook.command?.includes(captureMarker)&&!(event==='UserPromptSubmit'&&hook.command?.includes('# opencrawl-managed-hook')))})).filter(group=>group.hooks.length);
|
|
172
|
-
settings.hooks[event]=[...retained,{hooks:[{type:'command',command:captureCommandText,timeout:10}]}];
|
|
173
|
-
}
|
|
174
|
-
return settings;
|
|
175
|
-
}
|
|
176
|
-
if (clientName === 'claude-code' || clientName === 'cursor') {
|
|
177
|
-
const mcpPath = path.join(root, clientName === 'cursor' ? '.cursor/mcp.json' : '.mcp.json');
|
|
178
|
-
const existing = await readJsonIfPresent(mcpPath);
|
|
179
|
-
existing.mcpServers = { ...(existing.mcpServers || {}), opencrawl: server };
|
|
180
|
-
const settingsPath = path.join(root, '.claude/settings.local.json');
|
|
181
|
-
// Parse both files before any writes so an invalid settings file cannot partially install.
|
|
182
|
-
const settings = clientName === 'claude-code' ? await readJsonIfPresent(settingsPath) : null;
|
|
183
|
-
const priorCapture=settings&&JSON.stringify(settings.hooks||{}).includes(captureMarker);
|
|
184
|
-
if(settings&&options.capture)captureSettings(settings);
|
|
185
|
-
else if (settings&&!priorCapture) {
|
|
186
|
-
const command = [process.execPath, SCRIPT, 'hook', ...bound].map(shellQuote).join(' ') + ' # opencrawl-managed-hook';
|
|
187
|
-
settings.hooks ||= {};
|
|
188
|
-
const existingHooks = settings.hooks.UserPromptSubmit || [];
|
|
189
|
-
if (!Array.isArray(existingHooks)) throw new Error('Existing Claude Code hooks are malformed; no settings were changed.');
|
|
190
|
-
const retained = existingHooks.map(group => ({...group, hooks: (group.hooks || []).filter(hook => !hook.command?.includes('# opencrawl-managed-hook'))})).filter(group => group.hooks.length);
|
|
191
|
-
settings.hooks.UserPromptSubmit = [...retained, { hooks: [{ type: 'command', command, timeout: 5 }] }];
|
|
192
|
-
}
|
|
193
|
-
if(options.capture)await configureCapture(client,clientName,root);
|
|
194
|
-
await backupWrite(mcpPath, JSON.stringify(existing, null, 2) + '\n');
|
|
195
|
-
if (settings) await backupWrite(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
196
|
-
print(`Installed OpenCrawl ${client.profile?.id||'verified environment account'} at ${client.baseUrl} in ${root}. Existing settings were preserved and changed files backed up. Restart ${clientName}.`);
|
|
197
|
-
if (settings) print(options.capture||priorCapture?'New user prompts and final assistant messages in this exact project are captured privately. No history, tool output or transcript files are read. Use capture status; capture pause stops new collection.':'Each submitted prompt now retrieves relevant OpenCrawl context. This does not upload the conversation.');
|
|
198
|
-
} else if (clientName === 'codex') {
|
|
199
|
-
const file = path.join(root, '.codex/config.toml');
|
|
200
|
-
const hooksFile=path.join(root,'.codex/hooks.json');
|
|
201
|
-
const captureHooks=options.capture?captureSettings(await readJsonIfPresent(hooksFile)):null;
|
|
202
|
-
let text = '';
|
|
203
|
-
try { text = await fs.readFile(file, 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
204
|
-
const start = '# OpenCrawl managed MCP start', end = '# OpenCrawl managed MCP end';
|
|
205
|
-
if (text.includes('[mcp_servers.opencrawl]') && !text.includes(start)) throw new Error('An unmanaged OpenCrawl MCP entry already exists. Preserve it or remove it before installing.');
|
|
206
|
-
const block = `${start}\n[mcp_servers.opencrawl]\ncommand = ${JSON.stringify(process.execPath)}\nargs = ${JSON.stringify([SCRIPT, 'mcp', ...bound])}\n${options['credential-env']?'env_vars = '+JSON.stringify([options['credential-env']])+'\n':''}${end}`;
|
|
207
|
-
const begin = text.indexOf(start), finish = text.indexOf(end);
|
|
208
|
-
if ((begin >= 0) !== (finish >= 0)) throw new Error('The existing managed MCP block is incomplete. No settings were changed.');
|
|
209
|
-
if (begin >= 0) text = text.slice(0, begin) + block + text.slice(finish + end.length);
|
|
210
|
-
else text = text.replace(/\s*$/, '') + (text.trim() ? '\n\n' : '') + block + '\n';
|
|
211
|
-
if(options.capture)await configureCapture(client,clientName,root);
|
|
212
|
-
await backupWrite(file, text);
|
|
213
|
-
if(captureHooks)await backupWrite(hooksFile,JSON.stringify(captureHooks,null,2)+'\n');
|
|
214
|
-
print(`Installed OpenCrawl MCP in ${file}. Restart Codex and trust the project to load project settings.`);
|
|
215
|
-
if(captureHooks)print('New prompts and final assistant messages in this exact project are captured privately. Review and trust these hooks with /hooks in Codex; nothing bypasses client approval. No history or tool output is read. Use capture status to confirm actual delivery.');
|
|
216
|
-
} else throw new Error('Install targets: claude-code, cursor, codex');
|
|
217
|
-
if(options.capture) {
|
|
218
|
-
print('Check delivery with this account and project binding:');
|
|
219
|
-
print(['opencrawl','capture','status','--provider',clientName,'--project',root,...bound,'--json'].map(shellQuote).join(' '));
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
async function uploadFile(file, client, options = {}, callbacks = {}) {
|
|
223
|
-
return withInterrupt(signal => submitUpload(file, client, options, { ...callbacks, signal }));
|
|
224
|
-
}
|
|
225
|
-
async function importFile(file, provider, client, options = {}) {
|
|
226
|
-
if (/\.zip$/i.test(file)) return uploadFile(file, client, { ...options, chat: true, provider: provider || 'auto' });
|
|
227
|
-
if (/\.(pdf|docx?|pptx?|xlsx?|xlsm|png|jpe?g|tiff?|heic|bmp|rtf|mp4|webm|mov|mkv|avi|mp3|m4a|wav|flac|ogg|amr)$/i.test(file)) {
|
|
228
|
-
if (provider) throw new Error('Use a JSON, JSONL, or ZIP chat export with --provider, or use opencrawl ingest for documents and media.');
|
|
229
|
-
return uploadFile(file, client, options);
|
|
230
|
-
}
|
|
231
|
-
const stat = await fs.stat(file);
|
|
232
|
-
if (!stat.isFile() || stat.size > 25_000_000) throw new Error('Import a regular file smaller than 25 MB.');
|
|
233
|
-
const bytes = await fs.readFile(file);
|
|
234
|
-
let text;
|
|
235
|
-
try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); }
|
|
236
|
-
catch { throw new Error('This file is not UTF-8 text. Use opencrawl ingest for documents and media.'); }
|
|
237
|
-
if (text.includes('\0')) throw new Error('This file contains binary data. Use opencrawl ingest for documents and media.');
|
|
238
|
-
if (provider || /\.(json|jsonl)$/i.test(file)) {
|
|
239
|
-
return uploadFile(file, client, { ...options, chat: true, provider: provider || 'auto' });
|
|
240
|
-
}
|
|
241
|
-
if (options['request-key']) return uploadFile(file, client, options);
|
|
242
|
-
const hash = crypto.createHash('sha256').update(text).digest('hex');
|
|
243
|
-
const body = { title: path.basename(file), content: text, visibility: options.visibility || 'private',
|
|
244
|
-
source_id: options.source, external_id: `cli-file:${path.resolve(file)}`, metadata: { imported_by: 'cli', content_hash: hash } };
|
|
245
|
-
if (!['private', 'workspace'].includes(body.visibility)) throw new Error('--visibility must be private or workspace.');
|
|
246
|
-
// JSON escaping can make a text document much larger than its source file.
|
|
247
|
-
// Oversized notes use the same bounded multipart queue as documents/media.
|
|
248
|
-
if (Buffer.byteLength(JSON.stringify(body)) > 8 * 1024 * 1024) return uploadFile(file, client, options);
|
|
249
|
-
return client.request('/api/v1/documents', { method: 'POST', body });
|
|
250
|
-
}
|
|
251
|
-
async function listFiles(directory, depth = 0) {
|
|
252
|
-
if (depth > 8) return [];
|
|
253
|
-
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
254
|
-
const files = [];
|
|
255
|
-
for (const entry of entries) {
|
|
256
|
-
if (entry.name.startsWith('.') || entry.isSymbolicLink()) continue;
|
|
257
|
-
const full = path.join(directory, entry.name);
|
|
258
|
-
if (entry.isDirectory()) files.push(...await listFiles(full, depth + 1));
|
|
259
|
-
else if (entry.isFile() && /\.(json|jsonl)$/i.test(entry.name)) files.push(full);
|
|
260
|
-
if (files.length > 10000) throw new Error('Choose a smaller export folder (maximum 10,000 files).');
|
|
261
|
-
}
|
|
262
|
-
return files;
|
|
263
|
-
}
|
|
264
|
-
async function watch(directory, options, client) {
|
|
265
|
-
if (!directory) throw new Error('Choose an explicit directory to watch.');
|
|
266
|
-
if (!PROVIDERS.includes(options.provider)) throw new Error('Specify --provider auto, chatgpt, claude, claude-code, codex, or generic.');
|
|
267
|
-
if (options.visibility !== undefined && !['private','workspace'].includes(options.visibility)) throw new Error('--visibility must be private or workspace.');
|
|
268
|
-
const root = await fs.realpath(directory);
|
|
269
|
-
if (!(await fs.stat(root)).isDirectory()) throw new Error('Watch target must be a directory.');
|
|
270
|
-
// Receipt ownership follows the verified account, not a rotatable API key.
|
|
271
|
-
let userId = client.profile?.userId, workspaceId = client.profile?.workspaceId;
|
|
272
|
-
if (!userId || !workspaceId) {
|
|
273
|
-
const identity = await client.request('/api/auth/me', { maxResponseBytes: 128 * 1024 });
|
|
274
|
-
userId = identity?.user?.id; workspaceId = identity?.workspace?.id;
|
|
275
|
-
}
|
|
276
|
-
if (![userId, workspaceId].every(value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,200}$/.test(value))) throw new Error('OpenCrawl did not return a verified watcher account. No files were submitted.');
|
|
277
|
-
const scope = { server: client.baseUrl, user_id: userId, workspace_id: workspaceId, directory: root,
|
|
278
|
-
provider: options.provider, source_id: options.source || null, visibility: options.visibility || 'private' };
|
|
279
|
-
const stateName = crypto.createHash('sha256').update(JSON.stringify(scope)).digest('hex').slice(0, 24);
|
|
280
|
-
const legacyNames = [`${client.baseUrl}:${client.identity}:${root}:${options.provider}`, `${client.baseUrl}:${userId}:${workspaceId}:${root}:${options.provider}`]
|
|
281
|
-
.map(value => crypto.createHash('sha256').update(value).digest('hex').slice(0, 24));
|
|
282
|
-
const legacyPaths = legacyNames.map(name => path.join(configDir(), `watch-${name}.json`));
|
|
283
|
-
const statePath = path.join(configDir(), `watch-${stateName}.json`), lockPath = statePath + '.lock';
|
|
284
|
-
await fs.mkdir(configDir(), { recursive: true, mode: 0o700 });
|
|
285
|
-
let lock;
|
|
286
|
-
try { lock = await fs.open(lockPath, 'wx', 0o600); await lock.writeFile(String(process.pid)); }
|
|
287
|
-
catch (error) { if (error.code === 'EEXIST') throw new Error('This export folder already has a watcher lock. Stop that watcher first. If it crashed, verify its process has stopped before removing the matching watch-*.json.lock file from the configuration directory.'); throw error; }
|
|
288
|
-
let running = true; const controller = new AbortController();
|
|
289
|
-
const stop = () => { running = false; controller.abort(); };
|
|
290
|
-
process.on('SIGINT', stop); process.on('SIGTERM', stop);
|
|
291
|
-
try {
|
|
292
|
-
let state = {};
|
|
293
|
-
try {
|
|
294
|
-
const info = await fs.stat(statePath);
|
|
295
|
-
if (!info.isFile() || info.size > 32 * 1024 * 1024) throw new Error('Watcher state is not a bounded regular file. Review it before continuing.');
|
|
296
|
-
const saved = await readJsonIfPresent(statePath);
|
|
297
|
-
if (saved.version !== 2 || JSON.stringify(saved.scope) !== JSON.stringify(scope) || !saved.entries || typeof saved.entries !== 'object' || Array.isArray(saved.entries) || Object.keys(saved.entries).length > 10000)
|
|
298
|
-
throw new Error('Watcher state does not match this exact account and scope. Review it before continuing; no upload was submitted.');
|
|
299
|
-
state = saved.entries;
|
|
300
|
-
} catch (error) {
|
|
301
|
-
if (error.code !== 'ENOENT') throw error;
|
|
302
|
-
// Old signatures do not prove their source/visibility. Never silently
|
|
303
|
-
// assign them to a new scope or discard an uncertain prior request.
|
|
304
|
-
for (const legacyPath of legacyPaths) {
|
|
305
|
-
try { await fs.access(legacyPath); }
|
|
306
|
-
catch (missing) { if (missing.code === 'ENOENT') continue; throw missing; }
|
|
307
|
-
throw new Error(`Older watcher state has no verified source/visibility scope: ${legacyPath}. Stop old watchers, inspect any saved receipts, and back up or rename that state only after deciding a new scoped import is intended. The original state was preserved.`);
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
const reported = new Set();
|
|
311
|
-
const save = async () => {
|
|
312
|
-
const encoded = JSON.stringify({ version: 2, scope, entries: state });
|
|
313
|
-
if (Buffer.byteLength(encoded) > 32 * 1024 * 1024) throw new Error('Watcher state reached its byte limit. Review completed records before continuing.');
|
|
314
|
-
const temp = `${statePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
315
|
-
const file = await fs.open(temp, 'wx', 0o600);
|
|
316
|
-
try { await file.writeFile(encoded); await file.sync(); }
|
|
317
|
-
finally { await file.close(); }
|
|
318
|
-
try { await fs.rename(temp, statePath); }
|
|
319
|
-
finally { await fs.rm(temp, { force: true }); }
|
|
320
|
-
// POSIX directory fsync makes the rename durable before network effects.
|
|
321
|
-
// Windows does not expose directory handles through fs.open; the file
|
|
322
|
-
// itself is still synced before the atomic replacement there.
|
|
323
|
-
if (process.platform !== 'win32') {
|
|
324
|
-
const directory = await fs.open(configDir(), 'r');
|
|
325
|
-
try { await directory.sync(); } finally { await directory.close(); }
|
|
326
|
-
}
|
|
327
|
-
};
|
|
328
|
-
await save(); // Persist the exact account/scope before any network side effect.
|
|
329
|
-
process.stderr.write(`Watching only ${displayLine(root)}. Matching JSON/JSONL exports will be uploaded to ${displayLine(client.baseUrl)}.\n`);
|
|
330
|
-
do {
|
|
331
|
-
const outcomes = [];
|
|
332
|
-
for (const file of await listFiles(root)) {
|
|
333
|
-
if (!running) break;
|
|
334
|
-
try {
|
|
335
|
-
const stat = await fs.stat(file);
|
|
336
|
-
if (stat.size > 25_000_000) continue;
|
|
337
|
-
const signature = `${stat.size}:${stat.mtimeMs}`;
|
|
338
|
-
let entry = state[file];
|
|
339
|
-
if (entry === signature || entry?.accepted === signature) continue;
|
|
340
|
-
if (entry?.pending) {
|
|
341
|
-
// An earlier process may have lost prepare/content acceptance. Never
|
|
342
|
-
// create another key or resend bytes until this request is resolved.
|
|
343
|
-
let receipt;
|
|
344
|
-
try { receipt = await lookupReceipt(client, entry.pending.request_key, { timeout: 10000, signal: controller.signal }); }
|
|
345
|
-
catch (error) {
|
|
346
|
-
const report = `${entry.pending.request_key}:lookup`;
|
|
347
|
-
if (!reported.has(report)) { process.stderr.write(`Upload pending for ${displayLine(path.basename(file))}. Inspect request ${entry.pending.request_key}: ${requestFailure(error).message}\n`); reported.add(report); }
|
|
348
|
-
outcomes.push({ request_key: entry.pending.request_key, outcome: 'unconfirmed' }); continue;
|
|
349
|
-
}
|
|
350
|
-
if (receipt.state === 'accepted') {
|
|
351
|
-
state[file] = { accepted: entry.pending.signature, receipt_id: receipt.id, job_id: receipt.job.id }; await save();
|
|
352
|
-
process.stderr.write(`Accepted ${displayLine(path.basename(file))}; job ${displayLine(receipt.job.id)}.\n`);
|
|
353
|
-
} else {
|
|
354
|
-
const report = `${receipt.request_key}:${receipt.state}`;
|
|
355
|
-
if (!reported.has(report)) { process.stderr.write(`Upload ${displayLine(receipt.state)} for ${displayLine(path.basename(file))}. Inspect with opencrawl uploads lookup ${receipt.request_key}; no automatic resend.\n`); reported.add(report); }
|
|
356
|
-
}
|
|
357
|
-
outcomes.push({ request_key: receipt.request_key, receipt_id: receipt.id, outcome: receipt.state, job_id: receipt.job?.id || null });
|
|
358
|
-
continue;
|
|
359
|
-
}
|
|
360
|
-
const result = await submitUpload(file, client, { ...options, chat: true }, {
|
|
361
|
-
signal: controller.signal,
|
|
362
|
-
onIntent: async (key, descriptor) => { if (!Object.hasOwn(state, file) && Object.keys(state).length >= 10000) throw new Error('Watcher state reached 10,000 records. Review completed records before continuing.'); state[file] = { accepted: typeof entry === 'string' ? entry : entry?.accepted, pending: { request_key: key, signature, descriptor } }; await save(); },
|
|
363
|
-
});
|
|
364
|
-
if (result.outcome === 'accepted') {
|
|
365
|
-
state[file] = { accepted: signature, receipt_id: result.receipt_id, job_id: result.job_id }; await save();
|
|
366
|
-
process.stderr.write(`Accepted ${displayLine(path.basename(file))}; job ${displayLine(result.job_id)}.\n`);
|
|
367
|
-
} else process.stderr.write(`Upload ${displayLine(result.outcome)} for ${displayLine(path.basename(file))}. Keep request ${result.request_key}; no automatic resend.\n`);
|
|
368
|
-
outcomes.push({ request_key: result.request_key, receipt_id: result.receipt_id, outcome: result.outcome, job_id: result.job_id });
|
|
369
|
-
} catch (error) { process.stderr.write(`Skipped ${displayLine(path.basename(file))}: ${displayLine(error.message)}\n`); outcomes.push({ outcome: 'failed', error: 'This file could not be submitted.' }); }
|
|
370
|
-
}
|
|
371
|
-
if (options.json && options.once) print({ outcomes });
|
|
372
|
-
if (options.once) { if (outcomes.some(item => item.outcome !== 'accepted')) process.exitCode = 1; break; }
|
|
373
|
-
if (!running) break;
|
|
374
|
-
const until = Date.now() + 2500;
|
|
375
|
-
while (running && Date.now() < until) await new Promise(resolve => setTimeout(resolve, 100));
|
|
376
|
-
} while (running);
|
|
377
|
-
} finally {
|
|
378
|
-
process.off('SIGINT', stop); process.off('SIGTERM', stop);
|
|
379
|
-
await lock.close(); await fs.unlink(lockPath);
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
async function hook(client) {
|
|
383
|
-
try {
|
|
384
|
-
const input = JSON.parse(await readStdin());
|
|
385
|
-
if (typeof input.prompt !== 'string' || !input.prompt.trim() || !client.configured) return;
|
|
386
|
-
if(client.expectedIdentity)await verifyAgentIdentity(client,1000);
|
|
387
|
-
const data = await client.request('/api/v1/context', { method: 'POST', body: { query: input.prompt.slice(0, 1000), budget: HOOK_RETRIEVAL_BUDGET, limit: 5 }, timeout: 3500, channel: 'hook' });
|
|
388
|
-
const wrapped = hookContext(data);
|
|
389
|
-
if (!wrapped) return;
|
|
390
|
-
print({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: wrapped } });
|
|
391
|
-
} catch { /* Retrieval is best effort and must never block the user's prompt. */ }
|
|
392
|
-
}
|
|
393
|
-
async function liveCaptureHook(client,options) {
|
|
394
|
-
let result={},input;
|
|
395
|
-
try {
|
|
396
|
-
input=JSON.parse(await readStdin(300000));
|
|
397
|
-
await assertCaptureHook(input,options,client);
|
|
398
|
-
// Read previous knowledge before storing the current question, so a new
|
|
399
|
-
// prompt cannot immediately retrieve itself as supporting evidence.
|
|
400
|
-
if(input.hook_event_name==='UserPromptSubmit'&&typeof input.prompt==='string'&&input.prompt.trim()) {
|
|
401
|
-
try {
|
|
402
|
-
await verifyAgentIdentity(client,1000);
|
|
403
|
-
const data=await client.request('/api/v1/context',{method:'POST',body:{query:input.prompt.slice(0,1000),budget:HOOK_RETRIEVAL_BUDGET,limit:5},timeout:2500,channel:'hook'});
|
|
404
|
-
const wrapped=hookContext(data);if(wrapped)result={hookSpecificOutput:{hookEventName:'UserPromptSubmit',additionalContext:wrapped}};
|
|
405
|
-
}catch{/* Retrieval remains fail-open. */}
|
|
406
|
-
}
|
|
407
|
-
await captureHook(input,options,client);
|
|
408
|
-
} catch {if(input?.hook_event_name!=='Stop')process.stderr.write('OpenCrawl capture did not complete. Inspect capture status in this project; the agent may continue.\n');}
|
|
409
|
-
printRpc(result); // Stop requires JSON and must never control continuation.
|
|
410
|
-
}
|
|
411
|
-
async function mcp(client) {
|
|
412
|
-
let protocolVersion;
|
|
413
|
-
const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
414
|
-
for await (const line of lines) {
|
|
415
|
-
if (!line.trim()) continue;
|
|
416
|
-
let message;
|
|
417
|
-
try { message = JSON.parse(line); }
|
|
418
|
-
catch { printRpc({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); continue; }
|
|
419
|
-
try {
|
|
420
|
-
const result = await client.request('/mcp', { method: 'POST', body: message, protocolVersion });
|
|
421
|
-
if (message.method === 'initialize' && result?.result?.protocolVersion) protocolVersion = result.result.protocolVersion;
|
|
422
|
-
if (result !== null) printRpc(result);
|
|
423
|
-
} catch (error) {
|
|
424
|
-
if (message && Object.hasOwn(message, 'id')) printRpc({ jsonrpc: '2.0', id: message.id, error: { code: -32603, message: error.message } });
|
|
425
|
-
else process.stderr.write(error.message + '\n');
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
async function main() {
|
|
430
|
-
const { positional, options } = parseArgs(process.argv.slice(2));
|
|
431
|
-
productMonitorJson = positional[0] === 'browser' && positional[1] === 'monitor' && options.json === true;
|
|
432
|
-
const [command, ...rest] = positional;
|
|
433
|
-
if (options['config-dir']) process.env.OPENCRAWL_CONFIG_DIR = path.resolve(options['config-dir']);
|
|
434
|
-
if (options.token !== undefined || options.key !== undefined) throw new Error('Do not pass credentials in command-line arguments. Use the hidden login prompt or OPENCRAWL_API_KEY.');
|
|
435
|
-
if (options.version) return print('0.1.1');
|
|
436
|
-
if (command === 'agent' && (options.help || (!rest.length && !options.session))) { const { AGENT_HELP } = await import('../src/agent.mjs'); return print(AGENT_HELP); }
|
|
437
|
-
if (command === 'sessions' && (options.help || !rest.length)) return print(SESSIONS_HELP);
|
|
438
|
-
if (command === 'research' && (options.help || !rest.length)) return print(RESEARCH_HELP);
|
|
439
|
-
if (command === 'tasks' && (options.help || !rest.length)) return print(TASKS_HELP);
|
|
440
|
-
if (command === 'monitors' && (options.help || !rest.length)) return print(MONITORS_HELP);
|
|
441
|
-
if (command === 'connect' && options.help) return print(CONNECT_HELP);
|
|
442
|
-
if (command === 'capture' && (options.help || !rest.length)) return print(CAPTURE_HELP);
|
|
443
|
-
if (command === 'jobs' && (options.help || !rest.length)) return print(JOBS_HELP);
|
|
444
|
-
if (command === 'watch' && options.help) return print(WATCH_HELP);
|
|
445
|
-
if (command === 'uploads' && (options.help || !rest.length) || ['import','ingest'].includes(command) && options.help) return print(UPLOADS_HELP);
|
|
446
|
-
if(command==='browser'&&rest[0]==='monitor'&&(options.help||rest.length===1))return print(PRODUCT_MONITORS_HELP);
|
|
447
|
-
if (!command || command === 'help' || options.help) return print(HELP);
|
|
448
|
-
if (command === 'configure') {
|
|
449
|
-
if (!options.server) throw new Error('Use configure --server URL.');
|
|
450
|
-
const config = await readConfig(); config.server = validateBaseUrl(options.server); await writeConfig(config); return print(`Server: ${config.server}. Saved keys remain bound to their original servers. Use login to authorize this destination.`);
|
|
451
|
-
}
|
|
452
|
-
if (command === 'login') {
|
|
453
|
-
const config = await readConfig();
|
|
454
|
-
// Resolve an explicit account before reading/sending the replacement key.
|
|
455
|
-
const { baseUrl: server } = resolveCredentials(config, { account: options.account, server: options.server }, { ...process.env, OPENCRAWL_API_KEY: undefined });
|
|
456
|
-
const apiKey = process.env.OPENCRAWL_API_KEY ?? await askSecret('Workspace API key (hidden): ');
|
|
457
|
-
if (!apiKey) throw new Error('No API key was given. New to OpenCrawl? Create a free organization and key with: opencrawl agent signup --organization NAME --email EMAIL. Existing account: create a key under Connect an agent → API keys and developer tools, then pipe it into opencrawl login or set OPENCRAWL_API_KEY.');
|
|
458
|
-
const client = await createClient({ server, token: apiKey });
|
|
459
|
-
const identity = await client.request('/api/auth/me');
|
|
460
|
-
const next = loggedInConfig(config, server, apiKey, identity);
|
|
461
|
-
if (options.account && options.account !== next.activeProfile) throw new Error('This key does not match the selected account’s server and workspace. No saved profile was changed.');
|
|
462
|
-
await writeConfig(next); return print(`Signed in to ${server}. Account ${next.activeProfile} saved with owner-only permissions.`);
|
|
463
|
-
}
|
|
464
|
-
if (command === 'logout') { await writeConfig(loggedOutConfig(await readConfig(), options.account)); return print('Saved API key removed for this account. Other account profiles and server keys are unchanged.'); }
|
|
465
|
-
if (command === 'profiles') return print({ accounts: profileSummaries(await readConfig()) });
|
|
466
|
-
if (command === 'install') return install(rest[0], options);
|
|
467
|
-
const client = ['mcp','hook','capture','capture-hook'].includes(command)?await agentClient(options):await createClient(options);
|
|
468
|
-
if (['mcp', 'hook'].includes(command) && client.credentialSource === 'profile' && !options.account)
|
|
469
|
-
throw new Error('This agent connection needs a pinned account. Rerun opencrawl install for this project, or use --account PROFILE_ID from opencrawl profiles.');
|
|
470
|
-
if(command==='browser'&&rest[0]==='monitor'){process.exitCode=await productMonitorsCommand(rest.slice(1),options,client);return;}
|
|
471
|
-
if (command === 'browser') { const { browserCommand } = await import('../browser.mjs'); return print(await browserCommand(rest[0], rest[1], options, client)); }
|
|
472
|
-
if (command === 'agent') {
|
|
473
|
-
const { agentCommand } = await import('../src/agent.mjs');
|
|
474
|
-
// Save each returned key as a verified account plus an owner-only key file; the default account changes only at signup.
|
|
475
|
-
const saveKey = async (apiKey, { activate }) => {
|
|
476
|
-
const identity = await (await createClient({ server: client.baseUrl, token: apiKey })).request('/api/auth/me');
|
|
477
|
-
const config = await readConfig();
|
|
478
|
-
const next = loggedInConfig(config, client.baseUrl, apiKey, identity);
|
|
479
|
-
const account = next.activeProfile;
|
|
480
|
-
if (!activate && config.activeProfile) Object.assign(next, { activeProfile: config.activeProfile, server: config.server });
|
|
481
|
-
await writeConfig(next);
|
|
482
|
-
const dir = path.join(configDir(), 'keys');
|
|
483
|
-
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
484
|
-
const keyFile = path.join(dir, `${account}.key`);
|
|
485
|
-
await fs.writeFile(keyFile, apiKey + '\n', { mode: 0o600 }); await fs.chmod(keyFile, 0o600);
|
|
486
|
-
return { account, key_file: keyFile, use: `opencrawl --account ${account} <command>, or OPENCRAWL_API_KEY="$(cat ${keyFile})" for MCP and server code` };
|
|
487
|
-
};
|
|
488
|
-
process.exitCode = await agentCommand(rest, options, client, undefined, saveKey); return;
|
|
489
|
-
}
|
|
490
|
-
if (command === 'sessions') return sessionsCommand(rest,options,client);
|
|
491
|
-
if (command === 'research') { process.exitCode = await researchCommand(rest, options, client); return; }
|
|
492
|
-
if (command === 'tasks') { process.exitCode = await tasksCommand(rest, options, client); return; }
|
|
493
|
-
if (command === 'monitors') { process.exitCode = await monitorsCommand(rest, options, client); return; }
|
|
494
|
-
if (command === 'connect') { process.exitCode = await connectCommand(rest, options, client); return; }
|
|
495
|
-
if (command === 'jobs') { process.exitCode = await jobsCommand(rest, options, client); return; }
|
|
496
|
-
if (command === 'uploads') { process.exitCode = await uploadsCommand(rest, options, client); return; }
|
|
497
|
-
if (command === 'capture') { process.exitCode = await captureCommand(rest,options,client); return; }
|
|
498
|
-
if (command === 'capture-hook') {allowedOptions(options,['provider','project',...AGENT_OPTIONS],'capture-hook');return liveCaptureHook(client,options);}
|
|
499
|
-
if (command === 'hook') return hook(client);
|
|
500
|
-
if (command === 'mcp') {if(client.expectedIdentity)await verifyAgentIdentity(client);return mcp(client);}
|
|
501
|
-
if (command === 'search' || command === 'context') {
|
|
502
|
-
const query = rest.join(' ');
|
|
503
|
-
if (!query.trim()) throw new Error('Enter a query.');
|
|
504
|
-
const body = { query, limit: integer(options.limit, 8, 1, 50), budget: integer(options.budget, 2000, 256), ...(options.source ? { source_ids: [options.source] } : {}), ...(options.customer ? { customer: String(options.customer) } : {}) };
|
|
505
|
-
const data = await client.request(`/api/v1/${command}`, { method: 'POST', body });
|
|
506
|
-
if (options.json) return print(data);
|
|
507
|
-
for (const warning of coverageWarnings(data)) process.stderr.write(`OpenCrawl coverage: ${displayLine(warning)}\n`);
|
|
508
|
-
if (command === 'context') {
|
|
509
|
-
const value = data.context ?? data.text;
|
|
510
|
-
if (typeof value !== 'string') throw new Error('Server returned an invalid context response.');
|
|
511
|
-
const bounded = utf8Prefix(value, body.budget);
|
|
512
|
-
process.stdout.write(bounded);
|
|
513
|
-
if (bounded && !bounded.endsWith('\n') && Buffer.byteLength(bounded) < body.budget) process.stdout.write('\n');
|
|
514
|
-
return;
|
|
515
|
-
}
|
|
516
|
-
const results = data.results || data.hits || [];
|
|
517
|
-
if (!results.length) return print('No results. Connect a source or import a document to get started.');
|
|
518
|
-
for (const [index, result] of results.entries()) print(`${index + 1}. ${displayLine(result.title || 'Untitled')}\n${displayText(result.snippet || result.content || result.text || '')}\n${displayLine(result.url || result.citation || result.id || '')}\n`);
|
|
519
|
-
return;
|
|
520
|
-
}
|
|
521
|
-
if (command === 'import' || command === 'ingest') {
|
|
522
|
-
allowedOptions(options, ['source','visibility','request-key','wait','timeout', ...(command === 'import' ? ['provider'] : [])], command);
|
|
523
|
-
waitOptions(options);
|
|
524
|
-
if (rest.length !== 1) throw new Error('Choose exactly one file to import or ingest.');
|
|
525
|
-
const result = command === 'import' ? await importFile(rest[0], options.provider, client, options) : await uploadFile(rest[0], client, options);
|
|
526
|
-
if (result?.request_key) process.exitCode = await finishUpload(result, options, client);
|
|
527
|
-
else print(result); // Small text imports index synchronously and have no background job.
|
|
528
|
-
return;
|
|
529
|
-
}
|
|
530
|
-
if (command === 'watch') {
|
|
531
|
-
allowedOptions(options, ['source','visibility','provider','once'], 'watch');
|
|
532
|
-
if (rest.length !== 1) throw new Error('Choose exactly one directory to watch.');
|
|
533
|
-
return watch(rest[0], options, client);
|
|
534
|
-
}
|
|
535
|
-
if (command === 'status') return print(await client.request('/api/v1/usage'));
|
|
536
|
-
if (command === 'doctor') {
|
|
537
|
-
let health, access;
|
|
538
|
-
try { health = await client.request('/healthz', { authenticated: false, timeout: 5000 }); } catch (error) { health = { error: error.message }; }
|
|
539
|
-
if (client.configured) { try { await client.request('/api/v1/usage', { timeout: 5000 }); access = 'verified'; } catch (error) { access = error.message; } }
|
|
540
|
-
const report = { node: process.versions.node, server: client.baseUrl, account: client.profile?.id, credentials: client.configured ? 'present' : 'missing', access: access || client.credentialError || 'not configured', health };
|
|
541
|
-
print(report); if (health?.error || !client.configured || access && access !== 'verified') process.exitCode = 1; return;
|
|
542
|
-
}
|
|
543
|
-
throw new Error(`Unknown command: ${command}. Run opencrawl help.`);
|
|
544
|
-
}
|
|
545
|
-
main().catch(error => {
|
|
546
|
-
// A hook is nonblocking even when config cannot be loaded.
|
|
547
|
-
if (process.argv[2] === 'hook') return;
|
|
548
|
-
if (process.argv[2] === 'capture-hook') {process.stderr.write('OpenCrawl capture is unavailable. Inspect capture status; the agent may continue.\n');printRpc({});return;}
|
|
549
|
-
const json = productMonitorJson || process.argv.slice(2).includes('--json') && process.argv.slice(2).some(value => ['research','tasks','monitors','jobs','uploads','import','ingest','watch','connect','capture'].includes(value));
|
|
550
|
-
if (json) print({ error: { code: Number.isInteger(error.status) ? `http_${error.status}` : 'command_failed', message: Number.isInteger(error.status) ? requestFailure(error).message : error.message } });
|
|
551
|
-
else process.stderr.write(`OpenCrawl: ${displayLine(error.message)}\n`);
|
|
552
|
-
process.exitCode = 1;
|
|
553
|
-
});
|