CLI Scripting Contract

Tracked by pm-psy1, pm-hqa8g1, pm-gknu, pm-999jh7, and pm-srns.

Use this contract when composing pm with shells, CI runners, jq, or another process. Exact flags remain discoverable from pm <command> --help --json and pm contracts --command <command> --flags-only --json.

Process Contract

Exit Meaning Script response
0 The requested operation completed. A successful read may still return zero rows. Parse stdout.
1 Runtime or unexpected failure. Preserve stderr and stop.
2 Invalid flags, values, or command composition. Correct the invocation; do not retry unchanged.
3 Requested tracker or resource was not found. Correct the path or ID.
4 State or concurrency conflict. Refresh live state before deciding whether to retry.
5 A required dependency operation failed. Inspect the dependency evidence before retrying.
6 The request succeeded but matched nothing to change. Treat as success and inspect the effect receipt.
7 The request succeeded and changed only part of the selected targets. Treat as success and inspect unmatched/skipped rows.

Exits 0, 6, and 7 are successful outcomes. Bulk mutation envelopes repeat the distinction as outcome: effect, outcome: no_effect, or outcome: partial_effect with the same exit_code. Because POSIX shells treat every nonzero exit as a false condition, scripts invoking effect-aware bulk commands must preserve and classify the status explicitly rather than relying on a bare if condition:

set +e
result=$(pm update-many --ids "$ids" --tags reviewed --json)
status=$?
set -e

case "$status" in
  0|6|7) printf '%s\n' "$result" | jq '{outcome, matched_count, updated_count}' ;;
  *)     printf '%s\n' "pm update-many failed with exit $status" >&2; exit "$status" ;;
esac

The generated contract is authoritative. pm contracts --command update-many --full --json returns command_exit_contracts.vocabulary and the selected command's exhaustive exit_codes; SDK consumers can use the same declarations and isPmSuccessfulExitCode from @unbrained/pm-cli/sdk/contracts.

Successful structured results are written to stdout. Diagnostics, warnings, profiles, and errors are written to stderr so --json, --format ndjson, CSV, and table stdout remain pipe-safe. Never merge stderr into stdout before parsing structured output.

if result=$(pm list --type Task,Issue --status open,in_progress --json); then
  printf '%s\n' "$result" | jq -r '.items[].id'
else
  status=$?
  printf '%s\n' "pm list failed" >&2
  exit "$status"
fi

Stable Structured Fields

Mutation and read envelopes are intentionally different. Single-item mutation commands emit a flat receipt whose id, status, and changed_field_count are top-level fields. Reads wrap their primary entity or rows under documented keys such as item or items. Bulk mutations such as close-many and update-many use collection envelopes under rows; consult command_output_contracts for the exact command path. Never infer one shape from another.

TypeScript package consumers should parse mutation stdout with the SDK boundary helper so a wrapped or malformed result fails loudly:

import { parseMutationReceipt } from "@unbrained/pm-cli/sdk/contracts";

const { id, status, changedFieldCount } = parseMutationReceipt(stdout);

pm contracts --summary --json keeps bootstrap discovery compact while declaring every command's default token ceiling. Use pm contracts --full --json for command_output_contracts, which pairs the envelope declaration with TOON- and JSON-specific token ceilings for every active core or package command.

JSON object field order is not an API. Consume fields by name. Read envelopes keep the stable pagination vocabulary items, count, total, has_more, and, when another page exists, next_cursor. The filters object echoes the effective query scope. Plain pm list and pm search are all-status reads and disclose filters.status: "all"; lifecycle-specific commands such as pm list-open remain explicit shortcuts.

Projection flags intentionally change row shape. Use --fields when a script requires an exact subset, --brief or --compact only when the documented sparse shape is sufficient, and --full when linked metadata is required. Check row_contract on generic read surfaces that expose one; do not infer omitted fields as empty values.

For long-lived clients, generate or inspect the runtime contract instead of hard-coding recalled flags:

set -o pipefail
pm contracts --command list --flags-only --json |
  jq -e '.flags[] | select(.flag == "--status" and .list == true)'

Uniform OR Filters

list, search, aggregate, and update-many accept repeated or comma-separated values for lifecycle status, type, tag, priority, assignee, sprint, and release selectors. Values within one selector use OR semantics; different selectors combine with AND semantics. Every token is validated before tracker rows are returned or mutated.

pm list --status open --status in_progress --type Task,Issue --json
pm search "release readiness" --priority 0,1 --assignee alice,bob --json
pm aggregate --group-by status --status open,blocked --type Task,Issue --json
pm update-many --filter-status open,blocked --filter-type Task,Issue \
  --priority 1 --dry-run --json

Tags follow the same OR grammar. Escape a literal comma as \, and a literal backslash as \\; quote the argument so the shell passes the backslash through:

pm list --tag 'customer\,success,security' --json

Package authors can reuse the exact parser without importing CLI adapters:

import {
  parsePriorityFilterSet,
  parseStringFilterSet,
  parseTypeFilterSet,
} from "@unbrained/pm-cli/sdk/query";

Composition Recipes

Prefer cursor continuation over offsets for a changing corpus, and pass IDs through JSON rather than parsing human output:

if search_result=$(pm search "needs documentation" --status open,in_progress --fields id --json); then
  ids=$(printf '%s\n' "$search_result" | jq -r '.items[].id') || exit $?
  if [ -n "$ids" ]; then
    printf '%s\n' "$ids" | xargs -n 1 pm get --fields id,title,status --json
  fi
else
  status=$?
  exit "$status"
fi

Use NDJSON for streaming row-by-row tools:

set -o pipefail
pm list --status all --brief --format ndjson |
  jq -c 'select(.priority <= 1) | {id, title, status}'

Before a bulk mutation, run the same selectors with --dry-run --json, verify matched_count, filters, and item_plans, then repeat without --dry-run. Explicit --ids remains the safest final allowlist for automation.

Do not parse default TOON or table output with whitespace tools. Those formats optimize human and agent context; JSON, NDJSON, and CSV are the scripting surfaces.


Scripting remote
Report an issue