@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
@@ -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 {};
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Tests for plugin scaffold: create new plugin directories.
3
+ */
4
+ import { describe, it, expect, afterEach } from 'vitest';
5
+ import * as fs from 'node:fs';
6
+ import * as os from 'node:os';
7
+ import * as path from 'node:path';
8
+ import { createPluginScaffold } from './plugin-scaffold.js';
9
+ describe('createPluginScaffold', () => {
10
+ const createdDirs = [];
11
+ afterEach(() => {
12
+ for (const dir of createdDirs) {
13
+ try {
14
+ fs.rmSync(dir, { recursive: true, force: true });
15
+ }
16
+ catch { }
17
+ }
18
+ createdDirs.length = 0;
19
+ });
20
+ it('creates all expected files', () => {
21
+ const dir = path.join(os.tmpdir(), `opencli-scaffold-${Date.now()}`);
22
+ createdDirs.push(dir);
23
+ const result = createPluginScaffold('my-test', { dir });
24
+ expect(result.name).toBe('my-test');
25
+ expect(result.dir).toBe(dir);
26
+ expect(result.files).toContain('opencli-plugin.json');
27
+ expect(result.files).toContain('package.json');
28
+ expect(result.files).toContain('hello.yaml');
29
+ expect(result.files).toContain('greet.ts');
30
+ expect(result.files).toContain('README.md');
31
+ // All files exist
32
+ for (const f of result.files) {
33
+ expect(fs.existsSync(path.join(dir, f))).toBe(true);
34
+ }
35
+ });
36
+ it('generates valid opencli-plugin.json', () => {
37
+ const dir = path.join(os.tmpdir(), `opencli-scaffold-${Date.now()}`);
38
+ createdDirs.push(dir);
39
+ createPluginScaffold('test-manifest', { dir, description: 'Test desc' });
40
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'opencli-plugin.json'), 'utf-8'));
41
+ expect(manifest.name).toBe('test-manifest');
42
+ expect(manifest.version).toBe('0.1.0');
43
+ expect(manifest.description).toBe('Test desc');
44
+ expect(manifest.opencli).toMatch(/^>=/);
45
+ });
46
+ it('generates ESM package.json', () => {
47
+ const dir = path.join(os.tmpdir(), `opencli-scaffold-${Date.now()}`);
48
+ createdDirs.push(dir);
49
+ createPluginScaffold('test-pkg', { dir });
50
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
51
+ expect(pkg.type).toBe('module');
52
+ expect(pkg.peerDependencies?.['@jackwener/opencli']).toBeDefined();
53
+ });
54
+ it('generates a TS sample that matches the current plugin API', () => {
55
+ const dir = path.join(os.tmpdir(), `opencli-scaffold-${Date.now()}`);
56
+ createdDirs.push(dir);
57
+ createPluginScaffold('test-ts', { dir });
58
+ const tsSample = fs.readFileSync(path.join(dir, 'greet.ts'), 'utf-8');
59
+ expect(tsSample).toContain(`import { cli, Strategy } from '@jackwener/opencli/registry';`);
60
+ expect(tsSample).toContain(`strategy: Strategy.PUBLIC`);
61
+ expect(tsSample).toContain(`help: 'Name to greet'`);
62
+ expect(tsSample).toContain(`func: async (_page, kwargs)`);
63
+ expect(tsSample).not.toContain('async run(');
64
+ });
65
+ it('documents a supported local install flow', () => {
66
+ const dir = path.join(os.tmpdir(), `opencli-scaffold-${Date.now()}`);
67
+ createdDirs.push(dir);
68
+ createPluginScaffold('test-readme', { dir });
69
+ const readme = fs.readFileSync(path.join(dir, 'README.md'), 'utf-8');
70
+ expect(readme).toContain(`opencli plugin install file://${dir}`);
71
+ });
72
+ it('rejects invalid names', () => {
73
+ expect(() => createPluginScaffold('Bad_Name')).toThrow('Invalid plugin name');
74
+ expect(() => createPluginScaffold('123start')).toThrow('Invalid plugin name');
75
+ });
76
+ it('rejects non-empty directory', () => {
77
+ const dir = path.join(os.tmpdir(), `opencli-scaffold-${Date.now()}`);
78
+ createdDirs.push(dir);
79
+ fs.mkdirSync(dir, { recursive: true });
80
+ fs.writeFileSync(path.join(dir, 'existing.txt'), 'x');
81
+ expect(() => createPluginScaffold('test', { dir })).toThrow('not empty');
82
+ });
83
+ });
package/dist/plugin.d.ts CHANGED
@@ -3,26 +3,31 @@
3
3
  *
