@jackwener/opencli 0.4.0 → 0.4.2

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/CLI-CREATOR.md CHANGED
@@ -3,6 +3,64 @@
3
3
  > 本文档教你(或 AI Agent)如何为 OpenCLI 添加一个新网站的命令。
4
4
  > 从零到发布,覆盖 API 发现、方案选择、适配器编写、测试验证全流程。
5
5
 
6
+ ---
7
+
8
+ ## ⚠️ AI Agent 开发者必读:用 Playwright MCP Bridge 探索
9
+
10
+ > [!CAUTION]
11
+ > **你(AI Agent)必须通过 Playwright MCP Bridge 打开浏览器去访问目标网站!**
12
+ > 不要只靠 `opencli explore` 命令或静态分析来发现 API。
13
+ > 你拥有 Playwright MCP 工具,必须主动用它们浏览网页、观察网络请求、模拟用户交互。
14
+
15
+ ### 为什么?
16
+
17
+ 很多 API 是**懒加载**的(用户必须点击某个按钮/标签才会触发网络请求)。字幕、评论、关注列表等深层数据不会在页面首次加载时出现在 Network 面板中。**如果你不主动去浏览和交互页面,你永远发现不了这些 API。**
18
+
19
+ ### AI Agent 探索工作流(必须遵循)
20
+
21
+ | 步骤 | 工具 | 做什么 |
22
+ |------|------|--------|
23
+ | 0. 打开浏览器 | `browser_navigate` | 导航到目标页面 |
24
+ | 1. 观察页面 | `browser_snapshot` | 观察可交互元素(按钮/标签/链接) |
25
+ | 2. 首次抓包 | `browser_network_requests` | 筛选 JSON API 端点,记录 URL pattern |
26
+ | 3. 模拟交互 | `browser_click` + `browser_wait_for` | 点击"字幕""评论""关注"等按钮 |
27
+ | 4. 二次抓包 | `browser_network_requests` | 对比步骤 2,找出新触发的 API |
28
+ | 5. 验证 API | `browser_evaluate` | `fetch(url, {credentials:'include'})` 测试返回结构 |
29
+ | 6. 写代码 | — | 基于确认的 API 写适配器 |
30
+
31
+ ### 常犯错误
32
+
33
+ | ❌ 错误做法 | ✅ 正确做法 |
34
+ |------------|------------|
35
+ | 只用 `opencli explore` 命令,等结果自动出来 | 用 MCP Bridge 打开浏览器,主动浏览页面 |
36
+ | 直接在代码里 `fetch(url)`,不看浏览器实际请求 | 先在浏览器中确认 API 可用,再写代码 |
37
+ | 页面打开后直接抓包,期望所有 API 都出现 | 模拟点击交互(展开评论/切换标签/加载更多) |
38
+ | 遇到 HTTP 200 但空数据就放弃 | 检查是否需要 Wbi 签名或 Cookie 鉴权 |
39
+ | 完全依赖 `__INITIAL_STATE__` 拿所有数据 | `__INITIAL_STATE__` 只有首屏数据,深层数据要调 API |
40
+
41
+ ### ✅ 实战成功案例:5 分钟实现「关注列表」适配器
42
+
43
+ 以下是用上述工作流实际发现 Bilibili 关注列表 API 的完整过程:
44
+
45
+ ```
46
+ 1. browser_navigate → https://space.bilibili.com/{uid}/fans/follow
47
+ 2. browser_network_requests → 发现:
48
+ GET /x/relation/followings?vmid={uid}&pn=1&ps=24 → [200]
49
+ GET /x/relation/stat?vmid={uid} → [200]
50
+ 3. browser_evaluate → 验证 API:
51
+ fetch('/x/relation/followings?vmid=137702077&pn=1&ps=5', {credentials:'include'})
52
+ → { code: 0, data: { total: 1342, list: [{mid, uname, sign, ...}] } }
53
+ 4. 结论:标准 Cookie API,无需 Wbi 签名
54
+ 5. 写 following.ts → 一次构建通过 ✅
55
+ ```
56
+
57
+ **关键决策点**:
58
+ - 直接访问 `fans/follow` 页面(不是首页),页面加载就会触发 following API
59
+ - 看到 URL 里没有 `/wbi/` → 不需要签名 → 直接用 `fetchJson` 而非 `apiGet`
60
+ - API 返回 `code: 0` + 非空 `list` → Tier 2 Cookie 策略确认
61
+
62
+ ---
63
+
6
64
  ## 核心流程
