hanzi-browse 2.3.0 → 2.3.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.
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Domain-specific knowledge for the server-side agent loop.
3
+ * Matches the extension's domain-skills.js but only includes domains
4
+ * relevant to managed/API tasks.
5
+ */
6
+ interface DomainEntry {
7
+ domain: string;
8
+ skill: string;
9
+ }
10
+ /**
11
+ * Look up domain knowledge for a URL.
12
+ * Returns the first matching entry, or null.
13
+ */
14
+ export declare function getDomainSkill(url: string): DomainEntry | null;
15
+ export {};
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Domain-specific knowledge for the server-side agent loop.
3
+ * Matches the extension's domain-skills.js but only includes domains
4
+ * relevant to managed/API tasks.
5
+ */
6
+ const DOMAIN_KNOWLEDGE = [
7
+ {
8
+ domain: "x.com",
9
+ skill: `X/Twitter — verified patterns (updated 2026-03-30)
10
+
11
+ ## Reading pages (CRITICAL)
12
+ - X loads content asynchronously — page looks empty for 3-5 seconds after navigation.
13
+ - read_page often returns ONLY "To view keyboard shortcuts" — tweets haven't loaded yet.
14
+ - DO NOT re-navigate to the same URL. That resets loading and makes it worse.
15
+ - Instead: wait 5 seconds, then use get_page_text — it reads visible text and is more reliable.
16
+ - If get_page_text returns nothing, scroll down once and try again.
17
+
18
+ ## Search
19
+ - URL: x.com/search?q={encoded_query}&src=typed_query&f=live
20
+ - After navigating, wait 5 seconds, then get_page_text (NOT read_page).
21
+ - Scroll down once to load more tweets, then get_page_text again.
22
+ - Tweet URLs in page text follow pattern: /status/{id}
23
+
24
+ ## Text input (CRITICAL — Draft.js)
25
+ - form_input DOES NOT WORK — Draft.js ignores programmatic input.
26
+ - computer type action GARBLES TEXT.
27
+ - ONLY RELIABLE METHOD — use javascript_tool:
28
+ document.querySelector('[data-testid="tweetTextarea_0"]').focus();
29
+ document.execCommand('insertText', false, 'your reply text here');
30
+ - Always verify text appeared by reading after insertion.
31
+
32
+ ## Replying to a tweet
33
+ 1. Navigate to tweet URL (x.com/{handle}/status/{id})
34
+ 2. Wait 3 seconds, read the page
35
+ 3. Click the reply/comment icon (speech bubble) in the action bar
36
+ 4. Use javascript_tool to insert text (see above)
37
+ 5. Verify text appeared, then click blue "Reply" button
38
+ 6. Wait 2 seconds to confirm reply posted
39
+
40
+ ## Known traps
41
+ - DO NOT scroll looking for "Post your reply" — reply box appears after clicking comment icon
42
+ - x.com/compose/post may open — that's fine, type and click Reply there
43
+ - "Leave site?" dialog — ALWAYS click Cancel, finish posting first
44
+ - Reply button is disabled until text is entered — verify first
45
+ - Space replies 15+ seconds apart (rate limiting)
46
+ - NEVER navigate to the same URL you're already on`,
47
+ },
48
+ ];
49
+ /**
50
+ * Look up domain knowledge for a URL.
51
+ * Returns the first matching entry, or null.
52
+ */
53
+ export function getDomainSkill(url) {
54
+ try {
55
+ const hostname = new URL(url).hostname.toLowerCase();
56
+ return DOMAIN_KNOWLEDGE.find((d) => hostname === d.domain || hostname.endsWith("." + d.domain)) || null;
57
+ }
58
+ catch {
59
+ // URL might not be a full URL — try matching as a bare domain
60
+ const lower = url.toLowerCase();
61
+ return DOMAIN_KNOWLEDGE.find((d) => lower.includes(d.domain)) || null;
62
+ }
63
+ }
@@ -48,6 +48,16 @@ export interface StepUpdate {
48
48
  toolInput?: Record<string, any>;
49
49
  text?: string;
50
50
  }
