Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 17 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# contree-cli

[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
[![Zero Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen.svg)](#zero-dependencies)
[![PyPI](https://img.shields.io/pypi/v/contree-cli.svg)](https://pypi.org/project/contree-cli/)

Command-line client for the [ConTree](https://contree.dev) sandboxing platform — secure, VM-isolated sandboxes with git-like branching for AI agents and developers.
Expand All @@ -11,8 +10,9 @@ eval $(contree use tag:ubuntu:latest) # pick a base image for current session
contree run apt-get update -qq # each run snapshots the result
contree run apt-get install -y curl # builds on the previous snapshot
contree session branch experiment # branch the sandbox state
contree session checkout experiment # switch to the branch
contree run -- make test # experiment freely
contree session checkout main # switch back instantly
contree session checkout main # switch back
contree session rollback -- -2 # or rewind two steps
```

Expand All @@ -27,8 +27,6 @@ contree session rollback -- -2 # or rewind two steps
- **Safe code execution** — run untrusted or LLM-generated code inside VM-level isolation; crashes and side effects stay in the sandbox
- **Session continuity** — rewind and resume long-running agent workflows with full filesystem context preserved

`contree-cli` talks to the ConTree API. Install it, authenticate with your project token, and create sandboxes, run commands, inspect filesystems, and manage sessions — all from your terminal, shell scripts, or agent toolchains.

## Install

```bash
Expand Down Expand Up @@ -62,7 +60,7 @@ Verify:
contree --help
```

**Requirements:** Python 3.10+ and nothing else. Zero external dependencies — stdlib only.
**Requirements:** Python 3.10+.

## Quick Start

Expand All @@ -82,7 +80,7 @@ If `--token`/`--url`/`--project` flags are omitted, `contree auth` reads `CONTRE
contree skill install
```

Autodetects installed agents (Claude Code, Codex, OpenCode, Cline, Amp) and installs ConTree skill files into their skill directories. Use `contree skill install -F` to force-overwrite.
Autodetects installed agents (Claude Code, Codex, OpenCode, Cline, Amp) and installs ConTree skill files into their skill directories. Use `contree skill install -f` to force-overwrite.

### 3. Start a session

Expand Down Expand Up @@ -114,7 +112,8 @@ contree cp /app/output.log . # download to local machine

```bash
contree session branch experiment # create a branch
contree run -- make test # experiment on it
contree session checkout experiment # switch to it
contree run -- make test # experiment on the branch
contree session checkout main # switch back
contree session rollback # undo last run (default: back 1 entry)
```
Expand Down Expand Up @@ -234,7 +233,7 @@ main: A ── B ── C ── D
experiment: E ── F
```

Every `run` creates a checkpoint. Branch to explore alternatives. Roll back to any point. Switch branches instantly.
Every non-disposable `run` creates a checkpoint in the session history.

```bash
contree session # show current state
Expand All @@ -247,14 +246,14 @@ contree session use other-session # import image from another session

## Output Formats

All commands support structured output via `-f`/`--format`:
All commands support structured output via `-o`/`--format`/`--output`:

```bash
contree images -f json # JSON (one object per line)
contree images -f json-pretty # pretty-printed JSON array
contree ps -f csv # RFC 4180 CSV
contree ps -f tsv # tab-separated values
contree ls -f table # ASCII table
contree -o json images # JSON (one object per line)
contree -o json-pretty images # pretty-printed JSON array
contree -o csv ps # RFC 4180 CSV
contree -o tsv ps # tab-separated values
contree -o table ls # ASCII table
```

Pipe JSON output into `jq`, feed CSV into spreadsheets, or parse programmatically in your agent toolchain.
Expand Down Expand Up @@ -283,7 +282,7 @@ contree auth --profile=staging # save staging token
contree auth --profile=prod # save production token
contree auth profiles # list all profiles + status probe
contree auth profiles --offline # list profiles without network checks
contree -f json auth profiles # structured profile health output
contree -o json auth profiles # structured profile health output
contree auth switch staging # switch active profile
```

Expand All @@ -308,9 +307,9 @@ Read only by `contree auth` (registration-time fallbacks for omitted flags):

Credentials come strictly from the saved profile at runtime. `--token`, `--url`, `--project` CLI flags override profile fields for a single invocation.

## Zero Dependencies
## Dependencies

`contree-cli` uses only the Python standard library. No `requests`, no `click`, no `rich` — just `http.client`, `argparse`, `json`, `sqlite3`, and friends. It runs anywhere Python 3.10+ is available with nothing to install beyond the package itself.
`contree-cli` has a single runtime dependency: the `contree-client` library, which provides the HTTP transport and typed API bindings.

## Development

Expand All @@ -327,7 +326,7 @@ make check # lint + types
make tests # lint + types + pytest
```

The project enforces strict mypy, ruff linting (E/F/W/I/UP/B/SIM/RUF rules), and full test coverage across 23+ test modules.
The project enforces strict mypy and ruff linting (E/F/W/I/UP/B/SIM/RUF rules).

## Documentation

Expand Down
9 changes: 5 additions & 4 deletions contree_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@
from typing import TYPE_CHECKING, Protocol

if TYPE_CHECKING:
from contree_cli.client import ContreeClient
from contree_cli.config import ConfigProfile
from contree_client.profiles import Profile

from contree_cli.client import CliClient
from contree_cli.output import OutputFormatter
from contree_cli.session import SessionStore

PROFILE: ContextVar[ConfigProfile] = ContextVar("PROFILE")
CLIENT: ContextVar[ContreeClient] = ContextVar("CLIENT")
PROFILE: ContextVar[Profile] = ContextVar("PROFILE")
CLIENT: ContextVar[CliClient] = ContextVar("CLIENT")
FORMATTER: ContextVar[OutputFormatter] = ContextVar("FORMATTER")
SESSION_STORE: ContextVar[SessionStore] = ContextVar("SESSION_STORE")
IN_SHELL: ContextVar[bool] = ContextVar("IN_SHELL", default=False)
Expand Down
46 changes: 26 additions & 20 deletions contree_cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
import logging
import sys
from collections.abc import Callable
from contextlib import suppress
from contextlib import ExitStack, suppress
from dataclasses import replace

from contree_client.exceptions import ContreeError

import contree_cli.config as config_mod
from contree_cli import CLIENT, FORMATTER, PROFILE, SESSION_STORE, ArgumentsProtocol
from contree_cli.arguments import parser
from contree_cli.client import ApiError, client_from_profile
from contree_cli.client import client_from_profile
from contree_cli.config import SETTINGS, Config
from contree_cli.log import setup_logging
from contree_cli.output import FORMATTERS
Expand Down Expand Up @@ -71,32 +73,38 @@ def main() -> None:
)
exit(1)

if needs_client:
try:
client = client_from_profile(profile)
except ValueError as exc:
log.error("%s", exc)
exit(1)
CLIENT.set(client)

formatter = FORMATTERS[args.output_format]()

session_key = get_session_key(profile.name, override=args.session_key)
db_path = profile.session_db_path
log.debug("Running in session: %s", session_key)
# One stack owns every resource the command needs; entries are
# added as they come to life and unwound together on the way out.
with ExitStack() as stack:
if needs_client:
# Session-based transports (requests/httpx/urllib3) hold
# pooled connections; enter the client so open()/close()
# run around the whole command.
try:
client = client_from_profile(profile)
except ValueError as exc:
log.error("%s", exc)
exit(1)
CLIENT.set(stack.enter_context(client))

formatter = FORMATTERS[args.output_format]()
stack.callback(formatter.close)

session_key = get_session_key(profile.name, override=args.session_key)
db_path = config_mod.session_db_path(profile.name)
log.debug("Running in session: %s", session_key)

with SessionStore(db_path, session_key) as store:
PROFILE.set(profile)
FORMATTER.set(formatter)
SESSION_STORE.set(store)
SESSION_STORE.set(stack.enter_context(SessionStore(db_path, session_key)))
ctx = contextvars.copy_context()

loader: type[ArgumentsProtocol] = args.load_args
handler: Callable[[ArgumentsProtocol], int | None] = args.handler

try:
exit_code = ctx.run(handler, loader.from_args(args))
except ApiError as exc:
except ContreeError as exc:
log.error("%s", exc)
exit(1)
except ValueError as exc:
Expand All @@ -110,8 +118,6 @@ def main() -> None:
except KeyboardInterrupt:
log.error("User interrupted")
exit(1)
finally:
formatter.close()

exit(exit_code or 0)

Expand Down
52 changes: 27 additions & 25 deletions contree_cli/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Agent protocol — follow this sequence for every task:
4. Inspect first (read-only):
contree ls /path
contree cat /path/file
contree export /path -F subtree.tar.gz
contree images --prefix=...
contree session show

Expand Down Expand Up @@ -104,7 +105,7 @@ Listing:
contree images --prefix=python filter by tag prefix
contree images -a include untagged
contree images --since 1d last 24 hours
contree -f json images JSON output for scripting
contree -o json images JSON output for scripting

Tagging:
contree tag my-app:v1.0 tag current session image
Expand Down Expand Up @@ -145,7 +146,7 @@ Building from a Dockerfile:

.dockerignore is applied to every COPY/ADD walk on top of the default exclude list (.git, __pycache__, node_modules, etc.).

build runs in its own session keyed by abspath(CONTEXT) (visible as "session": "build:<hash>" in -f json output). `-S <agent_key>` on `build` is harmless but does not bind the build to your agent session. Verify the resulting image from a normal session:
build runs in its own session keyed by abspath(CONTEXT) (visible as "session": "build:<hash>" in -o json output). `-S <agent_key>` on `build` is harmless but does not bind the build to your agent session. Verify the resulting image from a normal session:
contree build . --tag myapp:dev
contree -S agent_verify use tag:myapp:dev
contree -S agent_verify run -D -- myapp --version
Expand Down Expand Up @@ -188,7 +189,7 @@ Listing uploaded files:
contree file ls list all uploaded files in the project
contree file ls --since 1d narrow by upload time
contree file ls -q uuid + sha256 + source only (quiet)
contree -f json file ls JSON output for jq
contree -o json file ls JSON output for jq

Output joins remote files (uuid, sha256, size, created_at) with the local upload cache. The SOURCE column shows whatever this machine used to produce the file:
- absolute host path for files uploaded via `run --file` / `COPY`;
Expand Down Expand Up @@ -243,7 +244,7 @@ Detached mode (-d):
contree session wait block until done + advance branch
contree session wait UUID1 UUID2 poll only (NO branch advance)

NOTE: status filtering uses --status, NOT -S. `-S` is the global session flag and only works BEFORE the subcommand. Also, the default `run -d` output is plain/table -- use `contree -f json run -d ...` to capture the UUID via `jq -r .uuid` reliably.
NOTE: status filtering uses --status, NOT -S. `-S` is the global session flag and only works BEFORE the subcommand. Also, the default `run -d` output is plain/table -- use `contree -o json run -d ...` to capture the UUID via `jq -r .uuid` reliably.

Operation references (UUID_OR_REF):
Every positional that `--help` labels `UUID_OR_REF` -- `op show`, `op cancel`, `op wait`, top-level `show`/`kill`, and `session wait` -- accepts either a real operation UUID OR a session-history reference. References are resolved against the active session (the one selected by `-S <key>`) before the API is called, so the same notation works everywhere.
Expand Down Expand Up @@ -281,24 +282,24 @@ Monitoring background operations:

Default `op ls`/`ps` lists only `EXECUTING`; `PENDING` and `ASSIGNED` are hidden until `-a` or an explicit `--status`. For a full active snapshot, fetch with `-a` and filter client-side.

`op wait` is a pure observer: polls and prints one operation record per completion. Default formatter pins uuid, status, exit_code, timed_out, duration first and error last; every other scalar API field appears between them, so column count is not fixed. For scripts use `-f json` (one object per line) or `-f tsv` and select fields explicitly. `status` is the server's word verbatim (orchestration outcome — did the API run the job?); the sandbox process's exit code lives in the separate `exit_code` column. The CLI exit code is 1 when any op finishes non-SUCCESS, or the actual `exit_code` when a SUCCESS op had a non-zero sandbox exit (so `op wait && next-step` still composes naturally with `run -- false`). --timeout (default 60s) caps the wait. Use --all to wait for every currently active op in the project.
`op wait` is a pure observer: polls and prints one operation record per completion. Default formatter pins uuid, status, exit_code, timed_out, duration first and error last; every other scalar API field appears between them, so column count is not fixed. For scripts use `-o json` (one object per line) or `-o tsv` and select fields explicitly. `status` is the server's word verbatim (orchestration outcome — did the API run the job?); the sandbox process's exit code lives in the separate `exit_code` column. The CLI exit code is 1 when any op finishes non-SUCCESS, or the actual `exit_code` when a SUCCESS op had a non-zero sandbox exit (so `op wait && next-step` still composes naturally with `run -- false`). --timeout (default 60s) caps the wait. Use --all to wait for every currently active op in the project.

Rule of thumb -- use `op wait` ONLY outside session context: `op wait` is the right tool when the UUIDs came from somewhere else (different session, different agent, `images import`, raw API call) and you only need "is it done yet?". For ops you spawned in *this* session, use `session wait` (no-arg form) instead -- it polls AND advances the active branch to each result image, which `op wait` will not do.

Caveat 1 -- `op wait` does NOT advance session state: each `run -d` (non-disposable) creates a `detached-<op-uuid>` branch pointing at the START image. `op wait` does not move those branches to the result image; the result lives only on the server. After fan-out + wait the session looks the same as before the wait, just with `detached-*` branches accumulated.

PREFERRED fan-out (--disposable, no image-tracking concerns):
A=$(contree -S <key> -f json run -d --disposable -- pytest tests/a | jq -r .uuid)
B=$(contree -S <key> -f json run -d --disposable -- pytest tests/b | jq -r .uuid)
C=$(contree -S <key> -f json run -d --disposable -- pytest tests/c | jq -r .uuid)
A=$(contree -S <key> -o json run -d --disposable -- pytest tests/a | jq -r .uuid)
B=$(contree -S <key> -o json run -d --disposable -- pytest tests/b | jq -r .uuid)
C=$(contree -S <key> -o json run -d --disposable -- pytest tests/c | jq -r .uuid)
contree -S <key> op wait "$A" "$B" "$C" block until all complete
contree -S <key> op show "$A" "$B" "$C" stdout/stderr per op

Non-disposable fan-out (must recover images manually):
A=$(contree -S <key> -f json run -d -- apt-get install -y curl | jq -r .uuid)
B=$(contree -S <key> -f json run -d -- apt-get install -y wget | jq -r .uuid)
A=$(contree -S <key> -o json run -d -- apt-get install -y curl | jq -r .uuid)
B=$(contree -S <key> -o json run -d -- apt-get install -y wget | jq -r .uuid)
contree -S <key> op wait "$A" "$B"
IMG_A=$(contree -f json op show "$A" | jq -r .image)
IMG_A=$(contree -o json op show "$A" | jq -r .image)
contree use "$IMG_A" bind chosen result back

Caveat 2 -- `op wait --all` is project-wide: if another agent (or another shell of yours) is running concurrently in the same project, your --all will block on its ops too. The result is still a valid wait, just possibly not over the set you expected. For session-spawned fan-out the correct alternative is `contree -S <key> session wait` (no args): it drains only this session's pending detached ops and advances the active branch with each result image. Reach for `op wait --all` only when you really want a project-wide observer (admin/cleanup tooling).
Expand Down Expand Up @@ -362,10 +363,10 @@ Rules for reliable agent workflows:
3. Why split? Chained runs collapse into one checkpoint. If `make test` fails, you can't rollback to just after `apt install`. Split runs give you granular rollback.

4. Global flags (-f, -S, -p) MUST come before the subcommand:
Right: contree -S key -f json images
Wrong: contree images -S key -f json
Right: contree -S key -o json images
Wrong: contree images -S key -o json

5. Use -f json for structured output in automation: `contree -f json images | jq '.uuid'`.
5. Use -o json for structured output in automation: `contree -o json images | jq '.uuid'`.

6. Agents must never run `contree auth`. Only users manage auth.

Expand All @@ -378,23 +379,23 @@ Rules for reliable agent workflows:
Output formats
==============

Global -f flag goes before the subcommand. Always available formats:
Global -o flag goes before the subcommand. Always available formats:

contree -f json images one JSON object per line (JSONL)
contree -f json-pretty ps pretty-printed JSON array
contree -f csv images CSV with header row
contree -f tsv ps tab-separated values
contree -f plain images key: value blocks
contree -o json images one JSON object per line (JSONL)
contree -o json-pretty ps pretty-printed JSON array
contree -o csv images CSV with header row
contree -o tsv ps tab-separated values
contree -o plain images key: value blocks

`-f toml` is available only on Python 3.11+ (it relies on stdlib `tomllib`). On Python 3.10 it is silently absent from --help.
`-o toml` is available only on Python 3.11+ (it relies on stdlib `tomllib`). On Python 3.10 it is silently absent from --help.

Scripting examples:
contree -f json images --prefix=python | jq -r '.uuid'
contree -f json ps -a | jq 'select(.status=="SUCCESS")'
contree -f csv images > images.csv
contree -o json images --prefix=python | jq -r '.uuid'
contree -o json ps -a | jq 'select(.status=="SUCCESS")'
contree -o csv images > images.csv
contree ps -q | xargs contree show

Note: `run` with default formatter prints raw stdout/stderr. Use -f json to get structured operation metadata instead.
Note: `run` with default formatter prints raw stdout/stderr. Use -o json to get structured operation metadata instead.

Profiles
========
Expand Down Expand Up @@ -450,6 +451,7 @@ All commands
ls [PATH] List files in image (no VM)
cat PATH Show file content (no VM)
cp PATH DEST Download file from image
export [PATH] Export rootfs or a subtree as tar.gz (-F FILE or pipe; --decompress for plain tar)
cd [PATH] Change session working directory
env [KEY=VALUE ...] Session env vars (-U to unset)
file edit PATH Edit remote file via $EDITOR
Expand Down
Loading
Loading