Skip to content

feat(cli)!: preview writes until --write, truncate raw output, and close the AXI catalog gaps - #37

Merged
dmealing merged 4 commits into
mainfrom
fm/hass-axi-close-gaps
Oct 4, 2026
Merged

dmealing merged 4 commits into
mainfrom
fm/hass-axi-close-gaps

Conversation

@dmealing

@dmealing dmealing commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner

Intent

Close all of the gaps the AXI catalog maintainer recorded when admitting hass-axi with an exception (kunchenguid/axi#231, pin v0.8.0), so the tool can be listed without the exception. The catalog entry's own words for the gaps:

  • Principle 2 is partial: sensor list defaults to six fields and logbook get to five. (Other list commands default to three fields, statistics list to four, with --fields selecting from the wider set.)
  • Principle 3 is partial: api and ws output is not size-truncated. (src/hass_axi/commands/api.py and wscmd.py print the whole response.)
  • Principle 6 is partial: service call and the raw api/ws write paths mutate without a confirmation or preview step, relying on the capability pre-check and the opt-in HASS_AXI_READ_ONLY switch.
  • Principle 7 lacks hook status/removal and session-end capture. (setup hooks has no status or removal subcommand and installs no session-end hook.)
  • Principle 8: the bare home view prints the description and setup help but exits 1 when HA_URL/HA_TOKEN are unset or Home Assistant is unreachable.

Make sure to test all command locally again my HA.

What Changed

  • Writes preview until --write (breaking). service call, a write-method api request and a write ws command send nothing without --write; without it each prints what it would send and exits 0. The service call preview reads the published service, resolves the target and runs the capability pre-check, mirrors the sent call's verdicts (unknown service, wrong response mode, nothing targeted), and degrades the same way when the registries are unreadable. A preview is a read, so a read-only session can still see one and is told the write would be refused; ws --list gains an access column.
  • Raw output truncates; list defaults tighten; the bare home view stops failing. api and ws shorten long responses — first 25 items per list, 1200 characters per string — reporting what was withheld, with --full printing everything. sensor list and logbook get default to the AXI-standard four columns with the rest behind --fields. The no-argument home view now exits 0 when unconfigured or unreachable, reporting the fault under live_state, code and class instead of erroring; ping and doctor remain the reachability commands.
  • Session hooks gain status, removal and end-of-session capture. setup hooks status reports each target installed/stale/missing without writing, and setup hooks remove takes out the entries this tool wrote by the same ownership test an install uses (including the pre-rename shapes), leaving shared flags and unmanaged entries alone. A SessionEnd hook runs the new context end, which records command names and counts — never arguments — via the new sessionlog.py module, and context reports the last session in the same directory.

New suites: tests/test_write_preview.py and tests/test_session_capture.py, with the doubles and existing sweeps extended to cover the preview gate, truncation and hook lifecycle.

Risk Assessment

⚠️ Medium: No blocking defects: preview/sent verdict parity traced clean across every reachable target world, the hook installer's install/status/remove symmetry verified, the session recorder's no-arguments privacy boundary holds, and both prior fix rounds do exactly what was prescribed; the medium level reflects only the size of the breaking surface (every write path now gated behind --write, user settings files rewritten, new persistent state file) rather than any found fault.

Testing

Full regression suite passed (ci-local: leakcheck, commits, test, skill). Then drove 20+ named scenarios against running servers: the loopback double lab, a WS-refusing lab, a throwaway real Home Assistant 2026.9.4 container, and the maintainer's live HA 2026.7.2 for every command with reads and previews only (no mutations on his installation; all --write mutations ran on the throwaway container and double lab). Preview-before-write, both review fixes, truncation, default fields, hook status/remove/session-end, and the exit-0 home view all verified with transcripts in the evidence directory. No LLM API spend; teardown removed the container, its root-owned config, and the fetched real token; worktree left clean.

  • Live validation: ✅ go - 22 of 22 scenarios driven live against the product
Scenario Result Live Evidence
Bare home view exits 0 when nothing is configured, naming NOT_CONFIGURED under live_state/code/class ✅ pass live S_home_view.txt (hass-axi with empty env: exit=0)
Bare home view exits 0 when the installation is unreachable, reporting UNREACHABLE/transport ✅ pass live S_home_view.txt (HA_URL=http://127.0.0.1:9: exit=0, code UNREACHABLE)
Bare home view against a live installation exits 0 with live state (double lab, real HA container 2026.9.4, live HA 2026.7.2) ✅ pass live S_home_view.txt, S_real_ha_adversarial.txt (R6), S_live_sweep_1.txt
sensor list defaults to four columns (entity_id,name,value,unit) and --fields reaches the wider set ✅ pass live S_default_fields.txt (double lab), S_live_sweep_1.txt (live HA: sensors[100]{entity_id,name,value,unit})
logbook get defaults to four columns (when,name,event,cause) and --fields reaches the wider set ✅ pass live S_logbook_fields.txt, S_live_sweep_1.txt (live HA: entries[50]{when,name,event,cause})
api shortens a large response (lists to 25 items, strings to 1200 chars) with size reported; --full prints everything ✅ pass live S_live_sweep_2.txt (live HA: 'first 25 of 909 items shown (truncated, 417965 chars total)', short=15160 vs full=455795 bytes; '1 string cut to 1200 chars')
ws raw output is truncated the same way and --full prints all of it ✅ pass live S_live_sweep_2.txt (live HA registry: 'first 25 of 2237 items shown'), S_real_ha_adversarial.txt (real HA container: 25 of 90)
service call without --write previews the request, resolves the target and sends nothing (state provably unchanged) ✅ pass live S_preview_sends_nothing.txt (double: state stayed on until --write), S_real_ha_writes.txt (real HA 2026.9.4: off after preview, on after --write), S_live_sweep_2.txt (live HA preview)
service call --write sends and reports the changed states at exit 0 ✅ pass live S_real_ha_writes.txt (real HA: changed[2], state on), S_service_preview_write.txt (double)
Review fix 1: a target matching only unavailable entities previews at exit 0 naming the skip, matching --write's exit 0 ✅ pass live S_preview_unavailable_target.txt (switch.example_outlet: preview 'would reach 0 entities ... unavailable' exit 0; --write 'accepted with 0 states changed' exit 0)
Review fix 2: with the WebSocket upgrade refused (404) and REST answering, the area-targeted preview exits 0 with the degraded target/capability report, matching --write ✅ pass live S_preview_ws_down.txt (WS-refusing lab: preview and both --write runs exit 0, 'area example_room could not be resolved ... NO_WEBSOCKET_API')
A write-method api request previews until --write, then sends ✅ pass live S_raw_api_ws_preview.txt (double), S_live_sweep_2.txt (live HA preview)
A write ws command previews until --write, then writes the registry (area created on real HA) ✅ pass live S_raw_api_ws_preview.txt, S_real_ha_adversarial.txt (config/area_registry/create --write returned area_id example_attic on the real container)
A read-only session still sees the preview with a note that --write would be refused, and --write exits 2 READ_ONLY ✅ pass live S_readonly_and_wslist.txt
ws --list carries the access column separating read from write commands ✅ pass live S_readonly_and_wslist.txt (double), S_live_sweep_2.txt (live HA)
Adversarial: a named entity lacking the published capability is refused UNSUPPORTED_CAPABILITY by preview and by --write alike (real HA refusal) ✅ pass live S_real_ha_adversarial.txt (cover.set_cover_position on a features-3 cover: identical refusal both ways, sent exit 1)
Adversarial: a target that reaches nothing (empty area) raises NO_ENTITIES_TARGETED exit 1 in preview and on the sent call ✅ pass live S_real_ha_adversarial.txt (real HA container: same code, class and message both ways)
setup hooks status reports all six targets as installed/stale/missing and writes nothing ✅ pass live S_hooks_lifecycle.txt (isolated --home), S_live_sweep_2.txt (real home)
setup hooks install writes session-start AND session-end hooks for Claude Code and Codex with the managed_by marker ✅ pass live S_hooks_written_files.txt (inspected .claude/settings.json and .codex/hooks.json)
setup hooks remove reports all six rows, removes only its own entries, keeps the shared Codex feature flag, and is idempotent ✅ pass live S_hooks_remove.txt (claude-code-session-end and codex-session-end removed; codex-features kept; second remove reports absent; settings.json back to {})
Session-end capture: context end counts the session's hass-axi commands from a transcript (names and counts, never arguments) and context reports the last session in the directory ✅ pass live S_session_capture.txt ('ran sensor list x1 and service call x1 and state list x1 with 1 sent by --write'; second session replaced the line)
All 17 commands ran against the maintainer's live installation (reads and previews only), each answering in its ordinary shape at the expected exit code ✅ pass live S_live_sweep_1.txt and S_live_sweep_2.txt (909 entities, 22 areas, doctor healthy true, statistics and history windows, service model 77 domains)
Evidence: Evidence manifest: scenario-to-file map, run summary
no-mistakes test phase - hass-axi close-gaps (b8d1764..38953fb)
run: 2026-10-04T19:17:14Z

Regression: scripts/ci-local.sh --only leakcheck --only commits --only test --only skill => PASS (leakcheck commits test skill), ~1300 tests
Live surfaces driven: loopback double lab (REST+WS), loopback double with WS upgrade refused, real Home Assistant 2026.9.4 throwaway container, the maintainer's live HA 2026.7.2 (reads and previews only, no mutations)
LLM API spend: none. All steps are local CLI, pytest and docker runs.

scenario -> evidence file
  P8 home exit 0 (unconfigured / unreachable / live)         -> S_home_view.txt
  P2 sensor list default fields + --fields                   -> S_default_fields.txt, S_live_sweep_1.txt
  P2 logbook get default fields + --fields                   -> S_logbook_fields.txt, S_live_sweep_1.txt
  P3 api/ws truncation + --full (list and 1200-char string)  -> S_live_sweep_2.txt (909-item and 2237-item responses, string cut to 1200)
  P6 service call preview sends nothing; --write acts        -> S_service_preview_write.txt, S_preview_sends_nothing.txt, S_real_ha_writes.txt
  P6 raw api POST preview until --write                      -> S_raw_api_ws_preview.txt, S_live_sweep_2.txt
  P6 raw ws write preview until --write                      -> S_raw_api_ws_preview.txt, S_real_ha_adversarial.txt
  review fix 1: unavailable-only target, preview == --write  -> S_preview_unavailable_target.txt
  review fix 2: WS down, preview == --write (degraded, x0)   -> S_preview_ws_down.txt
  P6 read-only session preview note + refusal                -> S_readonly_and_wslist.txt
  P6 ws --list access column                                 -> S_readonly_and_wslist.txt
  P7 hooks status/install/remove, six rows, codex kept       -> S_hooks_lifecycle.txt, S_hooks_remove.txt, S_hooks_written_files.txt
  P7 session-end capture (context end / context)             -> S_session_capture.txt
  adversarial on real HA: UNSUPPORTED_CAPABILITY, NO_ENTITIES_TARGETED, preview == write verdicts -> S_real_ha_adversarial.txt
  live HA broad sweep, all 17 nouns                          -> S_live_sweep_1.txt, S_live_sweep_2.txt
Evidence: Real Home Assistant container: preview leaves light off, --write turns it on (2 states changed)
\### Real Home Assistant 2026.9.4 lab - write paths end to end

\### R1: preview leaves the light off, --write turns it on
service: light.turn_on
preview: nothing was sent to Home Assistant
request:
  path: /api/services/light/turn_on
fields: every field sent is one the service publishes and no required one is missing
target: entity light.example_lamp would reach 1 entity
capability_check: nothing to check - this service publishes no requirement
preview exit=0
after preview:
  state: off
service: light.turn_on
changed[2]{entity_id,name,state}:
count: 2 states changed
write exit=0
after write:
  state: on
Evidence: Review fix 1: unavailable-only target previews at exit 0 matching --write
\### S9 (review fix 1): target matches only an unavailable entity - PREVIEW
service: switch.toggle
preview: nothing was sent to Home Assistant
request:
  method: POST
  path: /api/services/switch/toggle
  body:
    entity_id[1]: switch.example_outlet
fields: every field sent is one the service publishes and no required one is missing
target: "entity switch.example_outlet would reach 0 entities; switch.example_outlet unavailable, which Home Assistant skips"
would_reach: []
capability_check: nothing to check - this service publishes no requirement
help[1]:
  Run the same command with --write to send it
exit=0

\### S9: the identical call with --write
service: switch.toggle
changed: switch.toggle accepted with 0 states changed
target: "entity switch.example_outlet matched 1 entity; switch.example_outlet unavailable, which Home Assistant skips without a word"
help[2]:
  Run `hass-axi state get <entity_id>` to see an entity's current state
  Run `hass-axi service get switch.toggle` to see what this service targets
exit=0
Evidence: Review fix 2: WebSocket upgrade refused, preview and --write both exit 0 with degraded target report
\### sanity: ws transport is refused here, REST answers
healthy: false
  - check: rest
  - check: websocket
    code: NO_WEBSOCKET_API
    detail: there is no WebSocket API at this URL (HTTP 404)

\### S10 (review fix 2): area-targeted call, registries unreadable - PREVIEW
service: light.turn_on
preview: nothing was sent to Home Assistant
request:
  method: POST
  path: /api/services/light/turn_on
  body:
    area_id[1]: example_room
fields: every field sent is one the service publishes and no required one is missing
target: "area example_room could not be resolved: there is no WebSocket API at this URL (HTTP 404)"
capability_check: "not run - the target could not be resolved: there is no WebSocket API at this URL (HTTP 404)"
help[4]:
  Run the same command with --write to send it
  Run `hass-axi state list --area example_room --domain light` to see what is there
  Run `hass-axi area list` to see each area's id and how much it holds
  Run `hass-axi service get light.turn_on` to see what this service targets
exit=0

\### S10: first --write turns the area on (still no registry answer)
service: light.turn_on
changed: light.turn_on accepted with 0 states changed
target: "area example_room could not be resolved: there is no WebSocket API at this URL (HTTP 404)"
help[3]:
  Run `hass-axi state list --area example_room --domain light` to see what is there
  Run `hass-axi area list` to see each area's id and how much it holds
  Run `hass-axi service get light.turn_on` to see what this service targets
exit=0

\### S10: second identical --write: empty change set, target degraded not fatal
service: light.turn_on
changed: light.turn_on accepted with 0 states changed
target: "area example_room could not be resolved: there is no WebSocket API at this URL (HTTP 404)"
help[3]:
  Run `hass-axi state list --area example_room --domain light` to see what is there
  Run `hass-axi area list` to see each area's id and how much it holds
  Run `hass-axi service get light.turn_on` to see what this service targets
exit=0
Evidence: Live HA: api/ws truncation (first 25 of 909 and 2237 items), 1200-char string cut, --full sizes, write previews
--- service list / service get (structural)
count: 77 domains
domains[77]{domain,services}:
service: light.turn_on
target: entity domain light
fields[16]{field,required,type,description}:

--- S7-live: service call PREVIEW against the live installation (nothing sent)
service: <entity_id>
preview: nothing was sent to Home Assistant
request:
  method: POST
  path: /api/services/light/turn_on
fields: every field sent is one the service publishes and no required one is missing
target: entity <entity_id> would reach 1 entity
capability_check: nothing to check - this service publishes no requirement
help[1]:
exit=0
state after preview (still off):
  state: off

--- S3-live: api truncation over a large response (P3)
result[25]:
truncated: "first 25 of 909 items shown (truncated, 417965 chars total)"
help[1]:
--- api --full reports no truncation:
--- size comparison (bytes):
short=15160 full=455795

--- S3-live: ws truncation over the entity registry (P3)
result[25]:
truncated: "first 25 of 2237 items shown (truncated, 1398356 chars total)"

--- ws --list access column (live)
commands[17]{command,type,params,access}:

--- template render (live)
error: "unexpected argument '{{ states | count }}' for `template render`"
code: UNEXPECTED_ARGUMENT
class: usage

--- registries over WebSocket (structural)
count: 22 areas
unassigned_entities: 1400
count: 100 of 2237 total
count: 100 of 206 total
devices[100]{device_id,name,area}:

--- S11-live: api write preview against live (nothing sent)
request:
  method: POST
  path: /api/services/light/turn_on
preview: nothing was sent to Home Assistant
help[1]:

--- S12-live: ws write command preview against live (nothing sent)
command:
  access: write
params:
preview: nothing was sent to Home Assistant
help[1]:
--- template render (live, correct flag)
template:
  result: "909"
  chars: 3

--- entity list --search (live, structural)
count: 75 of 75 matched (2237 total)
entities[75]{entity_id,name,area}:

--- sensor list --stale (live, structural)

--- long-string / long-list truncation: a weather state with a forecast (P3)
weather entity found: yes
result:

--- setup hooks status against the real home (reads nothing but hook files)
hooks:
targets[6]{target,status}:
  claude-code,missing
  claude-code-session-end,missing
  codex,missing
  codex-session-end,missing
  codex-features,installed
  opencode,missing
--- state list --stale (live, structural)
exit=2

--- string truncation (P3): long template output through the raw api path
result: "0.1.2.3. [number sequence elided] 
truncated: "1 string cut to 1200 chars (truncated, 1491 chars total)"
exit=0
Evidence: Live HA reads: home/doctor counts, sensor list and logbook get default columns
\### live installation sweep (reads and previews only) - 2026-10-04T19:07:37Z

--- hass-axi (home view, exit code)
exit=0
entities: 909 in 32 domains
unavailable: 120
unknown: 99

--- doctor
healthy: true

--- state list --domain sensor (structural)
count: 100 of 336 matched (909 total)
states[100]{entity_id,name,state}:

--- S4-live: sensor list default columns (P2)
count: 100 of 285 total
set_aside: "51 diagnostic, config or hidden sensors not shown"
sensors[100]{entity_id,name,value,unit}:

--- S5-live: logbook get default columns (P2)
window: "2026-10-03T19:07:38+00:00 to 2026-10-04T19:07:38+00:00 (1d)"
count: 50 of 3056 total
entries[50]{when,name,event,cause}:

--- history get (first entity from state list, structural)
window: "2026-10-03T19:07:38+00:00 to 2026-10-04T19:07:38+00:00 (1d)"

--- statistics get (energy meter, structural)
window: "2026-09-27T19:07:38+00:00 to 2026-10-04T19:07:38+00:00 (7d, daily buckets)"
statistics[1]:
Evidence: Real HA adversarial: UNSUPPORTED_CAPABILITY and NO_ENTITIES_TARGETED, preview verdict equals sent verdict; truncation; typed registry rename; doctor
\### R2 (adversarial): named entity lacking the capability - preview refuses as the send would
error: "cover.set_cover_position needs a supported_features bitmask containing any of 4, which is not what cover.example_blind reports 3"
code: UNSUPPORTED_CAPABILITY
class: refused
help[2]:
  Run `hass-axi service get cover.set_cover_position` to see what it targets
  Run `hass-axi state get <entity_id>` to read an entity's supported_features
preview exit=0
--- the identical call with --write (Home Assistant refusal, explained):
error: "cover.set_cover_position needs a supported_features bitmask containing any of 4, which is not what cover.example_blind reports 3"
code: UNSUPPORTED_CAPABILITY
class: refused
help[2]:
  Run `hass-axi service get cover.set_cover_position` to see what it targets
  Run `hass-axi state get <entity_id>` to read an entity's supported_features
write exit=1

\### R3 (adversarial): a target that reaches nothing - empty area
  name: config/area_registry/create
  area_id: example_attic
  name: Example Attic
--- preview:
error: "area example_attic matched 0 entities light.turn_on can act on, so the call did nothing"
code: NO_ENTITIES_TARGETED
class: not_found
help[3]:
  Run `hass-axi state list --area example_attic --domain light` to see what is there
  Run `hass-axi area list` to see each area's id and how much it holds
preview exit=0
--- write:
error: "area example_attic matched 0 entities light.turn_on can act on, so the call did nothing"
code: NO_ENTITIES_TARGETED
class: not_found
help[3]:
  Run `hass-axi state list --area example_attic --domain light` to see what is there
  Run `hass-axi area list` to see each area's id and how much it holds
write exit=1
\### corrected exit-code capture for R2/R3 previews
R2 preview exit=1 (code UNSUPPORTED_CAPABILITY)
R3 preview exit=1 (code NO_ENTITIES_TARGETED)

\### R4: truncation on real HA - api /states
result[25]:
truncated: "first 25 of 123 items shown (truncated, 57655 chars total)"
--- ws raw entity registry
result[25]:
truncated: "first 25 of 90 items shown (truncated, 50461 chars total)"

\### R5: typed registry write on real HA - rename the lamp
name: Reading Lamp
area: ""
area_id: ""
area_source: ""
exit=0

\### R6: home view against real HA
entities: 123 in 39 domains
unavailable: 0
unknown: 10

\### R7: doctor and ping against real HA
ok: true
url: "http://127.0.0.1:18123"
latency_ms: 2
version: 2026.9.4
healthy: true
Evidence: Hooks lifecycle: status six targets, install, remove all six rows, codex-features kept, idempotent
\### S16: remove reports all six rows, leaves the shared Codex feature on
targets[6]{target,status}:
  claude-code,removed
  claude-code-session-end,removed
  codex,removed
  codex-session-end,removed
  codex-features,kept
  opencode,removed
help[1]:
  Run `hass-axi setup hooks` to install them again
exit=0

\### status after remove
targets[6]{target,status}:
  claude-code,missing
  claude-code-session-end,missing
  codex,missing
  codex-session-end,missing
  codex-features,installed
  opencode,missing
help[1]:

\### remove again: idempotent
  claude-code-session-end,absent
  codex,absent
  codex-session-end,absent
  codex-features,kept
  opencode,absent
help[1]:
  Run `hass-axi setup hooks` to install them again

\### what the install actually wrote (claude settings.json):
{}
Evidence: Written hook files: SessionStart and SessionEnd entries with managed_by marker (Claude Code and Codex)
\### what install writes: both rows per settings file, session-end included
--- ~/.claude/settings.json:
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "<worktree>/.venv/bin/hass-axi context",
            "timeout": 10,
            "managed_by": "hass-axi"
          }
        ]
      }
    ],
    "SessionEnd": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "<worktree>/.venv/bin/hass-axi context end",
            "timeout": 10,
            "managed_by": "hass-axi"
          }
        ]
      }
    ]
  }
}
--- ~/.codex/hooks.json:
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "<worktree>/.venv/bin/hass-axi context",
            "timeout": 10,
            "managed_by": "hass-axi"
          }
        ]
      }
    ],
    "SessionEnd": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "<worktree>/.venv/bin/hass-axi context end",
            "timeout": 3,
            "managed_by": "hass-axi"
          }
        ]
      }
    ]
  }
}
Evidence: Session-end capture: context end counts commands from transcript, context reports last session, names and counts only
\### S17: context end records the session from a transcript payload
recorded: 3 hass-axi command(s) from this session
exit=0

