Given a URL + a one-sentence description, generate a CLI command in 4 steps. For full exploratory development, see opencli-explorer skill。
Switch to explorer immediately in the following cases; don't keep forcing it in oneshot:
- Step 3 verification: fetch consistently fails to get data (signing/risk control, not something cookie/header can solve)
- Need a Pinia Store Action to trigger the API
- Need to generate 2 or more commands for the same site
opencli browser networkis completely empty, and baseURL can't be found in the JS bundle either
Input
| Project | Example |
|---|---|
| URL | https://x.com/jakevin7/lists |
| Goal | Get my Twitter Lists |
Flow
Step 1: Open the page + capture traffic
opencli browser open <目标 URL> # 打开目标页面(自动开始抓包)
opencli browser wait time 3 # 等页面加载完、API 请求触发
opencli browser network # 查看捕获的 JSON API 请求
Key:network By default, static resources are already filtered out, showing only JSON/XML/text API requests.
If the API isn't triggered automatically, use opencli browser state to find the button index,opencli browser click <N> click it, then network capture again.
network is empty? ① Re- open refresh the capture window; ② If it's an SPA, the API domain may be api.xxx.com rather than app.xxx.com, use the bundle search in Step 2 to find the real baseURL.
Step 2: Lock onto one endpoint
From the opencli browser network results, findthattarget API, use --detail to view the full response:
opencli browser network --detail <N> # 查看第 N 条请求的完整响应体
Pay attention to these fields:
| Field | What to look for |
|---|---|
| URL | API path pattern (e.g. /i/api/graphql/xxx/ListsManagePinTimeline) |
| Method | GET / POST |
| Headers | Has Cookie? Bearer? CSRF? Custom signature? |
| Response | Which path the data is at (e.g. data.list.lists) |
SPA note: If the API host in
networkdiffers from the page host (e.g.api.xxx.comvsapp.xxx.com), then for subsequentfetchuse the full URL. Ifnetworkhas no API requests at all, search the JS bundle for baseURL:opencli browser eval "(async()=>{const s=[...document.querySelectorAll('script[src]')].map(e=>e.src).find(s=>s.match(/index|main|app/));const t=await fetch(s).then(r=>r.text());const m=t.indexOf('baseURL')>-1?t.indexOf('baseURL'):t.indexOf('baseUrl');return m>-1?t.slice(m-10,m+80):'not found'})()"
Step 3: Verify the endpoint can be reproduced
Use opencli browser eval to reproduce the request in the page via fetch :
# Tier 2 (Cookie): 传统网站
opencli browser eval "fetch('/api/endpoint', { credentials: 'include' }).then(r => r.json())"
# Tier 2.5 (localStorage Bearer): 现代 SaaS 主流(slock、Linear、Notion 等)
opencli browser eval "(async () => {
const token = localStorage.getItem('access_token'); // 换成实际 key
const res = await fetch('https://api.example.com/endpoint', {
headers: { 'Authorization': 'Bearer ' + token },
credentials: 'include'
});
return res.json();
})()"
# Tier 3 (Header): 如 Twitter 需要额外 header
opencli browser eval "(async () => {
const ct0 = document.cookie.match(/ct0=([^;]+)/)?.[1];
const res = await fetch('/api/endpoint', {
headers: { 'Authorization': 'Bearer ...', 'X-Csrf-Token': ct0 },
credentials: 'include'
});
return res.json();
})()"
Carries Bearer but returns
{ error: "Missing X-Xxx-Id header" }(HTTP 400) → multi-tenant SaaS needs a business context Header. First call/serversor/workspacesto get the ID, then add it to headers.
If fetch can get the data → use a TS adapter (cli() pipeline or func()).
If fetch can't get it (signing/risk control) → use the intercept strategy (TS func() + installInterceptor)。
Step 4: Apply the template, generate the adapter
Based on the strategy determined in Step 3, pick a template to generate the file.
Auth quick reference
fetch(url) 直接能拿到? → Tier 1: public (browser: false)
fetch(url, {credentials:'include'})? → Tier 2: cookie
localStorage 有 token + Bearer header 能拿到? → Tier 2.5: localStorage Bearer ← 现代 SaaS 主流
带了 Bearer 但返回 400 "Missing X-Xxx header"? → 先拿业务上下文 ID,加进 header
加 CSRF/Bearer header 后拿到? → Tier 3: header
都不行,但页面自己能请求成功? → Tier 4: intercept (installInterceptor)
Templates
JS — Cookie/Public (simplest,func() mode)
// clis/<site>/<name>.js
import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'mysite',
name: 'mycommand',
description: '一句话描述',
domain: 'www.example.com',
strategy: Strategy.COOKIE, // 或 Strategy.PUBLIC (加 browser: false)
browser: true,
args: [
{ name: 'limit', type: 'int', default: 20 },
],
columns: ['rank', 'title', 'value'],
func: async (page, kwargs) => {
await page.goto('https://www.example.com/target-page');
const data = await page.evaluate(`(async () => {
const res = await fetch('/api/target', { credentials: 'include' });
const d = await res.json();
return (d.data?.items || []).map(item => ({
title: item.title,
value: item.value,
}));
})()`);
return (data as any[]).slice(0, kwargs.limit).map((item, i) => ({
rank: i + 1,
title: item.title || '',
value: item.value || '',
}));
},
});
JS — localStorage Bearer (modern SaaS)
// clis/<site>/<name>.js
import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'mysite',
name: 'mycommand',
description: '一句话描述',
domain: 'app.example.com',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'limit', type: 'int', default: 20 },
],
columns: ['rank', 'title', 'value'],
func: async (page, kwargs) => {
await page.goto('https://app.example.com');
const data = await page.evaluate(`(async () => {
const token = localStorage.getItem('access_token');
// 多租户 SaaS:先拿工作空间 ID
const servers = await fetch('https://api.example.com/api/servers', {
headers: { 'Authorization': 'Bearer ' + token }
}).then(r => r.json());
const server = servers[0];
const res = await fetch('https://api.example.com/api/items', {
headers: { 'Authorization': 'Bearer ' + token, 'X-Server-Id': server.id }
});
return res.json();
})()`);
return (data as any[]).slice(0, kwargs.limit).map((item, i) => ({
rank: i + 1,
title: item.title || '',
value: item.value || '',
}));
},
});
JS — Intercept (capture mode)
// clis/<site>/<name>.js
import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'mysite',
name: 'mycommand',
description: '一句话描述',
domain: 'www.example.com',
strategy: Strategy.INTERCEPT,
browser: true,
args: [
{ name: 'limit', type: 'int', default: 20 },
],
columns: ['rank', 'title', 'value'],
func: async (page, kwargs) => {
// 1. 导航
await page.goto('https://www.example.com/target-page');
await page.wait(3);
// 2. 注入拦截器(URL 子串匹配)
await page.installInterceptor('target-api-keyword');
// 3. 触发 API(滚动/点击)
await page.autoScroll({ times: 2, delayMs: 2000 });
// 4. 读取拦截的响应
const requests = await page.getInterceptedRequests();
if (!requests?.length) return [];
let results: any[] = [];
for (const req of requests) {
const items = req.data?.data?.items || [];
results.push(...items);
}
return results.slice(0, kwargs.limit).map((item, i) => ({
rank: i + 1,
title: item.title || '',
value: item.value || '',
}));
},
});
TS — Header (e.g. Twitter GraphQL)
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError } from '@jackwener/opencli/errors';
cli({
site: 'twitter',
name: 'mycommand',
description: '一句话描述',
domain: 'x.com',
strategy: Strategy.HEADER,
browser: true,
args: [
{ name: 'limit', type: 'int', default: 20 },
],
columns: ['rank', 'name', 'value'],
func: async (page, kwargs) => {
await page.goto('https://x.com');
const data = await page.evaluate(`(async () => {
const ct0 = document.cookie.match(/ct0=([^;]+)/)?.[1];
if (!ct0) return { error: 'Not logged in' };
const bearer = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D...';
const res = await fetch('/i/api/graphql/QUERY_ID/Endpoint', {
headers: {
'Authorization': 'Bearer ' + decodeURIComponent(bearer),
'X-Csrf-Token': ct0,
'X-Twitter-Auth-Type': 'OAuth2Session',
},
credentials: 'include',
});
return res.json();
})()`);
if ((data as any).error) throw new AuthRequiredError('x.com');
// 解析 data...
return [];
},
});
Testing (required)
Two development scenarios:
- Repo contribution: Put the file in
clis/<site>/<name>.js,npm run buildand it auto-registers- Private adapter(for local use, no PR needed): Put the file in
~/.opencli/clis/<site>/<name>.js, no build needed
# Repo 贡献:build 后直接运行
npm run build
opencli list | grep mysite # 确认注册
opencli mysite mycommand --limit 3 -v # 实际运行
# 私人 adapter(~/.opencli/clis/):一键验证
opencli browser verify <site>/<name>
Done criteria: After the command runs, it returns a non-empty table, and the fields match expectations.
That's it, nothing more
After writing the files → build + run (Repo contribution) or browser verify (private adapter) → submit. If there are issues, look again at opencli-explorer skill。