@oh-my-pi/cli 0.1.0 → 0.2.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 (78) 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 +131 -145
  6. package/biome.json +1 -1
  7. package/dist/cli.js +2032 -1136
  8. package/dist/commands/create.d.ts.map +1 -1
  9. package/dist/commands/doctor.d.ts +1 -0
  10. package/dist/commands/doctor.d.ts.map +1 -1
  11. package/dist/commands/enable.d.ts +1 -0
  12. package/dist/commands/enable.d.ts.map +1 -1
  13. package/dist/commands/info.d.ts +1 -0
  14. package/dist/commands/info.d.ts.map +1 -1
  15. package/dist/commands/init.d.ts.map +1 -1
  16. package/dist/commands/install.d.ts +1 -0
  17. package/dist/commands/install.d.ts.map +1 -1
  18. package/dist/commands/link.d.ts +2 -0
  19. package/dist/commands/link.d.ts.map +1 -1
  20. package/dist/commands/list.d.ts +1 -0
  21. package/dist/commands/list.d.ts.map +1 -1
  22. package/dist/commands/outdated.d.ts +1 -0
  23. package/dist/commands/outdated.d.ts.map +1 -1
  24. package/dist/commands/search.d.ts.map +1 -1
  25. package/dist/commands/uninstall.d.ts +1 -0
  26. package/dist/commands/uninstall.d.ts.map +1 -1
  27. package/dist/commands/update.d.ts +1 -0
  28. package/dist/commands/update.d.ts.map +1 -1
  29. package/dist/commands/why.d.ts +1 -0
  30. package/dist/commands/why.d.ts.map +1 -1
  31. package/dist/conflicts.d.ts +9 -1
  32. package/dist/conflicts.d.ts.map +1 -1
  33. package/dist/errors.d.ts +8 -0
  34. package/dist/errors.d.ts.map +1 -0
  35. package/dist/index.d.ts +19 -19
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/lock.d.ts +3 -0
  38. package/dist/lock.d.ts.map +1 -0
  39. package/dist/lockfile.d.ts +52 -0
  40. package/dist/lockfile.d.ts.map +1 -0
  41. package/dist/manifest.d.ts +5 -0
  42. package/dist/manifest.d.ts.map +1 -1
  43. package/dist/migrate.d.ts.map +1 -1
  44. package/dist/npm.d.ts +14 -2
  45. package/dist/npm.d.ts.map +1 -1
  46. package/dist/paths.d.ts +34 -2
  47. package/dist/paths.d.ts.map +1 -1
  48. package/dist/symlinks.d.ts +10 -4
  49. package/dist/symlinks.d.ts.map +1 -1
  50. package/package.json +7 -2
  51. package/plugins/metal-theme/package.json +6 -1
  52. package/plugins/subagents/package.json +6 -1
  53. package/src/cli.ts +69 -43
  54. package/src/commands/create.ts +51 -1
  55. package/src/commands/doctor.ts +95 -7
  56. package/src/commands/enable.ts +25 -8
  57. package/src/commands/info.ts +41 -5
  58. package/src/commands/init.ts +20 -2
  59. package/src/commands/install.ts +266 -52
  60. package/src/commands/link.ts +60 -9
  61. package/src/commands/list.ts +10 -5
  62. package/src/commands/outdated.ts +17 -6
  63. package/src/commands/search.ts +20 -3
  64. package/src/commands/uninstall.ts +57 -6
  65. package/src/commands/update.ts +67 -9
  66. package/src/commands/why.ts +47 -16
  67. package/src/conflicts.ts +33 -1
  68. package/src/errors.ts +22 -0
  69. package/src/index.ts +19 -25
  70. package/src/lock.ts +46 -0
  71. package/src/lockfile.ts +132 -0
  72. package/src/manifest.ts +143 -35
  73. package/src/migrate.ts +14 -3
  74. package/src/npm.ts +74 -18
  75. package/src/paths.ts +77 -9
  76. package/src/symlinks.ts +134 -17
  77. package/tsconfig.json +7 -3
  78. package/CHECK.md +0 -352
