@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.
Files changed (58) hide show
  1. package/README.md +75 -298
  2. package/bin/opencrawl.cjs +168 -0
  3. package/package.json +20 -14
  4. package/bin/opencrawl.mjs +0 -553
  5. package/browser.mjs +0 -166
  6. package/src/accounts.test.mjs +0 -223
  7. package/src/agent.mjs +0 -95
  8. package/src/agent.test.mjs +0 -58
  9. package/src/answer-contract.mjs +0 -112
  10. package/src/answer-view.mjs +0 -118
  11. package/src/browser.test.mjs +0 -72
  12. package/src/capture.mjs +0 -273
  13. package/src/capture.test.mjs +0 -171
  14. package/src/client.mjs +0 -183
  15. package/src/client.test.mjs +0 -34
  16. package/src/connect.mjs +0 -230
  17. package/src/connect.test.mjs +0 -131
  18. package/src/context.mjs +0 -34
  19. package/src/context.test.mjs +0 -32
  20. package/src/display.mjs +0 -4
  21. package/src/fixtures/letter-connections.json +0 -59
  22. package/src/fixtures/product-monitor-contract.json +0 -312
  23. package/src/fixtures/research-completion-hosted.json +0 -502
  24. package/src/fixtures/research-completion-v1.json +0 -58
  25. package/src/fixtures/research-completion-v2.json +0 -33
  26. package/src/fixtures/research-presentation-hosted.json +0 -720
  27. package/src/jobs.mjs +0 -117
  28. package/src/jobs.test.mjs +0 -67
  29. package/src/mcp-conversations.test.mjs +0 -80
  30. package/src/monitors.mjs +0 -146
  31. package/src/monitors.test.mjs +0 -62
  32. package/src/product-letter-capabilities.mjs +0 -71
  33. package/src/product-letter-read.mjs +0 -178
  34. package/src/product-monitor-login.test.mjs +0 -140
  35. package/src/product-monitors.mjs +0 -321
  36. package/src/product-monitors.test.mjs +0 -69
  37. package/src/research-agent-errors.test.mjs +0 -110
  38. package/src/research-agent-feedback.test.mjs +0 -28
  39. package/src/research-agent.mjs +0 -164
  40. package/src/research-agent.test.mjs +0 -231
  41. package/src/research-answer-contract.mjs +0 -3
  42. package/src/research-answer-contract.test.mjs +0 -14
  43. package/src/research-answer-view.mjs +0 -7
  44. package/src/research-answer-view.test.mjs +0 -27
  45. package/src/research-completion.mjs +0 -94
  46. package/src/research-completion.test.mjs +0 -110
  47. package/src/research-event.mjs +0 -39
  48. package/src/research-event.test.mjs +0 -57
  49. package/src/research-presentation.test.mjs +0 -56
  50. package/src/research.mjs +0 -141
  51. package/src/research.test.mjs +0 -97
  52. package/src/sessions.mjs +0 -165
  53. package/src/sessions.test.mjs +0 -151
  54. package/src/tasks.mjs +0 -115
  55. package/src/tasks.test.mjs +0 -83
  56. package/src/upload-commands.test.mjs +0 -140
  57. package/src/uploads.mjs +0 -141
  58. 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.1.1",
4
- "description": "Search, import and bring OpenCrawl context to your agents",
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.mjs"
8
+ "opencrawl": "bin/opencrawl.cjs"
8
9
  },
9
- "exports": "./src/client.mjs",
10
10
  "files": [
11
- "bin",
12
- "src",
13
- "README.md",
14
- "browser.mjs"
11
+ "bin/opencrawl.cjs",
12
+ "README.md"
15
13
  ],
16
- "scripts": {
17
- "test": "node --test src/*.test.mjs",
18
- "test:product-browser": "node --test tests/product-monitor-browser.test.mjs"
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"
20
+ "node": ">=22"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public",
24
+ "registry": "https://registry.npmjs.org"
22
25
  },
23
- "license": "UNLICENSED",
24
26
  "optionalDependencies": {
25
- "playwright": "^1.58.0"
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
- });