@jackwener/opencli 1.0.0 → 1.0.1

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 (98) hide show
  1. package/README.md +20 -1
  2. package/README.zh-CN.md +20 -1
  3. package/dist/browser/daemon-client.d.ts +1 -1
  4. package/dist/browser/index.d.ts +1 -2
  5. package/dist/browser/index.js +1 -5
  6. package/dist/browser/mcp.d.ts +5 -8
  7. package/dist/browser/mcp.js +9 -10
  8. package/dist/browser/page.d.ts +8 -1
  9. package/dist/browser/page.js +23 -17
  10. package/dist/browser.test.js +6 -6
  11. package/dist/cli-manifest.json +394 -14
  12. package/dist/clis/apple-podcasts/episodes.d.ts +1 -0
  13. package/dist/clis/apple-podcasts/episodes.js +28 -0
  14. package/dist/clis/apple-podcasts/search.d.ts +1 -0
  15. package/dist/clis/apple-podcasts/search.js +29 -0
  16. package/dist/clis/apple-podcasts/top.d.ts +1 -0
  17. package/dist/clis/apple-podcasts/top.js +34 -0
  18. package/dist/clis/apple-podcasts/utils.d.ts +11 -0
  19. package/dist/clis/apple-podcasts/utils.js +30 -0
  20. package/dist/clis/apple-podcasts/utils.test.d.ts +1 -0
  21. package/dist/clis/apple-podcasts/utils.test.js +57 -0
  22. package/dist/clis/chatwise/history.js +18 -1
  23. package/dist/clis/discord-app/channels.js +33 -21
  24. package/dist/clis/twitter/accept.d.ts +1 -0
  25. package/dist/clis/twitter/accept.js +202 -0
  26. package/dist/clis/twitter/followers.js +30 -22
  27. package/dist/clis/twitter/following.js +19 -14
  28. package/dist/clis/twitter/notifications.js +29 -22
  29. package/dist/clis/twitter/reply-dm.d.ts +1 -0
  30. package/dist/clis/twitter/reply-dm.js +181 -0
  31. package/dist/clis/twitter/search.js +50 -12
  32. package/dist/clis/weread/book.d.ts +1 -0
  33. package/dist/clis/weread/book.js +26 -0
  34. package/dist/clis/weread/highlights.d.ts +1 -0
  35. package/dist/clis/weread/highlights.js +23 -0
  36. package/dist/clis/weread/notebooks.d.ts +1 -0
  37. package/dist/clis/weread/notebooks.js +21 -0
  38. package/dist/clis/weread/notes.d.ts +1 -0
  39. package/dist/clis/weread/notes.js +29 -0
  40. package/dist/clis/weread/ranking.d.ts +1 -0
  41. package/dist/clis/weread/ranking.js +28 -0
  42. package/dist/clis/weread/search.d.ts +1 -0
  43. package/dist/clis/weread/search.js +25 -0
  44. package/dist/clis/weread/shelf.d.ts +1 -0
  45. package/dist/clis/weread/shelf.js +24 -0
  46. package/dist/clis/weread/utils.d.ts +20 -0
  47. package/dist/clis/weread/utils.js +72 -0
  48. package/dist/clis/weread/utils.test.d.ts +1 -0
  49. package/dist/clis/weread/utils.test.js +85 -0
  50. package/dist/daemon.js +2 -2
  51. package/dist/doctor.d.ts +0 -21
  52. package/dist/doctor.js +2 -24
  53. package/dist/main.js +6 -16
  54. package/dist/runtime.d.ts +1 -4
  55. package/dist/runtime.js +1 -4
  56. package/dist/setup.js +2 -2
  57. package/extension/dist/background.js +484 -0
  58. package/extension/manifest.json +1 -1
  59. package/extension/package.json +1 -1
  60. package/extension/src/background.ts +99 -22
  61. package/extension/src/protocol.ts +1 -1
  62. package/package.json +1 -1
  63. package/src/browser/daemon-client.ts +1 -1
  64. package/src/browser/index.ts +1 -6
  65. package/src/browser/mcp.ts +14 -15
  66. package/src/browser/page.ts +23 -17
  67. package/src/browser.test.ts +6 -6
  68. package/src/clis/apple-podcasts/episodes.ts +28 -0
  69. package/src/clis/apple-podcasts/search.ts +29 -0
  70. package/src/clis/apple-podcasts/top.ts +34 -0
  71. package/src/clis/apple-podcasts/utils.test.ts +72 -0
  72. package/src/clis/apple-podcasts/utils.ts +37 -0
  73. package/src/clis/chatwise/history.ts +15 -1
  74. package/src/clis/discord-app/channels.ts +33 -21
  75. package/src/clis/twitter/accept.ts +213 -0
  76. package/src/clis/twitter/followers.ts +36 -29
  77. package/src/clis/twitter/following.ts +25 -20
  78. package/src/clis/twitter/notifications.ts +34 -27
  79. package/src/clis/twitter/reply-dm.ts +193 -0
  80. package/src/clis/twitter/search.ts +53 -13
  81. package/src/clis/weread/book.ts +28 -0
  82. package/src/clis/weread/highlights.ts +25 -0
  83. package/src/clis/weread/notebooks.ts +23 -0
  84. package/src/clis/weread/notes.ts +31 -0
  85. package/src/clis/weread/ranking.ts +29 -0
  86. package/src/clis/weread/search.ts +26 -0
  87. package/src/clis/weread/shelf.ts +26 -0
  88. package/src/clis/weread/utils.test.ts +104 -0
  89. package/src/clis/weread/utils.ts +74 -0
  90. package/src/daemon.ts +2 -2
  91. package/src/doctor.ts +2 -19
  92. package/src/main.ts +5 -11
  93. package/src/runtime.ts +2 -6
  94. package/src/setup.ts +2 -2
  95. package/tests/e2e/public-commands.test.ts +68 -1
  96. package/dist/clis/grok/debug.d.ts +0 -1
  97. package/dist/clis/grok/debug.js +0 -45
  98. package/src/clis/grok/debug.ts +0 -49
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { Command, Result } from './protocol';
9
9
  import { DAEMON_WS_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol';
10
- import * as cdp from './cdp';
10
+ import * as executor from './cdp';
11
11
 
12
12
  let ws: WebSocket | null = null;
13
13
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -85,6 +85,66 @@ function scheduleReconnect(): void {
85
85
  }, delay);
