From cb0cb2f7a9dcc41a19d0ec855ef94d987d7eb24a Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Sat, 18 Jul 2026 19:12:18 -0600 Subject: [PATCH 1/3] TTY-aware output under the git porcelain rule (Closes #33) - new TTY.swift: isatty detection, NO_COLOR-aware Style, aligned table - keys: aligned bold-header table on TTY, byte-stable TSV when piped, --quiet for names only - JSON pretty on TTY, compact when piped (gh rule) via CLIOutput.encodeJSON and JSONCoding.encodeAuto (dump keeps the injected dependency) - watch --json aliases --jsonl - README documents output modes and --generate-completion-script - tests (4 new) + smoke (piped-plain, alias, quiet, compact greps); specs in lockstep - SpecSync CHG-0021 accepted; evidence rebound for shared changes (audited reopens, actor 0xLeif) --- .specsync/change-sequence.json | 4 +- .../approvals.json | 19 ++ .../change.md | 25 ++ .../context.md | 23 ++ .../deltas/aps-cli.md | 65 +++++ .../deltas/state-store.md | 39 +++ .../design.md | 18 ++ .../docs.md | 11 + .../plan.md | 15 ++ .../requirements.md | 12 + .../state.json | 49 ++++ .../tasks.md | 12 + .../testing.md | 17 ++ .../verification-attempts.json | 22 ++ .../verification.json | 233 ++++++++++++++++++ README.md | 9 + Scripts/smoke.sh | 37 ++- Sources/aps/Aps.swift | 33 ++- Sources/aps/CLIOutput.swift | 17 ++ Sources/aps/Dependencies.swift | 16 ++ Sources/aps/StateStore.swift | 2 +- Sources/aps/TTY.swift | 50 ++++ Tests/apsTests/APSTests.swift | 54 +++- specs/aps-cli/aps-cli.spec.md | 11 +- specs/aps-cli/requirements.md | 11 + specs/state-store/state-store.spec.md | 7 +- 26 files changed, 781 insertions(+), 30 deletions(-) create mode 100644 .specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/approvals.json create mode 100644 .specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/change.md create mode 100644 .specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/context.md create mode 100644 .specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/deltas/aps-cli.md create mode 100644 .specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/deltas/state-store.md create mode 100644 .specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/design.md create mode 100644 .specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/docs.md create mode 100644 .specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/plan.md create mode 100644 .specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/requirements.md create mode 100644 .specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/state.json create mode 100644 .specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/tasks.md create mode 100644 .specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/testing.md create mode 100644 .specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/verification-attempts.json create mode 100644 .specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/verification.json create mode 100644 Sources/aps/TTY.swift diff --git a/.specsync/change-sequence.json b/.specsync/change-sequence.json index 085fce3..6e9ea27 100644 --- a/.specsync/change-sequence.json +++ b/.specsync/change-sequence.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "sequence": 21, - "id": "CHG-0021-error-contract-exit-code-taxonomy-and-json-error-envelope-issue-31-rebuilt-on", + "sequence": 22, + "id": "CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33", "acknowledged_collisions": [] } diff --git a/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/approvals.json b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/approvals.json new file mode 100644 index 0000000..ab21ce7 --- /dev/null +++ b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/approvals.json @@ -0,0 +1,19 @@ +{ + "approvals": [ + { + "gate": "definition", + "actor": "0xLeif", + "timestamp": 1784422877, + "digest": "bc65bd79c314567ae1552bc3e3e33b38bdfe280db7987e702ac123388b3e0e59", + "note": null + }, + { + "gate": "acceptance", + "actor": "0xLeif", + "timestamp": 1784422893, + "digest": "721d4fcfda3aa91fa911fd7b6104741430ba55347bdbfec0e4cc3b5115d77e0f", + "note": null + } + ], + "reopenings": [] +} diff --git a/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/change.md b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/change.md new file mode 100644 index 0000000..0ebc1e1 --- /dev/null +++ b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/change.md @@ -0,0 +1,25 @@ +--- +id: CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33 +state: accepted +type: feature +base_commit: c4ecd60b2a46bca4780f1b4af79a1d495d646287 +--- + +# TTY-aware output under the git porcelain rule (issue 33) + +## Intent + +TTY-aware output under the git porcelain rule (issue 33) + +## Affected Canonical Specs + +- `aps-cli` +- `state-store` + +## Acceptance Criteria + +- TTY output is human-pretty (aligned keys table with bold header, semantic color honoring NO_COLOR) while piped output stays byte-stable plain (TSV, no ANSI); JSON is pretty on TTY and compact when piped for dump and all --json payloads; watch --json aliases --jsonl; keys --quiet prints names only; completion scripts documented; machine shapes additive-only; tests and smoke assert all of it. + +## No-spec Rationale + +Not applicable diff --git a/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/context.md b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/context.md new file mode 100644 index 0000000..e3340b9 --- /dev/null +++ b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/context.md @@ -0,0 +1,23 @@ +--- +change: CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33 +artifact: context +--- + +# Context + +Issue #33 (design decision 5, minimal chrome from round 2). aps emitted +plain text and always-pretty JSON everywhere; humans got no polish and +piped agents paid multi-line token cost. The contract must be: human +output may be pretty, machine output is frozen. + +## Decisions + +- Git porcelain rule: human output may evolve; machine shapes are + additive-only contracts. +- Minimal chrome: aligned `keys` table, bold headers, semantic color + honoring NO_COLOR; richer styling deferred. +- JSON pretty on TTY, compact when piped (gh rule), applied via + `CLIOutput.encodeJSON` and `JSONCoding.encodeAuto` (dump keeps the + injected dependency). +- Flag symmetry: `watch --json` aliases `--jsonl`; `keys --quiet` prints + names only. diff --git a/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/deltas/aps-cli.md b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/deltas/aps-cli.md new file mode 100644 index 0000000..8642778 --- /dev/null +++ b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/deltas/aps-cli.md @@ -0,0 +1,65 @@ +# aps-cli TTY-aware output + +## MODIFIED + +### REQUIREMENT REQ-aps-cli-019 + +Human output SHALL be TTY-aware under the git porcelain rule: pretty for interactive humans, byte-stable plain text when piped. JSON SHALL be pretty on TTY and compact when piped. + +Acceptance Criteria +- Piped `keys` output is the TSV form with no ANSI escapes; TTY gets an aligned table with bold headers and semantic color honoring NO_COLOR. +- `dump` / `--json` payloads are single-line compact JSON off-TTY and pretty on TTY. +- `watch --json` behaves as `--jsonl`; `keys --quiet` prints key names only. +- Shell completion scripts (bash/zsh/fish) are documented in README. + +### SPEC SECTION Public API + +| Export | Description | +|--------|-------------| +| `DemoKey` | Fixed schema enum including `profile`, `secret`, and `profileName`. | +| `ProfileDocument` | Codable `{name, version}` FileState document. | +| `name` | ProfileDocument display name field. | +| `version` | ProfileDocument integer version field. | +| `init` | ProfileDocument memberwise initializer. | +| `APSKeychain` | Well-known Keychain service/account for `secret`. | +| `service` | Reverse-DNS Keychain feature namespace (`dev.leif.aps`). | +| `account` | Keychain account id (`secret`). | +| `secretAccount` | Full account key (`dev.leif.aps/secret`). | +| `APSError` | Typed CLI/domain errors. | +| `counter` | Int key stored in AppState `State`. | +| `message` | String key stored in AppState `State`. | +| `flag` | Bool key stored in AppState `StoredState`. | +| `note` | String key stored in AppState `FileState`. | +| `profile` | ProfileDocument key stored in AppState `FileState`. | +| `secret` | String key stored in AppState `SecureState` (Keychain). | +| `profileName` | String Slice over `ProfileDocument.name`. | +| `invalidValue` | Value could not parse for the key type. | +| `encodingFailed` | UTF-8 JSON encode failure. | +| `decodingFailed` | UTF-8 JSON decode failure. | +| `persistenceFailed` | Disk/Keychain-backed key did not persist after write. | +| `keychainUnavailable` | SecureState unavailable without Apple Security. | +| `corruptState` | FileState file exists but is undecodable (torn write). | +| `corruptStateExitCode` | Exit code 65 (`EX_DATAERR`) for `corruptState`. | +| `storage` | Human storage kind (`State` / `StoredState` / `FileState` / `SecureState` / `Slice`). | +| `valueType` | Human value type (`Int` / `String` / `Bool` / `ProfileDocument`). | +| `helpSummary` | Tab-separated key/type/storage columns for `keys`. | +| `detail` | One-line description for `keys`. | +| `description` | Actionable error text for humans and ValidationError bridging. | + +Command output modes (informational): human output is TTY-aware (aligned +`keys` table, bold headers, semantic color, NO_COLOR respected); piped output +is byte-stable plain text. JSON is pretty on TTY and compact when piped (gh +rule). `watch --json` is an alias for `--jsonl`; `keys --quiet` prints key +names only. Machine shapes are additive-only contracts; human text may evolve. + +### SPEC SECTION Invariants + +1. The CLI entry point runs on the real main thread so AppState + `notifyChange()` assertions hold on Linux and macOS. +2. stdout for `get` / `set` / `watch` / `reset ` is only the value line(s); + help and errors use ArgumentParser defaults. Piped output stays plain: + no ANSI styling and compact JSON off-TTY. +3. `State` keys are process-local; a new process must not be expected to retain + `counter` or `message`. +4. `watch` must flush each printed value immediately when stdout is not a TTY. +5. `keys` and `--help` do not mutate application state. diff --git a/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/deltas/state-store.md b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/deltas/state-store.md new file mode 100644 index 0000000..cd978a4 --- /dev/null +++ b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/deltas/state-store.md @@ -0,0 +1,39 @@ +# state-store TTY-aware dump + +## MODIFIED + +### SPEC SECTION Public API + +| Export | Description | +|--------|-------------| +| `StateStore` | MainActor facade over demo AppState keys. | +| `init` | Loads clock/jsonCoding/stats dependencies without forcing `~/.aps`. | +| `get` | Returns the current string rendering for a demo key. | +| `set` | Parses and writes a demo key value; records a stats mutation. | +| `reset` | Restores one demo key to its initial value; records a stats mutation. | +| `resetAll` | Restores every demo key. | +| `dump` | JSON snapshot with typed values (pretty on TTY, compact when piped). | +| `watchBlocking` | Observation + polling watch loop for demo keys. | +| `watchStatsBlocking` | Combine + polling watch loop for ObservedDependency stats. | +| `statsSnapshot` | Immutable view of DemoStats counters. | +| `resetStats` | Clears process-local DemoStats counters. | +| `profileDocument` | Typed profile FileState accessor. | +| `profileName` | Slice accessor for ProfileDocument.name. | +| `readNoteFromDisk` | Direct `note.json` read requiring a present decodable file. | +| `readNoteFromDiskIfPresent` | Optional `note.json` read; throws `corruptState` if torn. | +| `readProfileFromDisk` | Direct `profile.json` read requiring a present decodable file. | +| `readProfileFromDiskIfPresent` | Optional `profile.json` read; throws `corruptState` if torn. | +| `requireDecodableDiskState` | Loud-fail helper for CLI get/watch on FileState keys. | +| `parseBool` | Bool token parser for flag values. | +| `APSClock` | Injected clock dependency protocol. | +| `now` | APSClock current instant. | +| `SystemAPSClock` | Date-backed clock. | +| `JSONCoding` | Shared encode helpers for dump output. | +| `encodePretty` | Pretty JSON encode helper. | +| `encodeAuto` | TTY-aware JSON encode helper (pretty on TTY, compact when piped). | +| `DemoStats` | ObservableObject mutation-stats dependency. | +| `mutationCount` | Number of recorded set/reset mutations. | +| `lastMutatedKey` | Raw demo key of the latest mutation. | +| `recordMutation` | Increments counters for a demo key. | +| `reset` | Clears DemoStats counters. | +| `DemoStatsSnapshot` | Codable snapshot of DemoStats. | diff --git a/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/design.md b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/design.md new file mode 100644 index 0000000..913988f --- /dev/null +++ b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/design.md @@ -0,0 +1,18 @@ +--- +change: CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33 +artifact: design +--- + +# Design + +- New `Sources/aps/TTY.swift`: isatty checks, `colorEnabled` (TTY and no + NO_COLOR), `Style` (bold/red/green, identity when disabled), and a + column-aligning `table(header:rows:)`. +- `CLIOutput.encodeJSON` and `JSONCoding.encodeAuto`: sortedKeys plus + withoutEscapingSlashes, inserting prettyPrinted only on TTY. All `--json` + call sites and `dump` use them (dump keeps the injected dependency). +- `keys`: quiet -> names only; json -> payload; TTY -> aligned table; + else byte-stable TSV (unchanged). +- `watch`: effective jsonl = `--jsonl || --json`. +- Error paths are untouched; the error contract PR (#50) owns fail styling + when it lands. diff --git a/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/docs.md b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/docs.md new file mode 100644 index 0000000..48ca7f3 --- /dev/null +++ b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/docs.md @@ -0,0 +1,11 @@ +--- +change: CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33 +artifact: docs +--- + +# Docs + +README gains an "Output modes (human and agent)" section: the porcelain +rule, TTY behaviors (table, pretty JSON, NO_COLOR), piped behaviors +(TSV, compact JSON), the `--json` alias and `keys --quiet`, and the +`--generate-completion-script bash|zsh|fish` documentation. diff --git a/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/plan.md b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/plan.md new file mode 100644 index 0000000..13872b2 --- /dev/null +++ b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/plan.md @@ -0,0 +1,15 @@ +--- +change: CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33 +artifact: plan +--- + +# Plan + +1. Add TTY.swift (detection, Style, table). +2. Add encodeJSON / encodeAuto and switch all JSON call sites including + dump (via the injected JSONCoding). +3. keys: table on TTY, TSV when piped, --quiet. +4. watch: --json alias for --jsonl. +5. Tests (table alignment, style identity off-TTY, compact JSON) and + smoke (piped-plain assertions, alias, quiet, compact greps). +6. Specs (both modules) and README in the same change. diff --git a/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/requirements.md b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/requirements.md new file mode 100644 index 0000000..674a1ce --- /dev/null +++ b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/requirements.md @@ -0,0 +1,12 @@ +--- +change: CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33 +artifact: requirements +--- + +# Requirements + +- New REQ-aps-cli-019: TTY-aware output under the git porcelain rule + (TTY pretty, piped byte-stable plain; JSON pretty on TTY, compact when + piped; flag symmetry; completion docs). +- state-store spec: `dump` and `JSONCoding` documents gain the TTY-aware + `encodeAuto` behavior. diff --git a/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/state.json b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/state.json new file mode 100644 index 0000000..b2180ac --- /dev/null +++ b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/state.json @@ -0,0 +1,49 @@ +{ + "schema_version": 1, + "id": "CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33", + "slug": "tty-aware-output-under-the-git-porcelain-rule-issue-33", + "title": "TTY-aware output under the git porcelain rule (issue 33)", + "description": "TTY-aware output under the git porcelain rule (issue 33)", + "kind": "feature", + "state": "accepted", + "canonical_applied": true, + "base_commit": "c4ecd60b2a46bca4780f1b4af79a1d495d646287", + "created_at": 1784422790, + "updated_at": 1784422893, + "affected_specs": [ + "aps-cli", + "state-store" + ], + "affected_paths": [ + "Sources/aps/TTY.swift", + "Sources/aps/Aps.swift", + "Sources/aps/CLIOutput.swift", + "Sources/aps/Dependencies.swift", + "Sources/aps/StateStore.swift", + "Tests/apsTests/APSTests.swift", + "Scripts/smoke.sh", + "specs/aps-cli", + "specs/state-store", + "README.md", + ".specsync/change-sequence.json" + ], + "no_spec_change": false, + "no_spec_change_rationale": null, + "acceptance_criteria": [ + "TTY output is human-pretty (aligned keys table with bold header, semantic color honoring NO_COLOR) while piped output stays byte-stable plain (TSV, no ANSI); JSON is pretty on TTY and compact when piped for dump and all --json payloads; watch --json aliases --jsonl; keys --quiet prints names only; completion scripts documented; machine shapes additive-only; tests and smoke assert all of it." + ], + "selected_artifacts": [ + "context", + "requirements", + "plan", + "tasks", + "testing", + "docs", + "design" + ], + "dependencies": [], + "answers": { + "architecture_risk": "no", + "public_contract": "yes" + } +} diff --git a/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/tasks.md b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/tasks.md new file mode 100644 index 0000000..82e1da9 --- /dev/null +++ b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/tasks.md @@ -0,0 +1,12 @@ +--- +change: CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33 +artifact: tasks +--- + +# Tasks + +- [x] TTY.swift detection, Style, table +- [x] encodeJSON / encodeAuto + call-site switch (incl. dump) +- [x] keys table/TSV/--quiet, watch --json alias +- [x] Unit tests and smoke assertions +- [x] Specs (both modules) and README updates diff --git a/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/testing.md b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/testing.md new file mode 100644 index 0000000..58ea37e --- /dev/null +++ b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/testing.md @@ -0,0 +1,17 @@ +--- +change: CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33 +artifact: testing +--- + +# Testing + +## Requirement evidence + +| Requirement | Evidence | +| --- | --- | +| REQ-aps-cli-019 | `testTTYTableAlignsColumnsAndBoldsHeader`, `testStyleIsIdentityWhenColorDisabled`, `testEncodeJSONIsCompactOffTTY`, `testEncodeAutoIsCompactOffTTY`; smoke asserts TSV/no-ANSI keys, compact greps, watch --json alias, keys --quiet | + +## Suites + +- `swift test` +- `fledge lanes run verify` diff --git a/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/verification-attempts.json b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/verification-attempts.json new file mode 100644 index 0000000..29df4ec --- /dev/null +++ b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/verification-attempts.json @@ -0,0 +1,22 @@ +{ + "schema_version": 1, + "attempts": [ + { + "timestamp": 1784422888, + "commit": "c4ecd60b2a46bca4780f1b4af79a1d495d646287", + "contract_digest": "bc65bd79c314567ae1552bc3e3e33b38bdfe280db7987e702ac123388b3e0e59", + "workspace_digest": "8009885f006b6accb7ae1ca879bb51e79d5ebe39290dcfb55f1418e2eae03995", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-019" + ] + } + ] +} diff --git a/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/verification.json b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/verification.json new file mode 100644 index 0000000..d89a80c --- /dev/null +++ b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/verification.json @@ -0,0 +1,233 @@ +{ + "timestamp": 1784422888, + "commit": "c4ecd60b2a46bca4780f1b4af79a1d495d646287", + "contract_digest": "bc65bd79c314567ae1552bc3e3e33b38bdfe280db7987e702ac123388b3e0e59", + "workspace_digest": "8009885f006b6accb7ae1ca879bb51e79d5ebe39290dcfb55f1418e2eae03995", + "acceptance_input_digest": "d9f2e380fb092c289727ec18fcbcc51e790996f5001a9c982c12a6da77a27f90", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "128527126d1e45d9a9059116efbc5e72af8648ab276f0b39ad4e5cd2edc79bfc", + "entry_digest": "5db6425af381d01086c6b54d0dc7ce8ae0f1877d6a8074e5d4e2a90ebe33ed26", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "README.md", + "kind": "file", + "mode": 33188, + "payload_digest": "4755aca7a8e78d4d4282e3e7508751f9f34ea23457c7166a31f02cbe29a61031", + "entry_digest": "f575f797154ec5bc691843808190778252092e451eb818bd1cc210e8df4f81c3", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Scripts/smoke.sh", + "kind": "file", + "mode": 33261, + "payload_digest": "a07487700ba9d3765aa46279c616d6abecaad398d7c2fd12881ffe85a0c5a86c", + "entry_digest": "89c04f4fcaae18c3afa1bd9c24a288660c19502efa9987f750a1d6a5bc2d91e9", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Sources/aps/Aps.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "2c3b944ee950034273463fb1b7deda0b438f36bcc521540a744c3deeea6b7057", + "entry_digest": "527db309bdaf08a1325df856480014dd5101354f96d6eaca8fde40fddfbc177f", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/CLIOutput.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "7649af59f734429ef236336f3510ab300e70cfc427adf27374507eecdf8f4879", + "entry_digest": "25e9019dcc87f5d5544a64b9601c92c77865aaa858d7de5790ccaa10796f9db1", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/Dependencies.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "3851abdfda1f8979f7d1580636eeeab33698f59b4fff8fb7b1a85e01ee617e04", + "entry_digest": "0fd4b9ab468b05c084fb573556020f919162494eee8966f7b7a51a1ae2ed7b97", + "owners": [ + "state-store" + ] + }, + { + "path": "Sources/aps/StateStore.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "85ba3422497163a8392bc125c0c7fb034b950fd83c9173c566c792c43cc2e442", + "entry_digest": "650ab5d13b8ca493240c9cf3873192f609c6ed0dac720a3a952181d738b1a877", + "owners": [ + "state-store" + ] + }, + { + "path": "Sources/aps/TTY.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "c27fdaf2de4ced4dd7e3523b14fd017df07676159cb73f186b4cecf783276d29", + "entry_digest": "686ac123a2a7a341e96fc01165b64d1dab2afbbb06c0dab7dda531612737de2b", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Tests/apsTests/APSTests.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "97ac4d583861b7663d4a3ee8bb88393b43f902dd176e6e59494cb6ae9886052c", + "entry_digest": "2bbd2da2e83455651f197eddab3ccee9ee69833762292a4dd54a053cdb6b96ea", + "owners": [ + "@exact:test" + ] + }, + { + "path": "specs/aps-cli", + "kind": "non_file", + "mode": 0, + "payload_digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "entry_digest": "6f7c83bad496153e7b31ed762f67bd1c75394999419522d3535142c1e5746b58", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "specs/aps-cli/aps-cli.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "cb54d44484899673e4f1ec9b156d72f665a62edc149eef4d25cedba16d485878", + "entry_digest": "03b6dbc20f0ed69fd028d73e4c93524e5b865619eff3565e00adcb1cd6d5de62", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "7304a6233da2051242aeac63a51197aaf6f92d936692fe8655801eff842a2fd0", + "entry_digest": "e78ad0996c0135c37d41afa37c5fda13901bb63e14f00c3208896f7b104a72e8", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "2c923807fb5aade6240910be130b775462096cb8f3ce438fbc366f58598c3191", + "entry_digest": "18ae2ec6b455ce77971b2b8bccefdbb213cc15eae745d83c1a16d729b24a6c8a", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ca6cafd22fb3c803eed3dc94cb980d8f535d51e0fd289b46bae82b7f255377de", + "entry_digest": "c349b8f073cefe7e387f8fabdd4dada3830be59b4b6d22a4ce79b2c29399c70e", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ee5ae7f63536e2089a2b51887ed62b8392561052cdaa37c29707e7a10e2136bd", + "entry_digest": "daf1e496e836c46da31eb6ac679b352d7bf615536595be310953eb0ae9a36fd4", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/state-store", + "kind": "non_file", + "mode": 0, + "payload_digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "entry_digest": "50cce5fd7b7eb99ea3853de377333e885669a427362ee54ce3a0645c83c2f060", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "specs/state-store/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "12d848cab584c8a64f7da8bee796b581ec3b1b5efc72448299330dd493f39aa7", + "entry_digest": "76b734c278c05e3e74ff8f41dbb07aac3dcc7b1fbda64adcf32691e31365317c", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "6d94234b7a6b7d4dab2b432a46d1d59449941bd031b7e6e841a13d7830305ef1", + "entry_digest": "42a35cb8eed79a217a0016bc9ab3d9a5cc41b4c20f6f950e66edc40c21ac6d72", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/state-store.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "d27b0ef2da49ec9bc9fd0511a2e4b52d59c6ca48989346ee02bccc396ca9ff2e", + "entry_digest": "57cd000f546caaed34e32f52e5912ac1fd281971a75f12686c24fd38e9813509", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "0f02b782887467b358568bf22c2d68f39c40a00d56143bd6c64abdf8198cf433", + "entry_digest": "8f828f3625d4f24c3c71852bd28f76aac97cd31ace2a18d1a901a3d34642de8d", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "63705355034895d0dfab654892140beecd317afb08530917c8cf4b308824ab63", + "entry_digest": "88818028e56f72fbfd2bdf24012faf448fc049911bf77a2ecb1f32ba1c3e6389", + "owners": [ + "state-store" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-019" + ] +} diff --git a/README.md b/README.md index b94b456..ea0a4a6 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,15 @@ swift run aps stats --json `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). + ### 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. diff --git a/Scripts/smoke.sh b/Scripts/smoke.sh index b6a796b..ea4e5d6 100755 --- a/Scripts/smoke.sh +++ b/Scripts/smoke.sh @@ -19,25 +19,25 @@ test "$("$bin" --version)" = "0.2.0" "$bin" keys | grep -q counter "$bin" keys | grep -q profile "$bin" keys | grep -q secret -"$bin" keys --json | grep -q '"key" : "profile"' -"$bin" keys --json | grep -q '"key" : "secret"' +"$bin" keys --json | grep -q '"key":"profile"' +"$bin" keys --json | grep -q '"key":"secret"' # `set` prints the value; State is process-local so don't expect get in a new process. test "$("$bin" set counter 11)" = "11" test "$("$bin" set message "smoke")" = "smoke" -"$bin" set counter 11 --json | grep -q '"value" : 11' +"$bin" set counter 11 --json | grep -q '"value":11' # StoredState / FileState must survive process boundaries. "$bin" set flag true >/dev/null test "$("$bin" get flag)" = "true" -"$bin" get flag --json | grep -q '"value" : true' +"$bin" get flag --json | grep -q '"value":true' "$bin" set note "smoke-note" >/dev/null test "$("$bin" get note)" = "smoke-note" "$bin" set profile '{"name":"smoke","version":2}' >/dev/null -"$bin" get profile --json | grep -q '"name" : "smoke"' -"$bin" get profile --json | grep -q '"version" : 2' +"$bin" get profile --json | grep -q '"name":"smoke"' +"$bin" get profile --json | grep -q '"version":2' # SecureState / Keychain smoke temporarily disabled (Keychain prompts / hangs). # Re-enable with: APS_SMOKE_SECURESTATE=1 @@ -45,7 +45,7 @@ if [[ "${APS_SMOKE_SECURESTATE:-}" == "1" ]]; then if [[ "$(uname -s)" == "Darwin" ]]; then "$bin" set secret "smoke-secret" >/dev/null test "$("$bin" get secret)" = "smoke-secret" - "$bin" get secret --json | grep -q '"storage" : "SecureState"' + "$bin" get secret --json | grep -q '"storage":"SecureState"' "$bin" reset secret >/dev/null test -z "$("$bin" get secret)" else @@ -62,9 +62,9 @@ OTHER="$(mktemp -d "${TMPDIR:-/tmp}/aps-smoke-other.XXXXXX")" test "$("$bin" get note --state-dir "$OTHER")" = "other-root" test "$("$bin" get note)" = "smoke-note" -"$bin" dump | grep -q '"key" : "flag"' -"$bin" dump --json | grep -q '"key" : "profile"' -"$bin" dump --json | grep -q '"key" : "secret"' +"$bin" dump | grep -q '"key":"flag"' +"$bin" dump --json | grep -q '"key":"profile"' +"$bin" dump --json | grep -q '"key":"secret"' "$bin" reset flag >/dev/null test "$("$bin" get flag)" = "false" @@ -72,7 +72,7 @@ test "$("$bin" get flag)" = "false" "$bin" reset note >/dev/null test -z "$("$bin" get note)" -"$bin" reset profile --json | grep -q '"reset" : "key"' +"$bin" reset profile --json | grep -q '"reset":"key"' "$bin" reset --all >/dev/null test "$("$bin" get flag)" = "false" @@ -82,7 +82,7 @@ test -z "$("$bin" get note)" "$bin" watch counter --count 1 --timeout 2 >/dev/null # ObservedDependency stats command (process-local; fresh process starts at 0). -"$bin" stats --json | grep -q '"mutationCount" : 0' +"$bin" stats --json | grep -q '"mutationCount":0' "$bin" stats --watch --count 1 --timeout 2 >/dev/null # Invalid values should fail clearly. @@ -117,4 +117,17 @@ rm -f "$BADROOT" err="$(APS_ERROR_JSON=1 "$bin" set flag maybe 2>&1 >/dev/null || true)" echo "$err" | grep -q '"code":"invalid_value"' +# TTY contract: piped output stays plain (TSV, no ANSI), --quiet prints names only. +"$bin" keys | grep -q $'KEY\tTYPE' +if "$bin" keys | grep -q $'\x1b'; then + echo "piped keys output must not contain ANSI escapes" >&2 + exit 1 +fi +test "$("$bin" keys --quiet | head -1)" = "counter" +test "$("$bin" keys --quiet | wc -l | tr -d ' ')" = "7" + +# JSON is compact when piped (gh rule); --json accepted as --jsonl alias on watch. +"$bin" dump | grep -q '"key":"flag"' +"$bin" watch counter --count 1 --timeout 2 --json >/dev/null + echo "smoke ok" diff --git a/Sources/aps/Aps.swift b/Sources/aps/Aps.swift index 5cca6c8..588dee9 100644 --- a/Sources/aps/Aps.swift +++ b/Sources/aps/Aps.swift @@ -63,7 +63,7 @@ extension Aps { storage: key.storage, value: try CLIOutput.typedValue(for: key, store: store) ) - print(try CLIOutput.encodePretty(payload)) + print(try CLIOutput.encodeJSON(payload)) } else { print(store.get(key)) } @@ -101,7 +101,7 @@ extension Aps { storage: key.storage, value: try CLIOutput.typedValue(for: key, store: store) ) - print(try CLIOutput.encodePretty(payload)) + print(try CLIOutput.encodeJSON(payload)) } else { print(store.get(key)) } @@ -129,6 +129,9 @@ extension Aps { @Flag(name: .long, help: "Emit one JSON object per line.") var jsonl: Bool = false + @Flag(name: .long, help: "Alias for --jsonl (flag symmetry with other commands).") + var json: Bool = false + @Option(name: .long, help: "Override state directory (takes precedence over APS_HOME).") var stateDir: String? @@ -136,6 +139,7 @@ extension Aps { try onMainThread { boot(stateDir: stateDir) let store = StateStore() + let jsonl = jsonl || json let deadline = timeout.map { Date().addingTimeInterval($0) } var emitted = 0 @@ -215,8 +219,15 @@ extension Aps { @Flag(name: .long, help: "Emit machine-readable JSON.") var json: Bool = false + @Flag(name: .long, help: "Print only key names, one per line.") + var quiet: Bool = false + func run() throws { - if json { + if quiet { + for key in DemoKey.allCases { + print(key.rawValue) + } + } else if json { let payload = CLIOutput.KeysPayload( keys: DemoKey.allCases.map { CLIOutput.KeyInfo( @@ -227,7 +238,15 @@ extension Aps { ) } ) - print(try CLIOutput.encodePretty(payload)) + print(try CLIOutput.encodeJSON(payload)) + } else if TTY.stdoutIsTTY { + // Human table; piped output below stays byte-stable TSV. + print(TTY.table( + header: ["KEY", "TYPE", "STORAGE", "DESCRIPTION"], + rows: DemoKey.allCases.map { + [$0.rawValue, $0.valueType, $0.storage, $0.detail] + } + )) } else { print("KEY\tTYPE\tSTORAGE\tDESCRIPTION") for key in DemoKey.allCases { @@ -286,7 +305,7 @@ extension Aps { private static func printSnapshot(_ snapshot: DemoStatsSnapshot, json: Bool, pretty: Bool) { if json { let payload = CLIOutput.StatsPayload(snapshot: snapshot) - if pretty, let text = try? CLIOutput.encodePretty(payload) { + if let text = try? CLIOutput.encodeJSON(payload), pretty { print(text) } else if let line = try? CLIOutput.encodeLine(payload) { CLIOutput.writeLine(line) @@ -328,7 +347,7 @@ extension Aps { store.resetAll() if options.json { let payload = CLIOutput.ResetPayload(reset: "all", key: nil, value: nil) - print(try CLIOutput.encodePretty(payload)) + print(try CLIOutput.encodeJSON(payload)) } else { print("reset all keys") } @@ -340,7 +359,7 @@ extension Aps { key: key.rawValue, value: try CLIOutput.typedValue(for: key, store: store) ) - print(try CLIOutput.encodePretty(payload)) + print(try CLIOutput.encodeJSON(payload)) } else { print(store.get(key)) } diff --git a/Sources/aps/CLIOutput.swift b/Sources/aps/CLIOutput.swift index df2e3e6..8fe7b39 100644 --- a/Sources/aps/CLIOutput.swift +++ b/Sources/aps/CLIOutput.swift @@ -97,6 +97,23 @@ enum CLIOutput { return string } + /// TTY-aware JSON (gh rule): pretty when interactive, compact when piped. + /// Machine consumers always get valid JSON either way; only whitespace adapts. + static func encodeJSON(_ value: T) throws -> String { + let encoder = JSONEncoder() + var formatting: JSONEncoder.OutputFormatting = [.sortedKeys, .withoutEscapingSlashes] + if TTY.stdoutIsTTY { + formatting.insert(.prettyPrinted) + } + encoder.outputFormatting = formatting + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(value) + guard let string = String(data: data, encoding: .utf8) else { + throw APSError.encodingFailed + } + return string + } + static func encodeLine(_ value: T) throws -> String { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] diff --git a/Sources/aps/Dependencies.swift b/Sources/aps/Dependencies.swift index 69a500f..2115092 100644 --- a/Sources/aps/Dependencies.swift +++ b/Sources/aps/Dependencies.swift @@ -29,6 +29,22 @@ public struct JSONCoding: Sendable { } return string } + + /// TTY-aware JSON (gh rule): pretty when interactive, compact when piped. + public func encodeAuto(_ value: T) throws -> String { + let encoder = JSONEncoder() + var formatting: JSONEncoder.OutputFormatting = [.sortedKeys, .withoutEscapingSlashes] + if TTY.stdoutIsTTY { + formatting.insert(.prettyPrinted) + } + encoder.outputFormatting = formatting + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(value) + guard let string = String(data: data, encoding: .utf8) else { + throw APSError.encodingFailed + } + return string + } } /// Process-local mutation counters dogfooded through `@ObservedDependency`. diff --git a/Sources/aps/StateStore.swift b/Sources/aps/StateStore.swift index 6bb7cb4..18936d5 100644 --- a/Sources/aps/StateStore.swift +++ b/Sources/aps/StateStore.swift @@ -195,7 +195,7 @@ public final class StateStore { ) } ) - return try jsonCoding.encodePretty(snapshot) + return try jsonCoding.encodeAuto(snapshot) } /// Blocking watch over the `@ObservedDependency` stats service. diff --git a/Sources/aps/TTY.swift b/Sources/aps/TTY.swift new file mode 100644 index 0000000..0ba54b5 --- /dev/null +++ b/Sources/aps/TTY.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Minimal TTY awareness (issue #33, git porcelain rule): pretty for humans +/// when interactive, frozen plain output when piped. Machine shapes never +/// carry ANSI; human styling is additive only. +enum TTY { + /// True when stdout is a terminal (not a pipe or file). + static var stdoutIsTTY: Bool { + isatty(FileHandle.standardOutput.fileDescriptor) == 1 + } + + /// True when stderr is a terminal. + static var stderrIsTTY: Bool { + isatty(FileHandle.standardError.fileDescriptor) == 1 + } + + /// Color is emitted only on a TTY and never when NO_COLOR is set (any value). + static var colorEnabled: Bool { + stdoutIsTTY && ProcessInfo.processInfo.environment["NO_COLOR"] == nil + } + + enum Style { + static func bold(_ text: String) -> String { apply("\u{1B}[1m", to: text) } + static func red(_ text: String) -> String { apply("\u{1B}[31m", to: text) } + static func green(_ text: String) -> String { apply("\u{1B}[32m", to: text) } + + private static func apply(_ escape: String, to text: String) -> String { + guard TTY.colorEnabled else { return text } + return escape + text + "\u{1B}[0m" + } + } + + /// Aligned columns for human tables (e.g. `keys` on a TTY). + /// Pads every column except the last to the widest row plus two spaces. + static func table(header: [String], rows: [[String]]) -> String { + let all = [header] + rows + let widths = header.indices.map { column in + all.map { $0.count > column ? $0[column].count : 0 }.max() ?? 0 + } + func line(_ cells: [String]) -> String { + cells.enumerated().map { index, cell in + index == cells.count - 1 + ? cell + : cell.padding(toLength: widths[index] + 2, withPad: " ", startingAt: 0) + }.joined() + } + let lines = [Style.bold(line(header))] + rows.map { line($0) } + return lines.joined(separator: "\n") + } +} diff --git a/Tests/apsTests/APSTests.swift b/Tests/apsTests/APSTests.swift index 5bc0543..10346bc 100644 --- a/Tests/apsTests/APSTests.swift +++ b/Tests/apsTests/APSTests.swift @@ -260,11 +260,11 @@ final class APSTests: XCTestCase { try store.set(.profile, value: #"{"name":"x","version":1}"#) let json = try store.dump() - XCTAssertTrue(json.contains("\"key\" : \"counter\"")) - XCTAssertTrue(json.contains("\"value\" : 3")) - XCTAssertTrue(json.contains("\"key\" : \"message\"")) - XCTAssertTrue(json.contains("\"key\" : \"profile\"")) - XCTAssertTrue(json.contains("\"storage\" : \"FileState\"")) + XCTAssertTrue(json.contains("\"key\":\"counter\"")) + XCTAssertTrue(json.contains("\"value\":3")) + XCTAssertTrue(json.contains("\"key\":\"message\"")) + XCTAssertTrue(json.contains("\"key\":\"profile\"")) + XCTAssertTrue(json.contains("\"storage\":\"FileState\"")) XCTAssertTrue(json.contains("timestamp")) } @@ -753,4 +753,48 @@ final class APSTests: XCTestCase { XCTAssertEqual((error as? APSError)?.exitCode, 65) } } + + func testTTYTableAlignsColumnsAndBoldsHeader() { + let table = TTY.table( + header: ["KEY", "TYPE", "STORAGE"], + rows: [ + ["counter", "Int", "State"], + ["profileName", "String", "Slice"], + ] + ) + let lines = table.split(separator: "\n").map(String.init) + XCTAssertEqual(lines.count, 3) + // Column 2 starts at the same offset on every line (widest KEY + 2). + let columnTwoOffset = max("KEY".count, "counter".count, "profileName".count) + 2 + let expected = [ + "TYPE", + "Int", + "String", + ] + for (line, word) in zip(lines, expected) { + let index = line.range(of: word)?.lowerBound + XCTAssertEqual(index.map { line.distance(from: line.startIndex, to: $0) }, columnTwoOffset) + } + // Piped test environment: no ANSI escapes are emitted. + XCTAssertFalse(table.contains("\u{1B}"), "no ANSI when color is disabled") + } + + func testStyleIsIdentityWhenColorDisabled() { + XCTAssertEqual(TTY.Style.red("err"), "err") + XCTAssertEqual(TTY.Style.bold("h"), "h") + } + + func testEncodeJSONIsCompactOffTTY() throws { + let payload = CLIOutput.KeyInfo(key: "counter", type: "Int", storage: "State", detail: "d") + let json = try CLIOutput.encodeJSON(payload) + XCTAssertFalse(json.contains("\n"), "piped JSON must be compact single-line") + XCTAssertTrue(json.contains("\"key\":\"counter\"")) + } + + func testEncodeAutoIsCompactOffTTY() throws { + let coding = JSONCoding() + let json = try coding.encodeAuto(["ok": true]) + XCTAssertFalse(json.contains("\n")) + XCTAssertTrue(json.contains("\"ok\":true")) + } } diff --git a/specs/aps-cli/aps-cli.spec.md b/specs/aps-cli/aps-cli.spec.md index 256912e..511c0b7 100644 --- a/specs/aps-cli/aps-cli.spec.md +++ b/specs/aps-cli/aps-cli.spec.md @@ -1,11 +1,12 @@ --- module: aps-cli -version: 20 +version: 21 status: active files: - Sources/aps/Aps.swift - Sources/aps/CLIOutput.swift - Sources/aps/DemoKey.swift + - Sources/aps/TTY.swift db_tables: [] depends_on: - state-store @@ -56,6 +57,12 @@ agents can get, set, watch, dump, list, and reset typed application state. | `exitCode` | sysexits-aligned process exit code. | | `hint` | Actionable next step in the error envelope. | +Command output modes (informational): human output is TTY-aware (aligned +`keys` table, bold headers, semantic color, NO_COLOR respected); piped output +is byte-stable plain text. JSON is pretty on TTY and compact when piped (gh +rule). `watch --json` is an alias for `--jsonl`; `keys --quiet` prints key +names only. Machine shapes are additive-only contracts; human text may evolve. + ## Invariants 1. The CLI entry point runs on the real main thread so AppState @@ -63,6 +70,7 @@ agents can get, set, watch, dump, list, and reset typed application state. 2. stdout for `get` / `set` / `watch` / `reset ` is only the value line(s); stdout stays empty on error; help uses ArgumentParser defaults and domain errors use the Error Cases contract (human line plus optional JSON envelope). + Piped output stays plain: no ANSI styling and compact JSON off-TTY. 3. `State` keys are process-local; a new process must not be expected to retain `counter` or `message`. 4. `watch` must flush each printed value immediately when stdout is not a TTY. @@ -151,3 +159,4 @@ Exit codes (sysexits-aligned): | 2026-07-18 | CHG-0020-prove-swift-test-on-windows-latest-and-portable-aps-home-env-tests-for-issue-46: Prove swift test on windows-latest and portable APS_HOME env tests for issue 46 | | 2026-07-18 | CHG-0021-error-contract-exit-code-taxonomy-and-json-error-envelope-issue-31-rebuilt-on: Error contract: exit-code taxonomy and JSON error envelope (issue 31, rebuilt on corruptState main) | | 2026-07-19 | CHG-0021-error-contract-exit-code-taxonomy-and-json-error-envelope-issue-31-rebuilt-on: Error contract: exit-code taxonomy and JSON error envelope (issue 31, rebuilt on corruptState main) | +| 2026-07-19 | CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33: TTY-aware output under the git porcelain rule (issue 33) | diff --git a/specs/aps-cli/requirements.md b/specs/aps-cli/requirements.md index 46604e8..30ac845 100644 --- a/specs/aps-cli/requirements.md +++ b/specs/aps-cli/requirements.md @@ -135,3 +135,14 @@ Acceptance Criteria - `APS_HOME` resolution tests mutate the process environment with a portable helper (not POSIX-only `setenv`). - `specs/aps-cli/testing.md` and README document the Windows test + smoke path. + +### REQ-aps-cli-019 + +Human output SHALL be TTY-aware under the git porcelain rule: pretty for interactive humans, byte-stable plain text when piped. JSON SHALL be pretty on TTY and compact when piped. + +Acceptance Criteria +- Piped `keys` output is the TSV form with no ANSI escapes; TTY gets an aligned table with bold headers and semantic color honoring NO_COLOR. +- `dump` / `--json` payloads are single-line compact JSON off-TTY and pretty on TTY. +- `watch --json` behaves as `--jsonl`; `keys --quiet` prints key names only. +- Shell completion scripts (bash/zsh/fish) are documented in README. + diff --git a/specs/state-store/state-store.spec.md b/specs/state-store/state-store.spec.md index ae4e0ce..89cfd1a 100644 --- a/specs/state-store/state-store.spec.md +++ b/specs/state-store/state-store.spec.md @@ -1,6 +1,6 @@ --- module: state-store -version: 17 +version: 19 status: active files: - Sources/aps/StateStore.swift @@ -29,7 +29,7 @@ non-UI use. | `set` | Parses and writes a demo key value; records a stats mutation. | | `reset` | Restores one demo key to its initial value; records a stats mutation. | | `resetAll` | Restores every demo key. | -| `dump` | Pretty JSON snapshot with typed values. | +| `dump` | JSON snapshot with typed values (pretty on TTY, compact when piped). | | `watchBlocking` | Observation + polling watch loop for demo keys. | | `watchStatsBlocking` | Combine + polling watch loop for ObservedDependency stats. | | `statsSnapshot` | Immutable view of DemoStats counters. | @@ -47,6 +47,7 @@ non-UI use. | `SystemAPSClock` | Date-backed clock. | | `JSONCoding` | Shared encode helpers for dump output. | | `encodePretty` | Pretty JSON encode helper. | +| `encodeAuto` | TTY-aware JSON encode helper (pretty on TTY, compact when piped). | | `DemoStats` | ObservableObject mutation-stats dependency. | | `mutationCount` | Number of recorded set/reset mutations. | | `lastMutatedKey` | Raw demo key of the latest mutation. | @@ -123,3 +124,5 @@ Then keys include message with value "hi" and a timestamp field exists. | 2026-07-18 | CHG-0015-remove-unreachable-apserror-unknownkey-and-jsoncoding-decode-for-issue-15: Remove unreachable APSError.unknownKey and JSONCoding.decode for issue 15 | | 2026-07-18 | CHG-0016-loud-torn-filestate-reads-and-document-multi-writer-semantics-for-issue-38: Loud torn FileState reads + multi-writer docs | | 2026-07-18 | CHG-0016-loud-torn-filestate-reads-and-document-multi-writer-semantics-for-issue-38: Loud torn FileState reads and document multi-writer semantics for issue 38 | +| 2026-07-18 | CHG-0022-tty-aware-output-git-porcelain-rule-for-issue-33: TTY-aware output under the git porcelain rule for issue 33 | +| 2026-07-19 | CHG-0021-tty-aware-output-under-the-git-porcelain-rule-issue-33: TTY-aware output under the git porcelain rule (issue 33) | From c00f731004ae761f3b0381e89a0e2c86ca2733b4 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Sat, 18 Jul 2026 20:53:14 -0600 Subject: [PATCH 2/3] Fix windows-smoke: gate TTY detection for Windows FileHandle.fileDescriptor is unavailable on Windows; use MSVCRT _isatty/_fileno via ucrt there. --- .../verification-attempts.json | 24 ++ .../approvals.json | 261 +++++++++++++++++- .../state.json | 2 +- .../verification-attempts.json | 17 ++ .../verification.json | 12 +- Sources/aps/TTY.swift | 18 +- 6 files changed, 324 insertions(+), 10 deletions(-) diff --git a/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json index e300921..8405a48 100644 --- a/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json +++ b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json @@ -576,6 +576,30 @@ "REQ-state-store-010", "REQ-state-store-011" ] + }, + { + "timestamp": 1784429477, + "commit": "69a4f6ea4ddb2cfbdefd87246e96b53e01a85dfa", + "contract_digest": "73c000607b3050c54981b4c29532280dd08bf00b7a15c8ec1801e045fa5496c9", + "workspace_digest": "6da61180d0d4ca0fb27fba39a165056ee137ff239c75a7a6b03d88e6fb105c5d", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-001", + "REQ-aps-cli-010", + "REQ-aps-cli-011", + "REQ-aps-cli-012", + "REQ-aps-cli-013", + "REQ-state-store-001", + "REQ-state-store-010", + "REQ-state-store-011" + ] } ] } diff --git a/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/approvals.json b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/approvals.json index ab21ce7..ec03a3f 100644 --- a/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/approvals.json +++ b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/approvals.json @@ -13,7 +13,266 @@ "timestamp": 1784422893, "digest": "721d4fcfda3aa91fa911fd7b6104741430ba55347bdbfec0e4cc3b5115d77e0f", "note": null + }, + { + "gate": "acceptance", + "actor": "0xLeif", + "timestamp": 1784429522, + "digest": "276e2b56b0d4afd150f1b3b9377753f5d2bfb2ba6017922d252c9ac264fd93e5", + "note": null } ], - "reopenings": [] + "reopenings": [ + { + "schema_version": 1, + "change_id": "CHG-0021-tty-aware-output-under-the-git-porcelain-rule-issue-33", + "actor": "0xLeif", + "reason": "TTY.swift Windows gate fix; rebinding evidence", + "timestamp": 1784429502, + "from_state": "accepted", + "to_state": "verifying", + "superseded_approval": { + "gate": "acceptance", + "actor": "0xLeif", + "timestamp": 1784422893, + "digest": "721d4fcfda3aa91fa911fd7b6104741430ba55347bdbfec0e4cc3b5115d77e0f", + "note": null + }, + "prior_verification": { + "timestamp": 1784422888, + "commit": "c4ecd60b2a46bca4780f1b4af79a1d495d646287", + "contract_digest": "bc65bd79c314567ae1552bc3e3e33b38bdfe280db7987e702ac123388b3e0e59", + "workspace_digest": "8009885f006b6accb7ae1ca879bb51e79d5ebe39290dcfb55f1418e2eae03995", + "acceptance_input_digest": "d9f2e380fb092c289727ec18fcbcc51e790996f5001a9c982c12a6da77a27f90", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "128527126d1e45d9a9059116efbc5e72af8648ab276f0b39ad4e5cd2edc79bfc", + "entry_digest": "5db6425af381d01086c6b54d0dc7ce8ae0f1877d6a8074e5d4e2a90ebe33ed26", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "README.md", + "kind": "file", + "mode": 33188, + "payload_digest": "4755aca7a8e78d4d4282e3e7508751f9f34ea23457c7166a31f02cbe29a61031", + "entry_digest": "f575f797154ec5bc691843808190778252092e451eb818bd1cc210e8df4f81c3", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Scripts/smoke.sh", + "kind": "file", + "mode": 33261, + "payload_digest": "a07487700ba9d3765aa46279c616d6abecaad398d7c2fd12881ffe85a0c5a86c", + "entry_digest": "89c04f4fcaae18c3afa1bd9c24a288660c19502efa9987f750a1d6a5bc2d91e9", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Sources/aps/Aps.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "2c3b944ee950034273463fb1b7deda0b438f36bcc521540a744c3deeea6b7057", + "entry_digest": "527db309bdaf08a1325df856480014dd5101354f96d6eaca8fde40fddfbc177f", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/CLIOutput.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "7649af59f734429ef236336f3510ab300e70cfc427adf27374507eecdf8f4879", + "entry_digest": "25e9019dcc87f5d5544a64b9601c92c77865aaa858d7de5790ccaa10796f9db1", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/Dependencies.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "3851abdfda1f8979f7d1580636eeeab33698f59b4fff8fb7b1a85e01ee617e04", + "entry_digest": "0fd4b9ab468b05c084fb573556020f919162494eee8966f7b7a51a1ae2ed7b97", + "owners": [ + "state-store" + ] + }, + { + "path": "Sources/aps/StateStore.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "85ba3422497163a8392bc125c0c7fb034b950fd83c9173c566c792c43cc2e442", + "entry_digest": "650ab5d13b8ca493240c9cf3873192f609c6ed0dac720a3a952181d738b1a877", + "owners": [ + "state-store" + ] + }, + { + "path": "Sources/aps/TTY.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "c27fdaf2de4ced4dd7e3523b14fd017df07676159cb73f186b4cecf783276d29", + "entry_digest": "686ac123a2a7a341e96fc01165b64d1dab2afbbb06c0dab7dda531612737de2b", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Tests/apsTests/APSTests.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "97ac4d583861b7663d4a3ee8bb88393b43f902dd176e6e59494cb6ae9886052c", + "entry_digest": "2bbd2da2e83455651f197eddab3ccee9ee69833762292a4dd54a053cdb6b96ea", + "owners": [ + "@exact:test" + ] + }, + { + "path": "specs/aps-cli", + "kind": "non_file", + "mode": 0, + "payload_digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "entry_digest": "6f7c83bad496153e7b31ed762f67bd1c75394999419522d3535142c1e5746b58", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "specs/aps-cli/aps-cli.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "cb54d44484899673e4f1ec9b156d72f665a62edc149eef4d25cedba16d485878", + "entry_digest": "03b6dbc20f0ed69fd028d73e4c93524e5b865619eff3565e00adcb1cd6d5de62", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "7304a6233da2051242aeac63a51197aaf6f92d936692fe8655801eff842a2fd0", + "entry_digest": "e78ad0996c0135c37d41afa37c5fda13901bb63e14f00c3208896f7b104a72e8", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "2c923807fb5aade6240910be130b775462096cb8f3ce438fbc366f58598c3191", + "entry_digest": "18ae2ec6b455ce77971b2b8bccefdbb213cc15eae745d83c1a16d729b24a6c8a", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ca6cafd22fb3c803eed3dc94cb980d8f535d51e0fd289b46bae82b7f255377de", + "entry_digest": "c349b8f073cefe7e387f8fabdd4dada3830be59b4b6d22a4ce79b2c29399c70e", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ee5ae7f63536e2089a2b51887ed62b8392561052cdaa37c29707e7a10e2136bd", + "entry_digest": "daf1e496e836c46da31eb6ac679b352d7bf615536595be310953eb0ae9a36fd4", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/state-store", + "kind": "non_file", + "mode": 0, + "payload_digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "entry_digest": "50cce5fd7b7eb99ea3853de377333e885669a427362ee54ce3a0645c83c2f060", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "specs/state-store/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "12d848cab584c8a64f7da8bee796b581ec3b1b5efc72448299330dd493f39aa7", + "entry_digest": "76b734c278c05e3e74ff8f41dbb07aac3dcc7b1fbda64adcf32691e31365317c", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "6d94234b7a6b7d4dab2b432a46d1d59449941bd031b7e6e841a13d7830305ef1", + "entry_digest": "42a35cb8eed79a217a0016bc9ab3d9a5cc41b4c20f6f950e66edc40c21ac6d72", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/state-store.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "d27b0ef2da49ec9bc9fd0511a2e4b52d59c6ca48989346ee02bccc396ca9ff2e", + "entry_digest": "57cd000f546caaed34e32f52e5912ac1fd281971a75f12686c24fd38e9813509", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "0f02b782887467b358568bf22c2d68f39c40a00d56143bd6c64abdf8198cf433", + "entry_digest": "8f828f3625d4f24c3c71852bd28f76aac97cd31ace2a18d1a901a3d34642de8d", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "63705355034895d0dfab654892140beecd317afb08530917c8cf4b308824ab63", + "entry_digest": "88818028e56f72fbfd2bdf24012faf448fc049911bf77a2ecb1f32ba1c3e6389", + "owners": [ + "state-store" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-019" + ] + }, + "stale_acceptance_input_digest": "d9f2e380fb092c289727ec18fcbcc51e790996f5001a9c982c12a6da77a27f90", + "current_acceptance_input_digest": "74a16fa249f701bdaf762713514c61dff234a4e62487e6be9e6f766d8b3c2397" + } + ] } diff --git a/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/state.json b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/state.json index b2180ac..4f530c8 100644 --- a/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/state.json +++ b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/state.json @@ -9,7 +9,7 @@ "canonical_applied": true, "base_commit": "c4ecd60b2a46bca4780f1b4af79a1d495d646287", "created_at": 1784422790, - "updated_at": 1784422893, + "updated_at": 1784429522, "affected_specs": [ "aps-cli", "state-store" diff --git a/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/verification-attempts.json b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/verification-attempts.json index 29df4ec..58f7b4c 100644 --- a/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/verification-attempts.json +++ b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/verification-attempts.json @@ -17,6 +17,23 @@ "requirement_ids": [ "REQ-aps-cli-019" ] + }, + { + "timestamp": 1784429513, + "commit": "69a4f6ea4ddb2cfbdefd87246e96b53e01a85dfa", + "contract_digest": "bc65bd79c314567ae1552bc3e3e33b38bdfe280db7987e702ac123388b3e0e59", + "workspace_digest": "6da61180d0d4ca0fb27fba39a165056ee137ff239c75a7a6b03d88e6fb105c5d", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-019" + ] } ] } diff --git a/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/verification.json b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/verification.json index d89a80c..bce74f5 100644 --- a/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/verification.json +++ b/.specsync/changes/CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33/verification.json @@ -1,9 +1,9 @@ { - "timestamp": 1784422888, - "commit": "c4ecd60b2a46bca4780f1b4af79a1d495d646287", + "timestamp": 1784429513, + "commit": "69a4f6ea4ddb2cfbdefd87246e96b53e01a85dfa", "contract_digest": "bc65bd79c314567ae1552bc3e3e33b38bdfe280db7987e702ac123388b3e0e59", - "workspace_digest": "8009885f006b6accb7ae1ca879bb51e79d5ebe39290dcfb55f1418e2eae03995", - "acceptance_input_digest": "d9f2e380fb092c289727ec18fcbcc51e790996f5001a9c982c12a6da77a27f90", + "workspace_digest": "6da61180d0d4ca0fb27fba39a165056ee137ff239c75a7a6b03d88e6fb105c5d", + "acceptance_input_digest": "74a16fa249f701bdaf762713514c61dff234a4e62487e6be9e6f766d8b3c2397", "acceptance_manifest": { "schema_version": 1, "entries": [ @@ -81,8 +81,8 @@ "path": "Sources/aps/TTY.swift", "kind": "file", "mode": 33188, - "payload_digest": "c27fdaf2de4ced4dd7e3523b14fd017df07676159cb73f186b4cecf783276d29", - "entry_digest": "686ac123a2a7a341e96fc01165b64d1dab2afbbb06c0dab7dda531612737de2b", + "payload_digest": "c204c058e2632d00d75a67bce0122cda060c9518b5f26aa572a5f4a701983035", + "entry_digest": "556cc447fc50013ded3e7eada49a72abac3265c79619d8e6ab26da1f834d4b24", "owners": [ "aps-cli" ] diff --git a/Sources/aps/TTY.swift b/Sources/aps/TTY.swift index 0ba54b5..b2246b1 100644 --- a/Sources/aps/TTY.swift +++ b/Sources/aps/TTY.swift @@ -3,15 +3,29 @@ import Foundation /// Minimal TTY awareness (issue #33, git porcelain rule): pretty for humans /// when interactive, frozen plain output when piped. Machine shapes never /// carry ANSI; human styling is additive only. +#if os(Windows) +import ucrt +#endif + enum TTY { /// True when stdout is a terminal (not a pipe or file). + /// Windows uses MSVCRT `_isatty`/`_fileno` (FileHandle.fileDescriptor + /// is unavailable there). static var stdoutIsTTY: Bool { - isatty(FileHandle.standardOutput.fileDescriptor) == 1 + #if os(Windows) + return _isatty(_fileno(stdout)) != 0 + #else + return isatty(FileHandle.standardOutput.fileDescriptor) == 1 + #endif } /// True when stderr is a terminal. static var stderrIsTTY: Bool { - isatty(FileHandle.standardError.fileDescriptor) == 1 + #if os(Windows) + return _isatty(_fileno(stderr)) != 0 + #else + return isatty(FileHandle.standardError.fileDescriptor) == 1 + #endif } /// Color is emitted only on a TTY and never when NO_COLOR is set (any value). From ea7599b1bc0d0d08c82c2e525c4d0d50c8a69268 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Sat, 18 Jul 2026 21:07:06 -0600 Subject: [PATCH 3/3] Fix windows-smoke: move smoke.ps1 to the compact JSON contract Piped JSON is now compact (gh rule); the PowerShell smoke greps still expected the pretty form. --- .../verification-attempts.json | 24 +++++++++++++++++++ .../verification-attempts.json | 17 +++++++++++++ Scripts/smoke.ps1 | 24 +++++++++---------- 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json index 8405a48..f7b776d 100644 --- a/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json +++ b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json @@ -600,6 +600,30 @@ "REQ-state-store-010", "REQ-state-store-011" ] + }, + { + "timestamp": 1784430339, + "commit": "4b2ec8e618dd22a0c84ca1bf0b9c8ce0a8c553d4", + "contract_digest": "73c000607b3050c54981b4c29532280dd08bf00b7a15c8ec1801e045fa5496c9", + "workspace_digest": "1699283608fb41fd83d454a77fd5cf3f1e1dd1685052918df67f355b5ceb4879", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-001", + "REQ-aps-cli-010", + "REQ-aps-cli-011", + "REQ-aps-cli-012", + "REQ-aps-cli-013", + "REQ-state-store-001", + "REQ-state-store-010", + "REQ-state-store-011" + ] } ] } diff --git a/.specsync/changes/CHG-0019-add-powershell-smoke-script-and-windows-latest-smoke-ci-for-issue-45/verification-attempts.json b/.specsync/changes/CHG-0019-add-powershell-smoke-script-and-windows-latest-smoke-ci-for-issue-45/verification-attempts.json index e3a2364..0eda8c4 100644 --- a/.specsync/changes/CHG-0019-add-powershell-smoke-script-and-windows-latest-smoke-ci-for-issue-45/verification-attempts.json +++ b/.specsync/changes/CHG-0019-add-powershell-smoke-script-and-windows-latest-smoke-ci-for-issue-45/verification-attempts.json @@ -119,6 +119,23 @@ "requirement_ids": [ "REQ-aps-cli-018" ] + }, + { + "timestamp": 1784430363, + "commit": "4b2ec8e618dd22a0c84ca1bf0b9c8ce0a8c553d4", + "contract_digest": "bbe132cc0578bf3b207567f6f95971c09abbaa9370e1f2de062c2e1aa399b968", + "workspace_digest": "1699283608fb41fd83d454a77fd5cf3f1e1dd1685052918df67f355b5ceb4879", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-018" + ] } ] } diff --git a/Scripts/smoke.ps1 b/Scripts/smoke.ps1 index aa1e9a4..05b52b5 100644 --- a/Scripts/smoke.ps1 +++ b/Scripts/smoke.ps1 @@ -87,26 +87,26 @@ Assert-Match $keys 'profile' 'keys profile' Assert-Match $keys 'secret' 'keys secret' $keysJson = Invoke-ApsOk keys --json -Assert-Match $keysJson '"key" : "profile"' 'keys json profile' -Assert-Match $keysJson '"key" : "secret"' 'keys json secret' +Assert-Match $keysJson '"key":"profile"' 'keys json profile' +Assert-Match $keysJson '"key":"secret"' 'keys json secret' # `set` prints the value; State is process-local so don't expect get in a new process. Assert-Equal '11' (Invoke-ApsOk set counter 11) 'set counter' Assert-Equal 'smoke' (Invoke-ApsOk set message smoke) 'set message' -Assert-Match (Invoke-ApsOk set counter 11 --json) '"value" : 11' 'set counter json' +Assert-Match (Invoke-ApsOk set counter 11 --json) '"value":11' 'set counter json' # StoredState / FileState must survive process boundaries. $null = Invoke-ApsOk set flag true Assert-Equal 'true' (Invoke-ApsOk get flag) 'get flag' -Assert-Match (Invoke-ApsOk get flag --json) '"value" : true' 'get flag json' +Assert-Match (Invoke-ApsOk get flag --json) '"value":true' 'get flag json' $null = Invoke-ApsOk set note smoke-note Assert-Equal 'smoke-note' (Invoke-ApsOk get note) 'get note' $null = Invoke-ApsOk set profile '{"name":"smoke","version":2}' $profileJson = Invoke-ApsOk get profile --json -Assert-Match $profileJson '"name" : "smoke"' 'profile name' -Assert-Match $profileJson '"version" : 2' 'profile version' +Assert-Match $profileJson '"name":"smoke"' 'profile name' +Assert-Match $profileJson '"version":2' 'profile version' # SecureState / Keychain smoke temporarily disabled (Keychain prompts / hangs). # Re-enable with: APS_SMOKE_SECURESTATE=1 @@ -114,7 +114,7 @@ if ($env:APS_SMOKE_SECURESTATE -eq '1') { if ($IsMacOS) { $null = Invoke-ApsOk set secret smoke-secret Assert-Equal 'smoke-secret' (Invoke-ApsOk get secret) 'get secret' - Assert-Match (Invoke-ApsOk get secret --json) '"storage" : "SecureState"' 'secret storage' + Assert-Match (Invoke-ApsOk get secret --json) '"storage":"SecureState"' 'secret storage' $null = Invoke-ApsOk reset secret $after = Invoke-ApsOk get secret if (-not [string]::IsNullOrEmpty($after)) { @@ -132,10 +132,10 @@ $null = Invoke-ApsOk set note other-root --state-dir $other Assert-Equal 'other-root' (Invoke-ApsOk get note --state-dir $other) 'state-dir get' Assert-Equal 'smoke-note' (Invoke-ApsOk get note) 'default home note unchanged' -Assert-Match (Invoke-ApsOk dump) '"key" : "flag"' 'dump flag' +Assert-Match (Invoke-ApsOk dump) '"key":"flag"' 'dump flag' $dumpJson = Invoke-ApsOk dump --json -Assert-Match $dumpJson '"key" : "profile"' 'dump json profile' -Assert-Match $dumpJson '"key" : "secret"' 'dump json secret' +Assert-Match $dumpJson '"key":"profile"' 'dump json profile' +Assert-Match $dumpJson '"key":"secret"' 'dump json secret' $null = Invoke-ApsOk reset flag Assert-Equal 'false' (Invoke-ApsOk get flag) 'reset flag' @@ -146,7 +146,7 @@ if (-not [string]::IsNullOrEmpty($noteAfter)) { throw "expected empty note after reset, got '$noteAfter'" } -Assert-Match (Invoke-ApsOk reset profile --json) '"reset" : "key"' 'reset profile json' +Assert-Match (Invoke-ApsOk reset profile --json) '"reset":"key"' 'reset profile json' $null = Invoke-ApsOk reset --all Assert-Equal 'false' (Invoke-ApsOk get flag) 'reset all flag' @@ -159,7 +159,7 @@ if (-not [string]::IsNullOrEmpty($noteAll)) { $null = Invoke-ApsOk watch counter --count 1 --timeout 2 # ObservedDependency stats command (process-local; fresh process starts at 0). -Assert-Match (Invoke-ApsOk stats --json) '"mutationCount" : 0' 'stats json' +Assert-Match (Invoke-ApsOk stats --json) '"mutationCount":0' 'stats json' $null = Invoke-ApsOk stats --watch --count 1 --timeout 2 # Invalid values should fail clearly.