4
4
  * Plugins live in ~/.opencli/plugins/<name>/.
5
5
  * Monorepo clones live in ~/.opencli/monorepos/<repo-name>/.
6
- * Install source format: "github:user/repo" or "github:user/repo/subplugin"
6
+ * Install source format: "github:user/repo", "github:user/repo/subplugin",
7
+ * "https://github.com/user/repo", "file:///local/plugin", or a local directory path.
7
8
  */
9
+ import * as fs from 'node:fs';
8
10
  /** Path to the lock file that tracks installed plugin versions. */
9
11
  export declare function getLockFilePath(): string;
10
12
  /** Monorepo clones directory: ~/.opencli/monorepos/ */
11
13
  export declare function getMonoreposDir(): string;
12
- export declare const LOCK_FILE: string;
13
- export declare const MONOREPOS_DIR: string;
14
+ export type PluginSourceRecord = {
15
+ kind: 'git';
16
+ url: string;
17
+ } | {
18
+ kind: 'local';
19
+ path: string;
20
+ } | {
21
+ kind: 'monorepo';
22
+ url: string;
23
+ repoName: string;
24
+ subPath: string;
25
+ };
14
26
  export interface LockEntry {
15
- source: string;
27
+ source: PluginSourceRecord;
16
28
  commitHash: string;
17
29
  installedAt: string;
18
30
  updatedAt?: string;
19
- /** Present when this plugin comes from a monorepo. */
20
- monorepo?: {
21
- /** Monorepo directory name under ~/.opencli/monorepos/ */
22
- name: string;
23
- /** Relative path of this sub-plugin within the monorepo. */
24
- subPath: string;
25
- };
26
31
  }