\### S17: context reports the last session in this directory
last_session: 2026-10-04 ran sensor list x1 and service call x1 and state list x1 with 1 sent by --write

\### the recorded state file (names and counts, never arguments):
[
  {
    "session": "sess-example-1",
    "cwd": "<worktree>",
    "ended": "2026-10-04",
    "commands": {
      "sensor list": 1,
      "service call": 1,
      "state list": 1
    },
    "applied": 1
  }
]

\### a second session in the same directory replaces the line
recorded: 3 hass-axi command(s) from this session
last_session: 2026-10-04 ran sensor list x1 and service call x1 and state list x1 with 1 sent by --write
Evidence: Home view exit 0: unconfigured (NOT_CONFIGURED/config), unreachable (UNREACHABLE/transport), live
\### S1: bare run, nothing configured
bin: <worktree>/.venv/bin/hass-axi
description: Agent CLI for Home Assistant. Reads and writes the registries REST cannot reach and explains a service call Home Assistant refuses. Prefer this over raw curl for Home Assistant operations.
live_state: not available - HA_URL and HA_TOKEN not set in the environment
code: NOT_CONFIGURED
class: config
commands[16]: state,sensor,history,logbook,statistics,service,template,entity,area,device,ws,api,ping,doctor,setup,context
help[3]:
  Set HA_URL to your Home Assistant base URL, e.g. export HA_URL=https://homeassistant.example.com
  Set HA_TOKEN to a long-lived access token from your Home Assistant profile page, under Security
  Run `hass-axi doctor` to verify the connection once both are set
