chore: import upstream snapshot with attribution
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:30:44 +08:00
commit bcbd1bdb22
5748 changed files with 562488 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
# Mirage CLI
`mirage` is the command-line client for the Mirage daemon. It talks to a local
daemon over HTTP and auto-spawns one on first use if none is running.
## Basic usage
```bash
# A minimal workspace config (workspace.yaml)
cat > workspace.yaml <<'YAML'
mounts:
/:
resource: ram
mode: WRITE
YAML
# Create a workspace (spawns the daemon if needed)
mirage workspace create ./workspace.yaml --id myws
# Run a command in it
mirage execute -w myws -c 'echo hello'
# Inspect / clean up
mirage workspace list
mirage workspace get myws
mirage workspace delete myws
# Stop the daemon
mirage daemon stop
```
Other command groups: `session`, `provision`, `daemon`, plus
`workspace clone|snapshot|load`. Run `mirage <command> --help` for details.
## Environment variables
| Variable | Default | Purpose |
| --------------------------- | ------------------------- | --------------------------------------------------------------------------------- |
| `MIRAGE_DAEMON_URL` | `http://127.0.0.1:8765` | Daemon address the CLI connects to |
| `MIRAGE_TOKEN` | (none) | Bearer token the CLI sends to the daemon |
| `MIRAGE_AUTH_MODE` | `local` | Daemon auth mode: `local`, `token`, or `jwt` |
| `MIRAGE_AUTH_TOKEN` | (auto-minted in `local`) | Token the daemon accepts |
| `MIRAGE_IDLE_GRACE_SECONDS` | `30` | Seconds the daemon waits after its last workspace is removed before shutting down |
| `MIRAGE_ALLOWED_HOSTS` | `127.0.0.1,localhost,::1` | Daemon Host-header allowlist (CSV; `*` disables the check) |
| `MIRAGE_DAEMON_PORT` | `8765` | Port the spawned daemon binds |
| `MIRAGE_PID_FILE` | `~/.mirage/daemon.pid` | Daemon pid file location |
| `MIRAGE_VERSION_ROOT` | `~/.mirage/repos` | Bare git repos backing workspace versioning |
| `MIRAGE_SNAPSHOT_ROOT` | `~/.mirage/snapshots` | Snapshot storage root |
Every daemon setting (except the bootstrap `MIRAGE_HOME` and raw
secrets) can also live in `~/.mirage/config.toml` under `[daemon]`,
managed with `mirage config list|get|set|unset`. Per key the
precedence is: env var > config.toml > default. `mirage config list --resolved` prints the effective value of every key and where it came
from. The daemon validates the `[daemon]` table at startup and
refuses to start on unknown keys or malformed TOML; `mirage config unset <key>` accepts unknown keys so a broken file can be repaired.
+17
View File
@@ -0,0 +1,17 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.cli.main import app
__all__ = ["app"]
+167
View File
@@ -0,0 +1,167 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import os
import subprocess
import sys
import time
from urllib.parse import urlparse
import httpx
from mirage.cli.env import ENV_AUTH_MODE, ENV_AUTH_TOKEN
from mirage.cli.settings import DaemonSettings, load_daemon_settings
from mirage.server.auth import AuthMode
from mirage.server.auth import storage as auth_storage
from mirage.server.daemon_config import (read_daemon_table,
validate_daemon_table)
from mirage.server.env import ENV_DAEMON_PORT
from mirage.server.paths import mirage_home
class DaemonUnreachable(RuntimeError):
pass
class DaemonClient:
"""Thin httpx wrapper around the Mirage daemon REST API.
Constructs once per CLI invocation and is reused across calls.
Handles auth header injection, daemon auto-spawn on first
``workspace --create``, and discovery via the settings chain.
"""
def __init__(self, settings: DaemonSettings) -> None:
self.settings = settings
self._client = httpx.Client(base_url=settings.url, timeout=60.0)
def __enter__(self) -> "DaemonClient":
return self
def __exit__(self, *exc_info) -> None:
self._client.close()
def _headers(self) -> dict[str, str]:
if self.settings.auth_token:
return {"Authorization": f"Bearer {self.settings.auth_token}"}
return {}
def request(self, method: str, path: str, **kwargs) -> httpx.Response:
headers = {**self._headers(), **kwargs.pop("headers", {})}
return self._client.request(method, path, headers=headers, **kwargs)
def is_reachable(self, timeout: float = 0.5) -> bool:
try:
r = self._client.get("/v1/health",
timeout=timeout,
headers=self._headers())
return r.status_code == 200
except httpx.RequestError:
return False
def ensure_running(self,
startup_timeout: float = 5.0,
allow_spawn: bool = True) -> None:
"""Ensure the daemon is reachable, optionally spawning it.
Args:
startup_timeout (float): seconds to wait for the spawned
daemon to answer ``/v1/health``.
allow_spawn (bool): if True and the daemon is unreachable,
fork-execs ``uvicorn mirage.server.app:app`` detached.
Raises:
DaemonUnreachable: daemon is not reachable and either
``allow_spawn=False`` or the spawned daemon failed to
come up within ``startup_timeout``.
"""
if self.is_reachable():
return
if not allow_spawn:
raise DaemonUnreachable(
f"daemon not reachable at {self.settings.url}; "
"run `mirage workspace --create CONFIG.yaml` to spawn one")
self._spawn_daemon()
deadline = time.monotonic() + startup_timeout
while time.monotonic() < deadline:
if self.is_reachable(timeout=0.3):
return
time.sleep(0.1)
raise DaemonUnreachable(
f"daemon spawned but did not answer /v1/health within "
f"{startup_timeout:.1f}s")
def _spawn_daemon(self) -> None:
table = read_daemon_table(mirage_home())
validate_daemon_table(table)
port = self._resolve_port(table)
env = dict(os.environ)
if not self.settings.auth_token:
self.settings.auth_token = auth_storage.ensure_token_file(
auth_storage.default_token_file())
env[ENV_AUTH_TOKEN] = self.settings.auth_token
if ENV_AUTH_MODE not in env and not table.get("auth_mode"):
env[ENV_AUTH_MODE] = AuthMode.LOCAL.value
cmd = [
sys.executable,
"-m",
"uvicorn",
"mirage.server.daemon:app",
"--host",
"127.0.0.1",
"--port",
str(port),
"--log-level",
"warning",
]
log_dir = mirage_home()
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / "daemon.log"
with open(log_file, "ab") as f:
subprocess.Popen(
cmd,
env=env,
stdout=f,
stderr=subprocess.STDOUT,
start_new_session=True,
)
def _resolve_port(self, table: dict) -> int:
"""Resolve the listen port for a spawned daemon.
Priority: ``$MIRAGE_DAEMON_PORT``, then the ``port`` key in
``config.toml`` ``[daemon]``, then the port embedded in the
client ``url`` setting, then 8765.
Args:
table (dict): the already-read ``[daemon]`` table.
Returns:
int: the port to bind.
"""
env_port = os.environ.get(ENV_DAEMON_PORT)
if env_port:
return int(env_port)
config_port = table.get("port")
if config_port:
return int(config_port)
return self._port_from_url()
def _port_from_url(self) -> int:
parsed = urlparse(self.settings.url)
return parsed.port or 8765
def make_client() -> DaemonClient:
return DaemonClient(load_daemon_settings())
+120
View File
@@ -0,0 +1,120 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import typer
from mirage.cli.output import emit, fail
from mirage.cli.settings import (get_config, list_config, resolved_config,
set_config, unset_config)
from mirage.server.daemon_config import ALLOWED_KEYS, DaemonConfigError
app = typer.Typer(no_args_is_help=True,
help="Read and write daemon settings in config.toml.")
def _mask(key: str, value: str) -> str:
if key == "auth_token" and value:
return "***"
return value
def _human_table(table: dict) -> str:
return "\n".join(f"{k} = {v}" for k, v in table.items())
def _human_resolved(table: dict) -> str:
return "\n".join(f"{k} = {e['value']} ({e['origin']})"
for k, e in table.items())
def _list_resolved() -> None:
try:
resolved = resolved_config()
except DaemonConfigError as e:
fail(str(e), exit_code=2)
payload = {}
for key, (value, origin) in resolved.items():
payload[key] = {"value": _mask(key, value), "origin": origin}
emit(payload, human=_human_resolved)
def _list_file() -> None:
try:
table = list_config()
except DaemonConfigError as e:
fail(str(e), exit_code=2)
unknown = sorted(set(table) - ALLOWED_KEYS)
if unknown:
typer.echo(
"warning: unknown [daemon] keys (daemon will refuse to "
f"start): {', '.join(unknown)}",
err=True)
emit(table, human=_human_table)
_RESOLVED_OPTION = typer.Option(False,
"--resolved",
help="show effective values and their origins")
@app.command("list")
def list_cmd(resolved: bool = _RESOLVED_OPTION) -> None:
"""Print every key in the config.toml [daemon] table.
With --resolved, print the effective value of every key after
applying precedence (env var > config file > default) and where
each value came from. auth_token is masked.
"""
if resolved:
_list_resolved()
else:
_list_file()
@app.command("get")
def get_cmd(key: str = typer.Argument(..., help="config key")) -> None:
"""Print one [daemon] key's value from config.toml."""
try:
value = get_config(key)
except DaemonConfigError as e:
fail(str(e), exit_code=2)
if value is None:
fail(f"{key} is not set", exit_code=1)
emit({key: value}, human=lambda d: str(d[key]))
@app.command("set")
def set_cmd(
key: str = typer.Argument(..., help="config key"),
value: str = typer.Argument(..., help="value to store"),
) -> None:
"""Write a [daemon] key to config.toml.
Path settings take effect on the next daemon start/restart.
"""
try:
set_config(key, value)
except DaemonConfigError as e:
fail(str(e), exit_code=2)
emit({key: value, "written": True}, human=lambda d: f"{key} = {value}")
@app.command("unset")
def unset_cmd(key: str = typer.Argument(..., help="config key")) -> None:
"""Remove a [daemon] key from config.toml."""
try:
unset_config(key)
except DaemonConfigError as e:
fail(str(e), exit_code=2)
emit({key: None, "unset": True}, human=lambda d: f"unset {key}")
+232
View File
@@ -0,0 +1,232 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import os
import signal
import time
import httpx
import typer
from mirage.cli.client import make_client
from mirage.cli.output import emit, fail, format_age
from mirage.server.paths import pid_file_path
app = typer.Typer(no_args_is_help=True,
help="Manage the daemon process lifecycle.")
def _format_status(d: dict) -> str:
if not d.get("running"):
return f"Daemon not running. URL: {d['url']}"
parts = [f"Running. PID {d.get('pid', '?')}"]
uptime = d.get("uptime_s")
if uptime is not None:
parts.append(f"uptime {format_age(time.time() - uptime)}")
ws_count = d.get("workspaces")
if ws_count is not None:
parts.append(f"{ws_count} workspace{'s' if ws_count != 1 else ''}")
return ", ".join(parts) + f". URL: {d['url']}"
def _format_stop(d: dict) -> str:
via = d.get("via", "?")
pid = d.get("pid")
return f"Stopped (via {via}{f', PID {pid}' if pid else ''})."
def _format_restart(d: dict) -> str:
if d.get("spawned_fresh"):
return "Restarted (eager spawn)."
return "Restarted; next CLI command will auto-spawn."
def _format_kill(d: dict) -> str:
if d.get("killed"):
return f"Killed PID {d['pid']}."
pid = d.get("pid")
if pid is None:
return "Daemon not running."
return f"Already gone (PID {pid})."
def _read_pid() -> int | None:
pid_file = pid_file_path()
if not pid_file.exists():
return None
try:
return int(pid_file.read_text().strip())
except (ValueError, OSError):
return None
def _process_alive(pid: int) -> bool:
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
@app.command("status")
def status_cmd() -> None:
"""Show daemon health, PID, uptime, workspace count."""
pid = _read_pid()
with make_client() as client:
url = client.settings.url
try:
r = client.request("GET", "/v1/health")
health = r.json()
except httpx.RequestError:
health = None
if health is None:
emit({"running": False, "pid": pid, "url": url}, human=_format_status)
raise typer.Exit(code=1)
emit({
"running": True,
"pid": pid,
"url": url,
**health
},
human=_format_status)
@app.command("stop")
def stop_cmd(
timeout: float = typer.Option(
5.0,
"--timeout",
help="Seconds to wait for graceful exit before SIGTERM."),
) -> None:
"""Gracefully stop the daemon.
Calls ``POST /v1/shutdown`` which trips the daemon's exit event.
The daemon closes active workspaces and exits. If the daemon
doesn't exit within ``--timeout``, fall back to SIGTERM via the
PID file.
"""
with make_client() as client:
try:
r = client.request("POST", "/v1/shutdown")
except httpx.RequestError as e:
fail(f"daemon not reachable: {e}", exit_code=1)
if r.status_code >= 400:
fail(f"shutdown failed: {r.text}", exit_code=2)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
with make_client() as client:
if not client.is_reachable(timeout=0.3):
emit({"stopped": True, "via": "graceful"}, human=_format_stop)
return
time.sleep(0.2)
pid = _read_pid()
if pid and _process_alive(pid):
try:
os.kill(pid, signal.SIGTERM)
emit({
"stopped": True,
"via": "sigterm",
"pid": pid
},
human=_format_stop)
return
except ProcessLookupError:
pass
fail(f"daemon did not exit within {timeout}s and no live PID found",
exit_code=2)
@app.command("restart")
def restart_cmd(
timeout: float = typer.Option(5.0, "--timeout"),
eager: bool = typer.Option(
False,
"--eager",
help="Spawn a fresh daemon immediately rather than waiting for "
"the next CLI command to auto-spawn."),
) -> None:
"""Stop the daemon. Next CLI command auto-spawns a fresh one.
Workspaces are LOST on restart. Save any you want to keep first
with ``mirage workspace snapshot <id> <path>`` and bring them
back with ``mirage workspace load <path>``.
"""
with make_client() as client:
try:
client.request("POST", "/v1/shutdown")
except httpx.RequestError:
pass
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
with make_client() as client:
if not client.is_reachable(timeout=0.3):
break
time.sleep(0.2)
else:
pid = _read_pid()
if pid and _process_alive(pid):
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
pass
if eager:
with make_client() as client:
client.ensure_running()
emit({"restarted": True, "spawned_fresh": True}, human=_format_restart)
return
emit(
{
"restarted": True,
"spawned_fresh": False,
"note": "next workspace --create will auto-spawn",
},
human=_format_restart,
)
@app.command("kill")
def kill_cmd() -> None:
"""SIGKILL the daemon. Last resort -- skips graceful shutdown."""
pid = _read_pid()
if pid is None:
emit({
"killed": False,
"reason": "no daemon running",
"pid": None
},
human=_format_kill)
return
if not _process_alive(pid):
emit({
"killed": False,
"reason": "process already gone",
"pid": pid
},
human=_format_kill)
return
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
emit({
"killed": False,
"reason": "process gone",
"pid": pid
},
human=_format_kill)
return
emit({"killed": True, "pid": pid}, human=_format_kill)
+38
View File
@@ -0,0 +1,38 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.server.auth.config import (ENV_AUTH_MODE, ENV_AUTH_TOKEN,
ENV_JWT_ALG, ENV_JWT_AUDIENCE,
ENV_JWT_AUTHORIZED_PARTIES,
ENV_JWT_CLOCK_SKEW, ENV_JWT_ISSUER,
ENV_JWT_PUBKEY, ENV_JWT_PUBKEY_FILE)
from mirage.server.env import ENV_IDLE_GRACE_SECONDS
ENV_DAEMON_URL = "MIRAGE_DAEMON_URL"
ENV_TOKEN = "MIRAGE_TOKEN"
__all__ = [
"ENV_AUTH_MODE",
"ENV_AUTH_TOKEN",
"ENV_DAEMON_URL",
"ENV_IDLE_GRACE_SECONDS",
"ENV_JWT_ALG",
"ENV_JWT_AUDIENCE",
"ENV_JWT_AUTHORIZED_PARTIES",
"ENV_JWT_CLOCK_SKEW",
"ENV_JWT_ISSUER",
"ENV_JWT_PUBKEY",
"ENV_JWT_PUBKEY_FILE",
"ENV_TOKEN",
]
+75
View File
@@ -0,0 +1,75 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import json
import sys
import typer
from mirage.cli.client import make_client
from mirage.cli.output import emit, exit_code_from_response, handle_response
app = typer.Typer(invoke_without_command=True, help="Execute a command.")
@app.callback(invoke_without_command=True)
def execute_cmd(
workspace_id: str = typer.Option(...,
"--workspace_id",
"--workspace",
"-w",
help="Workspace id."),
command: str = typer.Option(...,
"--command",
"-c",
help="Shell command to execute."),
session_id: str | None = typer.Option(None,
"--session_id",
"--session",
"-s",
help="Session id."),
background: bool = typer.Option(
False,
"--background",
"--bg",
help="Don't wait; return job_id immediately.",
),
) -> None:
"""Execute a command in a workspace.
For dry-run / cost-estimate output, use ``mirage provision``
instead.
"""
payload: dict = {"command": command, "provision": False}
if session_id:
payload["session_id"] = session_id
path = f"/v1/workspaces/{workspace_id}/execute"
if background:
path += "?background=true"
with make_client() as client:
client.ensure_running(allow_spawn=False)
if not sys.stdin.isatty() and not background:
stdin_bytes = sys.stdin.buffer.read()
files = {
"request":
("request.json", json.dumps(payload), "application/json"),
"stdin":
("stdin.bin", stdin_bytes, "application/octet-stream"),
}
r = client.request("POST", path, files=files)
else:
r = client.request("POST", path, json=payload)
response = handle_response(r)
emit(response)
raise typer.Exit(code=exit_code_from_response(response))
+77
View File
@@ -0,0 +1,77 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import typer
from mirage.cli.client import make_client
from mirage.cli.output import emit, exit_code_from_response, handle_response
app = typer.Typer(no_args_is_help=True, help="Manage daemon jobs.")
@app.command("list")
def list_cmd(
workspace_id: str | None = typer.Option(
None,
"--workspace_id",
"--workspace",
"-w",
help="Filter to one workspace.",
),
) -> None:
path = "/v1/jobs"
if workspace_id:
path += f"?workspace_id={workspace_id}"
with make_client() as client:
client.ensure_running(allow_spawn=False)
r = client.request("GET", path)
emit(handle_response(r))
@app.command("get")
def get_cmd(job_id: str = typer.Argument(...)) -> None:
with make_client() as client:
client.ensure_running(allow_spawn=False)
r = client.request("GET", f"/v1/jobs/{job_id}")
response = handle_response(r)
emit(response)
raise typer.Exit(code=exit_code_from_response(response))
@app.command("wait")
def wait_cmd(
job_id: str = typer.Argument(...),
timeout: float | None = typer.Option(
None,
"--timeout",
help="Seconds to wait before returning a still-running snapshot.",
),
) -> None:
body: dict = {}
if timeout is not None:
body["timeout_s"] = timeout
with make_client() as client:
client.ensure_running(allow_spawn=False)
r = client.request("POST", f"/v1/jobs/{job_id}/wait", json=body)
response = handle_response(r)
emit(response)
raise typer.Exit(code=exit_code_from_response(response))
@app.command("cancel")
def cancel_cmd(job_id: str = typer.Argument(...)) -> None:
with make_client() as client:
client.ensure_running(allow_spawn=False)
r = client.request("DELETE", f"/v1/jobs/{job_id}")
emit(handle_response(r))
+50
View File
@@ -0,0 +1,50 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import typer
from mirage.cli import config as config_module
from mirage.cli import daemon as daemon_module
from mirage.cli import execute as execute_module
from mirage.cli import job as job_module
from mirage.cli import provision as provision_module
from mirage.cli import session as session_module
from mirage.cli import workspace as workspace_module
from mirage.server.daemon_config import DaemonConfigError
app = typer.Typer(
name="mirage",
help="Mirage daemon CLI: manage workspaces and execute commands.",
no_args_is_help=True,
)
app.add_typer(workspace_module.app, name="workspace")
app.add_typer(session_module.app, name="session")
app.add_typer(job_module.app, name="job")
app.add_typer(execute_module.app, name="execute")
app.add_typer(provision_module.app, name="provision")
app.add_typer(daemon_module.app, name="daemon")
app.add_typer(config_module.app, name="config")
def main() -> None:
"""Entry point that turns config errors into clean exit-2 lines."""
try:
app()
except DaemonConfigError as e:
typer.echo(str(e), err=True)
raise SystemExit(2) from e
if __name__ == "__main__":
main()
+103
View File
@@ -0,0 +1,103 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import json
import math
import sys
import time
from typing import Any, Callable
import httpx
import typer
def emit(obj: Any, human: Callable[[Any], str] | None = None) -> None:
"""Print ``obj`` to stdout.
Renders ``human(obj)`` when stdout is a TTY and a formatter is
given; otherwise pretty JSON, so ``| jq`` keeps working.
Args:
obj (Any): Value to print.
human (Callable | None): Optional table/describe formatter.
"""
if human is not None and sys.stdout.isatty():
typer.echo(human(obj))
return
typer.echo(json.dumps(obj, indent=2, default=str))
def fail(message: str, exit_code: int = 1) -> None:
typer.echo(message, err=True)
raise typer.Exit(code=exit_code)
def exit_code_from_response(r: Any) -> int:
if not isinstance(r, dict):
return 0
if r.get("kind") == "io":
inner: dict | None = r
else:
result = r.get("result")
inner = result if isinstance(result, dict) else None
if inner is not None and inner.get("kind") == "io":
code = inner.get("exit_code")
if isinstance(code, bool) or not isinstance(code, (int, float)):
return 0
if not math.isfinite(code):
return 0
return max(0, min(255, int(code)))
status = r.get("status")
if status in ("failed", "canceled"):
return 2
return 0
def handle_response(r: httpx.Response) -> dict | list:
if r.status_code >= 400:
try:
detail = r.json().get("detail", r.text)
except ValueError:
detail = r.text
fail(f"daemon error {r.status_code}: {detail}", exit_code=2)
if not r.content:
return {}
return r.json()
def format_age(epoch: float, now: float | None = None) -> str:
"""Render a Unix timestamp as a short relative age (``2m``, ``3h``)."""
delta = max(0, (now if now is not None else time.time()) - epoch)
if delta < 60:
return f"{int(delta)}s"
if delta < 3600:
return f"{int(delta // 60)}m"
if delta < 86400:
return f"{int(delta // 3600)}h"
return f"{int(delta // 86400)}d"
def format_table(headers: list[str], rows: list[list[str]]) -> str:
"""Format a left-aligned table with two-space column gutters."""
if not rows:
return " ".join(headers)
widths = [len(h) for h in headers]
for row in rows:
for i, cell in enumerate(row):
widths[i] = max(widths[i], len(cell))
lines = [" ".join(h.ljust(w) for h, w in zip(headers, widths)).rstrip()]
for row in rows:
lines.append(" ".join(c.ljust(w)
for c, w in zip(row, widths)).rstrip())
return "\n".join(lines)
+56
View File
@@ -0,0 +1,56 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import typer
from mirage.cli.client import make_client
from mirage.cli.output import emit, handle_response
app = typer.Typer(invoke_without_command=True,
help="Estimate cost / preview a command without running it.")
@app.callback(invoke_without_command=True)
def provision_cmd(
workspace_id: str = typer.Option(...,
"--workspace_id",
"--workspace",
"-w",
help="Workspace id."),
command: str = typer.Option(...,
"--command",
"-c",
help="Shell command to estimate."),
session_id: str | None = typer.Option(None,
"--session_id",
"--session",
"-s",
help="Session id."),
) -> None:
"""Dry-run a command and return its cost estimate.
Returns a ``ProvisionResult`` shape (network bytes, cache hits,
estimated cost) instead of actually running the command.
"""
payload: dict = {"command": command, "provision": True}
if session_id:
payload["session_id"] = session_id
with make_client() as client:
client.ensure_running(allow_spawn=False)
r = client.request(
"POST",
f"/v1/workspaces/{workspace_id}/execute",
json=payload,
)
emit(handle_response(r))
+67
View File
@@ -0,0 +1,67 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import typer
from mirage.cli.client import make_client
from mirage.cli.output import emit, handle_response
app = typer.Typer(no_args_is_help=True, help="Manage workspace sessions.")
@app.command("create")
def create_cmd(
workspace_id: str = typer.Argument(...),
session_id: str | None = typer.Option(None,
"--id",
help="Explicit session id."),
mount: list[str] = typer.Option(
[],
"--mount",
"-m",
help=("Restrict this session to the listed mount prefix. "
"Repeat to allow multiple mounts; omit for unrestricted."),
),
) -> None:
body: dict = {}
if session_id:
body["session_id"] = session_id
if mount:
body["allowed_mounts"] = mount
with make_client() as client:
client.ensure_running(allow_spawn=False)
r = client.request("POST",
f"/v1/workspaces/{workspace_id}/sessions",
json=body)
emit(handle_response(r))
@app.command("list")
def list_cmd(workspace_id: str = typer.Argument(...)) -> None:
with make_client() as client:
client.ensure_running(allow_spawn=False)
r = client.request("GET", f"/v1/workspaces/{workspace_id}/sessions")
emit(handle_response(r))
@app.command("delete")
def delete_cmd(
workspace_id: str = typer.Argument(...),
session_id: str = typer.Argument(...),
) -> None:
with make_client() as client:
client.ensure_running(allow_spawn=False)
r = client.request(
"DELETE", f"/v1/workspaces/{workspace_id}/sessions/{session_id}")
emit(handle_response(r))
+292
View File
@@ -0,0 +1,292 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import os
import tomllib
from dataclasses import dataclass
from pathlib import Path
from mirage.cli.env import ENV_DAEMON_URL, ENV_TOKEN
from mirage.server.auth import storage as auth_storage
from mirage.server.auth.config import (ENV_AUTH_MODE, ENV_JWT_ALG,
ENV_JWT_AUDIENCE,
ENV_JWT_AUTHORIZED_PARTIES,
ENV_JWT_CLOCK_SKEW, ENV_JWT_ISSUER,
ENV_JWT_PUBKEY_FILE)
from mirage.server.daemon_config import (ALLOWED_KEYS, NUMERIC_KEYS,
DaemonConfigError, read_daemon_table)
from mirage.server.env import (ENV_ALLOWED_HOSTS, ENV_DAEMON_PORT,
ENV_IDLE_GRACE_SECONDS, ENV_PID_FILE,
ENV_SNAPSHOT_ROOT, ENV_VERSION_ROOT)
from mirage.server.host_validation_constants import DEFAULT_ALLOWED_HOSTS
from mirage.server.paths import mirage_home
DEFAULT_DAEMON_URL = "http://127.0.0.1:8765"
_ENV_FOR_KEY = {
"url": ENV_DAEMON_URL,
"allowed_hosts": ENV_ALLOWED_HOSTS,
"auth_mode": ENV_AUTH_MODE,
"jwt_alg": ENV_JWT_ALG,
"jwt_issuer": ENV_JWT_ISSUER,
"jwt_audience": ENV_JWT_AUDIENCE,
"jwt_pubkey_file": ENV_JWT_PUBKEY_FILE,
"jwt_clock_skew": ENV_JWT_CLOCK_SKEW,
"jwt_authorized_parties": ENV_JWT_AUTHORIZED_PARTIES,
"auth_token": ENV_TOKEN,
"idle_grace_seconds": ENV_IDLE_GRACE_SECONDS,
"port": ENV_DAEMON_PORT,
"pid_file": ENV_PID_FILE,
"version_root": ENV_VERSION_ROOT,
"snapshot_root": ENV_SNAPSHOT_ROOT,
}
@dataclass
class DaemonSettings:
url: str = DEFAULT_DAEMON_URL
socket: str = ""
auth_token: str = ""
idle_grace_seconds: float = 30.0
def config_path() -> Path:
return mirage_home() / "config.toml"
def load_daemon_settings(path: Path | None = None) -> DaemonSettings:
"""Load daemon settings, applying the override chain.
Order of precedence (highest first):
1. ``MIRAGE_DAEMON_URL`` env var
2. ``MIRAGE_TOKEN`` env var
3. values in ``$MIRAGE_HOME/config.toml`` (default
``~/.mirage/config.toml``) ``[daemon]`` table
4. defaults
Args:
path (Path | None): config file location. Defaults to
``config_path()``.
Returns:
DaemonSettings: resolved settings.
"""
use_path = path or config_path()
if path is not None:
if use_path.exists():
with open(use_path, "rb") as f:
table = tomllib.load(f).get("daemon", {})
else:
table = {}
else:
table = read_daemon_table(mirage_home())
settings = DaemonSettings(
url=str(table.get("url", DEFAULT_DAEMON_URL)),
socket=str(table.get("socket", "")),
auth_token=str(table.get("auth_token", "")),
idle_grace_seconds=float(table.get("idle_grace_seconds", 30.0)),
)
env_url = os.environ.get(ENV_DAEMON_URL)
if env_url:
settings.url = env_url
env_token = os.environ.get(ENV_TOKEN)
if env_token:
settings.auth_token = env_token
if not settings.auth_token:
file_token = auth_storage.read_token_file(
auth_storage.default_token_file())
if file_token:
settings.auth_token = file_token
return settings
def _default_for_key(key: str, home: Path) -> str:
defaults = {
"url": DEFAULT_DAEMON_URL,
"allowed_hosts": ",".join(DEFAULT_ALLOWED_HOSTS),
"auth_mode": "local",
"jwt_alg": "",
"jwt_issuer": "",
"jwt_audience": "",
"jwt_pubkey_file": "",
"jwt_clock_skew": "5",
"jwt_authorized_parties": "",
"socket": "",
"auth_token": "",
"idle_grace_seconds": "30",
"port": "8765",
"pid_file": str(home / "daemon.pid"),
"version_root": str(home / "repos"),
"snapshot_root": str(home / "snapshots"),
}
return defaults[key]
def resolved_config() -> dict[str, tuple[str, str]]:
"""Resolve every config key to its effective value and origin.
Origin is ``"env <NAME>"``, ``"file"``, or ``"default"``, applying
the same precedence the daemon and CLI use (env > file > default;
explicit per-call arguments are not represented here).
Returns:
dict[str, tuple[str, str]]: key to (effective value, origin).
"""
home = mirage_home()
table = read_daemon_table(home)
out: dict[str, tuple[str, str]] = {}
for key in sorted(ALLOWED_KEYS):
env_name = _ENV_FOR_KEY.get(key)
env_value = os.environ.get(env_name) if env_name else None
if env_value:
out[key] = (env_value, f"env {env_name}")
elif str(table.get(key, "")):
out[key] = (str(table[key]), "file")
else:
out[key] = (_default_for_key(key, home), "default")
return out
def _check_key(key: str) -> None:
if key not in ALLOWED_KEYS:
raise DaemonConfigError(f"unknown config key: {key!r}; allowed: "
f"{', '.join(sorted(ALLOWED_KEYS))}")
def _format_value(key: str, value: str) -> str:
if key in NUMERIC_KEYS:
return value
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
def _config_lines(path: Path) -> list[str]:
if not path.exists():
return []
return path.read_text().splitlines()
def list_config(path: Path | None = None) -> dict:
"""Return the ``[daemon]`` table as written in the config file.
Args:
path (Path | None): config file. Defaults to ``config_path()``.
Returns:
dict: file-level key/value strings (no env or default folding).
Raises:
DaemonConfigError: the file exists but is not valid TOML.
"""
use_path = path or config_path()
if not use_path.exists():
return {}
try:
with open(use_path, "rb") as f:
table = tomllib.load(f).get("daemon", {})
except tomllib.TOMLDecodeError as e:
raise DaemonConfigError(f"malformed {use_path}: {e}") from e
return {k: str(v) for k, v in table.items()}
def get_config(key: str, path: Path | None = None) -> str | None:
"""Return one ``[daemon]`` key's file value, or ``None`` if unset.
Args:
key (str): a key in :data:`ALLOWED_KEYS`.
path (Path | None): config file. Defaults to ``config_path()``.
Returns:
str | None: the value, or ``None`` if absent.
"""
_check_key(key)
return list_config(path).get(key)
def set_config(key: str, value: str, path: Path | None = None) -> None:
"""Write ``key = value`` into the ``[daemon]`` table, in place.
Creates the file and ``[daemon]`` header if missing, updates the key
line if present, otherwise appends it inside ``[daemon]``. Comments
and unrelated lines are preserved.
Args:
key (str): a key in :data:`ALLOWED_KEYS`.
value (str): the value to store.
path (Path | None): config file. Defaults to ``config_path()``.
"""
_check_key(key)
use_path = path or config_path()
lines = _config_lines(use_path)
rendered = f"{key} = {_format_value(key, value)}"
header_idx = None
for i, line in enumerate(lines):
if line.strip() == "[daemon]":
header_idx = i
break
if header_idx is None:
if lines and lines[-1].strip() != "":
lines.append("")
lines.append("[daemon]")
lines.append(rendered)
else:
end = len(lines)
for i in range(header_idx + 1, len(lines)):
if lines[i].strip().startswith("["):
end = i
break
for i in range(header_idx + 1, end):
stripped = lines[i].strip()
if stripped.startswith("#") or "=" not in stripped:
continue
if stripped.split("=", 1)[0].strip() == key:
lines[i] = rendered
break
else:
lines.insert(end, rendered)
use_path.parent.mkdir(parents=True, exist_ok=True)
use_path.write_text("\n".join(lines) + "\n")
os.chmod(use_path, 0o600)
def unset_config(key: str, path: Path | None = None) -> None:
"""Remove ``key`` from the ``[daemon]`` table if present.
Unknown keys are allowed so a file the daemon refuses to load can
be repaired from the CLI.
Args:
key (str): any key present in the file.
path (Path | None): config file. Defaults to ``config_path()``.
"""
use_path = path or config_path()
if not use_path.exists():
return
lines = _config_lines(use_path)
kept = []
in_daemon = False
for line in lines:
stripped = line.strip()
if stripped == "[daemon]":
in_daemon = True
kept.append(line)
continue
if stripped.startswith("["):
in_daemon = False
if (in_daemon and "=" in stripped and not stripped.startswith("#")
and stripped.split("=", 1)[0].strip() == key):
continue
kept.append(line)
use_path.write_text("\n".join(kept) + "\n")
os.chmod(use_path, 0o600)
+346
View File
@@ -0,0 +1,346 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import os
from pathlib import Path
from typing import Any
import typer
import yaml
from mirage.cli.client import make_client
from mirage.cli.output import (emit, fail, format_age, format_table,
handle_response)
from mirage.config import _interpolate_env, load_config
app = typer.Typer(no_args_is_help=True, help="Manage workspaces.")
def _load_yaml(path: Path) -> dict:
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
def _resolve_config(path: Path) -> dict:
"""Load + validate + interpolate env vars from the CLI's environment.
Env interpolation runs client-side so the user's shell env (where
they sourced ``.env.development`` etc.) is the source of truth.
Missing vars fail fast here rather than producing a confusing
error after a network round-trip.
"""
try:
cfg = load_config(path)
except ValueError as e:
fail(str(e), exit_code=2)
return cfg.model_dump()
def _resolve_config_arg(path: Path) -> dict:
"""Read a workspace YAML/JSON config and interpolate ``${VAR}`` from
the CLI's env. Skips validation because load/clone may only need a
subset of mounts.
"""
raw = _load_yaml(path)
try:
return _interpolate_env(raw, dict(os.environ))
except ValueError as e:
fail(str(e), exit_code=2)
def _format_workspace_list(items: list[dict[str, Any]]) -> str:
if not items:
return "No active workspaces."
rows = [[
item["id"],
item["mode"],
str(item["mount_count"]),
str(item["session_count"]),
format_age(item["created_at"]),
] for item in items]
return format_table(["ID", "MODE", "MOUNTS", "SESSIONS", "AGE"], rows)
def _format_workspace_detail(detail: dict[str, Any]) -> str:
lines = [
f"ID: {detail['id']}",
f"Mode: {detail['mode']}",
f"Created: {format_age(detail['created_at'])} ago",
]
mounts = detail.get("mounts") or []
if mounts:
rows = [[m["prefix"], m["resource"], m["mode"]] for m in mounts]
lines.append("")
lines.append("Mounts:")
table = format_table(["PREFIX", "RESOURCE", "MODE"], rows)
lines.extend(" " + ln for ln in table.splitlines())
sessions = detail.get("sessions") or []
if sessions:
rows = [[s["session_id"], s["cwd"]] for s in sessions]
lines.append("")
lines.append("Sessions:")
table = format_table(["SESSION", "CWD"], rows)
lines.extend(" " + ln for ln in table.splitlines())
internals = detail.get("internals")
if internals:
lines.append("")
lines.append("Internals:")
for key in ("cache_bytes", "cache_entries", "history_length",
"in_flight_jobs"):
value = internals[key]
shown = "n/a (not tracked)" if value is None else value
lines.append(f" {key:<16} {shown}")
return "\n".join(lines)
def _format_version_log(versions: list[dict[str, Any]]) -> str:
if not versions:
return "No versions."
rows = [[v["id"][:12], v["message"]] for v in versions]
return format_table(["VERSION", "MESSAGE"], rows)
def _format_diff(changes: dict[str, list[str]]) -> str:
lines: list[str] = []
for kind in ("added", "modified", "deleted"):
for path in changes.get(kind, []):
lines.append(f"{kind:<9} {path}")
return "\n".join(lines) if lines else "No changes."
@app.command("create")
def create_cmd(
config_path: Path = typer.Argument(...,
exists=True,
readable=True,
help="YAML/JSON workspace config."),
workspace_id: str
| None = typer.Option(None, "--id", help="Explicit workspace id."),
) -> None:
"""Create a workspace; daemon auto-spawns if not running."""
body: dict = {"config": _resolve_config(config_path)}
if workspace_id:
body["id"] = workspace_id
with make_client() as client:
client.ensure_running()
r = client.request("POST", "/v1/workspaces", json=body)
emit(handle_response(r), human=_format_workspace_detail)
@app.command("list")
def list_cmd() -> None:
"""List active workspaces."""
with make_client() as client:
client.ensure_running(allow_spawn=False)
r = client.request("GET", "/v1/workspaces")
emit(handle_response(r), human=_format_workspace_list)
@app.command("get")
def get_cmd(
workspace_id: str = typer.Argument(..., help="Workspace id."),
verbose: bool = typer.Option(
False,
"--verbose",
help="Include cache / dirty / history internals.",
),
) -> None:
"""Show full details for one workspace."""
with make_client() as client:
client.ensure_running(allow_spawn=False)
path = f"/v1/workspaces/{workspace_id}"
if verbose:
path += "?verbose=true"
r = client.request("GET", path)
emit(handle_response(r), human=_format_workspace_detail)
@app.command("delete")
def delete_cmd(workspace_id: str = typer.Argument(...)) -> None:
"""Stop and remove a workspace."""
with make_client() as client:
client.ensure_running(allow_spawn=False)
r = client.request("DELETE", f"/v1/workspaces/{workspace_id}")
emit(handle_response(r), human=lambda d: f"Deleted workspace {d['id']}.")
@app.command("clone")
def clone_cmd(
source_id: str = typer.Argument(..., help="Source workspace id."),
new_id: str
| None = typer.Option(None, "--id", help="Explicit id for the clone."),
at: str | None = typer.Option(
None,
"--at",
help="Clone from a past version (id or branch) not the live state."),
) -> None:
"""Clone a workspace, optionally from one of its past versions."""
body: dict = {"source_id": source_id}
if new_id:
body["id"] = new_id
if at:
body["at"] = at
with make_client() as client:
client.ensure_running(allow_spawn=False)
r = client.request("POST", "/v1/workspaces/clone", json=body)
emit(handle_response(r), human=_format_workspace_detail)
@app.command("snapshot")
def snapshot_cmd(
workspace_id: str = typer.Argument(...),
output: Path = typer.Argument(..., help="Path to write the .tar to."),
) -> None:
"""Snapshot a workspace to a tar file.
The path is resolved to an absolute path and sent to the daemon,
which writes the tar itself. With the default local daemon that is
your filesystem; against a remote daemon the tar lands on the
daemon host.
"""
body = {"path": str(output.expanduser().resolve())}
with make_client() as client:
client.ensure_running(allow_spawn=False)
r = client.request("POST",
f"/v1/workspaces/{workspace_id}/snapshot",
json=body)
emit(
handle_response(r),
human=lambda d:
f"Snapshot {d['id']} -> {d['path']} ({d['size']:,} bytes).",
)
@app.command("load")
def load_cmd(
tar_path: Path = typer.Argument(..., exists=True, readable=True),
config_path: Path | None = typer.Argument(
None,
exists=True,
readable=True,
help="Optional workspace YAML/JSON config.",
),
new_id: str | None = typer.Option(
None, "--id", help="Explicit id for the restored workspace."),
) -> None:
"""Load a workspace from a tar file.
The path is resolved to an absolute path and sent to the daemon,
which reads the tar itself.
"""
body: dict = {"path": str(tar_path.expanduser().resolve())}
if new_id:
body["id"] = new_id
if config_path:
body["override"] = _resolve_config_arg(config_path)
with make_client() as client:
client.ensure_running()
r = client.request("POST", "/v1/workspaces/load", json=body)
emit(handle_response(r), human=_format_workspace_detail)
@app.command("commit")
def commit_cmd(
workspace_id: str = typer.Argument(..., help="Workspace id."),
message: str = typer.Option("", "-m", "--message",
help="Version message."),
branch: str = typer.Option("main",
"-b",
"--branch",
help="Branch to commit on."),
) -> None:
"""Commit the workspace's current state as a version."""
body = {"message": message, "branch": branch}
with make_client() as client:
client.ensure_running(allow_spawn=False)
r = client.request("POST",
f"/v1/workspaces/{workspace_id}/commit",
json=body)
emit(handle_response(r),
human=lambda d: f"Committed {d['version'][:12]} on {d['branch']}.")
@app.command("branch")
def branch_cmd(
workspace_id: str = typer.Argument(..., help="Workspace id."),
name: str = typer.Argument(..., help="New branch name."),
from_branch: str = typer.Option("main",
"--from",
help="Branch to fork from."),
) -> None:
"""Create a branch at another branch's current version."""
body = {"name": name, "from_branch": from_branch}
with make_client() as client:
client.ensure_running(allow_spawn=False)
r = client.request("POST",
f"/v1/workspaces/{workspace_id}/branch",
json=body)
emit(handle_response(r),
human=lambda d:
f"Created branch {d['branch']} at {d['version'][:12]}.")
@app.command("log")
def log_cmd(
workspace_id: str = typer.Argument(..., help="Workspace id."),
branch: str = typer.Option("main", "-b", "--branch"),
) -> None:
"""List a workspace's versions (newest first)."""
with make_client() as client:
client.ensure_running(allow_spawn=False)
r = client.request(
"GET", f"/v1/workspaces/{workspace_id}/versions?branch={branch}")
emit(handle_response(r), human=_format_version_log)
@app.command("diff")
def diff_cmd(
workspace_id: str = typer.Argument(..., help="Workspace id."),
a: str | None = typer.Argument(
None, help="Base ref; omit to use live state."),
b: str | None = typer.Argument(
None, help="Compare ref; omit to use live state."),
branch: str = typer.Option("main", "-b", "--branch"),
) -> None:
"""Show changed files (git-style).
diff <id> live vs HEAD
diff <id> <a> live vs <a>
diff <id> <a> <b> <a> vs <b>
"""
params: dict[str, str] = {"branch": branch}
if a is not None:
params["a"] = a
if b is not None:
params["b"] = b
with make_client() as client:
client.ensure_running(allow_spawn=False)
r = client.request("GET",
f"/v1/workspaces/{workspace_id}/diff",
params=params)
emit(handle_response(r), human=_format_diff)
@app.command("checkout")
def checkout_cmd(
workspace_id: str = typer.Argument(..., help="Workspace id."),
ref: str = typer.Argument(..., help="Version id or branch to restore."),
) -> None:
"""Restore a workspace in place to one of its versions."""
body = {"ref": ref}
with make_client() as client:
client.ensure_running(allow_spawn=False)
r = client.request("POST",
f"/v1/workspaces/{workspace_id}/checkout",
json=body)
emit(handle_response(r), human=_format_workspace_detail)