@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
@@ -83,9 +83,15 @@ cli({
83
83
  describe('discoverPlugins', () => {
84
84
  const testPluginDir = path.join(PLUGINS_DIR, '__test-plugin__');
85
85
  const yamlPath = path.join(testPluginDir, 'greeting.yaml');
86
+ const symlinkTargetDir = path.join(os.tmpdir(), '__test-plugin-symlink-target__');
87
+ const symlinkPluginDir = path.join(PLUGINS_DIR, '__test-plugin-symlink__');
88
+ const brokenSymlinkDir = path.join(PLUGINS_DIR, '__test-plugin-broken__');
86
89
 
87
90
  afterEach(async () => {
88
91
  try { await fs.promises.rm(testPluginDir, { recursive: true }); } catch {}
92
+ try { await fs.promises.rm(symlinkPluginDir, { recursive: true, force: true }); } catch {}
93
+ try { await fs.promises.rm(symlinkTargetDir, { recursive: true, force: true }); } catch {}
94
+ try { await fs.promises.rm(brokenSymlinkDir, { recursive: true, force: true }); } catch {}
89
95
  });
90
96
 
91
97
  it('discovers YAML plugins from ~/.opencli/plugins/', async () => {
@@ -118,6 +124,38 @@ columns: [message]
118
124
  // discoverPlugins should not throw if ~/.opencli/plugins/ does not exist
119
125
  await expect(discoverPlugins()).resolves.not.toThrow();
120
126
  });
127
+
128
+ it('discovers YAML plugins from symlinked plugin directories', async () => {
129
+ await fs.promises.mkdir(PLUGINS_DIR, { recursive: true });
130
+ await fs.promises.mkdir(symlinkTargetDir, { recursive: true });
131
+ await fs.promises.writeFile(path.join(symlinkTargetDir, 'hello.yaml'), `
132
+ site: __test-plugin-symlink__
133
+ name: hello
134
+ description: Test plugin greeting via symlink
135
+ strategy: public
136
+ browser: false
137
+
138
+ pipeline:
139
+ - evaluate: "() => [{ message: 'hello from symlink plugin' }]"
140
+
141
+ columns: [message]
142
+ `);
143
+ await fs.promises.symlink(symlinkTargetDir, symlinkPluginDir, 'dir');
144
+
145
+ await discoverPlugins();
146
+
147
+ const cmd = getRegistry().get('__test-plugin-symlink__/hello');
148
+ expect(cmd).toBeDefined();
149
+ expect(cmd!.description).toBe('Test plugin greeting via symlink');
150
+ });
151
+
152
+ it('skips broken plugin symlinks without throwing', async () => {
153
+ await fs.promises.mkdir(PLUGINS_DIR, { recursive: true });
154
+ await fs.promises.symlink(path.join(os.tmpdir(), '__missing-plugin-target__'), brokenSymlinkDir, 'dir');
155
+
156
+ await expect(discoverPlugins()).resolves.not.toThrow();
157
+ expect(getRegistry().get('__test-plugin-broken__/hello')).toBeUndefined();
158
+ });
121
159
  });
122
160
 
123
161
  describe('executeCommand', () => {
package/src/errors.ts CHANGED
@@ -51,8 +51,12 @@ export class AuthRequiredError extends CliError {
51
51
  }
52
52
 
53
53
  export class TimeoutError extends CliError {
54
- constructor(label: string, seconds: number) {
55
- super('TIMEOUT', `${label} timed out after ${seconds}s`, 'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var');
54
+ constructor(label: string, seconds: number, hint?: string) {
55
+ super(
56
+ 'TIMEOUT',
57
+ `${label} timed out after ${seconds}s`,
58
+ hint ?? 'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var',
59
+ );
56
60
  }
57
61
  }
58
62
 
@@ -0,0 +1,47 @@
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
+
7
+ describe('executeCommand — non-browser timeout', () => {
8
+ it('applies timeoutSeconds to non-browser commands', async () => {
9
+ const cmd = cli({
10
+ site: 'test-execution',
11
+ name: 'non-browser-timeout',
12
+ description: 'test non-browser timeout',
13
+ browser: false,
14
+ strategy: Strategy.PUBLIC,
15
+ timeoutSeconds: 0.01,
16
+ func: () => new Promise(() => {}),
17
+ });
18
+
19
+ // Sentinel timeout at 200ms — if the inner 10ms timeout fires first,
20
+ // the error will be a TimeoutError with the command label, not 'sentinel'.
21
+ const error = await withTimeoutMs(executeCommand(cmd, {}), 200, 'sentinel timeout')
22
+ .catch((err) => err);
23
+
24
+ expect(error).toBeInstanceOf(TimeoutError);
25
+ expect(error).toMatchObject({
26
+ code: 'TIMEOUT',
27
+ message: 'test-execution/non-browser-timeout timed out after 0.01s',
28
+ });
29
+ });
30
+
31
+ it('skips timeout when timeoutSeconds is 0', async () => {
32
+ const cmd = cli({
33
+ site: 'test-execution',
34
+ name: 'non-browser-zero-timeout',
35
+ description: 'test zero timeout bypasses wrapping',
36
+ browser: false,
37
+ strategy: Strategy.PUBLIC,
38
+ timeoutSeconds: 0,
39
+ func: () => new Promise(() => {}),
40
+ });
41
+
42
+ // With timeout guard skipped, the sentinel fires instead.
43
+ await expect(
44
+ withTimeoutMs(executeCommand(cmd, {}), 50, 'sentinel timeout'),
45
+ ).rejects.toThrow('sentinel timeout');
46
+ });
47
+ });
package/src/execution.ts CHANGED
@@ -19,8 +19,6 @@ import { shouldUseBrowserSession } from './capabilityRouting.js';
19
19
  import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
20
20
  import { emitHook, type HookContext } from './hooks.js';
21
21
  import { checkDaemonStatus } from './browser/discover.js';
22
- import { PKG_VERSION } from './version.js';
23
- import chalk from 'chalk';
24
22
 
25
23
  const _loadedModules = new Set<string>();
26
24
 
@@ -130,6 +128,23 @@ function ensureRequiredEnv(cmd: CliCommand): void {
130
128
  );
131
129
  }
132
130
 
131
+ /**
132
+ * Check if the browser is already on the target domain, avoiding redundant navigation.
133
+ * Returns true if current page hostname matches the pre-nav URL hostname.
134
+ */
135
+ async function isAlreadyOnDomain(page: IPage, targetUrl: string): Promise<boolean> {
136
+ if (!page.getCurrentUrl) return false;
137
+ try {
138
+ const currentUrl = await page.getCurrentUrl();
139
+ if (!currentUrl) return false;
140
+ const currentHost = new URL(currentUrl).hostname;
141
+ const targetHost = new URL(targetUrl).hostname;
142
+ return currentHost === targetHost;
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+
133
148
  export async function executeCommand(
134
149
  cmd: CliCommand,
135
150
  rawKwargs: CommandArgs,
@@ -169,23 +184,22 @@ export async function executeCommand(
169
184
  ' Then run: opencli doctor',
170
185
  );
171
186
  }
172
- // ── Version mismatch: warn but don't block ──
173
- if (status.extensionVersion && status.extensionVersion !== PKG_VERSION) {
174
- process.stderr.write(
175
- chalk.yellow(`⚠ Extension v${status.extensionVersion} ≠ CLI v${PKG_VERSION} — consider updating the extension.\n`)
176
- );
177
- }
178
-
179
187
  ensureRequiredEnv(cmd);
180
188
  const BrowserFactory = getBrowserFactory();
181
189
  result = await browserSession(BrowserFactory, async (page) => {
182
190
  const preNavUrl = resolvePreNav(cmd);
183
191
  if (preNavUrl) {
184
- try {
185
- await page.goto(preNavUrl);
186
- await page.wait(2);
187
- } catch (err) {
188
- if (debug) console.error(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
192
+ const skip = await isAlreadyOnDomain(page, preNavUrl);
193
+ if (skip) {
194
+ if (debug) console.error(`[pre-nav] Already on target domain, skipping navigation`);
195
+ } else {
196
+ try {
197
+ // goto() already includes smart DOM-settle detection (waitForDomStable).
198
+ // No additional fixed sleep needed.
199
+ await page.goto(preNavUrl);
200
+ } catch (err) {
201
+ if (debug) console.error(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
202
+ }
189
203
  }
190
204
  }
191
205
  return runWithTimeout(runCommand(cmd, page, kwargs, debug), {
@@ -194,7 +208,17 @@ export async function executeCommand(
194
208
  });
195
209
  }, { workspace: `site:${cmd.site}` });
196
210
  } else {
197
- result = await runCommand(cmd, null, kwargs, debug);
211
+ // Non-browser commands: apply timeout only when explicitly configured.
212
+ const timeout = cmd.timeoutSeconds;
213
+ if (timeout !== undefined && timeout > 0) {
214
+ result = await runWithTimeout(runCommand(cmd, null, kwargs, debug), {
215
+ timeout,
216
+ label: fullName(cmd),
217
+ hint: `Increase the adapter's timeoutSeconds setting (currently ${timeout}s)`,
218
+ });
219
+ } else {
220
+ result = await runCommand(cmd, null, kwargs, debug);
221
+ }
198
222
  }
199
223
  } catch (err) {
200
224
  hookCtx.error = err;
@@ -0,0 +1,17 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ describe('extension manifest regression', () => {
6
+ it('keeps host permissions required by chrome.cookies.getAll', async () => {
7
+ const manifestPath = path.resolve(process.cwd(), 'extension', 'manifest.json');
8
+ const raw = await fs.readFile(manifestPath, 'utf8');
9
+ const manifest = JSON.parse(raw) as {
10
+ permissions?: string[];
11
+ host_permissions?: string[];
12
+ };
13
+
14
+ expect(manifest.permissions).toContain('cookies');
15
+ expect(manifest.host_permissions).toContain('<all_urls>');
16
+ });
17
+ });
package/src/external.ts CHANGED
@@ -31,7 +31,10 @@ function getUserRegistryPath(): string {
31
31
  return path.join(home, '.opencli', 'external-clis.yaml');
32
32
  }
33
33
 
34
+ let _cachedExternalClis: ExternalCliConfig[] | null = null;
35
+
34
36
  export function loadExternalClis(): ExternalCliConfig[] {
37
+ if (_cachedExternalClis) return _cachedExternalClis;
35
38
  const configs = new Map<string, ExternalCliConfig>();
36
39
 
37
40
  // 1. Load built-in
@@ -60,7 +63,8 @@ export function loadExternalClis(): ExternalCliConfig[] {
60
63
  log.warn(`Failed to parse user external-clis.yaml: ${getErrorMessage(err)}`);
61
64
  }
62
65
 
63
- return Array.from(configs.values()).sort((a, b) => a.name.localeCompare(b.name));
66
+ _cachedExternalClis = Array.from(configs.values()).sort((a, b) => a.name.localeCompare(b.name));
67
+ return _cachedExternalClis;
64
68
  }
65
69
 
66
70
  export function isBinaryInstalled(binary: string): boolean {
@@ -237,5 +241,6 @@ export function registerExternalCli(name: string, opts?: RegisterOptions): void
237
241
 
238
242
  const dump = yaml.dump(items, { indent: 2, sortKeys: true });
239
243
  fs.writeFileSync(userPath, dump, 'utf8');
244
+ _cachedExternalClis = null; // Invalidate cache so next load reflects the change
240
245
  console.log(chalk.dim(userPath));
241
246
  }
package/src/main.ts CHANGED
@@ -27,6 +27,7 @@ const __dirname = path.dirname(__filename);
27
27
  const BUILTIN_CLIS = path.resolve(__dirname, 'clis');
28
28
  const USER_CLIS = path.join(os.homedir(), '.opencli', 'clis');
29
29
 
30
+ // Sequential: plugins must run after built-in discovery so they can override built-in commands.
30
31
  await discoverClis(BUILTIN_CLIS, USER_CLIS);
31
32
  await discoverPlugins();
32
33
 
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Tests for plugin scaffold: create new plugin directories.
3
+ */
4
+
5
+ import { describe, it, expect, afterEach } from 'vitest';
6
+ import * as fs from 'node:fs';
7
+ import * as os from 'node:os';
8
+ import * as path from 'node:path';
9
+ import { createPluginScaffold } from './plugin-scaffold.js';
10
+
11
+ describe('createPluginScaffold', () => {
12
+ const createdDirs: string[] = [];
13
+
14
+ afterEach(() => {
15
+ for (const dir of createdDirs) {
16
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
17
+ }
18
+ createdDirs.length = 0;
19
+ });
20
+
21
+ it('creates all expected files', () => {
22
+ const dir = path.join(os.tmpdir(), `opencli-scaffold-${Date.now()}`);
23
+ createdDirs.push(dir);
24
+
25
+ const result = createPluginScaffold('my-test', { dir });
26
+ expect(result.name).toBe('my-test');
27
+ expect(result.dir).toBe(dir);
28
+ expect(result.files).toContain('opencli-plugin.json');
29
+ expect(result.files).toContain('package.json');
30
+ expect(result.files).toContain('hello.yaml');
31
+ expect(result.files).toContain('greet.ts');
32
+ expect(result.files).toContain('README.md');
33
+
34
+ // All files exist
35
+ for (const f of result.files) {
36
+ expect(fs.existsSync(path.join(dir, f))).toBe(true);
37
+ }
38
+ });
39
+
40
+ it('generates valid opencli-plugin.json', () => {
41
+ const dir = path.join(os.tmpdir(), `opencli-scaffold-${Date.now()}`);
42
+ createdDirs.push(dir);
43
+
44
+ createPluginScaffold('test-manifest', { dir, description: 'Test desc' });
45
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'opencli-plugin.json'), 'utf-8'));
46
+ expect(manifest.name).toBe('test-manifest');
47
+ expect(manifest.version).toBe('0.1.0');
48
+ expect(manifest.description).toBe('Test desc');
49
+ expect(manifest.opencli).toMatch(/^>=/);
50
+ });
51
+
52
+ it('generates ESM package.json', () => {
53
+ const dir = path.join(os.tmpdir(), `opencli-scaffold-${Date.now()}`);
54
+ createdDirs.push(dir);
55
+
56
+ createPluginScaffold('test-pkg', { dir });
57
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
58
+ expect(pkg.type).toBe('module');
59
+ expect(pkg.peerDependencies?.['@jackwener/opencli']).toBeDefined();
60
+ });
61
+
62
+ it('generates a TS sample that matches the current plugin API', () => {
63
+ const dir = path.join(os.tmpdir(), `opencli-scaffold-${Date.now()}`);
64
+ createdDirs.push(dir);
65
+
66
+ createPluginScaffold('test-ts', { dir });
67
+ const tsSample = fs.readFileSync(path.join(dir, 'greet.ts'), 'utf-8');
68
+
69
+ expect(tsSample).toContain(`import { cli, Strategy } from '@jackwener/opencli/registry';`);
70
+ expect(tsSample).toContain(`strategy: Strategy.PUBLIC`);
71
+ expect(tsSample).toContain(`help: 'Name to greet'`);
72
+ expect(tsSample).toContain(`func: async (_page, kwargs)`);
73
+ expect(tsSample).not.toContain('async run(');
74
+ });
75
+
76
+ it('documents a supported local install flow', () => {
77
+ const dir = path.join(os.tmpdir(), `opencli-scaffold-${Date.now()}`);
78
+ createdDirs.push(dir);
79
+
80
+ createPluginScaffold('test-readme', { dir });
81
+ const readme = fs.readFileSync(path.join(dir, 'README.md'), 'utf-8');
82
+
83
+ expect(readme).toContain(`opencli plugin install file://${dir}`);
84
+ });
85
+
86
+ it('rejects invalid names', () => {
87
+ expect(() => createPluginScaffold('Bad_Name')).toThrow('Invalid plugin name');
88
+ expect(() => createPluginScaffold('123start')).toThrow('Invalid plugin name');
89
+ });
90
+
91
+ it('rejects non-empty directory', () => {
92
+ const dir = path.join(os.tmpdir(), `opencli-scaffold-${Date.now()}`);
93
+ createdDirs.push(dir);
94
+ fs.mkdirSync(dir, { recursive: true });
95
+ fs.writeFileSync(path.join(dir, 'existing.txt'), 'x');
96
+ expect(() => createPluginScaffold('test', { dir })).toThrow('not empty');
97
+ });
98
+ });
@@ -0,0 +1,170 @@
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
+
15
+ import * as fs from 'node:fs';
16
+ import * as path from 'node:path';
17
+ import { PKG_VERSION } from './version.js';
18
+
19
+ export interface ScaffoldOptions {
20
+ /** Directory to create the plugin in. Defaults to `./<name>` */
21
+ dir?: string;
22
+ /** Plugin description */
23
+ description?: string;
24
+ }
25
+
26
+ export interface ScaffoldResult {
27
+ name: string;
28
+ dir: string;
29
+ files: string[];
30
+ }
31
+
32
+ /**
33
+ * Create a new plugin scaffold directory.
34
+ */
35
+ export function createPluginScaffold(name: string, opts: ScaffoldOptions = {}): ScaffoldResult {
36
+ // Validate name
37
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) {
38
+ throw new Error(
39
+ `Invalid plugin name "${name}". ` +
40
+ `Plugin names must start with a lowercase letter and contain only lowercase letters, digits, and hyphens.`
41
+ );
42
+ }
43
+
44
+ const targetDir = opts.dir
45
+ ? path.resolve(opts.dir)
46
+ : path.resolve(name);
47
+
48
+ if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length > 0) {
49
+ throw new Error(`Directory "${targetDir}" already exists and is not empty.`);
50
+ }
51
+
52
+ fs.mkdirSync(targetDir, { recursive: true });
53
+
54
+ const files: string[] = [];
55
+
56
+ // opencli-plugin.json
57
+ const manifest = {
58
+ name,
59
+ version: '0.1.0',
60
+ description: opts.description ?? `An opencli plugin: ${name}`,
61
+ opencli: `>=${PKG_VERSION}`,
62
+ };
63
+ writeFile(targetDir, 'opencli-plugin.json', JSON.stringify(manifest, null, 2) + '\n');
64
+ files.push('opencli-plugin.json');
65
+
66
+ // package.json
67
+ const pkg = {
68
+ name: `opencli-plugin-${name}`,
69
+ version: '0.1.0',
70
+ type: 'module',
71
+ description: opts.description ?? `An opencli plugin: ${name}`,
72
+ peerDependencies: {
73
+ '@jackwener/opencli': `>=${PKG_VERSION}`,
74
+ },
75
+ };
76
+ writeFile(targetDir, 'package.json', JSON.stringify(pkg, null, 2) + '\n');
77
+ files.push('package.json');
78
+
79
+ // hello.yaml — sample YAML command
80
+ const yamlContent = `# Sample YAML command for ${name}
81
+ # See: https://github.com/jackwener/opencli#yaml-commands
82
+
83
+ site: ${name}
84
+ name: hello
85
+ description: "A sample YAML command"
86
+ strategy: public
87
+ browser: false
88
+
89
+ domain: https://httpbin.org
90
+
91
+ pipeline:
92
+ - fetch:
93
+ url: "https://httpbin.org/get?greeting=hello"
94
+ method: GET
95
+ - extract:
96
+ type: json
97
+ selector: "$.args"
98
+ `;
99
+ writeFile(targetDir, 'hello.yaml', yamlContent);
100
+ files.push('hello.yaml');
101
+
102
+ // greet.ts — sample TS command using registry API
103
+ const tsContent = `/**
104
+ * Sample TypeScript command for ${name}.
105
+ * Demonstrates the programmatic cli() registration API.
106
+ */
107
+
108
+ import { cli, Strategy } from '@jackwener/opencli/registry';
109
+
110
+ cli({
111
+ site: '${name}',
112
+ name: 'greet',
113
+ description: 'Greet someone by name',
114
+ strategy: Strategy.PUBLIC,
115
+ browser: false,
116
+ args: [
117
+ { name: 'name', positional: true, required: true, help: 'Name to greet' },
118
+ ],
119
+ columns: ['greeting'],
120
+ func: async (_page, kwargs) => [{ greeting: \`Hello, \${String(kwargs.name ?? 'World')}!\` }],
121
+ });
122
+ `;
123
+ writeFile(targetDir, 'greet.ts', tsContent);
124
+ files.push('greet.ts');
125
+
126
+ // README.md
127
+ const readme = `# opencli-plugin-${name}
128
+
129
+ ${opts.description ?? `An opencli plugin: ${name}`}
130
+
131
+ ## Install
132
+
133
+ \`\`\`bash
134
+ # From local development directory
135
+ opencli plugin install file://${targetDir}
136
+
137
+ # From GitHub (after publishing)
138
+ opencli plugin install github:<user>/opencli-plugin-${name}
139
+ \`\`\`
140
+
141
+ ## Commands
142
+
143
+ | Command | Type | Description |
144
+ |---------|------|-------------|
145
+ | \`${name}/hello\` | YAML | Sample YAML command |
146
+ | \`${name}/greet\` | TypeScript | Sample TS command |
147
+
148
+ ## Development
149
+
150
+ \`\`\`bash
151
+ # Install locally for development (symlinked, changes reflect immediately)
152
+ opencli plugin install file://${targetDir}
153
+
154
+ # Verify commands are registered
155
+ opencli list | grep ${name}
156
+
157
+ # Run a command
158
+ opencli ${name} hello
159
+ opencli ${name} greet --name World
160
+ \`\`\`
161
+ `;
162
+ writeFile(targetDir, 'README.md', readme);
163
+ files.push('README.md');
164
+
165
+ return { name, dir: targetDir, files };
166
+ }
167
+
168
+ function writeFile(dir: string, name: string, content: string): void {
169
+ fs.writeFileSync(path.join(dir, name), content);
170
+ }