@jackwener/opencli 1.5.3 → 1.5.4

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.
@@ -16,8 +16,8 @@ description: "Cross-project CLI command migration workflow for opencli. Use when
16
16
 
17
17
  ## Prerequisites
18
18
 
19
- - 熟悉 [CLI-EXPLORER.md](file:///Users/jakevin/code/opencli/CLI-EXPLORER.md)(adapter 开发决策树)
20
- - 熟悉 [SKILL.md](file:///Users/jakevin/code/opencli/SKILL.md)(命令参考 & 模板)
19
+ - 熟悉 [CLI-EXPLORER.md](../../../CLI-EXPLORER.md)(adapter 开发决策树)
20
+ - 熟悉 [SKILL.md](../../../SKILL.md)(命令参考 & 模板)
21
21
 
22
22
  ---
23
23
 
@@ -82,7 +82,7 @@ opencli list | grep <site> # 确认已注册命令
82
82
  ## Phase 3: 批量实现
83
83
 
84
84
  > [!IMPORTANT]
85
- > 实现前必须查阅 [CLI-EXPLORER.md](file:///Users/jakevin/code/opencli/CLI-EXPLORER.md) 确认策略选择。
85
+ > 实现前必须查阅 [CLI-EXPLORER.md](../../../CLI-EXPLORER.md) 确认策略选择。
86
86
 
87
87
  ### 3.1 选择实现方式
88
88
 
package/README.md CHANGED
@@ -8,38 +8,233 @@
8
8
  [![Node.js Version](https://img.shields.io/node/v/@jackwener/opencli?style=flat-square)](https://nodejs.org)
9
9
  [![License](https://img.shields.io/npm/l/@jackwener/opencli?style=flat-square)](./LICENSE)
10
10
 
11
- A CLI tool that turns **any website**, **Electron app**, or **local CLI tool** into a command-line interface — powered by browser session reuse and AI-native discovery.
11
+ A CLI tool that turns **any website**, **Electron app**, or **local CLI tool** into a command-line interface — Bilibili, Zhihu, 小红书, Twitter/X, Reddit, YouTube, Antigravity, `gh`, `docker`, and [many more](#built-in-commands) — powered by browser session reuse and AI-native discovery.
12
12
 
13
- - **Zero LLM cost** — No tokens consumed at runtime.
13
+ **Built for AI Agents** — Configure an instruction in your `AGENT.md` or `.cursorrules` to run `opencli list` via Bash. The AI will automatically discover and invoke all available tools.
14
+
15
+ **CLI Hub** — Register any local CLI (`opencli register mycli`) so AI agents can discover and call it alongside built-in commands. Auto-installs missing tools via your package manager (e.g. if `gh` isn't installed, `opencli gh ...` runs `brew install gh` first then re-executes seamlessly).
16
+
17
+ **CLI for Electron Apps** — Turn any Electron application into a CLI tool. Recombine, script, and extend apps like Antigravity Ultra from the terminal. AI agents can now control other AI apps natively.
18
+
19
+ ---
20
+
21
+ ## Highlights
22
+
23
+ - **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively.
14
24
  - **Account-safe** — Reuses Chrome's logged-in state; your credentials never leave the browser.
15
- - **Deterministic** — Same command, same output schema, every time. Pipeable, scriptable, CI-friendly.
25
+ - **Anti-detection built-in** — Patches `navigator.webdriver`, stubs `window.chrome`, fakes plugin lists, cleans ChromeDriver/Playwright globals, and strips CDP frames from Error stack traces. Extensive anti-fingerprinting and risk-control evasion measures baked in at every layer.
16
26
  - **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies.
17
- - **65+ built-in adapters** — Global and Chinese platforms, plus desktop Electron apps via CDP.
27
+ - **External CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, obsidian, docker, etc). Zero setup.
28
+ - **Self-healing setup** — `opencli doctor` diagnoses and auto-starts the daemon, extension, and live browser connectivity.
29
+ - **Dynamic Loader** — Simply drop `.ts` or `.yaml` adapters into the `clis/` folder for auto-registration.
30
+ - **Dual-Engine Architecture** — Supports both YAML declarative data pipelines and robust browser runtime TypeScript injections.
31
+
32
+ ## Why opencli?
33
+
34
+ There are many great browser automation tools. Here's when opencli is the right choice:
35
+
36
+ | Your need | Best tool | Why |
37
+ |-----------|-----------|-----|
38
+ | Scheduled data extraction from specific sites | **opencli** | Pre-built adapters, deterministic JSON, zero LLM cost |
39
+ | AI agent needs reliable site operations | **opencli** | Hundreds of commands, structured output, fast deterministic response |
40
+ | Explore an unknown website ad-hoc | Browser-Use, Stagehand | LLM-driven general browsing for one-off tasks |
41
+ | Large-scale web crawling | Crawl4AI, Scrapy | Purpose-built for throughput and scale |
42
+ | Control desktop Electron apps from terminal | **opencli** | CDP + AppleScript — the only CLI tool that does this |
43
+
44
+ **What makes opencli different:**
45
+
46
+ - **Zero LLM cost** — No tokens consumed at runtime. Run 10,000 times and pay nothing.
47
+ - **Deterministic** — Same command, same output schema, every time. Pipeable, scriptable, CI-friendly.
48
+ - **Broad coverage** — 50+ sites across global and Chinese platforms (Bilibili, Zhihu, Xiaohongshu, Reddit, HackerNews, and more), plus desktop Electron apps via CDP.
49
+
50
+ > For a detailed comparison with Browser-Use, Crawl4AI, Firecrawl, and others, see the [Comparison Guide](./docs/comparison.md).
51
+
52
+ ---
18
53
 
19
54
  ## Quick Start
20
55
 
56
+ ### 1. Install Browser Bridge Extension
57
+
58
+ > OpenCLI connects to your browser through a lightweight **Browser Bridge** Chrome Extension + micro-daemon (zero config, auto-start).
59
+
60
+ 1. Go to the GitHub [Releases page](https://github.com/jackwener/opencli/releases) and download the latest `opencli-extension.zip`.
61
+ 2. Unzip the file and open `chrome://extensions`, enable **Developer mode** (top-right toggle).
62
+ 3. Click **Load unpacked** and select the unzipped folder.
63
+
64
+ ### 2. Install OpenCLI
65
+
66
+ **Install via npm (recommended)**
67
+
21
68
  ```bash
22
69
  npm install -g @jackwener/opencli
23
- opencli doctor # Check setup
70
+ ```
71
+
72
+ ### 3. Verify & Try
73
+
74
+ ```bash
75
+ opencli doctor # Check extension + daemon connectivity
76
+ ```
77
+
78
+ **Try it out:**
79
+
80
+ ```bash
24
81
  opencli list # See all commands
25
82
  opencli hackernews top --limit 5 # Public API, no browser needed
26
- opencli bilibili hot -f json # Browser command, JSON output
83
+ opencli bilibili hot --limit 5 # Browser command (requires Extension)
27
84
  ```
28
85
 
29
- **[→ Full documentation](./docs/index.md)**
86
+ ### Update
87
+
88
+ ```bash
89
+ npm install -g @jackwener/opencli@latest
90
+ ```
91
+
92
+ ---
93
+
94
+ ### For Developers
95
+
96
+ **Install from source**
97
+
98
+ ```bash
99
+ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && npm run build && npm link
100
+ ```
101
+
102
+ **Load Source Browser Bridge Extension**
103
+
104
+ 1. Open `chrome://extensions` and enable **Developer mode** (top-right toggle).
105
+ 2. Click **Load unpacked** and select the `extension/` directory from this repository.
106
+
107
+ ---
108
+
109
+ ## Prerequisites
110
+
111
+ - **Node.js**: >= 20.0.0 (or **Bun** >= 1.0)
112
+ - **Chrome** running **and logged into the target site** (e.g. bilibili.com, zhihu.com, xiaohongshu.com).
113
+
114
+ > **⚠️ Important**: Browser commands reuse your Chrome login session. You must be logged into the target website in Chrome before running commands. If you get empty data or errors, check your login status first.
115
+
116
+ ## Built-in Commands
117
+
118
+ | Site | Commands |
119
+ |------|----------|
120
+ | **xiaohongshu** | `search` `feed` `user` `download` `publish` `comments` `notifications` `creator-notes` `creator-notes-summary` `creator-note-detail` `creator-profile` `creator-stats` |
121
+ | **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `user-videos` |
122
+ | **twitter** | `trending` `search` `timeline` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` |
123
+ | **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `user` `user-posts` `user-comments` `read` `save` `saved` `subscribe` `upvote` `upvoted` `comment` |
124
+
125
+ 65+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)**
126
+
127
+ ## CLI Hub
128
+
129
+ OpenCLI acts as a universal hub for your existing command-line tools — unified discovery, pure passthrough execution, and auto-install (if a tool isn't installed, OpenCLI runs `brew install <tool>` automatically before re-running the command).
130
+
131
+ | External CLI | Description | Example |
132
+ |--------------|-------------|---------|
133
+ | **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
134
+ | **obsidian** | Obsidian vault management | `opencli obsidian search query="AI"` |
135
+ | **docker** | Docker | `opencli docker ps` |
136
+ | **gws** | Google Workspace CLI | `opencli gws docs list` |
137
+ | **lark-cli** | Lark/Feishu — messages, docs, calendar, tasks, 200+ commands | `opencli lark-cli calendar +agenda` |
138
+ | **vercel** | Vercel — deploy projects, manage domains, env vars, logs | `opencli vercel deploy --prod` |
139
+
140
+ **Register your own** — add any local CLI so AI agents can discover it via `opencli list`:
141
+
142
+ ```bash
143
+ opencli register mycli
144
+ ```
145
+
146
+ ### Desktop App Adapters
147
+
148
+ Control Electron desktop apps directly from the terminal. Each adapter has its own detailed documentation:
149
+
150
+ | App | Description | Doc |
151
+ |-----|-------------|-----|
152
+ | **Cursor** | Control Cursor IDE — Composer, chat, code extraction | [Doc](./docs/adapters/desktop/cursor.md) |
153
+ | **Codex** | Drive OpenAI Codex CLI agent headlessly | [Doc](./docs/adapters/desktop/codex.md) |
154
+ | **Antigravity** | Control Antigravity Ultra from terminal | [Doc](./docs/adapters/desktop/antigravity.md) |
155
+ | **ChatGPT** | Automate ChatGPT macOS desktop app | [Doc](./docs/adapters/desktop/chatgpt.md) |
156
+ | **ChatWise** | Multi-LLM client (GPT-4, Claude, Gemini) | [Doc](./docs/adapters/desktop/chatwise.md) |
157
+ | **Notion** | Search, read, write Notion pages | [Doc](./docs/adapters/desktop/notion.md) |
158
+ | **Discord** | Discord Desktop — messages, channels, servers | [Doc](./docs/adapters/desktop/discord.md) |
159
+ | **Doubao** | Control Doubao AI desktop app via CDP | [Doc](./docs/adapters/desktop/doubao-app.md) |
160
+
161
+ To add a new Electron app, start with [docs/guide/electron-app-cli.md](./docs/guide/electron-app-cli.md).
162
+
163
+ ## Download Support
164
+
165
+ OpenCLI supports downloading images, videos, and articles from supported platforms.
166
+
167
+ | Platform | Content Types | Notes |
168
+ |----------|---------------|-------|
169
+ | **xiaohongshu** | Images, Videos | Downloads all media from a note |
170
+ | **bilibili** | Videos | Requires `yt-dlp` installed |
171
+ | **twitter** | Images, Videos | From user media tab or single tweet |
172
+ | **douban** | Images | Poster / still image lists |
173
+ | **pixiv** | Images | Original-quality illustrations, multi-page |
174
+ | **zhihu** | Articles (Markdown) | Exports with optional image download |
175
+ | **weixin** | Articles (Markdown) | WeChat Official Account articles |
176
+
177
+ For video downloads, install `yt-dlp` first: `brew install yt-dlp`
178
+
179
+ ```bash
180
+ opencli xiaohongshu download abc123 --output ./xhs
181
+ opencli bilibili download BV1xxx --output ./bilibili
182
+ opencli twitter download elonmusk --limit 20 --output ./twitter
183
+ ```
184
+
185
+ ## Output Formats
186
+
187
+ All built-in commands support `--format` / `-f` with `table` (default), `json`, `yaml`, `md`, and `csv`.
188
+
189
+ ```bash
190
+ opencli bilibili hot -f json # Pipe to jq or LLMs
191
+ opencli bilibili hot -f csv # Spreadsheet-friendly
192
+ opencli bilibili hot -v # Verbose: show pipeline debug steps
193
+ ```
194
+
195
+ ## Plugins
196
+
197
+ Extend OpenCLI with community-contributed adapters:
198
+
199
+ ```bash
200
+ opencli plugin install github:user/opencli-plugin-my-tool
201
+ opencli plugin list
202
+ opencli plugin update --all
203
+ opencli plugin uninstall my-tool
204
+ ```
205
+
206
+ | Plugin | Type | Description |
207
+ |--------|------|-------------|
208
+ | [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | YAML | GitHub Trending repositories |
209
+ | [opencli-plugin-hot-digest](https://github.com/ByteYue/opencli-plugin-hot-digest) | TS | Multi-platform trending aggregator |
210
+ | [opencli-plugin-juejin](https://github.com/Astro-Han/opencli-plugin-juejin) | YAML | 稀土掘金 (Juejin) hot articles |
211
+
212
+ See [Plugins Guide](./docs/guide/plugins.md) for creating your own plugin.
213
+
214
+ ## For AI Agents (Developer Guide)
215
+
216
+ > **Quick mode**: To generate a single command for a specific page URL, see [CLI-ONESHOT.md](./CLI-ONESHOT.md) — just a URL + one-line goal, 4 steps done.
217
+
218
+ > **Full mode**: Before writing any adapter code, read [CLI-EXPLORER.md](./CLI-EXPLORER.md). It contains the complete browser exploration workflow, the 5-tier authentication strategy decision tree, and debugging guide.
219
+
220
+ ```bash
221
+ opencli explore https://example.com --site mysite # Discover APIs + capabilities
222
+ opencli synthesize mysite # Generate YAML adapters
223
+ opencli generate https://example.com --goal "hot" # One-shot: explore → synthesize → register
224
+ opencli cascade https://api.example.com/data # Auto-probe: PUBLIC → COOKIE → HEADER
225
+ ```
226
+
227
+ ## Testing
228
+
229
+ See **[TESTING.md](./TESTING.md)** for how to run and write tests.
30
230
 
31
- ## Documentation
231
+ ## Troubleshooting
32
232
 
33
- | Topic | Link |
34
- |-------|------|
35
- | Installation & Setup | [docs/guide/installation.md](./docs/guide/installation.md) |
36
- | Getting Started | [docs/guide/getting-started.md](./docs/guide/getting-started.md) |
37
- | Built-in Commands | [docs/adapters/index.md](./docs/adapters/index.md) |
38
- | Desktop App Adapters | [docs/adapters/desktop](./docs/adapters/desktop) |
39
- | Plugins | [docs/guide/plugins.md](./docs/guide/plugins.md) |
40
- | Electron App Guide | [docs/guide/electron-app-cli.md](./docs/guide/electron-app-cli.md) |
41
- | Troubleshooting | [docs/guide/troubleshooting.md](./docs/guide/troubleshooting.md) |
42
- | Comparison with other tools | [docs/comparison.md](./docs/comparison.md) |
233
+ - **"Extension not connected"** — Ensure the Browser Bridge extension is installed and **enabled** in `chrome://extensions`.
234
+ - **"attach failed: Cannot access a chrome-extension:// URL"** — Another extension may be interfering. Try disabling other extensions temporarily.
235
+ - **Empty data or 'Unauthorized' error** — Your Chrome login session may have expired. Navigate to the target site and log in again.
236
+ - **Node API errors** — Ensure Node.js >= 20. Some dependencies require modern Node APIs.
237
+ - **Daemon issues** — Check status: `curl localhost:19825/status` · View logs: `curl localhost:19825/logs`
43
238
 
44
239
  ## Star History
45
240
 
@@ -36,11 +36,10 @@ export interface ManifestEntry {
36
36
  /** Pre-navigation control — see CliCommand.navigateBefore */
37
37
  navigateBefore?: boolean | string;
38
38
  }
39
- export declare function parseTsArgsBlock(argsBlock: string): ManifestEntry['args'];
40
- export declare function scanTs(filePath: string, site: string): ManifestEntry | null;
39
+ export declare function loadTsManifestEntries(filePath: string, site: string, importer?: (moduleHref: string) => Promise<unknown>): Promise<ManifestEntry[]>;
41
40
  /**
42
41
  * When both YAML and TS adapters exist for the same site/name,
43
42
  * prefer the TS version (it self-registers and typically has richer logic).
44
43
  */
45
44
  export declare function shouldReplaceManifestEntry(current: ManifestEntry, next: ManifestEntry): boolean;
46
- export declare function buildManifest(): ManifestEntry[];
45
+ export declare function buildManifest(): Promise<ManifestEntry[]>;
@@ -13,109 +13,52 @@ import * as path from 'node:path';
13
13
  import { fileURLToPath, pathToFileURL } from 'node:url';
14
14
  import yaml from 'js-yaml';
15
15
  import { getErrorMessage } from './errors.js';
16
+ import { fullName, getRegistry } from './registry.js';
16
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
18
  const CLIS_DIR = path.resolve(__dirname, 'clis');
18
19
  const OUTPUT = path.resolve(__dirname, '..', 'dist', 'cli-manifest.json');
19
20
  import { parseYamlArgs } from './yaml-schema.js';
20
21
  import { isRecord } from './utils.js';
21
- function extractBalancedBlock(source, startIndex, openChar, closeChar) {
22
- let depth = 0;
23
- let quote = null;
24
- let escaped = false;
25
- for (let i = startIndex; i < source.length; i++) {
26
- const ch = source[i];
27
- if (quote) {
28
- if (escaped) {
29
- escaped = false;
30
- continue;
31
- }
32
- if (ch === '\\') {
33
- escaped = true;
34
- continue;
35
- }
36
- if (ch === quote)
37
- quote = null;
38
- continue;
39
- }
40
- if (ch === '"' || ch === '\'' || ch === '`') {
41
- quote = ch;
42
- continue;
43
- }
44
- if (ch === openChar) {
45
- depth++;
46
- }
47
- else if (ch === closeChar) {
48
- depth--;
49
- if (depth === 0) {
50
- return source.slice(startIndex + 1, i);
51
- }
52
- }
53
- }
54
- return null;
22
+ const CLI_MODULE_PATTERN = /\bcli\s*\(/;
23
+ function toManifestArgs(args) {
24
+ return args.map(arg => ({
25
+ name: arg.name,
26
+ type: arg.type ?? 'str',
27
+ default: arg.default,
28
+ required: !!arg.required,
29
+ positional: arg.positional || undefined,
30
+ help: arg.help ?? '',
31
+ choices: arg.choices,
32
+ }));
55
33
  }
56
- function extractTsArgsBlock(source) {
57
- const argsMatch = source.match(/args\s*:/);
58
- if (!argsMatch || argsMatch.index === undefined)
59
- return null;
60
- const bracketIndex = source.indexOf('[', argsMatch.index);
61
- if (bracketIndex === -1)
62
- return null;
63
- return extractBalancedBlock(source, bracketIndex, '[', ']');
34
+ function toTsModulePath(filePath, site) {
35
+ const baseName = path.basename(filePath, path.extname(filePath));
36
+ return `${site}/${baseName}.js`;
64
37
  }
65
- function parseInlineChoices(body) {
66
- const choicesMatch = body.match(/choices\s*:\s*\[([^\]]*)\]/);
67
- if (!choicesMatch)
68
- return undefined;
69
- const values = choicesMatch[1]
70
- .split(',')
71
- .map(s => s.trim().replace(/^['"`]|['"`]$/g, ''))
72
- .filter(Boolean);
73
- return values.length > 0 ? values : undefined;
38
+ function isCliCommandValue(value, site) {
39
+ return isRecord(value)
40
+ && typeof value.site === 'string'
41
+ && value.site === site
42
+ && typeof value.name === 'string'
43
+ && Array.isArray(value.args);
74
44
  }
75
- export function parseTsArgsBlock(argsBlock) {
76
- const args = [];
77
- let cursor = 0;
78
- while (cursor < argsBlock.length) {
79
- const nameMatch = argsBlock.slice(cursor).match(/\{\s*name\s*:\s*['"`]([^'"`]+)['"`]/);
80
- if (!nameMatch || nameMatch.index === undefined)
81
- break;
82
- const objectStart = cursor + nameMatch.index;
83
- const body = extractBalancedBlock(argsBlock, objectStart, '{', '}');
84
- if (body == null)
85
- break;
86
- const typeMatch = body.match(/type\s*:\s*['"`](\w+)['"`]/);
87
- const defaultMatch = body.match(/default\s*:\s*([^,}]+)/);
88
- const requiredMatch = body.match(/required\s*:\s*(true|false)/);
89
- const helpMatch = body.match(/help\s*:\s*['"`]([^'"`]*)['"`]/);
90
- const positionalMatch = body.match(/positional\s*:\s*(true|false)/);
91
- let defaultVal = undefined;
92
- if (defaultMatch) {
93
- const raw = defaultMatch[1].trim();
94
- if (raw === 'true')
95
- defaultVal = true;
96
- else if (raw === 'false')
97
- defaultVal = false;
98
- else if (/^\d+$/.test(raw))
99
- defaultVal = parseInt(raw, 10);
100
- else if (/^\d+\.\d+$/.test(raw))
101
- defaultVal = parseFloat(raw);
102
- else
103
- defaultVal = raw.replace(/^['"`]|['"`]$/g, '');
104
- }
105
- args.push({
106
- name: nameMatch[1],
107
- type: typeMatch?.[1] ?? 'str',
108
- default: defaultVal,
109
- required: requiredMatch?.[1] === 'true',
110
- positional: positionalMatch?.[1] === 'true' || undefined,
111
- help: helpMatch?.[1] ?? '',
112
- choices: parseInlineChoices(body),
113
- });
114
- cursor = objectStart + body.length;
115
- if (cursor <= objectStart)
116
- break; // safety: prevent infinite loop
117
- }
118
- return args;
45
+ function toManifestEntry(cmd, modulePath) {
46
+ return {
47
+ site: cmd.site,
48
+ name: cmd.name,
49
+ description: cmd.description ?? '',
50
+ domain: cmd.domain,
51
+ strategy: (cmd.strategy ?? 'public').toString().toLowerCase(),
52
+ browser: cmd.browser ?? true,
53
+ args: toManifestArgs(cmd.args),
54
+ columns: cmd.columns,
55
+ timeout: cmd.timeoutSeconds,
56
+ deprecated: cmd.deprecated,
57
+ replacedBy: cmd.replacedBy,
58
+ type: 'ts',
59
+ modulePath,
60
+ navigateBefore: cmd.navigateBefore,
61
+ };
119
62
  }
120
63
  function scanYaml(filePath, site) {
121
64
  try {
@@ -150,82 +93,44 @@ function scanYaml(filePath, site) {
150
93
  return null;
151
94
  }
152
95
  }
153
- export function scanTs(filePath, site) {
154
- // TS adapters self-register via cli() at import time.
155
- // We statically parse the source to extract metadata for the manifest stub.
156
- const baseName = path.basename(filePath, path.extname(filePath));
157
- const relativePath = `${site}/${baseName}.js`;
96
+ export async function loadTsManifestEntries(filePath, site, importer = moduleHref => import(moduleHref)) {
158
97
  try {
159
98
  const src = fs.readFileSync(filePath, 'utf-8');
160
99
  // Helper/test modules should not appear as CLI commands in the manifest.
161
- if (!/\bcli\s*\(/.test(src))
162
- return null;
163
- const entry = {
164
- site,
165
- name: baseName,
166
- description: '',
167
- strategy: 'cookie',
168
- browser: true,
169
- args: [],
170
- type: 'ts',
171
- modulePath: relativePath,
172
- };
173
- // Extract description
174
- const descMatch = src.match(/description\s*:\s*['"`]([^'"`]*)['"`]/);
175
- if (descMatch)
176
- entry.description = descMatch[1];
177
- // Extract domain
178
- const domainMatch = src.match(/domain\s*:\s*['"`]([^'"`]*)['"`]/);
179
- if (domainMatch)
180
- entry.domain = domainMatch[1];
181
- // Extract strategy
182
- const stratMatch = src.match(/strategy\s*:\s*Strategy\.(\w+)/);
183
- if (stratMatch)
184
- entry.strategy = stratMatch[1].toLowerCase();
185
- // Extract browser: false (some adapters bypass browser entirely)
186
- const browserMatch = src.match(/browser\s*:\s*(true|false)/);
187
- if (browserMatch)
188
- entry.browser = browserMatch[1] === 'true';
189
- else
190
- entry.browser = entry.strategy !== 'public';
191
- // Extract columns
192
- const colMatch = src.match(/columns\s*:\s*\[([^\]]*)\]/);
193
- if (colMatch) {
194
- entry.columns = colMatch[1].split(',').map(s => s.trim().replace(/^['"`]|['"`]$/g, '')).filter(Boolean);
195
- }
196
- // Extract args array items: { name: '...', ... }
197
- const argsBlock = extractTsArgsBlock(src);
198
- if (argsBlock) {
199
- entry.args = parseTsArgsBlock(argsBlock);
200
- }
201
- // Extract navigateBefore: false / true / 'https://...'
202
- const navBoolMatch = src.match(/navigateBefore\s*:\s*(true|false)/);
203
- if (navBoolMatch) {
204
- entry.navigateBefore = navBoolMatch[1] === 'true';
205
- }
206
- else {
207
- const navStringMatch = src.match(/navigateBefore\s*:\s*['"`]([^'"`]+)['"`]/);
208
- if (navStringMatch)
209
- entry.navigateBefore = navStringMatch[1];
210
- }
211
- const deprecatedBoolMatch = src.match(/deprecated\s*:\s*(true|false)/);
212
- if (deprecatedBoolMatch) {
213
- entry.deprecated = deprecatedBoolMatch[1] === 'true';
214
- }
215
- else {
216
- const deprecatedStringMatch = src.match(/deprecated\s*:\s*['"`]([^'"`]+)['"`]/);
217
- if (deprecatedStringMatch)
218
- entry.deprecated = deprecatedStringMatch[1];
219
- }
220
- const replacedByMatch = src.match(/replacedBy\s*:\s*['"`]([^'"`]+)['"`]/);
221
- if (replacedByMatch)
222
- entry.replacedBy = replacedByMatch[1];
223
- return entry;
100
+ if (!CLI_MODULE_PATTERN.test(src))
101
+ return [];
102
+ const modulePath = toTsModulePath(filePath, site);
103
+ const registry = getRegistry();
104
+ const before = new Map(registry.entries());
105
+ const mod = await importer(pathToFileURL(filePath).href);
106
+ const exportedCommands = Object.values(isRecord(mod) ? mod : {})
107
+ .filter(value => isCliCommandValue(value, site));
108
+ const runtimeCommands = exportedCommands.length > 0
109
+ ? exportedCommands
110
+ : [...registry.entries()]
111
+ .filter(([key, cmd]) => {
112
+ if (cmd.site !== site)
113
+ return false;
114
+ const previous = before.get(key);
115
+ return !previous || previous !== cmd;
116
+ })
117
+ .map(([, cmd]) => cmd);
118
+ const seen = new Set();
119
+ return runtimeCommands
120
+ .filter((cmd) => {
121
+ const key = fullName(cmd);
122
+ if (seen.has(key))
123
+ return false;
124
+ seen.add(key);
125
+ return true;
126
+ })
127
+ .sort((a, b) => a.name.localeCompare(b.name))
128
+ .map(cmd => toManifestEntry(cmd, modulePath));
224
129
  }
225
130
  catch (err) {
226
131
  // If parsing fails, log a warning (matching scanYaml behaviour) and skip the entry.
227
132
  process.stderr.write(`Warning: failed to scan ${filePath}: ${getErrorMessage(err)}\n`);
228
- return null;
133
+ return [];
229
134
  }
230
135
  }
231
136
  /**
@@ -237,7 +142,7 @@ export function shouldReplaceManifestEntry(current, next) {
237
142
  return false;
238
143
  return current.type === 'yaml' && next.type === 'ts';
239
144
  }
240
- export function buildManifest() {
145
+ export async function buildManifest() {
241
146
  const manifest = new Map();
242
147
  if (fs.existsSync(CLIS_DIR)) {
243
148
  for (const site of fs.readdirSync(CLIS_DIR)) {
@@ -261,8 +166,8 @@ export function buildManifest() {
261
166
  }
262
167
  else if ((file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts') && file !== 'index.ts') ||
263
168
  (file.endsWith('.js') && !file.endsWith('.d.js') && !file.endsWith('.test.js') && file !== 'index.js')) {
264
- const entry = scanTs(filePath, site);
265
- if (entry) {
169
+ const entries = await loadTsManifestEntries(filePath, site);
170
+ for (const entry of entries) {
266
171
  const key = `${entry.site}/${entry.name}`;
267
172
  const existing = manifest.get(key);
268
173
  if (!existing || shouldReplaceManifestEntry(existing, entry)) {
@@ -278,8 +183,8 @@ export function buildManifest() {
278
183
  }
279
184
  return [...manifest.values()];
280
185
  }
281
- function main() {
282
- const manifest = buildManifest();
186
+ async function main() {
187
+ const manifest = await buildManifest();
283
188
  fs.mkdirSync(path.dirname(OUTPUT), { recursive: true });
284
189
  fs.writeFileSync(OUTPUT, JSON.stringify(manifest, null, 2));
285
190
  const yamlCount = manifest.filter(e => e.type === 'yaml').length;
@@ -311,5 +216,5 @@ function main() {
311
216
  }
312
217
  const entrypoint = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null;
313
218
  if (entrypoint === import.meta.url) {
314
- main();
219
+ void main();
315
220
  }