State you can see. Contracts you can trust.
aps is a small Swift CLI that brings AppState outside SwiftUI. Declare typed state, read it, change it, watch it, and expose the same stable contract to humans, agents, and CI.
Current release: 1.0.0. The next release is a safety and distribution hardening release; see release readiness before cutting a tag. The source and CI target macOS, Linux, and Windows, while published binary availability varies by platform.
Keys live in <state-root>/schema.json, with demo defaults materialized on first use. Start with the documentation map or the dynamic schema design.
This repository is gated by the CorvidLabs trust toolchain (fledge, spec-sync, augur, attest). See AGENTS.md.
The v1.1.0 release workflow introduces a stricter boundary than ordinary pull
requests: the exact tag commit must carry passing-test evidence signed by the
pinned human:leif Attest key. Both tag pushes and manual backfills use that
gate before artifact upload. See the release provenance runbook.
| Surface | Status |
|---|---|
| Serial and four-worker Swift verification lanes | Passing |
| macOS, Linux, and Windows source CI | Active |
| Homebrew and release packaging | Hardening before the next tag |
| Dynamic schema and secret safety | Safe paths, strict v2 envelopes, and transactional destructive operations |
| SpecSync contracts | Passing with 2 active module specs |
The current release remains useful for local AppState exploration. The next release hardens passphrase secrets for local use; production automation still depends on the remaining distribution items in release readiness.
Homebrew:
brew install 0xLeif/tap/apsfledge plugin (this repo is the plugin; no separate hub repo needed):
fledge plugins install 0xLeif/aps-cli
fledge aps keys --jsonMint (builds the SwiftPM executable from source):
mint install 0xLeif/aps-cliSource (Swift 6.0+):
git clone https://github.com/0xLeif/aps-cli.git
cd aps-cli && swift build -c release
.build/release/aps --helpForeign GitHub Actions workflows: use the reusable composite Action to install the release binary without a Swift toolchain:
- uses: 0xLeif/aps-cli/.github/actions/install-aps@8e2108601b182584e59b3e534b67199247593a0a
with:
version: 1.0.0
- run: aps set note "run-${{ github.run_id }}"The Action selects the release asset for Linux x64, macOS x64, or macOS arm64, verifies its .sha256 sidecar before moving it into the job-scoped ${RUNNER_TEMP}/aps/bin, and adds that directory to PATH. Linux releases are portable tar bundles containing the Swift runtime libraries, so no Swift toolchain is needed. The existing v1.0.0 release predates this Action and does not contain the portable Linux bundle; use a release built by the current release workflow for Linux. The Action defaults APS_HOME to ${RUNNER_TEMP}/aps-home through GITHUB_ENV; an existing APS_HOME is preserved. Pin the Action to a release tag or pass an explicit semantic version. Windows is not supported until a Windows release asset is published.
aps [--state-dir PATH] <subcommand> ...
aps get <key> [--json] [--state-dir PATH]
aps set <key> <value> [--json] [--state-dir PATH]
aps watch <key> [--count N] [--timeout SEC] [--jsonl] [--interval MS] [--state-dir PATH]
aps dump [--json] [--state-dir PATH]
aps keys [--json] [--state-dir PATH]
aps key add|remove|list ...
aps stats [--json] [--watch] [--count N] [--timeout SEC] [--state-dir PATH]
aps reset <key> [--json] [--state-dir PATH]
aps reset --all [--json] [--state-dir PATH]
aps reset --registered [--json] [--state-dir PATH]
aps schema [--json] [--state-dir PATH]
aps --help
aps --version
State root resolution: --state-dir (root form aps --state-dir PATH <cmd> or on the subcommand; subcommand wins) > APS_HOME > ~/.aps.
On first use, aps materializes <state-root>/schema.json with these seed keys:
| Key | Type | Storage | Lifetime |
|---|---|---|---|
counter |
Int |
State |
Process (in-memory) |
message |
String |
State |
Process (in-memory) |
flag |
Bool |
StoredState |
Persisted (UserDefaults; CLI calls synchronize() so Linux flushes) |
note |
String |
FileState |
Persisted ($APS_HOME/note.json) |
profile |
{name,version} |
FileState |
Persisted structured Codable ($APS_HOME/profile.json) |
secret |
String |
EncryptedFile |
Persisted encrypted under the state root (secret.enc) |
profileName |
String |
Slice |
Projection of profile.name via AppState Slice |
Add or remove keys at runtime:
aps key add smokeNote --type String --storage FileState --path smoke-note.json --initial ''
aps set smokeNote hello
aps key add settings --type object --storage FileState --path settings.json \
--field name=String --field retries=Int \
--initial '{"name":"agent","retries":3,"features":{"watch":true}}'
aps key add settingsName --type String --storage Slice \
--slice-of settings --slice-field name --initial ''
aps get settings --json
aps key remove smokeNote --purge
aps key list --json--field NAME=TYPE is repeatable for object keys. Every declared field is
required and type-checked, while undeclared fields are preserved. Object
values are structural JSON and may recursively contain nulls, booleans,
integers, finite doubles, strings, arrays, and objects. A Slice is valid only
when its parent object declares sliceField with the same type as the Slice.
Persistent schema paths must be portable relative file paths below the state root. aps rejects absolute or traversing paths, reserved APS files, case or Unicode-equivalent collisions, directories, special files, and paths that traverse symbolic links. Reset and purge delete only a validated regular-file leaf.
schema.json is authoritative for every registered name, including the seven
default seed names. DemoKey defines only the schema materialized for a new
state root. After key add --force, get, set, reset, watch, dump, type parsing,
and machine output all use the replacement entry. Force is non-destructive:
changing storage or path does not migrate, purge, or read the former adapter's
data, so changing back can reveal it again.
secret is backed by a v2 encrypted envelope under the state root, not the
Keychain. The payload uses ephemeral X25519 ECDH, HKDF-SHA256, and
ChaCha20-Poly1305 through
swift-crypto.
secret.enc: v2 JSON recordsrecipientModeplus the base64 ephemeral public key, nonce, ciphertext, and tag. Nothing plaintext is written at rest.- Key-file mode (default): v2 records
recipientMode: "keyFile"and omitskdf. The X25519 key is generated at<state-root>/secret.key. POSIX access uses no-follow handles, requires current-user ownership and exact mode0600, and safely repairs permissions through the open handle. Windows rejects reparse, directory, non-disk, and foreign-owned handles and requires a protected private DACL for the current user. New files are exclusive and private before key bytes are written. The key and ciphertext share a state root, so this mode does not protect against compromise of the whole root. - Passphrase mode: set
APS_SECRET_PASSPHRASE. Every seal creates a fresh cryptographically random 16-byte salt and derives a 32-byte X25519 private key with the public CryptoExtras scrypt API atN=131072,r=8,p=1. This profile costs approximately 128 MiB per derivation. aps depends on apple/swift-crypto4.0.0..<4.4.0; 4.4+ requires Swift tools 6.1 while aps retains its Swift 6.0 floor. - Strict metadata: v2 validates the exact JSON shape, canonical base64,
cryptographic field lengths, mode/KDF combination, 16-byte salt, and fixed
KDF constants before invoking scrypt. Unknown version or mode returns
unsupported_secret_envelope; malformed metadata returnsdecoding_failed. - Mode binding: a v2 envelope opens only with its recorded recipient mode.
aps never falls back between passphrase and key-file modes. A mismatch or
wrong credential returns
secret_unlock_failedwithout changing envelope or key bytes. - Legacy compatibility: unversioned key-file envelopes remain readable and
upgrade on the next successful set. A successfully unlocked unversioned
passphrase envelope migrates once, under
secret.store.lock, to v2 with a fresh salt. A wrong passphrase does not migrate. Any detected migration failure restores and verifies the exact original bytes before reporting failure; a restoration failure returnsrollback_failed. - Bounded KDF work: derived passphrase keys are cached only inside one SecretStore operation and only for a validated salt and the fixed profile. Encrypted watch compares complete envelope bytes first, so unchanged polls do no decoding, decryption, or scrypt work.
- An unsafe
secret.keyreturnsinsecure_secret_key_filewithout following, replacing, truncating, or deleting that path. Corrupt envelope data blocks get and set untilaps reset secretor repair. - Interactive opt-in: with
APS_SECRET_USE_PASSPHRASE=1on a TTY, aps prompts once itself (its own getpass prompt, not macOS Keychain's). aps reset secrettakes the secret-store lock, stages and verifies deletion ofsecret.enc, restores the envelope after a detected verification failure, and keeps the shared key file for future writes.aps reset --allrestores currently registered seed names through their live schema entries (safe for agent keys). Removed seed names stay removed. Useaps reset --registeredto restore every key inschema.json. Bulk reset is deterministic and fail-fast; JSON success and error payloads report reset, failed, and not-attempted keys.- Registered writes and resets resolve under the schema lock and retain it
through verified persistence.
aps key remove NAME --purgekeeps that lock through storage deletion, so a stale writer cannot recreate purged data. A detected purge failure restores the original schema before returning an error. This is detected-error transactional behavior, not crash atomicity. - The previous AppState
SecureState/ Keychain backend was replaced: ad-hoc signed CLI binaries can never earn durable Keychain trust, so every access prompted for a password. AppState itself is unchanged; SecureState remains dogfooded in AppStateExamples.
aps injects real services with @AppDependency / Application.dependency, plus one
@ObservedDependency consumer for AppState's observable dependency surface:
clock: wall clock for dump timestamps (@AppDependency)jsonCoding: shared JSON encoder helpers foraps dump(@AppDependency)stats: process-local mutation counters (DemoStats/@ObservedDependency);aps statsreads them andaps stats --watchsurfaces Combine updates
- Swift 6.0+
- apple/swift-crypto
4.0.0..<4.4.0, including the publicCryptoExtrasscrypt product - macOS 14+ (primary CI on
macos-latest). Linux smoke runs onubuntu-latest. Windows smoke runs onwindows-latestviaScripts/smoke.ps1. - For the trust gate locally: corvid-trust (
brew install CorvidLabs/tap/corvid-trust) - SpecSync 5.2.0 (see
.specsync/version). Trust CI mirrors that exact release; brewspec-synclatest should match. - Attest 1.0.0 for the local release-provenance contract test. Ordinary Swift build and test commands do not require the release signing key.
git clone https://github.com/0xLeif/aps-cli.git
cd aps-cli
swift build
swift run aps --helpOr through fledge:
fledge lanes run verifyThis repo ships a root plugin.toml, so aps-cli is itself the fledge plugin (fledge-plugin-aps v1.0.0 tracks the CLI version). Install straight from this repo: fledge plugins install 0xLeif/aps-cli. The earlier hub snapshot repo (0xLeif/fledge-plugin-aps) is retired; aps-cli is the only source of truth.
# From a clone of aps-cli:
fledge plugins install . # live-link; rebuilds release binary via the build hook
fledge aps keys --json # same CLI, invoked through fledge
fledge plugins validate . # also runs in the verify lane (manifest drift fails CI)Release build:
swift build -c release
.build/release/aps dumpswift run aps keys
swift run aps set counter 3
swift run aps set flag true
swift run aps set note "saved across launches"
swift run aps set profile '{"name":"agent","version":1}'
swift run aps dump
swift run aps watch note --interval 200 --count 2 --timeout 5
swift run aps reset --all
swift run aps reset --registeredswift run aps schema # self-describing contract: keys, commands, payloads, errors
swift run aps get note --json
swift run aps set counter 3 --json
swift run aps dump --json
swift run aps keys --json
swift run aps reset note --json
APS_HOME=/tmp/aps-agent swift run aps set note "isolated state"
swift run aps --state-dir /tmp/aps-agent get note --json
swift run aps get note --json --state-dir /tmp/aps-agent
swift run aps watch note --count 2 --timeout 5 --jsonl
swift run aps set profile '{"name":"agent","version":1}' --json
swift run aps get profile --json
swift run aps set profileName agent --json
swift run aps stats --json
swift run aps key add agentNote --type String --storage FileState --path agent-note.json --initial ''aps schema is the contract endpoint: one cacheable JSON document with cliVersion, integer schemaVersion
(bumped when the document shape changes; currently 6), live userSchema meta (formatVersion, keyCount, hash),
state-root precedence, every registered key and command, payload shapes, and the error-code table. Live values stay in
aps dump. ArgumentParser's full command tree is also available as JSON via
aps <cmd> --experimental-dump-help.
watch polls the adapter selected by the resolved registry entry, so forced seed replacements and disk-backed FileState / StoredState changes surface even when another aps process writes them. The default flag can read legacy AppState App/aps.flag data when aps.user.flag is absent; new registry writes use the canonical aps.user.<name> namespace.
aps follows the git porcelain rule: human output may be pretty, machine output is a frozen contract.
- On a TTY:
keysrenders an aligned table with a bold header, JSON is pretty-printed, and semantic color is used sparingly (setNO_COLORto disable). - When piped:
keysis byte-stable TSV with no ANSI escapes, and all JSON is single-line compact. - Object values remain JSON objects in machine output. They are never
stringified, and recursively nested arrays, objects, nulls, and numeric
values retain their structure and numeric value. Equivalent integral JSON
spellings such as
1,1.0, and1e0may canonicalize to1. watch --jsonis an alias for--jsonl;keys --quietprints key names only (handy forxargs aps reset).- Shell completions:
aps --generate-completion-script bash|zsh|fish(install into your shell's completion directory).
watch termination is observable in both channels. Exit codes: 0 when --count is satisfied, 124 on --timeout (GNU convention), 130 on SIGINT, 143 on SIGTERM. In --jsonl mode the stream ends with a terminal {"type":"end","reason":"count|timeout|sigint|sigterm",...} event and never contains non-JSON lines; in human mode a one-line summary goes to stderr. An unbounded watch prints a one-time stderr hint suggesting --count / --timeout.
aps expects one writer per key unless a storage adapter documents stronger behavior. Profile and Slice read-modify-write operations use a lock. The seed note FileState path still relies on AppState's unlocked write behavior, so concurrent writers can tear that JSON file.
When a FileState file exists but is undecodable (torn write), aps does not fall back to AppState's initial/cached value on the direct disk path:
get/watchfornote,profile, andprofileNamefail withcorruptStateand exit code 65 (EX_DATAERR)watch --jsonlemits one{"type":"error","error":"corruptState",...}line before exiting- Missing files still resolve to the key's initial value (same as a fresh store)
AppState itself is unchanged; this is an aps dogfood/CLI contract only. Repair with aps reset <key> (or delete the torn file under the state root).
Domain errors always print a human line to stderr and keep stdout empty, with a sysexits-aligned exit code:
| Code | Meaning | When |
|---|---|---|
| 0 | success | stdout contract satisfied |
| 64 | EX_USAGE | caller-fixable input: bad key/flags, invalid value, unknown_key, schema_conflict |
| 65 | EX_DATAERR | corrupt data, invalid schema, or unsupported_secret_envelope |
| 69 | EX_UNAVAILABLE | the configured passphrase or key cannot unlock the envelope |
| 70 | EX_SOFTWARE | internal bug |
| 73 | EX_CANTCREAT | persistence or schema rollback did not complete |
| 77 | EX_NOPERM | secret.key ownership, type, or privacy could not be proven |
64 means fix the invocation; 65+ means environment or data; 70 means an aps bug. Missing state files are not errors: they mean the initial value.
With --json / --jsonl, or when APS_ERROR_JSON=1, stderr additionally gets one structured envelope:
{"error":{"code":"invalid_value","hint":"Run `aps keys` to see expected types per key.","message":"Invalid value 'nope' for counter (Int)"}}code is stable and safe to match on: invalid_value, encoding_failed,
decoding_failed, persistence_failed, corrupt_state, schema_invalid,
unknown_key, schema_conflict, secret_unlock_failed,
unsupported_secret_envelope, insecure_secret_key_file, and
rollback_failed.
swift test
./Scripts/smoke.sh
# Windows / PowerShell 7+ (same behavioral coverage as smoke.sh):
pwsh ./Scripts/smoke.ps1| Workflow | Runner | Role |
|---|---|---|
.github/workflows/ci.yml |
macos-latest |
build / test / smoke |
.github/workflows/linux-smoke.yml |
ubuntu-latest |
Linux build + smoke |
.github/workflows/windows-smoke.yml |
windows-latest |
Windows swift test + PowerShell smoke |
.github/workflows/trust.yml |
macos-latest |
CorvidLabs Trust gate |
| File | Purpose |
|---|---|
fledge.toml |
Tasks + verify lane |
.trust.toml |
Unified Trust policy |
.augur.toml |
Diff-risk thresholds |
.attest.json |
Provenance policy |
.attest-release.json |
Strict release-only provenance policy |
.specsync/ |
SpecSync 5.2.0 config + SDD change tracking (.specsync/version) |
specs/ |
Module contracts (aps-cli, state-store) |
GOAL.md |
Shipped 1.0.0 release record |
AGENTS.md |
Standing rules (managed block required by CI) |
fledge trust doctor
fledge trust verifyPackage.swift
Sources/aps/
Tests/apsTests/
specs/
docs/design/
Scripts/smoke.sh
Scripts/smoke.ps1
Scripts/release-provenance-gate.sh
Scripts/test-release-provenance.sh
GOAL.md
LICENSE
.github/workflows/{ci,linux-smoke,windows-smoke,trust,release,post-release-formula}.yml
1.0.0 is shipped and public: release v1.0.0 and GOAL.md record.
The next release will:
- make dynamic schema paths and destructive operations safe by construction;
- make the runtime registry authoritative for every key;
- align portable Linux, Homebrew, and GitHub Action distribution;
- ship the implemented v2 passphrase and key-file hardening;
- require signed passing-test provenance on the exact release commit;
- rehearse installation from real release artifacts on clean hosts.
The full evidence, blockers, and exit criteria live in docs/release-readiness.md.
| AppState surface | Demo key / command | Status |
|---|---|---|
State |
counter, message |
Dogfooded |
StoredState |
flag |
Dogfooded |
FileState |
note, profile |
Dogfooded |
SecureState |
(none) | Not dogfooded here; moved to AppStateExamples after the Keychain prompt issue (issue #35) |
Slice |
profileName |
Dogfooded |
@AppDependency |
clock, jsonCoding |
Dogfooded |
@ObservedDependency |
stats / aps stats |
Dogfooded |
SyncState |
- | No-go (spike) |
ModelState |
- | No-go (spike) |
| OptionalSlice / DependencySlice | - | Not planned |
- No iCloud
SyncStateor SwiftDataModelState(seedocs/spikes/) - No in-process plugin/daemon/network API inside
apsitself (the repo may still be a fledge plugin shim; see above)
Audit findings and per-OS gaps live in docs/windows-readiness.md. CI runs the full matrix on GitHub-hosted runners (macos-latest, ubuntu-latest, windows-latest).
- Documentation map:
docs/README.md - Release readiness:
docs/release-readiness.md - Dynamic / user-defined keys:
docs/design/dynamic-schema.md(issues #39, #62-#64) - Go-public checklist: #40