opensandbox-cli 0.1.0__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.
- opensandbox_cli/__init__.py +20 -0
- opensandbox_cli/__main__.py +20 -0
- opensandbox_cli/client.py +127 -0
- opensandbox_cli/commands/__init__.py +13 -0
- opensandbox_cli/commands/command.py +359 -0
- opensandbox_cli/commands/config_cmd.py +183 -0
- opensandbox_cli/commands/devops.py +123 -0
- opensandbox_cli/commands/egress.py +98 -0
- opensandbox_cli/commands/file.py +442 -0
- opensandbox_cli/commands/sandbox.py +580 -0
- opensandbox_cli/commands/skills.py +775 -0
- opensandbox_cli/config.py +160 -0
- opensandbox_cli/main.py +138 -0
- opensandbox_cli/output.py +363 -0
- opensandbox_cli/py.typed +1 -0
- opensandbox_cli/skill_registry.py +184 -0
- opensandbox_cli/skills/opensandbox-command-execution.md +215 -0
- opensandbox_cli/skills/opensandbox-file-operations.md +244 -0
- opensandbox_cli/skills/opensandbox-network-egress.md +179 -0
- opensandbox_cli/skills/opensandbox-sandbox-lifecycle.md +305 -0
- opensandbox_cli/skills/opensandbox-sandbox-troubleshooting.md +177 -0
- opensandbox_cli/utils.py +212 -0
- opensandbox_cli-0.1.0.dist-info/METADATA +597 -0
- opensandbox_cli-0.1.0.dist-info/RECORD +27 -0
- opensandbox_cli-0.1.0.dist-info/WHEEL +4 -0
- opensandbox_cli-0.1.0.dist-info/entry_points.txt +3 -0
- opensandbox_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# Copyright 2026 Alibaba Group Holding Ltd.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""CLI configuration loading and management.
|
|
16
|
+
|
|
17
|
+
Priority (highest to lowest):
|
|
18
|
+
1. CLI flags
|
|
19
|
+
2. Environment variables
|
|
20
|
+
3. Config file (~/.opensandbox/config.toml)
|
|
21
|
+
4. SDK defaults
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
if sys.version_info >= (3, 11):
|
|
32
|
+
import tomllib
|
|
33
|
+
else:
|
|
34
|
+
try:
|
|
35
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
36
|
+
except ModuleNotFoundError: # pragma: no cover
|
|
37
|
+
tomllib = None # type: ignore[assignment]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".opensandbox"
|
|
41
|
+
DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.toml"
|
|
42
|
+
|
|
43
|
+
DEFAULT_CONFIG_TEMPLATE = """\
|
|
44
|
+
# OpenSandbox CLI configuration
|
|
45
|
+
# Priority: CLI flags > environment variables > this file > SDK defaults
|
|
46
|
+
|
|
47
|
+
[connection]
|
|
48
|
+
# api_key = "your-api-key"
|
|
49
|
+
# domain = "localhost:8080"
|
|
50
|
+
# protocol = "http"
|
|
51
|
+
# request_timeout = 30
|
|
52
|
+
# use_server_proxy = false
|
|
53
|
+
|
|
54
|
+
[output]
|
|
55
|
+
# color = true
|
|
56
|
+
|
|
57
|
+
[defaults]
|
|
58
|
+
# image = "python:3.11"
|
|
59
|
+
# timeout = "10m" # or "none" for manual cleanup mode
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_config_file(config_path: Path | None = None) -> dict[str, Any]:
|
|
64
|
+
"""Load and parse the TOML config file.
|
|
65
|
+
|
|
66
|
+
Returns an empty dict if the file doesn't exist or tomllib is unavailable.
|
|
67
|
+
"""
|
|
68
|
+
path = config_path or DEFAULT_CONFIG_PATH
|
|
69
|
+
if not path.exists():
|
|
70
|
+
return {}
|
|
71
|
+
if tomllib is None:
|
|
72
|
+
return {}
|
|
73
|
+
with open(path, "rb") as f:
|
|
74
|
+
return tomllib.load(f)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_config(
|
|
78
|
+
*,
|
|
79
|
+
cli_api_key: str | None = None,
|
|
80
|
+
cli_domain: str | None = None,
|
|
81
|
+
cli_protocol: str | None = None,
|
|
82
|
+
cli_timeout: int | None = None,
|
|
83
|
+
cli_use_server_proxy: bool | None = None,
|
|
84
|
+
config_path: Path | None = None,
|
|
85
|
+
) -> dict[str, Any]:
|
|
86
|
+
"""Merge config from all sources and return a flat dict.
|
|
87
|
+
|
|
88
|
+
Keys returned:
|
|
89
|
+
- api_key, domain, protocol, request_timeout (int seconds), use_server_proxy (bool)
|
|
90
|
+
- default_image, default_timeout (str like "10m")
|
|
91
|
+
"""
|
|
92
|
+
file_cfg = load_config_file(config_path)
|
|
93
|
+
conn = file_cfg.get("connection", {})
|
|
94
|
+
output_cfg = file_cfg.get("output", {})
|
|
95
|
+
defaults = file_cfg.get("defaults", {})
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
"api_key": cli_api_key
|
|
99
|
+
or os.getenv("OPEN_SANDBOX_API_KEY")
|
|
100
|
+
or conn.get("api_key"),
|
|
101
|
+
"domain": cli_domain
|
|
102
|
+
or os.getenv("OPEN_SANDBOX_DOMAIN")
|
|
103
|
+
or conn.get("domain"),
|
|
104
|
+
"protocol": cli_protocol
|
|
105
|
+
or os.getenv("OPEN_SANDBOX_PROTOCOL")
|
|
106
|
+
or conn.get("protocol")
|
|
107
|
+
or "http",
|
|
108
|
+
"request_timeout": cli_timeout
|
|
109
|
+
or _int_or_none(os.getenv("OPEN_SANDBOX_REQUEST_TIMEOUT"))
|
|
110
|
+
or conn.get("request_timeout")
|
|
111
|
+
or 30,
|
|
112
|
+
"use_server_proxy": _coalesce(
|
|
113
|
+
cli_use_server_proxy,
|
|
114
|
+
_bool_or_none(os.getenv("OPEN_SANDBOX_USE_SERVER_PROXY")),
|
|
115
|
+
conn.get("use_server_proxy"),
|
|
116
|
+
False,
|
|
117
|
+
),
|
|
118
|
+
"color": output_cfg.get("color", True),
|
|
119
|
+
"default_image": defaults.get("image"),
|
|
120
|
+
"default_timeout": defaults.get("timeout"),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def init_config_file(config_path: Path | None = None, *, force: bool = False) -> Path:
|
|
125
|
+
"""Create a default config file. Returns the path written."""
|
|
126
|
+
path = config_path or DEFAULT_CONFIG_PATH
|
|
127
|
+
if path.exists() and not force:
|
|
128
|
+
raise FileExistsError(
|
|
129
|
+
f"Config file already exists at {path}. Use --force to overwrite."
|
|
130
|
+
)
|
|
131
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
path.write_text(DEFAULT_CONFIG_TEMPLATE)
|
|
133
|
+
return path
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _int_or_none(value: str | None) -> int | None:
|
|
137
|
+
if value is None:
|
|
138
|
+
return None
|
|
139
|
+
try:
|
|
140
|
+
return int(value)
|
|
141
|
+
except ValueError:
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _bool_or_none(value: str | None) -> bool | None:
|
|
146
|
+
if value is None:
|
|
147
|
+
return None
|
|
148
|
+
normalized = value.strip().lower()
|
|
149
|
+
if normalized in ("1", "true", "yes", "on"):
|
|
150
|
+
return True
|
|
151
|
+
if normalized in ("0", "false", "no", "off"):
|
|
152
|
+
return False
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _coalesce(*values: Any) -> Any:
|
|
157
|
+
for value in values:
|
|
158
|
+
if value is not None:
|
|
159
|
+
return value
|
|
160
|
+
return None
|
opensandbox_cli/main.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Copyright 2026 Alibaba Group Holding Ltd.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Root Click group with global options."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import click
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
|
|
24
|
+
from opensandbox_cli import __version__
|
|
25
|
+
from opensandbox_cli.client import ClientContext
|
|
26
|
+
from opensandbox_cli.commands.command import command_group
|
|
27
|
+
from opensandbox_cli.commands.config_cmd import config_group
|
|
28
|
+
from opensandbox_cli.commands.devops import devops_group
|
|
29
|
+
from opensandbox_cli.commands.egress import egress_group
|
|
30
|
+
from opensandbox_cli.commands.file import file_group
|
|
31
|
+
from opensandbox_cli.commands.sandbox import sandbox_group
|
|
32
|
+
from opensandbox_cli.commands.skills import skills_group
|
|
33
|
+
from opensandbox_cli.config import resolve_config
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Banner
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
BANNER = r"""[bold cyan]
|
|
40
|
+
____ _____ _ _
|
|
41
|
+
/ __ \ / ____| | | |
|
|
42
|
+
| | | |_ __ ___ _ _| (___ __ _ _ __ __| | |__ _____ __
|
|
43
|
+
| | | | '_ \ / _ \ '_ \___ \ / _` | '_ \ / _` | '_ \ / _ \ \/ /
|
|
44
|
+
| |__| | |_) | __/ | | |___) | (_| | | | | (_| | |_) | (_) > <
|
|
45
|
+
\____/| .__/ \___|_| |_|____/ \__,_|_| |_|\__,_|_.__/ \___/_/\_\
|
|
46
|
+
| |
|
|
47
|
+
|_|[/] [dim]v{version}[/]
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class BannerGroup(click.Group):
|
|
52
|
+
"""Custom Click group that shows a banner before help text."""
|
|
53
|
+
|
|
54
|
+
def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
|
|
55
|
+
console = Console(stderr=False)
|
|
56
|
+
console.print(BANNER.format(version=__version__))
|
|
57
|
+
super().format_help(ctx, formatter)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@click.group(cls=BannerGroup, context_settings={"help_option_names": ["-h", "--help"]})
|
|
61
|
+
@click.option("--api-key", envvar="OPEN_SANDBOX_API_KEY", default=None, help="API key for authentication.")
|
|
62
|
+
@click.option("--domain", envvar="OPEN_SANDBOX_DOMAIN", default=None, help="API server domain (e.g. localhost:8080).")
|
|
63
|
+
@click.option("--protocol", type=click.Choice(["http", "https"]), default=None, help="Protocol (http/https).")
|
|
64
|
+
@click.option("--request-timeout", type=int, default=None, help="Request timeout in seconds.")
|
|
65
|
+
@click.option(
|
|
66
|
+
"--use-server-proxy/--no-use-server-proxy",
|
|
67
|
+
default=None,
|
|
68
|
+
help="Route execd and endpoint traffic through the sandbox server proxy.",
|
|
69
|
+
)
|
|
70
|
+
@click.option("--config", "config_path", type=click.Path(exists=False, path_type=Path), default=None, help="Config file path.")
|
|
71
|
+
@click.option("-v", "--verbose", is_flag=True, default=False, help="Enable verbose/debug output.")
|
|
72
|
+
@click.option("--no-color", is_flag=True, default=False, help="Disable colored output.")
|
|
73
|
+
@click.version_option(version=__version__, prog_name="opensandbox")
|
|
74
|
+
@click.pass_context
|
|
75
|
+
def cli(
|
|
76
|
+
ctx: click.Context,
|
|
77
|
+
api_key: str | None,
|
|
78
|
+
domain: str | None,
|
|
79
|
+
protocol: str | None,
|
|
80
|
+
request_timeout: int | None,
|
|
81
|
+
use_server_proxy: bool | None,
|
|
82
|
+
config_path: Path | None,
|
|
83
|
+
verbose: bool,
|
|
84
|
+
no_color: bool,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""OpenSandbox CLI — manage sandboxes from your terminal."""
|
|
87
|
+
if verbose:
|
|
88
|
+
import logging
|
|
89
|
+
|
|
90
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
91
|
+
|
|
92
|
+
cli_overrides = {
|
|
93
|
+
"api_key": api_key,
|
|
94
|
+
"domain": domain,
|
|
95
|
+
"protocol": protocol,
|
|
96
|
+
"request_timeout": request_timeout,
|
|
97
|
+
"use_server_proxy": use_server_proxy,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if ctx.invoked_subcommand == "config":
|
|
101
|
+
resolved = {
|
|
102
|
+
"api_key": api_key,
|
|
103
|
+
"domain": domain,
|
|
104
|
+
"protocol": protocol or "http",
|
|
105
|
+
"request_timeout": request_timeout or 30,
|
|
106
|
+
"use_server_proxy": use_server_proxy if use_server_proxy is not None else False,
|
|
107
|
+
"color": True,
|
|
108
|
+
"default_image": None,
|
|
109
|
+
"default_timeout": None,
|
|
110
|
+
}
|
|
111
|
+
else:
|
|
112
|
+
resolved = resolve_config(
|
|
113
|
+
cli_api_key=api_key,
|
|
114
|
+
cli_domain=domain,
|
|
115
|
+
cli_protocol=protocol,
|
|
116
|
+
cli_timeout=request_timeout,
|
|
117
|
+
cli_use_server_proxy=use_server_proxy,
|
|
118
|
+
config_path=config_path,
|
|
119
|
+
)
|
|
120
|
+
resolved["color"] = not no_color and resolved.get("color", True)
|
|
121
|
+
|
|
122
|
+
effective_config_path = config_path or Path.home() / ".opensandbox" / "config.toml"
|
|
123
|
+
ctx.obj = ClientContext(
|
|
124
|
+
resolved_config=resolved,
|
|
125
|
+
config_path=effective_config_path,
|
|
126
|
+
cli_overrides=cli_overrides,
|
|
127
|
+
)
|
|
128
|
+
ctx.call_on_close(lambda: ctx.obj.close())
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# Register sub-command groups
|
|
132
|
+
cli.add_command(sandbox_group)
|
|
133
|
+
cli.add_command(command_group)
|
|
134
|
+
cli.add_command(file_group)
|
|
135
|
+
cli.add_command(egress_group)
|
|
136
|
+
cli.add_command(config_group)
|
|
137
|
+
cli.add_command(devops_group)
|
|
138
|
+
cli.add_command(skills_group)
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
# Copyright 2026 Alibaba Group Holding Ltd.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Output formatting: table (rich), JSON, YAML."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from collections.abc import Generator, Sequence
|
|
22
|
+
from contextlib import contextmanager
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import click
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
import yaml
|
|
29
|
+
except ImportError: # pragma: no cover
|
|
30
|
+
yaml = None # type: ignore[assignment]
|
|
31
|
+
|
|
32
|
+
from pydantic import BaseModel
|
|
33
|
+
from rich import box
|
|
34
|
+
from rich.console import Console
|
|
35
|
+
from rich.panel import Panel
|
|
36
|
+
from rich.status import Status
|
|
37
|
+
from rich.table import Table
|
|
38
|
+
from rich.text import Text
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Status badge styling (sandbox state → color + icon)
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
_STATUS_STYLES: dict[str, tuple[str, str]] = {
|
|
45
|
+
# state → (rich style, icon)
|
|
46
|
+
"running": ("bold green", "●"),
|
|
47
|
+
"ready": ("bold green", "●"),
|
|
48
|
+
"healthy": ("bold green", "●"),
|
|
49
|
+
"pending": ("bold yellow", "◐"),
|
|
50
|
+
"creating": ("bold yellow", "◐"),
|
|
51
|
+
"starting": ("bold yellow", "◐"),
|
|
52
|
+
"paused": ("bold blue", "⏸"),
|
|
53
|
+
"stopped": ("dim", "○"),
|
|
54
|
+
"terminated": ("dim", "○"),
|
|
55
|
+
"killed": ("dim", "○"),
|
|
56
|
+
"error": ("bold red", "✗"),
|
|
57
|
+
"failed": ("bold red", "✗"),
|
|
58
|
+
"unhealthy": ("bold red", "✗"),
|
|
59
|
+
"created": ("bold cyan", "✦"),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Columns that contain status-like values
|
|
63
|
+
_STATUS_COLUMNS = {"status", "state", "healthy"}
|
|
64
|
+
|
|
65
|
+
# Columns that should be rendered in a dimmer style (long IDs, timestamps)
|
|
66
|
+
_DIM_COLUMNS = {"created_at", "expires_at", "modified_at", "updated_at"}
|
|
67
|
+
|
|
68
|
+
# Columns that are primary identifiers
|
|
69
|
+
_ID_COLUMNS = {"id", "sandbox_id", "execution_id", "context_id"}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _style_value(col: str, value: str) -> Text:
|
|
73
|
+
"""Apply contextual styling to a cell value."""
|
|
74
|
+
lower = value.lower()
|
|
75
|
+
|
|
76
|
+
if col in _STATUS_COLUMNS:
|
|
77
|
+
style, icon = _STATUS_STYLES.get(lower, ("", ""))
|
|
78
|
+
if style:
|
|
79
|
+
return Text(f"{icon} {value}", style=style)
|
|
80
|
+
|
|
81
|
+
if col in _DIM_COLUMNS:
|
|
82
|
+
return Text(value, style="dim")
|
|
83
|
+
|
|
84
|
+
if col in _ID_COLUMNS:
|
|
85
|
+
return Text(value, style="bold cyan")
|
|
86
|
+
|
|
87
|
+
return Text(value)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class OutputFormatter:
|
|
91
|
+
"""Renders data in table / json / yaml format."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, fmt: str = "table", *, color: bool = True) -> None:
|
|
94
|
+
self.fmt = fmt
|
|
95
|
+
self.color = color
|
|
96
|
+
self.console = Console(
|
|
97
|
+
stderr=False, no_color=not color, force_terminal=None
|
|
98
|
+
)
|
|
99
|
+
self._err_console = Console(
|
|
100
|
+
stderr=True, no_color=not color, force_terminal=None
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
# Status messages with icons
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def success(self, msg: str) -> None:
|
|
108
|
+
"""Print a success message with ✅ icon."""
|
|
109
|
+
if self.fmt == "json":
|
|
110
|
+
self._print_json({"status": "ok", "message": msg})
|
|
111
|
+
return
|
|
112
|
+
if self.fmt == "yaml":
|
|
113
|
+
self._print_yaml({"status": "ok", "message": msg})
|
|
114
|
+
return
|
|
115
|
+
if self.color:
|
|
116
|
+
self.console.print(f" [bold green]✅ {msg}[/]")
|
|
117
|
+
else:
|
|
118
|
+
click.echo(f"OK: {msg}")
|
|
119
|
+
|
|
120
|
+
def info(self, msg: str) -> None:
|
|
121
|
+
"""Print an info message with ℹ️ icon."""
|
|
122
|
+
if self.fmt == "json":
|
|
123
|
+
self._print_json({"status": "info", "message": msg})
|
|
124
|
+
return
|
|
125
|
+
if self.fmt == "yaml":
|
|
126
|
+
self._print_yaml({"status": "info", "message": msg})
|
|
127
|
+
return
|
|
128
|
+
if self.color:
|
|
129
|
+
self.console.print(f" [bold blue]ℹ️ {msg}[/]")
|
|
130
|
+
else:
|
|
131
|
+
click.echo(f"INFO: {msg}")
|
|
132
|
+
|
|
133
|
+
def warning(self, msg: str) -> None:
|
|
134
|
+
"""Print a warning message with ⚠️ icon."""
|
|
135
|
+
if self.fmt == "json":
|
|
136
|
+
self._print_json({"status": "warning", "message": msg})
|
|
137
|
+
return
|
|
138
|
+
if self.fmt == "yaml":
|
|
139
|
+
self._print_yaml({"status": "warning", "message": msg})
|
|
140
|
+
return
|
|
141
|
+
if self.color:
|
|
142
|
+
self._err_console.print(f" [bold yellow]⚠️ {msg}[/]")
|
|
143
|
+
else:
|
|
144
|
+
click.echo(f"WARN: {msg}", err=True)
|
|
145
|
+
|
|
146
|
+
def error(self, msg: str) -> None:
|
|
147
|
+
"""Print an error message with ❌ icon."""
|
|
148
|
+
if self.fmt == "json":
|
|
149
|
+
self._print_json({"status": "error", "message": msg})
|
|
150
|
+
return
|
|
151
|
+
if self.fmt == "yaml":
|
|
152
|
+
self._print_yaml({"status": "error", "message": msg})
|
|
153
|
+
return
|
|
154
|
+
if self.color:
|
|
155
|
+
self._err_console.print(f" [bold red]❌ {msg}[/]")
|
|
156
|
+
else:
|
|
157
|
+
click.echo(f"ERROR: {msg}", err=True)
|
|
158
|
+
|
|
159
|
+
def error_panel(self, msg: str, title: str = "Error") -> None:
|
|
160
|
+
"""Print an error with a bold header and message."""
|
|
161
|
+
if self.color:
|
|
162
|
+
self._err_console.print()
|
|
163
|
+
self._err_console.print(f" [bold red]{title}[/]")
|
|
164
|
+
self._err_console.print(f" [dim]{'─' * (len(title) + 2)}[/]")
|
|
165
|
+
for line in msg.splitlines():
|
|
166
|
+
self._err_console.print(f" {line}")
|
|
167
|
+
self._err_console.print()
|
|
168
|
+
else:
|
|
169
|
+
click.echo(f"ERROR [{title}]: {msg}", err=True)
|
|
170
|
+
|
|
171
|
+
# ------------------------------------------------------------------
|
|
172
|
+
# Spinner for long-running operations
|
|
173
|
+
# ------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
@contextmanager
|
|
176
|
+
def spinner(self, msg: str) -> Generator[Status, None, None]:
|
|
177
|
+
"""Context manager that shows a spinner while work is in progress."""
|
|
178
|
+
if self.color and self.fmt == "table":
|
|
179
|
+
with self._err_console.status(f"[bold cyan]⏳ {msg}[/]", spinner="dots") as status:
|
|
180
|
+
yield status
|
|
181
|
+
else:
|
|
182
|
+
# No spinner in non-color or non-table mode
|
|
183
|
+
yield None # type: ignore[arg-type]
|
|
184
|
+
|
|
185
|
+
# ------------------------------------------------------------------
|
|
186
|
+
# Panel output
|
|
187
|
+
# ------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def panel(self, content: str, *, title: str | None = None, style: str = "cyan") -> None:
|
|
190
|
+
"""Print content inside a styled panel."""
|
|
191
|
+
if self.color:
|
|
192
|
+
self.console.print(Panel(
|
|
193
|
+
content,
|
|
194
|
+
title=title,
|
|
195
|
+
title_align="left",
|
|
196
|
+
border_style=style,
|
|
197
|
+
box=box.ROUNDED,
|
|
198
|
+
padding=(0, 1),
|
|
199
|
+
))
|
|
200
|
+
else:
|
|
201
|
+
if title:
|
|
202
|
+
click.echo(f"--- {title} ---")
|
|
203
|
+
click.echo(content)
|
|
204
|
+
|
|
205
|
+
def success_panel(self, data: dict[str, Any], *, title: str = "Success") -> None:
|
|
206
|
+
"""Print a success result with a header and indented key-value pairs."""
|
|
207
|
+
if self.fmt != "table":
|
|
208
|
+
if self.fmt == "json":
|
|
209
|
+
self._print_json(data)
|
|
210
|
+
elif self.fmt == "yaml":
|
|
211
|
+
self._print_yaml(data)
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
if self.color:
|
|
215
|
+
self.console.print()
|
|
216
|
+
self.console.print(f" [bold green]✓ {title}[/]")
|
|
217
|
+
self.console.print(f" [dim]{'─' * (len(title) + 2)}[/]")
|
|
218
|
+
for k, v in data.items():
|
|
219
|
+
self.console.print(f" [bold]{k}:[/] [cyan]{v}[/]")
|
|
220
|
+
self.console.print()
|
|
221
|
+
else:
|
|
222
|
+
click.echo(f"--- {title} ---")
|
|
223
|
+
for k, v in data.items():
|
|
224
|
+
click.echo(f" {k}: {v}")
|
|
225
|
+
|
|
226
|
+
# ------------------------------------------------------------------
|
|
227
|
+
# Public helpers
|
|
228
|
+
# ------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
def print_model(self, model: BaseModel, title: str | None = None) -> None:
|
|
231
|
+
"""Print a single Pydantic model as key-value panel or JSON/YAML."""
|
|
232
|
+
data = _model_to_dict(model)
|
|
233
|
+
if self.fmt == "json":
|
|
234
|
+
self._print_json(data)
|
|
235
|
+
elif self.fmt == "yaml":
|
|
236
|
+
self._print_yaml(data)
|
|
237
|
+
else:
|
|
238
|
+
self._print_kv_table(data, title=title)
|
|
239
|
+
|
|
240
|
+
def print_models(
|
|
241
|
+
self,
|
|
242
|
+
models: Sequence[BaseModel],
|
|
243
|
+
columns: list[str],
|
|
244
|
+
*,
|
|
245
|
+
title: str | None = None,
|
|
246
|
+
) -> None:
|
|
247
|
+
"""Print a list of Pydantic models as a table or JSON/YAML."""
|
|
248
|
+
rows = [_model_to_dict(m) for m in models]
|
|
249
|
+
if self.fmt == "json":
|
|
250
|
+
self._print_json(rows)
|
|
251
|
+
elif self.fmt == "yaml":
|
|
252
|
+
self._print_yaml(rows)
|
|
253
|
+
else:
|
|
254
|
+
self._print_table(rows, columns, title=title)
|
|
255
|
+
|
|
256
|
+
def print_rows(
|
|
257
|
+
self,
|
|
258
|
+
rows: list[dict[str, Any]],
|
|
259
|
+
columns: list[str],
|
|
260
|
+
*,
|
|
261
|
+
title: str | None = None,
|
|
262
|
+
) -> None:
|
|
263
|
+
"""Print pre-processed rows (list of dicts) as a table or JSON/YAML."""
|
|
264
|
+
if self.fmt == "json":
|
|
265
|
+
self._print_json(rows)
|
|
266
|
+
elif self.fmt == "yaml":
|
|
267
|
+
self._print_yaml(rows)
|
|
268
|
+
else:
|
|
269
|
+
self._print_table(rows, columns, title=title)
|
|
270
|
+
|
|
271
|
+
def print_dict(self, data: dict[str, Any], title: str | None = None) -> None:
|
|
272
|
+
"""Print a flat dict."""
|
|
273
|
+
if self.fmt == "json":
|
|
274
|
+
self._print_json(data)
|
|
275
|
+
elif self.fmt == "yaml":
|
|
276
|
+
self._print_yaml(data)
|
|
277
|
+
else:
|
|
278
|
+
self._print_kv_table(data, title=title)
|
|
279
|
+
|
|
280
|
+
def print_text(self, text: str) -> None:
|
|
281
|
+
"""Print raw text (ignores format)."""
|
|
282
|
+
click.echo(text)
|
|
283
|
+
|
|
284
|
+
# ------------------------------------------------------------------
|
|
285
|
+
# Internal renderers
|
|
286
|
+
# ------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
def _print_json(self, data: Any) -> None:
|
|
289
|
+
if self.color:
|
|
290
|
+
self.console.print_json(json.dumps(data, default=str))
|
|
291
|
+
else:
|
|
292
|
+
click.echo(json.dumps(data, indent=2, default=str))
|
|
293
|
+
|
|
294
|
+
def _print_yaml(self, data: Any) -> None:
|
|
295
|
+
if yaml is None:
|
|
296
|
+
click.secho(
|
|
297
|
+
"PyYAML is not installed. Use --output json instead.", fg="red", err=True
|
|
298
|
+
)
|
|
299
|
+
sys.exit(1)
|
|
300
|
+
click.echo(yaml.dump(data, default_flow_style=False, allow_unicode=True).rstrip())
|
|
301
|
+
|
|
302
|
+
def _print_kv_table(self, data: dict[str, Any], *, title: str | None = None) -> None:
|
|
303
|
+
table = Table(
|
|
304
|
+
title=title,
|
|
305
|
+
show_header=True,
|
|
306
|
+
header_style="bold magenta",
|
|
307
|
+
title_style="bold cyan",
|
|
308
|
+
box=box.ROUNDED,
|
|
309
|
+
border_style="bright_black",
|
|
310
|
+
padding=(0, 1),
|
|
311
|
+
show_lines=True,
|
|
312
|
+
)
|
|
313
|
+
table.add_column("Key", style="bold cyan", no_wrap=True)
|
|
314
|
+
table.add_column("Value")
|
|
315
|
+
for k, v in data.items():
|
|
316
|
+
val_text = _style_value(k, str(v)) if v is not None else Text("-", style="dim")
|
|
317
|
+
table.add_row(str(k), val_text)
|
|
318
|
+
self.console.print(table)
|
|
319
|
+
|
|
320
|
+
def _print_table(
|
|
321
|
+
self,
|
|
322
|
+
rows: list[dict[str, Any]],
|
|
323
|
+
columns: list[str],
|
|
324
|
+
*,
|
|
325
|
+
title: str | None = None,
|
|
326
|
+
) -> None:
|
|
327
|
+
table = Table(
|
|
328
|
+
title=title,
|
|
329
|
+
show_header=True,
|
|
330
|
+
header_style="bold magenta",
|
|
331
|
+
title_style="bold cyan",
|
|
332
|
+
box=box.ROUNDED,
|
|
333
|
+
border_style="bright_black",
|
|
334
|
+
padding=(0, 1),
|
|
335
|
+
row_styles=["", "dim"],
|
|
336
|
+
)
|
|
337
|
+
for col in columns:
|
|
338
|
+
style = ""
|
|
339
|
+
if col in _ID_COLUMNS:
|
|
340
|
+
style = "bold cyan"
|
|
341
|
+
elif col in _DIM_COLUMNS:
|
|
342
|
+
style = "dim"
|
|
343
|
+
table.add_column(col.upper(), style=style, no_wrap=(col in _ID_COLUMNS))
|
|
344
|
+
|
|
345
|
+
for row in rows:
|
|
346
|
+
cells: list[Text | str] = []
|
|
347
|
+
for col in columns:
|
|
348
|
+
val = str(row.get(col, "-"))
|
|
349
|
+
if col in _STATUS_COLUMNS:
|
|
350
|
+
cells.append(_style_value(col, val))
|
|
351
|
+
else:
|
|
352
|
+
cells.append(val)
|
|
353
|
+
table.add_row(*cells)
|
|
354
|
+
self.console.print(table)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# ------------------------------------------------------------------
|
|
358
|
+
# Helpers
|
|
359
|
+
# ------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _model_to_dict(model: BaseModel) -> dict[str, Any]:
|
|
363
|
+
return model.model_dump(mode="json")
|
opensandbox_cli/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|