@4ier/neo 1.2.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/tools/neo.cjs +310 -44
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4ier/neo",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "Turn any website into an AI-callable API. Passive traffic capture, API schema generation, and execution.",
5
5
  "type": "module",
6
6
  "scripts": {
package/tools/neo.cjs CHANGED
@@ -28,7 +28,7 @@
28
28
  // neo tab List CDP targets in the active session
29
29
  // neo tab <index> | neo tab --url <pat> Switch active tab target
30
30
  // neo inject [--persist] [--tab pattern] Inject Neo capture script into page target
31
- // neo snapshot [-i] [-C] [--json] Snapshot a11y tree with @ref mapping
31
+ // neo snapshot [-i] [-C] [--json] [--diff] Snapshot a11y tree with @ref mapping
32
32
  // neo click @ref [--new-tab] Click element by @ref
33
33
  // neo fill @ref "text" Clear then fill element by @ref
34
34
  // neo type @ref "text" Type text without clearing
@@ -58,7 +58,13 @@ const SCHEMA_DIR = process.env.NEO_SCHEMA_DIR || path.join(process.env.HOME, '.n
58
58
  const WORKFLOW_FILE_EXT = '.workflows.json';
59
59
  const SESSION_FILE = '/tmp/neo-sessions.json';
60
60
  const EXTENSION_ID_CACHE_FILE = '/tmp/neo-ext-id';
61
- const NEO_HOME_DIR = path.join(process.env.HOME, '.neo');
61
+ const NEO_BASE_DIR = path.join(process.env.HOME, '.neo');
62
+ const NEO_PROFILE = process.env.NEO_PROFILE || null;
63
+ function getNeoHomeDir(profile) {
64
+ const p = profile || NEO_PROFILE;
65
+ return p ? path.join(NEO_BASE_DIR, 'profiles', p) : NEO_BASE_DIR;
66
+ }
67
+ const NEO_HOME_DIR = getNeoHomeDir();
62
68
  const NEO_CONFIG_FILE = path.join(NEO_HOME_DIR, 'config.json');
63
69
  const NEO_EXTENSION_DIR = path.join(NEO_HOME_DIR, 'extension');
64
70
  const DEFAULT_SESSION_NAME = '__default__';
@@ -2151,6 +2157,7 @@ function parseSnapshotArgs(argv) {
2151
2157
  interactiveOnly: false,
2152
2158
  includeCursorPointer: false,
2153
2159
  json: false,
2160
+ diff: false,
2154
2161
  selector: null,
2155
2162
  };
2156
2163
  const unknown = [];
@@ -2170,6 +2177,10 @@ function parseSnapshotArgs(argv) {
2170
2177
  options.json = true;
2171
2178
  continue;
2172
2179
  }
2180
+ if (current === '--diff') {
2181
+ options.diff = true;
2182
+ continue;
2183
+ }
2173
2184
  if (current === '--selector') {
2174
2185
  if (args[i + 1] && !args[i + 1].startsWith('-')) {
2175
2186
  options.selector = args[i + 1];
@@ -2397,14 +2408,43 @@ commands.version = function() {
2397
2408
  console.log(`neo v${pkg.version}`);
2398
2409
  };
2399
2410
 
2400
- // neo setup
2411
+ // neo profiles — list configured profiles
2412
+ commands.profiles = function() {
2413
+ const profilesDir = path.join(NEO_BASE_DIR, 'profiles');
2414
+ const profiles = [];
2415
+ if (fs.existsSync(profilesDir)) {
2416
+ for (const name of fs.readdirSync(profilesDir)) {
2417
+ const configPath = path.join(profilesDir, name, 'config.json');
2418
+ if (fs.existsSync(configPath)) {
2419
+ profiles.push(name);
2420
+ }
2421
+ }
2422
+ }
2423
+ const defaultExists = fs.existsSync(NEO_CONFIG_FILE);
2424
+ if (defaultExists) console.log(` default ${NEO_HOME_DIR}`);
2425
+ for (const name of profiles) {
2426
+ console.log(` ${name.padEnd(9)} ${path.join(profilesDir, name)}`);
2427
+ }
2428
+ if (!defaultExists && profiles.length === 0) {
2429
+ console.log('No profiles configured. Run neo setup to create default profile.');
2430
+ }
2431
+ };
2432
+
2433
+ // neo setup [--profile <name>]
2401
2434
  commands.setup = async function(args) {
2402
2435
  const { positional, flags } = parseArgs(args || []);
2403
- if (positional.length > 0 || Object.keys(flags).length > 0) {
2404
- console.error('Usage: neo setup');
2436
+ const profileName = typeof flags.profile === 'string' ? flags.profile : null;
2437
+ if (positional.length > 0 || (Object.keys(flags).length > 0 && !('profile' in flags))) {
2438
+ console.error('Usage: neo setup [--profile <name>]');
2405
2439
  process.exit(1);
2406
2440
  }
2407
2441
 
2442
+ // If profile specified, override home dir for this run
2443
+ const homeDir = profileName ? getNeoHomeDir(profileName) : NEO_HOME_DIR;
2444
+ const configFile = path.join(homeDir, 'config.json');
2445
+ const extensionDir = path.join(homeDir, 'extension');
2446
+ const setupSchemaDir = path.join(homeDir, 'schemas');
2447
+
2408
2448
  const chromePath = detectChromeBinaryPath();
2409
2449
  if (!chromePath) {
2410
2450
  throw new Error('Chrome binary not found. Tried: google-chrome-stable, google-chrome, chromium-browser, chromium');
@@ -2412,20 +2452,20 @@ commands.setup = async function(args) {
2412
2452
 
2413
2453
  const projectRoot = path.join(fs.realpathSync(__dirname), '..');
2414
2454
  const extensionSourceDir = path.join(projectRoot, 'extension-dist');
2415
- const setupSchemaDir = path.join(NEO_HOME_DIR, 'schemas');
2416
2455
 
2417
- fs.mkdirSync(NEO_HOME_DIR, { recursive: true });
2418
- copyDirectoryRecursive(extensionSourceDir, NEO_EXTENSION_DIR);
2456
+ fs.mkdirSync(homeDir, { recursive: true });
2457
+ copyDirectoryRecursive(extensionSourceDir, extensionDir);
2419
2458
  fs.mkdirSync(setupSchemaDir, { recursive: true });
2420
2459
 
2421
2460
  const config = {
2422
2461
  chromePath,
2423
2462
  cdpPort: 9222,
2424
2463
  };
2425
- fs.writeFileSync(NEO_CONFIG_FILE, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
2464
+ if (profileName) config.profile = profileName;
2465
+ fs.writeFileSync(configFile, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
2426
2466
 
2427
2467
  // Pre-register Neo extension in Chrome Preferences (zero-config install)
2428
- const profileDefaultDir = path.join(NEO_HOME_DIR, 'chrome-profile', 'Default');
2468
+ const profileDefaultDir = path.join(homeDir, 'chrome-profile', 'Default');
2429
2469
  const prefsFile = path.join(profileDefaultDir, 'Preferences');
2430
2470
  fs.mkdirSync(profileDefaultDir, { recursive: true });
2431
2471
  let prefs = {};
@@ -2442,12 +2482,12 @@ commands.setup = async function(args) {
2442
2482
 
2443
2483
  // Generate deterministic extension ID from path (same algorithm Chrome uses for unpacked)
2444
2484
  const crypto = require('crypto');
2445
- const extRealPath = fs.realpathSync(NEO_EXTENSION_DIR);
2485
+ const extRealPath = fs.realpathSync(extensionDir);
2446
2486
  const hashHex = crypto.createHash('sha256').update(extRealPath).digest('hex').slice(0, 32);
2447
2487
  const extensionId = [...hashHex].map(c => String.fromCharCode('a'.charCodeAt(0) + parseInt(c, 16))).join('');
2448
2488
 
2449
2489
  // Read extension manifest for permissions
2450
- const manifest = JSON.parse(fs.readFileSync(path.join(NEO_EXTENSION_DIR, 'manifest.json'), 'utf8'));
2490
+ const manifest = JSON.parse(fs.readFileSync(path.join(extensionDir, 'manifest.json'), 'utf8'));
2451
2491
  const permissions = manifest.permissions || [];
2452
2492
 
2453
2493
  // Register as unpacked extension (location: 4)
@@ -2486,51 +2526,54 @@ commands.setup = async function(args) {
2486
2526
  fs.writeFileSync(prefsFile, JSON.stringify(prefs, null, 2) + '\n', 'utf8');
2487
2527
 
2488
2528
  console.log('Neo setup complete');
2529
+ if (profileName) console.log(` Profile: ${profileName}`);
2489
2530
  console.log(` Chrome binary: ${chromePath}`);
2490
- console.log(` Extension dir: ${NEO_EXTENSION_DIR}`);
2531
+ console.log(` Extension dir: ${extensionDir}`);
2491
2532
  console.log(` Extension ID: ${extensionId}`);
2492
- console.log(` Config file: ${NEO_CONFIG_FILE}`);
2533
+ console.log(` Config file: ${configFile}`);
2493
2534
  console.log(` Schema dir: ${setupSchemaDir}`);
2494
2535
  console.log('');
2495
2536
  console.log('Launch Chrome with: neo start');
2496
2537
  };
2497
2538
 
2498
- // neo start
2539
+ // neo start [--profile <name>] [--force]
2499
2540
  commands.start = async function(args) {
2500
2541
  const { positional, flags } = parseArgs(args || []);
2501
- if (positional.length > 0 || Object.keys(flags).length > 0) {
2502
- console.error('Usage: neo start');
2503
- process.exit(1);
2504
- }
2542
+ const profileName = typeof flags.profile === 'string' ? flags.profile : null;
2543
+ const force = 'force' in flags;
2544
+
2545
+ const homeDir = profileName ? getNeoHomeDir(profileName) : NEO_HOME_DIR;
2546
+ const configFile = path.join(homeDir, 'config.json');
2547
+ const extensionDir = path.join(homeDir, 'extension');
2505
2548
 
2506
- if (!fs.existsSync(NEO_CONFIG_FILE)) {
2507
- throw new Error(`Missing config: ${NEO_CONFIG_FILE}. Run neo setup first`);
2549
+ if (!fs.existsSync(configFile)) {
2550
+ throw new Error(`Missing config: ${configFile}. Run neo setup${profileName ? ' --profile ' + profileName : ''} first`);
2508
2551
  }
2509
2552
 
2510
2553
  let config = null;
2511
2554
  try {
2512
- config = JSON.parse(fs.readFileSync(NEO_CONFIG_FILE, 'utf8'));
2555
+ config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
2513
2556
  } catch {
2514
- throw new Error(`Invalid config JSON: ${NEO_CONFIG_FILE}`);
2557
+ throw new Error(`Invalid config JSON: ${configFile}`);
2515
2558
  }
2516
2559
 
2517
2560
  const chromePath = String(config && config.chromePath || '').trim();
2518
2561
  const cdpPort = parseInt(String(config && config.cdpPort !== undefined ? config.cdpPort : 9222), 10);
2519
2562
 
2520
2563
  if (!chromePath) {
2521
- throw new Error(`Missing chromePath in ${NEO_CONFIG_FILE}. Run neo setup again`);
2564
+ throw new Error(`Missing chromePath in ${configFile}. Run neo setup again`);
2522
2565
  }
2523
2566
  if (!Number.isInteger(cdpPort) || cdpPort <= 0 || cdpPort > 65535) {
2524
- throw new Error(`Invalid cdpPort in ${NEO_CONFIG_FILE}: ${config && config.cdpPort}`);
2567
+ throw new Error(`Invalid cdpPort in ${configFile}: ${config && config.cdpPort}`);
2525
2568
  }
2526
- if (!fs.existsSync(NEO_EXTENSION_DIR)) {
2527
- throw new Error(`Missing extension directory: ${NEO_EXTENSION_DIR}. Run neo setup first`);
2569
+ if (!fs.existsSync(extensionDir)) {
2570
+ throw new Error(`Missing extension directory: ${extensionDir}. Run neo setup first`);
2528
2571
  }
2529
2572
  if (chromePath.includes('/') && !fs.existsSync(chromePath)) {
2530
2573
  throw new Error(`Chrome binary does not exist: ${chromePath}`);
2531
2574
  }
2532
2575
 
2533
- const userDataDir = String(config && config.userDataDir || '').trim() || path.join(NEO_HOME_DIR, 'chrome-profile');
2576
+ const userDataDir = String(config && config.userDataDir || '').trim() || path.join(homeDir, 'chrome-profile');
2534
2577
  fs.mkdirSync(userDataDir, { recursive: true });
2535
2578
 
2536
2579
  const child = spawn(chromePath, [
@@ -2781,11 +2824,11 @@ commands.inject = async function(args, context = {}) {
2781
2824
  console.log(`Injected Neo capture script${persisted}`);
2782
2825
  };
2783
2826
 
2784
- // neo snapshot [-i] [-C] [--json] [--selector css]
2827
+ // neo snapshot [-i] [-C] [--json] [--diff] [--selector css]
2785
2828
  commands.snapshot = async function(args, context = {}) {
2786
2829
  const { options, unknown } = parseSnapshotArgs(args || []);
2787
2830
  if (unknown.length > 0) {
2788
- console.error('Usage: neo snapshot [-i] [-C] [--json] [--selector css]');
2831
+ console.error('Usage: neo snapshot [-i] [-C] [--json] [--diff] [--selector css]');
2789
2832
  process.exit(1);
2790
2833
  }
2791
2834
 
@@ -2803,11 +2846,12 @@ commands.snapshot = async function(args, context = {}) {
2803
2846
  const displayNodes = options.interactiveOnly
2804
2847
  ? assigned.nodes.filter(node => INTERACTIVE_ROLES.has(String(node.role || '').toLowerCase()))
2805
2848
  : assigned.nodes;
2806
-
2807
- setSession(sessionName, {
2808
- ...session,
2809
- refs: assigned.refs,
2810
- });
2849
+ const storedSnapshot = displayNodes.map(node => ({
2850
+ ref: node.ref,
2851
+ role: node.role,
2852
+ name: node.name,
2853
+ depth: node.depth,
2854
+ }));
2811
2855
 
2812
2856
  if (options.includeCursorPointer) {
2813
2857
  // TODO: Add Runtime.evaluate scan for cursor:pointer elements.
@@ -2818,6 +2862,59 @@ commands.snapshot = async function(args, context = {}) {
2818
2862
  console.error('TODO: snapshot --selector is not implemented yet');
2819
2863
  }
2820
2864
 
2865
+ if (options.diff) {
2866
+ const prev = Array.isArray(session.prevSnapshot) ? session.prevSnapshot : [];
2867
+ const prevMap = new Map(prev.map(node => [`${node.role}:${node.name}`, node]));
2868
+ const currMap = new Map(displayNodes.map(node => [`${node.role}:${node.name}`, node]));
2869
+
2870
+ const added = displayNodes.filter(node => !prevMap.has(`${node.role}:${node.name}`));
2871
+ const removed = prev.filter(node => !currMap.has(`${node.role}:${node.name}`));
2872
+ const changed = displayNodes.filter((node) => {
2873
+ const previousNode = prevMap.get(`${node.role}:${node.name}`);
2874
+ return previousNode && previousNode.depth !== node.depth;
2875
+ });
2876
+
2877
+ const latestSession = getSession(sessionName) || session || {};
2878
+ setSession(sessionName, {
2879
+ ...latestSession,
2880
+ refs: assigned.refs,
2881
+ prevSnapshot: storedSnapshot,
2882
+ });
2883
+
2884
+ if (options.json) {
2885
+ console.log(JSON.stringify({ added, removed, changed }, null, 2));
2886
+ return;
2887
+ }
2888
+
2889
+ const lines = [];
2890
+ if (added.length) {
2891
+ lines.push(`+ Added (${added.length}):`);
2892
+ lines.push(formatSnapshot(added));
2893
+ }
2894
+ if (removed.length) {
2895
+ lines.push(`- Removed (${removed.length}):`);
2896
+ for (const node of removed) {
2897
+ const indent = ' '.repeat(node.depth || 0);
2898
+ lines.push(` ${indent}${node.ref} [${node.role}] "${node.name}"`);
2899
+ }
2900
+ }
2901
+ if (changed.length) {
2902
+ lines.push(`~ Changed (${changed.length}):`);
2903
+ lines.push(formatSnapshot(changed));
2904
+ }
2905
+ if (lines.length === 0) {
2906
+ lines.push('(no changes)');
2907
+ }
2908
+ console.log(lines.join('\n'));
2909
+ return;
2910
+ }
2911
+
2912
+ setSession(sessionName, {
2913
+ ...session,
2914
+ refs: assigned.refs,
2915
+ prevSnapshot: storedSnapshot,
2916
+ });
2917
+
2821
2918
  if (options.json) {
2822
2919
  const refs = {};
2823
2920
  for (const node of displayNodes) {
@@ -4802,11 +4899,13 @@ commands.read = async function(args) {
4802
4899
 
4803
4900
  commands.bridge = async function(args) {
4804
4901
  const { WebSocketServer } = require('ws');
4902
+ const http = require('http');
4805
4903
  const port = parseInt(args.find(a => /^\d+$/.test(a)) || '9234', 10);
4806
4904
  const json = args.includes('--json');
4807
4905
  const quiet = args.includes('--quiet');
4906
+ const wsOnly = args.includes('--ws-only');
4808
4907
 
4809
- const wss = new WebSocketServer({ port });
4908
+ const wss = new WebSocketServer({ noServer: true });
4810
4909
  const clients = new Set();
4811
4910
  const pendingResponses = new Map(); // id → { resolve, timer }
4812
4911
  let cmdIdCounter = 0;
@@ -4824,9 +4923,143 @@ commands.bridge = async function(args) {
4824
4923
 
4825
4924
  if (!quiet) {
4826
4925
  process.stderr.write(`[Neo Bridge] listening on ws://127.0.0.1:${port}\n`);
4926
+ if (!wsOnly) process.stderr.write(`[Neo Bridge] REST API at http://127.0.0.1:${port}\n`);
4827
4927
  process.stderr.write(`[Neo Bridge] waiting for extension to connect...\n`);
4828
4928
  }
4829
4929
 
4930
+ // ─── HTTP REST handler ───────────────────────────────────
4931
+ function jsonResponse(res, status, data) {
4932
+ const body = JSON.stringify(data, null, 2);
4933
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
4934
+ res.end(body);
4935
+ }
4936
+
4937
+ function readBody(req) {
4938
+ return new Promise((resolve) => {
4939
+ let body = '';
4940
+ req.on('data', c => body += c);
4941
+ req.on('end', () => resolve(body));
4942
+ });
4943
+ }
4944
+
4945
+ async function handleHttpRequest(req, res) {
4946
+ if (req.method === 'OPTIONS') {
4947
+ res.writeHead(204, {
4948
+ 'Access-Control-Allow-Origin': '*',
4949
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
4950
+ 'Access-Control-Allow-Headers': 'Content-Type',
4951
+ });
4952
+ res.end();
4953
+ return;
4954
+ }
4955
+
4956
+ const url = new URL(req.url, 'http://localhost');
4957
+ const pathname = url.pathname;
4958
+
4959
+ try {
4960
+ // GET /status
4961
+ if (pathname === '/status' && req.method === 'GET') {
4962
+ return jsonResponse(res, 200, { ok: true, data: { clients: clients.size, uptime: process.uptime() } });
4963
+ }
4964
+
4965
+ // GET /captures?domain=...&limit=...
4966
+ if (pathname === '/captures' && req.method === 'GET') {
4967
+ const cmdArgs = {};
4968
+ if (url.searchParams.has('domain')) cmdArgs.domain = url.searchParams.get('domain');
4969
+ if (url.searchParams.has('limit')) cmdArgs.limit = parseInt(url.searchParams.get('limit'));
4970
+ const resp = await sendCommand('capture.list', cmdArgs);
4971
+ return jsonResponse(res, 200, { ok: true, data: resp.result });
4972
+ }
4973
+
4974
+ // GET /captures/count
4975
+ if (pathname === '/captures/count' && req.method === 'GET') {
4976
+ const resp = await sendCommand('capture.count', {});
4977
+ return jsonResponse(res, 200, { ok: true, data: resp.result });
4978
+ }
4979
+
4980
+ // GET /captures/domains
4981
+ if (pathname === '/captures/domains' && req.method === 'GET') {
4982
+ const resp = await sendCommand('capture.domains', {});
4983
+ return jsonResponse(res, 200, { ok: true, data: resp.result });
4984
+ }
4985
+
4986
+ // GET /tabs
4987
+ if (pathname === '/tabs' && req.method === 'GET') {
4988
+ const tabs = await (await fetch(`${CDP_URL}/json/list`)).json();
4989
+ const pages = tabs.filter(t => t.type === 'page');
4990
+ return jsonResponse(res, 200, { ok: true, data: pages.map(t => ({ title: t.title, url: t.url, id: t.id })) });
4991
+ }
4992
+
4993
+ // GET /schemas
4994
+ if (pathname === '/schemas' && req.method === 'GET') {
4995
+ const files = fs.existsSync(SCHEMA_DIR) ? fs.readdirSync(SCHEMA_DIR).filter(f => f.endsWith('.json')) : [];
4996
+ const schemas = files.map(f => f.replace('.json', ''));
4997
+ return jsonResponse(res, 200, { ok: true, data: schemas });
4998
+ }
4999
+
5000
+ // GET /schemas/:domain
5001
+ if (pathname.startsWith('/schemas/') && req.method === 'GET') {
5002
+ const domain = pathname.slice('/schemas/'.length);
5003
+ const schemaPath = path.join(SCHEMA_DIR, `${domain}.json`);
5004
+ if (!fs.existsSync(schemaPath)) {
5005
+ return jsonResponse(res, 404, { ok: false, error: `No schema for ${domain}` });
5006
+ }
5007
+ const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
5008
+ return jsonResponse(res, 200, { ok: true, data: schema });
5009
+ }
5010
+
5011
+ // GET /snapshot?interactive=true
5012
+ if (pathname === '/snapshot' && req.method === 'GET') {
5013
+ const sessionName = DEFAULT_SESSION_NAME;
5014
+ const session = getSession(sessionName);
5015
+ if (!session || !session.pageWsUrl) {
5016
+ return jsonResponse(res, 400, { ok: false, error: 'No active session. Run neo connect first.' });
5017
+ }
5018
+ await cdpSend(session.pageWsUrl, 'Accessibility.enable');
5019
+ const treeResult = await cdpSend(session.pageWsUrl, 'Accessibility.getFullAXTree');
5020
+ const assigned = assignRefs(treeResult && Array.isArray(treeResult.nodes) ? treeResult.nodes : []);
5021
+ const interactiveOnly = url.searchParams.get('interactive') === 'true';
5022
+ const nodes = interactiveOnly
5023
+ ? assigned.nodes.filter(node => INTERACTIVE_ROLES.has(String(node.role || '').toLowerCase()))
5024
+ : assigned.nodes;
5025
+ setSession(sessionName, { ...session, refs: assigned.refs });
5026
+ return jsonResponse(res, 200, { ok: true, data: { count: nodes.length, nodes } });
5027
+ }
5028
+
5029
+ // POST /eval { expression: "..." }
5030
+ if (pathname === '/eval' && req.method === 'POST') {
5031
+ const body = JSON.parse(await readBody(req));
5032
+ const sessionName = DEFAULT_SESSION_NAME;
5033
+ const session = getSession(sessionName);
5034
+ if (!session || !session.pageWsUrl) {
5035
+ return jsonResponse(res, 400, { ok: false, error: 'No active session' });
5036
+ }
5037
+ const result = await cdpSend(session.pageWsUrl, 'Runtime.evaluate', {
5038
+ expression: body.expression,
5039
+ returnByValue: true,
5040
+ });
5041
+ return jsonResponse(res, 200, { ok: true, data: result.result });
5042
+ }
5043
+
5044
+ return jsonResponse(res, 404, { ok: false, error: 'Not found' });
5045
+ } catch (err) {
5046
+ return jsonResponse(res, 500, { ok: false, error: err.message });
5047
+ }
5048
+ }
5049
+
5050
+ // ─── HTTP Server + WebSocket upgrade ─────────────────────
5051
+ const server = http.createServer(wsOnly ? (req, res) => {
5052
+ jsonResponse(res, 404, { ok: false, error: 'REST disabled (--ws-only)' });
5053
+ } : handleHttpRequest);
5054
+
5055
+ server.on('upgrade', (req, socket, head) => {
5056
+ wss.handleUpgrade(req, socket, head, (ws) => {
5057
+ wss.emit('connection', ws, req);
5058
+ });
5059
+ });
5060
+
5061
+ server.listen(port);
5062
+
4830
5063
  wss.on('connection', (ws) => {
4831
5064
  clients.add(ws);
4832
5065
  if (!quiet) process.stderr.write(`[Neo Bridge] extension connected (${clients.size} client${clients.size > 1 ? 's' : ''})\n`);
@@ -5854,14 +6087,22 @@ commands.reload = async function() {
5854
6087
  };
5855
6088
 
5856
6089
  // neo doctor — diagnose setup issues
5857
- commands.doctor = async function() {
6090
+ commands.doctor = async function(args) {
6091
+ const fix = (args || []).includes('--fix');
5858
6092
  const checks = [];
5859
- function check(name, fn) { checks.push({ name, fn }); }
6093
+ function check(name, fn, fixFn) { checks.push({ name, fn, fixFn }); }
5860
6094
 
5861
6095
  check('Chrome CDP endpoint', async () => {
5862
6096
  const resp = await fetch(`${CDP_URL}/json/version`);
5863
6097
  const info = await resp.json();
5864
6098
  return `${info.Browser} (${CDP_URL})`;
6099
+ }, async () => {
6100
+ process.stderr.write(' → Starting Chrome with neo start...\n');
6101
+ await commands.start([]);
6102
+ await new Promise(r => setTimeout(r, 2000));
6103
+ const resp = await fetch(`${CDP_URL}/json/version`);
6104
+ const info = await resp.json();
6105
+ return `Fixed: ${info.Browser} (${CDP_URL})`;
5865
6106
  });
5866
6107
 
5867
6108
  check('Browser tabs', async () => {
@@ -5876,6 +6117,15 @@ commands.doctor = async function() {
5876
6117
  const extensionId = parseExtensionIdFromUrl(sw.url);
5877
6118
  if (!extensionId) return 'OK';
5878
6119
  return `OK (${extensionId.slice(0, 8)}…)`;
6120
+ }, async () => {
6121
+ process.stderr.write(' → Running neo setup to install extension...\n');
6122
+ await commands.setup([]);
6123
+ process.stderr.write(' → Restarting Chrome...\n');
6124
+ await commands.start(['--force']);
6125
+ await new Promise(r => setTimeout(r, 3000));
6126
+ const sw = await findExtensionServiceWorker({ cdpUrl: CDP_URL });
6127
+ if (!sw) throw new Error('Still not found after fix');
6128
+ return 'Fixed: extension installed and loaded';
5879
6129
  });
5880
6130
 
5881
6131
  check('IndexedDB captures', async () => {
@@ -5890,6 +6140,9 @@ commands.doctor = async function() {
5890
6140
  if (!fs.existsSync(SCHEMA_DIR)) throw new Error(`Missing: ${SCHEMA_DIR}`);
5891
6141
  const files = fs.readdirSync(SCHEMA_DIR).filter(f => f.endsWith('.json'));
5892
6142
  return `${files.length} schema(s) in ${SCHEMA_DIR}`;
6143
+ }, async () => {
6144
+ fs.mkdirSync(SCHEMA_DIR, { recursive: true });
6145
+ return `Fixed: created ${SCHEMA_DIR}`;
5893
6146
  });
5894
6147
 
5895
6148
  check('WebSocket bridge port', async () => {
@@ -5901,14 +6154,26 @@ commands.doctor = async function() {
5901
6154
  });
5902
6155
  });
5903
6156
 
5904
- for (const { name, fn } of checks) {
6157
+ let fixed = 0;
6158
+ for (const { name, fn, fixFn } of checks) {
5905
6159
  try {
5906
6160
  const result = await fn();
5907
6161
  console.log(` ✓ ${name}: ${result}`);
5908
6162
  } catch (err) {
5909
- console.log(` ✗ ${name}: ${err.message}`);
6163
+ if (fix && fixFn) {
6164
+ try {
6165
+ const result = await fixFn();
6166
+ console.log(` ✓ ${name}: ${result}`);
6167
+ fixed++;
6168
+ } catch (fixErr) {
6169
+ console.log(` ✗ ${name}: ${fixErr.message}`);
6170
+ }
6171
+ } else {
6172
+ console.log(` ✗ ${name}: ${err.message}${fixFn ? ' (use --fix to auto-repair)' : ''}`);
6173
+ }
5910
6174
  }
5911
6175
  }
6176
+ if (fix && fixed > 0) console.log(`\n Fixed ${fixed} issue(s).`);
5912
6177
  };
5913
6178
 
5914
6179
  async function main() {
@@ -5933,8 +6198,9 @@ Commands:
5933
6198
  neo eval "<js>" --tab <pattern> Evaluate JS in page context
5934
6199
  neo open <url> Open URL in Chrome
5935
6200
  neo read <tab-pattern> Extract readable text from page
5936
- neo setup Setup ~/.neo config, extension, and schemas
5937
- neo start Start Chrome with configured Neo extension
6201
+ neo setup [--profile <name>] Setup ~/.neo config, extension, and schemas
6202
+ neo start [--profile <name>] Start Chrome with configured Neo extension
6203
+ neo profiles List configured profiles
5938
6204
  neo launch <app> [--port N] Launch Electron app with CDP enabled
5939
6205
  neo connect [port] Connect to CDP and save active session
5940
6206
  neo connect --electron <app-name> Auto-discover Electron CDP port and connect
@@ -5943,7 +6209,7 @@ Commands:
5943
6209
  neo tab List CDP targets in the active session
5944
6210
  neo tab <index> | neo tab --url <pat> Switch active tab target
5945
6211
  neo inject [--persist] [--tab pattern] Inject Neo capture script into page target
5946
- neo snapshot [-i] [-C] [--json] Snapshot a11y tree with @ref mapping
6212
+ neo snapshot [-i] [-C] [--json] [--diff] Snapshot a11y tree with @ref mapping
5947
6213
  neo click @ref [--new-tab] Click element by @ref
5948
6214
  neo fill @ref "text" Clear then fill element by @ref
5949
6215
  neo type @ref "text" Type text without clearing
@@ -5964,7 +6230,7 @@ Commands:
5964
6230
  neo export-skill <domain> Generate agent-ready API reference
5965
6231
  neo mock <domain> [--port N] Generate mock server from schema
5966
6232
  neo bridge [port] [--json] [--quiet] Start WebSocket bridge server
5967
- neo doctor Diagnose setup issues
6233
+ neo doctor [--fix] Diagnose setup issues (--fix to auto-repair)
5968
6234
  neo reload Reload the Neo extension
5969
6235
 
5970
6236
  Options (for exec):