86
86
  }
87
87
 
88
+ // ─── Automation window isolation ─────────────────────────────────────
89
+ // All opencli operations happen in a dedicated Chrome window so the
90
+ // user's active browsing session is never touched.
91
+ // The window auto-closes after 30s of idle (no commands).
92
+
93
+ let automationWindowId: number | null = null;
94
+ let windowIdleTimer: ReturnType<typeof setTimeout> | null = null;
95
+ const WINDOW_IDLE_TIMEOUT = 30000; // 30s
96
+
97
+ function resetWindowIdleTimer(): void {
98
+ if (windowIdleTimer) clearTimeout(windowIdleTimer);
99
+ windowIdleTimer = setTimeout(async () => {
100
+ if (automationWindowId !== null) {
101
+ try {
102
+ await chrome.windows.remove(automationWindowId);
103
+ console.log(`[opencli] Automation window ${automationWindowId} closed (idle timeout)`);
104
+ } catch {
105
+ // Already gone
106
+ }
107
+ automationWindowId = null;
108
+ }
109
+ windowIdleTimer = null;
110
+ }, WINDOW_IDLE_TIMEOUT);
111
+ }
112
+
113
+ /** Get or create the dedicated automation window. */
114
+ async function getAutomationWindow(): Promise<number> {
115
+ // Check if our window is still alive
116
+ if (automationWindowId !== null) {
117
+ try {
118
+ await chrome.windows.get(automationWindowId);
119
+ return automationWindowId;
120
+ } catch {
121
+ // Window was closed by user
122
+ automationWindowId = null;
123
+ }
124
+ }
125
+
126
+ // Create a new window with about:blank (not chrome://newtab which blocks scripting)
127
+ const win = await chrome.windows.create({
128
+ url: 'about:blank',
129
+ focused: false,
130
+ width: 1280,
131
+ height: 900,
132
+ type: 'normal',
133
+ });
134
+ automationWindowId = win.id!;
135
+ console.log(`[opencli] Created automation window ${automationWindowId}`);
136
+ return automationWindowId;
137
+ }
138
+
139
+ // Clean up when the automation window is closed
140
+ chrome.windows.onRemoved.addListener((windowId) => {
141
+ if (windowId === automationWindowId) {
142
+ console.log('[opencli] Automation window closed');
143
+ automationWindowId = null;
144
+ if (windowIdleTimer) { clearTimeout(windowIdleTimer); windowIdleTimer = null; }
145
+ }
146
+ });
147
+
88
148
  // ─── Lifecycle events ────────────────────────────────────────────────
