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,189 @@
1
+ // cli/commands/send.js — Send an API request via curl
2
+
3
+ import fs from 'node:fs';
4
+ import jq from '@michaelhomer/jqjs';
5
+ import { parseSpec, extractEndpoints, getEndpointDetails } from '../../core/spec-parser.js';
6
+ import { buildCurlArgs, buildUrl, resolveVariables, shellQuote } from '../../core/curl-builder.js';
7
+ import { loadEnvironments, resolveEnvironment, resolveAllParams } from '../../core/env-resolver.js';
8
+ import { executeCurl } from '../../core/curl-executor.js';
9
+ import { saveLastResponse } from '../../core/response-store.js';
10
+ import { formatResponse } from '../output.js';
11
+ import { resolveProjectContext } from '../resolve-project.js';
12
+
13
+ /**
14
+ * Resolve body data from -d argument.
15
+ * Supports: inline JSON, @filename (read from file), @- (read from stdin).
16
+ */
17
+ function resolveBodyData(data) {
18
+ if (!data) return data;
19
+ if (typeof data !== 'string') return data;
20
+ if (!data.startsWith('@')) return data;
21
+
22
+ const source = data.slice(1);
23
+ if (source === '-') {
24
+ return fs.readFileSync(0, 'utf8').trim();
25
+ }
26
+ if (!fs.existsSync(source)) {
27
+ console.error(`Body file not found: ${source}`);
28
+ process.exitCode = 1;
29
+ return null;
30
+ }
31
+ return fs.readFileSync(source, 'utf8').trim();
32
+ }
33
+
34
+ /**
35
+ * Send command handler.
36
+ * @param {object} argv - Parsed CLI arguments
37
+ */
38
+ export async function sendCommand(argv) {
39
+ const { projectDir, specPath } = await resolveProjectContext(argv);
40
+ if (!specPath) {
41
+ console.error('No spec file found');
42
+ process.exit(2);
43
+ }
44
+
45
+ let spec;
46
+ try {
47
+ spec = await parseSpec(specPath);
48
+ } catch (err) {
49
+ console.error(`Spec parse error: ${err.message}`);
50
+ process.exit(2);
51
+ }
52
+
53
+ const method = argv.method.toUpperCase();
54
+ const pathArg = argv.path;
55
+ const apiPath = typeof pathArg === 'string' ? (pathArg.startsWith('/') ? pathArg : '/' + pathArg) : '/' + String(pathArg);
56
+ const details = getEndpointDetails(spec, method, apiPath);
57
+ if (!details) {
58
+ console.error(`Endpoint not found: ${method} ${apiPath}`);
59
+ process.exit(3);
60
+ }
61
+
62
+ // Parse path params from --pathParam key=value pairs
63
+ const pathParams = {};
64
+ if (argv.pathParam) {
65
+ for (const p of argv.pathParam) {
66
+ const [k, ...v] = p.split('=');
67
+ pathParams[k] = v.join('=');
68
+ }
69
+ }
70
+
71
+ const queryParams = {};
72
+ if (argv.query) {
73
+ for (const q of argv.query) {
74
+ const [k, ...v] = q.split('=');
75
+ queryParams[k] = v.join('=');
76
+ }
77
+ }
78
+ const headers = {};
79
+ if (argv.header) {
80
+ for (const h of argv.header) {
81
+ const [k, ...v] = h.split(':');
82
+ headers[k.trim()] = v.join(':').trim();
83
+ }
84
+ }
85
+
86
+ // Resolve environment
87
+ let env = {};
88
+ try {
89
+ const envData = loadEnvironments(projectDir);
90
+ if (argv.env) {
91
+ envData.active = argv.env;
92
+ }
93
+ // Manually merge base + active environment
94
+ env = { ...envData.base };
95
+ if (envData.active && envData.environments[envData.active]) {
96
+ Object.assign(env, envData.environments[envData.active]);
97
+ }
98
+ } catch (e) {
99
+ // No env file, continue without
100
+ }
101
+
102
+ // Resolve {{ variable }} references
103
+ for (const [k, v] of Object.entries(pathParams)) {
104
+ pathParams[k] = resolveVariables(v, env);
105
+ }
106
+ for (const [k, v] of Object.entries(queryParams)) {
107
+ queryParams[k] = resolveVariables(v, env);
108
+ }
109
+ for (const [k, v] of Object.entries(headers)) {
110
+ headers[k] = resolveVariables(v, env);
111
+ }
112
+ const bodyData = resolveBodyData(argv.data);
113
+ if (bodyData === null && argv.data) {
114
+ return; // resolveBodyData already printed error
115
+ }
116
+ const resolvedBody = bodyData ? resolveVariables(bodyData, env) : bodyData;
117
+
118
+ const servers = details.servers || spec.servers || [];
119
+ const url = buildUrl(servers, 0, {}, apiPath, pathParams, queryParams, env.base_url);
120
+
121
+ const contentType = resolvedBody ? 'application/json' : null;
122
+ const curlArgs = buildCurlArgs({
123
+ method,
124
+ url,
125
+ headers,
126
+ body: resolvedBody,
127
+ contentType,
128
+ insecure: env.insecure === true,
129
+ timeout: env.timeout || 30,
130
+ outputFile: 'TMPFILE',
131
+ });
132
+
133
+ if (argv.curl) {
134
+ console.log(`curl ${curlArgs.map(a => shellQuote(a)).join(' ')}`);
135
+ process.exit(0);
136
+ }
137
+
138
+ if (argv.dryRun) {
139
+ console.log(`Would send: ${method} ${url}`);
140
+ console.log(`curl ${curlArgs.map(a => shellQuote(a)).join(' ')}`);
141
+ process.exit(0);
142
+ }
143
+
144
+ try {
145
+ const response = await executeCurl(curlArgs);
146
+
147
+ // Cache response for later retrieval
148
+ try {
149
+ saveLastResponse(projectDir, method, apiPath, {
150
+ status: response.status,
151
+ status_text: response.status_text,
152
+ headers: response.headers,
153
+ body: response.body,
154
+ size_bytes: response.size_bytes,
155
+ timing: response.timing,
156
+ curl_command: response.curl_command,
157
+ });
158
+ } catch {
159
+ // Non-critical — don't fail the command if caching fails
160
+ }
161
+
162
+ if (argv.filter) {
163
+ // Apply jq filter to response body
164
+ let parsed;
165
+ try {
166
+ parsed = JSON.parse(response.body);
167
+ } catch {
168
+ console.error('Cannot apply --filter: response body is not valid JSON');
169
+ console.log(response.body);
170
+ process.exit(1);
171
+ }
172
+ try {
173
+ const fn = jq.compile(argv.filter);
174
+ const results = [...fn(parsed)];
175
+ for (const r of results) {
176
+ console.log(typeof r === 'string' ? r : JSON.stringify(r, null, 2));
177
+ }
178
+ } catch (err) {
179
+ console.error(`jq filter error: ${err.message}`);
180
+ process.exit(1);
181
+ }
182
+ } else {
183
+ console.log(formatResponse(response, { verbose: argv.verbose, headers: argv.headers }));
184
+ }
185
+ } catch (err) {
186
+ console.error(`Request failed: ${err.message}`);
187
+ process.exit(1);
188
+ }
189
+ }
@@ -0,0 +1,46 @@
1
+ import fs from 'node:fs';
2
+ import { parseSpec } from '../../core/spec-parser.js';
3
+ import { createServer } from '../../server/index.js';
4
+ import { resolveProjectContext } from '../resolve-project.js';
5
+
6
+ export async function serveCommand(argv) {
7
+ const { projectDir, specPath } = await resolveProjectContext(argv);
8
+
9
+ if (!fs.existsSync(projectDir)) {
10
+ console.error(`Project directory does not exist: ${projectDir}`);
11
+ process.exitCode = 2;
12
+ return;
13
+ }
14
+
15
+ let spec = null;
16
+ if (specPath) {
17
+ try {
18
+ spec = await parseSpec(specPath);
19
+ } catch (err) {
20
+ console.error(`Warning: Spec parse error: ${err.message}`);
21
+ }
22
+ }
23
+
24
+ const noBrowse = argv.noBrowse || argv['no-browse'] || false;
25
+ const port = argv.port || 3000;
26
+ const host = argv.host || '127.0.0.1';
27
+
28
+ // Warn when exposing to network without --no-browse
29
+ if (host !== '127.0.0.1' && host !== 'localhost' && !noBrowse) {
30
+ console.warn('WARNING: Binding to a public interface without --no-browse.');
31
+ console.warn(' The /api/browse endpoint exposes your filesystem.');
32
+ console.warn(' Use --no-browse to disable directory listing and project switching.');
33
+ }
34
+
35
+ const { app } = createServer({ projectDir, specPath, spec, noBrowse });
36
+
37
+ app.listen(port, host, () => {
38
+ console.log(`Server listening on http://${host}:${port}`);
39
+ if (argv.open) {
40
+ import('child_process').then(({ exec }) => {
41
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
42
+ exec(`${cmd} http://${host}:${port}`);
43
+ });
44
+ }
45
+ });
46
+ }
package/cli/index.js ADDED
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+ import yargs from 'yargs';
3
+ import { hideBin } from 'yargs/helpers';
4
+
5
+ // Register commands
6
+ const cli = yargs(hideBin(process.argv))
7
+ .scriptName('apigrip')
8
+ .usage('$0 <command> [options]')
9
+ .command('serve', 'Start the web UI server', (yargs) => {
10
+ return yargs
11
+ .option('port', { type: 'number', default: 3000, describe: 'Port to listen on' })
12
+ .option('host', { type: 'string', default: '127.0.0.1', describe: 'Host to bind to' })
13
+ .option('open', { type: 'boolean', default: false, describe: 'Open browser on start' })
14
+ .option('no-browse', { type: 'boolean', default: false, describe: 'Disable directory browsing and project switching' });
15
+ }, async (argv) => {
16
+ const { serveCommand } = await import('./commands/serve.js');
17
+ await serveCommand(argv);
18
+ })
19
+ .command('send <method> <path>', 'Send an API request', (yargs) => {
20
+ return yargs
21
+ .positional('method', { type: 'string', describe: 'HTTP method' })
22
+ .positional('path', { type: 'string', describe: 'API path' })
23
+ .option('d', { alias: 'data', type: 'string', describe: 'Request body' })
24
+ .option('e', { alias: 'env', type: 'string', describe: 'Environment name' })
25
+ .option('query', { type: 'array', describe: 'Query params (key=value)' })
26
+ .option('header', { type: 'array', describe: 'Headers (Name: value)' })
27
+ .option('pathParam', { type: 'array', describe: 'Path params (key=value)' })
28
+ .option('v', { alias: 'verbose', type: 'boolean', describe: 'Verbose output' })
29
+ .option('i', { alias: 'headers', type: 'boolean', describe: 'Include headers' })
30
+ .option('dry-run', { type: 'boolean', describe: 'Print request without sending' })
31
+ .option('curl', { type: 'boolean', describe: 'Print curl command only' })
32
+ .option('filter', { type: 'string', describe: 'jq filter for response body (e.g. .data[0].name)' })
33
+ .option('spec', { type: 'string', describe: 'Path to spec file' })
34
+ .option('project', { type: 'string', describe: 'Project directory' });
35
+ }, async (argv) => {
36
+ const { sendCommand } = await import('./commands/send.js');
37
+ await sendCommand(argv);
38
+ })
39
+ .command('curl <method> <path>', 'Generate curl command', (yargs) => {
40
+ return yargs
41
+ .positional('method', { type: 'string' })
42
+ .positional('path', { type: 'string' })
43
+ .option('d', { alias: 'data', type: 'string' })
44
+ .option('e', { alias: 'env', type: 'string' })
45
+ .option('query', { type: 'array' })
46
+ .option('header', { type: 'array' })
47
+ .option('pathParam', { type: 'array', describe: 'Path params (key=value)' })
48
+ .option('spec', { type: 'string' })
49
+ .option('project', { type: 'string' });
50
+ }, async (argv) => {
51
+ const { curlCommand } = await import('./commands/curl.js');
52
+ await curlCommand(argv);
53
+ })
54
+ .command('list', 'List endpoints from spec', (yargs) => {
55
+ return yargs
56
+ .option('tag', { type: 'string', describe: 'Filter by tag' })
57
+ .option('search', { type: 'string', describe: 'Search endpoints' })
58
+ .option('json', { type: 'boolean', describe: 'JSON output' })
59
+ .option('spec', { type: 'string' })
60
+ .option('project', { type: 'string' });
61
+ }, async (argv) => {
62
+ const { listCommand } = await import('./commands/list.js');
63
+ await listCommand(argv);
64
+ })
65
+ .command('projects [action] [dir]', 'Manage project bookmarks', (yargs) => {
66
+ return yargs
67
+ .positional('action', { choices: ['add', 'remove'], describe: 'Action' })
68
+ .positional('dir', { type: 'string', describe: 'Directory path' });
69
+ }, async (argv) => {
70
+ const { projectsCommand } = await import('./commands/projects.js');
71
+ await projectsCommand(argv);
72
+ })
73
+ .command('last [method] [path]', 'Show last cached response for an endpoint', (yargs) => {
74
+ return yargs
75
+ .positional('method', { type: 'string', describe: 'HTTP method' })
76
+ .positional('path', { type: 'string', describe: 'API path' })
77
+ .option('json', { type: 'boolean', describe: 'Full JSON output' })
78
+ .option('v', { alias: 'verbose', type: 'boolean', describe: 'Verbose output' })
79
+ .option('i', { alias: 'headers', type: 'boolean', describe: 'Include headers' })
80
+ .option('project', { type: 'string', describe: 'Project directory' })
81
+ .option('spec', { type: 'string', describe: 'Path to spec file' });
82
+ }, async (argv) => {
83
+ const { lastCommand } = await import('./commands/last.js');
84
+ await lastCommand(argv);
85
+ })
86
+ .command('env [action] [name] [key] [value]', 'Manage environments', (yargs) => {
87
+ return yargs
88
+ .positional('action', { choices: ['show', 'set', 'delete', 'import'], describe: 'Action' })
89
+ .positional('name', { type: 'string', describe: 'Environment name or file path (for import)' })
90
+ .positional('key', { type: 'string', describe: 'Variable key' })
91
+ .positional('value', { type: 'string', describe: 'Variable value' })
92
+ .option('project', { type: 'string', describe: 'Project directory' })
93
+ .option('import-name', { type: 'string', describe: 'Target environment name for import (default: auto-detect)' })
94
+ .option('merge', { type: 'boolean', default: false, describe: 'Merge with existing values instead of replacing' });
95
+ }, async (argv) => {
96
+ const { envCommand } = await import('./commands/env.js');
97
+ await envCommand(argv);
98
+ })
99
+ .command('mcp', 'Start MCP server', (yargs) => {
100
+ return yargs
101
+ .option('project', { type: 'string' })
102
+ .option('spec', { type: 'string' });
103
+ }, async (argv) => {
104
+ const { mcpCommand } = await import('./commands/mcp.js');
105
+ await mcpCommand(argv);
106
+ })
107
+ .demandCommand(1, 'You need at least one command')
108
+ .help()
109
+ .argv;
package/cli/output.js ADDED
@@ -0,0 +1,168 @@
1
+ // cli/output.js — CLI output formatting functions
2
+
3
+ const RESET = '\x1b[0m';
4
+
5
+ const METHOD_COLORS = {
6
+ GET: '\x1b[32m', // green
7
+ POST: '\x1b[35m', // magenta
8
+ PUT: '\x1b[33m', // yellow
9
+ DELETE: '\x1b[31m', // red
10
+ PATCH: '\x1b[33m', // yellow
11
+ };
12
+
13
+ /**
14
+ * Color an HTTP method string using ANSI codes.
15
+ * @param {string} method - HTTP method (e.g. "GET", "POST")
16
+ * @returns {string} ANSI-colored method string
17
+ */
18
+ export function colorMethod(method) {
19
+ const upper = method.toUpperCase();
20
+ const color = METHOD_COLORS[upper] || '';
21
+ if (!color) return upper;
22
+ return `${color}${upper}${RESET}`;
23
+ }
24
+
25
+ /**
26
+ * Format rows into a simple padded text table.
27
+ * @param {string[][]} rows - Array of row arrays
28
+ * @param {string[]} [headers] - Optional header row
29
+ * @returns {string} Formatted table string
30
+ */
31
+ export function formatTable(rows, headers) {
32
+ const allRows = headers ? [headers, ...rows] : rows;
33
+ if (allRows.length === 0) return '';
34
+
35
+ // Determine the number of columns
36
+ const numCols = Math.max(...allRows.map(r => r.length));
37
+
38
+ // Calculate max width for each column
39
+ const colWidths = [];
40
+ for (let col = 0; col < numCols; col++) {
41
+ let maxWidth = 0;
42
+ for (const row of allRows) {
43
+ const cell = row[col] || '';
44
+ // Strip ANSI codes for width calculation
45
+ const stripped = cell.replace(/\x1b\[[0-9;]*m/g, '');
46
+ if (stripped.length > maxWidth) {
47
+ maxWidth = stripped.length;
48
+ }
49
+ }
50
+ colWidths.push(maxWidth);
51
+ }
52
+
53
+ // Format each row
54
+ const lines = allRows.map(row => {
55
+ const cells = [];
56
+ for (let col = 0; col < numCols; col++) {
57
+ const cell = row[col] || '';
58
+ const stripped = cell.replace(/\x1b\[[0-9;]*m/g, '');
59
+ const padding = colWidths[col] - stripped.length;
60
+ // Right-pad each cell (except last column)
61
+ if (col < numCols - 1) {
62
+ cells.push(cell + ' '.repeat(padding));
63
+ } else {
64
+ cells.push(cell);
65
+ }
66
+ }
67
+ return cells.join(' ');
68
+ });
69
+
70
+ return lines.join('\n');
71
+ }
72
+
73
+ /**
74
+ * Format a list of endpoints grouped by tag.
75
+ * @param {Array<{method: string, path: string, summary: string, tags: string[]}>} endpoints
76
+ * @param {object} [options]
77
+ * @param {boolean} [options.json] - If true, return JSON string instead
78
+ * @returns {string} Formatted endpoint list
79
+ */
80
+ export function formatEndpointList(endpoints, options = {}) {
81
+ if (options.json) {
82
+ return JSON.stringify(endpoints);
83
+ }
84
+
85
+ if (!endpoints || endpoints.length === 0) {
86
+ return 'No endpoints found.';
87
+ }
88
+
89
+ // Group endpoints by tag
90
+ const groups = new Map();
91
+ for (const ep of endpoints) {
92
+ const tags = ep.tags && ep.tags.length > 0 ? ep.tags : ['untagged'];
93
+ for (const tag of tags) {
94
+ if (!groups.has(tag)) {
95
+ groups.set(tag, []);
96
+ }
97
+ groups.get(tag).push(ep);
98
+ }
99
+ }
100
+
101
+ // Calculate max path width across all endpoints for consistent alignment
102
+ let maxPathLen = 0;
103
+ for (const ep of endpoints) {
104
+ if (ep.path.length > maxPathLen) maxPathLen = ep.path.length;
105
+ }
106
+ const pathPad = Math.max(maxPathLen + 2, 20);
107
+
108
+ const lines = [];
109
+ let first = true;
110
+ for (const [tag, eps] of groups) {
111
+ if (!first) lines.push('');
112
+ first = false;
113
+ lines.push(`[${tag}]`);
114
+ for (const ep of eps) {
115
+ // Pad the raw method to 8 chars for alignment, accounting for ANSI codes
116
+ const methodStr = colorMethod(ep.method);
117
+ const rawLen = ep.method.length;
118
+ const padded = methodStr + ' '.repeat(Math.max(0, 8 - rawLen));
119
+ const summary = ep.summary || '';
120
+ lines.push(` ${padded}${ep.path.padEnd(pathPad)}${summary}`);
121
+ }
122
+ }
123
+
124
+ return lines.join('\n');
125
+ }
126
+
127
+ /**
128
+ * Format a curl/HTTP response for terminal output.
129
+ * @param {object} response - Response object with status, statusText, headers, body, verbose
130
+ * @param {object} [options]
131
+ * @param {boolean} [options.verbose] - Show verbose output (like curl -v)
132
+ * @param {boolean} [options.headers] - Show status line + headers + body
133
+ * @returns {string} Formatted response output
134
+ */
135
+ export function formatResponse(response, options = {}) {
136
+ if (options.verbose) {
137
+ // Full verbose output
138
+ const parts = [];
139
+ if (response.verbose) {
140
+ parts.push(response.verbose);
141
+ }
142
+ if (response.body) {
143
+ parts.push(response.body);
144
+ }
145
+ return parts.join('\n');
146
+ }
147
+
148
+ if (options.headers) {
149
+ // Status line + headers + body
150
+ const parts = [];
151
+ const httpVersion = response.http_version || response.httpVersion || '1.1';
152
+ const statusText = response.status_text || response.statusText || '';
153
+ parts.push(`HTTP/${httpVersion} ${response.status} ${statusText}`);
154
+ if (response.headers) {
155
+ for (const [name, value] of Object.entries(response.headers)) {
156
+ parts.push(`${name}: ${value}`);
157
+ }
158
+ }
159
+ parts.push('');
160
+ if (response.body) {
161
+ parts.push(response.body);
162
+ }
163
+ return parts.join('\n');
164
+ }
165
+
166
+ // Default: body only
167
+ return response.body || '';
168
+ }
@@ -0,0 +1,43 @@
1
+ // cli/resolve-project.js — Shared project context resolution for all CLI commands
2
+ //
3
+ // Resolution order (per spec):
4
+ // 1. If --project or --spec is passed, use that
5
+ // 2. If cwd is inside a bookmarked project, use it
6
+ // 3. If cwd contains an OpenAPI spec file, use it directly
7
+ // 4. Otherwise, return null specPath
8
+
9
+ import { discoverSpec } from '../core/spec-discovery.js';
10
+ import { findProjectForDir } from '../core/projects-store.js';
11
+
12
+ /**
13
+ * Resolve the project directory and spec path from CLI args.
14
+ * @param {object} argv - Parsed CLI arguments (may have .project, .spec)
15
+ * @returns {Promise<{ projectDir: string, specPath: string|null }>}
16
+ */
17
+ export async function resolveProjectContext(argv) {
18
+ // 1. Explicit flags take priority
19
+ if (argv.spec) {
20
+ return { projectDir: argv.project || process.cwd(), specPath: argv.spec };
21
+ }
22
+ if (argv.project) {
23
+ const discovered = await discoverSpec(argv.project);
24
+ return { projectDir: argv.project, specPath: discovered?.specPath || null };
25
+ }
26
+
27
+ // 2. Check if cwd is inside a bookmarked project
28
+ const cwd = process.cwd();
29
+ const bookmark = findProjectForDir(cwd);
30
+ if (bookmark) {
31
+ const specPath = bookmark.spec || (await discoverSpec(bookmark.path))?.specPath || null;
32
+ return { projectDir: bookmark.path, specPath };
33
+ }
34
+
35
+ // 3. Try spec discovery in cwd
36
+ const discovered = await discoverSpec(cwd);
37
+ if (discovered?.specPath) {
38
+ return { projectDir: cwd, specPath: discovered.specPath };
39
+ }
40
+
41
+ // 4. Nothing found
42
+ return { projectDir: cwd, specPath: null };
43
+ }