"""Claude Code subprocess runner. Builds prompts from skill files + task context, runs `claude -p`, collects output files, and returns structured results. """ from __future__ import annotations import logging import shutil import subprocess import tempfile from dataclasses import dataclass, field from pathlib import Path from typing import Any from .clickup_client import ClickUpTask from .config import Config from .skill_map import SkillRoute log = logging.getLogger(__name__) @dataclass class RunResult: """Outcome of a Claude Code run.""" success: bool output: str = "" error: str = "" output_files: list[Path] = field(default_factory=list) work_dir: Path | None = None def build_prompt( task: ClickUpTask, route: SkillRoute, skill_content: str, attachment_filenames: list[str] | None = None, ) -> str: """Assemble the prompt sent to `claude -p`. Structure: 1. Skill file content (system-level instructions) 2. Task context block (name, description, customer, URL, attachments) Args: attachment_filenames: Local filenames (not paths) of attachments already downloaded to the working directory. """ parts: list[str] = [] # -- Skill instructions -- parts.append(skill_content.strip()) # -- Task context -- ctx_lines = [ "", "---", "## Task Context", "", "Task: %s" % task.name, ] client_name = task.get_field_value("Client") if client_name: ctx_lines.append("Client: %s" % client_name) ims_url = task.get_field_value("IMSURL") if ims_url: ctx_lines.append("Target URL: %s" % ims_url) if task.url: ctx_lines.append("ClickUp Task: %s" % task.url) if task.description: ctx_lines.append("") ctx_lines.append("### Description") ctx_lines.append(task.description.strip()) if attachment_filenames: ctx_lines.append("") ctx_lines.append("### Attached Files (in working directory)") for fname in attachment_filenames: ctx_lines.append("- %s" % fname) # Tell Claude where to write output ctx_lines.append("") ctx_lines.append( "### Output Instructions" ) ctx_lines.append( "Write all output files to the current working directory. " "Do NOT create subdirectories." ) parts.append("\n".join(ctx_lines)) return "\n\n".join(parts) def _collect_output_files( work_dir: Path, exclude: set[str] | None = None, ) -> list[Path]: """Return all files Claude created in the working directory. Args: exclude: Set of filenames to skip (e.g. downloaded attachments that were in the dir before Claude ran). """ if not work_dir.exists(): return [] exclude = exclude or set() files = [f for f in work_dir.iterdir() if f.is_file() and f.name not in exclude] # Sort for deterministic ordering files.sort(key=lambda p: p.name) return files def run_claude( prompt: str, route: SkillRoute, cfg: Config, work_dir: Path | None = None, exclude_files: set[str] | None = None, ) -> RunResult: """Run `claude -p` as a subprocess and return the result. Args: prompt: The assembled prompt string. route: SkillRoute with tools and max_turns. cfg: Runner config (timeout, etc.). work_dir: Directory for Claude to write files into. If None, a temp directory is created. exclude_files: Filenames to exclude from output_files (e.g. pre-existing attachments). """ created_tmp = False if work_dir is None: work_dir = Path(tempfile.mkdtemp(prefix="clickup_runner_")) created_tmp = True cmd = [ "claude", "-p", prompt, "--output-format", "text", "--permission-mode", "bypassPermissions", ] if route.tools: cmd.extend(["--allowedTools", route.tools]) if route.max_turns: cmd.extend(["--max-turns", str(route.max_turns)]) log.info( "Running claude: tools=%s, max_turns=%d, timeout=%ds, work_dir=%s", route.tools or "(all)", route.max_turns, cfg.runner.claude_timeout_seconds, work_dir, ) try: result = subprocess.run( cmd, capture_output=True, text=True, timeout=cfg.runner.claude_timeout_seconds, cwd=str(work_dir), ) stdout = result.stdout or "" stderr = result.stderr or "" if result.returncode != 0: log.error( "Claude exited with code %d.\nstderr: %s", result.returncode, stderr[:2000], ) return RunResult( success=False, output=stdout, error="Claude exited with code %d: %s" % (result.returncode, stderr[:2000]), output_files=_collect_output_files(work_dir, exclude_files), work_dir=work_dir, ) output_files = _collect_output_files(work_dir, exclude_files) log.info( "Claude completed successfully. %d output file(s).", len(output_files) ) return RunResult( success=True, output=stdout, error="", output_files=output_files, work_dir=work_dir, ) except subprocess.TimeoutExpired: log.error( "Claude timed out after %ds", cfg.runner.claude_timeout_seconds ) return RunResult( success=False, output="", error="Claude timed out after %d seconds" % cfg.runner.claude_timeout_seconds, output_files=_collect_output_files(work_dir, exclude_files), work_dir=work_dir, ) except FileNotFoundError: log.error("claude CLI not found on PATH") return RunResult( success=False, output="", error="claude CLI not found on PATH. Is Claude Code installed?", work_dir=work_dir, ) def copy_to_nas( files: list[Path], customer: str, nas_dir: str, ) -> list[Path]: """Best-effort copy of output files to NAS. Returns list of successfully copied paths. """ if not nas_dir or not customer: return [] dest_dir = Path(nas_dir) / customer copied: list[Path] = [] try: dest_dir.mkdir(parents=True, exist_ok=True) except OSError as e: log.warning("Cannot create NAS directory %s: %s", dest_dir, e) return [] for f in files: try: dest = dest_dir / f.name shutil.copy2(str(f), str(dest)) copied.append(dest) log.info("Copied %s to NAS: %s", f.name, dest) except OSError as e: log.warning("Failed to copy %s to NAS: %s", f.name, e) return copied def notify( cfg: Config, title: str, message: str, is_error: bool = False, ) -> None: """Send a notification via ntfy.sh (best-effort).""" topic = cfg.ntfy.error_topic if is_error else cfg.ntfy.success_topic if not topic: return url = "%s/%s" % (cfg.ntfy.server.rstrip("/"), topic) try: import httpx as _httpx _httpx.post( url, content=message.encode("ascii", errors="replace"), headers={ "Title": title.encode("ascii", errors="replace").decode("ascii"), "Priority": "high" if is_error else "default", }, timeout=10.0, ) log.info("Sent ntfy notification to %s", topic) except Exception as e: log.warning("Failed to send ntfy notification: %s", e) def read_skill_file(route: SkillRoute, skills_dir: Path) -> str: """Read a skill .md file and return its content. Raises FileNotFoundError if the skill file doesn't exist. """ skill_path = skills_dir / route.skill_file if not skill_path.exists(): raise FileNotFoundError( "Skill file not found: %s" % skill_path ) return skill_path.read_text(encoding="utf-8")