Skip to content

CLI Conventions

Philip Helger edited this page Oct 9, 2026 · 18 revisions

CLI Conventions

Conventions are binding for every command, so that a user who has learned one area can guess the next one. Status of this page: proposed — nothing here is implemented yet.

Command structure

peppol <area> <action> [options] [arguments]

Exactly two levels before the arguments. No deeper nesting; if an area grows too large, it becomes its own area.

The four identifier areas are separate for a substantive reason, not tidiness. POLICY 2 of the Peppol Policy for use of Identifiers makes participant identifier values case-insensitive while document type and process identifier values are case-sensitive; one id validate would have to pick a comparison rule or guess from the input, and either way some answers would be wrong. spid is separate because SPIS 1.0.0 is its own specification with its own structure, length limit and assigning authority. The area name states which rule set applies.

pid and spid overlap on purpose: SPIS is registered as participant identifier scheme ICD 0242, so peppol pid validate 0242:000270 and peppol spid validate 000270 are both legitimate and ask different questions. The pid command validates the participant envelope and delegates the value to the SPIS rules, and says in its output which rule set it applied — so the two areas can never appear to contradict each other.

Area Purpose Examples
pid Participant identifier validation and inspection peppol pid validate, peppol pid normalise, peppol pid scheme
doctypeid Document type identifier validation and inspection peppol doctypeid validate, peppol doctypeid parse
procid Process identifier validation peppol procid validate
spid Service Provider ID and Seat ID handling peppol spid validate, peppol spid seat, peppol spid main-id
dns Hash calculation and the DNS resolution chain peppol dns hash, peppol dns resolve
sml SML queries and registrations. One command, peppol sml cert-change, is a BDMSL extension rather than SML 1.3.0, and says so in its help and output peppol sml query, peppol sml register-smp
smp SMP read, per Peppol SMP 1.4.0 peppol smp lookup, peppol smp can-receive
phoss-smp (alias phosssmp) SMP administration via the phoss-SMP write API peppol phoss-smp create-participant, peppol phoss-smp set-endpoint
dir Peppol Directory peppol dir search, peppol dir businesscard
cert Certificates and PKI peppol cert verify, peppol cert info
endpoint Transport-level diagnostics peppol endpoint tls, peppol endpoint http
codelist Code list browsing peppol codelist list, peppol codelist search
validate Document validation via Phorm peppol validate file
mls Message Level Status handling peppol mls parse, peppol mls validate
sbdh Business message envelope inspection peppol sbdh parse
bulk Run a lookup over many participants peppol bulk lookup

Supporting commands: peppol help [area [action]], peppol version, peppol completion <shell>, peppol config show.

Why smp and phoss-smp are separate areas

Peppol SMP 1.4.0 specifies a read interface only — §3 states it "does not specify interfaces for creating, updating, deleting and managing Service Metadata". Every write operation therefore targets an implementation's own API, in practice phoss-SMP's.

Putting those under smp alongside the specified read commands would imply a standardisation that does not exist, and would leave a user unable to tell which of two adjacent commands is portable. So the area name carries the information: peppol smp … works against any conformant SMP, peppol phoss-smp … targets a specific implementation. A future peppol <other>-smp area can be added without renaming anything.

There is deliberately no area for commands spanning SMP and SML. An SMP with an SML connection configured performs its own SML registration, so an SMP write is one operation rather than two — see the Feature Catalogue. The sml area is for managing the SML directly, which is a different job from administering SMP content.

Global options

Valid on every command; area-specific options never reuse these names for a different meaning.