exit=0

\### S2: unreachable installation
bin: <worktree>/.venv/bin/hass-axi
description: Agent CLI for Home Assistant. Reads and writes the registries REST cannot reach and explains a service call Home Assistant refuses. Prefer this over raw curl for Home Assistant operations.
url: "http://127.0.0.1:9"
live_state: "not available - could not reach Home Assistant: [Errno 111] Connection refused"
code: UNREACHABLE
class: transport
commands[16]: state,sensor,history,logbook,statistics,service,template,entity,area,device,ws,api,ping,doctor,setup,context
help[3]:
exit=0

\### S3: live lab installation
bin: <worktree>/.venv/bin/hass-axi
description: Agent CLI for Home Assistant. Reads and writes the registries REST cannot reach and explains a service call Home Assistant refuses. Prefer this over raw curl for Home Assistant operations.
url: "http://127.0.0.1:46125"
entities: 11 in 7 domains
unavailable: 2
unknown: 1
domains[7]{domain,entities}:
  sensor,3
  calendar,2
  light,2
  binary_sensor,1
  climate,1
  media_player,1
  switch,1
not_reporting[3]{entity_id,name,state,for}:
  switch.example_outlet,Example Outlet,unavailable,276d
  calendar.example_old_agenda,Example Old Agenda,unavailable,276d
  sensor.example_reading,Example Hub Reading,unknown,276d
