Metadata-Version: 2.4
Name: virtuai-cli
Version: 1.0.22
Summary: Run VirtuAI deep agents on your local machine
Author-email: uCloudStore <lmoreno@ucloudstore.com>
License: Proprietary
Project-URL: Homepage, https://imvituai.com
Keywords: virtuai,ai,agents,cli
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Operating System :: OS Independent
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: websockets>=12.0
Requires-Dist: httpx[http2]>=0.27
Requires-Dist: httpx-sse>=0.4
Requires-Dist: certifi>=2024.0
Requires-Dist: keyring>=25.0
Requires-Dist: typer>=0.12
Requires-Dist: rich>=13.0
Requires-Dist: textual>=0.86
Requires-Dist: llmfit>=1.1.16
Provides-Extra: local
Requires-Dist: langchain-ollama>=0.3.4; extra == "local"
Requires-Dist: langchain-openai>=0.3; extra == "local"
Provides-Extra: browser
Requires-Dist: browser-use>=0.1.40; extra == "browser"
Requires-Dist: pydantic-settings>=2.0; extra == "browser"

# VirtuAI CLI

Run VirtuAI agents from your terminal — chat interactively in a Claude-Code-style TUI, or pipe them into shell scripts. The agent's bash/file tools run on **your** machine, so files appear in your filesystem and commands hit your real shell.

## Installation

```bash
pip install virtuai-cli
```

