mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-31 09:36:24 +00:00
07d8c3aa19
The dashboard's Claude page now manages the whole Claude Code install agents run on, not just the account login: - Skills: operator-authored SKILL.md rows, synced to each worker's user-level ~/.claude/skills at every launch. Managed dirs carry a .handler-managed marker so deletions in the UI propagate while hand-installed skills survive. - Connectors: MCP servers (stdio/http/sse) written per-launch as .claude/mcp-servers.json and passed to claude via --mcp-config, so nothing lands in the managed repo's tracked tree. - Plugins: marketplace-pinned plugins folded into generated settings as extraKnownMarketplaces + enabledPlugins, installing on boot of headless runs. - Permissions: defaultMode override plus allow/deny/ask rules merged over the env baseline into every generated settings.json. All of it is plain DB state (new claude_skills / claude_connectors / claude_plugins / claude_config tables, migration 0010) edited through the new admin-gated /claude/* API routes and applied by the control container at spawn and resume — changes reach the next launch of every agent with no redeploy. The login flow moved into the page's Account tab unchanged; /login redirects to /claude for old bookmarks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019f42XmjtVsc3zQ9Dhn6DqZ
117 lines
4.4 KiB
Python
117 lines
4.4 KiB
Python
"""Generate the per-agent Claude Code ``settings.json`` that wires each hook event to
|
|
``python -m handler.hooks <event>``.
|
|
|
|
This is the declarative half of hook integration; the imperative half — the agent
|
|
identity and ``DATABASE_URL`` — is injected as environment via tmux (see
|
|
``control.spawn``), because hook stdin does not carry our identity.
|
|
|
|
The dashboard's Claude page feeds in here too: the operator's permission overrides and
|
|
plugins (``claude_config`` / ``claude_plugins`` rows) are merged over the env-configured
|
|
baseline on every generation, so a change in the web UI applies to the next launch of
|
|
every agent without a redeploy.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
from sqlalchemy import Connection
|
|
|
|
from ..config import get_settings
|
|
from ..db import repository as repo
|
|
from ..db.engine import connection
|
|
|
|
_OWNER_REPO_RE = re.compile(r"^[\w.-]+/[\w.-]+$")
|
|
|
|
|
|
def _hook_command(event: str) -> str:
|
|
# Use the exact interpreter the control layer runs under, so the hook resolves the
|
|
# same handler package and virtualenv inside the tmux session.
|
|
return f"{sys.executable} -m handler.hooks {event}"
|
|
|
|
|
|
def _marketplace_source(repo_ref: str) -> dict:
|
|
"""The settings-shaped source for a marketplace: ``owner/repo`` is a GitHub source,
|
|
anything else is a git URL (the API validates it as one)."""
|
|
if _OWNER_REPO_RE.match(repo_ref):
|
|
return {"source": "github", "repo": repo_ref}
|
|
return {"source": "git", "url": repo_ref}
|
|
|
|
|
|
def build_settings(conn: Connection | None = None) -> dict:
|
|
settings = {
|
|
"hooks": {
|
|
"Stop": [{"hooks": [{"type": "command", "command": _hook_command("stop")}]}],
|
|
"SessionEnd": [
|
|
{"hooks": [{"type": "command", "command": _hook_command("session_end")}]}
|
|
],
|
|
"PreToolUse": [
|
|
{
|
|
"matcher": "AskUserQuestion|Bash",
|
|
"hooks": [{"type": "command", "command": _hook_command("pre_tool_use")}],
|
|
}
|
|
],
|
|
"Notification": [
|
|
{"hooks": [{"type": "command", "command": _hook_command("notification")}]}
|
|
],
|
|
}
|
|
}
|
|
# ``claude -p`` never prompts — anything that would ask for permission is
|
|
# auto-denied. The allowlist is therefore what lets normal work (git, mise, the
|
|
# project's own tooling) proceed; the PreToolUse/Stop hooks above remain the hard
|
|
# gate either way, since a hook deny overrides any allow.
|
|
s = get_settings()
|
|
mode = s.headless_permission_mode
|
|
allow = list(s.headless_allowed_tools_list)
|
|
deny: list[str] = []
|
|
ask: list[str] = []
|
|
plugins: list[dict] = []
|
|
if conn is not None:
|
|
stored = repo.get_claude_config(conn, "permissions") or {}
|
|
if stored.get("default_mode"):
|
|
mode = stored["default_mode"]
|
|
allow += [r for r in stored.get("allow", []) if r not in allow]
|
|
deny = list(stored.get("deny", []))
|
|
ask = list(stored.get("ask", []))
|
|
plugins = repo.list_claude_plugins(conn, enabled_only=True)
|
|
permissions: dict = {"defaultMode": mode, "allow": allow}
|
|
if deny:
|
|
permissions["deny"] = deny
|
|
if ask:
|
|
permissions["ask"] = ask
|
|
settings["permissions"] = permissions
|
|
|
|
# Web-managed plugins: declaring the marketplace + the enabled plugin makes a
|
|
# headless run install both on boot, no interactive `/plugin` flow needed.
|
|
if plugins:
|
|
settings["extraKnownMarketplaces"] = {
|
|
p["marketplace"]: {"source": _marketplace_source(p["marketplace_repo"])}
|
|
for p in plugins
|
|
}
|
|
settings["enabledPlugins"] = {
|
|
f"{p['name']}@{p['marketplace']}": True for p in plugins
|
|
}
|
|
return settings
|
|
|
|
|
|
def write_settings(working_dir: str, conn: Connection | None = None) -> str:
|
|
"""Write ``.claude/settings.json`` under the agent's working dir; return its path.
|
|
|
|
Opens a short read connection when the caller doesn't hold one — the web-managed
|
|
permission overrides and plugins live in the database.
|
|
"""
|
|
claude_dir = os.path.join(working_dir, ".claude")
|
|
os.makedirs(claude_dir, exist_ok=True)
|
|
path = os.path.join(claude_dir, "settings.json")
|
|
if conn is None:
|
|
with connection() as c:
|
|
settings = build_settings(c)
|
|
else:
|
|
settings = build_settings(conn)
|
|
with open(path, "w") as fh:
|
|
json.dump(settings, fh, indent=2)
|
|
return path
|