treadstone-cli 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m treadstone_cli` and PyInstaller builds."""
2
+
3
+ from treadstone_cli.main import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli()
@@ -0,0 +1,77 @@
1
+ """HTTP client factory for CLI commands.
2
+
3
+ Priority: CLI flags > environment variables > config file.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ import click
13
+ import httpx
14
+
15
+ CONFIG_DIR = Path.home() / ".config" / "treadstone"
16
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
17
+
18
+ _DEFAULT_BASE_URL = "https://api.treadstone-ai.dev"
19
+
20
+
21
+ def _read_config() -> dict[str, str]:
22
+ if not CONFIG_FILE.exists():
23
+ return {}
24
+ try:
25
+ import tomllib
26
+ except ModuleNotFoundError:
27
+ import tomli as tomllib # type: ignore[no-redef]
28
+ with open(CONFIG_FILE, "rb") as f:
29
+ data = tomllib.load(f)
30
+ return data.get("default", {})
31
+
32
+
33
+ def get_base_url(ctx: click.Context) -> str:
34
+ url = ctx.obj.get("base_url") or _read_config().get("base_url") or _DEFAULT_BASE_URL
35
+ return url.rstrip("/")
36
+
37
+
38
+ def get_api_key(ctx: click.Context) -> str | None:
39
+ return ctx.obj.get("api_key") or _read_config().get("api_key")
40
+
41
+
42
+ def effective_base_url() -> tuple[str, str]:
43
+ """Return (url, source) without requiring a Click context.
44
+
45
+ Source is one of: 'env', 'file', 'default'.
46
+ Used for displaying configuration in help text.
47
+ """
48
+ env = os.environ.get("TREADSTONE_BASE_URL")
49
+ if env:
50
+ return env.rstrip("/"), "env"
51
+ cfg = _read_config().get("base_url")
52
+ if cfg:
53
+ return cfg.rstrip("/"), "file"
54
+ return _DEFAULT_BASE_URL, "default"
55
+
56
+
57
+ def effective_api_key() -> str | None:
58
+ """Return API key without requiring a Click context."""
59
+ return os.environ.get("TREADSTONE_API_KEY") or _read_config().get("api_key")
60
+
61
+
62
+ def build_client(ctx: click.Context) -> httpx.Client:
63
+ base_url = get_base_url(ctx)
64
+ api_key = get_api_key(ctx)
65
+ headers: dict[str, str] = {}
66
+ if api_key:
67
+ headers["Authorization"] = f"Bearer {api_key}"
68
+ return httpx.Client(base_url=base_url, headers=headers, timeout=30.0)
69
+
70
+
71
+ def require_auth(ctx: click.Context) -> httpx.Client:
72
+ """Build client and abort if no API key is configured."""
73
+ client = build_client(ctx)
74
+ if "Authorization" not in client.headers:
75
+ click.echo("Error: No API key configured. Set TREADSTONE_API_KEY or use --api-key.", err=True)
76
+ sys.exit(1)
77
+ return client
@@ -0,0 +1,102 @@
1
+ """Output formatting utilities for CLI — table (Rich) or JSON mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import Any
8
+
9
+ import click
10
+ import httpx
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ console = Console()
15
+
16
+
17
+ def friendly_exception_handler(cli_main: click.Group) -> click.Group:
18
+ """Wrap a Click group so unhandled exceptions print user-friendly messages instead of tracebacks."""
19
+ original_main = cli_main.main
20
+
21
+ def _patched_main(*args: Any, **kwargs: Any) -> Any:
22
+ try:
23
+ return original_main(*args, standalone_mode=False, **kwargs)
24
+ except click.exceptions.Abort:
25
+ click.echo("\nAborted.", err=True)
26
+ sys.exit(130)
27
+ except click.exceptions.Exit as exc:
28
+ sys.exit(exc.exit_code)
29
+ except click.UsageError as exc:
30
+ exc.show()
31
+ sys.exit(exc.exit_code if exc.exit_code is not None else 2)
32
+ except SystemExit:
33
+ raise
34
+ except KeyboardInterrupt:
35
+ click.echo("\nInterrupted.", err=True)
36
+ sys.exit(130)
37
+ except httpx.ConnectError as exc:
38
+ _print_network_hint(str(exc), "Connection refused")
39
+ sys.exit(1)
40
+ except httpx.TimeoutException:
41
+ click.echo("Error: Request timed out. The server may be slow or unreachable.", err=True)
42
+ sys.exit(1)
43
+ except httpx.HTTPStatusError as exc:
44
+ click.echo(f"Error: HTTP {exc.response.status_code} — {exc.response.text[:200]}", err=True)
45
+ sys.exit(1)
46
+ except httpx.HTTPError as exc:
47
+ click.echo(f"Error: {type(exc).__name__} — {exc}", err=True)
48
+ sys.exit(1)
49
+ except Exception as exc:
50
+ click.echo(f"Error: {exc}", err=True)
51
+ sys.exit(1)
52
+
53
+ cli_main.main = _patched_main # type: ignore[assignment]
54
+ return cli_main
55
+
56
+
57
+ def _print_network_hint(detail: str, summary: str) -> None:
58
+ click.echo(f"Error: {summary}.", err=True)
59
+ click.echo(" Possible causes:", err=True)
60
+ click.echo(" - The Treadstone server is not running", err=True)
61
+ click.echo(" - The --base-url or TREADSTONE_BASE_URL is incorrect", err=True)
62
+ click.echo(" - A firewall or proxy is blocking the connection", err=True)
63
+ click.echo(f" Detail: {detail}", err=True)
64
+
65
+
66
+ def is_json_mode(ctx: click.Context) -> bool:
67
+ return ctx.obj.get("json_output", False)
68
+
69
+
70
+ def print_json(data: Any) -> None:
71
+ click.echo(json.dumps(data, indent=2, default=str))
72
+
73
+
74
+ def print_table(columns: list[str], rows: list[list[Any]], title: str | None = None) -> None:
75
+ table = Table(title=title, show_header=True, header_style="bold cyan")
76
+ for col in columns:
77
+ table.add_column(col)
78
+ for row in rows:
79
+ table.add_row(*[str(v) if v is not None else "" for v in row])
80
+ console.print(table)
81
+
82
+
83
+ def print_detail(data: dict[str, Any], title: str | None = None) -> None:
84
+ table = Table(title=title, show_header=False)
85
+ table.add_column("Field", style="bold")
86
+ table.add_column("Value")
87
+ for k, v in data.items():
88
+ table.add_row(k, str(v) if v is not None else "")
89
+ console.print(table)
90
+
91
+
92
+ def handle_error(resp: Any) -> None:
93
+ """Print error and exit if response is not 2xx."""
94
+ if resp.status_code >= 400:
95
+ try:
96
+ body = resp.json()
97
+ err = body.get("error", body)
98
+ msg = err.get("message", str(err)) if isinstance(err, dict) else str(err)
99
+ except Exception:
100
+ msg = resp.text
101
+ click.echo(f"Error ({resp.status_code}): {msg}", err=True)
102
+ sys.exit(1)
@@ -0,0 +1,73 @@
1
+ """API key management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from treadstone_cli._client import require_auth
8
+ from treadstone_cli._output import handle_error, is_json_mode, print_json, print_table
9
+
10
+
11
+ @click.group()
12
+ def api_keys() -> None:
13
+ """Manage API keys.
14
+
15
+ API keys provide long-lived authentication tokens for programmatic access.
16
+
17
+ \b
18
+ Examples:
19
+ treadstone api-keys create --name ci-bot
20
+ treadstone api-keys list
21
+ treadstone api-keys delete <key-id>
22
+ """
23
+
24
+
25
+ @api_keys.command("create")
26
+ @click.option("--name", default="default", help="Key name.")
27
+ @click.option("--expires-in", default=None, type=int, help="Key lifetime in seconds.")
28
+ @click.pass_context
29
+ def create_key(ctx: click.Context, name: str, expires_in: int | None) -> None:
30
+ """Create a new API key.
31
+
32
+ The full key is shown only once — store it securely.
33
+ """
34
+ client = require_auth(ctx)
35
+ body: dict = {"name": name}
36
+ if expires_in is not None:
37
+ body["expires_in"] = expires_in
38
+ resp = client.post("/v1/auth/api-keys", json=body)
39
+ handle_error(resp)
40
+ data = resp.json()
41
+ if is_json_mode(ctx):
42
+ print_json(data)
43
+ else:
44
+ click.echo(f"API Key created: {data['key']}")
45
+ click.echo(f" ID: {data['id']} Name: {data['name']}")
46
+ click.echo(" Store this key securely — it won't be shown again.")
47
+
48
+
49
+ @api_keys.command("list")
50
+ @click.pass_context
51
+ def list_keys(ctx: click.Context) -> None:
52
+ """List all API keys for the current user."""
53
+ client = require_auth(ctx)
54
+ resp = client.get("/v1/auth/api-keys")
55
+ handle_error(resp)
56
+ data = resp.json()
57
+ if is_json_mode(ctx):
58
+ print_json(data)
59
+ else:
60
+ items = data.get("items", [])
61
+ rows = [[k["id"], k["name"], k["key_prefix"], k.get("created_at", ""), k.get("expires_at", "")] for k in items]
62
+ print_table(["ID", "Name", "Key Prefix", "Created", "Expires"], rows, title="API Keys")
63
+
64
+
65
+ @api_keys.command("delete")
66
+ @click.argument("key_id")
67
+ @click.pass_context
68
+ def delete_key(ctx: click.Context, key_id: str) -> None:
69
+ """Revoke and delete an API key."""
70
+ client = require_auth(ctx)
71
+ resp = client.delete(f"/v1/auth/api-keys/{key_id}")
72
+ handle_error(resp)
73
+ click.echo(f"API key {key_id} deleted.")
treadstone_cli/auth.py ADDED
@@ -0,0 +1,153 @@
1
+ """Auth commands — login, logout, register, whoami, change-password, invite, users, delete-user."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from treadstone_cli._client import build_client, require_auth
8
+ from treadstone_cli._output import handle_error, is_json_mode, print_detail, print_json, print_table
9
+
10
+
11
+ @click.group()
12
+ def auth() -> None:
13
+ """Authentication and user management.
14
+
15
+ Register, log in, manage users, and change passwords.
16
+
17
+ \b
18
+ Quick start:
19
+ treadstone auth register Create a new account
20
+ treadstone auth login Log in (saves session)
21
+ treadstone auth whoami Verify current identity
22
+ """
23
+
24
+
25
+ @auth.command("login")
26
+ @click.option("--email", required=True, prompt=True, help="Account email.")
27
+ @click.option("--password", required=True, prompt=True, hide_input=True, help="Account password.")
28
+ @click.pass_context
29
+ def login(ctx: click.Context, email: str, password: str) -> None:
30
+ """Log in with email and password.
31
+
32
+ If --email or --password are not provided, you will be prompted interactively.
33
+ """
34
+ client = build_client(ctx)
35
+ resp = client.post("/v1/auth/login", json={"email": email, "password": password})
36
+ handle_error(resp)
37
+ data = resp.json()
38
+ if is_json_mode(ctx):
39
+ print_json(data)
40
+ else:
41
+ click.echo("Login successful.")
42
+
43
+
44
+ @auth.command("logout")
45
+ @click.pass_context
46
+ def logout(ctx: click.Context) -> None:
47
+ """Log out (clear session cookie)."""
48
+ client = build_client(ctx)
49
+ resp = client.post("/v1/auth/logout")
50
+ handle_error(resp)
51
+ if is_json_mode(ctx):
52
+ print_json(resp.json())
53
+ else:
54
+ click.echo("Logged out.")
55
+
56
+
57
+ @auth.command("register")
58
+ @click.option("--email", required=True, prompt=True, help="Account email.")
59
+ @click.option("--password", required=True, prompt=True, hide_input=True, confirmation_prompt=True, help="Password.")
60
+ @click.option("--invitation-token", default=None, help="Invitation token (required in invitation mode).")
61
+ @click.pass_context
62
+ def register(ctx: click.Context, email: str, password: str, invitation_token: str | None) -> None:
63
+ """Register a new account.
64
+
65
+ In invitation-only mode, an --invitation-token is required.
66
+ """
67
+ client = build_client(ctx)
68
+ body: dict = {"email": email, "password": password}
69
+ if invitation_token:
70
+ body["invitation_token"] = invitation_token
71
+ resp = client.post("/v1/auth/register", json=body)
72
+ handle_error(resp)
73
+ data = resp.json()
74
+ if is_json_mode(ctx):
75
+ print_json(data)
76
+ else:
77
+ click.echo(f"Registered: {data['email']} (role: {data['role']})")
78
+
79
+
80
+ @auth.command("whoami")
81
+ @click.pass_context
82
+ def whoami(ctx: click.Context) -> None:
83
+ """Show current user info."""
84
+ client = require_auth(ctx)
85
+ resp = client.get("/v1/auth/user")
86
+ handle_error(resp)
87
+ data = resp.json()
88
+ if is_json_mode(ctx):
89
+ print_json(data)
90
+ else:
91
+ print_detail(data, title="Current User")
92
+
93
+
94
+ @auth.command("change-password")
95
+ @click.option("--old-password", required=True, prompt=True, hide_input=True, help="Current password.")
96
+ @click.option(
97
+ "--new-password", required=True, prompt=True, hide_input=True, confirmation_prompt=True, help="New password."
98
+ )
99
+ @click.pass_context
100
+ def change_password(ctx: click.Context, old_password: str, new_password: str) -> None:
101
+ """Change your password."""
102
+ client = require_auth(ctx)
103
+ resp = client.post("/v1/auth/change-password", json={"old_password": old_password, "new_password": new_password})
104
+ handle_error(resp)
105
+ if is_json_mode(ctx):
106
+ print_json(resp.json())
107
+ else:
108
+ click.echo("Password changed.")
109
+
110
+
111
+ @auth.command("invite")
112
+ @click.option("--email", required=True, help="Email of the person to invite.")
113
+ @click.option("--role", default="ro", help="Role for the invitee (admin or ro).")
114
+ @click.pass_context
115
+ def invite(ctx: click.Context, email: str, role: str) -> None:
116
+ """Generate an invitation token for a new user (admin only)."""
117
+ client = require_auth(ctx)
118
+ resp = client.post("/v1/auth/invite", json={"email": email, "role": role})
119
+ handle_error(resp)
120
+ data = resp.json()
121
+ if is_json_mode(ctx):
122
+ print_json(data)
123
+ else:
124
+ click.echo(f"Invitation sent to {data['email']}. Token: {data['token']}")
125
+
126
+
127
+ @auth.command("users")
128
+ @click.option("--limit", default=100, type=int, help="Max results.")
129
+ @click.option("--offset", default=0, type=int, help="Skip N results.")
130
+ @click.pass_context
131
+ def list_users(ctx: click.Context, limit: int, offset: int) -> None:
132
+ """List all registered users (admin only)."""
133
+ client = require_auth(ctx)
134
+ resp = client.get("/v1/auth/users", params={"limit": limit, "offset": offset})
135
+ handle_error(resp)
136
+ data = resp.json()
137
+ if is_json_mode(ctx):
138
+ print_json(data)
139
+ else:
140
+ items = data.get("items", [])
141
+ rows = [[u["id"], u["email"], u["role"]] for u in items]
142
+ print_table(["ID", "Email", "Role"], rows, title=f"Users ({data['total']} total)")
143
+
144
+
145
+ @auth.command("delete-user")
146
+ @click.argument("user_id")
147
+ @click.pass_context
148
+ def delete_user(ctx: click.Context, user_id: str) -> None:
149
+ """Delete a user (admin only)."""
150
+ client = require_auth(ctx)
151
+ resp = client.delete(f"/v1/auth/users/{user_id}")
152
+ handle_error(resp)
153
+ click.echo(f"User {user_id} deleted.")
@@ -0,0 +1,157 @@
1
+ """Local CLI configuration commands.
2
+
3
+ Manages ~/.config/treadstone/config.toml which stores defaults for
4
+ base_url, api_key, and other settings so they don't need to be passed
5
+ as flags or env vars every time.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import click
11
+
12
+ from treadstone_cli._client import CONFIG_DIR, CONFIG_FILE, _read_config
13
+
14
+
15
+ def _write_config(data: dict[str, dict[str, str]]) -> None:
16
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
17
+ lines: list[str] = []
18
+ for section, kvs in data.items():
19
+ lines.append(f"[{section}]")
20
+ for k, v in kvs.items():
21
+ lines.append(f'{k} = "{v}"')
22
+ lines.append("")
23
+ CONFIG_FILE.write_text("\n".join(lines))
24
+
25
+
26
+ _VALID_KEYS = ("base_url", "api_key")
27
+
28
+
29
+ @click.group()
30
+ def config() -> None:
31
+ """Manage local CLI configuration.
32
+
33
+ Configuration is stored in ~/.config/treadstone/config.toml and provides
34
+ default values for --base-url and --api-key so they don't need to be
35
+ repeated on every command invocation.
36
+
37
+ \b
38
+ Priority (highest to lowest):
39
+ 1. CLI flags (--base-url, --api-key)
40
+ 2. Env vars (TREADSTONE_BASE_URL, TREADSTONE_API_KEY)
41
+ 3. Config file (~/.config/treadstone/config.toml)
42
+ """
43
+
44
+
45
+ @config.command("set")
46
+ @click.argument("key", type=click.Choice(_VALID_KEYS, case_sensitive=False))
47
+ @click.argument("value")
48
+ def set_value(key: str, value: str) -> None:
49
+ """Set a configuration value.
50
+
51
+ \b
52
+ Available keys:
53
+ base_url Base URL of the Treadstone server
54
+ api_key Default API key for authentication
55
+
56
+ \b
57
+ Examples:
58
+ treadstone config set base_url https://my-server.example.com
59
+ treadstone config set api_key ts_live_xxxxxxxxxxxx
60
+ """
61
+ try:
62
+ import tomllib
63
+ except ModuleNotFoundError:
64
+ import tomli as tomllib # type: ignore[no-redef]
65
+
66
+ raw: dict[str, dict[str, str]] = {}
67
+ if CONFIG_FILE.exists():
68
+ with open(CONFIG_FILE, "rb") as f:
69
+ raw = tomllib.load(f) # type: ignore[assignment]
70
+
71
+ section = raw.setdefault("default", {})
72
+ section[key] = value
73
+ _write_config(raw)
74
+ click.echo(f"Set {key} = {value}")
75
+
76
+
77
+ @config.command("get")
78
+ @click.argument("key", required=False, default=None)
79
+ def get_value(key: str | None) -> None:
80
+ """Get a configuration value (or all values if no key given).
81
+
82
+ \b
83
+ Examples:
84
+ treadstone config get Show all config values
85
+ treadstone config get base_url Show only base_url
86
+ """
87
+ data = _read_config()
88
+ if not data:
89
+ click.echo("No configuration set. Run 'treadstone config set <key> <value>' to get started.")
90
+ return
91
+
92
+ if key is not None:
93
+ if key not in _VALID_KEYS:
94
+ click.echo(f"Error: Unknown key '{key}'. Valid keys: {', '.join(_VALID_KEYS)}", err=True)
95
+ raise SystemExit(1)
96
+ val = data.get(key)
97
+ if val is None:
98
+ click.echo(f"{key} is not set.")
99
+ else:
100
+ display = _mask_secret(key, val)
101
+ click.echo(f"{key} = {display}")
102
+ else:
103
+ for k in _VALID_KEYS:
104
+ val = data.get(k)
105
+ if val is not None:
106
+ click.echo(f"{k} = {_mask_secret(k, val)}")
107
+
108
+
109
+ @config.command("unset")
110
+ @click.argument("key", type=click.Choice(_VALID_KEYS, case_sensitive=False))
111
+ def unset_value(key: str) -> None:
112
+ """Remove a configuration value.
113
+
114
+ \b
115
+ Example:
116
+ treadstone config unset api_key
117
+ """
118
+ try:
119
+ import tomllib
120
+ except ModuleNotFoundError:
121
+ import tomli as tomllib # type: ignore[no-redef]
122
+
123
+ if not CONFIG_FILE.exists():
124
+ click.echo(f"{key} is not set.")
125
+ return
126
+
127
+ with open(CONFIG_FILE, "rb") as f:
128
+ raw: dict[str, dict[str, str]] = tomllib.load(f) # type: ignore[assignment]
129
+
130
+ section = raw.get("default", {})
131
+ if key not in section:
132
+ click.echo(f"{key} is not set.")
133
+ return
134
+
135
+ del section[key]
136
+ _write_config(raw)
137
+ click.echo(f"Unset {key}.")
138
+
139
+
140
+ @config.command("path")
141
+ def show_path() -> None:
142
+ """Print the path to the configuration file.
143
+
144
+ \b
145
+ Example:
146
+ treadstone config path
147
+ """
148
+ exists = CONFIG_FILE.exists()
149
+ click.echo(str(CONFIG_FILE))
150
+ if not exists:
151
+ click.echo(" (file does not exist yet)")
152
+
153
+
154
+ def _mask_secret(key: str, value: str) -> str:
155
+ if key == "api_key" and len(value) > 8:
156
+ return value[:8] + "..." + value[-4:]
157
+ return value
treadstone_cli/main.py ADDED
@@ -0,0 +1,104 @@
1
+ """Treadstone CLI — agent-native sandbox management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from treadstone_cli._client import build_client, effective_api_key, effective_base_url, get_base_url
8
+ from treadstone_cli._output import friendly_exception_handler, handle_error, is_json_mode, print_json
9
+
10
+ _STATIC_EPILOG = """\b
11
+ Configuration (highest to lowest priority):
12
+ CLI flags --api-key, --base-url
13
+ Env vars TREADSTONE_API_KEY, TREADSTONE_BASE_URL
14
+ Config file ~/.config/treadstone/config.toml
15
+
16
+ Run 'treadstone config --help' to manage the config file.
17
+
18
+ Examples:
19
+ treadstone health Check server status
20
+ treadstone sandboxes list List running sandboxes
21
+ treadstone sb create --template default Create a sandbox
22
+ treadstone auth login Log in with email/password
23
+ """
24
+
25
+
26
+ class _TreadstoneGroup(click.Group):
27
+ """Custom group that appends a live config summary to the help output."""
28
+
29
+ def format_epilog(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
30
+ super().format_epilog(ctx, formatter)
31
+ url, source = effective_base_url()
32
+ api_key = effective_api_key()
33
+ api_key_status = "configured" if api_key else "not set"
34
+ with formatter.section("Active configuration"):
35
+ formatter.write_dl(
36
+ [
37
+ ("Base URL", f"{url} [{source}]"),
38
+ ("API key", api_key_status),
39
+ ]
40
+ )
41
+
42
+
43
+ @click.group(cls=_TreadstoneGroup, epilog=_STATIC_EPILOG)
44
+ @click.option("--json", "json_output", is_flag=True, default=False, help="Output in JSON format.")
45
+ @click.option(
46
+ "--api-key",
47
+ envvar="TREADSTONE_API_KEY",
48
+ default=None,
49
+ help="API key for authentication (env: TREADSTONE_API_KEY).",
50
+ )
51
+ @click.option(
52
+ "--base-url",
53
+ envvar="TREADSTONE_BASE_URL",
54
+ default=None,
55
+ help="Base URL of the Treadstone server (env: TREADSTONE_BASE_URL).",
56
+ )
57
+ @click.version_option(package_name="treadstone-cli")
58
+ @click.pass_context
59
+ def cli(ctx: click.Context, json_output: bool, api_key: str | None, base_url: str | None) -> None:
60
+ """Treadstone CLI — manage sandboxes, templates, and API keys.
61
+
62
+ An agent-native sandbox service. Run code, build projects, and deploy
63
+ environments via the command line.
64
+ """
65
+ ctx.ensure_object(dict)
66
+ ctx.obj["json_output"] = json_output
67
+ if api_key:
68
+ ctx.obj["api_key"] = api_key
69
+ if base_url:
70
+ ctx.obj["base_url"] = base_url
71
+
72
+
73
+ @cli.command()
74
+ @click.pass_context
75
+ def health(ctx: click.Context) -> None:
76
+ """Check if the Treadstone server is reachable and healthy."""
77
+ client = build_client(ctx)
78
+ base_url = get_base_url(ctx)
79
+ if not is_json_mode(ctx):
80
+ click.echo(f"Connecting to {base_url} ...")
81
+ resp = client.get("/health")
82
+ handle_error(resp)
83
+ data = resp.json()
84
+ if is_json_mode(ctx):
85
+ print_json(data)
86
+ else:
87
+ status = data.get("status", "unknown")
88
+ click.echo(f"Server is {status}")
89
+
90
+
91
+ from treadstone_cli.api_keys import api_keys # noqa: E402
92
+ from treadstone_cli.auth import auth # noqa: E402
93
+ from treadstone_cli.config_cmd import config # noqa: E402
94
+ from treadstone_cli.sandboxes import sandboxes # noqa: E402
95
+ from treadstone_cli.templates import templates # noqa: E402
96
+
97
+ cli.add_command(auth)
98
+ cli.add_command(api_keys, "api-keys")
99
+ cli.add_command(sandboxes)
100
+ cli.add_command(sandboxes, "sb")
101
+ cli.add_command(templates)
102
+ cli.add_command(config)
103
+
104
+ friendly_exception_handler(cli)
@@ -0,0 +1,170 @@
1
+ """Sandbox management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from treadstone_cli._client import require_auth
8
+ from treadstone_cli._output import handle_error, is_json_mode, print_detail, print_json, print_table
9
+
10
+
11
+ @click.group()
12
+ def sandboxes() -> None:
13
+ """Manage sandboxes.
14
+
15
+ Create, list, start, stop, and delete sandboxes. Use 'sb' as a shorthand.
16
+
17
+ \b
18
+ Examples:
19
+ treadstone sandboxes create --template default --name my-box
20
+ treadstone sb list
21
+ treadstone sb get <sandbox-id>
22
+ treadstone sb delete <sandbox-id>
23
+ """
24
+
25
+
26
+ @sandboxes.command("create")
27
+ @click.option("--template", required=True, help="Sandbox template name.")
28
+ @click.option("--name", default=None, help="Sandbox name (auto-generated if omitted).")
29
+ @click.option("--label", multiple=True, help="Labels in key:val format (repeatable).")
30
+ @click.option("--persist", is_flag=True, default=False, help="Enable persistent storage.")
31
+ @click.option("--storage-size", default="10Gi", help="PVC size when --persist is set.")
32
+ @click.pass_context
33
+ def create(
34
+ ctx: click.Context,
35
+ template: str,
36
+ name: str | None,
37
+ label: tuple[str, ...],
38
+ persist: bool,
39
+ storage_size: str,
40
+ ) -> None:
41
+ """Create a new sandbox from a template.
42
+
43
+ \b
44
+ Examples:
45
+ treadstone sb create --template default
46
+ treadstone sb create --template python --name dev-box --label env:dev
47
+ treadstone sb create --template node --persist --storage-size 20Gi
48
+ """
49
+ client = require_auth(ctx)
50
+ labels = {}
51
+ for lbl in label:
52
+ if ":" not in lbl:
53
+ click.echo(f"Error: Invalid label format '{lbl}'. Use key:val.", err=True)
54
+ raise SystemExit(1)
55
+ k, v = lbl.split(":", 1)
56
+ labels[k] = v
57
+ body: dict = {"template": template, "labels": labels, "persist": persist, "storage_size": storage_size}
58
+ if name:
59
+ body["name"] = name
60
+ resp = client.post("/v1/sandboxes", json=body)
61
+ handle_error(resp)
62
+ data = resp.json()
63
+ if is_json_mode(ctx):
64
+ print_json(data)
65
+ else:
66
+ print_detail(data, title="Sandbox Created")
67
+
68
+
69
+ @sandboxes.command("list")
70
+ @click.option("--label", default=None, help="Filter by label (key:val).")
71
+ @click.option("--limit", default=100, type=int, help="Max results.")
72
+ @click.option("--offset", default=0, type=int, help="Skip N results.")
73
+ @click.pass_context
74
+ def list_sandboxes(ctx: click.Context, label: str | None, limit: int, offset: int) -> None:
75
+ """List sandboxes with optional filtering.
76
+
77
+ \b
78
+ Examples:
79
+ treadstone sb list
80
+ treadstone sb list --label env:prod --limit 10
81
+ """
82
+ client = require_auth(ctx)
83
+ params: dict = {"limit": limit, "offset": offset}
84
+ if label:
85
+ params["label"] = label
86
+ resp = client.get("/v1/sandboxes", params=params)
87
+ handle_error(resp)
88
+ data = resp.json()
89
+ if is_json_mode(ctx):
90
+ print_json(data)
91
+ else:
92
+ items = data.get("items", [])
93
+ rows = [[s["id"], s["name"], s["template"], s["status"], s.get("created_at", "")] for s in items]
94
+ print_table(["ID", "Name", "Template", "Status", "Created"], rows, title=f"Sandboxes ({data['total']} total)")
95
+
96
+
97
+ @sandboxes.command("get")
98
+ @click.argument("sandbox_id")
99
+ @click.pass_context
100
+ def get_sandbox(ctx: click.Context, sandbox_id: str) -> None:
101
+ """Show detailed information about a sandbox."""
102
+ client = require_auth(ctx)
103
+ resp = client.get(f"/v1/sandboxes/{sandbox_id}")
104
+ handle_error(resp)
105
+ data = resp.json()
106
+ if is_json_mode(ctx):
107
+ print_json(data)
108
+ else:
109
+ print_detail(data, title=f"Sandbox {sandbox_id}")
110
+
111
+
112
+ @sandboxes.command("delete")
113
+ @click.argument("sandbox_id")
114
+ @click.pass_context
115
+ def delete_sandbox(ctx: click.Context, sandbox_id: str) -> None:
116
+ """Delete a sandbox."""
117
+ client = require_auth(ctx)
118
+ resp = client.delete(f"/v1/sandboxes/{sandbox_id}")
119
+ handle_error(resp)
120
+ click.echo(f"Sandbox {sandbox_id} deleted.")
121
+
122
+
123
+ @sandboxes.command("start")
124
+ @click.argument("sandbox_id")
125
+ @click.pass_context
126
+ def start_sandbox(ctx: click.Context, sandbox_id: str) -> None:
127
+ """Start a stopped sandbox."""
128
+ client = require_auth(ctx)
129
+ resp = client.post(f"/v1/sandboxes/{sandbox_id}/start")
130
+ handle_error(resp)
131
+ data = resp.json()
132
+ if is_json_mode(ctx):
133
+ print_json(data)
134
+ else:
135
+ click.echo(f"Sandbox {sandbox_id} starting.")
136
+
137
+
138
+ @sandboxes.command("stop")
139
+ @click.argument("sandbox_id")
140
+ @click.pass_context
141
+ def stop_sandbox(ctx: click.Context, sandbox_id: str) -> None:
142
+ """Stop a running sandbox."""
143
+ client = require_auth(ctx)
144
+ resp = client.post(f"/v1/sandboxes/{sandbox_id}/stop")
145
+ handle_error(resp)
146
+ data = resp.json()
147
+ if is_json_mode(ctx):
148
+ print_json(data)
149
+ else:
150
+ click.echo(f"Sandbox {sandbox_id} stopping.")
151
+
152
+
153
+ @sandboxes.command("token")
154
+ @click.argument("sandbox_id")
155
+ @click.option("--expires-in", default=3600, type=int, help="Token lifetime in seconds.")
156
+ @click.pass_context
157
+ def create_token(ctx: click.Context, sandbox_id: str, expires_in: int) -> None:
158
+ """Create a short-lived access token for a sandbox.
159
+
160
+ The token can be used to connect to the sandbox directly (e.g. via
161
+ WebSocket) without needing the main API key.
162
+ """
163
+ client = require_auth(ctx)
164
+ resp = client.post(f"/v1/sandboxes/{sandbox_id}/token", json={"expires_in": expires_in})
165
+ handle_error(resp)
166
+ data = resp.json()
167
+ if is_json_mode(ctx):
168
+ print_json(data)
169
+ else:
170
+ print_detail(data, title="Sandbox Token")
@@ -0,0 +1,39 @@
1
+ """Sandbox template commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from treadstone_cli._client import require_auth
8
+ from treadstone_cli._output import handle_error, is_json_mode, print_json, print_table
9
+
10
+
11
+ @click.group()
12
+ def templates() -> None:
13
+ """Manage sandbox templates.
14
+
15
+ Templates define the runtime environment (image, CPU, memory) for sandboxes.
16
+
17
+ \b
18
+ Examples:
19
+ treadstone templates list
20
+ """
21
+
22
+
23
+ @templates.command("list")
24
+ @click.pass_context
25
+ def list_templates(ctx: click.Context) -> None:
26
+ """List all available sandbox templates with resource specs."""
27
+ client = require_auth(ctx)
28
+ resp = client.get("/v1/sandbox-templates")
29
+ handle_error(resp)
30
+ data = resp.json()
31
+ if is_json_mode(ctx):
32
+ print_json(data)
33
+ else:
34
+ items = data.get("items", [])
35
+ rows = [
36
+ [t["name"], t["display_name"], t["resource_spec"]["cpu"], t["resource_spec"]["memory"], t["description"]]
37
+ for t in items
38
+ ]
39
+ print_table(["Name", "Display Name", "CPU", "Memory", "Description"], rows, title="Sandbox Templates")
@@ -0,0 +1,222 @@
1
+ Metadata-Version: 2.4
2
+ Name: treadstone-cli
3
+ Version: 0.2.1
4
+ Summary: CLI for the Treadstone sandbox service.
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: click>=8.1
7
+ Requires-Dist: httpx>=0.28.1
8
+ Requires-Dist: rich>=14.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # Treadstone CLI
12
+
13
+ Command-line interface for the Treadstone sandbox service.
14
+
15
+ ## Installation
16
+
17
+ ### From PyPI
18
+
19
+ ```bash
20
+ pip install treadstone
21
+ ```
22
+
23
+ ### Pre-built binary
24
+
25
+ Download the latest release from [GitHub Releases](https://github.com/earayu/treadstone/releases). The binary is self-contained and does not require Python.
26
+
27
+ ```bash
28
+ # macOS / Linux
29
+ chmod +x treadstone
30
+ sudo mv treadstone /usr/local/bin/
31
+ ```
32
+
33
+ ## Quick start
34
+
35
+ ```bash
36
+ # Check that the server is reachable
37
+ treadstone health
38
+
39
+ # Register an account (first time only)
40
+ treadstone auth register
41
+
42
+ # Log in
43
+ treadstone auth login
44
+
45
+ # Create an API key for non-interactive use
46
+ treadstone api-keys create --name my-key
47
+ # Save the key — it is shown only once
48
+
49
+ # Store the key in config so you don't have to pass it every time
50
+ treadstone config set api_key ts_live_xxxxxxxxxxxx
51
+
52
+ # Create a sandbox
53
+ treadstone sandboxes create --template default --name my-sandbox
54
+
55
+ # List sandboxes
56
+ treadstone sb list
57
+ ```
58
+
59
+ ## Configuration
60
+
61
+ The CLI reads configuration from three sources, in order of priority:
62
+
63
+ | Priority | Source | Example |
64
+ |----------|--------|---------|
65
+ | 1 (highest) | CLI flags | `--base-url https://... --api-key ts_...` |
66
+ | 2 | Environment variables | `TREADSTONE_BASE_URL`, `TREADSTONE_API_KEY` |
67
+ | 3 (lowest) | Config file | `~/.config/treadstone/config.toml` |
68
+
69
+ ### Config file
70
+
71
+ Location: `~/.config/treadstone/config.toml`
72
+
73
+ ```toml
74
+ [default]
75
+ base_url = "https://your-server.example.com"
76
+ api_key = "ts_live_xxxxxxxxxxxx"
77
+ ```
78
+
79
+ You can manage this file with the `config` subcommand:
80
+
81
+ ```bash
82
+ # Set a value
83
+ treadstone config set base_url https://your-server.example.com
84
+ treadstone config set api_key ts_live_xxxxxxxxxxxx
85
+
86
+ # View current settings (api_key is partially masked)
87
+ treadstone config get
88
+
89
+ # View a single key
90
+ treadstone config get base_url
91
+
92
+ # Remove a value
93
+ treadstone config unset api_key
94
+
95
+ # Show config file path
96
+ treadstone config path
97
+ ```
98
+
99
+ ### Environment variables
100
+
101
+ | Variable | Description | Default |
102
+ |----------|-------------|---------|
103
+ | `TREADSTONE_BASE_URL` | Base URL of the Treadstone server | `https://api.treadstone-ai.dev` |
104
+ | `TREADSTONE_API_KEY` | API key for authentication | (none) |
105
+
106
+ ### Global flags
107
+
108
+ | Flag | Description |
109
+ |------|-------------|
110
+ | `--base-url URL` | Override the server URL |
111
+ | `--api-key KEY` | Override the API key |
112
+ | `--json` | Output responses as JSON (useful for scripting) |
113
+ | `--version` | Show CLI version |
114
+ | `--help` | Show help message |
115
+
116
+ ## Commands
117
+
118
+ ### `health`
119
+
120
+ Check if the server is reachable and healthy.
121
+
122
+ ```bash
123
+ treadstone health
124
+ # Server is healthy
125
+ ```
126
+
127
+ ### `auth`
128
+
129
+ Authentication and user management.
130
+
131
+ ```bash
132
+ treadstone auth register # Create a new account
133
+ treadstone auth login # Log in interactively
134
+ treadstone auth logout # Log out
135
+ treadstone auth whoami # Show current user info
136
+ treadstone auth change-password # Change your password
137
+ treadstone auth invite --email user@example.com # Invite a user (admin)
138
+ treadstone auth users # List all users (admin)
139
+ treadstone auth delete-user <user-id> # Delete a user (admin)
140
+ ```
141
+
142
+ ### `sandboxes` (alias: `sb`)
143
+
144
+ Create and manage sandboxes.
145
+
146
+ ```bash
147
+ treadstone sb create --template default --name my-box
148
+ treadstone sb create --template python --label env:dev --persist
149
+ treadstone sb list
150
+ treadstone sb list --label env:prod --limit 10
151
+ treadstone sb get <sandbox-id>
152
+ treadstone sb start <sandbox-id>
153
+ treadstone sb stop <sandbox-id>
154
+ treadstone sb delete <sandbox-id>
155
+ treadstone sb token <sandbox-id> # Get an access token
156
+ treadstone sb token <sandbox-id> --expires-in 7200 # Custom TTL
157
+ ```
158
+
159
+ ### `templates`
160
+
161
+ List available sandbox templates.
162
+
163
+ ```bash
164
+ treadstone templates list
165
+ ```
166
+
167
+ ### `api-keys`
168
+
169
+ Manage long-lived API keys.
170
+
171
+ ```bash
172
+ treadstone api-keys create --name ci-bot
173
+ treadstone api-keys create --name temp --expires-in 86400 # 24h
174
+ treadstone api-keys list
175
+ treadstone api-keys delete <key-id>
176
+ ```
177
+
178
+ ### `config`
179
+
180
+ Manage local CLI configuration.
181
+
182
+ ```bash
183
+ treadstone config set base_url https://...
184
+ treadstone config set api_key ts_...
185
+ treadstone config get
186
+ treadstone config get base_url
187
+ treadstone config unset api_key
188
+ treadstone config path
189
+ ```
190
+
191
+ ## JSON output
192
+
193
+ Pass `--json` before any command to get machine-readable JSON output:
194
+
195
+ ```bash
196
+ treadstone --json sb list
197
+ treadstone --json health
198
+ treadstone --json api-keys list
199
+ ```
200
+
201
+ ## Error handling
202
+
203
+ The CLI displays user-friendly error messages instead of stack traces:
204
+
205
+ ```
206
+ Error: Connection refused.
207
+ Possible causes:
208
+ - The Treadstone server is not running
209
+ - The --base-url or TREADSTONE_BASE_URL is incorrect
210
+ - A firewall or proxy is blocking the connection
211
+ Detail: ...
212
+ ```
213
+
214
+ Common errors:
215
+
216
+ | Error | Cause | Fix |
217
+ |-------|-------|-----|
218
+ | Connection refused | Server not running or wrong URL | Check `treadstone config get base_url` |
219
+ | Request timed out | Server slow or unreachable | Retry, or check network |
220
+ | No API key configured | Missing authentication | `treadstone config set api_key <key>` |
221
+ | HTTP 401 | Invalid or expired API key | Create a new key with `treadstone api-keys create` |
222
+ | HTTP 404 | Resource not found | Verify the ID is correct |
@@ -0,0 +1,14 @@
1
+ treadstone_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ treadstone_cli/__main__.py,sha256=eR-fq48-urz4L6AhswpU2pJf7ZaQ2rtcH6XZF0s3_Fs,148
3
+ treadstone_cli/_client.py,sha256=90xjZNjpWavW0qV2Y-WIbUrDnAnQR8sCt5LYPNi3mAY,2228
4
+ treadstone_cli/_output.py,sha256=qfjz_7kMO6pJFuiftObgvxDGLvmv_uB9cqchk439PCE,3623
5
+ treadstone_cli/api_keys.py,sha256=Otx-n5P3rTX6KxREphkuiGg6EUff_UvT7j3OluTm6y8,2285
6
+ treadstone_cli/auth.py,sha256=cDnVBnBs_LW-MpB_G2ahlLk6nW_LN4JweAjq9xOxv5E,5349
7
+ treadstone_cli/config_cmd.py,sha256=AlGxldd4GxahsbdOTIPSZ0Lq9JXfg3ZVWBqQAYtxbJ0,4421
8
+ treadstone_cli/main.py,sha256=Wj6ILbjRuGLsgURnQhTty1r1y10aCweT64CTbBFkveI,3538
9
+ treadstone_cli/sandboxes.py,sha256=-hSWyfvP-xiymIDCxPdUnYPeaDtIEhnMZu1wWEiPQHk,5529
10
+ treadstone_cli/templates.py,sha256=Q1OMc2rAIo802Pt5eFrgIEEp20mVUwCmeCQQwyySBG0,1109
11
+ treadstone_cli-0.2.1.dist-info/METADATA,sha256=kugHiZgcVi2qGcZShmMxvHP4Ps2Ozzx1w-C1faeEKaI,5491
12
+ treadstone_cli-0.2.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
13
+ treadstone_cli-0.2.1.dist-info/entry_points.txt,sha256=AEweiekAwgT6jzNSIVdmYk714t6zO3xMIHv4Hhh2vII,55
14
+ treadstone_cli-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ treadstone = treadstone_cli.main:cli