@jackwener/opencli 0.7.4 → 0.7.6

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/README.md CHANGED
@@ -48,7 +48,7 @@ OpenCLI connects to your browser through the Playwright MCP Bridge extension.
48
48
  ### Playwright MCP Bridge Extension Setup
49
49
 
50
50
  1. Install **[Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm)** extension in Chrome.
51
- 2. Run `opencli setup` — it auto-discovers your token and lets you choose which tools to configure:
51
+ 2. Run `opencli setup` — discovers the token, distributes it to your tools, and verifies connectivity:
52
52
 
53
53
  ```bash
54
54
  opencli setup
@@ -58,6 +58,15 @@ The interactive TUI will:
58
58
  - 🔍 Auto-discover `PLAYWRIGHT_MCP_EXTENSION_TOKEN` from Chrome (no manual copy needed)
59
59
  - ☑️ Show all detected tools (Codex, Cursor, Claude Code, Gemini CLI, etc.)
60
60
  - ✏️ Update only the files you select (Space to toggle, Enter to confirm)
61
+ - 🔌 Auto-verify browser connectivity after writing configs
62
+
63
+ > **Tip**: Use `opencli doctor` for ongoing diagnosis and maintenance:
64
+ > ```bash
65
+ > opencli doctor # Read-only token & config diagnosis
66
+ > opencli doctor --live # Also test live browser connectivity
67
+ > opencli doctor --fix # Fix mismatched configs (interactive)
68
+ > opencli doctor --fix -y # Fix all configs non-interactively
69
+ > ```
61
70
 
62
71
  <details>
63
72
  <summary>Manual setup (alternative)</summary>
@@ -86,14 +95,6 @@ export PLAYWRIGHT_MCP_EXTENSION_TOKEN="<your-token-here>"
86
95
 
87
96
  </details>
88
97
 
89
- Verify with `opencli doctor` — shows colored status for extension install, token consistency, and all config locations:
90
-
91
- ```bash
92
- opencli doctor # Token & config diagnosis
93
- opencli doctor --live # Also test live browser connectivity
94
- opencli doctor --fix -y # Auto-fix all mismatched configs
95
- ```
96
-
97
98
  ## Quick Start
98
99
 
99
100
  ### Install via npm (recommended)
package/README.zh-CN.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # OpenCLI
2
2
 
3
3
  > **把任何网站变成你的命令行工具。**
4
- > 零风控 · 复用 Chrome 登录 · AI 自动发现接口
4
+ > 零风控 · 复用 Chrome 登录 · AI 自动发现接口 · 80+ 命令 · 19 站点
5
5
 
6
6
  [English](./README.md)
7
7
 
@@ -9,7 +9,7 @@
9
9
  [![Node.js Version](https://img.shields.io/node/v/@jackwener/opencli?style=flat-square)](https://nodejs.org)
10
10
  [![License](https://img.shields.io/npm/l/@jackwener/opencli?style=flat-square)](./LICENSE)
11
11
 
12
- OpenCLI 将任何网站变成命令行工具 — B站、知乎、小红书、Twitter、Reddit 等众多站点 — 复用浏览器登录态,AI 驱动探索。
12
+ OpenCLI 将任何网站变成命令行工具 — B站、知乎、小红书、Twitter/X、Reddit、YouTube [19 个站点](#内置命令) — 复用浏览器登录态,AI 驱动探索。
13
13
 
14
14
  ---
15
15
 
@@ -29,8 +29,9 @@ OpenCLI 将任何网站变成命令行工具 — B站、知乎、小红书、Twi
29
29
 
30
30
  ## 亮点
31
31
 
32
- - **多站点覆盖** — B站、知乎、小红书、Twitter、Reddit 等众多站点
32
+ - **多站点覆盖** — B站、知乎、小红书、Twitter、Reddit 等 19 个站点,80+ 命令
33
33
  - **零风控** — 复用 Chrome 登录态,无需存储任何凭证
34
+ - **自修复配置** — `opencli setup` 自动发现 Token;`opencli doctor` 诊断 10+ 工具配置;`--fix` 一键修复
34
35
  - **AI 原生** — `explore` 自动发现 API,`synthesize` 生成适配器,`cascade` 探测认证策略
35
36
  - **动态加载引擎** — 声明式的 `.yaml` 或者底层定制的 `.ts` 适配器,放入 `clis/` 文件夹即可自动注册生效
36
37
 
@@ -46,7 +47,7 @@ OpenCLI 通过 Playwright MCP Bridge 扩展与你的浏览器通信。
46
47
  ### Playwright MCP Bridge 扩展配置
47
48
 
48
49
  1. 安装 **[Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm)** 扩展
49
- 2. 运行 `opencli setup` — 自动发现 Token 并让你选择要配置哪些工具:
50
+ 2. 运行 `opencli setup` — 自动发现 Token、分发到各工具、验证连通性:
50
51
 
51
52
  ```bash
52
53
  opencli setup
@@ -56,6 +57,15 @@ opencli setup
56
57
  - 🔍 从 Chrome 自动发现 `PLAYWRIGHT_MCP_EXTENSION_TOKEN`(无需手动复制)
57
58
  - ☑️ 显示所有支持的工具(Codex、Cursor、Claude Code、Gemini CLI 等)
58
59
  - ✏️ 只更新你选中的文件(空格切换,回车确认)
60
+ - 🔌 完成后自动验证浏览器连通性
61
+
62
+ > **Tip**:后续诊断和维护用 `opencli doctor`:
63
+ > ```bash
64
+ > opencli doctor # 只读 Token 与配置诊断
65
+ > opencli doctor --live # 额外测试浏览器连通性
66
+ > opencli doctor --fix # 修复不一致的配置(交互确认)
67
+ > opencli doctor --fix -y # 无交互直接修复所有配置
68
+ > ```
59
69
 
60
70
  <details>
61
71
  <summary>手动配置(备选方案)</summary>
@@ -84,12 +94,6 @@ export PLAYWRIGHT_MCP_EXTENSION_TOKEN="<你的-token>"
84
94
 
85
95
  </details>
86
96
 
87
- 配置后运行 `opencli doctor` 检查所有位置的 Token 状态:
88
-
89
- ```bash
90
- opencli doctor
91
- ```
92
-
93
97
  ## 快速开始
94
98
 
95
99
  ### npm 全局安装(推荐)
@@ -129,27 +133,29 @@ npm install -g @jackwener/opencli@latest
129
133
 
130
134
  ## 内置命令
131
135
 
