mooncat-browser 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,39 +1,60 @@
1
+ #!/usr/bin/env node
1
2
  // -*- coding: utf-8 -*-
2
3
  // cli.ts — mooncat-browser 命令行入口。
3
4
  //
4
- // 命令:
5
- // mooncat-browser start [--port 17322] [--profile D:\p] [--chrome path] [--health-port 17440]
6
- // mooncat-browser health [--port 17322]
7
- // mooncat-browser stop [--port 17322]
8
- // mooncat-browser open --url <url> [--port 17322]
9
- // mooncat-browser install-extension
10
- // mooncat-browser skills [--skill <name>] [--target <dir>] [--list]
5
+ // mooncat-browser 是独立包:不写全局 appData,不假设 mooncat space 目录。
6
+ // 所有运行数据由项目配置(config/browser.json mooncat-browser.config.json)显式声明。
11
7
  //
12
- // --port 语义 = browserd RPC 端口(client 连这里,默认 17322)。
13
- // start 还会顺带起一个 wrapper health 端口(--health-port,默认 17440),供 stop/监控用。
8
+ // 命令:
9
+ // mooncat-browser init [name] 初始化项目配置 + 数据目录
10
+ // mooncat-browser start <name> 启动实例(wrapper + browserd + Chrome)
11
+ // mooncat-browser start <name> --temp 临时实例(系统 temp,退出清理)
12
+ // mooncat-browser stop <name> graceful 停止实例
13
+ // mooncat-browser status <name> 查实例运行状态
14
+ // mooncat-browser health <name> 探测 browserd 是否就绪
15
+ // mooncat-browser open <name> --url <url> 打开/复用浏览器并导航
16
+ // mooncat-browser list 列出配置里的实例 + 运行状态
17
+ // mooncat-browser install-extension 打印 WebPlater 扩展路径
18
+ // mooncat-browser skills [--skill <name>] 安装内置 skills
19
+ //
20
+ // <name> 是 config 里 instances 的 key(默认实例叫 default)。
21
+ // --rpc-port / --health-port / --profile 等作为高级覆盖(覆盖配置文件值)。
14
22
  import { Command } from "commander";
15
23
  import { createRequire } from "node:module";
16
- import { resolveConfig } from "./config.js";
17
24
  import { BrowserClient } from "./client.js";
18
25
  import { startService, readServiceState, serviceStatePath, browserdScriptPath } from "./server.js";
26
+ import { defaultInstanceEntry, findProjectConfig, InstanceNotFoundError, listInstanceNames, loadInstance, NotInitializedError, tempInstance, } from "./instance.js";
27
+ import { prepareExtension, verifyInstanceExtension } from "./prepare-extension.js";
19
28
  import { fileURLToPath } from "node:url";
20
- import { homedir } from "node:os";
21
29
  import { join } from "node:path";
22
- import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
30
+ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
23
31
  const require = createRequire(import.meta.url);
24
32
  const __dirname = fileURLToPath(new URL(".", import.meta.url));
25
- /** webplater 扩展已构建产物目录(install-extension 指向这里)。 */
33
+ /** webplater 扩展已构建产物目录(install-extension 指向这里)。 */
26
34
  function extensionDistDir() {
27
35
  return process.env.MOONCAT_BROWSER_EXTENSION || join(__dirname, "..", "browser-op", "webplater", "dist", "chrome-mv3");
28
36
  }
29
37
  function printHeader(title) {
30
38
  console.log(`\n[mooncat-browser] ${title}`);
31
39
  }