@@ -0,0 +1,132 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { GLOBAL_LOCK_FILE, PROJECT_PLUGINS_LOCK } from "@omp/paths";
4
+ import chalk from "chalk";
5
+
6
+ /**
7
+ * Lock file schema version
8
+ */
9
+ export const LOCKFILE_VERSION = 1;
10
+
11
+ /**
12
+ * Package entry in the lock file
13
+ */
14
+ export interface LockFilePackage {
15
+ version: string;
16
+ resolved?: string;
17
+ integrity?: string;
18
+ dependencies?: Record<string, string>;
19
+ }
20
+
21
+ /**
22
+ * Lock file structure
23
+ */
24
+ export interface LockFile {
25
+ lockfileVersion: number;
26
+ packages: Record<string, LockFilePackage>;
27
+ }
28
+
29
+ /**
30
+ * Load and validate a lock file.
31
+ *
32
+ * Returns null if:
33
+ * - File doesn't exist
34
+ * - File contains invalid JSON (corrupted)
35
+ * - File has invalid/incompatible schema
36
+ */
37
+ export async function loadLockFile(global = true): Promise<LockFile | null> {
38
+ const path = global ? GLOBAL_LOCK_FILE : PROJECT_PLUGINS_LOCK;
39
+ try {
40
+ if (!existsSync(path)) return null;
41
+ const data = await readFile(path, "utf-8");
42
+ const parsed = JSON.parse(data);
43
+
44
+ // Validate schema
45
+ if (typeof parsed.lockfileVersion !== "number" || typeof parsed.packages !== "object") {
46
+ console.log(chalk.yellow(`Warning: ${path} has invalid schema, ignoring`));
47
+ return null;
48
+ }
49
+
50
+ // Check for incompatible version
51
+ if (parsed.lockfileVersion > LOCKFILE_VERSION) {
52
+ console.log(
53
+ chalk.yellow(
54
+ `Warning: ${path} was created by a newer version of omp (lockfile v${parsed.lockfileVersion}), ignoring`,
55
+ ),
56
+ );
57
+ return null;
58
+ }
59
+
60
+ return parsed as LockFile;
61
+ } catch (err) {
62
+ if ((err as Error).name === "SyntaxError") {
63
+ console.log(chalk.yellow(`Warning: ${path} is corrupted (invalid JSON), ignoring`));
64
+ }
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Save lock file
71
+ */
72
+ export async function saveLockFile(lockFile: LockFile, global = true): Promise<void> {
73
+ const path = global ? GLOBAL_LOCK_FILE : PROJECT_PLUGINS_LOCK;
74
+ await writeFile(path, JSON.stringify(lockFile, null, 2));
75
+ }
76
+
77
+ /**
78
+ * Create a new empty lock file
79
+ */
80
+ export function createLockFile(): LockFile {
81
+ return {
82
+ lockfileVersion: LOCKFILE_VERSION,
83
+ packages: {},
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Validate and optionally regenerate a corrupted lock file.
89
+ *
90
+ * @returns The loaded lock file, a new empty lock file if corrupted/missing, or null if validation fails
91
+ */
92
+ export async function validateOrRegenerateLockFile(global = true): Promise<LockFile> {
93
+ const existing = await loadLockFile(global);
94
+ if (existing) {
95
+ return existing;
96
+ }
97
+
98
+ // Lock file is missing or corrupted - create a fresh one
99
+ const path = global ? GLOBAL_LOCK_FILE : PROJECT_PLUGINS_LOCK;
100
+ if (existsSync(path)) {
101
+ console.log(chalk.yellow(`Regenerating corrupted lock file: ${path}`));
102
+ }
103
+
104
+ return createLockFile();
105
+ }
106
+
107
+ /**
108
+ * Get the locked version for a package, if it exists in the lock file.
109
+ */
110
+ export async function getLockedVersion(packageName: string, global = true): Promise<string | null> {
111
+ const lockFile = await loadLockFile(global);
112
+ if (!lockFile) return null;
113
+
114
+ const entry = lockFile.packages[packageName];
115
+ return entry?.version ?? null;
116
+ }
117
+
118
+ /**
119
+ * Update the lock file with a package's exact version.
120
+ */
121
+ export async function updateLockFile(packageName: string, version: string, global = true): Promise<void> {
122
+ let lockFile = await loadLockFile(global);
123
+ if (!lockFile) {
124
+ lockFile = createLockFile();
125
+ }
126
+
127
+ lockFile.packages[packageName] = {
128
+ version,
129
+ };
130
+
131
+ await saveLockFile(lockFile, global);
132
+ }
package/src/manifest.ts CHANGED
@@ -6,8 +6,19 @@ import {
6
6
  LEGACY_MANIFEST_PATH,
7
7
  NODE_MODULES_DIR,
8
8
  PLUGINS_DIR,
9
+ PROJECT_PACKAGE_JSON,
9
10
  PROJECT_PLUGINS_JSON,
10
- } from "./paths.js";
11
+ } from "@omp/paths";
12
+
13
+ /**
14
+ * Format permission-related errors with actionable guidance
15
+ */
16
+ function formatPermissionError(err: NodeJS.ErrnoException, path: string): string {
17
+ if (err.code === "EACCES" || err.code === "EPERM") {
18
+ return `Permission denied: Cannot write to ${path}. Check directory permissions or run with appropriate privileges.`;
19
+ }
20
+ return err.message;
21
+ }
11
22
 
12
23
  /**
13
24
  * OMP field in package.json - defines what files to install
@@ -41,6 +52,7 @@ export interface PluginPackageJson {
41
52
  */
42
53
  export interface PluginsJson {
43
54
  plugins: Record<string, string>; // name -> version specifier
55
+ devDependencies?: Record<string, string>; // dev dependencies
44
56
  disabled?: string[]; // disabled plugin names
45
57
  }
46
58
 
@@ -67,17 +79,61 @@ export interface LegacyManifest {
67
79
  * Initialize the global plugins directory with package.json
68
80
  */
69
81
  export async function initGlobalPlugins(): Promise<void> {
70
- await mkdir(PLUGINS_DIR, { recursive: true });
82
+ try {
83
+ await mkdir(PLUGINS_DIR, { recursive: true });
71
84
 
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));
85
+ if (!existsSync(GLOBAL_PACKAGE_JSON)) {
86
+ const packageJson = {
87
+ name: "pi-plugins",
88
+ version: "1.0.0",
89
+ private: true,
90
+ description: "Global pi plugins managed by omp",
91
+ dependencies: {},
92
+ };
93
+ await writeFile(GLOBAL_PACKAGE_JSON, JSON.stringify(packageJson, null, 2));
94
+ }
95
+ } catch (err) {
96
+ const error = err as NodeJS.ErrnoException;
97
+ if (error.code === "EACCES" || error.code === "EPERM") {
98
+ throw new Error(formatPermissionError(error, PLUGINS_DIR));
99
+ }
100
+ throw err;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Initialize the project-local .pi directory with plugins.json and package.json
106
+ */
107
+ export async function initProjectPlugins(): Promise<void> {
108
+ const PROJECT_PI_DIR = dirname(PROJECT_PLUGINS_JSON);
109
+ try {
110
+ await mkdir(PROJECT_PI_DIR, { recursive: true });
111
+
112
+ // Create plugins.json if it doesn't exist
113
+ if (!existsSync(PROJECT_PLUGINS_JSON)) {
114
+ const pluginsJson = {
115
+ plugins: {},
116
+ };
117
+ await writeFile(PROJECT_PLUGINS_JSON, JSON.stringify(pluginsJson, null, 2));
118
+ }
119
+
120
+ // Create package.json if it doesn't exist (for npm operations)
121
+ if (!existsSync(PROJECT_PACKAGE_JSON)) {
122
+ const packageJson = {
123
+ name: "pi-project-plugins",
124
+ version: "1.0.0",
125
+ private: true,
126
+ description: "Project-local pi plugins managed by omp",
127
+ dependencies: {},
128
+ };
129
+ await writeFile(PROJECT_PACKAGE_JSON, JSON.stringify(packageJson, null, 2));
130
+ }
131
+ } catch (err) {
132
+ const error = err as NodeJS.ErrnoException;
133
+ if (error.code === "EACCES" || error.code === "EPERM") {
134
+ throw new Error(formatPermissionError(error, PROJECT_PI_DIR));
135
+ }
136
+ throw err;
81
137
  }
82
138
  }
83
139
 
@@ -95,6 +151,7 @@ export async function loadPluginsJson(global = true): Promise<PluginsJson> {
95
151
  // Global uses standard package.json format
96
152
  return {
97
153
  plugins: parsed.dependencies || {},
154
+ devDependencies: parsed.devDependencies || {},
98
155
  disabled: parsed.omp?.disabled || [],
99
156
  };
100
157
  }
@@ -102,46 +159,97 @@ export async function loadPluginsJson(global = true): Promise<PluginsJson> {
102
159
  // Project uses plugins.json format
103
160
  return {
104
161
  plugins: parsed.plugins || {},
162
+ devDependencies: parsed.devDependencies || {},
105
163
  disabled: parsed.disabled || [],
106
164
  };
107
165
  } catch (err) {
108
166
  if ((err as NodeJS.ErrnoException).code === "ENOENT") {
109
- return { plugins: {}, disabled: [] };
167
+ return { plugins: {}, devDependencies: {}, disabled: [] };
110
168
  }
111
169
  throw err;
112
170
  }
113
171
  }
114
172
 
173
+ /**
174
+ * Sync .pi/package.json with plugins.json for npm operations in project-local mode
175
+ */
176
+ async function syncProjectPackageJson(data: PluginsJson): Promise<void> {
177
+ let existing: Record<string, unknown> = {};
178
+ try {
179
+ existing = JSON.parse(await readFile(PROJECT_PACKAGE_JSON, "utf-8"));
180
+ } catch {
181
+ existing = {
182
+ name: "pi-project-plugins",
183
+ version: "1.0.0",
184
+ private: true,
185
+ description: "Project-local pi plugins managed by omp",
186
+ };
187
+ }
188
+
189
+ existing.dependencies = data.plugins;
190
+ if (data.devDependencies && Object.keys(data.devDependencies).length > 0) {
191
+ existing.devDependencies = data.devDependencies;
192
+ } else {
193
+ delete existing.devDependencies;
194
+ }
195
+
196
+ await writeFile(PROJECT_PACKAGE_JSON, JSON.stringify(existing, null, 2));
197
+ }
198
+
115
199
  /**
116
200
  * Save plugins.json (global or project)
117
201
  */
118
202
  export async function savePluginsJson(data: PluginsJson, global = true): Promise<void> {
119
203
  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
204
 
136
- existing.dependencies = data.plugins;
137
- if (data.disabled?.length) {
138
- existing.omp = { ...((existing.omp as Record<string, unknown>) || {}), disabled: data.disabled };
139
- }
205
+ try {
206
+ await mkdir(dirname(path), { recursive: true });
140
207
 
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));
208
+ if (global) {
209
+ // Read existing package.json and update dependencies
210
+ let existing: Record<string, unknown> = {};
211
+ try {
212
+ existing = JSON.parse(await readFile(path, "utf-8"));
213
+ } catch {
214
+ existing = {
215
+ name: "pi-plugins",
216
+ version: "1.0.0",
217
+ private: true,
218
+ description: "Global pi plugins managed by omp",
219
+ };
220
+ }
221
+
222
+ existing.dependencies = data.plugins;
223
+ if (data.devDependencies && Object.keys(data.devDependencies).length > 0) {
224
+ existing.devDependencies = data.devDependencies;
225
+ } else {
226
+ delete existing.devDependencies;
227
+ }
228
+ if (data.disabled?.length) {
229
+ existing.omp = { ...((existing.omp as Record<string, unknown>) || {}), disabled: data.disabled };
230
+ }
231
+
232
+ await writeFile(path, JSON.stringify(existing, null, 2));
233
+ } else {
234
+ // Project uses simple plugins.json format
235
+ const output: Record<string, unknown> = { plugins: data.plugins };
236
+ if (data.devDependencies && Object.keys(data.devDependencies).length > 0) {
237
+ output.devDependencies = data.devDependencies;
238
+ }
239
+ if (data.disabled?.length) {
240
+ output.disabled = data.disabled;
241
+ }
242
+ await writeFile(path, JSON.stringify(output, null, 2));
243
+
244
+ // Sync .pi/package.json for npm operations
245
+ await syncProjectPackageJson(data);
246
+ }
247
+ } catch (err) {
248
+ const error = err as NodeJS.ErrnoException;
249
+ if (error.code === "EACCES" || error.code === "EPERM") {
250
+ throw new Error(formatPermissionError(error, path));
251
+ }
252
+ throw err;
145
253
  }
146
254
  }
147
255
 
package/src/migrate.ts CHANGED
@@ -2,16 +2,18 @@ import { existsSync } from "node:fs";
2
2
  import { mkdir, readFile, rename, rm, symlink, writeFile } from "node:fs/promises";
3
3
  import { basename, join } from "node:path";
4
4
  import { createInterface } from "node:readline";
5
- import chalk from "chalk";
6
5
  import {
7
6
  hasLegacyManifest,
8
7
  type LegacyPluginInfo,
9
8
  loadLegacyManifest,
10
9
  type PluginPackageJson,
11
10
  type PluginsJson,
11
+ readPluginPackageJson,
12
12
  savePluginsJson,
13
- } from "./manifest.js";
14
- import { LEGACY_MANIFEST_PATH, NODE_MODULES_DIR, PLUGINS_DIR } from "./paths.js";
13
+ } from "@omp/manifest";
14
+ import { LEGACY_MANIFEST_PATH, NODE_MODULES_DIR, PLUGINS_DIR } from "@omp/paths";
15
+ import { createPluginSymlinks } from "@omp/symlinks";
16
+ import chalk from "chalk";
15
17
 