132
- | 站点 | 命令 | 模式 |
133
- |------|------|------|
134
- | **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` | 🔐 浏览器 |
135
- | **zhihu** | `hot` `search` `question` | 🔐 浏览器 |
136
- | **xiaohongshu** | `search` `notifications` `feed` `user` | 🔐 浏览器 |
137
- | **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 🔐 浏览器 |
138
- | **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` | 🔐 浏览器 |
139
- | **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 🔐 浏览器 |
140
- | **weibo** | `hot` | 🔐 浏览器 |
141
- | **boss** | `search` `detail` | 🔐 浏览器 |
142
- | **coupang** | `search` `add-to-cart` | 🔐 浏览器 |
143
- | **youtube** | `search` | 🔐 浏览器 |
144
- | **linkedin** | `search` | 🔐 浏览器 |
145
- | **yahoo-finance** | `quote` | 🔐 浏览器 |
146
- | **reuters** | `search` | 🔐 浏览器 |
147
- | **smzdm** | `search` | 🔐 浏览器 |
148
- | **ctrip** | `search` | 🔐 浏览器 |
149
- | **github** | `search` | 🌐 公共 API |
150
- | **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 🌐 公共 API / 🔐 浏览器 |
151
- | **hackernews** | `top` | 🌐 公共 API |
152
- | **bbc** | `news` | 🌐 公共 API |
136
+ **19 个站点 · 80+ 命令** 运行 `opencli list` 查看完整注册表。
137
+
138
+ | 站点 | 命令 | 数量 | 模式 |
139
+ |------|------|:----:|------|
140
+ | **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` | 18 | 🔐 浏览器 |
141
+ | **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 15 | 🔐 浏览器 |
142
+ | **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` | 11 | 🔐 浏览器 |
143
+ | **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 6 | 🌐 / 🔐 |
144
+ | **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 6 | 🔐 浏览器 |
145
+ | **xiaohongshu** | `search` `notifications` `feed` `me` `user` | 5 | 🔐 浏览器 |
146
+ | **youtube** | `search` `video` `transcript` | 3 | 🔐 浏览器 |
147
+ | **zhihu** | `hot` `search` `question` | 3 | 🔐 浏览器 |
148
+ | **boss** | `search` `detail` | 2 | 🔐 浏览器 |
149
+ | **coupang** | `search` `add-to-cart` | 2 | 🔐 浏览器 |
150
+ | **bbc** | `news` | 1 | 🌐 公共 API |
151
+ | **ctrip** | `search` | 1 | 🔐 浏览器 |
152
+ | **github** | `search` | 1 | 🌐 公共 API |
153
+ | **hackernews** | `top` | 1 | 🌐 公共 API |
154
+ | **linkedin** | `search` | 1 | 🔐 浏览器 |
155
+ | **reuters** | `search` | 1 | 🔐 浏览器 |
156
+ | **smzdm** | `search` | 1 | 🔐 浏览器 |
157
+ | **weibo** | `hot` | 1 | 🔐 浏览器 |
158
+ | **yahoo-finance** | `quote` | 1 | 🔐 浏览器 |
153
159
 
154
160
  ## 输出格式
155
161
 
@@ -201,6 +207,7 @@ opencli cascade https://api.example.com/data
201
207
  - 确保 Node.js 版本 `>= 18`。旧版不支持我们使用的现代核心库 API。
202
208
  - **Token 问题**
203
209
  - 运行 `opencli doctor` 诊断所有工具的 Token 配置状态。
210
+ - 使用 `opencli doctor --live` 测试浏览器连通性。
204
211
 
205
212
  ## 版本发布
206
213
 
package/SKILL.md CHANGED
@@ -164,7 +164,8 @@ opencli validate bilibili # Validate specific site
164
164
  opencli setup # Interactive token setup (auto-discover + TUI checkbox)
165
165
  opencli doctor # Diagnose token & extension config across all tools
166
166
  opencli doctor --live # Also test live browser connectivity