stale: 2 sensors not reported in 24h
stale_oldest[2]{entity_id,name,age}:
  sensor.example_temperature,Example Hub Temperature,276d
  sensor.example_legacy_meter,Example Doorway Legacy Meter,276d
help[5]:
  Run `hass-axi state list --domain <domain>` to list entity states
  Run `hass-axi entity list --area <id|name>` to read the registry, which REST cannot reach
  Run `hass-axi area list` to see the areas defined here
  Run `hass-axi sensor list --device-class <class>` to find a reading by what it measures
  Run `hass-axi service call <domain>.<service> --target-entity <entity_id>` to preview an action and add --write to send it
exit=0

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ⚠️ src/hass_axi/commands/service.py:506 - service call preview raises NO_ENTITIES_TARGETED (exit 1) for a target that matches only unavailable entities, while the identical command with --write exits 0 ('accepted with 0 states changed' with the unavailable entity named by _report_target). Concrete sequence: hass-axi service call switch.toggle --target-entity switch.example_outlet (no --write) hits the condition at line 506 (not reached), _unreached_target sees matched non-empty and raises; add --write and the sent path's _report_target finds reachable (domain-filtered, not availability-filtered) non-empty and reports at exit 0. This contradicts the change's own documented contract ('every refusal below is the same answer with or without the flag', README and COMMAND notes). The sent path raises only when matched is empty, or under --response (bodyless 500 explained via _unreached_target); the preview should mirror those two worlds. Note lines 510-518 already build the matched-but-skipped phrasing and are unreachable for this case because the raise at 509 fires first.
  • ⚠️ src/hass_axi/hooks.py:714 - hooks.remove skips appending the second target row for each settings file (else: continue at line 714), so setup hooks remove output reports claude-code/codex but never claude-code-session-end/codex-session-end, while install and status report all six JSON targets. The comment at lines 678-679 states the opposite design ('both rows report what that one rewrite did'). tests/test_hooks.py::test_remove_takes_out_what_install_wrote_and_is_idempotent pins the four-row shape via exact dict equality, so the fix must extend that test's expected statuses with the two session-end rows.