32
- /** 构造 client(按 --port / env / default)。 */
33
- function makeClient(port) {
34
- return new BrowserClient({ port });
40
+ /** commander 解析的 opts 转成 CliOverrides(只取高级覆盖字段)。 */
41
+ function optsToOverrides(opts) {
42
+ const num = (v) => (typeof v === "number" ? v : undefined);
43
+ const str = (v) => (typeof v === "string" ? v : undefined);
44
+ return {
45
+ rpcPort: num(opts.rpcPort) ?? num(opts.port),
46
+ port: num(opts.port) ?? num(opts.rpcPort),
47
+ healthPort: num(opts.healthPort),
48
+ extensionPort: num(opts.extensionPort),
49
+ profileDir: str(opts.profile),
50
+ stateDir: str(opts.stateDir),
51
+ logsDir: str(opts.logsDir),
52
+ extensionDir: str(opts.extensionDir),
53
+ chromePath: str(opts.chrome),
54
+ routeMode: str(opts.routeMode),
55
+ };
35
56
  }
36
- /** 轮询 rpcPort,直到连不上(browserd 已退)或超时。返回 true 表示已退出。 */
57
+ /** 轮询 rpcPort,直到连不上(browserd 已退)或超时。返回 true 表示已退出。 */
37
58
  async function waitForExit(rpcPort, timeoutMs) {
38
59
  const deadline = Date.now() + timeoutMs;
39
60
  while (Date.now() < deadline) {
@@ -41,7 +62,7 @@ async function waitForExit(rpcPort, timeoutMs) {
41
62
  await fetch(`http://127.0.0.1:${rpcPort}/health`, {
42
63
  signal: AbortSignal.timeout(1000),
43
64
  });
44
- // 还活着,继续等
65
+ // 还活着,继续等
45
66
  }
46
67
  catch {
47
68
  return true; // 连不上 = 已退
@@ -50,72 +71,243 @@ async function waitForExit(rpcPort, timeoutMs) {
50
71
  }
51
72
  return false;
52
73
  }
74
+ /** 友好处理 NotInitializedError / InstanceNotFoundError,其它原样抛。 */
75
+ function handleConfigError(e) {
76
+ if (e instanceof NotInitializedError || e instanceof InstanceNotFoundError) {
77
+ console.error(`\n[mooncat-browser] ${e.message}`);
78
+ process.exit(1);
79
+ }
80
+ throw e;
81
+ }
82
+ /**
83
+ * 判断实例是否真在跑且 state 与响应进程对得上。
84
+ * 只 curl 到 health 端口不够——可能是上一个僵尸 wrapper 占着端口。
85
+ * 必须校验响应里的 wrapperPid == state.pid,才是这个 state 对应的进程在响应。
86
+ * 返回: "running" | "stale"(state 有但进程不对/没响应)| "stopped"(无 state)。
87
+ */
88
+ async function realInstanceStatus(cfg) {
89
+ const state = readServiceState(cfg);
90
+ if (!state)
91
+ return "stopped";
92
+ try {
93
+ const resp = await fetch(`http://127.0.0.1:${state.healthPort}/health`, {
94
+ signal: AbortSignal.timeout(1000),
95
+ });
96
+ if (!resp.ok)
97
+ return "stale";
98
+ const body = (await resp.json());
99
+ // wrapperPid 对得上 state.pid = 这个 state 的进程还活着
100
+ if (typeof body.wrapperPid === "number" && body.wrapperPid === state.pid)
101
+ return "running";
102
+ // 端口响应了但 pid 不是 state 记录的 = 僵尸/被占用
103
+ return "stale";
104
+ }
105
+ catch {
106
+ return "stale";
107
+ }
108
+ }
53
109
  async function run() {
54
110
  const program = new Command();
55
111
  program
56
112
  .name("mooncat-browser")
57
- .description("浏览器自动化工具包:可独立启动的本地服务 + JS/TS client")
113
+ .description("浏览器自动化工具包:可独立启动的本地服务 + JS/TS client (实例化、项目级配置)")
58
114
  .version(getVersion());
115
+ // ─── init ───────────────────────────────────────────────────────────────
116
+ program
117
+ .command("init [name]")
118
+ .description("初始化项目配置 + 数据目录(默认实例名 default)")
119
+ .option("--dir <path>", "数据根目录(默认 .browser)")
120
+ .action((name, opts) => {
121
+ const instName = name || "default";
122
+ const dataRoot = opts.dir || ".browser";
123
+ const cwd = process.cwd();
124
+ // 选 config/browser.json 作为默认配置文件(config/ 目录更直观)
125
+ const cfgDir = join(cwd, "config");
126
+ const cfgPath = join(cfgDir, "browser.json");
127
+ if (existsSync(cfgPath)) {
128
+ console.error(`\n[mooncat-browser] config already exists: ${cfgPath}`);
129
+ console.error(` to add an instance, edit it manually or remove it first`);
130
+ process.exit(1);
131
+ }
132
+ mkdirSync(cfgDir, { recursive: true });
133
+ const entry = {
134
+ ...defaultInstanceEntry(instName),
135
+ profileDir: `${dataRoot}/${instName}/profile`,
136
+ stateDir: `${dataRoot}/${instName}/state`,
137
+ logsDir: `${dataRoot}/${instName}/logs`,
138
+ extensionDir: `${dataRoot}/${instName}/extension`,
139
+ };
140
+ const file = { instances: { [instName]: entry } };
141
+ writeFileSync(cfgPath, JSON.stringify(file, null, 2) + "\n", "utf8");
142
+ // 预创建数据目录(加 .gitkeep,方便进版本库)。extension 目录不预创建——
143
+ // 它由 prepare-extension 命令显式生成(含占位符替换),不该手动建空壳。
144
+ for (const sub of ["profile", "state", "logs"]) {
145
+ const d = join(cwd, dataRoot, instName, sub);
146
+ mkdirSync(d, { recursive: true });
147
+ writeFileSync(join(d, ".gitkeep"), "", "utf8");
148
+ }
149
+ printHeader(`initialized`);
150
+ console.log(` config : ${cfgPath}`);
151
+ console.log(` instance : ${instName}`);
152
+ console.log(` data : ${dataRoot}/${instName}/{profile,state,logs}`);
153
+ console.log(`\n next:`);
154
+ console.log(` mooncat-browser start ${instName} # CDP 路直跑`);
155
+ console.log(` mooncat-browser prepare-extension ${instName} # extension 路需先生成扩展目录`);
156
+ });
157
+ // ─── prepare-extension ──────────────────────────────────────────────────
158
+ program
159
+ .command("prepare-extension <name>")
160
+ .description("为实例生成 unpacked 扩展目录(替换 WS 端口占位符;然后手动 load unpacked 到 profileDir)")
161
+ .action((name, opts) => {
162
+ let cfg;
163
+ try {
164
+ const handle = findProjectConfig(process.cwd());
165
+ cfg = loadInstance(name, handle, optsToOverrides(opts));
166
+ }
167
+ catch (e) {
168
+ handleConfigError(e);
169
+ return;
170
+ }
171
+ printHeader(`prepare extension for instance "${name}"`);
172
+ try {
173
+ const { replacedFiles, metadata, templateDir } = prepareExtension(cfg);
174
+ console.log(` template : ${templateDir}`);
175
+ console.log(` target : ${cfg.extensionDir}`);
176
+ console.log(` extensionPort : ${cfg.extensionPort}`);
177
+ console.log(` hostWsUrl : ${metadata.hostWsUrl}`);
178
+ console.log(` replaced files: ${replacedFiles}`);
179
+ console.log(` build version : ${metadata.sourceBuildVersion}`);
180
+ console.log(`\n next: 手动 load unpacked 到本实例的 Chrome profile`);
181
+ console.log(` 1. mooncat-browser start ${name} --route-mode extension`);
182
+ console.log(` 2. Chrome -> chrome://extensions -> 开发者模式 -> 加载已解压的扩展程序`);
183
+ console.log(` 3. 选择目录: ${cfg.extensionDir}`);
184
+ console.log(` 4. 重启浏览器(close + open)走 extension 路`);
185
+ }
186
+ catch (e) {
187
+ console.error(`\n [ERROR] prepare failed: ${e.message}`);
188
+ process.exit(1);
189
+ }
190
+ });
59
191
  // ─── start ──────────────────────────────────────────────────────────────
60
192
  program
61
- .command("start")
62
- .description("启动 browser 服务(wrapper + browserd 常驻)")
63
- .option("-p, --port <port>", "browserd RPC 端口(client 连这里,默认 17322)", (v) => Number(v))
193
+ .command("start <name>")
194
+ .description("启动实例(wrapper + browserd 常驻 + Chrome)")
195
+ .option("-p, --port <port>", "高级覆盖:browserd RPC 端口", (v) => Number(v))
64
196
  .option("--rpc-port <port>", "同 --port", (v) => Number(v))
65
- .option("--health-port <port>", "wrapper health 端口(默认 17440)", (v) => Number(v))
66
- .option("--profile <path>", "Chrome user-data-dir(默认 %LOCALAPPDATA%\\mooncat-browser\\profile)")
67
- .option("--chrome <path>", "Chrome 可执行文件路径(不传则自动探测)")
68
- .action(async (opts) => {
69
- const cfg = resolveConfig({
70
- port: opts.port ?? opts.rpcPort,
71
- healthPort: opts.healthPort,
72
- profile: opts.profile,
73
- chromePath: opts.chrome,
74
- });
75
- printHeader(`starting service`);
76
- console.log(` rpc port : ${cfg.rpcPort} (clients connect here)`);
77
- console.log(` health port : ${cfg.healthPort}`);
78
- console.log(` profile : ${cfg.profile}`);
79
- console.log(` chrome : ${cfg.chromePath || "(auto-detect)"}`);
80
- console.log(` logs : ${cfg.logsDir}`);
81
- console.log(` browserd : ${browserdScriptPath()}`);
197
+ .option("--health-port <port>", "高级覆盖:wrapper health 端口", (v) => Number(v))
198
+ .option("--extension-port <port>", "高级覆盖:extension WS 端口(第一版不消费)", (v) => Number(v))
199
+ .option("--profile <path>", "高级覆盖:Chrome user-data-dir")
200
+ .option("--state-dir <path>", "高级覆盖:wrapper/browserd 状态目录")
201
+ .option("--logs-dir <path>", "高级覆盖:日志目录")
202
+ .option("--chrome <path>", "高级覆盖:Chrome 可执行文件路径")
203
+ .option("--route-mode <mode>", "高级覆盖:路由模式(auto|cdp|extension)")
204
+ .option("--temp", "临时实例(系统 temp,退出清理,不读项目配置)")
205
+ .action(async (name, opts) => {
206
+ let cfg;
207
+ let cleanup = null;
208
+ try {
209
+ if (opts.temp) {
210
+ const t = tempInstance(name, optsToOverrides(opts));
211
+ cfg = t.cfg;
212
+ cleanup = t.cleanup;
213
+ }
214
+ else {
215
+ const handle = findProjectConfig(process.cwd());
216
+ cfg = loadInstance(name, handle, optsToOverrides(opts));
217
+ }
218
+ }
219
+ catch (e) {
220
+ handleConfigError(e);
221
+ return;
222
+ }
223
+ printHeader(`starting instance "${cfg.name}"`);
224
+ console.log(` rpc port : ${cfg.rpcPort} (clients connect here)`);
225
+ console.log(` health port : ${cfg.healthPort}`);
226
+ console.log(` extension port: ${cfg.extensionPort} (WS port for WebPlater extension)`);
227
+ console.log(` profile : ${cfg.profileDir}`);
228
+ console.log(` state : ${cfg.stateDir}`);
229
+ console.log(` logs : ${cfg.logsDir}`);
230
+ console.log(` extension dir : ${cfg.extensionDir}`);
231
+ console.log(` chrome : ${cfg.chromePath || "(auto-detect)"}`);
232
+ console.log(` browserd : ${browserdScriptPath()}`);
233
+ if (opts.temp)
234
+ console.log(` mode : TEMP (cleaned on exit)`);
235
+ // route=extension 前置检查:实例 extensionDir 必须先 prepare。
236
+ // route=cdp/auto 不需要(temp 也不需要)。
237
+ const wantExtension = cfg.routeMode === "extension";
238
+ if (wantExtension && !opts.temp) {
239
+ const issue = verifyInstanceExtension(cfg);
240
+ if (issue) {
241
+ console.error(`\n [ERROR] extension 目录未就绪: ${issue}`);
242
+ console.error(` run: mooncat-browser prepare-extension ${cfg.name}`);
243
+ if (cleanup)
244
+ cleanup();
245
+ process.exit(1);
246
+ }
247
+ }
82
248
  try {
83
249
  await startService(cfg);
84
- console.log(`\n service running. Ctrl+C to stop, or: mooncat-browser stop --port ${cfg.rpcPort}`);
250
+ console.log(`\n service running. Ctrl+C to stop, or: mooncat-browser stop ${cfg.name}`);
85
251
  }
86
252
  catch (e) {
253
+ if (cleanup)
254
+ cleanup();
255
+ // 失败时清理 state 文件:BrowserService.start() 在 spawn 前就写了 service.json,
256
+ // 启动失败(EADDRINUSE 等)后若不删,残留 state 会误导 list/status 误报 running。
257
+ try {
258
+ unlinkSyncSafe(serviceStatePath(cfg));
259
+ }
260
+ catch { /* ignore */ }
87
261
  console.error(`\n [ERROR] failed to start: ${e.message}`);
88
262
  process.exit(1);
89
263
  }
264
+ // temp 模式:wrapper 退出时清理(SIGINT/SIGTERM 走 stop(),process.exit 前也清)
265
+ if (cleanup) {
266
+ process.on("exit", () => cleanup());
267
+ }
90
268
  });
91
269
  // ─── health ─────────────────────────────────────────────────────────────
92
270
  program
93
- .command("health")
94
- .description("探测 browserd 是否就绪 + 是否已 open")
95
- .option("-p, --port <port>", "browserd RPC 端口(默认 17322)", (v) => Number(v))
96
- .action(async (opts) => {
97
- const client = makeClient(opts.port);
271
+ .command("health <name>")
272
+ .description("探测实例的 browserd 是否就绪 + 是否已 open")
273
+ .action(async (name, opts) => {
274
+ let cfg;
275
+ try {
276
+ const handle = findProjectConfig(process.cwd());
277
+ cfg = loadInstance(name, handle, optsToOverrides(opts));
278
+ }
279
+ catch (e) {
280
+ handleConfigError(e);
281
+ return;
282
+ }
283
+ const client = new BrowserClient({ port: cfg.rpcPort });
98
284
  const h = await client.health();
99
285
  if (!h) {
100
286
  console.error(`\n[mooncat-browser] browserd not reachable at ${client.baseUrl}`);
101
- console.error(` hint: run \`mooncat-browser start\` first`);
287
+ console.error(` hint: run \`mooncat-browser start ${name}\` first`);
102
288
  process.exit(1);
103
289
  }
104
- printHeader(`health @ ${client.baseUrl}`);
290
+ printHeader(`health @ ${client.baseUrl} (instance ${name})`);
105
291
  console.log(JSON.stringify(h, null, 2));
106
292
  });
107
293
  // ─── stop ───────────────────────────────────────────────────────────────
108
294
  program
109
- .command("stop")
110
- .description("graceful 停止服务(关闭 Chrome + 退出 wrapper,不重启)")
111
- .option("-p, --port <port>", "browserd RPC 端口(默认 17322)", (v) => Number(v))
112
- .action(async (opts) => {
113
- const cfg = resolveConfig({ port: opts.port });
114
- const state = readServiceState(cfg.rpcPort);
295
+ .command("stop <name>")
296
+ .description("graceful 停止实例(关闭 Chrome + 退出 wrapper,不重启)")
297
+ .action(async (name, opts) => {
298
+ let cfg;
299
+ try {
300
+ const handle = findProjectConfig(process.cwd());
301
+ cfg = loadInstance(name, handle, optsToOverrides(opts));
302
+ }
303
+ catch (e) {
304
+ handleConfigError(e);
305
+ return;
306
+ }
307
+ const state = readServiceState(cfg);
115
308
  const healthPort = state?.healthPort ?? cfg.healthPort;
116
- printHeader(`stopping service (rpc :${cfg.rpcPort})`);
117
- // 1) 优先走 wrapper /shutdownwrapper 协调 graceful 关闭 + 不重启)
118
- // healthPort 从 start 时持久化的状态文件读,避免 CLI 覆盖不一致。
309
+ printHeader(`stopping instance "${name}" (rpc :${cfg.rpcPort})`);
310
+ // 1) 优先走 wrapper /shutdown(wrapper 协调 graceful 关闭 + 不重启)
119
311
  let viaWrapper = false;
120
312
  try {
121
313
  const resp = await fetch(`http://127.0.0.1:${healthPort}/shutdown`, { method: "POST" });
@@ -125,7 +317,7 @@ async function run() {
125
317
  }
126
318
  }
127
319
  catch {
128
- /* wrapper 不在,回落到直接关 browserd */
320
+ /* wrapper 不在,回落到直接关 browserd */
129
321
  }
130
322
  // 2) wrapper 不在 → 直接发 close 给 browserd
131
323
  if (!viaWrapper) {
@@ -138,14 +330,13 @@ async function run() {
138
330
  console.error(` [WARN] direct close failed: ${e.message}`);
139
331
  }
140
332
  }
141
- // 3) 等 browserd 退出(最多 ~12s
333
+ // 3) 等 browserd 退出(最多 ~12s)
142
334
  const gone = await waitForExit(cfg.rpcPort, 12000);
143
335
  console.log(` browserd ${gone ? "stopped" : "still alive (may be restarting or stuck)"}`);
144
336
  // 4) 清理可能残留的状态文件
145
337
  try {
146
- if (existsSync(serviceStatePath(cfg.rpcPort))) {
147
- const { unlinkSync } = await import("node:fs");
148
- unlinkSync(serviceStatePath(cfg.rpcPort));
338
+ if (existsSync(serviceStatePath(cfg))) {
339
+ unlinkSyncSafe(serviceStatePath(cfg));
149
340
  console.log(` removed stale state file`);
150
341
  }
151
342
  }
@@ -154,30 +345,110 @@ async function run() {
154
345
  }
155
346
  console.log(` done. (Chrome closed gracefully, login state preserved)`);
156
347
  });
348
+ // ─── status ─────────────────────────────────────────────────────────────
349
+ program
350
+ .command("status <name>")
351
+ .description("查实例运行状态(走 wrapper /health,含 browserd/chrome pid)")
352
+ .action(async (name, opts) => {
353
+ let cfg;
354
+ try {
355
+ const handle = findProjectConfig(process.cwd());
356
+ cfg = loadInstance(name, handle, optsToOverrides(opts));
357
+ }
358
+ catch (e) {
359
+ handleConfigError(e);
360
+ return;
361
+ }
362
+ const state = readServiceState(cfg);
363
+ const healthPort = state?.healthPort ?? cfg.healthPort;
364
+ printHeader(`status of instance "${name}"`);
365
+ try {
366
+ const resp = await fetch(`http://127.0.0.1:${healthPort}/health`, {
367
+ signal: AbortSignal.timeout(2000),
368
+ });
369
+ if (!resp.ok)
370
+ throw new Error(`HTTP ${resp.status}`);
371
+ const s = (await resp.json());
372
+ // 校验响应来自 state 记录的同一进程,否则是僵尸/被占用
373
+ if (state && typeof s.wrapperPid === "number" && s.wrapperPid !== state.pid) {
374
+ console.log(` [STALE] health 端口 ${healthPort} 响应了,但 wrapperPid=${s.wrapperPid} ≠ state.pid=${state.pid}`);
375
+ console.log(` 说明:state 记录的 wrapper(pid=${state.pid})已死,端口被别的进程占用(僵尸或冲突)。`);
376
+ console.log(` 修复:确认无重要进程后,手动清理端口占用 + 删 state 文件,再 start。`);
377
+ process.exit(1);
378
+ }
379
+ console.log(JSON.stringify(s, null, 2));
380
+ }
381
+ catch (e) {
382
+ console.log(` not running (${e.message})`);
383
+ if (state)
384
+ console.log(` (stale state file: pid=${state.pid}, started ${state.startedAt})`);
385
+ process.exit(1);
386
+ }
387
+ });
157
388
  // ─── open ───────────────────────────────────────────────────────────────
158
389
  program
159
- .command("open")
160
- .description("连接已运行的服务,打开/复用浏览器并导航到 URL")
390
+ .command("open <name>")
391
+ .description("连接已运行实例,打开/复用浏览器并导航到 URL")
161
392
  .requiredOption("-u, --url <url>", "要打开的 URL")
162
- .option("-p, --port <port>", "browserd RPC 端口(默认 17322)", (v) => Number(v))
163
- .option("--headless", "无头模式(仅 CDP 路生效)")
164
- .action(async (opts) => {
165
- const client = makeClient(opts.port);
393
+ .option("--headless", "无头模式( CDP 路生效)")
394
+ .option("--route-mode <mode>", "路由模式(auto|cdp|extension)。高危平台用 extension")
395
+ .action(async (name, opts) => {
396
+ let cfg;
397
+ try {
398
+ const handle = findProjectConfig(process.cwd());
399
+ cfg = loadInstance(name, handle, optsToOverrides(opts));
400
+ }
401
+ catch (e) {
402
+ handleConfigError(e);
403
+ return;
404
+ }
405
+ const client = new BrowserClient({ port: cfg.rpcPort });
166
406
  const h = await client.health();
167
407
  if (!h) {
168
408
  console.error(`\n[mooncat-browser] browserd not reachable at ${client.baseUrl}`);
169
- console.error(` hint: run \`mooncat-browser start\` first`);
409
+ console.error(` hint: run \`mooncat-browser start ${name}\` first`);
170
410
  process.exit(1);
171
411
  }
172
412
  if (!h.open && !h.browserOpen) {
173
- await client.open({ headless: !!opts.headless });
174
- console.log(` browser opened`);
413
+ // routeMode:CLI 显式给 > 实例配置 routeMode > 不传(browsed auto 默认)
414
+ const openOpts = { headless: !!opts.headless };
415
+ const rm = opts.routeMode || cfg.routeMode;
416
+ if (rm)
417
+ openOpts.routeMode = rm;
418
+ await client.open(openOpts);
419
+ console.log(` browser opened${rm ? ` (routeMode=${rm})` : ""}`);
175
420
  }
176
421
  const tab = await client.newTab({ url: opts.url });
177
422
  printHeader(`opened`);
178
- console.log(` url : ${opts.url}`);
179
- console.log(` pageHandle : ${JSON.stringify(tab.pageHandle)}`);
180
- console.log(` reused : ${tab.reused ?? false}`);
423
+ console.log(` instance : ${name}`);
424
+ console.log(` url : ${opts.url}`);
425
+ console.log(` pageHandle : ${JSON.stringify(tab.pageHandle)}`);
426
+ console.log(` reused : ${tab.reused ?? false}`);
427
+ });
428
+ // ─── list ───────────────────────────────────────────────────────────────
429
+ program
430
+ .command("list")
431
+ .description("列出项目配置里所有实例 + 运行状态")
432
+ .action(async () => {
433
+ let handle;
434
+ try {
435
+ handle = findProjectConfig(process.cwd());
436
+ }
437
+ catch (e) {
438
+ handleConfigError(e);
439
+ return;
440
+ }
441
+ const names = listInstanceNames(handle);
442
+ printHeader(`instances in ${handle.configPath}`);
443
+ if (!names.length) {
444
+ console.log(` (no instances defined)`);
445
+ return;
446
+ }
447
+ for (const n of names) {
448
+ const cfg = loadInstance(n, handle);
449
+ const flag = await realInstanceStatus(cfg);
450
+ console.log(` ${n.padEnd(16)} rpc:${cfg.rpcPort} health:${cfg.healthPort} [${flag}]`);
451
+ }
181
452
  });
182
453
  // ─── install-extension ──────────────────────────────────────────────────
183
454
  program
@@ -196,15 +467,16 @@ async function run() {
196
467
  console.log(` 1. enable "Developer mode" (top right)`);
197
468
  console.log(` 2. click "Load unpacked"`);
198
469
  console.log(` 3. select the directory above`);
199
- console.log(`\n 装扩展后,重启浏览器(close + open)才会切到 extension 路由。`);
470
+ console.log(`\n 装扩展后,重启浏览器(close + open)才会切到 extension 路由。`);
471
+ console.log(` 注意:第一版 extension 路是单实例(WS 端口固定)。多实例请走 CDP 路。`);
200
472
  });
201
473
  // ─── skills ─────────────────────────────────────────────────────────────
202
474
  program
203
475
  .command("skills")
204
- .description("安装/更新内置 skills ~/.agents/skills(覆盖同名,幂等可重复执行)")
205
- .option("-s, --skill <name>", "只安装指定 skill(默认全部)")
206
- .option("-t, --target <dir>", "自定义目标目录(默认 ~/.agents/skills")
207
- .option("--list", "只列出包内的 skills,不拷贝")
476
+ .description("安装/更新内置 skills 到项目本地 .agents/skills(覆盖同名,幂等可重复执行)")
477
+ .option("-s, --skill <name>", "只安装指定 skill(默认全部)")
478
+ .option("-t, --target <dir>", "自定义目标目录(默认 .agents/skills)")
479
+ .option("--list", "只列出包内的 skills,不拷贝")
208
480
  .action((opts) => {
209
481
  const srcRoot = skillsSourceDir();
210
482
  if (!existsSync(srcRoot)) {
@@ -212,7 +484,6 @@ async function run() {
212
484
  console.error(` hint: 包发布时可能漏了 skills/ 目录`);
213
485
  process.exit(1);
214
486
  }
215
- // 列出源里的 skill 子目录(每个含 SKILL.md)
216
487
  const names = readdirSync(srcRoot, { withFileTypes: true })
217
488
  .filter((d) => d.isDirectory())
218
489
  .filter((d) => existsSync(join(srcRoot, d.name, "SKILL.md")))
@@ -236,7 +507,6 @@ async function run() {
236
507
  for (const n of requested) {
237
508
  const src = join(srcRoot, n);
238
509
  const dest = join(destRoot, n);
239
- // 先清空目标 skill 目录再拷贝,保证与源完全一致(删除源里已移除的旧文件,如重命名后的残留)
240
510
  rmSync(dest, { recursive: true, force: true });
241
511
  cpSync(src, dest, { recursive: true, force: true });
242
512
  console.log(` [ok] ${n} -> ${dest}`);
@@ -245,17 +515,25 @@ async function run() {
245
515
  });
246
516
  await program.parseAsync(process.argv);
247
517
  }
248
- /** 包内 skills 源目录:dist/cli.js -> ../skills;源码运行 -> ../../skills。 */
518
+ /** 安全 unlink(独立函数,方便 stop import 前已可用)。 */
519
+ function unlinkSyncSafe(p) {
520
+ try {
521
+ rmSync(p, { force: true });
522
+ }
523
+ catch {
524
+ /* ignore */
525
+ }
526
+ }
527
+ /** 包内 skills 源目录:dist/cli.js -> ../skills;源码运行 -> ../../skills。 */
249
528
  function skillsSourceDir() {
250
529
  return process.env.MOONCAT_BROWSER_SKILLS_SRC || join(__dirname, "..", "skills");
251
530
  }
252
- /** skills 安装默认目标:~/.agents/skills(跨平台 homedir())。 */
531
+ /** skills 安装默认目标:项目本地 .agents/skills(cwd )
532
+ * 与 mooncat-browser "项目级、不写全局" 的定位一致——skills 跟着项目走,不污染全局。 */
253
533
  function defaultSkillsTarget() {
254
- return join(homedir(), ".agents", "skills");
534
+ return join(process.cwd(), ".agents", "skills");
255
535
  }
256
536
  function getVersion() {
257
- // 编译后 dist/cli.js 读 ../package.json;源码运行时读 ../../package.json
258
- // 统一用相对包根查找(容忍 dist/ 与 src/ 两种位置)
259
537
  const candidates = [
260
538
  join(__dirname, "..", "package.json"),
261
539
  join(__dirname, "..", "..", "package.json"),