@oh-my-pi/cli 0.1.0 → 0.3.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 (105) hide show
  1. package/.github/icon.png +0 -0
  2. package/.github/logo.png +0 -0
  3. package/.github/workflows/publish.yml +1 -1
  4. package/LICENSE +21 -0
  5. package/README.md +243 -138
  6. package/biome.json +1 -1
  7. package/bun.lock +59 -0
  8. package/dist/cli.js +6311 -2900
  9. package/dist/commands/config.d.ts +32 -0
  10. package/dist/commands/config.d.ts.map +1 -0
  11. package/dist/commands/create.d.ts.map +1 -1
  12. package/dist/commands/doctor.d.ts +1 -0
  13. package/dist/commands/doctor.d.ts.map +1 -1
  14. package/dist/commands/enable.d.ts +1 -0
  15. package/dist/commands/enable.d.ts.map +1 -1
  16. package/dist/commands/env.d.ts +14 -0
  17. package/dist/commands/env.d.ts.map +1 -0
  18. package/dist/commands/features.d.ts +25 -0
  19. package/dist/commands/features.d.ts.map +1 -0
  20. package/dist/commands/info.d.ts +1 -0
  21. package/dist/commands/info.d.ts.map +1 -1
  22. package/dist/commands/init.d.ts.map +1 -1
  23. package/dist/commands/install.d.ts +37 -0
  24. package/dist/commands/install.d.ts.map +1 -1
  25. package/dist/commands/link.d.ts +2 -0
  26. package/dist/commands/link.d.ts.map +1 -1
  27. package/dist/commands/list.d.ts +1 -0
  28. package/dist/commands/list.d.ts.map +1 -1
  29. package/dist/commands/outdated.d.ts +1 -0
  30. package/dist/commands/outdated.d.ts.map +1 -1
  31. package/dist/commands/search.d.ts.map +1 -1
  32. package/dist/commands/uninstall.d.ts +1 -0
  33. package/dist/commands/uninstall.d.ts.map +1 -1
  34. package/dist/commands/update.d.ts +1 -0
  35. package/dist/commands/update.d.ts.map +1 -1
  36. package/dist/commands/why.d.ts +1 -0
  37. package/dist/commands/why.d.ts.map +1 -1
  38. package/dist/conflicts.d.ts +9 -1
  39. package/dist/conflicts.d.ts.map +1 -1
  40. package/dist/errors.d.ts +8 -0
  41. package/dist/errors.d.ts.map +1 -0
  42. package/dist/index.d.ts +18 -19
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/lock.d.ts +3 -0
  45. package/dist/lock.d.ts.map +1 -0
  46. package/dist/lockfile.d.ts +52 -0
  47. package/dist/lockfile.d.ts.map +1 -0
  48. package/dist/manifest.d.ts +60 -25
  49. package/dist/manifest.d.ts.map +1 -1
  50. package/dist/npm.d.ts +14 -2
  51. package/dist/npm.d.ts.map +1 -1
  52. package/dist/paths.d.ts +34 -3
  53. package/dist/paths.d.ts.map +1 -1
  54. package/dist/runtime.d.ts +14 -0
  55. package/dist/runtime.d.ts.map +1 -0
  56. package/dist/symlinks.d.ts +43 -7
  57. package/dist/symlinks.d.ts.map +1 -1
  58. package/package.json +11 -5
  59. package/plugins/exa/README.md +153 -0
  60. package/plugins/exa/package.json +56 -0
  61. package/plugins/exa/tools/exa/company.ts +35 -0
  62. package/plugins/exa/tools/exa/index.ts +66 -0
  63. package/plugins/exa/tools/exa/linkedin.ts +35 -0
  64. package/plugins/exa/tools/exa/researcher.ts +40 -0
  65. package/plugins/exa/tools/exa/runtime.json +4 -0
  66. package/plugins/exa/tools/exa/search.ts +46 -0
  67. package/plugins/exa/tools/exa/shared.ts +230 -0
  68. package/plugins/exa/tools/exa/websets.ts +62 -0
  69. package/plugins/metal-theme/package.json +7 -2
  70. package/plugins/subagents/package.json +7 -2
  71. package/plugins/user-prompt/README.md +130 -0
  72. package/plugins/user-prompt/package.json +19 -0
  73. package/plugins/user-prompt/tools/user-prompt/index.ts +235 -0
  74. package/src/cli.ts +133 -58
  75. package/src/commands/config.ts +384 -0
  76. package/src/commands/create.ts +51 -1
  77. package/src/commands/doctor.ts +95 -7
  78. package/src/commands/enable.ts +25 -8
  79. package/src/commands/env.ts +38 -0
  80. package/src/commands/features.ts +295 -0
  81. package/src/commands/info.ts +41 -5
  82. package/src/commands/init.ts +20 -2
  83. package/src/commands/install.ts +453 -80
  84. package/src/commands/link.ts +60 -9
  85. package/src/commands/list.ts +122 -7
  86. package/src/commands/outdated.ts +17 -6
  87. package/src/commands/search.ts +20 -3
  88. package/src/commands/uninstall.ts +57 -6
  89. package/src/commands/update.ts +67 -9
  90. package/src/commands/why.ts +47 -16
  91. package/src/conflicts.ts +33 -1
  92. package/src/errors.ts +22 -0
  93. package/src/index.ts +18 -25
  94. package/src/lock.ts +46 -0
  95. package/src/lockfile.ts +132 -0
  96. package/src/manifest.ts +219 -71
  97. package/src/npm.ts +74 -18
  98. package/src/paths.ts +77 -12
  99. package/src/runtime.ts +116 -0
  100. package/src/symlinks.ts +291 -35
  101. package/tsconfig.json +7 -3
  102. package/CHECK.md +0 -352
  103. package/dist/migrate.d.ts +0 -9
  104. package/dist/migrate.d.ts.map +0 -1
  105. package/src/migrate.ts +0 -181
