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,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)
|