handler-ai 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.
Files changed (234) hide show
  1. handler_ai-0.1.0.dist-info/METADATA +60 -0
  2. handler_ai-0.1.0.dist-info/RECORD +234 -0
  3. handler_ai-0.1.0.dist-info/WHEEL +4 -0
  4. handler_ai-0.1.0.dist-info/entry_points.txt +6 -0
  5. handler_cli/__init__.py +1 -0
  6. handler_cli/account_commands.py +314 -0
  7. handler_cli/addon_commands.py +128 -0
  8. handler_cli/agent_commands.py +139 -0
  9. handler_cli/app_commands.py +175 -0
  10. handler_cli/app_window.py +85 -0
  11. handler_cli/automation_commands.py +309 -0
  12. handler_cli/bridge.py +42 -0
  13. handler_cli/cli.py +326 -0
  14. handler_cli/client.py +81 -0
  15. handler_cli/daemon_commands.py +108 -0
  16. handler_cli/image_commands.py +102 -0
  17. handler_cli/inbox_commands.py +33 -0
  18. handler_cli/output.py +12 -0
  19. handler_cli/project_commands.py +63 -0
  20. handler_cli/provider_commands.py +113 -0
  21. handler_cli/secret_commands.py +64 -0
  22. handler_cli/session_bridge.py +93 -0
  23. handler_cli/session_commands.py +485 -0
  24. handler_cli/setup.py +120 -0
  25. handler_cli/terminal.py +280 -0
  26. handler_cli/terminal_client.py +37 -0
  27. handler_contracts/__init__.py +642 -0
  28. handler_contracts/account.py +36 -0
  29. handler_contracts/acp_endpoint.py +69 -0
  30. handler_contracts/addon.py +147 -0
  31. handler_contracts/agent_spec.py +75 -0
  32. handler_contracts/automation.py +285 -0
  33. handler_contracts/common.py +229 -0
  34. handler_contracts/control.py +1208 -0
  35. handler_contracts/harness.py +300 -0
  36. handler_contracts/miniapp.py +141 -0
  37. handler_contracts/provider.py +82 -0
  38. handler_contracts/remote.py +167 -0
  39. handler_contracts/sandbox.py +199 -0
  40. handler_contracts/schemas.py +313 -0
  41. handler_contracts/terminal.py +32 -0
  42. handler_control/__init__.py +7 -0
  43. handler_control/access.py +62 -0
  44. handler_control/accounts.py +279 -0
  45. handler_control/addon_install.py +268 -0
  46. handler_control/addons.py +118 -0
  47. handler_control/agent_state.py +46 -0
  48. handler_control/agents.py +257 -0
  49. handler_control/api/__init__.py +137 -0
  50. handler_control/api/accounts.py +231 -0
  51. handler_control/api/acp.py +116 -0
  52. handler_control/api/addons.py +95 -0
  53. handler_control/api/agents.py +150 -0
  54. handler_control/api/apps.py +104 -0
  55. handler_control/api/automations.py +128 -0
  56. handler_control/api/bindings.py +105 -0
  57. handler_control/api/builtin_agents.py +22 -0
  58. handler_control/api/caches.py +79 -0
  59. handler_control/api/catalog.py +197 -0
  60. handler_control/api/common.py +205 -0
  61. handler_control/api/contexts.py +88 -0
  62. handler_control/api/folders.py +51 -0
  63. handler_control/api/images.py +164 -0
  64. handler_control/api/inbox.py +21 -0
  65. handler_control/api/projects.py +170 -0
  66. handler_control/api/providers.py +107 -0
  67. handler_control/api/remote.py +45 -0
  68. handler_control/api/secrets.py +177 -0
  69. handler_control/api/sessions.py +192 -0
  70. handler_control/api/skills.py +270 -0
  71. handler_control/app_api.py +84 -0
  72. handler_control/app_http.py +214 -0
  73. handler_control/app_remote.py +206 -0
  74. handler_control/apps.py +867 -0
  75. handler_control/auth.py +48 -0
  76. handler_control/automation_config.py +255 -0
  77. handler_control/automations.py +1134 -0
  78. handler_control/bindings.py +149 -0
  79. handler_control/builds.py +341 -0
  80. handler_control/builtin_agents.py +82 -0
  81. handler_control/caches.py +113 -0
  82. handler_control/checks.py +287 -0
  83. handler_control/compose.py +317 -0
  84. handler_control/config_files.py +41 -0
  85. handler_control/contexts.py +75 -0
  86. handler_control/daemon.py +64 -0
  87. handler_control/data/automation/bin/handler-llm +85 -0
  88. handler_control/data/automations/env-issues/automation.toml +17 -0
  89. handler_control/data/automations/env-issues/extract.py +73 -0
  90. handler_control/data/automations/env-issues/findings.schema.json +28 -0
  91. handler_control/data/automations/env-issues/prompt.md +19 -0
  92. handler_control/data/builtin-agents/skill-creator/agent.toml +10 -0
  93. handler_control/data/builtin-agents/skill-creator/instructions.md +5 -0
  94. handler_control/data/builtin-agents/skill-creator/skills/handler-skills/SKILL.md +44 -0
  95. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/LICENSE.txt +202 -0
  96. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/SKILL.md +485 -0
  97. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/analyzer.md +274 -0
  98. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/comparator.md +202 -0
  99. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/grader.md +223 -0
  100. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/assets/eval_review.html +146 -0
  101. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/eval-viewer/generate_review.py +471 -0
  102. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/eval-viewer/viewer.html +1325 -0
  103. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/references/schemas.md +430 -0
  104. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/__init__.py +0 -0
  105. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  106. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/generate_report.py +326 -0
  107. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/improve_description.py +247 -0
  108. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/package_skill.py +136 -0
  109. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/quick_validate.py +103 -0
  110. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/run_eval.py +310 -0
  111. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/run_loop.py +328 -0
  112. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/utils.py +47 -0
  113. handler_control/data/builtin-agents/skill-review/agent.toml +13 -0
  114. handler_control/data/builtin-agents/skill-review/instructions.md +30 -0
  115. handler_control/data/builtin-agents/skill-review/interface.json +83 -0
  116. handler_control/data/interfaces/llm/v1/interface.toml +17 -0
  117. handler_control/data/interfaces/llm/v1/tools.json +138 -0
  118. handler_control/data/templates/README.md +10 -0
  119. handler_control/data/templates/providers/anthropic-api.toml +18 -0
  120. handler_control/data/templates/providers/llamacpp.toml +210 -0
  121. handler_control/data/templates/providers/mistral-api.toml +14 -0
  122. handler_control/data/templates/providers/ollama.toml +10 -0
  123. handler_control/data/templates/providers/openai-api.toml +13 -0
  124. handler_control/data/templates/providers/openrouter.toml +10 -0
  125. handler_control/desktop.py +68 -0
  126. handler_control/display.py +62 -0
  127. handler_control/files.py +23 -0
  128. handler_control/folders.py +68 -0
  129. handler_control/git_read.py +39 -0
  130. handler_control/image_check.py +322 -0
  131. handler_control/image_updates.py +174 -0
  132. handler_control/inbox.py +70 -0
  133. handler_control/interfaces.py +151 -0
  134. handler_control/knowledge.py +237 -0
  135. handler_control/launcher.py +356 -0
  136. handler_control/llm.py +194 -0
  137. handler_control/migrations/__init__.py +62 -0
  138. handler_control/policy.py +305 -0
  139. handler_control/projects.py +266 -0
  140. handler_control/providers.py +180 -0
  141. handler_control/relay.py +133 -0
  142. handler_control/remote.py +888 -0
  143. handler_control/remote_crypto.py +97 -0
  144. handler_control/runtime.py +140 -0
  145. handler_control/sandbox_process.py +224 -0
  146. handler_control/schedules.py +58 -0
  147. handler_control/secrets.py +242 -0
  148. handler_control/service_router.py +180 -0
  149. handler_control/services.py +92 -0
  150. handler_control/session_files.py +429 -0
  151. handler_control/sessions.py +1116 -0
  152. handler_control/settings.py +129 -0
  153. handler_control/shortcuts.py +80 -0
  154. handler_control/skill_scan.py +88 -0
  155. handler_control/skill_sources.py +61 -0
  156. handler_control/skills.py +378 -0
  157. handler_control/terminal.py +79 -0
  158. handler_control/terminals.py +108 -0
  159. handler_control/transcript.py +174 -0
  160. handler_control/web-ui/assets/index-1w5RPsfA.js +190 -0
  161. handler_control/web-ui/assets/index-dWnzCLzQ.css +1 -0
  162. handler_control/web-ui/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  163. handler_control/web-ui/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  164. handler_control/web-ui/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  165. handler_control/web-ui/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  166. handler_control/web-ui/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  167. handler_control/web-ui/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  168. handler_control/web-ui/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  169. handler_control/web-ui/favicon.svg +1 -0
  170. handler_control/web-ui/index.html +15 -0
  171. handler_control/webpush.py +90 -0
  172. handler_control/webui.py +105 -0
  173. handler_harness/__init__.py +13 -0
  174. handler_harness/addons.py +129 -0
  175. handler_harness/catalog.py +116 -0
  176. handler_harness/cli.py +109 -0
  177. handler_harness/data/addons/README.md +44 -0
  178. handler_harness/data/addons/miniapp/Containerfile +20 -0
  179. handler_harness/data/addons/miniapp/PROVISIONING.md +16 -0
  180. handler_harness/data/addons/miniapp/addon.toml +16 -0
  181. handler_harness/data/addons/miniapp/skills/miniapp/SKILL.md +140 -0
  182. handler_harness/data/addons/miniapp/skills/miniapp/template/.gitignore +2 -0
  183. handler_harness/data/addons/miniapp/skills/miniapp/template/app.toml +15 -0
  184. handler_harness/data/addons/miniapp/skills/miniapp/template/server/db.ts +30 -0
  185. handler_harness/data/addons/miniapp/skills/miniapp/template/server/deno.json +7 -0
  186. handler_harness/data/addons/miniapp/skills/miniapp/template/server/handler.ts +26 -0
  187. handler_harness/data/addons/miniapp/skills/miniapp/template/server/main.ts +18 -0
  188. handler_harness/data/addons/miniapp/skills/miniapp/template/server/main_test.ts +32 -0
  189. handler_harness/data/addons/miniapp/skills/miniapp/template/server/migrations/001_notes.sql +6 -0
  190. handler_harness/data/addons/miniapp/skills/miniapp/template/server/routes.ts +32 -0
  191. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/deno.json +12 -0
  192. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/index.html +12 -0
  193. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/App.svelte +44 -0
  194. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/api.ts +16 -0
  195. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/app.css +24 -0
  196. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/main.ts +5 -0
  197. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/vite.config.ts +17 -0
  198. handler_harness/data/catalog/README.md +12 -0
  199. handler_harness/data/catalog/playwright/Containerfile +28 -0
  200. handler_harness/data/catalog/playwright/PROVISIONING.md +53 -0
  201. handler_harness/data/catalog/playwright/addon.toml +25 -0
  202. handler_harness/data/catalog/playwright/handler/playwright-mcp-check.mjs +23 -0
  203. handler_harness/data/catalog/playwright/handler/playwright-mcp.mjs +61 -0
  204. handler_harness/data/catalog/playwright/skills/browser/SKILL.md +41 -0
  205. handler_harness/data/catalog/shopify/Containerfile +22 -0
  206. handler_harness/data/catalog/shopify/PROVISIONING.md +49 -0
  207. handler_harness/data/catalog/shopify/addon.toml +39 -0
  208. handler_harness/data/catalog/shopify/skills/shopify/SKILL.md +41 -0
  209. handler_harness/data/handler-sandbox-env +30 -0
  210. handler_harness/data/harnesses/README.md +47 -0
  211. handler_harness/data/harnesses/claude-code/Containerfile +27 -0
  212. handler_harness/data/harnesses/claude-code/harness.toml +95 -0
  213. handler_harness/data/harnesses/codex/Containerfile +26 -0
  214. handler_harness/data/harnesses/codex/harness.toml +103 -0
  215. handler_harness/data/harnesses/pi/Containerfile +27 -0
  216. handler_harness/data/harnesses/pi/harness.toml +65 -0
  217. handler_harness/data/harnesses/vibe/Containerfile +19 -0
  218. handler_harness/data/harnesses/vibe/harness.toml +96 -0
  219. handler_harness/data/toolbox/Containerfile +56 -0
  220. handler_harness/data/toolbox/README.md +24 -0
  221. handler_harness/data/toolbox/toolbox.toml +41 -0
  222. handler_harness/images.py +62 -0
  223. handler_harness/providers.py +300 -0
  224. handler_harness/render.py +472 -0
  225. handler_sandbox_podman/__init__.py +1 -0
  226. handler_sandbox_podman/caches.py +97 -0
  227. handler_sandbox_podman/check.py +121 -0
  228. handler_sandbox_podman/cli.py +239 -0
  229. handler_sandbox_podman/egress.py +118 -0
  230. handler_sandbox_podman/images.py +199 -0
  231. handler_sandbox_podman/launch.py +474 -0
  232. handler_sandbox_podman/podman.py +108 -0
  233. handler_sandbox_podman/proxy/Containerfile +6 -0
  234. handler_sandbox_podman/proxy/addon.py +255 -0
