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
@@ -0,0 +1,129 @@
1
+ """Daemon settings: where things live and how it listens. All overridable by environment.
2
+
3
+ HANDLER_PORT listening port on 127.0.0.1 (default 7430)
4
+ HANDLER_CONFIG_DIR token file (default ~/.config/handler-ai)
5
+ HANDLER_STATE_DIR agents, sessions, apps (default ~/.local/state/handler-ai)
6
+ HANDLER_DATA_DIR the secret store, mini apps' versions, data
7
+ (default ~/.local/share/handler-ai)
8
+ HANDLER_ACCOUNTS_DIR accounts (harness logins and keys) (default <config>/accounts)
9
+ HANDLER_PROVIDERS_DIR providers (endpoints, keys, models) (default <config>/providers)
10
+ HANDLER_BINDINGS_DIR bindings (addon + instance + credentials) (default <config>/bindings)
11
+ HANDLER_AUTOMATIONS_DIR automations (one folder each, I.1) (default <config>/automations)
12
+ HANDLER_CACHE_DIR the toolbox's global caches (default ~/.cache/handler-ai)
13
+ HANDLER_RUNTIME_DIR mini app backends' sockets
14
+ (default $XDG_RUNTIME_DIR/handler-ai, else <state>)
15
+ HANDLER_KNOWLEDGE_DIR knowledge root: folders agents can be given; when unset, the root
16
+ set from the web UI (<config>/knowledge.toml), else ~/knowledge
17
+ HANDLER_ALLOWED_ORIGINS extra browser origins, comma-separated (the daemon's own is allowed)
18
+ HANDLER_TEMPLATES_DIR starting points for providers (default: this package's
19
+ data/templates)
20
+ HANDLER_SESSION_IDLE_TIMEOUT seconds before an unattached, idle session is stopped (default 1800)
21
+ HANDLER_SESSION_MAX_ATTACH clients attached to one session at once; beyond, the oldest is
22
+ closed (default 8)
23
+ HANDLER_APP_IDLE_TIMEOUT seconds before an unused mini app's backend is stopped (default 600)
24
+ HANDLER_TERMINAL the terminal an account's login window opens in, a command line the
25
+ command is appended to (default: the first found of
26
+ terminal.TERMINALS)
27
+ """
28
+
29
+ import os
30
+ from dataclasses import dataclass
31
+ from pathlib import Path
32
+
33
+ from handler_contracts import DEFAULT_PORT, TOKEN_FILE_NAME
34
+
35
+ HOST = "127.0.0.1" # invariant 5: loopback only
36
+ TEMPLATES = Path(__file__).resolve().parent / "data" / "templates" # platform data, read-only
37
+ # the built web UI (scripts/build-web-ui.sh writes it here; the wheel ships it)
38
+ WEB_UI = Path(__file__).resolve().parent / "web-ui"
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Sources:
43
+ """The folders an agent's account (accounts/<name>.toml) or provider
44
+ (providers/<name>.toml) is read from, and where accounts' login files are kept."""
45
+
46
+ accounts: Path
47
+ providers: Path
48
+ credentials: Path | None = None # <data>/accounts; None: logins are not looked for
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Settings:
53
+ port: int
54
+ config_dir: Path
55
+ state_dir: Path
56
+ bindings_dir: Path
57
+ allowed_origins: frozenset[str]
58
+ web_ui_dir: Path
59
+ session_idle_timeout: float = 1800.0
60
+ session_max_attach: int = 8
61
+ data_dir: Path = Path.home() / ".local" / "share" / "handler-ai"
62
+ app_idle_timeout: float = 600.0
63
+ knowledge_dir: Path | None = None # forced by HANDLER_KNOWLEDGE_DIR; see knowledge.root
64
+ templates_dir: Path = TEMPLATES
65
+ accounts_dir: Path = Path.home() / ".config" / "handler-ai" / "accounts"
66
+ providers_dir: Path = Path.home() / ".config" / "handler-ai" / "providers"
67
+ skills_dir: Path = Path.home() / ".config" / "handler-ai" / "skills" # skill library (D.17)
68
+ automations_dir: Path = Path.home() / ".config" / "handler-ai" / "automations" # I.1
69
+ cache_dir: Path = Path.home() / ".cache" / "handler-ai" # the toolbox's global caches
70
+ # sockets: short paths, tmpfs
71
+ runtime_dir: Path = Path.home() / ".local" / "state" / "handler-ai"
72
+
73
+ @property
74
+ def host(self) -> str:
75
+ return HOST
76
+
77
+ @property
78
+ def sources(self) -> Sources:
79
+ """Where an agent's account or provider is looked for."""
80
+ return Sources(self.accounts_dir, self.providers_dir, self.data_dir / "accounts")
81
+
82
+ @property
83
+ def token_file(self) -> Path:
84
+ return self.config_dir / TOKEN_FILE_NAME
85
+
86
+ @property
87
+ def base_url(self) -> str:
88
+ return f"http://{HOST}:{self.port}"
89
+
90
+ @classmethod
91
+ def from_env(cls) -> "Settings":
92
+ home = Path.home()
93
+ xdg_config = Path(os.environ.get("XDG_CONFIG_HOME", home / ".config"))
94
+ xdg_state = Path(os.environ.get("XDG_STATE_HOME", home / ".local" / "state"))
95
+ xdg_data = Path(os.environ.get("XDG_DATA_HOME", home / ".local" / "share"))
96
+ xdg_cache = Path(os.environ.get("XDG_CACHE_HOME", home / ".cache"))
97
+ config_dir = Path(os.environ.get("HANDLER_CONFIG_DIR", xdg_config / "handler-ai"))
98
+ port = int(os.environ.get("HANDLER_PORT", DEFAULT_PORT))
99
+ state_dir = Path(os.environ.get("HANDLER_STATE_DIR", xdg_state / "handler-ai"))
100
+ xdg_runtime = os.environ.get("XDG_RUNTIME_DIR")
101
+ runtime_dir = Path(xdg_runtime) / "handler-ai" if xdg_runtime else state_dir
102
+ own = {f"http://{HOST}:{port}", f"http://localhost:{port}"}
103
+ extra = {
104
+ o.strip() for o in os.environ.get("HANDLER_ALLOWED_ORIGINS", "").split(",") if o.strip()
105
+ }
106
+ return cls(
107
+ port=port,
108
+ config_dir=config_dir,
109
+ state_dir=state_dir,
110
+ bindings_dir=Path(os.environ.get("HANDLER_BINDINGS_DIR", config_dir / "bindings")),
111
+ accounts_dir=Path(os.environ.get("HANDLER_ACCOUNTS_DIR", config_dir / "accounts")),
112
+ providers_dir=Path(os.environ.get("HANDLER_PROVIDERS_DIR", config_dir / "providers")),
113
+ skills_dir=Path(os.environ.get("HANDLER_SKILLS_DIR", config_dir / "skills")),
114
+ automations_dir=Path(
115
+ os.environ.get("HANDLER_AUTOMATIONS_DIR", config_dir / "automations")
116
+ ),
117
+ allowed_origins=frozenset(own | extra),
118
+ web_ui_dir=WEB_UI,
119
+ session_idle_timeout=float(os.environ.get("HANDLER_SESSION_IDLE_TIMEOUT", "1800")),
120
+ session_max_attach=max(1, int(os.environ.get("HANDLER_SESSION_MAX_ATTACH", "8"))),
121
+ data_dir=Path(os.environ.get("HANDLER_DATA_DIR", xdg_data / "handler-ai")),
122
+ cache_dir=Path(os.environ.get("HANDLER_CACHE_DIR", xdg_cache / "handler-ai")),
123
+ runtime_dir=Path(os.environ.get("HANDLER_RUNTIME_DIR", runtime_dir)),
124
+ app_idle_timeout=float(os.environ.get("HANDLER_APP_IDLE_TIMEOUT", "600")),
125
+ knowledge_dir=Path(forced)
126
+ if (forced := os.environ.get("HANDLER_KNOWLEDGE_DIR"))
127
+ else None,
128
+ templates_dir=Path(os.environ.get("HANDLER_TEMPLATES_DIR", TEMPLATES)),
129
+ )
@@ -0,0 +1,80 @@
1
+ """GNOME keyboard shortcuts that open mini apps (H.7): one custom keybinding per app, at
2
+ …/custom-keybindings/handler-<app>/, running `handler app open <app>`. Set through `gsettings`, as
3
+ the daemon's user, in the desktop session it runs in; the person's own keybindings are left alone.
4
+ """
5
+
6
+ import re
7
+ import shlex
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ SCHEMA = "org.gnome.settings-daemon.plugins.media-keys"
14
+ BASE = "/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/"
15
+ TIMEOUT = 10
16
+
17
+
18
+ class ShortcutError(RuntimeError):
19
+ pass
20
+
21
+
22
+ def entry_path(app: str) -> str:
23
+ return f"{BASE}handler-{app}/"
24
+
25
+
26
+ def handler_command(app: str) -> str:
27
+ """What the shortcut runs: this install's `handler`, next to the daemon's interpreter."""
28
+ sibling = Path(sys.executable).parent / "handler"
29
+ return shlex.join([str(sibling) if sibling.exists() else "handler", "app", "open", app])
30
+
31
+
32
+ def _gvariant_string(s: str) -> str:
33
+ return "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'"
34
+
35
+
36
+ def _gsettings(*args: str) -> str:
37
+ if not shutil.which("gsettings"):
38
+ raise ShortcutError("gsettings not found: keyboard shortcuts need GNOME")
39
+ try:
40
+ r = subprocess.run(
41
+ ["gsettings", *args], capture_output=True, text=True, timeout=TIMEOUT, check=False
42
+ )
43
+ except subprocess.TimeoutExpired:
44
+ raise ShortcutError("gsettings did not answer (no desktop session?)") from None
45
+ if r.returncode:
46
+ raise ShortcutError(r.stderr.strip() or f"gsettings exited {r.returncode}")
47
+ return r.stdout.strip()
48
+
49
+
50
+ def _entries() -> list[str]:
51
+ out = _gsettings("get", SCHEMA, "custom-keybindings")
52
+ return [m.replace("\\'", "'") for m in re.findall(r"'((?:[^'\\]|\\.)*)'", out)]
53
+
54
+
55
+ def _set_entries(paths: list[str]) -> None:
56
+ value = "[" + ", ".join(_gvariant_string(p) for p in paths) + "]" if paths else "@as []"
57
+ _gsettings("set", SCHEMA, "custom-keybindings", value)
58
+
59
+
60
+ def install(app: str, binding: str) -> None:
61
+ """Bind `binding` (GNOME accelerator syntax, e.g. <Super>n) to opening `app`."""
62
+ path = entry_path(app)
63
+ relocatable = f"{SCHEMA}.custom-keybinding:{path}"
64
+ for key, value in (
65
+ ("name", f"handler: {app}"),
66
+ ("command", handler_command(app)),
67
+ ("binding", binding),
68
+ ):
69
+ _gsettings("set", relocatable, key, _gvariant_string(value))
70
+ entries = _entries()
71
+ if path not in entries:
72
+ _set_entries([*entries, path])
73
+
74
+
75
+ def remove(app: str) -> None:
76
+ path = entry_path(app)
77
+ entries = _entries()
78
+ if path in entries:
79
+ _set_entries([p for p in entries if p != path])
80
+ _gsettings("reset-recursively", f"{SCHEMA}.custom-keybinding:{path}")
@@ -0,0 +1,88 @@
1
+ """What handlerd finds in a skill's files, no model involved (D.17): what a model reading the skill
2
+ cannot be talked out of. Shown on the skill's page and given to the agents reviewing it (D.18).
3
+
4
+ executables files with the executable bit
5
+ binaries files that are not UTF-8 text
6
+ hosts URLs in its text, by host
7
+ hiddenText invisible or bidirectional Unicode (format characters: zero-width, bidi
8
+ controls, tags), which can hide instructions from a reader
9
+ blobs long base64 or hex runs, which can hide a payload
10
+ """
11
+
12
+ import re
13
+ import unicodedata
14
+ from pathlib import Path
15
+
16
+ from handler_contracts import ScanHit, SkillScan
17
+
18
+ from . import skills
19
+
20
+ MAX_HITS = 100 # per kind
21
+ URL = re.compile(r"\b[a-zA-Z][a-zA-Z0-9+.-]*://([^/\s\"'<>()\[\]`]+)")
22
+ BLOB = re.compile(r"[A-Za-z0-9+/]{200,}={0,2}|\b[0-9a-fA-F]{128,}\b")
23
+ BOM = "\ufeff" # a byte order mark at the start of a file is not hidden text
24
+
25
+
26
+ def scan(folder: Path) -> SkillScan:
27
+ """The scan of a skill's SKILL.md and other files (the ones agents get: no hidden files, no
28
+ symbolic links)."""
29
+ executables, binaries = [], []
30
+ hosts: list[ScanHit] = []
31
+ hidden: list[ScanHit] = []
32
+ blobs: list[ScanHit] = []
33
+ for f in [skills.SkillFile(skills.SKILL_FILE, 0, False), *skills.files_of(folder)]:
34
+ path = folder / f.path
35
+ if f.executable or skills.executable(path.stat().st_mode):
36
+ executables.append(f.path)
37
+ try:
38
+ text = path.read_bytes().decode()
39
+ except UnicodeDecodeError:
40
+ binaries.append(f.path)
41
+ continue
42
+ except OSError:
43
+ continue
44
+ for number, line in enumerate(text.splitlines(), 1):
45
+ for match in URL.finditer(line):
46
+ host = match.group(1).rsplit("@", 1)[-1].lower()
47
+ hosts.append(ScanHit(path=f.path, line=number, text=host))
48
+ if any(_hidden(c) for c in line.lstrip(BOM) if c not in "\t"):
49
+ hidden.append(ScanHit(path=f.path, line=number, text=_shown(line)))
50
+ if (blob := BLOB.search(line)) is not None:
51
+ blobs.append(ScanHit(path=f.path, line=number, text=blob.group()[:40] + "…"))
52
+ return SkillScan(
53
+ executables=executables[:MAX_HITS],
54
+ binaries=binaries[:MAX_HITS],
55
+ hosts=hosts[:MAX_HITS],
56
+ hidden_text=hidden[:MAX_HITS],
57
+ blobs=blobs[:MAX_HITS],
58
+ )
59
+
60
+
61
+ def _hidden(c: str) -> bool:
62
+ return unicodedata.category(c) == "Cf"
63
+
64
+
65
+ def _shown(line: str) -> str:
66
+ """The line with its hidden characters made visible (U+200B → ⟨U+200B⟩), shortened."""
67
+ shown = "".join(f"⟨U+{ord(c):04X}⟩" if _hidden(c) else c for c in line)
68
+ return shown if len(shown) <= 200 else shown[:199] + "…"
69
+
70
+
71
+ def notes(found: SkillScan) -> str:
72
+ """The scan, for the agents reviewing the skill (D.18): what to look at first."""
73
+ lines = ["What handler found in its files, without a model (check these):"]
74
+ if found.executables:
75
+ lines.append(f"- executable files: {', '.join(found.executables)}")
76
+ if found.binaries:
77
+ lines.append(f"- binary files: {', '.join(found.binaries)}")
78
+ for name, hits in (
79
+ ("URLs (host)", found.hosts),
80
+ ("invisible or bidirectional characters", found.hidden_text),
81
+ ("long base64 or hex runs", found.blobs),
82
+ ):
83
+ if hits:
84
+ where = ", ".join(f"{h.path}:{h.line} {h.text}" for h in hits[:30])
85
+ lines.append(f"- {name}: {where}")
86
+ if len(lines) == 1:
87
+ lines.append("- nothing: no executable, binary, URL, hidden character or blob")
88
+ return "\n".join(lines)
@@ -0,0 +1,61 @@
1
+ """Skills from a URL (D.17): a folder of a GitHub repository, or an https:// zip archive of one
2
+ skill. handlerd downloads the archive (GitHub's zip of the repository at that ref) and takes the
3
+ skill out of it with `skills.unpack`; the import itself is the archive import's.
4
+
5
+ locate a URL → the archive to download and the folder in it (None: a skill's own archive)
6
+ fetch a URL → the skill's name and files
7
+ """
8
+
9
+ import posixpath
10
+ import re
11
+
12
+ import httpx
13
+
14
+ from . import skills
15
+
16
+ MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024 # a repository's archive; the skill in it: skills' limits
17
+ DOWNLOAD_TIMEOUT = 60.0
18
+ GITHUB = re.compile(
19
+ r"^https://github\.com/(?P<owner>[\w.-]+)/(?P<repo>[\w.-]+?)(?:\.git)?"
20
+ r"(?:/(?P<kind>tree|blob)/(?P<ref>[^/]+)(?:/(?P<path>.*?))?)?/?$"
21
+ )
22
+
23
+
24
+ def locate(url: str) -> tuple[str, str | None]:
25
+ """The archive to download, and the skill's folder in it: a GitHub repository's folder
26
+ (`tree/<ref>/<folder>`, a `blob/<ref>/<folder>/SKILL.md`, or the repository itself: its
27
+ default branch) is that folder of the repository's zip; any other https:// URL is a skill's
28
+ own archive (None). A branch whose name has a `/` is not told from a folder: the first
29
+ segment is taken as the ref."""
30
+ if not url.startswith("https://"):
31
+ raise skills.SkillError("a skill is fetched over https:// only")
32
+ found = GITHUB.match(url)
33
+ if not found:
34
+ return url, None
35
+ path = found["path"] or ""
36
+ if found["kind"] == "blob":
37
+ if posixpath.basename(path) != skills.SKILL_FILE:
38
+ raise skills.SkillError(f"a file link of a repository must be a {skills.SKILL_FILE}")
39
+ path = posixpath.dirname(path)
40
+ repository = f"https://github.com/{found['owner']}/{found['repo']}"
41
+ return f"{repository}/archive/{found['ref'] or 'HEAD'}.zip", path
42
+
43
+
44
+ def _download(url: str) -> bytes:
45
+ data = bytearray()
46
+ try:
47
+ with httpx.stream("GET", url, follow_redirects=True, timeout=DOWNLOAD_TIMEOUT) as r:
48
+ r.raise_for_status()
49
+ for chunk in r.iter_bytes():
50
+ data += chunk
51
+ if len(data) > MAX_DOWNLOAD_BYTES:
52
+ raise skills.SkillError(f"the archive is over {MAX_DOWNLOAD_BYTES} bytes")
53
+ except httpx.HTTPError as e:
54
+ raise skills.SkillError(f"cannot download {url}: {e}") from None
55
+ return bytes(data)
56
+
57
+
58
+ def fetch(url: str) -> tuple[str, dict[str, tuple[bytes, bool]]]:
59
+ """The skill at `url`: its name and files, as `skills.unpack` gives them."""
60
+ archive, folder = locate(url)
61
+ return skills.unpack(_download(archive), folder)