scripts/paperclip-upload-artifact.sh
scripts/paperclip-upload-artifact.shBrowse 9 files
4,946 tokens
17,527 bytes
Token encoding: o200k_base
Snapshot 5b913e7
← Back to SKILL.md
1#!/usr/bin/env bash2 3set -euo pipefail4 5usage() {6 cat <<'EOF'7Usage:8 paperclip-upload-artifact.sh FILE [options]9 10Uploads a generated file from the current workspace to the current Paperclip11issue, then creates an attachment-backed artifact work product by default.12 13Required environment for live uploads:14 PAPERCLIP_API_URL, PAPERCLIP_API_KEY, PAPERCLIP_COMPANY_ID, PAPERCLIP_TASK_ID, PAPERCLIP_RUN_ID15 16Options:17 --issue-id ID Issue id to attach to (default: PAPERCLIP_TASK_ID)18 --company-id ID Company id (default: PAPERCLIP_COMPANY_ID)19 --title TEXT Work product title (default: file basename)20 --summary TEXT Work product summary21 --content-type TYPE Override detected upload content type22 --status STATUS Work product status (default: ready_for_review)23 --chat-comment TEXT Bind this file to an explicit external-chat response24 --retry-unknown-upload Retry after an unresolved transport failure (duplicate risk)25 --no-work-product Only upload the issue attachment26 --no-primary Do not mark the artifact work product primary for its type27 --output FORMAT markdown or json (default: markdown)28 --dry-run Print resolved upload settings without calling the API29 --help, -h Show this help30 31Examples:32 scripts/paperclip-upload-artifact.sh dist/demo.mp4 \33 --title "Demo video render" \34 --summary "MP4 render for board review"35 36 scripts/paperclip-upload-artifact.sh out/walkthrough.webm \37 --title "Walkthrough video" \38 --content-type video/webm39 40 scripts/paperclip-upload-artifact.sh out/result.png \41 --title "Generated image" \42 --chat-comment "Here is the requested image."43EOF44}45 46require_command() {47 if ! command -v "$1" >/dev/null 2>&1; then48 printf 'Missing required command: %s\n' "$1" >&249 exit 150 fi51}52 53json_bool() {54 if [[ "${1:-0}" == "1" ]]; then55 printf 'true'56 else57 printf 'false'58 fi59}60 61detect_content_type() {62 local path="$1"63 local lower64 lower="$(printf '%s' "$path" | tr '[:upper:]' '[:lower:]')"65 66 case "$lower" in67 *.mp4|*.m4v) printf 'video/mp4' ;;68 *.webm) printf 'video/webm' ;;69 *.mov|*.qt) printf 'video/quicktime' ;;70 *.png) printf 'image/png' ;;71 *.jpg|*.jpeg) printf 'image/jpeg' ;;72 *.gif) printf 'image/gif' ;;73 *.webp) printf 'image/webp' ;;74 *.svg) printf 'image/svg+xml' ;;75 *.pdf) printf 'application/pdf' ;;76 *.txt|*.log) printf 'text/plain' ;;77 *.md|*.markdown) printf 'text/markdown' ;;78 *.json) printf 'application/json' ;;79 *.csv) printf 'text/csv' ;;80 *.html|*.htm) printf 'text/html' ;;81 *.zip) printf 'application/zip' ;;82 *)83 if command -v file >/dev/null 2>&1; then84 file --brief --mime-type "$path"85 else86 printf 'application/octet-stream'87 fi88 ;;89 esac90}91 92sha256_file() {93 local path="$1"94 if command -v sha256sum >/dev/null 2>&1; then95 sha256sum -- "$path" | awk '{print tolower($1)}'96 elif command -v shasum >/dev/null 2>&1; then97 shasum -a 256 -- "$path" | awk '{print tolower($1)}'98 else99 printf 'Missing required command: sha256sum or shasum\n' >&2100 exit 1101 fi102}103 104sha256_text() {105 local value="$1"106 if command -v sha256sum >/dev/null 2>&1; then107 printf '%s' "$value" | sha256sum | awk '{print tolower($1)}'108 elif command -v shasum >/dev/null 2>&1; then109 printf '%s' "$value" | shasum -a 256 | awk '{print tolower($1)}'110 else111 printf 'Missing required command: sha256sum or shasum\n' >&2112 exit 1113 fi114}115 116request_json() {117 local method="$1"118 local url="$2"119 local body="${3:-}"120 local response_file121 local status_code122 123 response_file="$(mktemp)"124 if [[ -n "$body" ]]; then125 status_code="$(126 curl -sS -X "$method" -w '%{http_code}' -o "$response_file" \127 "$url" \128 -H "Authorization: Bearer $PAPERCLIP_API_KEY" \129 -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \130 -H 'Content-Type: application/json' \131 --data-binary "$body"132 )"133 else134 status_code="$(135 curl -sS -X "$method" -w '%{http_code}' -o "$response_file" \136 "$url" \137 -H "Authorization: Bearer $PAPERCLIP_API_KEY" \138 -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID"139 )"140 fi141 142 if [[ "$status_code" -lt 200 || "$status_code" -ge 300 ]]; then143 printf 'Request failed (%s): %s\n' "$status_code" "$url" >&2144 cat "$response_file" >&2145 printf '\n' >&2146 rm -f "$response_file"147 exit 1148 fi149 150 cat "$response_file"151 rm -f "$response_file"152}153 154upload_file() {155 local url="$1"156 local path="$2"157 local content_type="$3"158 local escaped_path159 local response_file160 local status_code161 local curl_status=0162 163 escaped_path="${path//\\/\\\\}"164 escaped_path="${escaped_path//\"/\\\"}"165 response_file="$(mktemp)"166 status_code="$(167 curl -sS -X POST -w '%{http_code}' -o "$response_file" \168 "$url" \169 -H "Authorization: Bearer $PAPERCLIP_API_KEY" \170 -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \171 -F "file=@\"${escaped_path}\";type=${content_type}"172 )" || curl_status=$?173 174 if [[ "$curl_status" -ne 0 ]]; then175 rm -f "$response_file"176 return 75177 fi178 179 if [[ "$status_code" -lt 200 || "$status_code" -ge 300 ]]; then180 printf 'Upload failed (%s): %s\n' "$status_code" "$url" >&2181 cat "$response_file" >&2182 printf '\n' >&2183 rm -f "$response_file"184 if [[ "$status_code" == "408" || "$status_code" -ge 500 ]]; then185 return 75186 fi187 return 1188 fi189 190 cat "$response_file"191 rm -f "$response_file"192}193 194operation_lock_path=""195operation_lock_owner=""196operation_lock_held=0197operation_state_root=""198 199process_start_identity() {200 local pid="$1"201 ps -p "$pid" -o lstart= 2>/dev/null | tr -s '[:space:]' ' ' | sed 's/^ //;s/ $//'202}203 204release_operation_lock() {205 if [[ "$operation_lock_held" == "1" && -n "$operation_lock_path" ]]; then206 local current_owner=""207 current_owner="$(readlink "$operation_lock_path" 2>/dev/null || true)"208 if [[ "$current_owner" == "$operation_lock_owner" ]]; then209 rm -f "$operation_lock_path"210 fi211 operation_lock_held=0212 fi213}214 215acquire_operation_lock() {216 local operation_key="$1"217 local attempts=0218 219 umask 077220 operation_state_root="${PAPERCLIP_HELPER_STATE_DIR:-${TMPDIR:-/tmp}/paperclip-upload-artifact}"221 mkdir -p "$operation_state_root"222 operation_lock_path="$operation_state_root/$operation_key.lock"223 operation_lock_owner="$$|$(process_start_identity "$$" || true)"224 while ! ln -s "$operation_lock_owner" "$operation_lock_path" 2>/dev/null; do225 local owner_pid=""226 local owner_start=""227 local current_lock_owner=""228 current_lock_owner="$(readlink "$operation_lock_path" 2>/dev/null || true)"229 IFS='|' read -r owner_pid owner_start <<<"$current_lock_owner"230 if [[ "$owner_pid" =~ ^[0-9]+$ ]]; then231 local current_owner_start=""232 current_owner_start="$(process_start_identity "$owner_pid" || true)"233 if ! kill -0 "$owner_pid" 2>/dev/null ||234 [[ -n "$owner_start" && -n "$current_owner_start" && "$owner_start" != "$current_owner_start" ]]; then235 # Serialize stale-lock reclamation separately. Without this guard, two236 # contenders can both observe the old owner and the slower one can237 # delete the faster contender's newly acquired live lock.238 local reclaim_lock_path="$operation_lock_path.reclaim"239 if mkdir "$reclaim_lock_path" 2>/dev/null; then240 local guarded_owner=""241 guarded_owner="$(readlink "$operation_lock_path" 2>/dev/null || true)"242 if [[ "$guarded_owner" == "$current_lock_owner" ]]; then243 rm -f "$operation_lock_path"244 fi245 rmdir "$reclaim_lock_path" 2>/dev/null || true246 continue247 fi248 fi249 fi250 attempts=$((attempts + 1))251 if [[ "$attempts" -ge 400 ]]; then252 printf 'Another matching artifact upload is still in progress; retry after it finishes.\n' >&2253 exit 1254 fi255 sleep 0.05256 done257 operation_lock_held=1258}259 260file_path=""261issue_id="${PAPERCLIP_TASK_ID:-}"262company_id="${PAPERCLIP_COMPANY_ID:-}"263title=""264summary=""265content_type=""266status="ready_for_review"267chat_comment=""268create_work_product=1269is_primary=1270output_format="markdown"271dry_run=0272retry_unknown_upload=0273 274while [[ $# -gt 0 ]]; do275 case "$1" in276 --issue-id)277 issue_id="${2:-}"278 shift 2279 ;;280 --company-id)281 company_id="${2:-}"282 shift 2283 ;;284 --title)285 title="${2:-}"286 shift 2287 ;;288 --summary)289 summary="${2:-}"290 shift 2291 ;;292 --content-type)293 content_type="${2:-}"294 shift 2295 ;;296 --status)297 status="${2:-}"298 shift 2299 ;;300 --chat-comment)301 chat_comment="${2:-}"302 shift 2303 ;;304 --no-work-product)305 create_work_product=0306 shift307 ;;308 --retry-unknown-upload)309 retry_unknown_upload=1310 shift311 ;;312 --no-primary)313 is_primary=0314 shift315 ;;316 --output)317 output_format="${2:-}"318 shift 2319 ;;320 --dry-run)321 dry_run=1322 shift323 ;;324 --help|-h)325 usage326 exit 0327 ;;328 --*)329 printf 'Unknown argument: %s\n' "$1" >&2330 usage >&2331 exit 1332 ;;333 *)334 if [[ -n "$file_path" ]]; then335 printf 'Unexpected positional argument: %s\n' "$1" >&2336 usage >&2337 exit 1338 fi339 file_path="$1"340 shift341 ;;342 esac343done344 345if [[ -z "$file_path" ]]; then346 printf 'Missing file path.\n' >&2347 usage >&2348 exit 1349fi350 351if [[ ! -f "$file_path" ]]; then352 printf 'Artifact file does not exist: %s\n' "$file_path" >&2353 exit 1354fi355 356if [[ "$output_format" != "markdown" && "$output_format" != "json" ]]; then357 printf 'Unsupported output format: %s\n' "$output_format" >&2358 exit 1359fi360 361if [[ -n "$chat_comment" && "$create_work_product" != "1" ]]; then362 printf '%s\n' '--chat-comment requires the attachment-backed work product created by this helper.' >&2363 exit 1364fi365 366require_command curl367require_command jq368 369if [[ -z "$title" ]]; then370 title="$(basename "$file_path")"371fi372 373if [[ -z "$content_type" ]]; then374 content_type="$(detect_content_type "$file_path")"375fi376 377if [[ "$dry_run" == "1" ]]; then378 create_work_product_json="$(json_bool "$create_work_product")"379 is_primary_json="$(json_bool "$is_primary")"380 jq -n \381 --arg file "$file_path" \382 --arg issueId "$issue_id" \383 --arg companyId "$company_id" \384 --arg title "$title" \385 --arg summary "$summary" \386 --arg contentType "$content_type" \387 --arg status "$status" \388 --arg chatComment "$chat_comment" \389 --argjson createWorkProduct "$create_work_product_json" \390 --argjson isPrimary "$is_primary_json" \391 '{file: $file, issueId: $issueId, companyId: $companyId, title: $title, summary: $summary, contentType: $contentType, status: $status, chatComment: (if $chatComment == "" then null else $chatComment end), createWorkProduct: $createWorkProduct, isPrimary: $isPrimary}'392 exit 0393fi394 395if [[ -z "${PAPERCLIP_API_URL:-}" || -z "${PAPERCLIP_API_KEY:-}" || -z "${PAPERCLIP_RUN_ID:-}" ]]; then396 printf 'Missing PAPERCLIP_API_URL, PAPERCLIP_API_KEY, or PAPERCLIP_RUN_ID.\n' >&2397 exit 1398fi399 400if [[ -z "$issue_id" || -z "$company_id" ]]; then401 printf 'Missing issue or company id. Pass --issue-id/--company-id or set PAPERCLIP_TASK_ID/PAPERCLIP_COMPANY_ID.\n' >&2402 exit 1403fi404 405api_root="${PAPERCLIP_API_URL%/}"406case "$api_root" in407 */api) api_base="$api_root" ;;408 *) api_base="$api_root/api" ;;409esac410file_sha256="$(sha256_file "$file_path")"411original_filename="$(basename "$file_path")"412operation_key="$(413 sha256_text "$api_base|$company_id|$issue_id|$PAPERCLIP_RUN_ID|$original_filename|$file_sha256|$content_type"414)"415acquire_operation_lock "$operation_key"416trap release_operation_lock EXIT417 418attachment=""419reused_attachment=0420unknown_upload_marker="$operation_state_root/$operation_key.uncertain"421lookup_attempts=1422if [[ -f "$unknown_upload_marker" && "$retry_unknown_upload" != "1" ]]; then423 lookup_attempts=20424fi425for ((lookup_attempt = 1; lookup_attempt <= lookup_attempts; lookup_attempt++)); do426 existing_attachments="$(request_json GET "$api_base/issues/$issue_id/attachments")"427 attachment="$(428 jq -nc \429 --argjson attachments "$existing_attachments" \430 --arg runId "$PAPERCLIP_RUN_ID" \431 --arg sha256 "$file_sha256" \432 --arg originalFilename "$original_filename" \433 --arg contentType "$content_type" \434 'first(435 $attachments[]436 | select(437 .originatingRunId == $runId438 and ((.sha256 // "") | ascii_downcase) == ($sha256 | ascii_downcase)439 and (.originalFilename // "") == $originalFilename440 and ((.contentType // "") | ascii_downcase) == ($contentType | ascii_downcase)441 )442 ) // empty'443 )"444 if [[ -n "$attachment" ]]; then445 reused_attachment=1446 rm -f "$unknown_upload_marker"447 break448 fi449 if [[ "$lookup_attempt" -lt "$lookup_attempts" ]]; then450 sleep 0.25451 fi452done453 454if [[ -z "$attachment" && -f "$unknown_upload_marker" && "$retry_unknown_upload" != "1" ]]; then455 printf '%s\n' 'A previous matching upload ended without a definitive response, and Paperclip has not exposed its durable attachment yet.' >&2456 printf '%s\n' 'Retry this command later. If the upload definitely did not commit, pass --retry-unknown-upload to accept the duplicate-file risk.' >&2457 exit 1458fi459 460if [[ -z "$attachment" ]]; then461 rm -f "$unknown_upload_marker"462 : >"$unknown_upload_marker"463 upload_status=0464 attachment="$(465 upload_file \466 "$api_base/companies/$company_id/issues/$issue_id/attachments" \467 "$file_path" \468 "$content_type"469 )" || upload_status=$?470 if [[ "$upload_status" -ne 0 ]]; then471 if [[ "$upload_status" -ne 75 ]]; then472 rm -f "$unknown_upload_marker"473 fi474 exit 1475 fi476fi477 478attachment_id="$(jq -r '.id // empty' <<<"$attachment")"479content_path="$(jq -r '.contentPath // empty' <<<"$attachment")"480download_path="$(jq -r '.downloadPath // (if .contentPath then (.contentPath + "?download=1") else "" end)' <<<"$attachment")"481if [[ -z "$attachment_id" || -z "$content_path" || -z "$download_path" ]]; then482 printf 'Upload response did not include attachment path metadata.\n' >&2483 printf '%s\n' "$attachment" >&2484 exit 1485fi486rm -f "$unknown_upload_marker"487 488work_product="null"489if [[ "$create_work_product" == "1" ]]; then490 is_primary_json="$(json_bool "$is_primary")"491 byte_size="$(jq -r '.byteSize // 0' <<<"$attachment")"492 open_path="$(jq -r '.openPath // .contentPath // empty' <<<"$attachment")"493 original_filename="$(jq -r '.originalFilename // empty' <<<"$attachment")"494 495 work_product_payload="$(496 jq -nc \497 --arg title "$title" \498 --arg summary "$summary" \499 --arg status "$status" \500 --arg runId "$PAPERCLIP_RUN_ID" \501 --arg attachmentId "$attachment_id" \502 --arg contentType "$content_type" \503 --argjson byteSize "$byte_size" \504 --arg contentPath "$content_path" \505 --arg openPath "$open_path" \506 --arg downloadPath "$download_path" \507 --arg originalFilename "$original_filename" \508 --argjson isPrimary "$is_primary_json" \509 '{510 type: "artifact",511 provider: "paperclip",512 title: $title,513 status: $status,514 reviewState: "none",515 isPrimary: $isPrimary,516 healthStatus: "unknown",517 summary: (if $summary == "" then null else $summary end),518 createdByRunId: $runId,519 metadata: {520 attachmentId: $attachmentId,521 contentType: $contentType,522 byteSize: $byteSize,523 contentPath: $contentPath,524 openPath: $openPath,525 downloadPath: $downloadPath,526 originalFilename: (if $originalFilename == "" then null else $originalFilename end)527 }528 }'529 )"530 531 work_product="$(532 request_json \533 POST \534 "$api_base/issues/$issue_id/work-products" \535 "$work_product_payload"536 )"537fi538 539chat_response="null"540if [[ -n "$chat_comment" ]]; then541 chat_comment_payload="$(542 jq -nc \543 --arg body "$chat_comment" \544 --arg attachmentId "$attachment_id" \545 '{body: $body, attachmentIds: [$attachmentId]}'546 )"547 chat_response="$(548 request_json \549 POST \550 "$api_base/issues/$issue_id/comments" \551 "$chat_comment_payload"552 )"553 chat_comment_id="$(jq -r '.id // empty' <<<"$chat_response")"554 if [[ -z "$chat_comment_id" ]]; then555 printf 'Chat attachment response did not include a comment id.\n' >&2556 exit 1557 fi558fi559 560if [[ "$output_format" == "json" ]]; then561 jq -n \562 --argjson attachment "$attachment" \563 --argjson workProduct "$work_product" \564 --argjson chatComment "$chat_response" \565 '{attachment: $attachment, workProduct: $workProduct, chatComment: $chatComment}'566 exit 0567fi568 569work_product_id="$(jq -r '.id // empty' <<<"$work_product")"570 571if [[ "$reused_attachment" == "1" ]]; then572 printf 'Reused matching artifact from this run\n\n'573else574 printf 'Uploaded artifact\n\n'575fi576printf -- '- Attachment: [%s](%s)\n' "$title" "$content_path"577printf -- '- Download: [%s](%s)\n' "$title" "$download_path"578printf -- '- Attachment ID: `%s`\n' "$attachment_id"579if [[ -n "$work_product_id" ]]; then580 printf -- '- Work product ID: `%s`\n' "$work_product_id"581fi582if [[ -n "$chat_comment" ]]; then583 printf -- '- Paperclip comment binding: saved. External publication requires an authorized active chat origin; this helper does not confirm provider delivery.\n'584fi585printf '\nFinal comment snippet:\n\n'586printf -- '- Artifact: [%s](%s)\n' "$title" "$content_path"587