@jackwener/opencli 0.6.3 → 0.7.0

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 (63) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/SKILL.md +7 -2
  4. package/dist/build-manifest.js +2 -0
  5. package/dist/cli-manifest.json +604 -24
  6. package/dist/clis/reddit/comment.d.ts +1 -0
  7. package/dist/clis/reddit/comment.js +57 -0
  8. package/dist/clis/reddit/popular.yaml +40 -0
  9. package/dist/clis/reddit/read.yaml +76 -0
  10. package/dist/clis/reddit/save.d.ts +1 -0
  11. package/dist/clis/reddit/save.js +51 -0
  12. package/dist/clis/reddit/saved.d.ts +1 -0
  13. package/dist/clis/reddit/saved.js +46 -0
  14. package/dist/clis/reddit/search.yaml +37 -11
  15. package/dist/clis/reddit/subreddit.yaml +14 -4
  16. package/dist/clis/reddit/subscribe.d.ts +1 -0
  17. package/dist/clis/reddit/subscribe.js +50 -0
  18. package/dist/clis/reddit/upvote.d.ts +1 -0
  19. package/dist/clis/reddit/upvote.js +64 -0
  20. package/dist/clis/reddit/upvoted.d.ts +1 -0
  21. package/dist/clis/reddit/upvoted.js +46 -0
  22. package/dist/clis/reddit/user-comments.yaml +45 -0
  23. package/dist/clis/reddit/user-posts.yaml +43 -0
  24. package/dist/clis/reddit/user.yaml +39 -0
  25. package/dist/clis/twitter/article.d.ts +1 -0
  26. package/dist/clis/twitter/article.js +157 -0
  27. package/dist/clis/twitter/bookmark.d.ts +1 -0
  28. package/dist/clis/twitter/bookmark.js +63 -0
  29. package/dist/clis/twitter/follow.d.ts +1 -0
  30. package/dist/clis/twitter/follow.js +65 -0
  31. package/dist/clis/twitter/profile.js +110 -42
  32. package/dist/clis/twitter/thread.d.ts +1 -0
  33. package/dist/clis/twitter/thread.js +150 -0
  34. package/dist/clis/twitter/unbookmark.d.ts +1 -0
  35. package/dist/clis/twitter/unbookmark.js +62 -0
  36. package/dist/clis/twitter/unfollow.d.ts +1 -0
  37. package/dist/clis/twitter/unfollow.js +71 -0
  38. package/dist/main.js +31 -8
  39. package/dist/registry.d.ts +1 -0
  40. package/package.json +1 -1
  41. package/src/build-manifest.ts +3 -0
  42. package/src/clis/reddit/comment.ts +60 -0
  43. package/src/clis/reddit/popular.yaml +40 -0
  44. package/src/clis/reddit/read.yaml +76 -0
  45. package/src/clis/reddit/save.ts +54 -0
  46. package/src/clis/reddit/saved.ts +48 -0
  47. package/src/clis/reddit/search.yaml +37 -11
  48. package/src/clis/reddit/subreddit.yaml +14 -4
  49. package/src/clis/reddit/subscribe.ts +53 -0
  50. package/src/clis/reddit/upvote.ts +67 -0
  51. package/src/clis/reddit/upvoted.ts +48 -0
  52. package/src/clis/reddit/user-comments.yaml +45 -0
  53. package/src/clis/reddit/user-posts.yaml +43 -0
  54. package/src/clis/reddit/user.yaml +39 -0
  55. package/src/clis/twitter/article.ts +161 -0
  56. package/src/clis/twitter/bookmark.ts +67 -0
  57. package/src/clis/twitter/follow.ts +69 -0
  58. package/src/clis/twitter/profile.ts +113 -45
  59. package/src/clis/twitter/thread.ts +181 -0
  60. package/src/clis/twitter/unbookmark.ts +66 -0
  61. package/src/clis/twitter/unfollow.ts +75 -0
  62. package/src/main.ts +24 -5
  63. package/src/registry.ts +1 -0
