@oh-my-pi/cli 0.3.0 → 0.5.0

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/README.md +79 -84
  2. package/dist/cli.js +5025 -1016
  3. package/dist/commands/config.d.ts +27 -0
  4. package/dist/commands/config.d.ts.map +1 -1
  5. package/dist/commands/create.d.ts.map +1 -1
  6. package/dist/commands/doctor.d.ts +2 -0
  7. package/dist/commands/doctor.d.ts.map +1 -1
  8. package/dist/commands/env.d.ts.map +1 -1
  9. package/dist/commands/features.d.ts.map +1 -1
  10. package/dist/commands/info.d.ts.map +1 -1
  11. package/dist/commands/init.d.ts.map +1 -1
  12. package/dist/commands/install.d.ts +6 -0
  13. package/dist/commands/install.d.ts.map +1 -1
  14. package/dist/commands/link.d.ts +1 -0
  15. package/dist/commands/link.d.ts.map +1 -1
  16. package/dist/commands/list.d.ts.map +1 -1
  17. package/dist/commands/outdated.d.ts.map +1 -1
  18. package/dist/commands/search.d.ts.map +1 -1
  19. package/dist/commands/uninstall.d.ts +3 -0
  20. package/dist/commands/uninstall.d.ts.map +1 -1
  21. package/dist/commands/update.d.ts +1 -0
  22. package/dist/commands/update.d.ts.map +1 -1
  23. package/dist/commands/why.d.ts.map +1 -1
  24. package/dist/conflicts.d.ts +7 -2
  25. package/dist/conflicts.d.ts.map +1 -1
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/lock.d.ts.map +1 -1
  29. package/dist/lockfile.d.ts +24 -3
  30. package/dist/lockfile.d.ts.map +1 -1
  31. package/dist/manifest.d.ts +12 -1
  32. package/dist/manifest.d.ts.map +1 -1
  33. package/dist/npm.d.ts +11 -0
  34. package/dist/npm.d.ts.map +1 -1
  35. package/dist/output.d.ts +51 -0
  36. package/dist/output.d.ts.map +1 -0
  37. package/dist/paths.d.ts +5 -0
  38. package/dist/paths.d.ts.map +1 -1
  39. package/dist/progress.d.ts +78 -0
  40. package/dist/progress.d.ts.map +1 -0
  41. package/dist/runtime.d.ts.map +1 -1
  42. package/dist/symlinks.d.ts +1 -0
  43. package/dist/symlinks.d.ts.map +1 -1
  44. package/package.json +24 -10
  45. package/.github/icon.png +0 -0
  46. package/.github/logo.png +0 -0
  47. package/.github/workflows/ci.yml +0 -32
  48. package/.github/workflows/publish.yml +0 -42
  49. package/biome.json +0 -29
  50. package/bun.lock +0 -109
  51. package/plugins/exa/README.md +0 -153
  52. package/plugins/exa/package.json +0 -56
  53. package/plugins/exa/tools/exa/company.ts +0 -35
  54. package/plugins/exa/tools/exa/index.ts +0 -66
  55. package/plugins/exa/tools/exa/linkedin.ts +0 -35
  56. package/plugins/exa/tools/exa/researcher.ts +0 -40
  57. package/plugins/exa/tools/exa/runtime.json +0 -4
  58. package/plugins/exa/tools/exa/search.ts +0 -46
  59. package/plugins/exa/tools/exa/shared.ts +0 -230
  60. package/plugins/exa/tools/exa/websets.ts +0 -62
  61. package/plugins/metal-theme/README.md +0 -13
  62. package/plugins/metal-theme/omp.json +0 -8
  63. package/plugins/metal-theme/package.json +0 -19
  64. package/plugins/metal-theme/themes/metal.json +0 -79
  65. package/plugins/subagents/README.md +0 -25
  66. package/plugins/subagents/agents/explore.md +0 -71
  67. package/plugins/subagents/agents/planner.md +0 -51
  68. package/plugins/subagents/agents/reviewer.md +0 -53
  69. package/plugins/subagents/agents/task.md +0 -46
  70. package/plugins/subagents/commands/architect-plan.md +0 -9
  71. package/plugins/subagents/commands/implement-with-critic.md +0 -10
  72. package/plugins/subagents/commands/implement.md +0 -10
  73. package/plugins/subagents/omp.json +0 -15
  74. package/plugins/subagents/package.json +0 -26
  75. package/plugins/subagents/tools/task/index.ts +0 -1019
  76. package/plugins/user-prompt/README.md +0 -130
  77. package/plugins/user-prompt/package.json +0 -19
  78. package/plugins/user-prompt/tools/user-prompt/index.ts +0 -235
  79. package/scripts/bump-version.sh +0 -52
  80. package/scripts/publish.sh +0 -35
  81. package/src/cli.ts +0 -242
  82. package/src/commands/config.ts +0 -384
  83. package/src/commands/create.ts +0 -203
  84. package/src/commands/doctor.ts +0 -305
  85. package/src/commands/enable.ts +0 -122
  86. package/src/commands/env.ts +0 -38
  87. package/src/commands/features.ts +0 -295
  88. package/src/commands/info.ts +0 -120
  89. package/src/commands/init.ts +0 -60
  90. package/src/commands/install.ts +0 -700
  91. package/src/commands/link.ts +0 -159
  92. package/src/commands/list.ts +0 -186
  93. package/src/commands/outdated.ts +0 -87
  94. package/src/commands/search.ts +0 -77
  95. package/src/commands/uninstall.ts +0 -124
  96. package/src/commands/update.ts +0 -170
  97. package/src/commands/why.ts +0 -136
  98. package/src/conflicts.ts +0 -116
  99. package/src/errors.ts +0 -22
  100. package/src/index.ts +0 -46
  101. package/src/lock.ts +0 -46
  102. package/src/lockfile.ts +0 -132
  103. package/src/manifest.ts +0 -360
  104. package/src/npm.ts +0 -206
  105. package/src/paths.ts +0 -137
  106. package/src/runtime.ts +0 -116
  107. package/src/symlinks.ts +0 -455
  108. package/tsconfig.json +0 -28
