@jackwener/opencli 1.7.1 → 1.7.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.
Files changed (54) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/cli-manifest.json +386 -1
  4. package/clis/binance/asks.js +21 -0
  5. package/clis/binance/commands.test.js +70 -0
  6. package/clis/binance/depth.js +21 -0
  7. package/clis/binance/gainers.js +22 -0
  8. package/clis/binance/klines.js +21 -0
  9. package/clis/binance/losers.js +22 -0
  10. package/clis/binance/pairs.js +21 -0
  11. package/clis/binance/price.js +18 -0
  12. package/clis/binance/prices.js +19 -0
  13. package/clis/binance/ticker.js +21 -0
  14. package/clis/binance/top.js +21 -0
  15. package/clis/binance/trades.js +20 -0
  16. package/clis/twitter/lists-parser.js +77 -0
  17. package/clis/twitter/lists.d.ts +5 -0
  18. package/clis/twitter/lists.js +62 -0
  19. package/clis/twitter/lists.test.js +50 -0
  20. package/clis/weibo/feed.js +18 -5
  21. package/clis/zsxq/topic.js +5 -3
  22. package/clis/zsxq/topic.test.js +4 -3
  23. package/clis/zsxq/utils.js +1 -1
  24. package/dist/src/cli.js +108 -0
  25. package/dist/src/discovery.d.ts +5 -2
  26. package/dist/src/discovery.js +7 -35
  27. package/dist/src/engine.test.js +29 -1
  28. package/dist/src/main.js +6 -5
  29. package/package.json +3 -3
  30. package/scripts/fetch-adapters.js +59 -28
  31. package/dist/src/clis/binance/asks.d.ts +0 -1
  32. package/dist/src/clis/binance/asks.js +0 -20
  33. package/dist/src/clis/binance/commands.test.d.ts +0 -3
  34. package/dist/src/clis/binance/commands.test.js +0 -58
  35. package/dist/src/clis/binance/depth.d.ts +0 -1
  36. package/dist/src/clis/binance/depth.js +0 -20
  37. package/dist/src/clis/binance/gainers.d.ts +0 -1
  38. package/dist/src/clis/binance/gainers.js +0 -21
  39. package/dist/src/clis/binance/klines.d.ts +0 -1
  40. package/dist/src/clis/binance/klines.js +0 -20
  41. package/dist/src/clis/binance/losers.d.ts +0 -1
  42. package/dist/src/clis/binance/losers.js +0 -21
  43. package/dist/src/clis/binance/pairs.d.ts +0 -1
  44. package/dist/src/clis/binance/pairs.js +0 -20
  45. package/dist/src/clis/binance/price.d.ts +0 -1
  46. package/dist/src/clis/binance/price.js +0 -17
  47. package/dist/src/clis/binance/prices.d.ts +0 -1
  48. package/dist/src/clis/binance/prices.js +0 -18
  49. package/dist/src/clis/binance/ticker.d.ts +0 -1
  50. package/dist/src/clis/binance/ticker.js +0 -20
  51. package/dist/src/clis/binance/top.d.ts +0 -1
  52. package/dist/src/clis/binance/top.js +0 -20
  53. package/dist/src/clis/binance/trades.d.ts +0 -1
  54. package/dist/src/clis/binance/trades.js +0 -19
