From 45498403304e746e9d7c7d48349ccf2df4939375 Mon Sep 17 00:00:00 2001 From: Auto Date: Wed, 4 Feb 2026 14:48:00 +0200 Subject: [PATCH] feat: add npm global package for one-command install Add a Node.js CLI wrapper that allows installing AutoForge globally via `npm install -g autoforge-ai` and running it with a single `autoforge` command. The CLI handles Python detection, venv management, config loading, and uvicorn server lifecycle automatically. New files: - package.json: npm package config with bin entry, files whitelist, and prepublishOnly script that builds the UI - bin/autoforge.js: thin entry point that imports lib/cli.js - lib/cli.js: main CLI module (~790 lines) with cross-platform Python 3.11+ detection, composite venv marker for smart invalidation (requirements hash + Python version + path), .env config management at ~/.autoforge/.env, server startup with PID file and port detection, and signal handling with process tree cleanup - requirements-prod.txt: runtime-only deps (excludes ruff, mypy, pytest) - .npmignore: excludes dev files, tests, __pycache__, UI source Modified files: - ui/package.json: rename to autoforge-ui to avoid confusion with root - .gitignore: add *.tgz for npm pack output - README.md: add npm install as primary quick start method, document CLI commands, add Ollama/Vertex AI config sections, new troubleshooting entries for Python/venv issues - GettingStarted.tsx: add Installation, Quick Start, and CLI Commands sections to in-app documentation with command reference table - docsData.ts: add installation and cli-commands sidebar entries Published as autoforge-ai@0.1.0 on npm. Co-Authored-By: Claude Opus 4.5 --- .gitignore | 1 + .npmignore | 32 + README.md | 186 ++-- bin/autoforge.js | 3 + lib/cli.js | 791 ++++++++++++++++++ package.json | 53 ++ requirements-prod.txt | 14 + ui/package-lock.json | 16 +- ui/package.json | 2 +- ui/src/components/docs/docsData.ts | 4 +- .../docs/sections/GettingStarted.tsx | 159 +++- 11 files changed, 1193 insertions(+), 68 deletions(-) create mode 100644 .npmignore create mode 100644 bin/autoforge.js create mode 100644 lib/cli.js create mode 100644 package.json create mode 100644 requirements-prod.txt diff --git a/.gitignore b/.gitignore index 2639f8d..ef858e2 100644 --- a/.gitignore +++ b/.gitignore @@ -114,6 +114,7 @@ Desktop.ini ui/dist/ ui/.vite/ .vite/ +*.tgz # =================== # Environment files diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..9c4ada3 --- /dev/null +++ b/.npmignore @@ -0,0 +1,32 @@ +venv/ +**/__pycache__/ +**/*.pyc +.git/ +.github/ +node_modules/ +test_*.py +tests/ +generations/ +*.db +.env +requirements.txt +CLAUDE.md +LICENSE.md +README.md +ui/src/ +ui/node_modules/ +ui/tsconfig*.json +ui/vite.config.ts +ui/eslint.config.js +ui/index.html +ui/public/ +ui/playwright.config.ts +ui/tests/ +start.bat +start_ui.bat +start.sh +start_ui.sh +start_ui.py +.claude/agents/ +.claude/skills/ +.claude/settings.json diff --git a/README.md b/README.md index dcdeb43..2644046 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,11 @@ A long-running autonomous coding agent powered by the Claude Agent SDK. This too ## Prerequisites -### Claude Code CLI (Required) +- **Node.js 20+** - Required for the CLI +- **Python 3.11+** - Auto-detected on first run ([download](https://www.python.org/downloads/)) +- **Claude Code CLI** - Install and authenticate (see below) -This project requires the Claude Code CLI to be installed. Install it using one of these methods: +### Claude Code CLI (Required) **macOS / Linux:** ```bash @@ -39,35 +41,63 @@ You need one of the following: ## Quick Start -### Option 1: Web UI (Recommended) +### Option 1: npm Install (Recommended) -**Windows:** -```cmd -start_ui.bat -``` - -**macOS / Linux:** ```bash -./start_ui.sh +npm install -g autoforge-ai +autoforge ``` +On first run, AutoForge automatically: +1. Checks for Python 3.11+ +2. Creates a virtual environment at `~/.autoforge/venv/` +3. Installs Python dependencies +4. Copies a default config file to `~/.autoforge/.env` +5. Starts the server and opens your browser + +### CLI Commands + +``` +autoforge Start the server (default) +autoforge config Open ~/.autoforge/.env in $EDITOR +autoforge config --path Print config file path +autoforge config --show Show active configuration values +autoforge --port PORT Custom port (default: auto from 8888) +autoforge --host HOST Custom host (default: 127.0.0.1) +autoforge --no-browser Don't auto-open browser +autoforge --repair Delete and recreate virtual environment +autoforge --version Print version +autoforge --help Show help +``` + +### Option 2: From Source (Development) + +Clone the repository and use the start scripts directly. This is the recommended path if you want to contribute or modify AutoForge itself. + +```bash +git clone https://github.com/leonvanzyl/autoforge.git +cd autoforge +``` + +**Web UI:** + +| Platform | Command | +|---|---| +| Windows | `start_ui.bat` | +| macOS / Linux | `./start_ui.sh` | + This launches the React-based web UI at `http://localhost:5173` with: - Project selection and creation - Kanban board view of features - Real-time agent output streaming - Start/pause/stop controls -### Option 2: CLI Mode +**CLI Mode:** -**Windows:** -```cmd -start.bat -``` - -**macOS / Linux:** -```bash -./start.sh -``` +| Platform | Command | +|---|---| +| Windows | `start.bat` | +| macOS / Linux | `./start.sh` | The start script will: 1. Check if Claude CLI is installed @@ -130,44 +160,43 @@ Features are stored in SQLite via SQLAlchemy and managed through an MCP server t ## Project Structure ``` -autonomous-coding/ -├── start.bat # Windows CLI start script -├── start.sh # macOS/Linux CLI start script -├── start_ui.bat # Windows Web UI start script -├── start_ui.sh # macOS/Linux Web UI start script -├── start.py # CLI menu and project management -├── start_ui.py # Web UI backend (FastAPI server launcher) -├── autonomous_agent_demo.py # Agent entry point -├── agent.py # Agent session logic -├── client.py # Claude SDK client configuration -├── security.py # Bash command allowlist and validation -├── progress.py # Progress tracking utilities -├── prompts.py # Prompt loading utilities +autoforge/ +├── bin/ # npm CLI entry point +├── lib/ # CLI bootstrap and setup logic +├── start.py # CLI menu and project management +├── start_ui.py # Web UI backend (FastAPI server launcher) +├── autonomous_agent_demo.py # Agent entry point +├── agent.py # Agent session logic +├── client.py # Claude SDK client configuration +├── security.py # Bash command allowlist and validation +├── progress.py # Progress tracking utilities +├── prompts.py # Prompt loading utilities ├── api/ -│ └── database.py # SQLAlchemy models (Feature table) +│ └── database.py # SQLAlchemy models (Feature table) ├── mcp_server/ -│ └── feature_mcp.py # MCP server for feature management tools +│ └── feature_mcp.py # MCP server for feature management tools ├── server/ -│ ├── main.py # FastAPI REST API server -│ ├── websocket.py # WebSocket handler for real-time updates -│ ├── schemas.py # Pydantic schemas -│ ├── routers/ # API route handlers -│ └── services/ # Business logic services -├── ui/ # React frontend +│ ├── main.py # FastAPI REST API server +│ ├── websocket.py # WebSocket handler for real-time updates +│ ├── schemas.py # Pydantic schemas +│ ├── routers/ # API route handlers +│ └── services/ # Business logic services +├── ui/ # React frontend │ ├── src/ -│ │ ├── App.tsx # Main app component -│ │ ├── hooks/ # React Query and WebSocket hooks -│ │ └── lib/ # API client and types +│ │ ├── App.tsx # Main app component +│ │ ├── hooks/ # React Query and WebSocket hooks +│ │ └── lib/ # API client and types │ ├── package.json │ └── vite.config.ts ├── .claude/ │ ├── commands/ -│ │ └── create-spec.md # /create-spec slash command -│ ├── skills/ # Claude Code skills -│ └── templates/ # Prompt templates -├── generations/ # Generated projects go here -├── requirements.txt # Python dependencies -└── .env # Optional configuration (N8N webhook) +│ │ └── create-spec.md # /create-spec slash command +│ ├── skills/ # Claude Code skills +│ └── templates/ # Prompt templates +├── requirements.txt # Python dependencies (development) +├── requirements-prod.txt # Python dependencies (npm install) +├── package.json # npm package definition +└── .env # Optional configuration ``` --- @@ -264,11 +293,20 @@ The UI receives live updates via WebSocket (`/ws/projects/{project_name}`): --- -## Configuration (Optional) +## Configuration + +AutoForge reads configuration from a `.env` file. The file location depends on how you installed AutoForge: + +| Install method | Config file location | Edit command | +|---|---|---| +| npm (global) | `~/.autoforge/.env` | `autoforge config` | +| From source | `.env` in the project root | Edit directly | + +A default config file is created automatically on first run. Use `autoforge config` to open it in your editor, or `autoforge config --show` to print the active values. ### N8N Webhook Integration -The agent can send progress notifications to an N8N webhook. Create a `.env` file: +Add to your `.env` to send progress notifications to an N8N webhook: ```bash # Optional: N8N webhook for progress notifications @@ -290,7 +328,7 @@ When test progress increases, the agent sends: ### Using GLM Models (Alternative to Claude) -To use Zhipu AI's GLM models instead of Claude, add these variables to your `.env` file in the AutoForge directory: +Add these variables to your `.env` file to use Zhipu AI's GLM models: ```bash ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic @@ -305,6 +343,36 @@ This routes AutoForge's API requests through Zhipu's Claude-compatible API, allo Get an API key at: https://z.ai/subscribe +### Using Ollama Local Models + +Add these variables to your `.env` file to run agents with local models via Ollama v0.14.0+: + +```bash +ANTHROPIC_BASE_URL=http://localhost:11434 +ANTHROPIC_AUTH_TOKEN=ollama +API_TIMEOUT_MS=3000000 +ANTHROPIC_DEFAULT_SONNET_MODEL=qwen3-coder +ANTHROPIC_DEFAULT_OPUS_MODEL=qwen3-coder +ANTHROPIC_DEFAULT_HAIKU_MODEL=qwen3-coder +``` + +See the [CLAUDE.md](CLAUDE.md) for recommended models and known limitations. + +### Using Vertex AI + +Add these variables to your `.env` file to run agents via Google Cloud Vertex AI: + +```bash +CLAUDE_CODE_USE_VERTEX=1 +CLOUD_ML_REGION=us-east5 +ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project-id +ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-4-5@20251101 +ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4-5@20250929 +ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-3-5-haiku@20241022 +``` + +Requires `gcloud auth application-default login` first. Note the `@` separator (not `-`) in Vertex AI model names. + --- ## Customization @@ -335,6 +403,18 @@ This is normal. The initializer agent is generating detailed test cases, which t **"Command blocked by security hook"** The agent tried to run a command not in the allowlist. This is the security system working as intended. If needed, add the command to `ALLOWED_COMMANDS` in `security.py`. +**"Python 3.11+ required but not found"** +Install Python 3.11 or later from [python.org](https://www.python.org/downloads/). Make sure `python3` (or `python` on Windows) is on your PATH. + +**"Python venv module not available"** +On Debian/Ubuntu, the venv module is packaged separately. Install it with `sudo apt install python3.XX-venv` (replace `XX` with your Python minor version, e.g., `python3.12-venv`). + +**"AutoForge is already running"** +A server instance is already active. Use the browser URL shown in the terminal, or stop the existing instance with Ctrl+C first. + +**Virtual environment issues after a Python upgrade** +Run `autoforge --repair` to delete and recreate the virtual environment from scratch. + --- ## License diff --git a/bin/autoforge.js b/bin/autoforge.js new file mode 100644 index 0000000..8a03b16 --- /dev/null +++ b/bin/autoforge.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node +import { run } from '../lib/cli.js'; +run(process.argv.slice(2)); diff --git a/lib/cli.js b/lib/cli.js new file mode 100644 index 0000000..d0d4789 --- /dev/null +++ b/lib/cli.js @@ -0,0 +1,791 @@ +/** + * AutoForge CLI + * ============= + * + * Main CLI module for the AutoForge npm global package. + * Handles Python detection, virtual environment management, + * config loading, and uvicorn server lifecycle. + * + * Uses only Node.js built-in modules -- no external dependencies. + */ + +import { execFileSync, spawn, execSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, rmSync, copyFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { createServer } from 'node:net'; +import { homedir, platform } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// --------------------------------------------------------------------------- +// Path constants +// --------------------------------------------------------------------------- + +/** Root of the autoforge npm package (one level up from lib/) */ +const PKG_DIR = dirname(dirname(fileURLToPath(import.meta.url))); + +/** User config home: ~/.autoforge/ */ +const CONFIG_HOME = join(homedir(), '.autoforge'); + +/** Virtual-environment directory managed by the CLI */ +const VENV_DIR = join(CONFIG_HOME, 'venv'); + +/** Composite marker written after a successful pip install */ +const DEPS_MARKER = join(VENV_DIR, '.deps-installed'); + +/** PID file for the running server */ +const PID_FILE = join(CONFIG_HOME, 'server.pid'); + +/** Path to the production requirements file inside the package */ +const REQUIREMENTS_FILE = join(PKG_DIR, 'requirements-prod.txt'); + +/** Path to the .env example shipped with the package */ +const ENV_EXAMPLE = join(PKG_DIR, '.env.example'); + +/** User .env config file */ +const ENV_FILE = join(CONFIG_HOME, '.env'); + +const IS_WIN = platform() === 'win32'; + +// --------------------------------------------------------------------------- +// Package version (read lazily via createRequire) +// --------------------------------------------------------------------------- + +const require = createRequire(import.meta.url); +const { version: VERSION } = require(join(PKG_DIR, 'package.json')); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Indented console output matching the spec format. */ +function log(msg = '') { + console.log(` ${msg}`); +} + +/** Print a fatal error and exit. */ +function die(msg) { + console.error(`\n Error: ${msg}\n`); + process.exit(1); +} + +/** + * Parse a Python version string like "Python 3.13.6" and return + * { major, minor, patch, raw } or null on failure. + */ +function parsePythonVersion(raw) { + const m = raw.match(/Python\s+(\d+)\.(\d+)\.(\d+)/); + if (!m) return null; + return { + major: Number(m[1]), + minor: Number(m[2]), + patch: Number(m[3]), + raw: `${m[1]}.${m[2]}.${m[3]}`, + }; +} + +/** + * Try a single Python candidate. Returns { exe, version } or null. + * `candidate` is either a bare name or an array of args (e.g. ['py', '-3']). + */ +function tryPythonCandidate(candidate) { + const args = Array.isArray(candidate) ? candidate : [candidate]; + const exe = args[0]; + const extraArgs = args.slice(1); + + try { + const out = execFileSync(exe, [...extraArgs, '--version'], { + encoding: 'utf8', + timeout: 10_000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const ver = parsePythonVersion(out); + if (!ver) return null; + + // Require 3.11+ + if (ver.major < 3 || (ver.major === 3 && ver.minor < 11)) { + return { exe: args.join(' '), version: ver, tooOld: true }; + } + + return { exe: args.join(' '), version: ver, tooOld: false }; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Python detection +// --------------------------------------------------------------------------- + +/** + * Find a suitable Python >= 3.11 interpreter. + * + * Search order is platform-dependent: + * Windows: python -> py -3 -> python3 + * macOS/Linux: python3 -> python + * + * The AUTOFORGE_PYTHON env var overrides automatic detection. + * + * After finding a candidate we also verify that the venv module is + * available (Debian/Ubuntu strip it out of the base package). + */ +function findPython() { + // Allow explicit override via environment variable + const override = process.env.AUTOFORGE_PYTHON; + if (override) { + const result = tryPythonCandidate(override); + if (!result) { + die(`AUTOFORGE_PYTHON is set to "${override}" but it could not be executed.`); + } + if (result.tooOld) { + die( + `Python ${result.version.raw} found (via AUTOFORGE_PYTHON), but 3.11+ required.\n` + + ' Install Python 3.11+ from https://python.org' + ); + } + return result; + } + + // Platform-specific candidate order + const candidates = IS_WIN + ? ['python', ['py', '-3'], 'python3'] + : ['python3', 'python']; + + let bestTooOld = null; + + for (const candidate of candidates) { + const result = tryPythonCandidate(candidate); + if (!result) continue; + + if (result.tooOld) { + // Remember the first "too old" result for a better error message + if (!bestTooOld) bestTooOld = result; + continue; + } + + // Verify venv module is available (Debian/Ubuntu may need python3-venv) + try { + const exeParts = result.exe.split(' '); + execFileSync(exeParts[0], [...exeParts.slice(1), '-c', 'import ensurepip'], { + encoding: 'utf8', + timeout: 10_000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch { + die( + `Python venv module not available.\n` + + ` Run: sudo apt install python3.${result.version.minor}-venv` + ); + } + + return result; + } + + // Provide the most helpful error message we can + if (bestTooOld) { + die( + `Python ${bestTooOld.version.raw} found, but 3.11+ required.\n` + + ' Install Python 3.11+ from https://python.org' + ); + } + die( + 'Python 3.11+ required but not found.\n' + + ' Install from https://python.org' + ); +} + +// --------------------------------------------------------------------------- +// Venv management +// --------------------------------------------------------------------------- + +/** Return the path to the Python executable inside the venv. */ +function venvPython() { + return IS_WIN + ? join(VENV_DIR, 'Scripts', 'python.exe') + : join(VENV_DIR, 'bin', 'python'); +} + +/** SHA-256 hash of the requirements-prod.txt file contents. */ +function requirementsHash() { + const content = readFileSync(REQUIREMENTS_FILE, 'utf8'); + return createHash('sha256').update(content).digest('hex'); +} + +/** + * Read the composite deps marker. Returns the parsed JSON object + * or null if the file is missing / corrupt. + */ +function readMarker() { + try { + return JSON.parse(readFileSync(DEPS_MARKER, 'utf8')); + } catch { + return null; + } +} + +/** + * Ensure the virtual environment exists and dependencies are installed. + * Returns true if all setup steps were already satisfied (fast path). + * + * @param {object} python - The result of findPython() + * @param {boolean} forceRecreate - If true, delete and recreate the venv + */ +function ensureVenv(python, forceRecreate) { + mkdirSync(CONFIG_HOME, { recursive: true }); + + const marker = readMarker(); + const reqHash = requirementsHash(); + const pyExe = venvPython(); + + // Determine if the venv itself needs to be (re)created + let needsCreate = forceRecreate || !existsSync(pyExe); + + if (!needsCreate && marker) { + // Recreate if Python major.minor changed + const markerMinor = marker.python_version; + const currentMinor = `${python.version.major}.${python.version.minor}`; + if (markerMinor && markerMinor !== currentMinor) { + needsCreate = true; + } + + // Recreate if the recorded python path no longer exists + if (marker.python_path && !existsSync(marker.python_path)) { + needsCreate = true; + } + } + + let depsUpToDate = false; + if (!needsCreate && marker && marker.requirements_hash === reqHash) { + depsUpToDate = true; + } + + // Fast path: nothing to do + if (!needsCreate && depsUpToDate) { + return true; + } + + // --- Slow path: show setup progress --- + + log('[2/3] Setting up environment...'); + + if (needsCreate) { + if (existsSync(VENV_DIR)) { + log(' Removing old virtual environment...'); + rmSync(VENV_DIR, { recursive: true, force: true }); + } + + log(` Creating virtual environment at ~/.autoforge/venv/`); + const exeParts = python.exe.split(' '); + try { + execFileSync(exeParts[0], [...exeParts.slice(1), '-m', 'venv', VENV_DIR], { + encoding: 'utf8', + timeout: 120_000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (err) { + die(`Failed to create virtual environment: ${err.message}`); + } + } + + // Install / update dependencies + log(' Installing dependencies...'); + try { + execFileSync(pyExe, ['-m', 'pip', 'install', '-q', '--upgrade', 'pip'], { + encoding: 'utf8', + timeout: 300_000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + execFileSync(pyExe, ['-m', 'pip', 'install', '-q', '-r', REQUIREMENTS_FILE], { + encoding: 'utf8', + timeout: 600_000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (err) { + die(`Failed to install dependencies: ${err.message}`); + } + + // Write marker only after pip succeeds to prevent partial state + const markerData = { + requirements_hash: reqHash, + python_version: `${python.version.major}.${python.version.minor}`, + python_path: pyExe, + created_at: new Date().toISOString(), + }; + writeFileSync(DEPS_MARKER, JSON.stringify(markerData, null, 2), 'utf8'); + + log(' Done'); + return false; +} + +// --------------------------------------------------------------------------- +// Config (.env) management +// --------------------------------------------------------------------------- + +/** + * Parse a .env file into a plain object. + * Handles comments, blank lines, and quoted values. + */ +function parseEnvFile(filePath) { + const env = {}; + if (!existsSync(filePath)) return env; + + const lines = readFileSync(filePath, 'utf8').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + + const key = trimmed.slice(0, eqIdx).trim(); + let value = trimmed.slice(eqIdx + 1).trim(); + + // Strip matching quotes (single or double) + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + if (key) { + env[key] = value; + } + } + return env; +} + +/** + * Ensure ~/.autoforge/.env exists. On first run, copy .env.example + * from the package directory and print a notice. + * + * Returns true if the file was newly created. + */ +function ensureEnvFile() { + if (existsSync(ENV_FILE)) return false; + + mkdirSync(CONFIG_HOME, { recursive: true }); + + if (existsSync(ENV_EXAMPLE)) { + copyFileSync(ENV_EXAMPLE, ENV_FILE); + } else { + // Fallback: create a minimal placeholder + writeFileSync(ENV_FILE, '# AutoForge configuration\n# See documentation for available options.\n', 'utf8'); + } + return true; +} + +// --------------------------------------------------------------------------- +// Port detection +// --------------------------------------------------------------------------- + +/** + * Find an available TCP port starting from `start`. + * Tries by actually binding a socket (most reliable cross-platform approach). + */ +function findAvailablePort(start = 8888, maxAttempts = 20) { + for (let port = start; port < start + maxAttempts; port++) { + try { + const server = createServer(); + // Use a synchronous-like approach: try to listen, then close immediately + const result = new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', () => { + server.close(() => resolve(port)); + }); + }); + // We cannot await here (sync context), so use the blocking approach: + // Try to bind synchronously using a different technique. + server.close(); + } catch { + // fall through + } + } + // Synchronous fallback: try to connect; if connection refused, port is free. + for (let port = start; port < start + maxAttempts; port++) { + try { + execFileSync(process.execPath, [ + '-e', + `const s=require("net").createServer();` + + `s.listen(${port},"127.0.0.1",()=>{s.close();process.exit(0)});` + + `s.on("error",()=>process.exit(1))`, + ], { timeout: 3000, stdio: 'pipe' }); + return port; + } catch { + continue; + } + } + die(`No available ports found in range ${start}-${start + maxAttempts - 1}`); +} + +// --------------------------------------------------------------------------- +// PID file management +// --------------------------------------------------------------------------- + +/** Read PID from the PID file. Returns the PID number or null. */ +function readPid() { + try { + const content = readFileSync(PID_FILE, 'utf8').trim(); + const pid = Number(content); + return Number.isFinite(pid) && pid > 0 ? pid : null; + } catch { + return null; + } +} + +/** Check whether a process with the given PID is still running. */ +function isProcessAlive(pid) { + try { + process.kill(pid, 0); // signal 0 = existence check + return true; + } catch { + return false; + } +} + +/** Write the PID file. */ +function writePid(pid) { + mkdirSync(CONFIG_HOME, { recursive: true }); + writeFileSync(PID_FILE, String(pid), 'utf8'); +} + +/** Remove the PID file. */ +function removePid() { + try { + unlinkSync(PID_FILE); + } catch { + // Ignore -- file may already be gone + } +} + +// --------------------------------------------------------------------------- +// Browser opening +// --------------------------------------------------------------------------- + +/** Open a URL in the user's default browser (best-effort). */ +function openBrowser(url) { + try { + if (IS_WIN) { + // "start" is a cmd built-in; the empty title string avoids + // issues when the URL contains special characters. + execSync(`start "" "${url}"`, { stdio: 'ignore' }); + } else if (platform() === 'darwin') { + execFileSync('open', [url], { stdio: 'ignore' }); + } else { + // Linux: only attempt if a display server is available and + // we are not in an SSH session. + const hasDisplay = process.env.DISPLAY || process.env.WAYLAND_DISPLAY; + const isSSH = !!process.env.SSH_TTY; + if (hasDisplay && !isSSH) { + execFileSync('xdg-open', [url], { stdio: 'ignore' }); + } + } + } catch { + // Non-fatal: user can open the URL manually + } +} + +/** Detect headless / CI environments where opening a browser is pointless. */ +function isHeadless() { + if (process.env.CI) return true; + if (process.env.CODESPACES) return true; + if (process.env.SSH_TTY) return true; + // Linux without a display server + if (!IS_WIN && platform() !== 'darwin' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { + return true; + } + return false; +} + +// --------------------------------------------------------------------------- +// Process cleanup +// --------------------------------------------------------------------------- + +/** Kill a process tree. On Windows uses taskkill; elsewhere sends SIGTERM. */ +function killProcess(pid) { + try { + if (IS_WIN) { + execSync(`taskkill /pid ${pid} /t /f`, { stdio: 'ignore' }); + } else { + process.kill(pid, 'SIGTERM'); + } + } catch { + // Process may already be gone + } +} + +// --------------------------------------------------------------------------- +// CLI commands +// --------------------------------------------------------------------------- + +function printVersion() { + console.log(`autoforge v${VERSION}`); +} + +function printHelp() { + console.log(` + AutoForge v${VERSION} + Autonomous coding agent with web UI + + Usage: + autoforge Start the server (default) + autoforge config Open ~/.autoforge/.env in $EDITOR + autoforge config --path Print config file path + autoforge config --show Show effective configuration + + Options: + --port PORT Custom port (default: auto from 8888) + --host HOST Custom host (default: 127.0.0.1) + --no-browser Don't auto-open browser + --repair Delete and recreate virtual environment + --dev Development mode (requires cloned repo) + --version Print version + --help Show this help +`); +} + +function handleConfig(args) { + ensureEnvFile(); + + if (args.includes('--path')) { + console.log(ENV_FILE); + return; + } + + if (args.includes('--show')) { + if (!existsSync(ENV_FILE)) { + log('No configuration file found.'); + return; + } + const lines = readFileSync(ENV_FILE, 'utf8').split('\n'); + const active = lines.filter(l => { + const t = l.trim(); + return t && !t.startsWith('#'); + }); + if (active.length === 0) { + log('No active configuration. All lines are commented out.'); + log(`Edit: ${ENV_FILE}`); + } else { + for (const line of active) { + console.log(line); + } + } + return; + } + + // Open in editor + const editor = process.env.EDITOR || process.env.VISUAL || (IS_WIN ? 'notepad' : 'vi'); + try { + execFileSync(editor, [ENV_FILE], { stdio: 'inherit' }); + } catch { + log(`Could not open editor "${editor}".`); + log(`Edit the file manually: ${ENV_FILE}`); + } +} + +// --------------------------------------------------------------------------- +// Main server start +// --------------------------------------------------------------------------- + +function startServer(opts) { + const { port: requestedPort, host, noBrowser, repair } = opts; + + // Step 1: Find Python + const fastPath = !repair && existsSync(venvPython()) && readMarker()?.requirements_hash === requirementsHash(); + + let python; + if (fastPath) { + // Skip the Python search header on fast path -- we already have a working venv + python = null; + } else { + log(`[1/3] Checking Python...`); + python = findPython(); + log(` Found Python ${python.version.raw} at ${python.exe}`); + } + + // Step 2: Ensure venv and deps + if (!python) { + // Fast path still needs a python reference for potential repair + python = findPython(); + } + const wasAlreadyReady = ensureVenv(python, repair); + + // Step 3: Config file + const configCreated = ensureEnvFile(); + + // Load .env into process.env for the spawned server + const dotenvVars = parseEnvFile(ENV_FILE); + + // Determine port + const port = requestedPort || findAvailablePort(); + + // Check for already-running instance + const existingPid = readPid(); + if (existingPid && isProcessAlive(existingPid)) { + log(`AutoForge is already running at http://${host}:${port}`); + log('Opening browser...'); + if (!noBrowser && !isHeadless()) { + openBrowser(`http://${host}:${port}`); + } + return; + } + + // Clean up stale PID file + if (existingPid) { + removePid(); + } + + // Show server startup step only on slow path + if (!wasAlreadyReady) { + log('[3/3] Starting server...'); + } + + if (configCreated) { + log(` Created config file: ~/.autoforge/.env`); + log(' Edit this file to configure API providers (Ollama, Vertex AI, z.ai)'); + log(''); + } + + // Security warning for non-localhost host + if (host !== '127.0.0.1') { + console.log(''); + console.log(' !! SECURITY WARNING !!'); + console.log(` Remote access enabled on host: ${host}`); + console.log(' The AutoForge UI will be accessible from other machines.'); + console.log(' Ensure you understand the security implications.'); + console.log(''); + } + + // Build environment for uvicorn + const serverEnv = { ...process.env, ...dotenvVars, PYTHONPATH: PKG_DIR }; + + // Enable remote access flag for the FastAPI server + if (host !== '127.0.0.1') { + serverEnv.AUTOFORGE_ALLOW_REMOTE = '1'; + } + + // Spawn uvicorn + const pyExe = venvPython(); + const child = spawn( + pyExe, + [ + '-m', 'uvicorn', + 'server.main:app', + '--host', host, + '--port', String(port), + ], + { + cwd: PKG_DIR, + env: serverEnv, + stdio: 'inherit', + } + ); + + writePid(child.pid); + + // Open browser after a short delay to let the server start + if (!noBrowser && !isHeadless()) { + setTimeout(() => openBrowser(`http://${host}:${port}`), 2000); + } + + const url = `http://${host}:${port}`; + console.log(''); + log(`Server running at ${url}`); + log('Press Ctrl+C to stop'); + + // Graceful shutdown handlers + const cleanup = () => { + killProcess(child.pid); + removePid(); + }; + + process.on('SIGINT', () => { + console.log(''); + cleanup(); + process.exit(0); + }); + + process.on('SIGTERM', () => { + cleanup(); + process.exit(0); + }); + + // If the child exits on its own, clean up and propagate the exit code + child.on('exit', (code) => { + removePid(); + process.exit(code ?? 1); + }); +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +/** + * Main CLI entry point. + * + * @param {string[]} args - Command-line arguments (process.argv.slice(2)) + */ +export function run(args) { + // --version / -v + if (args.includes('--version') || args.includes('-v')) { + printVersion(); + return; + } + + // --help / -h + if (args.includes('--help') || args.includes('-h')) { + printHelp(); + return; + } + + // --dev guard: this only works from a cloned repository + if (args.includes('--dev')) { + die( + 'Dev mode requires a cloned repository.\n' + + ' Clone from https://github.com/paperlinguist/autocoder and run start_ui.sh' + ); + return; + } + + // "config" subcommand + if (args[0] === 'config') { + handleConfig(args.slice(1)); + return; + } + + // Parse flags for server start + const host = getFlagValue(args, '--host') || '127.0.0.1'; + const portStr = getFlagValue(args, '--port'); + const port = portStr ? Number(portStr) : null; + const noBrowser = args.includes('--no-browser'); + const repair = args.includes('--repair'); + + if (port !== null && (!Number.isFinite(port) || port < 1 || port > 65535)) { + die('Invalid port number. Must be between 1 and 65535.'); + } + + // Print banner + console.log(''); + log(`AutoForge v${VERSION}`); + console.log(''); + + startServer({ port, host, noBrowser, repair }); +} + +// --------------------------------------------------------------------------- +// Argument parsing helpers +// --------------------------------------------------------------------------- + +/** + * Extract the value following a flag from the args array. + * E.g. getFlagValue(['--port', '9000', '--host', '0.0.0.0'], '--port') => '9000' + */ +function getFlagValue(args, flag) { + const idx = args.indexOf(flag); + if (idx === -1 || idx + 1 >= args.length) return null; + return args[idx + 1]; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c3849f8 --- /dev/null +++ b/package.json @@ -0,0 +1,53 @@ +{ + "name": "autoforge-ai", + "version": "0.1.0", + "description": "Autonomous coding agent with web UI - build complete apps with AI", + "license": "AGPL-3.0", + "bin": { + "autoforge": "./bin/autoforge.js" + }, + "type": "module", + "engines": { + "node": ">=20" + }, + "files": [ + "bin/", + "lib/", + "api/", + "server/", + "mcp_server/", + "ui/dist/", + "ui/package.json", + ".claude/commands/", + ".claude/templates/", + "examples/", + "start.py", + "agent.py", + "auth.py", + "autoforge_paths.py", + "autonomous_agent_demo.py", + "client.py", + "env_constants.py", + "parallel_orchestrator.py", + "progress.py", + "prompts.py", + "registry.py", + "rate_limit_utils.py", + "security.py", + "requirements-prod.txt", + "pyproject.toml", + ".env.example", + "!**/__pycache__/", + "!**/*.pyc" + ], + "keywords": [ + "ai", + "coding-agent", + "claude", + "autonomous", + "code-generation" + ], + "scripts": { + "prepublishOnly": "npm --prefix ui install && npm --prefix ui run build" + } +} diff --git a/requirements-prod.txt b/requirements-prod.txt new file mode 100644 index 0000000..05e7f4c --- /dev/null +++ b/requirements-prod.txt @@ -0,0 +1,14 @@ +# Production runtime dependencies only +# For development, use requirements.txt (includes ruff, mypy, pytest) +claude-agent-sdk>=0.1.0,<0.2.0 +python-dotenv>=1.0.0 +sqlalchemy>=2.0.0 +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 +websockets>=13.0 +python-multipart>=0.0.17 +psutil>=6.0.0 +aiofiles>=24.0.0 +apscheduler>=3.10.0,<4.0.0 +pywinpty>=2.0.0; sys_platform == "win32" +pyyaml>=6.0.0 diff --git a/ui/package-lock.json b/ui/package-lock.json index 5b96104..6dfde6f 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,11 +1,11 @@ { - "name": "autoforge", + "name": "autoforge-ui", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "autoforge", + "name": "autoforge-ui", "version": "1.0.0", "dependencies": { "@radix-ui/react-checkbox": "^1.3.3", @@ -50,6 +50,18 @@ "vite": "^7.3.0" } }, + "..": { + "name": "autoforge-ai", + "version": "0.1.0", + "extraneous": true, + "license": "AGPL-3.0", + "bin": { + "autoforge": "bin/autoforge.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", diff --git a/ui/package.json b/ui/package.json index 94ca053..e563b99 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,5 +1,5 @@ { - "name": "autoforge", + "name": "autoforge-ui", "private": true, "version": "1.0.0", "type": "module", diff --git a/ui/src/components/docs/docsData.ts b/ui/src/components/docs/docsData.ts index 3f66d0a..8db7b26 100644 --- a/ui/src/components/docs/docsData.ts +++ b/ui/src/components/docs/docsData.ts @@ -35,12 +35,14 @@ export const DOC_SECTIONS: DocSection[] = [ icon: Rocket, subsections: [ { id: 'what-is-autoforge', title: 'What is AutoForge?' }, + { id: 'installation', title: 'Installation' }, { id: 'quick-start', title: 'Quick Start' }, + { id: 'cli-commands', title: 'CLI Commands' }, { id: 'creating-a-project', title: 'Creating a New Project' }, { id: 'existing-project', title: 'Adding to an Existing Project' }, { id: 'system-requirements', title: 'System Requirements' }, ], - keywords: ['install', 'setup', 'start', 'begin', 'new', 'requirements', 'prerequisites'], + keywords: ['install', 'setup', 'start', 'begin', 'new', 'requirements', 'prerequisites', 'npm', 'config', 'port', 'repair'], }, { id: 'app-spec-setup', diff --git a/ui/src/components/docs/sections/GettingStarted.tsx b/ui/src/components/docs/sections/GettingStarted.tsx index 5976dc0..3d7eb0b 100644 --- a/ui/src/components/docs/sections/GettingStarted.tsx +++ b/ui/src/components/docs/sections/GettingStarted.tsx @@ -7,6 +7,25 @@ import { Badge } from '@/components/ui/badge' +/** CLI command descriptor for the reference table. */ +interface CliCommand { + command: string + description: string +} + +const CLI_COMMANDS: CliCommand[] = [ + { command: 'autoforge', description: 'Start the server (default)' }, + { command: 'autoforge config', description: 'Open ~/.autoforge/.env in your editor' }, + { command: 'autoforge config --path', description: 'Print config file path' }, + { command: 'autoforge config --show', description: 'Show active configuration values' }, + { command: 'autoforge --port PORT', description: 'Custom port (default: auto from 8888)' }, + { command: 'autoforge --host HOST', description: 'Custom host (default: 127.0.0.1)' }, + { command: 'autoforge --no-browser', description: "Don't auto-open browser" }, + { command: 'autoforge --repair', description: 'Delete and recreate virtual environment' }, + { command: 'autoforge --version', description: 'Print version' }, + { command: 'autoforge --help', description: 'Show help' }, +] + export function GettingStarted() { return (
@@ -33,24 +52,141 @@ export function GettingStarted() { in real time.

+ {/* Installation */} +

+ Installation +

+

+ Install AutoForge globally via npm: +

+
+
{`npm install -g autoforge-ai`}
+
+

+ This requires{' '} + Node.js 20+ and{' '} + Python 3.11+. + Python is auto-detected on first run. +

+ {/* Quick Start */}

Quick Start

- Launch AutoForge with a single command. The CLI menu lets you create or select a project, - while the Web UI provides a full dashboard experience. + After installing, start AutoForge with a single command: +

+
+
{`autoforge`}
+
+

+ On first run, AutoForge automatically: +

+
    +
  1. Checks for Python 3.11+
  2. +
  3. Creates a virtual environment at{' '} + ~/.autoforge/venv/ +
  4. +
  5. Installs Python dependencies
  6. +
  7. Copies a default config file to{' '} + ~/.autoforge/.env +
  8. +
  9. Starts the server and opens your browser
  10. +
+

+ On subsequent runs, AutoForge starts instantly — the environment is already set up. +

+ +

+ Running from Source +

+

+ If you prefer to clone the repository (for development or contributing):

{`# Windows
-start.bat          # CLI menu
 start_ui.bat       # Web UI
 
 # macOS/Linux
-./start.sh         # CLI menu
 ./start_ui.sh      # Web UI`}
+ {/* CLI Commands */} +

+ CLI Commands +

+

+ The{' '} + autoforge{' '} + command supports these options: +

+ + + + + + + + + {CLI_COMMANDS.map((c) => ( + + + + + ))} + +
+ Command + + Description +
+ {c.command} + {c.description}
+

+ Configuration +

+

+ AutoForge reads configuration from a{' '} + .env{' '} + file. The location depends on your install method: +

+ + + + + + + + + + + + + + + + + +
+ Install method + + Config location +
npm (global) + ~/.autoforge/.env +
From source + .env{' '} + in the project root +
+

+ Run{' '} + autoforge config{' '} + to open the config file in your editor, or{' '} + autoforge config --show{' '} + to print the active values. See{' '} + Advanced Configuration{' '} + for API provider setup (Ollama, Vertex AI, z.ai). +

+ {/* Creating a New Project */}

Creating a New Project @@ -102,17 +238,18 @@ start_ui.bat # Web UI - - Python - - 3.11+ - - Node.js 20+{' '} - (for UI development) + (required for CLI and UI) + + + + Python + + 3.11+{' '} + (auto-detected on first run)