@jackwener/opencli 1.5.0 → 1.5.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 (79) hide show
  1. package/dist/browser/cdp.js +5 -0
  2. package/dist/browser/page.d.ts +3 -0
  3. package/dist/browser/page.js +24 -1
  4. package/dist/cli-manifest.json +465 -5
  5. package/dist/cli.js +34 -3
  6. package/dist/clis/bluesky/feeds.yaml +29 -0
  7. package/dist/clis/bluesky/followers.yaml +33 -0
  8. package/dist/clis/bluesky/following.yaml +33 -0
  9. package/dist/clis/bluesky/profile.yaml +27 -0
  10. package/dist/clis/bluesky/search.yaml +34 -0
  11. package/dist/clis/bluesky/starter-packs.yaml +34 -0
  12. package/dist/clis/bluesky/thread.yaml +32 -0
  13. package/dist/clis/bluesky/trending.yaml +27 -0
  14. package/dist/clis/bluesky/user.yaml +34 -0
  15. package/dist/clis/twitter/trending.js +29 -61
  16. package/dist/clis/v2ex/hot.yaml +17 -3
  17. package/dist/clis/xiaohongshu/publish.js +78 -42
  18. package/dist/clis/xiaohongshu/publish.test.js +20 -8
  19. package/dist/clis/xiaohongshu/search.d.ts +8 -1
  20. package/dist/clis/xiaohongshu/search.js +20 -1
  21. package/dist/clis/xiaohongshu/search.test.d.ts +1 -1
  22. package/dist/clis/xiaohongshu/search.test.js +32 -1
  23. package/dist/discovery.js +40 -28
  24. package/dist/doctor.d.ts +1 -2
  25. package/dist/doctor.js +2 -2
  26. package/dist/engine.test.js +42 -0
  27. package/dist/errors.d.ts +1 -1
  28. package/dist/errors.js +2 -2
  29. package/dist/execution.js +45 -7
  30. package/dist/execution.test.d.ts +1 -0
  31. package/dist/execution.test.js +40 -0
  32. package/dist/external.js +6 -1
  33. package/dist/main.js +1 -0
  34. package/dist/plugin-scaffold.d.ts +28 -0
  35. package/dist/plugin-scaffold.js +142 -0
  36. package/dist/plugin-scaffold.test.d.ts +4 -0
  37. package/dist/plugin-scaffold.test.js +83 -0
  38. package/dist/plugin.d.ts +55 -17
  39. package/dist/plugin.js +706 -154
  40. package/dist/plugin.test.js +836 -38
  41. package/dist/runtime.d.ts +1 -0
  42. package/dist/runtime.js +1 -1
  43. package/dist/types.d.ts +2 -0
  44. package/docs/adapters/browser/bluesky.md +53 -0
  45. package/docs/guide/plugins.md +10 -0
  46. package/package.json +1 -1
  47. package/src/browser/cdp.ts +6 -0
  48. package/src/browser/page.ts +24 -1
  49. package/src/cli.ts +34 -3
  50. package/src/clis/bluesky/feeds.yaml +29 -0
  51. package/src/clis/bluesky/followers.yaml +33 -0
  52. package/src/clis/bluesky/following.yaml +33 -0
  53. package/src/clis/bluesky/profile.yaml +27 -0
  54. package/src/clis/bluesky/search.yaml +34 -0
  55. package/src/clis/bluesky/starter-packs.yaml +34 -0
  56. package/src/clis/bluesky/thread.yaml +32 -0
  57. package/src/clis/bluesky/trending.yaml +27 -0
  58. package/src/clis/bluesky/user.yaml +34 -0
  59. package/src/clis/twitter/trending.ts +29 -77
  60. package/src/clis/v2ex/hot.yaml +17 -3
  61. package/src/clis/xiaohongshu/publish.test.ts +22 -8
  62. package/src/clis/xiaohongshu/publish.ts +93 -52
  63. package/src/clis/xiaohongshu/search.test.ts +39 -1
  64. package/src/clis/xiaohongshu/search.ts +19 -1
  65. package/src/discovery.ts +41 -33
  66. package/src/doctor.ts +2 -3
  67. package/src/engine.test.ts +38 -0
  68. package/src/errors.ts +6 -2
  69. package/src/execution.test.ts +47 -0
  70. package/src/execution.ts +39 -6
  71. package/src/external.ts +6 -1
  72. package/src/main.ts +1 -0
  73. package/src/plugin-scaffold.test.ts +98 -0
  74. package/src/plugin-scaffold.ts +170 -0
  75. package/src/plugin.test.ts +881 -38
  76. package/src/plugin.ts +871 -158
  77. package/src/runtime.ts +2 -2
  78. package/src/types.ts +2 -0
  79. package/tests/e2e/browser-public.test.ts +1 -1
