hypha-debugger 0.1.11__tar.gz → 0.2.0__tar.gz

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.
Files changed (26) hide show
  1. {hypha_debugger-0.1.11 → hypha_debugger-0.2.0}/PKG-INFO +1 -1
  2. {hypha_debugger-0.1.11 → hypha_debugger-0.2.0}/hypha_debugger/__init__.py +1 -1
  3. hypha_debugger-0.2.0/hypha_debugger/cli.py +426 -0
  4. {hypha_debugger-0.1.11 → hypha_debugger-0.2.0}/hypha_debugger/debugger.py +14 -24
  5. hypha_debugger-0.2.0/hypha_debugger/services/shell.py +154 -0
  6. {hypha_debugger-0.1.11 → hypha_debugger-0.2.0}/hypha_debugger/services/source.py +47 -0
  7. {hypha_debugger-0.1.11 → hypha_debugger-0.2.0}/hypha_debugger.egg-info/PKG-INFO +1 -1
  8. {hypha_debugger-0.1.11 → hypha_debugger-0.2.0}/hypha_debugger.egg-info/SOURCES.txt +5 -1
  9. {hypha_debugger-0.1.11 → hypha_debugger-0.2.0}/hypha_debugger.egg-info/entry_points.txt +1 -0
  10. {hypha_debugger-0.1.11 → hypha_debugger-0.2.0}/pyproject.toml +2 -1
  11. hypha_debugger-0.2.0/tests/test_cli.py +131 -0
  12. {hypha_debugger-0.1.11 → hypha_debugger-0.2.0}/tests/test_services.py +7 -9
  13. hypha_debugger-0.2.0/tests/test_shell.py +50 -0
  14. {hypha_debugger-0.1.11 → hypha_debugger-0.2.0}/README.md +0 -0
  15. {hypha_debugger-0.1.11 → hypha_debugger-0.2.0}/hypha_debugger/__main__.py +0 -0
  16. {hypha_debugger-0.1.11 → hypha_debugger-0.2.0}/hypha_debugger/services/__init__.py +0 -0
  17. {hypha_debugger-0.1.11 → hypha_debugger-0.2.0}/hypha_debugger/services/execute.py +0 -0
  18. {hypha_debugger-0.1.11 → hypha_debugger-0.2.0}/hypha_debugger/services/filesystem.py +0 -0
  19. {hypha_debugger-0.1.11 → hypha_debugger-0.2.0}/hypha_debugger/services/info.py +0 -0
  20. {hypha_debugger-0.1.11 → hypha_debugger-0.2.0}/hypha_debugger/services/inspect_vars.py +0 -0
  21. {hypha_debugger-0.1.11 → hypha_debugger-0.2.0}/hypha_debugger/utils/__init__.py +0 -0
  22. {hypha_debugger-0.1.11 → hypha_debugger-0.2.0}/hypha_debugger/utils/env.py +0 -0
  23. {hypha_debugger-0.1.11 → hypha_debugger-0.2.0}/hypha_debugger.egg-info/dependency_links.txt +0 -0
  24. {hypha_debugger-0.1.11 → hypha_debugger-0.2.0}/hypha_debugger.egg-info/requires.txt +0 -0
  25. {hypha_debugger-0.1.11 → hypha_debugger-0.2.0}/hypha_debugger.egg-info/top_level.txt +0 -0
  26. {hypha_debugger-0.1.11 → hypha_debugger-0.2.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hypha-debugger
3
- Version: 0.1.11
3
+ Version: 0.2.0
4
4
  Summary: Injectable debugger for Python processes and AI agents, powered by Hypha RPC
5
5
  Author: Amun AI AB
6
6
  License: MIT
@@ -12,5 +12,5 @@ Usage (sync):
12
12
 
13
13
  from hypha_debugger.debugger import start_debugger, start_debugger_sync, DebugSession
14
14
 
15
- __version__ = "0.1.10"
15
+ __version__ = "0.2.0"
16
16
  __all__ = ["start_debugger", "start_debugger_sync", "DebugSession", "__version__"]
@@ -0,0 +1,426 @@
1
+ """``hyd`` — client CLI for the Hypha remote debugger.
2
+
3
+ Lets an agent (or a human) run remote shell commands with almost no per-call
4
+ overhead: connection profiles (URL + token) are stored once on disk, and each
5
+ call is just ``hyd sh 'cmd'`` (or the bare ``hyd 'cmd'``).
6
+
7
+ The remote is STATELESS. The "current session" — which profile is active and the
8
+ current directory — is held in **environment variables** (``HYD_PROFILE`` and
9
+ ``HYD_CWD``), NOT on disk. Because env vars are per-shell, every terminal is
10
+ naturally isolated: switching terminals does not carry the active profile/cwd.
11
+
12
+ - ``profiles.json`` (disk, mode 0600) — the machines only: {profiles: {name: {service_url, token?, workspace?}}}
13
+ - ``HYD_PROFILE`` (env) — the active profile for this shell
14
+ - ``HYD_CWD`` (env) — the current remote directory for this shell
15
+
16
+ Commands that change the current state (``use``, ``cd``, ``profile add --use``)
17
+ print shell ``export`` lines (safe to ``eval``); ``eval "$(hyd shell-init)"``
18
+ installs a wrapper that applies them automatically and keeps ``HYD_CWD`` in sync
19
+ after every ``hyd sh``. See any debugger's ``GET <url>/get_skill_md``.
20
+ """
21
+
22
+ import json
23
+ import os
24
+ import shlex
25
+ import sys
26
+ import urllib.error
27
+ import urllib.request
28
+
29
+ __all__ = ["main"]
30
+
31
+
32
+ class CliError(Exception):
33
+ pass
34
+
35
+
36
+ # ---- config (profile DEFINITIONS only — never current-state) -------------
37
+ def _config_dir() -> str:
38
+ xdg = os.environ.get("XDG_CONFIG_HOME")
39
+ if xdg:
40
+ d = os.path.join(xdg, "hypha-debugger")
41
+ else:
42
+ legacy = os.path.expanduser("~/.hypha-debugger")
43
+ d = legacy if os.path.isdir(legacy) else os.path.expanduser("~/.config/hypha-debugger")
44
+ os.makedirs(d, exist_ok=True)
45
+ return d
46
+
47
+
48
+ def _profiles_path() -> str:
49
+ return os.path.join(_config_dir(), "profiles.json")
50
+
51
+
52
+ def _load_profiles() -> dict:
53
+ try:
54
+ with open(_profiles_path()) as f:
55
+ return json.load(f).get("profiles", {})
56
+ except (FileNotFoundError, json.JSONDecodeError):
57
+ return {}
58
+
59
+
60
+ def _save_profiles(profiles: dict) -> None:
61
+ path = _profiles_path()
62
+ tmp = path + ".tmp"
63
+ with open(tmp, "w") as f:
64
+ json.dump({"profiles": profiles}, f, indent=2)
65
+ os.replace(tmp, path)
66
+ try:
67
+ os.chmod(path, 0o600)
68
+ except OSError:
69
+ pass
70
+
71
+
72
+ # ---- current state = environment variables -------------------------------
73
+ def _env_profile() -> str:
74
+ return os.environ.get("HYD_PROFILE", "")
75
+
76
+
77
+ def _env_cwd() -> str:
78
+ return os.environ.get("HYD_CWD", "")
79
+
80
+
81
+ def _emit_state(exports: dict, human: str = "") -> None:
82
+ """Apply env changes. Under the shell wrapper (`$HYD_STATE_FILE` set) write
83
+ export lines to that file for the parent shell to source; otherwise print
84
+ eval-safe output (a `# comment` for humans + the `export` lines)."""
85
+ lines = [f"export {k}={shlex.quote(str(v))}" for k, v in exports.items()]
86
+ sf = os.environ.get("HYD_STATE_FILE")
87
+ if sf:
88
+ with open(sf, "a") as f:
89
+ f.write("\n".join(lines) + "\n")
90
+ if human:
91
+ sys.stderr.write(human + "\n")
92
+ else:
93
+ if human:
94
+ print("# " + human)
95
+ for line in lines:
96
+ print(line)
97
+
98
+
99
+ def _write_cwd(new_cwd: str) -> None:
100
+ """After `hyd sh`, persist the new cwd to the shell (only via the wrapper's
101
+ state file — never to stdout, which carries the command output)."""
102
+ sf = os.environ.get("HYD_STATE_FILE")
103
+ if sf and new_cwd:
104
+ with open(sf, "a") as f:
105
+ f.write(f"export HYD_CWD={shlex.quote(new_cwd)}\n")
106
+
107
+
108
+ # ---- resolution ----------------------------------------------------------
109
+ def _normalize_url(url: str) -> str:
110
+ url = url.strip().rstrip("/")
111
+ for suffix in ("/get_skill_md", "/get_page_info"):
112
+ if url.endswith(suffix):
113
+ url = url[: -len(suffix)]
114
+ return url.rstrip("/")
115
+
116
+
117
+ def _resolve_profile(explicit: str = "") -> tuple:
118
+ """Return (name, profile_dict). Priority: -p flag > $HYD_PROFILE > sole profile."""
119
+ profiles = _load_profiles()
120
+ if not profiles:
121
+ raise CliError("no profiles configured. Run: hyd profile add <name> <service_url> --use")
122
+ name = explicit or _env_profile()
123
+ if not name:
124
+ if len(profiles) == 1:
125
+ name = next(iter(profiles))
126
+ else:
127
+ raise CliError(
128
+ "no active profile. Set one: `export HYD_PROFILE=<name>` (or `eval \"$(hyd use <name>)\"`, "
129
+ f"or pass `-p <name>`). Profiles: {', '.join(profiles)}"
130
+ )
131
+ if name not in profiles:
132
+ raise CliError(f"unknown profile '{name}'. Profiles: {', '.join(profiles) or '(none)'}")
133
+ return name, profiles[name]
134
+
135
+
136
+ # ---- transport -----------------------------------------------------------
137
+ def _call_remote(profile: dict, fn: str, params: dict, http_timeout: int = 75) -> dict:
138
+ url = _normalize_url(profile["service_url"]) + f"/{fn}?_mode=last"
139
+ req = urllib.request.Request(url, data=json.dumps(params).encode(), method="POST")
140
+ req.add_header("Content-Type", "application/json")
141
+ if profile.get("token"):
142
+ req.add_header("Authorization", "Bearer " + profile["token"])
143
+ try:
144
+ with urllib.request.urlopen(req, timeout=http_timeout) as resp:
145
+ raw = resp.read().decode("utf-8", "replace")
146
+ except urllib.error.HTTPError as e:
147
+ body = e.read().decode("utf-8", "replace") if e.fp else ""
148
+ raise CliError(f"remote HTTP {e.code} calling {fn}: {body[:400]}")
149
+ except urllib.error.URLError as e:
150
+ raise CliError(f"cannot reach {url}: {e.reason}")
151
+ try:
152
+ data = json.loads(raw)
153
+ except json.JSONDecodeError:
154
+ return {"stdout": raw, "exit_code": 0, "cwd": params.get("cwd", "")}
155
+ if isinstance(data, dict) and "detail" in data and "stdout" not in data and "exit_code" not in data:
156
+ raise CliError(f"gateway error: {data['detail']} (is the debugger still connected?)")
157
+ return data
158
+
159
+
160
+ # ---- run -----------------------------------------------------------------
161
+ def _extract_opts(args: list) -> tuple:
162
+ """Pull -p/--profile, -t/--timeout, --cwd out of args; return (opts, rest)."""
163
+ opts = {"profile": "", "timeout": None, "cwd": ""}
164
+ rest = []
165
+ i = 0
166
+ while i < len(args):
167
+ a = args[i]
168
+ if a in ("-p", "--profile") and i + 1 < len(args):
169
+ opts["profile"] = args[i + 1]; i += 2; continue
170
+ if a in ("-t", "--timeout") and i + 1 < len(args):
171
+ opts["timeout"] = int(args[i + 1]); i += 2; continue
172
+ if a == "--cwd" and i + 1 < len(args):
173
+ opts["cwd"] = args[i + 1]; i += 2; continue
174
+ rest.append(a); i += 1
175
+ return opts, rest
176
+
177
+
178
+ def cmd_sh(args: list, bare: bool = False) -> int:
179
+ if bare:
180
+ opts, command_parts = {"profile": "", "timeout": None, "cwd": ""}, args
181
+ else:
182
+ opts, command_parts = _extract_opts(args)
183
+ command = " ".join(command_parts).strip()
184
+ if not command:
185
+ raise CliError("nothing to run. Usage: hyd sh '<command>'")
186
+ _name, profile = _resolve_profile(opts["profile"])
187
+ cwd = opts["cwd"] or _env_cwd()
188
+ timeout = opts["timeout"] if opts["timeout"] is not None else 30
189
+ data = _call_remote(
190
+ profile, "execute_bash",
191
+ {"command": command, "cwd": cwd, "timeout": timeout},
192
+ http_timeout=(timeout or 60) + 15,
193
+ )
194
+ out = data.get("stdout", "")
195
+ if out:
196
+ sys.stdout.write(out if out.endswith("\n") else out + "\n")
197
+ if data.get("error"):
198
+ sys.stderr.write(f"hyd: {data['error']}\n")
199
+ _write_cwd(data.get("cwd", cwd)) # keep cwd in sync (via the shell wrapper)
200
+ return int(data.get("exit_code", 0) or 0)
201
+
202
+
203
+ def cmd_py(args: list) -> int:
204
+ opts, code_parts = _extract_opts(args)
205
+ code = " ".join(code_parts).strip()
206
+ if not code:
207
+ raise CliError("nothing to run. Usage: hyd py '<python code>'")
208
+ _name, profile = _resolve_profile(opts["profile"])
209
+ data = _call_remote(profile, "execute_code", {"code": code})
210
+ if isinstance(data, dict):
211
+ if data.get("stdout"):
212
+ sys.stdout.write(data["stdout"])
213
+ for key in ("result", "value"):
214
+ if data.get(key) is not None:
215
+ print(data[key]); break
216
+ if data.get("error"):
217
+ sys.stderr.write(f"hyd: {data['error']}\n"); return 1
218
+ else:
219
+ print(data)
220
+ return 0
221
+
222
+
223
+ # ---- profiles ------------------------------------------------------------
224
+ def cmd_profile(args: list) -> int:
225
+ if not args:
226
+ return cmd_profile_list([])
227
+ sub, rest = args[0], args[1:]
228
+ if sub == "add":
229
+ return cmd_profile_add(rest)
230
+ if sub in ("list", "ls"):
231
+ return cmd_profile_list(rest)
232
+ if sub == "show":
233
+ return cmd_profile_show(rest)
234
+ if sub in ("rm", "remove", "delete"):
235
+ return cmd_profile_rm(rest)
236
+ raise CliError(f"unknown 'profile' subcommand: {sub}")
237
+
238
+
239
+ def cmd_profile_add(args: list) -> int:
240
+ use = "--use" in args
241
+ args = [a for a in args if a != "--use"]
242
+ opts, pos = {}, []
243
+ i = 0
244
+ while i < len(args):
245
+ a = args[i]
246
+ if a in ("--token", "--workspace") and i + 1 < len(args):
247
+ opts[a.lstrip("-")] = args[i + 1]; i += 2; continue
248
+ pos.append(a); i += 1
249
+ if len(pos) < 2:
250
+ raise CliError("usage: hyd profile add <name> <service_url> [--token T] [--workspace W] [--use]")
251
+ name, url = pos[0], _normalize_url(pos[1])
252
+ profiles = _load_profiles()
253
+ entry = profiles.get(name, {})
254
+ entry["service_url"] = url
255
+ if opts.get("token"):
256
+ entry["token"] = opts["token"]
257
+ if opts.get("workspace"):
258
+ entry["workspace"] = opts["workspace"]
259
+ profiles[name] = entry
260
+ _save_profiles(profiles)
261
+ if use:
262
+ _emit_state({"HYD_PROFILE": name}, human=f"profile '{name}' -> {url} (now active)")
263
+ else:
264
+ # No current-state change → just confirm (eval-safe comment).
265
+ print(f"# profile '{name}' -> {url} (activate: export HYD_PROFILE={name})")
266
+ return 0
267
+
268
+
269
+ def cmd_profile_list(_args: list) -> int:
270
+ profiles = _load_profiles()
271
+ if not profiles:
272
+ print("no profiles. Add one: hyd profile add <name> <service_url> --use")
273
+ return 0
274
+ active = _env_profile()
275
+ for name, p in profiles.items():
276
+ mark = "*" if name == active else " "
277
+ tok = " (token)" if p.get("token") else ""
278
+ print(f"{mark} {name} {p.get('service_url', '')}{tok}")
279
+ return 0
280
+
281
+
282
+ def cmd_profile_show(args: list) -> int:
283
+ if not args:
284
+ raise CliError("usage: hyd profile show <name>")
285
+ p = _load_profiles().get(args[0])
286
+ if not p:
287
+ raise CliError(f"unknown profile '{args[0]}'")
288
+ shown = dict(p)
289
+ if shown.get("token"):
290
+ shown["token"] = shown["token"][:6] + "…"
291
+ print(json.dumps({args[0]: shown}, indent=2))
292
+ return 0
293
+
294
+
295
+ def cmd_profile_rm(args: list) -> int:
296
+ if not args:
297
+ raise CliError("usage: hyd profile rm <name>")
298
+ profiles = _load_profiles()
299
+ if args[0] not in profiles:
300
+ raise CliError(f"unknown profile '{args[0]}'")
301
+ del profiles[args[0]]
302
+ _save_profiles(profiles)
303
+ print(f"# removed profile '{args[0]}'")
304
+ return 0
305
+
306
+
307
+ # ---- current state commands (emit env exports) ---------------------------
308
+ def cmd_use(args: list) -> int:
309
+ if not args:
310
+ raise CliError("usage: hyd use <profile>")
311
+ name = args[0]
312
+ if name not in _load_profiles():
313
+ raise CliError(f"unknown profile '{name}'. Add it: hyd profile add {name} <service_url>")
314
+ _emit_state({"HYD_PROFILE": name}, human=f"active profile -> {name}")
315
+ return 0
316
+
317
+
318
+ def cmd_cd(args: list) -> int:
319
+ target = args[0] if args else ""
320
+ _emit_state({"HYD_CWD": target}, human=f"cwd -> {target or '(remote default)'}")
321
+ return 0
322
+
323
+
324
+ def cmd_pwd(_args: list) -> int:
325
+ print(_env_cwd() or "(remote default)")
326
+ return 0
327
+
328
+
329
+ def cmd_status(_args: list) -> int:
330
+ try:
331
+ name, profile = _resolve_profile()
332
+ except CliError as e:
333
+ print(e); return 1
334
+ print(f"profile: {name} cwd: {_env_cwd() or '(remote default)'} (HYD_PROFILE/HYD_CWD)")
335
+ print(f"url: {_normalize_url(profile['service_url'])}")
336
+ try:
337
+ info = _call_remote(profile, "get_process_info", {})
338
+ print(f"connected: pid={info.get('pid')} host={info.get('hostname')} py={info.get('python_version')}")
339
+ except CliError as e:
340
+ print(f"UNREACHABLE: {e}"); return 1
341
+ return 0
342
+
343
+
344
+ def cmd_shell_init(_args: list) -> int:
345
+ print(
346
+ "# hyd shell integration — add to your shell rc: eval \"$(hyd shell-init)\"\n"
347
+ "# Wraps hyd so `use`/`cd`/`profile add --use` update this shell's env and\n"
348
+ "# `hyd sh` keeps HYD_CWD in sync. State lives in env vars (per-terminal), not on disk.\n"
349
+ "hyd() {\n"
350
+ " local __f; __f=\"$(mktemp)\"\n"
351
+ " HYD_STATE_FILE=\"$__f\" command hyd \"$@\"; local __rc=$?\n"
352
+ " [ -s \"$__f\" ] && . \"$__f\"\n"
353
+ " rm -f \"$__f\"\n"
354
+ " return $__rc\n"
355
+ "}"
356
+ )
357
+ return 0
358
+
359
+
360
+ def _print_help() -> int:
361
+ print(
362
+ """hyd — Hypha remote debugger CLI (run remote shell commands with minimal overhead)
363
+
364
+ USAGE
365
+ hyd sh '<command>' Run a shell command on the active profile (cwd via HYD_CWD)
366
+ hyd '<command>' Same (bare form; anything not a subcommand runs as a command)
367
+ hyd py '<python>' Run Python via the debugger's execute_code
368
+ hyd -p <profile> sh '...' Run against a specific profile (overrides HYD_PROFILE)
369
+
370
+ PROFILES (machines — stored on disk)
371
+ hyd profile add <name> <service_url> [--token T] [--workspace W] [--use]
372
+ hyd profile list | show <name> | rm <name>
373
+
374
+ CURRENT STATE (env vars, per-terminal — NOT on disk)
375
+ export HYD_PROFILE=<name> Select the active profile for this shell
376
+ export HYD_CWD=<dir> Set the current remote directory for this shell
377
+ hyd use <name> Prints `export HYD_PROFILE=<name>` (eval it, or use the wrapper)
378
+ hyd cd <dir> | pwd Prints `export HYD_CWD=<dir>` / shows HYD_CWD
379
+ hyd shell-init Wrapper so use/cd/sh update this shell's env automatically
380
+ hyd status Show active profile + cwd and ping the remote
381
+
382
+ Profiles: $XDG_CONFIG_HOME/hypha-debugger (or ~/.hypha-debugger). Setup docs:
383
+ GET <service_url>/get_skill_md."""
384
+ )
385
+ return 0
386
+
387
+
388
+ def main(argv=None) -> int:
389
+ argv = list(sys.argv[1:] if argv is None else argv)
390
+ if not argv:
391
+ return _print_help()
392
+ cmd = argv[0]
393
+ try:
394
+ if cmd in ("help", "-h", "--help"):
395
+ return _print_help()
396
+ if cmd in ("version", "--version"):
397
+ from hypha_debugger import __version__
398
+ print(f"hyd (hypha-debugger) {__version__}")
399
+ return 0
400
+ if cmd in ("sh", "run", "exec"):
401
+ return cmd_sh(argv[1:])
402
+ if cmd == "py":
403
+ return cmd_py(argv[1:])
404
+ if cmd in ("profile", "profiles"):
405
+ return cmd_profile(argv[1:] if cmd == "profile" else ["list"] + argv[1:])
406
+ if cmd == "use":
407
+ return cmd_use(argv[1:])
408
+ if cmd == "cd":
409
+ return cmd_cd(argv[1:])
410
+ if cmd == "pwd":
411
+ return cmd_pwd(argv[1:])
412
+ if cmd == "status":
413
+ return cmd_status(argv[1:])
414
+ if cmd == "shell-init":
415
+ return cmd_shell_init(argv[1:])
416
+ # Bare fallback: the whole argv is a shell command.
417
+ return cmd_sh(argv, bare=True)
418
+ except CliError as e:
419
+ sys.stderr.write(f"hyd: {e}\n")
420
+ return 2
421
+ except KeyboardInterrupt:
422
+ return 130
423
+
424
+
425
+ if __name__ == "__main__":
426
+ sys.exit(main())
@@ -9,6 +9,7 @@ from typing import Optional
9
9
 
10
10
  from hypha_debugger.services.info import get_process_info, get_installed_packages
11
11
  from hypha_debugger.services.execute import execute_code
12
+ from hypha_debugger.services.shell import execute_bash
12
13
  from hypha_debugger.services.inspect_vars import (
13
14
  get_variable,
14
15
  list_variables,
@@ -47,32 +48,19 @@ def _build_service_url(server_url: str, service_id: str) -> str:
47
48
 
48
49
 
49
50
  def _build_instruction_block(service_url: str, token: str = "") -> str:
50
- """Build the instruction block with copy-paste commands for remote access.
51
+ """One-sentence instruction to paste into an AI agent.
51
52
 
52
- Returns a formatted string that can be pasted directly into an AI agent chat.
53
+ Says what it is and where the API is; the agent reads the URL
54
+ (get_skill_md) for everything else.
53
55
  """
54
- auth = f' -H "Authorization: Bearer $TOKEN"' if token else ""
55
- lines = [
56
- "# Hypha Remote Debugger — Python Process",
57
- "# A remote debugger is attached to a running Python process.",
58
- "# You can execute code, read/write files, inspect variables, and more.",
59
- "# Call get_skill_md for full API docs with all available functions.",
60
- "",
61
- f'SERVICE_URL="{service_url}"',
62
- ]
63
- if token:
64
- lines.append(f'TOKEN="{token}"')
65
- lines += [
66
- "",
67
- "# Execute Python code:",
68
- f'curl -X POST "$SERVICE_URL/execute_code"{auth}'
69
- ' -H "Content-Type: application/json"'
70
- " -d '{\"code\": \"import sys; sys.version\"}'",
71
- "",
72
- "# Get full API docs (all functions, parameters, examples):",
73
- f'curl "$SERVICE_URL/get_skill_md"{auth}',
74
- ]
75
- return "\n".join(lines)
56
+ auth = (
57
+ f" (auth header: Authorization: Bearer {token})" if token else ""
58
+ )
59
+ return (
60
+ "A Hypha debugger is attached to this live Python process — call its HTTP "
61
+ f"API at {service_url} to run code and inspect the process; GET "
62
+ f"{service_url}/get_skill_md for the full tool list and usage{auth}."
63
+ )
76
64
 
77
65
 
78
66
  def _print_session_info(
@@ -241,6 +229,7 @@ async def start_debugger(
241
229
  "get_process_info": get_process_info,
242
230
  "get_installed_packages": get_installed_packages,
243
231
  "execute_code": execute_code,
232
+ "execute_bash": execute_bash,
244
233
  "get_variable": get_variable,
245
234
  "list_variables": list_variables,
246
235
  "get_stack_trace": get_stack_trace,
@@ -324,6 +313,7 @@ def start_debugger_sync(
324
313
  "get_process_info": get_process_info,
325
314
  "get_installed_packages": get_installed_packages,
326
315
  "execute_code": execute_code,
316
+ "execute_bash": execute_bash,
327
317
  "get_variable": get_variable,
328
318
  "list_variables": list_variables,
329
319
  "get_stack_trace": get_stack_trace,
@@ -0,0 +1,154 @@
1
+ """Stateless remote shell execution service.
2
+
3
+ Unlike ``execute_code`` (a persistent Python REPL), ``execute_bash`` runs each
4
+ command in a **fresh** ``bash -c`` — the remote holds NO per-connection state.
5
+ The *client* (e.g. the ``hyd`` CLI) tracks the "current directory" and passes it
6
+ in as ``cwd``; the call returns the directory AFTER the command (so a ``cd`` in
7
+ the command is reflected back), letting the client emulate a persistent terminal
8
+ while keeping the server stateless (scales, survives reconnects).
9
+ """
10
+
11
+ import os
12
+ import shlex
13
+ import subprocess
14
+ from typing import Any, Dict, Optional
15
+
16
+ # Default timeout for a shell command (seconds).
17
+ DEFAULT_TIMEOUT = 30
18
+ # Cap the returned output so a runaway command can't return megabytes.
19
+ MAX_OUTPUT_CHARS = 200_000
20
+
21
+ # Unique markers we append to the command to read back the final cwd + exit code
22
+ # without them colliding with normal output.
23
+ _MARK_CWD = "__HYD_CWD_9f3a2b__:"
24
+ _MARK_EC = "__HYD_EC_9f3a2b__:"
25
+
26
+
27
+ def _run_bash(command: str, cwd: str, env: Optional[Dict[str, str]], timeout: int) -> Dict[str, Any]:
28
+ start_dir = cwd or os.getcwd()
29
+ exports = ""
30
+ if env:
31
+ exports = "".join(
32
+ f"export {k}={shlex.quote(str(v))}\n" for k, v in env.items()
33
+ )
34
+ # Wrapper: cd into the requested dir, run the command, then emit the final
35
+ # pwd + exit code on marker lines we strip out of the returned output.
36
+ wrapper = (
37
+ f"cd {shlex.quote(start_dir)} 2>/dev/null || cd \"$HOME\" 2>/dev/null || true\n"
38
+ f"{exports}"
39
+ f"{command}\n"
40
+ f"__hyd_ec=$?\n"
41
+ f'printf "\\n{_MARK_CWD}%s\\n{_MARK_EC}%s\\n" "$(pwd)" "$__hyd_ec"\n'
42
+ )
43
+
44
+ try:
45
+ proc = subprocess.run(
46
+ ["bash", "-c", wrapper],
47
+ stdout=subprocess.PIPE,
48
+ stderr=subprocess.STDOUT, # merge stderr for a terminal-like feel
49
+ timeout=timeout if timeout and timeout > 0 else None,
50
+ text=True,
51
+ )
52
+ raw = proc.stdout or ""
53
+ except subprocess.TimeoutExpired as e:
54
+ partial = e.stdout or ""
55
+ if isinstance(partial, bytes):
56
+ partial = partial.decode("utf-8", "replace")
57
+ return {
58
+ "stdout": _truncate(_strip_markers(partial)[0]),
59
+ "exit_code": 124,
60
+ "cwd": start_dir,
61
+ "error": f"Command timed out after {timeout}s",
62
+ }
63
+ except FileNotFoundError:
64
+ return {
65
+ "stdout": "",
66
+ "exit_code": 127,
67
+ "cwd": start_dir,
68
+ "error": "bash not found on the remote host",
69
+ }
70
+
71
+ output, final_cwd, exit_code = _parse(raw, start_dir)
72
+ # If the command called `exit`/`kill` etc., our epilogue never ran and the
73
+ # markers are absent — fall back to bash's real return code.
74
+ if _MARK_EC not in raw:
75
+ exit_code = proc.returncode
76
+ return {"stdout": _truncate(output), "exit_code": exit_code, "cwd": final_cwd}
77
+
78
+
79
+ def _parse(raw: str, fallback_cwd: str):
80
+ """Split the trailing cwd/exit-code markers off the real output."""
81
+ cwd = fallback_cwd
82
+ ec = 0
83
+ idx = raw.rfind("\n" + _MARK_CWD)
84
+ if idx == -1:
85
+ idx = raw.rfind(_MARK_CWD)
86
+ if idx != -1:
87
+ tail = raw[idx:]
88
+ output = raw[:idx]
89
+ for line in tail.splitlines():
90
+ if _MARK_CWD in line:
91
+ cwd = line.split(_MARK_CWD, 1)[1].strip() or fallback_cwd
92
+ elif _MARK_EC in line:
93
+ try:
94
+ ec = int(line.split(_MARK_EC, 1)[1].strip())
95
+ except ValueError:
96
+ ec = 0
97
+ else:
98
+ output = raw
99
+ return output, cwd, ec
100
+
101
+
102
+ def _strip_markers(raw: str):
103
+ output, cwd, ec = _parse(raw, "")
104
+ return output, cwd, ec
105
+
106
+
107
+ def _truncate(text: str) -> str:
108
+ if len(text) > MAX_OUTPUT_CHARS:
109
+ return text[:MAX_OUTPUT_CHARS] + f"\n... [truncated at {MAX_OUTPUT_CHARS} chars]"
110
+ return text
111
+
112
+
113
+ try:
114
+ from pydantic import Field
115
+ from hypha_rpc.utils.schema import schema_function
116
+
117
+ @schema_function
118
+ def execute_bash(
119
+ command: str = Field(..., description="Shell command to run (bash)."),
120
+ cwd: str = Field(
121
+ default="",
122
+ description=(
123
+ "Working directory to run in. The remote is STATELESS: pass the "
124
+ "current directory here and use the returned `cwd` for the next "
125
+ "call to emulate a persistent shell. Empty = the process cwd."
126
+ ),
127
+ ),
128
+ env: Optional[Dict[str, str]] = Field(
129
+ default=None,
130
+ description="Optional environment variables to export before the command.",
131
+ ),
132
+ timeout: int = Field(
133
+ default=DEFAULT_TIMEOUT,
134
+ description=f"Max seconds before the command is killed (default {DEFAULT_TIMEOUT}, 0 = no limit).",
135
+ ),
136
+ ) -> Dict[str, Any]:
137
+ """Run a shell command on the remote host and return its output.
138
+
139
+ Stateless: each call is a fresh shell. Returns {stdout (stderr merged in),
140
+ exit_code, cwd} where `cwd` is the directory AFTER the command — pass it
141
+ back as `cwd` next time so `cd` persists (this is what the `hyd` CLI does).
142
+ """
143
+ return _run_bash(command, cwd, env, timeout)
144
+
145
+ except ImportError:
146
+
147
+ def execute_bash(
148
+ command: str,
149
+ cwd: str = "",
150
+ env: Optional[Dict[str, str]] = None,
151
+ timeout: int = DEFAULT_TIMEOUT,
152
+ ) -> Dict[str, Any]:
153
+ """Run a shell command on the remote host and return its output."""
154
+ return _run_bash(command, cwd, env, timeout)
@@ -48,6 +48,8 @@ except ImportError:
48
48
  _MODULES = {
49
49
  "debugger": "hypha_debugger.debugger",
50
50
  "services.execute": "hypha_debugger.services.execute",
51
+ "services.shell": "hypha_debugger.services.shell",
52
+ "cli": "hypha_debugger.cli",
51
53
  "services.filesystem": "hypha_debugger.services.filesystem",
52
54
  "services.info": "hypha_debugger.services.info",
53
55
  "services.inspect_vars": "hypha_debugger.services.inspect_vars",
@@ -94,6 +96,38 @@ to execute code, read/write files, inspect variables, and install packages.
94
96
  persistent Python REPL where variables, imports, and functions survive across
95
97
  calls. The other endpoints are convenience shortcuts.
96
98
 
99
+ ## Fast path — the `hyd` CLI (recommended for agents)
100
+
101
+ For repeated shell work, don't hand-write authenticated curl calls — install the
102
+ `hyd` client CLI once and run remote commands with almost no per-call overhead.
103
+ It stores this connection as a named **profile** (URL + token) on disk, so later
104
+ calls carry no auth or JSON boilerplate. Bootstrap yourself:
105
+
106
+ ```bash
107
+ pip install hypha-debugger # ships the `hyd` command
108
+ hyd profile add main "<THIS_SERVICE_URL>" # add --token <t> if the URL is token-protected
109
+ export HYD_PROFILE=main # select it (env var — per-terminal, not on disk)
110
+
111
+ hyd sh 'uname -a && pwd' # run a remote shell command
112
+ hyd 'ls -la' # bare form: anything not a subcommand runs as a command
113
+ hyd py 'import sys; sys.version' # run Python via execute_code
114
+ ```
115
+
116
+ Key ideas:
117
+ - **The remote is stateless.** The "current directory" and "current profile" live
118
+ in the environment variables `HYD_CWD` and `HYD_PROFILE` (per shell), so different
119
+ terminals are isolated and nothing session-specific is written to disk. Set
120
+ `export HYD_CWD=/some/dir` to fix a working directory, or run
121
+ `eval "$(hyd shell-init)"` once so `cd`/`cwd` stay in sync automatically after each
122
+ `hyd sh`.
123
+ - **Multiple machines**: add more profiles (`hyd profile add other "<url>" --token …`)
124
+ and switch with `export HYD_PROFILE=other` or a one-off `hyd -p other sh '…'`.
125
+ - `hyd sh` streams stdout/stderr and exits with the remote command's exit code, so
126
+ it behaves like a local shell. Run `hyd` (no args) for full help.
127
+
128
+ The raw HTTP API below still works (and powers the CLI) — use it directly if you
129
+ can't install the CLI.
130
+
97
131
  ## Quick Start
98
132
 
99
133
  ```bash
@@ -128,6 +162,19 @@ captured as the return value via AST parsing.
128
162
  - Variables, functions, and imports persist across calls.
129
163
  - Example: `"x = 1\\nx + 1"` returns `{ok: true, result: 2}`.
130
164
 
165
+ ### execute_bash(command, cwd?, env?, timeout?) — remote shell (STATELESS)
166
+ Run a shell command on the remote host. Each call is a fresh shell (no server
167
+ state). To emulate a persistent terminal, pass the current directory as `cwd` and
168
+ reuse the returned `cwd` on the next call — this is exactly what the `hyd` CLI
169
+ above does for you.
170
+ - **command** (str, required): bash command line.
171
+ - **cwd** (str, default `""`): directory to run in (`""` = the process cwd).
172
+ - **env** (object, optional): env vars to export before the command.
173
+ - **timeout** (int, default `30`): seconds; 0 = no limit.
174
+ - **Returns**: `{stdout, exit_code, cwd}` — `stdout` has stderr merged in; `cwd` is
175
+ the directory AFTER the command (so `cd` is reflected).
176
+ - Example: `curl -X POST "$SERVICE_URL/execute_bash" -d '{"command":"ls -la","cwd":"/tmp"}'`
177
+
131
178
  ### get_process_info()
132
179
  PID, CWD, Python version, hostname, platform, memory usage, CPU count.
133
180
  - **Example**: `curl "$SERVICE_URL/get_process_info"`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hypha-debugger
3
- Version: 0.1.11
3
+ Version: 0.2.0
4
4
  Summary: Injectable debugger for Python processes and AI agents, powered by Hypha RPC
5
5
  Author: Amun AI AB
6
6
  License: MIT
@@ -2,6 +2,7 @@ README.md
2
2
  pyproject.toml
3
3
  hypha_debugger/__init__.py
4
4
  hypha_debugger/__main__.py
5
+ hypha_debugger/cli.py
5
6
  hypha_debugger/debugger.py
6
7
  hypha_debugger.egg-info/PKG-INFO
7
8
  hypha_debugger.egg-info/SOURCES.txt
@@ -14,7 +15,10 @@ hypha_debugger/services/execute.py
14
15
  hypha_debugger/services/filesystem.py
15
16
  hypha_debugger/services/info.py
16
17
  hypha_debugger/services/inspect_vars.py
18
+ hypha_debugger/services/shell.py
17
19
  hypha_debugger/services/source.py
18
20
  hypha_debugger/utils/__init__.py
19
21
  hypha_debugger/utils/env.py
20
- tests/test_services.py
22
+ tests/test_cli.py
23
+ tests/test_services.py
24
+ tests/test_shell.py
@@ -1,2 +1,3 @@
1
1
  [console_scripts]
2
+ hyd = hypha_debugger.cli:main
2
3
  hypha-debugger = hypha_debugger.__main__:main
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hypha-debugger"
7
- version = "0.1.11"
7
+ version = "0.2.0"
8
8
  description = "Injectable debugger for Python processes and AI agents, powered by Hypha RPC"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -42,6 +42,7 @@ full = [
42
42
 
43
43
  [project.scripts]
44
44
  hypha-debugger = "hypha_debugger.__main__:main"
45
+ hyd = "hypha_debugger.cli:main"
45
46
 
46
47
  [tool.setuptools.packages.find]
47
48
  where = ["."]
@@ -0,0 +1,131 @@
1
+ """Tests for the `hyd` client CLI (profiles on disk, current state in env vars)."""
2
+
3
+ import pytest
4
+
5
+ from hypha_debugger import cli
6
+
7
+
8
+ @pytest.fixture
9
+ def cfg(tmp_path, monkeypatch):
10
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
11
+ for var in ("HYD_PROFILE", "HYD_CWD", "HYD_STATE_FILE"):
12
+ monkeypatch.delenv(var, raising=False)
13
+ return tmp_path
14
+
15
+
16
+ def test_profile_add_normalizes_url(cfg):
17
+ assert cli.main(["profile", "add", "main", "https://h.io/services/x/get_skill_md"]) == 0
18
+ profs = cli._load_profiles()
19
+ assert profs["main"]["service_url"] == "https://h.io/services/x"
20
+
21
+
22
+ def test_profile_add_with_token_and_list(cfg, capsys):
23
+ cli.main(["profile", "add", "main", "https://h.io/s/x", "--token", "secret"])
24
+ assert cli._load_profiles()["main"]["token"] == "secret"
25
+ cli.main(["profile", "list"])
26
+ out = capsys.readouterr().out
27
+ assert "main" in out and "(token)" in out
28
+
29
+
30
+ def test_profile_rm(cfg):
31
+ cli.main(["profile", "add", "p", "https://h.io/s/x"])
32
+ cli.main(["profile", "rm", "p"])
33
+ assert "p" not in cli._load_profiles()
34
+
35
+
36
+ def test_use_emits_export_no_disk_state(cfg, capsys):
37
+ cli.main(["profile", "add", "main", "https://h.io/s/x"])
38
+ cli.main(["use", "main"])
39
+ out = capsys.readouterr().out
40
+ assert "export HYD_PROFILE=main" in out
41
+ # No session/current-state file should exist on disk.
42
+ assert not (cfg / "hypha-debugger" / "sessions").exists()
43
+
44
+
45
+ def test_cd_emits_export(cfg, capsys):
46
+ cli.main(["cd", "/var/log"])
47
+ assert "export HYD_CWD=/var/log" in capsys.readouterr().out
48
+
49
+
50
+ def test_resolve_profile_from_env(cfg, monkeypatch):
51
+ cli.main(["profile", "add", "a", "https://a.io/s/1"])
52
+ cli.main(["profile", "add", "b", "https://b.io/s/2"])
53
+ monkeypatch.setenv("HYD_PROFILE", "b")
54
+ name, prof = cli._resolve_profile()
55
+ assert name == "b" and prof["service_url"].endswith("/s/2")
56
+
57
+
58
+ def test_resolve_profile_flag_overrides_env(cfg, monkeypatch):
59
+ cli.main(["profile", "add", "a", "https://a.io/s/1"])
60
+ cli.main(["profile", "add", "b", "https://b.io/s/2"])
61
+ monkeypatch.setenv("HYD_PROFILE", "a")
62
+ name, _ = cli._resolve_profile("b")
63
+ assert name == "b"
64
+
65
+
66
+ def test_resolve_sole_profile_when_unset(cfg):
67
+ cli.main(["profile", "add", "only", "https://o.io/s/1"])
68
+ name, _ = cli._resolve_profile()
69
+ assert name == "only"
70
+
71
+
72
+ def test_sh_calls_execute_bash_and_returns_exit_code(cfg, monkeypatch):
73
+ cli.main(["profile", "add", "main", "https://h.io/s/x"])
74
+ monkeypatch.setenv("HYD_PROFILE", "main")
75
+ seen = {}
76
+
77
+ def fake(profile, fn, params, http_timeout=75):
78
+ seen["fn"] = fn
79
+ seen["params"] = params
80
+ return {"stdout": "hi\n", "exit_code": 7, "cwd": "/tmp"}
81
+
82
+ monkeypatch.setattr(cli, "_call_remote", fake)
83
+ rc = cli.main(["sh", "echo hi"])
84
+ assert rc == 7
85
+ assert seen["fn"] == "execute_bash"
86
+ assert seen["params"]["command"] == "echo hi"
87
+
88
+
89
+ def test_sh_persists_cwd_via_state_file(cfg, monkeypatch, tmp_path):
90
+ cli.main(["profile", "add", "main", "https://h.io/s/x"])
91
+ monkeypatch.setenv("HYD_PROFILE", "main")
92
+ sf = tmp_path / "state.sh"
93
+ sf.write_text("")
94
+ monkeypatch.setenv("HYD_STATE_FILE", str(sf))
95
+ monkeypatch.setattr(cli, "_call_remote", lambda *a, **k: {"stdout": "", "exit_code": 0, "cwd": "/var"})
96
+ cli.main(["sh", "pwd"])
97
+ assert "export HYD_CWD=/var" in sf.read_text()
98
+
99
+
100
+ def test_sh_uses_env_cwd(cfg, monkeypatch):
101
+ cli.main(["profile", "add", "main", "https://h.io/s/x"])
102
+ monkeypatch.setenv("HYD_PROFILE", "main")
103
+ monkeypatch.setenv("HYD_CWD", "/opt")
104
+ seen = {}
105
+ monkeypatch.setattr(cli, "_call_remote", lambda profile, fn, params, http_timeout=75: seen.update(params) or {"stdout": "", "exit_code": 0, "cwd": "/opt"})
106
+ cli.main(["sh", "pwd"])
107
+ assert seen["cwd"] == "/opt"
108
+
109
+
110
+ def test_bare_fallback_runs_as_command(cfg, monkeypatch):
111
+ cli.main(["profile", "add", "main", "https://h.io/s/x"])
112
+ monkeypatch.setenv("HYD_PROFILE", "main")
113
+ seen = {}
114
+ monkeypatch.setattr(cli, "_call_remote", lambda profile, fn, params, http_timeout=75: seen.update(params) or {"stdout": "", "exit_code": 0, "cwd": ""})
115
+ cli.main(["ls", "-la"])
116
+ assert seen["command"] == "ls -la"
117
+
118
+
119
+ def test_p_flag_selects_profile(cfg, monkeypatch):
120
+ cli.main(["profile", "add", "a", "https://a.io/s/1"])
121
+ cli.main(["profile", "add", "b", "https://b.io/s/2"])
122
+ monkeypatch.setenv("HYD_PROFILE", "a")
123
+ seen = {}
124
+
125
+ def fake(profile, fn, params, http_timeout=75):
126
+ seen["url"] = profile["service_url"]
127
+ return {"stdout": "", "exit_code": 0, "cwd": ""}
128
+
129
+ monkeypatch.setattr(cli, "_call_remote", fake)
130
+ cli.main(["sh", "-p", "b", "hostname"])
131
+ assert seen["url"].endswith("/s/2")
@@ -270,19 +270,17 @@ def test_write_file_invalid_mode():
270
270
 
271
271
  def test_instruction_block_no_token():
272
272
  from hypha_debugger.debugger import _build_instruction_block
273
- block = _build_instruction_block("https://example.com/ws/services/py-debugger-abc")
274
- assert "SERVICE_URL=" in block
275
- assert "TOKEN=" not in block
276
- assert "Authorization" not in block
277
- assert "execute_code" in block
273
+ url = "https://example.com/ws/services/py-debugger-abc"
274
+ block = _build_instruction_block(url)
275
+ assert url in block
278
276
  assert "get_skill_md" in block
277
+ assert "Authorization" not in block # no token -> no auth header
279
278
 
280
279
 
281
280
  def test_instruction_block_with_token():
282
281
  from hypha_debugger.debugger import _build_instruction_block
283
282
  block = _build_instruction_block("https://example.com/ws/services/py-debugger", "mytoken123")
284
- assert 'TOKEN="mytoken123"' in block
285
- assert "Authorization" in block
283
+ assert "Authorization: Bearer mytoken123" in block
286
284
 
287
285
 
288
286
  # --- source ---
@@ -341,6 +339,6 @@ def test_debug_session_print_instructions(capsys):
341
339
  captured = capsys.readouterr()
342
340
  assert "WARNING" in captured.out
343
341
  assert "trusted" in captured.out
344
- assert "SERVICE_URL=" in captured.out
342
+ assert "get_skill_md" in captured.out
345
343
  assert isinstance(result, str)
346
- assert "SERVICE_URL=" in result
344
+ assert "py-debugger-abc" in result
@@ -0,0 +1,50 @@
1
+ """Tests for the stateless remote shell service (no server required)."""
2
+
3
+ import os
4
+
5
+ from hypha_debugger.services.shell import execute_bash
6
+
7
+
8
+ def test_basic_stdout():
9
+ r = execute_bash(command="echo hello")
10
+ assert r["exit_code"] == 0
11
+ assert "hello" in r["stdout"]
12
+
13
+
14
+ def test_exit_code_propagates():
15
+ r = execute_bash(command="exit 3")
16
+ assert r["exit_code"] == 3
17
+
18
+
19
+ def test_stderr_merged_into_stdout():
20
+ r = execute_bash(command="echo oops >&2")
21
+ assert "oops" in r["stdout"]
22
+
23
+
24
+ def test_cwd_reflected_after_cd():
25
+ r = execute_bash(command="cd /tmp && pwd", cwd="")
26
+ assert r["exit_code"] == 0
27
+ assert os.path.realpath(r["cwd"]) == os.path.realpath("/tmp")
28
+
29
+
30
+ def test_cwd_argument_used():
31
+ r = execute_bash(command="pwd", cwd="/tmp")
32
+ assert os.path.realpath(r["stdout"].strip()) == os.path.realpath("/tmp")
33
+
34
+
35
+ def test_stateless_no_shared_process():
36
+ # First call cd's within its own shell; the second (no cwd) must NOT inherit it.
37
+ execute_bash(command="cd /tmp", cwd="")
38
+ r = execute_bash(command="pwd", cwd="")
39
+ assert os.path.realpath(r["stdout"].strip()) == os.path.realpath(os.getcwd())
40
+
41
+
42
+ def test_timeout_kills():
43
+ r = execute_bash(command="sleep 5", timeout=1)
44
+ assert r["exit_code"] == 124
45
+ assert "timed out" in (r.get("error") or "").lower()
46
+
47
+
48
+ def test_env_exported():
49
+ r = execute_bash(command="echo $FOO", env={"FOO": "bar"})
50
+ assert "bar" in r["stdout"]