300 lines
8.1 KiB
Python
300 lines
8.1 KiB
Python
"""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")
|