@jackwener/opencli 0.7.6 → 0.7.9
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/.agents/skills/cross-project-adapter-migration/SKILL.md +249 -0
- package/.agents/workflows/cross-project-adapter-migration.md +54 -0
- package/dist/_debug.d.ts +1 -0
- package/dist/_debug.js +7 -0
- package/dist/browser/discover.d.ts +8 -0
- package/dist/browser/discover.js +83 -0
- package/dist/browser/errors.d.ts +21 -0
- package/dist/browser/errors.js +54 -0
- package/dist/browser/index.d.ts +22 -0
- package/dist/browser/index.js +22 -0
- package/dist/browser/mcp.d.ts +33 -0
- package/dist/browser/mcp.js +304 -0
- package/dist/browser/page.d.ts +41 -0
- package/dist/browser/page.js +142 -0
- package/dist/browser/tabs.d.ts +13 -0
- package/dist/browser/tabs.js +70 -0
- package/dist/browser-tab.d.ts +2 -0
- package/dist/browser-tab.js +30 -0
- package/dist/browser.test.js +1 -1
- package/dist/cli-manifest.json +70 -3
- package/dist/clis/github/search.d.ts +1 -0
- package/dist/clis/github/search.js +20 -0
- package/dist/clis/index.d.ts +27 -0
- package/dist/clis/index.js +41 -0
- package/dist/clis/twitter/timeline.js +174 -35
- package/dist/clis/xiaohongshu/me.d.ts +1 -0
- package/dist/clis/xiaohongshu/me.js +86 -0
- package/dist/completion.js +2 -2
- package/dist/doctor.js +7 -7
- package/dist/engine.js +6 -4
- package/dist/errors.d.ts +25 -0
- package/dist/errors.js +42 -0
- package/dist/logger.d.ts +22 -0
- package/dist/logger.js +47 -0
- package/dist/main.js +8 -2
- package/dist/pipeline/_debug.d.ts +1 -0
- package/dist/pipeline/_debug.js +7 -0
- package/dist/pipeline/executor.js +8 -8
- package/dist/pipeline/steps/browser.d.ts +7 -7
- package/dist/pipeline/steps/intercept.d.ts +1 -1
- package/dist/pipeline/steps/tap.d.ts +1 -1
- package/dist/promote.d.ts +1 -0
- package/dist/promote.js +3 -0
- package/dist/register.d.ts +2 -0
- package/dist/register.js +2 -0
- package/dist/scaffold.d.ts +2 -0
- package/dist/scaffold.js +2 -0
- package/dist/setup.js +9 -3
- package/dist/smoke.d.ts +2 -0
- package/dist/smoke.js +2 -0
- package/package.json +3 -3
- package/scripts/clean-yaml.cjs +19 -0
- package/scripts/copy-yaml.cjs +21 -0
- package/scripts/postinstall.js +30 -9
- package/src/bilibili.ts +1 -1
- package/src/browser/discover.ts +90 -0
- package/src/browser/errors.ts +89 -0
- package/src/browser/index.ts +26 -0
- package/src/browser/mcp.ts +305 -0
- package/src/browser/page.ts +152 -0
- package/src/browser/tabs.ts +76 -0
- package/src/browser.test.ts +1 -1
- package/src/clis/twitter/timeline.ts +204 -36
- package/src/completion.ts +2 -2
- package/src/doctor.ts +13 -1
- package/src/engine.ts +9 -4
- package/src/errors.ts +48 -0
- package/src/logger.ts +57 -0
- package/src/main.ts +10 -3
- package/src/pipeline/executor.ts +8 -7
- package/src/pipeline/steps/browser.ts +18 -18
- package/src/pipeline/steps/intercept.ts +8 -8
- package/src/pipeline/steps/tap.ts +2 -2
- package/src/setup.ts +9 -3
- package/tsconfig.json +1 -2
- package/src/browser.ts +0 -698
|
@@ -1,50 +1,218 @@
|
|
|
1
1
|
import { cli, Strategy } from '../../registry.js';
|
|
2
2
|
|
|
3
|
+
// ── Twitter GraphQL constants ──────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
6
|
+
const HOME_TIMELINE_QUERY_ID = 'c-CzHF1LboFilMpsx4ZCrQ';
|
|
7
|
+
|
|
8
|
+
const FEATURES = {
|
|
9
|
+
rweb_video_screen_enabled: false,
|
|
10
|
+
profile_label_improvements_pcf_label_in_post_enabled: true,
|
|
11
|
+
rweb_tipjar_consumption_enabled: true,
|
|
12
|
+
verified_phone_label_enabled: false,
|
|
13
|
+
creator_subscriptions_tweet_preview_api_enabled: true,
|
|
14
|
+
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
15
|
+
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
|
16
|
+
premium_content_api_read_enabled: false,
|
|
17
|
+
communities_web_enable_tweet_community_results_fetch: true,
|
|
18
|
+
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
|
19
|
+
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
|
20
|
+
responsive_web_grok_analyze_post_followups_enabled: true,
|
|
21
|
+
responsive_web_jetfuel_frame: false,
|
|
22
|
+
responsive_web_grok_share_attachment_enabled: true,
|
|
23
|
+
articles_preview_enabled: true,
|
|
24
|
+
responsive_web_edit_tweet_api_enabled: true,
|
|
25
|
+
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
|
26
|
+
view_counts_everywhere_api_enabled: true,
|
|
27
|
+
longform_notetweets_consumption_enabled: true,
|
|
28
|
+
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
|
29
|
+
tweet_awards_web_tipping_enabled: false,
|
|
30
|
+
responsive_web_grok_show_grok_translated_post: false,
|
|
31
|
+
responsive_web_grok_analysis_button_from_backend: false,
|
|
32
|
+
creator_subscriptions_quote_tweet_preview_enabled: false,
|
|
33
|
+
freedom_of_speech_not_reach_fetch_enabled: true,
|
|
34
|
+
standardized_nudges_misinfo: true,
|
|
35
|
+
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
|
36
|
+
longform_notetweets_rich_text_read_enabled: true,
|
|
37
|
+
longform_notetweets_inline_media_enabled: true,
|
|
38
|
+
responsive_web_grok_image_annotation_enabled: true,
|
|
39
|
+
responsive_web_enhance_cards_enabled: false,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ── Pure functions (type-safe, testable) ───────────────────────────────
|
|
43
|
+
|
|
44
|
+
interface TimelineTweet {
|
|
45
|
+
id: string;
|
|
46
|
+
author: string;
|
|
47
|
+
text: string;
|
|
48
|
+
likes: number;
|
|
49
|
+
retweets: number;
|
|
50
|
+
replies: number;
|
|
51
|
+
views: number;
|
|
52
|
+
created_at: string;
|
|
53
|
+
url: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildHomeTimelineUrl(count: number, cursor?: string | null): string {
|
|
57
|
+
const vars: Record<string, any> = {
|
|
58
|
+
count,
|
|
59
|
+
includePromotedContent: false,
|
|
60
|
+
latestControlAvailable: true,
|
|
61
|
+
requestContext: 'launch',
|
|
62
|
+
withCommunity: true,
|
|
63
|
+
};
|
|
64
|
+
if (cursor) vars.cursor = cursor;
|
|
65
|
+
|
|
66
|
+
return `/i/api/graphql/${HOME_TIMELINE_QUERY_ID}/HomeTimeline`
|
|
67
|
+
+ `?variables=${encodeURIComponent(JSON.stringify(vars))}`
|
|
68
|
+
+ `&features=${encodeURIComponent(JSON.stringify(FEATURES))}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function extractTweet(result: any, seen: Set<string>): TimelineTweet | null {
|
|
72
|
+
if (!result) return null;
|
|
73
|
+
const tw = result.tweet || result;
|
|
74
|
+
const l = tw.legacy || {};
|
|
75
|
+
if (!tw.rest_id || seen.has(tw.rest_id)) return null;
|
|
76
|
+
seen.add(tw.rest_id);
|
|
77
|
+
|
|
78
|
+
const u = tw.core?.user_results?.result;
|
|
79
|
+
const screenName = u?.legacy?.screen_name || u?.core?.screen_name || 'unknown';
|
|
80
|
+
const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
|
|
81
|
+
const views = tw.views?.count ? parseInt(tw.views.count, 10) : 0;
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
id: tw.rest_id,
|
|
85
|
+
author: screenName,
|
|
86
|
+
text: noteText || l.full_text || '',
|
|
87
|
+
likes: l.favorite_count || 0,
|
|
88
|
+
retweets: l.retweet_count || 0,
|
|
89
|
+
replies: l.reply_count || 0,
|
|
90
|
+
views,
|
|
91
|
+
created_at: l.created_at || '',
|
|
92
|
+
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseHomeTimeline(data: any, seen: Set<string>): { tweets: TimelineTweet[]; nextCursor: string | null } {
|
|
97
|
+
const tweets: TimelineTweet[] = [];
|
|
98
|
+
let nextCursor: string | null = null;
|
|
99
|
+
|
|
100
|
+
const instructions =
|
|
101
|
+
data?.data?.home?.home_timeline_urt?.instructions || [];
|
|
102
|
+
|
|
103
|
+
for (const inst of instructions) {
|
|
104
|
+
for (const entry of inst.entries || []) {
|
|
105
|
+
const c = entry.content;
|
|
106
|
+
|
|
107
|
+
// Cursor entries
|
|
108
|
+
if (c?.entryType === 'TimelineTimelineCursor' || c?.__typename === 'TimelineTimelineCursor') {
|
|
109
|
+
if (c.cursorType === 'Bottom') nextCursor = c.value;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (entry.entryId?.startsWith('cursor-bottom-')) {
|
|
113
|
+
nextCursor = c?.value || nextCursor;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Single tweet entry
|
|
118
|
+
const tweetResult = c?.itemContent?.tweet_results?.result;
|
|
119
|
+
if (tweetResult) {
|
|
120
|
+
// Skip promoted content
|
|
121
|
+
if (c?.itemContent?.promotedMetadata) continue;
|
|
122
|
+
const tw = extractTweet(tweetResult, seen);
|
|
123
|
+
if (tw) tweets.push(tw);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Conversation module (grouped tweets)
|
|
128
|
+
for (const item of c?.items || []) {
|
|
129
|
+
const nested = item.item?.itemContent?.tweet_results?.result;
|
|
130
|
+
if (nested) {
|
|
131
|
+
if (item.item?.itemContent?.promotedMetadata) continue;
|
|
132
|
+
const tw = extractTweet(nested, seen);
|
|
133
|
+
if (tw) tweets.push(tw);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { tweets, nextCursor };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── CLI definition ────────────────────────────────────────────────────
|
|
143
|
+
|
|
3
144
|
cli({
|
|
4
145
|
site: 'twitter',
|
|
5
146
|
name: 'timeline',
|
|
6
|
-
description: 'Twitter Home Timeline',
|
|
147
|
+
description: 'Fetch Twitter Home Timeline',
|
|
7
148
|
domain: 'x.com',
|
|
8
149
|
strategy: Strategy.COOKIE,
|
|
150
|
+
browser: true,
|
|
9
151
|
args: [
|
|
10
152
|
{ name: 'limit', type: 'int', default: 20 },
|
|
11
153
|
],
|
|
12
|
-
columns: ['
|
|
154
|
+
columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'views', 'created_at', 'url'],
|
|
13
155
|
func: async (page, kwargs) => {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
//
|
|
17
|
-
await page.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return
|
|
34
|
-
}
|
|
156
|
+
const limit = kwargs.limit || 20;
|
|
157
|
+
|
|
158
|
+
// Navigate to x.com for cookie context
|
|
159
|
+
await page.goto('https://x.com');
|
|
160
|
+
await page.wait(3);
|
|
161
|
+
|
|
162
|
+
// Extract CSRF token
|
|
163
|
+
const ct0 = await page.evaluate(`() => {
|
|
164
|
+
return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
|
|
165
|
+
}`);
|
|
166
|
+
if (!ct0) throw new Error('Not logged into x.com (no ct0 cookie)');
|
|
167
|
+
|
|
168
|
+
// Dynamically resolve queryId
|
|
169
|
+
const queryId = await page.evaluate(`async () => {
|
|
170
|
+
try {
|
|
171
|
+
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
|
|
172
|
+
if (ghResp.ok) {
|
|
173
|
+
const data = await ghResp.json();
|
|
174
|
+
const entry = data['HomeTimeline'];
|
|
175
|
+
if (entry && entry.queryId) return entry.queryId;
|
|
176
|
+
}
|
|
177
|
+
} catch {}
|
|
178
|
+
return null;
|
|
179
|
+
}`) || HOME_TIMELINE_QUERY_ID;
|
|
180
|
+
|
|
181
|
+
// Build auth headers
|
|
182
|
+
const headers = JSON.stringify({
|
|
183
|
+
'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
|
|
184
|
+
'X-Csrf-Token': ct0,
|
|
185
|
+
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
186
|
+
'X-Twitter-Active-User': 'yes',
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Paginate — fetch in browser, parse in TypeScript
|
|
190
|
+
const allTweets: TimelineTweet[] = [];
|
|
191
|
+
const seen = new Set<string>();
|
|
192
|
+
let cursor: string | null = null;
|
|
193
|
+
|
|
194
|
+
for (let i = 0; i < 5 && allTweets.length < limit; i++) {
|
|
195
|
+
const fetchCount = Math.min(40, limit - allTweets.length + 5); // over-fetch slightly for promoted filtering
|
|
196
|
+
const apiUrl = buildHomeTimelineUrl(fetchCount, cursor)
|
|
197
|
+
.replace(HOME_TIMELINE_QUERY_ID, queryId);
|
|
198
|
+
|
|
199
|
+
const data = await page.evaluate(`async () => {
|
|
200
|
+
const r = await fetch("${apiUrl}", { headers: ${headers}, credentials: 'include' });
|
|
201
|
+
return r.ok ? await r.json() : { error: r.status };
|
|
202
|
+
}`);
|
|
203
|
+
|
|
204
|
+
if (data?.error) {
|
|
205
|
+
if (allTweets.length === 0) throw new Error(`HTTP ${data.error}: Failed to fetch timeline. queryId may have expired.`);
|
|
206
|
+
break;
|
|
35
207
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
208
|
+
|
|
209
|
+
const { tweets, nextCursor } = parseHomeTimeline(data, seen);
|
|
210
|
+
allTweets.push(...tweets);
|
|
211
|
+
|
|
212
|
+
if (!nextCursor || nextCursor === cursor) break;
|
|
213
|
+
cursor = nextCursor;
|
|
42
214
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (!data || data.length === 0) return [{responseType: 'no data captured'}];
|
|
47
|
-
|
|
48
|
-
return [{responseType: `captured ${data.length} responses`, first: JSON.stringify(data[0]).substring(0,300)}];
|
|
49
|
-
}
|
|
215
|
+
|
|
216
|
+
return allTweets.slice(0, limit);
|
|
217
|
+
},
|
|
50
218
|
});
|
package/src/completion.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { getRegistry } from './registry.js';
|
|
10
|
+
import { CliError } from './errors.js';
|
|
10
11
|
|
|
11
12
|
// ── Dynamic completion logic ───────────────────────────────────────────────
|
|
12
13
|
|
|
@@ -123,7 +124,6 @@ export function printCompletionScript(shell: string): void {
|
|
|
123
124
|
process.stdout.write(fishCompletionScript());
|
|
124
125
|
break;
|
|
125
126
|
default:
|
|
126
|
-
|
|
127
|
-
process.exitCode = 1;
|
|
127
|
+
throw new CliError('UNSUPPORTED_SHELL', `Unsupported shell: ${shell}. Supported: bash, zsh, fish`);
|
|
128
128
|
}
|
|
129
129
|
}
|
package/src/doctor.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { createInterface } from 'node:readline/promises';
|
|
|
6
6
|
import { stdin as input, stdout as output } from 'node:process';
|
|
7
7
|
import chalk from 'chalk';
|
|
8
8
|
import type { IPage } from './types.js';
|
|
9
|
-
import { PlaywrightMCP, getTokenFingerprint } from './browser.js';
|
|
9
|
+
import { PlaywrightMCP, getTokenFingerprint } from './browser/index.js';
|
|
10
10
|
import { browserSession } from './runtime.js';
|
|
11
11
|
|
|
12
12
|
const PLAYWRIGHT_SERVER_NAME = 'playwright';
|
|
@@ -325,6 +325,8 @@ export function discoverExtensionToken(): string | null {
|
|
|
325
325
|
if (platform === 'darwin') {
|
|
326
326
|
bases.push(
|
|
327
327
|
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'),
|
|
328
|
+
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Dev'),
|
|
329
|
+
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Beta'),
|
|
328
330
|
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary'),
|
|
329
331
|
path.join(home, 'Library', 'Application Support', 'Chromium'),
|
|
330
332
|
path.join(home, 'Library', 'Application Support', 'Microsoft Edge'),
|
|
@@ -332,6 +334,8 @@ export function discoverExtensionToken(): string | null {
|
|
|
332
334
|
} else if (platform === 'linux') {
|
|
333
335
|
bases.push(
|
|
334
336
|
path.join(home, '.config', 'google-chrome'),
|
|
337
|
+
path.join(home, '.config', 'google-chrome-unstable'),
|
|
338
|
+
path.join(home, '.config', 'google-chrome-beta'),
|
|
335
339
|
path.join(home, '.config', 'chromium'),
|
|
336
340
|
path.join(home, '.config', 'microsoft-edge'),
|
|
337
341
|
);
|
|
@@ -339,6 +343,8 @@ export function discoverExtensionToken(): string | null {
|
|
|
339
343
|
const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
340
344
|
bases.push(
|
|
341
345
|
path.join(appData, 'Google', 'Chrome', 'User Data'),
|
|
346
|
+
path.join(appData, 'Google', 'Chrome Dev', 'User Data'),
|
|
347
|
+
path.join(appData, 'Google', 'Chrome Beta', 'User Data'),
|
|
342
348
|
path.join(appData, 'Microsoft', 'Edge', 'User Data'),
|
|
343
349
|
);
|
|
344
350
|
}
|
|
@@ -451,6 +457,8 @@ export function checkExtensionInstalled(): { installed: boolean; browsers: strin
|
|
|
451
457
|
if (platform === 'darwin') {
|
|
452
458
|
browserDirs.push(
|
|
453
459
|
{ name: 'Chrome', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome') },
|
|
460
|
+
{ name: 'Chrome Dev', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Dev') },
|
|
461
|
+
{ name: 'Chrome Beta', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Beta') },
|
|
454
462
|
{ name: 'Chrome Canary', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary') },
|
|
455
463
|
{ name: 'Chromium', base: path.join(home, 'Library', 'Application Support', 'Chromium') },
|
|
456
464
|
{ name: 'Edge', base: path.join(home, 'Library', 'Application Support', 'Microsoft Edge') },
|
|
@@ -458,6 +466,8 @@ export function checkExtensionInstalled(): { installed: boolean; browsers: strin
|
|
|
458
466
|
} else if (platform === 'linux') {
|
|
459
467
|
browserDirs.push(
|
|
460
468
|
{ name: 'Chrome', base: path.join(home, '.config', 'google-chrome') },
|
|
469
|
+
{ name: 'Chrome Dev', base: path.join(home, '.config', 'google-chrome-unstable') },
|
|
470
|
+
{ name: 'Chrome Beta', base: path.join(home, '.config', 'google-chrome-beta') },
|
|
461
471
|
{ name: 'Chromium', base: path.join(home, '.config', 'chromium') },
|
|
462
472
|
{ name: 'Edge', base: path.join(home, '.config', 'microsoft-edge') },
|
|
463
473
|
);
|
|
@@ -465,6 +475,8 @@ export function checkExtensionInstalled(): { installed: boolean; browsers: strin
|
|
|
465
475
|
const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
466
476
|
browserDirs.push(
|
|
467
477
|
{ name: 'Chrome', base: path.join(appData, 'Google', 'Chrome', 'User Data') },
|
|
478
|
+
{ name: 'Chrome Dev', base: path.join(appData, 'Google', 'Chrome Dev', 'User Data') },
|
|
479
|
+
{ name: 'Chrome Beta', base: path.join(appData, 'Google', 'Chrome Beta', 'User Data') },
|
|
468
480
|
{ name: 'Edge', base: path.join(appData, 'Microsoft', 'Edge', 'User Data') },
|
|
469
481
|
);
|
|
470
482
|
}
|
package/src/engine.ts
CHANGED
|
@@ -14,6 +14,8 @@ import yaml from 'js-yaml';
|
|
|
14
14
|
import { type CliCommand, type InternalCliCommand, type Arg, Strategy, registerCommand } from './registry.js';
|
|
15
15
|
import type { IPage } from './types.js';
|
|
16
16
|
import { executePipeline } from './pipeline.js';
|
|
17
|
+
import { log } from './logger.js';
|
|
18
|
+
import { AdapterLoadError } from './errors.js';
|
|
17
19
|
|
|
18
20
|
/** Set of TS module paths that have been loaded */
|
|
19
21
|
const _loadedModules = new Set<string>();
|
|
@@ -84,7 +86,7 @@ function loadFromManifest(manifestPath: string, clisDir: string): void {
|
|
|
84
86
|
}
|
|
85
87
|
}
|
|
86
88
|
} catch (err: any) {
|
|
87
|
-
|
|
89
|
+
log.warn(`Failed to load manifest ${manifestPath}: ${err.message}`);
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
92
|
|
|
@@ -107,7 +109,7 @@ async function discoverClisFromFs(dir: string): Promise<void> {
|
|
|
107
109
|
) {
|
|
108
110
|
promises.push(
|
|
109
111
|
import(`file://${filePath}`).catch((err: any) => {
|
|
110
|
-
|
|
112
|
+
log.warn(`Failed to load module ${filePath}: ${err.message}`);
|
|
111
113
|
})
|
|
112
114
|
);
|
|
113
115
|
}
|
|
@@ -158,7 +160,7 @@ function registerYamlCli(filePath: string, defaultSite: string): void {
|
|
|
158
160
|
|
|
159
161
|
registerCommand(cmd);
|
|
160
162
|
} catch (err: any) {
|
|
161
|
-
|
|
163
|
+
log.warn(`Failed to load ${filePath}: ${err.message}`);
|
|
162
164
|
}
|
|
163
165
|
}
|
|
164
166
|
|
|
@@ -180,7 +182,10 @@ export async function executeCommand(
|
|
|
180
182
|
await import(`file://${modulePath}`);
|
|
181
183
|
_loadedModules.add(modulePath);
|
|
182
184
|
} catch (err: any) {
|
|
183
|
-
throw new
|
|
185
|
+
throw new AdapterLoadError(
|
|
186
|
+
`Failed to load adapter module ${modulePath}: ${err.message}`,
|
|
187
|
+
'Check that the adapter file exists and has no syntax errors.',
|
|
188
|
+
);
|
|
184
189
|
}
|
|
185
190
|
}
|
|
186
191
|
// After loading, the module's cli() call will have updated the registry
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified error types for opencli.
|
|
3
|
+
*
|
|
4
|
+
* All errors thrown by the framework should extend CliError so that
|
|
5
|
+
* the top-level handler in main.ts can render consistent, helpful output.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class CliError extends Error {
|
|
9
|
+
/** Machine-readable error code (e.g. 'BROWSER_CONNECT', 'ADAPTER_LOAD') */
|
|
10
|
+
readonly code: string;
|
|
11
|
+
/** Human-readable hint on how to fix the problem */
|
|
12
|
+
readonly hint?: string;
|
|
13
|
+
|
|
14
|
+
constructor(code: string, message: string, hint?: string) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'CliError';
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.hint = hint;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class BrowserConnectError extends CliError {
|
|
23
|
+
constructor(message: string, hint?: string) {
|
|
24
|
+
super('BROWSER_CONNECT', message, hint);
|
|
25
|
+
this.name = 'BrowserConnectError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class AdapterLoadError extends CliError {
|
|
30
|
+
constructor(message: string, hint?: string) {
|
|
31
|
+
super('ADAPTER_LOAD', message, hint);
|
|
32
|
+
this.name = 'AdapterLoadError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class CommandExecutionError extends CliError {
|
|
37
|
+
constructor(message: string, hint?: string) {
|
|
38
|
+
super('COMMAND_EXEC', message, hint);
|
|
39
|
+
this.name = 'CommandExecutionError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class ConfigError extends CliError {
|
|
44
|
+
constructor(message: string, hint?: string) {
|
|
45
|
+
super('CONFIG', message, hint);
|
|
46
|
+
this.name = 'ConfigError';
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified logging for opencli.
|
|
3
|
+
*
|
|
4
|
+
* All framework output (warnings, debug info, errors) should go through
|
|
5
|
+
* this module so that verbosity levels are respected consistently.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
|
|
10
|
+
function isVerbose(): boolean {
|
|
11
|
+
return !!process.env.OPENCLI_VERBOSE;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isDebug(): boolean {
|
|
15
|
+
return !!process.env.DEBUG?.includes('opencli');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const log = {
|
|
19
|
+
/** Informational message (always shown) */
|
|
20
|
+
info(msg: string): void {
|
|
21
|
+
process.stderr.write(`${chalk.blue('ℹ')} ${msg}\n`);
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
/** Warning (always shown) */
|
|
25
|
+
warn(msg: string): void {
|
|
26
|
+
process.stderr.write(`${chalk.yellow('⚠')} ${msg}\n`);
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
/** Error (always shown) */
|
|
30
|
+
error(msg: string): void {
|
|
31
|
+
process.stderr.write(`${chalk.red('✖')} ${msg}\n`);
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
/** Verbose output (only when OPENCLI_VERBOSE is set or -v flag) */
|
|
35
|
+
verbose(msg: string): void {
|
|
36
|
+
if (isVerbose()) {
|
|
37
|
+
process.stderr.write(`${chalk.dim('[verbose]')} ${msg}\n`);
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
/** Debug output (only when DEBUG includes 'opencli') */
|
|
42
|
+
debug(msg: string): void {
|
|
43
|
+
if (isDebug()) {
|
|
44
|
+
process.stderr.write(`${chalk.dim('[debug]')} ${msg}\n`);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
/** Step-style debug (for pipeline steps, etc.) */
|
|
49
|
+
step(stepNum: number, total: number, op: string, preview: string = ''): void {
|
|
50
|
+
process.stderr.write(` ${chalk.dim(`[${stepNum}/${total}]`)} ${chalk.bold.cyan(op)}${preview}\n`);
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
/** Step result summary */
|
|
54
|
+
stepResult(summary: string): void {
|
|
55
|
+
process.stderr.write(` ${chalk.dim(`→ ${summary}`)}\n`);
|
|
56
|
+
},
|
|
57
|
+
};
|
package/src/main.ts
CHANGED
|
@@ -11,10 +11,11 @@ import chalk from 'chalk';
|
|
|
11
11
|
import { discoverClis, executeCommand } from './engine.js';
|
|
12
12
|
import { type CliCommand, fullName, getRegistry, strategyLabel } from './registry.js';
|
|
13
13
|
import { render as renderOutput } from './output.js';
|
|
14
|
-
import { PlaywrightMCP } from './browser.js';
|
|
14
|
+
import { PlaywrightMCP } from './browser/index.js';
|
|
15
15
|
import { browserSession, DEFAULT_BROWSER_COMMAND_TIMEOUT, runWithTimeout } from './runtime.js';
|
|
16
16
|
import { PKG_VERSION } from './version.js';
|
|
17
17
|
import { getCompletions, printCompletionScript } from './completion.js';
|
|
18
|
+
import { CliError } from './errors.js';
|
|
18
19
|
|
|
19
20
|
const __filename = fileURLToPath(import.meta.url);
|
|
20
21
|
const __dirname = path.dirname(__filename);
|
|
@@ -212,8 +213,14 @@ for (const [, cmd] of registry) {
|
|
|
212
213
|
}
|
|
213
214
|
renderOutput(result, { fmt: actionOpts.format, columns: cmd.columns, title: `${cmd.site}/${cmd.name}`, elapsed: (Date.now() - startTime) / 1000, source: fullName(cmd) });
|
|
214
215
|
} catch (err: any) {
|
|
215
|
-
if (
|
|
216
|
-
|
|
216
|
+
if (err instanceof CliError) {
|
|
217
|
+
console.error(chalk.red(`Error [${err.code}]: ${err.message}`));
|
|
218
|
+
if (err.hint) console.error(chalk.yellow(`Hint: ${err.hint}`));
|
|
219
|
+
} else if (actionOpts.verbose && err.stack) {
|
|
220
|
+
console.error(chalk.red(err.stack));
|
|
221
|
+
} else {
|
|
222
|
+
console.error(chalk.red(`Error: ${err.message ?? err}`));
|
|
223
|
+
}
|
|
217
224
|
process.exitCode = 1;
|
|
218
225
|
}
|
|
219
226
|
});
|
package/src/pipeline/executor.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { stepFetch } from './steps/fetch.js';
|
|
|
9
9
|
import { stepSelect, stepMap, stepFilter, stepSort, stepLimit } from './steps/transform.js';
|
|
10
10
|
import { stepIntercept } from './steps/intercept.js';
|
|
11
11
|
import { stepTap } from './steps/tap.js';
|
|
12
|
+
import { log } from '../logger.js';
|
|
12
13
|
|
|
13
14
|
export interface PipelineContext {
|
|
14
15
|
args?: Record<string, any>;
|
|
@@ -57,7 +58,7 @@ export async function executePipeline(
|
|
|
57
58
|
if (handler) {
|
|
58
59
|
data = await handler(page, params, data, args);
|
|
59
60
|
} else {
|
|
60
|
-
if (debug)
|
|
61
|
+
if (debug) log.warn(`Unknown step: ${op}`);
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
if (debug) debugStepResult(op, data);
|
|
@@ -73,21 +74,21 @@ function debugStepStart(stepNum: number, total: number, op: string, params: any)
|
|
|
73
74
|
} else if (params && typeof params === 'object' && !Array.isArray(params)) {
|
|
74
75
|
preview = ` (${Object.keys(params).join(', ')})`;
|
|
75
76
|
}
|
|
76
|
-
|
|
77
|
+
log.step(stepNum, total, op, preview);
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
function debugStepResult(op: string, data: any): void {
|
|
80
81
|
if (data === null || data === undefined) {
|
|
81
|
-
|
|
82
|
+
log.stepResult('(no data)');
|
|
82
83
|
} else if (Array.isArray(data)) {
|
|
83
|
-
|
|
84
|
+
log.stepResult(`${data.length} items`);
|
|
84
85
|
} else if (typeof data === 'object') {
|
|
85
86
|
const keys = Object.keys(data).slice(0, 5);
|
|
86
|
-
|
|
87
|
+
log.stepResult(`dict (${keys.join(', ')}${Object.keys(data).length > 5 ? '...' : ''})`);
|
|
87
88
|
} else if (typeof data === 'string') {
|
|
88
89
|
const p = data.slice(0, 60).replace(/\n/g, '\\n');
|
|
89
|
-
|
|
90
|
+
log.stepResult(`"${p}${data.length > 60 ? '...' : ''}"`);
|
|
90
91
|
} else {
|
|
91
|
-
|
|
92
|
+
log.stepResult(`${typeof data}`);
|
|
92
93
|
}
|
|
93
94
|
}
|
|
@@ -6,53 +6,53 @@
|
|
|
6
6
|
import type { IPage } from '../../types.js';
|
|
7
7
|
import { render, normalizeEvaluateSource } from '../template.js';
|
|
8
8
|
|
|
9
|
-
export async function stepNavigate(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> {
|
|
9
|
+
export async function stepNavigate(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
|
|
10
10
|
const url = render(params, { args, data });
|
|
11
|
-
await page
|
|
11
|
+
await page!.goto(String(url));
|
|
12
12
|
return data;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export async function stepClick(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> {
|
|
16
|
-
await page
|
|
15
|
+
export async function stepClick(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
|
|
16
|
+
await page!.click(String(render(params, { args, data })).replace(/^@/, ''));
|
|
17
17
|
return data;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
export async function stepType(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> {
|
|
20
|
+
export async function stepType(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
|
|
21
21
|
if (typeof params === 'object' && params) {
|
|
22
22
|
const ref = String(render(params.ref ?? '', { args, data })).replace(/^@/, '');
|
|
23
23
|
const text = String(render(params.text ?? '', { args, data }));
|
|
24
|
-
await page
|
|
25
|
-
if (params.submit) await page
|
|
24
|
+
await page!.typeText(ref, text);
|
|
25
|
+
if (params.submit) await page!.pressKey('Enter');
|
|
26
26
|
}
|
|
27
27
|
return data;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
export async function stepWait(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> {
|
|
31
|
-
if (typeof params === 'number') await page
|
|
30
|
+
export async function stepWait(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
|
|
31
|
+
if (typeof params === 'number') await page!.wait(params);
|
|
32
32
|
else if (typeof params === 'object' && params) {
|
|
33
33
|
if ('text' in params) {
|
|
34
|
-
await page
|
|
34
|
+
await page!.wait({
|
|
35
35
|
text: String(render(params.text, { args, data })),
|
|
36
36
|
timeout: params.timeout
|
|
37
37
|
});
|
|
38
|
-
} else if ('time' in params) await page
|
|
39
|
-
} else if (typeof params === 'string') await page
|
|
38
|
+
} else if ('time' in params) await page!.wait(Number(params.time));
|
|
39
|
+
} else if (typeof params === 'string') await page!.wait(Number(render(params, { args, data })));
|
|
40
40
|
return data;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
export async function stepPress(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> {
|
|
44
|
-
await page
|
|
43
|
+
export async function stepPress(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
|
|
44
|
+
await page!.pressKey(String(render(params, { args, data })));
|
|
45
45
|
return data;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
export async function stepSnapshot(page: IPage, params: any, _data: any, _args: Record<string, any>): Promise<any> {
|
|
48
|
+
export async function stepSnapshot(page: IPage | null, params: any, _data: any, _args: Record<string, any>): Promise<any> {
|
|
49
49
|
const opts = (typeof params === 'object' && params) ? params : {};
|
|
50
|
-
return page
|
|
50
|
+
return page!.snapshot({ interactive: opts.interactive ?? false, compact: opts.compact ?? false, maxDepth: opts.max_depth, raw: opts.raw ?? false });
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
export async function stepEvaluate(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> {
|
|
53
|
+
export async function stepEvaluate(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
|
|
54
54
|
const js = String(render(params, { args, data }));
|
|
55
|
-
let result = await page
|
|
55
|
+
let result = await page!.evaluate(normalizeEvaluateSource(js));
|
|
56
56
|
// MCP may return JSON as a string — auto-parse it
|
|
57
57
|
if (typeof result === 'string') {
|
|
58
58
|
const trimmed = result.trim();
|