Bundle artifact upload helper with Paperclip skill

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-05-31 18:17:01 +00:00
parent e7cdd0f8c5
commit c4bb68c14b
8 changed files with 18 additions and 14 deletions

View file

@ -98,7 +98,7 @@ If `currentParticipant` does not match you, do not try to advance the stage —
When work produces a user-inspectable file, upload it to the current issue before final disposition. Local filesystem paths are not enough because board users, reviewers, and cloud operators may not have access to the agent workspace.
Use the repo helper when it exists in the active workspace:
Use the helper bundled with this skill. From an installed `paperclip` skill directory, the helper lives at `scripts/paperclip-upload-artifact.sh`:
```bash
scripts/paperclip-upload-artifact.sh path/to/output.webm \

View file

@ -0,0 +1,371 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
paperclip-upload-artifact.sh FILE [options]
Uploads a generated file from the current workspace to the current Paperclip
issue, then creates an attachment-backed artifact work product by default.
Required environment for live uploads:
PAPERCLIP_API_URL, PAPERCLIP_API_KEY, PAPERCLIP_COMPANY_ID, PAPERCLIP_TASK_ID, PAPERCLIP_RUN_ID
Options:
--issue-id ID Issue id to attach to (default: PAPERCLIP_TASK_ID)
--company-id ID Company id (default: PAPERCLIP_COMPANY_ID)
--title TEXT Work product title (default: file basename)
--summary TEXT Work product summary
--content-type TYPE Override detected upload content type
--status STATUS Work product status (default: ready_for_review)
--no-work-product Only upload the issue attachment
--no-primary Do not mark the artifact work product primary for its type
--output FORMAT markdown or json (default: markdown)
--dry-run Print resolved upload settings without calling the API
--help, -h Show this help
Examples:
scripts/paperclip-upload-artifact.sh dist/demo.mp4 \
--title "Demo video render" \
--summary "MP4 render for board review"
scripts/paperclip-upload-artifact.sh out/walkthrough.webm \
--title "Walkthrough video" \
--content-type video/webm
EOF
}
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
printf 'Missing required command: %s\n' "$1" >&2
exit 1
fi
}
json_bool() {
if [[ "${1:-0}" == "1" ]]; then
printf 'true'
else
printf 'false'
fi
}
detect_content_type() {
local path="$1"
local lower
lower="$(printf '%s' "$path" | tr '[:upper:]' '[:lower:]')"
case "$lower" in
*.mp4|*.m4v) printf 'video/mp4' ;;
*.webm) printf 'video/webm' ;;
*.mov|*.qt) printf 'video/quicktime' ;;
*.png) printf 'image/png' ;;
*.jpg|*.jpeg) printf 'image/jpeg' ;;
*.gif) printf 'image/gif' ;;
*.webp) printf 'image/webp' ;;
*.svg) printf 'image/svg+xml' ;;
*.pdf) printf 'application/pdf' ;;
*.txt|*.log) printf 'text/plain' ;;
*.md|*.markdown) printf 'text/markdown' ;;
*.json) printf 'application/json' ;;
*.csv) printf 'text/csv' ;;
*.html|*.htm) printf 'text/html' ;;
*.zip) printf 'application/zip' ;;
*)
if command -v file >/dev/null 2>&1; then
file --brief --mime-type "$path"
else
printf 'application/octet-stream'
fi
;;
esac
}
request_json() {
local method="$1"
local url="$2"
local body="${3:-}"
local response_file
local status_code
response_file="$(mktemp)"
if [[ -n "$body" ]]; then
status_code="$(
curl -sS -X "$method" -w '%{http_code}' -o "$response_file" \
"$url" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-H 'Content-Type: application/json' \
--data-binary "$body"
)"
else
status_code="$(
curl -sS -X "$method" -w '%{http_code}' -o "$response_file" \
"$url" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID"
)"
fi
if [[ "$status_code" -lt 200 || "$status_code" -ge 300 ]]; then
printf 'Request failed (%s): %s\n' "$status_code" "$url" >&2
cat "$response_file" >&2
printf '\n' >&2
rm -f "$response_file"
exit 1
fi
cat "$response_file"
rm -f "$response_file"
}
upload_file() {
local url="$1"
local path="$2"
local content_type="$3"
local escaped_path
local response_file
local status_code
escaped_path="${path//\\/\\\\}"
escaped_path="${escaped_path//\"/\\\"}"
response_file="$(mktemp)"
status_code="$(
curl -sS -X POST -w '%{http_code}' -o "$response_file" \
"$url" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-F "file=@\"${escaped_path}\";type=${content_type}"
)"
if [[ "$status_code" -lt 200 || "$status_code" -ge 300 ]]; then
printf 'Upload failed (%s): %s\n' "$status_code" "$url" >&2
cat "$response_file" >&2
printf '\n' >&2
rm -f "$response_file"
exit 1
fi
cat "$response_file"
rm -f "$response_file"
}
file_path=""
issue_id="${PAPERCLIP_TASK_ID:-}"
company_id="${PAPERCLIP_COMPANY_ID:-}"
title=""
summary=""
content_type=""
status="ready_for_review"
create_work_product=1
is_primary=1
output_format="markdown"
dry_run=0
while [[ $# -gt 0 ]]; do
case "$1" in
--issue-id)
issue_id="${2:-}"
shift 2
;;
--company-id)
company_id="${2:-}"
shift 2
;;
--title)
title="${2:-}"
shift 2
;;
--summary)
summary="${2:-}"
shift 2
;;
--content-type)
content_type="${2:-}"
shift 2
;;
--status)
status="${2:-}"
shift 2
;;
--no-work-product)
create_work_product=0
shift
;;
--no-primary)
is_primary=0
shift
;;
--output)
output_format="${2:-}"
shift 2
;;
--dry-run)
dry_run=1
shift
;;
--help|-h)
usage
exit 0
;;
--*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 1
;;
*)
if [[ -n "$file_path" ]]; then
printf 'Unexpected positional argument: %s\n' "$1" >&2
usage >&2
exit 1
fi
file_path="$1"
shift
;;
esac
done
if [[ -z "$file_path" ]]; then
printf 'Missing file path.\n' >&2
usage >&2
exit 1
fi
if [[ ! -f "$file_path" ]]; then
printf 'Artifact file does not exist: %s\n' "$file_path" >&2
exit 1
fi
if [[ "$output_format" != "markdown" && "$output_format" != "json" ]]; then
printf 'Unsupported output format: %s\n' "$output_format" >&2
exit 1
fi
require_command curl
require_command jq
if [[ -z "$title" ]]; then
title="$(basename "$file_path")"
fi
if [[ -z "$content_type" ]]; then
content_type="$(detect_content_type "$file_path")"
fi
if [[ "$dry_run" == "1" ]]; then
create_work_product_json="$(json_bool "$create_work_product")"
is_primary_json="$(json_bool "$is_primary")"
jq -n \
--arg file "$file_path" \
--arg issueId "$issue_id" \
--arg companyId "$company_id" \
--arg title "$title" \
--arg summary "$summary" \
--arg contentType "$content_type" \
--arg status "$status" \
--argjson createWorkProduct "$create_work_product_json" \
--argjson isPrimary "$is_primary_json" \
'{file: $file, issueId: $issueId, companyId: $companyId, title: $title, summary: $summary, contentType: $contentType, status: $status, createWorkProduct: $createWorkProduct, isPrimary: $isPrimary}'
exit 0
fi
if [[ -z "${PAPERCLIP_API_URL:-}" || -z "${PAPERCLIP_API_KEY:-}" || -z "${PAPERCLIP_RUN_ID:-}" ]]; then
printf 'Missing PAPERCLIP_API_URL, PAPERCLIP_API_KEY, or PAPERCLIP_RUN_ID.\n' >&2
exit 1
fi
if [[ -z "$issue_id" || -z "$company_id" ]]; then
printf 'Missing issue or company id. Pass --issue-id/--company-id or set PAPERCLIP_TASK_ID/PAPERCLIP_COMPANY_ID.\n' >&2
exit 1
fi
api_base="${PAPERCLIP_API_URL%/}/api"
attachment="$(
upload_file \
"$api_base/companies/$company_id/issues/$issue_id/attachments" \
"$file_path" \
"$content_type"
)"
work_product="null"
if [[ "$create_work_product" == "1" ]]; then
is_primary_json="$(json_bool "$is_primary")"
attachment_id="$(jq -r '.id // empty' <<<"$attachment")"
byte_size="$(jq -r '.byteSize // 0' <<<"$attachment")"
content_path="$(jq -r '.contentPath // empty' <<<"$attachment")"
open_path="$(jq -r '.openPath // .contentPath // empty' <<<"$attachment")"
download_path="$(jq -r '.downloadPath // (if .contentPath then (.contentPath + "?download=1") else "" end)' <<<"$attachment")"
original_filename="$(jq -r '.originalFilename // empty' <<<"$attachment")"
if [[ -z "$attachment_id" || -z "$content_path" || -z "$download_path" ]]; then
printf 'Upload response did not include attachment path metadata.\n' >&2
printf '%s\n' "$attachment" >&2
exit 1
fi
work_product_payload="$(
jq -nc \
--arg title "$title" \
--arg summary "$summary" \
--arg status "$status" \
--arg runId "$PAPERCLIP_RUN_ID" \
--arg attachmentId "$attachment_id" \
--arg contentType "$content_type" \
--argjson byteSize "$byte_size" \
--arg contentPath "$content_path" \
--arg openPath "$open_path" \
--arg downloadPath "$download_path" \
--arg originalFilename "$original_filename" \
--argjson isPrimary "$is_primary_json" \
'{
type: "artifact",
provider: "paperclip",
title: $title,
status: $status,
reviewState: "none",
isPrimary: $isPrimary,
healthStatus: "unknown",
summary: (if $summary == "" then null else $summary end),
createdByRunId: $runId,
metadata: {
attachmentId: $attachmentId,
contentType: $contentType,
byteSize: $byteSize,
contentPath: $contentPath,
openPath: $openPath,
downloadPath: $downloadPath,
originalFilename: (if $originalFilename == "" then null else $originalFilename end)
}
}'
)"
work_product="$(
request_json \
POST \
"$api_base/issues/$issue_id/work-products" \
"$work_product_payload"
)"
fi
if [[ "$output_format" == "json" ]]; then
jq -n --argjson attachment "$attachment" --argjson workProduct "$work_product" \
'{attachment: $attachment, workProduct: $workProduct}'
exit 0
fi
content_path="$(jq -r '.contentPath // empty' <<<"$attachment")"
download_path="$(jq -r '.downloadPath // (if .contentPath then (.contentPath + "?download=1") else "" end)' <<<"$attachment")"
attachment_id="$(jq -r '.id // empty' <<<"$attachment")"
work_product_id="$(jq -r '.id // empty' <<<"$work_product")"
printf 'Uploaded artifact\n\n'
printf -- '- Attachment: [%s](%s)\n' "$title" "$content_path"
printf -- '- Download: [%s](%s)\n' "$title" "$download_path"
printf -- '- Attachment ID: `%s`\n' "$attachment_id"
if [[ -n "$work_product_id" ]]; then
printf -- '- Work product ID: `%s`\n' "$work_product_id"
fi
printf '\nFinal comment snippet:\n\n'
printf -- '- Artifact: [%s](%s)\n' "$title" "$content_path"