handler_cli/client.py ADDED
@@ -0,0 +1,81 @@
1
+ """Talking to handlerd: the Control API (contract D) over HTTP, ACP (contract C) over WebSocket.
2
+
3
+ Discovery: `$HANDLER_URL` (default http://127.0.0.1:<DEFAULT_PORT>) and the token file
4
+ `$HANDLER_CONFIG_DIR/token` (default ~/.config/handler-ai/token), written by the daemon on first
5
+ start.
6
+ """
7
+
8
+ import os
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ from handler_contracts import DEFAULT_PORT, TOKEN_FILE_NAME
15
+
16
+
17
+ class ClientError(RuntimeError):
18
+ def __init__(
19
+ self,
20
+ message: str,
21
+ status: int | None = None,
22
+ title: str | None = None,
23
+ detail: str | None = None,
24
+ ) -> None:
25
+ super().__init__(message)
26
+ self.status, self.title, self.detail = status, title, detail # handlerd's answer, if any
27
+
28
+
29
+ def base_url() -> str:
30
+ port = os.environ.get("HANDLER_PORT", str(DEFAULT_PORT))
31
+ return os.environ.get("HANDLER_URL", f"http://127.0.0.1:{port}").rstrip("/")
32
+
33
+
34
+ def token_file() -> Path:
35
+ xdg = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
36
+ return Path(os.environ.get("HANDLER_CONFIG_DIR", xdg / "handler-ai")) / TOKEN_FILE_NAME
37
+
38
+
39
+ def read_token() -> str:
40
+ try:
41
+ return token_file().read_text().strip()
42
+ except FileNotFoundError:
43
+ raise ClientError(
44
+ f"no token at {token_file()}: is handlerd installed and started?"
45
+ ) from None
46
+
47
+
48
+ class Daemon:
49
+ def __init__(self) -> None:
50
+ self.url = base_url()
51
+ self.token = read_token()
52
+ self.http = httpx.Client(
53
+ base_url=self.url, headers={"Authorization": f"Bearer {self.token}"}, timeout=60
54
+ )
55
+
56
+ def call(self, method: str, path: str, json: Any = None, timeout: float | None = None) -> Any:
57
+ r = self.request(method, path, json, timeout)
58
+ return r.json() if r.content else None
59
+
60
+ def text(self, path: str) -> str:
61
+ """A GET answering plain text (a log, a file)."""
62
+ return self.request("GET", path).text
63
+
64
+ def request(
65
+ self, method: str, path: str, json: Any = None, timeout: float | None = None
66
+ ) -> httpx.Response:
67
+ try:
68
+ r = self.http.request(method, path, json=json, timeout=timeout or self.http.timeout)
69
+ except httpx.ConnectError:
70
+ raise ClientError(
71
+ f"handlerd is not reachable at {self.url} (handler daemon status)"
72
+ ) from None
73
+ if r.status_code >= 400:
74
+ try:
75
+ problem = r.json()
76
+ except ValueError:
77
+ raise ClientError(f"{r.status_code} {r.text}", r.status_code) from None
78
+ title, detail = problem.get("title"), problem.get("detail")
79
+ message = f"{r.status_code} {title}: {detail or ''}".rstrip(": ")
80
+ raise ClientError(message, r.status_code, title, detail)
81
+ return r
@@ -0,0 +1,108 @@
1
+ """`handler daemon …` — handlerd, the daemon that runs your agents, as a systemd user service
2
+ (started with your session). `handler setup` installs it.
3
+
4
+ handler daemon status is it up (its version, its address); if not, the service's
5
+ state
6
+ handler daemon install [--dry-run] write the unit, enable and start it (--dry-run: print it)
7
+ handler daemon uninstall stop and disable it, remove the unit (configuration and state
8
+ are kept)
9
+ handler daemon log [-f] its log (journalctl; -f: follow it)
10
+ """
11
+
12
+ import argparse
13
+ import os
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import httpx
21
+
22
+ from .client import base_url
23
+
24
+ UNIT_NAME = "handler-ai.service"
25
+
26
+
27
+ def unit_path() -> Path:
28
+ xdg = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
29
+ return xdg / "systemd" / "user" / UNIT_NAME
30
+
31
+
32
+ def handlerd_path() -> str:
33
+ sibling = Path(sys.executable).parent / "handlerd" # same installation as this CLI
34
+ return str(sibling) if sibling.exists() else (shutil.which("handlerd") or "handlerd")
35
+
36
+
37
+ def unit_text() -> str:
38
+ handlerd = handlerd_path()
39
+ bin_dir = str(Path(handlerd).parent)
40
+ return f"""[Unit]
41
+ Description=Handler control plane
42
+
43
+ [Service]
44
+ ExecStart={handlerd}
45
+ # handlerd finds handler-sandbox-* next to itself; podman must be on PATH.
46
+ Environment=PATH={bin_dir}:/usr/local/bin:/usr/bin:/bin
47
+ Restart=on-failure
48
+
49
+ [Install]
50
+ WantedBy=default.target
51
+ """
52
+
53
+
54
+ def _systemctl(*args: str) -> int:
55
+ return subprocess.run(["systemctl", "--user", *args], check=False).returncode
56
+
57
+
58
+ def install(dry_run: bool) -> int:
59
+ if dry_run:
60
+ print(f"# {unit_path()}\n{unit_text()}", end="")
61
+ return 0
62
+ path = unit_path()
63
+ path.parent.mkdir(parents=True, exist_ok=True)
64
+ path.write_text(unit_text())
65
+ print(f"wrote {path}")
66
+ return _systemctl("daemon-reload") or _systemctl("enable", "--now", UNIT_NAME)
67
+
68
+
69
+ def uninstall() -> int:
70
+ _systemctl("disable", "--now", UNIT_NAME)
71
+ unit_path().unlink(missing_ok=True)
72
+ return _systemctl("daemon-reload")
73
+
74
+
75
+ def status() -> int:
76
+ try:
77
+ health = httpx.get(f"{base_url()}/v1/health", timeout=5).json()
78
+ except httpx.HTTPError:
79
+ print(f"handlerd is not reachable at {base_url()}")
80
+ _systemctl("status", "--no-pager", UNIT_NAME)
81
+ return 1
82
+ print(f"handlerd {health['version']} up at {base_url()}")
83
+ return 0
84
+
85
+
86
+ def log(follow: bool) -> int:
87
+ command = ["journalctl", "--user", "-u", UNIT_NAME, "--no-pager", "-n", "200"]
88
+ return subprocess.run([*command, *(["-f"] if follow else [])], check=False).returncode
89
+
90
+
91
+ def add_parser(sub: Any) -> None:
92
+ daemon = sub.add_parser("daemon", help="handlerd as a user service", description=__doc__,
93
+ formatter_class=argparse.RawDescriptionHelpFormatter) # fmt: skip
94
+ a = daemon.add_subparsers(dest="daemon_cmd", required=True)
95
+ a.add_parser("status")
96
+ a.add_parser("install").add_argument("--dry-run", action="store_true")
97
+ a.add_parser("uninstall")
98
+ a.add_parser("log").add_argument("-f", "--follow", action="store_true")
99
+
100
+
101
+ def dispatch(args: argparse.Namespace) -> int:
102
+ if args.daemon_cmd == "install":
103
+ return install(args.dry_run)
104
+ if args.daemon_cmd == "uninstall":
105
+ return uninstall()
106
+ if args.daemon_cmd == "log":
107
+ return log(args.follow)
108
+ return status()
@@ -0,0 +1,102 @@
1
+ """`handler image …` — the images agents run in: the toolbox (the base every agent gets), one per
2
+ harness and per addon on top of it, and the network filter. Built locally, through handlerd (D.4,
3
+ G.4), like the web UI.
4
+
5
+ handler image list every image: built or not, the harness's version, when built
6
+ handler image rebuild NAME rebuild a harness image from the files on disk (the
7
+ harness's latest release), check it, then switch agents to
8
+ it; the log streams to the terminal
9
+ handler image rebuild addon:NAME same for an addon's image (also its first build)
10
+ handler image rebuild toolbox same for the toolbox (system updates), then every harness and
11
+ addon on top of it: all switch, or none
12
+ handler image rebuild network-filter
13
+ the network filter: what checks where agents connect and adds
14
+ your secrets to their requests (handlerd builds it when it is
15
+ missing)
16
+
17
+ Ctrl-C during a rebuild cancels it: nothing switches. Running sessions keep the image they started
18
+ on.
19
+ """
20
+
21
+ import argparse
22
+ import sys
23
+ import time
24
+ from typing import Any
25
+
26
+ from .client import Daemon
27
+ from .output import table
28
+
29
+
30
+ def add_parser(sub: Any) -> None:
31
+ image = sub.add_parser("image", help="images", description=__doc__,
32
+ formatter_class=argparse.RawDescriptionHelpFormatter) # fmt: skip
33
+ i = image.add_subparsers(dest="image_cmd", required=True)
34
+ i.add_parser("list")
35
+ i.add_parser("rebuild").add_argument(
36
+ "target", help="a harness name, 'toolbox', 'network-filter', or 'addon:NAME'"
37
+ )
38
+
39
+
40
+ def dispatch(args: argparse.Namespace) -> int:
41
+ d = Daemon()
42
+ if args.image_cmd == "list":
43
+ print_images(d)
44
+ return 0
45
+ return rebuild(d, args.target)
46
+
47
+
48
+ def print_images(d: Daemon) -> None:
49
+ rows = [["NAME", "IMAGE", "VERSION", "BUILT"]]
50
+
51
+ def row(name: str, info: dict[str, Any], version: str = "-") -> None:
52
+ built = (info.get("builtAt") or "-")[:16].replace("T", " ")
53
+ rows.append([name, info["image"], version if info["imagePresent"] else "not built", built])
54
+
55
+ row("toolbox", d.call("GET", "/v1/toolbox"))
56
+ for h in d.call("GET", "/v1/harnesses"):
57
+ row(h["name"], h, h.get("version", "-"))
58
+ for a in d.call("GET", "/v1/addons"):
59
+ if a.get("image"):
60
+ row(f"addon:{a['name']}", a, a.get("version", "-"))
61
+ row("network-filter", d.call("GET", "/v1/network-filter"))
62
+ table(rows)
63
+
64
+
65
+ def path(name: str) -> str:
66
+ """API path of a harness or `addon:NAME`."""
67
+ if name.startswith("addon:"):
68
+ return f"addons/{name.removeprefix('addon:')}"
69
+ return f"harnesses/{name}"
70
+
71
+
72
+ def rebuild(d: Daemon, target: str) -> int:
73
+ own = target in ("toolbox", "network-filter")
74
+ return watch(d, d.call("POST", f"/v1/{target if own else path(target)}/rebuild"))
75
+
76
+
77
+ def watch(d: Daemon, build: Any) -> int:
78
+ """Stream a started build's log to its end (Ctrl-C cancels it); 0 when it succeeded."""
79
+ try:
80
+ build = follow(d, build["id"])
81
+ except KeyboardInterrupt:
82
+ print("\ncancelling…", file=sys.stderr)
83
+ build = d.call("POST", f"/v1/builds/{build['id']}/cancel")
84
+ for name, version in build.get("versions", {}).items():
85
+ print(f"{name}: {version}")
86
+ error = f" ({build['error']})" if build.get("error") else ""
87
+ print(f"build {build['id']}: {build['state']}{error}")
88
+ return 0 if build["state"] == "succeeded" else 1
89
+
90
+
91
+ def follow(d: Daemon, build_id: str) -> Any:
92
+ """Print the log as it grows; return the build once it has ended."""
93
+ offset = 0
94
+ while True:
95
+ chunk = d.call("GET", f"/v1/builds/{build_id}/log?offset={offset}")
96
+ sys.stdout.write(chunk["text"])
97
+ sys.stdout.flush()
98
+ offset = chunk["nextOffset"]
99
+ if chunk["complete"]:
100
+ return d.call("GET", f"/v1/builds/{build_id}")
101
+ if not chunk["text"]:
102
+ time.sleep(1)
@@ -0,0 +1,33 @@
1
+ """`handler inbox` — what handler told you: your apps' notifications, your automations' results and
2
+ failures, newest first (the web UI's Inbox)."""
3
+
4
+ import argparse
5
+ from typing import Any
6
+
7
+ from .automation_commands import when_at
8
+ from .client import Daemon
9
+ from .output import one_line, table
10
+
11
+
12
+ def add_parser(sub: Any) -> None:
13
+ sub.add_parser("inbox", help="what handler told you", description=__doc__)
14
+
15
+
16
+ def dispatch(_: argparse.Namespace) -> int:
17
+ messages = Daemon().call("GET", "/v1/inbox")
18
+ if not messages:
19
+ print("nothing yet")
20
+ return 0
21
+ rows = [["AT", "FROM", "STATUS", "TITLE", "SUMMARY"]]
22
+ for m in messages:
23
+ source = f"{m['source']} {m['name']}"
24
+ unread = "" if m.get("read") else "• "
25
+ rows.append([
26
+ unread + when_at(m["at"]),
27
+ source,
28
+ m.get("status") or "-",
29
+ one_line(m["title"], 40),
30
+ one_line(m.get("summary") or "", 70),
31
+ ]) # fmt: skip
32
+ table(rows)
33
+ return 0
handler_cli/output.py ADDED
@@ -0,0 +1,12 @@
1
+ """Plain-text output shared by the commands."""
2
+
3
+
4
+ def one_line(text: str, width: int) -> str:
5
+ flat = " ".join(text.split())
6
+ return flat if len(flat) <= width else flat[: width - 1] + "…"
7
+
8
+
9
+ def table(rows: list[list[str]]) -> None:
10
+ widths = [max(len(r[i]) for r in rows) for i in range(len(rows[0]))]
11
+ for r in rows:
12
+ print(" ".join(cell.ljust(w) for cell, w in zip(r, widths, strict=True)).rstrip())
@@ -0,0 +1,63 @@
1
+ """`handler project …` — projects: a folder and its agents (contract D.8), created and edited in
2
+ the web UI. In a project's folder, `handler run`, `terminal` and `acp` use its agents.
3
+
4
+ handler project list projects, most recently used first: context, agents, folder
5
+ handler project show NAME its agents (`*`: the default one), what each runs on and its
6
+ settings
7
+ """
8
+
9
+ import argparse
10
+ from typing import Any
11
+
12
+ from .agent_commands import describe_agent, runs
13
+ from .client import ClientError, Daemon
14
+ from .output import table
15
+ from .session_commands import agent_label
16
+
17
+
18
+ def add_parser(sub: Any) -> None:
19
+ project = sub.add_parser("project", help="projects", description=__doc__,
20
+ formatter_class=argparse.RawDescriptionHelpFormatter) # fmt: skip
21
+ p = project.add_subparsers(dest="project_cmd", required=True)
22
+ p.add_parser("list")
23
+ p.add_parser("show").add_argument("name")
24
+
25
+
26
+ def dispatch(args: argparse.Namespace) -> int:
27
+ d = Daemon()
28
+ projects = d.call("GET", "/v1/projects")
29
+ if args.project_cmd == "list":
30
+ rows = [["PROJECT", "CONTEXT", "AGENTS", "FOLDER"]]
31
+ for p in projects:
32
+ agents = ", ".join(agent_label(a) for a in p["agents"])
33
+ rows.append([p["name"], p.get("context") or "-", agents, p["folder"]])
34
+ table(rows) if len(rows) > 1 else print("no project: create one in the web UI (handler ui)")
35
+ return 0
36
+ wanted = args.name.lower()
37
+ found = next((p for p in projects if wanted in (p["name"].lower(), p["id"])), None)
38
+ if found is None:
39
+ names = ", ".join(p["name"] for p in projects) or "none"
40
+ raise ClientError(f"no project '{args.name}': {names}")
41
+ show(found)
42
+ return 0
43
+
44
+
45
+ def show(p: dict[str, Any]) -> None:
46
+ context = f" · {p['context']}" if p.get("context") else ""
47
+ print(f"{p['name']}{context} · {p['folder']}")
48
+ if p.get("description"):
49
+ print(f" {p['description']}")
50
+ rows = [["AGENT", "ID", "RUNS", "SETTINGS", "LIVE"]]
51
+ for a in p["agents"]:
52
+ default = " *" if a["id"] == p.get("defaultAgent") else ""
53
+ rows.append(
54
+ [
55
+ f"{agent_label(a)}{default}",
56
+ a["id"],
57
+ runs(a),
58
+ describe_agent(a, runs_too=False),
59
+ str(len(a["connections"])),
60
+ ]
61
+ )
62
+ table(rows)
63
+ print("* the project's default agent")
@@ -0,0 +1,113 @@
1
+ """`handler provider …` — providers (contract D.12): where model calls go when no subscription
2
+ does, an API and its key (Anthropic, OpenAI, Mistral, OpenRouter) or a model server on this
3
+ computer (llama.cpp, Ollama). A provider starts from a template; its key goes to handler's secret
4
+ store, never shown again.
5
+
6
+ handler provider list the providers: endpoints, models, key, contexts, agents
7
+ handler provider templates what a provider can start from
8
+ handler provider add NAME --from TEMPLATE [--key] [--endpoint PROTOCOL=URL …] [--model ID …]
9
+ [--context CONTEXT …] [--description TEXT]
10
+ a provider from a template. --key: ask for its key (hidden,
11
+ or read from stdin). --endpoint: another URL for a protocol
12
+ (e.g. a server on another port). --model: the models it offers,
13
+ instead of the template's. --context: the project contexts it
14
+ is made for (Work, Personal…, or any; none: it runs nowhere a
15
+ project has a context)
16
+ handler provider rm NAME it and its key (agents using it cannot start until given
17
+ another)
18
+
19
+ Models' details and per-harness files are edited in the web UI (Settings → Model access) or in
20
+ ~/.config/handler-ai/providers/NAME.toml.
21
+ """
22
+
23
+ import argparse
24
+ from typing import Any
25
+
26
+ from .account_commands import ask_token
27
+ from .client import ClientError, Daemon
28
+ from .output import table
29
+
30
+
31
+ def add_parser(sub: Any) -> None:
32
+ provider = sub.add_parser("provider", help="providers", description=__doc__,
33
+ formatter_class=argparse.RawDescriptionHelpFormatter) # fmt: skip
34
+ p = provider.add_subparsers(dest="provider_cmd", required=True)
35
+ p.add_parser("list")
36
+ p.add_parser("templates")
37
+ add = p.add_parser("add")
38
+ add.add_argument("name")
39
+ add.add_argument("--from", dest="template", required=True, metavar="TEMPLATE")
40
+ add.add_argument("--key", action="store_true", help="ask for its key")
41
+ add.add_argument("--endpoint", action="append", metavar="PROTOCOL=URL", default=[])
42
+ add.add_argument("--model", action="append", metavar="ID", default=[])
43
+ add.add_argument("--context", action="append", metavar="CONTEXT", default=[])
44
+ add.add_argument("--description")
45
+ p.add_parser("rm").add_argument("name")
46
+
47
+
48
+ def dispatch(args: argparse.Namespace) -> int:
49
+ d = Daemon()
50
+ if args.provider_cmd == "list":
51
+ rows = [["NAME", "STATUS", "ENDPOINTS", "MODELS", "KEY", "CONTEXTS", "AGENTS"]]
52
+ for p in d.call("GET", "/v1/providers"):
53
+ status = p["status"] if p["status"] == "ready" else "; ".join(
54
+ p.get("problems") or [p.get("error") or p["status"]]) # fmt: skip
55
+ rows.append(
56
+ [
57
+ p["name"],
58
+ status,
59
+ ", ".join(p.get("endpoints", {})),
60
+ ", ".join(m["id"] for m in p.get("models", [])),
61
+ p.get("key") or "-",
62
+ ", ".join(p.get("contexts", [])) or "-",
63
+ ", ".join(p.get("agents", [])) or "-",
64
+ ]
65
+ )
66
+ table(rows) if len(rows) > 1 else print("no provider: handler provider templates")
67
+ elif args.provider_cmd == "templates":
68
+ rows = [["TEMPLATE", "KEY", "ENDPOINTS", "DESCRIPTION"]]
69
+ for t in d.call("GET", "/v1/provider-templates"):
70
+ needs = "needed" if t.get("needsKey") else "-"
71
+ endpoints = ", ".join(f"{k} {v}" for k, v in t["endpoints"].items())
72
+ rows.append([t["name"], needs, endpoints, t.get("description") or ""])
73
+ table(rows)
74
+ elif args.provider_cmd == "add":
75
+ made = d.call("POST", "/v1/providers", request(d, args))
76
+ print(f"{made['name']}: created ({made['status']})")
77
+ else:
78
+ d.call("DELETE", f"/v1/providers/{args.name}")
79
+ print(f"{args.name}: removed")
80
+ return 0
81
+
82
+
83
+ def request(d: Daemon, args: argparse.Namespace) -> dict[str, Any]:
84
+ """`POST /v1/providers`'s body: the template, changed by the flags typed (as the web UI's form
85
+ starting from it)."""
86
+ templates = d.call("GET", "/v1/provider-templates")
87
+ template = next((t for t in templates if t["name"] == args.template), None)
88
+ if template is None:
89
+ names = ", ".join(t["name"] for t in templates)
90
+ raise ClientError(f"no template '{args.template}': {names}")
91
+ endpoints = dict(template["endpoints"])
92
+ for given in args.endpoint:
93
+ protocol, _, url = given.partition("=")
94
+ if not url:
95
+ raise ClientError(f"--endpoint {given}: PROTOCOL=URL, e.g. openai-chat=http://…")
96
+ endpoints[protocol] = url
97
+ models = template["models"]
98
+ if args.model:
99
+ known = {m["id"]: m for m in models}
100
+ models = [known.get(model, {"id": model}) for model in args.model]
101
+ body: dict[str, Any] = {
102
+ "name": args.name,
103
+ "description": args.description or template.get("description"),
104
+ "endpoints": endpoints,
105
+ "models": models,
106
+ "contexts": args.context,
107
+ "files": template.get("files"),
108
+ }
109
+ if args.key:
110
+ body["key"] = ask_token(args.name, "key")
111
+ elif template.get("needsKey"):
112
+ raise ClientError(f"{args.template} needs a key: add --key")
113
+ return body
@@ -0,0 +1,64 @@
1
+ """`handler secret …` — handler's secret store (contract D.14). A value is named by a path after
2
+ what owns it (`providers/openrouter/apiKey`) and referenced from an account, a provider or a binding
3
+ with `{ secret = "<path>" }`. Values are written, never read back. An app's secrets are set with
4
+ `handler app secret`.
5
+
6
+ handler secret list the secrets handler uses (in its store or kept elsewhere):
7
+ where each comes from, what it belongs to, and whether agents
8
+ can read it (else handler adds it to their requests to its
9
+ hosts)
10
+ handler secret set PATH [--description TEXT]
11
+ set a value (asked for, or read from stdin)
12
+ handler secret rm PATH forget PATH and everything under it (refused while referenced)
13
+ """
14
+
15
+ import argparse
16
+ import getpass
17
+ import sys
18
+ from typing import Any
19
+
20
+ from .client import Daemon
21
+ from .output import table
22
+
23
+
24
+ def add_parser(sub: Any) -> None:
25
+ secret = sub.add_parser("secret", help="the secret store", description=__doc__,
26
+ formatter_class=argparse.RawDescriptionHelpFormatter) # fmt: skip
27
+ s = secret.add_subparsers(dest="secret_cmd", required=True)
28
+ s.add_parser("list")
29
+ put = s.add_parser("set")
30
+ put.add_argument("path")
31
+ put.add_argument("--description")
32
+ s.add_parser("rm").add_argument("path")
33
+
34
+
35
+ def dispatch(args: argparse.Namespace) -> int:
36
+ d = Daemon()
37
+ if args.secret_cmd == "list":
38
+ rows = [["PATH", "FROM", "USED BY", "AGENTS", "UPDATED"]]
39
+ for s in d.call("GET", "/v1/secrets"):
40
+ source = s["source"] if s["hasValue"] else f"{s['source']} (not set)"
41
+ used = ", ".join(s["usedBy"]) or "nothing"
42
+ rows.append([s["path"], source, used, _given(s), (s.get("updatedAt") or "")[:16]])
43
+ table(rows) if len(rows) > 1 else print("no secret")
44
+ return 0
45
+ if args.secret_cmd == "set":
46
+ prompt = f"{args.path}: "
47
+ value = getpass.getpass(prompt) if sys.stdin.isatty() else sys.stdin.readline()
48
+ body = {"value": value.strip(), "description": args.description}
49
+ s = d.call("PUT", f"/v1/secrets/{args.path}", body)
50
+ print(f"{s['path']}: set (used by {', '.join(s['usedBy']) or 'nothing yet'})")
51
+ return 0
52
+ d.call("DELETE", f"/v1/secrets/{args.path}")
53
+ print(f"{args.path}: removed")
54
+ return 0
55
+
56
+
57
+ def _given(s: dict[str, Any]) -> str:
58
+ """What agents get (E3): a stand-in handler swaps for the value on its way to its hosts, or the
59
+ value itself."""
60
+ if s.get("visible"):
61
+ return f"can read it: {s['visible']}"
62
+ if s["hosts"]:
63
+ return "never see it, handler adds it for " + ", ".join(s["hosts"])
64
+ return "-"
@@ -0,0 +1,93 @@
1
+ """`handler attach --stdio` and `handler acp AGENT`: a daemon-held session (F) as a plain stdio ACP
2
+ agent, for editors (Zed) and terminal ACP clients.
3
+
4
+ Such a client starts a conversation with `session/new`; a session already has one. So the bridge
5
+ answers `session/new` with the session's own: it sends `session/load` instead (the daemon replays
6
+ what was said) and adds the `sessionId` to the answer. A `session/load` of another id is pointed at
7
+ the session's. The daemon's `_handler/*` notifications (state for its own clients) are not passed
8
+ on. Everything else goes through unchanged, and the policy (C.4) applies in the daemon as usual.
9
+ """
10
+
11
+ import json
12
+ import sys
13
+ import threading
14
+ from typing import Any
15
+
16
+ from websockets.exceptions import ConnectionClosed
17
+ from websockets.sync.client import ClientConnection, connect
18
+ from websockets.typing import Subprotocol
19
+
20
+ from handler_contracts import ACP_SUBPROTOCOL, BEARER_SUBPROTOCOL_PREFIX, AcpCloseCode
21
+
22
+ from .client import Daemon
23
+
24
+
25
+ class SessionBridge:
26
+ def __init__(self, daemon: Daemon, session_id: str) -> None:
27
+ self.daemon = daemon
28
+ self.session_id = session_id
29
+ self.new_ids: set[Any] = set() # the client's session/new requests, answered by a load
30
+ self.lock = threading.Lock()
31
+
32
+ def acp_session(self) -> dict[str, Any]:
33
+ return self.daemon.call("GET", f"/v1/sessions/{self.session_id}")
34
+
35
+ def to_daemon(self, msg: dict[str, Any]) -> dict[str, Any]:
36
+ method = msg.get("method")
37
+ if method in ("session/new", "session/load") and "id" in msg:
38
+ info = self.acp_session() # asked after initialize: the agent runs, its id is known
39
+ params = {"sessionId": info["acpSessionId"], "cwd": info["scope"], "mcpServers": []}
40
+ if method == "session/new":
41
+ with self.lock:
42
+ self.new_ids.add(msg["id"])
43
+ return msg | {"method": "session/load", "params": params}
44
+ return msg
45
+
46
+ def to_client(self, msg: dict[str, Any]) -> dict[str, Any] | None:
47
+ method = msg.get("method")
48
+ if isinstance(method, str) and method.startswith("_handler/"):
49
+ return None
50
+ with self.lock:
51
+ answered_new = "method" not in msg and msg.get("id") in self.new_ids
52
+ self.new_ids.discard(msg.get("id"))
53
+ if answered_new and isinstance(msg.get("result"), dict):
54
+ acp_id = self.acp_session()["acpSessionId"]
55
+ return msg | {"result": msg["result"] | {"sessionId": acp_id}}
56
+ return msg
57
+
58
+ def stdin_to_ws(self, ws: ClientConnection) -> None:
59
+ try:
60
+ for line in sys.stdin.buffer:
61
+ if not line.strip():
62
+ continue
63
+ try:
64
+ msg = json.loads(line)
65
+ except ValueError:
66
+ continue
67
+ if isinstance(msg, dict):
68
+ ws.send(json.dumps(self.to_daemon(msg)))
69
+ except ConnectionClosed:
70
+ return
71
+ ws.close() # the client closed our stdin: detach (the agent keeps running)
72
+
73
+ def run(self, acp_url: str) -> int:
74
+ token = self.daemon.token
75
+ protocols = [Subprotocol(ACP_SUBPROTOCOL), Subprotocol(BEARER_SUBPROTOCOL_PREFIX + token)]
76
+ with connect(acp_url, subprotocols=protocols, max_size=None) as ws:
77
+ threading.Thread(target=self.stdin_to_ws, args=(ws,), daemon=True).start()
78
+ try:
79
+ for message in ws:
80
+ for line in str(message).splitlines():
81
+ if line.strip() and (out := self.to_client(json.loads(line))) is not None:
82
+ sys.stdout.write(json.dumps(out) + "\n")
83
+ sys.stdout.flush()
84
+ except ConnectionClosed:
85
+ pass
86
+ if ws.close_code in (None, AcpCloseCode.NORMAL):
87
+ return 0
88
+ print(f"handler: session closed ({ws.close_code}) {ws.close_reason}", file=sys.stderr)
89
+ return 1
90
+
91
+
92
+ def run_session_bridge(daemon: Daemon, session: dict[str, Any]) -> int:
93
+ return SessionBridge(daemon, session["id"]).run(session["acpUrl"])