openzoo 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/openzoo.js CHANGED
@@ -6,9 +6,10 @@ const HELP = `openzoo — local x402-paying proxy + MCP server for openzoo.fun
6
6
  usage:
7
7
  npx openzoo start the proxy: http://localhost:8402/v1 (keyless) PLUS a
8
8
  public HTTPS url for cloud IDEs (key required, printed at start)
9
- npx openzoo cursor wire Cursor to the zoo (writes MCP config, prints the
10
- base_url/key to paste into its AI settings)
11
- npx openzoo vscode same, for VS Code
9
+ npx openzoo cursor [path] start proxy+tunnel, write MCP config, and LAUNCH
10
+ Cursor already pointed at the zoo (env inherited)
11
+ npx openzoo vscode [path] same, for VS Code
12
+ npx openzoo editor [path] whichever is installed (Cursor wins if both)
12
13
  npx openzoo launch <cmd> [args] launch a TERMINAL Messages API client
13
14
  (claude, aider...) already pointed at the zoo
14
15
  npx openzoo mcp stdio MCP server (tools: zoo_ask, zoo_bind, zoo_models, zoo_wallet, zoo_contexts)
@@ -49,10 +50,11 @@ async function main() {
49
50
  case 'start':
50
51
  await (await import('../lib/proxy.js')).startProxy({ autoTunnel: true });
51
52
  break;
53
+ case 'editor':
52
54
  case 'cursor':
53
55
  case 'vscode':
54
56
  // GUI editors read config files, not env vars — see lib/setup.js.
55
- (await import('../lib/setup.js')).setupEditor(cmd);
57
+ await (await import('../lib/setup.js')).setupEditor(cmd === 'editor' ? undefined : cmd, process.argv[3]);
56
58
  break;
57
59
  case 'mcp':
58
60
  await (await import('../lib/mcp.js')).startMcp();
package/lib/proxy.js CHANGED
@@ -691,5 +691,15 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
691
691
  }
692
692
  })();
693
693
  }
694
- return { server, client, spent: () => sessionSpent };
694
+ // Expose live tunnel details so a caller that starts the proxy in-process
695
+ // (openzoo cursor/vscode) can surface the public URL + key instead of the
696
+ // user hunting for them. Getters, because the tunnel resolves ASYNC after
697
+ // this returns — a snapshot would always be null.
698
+ return {
699
+ server,
700
+ client,
701
+ spent: () => sessionSpent,
702
+ get publicUrl() { return tunnelGate?.publicUrl ?? null; },
703
+ get tunnelToken() { return tunnelGate?.token ?? null; },
704
+ };
695
705
  }
package/lib/setup.js CHANGED
@@ -1,59 +1,176 @@
1
1
  /**
2
- * `npx openzoo cursor` / `npx openzoo vscode` wire a GUI editor to the zoo.
2
+ * `npx openzoo cursor|vscode [path]` — one command: proxy + tunnel up, config
3
+ * written, editor launched already pointed at the zoo.
3
4
  *
4
- * WHY THIS IS NOT `launch`: Cursor and VS Code are GUI apps, not processes you
5
- * hand env vars to. `openzoo launch <cmd>` works for terminal harnesses (claude,
6
- * aider) because they read ANTHROPIC_BASE_URL/OPENAI_BASE_URL at startup. An
7
- * editor reads its own config instead, and Cursor keeps the provider base-URL +
8
- * key in an ENCRYPTED internal store that no CLI can write — that half stays a
9
- * GUI toggle. So this command does the half that IS automatable (MCP server
10
- * registration, which is plain JSON) and prints the exact values to paste for
11
- * the half that is not, instead of pretending it did everything.
5
+ * THREE THINGS THIS MUST DO, because the user should configure nothing:
6
+ * 1. ANTHROPIC_BASE_URL (and OPENAI_BASE_URL) exported INTO the editor, so
7
+ * its embedded terminals and the Claude Code extension bill through x402.
8
+ * 2. Config written for them MCP server registered in the editor's own
9
+ * mcp.json, no hand-editing.
10
+ * 3. The TUNNEL, because a cloud-run harness cannot reach localhost.
11
+ *
12
+ * WHY LAUNCH THE BINARY, NOT `open -a`: macOS `open` hands the app to launchd,
13
+ * which does NOT pass the caller's environment. `open -a Cursor` therefore
14
+ * configures nothing — the editor comes up with no idea the zoo exists.
15
+ * Spawning Contents/MacOS/Cursor directly keeps the env, which is the entire
16
+ * point of this command.
12
17
  */
13
18
  import fs from 'node:fs';
14
19
  import os from 'node:os';
15
20
  import path from 'node:path';
21
+ import { spawn } from 'node:child_process';
16
22
  import { config } from './config.js';
17
23
 
18
- const CURSOR_MCP = path.join(os.homedir(), '.cursor', 'mcp.json');
19
- const VSCODE_MCP = path.join(os.homedir(), '.vscode', 'mcp.json');
24
+ const MCP_FILES = {
25
+ cursor: path.join(os.homedir(), '.cursor', 'mcp.json'),
26
+ vscode: path.join(os.homedir(), '.vscode', 'mcp.json'),
27
+ };
28
+
29
+ /**
30
+ * Find a launchable editor binary, platform-agnostically.
31
+ *
32
+ * Hardcoding /Applications broke every non-mac install and any mac install
33
+ * that is not in /Applications (~/Applications, Setapp, a homebrew cask on a
34
+ * different volume). Order per editor: PATH first (works everywhere and is
35
+ * what a Linux/Windows user has), then the known app-bundle locations.
36
+ *
37
+ * The BUNDLE BINARY is preferred over `open -a` on macOS because `open` hands
38
+ * the app to launchd, which drops our environment — and the environment IS the
39
+ * configuration here.
40
+ */
41
+ const EDITORS = {
42
+ cursor: {
43
+ cli: ['cursor'],
44
+ bundles: [
45
+ '/Applications/Cursor.app/Contents/MacOS/Cursor',
46
+ path.join(os.homedir(), 'Applications', 'Cursor.app', 'Contents', 'MacOS', 'Cursor'),
47
+ ],
48
+ },
49
+ vscode: {
50
+ cli: ['code', 'code-insiders', 'codium'],
51
+ bundles: [
52
+ '/Applications/Visual Studio Code.app/Contents/MacOS/Electron',
53
+ path.join(os.homedir(), 'Applications', 'Visual Studio Code.app', 'Contents', 'MacOS', 'Electron'),
54
+ ],
55
+ },
56
+ };
57
+
58
+ function onPath(bin) {
59
+ const exts = process.platform === 'win32' ? ['.cmd', '.exe', ''] : [''];
60
+ for (const dir of (process.env.PATH || '').split(path.delimiter)) {
61
+ for (const ext of exts) {
62
+ const f = path.join(dir, bin + ext);
63
+ try { fs.accessSync(f, fs.constants.X_OK); return f; } catch { /* keep looking */ }
64
+ }
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /** Resolve one editor to a runnable command, or null if it is not installed. */
70
+ function resolveEditor(which) {
71
+ const spec = EDITORS[which];
72
+ if (!spec) return null;
73
+ for (const c of spec.cli) { const f = onPath(c); if (f) return f; }
74
+ for (const b of spec.bundles) { try { fs.accessSync(b, fs.constants.X_OK); return b; } catch { /* next */ } }
75
+ return null;
76
+ }
77
+
78
+ /** Which editor to use: the one asked for, else Cursor if present, else VS Code. */
79
+ export function pickEditor(requested) {
80
+ if (requested && EDITORS[requested]) {
81
+ const found = resolveEditor(requested);
82
+ if (found) return { which: requested, cmd: found };
83
+ }
84
+ for (const which of ['cursor', 'vscode']) { // cursor wins when both exist
85
+ const cmd = resolveEditor(which);
86
+ if (cmd) return { which, cmd };
87
+ }
88
+ return null;
89
+ }
20
90
 
21
91
  /** Merge our server into an existing mcp.json without clobbering the user's. */
22
92
  function addMcpServer(file) {
23
93
  let doc = {};
24
94
  try { doc = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch { doc = {}; }
25
- const key = doc.mcpServers ? 'mcpServers' : (doc.servers ? 'servers' : 'mcpServers');
95
+ const key = doc.servers && !doc.mcpServers ? 'servers' : 'mcpServers';
26
96
  doc[key] = doc[key] || {};
27
- const existed = Boolean(doc[key].openzoo);
28
97
  doc[key].openzoo = { command: 'npx', args: ['-y', 'openzoo@latest', 'mcp'] };
29
98
  fs.mkdirSync(path.dirname(file), { recursive: true });
30
99
  fs.writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
31
- return { file, existed };
100
+ return file;
32
101
  }
33
102
 
34
- export function setupEditor(which = 'cursor') {
103
+ /** Is a proxy already listening? Returns its /v1 base or null. */
104
+ async function proxyUp(base) {
105
+ try {
106
+ const r = await fetch(`${base}/models`, { signal: AbortSignal.timeout(2500) });
107
+ return r.ok;
108
+ } catch { return false; }
109
+ }
110
+
111
+ export async function setupEditor(which, target) {
35
112
  const base = `http://localhost:${config.port}/v1`;
