Repository navigation
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.
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.
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.
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.
-
stdout carries the result, and nothing else. In
--output jsonstdout is a single valid JSON document. In--output jsonlit 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.
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-receivereturning 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 expiryon 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 inmessages[]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.
--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": "..." } ] } -
resultis command-specific; the envelope is not. -
--output jsonlcarries the same envelope fields, split across a header line and a trailer line with the rows between, and is held to the sameschemaVersiondiscipline. 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 incrementsschemaVersionand is announced in the release notes. - Machine-readable
codevalues inmessagesare part of the contract; the humantextis not. - Numbers, booleans and
nullare used as such — no stringly-typed values. - Errors are still valid JSON: on failure
successisfalseandmessagesexplains 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?
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:
-
Aliases derive from the code list display names (
getCommonName()/ thenameattribute), slugified: lowercased, with spaces and punctuation collapsed to hyphens, so no shell quoting is needed. -
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-qnsand apeppol-doctype-wildcardvariant of the same profile. This applies to every command that accepts an alias, including thecodelistactions 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 jsonthe failure carries the stable codeALIAS_NOT_UNIQUEand 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.
-
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.
-
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 jsonalways reports the full ID regardless. -
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.
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.
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-smpwrite command requires an explicit confirmation or--yes, and supports--dry-run. -
A
bulkwrite is stricter:--yesis 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 deletethat 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.
One documented chain, the same for every setting, highest priority first:
- The command line option (
--network prod) - The environment variable (
PEPPOL_NETWORK=prod) - The selected profile in the config file
- The
defaultprofile in the config file - 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.
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".
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'sCreateList()andDeleteList()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.
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?
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.
peppolnever 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 initis 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;
--helpshows exactly one spelling. This governs options. The one area alias —phosssmpforphoss-smp— is a deliberate exception on different grounds: it exists because a triplesis easy to mistype in either direction, both spellings dispatch to exactly the same commands so there is nothing to drift, and only the canonicalphoss-smpappears 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-idandpeppol spid from-certare 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.
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
codelistand--network. - The distribution archive ships the generated scripts so that packagers can place them in the right directory.
peppol-cli — licensed under the Apache License, Version 2.0.
Not an official OpenPeppol tool. This is an independent open source project and is neither endorsed by nor affiliated with OpenPeppol AISBL. "Peppol" is a trademark of OpenPeppol AISBL.
Status: planning
Overview
Design
Decisions
Reference