@@ -3,59 +3,127 @@ import { cli, Strategy } from '../../registry.js';
3
3
  cli({
4
4
  site: 'twitter',
5
5
  name: 'profile',
6
- description: 'Fetch tweets from a user profile',
6
+ description: 'Fetch a Twitter user profile (bio, stats, etc.)',
7
7
  domain: 'x.com',
8
- strategy: Strategy.INTERCEPT,
8
+ strategy: Strategy.COOKIE,
9
9
  browser: true,
10
10
  args: [
11
- { name: 'username', type: 'string', required: true },
12
- { name: 'limit', type: 'int', default: 15 },
11
+ { name: 'username', type: 'string', positional: true, help: 'Twitter screen name (without @). Defaults to logged-in user.' },
13
12
  ],
14
- columns: ['id', 'text', 'likes', 'views', 'url'],
13
+ columns: ['screen_name', 'name', 'bio', 'location', 'url', 'followers', 'following', 'tweets', 'likes', 'verified', 'created_at'],
15
14
  func: async (page, kwargs) => {
16
- // Navigate to user profile via search for reliability
17
- await page.goto(`https://x.com/search?q=from:${kwargs.username}&f=live`);
18
- await page.wait(5);
19
-
20
- // Inject XHR interceptor
21
- await page.installInterceptor('SearchTimeline');
22
-
23
- // Trigger API by scrolling
24
- await page.autoScroll({ times: 3, delayMs: 2000 });
25
-
26
- // Retrieve data
27
- const requests = await page.getInterceptedRequests();
28
- if (!requests || requests.length === 0) return [];
29
-
30
- let results: any[] = [];
31
- for (const req of requests) {
32
- try {
33
- const insts = req.data.data.search_by_raw_query.search_timeline.timeline.instructions;
34
- const addEntries = insts.find((i: any) => i.type === 'TimelineAddEntries');
35
- if (!addEntries) continue;
36
-
37
- for (const entry of addEntries.entries) {
38
- if (!entry.entryId.startsWith('tweet-')) continue;
39
-
40
- let tweet = entry.content?.itemContent?.tweet_results?.result;
41
- if (!tweet) continue;
42
-
43
- if (tweet.__typename === 'TweetWithVisibilityResults' && tweet.tweet) {
44
- tweet = tweet.tweet;
45
- }
46
-
47
- results.push({
48
- id: tweet.rest_id,
49
- text: tweet.legacy?.full_text || '',
50
- likes: tweet.legacy?.favorite_count || 0,
51
- views: tweet.views?.count || '0',
52
- url: `https://x.com/i/status/${tweet.rest_id}`
53
- });
15
+ let username = (kwargs.username || '').replace(/^@/, '');
16
+
17
+ // If no username, detect the logged-in user
18
+ if (!username) {
19
+ await page.goto('https://x.com/home');
20
+ await page.wait(5);
21
+ const href = await page.evaluate(`() => {
22
+ const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
23
+ return link ? link.getAttribute('href') : null;
24
+ }`);
25
+ if (!href) throw new Error('Could not detect logged-in user. Are you logged in?');
26
+ username = href.replace('/', '');
27
+ }
28
+
29
+ // Navigate directly to the user's profile page (gives us cookie context)
30
+ await page.goto(`https://x.com/${username}`);
31
+ await page.wait(3);
32
+
33
+ const result = await page.evaluate(`
34
+ async () => {
35
+ const screenName = "${username}";
36
+ const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1];
37
+ if (!ct0) return {error: 'No ct0 cookie — not logged into x.com'};
38
+
39
+ const bearer = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
40
+ const headers = {
41
+ 'Authorization': 'Bearer ' + decodeURIComponent(bearer),
42
+ 'X-Csrf-Token': ct0,
43
+ 'X-Twitter-Auth-Type': 'OAuth2Session',
44
+ 'X-Twitter-Active-User': 'yes'
45
+ };
46
+
47
+ const variables = JSON.stringify({
48
+ screen_name: screenName,
49
+ withSafetyModeUserFields: true,
50
+ });
51
+ const features = JSON.stringify({
52
+ hidden_profile_subscriptions_enabled: true,
53
+ rweb_tipjar_consumption_enabled: true,
54
+ responsive_web_graphql_exclude_directive_enabled: true,
55
+ verified_phone_label_enabled: false,
56
+ subscriptions_verification_info_is_identity_verified_enabled: true,
57
+ subscriptions_verification_info_verified_since_enabled: true,
58
+ highlights_tweets_tab_ui_enabled: true,
59
+ responsive_web_twitter_article_notes_tab_enabled: true,
60
+ subscriptions_feature_can_gift_premium: true,
61
+ creator_subscriptions_tweet_preview_api_enabled: true,
62
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
63
+ responsive_web_graphql_timeline_navigation_enabled: true,
64
+ });
65
+
66
+ // Dynamically resolve queryId: GitHub community source → JS bundle scan → hardcoded fallback
67
+ async function resolveQueryId(operationName, fallbackId) {
68
+ try {
69
+ const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
70
+ if (ghResp.ok) {
71
+ const data = await ghResp.json();
72
+ const entry = data[operationName];
73
+ if (entry && entry.queryId) return entry.queryId;
74
+ }
75
+ } catch {}
76
+ try {
77
+ const scripts = performance.getEntriesByType('resource')
78
+ .filter(r => r.name.includes('client-web') && r.name.endsWith('.js'))
79
+ .map(r => r.name);
80
+ for (const scriptUrl of scripts.slice(0, 15)) {
81
+ try {
82
+ const text = await (await fetch(scriptUrl)).text();
83
+ const re = new RegExp('queryId:"([A-Za-z0-9_-]+)"[^}]{0,200}operationName:"' + operationName + '"');
84
+ const m = text.match(re);
85
+ if (m) return m[1];
86
+ } catch {}
87
+ }
88
+ } catch {}
89
+ return fallbackId;
54
90
  }
55
- } catch (e) {
91
+
92
+ const queryId = await resolveQueryId('UserByScreenName', 'qRednkZG-rn1P6b48NINmQ');
93
+ const url = '/i/api/graphql/' + queryId + '/UserByScreenName?variables='
94
+ + encodeURIComponent(variables)
95
+ + '&features=' + encodeURIComponent(features);
96
+
97
+ const resp = await fetch(url, {headers, credentials: 'include'});
98
+ if (!resp.ok) return {error: 'HTTP ' + resp.status, hint: 'User may not exist or queryId expired'};
99
+ const d = await resp.json();
100
+
101
+ const result = d.data?.user?.result;
102
+ if (!result) return {error: 'User @' + screenName + ' not found'};
103
+
104
+ const legacy = result.legacy || {};
105
+ const expandedUrl = legacy.entities?.url?.urls?.[0]?.expanded_url || '';
106
+
107
+ return [{
108
+ screen_name: legacy.screen_name || screenName,
109
+ name: legacy.name || '',
110
+ bio: legacy.description || '',
111
+ location: legacy.location || '',
112
+ url: expandedUrl,
113
+ followers: legacy.followers_count || 0,
114
+ following: legacy.friends_count || 0,
115
+ tweets: legacy.statuses_count || 0,
116
+ likes: legacy.favourites_count || 0,
117
+ verified: result.is_blue_verified || legacy.verified || false,
118
+ created_at: legacy.created_at || '',
119
+ }];
56
120
  }
121
+ `);
122
+
123
+ if (result?.error) {
124
+ throw new Error(result.error + (result.hint ? ` (${result.hint})` : ''));
57
125
  }
58
126
 
59
- return results.slice(0, kwargs.limit);
127
+ return result || [];
60
128
  }
61
129
  });
@@ -0,0 +1,181 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+
3
+ // ── Twitter GraphQL constants ──────────────────────────────────────────
4
+
5
+ const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
6
+ const TWEET_DETAIL_QUERY_ID = 'nBS-WpgA6ZG0CyNHD517JQ';
7
+
8
+ const FEATURES = {
9
+ responsive_web_graphql_exclude_directive_enabled: true,
10
+ verified_phone_label_enabled: false,
11
+ creator_subscriptions_tweet_preview_api_enabled: true,
12
+ responsive_web_graphql_timeline_navigation_enabled: true,
13
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
14
+ longform_notetweets_consumption_enabled: true,
15
+ longform_notetweets_rich_text_read_enabled: true,
16
+ longform_notetweets_inline_media_enabled: true,
17
+ freedom_of_speech_not_reach_fetch_enabled: true,
18
+ };
19
+
20
+ const FIELD_TOGGLES = { withArticleRichContentState: true, withArticlePlainText: false };
21
+
22
+ // ── Pure functions (type-safe, testable) ───────────────────────────────
23
+
24
+ interface ThreadTweet {
25
+ id: string;
26
+ author: string;
27
+ text: string;
28
+ likes: number;
29
+ retweets: number;
30
+ in_reply_to?: string;
31
+ created_at?: string;
32
+ url: string;
33
+ }
34
+
35
+ function buildTweetDetailUrl(tweetId: string, cursor?: string | null): string {
36
+ const vars: Record<string, any> = {
37
+ focalTweetId: tweetId,
38
+ referrer: 'tweet',
39
+ with_rux_injections: false,
40
+ includePromotedContent: false,
41
+ rankingMode: 'Recency',
42
+ withCommunity: true,
43
+ withQuickPromoteEligibilityTweetFields: true,
44
+ withBirdwatchNotes: true,
45
+ withVoice: true,
46
+ };
47
+ if (cursor) vars.cursor = cursor;
48
+
49
+ return `/i/api/graphql/${TWEET_DETAIL_QUERY_ID}/TweetDetail`
50
+ + `?variables=${encodeURIComponent(JSON.stringify(vars))}`
51
+ + `&features=${encodeURIComponent(JSON.stringify(FEATURES))}`
52
+ + `&fieldToggles=${encodeURIComponent(JSON.stringify(FIELD_TOGGLES))}`;
53
+ }
54
+
55
+ function extractTweet(r: any, seen: Set<string>): ThreadTweet | null {
56
+ if (!r) return null;
57
+ const tw = r.tweet || r;
58
+ const l = tw.legacy || {};
59
+ if (!tw.rest_id || seen.has(tw.rest_id)) return null;
60
+ seen.add(tw.rest_id);
61
+
62
+ const u = tw.core?.user_results?.result;
63
+ const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
64
+ const screenName = u?.legacy?.screen_name || u?.core?.screen_name || 'unknown';
65
+
66
+ return {
67
+ id: tw.rest_id,
68
+ author: screenName,
69
+ text: noteText || l.full_text || '',
70
+ likes: l.favorite_count || 0,
71
+ retweets: l.retweet_count || 0,
72
+ in_reply_to: l.in_reply_to_status_id_str || undefined,
73
+ created_at: l.created_at,
74
+ url: `https://x.com/${screenName}/status/${tw.rest_id}`,
75
+ };
76
+ }
77
+
78
+ function parseTweetDetail(data: any, seen: Set<string>): { tweets: ThreadTweet[]; nextCursor: string | null } {
79
+ const tweets: ThreadTweet[] = [];
80
+ let nextCursor: string | null = null;
81
+
82
+ const instructions =
83
+ data?.data?.threaded_conversation_with_injections_v2?.instructions
84
+ || data?.data?.tweetResult?.result?.timeline?.instructions
85
+ || [];
86
+
87
+ for (const inst of instructions) {
88
+ for (const entry of inst.entries || []) {
89
+ // Cursor entries
90
+ const c = entry.content;
91
+ if (c?.entryType === 'TimelineTimelineCursor' || c?.__typename === 'TimelineTimelineCursor') {
92
+ if (c.cursorType === 'Bottom' || c.cursorType === 'ShowMore') nextCursor = c.value;
93
+ continue;
94
+ }
95
+ if (entry.entryId?.startsWith('cursor-bottom-') || entry.entryId?.startsWith('cursor-showMore-')) {
96
+ nextCursor = c?.itemContent?.value || c?.value || nextCursor;
97
+ continue;
98
+ }
99
+
100
+ // Direct tweet entry
101
+ const tw = extractTweet(c?.itemContent?.tweet_results?.result, seen);
102
+ if (tw) tweets.push(tw);
103
+
104
+ // Conversation module (nested replies)
105
+ for (const item of c?.items || []) {
106
+ const nested = extractTweet(item.item?.itemContent?.tweet_results?.result, seen);
107
+ if (nested) tweets.push(nested);
108
+ }
109
+ }
110
+ }
111
+
112
+ return { tweets, nextCursor };
113
+ }
114
+
115
+ // ── CLI definition ────────────────────────────────────────────────────
116
+
117
+ cli({
118
+ site: 'twitter',
119
+ name: 'thread',
120
+ description: 'Get a tweet thread (original + all replies)',
121
+ domain: 'x.com',
122
+ strategy: Strategy.COOKIE,
123
+ browser: true,
124
+ args: [
125
+ { name: 'tweet_id', type: 'string', required: true },
126
+ { name: 'limit', type: 'int', default: 50 },
127
+ ],
128
+ columns: ['id', 'author', 'text', 'likes', 'retweets', 'url'],
129
+ func: async (page, kwargs) => {
130
+ let tweetId = kwargs.tweet_id;
131
+ const urlMatch = tweetId.match(/\/status\/(\d+)/);
132
+ if (urlMatch) tweetId = urlMatch[1];
133
+
134
+ // Navigate to x.com for cookie context
135
+ await page.goto('https://x.com');
136
+ await page.wait(3);
137
+
138
+ // Extract CSRF token — the only thing we need from the browser
139
+ const ct0 = await page.evaluate(`() => {
140
+ return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
141
+ }`);
142
+ if (!ct0) throw new Error('Not logged into x.com (no ct0 cookie)');
143
+
144
+ // Build auth headers in TypeScript
145
+ const headers = JSON.stringify({
146
+ 'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
147
+ 'X-Csrf-Token': ct0,
148
+ 'X-Twitter-Auth-Type': 'OAuth2Session',
149
+ 'X-Twitter-Active-User': 'yes',
150
+ });
151
+
152
+ // Paginate — fetch in browser, parse in TypeScript
153
+ const allTweets: ThreadTweet[] = [];
154
+ const seen = new Set<string>();
155
+ let cursor: string | null = null;
156
+
157
+ for (let i = 0; i < 5; i++) {
158
+ const apiUrl = buildTweetDetailUrl(tweetId, cursor);
159
+
160
+ // Browser-side: just fetch + return JSON (3 lines)
161
+ const data = await page.evaluate(`async () => {
162
+ const r = await fetch("${apiUrl}", { headers: ${headers}, credentials: 'include' });
163
+ return r.ok ? await r.json() : { error: r.status };
164
+ }`);
165
+
166
+ if (data?.error) {
167
+ if (allTweets.length === 0) throw new Error(`HTTP ${data.error}: Tweet not found or queryId expired`);
168
+ break;
169
+ }
170
+
171
+ // TypeScript-side: type-safe parsing + cursor extraction
172
+ const { tweets, nextCursor } = parseTweetDetail(data, seen);
173
+ allTweets.push(...tweets);
174
+
175
+ if (!nextCursor || nextCursor === cursor) break;
176
+ cursor = nextCursor;
177
+ }
178
+
179
+ return allTweets.slice(0, kwargs.limit);
180
+ },
181
+ });
@@ -0,0 +1,66 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import type { IPage } from '../../types.js';
3
+
4
+ cli({
5
+ site: 'twitter',
6
+ name: 'unbookmark',
7
+ description: 'Remove a tweet from bookmarks',
8
+ domain: 'x.com',
9
+ strategy: Strategy.UI,
10
+ browser: true,
11
+ args: [
12
+ { name: 'url', type: 'string', positional: true, required: true, help: 'Tweet URL to unbookmark' },
13
+ ],
14
+ columns: ['status', 'message'],
15
+ func: async (page: IPage | null, kwargs: any) => {
16
+ if (!page) throw new Error('Requires browser');
17
+
18
+ await page.goto(kwargs.url);
19
+ await page.wait(5);
20
+
21
+ const result = await page.evaluate(`(async () => {
22
+ try {
23
+ let attempts = 0;
24
+ let removeBtn = null;
25
+
26
+ while (attempts < 20) {
27
+ // Check if not bookmarked
28
+ const bookmarkBtn = document.querySelector('[data-testid="bookmark"]');
29
+ if (bookmarkBtn) {
30
+ return { ok: true, message: 'Tweet is not bookmarked (already removed).' };
31
+ }
32
+
33
+ removeBtn = document.querySelector('[data-testid="removeBookmark"]');
34
+ if (removeBtn) break;
35
+
36
+ await new Promise(r => setTimeout(r, 500));
37
+ attempts++;
38
+ }
39
+
40
+ if (!removeBtn) {
41
+ return { ok: false, message: 'Could not find Remove Bookmark button. Are you logged in?' };
42
+ }
43
+
44
+ removeBtn.click();
45
+ await new Promise(r => setTimeout(r, 1000));
46
+
47
+ // Verify
48
+ const verify = document.querySelector('[data-testid="bookmark"]');
49
+ if (verify) {
50
+ return { ok: true, message: 'Tweet successfully removed from bookmarks.' };
51
+ } else {
52
+ return { ok: false, message: 'Unbookmark action initiated but UI did not update.' };
53
+ }
54
+ } catch (e) {
55
+ return { ok: false, message: e.toString() };
56
+ }
57
+ })()`);
58
+
59
+ if (result.ok) await page.wait(2);
60
+
61
+ return [{
62
+ status: result.ok ? 'success' : 'failed',
63
+ message: result.message
64
+ }];
65
+ }
66
+ });
@@ -0,0 +1,75 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import type { IPage } from '../../types.js';
3
+
4
+ cli({
5
+ site: 'twitter',
6
+ name: 'unfollow',
7
+ description: 'Unfollow a Twitter user',
8
+ domain: 'x.com',
9
+ strategy: Strategy.UI,
10
+ browser: true,
11
+ args: [
12
+ { name: 'username', type: 'string', positional: true, required: true, help: 'Twitter screen name (without @)' },
13
+ ],
14
+ columns: ['status', 'message'],
15
+ func: async (page: IPage | null, kwargs: any) => {
16
+ if (!page) throw new Error('Requires browser');
17
+ const username = kwargs.username.replace(/^@/, '');
18
+
19
+ await page.goto(`https://x.com/${username}`);
20
+ await page.wait(5);
21
+
22
+ const result = await page.evaluate(`(async () => {
23
+ try {
24
+ let attempts = 0;
25
+ let unfollowBtn = null;
26
+
27
+ while (attempts < 20) {
28
+ // Check if already not following
29
+ const followBtn = document.querySelector('[data-testid$="-follow"]');
30
+ if (followBtn) {
31
+ return { ok: true, message: 'Not following @${username} (already unfollowed).' };
32
+ }
33
+
34
+ unfollowBtn = document.querySelector('[data-testid$="-unfollow"]');
35
+ if (unfollowBtn) break;
36
+
37
+ await new Promise(r => setTimeout(r, 500));
38
+ attempts++;
39
+ }
40
+
41
+ if (!unfollowBtn) {
42
+ return { ok: false, message: 'Could not find Unfollow button. Are you logged in?' };
43
+ }
44
+
45
+ // Click the unfollow button — this opens a confirmation dialog
46
+ unfollowBtn.click();
47
+ await new Promise(r => setTimeout(r, 1000));
48
+
49
+ // Confirm the unfollow in the dialog
50
+ const confirmBtn = document.querySelector('[data-testid="confirmationSheetConfirm"]');
51
+ if (confirmBtn) {
52
+ confirmBtn.click();
53
+ await new Promise(r => setTimeout(r, 1000));
54
+ }
55
+
56
+ // Verify
57
+ const verify = document.querySelector('[data-testid$="-follow"]');
58
+ if (verify) {
59
+ return { ok: true, message: 'Successfully unfollowed @${username}.' };
60
+ } else {
61
+ return { ok: false, message: 'Unfollow action initiated but UI did not update.' };
62
+ }
63
+ } catch (e) {
64
+ return { ok: false, message: e.toString() };
65
+ }
66
+ })()`);
67
+
68
+ if (result.ok) await page.wait(2);
69
+
70
+ return [{
71
+ status: result.ok ? 'success' : 'failed',
72
+ message: result.message
73
+ }];
74
+ }
75
+ });
package/src/main.ts CHANGED
@@ -129,18 +129,37 @@ for (const [, cmd] of registry) {
129
129
  if (!siteCmd) { siteCmd = program.command(cmd.site).description(`${cmd.site} commands`); siteGroups.set(cmd.site, siteCmd); }
130
130
  const subCmd = siteCmd.command(cmd.name).description(cmd.description);
131
131
 
132
+ // Register positional args first, then named options
133
+ const positionalArgs: typeof cmd.args = [];
132
134
  for (const arg of cmd.args) {
133
- const flag = arg.required ? `--${arg.name} <value>` : `--${arg.name} [value]`;
134
- if (arg.required) subCmd.requiredOption(flag, arg.help ?? '');
135
- else if (arg.default != null) subCmd.option(flag, arg.help ?? '', String(arg.default));
136
- else subCmd.option(flag, arg.help ?? '');
135
+ if (arg.positional) {
136
+ const bracket = arg.required ? `<${arg.name}>` : `[${arg.name}]`;
137
+ subCmd.argument(bracket, arg.help ?? '');
138
+ positionalArgs.push(arg);
139
+ } else {
140
+ const flag = arg.required ? `--${arg.name} <value>` : `--${arg.name} [value]`;
141
+ if (arg.required) subCmd.requiredOption(flag, arg.help ?? '');
142
+ else if (arg.default != null) subCmd.option(flag, arg.help ?? '', String(arg.default));
143
+ else subCmd.option(flag, arg.help ?? '');
144
+ }
137
145
  }
138
146
  subCmd.option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table').option('-v, --verbose', 'Debug output', false);
139
147
 
140
- subCmd.action(async (actionOpts) => {
148
+ subCmd.action(async (...actionArgs: any[]) => {
149
+ // Commander passes positional args first, then options object, then the Command
150
+ const actionOpts = actionArgs[positionalArgs.length] ?? {};
141
151
  const startTime = Date.now();
142
152
  const kwargs: Record<string, any> = {};
153
+ // Collect positional args
154
+ for (let i = 0; i < positionalArgs.length; i++) {
155
+ const arg = positionalArgs[i];
156
+ const v = actionArgs[i];
157
+ if (v !== undefined) kwargs[arg.name] = coerce(v, arg.type ?? 'str');
158
+ else if (arg.default != null) kwargs[arg.name] = arg.default;
159
+ }
160
+ // Collect named options
143
161
  for (const arg of cmd.args) {
162
+ if (arg.positional) continue;
144
163
  const v = actionOpts[arg.name]; if (v !== undefined) kwargs[arg.name] = coerce(v, arg.type ?? 'str');
145
164
  else if (arg.default != null) kwargs[arg.name] = arg.default;
146
165
  }
package/src/registry.ts CHANGED
@@ -17,6 +17,7 @@ export interface Arg {
17
17
  type?: string;
18
18
  default?: any;
19
19
  required?: boolean;
20
+ positional?: boolean;
20
21
  help?: string;
21
22
  choices?: string[];
22
23
  }