A tiny Swift CLI that dogfoods AppState outside SwiftUI: declare typed app state, get/set/watch/dump it, and show dependency injection.
Current release line: 1.0.0. Targets macOS, Linux, and Windows where AppState allows. Keys come from <state-root>/schema.json (demo defaults materialize on first use); see the dynamic schema RFC.
This repository is gated by the CorvidLabs trust toolchain (fledge, spec-sync, augur, attest). See AGENTS.md.
Homebrew:
brew install 0xLeif/tap/apsfledge plugin (live-linked from the plugin hub):
fledge plugins install https://github.com/0xLeif/fledge-plugin-aps.git
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 --helpaps [--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 remove smokeNote --purge
aps key list --jsonsecret is backed by an age-style encrypted envelope under the state root (issue #35), not the Keychain: ephemeral X25519 ECDH + HKDF + ChaCha20-Poly1305 via swift-crypto, the same construction as AlgoChat's message encryptor.
secret.encholds the encrypted envelope (ephemeral public key, nonce, ciphertext, tag, base64 JSON). Nothing plaintext at rest.- Key file mode (default): a recipient key is generated on first use at
<state-root>/secret.key(base64 X25519, mode 0600), like an SSH key. Zero prompts, works headless and in CI, on every OS. - Passphrase mode: set
APS_SECRET_PASSPHRASEto derive the key from a passphrase via HKDF-SHA256 (no key file). Wrong passphrases fail loudly withsecretUnlockFailedon both get and set. - Stateful gating: until
secret.encexists, the first write seals with whichever recipient is active (key file or passphrase). After that, set must unlock the existing envelope before rewrite; a wrong passphrase cannot silently re-key. - A corrupt
secret.encenvelope blocks both get and set withdecoding_faileduntil you runaps reset secret(or repair the file). - 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 secretdeletessecret.enc; the key file is kept for future writes.aps reset --allrestores DemoKey seed keys only (safe for agent FileState keys). Useaps reset --registeredto wipe every key inschema.json.- 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+
- 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.
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 fledge can install aps as a live-linked plugin (fledge-plugin-aps v1.0.0 tracks the CLI version). Published on the plugin hub at 0xLeif/fledge-plugin-aps.
# 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 4), 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 uses Swift Observation for in-process updates and polls as a fallback so disk-backed FileState / StoredState changes can still surface, including updates written by another aps process. For note and profile, polling reads the JSON files directly so AppState's FileState cache cannot hide cross-process writes.
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. 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 at a time. Concurrent aps set on the same FileState key (note / profile) is last-writer-wins and is not locked: AppState writes files non-atomically, so two writers can tear a 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 or undecodable persisted state, or invalid schema.json (schema_invalid) |
| 70 | EX_SOFTWARE | internal bug |
| 73 | EX_CANTCREAT | write did not persist (unwritable state root) |
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.
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 |
.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
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, GOAL.md record, go-public checklist #40. Next steps live in the issue backlog, starting with release binaries and the Homebrew tap formula (#68).
| 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).
- Dynamic / user-defined keys:
docs/design/dynamic-schema.md(issues #39, #62-#64) - Go-public checklist: #40