hyperbox-mcp 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,90 @@
1
+ """Building custom sandbox environments, from a terminal only.
2
+
3
+ This is deliberately not reachable from the MCP tool surface. A build
4
+ runs whatever the Dockerfile says — arbitrary commands, as root, with
5
+ network access, under none of the limits policy.py enforces on a
6
+ sandbox. That is a decision for the person at the keyboard, not for a
7
+ model. Agents consume the result by name; they never produce it.
8
+
9
+ Anything here may print to stdout: it runs under the `hyperbox` CLI,
10
+ never inside the server process, whose stdout belongs to the client.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import shutil
16
+ import subprocess
17
+ from pathlib import Path
18
+
19
+ from hyperbox_mcp import engine, policy, validate
20
+
21
+ HYPERBOX_DIR = Path.home() / ".hyperbox"
22
+ ENV_DIR = HYPERBOX_DIR / "environments"
23
+
24
+
25
+ def list_environments() -> int:
26
+ """Print every environment the server would currently resolve."""
27
+ envs = policy.environments()
28
+ print(f"{'ENVIRONMENT':<20} {'IMAGE':<48} {'DOCKERFILE'}")
29
+ print("-" * 82)
30
+ for name, image in sorted(envs.items()):
31
+ dockerfile = ENV_DIR / name / "Dockerfile"
32
+ print(f"{name:<20} {image:<48} {'yes' if dockerfile.exists() else 'built-in'}")
33
+ return 0
34
+
35
+
36
+ def run_build(env_name: str, custom_path: str | None = None) -> int:
37
+ """Build a local Dockerfile into the image `create_sandbox` will use."""
38
+ # Validated here too, not only at the MCP boundary: env_name becomes a
39
+ # directory under ~/.hyperbox and an image tag, and this path never
40
+ # goes through the server.
41
+ try:
42
+ name = validate.environment_name(env_name)
43
+ except validate.InvalidInput as exc:
44
+ print(f"Error: {exc}")
45
+ return 2
46
+
47
+ env_dir = ENV_DIR / name
48
+ dockerfile = env_dir / "Dockerfile"
49
+
50
+ if custom_path:
51
+ source = Path(custom_path).expanduser().resolve()
52
+ if not source.is_file():
53
+ print(f"Error: no Dockerfile at {source}")
54
+ return 1
55
+ env_dir.mkdir(parents=True, exist_ok=True)
56
+ shutil.copy2(source, dockerfile)
57
+ print(f"Copied {source} -> {dockerfile}")
58
+
59
+ if not dockerfile.exists():
60
+ print(f"Error: no Dockerfile at {dockerfile}")
61
+ print("Provide one with:")
62
+ print(f" hyperbox build {name} --custom /path/to/Dockerfile")
63
+ return 1
64
+
65
+ try:
66
+ backend = engine.detect("auto")
67
+ except Exception as exc: # noqa: BLE001 - reported, not swallowed
68
+ print(f"Error: no container engine available: {exc}")
69
+ return 1
70
+
71
+ target_image = f"hyperbox-local/{name}:latest"
72
+ binary = engine.client_dialect(backend)
73
+ cmd = [binary, "build", "-t", target_image, "-f", str(dockerfile), str(env_dir)]
74
+
75
+ print(f"Building '{name}' as {target_image} using {binary}...")
76
+ try:
77
+ result = subprocess.run(cmd, check=False)
78
+ except KeyboardInterrupt:
79
+ print("\nBuild cancelled.")
80
+ return 130
81
+ except OSError as exc:
82
+ print(f"\nCould not run {binary}: {exc}")
83
+ return 1
84
+
85
+ if result.returncode == 0:
86
+ print(f"\nBuilt '{name}'. Use it with create_sandbox(environment='{name}').")
87
+ print("A running server picks it up without a restart.")
88
+ else:
89
+ print(f"\nBuild failed with exit code {result.returncode}.")
90
+ return result.returncode
hyperbox_mcp/cli.py ADDED
@@ -0,0 +1,164 @@
1
+ """Terminal subcommands for the `hyperbox` executable.
2
+
3
+ Kept apart from server.py so that starting the MCP server imports none of
4
+ it. Anything here may print to stdout; the server may not, because a
5
+ stdio MCP client is reading that stream.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from importlib.metadata import PackageNotFoundError, version
12
+
13
+
14
+ def usage() -> str:
15
+ return (
16
+ "Usage:\n"
17
+ " hyperbox Start the MCP server on stdio (default)\n"
18
+ " hyperbox doctor Check this machine can run sandboxes\n"
19
+ " --pull Also pull the sandbox image if missing\n"
20
+ " --quick Skip the live create/run/destroy check\n"
21
+ " hyperbox config Print a ready-to-paste MCP client config\n"
22
+ " --format json mcpServers block (Claude Desktop, generic)\n"
23
+ " --format cursor servers block (.vscode/mcp.json)\n"
24
+ " --format yaml YAML list (Continue-based clients)\n"
25
+ " --format antigravity Antigravity mcp_config.json\n"
26
+ " hyperbox envs List environments create_sandbox can use\n"
27
+ " hyperbox build <name> Build an environment from a Dockerfile\n"
28
+ " --custom <path> Copy that Dockerfile in and build it\n"
29
+ " hyperbox logs Show the server log\n"
30
+ " --follow Keep printing as new lines arrive\n"
31
+ " hyperbox --version Print the installed version\n"
32
+ )
33
+
34
+
35
+ def _version() -> str:
36
+ try:
37
+ return version("hyperbox-mcp")
38
+ except PackageNotFoundError: # pragma: no cover - running from source
39
+ return "unknown (not installed as a package)"
40
+
41
+
42
+ def _logs(follow: bool = False) -> int:
43
+ """Print the server log, optionally following it.
44
+
45
+ Written in Python rather than shelling out to `tail -f`: this package
46
+ is expected to work on Windows, where there is no tail, and building
47
+ a shell command out of a home-directory path invites quoting bugs.
48
+ """
49
+ import time
50
+
51
+ from hyperbox_mcp.server import LOG_FILE
52
+
53
+ if not LOG_FILE.exists():
54
+ print(f"No log yet at {LOG_FILE}")
55
+ print("It is created when the server next starts.")
56
+ return 1
57
+
58
+ with LOG_FILE.open("r", encoding="utf-8", errors="replace") as fh:
59
+ sys.stdout.write(fh.read())
60
+ if not follow:
61
+ return 0
62
+ sys.stdout.flush()
63
+ try:
64
+ while True:
65
+ line = fh.readline()
66
+ if line:
67
+ sys.stdout.write(line)
68
+ sys.stdout.flush()
69
+ else:
70
+ time.sleep(0.4)
71
+ except KeyboardInterrupt:
72
+ return 0
73
+
74
+
75
+ def dispatch(argv: list[str]) -> int:
76
+ command, *rest = argv
77
+
78
+ if command in {"--version", "-V"}:
79
+ print(f"hyperbox {_version()}")
80
+ return 0
81
+
82
+ if command in {"help", "--help", "-h"}:
83
+ print(usage())
84
+ return 0
85
+
86
+ if command == "doctor":
87
+ unknown = [a for a in rest if a not in {"--pull", "--quick"}]
88
+ if unknown:
89
+ print(f"hyperbox doctor: unknown option {unknown[0]!r}\n")
90
+ print(usage())
91
+ return 2
92
+ from hyperbox_mcp.doctor import run_doctor
93
+
94
+ return run_doctor(pull="--pull" in rest, live="--quick" not in rest)
95
+
96
+ if command == "config":
97
+ fmt = "json"
98
+ rest_iter = list(rest)
99
+ while rest_iter:
100
+ arg = rest_iter.pop(0)
101
+ if arg == "--format":
102
+ if not rest_iter:
103
+ print("hyperbox config: --format needs a value\n")
104
+ print(usage())
105
+ return 2
106
+ fmt = rest_iter.pop(0)
107
+ elif arg.startswith("--format="):
108
+ fmt = arg.split("=", 1)[1]
109
+ else:
110
+ print(f"hyperbox config: unknown option {arg!r}\n")
111
+ print(usage())
112
+ return 2
113
+ if fmt not in {"json", "cursor", "yaml", "antigravity"}:
114
+ print(f"hyperbox config: unknown format {fmt!r}. "
115
+ "Use json, cursor, yaml or antigravity.\n")
116
+ return 2
117
+ from hyperbox_mcp.clientconfig import print_config
118
+
119
+ return print_config(fmt)
120
+
121
+ if command == "logs":
122
+ unknown = [a for a in rest if a not in {"--follow", "-f"}]
123
+ if unknown:
124
+ print(f"hyperbox logs: unknown option {unknown[0]!r}\n")
125
+ print(usage())
126
+ return 2
127
+ return _logs(follow=bool(rest))
128
+
129
+ if command == "envs":
130
+ if rest:
131
+ print(f"hyperbox envs: unexpected argument {rest[0]!r}\n")
132
+ print(usage())
133
+ return 2
134
+ from hyperbox_mcp.builder import list_environments
135
+
136
+ return list_environments()
137
+
138
+ if command == "build":
139
+ if not rest:
140
+ print("hyperbox build: missing environment name\n")
141
+ print(usage())
142
+ return 2
143
+ name, *opts = rest
144
+ custom = None
145
+ while opts:
146
+ arg = opts.pop(0)
147
+ if arg == "--custom":
148
+ if not opts:
149
+ print("hyperbox build: --custom needs a path\n")
150
+ print(usage())
151
+ return 2
152
+ custom = opts.pop(0)
153
+ elif arg.startswith("--custom="):
154
+ custom = arg.split("=", 1)[1]
155
+ else:
156
+ print(f"hyperbox build: unknown option {arg!r}\n")
157
+ print(usage())
158
+ return 2
159
+ from hyperbox_mcp.builder import run_build
160
+
161
+ return run_build(name, custom)
162
+
163
+ print(usage())
164
+ return 2
@@ -0,0 +1,203 @@
1
+ """Generate a ready-to-paste MCP client configuration.
2
+
3
+ Every value in a client config is something this process already knows:
4
+ where its own executable lives, and where the container CLI lives. Asking
5
+ a person to retype those by hand is where setup actually fails — most
6
+ sharply on Windows, where an absolute path routinely contains a space and
7
+ every backslash has to be escaped for JSON, in a file whose only failure
8
+ mode is "no tools appeared" with no error anywhere.
9
+
10
+ So the escaping is done by serialising rather than by hand. `json.dumps`
11
+ gets Windows paths right by construction, and YAML's double-quoted
12
+ scalars use the same escape rules, so the YAML output quotes its strings
13
+ the same way — which is also why this needs no YAML dependency.
14
+
15
+ Notes go to stderr and the config to stdout, so `hyperbox config >
16
+ mcp.json` writes a clean file while a person still sees the guidance.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import shutil
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ from hyperbox_mcp import engine, policy
28
+
29
+ #: The server name a client will show these tools under.
30
+ SERVER_NAME = "hyperbox"
31
+
32
+ #: Directories that must be on PATH for the server to find its engine,
33
+ #: beyond whatever the engine CLI's own directory turns out to be.
34
+ _SYSTEM_PATH_DIRS = {
35
+ "win32": (r"C:\Windows\System32", r"C:\Windows"),
36
+ }
37
+ _POSIX_PATH_DIRS = ("/usr/local/bin", "/usr/bin", "/bin")
38
+
39
+
40
+ def executable_path() -> str:
41
+ """Absolute path to the `hyperbox` command a client should launch.
42
+
43
+ `shutil.which` is preferred over `sys.argv[0]`: it resolves the name
44
+ the way a client would, which is the thing being configured.
45
+ """
46
+ found = shutil.which("hyperbox")
47
+ if found:
48
+ return os.path.abspath(found)
49
+ # Running as `python -m hyperbox_mcp.server`, or a console script not
50
+ # on PATH. argv[0] still names something launchable.
51
+ argv0 = sys.argv[0] or ""
52
+ if argv0 and os.path.exists(argv0):
53
+ return os.path.abspath(argv0)
54
+ return "hyperbox"
55
+
56
+
57
+ def _looks_like_a_checkout(executable: str) -> bool:
58
+ """Whether this executable lives inside a source tree rather than an
59
+ installed tool — `uv run` in a clone produces exactly that."""
60
+ parts = {p.lower() for p in Path(executable).parts}
61
+ return bool({".venv", "venv"} & parts)
62
+
63
+
64
+ def path_entries() -> list[str]:
65
+ """Directories a client must put on PATH, most specific first.
66
+
67
+ Clients launch servers with a trimmed environment, so the container
68
+ CLI is frequently unreachable unless it is named explicitly. The
69
+ engine probe already knows where it is.
70
+ """
71
+ entries: list[str] = []
72
+
73
+ def add(directory: str) -> None:
74
+ if directory and directory not in entries:
75
+ entries.append(directory)
76
+
77
+ # The executable's own directory, so a client that resolves by name
78
+ # still finds it.
79
+ add(os.path.dirname(executable_path()))
80
+
81
+ for backend in policy.BACKENDS:
82
+ try:
83
+ status = engine.probe(backend)
84
+ except Exception: # noqa: BLE001 - a config is useful without an engine
85
+ continue
86
+ if status.binary:
87
+ add(os.path.dirname(status.binary))
88
+
89
+ for directory in _SYSTEM_PATH_DIRS.get(sys.platform, _POSIX_PATH_DIRS):
90
+ add(directory)
91
+ return entries
92
+
93
+
94
+ def _server_entry() -> dict:
95
+ separator = ";" if sys.platform == "win32" else ":"
96
+ return {
97
+ "command": executable_path(),
98
+ "args": [],
99
+ "env": {"PATH": separator.join(path_entries())},
100
+ }
101
+
102
+
103
+ def _yaml_scalar(value: str) -> str:
104
+ """A double-quoted YAML scalar.
105
+
106
+ YAML's double-quoted style uses JSON's escape rules, so serialising
107
+ with json.dumps produces a correct — and correctly escaped — scalar.
108
+ That is the whole point: Windows backslashes are handled by the
109
+ serialiser rather than by whoever is editing the file.
110
+ """
111
+ return json.dumps(value)
112
+
113
+
114
+ def render(fmt: str = "json") -> str:
115
+ """The configuration block for `fmt`, ready to paste."""
116
+ entry = _server_entry()
117
+
118
+ if fmt == "yaml":
119
+ # Continue-based clients: a YAML list under mcpServers.
120
+ lines = [
121
+ "mcpServers:",
122
+ f" - name: {SERVER_NAME}",
123
+ f" command: {_yaml_scalar(entry['command'])}",
124
+ " args: []",
125
+ " env:",
126
+ f" PATH: {_yaml_scalar(entry['env']['PATH'])}",
127
+ ]
128
+ return "\n".join(lines)
129
+
130
+ # Cursor and VS Code use "servers" in .vscode/mcp.json; Claude Desktop,
131
+ # Antigravity and most others use "mcpServers". Same object either way,
132
+ # so antigravity needs no branch here — only a different file to put it
133
+ # in, which notes() names.
134
+ key = "servers" if fmt == "cursor" else "mcpServers"
135
+ return json.dumps({key: {SERVER_NAME: entry}}, indent=2)
136
+
137
+
138
+ def notes(fmt: str) -> list[str]:
139
+ """Guidance printed to stderr, so stdout stays paste-clean."""
140
+ executable = executable_path()
141
+ out = []
142
+
143
+ if fmt == "yaml":
144
+ out.append(
145
+ "Continue-based clients: merge into your mcpservers YAML "
146
+ "config (often mcpservers/config.yaml)"
147
+ )
148
+ elif fmt == "cursor":
149
+ out.append("Cursor / VS Code: merge into .vscode/mcp.json")
150
+ elif fmt == "antigravity":
151
+ out.append(
152
+ "Antigravity: merge into ~/.gemini/antigravity/mcp_config.json"
153
+ )
154
+ out.append(
155
+ " (restart Antigravity afterwards; it reads the file at startup)"
156
+ )
157
+ else:
158
+ out.append("Merge into your client's MCP config (Claude Desktop: "
159
+ "claude_desktop_config.json)")
160
+
161
+ out.append("")
162
+ if _looks_like_a_checkout(executable):
163
+ out.append(
164
+ "WARNING: this command lives inside a source checkout, so the "
165
+ "config below is tied to that folder and breaks if you move or "
166
+ "delete it. Install it as a tool, then run this again:"
167
+ )
168
+ out.append(" uv tool install hyperbox-mcp")
169
+ out.append(f" (current: {executable})")
170
+ else:
171
+ out.append(
172
+ "No checkout is needed: this launches the installed executable "
173
+ "directly, so the repository can be moved or deleted."
174
+ )
175
+
176
+ reachable = [b for b in policy.BACKENDS if engine.probe(b).reachable]
177
+ if reachable:
178
+ out.append("")
179
+ out.append(f"Container engine found: {', '.join(reachable)}")
180
+ else:
181
+ out.append("")
182
+ out.append(
183
+ "WARNING: no container engine is reachable right now. The "
184
+ "config below is still correct — start Docker or Podman, then "
185
+ "run `hyperbox doctor`."
186
+ )
187
+
188
+ out.append("")
189
+ out.append(
190
+ "Before the first sandbox, pull the image once so a multi-gigabyte "
191
+ "download never happens inside a client request (clients time a "
192
+ "request out long before it could finish):"
193
+ )
194
+ out.append(" hyperbox doctor --pull")
195
+ return out
196
+
197
+
198
+ def print_config(fmt: str = "json") -> int:
199
+ for line in notes(fmt):
200
+ print(line, file=sys.stderr)
201
+ print("", file=sys.stderr)
202
+ print(render(fmt))
203
+ return 0