@getopencrawl/cli 0.1.0 → 0.1.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 CHANGED
@@ -4,6 +4,24 @@ Node.js 20 or later. Core CLI commands have no required package dependencies; br
4
4
 
5
5
  The canonical deployment is live at `https://app.getopencrawl.com`; [STATUS.md](../STATUS.md) records its verification. Copy the exact immutable `npm install -g` command from **Connect an agent**, or use `packages.cli.url` from the [current manifest](https://app.getopencrawl.com/downloads/manifest.json) and use `opencrawl login --server https://app.getopencrawl.com`. Existing installations keep their original origin and account pins. Follow the [domain migration steps](../docs/cli.md#moving-to-the-canonical-domain), including flushing old capture and starting a new native chat after reinstalling.
6
6
 
7
+ ## Start here: new to OpenCrawl
8
+
9
+ No account, card or browser is needed. These commands create a free organization, describe the data you need to the OpenCrawl agent, and build a project that crawls and refreshes in the background. The same steps over HTTP or MCP are in [the agent guide](https://app.getopencrawl.com/start.md).
10
+
11
+ ```sh
12
+ npm install -g "$(curl -fsS https://app.getopencrawl.com/downloads/manifest.json | node -pe 'JSON.parse(require("fs").readFileSync(0)).packages.cli.url')"
13
+ opencrawl agent signup --organization "Acme" --email you@acme.com --server https://app.getopencrawl.com
14
+ opencrawl agent "Watch the planning commission agendas at https://example.gov/agendas daily, including linked PDFs, for data-center rezonings"
15
+ opencrawl agent "answers to its questions" --session SESSION_ID
16
+ opencrawl agent --session SESSION_ID --build
17
+ ```
18
+
19
+ - `signup` saves the organization key as the default CLI account and in an owner-only key file, and prints where, never the key. `--build` does the same for the project key.
20
+ - The OpenCrawl agent checks every page with OpenCrawl's crawler before a plan is ready. A page it cannot read (blocked by robots.txt or by the site) is listed in `questions`; replace it or drop it.
21
+ - Read a project's watches, jobs and research with its project account: `opencrawl --account PROJECT_ACCOUNT research list`, `research job FIRST_CHECK_JOB --wait`, `research search "question" --collection WATCH_ID`, `search "question"`. The organization key cannot read inside projects (404).
22
+ - Research questions run after their watches' first check, then daily. To change a built project, send the change on the same session, then `--build` again: new watches and research are added and existing ones keep running.
23
+ - For MCP or server code, use the project key file: `OPENCRAWL_API_KEY="$(cat KEY_FILE)"`. Never paste keys into chat or commit them.
24
+
7
25
  From this repository:
8
26
 
9
27
  ```sh
package/bin/opencrawl.mjs CHANGED
@@ -34,9 +34,15 @@ Global options: --account PROFILE_ID --server URL --config-dir DIRECTORY --json
34
34
  `;
35
35
  const HELP = `OpenCrawl · knowledge that follows your work
36
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
+
37
42
  opencrawl login [--server URL] Save a workspace API key securely
38
43
  opencrawl configure --server URL Change the server
39
44
  opencrawl profiles List saved workspace account profiles
45
+ opencrawl agent --help Ask the OpenCrawl agent to build a data project
40
46
  opencrawl search "question" [--json] Search connected knowledge
41
47
  opencrawl context "task" [--budget N] Get a compact, cited context pack
42
48
  opencrawl research --help Collect and search public-source research
@@ -63,7 +69,7 @@ const HELP = `OpenCrawl · knowledge that follows your work
63
69
  opencrawl hook Run Claude Code's UserPromptSubmit hook
64
70
  opencrawl logout Remove the locally saved API key
65
71
 
66
- Options: --server URL --account PROFILE_ID --json --limit N --source ID --once (watch)
72
+ Options: --server URL --account PROFILE_ID --json --limit N --source ID --customer NAME (search/context: one customer's index) --once (watch)
67
73
  Providers: auto, chatgpt, claude, claude-code, codex, generic
68
74
  Credentials: OPENCRAWL_API_KEY, OPENCRAWL_URL. Node.js 20+.
69
75
  Hosted ChatGPT/Claude history is imported from an export; it is not silently read.
@@ -76,8 +82,8 @@ function parseArgs(args) {
76
82
  if (!arg.startsWith('--')) { positional.push(arg); continue; }
77
83
  const [key, inline] = arg.slice(2).split(/=(.*)/s);
78
84
  if (key !== 'selection' && Object.hasOwn(options,key)) duplicates.push(key);
79
- if (['json','help','version','wait','once','acknowledge-unknown','capture'].includes(key) && inline !== undefined) valuedFlags.push(key);
80
- if (['json', 'once', 'help', 'version', 'headless', 'interactive', 'ocr', 'wait','acknowledge-unknown','capture'].includes(key)) options[key] = true;
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;
81
87
  else {
82
88
  const value = inline ?? args[++index];
83
89
  if (value === undefined || value.startsWith('--')) throw new Error(`Missing value for --${key}`);
@@ -426,7 +432,8 @@ async function main() {
426
432
  const [command, ...rest] = positional;
427
433
  if (options['config-dir']) process.env.OPENCRAWL_CONFIG_DIR = path.resolve(options['config-dir']);
428
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.');
429
- if (options.version) return print('0.1.0');
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); }
430
437
  if (command === 'sessions' && (options.help || !rest.length)) return print(SESSIONS_HELP);
431
438
  if (command === 'research' && (options.help || !rest.length)) return print(RESEARCH_HELP);
432
439
  if (command === 'tasks' && (options.help || !rest.length)) return print(TASKS_HELP);
@@ -447,7 +454,7 @@ async function main() {
447
454
  // Resolve an explicit account before reading/sending the replacement key.
448
455
  const { baseUrl: server } = resolveCredentials(config, { account: options.account, server: options.server }, { ...process.env, OPENCRAWL_API_KEY: undefined });
449
456
  const apiKey = process.env.OPENCRAWL_API_KEY ?? await askSecret('Workspace API key (hidden): ');
450
- if (!apiKey) throw new Error('Create an API key in OpenCrawl → Agents & API, then paste it here.');
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.');
451
458
  const client = await createClient({ server, token: apiKey });
452
459
  const identity = await client.request('/api/auth/me');
453
460
  const next = loggedInConfig(config, server, apiKey, identity);
@@ -462,6 +469,24 @@ async function main() {
462
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.');
463
470
  if(command==='browser'&&rest[0]==='monitor'){process.exitCode=await productMonitorsCommand(rest.slice(1),options,client);return;}
464
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
+ }
465
490
  if (command === 'sessions') return sessionsCommand(rest,options,client);
466
491
  if (command === 'research') { process.exitCode = await researchCommand(rest, options, client); return; }
467
492
  if (command === 'tasks') { process.exitCode = await tasksCommand(rest, options, client); return; }
@@ -476,7 +501,7 @@ async function main() {
476
501
  if (command === 'search' || command === 'context') {
477
502
  const query = rest.join(' ');
478
503
  if (!query.trim()) throw new Error('Enter a query.');
479
- const body = { query, limit: integer(options.limit, 8, 1, 50), budget: integer(options.budget, 2000, 256), ...(options.source ? { source_ids: [options.source] } : {}) };
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) } : {}) };
480
505
  const data = await client.request(`/api/v1/${command}`, { method: 'POST', body });
481
506
  if (options.json) return print(data);
482
507
  for (const warning of coverageWarnings(data)) process.stderr.write(`OpenCrawl coverage: ${displayLine(warning)}\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getopencrawl/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Search, import and bring OpenCrawl context to your agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,7 +10,6 @@
10
10
  "files": [
11
11
  "bin",
12
12
  "src",
13
- "!src/**/*.test.mjs",
14
13
  "README.md",
15
14
  "browser.mjs"
16
15
  ],
@@ -24,8 +23,5 @@
24
23
  "license": "UNLICENSED",
25
24
  "optionalDependencies": {
26
25
  "playwright": "^1.58.0"
27
- },
28
- "publishConfig": {
29
- "access": "public"
30
26
  }
31
27
  }
@@ -0,0 +1,223 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import http from 'node:http';
7
+ import { spawn } from 'node:child_process';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { validateBaseUrl, resolveCredentials, loggedInConfig, loggedOutConfig, profileSummaries, createClient } from './client.mjs';
10
+
11
+ const identity = (digit = 'a') => ({ user: { id: 'usr_' + digit.repeat(32), name: 'Synthetic user' },
12
+ workspace: { id: 'ws_' + digit.repeat(32), name: 'Synthetic workspace' } });
13
+ const saved = () => loggedInConfig({}, 'https://a.example', 'oc_synthetic_a', identity());
14
+
15
+ test('origins canonicalize standard ports and reject path/credential surprises', () => {
16
+ for (const value of ['https://A.example:443/', 'https://a.example', ' https://a.example/ '])
17
+ assert.equal(validateBaseUrl(value), 'https://a.example');
18
+ assert.equal(validateBaseUrl('http://localhost:80/'), 'http://localhost');
19
+ for (const value of ['https://a.example/api', 'https://a.example/..', 'https://a.example/%2e/',
20
+ 'https://a.example\\anything', 'https://user:pass@a.example', 'https://a.example?token=x',
21
+ 'https://a.example/#fragment', 'file:///tmp', 'http://remote.example'])
22
+ assert.throws(() => validateBaseUrl(value));
23
+ });
24
+
25
+ test('saved credentials are origin-bound across arguments, environment, and configured defaults', () => {
26
+ const config = saved();
27
+ assert.equal(resolveCredentials(config, { server: 'https://A.example:443/' }, {}).token, 'oc_synthetic_a');
28
+ for (const [value, overrides, environment] of [
29
+ [config, { server: 'https://b.example' }, {}], [config, {}, { OPENCRAWL_URL: 'https://b.example' }],
30
+ [{ ...config, server: 'https://b.example' }, {}, {}], [config, { server: 'https://a.example:444' }, {}]]) {
31
+ const choice = resolveCredentials(value, overrides, environment);
32
+ assert.equal(choice.token, null);assert.match(choice.credentialError, /another server/);
33
+ }
34
+ });
35
+
36
+ test('legacy unbound keys never migrate by guessing their current destination', () => {
37
+ const config = { server: 'https://b.example', apiKey: 'oc_old_key', otherSetting: 'keep' };
38
+ for (const overrides of [{}, { server: 'https://a.example' }]) {
39
+ const choice = resolveCredentials(config, overrides, {});
40
+ assert.equal(choice.token, null);assert.match(choice.credentialError, /predates server binding/);
41
+ }
42
+ const upgraded = loggedInConfig(config, 'https://b.example', 'oc_explicit_new', identity('b'));
43
+ assert.equal(upgraded.version, 2);assert.equal(upgraded.otherSetting, 'keep');assert.equal(upgraded.apiKey, undefined);
44
+ assert.equal(config.apiKey, 'oc_old_key');
45
+ });
46
+
47
+ test('intentional explicit credentials stay supported without empty-value saved fallback', () => {
48
+ const config = saved();
49
+ assert.equal(resolveCredentials(config, { server: 'https://b.example', token: 'oc_explicit_b' }, {}).token, 'oc_explicit_b');
50
+ assert.equal(resolveCredentials(config, {}, { OPENCRAWL_URL: 'https://b.example', OPENCRAWL_API_KEY: 'oc_explicit_b' }).token, 'oc_explicit_b');
51
+ for (const token of ['', ' ', 'bad\nvalue']) {
52
+ assert.throws(() => resolveCredentials(config, { token }, {}));
53
+ assert.throws(() => resolveCredentials(config, {}, { OPENCRAWL_API_KEY: token }));
54
+ }
55
+ });
56
+
57
+ test('explicit account pins win over global defaults and ambient URL/key changes', () => {
58
+ const a = saved(), account = a.activeProfile;
59
+ const config = loggedInConfig(a, 'https://b.example', 'oc_synthetic_b', identity('b'));
60
+ const choice = resolveCredentials(config, { account }, { OPENCRAWL_URL: 'https://c.example', OPENCRAWL_API_KEY: 'oc_ambient' });
61
+ assert.equal(choice.baseUrl, 'https://a.example');assert.equal(choice.token, 'oc_synthetic_a');
62
+ assert.throws(() => resolveCredentials(config, { account, server: 'https://b.example' }, {}), /bound to another server/);
63
+ assert.throws(() => resolveCredentials(config, { account: 'missing' }, {}), /not found/);
64
+ });
65
+
66
+ test('key rotation preserves profile identity and logout leaves other accounts alone', () => {
67
+ let config = saved();const first = config.activeProfile;
68
+ config = loggedInConfig(config, 'https://b.example', 'oc_synthetic_b', identity('b'));const second = config.activeProfile;
69
+ config = loggedInConfig(config, 'https://a.example:443/', 'oc_rotated_a', identity());
70
+ assert.equal(config.activeProfile, first);assert.equal(Object.keys(config.profiles).length, 2);
71
+ const removed = loggedOutConfig(config, first);
72
+ assert.equal(resolveCredentials(removed, { account: first }, {}).token, null);
73
+ assert.equal(resolveCredentials(removed, { account: second }, {}).token, 'oc_synthetic_b');
74
+ assert.equal(JSON.stringify(profileSummaries(config)).includes('oc_rotated_a'), false);
75
+ assert.equal(JSON.stringify(profileSummaries(config)).includes('apiKey'), false);
76
+ });
77
+
78
+ test('invalid or tampered identity bindings fail before credential selection', () => {
79
+ assert.throws(() => loggedInConfig({}, 'https://a.example', 'oc_key', { user: { id: 'bad' }, workspace: {} }), /valid workspace identity/);
80
+ const config = saved();config.profiles[config.activeProfile].origin = 'https://b.example';
81
+ assert.throws(() => resolveCredentials(config, {}, {}), /binding is invalid/);
82
+ });
83
+
84
+ test('exported client rejects route-based host and port changes before fetch', async t => {
85
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'opencrawl-route-'));
86
+ const previous = process.env.OPENCRAWL_CONFIG_DIR;
87
+ process.env.OPENCRAWL_CONFIG_DIR = directory;
88
+ const calls = [];
89
+ t.mock.method(globalThis, 'fetch', async (url, options) => {
90
+ calls.push({ url, options });return new Response('{}', { status: 200 });
91
+ });
92
+ try {
93
+ const client = await createClient({ server: 'https://a.example', token: 'oc_synthetic_a' });
94
+ for (const route of ['@b.example/path', ':444/path', 'https://b.example/path', '//b.example/path',
95
+ '/\\b.example/path', '\\b.example/path', ' /api/v1/usage', '/\n/b.example/path', null])
96
+ await assert.rejects(client.request(route), /routes must be absolute paths/);
97
+ assert.equal(calls.length, 0);
98
+ await client.request('/api/v1/usage?query=synthetic%20value');
99
+ assert.equal(calls.length, 1);
100
+ assert.equal(calls[0].url, 'https://a.example/api/v1/usage?query=synthetic%20value');
101
+ assert.equal(calls[0].options.headers.Authorization, 'Bearer oc_synthetic_a');
102
+ assert.equal(calls[0].options.redirect, 'error');
103
+ } finally {
104
+ if (previous === undefined) delete process.env.OPENCRAWL_CONFIG_DIR;else process.env.OPENCRAWL_CONFIG_DIR = previous;
105
+ await fs.rm(directory, { recursive: true, force: true });
106
+ }
107
+ });
108
+
109
+ const script = fileURLToPath(new URL('../bin/opencrawl.mjs', import.meta.url));
110
+ function run(args, env, cwd, input = '') {
111
+ return new Promise((resolve, reject) => {
112
+ const child = spawn(process.execPath, [script, ...args], { env, cwd, stdio: ['pipe', 'pipe', 'pipe'] });
113
+ let stdout = '', stderr = '';const timer = setTimeout(() => child.kill('SIGKILL'), 10000);
114
+ child.stdout.on('data', value => { stdout += value });child.stderr.on('data', value => { stderr += value });
115
+ child.on('error', reject);child.on('close', code => { clearTimeout(timer);resolve({ code, stdout, stderr }) });child.stdin.end(input);
116
+ });
117
+ }
118
+ async function fixtureServer(digit) {
119
+ const calls = [], key = 'oc_synthetic_' + digit;
120
+ const server = http.createServer(async (request, response) => {
121
+ let raw = '';for await (const part of request) raw += part;
122
+ calls.push({ path: request.url, authorization: request.headers.authorization });
123
+ let result = { status: 'ok' };
124
+ if (request.url !== '/healthz' && request.headers.authorization !== 'Bearer ' + key) {
125
+ response.writeHead(401, { 'Content-Type': 'application/json' });response.end('{"detail":"Synthetic key rejected"}');return;
126
+ }
127
+ if (request.url === '/api/auth/me') result = identity(digit);
128
+ else if (request.url === '/mcp') { const message = JSON.parse(raw);result = { jsonrpc: '2.0', id: message.id, result: { tools: [], workspace: digit } }; }
129
+ else if (request.url === '/api/v1/context') result = { context: '[S1] Synthetic workspace ' + digit };
130
+ else result = { workspace: digit };
131
+ response.writeHead(200, { 'Content-Type': 'application/json' });response.end(JSON.stringify(result));
132
+ });
133
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
134
+ return { calls, key, url: 'http://127.0.0.1:' + server.address().port,
135
+ close: () => new Promise(resolve => server.close(resolve)) };
136
+ }
137
+
138
+ test('real two-origin commands never send a saved key to a changed destination', async () => {
139
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'opencrawl-accounts-'));
140
+ const env = { ...process.env };for (const key of Object.keys(env)) if (key.startsWith('OPENCRAWL_')) delete env[key];
141
+ env.OPENCRAWL_CONFIG_DIR = path.join(directory, 'config');
142
+ const a = await fixtureServer('a'), b = await fixtureServer('b');
143
+ const configPath = path.join(env.OPENCRAWL_CONFIG_DIR, 'config.json');
144
+ try {
145
+ let result = await run(['login', '--server', a.url], env, directory, a.key + '\n');
146
+ assert.equal(result.code, 0, result.stderr);assert.equal(result.stdout.includes(a.key), false);
147
+ const account = JSON.parse(await fs.readFile(configPath)).activeProfile;
148
+ assert.equal((await fs.stat(configPath)).mode & 0o777, 0o600);
149
+ for (const [args, extra] of [[['status', '--server', b.url], {}], [['status'], { OPENCRAWL_URL: b.url }]]) {
150
+ result = await run(args, { ...env, ...extra }, directory);
151
+ assert.equal(result.code, 1);assert.equal(b.calls.length, 0);
152
+ }
153
+ assert.equal((await run(['configure', '--server', b.url], env, directory)).code, 0);
154
+ assert.equal((await run(['status'], env, directory)).code, 1);assert.equal(b.calls.length, 0);
155
+ const doctor = await run(['doctor'], env, directory);
156
+ assert.equal(doctor.code, 1);assert.equal(b.calls.at(-1).path, '/healthz');assert.equal(b.calls.at(-1).authorization, undefined);
157
+ result = await run(['status'], { ...env, OPENCRAWL_API_KEY: b.key }, directory);
158
+ assert.equal(result.code, 0);assert.equal(b.calls.at(-1).authorization, 'Bearer ' + b.key);
159
+ result = await run(['login', '--account', account], { ...env, OPENCRAWL_URL: b.url }, directory, a.key + '\n');
160
+ assert.equal(result.code, 0, result.stderr);assert.equal(a.calls.at(-1).path, '/api/auth/me');
161
+ assert.equal(b.calls.some(call => call.authorization === 'Bearer ' + a.key), false);
162
+ const before = await fs.readFile(configPath, 'utf8');
163
+ result = await run(['login', '--account', account, '--server', b.url], env, directory, a.key + '\n');
164
+ assert.equal(result.code, 1);assert.equal(await fs.readFile(configPath, 'utf8'), before);
165
+ } finally { await Promise.all([a.close(), b.close()]);await fs.rm(directory, { recursive: true, force: true }); }
166
+ });
167
+
168
+ test('installed MCP and hook stay pinned after another login and ambient configuration changes', async () => {
169
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'opencrawl-pins-'));
170
+ const env = { ...process.env };for (const key of Object.keys(env)) if (key.startsWith('OPENCRAWL_')) delete env[key];
171
+ env.OPENCRAWL_CONFIG_DIR = path.join(directory, 'config');
172
+ const a = await fixtureServer('a'), b = await fixtureServer('b');
173
+ try {
174
+ assert.equal((await run(['login', '--server', a.url], env, directory, a.key + '\n')).code, 0);
175
+ for (const target of ['claude-code', 'cursor', 'codex']) assert.equal((await run(['install', target], env, directory)).code, 0);
176
+ const mcp = JSON.parse(await fs.readFile(path.join(directory, '.mcp.json'), 'utf8')).mcpServers.opencrawl;
177
+ const settings = await fs.readFile(path.join(directory, '.claude/settings.local.json'), 'utf8');
178
+ const codex = await fs.readFile(path.join(directory, '.codex/config.toml'), 'utf8');
179
+ assert.ok(mcp.args.includes('--account') && mcp.args.includes(a.url) && mcp.args.includes('--config-dir'));
180
+ for (const value of [JSON.stringify(mcp), settings, codex]) assert.ok(!value.includes(a.key) && value.includes('--account'));
181
+ assert.equal((await run(['login', '--server', b.url], env, directory, b.key + '\n')).code, 0);
182
+ const before = b.calls.length;
183
+ const legacyBridge = await run(['mcp'], env, directory, '{"jsonrpc":"2.0","id":1,"method":"tools/list"}\n');
184
+ assert.equal(legacyBridge.code, 1);assert.match(legacyBridge.stderr, /pinned account/);assert.equal(b.calls.length, before);
185
+ assert.equal((await run(['hook'], env, directory, '{"prompt":"Synthetic question"}')).stdout, '');
186
+ const changedEnv = { ...env, OPENCRAWL_URL: b.url, OPENCRAWL_API_KEY: b.key, OPENCRAWL_CONFIG_DIR: path.join(directory, 'unrelated') };
187
+ const argumentsAfterScript = mcp.args.slice(1);
188
+ const response = await run(argumentsAfterScript, changedEnv, directory, '{"jsonrpc":"2.0","id":7,"method":"tools/list"}\n');
189
+ assert.equal(response.code, 0, response.stderr);assert.equal(JSON.parse(response.stdout).result.workspace, 'a');
190
+ const hooked = await run(['hook', ...argumentsAfterScript.slice(1)], changedEnv, directory, '{"prompt":"Synthetic question"}');
191
+ assert.equal(hooked.code, 0);assert.match(JSON.parse(hooked.stdout).hookSpecificOutput.additionalContext, /workspace a/);
192
+ assert.equal(b.calls.some(call => call.path === '/mcp' || call.path === '/api/v1/context'), false);
193
+ const account = argumentsAfterScript[argumentsAfterScript.indexOf('--account') + 1];
194
+ assert.equal((await run(['logout', '--account', account], env, directory)).code, 0);
195
+ const disconnected = await run(argumentsAfterScript, changedEnv, directory, '{"jsonrpc":"2.0","id":8,"method":"tools/list"}\n');
196
+ assert.ok(JSON.parse(disconnected.stdout).error);assert.equal(b.calls.some(call => call.path === '/mcp'), false);
197
+ } finally { await Promise.all([a.close(), b.close()]);await fs.rm(directory, { recursive: true, force: true }); }
198
+ });
199
+
200
+ test('legacy migration requires explicit login and failed login leaves the old file untouched', async () => {
201
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'opencrawl-legacy-'));
202
+ const env = { ...process.env };for (const key of Object.keys(env)) if (key.startsWith('OPENCRAWL_')) delete env[key];
203
+ env.OPENCRAWL_CONFIG_DIR = path.join(directory, 'config');const server = await fixtureServer('a');
204
+ try {
205
+ await fs.mkdir(env.OPENCRAWL_CONFIG_DIR);
206
+ const file = path.join(env.OPENCRAWL_CONFIG_DIR, 'config.json');
207
+ const legacy = JSON.stringify({ server: server.url, apiKey: 'oc_unknown_origin', unrelated: 'preserved' });
208
+ await fs.writeFile(file, legacy, { mode: 0o600 });
209
+ const denied = await run(['status'], env, directory);
210
+ assert.equal(denied.code, 1);assert.match(denied.stderr, /predates server binding/);assert.equal(server.calls.length, 0);
211
+ assert.equal((await run(['install', 'claude-code'], env, directory)).code, 1);
212
+ await assert.rejects(fs.stat(path.join(directory, '.mcp.json')), { code: 'ENOENT' });
213
+ const failed = await run(['login', '--server', server.url], env, directory, 'oc_explicit_invalid\n');
214
+ assert.equal(failed.code, 1);assert.equal(await fs.readFile(file, 'utf8'), legacy);
215
+ assert.equal((await run(['login', '--server', server.url], env, directory, server.key + '\n')).code, 0);
216
+ const upgraded = JSON.parse(await fs.readFile(file));
217
+ assert.equal(upgraded.version, 2);assert.equal(upgraded.apiKey, undefined);assert.equal(upgraded.unrelated, 'preserved');
218
+ assert.equal((await run(['status'], env, directory)).code, 0);
219
+ const inventory = await run(['profiles'], env, directory);
220
+ assert.equal(JSON.parse(inventory.stdout).accounts.length, 1);assert.equal(inventory.stdout.includes(server.key), false);
221
+ assert.equal(server.calls.some(call => call.authorization === 'Bearer oc_unknown_origin'), false);
222
+ } finally { await server.close();await fs.rm(directory, { recursive: true, force: true }); }
223
+ });
package/src/agent.mjs ADDED
@@ -0,0 +1,95 @@
1
+ // Talk to the OpenCrawl agent from a terminal or a coding agent. Output is JSON for agents.
2
+ import {settle, transient} from './jobs.mjs';
3
+
4
+ export const AGENT_HELP = `Usage:
5
+ opencrawl agent signup --organization NAME --email EMAIL [--create-new]
6
+ opencrawl agent join POLL_URL after signup asks for approval: wait for an admin to approve, then save the key
7
+ opencrawl agent "what you want to build" start a session
8
+ opencrawl agent "answer" --session SESSION_ID continue it
9
+ opencrawl agent --session SESSION_ID read the session (customer discovery progress, spec, project)
10
+ opencrawl agent --session SESSION_ID --build build the project, or apply changes described on its session
11
+ opencrawl agent --session SESSION_ID --new-key a new project key (the previous one stops working), if a key was lost
12
+ opencrawl agent --session SESSION_ID --check verify app authorizations
13
+ opencrawl agent --session SESSION_ID --connect APP add gmail, googledrive, notion, hubspot or granola to a built project
14
+ opencrawl agent projects list built projects
15
+ opencrawl agent memory what the OpenCrawl agent remembers for this organization
16
+ opencrawl agent remember "note" add to that memory (or say "remember ..." in any message)
17
+ Use an organization admin key (OPENCRAWL_API_KEY or opencrawl login). Signup needs no key.
18
+ Signup and build save the keys they return as CLI accounts and owner-only key files, and print where
19
+ instead of the key: use --account ACCOUNT on any command, or read key_file for MCP or your server.
20
+ Signup makes the organization account the default; a project account is used with --account.
21
+ Use the project account to read a project's watches, jobs and research. --print-keys prints keys instead.`;
22
+
23
+ const SESSION = /^ags_[a-f0-9]{32}$/;
24
+
25
+ export function agentRequest(rest, options) {
26
+ const [first, ...more] = rest;
27
+ if (first === 'signup') {
28
+ if (!options.organization || !options.email) throw new Error('Use agent signup --organization NAME --email EMAIL.');
29
+ return {route: '/api/v1/agent/signup', method: 'POST', authenticated: false,
30
+ body: {organization: String(options.organization), email: String(options.email), agent: 'OpenCrawl CLI', ...(options['create-new'] ? {create_new: true} : {})}};
31
+ }
32
+ if (first === 'join') {
33
+ const token = /^https?:\/\/[^/]+\/api\/v1\/agent\/signup\/([A-Za-z0-9_-]{20,64})$/.exec(String(more[0] ?? ''))?.[1];
34
+ if (!token || more.length > 1) throw new Error('Use agent join POLL_URL, with the poll_url agent signup returned.');
35
+ return {route: `/api/v1/agent/signup/${token}`, method: 'GET', authenticated: false, poll: true};
36
+ }
37
+ if (first === 'projects' && !more.length) return {route: '/api/v1/agent/projects', method: 'GET'};
38
+ if (first === 'memory' && !more.length) return {route: '/api/v1/agent/memory', method: 'GET'};
39
+ if (first === 'remember') {
40
+ if (!more.join(' ').trim()) throw new Error('Use agent remember "note".');
41
+ return {route: '/api/v1/agent/memory', method: 'POST', body: {note: more.join(' ').trim()}};
42
+ }
43
+ const message = rest.join(' ').trim();
44
+ const session = options.session;
45
+ if (session !== undefined && !SESSION.test(String(session))) throw new Error('--session must be a session ID returned by opencrawl agent.');
46
+ if ([options.build, options.check, options.connect, options['new-key']].filter(Boolean).length > 1) throw new Error('Choose one of --build, --check, --connect or --new-key.');
47
+ const action = options.build ? 'build' : options.check ? 'check_connections' : options.connect ? 'connect' : options['new-key'] ? 'issue_key' : undefined;
48
+ if (action && !session) throw new Error(`--${options.build ? 'build' : options.check ? 'check' : options.connect ? 'connect' : 'new-key'} needs --session.`);
49
+ if (options.connect === true) throw new Error('--connect needs an app name, such as granola.');
50
+ if (!action && !message && session) return {route: `/api/v1/agent/sessions/${session}`, method: 'GET'};
51
+ if (!action && !message) throw new Error('Say what you want to build, or use --session with --build or --check.');
52
+ const body = {...(message ? {message} : {}), ...(action ? {action} : {}), ...(action === 'connect' ? {toolkit: String(options.connect)} : {})};
53
+ return session ? {route: `/api/v1/agent/sessions/${session}/messages`, method: 'POST', body}
54
+ : {route: '/api/v1/agent/sessions', method: 'POST', body};
55
+ }
56
+
57
+ // Keys are shown once. Keep them out of terminal output (and an agent's chat) unless asked: save them instead.
58
+ export async function keepKeys(result, saveKey) {
59
+ if (!result || typeof result !== 'object') return result;
60
+ let next = result;
61
+ if (typeof result.api_key === 'string') {
62
+ const {api_key: key, ...rest} = result;
63
+ next = {...rest, api_key_saved: {...await saveKey(key, {activate: true}), kind: 'organization'}};
64
+ }
65
+ if (typeof next.project?.api_key === 'string') {
66
+ const {api_key: key, ...project} = next.project;
67
+ next = {...next, project: {...project, api_key_saved: {...await saveKey(key, {activate: false}), kind: 'project'}}};
68
+ }
69
+ return next;
70
+ }
71
+
72
+ export async function agentCommand(rest, options, client, out = value => process.stdout.write(JSON.stringify(value, null, 2) + '\n'), saveKey,
73
+ {now = () => performance.now(), sleep = ms => new Promise(resolve => setTimeout(resolve, ms))} = {}) {
74
+ const request = agentRequest(rest, options);
75
+ // A join request waits up to the hour an approval link stays valid.
76
+ const pollUntil = now() + 3600000;
77
+ let deadline = now() + 120000, result, failures = 0;
78
+ while (true) {
79
+ try {
80
+ result = await client.request(request.route, {method: request.method, body: request.body, authenticated: request.authenticated !== false, timeout: 120000});
81
+ if (!request.poll || result?.status !== 'pending' || now() >= pollUntil) break;
82
+ await sleep(10000); deadline = now() + 120000; failures = 0; continue;
83
+ }
84
+ catch (error) {
85
+ // Reads are safe to repeat while the server restarts. A write's outcome is unknown, so say how to find out.
86
+ if (request.method === 'GET' && transient(error) && now() < deadline) { await settle(++failures, {now, sleep, deadline}); continue; }
87
+ if (transient(error) && request.method === 'POST' && options.session)
88
+ error.message += ` The outcome is unknown: read the session (opencrawl agent --session ${options.session}) before sending again.` +
89
+ (request.body?.action === 'build' ? ' If it shows the project built but you did not receive its key, use --new-key.' : '');
90
+ throw error;
91
+ }
92
+ }
93
+ out(saveKey && !options['print-keys'] ? await keepKeys(result, saveKey) : result);
94
+ return 0;
95
+ }
@@ -0,0 +1,58 @@
1
+ import assert from 'node:assert/strict';
2
+ import {test} from 'node:test';
3
+ import {agentRequest, agentCommand} from './agent.mjs';
4
+
5
+ const session = 'ags_' + 'a'.repeat(32);
6
+ test('agent requests map messages, actions and signup to the OpenCrawl agent API', () => {
7
+ assert.deepEqual(agentRequest(['watch', 'rival', 'changelogs'], {}), {route: '/api/v1/agent/sessions', method: 'POST', body: {message: 'watch rival changelogs'}});
8
+ assert.deepEqual(agentRequest(['hourly'], {session}), {route: `/api/v1/agent/sessions/${session}/messages`, method: 'POST', body: {message: 'hourly'}});
9
+ assert.deepEqual(agentRequest([], {session, build: true}).body, {action: 'build'});
10
+ assert.deepEqual(agentRequest([], {session, check: true}).body, {action: 'check_connections'});
11
+ assert.deepEqual(agentRequest([], {session, connect: 'granola'}).body, {action: 'connect', toolkit: 'granola'});
12
+ assert.deepEqual(agentRequest(['projects'], {}), {route: '/api/v1/agent/projects', method: 'GET'});
13
+ assert.deepEqual(agentRequest([], {session}), {route: `/api/v1/agent/sessions/${session}`, method: 'GET'});
14
+ assert.deepEqual(agentRequest(['memory'], {}), {route: '/api/v1/agent/memory', method: 'GET'});
15
+ assert.deepEqual(agentRequest(['remember', 'Lantern', 'is', 'lantern.fm'], {}), {route: '/api/v1/agent/memory', method: 'POST', body: {note: 'Lantern is lantern.fm'}});
16
+ const signup = agentRequest(['signup'], {organization: 'Acme', email: 'a@acme.example', 'create-new': true});
17
+ assert.equal(signup.authenticated, false); assert.deepEqual(signup.body, {organization: 'Acme', email: 'a@acme.example', agent: 'OpenCrawl CLI', create_new: true});
18
+ for (const [rest, options] of [[[], {}], [[], {build: true}], [['x'], {session: 'bad'}], [['signup'], {email: 'a@b.c'}], [[], {session, build: true, check: true}], [[], {session, connect: true}], [[], {connect: 'gmail'}], [['remember'], {}]]) assert.throws(() => agentRequest(rest, options));
19
+ });
20
+ test('agent command prints the server response as JSON', async () => {
21
+ const calls = [], printed = [];
22
+ await agentCommand(['hello'], {}, {request: async (route, init) => { calls.push({route, init}); return {session_id: session, reply: 'Hi'}; }}, v => printed.push(v));
23
+ assert.equal(calls[0].init.timeout, 120000); assert.equal(calls[0].init.authenticated, true); assert.deepEqual(printed, [{session_id: session, reply: 'Hi'}]);
24
+ });
25
+ test('research feed and subscription commands map to the project signal routes', async () => {
26
+ const {researchRequest} = await import('./research.mjs');
27
+ assert.deepEqual(researchRequest(['feed'], {customer: 'Basedash', after: '7'}), {command: 'feed', route: '/api/research/feed?after=7&limit=50&customer=Basedash'});
28
+ assert.deepEqual(researchRequest(['subscribe', 'https://hooks.example/oc'], {customer: 'Basedash'}).body, {endpoint: 'https://hooks.example/oc', customer: 'Basedash'});
29
+ assert.equal(researchRequest(['unsubscribe', 'rsub_' + 'a'.repeat(32)], {}).method, 'DELETE');
30
+ for (const [rest, options] of [[['subscribe', 'http://insecure.example'], {}], [['unsubscribe', 'bad'], {}], [['feed', 'extra'], {}]]) assert.throws(() => researchRequest(rest, options));
31
+ });
32
+ test('session reads ride out a server restart; a lost build says how to recover the key', async () => {
33
+ assert.deepEqual(agentRequest([], {session, 'new-key': true}).body, {action: 'issue_key'});
34
+ let time = 0, calls = 0;
35
+ const clock = {now: () => time, sleep: async ms => { time += ms; }};
36
+ const printed = [];
37
+ await agentCommand([], {session}, {request: async () => { if (++calls < 3) { const e = Error('OpenCrawl 502'); e.status = 502; throw e; } return {state: 'ready'}; }}, v => printed.push(v), undefined, clock);
38
+ assert.equal(calls, 3); assert.deepEqual(printed, [{state: 'ready'}]);
39
+ calls = 0;
40
+ await assert.rejects(agentCommand([], {session, build: true}, {request: async () => { calls++; const e = Error('OpenCrawl 502.'); e.status = 502; throw e; }}, () => {}, undefined, clock),
41
+ error => error.message.includes('--new-key') && error.message.includes(`--session ${session}`));
42
+ assert.equal(calls, 1); // a build is never sent twice on its own
43
+ await assert.rejects(agentCommand([], {session}, {request: async () => { const e = Error('OpenCrawl 404'); e.status = 404; throw e; }}, () => {}, undefined, clock));
44
+ });
45
+ test('join waits for an admin to approve, then saves the organization key', async () => {
46
+ const poll = 'https://app.getopencrawl.com/api/v1/agent/signup/' + 'T'.repeat(43);
47
+ assert.deepEqual(agentRequest(['join', poll], {}), {route: '/api/v1/agent/signup/' + 'T'.repeat(43), method: 'GET', authenticated: false, poll: true});
48
+ for (const rest of [['join'], ['join', 'https://app.getopencrawl.com/api/v1/agent/sessions'], ['join', poll, 'extra']]) assert.throws(() => agentRequest(rest, {}));
49
+ let time = 0, calls = 0;
50
+ const clock = {now: () => time, sleep: async ms => { time += ms; }}, printed = [], saved = [];
51
+ await agentCommand(['join', poll], {}, {request: async (route, init) => { assert.equal(init.authenticated, false); return ++calls < 3 ? {status: 'pending'} : {status: 'approved', organization: {name: 'Acme'}, api_key: 'oc_secret'}; }},
52
+ v => printed.push(v), async (key, options) => { saved.push([key, options]); return {account: 'acc_1'}; }, clock);
53
+ assert.equal(calls, 3); assert.equal(time, 20000); assert.deepEqual(saved, [['oc_secret', {activate: true}]]);
54
+ assert.deepEqual(printed, [{status: 'approved', organization: {name: 'Acme'}, api_key_saved: {account: 'acc_1', kind: 'organization'}}]);
55
+ time = 0; printed.length = 0;
56
+ await agentCommand(['join', poll], {}, {request: async () => ({status: 'pending'})}, v => printed.push(v), undefined, clock);
57
+ assert.ok(time >= 3600000); assert.deepEqual(printed, [{status: 'pending'}]);
58
+ });
@@ -0,0 +1,72 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { validateCaptureUrl, isReadOnlyRequest, browserCommand } from '../browser.mjs';
7
+
8
+ test('browser scope rejects executable schemes and action links', () => {
9
+ for (const url of ['file:///etc/passwd', 'javascript:alert(1)', 'https://x.test/logout', 'https://x.test/%64elete/123', 'https://x.test/?action=delete', 'https://user:password@x.test']) {
10
+ assert.throws(() => validateCaptureUrl(url));
11
+ }
12
+ assert.equal(validateCaptureUrl('https://crm.example/contacts#top').href, 'https://crm.example/contacts');
13
+ });
14
+ test('only read HTTP methods and GraphQL queries are accepted', () => {
15
+ assert.equal(isReadOnlyRequest('GET'), true);
16
+ assert.equal(isReadOnlyRequest('DELETE'), false);
17
+ assert.equal(isReadOnlyRequest('POST', '{"name":"Change me"}'), false);
18
+ assert.equal(isReadOnlyRequest('POST', JSON.stringify({ query: 'query Contacts { contacts { name } }' })), true);
19
+ assert.equal(isReadOnlyRequest('POST', JSON.stringify({ query: 'mutation DeleteContact { deleteContact(id: 1) }' })), false);
20
+ });
21
+
22
+ function browserFixture() {
23
+ const observations = { urls: [], closed: false, documents: [], routes: [] };
24
+ const frame = {};
25
+ let current = 'about:blank';
26
+ const page = {
27
+ goto: async url => { current = url; observations.urls.push(url); return { status: () => 200 }; },
28
+ url: () => current, mainFrame: () => frame,
29
+ waitForLoadState: async () => {},
30
+ evaluate: async () => ({ title: 'Customer account', text: 'Acme customer account. Renewal is on November 15. Owner is Jamie.', links: [
31
+ 'https://crm.example/accounts/next', 'https://crm.example/logout', 'https://unrelated.example/private',
32
+ ], headings: ['Acme'] }),
33
+ locator: () => ({}), screenshot: async ({ path: output }) => fs.writeFile(output, 'test screenshot'),
34
+ };
35
+ const context = {
36
+ pages: () => [page], newPage: async () => page, route: async (match, callback) => observations.routes.push(callback),
37
+ on: () => {}, close: async () => { observations.closed = true; },
38
+ };
39
+ const playwright = { chromium: { launchPersistentContext: async (dir, options) => { observations.profile = dir; observations.options = options; return context; } } };
40
+ const client = { configured: true, request: async (route, { body }) => { observations.documents.push(body); return { id: 'document-' + observations.documents.length }; } };
41
+ return { observations, playwright, client };
42
+ }
43
+
44
+ test('capture imports provenance using isolated profile and blocks mutations', async () => {
45
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'opencrawl-browser-test-'));
46
+ const prior = process.env.OPENCRAWL_CONFIG_DIR; process.env.OPENCRAWL_CONFIG_DIR = directory;
47
+ try {
48
+ const { observations, playwright, client } = browserFixture();
49
+ const result = await browserCommand('capture', 'https://crm.example/accounts/123', { headless: true }, client, { playwright });
50
+ assert.equal(result.imported, 1);
51
+ assert.equal(observations.documents[0].visibility, 'private');
52
+ assert.equal(observations.documents[0].url, 'https://crm.example/accounts/123');
53
+ assert.ok(observations.profile.startsWith(path.join(directory, 'browser-profiles')));
54
+ assert.equal(observations.options.serviceWorkers, 'block');
55
+ assert.equal(observations.closed, true);
56
+ let aborted = false;
57
+ await observations.routes[0]({ request: () => ({ method: () => 'DELETE', postData: () => null }), abort: async () => { aborted = true; } });
58
+ assert.equal(aborted, true);
59
+ await assert.rejects(browserCommand('capture', 'https://other.example/page', {}, client, { playwright }), /scoped to/);
60
+ } finally { if (prior === undefined) delete process.env.OPENCRAWL_CONFIG_DIR; else process.env.OPENCRAWL_CONFIG_DIR = prior; await fs.rm(directory, { recursive: true, force: true }); }
61
+ });
62
+
63
+ test('bounded crawl excludes other origins and action links', async () => {
64
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'opencrawl-crawl-test-'));
65
+ const prior = process.env.OPENCRAWL_CONFIG_DIR; process.env.OPENCRAWL_CONFIG_DIR = directory;
66
+ try {
67
+ const { observations, playwright, client } = browserFixture();
68
+ const result = await browserCommand('crawl', 'https://crm.example/accounts/123', { 'max-pages': '2', 'path-prefix': '/accounts/' }, client, { playwright });
69
+ assert.equal(result.imported, 2);
70
+ assert.deepEqual(observations.urls, ['https://crm.example/accounts/123', 'https://crm.example/accounts/next']);
71
+ } finally { if (prior === undefined) delete process.env.OPENCRAWL_CONFIG_DIR; else process.env.OPENCRAWL_CONFIG_DIR = prior; await fs.rm(directory, { recursive: true, force: true }); }
72
+ });