@@ -1,24 +1,204 @@
1
- import { execSync } from "node:child_process";
1
+ import { execFileSync } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import { cp, mkdir, readFile, rm } from "node:fs/promises";
4
4
  import { basename, join, resolve } from "node:path";
5
5
  import { createInterface } from "node:readline";
6
- import chalk from "chalk";
7
- import { type Conflict, detectConflicts, formatConflicts } from "../conflicts.js";
6
+ import { type Conflict, detectConflicts, detectIntraPluginDuplicates, formatConflicts } from "@omp/conflicts";
7
+ import { updateLockFile } from "@omp/lockfile";
8
8
  import {
9
9
  getInstalledPlugins,
10
10
  initGlobalPlugins,
11
+ initProjectPlugins,
11
12
  loadPluginsJson,
13
+ type PluginConfig,
12
14
  type PluginPackageJson,
15
+ type PluginsJson,
13
16
  readPluginPackageJson,
14
17
  savePluginsJson,
15
- } from "../manifest.js";
16
- import { npmInfo, npmInstall } from "../npm.js";
17
- import { NODE_MODULES_DIR, PLUGINS_DIR, PROJECT_NODE_MODULES, PROJECT_PLUGINS_JSON } from "../paths.js";
18
- import { createPluginSymlinks } from "../symlinks.js";
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
+ }
19
198
 
20
199
  export interface InstallOptions {
21
200
  global?: boolean;
201
+ local?: boolean;
22
202
  save?: boolean;
23
203
  saveDev?: boolean;
24
204
  force?: boolean;
@@ -53,34 +233,6 @@ async function promptConflictResolution(conflict: Conflict): Promise<number | nu
53
233
  });
54
234
  }
55
235
 
56
- /**
57
- * Parse package specifier into name and version
58
- */
59
- function parsePackageSpec(spec: string): { name: string; version: string } {
60
- // Handle scoped packages: @scope/name@version
61
- if (spec.startsWith("@")) {
62
- const lastAt = spec.lastIndexOf("@");
63
- if (lastAt > 0) {
64
- return {
65
- name: spec.slice(0, lastAt),
66
- version: spec.slice(lastAt + 1),
67
- };
68
- }
69
- return { name: spec, version: "latest" };
70
- }
71
-
72
- // Handle regular packages: name@version
73
- const atIndex = spec.indexOf("@");
74
- if (atIndex > 0) {
75
- return {
76
- name: spec.slice(0, atIndex),
77
- version: spec.slice(atIndex + 1),
78
- };
79
- }
80
-
81
- return { name: spec, version: "latest" };
82
- }
83
-
84
236
  /**
85
237
  * Check if a path looks like a local path
86
238
  */
@@ -93,7 +245,7 @@ function isLocalPath(spec: string): boolean {
93
245
  * omp install [pkg...]
94
246
  */
95
247
  export async function installPlugin(packages?: string[], options: InstallOptions = {}): Promise<void> {
96
- const isGlobal = options.global !== false; // Default to global
248
+ const isGlobal = resolveScope(options);
97
249
  const prefix = isGlobal ? PLUGINS_DIR : ".pi";
98
250
  const _nodeModules = isGlobal ? NODE_MODULES_DIR : PROJECT_NODE_MODULES;
99
251
 
@@ -101,24 +253,29 @@ export async function installPlugin(packages?: string[], options: InstallOptions
101
253
  if (isGlobal) {
102
254
  await initGlobalPlugins();
103
255
  } else {
104
- // Ensure project .pi directory exists
105
- await mkdir(prefix, { recursive: true });
106
- // Initialize plugins.json if it doesn't exist
107
- if (!existsSync(PROJECT_PLUGINS_JSON)) {
108
- await savePluginsJson({ plugins: {} }, false);
109
- }
256
+ // Initialize project .pi directory with both plugins.json and package.json
257
+ await initProjectPlugins();
110
258
  }
111
259
 
112
260
  // If no packages specified, install from plugins.json
113
261
  if (!packages || packages.length === 0) {
114
262
  const pluginsJson = await loadPluginsJson(isGlobal);
115
- packages = Object.entries(pluginsJson.plugins).map(([name, version]) => `${name}@${version}`);
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
+ );
116
272
 
117
273
  if (packages.length === 0) {
118
274
  console.log(chalk.yellow("No plugins to install."));
119
275
  console.log(
120
276
  chalk.dim(isGlobal ? "Add plugins with: omp install <package>" : "Add plugins to .pi/plugins.json"),
121
277
  );
278
+ process.exitCode = 1;
122
279
  return;
123
280
  }
124
281
 
@@ -132,6 +289,9 @@ export async function installPlugin(packages?: string[], options: InstallOptions
132
289
 
133
290
  const results: Array<{ name: string; version: string; success: boolean; error?: string }> = [];
134
291
 
292
+ // Load plugins.json once for reinstall detection and config storage
293
+ let pluginsJson = await loadPluginsJson(isGlobal);
294
+
135
295
  for (const spec of packages) {
136
296
  // Check if it's a local path
137
297
  if (isLocalPath(spec)) {
@@ -140,27 +300,98 @@ export async function installPlugin(packages?: string[], options: InstallOptions
140
300
  continue;
141
301
  }
142
302
 
143
- const { name, version } = parsePackageSpec(spec);
303
+ const parsed = parsePackageSpecWithFeatures(spec);
304
+ const { name, version } = parsed;
144
305
  const pkgSpec = version === "latest" ? name : `${name}@${version}`;
145
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
+
146
316
  try {
147
317
  console.log(chalk.blue(`\nInstalling ${pkgSpec}...`));
148
318
 
149
- // 1. Resolve version from npm registry
319
+ // 1. Resolve version and fetch package metadata from npm registry
320
+ // npm info includes omp field if present in package.json
150
321
  const info = await npmInfo(pkgSpec);
151
322
  if (!info) {
152
323
  console.log(chalk.red(` ✗ Package not found: ${name}`));
324
+ process.exitCode = 1;
153
325
  results.push({ name, version, success: false, error: "Package not found" });
154
326
  continue;
155
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
+ }
156
346
 
157
- // 2. Check for conflicts before installing
158
- // We need to fetch the package.json to check omp.install
159
- // For now, we'll check after npm install and rollback if needed
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
+ }
160
381
 
161
- // 3. npm install
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
162
392
  console.log(chalk.dim(` Fetching from npm...`));
163
393
  await npmInstall([pkgSpec], prefix, { save: options.save || isGlobal });
394
+ npmInstallSucceeded = true;
164
395
 
165
396
  // 4. Read package.json from installed package
166
397
  const pkgJson = await readPluginPackageJson(name, isGlobal);
@@ -170,55 +401,166 @@ export async function installPlugin(packages?: string[], options: InstallOptions
170
401
  continue;
171
402
  }
172
403
 
173
- // 5. Check for conflicts
174
- const conflicts = detectConflicts(name, pkgJson, existingPlugins);
175
-
176
- if (conflicts.length > 0 && !options.force) {
177
- // Handle conflicts
178
- let abort = false;
179
- for (const conflict of conflicts) {
180
- const choice = await promptConflictResolution(conflict);
181
- if (choice === null) {
182
- abort = true;
183
- break;
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(", ")}`));
184
413
  }
185
- // If user chose the new plugin, we continue
186
- // If user chose existing plugin, we skip this destination
187
- // For now, simplify: if not aborted, force overwrite
188
- }
189
-
190
- if (abort) {
191
- console.log(chalk.yellow(` Aborted due to conflicts`));
192
414
  // Rollback: uninstall the package
193
- execSync(`npm uninstall --prefix ${prefix} ${name}`, { stdio: "pipe" });
194
- results.push({ name, version: info.version, success: false, error: "Conflicts" });
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" });
195
418
  continue;
196
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
+ }
197
484
  }
198
485
 
199
- // 6. Create symlinks for omp.install entries
200
- const _symlinkResult = await createPluginSymlinks(name, pkgJson, isGlobal);
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
+ }
201
512
 
202
- // 7. Process dependencies with omp field
203
- if (pkgJson.dependencies) {
204
- for (const depName of Object.keys(pkgJson.dependencies)) {
205
- const depPkgJson = await readPluginPackageJson(depName, isGlobal);
206
- if (depPkgJson?.omp?.install) {
207
- console.log(chalk.dim(` Processing dependency: ${depName}`));
208
- await createPluginSymlinks(depName, depPkgJson, isGlobal);
513
+ // Store feature config if changed
514
+ if (configToStore) {
515
+ if (!pluginsJson.config) {
516
+ pluginsJson.config = {};
209
517
  }
518
+ pluginsJson.config[name] = {
519
+ ...pluginsJson.config[name],
520
+ ...configToStore,
521
+ };
210
522
  }
523
+
524
+ await savePluginsJson(pluginsJson, isGlobal);
211
525
  }
212
526
 
213
527
  // Add to installed plugins map for subsequent conflict detection
214
528
  existingPlugins.set(name, pkgJson);
215
529
 
530
+ // Update lock file with exact version
531
+ await updateLockFile(name, info.version, isGlobal);
532
+
216
533
  console.log(chalk.green(`✓ Installed ${name}@${info.version}`));
217
534
  results.push({ name, version: info.version, success: true });
218
535
  } catch (err) {
219
536
  const errorMsg = (err as Error).message;
220
537
  console.log(chalk.red(` ✗ Failed to install ${name}: ${errorMsg}`));
221
- results.push({ name, version, success: false, error: 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 });
222
564
  }
223
565
  }
224
566
 
@@ -232,6 +574,7 @@ export async function installPlugin(packages?: string[], options: InstallOptions
232
574
  }
233
575
  if (failed.length > 0) {
234
576
  console.log(chalk.red(`✗ Failed to install ${failed.length} plugin(s)`));
577
+ process.exitCode = 1;
235
578
  }
236
579
 
237
580
  if (options.json) {
@@ -255,6 +598,7 @@ async function installLocalPlugin(
255
598
 
256
599
  if (!existsSync(localPath)) {
257
600
  console.log(chalk.red(`Error: Path does not exist: ${localPath}`));
601
+ process.exitCode = 1;
258
602
  return { name: basename(localPath), version: "local", success: false, error: "Path not found" };
259
603
  }
260
604
 
@@ -295,6 +639,22 @@ async function installLocalPlugin(
295
639
  const pluginName = pkgJson.name;
296
640
  const pluginDir = join(nodeModules, pluginName);
297
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
+
298
658
  console.log(chalk.blue(`\nInstalling ${pluginName} from ${localPath}...`));
299
659
 
300
660
  // Create node_modules directory
@@ -311,17 +671,30 @@ async function installLocalPlugin(
311
671
 
312
672
  // Update plugins.json/package.json
313
673
  const pluginsJson = await loadPluginsJson(isGlobal);
314
- pluginsJson.plugins[pluginName] = `file:${localPath}`;
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
+ }
315
684
  await savePluginsJson(pluginsJson, isGlobal);
316
685
 
317
686
  // Create symlinks
318
687
  await createPluginSymlinks(pluginName, pkgJson, isGlobal);
319
688
 
689
+ // Update lock file for local plugin
690
+ await updateLockFile(pluginName, pkgJson.version, isGlobal);
691
+
320
692
  console.log(chalk.green(`✓ Installed ${pluginName}@${pkgJson.version}`));
321
693
  return { name: pluginName, version: pkgJson.version, success: true };
322
694
  } catch (err) {
323
695
  const errorMsg = (err as Error).message;
324
696
  console.log(chalk.red(` ✗ Failed: ${errorMsg}`));
697
+ process.exitCode = 1;
325
698
  return { name: basename(localPath), version: "local", success: false, error: errorMsg };
326
699
  }
327
700
  }