Why the OAA CLI Has Separate Inspection Commands¶
The oaa CLI could have stayed a single entry point: point it at a
config.yaml, run a sync, done. Instead it exposes nearly 30 commands
that inspect configs, preview data, scaffold integrations, and manage
objects in a Veza tenant, all without running a sync. This document
explains why that surface exists and how it’s organized.
The core problem: sync is slow and expensive to iterate on¶
Running a full sync means fetching real data from a source system, running it through every transform and hook, and pushing the result to Veza. When something goes wrong — a missing field mapping, a bad transform, an unset environment variable — the sync is the last place you want to discover it. Each failed attempt costs an API round-trip to the source, wall-clock time, and (for a live tenant) a bad or partial push to Veza.
The CLI commands exist to move that feedback loop earlier and make it
cheap. preview and preview_mapped let you see what a source actually
returns, before and after mapping, without touching Veza at all.
validate_mappings catches typo’d field names by diffing your
field_mapping against real fetched data. check_env catches missing
environment variables before they surface as a confusing runtime
KeyError mid-sync. test_transform lets you iterate on a transform
function against one sample value instead of a whole dataset. None of
these commands write anything — they’re all safe to run repeatedly while
you build out a config.
Why introspection commands exist at all¶
list_transforms, list_entity_types, show_entity, list_hooks, and
config_schema answer a different question: not “is my config correct?”
but “what’s even possible?” The set of entity types, transform functions,
and hook events is defined in code (oaa.commands, oaa.hooks.decorators,
oaa.config.config_models), and it changes as the framework grows. Rather
than requiring every integration author to go read that source, these
commands surface it directly from the running code — so the CLI’s answer
to “what fields does LocalRole need?” or “what hook events exist?” can
never drift out of sync with the actual implementation.
Why scaffolding is a command, not a template repo¶
init, generate_source, and generate_hook exist because integration
projects share a large amount of boilerplate — directory layout,
sample.env, a base config.yaml structure, module stubs with the
fetch() entry point CLAUDE.md/AGENTS.md require. Keeping that
boilerplate as CLI-generated output (rather than a copy-pasted template
repo) means it’s generated fresh from the current framework version every
time, and additions like generate_source’s !include-aware YAML
insertion (_insert_yaml_block) stay correct even as an existing config
grows more complex.
Why the Veza API commands are separate from sync¶
list_providers, show_provider, delete_provider,
show_datasource, delete_datasource, rename_provider, and
export_provider operate on objects that already exist in a Veza tenant,
independent of any particular sync run. A provider or datasource can
outlive the config that created it, get renamed, or need to be cleaned up
entirely — none of which fits naturally into “run a sync.” Splitting
tenant management into its own command group also lets these commands be
guarded more tightly: both delete operations require an explicit
--confirm=True, and default to a dry-run report of what would happen,
because deleting a provider or datasource is destructive and there’s no
undo.
Why run_query exists even though it doesn’t work yet¶
run_query is deliberately shipped ahead of the Veza API endpoint it
depends on. The command, its argument parsing, and its tests are already
correct against the documented API shape; only the live endpoint
(/api/v1/assessments/queries:execute) is missing on the Veza side. Once
that endpoint ships, run_query works with no CLI-side changes needed.
In the meantime, download_query covers the common case — pulling
results from a query that’s already saved in Veza — and run_query’s own
docstring says so explicitly, rather than pretending to be feature-complete.
Why --json is a flag, not a separate command¶
Every command’s core logic returns a plain Python value (a dict, a list
of dicts, or a scalar); a small shared decorator
(oaa.commands.json_decorator.json_command) is responsible for deciding
how to print it. That split means every command gets both a
human-readable text rendering and a machine-readable JSON rendering for
free, without duplicating logic — a script can pipe --json output into
jq, while a person reading a terminal gets a formatted table or
message. The one sharp edge from this design, documented per-command in
the reference doc, is that a few commands
(check_env, validate_mappings, diff_config) signal failure via
process exit code only in their text-mode renderer — --json mode
returns the same data but always exits 0, since the exit-triggering logic
lives in the renderer the JSON path skips. Anyone scripting around
--json output needs to check the payload’s own success/failure field
instead of the exit code.