autoforge-ai 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 (84) hide show
  1. package/.claude/commands/check-code.md +32 -0
  2. package/.claude/commands/checkpoint.md +40 -0
  3. package/.claude/commands/create-spec.md +613 -0
  4. package/.claude/commands/expand-project.md +234 -0
  5. package/.claude/commands/gsd-to-autoforge-spec.md +10 -0
  6. package/.claude/commands/review-pr.md +75 -0
  7. package/.claude/templates/app_spec.template.txt +331 -0
  8. package/.claude/templates/coding_prompt.template.md +265 -0
  9. package/.claude/templates/initializer_prompt.template.md +354 -0
  10. package/.claude/templates/testing_prompt.template.md +146 -0
  11. package/.env.example +64 -0
  12. package/LICENSE.md +676 -0
  13. package/README.md +423 -0
  14. package/agent.py +444 -0
  15. package/api/__init__.py +10 -0
  16. package/api/database.py +536 -0
  17. package/api/dependency_resolver.py +449 -0
  18. package/api/migration.py +156 -0
  19. package/auth.py +83 -0
  20. package/autoforge_paths.py +315 -0
  21. package/autonomous_agent_demo.py +293 -0
  22. package/bin/autoforge.js +3 -0
  23. package/client.py +607 -0
  24. package/env_constants.py +27 -0
  25. package/examples/OPTIMIZE_CONFIG.md +230 -0
  26. package/examples/README.md +531 -0
  27. package/examples/org_config.yaml +172 -0
  28. package/examples/project_allowed_commands.yaml +139 -0
  29. package/lib/cli.js +791 -0
  30. package/mcp_server/__init__.py +1 -0
  31. package/mcp_server/feature_mcp.py +988 -0
  32. package/package.json +53 -0
  33. package/parallel_orchestrator.py +1800 -0
  34. package/progress.py +247 -0
  35. package/prompts.py +427 -0
  36. package/pyproject.toml +17 -0
  37. package/rate_limit_utils.py +132 -0
  38. package/registry.py +614 -0
  39. package/requirements-prod.txt +14 -0
  40. package/security.py +959 -0
  41. package/server/__init__.py +17 -0
  42. package/server/main.py +261 -0
  43. package/server/routers/__init__.py +32 -0
  44. package/server/routers/agent.py +177 -0
  45. package/server/routers/assistant_chat.py +327 -0
  46. package/server/routers/devserver.py +309 -0
  47. package/server/routers/expand_project.py +239 -0
  48. package/server/routers/features.py +746 -0
  49. package/server/routers/filesystem.py +514 -0
  50. package/server/routers/projects.py +524 -0
  51. package/server/routers/schedules.py +356 -0
  52. package/server/routers/settings.py +127 -0
  53. package/server/routers/spec_creation.py +357 -0
  54. package/server/routers/terminal.py +453 -0
  55. package/server/schemas.py +593 -0
  56. package/server/services/__init__.py +36 -0
  57. package/server/services/assistant_chat_session.py +496 -0
  58. package/server/services/assistant_database.py +304 -0
  59. package/server/services/chat_constants.py +57 -0
  60. package/server/services/dev_server_manager.py +557 -0
  61. package/server/services/expand_chat_session.py +399 -0
  62. package/server/services/process_manager.py +657 -0
  63. package/server/services/project_config.py +475 -0
  64. package/server/services/scheduler_service.py +683 -0
  65. package/server/services/spec_chat_session.py +502 -0
  66. package/server/services/terminal_manager.py +756 -0
  67. package/server/utils/__init__.py +1 -0
  68. package/server/utils/process_utils.py +134 -0
  69. package/server/utils/project_helpers.py +32 -0
  70. package/server/utils/validation.py +54 -0
  71. package/server/websocket.py +903 -0
  72. package/start.py +456 -0
  73. package/ui/dist/assets/index-8W_wmZzz.js +168 -0
  74. package/ui/dist/assets/index-B47Ubhox.css +1 -0
  75. package/ui/dist/assets/vendor-flow-CVNK-_lx.js +7 -0
  76. package/ui/dist/assets/vendor-query-BUABzP5o.js +1 -0
  77. package/ui/dist/assets/vendor-radix-DTNNCg2d.js +45 -0
  78. package/ui/dist/assets/vendor-react-qkC6yhPU.js +1 -0
  79. package/ui/dist/assets/vendor-utils-COeKbHgx.js +2 -0
  80. package/ui/dist/assets/vendor-xterm-DP_gxef0.js +16 -0
  81. package/ui/dist/index.html +23 -0
  82. package/ui/dist/ollama.png +0 -0
  83. package/ui/dist/vite.svg +6 -0
  84. package/ui/package.json +57 -0
