Generated CLI
Aperture can generate a standalone CLI for your API directly from your manifests. The generated CLI is a self-contained Picocli application that knows about every entity, relationship, and auth endpoint in your model, with no hand-writing required.
Enabling the CLI
The CLI generator is off by default. Enable it by adding <cli><enabled>true</enabled></cli> to the aperture plugin configuration in your pom.xml, then bind exec-maven-plugin to compile it:
<plugin>
<groupId>com.itsjool.aperture</groupId>
<artifactId>aperture-maven-plugin</artifactId>
<executions>
<execution><goals><goal>generate</goal></goals></execution>
</executions>
<configuration>
<cli>
<enabled>true</enabled>
</cli>
</configuration>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<id>build-generated-cli</id>
<phase>test-compile</phase>
<goals><goal>exec</goal></goals>
<configuration>
<executable>/bin/sh</executable>
<workingDirectory>${project.build.directory}/generated-cli/aperture-cli</workingDirectory>
<arguments>
<argument>-c</argument>
<argument>if command -v native-image >/dev/null 2>&1; then mvn package -Pnative -DskipTests --no-transfer-progress; else echo "[aperture-cli] native-image not found; building fat JAR (install oracle-graalvm for a native binary)" && mvn package -DskipTests --no-transfer-progress; fi</argument>
</arguments>
</configuration>
</execution>
</executions>
</plugin>Running mvn test-compile (or mvn package) generates the CLI project and compiles it. The generated source lands at target/generated-cli/aperture-cli/.
Naming the binary
By default the CLI is named aperture. Override it in manifests/aperture/config.yaml:
apiVersion: aperture.itsjool.com/v1
kind: ApertureConfig
metadata:
name: config
spec:
cli:
binaryName: acme # your binary name hereThis name is used for the picocli @Command(name=...), the version output, and the native binary filename.
Custom auth extensions
The generated CLI writes auth commands from CliAuthExtension metadata at build time. The extension class must be available on the Maven plugin classpath, then listed under <cli><extensions>. Only one auth extension may be configured for a generated CLI.
Auth extensions have two tiers:
| Tier | Use when | Contract |
|---|---|---|
| Paths-only | Your server uses the generated username/password auth shape, but with different endpoint paths | Return AuthPaths; the built-in SimpleAuthCommand is generated with those paths |
| Full source override | Your login flow is structurally different, such as OIDC device-code login | Return complete Java source from authCommandSource(binaryName); the generator writes it instead of SimpleAuthCommand |
Token consumption is intentionally uniform. Every auth flow must persist credentials in the generated profile shape the API client already reads:
profiles:
default:
auth:
kind: bearer
accessToken: eyJ...
refreshToken: opaque-refresh-tokenThat keeps entity commands, ApiClient, env token overrides, and profile handling unchanged across auth providers.
Paths-only auth
<plugin>
<groupId>com.itsjool.aperture</groupId>
<artifactId>aperture-maven-plugin</artifactId>
<version>1.0.0-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>com.itsjool.aperture</groupId>
<artifactId>aperture-simple-cli</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
<configuration>
<cli>
<enabled>true</enabled>
<extensions>
<extension>com.itsjool.aperture.cli.simple.SimpleAuthCliExtension</extension>
</extensions>
</cli>
</configuration>
</plugin>If no extension is configured, the generator defaults to the simple-auth endpoint paths under /auth.
Full auth command override
For flows that are not username/password POSTs to Aperture, implement the source-emitting tier:
public interface CliAuthExtension {
String id();
AuthPaths authPaths();
default String authCommandSource(String binaryName) { return null; }
default String authCommandClassName() { return "AuthCommand"; }
}When authCommandSource() returns non-null source, the generator writes that source into com.itsjool.aperture.cli.cmd, registers authCommandClassName() in ApertureCli, and skips the built-in SimpleAuthCommand. The source must declare package com.itsjool.aperture.cli.cmd and provide a Picocli command group named auth.
The reusable OIDC implementation lives in implementations/aperture-cli-auth-oidc:
<plugin>
<groupId>com.itsjool.aperture</groupId>
<artifactId>aperture-maven-plugin</artifactId>
<version>1.0.0-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>com.itsjool.aperture</groupId>
<artifactId>aperture-cli-auth-oidc</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
<configuration>
<cli>
<enabled>true</enabled>
<extensions>
<extension>com.itsjool.aperture.cli.oidc.OidcDeviceCodeCliExtension</extension>
</extensions>
</cli>
</configuration>
</plugin>It generates auth login, auth refresh, auth logout, and auth me for OIDC device authorization:
apkc auth login \
--issuer http://localhost:8181/realms/aperture \
--client-id aperture-clilogin discovers the issuer, starts the device flow, prints the verification URL and user code, polls the token endpoint, then stores auth.kind=bearer, accessToken, refreshToken, and oidc: { issuer, clientId } on the active profile. Subsequent auth refresh and auth login can reuse the persisted issuer/client id.
For a full worked example with a real Keycloak realm wired to the device grant, a manual browser walkthrough, and a headless device-flow-smoke.sh script that verifies the same flow in CI, see demos/aperture-keycloak-cli-demo/README.md.
Custom commands
Beyond auth, you can contribute entirely new top-level commands via the CliCommandContribution SPI.
The generated CLI is a standalone Maven project that is compiled long after the Aperture plugin has finished running. Native binaries also pass through GraalVM native-image at this later stage. There is no live JVM at that point to load a runtime plugin object into, and native-image cannot discover or invoke arbitrary classes reflectively. CliCommandContribution implementations therefore do not hand over a command object; they act as source emitters. commandSource(binaryName) returns the complete Java source of a Picocli @Command class, which the generator writes into the generated project's com.itsjool.aperture.cli.cmd package and compiles alongside the entity and auth commands. The result is an ordinary, fully native-image-friendly generated-project class with no reflection involved.
public interface CliCommandContribution {
String id();
String commandClassName();
String commandSource(String binaryName);
}A single class may implement both CliAuthExtension and CliCommandContribution if it wants to contribute auth commands and other top-level commands together. The mojo instantiates each configured class once and adds it to whichever list(s) it implements.
Here's a trimmed version of the demo StatusCommand, contributed by SimpleStatusCliContribution in aperture-simple-cli. It hits the public /actuator/health endpoint with plain java.net.http (no auth headers required) and reports the active profile's login state:
public class SimpleStatusCliContribution implements CliCommandContribution {
@Override public String id() { return "simple-status"; }
@Override public String commandClassName() { return "StatusCommand"; }
@Override
public String commandSource(String binaryName) {
return """
package com.itsjool.aperture.cli.cmd;
import com.itsjool.aperture.cli.ApertureCli;
import com.itsjool.aperture.cli.config.ConfigStore;
import com.itsjool.aperture.cli.config.ProfileConfig;
import picocli.CommandLine;
import picocli.CommandLine.Command;
@Command(name = "status", description = "Show server health and auth status", mixinStandardHelpOptions = true)
public class StatusCommand implements Runnable {
@CommandLine.ParentCommand ApertureCli root;
@Override
public void run() {
var config = ConfigStore.load();
ProfileConfig profile = ConfigStore.activeProfile(config, root.global.profile);
if (root.global.server != null) profile.server = root.global.server;
if (profile.server == null) {
System.err.println("No server configured. Use --server or: @BINARY@ config set-server <url>");
System.exit(1);
return;
}
// GET <server>/actuator/health with plain java.net.http, then print
// server URL, health status, active profile, and auth kind/logged-in state.
// ...
}
}
""".replace("@BINARY@", binaryName);
}
}Register it exactly like a CliAuthExtension, as another entry under <cli><extensions>:
<configuration>
<cli>
<enabled>true</enabled>
<extensions>
<extension>com.itsjool.aperture.cli.simple.SimpleAuthCliExtension</extension>
<extension>com.itsjool.aperture.cli.simple.SimpleStatusCliContribution</extension>
</extensions>
</cli>
</configuration>The contributed command appears as a top-level subcommand alongside the entity and auth/config/apply commands. In the demo app, it is aperture status.
Fat JAR vs native binary
The build produces two possible artifacts depending on your JDK:
| Artifact | Requires | Path | Cold start |
|---|---|---|---|
| Fat JAR | Any JDK | target/generated-cli/aperture-cli/target/aperture-cli-0.0.1-SNAPSHOT.jar | ~1–2 s |
| Native binary | GraalVM (see below) | target/generated-cli/aperture-cli/target/<binaryName> | ~30 ms |
The exec-maven-plugin snippet above detects native-image at build time: if it's present the native binary is built; otherwise the fat JAR is produced and a clear message is printed. The build never fails because GraalVM is absent.
To use the fat JAR:
java -jar target/generated-cli/aperture-cli/target/aperture-cli-0.0.1-SNAPSHOT.jar --helpTo use the native binary (after a GraalVM build):
target/generated-cli/aperture-cli/target/aperture --helpGetting GraalVM for native builds
The project uses mise for toolchain management. Switch to Oracle GraalVM, a drop-in replacement for a standard JDK that adds native-image:
# mise.toml
[tools]
java = "oracle-graalvm-25.0.3" # includes native-image
maven = "3.9.16"Then reinstall:
mise installOracle GraalVM bundles native-image, so no separate installation step is needed.
System dependencies
native-image links against system C libraries. Make sure these are installed before building:
sudo dnf install gcc zlib-develsudo apt install gcc zlib1g-dev# Xcode Command Line Tools cover everything needed
xcode-select --installIf the build fails with collect2: error: ld returned 1 exit status and the error report says libz.a is missing, zlib-devel (or zlib1g-dev) is the missing package.
To build the native binary manually outside of the Maven lifecycle:
cd target/generated-cli/aperture-cli
mvn package -Pnative -DskipTests --no-transfer-progress
./target/aperture versionPlatform targeting
native-image always compiles for the host platform:
| Build machine | Binary target |
|---|---|
| Linux x86-64 | Linux ELF binary |
| macOS arm64 | macOS Mach-O (Apple Silicon) |
| macOS x86-64 | macOS Mach-O (Intel) |
| Windows x64 | aperture.exe |
To distribute binaries for multiple platforms, build on each target in CI (GitHub Actions matrix, for example). The fat JAR runs anywhere a JVM is present.
What the CLI generates
The CLI is verb-first, kubectl-style: a fixed set of top-level verbs, each with one subcommand per entity (named by its plural resource path). There is no per-entity top-level command group.
<binary> get customers [--page N] [--size N] [--filter key=value] [--sort field] [--include rel]
<binary> get customers <id>
<binary> create customers --name "Acme" --email "a@example.com"
<binary> update customers <id> --name "Acme Corp"
<binary> delete customers <id>get with no id lists resources; passing an id fetches that one resource. Either way it passes JSON:API query options through to the server:
aperture get customers --filter status=ACTIVE --sort -createdAt --include ordersFilters may be repeated or comma-separated. They are encoded as filter[key]=value.
For entities with optimisticLocking: true, update and delete take a --if-match <etag> option. The CLI does not fetch the current ETag automatically, so pass the version attribute from a prior get/create call, quoted (e.g. --if-match '"3"'). Omitting it on a locked entity returns HTTP 428.
create and update also accept -f <file> as an alternative to typed flags, and delete accepts -f <file> [-y]. See Declarative apply below. These -f paths share the same file format and lookup machinery as apply.
For oneof relationship fields, typed create and update commands use a concrete resource type and id pair:
aperture create lineitems \
--quantity 1 \
--unit-price 750 \
--billable-type servicepackages \
--billable-id 018fbf0c-2f4d-7b5e-a5c7-78dd0f03e21aThe --<field>-type value is the JSON:API resource type for the selected member (products, servicepackages, and so on), not the OneOf manifest name.
Plus a built-in auth command group for token-based login:
<binary> auth login --username admin@example.com --password
<binary> auth token --client-id svc-1 --client-secret
<binary> auth me
<binary> auth refresh
<binary> auth logoutAPI keys
The generated CLI can create, list, and disable personal API keys through the auth provider, then store a raw key on a profile for subsequent API calls:
aperture auth api-keys create --name smoke-key --non-expiring
aperture auth api-keys create --name smoke-key --expires-at 2027-01-01T00:00:00Z
aperture auth api-keys list
aperture auth api-keys disable <key-id>
aperture config set-api-key <raw-key>
aperture get customersSome tenants disallow non-expiring keys; if --non-expiring is rejected, use --expires-at instead.
config set-api-key stores credentials as auth.kind=api-key. Entity and apply commands then send the key as X-API-Key. config show reports the active auth kind, but does not print bearer tokens or API key values.
Service-account tokens are available through auth token:
aperture auth token --client-id svc-1 --client-secret s3cretThe returned access token is stored on the resolved profile as a bearer token.
Declarative apply
The generated CLI can create or delete resources from YAML files. This is useful for seed data, demo setup, and repeatable smoke tests.
Top-level YAML keys are plural JSON:API resource paths. Each value is a list of records:
customers:
- name: Initech Corporation
email: ar@initech.example
products:
- name: Widget Pro
sku: WGT-001
unit_price: 49.99
invoices:
- amount: 199.97
status: DRAFT
customer: Initech CorporationRelationship values can be UUIDs or display values. For display values, the CLI first checks resources created earlier in the same apply run, then falls back to an API lookup using the target entity's natural key.
One-of relationship values use an explicit JSON:API resource identifier map:
lineitems:
- description: Priority onboarding
quantity: 1
unit_price: 750
billable:
type: servicepackages
id: 018fbf0c-2f4d-7b5e-a5c7-78dd0f03e21aAtomic apply may also use lid instead of id when the selected member was created earlier in the same atomic batch.
Records can also set an explicit _ref: key. This user-chosen label is valid for any entity, and other records in the same run can reference it instead of (or as well as) the natural key:
customers:
- _ref: acme
name: Initech Corporation
email: ar@initech.example
invoices:
- customer: acme # resolves via _ref, same as "Initech Corporation" would
amount: 199.97
status: DRAFT_ref is never sent to the server; it is stripped like any other field the entity does not declare. It is most useful for entities with no natural key (no unique/name/code/sku/email/company_name/username field), where the only alternative is an internal, order-dependent positional label (see Limitations below).
Common commands:
aperture apply -f resources/customers.yaml
aperture apply -f resources/
aperture apply -f resources/customers.yaml --dry-run
aperture apply -f resources/customers.yaml --upsert
aperture apply -f resources/ --continue-on-error
aperture apply -f resources/ --atomic
# create/update/delete also accept -f, a resource-less file mode on the same verbs used
# for typed-flag CRUD, sharing apply's file format and lookup machinery (kubectl parity:
# `-f` and a resource subcommand are mutually exclusive on the verb).
aperture create -f resources/customers.yaml
aperture update -f resources/customers.yaml
aperture delete -f resources/customers.yaml
aperture delete -f resources/customers.yaml --yes
aperture delete -f resources/customers.yaml --dry-runDirectories are processed alphabetically. If one file depends on another, number the files explicitly, for example 01-customers.yaml, 02-products.yaml, 03-invoices.yaml.
For a worked example of --atomic creating multiple new, cross-referencing resources in one call (including what happens when one of them fails validation), see demos/aperture-demo/resources-atomic/order.yaml.
delete -f processes records in reverse order so child resources are deleted before parent resources. Without --yes, it prompts before sending any delete requests.
Unlike the plain update/delete subcommands with typed flags (which require an explicit --if-match), apply --upsert, update -f, and delete -f fetch the current ETag themselves before patching or deleting, so optimistic-locked entities work without any extra flags.
Limitations:
| Behaviour | Notes |
|---|---|
| Dependency ordering | The CLI does not build a dependency graph. Use file order for dependencies. |
| Upsert | --upsert (and the default skip-on-conflict path) require the server to return a conflict for duplicate natural keys, which in turn requires the natural-key field to be declared unique: true in the manifest. If it isn't, apply prints a one-time warning per entity type and applying the same file twice creates duplicates instead of skipping or patching. |
| Atomic apply | --atomic batches everything through JSON:API Atomic Operations (POST {versionPrefix}/operations). Unlike the default mode, it has no --upsert/skip-on-conflict path at all: it always issues add operations, so re-running the same file creates duplicate resources rather than erroring or being skipped. Use it for "create N related resources together, all-or-nothing," not for repeated/idempotent syncs. |
| Referencing a not-yet-created resource that has no natural key | Set _ref: explicitly (see above) rather than relying on the fallback: entities with no natural key and no _ref fall back to an internal positional label (<entityPath>-<index>) for same-batch --atomic cross-references, which isn't user-facing and shifts if records are reordered. |
Global options
All commands accept these flags (inherited by every subcommand):
| Flag | Description |
|---|---|
--server <url> | API base URL (overrides profile) |
--profile <name> | Config profile to use |
--api-version <v> | API version prefix (e.g. 1 → /api/v1/...) |
--tenant <id> | Tenant context header (SuperAdmin use) |
--scope <field>=<value> | Scope context header for a scopedBy entity (repeatable) |
--format table|json | Output format (default: table) |
-v, --verbose | Print HTTP request/response details |
If your manifests declare an ApiVersionConfig (manifests/aperture/versions.yaml), every entity is registered only under its real versions, with no unversioned fallback. You should not normally need --api-version at all, though: the generated CLI bakes in the manifest's ACTIVE version as GlobalOptions.DEFAULT_API_VERSION, used whenever neither --api-version nor a profile's pinned version (config set-api-version) is set. Use --api-version/config set-api-version to target a different (e.g. SUNSET) version explicitly.
Scope context (scopedBy)
Some entities declare scopedBy: <field> in their manifest. This is a partition key, not a role. Every operation on a scoped entity is fail-closed on the matching scope context, including create, but the failure shape depends on what is being evaluated. A list (get tasks) with no X-Aperture-Scope-<Field> header comes back 200 with an empty result set (the server compiles the scope check into a SQL predicate, so nothing matches). You do not see a 403; you see zero rows. A single-object evaluation returns 403: a fetch by id and the read-back a create/update performs to echo the just-written record (so even a bare create tasks fails closed with 403 if no scope header is present, though the write itself may already have landed). Distinct from all of that is the 400-on-mismatch case: if a scope value is present in context but the relationship in the request body names a different one, the write is rejected outright with 400 before anything is persisted. In summary, no scope header means a list is empty (200) and a single-object read/read-back is 403; a present but wrong scope value on create/update produces 400. scopedBy partitions data but does not authorize access to a partition. Any authenticated caller may name any scope value, so pair it with an ABAC policy for real per-scope access control (see the manifest guide's scopedBy section).
The CLI layers scope context exactly like --tenant, kubectl-namespace-style:
# One-off, for a single command:
aperture get tasks --scope project=<project-id>
# Sticky, for every subsequent command on this profile:
aperture config set-scope project <project-id>
aperture get tasks # now scoped automatically
aperture config unset-scope project--scope <field>=<value> is repeatable, allowing a command that touches several entities to set a different scope per field in one invocation:
aperture get tasks --scope project=<project-id> --scope team=<team-id>Precedence: --scope overrides a persisted profile scope for the same field; any field not overridden on the command line falls through to what is stored on the profile. Field names are canonicalized to lowercase wherever they are set (--scope Project=x and --scope project=x collapse to the same entry). The server also lowercases the header suffix it reads (X-Aperture-Scope-<Field>), so casing never matters.
config show lists the active profile's configured scopes alongside its tenant:
aperture config show
# ...
# Tenant: (none)
# Scopes: project=8f14e45f-fceb-4dc5-8b1a-4d6c9e6f2b1aWithout any scope configured, a request to a scoped entity fails closed. A list comes back empty, and a create fails on the read-back:
aperture get tasks
# (no rows) HTTP 200 with an empty result set; the scope filter matches nothing
aperture create tasks --title "Ship it" --status TODO --project-id <project-id>
# Error: ...HTTP 403... (scope header missing on the read-back, even though the write itself was well-formed)
aperture create tasks --title "Ship it" --status TODO --project-id <project-id> --scope project=<project-id>
# succeeds because scope in context matches the project on the recordShell completion
Generated CLIs include Picocli's completion generator:
aperture generate-completion > aperture-completion.sh
source aperture-completion.shRegenerate the script after changing manifests so entity commands and options stay current.
Configuration profiles
Credentials and server settings are stored in a per-user config file:
| OS | Location |
|---|---|
| Linux | ~/.config/<binaryName>/config.json |
| macOS | ~/Library/Application Support/<BinaryName>/config.json |
| Windows | %APPDATA%\<BinaryName>\config.json |
The file is plaintext JSON so operators can inspect and repair profiles directly. It is created with rw------- permissions on first login on POSIX file systems, and parent directories are restricted to the owner.
Profile helpers:
aperture config show
aperture config set-server http://localhost:8080
aperture config set-api-version 1
aperture config set-tenant tenant-a
aperture config set-scope project <project-id>
aperture config unset-scope project
aperture config set-api-key <raw-key>
aperture config use staging
aperture config list-profiles
aperture config delete-profile stagingconfig delete-profile refuses to delete the active profile; switch profiles first with config use.
You can also use environment variables to override the config. The prefix is derived from the generated binary name by uppercasing it and replacing non-alphanumeric characters with underscores. The default aperture binary therefore uses:
| Variable | Purpose |
|---|---|
APERTURE_CONFIG | Absolute path to the config file |
APERTURE_SERVER | Server URL |
APERTURE_TOKEN | Bearer token (overrides stored credentials) |
APERTURE_API_KEY | API key |
APERTURE_TENANT | Tenant context |