Add cold-start CLI installer (#313)

This commit is contained in:
Avanika Narayan
2026-05-05 14:42:48 -07:00
committed by GitHub
parent a3ba63d148
commit e79bc1e196
49 changed files with 2539 additions and 520 deletions
+27
View File
@@ -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/
+118
View File
@@ -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
+14 -37
View File
@@ -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 <name>` 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
+52
View File
@@ -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 <url> \| 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`
+113
View File
@@ -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.
+30
View File
@@ -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)
+13 -430
View File
@@ -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.103.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)
+31
View File
@@ -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)
+1
View File
@@ -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"]
+49
View File
@@ -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 <model-id> [<model-id> ...]
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"
+44
View File
@@ -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
+23
View File
@@ -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
+264
View File
@@ -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 <<EOF
install.sh: '$1' is required but not found.
Install hints:
macOS: xcode-select --install (provides git, curl)
Debian/Ubuntu: sudo apt install git curl
Fedora/RHEL: sudo dnf install git curl
Arch: sudo pacman -S git curl
EOF
exit 1
fi
}
need git
need curl
# ---- env ----
OPENJARVIS_HOME="${OPENJARVIS_HOME:-$HOME/.openjarvis}"
OPENJARVIS_REPO_URL="${OPENJARVIS_REPO_URL:-https://github.com/open-jarvis/OpenJarvis.git}"
SRC_DIR="$OPENJARVIS_HOME/src"
VENV_DIR="$OPENJARVIS_HOME/.venv"
STATE_DIR="$OPENJARVIS_HOME/.state"
SCRIPTS_DIR="$OPENJARVIS_HOME/.scripts"
STATE_FILE="$STATE_DIR/install-state.json"
mkdir -p "$OPENJARVIS_HOME" "$STATE_DIR" "$SCRIPTS_DIR"
# ---- WSL detection ----
WSL=0
if [[ "${OPENJARVIS_FORCE_WSL:-0}" == "1" ]]; then
WSL=1
elif [[ -f /proc/sys/kernel/osrelease ]] && grep -qi "microsoft" /proc/sys/kernel/osrelease 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 <<EOF
Done. Type 'jarvis' to start chatting.
Background work continues silently:
- Rust toolchain + maturin extension build
- Bigger model downloads
Run 'jarvis doctor' to check status anytime.
EOF
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# jarvis-uninstall.sh — clean removal of OpenJarvis from $HOME.
#
# Removes:
# ~/.openjarvis/
# ~/.local/bin/jarvis
# ~/.local/bin/jarvis-uninstall
#
# Does NOT remove: ollama, uv, or the Rust toolchain.
set -euo pipefail
OPENJARVIS_HOME="${OPENJARVIS_HOME:-$HOME/.openjarvis}"
if [[ -f "$OPENJARVIS_HOME/.state/bg.pid" ]]; then
pid=$(cat "$OPENJARVIS_HOME/.state/bg.pid" 2>/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 <<EOF
OpenJarvis removed.
Left intact (may be used by other tools):
- Ollama (uninstall: brew uninstall ollama / rm -f /usr/local/bin/ollama)
- uv (uninstall: rm -rf ~/.local/share/uv ~/.cargo/bin/uv)
- Rust toolchain (uninstall: rustup self uninstall)
EOF
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# jarvis-wrapper.sh — symlinked to ~/.local/bin/jarvis.
# Activates the managed venv and execs the real jarvis CLI.
OPENJARVIS_HOME="${OPENJARVIS_HOME:-$HOME/.openjarvis}"
VENV="$OPENJARVIS_HOME/.venv"
if [[ ! -d "$VENV" ]]; then
echo "jarvis: venv not found at $VENV" >&2
echo "Re-run the installer: curl -fsSL https://openjarvis.ai/install.sh | bash" >&2
exit 1
fi
exec "$VENV/bin/jarvis" "$@"
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# pull-model.sh — pull an Ollama model with state-file lifecycle.
#
# Usage: pull-model.sh <model-id>
#
# State files written under $OPENJARVIS_HOME/.state/models/:
# <model>.downloading — created on start
# <model>.ready — on success (atomic rename)
# <model>.failed — on failure after retries
# <model>.log — captured stderr
set -euo pipefail
MODEL="${1:-}"
if [[ -z "$MODEL" ]]; then
echo "usage: pull-model.sh <model-id>" >&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"
+12 -1
View File
@@ -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:
+249
View File
@@ -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}")
+29
View File
@@ -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."
+48
View File
@@ -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
+35
View File
@@ -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)
+16
View File
@@ -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]")
+36
View File
@@ -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)
+66 -45
View File
@@ -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)
+7
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# Stub: presence is the point — file existing in PATH = cargo "installed".
exit 0
+7
View File
@@ -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}"
+55
View File
@@ -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" <<EOF2
#!/usr/bin/env bash
exit 0
EOF2
chmod +x "$target/scripts/install/$s"
done
# Make it look like a git repo so subsequent .git checks pass.
mkdir -p "$target/.git"
# Also create a pyproject.toml so editable install would work in real life.
cat > "$target/pyproject.toml" <<EOF2
[project]
name = "OpenJarvis"
version = "0.1.1"
EOF2
;;
pull)
;;
*)
;;
esac
exit "${GIT_STUB_EXIT:-0}"
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# Stub for `ollama` — records args to OLLAMA_STUB_LOG and exits with $OLLAMA_STUB_EXIT.
echo "$@" >> "${OLLAMA_STUB_LOG:-/dev/null}"
exit "${OLLAMA_STUB_EXIT:-0}"
+25
View File
@@ -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
+4
View File
@@ -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
+4
View File
@@ -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}"
+57
View File
@@ -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" <<EOF
#!/usr/bin/env bash
exit 0
EOF
cat > "$OPENJARVIS_HOME/.scripts/build-extension.sh" <<EOF
#!/usr/bin/env bash
touch "\$OPENJARVIS_HOME/.state/extension-built"
exit 0
EOF
cat > "$OPENJARVIS_HOME/.scripts/pull-model.sh" <<EOF
#!/usr/bin/env bash
touch "\$OPENJARVIS_HOME/.state/models/\${1}.ready"
exit 0
EOF
chmod +x "$OPENJARVIS_HOME/.scripts/"*.sh
export SCRIPT="$BATS_TEST_DIRNAME/../../../scripts/install/bg-orchestrator.sh"
}
teardown() {
[[ -n "${TEST_TMPDIR:-}" ]] && rm -rf "$TEST_TMPDIR"
}
@test "writes pid file" {
run bash "$SCRIPT" qwen3.5:9b
[ "$status" -eq 0 ]
# bg-orchestrator's pid file is removed by the trap on exit.
# We verify the file existed at SOME point by checking for the log mention.
[ -f "$OPENJARVIS_HOME/.state/bg-orchestrator.log" ]
grep -q "pid=" "$OPENJARVIS_HOME/.state/bg-orchestrator.log"
}
@test "invokes build-extension after install-rust succeeds" {
run bash "$SCRIPT" qwen3.5:9b
[ "$status" -eq 0 ]
[ -f "$OPENJARVIS_HOME/.state/extension-built" ]
}
@test "invokes pull-model.sh for each model arg" {
run bash "$SCRIPT" qwen3.5:9b qwen3.5:27b
[ "$status" -eq 0 ]
[ -f "$OPENJARVIS_HOME/.state/models/qwen3.5:9b.ready" ]
[ -f "$OPENJARVIS_HOME/.state/models/qwen3.5:27b.ready" ]
}
@test "removes pid file when done (via trap)" {
run bash "$SCRIPT" qwen3.5:9b
[ "$status" -eq 0 ]
[ ! -f "$OPENJARVIS_HOME/.state/bg.pid" ]
}
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bats
setup() {
TEST_TMPDIR=$(mktemp -d)
export OPENJARVIS_HOME="$TEST_TMPDIR/.openjarvis"
mkdir -p "$OPENJARVIS_HOME/.state"
mkdir -p "$OPENJARVIS_HOME/src/rust/crates/openjarvis-python"
touch "$OPENJARVIS_HOME/src/rust/crates/openjarvis-python/Cargo.toml"
export UV_STUB_LOG="$TEST_TMPDIR/uv.log"
: > "$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 ]
}
+175
View File
@@ -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" <<EOF
#!/usr/bin/env bash
echo "\$@" >> "\$UV_STUB_LOG"
case "\$1" in
venv)
# uv venv [--python X.Y] <path>
# 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
}
+35
View File
@@ -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"
}
+48
View File
@@ -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 ]
}
+17
View File
@@ -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.)
+23
View File
@@ -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"`
+23
View File
@@ -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"`
+10 -7
View File
@@ -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
+82
View File
@@ -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
+126
View File
@@ -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()
+101
View File
@@ -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
+42
View File
@@ -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()
+50
View File
@@ -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 == []
+36
View File
@@ -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
+98
View File
@@ -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}"
+70
View File
@@ -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