🔧 Fix applied.
1 warning still open:

  • ⚠️ src/hass_axi/commands/service.py:503 - For an area- or device-targeted service call, the preview's registry resolution (live.resolved(parsed), memoized by _Live) raises the WebSocket transport fault and exits 1, while the identical command with --write succeeds and exits 0: the sent path's _precheck (service.py:599-604) deliberately swallows the resolution failure, sends the POST over the working REST transport, and on an empty change set degrades to _report_target's "target could not be resolved" answer at exit 0 (service.py:757-763). Concrete sequence: a reverse proxy that answers REST but refuses the WebSocket upgrade (the topology AGENTS.md itself names) — hass-axi service call light.turn_off --target-area example_room exits 1 class: transport (taxonomy's "retry" can never succeed), and adding --write mutates and exits 0. This breaks the change's documented contract ("every refusal below is the same answer with or without the flag", README and COMMAND notes) in the one deployment where it is reachable, and since the preview is now the mandatory gateway to writes (Principle 6), it blocks area/device-targeted calls that would work. Remedy mirrors _report_target: catch the AxiError at the preview's resolution and answer target: &lt;scope&gt; could not be resolved: &lt;message&gt; with the checks marked not run, exit 0 — the same verdict the sent call gives.

🔧 Fix applied.
1 info still open:

  • ℹ️ src/hass_axi/commands/context.py:64 - context end writes the session-record file but declares access=READ and honors HASS_AXI_READ_ONLY inside the command body (comment at context.py:61-63, test-pinned by test_a_read_only_session_records_nothing_and_its_hook_still_exits_zero). That is deliberate and correct — a dispatch-level WRITE refusal would be reported as every read-only session failing to close — but AGENTS.md's read-only gate section still states "Enforcement is at dispatch, never in a command body" as an absolute and was not updated to record this exception, so the committed project memory contradicts the shipped code and a future session could 'fix' the body-level check back into a declaration. Noting the tradeoff; the one-paragraph AGENTS.md amendment can ride along with any later change.
✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 22 of 22 scenarios driven live against the product
Scenario Result Live Evidence
Bare home view exits 0 when nothing is configured, naming NOT_CONFIGURED under live_state/code/class ✅ pass live S_home_view.txt (hass-axi with empty env: exit=0)
Bare home view exits 0 when the installation is unreachable, reporting UNREACHABLE/transport ✅ pass live S_home_view.txt (HA_URL=http://127.0.0.1:9: exit=0, code UNREACHABLE)
Bare home view against a live installation exits 0 with live state (double lab, real HA container 2026.9.4, live HA 2026.7.2) ✅ pass live S_home_view.txt, S_real_ha_adversarial.txt (R6), S_live_sweep_1.txt
sensor list defaults to four columns (entity_id,name,value,unit) and --fields reaches the wider set ✅ pass live S_default_fields.txt (double lab), S_live_sweep_1.txt (live HA: sensors[100]{entity_id,name,value,unit})
logbook get defaults to four columns (when,name,event,cause) and --fields reaches the wider set ✅ pass live S_logbook_fields.txt, S_live_sweep_1.txt (live HA: entries[50]{when,name,event,cause})
api shortens a large response (lists to 25 items, strings to 1200 chars) with size reported; --full prints everything ✅ pass live S_live_sweep_2.txt (live HA: 'first 25 of 909 items shown (truncated, 417965 chars total)', short=15160 vs full=455795 bytes; '1 string cut to 1200 chars')
ws raw output is truncated the same way and --full prints all of it ✅ pass live S_live_sweep_2.txt (live HA registry: 'first 25 of 2237 items shown'), S_real_ha_adversarial.txt (real HA container: 25 of 90)
service call without --write previews the request, resolves the target and sends nothing (state provably unchanged) ✅ pass live S_preview_sends_nothing.txt (double: state stayed on until --write), S_real_ha_writes.txt (real HA 2026.9.4: off after preview, on after --write), S_live_sweep_2.txt (live HA preview)
service call --write sends and reports the changed states at exit 0 ✅ pass live S_real_ha_writes.txt (real HA: changed[2], state on), S_service_preview_write.txt (double)
Review fix 1: a target matching only unavailable entities previews at exit 0 naming the skip, matching --write's exit 0 ✅ pass live S_preview_unavailable_target.txt (switch.example_outlet: preview 'would reach 0 entities ... unavailable' exit 0; --write 'accepted with 0 states changed' exit 0)
Review fix 2: with the WebSocket upgrade refused (404) and REST answering, the area-targeted preview exits 0 with the degraded target/capability report, matching --write ✅ pass live S_preview_ws_down.txt (WS-refusing lab: preview and both --write runs exit 0, 'area example_room could not be resolved ... NO_WEBSOCKET_API')
A write-method api request previews until --write, then sends ✅ pass live S_raw_api_ws_preview.txt (double), S_live_sweep_2.txt (live HA preview)
A write ws command previews until --write, then writes the registry (area created on real HA) ✅ pass live S_raw_api_ws_preview.txt, S_real_ha_adversarial.txt (config/area_registry/create --write returned area_id example_attic on the real container)
A read-only session still sees the preview with a note that --write would be refused, and --write exits 2 READ_ONLY ✅ pass live S_readonly_and_wslist.txt
ws --list carries the access column separating read from write commands ✅ pass live S_readonly_and_wslist.txt (double), S_live_sweep_2.txt (live HA)
Adversarial: a named entity lacking the published capability is refused UNSUPPORTED_CAPABILITY by preview and by --write alike (real HA refusal) ✅ pass live S_real_ha_adversarial.txt (cover.set_cover_position on a features-3 cover: identical refusal both ways, sent exit 1)
Adversarial: a target that reaches nothing (empty area) raises NO_ENTITIES_TARGETED exit 1 in preview and on the sent call ✅ pass live S_real_ha_adversarial.txt (real HA container: same code, class and message both ways)
setup hooks status reports all six targets as installed/stale/missing and writes nothing ✅ pass live S_hooks_lifecycle.txt (isolated --home), S_live_sweep_2.txt (real home)
setup hooks install writes session-start AND session-end hooks for Claude Code and Codex with the managed_by marker ✅ pass live S_hooks_written_files.txt (inspected .claude/settings.json and .codex/hooks.json)
setup hooks remove reports all six rows, removes only its own entries, keeps the shared Codex feature flag, and is idempotent ✅ pass live S_hooks_remove.txt (claude-code-session-end and codex-session-end removed; codex-features kept; second remove reports absent; settings.json back to {})
Session-end capture: context end counts the session's hass-axi commands from a transcript (names and counts, never arguments) and context reports the last session in the directory ✅ pass live S_session_capture.txt ('ran sensor list x1 and service call x1 and state list x1 with 1 sent by --write'; second session replaced the line)
All 17 commands ran against the maintainer's live installation (reads and previews only), each answering in its ordinary shape at the expected exit code ✅ pass live S_live_sweep_1.txt and S_live_sweep_2.txt (909 entities, 22 areas, doctor healthy true, statistics and history windows, service model 77 domains)
  • scripts/ci-local.sh --only leakcheck --only commits --only test --only skill
  • scripts/ci-local.sh --only leakcheck --only commits --only test --only skill (full ~1300-test suite, lint, leak scan, generated-skill check) — PASS
  • hass-axi with empty env / unreachable HA_URL / live HA_URL (home view exit codes and live_state, code, class)
  • hass-axi sensor list default columns vs --fields (lab double + live HA)
  • hass-axi logbook get default columns vs --fields (lab double + live HA)
  • hass-axi api /states and ws --raw config/entity_registry/list truncation + --full (live HA: first 25 of 909 and of 2237 items; 1200-char string cut via POST /template)
  • hass-axi service call preview vs --write: state-unchanged proof, changed list, unavailable-only target (fix 1), WS-down degraded target (fix 2), UNSUPPORTED_CAPABILITY, NO_ENTITIES_TARGETED — all on running servers
  • hass-axi api POST preview until --write; hass-axi ws --raw config/area_registry/create preview until --write (real HA container)
  • HASS_AXI_READ_ONLY preview note and exit-2 refusal; ws --list access column
  • setup hooks install/status/remove under an isolated --home, plus status against the real home; inspected the written settings.json/hooks.json for SessionStart and SessionEnd entries
  • hass-axi context end with a synthetic transcript payload, then hass-axi context last_session line
  • live HA sweep across all 17 commands (state/sensor/history/logbook/statistics/service/template/entity/area/device/ws/api/ping/doctor/setup/context)
⚠️ **Document** - 1 info
  • ℹ️ The change edited several README $ hass-axi … example blocks (adding --write, changing help lines, the context document) and the README's own rule is that every block is real output re-run against a throwaway Home Assistant. This phase verified the prose against the code but could not re-run those blocks against any installation; live verification against the user's HA is the outer run's remaining acceptance step (per the intent's "test all commands locally against my HA").
✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

…ose the AXI catalog gaps

The AXI catalog admitted this tool with an exception that named five gaps.
This closes all five.

Section 6, writes. `service call`, a write-method `api` request and a write
`ws` command now send nothing without `--write`. Without it each prints what
it would send. The `service call` preview reads the published service,
resolves the target, runs the capability pre-check and lists the entities it
would reach; it fails as the call would for a service that does not exist, a
response mode the service does not have, or a target that reaches nothing,
and reports an unpublished field as a warning. A preview is a read, so a
read-only session can still see one and is told the write would be refused.
`ws --list` gains an `access` column.

Section 3, truncation. `api` and `ws` shorten a long response: each list to
its first 25 items and each string to 1200 characters, with the number of
items withheld and the full size reported. `--full` prints all of it, and the
hint appears only when something was cut.

Section 2, default fields. `sensor list` defaults to entity_id, name, value
and unit, and `logbook get` to when, name, event and cause. The dropped
columns stay reachable through `--fields`, and the help block says so.

Section 7, session hooks. `setup hooks status` reports each target as
installed, stale or missing and writes nothing. `setup hooks remove` takes
out the entries this tool wrote, by the same ownership test an install uses,
and leaves the shared Codex feature flag on. A session-end hook runs the new
`context end`, which counts the commands a session ran from the transcript
the agent names; names and counts only, never an argument. `context` reports
the last session in the same directory. Claude Code and Codex get a
SessionEnd hook; OpenCode has no such event, so its plugin records when a
session goes idle.

Section 8, the home view. A bare run exits 0 when nothing is configured or
the installation does not answer. It prints `live_state: not available`,
the `code` and `class` of the fault, the command names and the setup help.
`ping` and `doctor` remain the commands whose exit code reports reachability.

The README and AGENTS.md text describing the changed behaviour is corrected,
and the skill is regenerated.

BREAKING CHANGE: `service call`, write-method `api` requests and write `ws` commands no longer act unless `--write` is passed; without it they preview and exit 0. The no-argument home view now exits 0 when unconfigured or unreachable, reporting the fault under `live_state`, `code` and `class` instead of `error`. `sensor list` and `logbook get` print four default columns; pass `--fields` for the rest.
@dmealing
dmealing merged commit a4115ef into main Oct 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant