CLI

Scripting and CI

The CLI is built to be driven by a script: JSON on stdout, everything else on stderr, exit codes that mean something, and a credential that comes from the environment rather than a file on disk.

The --json contract

Two rules the whole CLI obeys, so a pipeline never has to pattern-match English:

engram corpora list --json | jq -r '.[] | "\(.id)\t\(.name)\t\(.status)"'

--json works before or after the subcommand, so it can be set once at the top of a wrapper. Some commands carry a JSON document even on failure, which is the point: a script driving pushes in bulk has to tell a quota wall from a network blip exactly, not approximately.

engram push ./docs --corpus "Support KB" --json > result.json || true
jq -r '.error_status, .error_code, .error' result.json

A failed push emits corpus_id, source, error, error_status and error_code. A successful one emits the counters, the sync run id and its state. engram status --json emits a usable document even when the API is unreachable, carrying api_ok: false and api_error, so a monitoring script gets the reason rather than an empty stdout.

Exit codes

CodeMeansTypical cause
0SuccessThe command did what it said.
1The operation failedAn API error, a refused credential, an unreachable control plane, a push that did not fully succeed, a sync run that ended badly.
2Usage, before anything was sentA missing required option, a key that cannot be an HTTP header, a plain http URL, no API key with nobody at the keyboard, --file pointed at a folder.

Exit 2 is worth handling separately: it means nothing left the machine, so retrying will produce the same result until the command line or the environment changes. Exit 1 is a condition worth a retry or an alert.

engram status is designed as a gate. It exits 1 when the API is unreachable, when nobody is logged in, or when the key is refused, so this is a real check:

engram status >/dev/null || { echo "engram unavailable"; exit 1; }
engram push ./docs --corpus "Support KB"

Credentials in a pipeline

Do not run engram login in CI. The environment variables take precedence over any stored profile and write nothing to disk, which is the right shape when the key comes from a secret store:

export ENGRAM_API_KEY="$ENGRAM_KEY"
export ENGRAM_API_URL="https://api.engramdynamics.org"   # optional, this is the default
engram status --json

Scope the key to what the job does, and give each job its own so revoking one does not stop the others:

engram keys create --name ci-docs --scope ingest --json | jq -r '.key'

Two guards fire before any request when the credential is wrong, and both exit non-zero with a sentence rather than a traceback. A key with a non-ASCII character in it is refused because HTTP headers cannot carry it. And an ENGRAM_API_URL on plain http is refused at use time, on every command, because it never passed through login where --insecure is granted. Only localhost, 127.0.0.1 and ::1 are exempt.

engram login with no key and no terminal exits 2 with No API key given. Pass --api-key or set ENGRAM_API_KEY, rather than waiting at a hidden prompt forever. That is the behaviour you want in CI, and it is why the variables are the supported path.

A worked example: GitHub Actions

Push a docs folder on every merge, and fail the job if the load did not land:

name: Publish docs to Engram

on:
  push:
    branches: [main]

jobs:
  publish:
    runs-on: ubuntu-latest
    env:
      ENGRAM_API_KEY: ${{ secrets.ENGRAM_API_KEY }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install the Engram CLI
        run: pipx install engram-dynamics

      - name: Check the platform is up
        run: engram status

      - name: Push the docs folder
        run: |
          engram push ./docs \
            --corpus "Support KB" \
            --exclude 'drafts/**' \
            --json > push.json
      - name: Summarise
        if: always()
        run: jq -r '.counts, .state, .error // empty' push.json

Nothing is committed unless every byte landed, so a failed step leaves the base as it was and re-running the job finishes the work. That makes the retry button safe.

Patterns worth copying

Wait for onboarding, then do something else

push waits by default and exits non-zero if the run did not succeed, so the sequencing is just shell:

set -euo pipefail
engram push ./docs --corpus "Support KB" --json > push.json
echo "sync run: $(jq -r '.sync_run_id' push.json)"
./notify-team.sh

Stage a large load, build later

engram push ./archive --corpus "Support KB" --no-onboard --json > staged.json
# ... in the build window ...
engram sync --corpus "Support KB" --wait

Check a base before pointing traffic at it

ready=$(engram status "Support KB" --json | jq -r '.status')
[ "$ready" = "ready" ] || { echo "not ready yet"; exit 1; }

Feed a generated document straight in

./render-release-notes.py | \
  engram docs upsert --corpus "Support KB" --path notes/release.md --text -

Gotchas

Next