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 logging
import signal import signal
import sys import sys
import tempfile
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
from .autocora import archive_result, scan_results, submit_job from .autocora import archive_result, scan_results, submit_job
from .claude_runner import ( 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( def _dispatch_claude(
client: ClickUpClient, client: ClickUpClient,
cfg: Config, cfg: Config,
@ -460,20 +493,20 @@ def _dispatch_claude(
) )
return return
# 3. Gather .xlsx attachment URLs for the prompt # 3. Create work dir and download all attachments into it
xlsx_urls = [ work_dir = Path(tempfile.mkdtemp(prefix="clickup_runner_"))
a.get("url", "") downloaded_files = _download_attachments(client, task, work_dir)
for a in task.attachments
if a.get("title", "").lower().endswith(".xlsx")
or a.get("url", "").lower().endswith(".xlsx")
]
# 4. Build prompt # 4. Build prompt (reference local filenames, not URLs)
prompt = build_prompt(task, route, skill_content, xlsx_urls or None) prompt = build_prompt(task, route, skill_content, downloaded_files)
# 5. Run Claude # 5. Run Claude
log.info("Starting Claude for task %s (%s)", task.id, task.name) 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: if not result.success:
_handle_dispatch_error( _handle_dispatch_error(

View File

@ -36,13 +36,17 @@ def build_prompt(
task: ClickUpTask, task: ClickUpTask,
route: SkillRoute, route: SkillRoute,
skill_content: str, skill_content: str,
xlsx_urls: list[str] | None = None, attachment_filenames: list[str] | None = None,
) -> str: ) -> str:
"""Assemble the prompt sent to `claude -p`. """Assemble the prompt sent to `claude -p`.
Structure: Structure:
1. Skill file content (system-level instructions) 1. Skill file content (system-level instructions)
2. Task context block (name, description, customer, URL, attachments) 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] = [] parts: list[str] = []
@ -74,11 +78,11 @@ def build_prompt(
ctx_lines.append("### Description") ctx_lines.append("### Description")
ctx_lines.append(task.description.strip()) ctx_lines.append(task.description.strip())
if xlsx_urls: if attachment_filenames:
ctx_lines.append("") ctx_lines.append("")
ctx_lines.append("### Attached Cora Reports (.xlsx)") ctx_lines.append("### Attached Files (in working directory)")
for url in xlsx_urls: for fname in attachment_filenames:
ctx_lines.append("- %s" % url) ctx_lines.append("- %s" % fname)
# Tell Claude where to write output # Tell Claude where to write output
ctx_lines.append("") ctx_lines.append("")
@ -94,11 +98,20 @@ def build_prompt(
return "\n\n".join(parts) return "\n\n".join(parts)
def _collect_output_files(work_dir: Path) -> list[Path]: def _collect_output_files(
"""Return all files Claude created in the working directory.""" 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(): if not work_dir.exists():
return [] 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 # Sort for deterministic ordering
files.sort(key=lambda p: p.name) files.sort(key=lambda p: p.name)
return files return files
@ -109,6 +122,7 @@ def run_claude(
route: SkillRoute, route: SkillRoute,
cfg: Config, cfg: Config,
work_dir: Path | None = None, work_dir: Path | None = None,
exclude_files: set[str] | None = None,
) -> RunResult: ) -> RunResult:
"""Run `claude -p` as a subprocess and return the result. """Run `claude -p` as a subprocess and return the result.
@ -118,6 +132,8 @@ def run_claude(
cfg: Runner config (timeout, etc.). cfg: Runner config (timeout, etc.).
work_dir: Directory for Claude to write files into. work_dir: Directory for Claude to write files into.
If None, a temp directory is created. If None, a temp directory is created.
exclude_files: Filenames to exclude from output_files
(e.g. pre-existing attachments).
""" """
created_tmp = False created_tmp = False
if work_dir is None: if work_dir is None:
@ -169,11 +185,11 @@ def run_claude(
output=stdout, output=stdout,
error="Claude exited with code %d: %s" error="Claude exited with code %d: %s"
% (result.returncode, stderr[:2000]), % (result.returncode, stderr[:2000]),
output_files=_collect_output_files(work_dir), output_files=_collect_output_files(work_dir, exclude_files),
work_dir=work_dir, work_dir=work_dir,
) )
output_files = _collect_output_files(work_dir) output_files = _collect_output_files(work_dir, exclude_files)
log.info( log.info(
"Claude completed successfully. %d output file(s).", len(output_files) "Claude completed successfully. %d output file(s).", len(output_files)
) )
@ -195,7 +211,7 @@ def run_claude(
output="", output="",
error="Claude timed out after %d seconds" error="Claude timed out after %d seconds"
% cfg.runner.claude_timeout_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, work_dir=work_dir,
) )
except FileNotFoundError: except FileNotFoundError:

View File

@ -272,6 +272,26 @@ class ClickUpClient:
) )
return [] 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]: def get_custom_fields(self, list_id: str) -> list[dict]:
"""Get custom field definitions for a list.""" """Get custom field definitions for a list."""
try: 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 -->` - Mark the section: `<!-- FOQ SECTION START -->` and `<!-- FOQ SECTION END -->`
- FOQs are excluded from the main word count - 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 - 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.
```bash - Scan for the top LSI terms. If key ones are absent, weave them in where natural.
cd .claude/skills/content-researcher/scripts && uv run --with openpyxl python entity_optimizer.py "{draft_path}" "{cora_xlsx_path}" --format json --top-n 30 - Re-read for keyword stuffing -- if any paragraph sounds forced, rewrite it.
```
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.
## Step 5: Generate Meta Tags ## Step 5: Generate Meta Tags