Option Values Default Meaning
--network test, prod prod Selects the network profile (SML zone, Directory URL, trust anchors). Production is the default because the overwhelming majority of use is looking up real participants; --network test is the deliberate act.
--config <file> path see Configuration Use this config file instead of the discovered one.
--output text, json, jsonl text Output format. text is for humans, json is the machine contract, jsonl is the streaming form of it for commands that produce many rows.
--verbose, -v repeatable off -v adds progress detail, -vv adds wire-level detail. Always on stderr.
--quiet, -q flag off Suppress everything but the result and errors.
--no-cache flag off Bypass the caches this run would otherwise use: the revocation result cache (6 hours, the one that can genuinely return a stale verdict), the in-process DNS cache and the DNSSEC key cache. It does not clear the operating system's resolver cache, and there is no code list cache to bypass — the code lists are compiled in. See Feature Catalogue.
--connect-timeout <duration> e.g. 10s 10s How long to wait for a connection to be established. A host that is down should fail fast.
--read-timeout <duration> e.g. 2m 2m How long to wait for a response once connected. Generous on purpose: a Phorm validation of a large document, or a slow SMP, can legitimately take a while, and killing a request that would have succeeded is worse than waiting.
--dnssec / --no-dnssec flag on Validate the DNS resolution chain with DNSSEC. On by default — see ADR-009 — and global rather than per-command, because every command that resolves a participant depends on it. On failure the command fails; there is no silent fallback.
--dns-timeout <duration> e.g. 5s 5s Overall budget for a DNS lookup, separate from the HTTP timeouts because DNS is a different transport with much lower expected latency. The default matches the library default (DNSConfig.DEFAULT_RESOLVER_TIMEOUT, itself taken from dnsjava's ExtendedResolver.DEFAULT_TIMEOUT), so the tool does not silently behave differently from everything else in the ecosystem. Applies to the discovery step of every command that resolves a participant, not just peppol dns ….
--no-color flag auto Disable ANSI colour. Colour is off automatically when stdout is not a TTY.
--profile <name> name default Selects a named profile from the config file (see Configuration).
--query <expr> expression none Extract part of the JSON result, so simple cases need no jq. > TODO(decide): adopt JMESPath (as the AWS CLI does) or a simpler dotted path?
--retries <n> integer > TODO(decide) Retry attempts for retryable network failures, with exponential backoff. 0 disables.
--debug flag off Full wire-level diagnostics on stderr, with credentials redacted.

Every option also has an environment-variable form; see Configuration.

Streams

  • stdout carries the result, and nothing else. In --output json stdout is a single valid JSON document. In --output jsonl it is one JSON document per line — a header, the rows, then a trailer — which is a separate format rather than an exception to that rule.
  • stderr carries progress, warnings, log output and error messages, in every output mode.

This split is what makes peppol ... --output json | jq safe.

Exit codes

Distinct codes so a CI job can branch on the kind of failure without parsing output. 0, 1 and 2 are fixed by the picocli defaults (CommandLine.ExitCode.OK = 0, SOFTWARE = 1, USAGE = 2, verified in picocli 4.7.7 sources), the rest are ours.

Code Name Meaning
0 OK The command succeeded. For a check command, the check passed.
1 SOFTWARE Unexpected internal error / unhandled exception. A bug report is appropriate.
2 USAGE Command line usage error: unknown command or option, missing or malformed argument.
3 NOT_FOUND The lookup was technically successful but the object does not exist (no SML record, no service group, no matching endpoint).
4 CHECK_FAILED The subject was found but is not valid: invalid identifier, failed document validation, bad SMP signature, expired or untrusted certificate.
5 NETWORK Transport-level failure: DNS failure, connection refused, TLS handshake failure, timeout, HTTP 5xx.
6 CONFIG Configuration error: config file missing/unparseable, required profile or credential absent, keystore not loadable.
7 AUTH Authentication or authorisation rejected by the remote service (HTTP 401/403 from an SMP or Phorm).

Two mappings are fixed by the specifications rather than chosen by us, which is worth stating because it makes them non-negotiable:

Remote condition Source Exit code
SMP returns HTTP 404 SMP 1.4.0 §5.1 — "must be returned if a specific resource could not be found" 3
SMP returns HTTP 500 SMP 1.4.0 §5.1 — internal processing error 5
SML notFoundFault SML 1.3.0 §3.1.4.1 3
SML unauthorizedFault SML 1.3.0 §3.1.4.2 7
SML badRequestFault SML 1.3.0 §3.1.4.3 — "the operation request was incorrect in some way" 2
SML internalErrorFault SML 1.3.0 §3.1.4.4 5
Phorm HTTP 403 Verified in its source (X-Token mismatch) 7
DNSSEC validation BOGUS A response was received and failed validation 4

Rules:

  • "Not found" (3) and "found but invalid" (4) are deliberately different. peppol smp can-receive returning 3 means this participant is not registered; returning 4 means registered, but cannot receive what you asked for.
  • A command that only reports information (peppol doctypeid parse, peppol codelist search) returns 0 whenever it produced its report, even if the report contains warnings.
  • A warning never changes the exit code. A warning is advice about something that is currently valid; a non-zero exit means a check actually failed. Mixing the two makes every exit code ambiguous and trains people to ignore failures. So:
    • peppol cert expiry on a certificate expiring in three days → exit 0 with a warning. It is still valid.
    • The same certificate once expired → exit 4. The check has genuinely failed.
    • A deprecated participant scheme or document type → exit 0 with a warning, by the same rule. > TODO(decide): confirm this reading for deprecation specifically — it follows from the cert expiry decision, but deprecated identifiers are arguably a stronger signal than a looming expiry.
  • Because warnings are invisible to $?, they must be visible elsewhere: every warning carries a stable machine code in messages[] under --output json, so a pipeline that wants to act on one can.
  • Where a warning genuinely needs to gate a build, the opt-in is command-specific and expressive rather than a blanket --fail-on-warn. peppol cert expiry --fail-below <duration> is the model: the threshold is the requirement, and a boolean could not carry it. A global flag would also lump unrelated advisory warnings — a deprecated scheme, an outdated business card version — into the same gate as a certificate that is about to take a participant off the network, which are not comparable consequences.

JSON output is a stable contract

--output json is intended for CI pipelines, so it is treated as API surface:

  • Every JSON response is a single object with a stable envelope. Draft:
    {
      "schemaVersion": 1,
      "command": "smp can-receive",
      "network": "test",
      "success": true,
      "exitCode": 0,
      "result": { },
      "messages": [ { "level": "warn", "code": "SCHEME_DEPRECATED", "text": "..." } ]
    }
  • result is command-specific; the envelope is not.
  • --output jsonl carries the same envelope fields, split across a header line and a trailer line with the rows between, and is held to the same schemaVersion discipline. It is a second serialisation of this contract, not a relaxation of it: a field cannot appear in one form and not the other.
  • Compatibility: fields are added, never removed or repurposed, within one schemaVersion. A breaking change increments schemaVersion and is announced in the release notes.
  • Machine-readable code values in messages are part of the contract; the human text is not.
  • Numbers, booleans and null are used as such — no stringly-typed values.
  • Errors are still valid JSON: on failure success is false and messages explains why. A CI job never has to parse a stack trace.
  • > TODO(decide): publish a JSON Schema per command, or document the shapes in the wiki only?

Identifier aliases

Peppol document type identifiers are long enough to be unusable on a command line — routinely over 200 characters. Wherever a command takes a document type identifier, it also accepts a slugified code list display name as an alias:

peppol smp can-receive 0088:4035600210708 peppol-bis-invoice-3
# instead of
peppol smp can-receive 0088:4035600210708 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice##urn:cen.eu:...'

Rules, which exist because the underlying data is messier than it first appears:

  1. Aliases derive from the code list display names (getCommonName() / the name attribute), slugified: lowercased, with spaces and punctuation collapsed to hyphens, so no shell quoting is needed.

  2. Ambiguity is an error, never a guess. Verified: 16 of the 321 document type display names in code list v9.7 are used by two entries each — typically a busdox-docid-qns and a peppol-doctype-wildcard variant of the same profile. This applies to every command that accepts an alias, including the codelist actions themselves. When an alias resolves to more than one identifier the command fails — it does not pick one, and it does not pick the first:

    • Exit code 2 (usage error): the input cannot be acted on unambiguously.
    • The message says so plainly — "alias is not unique" — names each candidate identifier in full, and states the remedy: pass the full identifier instead.
    • In --output json the failure carries the stable code ALIAS_NOT_UNIQUE and the candidates as a typed list, so a pipeline can report them without scraping text.

    Guessing here would be the worst possible failure: the command would succeed, the output would look right, and the answer would be about a different document type than the user meant.

  3. The resolved identifier is always echoed — on stderr in text mode, as an explicit field in JSON. An audit trail must record what was actually queried.

  4. Aliases are a convenience, not a contract. Display names can change between code list versions, so a script pinned to an alias can change meaning after an upgrade. The documentation should recommend full identifiers in unattended use, and --output json always reports the full ID regardless.

  5. peppol codelist alias <name> resolves an alias without doing anything else, so it can be used to expand aliases in scripts once and then use the result.

> TODO(decide): should aliases also cover process identifiers and participant schemes? Process IDs are similarly long, and the same uniqueness question applies. > TODO(decide): is a curated, CLI-maintained alias table (stable by construction, e.g. "bis3-invoice") preferable to deriving aliases from display names (free, but unstable and ambiguous)? A small curated set for the handful of document types people actually type, falling back to derived names, may be the best of both.

One subtlety about --dns-timeout

The value is an overall budget across resolvers, not a per-resolver one. ph-dns divides it by ResolverHelper.SINGLE_RESOLVER_TIMEOUT_DIVISOR (currently 2) to derive each individual resolver's timeout, because dnsjava requires the ExtendedResolver's overall timeout to exceed that of the resolvers it contains — if they were equal, the overall budget would already be exhausted when the first resolver timed out and no second resolver would ever be tried.

So --dns-timeout 5s gives each resolver roughly 2.5s. Worth stating in --help, because otherwise "I set 5 seconds and it gave up after two and a half" is a reasonable thing to be confused by. Under --verbose the resolution step should report which resolvers were tried and how long each took, which makes the behaviour visible rather than something to be inferred.

The prod default and write safety

With --network defaulting to production, the entire safety burden for state-changing commands sits on the write rails rather than on the network selector. That makes them non-negotiable:

  • Every SML and phoss-smp write command requires an explicit confirmation or --yes, and supports --dry-run.
  • A bulk write is stricter: --yes is mandatory, and the confirmation prompt is additionally shown when stdin is a TTY. Without the flag the command exits 2 without sending anything. Bulk writes mostly run unattended, where a prompt cannot be answered and would only hang the job — so the gate has to be something a job definition can carry and a reviewer can see.
  • A destructive command prints what it is about to affect before asking — for sml smp delete that means the blast radius, since deleting an SMP registration detaches every participant under it.
  • The resolved network is named in the confirmation prompt and in the output of every write command, so "I thought I was on test" is caught before the call, not after.

Read commands need none of this, which is the other half of the reason the default is safe to set: a lookup against production is harmless, and it is what the user almost always meant.

Option precedence

One documented chain, the same for every setting, highest priority first:

  1. The command line option (--network prod)
  2. The environment variable (PEPPOL_NETWORK=prod)
  3. The selected profile in the config file
  4. The default profile in the config file
  5. The built-in default

peppol config show prints the effective value of every setting and where it came from, which is the fastest way to end an argument about why a command hit the wrong network.

Pagination

Result sets that can be large (Directory search, service group listings, bulk operations) follow one pattern:

Option Meaning
--max-items <n> Stop after this many result items.
--page-size <n> Items requested per underlying call. A tuning knob, not a result limit.
--all Follow all pages. Off by default, so an unbounded query cannot be issued by accident.

In --output json a truncated result is marked as such in the envelope, so a CI consumer can tell "10 results" from "the first 10 of many".

Batch calls versus bulk

Where a remote API offers its own batch operation, the two forms are kept separate rather than merged behind a flag:

  • The area command with --from <file> issues the API's own batch call — one request, and only the detail that call returns. For the SML's CreateList() and DeleteList() that is nothing per identifier at all.
  • peppol bulk <operation> <file> issues one call per row and reports one result per row.

Which command the user types therefore states what they are asking for, and what it will cost. That is a better place for the trade-off than a flag such as --isolate-failures, which would have hidden an N-fold change in request count inside an option.

One documented exception: peppol sml participant. There, fetching everything is a separate action — listall — rather than list --all. The convention above assumes the one-page and all-pages forms differ only in how much they return, whereas here the all-pages form issues potentially thousands of authenticated SOAP calls instead of one and cannot guarantee a consistent set of data. Those are different operations, not two sizes of the same one, so they get different names and the cost is visible in what the user types. If another command ever has the same shape, it should follow the same pattern rather than overloading --all.

Long-running operations: wait

SML registrations become visible through DNS only after propagation, which makes "register, then immediately look up" a classic failure. Rather than leaving users to write sleep loops, the tool offers explicit waiting, the way established CLIs expose waiters:

peppol dns wait <participant-id> --until resolvable --timeout 15m

It polls with backoff, reports progress on stderr, exits 0 as soon as the condition holds and exit code 5 on timeout.

Note --timeout here is the waiter's total wait budget, which is a different thing from the global --connect-timeout and --read-timeout that bound a single HTTP or DNS request. It is local to the waiting commands, so the global options keep one meaning each. > TODO(verify): the realistic DNS propagation characteristics after an SML operation, to pick a sensible default wait budget > TODO(decide): name it --max-wait instead, to remove any chance of confusing it with the request timeouts?

Prior art

Conventions deliberately borrowed from established CLIs — chiefly the AWS CLI, plus git, kubectl and gh:

Practice Adopted as
<tool> <service> <operation> two-level verbs peppol <area> <action>.
Named profiles in a config file --profile, network profiles in Configuration.
Config and credentials in separate files Secrets live in their own file with restrictive permissions.
Documented option precedence chain The chain above, with peppol config show to explain the outcome.
Consistent env var prefix PEPPOL_* for every setting.
configure subcommands peppol config init / set / get / show.
Machine-readable output as a first-class mode --output json as a versioned contract, not a debug aid.
Distinct, documented exit codes The table above.
--dry-run on mutating operations Required for all SML/SMP write commands.
Waiters for asynchronous state peppol dns wait.
Client-side result filtering --query.
Credential redaction in debug output --debug never prints keystore passwords or Authorization headers.

And the lessons learned from their rough edges, stated as rules:

  • No pager by default. The AWS CLI v2 sends output to a pager unless told otherwise, which breaks casual piping and surprises people in scripts. peppol never invokes a pager; the user pipes to one if they want it.
  • No interactive prompting unless asked. A missing required argument is a usage error (exit 2) with a clear message, not an interactive question — otherwise an unattended job hangs. peppol config init is the one deliberately interactive command. > TODO(decide): offer an opt-in --interactive mode for exploratory use?
  • Colour and progress only for a TTY. Detected automatically, overridable with --no-color, and never present in --output json.
  • One canonical name per option. No aliases that drift apart; --help shows exactly one spelling. This governs options. The one area alias — phosssmp for phoss-smp — is a deliberate exception on different grounds: it exists because a triple s is easy to mistype in either direction, both spellings dispatch to exactly the same commands so there is nothing to drift, and only the canonical phoss-smp appears in help output and shell completion. An alias is acceptable when it removes a guess; it is not acceptable when it creates one.
  • The same operation may appear in two areas when users genuinely arrive at it from two directions — peppol cert seat-id and peppol spid from-cert are the one case, since "I have a certificate, what is its Seat ID?" and "I need the Main ID, where does it come from?" are different starting points. The condition is strict: one implementation, identical output and exit codes, and the same test fixtures run through both entry points. Two commands that can drift apart are worse than one command filed in the less obvious place.
  • Errors say what to do next. "No SML record found for 0088:... on the test network — is the participant registered on prod instead?" beats a bare stack trace, and the remediation hint belongs on stderr.

Shell completion

peppol completion bash|zsh|fish prints a completion script to stdout, to be sourced or installed by the user.

  • picocli ships completion-script generation via picocli-codegen (> TODO(verify): confirm which shells picocli 4.7.7 can generate for — bash and zsh are covered; check whether fish needs a hand-written script).
  • Completion must cover areas, actions and option names. Completing values (participant identifiers from the cache, code list entries, profile names from the config) is desirable for codelist and --network.
  • The distribution archive ships the generated scripts so that packagers can place them in the right directory.

Clone this wiki locally