@@ -43,9 +43,10 @@ describe('xiaohongshu publish', () => {
43
43
  const page = createPageMock([
44
44
  'https://creator.xiaohongshu.com/publish/publish?from=menu_left',
45
45
  { ok: true, target: '上传图文', text: '上传图文' },
46
- { hasTitleInput: true, hasImageInput: true, hasVideoSurface: false },
46
+ { state: 'editor_ready', hasTitleInput: true, hasImageInput: true, hasVideoSurface: false },
47
47
  { ok: true, count: 1 },
48
48
  false,
49
+ true, // waitForEditForm: editor appeared
49
50
  { ok: true, sel: 'input[maxlength="20"]' },
50
51
  { ok: true, sel: '[contenteditable="true"][class*="content"]' },
51
52
  true,
@@ -72,17 +73,21 @@ describe('xiaohongshu publish', () => {
72
73
  it('fails early with a clear error when still on the video page', async () => {
73
74
  const cmd = getRegistry().get('xiaohongshu/publish');
74
75
  expect(cmd?.func).toBeTypeOf('function');
76
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-'));
77
+ const imagePath = path.join(tempDir, 'demo.jpg');
78
+ fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
75
79
  const page = createPageMock([
76
80
  'https://creator.xiaohongshu.com/publish/publish?from=menu_left',
77
81
  { ok: false, visibleTexts: ['上传视频', '上传图文'] },
78
- { hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
79
- { hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
80
- { hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
81
- { hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
82
+ { state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
83
+ { state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
84
+ { state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
85
+ { state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
82
86
  ]);
83
87
  await expect(cmd.func(page, {
84
88
  title: 'DeepSeek别乱问',
85
89
  content: '一篇真实一点的小红书正文',
90
+ images: imagePath,
86
91
  topics: '',
87
92
  draft: false,
88
93
  })).rejects.toThrow('Still on the video publish page after trying to select 图文');
@@ -91,11 +96,17 @@ describe('xiaohongshu publish', () => {
91
96
  it('waits for the image-text surface to appear after clicking the tab', async () => {
92
97
  const cmd = getRegistry().get('xiaohongshu/publish');
93
98
  expect(cmd?.func).toBeTypeOf('function');
99
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-'));
100
+ const imagePath = path.join(tempDir, 'demo.jpg');
101
+ fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
94
102
  const page = createPageMock([
95
103
  'https://creator.xiaohongshu.com/publish/publish?from=menu_left',
96
104
  { ok: true, target: '上传图文', text: '上传图文' },
97
- { hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
98
- { hasTitleInput: true, hasImageInput: true, hasVideoSurface: false },
105
+ { state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
106
+ { state: 'editor_ready', hasTitleInput: true, hasImageInput: true, hasVideoSurface: false },
107
+ { ok: true, count: 1 }, // injectImages
108
+ false, // waitForUploads: no progress indicator
109
+ true, // waitForEditForm: editor appeared
99
110
  { ok: true, sel: 'input[maxlength="20"]' },
100
111
  { ok: true, sel: '[contenteditable="true"][class*="content"]' },
101
112
  true,
@@ -105,6 +116,7 @@ describe('xiaohongshu publish', () => {
105
116
  const result = await cmd.func(page, {
106
117
  title: '延迟切换也能过',
107
118
  content: '图文页切换慢一点也继续等',
119
+ images: imagePath,
108
120
  topics: '',
109
121
  draft: false,
110
122
  });
@@ -112,7 +124,7 @@ describe('xiaohongshu publish', () => {
112
124
  expect(result).toEqual([
113
125
  {
114
126
  status: '✅ 发布成功',
115
- detail: '"延迟切换也能过" · 无图 · 发布成功',
127
+ detail: '"延迟切换也能过" · 1张图片 · 发布成功',
116
128
  },
117
129
  ]);
118
130
  });
@@ -5,4 +5,11 @@
5
5
  * the search results page and extracts data from rendered DOM elements.
6
6
  * Ref: https://github.com/jackwener/opencli/issues/10
7
7
  */
8
- export {};
8
+ /**
9
+ * Extract approximate publish date from a Xiaohongshu note URL.
10
+ * XHS note IDs follow MongoDB ObjectID format where the first 8 hex
11
+ * characters encode a Unix timestamp (the moment the ID was generated,
12
+ * which closely matches publish time but is not an official API field).
13
+ * e.g. "697f6c74..." → 0x697f6c74 = 1769958516 → 2026-02-01
14
+ */
15
+ export declare function noteIdToDate(url: string): string;
@@ -7,6 +7,24 @@
7
7
  */
8
8
  import { cli, Strategy } from '../../registry.js';
9
9
  import { AuthRequiredError } from '../../errors.js';
10
+ /**
11
+ * Extract approximate publish date from a Xiaohongshu note URL.
12
+ * XHS note IDs follow MongoDB ObjectID format where the first 8 hex
13
+ * characters encode a Unix timestamp (the moment the ID was generated,
14
+ * which closely matches publish time but is not an official API field).
15
+ * e.g. "697f6c74..." → 0x697f6c74 = 1769958516 → 2026-02-01
16
+ */
17
+ export function noteIdToDate(url) {
18
+ const match = url.match(/\/(?:search_result|explore|note)\/([0-9a-f]{24})(?=[?#/]|$)/i);
19
+ if (!match)
20
+ return '';
21
+ const hex = match[1].substring(0, 8);
22
+ const ts = parseInt(hex, 16);
23
+ if (!ts || ts < 1_000_000_000 || ts > 4_000_000_000)
24
+ return '';
25
+ // Offset by UTC+8 (China Standard Time) so the date matches what XHS users see
26
+ return new Date((ts + 8 * 3600) * 1000).toISOString().slice(0, 10);
27
+ }
10
28
  cli({
11
29
  site: 'xiaohongshu',
12
30
  name: 'search',
@@ -17,7 +35,7 @@ cli({
17
35
  { name: 'query', required: true, positional: true, help: 'Search keyword' },
18
36
  { name: 'limit', type: 'int', default: 20, help: 'Number of results' },
19
37
  ],
20
- columns: ['rank', 'title', 'author', 'likes', 'url'],
38
+ columns: ['rank', 'title', 'author', 'likes', 'published_at', 'url'],
21
39
  func: async (page, kwargs) => {
22
40
  const keyword = encodeURIComponent(kwargs.query);
23
41
  await page.goto(`https://www.xiaohongshu.com/search_result?keyword=${keyword}&source=web_search_result_notes`);
@@ -89,6 +107,7 @@ cli({
89
107
  .map((item, i) => ({
90
108
  rank: i + 1,
91
109
  ...item,
110
+ published_at: noteIdToDate(item.url),
92
111
  }));
93
112
  },
94
113
  });
@@ -1 +1 @@
1
- import './search.js';
1
+ export {};
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '../../registry.js';
3
- import './search.js';
3
+ import { noteIdToDate } from './search.js';
4
4
  function createPageMock(evaluateResults) {
5
5
  const evaluate = vi.fn();
6
6
  for (const result of evaluateResults) {
@@ -70,6 +70,7 @@ describe('xiaohongshu search', () => {
70
70
  title: '某鱼买FSD被坑了4万',
71
71
  author: '随风',
72
72
  likes: '261',
73
+ published_at: '2025-10-10',
73
74
  url: detailUrl,
74
75
  author_url: authorUrl,
75
76
  },
@@ -112,3 +113,33 @@ describe('xiaohongshu search', () => {
112
113
  expect(result[0]).toMatchObject({ rank: 1, title: 'Result A' });
113
114
  });
114
115
  });
116
+ describe('noteIdToDate (ObjectID timestamp parsing)', () => {
117
+ it('parses a known note ID to the correct China-timezone date', () => {
118
+ // 0x697f6c74 = 1769958516 → 2026-02-01 in UTC+8
119
+ expect(noteIdToDate('https://www.xiaohongshu.com/search_result/697f6c74000000002103de17')).toBe('2026-02-01');
120
+ // 0x68e90be8 → 2025-10-10 in UTC+8
121
+ expect(noteIdToDate('https://www.xiaohongshu.com/explore/68e90be80000000004022e66')).toBe('2025-10-10');
122
+ });
123
+ it('returns China date when UTC+8 crosses into the next day', () => {
124
+ // 0x69b739f0 = 2026-03-15 23:00 UTC = 2026-03-16 07:00 CST
125
+ // Without UTC+8 offset this would incorrectly return 2026-03-15
126
+ expect(noteIdToDate('https://www.xiaohongshu.com/search_result/69b739f00000000000000000')).toBe('2026-03-16');
127
+ });
128
+ it('handles /note/ path variant', () => {
129
+ expect(noteIdToDate('https://www.xiaohongshu.com/note/697f6c74000000002103de17')).toBe('2026-02-01');
130
+ });
131
+ it('handles URL with query parameters', () => {
132
+ expect(noteIdToDate('https://www.xiaohongshu.com/search_result/697f6c74000000002103de17?xsec_token=abc')).toBe('2026-02-01');
133
+ });
134
+ it('returns empty string for non-matching URLs', () => {
135
+ expect(noteIdToDate('https://www.xiaohongshu.com/user/profile/635a9c720000000018028b40')).toBe('');
136
+ expect(noteIdToDate('https://www.xiaohongshu.com/')).toBe('');
137
+ });
138
+ it('returns empty string for IDs shorter than 24 hex chars', () => {
139
+ expect(noteIdToDate('https://www.xiaohongshu.com/search_result/abcdef')).toBe('');
140
+ });
141
+ it('returns empty string when timestamp is out of range', () => {
142
+ // All zeros → ts = 0
143
+ expect(noteIdToDate('https://www.xiaohongshu.com/search_result/000000000000000000000000')).toBe('');
144
+ });
145
+ });
package/dist/discovery.js CHANGED
@@ -127,22 +127,20 @@ async function discoverClisFromFs(dir) {
127
127
  const site = entry.name;
128
128
  const siteDir = path.join(dir, site);
129
129
  const files = await fs.promises.readdir(siteDir);
130
- const filePromises = [];
131
- for (const file of files) {
130
+ await Promise.all(files.map(async (file) => {
132
131
  const filePath = path.join(siteDir, file);
133
132
  if (file.endsWith('.yaml') || file.endsWith('.yml')) {
134
- filePromises.push(registerYamlCli(filePath, site));
133
+ await registerYamlCli(filePath, site);
135
134
  }
136
135
  else if ((file.endsWith('.js') && !file.endsWith('.d.js')) ||
137
136
  (file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts'))) {
138
137
  if (!(await isCliModule(filePath)))
139
- continue;
140
- filePromises.push(import(pathToFileURL(filePath).href).catch((err) => {
138
+ return;
139
+ await import(pathToFileURL(filePath).href).catch((err) => {
141
140
  log.warn(`Failed to load module ${filePath}: ${getErrorMessage(err)}`);
142
- }));
141
+ });
143
142
  }
144
- }
145
- await Promise.all(filePromises);
143
+ }));
146
144
  });
147
145
  await Promise.all(sitePromises);
148
146
  }
@@ -194,11 +192,12 @@ export async function discoverPlugins() {
194
192
  return;
195
193
  }
196
194
  const entries = await fs.promises.readdir(PLUGINS_DIR, { withFileTypes: true });
197
- for (const entry of entries) {
198
- if (!entry.isDirectory())
199
- continue;
200
- await discoverPluginDir(path.join(PLUGINS_DIR, entry.name), entry.name);
201
- }
195
+ await Promise.all(entries.map(async (entry) => {
196
+ const pluginDir = path.join(PLUGINS_DIR, entry.name);
197
+ if (!(await isDiscoverablePluginDir(entry, pluginDir)))
198
+ return;
199
+ await discoverPluginDir(pluginDir, entry.name);
200
+ }));
202
201
  }
203
202
  /**
204
203
  * Flat scan: read yaml/ts files directly in a plugin directory.
@@ -207,32 +206,29 @@ export async function discoverPlugins() {
207
206
  async function discoverPluginDir(dir, site) {
208
207
  const files = await fs.promises.readdir(dir);
209
208
  const fileSet = new Set(files);
210
- const promises = [];
211
- for (const file of files) {
209
+ await Promise.all(files.map(async (file) => {
212
210
  const filePath = path.join(dir, file);
213
211
  if (file.endsWith('.yaml') || file.endsWith('.yml')) {
214
- promises.push(registerYamlCli(filePath, site));
212
+ await registerYamlCli(filePath, site);
215
213
  }
216
214
  else if (file.endsWith('.js') && !file.endsWith('.d.js')) {
217
215
  if (!(await isCliModule(filePath)))
218
- continue;
219
- promises.push(import(pathToFileURL(filePath).href).catch((err) => {
216
+ return;
217
+ await import(pathToFileURL(filePath).href).catch((err) => {
220
218
  log.warn(`Plugin ${site}/${file}: ${getErrorMessage(err)}`);
221
- }));
219
+ });
222
220
  }
223
221
  else if (file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts')) {
224
- // Skip .ts if a compiled .js sibling exists (production mode can't load .ts)
225
222
  const jsFile = file.replace(/\.ts$/, '.js');
223
+ // Prefer compiled .js — skip the .ts source file
226
224
  if (fileSet.has(jsFile))
227
- continue;
228
- if (!(await isCliModule(filePath)))
229
- continue;
230
- promises.push(import(pathToFileURL(filePath).href).catch((err) => {
231
- log.warn(`Plugin ${site}/${file}: ${getErrorMessage(err)}`);
232
- }));
225
+ return;
226
+ // No compiled .js found — cannot import raw .ts in production Node.js.
227
+ // This typically means esbuild transpilation failed during plugin install.
228
+ log.warn(`Plugin ${site}/${file}: no compiled .js found. ` +
229
+ `Run "opencli plugin update ${site}" to re-transpile, or install esbuild.`);
233
230
  }
234
- }
235
- await Promise.all(promises);
231
+ }));
236
232
  }
237
233
  async function isCliModule(filePath) {
238
234
  try {
@@ -244,3 +240,19 @@ async function isCliModule(filePath) {
244
240
  return false;
245
241
  }
246
242
  }
243
+ async function isDiscoverablePluginDir(entry, pluginDir) {
244
+ if (entry.isDirectory())
245
+ return true;
246
+ if (!entry.isSymbolicLink())
247
+ return false;
248
+ try {
249
+ return (await fs.promises.stat(pluginDir)).isDirectory();
250
+ }
251
+ catch (err) {
252
+ const code = err.code;
253
+ if (code !== 'ENOENT' && code !== 'ENOTDIR') {
254
+ log.warn(`Failed to inspect plugin link ${pluginDir}: ${getErrorMessage(err)}`);
255
+ }
256
+ return false;
257
+ }
258
+ }
package/dist/doctor.d.ts CHANGED
@@ -1,11 +1,10 @@
1
1
  /**
2
- * opencli doctor — diagnose and fix browser connectivity.
2
+ * opencli doctor — diagnose browser connectivity.
3
3
  *
4
4
  * Simplified for the daemon-based architecture. No more token management,
5
5
  * MCP path discovery, or config file scanning.
6
6
  */
7
7
  export type DoctorOptions = {
8
- fix?: boolean;
9
8
  yes?: boolean;
10
9
  live?: boolean;
11
10
  sessions?: boolean;
package/dist/doctor.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * opencli doctor — diagnose and fix browser connectivity.
2
+ * opencli doctor — diagnose browser connectivity.
3
3
  *
4
4
  * Simplified for the daemon-based architecture. No more token management,
5
5
  * MCP path discovery, or config file scanning.
@@ -58,7 +58,7 @@ export async function runBrowserDoctor(opts = {}) {
58
58
  if (status.running && !status.extensionConnected) {
59
59
  issues.push('Daemon is running but the Chrome extension is not connected.\n' +
60
60
  'Please install the opencli Browser Bridge extension:\n' +
61
- ' 1. Download from GitHub Releases\n' +
61
+ ' 1. Download from https://github.com/jackwener/opencli/releases\n' +
62
62
  ' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
63
63
  ' 3. Click "Load unpacked" → select the extension folder');
64
64
  }
@@ -75,11 +75,26 @@ cli({
75
75
  describe('discoverPlugins', () => {
76
76
  const testPluginDir = path.join(PLUGINS_DIR, '__test-plugin__');
77
77
  const yamlPath = path.join(testPluginDir, 'greeting.yaml');
78
+ const symlinkTargetDir = path.join(os.tmpdir(), '__test-plugin-symlink-target__');
79
+ const symlinkPluginDir = path.join(PLUGINS_DIR, '__test-plugin-symlink__');
80
+ const brokenSymlinkDir = path.join(PLUGINS_DIR, '__test-plugin-broken__');
78
81
  afterEach(async () => {
79
82
  try {
80
83
  await fs.promises.rm(testPluginDir, { recursive: true });
81
84
  }
82
85
  catch { }
86
+ try {
87
+ await fs.promises.rm(symlinkPluginDir, { recursive: true, force: true });
88
+ }
89
+ catch { }
90
+ try {
91
+ await fs.promises.rm(symlinkTargetDir, { recursive: true, force: true });
92
+ }
93
+ catch { }
94
+ try {
95
+ await fs.promises.rm(brokenSymlinkDir, { recursive: true, force: true });
96
+ }
97
+ catch { }
83
98
  });
84
99
  it('discovers YAML plugins from ~/.opencli/plugins/', async () => {
85
100
  // Create a simple YAML adapter in the plugins directory
@@ -108,6 +123,33 @@ columns: [message]
108
123
  // discoverPlugins should not throw if ~/.opencli/plugins/ does not exist
109
124
  await expect(discoverPlugins()).resolves.not.toThrow();
110
125
  });
126
+ it('discovers YAML plugins from symlinked plugin directories', async () => {
127
+ await fs.promises.mkdir(PLUGINS_DIR, { recursive: true });
128
+ await fs.promises.mkdir(symlinkTargetDir, { recursive: true });
129
+ await fs.promises.writeFile(path.join(symlinkTargetDir, 'hello.yaml'), `
130
+ site: __test-plugin-symlink__
131
+ name: hello
132
+ description: Test plugin greeting via symlink
133
+ strategy: public
134
+ browser: false
135
+
136
+ pipeline:
137
+ - evaluate: "() => [{ message: 'hello from symlink plugin' }]"
138
+
139
+ columns: [message]
140
+ `);
141
+ await fs.promises.symlink(symlinkTargetDir, symlinkPluginDir, 'dir');
142
+ await discoverPlugins();
143
+ const cmd = getRegistry().get('__test-plugin-symlink__/hello');
144
+ expect(cmd).toBeDefined();
145
+ expect(cmd.description).toBe('Test plugin greeting via symlink');
146
+ });
147
+ it('skips broken plugin symlinks without throwing', async () => {
148
+ await fs.promises.mkdir(PLUGINS_DIR, { recursive: true });
149
+ await fs.promises.symlink(path.join(os.tmpdir(), '__missing-plugin-target__'), brokenSymlinkDir, 'dir');
150
+ await expect(discoverPlugins()).resolves.not.toThrow();
151
+ expect(getRegistry().get('__test-plugin-broken__/hello')).toBeUndefined();
152
+ });
111
153
  });
112
154
  describe('executeCommand', () => {
113
155
  beforeEach(() => {
package/dist/errors.d.ts CHANGED
@@ -31,7 +31,7 @@ export declare class AuthRequiredError extends CliError {
31
31
  constructor(domain: string, message?: string);
32
32
  }
33
33
  export declare class TimeoutError extends CliError {
34
- constructor(label: string, seconds: number);
34
+ constructor(label: string, seconds: number, hint?: string);
35
35
  }
36
36
  export declare class ArgumentError extends CliError {
37
37
  constructor(message: string, hint?: string);
package/dist/errors.js CHANGED
@@ -41,8 +41,8 @@ export class AuthRequiredError extends CliError {
41
41
  }
42
42
  }
43
43
  export class TimeoutError extends CliError {
44
- constructor(label, seconds) {
45
- super('TIMEOUT', `${label} timed out after ${seconds}s`, 'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var');
44
+ constructor(label, seconds, hint) {
45
+ super('TIMEOUT', `${label} timed out after ${seconds}s`, hint ?? 'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var');
46
46
  }
47
47
  }
48
48
  export class ArgumentError extends CliError {
package/dist/execution.js CHANGED
@@ -110,6 +110,25 @@ function ensureRequiredEnv(cmd) {
110
110
  return;
111
111
  throw new CommandExecutionError(`Command ${fullName(cmd)} requires environment variable ${missing.name}.`, missing.help ?? `Set ${missing.name} before running ${fullName(cmd)}.`);
112
112
  }
113
+ /**
114
+ * Check if the browser is already on the target domain, avoiding redundant navigation.
115
+ * Returns true if current page hostname matches the pre-nav URL hostname.
116
+ */
117
+ async function isAlreadyOnDomain(page, targetUrl) {
118
+ if (!page.getCurrentUrl)
119
+ return false;
120
+ try {
121
+ const currentUrl = await page.getCurrentUrl();
122
+ if (!currentUrl)
123
+ return false;
124
+ const currentHost = new URL(currentUrl).hostname;
125
+ const targetHost = new URL(targetUrl).hostname;
126
+ return currentHost === targetHost;
127
+ }
128
+ catch {
129
+ return false;
130
+ }
131
+ }
113
132
  export async function executeCommand(cmd, rawKwargs, debug = false) {
114
133
  let kwargs;
115
134
  try {
@@ -151,13 +170,21 @@ export async function executeCommand(cmd, rawKwargs, debug = false) {
151
170
  result = await browserSession(BrowserFactory, async (page) => {
152
171
  const preNavUrl = resolvePreNav(cmd);
153
172
  if (preNavUrl) {
154
- try {
155
- await page.goto(preNavUrl);
156
- await page.wait(2);
157
- }
158
- catch (err) {
173
+ const skip = await isAlreadyOnDomain(page, preNavUrl);
174
+ if (skip) {
159
175
  if (debug)
160
- console.error(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
176
+ console.error(`[pre-nav] Already on target domain, skipping navigation`);
177
+ }
178
+ else {
179
+ try {
180
+ // goto() already includes smart DOM-settle detection (waitForDomStable).
181
+ // No additional fixed sleep needed.
182
+ await page.goto(preNavUrl);
183
+ }
184
+ catch (err) {
185
+ if (debug)
186
+ console.error(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
187
+ }
161
188
  }
162
189
  }
163
190
  return runWithTimeout(runCommand(cmd, page, kwargs, debug), {
@@ -167,7 +194,18 @@ export async function executeCommand(cmd, rawKwargs, debug = false) {
167
194
  }, { workspace: `site:${cmd.site}` });
168
195
  }
169
196
  else {
170
- result = await runCommand(cmd, null, kwargs, debug);
197
+ // Non-browser commands: apply timeout only when explicitly configured.
198
+ const timeout = cmd.timeoutSeconds;
199
+ if (timeout !== undefined && timeout > 0) {
200
+ result = await runWithTimeout(runCommand(cmd, null, kwargs, debug), {
201
+ timeout,
202
+ label: fullName(cmd),
203
+ hint: `Increase the adapter's timeoutSeconds setting (currently ${timeout}s)`,
204
+ });
205
+ }
206
+ else {
207
+ result = await runCommand(cmd, null, kwargs, debug);
208
+ }
171
209
  }
172
210
  }
173
211
  catch (err) {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { executeCommand } from './execution.js';
3
+ import { TimeoutError } from './errors.js';
4
+ import { cli, Strategy } from './registry.js';
5
+ import { withTimeoutMs } from './runtime.js';
6
+ describe('executeCommand — non-browser timeout', () => {
7
+ it('applies timeoutSeconds to non-browser commands', async () => {
8
+ const cmd = cli({
9
+ site: 'test-execution',
10
+ name: 'non-browser-timeout',
11
+ description: 'test non-browser timeout',
12
+ browser: false,
13
+ strategy: Strategy.PUBLIC,
14
+ timeoutSeconds: 0.01,
15
+ func: () => new Promise(() => { }),
16
+ });
17
+ // Sentinel timeout at 200ms — if the inner 10ms timeout fires first,
18
+ // the error will be a TimeoutError with the command label, not 'sentinel'.
19
+ const error = await withTimeoutMs(executeCommand(cmd, {}), 200, 'sentinel timeout')
20
+ .catch((err) => err);
21
+ expect(error).toBeInstanceOf(TimeoutError);
22
+ expect(error).toMatchObject({
23
+ code: 'TIMEOUT',
24
+ message: 'test-execution/non-browser-timeout timed out after 0.01s',
25
+ });
26
+ });
27
+ it('skips timeout when timeoutSeconds is 0', async () => {
28
+ const cmd = cli({
29
+ site: 'test-execution',
30
+ name: 'non-browser-zero-timeout',
31
+ description: 'test zero timeout bypasses wrapping',
32
+ browser: false,
33
+ strategy: Strategy.PUBLIC,
34
+ timeoutSeconds: 0,
35
+ func: () => new Promise(() => { }),
36
+ });
37
+ // With timeout guard skipped, the sentinel fires instead.
38
+ await expect(withTimeoutMs(executeCommand(cmd, {}), 50, 'sentinel timeout')).rejects.toThrow('sentinel timeout');
39
+ });
40
+ });
package/dist/external.js CHANGED
@@ -12,7 +12,10 @@ function getUserRegistryPath() {
12
12
  const home = os.homedir();
13
13
  return path.join(home, '.opencli', 'external-clis.yaml');
14
14
  }
15
+ let _cachedExternalClis = null;
15
16
  export function loadExternalClis() {
17
+ if (_cachedExternalClis)
18
+ return _cachedExternalClis;
16
19
  const configs = new Map();
17
20
  // 1. Load built-in
18
21
  const builtinPath = path.resolve(__dirname, 'external-clis.yaml');
@@ -41,7 +44,8 @@ export function loadExternalClis() {
41
44
  catch (err) {
42
45
  log.warn(`Failed to parse user external-clis.yaml: ${getErrorMessage(err)}`);
43
46
  }
44
- return Array.from(configs.values()).sort((a, b) => a.name.localeCompare(b.name));
47
+ _cachedExternalClis = Array.from(configs.values()).sort((a, b) => a.name.localeCompare(b.name));
48
+ return _cachedExternalClis;
45
49
  }
46
50
  export function isBinaryInstalled(binary) {
47
51
  try {
@@ -200,5 +204,6 @@ export function registerExternalCli(name, opts) {
200
204
  }
201
205
  const dump = yaml.dump(items, { indent: 2, sortKeys: true });
202
206
  fs.writeFileSync(userPath, dump, 'utf8');
207
+ _cachedExternalClis = null; // Invalidate cache so next load reflects the change
203
208
  console.log(chalk.dim(userPath));
204
209
  }
package/dist/main.js CHANGED
@@ -24,6 +24,7 @@ const __filename = fileURLToPath(import.meta.url);
24
24
  const __dirname = path.dirname(__filename);
25
25
  const BUILTIN_CLIS = path.resolve(__dirname, 'clis');
26
26
  const USER_CLIS = path.join(os.homedir(), '.opencli', 'clis');
27
+ // Sequential: plugins must run after built-in discovery so they can override built-in commands.
27
28
  await discoverClis(BUILTIN_CLIS, USER_CLIS);
28
29
  await discoverPlugins();
29
30
  // Register exit hook: notice appears after command output (same as npm/gh/yarn)
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Plugin scaffold: generates a ready-to-develop plugin directory.
3
+ *
4
+ * Usage: opencli plugin create <name> [--dir <path>]
5
+ *
6
+ * Creates:
7
+ * <name>/
8
+ * opencli-plugin.json — manifest with name, version, description
9
+ * package.json — ESM package with opencli peer dependency
10
+ * hello.yaml — sample YAML command
11
+ * greet.ts — sample TS command using the current registry API
12
+ * README.md — basic documentation
13
+ */
14
+ export interface ScaffoldOptions {
15
+ /** Directory to create the plugin in. Defaults to `./<name>` */
16
+ dir?: string;
17
+ /** Plugin description */
18
+ description?: string;
19
+ }
20
+ export interface ScaffoldResult {
21
+ name: string;
22
+ dir: string;
23
+ files: string[];
24
+ }
25
+ /**
26
+ * Create a new plugin scaffold directory.
27
+ */
28
+ export declare function createPluginScaffold(name: string, opts?: ScaffoldOptions): ScaffoldResult;