@amodalai/amodal 0.2.10 → 0.3.1

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.
@@ -5,13 +5,60 @@
5
5
  */
6
6
 
7
7
  import type {CommandModule} from 'yargs';
8
+ import type {ChildProcess} from 'node:child_process';
8
9
  import {existsSync, readFileSync} from 'node:fs';
9
10
  import {createRequire} from 'node:module';
11
+ import {spawn} from 'node:child_process';
10
12
  import path from 'node:path';
11
13
  import {fileURLToPath} from 'node:url';
12
- import {createLocalServer, initLogLevel, interceptConsole} from '@amodalai/runtime';
14
+ import {createLocalServer, initLogLevel, interceptConsole, log} from '@amodalai/runtime';
15
+ import {resolveAdminAgent} from '@amodalai/core';
13
16
  import {findRepoRoot} from '../shared/repo-discovery.js';
17
+ import {findFreePort} from '../shared/find-free-port.js';
14
18
  import {runConnectionPreflight, printPreflightTable} from '../shared/connection-preflight.js';
19
+ import {resolveEnv} from '../shared/env-resolution.js';
20
+ import {getDb, ensureSchema, closeDb} from '@amodalai/db';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Constants
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const DEFAULT_RUNTIME_PORT = 3847;
27
+ const DEFAULT_STUDIO_PORT = 3848;
28
+ const DEFAULT_ADMIN_PORT = 3849;
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Studio resolution
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * Locate the @amodalai/studio package directory. Two strategies:
36
+ * 1. Sibling directory relative to the CLI package (works when symlinked)
37
+ * 2. Node module resolution via createRequire (works when installed)
38
+ */
39
+ function resolveStudioDir(): string | null {
40
+ // scriptDir is packages/cli/dist/src/commands/ at runtime (or packages/cli/src/commands/ in source)
41
+ // CLI package root is 3 levels up, then ../studio is the sibling package
42
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
43
+ const cliRoot = path.resolve(scriptDir, '..', '..', '..');
44
+ const siblingCandidate = path.resolve(cliRoot, '..', 'studio');
45
+ if (existsSync(path.join(siblingCandidate, 'package.json'))) {
46
+ return siblingCandidate;
47
+ }
48
+ const require = createRequire(import.meta.url);
49
+ try {
50
+ return path.dirname(require.resolve('@amodalai/studio/package.json'));
51
+ } catch (err: unknown) {
52
+ log.debug('studio_resolve_failed', {
53
+ error: err instanceof Error ? err.message : String(err),
54
+ });
55
+ return null;
56
+ }
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Options
61
+ // ---------------------------------------------------------------------------
15
62
 
16
63
  export interface DevOptions {
17
64
  cwd?: string;
@@ -20,12 +67,240 @@ export interface DevOptions {
20
67
  resume?: string;
21
68
  verbose?: number;
22
69
  quiet?: boolean;
70
+ /** Disable Studio subprocess. */
71
+ noStudio?: boolean;
72
+ /** Disable admin agent subprocess. */
73
+ noAdmin?: boolean;
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Subprocess helpers
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /** Managed subprocess with a label for log output. */
81
+ interface ManagedProcess {
82
+ label: string;
83
+ child: ChildProcess;
84
+ }
85
+
86
+ /**
87
+ * Pipe a child process's stdout/stderr to the parent's stderr, prefixed
88
+ * with a label. Lines are buffered per-stream to avoid interleaved output
89
+ * from concurrent subprocesses.
90
+ */
91
+ function pipeWithLabel(child: ChildProcess, label: string): void {
92
+ const prefix = `[${label}] `;
93
+ for (const stream of [child.stdout, child.stderr]) {
94
+ if (!stream) continue;
95
+ let buffer = '';
96
+ stream.setEncoding('utf-8');
97
+ stream.on('data', (chunk: string) => {
98
+ buffer += chunk;
99
+ const lines = buffer.split('\n');
100
+ // Keep the last (potentially incomplete) line in the buffer
101
+ buffer = lines.pop() ?? '';
102
+ for (const line of lines) {
103
+ process.stderr.write(`${prefix}${line}\n`);
104
+ }
105
+ });
106
+ stream.on('end', () => {
107
+ if (buffer.length > 0) {
108
+ process.stderr.write(`${prefix}${buffer}\n`);
109
+ buffer = '';
110
+ }
111
+ });
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Kill all managed subprocesses. Returns once all processes have exited
117
+ * or after a 5-second timeout (whichever comes first).
118
+ */
119
+ async function killAll(processes: ManagedProcess[]): Promise<void> {
120
+ const KILL_TIMEOUT_MS = 5000;
121
+ const exitPromises: Array<Promise<void>> = [];
122
+
123
+ for (const {child, label} of processes) {
124
+ if (child.exitCode !== null) continue; // already exited
125
+ exitPromises.push(
126
+ new Promise<void>((resolve) => {
127
+ const timer = setTimeout(() => {
128
+ log.warn('subprocess_kill_timeout', {label});
129
+ child.kill('SIGKILL');
130
+ resolve();
131
+ }, KILL_TIMEOUT_MS);
132
+
133
+ child.once('exit', () => {
134
+ clearTimeout(timer);
135
+ resolve();
136
+ });
137
+
138
+ child.kill('SIGTERM');
139
+ }),
140
+ );
141
+ }
142
+
143
+ await Promise.all(exitPromises);
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Studio subprocess
148
+ // ---------------------------------------------------------------------------
149
+
150
+ interface StudioSpawnResult {
151
+ process: ManagedProcess;
152
+ url: string;
153
+ }
154
+
155
+ function spawnStudio(opts: {
156
+ port: number;
157
+ runtimePort: number;
158
+ repoPath: string;
159
+ agentId?: string;
160
+ }): StudioSpawnResult | null {
161
+ // Resolve @amodalai/studio package directory. Try two strategies:
162
+ // 1. Sibling directory relative to the CLI package (works when symlinked from outside)
163
+ // 2. Node module resolution via createRequire (works when installed as a dependency)
164
+ const studioDir = resolveStudioDir();
165
+ if (!studioDir) {
166
+ log.info('studio_not_available', {
167
+ hint: '@amodalai/studio package not found — Studio subprocess skipped',
168
+ });
169
+ return null;
170
+ }
171
+
172
+ // Resolve the `next` binary from the studio-app's dependency tree.
173
+ // In pnpm the binary may live in the package's own node_modules or in a
174
+ // hoisted location; createRequire resolves correctly in both cases.
175
+ let nextBin: string;
176
+ try {
177
+ const studioRequire = createRequire(path.join(studioDir, 'package.json'));
178
+ // next/dist/bin/next is the actual CLI entrypoint
179
+ nextBin = studioRequire.resolve('next/dist/bin/next');
180
+ } catch {
181
+ log.info('studio_next_not_found', {
182
+ hint: 'next binary not resolvable from @amodalai/studio — Studio subprocess skipped',
183
+ });
184
+ return null;
185
+ }
186
+
187
+ const studioUrl = `http://localhost:${String(opts.port)}`;
188
+ const child = spawn(
189
+ process.execPath,
190
+ [nextBin, 'dev', '--port', String(opts.port)],
191
+ {
192
+ cwd: studioDir,
193
+ env: {
194
+ ...process.env,
195
+ REPO_PATH: opts.repoPath,
196
+ STUDIO_CORS_ORIGINS: `http://localhost:${String(opts.runtimePort)}`,
197
+ RUNTIME_URL: `http://localhost:${String(opts.runtimePort)}`,
198
+ PORT: String(opts.port),
199
+ ...(opts.agentId ? {AGENT_ID: opts.agentId} : {}),
200
+ },
201
+ stdio: ['ignore', 'pipe', 'pipe'],
202
+ },
203
+ );
204
+
205
+ child.once('error', (err) => {
206
+ log.warn('studio_spawn_error', {error: err.message});
207
+ });
208
+
209
+ const label = 'studio';
210
+ pipeWithLabel(child, label);
211
+
212
+ child.once('exit', (code, signal) => {
213
+ if (code !== null && code !== 0) {
214
+ log.warn('subprocess_exited', {label, code});
215
+ } else if (signal) {
216
+ log.debug('subprocess_signaled', {label, signal});
217
+ }
218
+ });
219
+
220
+ return {
221
+ process: {label, child},
222
+ url: studioUrl,
223
+ };
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Admin agent subprocess
228
+ // ---------------------------------------------------------------------------
229
+
230
+ interface AdminSpawnResult {
231
+ process: ManagedProcess;
232
+ url: string;
233
+ }
234
+
235
+ async function spawnAdminAgent(opts: {
236
+ port: number;
237
+ studioUrl: string | null;
238
+ repoPath: string;
239
+ }): Promise<AdminSpawnResult | null> {
240
+ const adminAgentPath = await resolveAdminAgent(opts.repoPath);
241
+ if (!adminAgentPath) {
242
+ log.info('admin_agent_not_available', {
243
+ hint: 'No admin agent found at ~/.amodal/admin-agent/ or in amodal.json — skipped',
244
+ });
245
+ return null;
246
+ }
247
+
248
+ // Verify it has an amodal.json (is a valid agent directory)
249
+ if (!existsSync(path.join(adminAgentPath, 'amodal.json'))) {
250
+ log.warn('admin_agent_invalid', {
251
+ path: adminAgentPath,
252
+ hint: 'Directory exists but has no amodal.json — skipped',
253
+ });
254
+ return null;
255
+ }
256
+
257
+ // Resolve the CLI entrypoint. We're running inside the CLI already, so
258
+ // use the same executable to spawn the admin agent's dev server.
259
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
260
+ const cliEntrypoint = path.resolve(scriptDir, '..', 'main.js');
261
+
262
+ const adminUrl = `http://localhost:${String(opts.port)}`;
263
+ const env: NodeJS.ProcessEnv = {
264
+ ...process.env,
265
+ };
266
+ if (opts.studioUrl) {
267
+ env['STUDIO_URL'] = opts.studioUrl;
268
+ }
269
+
270
+ const child = spawn(
271
+ process.execPath,
272
+ [cliEntrypoint, 'dev', '--port', String(opts.port)],
273
+ {
274
+ cwd: adminAgentPath,
275
+ env,
276
+ stdio: ['ignore', 'pipe', 'pipe'],
277
+ },
278
+ );
279
+
280
+ const label = 'admin';
281
+ pipeWithLabel(child, label);
282
+
283
+ child.once('exit', (code, signal) => {
284
+ if (code !== null && code !== 0) {
285
+ log.warn('subprocess_exited', {label, code});
286
+ } else if (signal) {
287
+ log.debug('subprocess_signaled', {label, signal});
288
+ }
289
+ });
290
+
291
+ return {
292
+ process: {label, child},
293
+ url: adminUrl,
294
+ };
23
295
  }
24
296
 
25
- const DEFAULT_PORT = 3847;
297
+ // ---------------------------------------------------------------------------
298
+ // Main dev command
299
+ // ---------------------------------------------------------------------------
26
300
 
27
301
  /**
28
- * Starts a local development server for the repo with hot reload enabled.
302
+ * Starts a local development server for the repo with hot reload enabled,
303
+ * and optionally spawns Studio and admin agent as subprocesses.
29
304
  */
30
305
  export async function runDev(options: DevOptions = {}): Promise<void> {
31
306
  initLogLevel({verbosity: options.verbose ?? 0, quiet: options.quiet ?? false});
@@ -40,26 +315,133 @@ export async function runDev(options: DevOptions = {}): Promise<void> {
40
315
  process.exit(1);
41
316
  }
42
317
 
43
- // Load .env file from the repo root (if present)
44
- const envPath = path.join(repoPath, '.env');
45
- if (existsSync(envPath)) {
46
- const envContent = readFileSync(envPath, 'utf-8');
47
- for (const line of envContent.split('\n')) {
48
- const trimmed = line.trim();
49
- if (!trimmed || trimmed.startsWith('#')) continue;
50
- const eqIdx = trimmed.indexOf('=');
51
- if (eqIdx === -1) continue;
52
- const key = trimmed.slice(0, eqIdx).trim();
53
- const value = trimmed.slice(eqIdx + 1).trim();
54
- if (key && !(key in process.env)) {
55
- process.env[key] = value;
318
+ // -------------------------------------------------------------------------
319
+ // Require DATABASE_URL
320
+ // -------------------------------------------------------------------------
321
+
322
+ const databaseUrl = resolveEnv('DATABASE_URL', repoPath);
323
+ if (!databaseUrl) {
324
+ log.error('database_url_required', {});
325
+ process.stderr.write(`
326
+ DATABASE_URL is required. Start Postgres and configure it:
327
+
328
+ docker run -d --name amodal-pg -p 5432:5432 \\
329
+ -e POSTGRES_DB=amodal -e POSTGRES_HOST_AUTH_METHOD=trust postgres:17
330
+
331
+ Then set the connection string:
332
+
333
+ echo 'DATABASE_URL=postgres://localhost:5432/amodal' >> ~/.amodal/.env
334
+
335
+ Or add it to your agent's .env file:
336
+
337
+ echo 'DATABASE_URL=postgres://localhost:5432/amodal' >> .env
338
+
339
+ `);
340
+ process.exit(1);
341
+ }
342
+
343
+ // Make DATABASE_URL available to child processes (runtime, Studio)
344
+ process.env['DATABASE_URL'] = databaseUrl;
345
+
346
+ // Read agent name from amodal.json for AGENT_ID
347
+ const amodalJsonPath = path.join(repoPath, 'amodal.json');
348
+ let agentId: string | undefined;
349
+ if (existsSync(amodalJsonPath)) {
350
+ try {
351
+ const amodalJson: unknown = JSON.parse(readFileSync(amodalJsonPath, 'utf-8'));
352
+ const parsed = typeof amodalJson === 'object' && amodalJson !== null
353
+ ? amodalJson
354
+ : undefined;
355
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- JSON.parse boundary: validated with typeof guard above
356
+ const nameValue = parsed !== undefined ? (parsed as Record<string, unknown>)['name'] : undefined;
357
+ if (typeof nameValue === 'string') {
358
+ agentId = nameValue;
359
+ process.env['AGENT_ID'] = agentId;
56
360
  }
361
+ } catch (err: unknown) {
362
+ log.warn('amodal_json_parse_error', {
363
+ path: amodalJsonPath,
364
+ error: err instanceof Error ? err.message : String(err),
365
+ });
57
366
  }
58
367
  }
59
368
 
60
- const port = options.port ?? DEFAULT_PORT;
369
+ // -------------------------------------------------------------------------
370
+ // Run schema migrations
371
+ // -------------------------------------------------------------------------
372
+
373
+ try {
374
+ const migrationDb = getDb(databaseUrl);
375
+ await ensureSchema(migrationDb);
376
+ await closeDb(); // Close migration connection — runtime and Studio open their own
377
+ log.info('schema_migration_complete', {});
378
+ } catch (err: unknown) {
379
+ const msg = err instanceof Error ? err.message : String(err);
380
+ log.error('schema_migration_failed', {error: msg});
381
+ process.stderr.write(`[dev] Failed to run database migrations: ${msg}\n`);
382
+ process.stderr.write('[dev] Is Postgres running and DATABASE_URL correct?\n');
383
+ process.exit(1);
384
+ }
385
+
61
386
  const host = options.host ?? '0.0.0.0';
62
387
 
388
+ // -------------------------------------------------------------------------
389
+ // Port allocation
390
+ // -------------------------------------------------------------------------
391
+
392
+ const runtimePort = await findFreePort(options.port ?? DEFAULT_RUNTIME_PORT);
393
+ const studioPort = options.noStudio
394
+ ? DEFAULT_STUDIO_PORT
395
+ : await findFreePort(DEFAULT_STUDIO_PORT);
396
+ const adminPort = options.noAdmin
397
+ ? DEFAULT_ADMIN_PORT
398
+ : await findFreePort(DEFAULT_ADMIN_PORT);
399
+
400
+ log.info('ports_allocated', {
401
+ runtime: runtimePort,
402
+ studio: options.noStudio ? null : studioPort,
403
+ admin: options.noAdmin ? null : adminPort,
404
+ });
405
+
406
+ // -------------------------------------------------------------------------
407
+ // Spawn subprocesses
408
+ // -------------------------------------------------------------------------
409
+
410
+ const managedProcesses: ManagedProcess[] = [];
411
+ let studioUrl: string | null = null;
412
+ let adminAgentUrl: string | null = null;
413
+
414
+ // Studio
415
+ if (!options.noStudio) {
416
+ const studioResult = spawnStudio({
417
+ port: studioPort,
418
+ runtimePort,
419
+ repoPath,
420
+ agentId,
421
+ });
422
+ if (studioResult) {
423
+ managedProcesses.push(studioResult.process);
424
+ studioUrl = studioResult.url;
425
+ }
426
+ }
427
+
428
+ // Admin agent
429
+ if (!options.noAdmin) {
430
+ const adminResult = await spawnAdminAgent({
431
+ port: adminPort,
432
+ studioUrl,
433
+ repoPath,
434
+ });
435
+ if (adminResult) {
436
+ managedProcesses.push(adminResult.process);
437
+ adminAgentUrl = adminResult.url;
438
+ }
439
+ }
440
+
441
+ // -------------------------------------------------------------------------
442
+ // Start the runtime server
443
+ // -------------------------------------------------------------------------
444
+
63
445
  process.stderr.write(`[dev] Starting dev server for ${repoPath}\n`);
64
446
 
65
447
  try {
@@ -88,16 +470,30 @@ export async function runDev(options: DevOptions = {}): Promise<void> {
88
470
 
89
471
  const server = await createLocalServer({
90
472
  repoPath,
91
- port,
473
+ port: runtimePort,
92
474
  host,
93
475
  hotReload: true,
94
476
  corsOrigin: '*',
95
477
  staticAppDir,
96
478
  resumeSessionId: options.resume,
479
+ studioUrl: studioUrl ?? undefined,
480
+ adminAgentUrl: adminAgentUrl ?? undefined,
97
481
  });
98
482
 
99
483
  await server.start();
100
484
 
485
+ // Print all URLs
486
+ process.stderr.write('\n');
487
+ process.stderr.write(` Runtime: http://localhost:${String(runtimePort)}\n`);
488
+ if (studioUrl) {
489
+ process.stderr.write(` Studio: ${studioUrl}\n`);
490
+ }
491
+ if (adminAgentUrl) {
492
+ process.stderr.write(` Admin Agent: ${adminAgentUrl}\n`);
493
+ }
494
+ process.stderr.write(` Database: ${databaseUrl}\n`);
495
+ process.stderr.write('\n');
496
+
101
497
  // Preflight connection check (non-blocking)
102
498
  const preflight = await runConnectionPreflight(repoPath);
103
499
  if (preflight.results.length > 0) {
@@ -112,6 +508,13 @@ export async function runDev(options: DevOptions = {}): Promise<void> {
112
508
  // Graceful shutdown
113
509
  const shutdown = async (signal: string): Promise<void> => {
114
510
  process.stderr.write(`\n[dev] Received ${signal}, shutting down...\n`);
511
+
512
+ // Kill subprocesses first
513
+ if (managedProcesses.length > 0) {
514
+ log.info('subprocess_shutdown', {count: managedProcesses.length});
515
+ await killAll(managedProcesses);
516
+ }
517
+
115
518
  await server.stop();
116
519
  process.exit(0);
117
520
  };
@@ -119,6 +522,10 @@ export async function runDev(options: DevOptions = {}): Promise<void> {
119
522
  process.on('SIGTERM', () => void shutdown('SIGTERM'));
120
523
  process.on('SIGINT', () => void shutdown('SIGINT'));
121
524
  } catch (err) {
525
+ // Kill any already-spawned subprocesses before exiting
526
+ if (managedProcesses.length > 0) {
527
+ await killAll(managedProcesses);
528
+ }
122
529
  const msg = err instanceof Error ? err.message : String(err);
123
530
  process.stderr.write(`[dev] Failed to start: ${msg}\n`);
124
531
  process.exit(1);
@@ -153,6 +560,16 @@ export const devCommand: CommandModule = {
153
560
  describe: 'Only show errors',
154
561
  default: false,
155
562
  },
563
+ 'no-studio': {
564
+ type: 'boolean',
565
+ describe: 'Do not spawn Studio subprocess',
566
+ default: false,
567
+ },
568
+ 'no-admin': {
569
+ type: 'boolean',
570
+ describe: 'Do not spawn admin agent subprocess',
571
+ default: false,
572
+ },
156
573
  },
157
574
  handler: async (argv) => {
158
575
  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
@@ -165,6 +582,10 @@ export const devCommand: CommandModule = {
165
582
  const verbose = argv['verbose'] as number;
166
583
  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
167
584
  const quiet = argv['quiet'] as boolean;
168
- await runDev({port, host, resume, verbose, quiet});
585
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
586
+ const noStudio = argv['no-studio'] as boolean;
587
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
588
+ const noAdmin = argv['no-admin'] as boolean;
589
+ await runDev({port, host, resume, verbose, quiet, noStudio, noAdmin});
169
590
  },
170
591
  };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Amodal Labs, Inc.
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+
7
+ import {describe, it, expect, beforeEach, afterEach} from 'vitest';
8
+ import {mkdtempSync, writeFileSync, mkdirSync, rmSync} from 'node:fs';
9
+ import path from 'node:path';
10
+ import os from 'node:os';
11
+ import {resolveEnv} from './env-resolution.js';
12
+
13
+ describe('resolveEnv', () => {
14
+ let tmpDir: string;
15
+ let fakeHome: string;
16
+ let origHome: string;
17
+ let origEnvValue: string | undefined;
18
+ const TEST_KEY = 'AMODAL_TEST_RESOLVE_ENV_KEY';
19
+
20
+ beforeEach(() => {
21
+ tmpDir = mkdtempSync(path.join(os.tmpdir(), 'env-resolution-test-'));
22
+ fakeHome = mkdtempSync(path.join(os.tmpdir(), 'env-resolution-home-'));
23
+ origHome = process.env['HOME'] ?? '';
24
+ origEnvValue = process.env[TEST_KEY];
25
+ delete process.env[TEST_KEY];
26
+ // Override HOME so ~/.amodal/env resolves to our temp dir
27
+ process.env['HOME'] = fakeHome;
28
+ });
29
+
30
+ afterEach(() => {
31
+ process.env['HOME'] = origHome;
32
+ if (origEnvValue !== undefined) {
33
+ process.env[TEST_KEY] = origEnvValue;
34
+ } else {
35
+ delete process.env[TEST_KEY];
36
+ }
37
+ rmSync(tmpDir, {recursive: true, force: true});
38
+ rmSync(fakeHome, {recursive: true, force: true});
39
+ });
40
+
41
+ it('resolves from agent .env file', () => {
42
+ writeFileSync(path.join(tmpDir, '.env'), `${TEST_KEY}=agent-value\n`);
43
+ expect(resolveEnv(TEST_KEY, tmpDir)).toBe('agent-value');
44
+ });
45
+
46
+ it('resolves from ~/.amodal/env', () => {
47
+ mkdirSync(path.join(fakeHome, '.amodal'), {recursive: true});
48
+ writeFileSync(path.join(fakeHome, '.amodal', '.env'), `${TEST_KEY}=global-value\n`);
49
+ expect(resolveEnv(TEST_KEY, tmpDir)).toBe('global-value');
50
+ });
51
+
52
+ it('resolves from process.env', () => {
53
+ process.env[TEST_KEY] = 'shell-value';
54
+ expect(resolveEnv(TEST_KEY, tmpDir)).toBe('shell-value');
55
+ });
56
+
57
+ it('agent .env takes priority over global', () => {
58
+ writeFileSync(path.join(tmpDir, '.env'), `${TEST_KEY}=agent-value\n`);
59
+ mkdirSync(path.join(fakeHome, '.amodal'), {recursive: true});
60
+ writeFileSync(path.join(fakeHome, '.amodal', '.env'), `${TEST_KEY}=global-value\n`);
61
+ process.env[TEST_KEY] = 'shell-value';
62
+ expect(resolveEnv(TEST_KEY, tmpDir)).toBe('agent-value');
63
+ });
64
+
65
+ it('global takes priority over process.env', () => {
66
+ mkdirSync(path.join(fakeHome, '.amodal'), {recursive: true});
67
+ writeFileSync(path.join(fakeHome, '.amodal', '.env'), `${TEST_KEY}=global-value\n`);
68
+ process.env[TEST_KEY] = 'shell-value';
69
+ expect(resolveEnv(TEST_KEY, tmpDir)).toBe('global-value');
70
+ });
71
+
72
+ it('returns undefined when not found anywhere', () => {
73
+ expect(resolveEnv(TEST_KEY, tmpDir)).toBeUndefined();
74
+ });
75
+
76
+ it('handles comments and empty lines in .env files', () => {
77
+ writeFileSync(
78
+ path.join(tmpDir, '.env'),
79
+ `# This is a comment\n\nIRRELEVANT=foo\n\n${TEST_KEY}=found-it\n# trailing comment\n`,
80
+ );
81
+ expect(resolveEnv(TEST_KEY, tmpDir)).toBe('found-it');
82
+ });
83
+
84
+ it('strips quotes from values', () => {
85
+ writeFileSync(path.join(tmpDir, '.env'), `${TEST_KEY}="quoted-value"\n`);
86
+ expect(resolveEnv(TEST_KEY, tmpDir)).toBe('quoted-value');
87
+ });
88
+
89
+ it('strips single quotes from values', () => {
90
+ writeFileSync(path.join(tmpDir, '.env'), `${TEST_KEY}='single-quoted'\n`);
91
+ expect(resolveEnv(TEST_KEY, tmpDir)).toBe('single-quoted');
92
+ });
93
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Amodal Labs, Inc.
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+
7
+ import { readFileSync, existsSync } from 'node:fs';
8
+ import path from 'node:path';
9
+ import os from 'node:os';
10
+
11
+ /**
12
+ * Resolve a config value from (in priority order):
13
+ * 1. Agent .env file (cwd/.env)
14
+ * 2. Global ~/.amodal/env
15
+ * 3. Shell environment (process.env)
16
+ */
17
+ export function resolveEnv(key: string, cwd: string): string | undefined {
18
+ // 1. Agent .env
19
+ const agentEnvPath = path.join(cwd, '.env');
20
+ const agentValue = readEnvFile(agentEnvPath, key);
21
+ if (agentValue !== undefined) return agentValue;
22
+
23
+ // 2. Global ~/.amodal/.env
24
+ const globalEnvPath = path.join(os.homedir(), '.amodal', '.env');
25
+ const globalValue = readEnvFile(globalEnvPath, key);
26
+ if (globalValue !== undefined) return globalValue;
27
+
28
+ // 3. Shell environment
29
+ return process.env[key];
30
+ }
31
+
32
+ /**
33
+ * Read a single key from a .env-style file. Returns undefined if the file
34
+ * does not exist, cannot be read, or does not contain the key.
35
+ */
36
+ function readEnvFile(filePath: string, key: string): string | undefined {
37
+ if (!existsSync(filePath)) return undefined;
38
+ try {
39
+ const content = readFileSync(filePath, 'utf-8');
40
+ for (const line of content.split('\n')) {
41
+ const trimmed = line.trim();
42
+ if (trimmed.startsWith('#') || !trimmed.includes('=')) continue;
43
+ const eqIndex = trimmed.indexOf('=');
44
+ const k = trimmed.slice(0, eqIndex).trim();
45
+ const v = trimmed.slice(eqIndex + 1).trim().replace(/^["']|["']$/g, '');
46
+ if (k === key) return v;
47
+ }
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ return undefined;
52
+ }