@@ -1,700 +0,0 @@
1
- import { execFileSync } from "node:child_process";
2
- import { existsSync } from "node:fs";
3
- import { cp, mkdir, readFile, rm } from "node:fs/promises";
4
- import { basename, join, resolve } from "node:path";
5
- import { createInterface } from "node:readline";
6
- import { type Conflict, detectConflicts, detectIntraPluginDuplicates, formatConflicts } from "@omp/conflicts";
7
- import { updateLockFile } from "@omp/lockfile";
8
- import {
9
- getInstalledPlugins,
10
- initGlobalPlugins,
11
- initProjectPlugins,
12
- loadPluginsJson,
13
- type PluginConfig,
14
- type PluginPackageJson,
15
- type PluginsJson,
16
- readPluginPackageJson,
17
- savePluginsJson,
18
- } from "@omp/manifest";
19
- import { npmInfo, npmInstall } from "@omp/npm";
20
- import {
21
- NODE_MODULES_DIR,
22
- PI_CONFIG_DIR,
23
- PLUGINS_DIR,
24
- PROJECT_NODE_MODULES,
25
- PROJECT_PI_DIR,
26
- resolveScope,
27
- } from "@omp/paths";
28
- import { createPluginSymlinks, getAllFeatureNames, getDefaultFeatures } from "@omp/symlinks";
29
- import chalk from "chalk";
30
-
31
- /**
32
- * Parsed package specifier with optional features
33
- */
34
- export interface ParsedPackageSpec {
35
- name: string;
36
- version: string;
37
- /** null = not specified, [] = explicit empty, string[] = specific features */
38
- features: string[] | null;
39
- /** true if [*] was used */
40
- allFeatures: boolean;
41
- }
42
-
43
- /**
44
- * Parse package specifier with optional features bracket syntax.
45
- * Examples:
46
- * "exa" -> { name: "exa", version: "latest", features: null }
47
- * "exa@^1.0" -> { name: "exa", version: "^1.0", features: null }
48
- * "exa[search]" -> { name: "exa", version: "latest", features: ["search"] }
49
- * "exa[search,websets]@^1.0" -> { name: "exa", version: "^1.0", features: ["search", "websets"] }
50
- * "@scope/exa[*]" -> { name: "@scope/exa", version: "latest", allFeatures: true }
51
- * "exa[]" -> { name: "exa", version: "latest", features: [] } (no optional features)
52
- */
53
- export function parsePackageSpecWithFeatures(spec: string): ParsedPackageSpec {
54
- // Regex breakdown:
55
- // ^(@?[^@[\]]+) - Capture name (optionally scoped with @, no @ [ or ] in name)
56
- // (?:\[([^\]]*)\])? - Optionally capture features inside []
57
- // (?:@(.+))?$ - Optionally capture version after @
58
- const match = spec.match(/^(@?[^@[\]]+)(?:\[([^\]]*)\])?(?:@(.+))?$/);
59
-
60
- if (!match) {
61
- // Fallback: treat as plain name
62
- return { name: spec, version: "latest", features: null, allFeatures: false };
63
- }
64
-
65
- const [, name, featuresStr, version = "latest"] = match;
66
-
67
- // No bracket at all
68
- if (featuresStr === undefined) {
69
- return { name, version, features: null, allFeatures: false };
70
- }
71
-
72
- // [*] = all features
73
- if (featuresStr === "*") {
74
- return { name, version, features: null, allFeatures: true };
75
- }
76
-
77
- // [] = explicit empty (no optional features, core only)
78
- if (featuresStr === "") {
79
- return { name, version, features: [], allFeatures: false };
80
- }
81
-
82
- // [f1,f2,...] = specific features
83
- const features = featuresStr.split(",").map((f) => f.trim()).filter(Boolean);
84
- return { name, version, features, allFeatures: false };
85
- }
86
-
87
- /**
88
- * Resolve which features to enable based on user request, existing config, and plugin defaults.
89
- *
90
- * Resolution order:
91
- * 1. User explicitly requested [*] -> all features
92
- * 2. User explicitly specified [f1,f2] -> exactly those features
93
- * 3. Reinstall with no bracket -> preserve existing selection
94
- * 4. First install with no bracket -> ALL features
95
- */
96
- export function resolveFeatures(
97
- pkgJson: PluginPackageJson,
98
- requested: ParsedPackageSpec,
99
- existingConfig: PluginConfig | undefined,
100
- isReinstall: boolean,
101
- ): { enabledFeatures: string[]; configToStore: PluginConfig | undefined } {
102
- const pluginFeatures = pkgJson.omp?.features || {};
103
- const allFeatureNames = Object.keys(pluginFeatures);
104
-
105
- // No features defined in plugin -> nothing to configure
106
- if (allFeatureNames.length === 0) {
107
- return { enabledFeatures: [], configToStore: undefined };
108
- }
109
-
110
- // Case 1: User explicitly requested [*] -> all features
111
- if (requested.allFeatures) {
112
- return {
113
- enabledFeatures: allFeatureNames,
114
- configToStore: { features: ["*"] },
115
- };
116
- }
117
-
118
- // Case 2: User explicitly specified features -> use exactly those
119
- if (requested.features !== null) {
120
- // Validate requested features exist
121
- for (const f of requested.features) {
122
- if (!pluginFeatures[f]) {
123
- throw new Error(`Unknown feature "${f}". Available: ${allFeatureNames.join(", ")}`);
124
- }
125
- }
126
- return {
127
- enabledFeatures: requested.features,
128
- configToStore: { features: requested.features },
129
- };
130
- }
131
-
132
- // Case 3: Reinstall with no bracket -> preserve existing config
133
- if (isReinstall && existingConfig?.features !== undefined) {
134
- const storedFeatures = existingConfig.features;
135
-
136
- // null means "first install, used defaults" - recompute defaults in case plugin updated
137
- if (storedFeatures === null) {
138
- return {
139
- enabledFeatures: getDefaultFeatures(pluginFeatures),
140
- configToStore: undefined, // Keep existing
141
- };
142
- }
143
-
144
- // ["*"] means explicitly all
145
- if (Array.isArray(storedFeatures) && storedFeatures.includes("*")) {
146
- return {
147
- enabledFeatures: allFeatureNames,
148
- configToStore: undefined,
149
- };
150
- }
151
-
152
- // Specific features
153
- return {
154
- enabledFeatures: storedFeatures as string[],
155
- configToStore: undefined,
156
- };
157
- }
158
-
159
- // Case 4: First install with no bracket -> use DEFAULT features
160
- if (!isReinstall) {
161
- const defaultFeatures = getDefaultFeatures(pluginFeatures);
162
- return {
163
- enabledFeatures: defaultFeatures,
164
- configToStore: { features: null }, // null = "first install, used defaults"
165
- };
166
- }
167
-
168
- // Case 5: Reinstall, no existing config -> use defaults
169
- return {
170
- enabledFeatures: getDefaultFeatures(pluginFeatures),
171
- configToStore: undefined,
172
- };
173
- }
174
-
175
- /**
176
- * Process omp dependencies recursively with cycle detection.
177
- * Creates symlinks for dependencies that have omp.install entries.
178
- */
179
- async function processOmpDependencies(pkgJson: PluginPackageJson, isGlobal: boolean, seen: Set<string>): Promise<void> {
180
- if (!pkgJson.dependencies) return;
181
-
182
- for (const depName of Object.keys(pkgJson.dependencies)) {
183
- if (seen.has(depName)) {
184
- console.log(chalk.yellow(` Skipping circular dependency: ${depName}`));
185
- continue;
186
- }
187
- seen.add(depName);
188
-
189
- const depPkgJson = await readPluginPackageJson(depName, isGlobal);
190
- if (depPkgJson?.omp?.install) {
191
- console.log(chalk.dim(` Processing dependency: ${depName}`));
192
- await createPluginSymlinks(depName, depPkgJson, isGlobal);
193
- // Recurse into this dependency's dependencies
194
- await processOmpDependencies(depPkgJson, isGlobal, seen);
195
- }
196
- }
197
- }
198
-
199
- export interface InstallOptions {
200
- global?: boolean;
201
- local?: boolean;
202
- save?: boolean;
203
- saveDev?: boolean;
204
- force?: boolean;
205
- json?: boolean;
206
- }
207
-
208
- /**
209
- * Prompt user to choose when there's a conflict
210
- */
211
- async function promptConflictResolution(conflict: Conflict): Promise<number | null> {
212
- const rl = createInterface({
213
- input: process.stdin,
214
- output: process.stdout,
215
- });
216
-
217
- return new Promise((resolve) => {
218
- console.log(chalk.yellow(`\n⚠ Conflict: ${formatConflicts([conflict])[0]}`));
219
- conflict.plugins.forEach((p, i) => {
220
- console.log(` [${i + 1}] ${p.name}`);
221
- });
222
- console.log(` [${conflict.plugins.length + 1}] abort`);
223
-
224
- rl.question(" Choose: ", (answer) => {
225
- rl.close();
226
- const choice = parseInt(answer, 10);
227
- if (choice > 0 && choice <= conflict.plugins.length) {
228
- resolve(choice - 1);
229
- } else {
230
- resolve(null);
231
- }
232
- });
233
- });
234
- }
235
-
236
- /**
237
- * Check if a path looks like a local path
238
- */
239
- function isLocalPath(spec: string): boolean {
240
- return spec.startsWith("/") || spec.startsWith("./") || spec.startsWith("../") || spec.startsWith("~");
241
- }
242
-
243
- /**
244
- * Install plugins from package specifiers
245
- * omp install [pkg...]
246
- */
247
- export async function installPlugin(packages?: string[], options: InstallOptions = {}): Promise<void> {
248
- const isGlobal = resolveScope(options);
249
- const prefix = isGlobal ? PLUGINS_DIR : ".pi";
250
- const _nodeModules = isGlobal ? NODE_MODULES_DIR : PROJECT_NODE_MODULES;
251
-
252
- // Initialize plugins directory if needed
253
- if (isGlobal) {
254
- await initGlobalPlugins();
255
- } else {
256
- // Initialize project .pi directory with both plugins.json and package.json
257
- await initProjectPlugins();
258
- }
259
-
260
- // If no packages specified, install from plugins.json
261
- if (!packages || packages.length === 0) {
262
- const pluginsJson = await loadPluginsJson(isGlobal);
263
- // Prefer locked versions for reproducible installs
264
- const lockFile = await import("@omp/lockfile").then((m) => m.loadLockFile(isGlobal));
265
- packages = await Promise.all(
266
- Object.entries(pluginsJson.plugins).map(async ([name, version]) => {
267
- // Use locked version if available for reproducibility
268
- const lockedVersion = lockFile?.packages[name]?.version;
269
- return `${name}@${lockedVersion || version}`;
270
- }),
271
- );
272
-
273
- if (packages.length === 0) {
274
- console.log(chalk.yellow("No plugins to install."));
275
- console.log(
276
- chalk.dim(isGlobal ? "Add plugins with: omp install <package>" : "Add plugins to .pi/plugins.json"),
277
- );
278
- process.exitCode = 1;
279
- return;
280
- }
281
-
282
- console.log(
283
- chalk.blue(`Installing ${packages.length} plugin(s) from ${isGlobal ? "package.json" : "plugins.json"}...`),
284
- );
285
- }
286
-
287
- // Get existing plugins for conflict detection
288
- const existingPlugins = await getInstalledPlugins(isGlobal);
289
-
290
- const results: Array<{ name: string; version: string; success: boolean; error?: string }> = [];
291
-
292
- // Load plugins.json once for reinstall detection and config storage
293
- let pluginsJson = await loadPluginsJson(isGlobal);
294
-
295
- for (const spec of packages) {
296
- // Check if it's a local path
297
- if (isLocalPath(spec)) {
298
- const result = await installLocalPlugin(spec, isGlobal, options);
299
- results.push(result);
300
- continue;
301
- }
302
-
303
- const parsed = parsePackageSpecWithFeatures(spec);
304
- const { name, version } = parsed;
305
- const pkgSpec = version === "latest" ? name : `${name}@${version}`;
306
-
307
- // Track installation state for rollback
308
- let npmInstallSucceeded = false;
309
- let createdSymlinks: string[] = [];
310
- let resolvedVersion = version;
311
-
312
- // Check if this is a reinstall (plugin already exists)
313
- const isReinstall = existingPlugins.has(name) || !!pluginsJson.plugins[name];
314
- const existingConfig = pluginsJson.config?.[name];
315
-
316
- try {
317
- console.log(chalk.blue(`\nInstalling ${pkgSpec}...`));
318
-
319
- // 1. Resolve version and fetch package metadata from npm registry
320
- // npm info includes omp field if present in package.json
321
- const info = await npmInfo(pkgSpec);
322
- if (!info) {
323
- console.log(chalk.red(` ✗ Package not found: ${name}`));
324
- process.exitCode = 1;
325
- results.push({ name, version, success: false, error: "Package not found" });
326
- continue;
327
- }
328
- resolvedVersion = info.version;
329
-
330
- // 2. Check for conflicts BEFORE npm install using registry metadata
331
- const skipDestinations = new Set<string>();
332
- const preInstallPkgJson = info.omp?.install ? { name: info.name, version: info.version, omp: info.omp } : null;
333
-
334
- if (preInstallPkgJson) {
335
- // Check for intra-plugin duplicates first
336
- const intraDupes = detectIntraPluginDuplicates(preInstallPkgJson);
337
- if (intraDupes.length > 0) {
338
- console.log(chalk.red(` ✗ Plugin has duplicate destinations:`));
339
- for (const dupe of intraDupes) {
340
- console.log(chalk.red(` ${dupe.dest} ← ${dupe.sources.join(", ")}`));
341
- }
342
- process.exitCode = 1;
343
- results.push({ name, version: info.version, success: false, error: "Duplicate destinations in plugin" });
344
- continue;
345
- }
346
-
347
- const preInstallConflicts = detectConflicts(name, preInstallPkgJson, existingPlugins);
348
-
349
- if (preInstallConflicts.length > 0 && !options.force) {
350
- // Check for non-interactive terminal (CI environments)
351
- if (!process.stdout.isTTY || !process.stdin.isTTY) {
352
- console.log(chalk.red("Conflicts detected in non-interactive mode. Use --force to override."));
353
- for (const conflict of preInstallConflicts) {
354
- console.log(chalk.yellow(` ⚠ ${formatConflicts([conflict])[0]}`));
355
- }
356
- process.exitCode = 1;
357
- results.push({
358
- name,
359
- version: info.version,
360
- success: false,
361
- error: "Conflicts in non-interactive mode",
362
- });
363
- continue;
364
- }
365
-
366
- // Handle conflicts BEFORE downloading the package
367
- let abort = false;
368
- for (const conflict of preInstallConflicts) {
369
- const choice = await promptConflictResolution(conflict);
370
- if (choice === null) {
371
- abort = true;
372
- break;
373
- }
374
- // choice is 0-indexed: 0 = first plugin (existing), last index = new plugin
375
- const newPluginIndex = conflict.plugins.length - 1;
376
- if (choice !== newPluginIndex) {
377
- // User chose an existing plugin, skip this destination
378
- skipDestinations.add(conflict.dest);
379
- }
380
- }
381
-
382
- if (abort) {
383
- console.log(chalk.yellow(` Aborted due to conflicts (before download)`));
384
- process.exitCode = 1;
385
- results.push({ name, version: info.version, success: false, error: "Conflicts" });
386
- continue;
387
- }
388
- }
389
- }
390
-
391
- // 3. npm install - only reached if no conflicts or user resolved them
392
- console.log(chalk.dim(` Fetching from npm...`));
393
- await npmInstall([pkgSpec], prefix, { save: options.save || isGlobal });
394
- npmInstallSucceeded = true;
395
-
396
- // 4. Read package.json from installed package
397
- const pkgJson = await readPluginPackageJson(name, isGlobal);
398
- if (!pkgJson) {
399
- console.log(chalk.yellow(` ⚠ Installed but no package.json found`));
400
- results.push({ name, version: info.version, success: true });
401
- continue;
402
- }
403
-
404
- // 5. Re-check conflicts with full package.json if we didn't check pre-install
405
- // This handles edge cases where omp field wasn't in registry metadata
406
- if (!preInstallPkgJson) {
407
- // Check for intra-plugin duplicates first
408
- const intraDupes = detectIntraPluginDuplicates(pkgJson);
409
- if (intraDupes.length > 0) {
410
- console.log(chalk.red(` ✗ Plugin has duplicate destinations:`));
411
- for (const dupe of intraDupes) {
412
- console.log(chalk.red(` ${dupe.dest} ← ${dupe.sources.join(", ")}`));
413
- }
414
- // Rollback: uninstall the package
415
- execFileSync("npm", ["uninstall", "--prefix", prefix, name], { stdio: "pipe" });
416
- process.exitCode = 1;
417
- results.push({ name, version: info.version, success: false, error: "Duplicate destinations in plugin" });
418
- continue;
419
- }
420
-
421
- const conflicts = detectConflicts(name, pkgJson, existingPlugins);
422
-
423
- if (conflicts.length > 0 && !options.force) {
424
- // Check for non-interactive terminal (CI environments)
425
- if (!process.stdout.isTTY || !process.stdin.isTTY) {
426
- console.log(chalk.red("Conflicts detected in non-interactive mode. Use --force to override."));
427
- for (const conflict of conflicts) {
428
- console.log(chalk.yellow(` ⚠ ${formatConflicts([conflict])[0]}`));
429
- }
430
- // Rollback: uninstall the package
431
- execFileSync("npm", ["uninstall", "--prefix", prefix, name], { stdio: "pipe" });
432
- process.exitCode = 1;
433
- results.push({
434
- name,
435
- version: info.version,
436
- success: false,
437
- error: "Conflicts in non-interactive mode",
438
- });
439
- continue;
440
- }
441
-
442
- let abort = false;
443
- for (const conflict of conflicts) {
444
- const choice = await promptConflictResolution(conflict);
445
- if (choice === null) {
446
- abort = true;
447
- break;
448
- }
449
- const newPluginIndex = conflict.plugins.length - 1;
450
- if (choice !== newPluginIndex) {
451
- skipDestinations.add(conflict.dest);
452
- }
453
- }
454
-
455
- if (abort) {
456
- console.log(chalk.yellow(` Aborted due to conflicts`));
457
- // Rollback: uninstall the package
458
- execFileSync("npm", ["uninstall", "--prefix", prefix, name], { stdio: "pipe" });
459
- process.exitCode = 1;
460
- results.push({ name, version: info.version, success: false, error: "Conflicts" });
461
- continue;
462
- }
463
- }
464
- }
465
-
466
- // 6. Resolve features and create symlinks
467
- const { enabledFeatures, configToStore } = resolveFeatures(
468
- pkgJson,
469
- parsed,
470
- existingConfig,
471
- isReinstall,
472
- );
473
-
474
- // Log feature selection if plugin has features
475
- const allFeatureNames = getAllFeatureNames(pkgJson);
476
- if (allFeatureNames.length > 0) {
477
- if (enabledFeatures.length === allFeatureNames.length) {
478
- console.log(chalk.dim(` Features: all (${enabledFeatures.join(", ")})`));
479
- } else if (enabledFeatures.length === 0) {
480
- console.log(chalk.dim(` Features: none (core only)`));
481
- } else {
482
- console.log(chalk.dim(` Features: ${enabledFeatures.join(", ")}`));
483
- }
484
- }
485
-
486
- // Create symlinks for omp.install entries (skip destinations user assigned to existing plugins)
487
- const symlinkResult = await createPluginSymlinks(name, pkgJson, isGlobal, true, skipDestinations, enabledFeatures);
488
- createdSymlinks = symlinkResult.created;
489
-
490
- // 7. Process dependencies with omp field (with cycle detection)
491
- await processOmpDependencies(pkgJson, isGlobal, new Set([name]));
492
-
493
- // 8. Update manifest and config
494
- // For global mode, npm --save already updates package.json dependencies
495
- // but we need to handle devDependencies and config manually
496
- // For project-local mode, we must manually update plugins.json
497
- if (options.save || options.saveDev || configToStore) {
498
- // Reload to avoid stale data if multiple packages are being installed
499
- pluginsJson = await loadPluginsJson(isGlobal);
500
- if (options.saveDev) {
501
- // Save to devDependencies
502
- if (!pluginsJson.devDependencies) {
503
- pluginsJson.devDependencies = {};
504
- }
505
- pluginsJson.devDependencies[name] = info.version;
506
- // Remove from plugins if it was there
507
- delete pluginsJson.plugins[name];
508
- } else if (!isGlobal) {
509
- // Save to plugins (project-local mode only - npm handles global)
510
- pluginsJson.plugins[name] = info.version;
511
- }
512
-
513
- // Store feature config if changed
514
- if (configToStore) {
515
- if (!pluginsJson.config) {
516
- pluginsJson.config = {};
517
- }
518
- pluginsJson.config[name] = {
519
- ...pluginsJson.config[name],
520
- ...configToStore,
521
- };
522
- }
523
-
524
- await savePluginsJson(pluginsJson, isGlobal);
525
- }
526
-
527
- // Add to installed plugins map for subsequent conflict detection
528
- existingPlugins.set(name, pkgJson);
529
-
530
- // Update lock file with exact version
531
- await updateLockFile(name, info.version, isGlobal);
532
-
533
- console.log(chalk.green(`✓ Installed ${name}@${info.version}`));
534
- results.push({ name, version: info.version, success: true });
535
- } catch (err) {
536
- const errorMsg = (err as Error).message;
537
- console.log(chalk.red(` ✗ Failed to install ${name}: ${errorMsg}`));
538
-
539
- // Rollback: remove any symlinks that were created
540
- if (createdSymlinks.length > 0) {
541
- console.log(chalk.dim(" Rolling back symlinks..."));
542
- const baseDir = isGlobal ? PI_CONFIG_DIR : PROJECT_PI_DIR;
543
- for (const dest of createdSymlinks) {
544
- try {
545
- await rm(join(baseDir, dest), { force: true, recursive: true });
546
- } catch {
547
- // Ignore cleanup errors
548
- }
549
- }
550
- }
551
-
552
- // Rollback: uninstall npm package if it was installed
553
- if (npmInstallSucceeded) {
554
- console.log(chalk.dim(" Rolling back npm install..."));
555
- try {
556
- execFileSync("npm", ["uninstall", "--prefix", prefix, name], { stdio: "pipe" });
557
- } catch {
558
- // Ignore cleanup errors
559
- }
560
- }
561
-
562
- process.exitCode = 1;
563
- results.push({ name, version: resolvedVersion, success: false, error: errorMsg });
564
- }
565
- }
566
-
567
- // Summary
568
- const successful = results.filter((r) => r.success);
569
- const failed = results.filter((r) => !r.success);
570
-
571
- console.log();
572
- if (successful.length > 0) {
573
- console.log(chalk.green(`✓ Installed ${successful.length} plugin(s)`));
574
- }
575
- if (failed.length > 0) {
576
- console.log(chalk.red(`✗ Failed to install ${failed.length} plugin(s)`));
577
- process.exitCode = 1;
578
- }
579
-
580
- if (options.json) {
581
- console.log(JSON.stringify({ results }, null, 2));
582
- }
583
- }
584
-
585
- /**
586
- * Install a local plugin (copy or link based on path type)
587
- */
588
- async function installLocalPlugin(
589
- localPath: string,
590
- isGlobal: boolean,
591
- _options: InstallOptions,
592
- ): Promise<{ name: string; version: string; success: boolean; error?: string }> {
593
- // Expand ~ to home directory
594
- if (localPath.startsWith("~")) {
595
- localPath = join(process.env.HOME || "", localPath.slice(1));
596
- }
597
- localPath = resolve(localPath);
598
-
599
- if (!existsSync(localPath)) {
600
- console.log(chalk.red(`Error: Path does not exist: ${localPath}`));
601
- process.exitCode = 1;
602
- return { name: basename(localPath), version: "local", success: false, error: "Path not found" };
603
- }
604
-
605
- const _prefix = isGlobal ? PLUGINS_DIR : ".pi";
606
- const nodeModules = isGlobal ? NODE_MODULES_DIR : PROJECT_NODE_MODULES;
607
-
608
- try {
609
- // Read package.json from local path
610
- const localPkgJsonPath = join(localPath, "package.json");
611
- let pkgJson: PluginPackageJson;
612
-
613
- if (existsSync(localPkgJsonPath)) {
614
- pkgJson = JSON.parse(await readFile(localPkgJsonPath, "utf-8"));
615
- } else {
616
- // Check for omp.json (legacy format)
617
- const ompJsonPath = join(localPath, "omp.json");
618
- if (existsSync(ompJsonPath)) {
619
- const ompJson = JSON.parse(await readFile(ompJsonPath, "utf-8"));
620
- // Convert omp.json to package.json format
621
- pkgJson = {
622
- name: ompJson.name || basename(localPath),
623
- version: ompJson.version || "0.0.0",
624
- description: ompJson.description,
625
- keywords: ["omp-plugin"],
626
- omp: {
627
- install: ompJson.install,
628
- },
629
- };
630
- } else {
631
- pkgJson = {
632
- name: basename(localPath),
633
- version: "0.0.0",
634
- keywords: ["omp-plugin"],
635
- };
636
- }
637
- }
638
-
639
- const pluginName = pkgJson.name;
640
- const pluginDir = join(nodeModules, pluginName);
641
-
642
- // Check for intra-plugin duplicates
643
- const intraDupes = detectIntraPluginDuplicates(pkgJson);
644
- if (intraDupes.length > 0) {
645
- console.log(chalk.red(`\nError: Plugin has duplicate destinations:`));
646
- for (const dupe of intraDupes) {
647
- console.log(chalk.red(` ${dupe.dest} ← ${dupe.sources.join(", ")}`));
648
- }
649
- process.exitCode = 1;
650
- return {
651
- name: pluginName,
652
- version: pkgJson.version,
653
- success: false,
654
- error: "Duplicate destinations in plugin",
655
- };
656
- }
657
-
658
- console.log(chalk.blue(`\nInstalling ${pluginName} from ${localPath}...`));
659
-
660
- // Create node_modules directory
661
- await mkdir(nodeModules, { recursive: true });
662
-
663
- // Remove existing if present
664
- if (existsSync(pluginDir)) {
665
- await rm(pluginDir, { recursive: true, force: true });
666
- }
667
-
668
- // Copy the plugin
669
- await cp(localPath, pluginDir, { recursive: true });
670
- console.log(chalk.dim(` Copied to ${pluginDir}`));
671
-
672
- // Update plugins.json/package.json
673
- const pluginsJson = await loadPluginsJson(isGlobal);
674
- if (_options.saveDev) {
675
- if (!pluginsJson.devDependencies) {
676
- pluginsJson.devDependencies = {};
677
- }
678
- pluginsJson.devDependencies[pluginName] = `file:${localPath}`;
679
- // Remove from plugins if it was there
680
- delete pluginsJson.plugins[pluginName];
681
- } else {
682
- pluginsJson.plugins[pluginName] = `file:${localPath}`;
683
- }
684
- await savePluginsJson(pluginsJson, isGlobal);
685
-
686
- // Create symlinks
687
- await createPluginSymlinks(pluginName, pkgJson, isGlobal);
688
-
689
- // Update lock file for local plugin
690
- await updateLockFile(pluginName, pkgJson.version, isGlobal);
691
-
692
- console.log(chalk.green(`✓ Installed ${pluginName}@${pkgJson.version}`));
693
- return { name: pluginName, version: pkgJson.version, success: true };
694
- } catch (err) {
695
- const errorMsg = (err as Error).message;
696
- console.log(chalk.red(` ✗ Failed: ${errorMsg}`));
697
- process.exitCode = 1;
698
- return { name: basename(localPath), version: "local", success: false, error: errorMsg };
699
- }
700
- }