16
18
  /**
17
19
  * Prompt user for migration
@@ -104,6 +106,15 @@ export async function migrateToNpm(): Promise<boolean> {
104
106
  // Archive legacy manifest
105
107
  await archiveLegacyManifest();
106
108
 
109
+ // Re-create symlinks for migrated plugins
110
+ console.log(chalk.dim(" Creating symlinks..."));
111
+ for (const [name] of Object.entries(newPluginsJson.plugins)) {
112
+ const pkgJson = await readPluginPackageJson(name, true); // global mode
113
+ if (pkgJson?.omp?.install?.length) {
114
+ await createPluginSymlinks(name, pkgJson, true);
115
+ }
116
+ }
117
+
107
118
  console.log();
108
119
  console.log(chalk.green(`✓ Migrated ${migrated.length} plugin(s)`));
109
120
  if (failed.length > 0) {
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");
@@ -25,6 +26,9 @@ export const PROJECT_PI_DIR = ".pi";
25
26
  // Project-local plugins.json
26
27
  export const PROJECT_PLUGINS_JSON = join(PROJECT_PI_DIR, "plugins.json");
27
28
 
29
+ // Project-local package.json (for npm operations)
30
+ export const PROJECT_PACKAGE_JSON = join(PROJECT_PI_DIR, "package.json");
31
+
28
32
  // Project-local lock file
29
33
  export const PROJECT_PLUGINS_LOCK = join(PROJECT_PI_DIR, "plugins-lock.json");
30
34
 
@@ -32,13 +36,45 @@ export const PROJECT_PLUGINS_LOCK = join(PROJECT_PI_DIR, "plugins-lock.json");
32
36
  export const PROJECT_NODE_MODULES = join(PROJECT_PI_DIR, "node_modules");
33
37
 
34
38
  /**
35
- * Get the agent directory (where symlinks are installed)
39
+ * Find the project root by walking up parent directories looking for .pi/plugins.json.
40
+ * Similar to how git finds .git directories.
41
+ *
42
+ * @returns The absolute path to the project root, or null if not found
36
43
  */
