@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
package/src/plugin.ts CHANGED
@@ -3,7 +3,8 @@
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
  */
8
9
 
9
10
  import * as fs from 'node:fs';
@@ -23,6 +24,7 @@ import {
23
24
  } from './plugin-manifest.js';
24
25
 
25
26
  const isWindows = process.platform === 'win32';
27
+ const LOCAL_PLUGIN_SOURCE_PREFIX = 'local:';
26
28
 
27
29
  /** Get home directory, respecting HOME environment variable for test isolation. */
28
30
  function getHomeDir(): string {
@@ -39,22 +41,16 @@ export function getMonoreposDir(): string {
39
41
  return path.join(getHomeDir(), '.opencli', 'monorepos');
40
42
  }
41
43
 
42
- // Legacy const for backward compatibility (computed at load time)
43
- export const LOCK_FILE = path.join(os.homedir(), '.opencli', 'plugins.lock.json');
44
- export const MONOREPOS_DIR = path.join(os.homedir(), '.opencli', 'monorepos');
44
+ export type PluginSourceRecord =
45
+ | { kind: 'git'; url: string }
46
+ | { kind: 'local'; path: string }
47
+ | { kind: 'monorepo'; url: string; repoName: string; subPath: string };
45
48
 
46
49
  export interface LockEntry {
47
- source: string;
50
+ source: PluginSourceRecord;
48
51
  commitHash: string;
49
52
  installedAt: string;
50
53
  updatedAt?: string;
51
- /** Present when this plugin comes from a monorepo. */
52
- monorepo?: {
53
- /** Monorepo directory name under ~/.opencli/monorepos/ */
54
- name: string;
55
- /** Relative path of this sub-plugin within the monorepo. */
56
- subPath: string;
57
- };
58
54
  }
59
55
 
60
56
  export interface PluginInfo {
@@ -70,6 +66,382 @@ export interface PluginInfo {
70
66
  description?: string;
71
67
  }
72
68
 
69
+ interface ParsedSource {
70
+ type: 'git' | 'local';
71
+ name: string;
72
+ subPlugin?: string;
73
+ cloneUrl?: string;
74
+ localPath?: string;
75
+ }
76
+
77
+ function parseStoredPluginSource(source?: string): PluginSourceRecord | undefined {
78
+ if (!source) return undefined;
79
+ if (source.startsWith(LOCAL_PLUGIN_SOURCE_PREFIX)) {
80
+ return {
81
+ kind: 'local',
82
+ path: path.resolve(source.slice(LOCAL_PLUGIN_SOURCE_PREFIX.length)),
83
+ };
84
+ }
85
+ return { kind: 'git', url: source };
86
+ }
87
+
88
+ function isLocalPluginSource(source?: string): boolean {
89
+ return parseStoredPluginSource(source)?.kind === 'local';
90
+ }
91
+
92
+ function toStoredPluginSource(source: PluginSourceRecord): string {
93
+ if (source.kind === 'local') {
94
+ return `${LOCAL_PLUGIN_SOURCE_PREFIX}${path.resolve(source.path)}`;
95
+ }
96
+ return source.url;
97
+ }
98
+
99
+ function toLocalPluginSource(pluginDir: string): string {
100
+ return toStoredPluginSource({ kind: 'local', path: pluginDir });
101
+ }
102
+
103
+ function isRecord(value: unknown): value is Record<string, unknown> {
104
+ return typeof value === 'object' && value !== null;
105
+ }
106
+
107
+ function normalizeLegacyMonorepo(
108
+ value: unknown,
109
+ ): { name: string; subPath: string } | undefined {
110
+ if (!isRecord(value)) return undefined;
111
+ if (typeof value.name !== 'string' || typeof value.subPath !== 'string') return undefined;
112
+ return { name: value.name, subPath: value.subPath };
113
+ }
114
+
115
+ function normalizePluginSource(
116
+ source: unknown,
117
+ legacyMonorepo?: { name: string; subPath: string },
118
+ ): PluginSourceRecord | undefined {
119
+ if (typeof source === 'string') {
120
+ const parsed = parseStoredPluginSource(source);
121
+ if (!parsed) return undefined;
122
+ if (parsed.kind === 'git' && legacyMonorepo) {
123
+ return {
124
+ kind: 'monorepo',
125
+ url: parsed.url,
126
+ repoName: legacyMonorepo.name,
127
+ subPath: legacyMonorepo.subPath,
128
+ };
129
+ }
130
+ return parsed;
131
+ }
132
+
133
+ if (!isRecord(source) || typeof source.kind !== 'string') return undefined;
134
+ switch (source.kind) {
135
+ case 'git':
136
+ return typeof source.url === 'string'
137
+ ? { kind: 'git', url: source.url }
138
+ : undefined;
139
+ case 'local':
140
+ return typeof source.path === 'string'
141
+ ? { kind: 'local', path: path.resolve(source.path) }
142
+ : undefined;
143
+ case 'monorepo':
144
+ return typeof source.url === 'string'
145
+ && typeof source.repoName === 'string'
146
+ && typeof source.subPath === 'string'
147
+ ? {
148
+ kind: 'monorepo',
149
+ url: source.url,
150
+ repoName: source.repoName,
151
+ subPath: source.subPath,
152
+ }
153
+ : undefined;
154
+ default:
155
+ return undefined;
156
+ }
157
+ }
158
+
159
+ function normalizeLockEntry(value: unknown): LockEntry | undefined {
160
+ if (!isRecord(value)) return undefined;
161
+
162
+ const legacyMonorepo = normalizeLegacyMonorepo(value.monorepo);
163
+ const source = normalizePluginSource(value.source, legacyMonorepo);
164
+ if (!source) return undefined;
165
+ if (typeof value.commitHash !== 'string' || typeof value.installedAt !== 'string') {
166
+ return undefined;
167
+ }
168
+
169
+ const entry: LockEntry = {
170
+ source,
171
+ commitHash: value.commitHash,
172
+ installedAt: value.installedAt,
173
+ };
174
+
175
+ if (typeof value.updatedAt === 'string') {
176
+ entry.updatedAt = value.updatedAt;
177
+ }
178
+
179
+ return entry;
180
+ }
181
+
182
+ function resolvePluginSource(lockEntry: LockEntry | undefined, pluginDir: string): PluginSourceRecord | undefined {
183
+ if (lockEntry) {
184
+ return lockEntry.source;
185
+ }
186
+ return parseStoredPluginSource(getPluginSource(pluginDir));
187
+ }
188
+
189
+ function resolveStoredPluginSource(lockEntry: LockEntry | undefined, pluginDir: string): string | undefined {
190
+ const source = resolvePluginSource(lockEntry, pluginDir);
191
+ return source ? toStoredPluginSource(source) : undefined;
192
+ }
193
+
194
+ // ── Filesystem helpers ──────────────────────────────────────────────────────
195
+
196
+ /**
197
+ * Move a directory, with EXDEV fallback.
198
+ * fs.renameSync fails when source and destination are on different
199
+ * filesystems (e.g. /tmp → ~/.opencli). In that case we copy then remove.
200
+ */
201
+ type MoveDirFsOps = Pick<typeof fs, 'renameSync' | 'cpSync' | 'rmSync'>;
202
+
203
+ function moveDir(src: string, dest: string, fsOps: MoveDirFsOps = fs): void {
204
+ try {
205
+ fsOps.renameSync(src, dest);
206
+ } catch (err: unknown) {
207
+ if ((err as NodeJS.ErrnoException).code === 'EXDEV') {
208
+ try {
209
+ fsOps.cpSync(src, dest, { recursive: true });
210
+ } catch (copyErr) {
211
+ try { fsOps.rmSync(dest, { recursive: true, force: true }); } catch {}
212
+ throw copyErr;
213
+ }
214
+ fsOps.rmSync(src, { recursive: true, force: true });
215
+ } else {
216
+ throw err;
217
+ }
218
+ }
219
+ }
220
+
221
+ type PromoteDirFsOps = MoveDirFsOps & Pick<typeof fs, 'existsSync' | 'mkdirSync'>;
222
+
223
+ function createSiblingTempPath(dest: string, kind: 'tmp' | 'bak'): string {
224
+ const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
225
+ return path.join(path.dirname(dest), `.${path.basename(dest)}.${kind}-${suffix}`);
226
+ }
227
+
228
+ /**
229
+ * Promote a prepared staging directory into its final location.
230
+ * The final path is only exposed after the directory has been fully prepared.
231
+ */
232
+ function promoteDir(stagingDir: string, dest: string, fsOps: PromoteDirFsOps = fs): void {
233
+ if (fsOps.existsSync(dest)) {
234
+ throw new Error(`Destination already exists: ${dest}`);
235
+ }
236
+
237
+ fsOps.mkdirSync(path.dirname(dest), { recursive: true });
238
+ const tempDest = createSiblingTempPath(dest, 'tmp');
239
+
240
+ try {
241
+ moveDir(stagingDir, tempDest, fsOps);
242
+ fsOps.renameSync(tempDest, dest);
243
+ } catch (err) {
244
+ try { fsOps.rmSync(tempDest, { recursive: true, force: true }); } catch {}
245
+ throw err;
246
+ }
247
+ }
248
+
249
+ function replaceDir(stagingDir: string, dest: string, fsOps: PromoteDirFsOps = fs): void {
250
+ const replacement = beginReplaceDir(stagingDir, dest, fsOps);
251
+ replacement.finalize();
252
+ }
253
+
254
+ function cloneRepoToTemp(cloneUrl: string): string {
255
+ const tmpCloneDir = path.join(
256
+ os.tmpdir(),
257
+ `opencli-clone-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
258
+ );
259
+
260
+ try {
261
+ execFileSync('git', ['clone', '--depth', '1', cloneUrl, tmpCloneDir], {
262
+ encoding: 'utf-8',
263
+ stdio: ['pipe', 'pipe', 'pipe'],
264
+ });
265
+ } catch (err) {
266
+ throw new Error(`Failed to clone plugin: ${getErrorMessage(err)}`);
267
+ }
268
+
269
+ return tmpCloneDir;
270
+ }
271
+
272
+ function withTempClone<T>(cloneUrl: string, work: (cloneDir: string) => T): T {
273
+ const tmpCloneDir = cloneRepoToTemp(cloneUrl);
274
+ try {
275
+ return work(tmpCloneDir);
276
+ } finally {
277
+ try { fs.rmSync(tmpCloneDir, { recursive: true, force: true }); } catch {}
278
+ }
279
+ }
280
+
281
+ function resolveRemotePluginSource(lockEntry: LockEntry | undefined, dir: string): string {
282
+ const source = resolvePluginSource(lockEntry, dir);
283
+ if (!source || source.kind === 'local') {
284
+ throw new Error(`Unable to determine remote source for plugin at ${dir}`);
285
+ }
286
+ return source.url;
287
+ }
288
+
289
+ function pathExistsSync(p: string): boolean {
290
+ try {
291
+ fs.lstatSync(p);
292
+ return true;
293
+ } catch {
294
+ return false;
295
+ }
296
+ }
297
+
298
+ function removePathSync(p: string): void {
299
+ try {
300
+ const stat = fs.lstatSync(p);
301
+ if (stat.isSymbolicLink()) {
302
+ fs.unlinkSync(p);
303
+ return;
304
+ }
305
+ fs.rmSync(p, { recursive: true, force: true });
306
+ } catch {}
307
+ }
308
+
309
+ interface TransactionHandle {
310
+ finalize(): void;
311
+ rollback(): void;
312
+ }
313
+
314
+ class Transaction {
315
+ #handles: TransactionHandle[] = [];
316
+ #settled = false;
317
+
318
+ track<T extends TransactionHandle>(handle: T): T {
319
+ this.#handles.push(handle);
320
+ return handle;
321
+ }
322
+
323
+ commit(): void {
324
+ if (this.#settled) return;
325
+ this.#settled = true;
326
+ for (const handle of this.#handles) {
327
+ handle.finalize();
328
+ }
329
+ }
330
+
331
+ rollback(): void {
332
+ if (this.#settled) return;
333
+ this.#settled = true;
334
+ for (const handle of [...this.#handles].reverse()) {
335
+ handle.rollback();
336
+ }
337
+ }
338
+ }
339
+
340
+ function runTransaction<T>(work: (tx: Transaction) => T): T {
341
+ const tx = new Transaction();
342
+ try {
343
+ const result = work(tx);
344
+ tx.commit();
345
+ return result;
346
+ } catch (err) {
347
+ tx.rollback();
348
+ throw err;
349
+ }
350
+ }
351
+
352
+ function beginReplaceDir(
353
+ stagingDir: string,
354
+ dest: string,
355
+ fsOps: PromoteDirFsOps = fs,
356
+ ): TransactionHandle {
357
+ const destExisted = fsOps.existsSync(dest);
358
+ fsOps.mkdirSync(path.dirname(dest), { recursive: true });
359
+
360
+ const tempDest = createSiblingTempPath(dest, 'tmp');
361
+ const backupDest = destExisted ? createSiblingTempPath(dest, 'bak') : null;
362
+ let settled = false;
363
+
364
+ try {
365
+ moveDir(stagingDir, tempDest, fsOps);
366
+ if (backupDest) {
367
+ fsOps.renameSync(dest, backupDest);
368
+ }
369
+ fsOps.renameSync(tempDest, dest);
370
+ } catch (err) {
371
+ try { fsOps.rmSync(tempDest, { recursive: true, force: true }); } catch {}
372
+ if (backupDest && !fsOps.existsSync(dest)) {
373
+ try { fsOps.renameSync(backupDest, dest); } catch {}
374
+ }
375
+ throw err;
376
+ }
377
+
378
+ return {
379
+ finalize() {
380
+ if (settled) return;
381
+ settled = true;
382
+ if (backupDest) {
383
+ try { fsOps.rmSync(backupDest, { recursive: true, force: true }); } catch {}
384
+ }
385
+ },
386
+ rollback() {
387
+ if (settled) return;
388
+ settled = true;
389
+ try { fsOps.rmSync(dest, { recursive: true, force: true }); } catch {}
390
+ if (backupDest) {
391
+ try { fsOps.renameSync(backupDest, dest); } catch {}
392
+ }
393
+ try { fsOps.rmSync(tempDest, { recursive: true, force: true }); } catch {}
394
+ },
395
+ };
396
+ }
397
+
398
+ function beginReplaceSymlink(target: string, linkPath: string): TransactionHandle {
399
+ const linkExists = pathExistsSync(linkPath);
400
+ if (linkExists && !isSymlinkSync(linkPath)) {
401
+ throw new Error(`Expected monorepo plugin link at ${linkPath} to be a symlink`);
402
+ }
403
+
404
+ fs.mkdirSync(path.dirname(linkPath), { recursive: true });
405
+
406
+ const tempLink = createSiblingTempPath(linkPath, 'tmp');
407
+ const backupLink = linkExists ? createSiblingTempPath(linkPath, 'bak') : null;
408
+ const linkType = isWindows ? 'junction' : 'dir';
409
+ let settled = false;
410
+
411
+ try {
412
+ fs.symlinkSync(target, tempLink, linkType);
413
+ if (backupLink) {
414
+ fs.renameSync(linkPath, backupLink);
415
+ }
416
+ fs.renameSync(tempLink, linkPath);
417
+ } catch (err) {
418
+ removePathSync(tempLink);
419
+ if (backupLink && !pathExistsSync(linkPath)) {
420
+ try { fs.renameSync(backupLink, linkPath); } catch {}
421
+ }
422
+ throw err;
423
+ }
424
+
425
+ return {
426
+ finalize() {
427
+ if (settled) return;
428
+ settled = true;
429
+ if (backupLink) {
430
+ removePathSync(backupLink);
431
+ }
432
+ },
433
+ rollback() {
434
+ if (settled) return;
435
+ settled = true;
436
+ removePathSync(linkPath);
437
+ if (backupLink && !pathExistsSync(linkPath)) {
438
+ try { fs.renameSync(backupLink, linkPath); } catch {}
439
+ }
440
+ removePathSync(tempLink);
441
+ },
442
+ };
443
+ }
444
+
73
445
  // ── Validation helpers ──────────────────────────────────────────────────────
74
446
 
75
447
  export interface ValidationResult {
@@ -79,19 +451,67 @@ export interface ValidationResult {
79
451
 
80
452
  // ── Lock file helpers ───────────────────────────────────────────────────────
81
453
 
82
- export function readLockFile(): Record<string, LockEntry> {
454
+ function readLockFileWithWriter(
455
+ writeLock: (lock: Record<string, LockEntry>) => void = writeLockFile,
456
+ ): Record<string, LockEntry> {
83
457
  try {
84
458
  const raw = fs.readFileSync(getLockFilePath(), 'utf-8');
85
- return JSON.parse(raw) as Record<string, LockEntry>;
459
+ const parsed = JSON.parse(raw) as unknown;
460
+ if (!isRecord(parsed)) return {};
461
+
462
+ const lock: Record<string, LockEntry> = {};
463
+ let changed = false;
464
+
465
+ for (const [name, entry] of Object.entries(parsed)) {
466
+ const normalized = normalizeLockEntry(entry);
467
+ if (!normalized) {
468
+ changed = true;
469
+ continue;
470
+ }
471
+
472
+ lock[name] = normalized;
473
+ if (JSON.stringify(entry) !== JSON.stringify(normalized)) {
474
+ changed = true;
475
+ }
476
+ }
477
+
478
+ if (changed) {
479
+ try {
480
+ writeLock(lock);
481
+ } catch {}
482
+ }
483
+
484
+ return lock;
86
485
  } catch {
87
486
  return {};
88
487
  }
89
488
  }
90
489
 
91
- export function writeLockFile(lock: Record<string, LockEntry>): void {
490
+ export function readLockFile(): Record<string, LockEntry> {
491
+ return readLockFileWithWriter(writeLockFile);
492
+ }
493
+
494
+ type WriteLockFileFsOps = Pick<typeof fs, 'mkdirSync' | 'writeFileSync' | 'renameSync' | 'rmSync'>;
495
+
496
+ function writeLockFileWithFs(
497
+ lock: Record<string, LockEntry>,
498
+ fsOps: WriteLockFileFsOps = fs,
499
+ ): void {
92
500
  const lockPath = getLockFilePath();
93
- fs.mkdirSync(path.dirname(lockPath), { recursive: true });
94
- fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
501
+ fsOps.mkdirSync(path.dirname(lockPath), { recursive: true });
502
+ const tempPath = createSiblingTempPath(lockPath, 'tmp');
503
+
504
+ try {
505
+ fsOps.writeFileSync(tempPath, JSON.stringify(lock, null, 2) + '\n');
506
+ fsOps.renameSync(tempPath, lockPath);
507
+ } catch (err) {
508
+ try { fsOps.rmSync(tempPath, { force: true }); } catch {}
509
+ throw err;
510
+ }
511
+ }
512
+
513
+ export function writeLockFile(lock: Record<string, LockEntry>): void {
514
+ writeLockFileWithFs(lock, fs);
95
515
  }
96
516
 
97
517
  /** Get the HEAD commit hash of a git repo directory. */
@@ -114,7 +534,7 @@ export function getCommitHash(dir: string): string | undefined {
114
534
  */
115
535
  export function validatePluginStructure(pluginDir: string): ValidationResult {
116
536
  const errors: string[] = [];
117
-
537
+
118
538
  if (!fs.existsSync(pluginDir)) {
119
539
  return { valid: false, errors: ['Plugin directory does not exist'] };
120
540
  }
@@ -125,21 +545,21 @@ export function validatePluginStructure(pluginDir: string): ValidationResult {
125
545
  const hasJs = files.some(f => f.endsWith('.js') && !f.endsWith('.d.js'));
126
546
 
127
547
  if (!hasYaml && !hasTs && !hasJs) {
128
- errors.push(`No command files found in plugin directory. A plugin must contain at least one .yaml, .ts, or .js command file.`);
548
+ errors.push('No command files found in plugin directory. A plugin must contain at least one .yaml, .ts, or .js command file.');
129
549
  }
130
550
 
131
551
  if (hasTs) {
132
552
  const pkgJsonPath = path.join(pluginDir, 'package.json');
133
553
  if (!fs.existsSync(pkgJsonPath)) {
134
- errors.push(`Plugin contains .ts files but no package.json. A package.json with "type": "module" and "@jackwener/opencli" peer dependency is required for TS plugins.`);
554
+ errors.push('Plugin contains .ts files but no package.json. A package.json with "type": "module" and "@jackwener/opencli" peer dependency is required for TS plugins.');
135
555
  } else {
136
556
  try {
137
557
  const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
138
558
  if (pkg.type !== 'module') {
139
- errors.push(`Plugin package.json must have "type": "module" for TypeScript plugins.`);
559
+ errors.push('Plugin package.json must have "type": "module" for TypeScript plugins.');
140
560
  }
141
561
  } catch {
142
- errors.push(`Plugin package.json is malformed or invalid JSON.`);
562
+ errors.push('Plugin package.json is malformed or invalid JSON.');
143
563
  }
144
564
  }
145
565
  }
@@ -190,12 +610,76 @@ function postInstallMonorepoLifecycle(repoDir: string, pluginDirs: string[]): vo
190
610
  }
191
611
  }
192
612
 
613
+ function ensureStandalonePluginReady(pluginDir: string): void {
614
+ const validation = validatePluginStructure(pluginDir);
615
+ if (!validation.valid) {
616
+ throw new Error(`Invalid plugin structure:\n- ${validation.errors.join('\n- ')}`);
617
+ }
618
+
619
+ postInstallLifecycle(pluginDir);
620
+ }
621
+
622
+ type LockEntryInput = Omit<LockEntry, 'installedAt'> & Partial<Pick<LockEntry, 'installedAt'>>;
623
+
624
+ function upsertLockEntry(
625
+ lock: Record<string, LockEntry>,
626
+ name: string,
627
+ entry: LockEntryInput,
628
+ ): void {
629
+ lock[name] = {
630
+ ...entry,
631
+ installedAt: entry.installedAt ?? new Date().toISOString(),
632
+ };
633
+ }
634
+
635
+ function publishStandalonePlugin(
636
+ stagingDir: string,
637
+ targetDir: string,
638
+ writeLock: (commitHash: string | undefined) => void,
639
+ ): void {
640
+ runTransaction((tx) => {
641
+ tx.track(beginReplaceDir(stagingDir, targetDir));
642
+ writeLock(getCommitHash(targetDir));
643
+ });
644
+ }
645
+
646
+ interface MonorepoPublishPlugin {
647
+ name: string;
648
+ subPath: string;
649
+ }
650
+
651
+ function publishMonorepoPlugins(
652
+ repoDir: string,
653
+ pluginsDir: string,
654
+ plugins: MonorepoPublishPlugin[],
655
+ publishRepo?: { stagingDir: string; parentDir: string },
656
+ writeLock?: (commitHash: string | undefined) => void,
657
+ ): void {
658
+ runTransaction((tx) => {
659
+ if (publishRepo) {
660
+ fs.mkdirSync(publishRepo.parentDir, { recursive: true });
661
+ tx.track(beginReplaceDir(publishRepo.stagingDir, repoDir));
662
+ }
663
+
664
+ const commitHash = getCommitHash(repoDir);
665
+ for (const plugin of plugins) {
666
+ const linkPath = path.join(pluginsDir, plugin.name);
667
+ const subDir = path.join(repoDir, plugin.subPath);
668
+ tx.track(beginReplaceSymlink(subDir, linkPath));
669
+ }
670
+
671
+ writeLock?.(commitHash);
672
+ });
673
+ }
674
+
193
675
  /**
194
676
  * Install a plugin from a source.
195
677
  * Supports:
196
678
  * "github:user/repo" — single plugin or full monorepo
197
679
  * "github:user/repo/subplugin" — specific sub-plugin from a monorepo
198
680
  * "https://github.com/user/repo"
681
+ * "file:///absolute/path" — local plugin directory (symlinked)
682
+ * "/absolute/path" — local plugin directory (symlinked)
199
683
  *
200
684
  * Returns the installed plugin name(s).
201
685
  */
@@ -207,24 +691,22 @@ export function installPlugin(source: string): string | string[] {
207
691
  `Supported formats:\n` +
208
692
  ` github:user/repo\n` +
209
693
  ` github:user/repo/subplugin\n` +
210
- ` https://github.com/user/repo`
694
+ ` https://github.com/user/repo\n` +
695
+ ` https://<host>/<path>/repo.git\n` +
696
+ ` ssh://git@<host>/<path>/repo.git\n` +
697
+ ` git@<host>:user/repo.git\n` +
698
+ ` file:///absolute/path\n` +
699
+ ` /absolute/path`
211
700
  );
212
701
  }
213
702
 
214
- const { cloneUrl, name: repoName, subPlugin } = parsed;
703
+ const { name: repoName, subPlugin } = parsed;
215
704
 
216
- // Clone to a temporary location first so we can inspect the manifest
217
- const tmpCloneDir = path.join(os.tmpdir(), `opencli-clone-${Date.now()}`);
218
- try {
219
- execFileSync('git', ['clone', '--depth', '1', cloneUrl, tmpCloneDir], {
220
- encoding: 'utf-8',
221
- stdio: ['pipe', 'pipe', 'pipe'],
222
- });
223
- } catch (err) {
224
- throw new Error(`Failed to clone plugin: ${getErrorMessage(err)}`);
705
+ if (parsed.type === 'local') {
706
+ return installLocalPlugin(parsed.localPath!, repoName);
225
707
  }
226
708
 
227
- try {
709
+ return withTempClone(parsed.cloneUrl!, (tmpCloneDir) => {
228
710
  const manifest = readPluginManifest(tmpCloneDir);
229
711
 
230
712
  // Check top-level compatibility
@@ -235,15 +717,12 @@ export function installPlugin(source: string): string | string[] {
235
717
  }
236
718
 
237
719
  if (manifest && isMonorepo(manifest)) {
238
- return installMonorepo(tmpCloneDir, cloneUrl, repoName, manifest, subPlugin);
720
+ return installMonorepo(tmpCloneDir, parsed.cloneUrl!, repoName, manifest, subPlugin);
239
721
  }
240
722
 
241
723
  // Single plugin mode
242
- return installSinglePlugin(tmpCloneDir, cloneUrl, repoName, manifest);
243
- } finally {
244
- // Clean up temp clone (may already have been moved)
245
- try { fs.rmSync(tmpCloneDir, { recursive: true, force: true }); } catch {}
246
- }
724
+ return installSinglePlugin(tmpCloneDir, parsed.cloneUrl!, repoName, manifest);
725
+ });
247
726
  }
248
727
 
249
728
  /** Install a single (non-monorepo) plugin. */
@@ -260,30 +739,100 @@ function installSinglePlugin(
260
739
  throw new Error(`Plugin "${pluginName}" is already installed at ${targetDir}`);
261
740
  }
262
741
 
263
- const validation = validatePluginStructure(cloneDir);
742
+ ensureStandalonePluginReady(cloneDir);
743
+ publishStandalonePlugin(cloneDir, targetDir, (commitHash) => {
744
+ const lock = readLockFile();
745
+ if (commitHash) {
746
+ upsertLockEntry(lock, pluginName, {
747
+ source: { kind: 'git', url: cloneUrl },
748
+ commitHash,
749
+ });
750
+ writeLockFile(lock);
751
+ }
752
+ });
753
+
754
+ return pluginName;
755
+ }
756
+
757
+ /**
758
+ * Install a local plugin by creating a symlink.
759
+ * Used for plugin development: the source directory is symlinked into
760
+ * the plugins dir so changes are reflected immediately.
761
+ */
762
+ function installLocalPlugin(localPath: string, name: string): string {
763
+ if (!fs.existsSync(localPath)) {
764
+ throw new Error(`Local plugin path does not exist: ${localPath}`);
765
+ }
766
+
767
+ const stat = fs.statSync(localPath);
768
+ if (!stat.isDirectory()) {
769
+ throw new Error(`Local plugin path is not a directory: ${localPath}`);
770
+ }
771
+
772
+ const manifest = readPluginManifest(localPath);
773
+
774
+ if (manifest?.opencli && !checkCompatibility(manifest.opencli)) {
775
+ throw new Error(
776
+ `Plugin requires opencli ${manifest.opencli}, but current version is incompatible.`
777
+ );
778
+ }
779
+
780
+ const pluginName = manifest?.name ?? name;
781
+ const targetDir = path.join(PLUGINS_DIR, pluginName);
782
+
783
+ if (fs.existsSync(targetDir)) {
784
+ throw new Error(`Plugin "${pluginName}" is already installed at ${targetDir}`);
785
+ }
786
+
787
+ const validation = validatePluginStructure(localPath);
264
788
  if (!validation.valid) {
265
789
  throw new Error(`Invalid plugin structure:\n- ${validation.errors.join('\n- ')}`);
266
790
  }
267
791
 
268
792
  fs.mkdirSync(PLUGINS_DIR, { recursive: true });
269
- fs.renameSync(cloneDir, targetDir);
270
793
 
271
- postInstallLifecycle(targetDir);
794
+ const resolvedPath = path.resolve(localPath);
795
+ const linkType = isWindows ? 'junction' : 'dir';
796
+ fs.symlinkSync(resolvedPath, targetDir, linkType);
272
797
 
273
- const commitHash = getCommitHash(targetDir);
274
- if (commitHash) {
275
- const lock = readLockFile();
276
- lock[pluginName] = {
277
- source: cloneUrl,
278
- commitHash,
279
- installedAt: new Date().toISOString(),
280
- };
281
- writeLockFile(lock);
282
- }
798
+ installDependencies(localPath);
799
+ finalizePluginRuntime(localPath);
800
+
801
+ const lock = readLockFile();
802
+ const commitHash = getCommitHash(localPath);
803
+ upsertLockEntry(lock, pluginName, {
804
+ source: { kind: 'local', path: resolvedPath },
805
+ commitHash: commitHash ?? 'local',
806
+ });
807
+ writeLockFile(lock);
283
808
 
284
809
  return pluginName;
285
810
  }
286
811
 
812
+ function updateLocalPlugin(
813
+ name: string,
814
+ targetDir: string,
815
+ lock: Record<string, LockEntry>,
816
+ lockEntry?: LockEntry,
817
+ ): void {
818
+ const pluginDir = fs.realpathSync(targetDir);
819
+
820
+ const validation = validatePluginStructure(pluginDir);
821
+ if (!validation.valid) {
822
+ log.warn(`Plugin "${name}" structure invalid:\n- ${validation.errors.join('\n- ')}`);
823
+ }
824
+
825
+ postInstallLifecycle(pluginDir);
826
+
827
+ upsertLockEntry(lock, name, {
828
+ source: lockEntry?.source ?? { kind: 'local', path: pluginDir },
829
+ commitHash: getCommitHash(pluginDir) ?? 'local',
830
+ installedAt: lockEntry?.installedAt ?? new Date().toISOString(),
831
+ updatedAt: new Date().toISOString(),
832
+ });
833
+ writeLockFile(lock);
834
+ }
835
+
287
836
  /** Install sub-plugins from a monorepo. */
288
837
  function installMonorepo(
289
838
  cloneDir: string,
@@ -294,34 +843,34 @@ function installMonorepo(
294
843
  ): string[] {
295
844
  const monoreposDir = getMonoreposDir();
296
845
  const repoDir = path.join(monoreposDir, repoName);
846
+ const repoAlreadyInstalled = fs.existsSync(repoDir);
847
+ const repoRoot = repoAlreadyInstalled ? repoDir : cloneDir;
848
+ const effectiveManifest = repoAlreadyInstalled ? readPluginManifest(repoDir) : manifest;
297
849
 
298
- // Move clone to permanent monorepos location (if not already there)
299
- if (!fs.existsSync(repoDir)) {
300
- fs.mkdirSync(monoreposDir, { recursive: true });
301
- fs.renameSync(cloneDir, repoDir);
850
+ if (!effectiveManifest || !isMonorepo(effectiveManifest)) {
851
+ throw new Error(`Monorepo manifest missing or invalid at ${repoRoot}`);
302
852
  }
303
853
 
304
- let pluginsToInstall = getEnabledPlugins(manifest);
854
+ let pluginsToInstall = getEnabledPlugins(effectiveManifest);
305
855
 
306
856
  // If a specific sub-plugin was requested, filter to just that one
307
857
  if (subPlugin) {
308
858
  pluginsToInstall = pluginsToInstall.filter((p) => p.name === subPlugin);
309
859
  if (pluginsToInstall.length === 0) {
310
860
  // Check if it exists but is disabled
311
- const disabled = manifest.plugins?.[subPlugin];
861
+ const disabled = effectiveManifest.plugins?.[subPlugin];
312
862
  if (disabled) {
313
863
  throw new Error(`Sub-plugin "${subPlugin}" is disabled in the manifest.`);
314
864
  }
315
865
  throw new Error(
316
- `Sub-plugin "${subPlugin}" not found in monorepo. Available: ${Object.keys(manifest.plugins ?? {}).join(', ')}`
866
+ `Sub-plugin "${subPlugin}" not found in monorepo. Available: ${Object.keys(effectiveManifest.plugins ?? {}).join(', ')}`
317
867
  );
318
868
  }
319
869
  }
320
870
 
321
871
  const installedNames: string[] = [];
322
872
  const lock = readLockFile();
323
- const commitHash = getCommitHash(repoDir);
324
- const eligiblePlugins: Array<{ name: string; entry: typeof pluginsToInstall[number]['entry']; subDir: string }> = [];
873
+ const eligiblePlugins: Array<{ name: string; entry: typeof pluginsToInstall[number]['entry'] }> = [];
325
874
 
326
875
  fs.mkdirSync(PLUGINS_DIR, { recursive: true });
327
876
 
@@ -332,7 +881,7 @@ function installMonorepo(
332
881
  continue;
333
882
  }
334
883
 
335
- const subDir = path.join(repoDir, entry.path);
884
+ const subDir = path.join(repoRoot, entry.path);
336
885
  if (!fs.existsSync(subDir)) {
337
886
  log.warn(`Skipping "${name}": path "${entry.path}" not found in repo.`);
338
887
  continue;
@@ -350,34 +899,128 @@ function installMonorepo(
350
899
  continue;
351
900
  }
352
901
 
353
- eligiblePlugins.push({ name, entry, subDir });
902
+ eligiblePlugins.push({ name, entry });
354
903
  }
355
904
 
356
- if (eligiblePlugins.length > 0) {
357
- postInstallMonorepoLifecycle(repoDir, eligiblePlugins.map((p) => p.subDir));
905
+ if (eligiblePlugins.length === 0) {
906
+ return installedNames;
358
907
  }
359
908
 
360
- for (const { name, entry, subDir } of eligiblePlugins) {
361
- const linkPath = path.join(PLUGINS_DIR, name);
909
+ const publishPlugins = eligiblePlugins.map(({ name, entry }) => ({ name, subPath: entry.path }));
362
910
 
363
- // Create symlink (junction on Windows)
364
- const linkType = isWindows ? 'junction' : 'dir';
365
- fs.symlinkSync(subDir, linkPath, linkType);
911
+ if (repoAlreadyInstalled) {
912
+ postInstallMonorepoLifecycle(repoDir, eligiblePlugins.map((p) => path.join(repoDir, p.entry.path)));
913
+ } else {
914
+ postInstallMonorepoLifecycle(cloneDir, eligiblePlugins.map((p) => path.join(cloneDir, p.entry.path)));
915
+ }
366
916
 
367
- if (commitHash) {
368
- lock[name] = {
369
- source: cloneUrl,
370
- commitHash,
371
- installedAt: new Date().toISOString(),
372
- monorepo: { name: repoName, subPath: entry.path },
373
- };
917
+ publishMonorepoPlugins(
918
+ repoDir,
919
+ PLUGINS_DIR,
920
+ publishPlugins,
921
+ repoAlreadyInstalled ? undefined : { stagingDir: cloneDir, parentDir: monoreposDir },
922
+ (commitHash) => {
923
+ for (const { name, entry } of eligiblePlugins) {
924
+ if (commitHash) {
925
+ upsertLockEntry(lock, name, {
926
+ source: {
927
+ kind: 'monorepo',
928
+ url: cloneUrl,
929
+ repoName,
930
+ subPath: entry.path,
931
+ },
932
+ commitHash,
933
+ });
934
+ }
935
+ installedNames.push(name);
936
+ }
937
+ writeLockFile(lock);
938
+ },
939
+ );
940
+
941
+ return installedNames;
942
+ }
943
+
944
+ function collectUpdatedMonorepoPlugins(
945
+ monoName: string,
946
+ lock: Record<string, LockEntry>,
947
+ manifest: PluginManifest,
948
+ cloneUrl: string,
949
+ tmpCloneDir: string,
950
+ ): Array<{
951
+ name: string;
952
+ lockEntry: LockEntry;
953
+ manifestEntry: NonNullable<PluginManifest['plugins']>[string];
954
+ }> {
955
+ const updatedPlugins: Array<{
956
+ name: string;
957
+ lockEntry: LockEntry;
958
+ manifestEntry: NonNullable<PluginManifest['plugins']>[string];
959
+ }> = [];
960
+
961
+ for (const [pluginName, entry] of Object.entries(lock)) {
962
+ if (entry.source.kind !== 'monorepo' || entry.source.repoName !== monoName) continue;
963
+ const manifestEntry = manifest.plugins?.[pluginName];
964
+ if (!manifestEntry || manifestEntry.disabled) {
965
+ throw new Error(`Installed sub-plugin "${pluginName}" no longer exists in ${cloneUrl}`);
966
+ }
967
+ if (manifestEntry.opencli && !checkCompatibility(manifestEntry.opencli)) {
968
+ throw new Error(`Sub-plugin "${pluginName}" requires opencli ${manifestEntry.opencli}`);
374
969
  }
375
970
 
376
- installedNames.push(name);
971
+ const subDir = path.join(tmpCloneDir, manifestEntry.path);
972
+ const validation = validatePluginStructure(subDir);
973
+ if (!validation.valid) {
974
+ throw new Error(`Updated sub-plugin "${pluginName}" is invalid:\n- ${validation.errors.join('\n- ')}`);
975
+ }
976
+ updatedPlugins.push({ name: pluginName, lockEntry: entry, manifestEntry });
377
977
  }
378
978
 
379
- writeLockFile(lock);
380
- return installedNames;
979
+ return updatedPlugins;
980
+ }
981
+
982
+ function updateMonorepoLockEntries(
983
+ lock: Record<string, LockEntry>,
984
+ plugins: Array<{
985
+ name: string;
986
+ lockEntry: LockEntry;
987
+ manifestEntry: NonNullable<PluginManifest['plugins']>[string];
988
+ }>,
989
+ cloneUrl: string,
990
+ monoName: string,
991
+ commitHash: string | undefined,
992
+ ): void {
993
+ for (const plugin of plugins) {
994
+ if (!commitHash) continue;
995
+ upsertLockEntry(lock, plugin.name, {
996
+ ...plugin.lockEntry,
997
+ source: {
998
+ kind: 'monorepo',
999
+ url: cloneUrl,
1000
+ repoName: monoName,
1001
+ subPath: plugin.manifestEntry.path,
1002
+ },
1003
+ commitHash,
1004
+ updatedAt: new Date().toISOString(),
1005
+ });
1006
+ }
1007
+ }
1008
+
1009
+ function updateStandaloneLockEntry(
1010
+ lock: Record<string, LockEntry>,
1011
+ name: string,
1012
+ cloneUrl: string,
1013
+ existing: LockEntry | undefined,
1014
+ commitHash: string | undefined,
1015
+ ): void {
1016
+ if (!commitHash) return;
1017
+
1018
+ upsertLockEntry(lock, name, {
1019
+ source: { kind: 'git', url: cloneUrl },
1020
+ commitHash,
1021
+ installedAt: existing?.installedAt ?? new Date().toISOString(),
1022
+ updatedAt: new Date().toISOString(),
1023
+ });
381
1024
  }
382
1025
 
383
1026
  /**
@@ -405,11 +1048,11 @@ export function uninstallPlugin(name: string): void {
405
1048
  }
406
1049
 
407
1050
  // Clean up monorepo directory if no more sub-plugins reference it
408
- if (lockEntry?.monorepo) {
1051
+ if (lockEntry?.source.kind === 'monorepo') {
409
1052
  delete lock[name];
410
- const monoName = lockEntry.monorepo.name;
1053
+ const monoName = lockEntry.source.repoName;
411
1054
  const stillReferenced = Object.values(lock).some(
412
- (entry) => entry.monorepo?.name === monoName,
1055
+ (entry) => entry.source.kind === 'monorepo' && entry.source.repoName === monoName,
413
1056
  );
414
1057
  if (!stillReferenced) {
415
1058
  const monoDir = path.join(getMonoreposDir(), monoName);
@@ -444,79 +1087,76 @@ export function updatePlugin(name: string): void {
444
1087
 
445
1088
  const lock = readLockFile();
446
1089
  const lockEntry = lock[name];
1090
+ const source = resolvePluginSource(lockEntry, targetDir);
447
1091
 
448
- if (lockEntry?.monorepo) {
449
- // Monorepo update: pull the repo root
450
- const monoDir = path.join(getMonoreposDir(), lockEntry.monorepo.name);
451
- try {
452
- execFileSync('git', ['pull', '--ff-only'], {
453
- cwd: monoDir,
454
- encoding: 'utf-8',
455
- stdio: ['pipe', 'pipe', 'pipe'],
456
- });
457
- } catch (err) {
458
- throw new Error(`Failed to update monorepo: ${getErrorMessage(err)}`);
459
- }
460
-
461
- // Re-run lifecycle for ALL sub-plugins from this monorepo
462
- const monoName = lockEntry.monorepo.name;
463
- const commitHash = getCommitHash(monoDir);
464
- const pluginDirs: string[] = [];
465
- for (const [pluginName, entry] of Object.entries(lock)) {
466
- if (entry.monorepo?.name !== monoName) continue;
467
- const subDir = path.join(monoDir, entry.monorepo.subPath);
468
- const validation = validatePluginStructure(subDir);
469
- if (!validation.valid) {
470
- log.warn(`Plugin "${pluginName}" structure invalid after update:\n- ${validation.errors.join('\n- ')}`);
471
- }
472
- pluginDirs.push(subDir);
473
- }
474
- if (pluginDirs.length > 0) {
475
- postInstallMonorepoLifecycle(monoDir, pluginDirs);
476
- }
477
- for (const [pluginName, entry] of Object.entries(lock)) {
478
- if (entry.monorepo?.name !== monoName) continue;
479
- if (commitHash) {
480
- lock[pluginName] = {
481
- ...entry,
482
- commitHash,
483
- updatedAt: new Date().toISOString(),
484
- };
485
- }
486
- }
487
- writeLockFile(lock);
1092
+ if (source?.kind === 'local') {
1093
+ updateLocalPlugin(name, targetDir, lock, lockEntry);
488
1094
  return;
489
1095
  }
490
1096
 
491
- // Standard single-plugin update
492
- try {
493
- execFileSync('git', ['pull', '--ff-only'], {
494
- cwd: targetDir,
495
- encoding: 'utf-8',
496
- stdio: ['pipe', 'pipe', 'pipe'],
1097
+ if (source?.kind === 'monorepo') {
1098
+ const monoDir = path.join(getMonoreposDir(), source.repoName);
1099
+ const monoName = source.repoName;
1100
+ const cloneUrl = source.url;
1101
+ withTempClone(cloneUrl, (tmpCloneDir) => {
1102
+ const manifest = readPluginManifest(tmpCloneDir);
1103
+ if (!manifest || !isMonorepo(manifest)) {
1104
+ throw new Error(`Updated source is no longer a monorepo: ${cloneUrl}`);
1105
+ }
1106
+
1107
+ if (manifest.opencli && !checkCompatibility(manifest.opencli)) {
1108
+ throw new Error(
1109
+ `Plugin requires opencli ${manifest.opencli}, but current version is incompatible.`
1110
+ );
1111
+ }
1112
+
1113
+ const updatedPlugins = collectUpdatedMonorepoPlugins(
1114
+ monoName,
1115
+ lock,
1116
+ manifest,
1117
+ cloneUrl,
1118
+ tmpCloneDir,
1119
+ );
1120
+
1121
+ if (updatedPlugins.length > 0) {
1122
+ postInstallMonorepoLifecycle(tmpCloneDir, updatedPlugins.map((plugin) => path.join(tmpCloneDir, plugin.manifestEntry.path)));
1123
+ }
1124
+
1125
+ publishMonorepoPlugins(
1126
+ monoDir,
1127
+ PLUGINS_DIR,
1128
+ updatedPlugins.map((plugin) => ({ name: plugin.name, subPath: plugin.manifestEntry.path })),
1129
+ { stagingDir: tmpCloneDir, parentDir: path.dirname(monoDir) },
1130
+ (commitHash) => {
1131
+ updateMonorepoLockEntries(lock, updatedPlugins, cloneUrl, monoName, commitHash);
1132
+ writeLockFile(lock);
1133
+ },
1134
+ );
497
1135
  });
498
- } catch (err) {
499
- throw new Error(`Failed to update plugin: ${getErrorMessage(err)}`);
1136
+ return;
500
1137
  }
501
1138
 
502
- const validation = validatePluginStructure(targetDir);
503
- if (!validation.valid) {
504
- log.warn(`Plugin "${name}" updated, but structure is now invalid:\n- ${validation.errors.join('\n- ')}`);
505
- }
1139
+ const cloneUrl = resolveRemotePluginSource(lockEntry, targetDir);
1140
+ withTempClone(cloneUrl, (tmpCloneDir) => {
1141
+ const manifest = readPluginManifest(tmpCloneDir);
1142
+ if (manifest && isMonorepo(manifest)) {
1143
+ throw new Error(`Updated source is now a monorepo: ${cloneUrl}`);
1144
+ }
506
1145
 
507
- postInstallLifecycle(targetDir);
1146
+ if (manifest?.opencli && !checkCompatibility(manifest.opencli)) {
1147
+ throw new Error(
1148
+ `Plugin requires opencli ${manifest.opencli}, but current version is incompatible.`
1149
+ );
1150
+ }
508
1151
 
509
- const commitHash = getCommitHash(targetDir);
510
- if (commitHash) {
511
- const existing = lock[name];
512
- lock[name] = {
513
- source: existing?.source ?? getPluginSource(targetDir) ?? '',
514
- commitHash,
515
- installedAt: existing?.installedAt ?? new Date().toISOString(),
516
- updatedAt: new Date().toISOString(),
517
- };
518
- writeLockFile(lock);
519
- }
1152
+ ensureStandalonePluginReady(tmpCloneDir);
1153
+ publishStandalonePlugin(tmpCloneDir, targetDir, (commitHash) => {
1154
+ updateStandaloneLockEntry(lock, name, cloneUrl, lock[name], commitHash);
1155
+ if (commitHash) {
1156
+ writeLockFile(lock);
1157
+ }
1158
+ });
1159
+ });
520
1160
  }
521
1161
 
522
1162
  export interface UpdateResult {
@@ -569,8 +1209,8 @@ export function listPlugins(): PluginInfo[] {
569
1209
  // For monorepo sub-plugins, also check the monorepo root manifest
570
1210
  let description = manifest?.description;
571
1211
  let version = manifest?.version;
572
- if (lockEntry?.monorepo && !description) {
573
- const monoDir = path.join(getMonoreposDir(), lockEntry.monorepo.name);
1212
+ if (lockEntry?.source.kind === 'monorepo' && !description) {
1213
+ const monoDir = path.join(getMonoreposDir(), lockEntry.source.repoName);
574
1214
  const monoManifest = readPluginManifest(monoDir);
575
1215
  const subEntry = monoManifest?.plugins?.[entry.name];
576
1216
  if (subEntry) {
@@ -579,9 +1219,7 @@ export function listPlugins(): PluginInfo[] {
579
1219
  }
580
1220
  }
581
1221
 
582
- const source = lockEntry?.monorepo
583
- ? lockEntry.source
584
- : getPluginSource(pluginDir);
1222
+ const source = resolveStoredPluginSource(lockEntry, pluginDir);
585
1223
 
586
1224
  plugins.push({
587
1225
  name: entry.name,
@@ -590,7 +1228,7 @@ export function listPlugins(): PluginInfo[] {
590
1228
  source,
591
1229
  version: version ?? lockEntry?.commitHash?.slice(0, 7),
592
1230
  installedAt: lockEntry?.installedAt,
593
- monorepoName: lockEntry?.monorepo?.name,
1231
+ monorepoName: lockEntry?.source.kind === 'monorepo' ? lockEntry.source.repoName : undefined,
594
1232
  description,
595
1233
  });
596
1234
  }
@@ -633,7 +1271,29 @@ function getPluginSource(dir: string): string | undefined {
633
1271
  /** Parse a plugin source string into clone URL, repo name, and optional sub-plugin. */
634
1272
  function parseSource(
635
1273
  source: string,
636
- ): { cloneUrl: string; name: string; subPlugin?: string } | null {
1274
+ ): ParsedSource | null {
1275
+ if (source.startsWith('file://')) {
1276
+ try {
1277
+ const localPath = path.resolve(fileURLToPath(source));
1278
+ return {
1279
+ type: 'local',
1280
+ localPath,
1281
+ name: path.basename(localPath).replace(/^opencli-plugin-/, ''),
1282
+ };
1283
+ } catch {
1284
+ return null;
1285
+ }
1286
+ }
1287
+
1288
+ if (path.isAbsolute(source)) {
1289
+ const localPath = path.resolve(source);
1290
+ return {
1291
+ type: 'local',
1292
+ localPath,
1293
+ name: path.basename(localPath).replace(/^opencli-plugin-/, ''),
1294
+ };
1295
+ }
1296
+
637
1297
  // github:user/repo/subplugin (monorepo specific sub-plugin)
638
1298
  const githubSubMatch = source.match(
639
1299
  /^github:([\w.-]+)\/([\w.-]+)\/([\w.-]+)$/,
@@ -642,6 +1302,7 @@ function parseSource(
642
1302
  const [, user, repo, sub] = githubSubMatch;
643
1303
  const name = repo.replace(/^opencli-plugin-/, '');
644
1304
  return {
1305
+ type: 'git',
645
1306
  cloneUrl: `https://github.com/${user}/${repo}.git`,
646
1307
  name,
647
1308
  subPlugin: sub,
@@ -654,6 +1315,7 @@ function parseSource(
654
1315
  const [, user, repo] = githubMatch;
655
1316
  const name = repo.replace(/^opencli-plugin-/, '');
656
1317
  return {
1318
+ type: 'git',
657
1319
  cloneUrl: `https://github.com/${user}/${repo}.git`,
658
1320
  name,
659
1321
  };
@@ -667,11 +1329,48 @@ function parseSource(
667
1329
  const [, user, repo] = urlMatch;
668
1330
  const name = repo.replace(/^opencli-plugin-/, '');
669
1331
  return {
1332
+ type: 'git',
670
1333
  cloneUrl: `https://github.com/${user}/${repo}.git`,
671
1334
  name,
672
1335
  };
673
1336
  }
674
1337
 
1338
+ // ── Generic git URL support ─────────────────────────────────────────────
1339
+
1340
+ // ssh://git@host/path/to/repo.git
1341
+ const sshUrlMatch = source.match(/^ssh:\/\/[^/]+\/(.*?)(?:\.git)?$/);
1342
+ if (sshUrlMatch) {
1343
+ const pathPart = sshUrlMatch[1];
1344
+ const segments = pathPart.split('/');
1345
+ const repoSegment = segments.pop()!;
1346
+ const name = repoSegment.replace(/^opencli-plugin-/, '');
1347
+ return { type: 'git', cloneUrl: source, name };
1348
+ }
1349
+
1350
+ // git@host:user/repo.git (SCP-style)
1351
+ const scpMatch = source.match(/^git@[^:]+:(.+?)(?:\.git)?$/);
1352
+ if (scpMatch) {
1353
+ const pathPart = scpMatch[1];
1354
+ const segments = pathPart.split('/');
1355
+ const repoSegment = segments.pop()!;
1356
+ const name = repoSegment.replace(/^opencli-plugin-/, '');
1357
+ return { type: 'git', cloneUrl: source, name };
1358
+ }
1359
+
1360
+ // Generic https/http git URL (non-GitHub hosts)
1361
+ const genericHttpMatch = source.match(
1362
+ /^https?:\/\/[^/]+\/(.+?)(?:\.git)?$/,
1363
+ );
1364
+ if (genericHttpMatch) {
1365
+ const pathPart = genericHttpMatch[1];
1366
+ const segments = pathPart.split('/');
1367
+ const repoSegment = segments.pop()!;
1368
+ const name = repoSegment.replace(/^opencli-plugin-/, '');
1369
+ // Ensure clone URL ends with .git
1370
+ const cloneUrl = source.endsWith('.git') ? source : `${source}.git`;
1371
+ return { type: 'git', cloneUrl, name };
1372
+ }
1373
+
675
1374
  return null;
676
1375
  }
677
1376
 
@@ -776,7 +1475,10 @@ function transpilePluginTs(pluginDir: string): void {
776
1475
  const esbuildBin = resolveEsbuildBin();
777
1476
 
778
1477
  if (!esbuildBin) {
779
- log.debug('esbuild not found in host node_modules, via resolve, or in PATH, skipping TS transpilation');
1478
+ log.warn(
1479
+ 'esbuild not found. TS plugin files will not be transpiled and may fail to load. ' +
1480
+ 'Install esbuild (`npm i -g esbuild`) or ensure it is available in the opencli host node_modules.'
1481
+ );
780
1482
  return;
781
1483
  }
782
1484
 
@@ -804,8 +1506,8 @@ function transpilePluginTs(pluginDir: string): void {
804
1506
  log.warn(`Failed to transpile ${tsFile}: ${getErrorMessage(err)}`);
805
1507
  }
806
1508
  }
807
- } catch {
808
- // Non-fatal: skip transpilation if anything goes wrong
1509
+ } catch (err) {
1510
+ log.warn(`TS transpilation setup failed: ${getErrorMessage(err)}`);
809
1511
  }
810
1512
  }
811
1513
 
@@ -816,9 +1518,20 @@ export {
816
1518
  parseSource as _parseSource,
817
1519
  postInstallMonorepoLifecycle as _postInstallMonorepoLifecycle,
818
1520
  readLockFile as _readLockFile,
1521
+ readLockFileWithWriter as _readLockFileWithWriter,
819
1522
  updateAllPlugins as _updateAllPlugins,
820
1523
  validatePluginStructure as _validatePluginStructure,
821
1524
  writeLockFile as _writeLockFile,
1525
+ writeLockFileWithFs as _writeLockFileWithFs,
822
1526
  isSymlinkSync as _isSymlinkSync,
823
1527
  getMonoreposDir as _getMonoreposDir,
1528
+ installLocalPlugin as _installLocalPlugin,
1529
+ isLocalPluginSource as _isLocalPluginSource,
1530
+ moveDir as _moveDir,
1531
+ promoteDir as _promoteDir,
1532
+ replaceDir as _replaceDir,
1533
+ resolvePluginSource as _resolvePluginSource,
1534
+ resolveStoredPluginSource as _resolveStoredPluginSource,
1535
+ toStoredPluginSource as _toStoredPluginSource,
1536
+ toLocalPluginSource as _toLocalPluginSource,
824
1537
  };