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,128 @@
|
|
|
1
|
+
"""`handler addon …` — addons (contract G.5): a tool for the agent's sandbox (Playwright's browser,
|
|
2
|
+
the Shopify CLI) with the hosts, secrets and skills it needs. An agent gets one through a binding
|
|
3
|
+
(`handler binding list`).
|
|
4
|
+
|
|
5
|
+
handler addon list the installed addons: where from, commands, image, the settings
|
|
6
|
+
and secrets they need, errors, updates
|
|
7
|
+
handler addon catalog the addons handler ships, installed or not
|
|
8
|
+
handler addon add NAME|FOLDER|URL [--yes]
|
|
9
|
+
what it brings (its hosts, secrets, commands, MCP server,
|
|
10
|
+
skills), then, once you agree, install it and build its image:
|
|
11
|
+
NAME from the catalog, a FOLDER, an https:// .tar.gz archive
|
|
12
|
+
handler addon rm NAME an installed addon (refused while a binding uses it)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from . import image_commands
|
|
21
|
+
from .client import Daemon
|
|
22
|
+
from .output import table
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def add_parser(sub: Any) -> None:
|
|
26
|
+
addon = sub.add_parser("addon", help="install and remove addons", description=__doc__,
|
|
27
|
+
formatter_class=argparse.RawDescriptionHelpFormatter) # fmt: skip
|
|
28
|
+
s = addon.add_subparsers(dest="addon_cmd", required=True)
|
|
29
|
+
s.add_parser("list")
|
|
30
|
+
s.add_parser("catalog")
|
|
31
|
+
add = s.add_parser("add")
|
|
32
|
+
add.add_argument("source", metavar="NAME|FOLDER|URL")
|
|
33
|
+
add.add_argument("--yes", action="store_true", help="install without asking")
|
|
34
|
+
s.add_parser("rm").add_argument("name")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def source(arg: str) -> str:
|
|
38
|
+
"""What the API calls the source: catalog:NAME, an absolute folder path, or the URL."""
|
|
39
|
+
if arg.startswith(("https://", "http://")):
|
|
40
|
+
return arg
|
|
41
|
+
if "/" in arg or arg.startswith(".") or Path(arg).is_dir():
|
|
42
|
+
return str(Path(arg).resolve())
|
|
43
|
+
return f"catalog:{arg}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def describe(p: dict[str, Any]) -> str:
|
|
47
|
+
"""The trust screen, as text: everything the addon would bring."""
|
|
48
|
+
lines = [f"{p['name']}: {p.get('description') or ''}".rstrip(": "), f" from {p['source']}"]
|
|
49
|
+
if replaces := p.get("replaces"):
|
|
50
|
+
lines.append(f" replaces the {replaces} addon '{p['name']}'")
|
|
51
|
+
rows = [
|
|
52
|
+
("hosts", ", ".join(p.get("egress", []))),
|
|
53
|
+
("secrets", ", ".join(s["name"] for s in p.get("secretSlots", []))),
|
|
54
|
+
("settings", ", ".join(s["name"] for s in p.get("settings", []))),
|
|
55
|
+
("env", ", ".join(p.get("env", []))),
|
|
56
|
+
("commands", ", ".join(p.get("commands", []))),
|
|
57
|
+
("MCP", " ".join(p.get("mcp") or [])),
|
|
58
|
+
("skills", ", ".join(p.get("skills", []))),
|
|
59
|
+
]
|
|
60
|
+
lines += [f" {k:<8}{v}" for k, v in rows if v]
|
|
61
|
+
if image := p.get("image"):
|
|
62
|
+
lines.append(f" image {image}: its Containerfile runs code on this machine when built")
|
|
63
|
+
return "\n".join(lines)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def dispatch(args: argparse.Namespace) -> int: # noqa: PLR0911 — one branch per subcommand
|
|
67
|
+
d = Daemon()
|
|
68
|
+
if args.addon_cmd == "list":
|
|
69
|
+
print_addons(d)
|
|
70
|
+
return 0
|
|
71
|
+
if args.addon_cmd == "catalog":
|
|
72
|
+
rows = [["NAME", "INSTALLED", "DESCRIPTION"]]
|
|
73
|
+
for e in d.call("GET", "/v1/addon-catalog"):
|
|
74
|
+
rows.append([e["name"], "yes" if e["installed"] else "-", e.get("description", "")])
|
|
75
|
+
table(rows)
|
|
76
|
+
return 0
|
|
77
|
+
if args.addon_cmd == "rm":
|
|
78
|
+
d.call("DELETE", f"/v1/addons/{args.name}")
|
|
79
|
+
print(f"removed {args.name}")
|
|
80
|
+
return 0
|
|
81
|
+
seen = d.call("POST", "/v1/addons/preview", {"source": source(args.source)})
|
|
82
|
+
print(describe(seen))
|
|
83
|
+
if not args.yes:
|
|
84
|
+
if not sys.stdin.isatty():
|
|
85
|
+
print("not installed: run again with --yes to install it")
|
|
86
|
+
return 1
|
|
87
|
+
if input("\nInstall it? [y/N] ").strip().lower() not in ("y", "yes"):
|
|
88
|
+
return 1
|
|
89
|
+
installed = d.call(
|
|
90
|
+
"POST", "/v1/addons/install", {"source": seen["source"], "digest": seen["digest"]}
|
|
91
|
+
)
|
|
92
|
+
print(f"installed {installed['addon']['name']} in {installed['addon']['directory']}")
|
|
93
|
+
if build := installed.get("build"):
|
|
94
|
+
return image_commands.watch(d, build)
|
|
95
|
+
if seen.get("image"):
|
|
96
|
+
print(
|
|
97
|
+
"another build is running: build it later with "
|
|
98
|
+
f"`handler image rebuild addon:{seen['name']}`"
|
|
99
|
+
)
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def print_addons(d: Daemon) -> None:
|
|
104
|
+
rows = [["NAME", "FROM", "COMMANDS", "VERSION", "BUILT", "SLOTS", "ERROR"]]
|
|
105
|
+
for a in d.call("GET", "/v1/addons"):
|
|
106
|
+
if a.get("image"):
|
|
107
|
+
version = a.get("version", "-") if a["imagePresent"] else "not built"
|
|
108
|
+
else:
|
|
109
|
+
version = "(no image)"
|
|
110
|
+
slots = [
|
|
111
|
+
*(s["name"] for s in a.get("settings", [])),
|
|
112
|
+
*(f"{s['name']} (secret)" for s in a.get("secretSlots", [])),
|
|
113
|
+
]
|
|
114
|
+
origin = "built-in" if a.get("builtin") else a.get("source", "yours")
|
|
115
|
+
if a.get("updateAvailable"):
|
|
116
|
+
origin += " (update: handler addon add " + a["name"] + ")"
|
|
117
|
+
rows.append(
|
|
118
|
+
[
|
|
119
|
+
a["name"],
|
|
120
|
+
origin,
|
|
121
|
+
", ".join(a.get("commands", [])) or "-",
|
|
122
|
+
version,
|
|
123
|
+
a.get("builtAt", "-")[:16].replace("T", " "),
|
|
124
|
+
", ".join(slots) or "-",
|
|
125
|
+
a.get("error", "")[:80],
|
|
126
|
+
]
|
|
127
|
+
)
|
|
128
|
+
table(rows)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""`handler agent …` — every agent, of projects and outside them (contract D.1, D.8). Agents of
|
|
2
|
+
projects are created and edited in the web UI; agents outside projects are made by `handler run`,
|
|
3
|
+
`terminal` and `acp` in a folder.
|
|
4
|
+
|
|
5
|
+
handler agent list every agent, most recently used first, with its id
|
|
6
|
+
handler agent show ID its settings, models, folder and live sandboxes
|
|
7
|
+
handler agent rm ID delete an agent outside projects (stops its sandboxes)
|
|
8
|
+
handler agent network ID [log-only|enforce]
|
|
9
|
+
its network mode and recent connections (allowed, denied,
|
|
10
|
+
unlisted: let through in log-only, enforcing would deny); with
|
|
11
|
+
a mode, switch a filtered agent to it, live (log-only: every
|
|
12
|
+
host goes through and is logged, to learn which it needs)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .client import Daemon
|
|
19
|
+
from .output import table
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def add_parser(sub: Any) -> None:
|
|
23
|
+
agent = sub.add_parser("agent", help="agents", description=__doc__,
|
|
24
|
+
formatter_class=argparse.RawDescriptionHelpFormatter) # fmt: skip
|
|
25
|
+
a = agent.add_subparsers(dest="agent_cmd", required=True)
|
|
26
|
+
a.add_parser("list")
|
|
27
|
+
a.add_parser("show").add_argument("id")
|
|
28
|
+
a.add_parser("rm").add_argument("id")
|
|
29
|
+
network = a.add_parser("network")
|
|
30
|
+
network.add_argument("id")
|
|
31
|
+
network.add_argument("mode", nargs="?", choices=["log-only", "enforce"])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def dispatch(args: argparse.Namespace) -> int:
|
|
35
|
+
d = Daemon()
|
|
36
|
+
if args.agent_cmd == "list":
|
|
37
|
+
print_agents(d)
|
|
38
|
+
elif args.agent_cmd == "show":
|
|
39
|
+
show(d, d.call("GET", f"/v1/agents/{args.id}"))
|
|
40
|
+
elif args.agent_cmd == "rm":
|
|
41
|
+
d.call("DELETE", f"/v1/agents/{args.id}")
|
|
42
|
+
print(f"{args.id}: removed")
|
|
43
|
+
elif args.mode:
|
|
44
|
+
w = d.call("PATCH", f"/v1/agents/{args.id}", {"egress": args.mode})
|
|
45
|
+
print(f"agent {w['id']}: network {network_mode(w)}")
|
|
46
|
+
else:
|
|
47
|
+
print_network(d, d.call("GET", f"/v1/agents/{args.id}"))
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def print_agents(d: Daemon) -> None:
|
|
52
|
+
projects = {p["id"]: p["name"] for p in d.call("GET", "/v1/projects")}
|
|
53
|
+
rows = [["ID", "PROJECT", "NAME", "RUNS", "SETTINGS", "LIVE", "FOLDER"]]
|
|
54
|
+
listed = d.call("GET", "/v1/agents")
|
|
55
|
+
listed.sort(key=lambda a: a.get("lastUsedAt") or a["createdAt"], reverse=True)
|
|
56
|
+
for a in listed:
|
|
57
|
+
rows.append(
|
|
58
|
+
[
|
|
59
|
+
a["id"],
|
|
60
|
+
projects.get(a.get("projectId") or "", "(ad hoc)"),
|
|
61
|
+
(a.get("name") or "-") + (f" (built-in {a['origin']})" if a.get("origin") else ""),
|
|
62
|
+
runs(a),
|
|
63
|
+
describe_agent(a, runs_too=False),
|
|
64
|
+
str(len(a["connections"])),
|
|
65
|
+
a.get("scope") or "(a folder per session)",
|
|
66
|
+
]
|
|
67
|
+
)
|
|
68
|
+
table(rows)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def show(d: Daemon, a: dict[str, Any]) -> None:
|
|
72
|
+
projects = {p["id"]: p["name"] for p in d.call("GET", "/v1/projects")}
|
|
73
|
+
project = projects.get(a.get("projectId") or "", "ad hoc")
|
|
74
|
+
print(f"{a.get('name') or a['models'][0]['harness']} · {a['id']} · {project}")
|
|
75
|
+
if a.get("description"):
|
|
76
|
+
print(f" {a['description']}")
|
|
77
|
+
print(f" folder {a.get('scope') or '(a folder per session)'}")
|
|
78
|
+
for i, m in enumerate(a["models"]):
|
|
79
|
+
print(f" {'runs' if i == 0 else '':<12} {model_label(m)}")
|
|
80
|
+
print(f" network {network_mode(a)}")
|
|
81
|
+
if a.get("allow"):
|
|
82
|
+
print(f" its hosts {', '.join(a['allow'])}")
|
|
83
|
+
print(f" permissions {a['permissions']}")
|
|
84
|
+
for key, label in (("bindings", "bindings"), ("knowledge", "knowledge"), ("skills", "skills")):
|
|
85
|
+
if a.get(key):
|
|
86
|
+
print(f" {label:<12} {', '.join(a[key])}")
|
|
87
|
+
if a.get("confinement", "strict") != "strict":
|
|
88
|
+
print(f" containers {'yes' if a['confinement'] == 'nested' else a['confinement']}")
|
|
89
|
+
if a.get("display"):
|
|
90
|
+
print(" display yes")
|
|
91
|
+
print(f" live {len(a['connections'])} sandbox(es)")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def network_mode(a: dict[str, Any]) -> str:
|
|
95
|
+
"""`filtered, log-only` / `filtered, enforce` / `open` / `none`."""
|
|
96
|
+
if a["network"] != "filtered":
|
|
97
|
+
return a["network"]
|
|
98
|
+
return f"filtered, {a.get('egress') or 'enforce'}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def print_network(d: Daemon, a: dict[str, Any]) -> None:
|
|
102
|
+
print(f"network {network_mode(a)}" + (f" + {', '.join(a['allow'])}" if a.get("allow") else ""))
|
|
103
|
+
events = d.call("GET", f"/v1/agents/{a['id']}/egress-events")
|
|
104
|
+
if not events:
|
|
105
|
+
print("no connection yet")
|
|
106
|
+
return
|
|
107
|
+
rows = [["TIME", "DECISION", "HOST", "CONNECTION"]]
|
|
108
|
+
rows += [[e["t"][11:19], e["decision"], e["host"], e["connection"]] for e in events]
|
|
109
|
+
table(rows)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def runs(w: dict[str, Any]) -> str:
|
|
113
|
+
"""Its models, the default first: `claude-code · account claude-pro`, `codex · provider
|
|
114
|
+
llamacpp · qwen3`."""
|
|
115
|
+
return " | ".join(model_label(m) for m in w["models"])
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def model_label(m: dict[str, Any]) -> str:
|
|
119
|
+
access = f"account {m['account']}" if m.get("account") else f"provider {m.get('provider')}"
|
|
120
|
+
return " · ".join([m["harness"], access, *([m["model"]] if m.get("model") else [])])
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def describe_agent(w: dict[str, Any], *, runs_too: bool = True) -> str:
|
|
124
|
+
"""Its settings in one line, the defaults left out: `runs=… network=filtered/log-only
|
|
125
|
+
+2 hosts bindings=… knowledge=… containers display`."""
|
|
126
|
+
network = w["network"] + (f"/{w['egress']}" if w.get("egress") == "log-only" else "")
|
|
127
|
+
parts = [f"runs={runs(w)}"] if runs_too else []
|
|
128
|
+
parts.append(f"network={network}" + (f" +{len(w['allow'])} hosts" if w.get("allow") else ""))
|
|
129
|
+
if w["permissions"] != "bypass":
|
|
130
|
+
parts.append(f"permissions={w['permissions']}")
|
|
131
|
+
if w.get("bindings"):
|
|
132
|
+
parts.append(f"bindings={','.join(w['bindings'])}")
|
|
133
|
+
if w.get("knowledge"):
|
|
134
|
+
parts.append(f"knowledge={','.join(w['knowledge'])}")
|
|
135
|
+
if w.get("confinement", "strict") != "strict":
|
|
136
|
+
parts.append("containers" if w["confinement"] == "nested" else w["confinement"])
|
|
137
|
+
if w.get("display"):
|
|
138
|
+
parts.append("display")
|
|
139
|
+
return " ".join(parts)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""`handler app …` — mini apps (contract H, docs/dev/design/miniapps.md): small web apps written by
|
|
2
|
+
an agent, run by handlerd on demand, each at its own origin (http://<name>.localhost:<port>/).
|
|
3
|
+
|
|
4
|
+
handler app list registered apps, their versions and state
|
|
5
|
+
handler app add [DIR] register the app whose app.toml is in DIR (default: here)
|
|
6
|
+
handler app deploy NAME [--yes] install what its folder holds now: tests, health check on a
|
|
7
|
+
copy of the data, then switch (asks first when it may reach
|
|
8
|
+
more than before)
|
|
9
|
+
handler app rollback NAME back to the previous version and its data as it was then
|
|
10
|
+
handler app open NAME [--print] open it in its own window (a one-time link): Firefox with
|
|
11
|
+
the handler-apps profile, else Chromium's app mode, else the
|
|
12
|
+
default browser (HANDLER_APP_BROWSER=firefox|chromium|default)
|
|
13
|
+
handler app shortcut NAME KEYS|--none the GNOME keyboard shortcut that opens it, e.g. "<Super>n"
|
|
14
|
+
handler app stop NAME stop its backend now (it starts again when used)
|
|
15
|
+
handler app log NAME its last deploys and its backend's output
|
|
16
|
+
handler app secret NAME SECRET [--unset]
|
|
17
|
+
set a secret the app declares (asked for, or read from stdin);
|
|
18
|
+
the backend restarts with it
|
|
19
|
+
handler app rm NAME unregister it; its data folder is kept
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import getpass
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from . import app_window
|
|
30
|
+
from .client import ClientError, Daemon
|
|
31
|
+
from .output import table
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def add_parser(sub: Any) -> None:
|
|
35
|
+
app = sub.add_parser("app", help="mini apps", description=__doc__,
|
|
36
|
+
formatter_class=argparse.RawDescriptionHelpFormatter) # fmt: skip
|
|
37
|
+
a = app.add_subparsers(dest="app_cmd", required=True)
|
|
38
|
+
a.add_parser("list")
|
|
39
|
+
a.add_parser("add").add_argument("dir", nargs="?", default=".")
|
|
40
|
+
d = a.add_parser("deploy")
|
|
41
|
+
d.add_argument("name")
|
|
42
|
+
d.add_argument("--yes", action="store_true", help="do not ask about new needs")
|
|
43
|
+
o = a.add_parser("open")
|
|
44
|
+
o.add_argument("name")
|
|
45
|
+
o.add_argument("--print", action="store_true", help="only print the link")
|
|
46
|
+
sc = a.add_parser("shortcut")
|
|
47
|
+
sc.add_argument("name")
|
|
48
|
+
keys = sc.add_mutually_exclusive_group(required=True)
|
|
49
|
+
keys.add_argument("keys", nargs="?", help='GNOME accelerator, e.g. "<Super>n"')
|
|
50
|
+
keys.add_argument("--none", action="store_true", help="remove its shortcut")
|
|
51
|
+
sec = a.add_parser("secret")
|
|
52
|
+
sec.add_argument("name")
|
|
53
|
+
sec.add_argument("secret")
|
|
54
|
+
sec.add_argument("--unset", action="store_true")
|
|
55
|
+
for name in ("rollback", "stop", "log", "rm"):
|
|
56
|
+
a.add_parser(name).add_argument("name")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def describe(app: dict[str, Any]) -> str:
|
|
60
|
+
current = app.get("current")
|
|
61
|
+
installed = current["version"] if current else "not deployed"
|
|
62
|
+
lines = [f"{app['name']} {installed} ({app['state']}) — {app['url']}"]
|
|
63
|
+
source = app["source"]
|
|
64
|
+
if source.get("error"):
|
|
65
|
+
lines.append(f" source: {source['error']}")
|
|
66
|
+
elif not current or source.get("version") != current["version"]:
|
|
67
|
+
lines.append(f" source: {source.get('version')} ready to deploy")
|
|
68
|
+
if missing := [s["name"] for s in app.get("secrets", []) if not s["set"]]:
|
|
69
|
+
lines.append(f" secrets not set: {', '.join(missing)} (handler app secret)")
|
|
70
|
+
if app.get("shortcut"):
|
|
71
|
+
lines.append(f" shortcut: {app['shortcut']}")
|
|
72
|
+
if app.get("error"):
|
|
73
|
+
lines.append(f" error: {app['error']}")
|
|
74
|
+
return "\n".join(lines)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def list_apps(d: Daemon, _: argparse.Namespace) -> int:
|
|
78
|
+
rows = [["NAME", "VERSION", "STATE", "SOURCE", "URL"]]
|
|
79
|
+
for app in d.call("GET", "/v1/apps"):
|
|
80
|
+
current = app.get("current")
|
|
81
|
+
source = app["source"].get("version") or "invalid"
|
|
82
|
+
rows.append(
|
|
83
|
+
[
|
|
84
|
+
app["name"],
|
|
85
|
+
current["version"] if current else "-",
|
|
86
|
+
app["state"],
|
|
87
|
+
source if not current or source != current["version"] else "=",
|
|
88
|
+
app["url"],
|
|
89
|
+
]
|
|
90
|
+
)
|
|
91
|
+
table(rows)
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def add(d: Daemon, args: argparse.Namespace) -> int:
|
|
96
|
+
print(describe(d.call("POST", "/v1/apps", {"sourceDir": os.path.abspath(args.dir)})))
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def deploy(d: Daemon, args: argparse.Namespace) -> int:
|
|
101
|
+
app = d.call("GET", f"/v1/apps/{args.name}")
|
|
102
|
+
if app["source"].get("error"):
|
|
103
|
+
raise ClientError(app["source"]["error"])
|
|
104
|
+
if (added := app["source"].get("addedNeeds")) and not args.yes:
|
|
105
|
+
print(f"{args.name} {app['source']['version']} may reach more than before:")
|
|
106
|
+
for need in added:
|
|
107
|
+
print(f" + {need}")
|
|
108
|
+
if input("Deploy? [y/N] ").strip().lower() != "y":
|
|
109
|
+
return 1
|
|
110
|
+
print(f"deploying {args.name} {app['source']['version']} (tests, health check)…")
|
|
111
|
+
print(describe(d.call("POST", f"/v1/apps/{args.name}/deploy")))
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def open_app(d: Daemon, args: argparse.Namespace) -> int:
|
|
116
|
+
url = d.call("POST", f"/v1/apps/{args.name}/open")["url"]
|
|
117
|
+
print(url)
|
|
118
|
+
if not args.print:
|
|
119
|
+
app_window.open_window(url)
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def shortcut(d: Daemon, args: argparse.Namespace) -> int:
|
|
124
|
+
keys = None if args.none else args.keys
|
|
125
|
+
app = d.call("PUT", f"/v1/apps/{args.name}/shortcut", {"shortcut": keys})
|
|
126
|
+
print(f"{args.name}: {app.get('shortcut') or 'no shortcut'}")
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def log(d: Daemon, args: argparse.Namespace) -> int:
|
|
131
|
+
print(d.call("GET", f"/v1/apps/{args.name}/log")["text"], end="")
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def secret(d: Daemon, args: argparse.Namespace) -> int:
|
|
136
|
+
path = f"/v1/apps/{args.name}/secrets/{args.secret}"
|
|
137
|
+
if args.unset:
|
|
138
|
+
app = d.call("DELETE", path)
|
|
139
|
+
else:
|
|
140
|
+
prompt = f"{args.secret} for {args.name}: "
|
|
141
|
+
value = getpass.getpass(prompt) if sys.stdin.isatty() else sys.stdin.readline()
|
|
142
|
+
app = d.call("PUT", path, {"value": value.strip()})
|
|
143
|
+
state = {s["name"]: s["set"] for s in app.get("secrets", [])}
|
|
144
|
+
print(f"{args.secret}: {'set' if state.get(args.secret) else 'not set'}")
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def remove(d: Daemon, args: argparse.Namespace) -> int:
|
|
149
|
+
d.call("DELETE", f"/v1/apps/{args.name}")
|
|
150
|
+
print(f"{args.name}: removed (its data folder is kept)")
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def action(d: Daemon, args: argparse.Namespace) -> int:
|
|
155
|
+
"""rollback, stop: a POST that answers the app."""
|
|
156
|
+
print(describe(d.call("POST", f"/v1/apps/{args.name}/{args.app_cmd}")))
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
COMMANDS: dict[str, Callable[[Daemon, argparse.Namespace], int]] = {
|
|
161
|
+
"list": list_apps,
|
|
162
|
+
"add": add,
|
|
163
|
+
"deploy": deploy,
|
|
164
|
+
"open": open_app,
|
|
165
|
+
"log": log,
|
|
166
|
+
"secret": secret,
|
|
167
|
+
"shortcut": shortcut,
|
|
168
|
+
"rm": remove,
|
|
169
|
+
"rollback": action,
|
|
170
|
+
"stop": action,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def dispatch(args: argparse.Namespace) -> int:
|
|
175
|
+
return COMMANDS[args.app_cmd](Daemon(), args)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Opening a mini app in its own window (docs/dev/design/miniapps.md §5.1).
|
|
2
|
+
|
|
3
|
+
Firefox first (a dedicated profile, `handler-apps`: apps' cookies and storage stay out of normal
|
|
4
|
+
browsing, and its toolbars are hidden, since Firefox has no `--app` mode), then Chromium's app
|
|
5
|
+
mode, then the default browser. `HANDLER_APP_BROWSER` (firefox · chromium · default) forces one.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import webbrowser
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
# the window's app id: GNOME groups app windows apart from the browser
|
|
15
|
+
WINDOW_CLASS = "handler-apps"
|
|
16
|
+
|
|
17
|
+
USER_JS = """\
|
|
18
|
+
// handler: the profile mini apps open in (handler app open). Rewritten by handler.
|
|
19
|
+
user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", true);
|
|
20
|
+
user_pref("browser.shell.checkDefaultBrowser", false);
|
|
21
|
+
user_pref("browser.startup.homepage_override.mstone", "ignore");
|
|
22
|
+
user_pref("browser.aboutwelcome.enabled", false);
|
|
23
|
+
user_pref("browser.startup.page", 0);
|
|
24
|
+
user_pref("browser.sessionstore.resume_from_crash", false);
|
|
25
|
+
user_pref("browser.tabs.warnOnClose", false);
|
|
26
|
+
user_pref("datareporting.policy.dataSubmissionEnabled", false);
|
|
27
|
+
user_pref("toolkit.telemetry.reportingpolicy.firstRun", false);
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
USER_CHROME = """\
|
|
31
|
+
/* handler: an app window, not a browser window. Rewritten by handler. */
|
|
32
|
+
#TabsToolbar, #nav-bar, #PersonalToolbar, #titlebar { visibility: collapse !important; }
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
CHROMIUMS = ("chromium", "chromium-browser", "google-chrome", "google-chrome-stable")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def profiles_dir() -> Path:
|
|
39
|
+
xdg = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
|
|
40
|
+
return Path(os.environ.get("HANDLER_DATA_DIR", xdg / "handler-ai"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def firefox_profile(root: Path) -> Path:
|
|
44
|
+
"""The `handler-apps` Firefox profile, created or refreshed (only handler's two files)."""
|
|
45
|
+
profile = root / "firefox-apps"
|
|
46
|
+
(profile / "chrome").mkdir(parents=True, exist_ok=True)
|
|
47
|
+
(profile / "user.js").write_text(USER_JS)
|
|
48
|
+
(profile / "chrome" / "userChrome.css").write_text(USER_CHROME)
|
|
49
|
+
return profile
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def argv(url: str, browser: str, root: Path) -> list[str] | None:
|
|
53
|
+
"""The command opening `url` in an app window; None: the default browser."""
|
|
54
|
+
if browser == "firefox" and (firefox := shutil.which("firefox")):
|
|
55
|
+
profile = firefox_profile(root)
|
|
56
|
+
return [firefox, "--profile", str(profile), "--name", WINDOW_CLASS, "--new-window", url]
|
|
57
|
+
if browser == "chromium" and (
|
|
58
|
+
chromium := next(filter(None, map(shutil.which, CHROMIUMS)), None)
|
|
59
|
+
):
|
|
60
|
+
data = root / "chromium-apps"
|
|
61
|
+
return [chromium, f"--user-data-dir={data}", f"--class={WINDOW_CLASS}", f"--app={url}"]
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def choose() -> str:
|
|
66
|
+
forced = os.environ.get("HANDLER_APP_BROWSER")
|
|
67
|
+
if forced:
|
|
68
|
+
return forced
|
|
69
|
+
if shutil.which("firefox"):
|
|
70
|
+
return "firefox"
|
|
71
|
+
return "chromium" if any(shutil.which(c) for c in CHROMIUMS) else "default"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def open_window(url: str) -> None:
|
|
75
|
+
command = argv(url, choose(), profiles_dir())
|
|
76
|
+
if command is None:
|
|
77
|
+
webbrowser.open(url)
|
|
78
|
+
return
|
|
79
|
+
subprocess.Popen(
|
|
80
|
+
command,
|
|
81
|
+
stdin=subprocess.DEVNULL,
|
|
82
|
+
stdout=subprocess.DEVNULL,
|
|
83
|
+
stderr=subprocess.DEVNULL,
|
|
84
|
+
start_new_session=True, # outlives `handler` (a keyboard shortcut runs it and returns)
|
|
85
|
+
)
|