@@ -0,0 +1,19 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+
3
+ cli({
4
+ site: 'binance',
5
+ name: 'prices',
6
+ description: 'Latest prices for all trading pairs',
7
+ domain: 'data-api.binance.vision',
8
+ strategy: Strategy.PUBLIC,
9
+ browser: false,
10
+ args: [
11
+ { name: 'limit', type: 'int', default: 20, help: 'Number of prices' },
12
+ ],
13
+ columns: ['rank', 'symbol', 'price'],
14
+ pipeline: [
15
+ { fetch: { url: 'https://data-api.binance.vision/api/v3/ticker/price' } },
16
+ { map: { rank: '${{ index + 1 }}', symbol: '${{ item.symbol }}', price: '${{ item.price }}' } },
17
+ { limit: '${{ args.limit }}' },
18
+ ],
19
+ });
@@ -0,0 +1,21 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+
3
+ cli({
4
+ site: 'binance',
5
+ name: 'ticker',
6
+ description: '24h ticker statistics for top trading pairs by volume',
7
+ domain: 'data-api.binance.vision',
8
+ strategy: Strategy.PUBLIC,
9
+ browser: false,
10
+ args: [
11
+ { name: 'limit', type: 'int', default: 20, help: 'Number of tickers' },
12
+ ],
13
+ columns: ['symbol', 'price', 'change_pct', 'high', 'low', 'volume', 'quote_vol', 'trades'],
14
+ pipeline: [
15
+ { fetch: { url: 'https://data-api.binance.vision/api/v3/ticker/24hr' } },
16
+ { map: { symbol: '${{ item.symbol }}', price: '${{ item.lastPrice }}', change_pct: '${{ item.priceChangePercent }}', high: '${{ item.highPrice }}', low: '${{ item.lowPrice }}', volume: '${{ item.volume }}', quote_vol: '${{ item.quoteVolume }}', trades: '${{ item.count }}', sort_volume: '${{ Number(item.quoteVolume) }}' } },
17
+ { sort: { by: 'sort_volume', order: 'desc' } },
18
+ { map: { symbol: '${{ item.symbol }}', price: '${{ item.lastPrice }}', change_pct: '${{ item.priceChangePercent }}', high: '${{ item.highPrice }}', low: '${{ item.lowPrice }}', volume: '${{ item.volume }}', quote_vol: '${{ item.quoteVolume }}', trades: '${{ item.count }}' } },
19
+ { limit: '${{ args.limit }}' },
20
+ ],
21
+ });
@@ -0,0 +1,21 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+
3
+ cli({
4
+ site: 'binance',
5
+ name: 'top',
6
+ description: 'Top trading pairs by 24h volume on Binance',
7
+ domain: 'data-api.binance.vision',
8
+ strategy: Strategy.PUBLIC,
9
+ browser: false,
10
+ args: [
11
+ { name: 'limit', type: 'int', default: 20, help: 'Number of trading pairs' },
12
+ ],
13
+ columns: ['rank', 'symbol', 'price', 'change_24h', 'high', 'low', 'volume'],
14
+ pipeline: [
15
+ { fetch: { url: 'https://data-api.binance.vision/api/v3/ticker/24hr' } },
16
+ { map: { symbol: '${{ item.symbol }}', price: '${{ item.lastPrice }}', change_24h: '${{ item.priceChangePercent }}', high: '${{ item.highPrice }}', low: '${{ item.lowPrice }}', volume: '${{ item.quoteVolume }}', sort_volume: '${{ Number(item.quoteVolume) }}' } },
17
+ { sort: { by: 'sort_volume', order: 'desc' } },
18
+ { map: { rank: '${{ index + 1 }}', symbol: '${{ item.symbol }}', price: '${{ item.lastPrice }}', change_24h: '${{ item.priceChangePercent }}', high: '${{ item.highPrice }}', low: '${{ item.lowPrice }}', volume: '${{ item.quoteVolume }}' } },
19
+ { limit: '${{ args.limit }}' },
20
+ ],
21
+ });
@@ -0,0 +1,20 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+
3
+ cli({
4
+ site: 'binance',
5
+ name: 'trades',
6
+ description: 'Recent trades for a trading pair',
7
+ domain: 'data-api.binance.vision',
8
+ strategy: Strategy.PUBLIC,
9
+ browser: false,
10
+ args: [
11
+ { name: 'symbol', type: 'str', required: true, positional: true, help: 'Trading pair symbol (e.g. BTCUSDT, ETHUSDT)' },
12
+ { name: 'limit', type: 'int', default: 20, help: 'Number of trades (max 1000)' },
13
+ ],
14
+ columns: ['id', 'price', 'qty', 'quote_qty', 'buyer_maker'],
15
+ pipeline: [
16
+ { fetch: { url: 'https://data-api.binance.vision/api/v3/trades?symbol=${{ args.symbol }}&limit=${{ args.limit }}' } },
17
+ { map: { id: '${{ item.id }}', price: '${{ item.price }}', qty: '${{ item.qty }}', quote_qty: '${{ item.quoteQty }}', buyer_maker: '${{ item.isBuyerMaker }}' } },
18
+ { limit: '${{ args.limit }}' },
19
+ ],
20
+ });
@@ -0,0 +1,77 @@
1
+ const MEMBER_PATTERNS = [
2
+ /([\d.,]+(?:\s?[KMB千萬万亿])?)\s*members?/i,
3
+ /([\d.,]+(?:\s?[KMB千萬万亿])?)\s*位成员/,
4
+ ];
5
+ const FOLLOWER_PATTERNS = [
6
+ /([\d.,]+(?:\s?[KMB千萬万亿])?)\s*followers?/i,
7
+ /([\d.,]+(?:\s?[KMB千萬万亿])?)\s*位关注者/,
8
+ ];
9
+ const PRIVATE_PATTERNS = [/\bprivate\b/i, /锁定列表/];
10
+ const EMPTY_STATE_PATTERNS = [
11
+ /hasn't created any lists/i,
12
+ /has not created any lists/i,
13
+ /no lists yet/i,
14
+ /没有创建任何列表/,
15
+ /还没有创建任何列表/,
16
+ ];
17
+ function normalizeText(text) {
18
+ return String(text || '').replace(/\s+/g, ' ').trim();
19
+ }
20
+ function matchMetric(text, patterns) {
21
+ for (const pattern of patterns) {
22
+ const match = text.match(pattern);
23
+ if (match)
24
+ return normalizeText(match[1]);
25
+ }
26
+ return '0';
27
+ }
28
+ function looksLikeMetadata(line) {
29
+ const text = normalizeText(line);
30
+ if (!text)
31
+ return true;
32
+ if (text.startsWith('@'))
33
+ return true;
34
+ if (MEMBER_PATTERNS.some((pattern) => pattern.test(text)))
35
+ return true;
36
+ if (FOLLOWER_PATTERNS.some((pattern) => pattern.test(text)))
37
+ return true;
38
+ if (PRIVATE_PATTERNS.some((pattern) => pattern.test(text)))
39
+ return true;
40
+ if (/^(public|pinned)$/i.test(text))
41
+ return true;
42
+ if (/^(lists?|你的列表)$/i.test(text))
43
+ return true;
44
+ return false;
45
+ }
46
+ export function parseListCards(cards) {
47
+ const seen = new Set();
48
+ const results = [];
49
+ for (const card of cards || []) {
50
+ const href = normalizeText(card?.href);
51
+ const rawText = String(card?.text || '');
52
+ if (!href || seen.has(href))
53
+ continue;
54
+ seen.add(href);
55
+ const text = normalizeText(rawText);
56
+ if (!text)
57
+ continue;
58
+ const lines = rawText
59
+ .split('\n')
60
+ .map((line) => normalizeText(line))
61
+ .filter(Boolean);
62
+ const name = lines.find((line) => !looksLikeMetadata(line));
63
+ if (!name)
64
+ continue;
65
+ results.push({
66
+ name,
67
+ members: matchMetric(text, MEMBER_PATTERNS),
68
+ followers: matchMetric(text, FOLLOWER_PATTERNS),
69
+ mode: PRIVATE_PATTERNS.some((pattern) => pattern.test(text)) ? 'private' : 'public',
70
+ });
71
+ }
72
+ return results;
73
+ }
74
+ export function isEmptyListsState(text) {
75
+ const normalized = normalizeText(text);
76
+ return EMPTY_STATE_PATTERNS.some((pattern) => pattern.test(normalized));
77
+ }
@@ -0,0 +1,5 @@
1
+ import { Argument, Column } from '@jackwener/opencli/types';
2
+ declare const args: Argument[];
3
+ declare const columns: Column[];
4
+ export { args, columns };
5
+ export default {};
@@ -0,0 +1,62 @@
1
+ import { AuthRequiredError, SelectorError } from '@jackwener/opencli/errors';
2
+ import { cli, Strategy } from '@jackwener/opencli/registry';
3
+ import { isEmptyListsState, parseListCards } from './lists-parser.js';
4
+
5
+ cli({
6
+ site: 'twitter',
7
+ name: 'lists',
8
+ description: 'Get Twitter/X lists for a user',
9
+ domain: 'x.com',
10
+ strategy: Strategy.COOKIE,
11
+ browser: true,
12
+ args: [
13
+ { name: 'user', positional: true, type: 'string', required: false },
14
+ { name: 'limit', type: 'int', default: 50 },
15
+ ],
16
+ columns: ['name', 'members', 'followers', 'mode'],
17
+ func: async (page, kwargs) => {
18
+ let targetUser = kwargs.user;
19
+ if (!targetUser) {
20
+ await page.goto('https://x.com/home');
21
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
22
+ const href = await page.evaluate(`() => {
23
+ const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
24
+ return link ? link.getAttribute('href') : null;
25
+ }`);
26
+ if (!href) {
27
+ throw new AuthRequiredError('x.com', 'Could not find logged-in user profile link. Are you logged in?');
28
+ }
29
+ targetUser = href.replace('/', '');
30
+ }
31
+ await page.goto(`https://x.com/${targetUser}/lists`);
32
+ await page.wait(3);
33
+ const pageData = await page.evaluate(`() => {
34
+ const cards = [];
35
+ const seen = new Set();
36
+ for (const anchor of Array.from(document.querySelectorAll('a[href*="/i/lists/"]'))) {
37
+ const href = anchor.getAttribute('href') || '';
38
+ if (!/\\/i\\/lists\\/\\d+/.test(href) || seen.has(href)) continue;
39
+ seen.add(href);
40
+ const container = anchor.closest('[data-testid="cellInnerDiv"]') || anchor;
41
+ const text = (container.innerText || anchor.innerText || '').trim();
42
+ if (!text) continue;
43
+ cards.push({ href, text });
44
+ }
45
+ return {
46
+ cards,
47
+ pageText: document.body.innerText || '',
48
+ };
49
+ }`);
50
+ if (!pageData?.pageText) {
51
+ throw new SelectorError('Twitter lists', 'Empty page text');
52
+ }
53
+ const results = parseListCards(pageData.cards);
54
+ if (results.length === 0) {
55
+ if (isEmptyListsState(pageData.pageText)) {
56
+ return [];
57
+ }
58
+ throw new SelectorError('Twitter lists', `Could not parse list data`);
59
+ }
60
+ return results.slice(0, kwargs.limit);
61
+ }
62
+ });
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { isEmptyListsState, parseListCards } from './lists-parser.js';
3
+
4
+ describe('twitter lists parser', () => {
5
+ it('parses english list cards without relying on page locale', () => {
6
+ const result = parseListCards([
7
+ {
8
+ href: '/i/lists/123',
9
+ text: `AI Researchers
10
+ @jack
11
+ 124 Members 3.4K Followers
12
+ Private`,
13
+ },
14
+ ]);
15
+ expect(result).toEqual([
16
+ {
17
+ name: 'AI Researchers',
18
+ members: '124',
19
+ followers: '3.4K',
20
+ mode: 'private',
21
+ },
22
+ ]);
23
+ });
24
+
25
+ it('parses chinese list cards without scanning document.body.innerText', () => {
26
+ const result = parseListCards([
27
+ {
28
+ href: '/i/lists/456',
29
+ text: `AI观察
30
+ @jack
31
+ 321 位成员 8.8K 位关注者
32
+ 锁定列表`,
33
+ },
34
+ ]);
35
+ expect(result).toEqual([
36
+ {
37
+ name: 'AI观察',
38
+ members: '321',
39
+ followers: '8.8K',
40
+ mode: 'private',
41
+ },
42
+ ]);
43
+ });
44
+
45
+ it('detects empty state text in english and chinese', () => {
46
+ expect(isEmptyListsState(`@jack hasn't created any Lists yet`)).toBe(true);
47
+ expect(isEmptyListsState('这个账号还没有创建任何列表')).toBe(true);
48
+ expect(isEmptyListsState('AI Researchers 124 Members')).toBe(false);
49
+ });
50
+ });
@@ -1,20 +1,32 @@
1
1
  /**
2
- * Weibo feed — home timeline from followed users.
2
+ * Weibo feed — for-you or following timeline.
3
3
  */
