@jackwener/opencli 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/browser/cdp.js +5 -0
  2. package/dist/browser/discover.js +11 -7
  3. package/dist/browser/index.d.ts +2 -0
  4. package/dist/browser/index.js +2 -0
  5. package/dist/browser/page.d.ts +4 -0
  6. package/dist/browser/page.js +52 -3
  7. package/dist/browser.test.js +5 -0
  8. package/dist/cli-manifest.json +460 -1
  9. package/dist/cli.js +34 -3
  10. package/dist/clis/apple-podcasts/commands.test.js +26 -3
  11. package/dist/clis/apple-podcasts/top.js +4 -1
  12. package/dist/clis/bluesky/feeds.yaml +29 -0
  13. package/dist/clis/bluesky/followers.yaml +33 -0
  14. package/dist/clis/bluesky/following.yaml +33 -0
  15. package/dist/clis/bluesky/profile.yaml +27 -0
  16. package/dist/clis/bluesky/search.yaml +34 -0
  17. package/dist/clis/bluesky/starter-packs.yaml +34 -0
  18. package/dist/clis/bluesky/thread.yaml +32 -0
  19. package/dist/clis/bluesky/trending.yaml +27 -0
  20. package/dist/clis/bluesky/user.yaml +34 -0
  21. package/dist/clis/twitter/trending.js +29 -61
  22. package/dist/clis/weread/shelf.js +132 -9
  23. package/dist/clis/weread/utils.js +5 -1
  24. package/dist/clis/xiaohongshu/publish.js +78 -42
  25. package/dist/clis/xiaohongshu/publish.test.js +20 -8
  26. package/dist/clis/xiaohongshu/search.d.ts +8 -1
  27. package/dist/clis/xiaohongshu/search.js +20 -1
  28. package/dist/clis/xiaohongshu/search.test.d.ts +1 -1
  29. package/dist/clis/xiaohongshu/search.test.js +32 -1
  30. package/dist/daemon.js +1 -0
  31. package/dist/discovery.js +40 -28
  32. package/dist/doctor.d.ts +1 -2
  33. package/dist/doctor.js +9 -5
  34. package/dist/engine.test.js +42 -0
  35. package/dist/errors.d.ts +1 -1
  36. package/dist/errors.js +2 -2
  37. package/dist/execution.js +45 -13
  38. package/dist/execution.test.d.ts +1 -0
  39. package/dist/execution.test.js +40 -0
  40. package/dist/extension-manifest-regression.test.d.ts +1 -0
  41. package/dist/extension-manifest-regression.test.js +12 -0
  42. package/dist/external.js +6 -1
  43. package/dist/main.js +1 -0
  44. package/dist/plugin-scaffold.d.ts +28 -0
  45. package/dist/plugin-scaffold.js +142 -0
  46. package/dist/plugin-scaffold.test.d.ts +4 -0
  47. package/dist/plugin-scaffold.test.js +83 -0
  48. package/dist/plugin.d.ts +55 -17
  49. package/dist/plugin.js +706 -154
  50. package/dist/plugin.test.js +836 -38
  51. package/dist/runtime.d.ts +1 -0
  52. package/dist/runtime.js +1 -1
  53. package/dist/types.d.ts +2 -0
  54. package/dist/weread-private-api-regression.test.js +185 -0
  55. package/docs/adapters/browser/bluesky.md +53 -0
  56. package/docs/guide/plugins.md +10 -0
  57. package/extension/dist/background.js +4 -2
  58. package/extension/manifest.json +4 -1
  59. package/extension/package-lock.json +2 -2
  60. package/extension/package.json +1 -1
  61. package/extension/src/background.ts +2 -1
  62. package/package.json +1 -1
  63. package/src/browser/cdp.ts +6 -0
  64. package/src/browser/discover.ts +10 -7
  65. package/src/browser/index.ts +2 -0
  66. package/src/browser/page.ts +49 -3
  67. package/src/browser.test.ts +6 -0
  68. package/src/cli.ts +34 -3
  69. package/src/clis/apple-podcasts/commands.test.ts +30 -2
  70. package/src/clis/apple-podcasts/top.ts +4 -1
  71. package/src/clis/bluesky/feeds.yaml +29 -0
  72. package/src/clis/bluesky/followers.yaml +33 -0
  73. package/src/clis/bluesky/following.yaml +33 -0
  74. package/src/clis/bluesky/profile.yaml +27 -0
  75. package/src/clis/bluesky/search.yaml +34 -0
  76. package/src/clis/bluesky/starter-packs.yaml +34 -0
  77. package/src/clis/bluesky/thread.yaml +32 -0
  78. package/src/clis/bluesky/trending.yaml +27 -0
  79. package/src/clis/bluesky/user.yaml +34 -0
  80. package/src/clis/twitter/trending.ts +29 -77
  81. package/src/clis/weread/shelf.ts +169 -9
  82. package/src/clis/weread/utils.ts +6 -1
  83. package/src/clis/xiaohongshu/publish.test.ts +22 -8
  84. package/src/clis/xiaohongshu/publish.ts +93 -52
  85. package/src/clis/xiaohongshu/search.test.ts +39 -1
  86. package/src/clis/xiaohongshu/search.ts +19 -1
  87. package/src/daemon.ts +1 -0
  88. package/src/discovery.ts +41 -33
  89. package/src/doctor.ts +11 -8
  90. package/src/engine.test.ts +38 -0
  91. package/src/errors.ts +6 -2
  92. package/src/execution.test.ts +47 -0
  93. package/src/execution.ts +39 -15
  94. package/src/extension-manifest-regression.test.ts +17 -0
  95. package/src/external.ts +6 -1
  96. package/src/main.ts +1 -0
  97. package/src/plugin-scaffold.test.ts +98 -0
  98. package/src/plugin-scaffold.ts +170 -0
  99. package/src/plugin.test.ts +881 -38
  100. package/src/plugin.ts +871 -158
  101. package/src/runtime.ts +2 -2
  102. package/src/types.ts +2 -0
  103. package/src/weread-private-api-regression.test.ts +207 -0
  104. package/tests/e2e/browser-public.test.ts +1 -1
  105. package/tests/e2e/output-formats.test.ts +10 -14
  106. package/tests/e2e/plugin-management.test.ts +4 -1
  107. package/tests/e2e/public-commands.test.ts +12 -1
  108. package/vitest.config.ts +1 -15
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,16 +58,20 @@ 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
  }
