#!/usr/bin/env python3 """UserPromptExpansion hook: run a skill TUI-side, without a model turn. Custom slash commands in Claude Code are prompts -- typing `/x` expands skills/x/SKILL.md and sends it to the model. For a command that is really just a local action (flip a setting, print a state) that round trip is waste. This hook gives any skill a TUI-side implementation: if `~/.claude/skills//local.py` exists, it is executed with the command's arguments, its output is shown to you, and the expansion is blocked (exit 2), so nothing reaches the model. Skills without a local.py expand as usual (exit 0). Order matters: the hook fires AFTER the skill body has been expanded, so any `!` inline commands in that SKILL.md have already run by the time this executes. A skill with a local.py must therefore not also apply its action via `!` -- it would happen twice. Input (stdin JSON): command_name, command_args (list), expanded_prompt, cwd, ... Fails open: any error here means exit 0 and the normal expansion goes ahead. """ import json import os import subprocess import sys def main(): try: data = json.load(sys.stdin) name = data.get("command_name") or "" args = data.get("command_args") or [] if isinstance(args, str): args = args.split() if not name or "/" in name or name.startswith("."): return script = os.path.expanduser(f"~/.claude/skills/{name}/local.py") if not os.path.isfile(script): return r = subprocess.run( [sys.executable, script, *[str(a) for a in args]], capture_output=True, text=True, timeout=20, cwd=data.get("cwd") or None, ) except Exception: return # fail open: let the skill expand normally out = (r.stdout or "") + (r.stderr or "") sys.stderr.write(out.rstrip("\n") + f"\n[/{name}: handled TUI-side by skills/{name}/local.py, no model turn]\n") sys.exit(2) if __name__ == "__main__": main()