51
+ export interface TurnLog {
52
+ step: number;
53
+ tools: Array<{
54
+ name: string;
55
+ input: Record<string, any>;
56
+ result: string;
57
+ durationMs: number;
58
+ }>;
59
+ ai_response: string | null;
60
+ }
51
61
  export interface AgentLoopResult {
52
62
  status: "complete" | "error" | "max_steps";
53
63
  answer: string;
@@ -59,5 +69,7 @@ export interface AgentLoopResult {
59
69
  };
60
70
  /** The model used for the last LLM call (for billing attribution) */
61
71
  model?: string;
72
+ /** Structured turn-by-turn log of the agent's actions */
73
+ turns?: TurnLog[];
62
74
  }
63
75
  export declare function runAgentLoop(params: AgentLoopParams): Promise<AgentLoopResult>;
@@ -19,9 +19,12 @@ import { buildSystemPrompt } from "./system-prompt.js";
19
19
  // --- Agent Loop ---
20
20
  export async function runAgentLoop(params) {
21
21
  const { task, url, context, executeTool, onStep, onText, maxSteps = 50, signal, } = params;
22
- const system = buildSystemPrompt();
22
+ // Detect target URL for domain knowledge — from explicit url param or from task text
23
+ const targetUrl = url || task.match(/https?:\/\/[^\s"')]+/)?.[0];
24
+ const system = buildSystemPrompt(targetUrl);
23
25
  const tools = AGENT_TOOLS;
24
26
  const messages = [];
27
+ const turns = [];
25
28
  let totalUsage = { inputTokens: 0, outputTokens: 0, apiCalls: 0 };
26
29
  let lastModel;
27
30
  // Build initial user message
@@ -41,6 +44,7 @@ export async function runAgentLoop(params) {
41
44
  steps: step - 1,
42
45
  usage: totalUsage,
43
46
  model: lastModel,
47
+ turns,
44
48
  };
45
49
  }
46
50
  onStep?.({ step, status: "thinking" });
@@ -63,6 +67,7 @@ export async function runAgentLoop(params) {
63
67
  steps: step,
64
68
  usage: totalUsage,
65
69
  model: lastModel,
70
+ turns,
66
71
  };
67
72
  }
68
73
  totalUsage.apiCalls++;
@@ -79,9 +84,17 @@ export async function runAgentLoop(params) {
79
84
  // Extract text and tool calls
80
85
  const textBlocks = response.content.filter((b) => b.type === "text");
81
86
  const toolUseBlocks = response.content.filter((b) => b.type === "tool_use");
87
+ // Start building the turn log for this step
88
+ const currentTurn = {
89
+ step,
90
+ tools: [],
91
+ ai_response: textBlocks.map((b) => b.text).join("\n").trim() || null,
92
+ };
82
93
  // If no tool calls, we're done
83
94
  if (response.stop_reason === "end_turn" || toolUseBlocks.length === 0) {
84
95
  const answer = textBlocks.map((b) => b.text).join("\n").trim();
96
+ turns.push(currentTurn);
97
+ console.error(`[AgentLoop] Complete at step ${step} (${totalUsage.apiCalls} API calls, ${totalUsage.inputTokens} input tokens)`);
85
98
  onStep?.({ step, status: "complete", text: answer });
86
99
  return {
87
100
  status: "complete",
@@ -89,6 +102,7 @@ export async function runAgentLoop(params) {
89
102
  steps: step,
90
103
  usage: totalUsage,
91
104
  model: lastModel,
105
+ turns,
92
106
  };
93
107
  }
94
108
  // Execute each tool call
@@ -105,6 +119,12 @@ export async function runAgentLoop(params) {
105
119
  });
106
120
  continue;
107
121
  }
122
+ // Log tool call
123
+ const inputSummary = toolUse.name === "navigate" ? toolUse.input.url
124
+ : toolUse.name === "computer" ? `${toolUse.input.action}${toolUse.input.ref ? ` ref=${toolUse.input.ref}` : ""}${toolUse.input.coordinate ? ` @${toolUse.input.coordinate}` : ""}`
125
+ : toolUse.name === "javascript_tool" ? toolUse.input.text?.slice(0, 80)
126
+ : JSON.stringify(toolUse.input).slice(0, 80);
127
+ console.error(`[AgentLoop] Step ${step}: ${toolUse.name}(${inputSummary})`);
108
128
  onStep?.({
109
129
  step,
110
130
  status: "tool_use",
@@ -112,6 +132,7 @@ export async function runAgentLoop(params) {
112
132
  toolInput: toolUse.input,
113
133
  });
114
134
  let result;
135
+ const toolStartMs = Date.now();
115
136
  try {
116
137
  result = await executeTool(toolUse.name, toolUse.input);
117
138
  }
@@ -133,15 +154,32 @@ export async function runAgentLoop(params) {
133
154
  result = { success: false, error: err.message };
134
155
  }
135
156
  }
157
+ // Log result summary
158
+ const toolDurationMs = Date.now() - toolStartMs;
159
+ const resultText = result.error ? `Error: ${result.error}`
160
+ : typeof result.output === "string" ? result.output
161
+ : JSON.stringify(result.output);
162
+ const resultSummary = resultText.length > 120 ? resultText.slice(0, 120) + "..." : resultText;
163
+ console.error(`[AgentLoop] Step ${step}: ${toolUse.name} → ${resultSummary}`);
164
+ // Add to structured turn log (truncate large results to keep log manageable)
165
+ currentTurn.tools.push({
166
+ name: toolUse.name,
167
+ input: toolUse.input,
168
+ result: (resultText.length > 5000 ? resultText.slice(0, 5000) + "... [truncated]" : resultText)
169
+ + (result.screenshot ? " [+screenshot]" : ""),
170
+ durationMs: toolDurationMs,
171
+ });
136
172
  onStep?.({ step, status: "tool_result", toolName: toolUse.name });
137
173
  // Check abort after each tool — don't feed results back to LLM if cancelled
138
174
  if (signal?.aborted) {
175
+ turns.push(currentTurn);
139
176
  return {
140
177
  status: "error",
141
178
  answer: "Task was cancelled.",
142
179
  steps: step,
143
180
  usage: totalUsage,
144
181
  model: lastModel,
182
+ turns,
145
183
  };
146
184
  }
147
185
  // Build tool result content block
@@ -172,6 +210,7 @@ export async function runAgentLoop(params) {
172
210
  }
173
211
  // Add tool results as user message
174
212
  messages.push({ role: "user", content: toolResults });
213
+ turns.push(currentTurn);
175
214
  }
