apigrip 0.1.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 (41) hide show
  1. package/README.md +240 -0
  2. package/cli/commands/curl.js +11 -0
  3. package/cli/commands/env.js +174 -0
  4. package/cli/commands/last.js +63 -0
  5. package/cli/commands/list.js +35 -0
  6. package/cli/commands/mcp.js +25 -0
  7. package/cli/commands/projects.js +50 -0
  8. package/cli/commands/send.js +189 -0
  9. package/cli/commands/serve.js +46 -0
  10. package/cli/index.js +109 -0
  11. package/cli/output.js +168 -0
  12. package/cli/resolve-project.js +43 -0
  13. package/client/dist/assets/index-CtHBIuEv.js +75 -0
  14. package/client/dist/assets/index-kzeRjfI8.css +1 -0
  15. package/client/dist/index.html +19 -0
  16. package/core/curl-builder.js +218 -0
  17. package/core/curl-executor.js +370 -0
  18. package/core/env-resolver.js +244 -0
  19. package/core/git-info.js +41 -0
  20. package/core/params-store.js +94 -0
  21. package/core/preferences-store.js +150 -0
  22. package/core/projects-store.js +173 -0
  23. package/core/response-store.js +121 -0
  24. package/core/schema-validator.js +196 -0
  25. package/core/spec-discovery.js +109 -0
  26. package/core/spec-parser.js +172 -0
  27. package/lib/index.cjs +16 -0
  28. package/lib/index.js +294 -0
  29. package/mcp/server.js +257 -0
  30. package/package.json +70 -0
  31. package/server/index.js +53 -0
  32. package/server/routes/browse.js +61 -0
  33. package/server/routes/environments.js +92 -0
  34. package/server/routes/events.js +40 -0
  35. package/server/routes/params.js +38 -0
  36. package/server/routes/preferences.js +27 -0
  37. package/server/routes/project.js +94 -0
  38. package/server/routes/projects.js +51 -0
  39. package/server/routes/requests.js +192 -0
  40. package/server/routes/spec.js +92 -0
  41. package/server/spec-watcher.js +236 -0