167
- opencli doctor --fix -y # Auto-fix all config files (non-interactive)
167
+ opencli doctor --fix # Fix mismatched configs (interactive confirmation)
168
+ opencli doctor --fix -y # Fix all configs non-interactively
168
169
  ```
169
170
 
170
171
  ### AI Agent Workflow
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Shell tab-completion support for opencli.
3
+ *
4
+ * Provides:
5
+ * - Shell script generators for bash, zsh, and fish
6
+ * - Dynamic completion logic that returns candidates for the current cursor position
7
+ */
8
+ /**
9
+ * Return completion candidates given the current command-line words and cursor index.
10
+ *
11
+ * @param words - The argv after 'opencli' (words[0] is the first arg, e.g. site name)
12
+ * @param cursor - 1-based position of the word being completed (1 = first arg)
13
+ */
14
+ export declare function getCompletions(words: string[], cursor: number): string[];
15
+ export declare function bashCompletionScript(): string;
16
+ export declare function zshCompletionScript(): string;
17
+ export declare function fishCompletionScript(): string;
18
+ /**
19
+ * Print the completion script for the requested shell.
20
+ */
21
+ export declare function printCompletionScript(shell: string): void;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Shell tab-completion support for opencli.
3
+ *
4
+ * Provides:
5
+ * - Shell script generators for bash, zsh, and fish
6
+ * - Dynamic completion logic that returns candidates for the current cursor position
7
+ */
8
+ import { getRegistry } from './registry.js';
9
+ // ── Dynamic completion logic ───────────────────────────────────────────────
10
+ /**
11
+ * Built-in (non-dynamic) top-level commands.
12
+ */
13
+ const BUILTIN_COMMANDS = [
14
+ 'list',
15
+ 'validate',
16
+ 'verify',
17
+ 'explore',
18
+ 'probe', // alias for explore
19
+ 'synthesize',
20
+ 'generate',
21
+ 'cascade',
22
+ 'doctor',
23
+ 'setup',
24
+ 'completion',
25
+ ];
26
+ /**
27
+ * Return completion candidates given the current command-line words and cursor index.
28
+ *
29
+ * @param words - The argv after 'opencli' (words[0] is the first arg, e.g. site name)
30
+ * @param cursor - 1-based position of the word being completed (1 = first arg)
31
+ */
32
+ export function getCompletions(words, cursor) {
33
+ // cursor === 1 → completing the first argument (site name or built-in command)
34
+ if (cursor <= 1) {
35
+ const sites = new Set();
36
+ for (const [, cmd] of getRegistry()) {
37
+ sites.add(cmd.site);
38
+ }
39
+ return [...BUILTIN_COMMANDS, ...sites].sort();
40
+ }
41
+ const site = words[0];
42
+ // If the first word is a built-in command, no further completion
43
+ if (BUILTIN_COMMANDS.includes(site)) {
44
+ return [];
45
+ }
46
+ // cursor === 2 → completing the sub-command name under a site
47
+ if (cursor === 2) {
48
+ const subcommands = [];
49
+ for (const [, cmd] of getRegistry()) {
50
+ if (cmd.site === site) {
51
+ subcommands.push(cmd.name);
52
+ }
53
+ }
54
+ return subcommands.sort();
55
+ }
56
+ // cursor >= 3 → no further completion
57
+ return [];
58
+ }
59
+ // ── Shell script generators ────────────────────────────────────────────────
60
+ export function bashCompletionScript() {
61
+ return `# Bash completion for opencli
62
+ # Add to ~/.bashrc: eval "$(opencli completion bash)"
63
+ _opencli_completions() {
64
+ local cur words cword
65
+ _get_comp_words_by_ref -n : cur words cword
66
+
67
+ local completions
68
+ completions=$(opencli --get-completions --cursor "$cword" "\${words[@]:1}" 2>/dev/null)
69
+
70
+ COMPREPLY=( $(compgen -W "$completions" -- "$cur") )
71
+ __ltrim_colon_completions "$cur"
72
+ }
73
+ complete -F _opencli_completions opencli
74
+ `;
75
+ }
76
+ export function zshCompletionScript() {
77
+ return `# Zsh completion for opencli
78
+ # Add to ~/.zshrc: eval "$(opencli completion zsh)"
79
+ _opencli() {
80
+ local -a completions
81
+ local cword=$((CURRENT - 1))
82
+ completions=(\${(f)"$(opencli --get-completions --cursor "$cword" "\${words[@]:1}" 2>/dev/null)"})
83
+ compadd -a completions
84
+ }
85
+ compdef _opencli opencli
86
+ `;
87
+ }
88
+ export function fishCompletionScript() {
89
+ return `# Fish completion for opencli
90
+ # Add to ~/.config/fish/config.fish: opencli completion fish | source
91
+ complete -c opencli -f -a '(
92
+ set -l tokens (commandline -cop)
93
+ set -l cursor (count (commandline -cop))
94
+ opencli --get-completions --cursor $cursor $tokens[2..] 2>/dev/null
95
+ )'
96
+ `;
97
+ }
98
+ /**
99
+ * Print the completion script for the requested shell.
100
+ */
101
+ export function printCompletionScript(shell) {
102
+ switch (shell) {
103
+ case 'bash':
104
+ process.stdout.write(bashCompletionScript());
105
+ break;
106
+ case 'zsh':
107
+ process.stdout.write(zshCompletionScript());
108
+ break;
109
+ case 'fish':
110
+ process.stdout.write(fishCompletionScript());
111
+ break;
112
+ default:
113
+ console.error(`Unsupported shell: ${shell}. Supported: bash, zsh, fish`);
114
+ process.exitCode = 1;
115
+ }
116
+ }
package/dist/doctor.d.ts CHANGED
@@ -50,8 +50,8 @@ export declare function toolName(p: string): string;
50
50
  export declare function getDefaultShellRcPath(): string;
51
51
  export declare function getDefaultMcpConfigPaths(cwd?: string): string[];
52
52
  export declare function readTokenFromShellContent(content: string): string | null;
53
- export declare function upsertShellToken(content: string, token: string): string;
54
- export declare function upsertJsonConfigToken(content: string, token: string): string;
53
+ export declare function upsertShellToken(content: string, token: string, filePath?: string): string;
54
+ export declare function upsertJsonConfigToken(content: string, token: string, filePath?: string): string;
55
55
  export declare function readTomlConfigToken(content: string): string | null;
56
56
  export declare function upsertTomlConfigToken(content: string, token: string): string;
57
57
  export declare function fileExists(filePath: string): boolean;
package/dist/doctor.js CHANGED
@@ -60,6 +60,13 @@ export function getDefaultShellRcPath() {
60
60
  return path.join(os.homedir(), '.config', 'fish', 'config.fish');
61
61
  return path.join(os.homedir(), '.zshrc');
62
62
  }
63
+ function isFishConfig(filePath) {
64
+ return filePath.endsWith('config.fish') || filePath.includes('/fish/');
65
+ }
66
+ /** Detect if a JSON config file uses OpenCode's `mcp` format vs standard `mcpServers` */
67
+ function isOpenCodeConfig(filePath) {
68
+ return filePath.includes('opencode');
69
+ }
63
70
  export function getDefaultMcpConfigPaths(cwd = process.cwd()) {
64
71
  const home = os.homedir();
65
72
  const candidates = [
@@ -83,7 +90,17 @@ export function readTokenFromShellContent(content) {
83
90
  const m = content.match(TOKEN_LINE_RE);
84
91
  return m?.[3] ?? null;
85
92
  }
86
- export function upsertShellToken(content, token) {
93
+ export function upsertShellToken(content, token, filePath) {
94
+ if (filePath && isFishConfig(filePath)) {
95
+ // Fish shell uses `set -gx` instead of `export`
96
+ const fishLine = `set -gx ${PLAYWRIGHT_TOKEN_ENV} "${token}"`;
97
+ const fishRe = /^\s*set\s+(-gx\s+)?PLAYWRIGHT_MCP_EXTENSION_TOKEN\s+.*/m;
98
+ if (!content.trim())
99
+ return `${fishLine}\n`;
100
+ if (fishRe.test(content))
101
+ return content.replace(fishRe, fishLine);
102
+ return `${content.replace(/\s*$/, '')}\n${fishLine}\n`;
103
+ }
87
104
  const nextLine = `export ${PLAYWRIGHT_TOKEN_ENV}="${token}"`;
88
105
  if (!content.trim())
89
106
  return `${nextLine}\n`;
@@ -109,17 +126,14 @@ function readTokenFromJsonObject(parsed) {
109
126
  return opencode;
110
127
  return null;
111
128
  }
112
- export function upsertJsonConfigToken(content, token) {
129
+ export function upsertJsonConfigToken(content, token, filePath) {
113
130
  const parsed = content.trim() ? JSON.parse(content) : {};
114
- if (parsed?.mcpServers) {
115
- parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] ?? {
116
- command: 'npx',
117
- args: ['-y', '@playwright/mcp@latest', '--extension'],
118
- };
119
- parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env ?? {};
120
- parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token;
121
- }
122
- else {
131
+ // Determine format: use OpenCode format only if explicitly an opencode config,
132
+ // or if the existing content already uses `mcp` key (not `mcpServers`)
133
+ const useOpenCodeFormat = filePath
134
+ ? isOpenCodeConfig(filePath)
135
+ : (!parsed.mcpServers && parsed.mcp);
136
+ if (useOpenCodeFormat) {
123
137
  parsed.mcp = parsed.mcp ?? {};
124
138
  parsed.mcp[PLAYWRIGHT_SERVER_NAME] = parsed.mcp[PLAYWRIGHT_SERVER_NAME] ?? {
125
139
  command: ['npx', '-y', '@playwright/mcp@latest', '--extension'],
@@ -129,6 +143,15 @@ export function upsertJsonConfigToken(content, token) {
129
143
  parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment = parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment ?? {};
130
144
  parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment[PLAYWRIGHT_TOKEN_ENV] = token;
131
145
  }
146
+ else {
147
+ parsed.mcpServers = parsed.mcpServers ?? {};
148
+ parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] ?? {
149
+ command: 'npx',
150
+ args: ['-y', '@playwright/mcp@latest', '--extension'],
151
+ };
152
+ parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env ?? {};
153
+ parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token;
154
+ }
132
155
  return `${JSON.stringify(parsed, null, 2)}\n`;
133
156
  }
134
157
  export function readTomlConfigToken(content) {
@@ -206,6 +229,28 @@ function readConfigStatus(filePath) {
206
229
  };
207
230
  }
208
231
  }
232
+ /**
233
+ * Dynamically enumerate Chrome profiles by scanning for 'Default' and 'Profile *'
234
+ * directories across all browser base paths. Falls back to ['Default'] if none found.
235
+ */
236
+ function enumerateProfiles(baseDirs) {
237
+ const profiles = new Set();
238
+ for (const base of baseDirs) {
239
+ if (!fileExists(base))
240
+ continue;
241
+ try {
242
+ for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
243
+ if (!entry.isDirectory())
244
+ continue;
245
+ if (entry.name === 'Default' || /^Profile \d+$/.test(entry.name)) {
246
+ profiles.add(entry.name);
247
+ }
248
+ }
249
+ }
250
+ catch { /* permission denied, etc. */ }
251
+ }
252
+ return profiles.size > 0 ? [...profiles].sort() : ['Default'];
253
+ }
209
254
  /**
210
255
  * Discover the auth token stored by the Playwright MCP Bridge extension
211
256
  * by scanning Chrome's LevelDB localStorage files directly.
@@ -230,7 +275,7 @@ export function discoverExtensionToken() {
230
275
  const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
231
276
  bases.push(path.join(appData, 'Google', 'Chrome', 'User Data'), path.join(appData, 'Microsoft', 'Edge', 'User Data'));
232
277
  }
233
- const profiles = ['Default', 'Profile 1', 'Profile 2', 'Profile 3'];
278
+ const profiles = enumerateProfiles(bases);
234
279
  const tokenRe = /([A-Za-z0-9_-]{40,50})/;
235
280
  for (const base of bases) {
236
281
  for (const profile of profiles) {
@@ -352,7 +397,7 @@ export function checkExtensionInstalled() {
352
397
  const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
353
398
  browserDirs.push({ name: 'Chrome', base: path.join(appData, 'Google', 'Chrome', 'User Data') }, { name: 'Edge', base: path.join(appData, 'Microsoft', 'Edge', 'User Data') });
354
399
  }
355
- const profiles = ['Default', 'Profile 1', 'Profile 2', 'Profile 3'];
400
+ const profiles = enumerateProfiles(browserDirs.map(d => d.base));
356
401
  const foundBrowsers = [];
357
402
  for (const { name, base } of browserDirs) {
358
403
  for (const profile of profiles) {
@@ -564,7 +609,7 @@ export async function applyBrowserDoctorFix(report, opts = {}) {
564
609
  const written = [];
565
610
  if (plannedWrites.includes(shellPath)) {
566
611
  const shellBefore = fileExists(shellPath) ? fs.readFileSync(shellPath, 'utf-8') : '';
567
- writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token));
612
+ writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token, shellPath));
568
613
  written.push(shellPath);
569
614
  }
570
615
  for (const config of report.configs) {
@@ -573,7 +618,9 @@ export async function applyBrowserDoctorFix(report, opts = {}) {
573
618
  if (config.parseError)
574
619
  continue;
575
620
  const before = fileExists(config.path) ? fs.readFileSync(config.path, 'utf-8') : '';
576
- const next = config.format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token);
621
+ const next = config.format === 'toml'
622
+ ? upsertTomlConfigToken(before, token)
623
+ : upsertJsonConfigToken(before, token, config.path);
577
624
  writeFileWithMkdir(config.path, next);
578
625
  written.push(config.path);
579
626
  }
@@ -68,6 +68,47 @@ describe('json token helpers', () => {
68
68
  const parsed = JSON.parse(next);
69
69
  expect(parsed.mcp.playwright.environment.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
70
70
  });
71
+ it('creates standard mcpServers format for empty file (not OpenCode)', () => {
72
+ const next = upsertJsonConfigToken('', 'abc123');
73
+ const parsed = JSON.parse(next);
74
+ expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
75
+ expect(parsed.mcp).toBeUndefined();
76
+ });
77
+ it('creates OpenCode format when filePath contains opencode', () => {
78
+ const next = upsertJsonConfigToken('', 'abc123', '/home/user/.config/opencode/opencode.json');
79
+ const parsed = JSON.parse(next);
80
+ expect(parsed.mcp.playwright.environment.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
81
+ expect(parsed.mcpServers).toBeUndefined();
82
+ });
83
+ it('creates standard format when filePath is claude.json', () => {
84
+ const next = upsertJsonConfigToken('', 'abc123', '/home/user/.claude.json');
85
+ const parsed = JSON.parse(next);
86
+ expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
87
+ });
88
+ });
89
+ describe('fish shell support', () => {
90
+ it('generates fish set -gx syntax for fish config path', () => {
91
+ const next = upsertShellToken('', 'abc123', '/home/user/.config/fish/config.fish');
92
+ expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "abc123"');
93
+ expect(next).not.toContain('export');
94
+ });
95
+ it('replaces existing fish set line', () => {
96
+ const content = 'set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "old"\n';
97
+ const next = upsertShellToken(content, 'new', '/home/user/.config/fish/config.fish');
98
+ expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "new"');
99
+ expect(next).not.toContain('"old"');
100
+ });
101
+ it('appends fish syntax to existing fish config', () => {
102
+ const content = 'set -gx PATH /usr/bin\n';
103
+ const next = upsertShellToken(content, 'abc123', '/home/user/.config/fish/config.fish');
104
+ expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "abc123"');
105
+ expect(next).toContain('set -gx PATH /usr/bin');
106
+ });
107
+ it('uses export syntax for zshrc even with filePath', () => {
108
+ const next = upsertShellToken('', 'abc123', '/home/user/.zshrc');
109
+ expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"');
110
+ expect(next).not.toContain('set -gx');
111
+ });
71
112
  });
72
113
  describe('doctor report rendering', () => {
73
114
  const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
package/dist/main.js CHANGED
@@ -13,11 +13,34 @@ import { render as renderOutput } from './output.js';
13
13
  import { PlaywrightMCP } from './browser.js';
14
14
  import { browserSession, DEFAULT_BROWSER_COMMAND_TIMEOUT, runWithTimeout } from './runtime.js';
15
15
  import { PKG_VERSION } from './version.js';
16
+ import { getCompletions, printCompletionScript } from './completion.js';
16
17
  const __filename = fileURLToPath(import.meta.url);
17
18
  const __dirname = path.dirname(__filename);
18
19
  const BUILTIN_CLIS = path.resolve(__dirname, 'clis');
19
20
  const USER_CLIS = path.join(os.homedir(), '.opencli', 'clis');
20
21
  await discoverClis(BUILTIN_CLIS, USER_CLIS);
22
+ // ── Fast-path: handle --get-completions before commander parses ─────────
23
+ // Usage: opencli --get-completions --cursor <N> [word1 word2 ...]
24
+ const getCompIdx = process.argv.indexOf('--get-completions');
25
+ if (getCompIdx !== -1) {
26
+ const rest = process.argv.slice(getCompIdx + 1);
27
+ let cursor;
28
+ const words = [];
29
+ for (let i = 0; i < rest.length; i++) {
30
+ if (rest[i] === '--cursor' && i + 1 < rest.length) {
31
+ cursor = parseInt(rest[i + 1], 10);
32
+ i++; // skip the value
33
+ }
34
+ else {
35
+ words.push(rest[i]);
36
+ }
37
+ }
38
+ if (cursor === undefined)
39
+ cursor = words.length;
40
+ const candidates = getCompletions(words, cursor);
41
+ process.stdout.write(candidates.join('\n') + '\n');
42
+ process.exit(0);
43
+ }
21
44
  const program = new Command();
22
45
  program.name('opencli').description('Make any website your CLI. Zero setup. AI-powered.').version(PKG_VERSION);
23
46
  // ── Built-in commands ──────────────────────────────────────────────────────
@@ -130,6 +153,12 @@ program.command('setup')
130
153
  const { runSetup } = await import('./setup.js');
131
154
  await runSetup({ cliVersion: PKG_VERSION, token: opts.token });
132
155
  });
156
+ program.command('completion')
157
+ .description('Output shell completion script')
158
+ .argument('<shell>', 'Shell type: bash, zsh, or fish')
159
+ .action((shell) => {
160
+ printCompletionScript(shell);
161
+ });
133
162
  // ── Dynamic site commands ──────────────────────────────────────────────────
134
163
  const registry = getRegistry();
135
164
  const siteGroups = new Map();
package/dist/setup.js CHANGED
@@ -8,7 +8,7 @@ import * as fs from 'node:fs';
8
8
  import chalk from 'chalk';
9
9
  import { createInterface } from 'node:readline/promises';
10
10
  import { stdin as input, stdout as output } from 'node:process';
11
- import { PLAYWRIGHT_TOKEN_ENV, discoverExtensionToken, fileExists, getDefaultShellRcPath, runBrowserDoctor, shortenPath, toolName, upsertJsonConfigToken, upsertShellToken, upsertTomlConfigToken, writeFileWithMkdir, } from './doctor.js';
11
+ import { PLAYWRIGHT_TOKEN_ENV, checkExtensionInstalled, checkTokenConnectivity, discoverExtensionToken, fileExists, getDefaultShellRcPath, runBrowserDoctor, shortenPath, toolName, upsertJsonConfigToken, upsertShellToken, upsertTomlConfigToken, writeFileWithMkdir, } from './doctor.js';
12
12
  import { getTokenFingerprint } from './browser.js';
13
13
  import { checkboxPrompt } from './tui.js';
14
14
  export async function runSetup(opts = {}) {
@@ -45,11 +45,24 @@ export async function runSetup(opts = {}) {
45
45
  chalk.dim(`(${getTokenFingerprint(token)})`));
46
46
  }
47
47
  if (!token) {
48
- console.log(` ${chalk.yellow('!')} No token found. Please enter it manually.`);
49
- console.log(chalk.dim(' (Find it in the Playwright MCP Bridge extension → Status page)'));
48
+ // Give precise diagnosis of why token scan failed
49
+ const extInstall = checkExtensionInstalled();
50
+ console.log(` ${chalk.red('✗')} Browser token scan failed\n`);
51
+ if (!extInstall.installed) {
52
+ console.log(chalk.dim(' Cause: Playwright MCP Bridge extension is not installed'));
53
+ console.log(chalk.dim(' Fix: Install from https://chromewebstore.google.com/detail/'));
54
+ console.log(chalk.dim(' playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm'));
55
+ }
56
+ else {
57
+ console.log(chalk.dim(` Cause: Extension is installed (${extInstall.browsers.join(', ')}) but token not found in LevelDB`));
58
+ console.log(chalk.dim(' Fix: 1) Open the extension popup and verify the token is generated'));
59
+ console.log(chalk.dim(' 2) Close Chrome completely, then re-run setup'));
60
+ }
61
+ console.log();
62
+ console.log(` You can enter the token manually, or fix the above and re-run ${chalk.bold('opencli setup')}.`);
50
63
  console.log();
51
64
  const rl = createInterface({ input, output });
52
- const answer = await rl.question(' Token: ');
65
+ const answer = await rl.question(' Token (press Enter to abort): ');
53
66
  rl.close();
54
67
  token = answer.trim();
55
68
  if (!token) {
@@ -105,7 +118,7 @@ export async function runSetup(opts = {}) {
105
118
  if (sel.startsWith('shell:')) {
106
119
  const p = sel.slice('shell:'.length);
107
120
  const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : '';
108
- writeFileWithMkdir(p, upsertShellToken(before, token));
121
+ writeFileWithMkdir(p, upsertShellToken(before, token, p));
109
122
  written.push(p);
110
123
  wroteShell = true;
111
124
  }
@@ -116,7 +129,7 @@ export async function runSetup(opts = {}) {
116
129
  continue;
117
130
  const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : '';
118
131
  const format = config?.format ?? (p.endsWith('.toml') ? 'toml' : 'json');
119
- const next = format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token);
132
+ const next = format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token, p);
120
133
  writeFileWithMkdir(p, next);
121
134
  written.push(p);
122
135
  }
@@ -138,6 +151,22 @@ export async function runSetup(opts = {}) {
138
151
  console.log(chalk.yellow(' No files were changed.'));
139
152
  }
140
153
  console.log();
154
+ // Step 7: Auto-verify browser connectivity
155
+ console.log(chalk.dim(' Verifying browser connectivity...'));
156
+ try {
157
+ const result = await checkTokenConnectivity({ timeout: 5 });
158
+ if (result.ok) {
159
+ console.log(` ${chalk.green('✓')} Browser connected in ${(result.durationMs / 1000).toFixed(1)}s`);
160
+ }
161
+ else {
162
+ console.log(` ${chalk.yellow('!')} Browser connectivity test failed: ${result.error ?? 'unknown'}`);
163
+ console.log(chalk.dim(' Make sure Chrome is running with the extension enabled.'));
164
+ }
165
+ }
166
+ catch {
167
+ console.log(` ${chalk.yellow('!')} Could not verify connectivity (Chrome may not be running)`);
168
+ }
169
+ console.log();
141
170
  }
142
171
  function padRight(s, n) {
143
172
  const visible = s.replace(/\x1b\[[0-9;]*m/g, '');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -20,6 +20,7 @@
20
20
  "clean-yaml": "node -e \"const{readdirSync:r,rmSync:d,existsSync:e,statSync:s}=require('fs'),p=require('path');function w(dir){if(!e(dir))return;for(const f of r(dir)){const fp=p.join(dir,f);s(fp).isDirectory()?w(fp):/\\.ya?ml$/.test(f)&&d(fp)}}w('dist/clis')\"",
21
21
  "copy-yaml": "node -e \"const{readdirSync:r,copyFileSync:c,mkdirSync:m,existsSync:e,statSync:s}=require('fs'),p=require('path');function w(src,dst){if(!e(src))return;for(const f of r(src)){const sp=p.join(src,f),dp=p.join(dst,f);s(sp).isDirectory()?w(sp,dp):/\\.ya?ml$/.test(f)&&(m(p.dirname(dp),{recursive:!0}),c(sp,dp))}}w('src/clis','dist/clis')\"",
22
22
  "start": "node dist/main.js",
23
+ "postinstall": "node scripts/postinstall.js || true",
23
24
  "typecheck": "tsc --noEmit",
24
25
  "lint": "tsc --noEmit",
25
26
  "prepublishOnly": "npm run build",
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * postinstall script — automatically install shell completion files.
5
+ *
6
+ * Detects the user's default shell and writes the completion script to the
7
+ * standard system completion directory so that tab-completion works immediately
8
+ * after `npm install -g`.
9
+ *
10
+ * Supported shells: bash, zsh, fish.
11
+ *
12
+ * This script is intentionally plain Node.js (no TypeScript, no imports from
13
+ * the main source tree) so that it can run without a build step.
14
+ */
15
+
16
+ import { mkdirSync, writeFileSync, existsSync, readFileSync, appendFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { homedir } from 'node:os';
19
+
20
+
21
+ // ── Completion script content ──────────────────────────────────────────────
22
+
23
+ const BASH_COMPLETION = `# Bash completion for opencli (auto-installed)
24
+ _opencli_completions() {
25
+ local cur words cword
26
+ _get_comp_words_by_ref -n : cur words cword
27
+
28
+ local completions
29
+ completions=$(opencli --get-completions --cursor "$cword" "\${words[@]:1}" 2>/dev/null)
30
+
31
+ COMPREPLY=( $(compgen -W "$completions" -- "$cur") )
32
+ __ltrim_colon_completions "$cur"
33
+ }
34
+ complete -F _opencli_completions opencli
35
+ `;
36
+
37
+ const ZSH_COMPLETION = `#compdef opencli
38
+ # Zsh completion for opencli (auto-installed)
39
+ _opencli() {
40
+ local -a completions
41
+ local cword=$((CURRENT - 1))
42
+ completions=(\${(f)"$(opencli --get-completions --cursor "$cword" "\${words[@]:1}" 2>/dev/null)"})
43
+ compadd -a completions
44
+ }
45
+ _opencli
46
+ `;
47
+
48
+ const FISH_COMPLETION = `# Fish completion for opencli (auto-installed)
49
+ complete -c opencli -f -a '(
50
+ set -l tokens (commandline -cop)
51
+ set -l cursor (count (commandline -cop))
52
+ opencli --get-completions --cursor $cursor $tokens[2..] 2>/dev/null
53
+ )'
54
+ `;
55
+
56
+ // ── Helpers ────────────────────────────────────────────────────────────────
57
+
58
+ function detectShell() {
59
+ const shell = process.env.SHELL || '';
60
+ if (shell.includes('zsh')) return 'zsh';
61
+ if (shell.includes('bash')) return 'bash';
62
+ if (shell.includes('fish')) return 'fish';
63
+ return null;
64
+ }
65
+
66
+ function ensureDir(dir) {
67
+ if (!existsSync(dir)) {
68
+ mkdirSync(dir, { recursive: true });
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Ensure fpath contains the custom completions directory in .zshrc
74
+ */
75
+ function ensureZshFpath(completionsDir, zshrcPath) {
76
+ const fpathLine = `fpath=(${completionsDir} $fpath)`;
77
+ const autoloadLine = `autoload -Uz compinit && compinit`;
78
+
79
+ if (!existsSync(zshrcPath)) {
80
+ writeFileSync(zshrcPath, `${fpathLine}\n${autoloadLine}\n`, 'utf8');
81
+ return;
82
+ }
83
+
84
+ const content = readFileSync(zshrcPath, 'utf8');
85
+
86
+ // Check if completions dir is already in fpath
87
+ if (content.includes(completionsDir)) {
88
+ return; // already configured
89
+ }
90
+
91
+ // Append fpath configuration
92
+ let addition = `\n# opencli completion\n${fpathLine}\n`;
93
+ if (!content.includes('compinit')) {
94
+ addition += `${autoloadLine}\n`;
95
+ }
96
+ appendFileSync(zshrcPath, addition, 'utf8');
97
+ }
98
+
99
+ // ── Main ───────────────────────────────────────────────────────────────────
100
+
101
+ function main() {
102
+ // Skip in CI environments
103
+ if (process.env.CI || process.env.CONTINUOUS_INTEGRATION) {
104
+ return;
105
+ }
106
+
107
+ // Only install completion for global installs and npm link
108
+ const isGlobal = process.env.npm_config_global === 'true';
109
+ if (!isGlobal) {
110
+ return;
111
+ }
112
+
113
+ const shell = detectShell();
114
+ if (!shell) {
115
+ // Cannot determine shell; silently skip
116
+ return;
117
+ }
118
+
119
+ const home = homedir();
120
+
121
+ try {
122
+ switch (shell) {
123
+ case 'zsh': {
124
+ const completionsDir = join(home, '.zsh', 'completions');
125
+ const completionFile = join(completionsDir, '_opencli');
126
+ ensureDir(completionsDir);
127
+ writeFileSync(completionFile, ZSH_COMPLETION, 'utf8');
128
+
129
+ // Ensure fpath is set up in .zshrc
130
+ const zshrcPath = join(home, '.zshrc');
131
+ ensureZshFpath(completionsDir, zshrcPath);
132
+
133
+ console.log(`✓ Zsh completion installed to ${completionFile}`);
134
+ console.log(` Restart your shell or run: source ~/.zshrc`);
135
+ break;
136
+ }
137
+ case 'bash': {
138
+ // Try system-level first, fall back to user-level
139
+ const userCompDir = join(home, '.bash_completion.d');
140
+ const completionFile = join(userCompDir, 'opencli');
141
+ ensureDir(userCompDir);
142
+ writeFileSync(completionFile, BASH_COMPLETION, 'utf8');
143
+
144
+ // Ensure .bashrc sources the completion directory
145
+ const bashrcPath = join(home, '.bashrc');
146
+ if (existsSync(bashrcPath)) {
147
+ const content = readFileSync(bashrcPath, 'utf8');
148
+ if (!content.includes('.bash_completion.d/opencli')) {
149
+ appendFileSync(bashrcPath,
150
+ `\n# opencli completion\n[ -f "${completionFile}" ] && source "${completionFile}"\n`,
151
+ 'utf8'
152
+ );
153
+ }
154
+ }
155
+
156
+ console.log(`✓ Bash completion installed to ${completionFile}`);
157
+ console.log(` Restart your shell or run: source ~/.bashrc`);
158
+ break;
159
+ }
160
+ case 'fish': {
161
+ const completionsDir = join(home, '.config', 'fish', 'completions');
162
+ const completionFile = join(completionsDir, 'opencli.fish');
163
+ ensureDir(completionsDir);
164
+ writeFileSync(completionFile, FISH_COMPLETION, 'utf8');
165
+
166
+ console.log(`✓ Fish completion installed to ${completionFile}`);
167
+ console.log(` Restart your shell to activate.`);
168
+ break;
169
+ }
170
+ }
171
+ } catch (err) {
172
+ // Completion install is best-effort; never fail the package install
173
+ if (process.env.OPENCLI_VERBOSE) {
174
+ console.error(`Warning: Could not install shell completion: ${err.message}`);
175
+ }
176
+ }
177
+ }
178
+
179
+ main();
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Shell tab-completion support for opencli.
3
+ *
4
+ * Provides:
5
+ * - Shell script generators for bash, zsh, and fish
6
+ * - Dynamic completion logic that returns candidates for the current cursor position
7
+ */
8
+
9
+ import { getRegistry } from './registry.js';
10
+
11
+ // ── Dynamic completion logic ───────────────────────────────────────────────
12
+
13
+ /**
14
+ * Built-in (non-dynamic) top-level commands.
15
+ */
16
+ const BUILTIN_COMMANDS = [
17
+ 'list',
18
+ 'validate',
19
+ 'verify',
20
+ 'explore',
21
+ 'probe', // alias for explore
22
+ 'synthesize',
23
+ 'generate',
24
+ 'cascade',
25
+ 'doctor',
26
+ 'setup',
27
+ 'completion',
28
+ ];
29
+
30
+ /**
31
+ * Return completion candidates given the current command-line words and cursor index.
32
+ *
33
+ * @param words - The argv after 'opencli' (words[0] is the first arg, e.g. site name)
34
+ * @param cursor - 1-based position of the word being completed (1 = first arg)
35
+ */
36
+ export function getCompletions(words: string[], cursor: number): string[] {
37
+ // cursor === 1 → completing the first argument (site name or built-in command)
38
+ if (cursor <= 1) {
39
+ const sites = new Set<string>();
40
+ for (const [, cmd] of getRegistry()) {
41
+ sites.add(cmd.site);
42
+ }
43
+ return [...BUILTIN_COMMANDS, ...sites].sort();
44
+ }
45
+
46
+ const site = words[0];
47
+
48
+ // If the first word is a built-in command, no further completion
49
+ if (BUILTIN_COMMANDS.includes(site)) {
50
+ return [];
51
+ }
52
+
53
+ // cursor === 2 → completing the sub-command name under a site
54
+ if (cursor === 2) {
55
+ const subcommands: string[] = [];
56
+ for (const [, cmd] of getRegistry()) {
57
+ if (cmd.site === site) {
58
+ subcommands.push(cmd.name);
59
+ }
60
+ }
61
+ return subcommands.sort();
62
+ }
63
+
64
+ // cursor >= 3 → no further completion
65
+ return [];
66
+ }
67
+
68
+ // ── Shell script generators ────────────────────────────────────────────────
69
+
70
+ export function bashCompletionScript(): string {
71
+ return `# Bash completion for opencli
72
+ # Add to ~/.bashrc: eval "$(opencli completion bash)"
73
+ _opencli_completions() {
74
+ local cur words cword
75
+ _get_comp_words_by_ref -n : cur words cword
76
+
77
+ local completions
78
+ completions=$(opencli --get-completions --cursor "$cword" "\${words[@]:1}" 2>/dev/null)
79
+
80
+ COMPREPLY=( $(compgen -W "$completions" -- "$cur") )
81
+ __ltrim_colon_completions "$cur"
82
+ }
83
+ complete -F _opencli_completions opencli
84
+ `;
85
+ }
86
+
87
+ export function zshCompletionScript(): string {
88
+ return `# Zsh completion for opencli
89
+ # Add to ~/.zshrc: eval "$(opencli completion zsh)"
90
+ _opencli() {
91
+ local -a completions
92
+ local cword=$((CURRENT - 1))
93
+ completions=(\${(f)"$(opencli --get-completions --cursor "$cword" "\${words[@]:1}" 2>/dev/null)"})
94
+ compadd -a completions
95
+ }
96
+ compdef _opencli opencli
97
+ `;
98
+ }
99
+
100
+ export function fishCompletionScript(): string {
101
+ return `# Fish completion for opencli
102
+ # Add to ~/.config/fish/config.fish: opencli completion fish | source
103
+ complete -c opencli -f -a '(
104
+ set -l tokens (commandline -cop)
105
+ set -l cursor (count (commandline -cop))
106
+ opencli --get-completions --cursor $cursor $tokens[2..] 2>/dev/null
107
+ )'
108
+ `;
109
+ }
110
+
111
+ /**
112
+ * Print the completion script for the requested shell.
113
+ */
114
+ export function printCompletionScript(shell: string): void {
115
+ switch (shell) {
116
+ case 'bash':
117
+ process.stdout.write(bashCompletionScript());
118
+ break;
119
+ case 'zsh':
120
+ process.stdout.write(zshCompletionScript());
121
+ break;
122
+ case 'fish':
123
+ process.stdout.write(fishCompletionScript());
124
+ break;
125
+ default:
126
+ console.error(`Unsupported shell: ${shell}. Supported: bash, zsh, fish`);
127
+ process.exitCode = 1;
128
+ }
129
+ }
@@ -83,6 +83,54 @@ describe('json token helpers', () => {
83
83
  const parsed = JSON.parse(next);
84
84
  expect(parsed.mcp.playwright.environment.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
85
85
  });
86
+
87
+ it('creates standard mcpServers format for empty file (not OpenCode)', () => {
88
+ const next = upsertJsonConfigToken('', 'abc123');
89
+ const parsed = JSON.parse(next);
90
+ expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
91
+ expect(parsed.mcp).toBeUndefined();
92
+ });
93
+
94
+ it('creates OpenCode format when filePath contains opencode', () => {
95
+ const next = upsertJsonConfigToken('', 'abc123', '/home/user/.config/opencode/opencode.json');
96
+ const parsed = JSON.parse(next);
97
+ expect(parsed.mcp.playwright.environment.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
98
+ expect(parsed.mcpServers).toBeUndefined();
99
+ });
100
+
101
+ it('creates standard format when filePath is claude.json', () => {
102
+ const next = upsertJsonConfigToken('', 'abc123', '/home/user/.claude.json');
103
+ const parsed = JSON.parse(next);
104
+ expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
105
+ });
106
+ });
107
+
108
+ describe('fish shell support', () => {
109
+ it('generates fish set -gx syntax for fish config path', () => {
110
+ const next = upsertShellToken('', 'abc123', '/home/user/.config/fish/config.fish');
111
+ expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "abc123"');
112
+ expect(next).not.toContain('export');
113
+ });
114
+
115
+ it('replaces existing fish set line', () => {
116
+ const content = 'set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "old"\n';
117
+ const next = upsertShellToken(content, 'new', '/home/user/.config/fish/config.fish');
118
+ expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "new"');
119
+ expect(next).not.toContain('"old"');
120
+ });
121
+
122
+ it('appends fish syntax to existing fish config', () => {
123
+ const content = 'set -gx PATH /usr/bin\n';
124
+ const next = upsertShellToken(content, 'abc123', '/home/user/.config/fish/config.fish');
125
+ expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "abc123"');
126
+ expect(next).toContain('set -gx PATH /usr/bin');
127
+ });
128
+
129
+ it('uses export syntax for zshrc even with filePath', () => {
130
+ const next = upsertShellToken('', 'abc123', '/home/user/.zshrc');
131
+ expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"');
132
+ expect(next).not.toContain('set -gx');
133
+ });
86
134
  });