Or with [pipx](https://pipx.pypa.io/) (recommended for CLI tools — handles PATH automatically):

```bash
pipx install virtuai-cli
```

For **local model inference** (running agents against [Ollama](https://ollama.com) or [oMLX](https://github.com/jundot/omlx) on your machine — see below), install the `local` extra:

```bash
pip install 'virtuai-cli[local]'      # or: pipx install 'virtuai-cli[local]'
```

Requires Python 3.11+.

## Quick start

### 1. Pair this machine with a workspace

Open the VirtuAI portal → **Settings → CLI** → generate a pairing code, then:

```bash
virtuai pair <CODE>
```

The pairing is one-time; the resulting token is stored in your system keychain.

Pairing then sets up the **command check** (also on the first `virtuai chat`, or any time with `virtuai guard setup`). Before the agent runs a command on this machine, a small local model — `tev1:0.8b` (811 MB) on [Ollama](https://ollama.com) ≥ 0.35 — says what the command does:

- reads, or runs the project's tests/build → it runs;
- deletes, installs, writes, uses the network, reads secrets, needs root → you are shown the command and decide. If you don't authorize it, it is left in the input as `!command` for you to run yourself; its output goes back to the agent.

Commands that only read (`ls`, `cat`, `git status`, `rg`…) run without the model; inline code (`python -c`, `bash -c`, `| sh`), `eval` and `sudo` are always asked. Setup uses [llmfit](https://pypi.org/project/llmfit/) to decide whether the model fits this machine, then offers to install or start Ollama and download the model. **If it does not fit, or you decline, there is no command check: what the agent runs is your responsibility**, you are asked to accept that, and the chat shows it.

When you are asked, you can also answer for more than this run: **once**, **in this conversation**, **for 1 hour**, or **always in this folder**. A grant covers the command's *base* in that folder — the program and its subcommand: `npm install`, `npm run build`, `pytest`, `make test` — so allowing `npm install` does not allow `npm publish`. What deletes, reads secrets, changes the system, reaches outside the folder or hides code is asked every time, whatever was allowed. "Always in this folder" is kept in `~/.virtuai/guard_grants.json`; the others end with the runner.

Under VirtuAI Desktop, when the turn comes from the web chat you have open, the question is shown **in the chat** instead of as a dialog (server and CLI 1.0.21 or later). What is always asked stays a dialog on this computer. `virtuai config set guard_ask_in_chat off` keeps every question on the computer.

```bash
virtuai guard status              # on, rules only, or off
virtuai guard test "rm -rf dist"  # what it would do: run or ask
virtuai guard grants              # what you allowed "always in this folder"
virtuai guard grants --revoke <id>   # or --all
```

### 2. Chat with an agent

```bash
virtuai chat
```

Opens an interactive TUI. Pick an agent (skipped if your workspace has only one), type a message, watch the response stream in. The agent's tool calls — `bash`, file reads, file writes — execute on this machine, in the current directory.

**Plan mode** (`Shift+Tab` or `/plan`): the same agent investigates and proposes a plan without changing anything — it may read and run commands that only look; writes, edits and tools that change something are refused by the server. When the plan is ready, choose to carry it out (plan mode turns off) or keep planning.

Every workspace also includes a built-in **Plan** agent, a separate agent with its own conversation: `/agent Plan` or `virtuai chat --agent Plan`. List agents with `/agents`.

### 3. Use it from a shell script

```bash
virtuai ask "summarize the changes in this branch"

git diff | virtuai ask "what does this change?"

virtuai ask --json "find any bugs" > events.jsonl
```

`ask` is non-interactive: it prints the response to stdout, then exits.

## Local model inference

Agents can run inference on **your own machine** — with [Ollama](https://ollama.com), or [oMLX](https://github.com/jundot/omlx) on Apple Silicon — instead of a cloud provider: for cost, data locality, or open models. Only the model calls go to the machine; the agent loop, tools, knowledge bases and chat history stay on the server. It works from **every channel** — web chat, Slack, Telegram, WhatsApp, Google Chat, OpenCode — not only the TUI, as long as a runner that can serve the model is connected.

### Set up

1. Install the CLI with the local extra (pulls `langchain-ollama` and `langchain-openai` for oMLX; `llmfit` comes with the base install):
   ```bash
   pip install 'virtuai-cli[local]'
   ```
2. Install Ollama **0.40 or newer** and pull a validated model:
   ```bash
   ollama pull qwen3:8b
   ```
   Ollama before 0.40 held the same models in two to three times the memory; `virtuai run` warns when it finds one.
3. In the VirtuAI portal, set the agent's **Provider** to **Local (Ollama)** (or **Local (oMLX)**), enter the model, and pick a **cloud fallback** model.
4. Keep a runner connected: `virtuai run`, or `virtuai chat` / `virtuai ask`, which start their own.

### Which models

Validated — every one of 10 tool-calling tasks right, 8k context, 16 GB Apple M3 with Ollama 0.40:

| Model | Memory | Median answer |
|---|---|---|
| `qwen3:8b` | 6.6 GB | 2.2 s |
| `qwen2.5:7b` | 5.0 GB | 3.5 s |
| `qwen2.5-coder:7b` | ~5 GB | 4.4 s |
| `qwen2.5:14b` | 10.3 GB | 6.0 s |

Other models run, but are not vouched for. `qwen3:4b` is accurate but slow (it reasons before every call). Gemma 3 is never used: Ollama cannot call tools with it, so the cloud fallback answers.

### Whose turns this machine answers

```bash
virtuai run                              # your own turns only (the default)
virtuai run --serve-models workspace     # anyone's in the workspace: a shared Mac mini or GPU box
virtuai run --serve-models none          # tools only, no model turns
```

Each model call goes to the runner of the person the turn is for; if they have none, to a runner started with `--serve-models workspace`; if there is none either, the agent's **cloud fallback** answers that call and every channel shows "Modelo local no disponible: responde …". A runner that fails before it answers (Ollama stopped, the model unloaded) costs the call a cloud answer, not an error.

> **Upgrading from 1.0.2:** with a current server, an older runner serves only the person who paired it. A machine that serves a whole team needs 1.0.3 and `--serve-models workspace`.

### What the runner tells the server

At connect and then every minute: the models it has, the Ollama version, what is loaded and how much memory it really took. With `llmfit` (part of the base install) it also estimates which pulled models fit this machine, so the server does not send a model to a runner that cannot hold it until it has actually run there. A model that spills to the CPU gets half the context on its next call on that machine (down to 4k).

The context sent to Ollama is 16k by default (set on the server). A model that does not fit at that context — `qwen3:14b` on a 16 GB Mac spills 7% to the CPU — is capped at half on that machine after its first call, and the server is told in the same reply: from then on it sends prompts that fit, compacting the conversation when they would not, instead of letting Ollama cut the start of the prompt. `OLLAMA_NUM_CTX` sets a lower context on a machine with little memory. For qwen3 the runner turns Ollama's thinking mode off: left on, it reasoned for minutes before every tool call.

### oMLX

The runner finds oMLX through its own settings (`~/.omlx/settings.json`: port and API key) or `VIRTUAI_OMLX_URL` / `OMLX_API_KEY`; the key never leaves the machine. Measured with `Qwen3-8B-4bit`: 10/10, 2.5 s per answer — on par with Ollama for one person. Its case is a shared host serving several people at once; raise `VIRTUAI_MAX_CONCURRENT_MODEL` there, since the runner otherwise sends one generation at a time.

## Environment variables

Two naming conventions, and the difference matters when sizing a shared
runner: `MAX_CONCURRENT_<thing>` bounds operations **in flight** and makes the
extra ones **wait**; `MAX_<thing>S` bounds **resident objects** and makes the
extra ones **fail**. They are separate knobs on purpose — coupling them would
mean "allow more commands at once" silently launched more browsers.

| Variable | Default | What it does |
|---|---|---|
| `VIRTUAI_ISOLATE_SESSIONS` | off | Give each conversation its own directory under the workdir, and each person their own browser. Turn it on when one runner serves several people. Also set by `--isolate-sessions`. |
| `VIRTUAI_MAX_NAMESPACES` | `5` | Browsers held at once — one per person. Each is a real headful Chrome (roughly 0.4–1 GB resident), so size this to the machine's memory: an OOM-killed Chrome is a worse failure than a clean refusal. Only applies with isolation on. Also set by `--max-namespaces`. |
| `VIRTUAI_MAX_CONCURRENT_WORK` | `4` | Commands running at once. Each conversation is already capped at one by its own queue, so in practice this is how many conversations may be executing at the same instant. |
| `VIRTUAI_MAX_CONCURRENT_MODEL` | `1` | Local model generations at once. Ollama does one well; its own parallelism lives in `OLLAMA_NUM_PARALLEL` on your machine, which the server cannot see. Raise it for oMLX on a shared host, which batches several at once. |
| `VIRTUAI_MAX_QUEUED_FRAMES` | `64` | Requests waiting for a slot. Past this the runner answers "busy" rather than queueing without bound. |
| `VIRTUAI_SESSION_IDLE_TIMEOUT` | off (`0`) | Seconds before an idle browser stops holding its slot. Off by default: a slot normally frees only when its browser closes or the runner restarts. |
| `VIRTUAI_SANDBOX_OWNER` | unset | The one person this runner serves, reported to the server so a browser action reaches *their* browser. Leave it unset on a laptop or a container shared by a team: the server then routes to it for anyone, which is the behaviour that existed before per-person sandboxes. Set it when a workspace has a sandbox per person, or actions land on whichever runner connected last. |
| `VIRTUAI_SERVE_MODELS` | `own` | Whose local-model turns this runner answers: `own`, `workspace` or `none`. Also set by `--serve-models`. |
| `OLLAMA_BASE_URL` | `http://localhost:11434` | Where to reach the local Ollama. |
| `OLLAMA_NUM_CTX` | unset | The context to ask Ollama for, instead of the server's (16k). Lower it on a machine with little memory. |
| `VIRTUAI_OMLX_URL` | from `~/.omlx/settings.json`, else `http://127.0.0.1:8000` | Where to reach oMLX. |
| `OMLX_API_KEY` | from `~/.omlx/settings.json` | oMLX's API key. Stays on this machine. |

Server-side, `VIRTUAI_ENV_WAIT_SECONDS` (default `45`) is how long the server
holds a message waiting for a runner to reconnect before telling the user the
environment is not responding.

## Commands

| Command | Description |
|---|---|
| `virtuai chat`          | Open the interactive chat TUI |
| `virtuai ask "..."`      | One-shot prompt for scripts (reads stdin if piped) |
| `virtuai opencode`      | Merge this workspace's agents into the local OpenCode TUI config |
| `virtuai pair <code>`    | Pair this machine with a workspace |
| `virtuai unpair`         | Revoke the current pairing |
| `virtuai run`            | Headless runner — let the web app and channels use this machine's tools and local models without opening the TUI (`--serve-models own\|workspace\|none`) |
| `virtuai run --desktop`  | The runner as VirtuAI Desktop starts it: reports its state and asks the app before a command the guard does not clear, over stdin/stdout (`desktop_bridge.py`). Not for a terminal |
| `virtuai status`         | Show pairing and connection status |
| `virtuai logs`           | Show recent commands the agent executed locally |
| `virtuai login`          | Authenticate with your VirtuAI account (browser flow) |
| `virtuai config get/set` | Read or set local CLI options (e.g. `server_url`) |

## Inside `virtuai chat`

The TUI streams tokens, renders tool calls as live cards (running → complete), tracks the agent's todo list, and shows a randomized "✻ Pondering…" indicator while the agent is composing.

Slash commands (type `/` to see the filtered list, Tab to autocomplete):

| Slash command       | What it does |
|---|---|
| `/help`             | Show this list |
| `/compact`          | Summarize the conversation's older messages to free up context. Nothing compacts on its own: the status bar shows `ctx N%` (from the server; `~` while it is an estimate) and suggests `/compact` from 80% |
| `/clear`, `/new`    | Drop the current session and start fresh |
| `/history`          | List your recent conversations with this agent |
| `/load <id>`        | Reopen a past conversation by session_id |
| `/plan`             | Switch to the built-in Plan agent |
| `/agents`           | List agents in this workspace |
| `/agent <name>`     | Switch agent without exiting (starts a new conversation) |
| `/models`           | List models available for this agent |
| `/model <id>`       | Switch the model for subsequent messages |
| `/exit`, `/quit`    | Close the TUI |

Key bindings:

- **Esc** — cancel the in-flight response
- **Ctrl+L** — start a new conversation
- **Ctrl+C** — quit
- **Tab** — autocomplete the current `/command`

## `virtuai ask` for scripting

```bash
# Direct prompt
virtuai ask "what's in this folder?"

# Pipe stdin in (concatenated with the argument if both given)
git log --oneline -20 | virtuai ask "summarize these commits"

# Continue a previous session (use --print-session to surface the id)
virtuai ask --session sess-aaa "and apply that fix" --print-session

# Machine-readable: every event as one JSON line
virtuai ask --json "find any bugs" > events.jsonl

# Just the final answer, no streaming (good for capturing into variables)
ANSWER=$(virtuai ask -q "give me the current time")

# Pick a different agent or model
virtuai ask --agent code-review --model claude-opus "review main.py"

# Skip the local runner for a faster startup when no tools are needed
virtuai ask --no-tools "tell me a joke"
```

Output streams:

- `stream` (default) — tokens go to **stdout**, tool indicators to **stderr** (so `> out.txt` captures only the answer)
- `--quiet` / `-q` — accumulate, print only the final assistant text at the end
- `--json` — every SSE event as one JSONL line on stdout (parseable)

Exit codes: `0` success · `1` stream error · `2` bad args / agent not found.

## Common options

```
--server <url>       Override the saved server URL
--workdir <path>     Working directory for tool execution (default: current dir)
--agent <id|name>    Pick a specific agent
--model <id>         Override the agent's default model
```

## Security model

**Read this section before pointing an agent at anything you care about.**

The CLI runs the agent's shell commands **as your user account**, with your full filesystem permissions. There is no sandbox. The agent can do anything you can do at the shell. The guardrails below are best described as *speed bumps*, not walls.

### What is enforced

- **Auth**: CLI token is stored in your OS keychain (Keychain on macOS, libsecret on Linux, Credential Manager on Windows). All traffic is TLS (WSS + HTTPS) with the [`certifi`](https://github.com/certifi/python-certifi) CA bundle.
- **File-tool path jail**: the deepagents file tools — `write_file`, `edit_file`, `read_file` — route through `upload_files`/`download_files`, which reject absolute paths outside the workdir. This part works.
- **`cd` jail in bash**: the executor wraps every shell command in a bash subshell with a custom `cd` function that refuses to leave the workdir. Catches the trivial `cd ..` case.
- **Denylist**: a regex match on the command string blocks `sudo`, `rm -rf /`, fork bombs, `mkfs`, `dd of=/dev/...`. Easy to bypass with aliasing or encoding; mostly there to stop drive-by accidents.
- **Audit log**: every executed command + exit code goes to `~/.virtuai/audit.log` with a timestamp.
- **Foreground-only**: the agent only has access while `virtuai chat`, `virtuai ask`, or `virtuai run` is running — no background daemon (VirtuAI Desktop, https://github.com/VirtuAiRepo/virtuaidesktop, keeps `virtuai run --desktop` running from the menu bar).
- **A bare `virtuai run` asks no one**: the command guard is the chat TUI's, so the standalone runner executes what agents send. `--desktop` puts every command the guard does not clear to the person as a native dialog.

- **Terminals from the web chat** (1.0.22+): dev mode's Terminal tab asks this runner for a shell. It is your login shell, on a pseudo-terminal, in the conversation's working directory, with the same scrubbed environment as the agent's commands, typed by the person in the web chat — not by the agent, so the command guard does not see it (as it does not see your own Terminal app). It reaches the server on a socket of its own (`/api/cli/terminal`), admitted by a single-use ticket the server sent with the request; at most 8 are open at once, and they hang up when the page closes them, the server drops them or the runner stops. macOS and Linux only.

### What is NOT enforced

- **Shell commands can use absolute paths anywhere**. The `cd` jail only intercepts `cd`. `cat /etc/passwd`, `ls /Users/you/Documents`, `rm /tmp/whatever` — none of these touch `cd`, so none are blocked. **The agent can read, write, and delete anything you can.**
- The `cd` jail itself is **bash-only**. `sh -c '...'`, `python -c "os.chdir('/')"`, `node -e "..."` all bypass it.
- **Command substitution, eval, env vars, aliases** all defeat the denylist. `$(echo s)$(echo udo) ...` looks nothing like `sudo` to a regex.
- **Network egress is not restricted.** The agent can `curl`/`wget` anywhere, exfiltrate to any host.

### If you need real isolation

The only honest options:

1. **Run the CLI inside a container** with the workdir bind-mounted and nothing else:
   ```bash
   docker run --rm -it -v "$PWD:/work" -w /work python:3.12 bash -c "pip install virtuai-cli && virtuai pair <code> && virtuai chat"
   ```
   The container has access only to what you mount.
2. **OS-level sandboxing**: wrap `virtuai run` in `sandbox-exec` on macOS or run inside a `landlock`/`bubblewrap` jail on Linux.
3. **Switch the agent's backend to the E2B remote sandbox** in the portal. The agent then runs against a disposable VM, not your laptop. The CLI runner becomes optional (you'd only need it for "interactive" workflows where local files matter).

**Use the CLI runner the same way you'd use a powerful shell session you don't fully trust.** Point it at a project directory, never at `~`, and don't run unattended on a machine with credentials lying around.

## Programmatic use

The async building blocks the TUI is built on are importable directly:

```python
import asyncio
from virtuai_cli import config as cfg
from virtuai_cli.chat.sse import stream_chat

async def main():
    token  = cfg.load_token(cfg._KEYRING_CLI_TOKEN_KEY)
    server = cfg.get_server_url()
    async for event in stream_chat(server, token, "your-agent-id", "Hello"):
        if event.get("type") == "token":
            print(event["content"], end="", flush=True)

asyncio.run(main())
```

A formal SDK (typed client class, sync wrappers) is on the roadmap.

## Links

- **Homepage**: <https://imvirtuai.com>
- **PyPI**: <https://pypi.org/project/virtuai-cli/>
- **Issues / docs**: contact your workspace admin