@@ -0,0 +1,244 @@
1
+ /**
2
+ * env-resolver.js - Environment loading, merging, and variable resolution.
3
+ */
4
+
5
+ import { createHash } from 'node:crypto';
6
+ import path from 'node:path';
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+
10
+ const DEFAULT_ENV_CONFIG = () => ({
11
+ base: {},
12
+ environments: {},
13
+ active: null,
14
+ });
15
+
16
+ /**
17
+ * Get the config directory for apigrip.
18
+ * Uses $XDG_CONFIG_HOME/apigrip/ if set,
19
+ * otherwise ~/.config/apigrip/.
20
+ *
21
+ * @returns {string}
22
+ */
23
+ export function getConfigDir() {
24
+ const xdg = process.env.XDG_CONFIG_HOME;
25
+ const base = xdg || path.join(os.homedir(), '.config');
26
+ return path.join(base, 'apigrip');
27
+ }
28
+
29
+ /**
30
+ * Get a project hash: SHA-256 of the resolved absolute path, truncated to 16 hex chars.
31
+ *
32
+ * @param {string} projectDir
33
+ * @returns {string}
34
+ */
35
+ export function getProjectHash(projectDir) {
36
+ let resolved;
37
+ try {
38
+ resolved = fs.realpathSync(path.resolve(projectDir));
39
+ } catch {
40
+ // Directory may have been deleted — fall back to path.resolve()
41
+ resolved = path.resolve(projectDir);
42
+ }
43
+ const hash = createHash('sha256').update(resolved).digest('hex');
44
+ return hash.slice(0, 16);
45
+ }
46
+
47
+ /**
48
+ * Get the environments file path for a project.
49
+ */
50
+ function getEnvFilePath(projectDir) {
51
+ const configDir = getConfigDir();
52
+ const hash = getProjectHash(projectDir);
53
+ return path.join(configDir, 'environments', `${hash}.json`);
54
+ }
55
+
56
+ /**
57
+ * Read and parse the environments file.
58
+ */
59
+ function readEnvFile(filePath) {
60
+ try {
61
+ if (!fs.existsSync(filePath)) {
62
+ return DEFAULT_ENV_CONFIG();
63
+ }
64
+ const raw = fs.readFileSync(filePath, 'utf-8');
65
+ const parsed = JSON.parse(raw);
66
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
67
+ console.warn(`[env-resolver] Corrupt environments file at ${filePath}, returning defaults`);
68
+ return DEFAULT_ENV_CONFIG();
69
+ }
70
+ return {
71
+ base: parsed.base || {},
72
+ environments: parsed.environments || {},
73
+ active: parsed.active != null ? parsed.active : null,
74
+ };
75
+ } catch (err) {
76
+ console.warn(`[env-resolver] Failed to load environments from ${filePath}: ${err.message}`);
77
+ return DEFAULT_ENV_CONFIG();
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Write the environments config to the file.
83
+ */
84
+ function writeEnvFile(filePath, config) {
85
+ const dir = path.dirname(filePath);
86
+ fs.mkdirSync(dir, { recursive: true });
87
+ fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf-8');
88
+ }
89
+
90
+ /**
91
+ * Load environments for a project from disk.
92
+ * Returns default structure if file doesn't exist or is corrupt JSON.
93
+ *
94
+ * @param {string} projectDir
95
+ * @returns {{ base: object, environments: object, active: string|null }}
96
+ */
97
+ export function loadEnvironments(projectDir) {
98
+ const filePath = getEnvFilePath(projectDir);
99
+ return readEnvFile(filePath);
100
+ }
101
+
102
+ /**
103
+ * Save environments for a project to disk.
104
+ * Creates the directory structure if needed.
105
+ *
106
+ * @param {string} projectDir
107
+ * @param {{ base: object, environments: object, active: string|null }} data
108
+ */
109
+ export function saveEnvironments(projectDir, data) {
110
+ const filePath = getEnvFilePath(projectDir);
111
+ writeEnvFile(filePath, data);
112
+ }
113
+
114
+ /**
115
+ * Resolve the active environment by merging base + the active named environment.
116
+ * Named environment values override base values.
117
+ *
118
+ * @param {{ base: object, environments: object, active: string|null }} envData
119
+ * @returns {object} - Merged key-value pairs
120
+ */
121
+ export function resolveEnvironment(envData) {
122
+ const base = envData.base || {};
123
+ const active = envData.active;
124
+ const environments = envData.environments || {};
125
+
126
+ if (!active || !environments[active]) {
127
+ return { ...base };
128
+ }
129
+
130
+ return { ...base, ...environments[active] };
131
+ }
132
+
133
+ /**
134
+ * Replace {{ variable_name }} patterns in a string with values from the environment.
135
+ * Unresolved variables are left as literal text.
136
+ *
137
+ * @param {string} str - String potentially containing {{ var }} patterns
138
+ * @param {object} env - Key-value environment map
139
+ * @returns {string} - String with resolved variables
140
+ */
141
+ export function resolveVariables(str, env) {
142
+ if (typeof str !== 'string') return str;
143
+ if (!env) return str;
144
+ return str.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (match, varName) => {
145
+ if (varName in env) {
146
+ return String(env[varName]);
147
+ }
148
+ return match; // Leave unresolved as literal text
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Resolve all string values in a params object against the environment.
154
+ * Recursively processes nested objects and arrays.
155
+ *
156
+ * @param {*} params - Parameters with potential {{ var }} values
157
+ * @param {object} env - Key-value environment map
158
+ * @returns {*} - Resolved params
159
+ */
160
+ export function resolveAllParams(params, env) {
161
+ if (params === null || params === undefined) return params;
162
+ if (typeof params === 'string') return resolveVariables(params, env);
163
+ if (Array.isArray(params)) {
164
+ return params.map(item => resolveAllParams(item, env));
165
+ }
166
+ if (typeof params === 'object') {
167
+ const result = {};
168
+ for (const [key, value] of Object.entries(params)) {
169
+ result[key] = resolveAllParams(value, env);
170
+ }
171
+ return result;
172
+ }
173
+ return params;
174
+ }
175
+
176
+ // --- Additional helpers used by server routes ---
177
+
178
+ /**
179
+ * Replace the base environment entirely.
180
+ * @param {string} projectDir
181
+ * @param {object} base - New base environment
182
+ * @returns {object} The new base environment
183
+ */
184
+ export function updateBase(projectDir, base) {
185
+ const filePath = getEnvFilePath(projectDir);
186
+ const config = readEnvFile(filePath);
187
+ config.base = base;
188
+ writeEnvFile(filePath, config);
189
+ return config.base;
190
+ }
191
+
192
+ /**
193
+ * Set the active environment name.
194
+ * @param {string} projectDir
195
+ * @param {string|null} name - Environment name or null to deactivate
196
+ * @returns {{ active: string|null }}
197
+ * @throws {Error} If the named environment does not exist
198
+ */
199
+ export function setActive(projectDir, name) {
200
+ const filePath = getEnvFilePath(projectDir);
201
+ const config = readEnvFile(filePath);
202
+ if (name !== null && !config.environments[name]) {
203
+ throw new Error(`Environment not found: ${name}`);
204
+ }
205
+ config.active = name;
206
+ writeEnvFile(filePath, config);
207
+ return { active: config.active };
208
+ }
209
+
210
+ /**
211
+ * Create or update a named environment.
212
+ * @param {string} projectDir
213
+ * @param {string} name - Environment name
214
+ * @param {object} vars - Key-value pairs
215
+ * @returns {object} The saved environment
216
+ */
217
+ export function updateNamedEnv(projectDir, name, vars) {
218
+ const filePath = getEnvFilePath(projectDir);
219
+ const config = readEnvFile(filePath);
220
+ config.environments[name] = vars;
221
+ writeEnvFile(filePath, config);
222
+ return vars;
223
+ }
224
+
225
+ /**
226
+ * Delete a named environment.
227
+ * @param {string} projectDir
228
+ * @param {string} name - Environment name
229
+ * @returns {{ deleted: string }}
230
+ * @throws {Error} If not found
231
+ */
232
+ export function deleteNamedEnv(projectDir, name) {
233
+ const filePath = getEnvFilePath(projectDir);
234
+ const config = readEnvFile(filePath);
235
+ if (!config.environments[name]) {
236
+ throw new Error(`Environment not found: ${name}`);
237
+ }
238
+ delete config.environments[name];
239
+ if (config.active === name) {
240
+ config.active = null;
241
+ }
242
+ writeEnvFile(filePath, config);
243
+ return { deleted: name };
244
+ }
@@ -0,0 +1,41 @@
1
+ import { execFile } from 'node:child_process';
2
+
3
+ /**
4
+ * Execute a git command and return trimmed stdout.
5
+ * Rejects if the command fails.
6
+ */
7
+ function gitExec(args, cwd) {
8
+ return new Promise((resolve, reject) => {
9
+ execFile('git', args, { cwd }, (error, stdout, stderr) => {
10
+ if (error) {
11
+ reject(error);
12
+ return;
13
+ }
14
+ resolve(stdout.trim());
15
+ });
16
+ });
17
+ }
18
+
19
+ /**
20
+ * Get git info for a project directory.
21
+ * @param {string} projectDir - Path to the project directory
22
+ * @returns {Promise<{branch: string, commit: string, dirty: boolean} | null>}
23
+ * Returns null if not a git repo or git commands fail.
24
+ */
25
+ export async function getGitInfo(projectDir) {
26
+ try {
27
+ const [branch, commit, status] = await Promise.all([
28
+ gitExec(['rev-parse', '--abbrev-ref', 'HEAD'], projectDir),
29
+ gitExec(['rev-parse', '--short', 'HEAD'], projectDir),
30
+ gitExec(['status', '--porcelain'], projectDir),
31
+ ]);
32
+
33
+ return {
34
+ branch,
35
+ commit,
36
+ dirty: status.length > 0,
37
+ };
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
@@ -0,0 +1,94 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getConfigDir, getProjectHash } from './env-resolver.js';
4
+
5
+ const DEFAULT_PARAMS = () => ({
6
+ path: {},
7
+ query: {},
8
+ headers: {},
9
+ body: null,
10
+ content_type: null,
11
+ });
12
+
13
+ /**
14
+ * Get the params file path for a project.
15
+ */
16
+ function getParamsFilePath(projectDir) {
17
+ const configDir = getConfigDir();
18
+ const hash = getProjectHash(projectDir);
19
+ return path.join(configDir, 'params', `${hash}.json`);
20
+ }
21
+
22
+ /**
23
+ * Read and parse the params file, returning the full object.
24
+ * Returns an empty object if the file doesn't exist or is corrupt.
25
+ */
26
+ function readParamsFile(filePath) {
27
+ try {
28
+ if (!fs.existsSync(filePath)) {
29
+ return {};
30
+ }
31
+ const raw = fs.readFileSync(filePath, 'utf-8');
32
+ const parsed = JSON.parse(raw);
33
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
34
+ console.warn(`[params-store] Corrupt params file at ${filePath}, returning defaults`);
35
+ return {};
36
+ }
37
+ return parsed;
38
+ } catch (err) {
39
+ console.warn(`[params-store] Failed to read params file at ${filePath}: ${err.message}`);
40
+ return {};
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Write the full params object to the params file.
46
+ */
47
+ function writeParamsFile(filePath, data) {
48
+ const dir = path.dirname(filePath);
49
+ fs.mkdirSync(dir, { recursive: true });
50
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
51
+ }
52
+
53
+ /**
54
+ * Load params for a specific endpoint.
55
+ * @param {string} projectDir - Project directory path
56
+ * @param {string} method - HTTP method (e.g., "GET")
57
+ * @param {string} endpointPath - Endpoint path (e.g., "/users/{id}")
58
+ * @returns {{ path: object, query: object, headers: object, body: any, content_type: string|null }}
59
+ */
60
+ export function loadParams(projectDir, method, endpointPath) {
61
+ const filePath = getParamsFilePath(projectDir);
62
+ const allParams = readParamsFile(filePath);
63
+ const key = `${method.toUpperCase()} ${endpointPath}`;
64
+ const stored = allParams[key];
65
+ if (stored && typeof stored === 'object') {
66
+ return { ...DEFAULT_PARAMS(), ...stored };
67
+ }
68
+ return DEFAULT_PARAMS();
69
+ }
70
+
71
+ /**
72
+ * Save params for a specific endpoint.
73
+ * @param {string} projectDir - Project directory path
74
+ * @param {string} method - HTTP method
75
+ * @param {string} endpointPath - Endpoint path
76
+ * @param {object} params - Params to save
77
+ */
78
+ export function saveParams(projectDir, method, endpointPath, params) {
79
+ const filePath = getParamsFilePath(projectDir);
80
+ const allParams = readParamsFile(filePath);
81
+ const key = `${method.toUpperCase()} ${endpointPath}`;
82
+ allParams[key] = params;
83
+ writeParamsFile(filePath, allParams);
84
+ }
85
+
86
+ /**
87
+ * Load all params for a project.
88
+ * @param {string} projectDir - Project directory path
89
+ * @returns {object} Full params object keyed by "METHOD /path"
90
+ */
91
+ export function loadAllParams(projectDir) {
92
+ const filePath = getParamsFilePath(projectDir);
93
+ return readParamsFile(filePath);
94
+ }
@@ -0,0 +1,150 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getConfigDir, getProjectHash } from './env-resolver.js';
4
+
5
+ const GLOBAL_DEFAULTS = () => ({
6
+ theme: 'dark',
7
+ view_mode: 'tag',
8
+ response_wrap: false,
9
+ last_active_project: null,
10
+ });
11
+
12
+ const PROJECT_DEFAULTS = () => ({
13
+ selected_server: 0,
14
+ server_variables: {},
15
+ doc_collapsed: false,
16
+ selected_endpoint: null,
17
+ tree_collapse_state: {},
18
+ response_tab: 'body',
19
+ });
20
+
21
+ /**
22
+ * Get the global preferences file path.
23
+ */
24
+ function getGlobalPrefsPath() {
25
+ return path.join(getConfigDir(), 'preferences.json');
26
+ }
27
+
28
+ /**
29
+ * Get the per-project preferences file path.
30
+ */
31
+ function getProjectPrefsPath(projectDir) {
32
+ const hash = getProjectHash(projectDir);
33
+ return path.join(getConfigDir(), 'preferences', `${hash}.json`);
34
+ }
35
+
36
+ /**
37
+ * Safely read and parse a JSON file.
38
+ * Returns null if the file doesn't exist.
39
+ * Returns null and logs a warning if the file is corrupt.
40
+ */
41
+ function readJsonFile(filePath) {
42
+ try {
43
+ if (!fs.existsSync(filePath)) {
44
+ return null;
45
+ }
46
+ const raw = fs.readFileSync(filePath, 'utf-8');
47
+ const parsed = JSON.parse(raw);
48
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
49
+ console.warn(`[preferences-store] Corrupt file at ${filePath}, resetting to defaults`);
50
+ return null;
51
+ }
52
+ return parsed;
53
+ } catch (err) {
54
+ console.warn(`[preferences-store] Failed to read ${filePath}: ${err.message}, resetting to defaults`);
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Write a JSON object to a file, creating directories as needed.
61
+ */
62
+ function writeJsonFile(filePath, data) {
63
+ const dir = path.dirname(filePath);
64
+ fs.mkdirSync(dir, { recursive: true });
65
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
66
+ }
67
+
68
+ /**
69
+ * Load global and per-project preferences.
70
+ * Missing or corrupt files fall back to defaults.
71
+ * @param {string} projectDir - Project directory path
72
+ * @returns {{ global: object, project: object }}
73
+ */
74
+ export function loadPreferences(projectDir) {
75
+ const globalPath = getGlobalPrefsPath();
76
+ const projectPath = getProjectPrefsPath(projectDir);
77
+
78
+ const globalStored = readJsonFile(globalPath);
79
+ const projectStored = readJsonFile(projectPath);
80
+
81
+ return {
82
+ global: { ...GLOBAL_DEFAULTS(), ...(globalStored || {}) },
83
+ project: { ...PROJECT_DEFAULTS(), ...(projectStored || {}) },
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Save preferences with merge (PATCH) semantics.
89
+ * Only updates provided keys; existing values are preserved.
90
+ * @param {string} projectDir - Project directory path
91
+ * @param {object} patch - Object with optional `global` and/or `project` keys
92
+ * @returns {{ global: object, project: object }}
93
+ */
94
+ export function savePreferences(projectDir, patch) {
95
+ const globalPath = getGlobalPrefsPath();
96
+ const projectPath = getProjectPrefsPath(projectDir);
97
+
98
+ // Load current values
99
+ const currentGlobal = readJsonFile(globalPath) || {};
100
+ const currentProject = readJsonFile(projectPath) || {};
101
+
102
+ // Merge patches
103
+ if (patch.global && typeof patch.global === 'object') {
104
+ Object.assign(currentGlobal, patch.global);
105
+ }
106
+ if (patch.project && typeof patch.project === 'object') {
107
+ Object.assign(currentProject, patch.project);
108
+ }
109
+
110
+ // Write back
111
+ writeJsonFile(globalPath, currentGlobal);
112
+ writeJsonFile(projectPath, currentProject);
113
+
114
+ // Return with defaults applied
115
+ return {
116
+ global: { ...GLOBAL_DEFAULTS(), ...currentGlobal },
117
+ project: { ...PROJECT_DEFAULTS(), ...currentProject },
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Load global preferences only (no project context needed).
123
+ * @returns {{ global: object, project: null }}
124
+ */
125
+ export function loadGlobalPreferences() {
126
+ const globalPath = getGlobalPrefsPath();
127
+ const globalStored = readJsonFile(globalPath);
128
+ return {
129
+ global: { ...GLOBAL_DEFAULTS(), ...(globalStored || {}) },
130
+ project: null,
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Save global preferences only (no project context needed).
136
+ * @param {object} patch - Object with `global` key
137
+ * @returns {{ global: object, project: null }}
138
+ */
139
+ export function saveGlobalPreferences(patch) {
140
+ const globalPath = getGlobalPrefsPath();
141
+ const currentGlobal = readJsonFile(globalPath) || {};
142
+ if (patch.global && typeof patch.global === 'object') {
143
+ Object.assign(currentGlobal, patch.global);
144
+ }
145
+ writeJsonFile(globalPath, currentGlobal);
146
+ return {
147
+ global: { ...GLOBAL_DEFAULTS(), ...currentGlobal },
148
+ project: null,
149
+ };
150
+ }
@@ -0,0 +1,173 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getConfigDir } from './env-resolver.js';
4
+
5
+ /**
6
+ * Get the projects.json file path.
7
+ */
8
+ function getProjectsFilePath() {
9
+ return path.join(getConfigDir(), 'projects.json');
10
+ }
11
+
12
+ /**
13
+ * Read and parse the projects file.
14
+ * Returns an empty array if the file doesn't exist or is corrupt.
15
+ */
16
+ function readProjectsFile() {
17
+ const filePath = getProjectsFilePath();
18
+ try {
19
+ if (!fs.existsSync(filePath)) {
20
+ return [];
21
+ }
22
+ const raw = fs.readFileSync(filePath, 'utf-8');
23
+ const parsed = JSON.parse(raw);
24
+ if (!Array.isArray(parsed)) {
25
+ console.warn(`[projects-store] Corrupt projects file at ${filePath}, returning empty array`);
26
+ return [];
27
+ }
28
+ return parsed;
29
+ } catch (err) {
30
+ console.warn(`[projects-store] Failed to read projects file: ${err.message}`);
31
+ return [];
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Write the projects array to the projects file.
37
+ */
38
+ function writeProjectsFile(projects) {
39
+ const filePath = getProjectsFilePath();
40
+ const dir = path.dirname(filePath);
41
+ fs.mkdirSync(dir, { recursive: true });
42
+ fs.writeFileSync(filePath, JSON.stringify(projects, null, 2), 'utf-8');
43
+ }
44
+
45
+ /**
46
+ * Resolve a project path to an absolute, real path.
47
+ */
48
+ function resolvePath(projectPath) {
49
+ const resolved = path.resolve(projectPath);
50
+ try {
51
+ return fs.realpathSync(resolved);
52
+ } catch {
53
+ // If realpathSync fails (e.g., path doesn't exist), return the resolved path
54
+ return resolved;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Enrich a stored project record with the derived name.
60
+ */
61
+ function enrichProject(record) {
62
+ return {
63
+ path: record.path,
64
+ name: path.basename(record.path),
65
+ spec: record.spec || null,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Load all bookmarked projects.
71
+ * @returns {Array<{path: string, name: string, spec: string|null}>}
72
+ */
73
+ export function loadProjects() {
74
+ const records = readProjectsFile();
75
+ return records.map(enrichProject);
76
+ }
77
+
78
+ /**
79
+ * Add a project bookmark.
80
+ * @param {string} projectPath - Directory path to bookmark
81
+ * @param {string|null} [specOverride] - Optional spec file path override
82
+ * @returns {{ path: string, name: string, spec: string|null }}
83
+ * @throws {Error} If the directory doesn't exist or is already bookmarked
84
+ */
85
+ export function addProject(projectPath, specOverride = null) {
86
+ // Validate directory exists
87
+ const resolvedPath = resolvePath(projectPath);
88
+ try {
89
+ const stat = fs.statSync(resolvedPath);
90
+ if (!stat.isDirectory()) {
91
+ throw new Error(`Not a directory: ${resolvedPath}`);
92
+ }
93
+ } catch (err) {
94
+ if (err.code === 'ENOENT') {
95
+ throw new Error(`Directory does not exist: ${resolvedPath}`);
96
+ }
97
+ throw err;
98
+ }
99
+
100
+ // Check for duplicates
101
+ const records = readProjectsFile();
102
+ const existing = records.find((r) => resolvePath(r.path) === resolvedPath);
103
+ if (existing) {
104
+ throw new Error(`Project already bookmarked: ${resolvedPath}`);
105
+ }
106
+
107
+ // Add the project
108
+ const record = { path: resolvedPath };
109
+ if (specOverride) {
110
+ record.spec = specOverride;
111
+ }
112
+ records.push(record);
113
+ writeProjectsFile(records);
114
+
115
+ return enrichProject(record);
116
+ }
117
+
118
+ /**
119
+ * Remove a project bookmark.
120
+ * @param {string} projectPath - Directory path to remove
121
+ * @returns {{ deleted: string }}
122
+ * @throws {Error} If the project is not bookmarked
123
+ */
124
+ export function removeProject(projectPath) {
125
+ const resolvedPath = resolvePath(projectPath);
126
+ const records = readProjectsFile();
127
+ const index = records.findIndex((r) => resolvePath(r.path) === resolvedPath);
128
+
129
+ if (index === -1) {
130
+ throw new Error(`Project not found: ${resolvedPath}`);
131
+ }
132
+
133
+ records.splice(index, 1);
134
+ writeProjectsFile(records);
135
+
136
+ return { deleted: resolvedPath };
137
+ }
138
+
139
+ /**
140
+ * Find a project by exact path match.
141
+ * @param {string} projectPath - Directory path to find
142
+ * @returns {{ path: string, name: string, spec: string|null } | null}
143
+ */
144
+ export function findProject(projectPath) {
145
+ const resolvedPath = resolvePath(projectPath);
146
+ const records = readProjectsFile();
147
+ const found = records.find((r) => resolvePath(r.path) === resolvedPath);
148
+ return found ? enrichProject(found) : null;
149
+ }
150
+
151
+ /**
152
+ * Find a bookmarked project that contains the given directory.
153
+ * Checks if dir equals or is a subdirectory of any bookmarked project.
154
+ * When multiple projects match, returns the deepest (most specific) one.
155
+ * @param {string} dir - Directory to check
156
+ * @returns {{ path: string, name: string, spec: string|null } | null}
157
+ */
158
+ export function findProjectForDir(dir) {
159
+ const resolvedDir = resolvePath(dir);
160
+ const records = readProjectsFile();
161
+ let best = null;
162
+ let bestLen = 0;
163
+ for (const record of records) {
164
+ const projectPath = resolvePath(record.path);
165
+ if (resolvedDir === projectPath || resolvedDir.startsWith(projectPath + path.sep)) {
166
+ if (projectPath.length > bestLen) {
167
+ best = record;
168
+ bestLen = projectPath.length;
169
+ }
170
+ }
171
+ }
172
+ return best ? enrichProject(best) : null;
173
+ }