87
135
 
88
136
  describe('doctor report rendering', () => {
package/src/doctor.ts CHANGED
@@ -111,6 +111,15 @@ export function getDefaultShellRcPath(): string {
111
111
  return path.join(os.homedir(), '.zshrc');
112
112
  }
113
113
 
114
+ function isFishConfig(filePath: string): boolean {
115
+ return filePath.endsWith('config.fish') || filePath.includes('/fish/');
116
+ }
117
+
118
+ /** Detect if a JSON config file uses OpenCode's `mcp` format vs standard `mcpServers` */
119
+ function isOpenCodeConfig(filePath: string): boolean {
120
+ return filePath.includes('opencode');
121
+ }
122
+
114
123
  export function getDefaultMcpConfigPaths(cwd: string = process.cwd()): string[] {
115
124
  const home = os.homedir();
116
125
  const candidates = [
@@ -136,7 +145,15 @@ export function readTokenFromShellContent(content: string): string | null {
136
145
  return m?.[3] ?? null;
137
146
  }
138
147
 
139
- export function upsertShellToken(content: string, token: string): string {
148
+ export function upsertShellToken(content: string, token: string, filePath?: string): string {
149
+ if (filePath && isFishConfig(filePath)) {
150
+ // Fish shell uses `set -gx` instead of `export`
151
+ const fishLine = `set -gx ${PLAYWRIGHT_TOKEN_ENV} "${token}"`;
152
+ const fishRe = /^\s*set\s+(-gx\s+)?PLAYWRIGHT_MCP_EXTENSION_TOKEN\s+.*/m;
153
+ if (!content.trim()) return `${fishLine}\n`;
154
+ if (fishRe.test(content)) return content.replace(fishRe, fishLine);
155
+ return `${content.replace(/\s*$/, '')}\n${fishLine}\n`;
156
+ }
140
157
  const nextLine = `export ${PLAYWRIGHT_TOKEN_ENV}="${token}"`;
141
158
  if (!content.trim()) return `${nextLine}\n`;
142
159
  if (TOKEN_LINE_RE.test(content)) return content.replace(TOKEN_LINE_RE, `$1"${
@@ -162,16 +179,16 @@ function readTokenFromJsonObject(parsed: any): string | null {
162
179
  return null;
163
180
  }
164
181
 
165
- export function upsertJsonConfigToken(content: string, token: string): string {
182
+ export function upsertJsonConfigToken(content: string, token: string, filePath?: string): string {
166
183
  const parsed = content.trim() ? JSON.parse(content) : {};
167
- if (parsed?.mcpServers) {
168
- parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] ?? {
169
- command: 'npx',
170
- args: ['-y', '@playwright/mcp@latest', '--extension'],
171
- };
172
- parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env ?? {};
173
- parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token;
174
- } else {
184
+
185
+ // Determine format: use OpenCode format only if explicitly an opencode config,
186
+ // or if the existing content already uses `mcp` key (not `mcpServers`)
187
+ const useOpenCodeFormat = filePath
188
+ ? isOpenCodeConfig(filePath)
189
+ : (!parsed.mcpServers && parsed.mcp);
190
+
191
+ if (useOpenCodeFormat) {
175
192
  parsed.mcp = parsed.mcp ?? {};
176
193
  parsed.mcp[PLAYWRIGHT_SERVER_NAME] = parsed.mcp[PLAYWRIGHT_SERVER_NAME] ?? {
177
194
  command: ['npx', '-y', '@playwright/mcp@latest', '--extension'],
@@ -180,6 +197,14 @@ export function upsertJsonConfigToken(content: string, token: string): string {
180
197
  };
181
198
  parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment = parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment ?? {};
182
199
  parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment[PLAYWRIGHT_TOKEN_ENV] = token;
200
+ } else {
201
+ parsed.mcpServers = parsed.mcpServers ?? {};
202
+ parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] ?? {
203
+ command: 'npx',
204
+ args: ['-y', '@playwright/mcp@latest', '--extension'],
205
+ };
206
+ parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env ?? {};
207
+ parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token;
183
208
  }
184
209
  return `${JSON.stringify(parsed, null, 2)}\n`;
185
210
  }
@@ -262,6 +287,26 @@ function readConfigStatus(filePath: string): McpConfigStatus {
262
287
  }
263
288
  }
264
289
 
290
+ /**
291
+ * Dynamically enumerate Chrome profiles by scanning for 'Default' and 'Profile *'
292
+ * directories across all browser base paths. Falls back to ['Default'] if none found.
293
+ */
294
+ function enumerateProfiles(baseDirs: string[]): string[] {
295
+ const profiles = new Set<string>();
296
+ for (const base of baseDirs) {
297
+ if (!fileExists(base)) continue;
298
+ try {
299
+ for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
300
+ if (!entry.isDirectory()) continue;
301
+ if (entry.name === 'Default' || /^Profile \d+$/.test(entry.name)) {
302
+ profiles.add(entry.name);
303
+ }
304
+ }
305
+ } catch { /* permission denied, etc. */ }
306
+ }
307
+ return profiles.size > 0 ? [...profiles].sort() : ['Default'];
308
+ }
309
+
265
310
  /**
266
311
  * Discover the auth token stored by the Playwright MCP Bridge extension
267
312
  * by scanning Chrome's LevelDB localStorage files directly.
@@ -298,7 +343,7 @@ export function discoverExtensionToken(): string | null {
298
343
  );
299
344
  }
300
345
 
301
- const profiles = ['Default', 'Profile 1', 'Profile 2', 'Profile 3'];
346
+ const profiles = enumerateProfiles(bases);
302
347
  const tokenRe = /([A-Za-z0-9_-]{40,50})/;
303
348
 
304
349
  for (const base of bases) {
@@ -424,7 +469,7 @@ export function checkExtensionInstalled(): { installed: boolean; browsers: strin
424
469
  );
425
470
  }
426
471
 
427
- const profiles = ['Default', 'Profile 1', 'Profile 2', 'Profile 3'];
472
+ const profiles = enumerateProfiles(browserDirs.map(d => d.base));
428
473
  const foundBrowsers: string[] = [];
429
474
 
430
475
  for (const { name, base } of browserDirs) {
@@ -643,7 +688,7 @@ export async function applyBrowserDoctorFix(report: DoctorReport, opts: DoctorOp
643
688
  const written: string[] = [];
644
689
  if (plannedWrites.includes(shellPath)) {
645
690
  const shellBefore = fileExists(shellPath) ? fs.readFileSync(shellPath, 'utf-8') : '';
646
- writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token));
691
+ writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token, shellPath));
647
692
  written.push(shellPath);
648
693
  }
649
694
 
@@ -651,7 +696,9 @@ export async function applyBrowserDoctorFix(report: DoctorReport, opts: DoctorOp
651
696
  if (!plannedWrites.includes(config.path)) continue;
652
697
  if (config.parseError) continue;
653
698
  const before = fileExists(config.path) ? fs.readFileSync(config.path, 'utf-8') : '';
654
- const next = config.format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token);
699
+ const next = config.format === 'toml'
700
+ ? upsertTomlConfigToken(before, token)
701
+ : upsertJsonConfigToken(before, token, config.path);
655
702
  writeFileWithMkdir(config.path, next);
656
703
  written.push(config.path);
657
704
  }
package/src/main.ts CHANGED
@@ -14,6 +14,7 @@ import { render as renderOutput } from './output.js';
14
14
  import { PlaywrightMCP } from './browser.js';
15
15
  import { browserSession, DEFAULT_BROWSER_COMMAND_TIMEOUT, runWithTimeout } from './runtime.js';
16
16
  import { PKG_VERSION } from './version.js';
17
+ import { getCompletions, printCompletionScript } from './completion.js';
17
18
 
18
19
  const __filename = fileURLToPath(import.meta.url);
19
20
  const __dirname = path.dirname(__filename);
@@ -22,6 +23,27 @@ const USER_CLIS = path.join(os.homedir(), '.opencli', 'clis');
22
23
 
23
24
  await discoverClis(BUILTIN_CLIS, USER_CLIS);
24
25
 
26
+ // ── Fast-path: handle --get-completions before commander parses ─────────
27
+ // Usage: opencli --get-completions --cursor <N> [word1 word2 ...]
28
+ const getCompIdx = process.argv.indexOf('--get-completions');
29
+ if (getCompIdx !== -1) {
30
+ const rest = process.argv.slice(getCompIdx + 1);
31
+ let cursor: number | undefined;
32
+ const words: string[] = [];
33
+ for (let i = 0; i < rest.length; i++) {
34
+ if (rest[i] === '--cursor' && i + 1 < rest.length) {
35
+ cursor = parseInt(rest[i + 1], 10);
36
+ i++; // skip the value
37
+ } else {
38
+ words.push(rest[i]);
39
+ }
40
+ }
41
+ if (cursor === undefined) cursor = words.length;
42
+ const candidates = getCompletions(words, cursor);
43
+ process.stdout.write(candidates.join('\n') + '\n');
44
+ process.exit(0);
45
+ }
46
+
25
47
  const program = new Command();
26
48
  program.name('opencli').description('Make any website your CLI. Zero setup. AI-powered.').version(PKG_VERSION);
27
49
 
@@ -128,6 +150,13 @@ program.command('setup')
128
150
  await runSetup({ cliVersion: PKG_VERSION, token: opts.token });
129
151
  });
130
152
 
153
+ program.command('completion')
154
+ .description('Output shell completion script')
155
+ .argument('<shell>', 'Shell type: bash, zsh, or fish')
156
+ .action((shell) => {
157
+ printCompletionScript(shell);
158
+ });
159
+
131
160
  // ── Dynamic site commands ──────────────────────────────────────────────────
132
161
 
133
162
  const registry = getRegistry();
package/src/setup.ts CHANGED
@@ -11,6 +11,8 @@ import { stdin as input, stdout as output } from 'node:process';
11
11
  import {
12
12
  type DoctorReport,
13
13
  PLAYWRIGHT_TOKEN_ENV,
14
+ checkExtensionInstalled,
15
+ checkTokenConnectivity,
14
16
  discoverExtensionToken,
15
17
  fileExists,
16
18
  getDefaultShellRcPath,
@@ -60,11 +62,24 @@ export async function runSetup(opts: { cliVersion?: string; token?: string } = {
60
62
  }
61
63
 
62
64
  if (!token) {
63
- console.log(` ${chalk.yellow('!')} No token found. Please enter it manually.`);
64
- console.log(chalk.dim(' (Find it in the Playwright MCP Bridge extension → Status page)'));
65
+ // Give precise diagnosis of why token scan failed
66
+ const extInstall = checkExtensionInstalled();
67
+
68
+ console.log(` ${chalk.red('✗')} Browser token scan failed\n`);
69
+ if (!extInstall.installed) {
70
+ console.log(chalk.dim(' Cause: Playwright MCP Bridge extension is not installed'));
71
+ console.log(chalk.dim(' Fix: Install from https://chromewebstore.google.com/detail/'));
72
+ console.log(chalk.dim(' playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm'));
73
+ } else {
74
+ console.log(chalk.dim(` Cause: Extension is installed (${extInstall.browsers.join(', ')}) but token not found in LevelDB`));
75
+ console.log(chalk.dim(' Fix: 1) Open the extension popup and verify the token is generated'));
76
+ console.log(chalk.dim(' 2) Close Chrome completely, then re-run setup'));
77
+ }
78
+ console.log();
79
+ console.log(` You can enter the token manually, or fix the above and re-run ${chalk.bold('opencli setup')}.`);
65
80
  console.log();
66
81
  const rl = createInterface({ input, output });
67
- const answer = await rl.question(' Token: ');
82
+ const answer = await rl.question(' Token (press Enter to abort): ');
68
83
  rl.close();
69
84
  token = answer.trim();
70
85
  if (!token) {
@@ -129,7 +144,7 @@ export async function runSetup(opts: { cliVersion?: string; token?: string } = {
129
144
  if (sel.startsWith('shell:')) {
130
145
  const p = sel.slice('shell:'.length);
131
146
  const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : '';
132
- writeFileWithMkdir(p, upsertShellToken(before, token));
147
+ writeFileWithMkdir(p, upsertShellToken(before, token, p));
133
148
  written.push(p);
134
149
  wroteShell = true;
135
150
  } else if (sel.startsWith('config:')) {
@@ -138,7 +153,7 @@ export async function runSetup(opts: { cliVersion?: string; token?: string } = {
138
153
  if (config && config.parseError) continue;
139
154
  const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : '';
140
155
  const format = config?.format ?? (p.endsWith('.toml') ? 'toml' : 'json');
141
- const next = format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token);
156
+ const next = format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token, p);
142
157
  writeFileWithMkdir(p, next);
143
158
  written.push(p);
144
159
  }
@@ -161,6 +176,21 @@ export async function runSetup(opts: { cliVersion?: string; token?: string } = {
161
176
  console.log(chalk.yellow(' No files were changed.'));
162
177
  }
163
178
  console.log();
179
+
180
+ // Step 7: Auto-verify browser connectivity
181
+ console.log(chalk.dim(' Verifying browser connectivity...'));
182
+ try {
183
+ const result = await checkTokenConnectivity({ timeout: 5 });
184
+ if (result.ok) {
185
+ console.log(` ${chalk.green('✓')} Browser connected in ${(result.durationMs / 1000).toFixed(1)}s`);
186
+ } else {
187
+ console.log(` ${chalk.yellow('!')} Browser connectivity test failed: ${result.error ?? 'unknown'}`);
188
+ console.log(chalk.dim(' Make sure Chrome is running with the extension enabled.'));
189
+ }
190
+ } catch {
191
+ console.log(` ${chalk.yellow('!')} Could not verify connectivity (Chrome may not be running)`);
192
+ }
193
+ console.log();
164
194
  }
165
195
 
166
196
  function padRight(s: string, n: number): string {