176
215
  // Exceeded max steps
177
216
  const lastText = messages
@@ -186,5 +225,6 @@ export async function runAgentLoop(params) {
186
225
  steps: maxSteps,
187
226
  usage: totalUsage,
188
227
  model: lastModel,
228
+ turns,
189
229
  };
190
230
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * System prompt for server-side managed agent loop.
3
3
  */
4
- export declare function buildSystemPrompt(): Array<{
4
+ export declare function buildSystemPrompt(taskUrl?: string): Array<{
5
5
  type: "text";
6
6
  text: string;
7
7
  }>;
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * System prompt for server-side managed agent loop.
3
3
  */
4
- export function buildSystemPrompt() {
4
+ import { getDomainSkill } from "./domain-knowledge.js";
5
+ export function buildSystemPrompt(taskUrl) {
5
6
  const now = new Date();
6
7
  const dateStr = now.toLocaleDateString("en-US", {
7
8
  month: "numeric",
@@ -9,7 +10,7 @@ export function buildSystemPrompt() {
9
10
  year: "numeric",
10
11
  });
11
12
  const timeStr = now.toLocaleTimeString("en-US");
12
- return [
13
+ const blocks = [
13
14
  {
14
15
  type: "text",
15
16
  text: `You are a web automation assistant with browser tools. Your priority is to complete the user's request efficiently and autonomously.
@@ -38,4 +39,13 @@ When a page shows only a loading spinner, use the computer tool with action "wai
38
39
  </tool_usage_requirements>`,
39
40
  },
40
41
  ];
42
+ // Inject domain-specific knowledge if the task targets a known site
43
+ const domainSkill = taskUrl ? getDomainSkill(taskUrl) : null;
44
+ if (domainSkill) {
45
+ blocks.push({
46
+ type: "text",
47
+ text: `<domain_knowledge domain="${domainSkill.domain}">\n${domainSkill.skill}\n</domain_knowledge>`,
48
+ });
49
+ }
50
+ return blocks;
41
51
  }
@@ -0,0 +1,21 @@
1
+ import type { SessionFileStatus } from './session-files.js';
2
+ export declare function buildTaskCompletePayload(sessionId: string, result: unknown): {
3
+ session_id: string;
4
+ status: string;
5
+ result: unknown;
6
+ };
7
+ export declare function buildTaskErrorPayload(sessionId: string, error: string): {
8
+ session_id: string;
9
+ status: string;
10
+ error: string;
11
+ };
12
+ export declare function buildStatusPayload(status: SessionFileStatus | SessionFileStatus[]): SessionFileStatus | SessionFileStatus[];
13
+ export declare function buildStopPayload(sessionId: string, remove?: boolean): {
14
+ session_id: string;
15
+ status: string;
16
+ removed: boolean;
17
+ };
18
+ export declare function buildScreenshotPayload(sessionId: string, screenshotPath: string): {
19
+ session_id: string;
20
+ screenshot_path: string;
21
+ };
@@ -0,0 +1,30 @@
1
+ export function buildTaskCompletePayload(sessionId, result) {
2
+ return {
3
+ session_id: sessionId,
4
+ status: 'completed',
5
+ result,
6
+ };
7
+ }
8
+ export function buildTaskErrorPayload(sessionId, error) {
9
+ return {
10
+ session_id: sessionId,
11
+ status: 'error',
12
+ error,
13
+ };
14
+ }
15
+ export function buildStatusPayload(status) {
16
+ return status;
17
+ }
18
+ export function buildStopPayload(sessionId, remove = false) {
19
+ return {
20
+ session_id: sessionId,
21
+ status: 'stopped',
22
+ removed: remove,
23
+ };
24
+ }
25
+ export function buildScreenshotPayload(sessionId, screenshotPath) {
26
+ return {
27
+ session_id: sessionId,
28
+ screenshot_path: screenshotPath,
29
+ };
30
+ }
@@ -4,7 +4,58 @@
4
4
  * Scans the machine for Claude Code, Cursor, Windsurf, and Claude Desktop,
5
5
  * then merges the Hanzi MCP server entry into each agent's config file.
6
6
  */
7
+ interface AgentConfig {
8
+ name: string;
9
+ slug: string;
10
+ method: 'json-merge' | 'cli-command';
11
+ detect: () => boolean;
12
+ configPath?: () => string;
13
+ cliCommand?: string;
14
+ skillsDir?: () => string;
15
+ }
16
+ interface SetupResult {
17
+ agent: string;
18
+ status: 'configured' | 'already-configured' | 'skipped' | 'error';
19
+ detail: string;
20
+ }
21
+ interface AgentRegistryDeps {
22
+ home?: string;
23
+ plat?: NodeJS.Platform;
24
+ appData?: string;
25
+ pathExists?: (path: string) => boolean;
26
+ runCommand?: (command: string, options?: any) => Buffer | string;
27
+ }
28
+ interface JsonConfigDeps {
29
+ pathExists?: (path: string) => boolean;
30
+ readTextFile?: (path: string, encoding: BufferEncoding) => string;
31
+ writeTextFile?: (path: string, contents: string) => void;
32
+ ensureDir?: (path: string, options: {
33
+ recursive: boolean;
34
+ }) => void;
35
+ copyFile?: (source: string, destination: string) => void;
36
+ }
37
+ interface BrowserDetectionDeps {
38
+ plat?: NodeJS.Platform;
39
+ pathExists?: (path: string) => boolean;
40
+ runCommand?: (command: string, options?: any) => Buffer | string;
41
+ }
42
+ export declare function getAgentRegistry(deps?: AgentRegistryDeps): AgentConfig[];
43
+ export declare function mergeJsonConfig(configPath: string, deps?: JsonConfigDeps): SetupResult;
44
+ interface BrowserInfo {
45
+ name: string;
46
+ slug: string;
47
+ macApp: string;
48
+ linuxBin: string;
49
+ winPaths: string[];
50
+ }
51
+ export declare function detectBrowsers(deps?: BrowserDetectionDeps): BrowserInfo[];
52
+ export declare function resolveInteractiveMode(options?: {
53
+ yes?: boolean;
54
+ }, stdinIsTTY?: boolean): boolean;
55
+ export declare function buildBrowserOpenCommand(browser: BrowserInfo, url: string, plat: NodeJS.Platform): string;
56
+ export declare function buildSystemOpenCommand(url: string, plat: NodeJS.Platform): string;
7
57
  export declare function runSetup(options?: {
8
58
  only?: string;
9
59
  yes?: boolean;
10
60
  }): Promise<void>;
61
+ export {};
package/dist/cli/setup.js CHANGED
@@ -65,12 +65,15 @@ const MCP_ENTRY = {
65
65
  args: ['-y', 'hanzi-browse'],
66
66
  };
67
67
  // ── Agent registry ─────────────────────────────────────────────────────
68
- function getAgentRegistry() {
69
- const home = homedir();
70
- const plat = platform();
68
+ export function getAgentRegistry(deps = {}) {
69
+ const home = deps.home ?? homedir();
70
+ const plat = deps.plat ?? platform();
71
+ const appData = deps.appData ?? process.env.APPDATA ?? join(home, 'AppData', 'Roaming');
72
+ const pathExists = deps.pathExists ?? existsSync;
73
+ const runCommand = deps.runCommand ?? execSync;
71
74
  const hasCli = (bin) => {
72
75
  try {
73
- execSync(`which ${bin}`, { stdio: 'ignore' });
76
+ runCommand(`which ${bin}`, { stdio: 'ignore' });
74
77
  return true;
75
78
  }
76
79
  catch {
@@ -94,7 +97,7 @@ function getAgentRegistry() {
94
97
  method: 'json-merge',
95
98
  configPath: () => join(home, '.cursor', 'mcp.json'),
96
99
  skillsDir: () => join(home, '.cursor', 'skills'),
97
- detect: () => existsSync(join(home, '.cursor')),
100
+ detect: () => pathExists(join(home, '.cursor')),
98
101
  },
99
102
  {
100
103
  name: 'Windsurf',
@@ -102,7 +105,7 @@ function getAgentRegistry() {
102
105
  method: 'json-merge',
103
106
  configPath: () => join(home, '.codeium', 'windsurf', 'mcp_config.json'),
104
107
  skillsDir: () => join(home, '.codeium', 'windsurf', 'skills'),
105
- detect: () => existsSync(join(home, '.codeium', 'windsurf')),
108
+ detect: () => pathExists(join(home, '.codeium', 'windsurf')),
106
109
  },
107
110
  {
108
111
  name: 'VS Code',
@@ -110,7 +113,7 @@ function getAgentRegistry() {
110
113
  method: 'json-merge',
111
114
  configPath: () => join(home, '.vscode', 'mcp.json'),
112
115
  skillsDir: () => join(home, '.vscode', 'skills'),
113
- detect: () => existsSync(join(home, '.vscode')),
116
+ detect: () => pathExists(join(home, '.vscode')),
114
117
  },
115
118
  {
116
119
  name: 'Codex',
@@ -118,7 +121,7 @@ function getAgentRegistry() {
118
121
  method: 'json-merge',
119
122
  configPath: () => join(home, '.codex', 'mcp.json'),
120
123
  skillsDir: () => join(home, '.agents', 'skills'),
121
- detect: () => existsSync(join(home, '.codex')) || hasCli('codex'),
124
+ detect: () => pathExists(join(home, '.codex')) || hasCli('codex'),
122
125
  },
123
126
  {
124
127
  name: 'Claude Desktop',
@@ -128,15 +131,15 @@ function getAgentRegistry() {
128
131
  if (plat === 'darwin')
129
132
  return join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
130
133
  if (plat === 'win32')
131
- return join(process.env.APPDATA || join(home, 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json');
134
+ return join(appData, 'Claude', 'claude_desktop_config.json');
132
135
  return join(home, '.config', 'Claude', 'claude_desktop_config.json');
133
136
  },
134
137
  detect: () => {
135
138
  if (plat === 'darwin')
136
- return existsSync(join(home, 'Library', 'Application Support', 'Claude'));
139
+ return pathExists(join(home, 'Library', 'Application Support', 'Claude'));
137
140
  if (plat === 'win32')
138
- return existsSync(join(process.env.APPDATA || join(home, 'AppData', 'Roaming'), 'Claude'));
139
- return existsSync(join(home, '.config', 'Claude'));
141
+ return pathExists(join(appData, 'Claude'));
142
+ return pathExists(join(home, '.config', 'Claude'));
140
143
  },
141
144
  },
142
145
  {
@@ -145,7 +148,7 @@ function getAgentRegistry() {
145
148
  method: 'json-merge',
146
149
  configPath: () => join(home, '.gemini', 'settings.json'),
147
150
  skillsDir: () => join(home, '.gemini', 'skills'),
148
- detect: () => existsSync(join(home, '.gemini')) || hasCli('gemini'),
151
+ detect: () => pathExists(join(home, '.gemini')) || hasCli('gemini'),
149
152
  },
150
153
  {
151
154
  name: 'Amp',
@@ -153,21 +156,21 @@ function getAgentRegistry() {
153
156
  method: 'json-merge',
154
157
  configPath: () => join(home, '.amp', 'mcp.json'),
155
158
  skillsDir: () => join(home, '.amp', 'skills'),
156
- detect: () => existsSync(join(home, '.amp')),
159
+ detect: () => pathExists(join(home, '.amp')),
157
160
  },
158
161
  {
159
162
  name: 'Cline',
160
163
  slug: 'cline',
161
164
  method: 'json-merge',
162
165
  configPath: () => join(home, '.cline', 'mcp_settings.json'),
163
- detect: () => existsSync(join(home, '.cline')),
166
+ detect: () => pathExists(join(home, '.cline')),
164
167
  },
165
168
  {
166
169
  name: 'Roo Code',
167
170
  slug: 'roo-code',
168
171
  method: 'json-merge',
169
172
  configPath: () => join(home, '.roo-code', 'mcp_settings.json'),
170
- detect: () => existsSync(join(home, '.roo-code')),
173
+ detect: () => pathExists(join(home, '.roo-code')),
171
174
  },
172
175
  ];
173
176
  }
@@ -177,16 +180,21 @@ function stripJsonComments(text) {
177
180
  .replace(/\/\/.*$/gm, '')
178
181
  .replace(/\/\*[\s\S]*?\*\//g, '');
179
182
  }
180
- function mergeJsonConfig(configPath) {
183
+ export function mergeJsonConfig(configPath, deps = {}) {
181
184
  const agentName = configPath;
185
+ const pathExists = deps.pathExists ?? existsSync;
186
+ const readTextFile = deps.readTextFile ?? readFileSync;
187
+ const writeTextFile = deps.writeTextFile ?? writeFileSync;
188
+ const ensureDir = deps.ensureDir ?? mkdirSync;
189
+ const copyFile = deps.copyFile ?? copyFileSync;
182
190
  try {
183
- if (!existsSync(configPath)) {
184
- mkdirSync(join(configPath, '..'), { recursive: true });
191
+ if (!pathExists(configPath)) {
192
+ ensureDir(join(configPath, '..'), { recursive: true });
185
193
  const config = { mcpServers: { "hanzi-browser": MCP_ENTRY } };
186
- writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
194
+ writeTextFile(configPath, JSON.stringify(config, null, 2) + '\n');
187
195
  return { agent: agentName, status: 'configured', detail: `created ${configPath}` };
188
196
  }
189
- const raw = readFileSync(configPath, 'utf-8');
197
+ const raw = readTextFile(configPath, 'utf-8');
190
198
  let config;
191
199
  try {
192
200
  config = JSON.parse(raw);
@@ -197,9 +205,9 @@ function mergeJsonConfig(configPath) {
197
205
  }
198
206
  catch {
199
207
  const bakPath = configPath + '.bak';
200
- copyFileSync(configPath, bakPath);
208
+ copyFile(configPath, bakPath);
201
209
  config = { mcpServers: { "hanzi-browser": MCP_ENTRY } };
202
- writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
210
+ writeTextFile(configPath, JSON.stringify(config, null, 2) + '\n');
203
211
  return { agent: agentName, status: 'configured', detail: `backed up malformed config to ${bakPath}` };
204
212
  }
205
213
  }
@@ -212,7 +220,7 @@ function mergeJsonConfig(configPath) {
212
220
  if (!config.mcpServers)
213
221
  config.mcpServers = {};
214
222
  config.mcpServers["hanzi-browser"] = MCP_ENTRY;
215
- writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
223
+ writeTextFile(configPath, JSON.stringify(config, null, 2) + '\n');
216
224
  return { agent: agentName, status: 'configured', detail: `merged into ${configPath}` };
217
225
  }
218
226
  catch (err) {
@@ -245,20 +253,67 @@ function runClaudeCodeSetup() {
245
253
  // ── Browser detection ──────────────────────────────────────────────────
246
254
  const EXTENSION_URL = 'https://chromewebstore.google.com/detail/hanzi-browse/iklpkemlmbhemkiojndpbhoakgikpmcd';
247
255
  const BROWSERS = [
248
- { name: 'Google Chrome', slug: 'chrome', macApp: 'Google Chrome', linuxBin: 'google-chrome' },
249
- { name: 'Brave', slug: 'brave', macApp: 'Brave Browser', linuxBin: 'brave-browser' },
250
- { name: 'Microsoft Edge', slug: 'edge', macApp: 'Microsoft Edge', linuxBin: 'microsoft-edge' },
251
- { name: 'Arc', slug: 'arc', macApp: 'Arc', linuxBin: 'arc' },
252
- { name: 'Chromium', slug: 'chromium', macApp: 'Chromium', linuxBin: 'chromium-browser' },
256
+ {
257
+ name: 'Google Chrome',
258
+ slug: 'chrome',
259
+ macApp: 'Google Chrome',
260
+ linuxBin: 'google-chrome',
261
+ winPaths: [
262
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
263
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
264
+ ],
265
+ },
266
+ {
267
+ name: 'Brave',
268
+ slug: 'brave',
269
+ macApp: 'Brave Browser',
270
+ linuxBin: 'brave-browser',
271
+ winPaths: [
272
+ 'C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe',
273
+ 'C:\\Program Files (x86)\\BraveSoftware\\Brave-Browser\\Application\\brave.exe',
274
+ ],
275
+ },
276
+ {
277
+ name: 'Microsoft Edge',
278
+ slug: 'edge',
279
+ macApp: 'Microsoft Edge',
280
+ linuxBin: 'microsoft-edge',
281
+ winPaths: [
282
+ 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
283
+ 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
284
+ ],
285
+ },
286
+ {
287
+ name: 'Arc',
288
+ slug: 'arc',
289
+ macApp: 'Arc',
290
+ linuxBin: 'arc',
291
+ winPaths: [],
292
+ },
293
+ {
294
+ name: 'Chromium',
295
+ slug: 'chromium',
296
+ macApp: 'Chromium',
297
+ linuxBin: 'chromium-browser',
298
+ winPaths: [
299
+ 'C:\\Program Files\\Chromium\\Application\\chrome.exe',
300
+ 'C:\\Program Files (x86)\\Chromium\\Application\\chrome.exe',
301
+ ],
302
+ },
253
303
  ];
254
- function detectBrowsers() {
255
- const plat = platform();
304
+ export function detectBrowsers(deps = {}) {
305
+ const plat = deps.plat ?? platform();
306
+ const pathExists = deps.pathExists ?? existsSync;
307
+ const runCommand = deps.runCommand ?? execSync;
256
308
  return BROWSERS.filter(b => {
257
309
  if (plat === 'darwin') {
258
- return existsSync(`/Applications/${b.macApp}.app`);
310
+ return pathExists(`/Applications/${b.macApp}.app`);
311
+ }
312
+ if (plat === 'win32') {
313
+ return b.winPaths.some(path => pathExists(path));
259
314
  }
260
315
  try {
261
- execSync(`which ${b.linuxBin}`, { stdio: 'ignore' });
316
+ runCommand(`which ${b.linuxBin}`, { stdio: 'ignore' });
262
317
  return true;
263
318
  }
264
319
  catch {
@@ -266,19 +321,36 @@ function detectBrowsers() {
266
321
  }
267
322
  });
268
323
  }
324
+ export function resolveInteractiveMode(options = {}, stdinIsTTY = process.stdin.isTTY ?? false) {
325
+ return options.yes ? false : stdinIsTTY;
326
+ }
327
+ export function buildBrowserOpenCommand(browser, url, plat) {
328
+ if (plat === 'darwin') {
329
+ return `open -a "${browser.macApp}" "${url}"`;
330
+ }
331
+ if (plat === 'win32') {
332
+ const exePath = browser.winPaths.find(path => existsSync(path)) ?? browser.winPaths[0];
333
+ if (!exePath)
334
+ return `cmd /c start "" "${url}"`;
335
+ return `cmd /c start "" "${exePath}" "${url}"`;
336
+ }
337
+ return `${browser.linuxBin} "${url}" &`;
338
+ }
339
+ export function buildSystemOpenCommand(url, plat) {
340
+ if (plat === 'darwin')
341
+ return `open "${url}"`;
342
+ if (plat === 'win32')
343
+ return `cmd /c start "" "${url}"`;
344
+ return `xdg-open "${url}"`;
345
+ }
269
346
  function openInBrowser(browser, url) {
270
347
  const plat = platform();
271
348
  try {
272
- if (plat === 'darwin') {
273
- execSync(`open -a "${browser.macApp}" "${url}"`, { stdio: 'ignore' });
274
- }
275
- else {
276
- execSync(`${browser.linuxBin} "${url}" &`, { stdio: 'ignore' });
277
- }
349
+ execSync(buildBrowserOpenCommand(browser, url, plat), { stdio: 'ignore' });
278
350
  }
279
351
  catch {
280
352
  // Fallback: system default
281
- execSync(`open "${url}" 2>/dev/null || xdg-open "${url}" 2>/dev/null`, { stdio: 'ignore' });
353
+ execSync(buildSystemOpenCommand(url, plat), { stdio: 'ignore' });
282
354
  }
283
355
  }
284
356
  async function ensureExtension(isInteractive) {
@@ -700,7 +772,7 @@ export async function runSetup(options = {}) {
700
772
  trackEvent("setup_started");
701
773
  const registry = getAgentRegistry();
702
774
  const only = options.only;
703
- const interactive = options.yes ? false : (process.stdin.isTTY ?? false);
775
+ const interactive = resolveInteractiveMode(options);
704
776
  // ── Banner ──
705
777
  if (interactive) {
706
778
  console.log(BANNER);