{ config, lib, pkgs, trollshell, ... }: let trollshellPkgs = trollshell.packages.${pkgs.stdenv.hostPlatform.system}; # The claude bridge's loopback port, shared between the daemon and the pet # that talks to it so there is one number to change. The address is fixed at # 127.0.0.1 by the bridge and is not configurable: the bridge validates no # bearer token at all, so reachability *is* the authorization boundary. bridgePort = 8787; in { programs.trollshell = { enable = true; weather.fallbackCity = "Berlin"; cliphist.enable = true; stats.layout = "split"; plugins = { pet = { enable = true; package = trollshellPkgs.hytte-plugin-pet; env = { PET_NAME = "foo"; PET_LLM_URL = "http://127.0.0.1:${toString bridgePort}"; # Not a real key. `hytte_ai_providers::load_key` checks this env # override *before* ~/.config/trollshell/openrouter.key, so this dummy # is what stops a genuine OpenRouter key being shipped to a loopback # port. It is a security control, not a placeholder — don't drop it. OPENROUTER_API_KEY = "local-bridge"; PET_LLM_MIN_GAP_SECS = "1"; PET_LLM_TIMEOUT_SECS = "30"; PET_PERSONA = "cheerful, impatient and easily excitable"; }; }; departures = { enable = true; package = trollshellPkgs.hytte-plugin-departures; }; usage = { enable = true; package = trollshellPkgs.hytte-plugin-usage; }; weather = { enable = true; package = trollshellPkgs.hytte-plugin-weather; }; }; }; # The keyless loopback shim that puts an OpenAI-compatible face on headless # `claude --print`, so pet rides the Claude Code subscription instead of # OpenRouter. trollshell ships the package and a reference unit under `etc/` # but no module option for it, so the unit is declared here — without it the # pet's PET_LLM_URL above points at nothing and the plugin stays canned-only. systemd.user.services.trollshell-claude-bridge = { Unit = { Description = "Keyless loopback OpenAI-compatible bridge to headless Claude Code"; PartOf = [ config.programs.trollshell.systemd.target ]; After = [ config.programs.trollshell.systemd.target ]; }; Service = { Type = "simple"; ExecStart = lib.getExe trollshellPkgs.hytte-claude-bridge; Restart = "on-failure"; RestartSec = 5; Environment = [ "RUST_LOG=hytte_claude_bridge=info" "CLAUDE_BRIDGE_PORT=${toString bridgePort}" # `claude --model` for the child. Worth pinning: the bridge's default # per-request budget is 8s (it must stay under the client's 10s), and # the child otherwise inherits the model from ~/.claude/settings.json — # currently opus, which would blow that budget on nearly every reply. "CLAUDE_BRIDGE_MODEL=claude-haiku-4-5" "CLAUDE_BRIDGE_TIMEOUT_SECS=25" # Belt-and-braces; the copy that actually prevents a leak is the one on # the pet above, because load_key runs in the plugin's process. "OPENROUTER_API_KEY=local-bridge" # The bridge shells out to `claude`, which is the OTEL wrapper from # ./claude.nix — so pet chatter is counted as workstation usage. "PATH=${config.home.profileDirectory}/bin:/run/current-system/sw/bin" ]; # SECURITY CONTROL — do not drop. These four would silently move `claude` # off the subscription and onto metered API credits (or Bedrock/Vertex). # The bridge cannot scrub them itself (`std::env::remove_var` is unsafe # under edition 2024 and that workspace forbids unsafe), so it fails # closed instead: it *refuses to start* if it finds any of them set. UnsetEnvironment = "ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_USE_BEDROCK CLAUDE_CODE_USE_VERTEX"; }; Install.WantedBy = [ config.programs.trollshell.systemd.target ]; }; }