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.