mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-27 21:05:34 +00:00
Add cold-start CLI installer (#313)
This commit is contained in:
@@ -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/
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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
@@ -1,443 +1,26 @@
|
||||
---
|
||||
title: macOS Installation Guide
|
||||
description: Complete step-by-step guide to installing OpenJarvis on macOS with llama.cpp, including common pitfalls and fixes
|
||||
search:
|
||||
boost: 2
|
||||
---
|
||||
# macOS Install
|
||||
|
||||
# macOS Installation Guide
|
||||
```bash
|
||||
curl -fsSL https://openjarvis.ai/install.sh | bash
|
||||
```
|
||||
|
||||
This guide walks through a complete OpenJarvis installation on macOS using **llama.cpp** as
|
||||
the inference engine. It covers every step from scratch — including pitfalls not documented
|
||||
elsewhere — and is suitable for both Apple Silicon and Intel Macs.
|
||||
Works on Intel and Apple Silicon. The installer auto-detects your CPU/GPU.
|
||||
|
||||
!!! tip "Prefer Ollama?"
|
||||
If you want the fastest possible setup, use [Ollama](installation.md#ollama-recommended)
|
||||
instead. This guide is for users who want to run GGUF models directly with llama.cpp,
|
||||
or who want a deeper understanding of the full stack.
|
||||
## Prerequisites
|
||||
|
||||
---
|
||||
If you've never run `git` or `curl` on this Mac, macOS will prompt you to install the Xcode Command Line Tools the first time you run them. Accept the prompt; that gives you both.
|
||||
|
||||
## What You'll Install
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| Xcode Command Line Tools | Apple's compiler toolchain — required by Homebrew, Rust, and `git` |
|
||||
| Homebrew | macOS package manager — installs everything else |
|
||||
| uv | Python version and dependency manager |
|
||||
| Git | Clones the OpenJarvis repo |
|
||||
| Node.js | Required for the browser UI |
|
||||
| Rust | Compiles the OpenJarvis security and memory extension |
|
||||
| llama.cpp | Local inference engine that runs GGUF model files |
|
||||
| OpenJarvis | The framework itself |
|
||||
| A GGUF model | The actual AI model (downloaded separately) |
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Installation
|
||||
|
||||
### Step 0 — Install Xcode Command Line Tools
|
||||
|
||||
On a fresh Mac, you need Apple's developer command line tools before anything else
|
||||
(`git`, `cc`, build headers). Install them with:
|
||||
If you'd rather pre-install:
|
||||
|
||||
```bash
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
A GUI installer will pop up — accept and let it finish. If the tools are already
|
||||
installed the command instead prints
|
||||
`xcode-select: error: command line tools are already installed, use "Software Update" to install updates`
|
||||
and exits non-zero, which is fine — you can move on.
|
||||
## Apple Silicon notes
|
||||
|
||||
!!! note "Homebrew will ask too"
|
||||
Step 1's Homebrew installer also requests the Command Line Tools if it doesn't see
|
||||
them. Running `xcode-select --install` upfront avoids the second prompt and confirms
|
||||
the tools landed correctly.
|
||||
- The installer picks `mlx` as the recommended engine via the standard hardware-detect path, but the foreground default is still Ollama for compatibility. Switch later with `jarvis init --force` and pick `mlx` if you've installed `mlx-lm`.
|
||||
- Unified memory is reported as "VRAM" by the installer — that's intentional; on Apple Silicon, system RAM is what GPU-accelerated models can use.
|
||||
|
||||
---
|
||||
## See also
|
||||
|
||||
### Step 1 — Install Homebrew
|
||||
|
||||
Homebrew is the standard macOS package manager. Everything else in this guide is installed
|
||||
through it.
|
||||
|
||||
```bash
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
```
|
||||
|
||||
If you already have Homebrew, skip this step.
|
||||
|
||||
---
|
||||
|
||||
### Step 2 — Install uv
|
||||
|
||||
`uv` replaces pip, virtualenv, and pyenv in one tool. OpenJarvis uses it to manage Python
|
||||
versions, virtual environments, and project dependencies.
|
||||
|
||||
```bash
|
||||
brew install uv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 3 — Install Git
|
||||
|
||||
Git is used to clone the OpenJarvis source code. It may already be present if you have
|
||||
Xcode Command Line Tools installed.
|
||||
|
||||
```bash
|
||||
brew install git
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 4 — Install Node.js
|
||||
|
||||
Node.js is required to build and run the browser frontend. Without it you can still use
|
||||
the CLI, but not the web UI.
|
||||
|
||||
```bash
|
||||
brew install node
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 5 — Install Rust
|
||||
|
||||
OpenJarvis includes a Rust extension that provides security scanning, memory indexing,
|
||||
rate limiting, and tool execution. It must be compiled from source.
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
```
|
||||
|
||||
After the installer finishes, reload your shell so `rustc` is available:
|
||||
|
||||
```bash
|
||||
source "$HOME/.cargo/env"
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
rustc --version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 6 — Install llama.cpp
|
||||
|
||||
llama.cpp is the inference engine that loads and runs GGUF model files. It is not a model
|
||||
itself — think of it as a media player and the `.gguf` file as the content.
|
||||
|
||||
```bash
|
||||
brew install llama.cpp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 7 — Clone the OpenJarvis repo
|
||||
|
||||
Run this from your home directory or any neutral parent folder.
|
||||
|
||||
```bash
|
||||
cd ~
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis
|
||||
```
|
||||
|
||||
!!! warning "Do not clone from inside an existing OpenJarvis folder"
|
||||
A common mistake is running `git clone` while already inside the repo, creating deeply
|
||||
nested duplicates (`OpenJarvis/OpenJarvis/OpenJarvis`). Always clone from `~` or a
|
||||
neutral parent directory.
|
||||
|
||||
---
|
||||
|
||||
### Step 8 — Pin Python to 3.12
|
||||
|
||||
!!! warning "Critical step — do not skip"
|
||||
OpenJarvis requires Python 3.10–3.13. Its Rust extension uses PyO3, which does not yet
|
||||
support Python 3.14. If `uv` has Python 3.14 available, it will use it by default,
|
||||
causing the Rust extension build to fail silently and resulting in ~250 test failures
|
||||
with `ModuleNotFoundError: No module named 'openjarvis_rust'`.
|
||||
|
||||
Pin the project to Python 3.12:
|
||||
|
||||
```bash
|
||||
echo "3.12" > .python-version
|
||||
uv python install 3.12
|
||||
rm -rf .venv
|
||||
uv venv
|
||||
```
|
||||
|
||||
**Restart your terminal**, then verify:
|
||||
|
||||
```bash
|
||||
uv run python --version
|
||||
# Must show: Python 3.12.x
|
||||
```
|
||||
|
||||
!!! tip "Why restart the terminal?"
|
||||
Without restarting, the shell may still reference the old virtual environment. This is
|
||||
the most common reason the version pin appears not to work.
|
||||
|
||||
---
|
||||
|
||||
### Step 9 — Install Python dependencies
|
||||
|
||||
```bash
|
||||
uv sync --extra dev --extra server
|
||||
```
|
||||
|
||||
The `--extra server` flag adds the FastAPI backend required for the browser UI.
|
||||
|
||||
---
|
||||
|
||||
### Step 10 — Build the Rust extension
|
||||
|
||||
This compiles the Rust extension and installs it into the virtual environment. It provides
|
||||
security scanning, memory indexing, MCP tool execution, and rate limiting. This step takes
|
||||
a few minutes on first run.
|
||||
|
||||
```bash
|
||||
uv run maturin develop -m rust/crates/openjarvis-python/Cargo.toml
|
||||
```
|
||||
|
||||
Verify it built correctly:
|
||||
|
||||
```bash
|
||||
uv run python -c "import openjarvis_rust; print('Rust extension OK')"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 11 — Install frontend dependencies
|
||||
|
||||
```bash
|
||||
cd frontend && npm install && cd ..
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 12 — Download a model
|
||||
|
||||
OpenJarvis needs a GGUF model file to run inference. First install the Hugging Face CLI,
|
||||
then download your chosen model.
|
||||
|
||||
```bash
|
||||
uv tool install huggingface_hub
|
||||
```
|
||||
|
||||
!!! note "The CLI command is `hf`, not `huggingface-cli`"
|
||||
When installed via `uv tool`, the Hugging Face CLI is invoked as `hf`.
|
||||
|
||||
=== "Qwen3 4B (~2.5 GB)"
|
||||
|
||||
Faster, lower RAM requirement. Good for most everyday tasks.
|
||||
|
||||
```bash
|
||||
hf download bartowski/Qwen_Qwen3-4B-GGUF \
|
||||
--include "Qwen_Qwen3-4B-Q4_K_M.gguf" \
|
||||
--local-dir ~/models
|
||||
```
|
||||
|
||||
=== "Qwen3 8B (~4.7 GB)"
|
||||
|
||||
Better reasoning and instruction following. Requires more RAM.
|
||||
|
||||
```bash
|
||||
hf download bartowski/Qwen_Qwen3-8B-GGUF \
|
||||
--include "Qwen_Qwen3-8B-Q4_K_M.gguf" \
|
||||
--local-dir ~/models
|
||||
```
|
||||
|
||||
!!! warning "Use the `Qwen_` prefix"
|
||||
bartowski's Qwen3 repos use the `Qwen_` prefix (e.g. `Qwen_Qwen3-4B-GGUF`). Using
|
||||
the shorter name without the prefix returns a "repository not found" error.
|
||||
|
||||
!!! tip "Apple Silicon vs Intel"
|
||||
On Apple Silicon, both models benefit from Metal GPU acceleration when using the MLX
|
||||
engine. On Intel, inference runs on CPU — the 4B model is recommended for speed.
|
||||
|
||||
---
|
||||
|
||||
### Step 13 — Configure OpenJarvis
|
||||
|
||||
Run the init command to detect your hardware and generate a config file:
|
||||
|
||||
```bash
|
||||
uv run jarvis init
|
||||
```
|
||||
|
||||
Then open the config and set the default model to match the filename you downloaded:
|
||||
|
||||
```bash
|
||||
nano ~/.openjarvis/config.toml
|
||||
```
|
||||
|
||||
Find the `default_model` line and update it, for example:
|
||||
|
||||
```toml
|
||||
default_model = "Qwen_Qwen3-4B-Q4_K_M.gguf"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 14 — Verify the installation
|
||||
|
||||
```bash
|
||||
uv run jarvis doctor
|
||||
```
|
||||
|
||||
A healthy setup looks like this:
|
||||
|
||||
```
|
||||
✓ Python version 3.12.x
|
||||
✓ Config file ~/.openjarvis/config.toml
|
||||
✓ Config parsing Config loaded successfully
|
||||
✓ Engine: llamacpp Reachable
|
||||
✓ Models: llamacpp Qwen_Qwen3-4B-Q4_K_M.gguf
|
||||
✓ Default model Qwen_Qwen3-4B-Q4_K_M.gguf (on llamacpp)
|
||||
```
|
||||
|
||||
!!! note "Warnings for other engines are normal"
|
||||
The `!` warnings for engines like `ollama`, `vllm`, and `lmstudio` simply mean those
|
||||
backends are not running. You only need `llamacpp` to be reachable.
|
||||
|
||||
---
|
||||
|
||||
## Running OpenJarvis
|
||||
|
||||
### CLI
|
||||
|
||||
Start llama-server in one terminal, then run queries in another:
|
||||
|
||||
```bash
|
||||
# Terminal 1 — start the inference engine
|
||||
llama-server -m ~/models/Qwen_Qwen3-4B-Q4_K_M.gguf -c 4096 -t 8
|
||||
|
||||
# Terminal 2 — ask a question
|
||||
cd ~/OpenJarvis
|
||||
uv run jarvis ask "What is the capital of France?"
|
||||
```
|
||||
|
||||
### Browser UI
|
||||
|
||||
```bash
|
||||
# Terminal 1 — inference engine
|
||||
llama-server -m ~/models/Qwen_Qwen3-4B-Q4_K_M.gguf -c 4096 -t 8
|
||||
|
||||
# Terminal 2 — backend
|
||||
cd ~/OpenJarvis && uv run jarvis serve --port 8000
|
||||
|
||||
# Terminal 3 — frontend
|
||||
cd ~/OpenJarvis/frontend && npm run dev
|
||||
```
|
||||
|
||||
Then open [http://localhost:5173](http://localhost:5173).
|
||||
|
||||
### Skip typing `uv run` every time
|
||||
|
||||
Activate the virtual environment for your current terminal session:
|
||||
|
||||
```bash
|
||||
source ~/OpenJarvis/.venv/bin/activate
|
||||
```
|
||||
|
||||
Your prompt will show `(openjarvis)` when active, and you can type `jarvis ask "..."` directly.
|
||||
|
||||
---
|
||||
|
||||
## Performance Tips
|
||||
|
||||
These tips apply when using llama.cpp for CPU inference.
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `-c 4096` | Reduces context window from the 32,768 default, freeing RAM for faster inference |
|
||||
| `-t 8` | Uses all available CPU threads (default is only 4) — adjust to your machine's thread count |
|
||||
| `Q4_K_M` quantization | Best balance of size, speed, and quality for CPU inference |
|
||||
|
||||
On Apple Silicon, switching to the [MLX engine](../architecture/engine.md) gives
|
||||
significantly better performance than llama.cpp for most models.
|
||||
|
||||
---
|
||||
|
||||
## Common Errors
|
||||
|
||||
### `No such file or directory` when loading model
|
||||
|
||||
The path `path/to/model.gguf` in examples is a placeholder. Replace it with your actual
|
||||
model path, e.g.:
|
||||
|
||||
```bash
|
||||
llama-server -m ~/models/Qwen_Qwen3-4B-Q4_K_M.gguf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `No module named 'openjarvis_rust'`
|
||||
|
||||
The Rust extension did not build correctly, or was built against the wrong Python version.
|
||||
|
||||
1. Confirm Python 3.12 is active: `uv run python --version`
|
||||
2. Rebuild: `uv run maturin develop -m rust/crates/openjarvis-python/Cargo.toml`
|
||||
|
||||
If the version shows 3.14, go back to [Step 8](#step-8--pin-python-to-312).
|
||||
|
||||
---
|
||||
|
||||
### `PyO3 version error — Python 3.14 too new`
|
||||
|
||||
```
|
||||
error: the configured Python interpreter version (3.14) is newer than
|
||||
PyO3's maximum supported version (3.13)
|
||||
```
|
||||
|
||||
PyO3 0.23.5 supports Python up to 3.13. Follow [Step 8](#step-8--pin-python-to-312) to
|
||||
pin to 3.12, then delete `.venv`, recreate it, and restart your terminal before retrying.
|
||||
|
||||
---
|
||||
|
||||
### `Repository not found` when downloading model
|
||||
|
||||
bartowski's Qwen3 repos use the `Qwen_` prefix. Use:
|
||||
|
||||
```
|
||||
bartowski/Qwen_Qwen3-4B-GGUF ✓
|
||||
bartowski/Qwen3-4B-GGUF ✗
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `No inference engine available`
|
||||
|
||||
llama-server is not running. Start it in a separate terminal before running any `jarvis`
|
||||
commands, and wait until you see `model loaded` in the output.
|
||||
|
||||
---
|
||||
|
||||
### Python version still shows 3.14 after recreating the venv
|
||||
|
||||
Close the terminal completely and reopen it. The old venv path is cached in the shell
|
||||
environment and persists across commands until the session ends.
|
||||
|
||||
---
|
||||
|
||||
### `zsh: command not found: huggingface-cli`
|
||||
|
||||
When installed via `uv tool`, the CLI is invoked as `hf`, not `huggingface-cli`:
|
||||
|
||||
```bash
|
||||
hf download ... # ✓
|
||||
huggingface-cli download ... # ✗
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Quick Start](quickstart.md) — Run your first query and explore agents and tools
|
||||
- [Configuration](configuration.md) — Customize engine hosts, model routing, memory, and more
|
||||
- [Architecture](../architecture/overview.md) — Understand how OpenJarvis is structured
|
||||
- [Full installer reference](install.md)
|
||||
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
|
||||
Executable
+49
@@ -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"
|
||||
Executable
+44
@@ -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
|
||||
Executable
+23
@@ -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
|
||||
Executable
+264
@@ -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
|
||||
Executable
+47
@@ -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
|
||||
Executable
+14
@@ -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" "$@"
|
||||
Executable
+57
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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]")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stub: presence is the point — file existing in PATH = cargo "installed".
|
||||
exit 0
|
||||
Executable
+7
@@ -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}"
|
||||
Executable
+55
@@ -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}"
|
||||
Executable
+4
@@ -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}"
|
||||
Executable
+25
@@ -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
|
||||
Executable
+4
@@ -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
|
||||
Executable
+4
@@ -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}"
|
||||
Executable
+57
@@ -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" ]
|
||||
}
|
||||
Executable
+49
@@ -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 ]
|
||||
}
|
||||
Executable
+175
@@ -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
|
||||
}
|
||||
Executable
+35
@@ -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"
|
||||
}
|
||||
Executable
+48
@@ -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 ]
|
||||
}
|
||||
@@ -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.)
|
||||
@@ -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"`
|
||||
@@ -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"`
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user