Claude Cursor Skill

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.

LLM Mart · 0 points · 3 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download davepoon-buildwithclaude-plugins_claude-ops_skills_ops-package-a6c484b.zip · 24 KB
Part of davepoon/buildwithclaude — 187 skills

Install

skills CLI npx skills add https://github.com/davepoon/buildwithclaude/tree/main/plugins/claude-ops/skills/ops-package
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install davepoon-buildwithclaude@llmmart
Git 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:

  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

${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] — call label <id> immediately
  • [Track it] — call track <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>.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:

{"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.

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.

No comments yet.

Reviews (0)

No reviews yet.

Related