Download ClickUp attachments to work dir before running Claude

- Added ClickUpClient.download_attachment() for fetching attachment files
- Runner now downloads all task attachments to the temp work dir before
  dispatching to claude -p. Claude reads local files instead of URLs.
- build_prompt() now lists local filenames, not ClickUp attachment URLs
- _collect_output_files() excludes pre-existing attachments so they
  don't get re-uploaded back to ClickUp
- Removed optimizer script steps from content_draft.md -- the outline
  stage already handles Cora parsing, draft just follows the outline

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
clickup-runner
PeninsulaInd 2026-03-31 16:49:05 -05:00
parent 7679467bf8
commit bba634a63e
4 changed files with 96 additions and 47 deletions

View File

@ -9,8 +9,10 @@ from __future__ import annotations
import logging
import signal
import sys
import tempfile
import time
from datetime import datetime, timezone
from pathlib import Path
from .autocora import archive_result, scan_results, submit_job
from .claude_runner import (
@ -436,6 +438,37 @@ def _dispatch_autocora(
)
def _download_attachments(
client: ClickUpClient,
task: ClickUpTask,
work_dir: Path,
) -> list[str]:
"""Download all task attachments to the work directory.
Returns list of local filenames (not full paths) that were downloaded.
"""
downloaded = []
for att in task.attachments:
title = att.get("title", "")
url = att.get("url", "")
if not title or not url:
continue
dest = work_dir / title
if client.download_attachment(url, dest):
downloaded.append(title)
else:
log.warning("Skipping attachment %s -- download failed", title)
if downloaded:
log.info(
"Downloaded %d attachment(s) to %s: %s",
len(downloaded), work_dir, ", ".join(downloaded),
)
return downloaded
def _dispatch_claude(
client: ClickUpClient,
cfg: Config,
@ -460,20 +493,20 @@ def _dispatch_claude(
)
return
# 3. Gather .xlsx attachment URLs for the prompt
xlsx_urls = [
a.get("url", "")
for a in task.attachments
if a.get("title", "").lower().endswith(".xlsx")
or a.get("url", "").lower().endswith(".xlsx")
]
# 3. Create work dir and download all attachments into it
work_dir = Path(tempfile.mkdtemp(prefix="clickup_runner_"))
downloaded_files = _download_attachments(client, task, work_dir)
# 4. Build prompt
prompt = build_prompt(task, route, skill_content, xlsx_urls or None)
# 4. Build prompt (reference local filenames, not URLs)
prompt = build_prompt(task, route, skill_content, downloaded_files)
# 5. Run Claude
log.info("Starting Claude for task %s (%s)", task.id, task.name)
result = run_claude(prompt, route, cfg)
result = run_claude(
prompt, route, cfg,
work_dir=work_dir,
exclude_files=set(downloaded_files),
)
if not result.success:
_handle_dispatch_error(

View File

@ -36,13 +36,17 @@ def build_prompt(
task: ClickUpTask,
route: SkillRoute,
skill_content: str,
xlsx_urls: list[str] | None = None,
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] = []
@ -74,11 +78,11 @@ def build_prompt(
ctx_lines.append("### Description")
ctx_lines.append(task.description.strip())
if xlsx_urls:
if attachment_filenames:
ctx_lines.append("")
ctx_lines.append("### Attached Cora Reports (.xlsx)")
for url in xlsx_urls:
ctx_lines.append("- %s" % url)
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("")
@ -94,11 +98,20 @@ def build_prompt(
return "\n\n".join(parts)
def _collect_output_files(work_dir: Path) -> list[Path]:
"""Return all files Claude created in the working directory."""
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 []
files = [f for f in work_dir.iterdir() if f.is_file()]
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
@ -109,6 +122,7 @@ def run_claude(
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.
@ -118,6 +132,8 @@ def run_claude(
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:
@ -169,11 +185,11 @@ def run_claude(
output=stdout,
error="Claude exited with code %d: %s"
% (result.returncode, stderr[:2000]),
output_files=_collect_output_files(work_dir),
output_files=_collect_output_files(work_dir, exclude_files),
work_dir=work_dir,
)
output_files = _collect_output_files(work_dir)
output_files = _collect_output_files(work_dir, exclude_files)
log.info(
"Claude completed successfully. %d output file(s).", len(output_files)
)
@ -195,7 +211,7 @@ def run_claude(
output="",
error="Claude timed out after %d seconds"
% cfg.runner.claude_timeout_seconds,
output_files=_collect_output_files(work_dir),
output_files=_collect_output_files(work_dir, exclude_files),
work_dir=work_dir,
)
except FileNotFoundError:

View File

@ -272,6 +272,26 @@ class ClickUpClient:
)
return []
def download_attachment(self, url: str, dest: Path) -> bool:
"""Download a ClickUp attachment to a local file.
ClickUp attachment URLs are pre-signed S3 URLs that don't need
auth headers, so we use a plain httpx request (not the API client).
Returns True on success, False on failure.
"""
try:
with httpx.stream("GET", url, follow_redirects=True, timeout=60.0) as resp:
resp.raise_for_status()
with open(dest, "wb") as f:
for chunk in resp.iter_bytes(chunk_size=8192):
f.write(chunk)
log.info("Downloaded attachment to %s", dest)
return True
except Exception as e:
log.warning("Failed to download attachment from %s: %s", url, e)
return False
def get_custom_fields(self, list_id: str) -> list[dict]:
"""Get custom field definitions for a list."""
try:

View File

@ -82,34 +82,14 @@ After the main content, write the FOQ section from the outline:
- Mark the section: `<!-- FOQ SECTION START -->` and `<!-- FOQ SECTION END -->`
- FOQs are excluded from the main word count
## Step 4: Verify Against Cora
## Step 4: Self-Check
Run the optimizer scripts against your draft to check coverage.
Before writing the output file, review your own draft:
### 4a. Entity Check
```bash
cd .claude/skills/content-researcher/scripts && uv run --with openpyxl python entity_optimizer.py "{draft_path}" "{cora_xlsx_path}" --format json --top-n 30
```
Review the output:
- Are all "Must mention" entities present? If any are missing, add them.
- Are any entities at 0 that should have at least 1 mention? Fix.
- Do NOT chase high deficit numbers for outlier entities.
### 4b. LSI Check
```bash
cd .claude/skills/content-researcher/scripts && uv run --with openpyxl python lsi_optimizer.py "{draft_path}" "{cora_xlsx_path}" --format json --top-n 30
```
Review the output:
- Are the top 20 LSI terms from the outline present? If any key ones are missing, weave them in.
- Do NOT force LSI terms where they don't fit.
### 4c. Word Count Check
Count the words in your draft (excluding FOQ section). Compare against the Cora target. If you're over by more than 10%, trim. If under by more than 10%, expand the thinnest sections.
- Count words per section (excluding FOQ). Compare against the outline's per-section targets. If any section is over by more than 10%, trim. If under by more than 10%, expand.
- Scan for each entity in the "Must mention" list from the Writer's Reference. If any are missing, add them.
- Scan for the top LSI terms. If key ones are absent, weave them in where natural.
- Re-read for keyword stuffing -- if any paragraph sounds forced, rewrite it.
## Step 5: Generate Meta Tags