Skip to content

CI CD & Scripting

Neil Martin edited this page Sep 16, 2026 · 23 revisions

CI/CD & Scripting

Patterns for using jamf-cli in automated environments.

Non-Interactive Mode

Pass --no-input in CI/CD. Commands that need input then fail with an error instead of hanging.

jamf-cli pro computers list --no-input -o json

Important: under --no-input, destructive operations (delete, erase, lock, wipe) fail with an error unless --yes is also passed.

Since v1.26.0: the "new jamf-cli release" advisory (see Configuration & Profiles#Release Update Notice (v1.26.0+)) stays silent in a pipeline. Any of CI, CONTINUOUS_INTEGRATION, BUILD_NUMBER, RUN_ID, GITHUB_ACTIONS, GITLAB_CI or TF_BUILD vetoes it, and so does stdout or stderr not being a terminal. No flag is needed. To turn it off in an interactive shell inside a container that sets none of those markers, pass --no-update-check or export JAMF_CLI_NO_UPDATE_CHECK=1.

Destructive Operations in CI

Destructive commands (delete, delete-multiple, erase, lock, wipe, remove, restart, shutdown) need explicit confirmation. Pass both --no-input and --yes to run them unattended:

# Will fail: --no-input blocks the confirmation prompt
jamf-cli pro scripts delete 42 --no-input

# Correct: --yes skips the confirmation
jamf-cli pro scripts delete 42 --no-input --yes

open prints the URL instead of launching a browser

pro open, protect open, school open and security open resolve a console URL. In a pipeline they print it and launch nothing, so a job gets the URL rather than a failed browser launch. The URL is printed under any of --print, --no-input, --dry-run, --out-file, --field, --select, or a non-terminal stdout, which covers every CI invocation without a flag.

# The scripting form
jamf-cli pro open policies --field url
# https://your-instance.jamfcloud.com/policies.html

jamf-cli pro open --list -o json          # every section name, no credentials needed

In the launch case stdout stays empty and the note goes to stderr, so nothing downstream reads a launch as data.

pro open is the only one of the four that can send a request: on a platform gateway profile it reads the instance URL from GET /v1/jamf-pro-server-url (scope jss-url:read), because a platform integration names a tenant and not a Jamf Pro host. On an instance profile it uses the configured URL and sends nothing. The other three send no request at all.

--dry-run is per command, and one destructive --all did not honour it

pro jamf-pro-notifications delete --all --yes -n sent a live tenant-wide DELETE and got a 204 in v1.29.0. Wire-checked before and after. A destructive generated command declares its own --dry-run/-n, which shadows the root persistent flag, so the template's own branch is the only thing honouring -n, and it sat after the --all block, which had already sent the request. Fixed in v1.30.0: the --all block previews before both the confirmation and the request.

Non-destructive bulk --all was never affected: pro app-installers-deployments installation-retry --all -n declares no local --dry-run, so dryRunClient covered it. If a pipeline uses -n as a safety net on a destructive --all, pin jamf-cli to v1.30.0 or later.

Authentication in CI

Environment variables (Jamf Platform Gateway, recommended)

The platform gateway enables both Pro API and Platform API commands. The base URL is https://{region}.api.jamfcloud.com with no /api segment, and the CLI refuses a profile or JAMF_URL still naming the retired pre-GA host {region}.apigw.jamf.com by name before sending. See Platform API GA Migration.

You create an API integration at one of three scope levels in Jamf Account, and its credential works with that level alone, so pick the variable that matches the integration:

export JAMF_URL="https://eu.api.jamfcloud.com"
export JAMF_CLIENT_ID="your-client-id"
export JAMF_CLIENT_SECRET="your-client-secret"

# Platform environment scope: the level to prefer
export JAMF_ENVIRONMENT_ID="your-environment-id"

# ...or tenant scope (legacy)
# export JAMF_TENANT_ID="your-tenant-id"

# ...or organization scope: neither ID. The gateway host alone selects platform auth.

# Pro API commands work through the gateway
jamf-cli pro computers list --no-input -o json

# Platform API commands are also available
jamf-cli pro blueprints list --no-input -o json
jamf-cli pro ddm-reports device declarations <device-id> --filter 'active=in=(true,false)' --no-input -o json   # --filter is required

Setting JAMF_ENVIRONMENT_ID and JAMF_TENANT_ID together is refused at exit 2. Either one supplied in the environment replaces a profile's scope, the same as every other JAMF_* override. The equivalent flags are --environment-id and --tenant-id.

Refusals key on the resolved auth method. A CI job supplying JAMF_URL + JAMF_CLIENT_ID + JAMF_CLIENT_SECRET (plus a scope ID, or the gateway host alone) gets the same pre-flight refusals a named profile would, in both directions, and before the token exchange, so on invalid credentials too. See Refused commands on a gateway profile below.

Environment variables (Jamf Pro, direct instance)

For on-premises instances or when platform gateway credentials are unavailable:

export JAMF_URL="https://jamf.company.com"
export JAMF_CLIENT_ID="your-client-id"
export JAMF_CLIENT_SECRET="your-client-secret"

jamf-cli pro computers list --no-input -o json

Environment variables (Jamf Protect)

export JAMFPROTECT_URL="https://tenant.protect.jamfcloud.com"
export JAMFPROTECT_CLIENT_ID="your-client-id"
export JAMFPROTECT_CLIENT_SECRET="your-client-secret"

jamf-cli protect plans list --no-input -o json

Note: The CLI also checks the generic JAMF_URL, JAMF_CLIENT_ID and JAMF_CLIENT_SECRET as fallbacks for Protect commands.

Environment variables (Jamf School)

export JAMFSCHOOL_URL="https://tenant.jamfschool.com"
export JAMFSCHOOL_NETWORK_ID="your-network-id"
export JAMFSCHOOL_API_KEY="your-api-key"

jamf-cli school devices list --no-input -o json

Note: JAMFSCHOOL_URL falls back to JAMF_URL when unset. To also enable Platform API commands (blueprints, DDM reports), set JAMFSCHOOL_PLATFORM_URL, JAMF_CLIENT_ID, JAMF_CLIENT_SECRET and JAMF_TENANT_ID.

Token from file

Read a token from a mounted secret file:

jamf-cli pro computers list --token-file /run/secrets/jamf-token --no-input -o json

Config file with env references

Create a config file that references CI environment variables:

default-profile: ci
profiles:
  ci:
    url: https://jamf.company.com
    auth-method: oauth2
    client-id: env:JAMF_CLIENT_ID
    client-secret: env:JAMF_CLIENT_SECRET

GitHub Actions

Installation

setup-jamf-cli action (recommended) installs in one line and handles platform detection, checksum verification, and PATH setup:

- uses: Jamf-Concepts/setup-jamf-cli@v1

Available inputs:

Input Description Default
version Version to install (1.5.0, v1.5.0, or latest) latest
token GitHub token used to resolve the latest version ${{ github.token }}
no-color Set NO_COLOR=1 for all subsequent steps false
extra-args Default flags prepended via JAMF_CLI_ARGS (e.g. --quiet --no-input) ''

Available outputs:

Output Description
version Installed version without v prefix

Supports ubuntu-latest and macos-latest. Windows runners are unsupported.

From source requires Go, and suits a specific unreleased commit:

      - uses: actions/setup-go@v5
        with:
          go-version: 'stable'

      - name: Install jamf-cli
        run: go install github.com/Jamf-Concepts/jamf-cli/cmd/jamf-cli@latest

Basic example

name: Fleet Report
on:
  schedule:
    - cron: '0 8 * * 1'  # Monday 8 AM

jobs:
  report:
    runs-on: ubuntu-latest
    steps:
      - uses: Jamf-Concepts/setup-jamf-cli@v1
        with:
          no-color: 'true'
          extra-args: '--quiet --no-input'

      - name: Export inventory
        env:
          JAMF_URL: ${{ secrets.JAMF_URL }}
          JAMF_CLIENT_ID: ${{ secrets.JAMF_CLIENT_ID }}
          JAMF_CLIENT_SECRET: ${{ secrets.JAMF_CLIENT_SECRET }}
        run: |
          jamf-cli pro computers list --all -o csv --out-file computers.csv
          jamf-cli pro mobile-devices list --all -o csv --out-file mobile-devices.csv

      - name: Upload artifacts
        uses: actions/upload-artifact@v4
        with:
          name: fleet-report
          path: |
            computers.csv
            mobile-devices.csv

Note: extra-args: '--quiet --no-input' prepends those flags to every jamf-cli call in subsequent steps via JAMF_CLI_ARGS, so there is no need to repeat them per command.

Platform API example (blueprints + compliance)

name: Platform Compliance Check
on:
  schedule:
    - cron: '0 9 * * 1'  # Monday 9 AM

jobs:
  compliance:
    runs-on: ubuntu-latest
    steps:
      - uses: Jamf-Concepts/setup-jamf-cli@v1
        with:
          no-color: 'true'
          extra-args: '--quiet --no-input'

      - name: Check compliance and blueprint status
        env:
          JAMF_URL: ${{ secrets.JAMF_GATEWAY_URL }}
          JAMF_CLIENT_ID: ${{ secrets.JAMF_CLIENT_ID }}
          JAMF_CLIENT_SECRET: ${{ secrets.JAMF_CLIENT_SECRET }}
          JAMF_TENANT_ID: ${{ secrets.JAMF_TENANT_ID }}
        run: |
          jamf-cli pro report blueprint-status -o json > blueprint-status.json
          jamf-cli pro report ddm-status -o json > ddm-status.json
          jamf-cli pro audit --checks platform -o json > audit.json

      - name: Upload reports
        uses: actions/upload-artifact@v4
        with:
          name: platform-reports
          path: |
            blueprint-status.json
            ddm-status.json
            audit.json

Health check on deploy

      - uses: Jamf-Concepts/setup-jamf-cli@v1

      - name: Verify Jamf Pro health
        env:
          JAMF_URL: ${{ secrets.JAMF_URL }}
          JAMF_CLIENT_ID: ${{ secrets.JAMF_CLIENT_ID }}
          JAMF_CLIENT_SECRET: ${{ secrets.JAMF_CLIENT_SECRET }}
        run: |
          jamf-cli pro computers list --no-input -o json > /dev/null
          echo "Jamf Pro connection verified"

Jamf Pro config-as-code with apply

Sync Jamf Pro resources from a Git repository. apply is idempotent: it creates resources that do not exist and replaces ones that do, so it is safe to run on every merge.

name: Sync Jamf Pro Config
on:
  push:
    branches: [main]
    paths: ['jamf-pro/**']

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: Jamf-Concepts/setup-jamf-cli@v1
        with:
          no-color: 'true'
          extra-args: '--quiet --no-input'

      - name: Apply buildings
        env:
          JAMF_URL: ${{ secrets.JAMF_URL }}
          JAMF_CLIENT_ID: ${{ secrets.JAMF_CLIENT_ID }}
          JAMF_CLIENT_SECRET: ${{ secrets.JAMF_CLIENT_SECRET }}
        run: |
          for f in jamf-pro/buildings/*.json; do
            jamf-cli pro buildings apply --from-file "$f" --yes
          done

      - name: Apply categories
        env:
          JAMF_URL: ${{ secrets.JAMF_URL }}
          JAMF_CLIENT_ID: ${{ secrets.JAMF_CLIENT_ID }}
          JAMF_CLIENT_SECRET: ${{ secrets.JAMF_CLIENT_SECRET }}
        run: |
          for f in jamf-pro/categories/*.json; do
            jamf-cli pro categories apply --from-file "$f" --yes
          done

Jamf Protect analytics sync

Sync community analytics from a Git repository into your Protect tenant on every merge to main:

name: Sync Protect Analytics
on:
  push:
    branches: [main]
    paths: ['analytics/**']

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: Jamf-Concepts/setup-jamf-cli@v1
        with:
          no-color: 'true'

      - name: Import analytics
        env:
          JAMFPROTECT_URL: ${{ secrets.JAMFPROTECT_URL }}
          JAMFPROTECT_CLIENT_ID: ${{ secrets.JAMFPROTECT_CLIENT_ID }}
          JAMFPROTECT_CLIENT_SECRET: ${{ secrets.JAMFPROTECT_CLIENT_SECRET }}
        run: |
          jamf-cli protect analytics import --dir analytics/ --no-input
          echo "Analytics sync complete"

Protect configuration backup

name: Protect Config Backup
on:
  schedule:
    - cron: '0 2 * * *'  # Daily 2 AM

jobs:
  backup:
    runs-on: ubuntu-latest
    steps:
      - uses: Jamf-Concepts/setup-jamf-cli@v1
        with:
          no-color: 'true'
          extra-args: '--no-input'

      - name: Export Protect configuration
        env:
          JAMFPROTECT_URL: ${{ secrets.JAMFPROTECT_URL }}
          JAMFPROTECT_CLIENT_ID: ${{ secrets.JAMFPROTECT_CLIENT_ID }}
          JAMFPROTECT_CLIENT_SECRET: ${{ secrets.JAMFPROTECT_CLIENT_SECRET }}
        run: |
          # Whole-tenant capture, one portable document per object,
          # in the same form `protect restore` replays
          jamf-cli protect backup --output ./backup

      - name: Upload artifacts
        uses: actions/upload-artifact@v4
        with:
          name: protect-backup
          path: backup/

protect backup exits non-zero when any resource failed to export, so the job above fails on a partial capture; --allow-partial-failure tolerates one. It also prunes documents an earlier run left that no longer match the tenant, reporting each; --no-prune keeps them. Both take --resources and --exclude.

A backup tree can contain secrets

Two Protect resources capture third-party credentials verbatim: action-configs (an HTTP report client's request headers carry its bearer token or API key) and data-forwarding (its CloudFormation blob embeds a tenant-specific IAM ExternalId). The command writes those documents 0600 instead of 0644 and reports which ones they are.

Git records no non-exec permissions. A clone of a backup repository hands every file back 0644, so the mode does not survive the round trip. A pipeline committing a Protect backup to a repository should either exclude those two resources (--exclude action-configs,data-forwarding) or treat the repository itself as a secret store: private, with access controlled the way you would control the credentials it contains.

Pin to a specific version

      - uses: Jamf-Concepts/setup-jamf-cli@v1
        with:
          version: 'v1.5.0'

Capture the installed version

      - uses: Jamf-Concepts/setup-jamf-cli@v1
        id: setup

      - run: echo "Installed jamf-cli ${{ steps.setup.outputs.version }}"

Docker

There is no published Docker image for jamf-cli. Download the binary in your Dockerfile.

Dockerfile

FROM alpine:3 AS base
RUN apk add --no-cache curl jq ca-certificates

# Download the latest release binary
RUN ASSET="jamf-cli_$(curl -sL https://api.github.com/repos/Jamf-Concepts/jamf-cli/releases/latest \
      | jq -r '.tag_name' | sed 's/^v//')_linux_amd64.tar.gz" \
    && curl -sL "https://github.com/Jamf-Concepts/jamf-cli/releases/latest/download/${ASSET}" | tar xz -C /usr/local/bin/ jamf-cli

# --- Minimal runtime image ---
FROM alpine:3
RUN apk add --no-cache ca-certificates
COPY --from=base /usr/local/bin/jamf-cli /usr/local/bin/jamf-cli
ENTRYPOINT ["jamf-cli"]

Build it:

docker build -t jamf-cli .

Running with environment variables

# Jamf Pro
docker run --rm \
  -e JAMF_URL=https://jamf.company.com \
  -e JAMF_CLIENT_ID=abc123 \
  -e JAMF_CLIENT_SECRET=secret \
  jamf-cli pro computers list --no-input -o json

# Jamf Protect
docker run --rm \
  -e JAMFPROTECT_URL=https://tenant.protect.jamfcloud.com \
  -e JAMFPROTECT_CLIENT_ID=abc123 \
  -e JAMFPROTECT_CLIENT_SECRET=secret \
  jamf-cli protect plans list --no-input -o json

Running with a config file and mounted secrets

For Kubernetes or Docker Swarm where secrets are mounted as files, use the file: prefix in your config:

# config.yaml
default-profile: container
profiles:
  container:
    url: https://jamf.company.com
    auth-method: oauth2
    client-id: file:/run/secrets/jamf-client-id
    client-secret: file:/run/secrets/jamf-client-secret
docker run --rm \
  -v /path/to/config.yaml:/root/.config/jamf-cli/config.yaml:ro \
  -v /path/to/client-id:/run/secrets/jamf-client-id:ro \
  -v /path/to/client-secret:/run/secrets/jamf-client-secret:ro \
  jamf-cli pro computers list --no-input -o json

In Kubernetes, mount the config as a ConfigMap and the secrets as a Secret volume at /run/secrets/.

JAMF_CLI_ARGS

Set JAMF_CLI_ARGS to prepend default flags to every invocation without modifying individual scripts:

# Always use quiet, non-interactive mode
export JAMF_CLI_ARGS='--quiet --no-input'

# Always use a specific profile
export JAMF_CLI_ARGS='--profile prod'

# Profile names with spaces work: shell quoting is supported
export JAMF_CLI_ARGS='--profile "My CI Profile"'

# Combine flags
export JAMF_CLI_ARGS='--no-input --no-color -o json'

The CLI parses JAMF_CLI_ARGS with full shell quoting, so a value containing spaces works, and prepends the flags before any command-line argument, so a per-invocation flag takes precedence.

-o in JAMF_CLI_ARGS breaks the two backup commands. pro backup and protect backup take --output as a destination directory, which shadows the global output-format flag and takes the -o shorthand with it, so those invocations exit 2 with unknown shorthand flag: 'o' in -o. JAMF_CLI_ARGS is prepended to every call, so one pipeline-wide -o json breaks the backup step of an otherwise working pipeline. Keep -o out of JAMF_CLI_ARGS where a job also runs a backup and set it per command; those commands write their files in the format --format yaml|json names. Details below: The -o shorthand is gone on the backup commands.

Reliable Machine Output

For machine parsing, combine these flags:

jamf-cli pro computers list \
  --no-input \     # never prompt
  --no-color \     # no ANSI escape codes
  -o json          # structured output
  • JSON (-o json): the most reliable for machine use. Errors are JSON too (see Error Handling & Exit Codes).
  • CSV (-o csv): good for spreadsheet imports and simple parsing.
  • Plain (-o plain): tab-separated, no headers. Good for cut, awk, sort.
  • --field: extract a single field without jq. Suits piping IDs into loops:
# Simpler than: jamf-cli pro scripts list -o json | jq -r '.[].id'
jamf-cli pro scripts list --field id --no-input

Suppress progress output

# Quiet mode: suppress spinner and non-error messages
jamf-cli pro computers list -q -o json

Stricter invocations in v1.29.0

Five changes tightened what the CLI accepts. Four of them answer exit 2, where the same mistakes used to exit 1, so a wrapper treating exit 1 as a bad invocation has to key on 2.

Invocation Answer
A missing required flag; a flag group with no member set; a flag group whose mutually exclusive members are set together; the wrong number of positionals Exit 2 (usage). 48 call sites declare a required flag, 118 declare a flag group, 671 validate an argument count.
A stray positional on a leaf that documents none, such as pro categories list junkarg or pro backup /tmp/out Exit 2. 736 leaves used to accept one and discard it, so a job passing an extra token now fails where it appeared to succeed.
--file on a Platform or Security Cloud write unknown flag: --file, exit 2. The request-body flag is --from-file in every product, with no compatibility alias. The error names the replacement, and under -o json it is the envelope's hint.
A pro resource name retired in v1.29.0 Still resolves, until 2027-03-09, with a stderr warning that neither --quiet nor --no-hints silences.
A moved operation name; a former leaf that is now a command group, asked for data; a verb whose meaning moved Exit 2. Three withdrawn names exit 1.
# Before
jamf-cli security ztna-gateways create --from-file gateway.yaml
# After
jamf-cli security ztna-gateways create --from-file gateway.yaml

--file is unchanged on the 11 commands where it names a multipart upload payload (pro packages upload, protect analytics import, and nine others). A --from-file body can arrive on a pipe instead; an upload cannot, the transport needing a filename and a length.

The rename warning:

warning: `computers-inventory` is a deprecated name for `computer-inventory` and stops working after
2027-03-09. Use `pro computer-inventory`.

Pro Command Renames has the old→new table; the refusal wording and exit codes are in Error Handling & Exit Codes#A retired pro command name.

The group-parent case is the one a pipeline hits most often. pro csas token used to return data; the path still resolves, and a bare invocation prints help at exit 0. An invocation carrying -o, --field, --select or --out-file is refused at exit 2 with the leaf named:

$ jamf-cli pro csa token --field id
{
  "error": "usage",
  "exitCode": 2,
  "exitCodeName": "usage",
  "hint": "run `jamf-cli pro csa token get`",
  "message": "`pro csa token` is a command group and returns no data; it returned data before the sub-resource split"
}

Stricter invocations in v1.30.0

One change breaks existing scripts: the Classic scope subcommands take an id, not a bare name.

Invocation Answer
pro classic-policies scope get "Deploy Chrome", a non-numeric positional on any Classic scope get/add/remove Exit 2, naming --name. Through v1.29.0 the positional was the name.
A --name that matches more than one record Refused. Classic names are not unique, and it used to resolve to the first in document order.
A positional and --name together Refused, matching every other Classic command.
# Before (v1.29.0)
jamf-cli pro classic-policies scope get "Deploy Chrome"
# After (v1.30.0): either form
jamf-cli pro classic-policies scope get 1
jamf-cli pro classic-policies scope get --name "Deploy Chrome"

Nine resources carry scope: classic-policies, classic-macos-config-profiles, classic-mobile-config-profiles, classic-mac-apps, classic-mobile-apps, classic-ebooks, classic-restricted-software, classic-vpp-assignments and classic-vpp-invitations. Each registers only the scope categories its own resource accepts, so a cross-family flag now arrives as cobra's unknown flag at exit 2 with the accepted categories named in the hint.

-n / --dry-run works on these commands in v1.30.0. It used to always fail: the preview suppressed the PUT and the post-write check then reported the server had not persisted it.

Refused commands on a gateway profile (exit 8)

On a platform gateway credential, a Jamf Pro or Classic command whose endpoint sits outside the gateway's published API is refused before the request is sent, with exit code 8 (unsupported). A Platform-only command on an instance profile is refused the same way.

Exit 2 covers every flag error, unknown subcommand, missing URL, missing credential, the retired-host refusal and the scope conflict, so key on 8 to tell a policy refusal from a mistyped command:

jamf-cli pro mobile-devices lock --serial F4GH5678 --yes --no-input -o json
case $? in
  0) echo "sent" ;;
  8) echo "not served on this credential: falling back to the instance profile"
     jamf-cli -p jamf-instance pro mobile-devices lock --serial F4GH5678 --yes --no-input -o json ;;
  *) exit 1 ;;
esac

Audit the whole surface up front. commands needs no auth, so this runs in any job:

jamf-cli commands -o json | jq -r '.[] | select(.gateway=="unserved") | .command + "\t" + .gatewayBasis'

POST /v2/mdm/commands is unpublished, so all 24 MDM device actions under pro mobile-devices (16) and pro computer-inventory (8) are refused on a gateway profile, 24 of the 59 refused entries in this binary. A job that issues device commands needs a second oauth2 profile pointed at the instance.

JAMF_CLI_ALLOW_UNPUBLISHED=1 downgrades an unpublished refusal to a stderr warning and sends the request. Treat it as a stopgap for one job. It is value-parsed with Go's strconv.ParseBool, so a runner that exports it can turn it off with JAMF_CLI_ALLOW_UNPUBLISHED=0 instead of unsetting it. Neither --quiet nor --no-hints silences the warning. Full detail in Error Handling & Exit Codes#Refused by Policy (exit 8) and Platform API GA Migration.

Gate on the preview catalog key, not on the description

commands -o json carries preview: true on every command whose endpoint upstream declares Preview, sourced from the spec's per-operation x-preview. Thirteen commands carry it in v1.30.0: platform ai-policies × 10 (including the apply the CLI synthesizes) and platform ai-tools × 3.

jamf-cli commands -o json | jq -r '.[] | select(.preview) | .command'

The field is positive-only, like every other catalog field: most specs declare nothing, so its absence means "nothing recorded" and not "this one is GA".

Twelve of the thirteen also have a description opening with Preview - (Preview - List active AI governance policies for the tenant), the synthesized apply being the exception. That prefix is upstream summary prose nobody here controls, so a job that wants to refuse Preview endpoints reads preview.

The -o shorthand is gone on the backup commands

pro backup and protect backup take --output as a destination directory. Cobra drops an inherited persistent flag whose name a local one already takes, and the shorthand goes with it, so -o does not exist on those two commands:

$ jamf-cli pro backup -o json --output ./backup
unknown shorthand flag: 'o' in -o        # exit 2

This bites hardest through JAMF_CLI_ARGS, which is prepended to every invocation, so export JAMF_CLI_ARGS='-o json' breaks the backup step of an otherwise working pipeline. Use --format yaml|json, these commands' own switch for the files they write, and keep -o out of JAMF_CLI_ARGS for those steps or set it per command.

Handling partial batch failures (exit 7)

Since v1.19.0, batch commands return exit code 7 (partial_failure) when some items succeed and some fail, distinct from a total failure: pro bulk *, generated bulk-delete, pro backup, protect backup and protect restore.

The three backup/restore commands exit non-zero by default: a backup that exits 0 with a resource missing looks the same as a good one to the job that scheduled it. --allow-partial-failure (a global flag) downgrades it to a warning and exit 0. In CI you can treat 7 as a hard failure or tolerate it:

# Fail the job on any partial failure (default: exit 7 is non-zero)
jamf-cli pro bulk enable-policies --category "Onboarding" --yes -o json

# Best-effort: keep going and exit 0 even if a few items failed
jamf-cli pro bulk enable-policies --category "Onboarding" --yes \
  --allow-partial-failure -o json

# Or branch on the code explicitly
jamf-cli pro backup --output ./backup --no-input -o json
case $? in
  0) echo "All objects exported" ;;
  7) echo "Partial export: some objects failed (see warnings)"; exit 1 ;;
  *) echo "Backup failed entirely"; exit 1 ;;
esac

The JSON envelope on a partial failure carries succeeded / failed counts alongside the standard error fields; see Error Handling & Exit Codes.

Script Templates

Jamf Pro script template

#!/bin/bash
set -euo pipefail

# Ensure required env vars
: "${JAMF_URL:?JAMF_URL must be set}"
: "${JAMF_CLIENT_ID:?JAMF_CLIENT_ID must be set}"
: "${JAMF_CLIENT_SECRET:?JAMF_CLIENT_SECRET must be set}"

# Fetch all computers
computers=$(jamf-cli pro computers list --all --no-input --no-color -o json)

# Process results
count=$(echo "$computers" | jq length)
echo "Found $count computers"

# Check for stale devices (no check-in for 30+ days)
stale=$(echo "$computers" | jq '[.[] | select(.lastContactDate < "2025-01-01")] | length')
echo "Stale devices: $stale"

# Exit based on threshold
if [ "$stale" -gt 100 ]; then
  echo "WARNING: Too many stale devices"
  exit 1
fi

Jamf Protect script template

#!/bin/bash
set -euo pipefail

# Ensure required env vars
: "${JAMFPROTECT_URL:?JAMFPROTECT_URL must be set}"
: "${JAMFPROTECT_CLIENT_ID:?JAMFPROTECT_CLIENT_ID must be set}"
: "${JAMFPROTECT_CLIENT_SECRET:?JAMFPROTECT_CLIENT_SECRET must be set}"

CLI="jamf-cli protect --no-input --no-color"

# Fetch overview data
plans=$($CLI plans list -o json)
analytics=$($CLI analytics list -o json)
computers=$($CLI computers list -o json)

echo "Plans:     $(echo "$plans" | jq length)"
echo "Analytics: $(echo "$analytics" | jq length)"
echo "Computers: $(echo "$computers" | jq length)"

# Export all plans for backup
echo "$plans" | jq -r '.[].name' | while read name; do
  $CLI plans export "$name" > "backup/${name}.json"
  echo "Exported plan: $name"
done

jamf-cli Wiki


Products

  • Jamf Pro: jamf-cli pro
  • Jamf Platform API: jamf-cli pro (blueprints, benchmarks, DDM reports)
  • Jamf Platform: jamf-cli platform (AI Governance, Jamf Account, audit)
  • Jamf Protect: jamf-cli protect
  • Jamf School: jamf-cli school
  • Jamf Security Cloud: jamf-cli security

Clone this wiki locally