36
- const file = which === 'vscode' ? VSCODE_MCP : CURSOR_MCP;
37
- const { existed } = addMcpServer(file);
113
+ const mcpFile = addMcpServer(MCP_FILES[which] || MCP_FILES.cursor);
114
+
115
+ // 1. PROXY + TUNNEL. Start in-process if nothing is listening, so the user
116
+ // does not need a second terminal. The tunnel URL is what a cloud-run
117
+ // harness must use; we surface it rather than leaving them to find it.
118
+ let publicUrl = null;
119
+ let tunnelKey = null;
120
+ if (!(await proxyUp(base))) {
121
+ console.log(`starting proxy on ${base} (+ public tunnel)...`);
122
+ const { startProxy } = await import('./proxy.js');
123
+ const started = await startProxy({ silent: true, autoTunnel: true });
124
+ publicUrl = started?.publicUrl ?? null;
125
+ tunnelKey = started?.tunnelToken ?? null;
126
+ // give the tunnel a moment to publish its URL
127
+ for (let i = 0; i < 20 && !publicUrl; i++) {
128
+ await new Promise((r) => setTimeout(r, 500));
129
+ publicUrl = started?.publicUrl ?? null;
130
+ tunnelKey = started?.tunnelToken ?? null;
131
+ }
132
+ } else {
133
+ console.log(`proxy already running on ${base}`);
134
+ }
135
+
136
+ // 2. ENV INTO THE EDITOR. Both vendor shapes, so an OpenAI-compatible pane
137
+ // and an Anthropic-shaped one (Claude Code extension) both route here.
138
+ const env = {
139
+ ...process.env,
140
+ OPENAI_BASE_URL: base,
141
+ OPENAI_API_KEY: process.env.OPENAI_API_KEY || 'sk-openzoo',
142
+ ANTHROPIC_BASE_URL: base,
143
+ ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || 'sk-openzoo',
144
+ ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN || 'sk-openzoo',
145
+ };
38
146
 
