Script for easily configuring, using, switching and comparing local offline coding models
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Initial commit (before AI edits)

dietrich ayala 155d66b8

+2898
+22
.claude/settings.local.json
··· 1 + { 2 + "permissions": { 3 + "allow": [ 4 + "Bash(~/.local/bin/llama-chat-server:*)", 5 + "Bash(curl:*)", 6 + "Bash(llama-server:*)", 7 + "Bash(pkill:*)", 8 + "Bash(sysctl:*)", 9 + "WebFetch(domain:github.com)", 10 + "WebFetch(domain:raw.githubusercontent.com)", 11 + "Bash(pipx install:*)", 12 + "Bash(python3.12:*)", 13 + "Bash(HOMEBREW_NO_AUTO_UPDATE=1 brew install:*)", 14 + "Bash(go install:*)", 15 + "Bash(~/.local/bin/localcode help:*)", 16 + "Bash(~/.local/bin/localcode status:*)", 17 + "Bash(~/.local/bin/localcode models:*)", 18 + "Bash(~/.local/bin/localcode tuis:*)", 19 + "Bash(~/.local/bin/localcode bench:*)" 20 + ] 21 + } 22 + }
+3
.gitignore
··· 1 + node_modules/ 2 + dist/ 3 + *.swp
+91
CLAUDE.md
··· 1 + # CLAUDE.md 2 + 3 + This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 + 5 + ## Project Overview 6 + 7 + `localcode` — a single CLI for managing a fully offline local AI coding environment on macOS Apple Silicon. Uses llama.cpp to serve Qwen 2.5 Coder models via OpenAI-compatible APIs, with switchable terminal coding agents (Aider, OpenCode, Pi). 8 + 9 + ## Commands 10 + 11 + - `npm run dev -- <args>` — Run via tsx (development) 12 + - `npm run build` — Compile TypeScript to `dist/` 13 + - `npx tsc --noEmit` — Type-check without emitting 14 + 15 + After `localcode setup`, the `localcode` binary is available in `~/.local/bin/`. 16 + 17 + ## CLI 18 + 19 + ``` 20 + localcode Launch active TUI in current directory 21 + localcode status Show current config + server health 22 + localcode start Start chat + autocomplete servers 23 + localcode stop Stop all servers 24 + localcode models List available models 25 + localcode models set-chat <id> Switch chat model 26 + localcode models set-auto <id> Switch autocomplete model 27 + localcode tuis List available TUIs 28 + localcode tuis set <id> Switch active TUI 29 + localcode bench Benchmark running chat model 30 + localcode bench history Show past benchmark results 31 + localcode pipe "prompt" Pipe stdin through the model 32 + localcode setup Full install 33 + ``` 34 + 35 + ## Architecture 36 + 37 + ``` 38 + src/ 39 + main.ts — CLI dispatcher (switch on process.argv[2]) 40 + config.ts — Path/port constants 41 + log.ts — log/warn/err with ANSI colors 42 + util.ts — Shell exec helpers, file writers 43 + runtime-config.ts — Read/write ~/.config/localcode/config.json 44 + registry/ 45 + models.ts — ModelDef interface + MODELS array 46 + tuis.ts — TuiDef interface + TUIS array 47 + commands/ 48 + run.ts — Default action: ensure server, init git, exec TUI 49 + status.ts — Show config + server health 50 + server.ts — Start/stop llama.cpp servers 51 + setup.ts — Full install pipeline 52 + models.ts — List/switch models, auto-download + regen scripts 53 + tuis.ts — List/switch TUIs, auto-install + regen scripts 54 + bench.ts — Benchmark against running llama.cpp server 55 + pipe.ts — Pipe stdin through the model 56 + steps/ — Individual setup phases (preflight, homebrew, llama, etc.) 57 + templates/ 58 + scripts.ts — Bash server launcher templates (parameterized by ModelDef) 59 + aider.ts — Aider config template 60 + opencode.ts — OpenCode config template 61 + pi.ts — Pi models.json template 62 + ``` 63 + 64 + ### Key patterns 65 + 66 + **Runtime config** (`~/.config/localcode/config.json`): Stores active chatModel, autocompleteModel, and tui IDs. Read by `runtime-config.ts` with defaults fallback. 67 + 68 + **Registries**: `registry/models.ts` and `registry/tuis.ts` define available options as typed arrays. Add new models/TUIs by appending to these arrays. 69 + 70 + **Script regeneration**: When models or TUI are switched, launcher scripts in `~/.local/bin/` and all TUI configs are automatically regenerated. 71 + 72 + **Template escaping**: Bash templates in `src/templates/scripts.ts` use `const D = "$"` to emit literal `$` without triggering TS interpolation. 73 + 74 + **Generated scripts**: Only 3 bash scripts are generated in `~/.local/bin/`: `localcode` (thin wrapper calling `node dist/main.js`), `llama-chat-server`, `llama-complete-server`. All other functionality lives in TypeScript commands. 75 + 76 + **Benchmark**: Hits `/v1/chat/completions` with 3 hardcoded prompts, measures wall-clock time + token counts. Results saved to `~/.config/localcode/benchmarks.json`. 77 + 78 + ## Key paths on the user's system 79 + 80 + - `~/.local/bin/` — `localcode` wrapper + server launcher scripts 81 + - `~/.local/share/llama-models/` — Downloaded GGUF model files 82 + - `~/.config/localcode/config.json` — Active model/TUI selection 83 + - `~/.config/localcode/benchmarks.json` — Benchmark history 84 + - `~/.aider/` — Aider config 85 + - `~/.config/opencode/opencode.json` — OpenCode config 86 + - `~/.pi/agent/models.json` — Pi config 87 + - Chat server port **8080**, autocomplete port **8081** 88 + 89 + ## Important: after changing TypeScript 90 + 91 + The `localcode` wrapper in `~/.local/bin/` calls `node dist/main.js`. After modifying TypeScript source, run `npm run build` to recompile, or the wrapper will run stale code.
+192
README.md
··· 1 + # Local AI Coding Environment 2 + 3 + A fully offline, privacy-first AI coding setup for macOS Apple Silicon. Uses **llama.cpp** to run **Qwen 2.5 Coder** models locally, with **Aider** and **OpenCode** as terminal-based coding agents — no API keys, no cloud, no costs. 4 + 5 + ## Hardware Requirements 6 + 7 + - **Mac with Apple Silicon** (M1/M2/M3/M4) 8 + - **32GB RAM** recommended (the 32B model uses ~20GB) 9 + - ~25GB free disk space for models 10 + 11 + ## What Gets Installed 12 + 13 + | Component | Purpose | 14 + |---|---| 15 + | **llama.cpp** | Local model inference with Metal GPU acceleration | 16 + | **Qwen 2.5 Coder 32B** (Q4_K_M) | Main chat/coding model (~20GB) | 17 + | **Qwen 2.5 Coder 1.5B** (Q4_K_M) | Fast autocomplete model (~1.2GB) | 18 + | **Aider** | Terminal coding agent (Claude Code alternative) | 19 + | **jq** | JSON processing for the pipe command | 20 + 21 + Both models are served via llama.cpp's built-in OpenAI-compatible API, making them work with any tool that supports the OpenAI API format. 22 + 23 + ## Installation 24 + 25 + ```bash 26 + chmod +x setup.sh 27 + ./setup.sh 28 + ``` 29 + 30 + The script is idempotent — safe to run multiple times. The first run downloads ~21GB of model weights from HuggingFace. 31 + 32 + After installation, restart your shell or run: 33 + 34 + ```bash 35 + source ~/.zshrc 36 + ``` 37 + 38 + ## Commands 39 + 40 + ### `llama-start` 41 + 42 + Starts both llama.cpp servers in the foreground. Press `Ctrl+C` to stop both. 43 + 44 + - Chat model (32B): `http://127.0.0.1:8080` 45 + - Autocomplete model (1.5B): `http://127.0.0.1:8081` 46 + 47 + ### `llama-stop` 48 + 49 + Kills all running llama-server processes. 50 + 51 + ### `ai-code [directory]` 52 + 53 + The main coding agent. Auto-starts the chat server if it's not running. Initializes a git repo if one doesn't exist, then launches Aider with full file-editing capabilities. 54 + 55 + ```bash 56 + cd ~/projects/my-app 57 + ai-code . 58 + 59 + # or from anywhere 60 + ai-code ~/projects/my-app 61 + ``` 62 + 63 + Inside Aider you can ask it to edit files, run commands, and refactor code across your project. Changes are auto-committed to git so you can always roll back. 64 + 65 + ### `ai-ask "question"` 66 + 67 + Quick Q&A mode — no file editing, just chat. Useful for coding questions without modifying your project. 68 + 69 + ```bash 70 + ai-ask "how do I handle errors in rust" 71 + ``` 72 + 73 + ### `ai-pipe "prompt"` 74 + 75 + Pipe code through the model via stdin. Useful for one-shot transforms in scripts. 76 + 77 + ```bash 78 + cat main.py | ai-pipe "add type hints" 79 + git diff | ai-pipe "write a commit message" 80 + cat api.go | ai-pipe "find bugs" 81 + ``` 82 + 83 + ## Using OpenCode Instead of Aider 84 + 85 + [OpenCode](https://opencode.ai) is another terminal coding agent with a polished TUI. It connects to the same llama.cpp backend. 86 + 87 + ### Install OpenCode 88 + 89 + ```bash 90 + brew install anomalyco/tap/opencode 91 + ``` 92 + 93 + ### Configure 94 + 95 + Create `~/.config/opencode/opencode.json`: 96 + 97 + ```json 98 + { 99 + "$schema": "https://opencode.ai/config.json", 100 + "model": "llama-cpp/qwen2.5-coder-32b", 101 + "provider": { 102 + "llama-cpp": { 103 + "npm": "@ai-sdk/openai-compatible", 104 + "name": "llama.cpp (local)", 105 + "options": { 106 + "baseURL": "http://127.0.0.1:8080/v1", 107 + "apiKey": "not-needed" 108 + }, 109 + "models": { 110 + "qwen2.5-coder-32b": { 111 + "name": "Qwen 2.5 Coder 32B", 112 + "tools": true 113 + } 114 + } 115 + } 116 + } 117 + } 118 + ``` 119 + 120 + ### Run 121 + 122 + ```bash 123 + llama-start # start the server (or ai-code auto-starts it) 124 + opencode # launch OpenCode in your project directory 125 + ``` 126 + 127 + Use `/models` inside OpenCode to select the Qwen model, and `Tab` to switch between Plan and Build modes. 128 + 129 + ## Configuration Files 130 + 131 + | File | Purpose | 132 + |---|---| 133 + | `~/.aider/aider.conf.yml` | Aider settings (model, git, UI) | 134 + | `~/.aider/.env` | API base URL and key for Aider | 135 + | `~/.config/opencode/opencode.json` | OpenCode provider config | 136 + | `~/.local/share/llama-models/` | Downloaded GGUF model files | 137 + | `~/.local/bin/` | Launcher scripts | 138 + 139 + ## llama.cpp Server Flags 140 + 141 + The chat server launches with these defaults: 142 + 143 + | Flag | Value | Purpose | 144 + |---|---|---| 145 + | `--ctx-size` | 16384 | Context window (increase to 32768 if tools misbehave) | 146 + | `--n-gpu-layers` | 99 | Offload all layers to Metal GPU | 147 + | `--flash-attn` | — | Enable flash attention for speed | 148 + | `--mlock` | — | Lock model in RAM to prevent swapping | 149 + | `--threads` | auto | Uses performance core count | 150 + 151 + ## Troubleshooting 152 + 153 + **Model loading is slow on first run**: The first inference after starting the server takes 10–30 seconds while the model loads into memory. Subsequent requests are fast. 154 + 155 + **Running out of RAM / swapping**: The 32B Q4 model needs ~20GB. Close memory-heavy apps. You can also try the smaller `qwen2.5-coder-14b-instruct-q4_k_m.gguf` instead. 156 + 157 + **OpenCode tools not working**: Increase `--ctx-size` to 32768 in the `llama-chat-server` script. Tool-calling needs more context to work reliably. 158 + 159 + **Slow generation speed**: Expect ~15–25 tokens/sec on the 32B model with M4. This is normal for a model this size running locally. The 1.5B autocomplete model runs much faster. 160 + 161 + **Server won't start**: Check if another process is using port 8080 or 8081 with `lsof -i :8080`. Use `llama-stop` to kill stale processes. 162 + 163 + ## Performance Expectations 164 + 165 + | Model | Speed | Use Case | 166 + |---|---|---| 167 + | Qwen 2.5 Coder 32B | ~15–25 tok/s | Chat, code generation, refactoring | 168 + | Qwen 2.5 Coder 1.5B | ~100+ tok/s | Autocomplete, quick suggestions | 169 + 170 + Both models run entirely on-device using Metal acceleration. No network connection required after initial setup. 171 + 172 + ## Uninstall 173 + 174 + ```bash 175 + # Remove models (~21GB) 176 + rm -rf ~/.local/share/llama-models 177 + 178 + # Remove launcher scripts 179 + rm ~/.local/bin/{ai-code,ai-ask,ai-pipe,llama-start,llama-stop,llama-chat-server,llama-complete-server} 180 + 181 + # Remove configs 182 + rm -rf ~/.aider 183 + rm -f ~/.config/opencode/opencode.json 184 + 185 + # Remove Ollama auto-start (if set) 186 + launchctl unload ~/Library/LaunchAgents/com.ollama.serve.plist 187 + rm ~/Library/LaunchAgents/com.ollama.serve.plist 188 + 189 + # Uninstall packages 190 + pipx uninstall aider-chat 191 + brew uninstall llama.cpp jq 192 + ```
+5
gen-scripts.ts
··· 1 + import { createLauncherScripts } from "./src/steps/scripts.js"; 2 + import { writeTuiConfig } from "./src/steps/aider-config.js"; 3 + 4 + await createLauncherScripts(); 5 + await writeTuiConfig();
+20
opencode-config.json
··· 1 + { 2 + "$schema": "https://opencode.ai/config.json", 3 + "model": "llama-cpp/qwen2.5-coder-32b", 4 + "provider": { 5 + "llama-cpp": { 6 + "npm": "@ai-sdk/openai-compatible", 7 + "name": "llama.cpp (local)", 8 + "options": { 9 + "baseURL": "http://127.0.0.1:8080/v1", 10 + "apiKey": "not-needed" 11 + }, 12 + "models": { 13 + "qwen2.5-coder-32b": { 14 + "name": "Qwen 2.5 Coder 32B", 15 + "tools": true 16 + } 17 + } 18 + } 19 + } 20 + }
+590
package-lock.json
··· 1 + { 2 + "name": "localcode-setup", 3 + "version": "1.0.0", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "localcode-setup", 9 + "version": "1.0.0", 10 + "devDependencies": { 11 + "@types/node": "^22", 12 + "tsx": "^4", 13 + "typescript": "^5" 14 + } 15 + }, 16 + "node_modules/@esbuild/aix-ppc64": { 17 + "version": "0.27.3", 18 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", 19 + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", 20 + "cpu": [ 21 + "ppc64" 22 + ], 23 + "dev": true, 24 + "license": "MIT", 25 + "optional": true, 26 + "os": [ 27 + "aix" 28 + ], 29 + "engines": { 30 + "node": ">=18" 31 + } 32 + }, 33 + "node_modules/@esbuild/android-arm": { 34 + "version": "0.27.3", 35 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", 36 + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", 37 + "cpu": [ 38 + "arm" 39 + ], 40 + "dev": true, 41 + "license": "MIT", 42 + "optional": true, 43 + "os": [ 44 + "android" 45 + ], 46 + "engines": { 47 + "node": ">=18" 48 + } 49 + }, 50 + "node_modules/@esbuild/android-arm64": { 51 + "version": "0.27.3", 52 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", 53 + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", 54 + "cpu": [ 55 + "arm64" 56 + ], 57 + "dev": true, 58 + "license": "MIT", 59 + "optional": true, 60 + "os": [ 61 + "android" 62 + ], 63 + "engines": { 64 + "node": ">=18" 65 + } 66 + }, 67 + "node_modules/@esbuild/android-x64": { 68 + "version": "0.27.3", 69 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", 70 + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", 71 + "cpu": [ 72 + "x64" 73 + ], 74 + "dev": true, 75 + "license": "MIT", 76 + "optional": true, 77 + "os": [ 78 + "android" 79 + ], 80 + "engines": { 81 + "node": ">=18" 82 + } 83 + }, 84 + "node_modules/@esbuild/darwin-arm64": { 85 + "version": "0.27.3", 86 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", 87 + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", 88 + "cpu": [ 89 + "arm64" 90 + ], 91 + "dev": true, 92 + "license": "MIT", 93 + "optional": true, 94 + "os": [ 95 + "darwin" 96 + ], 97 + "engines": { 98 + "node": ">=18" 99 + } 100 + }, 101 + "node_modules/@esbuild/darwin-x64": { 102 + "version": "0.27.3", 103 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", 104 + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", 105 + "cpu": [ 106 + "x64" 107 + ], 108 + "dev": true, 109 + "license": "MIT", 110 + "optional": true, 111 + "os": [ 112 + "darwin" 113 + ], 114 + "engines": { 115 + "node": ">=18" 116 + } 117 + }, 118 + "node_modules/@esbuild/freebsd-arm64": { 119 + "version": "0.27.3", 120 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", 121 + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", 122 + "cpu": [ 123 + "arm64" 124 + ], 125 + "dev": true, 126 + "license": "MIT", 127 + "optional": true, 128 + "os": [ 129 + "freebsd" 130 + ], 131 + "engines": { 132 + "node": ">=18" 133 + } 134 + }, 135 + "node_modules/@esbuild/freebsd-x64": { 136 + "version": "0.27.3", 137 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", 138 + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", 139 + "cpu": [ 140 + "x64" 141 + ], 142 + "dev": true, 143 + "license": "MIT", 144 + "optional": true, 145 + "os": [ 146 + "freebsd" 147 + ], 148 + "engines": { 149 + "node": ">=18" 150 + } 151 + }, 152 + "node_modules/@esbuild/linux-arm": { 153 + "version": "0.27.3", 154 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", 155 + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", 156 + "cpu": [ 157 + "arm" 158 + ], 159 + "dev": true, 160 + "license": "MIT", 161 + "optional": true, 162 + "os": [ 163 + "linux" 164 + ], 165 + "engines": { 166 + "node": ">=18" 167 + } 168 + }, 169 + "node_modules/@esbuild/linux-arm64": { 170 + "version": "0.27.3", 171 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", 172 + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", 173 + "cpu": [ 174 + "arm64" 175 + ], 176 + "dev": true, 177 + "license": "MIT", 178 + "optional": true, 179 + "os": [ 180 + "linux" 181 + ], 182 + "engines": { 183 + "node": ">=18" 184 + } 185 + }, 186 + "node_modules/@esbuild/linux-ia32": { 187 + "version": "0.27.3", 188 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", 189 + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", 190 + "cpu": [ 191 + "ia32" 192 + ], 193 + "dev": true, 194 + "license": "MIT", 195 + "optional": true, 196 + "os": [ 197 + "linux" 198 + ], 199 + "engines": { 200 + "node": ">=18" 201 + } 202 + }, 203 + "node_modules/@esbuild/linux-loong64": { 204 + "version": "0.27.3", 205 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", 206 + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", 207 + "cpu": [ 208 + "loong64" 209 + ], 210 + "dev": true, 211 + "license": "MIT", 212 + "optional": true, 213 + "os": [ 214 + "linux" 215 + ], 216 + "engines": { 217 + "node": ">=18" 218 + } 219 + }, 220 + "node_modules/@esbuild/linux-mips64el": { 221 + "version": "0.27.3", 222 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", 223 + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", 224 + "cpu": [ 225 + "mips64el" 226 + ], 227 + "dev": true, 228 + "license": "MIT", 229 + "optional": true, 230 + "os": [ 231 + "linux" 232 + ], 233 + "engines": { 234 + "node": ">=18" 235 + } 236 + }, 237 + "node_modules/@esbuild/linux-ppc64": { 238 + "version": "0.27.3", 239 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", 240 + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", 241 + "cpu": [ 242 + "ppc64" 243 + ], 244 + "dev": true, 245 + "license": "MIT", 246 + "optional": true, 247 + "os": [ 248 + "linux" 249 + ], 250 + "engines": { 251 + "node": ">=18" 252 + } 253 + }, 254 + "node_modules/@esbuild/linux-riscv64": { 255 + "version": "0.27.3", 256 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", 257 + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", 258 + "cpu": [ 259 + "riscv64" 260 + ], 261 + "dev": true, 262 + "license": "MIT", 263 + "optional": true, 264 + "os": [ 265 + "linux" 266 + ], 267 + "engines": { 268 + "node": ">=18" 269 + } 270 + }, 271 + "node_modules/@esbuild/linux-s390x": { 272 + "version": "0.27.3", 273 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", 274 + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", 275 + "cpu": [ 276 + "s390x" 277 + ], 278 + "dev": true, 279 + "license": "MIT", 280 + "optional": true, 281 + "os": [ 282 + "linux" 283 + ], 284 + "engines": { 285 + "node": ">=18" 286 + } 287 + }, 288 + "node_modules/@esbuild/linux-x64": { 289 + "version": "0.27.3", 290 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", 291 + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", 292 + "cpu": [ 293 + "x64" 294 + ], 295 + "dev": true, 296 + "license": "MIT", 297 + "optional": true, 298 + "os": [ 299 + "linux" 300 + ], 301 + "engines": { 302 + "node": ">=18" 303 + } 304 + }, 305 + "node_modules/@esbuild/netbsd-arm64": { 306 + "version": "0.27.3", 307 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", 308 + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", 309 + "cpu": [ 310 + "arm64" 311 + ], 312 + "dev": true, 313 + "license": "MIT", 314 + "optional": true, 315 + "os": [ 316 + "netbsd" 317 + ], 318 + "engines": { 319 + "node": ">=18" 320 + } 321 + }, 322 + "node_modules/@esbuild/netbsd-x64": { 323 + "version": "0.27.3", 324 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", 325 + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", 326 + "cpu": [ 327 + "x64" 328 + ], 329 + "dev": true, 330 + "license": "MIT", 331 + "optional": true, 332 + "os": [ 333 + "netbsd" 334 + ], 335 + "engines": { 336 + "node": ">=18" 337 + } 338 + }, 339 + "node_modules/@esbuild/openbsd-arm64": { 340 + "version": "0.27.3", 341 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", 342 + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", 343 + "cpu": [ 344 + "arm64" 345 + ], 346 + "dev": true, 347 + "license": "MIT", 348 + "optional": true, 349 + "os": [ 350 + "openbsd" 351 + ], 352 + "engines": { 353 + "node": ">=18" 354 + } 355 + }, 356 + "node_modules/@esbuild/openbsd-x64": { 357 + "version": "0.27.3", 358 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", 359 + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", 360 + "cpu": [ 361 + "x64" 362 + ], 363 + "dev": true, 364 + "license": "MIT", 365 + "optional": true, 366 + "os": [ 367 + "openbsd" 368 + ], 369 + "engines": { 370 + "node": ">=18" 371 + } 372 + }, 373 + "node_modules/@esbuild/openharmony-arm64": { 374 + "version": "0.27.3", 375 + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", 376 + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", 377 + "cpu": [ 378 + "arm64" 379 + ], 380 + "dev": true, 381 + "license": "MIT", 382 + "optional": true, 383 + "os": [ 384 + "openharmony" 385 + ], 386 + "engines": { 387 + "node": ">=18" 388 + } 389 + }, 390 + "node_modules/@esbuild/sunos-x64": { 391 + "version": "0.27.3", 392 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", 393 + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", 394 + "cpu": [ 395 + "x64" 396 + ], 397 + "dev": true, 398 + "license": "MIT", 399 + "optional": true, 400 + "os": [ 401 + "sunos" 402 + ], 403 + "engines": { 404 + "node": ">=18" 405 + } 406 + }, 407 + "node_modules/@esbuild/win32-arm64": { 408 + "version": "0.27.3", 409 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", 410 + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", 411 + "cpu": [ 412 + "arm64" 413 + ], 414 + "dev": true, 415 + "license": "MIT", 416 + "optional": true, 417 + "os": [ 418 + "win32" 419 + ], 420 + "engines": { 421 + "node": ">=18" 422 + } 423 + }, 424 + "node_modules/@esbuild/win32-ia32": { 425 + "version": "0.27.3", 426 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", 427 + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", 428 + "cpu": [ 429 + "ia32" 430 + ], 431 + "dev": true, 432 + "license": "MIT", 433 + "optional": true, 434 + "os": [ 435 + "win32" 436 + ], 437 + "engines": { 438 + "node": ">=18" 439 + } 440 + }, 441 + "node_modules/@esbuild/win32-x64": { 442 + "version": "0.27.3", 443 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", 444 + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", 445 + "cpu": [ 446 + "x64" 447 + ], 448 + "dev": true, 449 + "license": "MIT", 450 + "optional": true, 451 + "os": [ 452 + "win32" 453 + ], 454 + "engines": { 455 + "node": ">=18" 456 + } 457 + }, 458 + "node_modules/@types/node": { 459 + "version": "22.19.15", 460 + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", 461 + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", 462 + "dev": true, 463 + "license": "MIT", 464 + "dependencies": { 465 + "undici-types": "~6.21.0" 466 + } 467 + }, 468 + "node_modules/esbuild": { 469 + "version": "0.27.3", 470 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", 471 + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", 472 + "dev": true, 473 + "hasInstallScript": true, 474 + "license": "MIT", 475 + "bin": { 476 + "esbuild": "bin/esbuild" 477 + }, 478 + "engines": { 479 + "node": ">=18" 480 + }, 481 + "optionalDependencies": { 482 + "@esbuild/aix-ppc64": "0.27.3", 483 + "@esbuild/android-arm": "0.27.3", 484 + "@esbuild/android-arm64": "0.27.3", 485 + "@esbuild/android-x64": "0.27.3", 486 + "@esbuild/darwin-arm64": "0.27.3", 487 + "@esbuild/darwin-x64": "0.27.3", 488 + "@esbuild/freebsd-arm64": "0.27.3", 489 + "@esbuild/freebsd-x64": "0.27.3", 490 + "@esbuild/linux-arm": "0.27.3", 491 + "@esbuild/linux-arm64": "0.27.3", 492 + "@esbuild/linux-ia32": "0.27.3", 493 + "@esbuild/linux-loong64": "0.27.3", 494 + "@esbuild/linux-mips64el": "0.27.3", 495 + "@esbuild/linux-ppc64": "0.27.3", 496 + "@esbuild/linux-riscv64": "0.27.3", 497 + "@esbuild/linux-s390x": "0.27.3", 498 + "@esbuild/linux-x64": "0.27.3", 499 + "@esbuild/netbsd-arm64": "0.27.3", 500 + "@esbuild/netbsd-x64": "0.27.3", 501 + "@esbuild/openbsd-arm64": "0.27.3", 502 + "@esbuild/openbsd-x64": "0.27.3", 503 + "@esbuild/openharmony-arm64": "0.27.3", 504 + "@esbuild/sunos-x64": "0.27.3", 505 + "@esbuild/win32-arm64": "0.27.3", 506 + "@esbuild/win32-ia32": "0.27.3", 507 + "@esbuild/win32-x64": "0.27.3" 508 + } 509 + }, 510 + "node_modules/fsevents": { 511 + "version": "2.3.3", 512 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 513 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 514 + "dev": true, 515 + "hasInstallScript": true, 516 + "license": "MIT", 517 + "optional": true, 518 + "os": [ 519 + "darwin" 520 + ], 521 + "engines": { 522 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 523 + } 524 + }, 525 + "node_modules/get-tsconfig": { 526 + "version": "4.13.6", 527 + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", 528 + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", 529 + "dev": true, 530 + "license": "MIT", 531 + "dependencies": { 532 + "resolve-pkg-maps": "^1.0.0" 533 + }, 534 + "funding": { 535 + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" 536 + } 537 + }, 538 + "node_modules/resolve-pkg-maps": { 539 + "version": "1.0.0", 540 + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", 541 + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", 542 + "dev": true, 543 + "license": "MIT", 544 + "funding": { 545 + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" 546 + } 547 + }, 548 + "node_modules/tsx": { 549 + "version": "4.21.0", 550 + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", 551 + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", 552 + "dev": true, 553 + "license": "MIT", 554 + "dependencies": { 555 + "esbuild": "~0.27.0", 556 + "get-tsconfig": "^4.7.5" 557 + }, 558 + "bin": { 559 + "tsx": "dist/cli.mjs" 560 + }, 561 + "engines": { 562 + "node": ">=18.0.0" 563 + }, 564 + "optionalDependencies": { 565 + "fsevents": "~2.3.3" 566 + } 567 + }, 568 + "node_modules/typescript": { 569 + "version": "5.9.3", 570 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", 571 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 572 + "dev": true, 573 + "license": "Apache-2.0", 574 + "bin": { 575 + "tsc": "bin/tsc", 576 + "tsserver": "bin/tsserver" 577 + }, 578 + "engines": { 579 + "node": ">=14.17" 580 + } 581 + }, 582 + "node_modules/undici-types": { 583 + "version": "6.21.0", 584 + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", 585 + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", 586 + "dev": true, 587 + "license": "MIT" 588 + } 589 + } 590 + }
+18
package.json
··· 1 + { 2 + "name": "localcode", 3 + "version": "2.0.0", 4 + "type": "module", 5 + "bin": { 6 + "localcode": "dist/main.js" 7 + }, 8 + "scripts": { 9 + "dev": "tsx src/main.ts", 10 + "build": "tsc", 11 + "start": "node dist/main.js" 12 + }, 13 + "devDependencies": { 14 + "@types/node": "^22", 15 + "tsx": "^4", 16 + "typescript": "^5" 17 + } 18 + }
+406
setup.sh
··· 1 + #!/usr/bin/env bash 2 + set -euo pipefail 3 + 4 + # ============================================================================= 5 + # Local AI Coding Environment Setup for macOS Apple Silicon 6 + # llama.cpp + Qwen 2.5 Coder 32B (chat) + Qwen 2.5 Coder 1.5B (autocomplete) 7 + # + Aider (terminal coding agent) or OpenCode 8 + # ============================================================================= 9 + 10 + BOLD="\033[1m" 11 + GREEN="\033[0;32m" 12 + YELLOW="\033[1;33m" 13 + RED="\033[0;31m" 14 + RESET="\033[0m" 15 + 16 + MODELS_DIR="$HOME/.local/share/llama-models" 17 + CHAT_MODEL_URL="https://huggingface.co/Qwen/Qwen2.5-Coder-32B-Instruct-GGUF/resolve/main/qwen2.5-coder-32b-instruct-q4_k_m.gguf" 18 + CHAT_MODEL_FILE="qwen2.5-coder-32b-instruct-q4_k_m.gguf" 19 + AUTOCOMPLETE_MODEL_URL="https://huggingface.co/Qwen/Qwen2.5-Coder-1.5B-Instruct-GGUF/resolve/main/qwen2.5-coder-1.5b-instruct-q4_k_m.gguf" 20 + AUTOCOMPLETE_MODEL_FILE="qwen2.5-coder-1.5b-instruct-q4_k_m.gguf" 21 + 22 + CHAT_PORT=8080 23 + AUTOCOMPLETE_PORT=8081 24 + 25 + AIDER_CONFIG_DIR="$HOME/.aider" 26 + AIDER_CONFIG_FILE="$AIDER_CONFIG_DIR/aider.conf.yml" 27 + 28 + log() { echo -e "${GREEN}${BOLD}[✓]${RESET} $1"; } 29 + warn() { echo -e "${YELLOW}${BOLD}[!]${RESET} $1"; } 30 + err() { echo -e "${RED}${BOLD}[✗]${RESET} $1"; exit 1; } 31 + 32 + # ----------------------------------------------------------------------------- 33 + # Pre-flight checks 34 + # ----------------------------------------------------------------------------- 35 + echo -e "\n${BOLD}🔧 Local AI Coding Environment Installer (llama.cpp)${RESET}\n" 36 + 37 + [[ "$(uname)" == "Darwin" ]] || err "This script is for macOS only." 38 + [[ "$(uname -m)" == "arm64" ]] || warn "Not running on Apple Silicon — performance may vary." 39 + 40 + MEM_GB=$(( $(sysctl -n hw.memsize) / 1073741824 )) 41 + if (( MEM_GB < 32 )); then 42 + warn "You have ${MEM_GB}GB RAM. The 32B model needs ~20GB; you may experience swapping." 43 + fi 44 + 45 + # ----------------------------------------------------------------------------- 46 + # 1. Install Homebrew (if missing) 47 + # ----------------------------------------------------------------------------- 48 + if ! command -v brew &>/dev/null; then 49 + log "Installing Homebrew..." 50 + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" 51 + eval "$(/opt/homebrew/bin/brew shellenv)" 52 + else 53 + log "Homebrew already installed." 54 + fi 55 + 56 + # ----------------------------------------------------------------------------- 57 + # 2. Build / Install llama.cpp 58 + # ----------------------------------------------------------------------------- 59 + if ! command -v llama-server &>/dev/null; then 60 + log "Installing llama.cpp via Homebrew..." 61 + brew install llama.cpp 62 + else 63 + log "llama.cpp already installed." 64 + fi 65 + 66 + # Verify Metal support 67 + if llama-server --help 2>&1 | grep -qi metal; then 68 + log "Metal (GPU) acceleration available." 69 + else 70 + warn "Metal flag not detected — model will run on CPU only." 71 + fi 72 + 73 + # ----------------------------------------------------------------------------- 74 + # 3. Download Qwen GGUF models from HuggingFace 75 + # ----------------------------------------------------------------------------- 76 + mkdir -p "$MODELS_DIR" 77 + 78 + download_model() { 79 + local url="$1" file="$2" 80 + if [ -f "$MODELS_DIR/$file" ]; then 81 + log "Model already downloaded: $file" 82 + else 83 + log "Downloading $file (this may take a while)..." 84 + curl -L --progress-bar -o "$MODELS_DIR/$file" "$url" 85 + log "Downloaded: $file" 86 + fi 87 + } 88 + 89 + download_model "$CHAT_MODEL_URL" "$CHAT_MODEL_FILE" 90 + download_model "$AUTOCOMPLETE_MODEL_URL" "$AUTOCOMPLETE_MODEL_FILE" 91 + 92 + # ----------------------------------------------------------------------------- 93 + # 4. Install Python & Aider 94 + # ----------------------------------------------------------------------------- 95 + if ! command -v jq &>/dev/null; then 96 + log "Installing jq..." 97 + brew install jq 98 + else 99 + log "jq already installed." 100 + fi 101 + 102 + if ! command -v python3 &>/dev/null; then 103 + log "Installing Python 3..." 104 + brew install python@3.12 105 + fi 106 + 107 + if ! command -v pipx &>/dev/null; then 108 + log "Installing pipx..." 109 + brew install pipx 110 + pipx ensurepath 111 + fi 112 + 113 + if ! command -v aider &>/dev/null; then 114 + log "Installing Aider..." 115 + pipx install aider-chat 116 + else 117 + log "Aider already installed. Upgrading..." 118 + pipx upgrade aider-chat 119 + fi 120 + 121 + # ----------------------------------------------------------------------------- 122 + # 5. Create llama.cpp server launcher scripts 123 + # ----------------------------------------------------------------------------- 124 + LAUNCH_DIR="$HOME/.local/bin" 125 + mkdir -p "$LAUNCH_DIR" 126 + 127 + # --- Chat server launcher --- 128 + cat > "$LAUNCH_DIR/llama-chat-server" << SCRIPT 129 + #!/usr/bin/env bash 130 + # Start llama.cpp server with Qwen 2.5 Coder 32B for chat 131 + # Exposed as OpenAI-compatible API on port ${CHAT_PORT} 132 + 133 + MODEL="$MODELS_DIR/$CHAT_MODEL_FILE" 134 + 135 + exec llama-server \\ 136 + --model "\$MODEL" \\ 137 + --port ${CHAT_PORT} \\ 138 + --host 127.0.0.1 \\ 139 + --ctx-size 16384 \\ 140 + --n-gpu-layers 99 \\ 141 + --threads \$(sysctl -n hw.perflevel0.logicalcpu 2>/dev/null || echo 4) \\ 142 + --mlock \\ 143 + "\$@" 144 + SCRIPT 145 + chmod +x "$LAUNCH_DIR/llama-chat-server" 146 + 147 + # --- Autocomplete server launcher --- 148 + cat > "$LAUNCH_DIR/llama-complete-server" << SCRIPT 149 + #!/usr/bin/env bash 150 + # Start llama.cpp server with Qwen 2.5 Coder 1.5B for autocomplete 151 + # Exposed as OpenAI-compatible API on port ${AUTOCOMPLETE_PORT} 152 + 153 + MODEL="$MODELS_DIR/$AUTOCOMPLETE_MODEL_FILE" 154 + 155 + exec llama-server \\ 156 + --model "\$MODEL" \\ 157 + --port ${AUTOCOMPLETE_PORT} \\ 158 + --host 127.0.0.1 \\ 159 + --ctx-size 4096 \\ 160 + --n-gpu-layers 99 \\ 161 + --threads \$(sysctl -n hw.perflevel0.logicalcpu 2>/dev/null || echo 4) \\ 162 + --mlock \\ 163 + "\$@" 164 + SCRIPT 165 + chmod +x "$LAUNCH_DIR/llama-complete-server" 166 + 167 + # --- Combined server manager --- 168 + cat > "$LAUNCH_DIR/llama-start" << 'SCRIPT' 169 + #!/usr/bin/env bash 170 + # Start both llama.cpp servers (chat + autocomplete) 171 + 172 + BOLD="\033[1m"; GREEN="\033[0;32m"; RED="\033[0;31m"; RESET="\033[0m" 173 + CHAT_PID="" COMPLETE_PID="" 174 + 175 + cleanup() { 176 + echo -e "\n${RED}Shutting down servers...${RESET}" 177 + [ -n "$CHAT_PID" ] && kill "$CHAT_PID" 2>/dev/null 178 + [ -n "$COMPLETE_PID" ] && kill "$COMPLETE_PID" 2>/dev/null 179 + wait 2>/dev/null 180 + echo -e "${GREEN}Done.${RESET}" 181 + exit 0 182 + } 183 + trap cleanup SIGINT SIGTERM 184 + 185 + echo -e "${BOLD}Starting llama.cpp servers...${RESET}\n" 186 + 187 + echo -e "${GREEN}[1/2]${RESET} Chat model (32B) on :8080..." 188 + llama-chat-server &>/tmp/llama-chat.log & 189 + CHAT_PID=$! 190 + 191 + echo -e "${GREEN}[2/2]${RESET} Autocomplete model (1.5B) on :8081..." 192 + llama-complete-server &>/tmp/llama-complete.log & 193 + COMPLETE_PID=$! 194 + 195 + # Wait for servers to be ready 196 + echo -ne "\nWaiting for servers..." 197 + for i in $(seq 1 60); do 198 + CHAT_OK=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/health 2>/dev/null || true) 199 + COMP_OK=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8081/health 2>/dev/null || true) 200 + if [[ "$CHAT_OK" == "200" && "$COMP_OK" == "200" ]]; then 201 + echo -e " ${GREEN}ready!${RESET}" 202 + break 203 + fi 204 + echo -n "." 205 + sleep 2 206 + done 207 + 208 + echo "" 209 + echo -e "${BOLD}Servers running:${RESET}" 210 + echo -e " Chat (32B): http://127.0.0.1:8080" 211 + echo -e " Autocomplete (1.5B): http://127.0.0.1:8081" 212 + echo -e " Logs: /tmp/llama-chat.log, /tmp/llama-complete.log" 213 + echo -e "\n Press Ctrl+C to stop both servers.\n" 214 + 215 + wait 216 + SCRIPT 217 + chmod +x "$LAUNCH_DIR/llama-start" 218 + 219 + # --- Stop servers --- 220 + cat > "$LAUNCH_DIR/llama-stop" << 'SCRIPT' 221 + #!/usr/bin/env bash 222 + # Stop all running llama-server processes 223 + pkill -f "llama-server" 2>/dev/null && echo "Servers stopped." || echo "No servers running." 224 + SCRIPT 225 + chmod +x "$LAUNCH_DIR/llama-stop" 226 + 227 + # ----------------------------------------------------------------------------- 228 + # 6. Configure Aider to use llama.cpp OpenAI-compatible API 229 + # ----------------------------------------------------------------------------- 230 + mkdir -p "$AIDER_CONFIG_DIR" 231 + 232 + cat > "$AIDER_CONFIG_FILE" << 'EOF' 233 + # ============================================================================= 234 + # Aider Configuration — Qwen 2.5 Coder via llama.cpp 235 + # ============================================================================= 236 + 237 + # Point Aider at llama.cpp's OpenAI-compatible endpoint 238 + # The model name can be anything — llama.cpp ignores it and uses the loaded model 239 + model: openai/qwen2.5-coder-32b 240 + 241 + # Architect mode for better code planning 242 + architect: true 243 + editor-model: openai/qwen2.5-coder-32b 244 + 245 + # Git integration 246 + auto-commits: true 247 + dirty-commits: true 248 + attribute-author: false 249 + attribute-committer: false 250 + 251 + # UI preferences 252 + pretty: true 253 + stream: true 254 + dark-mode: true 255 + 256 + # Code style 257 + code-theme: monokai 258 + show-diffs: true 259 + 260 + # Disable analytics 261 + analytics-disable: true 262 + EOF 263 + 264 + # Environment file for API base URL 265 + cat > "$AIDER_CONFIG_DIR/.env" << 'EOF' 266 + # llama.cpp serves an OpenAI-compatible API — no real key needed 267 + OPENAI_API_KEY=sk-not-needed 268 + OPENAI_API_BASE=http://127.0.0.1:8080/v1 269 + EOF 270 + 271 + log "Aider config written to ${AIDER_CONFIG_FILE}" 272 + log "Aider env written to ${AIDER_CONFIG_DIR}/.env" 273 + 274 + # ----------------------------------------------------------------------------- 275 + # 7. Create main launcher: ai-code 276 + # ----------------------------------------------------------------------------- 277 + cat > "$LAUNCH_DIR/ai-code" << 'SCRIPT' 278 + #!/usr/bin/env bash 279 + # Launch Aider with local Qwen 2.5 Coder 32B via llama.cpp 280 + # Usage: ai-code [directory] [aider flags...] 281 + # 282 + # Starts llama.cpp servers automatically if not already running. 283 + 284 + BOLD="\033[1m"; GREEN="\033[0;32m"; RESET="\033[0m" 285 + 286 + # Check if chat server is running 287 + if ! curl -s http://127.0.0.1:8080/health &>/dev/null; then 288 + echo -e "${BOLD}Starting llama.cpp chat server...${RESET}" 289 + llama-chat-server &>/tmp/llama-chat.log & 290 + echo -n "Waiting for model to load" 291 + for i in $(seq 1 120); do 292 + if curl -s http://127.0.0.1:8080/health &>/dev/null; then 293 + echo -e " ${GREEN}ready!${RESET}" 294 + break 295 + fi 296 + echo -n "." 297 + sleep 2 298 + done 299 + fi 300 + 301 + DIR="${1:-.}" 302 + shift 2>/dev/null || true 303 + cd "$DIR" || exit 1 304 + 305 + # Initialize git repo if needed 306 + if [ ! -d .git ]; then 307 + echo "Initializing git repo..." 308 + git init 309 + git add -A 310 + git commit -m "Initial commit (before AI edits)" --allow-empty 311 + fi 312 + 313 + # Source the env file for API config 314 + export $(grep -v '^#' "$HOME/.aider/.env" | xargs) 315 + 316 + exec aider "$@" 317 + SCRIPT 318 + chmod +x "$LAUNCH_DIR/ai-code" 319 + 320 + # Quick question mode 321 + cat > "$LAUNCH_DIR/ai-ask" << 'SCRIPT' 322 + #!/usr/bin/env bash 323 + # Quick coding Q&A — no file editing 324 + if ! curl -s http://127.0.0.1:8080/health &>/dev/null; then 325 + llama-chat-server &>/tmp/llama-chat.log & 326 + sleep 5 327 + fi 328 + export $(grep -v '^#' "$HOME/.aider/.env" | xargs) 329 + if [ -n "$1" ]; then 330 + exec aider --no-auto-commits --message "$*" 331 + else 332 + exec aider --no-auto-commits 333 + fi 334 + SCRIPT 335 + chmod +x "$LAUNCH_DIR/ai-ask" 336 + 337 + # Pipe mode using llama.cpp CLI directly 338 + cat > "$LAUNCH_DIR/ai-pipe" << SCRIPT 339 + #!/usr/bin/env bash 340 + # Pipe code through llama.cpp 341 + # Usage: cat main.py | ai-pipe "add error handling" 342 + 343 + PROMPT="\${1:-Improve this code}" 344 + INPUT=\$(cat) 345 + 346 + curl -s http://127.0.0.1:8080/v1/chat/completions \\ 347 + -H "Content-Type: application/json" \\ 348 + -d "\$(jq -n --arg p "\$PROMPT" --arg c "\$INPUT" '{ 349 + model: "qwen", 350 + messages: [ 351 + {role: "system", content: "You are an expert programmer. Output only code, no explanations."}, 352 + {role: "user", content: ("\$p\n\n```\n" + \$c + "\n```")} 353 + ], 354 + stream: false 355 + }')" | jq -r '.choices[0].message.content' 356 + SCRIPT 357 + chmod +x "$LAUNCH_DIR/ai-pipe" 358 + 359 + # ----------------------------------------------------------------------------- 360 + # 8. Shell integration 361 + # ----------------------------------------------------------------------------- 362 + SHELL_RC="" 363 + case "$SHELL" in 364 + */zsh) SHELL_RC="$HOME/.zshrc" ;; 365 + */bash) SHELL_RC="$HOME/.bashrc" ;; 366 + *) SHELL_RC="$HOME/.profile" ;; 367 + esac 368 + 369 + if ! grep -q '.local/bin' "$SHELL_RC" 2>/dev/null; then 370 + echo '' >> "$SHELL_RC" 371 + echo '# Local AI coding tools' >> "$SHELL_RC" 372 + echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$SHELL_RC" 373 + log "Added ~/.local/bin to PATH in ${SHELL_RC}" 374 + fi 375 + 376 + # ----------------------------------------------------------------------------- 377 + # Done! 378 + # ----------------------------------------------------------------------------- 379 + echo "" 380 + echo -e "${GREEN}${BOLD}═══════════════════════════════════════════════════${RESET}" 381 + echo -e "${GREEN}${BOLD} ✅ Setup complete!${RESET}" 382 + echo -e "${GREEN}${BOLD}═══════════════════════════════════════════════════${RESET}" 383 + echo "" 384 + echo -e " ${BOLD}Models downloaded to:${RESET} ${MODELS_DIR}" 385 + echo -e " Chat: ${CHAT_MODEL_FILE} (~20GB)" 386 + echo -e " Autocomplete: ${AUTOCOMPLETE_MODEL_FILE} (~1.2GB)" 387 + echo "" 388 + echo -e " ${BOLD}Commands available${RESET} (restart your shell first):" 389 + echo "" 390 + echo -e " ${BOLD}llama-start${RESET} Start both llama.cpp servers" 391 + echo -e " ${BOLD}llama-stop${RESET} Stop all llama.cpp servers" 392 + echo "" 393 + echo -e " ${BOLD}ai-code${RESET} [dir] Full coding agent (auto-starts server)" 394 + echo -e " cd into a project and run 'ai-code .'" 395 + echo "" 396 + echo -e " ${BOLD}ai-ask${RESET} \"question\" Quick coding Q&A, no file edits" 397 + echo "" 398 + echo -e " ${BOLD}ai-pipe${RESET} \"prompt\" Pipe code through the model" 399 + echo -e " cat file.py | ai-pipe \"add types\"" 400 + echo "" 401 + echo -e " ${BOLD}Config:${RESET} ${AIDER_CONFIG_FILE}" 402 + echo -e " ${BOLD}API env:${RESET} ${AIDER_CONFIG_DIR}/.env" 403 + echo -e " ${BOLD}Server logs:${RESET} /tmp/llama-chat.log, /tmp/llama-complete.log" 404 + echo "" 405 + echo -e " Run ${BOLD}source ${SHELL_RC}${RESET} or open a new terminal to get started." 406 + echo ""
+275
src/commands/bench.ts
··· 1 + import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; 2 + import { join, dirname } from "node:path"; 3 + import { homedir } from "node:os"; 4 + import { performance } from "node:perf_hooks"; 5 + import { CHAT_PORT } from "../config.js"; 6 + import { getActiveChatModel } from "../runtime-config.js"; 7 + import { log, warn, err } from "../log.js"; 8 + import type { ModelDef } from "../registry/models.js"; 9 + 10 + const BOLD = "\x1b[1m"; 11 + const DIM = "\x1b[2m"; 12 + const RESET = "\x1b[0m"; 13 + 14 + const BENCHMARKS_PATH = join( 15 + homedir(), 16 + ".config", 17 + "localcode", 18 + "benchmarks.json", 19 + ); 20 + 21 + interface BenchPrompt { 22 + label: string; 23 + system: string; 24 + user: string; 25 + } 26 + 27 + const PROMPTS: BenchPrompt[] = [ 28 + { 29 + label: "fizzbuzz", 30 + system: "You are an expert programmer.", 31 + user: "Write a fizzbuzz function in Python.", 32 + }, 33 + { 34 + label: "BST class", 35 + system: "You are an expert programmer.", 36 + user: "Write a binary search tree implementation in TypeScript with insert, delete, and search methods.", 37 + }, 38 + { 39 + label: "code review", 40 + system: "You are an expert code reviewer.", 41 + user: `Review this code and suggest improvements: 42 + 43 + function processData(data) { 44 + var result = []; 45 + for (var i = 0; i < data.length; i++) { 46 + if (data[i].active == true) { 47 + var item = {}; 48 + item.name = data[i].firstName + " " + data[i].lastName; 49 + item.email = data[i].email; 50 + item.score = data[i].points / data[i].maxPoints * 100; 51 + if (item.score >= 90) { 52 + item.grade = "A"; 53 + } else if (item.score >= 80) { 54 + item.grade = "B"; 55 + } else if (item.score >= 70) { 56 + item.grade = "C"; 57 + } else if (item.score >= 60) { 58 + item.grade = "D"; 59 + } else { 60 + item.grade = "F"; 61 + } 62 + result.push(item); 63 + } 64 + } 65 + result.sort(function(a, b) { return b.score - a.score; }); 66 + return result; 67 + }`, 68 + }, 69 + ]; 70 + 71 + interface PromptResult { 72 + label: string; 73 + promptTokens: number; 74 + completionTokens: number; 75 + elapsedMs: number; 76 + tokensPerSec: number; 77 + } 78 + 79 + interface BenchmarkEntry { 80 + timestamp: string; 81 + model: string; 82 + modelName: string; 83 + results: PromptResult[]; 84 + avgTokPerSec: number; 85 + } 86 + 87 + async function checkHealth(port: number): Promise<boolean> { 88 + try { 89 + const res = await fetch(`http://127.0.0.1:${port}/health`); 90 + return res.ok; 91 + } catch { 92 + return false; 93 + } 94 + } 95 + 96 + async function runPrompt( 97 + port: number, 98 + prompt: BenchPrompt, 99 + ): Promise<PromptResult> { 100 + const body = JSON.stringify({ 101 + model: "qwen", 102 + messages: [ 103 + { role: "system", content: prompt.system }, 104 + { role: "user", content: prompt.user }, 105 + ], 106 + stream: false, 107 + }); 108 + 109 + const start = performance.now(); 110 + const res = await fetch( 111 + `http://127.0.0.1:${port}/v1/chat/completions`, 112 + { 113 + method: "POST", 114 + headers: { "Content-Type": "application/json" }, 115 + body, 116 + }, 117 + ); 118 + 119 + if (!res.ok) { 120 + const text = await res.text(); 121 + throw new Error(`Server returned ${res.status}: ${text}`); 122 + } 123 + 124 + const elapsed = performance.now() - start; 125 + const data = (await res.json()) as { 126 + usage?: { prompt_tokens?: number; completion_tokens?: number }; 127 + }; 128 + 129 + const promptTokens = data.usage?.prompt_tokens ?? 0; 130 + const completionTokens = data.usage?.completion_tokens ?? 0; 131 + const tokPerSec = 132 + completionTokens > 0 ? completionTokens / (elapsed / 1000) : 0; 133 + 134 + return { 135 + label: prompt.label, 136 + promptTokens, 137 + completionTokens, 138 + elapsedMs: elapsed, 139 + tokensPerSec: tokPerSec, 140 + }; 141 + } 142 + 143 + function saveBenchmark(entry: BenchmarkEntry): void { 144 + let history: BenchmarkEntry[] = []; 145 + try { 146 + history = JSON.parse( 147 + readFileSync(BENCHMARKS_PATH, "utf-8"), 148 + ) as BenchmarkEntry[]; 149 + } catch { 150 + // No existing file 151 + } 152 + history.push(entry); 153 + mkdirSync(dirname(BENCHMARKS_PATH), { recursive: true }); 154 + writeFileSync(BENCHMARKS_PATH, JSON.stringify(history, null, 2) + "\n"); 155 + } 156 + 157 + function printResults(model: ModelDef, results: PromptResult[]): void { 158 + console.log(""); 159 + console.log(`${BOLD}Model:${RESET} ${model.name} (${model.file})`); 160 + console.log(`${BOLD}Port:${RESET} ${CHAT_PORT}`); 161 + console.log(""); 162 + 163 + // Table header 164 + const hdr = [ 165 + "Prompt".padEnd(16), 166 + "Prompt Tok".padStart(10), 167 + "Compl Tok".padStart(10), 168 + "Time (s)".padStart(10), 169 + "Tok/s".padStart(8), 170 + ].join(" "); 171 + console.log(` ${BOLD}${hdr}${RESET}`); 172 + console.log(` ${"─".repeat(hdr.length)}`); 173 + 174 + for (const r of results) { 175 + const row = [ 176 + r.label.padEnd(16), 177 + String(r.promptTokens).padStart(10), 178 + String(r.completionTokens).padStart(10), 179 + (r.elapsedMs / 1000).toFixed(1).padStart(10), 180 + r.tokensPerSec.toFixed(1).padStart(8), 181 + ].join(" "); 182 + console.log(` ${row}`); 183 + } 184 + 185 + const avgTokSec = 186 + results.reduce((s, r) => s + r.tokensPerSec, 0) / results.length; 187 + console.log(""); 188 + console.log(` ${BOLD}Average: ${avgTokSec.toFixed(1)} tok/s${RESET}`); 189 + console.log(""); 190 + } 191 + 192 + function printHistory(): void { 193 + let history: BenchmarkEntry[] = []; 194 + try { 195 + history = JSON.parse( 196 + readFileSync(BENCHMARKS_PATH, "utf-8"), 197 + ) as BenchmarkEntry[]; 198 + } catch { 199 + console.log("No benchmark history found."); 200 + return; 201 + } 202 + 203 + if (history.length === 0) { 204 + console.log("No benchmark history found."); 205 + return; 206 + } 207 + 208 + console.log(`\n${BOLD}Benchmark History:${RESET}\n`); 209 + const hdr = [ 210 + "Date".padEnd(20), 211 + "Model".padEnd(24), 212 + "Avg Tok/s".padStart(10), 213 + ].join(" "); 214 + console.log(` ${BOLD}${hdr}${RESET}`); 215 + console.log(` ${"─".repeat(hdr.length)}`); 216 + 217 + for (const entry of history) { 218 + const date = entry.timestamp.replace("T", " ").slice(0, 19); 219 + const row = [ 220 + date.padEnd(20), 221 + entry.modelName.padEnd(24), 222 + entry.avgTokPerSec.toFixed(1).padStart(10), 223 + ].join(" "); 224 + console.log(` ${row}`); 225 + } 226 + console.log(""); 227 + } 228 + 229 + export async function runBench(args: string[]): Promise<void> { 230 + if (args.includes("--history")) { 231 + printHistory(); 232 + return; 233 + } 234 + 235 + const healthy = await checkHealth(CHAT_PORT); 236 + if (!healthy) { 237 + err( 238 + `Chat server not running on port ${CHAT_PORT}.\nStart it with: llama-start`, 239 + ); 240 + } 241 + 242 + const model = getActiveChatModel(); 243 + log(`Benchmarking ${model.name} on port ${CHAT_PORT}...`); 244 + console.log(`${DIM}Running ${PROMPTS.length} prompts (this may take a minute)...${RESET}`); 245 + 246 + const results: PromptResult[] = []; 247 + for (const prompt of PROMPTS) { 248 + process.stdout.write(` ${prompt.label}...`); 249 + try { 250 + const result = await runPrompt(CHAT_PORT, prompt); 251 + results.push(result); 252 + console.log(` ${result.tokensPerSec.toFixed(1)} tok/s`); 253 + } catch (e) { 254 + console.log(` FAILED: ${e instanceof Error ? e.message : e}`); 255 + } 256 + } 257 + 258 + if (results.length === 0) { 259 + err("All prompts failed."); 260 + } 261 + 262 + printResults(model, results); 263 + 264 + // Save to history 265 + const avgTokPerSec = 266 + results.reduce((s, r) => s + r.tokensPerSec, 0) / results.length; 267 + saveBenchmark({ 268 + timestamp: new Date().toISOString(), 269 + model: model.id, 270 + modelName: model.name, 271 + results, 272 + avgTokPerSec, 273 + }); 274 + log(`Results saved to ${BENCHMARKS_PATH}`); 275 + }
+102
src/commands/models.ts
··· 1 + import { existsSync } from "node:fs"; 2 + import { join } from "node:path"; 3 + import { MODELS, getChatModels, getAutocompleteModels, getModelById } from "../registry/models.js"; 4 + import { loadConfig, saveConfig, getActiveChatModel, getActiveAutocompleteModel } from "../runtime-config.js"; 5 + import { createLauncherScripts } from "../steps/scripts.js"; 6 + import { writeTuiConfig } from "../steps/aider-config.js"; 7 + import { MODELS_DIR } from "../config.js"; 8 + import { log, err } from "../log.js"; 9 + import { runPassthrough } from "../util.js"; 10 + import { mkdirSync } from "node:fs"; 11 + 12 + const BOLD = "\x1b[1m"; 13 + const GREEN = "\x1b[0;32m"; 14 + const DIM = "\x1b[2m"; 15 + const RESET = "\x1b[0m"; 16 + 17 + function isDownloaded(file: string): boolean { 18 + return existsSync(join(MODELS_DIR, file)); 19 + } 20 + 21 + export function listModels(): void { 22 + const activeChatId = getActiveChatModel().id; 23 + const activeAutoId = getActiveAutocompleteModel().id; 24 + 25 + console.log(`\n${BOLD}Chat models:${RESET}`); 26 + for (const m of getChatModels()) { 27 + const active = m.id === activeChatId ? ` ${GREEN}<- active${RESET}` : ""; 28 + const downloaded = isDownloaded(m.file) ? "" : ` ${DIM}(not downloaded)${RESET}`; 29 + console.log( 30 + ` ${BOLD}${m.id}${RESET} ${m.name} ~${m.sizeGB}GB min ${m.minRAMGB}GB RAM${active}${downloaded}`, 31 + ); 32 + } 33 + 34 + console.log(`\n${BOLD}Autocomplete models:${RESET}`); 35 + for (const m of getAutocompleteModels()) { 36 + const active = m.id === activeAutoId ? ` ${GREEN}<- active${RESET}` : ""; 37 + const downloaded = isDownloaded(m.file) ? "" : ` ${DIM}(not downloaded)${RESET}`; 38 + console.log( 39 + ` ${BOLD}${m.id}${RESET} ${m.name} ~${m.sizeGB}GB min ${m.minRAMGB}GB RAM${active}${downloaded}`, 40 + ); 41 + } 42 + console.log(""); 43 + } 44 + 45 + export async function setChatModel(id: string): Promise<void> { 46 + const model = getModelById(id); 47 + if (!model) { 48 + err(`Unknown model: ${id}\nAvailable: ${MODELS.map((m) => m.id).join(", ")}`); 49 + } 50 + if (model.role !== "chat") { 51 + err(`${id} is an ${model.role} model, not a chat model.`); 52 + } 53 + 54 + const config = loadConfig(); 55 + config.chatModel = id; 56 + saveConfig(config); 57 + log(`Chat model set to ${model.name}`); 58 + 59 + // Download if needed 60 + const dest = join(MODELS_DIR, model.file); 61 + if (!existsSync(dest)) { 62 + mkdirSync(MODELS_DIR, { recursive: true }); 63 + log(`Downloading ${model.name} (${model.sizeGB}GB)...`); 64 + runPassthrough(`curl -L --progress-bar -o "${dest}" "${model.url}"`); 65 + log(`Downloaded: ${model.file}`); 66 + } 67 + 68 + // Regenerate scripts and configs 69 + await createLauncherScripts(); 70 + await writeTuiConfig(); 71 + log("Launcher scripts and configs regenerated."); 72 + log("Run llama-stop && llama-start to use the new model."); 73 + } 74 + 75 + export async function setAutocompleteModel(id: string): Promise<void> { 76 + const model = getModelById(id); 77 + if (!model) { 78 + err(`Unknown model: ${id}\nAvailable: ${MODELS.map((m) => m.id).join(", ")}`); 79 + } 80 + if (model.role !== "autocomplete") { 81 + err(`${id} is a ${model.role} model, not an autocomplete model.`); 82 + } 83 + 84 + const config = loadConfig(); 85 + config.autocompleteModel = id; 86 + saveConfig(config); 87 + log(`Autocomplete model set to ${model.name}`); 88 + 89 + // Download if needed 90 + const dest = join(MODELS_DIR, model.file); 91 + if (!existsSync(dest)) { 92 + mkdirSync(MODELS_DIR, { recursive: true }); 93 + log(`Downloading ${model.name} (${model.sizeGB}GB)...`); 94 + runPassthrough(`curl -L --progress-bar -o "${dest}" "${model.url}"`); 95 + log(`Downloaded: ${model.file}`); 96 + } 97 + 98 + await createLauncherScripts(); 99 + await writeTuiConfig(); 100 + log("Launcher scripts and configs regenerated."); 101 + log("Run llama-stop && llama-start to use the new model."); 102 + }
+48
src/commands/pipe.ts
··· 1 + import { CHAT_PORT } from "../config.js"; 2 + import { err } from "../log.js"; 3 + 4 + export async function runPipe(prompt: string): Promise<void> { 5 + // Read stdin 6 + const chunks: Buffer[] = []; 7 + for await (const chunk of process.stdin) { 8 + chunks.push(chunk as Buffer); 9 + } 10 + const input = Buffer.concat(chunks).toString("utf-8"); 11 + 12 + const body = JSON.stringify({ 13 + model: "qwen", 14 + messages: [ 15 + { 16 + role: "system", 17 + content: 18 + "You are an expert programmer. Output only code, no explanations.", 19 + }, 20 + { role: "user", content: `${prompt}\n\n\`\`\`\n${input}\n\`\`\`` }, 21 + ], 22 + stream: false, 23 + }); 24 + 25 + let res: Response; 26 + try { 27 + res = await fetch( 28 + `http://127.0.0.1:${CHAT_PORT}/v1/chat/completions`, 29 + { 30 + method: "POST", 31 + headers: { "Content-Type": "application/json" }, 32 + body, 33 + }, 34 + ); 35 + } catch { 36 + err(`Chat server not running on port ${CHAT_PORT}.\nStart it with: localcode start`); 37 + } 38 + 39 + if (!res!.ok) { 40 + err(`Server returned ${res!.status}`); 41 + } 42 + 43 + const data = (await res!.json()) as { 44 + choices?: { message?: { content?: string } }[]; 45 + }; 46 + const content = data.choices?.[0]?.message?.content ?? ""; 47 + process.stdout.write(content + "\n"); 48 + }
+90
src/commands/run.ts
··· 1 + import { spawn, execSync } from "node:child_process"; 2 + import { existsSync } from "node:fs"; 3 + import { join } from "node:path"; 4 + import { CHAT_PORT, LAUNCH_DIR, AIDER_ENV_FILE } from "../config.js"; 5 + import { getActiveTui } from "../runtime-config.js"; 6 + import { log, warn } from "../log.js"; 7 + 8 + async function waitForHealth( 9 + port: number, 10 + timeoutSec: number, 11 + ): Promise<boolean> { 12 + for (let i = 0; i < timeoutSec; i++) { 13 + try { 14 + const res = await fetch(`http://127.0.0.1:${port}/health`); 15 + if (res.ok) return true; 16 + } catch { 17 + // not ready yet 18 + } 19 + await new Promise((r) => setTimeout(r, 2000)); 20 + process.stdout.write("."); 21 + } 22 + return false; 23 + } 24 + 25 + async function ensureServer(): Promise<void> { 26 + try { 27 + const res = await fetch(`http://127.0.0.1:${CHAT_PORT}/health`); 28 + if (res.ok) return; 29 + } catch { 30 + // not running 31 + } 32 + 33 + const serverScript = join(LAUNCH_DIR, "llama-chat-server"); 34 + if (!existsSync(serverScript)) { 35 + warn("Server script not found. Run: localcode setup"); 36 + process.exit(1); 37 + } 38 + 39 + log("Starting llama.cpp chat server..."); 40 + const child = spawn(serverScript, [], { 41 + stdio: "ignore", 42 + detached: true, 43 + }); 44 + child.unref(); 45 + 46 + process.stdout.write("Waiting for model to load"); 47 + const ready = await waitForHealth(CHAT_PORT, 120); 48 + if (ready) { 49 + console.log(" ready!"); 50 + } else { 51 + console.log(" timed out."); 52 + warn("Server may still be loading. Check /tmp/llama-chat.log"); 53 + } 54 + } 55 + 56 + function ensureGit(): void { 57 + if (!existsSync(".git")) { 58 + log("Initializing git repo..."); 59 + execSync("git init", { stdio: "inherit" }); 60 + execSync("git add -A", { stdio: "inherit" }); 61 + execSync('git commit -m "Initial commit (before AI edits)" --allow-empty', { 62 + stdio: "inherit", 63 + }); 64 + } 65 + } 66 + 67 + export async function runDefault(args: string[]): Promise<void> { 68 + await ensureServer(); 69 + ensureGit(); 70 + 71 + const tui = getActiveTui(); 72 + 73 + // Set up env for aider 74 + if (tui.id === "aider" && existsSync(AIDER_ENV_FILE)) { 75 + const { readFileSync } = await import("node:fs"); 76 + const env = readFileSync(AIDER_ENV_FILE, "utf-8"); 77 + for (const line of env.split("\n")) { 78 + if (line && !line.startsWith("#")) { 79 + const eq = line.indexOf("="); 80 + if (eq > 0) { 81 + process.env[line.slice(0, eq)] = line.slice(eq + 1); 82 + } 83 + } 84 + } 85 + } 86 + 87 + log(`Launching ${tui.name}...`); 88 + const child = spawn(tui.checkCmd, args, { stdio: "inherit" }); 89 + child.on("exit", (code) => process.exit(code ?? 0)); 90 + }
+96
src/commands/server.ts
··· 1 + import { spawn, execSync } from "node:child_process"; 2 + import { existsSync } from "node:fs"; 3 + import { join } from "node:path"; 4 + import { CHAT_PORT, AUTOCOMPLETE_PORT, LAUNCH_DIR } from "../config.js"; 5 + import { 6 + getActiveChatModel, 7 + getActiveAutocompleteModel, 8 + } from "../runtime-config.js"; 9 + import { log, warn, err } from "../log.js"; 10 + 11 + async function waitForHealth(port: number, label: string): Promise<boolean> { 12 + process.stdout.write(` Waiting for ${label}`); 13 + for (let i = 0; i < 60; i++) { 14 + try { 15 + const res = await fetch(`http://127.0.0.1:${port}/health`); 16 + if (res.ok) { 17 + console.log(" ready!"); 18 + return true; 19 + } 20 + } catch { 21 + // not ready 22 + } 23 + process.stdout.write("."); 24 + await new Promise((r) => setTimeout(r, 2000)); 25 + } 26 + console.log(" timed out."); 27 + return false; 28 + } 29 + 30 + export async function startServers(): Promise<void> { 31 + const chatModel = getActiveChatModel(); 32 + const autoModel = getActiveAutocompleteModel(); 33 + 34 + const chatScript = join(LAUNCH_DIR, "llama-chat-server"); 35 + const autoScript = join(LAUNCH_DIR, "llama-complete-server"); 36 + 37 + if (!existsSync(chatScript)) { 38 + err("Server scripts not found. Run: localcode setup"); 39 + } 40 + 41 + log(`Starting servers...`); 42 + 43 + // Chat server 44 + let chatAlready = false; 45 + try { 46 + const res = await fetch(`http://127.0.0.1:${CHAT_PORT}/health`); 47 + chatAlready = res.ok; 48 + } catch { 49 + // not running 50 + } 51 + 52 + if (chatAlready) { 53 + log(`Chat server already running on :${CHAT_PORT}`); 54 + } else { 55 + log(`Chat: ${chatModel.name} on :${CHAT_PORT}`); 56 + const c = spawn(chatScript, [], { 57 + stdio: ["ignore", "ignore", "ignore"], 58 + detached: true, 59 + }); 60 + c.unref(); 61 + await waitForHealth(CHAT_PORT, "chat model"); 62 + } 63 + 64 + // Autocomplete server 65 + let autoAlready = false; 66 + try { 67 + const res = await fetch(`http://127.0.0.1:${AUTOCOMPLETE_PORT}/health`); 68 + autoAlready = res.ok; 69 + } catch { 70 + // not running 71 + } 72 + 73 + if (autoAlready) { 74 + log(`Autocomplete server already running on :${AUTOCOMPLETE_PORT}`); 75 + } else { 76 + log(`Autocomplete: ${autoModel.name} on :${AUTOCOMPLETE_PORT}`); 77 + const c = spawn(autoScript, [], { 78 + stdio: ["ignore", "ignore", "ignore"], 79 + detached: true, 80 + }); 81 + c.unref(); 82 + await waitForHealth(AUTOCOMPLETE_PORT, "autocomplete model"); 83 + } 84 + 85 + console.log(""); 86 + log("Servers running. Logs: /tmp/llama-chat.log, /tmp/llama-complete.log"); 87 + } 88 + 89 + export function stopServers(): void { 90 + try { 91 + execSync('pkill -f "llama-server"', { stdio: "ignore" }); 92 + log("Servers stopped."); 93 + } catch { 94 + warn("No servers running."); 95 + } 96 + }
+91
src/commands/setup.ts
··· 1 + import { checkPreflight } from "../steps/preflight.js"; 2 + import { installHomebrew } from "../steps/homebrew.js"; 3 + import { installLlama } from "../steps/llama.js"; 4 + import { downloadModels } from "../steps/models.js"; 5 + import { installTools } from "../steps/tools.js"; 6 + import { createLauncherScripts } from "../steps/scripts.js"; 7 + import { writeTuiConfig } from "../steps/aider-config.js"; 8 + import { addToPath } from "../steps/shell-path.js"; 9 + import { MODELS_DIR, AIDER_CONFIG_FILE, AIDER_CONFIG_DIR } from "../config.js"; 10 + import { 11 + getActiveChatModel, 12 + getActiveAutocompleteModel, 13 + getActiveTui, 14 + } from "../runtime-config.js"; 15 + 16 + const BOLD = "\x1b[1m"; 17 + const GREEN = "\x1b[0;32m"; 18 + const RESET = "\x1b[0m"; 19 + 20 + function printSummary(): void { 21 + const chatModel = getActiveChatModel(); 22 + const autocompleteModel = getActiveAutocompleteModel(); 23 + const tui = getActiveTui(); 24 + const shellRC = 25 + process.env.SHELL?.endsWith("/zsh") !== false 26 + ? "~/.zshrc" 27 + : process.env.SHELL?.endsWith("/bash") 28 + ? "~/.bashrc" 29 + : "~/.profile"; 30 + 31 + console.log(""); 32 + console.log( 33 + `${GREEN}${BOLD}═══════════════════════════════════════════════════${RESET}`, 34 + ); 35 + console.log(`${GREEN}${BOLD} Setup complete!${RESET}`); 36 + console.log( 37 + `${GREEN}${BOLD}═══════════════════════════════════════════════════${RESET}`, 38 + ); 39 + console.log(""); 40 + console.log(` ${BOLD}Models downloaded to:${RESET} ${MODELS_DIR}`); 41 + console.log( 42 + ` Chat: ${chatModel.name} (${chatModel.file}, ~${chatModel.sizeGB}GB)`, 43 + ); 44 + console.log( 45 + ` Autocomplete: ${autocompleteModel.name} (${autocompleteModel.file}, ~${autocompleteModel.sizeGB}GB)`, 46 + ); 47 + console.log(""); 48 + console.log(` ${BOLD}Active TUI:${RESET} ${tui.name}`); 49 + console.log(""); 50 + console.log(` ${BOLD}Commands available${RESET} (restart your shell first):`); 51 + console.log(""); 52 + console.log(` ${BOLD}llama-start${RESET} Start both llama.cpp servers`); 53 + console.log(` ${BOLD}llama-stop${RESET} Stop all llama.cpp servers`); 54 + console.log(""); 55 + console.log( 56 + ` ${BOLD}ai-code${RESET} [dir] Full coding agent (auto-starts server)`, 57 + ); 58 + console.log( 59 + ` ${BOLD}ai-ask${RESET} "question" Quick coding Q&A, no file edits`, 60 + ); 61 + console.log( 62 + ` ${BOLD}ai-pipe${RESET} "prompt" Pipe code through the model`, 63 + ); 64 + console.log(""); 65 + console.log(` ${BOLD}Config:${RESET} ${AIDER_CONFIG_FILE}`); 66 + console.log(` ${BOLD}API env:${RESET} ${AIDER_CONFIG_DIR}/.env`); 67 + console.log( 68 + ` ${BOLD}Server logs:${RESET} /tmp/llama-chat.log, /tmp/llama-complete.log`, 69 + ); 70 + console.log(""); 71 + console.log( 72 + ` Run ${BOLD}source ${shellRC}${RESET} or open a new terminal to get started.`, 73 + ); 74 + console.log(""); 75 + } 76 + 77 + export async function runSetup(): Promise<void> { 78 + console.log( 79 + `\n${BOLD}Local AI Coding Environment Installer (llama.cpp)${RESET}\n`, 80 + ); 81 + 82 + checkPreflight(); 83 + installHomebrew(); 84 + installLlama(); 85 + downloadModels(); 86 + installTools(); 87 + await createLauncherScripts(); 88 + await writeTuiConfig(); 89 + addToPath(); 90 + printSummary(); 91 + }
+45
src/commands/status.ts
··· 1 + import { CHAT_PORT, AUTOCOMPLETE_PORT, LAUNCH_DIR } from "../config.js"; 2 + import { 3 + getActiveChatModel, 4 + getActiveAutocompleteModel, 5 + getActiveTui, 6 + } from "../runtime-config.js"; 7 + 8 + const BOLD = "\x1b[1m"; 9 + const GREEN = "\x1b[0;32m"; 10 + const RED = "\x1b[0;31m"; 11 + const DIM = "\x1b[2m"; 12 + const RESET = "\x1b[0m"; 13 + 14 + async function checkHealth(port: number): Promise<boolean> { 15 + try { 16 + const res = await fetch(`http://127.0.0.1:${port}/health`); 17 + return res.ok; 18 + } catch { 19 + return false; 20 + } 21 + } 22 + 23 + export async function showStatus(): Promise<void> { 24 + const chatModel = getActiveChatModel(); 25 + const autoModel = getActiveAutocompleteModel(); 26 + const tui = getActiveTui(); 27 + 28 + const chatOk = await checkHealth(CHAT_PORT); 29 + const autoOk = await checkHealth(AUTOCOMPLETE_PORT); 30 + 31 + const on = `${GREEN}running${RESET}`; 32 + const off = `${RED}stopped${RESET}`; 33 + 34 + console.log(` 35 + ${BOLD}localcode${RESET} — current configuration 36 + 37 + ${BOLD}Chat model:${RESET} ${chatModel.name} ${DIM}(${chatModel.id})${RESET} 38 + ${BOLD}Autocomplete model:${RESET} ${autoModel.name} ${DIM}(${autoModel.id})${RESET} 39 + ${BOLD}Active TUI:${RESET} ${tui.name} ${DIM}(${tui.id})${RESET} 40 + 41 + ${BOLD}Chat server:${RESET} :${CHAT_PORT} ${chatOk ? on : off} 42 + ${BOLD}Autocomplete:${RESET} :${AUTOCOMPLETE_PORT} ${autoOk ? on : off} 43 + ${BOLD}Scripts:${RESET} ${LAUNCH_DIR} 44 + `); 45 + }
+50
src/commands/tuis.ts
··· 1 + import { TUIS, getTuiById } from "../registry/tuis.js"; 2 + import { loadConfig, saveConfig, getActiveTui } from "../runtime-config.js"; 3 + import { createLauncherScripts } from "../steps/scripts.js"; 4 + import { writeTuiConfig } from "../steps/aider-config.js"; 5 + import { commandExists, runPassthrough } from "../util.js"; 6 + import { log, err } from "../log.js"; 7 + 8 + const BOLD = "\x1b[1m"; 9 + const GREEN = "\x1b[0;32m"; 10 + const DIM = "\x1b[2m"; 11 + const RESET = "\x1b[0m"; 12 + 13 + export function listTuis(): void { 14 + const activeId = getActiveTui().id; 15 + 16 + console.log(`\n${BOLD}Available TUIs:${RESET}`); 17 + for (const t of TUIS) { 18 + const active = t.id === activeId ? ` ${GREEN}<- active${RESET}` : ""; 19 + const installed = commandExists(t.checkCmd) 20 + ? "" 21 + : ` ${DIM}(not installed)${RESET}`; 22 + console.log(` ${BOLD}${t.id}${RESET} ${t.name}${active}${installed}`); 23 + } 24 + console.log(""); 25 + } 26 + 27 + export async function setTui(id: string): Promise<void> { 28 + const tui = getTuiById(id); 29 + if (!tui) { 30 + err( 31 + `Unknown TUI: ${id}\nAvailable: ${TUIS.map((t) => t.id).join(", ")}`, 32 + ); 33 + } 34 + 35 + // Install if needed 36 + if (!commandExists(tui.checkCmd)) { 37 + log(`Installing ${tui.name}...`); 38 + runPassthrough(tui.installCmd); 39 + } 40 + 41 + const config = loadConfig(); 42 + config.tui = id; 43 + saveConfig(config); 44 + log(`Active TUI set to ${tui.name}`); 45 + 46 + // Regenerate scripts and configs 47 + await createLauncherScripts(); 48 + await writeTuiConfig(); 49 + log("Launcher scripts and configs regenerated."); 50 + }
+14
src/config.ts
··· 1 + import { homedir } from "node:os"; 2 + import { join } from "node:path"; 3 + 4 + export const MODELS_DIR = join(homedir(), ".local/share/llama-models"); 5 + export const CHAT_PORT = 8080; 6 + export const AUTOCOMPLETE_PORT = 8081; 7 + export const LAUNCH_DIR = join(homedir(), ".local/bin"); 8 + export const AIDER_CONFIG_DIR = join(homedir(), ".aider"); 9 + export const AIDER_CONFIG_FILE = join(AIDER_CONFIG_DIR, "aider.conf.yml"); 10 + export const AIDER_ENV_FILE = join(AIDER_CONFIG_DIR, ".env"); 11 + export const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode"); 12 + export const OPENCODE_CONFIG_FILE = join(OPENCODE_CONFIG_DIR, "opencode.json"); 13 + export const PI_CONFIG_DIR = join(homedir(), ".pi", "agent"); 14 + export const PI_MODELS_FILE = join(PI_CONFIG_DIR, "models.json");
+18
src/log.ts
··· 1 + const BOLD = "\x1b[1m"; 2 + const GREEN = "\x1b[0;32m"; 3 + const YELLOW = "\x1b[1;33m"; 4 + const RED = "\x1b[0;31m"; 5 + const RESET = "\x1b[0m"; 6 + 7 + export function log(msg: string): void { 8 + console.log(`${GREEN}${BOLD}[✓]${RESET} ${msg}`); 9 + } 10 + 11 + export function warn(msg: string): void { 12 + console.log(`${YELLOW}${BOLD}[!]${RESET} ${msg}`); 13 + } 14 + 15 + export function err(msg: string): never { 16 + console.error(`${RED}${BOLD}[✗]${RESET} ${msg}`); 17 + process.exit(1); 18 + }
+144
src/main.ts
··· 1 + import { runSetup } from "./commands/setup.js"; 2 + import { listModels, setChatModel, setAutocompleteModel } from "./commands/models.js"; 3 + import { listTuis, setTui } from "./commands/tuis.js"; 4 + import { runBench } from "./commands/bench.js"; 5 + import { runDefault } from "./commands/run.js"; 6 + import { showStatus } from "./commands/status.js"; 7 + import { startServers, stopServers } from "./commands/server.js"; 8 + import { runPipe } from "./commands/pipe.js"; 9 + 10 + const BOLD = "\x1b[1m"; 11 + const DIM = "\x1b[2m"; 12 + const RESET = "\x1b[0m"; 13 + 14 + function printUsage(): void { 15 + console.log(` 16 + ${BOLD}localcode${RESET} — local AI coding environment 17 + 18 + ${BOLD}Usage:${RESET} 19 + localcode ${DIM}[flags...]${RESET} Launch active TUI in current directory 20 + localcode status Show current config and server health 21 + 22 + ${BOLD}Server:${RESET} 23 + localcode start Start chat + autocomplete servers 24 + localcode stop Stop all servers 25 + 26 + ${BOLD}Models:${RESET} 27 + localcode models List available models 28 + localcode models set-chat <id> Switch the chat model 29 + localcode models set-auto <id> Switch the autocomplete model 30 + 31 + ${BOLD}TUIs:${RESET} 32 + localcode tuis List available TUIs 33 + localcode tuis set <id> Switch the active TUI 34 + 35 + ${BOLD}Benchmark:${RESET} 36 + localcode bench Benchmark the running chat model 37 + localcode bench history Show past benchmark results 38 + 39 + ${BOLD}Other:${RESET} 40 + localcode pipe "prompt" Pipe stdin through the model 41 + localcode setup Full install (models, tools, scripts) 42 + `); 43 + } 44 + 45 + async function main(): Promise<void> { 46 + const cmd = process.argv[2]; 47 + 48 + switch (cmd) { 49 + case undefined: 50 + await runDefault(process.argv.slice(3)); 51 + break; 52 + 53 + case "status": 54 + await showStatus(); 55 + break; 56 + 57 + case "start": 58 + await startServers(); 59 + break; 60 + 61 + case "stop": 62 + stopServers(); 63 + break; 64 + 65 + case "models": { 66 + const sub = process.argv[3]; 67 + if (!sub) { 68 + listModels(); 69 + } else if (sub === "set-chat") { 70 + const id = process.argv[4]; 71 + if (!id) { 72 + console.error("Usage: localcode models set-chat <model-id>"); 73 + process.exit(1); 74 + } 75 + await setChatModel(id); 76 + } else if (sub === "set-auto" || sub === "set-autocomplete") { 77 + const id = process.argv[4]; 78 + if (!id) { 79 + console.error("Usage: localcode models set-auto <model-id>"); 80 + process.exit(1); 81 + } 82 + await setAutocompleteModel(id); 83 + } else { 84 + console.error(`Unknown: localcode models ${sub}`); 85 + process.exit(1); 86 + } 87 + break; 88 + } 89 + 90 + case "tuis": { 91 + const sub = process.argv[3]; 92 + if (!sub) { 93 + listTuis(); 94 + } else if (sub === "set") { 95 + const id = process.argv[4]; 96 + if (!id) { 97 + console.error("Usage: localcode tuis set <tui-id>"); 98 + process.exit(1); 99 + } 100 + await setTui(id); 101 + } else { 102 + console.error(`Unknown: localcode tuis ${sub}`); 103 + process.exit(1); 104 + } 105 + break; 106 + } 107 + 108 + case "bench": { 109 + const sub = process.argv[3]; 110 + if (sub === "history") { 111 + await runBench(["--history"]); 112 + } else { 113 + await runBench(process.argv.slice(3)); 114 + } 115 + break; 116 + } 117 + 118 + case "pipe": { 119 + const prompt = process.argv[3] ?? "Improve this code"; 120 + await runPipe(prompt); 121 + break; 122 + } 123 + 124 + case "setup": 125 + await runSetup(); 126 + break; 127 + 128 + case "help": 129 + case "--help": 130 + case "-h": 131 + printUsage(); 132 + break; 133 + 134 + default: 135 + console.error(`Unknown command: ${cmd}`); 136 + printUsage(); 137 + process.exit(1); 138 + } 139 + } 140 + 141 + main().catch((e: unknown) => { 142 + console.error(e); 143 + process.exit(1); 144 + });
+55
src/registry/models.ts
··· 1 + export interface ModelDef { 2 + id: string; 3 + name: string; 4 + role: "chat" | "autocomplete"; 5 + file: string; 6 + url: string; 7 + sizeGB: number; 8 + ctxSize: number; 9 + minRAMGB: number; 10 + } 11 + 12 + export const MODELS: ModelDef[] = [ 13 + { 14 + id: "qwen-32b-chat", 15 + name: "Qwen 2.5 Coder 32B", 16 + role: "chat", 17 + file: "qwen2.5-coder-32b-instruct-q4_k_m.gguf", 18 + url: "https://huggingface.co/Qwen/Qwen2.5-Coder-32B-Instruct-GGUF/resolve/main/qwen2.5-coder-32b-instruct-q4_k_m.gguf", 19 + sizeGB: 20, 20 + ctxSize: 16384, 21 + minRAMGB: 32, 22 + }, 23 + { 24 + id: "qwen-14b-chat", 25 + name: "Qwen 2.5 Coder 14B", 26 + role: "chat", 27 + file: "qwen2.5-coder-14b-instruct-q4_k_m.gguf", 28 + url: "https://huggingface.co/Qwen/Qwen2.5-Coder-14B-Instruct-GGUF/resolve/main/qwen2.5-coder-14b-instruct-q4_k_m.gguf", 29 + sizeGB: 9, 30 + ctxSize: 16384, 31 + minRAMGB: 16, 32 + }, 33 + { 34 + id: "qwen-1.5b-autocomplete", 35 + name: "Qwen 2.5 Coder 1.5B", 36 + role: "autocomplete", 37 + file: "qwen2.5-coder-1.5b-instruct-q4_k_m.gguf", 38 + url: "https://huggingface.co/Qwen/Qwen2.5-Coder-1.5B-Instruct-GGUF/resolve/main/qwen2.5-coder-1.5b-instruct-q4_k_m.gguf", 39 + sizeGB: 1.2, 40 + ctxSize: 4096, 41 + minRAMGB: 8, 42 + }, 43 + ]; 44 + 45 + export function getModelById(id: string): ModelDef | undefined { 46 + return MODELS.find((m) => m.id === id); 47 + } 48 + 49 + export function getChatModels(): ModelDef[] { 50 + return MODELS.filter((m) => m.role === "chat"); 51 + } 52 + 53 + export function getAutocompleteModels(): ModelDef[] { 54 + return MODELS.filter((m) => m.role === "autocomplete"); 55 + }
+35
src/registry/tuis.ts
··· 1 + export interface TuiDef { 2 + id: string; 3 + name: string; 4 + installCmd: string; 5 + checkCmd: string; 6 + launchArgs: string; 7 + } 8 + 9 + export const TUIS: TuiDef[] = [ 10 + { 11 + id: "aider", 12 + name: "Aider", 13 + installCmd: "pipx install aider-chat", 14 + checkCmd: "aider", 15 + launchArgs: '"$@"', 16 + }, 17 + { 18 + id: "opencode", 19 + name: "OpenCode", 20 + installCmd: "npm install -g opencode-ai@latest", 21 + checkCmd: "opencode", 22 + launchArgs: '"$@"', 23 + }, 24 + { 25 + id: "pi", 26 + name: "Pi", 27 + installCmd: "npm install -g @mariozechner/pi-coding-agent", 28 + checkCmd: "pi", 29 + launchArgs: '"$@"', 30 + }, 31 + ]; 32 + 33 + export function getTuiById(id: string): TuiDef | undefined { 34 + return TUIS.find((t) => t.id === id); 35 + }
+54
src/runtime-config.ts
··· 1 + import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; 2 + import { dirname, join } from "node:path"; 3 + import { homedir } from "node:os"; 4 + import { getModelById, MODELS } from "./registry/models.js"; 5 + import { getTuiById, TUIS } from "./registry/tuis.js"; 6 + import type { ModelDef } from "./registry/models.js"; 7 + import type { TuiDef } from "./registry/tuis.js"; 8 + 9 + export interface RuntimeConfig { 10 + chatModel: string; 11 + autocompleteModel: string; 12 + tui: string; 13 + } 14 + 15 + const CONFIG_PATH = join(homedir(), ".config", "localcode", "config.json"); 16 + 17 + const DEFAULTS: RuntimeConfig = { 18 + chatModel: "qwen-32b-chat", 19 + autocompleteModel: "qwen-1.5b-autocomplete", 20 + tui: "aider", 21 + }; 22 + 23 + export function loadConfig(): RuntimeConfig { 24 + try { 25 + const raw = readFileSync(CONFIG_PATH, "utf-8"); 26 + const parsed = JSON.parse(raw) as Partial<RuntimeConfig>; 27 + return { ...DEFAULTS, ...parsed }; 28 + } catch { 29 + return { ...DEFAULTS }; 30 + } 31 + } 32 + 33 + export function saveConfig(config: RuntimeConfig): void { 34 + mkdirSync(dirname(CONFIG_PATH), { recursive: true }); 35 + writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n"); 36 + } 37 + 38 + export function getActiveChatModel(): ModelDef { 39 + const config = loadConfig(); 40 + return getModelById(config.chatModel) ?? MODELS[0]!; 41 + } 42 + 43 + export function getActiveAutocompleteModel(): ModelDef { 44 + const config = loadConfig(); 45 + return ( 46 + getModelById(config.autocompleteModel) ?? 47 + MODELS.find((m) => m.role === "autocomplete")! 48 + ); 49 + } 50 + 51 + export function getActiveTui(): TuiDef { 52 + const config = loadConfig(); 53 + return getTuiById(config.tui) ?? TUIS[0]!; 54 + }
+36
src/steps/aider-config.ts
··· 1 + import { log } from "../log.js"; 2 + import { writeConfig } from "../util.js"; 3 + import { 4 + AIDER_CONFIG_FILE, 5 + AIDER_ENV_FILE, 6 + OPENCODE_CONFIG_FILE, 7 + PI_MODELS_FILE, 8 + } from "../config.js"; 9 + import { getActiveChatModel, getActiveTui } from "../runtime-config.js"; 10 + import { aiderConfig, aiderEnv } from "../templates/aider.js"; 11 + import { opencodeConfig } from "../templates/opencode.js"; 12 + import { piModelsConfig } from "../templates/pi.js"; 13 + 14 + export async function writeTuiConfig(): Promise<void> { 15 + const chatModel = getActiveChatModel(); 16 + const tui = getActiveTui(); 17 + 18 + // Always write all configs so switching TUIs doesn't require re-running setup 19 + await writeConfig(AIDER_CONFIG_FILE, aiderConfig(chatModel.id)); 20 + await writeConfig(AIDER_ENV_FILE, aiderEnv()); 21 + log(`Aider config written to ${AIDER_CONFIG_FILE}`); 22 + 23 + await writeConfig( 24 + OPENCODE_CONFIG_FILE, 25 + opencodeConfig(chatModel.id, chatModel.name), 26 + ); 27 + log(`OpenCode config written to ${OPENCODE_CONFIG_FILE}`); 28 + 29 + await writeConfig( 30 + PI_MODELS_FILE, 31 + piModelsConfig(chatModel.id, chatModel.name), 32 + ); 33 + log(`Pi config written to ${PI_MODELS_FILE}`); 34 + 35 + log(`Active TUI: ${tui.name}`); 36 + }
+15
src/steps/homebrew.ts
··· 1 + import { log } from "../log.js"; 2 + import { commandExists, runPassthrough } from "../util.js"; 3 + 4 + export function installHomebrew(): void { 5 + if (commandExists("brew")) { 6 + log("Homebrew already installed."); 7 + return; 8 + } 9 + 10 + log("Installing Homebrew..."); 11 + runPassthrough( 12 + '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"', 13 + ); 14 + runPassthrough('eval "$(/opt/homebrew/bin/brew shellenv)"'); 15 + }
+23
src/steps/llama.ts
··· 1 + import { log, warn } from "../log.js"; 2 + import { commandExists, run, runPassthrough } from "../util.js"; 3 + 4 + export function installLlama(): void { 5 + if (commandExists("llama-server")) { 6 + log("llama.cpp already installed."); 7 + } else { 8 + log("Installing llama.cpp via Homebrew..."); 9 + runPassthrough("brew install llama.cpp"); 10 + } 11 + 12 + // Verify Metal support 13 + try { 14 + const help = run("llama-server --help 2>&1", { silent: true }); 15 + if (/metal/i.test(help)) { 16 + log("Metal (GPU) acceleration available."); 17 + } else { 18 + warn("Metal flag not detected — model will run on CPU only."); 19 + } 20 + } catch { 21 + warn("Could not verify Metal support."); 22 + } 23 + }
+25
src/steps/models.ts
··· 1 + import { existsSync, mkdirSync } from "node:fs"; 2 + import { join } from "node:path"; 3 + import { log } from "../log.js"; 4 + import { runPassthrough } from "../util.js"; 5 + import { MODELS_DIR } from "../config.js"; 6 + import { getActiveChatModel, getActiveAutocompleteModel } from "../runtime-config.js"; 7 + import type { ModelDef } from "../registry/models.js"; 8 + 9 + function downloadModel(model: ModelDef): void { 10 + const dest = join(MODELS_DIR, model.file); 11 + if (existsSync(dest)) { 12 + log(`Model already downloaded: ${model.file}`); 13 + return; 14 + } 15 + 16 + log(`Downloading ${model.name} (${model.sizeGB}GB, this may take a while)...`); 17 + runPassthrough(`curl -L --progress-bar -o "${dest}" "${model.url}"`); 18 + log(`Downloaded: ${model.file}`); 19 + } 20 + 21 + export function downloadModels(): void { 22 + mkdirSync(MODELS_DIR, { recursive: true }); 23 + downloadModel(getActiveChatModel()); 24 + downloadModel(getActiveAutocompleteModel()); 25 + }
+19
src/steps/preflight.ts
··· 1 + import { platform, arch, totalmem } from "node:os"; 2 + import { log, warn, err } from "../log.js"; 3 + 4 + export function checkPreflight(): void { 5 + if (platform() !== "darwin") { 6 + err("This script is for macOS only."); 7 + } 8 + 9 + if (arch() !== "arm64") { 10 + warn("Not running on Apple Silicon — performance may vary."); 11 + } 12 + 13 + const memGB = Math.floor(totalmem() / 1073741824); 14 + if (memGB < 32) { 15 + warn( 16 + `You have ${memGB}GB RAM. The 32B model needs ~20GB; you may experience swapping.`, 17 + ); 18 + } 19 + }
+32
src/steps/scripts.ts
··· 1 + import { join } from "node:path"; 2 + import { log } from "../log.js"; 3 + import { writeExecutable } from "../util.js"; 4 + import { LAUNCH_DIR } from "../config.js"; 5 + import { 6 + getActiveChatModel, 7 + getActiveAutocompleteModel, 8 + } from "../runtime-config.js"; 9 + import { 10 + llamaChatServer, 11 + llamaCompleteServer, 12 + localcodeWrapper, 13 + } from "../templates/scripts.js"; 14 + 15 + export async function createLauncherScripts(): Promise<void> { 16 + const chatModel = getActiveChatModel(); 17 + const autocompleteModel = getActiveAutocompleteModel(); 18 + 19 + // Resolve the project directory (where dist/main.js lives) 20 + const projectDir = join(import.meta.dirname, "../.."); 21 + 22 + const scripts: [string, string][] = [ 23 + ["llama-chat-server", llamaChatServer(chatModel)], 24 + ["llama-complete-server", llamaCompleteServer(autocompleteModel)], 25 + ["localcode", localcodeWrapper(projectDir)], 26 + ]; 27 + 28 + for (const [name, content] of scripts) { 29 + await writeExecutable(join(LAUNCH_DIR, name), content); 30 + } 31 + log(`Launcher scripts written to ${LAUNCH_DIR}`); 32 + }
+34
src/steps/shell-path.ts
··· 1 + import { readFileSync, appendFileSync } from "node:fs"; 2 + import { homedir } from "node:os"; 3 + import { join } from "node:path"; 4 + import { log } from "../log.js"; 5 + 6 + export function addToPath(): void { 7 + const shell = process.env.SHELL ?? "/bin/zsh"; 8 + let rcFile: string; 9 + 10 + if (shell.endsWith("/zsh")) { 11 + rcFile = join(homedir(), ".zshrc"); 12 + } else if (shell.endsWith("/bash")) { 13 + rcFile = join(homedir(), ".bashrc"); 14 + } else { 15 + rcFile = join(homedir(), ".profile"); 16 + } 17 + 18 + let contents = ""; 19 + try { 20 + contents = readFileSync(rcFile, "utf-8"); 21 + } catch { 22 + // File doesn't exist yet, that's fine 23 + } 24 + 25 + if (contents.includes(".local/bin")) { 26 + return; 27 + } 28 + 29 + appendFileSync( 30 + rcFile, 31 + '\n# Local AI coding tools\nexport PATH="$HOME/.local/bin:$PATH"\n', 32 + ); 33 + log(`Added ~/.local/bin to PATH in ${rcFile}`); 34 + }
+27
src/steps/tools.ts
··· 1 + import { log } from "../log.js"; 2 + import { commandExists, runPassthrough } from "../util.js"; 3 + import { getActiveTui } from "../runtime-config.js"; 4 + 5 + function brewInstallIfMissing(cmd: string, pkg?: string): void { 6 + if (commandExists(cmd)) { 7 + log(`${pkg ?? cmd} already installed.`); 8 + } else { 9 + log(`Installing ${pkg ?? cmd}...`); 10 + runPassthrough(`brew install ${pkg ?? cmd}`); 11 + } 12 + } 13 + 14 + export function installTools(): void { 15 + brewInstallIfMissing("jq"); 16 + brewInstallIfMissing("python3", "python@3.12"); 17 + brewInstallIfMissing("pipx"); 18 + 19 + const tui = getActiveTui(); 20 + 21 + if (!commandExists(tui.checkCmd)) { 22 + log(`Installing ${tui.name}...`); 23 + runPassthrough(tui.installCmd); 24 + } else { 25 + log(`${tui.name} already installed.`); 26 + } 27 + }
+39
src/templates/aider.ts
··· 1 + export function aiderConfig(modelName: string): string { 2 + return `# ============================================================================= 3 + # Aider Configuration — ${modelName} via llama.cpp 4 + # ============================================================================= 5 + 6 + # Point Aider at llama.cpp's OpenAI-compatible endpoint 7 + # The model name can be anything — llama.cpp ignores it and uses the loaded model 8 + model: openai/${modelName} 9 + 10 + # Architect mode for better code planning 11 + architect: true 12 + editor-model: openai/${modelName} 13 + 14 + # Git integration 15 + auto-commits: true 16 + dirty-commits: true 17 + attribute-author: false 18 + attribute-committer: false 19 + 20 + # UI preferences 21 + pretty: true 22 + stream: true 23 + dark-mode: true 24 + 25 + # Code style 26 + code-theme: monokai 27 + show-diffs: true 28 + 29 + # Disable analytics 30 + analytics-disable: true 31 + `; 32 + } 33 + 34 + export function aiderEnv(): string { 35 + return `# llama.cpp serves an OpenAI-compatible API — no real key needed 36 + OPENAI_API_KEY=sk-not-needed 37 + OPENAI_API_BASE=http://127.0.0.1:8080/v1 38 + `; 39 + }
+28
src/templates/opencode.ts
··· 1 + import { CHAT_PORT } from "../config.js"; 2 + 3 + export function opencodeConfig(modelId: string, modelName: string): string { 4 + return JSON.stringify( 5 + { 6 + $schema: "https://opencode.ai/config.json", 7 + model: `llama-cpp/${modelId}`, 8 + provider: { 9 + "llama-cpp": { 10 + npm: "@ai-sdk/openai-compatible", 11 + name: "llama.cpp (local)", 12 + options: { 13 + baseURL: `http://127.0.0.1:${CHAT_PORT}/v1`, 14 + apiKey: "not-needed", 15 + }, 16 + models: { 17 + [modelId]: { 18 + name: modelName, 19 + tools: true, 20 + }, 21 + }, 22 + }, 23 + }, 24 + }, 25 + null, 26 + 2, 27 + ) + "\n"; 28 + }
+23
src/templates/pi.ts
··· 1 + import { CHAT_PORT } from "../config.js"; 2 + 3 + export function piModelsConfig(modelId: string, modelName: string): string { 4 + return JSON.stringify( 5 + { 6 + providers: { 7 + "llama-cpp": { 8 + baseUrl: `http://127.0.0.1:${CHAT_PORT}/v1`, 9 + api: "openai-completions", 10 + apiKey: "not-needed", 11 + models: [ 12 + { 13 + id: modelId, 14 + name: modelName, 15 + }, 16 + ], 17 + }, 18 + }, 19 + }, 20 + null, 21 + 2, 22 + ) + "\n"; 23 + }
+50
src/templates/scripts.ts
··· 1 + import { MODELS_DIR, CHAT_PORT, AUTOCOMPLETE_PORT } from "../config.js"; 2 + import type { ModelDef } from "../registry/models.js"; 3 + 4 + // Use this to emit a literal $ in template literals without triggering interpolation. 5 + const D = "$"; 6 + 7 + export function llamaChatServer(model: ModelDef): string { 8 + return `#!/usr/bin/env bash 9 + # Start llama.cpp server with ${model.name} for chat 10 + # Exposed as OpenAI-compatible API on port ${CHAT_PORT} 11 + 12 + MODEL="${MODELS_DIR}/${model.file}" 13 + 14 + exec llama-server \\ 15 + --model "${D}MODEL" \\ 16 + --port ${CHAT_PORT} \\ 17 + --host 127.0.0.1 \\ 18 + --ctx-size ${model.ctxSize} \\ 19 + --n-gpu-layers 99 \\ 20 + --threads ${D}(sysctl -n hw.perflevel0.logicalcpu 2>/dev/null || echo 4) \\ 21 + --mlock \\ 22 + "${D}@" 23 + `; 24 + } 25 + 26 + export function llamaCompleteServer(model: ModelDef): string { 27 + return `#!/usr/bin/env bash 28 + # Start llama.cpp server with ${model.name} for autocomplete 29 + # Exposed as OpenAI-compatible API on port ${AUTOCOMPLETE_PORT} 30 + 31 + MODEL="${MODELS_DIR}/${model.file}" 32 + 33 + exec llama-server \\ 34 + --model "${D}MODEL" \\ 35 + --port ${AUTOCOMPLETE_PORT} \\ 36 + --host 127.0.0.1 \\ 37 + --ctx-size ${model.ctxSize} \\ 38 + --n-gpu-layers 99 \\ 39 + --threads ${D}(sysctl -n hw.perflevel0.logicalcpu 2>/dev/null || echo 4) \\ 40 + --mlock \\ 41 + "${D}@" 42 + `; 43 + } 44 + 45 + export function localcodeWrapper(projectDir: string): string { 46 + return `#!/usr/bin/env bash 47 + # localcode — local AI coding environment manager 48 + exec node "${projectDir}/dist/main.js" "${D}@" 49 + `; 50 + }
+48
src/util.ts
··· 1 + import { execSync, execFileSync } from "node:child_process"; 2 + import { writeFile, mkdir } from "node:fs/promises"; 3 + import { dirname } from "node:path"; 4 + 5 + export function commandExists(name: string): boolean { 6 + try { 7 + execSync(`command -v ${name}`, { stdio: "ignore" }); 8 + return true; 9 + } catch { 10 + return false; 11 + } 12 + } 13 + 14 + export function run(cmd: string, opts?: { silent?: boolean }): string { 15 + const stdio = opts?.silent ? "pipe" : "inherit"; 16 + const result = execSync(cmd, { 17 + stdio: [stdio, "pipe", stdio], 18 + encoding: "utf-8", 19 + }); 20 + return result?.trim() ?? ""; 21 + } 22 + 23 + export function runPassthrough(cmd: string): void { 24 + execSync(cmd, { stdio: "inherit" }); 25 + } 26 + 27 + export function runShell(cmd: string): string { 28 + return execFileSync("/bin/bash", ["-c", cmd], { 29 + encoding: "utf-8", 30 + stdio: ["pipe", "pipe", "pipe"], 31 + }).trim(); 32 + } 33 + 34 + export async function writeExecutable( 35 + path: string, 36 + content: string, 37 + ): Promise<void> { 38 + await mkdir(dirname(path), { recursive: true }); 39 + await writeFile(path, content, { mode: 0o755 }); 40 + } 41 + 42 + export async function writeConfig( 43 + path: string, 44 + content: string, 45 + ): Promise<void> { 46 + await mkdir(dirname(path), { recursive: true }); 47 + await writeFile(path, content, { mode: 0o644 }); 48 + }
+14
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "es2022", 4 + "module": "node16", 5 + "moduleResolution": "node16", 6 + "outDir": "dist", 7 + "rootDir": "src", 8 + "strict": true, 9 + "esModuleInterop": true, 10 + "skipLibCheck": true, 11 + "declaration": true 12 + }, 13 + "include": ["src"] 14 + }
+21
verify-templates.ts
··· 1 + import { llamaChatServer, llamaCompleteServer, llamaStart, llamaStop, aiCode, aiAsk, aiPipe } from "./src/templates/scripts.js"; 2 + import { aiderConfig, aiderEnv } from "./src/templates/aider.js"; 3 + 4 + const templates: [string, string][] = [ 5 + ["llama-chat-server", llamaChatServer()], 6 + ["llama-complete-server", llamaCompleteServer()], 7 + ["llama-start", llamaStart()], 8 + ["llama-stop", llamaStop()], 9 + ["ai-code", aiCode()], 10 + ["ai-ask", aiAsk()], 11 + ["ai-pipe", aiPipe()], 12 + ["aider.conf.yml", aiderConfig()], 13 + ["aider .env", aiderEnv()], 14 + ]; 15 + 16 + for (const [name, content] of templates) { 17 + console.log(`\n${"=".repeat(60)}`); 18 + console.log(` ${name}`); 19 + console.log("=".repeat(60)); 20 + console.log(content); 21 + }