27
32
  export interface PluginInfo {
28
33
  name: string;
@@ -36,11 +41,40 @@ export interface PluginInfo {
36
41
  /** Description from opencli-plugin.json. */
37
42
  description?: string;
38
43
  }
44
+ interface ParsedSource {
45
+ type: 'git' | 'local';
46
+ name: string;
47
+ subPlugin?: string;
48
+ cloneUrl?: string;
49
+ localPath?: string;
50
+ }
51
+ declare function isLocalPluginSource(source?: string): boolean;
52
+ declare function toStoredPluginSource(source: PluginSourceRecord): string;
53
+ declare function toLocalPluginSource(pluginDir: string): string;
54
+ declare function resolvePluginSource(lockEntry: LockEntry | undefined, pluginDir: string): PluginSourceRecord | undefined;
55
+ declare function resolveStoredPluginSource(lockEntry: LockEntry | undefined, pluginDir: string): string | undefined;
56
+ /**
57
+ * Move a directory, with EXDEV fallback.
58
+ * fs.renameSync fails when source and destination are on different
59
+ * filesystems (e.g. /tmp → ~/.opencli). In that case we copy then remove.
60
+ */
61
+ type MoveDirFsOps = Pick<typeof fs, 'renameSync' | 'cpSync' | 'rmSync'>;
62
+ declare function moveDir(src: string, dest: string, fsOps?: MoveDirFsOps): void;
63
+ type PromoteDirFsOps = MoveDirFsOps & Pick<typeof fs, 'existsSync' | 'mkdirSync'>;
64
+ /**
65
+ * Promote a prepared staging directory into its final location.
66
+ * The final path is only exposed after the directory has been fully prepared.
67
+ */
68
+ declare function promoteDir(stagingDir: string, dest: string, fsOps?: PromoteDirFsOps): void;
69
+ declare function replaceDir(stagingDir: string, dest: string, fsOps?: PromoteDirFsOps): void;
39
70
  export interface ValidationResult {
40
71
  valid: boolean;
41
72
  errors: string[];
42
73
  }
74
+ declare function readLockFileWithWriter(writeLock?: (lock: Record<string, LockEntry>) => void): Record<string, LockEntry>;
43
75
  export declare function readLockFile(): Record<string, LockEntry>;
76
+ type WriteLockFileFsOps = Pick<typeof fs, 'mkdirSync' | 'writeFileSync' | 'renameSync' | 'rmSync'>;
77
+ declare function writeLockFileWithFs(lock: Record<string, LockEntry>, fsOps?: WriteLockFileFsOps): void;
44
78
  export declare function writeLockFile(lock: Record<string, LockEntry>): void;
45
79
  /** Get the HEAD commit hash of a git repo directory. */
46
80
  export declare function getCommitHash(dir: string): string | undefined;
@@ -61,10 +95,18 @@ declare function postInstallMonorepoLifecycle(repoDir: string, pluginDirs: strin
61
95
  * "github:user/repo" — single plugin or full monorepo
62
96
  * "github:user/repo/subplugin" — specific sub-plugin from a monorepo
63
97
  * "https://github.com/user/repo"
98
+ * "file:///absolute/path" — local plugin directory (symlinked)
99
+ * "/absolute/path" — local plugin directory (symlinked)
64
100
  *
65
101
  * Returns the installed plugin name(s).
66
102
  */
67
103
  export declare function installPlugin(source: string): string | string[];
104
+ /**
105
+ * Install a local plugin by creating a symlink.
106
+ * Used for plugin development: the source directory is symlinked into
107
+ * the plugins dir so changes are reflected immediately.
108
+ */
109
+ declare function installLocalPlugin(localPath: string, name: string): string;
68
110
  /**
69
111
  * Uninstall a plugin by name.
70
112
  * For monorepo sub-plugins: removes symlink and cleans up the monorepo
@@ -95,13 +137,9 @@ export declare function updateAllPlugins(): UpdateResult[];
95
137
  */
96
138
  export declare function listPlugins(): PluginInfo[];
97
139
  /** Parse a plugin source string into clone URL, repo name, and optional sub-plugin. */
98
- declare function parseSource(source: string): {
99
- cloneUrl: string;
100
- name: string;
101
- subPlugin?: string;
102
- } | null;
140
+ declare function parseSource(source: string): ParsedSource | null;
103
141
  /**
104
142
  * Resolve the path to the esbuild CLI executable with fallback strategies.
105
143
  */
106
144
  export declare function resolveEsbuildBin(): string | null;
107
- export { resolveEsbuildBin as _resolveEsbuildBin, getCommitHash as _getCommitHash, installDependencies as _installDependencies, parseSource as _parseSource, postInstallMonorepoLifecycle as _postInstallMonorepoLifecycle, readLockFile as _readLockFile, updateAllPlugins as _updateAllPlugins, validatePluginStructure as _validatePluginStructure, writeLockFile as _writeLockFile, isSymlinkSync as _isSymlinkSync, getMonoreposDir as _getMonoreposDir, };
145
+ export { resolveEsbuildBin as _resolveEsbuildBin, getCommitHash as _getCommitHash, installDependencies as _installDependencies, parseSource as _parseSource, postInstallMonorepoLifecycle as _postInstallMonorepoLifecycle, readLockFile as _readLockFile, readLockFileWithWriter as _readLockFileWithWriter, updateAllPlugins as _updateAllPlugins, validatePluginStructure as _validatePluginStructure, writeLockFile as _writeLockFile, writeLockFileWithFs as _writeLockFileWithFs, isSymlinkSync as _isSymlinkSync, getMonoreposDir as _getMonoreposDir, installLocalPlugin as _installLocalPlugin, isLocalPluginSource as _isLocalPluginSource, moveDir as _moveDir, promoteDir as _promoteDir, replaceDir as _replaceDir, resolvePluginSource as _resolvePluginSource, resolveStoredPluginSource as _resolveStoredPluginSource, toStoredPluginSource as _toStoredPluginSource, toLocalPluginSource as _toLocalPluginSource, };