65
65
  if (connectivity && !connectivity.ok) {
66
66
  issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
67
67
  }
68
- if (status.extensionVersion && opts.cliVersion && status.extensionVersion !== opts.cliVersion) {
69
- issues.push(`Extension version mismatch: extension v${status.extensionVersion} ≠ CLI v${opts.cliVersion}\n` +
70
- ' Download the latest extension from: https://github.com/jackwener/opencli/releases');
68
+ if (status.extensionVersion && opts.cliVersion) {
69
+ const extMajor = status.extensionVersion.split('.')[0];
70
+ const cliMajor = opts.cliVersion.split('.')[0];
71
+ if (extMajor !== cliMajor) {
72
+ issues.push(`Extension major version mismatch: extension v${status.extensionVersion} ≠ CLI v${opts.cliVersion}\n` +
73
+ ' Download the latest extension from: https://github.com/jackwener/opencli/releases');
74
+ }
71
75
  }
72
76
  return {
73
77
  cliVersion: opts.cliVersion,
@@ -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
@@ -17,8 +17,6 @@ import { shouldUseBrowserSession } from './capabilityRouting.js';
17
17
  import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
18
18
  import { emitHook } from './hooks.js';
19
19
  import { checkDaemonStatus } from './browser/discover.js';
20
- import { PKG_VERSION } from './version.js';
21
- import chalk from 'chalk';
22
20
  const _loadedModules = new Set();
23
21
  export function coerceAndValidateArgs(cmdArgs, kwargs) {
24
22
  const result = { ...kwargs };
@@ -110,6 +108,25 @@ function ensureRequiredEnv(cmd) {
110
108
  return;
111
109
  throw new CommandExecutionError(`Command ${fullName(cmd)} requires environment variable ${missing.name}.`, missing.help ?? `Set ${missing.name} before running ${fullName(cmd)}.`);
112
110
  }
111
+ /**
112
+ * Check if the browser is already on the target domain, avoiding redundant navigation.
113
+ * Returns true if current page hostname matches the pre-nav URL hostname.
114
+ */
115
+ async function isAlreadyOnDomain(page, targetUrl) {
116
+ if (!page.getCurrentUrl)
117
+ return false;
118
+ try {
119
+ const currentUrl = await page.getCurrentUrl();
120
+ if (!currentUrl)
121
+ return false;
122
+ const currentHost = new URL(currentUrl).hostname;
123
+ const targetHost = new URL(targetUrl).hostname;
124
+ return currentHost === targetHost;
125
+ }
126
+ catch {
127
+ return false;
128
+ }
129
+ }
113
130
  export async function executeCommand(cmd, rawKwargs, debug = false) {
114
131
  let kwargs;
115
132
  try {
@@ -142,22 +159,26 @@ export async function executeCommand(cmd, rawKwargs, debug = false) {
142
159
  ' 2. chrome://extensions → Developer Mode → Load unpacked\n' +
143
160
  ' Then run: opencli doctor');
144
161
  }
145
- // ── Version mismatch: warn but don't block ──
146
- if (status.extensionVersion && status.extensionVersion !== PKG_VERSION) {
147
- process.stderr.write(chalk.yellow(`⚠ Extension v${status.extensionVersion} ≠ CLI v${PKG_VERSION} — consider updating the extension.\n`));
148
- }
149
162
  ensureRequiredEnv(cmd);
150
163
  const BrowserFactory = getBrowserFactory();
151
164
  result = await browserSession(BrowserFactory, async (page) => {
152
165
  const preNavUrl = resolvePreNav(cmd);
153
166
  if (preNavUrl) {
154
- try {
155
- await page.goto(preNavUrl);
156
- await page.wait(2);
157
- }
158
- catch (err) {
167
+ const skip = await isAlreadyOnDomain(page, preNavUrl);
168
+ if (skip) {
159
169
  if (debug)
160
- console.error(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
170
+ console.error(`[pre-nav] Already on target domain, skipping navigation`);
171
+ }
172
+ else {
173
+ try {
174
+ // goto() already includes smart DOM-settle detection (waitForDomStable).
175
+ // No additional fixed sleep needed.
176
+ await page.goto(preNavUrl);
177
+ }
178
+ catch (err) {
179
+ if (debug)
180
+ console.error(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
181
+ }
161
182
  }
162
183
  }
163
184
  return runWithTimeout(runCommand(cmd, page, kwargs, debug), {
@@ -167,7 +188,18 @@ export async function executeCommand(cmd, rawKwargs, debug = false) {
167
188
  }, { workspace: `site:${cmd.site}` });
168
189
  }
169
190
  else {
170
- result = await runCommand(cmd, null, kwargs, debug);
191
+ // Non-browser commands: apply timeout only when explicitly configured.
192
+ const timeout = cmd.timeoutSeconds;
193
+ if (timeout !== undefined && timeout > 0) {
194
+ result = await runWithTimeout(runCommand(cmd, null, kwargs, debug), {
195
+ timeout,
196
+ label: fullName(cmd),
197
+ hint: `Increase the adapter's timeoutSeconds setting (currently ${timeout}s)`,
198
+ });
199
+ }
200
+ else {
201
+ result = await runCommand(cmd, null, kwargs, debug);
202
+ }
171
203
  }
172
204
  }
173
205
  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
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+ describe('extension manifest regression', () => {
5
+ it('keeps host permissions required by chrome.cookies.getAll', async () => {
6
+ const manifestPath = path.resolve(process.cwd(), 'extension', 'manifest.json');
7
+ const raw = await fs.readFile(manifestPath, 'utf8');
8
+ const manifest = JSON.parse(raw);
9
+ expect(manifest.permissions).toContain('cookies');
10
+ expect(manifest.host_permissions).toContain('<all_urls>');
11
+ });
12
+ });
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;
@@ -0,0 +1,142 @@
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
+ import * as fs from 'node:fs';
15
+ import * as path from 'node:path';
16
+ import { PKG_VERSION } from './version.js';
17
+ /**
18
+ * Create a new plugin scaffold directory.
19
+ */
20
+ export function createPluginScaffold(name, opts = {}) {
21
+ // Validate name
22
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) {
23
+ throw new Error(`Invalid plugin name "${name}". ` +
24
+ `Plugin names must start with a lowercase letter and contain only lowercase letters, digits, and hyphens.`);
25
+ }
26
+ const targetDir = opts.dir
27
+ ? path.resolve(opts.dir)
28
+ : path.resolve(name);
29
+ if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length > 0) {
30
+ throw new Error(`Directory "${targetDir}" already exists and is not empty.`);
31
+ }
32
+ fs.mkdirSync(targetDir, { recursive: true });
33
+ const files = [];
34
+ // opencli-plugin.json
35
+ const manifest = {
36
+ name,
37
+ version: '0.1.0',
38
+ description: opts.description ?? `An opencli plugin: ${name}`,
39
+ opencli: `>=${PKG_VERSION}`,
40
+ };
41
+ writeFile(targetDir, 'opencli-plugin.json', JSON.stringify(manifest, null, 2) + '\n');
42
+ files.push('opencli-plugin.json');
43
+ // package.json
44
+ const pkg = {
45
+ name: `opencli-plugin-${name}`,
46
+ version: '0.1.0',
47
+ type: 'module',
48
+ description: opts.description ?? `An opencli plugin: ${name}`,
49
+ peerDependencies: {
50
+ '@jackwener/opencli': `>=${PKG_VERSION}`,
51
+ },
52
+ };
53
+ writeFile(targetDir, 'package.json', JSON.stringify(pkg, null, 2) + '\n');
54
+ files.push('package.json');
55
+ // hello.yaml — sample YAML command
56
+ const yamlContent = `# Sample YAML command for ${name}
57
+ # See: https://github.com/jackwener/opencli#yaml-commands
58
+
59
+ site: ${name}
60
+ name: hello
61
+ description: "A sample YAML command"
62
+ strategy: public
63
+ browser: false
64
+
65
+ domain: https://httpbin.org
66
+
67
+ pipeline:
68
+ - fetch:
69
+ url: "https://httpbin.org/get?greeting=hello"
70
+ method: GET
71
+ - extract:
72
+ type: json
73
+ selector: "$.args"
74
+ `;
75
+ writeFile(targetDir, 'hello.yaml', yamlContent);
76
+ files.push('hello.yaml');
77
+ // greet.ts — sample TS command using registry API
78
+ const tsContent = `/**
79
+ * Sample TypeScript command for ${name}.
80
+ * Demonstrates the programmatic cli() registration API.
81
+ */
82
+
83
+ import { cli, Strategy } from '@jackwener/opencli/registry';
84
+
85
+ cli({
86
+ site: '${name}',
87
+ name: 'greet',
88
+ description: 'Greet someone by name',
89
+ strategy: Strategy.PUBLIC,
90
+ browser: false,
91
+ args: [
92
+ { name: 'name', positional: true, required: true, help: 'Name to greet' },
93
+ ],
94
+ columns: ['greeting'],
95
+ func: async (_page, kwargs) => [{ greeting: \`Hello, \${String(kwargs.name ?? 'World')}!\` }],
96
+ });
97
+ `;
98
+ writeFile(targetDir, 'greet.ts', tsContent);
99
+ files.push('greet.ts');
100
+ // README.md
101
+ const readme = `# opencli-plugin-${name}
102
+
103
+ ${opts.description ?? `An opencli plugin: ${name}`}
104
+
105
+ ## Install
106
+
107
+ \`\`\`bash
108
+ # From local development directory
109
+ opencli plugin install file://${targetDir}
110
+
111
+ # From GitHub (after publishing)
112
+ opencli plugin install github:<user>/opencli-plugin-${name}
113
+ \`\`\`
114
+
115
+ ## Commands
116
+
117
+ | Command | Type | Description |
118
+ |---------|------|-------------|
119
+ | \`${name}/hello\` | YAML | Sample YAML command |
120
+ | \`${name}/greet\` | TypeScript | Sample TS command |
121
+
122
+ ## Development
123
+
124
+ \`\`\`bash
125
+ # Install locally for development (symlinked, changes reflect immediately)
126
+ opencli plugin install file://${targetDir}
127
+
128
+ # Verify commands are registered
129
+ opencli list | grep ${name}
130
+
131
+ # Run a command
132
+ opencli ${name} hello
133
+ opencli ${name} greet --name World
134
+ \`\`\`
135
+ `;
136
+ writeFile(targetDir, 'README.md', readme);
137
+ files.push('README.md');
138
+ return { name, dir: targetDir, files };
139
+ }
140
+ function writeFile(dir, name, content) {
141
+ fs.writeFileSync(path.join(dir, name), content);
142
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Tests for plugin scaffold: create new plugin directories.
3
+ */
4
+ export {};