39
- console.log(`openzoo → ${which}`);
40
- console.log('');
41
- console.log(`1. MCP: ${existed ? 'updated' : 'added'} "openzoo" in ${file}`);
42
- console.log(' tools: zoo_bind, zoo_ask, zoo_models, zoo_wallet, zoo_contexts');
43
- console.log(' (restart the editor to pick it up)');
44
- console.log('');
45
- console.log('2. Model routing — paste these into the editor\'s AI settings.');
46
- console.log(' Cursor keeps provider settings in an encrypted store, so this');
47
- console.log(' part cannot be scripted; it is two fields:');
48
- console.log('');
49
- console.log(` Override OpenAI Base URL : ${base}`);
50
- console.log(' OpenAI API Key : sk-openzoo (any value; x402 pays, not keys)');
51
147
  console.log('');
52
- console.log(' Then add a model the zoo serves (Cursor validates the name):');
53
- console.log(' deepseek/deepseek-v4-pro-0813 cheap, stateless tools, recommended');
54
- console.log(' openai/gpt-5.6-sol-pro ← flagship, ~34x the output cost');
55
- console.log(' Turn OFF the built-in models (Composer/GPT/Cursor-Grok) while the');
56
- console.log(' override is on those only resolve against Cursor\'s own backend.');
148
+ console.log(`mcp: ${mcpFile} (openzoo: zoo_bind, zoo_ask, zoo_models, zoo_wallet, zoo_contexts)`);
149
+ console.log(`local: ${base} api_key sk-openzoo`);
150
+ if (publicUrl) {
151
+ console.log(`tunnel: ${publicUrl}/v1 api_key ${tunnelKey}`);
152
+ console.log(' (use the tunnel for any cloud-run harness it cannot reach localhost)');
153
+ }
57
154
  console.log('');
58
- console.log(`3. Make sure the proxy is running: npx openzoo (${base})`);
155
+
156
+ // 3. LAUNCH with that env. Editor resolved platform-agnostically; Cursor
157
+ // wins when both are installed.
158
+ const cwd = target && !target.startsWith('-') ? target : '.';
159
+ const picked = pickEditor(which);
160
+ if (!picked) {
161
+ console.error('no editor found — install Cursor or VS Code, or put `cursor`/`code` on PATH');
162
+ console.error(`(config is written either way: ${mcpFile})`);
163
+ return;
164
+ }
165
+ console.log(`launching ${picked.which}...`);
166
+ const child = spawn(picked.cmd, [cwd], { stdio: 'ignore', env, detached: true });
167
+ child.on('error', (e) => {
168
+ console.error(`could not launch ${picked.which}: ${e.message}`);
169
+ });
170
+ child.unref();
171
+ // Keep this process alive when we own the proxy — killing it would kill the
172
+ // zoo the editor was just pointed at.
173
+ if (publicUrl || !(await proxyUp(base))) {
174
+ console.log('proxy is running in this terminal — Ctrl-C when done.');
175
+ }
59
176
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Local x402-paying proxy + MCP server for openzoo.fun — point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
5
5
  "license": "MIT",
6
6
  "type": "module",