@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
package/src/manifest.ts CHANGED
@@ -3,11 +3,21 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { dirname, join } from "node:path";
4
4
  import {
5
5
  GLOBAL_PACKAGE_JSON,
6
- LEGACY_MANIFEST_PATH,
7
6
  NODE_MODULES_DIR,
8
7
  PLUGINS_DIR,
8
+ PROJECT_PACKAGE_JSON,
9
9
  PROJECT_PLUGINS_JSON,
10
- } from "./paths.js";
10
+ } from "@omp/paths";
11
+
12
+ /**
13
+ * Format permission-related errors with actionable guidance
14
+ */
15
+ function formatPermissionError(err: NodeJS.ErrnoException, path: string): string {
16
+ if (err.code === "EACCES" || err.code === "EPERM") {
17
+ return `Permission denied: Cannot write to ${path}. Check directory permissions or run with appropriate privileges.`;
18
+ }
19
+ return err.message;
20
+ }
11
21
 
12
22
  /**
13
23
  * OMP field in package.json - defines what files to install
@@ -15,10 +25,51 @@ import {
15
25
  export interface OmpInstallEntry {
16
26
  src: string;
17
27
  dest: string;
28
+ /** If true, this file is copied (not symlinked) and can be edited by omp */
29
+ copy?: boolean;
30
+ }
31
+
32
+ /**
33
+ * Runtime configuration stored in plugin's runtime.json
34
+ * This file is copied (not symlinked) and edited by omp features/config commands
35
+ */
36
+ export interface PluginRuntimeConfig {
37
+ features?: string[];
38
+ options?: Record<string, unknown>;
39
+ }
40
+
41
+ /**
42
+ * Runtime variable definition with type, default, and metadata
43
+ */
44
+ export interface OmpVariable {
45
+ type: "string" | "number" | "boolean" | "string[]";
46
+ default?: string | number | boolean | string[];
47
+ description?: string;
48
+ required?: boolean;
49
+ /** Environment variable name if injected as env (e.g., "EXA_API_KEY") */
50
+ env?: string;
51
+ }
52
+
53
+ /**
54
+ * Feature definition - metadata only, no install entries
55
+ * All feature files are always installed; runtime.json controls which are active
56
+ */
57
+ export interface OmpFeature {
58
+ description?: string;
59
+ /** Runtime variables specific to this feature */
60
+ variables?: Record<string, OmpVariable>;
61
+ /** Default enabled state (default: true) */
62
+ default?: boolean;
18
63
  }
19
64
 
20
65
  export interface OmpField {
66
+ /** Top-level install entries (always installed, not feature-gated) */
21
67
  install?: OmpInstallEntry[];
68
+ /** Top-level runtime variables (always available) */
69
+ variables?: Record<string, OmpVariable>;
70
+ /** Named features with their own install entries and variables */
71
+ features?: Record<string, OmpFeature>;
72
+ /** Disabled state (managed by omp, not plugin author) */
22
73
  disabled?: boolean;
23
74
  }
24
75
 
@@ -36,48 +87,92 @@ export interface PluginPackageJson {
36
87
  files?: string[];
37
88
  }
38
89
 
90
+ /**
91
+ * Per-plugin configuration stored in plugins.json
92
+ */
93
+ export interface PluginConfig {
94
+ /**
95
+ * Enabled feature names:
96
+ * - null/undefined: use plugin defaults (first install = all, reinstall = preserve)
97
+ * - ["*"]: explicitly all features
98
+ * - []: no optional features (core only)
99
+ * - ["f1", "f2"]: specific features
100
+ */
101
+ features?: string[] | null;
102
+ /** Runtime variable overrides */
103
+ variables?: Record<string, string | number | boolean | string[]>;
104
+ }
105
+
39
106
  /**
40
107
  * Global/project plugins.json structure
41
108
  */
42
109
  export interface PluginsJson {
43
110
  plugins: Record<string, string>; // name -> version specifier
111
+ devDependencies?: Record<string, string>; // dev dependencies
44
112
  disabled?: string[]; // disabled plugin names
113
+ /** Per-plugin feature and variable config */
114
+ config?: Record<string, PluginConfig>;
45
115
  }
46
116
 
