Skip to content

0xLeif/aps-cli

Repository files navigation

aps

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.

Install

Homebrew:

brew install 0xLeif/tap/aps

fledge plugin (live-linked from the plugin hub):

fledge plugins install https://github.com/0xLeif/fledge-plugin-aps.git
fledge aps keys --json

Mint (builds the SwiftPM executable from source):

mint install 0xLeif/aps-cli

Source (Swift 6.0+):

git clone https://github.com/0xLeif/aps-cli.git
cd aps-cli && swift build -c release
.build/release/aps --help

Commands

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.

Default demo keys (seed schema)

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

Encrypted-file secret store (secret)

secret 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.enc holds 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_PASSPHRASE to derive the key from a passphrase via HKDF-SHA256 (no key file). Wrong passphrases fail loudly with secretUnlockFailed on both get and set.
  • Stateful gating: until secret.enc exists, 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.enc envelope blocks both get and set with decoding_failed until you run aps reset secret (or repair the file).
  • Interactive opt-in: with APS_SECRET_USE_PASSPHRASE=1 on a TTY, aps prompts once itself (its own getpass prompt, not macOS Keychain's).
  • aps reset secret deletes secret.enc; the key file is kept for future writes.
  • aps reset --all restores DemoKey seed keys only (safe for agent FileState keys). Use aps reset --registered to wipe every key in schema.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.

Dependencies

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 for aps dump (@AppDependency)
  • stats : process-local mutation counters (DemoStats / @ObservedDependency); aps stats reads them and aps stats --watch surfaces Combine updates

Requirements

  • Swift 6.0+
  • macOS 14+ (primary CI on macos-latest). Linux smoke runs on ubuntu-latest. Windows smoke runs on windows-latest via Scripts/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; brew spec-sync latest should match.

Build and run

git clone https://github.com/0xLeif/aps-cli.git
cd aps-cli
swift build
swift run aps --help

Or through fledge:

fledge lanes run verify

Fledge plugin shim

This 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 dump

Examples

swift 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 --registered

Agent usage

swift 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.

Output modes (human and agent)

aps follows the git porcelain rule: human output may be pretty, machine output is a frozen contract.

  • On a TTY: keys renders an aligned table with a bold header, JSON is pretty-printed, and semantic color is used sparingly (set NO_COLOR to disable).
  • When piped: keys is byte-stable TSV with no ANSI escapes, and all JSON is single-line compact.
  • watch --json is an alias for --jsonl; keys --quiet prints key names only (handy for xargs 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.

Multi-process FileState semantics

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 / watch for note, profile, and profileName fail with corruptState and exit code 65 (EX_DATAERR)
  • watch --jsonl emits 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).

Error contract

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.

Tests and smoke

swift test
./Scripts/smoke.sh
# Windows / PowerShell 7+ (same behavioral coverage as smoke.sh):
pwsh ./Scripts/smoke.ps1

CI

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

Trust toolchain

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 verify

Layout

Package.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

Next goal

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 coverage

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

Non-goals

  • No iCloud SyncState or SwiftData ModelState (see docs/spikes/)
  • No in-process plugin/daemon/network API inside aps itself (the repo may still be a fledge plugin shim; see above)

Windows / tri-OS readiness

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).

Design

Related