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 (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 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 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 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