47
117
  /**
48
- * Legacy manifest structure (for migration)
118
+ * Initialize the global plugins directory with package.json
49
119
  */
50
- export interface LegacyPluginInfo {
51
- type: "github" | "local" | "npm";
52
- repo?: string;
53
- package?: string;
54
- path?: string;
55
- subdir?: string;
56
- version?: string;
57
- linked?: boolean;
58
- installed: string[];
59
- installedAt: string;
60
- }
120
+ export async function initGlobalPlugins(): Promise<void> {
121
+ try {
122
+ await mkdir(PLUGINS_DIR, { recursive: true });
61
123
 
62
- export interface LegacyManifest {
63
- plugins: Record<string, LegacyPluginInfo>;
124
+ if (!existsSync(GLOBAL_PACKAGE_JSON)) {
125
+ const packageJson = {
126
+ name: "pi-plugins",
127
+ version: "1.0.0",
128
+ private: true,
129
+ description: "Global pi plugins managed by omp",
130
+ dependencies: {},
131
+ };
132
+ await writeFile(GLOBAL_PACKAGE_JSON, JSON.stringify(packageJson, null, 2));
133
+ }
134
+ } catch (err) {
135
+ const error = err as NodeJS.ErrnoException;
136
+ if (error.code === "EACCES" || error.code === "EPERM") {
137
+ throw new Error(formatPermissionError(error, PLUGINS_DIR));
138
+ }
139
+ throw err;
140
+ }
64
141
  }
65
142
 
66
143
  /**
67
- * Initialize the global plugins directory with package.json
144
+ * Initialize the project-local .pi directory with plugins.json and package.json
68
145
  */
69
- export async function initGlobalPlugins(): Promise<void> {
70
- await mkdir(PLUGINS_DIR, { recursive: true });
146
+ export async function initProjectPlugins(): Promise<void> {
147
+ const PROJECT_PI_DIR = dirname(PROJECT_PLUGINS_JSON);
148
+ try {
149
+ await mkdir(PROJECT_PI_DIR, { recursive: true });
71
150
 
72
- if (!existsSync(GLOBAL_PACKAGE_JSON)) {
73
- const packageJson = {
74
- name: "pi-plugins",
75
- version: "1.0.0",
76
- private: true,
77
- description: "Global pi plugins managed by omp",
78
- dependencies: {},
79
- };
80
- await writeFile(GLOBAL_PACKAGE_JSON, JSON.stringify(packageJson, null, 2));
151
+ // Create plugins.json if it doesn't exist
152
+ if (!existsSync(PROJECT_PLUGINS_JSON)) {
153
+ const pluginsJson = {
154
+ plugins: {},
155
+ };
156
+ await writeFile(PROJECT_PLUGINS_JSON, JSON.stringify(pluginsJson, null, 2));
157
+ }
158
+
159
+ // Create package.json if it doesn't exist (for npm operations)
160
+ if (!existsSync(PROJECT_PACKAGE_JSON)) {
161
+ const packageJson = {
162
+ name: "pi-project-plugins",
163
+ version: "1.0.0",
164
+ private: true,
165
+ description: "Project-local pi plugins managed by omp",
166
+ dependencies: {},
167
+ };
168
+ await writeFile(PROJECT_PACKAGE_JSON, JSON.stringify(packageJson, null, 2));
169
+ }
170
+ } catch (err) {
171
+ const error = err as NodeJS.ErrnoException;
172
+ if (error.code === "EACCES" || error.code === "EPERM") {
173
+ throw new Error(formatPermissionError(error, PROJECT_PI_DIR));
174
+ }
175
+ throw err;
81
176
  }
82
177
  }
83
178
 
@@ -95,53 +190,125 @@ export async function loadPluginsJson(global = true): Promise<PluginsJson> {
95
190
  // Global uses standard package.json format
96
191
  return {
97
192
  plugins: parsed.dependencies || {},
193
+ devDependencies: parsed.devDependencies || {},
98
194
  disabled: parsed.omp?.disabled || [],
195
+ config: parsed.omp?.config || {},
99
196
  };
100
197
  }
101
198
 
102
199
  // Project uses plugins.json format
103
200
  return {
104
201
  plugins: parsed.plugins || {},
202
+ devDependencies: parsed.devDependencies || {},
105
203
  disabled: parsed.disabled || [],
204
+ config: parsed.config || {},
106
205
  };
107
206
  } catch (err) {
108
207
  if ((err as NodeJS.ErrnoException).code === "ENOENT") {
109
- return { plugins: {}, disabled: [] };
208
+ return { plugins: {}, devDependencies: {}, disabled: [], config: {} };
110
209
  }
111
210
  throw err;
112
211
  }
113
212
  }
114
213
 
214
+ /**
215
+ * Sync .pi/package.json with plugins.json for npm operations in project-local mode
216
+ */
217
+ async function syncProjectPackageJson(data: PluginsJson): Promise<void> {
218
+ let existing: Record<string, unknown> = {};
219
+ try {
220
+ existing = JSON.parse(await readFile(PROJECT_PACKAGE_JSON, "utf-8"));
221
+ } catch {
222
+ existing = {
223
+ name: "pi-project-plugins",
224
+ version: "1.0.0",
225
+ private: true,
226
+ description: "Project-local pi plugins managed by omp",
227
+ };
228
+ }
229
+
230
+ existing.dependencies = data.plugins;
231
+ if (data.devDependencies && Object.keys(data.devDependencies).length > 0) {
232
+ existing.devDependencies = data.devDependencies;
233
+ } else {
234
+ delete existing.devDependencies;
235
+ }
236
+
237
+ await writeFile(PROJECT_PACKAGE_JSON, JSON.stringify(existing, null, 2));
238
+ }
239
+
115
240
  /**
116
241
  * Save plugins.json (global or project)
117
242
  */
118
243
  export async function savePluginsJson(data: PluginsJson, global = true): Promise<void> {
119
244
  const path = global ? GLOBAL_PACKAGE_JSON : PROJECT_PLUGINS_JSON;
120
- await mkdir(dirname(path), { recursive: true });
121
-
122
- if (global) {
123
- // Read existing package.json and update dependencies
124
- let existing: Record<string, unknown> = {};
125
- try {
126
- existing = JSON.parse(await readFile(path, "utf-8"));
127
- } catch {
128
- existing = {
129
- name: "pi-plugins",
130
- version: "1.0.0",
131
- private: true,
132
- description: "Global pi plugins managed by omp",
133
- };
134
- }
135
245
 
136
- existing.dependencies = data.plugins;
137
- if (data.disabled?.length) {
138
- existing.omp = { ...((existing.omp as Record<string, unknown>) || {}), disabled: data.disabled };
139
- }
246
+ try {
247
+ await mkdir(dirname(path), { recursive: true });
140
248
 
141
- await writeFile(path, JSON.stringify(existing, null, 2));
142
- } else {
143
- // Project uses simple plugins.json format
144
- await writeFile(path, JSON.stringify(data, null, 2));
249
+ if (global) {
250
+ // Read existing package.json and update dependencies
251
+ let existing: Record<string, unknown> = {};
252
+ try {
253
+ existing = JSON.parse(await readFile(path, "utf-8"));
254
+ } catch {
255
+ existing = {
256
+ name: "pi-plugins",
257
+ version: "1.0.0",
258
+ private: true,
259
+ description: "Global pi plugins managed by omp",
260
+ };
261
+ }
262
+
263
+ existing.dependencies = data.plugins;
264
+ if (data.devDependencies && Object.keys(data.devDependencies).length > 0) {
265
+ existing.devDependencies = data.devDependencies;
266
+ } else {
267
+ delete existing.devDependencies;
268
+ }
269
+
270
+ // Build omp field with disabled and config
271
+ const ompField: Record<string, unknown> = (existing.omp as Record<string, unknown>) || {};
272
+ if (data.disabled?.length) {
273
+ ompField.disabled = data.disabled;
274
+ } else {
275
+ delete ompField.disabled;
276
+ }
277
+ if (data.config && Object.keys(data.config).length > 0) {
278
+ ompField.config = data.config;
279
+ } else {
280
+ delete ompField.config;
281
+ }
282
+ if (Object.keys(ompField).length > 0) {
283
+ existing.omp = ompField;
284
+ } else {
285
+ delete existing.omp;
286
+ }
287
+
288
+ await writeFile(path, JSON.stringify(existing, null, 2));
289
+ } else {
290
+ // Project uses simple plugins.json format
291
+ const output: Record<string, unknown> = { plugins: data.plugins };
292
+ if (data.devDependencies && Object.keys(data.devDependencies).length > 0) {
293
+ output.devDependencies = data.devDependencies;
294
+ }
295
+ if (data.disabled?.length) {
296
+ output.disabled = data.disabled;
297
+ }
298
+ if (data.config && Object.keys(data.config).length > 0) {
299
+ output.config = data.config;
300
+ }
301
+ await writeFile(path, JSON.stringify(output, null, 2));
302
+
303
+ // Sync .pi/package.json for npm operations
304
+ await syncProjectPackageJson(data);
305
+ }
306
+ } catch (err) {
307
+ const error = err as NodeJS.ErrnoException;
308
+ if (error.code === "EACCES" || error.code === "EPERM") {
309
+ throw new Error(formatPermissionError(error, path));
310
+ }
311
+ throw err;
145
312
  }
146
313
  }
147
314
 
@@ -175,25 +342,6 @@ export function getPluginSourceDir(pluginName: string, global = true): string {
175
342
  return join(nodeModules, pluginName);
176
343
  }
177
344
 
178
- /**
179
- * Check if legacy manifest.json exists
180
- */
181
- export function hasLegacyManifest(): boolean {
182
- return existsSync(LEGACY_MANIFEST_PATH);
183
- }
184
-
185
- /**
186
- * Load legacy manifest.json
187
- */
188
- export async function loadLegacyManifest(): Promise<LegacyManifest> {
189
- try {
190
- const data = await readFile(LEGACY_MANIFEST_PATH, "utf-8");
191
- return JSON.parse(data) as LegacyManifest;
192
- } catch {
193
- return { plugins: {} };
194
- }
195
- }
196
-
197
345
  /**
198
346
  * Get all installed plugins with their info
199
347
  */
package/src/npm.ts CHANGED
@@ -1,4 +1,40 @@
1
- import { execSync } from "node:child_process";
1
+ import { execFileSync } from "node:child_process";
2
+ import type { OmpField } from "@omp/manifest";
3
+
4
+ export interface NpmAvailability {
5
+ available: boolean;
6
+ version?: string;
7
+ error?: string;
8
+ }
9
+
10
+ /**
11
+ * Check npm availability and version
12
+ */
13
+ export function checkNpmAvailable(): NpmAvailability {
14
+ try {
15
+ const version = execFileSync("npm", ["--version"], {
16
+ encoding: "utf-8",
17
+ stdio: ["pipe", "pipe", "pipe"],
18
+ }).trim();
19
+
20
+ // Parse version and check minimum (npm 7+)
21
+ const major = parseInt(version.split(".")[0], 10);
22
+ if (major < 7) {
23
+ return {
24
+ available: false,
25
+ version,
26
+ error: `npm version ${version} is too old. Please upgrade to npm 7 or later.`,
27
+ };
28
+ }
29
+
30
+ return { available: true, version };
31
+ } catch {
32
+ return {
33
+ available: false,
34
+ error: "npm is not installed or not in PATH. Please install Node.js/npm.",
35
+ };
36
+ }
37
+ }
2
38
 
3
39
  export interface NpmPackageInfo {
4
40
  name: string;
@@ -10,6 +46,8 @@ export interface NpmPackageInfo {
10
46
  repository?: { type: string; url: string } | string;
11
47
  versions?: string[];
12
48
  "dist-tags"?: Record<string, string>;
49
+ omp?: OmpField;
50
+ dependencies?: Record<string, string>;
13
51
  }
14
52
 
15
53
  export interface NpmSearchResult {
@@ -21,23 +59,45 @@ export interface NpmSearchResult {
21
59
  author?: { name: string };
22
60
  }
23
61
 
62
+ const DEFAULT_TIMEOUT_MS = 60000; // 60 seconds
63
+
24
64
  /**
25
65
  * Execute npm command and return output
26
66
  */
27
- export function npmExec(args: string[], cwd?: string): string {
28
- const cmd = `npm ${args.join(" ")}`;
29
- return execSync(cmd, {
30
- cwd,
31
- stdio: ["pipe", "pipe", "pipe"],
32
- encoding: "utf-8",
33
- });
67
+ export function npmExec(args: string[], cwd?: string, timeout = DEFAULT_TIMEOUT_MS): string {
68
+ try {
69
+ return execFileSync("npm", args, {
70
+ cwd,
71
+ stdio: ["pipe", "pipe", "pipe"],
72
+ encoding: "utf-8",
73
+ timeout,
74
+ });
75
+ } catch (err) {
76
+ const error = err as { killed?: boolean; code?: string; message: string };
77
+ if (error.killed || error.code === "ETIMEDOUT") {
78
+ throw new Error(`npm operation timed out after ${timeout / 1000} seconds`);
79
+ }
80
+ throw err;
81
+ }
34
82
  }
35
83
 
36
84
  /**
37
85
  * Execute npm command with prefix (for installing to specific directory)
38
86
  */
39
- export function npmExecWithPrefix(args: string[], prefix: string): string {
40
- return npmExec(["--prefix", prefix, ...args]);
87
+ export function npmExecWithPrefix(args: string[], prefix: string, timeout = DEFAULT_TIMEOUT_MS): string {
88
+ try {
89
+ return execFileSync("npm", ["--prefix", prefix, ...args], {
90
+ stdio: ["pipe", "pipe", "pipe"],
91
+ encoding: "utf-8",
92
+ timeout,
93
+ });
94
+ } catch (err) {
95
+ const error = err as { killed?: boolean; code?: string; message: string };
96
+ if (error.killed || error.code === "ETIMEDOUT") {
97
+ throw new Error(`npm operation timed out after ${timeout / 1000} seconds`);
98
+ }
99
+ throw err;
100
+ }
41
101
  }
42
102
 
43
103
  /**
@@ -84,14 +144,10 @@ export async function npmInfo(packageName: string): Promise<NpmPackageInfo | nul
84
144
  * Search npm for packages with a keyword
85
145
  */
86
146
  export async function npmSearch(query: string, keyword = "omp-plugin"): Promise<NpmSearchResult[]> {
87
- try {
88
- // Search for packages with the omp-plugin keyword
89
- const searchTerm = keyword ? `keywords:${keyword} ${query}` : query;
90
- const output = npmExec(["search", searchTerm, "--json"]);
91
- return JSON.parse(output);
92
- } catch {
93
- return [];
94
- }
147
+ // Search for packages with the omp-plugin keyword
148
+ const searchTerm = keyword ? `keywords:${keyword} ${query}` : query;
149
+ const output = npmExec(["search", searchTerm, "--json"]);
150
+ return JSON.parse(output);
95
151
  }
96
152
 
97
153
  /**
package/src/paths.ts CHANGED
@@ -1,5 +1,6 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { homedir } from "node:os";
2
- import { join } from "node:path";
3
+ import { dirname, join, resolve } from "node:path";
3
4
 
4
5
  // Global pi configuration directory
5
6
  export const PI_CONFIG_DIR = join(homedir(), ".pi");
@@ -16,15 +17,15 @@ export const GLOBAL_PACKAGE_JSON = join(PLUGINS_DIR, "package.json");
16
17
  // Global package-lock.json
17
18
  export const GLOBAL_LOCK_FILE = join(PLUGINS_DIR, "package-lock.json");
18
19
 
19
- // Legacy manifest (for migration)
20
- export const LEGACY_MANIFEST_PATH = join(PLUGINS_DIR, "manifest.json");
21
-
22
20
  // Project-local config directory
23
21
  export const PROJECT_PI_DIR = ".pi";
24
22
 
25
23
  // Project-local plugins.json
26
24
  export const PROJECT_PLUGINS_JSON = join(PROJECT_PI_DIR, "plugins.json");
27
25
 
26
+ // Project-local package.json (for npm operations)
27
+ export const PROJECT_PACKAGE_JSON = join(PROJECT_PI_DIR, "package.json");
28
+
28
29
  // Project-local lock file
29
30
  export const PROJECT_PLUGINS_LOCK = join(PROJECT_PI_DIR, "plugins-lock.json");
30
31
 
@@ -32,13 +33,45 @@ export const PROJECT_PLUGINS_LOCK = join(PROJECT_PI_DIR, "plugins-lock.json");
32
33
  export const PROJECT_NODE_MODULES = join(PROJECT_PI_DIR, "node_modules");
33
34
 
34
35
  /**
35
- * Get the agent directory (where symlinks are installed)
36
+ * Find the project root by walking up parent directories looking for .pi/plugins.json.
37
+ * Similar to how git finds .git directories.
38
+ *
39
+ * @returns The absolute path to the project root, or null if not found
36
40
  */
37
- export function getAgentDir(global = true): string {
38
- if (global) {
39
- return join(PI_CONFIG_DIR, "agent");
41
+ export function findProjectRoot(): string | null {
42
+ let dir = process.cwd();
43
+ const root = resolve("/");
44
+
45
+ while (dir !== root) {
46
+ if (existsSync(join(dir, ".pi", "plugins.json"))) {
47
+ return dir;
48
+ }
49
+ const parent = dirname(dir);
50
+ if (parent === dir) break; // Reached filesystem root
51
+ dir = parent;
52
+ }
53
+
54
+ return null;
55
+ }
56
+
57
+ /**
58
+ * Check if a project-local .pi/plugins.json exists in the current directory or any parent
59
+ */
60
+ export function hasProjectPlugins(): boolean {
61
+ return findProjectRoot() !== null;
62
+ }
63
+
64
+ /**
65
+ * Get the project .pi directory path.
66
+ * Uses findProjectRoot() to locate the project, or falls back to cwd.
67
+ */
68
+ export function getProjectPiDir(): string {
69
+ const projectRoot = findProjectRoot();
70
+ if (projectRoot) {
71
+ return join(projectRoot, ".pi");
40
72
  }
41
- return join(PROJECT_PI_DIR, "agent");
73
+ // Fallback to cwd (e.g., for init command)
74
+ return resolve(PROJECT_PI_DIR);
42
75
  }
43
76
 
44
77
  /**
@@ -48,7 +81,7 @@ export function getPluginsDir(global = true): string {
48
81
  if (global) {
49
82
  return PLUGINS_DIR;
50
83
  }
51
- return PROJECT_PI_DIR;
84
+ return getProjectPiDir();
52
85
  }
53
86
 
54
87
  /**
@@ -58,7 +91,7 @@ export function getNodeModulesDir(global = true): string {
58
91
  if (global) {
59
92
  return NODE_MODULES_DIR;
60
93
  }
61
- return PROJECT_NODE_MODULES;
94
+ return join(getProjectPiDir(), "node_modules");
62
95
  }
63
96
 
64
97
  /**
@@ -68,5 +101,37 @@ export function getPackageJsonPath(global = true): string {
68
101
  if (global) {
69
102
  return GLOBAL_PACKAGE_JSON;
70
103
  }
71
- return PROJECT_PLUGINS_JSON;
104
+ return join(getProjectPiDir(), "plugins.json");
105
+ }
106
+
107
+ /**
108
+ * Get the agent directory (where symlinks are installed)
109
+ */
110
+ export function getAgentDir(global = true): string {
111
+ if (global) {
112
+ return join(PI_CONFIG_DIR, "agent");
113
+ }
114
+ return join(getProjectPiDir(), "agent");
115
+ }
116
+
117
+ /**
118
+ * Resolve whether to use global or local scope based on CLI flags and auto-detection.
119
+ *
120
+ * Logic:
121
+ * - If --global is passed: use global mode
122
+ * - If --local is passed: use local mode
123
+ * - If neither: check if .pi/plugins.json exists in cwd, if so use local, otherwise use global
124
+ *
125
+ * @param options - CLI options containing global and local flags
126
+ * @returns true if global scope should be used, false for local
127
+ */
128
+ export function resolveScope(options: { global?: boolean; local?: boolean }): boolean {
129
+ if (options.global) {
130
+ return true;
131
+ }
132
+ if (options.local) {
133
+ return false;
134
+ }
135
+ // Auto-detect: if project-local plugins.json exists, use local mode
136
+ return !hasProjectPlugins();
72
137
  }