4
4
  import { cli, Strategy } from '@jackwener/opencli/registry';
5
5
  import { getSelfUid } from './utils.js';
6
+ const TIMELINE_ENDPOINTS = {
7
+ 'for-you': 'unreadfriendstimeline',
8
+ following: 'friendstimeline',
9
+ };
6
10
  cli({
7
11
  site: 'weibo',
8
12
  name: 'feed',
9
- description: 'Weibo home timeline (posts from followed users)',
13
+ description: 'Fetch Weibo timeline (for-you or following)',
10
14
  domain: 'weibo.com',
11
15
  strategy: Strategy.COOKIE,
12
16
  args: [
17
+ {
18
+ name: 'type',
19
+ default: 'for-you',
20
+ choices: ['for-you', 'following'],
21
+ help: 'Timeline type: for-you (algorithmic) or following (chronological)',
22
+ },
13
23
  { name: 'limit', type: 'int', default: 15, help: 'Number of posts (max 50)' },
14
24
  ],
15
25
  columns: ['author', 'text', 'reposts', 'comments', 'likes', 'time', 'url'],
16
26
  func: async (page, kwargs) => {
17
27
  const count = Math.min(kwargs.limit || 15, 50);
28
+ const timelineType = kwargs.type === 'following' ? 'following' : 'for-you';
29
+ const endpoint = TIMELINE_ENDPOINTS[timelineType];
18
30
  await page.goto('https://weibo.com');
19
31
  await page.wait(2);
20
32
  const uid = await getSelfUid(page);
@@ -22,13 +34,14 @@ cli({
22
34
  (async () => {
23
35
  const uid = ${JSON.stringify(uid)};
24
36
  const count = ${count};
37
+ const endpoint = ${JSON.stringify(endpoint)};
25
38
  const listId = '10001' + uid;
26
39
  const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').trim();
27
40
 
28
- const resp = await fetch('/ajax/feed/unreadfriendstimeline?list_id=' + listId + '&refresh=4&since_id=0&count=' + count, {credentials: 'include'});
29
- if (!resp.ok) return {error: 'HTTP ' + resp.status};
41
+ const resp = await fetch('/ajax/feed/' + endpoint + '?list_id=' + listId + '&refresh=4&since_id=0&count=' + count, { credentials: 'include' });
42
+ if (!resp.ok) return { error: 'HTTP ' + resp.status };
30
43
  const data = await resp.json();
31
- if (!data.ok) return {error: 'API error: ' + (data.msg || 'unknown')};
44
+ if (!data.ok) return { error: 'API error: ' + (data.msg || 'unknown') };
32
45
 
33
46
  return (data.statuses || []).slice(0, count).map(s => {
34
47
  const u = s.user || {};
@@ -1,6 +1,6 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { CliError } from '@jackwener/opencli/errors';
3
- import { browserJsonRequest, ensureZsxqAuth, ensureZsxqPage, fetchFirstJson, getCommentsFromResponse, getTopicFromResponse, getTopicUrl, summarizeComments, toTopicRow, } from './utils.js';
3
+ import { getActiveGroupId, browserJsonRequest, ensureZsxqAuth, ensureZsxqPage, fetchFirstJson, getCommentsFromResponse, getTopicFromResponse, getTopicUrl, summarizeComments, toTopicRow, } from './utils.js';
4
4
  cli({
5
5
  site: 'zsxq',
6
6
  name: 'topic',
@@ -10,6 +10,7 @@ cli({
10
10
  browser: true,
11
11
  args: [
12
12
  { name: 'id', required: true, positional: true, help: 'Topic ID' },
13
+ { name: 'group_id', help: 'Group ID (optional; defaults to active group in Chrome)' },
13
14
  { name: 'comment_limit', type: 'int', default: 20, help: 'Number of comments to fetch' },
14
15
  ],
15
16
  columns: ['topic_id', 'type', 'author', 'title', 'comments', 'likes', 'comment_preview', 'url'],
@@ -17,8 +18,9 @@ cli({
17
18
  await ensureZsxqPage(page);
18
19
  await ensureZsxqAuth(page);
19
20
  const topicId = String(kwargs.id);
21
+ const groupId = String(kwargs.group_id || await getActiveGroupId(page));
20
22
  const commentLimit = Math.max(1, Number(kwargs.comment_limit) || 20);
21
- const detailUrl = `https://api.zsxq.com/v2/topics/${topicId}`;
23
+ const detailUrl = `https://api.zsxq.com/v2/groups/${groupId}/topics/${topicId}`;
22
24
  const detailResp = await browserJsonRequest(page, detailUrl);
23
25
  if (detailResp.status === 404) {
24
26
  throw new CliError('NOT_FOUND', `Topic ${topicId} not found`);
@@ -27,7 +29,7 @@ cli({
27
29
  throw new CliError('FETCH_ERROR', detailResp.error || `Failed to fetch topic ${topicId}`, `Checked endpoint: ${detailUrl}`);
28
30
  }
29
31
  const commentsResp = await fetchFirstJson(page, [
30
- `https://api.zsxq.com/v2/topics/${topicId}/comments?sort=asc&count=${commentLimit}`,
32
+ `https://api.zsxq.com/v2/groups/${groupId}/topics/${topicId}/comments?sort=asc&count=${commentLimit}`,
31
33
  ]);
32
34
  const topic = getTopicFromResponse(detailResp.data);
33
35
  if (!topic)
@@ -11,11 +11,12 @@ describe('zsxq topic command', () => {
11
11
  const mockPage = {
12
12
  goto: vi.fn().mockResolvedValue(undefined),
13
13
  evaluate: vi.fn()
14
- .mockResolvedValueOnce(true)
14
+ .mockResolvedValueOnce(true) // ensureZsxqAuth
15
+ .mockResolvedValueOnce('12345') // getActiveGroupId
15
16
  .mockResolvedValueOnce({
16
17
  ok: true,
17
18
  status: 404,
18
- url: 'https://api.zsxq.com/v2/topics/404',
19
+ url: 'https://api.zsxq.com/v2/groups/12345/topics/404',
19
20
  data: null,
20
21
  }),
21
22
  };
@@ -24,6 +25,6 @@ describe('zsxq topic command', () => {
24
25
  message: 'Topic 404 not found',
25
26
  });
26
27
  expect(mockPage.goto).toHaveBeenCalledWith('https://wx.zsxq.com');
27
- expect(mockPage.evaluate).toHaveBeenCalledTimes(2);
28
+ expect(mockPage.evaluate).toHaveBeenCalledTimes(3);
28
29
  });
29
30
  });
@@ -168,7 +168,7 @@ export function getTopicFromResponse(payload) {
168
168
  const data = unwrapRespData(payload);
169
169
  if (Array.isArray(data))
170
170
  return data[0] ?? null;
171
- if (typeof data.topic_id === 'number')
171
+ if (typeof data.topic_id === 'number' || typeof data.topic_id === 'string')
172
172
  return data;
173
173
  const record = asRecord(data);
174
174
  if (!record)
package/dist/src/cli.js CHANGED
@@ -891,6 +891,114 @@ cli({
891
891
  process.exitCode = EXIT_CODES.GENERIC_ERROR;
892
892
  }
893
893
  });
894
+ // ── Built-in: adapter management ─────────────────────────────────────────
895
+ const adapterCmd = program.command('adapter').description('Manage CLI adapters');
896
+ adapterCmd
897
+ .command('status')
898
+ .description('Show which sites have local overrides vs using official baseline')
899
+ .action(async () => {
900
+ const os = await import('node:os');
901
+ const userClisDir = path.join(os.homedir(), '.opencli', 'clis');
902
+ const builtinClisDir = BUILTIN_CLIS;
903
+ try {
904
+ const userEntries = await fs.promises.readdir(userClisDir, { withFileTypes: true });
905
+ const userSites = userEntries.filter(e => e.isDirectory()).map(e => e.name).sort();
906
+ let builtinSites = [];
907
+ try {
908
+ const builtinEntries = await fs.promises.readdir(builtinClisDir, { withFileTypes: true });
909
+ builtinSites = builtinEntries.filter(e => e.isDirectory()).map(e => e.name).sort();
910
+ }
911
+ catch { /* no builtin dir */ }
912
+ if (userSites.length === 0) {
913
+ console.log('No local adapter overrides. All sites use the official baseline.');
914
+ return;
915
+ }
916
+ console.log(`Local overrides in ~/.opencli/clis/ (${userSites.length} sites):\n`);
917
+ for (const site of userSites) {
918
+ const isOfficial = builtinSites.includes(site);
919
+ const label = isOfficial ? 'override' : 'custom';
920
+ console.log(` ${site} [${label}]`);
921
+ }
922
+ console.log(`\nOfficial baseline: ${builtinSites.length} sites in package`);
923
+ }
924
+ catch {
925
+ console.log('No local adapter overrides. All sites use the official baseline.');
926
+ }
927
+ });
928
+ adapterCmd
929
+ .command('eject')
930
+ .description('Copy an official adapter to ~/.opencli/clis/ for local editing')
931
+ .argument('<site>', 'Site name (e.g. twitter, bilibili)')
932
+ .action(async (site) => {
933
+ const os = await import('node:os');
934
+ const userClisDir = path.join(os.homedir(), '.opencli', 'clis');
935
+ const builtinSiteDir = path.join(BUILTIN_CLIS, site);
936
+ const userSiteDir = path.join(userClisDir, site);
937
+ try {
938
+ await fs.promises.access(builtinSiteDir);
939
+ }
940
+ catch {
941
+ console.error(styleText('red', `Error: Site "${site}" not found in official adapters.`));
942
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
943
+ return;
944
+ }
945
+ try {
946
+ await fs.promises.access(userSiteDir);
947
+ console.error(styleText('yellow', `Site "${site}" already exists in ~/.opencli/clis/. Use "opencli adapter reset ${site}" first to restore official version.`));
948
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
949
+ return;
950
+ }
951
+ catch { /* good, doesn't exist yet */ }
952
+ fs.cpSync(builtinSiteDir, userSiteDir, { recursive: true });
953
+ console.log(styleText('green', `✅ Ejected "${site}" to ~/.opencli/clis/${site}/`));
954
+ console.log('You can now edit the adapter files. Changes take effect immediately.');
955
+ console.log(styleText('yellow', 'Note: Official updates to this adapter will overwrite your changes.'));
956
+ });
957
+ adapterCmd
958
+ .command('reset')
959
+ .description('Remove local override and restore official adapter version')
960
+ .argument('[site]', 'Site name (e.g. twitter, bilibili)')
961
+ .option('--all', 'Reset all local overrides')
962
+ .action(async (site, opts) => {
963
+ const os = await import('node:os');
964
+ const userClisDir = path.join(os.homedir(), '.opencli', 'clis');
965
+ if (opts.all) {
966
+ try {
967
+ const userEntries = await fs.promises.readdir(userClisDir, { withFileTypes: true });
968
+ const dirs = userEntries.filter(e => e.isDirectory());
969
+ if (dirs.length === 0) {
970
+ console.log('No local sites to reset.');
971
+ return;
972
+ }
973
+ for (const dir of dirs) {
974
+ fs.rmSync(path.join(userClisDir, dir.name), { recursive: true, force: true });
975
+ }
976
+ console.log(styleText('green', `✅ Reset ${dirs.length} site(s). All adapters now use official baseline.`));
977
+ }
978
+ catch {
979
+ console.log('No local sites to reset.');
980
+ }
981
+ return;
982
+ }
983
+ if (!site) {
984
+ console.error(styleText('red', 'Error: Please specify a site name or use --all.'));
985
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
986
+ return;
987
+ }
988
+ const userSiteDir = path.join(userClisDir, site);
989
+ try {
990
+ await fs.promises.access(userSiteDir);
991
+ }
992
+ catch {
993
+ console.error(styleText('yellow', `Site "${site}" has no local override.`));
994
+ return;
995
+ }
996
+ const isOfficial = fs.existsSync(path.join(BUILTIN_CLIS, site));
997
+ fs.rmSync(userSiteDir, { recursive: true, force: true });
998
+ console.log(styleText('green', isOfficial
999
+ ? `✅ Reset "${site}". Now using official baseline.`
1000
+ : `✅ Removed custom site "${site}".`));
1001
+ });
894
1002
  // ── Built-in: daemon ──────────────────────────────────────────────────────
895
1003
  const daemonCmd = program.command('daemon').description('Manage the opencli daemon');
896
1004
  daemonCmd
@@ -23,8 +23,11 @@ export declare const PLUGINS_DIR: string;
23
23
  */
24
24
  export declare function ensureUserCliCompatShims(baseDir?: string): Promise<void>;
25
25
  /**
26
- * First-run fallback: if postinstall was skipped (--ignore-scripts) or failed,
27
- * trigger adapter fetch on first CLI invocation when ~/.opencli/clis/ is empty.
26
+ * Ensure the user adapters directory exists.
27
+ *
28
+ * With smart sync, ~/.opencli/clis/ only holds files that differ from the
29
+ * package baseline (upstream-synced cache + autofix output + user overrides).
30
+ * Built-in adapters are loaded directly from the installed package.
28
31
  */
29
32
  export declare function ensureUserAdapters(): Promise<void>;
30
33
  /**
@@ -14,7 +14,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
14
14
  import { Strategy, registerCommand } from './registry.js';
15
15
  import { getErrorMessage } from './errors.js';
16
16
  import { log } from './logger.js';
17
- import { findPackageRoot, getCliManifestPath, getFetchAdaptersScriptPath } from './package-paths.js';
17
+ import { findPackageRoot, getCliManifestPath } from './package-paths.js';
18
18
  /** User runtime directory: ~/.opencli */
19
19
  export const USER_OPENCLI_DIR = path.join(os.homedir(), '.opencli');
20
20
  /** User CLIs directory: ~/.opencli/clis */
@@ -77,43 +77,15 @@ export async function ensureUserCliCompatShims(baseDir = USER_OPENCLI_DIR) {
77
77
  log.warn(`Could not create symlink at ${symlinkPath}: ${getErrorMessage(err)}`);
78
78
  }
79
79
  }
80
- const ADAPTER_MANIFEST_PATH = path.join(USER_OPENCLI_DIR, 'adapter-manifest.json');
81
80
  /**
82
- * First-run fallback: if postinstall was skipped (--ignore-scripts) or failed,
83
- * trigger adapter fetch on first CLI invocation when ~/.opencli/clis/ is empty.
81
+ * Ensure the user adapters directory exists.
82
+ *
83
+ * With smart sync, ~/.opencli/clis/ only holds files that differ from the
84
+ * package baseline (upstream-synced cache + autofix output + user overrides).
85
+ * Built-in adapters are loaded directly from the installed package.
84
86
  */
85
87
  export async function ensureUserAdapters() {
86
- // If adapter manifest already exists, adapters were fetched — nothing to do
87
- try {
88
- await fs.promises.access(ADAPTER_MANIFEST_PATH);
89
- return;
90
- }
91
- catch {
92
- // No manifest — first run or postinstall was skipped
93
- }
94
- // Check if clis dir has any content (could be manually populated)
95
- try {
96
- const entries = await fs.promises.readdir(USER_CLIS_DIR);
97
- if (entries.length > 0)
98
- return;
99
- }
100
- catch {
101
- // Dir doesn't exist — needs fetch
102
- }
103
- log.info('First run detected — copying adapters (one-time setup)...');
104
- try {
105
- const { execFileSync } = await import('node:child_process');
106
- const scriptPath = getFetchAdaptersScriptPath(PACKAGE_ROOT);
107
- execFileSync(process.execPath, [scriptPath], {
108
- stdio: 'inherit',
109
- env: { ...process.env, _OPENCLI_FIRST_RUN: '1' },
110
- timeout: 120_000,
111
- });
112
- }
113
- catch (err) {
114
- log.warn(`Could not fetch adapters on first run: ${getErrorMessage(err)}`);
115
- log.warn('Built-in adapters from the package will be used.');
116
- }
88
+ await fs.promises.mkdir(USER_CLIS_DIR, { recursive: true });
117
89
  }
118
90
  /**
119
91
  * Discover and register CLI commands.
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { discoverClis, discoverPlugins, ensureUserCliCompatShims, PLUGINS_DIR } from './discovery.js';
2
+ import { discoverClis, discoverPlugins, ensureUserCliCompatShims, ensureUserAdapters, PLUGINS_DIR } from './discovery.js';
3
3
  import { executeCommand } from './execution.js';
4
4
  import { getRegistry, cli, Strategy } from './registry.js';
5
5
  import { clearAllHooks, onAfterExecute } from './hooks.js';
@@ -103,6 +103,34 @@ cli({
103
103
  }
104
104
  });
105
105
  });
106
+ describe('ensureUserAdapters', () => {
107
+ it('creates user clis directory without triggering full copy', async () => {
108
+ const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-ensure-'));
109
+ const clisDir = path.join(tempDir, 'clis');
110
+ try {
111
+ // Patch USER_CLIS_DIR is not easy, so we test the function behavior indirectly:
112
+ // ensureUserAdapters should not throw and should be very fast (no fetch script)
113
+ const start = Date.now();
114
+ await ensureUserAdapters();
115
+ const elapsed = Date.now() - start;
116
+ // Should complete quickly (< 1s) since it only creates a directory
117
+ expect(elapsed).toBeLessThan(1000);
118
+ }
119
+ finally {
120
+ await fs.promises.rm(tempDir, { recursive: true, force: true });
121
+ }
122
+ });
123
+ it('discoverClis handles empty user directory gracefully', async () => {
124
+ const emptyDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-empty-'));
125
+ try {
126
+ // Should not throw for an empty directory (no adapters to discover)
127
+ await expect(discoverClis(emptyDir)).resolves.not.toThrow();
128
+ }
129
+ finally {
130
+ await fs.promises.rm(emptyDir, { recursive: true, force: true });
131
+ }
132
+ });
133
+ });
106
134
  describe('discoverPlugins', () => {
107
135
  const testPluginDir = path.join(PLUGINS_DIR, '__test-plugin__');
108
136
  const yamlPath = path.join(testPluginDir, 'greeting.yaml');