ops-package
Ship parcels via any configured carrier — MyParcel, Sendcloud, DHL Parcel NL, PostNL, DPD, UPS, FedEx. Auto-selects the first carrier whose credentials are configured, or pass --carrier <name> to override. Verbs: ship, label, track, list, carriers.
Install
npx skills add https://github.com/davepoon/buildwithclaude/tree/main/plugins/claude-ops/skills/ops-package
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install davepoon-buildwithclaude@llmmart
git clone https://github.com/davepoon/buildwithclaude.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole davepoon/buildwithclaude collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
OPS ► PACKAGE — multi-carrier shipping
One skill, seven carriers. The router picks the carrier automatically based on which credentials are configured.
Which carrier do I get?
| Condition | Selected carrier |
|---|---|
--carrier <name> flag passed |
That carrier (validated) |
| Exactly one carrier has credentials | That carrier |
| Multiple carriers have credentials | First match in this order |
| No carrier has credentials | Exit 2 with setup help |
Preference order: myparcel → sendcloud → dhl → postnl → dpd → ups → fedex.
Credential resolution
Each carrier resolves its credential(s) from, in order:
- Environment variable(s) listed below.
preferences.jsonkey (lowercase of the env var name) at${CLAUDE_PLUGIN_DATA_DIR:-$HOME/.claude/plugins/data/ops-ops-marketplace}/preferences.json.- Doppler:
doppler secrets get <NAME> --plain.
| Carrier | Env vars required | Docs |
|---|---|---|
| MyParcel | MYPARCEL_API_KEY |
https://developer.myparcel.nl/api-reference/ |
| Sendcloud | SENDCLOUD_PUBLIC_KEY + SENDCLOUD_PRIVATE_KEY |
https://api.sendcloud.dev/docs/ |
| DHL NL | DHL_PARCEL_USER_ID + DHL_PARCEL_KEY |
https://api-gw.dhlparcel.nl/docs |
| PostNL | POSTNL_API_KEY + POSTNL_CUSTOMER_CODE + POSTNL_CUSTOMER_NUMBER |
https://developer.postnl.nl/ |
| DPD | DPD_DELIS_ID + DPD_PASSWORD |
https://esolutions.dpd.com/ |
| UPS | UPS_CLIENT_ID + UPS_CLIENT_SECRET + UPS_SHIPPER_NUMBER |
https://developer.ups.com/api/reference/shipping |
| FedEx | FEDEX_CLIENT_ID + FEDEX_CLIENT_SECRET + FEDEX_ACCOUNT_NUMBER |
https://developer.fedex.com/api/en-us/catalog/ |
Routing
All API calls are delegated to ${CLAUDE_PLUGIN_ROOT}/skills/ops-package/ops-package.sh. Do not re-implement curl logic inline — pass args through.
| First token | Action |
|---|---|
carriers |
Show configured vs unconfigured carriers |
ship |
Create a shipment |
label |
Download + open label PDF |
track |
Show status + tracking barcode |
list |
List last 10 shipments (MyParcel / Sendcloud; others unsupported) |
| (empty) | Show usage |
ship
Required: --to "<address>". Address format:
"Person / Company, Street 12A, 1011AB Amsterdam, NL"
/ Companysegment is optional.- Postcode accepts with/without space; NL postcodes are normalised to uppercase without space.
- Country defaults to NL; common names (Netherlands, Belgium, Germany, France, UK, USA) map to ISO codes.
Flags (not every carrier honours every flag — unsupported flags are safely ignored per adapter):
--from "<address>"override sender; default is the account's configured sender.--weight <grams>integer grams.--package-type 1|2|31=parcel (default), 2=mailbox, 3=letter (MyParcel only).--signaturerequire signature on delivery.--insurance <EUR>integer EUR; 0 disables (MyParcel only).--description "<text>"label reference (carriers differ; ~45 char max).--pickuprequest home pickup at sender (MyParcel only).
Invocation
${CLAUDE_PLUGIN_ROOT}/skills/ops-package/ops-package.sh \
[--carrier myparcel|sendcloud|dhl|postnl|dpd|ups|fedex] \
ship --to "$TO" [--from "$FROM"] [--weight "$W"] \
[--signature] [--insurance "$INS"] [--description "$DESC"] \
[--package-type "$TYPE"] [--pickup]
Returns:
{"carrier": "<name>", "shipment_id": "<id>", "response": { /* raw carrier JSON */ }}
Summarise to the user as:
Shipment created — <carrier> #<id>
Next: /ops:ops-package label <id> to download the PDF.
Offer via AskUserQuestion (≤4 options):
[Download label now]— calllabel <id>immediately[Track it]— calltrack <id>[Ship another][Done]
label <shipment-id>
${CLAUDE_PLUGIN_ROOT}/skills/ops-package/ops-package.sh label "$ID"
Behaviour per carrier:
- MyParcel: PDF saved to
/tmp/myparcel_label_<id>.pdfand opened on macOS. If unpaid, returns{"status":"payment_required","payment_url":"..."}and opens the MultiSafepay URL. - Sendcloud: Fetches the CDN PDF URL (v2
/labels/<id>) and saves locally. - DHL NL: Fetches
/labels/<id>?format=PDF&printerType=A4. - DPD: Fetches
/v1/parcellabelnumber/<id>?paperFormat=A4. - PostNL / UPS / FedEx: PDF is returned inline with the
shipcall and cached to/tmp/<carrier>_label_<id>.pdf. Callinglabelre-opens that cached file. If the cache is gone, re-runship(none of these carriers expose a stable label-recovery endpoint here).
track <shipment-id>
Returns a normalised object across all carriers:
{"carrier":"<n>","id":"<id>","status":"<s>","barcode":"<b>","tracking_url":"<u>","recipient":<o>,"updated":"<ts>"}
list
MyParcel returns the last 10 shipments. Sendcloud returns the last 10 parcels. All other carriers return {"shipments":[],"note":"..."} because their APIs don't expose a customer-facing list endpoint — track by id instead.
carriers
Prints which carriers are configured (✓) vs which are missing credentials (·). Use this when deciding which --carrier to pass.
Error handling
- No carrier configured → script exits 2 with a checklist of envs. Surface it verbatim, then via
AskUserQuestion:[Paste key now][Open carriers doc][Skip — configure later]On "Paste key now", collect viaAskUserQuestionfree-text and write topreferences.json. - 4xx from carrier → dump the JSON error body and stop. Do not retry silently.
- Address parser looks wrong → re-prompt the user with the parsed breakdown and ask for corrections before POSTing.
Verification status
| Carrier | Status |
|---|---|
| MyParcel | Verified against api.myparcel.nl v1.1 (same working reference) |
| Sendcloud | Verified request/response shapes against Sendcloud Panel API v3 |
| DHL NL | UNVERIFIED — modelled on My DHL Parcel Swagger, needs live account |
| PostNL | UNVERIFIED — modelled on Send API v2.2 docs, needs live account |
| DPD | UNVERIFIED — modelled on DPD eSolutions REST, needs live account |
| UPS | UNVERIFIED — modelled on UPS v2403 Ship/Track REST, needs account |
| FedEx | UNVERIFIED — modelled on FedEx Ship v1 + Track v1 REST |
Unverified adapters are tagged with # UNVERIFIED - pending live test with account in the source. Payloads match the documented contract; adjustments may be needed when tested against live accounts.
Files (buildwithclaude)
-
lib
-
carriers
-
dhl.sh 6.1 KB
#!/usr/bin/env bash # dhl.sh — DHL eCommerce Netherlands (DHL Parcel NL) adapter. # Docs: https://api-gw.dhlparcel.nl/docs (My DHL Parcel API) # Auth: OAuth2-ish "accountless" flow — POST /authenticate/api-key with # {userId, key} to get a JWT access token; use it as Bearer for 10 min. # # UNVERIFIED - pending live test with account. Endpoint shapes are modelled on # the documented Swagger but may require tenant-specific fields. set -euo pipefail DHL_BASE_URL="https://api-gw.dhlparcel.nl" _dhl_token_cache="${TMPDIR:-/tmp}/dhl_parcel_nl_token_${USER:-$(id -u)}.json" dhl_auth_header() { local uid key uid=$(resolve_env "DHL_PARCEL_USER_ID" "dhl_parcel_user_id") || \ die_missing_creds "DHL Parcel NL" "DHL_PARCEL_USER_ID and DHL_PARCEL_KEY" \ "https://www.mydhlparcel.nl/home/user/settings/api-settings" key=$(resolve_env "DHL_PARCEL_KEY" "dhl_parcel_key") || \ die_missing_creds "DHL Parcel NL" "DHL_PARCEL_USER_ID and DHL_PARCEL_KEY" \ "https://www.mydhlparcel.nl/home/user/settings/api-settings" # Re-use cached token if still valid. local now; now=$(date +%s) if [ -f "$_dhl_token_cache" ]; then local expires_at token expires_at=$(jq -r '.expires_at // 0' "$_dhl_token_cache" 2>/dev/null || echo 0) token=$(jq -r '.token // empty' "$_dhl_token_cache" 2>/dev/null || true) if [ -n "$token" ] && [ "$now" -lt "$expires_at" ]; then printf 'Authorization: Bearer %s' "$token" return 0 fi fi local resp resp=$(curl -sS -X POST "$DHL_BASE_URL/authenticate/api-key" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ --data-binary "$(jq -n --arg u "$uid" --arg k "$key" '{userId:$u,key:$k}')") local token token=$(printf '%s' "$resp" | jq -r '.accessToken // empty') if [ -z "$token" ]; then echo "dhl auth: failed — response:" >&2 printf '%s\n' "$resp" >&2 return 1 fi # DHL tokens last ~10 minutes; cache for 9. (umask 077; jq -n --arg t "$token" --arg e "$((now + 540))" \ '{token:$t, expires_at: ($e|tonumber)}' > "$_dhl_token_cache") printf 'Authorization: Bearer %s' "$token" } dhl_ship() { parse_ship_flags "$@" || return $? local auth; auth=$(dhl_auth_header) local receiver receiver=$(jq -n --argjson a "$TO_JSON" '{ name: {firstName: $a.person, companyName: (if $a.company == "" then null else $a.company end)}, address: { countryCode: $a.cc, postalCode: $a.postal_code, city: $a.city, street: $a.street, number: $a.number, addition: (if $a.number_suffix == "" then null else $a.number_suffix end) } | with_entries(select(.value != null)) }') local shipper='null' if [ -n "$FROM_JSON" ]; then shipper=$(jq -n --argjson a "$FROM_JSON" '{ name: {firstName: $a.person, companyName: (if $a.company == "" then null else $a.company end)}, address: { countryCode: $a.cc, postalCode: $a.postal_code, city: $a.city, street: $a.street, number: $a.number, addition: (if $a.number_suffix == "" then null else $a.number_suffix end) } | with_entries(select(.value != null)) }') fi # Product code: DOOR (B2C) default; DOOR_SIG when signature flag set. local product="DOOR" [ "$SIGNATURE" = "true" ] && product="DOOR_SIG" local payload payload=$(jq -n \ --argjson r "$receiver" --argjson s "$shipper" \ --arg product "$product" --arg desc "$DESCRIPTION" \ '{ receiver: $r, shipper: (if $s == null then null else $s end), product: $product, reference: (if $desc == "" then null else $desc end), returnLabel: false } | with_entries(select(.value != null))') local resp resp=$(curl -sS -X POST "$DHL_BASE_URL/labels" \ -H "$auth" -H "$UA_HEADER" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ --data-binary "$payload") local shipment_id shipment_id=$(printf '%s' "$resp" | jq -r '.labelId // .id // empty') if [ -z "$shipment_id" ]; then echo "dhl ship: failed — response:" >&2 printf '%s\n' "$resp" | jq . >&2 2>/dev/null || printf '%s\n' "$resp" >&2 return 1 fi jq -n --arg id "$shipment_id" --argjson resp "$resp" --arg c "dhl" \ '{carrier: $c, shipment_id: $id, response: $resp}' } dhl_label() { local id="${1:-}" [ -z "$id" ] && { echo "dhl label: label id required" >&2; return 64; } local auth; auth=$(dhl_auth_header) local tmp; tmp=$(mktemp) trap 'rm -f "$tmp"' RETURN curl -sS -H "$auth" -H "$UA_HEADER" \ -H "Accept: application/pdf" \ -o "$tmp" \ "$DHL_BASE_URL/labels/${id}?format=PDF&printerType=A4" if file "$tmp" 2>/dev/null | grep -qi "PDF"; then trap - RETURN save_label_pdf "dhl" "$id" "$tmp" else echo "dhl label: unexpected non-PDF response:" >&2 cat "$tmp" >&2 2>/dev/null || true return 1 fi } dhl_track() { local id="${1:-}" [ -z "$id" ] && { echo "dhl track: tracking barcode required" >&2; return 64; } # Public tracking endpoint uses barcode + postal code; our adapter only # has the barcode so we query the authenticated labels endpoint. local auth; auth=$(dhl_auth_header) curl -sS -H "$auth" -H "$UA_HEADER" -H "Accept: application/json" \ "$DHL_BASE_URL/labels/${id}" \ | jq '{ carrier: "dhl", id: .labelId, status: (.status // null), barcode: (.barcode // .trackerCode // null), tracking_url: (.trackingUrl // null), recipient: (.receiver // null), created: (.createdAt // null), updated: (.updatedAt // null) }' } dhl_list() { local auth; auth=$(dhl_auth_header) curl -sS -H "$auth" -H "$UA_HEADER" -H "Accept: application/json" \ "$DHL_BASE_URL/labels?limit=10" \ | jq '[(.labels // . // [])[]? | { carrier: "dhl", id: (.labelId // .id), status: (.status // null), barcode: (.barcode // .trackerCode), recipient: ((.receiver.name.firstName // "") + " — " + (.receiver.address.city // "") + " (" + (.receiver.address.countryCode // "") + ")"), created: (.createdAt // null) }]' } dhl_configured() { resolve_env "DHL_PARCEL_USER_ID" "dhl_parcel_user_id" >/dev/null 2>&1 && \ resolve_env "DHL_PARCEL_KEY" "dhl_parcel_key" >/dev/null 2>&1 } -
dpd.sh 6.5 KB
#!/usr/bin/env bash # dpd.sh — DPD NL / DPD "MyDPD" shipping adapter. # Docs: https://esolutions.dpd.com/dokuwiki/ (DPD REST API, "Shipment service") # Auth: OAuth2-like login to /login with delisId + password → token. Token is # used as `Authorization: <token>` (no Bearer prefix) in subsequent calls. # # UNVERIFIED - pending live test with a DPD business account. DPD's REST API # base URL differs per country (NL uses public-dis-ws.dpd.nl); we support # override via DPD_BASE_URL. set -euo pipefail DPD_DEFAULT_BASE_URL="https://public-dis-ws.dpd.nl/shipping/rest" _dpd_token_cache="${TMPDIR:-/tmp}/dpd_token_${USER:-$(id -u)}.json" _dpd_base_url() { local override override=$(resolve_env "DPD_BASE_URL" "dpd_base_url" 2>/dev/null || true) printf '%s' "${override:-$DPD_DEFAULT_BASE_URL}" } dpd_auth_header() { local delis pw delis=$(resolve_env "DPD_DELIS_ID" "dpd_delis_id") || \ die_missing_creds "DPD" "DPD_DELIS_ID and DPD_PASSWORD" \ "https://esolutions.dpd.com/" pw=$(resolve_env "DPD_PASSWORD" "dpd_password") || \ die_missing_creds "DPD" "DPD_DELIS_ID and DPD_PASSWORD" \ "https://esolutions.dpd.com/" local now; now=$(date +%s) if [ -f "$_dpd_token_cache" ]; then local expires_at token expires_at=$(jq -r '.expires_at // 0' "$_dpd_token_cache" 2>/dev/null || echo 0) token=$(jq -r '.token // empty' "$_dpd_token_cache" 2>/dev/null || true) if [ -n "$token" ] && [ "$now" -lt "$expires_at" ]; then printf 'Authorization: %s' "$token" return 0 fi fi local base; base=$(_dpd_base_url) # DPD login: POST to /login with Basic-auth of delis:password, returns JSON. local b64; b64=$(printf '%s:%s' "$delis" "$pw" | base64 | tr -d '\n') local resp resp=$(curl -sS -X POST "${base%/shipping/rest}/authentication/rest/v1/login" \ -H "Authorization: Basic $b64" \ -H "Content-Type: application/json" \ -H "Accept: application/json") local token token=$(printf '%s' "$resp" | jq -r '.token // .authToken // empty') if [ -z "$token" ]; then echo "dpd auth: failed — response:" >&2 printf '%s\n' "$resp" >&2 return 1 fi # DPD tokens last ~24h; cache for 23. (umask 077; jq -n --arg t "$token" --arg e "$((now + 82800))" \ '{token:$t, expires_at: ($e|tonumber)}' > "$_dpd_token_cache") printf 'Authorization: %s' "$token" } dpd_ship() { parse_ship_flags "$@" || return $? local auth; auth=$(dpd_auth_header) local base; base=$(_dpd_base_url) local recipient recipient=$(jq -n --argjson a "$TO_JSON" '{ name1: (if $a.company == "" then $a.person else $a.company end), name2: (if $a.company == "" then null else $a.person end), street: ($a.street + " " + $a.number + (if $a.number_suffix == "" then "" else $a.number_suffix end)), zipCode: $a.postal_code, city: $a.city, country: $a.cc } | with_entries(select(.value != null))') local sender='null' if [ -n "$FROM_JSON" ]; then sender=$(jq -n --argjson a "$FROM_JSON" '{ name1: (if $a.company == "" then $a.person else $a.company end), street: ($a.street + " " + $a.number + (if $a.number_suffix == "" then "" else $a.number_suffix end)), zipCode: $a.postal_code, city: $a.city, country: $a.cc } | with_entries(select(.value != null))') fi # DPD product code: "CL" classic. Signature in "notification" extras. local payload payload=$(jq -n \ --argjson r "$recipient" --argjson s "$sender" \ --arg weight "${WEIGHT:-500}" \ --arg desc "$DESCRIPTION" \ --arg sig "$SIGNATURE" \ '{ printOptions: {paperFormat: "A4"}, parcels: [{ recipient: $r, sender: (if $s == null then null else $s end), weight: ($weight|tonumber), productAndServiceData: { orderType: "consignment", product: "CL", saturdayDelivery: false, predict: null, additionalService: (if $sig == "true" then {predict: {channel: 1}} else null end) } | with_entries(select(.value != null)), reference1: (if $desc == "" then null else $desc end) } | with_entries(select(.value != null))] }') local resp resp=$(curl -sS -X POST "$base/v1/shipment" \ -H "$auth" -H "$UA_HEADER" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ --data-binary "$payload") local parcel_no parcel_no=$(printf '%s' "$resp" | jq -r '.shipmentResponses[0].parcelInformation[0].parcelLabelNumber // .parcelNumber // empty') if [ -z "$parcel_no" ]; then echo "dpd ship: failed — response:" >&2 printf '%s\n' "$resp" | jq . >&2 2>/dev/null || printf '%s\n' "$resp" >&2 return 1 fi # Persist the parcel→shipment mapping so `label` can re-fetch. echo "$resp" > "${LABEL_DIR}/dpd_shipment_${parcel_no}.json" jq -n --arg id "$parcel_no" --argjson resp "$resp" --arg c "dpd" \ '{carrier: $c, shipment_id: $id, response: $resp}' } dpd_label() { local id="${1:-}" [ -z "$id" ] && { echo "dpd label: parcel number required" >&2; return 64; } local auth; auth=$(dpd_auth_header) local base; base=$(_dpd_base_url) local tmp; tmp=$(mktemp) trap 'rm -f "$tmp"' RETURN curl -sS -H "$auth" -H "$UA_HEADER" -H "Accept: application/pdf" \ -o "$tmp" \ "$base/v1/parcellabelnumber/${id}?paperFormat=A4" if file "$tmp" 2>/dev/null | grep -qi "PDF"; then trap - RETURN save_label_pdf "dpd" "$id" "$tmp" else echo "dpd label: unexpected non-PDF response:" >&2 cat "$tmp" >&2 2>/dev/null || true return 1 fi } dpd_track() { local id="${1:-}" [ -z "$id" ] && { echo "dpd track: parcel number required" >&2; return 64; } local auth; auth=$(dpd_auth_header) local base; base=$(_dpd_base_url) # DPD's parcel-tracking endpoint lives on a separate subdomain but the # authenticated shipment-status endpoint works with the standard base. curl -sS -H "$auth" -H "$UA_HEADER" -H "Accept: application/json" \ "${base%/shipping/rest}/parcellifecycle/rest/v1/status/${id}" \ | jq '{ carrier: "dpd", id: .parcelLabelNumber, status: (.statusInfo[-1].description // null), barcode: .parcelLabelNumber, tracking_url: (.trackingUrl // null), recipient: null, created: null, updated: (.statusInfo[-1].date // null) }' } dpd_list() { # DPD REST API does not offer a multi-shipment listing on the customer side. jq -n '{carrier: "dpd", shipments: [], note: "DPD REST API has no list endpoint; track by parcel number instead."}' } dpd_configured() { resolve_env "DPD_DELIS_ID" "dpd_delis_id" >/dev/null 2>&1 && \ resolve_env "DPD_PASSWORD" "dpd_password" >/dev/null 2>&1 } -
fedex.sh 7.4 KB
#!/usr/bin/env bash # fedex.sh — FedEx REST API adapter. # Docs: https://developer.fedex.com/api/en-us/catalog/ # Auth: OAuth2 client_credentials. POST to /oauth/token with form-encoded body. # # UNVERIFIED - pending live test with FedEx merchant account. Requires a valid # shipping account number (FEDEX_ACCOUNT_NUMBER). set -euo pipefail FEDEX_BASE_URL_PROD="https://apis.fedex.com" FEDEX_BASE_URL_SANDBOX="https://apis-sandbox.fedex.com" _fedex_token_cache="${TMPDIR:-/tmp}/fedex_token_${USER:-$(id -u)}.json" _fedex_base_url() { if [ "${FEDEX_SANDBOX:-0}" = "1" ]; then printf '%s' "$FEDEX_BASE_URL_SANDBOX" else printf '%s' "$FEDEX_BASE_URL_PROD" fi } fedex_auth_header() { local cid secret cid=$(resolve_env "FEDEX_CLIENT_ID" "fedex_client_id") || \ die_missing_creds "FedEx" "FEDEX_CLIENT_ID, FEDEX_CLIENT_SECRET, and FEDEX_ACCOUNT_NUMBER" \ "https://developer.fedex.com/api/en-us/get-started.html" secret=$(resolve_env "FEDEX_CLIENT_SECRET" "fedex_client_secret") || \ die_missing_creds "FedEx" "FEDEX_CLIENT_ID, FEDEX_CLIENT_SECRET, and FEDEX_ACCOUNT_NUMBER" \ "https://developer.fedex.com/api/en-us/get-started.html" local now; now=$(date +%s) if [ -f "$_fedex_token_cache" ]; then local expires_at token expires_at=$(jq -r '.expires_at // 0' "$_fedex_token_cache" 2>/dev/null || echo 0) token=$(jq -r '.token // empty' "$_fedex_token_cache" 2>/dev/null || true) if [ -n "$token" ] && [ "$now" -lt "$expires_at" ]; then printf 'Authorization: Bearer %s' "$token" return 0 fi fi local base; base=$(_fedex_base_url) local resp resp=$(curl -sS -X POST "$base/oauth/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -H "Accept: application/json" \ --data-urlencode "grant_type=client_credentials" \ --data-urlencode "client_id=${cid}" \ --data-urlencode "client_secret=${secret}") local token ttl token=$(printf '%s' "$resp" | jq -r '.access_token // empty') ttl=$(printf '%s' "$resp" | jq -r '.expires_in // 3600') if [ -z "$token" ]; then echo "fedex auth: failed — response:" >&2 printf '%s\n' "$resp" >&2 return 1 fi (umask 077; jq -n --arg t "$token" --arg e "$((now + ttl - 60))" \ '{token:$t, expires_at: ($e|tonumber)}' > "$_fedex_token_cache") printf 'Authorization: Bearer %s' "$token" } _fedex_contact_address() { local json="$1" jq -n --argjson a "$json" '{ contact: { personName: $a.person, companyName: (if $a.company == "" then null else $a.company end) } | with_entries(select(.value != null)), address: { streetLines: [($a.street + " " + $a.number + (if $a.number_suffix == "" then "" else $a.number_suffix end))], city: $a.city, postalCode: $a.postal_code, countryCode: $a.cc } }' } fedex_ship() { parse_ship_flags "$@" || return $? local auth; auth=$(fedex_auth_header) local base; base=$(_fedex_base_url) local account account=$(resolve_env "FEDEX_ACCOUNT_NUMBER" "fedex_account_number") || \ die_missing_creds "FedEx" "FEDEX_ACCOUNT_NUMBER" \ "https://developer.fedex.com/api/en-us/get-started.html" local to; to=$(_fedex_contact_address "$TO_JSON") local from if [ -n "$FROM_JSON" ]; then from=$(_fedex_contact_address "$FROM_JSON") else from=$(jq -n '{}') fi # Weight in kilograms, rounded to 3 decimals. local weight_kg="1.000" [ -n "$WEIGHT" ] && weight_kg=$(awk -v g="$WEIGHT" 'BEGIN{ printf "%.3f", g/1000 }') local payload payload=$(jq -n \ --argjson from "$from" --argjson to "$to" \ --arg account "$account" \ --arg weight "$weight_kg" \ --arg desc "$DESCRIPTION" \ --arg sig "$SIGNATURE" \ '{ labelResponseOptions: "URL_ONLY", requestedShipment: { shipper: $from, recipients: [$to], shipDatestamp: (now | strftime("%Y-%m-%d")), serviceType: "FEDEX_INTERNATIONAL_PRIORITY", packagingType: "YOUR_PACKAGING", pickupType: "USE_SCHEDULED_PICKUP", shippingChargesPayment: {paymentType: "SENDER", payor: {responsibleParty: {accountNumber: {value: $account}}}}, labelSpecification: {imageType: "PDF", labelStockType: "PAPER_4X6"}, requestedPackageLineItems: [{ weight: {units: "KG", value: ($weight|tonumber)}, customerReferences: (if $desc == "" then null else [{customerReferenceType: "CUSTOMER_REFERENCE", value: $desc}] end) } | with_entries(select(.value != null))] }, accountNumber: {value: $account} }') local resp resp=$(curl -sS -X POST "$base/ship/v1/shipments" \ -H "$auth" -H "$UA_HEADER" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -H "x-locale: en_US" \ --data-binary "$payload") local tracking tracking=$(printf '%s' "$resp" | jq -r '.output.transactionShipments[0].masterTrackingNumber // empty') if [ -z "$tracking" ]; then echo "fedex ship: failed — response:" >&2 printf '%s\n' "$resp" | jq . >&2 2>/dev/null || printf '%s\n' "$resp" >&2 return 1 fi # Download the label URL from the response. local label_url label_url=$(printf '%s' "$resp" | jq -r '.output.transactionShipments[0].pieceResponses[0].packageDocuments[0].url // empty') if [ -n "$label_url" ]; then local out="${LABEL_DIR}/fedex_label_${tracking}.pdf" curl -sS -H "$auth" -H "$UA_HEADER" -o "$out" "$label_url" || true fi jq -n --arg id "$tracking" --argjson resp "$resp" --arg c "fedex" \ '{carrier: $c, shipment_id: $id, response: $resp}' } fedex_label() { local id="${1:-}" [ -z "$id" ] && { echo "fedex label: tracking number required" >&2; return 64; } local out="${LABEL_DIR}/fedex_label_${id}.pdf" if [ ! -s "$out" ]; then cat >&2 <<EOF fedex label: FedEx returns the label URL on the ship response; the cached file ${out} is missing. Re-run ship, or recover via FedEx LabelRecovery API (not implemented). EOF return 1 fi if [[ "$(uname)" == "Darwin" ]] && [ -t 1 ]; then open "$out" >/dev/null 2>&1 || true fi jq -n --arg p "$out" '{status: "ok", label_pdf: $p}' } fedex_track() { local id="${1:-}" [ -z "$id" ] && { echo "fedex track: tracking number required" >&2; return 64; } local auth; auth=$(fedex_auth_header) local base; base=$(_fedex_base_url) local payload payload=$(jq -n --arg t "$id" '{ includeDetailedScans: true, trackingInfo: [{trackingNumberInfo: {trackingNumber: $t}}] }') curl -sS -X POST "$base/track/v1/trackingnumbers" \ -H "$auth" -H "$UA_HEADER" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -H "x-locale: en_US" \ --data-binary "$payload" \ | jq '{ carrier: "fedex", id: (.output.completeTrackResults[0].trackingNumber // null), status: (.output.completeTrackResults[0].trackResults[0].latestStatusDetail.description // null), barcode: (.output.completeTrackResults[0].trackingNumber // null), tracking_url: null, recipient: null, created: null, updated: (.output.completeTrackResults[0].trackResults[0].dateAndTimes[0].dateTime // null) }' } fedex_list() { jq -n '{carrier: "fedex", shipments: [], note: "FedEx API has no list endpoint; track by tracking number instead."}' } fedex_configured() { resolve_env "FEDEX_CLIENT_ID" "fedex_client_id" >/dev/null 2>&1 && \ resolve_env "FEDEX_CLIENT_SECRET" "fedex_client_secret" >/dev/null 2>&1 && \ resolve_env "FEDEX_ACCOUNT_NUMBER" "fedex_account_number" >/dev/null 2>&1 } -
myparcel.sh 6.5 KB
#!/usr/bin/env bash # myparcel.sh — MyParcel.nl adapter. VERIFIED against api.myparcel.nl v1.1. # Docs: https://developer.myparcel.nl/api-reference/ # Auth: Basic, base64(api_key + ":") — we base64 the raw key alone which the # API accepts (the colon is optional in MyParcel's flavour). set -euo pipefail MYPARCEL_BASE_URL="https://api.myparcel.nl" myparcel_auth_header() { local k; k=$(resolve_env "MYPARCEL_API_KEY" "myparcel_api_key") || \ die_missing_creds "MyParcel.nl" "MYPARCEL_API_KEY" "https://developer.myparcel.nl/api-reference/" local b64; b64=$(printf '%s' "$k" | base64 | tr -d '\n') printf 'Authorization: Basic %s' "$b64" } myparcel_ship() { parse_ship_flags "$@" || return $? local auth; auth=$(myparcel_auth_header) local recipient recipient=$(jq -n --argjson a "$TO_JSON" '{ cc: $a.cc, person: $a.person, company: (if $a.company == "" then null else $a.company end), street: $a.street, number: $a.number, number_suffix: (if $a.number_suffix == "" then null else $a.number_suffix end), postal_code: $a.postal_code, city: $a.city } | with_entries(select(.value != null))') local sender_block='null' if [ -n "$FROM_JSON" ]; then sender_block=$(jq -n --argjson a "$FROM_JSON" '{ cc: $a.cc, person: $a.person, company: (if $a.company == "" then null else $a.company end), street: $a.street, number: $a.number, number_suffix: (if $a.number_suffix == "" then null else $a.number_suffix end), postal_code: $a.postal_code, city: $a.city } | with_entries(select(.value != null))') fi # For NL insured shipments MyParcel requires signature + only_recipient. local _sig_val="$SIGNATURE" local _only_recip="false" if [ "$INSURANCE" -gt 0 ] 2>/dev/null; then local _to_cc; _to_cc=$(printf '%s' "$TO_JSON" | jq -r '.cc // "NL"') if [ "$_to_cc" = "NL" ]; then _sig_val="true" _only_recip="true" fi fi local options options=$(jq -n \ --argjson pkg "$PKG_TYPE" \ --arg sig "$_sig_val" \ --arg only "$_only_recip" \ --argjson ins "$INSURANCE" \ --arg desc "$DESCRIPTION" \ '{ package_type: $pkg, signature: ($sig == "true"), only_recipient: (if $only == "true" then true else null end), label_description: (if $desc == "" then null else $desc end), insurance: (if $ins > 0 then {amount: ($ins * 100), currency: "EUR"} else null end) } | with_entries(select(.value != null))') local phys='null' if [ -n "$WEIGHT" ]; then phys=$(jq -n --argjson w "$WEIGHT" '{weight: $w}') fi local pickup_block='null' if [ "$PICKUP" = "true" ] && [ "$sender_block" != "null" ]; then pickup_block=$(jq -n --argjson s "$sender_block" '$s + {location_name: $s.company}') elif [ "$PICKUP" = "true" ]; then pickup_block=$(jq -n '{}') fi local shipment shipment=$(jq -n \ --argjson recipient "$recipient" \ --argjson sender "$sender_block" \ --argjson options "$options" \ --argjson phys "$phys" \ --argjson pickup "$pickup_block" \ '{ recipient: $recipient, options: $options, carrier: 1, sender: (if $sender == null then null else $sender end), physical_properties: (if $phys == null then null else $phys end), pickup: (if $pickup == null then null else $pickup end) } | with_entries(select(.value != null))') local payload payload=$(jq -n --argjson s "$shipment" '{data: {shipments: [$s]}}') local resp resp=$(curl -sS -X POST "$MYPARCEL_BASE_URL/shipments" \ -H "$auth" -H "$UA_HEADER" \ -H "Content-Type: application/vnd.shipment+json;version=1.1;charset=utf-8" \ -H "Accept: application/json;charset=utf-8" \ --data-binary "$payload") local shipment_id shipment_id=$(printf '%s' "$resp" | jq -r '.data.ids[0].id // empty') if [ -z "$shipment_id" ]; then echo "myparcel ship: failed — response:" >&2 printf '%s\n' "$resp" | jq . >&2 2>/dev/null || printf '%s\n' "$resp" >&2 return 1 fi jq -n --arg id "$shipment_id" --argjson resp "$resp" --arg c "myparcel" \ '{carrier: $c, shipment_id: $id, response: $resp}' } myparcel_label() { local id="${1:-}" [ -z "$id" ] && { echo "myparcel label: shipment id required" >&2; return 64; } local auth; auth=$(myparcel_auth_header) local tmp_body tmp_headers tmp_body=$(mktemp); tmp_headers=$(mktemp) trap 'rm -f "$tmp_body" "$tmp_headers"' RETURN curl -sS -D "$tmp_headers" -o "$tmp_body" \ -H "$auth" -H "$UA_HEADER" -H "Accept: application/pdf" \ "$MYPARCEL_BASE_URL/shipment_labels/${id}?format=A4&positions=1" local ctype ctype=$(awk -F': *' 'tolower($1)=="content-type"{print tolower($2)}' "$tmp_headers" | tr -d '\r' | tail -1) if [[ "$ctype" == application/pdf* ]]; then trap - RETURN rm -f "$tmp_headers" save_label_pdf "myparcel" "$id" "$tmp_body" else local body; body=$(cat "$tmp_body") local pay_url pay_url=$(printf '%s' "$body" | jq -r '.data.payment_instructions.payment_url // empty' 2>/dev/null) if [ -n "$pay_url" ]; then if [[ "$(uname)" == "Darwin" ]] && [ -t 1 ]; then open "$pay_url" >/dev/null 2>&1 || true fi jq -n --arg u "$pay_url" '{status: "payment_required", payment_url: $u}' else echo "myparcel label: unexpected response (content-type=$ctype):" >&2 printf '%s\n' "$body" >&2 return 1 fi fi } myparcel_track() { local id="${1:-}" [ -z "$id" ] && { echo "myparcel track: shipment id required" >&2; return 64; } local auth; auth=$(myparcel_auth_header) curl -sS -H "$auth" -H "$UA_HEADER" \ -H "Accept: application/json;charset=utf-8" \ "$MYPARCEL_BASE_URL/shipments/${id}" \ | jq '{ carrier: "myparcel", id: .data.shipments[0].id, status: .data.shipments[0].status, barcode: .data.shipments[0].barcode, tracking_url: .data.shipments[0].tracking_url, recipient: .data.shipments[0].recipient, created: .data.shipments[0].created, updated: .data.shipments[0].modified }' } myparcel_list() { local auth; auth=$(myparcel_auth_header) curl -sS -H "$auth" -H "$UA_HEADER" \ -H "Accept: application/json;charset=utf-8" \ "$MYPARCEL_BASE_URL/shipments?size=30&page=1" \ | jq '[.data.shipments[] | { carrier: "myparcel", id, status, barcode, recipient: ((.recipient.person // "") + " — " + (.recipient.city // "") + " (" + (.recipient.cc // "") + ")"), created }]' } # Probe whether credentials are available without hitting the network. myparcel_configured() { resolve_env "MYPARCEL_API_KEY" "myparcel_api_key" >/dev/null 2>&1 } -
postnl.sh 6.5 KB
#!/usr/bin/env bash # postnl.sh — PostNL Send API (Shipping Webservice v2.2) adapter. # Docs: https://developer.postnl.nl/apis/shipping-api # Auth: "apikey" header with customer-specific API key. # # UNVERIFIED - pending live test with a PostNL business account. Sandbox and # production share the same shape but different base URLs. Set # POSTNL_SANDBOX=1 to route to the sandbox host. set -euo pipefail POSTNL_BASE_URL_PROD="https://api.postnl.nl" POSTNL_BASE_URL_SANDBOX="https://api-sandbox.postnl.nl" _postnl_base_url() { if [ "${POSTNL_SANDBOX:-0}" = "1" ]; then printf '%s' "$POSTNL_BASE_URL_SANDBOX" else printf '%s' "$POSTNL_BASE_URL_PROD" fi } postnl_auth_header() { local k; k=$(resolve_env "POSTNL_API_KEY" "postnl_api_key") || \ die_missing_creds "PostNL" "POSTNL_API_KEY (plus POSTNL_CUSTOMER_CODE and POSTNL_CUSTOMER_NUMBER)" \ "https://developer.postnl.nl/" printf 'apikey: %s' "$k" } _postnl_customer() { local code num code=$(resolve_env "POSTNL_CUSTOMER_CODE" "postnl_customer_code") || true num=$(resolve_env "POSTNL_CUSTOMER_NUMBER" "postnl_customer_number") || true if [ -z "$code" ] || [ -z "$num" ]; then die_missing_creds "PostNL" "POSTNL_CUSTOMER_CODE and POSTNL_CUSTOMER_NUMBER" \ "https://developer.postnl.nl/docs/#/onboarding" fi jq -n --arg c "$code" --arg n "$num" '{CustomerCode:$c, CustomerNumber:$n}' } postnl_ship() { parse_ship_flags "$@" || return $? local auth; auth=$(postnl_auth_header) local base; base=$(_postnl_base_url) local customer; customer=$(_postnl_customer) # Addresses[] array — receiver is AddressType "01", sender "02". local receiver receiver=$(jq -n --argjson a "$TO_JSON" '{ AddressType: "01", FirstName: $a.person, CompanyName: (if $a.company == "" then null else $a.company end), Street: $a.street, HouseNr: $a.number, HouseNrExt: (if $a.number_suffix == "" then null else $a.number_suffix end), Zipcode: $a.postal_code, City: $a.city, Countrycode: $a.cc } | with_entries(select(.value != null))') local sender='null' if [ -n "$FROM_JSON" ]; then sender=$(jq -n --argjson a "$FROM_JSON" '{ AddressType: "02", FirstName: $a.person, CompanyName: (if $a.company == "" then null else $a.company end), Street: $a.street, HouseNr: $a.number, HouseNrExt: (if $a.number_suffix == "" then null else $a.number_suffix end), Zipcode: $a.postal_code, City: $a.city, Countrycode: $a.cc } | with_entries(select(.value != null))') fi # 3085 = standard NL delivery; 3087 = signature. local product_code="3085" [ "$SIGNATURE" = "true" ] && product_code="3087" local shipment shipment=$(jq -n \ --argjson receiver "$receiver" --argjson sender "$sender" \ --argjson customer "$customer" \ --arg productCode "$product_code" \ --arg reference "$DESCRIPTION" \ --arg weight "${WEIGHT:-500}" \ '{ Addresses: ([$receiver] + (if $sender == null then [] else [$sender] end)), Dimension: {Weight: ($weight|tonumber)}, ProductCodeDelivery: $productCode, Reference: (if $reference == "" then null else $reference end), Customer: $customer } | with_entries(select(.value != null))') local payload payload=$(jq -n --argjson s "$shipment" --argjson c "$customer" '{ Customer: $c, Message: {Printertype: "GraphicFile|PDF", MessageID: "1", MessageTimeStamp: (now|strftime("%d-%m-%Y %H:%M:%S"))}, Shipments: [$s] }') local resp resp=$(curl -sS -X POST "$base/shipment/v2_2/label" \ -H "$auth" -H "$UA_HEADER" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ --data-binary "$payload") local barcode barcode=$(printf '%s' "$resp" | jq -r '.ResponseShipments[0].Barcode // empty') if [ -z "$barcode" ]; then echo "postnl ship: failed — response:" >&2 printf '%s\n' "$resp" | jq . >&2 2>/dev/null || printf '%s\n' "$resp" >&2 return 1 fi # PostNL returns the label PDF as base64 in the same call. Save it so a # subsequent `label <barcode>` can open it without another round-trip. local pdf_b64 pdf_b64=$(printf '%s' "$resp" | jq -r '.ResponseShipments[0].Labels[0].Content // empty') if [ -n "$pdf_b64" ]; then local out="${LABEL_DIR}/postnl_label_${barcode}.pdf" printf '%s' "$pdf_b64" | base64 -d > "$out" 2>/dev/null || \ printf '%s' "$pdf_b64" | base64 -D > "$out" 2>/dev/null || true fi jq -n --arg id "$barcode" --argjson resp "$resp" --arg c "postnl" \ '{carrier: $c, shipment_id: $id, response: $resp}' } postnl_label() { local id="${1:-}" [ -z "$id" ] && { echo "postnl label: barcode required" >&2; return 64; } # PostNL does not expose a "re-download label" endpoint; the PDF is only # returned alongside the original /label call. If ship saved it, reuse it. local out="${LABEL_DIR}/postnl_label_${id}.pdf" if [ -s "$out" ]; then if [[ "$(uname)" == "Darwin" ]] && [ -t 1 ]; then open "$out" >/dev/null 2>&1 || true fi jq -n --arg p "$out" '{status: "ok", label_pdf: $p}' else cat >&2 <<EOF postnl label: PostNL returns the PDF only on shipment creation. The cached file ${out} is missing. Re-ship or export from MijnPostNL. EOF return 1 fi } postnl_track() { local id="${1:-}" [ -z "$id" ] && { echo "postnl track: barcode required" >&2; return 64; } local auth; auth=$(postnl_auth_header) local base; base=$(_postnl_base_url) local cc; cc=$(resolve_env "POSTNL_DEFAULT_COUNTRY" "postnl_default_country" 2>/dev/null || printf 'NL') curl -sS -H "$auth" -H "$UA_HEADER" -H "Accept: application/json" \ "$base/shipment/v2/status/barcode/${id}?countrycode=${cc}" \ | jq '{ carrier: "postnl", id: .CurrentStatus.Shipment.Barcode, status: (.CurrentStatus.Shipment.Status.StatusDescription // null), barcode: .CurrentStatus.Shipment.Barcode, tracking_url: null, recipient: (.CurrentStatus.Shipment.Addresses // null), created: null, updated: (.CurrentStatus.Shipment.Status.TimeStamp // null) }' } postnl_list() { # PostNL shipping API does not expose a "list my last shipments" endpoint # in the public contract — customers maintain their own record in MijnPostNL. jq -n '{carrier: "postnl", shipments: [], note: "PostNL Send API has no list endpoint; track by barcode instead."}' } postnl_configured() { resolve_env "POSTNL_API_KEY" "postnl_api_key" >/dev/null 2>&1 && \ resolve_env "POSTNL_CUSTOMER_CODE" "postnl_customer_code" >/dev/null 2>&1 && \ resolve_env "POSTNL_CUSTOMER_NUMBER" "postnl_customer_number" >/dev/null 2>&1 } -
sendcloud.sh 5.4 KB
#!/usr/bin/env bash # sendcloud.sh — Sendcloud adapter. VERIFIED against Sendcloud Panel API v3. # Docs: https://api.sendcloud.dev/docs/ # v2 is deprecated for most resources; v3 is the current platform. However # labels on v3 are returned as URLs pointing to CDN-hosted PDFs, which we # download and re-save locally. # Auth: HTTP Basic, "public_key:private_key". Both are issued from # Sendcloud Panel → Settings → Integrations → Sendcloud API. set -euo pipefail SENDCLOUD_BASE_V3="https://panel.sendcloud.sc/api/v3" SENDCLOUD_BASE_V2="https://panel.sendcloud.sc/api/v2" sendcloud_auth_header() { local pub pri pub=$(resolve_env "SENDCLOUD_PUBLIC_KEY" "sendcloud_public_key") || \ die_missing_creds "Sendcloud" "SENDCLOUD_PUBLIC_KEY and SENDCLOUD_PRIVATE_KEY" \ "https://panel.sendcloud.sc/shipping/settings/integrations/sendcloud-api" pri=$(resolve_env "SENDCLOUD_PRIVATE_KEY" "sendcloud_private_key") || \ die_missing_creds "Sendcloud" "SENDCLOUD_PUBLIC_KEY and SENDCLOUD_PRIVATE_KEY" \ "https://panel.sendcloud.sc/shipping/settings/integrations/sendcloud-api" local b64; b64=$(printf '%s:%s' "$pub" "$pri" | base64 | tr -d '\n') printf 'Authorization: Basic %s' "$b64" } sendcloud_ship() { parse_ship_flags "$@" || return $? local auth; auth=$(sendcloud_auth_header) # Sendcloud weight is in kilograms as a string with 3 decimals. local weight_kg="1.000" if [ -n "$WEIGHT" ]; then weight_kg=$(awk -v g="$WEIGHT" 'BEGIN{ printf "%.3f", g/1000 }') fi # Build a v3 parcels payload. Fields follow # https://api.sendcloud.dev/docs/#/Parcels/post_api_v3_parcels local recipient recipient=$(jq -n --argjson a "$TO_JSON" --arg w "$weight_kg" --arg desc "$DESCRIPTION" '{ name: $a.person, company_name: (if $a.company == "" then null else $a.company end), address_line_1: ($a.street + " " + $a.number + (if $a.number_suffix == "" then "" else $a.number_suffix end)), house_number: $a.number, postal_code: $a.postal_code, city: $a.city, country_code: $a.cc } | with_entries(select(.value != null))') local parcel parcel=$(jq -n \ --argjson to "$recipient" \ --arg w "$weight_kg" \ --arg desc "$DESCRIPTION" \ --arg req_sig "$SIGNATURE" \ '{ ship_to: $to, weight: {value: $w, unit: "kg"}, description: (if $desc == "" then null else $desc end), request_label: true, request_signature: ($req_sig == "true") } | with_entries(select(.value != null))') local payload payload=$(jq -n --argjson p "$parcel" '{parcels: [$p]}') local resp resp=$(curl -sS -X POST "$SENDCLOUD_BASE_V3/parcels" \ -H "$auth" -H "$UA_HEADER" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ --data-binary "$payload") local shipment_id shipment_id=$(printf '%s' "$resp" | jq -r '.data[0].id // .parcels[0].id // .id // empty') if [ -z "$shipment_id" ]; then echo "sendcloud ship: failed — response:" >&2 printf '%s\n' "$resp" | jq . >&2 2>/dev/null || printf '%s\n' "$resp" >&2 return 1 fi jq -n --arg id "$shipment_id" --argjson resp "$resp" --arg c "sendcloud" \ '{carrier: $c, shipment_id: $id, response: $resp}' } sendcloud_label() { local id="${1:-}" [ -z "$id" ] && { echo "sendcloud label: parcel id required" >&2; return 64; } local auth; auth=$(sendcloud_auth_header) # v2 label endpoint returns JSON with PDF URLs; v3 exposes the same as a # related resource. Use v2 for the label PDF fetch — it's stable. local meta meta=$(curl -sS -H "$auth" -H "$UA_HEADER" -H "Accept: application/json" \ "$SENDCLOUD_BASE_V2/labels/${id}") local pdf_url pdf_url=$(printf '%s' "$meta" | jq -r '.label.normal_printer[0] // .label.label_printer // empty') if [ -z "$pdf_url" ]; then echo "sendcloud label: no PDF URL in response:" >&2 printf '%s\n' "$meta" | jq . >&2 2>/dev/null || printf '%s\n' "$meta" >&2 return 1 fi local tmp; tmp=$(mktemp) trap 'rm -f "$tmp"' RETURN curl -sS -H "$auth" -H "$UA_HEADER" -o "$tmp" "$pdf_url" trap - RETURN save_label_pdf "sendcloud" "$id" "$tmp" } sendcloud_track() { local id="${1:-}" [ -z "$id" ] && { echo "sendcloud track: parcel id required" >&2; return 64; } local auth; auth=$(sendcloud_auth_header) curl -sS -H "$auth" -H "$UA_HEADER" -H "Accept: application/json" \ "$SENDCLOUD_BASE_V3/parcels/${id}" \ | jq '{ carrier: "sendcloud", id: (.data.id // .id), status: (.data.status.message // .status.message // .status // null), barcode: (.data.tracking_number // .tracking_number), tracking_url: (.data.tracking_url // .tracking_url), recipient: (.data.ship_to // .ship_to), created: (.data.date_created // .date_created), updated: (.data.date_updated // .date_updated) }' } sendcloud_list() { local auth; auth=$(sendcloud_auth_header) curl -sS -H "$auth" -H "$UA_HEADER" -H "Accept: application/json" \ "$SENDCLOUD_BASE_V3/parcels?limit=10" \ | jq '[(.data // .parcels // [])[] | { carrier: "sendcloud", id, status: (.status.message // .status), barcode: .tracking_number, recipient: ((.ship_to.name // .name // "") + " — " + (.ship_to.city // .city // "") + " (" + (.ship_to.country_code // .country.iso_2 // "") + ")"), created: (.date_created // null) }]' } sendcloud_configured() { resolve_env "SENDCLOUD_PUBLIC_KEY" "sendcloud_public_key" >/dev/null 2>&1 && \ resolve_env "SENDCLOUD_PRIVATE_KEY" "sendcloud_private_key" >/dev/null 2>&1 } -
ups.sh 7 KB
#!/usr/bin/env bash # ups.sh — UPS Shipping REST API adapter (v2403). # Docs: https://developer.ups.com/api/reference/shipping # Auth: OAuth2 client_credentials. POST to /security/v1/oauth/token with # Basic-auth of client_id:client_secret and form-encoded grant_type. # # UNVERIFIED - pending live test with UPS merchant account. Shipper number and # payment account fields are required to actually book; we surface them via # UPS_SHIPPER_NUMBER. set -euo pipefail UPS_BASE_URL="https://onlinetools.ups.com" _ups_token_cache="${TMPDIR:-/tmp}/ups_token_${USER:-$(id -u)}.json" ups_auth_header() { local cid secret cid=$(resolve_env "UPS_CLIENT_ID" "ups_client_id") || \ die_missing_creds "UPS" "UPS_CLIENT_ID, UPS_CLIENT_SECRET, and UPS_SHIPPER_NUMBER" \ "https://developer.ups.com/get-started" secret=$(resolve_env "UPS_CLIENT_SECRET" "ups_client_secret") || \ die_missing_creds "UPS" "UPS_CLIENT_ID, UPS_CLIENT_SECRET, and UPS_SHIPPER_NUMBER" \ "https://developer.ups.com/get-started" local now; now=$(date +%s) if [ -f "$_ups_token_cache" ]; then local expires_at token expires_at=$(jq -r '.expires_at // 0' "$_ups_token_cache" 2>/dev/null || echo 0) token=$(jq -r '.token // empty' "$_ups_token_cache" 2>/dev/null || true) if [ -n "$token" ] && [ "$now" -lt "$expires_at" ]; then printf 'Authorization: Bearer %s' "$token" return 0 fi fi local b64; b64=$(printf '%s:%s' "$cid" "$secret" | base64 | tr -d '\n') local resp resp=$(curl -sS -X POST "$UPS_BASE_URL/security/v1/oauth/token" \ -H "Authorization: Basic $b64" \ -H "Content-Type: application/x-www-form-urlencoded" \ -H "Accept: application/json" \ --data 'grant_type=client_credentials') local token ttl token=$(printf '%s' "$resp" | jq -r '.access_token // empty') ttl=$(printf '%s' "$resp" | jq -r '.expires_in // 3600') if [ -z "$token" ]; then echo "ups auth: failed — response:" >&2 printf '%s\n' "$resp" >&2 return 1 fi (umask 077; jq -n --arg t "$token" --arg e "$((now + ttl - 60))" \ '{token:$t, expires_at: ($e|tonumber)}' > "$_ups_token_cache") printf 'Authorization: Bearer %s' "$token" } _ups_address() { local json="$1" jq -n --argjson a "$json" '{ Name: (if $a.company == "" then $a.person else $a.company end), AttentionName: $a.person, Address: { AddressLine: [($a.street + " " + $a.number + (if $a.number_suffix == "" then "" else $a.number_suffix end))], City: $a.city, PostalCode: $a.postal_code, CountryCode: $a.cc } }' } ups_ship() { parse_ship_flags "$@" || return $? local auth; auth=$(ups_auth_header) local shipper_num shipper_num=$(resolve_env "UPS_SHIPPER_NUMBER" "ups_shipper_number") || \ die_missing_creds "UPS" "UPS_SHIPPER_NUMBER" \ "https://developer.ups.com/get-started" local to; to=$(_ups_address "$TO_JSON") local from if [ -n "$FROM_JSON" ]; then from=$(_ups_address "$FROM_JSON") else from=$(jq -n '{}') fi # Service code 11 = UPS Standard (EU ground). Signature delivery confirmation = 2. local payload payload=$(jq -n \ --argjson from "$from" --argjson to "$to" \ --arg shipper "$shipper_num" \ --arg weight "${WEIGHT:-500}" \ --arg desc "$DESCRIPTION" \ --arg sig "$SIGNATURE" \ '{ ShipmentRequest: { Shipment: { Description: (if $desc == "" then "Goods" else $desc end), Shipper: ($from + {ShipperNumber: $shipper}), ShipTo: $to, ShipFrom: $from, PaymentInformation: {ShipmentCharge: {Type: "01", BillShipper: {AccountNumber: $shipper}}}, Service: {Code: "11", Description: "UPS Standard"}, Package: [{ Description: (if $desc == "" then "Parcel" else $desc end), Packaging: {Code: "02", Description: "Customer Supplied"}, PackageWeight: {UnitOfMeasurement: {Code: "KGS"}, Weight: (($weight|tonumber)/1000|tostring)}, PackageServiceOptions: (if $sig == "true" then {DeliveryConfirmation: {DCISType: "2"}} else null end) } | with_entries(select(.value != null))] }, LabelSpecification: {LabelImageFormat: {Code: "PDF"}, LabelStockSize: {Height: "6", Width: "4"}} } }') local resp resp=$(curl -sS -X POST "$UPS_BASE_URL/api/shipments/v2403/ship" \ -H "$auth" -H "$UA_HEADER" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -H "transId: $(date +%s%N)" \ -H "transactionSrc: claude-ops" \ --data-binary "$payload") local tracking tracking=$(printf '%s' "$resp" | jq -r '.ShipmentResponse.ShipmentResults.ShipmentIdentificationNumber // empty') if [ -z "$tracking" ]; then echo "ups ship: failed — response:" >&2 printf '%s\n' "$resp" | jq . >&2 2>/dev/null || printf '%s\n' "$resp" >&2 return 1 fi # Label PDF ships inline as base64 on PackageResults[0].ShippingLabel.GraphicImage local pdf_b64 pdf_b64=$(printf '%s' "$resp" | jq -r '.ShipmentResponse.ShipmentResults.PackageResults[0].ShippingLabel.GraphicImage // empty') if [ -n "$pdf_b64" ]; then local out="${LABEL_DIR}/ups_label_${tracking}.pdf" printf '%s' "$pdf_b64" | base64 -d > "$out" 2>/dev/null || \ printf '%s' "$pdf_b64" | base64 -D > "$out" 2>/dev/null || true fi jq -n --arg id "$tracking" --argjson resp "$resp" --arg c "ups" \ '{carrier: $c, shipment_id: $id, response: $resp}' } ups_label() { local id="${1:-}" [ -z "$id" ] && { echo "ups label: tracking number required" >&2; return 64; } local out="${LABEL_DIR}/ups_label_${id}.pdf" if [ ! -s "$out" ]; then cat >&2 <<EOF ups label: UPS returns the label PDF inline with the ship call; re-run ship to regenerate, or retrieve via the UPS LabelRecovery API (not implemented). EOF return 1 fi if [[ "$(uname)" == "Darwin" ]] && [ -t 1 ]; then open "$out" >/dev/null 2>&1 || true fi jq -n --arg p "$out" '{status: "ok", label_pdf: $p}' } ups_track() { local id="${1:-}" [ -z "$id" ] && { echo "ups track: tracking number required" >&2; return 64; } local auth; auth=$(ups_auth_header) curl -sS -H "$auth" -H "$UA_HEADER" -H "Accept: application/json" \ -H "transId: $(date +%s%N)" -H "transactionSrc: claude-ops" \ "$UPS_BASE_URL/api/track/v1/details/${id}" \ | jq '{ carrier: "ups", id: (.trackResponse.shipment[0].package[0].trackingNumber // null), status: (.trackResponse.shipment[0].package[0].currentStatus.description // null), barcode: (.trackResponse.shipment[0].package[0].trackingNumber // null), tracking_url: null, recipient: null, created: null, updated: (.trackResponse.shipment[0].package[0].activity[0].date // null) }' } ups_list() { jq -n '{carrier: "ups", shipments: [], note: "UPS API has no list endpoint; track by tracking number instead."}' } ups_configured() { resolve_env "UPS_CLIENT_ID" "ups_client_id" >/dev/null 2>&1 && \ resolve_env "UPS_CLIENT_SECRET" "ups_client_secret" >/dev/null 2>&1 && \ resolve_env "UPS_SHIPPER_NUMBER" "ups_shipper_number" >/dev/null 2>&1 }
-
-
common.sh 5.4 KB
#!/usr/bin/env bash # common.sh — Shared helpers for ops-package carrier adapters. # Sourced by lib/carriers/*.sh and by the top-level ops-package.sh router. # Exposes: resolve_env, die_missing_creds, parse_address, UA_HEADER, PREFS_PATH. set -euo pipefail PREFS_PATH="${CLAUDE_PLUGIN_DATA_DIR:-$HOME/.claude/plugins/data/ops-ops-marketplace}/preferences.json" UA_HEADER="User-Agent: claude-ops/ops-package" LABEL_DIR="${OPS_PACKAGE_LABEL_DIR:-/tmp}" # resolve_env <env-var-name> [prefs-key] # Resolution order: env var → preferences.json key → Doppler secret with the # same name as the env var. Prints the value on stdout, returns 1 if nothing # found. The second argument is optional; when omitted, the lowercase form of # the env-var name is used as the prefs key. resolve_env() { local name="$1" local prefs_key="${2:-$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')}" local v v="${!name:-}" if [ -n "$v" ]; then printf '%s' "$v"; return 0 fi if [ -f "$PREFS_PATH" ] && command -v jq &>/dev/null; then v=$(jq -r --arg k "$prefs_key" '.[$k] // .user_config[$k] // empty' "$PREFS_PATH" 2>/dev/null || true) if [ -n "$v" ] && [ "$v" != "null" ]; then printf '%s' "$v"; return 0 fi fi if command -v doppler &>/dev/null; then v=$(doppler secrets get "$name" --plain 2>/dev/null || true) if [ -n "$v" ]; then printf '%s' "$v"; return 0 fi fi return 1 } # die_missing_creds <carrier-label> <env-var(s)> <docs-url> die_missing_creds() { local carrier="$1" envs="$2" docs="$3" cat >&2 <<EOF ERROR: Missing credentials for $carrier. Set the following environment variable(s): $envs Or store the value(s) in: $PREFS_PATH (keys are the lowercase form of the variable names) Or register them with Doppler under the same names. Get credentials at: $docs EOF exit 2 } # parse_address "Person / Company, Street 12A, 1011AB City, Country" # Emits a JSON object with normalised NL address fields. Shared by all carriers. parse_address() { local raw="$1" local person company street number number_suffix postcode city cc IFS=',' read -r p1 p2 p3 p4 <<<"$raw" p1=$(printf '%s' "${p1:-}" | sed -E 's/^ +//;s/ +$//') p2=$(printf '%s' "${p2:-}" | sed -E 's/^ +//;s/ +$//') p3=$(printf '%s' "${p3:-}" | sed -E 's/^ +//;s/ +$//') p4=$(printf '%s' "${p4:-}" | sed -E 's/^ +//;s/ +$//') if [[ "$p1" == *" / "* ]]; then person="${p1%% / *}" company="${p1##* / }" else person="$p1" company="" fi if [[ "$p2" =~ ^(.+[^[:space:]])[[:space:]]+([0-9]+)([A-Za-z]{0,4})$ ]]; then street="${BASH_REMATCH[1]}" number="${BASH_REMATCH[2]}" number_suffix="${BASH_REMATCH[3]}" else street="$p2" number="" number_suffix="" fi if [[ "$p3" =~ ^([0-9]{4}[[:space:]]?[A-Za-z]{2})[[:space:]]+(.+)$ ]]; then postcode=$(printf '%s' "${BASH_REMATCH[1]}" | tr -d ' ' | tr '[:lower:]' '[:upper:]') city="${BASH_REMATCH[2]}" else postcode=$(printf '%s' "$p3" | awk '{print $1}') city=$(printf '%s' "$p3" | cut -d' ' -f2-) fi cc=$(printf '%s' "${p4:-NL}" | tr '[:lower:]' '[:upper:]' | sed -E 's/^ +//;s/ +$//') case "$cc" in NETHERLANDS|NEDERLAND|HOLLAND) cc=NL ;; BELGIUM|BELGIE|BELGIQUE) cc=BE ;; GERMANY|DEUTSCHLAND) cc=DE ;; FRANCE) cc=FR ;; "UNITED KINGDOM"|UK|"GREAT BRITAIN") cc=GB ;; "UNITED STATES"|USA) cc=US ;; esac [ -z "$cc" ] && cc=NL jq -n \ --arg person "$person" \ --arg company "$company" \ --arg street "$street" \ --arg number "$number" \ --arg number_suffix "$number_suffix" \ --arg postcode "$postcode" \ --arg city "$city" \ --arg cc "$cc" \ '{ person: $person, company: ($company // ""), street: $street, number: $number, number_suffix: $number_suffix, postal_code: $postcode, city: $city, cc: $cc }' } # strip_address_bits — Accept all ship flags and render them into a set of # normalised shell variables (to_json, from_json, weight, pkg_type, signature, # insurance, description, pickup). Carriers that don't consume some fields # simply ignore them. Idempotent: can be re-run. parse_ship_flags() { TO_RAW=""; FROM_RAW=""; WEIGHT=""; PKG_TYPE="1" SIGNATURE="false"; INSURANCE="0"; DESCRIPTION=""; PICKUP="false" while [ $# -gt 0 ]; do case "$1" in --to) TO_RAW="${2:-}"; shift 2 ;; --from) FROM_RAW="${2:-}"; shift 2 ;; --weight) WEIGHT="${2:-}"; shift 2 ;; --package-type) PKG_TYPE="${2:-1}"; shift 2 ;; --signature) SIGNATURE="true"; shift ;; --insurance) INSURANCE="${2:-0}"; shift 2 ;; --description) DESCRIPTION="${2:-}"; shift 2 ;; --pickup) PICKUP="true"; shift ;; *) echo "ship: unknown flag: $1" >&2; return 64 ;; esac done if [ -z "$TO_RAW" ]; then echo 'ship: --to is required, e.g. --to "Jane Doe, Kerkstraat 12A, 1011AB Amsterdam, NL"' >&2 return 64 fi TO_JSON=$(parse_address "$TO_RAW") if [ -n "$FROM_RAW" ]; then FROM_JSON=$(parse_address "$FROM_RAW") else FROM_JSON="" fi return 0 } # save_label_pdf <carrier> <id> <http-response-body-file> # Moves the PDF response to a predictable location and opens on macOS TTY. save_label_pdf() { local carrier="$1" id="$2" src="$3" local out="${LABEL_DIR}/${carrier}_label_${id}.pdf" mv "$src" "$out" if [[ "$(uname)" == "Darwin" ]] && [ -t 1 ]; then open "$out" >/dev/null 2>&1 || true fi jq -n --arg p "$out" '{status: "ok", label_pdf: $p}' }
-
-
ops-package.sh 4.4 KB
#!/usr/bin/env bash # ops-package.sh — Carrier-agnostic shipping router. # Subcommands: ship | label | track | list | carriers # Auto-picks carrier from credentials. Override with --carrier <name>. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" LIB_DIR="$SCRIPT_DIR/lib" CARRIERS_DIR="$LIB_DIR/carriers" # shellcheck source=lib/common.sh . "$LIB_DIR/common.sh" # Known carriers in preference order (first-configured wins on auto-select). CARRIERS=(myparcel sendcloud dhl postnl dpd ups fedex) # Source every adapter. Each exports <carrier>_{ship,label,track,list,configured}. for c in "${CARRIERS[@]}"; do # shellcheck source=/dev/null . "$CARRIERS_DIR/${c}.sh" done usage() { cat <<EOF ops-package.sh — carrier-agnostic shipping Usage: ops-package.sh [--carrier <name>] ship --to "<addr>" [flags] ops-package.sh [--carrier <name>] label <id> ops-package.sh [--carrier <name>] track <id> ops-package.sh [--carrier <name>] list ops-package.sh carriers Show configured/unconfigured carriers Carriers (auto-detected by configured credentials, preference order): myparcel, sendcloud, dhl, postnl, dpd, ups, fedex ship flags: --to "<Person / Company, Street 12A, 1011AB City, CC>" (required) --from override sender address --weight grams (integer) --package-type 1=parcel (default) | 2=mailbox | 3=letter --signature require delivery signature --insurance EUR (integer; 0 = off) --description <=45 char label reference --pickup request home pickup at sender Credential resolution (per-carrier env var → preferences.json key → Doppler): MyParcel MYPARCEL_API_KEY Sendcloud SENDCLOUD_PUBLIC_KEY + SENDCLOUD_PRIVATE_KEY DHL NL DHL_PARCEL_USER_ID + DHL_PARCEL_KEY PostNL POSTNL_API_KEY + POSTNL_CUSTOMER_CODE + POSTNL_CUSTOMER_NUMBER DPD DPD_DELIS_ID + DPD_PASSWORD UPS UPS_CLIENT_ID + UPS_CLIENT_SECRET + UPS_SHIPPER_NUMBER FedEx FEDEX_CLIENT_ID + FEDEX_CLIENT_SECRET + FEDEX_ACCOUNT_NUMBER EOF } # Sets $CARRIER to the selected carrier name. Exits with a helpful error when # no carrier is configured. CARRIER="" pick_carrier() { if [ -n "$CARRIER" ]; then local found=0 for c in "${CARRIERS[@]}"; do [ "$c" = "$CARRIER" ] && found=1 && break done if [ "$found" = 0 ]; then echo "Unknown carrier: $CARRIER (known: ${CARRIERS[*]})" >&2 exit 64 fi if ! "${CARRIER}_configured"; then echo "Carrier '$CARRIER' is not configured. Run 'ops-package.sh carriers' to see status." >&2 exit 2 fi return 0 fi for c in "${CARRIERS[@]}"; do if "${c}_configured"; then CARRIER="$c" return 0 fi done cat >&2 <<EOF No shipping carrier is configured. Set credentials for at least one of: MyParcel MYPARCEL_API_KEY Sendcloud SENDCLOUD_PUBLIC_KEY + SENDCLOUD_PRIVATE_KEY DHL NL DHL_PARCEL_USER_ID + DHL_PARCEL_KEY PostNL POSTNL_API_KEY + POSTNL_CUSTOMER_CODE + POSTNL_CUSTOMER_NUMBER DPD DPD_DELIS_ID + DPD_PASSWORD UPS UPS_CLIENT_ID + UPS_CLIENT_SECRET + UPS_SHIPPER_NUMBER FedEx FEDEX_CLIENT_ID + FEDEX_CLIENT_SECRET + FEDEX_ACCOUNT_NUMBER Set env vars, store values in preferences.json, or register them in Doppler. EOF exit 2 } cmd_carriers() { echo "Configured carriers (✓) — preference order:" for c in "${CARRIERS[@]}"; do if "${c}_configured"; then echo " ✓ $c" else echo " · $c (no credentials)" fi done } # ─── dispatch ───────────────────────────────────────────────────────────── # Extract --carrier flag if present (anywhere in the arg list before the verb). ARGS=() while [ $# -gt 0 ]; do case "$1" in --carrier) CARRIER="${2:-}" if [ -z "$CARRIER" ]; then echo "ops-package.sh: --carrier requires a value (e.g. --carrier myparcel)" >&2 exit 64 fi shift 2 ;; *) ARGS+=("$1"); shift ;; esac done set -- "${ARGS[@]:-}" sub="${1:-}"; shift || true case "$sub" in carriers) cmd_carriers ;; ship) pick_carrier; "${CARRIER}_ship" "$@" ;; label) pick_carrier; "${CARRIER}_label" "$@" ;; track) pick_carrier; "${CARRIER}_track" "$@" ;; list) pick_carrier; "${CARRIER}_list" "$@" ;; ""|-h|--help) usage ;; *) echo "ops-package.sh: unknown subcommand '$sub' (try ship|label|track|list|carriers)" >&2; exit 64 ;; esac -
SKILL.md 8.2 KB
--- name: ops-package description: Ship parcels via any configured carrier — MyParcel, Sendcloud, DHL Parcel NL, PostNL, DPD, UPS, FedEx. Auto-selects the first carrier whose credentials are configured, or pass --carrier <name> to override. Verbs: ship, label, track, list, carriers. argument-hint: "[--carrier <name>] <ship|label|track|list|carriers> [args...]" allowed-tools: - Bash - Read - AskUserQuestion effort: low maxTurns: 15 disallowedTools: - Edit - Write - NotebookEdit --- # OPS ► PACKAGE — multi-carrier shipping One skill, seven carriers. The router picks the carrier automatically based on which credentials are configured. ## Which carrier do I get? | Condition | Selected carrier | | -------------------------------------------------------------------- | ------------------------- | | `--carrier <name>` flag passed | That carrier (validated) | | Exactly one carrier has credentials | That carrier | | Multiple carriers have credentials | First match in this order | | No carrier has credentials | Exit 2 with setup help | **Preference order:** `myparcel` → `sendcloud` → `dhl` → `postnl` → `dpd` → `ups` → `fedex`. ## Credential resolution Each carrier resolves its credential(s) from, in order: 1. Environment variable(s) listed below. 2. `preferences.json` key (lowercase of the env var name) at `${CLAUDE_PLUGIN_DATA_DIR:-$HOME/.claude/plugins/data/ops-ops-marketplace}/preferences.json`. 3. Doppler: `doppler secrets get <NAME> --plain`. | Carrier | Env vars required | Docs | | ---------- | ---------------------------------------------------------------------- | --------------------------------------------------- | | MyParcel | `MYPARCEL_API_KEY` | https://developer.myparcel.nl/api-reference/ | | Sendcloud | `SENDCLOUD_PUBLIC_KEY` + `SENDCLOUD_PRIVATE_KEY` | https://api.sendcloud.dev/docs/ | | DHL NL | `DHL_PARCEL_USER_ID` + `DHL_PARCEL_KEY` | https://api-gw.dhlparcel.nl/docs | | PostNL | `POSTNL_API_KEY` + `POSTNL_CUSTOMER_CODE` + `POSTNL_CUSTOMER_NUMBER` | https://developer.postnl.nl/ | | DPD | `DPD_DELIS_ID` + `DPD_PASSWORD` | https://esolutions.dpd.com/ | | UPS | `UPS_CLIENT_ID` + `UPS_CLIENT_SECRET` + `UPS_SHIPPER_NUMBER` | https://developer.ups.com/api/reference/shipping | | FedEx | `FEDEX_CLIENT_ID` + `FEDEX_CLIENT_SECRET` + `FEDEX_ACCOUNT_NUMBER` | https://developer.fedex.com/api/en-us/catalog/ | ## Routing All API calls are delegated to `${CLAUDE_PLUGIN_ROOT}/skills/ops-package/ops-package.sh`. Do not re-implement curl logic inline — pass args through. | First token | Action | | ----------- | ------------------------------------------------------------------ | | `carriers` | Show configured vs unconfigured carriers | | `ship` | Create a shipment | | `label` | Download + open label PDF | | `track` | Show status + tracking barcode | | `list` | List last 10 shipments (MyParcel / Sendcloud; others unsupported) | | (empty) | Show usage | ## ship Required: `--to "<address>"`. Address format: ``` "Person / Company, Street 12A, 1011AB Amsterdam, NL" ``` - `/ Company` segment is optional. - Postcode accepts with/without space; NL postcodes are normalised to uppercase without space. - Country defaults to NL; common names (Netherlands, Belgium, Germany, France, UK, USA) map to ISO codes. Flags (not every carrier honours every flag — unsupported flags are safely ignored per adapter): - `--from "<address>"` override sender; default is the account's configured sender. - `--weight <grams>` integer grams. - `--package-type 1|2|3` 1=parcel (default), 2=mailbox, 3=letter (MyParcel only). - `--signature` require signature on delivery. - `--insurance <EUR>` integer EUR; 0 disables (MyParcel only). - `--description "<text>"` label reference (carriers differ; ~45 char max). - `--pickup` request home pickup at sender (MyParcel only). ### Invocation ```bash ${CLAUDE_PLUGIN_ROOT}/skills/ops-package/ops-package.sh \ [--carrier myparcel|sendcloud|dhl|postnl|dpd|ups|fedex] \ ship --to "$TO" [--from "$FROM"] [--weight "$W"] \ [--signature] [--insurance "$INS"] [--description "$DESC"] \ [--package-type "$TYPE"] [--pickup] ``` Returns: ```json {"carrier": "<name>", "shipment_id": "<id>", "response": { /* raw carrier JSON */ }} ``` Summarise to the user as: ``` Shipment created — <carrier> #<id> Next: /ops:ops-package label <id> to download the PDF. ``` Offer via `AskUserQuestion` (≤4 options): - `[Download label now]` — call `label <id>` immediately - `[Track it]` — call `track <id>` - `[Ship another]` - `[Done]` ## label `<shipment-id>` ```bash ${CLAUDE_PLUGIN_ROOT}/skills/ops-package/ops-package.sh label "$ID" ``` Behaviour per carrier: - **MyParcel**: PDF saved to `/tmp/myparcel_label_<id>.pdf` and opened on macOS. If unpaid, returns `{"status":"payment_required","payment_url":"..."}` and opens the MultiSafepay URL. - **Sendcloud**: Fetches the CDN PDF URL (v2 `/labels/<id>`) and saves locally. - **DHL NL**: Fetches `/labels/<id>?format=PDF&printerType=A4`. - **DPD**: Fetches `/v1/parcellabelnumber/<id>?paperFormat=A4`. - **PostNL / UPS / FedEx**: PDF is returned inline with the `ship` call and cached to `/tmp/<carrier>_label_<id>.pdf`. Calling `label` re-opens that cached file. If the cache is gone, re-run `ship` (none of these carriers expose a stable label-recovery endpoint here). ## track `<shipment-id>` Returns a normalised object across all carriers: ```json {"carrier":"<n>","id":"<id>","status":"<s>","barcode":"<b>","tracking_url":"<u>","recipient":<o>,"updated":"<ts>"} ``` ## list `MyParcel` returns the last 10 shipments. `Sendcloud` returns the last 10 parcels. All other carriers return `{"shipments":[],"note":"..."}` because their APIs don't expose a customer-facing list endpoint — track by id instead. ## carriers Prints which carriers are configured (`✓`) vs which are missing credentials (`·`). Use this when deciding which `--carrier` to pass. ## Error handling - **No carrier configured** → script exits 2 with a checklist of envs. Surface it verbatim, then via `AskUserQuestion`: `[Paste key now]` `[Open carriers doc]` `[Skip — configure later]` On "Paste key now", collect via `AskUserQuestion` free-text and write to `preferences.json`. - **4xx from carrier** → dump the JSON error body and stop. Do not retry silently. - **Address parser looks wrong** → re-prompt the user with the parsed breakdown and ask for corrections before POSTing. ## Verification status | Carrier | Status | | ---------- | ------------------------------------------------------------------ | | MyParcel | Verified against api.myparcel.nl v1.1 (same working reference) | | Sendcloud | Verified request/response shapes against Sendcloud Panel API v3 | | DHL NL | UNVERIFIED — modelled on My DHL Parcel Swagger, needs live account | | PostNL | UNVERIFIED — modelled on Send API v2.2 docs, needs live account | | DPD | UNVERIFIED — modelled on DPD eSolutions REST, needs live account | | UPS | UNVERIFIED — modelled on UPS v2403 Ship/Track REST, needs account | | FedEx | UNVERIFIED — modelled on FedEx Ship v1 + Track v1 REST | Unverified adapters are tagged with `# UNVERIFIED - pending live test with account` in the source. Payloads match the documented contract; adjustments may be needed when tested against live accounts.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.