package/lib/cli.js ADDED
@@ -0,0 +1,791 @@
1
+ /**
2
+ * AutoForge CLI
3
+ * =============
4
+ *
5
+ * Main CLI module for the AutoForge npm global package.
6
+ * Handles Python detection, virtual environment management,
7
+ * config loading, and uvicorn server lifecycle.
8
+ *
9
+ * Uses only Node.js built-in modules -- no external dependencies.
10
+ */
11
+
12
+ import { execFileSync, spawn, execSync } from 'node:child_process';
13
+ import { createHash } from 'node:crypto';
14
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, rmSync, copyFileSync } from 'node:fs';
15
+ import { createRequire } from 'node:module';
16
+ import { createServer } from 'node:net';
17
+ import { homedir, platform } from 'node:os';
18
+ import { join, dirname } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Path constants
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /** Root of the autoforge npm package (one level up from lib/) */
26
+ const PKG_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
27
+
28
+ /** User config home: ~/.autoforge/ */
29
+ const CONFIG_HOME = join(homedir(), '.autoforge');
30
+
31
+ /** Virtual-environment directory managed by the CLI */
32
+ const VENV_DIR = join(CONFIG_HOME, 'venv');
33
+
34
+ /** Composite marker written after a successful pip install */
35
+ const DEPS_MARKER = join(VENV_DIR, '.deps-installed');
36
+
37
+ /** PID file for the running server */
38
+ const PID_FILE = join(CONFIG_HOME, 'server.pid');
39
+
40
+ /** Path to the production requirements file inside the package */
41
+ const REQUIREMENTS_FILE = join(PKG_DIR, 'requirements-prod.txt');
42
+
43
+ /** Path to the .env example shipped with the package */
44
+ const ENV_EXAMPLE = join(PKG_DIR, '.env.example');
45
+
46
+ /** User .env config file */
47
+ const ENV_FILE = join(CONFIG_HOME, '.env');
48
+
49
+ const IS_WIN = platform() === 'win32';
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Package version (read lazily via createRequire)
53
+ // ---------------------------------------------------------------------------
54
+
55
+ const require = createRequire(import.meta.url);
56
+ const { version: VERSION } = require(join(PKG_DIR, 'package.json'));
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Helpers
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /** Indented console output matching the spec format. */
63
+ function log(msg = '') {
64
+ console.log(` ${msg}`);
65
+ }
66
+
67
+ /** Print a fatal error and exit. */
68
+ function die(msg) {
69
+ console.error(`\n Error: ${msg}\n`);
70
+ process.exit(1);
71
+ }
72
+
73
+ /**
74
+ * Parse a Python version string like "Python 3.13.6" and return
75
+ * { major, minor, patch, raw } or null on failure.
76
+ */
77
+ function parsePythonVersion(raw) {
78
+ const m = raw.match(/Python\s+(\d+)\.(\d+)\.(\d+)/);
79
+ if (!m) return null;
80
+ return {
81
+ major: Number(m[1]),
82
+ minor: Number(m[2]),
83
+ patch: Number(m[3]),
84
+ raw: `${m[1]}.${m[2]}.${m[3]}`,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Try a single Python candidate. Returns { exe, version } or null.
90
+ * `candidate` is either a bare name or an array of args (e.g. ['py', '-3']).
91
+ */
92
+ function tryPythonCandidate(candidate) {
93
+ const args = Array.isArray(candidate) ? candidate : [candidate];
94
+ const exe = args[0];
95
+ const extraArgs = args.slice(1);
96
+
97
+ try {
98
+ const out = execFileSync(exe, [...extraArgs, '--version'], {
99
+ encoding: 'utf8',
100
+ timeout: 10_000,
101
+ stdio: ['pipe', 'pipe', 'pipe'],
102
+ });
103
+
104
+ const ver = parsePythonVersion(out);
105
+ if (!ver) return null;
106
+
107
+ // Require 3.11+
108
+ if (ver.major < 3 || (ver.major === 3 && ver.minor < 11)) {
109
+ return { exe: args.join(' '), version: ver, tooOld: true };
110
+ }
111
+
112
+ return { exe: args.join(' '), version: ver, tooOld: false };
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Python detection
120
+ // ---------------------------------------------------------------------------
121
+
122
+ /**
123
+ * Find a suitable Python >= 3.11 interpreter.
124
+ *
125
+ * Search order is platform-dependent:
126
+ * Windows: python -> py -3 -> python3
127
+ * macOS/Linux: python3 -> python
128
+ *
129
+ * The AUTOFORGE_PYTHON env var overrides automatic detection.
130
+ *
131
+ * After finding a candidate we also verify that the venv module is
132
+ * available (Debian/Ubuntu strip it out of the base package).
133
+ */
134
+ function findPython() {
135
+ // Allow explicit override via environment variable
136
+ const override = process.env.AUTOFORGE_PYTHON;
137
+ if (override) {
138
+ const result = tryPythonCandidate(override);
139
+ if (!result) {
140
+ die(`AUTOFORGE_PYTHON is set to "${override}" but it could not be executed.`);
141
+ }
142
+ if (result.tooOld) {
143
+ die(
144
+ `Python ${result.version.raw} found (via AUTOFORGE_PYTHON), but 3.11+ required.\n` +
145
+ ' Install Python 3.11+ from https://python.org'
146
+ );
147
+ }
148
+ return result;
149
+ }
150
+
151
+ // Platform-specific candidate order
152
+ const candidates = IS_WIN
153
+ ? ['python', ['py', '-3'], 'python3']
154
+ : ['python3', 'python'];
155
+
156
+ let bestTooOld = null;
157
+
158
+ for (const candidate of candidates) {
159
+ const result = tryPythonCandidate(candidate);
160
+ if (!result) continue;
161
+
162
+ if (result.tooOld) {
163
+ // Remember the first "too old" result for a better error message
164
+ if (!bestTooOld) bestTooOld = result;
165
+ continue;
166
+ }
167
+
168
+ // Verify venv module is available (Debian/Ubuntu may need python3-venv)
169
+ try {
170
+ const exeParts = result.exe.split(' ');
171
+ execFileSync(exeParts[0], [...exeParts.slice(1), '-c', 'import ensurepip'], {
172
+ encoding: 'utf8',
173
+ timeout: 10_000,
174
+ stdio: ['pipe', 'pipe', 'pipe'],
175
+ });
176
+ } catch {
177
+ die(
178
+ `Python venv module not available.\n` +
179
+ ` Run: sudo apt install python3.${result.version.minor}-venv`
180
+ );
181
+ }
182
+
183
+ return result;
184
+ }
185
+
186
+ // Provide the most helpful error message we can
187
+ if (bestTooOld) {
188
+ die(
189
+ `Python ${bestTooOld.version.raw} found, but 3.11+ required.\n` +
190
+ ' Install Python 3.11+ from https://python.org'
191
+ );
192
+ }
193
+ die(
194
+ 'Python 3.11+ required but not found.\n' +
195
+ ' Install from https://python.org'
196
+ );
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Venv management
201
+ // ---------------------------------------------------------------------------
202
+
203
+ /** Return the path to the Python executable inside the venv. */
204
+ function venvPython() {
205
+ return IS_WIN
206
+ ? join(VENV_DIR, 'Scripts', 'python.exe')
207
+ : join(VENV_DIR, 'bin', 'python');
208
+ }
209
+
210
+ /** SHA-256 hash of the requirements-prod.txt file contents. */
211
+ function requirementsHash() {
212
+ const content = readFileSync(REQUIREMENTS_FILE, 'utf8');
213
+ return createHash('sha256').update(content).digest('hex');
214
+ }
215
+
216
+ /**
217
+ * Read the composite deps marker. Returns the parsed JSON object
218
+ * or null if the file is missing / corrupt.
219
+ */
220
+ function readMarker() {
221
+ try {
222
+ return JSON.parse(readFileSync(DEPS_MARKER, 'utf8'));
223
+ } catch {
224
+ return null;
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Ensure the virtual environment exists and dependencies are installed.
230
+ * Returns true if all setup steps were already satisfied (fast path).
231
+ *
232
+ * @param {object} python - The result of findPython()
233
+ * @param {boolean} forceRecreate - If true, delete and recreate the venv
234
+ */
235
+ function ensureVenv(python, forceRecreate) {
236
+ mkdirSync(CONFIG_HOME, { recursive: true });
237
+
238
+ const marker = readMarker();
239
+ const reqHash = requirementsHash();
240
+ const pyExe = venvPython();
241
+
242
+ // Determine if the venv itself needs to be (re)created
243
+ let needsCreate = forceRecreate || !existsSync(pyExe);
244
+
245
+ if (!needsCreate && marker) {
246
+ // Recreate if Python major.minor changed
247
+ const markerMinor = marker.python_version;
248
+ const currentMinor = `${python.version.major}.${python.version.minor}`;
249
+ if (markerMinor && markerMinor !== currentMinor) {
250
+ needsCreate = true;
251
+ }
252
+
253
+ // Recreate if the recorded python path no longer exists
254
+ if (marker.python_path && !existsSync(marker.python_path)) {
255
+ needsCreate = true;
256
+ }
257
+ }
258
+
259
+ let depsUpToDate = false;
260
+ if (!needsCreate && marker && marker.requirements_hash === reqHash) {
261
+ depsUpToDate = true;
262
+ }
263
+
264
+ // Fast path: nothing to do
265
+ if (!needsCreate && depsUpToDate) {
266
+ return true;
267
+ }
268
+
269
+ // --- Slow path: show setup progress ---
270
+
271
+ log('[2/3] Setting up environment...');
272
+
273
+ if (needsCreate) {
274
+ if (existsSync(VENV_DIR)) {
275
+ log(' Removing old virtual environment...');
276
+ rmSync(VENV_DIR, { recursive: true, force: true });
277
+ }
278
+
279
+ log(` Creating virtual environment at ~/.autoforge/venv/`);
280
+ const exeParts = python.exe.split(' ');
281
+ try {
282
+ execFileSync(exeParts[0], [...exeParts.slice(1), '-m', 'venv', VENV_DIR], {
283
+ encoding: 'utf8',
284
+ timeout: 120_000,
285
+ stdio: ['pipe', 'pipe', 'pipe'],
286
+ });
287
+ } catch (err) {
288
+ die(`Failed to create virtual environment: ${err.message}`);
289
+ }
290
+ }
291
+
292
+ // Install / update dependencies
293
+ log(' Installing dependencies...');
294
+ try {
295
+ execFileSync(pyExe, ['-m', 'pip', 'install', '-q', '--upgrade', 'pip'], {
296
+ encoding: 'utf8',
297
+ timeout: 300_000,
298
+ stdio: ['pipe', 'pipe', 'pipe'],
299
+ });
300
+
301
+ execFileSync(pyExe, ['-m', 'pip', 'install', '-q', '-r', REQUIREMENTS_FILE], {
302
+ encoding: 'utf8',
303
+ timeout: 600_000,
304
+ stdio: ['pipe', 'pipe', 'pipe'],
305
+ });
306
+ } catch (err) {
307
+ die(`Failed to install dependencies: ${err.message}`);
308
+ }
309
+
310
+ // Write marker only after pip succeeds to prevent partial state
311
+ const markerData = {
312
+ requirements_hash: reqHash,
313
+ python_version: `${python.version.major}.${python.version.minor}`,
314
+ python_path: pyExe,
315
+ created_at: new Date().toISOString(),
316
+ };
317
+ writeFileSync(DEPS_MARKER, JSON.stringify(markerData, null, 2), 'utf8');
318
+
319
+ log(' Done');
320
+ return false;
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // Config (.env) management
325
+ // ---------------------------------------------------------------------------
326
+
327
+ /**
328
+ * Parse a .env file into a plain object.
329
+ * Handles comments, blank lines, and quoted values.
330
+ */
331
+ function parseEnvFile(filePath) {
332
+ const env = {};
333
+ if (!existsSync(filePath)) return env;
334
+
335
+ const lines = readFileSync(filePath, 'utf8').split('\n');
336
+ for (const line of lines) {
337
+ const trimmed = line.trim();
338
+ if (!trimmed || trimmed.startsWith('#')) continue;
339
+
340
+ const eqIdx = trimmed.indexOf('=');
341
+ if (eqIdx === -1) continue;
342
+
343
+ const key = trimmed.slice(0, eqIdx).trim();
344
+ let value = trimmed.slice(eqIdx + 1).trim();
345
+
346
+ // Strip matching quotes (single or double)
347
+ if (
348
+ (value.startsWith('"') && value.endsWith('"')) ||
349
+ (value.startsWith("'") && value.endsWith("'"))
350
+ ) {
351
+ value = value.slice(1, -1);
352
+ }
353
+
354
+ if (key) {
355
+ env[key] = value;
356
+ }
357
+ }
358
+ return env;
359
+ }
360
+
361
+ /**
362
+ * Ensure ~/.autoforge/.env exists. On first run, copy .env.example
363
+ * from the package directory and print a notice.
364
+ *
365
+ * Returns true if the file was newly created.
366
+ */
367
+ function ensureEnvFile() {
368
+ if (existsSync(ENV_FILE)) return false;
369
+
370
+ mkdirSync(CONFIG_HOME, { recursive: true });
371
+
372
+ if (existsSync(ENV_EXAMPLE)) {
373
+ copyFileSync(ENV_EXAMPLE, ENV_FILE);
374
+ } else {
375
+ // Fallback: create a minimal placeholder
376
+ writeFileSync(ENV_FILE, '# AutoForge configuration\n# See documentation for available options.\n', 'utf8');
377
+ }
378
+ return true;
379
+ }
380
+
381
+ // ---------------------------------------------------------------------------
382
+ // Port detection
383
+ // ---------------------------------------------------------------------------
384
+
385
+ /**
386
+ * Find an available TCP port starting from `start`.
387
+ * Tries by actually binding a socket (most reliable cross-platform approach).
388
+ */
389
+ function findAvailablePort(start = 8888, maxAttempts = 20) {
390
+ for (let port = start; port < start + maxAttempts; port++) {
391
+ try {
392
+ const server = createServer();
393
+ // Use a synchronous-like approach: try to listen, then close immediately
394
+ const result = new Promise((resolve, reject) => {
395
+ server.once('error', reject);
396
+ server.listen(port, '127.0.0.1', () => {
397
+ server.close(() => resolve(port));
398
+ });
399
+ });
400
+ // We cannot await here (sync context), so use the blocking approach:
401
+ // Try to bind synchronously using a different technique.
402
+ server.close();
403
+ } catch {
404
+ // fall through
405
+ }
406
+ }
407
+ // Synchronous fallback: try to connect; if connection refused, port is free.
408
+ for (let port = start; port < start + maxAttempts; port++) {
409
+ try {
410
+ execFileSync(process.execPath, [
411
+ '-e',
412
+ `const s=require("net").createServer();` +
413
+ `s.listen(${port},"127.0.0.1",()=>{s.close();process.exit(0)});` +
414
+ `s.on("error",()=>process.exit(1))`,
415
+ ], { timeout: 3000, stdio: 'pipe' });
416
+ return port;
417
+ } catch {
418
+ continue;
419
+ }
420
+ }
421
+ die(`No available ports found in range ${start}-${start + maxAttempts - 1}`);
422
+ }
423
+
424
+ // ---------------------------------------------------------------------------
425
+ // PID file management
426
+ // ---------------------------------------------------------------------------
427
+
428
+ /** Read PID from the PID file. Returns the PID number or null. */
429
+ function readPid() {
430
+ try {
431
+ const content = readFileSync(PID_FILE, 'utf8').trim();
432
+ const pid = Number(content);
433
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
434
+ } catch {
435
+ return null;
436
+ }
437
+ }
438
+
439
+ /** Check whether a process with the given PID is still running. */
440
+ function isProcessAlive(pid) {
441
+ try {
442
+ process.kill(pid, 0); // signal 0 = existence check
443
+ return true;
444
+ } catch {
445
+ return false;
446
+ }
447
+ }
448
+
449
+ /** Write the PID file. */
450
+ function writePid(pid) {
451
+ mkdirSync(CONFIG_HOME, { recursive: true });
452
+ writeFileSync(PID_FILE, String(pid), 'utf8');
453
+ }
454
+
455
+ /** Remove the PID file. */
456
+ function removePid() {
457
+ try {
458
+ unlinkSync(PID_FILE);
459
+ } catch {
460
+ // Ignore -- file may already be gone
461
+ }
462
+ }
463
+
464
+ // ---------------------------------------------------------------------------
465
+ // Browser opening
466
+ // ---------------------------------------------------------------------------
467
+
468
+ /** Open a URL in the user's default browser (best-effort). */
469
+ function openBrowser(url) {
470
+ try {
471
+ if (IS_WIN) {
472
+ // "start" is a cmd built-in; the empty title string avoids
473
+ // issues when the URL contains special characters.
474
+ execSync(`start "" "${url}"`, { stdio: 'ignore' });
475
+ } else if (platform() === 'darwin') {
476
+ execFileSync('open', [url], { stdio: 'ignore' });
477
+ } else {
478
+ // Linux: only attempt if a display server is available and
479
+ // we are not in an SSH session.
480
+ const hasDisplay = process.env.DISPLAY || process.env.WAYLAND_DISPLAY;
481
+ const isSSH = !!process.env.SSH_TTY;
482
+ if (hasDisplay && !isSSH) {
483
+ execFileSync('xdg-open', [url], { stdio: 'ignore' });
484
+ }
485
+ }
486
+ } catch {
487
+ // Non-fatal: user can open the URL manually
488
+ }
489
+ }
490
+
491
+ /** Detect headless / CI environments where opening a browser is pointless. */
492
+ function isHeadless() {
493
+ if (process.env.CI) return true;
494
+ if (process.env.CODESPACES) return true;
495
+ if (process.env.SSH_TTY) return true;
496
+ // Linux without a display server
497
+ if (!IS_WIN && platform() !== 'darwin' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
498
+ return true;
499
+ }
500
+ return false;
501
+ }
502
+
503
+ // ---------------------------------------------------------------------------
504
+ // Process cleanup
505
+ // ---------------------------------------------------------------------------
506
+
507
+ /** Kill a process tree. On Windows uses taskkill; elsewhere sends SIGTERM. */
508
+ function killProcess(pid) {
509
+ try {
510
+ if (IS_WIN) {
511
+ execSync(`taskkill /pid ${pid} /t /f`, { stdio: 'ignore' });
512
+ } else {
513
+ process.kill(pid, 'SIGTERM');
514
+ }
515
+ } catch {
516
+ // Process may already be gone
517
+ }
518
+ }
519
+
520
+ // ---------------------------------------------------------------------------
521
+ // CLI commands
522
+ // ---------------------------------------------------------------------------
523
+
524
+ function printVersion() {
525
+ console.log(`autoforge v${VERSION}`);
526
+ }
527
+
528
+ function printHelp() {
529
+ console.log(`
530
+ AutoForge v${VERSION}
531
+ Autonomous coding agent with web UI
532
+
533
+ Usage:
534
+ autoforge Start the server (default)
535
+ autoforge config Open ~/.autoforge/.env in $EDITOR
536
+ autoforge config --path Print config file path
537
+ autoforge config --show Show effective configuration
538
+
539
+ Options:
540
+ --port PORT Custom port (default: auto from 8888)
541
+ --host HOST Custom host (default: 127.0.0.1)
542
+ --no-browser Don't auto-open browser
543
+ --repair Delete and recreate virtual environment
544
+ --dev Development mode (requires cloned repo)
545
+ --version Print version
546
+ --help Show this help
547
+ `);
548
+ }
549
+
550
+ function handleConfig(args) {
551
+ ensureEnvFile();
552
+
553
+ if (args.includes('--path')) {
554
+ console.log(ENV_FILE);
555
+ return;
556
+ }
557
+
558
+ if (args.includes('--show')) {
559
+ if (!existsSync(ENV_FILE)) {
560
+ log('No configuration file found.');
561
+ return;
562
+ }
563
+ const lines = readFileSync(ENV_FILE, 'utf8').split('\n');
564
+ const active = lines.filter(l => {
565
+ const t = l.trim();
566
+ return t && !t.startsWith('#');
567
+ });
568
+ if (active.length === 0) {
569
+ log('No active configuration. All lines are commented out.');
570
+ log(`Edit: ${ENV_FILE}`);
571
+ } else {
572
+ for (const line of active) {
573
+ console.log(line);
574
+ }
575
+ }
576
+ return;
577
+ }
578
+
579
+ // Open in editor
580
+ const editor = process.env.EDITOR || process.env.VISUAL || (IS_WIN ? 'notepad' : 'vi');
581
+ try {
582
+ execFileSync(editor, [ENV_FILE], { stdio: 'inherit' });
583
+ } catch {
584
+ log(`Could not open editor "${editor}".`);
585
+ log(`Edit the file manually: ${ENV_FILE}`);
586
+ }
587
+ }
588
+
589
+ // ---------------------------------------------------------------------------
590
+ // Main server start
591
+ // ---------------------------------------------------------------------------
592
+
593
+ function startServer(opts) {
594
+ const { port: requestedPort, host, noBrowser, repair } = opts;
595
+
596
+ // Step 1: Find Python
597
+ const fastPath = !repair && existsSync(venvPython()) && readMarker()?.requirements_hash === requirementsHash();
598
+
599
+ let python;
600
+ if (fastPath) {
601
+ // Skip the Python search header on fast path -- we already have a working venv
602
+ python = null;
603
+ } else {
604
+ log(`[1/3] Checking Python...`);
605
+ python = findPython();
606
+ log(` Found Python ${python.version.raw} at ${python.exe}`);
607
+ }
608
+
609
+ // Step 2: Ensure venv and deps
610
+ if (!python) {
611
+ // Fast path still needs a python reference for potential repair
612
+ python = findPython();
613
+ }
614
+ const wasAlreadyReady = ensureVenv(python, repair);
615
+
616
+ // Step 3: Config file
617
+ const configCreated = ensureEnvFile();
618
+
619
+ // Load .env into process.env for the spawned server
620
+ const dotenvVars = parseEnvFile(ENV_FILE);
621
+
622
+ // Determine port
623
+ const port = requestedPort || findAvailablePort();
624
+
625
+ // Check for already-running instance
626
+ const existingPid = readPid();
627
+ if (existingPid && isProcessAlive(existingPid)) {
628
+ log(`AutoForge is already running at http://${host}:${port}`);
629
+ log('Opening browser...');
630
+ if (!noBrowser && !isHeadless()) {
631
+ openBrowser(`http://${host}:${port}`);
632
+ }
633
+ return;
634
+ }
635
+
636
+ // Clean up stale PID file
637
+ if (existingPid) {
638
+ removePid();
639
+ }
640
+
641
+ // Show server startup step only on slow path
642
+ if (!wasAlreadyReady) {
643
+ log('[3/3] Starting server...');
644
+ }
645
+
646
+ if (configCreated) {
647
+ log(` Created config file: ~/.autoforge/.env`);
648
+ log(' Edit this file to configure API providers (Ollama, Vertex AI, z.ai)');
649
+ log('');
650
+ }
651
+
652
+ // Security warning for non-localhost host
653
+ if (host !== '127.0.0.1') {
654
+ console.log('');
655
+ console.log(' !! SECURITY WARNING !!');
656
+ console.log(` Remote access enabled on host: ${host}`);
657
+ console.log(' The AutoForge UI will be accessible from other machines.');
658
+ console.log(' Ensure you understand the security implications.');
659
+ console.log('');
660
+ }
661
+
662
+ // Build environment for uvicorn
663
+ const serverEnv = { ...process.env, ...dotenvVars, PYTHONPATH: PKG_DIR };
664
+
665
+ // Enable remote access flag for the FastAPI server
666
+ if (host !== '127.0.0.1') {
667
+ serverEnv.AUTOFORGE_ALLOW_REMOTE = '1';
668
+ }
669
+
670
+ // Spawn uvicorn
671
+ const pyExe = venvPython();
672
+ const child = spawn(
673
+ pyExe,
674
+ [
675
+ '-m', 'uvicorn',
676
+ 'server.main:app',
677
+ '--host', host,
678
+ '--port', String(port),
679
+ ],
680
+ {
681
+ cwd: PKG_DIR,
682
+ env: serverEnv,
683
+ stdio: 'inherit',
684
+ }
685
+ );
686
+
687
+ writePid(child.pid);
688
+
689
+ // Open browser after a short delay to let the server start
690
+ if (!noBrowser && !isHeadless()) {
691
+ setTimeout(() => openBrowser(`http://${host}:${port}`), 2000);
692
+ }
693
+
694
+ const url = `http://${host}:${port}`;
695
+ console.log('');
696
+ log(`Server running at ${url}`);
697
+ log('Press Ctrl+C to stop');
698
+
699
+ // Graceful shutdown handlers
700
+ const cleanup = () => {
701
+ killProcess(child.pid);
702
+ removePid();
703
+ };
704
+
705
+ process.on('SIGINT', () => {
706
+ console.log('');
707
+ cleanup();
708
+ process.exit(0);
709
+ });
710
+
711
+ process.on('SIGTERM', () => {
712
+ cleanup();
713
+ process.exit(0);
714
+ });
715
+
716
+ // If the child exits on its own, clean up and propagate the exit code
717
+ child.on('exit', (code) => {
718
+ removePid();
719
+ process.exit(code ?? 1);
720
+ });
721
+ }
722
+
723
+ // ---------------------------------------------------------------------------
724
+ // Entry point
725
+ // ---------------------------------------------------------------------------
726
+
727
+ /**
728
+ * Main CLI entry point.
729
+ *
730
+ * @param {string[]} args - Command-line arguments (process.argv.slice(2))
731
+ */
732
+ export function run(args) {
733
+ // --version / -v
734
+ if (args.includes('--version') || args.includes('-v')) {
735
+ printVersion();
736
+ return;
737
+ }
738
+
739
+ // --help / -h
740
+ if (args.includes('--help') || args.includes('-h')) {
741
+ printHelp();
742
+ return;
743
+ }
744
+
745
+ // --dev guard: this only works from a cloned repository
746
+ if (args.includes('--dev')) {
747
+ die(
748
+ 'Dev mode requires a cloned repository.\n' +
749
+ ' Clone from https://github.com/paperlinguist/autocoder and run start_ui.sh'
750
+ );
751
+ return;
752
+ }
753
+
754
+ // "config" subcommand
755
+ if (args[0] === 'config') {
756
+ handleConfig(args.slice(1));
757
+ return;
758
+ }
759
+
760
+ // Parse flags for server start
761
+ const host = getFlagValue(args, '--host') || '127.0.0.1';
762
+ const portStr = getFlagValue(args, '--port');
763
+ const port = portStr ? Number(portStr) : null;
764
+ const noBrowser = args.includes('--no-browser');
765
+ const repair = args.includes('--repair');
766
+
767
+ if (port !== null && (!Number.isFinite(port) || port < 1 || port > 65535)) {
768
+ die('Invalid port number. Must be between 1 and 65535.');
769
+ }
770
+
771
+ // Print banner
772
+ console.log('');
773
+ log(`AutoForge v${VERSION}`);
774
+ console.log('');
775
+
776
+ startServer({ port, host, noBrowser, repair });
777
+ }
778
+
779
+ // ---------------------------------------------------------------------------
780
+ // Argument parsing helpers
781
+ // ---------------------------------------------------------------------------
782
+
783
+ /**
784
+ * Extract the value following a flag from the args array.
785
+ * E.g. getFlagValue(['--port', '9000', '--host', '0.0.0.0'], '--port') => '9000'
786
+ */
787
+ function getFlagValue(args, flag) {
788
+ const idx = args.indexOf(flag);
789
+ if (idx === -1 || idx + 1 >= args.length) return null;
790
+ return args[idx + 1];
791
+ }