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.
- handler_ai-0.1.0.dist-info/METADATA +60 -0
- handler_ai-0.1.0.dist-info/RECORD +234 -0
- handler_ai-0.1.0.dist-info/WHEEL +4 -0
- handler_ai-0.1.0.dist-info/entry_points.txt +6 -0
- handler_cli/__init__.py +1 -0
- handler_cli/account_commands.py +314 -0
- handler_cli/addon_commands.py +128 -0
- handler_cli/agent_commands.py +139 -0
- handler_cli/app_commands.py +175 -0
- handler_cli/app_window.py +85 -0
- handler_cli/automation_commands.py +309 -0
- handler_cli/bridge.py +42 -0
- handler_cli/cli.py +326 -0
- handler_cli/client.py +81 -0
- handler_cli/daemon_commands.py +108 -0
- handler_cli/image_commands.py +102 -0
- handler_cli/inbox_commands.py +33 -0
- handler_cli/output.py +12 -0
- handler_cli/project_commands.py +63 -0
- handler_cli/provider_commands.py +113 -0
- handler_cli/secret_commands.py +64 -0
- handler_cli/session_bridge.py +93 -0
- handler_cli/session_commands.py +485 -0
- handler_cli/setup.py +120 -0
- handler_cli/terminal.py +280 -0
- handler_cli/terminal_client.py +37 -0
- handler_contracts/__init__.py +642 -0
- handler_contracts/account.py +36 -0
- handler_contracts/acp_endpoint.py +69 -0
- handler_contracts/addon.py +147 -0
- handler_contracts/agent_spec.py +75 -0
- handler_contracts/automation.py +285 -0
- handler_contracts/common.py +229 -0
- handler_contracts/control.py +1208 -0
- handler_contracts/harness.py +300 -0
- handler_contracts/miniapp.py +141 -0
- handler_contracts/provider.py +82 -0
- handler_contracts/remote.py +167 -0
- handler_contracts/sandbox.py +199 -0
- handler_contracts/schemas.py +313 -0
- handler_contracts/terminal.py +32 -0
- handler_control/__init__.py +7 -0
- handler_control/access.py +62 -0
- handler_control/accounts.py +279 -0
- handler_control/addon_install.py +268 -0
- handler_control/addons.py +118 -0
- handler_control/agent_state.py +46 -0
- handler_control/agents.py +257 -0
- handler_control/api/__init__.py +137 -0
- handler_control/api/accounts.py +231 -0
- handler_control/api/acp.py +116 -0
- handler_control/api/addons.py +95 -0
- handler_control/api/agents.py +150 -0
- handler_control/api/apps.py +104 -0
- handler_control/api/automations.py +128 -0
- handler_control/api/bindings.py +105 -0
- handler_control/api/builtin_agents.py +22 -0
- handler_control/api/caches.py +79 -0
- handler_control/api/catalog.py +197 -0
- handler_control/api/common.py +205 -0
- handler_control/api/contexts.py +88 -0
- handler_control/api/folders.py +51 -0
- handler_control/api/images.py +164 -0
- handler_control/api/inbox.py +21 -0
- handler_control/api/projects.py +170 -0
- handler_control/api/providers.py +107 -0
- handler_control/api/remote.py +45 -0
- handler_control/api/secrets.py +177 -0
- handler_control/api/sessions.py +192 -0
- handler_control/api/skills.py +270 -0
- handler_control/app_api.py +84 -0
- handler_control/app_http.py +214 -0
- handler_control/app_remote.py +206 -0
- handler_control/apps.py +867 -0
- handler_control/auth.py +48 -0
- handler_control/automation_config.py +255 -0
- handler_control/automations.py +1134 -0
- handler_control/bindings.py +149 -0
- handler_control/builds.py +341 -0
- handler_control/builtin_agents.py +82 -0
- handler_control/caches.py +113 -0
- handler_control/checks.py +287 -0
- handler_control/compose.py +317 -0
- handler_control/config_files.py +41 -0
- handler_control/contexts.py +75 -0
- handler_control/daemon.py +64 -0
- handler_control/data/automation/bin/handler-llm +85 -0
- handler_control/data/automations/env-issues/automation.toml +17 -0
- handler_control/data/automations/env-issues/extract.py +73 -0
- handler_control/data/automations/env-issues/findings.schema.json +28 -0
- handler_control/data/automations/env-issues/prompt.md +19 -0
- handler_control/data/builtin-agents/skill-creator/agent.toml +10 -0
- handler_control/data/builtin-agents/skill-creator/instructions.md +5 -0
- handler_control/data/builtin-agents/skill-creator/skills/handler-skills/SKILL.md +44 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/LICENSE.txt +202 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/SKILL.md +485 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/analyzer.md +274 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/comparator.md +202 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/grader.md +223 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/assets/eval_review.html +146 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/eval-viewer/generate_review.py +471 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/eval-viewer/viewer.html +1325 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/references/schemas.md +430 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/__init__.py +0 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/generate_report.py +326 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/improve_description.py +247 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/package_skill.py +136 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/quick_validate.py +103 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/run_eval.py +310 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/run_loop.py +328 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/utils.py +47 -0
- handler_control/data/builtin-agents/skill-review/agent.toml +13 -0
- handler_control/data/builtin-agents/skill-review/instructions.md +30 -0
- handler_control/data/builtin-agents/skill-review/interface.json +83 -0
- handler_control/data/interfaces/llm/v1/interface.toml +17 -0
- handler_control/data/interfaces/llm/v1/tools.json +138 -0
- handler_control/data/templates/README.md +10 -0
- handler_control/data/templates/providers/anthropic-api.toml +18 -0
- handler_control/data/templates/providers/llamacpp.toml +210 -0
- handler_control/data/templates/providers/mistral-api.toml +14 -0
- handler_control/data/templates/providers/ollama.toml +10 -0
- handler_control/data/templates/providers/openai-api.toml +13 -0
- handler_control/data/templates/providers/openrouter.toml +10 -0
- handler_control/desktop.py +68 -0
- handler_control/display.py +62 -0
- handler_control/files.py +23 -0
- handler_control/folders.py +68 -0
- handler_control/git_read.py +39 -0
- handler_control/image_check.py +322 -0
- handler_control/image_updates.py +174 -0
- handler_control/inbox.py +70 -0
- handler_control/interfaces.py +151 -0
- handler_control/knowledge.py +237 -0
- handler_control/launcher.py +356 -0
- handler_control/llm.py +194 -0
- handler_control/migrations/__init__.py +62 -0
- handler_control/policy.py +305 -0
- handler_control/projects.py +266 -0
- handler_control/providers.py +180 -0
- handler_control/relay.py +133 -0
- handler_control/remote.py +888 -0
- handler_control/remote_crypto.py +97 -0
- handler_control/runtime.py +140 -0
- handler_control/sandbox_process.py +224 -0
- handler_control/schedules.py +58 -0
- handler_control/secrets.py +242 -0
- handler_control/service_router.py +180 -0
- handler_control/services.py +92 -0
- handler_control/session_files.py +429 -0
- handler_control/sessions.py +1116 -0
- handler_control/settings.py +129 -0
- handler_control/shortcuts.py +80 -0
- handler_control/skill_scan.py +88 -0
- handler_control/skill_sources.py +61 -0
- handler_control/skills.py +378 -0
- handler_control/terminal.py +79 -0
- handler_control/terminals.py +108 -0
- handler_control/transcript.py +174 -0
- handler_control/web-ui/assets/index-1w5RPsfA.js +190 -0
- handler_control/web-ui/assets/index-dWnzCLzQ.css +1 -0
- handler_control/web-ui/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- handler_control/web-ui/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- handler_control/web-ui/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- handler_control/web-ui/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- handler_control/web-ui/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- handler_control/web-ui/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- handler_control/web-ui/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- handler_control/web-ui/favicon.svg +1 -0
- handler_control/web-ui/index.html +15 -0
- handler_control/webpush.py +90 -0
- handler_control/webui.py +105 -0
- handler_harness/__init__.py +13 -0
- handler_harness/addons.py +129 -0
- handler_harness/catalog.py +116 -0
- handler_harness/cli.py +109 -0
- handler_harness/data/addons/README.md +44 -0
- handler_harness/data/addons/miniapp/Containerfile +20 -0
- handler_harness/data/addons/miniapp/PROVISIONING.md +16 -0
- handler_harness/data/addons/miniapp/addon.toml +16 -0
- handler_harness/data/addons/miniapp/skills/miniapp/SKILL.md +140 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/.gitignore +2 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/app.toml +15 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/db.ts +30 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/deno.json +7 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/handler.ts +26 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/main.ts +18 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/main_test.ts +32 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/migrations/001_notes.sql +6 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/routes.ts +32 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/deno.json +12 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/index.html +12 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/App.svelte +44 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/api.ts +16 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/app.css +24 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/main.ts +5 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/vite.config.ts +17 -0
- handler_harness/data/catalog/README.md +12 -0
- handler_harness/data/catalog/playwright/Containerfile +28 -0
- handler_harness/data/catalog/playwright/PROVISIONING.md +53 -0
- handler_harness/data/catalog/playwright/addon.toml +25 -0
- handler_harness/data/catalog/playwright/handler/playwright-mcp-check.mjs +23 -0
- handler_harness/data/catalog/playwright/handler/playwright-mcp.mjs +61 -0
- handler_harness/data/catalog/playwright/skills/browser/SKILL.md +41 -0
- handler_harness/data/catalog/shopify/Containerfile +22 -0
- handler_harness/data/catalog/shopify/PROVISIONING.md +49 -0
- handler_harness/data/catalog/shopify/addon.toml +39 -0
- handler_harness/data/catalog/shopify/skills/shopify/SKILL.md +41 -0
- handler_harness/data/handler-sandbox-env +30 -0
- handler_harness/data/harnesses/README.md +47 -0
- handler_harness/data/harnesses/claude-code/Containerfile +27 -0
- handler_harness/data/harnesses/claude-code/harness.toml +95 -0
- handler_harness/data/harnesses/codex/Containerfile +26 -0
- handler_harness/data/harnesses/codex/harness.toml +103 -0
- handler_harness/data/harnesses/pi/Containerfile +27 -0
- handler_harness/data/harnesses/pi/harness.toml +65 -0
- handler_harness/data/harnesses/vibe/Containerfile +19 -0
- handler_harness/data/harnesses/vibe/harness.toml +96 -0
- handler_harness/data/toolbox/Containerfile +56 -0
- handler_harness/data/toolbox/README.md +24 -0
- handler_harness/data/toolbox/toolbox.toml +41 -0
- handler_harness/images.py +62 -0
- handler_harness/providers.py +300 -0
- handler_harness/render.py +472 -0
- handler_sandbox_podman/__init__.py +1 -0
- handler_sandbox_podman/caches.py +97 -0
- handler_sandbox_podman/check.py +121 -0
- handler_sandbox_podman/cli.py +239 -0
- handler_sandbox_podman/egress.py +118 -0
- handler_sandbox_podman/images.py +199 -0
- handler_sandbox_podman/launch.py +474 -0
- handler_sandbox_podman/podman.py +108 -0
- handler_sandbox_podman/proxy/Containerfile +6 -0
- handler_sandbox_podman/proxy/addon.py +255 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""`handler automation …` — automations (docs/user/automations.md): when a
|
|
2
|
+
session stops or at a time you set, handler runs your script in a sandbox (it may call the models
|
|
3
|
+
you chose, what it writes is added to a knowledge note), one of your agents (its result goes to your
|
|
4
|
+
inbox: `handler inbox`), or a route of one of your apps. Each is a folder of
|
|
5
|
+
~/.config/handler-ai/automations/<name>/: edit its files in your editor.
|
|
6
|
+
|
|
7
|
+
handler automation list every automation: on or off, when, last run, problems
|
|
8
|
+
handler automation show NAME what it may touch, its files, its problems, its next run
|
|
9
|
+
handler automation new NAME --from EXAMPLE copy an example handler ships (env-issues), off
|
|
10
|
+
handler automation examples the examples handler ships
|
|
11
|
+
handler automation try NAME [--session ID|last]
|
|
12
|
+
run it now, on or off (one on a session that stopped:
|
|
13
|
+
on that session's whole conversation, default the one
|
|
14
|
+
that stopped last; a script's try adds nothing)
|
|
15
|
+
handler automation enable NAME [--yes] turn it on: shows what it may touch and asks first
|
|
16
|
+
handler automation disable NAME turn it off
|
|
17
|
+
handler automation delete NAME [--yes] delete it: its folder (its files) and its runs; asks first
|
|
18
|
+
handler automation runs NAME its runs, most recent first
|
|
19
|
+
handler automation log RUN a script run's output
|
|
20
|
+
handler automation cancel RUN stop a run
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from .client import ClientError, Daemon
|
|
31
|
+
from .output import one_line, table
|
|
32
|
+
|
|
33
|
+
WATCH_INTERVAL = 0.5
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def add_parser(sub: Any) -> None:
|
|
37
|
+
auto = sub.add_parser("automation", help="automations", description=__doc__,
|
|
38
|
+
formatter_class=argparse.RawDescriptionHelpFormatter) # fmt: skip
|
|
39
|
+
a = auto.add_subparsers(dest="automation_cmd", required=True)
|
|
40
|
+
a.add_parser("list")
|
|
41
|
+
a.add_parser("examples")
|
|
42
|
+
new = a.add_parser("new")
|
|
43
|
+
new.add_argument("name")
|
|
44
|
+
new.add_argument("--from", dest="example", required=True, help="an example (env-issues)")
|
|
45
|
+
t = a.add_parser("try")
|
|
46
|
+
t.add_argument("name")
|
|
47
|
+
t.add_argument("--session", default="last", help="a session's id, or last (the default)")
|
|
48
|
+
e = a.add_parser("enable")
|
|
49
|
+
e.add_argument("name")
|
|
50
|
+
e.add_argument("--yes", action="store_true", help="do not ask")
|
|
51
|
+
rm = a.add_parser("delete")
|
|
52
|
+
rm.add_argument("name")
|
|
53
|
+
rm.add_argument("--yes", action="store_true", help="do not ask")
|
|
54
|
+
for name in ("show", "disable", "runs"):
|
|
55
|
+
a.add_parser(name).add_argument("name")
|
|
56
|
+
for name in ("log", "cancel"):
|
|
57
|
+
a.add_parser(name).add_argument("run")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def sentence(definition: dict[str, Any] | None) -> str:
|
|
61
|
+
"""When it runs, in words."""
|
|
62
|
+
if not definition:
|
|
63
|
+
return "-"
|
|
64
|
+
when = definition["when"]
|
|
65
|
+
if isinstance(when, dict):
|
|
66
|
+
if when.get("every"):
|
|
67
|
+
return f"every {when['every']}"
|
|
68
|
+
days = f" on {','.join(when['days'])}" if when.get("days") else ""
|
|
69
|
+
return f"at {when['at']}{days}"
|
|
70
|
+
only = definition.get("only") or {}
|
|
71
|
+
who = [f"project {', '.join(only['project'])}"] if only.get("project") else []
|
|
72
|
+
who += [f"agent {', '.join(only['agent'])}"] if only.get("agent") else []
|
|
73
|
+
return "when a session stops" + (f" ({'; '.join(who)})" if who else "")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def what(definition: dict[str, Any] | None) -> str:
|
|
77
|
+
"""What it runs, in a few words."""
|
|
78
|
+
if not definition:
|
|
79
|
+
return "-"
|
|
80
|
+
run = definition["run"]
|
|
81
|
+
if isinstance(run, str):
|
|
82
|
+
return "a script"
|
|
83
|
+
if "agent" in run:
|
|
84
|
+
return f"agent {run['agent']}" + (f" ({run['project']})" if run.get("project") else "")
|
|
85
|
+
return f"app {run['app']}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def last_run(view: dict[str, Any]) -> str:
|
|
89
|
+
run = view.get("lastRun")
|
|
90
|
+
if not run:
|
|
91
|
+
return "-"
|
|
92
|
+
return f"{run['state']} {run['createdAt'][:16].replace('T', ' ')}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def list_automations(d: Daemon, _: argparse.Namespace) -> int:
|
|
96
|
+
views = d.call("GET", "/v1/automations")
|
|
97
|
+
if not views:
|
|
98
|
+
print("no automation: handler automation new NAME --from env-issues")
|
|
99
|
+
return 0
|
|
100
|
+
rows = [["NAME", "", "WHEN", "RUNS", "LAST RUN", "PROBLEMS"]]
|
|
101
|
+
for v in views:
|
|
102
|
+
state = "on" if v.get("enabled") else "off"
|
|
103
|
+
problems = one_line("; ".join(v.get("problems") or []), 60) or "-"
|
|
104
|
+
definition = v.get("definition")
|
|
105
|
+
rows.append([v["name"], state, sentence(definition), what(definition), last_run(v),
|
|
106
|
+
problems]) # fmt: skip
|
|
107
|
+
table(rows)
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def show(d: Daemon, args: argparse.Namespace) -> int:
|
|
112
|
+
v = d.call("GET", f"/v1/automations/{args.name}")
|
|
113
|
+
print(f"{v['name']}: {'on' if v.get('enabled') else 'off'}"
|
|
114
|
+
+ (f" — {v['description']}" if v.get("description") else "")) # fmt: skip
|
|
115
|
+
if permissions := v.get("permissions"):
|
|
116
|
+
print("What it does:")
|
|
117
|
+
for line in permissions:
|
|
118
|
+
print(f" {line}")
|
|
119
|
+
for warning in v.get("warnings") or []:
|
|
120
|
+
print(f"warning: {warning}")
|
|
121
|
+
for problem in v.get("problems") or []:
|
|
122
|
+
print(f"problem: {problem}")
|
|
123
|
+
print(f"Files, in {v['directory']}:")
|
|
124
|
+
for file in v.get("files") or []:
|
|
125
|
+
print(f" {file}")
|
|
126
|
+
if v.get("pending"):
|
|
127
|
+
capped = " (daily cap reached)" if v.get("capped") else ""
|
|
128
|
+
behind = f", behind {v['behind']} (one run at a time)" if v.get("behind") else ""
|
|
129
|
+
print(f"{v['pending']} run(s) waiting{capped}{behind}")
|
|
130
|
+
if v.get("nextAt"):
|
|
131
|
+
print(f"Next run: {when_at(v['nextAt'])}")
|
|
132
|
+
print(f"Last run: {last_run(v)}")
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def when_at(t: str) -> str:
|
|
137
|
+
"""A time of the API (UTC) as the local time, to the minute."""
|
|
138
|
+
return datetime.fromisoformat(t).astimezone().strftime("%Y-%m-%d %H:%M")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def examples(d: Daemon, _: argparse.Namespace) -> int:
|
|
142
|
+
table([["EXAMPLE", "DESCRIPTION"]] + [[e["name"], e.get("description") or ""]
|
|
143
|
+
for e in d.call("GET", "/v1/automation-examples")]) # fmt: skip
|
|
144
|
+
return 0
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def new(d: Daemon, args: argparse.Namespace) -> int:
|
|
148
|
+
v = d.call("POST", "/v1/automations", {"name": args.name, "from": args.example})
|
|
149
|
+
print(f"{args.name}: created, off, in {v['directory']}")
|
|
150
|
+
for problem in v.get("problems") or []:
|
|
151
|
+
print(f"problem: {problem}")
|
|
152
|
+
print(f"Edit its files, try it (handler automation try {args.name}), then turn it on "
|
|
153
|
+
f"(handler automation enable {args.name}).") # fmt: skip
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def last_session(d: Daemon) -> str:
|
|
158
|
+
sessions = d.call("GET", "/v1/sessions")
|
|
159
|
+
stopped = [s for s in sessions if s["state"] == "stopped" and not s.get("input")]
|
|
160
|
+
if not stopped:
|
|
161
|
+
raise ClientError("no stopped session to try it on (--session ID)")
|
|
162
|
+
return max(stopped, key=lambda s: s["lastActivityAt"])["id"]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def wait(d: Daemon, name: str, run: dict[str, Any]) -> dict[str, Any]:
|
|
166
|
+
while run["state"] in ("queued", "running"):
|
|
167
|
+
time.sleep(WATCH_INTERVAL)
|
|
168
|
+
run = d.call("GET", f"/v1/automations/{name}/runs/{run['id']}")
|
|
169
|
+
return run
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def run_for(run: dict[str, Any]) -> str:
|
|
173
|
+
"""What a run was for: the session that stopped, or the schedule's slot (and those missed)."""
|
|
174
|
+
if run.get("sessionId"):
|
|
175
|
+
return f"session {run['sessionId']}, events {run['seqFrom']} to {run['seqTo']}"
|
|
176
|
+
if run.get("slot"):
|
|
177
|
+
missed = run.get("missed") or 0
|
|
178
|
+
return f"slot {when_at(run['slot'])}" + (f", {missed} missed before" if missed else "")
|
|
179
|
+
return "now"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def describe_run(run: dict[str, Any]) -> list[str]:
|
|
183
|
+
llm = run.get("llm") or {}
|
|
184
|
+
lines = [f"{run['id']} {run['state']}{' (try)' if run.get('trial') else ''}: {run_for(run)}"]
|
|
185
|
+
if run.get("agentSessionId"):
|
|
186
|
+
lines.append(f" the agent's session: {run['agentSessionId']}")
|
|
187
|
+
if run.get("error"):
|
|
188
|
+
lines.append(f" error: {run['error']}")
|
|
189
|
+
if llm.get("calls"):
|
|
190
|
+
lines.append(f" {llm['calls']} model call(s), {llm['inTokens']} tokens in, "
|
|
191
|
+
f"{llm['outTokens']} out") # fmt: skip
|
|
192
|
+
for appended in run.get("appended") or []:
|
|
193
|
+
verb = "would add" if run.get("trial") else "added"
|
|
194
|
+
lines.append(f" {verb} {appended['bytes']} bytes to {appended['to']}")
|
|
195
|
+
return lines
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def try_it(d: Daemon, args: argparse.Namespace) -> int:
|
|
199
|
+
v = d.call("GET", f"/v1/automations/{args.name}")
|
|
200
|
+
if (v.get("definition") or {}).get("when") == "session.stopped":
|
|
201
|
+
session = last_session(d) if args.session == "last" else args.session
|
|
202
|
+
run = d.call("POST", f"/v1/automations/{args.name}/try", {"sessionId": session})
|
|
203
|
+
print(f"trying {args.name} on session {session} ({run['id']})…", file=sys.stderr)
|
|
204
|
+
else:
|
|
205
|
+
run = d.call("POST", f"/v1/automations/{args.name}/try", {})
|
|
206
|
+
print(f"trying {args.name} ({run['id']})…", file=sys.stderr)
|
|
207
|
+
run = wait(d, args.name, run)
|
|
208
|
+
print("\n".join(describe_run(run)))
|
|
209
|
+
if run.get("agentSessionId") and run["state"] == "succeeded":
|
|
210
|
+
output = d.call("GET", f"/v1/sessions/{run['agentSessionId']}").get("output") or {}
|
|
211
|
+
value = output.get("value") or {}
|
|
212
|
+
print(f" {value.get('status')}: {value.get('summary')} (in your inbox: handler inbox)")
|
|
213
|
+
for appended in run.get("appended") or []:
|
|
214
|
+
print(f"\n--- {appended['to']} would get:\n{appended.get('text', '')}", end="")
|
|
215
|
+
if run["state"] != "succeeded":
|
|
216
|
+
print(f"(its output: handler automation log {run['id']})", file=sys.stderr)
|
|
217
|
+
return 1
|
|
218
|
+
return 0
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def enable(d: Daemon, args: argparse.Namespace) -> int:
|
|
222
|
+
v = d.call("GET", f"/v1/automations/{args.name}")
|
|
223
|
+
if v.get("problems"):
|
|
224
|
+
raise ClientError(f"{args.name} has problems: {'; '.join(v['problems'])}")
|
|
225
|
+
print(f"{args.name}, once on:")
|
|
226
|
+
for line in v.get("permissions") or []:
|
|
227
|
+
print(f" {line}")
|
|
228
|
+
for warning in v.get("warnings") or []:
|
|
229
|
+
print(f"warning: {warning}")
|
|
230
|
+
if not args.yes and input("Turn it on? [y/N] ").strip().lower() != "y":
|
|
231
|
+
return 1
|
|
232
|
+
v = d.call("POST", f"/v1/automations/{args.name}/enable")
|
|
233
|
+
if v.get("nextAt"):
|
|
234
|
+
print(f"{args.name}: on, next run {when_at(v['nextAt'])}")
|
|
235
|
+
else:
|
|
236
|
+
print(f"{args.name}: on (sessions that stop from now on)")
|
|
237
|
+
return 0
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def disable(d: Daemon, args: argparse.Namespace) -> int:
|
|
241
|
+
d.call("POST", f"/v1/automations/{args.name}/disable")
|
|
242
|
+
print(f"{args.name}: off")
|
|
243
|
+
return 0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def delete(d: Daemon, args: argparse.Namespace) -> int:
|
|
247
|
+
v = d.call("GET", f"/v1/automations/{args.name}")
|
|
248
|
+
count = len(d.call("GET", f"/v1/automations/{args.name}/runs"))
|
|
249
|
+
print(f"{args.name}: {'on' if v.get('enabled') else 'off'}; deleting it removes")
|
|
250
|
+
print(f" its folder, with every file in it: {v['directory']}")
|
|
251
|
+
runs_of = f"{count} run{'' if count == 1 else 's'}"
|
|
252
|
+
print(f" its {runs_of} and their logs (what it told your inbox stays)")
|
|
253
|
+
if v.get("enabled"):
|
|
254
|
+
print(" and stops it (a run in progress is cancelled)")
|
|
255
|
+
if not args.yes and input("Delete it? [y/N] ").strip().lower() != "y":
|
|
256
|
+
return 1
|
|
257
|
+
d.call("DELETE", f"/v1/automations/{args.name}")
|
|
258
|
+
print(f"{args.name}: deleted")
|
|
259
|
+
return 0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def runs(d: Daemon, args: argparse.Namespace) -> int:
|
|
263
|
+
rows = [["RUN", "STATE", "FOR", "STARTED", "ADDED"]]
|
|
264
|
+
for run in d.call("GET", f"/v1/automations/{args.name}/runs"):
|
|
265
|
+
state = run["state"] + (" (try)" if run.get("trial") else "")
|
|
266
|
+
added = ", ".join(a["to"] for a in run.get("appended") or []) or "-"
|
|
267
|
+
rows.append([run["id"], state, run_for(run), when_at(run["createdAt"]), added])
|
|
268
|
+
table(rows)
|
|
269
|
+
return 0
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def find_run(d: Daemon, run_id: str) -> str:
|
|
273
|
+
"""The automation `run_id` is a run of."""
|
|
274
|
+
for v in d.call("GET", "/v1/automations"):
|
|
275
|
+
if any(r["id"] == run_id for r in d.call("GET", f"/v1/automations/{v['name']}/runs")):
|
|
276
|
+
return v["name"]
|
|
277
|
+
raise ClientError(f"no run {run_id}")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def log(d: Daemon, args: argparse.Namespace) -> int:
|
|
281
|
+
name = find_run(d, args.run)
|
|
282
|
+
print(d.text(f"/v1/automations/{name}/runs/{args.run}/log"), end="")
|
|
283
|
+
return 0
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def cancel(d: Daemon, args: argparse.Namespace) -> int:
|
|
287
|
+
name = find_run(d, args.run)
|
|
288
|
+
run = d.call("POST", f"/v1/automations/{name}/runs/{args.run}/cancel")
|
|
289
|
+
print("\n".join(describe_run(wait(d, name, run))))
|
|
290
|
+
return 0
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
COMMANDS: dict[str, Callable[[Daemon, argparse.Namespace], int]] = {
|
|
294
|
+
"list": list_automations,
|
|
295
|
+
"show": show,
|
|
296
|
+
"examples": examples,
|
|
297
|
+
"new": new,
|
|
298
|
+
"try": try_it,
|
|
299
|
+
"enable": enable,
|
|
300
|
+
"disable": disable,
|
|
301
|
+
"runs": runs,
|
|
302
|
+
"log": log,
|
|
303
|
+
"cancel": cancel,
|
|
304
|
+
"delete": delete,
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def dispatch(args: argparse.Namespace) -> int:
|
|
309
|
+
return COMMANDS[args.automation_cmd](Daemon(), args)
|
handler_cli/bridge.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""`handler acp`: bridge stdio (an editor such as Zed, or any ACP client) to an agent's ACP
|
|
2
|
+
WebSocket.
|
|
3
|
+
|
|
4
|
+
The editor talks NDJSON on our stdin/stdout; handlerd applies the policy (P1-P7) and runs the agent
|
|
5
|
+
in its sandbox. One JSON-RPC message per line one way, per frame the other way.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
|
|
11
|
+
from websockets.exceptions import ConnectionClosed
|
|
12
|
+
from websockets.sync.client import ClientConnection, connect
|
|
13
|
+
from websockets.typing import Subprotocol
|
|
14
|
+
|
|
15
|
+
from handler_contracts import ACP_SUBPROTOCOL, BEARER_SUBPROTOCOL_PREFIX, AcpCloseCode
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _stdin_to_ws(ws: ClientConnection) -> None:
|
|
19
|
+
try:
|
|
20
|
+
for line in sys.stdin.buffer:
|
|
21
|
+
if line.strip():
|
|
22
|
+
ws.send(line.decode().rstrip("\n"))
|
|
23
|
+
except ConnectionClosed:
|
|
24
|
+
return
|
|
25
|
+
ws.close() # the editor closed our stdin: end the connection (the sandbox stops)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_bridge(acp_url: str, token: str) -> int:
|
|
29
|
+
protocols = [Subprotocol(ACP_SUBPROTOCOL), Subprotocol(BEARER_SUBPROTOCOL_PREFIX + token)]
|
|
30
|
+
with connect(acp_url, subprotocols=protocols, max_size=None) as ws:
|
|
31
|
+
threading.Thread(target=_stdin_to_ws, args=(ws,), daemon=True).start()
|
|
32
|
+
try:
|
|
33
|
+
for message in ws:
|
|
34
|
+
text = message if isinstance(message, str) else message.decode()
|
|
35
|
+
sys.stdout.write(text + "\n")
|
|
36
|
+
sys.stdout.flush()
|
|
37
|
+
except ConnectionClosed:
|
|
38
|
+
pass
|
|
39
|
+
if ws.close_code in (None, AcpCloseCode.NORMAL):
|
|
40
|
+
return 0
|
|
41
|
+
print(f"handler acp: connection closed ({ws.close_code}) {ws.close_reason}", file=sys.stderr)
|
|
42
|
+
return 1
|
handler_cli/cli.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""handler — run coding agents in sandboxes on your machine. Each command talks to handlerd,
|
|
2
|
+
the daemon that keeps the agents running (`handler setup` installs it).
|
|
3
|
+
|
|
4
|
+
Working with agents:
|
|
5
|
+
handler run [AGENT|ACCOUNT|PROVIDER] [-p REQUEST] [agent options] [--remote] [--detach]
|
|
6
|
+
[--anyway] a session in the current directory, in the terminal
|
|
7
|
+
(also shown in the web UI): the project's agent in a
|
|
8
|
+
project's folder, else ad hoc (`handler run -h`)
|
|
9
|
+
handler attach [SESSION] [--stdio] back to a session: a running one is joined, a stopped
|
|
10
|
+
one resumed
|
|
11
|
+
handler terminal AGENT|ACCOUNT|PROVIDER [FOLDER] [agent options] [--anyway] [-- ARGS …]
|
|
12
|
+
the harness's own terminal UI (e.g. Claude Code's) in
|
|
13
|
+
its sandbox, ARGS given to it; not a session: one
|
|
14
|
+
sandbox, gone when it exits
|
|
15
|
+
handler acp AGENT|ACCOUNT|PROVIDER [FOLDER] [agent options] [--anyway]
|
|
16
|
+
ACP on stdio, for editors (e.g. Zed): an agent of the
|
|
17
|
+
folder's project (a session shown in the web UI), else
|
|
18
|
+
an account or a provider (one sandbox for this
|
|
19
|
+
connection)
|
|
20
|
+
|
|
21
|
+
What exists (`handler NOUN -h` for each):
|
|
22
|
+
handler session list | show | stop | share sessions
|
|
23
|
+
handler agent list | show | rm | network agents, their network (log-only, enforce)
|
|
24
|
+
handler project list | show projects (created and edited in the web UI)
|
|
25
|
+
handler account list | add | token | login | import | rm
|
|
26
|
+
subscriptions and their login
|
|
27
|
+
handler provider list | templates | add | rm API keys and local model servers
|
|
28
|
+
handler secret list | set | rm the secret store
|
|
29
|
+
handler addon list | catalog | add | rm addons an agent can get
|
|
30
|
+
handler binding list addons with their settings and secrets
|
|
31
|
+
handler knowledge list knowledge folders an agent can get
|
|
32
|
+
handler image list | rebuild the images agents run in
|
|
33
|
+
handler app … mini apps: register, deploy, open
|
|
34
|
+
handler automation … when a session stops or at a time, run a script,
|
|
35
|
+
an agent or an app's route
|
|
36
|
+
handler inbox what handler told you: apps' notifications,
|
|
37
|
+
automations' results and failures
|
|
38
|
+
|
|
39
|
+
Handler itself:
|
|
40
|
+
handler setup [--no-build] [--yes] after installing: start handlerd as a service,
|
|
41
|
+
check this machine, build the images
|
|
42
|
+
handler daemon status | install | uninstall | log
|
|
43
|
+
handlerd as a systemd user service
|
|
44
|
+
handler ui [--print] open the web UI (token passed in the URL fragment)
|
|
45
|
+
|
|
46
|
+
FOLDER defaults to the current directory, inside one of the work folders (set in the web UI,
|
|
47
|
+
Settings → Folders). A knowledge FOLDER is a path relative to the knowledge root (see `handler
|
|
48
|
+
knowledge list`; set in the web UI), or an absolute path inside it; the agent sees it read-only at
|
|
49
|
+
/knowledge/<folder name>. Outside projects the sandbox starts from a preset: filtered network,
|
|
50
|
+
enforced (--log-only lets every host through and logs it, to learn which it needs), strict
|
|
51
|
+
confinement, permissions bypass. A flag that widens it (--network open, --log-only, --containers)
|
|
52
|
+
is your confirmation: handler says on stderr what it gives the agent.
|
|
53
|
+
--allow adds hosts to a filtered agent's (on top of those its account or provider and its
|
|
54
|
+
addons require). --permissions sets the agent's starting mode: ask (you answer each request),
|
|
55
|
+
accept-edits (file edits allowed) or bypass (unsupervised); the session can change it.
|
|
56
|
+
--containers lets the agent run podman itself (nested confinement). --display gives the agent's
|
|
57
|
+
sandbox your Wayland display, e.g. to watch the browser of the playwright addon, and its
|
|
58
|
+
clipboard: pasting images in `handler terminal` needs it (the agent can then read what you copy);
|
|
59
|
+
nothing else of your desktop.
|
|
60
|
+
|
|
61
|
+
An ACCOUNT runs its own harness; a PROVIDER runs the harness --harness names (or the only one
|
|
62
|
+
that speaks its protocol). --model picks the model (default: the harness's or the provider's).
|
|
63
|
+
The account or provider and the model are for this time only: never saved to the agent.
|
|
64
|
+
|
|
65
|
+
Outside projects, the agent is the one of the folder and its --name (none: the unnamed one):
|
|
66
|
+
the same command in the same folder always gives the same agent, made with the flags typed
|
|
67
|
+
the first time. In a project's folder (or below), `run`, `acp` and `terminal` use the project's
|
|
68
|
+
agent named AGENT, or the one using that ACCOUNT or PROVIDER (default agent first); an account
|
|
69
|
+
or provider none of its agents uses runs the default agent, unless it is not made for the
|
|
70
|
+
project's context (refused; --anyway runs it there outside the project). A flag that differs
|
|
71
|
+
from the agent's settings is never ignored: handler says what differs and asks "Save this to the
|
|
72
|
+
agent?" (yes saves it, no starts nothing); without a terminal, --save saves it, else it is
|
|
73
|
+
refused. Something to try once is another agent: --name NAME (outside projects).
|
|
74
|
+
Elsewhere, `handler run` without a name takes the only account or provider ready.
|
|
75
|
+
The terminal client is built in; HANDLER_ACP_CLIENT names another one (see `handler run -h`).
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
import argparse
|
|
79
|
+
import os
|
|
80
|
+
import sys
|
|
81
|
+
import webbrowser
|
|
82
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
83
|
+
from typing import Any
|
|
84
|
+
from urllib.parse import urlencode
|
|
85
|
+
|
|
86
|
+
from . import (
|
|
87
|
+
account_commands,
|
|
88
|
+
addon_commands,
|
|
89
|
+
agent_commands,
|
|
90
|
+
app_commands,
|
|
91
|
+
automation_commands,
|
|
92
|
+
bridge,
|
|
93
|
+
daemon_commands,
|
|
94
|
+
image_commands,
|
|
95
|
+
inbox_commands,
|
|
96
|
+
project_commands,
|
|
97
|
+
provider_commands,
|
|
98
|
+
secret_commands,
|
|
99
|
+
session_commands,
|
|
100
|
+
setup,
|
|
101
|
+
terminal_client,
|
|
102
|
+
)
|
|
103
|
+
from .client import ClientError, Daemon, base_url, read_token
|
|
104
|
+
from .output import table
|
|
105
|
+
from .session_bridge import run_session_bridge
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def add_agent_options(s: argparse.ArgumentParser) -> None:
|
|
109
|
+
s.add_argument(
|
|
110
|
+
"--harness",
|
|
111
|
+
help="the harness, for a provider (an account has its own); in a project's folder: "
|
|
112
|
+
"which of the agent's models",
|
|
113
|
+
)
|
|
114
|
+
s.add_argument(
|
|
115
|
+
"--model",
|
|
116
|
+
help="the model (default: the harness's or the provider's); in a project's folder: "
|
|
117
|
+
"which of the agent's models",
|
|
118
|
+
)
|
|
119
|
+
s.add_argument("--name", help="a name, e.g. the use case (several per folder are fine)")
|
|
120
|
+
s.add_argument("--network", choices=["none", "filtered", "open"], help="default: filtered")
|
|
121
|
+
s.add_argument(
|
|
122
|
+
"--log-only",
|
|
123
|
+
action="store_true",
|
|
124
|
+
help="filtered: let every host through and log it, to learn which it needs "
|
|
125
|
+
"(default: deny what is not allowed)",
|
|
126
|
+
)
|
|
127
|
+
s.add_argument(
|
|
128
|
+
"--allow",
|
|
129
|
+
action="append",
|
|
130
|
+
metavar="HOST:PORT",
|
|
131
|
+
help="a host to allow (filtered network), on top of the required ones; repeatable",
|
|
132
|
+
)
|
|
133
|
+
s.add_argument(
|
|
134
|
+
"--binding",
|
|
135
|
+
action="append",
|
|
136
|
+
metavar="NAME",
|
|
137
|
+
help="a binding (addon + instance + credentials) to give the agent; repeatable",
|
|
138
|
+
)
|
|
139
|
+
s.add_argument(
|
|
140
|
+
"--knowledge",
|
|
141
|
+
action="append",
|
|
142
|
+
metavar="FOLDER",
|
|
143
|
+
help="a folder of the knowledge root, mounted read-only at /knowledge/<name>; repeatable",
|
|
144
|
+
)
|
|
145
|
+
s.add_argument(
|
|
146
|
+
"--permissions",
|
|
147
|
+
choices=["ask", "accept-edits", "bypass"],
|
|
148
|
+
help="the agent's starting permission mode (default: bypass)",
|
|
149
|
+
)
|
|
150
|
+
s.add_argument(
|
|
151
|
+
"--containers",
|
|
152
|
+
action="store_true",
|
|
153
|
+
help="let the agent run containers itself (podman in podman)",
|
|
154
|
+
)
|
|
155
|
+
s.add_argument(
|
|
156
|
+
"--display",
|
|
157
|
+
action="store_true",
|
|
158
|
+
help="give the sandbox your Wayland display and clipboard (watch a browser, paste images)",
|
|
159
|
+
)
|
|
160
|
+
s.add_argument(
|
|
161
|
+
"--save",
|
|
162
|
+
action="store_true",
|
|
163
|
+
help="save the flags that differ from the agent's settings to it, without being asked "
|
|
164
|
+
"(needed without a terminal)",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def acp(d: Daemon, args: argparse.Namespace) -> int:
|
|
169
|
+
"""An agent of the folder's project: a daemon session (shown in the web UI) as a stdio agent.
|
|
170
|
+
Otherwise (or --anyway) an account or provider: one sandbox for this connection only."""
|
|
171
|
+
found = session_commands.project_agent(d, args.access, os.path.abspath(args.scope), args)
|
|
172
|
+
if found is not None:
|
|
173
|
+
agent, model = found
|
|
174
|
+
body: dict[str, Any] = {"agentId": agent["id"]}
|
|
175
|
+
if model is not None:
|
|
176
|
+
body["model"] = session_commands.access_of(model)
|
|
177
|
+
return run_session_bridge(d, d.call("POST", "/v1/sessions", body))
|
|
178
|
+
agent, model = session_commands.open_ad_hoc(d, args)
|
|
179
|
+
return bridge.run_bridge(f"{agent['acpUrl']}?{urlencode(model)}", d.token)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def terminal(d: Daemon, args: argparse.Namespace) -> int:
|
|
183
|
+
"""D.16: an agent of the folder's project, else (or --anyway) an account or provider; one
|
|
184
|
+
sandbox for this terminal."""
|
|
185
|
+
found = session_commands.project_agent(d, args.access, os.path.abspath(args.scope), args)
|
|
186
|
+
agent, model = found if found is not None else session_commands.open_ad_hoc(d, args)
|
|
187
|
+
model = model or agent["models"][0]
|
|
188
|
+
print(terminal_identity(d, model), file=sys.stderr)
|
|
189
|
+
return terminal_client.run(d, agent["id"], args.args, session_commands.access_of(model))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def terminal_identity(d: Daemon, model: dict[str, Any]) -> str:
|
|
193
|
+
"""Who the harness runs as (the model of this terminal), from handler's own files: the harness's
|
|
194
|
+
terminal cannot tell (a token login says neither the email nor the plan)."""
|
|
195
|
+
if provider := model.get("provider"):
|
|
196
|
+
harness = model["harness"]
|
|
197
|
+
return f"handler: {harness} · provider {provider} (its endpoint and key, no subscription)"
|
|
198
|
+
name = model.get("account")
|
|
199
|
+
account = next((a for a in d.call("GET", "/v1/accounts") if a["name"] == name), {})
|
|
200
|
+
harness = model.get("harness") or account.get("harness") # a model it does not list: none
|
|
201
|
+
described = f" ({account['description']})" if account.get("description") else ""
|
|
202
|
+
plan = account.get("plan")
|
|
203
|
+
subscription = f"{plan} subscription" if plan else "subscription, plan not set in the account"
|
|
204
|
+
return f"handler: {harness} · account {name}{described} · {subscription}"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def print_knowledge(d: Daemon) -> None:
|
|
208
|
+
listing = d.call("GET", "/v1/knowledge")
|
|
209
|
+
if not listing["exists"]:
|
|
210
|
+
print(f"no knowledge root: {listing['root']} does not exist (set it in the web UI)")
|
|
211
|
+
return
|
|
212
|
+
print(f"knowledge root: {listing['root']} ({listing['source']})")
|
|
213
|
+
for folder in listing["folders"]:
|
|
214
|
+
print(f" {folder}")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def print_bindings(d: Daemon) -> None:
|
|
218
|
+
rows = [["NAME", "ADDON", "SETTINGS", "SECRETS", "ERROR"]]
|
|
219
|
+
for b in d.call("GET", "/v1/bindings"):
|
|
220
|
+
settings = ", ".join(f"{k}={v}" for k, v in b.get("settings", {}).items())
|
|
221
|
+
rows.append(
|
|
222
|
+
[
|
|
223
|
+
b["name"],
|
|
224
|
+
b.get("addon", "?"),
|
|
225
|
+
settings or "-",
|
|
226
|
+
", ".join(b.get("secrets", [])) or "-",
|
|
227
|
+
b.get("error", "")[:80],
|
|
228
|
+
]
|
|
229
|
+
)
|
|
230
|
+
table(rows)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def open_ui(*, print_only: bool) -> int:
|
|
234
|
+
"""The token travels in the URL fragment: browsers never send it to the server."""
|
|
235
|
+
url = f"{base_url()}/#token={read_token()}"
|
|
236
|
+
print(url)
|
|
237
|
+
if not print_only:
|
|
238
|
+
webbrowser.open(url)
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def installed_version() -> str:
|
|
243
|
+
"""This client's version, read without handlerd (a bug report needs it when handlerd is
|
|
244
|
+
down): the `handler-ai` wheel users install, else this package in a checkout."""
|
|
245
|
+
for distribution in ("handler-ai", "handler-cli"):
|
|
246
|
+
try:
|
|
247
|
+
return version(distribution)
|
|
248
|
+
except PackageNotFoundError:
|
|
249
|
+
pass
|
|
250
|
+
return "unknown"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def parser() -> argparse.ArgumentParser:
|
|
254
|
+
p = argparse.ArgumentParser(
|
|
255
|
+
prog="handler", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
|
256
|
+
)
|
|
257
|
+
p.add_argument("--version", action="version", version=f"handler {installed_version()}")
|
|
258
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
259
|
+
session_commands.add_parsers(sub, add_agent_options)
|
|
260
|
+
for name in ("terminal", "acp"):
|
|
261
|
+
s = sub.add_parser(name)
|
|
262
|
+
s.add_argument("access", metavar="AGENT|ACCOUNT|PROVIDER")
|
|
263
|
+
s.add_argument("scope", nargs="?", default=".", metavar="FOLDER")
|
|
264
|
+
add_agent_options(s)
|
|
265
|
+
session_commands.add_anyway_option(s)
|
|
266
|
+
for module in NOUNS.values():
|
|
267
|
+
module.add_parser(sub)
|
|
268
|
+
for name, help_text in (("binding", "addons with their settings and secrets"),
|
|
269
|
+
("knowledge", "knowledge folders")): # fmt: skip
|
|
270
|
+
noun = sub.add_parser(name, help=help_text)
|
|
271
|
+
noun.add_subparsers(dest=f"{name}_cmd", required=True).add_parser("list")
|
|
272
|
+
sub.add_parser("ui").add_argument("--print", action="store_true", help="only print the URL")
|
|
273
|
+
return p
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# nouns whose module parses and runs its own subcommands
|
|
277
|
+
NOUNS: dict[str, Any] = {
|
|
278
|
+
"agent": agent_commands,
|
|
279
|
+
"project": project_commands,
|
|
280
|
+
"account": account_commands,
|
|
281
|
+
"provider": provider_commands,
|
|
282
|
+
"secret": secret_commands,
|
|
283
|
+
"addon": addon_commands,
|
|
284
|
+
"image": image_commands,
|
|
285
|
+
"app": app_commands,
|
|
286
|
+
"automation": automation_commands,
|
|
287
|
+
"inbox": inbox_commands,
|
|
288
|
+
"daemon": daemon_commands,
|
|
289
|
+
"setup": setup,
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def dispatch(args: argparse.Namespace) -> int:
|
|
294
|
+
if args.cmd in NOUNS:
|
|
295
|
+
return NOUNS[args.cmd].dispatch(args)
|
|
296
|
+
if args.cmd == "ui":
|
|
297
|
+
return open_ui(print_only=args.print)
|
|
298
|
+
d = Daemon()
|
|
299
|
+
if args.cmd == "binding":
|
|
300
|
+
print_bindings(d)
|
|
301
|
+
elif args.cmd == "knowledge":
|
|
302
|
+
print_knowledge(d)
|
|
303
|
+
elif args.cmd in ("acp", "terminal"):
|
|
304
|
+
return (acp if args.cmd == "acp" else terminal)(d, args)
|
|
305
|
+
else:
|
|
306
|
+
return session_commands.dispatch(d, args)
|
|
307
|
+
return 0
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def main(argv: list[str] | None = None) -> int:
|
|
311
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
312
|
+
rest: list[str] = []
|
|
313
|
+
if argv[:1] == ["terminal"] and "--" in argv: # what follows `--` is the harness's
|
|
314
|
+
at = argv.index("--")
|
|
315
|
+
argv, rest = argv[:at], argv[at + 1 :]
|
|
316
|
+
args = parser().parse_args(argv)
|
|
317
|
+
args.args = rest
|
|
318
|
+
try:
|
|
319
|
+
return dispatch(args)
|
|
320
|
+
except ClientError as e:
|
|
321
|
+
print(f"handler: {e}", file=sys.stderr)
|
|
322
|
+
return 1
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
if __name__ == "__main__":
|
|
326
|
+
sys.exit(main())
|