89
149
 
90
150
  let initialized = false;
@@ -93,7 +153,7 @@ function initialize(): void {
93
153
  if (initialized) return;
94
154
  initialized = true;
95
155
  chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); // ~24 seconds
96
- cdp.registerListeners();
156
+ executor.registerListeners();
97
157
  connect();
98
158
  console.log('[opencli] Browser Bridge extension initialized');
99
159
  }
@@ -113,6 +173,8 @@ chrome.alarms.onAlarm.addListener((alarm) => {
113
173
  // ─── Command dispatcher ─────────────────────────────────────────────
114
174
 
115
175
  async function handleCommand(cmd: Command): Promise<Result> {
176
+ // Reset idle timer on every command (window stays alive while active)
177
+ resetWindowIdleTimer();
116
178
  try {
117
179
  switch (cmd.action) {
118
180
  case 'exec':
@@ -125,6 +187,8 @@ async function handleCommand(cmd: Command): Promise<Result> {
125
187
  return await handleCookies(cmd);
126
188
  case 'screenshot':
127
189
  return await handleScreenshot(cmd);
190
+ case 'close-window':
191
+ return await handleCloseWindow(cmd);
128
192
  default:
129
193
  return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
130
194
  }
@@ -145,27 +209,28 @@ function isWebUrl(url?: string): boolean {
145
209
  return !url.startsWith('chrome://') && !url.startsWith('chrome-extension://');
146
210
  }
147
211
 
148
- /** Resolve target tab: use specified tabId or fall back to active web page tab */
212
+ /**
213
+ * Resolve target tab in the automation window.
214
+ * If explicit tabId is given, use that directly.
215
+ * Otherwise, find or create a tab in the dedicated automation window.
216
+ */
149
217
  async function resolveTabId(tabId?: number): Promise<number> {
150
218
  if (tabId !== undefined) return tabId;
151
219
 
152
- // Try the active tab first
153
- const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
154
- if (activeTab?.id && isWebUrl(activeTab.url)) {
155
- return activeTab.id;
156
- }
220
+ // Get (or create) the automation window
221
+ const windowId = await getAutomationWindow();
157
222
 
158
- // Active tab is not debuggable try to find any open web page tab
159
- const allTabs = await chrome.tabs.query({ currentWindow: true });
160
- const webTab = allTabs.find(t => t.id && isWebUrl(t.url));
161
- if (webTab?.id) {
162
- await chrome.tabs.update(webTab.id, { active: true });
163
- return webTab.id;
164
- }
223
+ // Find the active tab in our automation window
224
+ const tabs = await chrome.tabs.query({ windowId });
225
+ const webTab = tabs.find(t => t.id && isWebUrl(t.url));
226
+ if (webTab?.id) return webTab.id;
165
227
 
166
- // No web tabs at all create one
167
- const newTab = await chrome.tabs.create({ url: 'about:blank', active: true });
168
- if (!newTab.id) throw new Error('Failed to create new tab');
228
+ // Use the first tab if it's a blank/new tab page
229
+ if (tabs.length > 0 && tabs[0]?.id) return tabs[0].id;
230
+
231
+ // No suitable tab — create one
232
+ const newTab = await chrome.tabs.create({ windowId, url: 'about:blank', active: true });
233
+ if (!newTab.id) throw new Error('Failed to create tab in automation window');
169
234
  return newTab.id;
170
235
  }
171
236
 
@@ -173,7 +238,7 @@ async function handleExec(cmd: Command): Promise<Result> {
173
238
  if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' };
174
239
  const tabId = await resolveTabId(cmd.tabId);
175
240
  try {
176
- const data = await cdp.evaluateAsync(tabId, cmd.code);
241
+ const data = await executor.evaluateAsync(tabId, cmd.code);
177
242
  return { id: cmd.id, ok: true, data };
178
243
  } catch (err) {
179
244
  return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
@@ -235,12 +300,12 @@ async function handleTabs(cmd: Command): Promise<Result> {
235
300
  const target = tabs[cmd.index];
236
301
  if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
237
302
  await chrome.tabs.remove(target.id);
238
- cdp.detach(target.id);
303
+ executor.detach(target.id);
239
304
  return { id: cmd.id, ok: true, data: { closed: target.id } };
240
305
  }
241
306
  const tabId = await resolveTabId(cmd.tabId);
242
307
  await chrome.tabs.remove(tabId);
243
- cdp.detach(tabId);
308
+ executor.detach(tabId);
244
309
  return { id: cmd.id, ok: true, data: { closed: tabId } };
245
310
  }
246
311
  case 'select': {
@@ -281,7 +346,7 @@ async function handleCookies(cmd: Command): Promise<Result> {
281
346
  async function handleScreenshot(cmd: Command): Promise<Result> {
282
347
  const tabId = await resolveTabId(cmd.tabId);
283
348
  try {
284
- const data = await cdp.screenshot(tabId, {
349
+ const data = await executor.screenshot(tabId, {
285
350
  format: cmd.format,
286
351
  quality: cmd.quality,
287
352
  fullPage: cmd.fullPage,
@@ -291,3 +356,15 @@ async function handleScreenshot(cmd: Command): Promise<Result> {
291
356
  return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
292
357
  }
293
358
  }
359
+
360
+ async function handleCloseWindow(cmd: Command): Promise<Result> {
361
+ if (automationWindowId !== null) {
362
+ try {
363
+ await chrome.windows.remove(automationWindowId);
364
+ } catch {
365
+ // Window may already be closed
366
+ }
367
+ automationWindowId = null;
368
+ }
369
+ return { id: cmd.id, ok: true, data: { closed: true } };
370
+ }
@@ -5,7 +5,7 @@
5
5
  * Everything else is just JS code sent via 'exec'.
6
6
  */
7
7
 
8
- export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot';
8
+ export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window';
9
9
 
10
10
  export interface Command {
11
11
  /** Unique request ID */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -15,7 +15,7 @@ function generateId(): string {
15
15
 
16
16
  export interface DaemonCommand {
17
17
  id: string;
18
- action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot';
18
+ action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window';
19
19
  tabId?: number;
20
20
  code?: string;
21
21
  url?: string;
@@ -6,14 +6,9 @@
6
6
  */
7
7
 
8
8
  export { Page } from './page.js';
9
- export { PlaywrightMCP } from './mcp.js';
9
+ export { BrowserBridge, BrowserBridge as PlaywrightMCP } from './mcp.js';
10
10
  export { isDaemonRunning } from './daemon-client.js';
11
11
 
12
- // Backward compatibility: getTokenFingerprint is no longer needed but kept as no-op export
13
- export function getTokenFingerprint(_token: string | undefined): string | null {
14
- return null;
15
- }
16
-
17
12
  import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
18
13
  import { withTimeoutMs } from '../runtime.js';
19
14
 
@@ -1,8 +1,5 @@
1
1
  /**
2
2
  * Browser session manager — auto-spawns daemon and provides IPage.
3
- *
4
- * Replaces the old PlaywrightMCP class. Still exports as PlaywrightMCP
5
- * for backward compatibility with main.ts and other consumers.
6
3
  */
7
4
 
8
5
  import { spawn, type ChildProcess } from 'node:child_process';
@@ -15,21 +12,17 @@ import { isDaemonRunning, isExtensionConnected } from './daemon-client.js';
15
12
 
16
13
  const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
17
14
 
18
- export type PlaywrightMCPState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed';
19
-
20
-
15
+ export type BrowserBridgeState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed';
21
16
 
22
17
  /**
23
18
  * Browser factory: manages daemon lifecycle and provides IPage instances.
24
- *
25
- * Kept as `PlaywrightMCP` class name for backward compatibility.
26
19
  */
27
- export class PlaywrightMCP {
28
- private _state: PlaywrightMCPState = 'idle';
20
+ export class BrowserBridge {
21
+ private _state: BrowserBridgeState = 'idle';
29
22
  private _page: Page | null = null;
30
23
  private _daemonProc: ChildProcess | null = null;
31
24
 
32
- get state(): PlaywrightMCPState {
25
+ get state(): BrowserBridgeState {
33
26
  return this._state;
34
27
  }
35
28
 
@@ -78,10 +71,13 @@ export class PlaywrightMCP {
78
71
  console.error(`[opencli] Starting daemon (${isTs ? 'ts' : 'js'})...`);
79
72
  }
80
73
 
81
- // Use the current runtime to spawn daemon avoids slow npx resolution.
82
- // If already running under tsx (dev), process.execPath is tsx's node.
83
- // If running compiled (node dist/), process.execPath is node.
84
- this._daemonProc = spawn(process.execPath, [daemonPath], {
74
+ // For compiled .js, use the current node binary directly (fast).
75
+ // For .ts dev mode, node can't run .ts files — use tsx via --import.
76
+ const spawnArgs = isTs
77
+ ? [process.execPath, '--import', 'tsx/esm', daemonPath]
78
+ : [process.execPath, daemonPath];
79
+
80
+ this._daemonProc = spawn(spawnArgs[0], spawnArgs.slice(1), {
85
81
  detached: true,
86
82
  stdio: 'ignore',
87
83
  env: { ...process.env },
@@ -110,3 +106,6 @@ export class PlaywrightMCP {
110
106
  );
111
107
  }
112
108
  }
109
+
110
+ /** @deprecated Use BrowserBridge instead */
111
+ export const PlaywrightMCP = BrowserBridge;
@@ -37,6 +37,15 @@ export class Page implements IPage {
37
37
  }
38
38
  }
39
39
 
40
+ /** Close the automation window in the extension */
41
+ async closeWindow(): Promise<void> {
42
+ try {
43
+ await sendCommand('close-window', {});
44
+ } catch {
45
+ // Window may already be closed or daemon may be down
46
+ }
47
+ }
48
+
40
49
  async evaluate(js: string): Promise<any> {
41
50
  const code = wrapForEval(js);
42
51
  return sendCommand('exec', { code, ...this._tabOpt() });
@@ -183,12 +192,12 @@ export class Page implements IPage {
183
192
  return sendCommand('exec', { code, ...this._tabOpt() });
184
193
  }
185
194
 
186
- async consoleMessages(level: string = 'info'): Promise<any> {
187
- // Console messages can't be retrospectively read via CDP Runtime.evaluate.
188
- // Would need Runtime.consoleAPICalled event listener, which is not yet implemented.
189
- if (process.env.OPENCLI_VERBOSE) {
190
- console.error('[page] consoleMessages() not supported in lightweight mode — returning empty');
191
- }
195
+ /**
196
+ * Console messages are not available in lightweight daemon mode.
197
+ * Would require CDP Runtime.consoleAPICalled event listener.
198
+ * @returns Always returns empty array.
199
+ */
200
+ async consoleMessages(_level: string = 'info'): Promise<any> {
192
201
  return [];
193
202
  }
194
203
 
@@ -260,21 +269,18 @@ export class Page implements IPage {
260
269
 
261
270
  async installInterceptor(pattern: string): Promise<void> {
262
271
  const { generateInterceptorJs } = await import('../interceptor.js');
263
- await sendCommand('exec', {
264
- code: generateInterceptorJs(JSON.stringify(pattern), {
265
- arrayName: '__opencli_xhr',
266
- patchGuard: '__opencli_interceptor_patched',
267
- }),
268
- ...this._tabOpt(),
269
- });
272
+ // Must use evaluate() so wrapForEval() converts the arrow function into an IIFE;
273
+ // sendCommand('exec') sends the code as-is, and CDP never executes a bare arrow.
274
+ await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
275
+ arrayName: '__opencli_xhr',
276
+ patchGuard: '__opencli_interceptor_patched',
277
+ }));
270
278
  }
271
279
 
272
280
  async getInterceptedRequests(): Promise<any[]> {
273
281
  const { generateReadInterceptedJs } = await import('../interceptor.js');
274
- const result = await sendCommand('exec', {
275
- code: generateReadInterceptedJs('__opencli_xhr'),
276
- ...this._tabOpt(),
277
- });
282
+ // Same as installInterceptor: must go through evaluate() for IIFE wrapping
283
+ const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
278
284
  return (result as any[]) || [];
279
285
  }
280
286
  }
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, it, expect, vi } from 'vitest';
2
- import { PlaywrightMCP, __test__ } from './browser/index.js';
2
+ import { BrowserBridge, __test__ } from './browser/index.js';
3
3
 
4
4
  describe('browser helpers', () => {
5
5
  it('extracts tab entries from string snapshots', () => {
@@ -45,9 +45,9 @@ describe('browser helpers', () => {
45
45
  });
46
46
  });
47
47
 
48
- describe('PlaywrightMCP state', () => {
48
+ describe('BrowserBridge state', () => {
49
49
  it('transitions to closed after close()', async () => {
50
- const mcp = new PlaywrightMCP();
50
+ const mcp = new BrowserBridge();
51
51
 
52
52
  expect(mcp.state).toBe('idle');
53
53
 
@@ -57,21 +57,21 @@ describe('PlaywrightMCP state', () => {
57
57
  });
58
58
 
59
59
  it('rejects connect() after the session has been closed', async () => {
60
- const mcp = new PlaywrightMCP();
60
+ const mcp = new BrowserBridge();
61
61
  await mcp.close();
62
62
 
63
63
  await expect(mcp.connect()).rejects.toThrow('Session is closed');
64
64
  });
65
65
 
66
66
  it('rejects connect() while already connecting', async () => {
67
- const mcp = new PlaywrightMCP();
67
+ const mcp = new BrowserBridge();
68
68
  (mcp as any)._state = 'connecting';
69
69
 
70
70
  await expect(mcp.connect()).rejects.toThrow('Already connecting');
71
71
  });
72
72
 
73
73
  it('rejects connect() while closing', async () => {
74
- const mcp = new PlaywrightMCP();
74
+ const mcp = new BrowserBridge();
75
75
  (mcp as any)._state = 'closing';
76
76
 
77
77
  await expect(mcp.connect()).rejects.toThrow('Session is closing');
@@ -0,0 +1,28 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { CliError } from '../../errors.js';
3
+ import { itunesFetch, formatDuration, formatDate } from './utils.js';
4
+
5
+ cli({
6
+ site: 'apple-podcasts',
7
+ name: 'episodes',
8
+ description: 'List recent episodes of an Apple Podcast (use ID from search)',
9
+ strategy: Strategy.PUBLIC,
10
+ browser: false,
11
+ args: [
12
+ { name: 'id', positional: true, required: true, help: 'Podcast ID (collectionId from search output)' },
13
+ { name: 'limit', type: 'int', default: 15, help: 'Max episodes to show' },
14
+ ],
15
+ columns: ['title', 'duration', 'date'],
16
+ func: async (_page, args) => {
17
+ const limit = Math.max(1, Math.min(Number(args.limit), 200));
18
+ // results[0] is the podcast itself; the rest are episodes
19
+ const data = await itunesFetch(`/lookup?id=${args.id}&entity=podcastEpisode&limit=${limit + 1}`);
20
+ const episodes = (data.results ?? []).filter((r: any) => r.kind === 'podcast-episode');
21
+ if (!episodes.length) throw new CliError('NOT_FOUND', 'No episodes found', 'Check the podcast ID from: opencli apple-podcasts search <keyword>');
22
+ return episodes.slice(0, limit).map((ep: any) => ({
23
+ title: ep.trackName,
24
+ duration: formatDuration(ep.trackTimeMillis),
25
+ date: formatDate(ep.releaseDate),
26
+ }));
27
+ },
28
+ });
@@ -0,0 +1,29 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { CliError } from '../../errors.js';
3
+ import { itunesFetch } from './utils.js';
4
+
5
+ cli({
6
+ site: 'apple-podcasts',
7
+ name: 'search',
8
+ description: 'Search Apple Podcasts',
9
+ strategy: Strategy.PUBLIC,
10
+ browser: false,
11
+ args: [
12
+ { name: 'keyword', positional: true, required: true, help: 'Search keyword' },
13
+ { name: 'limit', type: 'int', default: 10, help: 'Max results' },
14
+ ],
15
+ columns: ['id', 'title', 'author', 'episodes', 'genre'],
16
+ func: async (_page, args) => {
17
+ const term = encodeURIComponent(args.keyword);
18
+ const limit = Math.max(1, Math.min(Number(args.limit), 25));
19
+ const data = await itunesFetch(`/search?term=${term}&media=podcast&limit=${limit}`);
20
+ if (!data.results?.length) throw new CliError('NOT_FOUND', 'No podcasts found', `Try a different keyword`);
21
+ return data.results.map((p: any) => ({
22
+ id: p.collectionId,
23
+ title: p.collectionName,
24
+ author: p.artistName,
25
+ episodes: p.trackCount ?? '-',
26
+ genre: p.primaryGenreName ?? '-',
27
+ }));
28
+ },
29
+ });
@@ -0,0 +1,34 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { CliError } from '../../errors.js';
3
+
4
+ // Apple Marketing Tools RSS API — public, no key required
5
+ const CHARTS_URL = 'https://rss.applemarketingtools.com/api/v2';
6
+
7
+ cli({
8
+ site: 'apple-podcasts',
9
+ name: 'top',
10
+ description: 'Top podcasts chart on Apple Podcasts',
11
+ strategy: Strategy.PUBLIC,
12
+ browser: false,
13
+ args: [
14
+ { name: 'limit', type: 'int', default: 20, help: 'Number of podcasts (max 100)' },
15
+ { name: 'country', default: 'us', help: 'Country code (e.g. us, cn, gb, jp)' },
16
+ ],
17
+ columns: ['rank', 'title', 'author', 'id'],
18
+ func: async (_page, args) => {
19
+ const limit = Math.max(1, Math.min(Number(args.limit), 100));
20
+ const country = String(args.country || 'us').trim().toLowerCase();
21
+ const url = `${CHARTS_URL}/${country}/podcasts/top/${limit}/podcasts.json`;
22
+ const resp = await fetch(url);
23
+ if (!resp.ok) throw new CliError('FETCH_ERROR', `Charts API HTTP ${resp.status}`, `Check country code: ${country}`);
24
+ const data = await resp.json();
25
+ const results = data?.feed?.results;
26
+ if (!results?.length) throw new CliError('NOT_FOUND', 'No chart data found', `Try a different country code`);
27
+ return results.map((p: any, i: number) => ({
28
+ rank: i + 1,
29
+ title: p.name,
30
+ author: p.artistName,
31
+ id: p.id,
32
+ }));
33
+ },
34
+ });
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { formatDuration, formatDate, itunesFetch } from './utils.js';
3
+
4
+ describe('formatDuration', () => {
5
+ it('formats typical duration in ms', () => {
6
+ expect(formatDuration(3661000)).toBe('61:01');
7
+ });
8
+
9
+ it('pads single-digit seconds', () => {
10
+ expect(formatDuration(65000)).toBe('1:05');
11
+ });
12
+
13
+ it('formats exact minutes', () => {
14
+ expect(formatDuration(3600000)).toBe('60:00');
15
+ });
16
+
17
+ it('rounds fractional milliseconds', () => {
18
+ expect(formatDuration(3600500)).toBe('60:01');
19
+ });
20
+
21
+ it('returns dash for zero', () => {
22
+ expect(formatDuration(0)).toBe('-');
23
+ });
24
+
25
+ it('returns dash for NaN', () => {
26
+ expect(formatDuration(NaN)).toBe('-');
27
+ });
28
+ });
29
+
30
+ describe('formatDate', () => {
31
+ it('extracts YYYY-MM-DD from ISO string', () => {
32
+ expect(formatDate('2026-03-19T12:00:00.000Z')).toBe('2026-03-19');
33
+ });
34
+
35
+ it('handles date-only string', () => {
36
+ expect(formatDate('2025-01-01')).toBe('2025-01-01');
37
+ });
38
+
39
+ it('returns dash for empty string', () => {
40
+ expect(formatDate('')).toBe('-');
41
+ });
42
+
43
+ it('returns dash for undefined', () => {
44
+ expect(formatDate(undefined as any)).toBe('-');
45
+ });
46
+ });
47
+
48
+ describe('itunesFetch', () => {
49
+ beforeEach(() => {
50
+ vi.restoreAllMocks();
51
+ });
52
+
53
+ it('returns parsed JSON on success', async () => {
54
+ const mockData = { resultCount: 1, results: [{ collectionId: 123 }] };
55
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
56
+ ok: true,
57
+ json: () => Promise.resolve(mockData),
58
+ }));
59
+
60
+ const result = await itunesFetch('/search?term=test&media=podcast&limit=1');
61
+ expect(result).toEqual(mockData);
62
+ });
63
+
64
+ it('throws CliError on HTTP error', async () => {
65
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
66
+ ok: false,
67
+ status: 403,
68
+ }));
69
+
70
+ await expect(itunesFetch('/search?term=test')).rejects.toThrow('iTunes API HTTP 403');
71
+ });
72
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Shared Apple Podcasts utilities.
3
+ *
4
+ * Uses the public iTunes Search API — no API key required.
5
+ * https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/
6
+ */
7
+
8
+ import { CliError } from '../../errors.js';
9
+
10
+ const BASE = 'https://itunes.apple.com';
11
+
12
+ export async function itunesFetch(path: string): Promise<any> {
13
+ const resp = await fetch(`${BASE}${path}`);
14
+ if (!resp.ok) {
15
+ throw new CliError(
16
+ 'FETCH_ERROR',
17
+ `iTunes API HTTP ${resp.status}`,
18
+ 'Check your search term or podcast ID',
19
+ );
20
+ }
21
+ return resp.json();
22
+ }
23
+
24
+ /** Format milliseconds to mm:ss. Returns '-' for missing input. */
25
+ export function formatDuration(ms: number): string {
26
+ if (!ms || !Number.isFinite(ms)) return '-';
27
+ const totalSec = Math.round(ms / 1000);
28
+ const m = Math.floor(totalSec / 60);
29
+ const s = totalSec % 60;
30
+ return `${m}:${String(s).padStart(2, '0')}`;
31
+ }
32
+
33
+ /** Format ISO date string to YYYY-MM-DD. Returns '-' for missing input. */
34
+ export function formatDate(iso: string): string {
35
+ if (!iso) return '-';
36
+ return iso.slice(0, 10);
37
+ }
@@ -42,6 +42,20 @@ export const historyCommand = cli({
42
42
  return [{ Index: 0, Title: 'No history found. Ensure the sidebar is visible.' }];
43
43
  }
44
44
 
45
- return items;
45
+ const dateHeaders = /^(today|yesterday|last week|last month|last year|this week|this month|older|previous \d+ days|\d+ days ago)$/i;
46
+ const numericOnly = /^[\d\s]+$/;
47
+ const modelPath = /^[\w.-]+\/[\w.-]/;
48
+ const seen = new Set<string>();
49
+ const deduped = items.filter((item: { Index: number; Title: string }) => {
50
+ const t = item.Title.trim();
51
+ if (dateHeaders.test(t)) return false;
52
+ if (numericOnly.test(t)) return false;
53
+ if (modelPath.test(t)) return false;
54
+ if (seen.has(t)) return false;
55
+ seen.add(t);
56
+ return true;
57
+ }).map((item: { Index: number; Title: string }, i: number) => ({ Index: i + 1, Title: item.Title }));
58
+
59
+ return deduped;
46
60
  },
47
61
  });