7
65
 
8
66
  ```
@@ -114,6 +172,45 @@ opencli cascade https://api.example.com/hot
114
172
 
115
173
  ---
116
174
 
175
+ ## Step 2.5: 准备工作(写代码之前)
176
+
177
+ ### 🎯 先找模板:从最相似的现有适配器开始
178
+
179
+ **不要从零开始写**。先看看同站点已有哪些适配器:
180
+
181
+ ```bash
182
+ ls src/clis/<site>/ # 看看已有什么
183
+ cat src/clis/<site>/feed.ts # 读最相似的那个
184
+ ```
185
+
186
+ 最高效的方式是 **复制最相似的适配器,然后改 3 个地方**:
187
+ 1. `name` → 新命令名
188
+ 2. API URL → 你在 Step 1 发现的端点
189
+ 3. 字段映射 → 对应新 API 的字段
190
+
191
+ ### 平台 SDK 速查表
192
+
193
+ 写 TS 适配器之前,先看看你的目标站点有没有**现成的 helper 函数**可以复用:
194
+
195
+ #### Bilibili (`src/bilibili.ts`)
196
+
197
+ | 函数 | 用途 | 何时使用 |
198
+ |------|------|----------|
199
+ | `fetchJson(page, url)` | 带 Cookie 的 fetch + JSON 解析 | 普通 Cookie-tier API |
200
+ | `apiGet(page, path, {signed, params})` | 带 Wbi 签名的 API 调用 | URL 含 `/wbi/` 的接口 |
201
+ | `getSelfUid(page)` | 获取当前登录用户的 UID | "我的xxx" 类命令 |
202
+ | `resolveUid(page, input)` | 解析用户输入的 UID(支持数字/URL) | `--uid` 参数处理 |
203
+ | `wbiSign(page, params)` | 底层 Wbi 签名生成 | 通常不直接用,`apiGet` 已封装 |
204
+ | `stripHtml(s)` | 去除 HTML 标签 | 清理富文本字段 |
205
+
206
+ **如何判断需不需要 `apiGet`**?看 Network 请求 URL:
207
+ - 含 `/wbi/` 或 `w_rid=` → 必须用 `apiGet(..., { signed: true })`
208
+ - 不含 → 直接用 `fetchJson`
209
+
210
+ > 💡 其他站点(Twitter、小红书等)暂无专用 SDK,直接用 `page.evaluate` + `fetch` 即可。
211
+
212
+ ---
213
+
117
214
  ## Step 3: 编写适配器
118
215
 
119
216
  ### YAML vs TS?先看决策树
@@ -136,6 +233,27 @@ opencli cascade https://api.example.com/hot
136
233
 
137
234
  > **经验法则**:如果你发现 YAML 里嵌了超过 10 行 JS,改用 TS 更可维护。
138
235
 
236
+ ### 通用模式:分页 API
237
+
238
+ 很多 API 使用 `pn`(页码)+ `ps`(每页数量)分页。标准处理模式:
239
+
240
+ ```typescript
241
+ args: [
242
+ { name: 'page', type: 'int', required: false, default: 1, help: '页码' },
243
+ { name: 'limit', type: 'int', required: false, default: 50, help: '每页数量 (最大 50)' },
244
+ ],
245
+ func: async (page, kwargs) => {
246
+ const pn = kwargs.page ?? 1;
247
+ const ps = Math.min(kwargs.limit ?? 50, 50); // 尊重 API 的 ps 上限
248
+ const payload = await fetchJson(page,
249
+ `https://api.example.com/list?pn=${pn}&ps=${ps}`
250
+ );
251
+ return payload.data?.list || [];
252
+ },
253
+ ```
254
+
255
+ > 💡 大多数站点的 `ps` 上限是 20~50。超过会被静默截断或返回错误。
256
+
139
257
  ### 方式 A: YAML Pipeline(声明式,推荐)
140
258
 
141
259
  文件路径: `src/clis/<site>/<name>.yaml`,放入即自动注册。
@@ -412,34 +530,7 @@ cli({
412
530
 
413
531
  > **拦截核心思路**:不自己构造签名,而是利用 `installInterceptor` 劫持网站自己的 `XMLHttpRequest` 和 `fetch`,让网站发请求,我们直接在底层取出解析好的 `response.json()`。
414
532
 
415
- #### 进阶场景 1: 级联请求 (Cascading Requests) 与鉴权绕过
416
-
417
- 部分 API 获取是非常复杂的连环请求(例如 B 站获取视频字幕:先需要 `bvid` 获取核心 `cid`,再通过 `cid` 获取包含签名/Wbi 的字幕列表拉取地址,最后 fetch 真实的 CDN 资源)。在此类场景中,你必须在一个 `evaluate` 块内部或者在 TypeScript Node 端编排整个请求链条:
418
-
419
- ```typescript
420
- // 真实场景:B站获取视频字幕的级联获取思路
421
- const subtitleUrls = await page.evaluate(async (bvid) => {
422
- // Step 1: 拿 CID (通常可以通过页面全局状态极速提取)
423
- const cid = window.__INITIAL_STATE__?.videoData?.cid;
424
-
425
- // Step 2: 依据 BVID 和 CID 拿字幕配置 (可能需要携带 W_RID 签名或依赖浏览器当前登录状态 Cookie)
426
- const res = await fetch(\`/x/player/wbi/v2?bvid=\${bvid}&cid=\${cid}\`, { credentials: 'include' });
427
- const data = await res.json();
428
-
429
- // Step 3: 风控拦截/未登录降级空值检测 (Anti-Bot Empty Value Detection) ⚠️ 极其重要
430
- // 很多大厂 API 只要签名失败或无强登录 Cookie 依然会返回 HTTP 200,但把关键 URL 设为 ""
431
- const firstSubUrl = data.data?.subtitle?.subtitles?.[0]?.subtitle_url;
432
- if (!firstSubUrl) {
433
- throw new Error('被风控降级或需登录:拿不到真实的 subtitle_url,请检查 Cookie 状态 (Tier 2/3)');
434
- }
435
-
436
- return firstSubUrl;
437
- }, kwargs.bvid);
438
-
439
- // Step 4: 拉取最终的 CDN 静态文件 (无鉴权)
440
- const finalRes = await fetch(subtitleUrls.startsWith('//') ? 'https:' + subtitleUrls : subtitleUrls);
441
- const subtitles = await finalRes.json();
442
- ```
533
+ > 💡 **级联请求**(如 BVID→CID→字幕)的完整模板和要点见下方[进阶模式: 级联请求](#进阶模式-级联请求-cascading-requests)章节。
443
534
 
444
535
  ---
445
536
 
@@ -504,68 +595,26 @@ opencli evaluate "(() => {
504
595
  └──────────────┘ └──────────────┘ └──────────────┘ └────────┘
505
596
  ```
506
597
 
507
- ### Verbose 模式
598
+ ### Verbose 模式 & 输出验证
508
599
 
509
600
  ```bash
510
- # 查看 pipeline 每步的输入输出
511
- opencli bilibili hot --limit 1 -v
512
- ```
513
-
514
- 输出示例:
515
- ```
516
- [1/4] navigate → https://www.bilibili.com
517
- → (no data)
518
- [2/4] evaluate → (async () => { const res = await fetch(…
519
- → [{title: "…", author: "…", play: 230835}]
520
- [3/4] map (rank, title, author, play, danmaku)
521
- → [{rank: 1, title: "…", author: "…"}]
522
- [4/4] limit → 1
523
- → [{rank: 1, title: "…"}]
524
- ```
525
-
526
- ### 输出格式验证
527
-
528
- ```bash
529
- # 确认表格渲染正确
530
- opencli mysite hot -f table
531
-
532
- # 确认 JSON 可被 jq 解析
533
- opencli mysite hot -f json | jq '.[0]'
534
-
535
- # 确认 CSV 可被导入
536
- opencli mysite hot -f csv > data.csv
601
+ opencli bilibili hot --limit 1 -v # 查看 pipeline 每步数据流
602
+ opencli mysite hot -f json | jq '.[0]' # 确认 JSON 可被解析
603
+ opencli mysite hot -f csv > data.csv # 确认 CSV 可导入
537
604
  ```
538
605
 
539
606
  ---
540
607
 
541
- ## Step 5: 注册 & 发布
542
-
543
- ### YAML 适配器
544
-
545
- 放入 `src/clis/<site>/<name>.yaml` 即自动注册,无需额外操作。
608
+ ## Step 5: 提交发布
546
609
 
547
- ### TS 适配器
548
-
549
- 放入 `src/clis/<site>/<name>.ts` 即自动加载模块,无需在 `index.ts` 中写入 `import`。
550
-
551
- ### 验证注册
610
+ 文件放入 `src/clis/<site>/` 即自动注册(YAML 或 TS 无需手动 import),然后:
552
611
 
553
612
  ```bash
554
- opencli list # 确认新命令出现
555
- opencli validate mysite # 校验定义完整性
613
+ opencli list | grep mysite # 确认注册
614
+ git add src/clis/mysite/ && git commit -m "feat(mysite): add hot" && git push
556
615
  ```
557
616
 
558
- ### 提交
559
-
560
- ```bash
561
- git add src/clis/mysite/
562
- git commit -m "feat(mysite): add hot and search adapters"
563
- git push
564
- ```
565
-
566
- ## 设计哲学: Zero-Dependency jq
567
-
568
- > 💡 **架构理念升级**: OpenCLI 的原生机制本质上内建了一个 **Zero-Dependency jq 数据处理流**。使用时不需要依赖系统命令级别的 `jq` 包,而是将所有的解析拍平动作放在 `evaluate` 块内的原生 JavaScript 里,再由外层 YAML 通过 `select`、`map` 等命令提取。这将彻底消灭跨操作系统下产生的第三方二进制库依赖。
617
+ > 💡 **架构理念**:OpenCLI 内建 **Zero-Dependency jq** 数据流 — 所有解析在 `evaluate` 的原生 JS 内完成,外层 YAML 用 `select`/`map` 提取,无需依赖系统 `jq` 二进制。
569
618
 
570
619
  ---
571
620
 
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  [![npm](https://img.shields.io/npm/v/@jackwener/opencli)](https://www.npmjs.com/package/@jackwener/opencli)
9
9
 
10
- A CLI tool that turns **any website** into a command-line interface. **46 commands** across **17 sites** — bilibili, zhihu, xiaohongshu, twitter, reddit, xueqiu, github, v2ex, hackernews, bbc, weibo, boss, yahoo-finance, reuters, smzdm, ctrip, youtube — powered by browser session reuse and AI-native discovery.
10
+ A CLI tool that turns **any website** into a command-line interface. **47 commands** across **17 sites** — bilibili, zhihu, xiaohongshu, twitter, reddit, xueqiu, github, v2ex, hackernews, bbc, weibo, boss, yahoo-finance, reuters, smzdm, ctrip, youtube — powered by browser session reuse and AI-native discovery.
11
11
 
12
12
  ## ✨ Highlights
13
13
 
@@ -82,7 +82,7 @@ Public API commands (`hackernews`, `github search`, `v2ex`) need no browser at a
82
82
 
83
83
  | Site | Commands | Mode |
84
84
  |------|----------|------|
85
- | **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `user-videos` `subtitle` `dynamic` `ranking` | 🔐 Browser |
85
+ | **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `user-videos` `subtitle` `dynamic` `ranking` `following` | 🔐 Browser |
86
86
  | **zhihu** | `hot` `search` `question` | 🔐 Browser |
87
87
  | **xiaohongshu** | `search` `notifications` `feed` `me` `user` | 🔐 Browser |
88
88
  | **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 🔐 Browser |
@@ -95,7 +95,7 @@ Public API commands (`hackernews`, `github search`, `v2ex`) need no browser at a
95
95
  | **reuters** | `search` | 🔐 Browser |
96
96
  | **smzdm** | `search` | 🔐 Browser |
97
97
  | **ctrip** | `search` | 🔐 Browser |
98
- | **github** | `trending` `search` | 🔐 / 🌐 |
98
+ | **github** | `search` | 🌐 Public |
99
99
  | **v2ex** | `hot` `latest` `topic` | 🌐 Public |
100
100
  | **hackernews** | `top` | 🌐 Public |
101
101
  | **bbc** | `news` | 🌐 Public |
package/README.zh-CN.md CHANGED
@@ -11,7 +11,7 @@ OpenCLI 通过 Chrome 浏览器 + [Playwright MCP Bridge](https://github.com/nic
11
11
 
12
12
  ## ✨ 亮点
13
13
 
14
- - 🌐 **46 个命令,17 个站点** — B站、知乎、小红书、Twitter、Reddit、雪球(xueqiu)、GitHub、V2EX、Hacker News、BBC、微博、BOSS直聘、Yahoo Finance、路透社、什么值得买、携程、YouTube
14
+ - 🌐 **47 个命令,17 个站点** — B站、知乎、小红书、Twitter、Reddit、雪球(xueqiu)、GitHub、V2EX、Hacker News、BBC、微博、BOSS直聘、Yahoo Finance、路透社、什么值得买、携程、YouTube
15
15
  - 🔐 **零风控** — 复用 Chrome 登录态,无需存储任何凭证
16
16
  - 🤖 **AI 原生** — `explore` 自动发现 API,`synthesize` 生成适配器,`cascade` 探测认证策略
17
17
  - 🚀 **动态加载引擎** — 只需将 `.ts` 或 `.yaml` 适配器放入 `clis/` 文件夹即可自动注册生效
@@ -83,7 +83,7 @@ npm install -g @jackwener/opencli@latest
83
83
 
84
84
  | 站点 | 命令 | 模式 |
85
85
  |------|------|------|
86
- | **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `user-videos` `subtitle` `dynamic` `ranking` | 🔐 浏览器 |
86
+ | **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `user-videos` `subtitle` `dynamic` `ranking` `following` | 🔐 浏览器 |
87
87
  | **zhihu** | `hot` `search` `question` | 🔐 浏览器 |
88
88
  | **xiaohongshu** | `search` `notifications` `feed` `me` `user` | 🔐 浏览器 |
89
89
  | **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 🔐 浏览器 |
@@ -96,7 +96,7 @@ npm install -g @jackwener/opencli@latest
96
96
  | **reuters** | `search` | 🔐 浏览器 |
97
97
  | **smzdm** | `search` | 🔐 浏览器 |
98
98
  | **ctrip** | `search` | 🔐 浏览器 |
99
- | **github** | `trending` `search` | 🔐 / 🌐 |
99
+ | **github** | `search` | 🌐 公共 API |
100
100
  | **v2ex** | `hot` `latest` `topic` | 🌐 公共 API |
101
101
  | **hackernews** | `top` | 🌐 公共 API |
102
102
  | **bbc** | `news` | 🌐 公共 API |
package/SKILL.md CHANGED
@@ -52,6 +52,7 @@ opencli bilibili user-videos --uid 12345 # 用户投稿
52
52
  opencli bilibili subtitle --bvid BV1xxx # 获取视频字幕 (支持 --lang zh-CN)
53
53
  opencli bilibili dynamic --limit 10 # 动态
54
54
  opencli bilibili ranking --limit 10 # 排行榜
55
+ opencli bilibili following --limit 20 # 我的关注列表 (支持 --uid 查看他人)
55
56
 
56
57
  # 知乎 (browser)
57
58
  opencli zhihu hot --limit 10 # 知乎热榜
@@ -70,9 +71,10 @@ opencli xueqiu hot-stock --limit 10 # 雪球热门股票榜
70
71
  opencli xueqiu stock --symbol SH600519 # 查看股票实时行情
71
72
  opencli xueqiu watchlist # 获取自选股/持仓列表
72
73
  opencli xueqiu feed # 我的关注 timeline
74
+ opencli xueqiu hot --limit 10 # 雪球热榜
75
+ opencli xueqiu search --keyword "特斯拉" # 搜索
73
76
 
74
- # GitHub (trending=browser, search=public)
75
- opencli github trending --limit 10 # GitHub Trending
77
+ # GitHub (public)
76
78
  opencli github search --keyword "cli" # 搜索仓库
77
79
 
78
80
  # Twitter/X (browser)
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Build-time CLI manifest compiler.
4
+ *
5
+ * Scans all YAML/TS CLI definitions and pre-compiles them into a single
6
+ * manifest.json for instant cold-start registration (no runtime YAML parsing).
7
+ *
8
+ * Usage: npx tsx src/build-manifest.ts
9
+ * Output: dist/cli-manifest.json
10
+ */
11
+ export {};
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Build-time CLI manifest compiler.
4
+ *
5
+ * Scans all YAML/TS CLI definitions and pre-compiles them into a single
6
+ * manifest.json for instant cold-start registration (no runtime YAML parsing).
7
+ *
8
+ * Usage: npx tsx src/build-manifest.ts
9
+ * Output: dist/cli-manifest.json
10
+ */
11
+ import * as fs from 'node:fs';
12
+ import * as path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import yaml from 'js-yaml';
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ const CLIS_DIR = path.resolve(__dirname, 'clis');
17
+ const OUTPUT = path.resolve(__dirname, '..', 'dist', 'cli-manifest.json');
18
+ function scanYaml(filePath, site) {
19
+ try {
20
+ const raw = fs.readFileSync(filePath, 'utf-8');
21
+ const def = yaml.load(raw);
22
+ if (!def || typeof def !== 'object')
23
+ return null;
24
+ const strategyStr = def.strategy ?? (def.browser === false ? 'public' : 'cookie');
25
+ const strategy = strategyStr.toUpperCase();
26
+ const browser = def.browser ?? (strategy !== 'PUBLIC');
27
+ const args = [];
28
+ if (def.args && typeof def.args === 'object') {
29
+ for (const [argName, argDef] of Object.entries(def.args)) {
30
+ args.push({
31
+ name: argName,
32
+ type: argDef?.type ?? 'str',
33
+ default: argDef?.default,
34
+ required: argDef?.required ?? false,
35
+ help: argDef?.description ?? argDef?.help ?? '',
36
+ choices: argDef?.choices,
37
+ });
38
+ }
39
+ }
40
+ return {
41
+ site: def.site ?? site,
42
+ name: def.name ?? path.basename(filePath, path.extname(filePath)),
43
+ description: def.description ?? '',
44
+ domain: def.domain,
45
+ strategy: strategy.toLowerCase(),
46
+ browser,
47
+ args,
48
+ columns: def.columns,
49
+ pipeline: def.pipeline,
50
+ timeout: def.timeout,
51
+ type: 'yaml',
52
+ };
53
+ }
54
+ catch (err) {
55
+ process.stderr.write(`Warning: failed to parse ${filePath}: ${err.message}\n`);
56
+ return null;
57
+ }
58
+ }
59
+ function scanTs(filePath, site) {
60
+ // TS adapters self-register via cli() at import time.
61
+ // We statically parse the source to extract metadata for the manifest stub.
62
+ const baseName = path.basename(filePath, path.extname(filePath));
63
+ const relativePath = `${site}/${baseName}.js`;
64
+ const entry = {
65
+ site,
66
+ name: baseName,
67
+ description: '',
68
+ strategy: 'cookie',
69
+ browser: true,
70
+ args: [],
71
+ type: 'ts',
72
+ modulePath: relativePath,
73
+ };
74
+ try {
75
+ const src = fs.readFileSync(filePath, 'utf-8');
76
+ // Extract description
77
+ const descMatch = src.match(/description\s*:\s*['"`]([^'"`]*)['"`]/);
78
+ if (descMatch)
79
+ entry.description = descMatch[1];
80
+ // Extract domain
81
+ const domainMatch = src.match(/domain\s*:\s*['"`]([^'"`]*)['"`]/);
82
+ if (domainMatch)
83
+ entry.domain = domainMatch[1];
84
+ // Extract strategy
85
+ const stratMatch = src.match(/strategy\s*:\s*Strategy\.(\w+)/);
86
+ if (stratMatch)
87
+ entry.strategy = stratMatch[1].toLowerCase();
88
+ // Extract columns
89
+ const colMatch = src.match(/columns\s*:\s*\[([^\]]*)\]/);
90
+ if (colMatch) {
91
+ entry.columns = colMatch[1].split(',').map(s => s.trim().replace(/^['"`]|['"`]$/g, '')).filter(Boolean);
92
+ }
93
+ // Extract args array items: { name: '...', ... }
94
+ const argsBlockMatch = src.match(/args\s*:\s*\[([\s\S]*?)\]\s*,/);
95
+ if (argsBlockMatch) {
96
+ const argsBlock = argsBlockMatch[1];
97
+ const argRegex = /\{\s*name\s*:\s*['"`](\w+)['"`]([^}]*)\}/g;
98
+ let m;
99
+ while ((m = argRegex.exec(argsBlock)) !== null) {
100
+ const argName = m[1];
101
+ const body = m[2];
102
+ const typeMatch = body.match(/type\s*:\s*['"`](\w+)['"`]/);
103
+ const defaultMatch = body.match(/default\s*:\s*([^,}]+)/);
104
+ const requiredMatch = body.match(/required\s*:\s*(true|false)/);
105
+ const helpMatch = body.match(/help\s*:\s*['"`]([^'"`]*)['"`]/);
106
+ let defaultVal = undefined;
107
+ if (defaultMatch) {
108
+ const raw = defaultMatch[1].trim();
109
+ if (raw === 'true')
110
+ defaultVal = true;
111
+ else if (raw === 'false')
112
+ defaultVal = false;
113
+ else if (/^\d+$/.test(raw))
114
+ defaultVal = parseInt(raw, 10);
115
+ else if (/^\d+\.\d+$/.test(raw))
116
+ defaultVal = parseFloat(raw);
117
+ else
118
+ defaultVal = raw.replace(/^['"`]|['"`]$/g, '');
119
+ }
120
+ entry.args.push({
121
+ name: argName,
122
+ type: typeMatch?.[1] ?? 'str',
123
+ default: defaultVal,
124
+ required: requiredMatch?.[1] === 'true',
125
+ help: helpMatch?.[1] ?? '',
126
+ });
127
+ }
128
+ }
129
+ }
130
+ catch {
131
+ // If parsing fails, fall back to empty metadata — module will self-register at runtime
132
+ }
133
+ return entry;
134
+ }
135
+ // Main
136
+ const manifest = [];
137
+ if (fs.existsSync(CLIS_DIR)) {
138
+ for (const site of fs.readdirSync(CLIS_DIR)) {
139
+ const siteDir = path.join(CLIS_DIR, site);
140
+ if (!fs.statSync(siteDir).isDirectory())
141
+ continue;
142
+ for (const file of fs.readdirSync(siteDir)) {
143
+ const filePath = path.join(siteDir, file);
144
+ if (file.endsWith('.yaml') || file.endsWith('.yml')) {
145
+ const entry = scanYaml(filePath, site);
146
+ if (entry)
147
+ manifest.push(entry);
148
+ }
149
+ else if ((file.endsWith('.ts') && !file.endsWith('.d.ts') && file !== 'index.ts') ||
150
+ (file.endsWith('.js') && !file.endsWith('.d.js') && file !== 'index.js')) {
151
+ manifest.push(scanTs(filePath, site));
152
+ }
153
+ }
154
+ }
155
+ }
156
+ // Ensure output directory exists
157
+ fs.mkdirSync(path.dirname(OUTPUT), { recursive: true });
158
+ fs.writeFileSync(OUTPUT, JSON.stringify(manifest, null, 2));
159
+ const yamlCount = manifest.filter(e => e.type === 'yaml').length;
160
+ const tsCount = manifest.filter(e => e.type === 'ts').length;
161
+ console.log(`✅ Manifest compiled: ${manifest.length} entries (${yamlCount} YAML, ${tsCount} TS) → ${OUTPUT}`);