hanzi-browse 2.2.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 (78) hide show
  1. package/README.md +182 -0
  2. package/dist/agent/loop.d.ts +63 -0
  3. package/dist/agent/loop.js +186 -0
  4. package/dist/agent/system-prompt.d.ts +7 -0
  5. package/dist/agent/system-prompt.js +41 -0
  6. package/dist/agent/tools.d.ts +9 -0
  7. package/dist/agent/tools.js +154 -0
  8. package/dist/cli/detect-credentials.d.ts +31 -0
  9. package/dist/cli/detect-credentials.js +44 -0
  10. package/dist/cli/import-credentials-handler.d.ts +14 -0
  11. package/dist/cli/import-credentials-handler.js +22 -0
  12. package/dist/cli/session-files.d.ts +28 -0
  13. package/dist/cli/session-files.js +118 -0
  14. package/dist/cli/setup.d.ts +10 -0
  15. package/dist/cli/setup.js +915 -0
  16. package/dist/cli.d.ts +16 -0
  17. package/dist/cli.js +506 -0
  18. package/dist/dashboard/assets/index-CEFyesbT.js +46 -0
  19. package/dist/dashboard/assets/index-Dnht2kLU.css +1 -0
  20. package/dist/dashboard/index.html +13 -0
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.js +1116 -0
  23. package/dist/ipc/index.d.ts +8 -0
  24. package/dist/ipc/index.js +8 -0
  25. package/dist/ipc/native-host.d.ts +96 -0
  26. package/dist/ipc/native-host.js +223 -0
  27. package/dist/ipc/websocket-client.d.ts +73 -0
  28. package/dist/ipc/websocket-client.js +199 -0
  29. package/dist/license/manager.d.ts +20 -0
  30. package/dist/license/manager.js +15 -0
  31. package/dist/llm/client.d.ts +72 -0
  32. package/dist/llm/client.js +227 -0
  33. package/dist/llm/credentials.d.ts +61 -0
  34. package/dist/llm/credentials.js +200 -0
  35. package/dist/llm/vertex.d.ts +22 -0
  36. package/dist/llm/vertex.js +335 -0
  37. package/dist/managed/api-http.test.d.ts +7 -0
  38. package/dist/managed/api-http.test.js +623 -0
  39. package/dist/managed/api.d.ts +51 -0
  40. package/dist/managed/api.js +1448 -0
  41. package/dist/managed/api.test.d.ts +10 -0
  42. package/dist/managed/api.test.js +146 -0
  43. package/dist/managed/auth.d.ts +38 -0
  44. package/dist/managed/auth.js +192 -0
  45. package/dist/managed/billing.d.ts +70 -0
  46. package/dist/managed/billing.js +227 -0
  47. package/dist/managed/deploy.d.ts +17 -0
  48. package/dist/managed/deploy.js +385 -0
  49. package/dist/managed/e2e.test.d.ts +15 -0
  50. package/dist/managed/e2e.test.js +151 -0
  51. package/dist/managed/hardening.test.d.ts +14 -0
  52. package/dist/managed/hardening.test.js +346 -0
  53. package/dist/managed/integration.test.d.ts +8 -0
  54. package/dist/managed/integration.test.js +274 -0
  55. package/dist/managed/log.d.ts +18 -0
  56. package/dist/managed/log.js +31 -0
  57. package/dist/managed/server.d.ts +12 -0
  58. package/dist/managed/server.js +69 -0
  59. package/dist/managed/store-pg.d.ts +191 -0
  60. package/dist/managed/store-pg.js +479 -0
  61. package/dist/managed/store.d.ts +188 -0
  62. package/dist/managed/store.js +379 -0
  63. package/dist/relay/auto-start.d.ts +19 -0
  64. package/dist/relay/auto-start.js +71 -0
  65. package/dist/relay/server.d.ts +17 -0
  66. package/dist/relay/server.js +403 -0
  67. package/dist/types/index.d.ts +5 -0
  68. package/dist/types/index.js +4 -0
  69. package/dist/types/session.d.ts +134 -0
  70. package/dist/types/session.js +16 -0
  71. package/package.json +61 -0
  72. package/skills/README.md +48 -0
  73. package/skills/a11y-auditor/SKILL.md +42 -0
  74. package/skills/e2e-tester/SKILL.md +154 -0
  75. package/skills/hanzi-browse/SKILL.md +182 -0
  76. package/skills/linkedin-prospector/SKILL.md +149 -0
  77. package/skills/social-poster/SKILL.md +146 -0
  78. package/skills/x-marketer/SKILL.md +479 -0