37
- export function getAgentDir(global = true): string {
38
- if (global) {
39
- return join(PI_CONFIG_DIR, "agent");
44
+ export function findProjectRoot(): string | null {
45
+ let dir = process.cwd();
46
+ const root = resolve("/");
47
+
48
+ while (dir !== root) {
49
+ if (existsSync(join(dir, ".pi", "plugins.json"))) {
50
+ return dir;
51
+ }
52
+ const parent = dirname(dir);
53
+ if (parent === dir) break; // Reached filesystem root
54
+ dir = parent;
55
+ }
56
+
57
+ return null;
58
+ }
59
+
60
+ /**
61
+ * Check if a project-local .pi/plugins.json exists in the current directory or any parent
62
+ */
63
+ export function hasProjectPlugins(): boolean {
64
+ return findProjectRoot() !== null;
65
+ }
66
+
67
+ /**
68
+ * Get the project .pi directory path.
69
+ * Uses findProjectRoot() to locate the project, or falls back to cwd.
70
+ */
71
+ export function getProjectPiDir(): string {
72
+ const projectRoot = findProjectRoot();
73
+ if (projectRoot) {
74
+ return join(projectRoot, ".pi");
40
75
  }
41
- return join(PROJECT_PI_DIR, "agent");
76
+ // Fallback to cwd (e.g., for init command)
77
+ return resolve(PROJECT_PI_DIR);
42
78
  }
43
79
 
44
80
  /**
@@ -48,7 +84,7 @@ export function getPluginsDir(global = true): string {
48
84
  if (global) {
49
85
  return PLUGINS_DIR;
50
86
  }
51
- return PROJECT_PI_DIR;
87
+ return getProjectPiDir();
52
88
  }
53
89
 
54
90
  /**
@@ -58,7 +94,7 @@ export function getNodeModulesDir(global = true): string {
58
94
  if (global) {
59
95
  return NODE_MODULES_DIR;
60
96
  }
61
- return PROJECT_NODE_MODULES;
97
+ return join(getProjectPiDir(), "node_modules");
62
98
  }
63
99
 
64
100
  /**
@@ -68,5 +104,37 @@ export function getPackageJsonPath(global = true): string {
68
104
  if (global) {
69
105
  return GLOBAL_PACKAGE_JSON;
70
106
  }
71
- return PROJECT_PLUGINS_JSON;
107
+ return join(getProjectPiDir(), "plugins.json");
108
+ }
109
+
110
+ /**
111
+ * Get the agent directory (where symlinks are installed)
112
+ */
113
+ export function getAgentDir(global = true): string {
114
+ if (global) {
115
+ return join(PI_CONFIG_DIR, "agent");
116
+ }
117
+ return join(getProjectPiDir(), "agent");
118
+ }
119
+
120
+ /**
121
+ * Resolve whether to use global or local scope based on CLI flags and auto-detection.
122
+ *
123
+ * Logic:
124
+ * - If --global is passed: use global mode
125
+ * - If --local is passed: use local mode
126
+ * - If neither: check if .pi/plugins.json exists in cwd, if so use local, otherwise use global
127
+ *
128
+ * @param options - CLI options containing global and local flags
129
+ * @returns true if global scope should be used, false for local
130
+ */
131
+ export function resolveScope(options: { global?: boolean; local?: boolean }): boolean {
132
+ if (options.global) {
133
+ return true;
134
+ }
135
+ if (options.local) {
136
+ return false;
137
+ }
138
+ // Auto-detect: if project-local plugins.json exists, use local mode
139
+ return !hasProjectPlugins();
72
140
  }