diff --git a/.github/workflows/bash-tests.yml b/.github/workflows/bash-tests.yml new file mode 100644 index 00000000..96fab73f --- /dev/null +++ b/.github/workflows/bash-tests.yml @@ -0,0 +1,27 @@ +name: Bash tests + +on: + pull_request: + paths: + - 'scripts/install/**' + - 'tests/install/bash/**' + - '.github/workflows/bash-tests.yml' + push: + branches: [main] + paths: + - 'scripts/install/**' + - 'tests/install/bash/**' + +jobs: + bats: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install bats-core + run: | + sudo apt-get update + sudo apt-get install -y bats + + - name: Run bats tests + run: bats tests/install/bash/ diff --git a/.github/workflows/installer-integration.yml b/.github/workflows/installer-integration.yml new file mode 100644 index 00000000..7803f7ce --- /dev/null +++ b/.github/workflows/installer-integration.yml @@ -0,0 +1,118 @@ +name: Installer integration + +on: + pull_request: + paths: + - 'scripts/install/**' + - 'src/openjarvis/cli/**' + - 'tests/install/**' + - '.github/workflows/installer-integration.yml' + schedule: + - cron: '0 6 * * *' + +jobs: + container-matrix: + name: ${{ matrix.image }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + image: + - ubuntu:22.04 + - ubuntu:24.04 + - fedora:40 + + container: ${{ matrix.image }} + + steps: + - name: Install prereqs (Ubuntu/Debian) + if: contains(matrix.image, 'ubuntu') || contains(matrix.image, 'debian') + run: | + apt-get update + apt-get install -y curl git python3 sudo + + - name: Install prereqs (Fedora) + if: contains(matrix.image, 'fedora') + run: | + dnf install -y curl git python3 sudo + + - name: Create non-root user + run: useradd -m -s /bin/bash testuser + + - uses: actions/checkout@v4 + with: + path: openjarvis-src + + - name: Install Ollama mock + run: install -m 755 "$GITHUB_WORKSPACE/openjarvis-src/tests/install/bash/stubs/ollama-mock" /usr/local/bin/ollama + + - name: Run installer + run: | + chown -R testuser:testuser /home/testuser "$GITHUB_WORKSPACE/openjarvis-src" + su testuser -c ' + export OPENJARVIS_REPO_URL=file://'"$GITHUB_WORKSPACE"'/openjarvis-src + cd '"$GITHUB_WORKSPACE"'/openjarvis-src + bash scripts/install/install.sh --no-bg-orchestrator + ' + + - name: Verify install state + run: | + su testuser -c ' + test -d ~/.openjarvis/src + test -d ~/.openjarvis/.venv + test -f ~/.openjarvis/config.toml + test -f ~/.openjarvis/.state/install-state.json + test -L ~/.local/bin/jarvis + ' + + - name: Verify jarvis --version + run: su testuser -c '~/.local/bin/jarvis --version' + + - name: Verify jarvis doctor exits 0 + run: su testuser -c '~/.local/bin/jarvis doctor' + + - name: Verify uninstall is clean + run: | + su testuser -c ' + ~/.local/bin/jarvis-uninstall + test ! -d ~/.openjarvis + test ! -L ~/.local/bin/jarvis + ' + + macos: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-14, macos-15] + + steps: + - uses: actions/checkout@v4 + + - name: Install Ollama mock + run: sudo install -m 755 tests/install/bash/stubs/ollama-mock /usr/local/bin/ollama + + - name: Run installer + run: | + export OPENJARVIS_REPO_URL=file://$(pwd) + bash scripts/install/install.sh --no-bg-orchestrator + + - name: Verify install state + run: | + test -d ~/.openjarvis/src + test -d ~/.openjarvis/.venv + test -f ~/.openjarvis/config.toml + test -L ~/.local/bin/jarvis + + - name: Verify jarvis --version + run: ~/.local/bin/jarvis --version + + - name: Verify jarvis doctor exits 0 + run: ~/.local/bin/jarvis doctor + + - name: Verify uninstall is clean + run: | + ~/.local/bin/jarvis-uninstall + test ! -d ~/.openjarvis + test ! -L ~/.local/bin/jarvis diff --git a/README.md b/README.md index 7062a916..d6217f22 100644 --- a/README.md +++ b/README.md @@ -30,53 +30,30 @@ OpenJarvis is that stack. It is an opinionated framework for local-first persona ## Installation -### Prerequisites - -| Tool | Install | -|------|---------| -| **Python 3.10+** | [python.org](https://www.python.org/downloads/) | -| **uv** (Python package manager) | `curl -LsSf https://astral.sh/uv/install.sh \| sh` — or `brew install uv` on macOS | -| **Rust** | `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \| sh` | -| **Git** | [git-scm.com](https://git-scm.com/) — or `brew install git` on macOS | - -> **macOS users:** see the full [macOS Installation Guide](https://open-jarvis.github.io/OpenJarvis/getting-started/macos/) for a step-by-step walkthrough including Homebrew setup. - -### Setup - ```bash -git clone https://github.com/open-jarvis/OpenJarvis.git -cd OpenJarvis -uv sync # core framework -uv sync --extra server # + FastAPI server - -# Build the Rust extension -uv run maturin develop -m rust/crates/openjarvis-python/Cargo.toml +curl -fsSL https://openjarvis.ai/install.sh | bash ``` -> **Python 3.14+:** set `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1` before the `maturin` command. +That's it. The installer handles everything: uv, the Python venv, Ollama, and pulling a small starter model. About 3 minutes on a typical broadband connection. Then: -You also need a local inference backend: [Ollama](https://ollama.com), [vLLM](https://github.com/vllm-project/vllm), [SGLang](https://github.com/sgl-project/sglang), or [llama.cpp](https://github.com/ggerganov/llama.cpp). Alternatively, use the `cloud` engine with [OpenAI](https://openai.com), [Anthropic](https://anthropic.com), [Google Gemini](https://ai.google.dev), [OpenRouter](https://openrouter.ai), or [MiniMax](https://www.minimax.io) by setting the corresponding API key environment variable. +```bash +jarvis +``` + +The Rust extension and bigger models continue downloading in the background while you chat. Run `jarvis doctor` to see status. + +**Platforms:** macOS (Intel + Apple Silicon), Linux, WSL2 on Windows. + +**Manual install / contributors:** see [docs/getting-started/install.md](docs/getting-started/install.md). ## Quick Start ```bash -# 1. Install and detect hardware -git clone https://github.com/open-jarvis/OpenJarvis.git -cd OpenJarvis -uv sync -uv run maturin develop -m rust/crates/openjarvis-python/Cargo.toml # required for memory + security features -uv run jarvis init - -# 2. Start Ollama and pull a model -curl -fsSL https://ollama.com/install.sh | sh -ollama serve & -ollama pull qwen3.5:4b # CPU-friendly default; use qwen3.5:9b or larger if you have a GPU - -# 3. Ask a question -uv run jarvis ask "What is the capital of France?" +curl -fsSL https://openjarvis.ai/install.sh | bash +jarvis ``` -`jarvis init` auto-detects your hardware and recommends the best engine and model size. Run `uv run jarvis doctor` at any time to diagnose issues. +`jarvis init --preset ` switches to a starter config. Available presets: `morning-digest-mac`, `morning-digest-linux`, `morning-digest-minimal`, `deep-research`, `code-assistant`, `scheduled-monitor`, `chat-simple`. ## Starter Configs diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md new file mode 100644 index 00000000..701387ae --- /dev/null +++ b/docs/development/release-checklist.md @@ -0,0 +1,52 @@ +# Release Checklist + +Before tagging a release, run through this checklist on real machines (not just CI containers). + +## Manual smoke tests (~30 min total) + +For each platform below, start from a fresh user account / VM snapshot. Run the install one-liner and verify the steps in the table. + +| Platform | One-liner | Verify | +|---|---|---| +| macOS Intel laptop | `curl -fsSL \| bash` | (1)–(8) below | +| macOS ARM laptop | same | (1)–(8) | +| Ubuntu 22.04 fresh VM | same | (1)–(8) | +| Fedora 40 fresh VM | same | (1)–(8) | +| WSL2 Ubuntu on Windows | same | (1)–(8) | + +### Verification steps + +1. **Install completes ≤ 5 min** on typical broadband. +2. **`jarvis` (no args)** drops into a chat session within 2 s. +3. **First chat turn returns a response** from `qwen3.5:2b` via Ollama. +4. **Banner shows background work** ("Setting up in background: …") while it's still going. +5. **Completion notification fires** between turns when the bg work finishes (Rust extension or a model). +6. **`jarvis doctor`** exits 0 once all bg work completes; shows the Background tasks table. +7. **Re-run `curl … | bash`** on the same machine. It completes ≤ 30 s, says `[ok] step already done` for every step. +8. **`jarvis-uninstall`** removes `~/.openjarvis/` and `~/.local/bin/jarvis*`. Verify with `ls`. + +## Cloud quick-path verification + +On any one platform: + +```bash +export ANTHROPIC_API_KEY=test-fake-key +jarvis init --force +``` + +Verify init proposes cloud (mentions "anthropic" in the prompt), and the resulting `config.toml` has `[intelligence] provider = "anthropic"`. + +## Failure-mode spot checks + +Run at least one failure scenario per release; rotate which one. + +- Disconnect network mid-install — verify clear error and re-run completes. +- Delete `~/.openjarvis/config.toml` — verify bare `jarvis` re-runs init. +- Delete `~/.openjarvis/.venv` — verify re-running curl heals it. +- `EUID=0 bash install.sh` — verify hard-fail with "don't run as root". + +## CI gates (automated, no manual action) + +- All pytest tests pass: `uv run pytest tests/` +- All bats tests pass: see `.github/workflows/bash-tests.yml` +- Container integration matrix is green: see `.github/workflows/installer-integration.yml` diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md new file mode 100644 index 00000000..cf4082df --- /dev/null +++ b/docs/getting-started/install.md @@ -0,0 +1,113 @@ +# Installation + +OpenJarvis ships a one-line installer for macOS, Linux, and WSL2. + +```bash +curl -fsSL https://openjarvis.ai/install.sh | bash +``` + +About 3 minutes on a typical broadband connection. Type `jarvis` to start chatting. + +## What the installer does + +| Phase | Step | Where | +|---|---|---| +| Foreground | Install `uv` (Python package manager) | `~/.cargo/bin/` or `~/.local/bin/` | +| Foreground | Clone OpenJarvis repo | `~/.openjarvis/src/` | +| Foreground | Create Python 3.11 venv | `~/.openjarvis/.venv/` | +| Foreground | `uv pip install -e .` (editable install) | venv | +| Foreground | Install Ollama | system default | +| Foreground | Start `ollama serve` | systemd-user / launchd / nohup | +| Foreground | Pull `qwen3.5:2b` (~1.5 GB) | Ollama's model store | +| Foreground | Write `config.toml` (auto-detected hardware + engine + model) | `~/.openjarvis/config.toml` | +| Foreground | Symlink `jarvis` and `jarvis-uninstall` | `~/.local/bin/` | +| Foreground | Add `~/.local/bin` to PATH if missing (with on-screen notice) | `~/.bashrc` or `~/.zshrc` | +| Background | Install Rust toolchain via rustup | `~/.cargo/` | +| Background | Build the maturin extension (memory + security features) | venv | +| Background | Pull hardware-tier and tier+1 models | Ollama's model store | + +## What the installer does NOT touch + +- Your existing Python installations +- Your `~/.bashrc` / `~/.zshrc` other than appending one PATH line (with on-screen notice) +- Your existing Ollama models +- Any other tool or dotfile + +## Idempotent re-runs + +Re-running the curl line is safe. The installer reads `~/.openjarvis/.state/install-state.json` and skips completed steps. If your venv got nuked, re-running heals it. + +## Cloud quick-path + +If any of these env vars are set when you install or run `jarvis init`, the installer/init proposes cloud as the default and writes the matching provider into `config.toml`: + +- `OPENROUTER_API_KEY` +- `ANTHROPIC_API_KEY` +- `OPENAI_API_KEY` +- `GOOGLE_API_KEY` (or `GEMINI_API_KEY`) + +Local-first remains the default when no key is in env. Precedence is OpenRouter > Anthropic > OpenAI > Google. + +## Flags + +| Flag | Effect | +|---|---| +| `--minimal` | Skip the foreground model pull. First chat will need to wait for the bg pull to finish. | +| `--no-bg-orchestrator` | Don't detach the background work pipeline. (Mostly for testing.) | +| `--force` | Re-run all steps even if `install-state.json` says they're done. | + +## Environment overrides + +| Variable | Default | Purpose | +|---|---|---| +| `OPENJARVIS_HOME` | `$HOME/.openjarvis` | Install location. | +| `OPENJARVIS_REPO_URL` | `https://github.com/open-jarvis/OpenJarvis.git` | Source repo for the clone step. | + +## Uninstall + +```bash +jarvis-uninstall +``` + +Removes `~/.openjarvis/`, `~/.local/bin/jarvis`, and `~/.local/bin/jarvis-uninstall`. Leaves Ollama, uv, and the Rust toolchain in place (they may be used by other tools); the script prints removal hints. + +## Updating + +```bash +jarvis update +``` + +Pulls the latest source, refreshes the editable install, and rebuilds the Rust extension in the background. Models are not touched. + +## Troubleshooting + +### "command not found: jarvis" + +`~/.local/bin` isn't on your PATH. Run `source ~/.bashrc` (or `~/.zshrc`) or open a new terminal. + +### "memory features unavailable" + +Rust extension hasn't finished building yet (or failed). Check status: + +```bash +jarvis doctor +``` + +Manually retry: + +```bash +~/.openjarvis/.scripts/install-rust.sh && ~/.openjarvis/.scripts/build-extension.sh +``` + +### A bigger model failed to download + +Check status and retry: + +```bash +jarvis doctor +~/.openjarvis/.scripts/pull-model.sh qwen3.5:9b +``` + +### Behind a corporate proxy + +Set `HTTPS_PROXY` and `CURL_CA_BUNDLE` in your environment before running the installer. diff --git a/docs/getting-started/linux.md b/docs/getting-started/linux.md new file mode 100644 index 00000000..e1437905 --- /dev/null +++ b/docs/getting-started/linux.md @@ -0,0 +1,30 @@ +# Linux Install + +```bash +curl -fsSL https://openjarvis.ai/install.sh | bash +``` + +Tested on: Ubuntu 22.04 / 24.04, Fedora 40, Debian 12, Arch. + +## Prerequisites + +Most distros ship `git` and `curl`. If yours doesn't: + +```bash +# Debian / Ubuntu +sudo apt install git curl + +# Fedora / RHEL +sudo dnf install git curl + +# Arch +sudo pacman -S git curl +``` + +## NVIDIA / AMD GPU + +The installer auto-detects via `nvidia-smi` / `rocm-smi`. Datacenter cards (A100, H100, MI300+) get vLLM as the recommended engine; consumer cards get Ollama (NVIDIA) or Lemonade (AMD). + +## See also + +- [Full installer reference](install.md) diff --git a/docs/getting-started/macos.md b/docs/getting-started/macos.md index 427605a8..a6e83249 100644 --- a/docs/getting-started/macos.md +++ b/docs/getting-started/macos.md @@ -1,443 +1,26 @@ ---- -title: macOS Installation Guide -description: Complete step-by-step guide to installing OpenJarvis on macOS with llama.cpp, including common pitfalls and fixes -search: - boost: 2 ---- +# macOS Install -# macOS Installation Guide +```bash +curl -fsSL https://openjarvis.ai/install.sh | bash +``` -This guide walks through a complete OpenJarvis installation on macOS using **llama.cpp** as -the inference engine. It covers every step from scratch — including pitfalls not documented -elsewhere — and is suitable for both Apple Silicon and Intel Macs. +Works on Intel and Apple Silicon. The installer auto-detects your CPU/GPU. -!!! tip "Prefer Ollama?" - If you want the fastest possible setup, use [Ollama](installation.md#ollama-recommended) - instead. This guide is for users who want to run GGUF models directly with llama.cpp, - or who want a deeper understanding of the full stack. +## Prerequisites ---- +If you've never run `git` or `curl` on this Mac, macOS will prompt you to install the Xcode Command Line Tools the first time you run them. Accept the prompt; that gives you both. -## What You'll Install - -| Tool | Purpose | -|------|---------| -| Xcode Command Line Tools | Apple's compiler toolchain — required by Homebrew, Rust, and `git` | -| Homebrew | macOS package manager — installs everything else | -| uv | Python version and dependency manager | -| Git | Clones the OpenJarvis repo | -| Node.js | Required for the browser UI | -| Rust | Compiles the OpenJarvis security and memory extension | -| llama.cpp | Local inference engine that runs GGUF model files | -| OpenJarvis | The framework itself | -| A GGUF model | The actual AI model (downloaded separately) | - ---- - -## Step-by-Step Installation - -### Step 0 — Install Xcode Command Line Tools - -On a fresh Mac, you need Apple's developer command line tools before anything else -(`git`, `cc`, build headers). Install them with: +If you'd rather pre-install: ```bash xcode-select --install ``` -A GUI installer will pop up — accept and let it finish. If the tools are already -installed the command instead prints -`xcode-select: error: command line tools are already installed, use "Software Update" to install updates` -and exits non-zero, which is fine — you can move on. +## Apple Silicon notes -!!! note "Homebrew will ask too" - Step 1's Homebrew installer also requests the Command Line Tools if it doesn't see - them. Running `xcode-select --install` upfront avoids the second prompt and confirms - the tools landed correctly. +- The installer picks `mlx` as the recommended engine via the standard hardware-detect path, but the foreground default is still Ollama for compatibility. Switch later with `jarvis init --force` and pick `mlx` if you've installed `mlx-lm`. +- Unified memory is reported as "VRAM" by the installer — that's intentional; on Apple Silicon, system RAM is what GPU-accelerated models can use. ---- +## See also -### Step 1 — Install Homebrew - -Homebrew is the standard macOS package manager. Everything else in this guide is installed -through it. - -```bash -/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" -``` - -If you already have Homebrew, skip this step. - ---- - -### Step 2 — Install uv - -`uv` replaces pip, virtualenv, and pyenv in one tool. OpenJarvis uses it to manage Python -versions, virtual environments, and project dependencies. - -```bash -brew install uv -``` - ---- - -### Step 3 — Install Git - -Git is used to clone the OpenJarvis source code. It may already be present if you have -Xcode Command Line Tools installed. - -```bash -brew install git -``` - ---- - -### Step 4 — Install Node.js - -Node.js is required to build and run the browser frontend. Without it you can still use -the CLI, but not the web UI. - -```bash -brew install node -``` - ---- - -### Step 5 — Install Rust - -OpenJarvis includes a Rust extension that provides security scanning, memory indexing, -rate limiting, and tool execution. It must be compiled from source. - -```bash -curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -``` - -After the installer finishes, reload your shell so `rustc` is available: - -```bash -source "$HOME/.cargo/env" -``` - -Verify: - -```bash -rustc --version -``` - ---- - -### Step 6 — Install llama.cpp - -llama.cpp is the inference engine that loads and runs GGUF model files. It is not a model -itself — think of it as a media player and the `.gguf` file as the content. - -```bash -brew install llama.cpp -``` - ---- - -### Step 7 — Clone the OpenJarvis repo - -Run this from your home directory or any neutral parent folder. - -```bash -cd ~ -git clone https://github.com/open-jarvis/OpenJarvis.git -cd OpenJarvis -``` - -!!! warning "Do not clone from inside an existing OpenJarvis folder" - A common mistake is running `git clone` while already inside the repo, creating deeply - nested duplicates (`OpenJarvis/OpenJarvis/OpenJarvis`). Always clone from `~` or a - neutral parent directory. - ---- - -### Step 8 — Pin Python to 3.12 - -!!! warning "Critical step — do not skip" - OpenJarvis requires Python 3.10–3.13. Its Rust extension uses PyO3, which does not yet - support Python 3.14. If `uv` has Python 3.14 available, it will use it by default, - causing the Rust extension build to fail silently and resulting in ~250 test failures - with `ModuleNotFoundError: No module named 'openjarvis_rust'`. - -Pin the project to Python 3.12: - -```bash -echo "3.12" > .python-version -uv python install 3.12 -rm -rf .venv -uv venv -``` - -**Restart your terminal**, then verify: - -```bash -uv run python --version -# Must show: Python 3.12.x -``` - -!!! tip "Why restart the terminal?" - Without restarting, the shell may still reference the old virtual environment. This is - the most common reason the version pin appears not to work. - ---- - -### Step 9 — Install Python dependencies - -```bash -uv sync --extra dev --extra server -``` - -The `--extra server` flag adds the FastAPI backend required for the browser UI. - ---- - -### Step 10 — Build the Rust extension - -This compiles the Rust extension and installs it into the virtual environment. It provides -security scanning, memory indexing, MCP tool execution, and rate limiting. This step takes -a few minutes on first run. - -```bash -uv run maturin develop -m rust/crates/openjarvis-python/Cargo.toml -``` - -Verify it built correctly: - -```bash -uv run python -c "import openjarvis_rust; print('Rust extension OK')" -``` - ---- - -### Step 11 — Install frontend dependencies - -```bash -cd frontend && npm install && cd .. -``` - ---- - -### Step 12 — Download a model - -OpenJarvis needs a GGUF model file to run inference. First install the Hugging Face CLI, -then download your chosen model. - -```bash -uv tool install huggingface_hub -``` - -!!! note "The CLI command is `hf`, not `huggingface-cli`" - When installed via `uv tool`, the Hugging Face CLI is invoked as `hf`. - -=== "Qwen3 4B (~2.5 GB)" - - Faster, lower RAM requirement. Good for most everyday tasks. - - ```bash - hf download bartowski/Qwen_Qwen3-4B-GGUF \ - --include "Qwen_Qwen3-4B-Q4_K_M.gguf" \ - --local-dir ~/models - ``` - -=== "Qwen3 8B (~4.7 GB)" - - Better reasoning and instruction following. Requires more RAM. - - ```bash - hf download bartowski/Qwen_Qwen3-8B-GGUF \ - --include "Qwen_Qwen3-8B-Q4_K_M.gguf" \ - --local-dir ~/models - ``` - -!!! warning "Use the `Qwen_` prefix" - bartowski's Qwen3 repos use the `Qwen_` prefix (e.g. `Qwen_Qwen3-4B-GGUF`). Using - the shorter name without the prefix returns a "repository not found" error. - -!!! tip "Apple Silicon vs Intel" - On Apple Silicon, both models benefit from Metal GPU acceleration when using the MLX - engine. On Intel, inference runs on CPU — the 4B model is recommended for speed. - ---- - -### Step 13 — Configure OpenJarvis - -Run the init command to detect your hardware and generate a config file: - -```bash -uv run jarvis init -``` - -Then open the config and set the default model to match the filename you downloaded: - -```bash -nano ~/.openjarvis/config.toml -``` - -Find the `default_model` line and update it, for example: - -```toml -default_model = "Qwen_Qwen3-4B-Q4_K_M.gguf" -``` - ---- - -### Step 14 — Verify the installation - -```bash -uv run jarvis doctor -``` - -A healthy setup looks like this: - -``` -✓ Python version 3.12.x -✓ Config file ~/.openjarvis/config.toml -✓ Config parsing Config loaded successfully -✓ Engine: llamacpp Reachable -✓ Models: llamacpp Qwen_Qwen3-4B-Q4_K_M.gguf -✓ Default model Qwen_Qwen3-4B-Q4_K_M.gguf (on llamacpp) -``` - -!!! note "Warnings for other engines are normal" - The `!` warnings for engines like `ollama`, `vllm`, and `lmstudio` simply mean those - backends are not running. You only need `llamacpp` to be reachable. - ---- - -## Running OpenJarvis - -### CLI - -Start llama-server in one terminal, then run queries in another: - -```bash -# Terminal 1 — start the inference engine -llama-server -m ~/models/Qwen_Qwen3-4B-Q4_K_M.gguf -c 4096 -t 8 - -# Terminal 2 — ask a question -cd ~/OpenJarvis -uv run jarvis ask "What is the capital of France?" -``` - -### Browser UI - -```bash -# Terminal 1 — inference engine -llama-server -m ~/models/Qwen_Qwen3-4B-Q4_K_M.gguf -c 4096 -t 8 - -# Terminal 2 — backend -cd ~/OpenJarvis && uv run jarvis serve --port 8000 - -# Terminal 3 — frontend -cd ~/OpenJarvis/frontend && npm run dev -``` - -Then open [http://localhost:5173](http://localhost:5173). - -### Skip typing `uv run` every time - -Activate the virtual environment for your current terminal session: - -```bash -source ~/OpenJarvis/.venv/bin/activate -``` - -Your prompt will show `(openjarvis)` when active, and you can type `jarvis ask "..."` directly. - ---- - -## Performance Tips - -These tips apply when using llama.cpp for CPU inference. - -| Flag | Effect | -|------|--------| -| `-c 4096` | Reduces context window from the 32,768 default, freeing RAM for faster inference | -| `-t 8` | Uses all available CPU threads (default is only 4) — adjust to your machine's thread count | -| `Q4_K_M` quantization | Best balance of size, speed, and quality for CPU inference | - -On Apple Silicon, switching to the [MLX engine](../architecture/engine.md) gives -significantly better performance than llama.cpp for most models. - ---- - -## Common Errors - -### `No such file or directory` when loading model - -The path `path/to/model.gguf` in examples is a placeholder. Replace it with your actual -model path, e.g.: - -```bash -llama-server -m ~/models/Qwen_Qwen3-4B-Q4_K_M.gguf -``` - ---- - -### `No module named 'openjarvis_rust'` - -The Rust extension did not build correctly, or was built against the wrong Python version. - -1. Confirm Python 3.12 is active: `uv run python --version` -2. Rebuild: `uv run maturin develop -m rust/crates/openjarvis-python/Cargo.toml` - -If the version shows 3.14, go back to [Step 8](#step-8--pin-python-to-312). - ---- - -### `PyO3 version error — Python 3.14 too new` - -``` -error: the configured Python interpreter version (3.14) is newer than -PyO3's maximum supported version (3.13) -``` - -PyO3 0.23.5 supports Python up to 3.13. Follow [Step 8](#step-8--pin-python-to-312) to -pin to 3.12, then delete `.venv`, recreate it, and restart your terminal before retrying. - ---- - -### `Repository not found` when downloading model - -bartowski's Qwen3 repos use the `Qwen_` prefix. Use: - -``` -bartowski/Qwen_Qwen3-4B-GGUF ✓ -bartowski/Qwen3-4B-GGUF ✗ -``` - ---- - -### `No inference engine available` - -llama-server is not running. Start it in a separate terminal before running any `jarvis` -commands, and wait until you see `model loaded` in the output. - ---- - -### Python version still shows 3.14 after recreating the venv - -Close the terminal completely and reopen it. The old venv path is cached in the shell -environment and persists across commands until the session ends. - ---- - -### `zsh: command not found: huggingface-cli` - -When installed via `uv tool`, the CLI is invoked as `hf`, not `huggingface-cli`: - -```bash -hf download ... # ✓ -huggingface-cli download ... # ✗ -``` - ---- - -## Next Steps - -- [Quick Start](quickstart.md) — Run your first query and explore agents and tools -- [Configuration](configuration.md) — Customize engine hosts, model routing, memory, and more -- [Architecture](../architecture/overview.md) — Understand how OpenJarvis is structured +- [Full installer reference](install.md) diff --git a/docs/getting-started/wsl2.md b/docs/getting-started/wsl2.md new file mode 100644 index 00000000..e99bb725 --- /dev/null +++ b/docs/getting-started/wsl2.md @@ -0,0 +1,31 @@ +# WSL2 Install + +OpenJarvis runs in WSL2 on Windows. Native Windows is not supported. + +## One-time WSL setup + +In an admin PowerShell: + +```powershell +wsl --install +``` + +Then open the Ubuntu (or Debian) shell that gets installed. + +## Install OpenJarvis + +```bash +curl -fsSL https://openjarvis.ai/install.sh | bash +``` + +About 3 minutes. Type `jarvis` to start. + +## WSL-specific notes + +- The installer detects WSL via `/proc/sys/kernel/osrelease` and uses `nohup ollama serve &` instead of systemd to start the Ollama daemon (WSL2 doesn't ship systemd by default). +- The first time you run `jarvis`, the WSL kernel may show a "process running in background" notification — that's the bg-orchestrator detaching. It's expected. +- Models are stored in WSL's filesystem (`~/.openjarvis/`), not your Windows drive. To free up space later: `jarvis-uninstall` removes everything. + +## See also + +- [Full installer reference](install.md) diff --git a/pyproject.toml b/pyproject.toml index 63d75e88..23365058 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -141,6 +141,7 @@ packages = ["src/openjarvis"] [tool.hatch.build.targets.wheel.force-include] "src/openjarvis/agents/claude_code_runner" = "_node_modules/claude_code_runner" "src/openjarvis/channels/whatsapp_baileys_bridge" = "_node_modules/whatsapp_baileys_bridge" +"scripts/install" = "_install_scripts" [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/scripts/install/bg-orchestrator.sh b/scripts/install/bg-orchestrator.sh new file mode 100755 index 00000000..23caf095 --- /dev/null +++ b/scripts/install/bg-orchestrator.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# bg-orchestrator.sh — detached parent of all background install work. +# +# Spawned by install.sh via `nohup ... & disown`. Runs the Rust +# toolchain install + extension build sequentially, and in parallel +# kicks off model pulls for each model id passed as an argument. +# +# Usage: bg-orchestrator.sh [ ...] + +set -euo pipefail + +OPENJARVIS_HOME="${OPENJARVIS_HOME:-$HOME/.openjarvis}" +STATE_DIR="$OPENJARVIS_HOME/.state" +SCRIPTS_DIR="$OPENJARVIS_HOME/.scripts" +PID_FILE="$STATE_DIR/bg.pid" +LOG="$STATE_DIR/bg-orchestrator.log" + +mkdir -p "$STATE_DIR" +echo "$$" > "$PID_FILE" + +cleanup() { + rm -f "$PID_FILE" +} +trap cleanup EXIT + +echo "[$(date -u +%FT%TZ)] bg-orchestrator started, pid=$$" >> "$LOG" + +# Sequential: install rust, then build extension. +( + "$SCRIPTS_DIR/install-rust.sh" >> "$LOG" 2>&1 \ + && "$SCRIPTS_DIR/build-extension.sh" >> "$LOG" 2>&1 +) & +RUST_PID=$! + +# Parallel: each model pull. +MODEL_PIDS=() +for model in "$@"; do + "$SCRIPTS_DIR/pull-model.sh" "$model" >> "$LOG" 2>&1 & + MODEL_PIDS+=($!) +done + +# Wait for all subprocesses; non-zero exits don't fail the orchestrator +# because per-task state files already record success/failure. +wait "$RUST_PID" || true +for pid in "${MODEL_PIDS[@]}"; do + wait "$pid" || true +done + +echo "[$(date -u +%FT%TZ)] bg-orchestrator done" >> "$LOG" diff --git a/scripts/install/build-extension.sh b/scripts/install/build-extension.sh new file mode 100755 index 00000000..bbbc05e4 --- /dev/null +++ b/scripts/install/build-extension.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# build-extension.sh — build the Rust maturin extension into the venv. +# +# State files under $OPENJARVIS_HOME/.state/: +# extension-built — atomic marker written on success +# extension-failed — written on failure with stderr tail +# extension-build.log — captured stderr/stdout + +set -euo pipefail + +OPENJARVIS_HOME="${OPENJARVIS_HOME:-$HOME/.openjarvis}" +# Self-heal PATH for cargo: install-rust.sh installs to ~/.cargo/bin, but +# its export doesn't propagate to subsequent subprocess invocations. +export PATH="$HOME/.cargo/bin:$PATH" +SRC_DIR="$OPENJARVIS_HOME/src" +STATE_DIR="$OPENJARVIS_HOME/.state" +LOG="$STATE_DIR/extension-build.log" +BUILT="$STATE_DIR/extension-built" +FAILED="$STATE_DIR/extension-failed" +MANIFEST="$SRC_DIR/rust/crates/openjarvis-python/Cargo.toml" + +mkdir -p "$STATE_DIR" + +if [[ ! -f "$MANIFEST" ]]; then + echo "build-extension.sh: manifest not found at $MANIFEST" > "$FAILED" + exit 1 +fi + +cd "$SRC_DIR" +if uv run maturin develop -m "$MANIFEST" >>"$LOG" 2>&1; then + tmp="$BUILT.tmp" + date -u +"%Y-%m-%dT%H:%M:%SZ" > "$tmp" + mv "$tmp" "$BUILT" + rm -f "$FAILED" + exit 0 +else + rc=$? + { + echo "build-extension.sh failed (exit=$rc)" + tail -n 50 "$LOG" 2>/dev/null || true + } > "$FAILED" + rm -f "$BUILT" + exit "$rc" +fi diff --git a/scripts/install/install-rust.sh b/scripts/install/install-rust.sh new file mode 100755 index 00000000..c2dbf4bd --- /dev/null +++ b/scripts/install/install-rust.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# install-rust.sh — install Rust toolchain via rustup if cargo not present. +# +# Idempotent: exits 0 immediately if cargo is on PATH. + +set -euo pipefail + +if command -v cargo >/dev/null 2>&1; then + echo "install-rust.sh: cargo already present, skipping" + exit 0 +fi + +echo "install-rust.sh: installing Rust toolchain via rustup..." +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable + +if [[ -d "$HOME/.cargo/bin" ]]; then + export PATH="$HOME/.cargo/bin:$PATH" +fi + +if ! command -v cargo >/dev/null 2>&1; then + echo "install-rust.sh: cargo still not on PATH after install; check rustup output" >&2 + exit 1 +fi diff --git a/scripts/install/install.sh b/scripts/install/install.sh new file mode 100755 index 00000000..7bb326a3 --- /dev/null +++ b/scripts/install/install.sh @@ -0,0 +1,264 @@ +#!/usr/bin/env bash +# install.sh — OpenJarvis curl-pipe-bash installer. +# +# Usage: +# curl -fsSL https://openjarvis.ai/install.sh | bash +# +# Flags (only used in tests / power users): +# --no-bg-orchestrator Skip the detached background orchestrator +# --minimal Skip foreground model pull (no `qwen3.5:2b`) +# --force Re-run all steps even if state file says done +# +# Environment overrides: +# OPENJARVIS_HOME Install dir (default: $HOME/.openjarvis) +# OPENJARVIS_REPO_URL git repo URL (default: https://github.com/open-jarvis/OpenJarvis.git) +# OPENJARVIS_FORCE_WSL Set 1 to force WSL detection (testing) + +set -euo pipefail + +# ---- args ---- +SKIP_BG=0 +MINIMAL=0 +FORCE=0 +for arg in "$@"; do + case "$arg" in + --no-bg-orchestrator) SKIP_BG=1 ;; + --minimal) MINIMAL=1 ;; + --force) FORCE=1 ;; + *) echo "install.sh: unknown arg: $arg" >&2; exit 2 ;; + esac +done + +# ---- root refusal ---- +if [[ "$(id -u)" -eq 0 ]]; then + cat >&2 <<'EOF' +install.sh: don't run as root. + +OpenJarvis installs to $HOME/.openjarvis, not /usr/local. Re-run as your +regular user (without sudo). +EOF + exit 1 +fi + +# ---- prereq probe ---- +need() { + if ! command -v "$1" >/dev/null 2>&1; then + cat >&2 </dev/null; then + WSL=1 +fi + +# ---- helpers ---- +state_done() { + [[ -f "$STATE_FILE" ]] && grep -q "\"$1\":[[:space:]]*true" "$STATE_FILE" +} + +mark_done() { + if [[ ! -f "$STATE_FILE" ]]; then + echo '{}' > "$STATE_FILE" + fi + python3 - "$STATE_FILE" "$1" "$WSL" <<'PYEOF' +import json, sys +path, key, wsl = sys.argv[1], sys.argv[2], sys.argv[3] +with open(path) as f: + data = json.load(f) +data[key] = True +data["wsl"] = bool(int(wsl)) +with open(path, "w") as f: + json.dump(data, f, indent=2) +PYEOF +} + +step() { + local name="$1" desc="$2"; shift 2 + if [[ "$FORCE" -ne 1 ]] && state_done "$name"; then + echo "[ok] $desc (already done)" + return 0 + fi + echo "[..] $desc" + "$@" + mark_done "$name" + echo "[ok] $desc" +} + +# ---- step impls ---- +install_uv() { + if command -v uv >/dev/null 2>&1; then + echo " uv already installed" + return 0 + fi + curl -LsSf https://astral.sh/uv/install.sh | sh + if ! command -v uv >/dev/null 2>&1; then + export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH" + fi +} + +clone_repo() { + if [[ "$FORCE" -ne 1 ]] && [[ -d "$SRC_DIR/.git" ]]; then + echo " repo already at $SRC_DIR" + return 0 + fi + git clone --depth 1 "$OPENJARVIS_REPO_URL" "$SRC_DIR" +} + +copy_scripts() { + cp -f "$SRC_DIR"/scripts/install/*.sh "$SCRIPTS_DIR/" + chmod +x "$SCRIPTS_DIR"/*.sh +} + +create_venv() { + uv venv --python 3.11 "$VENV_DIR" +} + +editable_install() { + cd "$SRC_DIR" + uv pip install --python "$VENV_DIR/bin/python" -e . +} + +install_ollama() { + if command -v ollama >/dev/null 2>&1; then + echo " ollama already installed" + return 0 + fi + curl -fsSL https://ollama.com/install.sh | sh +} + +start_ollama() { + if pgrep -f "ollama serve" >/dev/null 2>&1; then + echo " ollama serve already running" + return 0 + fi + if [[ "$WSL" -eq 1 ]] || ! command -v systemctl >/dev/null 2>&1; then + nohup ollama serve > "$STATE_DIR/ollama.log" 2>&1 & + sleep 1 + else + systemctl --user start ollama 2>/dev/null \ + || (nohup ollama serve > "$STATE_DIR/ollama.log" 2>&1 & sleep 1) + fi +} + +pull_default_model() { + if [[ "$MINIMAL" -eq 1 ]]; then + echo " --minimal set; skipping model pull" + return 0 + fi + ollama pull qwen3.5:2b || echo " warning: ollama pull failed; bg-orchestrator will retry" +} + +write_config() { + "$VENV_DIR/bin/jarvis" _bootstrap --write-config \ + --engine ollama --model qwen3.5:2b \ + --prefer-cloud-when-available +} + +install_symlinks() { + mkdir -p "$HOME/.local/bin" + ln -sf "$SCRIPTS_DIR/jarvis-wrapper.sh" "$HOME/.local/bin/jarvis" + ln -sf "$SCRIPTS_DIR/jarvis-uninstall.sh" "$HOME/.local/bin/jarvis-uninstall" +} + +ensure_path() { + case ":$PATH:" in + *":$HOME/.local/bin:"*) return 0 ;; + esac + local rc="" + if [[ "$SHELL" == */zsh ]]; then + rc="$HOME/.zshrc" + else + rc="$HOME/.bashrc" + fi + if grep -q "OpenJarvis" "$rc" 2>/dev/null; then + return 0 + fi + { + echo '' + echo '# OpenJarvis' + echo 'export PATH="$HOME/.local/bin:$PATH"' + } >> "$rc" + echo " Added ~/.local/bin to PATH in $rc — run: source $rc" +} + +detach_bg_orchestrator() { + if [[ "$SKIP_BG" -eq 1 ]]; then + echo " --no-bg-orchestrator set; skipping detach" + return 0 + fi + local models + models=$("$VENV_DIR/bin/python" - <<'PYEOF' 2>/dev/null || true +from openjarvis.core.config import detect_hardware, recommend_model +hw = detect_hardware() +tier = recommend_model(hw, "ollama") +TIERS = ["qwen3.5:2b", "qwen3.5:4b", "qwen3.5:9b", "qwen3.5:27b"] +out = set([tier]) if tier else set() +if tier in TIERS: + idx = TIERS.index(tier) + if idx + 1 < len(TIERS): + out.add(TIERS[idx + 1]) +print(" ".join(sorted(out))) +PYEOF + ) + if [[ -z "$models" ]]; then + models="" + fi + nohup "$SCRIPTS_DIR/bg-orchestrator.sh" $models \ + > "$STATE_DIR/bg-orchestrator.log" 2>&1 & + disown +} + +# ---- run ---- +echo "OpenJarvis installer" +echo " install dir: $OPENJARVIS_HOME" +echo " WSL2: $WSL" +echo + +step install_uv "Install uv" install_uv +step clone_repo "Clone OpenJarvis repo" clone_repo +step copy_scripts "Copy install scripts" copy_scripts +step create_venv "Create venv" create_venv +step editable_install "Install OpenJarvis" editable_install +step install_ollama "Install Ollama" install_ollama +step start_ollama "Start Ollama daemon" start_ollama +step pull_default_model "Pull qwen3.5:2b" pull_default_model +step write_config "Write config.toml" write_config +step install_symlinks "Install symlinks" install_symlinks +step ensure_path "Ensure PATH" ensure_path +step detach_bg_orchestrator "Detach background work" detach_bg_orchestrator + +cat </dev/null || echo "") + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + echo "Stopping background work (pid=$pid)..." + kill "$pid" 2>/dev/null || true + fi +fi + +if command -v ollama >/dev/null 2>&1; then + ollama stop >/dev/null 2>&1 || true +fi + +if [[ -d "$OPENJARVIS_HOME" ]]; then + rm -rf "$OPENJARVIS_HOME" + echo "Removed $OPENJARVIS_HOME" +fi + +for f in "$HOME/.local/bin/jarvis" "$HOME/.local/bin/jarvis-uninstall"; do + if [[ -L "$f" ]] || [[ -f "$f" ]]; then + rm -f "$f" + echo "Removed $f" + fi +done + +cat <&2 + echo "Re-run the installer: curl -fsSL https://openjarvis.ai/install.sh | bash" >&2 + exit 1 +fi + +exec "$VENV/bin/jarvis" "$@" diff --git a/scripts/install/pull-model.sh b/scripts/install/pull-model.sh new file mode 100755 index 00000000..fda0b96a --- /dev/null +++ b/scripts/install/pull-model.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# pull-model.sh — pull an Ollama model with state-file lifecycle. +# +# Usage: pull-model.sh +# +# State files written under $OPENJARVIS_HOME/.state/models/: +# .downloading — created on start +# .ready — on success (atomic rename) +# .failed — on failure after retries +# .log — captured stderr + +set -euo pipefail + +MODEL="${1:-}" +if [[ -z "$MODEL" ]]; then + echo "usage: pull-model.sh " >&2 + exit 2 +fi + +OPENJARVIS_HOME="${OPENJARVIS_HOME:-$HOME/.openjarvis}" +STATE_DIR="$OPENJARVIS_HOME/.state/models" +mkdir -p "$STATE_DIR" + +DOWNLOADING="$STATE_DIR/${MODEL}.downloading" +READY="$STATE_DIR/${MODEL}.ready" +FAILED="$STATE_DIR/${MODEL}.failed" +LOG="$STATE_DIR/${MODEL}.log" + +# Cleanup any prior state for this model. +rm -f "$READY" "$FAILED" +touch "$DOWNLOADING" + +MAX_RETRIES=3 +attempt=0 +last_exit=0 + +while [[ $attempt -lt $MAX_RETRIES ]]; do + attempt=$((attempt + 1)) + if ollama pull "$MODEL" >>"$LOG" 2>&1; then + # Atomic rename: write to .tmp then mv. + tmp="$STATE_DIR/${MODEL}.ready.tmp" + date -u +"%Y-%m-%dT%H:%M:%SZ" > "$tmp" + mv "$tmp" "$READY" + rm -f "$DOWNLOADING" + exit 0 + else + last_exit=$? + fi +done + +# All retries exhausted. +{ + echo "pull-model.sh: $MODEL failed after $MAX_RETRIES attempts (exit=$last_exit)" + tail -n 50 "$LOG" 2>/dev/null || true +} > "$FAILED" +rm -f "$DOWNLOADING" +exit "$last_exit" diff --git a/src/openjarvis/cli/__init__.py b/src/openjarvis/cli/__init__.py index 00ed64ce..88b601f8 100644 --- a/src/openjarvis/cli/__init__.py +++ b/src/openjarvis/cli/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations import click import openjarvis +from openjarvis.cli._bootstrap import bootstrap_cmd from openjarvis.cli.add_cmd import add from openjarvis.cli.agent_cmd import agent from openjarvis.cli.ask import ask @@ -41,7 +42,10 @@ from openjarvis.cli.workflow_cmd import workflow from openjarvis.learning.distillation.cli import learning_group -@click.group(help="OpenJarvis — modular AI assistant backend") +@click.group( + help="OpenJarvis — modular AI assistant backend", + invoke_without_command=True, +) @click.version_option(version=openjarvis.__version__, prog_name="jarvis") @click.option("--verbose", is_flag=True, default=False, help="Enable debug logging") @click.option("--quiet", is_flag=True, default=False, help="Suppress non-error output") @@ -61,6 +65,12 @@ def cli(ctx: click.Context, verbose: bool, quiet: bool) -> None: check_for_updates(ctx.invoked_subcommand) + # First-run guard — routes bare `jarvis` to chat or init. + if ctx.invoked_subcommand is None: + from openjarvis.cli._first_run import check_and_route + + check_and_route(ctx) + cli.add_command(init, "init") cli.add_command(ask, "ask") @@ -100,6 +110,7 @@ cli.add_command(digest, "digest") cli.add_command(deep_research_setup, "deep-research-setup") cli.add_command(deep_research_setup, "research") cli.add_command(learning_group, "learning") +cli.add_command(bootstrap_cmd, "_bootstrap") # Gateway CLI commands (lazy import to avoid pulling starlette) try: diff --git a/src/openjarvis/cli/_bootstrap.py b/src/openjarvis/cli/_bootstrap.py new file mode 100644 index 00000000..87f8033c --- /dev/null +++ b/src/openjarvis/cli/_bootstrap.py @@ -0,0 +1,249 @@ +"""Cloud-key auto-detection and initial-config writing. + +Used by both ``install.sh`` (via ``jarvis _bootstrap --write-config``) +and ``jarvis init`` (so there is a single source of truth for the +TOML rendered at install time). +""" + +from __future__ import annotations + +import datetime as _dt +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +import click + +import openjarvis +from openjarvis.core import config as _cfg +from openjarvis.core.config import ( + HardwareInfo, + detect_hardware, + recommend_engine, + recommend_model, +) + +# Marker used to redact secret values in __repr__. +_REDACTED_PLACEHOLDER = "***redacted***" + +# Precedence order matters: first match wins. +# OpenRouter first because one key unlocks the most models; Anthropic +# next because it's the highest-quality single-provider option; then +# OpenAI; then Google (with GEMINI_API_KEY as an alias). +_KEY_TO_PROVIDER: tuple[tuple[str, str], ...] = ( + ("OPENROUTER_API_KEY", "openrouter"), + ("ANTHROPIC_API_KEY", "anthropic"), + ("OPENAI_API_KEY", "openai"), + ("GOOGLE_API_KEY", "google"), + ("GEMINI_API_KEY", "google"), +) + + +@dataclass(slots=True) +class CloudProvider: + """A detected cloud provider + the env var it came from.""" + + provider: str + env_var: str + api_key: str + + def __repr__(self) -> str: + return ( + f"CloudProvider(provider={self.provider!r}, " + f"env_var={self.env_var!r}, api_key='{_REDACTED_PLACEHOLDER}')" + ) + + +def detect_cloud_keys() -> Optional[CloudProvider]: + """Return the first matching cloud provider per precedence order, else None. + + Empty-string values are treated as unset (matches shell convention). + """ + for env_var, provider in _KEY_TO_PROVIDER: + value = os.environ.get(env_var, "") + if value: + return CloudProvider(provider=provider, env_var=env_var, api_key=value) + return None + + +# --------------------------------------------------------------------------- +# Initial config writer +# --------------------------------------------------------------------------- + +_DEFAULT_SOUL = "# Agent Persona\n\nYou are Jarvis, a helpful personal AI assistant.\n" +_DEFAULT_MEMORY = "# Agent Memory\n\n" +_DEFAULT_USER = "# User Profile\n\n" + + +def _toml_quote(value: str) -> str: + """Escape a runtime value for use inside TOML "..." double-quoted string. + + Per TOML spec: backslash and double-quote must be backslash-escaped in + basic strings. Other control chars are not common in our values, so we + don't escape them — keeping this helper minimal. + """ + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def _now_iso() -> str: + return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _installer_version() -> str: + return openjarvis.__version__ + + +def _render_provenance_lines() -> str: + return ( + f"installed_at = {_toml_quote(_now_iso())}\n" + f"installer_version = {_toml_quote(_installer_version())}\n" + ) + + +def write_initial_config( + *, + hardware: HardwareInfo, + engine: str, + model: str, + cloud: Optional[CloudProvider] = None, +) -> Path: + """Render the initial ``config.toml`` and seed memory files. + + Called by both ``install.sh`` (via ``jarvis _bootstrap --write-config``) + and ``jarvis init`` so the TOML format has one definition. + """ + _cfg.DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True) + + gpu_comment = "" + if hardware.gpu: + mem_label = "unified memory" if hardware.gpu.vendor == "apple" else "VRAM" + gpu_comment = ( + f"\n# GPU: {hardware.gpu.name} ({hardware.gpu.vram_gb} GB {mem_label})" + ) + + intelligence_section = f"default_model = {_toml_quote(model)}" + if cloud is not None: + intelligence_section += f"\nprovider = {_toml_quote(cloud.provider)}" + + # Provenance must come before table declarations to be top-level keys. + provenance = _render_provenance_lines().rstrip("\n") + + hardware_line = ( + f"# Hardware: {hardware.cpu_brand} " + f"({hardware.cpu_count} cores, {hardware.ram_gb} GB RAM)" + ) + + base_toml = ( + f"# OpenJarvis configuration\n" + f"{hardware_line}{gpu_comment}\n" + f"# Full reference config: jarvis init --full\n" + f"\n" + f"{provenance}\n" + f"\n" + f"[engine]\n" + f"default = {_toml_quote(engine)}\n" + f"\n" + f"[engine.{engine}]\n" + f"# host = " + f'"http://localhost:11434" ' + f"# set to remote URL if engine runs elsewhere\n" + f"\n" + f"[intelligence]\n" + f"{intelligence_section}\n" + f"\n" + f"[agent]\n" + f'default_agent = "simple"\n' + f"\n" + f"[tools]\n" + f'enabled = ["code_interpreter", "web_search", ' + f'"file_read", "shell_exec"]\n' + ) + + _cfg.DEFAULT_CONFIG_PATH.write_text(base_toml) + + _seed_memory_files() + + return _cfg.DEFAULT_CONFIG_PATH + + +def _seed_memory_files() -> None: + """Create SOUL.md / MEMORY.md / USER.md / skills/ if absent.""" + home = _cfg.DEFAULT_CONFIG_DIR + if not (home / "SOUL.md").exists(): + (home / "SOUL.md").write_text(_DEFAULT_SOUL) + if not (home / "MEMORY.md").exists(): + (home / "MEMORY.md").write_text(_DEFAULT_MEMORY) + if not (home / "USER.md").exists(): + (home / "USER.md").write_text(_DEFAULT_USER) + (home / "skills").mkdir(exist_ok=True) + + +# --------------------------------------------------------------------------- +# CLI command — invoked by install.sh, hidden from `jarvis --help` +# --------------------------------------------------------------------------- + +# Default model picked at install time when a cloud key is detected via +# --prefer-cloud-when-available. These IDs will rot as new model versions +# ship; bump them when sub-project A's release notes track new defaults. +_CLOUD_PROVIDER_DEFAULT_MODELS: dict[str, str] = { + "openrouter": "anthropic/claude-opus-4-6", + "anthropic": "claude-opus-4-6", + "openai": "gpt-5", + "google": "gemini-3-pro", +} + + +@click.command("_bootstrap", hidden=True) +@click.option( + "--write-config", + is_flag=True, + default=False, + help="Render config.toml from detected hardware + provided engine/model.", +) +@click.option( + "--engine", + default="", + help="Inference engine slug (e.g. ollama). Empty = auto-recommend.", +) +@click.option( + "--model", + default="", + help="Model id (e.g. qwen3.5:2b). Empty = auto-recommend.", +) +@click.option( + "--prefer-cloud-when-available", + is_flag=True, + default=False, + help="If a known cloud-API key is in env, override engine/model to cloud.", +) +def bootstrap_cmd( + write_config: bool, + engine: str, + model: str, + prefer_cloud_when_available: bool, +) -> None: + """Internal helper used by install.sh — not for direct user invocation.""" + if not write_config: + raise click.UsageError("--write-config is required") + + hw = detect_hardware() + chosen_engine = engine or recommend_engine(hw) + chosen_model = model or recommend_model(hw, chosen_engine) + + cloud = None + if prefer_cloud_when_available: + cloud = detect_cloud_keys() + if cloud is not None: + chosen_engine = "cloud" + chosen_model = _CLOUD_PROVIDER_DEFAULT_MODELS.get( + cloud.provider, chosen_model + ) + + write_initial_config( + hardware=hw, + engine=chosen_engine, + model=chosen_model, + cloud=cloud, + ) + click.echo(f"Wrote {_cfg.DEFAULT_CONFIG_PATH}") diff --git a/src/openjarvis/cli/_chat_banner.py b/src/openjarvis/cli/_chat_banner.py new file mode 100644 index 00000000..ab779f90 --- /dev/null +++ b/src/openjarvis/cli/_chat_banner.py @@ -0,0 +1,29 @@ +"""Chat startup banner — renders BgStatus as a one-line status string.""" + +from __future__ import annotations + +from openjarvis.cli._bg_state import BgStatus + + +def render_startup_banner(status: BgStatus) -> str: + """Return a single-line banner string, or empty if all background work is done.""" + if status.all_ready(): + return "" + + parts: list[str] = [] + + if status.rust_extension == "pending": + parts.append("Rust extension building") + elif status.rust_extension == "failed": + parts.append("⚠ Rust extension failed (run `jarvis doctor`)") + + for model_id, state in status.models.items(): + if state == "downloading": + parts.append(f"{model_id} downloading") + elif state == "failed": + parts.append(f"⚠ {model_id} failed (run `jarvis doctor`)") + # 'ready' models don't show in the banner. + + if not parts: + return "" + return "Background: " + " · ".join(parts) + ". Type to start." diff --git a/src/openjarvis/cli/_chat_notifications.py b/src/openjarvis/cli/_chat_notifications.py new file mode 100644 index 00000000..f1701e52 --- /dev/null +++ b/src/openjarvis/cli/_chat_notifications.py @@ -0,0 +1,48 @@ +"""Between-turn completion notifications for background work.""" + +from __future__ import annotations + +from openjarvis.cli._bg_state import BgStatus + + +class NotificationDispatcher: + """Compute one-shot notifications when background tasks transition state. + + Construct with the BgStatus snapshot at chat start; call ``diff(latest)`` + after each turn to get a list of new notification strings. Each + transition fires exactly once per session. + """ + + def __init__(self, initial: BgStatus) -> None: + self._reported_rust: bool = initial.rust_extension in ("ready", "failed") + self._reported_models: set[str] = { + m for m, s in initial.models.items() if s in ("ready", "failed") + } + + def diff(self, latest: BgStatus) -> list[str]: + msgs: list[str] = [] + + if not self._reported_rust: + if latest.rust_extension == "ready": + msgs.append("✓ Rust extension ready — memory features unlocked") + self._reported_rust = True + elif latest.rust_extension == "failed": + msgs.append( + "⚠ Rust extension failed to build (memory features unavailable). " + "Run `jarvis doctor` for details." + ) + self._reported_rust = True + + for model_id, state in latest.models.items(): + if model_id in self._reported_models: + continue + if state == "ready": + msgs.append(f"✓ {model_id} ready — try `/model {model_id}`") + self._reported_models.add(model_id) + elif state == "failed": + msgs.append( + f"⚠ {model_id} download failed. Run `jarvis doctor` for details." + ) + self._reported_models.add(model_id) + + return msgs diff --git a/src/openjarvis/cli/_first_run.py b/src/openjarvis/cli/_first_run.py new file mode 100644 index 00000000..e15f634a --- /dev/null +++ b/src/openjarvis/cli/_first_run.py @@ -0,0 +1,35 @@ +"""Bare-`jarvis` first-run guard. + +When the user types ``jarvis`` with no subcommand, route them to the +chat command if a config exists, otherwise into the init wizard with +the ``--from-bare-jarvis`` flag (which lets init suppress the +launch-chat prompt and auto-confirm downstream questions). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from openjarvis.core import config as _cfg + +if TYPE_CHECKING: + import click + + +def check_and_route(ctx: click.Context) -> None: + """Called from the root group when no subcommand is invoked. + + Returns None and does nothing if a subcommand is being invoked + (the user typed something specific like ``jarvis ask``). + """ + if ctx.invoked_subcommand is not None: + return + + # Late imports to avoid circular import with cli/__init__.py. + from openjarvis.cli.chat_cmd import chat as chat_cmd + from openjarvis.cli.init_cmd import init as init_cmd + + if _cfg.DEFAULT_CONFIG_PATH.exists(): + ctx.invoke(chat_cmd) + else: + ctx.invoke(init_cmd, from_bare_jarvis=True) diff --git a/src/openjarvis/cli/chat_cmd.py b/src/openjarvis/cli/chat_cmd.py index d1b7731c..315d60d8 100644 --- a/src/openjarvis/cli/chat_cmd.py +++ b/src/openjarvis/cli/chat_cmd.py @@ -133,6 +133,19 @@ def chat( f" Type /help for commands, /quit to exit.\n" ) + # Background-work status banner (disappears after first user message) + from openjarvis.cli._bg_state import get_status + from openjarvis.cli._chat_banner import render_startup_banner + + _banner = render_startup_banner(get_status()) + if _banner: + console.print(f"[dim cyan]{_banner}[/dim cyan]") + + # Completion-notification dispatcher (fires once per task per session) + from openjarvis.cli._chat_notifications import NotificationDispatcher + + _notifications = NotificationDispatcher(get_status()) + # Conversation state history: List[Message] = [] if system_prompt: @@ -140,6 +153,9 @@ def chat( # REPL loop while True: + for note in _notifications.diff(get_status()): + console.print(f"[dim cyan]{note}[/dim cyan]") + user_input = _read_input() if user_input is None: console.print("\n[dim]Goodbye![/dim]") diff --git a/src/openjarvis/cli/doctor_cmd.py b/src/openjarvis/cli/doctor_cmd.py index 07f35c2e..f9db7232 100644 --- a/src/openjarvis/cli/doctor_cmd.py +++ b/src/openjarvis/cli/doctor_cmd.py @@ -351,3 +351,39 @@ def doctor(as_json: bool) -> None: console.print() console.print(f" {ok_count} passed, {warn_count} warnings, {fail_count} failures") console.print() + + # Background tasks section + from openjarvis.cli._bg_state import get_status + + console.print("[bold]Background tasks[/bold]") + bg = get_status() + bg_failed = False + + if bg.rust_extension == "ready": + console.print(" [green]✓[/green] Rust extension: ready") + elif bg.rust_extension == "failed": + console.print(f" [red]✗[/red] Rust extension: failed — {bg.rust_error[:80]}") + console.print( + " retry: ~/.openjarvis/.scripts/install-rust.sh && " + "~/.openjarvis/.scripts/build-extension.sh" + ) + bg_failed = True + else: + console.print( + " [yellow]…[/yellow] Rust extension: building (run in background)" + ) + + if not bg.models: + console.print(" [dim]no model downloads tracked[/dim]") + for model_id, state in bg.models.items(): + if state == "ready": + console.print(f" [green]✓[/green] {model_id}: ready") + elif state == "failed": + console.print(f" [red]✗[/red] {model_id}: failed") + console.print(f" retry: ~/.openjarvis/.scripts/pull-model.sh {model_id}") + bg_failed = True + else: + console.print(f" [yellow]…[/yellow] {model_id}: downloading") + + if bg_failed: + raise click.exceptions.Exit(code=1) diff --git a/src/openjarvis/cli/init_cmd.py b/src/openjarvis/cli/init_cmd.py index 56280e58..e69baf09 100644 --- a/src/openjarvis/cli/init_cmd.py +++ b/src/openjarvis/cli/init_cmd.py @@ -11,11 +11,13 @@ from rich.console import Console from rich.markup import escape from rich.panel import Panel +from openjarvis.cli._bootstrap import detect_cloud_keys from openjarvis.cli.model import find_model_spec, hf_download, ollama_pull from openjarvis.cli.scan_cmd import PrivacyScanner from openjarvis.core.config import ( DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH, + _available_memory_gb, detect_hardware, estimated_download_gb, generate_default_toml, @@ -281,6 +283,13 @@ def _do_download(engine: str, model: str, spec, console: Console) -> None: default=None, help="Use a pre-built starter config instead of generating one.", ) +@click.option( + "--from-bare-jarvis", + is_flag=True, + default=False, + hidden=True, + help="Run init non-interactively; called by the bare-jarvis first-run guard.", +) def init( force: bool, config: Optional[Path], @@ -291,10 +300,20 @@ def init( host: Optional[str] = None, enable_digest: bool = False, preset: Optional[str] = None, + from_bare_jarvis: bool = False, ) -> None: """Detect hardware and generate ~/.openjarvis/config.toml.""" console = Console() + # Cloud auto-detect — inform user if a key is in env. + detected_cloud = detect_cloud_keys() + if detected_cloud is not None: + console.print( + f"[cyan]Detected cloud key in env:[/cyan] {detected_cloud.env_var} " + f"(provider: {detected_cloud.provider}). " + f"Cloud inference is available via this key." + ) + if DEFAULT_CONFIG_PATH.exists() and not force: console.print( f"[yellow]Config already exists at {DEFAULT_CONFIG_PATH}[/yellow]" @@ -349,54 +368,58 @@ def init( # Resolve engine: explicit flag > interactive selection > auto-detect if engine is None and config is None: recommended = recommend_engine(hw) - console.print() - console.print("[bold]Detecting running inference engines...[/bold]") - running = _detect_running_engines() - if running: - console.print(f" Found running: [green]{', '.join(running)}[/green]") + # Bare-jarvis cold path: use the recommended engine non-interactively. + if from_bare_jarvis: + engine = recommended else: - console.print(" No running engines detected.") + console.print() + console.print("[bold]Detecting running inference engines...[/bold]") + running = _detect_running_engines() + if running: + console.print(f" Found running: [green]{', '.join(running)}[/green]") + else: + console.print(" No running engines detected.") - # Build choices: show running engines first, then recommended, then rest - seen: set[str] = set() - choices: list[str] = [] - for r in running: - if r not in seen: - choices.append(r) - seen.add(r) - if recommended not in seen: - choices.append(recommended) - seen.add(recommended) - for e in _SUPPORTED_ENGINES: - if e not in seen: - choices.append(e) - seen.add(e) + # Build choices: show running engines first, then recommended, then rest + seen: set[str] = set() + choices: list[str] = [] + for r in running: + if r not in seen: + choices.append(r) + seen.add(r) + if recommended not in seen: + choices.append(recommended) + seen.add(recommended) + for e in _SUPPORTED_ENGINES: + if e not in seen: + choices.append(e) + seen.add(e) - # Default: first running engine, or hardware recommendation - default = running[0] if running else recommended + # Default: first running engine, or hardware recommendation + default = running[0] if running else recommended - labels = [] - for c in choices: - parts = [c] - if c in running: - parts.append("running") - if c == recommended: - parts.append("recommended") - labels.append( - f" {c}" + (f" ({', '.join(parts[1:])})" if len(parts) > 1 else "") + labels = [] + for c in choices: + parts = [c] + if c in running: + parts.append("running") + if c == recommended: + parts.append("recommended") + labels.append( + f" {c}" + (f" ({', '.join(parts[1:])})" if len(parts) > 1 else "") + ) + + console.print() + console.print("[bold]Available engines:[/bold]") + for label in labels: + console.print(label) + + engine = click.prompt( + "\nSelect inference engine", + type=click.Choice(choices, case_sensitive=False), + default=default, ) - console.print() - console.print("[bold]Available engines:[/bold]") - for label in labels: - console.print(label) - - engine = click.prompt( - "\nSelect inference engine", - type=click.Choice(choices, case_sensitive=False), - default=default, - ) - # Probe remote host if specified if host: console.print("\n[bold]Checking remote host...[/bold]") @@ -505,15 +528,13 @@ sources = ["hackernews", "news_rss"] else: spec = find_model_spec(model) size_gb = estimated_download_gb(spec.parameter_count_b) if spec else 0 - from openjarvis.core.config import _available_memory_gb - avail = _available_memory_gb(hw) console.print( f"\n [bold]Recommended model:[/bold] {model} (~{size_gb:.1f} GB)" f" [dim](selected for {avail:.0f} GB available memory)[/dim]" ) - if not no_download and spec: + if not no_download and not from_bare_jarvis and spec: prompt = f" Download {model} (~{size_gb:.1f} GB) now?" if click.confirm(prompt, default=True): _do_download(selected_engine, model, spec, console) diff --git a/src/openjarvis/core/config.py b/src/openjarvis/core/config.py index c94d2de6..53c17c5a 100644 --- a/src/openjarvis/core/config.py +++ b/src/openjarvis/core/config.py @@ -1353,6 +1353,8 @@ class DigestConfig: class JarvisConfig: """Top-level configuration for OpenJarvis.""" + installed_at: str = "" + installer_version: str = "" hardware: HardwareInfo = field(default_factory=HardwareInfo) engine: EngineConfig = field(default_factory=EngineConfig) intelligence: IntelligenceConfig = field(default_factory=IntelligenceConfig) @@ -1600,6 +1602,11 @@ def load_config(path: Optional[Path] = None) -> JarvisConfig: if "memory" in data: _apply_toml_section(cfg.tools.storage, data["memory"]) + # Top-level install provenance (installed_at, installer_version) + for key in ("installed_at", "installer_version"): + if key in data: + setattr(cfg, key, data[key]) + # Expand security profile (user TOML overrides take precedence) _user_security_keys = set(data.get("security", {}).keys()) apply_security_profile(cfg.security, cfg.server, overrides=_user_security_keys) diff --git a/tests/install/bash/stubs/cargo b/tests/install/bash/stubs/cargo new file mode 100755 index 00000000..7895a2d3 --- /dev/null +++ b/tests/install/bash/stubs/cargo @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# Stub: presence is the point — file existing in PATH = cargo "installed". +exit 0 diff --git a/tests/install/bash/stubs/curl b/tests/install/bash/stubs/curl new file mode 100755 index 00000000..3a2263bd --- /dev/null +++ b/tests/install/bash/stubs/curl @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Stub for curl — records args, returns canned output. +echo "$@" >> "${CURL_STUB_LOG:-/dev/null}" +if [[ "${CURL_STUB_OUTPUT_FILE:-}" ]]; then + cat "$CURL_STUB_OUTPUT_FILE" +fi +exit "${CURL_STUB_EXIT:-0}" diff --git a/tests/install/bash/stubs/git b/tests/install/bash/stubs/git new file mode 100755 index 00000000..f631e617 --- /dev/null +++ b/tests/install/bash/stubs/git @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Stub for git — supports clone and pull, returns success. +# For clone: creates a minimal fake repo tree at the target with the install scripts. +# Log the full command before any shifts. +echo "$@" >> "${GIT_STUB_LOG:-/dev/null}" +case "$1" in + clone) + # Args after `clone`: optional flags, then [url] [target]. Find the last two non-flag args. + target="" + url="" + shift # past 'clone' + while [[ $# -gt 0 ]]; do + if [[ "$1" == --* ]] || [[ "$1" == -* ]]; then + shift # skip flag and its possible value + if [[ $# -gt 0 ]] && [[ "$1" != --* ]] && [[ "$1" != -* ]] && [[ "$1" =~ ^[0-9]+$ ]]; then + shift # depth=N etc. + fi + continue + fi + if [[ -z "$url" ]]; then + url="$1" + else + target="$1" + fi + shift + done + if [[ -z "$target" ]]; then + target="${url##*/}" + target="${target%.git}" + fi + mkdir -p "$target/scripts/install" + # Provide minimal expected files so install.sh can copy them. + for s in install-rust.sh build-extension.sh pull-model.sh bg-orchestrator.sh \ + jarvis-wrapper.sh jarvis-uninstall.sh; do + cat > "$target/scripts/install/$s" < "$target/pyproject.toml" <> "${OLLAMA_STUB_LOG:-/dev/null}" +exit "${OLLAMA_STUB_EXIT:-0}" diff --git a/tests/install/bash/stubs/ollama-mock b/tests/install/bash/stubs/ollama-mock new file mode 100755 index 00000000..1a6f7a3c --- /dev/null +++ b/tests/install/bash/stubs/ollama-mock @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# ollama-mock — minimal stand-in for `ollama` in CI integration tests. +# Responds to: pull, serve, list. + +case "$1" in + pull) + mkdir -p "${OLLAMA_MOCK_DIR:-/tmp/ollama-mock}/models" + touch "${OLLAMA_MOCK_DIR:-/tmp/ollama-mock}/models/$2" + echo "pulling $2 (mocked)" + sleep 0.1 + ;; + serve) + echo "ollama-mock serve started" + exec sleep infinity + ;; + list) + ls "${OLLAMA_MOCK_DIR:-/tmp/ollama-mock}/models" 2>/dev/null || true + ;; + stop) + ;; + *) + echo "ollama-mock: unsupported subcommand: $1" >&2 + exit 1 + ;; +esac diff --git a/tests/install/bash/stubs/rustup b/tests/install/bash/stubs/rustup new file mode 100755 index 00000000..a513e912 --- /dev/null +++ b/tests/install/bash/stubs/rustup @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Stub for rustup curl-pipe-bash installer — does nothing. +echo "$@" >> "${RUSTUP_STUB_LOG:-/dev/null}" +exit 0 diff --git a/tests/install/bash/stubs/uv b/tests/install/bash/stubs/uv new file mode 100755 index 00000000..152fe72c --- /dev/null +++ b/tests/install/bash/stubs/uv @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Stub for `uv` — records args, exits with $UV_STUB_EXIT. +echo "$@" >> "${UV_STUB_LOG:-/dev/null}" +exit "${UV_STUB_EXIT:-0}" diff --git a/tests/install/bash/test_bg_orchestrator.bats b/tests/install/bash/test_bg_orchestrator.bats new file mode 100755 index 00000000..ef94a9bf --- /dev/null +++ b/tests/install/bash/test_bg_orchestrator.bats @@ -0,0 +1,57 @@ +#!/usr/bin/env bats + +setup() { + TEST_TMPDIR=$(mktemp -d) + export OPENJARVIS_HOME="$TEST_TMPDIR/.openjarvis" + mkdir -p "$OPENJARVIS_HOME/.state/models" + mkdir -p "$OPENJARVIS_HOME/.scripts" + # Provide mock scripts that all return success and write the expected state files. + cat > "$OPENJARVIS_HOME/.scripts/install-rust.sh" < "$OPENJARVIS_HOME/.scripts/build-extension.sh" < "$OPENJARVIS_HOME/.scripts/pull-model.sh" < "$UV_STUB_LOG" + export PATH="$BATS_TEST_DIRNAME/stubs:$PATH" + export SCRIPT="$BATS_TEST_DIRNAME/../../../scripts/install/build-extension.sh" +} + +teardown() { + [[ -n "${TEST_TMPDIR:-}" ]] && rm -rf "$TEST_TMPDIR" +} + +@test "writes extension-built marker on success" { + UV_STUB_EXIT=0 run bash "$SCRIPT" + [ "$status" -eq 0 ] + [ -f "$OPENJARVIS_HOME/.state/extension-built" ] +} + +@test "writes extension-failed marker on failure" { + UV_STUB_EXIT=1 run bash "$SCRIPT" + [ "$status" -ne 0 ] + [ -f "$OPENJARVIS_HOME/.state/extension-failed" ] + [ -s "$OPENJARVIS_HOME/.state/extension-failed" ] +} + +@test "removes prior failed marker on success" { + touch "$OPENJARVIS_HOME/.state/extension-failed" + UV_STUB_EXIT=0 run bash "$SCRIPT" + [ ! -f "$OPENJARVIS_HOME/.state/extension-failed" ] + [ -f "$OPENJARVIS_HOME/.state/extension-built" ] +} + +@test "calls uv run maturin develop with the right manifest" { + UV_STUB_EXIT=0 run bash "$SCRIPT" + grep -q "run maturin develop -m" "$UV_STUB_LOG" + grep -q "rust/crates/openjarvis-python/Cargo.toml" "$UV_STUB_LOG" +} + +@test "atomic rename — no .tmp file left behind" { + UV_STUB_EXIT=0 run bash "$SCRIPT" + leftover=$(find "$OPENJARVIS_HOME/.state" -name "*.tmp" 2>/dev/null | wc -l) + [ "$leftover" -eq 0 ] +} diff --git a/tests/install/bash/test_install.bats b/tests/install/bash/test_install.bats new file mode 100755 index 00000000..19ba5766 --- /dev/null +++ b/tests/install/bash/test_install.bats @@ -0,0 +1,175 @@ +#!/usr/bin/env bats + +setup() { + TEST_TMPDIR=$(mktemp -d) + export FAKE_HOME="$TEST_TMPDIR/home" + mkdir -p "$FAKE_HOME" + export HOME="$FAKE_HOME" + export OPENJARVIS_HOME="$FAKE_HOME/.openjarvis" + + export STUBS_DIR="$BATS_TEST_DIRNAME/stubs" + # Use a copy of stubs without uv-style real binaries that might be on the host. + # Just put our stubs first in PATH. + export PATH="$STUBS_DIR:/usr/bin:/bin" + + export GIT_STUB_LOG="$TEST_TMPDIR/git.log" + export OLLAMA_STUB_LOG="$TEST_TMPDIR/ollama.log" + export UV_STUB_LOG="$TEST_TMPDIR/uv.log" + export CURL_STUB_LOG="$TEST_TMPDIR/curl.log" + : > "$GIT_STUB_LOG" + : > "$OLLAMA_STUB_LOG" + : > "$UV_STUB_LOG" + : > "$CURL_STUB_LOG" + + # uv stub needs to fake creating a venv with a jarvis binary. + # Replace the stubs/uv with one that creates the venv tree on `venv` command. + # Since we don't want to mutate the real stub, override via a per-test stub dir. + export PER_TEST_STUBS="$TEST_TMPDIR/stubs" + mkdir -p "$PER_TEST_STUBS" + cat "$STUBS_DIR/uv" > "$PER_TEST_STUBS/uv" + chmod +x "$PER_TEST_STUBS/uv" + # Wrap uv to handle 'venv' specially. + cat > "$PER_TEST_STUBS/uv" <> "\$UV_STUB_LOG" +case "\$1" in + venv) + # uv venv [--python X.Y] + # Find the path arg (last non-flag). + for a in "\$@"; do + if [[ "\$a" != --* ]] && [[ "\$a" != -* ]] && [[ "\$a" != "venv" ]] && [[ ! "\$a" =~ ^[0-9]+\\.[0-9]+\$ ]]; then + venv_path="\$a" + fi + done + if [[ -n "\$venv_path" ]]; then + mkdir -p "\$venv_path/bin" + cat > "\$venv_path/bin/jarvis" <<'EOJ' +#!/usr/bin/env bash +# fake jarvis for tests +echo "fake jarvis: \$@" +exit 0 +EOJ + chmod +x "\$venv_path/bin/jarvis" + cat > "\$venv_path/bin/python" <<'EOJ' +#!/usr/bin/env bash +# fake python that prints empty for the inline embedded scripts (recommend_model, etc.) +exit 0 +EOJ + chmod +x "\$venv_path/bin/python" + fi + ;; +esac +exit 0 +EOF + chmod +x "$PER_TEST_STUBS/uv" + + # Copy other stubs, with the per-test uv first. + cp "$STUBS_DIR"/{git,curl,ollama,cargo,rustup} "$PER_TEST_STUBS/" + chmod +x "$PER_TEST_STUBS"/* + + export PATH="$PER_TEST_STUBS:/usr/bin:/bin" + + export SCRIPT="$BATS_TEST_DIRNAME/../../../scripts/install/install.sh" +} + +teardown() { + [[ -n "${TEST_TMPDIR:-}" ]] && rm -rf "$TEST_TMPDIR" +} + +@test "refuses to run as root" { + # EUID is readonly in bash, so we stub `id` to return uid 0 instead. + local root_stubs="$TEST_TMPDIR/root_stubs" + mkdir -p "$root_stubs" + cp "$PER_TEST_STUBS"/* "$root_stubs/" + cat > "$root_stubs/id" <<'IDEOF' +#!/usr/bin/env bash +if [[ "$1" == "-u" ]]; then echo 0; else echo "uid=0(root) gid=0(root) groups=0(root)"; fi +IDEOF + chmod +x "$root_stubs/id" + PATH="$root_stubs:/usr/bin:/bin" run bash "$SCRIPT" --no-bg-orchestrator + [ "$status" -ne 0 ] + echo "$output" | grep -qi "root" +} + +@test "creates ~/.openjarvis directory tree" { + run bash "$SCRIPT" --no-bg-orchestrator + [ "$status" -eq 0 ] + [ -d "$OPENJARVIS_HOME/src" ] + [ -d "$OPENJARVIS_HOME/.state" ] + [ -d "$OPENJARVIS_HOME/.scripts" ] +} + +@test "writes install-state.json on success" { + run bash "$SCRIPT" --no-bg-orchestrator + [ "$status" -eq 0 ] + [ -f "$OPENJARVIS_HOME/.state/install-state.json" ] +} + +@test "calls git clone for the OpenJarvis repo" { + run bash "$SCRIPT" --no-bg-orchestrator + grep -q "clone" "$GIT_STUB_LOG" +} + +@test "creates jarvis symlink in ~/.local/bin" { + run bash "$SCRIPT" --no-bg-orchestrator + [ "$status" -eq 0 ] + [ -L "$FAKE_HOME/.local/bin/jarvis" ] || [ -f "$FAKE_HOME/.local/bin/jarvis" ] +} + +@test "is idempotent — second run skips completed steps" { + run bash "$SCRIPT" --no-bg-orchestrator + [ "$status" -eq 0 ] + : > "$GIT_STUB_LOG" + run bash "$SCRIPT" --no-bg-orchestrator + [ "$status" -eq 0 ] + ! grep -q "clone" "$GIT_STUB_LOG" +} + +@test "detects WSL2 and writes platform note to install-state" { + OPENJARVIS_FORCE_WSL=1 run bash "$SCRIPT" --no-bg-orchestrator + [ "$status" -eq 0 ] + grep -q "wsl" "$OPENJARVIS_HOME/.state/install-state.json" +} + +@test "fails loudly when git is missing" { + # Build a minimal stubs dir that has curl/uv/ollama but NO git. + # We must NOT include /usr/bin or /bin in PATH because those dirs have real + # git binaries on this system. Instead, provide only the commands the script + # needs before it reaches the `need git` check (id, cat, bash). + local no_git_stubs="$TEST_TMPDIR/no_git_stubs" + mkdir -p "$no_git_stubs" + for f in curl uv ollama cargo rustup; do + cp "$PER_TEST_STUBS/$f" "$no_git_stubs/" + chmod +x "$no_git_stubs/$f" + done + # Provide minimal system utilities (no git) using explicit symlinks. + cat > "$no_git_stubs/id" <<'IDEOF' +#!/bin/bash +echo "1000" +IDEOF + chmod +x "$no_git_stubs/id" + ln -sf /bin/cat "$no_git_stubs/cat" + ln -sf /bin/bash "$no_git_stubs/bash" + PATH="$no_git_stubs" run bash "$SCRIPT" --no-bg-orchestrator + [ "$status" -ne 0 ] + echo "$output" | grep -qi "git" +} + +@test "--force re-runs completed steps" { + run bash "$SCRIPT" --no-bg-orchestrator + [ "$status" -eq 0 ] + : > "$GIT_STUB_LOG" + run bash "$SCRIPT" --no-bg-orchestrator --force + [ "$status" -eq 0 ] + # With --force, second run re-clones (clone is in git.log again). + grep -q "clone" "$GIT_STUB_LOG" +} + +@test "--minimal skips foreground model pull" { + run bash "$SCRIPT" --no-bg-orchestrator --minimal + [ "$status" -eq 0 ] + # Ollama 'pull' should NOT have been called. + if [ -s "$OLLAMA_STUB_LOG" ]; then + ! grep -q "^pull " "$OLLAMA_STUB_LOG" + fi +} diff --git a/tests/install/bash/test_install_rust.bats b/tests/install/bash/test_install_rust.bats new file mode 100755 index 00000000..7a123069 --- /dev/null +++ b/tests/install/bash/test_install_rust.bats @@ -0,0 +1,35 @@ +#!/usr/bin/env bats + +setup() { + TEST_TMPDIR=$(mktemp -d) + export STUBS_DIR="$BATS_TEST_DIRNAME/stubs" + export STUBS_NO_CARGO="$TEST_TMPDIR/stubs-no-cargo" + mkdir -p "$STUBS_NO_CARGO" + # Copy all stubs except cargo into the no-cargo dir. + for s in "$STUBS_DIR"/*; do + name=$(basename "$s") + if [[ "$name" != "cargo" ]]; then + cp "$s" "$STUBS_NO_CARGO/" + fi + done + export RUSTUP_STUB_LOG="$TEST_TMPDIR/rustup.log" + export CURL_STUB_LOG="$TEST_TMPDIR/curl.log" + : > "$RUSTUP_STUB_LOG" + : > "$CURL_STUB_LOG" + export SCRIPT="$BATS_TEST_DIRNAME/../../../scripts/install/install-rust.sh" +} + +teardown() { + [[ -n "${TEST_TMPDIR:-}" ]] && rm -rf "$TEST_TMPDIR" +} + +@test "skips install when cargo already present" { + PATH="$STUBS_DIR:$PATH" run bash "$SCRIPT" + [ "$status" -eq 0 ] + [ ! -s "$CURL_STUB_LOG" ] +} + +@test "runs rustup curl-pipe-bash when cargo is missing" { + PATH="$STUBS_NO_CARGO:/usr/bin:/bin:/usr/local/bin" run bash "$SCRIPT" + grep -q "sh.rustup.rs" "$CURL_STUB_LOG" +} diff --git a/tests/install/bash/test_pull_model.bats b/tests/install/bash/test_pull_model.bats new file mode 100755 index 00000000..715f7164 --- /dev/null +++ b/tests/install/bash/test_pull_model.bats @@ -0,0 +1,48 @@ +#!/usr/bin/env bats +# Tests for scripts/install/pull-model.sh + +setup() { + # Use mktemp to ensure temp dir exists and is writable + TEST_TMPDIR=$(mktemp -d) + export OPENJARVIS_HOME="$TEST_TMPDIR/.openjarvis" + mkdir -p "$OPENJARVIS_HOME/.state/models" + export OLLAMA_STUB_LOG="$TEST_TMPDIR/ollama.log" + : > "$OLLAMA_STUB_LOG" + export PATH="$BATS_TEST_DIRNAME/stubs:$PATH" + export SCRIPT="$BATS_TEST_DIRNAME/../../../scripts/install/pull-model.sh" +} + +teardown() { + # Clean up temp directory + [[ -n "${TEST_TMPDIR:-}" ]] && rm -rf "$TEST_TMPDIR" +} + +@test "writes .downloading marker, then .ready on success" { + OLLAMA_STUB_EXIT=0 run bash "$SCRIPT" qwen3.5:2b + [ "$status" -eq 0 ] + [ -f "$OPENJARVIS_HOME/.state/models/qwen3.5:2b.ready" ] + [ ! -f "$OPENJARVIS_HOME/.state/models/qwen3.5:2b.downloading" ] +} + +@test "writes .failed on error after retries exhausted" { + OLLAMA_STUB_EXIT=1 run bash "$SCRIPT" qwen3.5:2b + [ "$status" -ne 0 ] + [ -f "$OPENJARVIS_HOME/.state/models/qwen3.5:2b.failed" ] +} + +@test "calls ollama pull with the right model name" { + OLLAMA_STUB_EXIT=0 run bash "$SCRIPT" qwen3.5:9b + grep -q "pull qwen3.5:9b" "$OLLAMA_STUB_LOG" +} + +@test "retries 3 times on failure" { + OLLAMA_STUB_EXIT=1 run bash "$SCRIPT" qwen3.5:2b + pull_count=$(grep -c "pull qwen3.5:2b" "$OLLAMA_STUB_LOG") + [ "$pull_count" -eq 3 ] +} + +@test "ready marker is created via atomic rename (no .tmp leftovers)" { + OLLAMA_STUB_EXIT=0 run bash "$SCRIPT" qwen3.5:2b + leftover=$(find "$OPENJARVIS_HOME/.state/models" -name "*.tmp" 2>/dev/null | wc -l) + [ "$leftover" -eq 0 ] +} diff --git a/tests/install/cases/README.md b/tests/install/cases/README.md new file mode 100644 index 00000000..b3f06fbe --- /dev/null +++ b/tests/install/cases/README.md @@ -0,0 +1,17 @@ +# Failure-mode regression catalog + +Every error-handling case from the [design doc §8](../../../docs/superpowers/specs/2026-05-03-cli-cold-start-refresh-design.md) is documented here, with: + +- The trigger conditions +- The expected user-visible behavior +- The retry / fix command +- The test that exercises it (in `tests/install/`) + +When a real-world bug report uncovers a new failure mode, add an entry here at the same time you add the regression test. + +## Index + +- [run-as-root.md](run-as-root.md) — installer refuses to run as root +- [missing-git.md](missing-git.md) — installer refuses without `git` on PATH + +(Add new entries as the catalog grows.) diff --git a/tests/install/cases/missing-git.md b/tests/install/cases/missing-git.md new file mode 100644 index 00000000..817828c3 --- /dev/null +++ b/tests/install/cases/missing-git.md @@ -0,0 +1,23 @@ +# Failure: git missing from PATH + +## Trigger + +`git` is not installed (e.g., fresh macOS without Xcode CLI tools, minimal Linux container). + +## Expected behavior + +- Hard fail at prereq probe before any state is created. +- stderr includes platform-specific install hint: + - macOS: `xcode-select --install` + - Debian/Ubuntu: `sudo apt install git curl` + - Fedora/RHEL: `sudo dnf install git curl` + - Arch: `sudo pacman -S git curl` +- Exit code: non-zero. + +## Retry + +Install `git` per the printed hint, then re-run the curl line. + +## Test + +`tests/install/bash/test_install.bats::"fails loudly when git is missing"` diff --git a/tests/install/cases/run-as-root.md b/tests/install/cases/run-as-root.md new file mode 100644 index 00000000..cf0ce3c7 --- /dev/null +++ b/tests/install/cases/run-as-root.md @@ -0,0 +1,23 @@ +# Failure: install.sh run as root + +## Trigger + +User runs `sudo bash install.sh` or invokes the installer from a root shell. + +## Expected behavior + +- Hard fail before any state is created. +- stderr includes "don't run as root" and a hint to re-run as the regular user. +- Exit code: non-zero. + +## Retry + +Re-run as the regular user (without sudo): + +```bash +curl -fsSL https://openjarvis.ai/install.sh | bash +``` + +## Test + +`tests/install/bash/test_install.bats::"refuses to run as root"` diff --git a/tests/install/conftest.py b/tests/install/conftest.py index aa9457c0..82994c56 100644 --- a/tests/install/conftest.py +++ b/tests/install/conftest.py @@ -11,17 +11,20 @@ import pytest def tmp_openjarvis_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: """Point ``DEFAULT_CONFIG_DIR`` at a tmpdir for isolated tests. - Yields the directory; teardown is automatic via tmp_path. + Returns the directory; teardown is automatic via tmp_path. """ home = tmp_path / ".openjarvis" home.mkdir() (home / ".state").mkdir() (home / ".state" / "models").mkdir() - monkeypatch.setattr( - "openjarvis.core.config.DEFAULT_CONFIG_DIR", home - ) - monkeypatch.setattr( - "openjarvis.core.config.DEFAULT_CONFIG_PATH", home / "config.toml" - ) + (home / ".scripts").mkdir() + config_path = home / "config.toml" + monkeypatch.setattr("openjarvis.core.config.DEFAULT_CONFIG_DIR", home) + monkeypatch.setattr("openjarvis.core.config.DEFAULT_CONFIG_PATH", config_path) + # Also patch init_cmd's module-level bindings (imported with ``from ... import``). + monkeypatch.setattr("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", home) + monkeypatch.setattr("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path) + # Patch doctor_cmd's module-level bindings. + monkeypatch.setattr("openjarvis.cli.doctor_cmd.DEFAULT_CONFIG_PATH", config_path) monkeypatch.setenv("HOME", str(tmp_path)) return home diff --git a/tests/install/test_bootstrap_cli.py b/tests/install/test_bootstrap_cli.py new file mode 100644 index 00000000..adf7d7b1 --- /dev/null +++ b/tests/install/test_bootstrap_cli.py @@ -0,0 +1,82 @@ +"""Tests for the jarvis _bootstrap hidden CLI command.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +from click.testing import CliRunner + +from openjarvis.cli import cli + + +def test_bootstrap_command_writes_config( + tmp_openjarvis_home: Path, monkeypatch +) -> None: + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "_bootstrap", + "--write-config", + "--engine", + "ollama", + "--model", + "qwen3.5:2b", + ], + ) + assert result.exit_code == 0, result.output + cfg = tmp_openjarvis_home / "config.toml" + assert cfg.exists() + data = tomllib.loads(cfg.read_text()) + assert data["engine"]["default"] == "ollama" + assert data["intelligence"]["default_model"] == "qwen3.5:2b" + + +def test_bootstrap_command_uses_cloud_key_when_present( + tmp_openjarvis_home: Path, monkeypatch +) -> None: + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test") + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "_bootstrap", + "--write-config", + "--prefer-cloud-when-available", + "--engine", + "ollama", + "--model", + "qwen3.5:2b", + ], + ) + assert result.exit_code == 0, result.output + data = tomllib.loads((tmp_openjarvis_home / "config.toml").read_text()) + # When --prefer-cloud-when-available is set and a key was found, + # we override engine to cloud. + assert data["engine"]["default"] == "cloud" + assert data["intelligence"]["provider"] == "anthropic" + + +def test_bootstrap_command_is_hidden_from_help( + tmp_openjarvis_home: Path, +) -> None: + runner = CliRunner() + result = runner.invoke(cli, ["--help"]) + assert "_bootstrap" not in result.output diff --git a/tests/install/test_bootstrap_config.py b/tests/install/test_bootstrap_config.py new file mode 100644 index 00000000..1a63e706 --- /dev/null +++ b/tests/install/test_bootstrap_config.py @@ -0,0 +1,126 @@ +"""Tests for openjarvis.cli._bootstrap.write_initial_config.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +from openjarvis.cli import _bootstrap +from openjarvis.core.config import GpuInfo, HardwareInfo + + +def test_writes_minimal_local_config(tmp_openjarvis_home: Path) -> None: + hw = HardwareInfo( + platform="linux", + cpu_brand="AMD EPYC", + cpu_count=16, + ram_gb=32.0, + gpu=GpuInfo(vendor="nvidia", name="RTX 4090", vram_gb=24.0, count=1), + ) + _bootstrap.write_initial_config(hardware=hw, engine="ollama", model="qwen3.5:2b") + cfg_path = tmp_openjarvis_home / "config.toml" + assert cfg_path.exists() + data = tomllib.loads(cfg_path.read_text()) + assert data["engine"]["default"] == "ollama" + assert data["intelligence"]["default_model"] == "qwen3.5:2b" + assert data["agent"]["default_agent"] == "simple" + + +def test_writes_cloud_config(tmp_openjarvis_home: Path) -> None: + hw = HardwareInfo(platform="darwin", cpu_brand="Apple M2", cpu_count=8, ram_gb=16.0) + cloud = _bootstrap.CloudProvider( + provider="anthropic", + env_var="ANTHROPIC_API_KEY", + api_key="sk-ant-test", + ) + _bootstrap.write_initial_config( + hardware=hw, engine="cloud", model="claude-opus-4-6", cloud=cloud + ) + data = tomllib.loads((tmp_openjarvis_home / "config.toml").read_text()) + assert data["engine"]["default"] == "cloud" + assert data["intelligence"]["default_model"] == "claude-opus-4-6" + assert data["intelligence"]["provider"] == "anthropic" + assert "sk-ant-test" not in (tmp_openjarvis_home / "config.toml").read_text() + + +def test_includes_install_provenance(tmp_openjarvis_home: Path, monkeypatch) -> None: + monkeypatch.setattr(_bootstrap, "_now_iso", lambda: "2026-05-03T12:00:00Z") + monkeypatch.setattr(_bootstrap, "_installer_version", lambda: "0.1.1") + hw = HardwareInfo(platform="linux", cpu_brand="x", cpu_count=1, ram_gb=4.0) + _bootstrap.write_initial_config(hardware=hw, engine="ollama", model="qwen3.5:2b") + data = tomllib.loads((tmp_openjarvis_home / "config.toml").read_text()) + assert data["installed_at"] == "2026-05-03T12:00:00Z" + assert data["installer_version"] == "0.1.1" + + +def test_writes_seed_files_if_absent(tmp_openjarvis_home: Path) -> None: + hw = HardwareInfo(platform="linux", cpu_brand="x", cpu_count=1, ram_gb=4.0) + _bootstrap.write_initial_config(hardware=hw, engine="ollama", model="qwen3.5:2b") + assert (tmp_openjarvis_home / "SOUL.md").exists() + assert (tmp_openjarvis_home / "MEMORY.md").exists() + assert (tmp_openjarvis_home / "USER.md").exists() + assert (tmp_openjarvis_home / "skills").is_dir() + + +def test_does_not_overwrite_existing_seeds(tmp_openjarvis_home: Path) -> None: + soul = tmp_openjarvis_home / "SOUL.md" + soul.write_text("custom user content\n") + hw = HardwareInfo(platform="linux", cpu_brand="x", cpu_count=1, ram_gb=4.0) + _bootstrap.write_initial_config(hardware=hw, engine="ollama", model="qwen3.5:2b") + assert soul.read_text() == "custom user content\n" + + +def test_overwrites_existing_config_toml(tmp_openjarvis_home: Path) -> None: + cfg = tmp_openjarvis_home / "config.toml" + cfg.write_text('[engine]\ndefault = "old"\n') + hw = HardwareInfo(platform="linux", cpu_brand="x", cpu_count=1, ram_gb=4.0) + _bootstrap.write_initial_config(hardware=hw, engine="ollama", model="qwen3.5:2b") + data = tomllib.loads(cfg.read_text()) + assert data["engine"]["default"] == "ollama" + + +def test_handles_special_chars_in_model_name(tmp_openjarvis_home: Path) -> None: + """Model names with TOML-special chars (\\ and ") must produce valid TOML.""" + hw = HardwareInfo(platform="linux", cpu_brand="x", cpu_count=1, ram_gb=4.0) + weird_model = 'my"weird\\model:1b' + _bootstrap.write_initial_config(hardware=hw, engine="ollama", model=weird_model) + data = tomllib.loads((tmp_openjarvis_home / "config.toml").read_text()) + assert data["intelligence"]["default_model"] == weird_model + + +def test_jarvis_config_has_install_provenance_fields() -> None: + """Top-level provenance fields should be addressable as attributes.""" + from openjarvis.core.config import JarvisConfig + + cfg = JarvisConfig() + assert hasattr(cfg, "installed_at") + assert hasattr(cfg, "installer_version") + assert cfg.installed_at == "" + assert cfg.installer_version == "" + + +def test_load_config_parses_provenance_from_toml( + tmp_openjarvis_home: Path, +) -> None: + """If config.toml has installed_at/installer_version at top level, load them.""" + from openjarvis.core.config import load_config + + cfg_path = tmp_openjarvis_home / "config.toml" + cfg_path.write_text( + 'installed_at = "2026-05-03T12:00:00Z"\n' + 'installer_version = "0.1.1"\n' + "\n" + "[engine]\n" + 'default = "ollama"\n' + ) + # Clear lru_cache so load_config reads our new file. + load_config.cache_clear() + cfg = load_config(cfg_path) + assert cfg.installed_at == "2026-05-03T12:00:00Z" + assert cfg.installer_version == "0.1.1" + load_config.cache_clear() diff --git a/tests/install/test_bootstrap_keys.py b/tests/install/test_bootstrap_keys.py new file mode 100644 index 00000000..bc5c6d15 --- /dev/null +++ b/tests/install/test_bootstrap_keys.py @@ -0,0 +1,101 @@ +"""Tests for openjarvis.cli._bootstrap.detect_cloud_keys.""" + +from __future__ import annotations + +import pytest + +from openjarvis.cli import _bootstrap + +ALL_KEYS = ( + "OPENROUTER_API_KEY", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "GOOGLE_API_KEY", + "GEMINI_API_KEY", +) + + +@pytest.fixture(autouse=True) +def _clear_keys(monkeypatch: pytest.MonkeyPatch) -> None: + for k in ALL_KEYS: + monkeypatch.delenv(k, raising=False) + + +def test_no_keys_returns_none() -> None: + assert _bootstrap.detect_cloud_keys() is None + + +def test_openrouter_alone(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") + p = _bootstrap.detect_cloud_keys() + assert p is not None + assert p.provider == "openrouter" + assert p.api_key == "sk-or-test" + + +def test_anthropic_alone(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test") + p = _bootstrap.detect_cloud_keys() + assert p is not None + assert p.provider == "anthropic" + + +def test_openai_alone(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "sk-oa-test") + p = _bootstrap.detect_cloud_keys() + assert p is not None + assert p.provider == "openai" + + +def test_google_alone(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GOOGLE_API_KEY", "google-test") + p = _bootstrap.detect_cloud_keys() + assert p is not None + assert p.provider == "google" + + +def test_gemini_alias(monkeypatch: pytest.MonkeyPatch) -> None: + """GEMINI_API_KEY is a recognized alias for GOOGLE_API_KEY.""" + monkeypatch.setenv("GEMINI_API_KEY", "gem-test") + p = _bootstrap.detect_cloud_keys() + assert p is not None + assert p.provider == "google" + + +def test_precedence_openrouter_over_others(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENROUTER_API_KEY", "or") + monkeypatch.setenv("ANTHROPIC_API_KEY", "ant") + monkeypatch.setenv("OPENAI_API_KEY", "oa") + p = _bootstrap.detect_cloud_keys() + assert p is not None + assert p.provider == "openrouter" + + +def test_precedence_anthropic_over_openai(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ANTHROPIC_API_KEY", "ant") + monkeypatch.setenv("OPENAI_API_KEY", "oa") + p = _bootstrap.detect_cloud_keys() + assert p is not None + assert p.provider == "anthropic" + + +def test_empty_string_treated_as_unset(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENROUTER_API_KEY", "") + monkeypatch.setenv("ANTHROPIC_API_KEY", "ant") + p = _bootstrap.detect_cloud_keys() + assert p is not None + assert p.provider == "anthropic" + + +def test_cloud_provider_repr_redacts_api_key() -> None: + """Default repr must NOT expose the api_key (logging safety).""" + p = _bootstrap.CloudProvider( + provider="openrouter", + env_var="OPENROUTER_API_KEY", + api_key="sk-or-secret-do-not-leak", + ) + r = repr(p) + assert "sk-or-secret-do-not-leak" not in r + assert "openrouter" in r + assert "OPENROUTER_API_KEY" in r + assert "redacted" in r.lower() or "***" in r diff --git a/tests/install/test_chat_banner.py b/tests/install/test_chat_banner.py new file mode 100644 index 00000000..a7ae80d6 --- /dev/null +++ b/tests/install/test_chat_banner.py @@ -0,0 +1,42 @@ +"""Tests for the chat startup banner.""" + +from __future__ import annotations + +from pathlib import Path + +from openjarvis.cli import _bg_state +from openjarvis.cli._chat_banner import render_startup_banner + + +def test_banner_empty_when_all_ready(tmp_openjarvis_home: Path) -> None: + (tmp_openjarvis_home / ".state" / "extension-built").write_text("") + (tmp_openjarvis_home / ".state" / "models" / "qwen3.5:9b.ready").write_text("") + s = _bg_state.get_status() + banner = render_startup_banner(s) + assert banner == "" + + +def test_banner_shows_rust_building(tmp_openjarvis_home: Path) -> None: + """Pending rust ext (no marker file) is shown as 'building'.""" + s = _bg_state.get_status() # all pending + banner = render_startup_banner(s) + assert "Rust extension" in banner + assert "building" in banner.lower() + + +def test_banner_shows_model_downloading(tmp_openjarvis_home: Path) -> None: + (tmp_openjarvis_home / ".state" / "extension-built").write_text("") + models_dir = tmp_openjarvis_home / ".state" / "models" + (models_dir / "qwen3.5:9b.downloading").write_text("") + s = _bg_state.get_status() + banner = render_startup_banner(s) + assert "qwen3.5:9b" in banner + assert "downloading" in banner.lower() + + +def test_banner_shows_failed_in_dim_warning(tmp_openjarvis_home: Path) -> None: + (tmp_openjarvis_home / ".state" / "extension-failed").write_text("error tail") + s = _bg_state.get_status() + banner = render_startup_banner(s) + assert "failed" in banner.lower() + assert "doctor" in banner.lower() diff --git a/tests/install/test_chat_notifications.py b/tests/install/test_chat_notifications.py new file mode 100644 index 00000000..511efdf4 --- /dev/null +++ b/tests/install/test_chat_notifications.py @@ -0,0 +1,50 @@ +"""Tests for between-turn completion notifications.""" + +from __future__ import annotations + +from openjarvis.cli._bg_state import BgStatus +from openjarvis.cli._chat_notifications import NotificationDispatcher + + +def test_no_notifications_when_unchanged() -> None: + initial = BgStatus(rust_extension="pending", models={"a": "downloading"}) + later = BgStatus(rust_extension="pending", models={"a": "downloading"}) + d = NotificationDispatcher(initial) + msgs = d.diff(later) + assert msgs == [] + + +def test_notifies_when_rust_becomes_ready() -> None: + initial = BgStatus(rust_extension="pending") + later = BgStatus(rust_extension="ready") + d = NotificationDispatcher(initial) + msgs = d.diff(later) + assert len(msgs) == 1 + assert "Rust extension" in msgs[0] + assert "ready" in msgs[0].lower() + + +def test_notifies_when_model_becomes_ready() -> None: + initial = BgStatus(models={"qwen3.5:9b": "downloading"}) + later = BgStatus(models={"qwen3.5:9b": "ready"}) + d = NotificationDispatcher(initial) + msgs = d.diff(later) + assert any("qwen3.5:9b" in m and "ready" in m.lower() for m in msgs) + + +def test_notifies_when_failed() -> None: + initial = BgStatus(rust_extension="pending") + later = BgStatus(rust_extension="failed", rust_error="x") + d = NotificationDispatcher(initial) + msgs = d.diff(later) + assert any("failed" in m.lower() for m in msgs) + + +def test_does_not_renotify_in_same_session() -> None: + """Once a transition has been reported, don't fire again.""" + initial = BgStatus(rust_extension="pending") + d = NotificationDispatcher(initial) + msgs1 = d.diff(BgStatus(rust_extension="ready")) + assert len(msgs1) == 1 + msgs2 = d.diff(BgStatus(rust_extension="ready")) + assert msgs2 == [] diff --git a/tests/install/test_doctor_bg.py b/tests/install/test_doctor_bg.py new file mode 100644 index 00000000..3e5dd546 --- /dev/null +++ b/tests/install/test_doctor_bg.py @@ -0,0 +1,36 @@ +"""Tests for the doctor 'Background tasks' section.""" + +from __future__ import annotations + +from pathlib import Path + +from click.testing import CliRunner + +from openjarvis.cli.doctor_cmd import doctor + + +def test_doctor_shows_bg_section_when_state_present(tmp_openjarvis_home: Path) -> None: + (tmp_openjarvis_home / ".state" / "extension-built").write_text("") + (tmp_openjarvis_home / ".state" / "models" / "qwen3.5:9b.ready").write_text("") + runner = CliRunner() + result = runner.invoke(doctor, [], catch_exceptions=False) + assert "Background tasks" in result.output + assert "Rust extension" in result.output + assert "qwen3.5:9b" in result.output + assert "ready" in result.output + + +def test_doctor_exit_code_when_bg_failed(tmp_openjarvis_home: Path) -> None: + (tmp_openjarvis_home / ".state" / "extension-failed").write_text("oom") + runner = CliRunner() + result = runner.invoke(doctor, [], catch_exceptions=False) + # Doctor should exit non-zero when any bg task is failed. + assert result.exit_code != 0 + assert "failed" in result.output.lower() + + +def test_doctor_no_bg_section_when_state_dir_empty(tmp_openjarvis_home: Path) -> None: + """Empty .state/ — section still appears but reports 'no background tasks'.""" + runner = CliRunner() + result = runner.invoke(doctor, [], catch_exceptions=False) + assert "Background tasks" in result.output diff --git a/tests/install/test_first_run.py b/tests/install/test_first_run.py new file mode 100644 index 00000000..e83e50c6 --- /dev/null +++ b/tests/install/test_first_run.py @@ -0,0 +1,98 @@ +"""Tests for openjarvis.cli._first_run.check_and_route.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +from openjarvis.cli import _first_run + + +def _ctx_with_invocation(name: str | None) -> MagicMock: + ctx = MagicMock() + ctx.invoked_subcommand = name + return ctx + + +def test_passes_through_when_subcommand_present(tmp_openjarvis_home: Path) -> None: + """If user typed `jarvis ask ...`, guard is a no-op.""" + ctx = _ctx_with_invocation("ask") + result = _first_run.check_and_route(ctx) + assert result is None + ctx.invoke.assert_not_called() + + +def test_routes_to_chat_when_config_exists(tmp_openjarvis_home: Path) -> None: + (tmp_openjarvis_home / "config.toml").write_text('[engine]\ndefault = "ollama"\n') + ctx = _ctx_with_invocation(None) + _first_run.check_and_route(ctx) + assert ctx.invoke.called + invoked_cmd = ctx.invoke.call_args[0][0] + assert invoked_cmd.name == "chat" + + +def test_routes_to_init_when_no_config(tmp_openjarvis_home: Path) -> None: + ctx = _ctx_with_invocation(None) + _first_run.check_and_route(ctx) + assert ctx.invoke.called + invoked_cmd = ctx.invoke.call_args[0][0] + assert invoked_cmd.name == "init" + # Cold-path init must run with the from-bare-jarvis flag set. + assert ctx.invoke.call_args.kwargs.get("from_bare_jarvis") is True + + +def test_handles_missing_state_dir(tmp_path: Path, monkeypatch) -> None: + """When ~/.openjarvis doesn't exist at all, route to init.""" + fresh_home = tmp_path / "fresh" + monkeypatch.setattr("openjarvis.core.config.DEFAULT_CONFIG_DIR", fresh_home) + monkeypatch.setattr( + "openjarvis.core.config.DEFAULT_CONFIG_PATH", fresh_home / "config.toml" + ) + ctx = _ctx_with_invocation(None) + _first_run.check_and_route(ctx) + invoked_cmd = ctx.invoke.call_args[0][0] + assert invoked_cmd.name == "init" + + +def test_root_group_invokes_guard_on_bare_jarvis( + tmp_openjarvis_home: Path, monkeypatch +) -> None: + """End-to-end: bare `jarvis` invocation calls the first-run guard. + + We monkeypatch check_and_route to a recorder so we can verify it was + called with a click.Context whose invoked_subcommand is None. + """ + from click.testing import CliRunner + + calls: list[object] = [] + + def _recorder(ctx) -> None: + calls.append(ctx.invoked_subcommand) + + monkeypatch.setattr("openjarvis.cli._first_run.check_and_route", _recorder) + + from openjarvis.cli import cli + + runner = CliRunner() + runner.invoke(cli, [], catch_exceptions=False) + assert calls == [None], f"expected one call with None subcommand, got {calls}" + + +def test_root_group_does_not_invoke_guard_on_subcommand( + tmp_openjarvis_home: Path, monkeypatch +) -> None: + """When a subcommand is given, the guard must NOT fire.""" + from click.testing import CliRunner + + calls: list[object] = [] + + def _recorder(ctx) -> None: + calls.append(ctx.invoked_subcommand) + + monkeypatch.setattr("openjarvis.cli._first_run.check_and_route", _recorder) + + from openjarvis.cli import cli + + runner = CliRunner() + runner.invoke(cli, ["--help"], catch_exceptions=False) + assert calls == [], f"expected no guard calls, got {calls}" diff --git a/tests/install/test_init_cloud_detect.py b/tests/install/test_init_cloud_detect.py new file mode 100644 index 00000000..4ab16d1a --- /dev/null +++ b/tests/install/test_init_cloud_detect.py @@ -0,0 +1,70 @@ +"""Tests for init's cloud auto-detect and from-bare-jarvis flag.""" + +from __future__ import annotations + +from pathlib import Path + +from click.testing import CliRunner + +from openjarvis.cli.init_cmd import init + + +def _clear_keys(monkeypatch) -> None: + for k in ( + "OPENROUTER_API_KEY", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "GOOGLE_API_KEY", + "GEMINI_API_KEY", + ): + monkeypatch.delenv(k, raising=False) + + +def test_init_accepts_from_bare_jarvis_flag( + tmp_openjarvis_home: Path, monkeypatch +) -> None: + """The --from-bare-jarvis flag exists and suppresses the launch-chat prompt.""" + _clear_keys(monkeypatch) + runner = CliRunner() + # Use --engine + --no-download + --no-scan to skip interactive paths + # that would otherwise hang on a non-TTY runner. + result = runner.invoke( + init, + ["--from-bare-jarvis", "--engine", "ollama", "--no-download", "--no-scan"], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + # When --from-bare-jarvis, we expect NOT to see a "launch chat" affordance. + assert "Launch chat" not in result.output + + +def test_init_proposes_cloud_when_key_in_env( + tmp_openjarvis_home: Path, monkeypatch +) -> None: + """When ANTHROPIC_API_KEY is set, init mentions cloud / anthropic.""" + _clear_keys(monkeypatch) + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test") + runner = CliRunner() + result = runner.invoke( + init, + ["--from-bare-jarvis", "--engine", "ollama", "--no-download", "--no-scan"], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + assert "anthropic" in result.output.lower() or "cloud" in result.output.lower() + + +def test_init_from_bare_jarvis_skips_engine_prompt( + tmp_openjarvis_home: Path, monkeypatch +) -> None: + """--from-bare-jarvis must not hang on the engine-selection prompt + even when --engine is not provided.""" + _clear_keys(monkeypatch) + runner = CliRunner() + # Note: NO --engine flag passed. Without the gating fix this would hang. + result = runner.invoke( + init, + ["--from-bare-jarvis", "--no-download", "--no-scan"], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output