@@ -0,0 +1,915 @@
1
+ /**
2
+ * `hanzi-browser setup` — auto-detect AI agents and inject MCP config.
3
+ *
4
+ * Scans the machine for Claude Code, Cursor, Windsurf, and Claude Desktop,
5
+ * then merges the Hanzi MCP server entry into each agent's config file.
6
+ */
7
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, readdirSync } from 'fs';
8
+ import { join, dirname } from 'path';
9
+ import { fileURLToPath } from 'url';
10
+ import { homedir, platform } from 'os';
11
+ import { execSync } from 'child_process';
12
+ import { createInterface } from 'readline';
13
+ import { randomUUID } from 'crypto';
14
+ import { isRelayRunning } from '../relay/auto-start.js';
15
+ import { WebSocketClient } from '../ipc/websocket-client.js';
16
+ import { detectCredentialSources as detectSources, checkCredentialFlowResult, } from './detect-credentials.js';
17
+ // ── Style ──────────────────────────────────────────────────────────────
18
+ const c = {
19
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
20
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
21
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
22
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
23
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
24
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
25
+ };
26
+ const y1 = '\x1b[38;5;178m', y2 = '\x1b[38;5;214m', y3 = '\x1b[38;5;220m', y4 = '\x1b[38;5;221m', y5 = '\x1b[38;5;222m', rs = '\x1b[0m';
27
+ const BANNER = `
28
+ ${y1}██ ██${rs} ${y2} █████ ${rs} ${y3}███ ██${rs} ${y4}████████${rs} ${y5}██${rs}
29
+ ${y1}██ ██${rs} ${y2}██ ██${rs} ${y3}████ ██${rs} ${y4} ██ ${rs} ${y5}██${rs}
30
+ ${y1}███████${rs} ${y2}███████${rs} ${y3}██ ████${rs} ${y4} ██ ${rs} ${y5}██${rs}
31
+ ${y1}██ ██${rs} ${y2}██ ██${rs} ${y3}██ ███${rs} ${y4} ██ ${rs} ${y5}██${rs}
32
+ ${y1}██ ██${rs} ${y2}██ ██${rs} ${y3}██ ██${rs} ${y4}████████${rs} ${y5}██${rs}
33
+ ${c.dim('browser automation for your ai agent')}
34
+ `;
35
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
36
+ function sleep(ms) {
37
+ return new Promise(r => setTimeout(r, ms));
38
+ }
39
+ // Plain log for non-interactive mode (no ANSI, no spinners)
40
+ function log(msg) {
41
+ // Strip ANSI codes for clean output
42
+ const clean = msg.replace(/\x1b\[[0-9;]*m/g, '');
43
+ console.log(clean);
44
+ }
45
+ function spinner(text, isInteractive = true) {
46
+ if (!isInteractive) {
47
+ log(` ... ${text}`);
48
+ return { stop: (final) => log(` ${final}`) };
49
+ }
50
+ let i = 0;
51
+ const id = setInterval(() => {
52
+ process.stdout.write(`\r ${c.cyan(SPINNER_FRAMES[i++ % SPINNER_FRAMES.length])} ${text}`);
53
+ }, 80);
54
+ return {
55
+ stop: (final) => {
56
+ clearInterval(id);
57
+ process.stdout.write(`\r ${final}\x1b[K\n`);
58
+ },
59
+ };
60
+ }
61
+ // ── MCP config payload ─────────────────────────────────────────────────
62
+ const MCP_ENTRY = {
63
+ command: 'npx',
64
+ args: ['-y', 'hanzi-browse'],
65
+ };
66
+ // ── Agent registry ─────────────────────────────────────────────────────
67
+ function getAgentRegistry() {
68
+ const home = homedir();
69
+ const plat = platform();
70
+ const hasCli = (bin) => {
71
+ try {
72
+ execSync(`which ${bin}`, { stdio: 'ignore' });
73
+ return true;
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ };
79
+ return [
80
+ // ── Agents with CLI-based MCP setup ──
81
+ {
82
+ name: 'Claude Code',
83
+ slug: 'claude-code',
84
+ method: 'cli-command',
85
+ cliCommand: 'claude mcp add browser -- npx -y hanzi-browse',
86
+ skillsDir: () => join(home, '.claude', 'skills'),
87
+ detect: () => hasCli('claude'),
88
+ },
89
+ // ── Agents with JSON config merge ──
90
+ {
91
+ name: 'Cursor',
92
+ slug: 'cursor',
93
+ method: 'json-merge',
94
+ configPath: () => join(home, '.cursor', 'mcp.json'),
95
+ skillsDir: () => join(home, '.cursor', 'skills'),
96
+ detect: () => existsSync(join(home, '.cursor')),
97
+ },
98
+ {
99
+ name: 'Windsurf',
100
+ slug: 'windsurf',
101
+ method: 'json-merge',
102
+ configPath: () => join(home, '.codeium', 'windsurf', 'mcp_config.json'),
103
+ skillsDir: () => join(home, '.codeium', 'windsurf', 'skills'),
104
+ detect: () => existsSync(join(home, '.codeium', 'windsurf')),
105
+ },
106
+ {
107
+ name: 'VS Code',
108
+ slug: 'vscode',
109
+ method: 'json-merge',
110
+ configPath: () => join(home, '.vscode', 'mcp.json'),
111
+ skillsDir: () => join(home, '.vscode', 'skills'),
112
+ detect: () => existsSync(join(home, '.vscode')),
113
+ },
114
+ {
115
+ name: 'Codex',
116
+ slug: 'codex',
117
+ method: 'json-merge',
118
+ configPath: () => join(home, '.codex', 'mcp.json'),
119
+ skillsDir: () => join(home, '.agents', 'skills'),
120
+ detect: () => existsSync(join(home, '.codex')) || hasCli('codex'),
121
+ },
122
+ {
123
+ name: 'Claude Desktop',
124
+ slug: 'claude-desktop',
125
+ method: 'json-merge',
126
+ configPath: () => {
127
+ if (plat === 'darwin')
128
+ return join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
129
+ if (plat === 'win32')
130
+ return join(process.env.APPDATA || join(home, 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json');
131
+ return join(home, '.config', 'Claude', 'claude_desktop_config.json');
132
+ },
133
+ detect: () => {
134
+ if (plat === 'darwin')
135
+ return existsSync(join(home, 'Library', 'Application Support', 'Claude'));
136
+ if (plat === 'win32')
137
+ return existsSync(join(process.env.APPDATA || join(home, 'AppData', 'Roaming'), 'Claude'));
138
+ return existsSync(join(home, '.config', 'Claude'));
139
+ },
140
+ },
141
+ {
142
+ name: 'Gemini CLI',
143
+ slug: 'gemini',
144
+ method: 'json-merge',
145
+ configPath: () => join(home, '.gemini', 'settings.json'),
146
+ skillsDir: () => join(home, '.gemini', 'skills'),
147
+ detect: () => existsSync(join(home, '.gemini')) || hasCli('gemini'),
148
+ },
149
+ {
150
+ name: 'Amp',
151
+ slug: 'amp',
152
+ method: 'json-merge',
153
+ configPath: () => join(home, '.amp', 'mcp.json'),
154
+ skillsDir: () => join(home, '.amp', 'skills'),
155
+ detect: () => existsSync(join(home, '.amp')),
156
+ },
157
+ {
158
+ name: 'Cline',
159
+ slug: 'cline',
160
+ method: 'json-merge',
161
+ configPath: () => join(home, '.cline', 'mcp_settings.json'),
162
+ detect: () => existsSync(join(home, '.cline')),
163
+ },
164
+ {
165
+ name: 'Roo Code',
166
+ slug: 'roo-code',
167
+ method: 'json-merge',
168
+ configPath: () => join(home, '.roo-code', 'mcp_settings.json'),
169
+ detect: () => existsSync(join(home, '.roo-code')),
170
+ },
171
+ ];
172
+ }
173
+ // ── JSON merge ─────────────────────────────────────────────────────────
174
+ function stripJsonComments(text) {
175
+ return text
176
+ .replace(/\/\/.*$/gm, '')
177
+ .replace(/\/\*[\s\S]*?\*\//g, '');
178
+ }
179
+ function mergeJsonConfig(configPath) {
180
+ const agentName = configPath;
181
+ try {
182
+ if (!existsSync(configPath)) {
183
+ mkdirSync(join(configPath, '..'), { recursive: true });
184
+ const config = { mcpServers: { "hanzi-browser": MCP_ENTRY } };
185
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
186
+ return { agent: agentName, status: 'configured', detail: `created ${configPath}` };
187
+ }
188
+ const raw = readFileSync(configPath, 'utf-8');
189
+ let config;
190
+ try {
191
+ config = JSON.parse(raw);
192
+ }
193
+ catch {
194
+ try {
195
+ config = JSON.parse(stripJsonComments(raw));
196
+ }
197
+ catch {
198
+ const bakPath = configPath + '.bak';
199
+ copyFileSync(configPath, bakPath);
200
+ config = { mcpServers: { "hanzi-browser": MCP_ENTRY } };
201
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
202
+ return { agent: agentName, status: 'configured', detail: `backed up malformed config to ${bakPath}` };
203
+ }
204
+ }
205
+ if (config.mcpServers?.["hanzi-browser"]) {
206
+ const existing = config.mcpServers["hanzi-browser"];
207
+ if (existing.command === MCP_ENTRY.command && JSON.stringify(existing.args) === JSON.stringify(MCP_ENTRY.args)) {
208
+ return { agent: agentName, status: 'already-configured', detail: configPath };
209
+ }
210
+ }
211
+ if (!config.mcpServers)
212
+ config.mcpServers = {};
213
+ config.mcpServers["hanzi-browser"] = MCP_ENTRY;
214
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
215
+ return { agent: agentName, status: 'configured', detail: `merged into ${configPath}` };
216
+ }
217
+ catch (err) {
218
+ if (err.code === 'EACCES' || err.code === 'EPERM') {
219
+ return { agent: agentName, status: 'error', detail: `permission denied: ${configPath}` };
220
+ }
221
+ return { agent: agentName, status: 'error', detail: err.message };
222
+ }
223
+ }
224
+ function runClaudeCodeSetup() {
225
+ try {
226
+ const output = execSync('claude mcp add browser -- npx -y hanzi-browse', {
227
+ encoding: 'utf-8',
228
+ stdio: ['pipe', 'pipe', 'pipe'],
229
+ timeout: 10000,
230
+ });
231
+ if (output.toLowerCase().includes('already') || output.toLowerCase().includes('exists')) {
232
+ return { agent: 'Claude Code', status: 'already-configured', detail: 'claude mcp add' };
233
+ }
234
+ return { agent: 'Claude Code', status: 'configured', detail: 'ran: claude mcp add browser' };
235
+ }
236
+ catch (err) {
237
+ const stderr = err.stderr?.toString() || '';
238
+ if (stderr.toLowerCase().includes('already') || stderr.toLowerCase().includes('exists')) {
239
+ return { agent: 'Claude Code', status: 'already-configured', detail: 'claude mcp add' };
240
+ }
241
+ return { agent: 'Claude Code', status: 'error', detail: err.message };
242
+ }
243
+ }
244
+ // ── Browser detection ──────────────────────────────────────────────────
245
+ const EXTENSION_URL = 'https://chromewebstore.google.com/detail/hanzi-browse/iklpkemlmbhemkiojndpbhoakgikpmcd';
246
+ const BROWSERS = [
247
+ { name: 'Google Chrome', slug: 'chrome', macApp: 'Google Chrome', linuxBin: 'google-chrome' },
248
+ { name: 'Brave', slug: 'brave', macApp: 'Brave Browser', linuxBin: 'brave-browser' },
249
+ { name: 'Microsoft Edge', slug: 'edge', macApp: 'Microsoft Edge', linuxBin: 'microsoft-edge' },
250
+ { name: 'Arc', slug: 'arc', macApp: 'Arc', linuxBin: 'arc' },
251
+ { name: 'Chromium', slug: 'chromium', macApp: 'Chromium', linuxBin: 'chromium-browser' },
252
+ ];
253
+ function detectBrowsers() {
254
+ const plat = platform();
255
+ return BROWSERS.filter(b => {
256
+ if (plat === 'darwin') {
257
+ return existsSync(`/Applications/${b.macApp}.app`);
258
+ }
259
+ try {
260
+ execSync(`which ${b.linuxBin}`, { stdio: 'ignore' });
261
+ return true;
262
+ }
263
+ catch {
264
+ return false;
265
+ }
266
+ });
267
+ }
268
+ function openInBrowser(browser, url) {
269
+ const plat = platform();
270
+ try {
271
+ if (plat === 'darwin') {
272
+ execSync(`open -a "${browser.macApp}" "${url}"`, { stdio: 'ignore' });
273
+ }
274
+ else {
275
+ execSync(`${browser.linuxBin} "${url}" &`, { stdio: 'ignore' });
276
+ }
277
+ }
278
+ catch {
279
+ // Fallback: system default
280
+ execSync(`open "${url}" 2>/dev/null || xdg-open "${url}" 2>/dev/null`, { stdio: 'ignore' });
281
+ }
282
+ }
283
+ async function ensureExtension(isInteractive) {
284
+ // Already connected?
285
+ if (await isRelayRunning())
286
+ return true;
287
+ // Detect browsers
288
+ const browsers = detectBrowsers();
289
+ if (browsers.length === 0) {
290
+ const msg = `No Chromium browser found. Install the extension manually: ${EXTENSION_URL}`;
291
+ isInteractive
292
+ ? console.log(` ${c.yellow('●')} ${msg}\n`)
293
+ : log(` ● ${msg}`);
294
+ return false;
295
+ }
296
+ // Pick browser — auto-select first in non-interactive mode
297
+ let browser;
298
+ if (!isInteractive || browsers.length === 1) {
299
+ browser = browsers[0];
300
+ isInteractive
301
+ ? console.log(` ${c.green('✓')} Found ${c.bold(browser.name)}`)
302
+ : log(` ✓ Found ${browser.name}`);
303
+ }
304
+ else {
305
+ console.log(` ${c.green('✓')} Found ${c.bold(String(browsers.length))} browsers\n`);
306
+ browsers.forEach((b, i) => {
307
+ console.log(` ${c.bold(String(i + 1))} ${b.name}`);
308
+ });
309
+ console.log('');
310
+ const rl = (await import('readline')).createInterface({ input: process.stdin, output: process.stdout });
311
+ const answer = await new Promise(resolve => {
312
+ rl.question(` ${c.cyan('?')} Which browser has your logins? (1-${browsers.length}): `, resolve);
313
+ });
314
+ rl.close();
315
+ const idx = parseInt(answer) - 1;
316
+ browser = browsers[idx] || browsers[0];
317
+ }
318
+ // Open Chrome Web Store
319
+ const openMsg = `Opening Chrome Web Store in ${browser.name}...`;
320
+ isInteractive ? console.log(`\n ${openMsg}\n`) : log(` ${openMsg}`);
321
+ openInBrowser(browser, EXTENSION_URL);
322
+ // Poll for extension
323
+ const sp = spinner('Waiting for extension to connect...', isInteractive);
324
+ for (let i = 0; i < 90; i++) { // 3 minutes max
325
+ await sleep(2000);
326
+ if (await isRelayRunning()) {
327
+ sp.stop(`${c.green('✓')} Extension ${c.green('connected')}`);
328
+ return true;
329
+ }
330
+ }
331
+ sp.stop(`${c.yellow('●')} Timed out waiting for extension`);
332
+ isInteractive
333
+ ? console.log(` ${c.dim('Install the extension, then run setup again.')}`)
334
+ : log(' Install the extension, then run setup again.');
335
+ return false;
336
+ }
337
+ // ── Readline ───────────────────────────────────────────────────────────
338
+ let rl = null;
339
+ function ask(prompt) {
340
+ if (!rl)
341
+ rl = createInterface({ input: process.stdin, output: process.stdout });
342
+ return new Promise(resolve => {
343
+ rl.question(` ${c.cyan('?')} ${prompt}`, answer => resolve(answer.trim()));
344
+ });
345
+ }
346
+ // ── Relay ──────────────────────────────────────────────────────────────
347
+ let relay = null;
348
+ async function connectRelay() {
349
+ if (!(await isRelayRunning()))
350
+ return false;
351
+ try {
352
+ const origError = console.error;
353
+ console.error = () => { };
354
+ relay = new WebSocketClient({
355
+ role: 'cli',
356
+ autoStartRelay: false,
357
+ onDisconnect: () => { relay = null; },
358
+ });
359
+ relay.onMessage(() => { });
360
+ await relay.connect();
361
+ console.error = origError;
362
+ return true;
363
+ }
364
+ catch {
365
+ console.error = console.__proto__.error;
366
+ relay = null;
367
+ return false;
368
+ }
369
+ }
370
+ async function sendToExtension(type, payload) {
371
+ if (!relay?.isConnected())
372
+ return false;
373
+ try {
374
+ await relay.send({ type: `mcp_${type}`, requestId: randomUUID().slice(0, 8), ...payload });
375
+ await sleep(300);
376
+ return true;
377
+ }
378
+ catch {
379
+ return false;
380
+ }
381
+ }
382
+ // ── Credential setup ──────────────────────────────────────────────────
383
+ function keychainHas(service) {
384
+ if (platform() !== 'darwin')
385
+ return false;
386
+ try {
387
+ execSync(`security find-generic-password -s "${service}" -w 2>/dev/null`, { stdio: 'pipe' });
388
+ return true;
389
+ }
390
+ catch {
391
+ return false;
392
+ }
393
+ }
394
+ function detectCredentialSources() {
395
+ return detectSources({
396
+ platform: platform(),
397
+ homedir: homedir(),
398
+ fileExists: existsSync,
399
+ keychainHas,
400
+ });
401
+ }
402
+ async function promptAccessMode(isInteractive) {
403
+ if (!isInteractive) {
404
+ // Non-interactive: default to BYOM, auto-detect credentials
405
+ return 'byom';
406
+ }
407
+ console.log('');
408
+ console.log(` ${c.dim('step 3')} ${c.bold('Access mode')}`);
409
+ console.log(` ${c.dim(' How should Hanzi access an AI model for browser tasks?')}\n`);
410
+ console.log(` ${c.bold('1')} ${c.green('Use my own model')} ${c.dim('(BYOM)')}`);
411
+ console.log(` ${c.dim('Bring your own Claude, GPT, Gemini, or custom API key.')}`);
412
+ console.log(` ${c.dim('Everything runs locally — no data leaves your machine.')}`);
413
+ console.log('');
414
+ console.log(` ${c.bold('2')} ${c.cyan('Hanzi managed')} ${c.dim('($0.05/task, 20 free/month)')}`);
415
+ console.log(` ${c.dim('We handle the AI — no API key needed.')}`);
416
+ console.log(` ${c.dim('Sign in with Google, get 20 free tasks instantly.')}`);
417
+ console.log('');
418
+ console.log(` ${c.dim('s')} ${c.dim('Skip — set up later')}`);
419
+ console.log('');
420
+ const choice = await ask('Choose (1/2/s): ');
421
+ if (choice === '2')
422
+ return 'managed';
423
+ if (choice.toLowerCase() === 's')
424
+ return 'skip';
425
+ return 'byom'; // default for '1' or anything else
426
+ }
427
+ // ── Managed access ──────────────────────────────────────────────────
428
+ const MANAGED_DASHBOARD_URL = 'https://api.hanzilla.co/dashboard';
429
+ const MANAGED_SIGNIN_URL = 'https://api.hanzilla.co/api/auth/sign-in/social';
430
+ let managedApiKey = null;
431
+ async function handleManagedAccess() {
432
+ console.log('');
433
+ console.log(` ${c.cyan('●')} ${c.bold('Hanzi managed')}`);
434
+ console.log(` ${c.dim(' 20 free tasks/month. Only completed tasks count.')}\n`);
435
+ console.log(` Opening your browser to sign in...`);
436
+ openUrl(MANAGED_DASHBOARD_URL);
437
+ console.log(` ${c.cyan(MANAGED_DASHBOARD_URL)}`);
438
+ console.log('');
439
+ console.log(` ${c.bold('1.')} Sign in with Google`);
440
+ console.log(` ${c.bold('2.')} Create an API key in the dashboard`);
441
+ console.log(` ${c.bold('3.')} Copy and paste it below\n`);
442
+ const key = await ask(' Paste your API key (hic_live_...): ');
443
+ const trimmed = key.trim();
444
+ if (!trimmed || !trimmed.startsWith('hic_live_')) {
445
+ console.log(`\n ${c.yellow('●')} Skipped. You can set up managed later by running setup again.`);
446
+ return;
447
+ }
448
+ // Validate the key
449
+ try {
450
+ const res = await fetch(`https://api.hanzilla.co/v1/billing/credits`, {
451
+ headers: { Authorization: `Bearer ${trimmed}` },
452
+ });
453
+ const data = await res.json();
454
+ if (res.ok && data.free_remaining !== undefined) {
455
+ managedApiKey = trimmed;
456
+ console.log(`\n ${c.green('✓')} Key validated! ${data.free_remaining} free tasks + ${data.credit_balance || 0} credits available.`);
457
+ }
458
+ else {
459
+ console.log(`\n ${c.red('✗')} Invalid key: ${data.error || 'authentication failed'}`);
460
+ console.log(` Check the key in your dashboard at ${c.cyan(MANAGED_DASHBOARD_URL)}`);
461
+ }
462
+ }
463
+ catch (err) {
464
+ console.log(`\n ${c.yellow('●')} Could not validate key (network error). Saving anyway.`);
465
+ managedApiKey = trimmed;
466
+ }
467
+ }
468
+ function openUrl(url) {
469
+ try {
470
+ const cmd = platform() === 'darwin' ? `open "${url}"`
471
+ : platform() === 'win32' ? `start "${url}"`
472
+ : `xdg-open "${url}"`;
473
+ execSync(cmd, { stdio: 'ignore' });
474
+ }
475
+ catch { }
476
+ }
477
+ /**
478
+ * Re-inject MCP configs with HANZI_API_KEY env var for managed mode.
479
+ * Updates JSON configs directly. For Claude Code, re-runs the CLI command with env.
480
+ */
481
+ async function injectManagedKey(apiKey, agents) {
482
+ const managedEntry = {
483
+ ...MCP_ENTRY,
484
+ env: { HANZI_API_KEY: apiKey },
485
+ };
486
+ for (const agent of agents) {
487
+ try {
488
+ if (agent.method === 'json-merge' && agent.configPath) {
489
+ const configPath = agent.configPath();
490
+ if (existsSync(configPath)) {
491
+ const raw = readFileSync(configPath, 'utf-8');
492
+ const config = JSON.parse(raw);
493
+ if (config.mcpServers?.["hanzi-browser"]) {
494
+ config.mcpServers["hanzi-browser"] = managedEntry;
495
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
496
+ console.log(` ${c.green('✓')} Updated ${agent.name} with managed API key`);
497
+ }
498
+ }
499
+ }
500
+ else if (agent.method === 'cli-command' && agent.slug === 'claude-code') {
501
+ // Claude Code: remove and re-add with env
502
+ try {
503
+ execSync('claude mcp remove browser', { stdio: 'ignore' });
504
+ }
505
+ catch { }
506
+ execSync(`claude mcp add browser -e HANZI_API_KEY=${apiKey} -- npx -y hanzi-browse`, {
507
+ stdio: 'ignore',
508
+ });
509
+ console.log(` ${c.green('✓')} Updated Claude Code with managed API key`);
510
+ }
511
+ }
512
+ catch (err) {
513
+ console.log(` ${c.yellow('●')} Could not update ${agent.name}: ${err.message}`);
514
+ }
515
+ }
516
+ }
517
+ // ── BYOM credential setup ────────────────────────────────────────────
518
+ async function promptByomCredentials() {
519
+ console.log('');
520
+ console.log(` ${c.green('●')} ${c.bold('Bring your own model')}`);
521
+ console.log(` ${c.dim(' Connect a model source so the extension can run browser tasks.')}\n`);
522
+ // Connect relay for syncing
523
+ await connectRelay();
524
+ // Auto-detect
525
+ const sources = detectCredentialSources();
526
+ let anyImported = false;
527
+ let manualEntryChosen = false;
528
+ if (sources.length > 0) {
529
+ console.log('');
530
+ for (const source of sources) {
531
+ console.log(` ${c.green('✓')} Found ${source.name} credentials ${c.dim(source.path)}`);
532
+ }
533
+ for (const source of sources) {
534
+ console.log('');
535
+ const answer = await ask(`Import ${source.name}? (Y/n): `);
536
+ if (answer.toLowerCase() !== 'n') {
537
+ const sp = spinner(`Importing ${source.name}...`);
538
+ const sent = await sendToExtension('import_credentials', { source: source.slug });
539
+ sp.stop(sent
540
+ ? `${c.green('✓')} ${source.name} imported`
541
+ : `${c.yellow('●')} Could not sync — import from Chrome extension instead`);
542
+ if (sent)
543
+ anyImported = true;
544
+ }
545
+ }
546
+ }
547
+ // Manual options
548
+ let addMore = sources.length === 0;
549
+ if (sources.length === 0) {
550
+ console.log(` ${c.dim('No existing credentials found. Add one now:')}`);
551
+ }
552
+ else {
553
+ console.log('');
554
+ const more = await ask('Add an API key or custom endpoint too? (y/N): ');
555
+ addMore = more.toLowerCase() === 'y';
556
+ }
557
+ while (addMore) {
558
+ console.log('');
559
+ console.log(` ${c.bold('1')} API key ${c.dim('(Anthropic, OpenAI, Google, OpenRouter)')}`);
560
+ console.log(` ${c.bold('2')} Custom endpoint ${c.dim('(Ollama, LM Studio, etc.)')}`);
561
+ console.log(` ${c.dim('d')} ${c.dim('Done')}`);
562
+ console.log('');
563
+ const choice = await ask('(1/2/d): ');
564
+ if (choice === '1') {
565
+ manualEntryChosen = true;
566
+ console.log('');
567
+ console.log(` ${c.bold('a')} Anthropic ${c.bold('o')} OpenAI ${c.bold('g')} Google ${c.bold('r')} OpenRouter`);
568
+ console.log('');
569
+ const p = await ask('Provider (a/o/g/r): ');
570
+ const map = { a: 'anthropic', o: 'openai', g: 'google', r: 'openrouter' };
571
+ const providerId = map[p.toLowerCase()];
572
+ if (providerId) {
573
+ const key = await ask(`${providerId} API key: `);
574
+ if (key) {
575
+ const sp = spinner(`Saving ${providerId} key...`);
576
+ const sent = await sendToExtension('save_config', { payload: { providerKeys: { [providerId]: key } } });
577
+ sp.stop(sent
578
+ ? `${c.green('✓')} ${providerId} key saved`
579
+ : `${c.yellow('●')} Could not sync — add from Chrome extension instead`);
580
+ }
581
+ }
582
+ }
583
+ else if (choice === '2') {
584
+ manualEntryChosen = true;
585
+ console.log('');
586
+ const name = await ask('Display name (e.g. "Ollama Llama 3"): ');
587
+ if (name) {
588
+ const baseUrl = await ask('Base URL (e.g. http://localhost:11434/v1): ');
589
+ const modelId = await ask('Model ID (e.g. llama3): ');
590
+ const apiKey = await ask('API key (optional, enter to skip): ');
591
+ if (baseUrl && modelId) {
592
+ const sp = spinner(`Saving ${name}...`);
593
+ const sent = await sendToExtension('save_config', {
594
+ payload: { customModels: [{ name, baseUrl, modelId, apiKey: apiKey || '' }] },
595
+ });
596
+ sp.stop(sent
597
+ ? `${c.green('✓')} ${name} added`
598
+ : `${c.yellow('●')} Could not sync — add from Chrome extension instead`);
599
+ }
600
+ }
601
+ }
602
+ else {
603
+ break;
604
+ }
605
+ }
606
+ // Warn if the user went through setup but configured nothing
607
+ const flowResult = checkCredentialFlowResult({
608
+ sourcesDetected: sources.length,
609
+ anyImported,
610
+ manualEntryChosen,
611
+ });
612
+ if (flowResult) {
613
+ console.log('');
614
+ console.log(` ${c.yellow('●')} ${flowResult}`);
615
+ }
616
+ disconnectRelay();
617
+ }
618
+ function disconnectRelay() {
619
+ if (relay) {
620
+ const origError = console.error;
621
+ console.error = () => { };
622
+ relay.disconnect();
623
+ relay = null;
624
+ setTimeout(() => { console.error = origError; }, 500);
625
+ }
626
+ }
627
+ // ── Skill installation ──────────────────────────────────────────────────
628
+ function getSkillsSource() {
629
+ // Skills are bundled in the npm package at ../skills/ relative to dist/cli/
630
+ const fromDist = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'skills');
631
+ if (existsSync(fromDist))
632
+ return fromDist;
633
+ // Fallback: running from source at src/cli/
634
+ const fromSrc = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'skills');
635
+ return fromSrc;
636
+ }
637
+ const SKILL_NAMES = ['hanzi-browse', 'e2e-tester', 'social-poster', 'linkedin-prospector', 'a11y-auditor', 'x-marketer'];
638
+ async function installSkills(agents, isInteractive) {
639
+ const skillsSource = getSkillsSource();
640
+ if (!existsSync(skillsSource))
641
+ return; // No skills bundled
642
+ const agentsWithSkills = agents.filter(a => a.skillsDir);
643
+ if (agentsWithSkills.length === 0)
644
+ return;
645
+ const out = isInteractive ? console.log : log;
646
+ if (isInteractive) {
647
+ console.log('');
648
+ console.log(` ${c.dim(' Installing browser automation skills...')}`);
649
+ }
650
+ else {
651
+ log('\n Installing skills...');
652
+ }
653
+ let installed = 0;
654
+ for (const agent of agentsWithSkills) {
655
+ const targetDir = agent.skillsDir();
656
+ try {
657
+ for (const skillName of SKILL_NAMES) {
658
+ const src = join(skillsSource, skillName);
659
+ if (!existsSync(src))
660
+ continue;
661
+ const dest = join(targetDir, skillName);
662
+ mkdirSync(dest, { recursive: true });
663
+ // Copy SKILL.md and any supporting files
664
+ const files = readdirSync(src);
665
+ for (const file of files) {
666
+ copyFileSync(join(src, file), join(dest, file));
667
+ }
668
+ }
669
+ installed++;
670
+ if (isInteractive) {
671
+ console.log(` ${c.green('✓')} ${agent.name.padEnd(16)} ${c.dim(targetDir)}`);
672
+ }
673
+ else {
674
+ log(` ✓ ${agent.name} (${targetDir})`);
675
+ }
676
+ }
677
+ catch (err) {
678
+ if (isInteractive) {
679
+ console.log(` ${c.yellow('●')} ${agent.name.padEnd(16)} ${c.dim(err.message)}`);
680
+ }
681
+ else {
682
+ log(` ● ${agent.name} — ${err.message}`);
683
+ }
684
+ }
685
+ }
686
+ if (installed > 0) {
687
+ const msg = `${installed} agent${installed === 1 ? '' : 's'} got ${SKILL_NAMES.length} browser skills`;
688
+ if (isInteractive) {
689
+ console.log(`\n ${c.green('✓')} ${msg}`);
690
+ }
691
+ else {
692
+ log(` ✓ ${msg}`);
693
+ }
694
+ }
695
+ }
696
+ // ── Main ───────────────────────────────────────────────────────────────
697
+ export async function runSetup(options = {}) {
698
+ const registry = getAgentRegistry();
699
+ const only = options.only;
700
+ const interactive = options.yes ? false : (process.stdin.isTTY ?? false);
701
+ // ── Banner ──
702
+ if (interactive) {
703
+ console.log(BANNER);
704
+ }
705
+ else {
706
+ log('\nHanzi Setup (non-interactive)\n');
707
+ }
708
+ // ── Step 0: Chrome extension ──
709
+ if (interactive) {
710
+ console.log(` ${c.dim('step 1')} ${c.bold('Chrome extension')}`);
711
+ console.log(` ${c.dim(' Hanzi needs a Chrome extension to control your browser.')}\n`);
712
+ }
713
+ else {
714
+ log(' Step 1: Chrome extension');
715
+ }
716
+ const sp0 = spinner('Looking for the extension...', interactive);
717
+ if (interactive)
718
+ await sleep(400);
719
+ const relayUp = await isRelayRunning();
720
+ if (relayUp) {
721
+ sp0.stop(`${c.green('✓')} Chrome extension is running`);
722
+ }
723
+ else {
724
+ sp0.stop(`${c.dim('○')} Chrome extension not found`);
725
+ if (interactive) {
726
+ console.log('');
727
+ await ensureExtension(interactive);
728
+ }
729
+ else {
730
+ log(` Install from: ${EXTENSION_URL}`);
731
+ }
732
+ }
733
+ // ── Step 1: Detect agents ──
734
+ if (interactive) {
735
+ console.log('');
736
+ console.log(` ${c.dim('step 2')} ${c.bold('MCP server')}`);
737
+ console.log(` ${c.dim(' Adding Hanzi as an MCP tool to your coding agents.')}\n`);
738
+ }
739
+ else {
740
+ log('\n Step 2: MCP server');
741
+ }
742
+ const sp1 = spinner('Scanning for agents on this machine...', interactive);
743
+ if (interactive)
744
+ await sleep(600);
745
+ const detected = [];
746
+ for (const agent of registry) {
747
+ if (only && agent.slug !== only)
748
+ continue;
749
+ if (agent.detect())
750
+ detected.push(agent);
751
+ }
752
+ sp1.stop(interactive
753
+ ? `${c.green('✓')} Found ${c.bold(String(detected.length))} agent${detected.length === 1 ? '' : 's'} on this machine`
754
+ : ` ✓ Found ${detected.length} agent${detected.length === 1 ? '' : 's'} on this machine`);
755
+ const out = interactive ? console.log : log;
756
+ out('');
757
+ for (const agent of registry) {
758
+ if (only && agent.slug !== only)
759
+ continue;
760
+ const found = detected.includes(agent);
761
+ const path = agent.configPath ? agent.configPath() : '';
762
+ if (interactive) {
763
+ if (found) {
764
+ console.log(` ${c.green('✓')} ${agent.name.padEnd(16)} ${c.dim(path)}`);
765
+ }
766
+ else {
767
+ console.log(` ${c.dim('○')} ${c.dim(agent.name)}`);
768
+ }
769
+ }
770
+ else {
771
+ out(` ${found ? '✓' : '○'} ${agent.name}${path ? ` (${path})` : ''}`);
772
+ }
773
+ }
774
+ out('');
775
+ if (detected.length === 0) {
776
+ if (interactive) {
777
+ console.log(` ${c.yellow('●')} No agents found. Add this to your agent's MCP config manually:\n`);
778
+ console.log(` ${c.cyan(JSON.stringify({ mcpServers: { "hanzi-browser": MCP_ENTRY } }))}\n`);
779
+ }
780
+ else {
781
+ log(` ● No agents found. Add manually: ${JSON.stringify({ mcpServers: { "hanzi-browser": MCP_ENTRY } })}`);
782
+ }
783
+ return;
784
+ }
785
+ // ── Step 2: Configure agents ──
786
+ const sp2 = spinner('Adding Hanzi MCP server to each agent...', interactive);
787
+ if (interactive)
788
+ await sleep(400);
789
+ const results = [];
790
+ for (const agent of detected) {
791
+ let result;
792
+ if (agent.method === 'cli-command') {
793
+ result = runClaudeCodeSetup();
794
+ }
795
+ else {
796
+ result = mergeJsonConfig(agent.configPath());
797
+ }
798
+ results.push({ ...result, agent: agent.name });
799
+ await sleep(150);
800
+ }
801
+ const configured = results.filter(r => r.status === 'configured').length;
802
+ const alreadyDone = results.filter(r => r.status === 'already-configured').length;
803
+ if (interactive) {
804
+ sp2.stop(`${c.green('✓')} ${configured > 0 ? `Added to ${c.bold(String(configured))} agent${configured === 1 ? '' : 's'}` : 'All agents already have Hanzi'}`);
805
+ console.log('');
806
+ for (const result of results) {
807
+ if (result.status === 'configured') {
808
+ console.log(` ${c.green('✓')} ${result.agent.padEnd(16)} ${c.green('added')}`);
809
+ }
810
+ else if (result.status === 'already-configured') {
811
+ console.log(` ${c.dim('●')} ${result.agent.padEnd(16)} ${c.dim('already has Hanzi')}`);
812
+ }
813
+ else {
814
+ console.log(` ${c.red('✗')} ${result.agent.padEnd(16)} ${c.red(result.detail)}`);
815
+ }
816
+ }
817
+ }
818
+ else {
819
+ sp2.stop(` ✓ ${configured > 0 ? `Added to ${configured} agent${configured === 1 ? '' : 's'}` : 'All agents already have Hanzi'}`);
820
+ log('');
821
+ for (const result of results) {
822
+ const status = result.status === 'configured' ? 'added'
823
+ : result.status === 'already-configured' ? 'already has Hanzi'
824
+ : `error: ${result.detail}`;
825
+ log(` ${result.status === 'error' ? '✗' : result.status === 'configured' ? '✓' : '●'} ${result.agent} — ${status}`);
826
+ }
827
+ }
828
+ // ── Step 2b: Install skills ──
829
+ await installSkills(detected, interactive);
830
+ // ── Step 3: Access mode ──
831
+ let accessMode = 'byom';
832
+ if (interactive) {
833
+ accessMode = await promptAccessMode(interactive);
834
+ if (accessMode === 'byom') {
835
+ await promptByomCredentials();
836
+ }
837
+ else if (accessMode === 'managed') {
838
+ await handleManagedAccess();
839
+ // Re-configure agents with HANZI_API_KEY env var
840
+ if (managedApiKey) {
841
+ await injectManagedKey(managedApiKey, detected);
842
+ }
843
+ }
844
+ else {
845
+ console.log(`\n ${c.dim('○')} ${c.dim('Skipped — set up credentials later in the Chrome extension.')}`);
846
+ }
847
+ }
848
+ else {
849
+ // Non-interactive: auto-detect and report credentials
850
+ const sources = detectCredentialSources();
851
+ if (sources.length > 0) {
852
+ log('\n Step 3: Credentials (auto-detected)');
853
+ for (const source of sources) {
854
+ log(` ✓ Found ${source.name} credentials (${source.path})`);
855
+ }
856
+ }
857
+ else {
858
+ log('\n Step 3: No credentials auto-detected.');
859
+ log(' Add credentials in the Chrome extension settings or re-run setup interactively.');
860
+ }
861
+ }
862
+ // ── Summary ──
863
+ const errors = results.filter(r => r.status === 'error').length;
864
+ const hasCreds = detectCredentialSources().length > 0;
865
+ if (interactive) {
866
+ console.log('');
867
+ console.log(` ${c.bold('◆ Setup complete!')}`);
868
+ console.log('');
869
+ if (configured > 0) {
870
+ console.log(` ${c.green('▸')} Restart your agents to pick up the new MCP config.`);
871
+ }
872
+ if (accessMode === 'managed' && managedApiKey) {
873
+ console.log(` ${c.cyan('▸')} Managed mode configured — 20 free tasks/month.`);
874
+ }
875
+ else if (hasCreds) {
876
+ console.log(` ${c.green('▸')} Credentials detected — Hanzi is ready to use.`);
877
+ }
878
+ else {
879
+ console.log(` ${c.yellow('▸')} No credentials configured yet. Add one in the Chrome extension settings.`);
880
+ }
881
+ if (errors > 0) {
882
+ console.log(` ${c.red('▸')} ${errors} agent${errors === 1 ? '' : 's'} failed — check the errors above.`);
883
+ }
884
+ console.log('');
885
+ if (accessMode === 'managed' && managedApiKey) {
886
+ console.log(` ${c.bold('Try it:')} ask your agent to do something in the browser.`);
887
+ console.log(` ${c.dim(' Example: "Go to Hacker News and tell me the top 3 stories"')}`);
888
+ }
889
+ else if (accessMode === 'managed') {
890
+ console.log(` ${c.bold('Next:')} sign in at ${c.cyan(MANAGED_DASHBOARD_URL)}, create an API key, and re-run setup.`);
891
+ }
892
+ else if (hasCreds) {
893
+ console.log(` ${c.bold('Try it:')} ask your agent to do something in the browser.`);
894
+ console.log(` ${c.dim(' Example: "Go to Hacker News and tell me the top 3 stories"')}`);
895
+ }
896
+ console.log('');
897
+ }
898
+ else {
899
+ log('\n Setup complete!');
900
+ if (configured > 0)
901
+ log(` Restart your agents to pick up the new MCP config.`);
902
+ if (hasCreds) {
903
+ log(' Credentials detected — Hanzi is ready to use.');
904
+ log('\n Try it: ask your agent "Go to Hacker News and tell me the top 3 stories"');
905
+ }
906
+ else {
907
+ log(' No credentials configured yet. Add one in the Chrome extension settings.');
908
+ }
909
+ if (errors > 0)
910
+ log(` ${errors} agent(s) failed — check errors above.`);
911
+ log('');
912
+ }
913
+ rl?.close();
914
+ setTimeout(() => process.exit(0), 200);
915
+ }