AGENTS.md · diff
git:20261012.78dd1a7 to git:20261012.264b28a
3 added, 1 removed. Audit A to A.
# AGENTS.md
Instructions for AI agents working on the **unifly** codebase. This file is the
single source of truth for repository conventions. `CLAUDE.md` is a symlink to
this file. Cursor, Codex, Aider, Cline, and Claude Code all read from here.
For end-user documentation see `README.md`. For contributor workflow see
`CONTRIBUTING.md`. For how agents should _use_ the unifly CLI at runtime see
`skills/unifly/SKILL.md` (a separate audience from this file).
---
## What This Repository Is
unifly is a Rust CLI and TUI for managing Ubiquiti UniFi network
infrastructure. A single `unifly` binary ships three user-facing surfaces:
- **CLI commands** (`unifly devices list`, etc.): 29 top-level commands
(28 without the `tui` feature) covering devices and switch port management
(`ports / ports-export / port-set`), clients, networks, WiFi, firewall
(policies, zones, and groups), NAT, DNS, ACL, traffic-lists, hotspot,
events, stats, DPI, VPN (servers, tunnels, site-to-site, remote-access,
clients, connections, peers, magic-site-to-site, settings), Wi-Fi
observability (neighbors, channels, roams, experience), site settings
(`settings`), cloud fleet queries, and a raw `api` escape hatch.
- **TUI dashboard** (`unifly tui`) -- 11-screen Ratatui interface for real-time
monitoring and interactive management.
- **Agent skill** at `skills/unifly/SKILL.md`: a discovery stub that loads
version-matched guidance through `unifly skill`.
The binary is powered by the `unifly-api` library crate, which is also
published independently on crates.io for Rust developers building custom
integrations.
The moat is **triple-path coverage**: unifly speaks the modern Integration
API (REST, API key), the Session API (cookie + CSRF), and Site Manager cloud
fleet/connector APIs. Most competing tools are one path or two; unifly can
mix local and cloud workflows in one binary.
---
## Quick Command Reference
The project uses **just** for task orchestration. All recipes live in the
`justfile` at the repo root.
```bash
# The canonical "before commit" gate
just check # fmt-check + maintainability + clippy + test
# Individual gates
just fmt-check # nightly rustfmt + prettier, read-only
just maintainability # scripts/check-maintainability.sh line-count gate
just clippy # cargo clippy --workspace --all-targets
just test # cargo test --workspace
# Fix-it automation
just fix # clippy --fix + cargo fmt + prettier --write
just fmt # cargo fmt --all + prettier --write
# Running
just cli <args> # cargo run -p unifly -- <args>
just tui <args> # cargo run -p unifly -- tui <args>
# Targeted tests
just test-crate unifly-api
just test-verbose # cargo test --workspace -- --nocapture
just snap-review # cargo insta review for snapshot tests
# End-to-end (dockerized controller, feature-gated)
just e2e # full lifecycle: up, wait, test, tear down
just e2e-up # start the controller container
just e2e-wait # poll until the controller is ready
just e2e-build # compile the gated e2e binary without running it
just e2e-test # run the suite against a running controller
just e2e-down # stop and remove the container
# Build
just build # debug build (workspace)
just build-release # release build (workspace)
# Install
just install # cargo install --path crates/unifly (CLI + TUI)
just install-cli # CLI without TUI dependencies
# Docs and cleanup
just doc # cargo doc --workspace --no-deps --open
just docs-build # Zola docs site build + llms.txt generation
just docs-serve # Zola docs site with live reload
just clean # cargo clean
```
**Always run `just check` before committing.** It is the same gate CI runs.
---
## Workspace Layout
This is a **2-crate Cargo workspace** (not 5, despite older notes):
```
crates/
unifly-api/ # Library: HTTP/WS transport, Controller, DataStore, model
unifly/ # Single binary: CLI commands + TUI dashboard (feature-gated)
```
Dependency chain: `unifly` depends on `unifly-api`.
**Edition 2024. MSRV 1.94. Resolver 3. Workspace version is pinned in
`[workspace.package]` at `Cargo.toml`.** All member crates inherit the
version via `version.workspace = true`.
Published to crates.io: `unifly-api`, `unifly`. Private/internal artifacts
live under `aur/`, `homebrew-tap` (separate repo), and `skills/unifly/` (for
ClawHub and Claude Code plugin).
---
## Architecture Essentials
### Dual-API Transport
`crates/unifly-api/src/` splits transport into two clients:
- **`integration/`**: `IntegrationClient`, `X-API-KEY` auth, modern REST
endpoints at `/proxy/network/integration/v1/`. Returns clean JSON with
UUIDs. Covers configuration CRUD (networks, wifi, firewall, nat, dns,
acl, hotspot, traffic-lists, wans).
- **`session/`**: `SessionClient`, session cookie + CSRF for session auth,
plus `X-API-KEY` on UniFi OS session HTTP endpoints. Uses envelope-wrapped
responses at `/proxy/network/api/` and `/proxy/network/v2/api/`. Covers
events, stats, Wi-Fi/client observability, device commands, admin,
backups, DPI control. Session WebSocket still requires a session cookie.
Both clients share a common `TransportConfig` and `TlsMode`. TLS modes:
`SystemDefaults`, `AcceptInvalid`, `CustomPem`. Credentials are wrapped in
`secrecy::SecretString` throughout. **Never log or display them.**
### Controller Facade
`crates/unifly-api/src/controller/` wraps everything behind a single
`Controller` type:
```rust
pub struct Controller(Arc<ControllerInner>);
```
The `Arc` makes `Controller` cheaply cloneable across async tasks. Key
operations live in submodules:
- `lifecycle.rs`: connect, disconnect, reconnect
- `runtime.rs`: background refresh loop, command processor
- `query.rs` / `query/`: read-side operations
- `commands/`: write-side operations (`execute(CoreCommand)`)
- `payloads.rs` / `payloads/`: request body construction
- `refresh.rs`: periodic data sync
- `subscriptions.rs`: WebSocket event fan-out
- `session_queries.rs`: Session-specific read paths, including `raw_get`/
`raw_post` used by the `unifly api` command
### Reactive DataStore
`crates/unifly-api/src/store/` implements lock-free reactive storage:
- `EntityCollection<T>`: `DashMap` for lookup, `tokio::watch::Sender<Vec<T>>`
for broadcast
- `DataStore` -- aggregate of 14 `EntityCollection`s (devices, clients,
networks, wifi_broadcasts, firewall_policies, firewall_zones, firewall_groups,
nat_policies, acl_rules, dns_policies, traffic_matching_lists, vouchers,
sites, events)
plus `watch::Sender` fields for site_health, last_full_refresh, and
last_ws_event
- `EntityStream<T>`: subscriber handle wrapping `watch::Receiver`; provides
`current()`, `latest()`, `changed().await`
**Serde `rc` feature is required** because `Arc<T>` is serialized for JSON
output. That is set via `serde = { features = ["derive", "rc"] }` in the
workspace dependencies.
### Entity Identifiers
```rust
pub enum EntityId {
Uuid(Uuid), // Integration API records
Legacy(String), // Session API records (string _id fields)
}
```
Synthetic keys used for non-MAC entities: `net:{id}`, `wifi:{id}`,
`fwp:{id}`, `fwz:{id}`, `acl:{id}`, etc. Some domain types (`Client`,
`Device`) use MAC as the natural key.
### Auth Modes
```rust
pub enum AuthCredentials {
ApiKey(SecretString),
Credentials { username: String, password: SecretString },
Hybrid { api_key: SecretString, username: String, password: SecretString },
Cloud { api_key: SecretString, host_id: String },
}
```
**ApiKey mode is enough for most HTTP work on UniFi OS controllers.**
`connect()` builds a `SessionClient` with `X-API-KEY`, so `clients list`,
`devices list`, `topology`, device commands, stats, Wi-Fi observability
commands (`wifi neighbors`, `wifi channels`, `clients roams`, `clients wifi`),
admin operations, DHCP reservations, and `events list` can all use session
HTTP without a password. Use **Hybrid** when you need session WebSocket
features such as `events watch`, or when you want maximum compatibility
across controller variants. Merge still happens inline in `full_refresh()`
in `controller/refresh.rs`.
**Cloud mode now works for both fleet and connector paths.** `unifly cloud ...`
talks directly to the Site Manager fleet API at `api.ui.com/v1/`, while
regular Integration-backed commands can tunnel through the cloud connector
when `auth_mode = "cloud"` or `--host-id` is set. Session-only commands still
require direct controller/session access.
---
## CLI Architecture
### Module Layout
```
crates/unifly/src/cli/
args.rs # Top-level Cli struct, Command enum, GlobalOpts
args/
common.rs # GlobalOpts, ListArgs, OutputFormat
<entity>.rs # 24 entity-specific files (25 total with common.rs)
commands/
mod.rs # dispatch() router
util/
access.rs # ensure_integration_access / ensure_session_access
<entity>.rs # handler per entity
<entity>/ # for entities with multiple subhandlers
error.rs # CliError (thiserror + miette)
output.rs # Table/JSON/YAML/plain format dispatch
```
The pattern for every entity: `args/<entity>.rs` defines the clap structs,
`commands/<entity>.rs` (or `<entity>/handler.rs`) implements `async fn
handle(controller, args, global) -> Result<(), CliError>`. Dispatch happens
in `commands/mod.rs::dispatch()` via match on `Command`.
**To add a new top-level command:**
1. Add a new variant to `Command` in `cli/args.rs`
2. Create `cli/args/<new>.rs` with the clap subcommand definitions
3. Create `cli/commands/<new>.rs` with `handle(controller, args, global)`
4. Add the dispatch arm in `cli/commands/mod.rs::dispatch()`
5. Verify `unifly skill commands` includes the new command automatically
6. Update `crates/unifly/skill/commands.md` for behavior help cannot convey
### Access Gate Gotcha
`crates/unifly/src/cli/commands/util/access.rs` defines
**`ensure_integration_access`** and **`ensure_session_access`**. The
Integration gate is called by 8 handler files (acl, dns, hotspot,
networks, traffic_lists, wifi, vpn for servers/tunnels, and firewall
for policies and zones). The Session gate is called by `events`,
`nat`, `firewall` (groups subgroup and group-name resolution inside
policies), `devices` (ports / ports-export / port-set), and `vpn`
(status, health, site-to-site, remote-access, clients, connections,
peers, magic-site-to-site, settings). The `firewall` and `vpn`
handlers dispatch the gate per-subcommand rather than at the top
level. The top-level `settings` command has no gate call; insufficient
auth surfaces as a raw session-client error. New commands should add
the appropriate gate call for clean error messages when the auth mode
is insufficient.
### Output Formats
`cli/output.rs` dispatches on `OutputFormat`:
```rust
pub enum OutputFormat {
Table, // default, human-facing, uses tabled crate
Json, // pretty JSON
JsonCompact, // single-line JSON per record
Yaml, // serde_yaml_ng
Plain, // one ID per line for xargs pipelines
}
```
Every list/get handler uses `output::render(format, data)` to emit results.
Table rendering requires implementing `tabled::Tabled` on a row struct
(usually a local struct in the handler that borrows from the domain model).
### Global Flags and Environment
`cli/args/common.rs` defines `GlobalOpts` with clap `env` attributes. The
env var prefix is **`UNIFI_`**, not `UNIFLY_`. Exception: `UNIFLY_THEME`
(TUI only).
Hidden/undocumented flag: `--totp` (with `UNIFI_TOTP` env). Not in `--help`
but functional. Used for MFA controllers and 1Password CLI integration via
`totp_env` in config. `totp_env` names an env var read once at startup, so
both give a single-use code that serves only the first login. The
profile's `totp_command` (argv array, no shell, 10 s timeout, see
`config/totp_command.rs`) becomes a `TotpProvider` that runs for every
re-login, which is what lets an MFA session renew when its cookie expires.
Flag-aware paths (CLI, TUI launch) resolve TOTP through
`config::resolve::resolve_totp`; `profile_to_controller_config` uses the
same `config::resolve_totp_provider`. Never wrap a value captured once as
a provider; the session client refuses to resend a spent code anyway.
`--no-cache` forces a fresh Session login, bypassing the session cookie
cache stored under the system config dir.
Value resolution follows CLI flag/env > profile > `[defaults]` >
builtin (30-second timeout, system TLS verification). A profile's
explicit `ca_cert` beats an inherited `defaults.insecure = true`,
since a configured CA still means verification. `--insecure` /
`UNIFI_INSECURE` is tri-state (`Option<bool>` with `require_equals`):
bare `-k` means true, `--insecure=false` forces verification over any
profile or defaults value. `--timeout` has no clap default; the
30-second fallback is applied during resolution so profile and
`[defaults]` timeouts take effect.
---
## TUI Architecture
```
crates/unifly/src/tui/
mod.rs # launch() entry point
terminal.rs # alternate screen, raw mode, cleanup guard
theme.rs # semantic opaline adapter
event.rs # crossterm -> Action translation
action.rs # Action enum
component.rs / screen.rs # traits
data_bridge.rs # Controller streams -> App state
app.rs / app/ # top-level App state + run loop
screens/ # 11 screens (Dashboard, Devices, Clients,
# Networks, Firewall, Topology, Events, Stats,
# Wifi, Onboarding, Settings)
widgets/ # shared custom widgets
forms/ # editable form overlays (Networks, Settings)
```
`mod.rs::launch()` sets up file-based tracing (stderr is unavailable in alt
screen mode), installs panic hooks, initializes the opaline theme, builds a
Controller, and runs `App::run().await`.
Theme access is a global singleton: `opaline::current()` returns
`Arc<Theme>`, cheap to clone per-frame. `theme.rs` is a semantic adapter
that maps opaline tokens to unifly-specific color accessors (e.g.
`unifly.tx_fill`, `unifly.chart.0-5`).
`data_bridge.rs` subscribes to `EntityStream<T>` channels from the
Controller's DataStore and pushes updates into App state. Screens read
from App state during render. No direct Controller access from screens.
Key integration: the `ThemeSelector` widget from `opaline::widgets` is
embedded as a settings overlay. It holds an `Arc<Theme>` snapshot at open
time for exact rollback on Esc (the pattern was contributed back from the
git-iris bug fix).
---
## Error Handling
Two error types, composed via `From` impls:
- **`CoreError`** (`unifly-api/src/core_error.rs`): library-level, 14+
variants, one `From<unifly_api::Error>` impl mapping transport errors
- **`CliError`** (`unifly/src/cli/error.rs`): CLI-level, `thiserror` +
`miette` for rich terminal diagnostics, wraps `CoreError`
**Conventions:**
- Use `thiserror` for library errors, never `anyhow` in `unifly-api`
- CLI error messages are formatted by `miette` with color and snippets
- **Never `.unwrap()`** on user-provided data. Workspace lint
`clippy::unwrap_used = "deny"` enforces this
- `?` is the standard propagation. Use `.map_err(CliError::from)` or
`wrap_err` when crossing layer boundaries
---
## Testing
### Layout
```
crates/unifly-api/tests/
integration_client_test.rs # wiremock-based Integration API tests
session_client_test.rs # wiremock-based Session API tests
site_manager_client_test.rs # wiremock-based Site Manager cloud tests
controller_runtime_test.rs # Controller lifecycle + refresh loop
crates/unifly/tests/
cli_test.rs # assert_cmd-based CLI tests (fast, no controller)
e2e_test.rs # gated e2e suite against a dockerized controller
tests/e2e/ # docker-compose.yml + wait-for-controller.sh
```
Unit tests are inline in source files under `#[cfg(test)] mod tests`.
The e2e suite (`e2e_test.rs`) is feature-gated behind `--features e2e`
and drives the built binary against a real UniFi Network controller
running in simulation mode via `tests/e2e/docker-compose.yml`. `just
e2e` runs the full lifecycle (up, wait, test, tear down); `UNIFLY_E2E_URL`,
`UNIFLY_E2E_USERNAME`, `UNIFLY_E2E_PASSWORD`, `UNIFLY_E2E_SITE`, and
`UNIFLY_E2E_TIMEOUT_SECS` override the connection defaults. CI runs the
same lifecycle in `.github/workflows/e2e.yml`. The `integration_profile_*`
tests form a second lane for the Integration API (REST, `X-API-KEY`)
that runs only when `UNIFLY_E2E_API_KEY` is set and skips otherwise; the
simulation image cannot mint a key (Integration auth is backed by the
UniFi OS account subsystem the bare image lacks), so this lane needs a
real UniFi OS controller plus a console-minted key. See `CONTRIBUTING.md`
for the full provisioning findings.
### Libraries
- **wiremock**: mock HTTP servers for the Integration, Session, and
Site Manager client test suites.
- **insta**: snapshot tests for output formatting. `just snap-review`
opens the interactive UI to approve changes. Snapshot files live next to
the test as `.snap` or `.snap.new`.
- **assert_cmd** + **predicates**: CLI tests that spawn the built binary.
- **tempfile**: per-test config dir isolation so tests don't touch
`~/.config/unifly/`.
- **tokio-test**: poll-based async unit tests.
- **pretty_assertions**: better diffs on assertion failures.
`insta` is built with `opt-level = 3` in `[profile.dev.package.insta]` so
snapshot diffing is fast even in debug builds.
### Policy
- Unit tests should be pure and deterministic. No real network calls.
- Integration tests use wiremock or assert_cmd, never a real controller.
The one carve-out is the feature-gated e2e suite, which targets the
dockerized controller and never runs in a default `cargo test`.
- Tests must not require a specific UniFi hardware or firmware version.
- The TUI has inline unit tests (effects, forms, screen state) but no
`ratatui::TestBackend` render harness yet. Adding one is welcomed.
---
## Code Policies
### Lint Configuration
`Cargo.toml` enforces a strict workspace-wide lint profile:
```toml
[workspace.lints.rust]
unsafe_code = "forbid" # no unsafe anywhere, period
[workspace.lints.clippy]
all = { level = "deny" }
perf = { level = "deny" }
pedantic = { level = "deny" }
unwrap_used = "deny" # use ?, .ok(), .unwrap_or()
enum_glob_use = "deny"
out_of_bounds_indexing = "deny"
undocumented_unsafe_blocks = "deny"
# numeric casts produce warnings (not denials, to allow pragmatism)
cast_precision_loss = "warn"
cast_possible_truncation = "warn"
cast_sign_loss = "warn"
# complexity and modernization warnings
too_many_lines = "warn"
cognitive_complexity = "warn"
future_not_send = "warn"
manual_let_else = "warn"
semicolon_if_nothing_returned = "warn"
```
A few pedantic rules are explicitly allowed for pragmatic reasons:
`module_name_repetitions`, `significant_drop_tightening`,
`must_use_candidate`, `return_self_not_must_use`, `doc_markdown`,
`missing_errors_doc`, `missing_panics_doc`. Do not disable other pedantic
lints without a strong reason.
### Format
`rustfmt.toml` pins: edition 2024, 100-char max width, field init
shorthand, try shorthand. **Nightly rustfmt is required** for stable
output (`rustup component add rustfmt --toolchain nightly`). `just fmt`
runs the nightly formatter.
### Secrets
All credentials flow through `secrecy::SecretString`. Do not:
- Log secret values via `tracing` or `println!`
- Deserialize them into plain `String`
- Embed them in error messages
- Store them in plain files outside the OS keyring unless the user
explicitly opts in via `auth_mode` config
**Credential precedence**: when both an explicit `api_key` (or
`password`) is set in a profile's TOML config and a corresponding
keyring entry exists, the explicit config value wins. This is the
opposite of the older behavior; see PR #22 (`config/mod.rs` resolve
paths). Treat this as the contract for any code that reaches into
config when materializing `AuthCredentials`.
### Dependencies
`deny.toml` enforces:
- Vulnerability advisories fail the build
- License allowlist: MIT, Apache-2.0, BSD-2/3-Clause, ISC, Unicode-3.0,
Unicode-DFS-2016, Zlib, OpenSSL, MPL-2.0
- Wildcards are denied
- Git sources are denied by default (all deps must come from crates.io)
Run `cargo deny check` before adding or upgrading dependencies. New
licenses require a deliberate policy update.
---
## Release Workflow
Releases use the shared workflow at
`hyperb1iss/shared-workflows/.github/workflows/rust-release.yml`.
### Before triggering
Run `just changelog-cut X.Y.Z` and commit the result. It moves the
`[Unreleased]` notes under `## [X.Y.Z] - <date>`, opens a fresh empty
`[Unreleased]`, and updates the compare links. Skipping it leaves the
shipped notes under `[Unreleased]`, so the changelog credits them to the
next release.
### Steps (handled automatically once triggered)
1. `.github/workflows/release.yml` is triggered manually via
`workflow_dispatch` with a `version` or `bump` input.
2. The shared workflow bumps the workspace version in `Cargo.toml` and
the plugin manifests (its `version-files` list), runs the
full build + test + clippy gate, generates release notes with git-iris
and uploads them as an artifact, commits the bump, tags `vX.Y.Z`,
pushes the commit and tag, and dispatches `.github/workflows/cicd.yml`
on the tag with its run ID.
3. That dispatched cicd run (the only one that releases; a tag push does
not trigger cicd):
- Rebuilds and tests via the shared rust-ci workflow
- Builds release artifacts for 4 targets (linux amd64+arm64, macOS
arm64, Windows x86_64 MSVC)
- Publishes `unifly-api` and `unifly` to crates.io (shared rust-publish)
- Creates a GitHub Release with all artifacts and the notes artifact
from the release run (git-iris regenerates them only as a fallback)
- Updates the Homebrew formula, the AUR package, and the ClawHub skill
Recovering a failed release depends on how far it got:
- **Nothing published yet** (the dispatch never ran, or the cicd run failed
before `publish`): rerun `release.yml` with the same `version` and
`resume_release` set. It regenerates the notes and dispatches a fresh
cicd run on the existing tag without moving it.
- **Crates already on crates.io** (a later job such as the GitHub Release,
Homebrew, or AUR failed): rerun just the failed jobs of the existing
cicd run (`gh run rerun <run-id> --failed`). A fresh dispatch would try
to publish again and fail, because `cargo publish` rejects a version
that already exists.
Pushing a tag by hand does not release anything.
---
## Sacred Rules for Agents
1. **Never `git push` without explicit approval.** Bliss handles all
pushes. Commit locally, show the result, wait.
2. **Never force-push to main without a `--force-with-lease` and an
explicit confirmation.** The main branch is shared. Force pushes have
gone wrong before.
3. **Never bypass hooks** with `--no-verify`, `--no-gpg-sign`, or similar.
If a hook fails, investigate. Do not route around it.
4. **Never commit secrets, API keys, or the `token/` directory.** That
directory is `.gitignore`d but guard against accidental `git add -A`.
Prefer adding files by name.
5. **Never commit `docs/plans/`**. It is a scratch area for design notes
and tracking docs that should not ship. Treat it like a gitignored
notebook even though it is not currently gitignored.
6. **Never delete or modify files you did not touch.** Other agents may be
working in this repo simultaneously. Respect their in-progress work.
7. **Never run destructive operations** (`cargo clean` in CI paths,
`git reset --hard`, `git clean -fd`, etc.) without explicit approval.
8. **Never `.unwrap()` in production code paths.** The `unwrap_used` lint
will fail the build. Use `?`, `.ok()`, `.unwrap_or_default()`, or
proper error propagation.
9. **Never introduce `unsafe` code.** `unsafe_code = "forbid"` at the
workspace level. There is no justification in this codebase.
10. **Never remove or weaken an existing clippy lint** to work around a
warning. Fix the code instead.
---
## Plugin Manifests
Three plugin manifests must stay in sync with `workspace.package.version`:
```
.claude-plugin/plugin.json # Claude Code plugin
.claude-plugin/marketplace.json # Claude Code marketplace listing
.cursor-plugin/plugin.json # Cursor marketplace plugin
```
The `version` field in each must match the workspace version exactly.
These have drifted before. If the workspace is at `0.8.0`, all three must
say `0.8.0`.
The release workflow patches all three through its `version-files` input,
so a normal release keeps them in sync. The discovery skill is versionless;
ClawHub publication receives the release version explicitly.
Check them by hand only when bumping the version outside `release.yml`.
---
## Non-Obvious Gotchas
### UniFi API Quirks
- **CSRF tokens rotate.** The Session client captures `X-CSRF-Token` on
login and updates it from `X-Updated-CSRF-Token` on every response. Do
not cache the token across requests; trust the rotating value.
- **UniFi OS wraps some errors as HTTP 200.** Response bodies can look
like `{"error": {"code": N, "message": "..."}}` with a 200 status. The
envelope decoder must detect this before parsing the `data` field.
- **`stat/admin` is controller-level, not site-scoped.** Use `api_url`
not `site_url` for admin operations.
- **Integration field names differ from expectations.** `networks get`
uses `hostIpAddress` (not `host`), `prefixLength` (not `prefix`),
`dhcpConfiguration.mode/leaseTimeSeconds/ipAddressRange` (not
`dhcp.server.*`). The `networks list` endpoint returns SUMMARY data
without `ipv4Configuration`. Must fetch each network individually to
get full config. See `convert.rs::parse_network_fields` which handles
both old and new styles.
- **Integration clients lack fields** the UI shows: `wireless`,
`uplink_device_mac`, `vlan`, `tx_bytes`, `rx_bytes`, `hostname`. These
must come from Session API. Hybrid merge is by IP-address match in
`controller::refresh::full_refresh`.
- **`stat/rogueap` uses epoch seconds**, not milliseconds. Passing
millisecond-style values or assuming stats-report semantics returns empty
data silently.
- **`wifiman/{ip}/` band field is `wlan_band`**, not `band`. Values are
`2.4g` / `5g` / `6g` (station data uses `ng` / `na` / `6e`). Uplink
devices use `display_name` (not `device_name`/`name`) and `experience`
(not `wifi_experience`). Neighbor signal is nested:
`signal: [{"signal": -67, "signal_type": "AP_AP"}]`.
- **`stat/report/*.ap` and `*.site` use different attribute prefixes**.
`.ap` expects `ng-cu_total`; `.site` expects `ap-ng-cu_total`.
- **`system-log/client-connection/{mac}`** requires MAC duplication in both
the URL path and the `?mac=` query parameter. The response uses a deeply
nested `parameters` structure: event details live under
`parameters.DEVICE_FROM.name`, `parameters.WLAN.name`,
`parameters.SIGNAL_STRENGTH.name`, `parameters.RADIO_BAND.name`,
`parameters.CHANNEL.name`, etc. — **not** flat top-level fields.
- **`stat/current-channel`** returns country-level regulatory data, not
per-radio rows. Channel lists are keyed by band: `channels_ng` (2.4 GHz),
`channels_na` (5 GHz), `channels_na_dfs`, `channels_6e` (6 GHz), plus
width-specific variants (`channels_na_40`, `channels_6e_80`, etc.) and
AFC data. Country is identified by `code` (numeric, e.g. `"840"`),
`key` (e.g. `"US"`), and `name` (e.g. `"United States"`).
- **Session v2 observability routes return raw JSON**, not the classic
`{meta, data}` envelope. Use `get_raw()` / `raw_get()` patterns.
- **Device `radios` come from the session `radio_table`**, merged with
`radio_table_stats` by `convert.rs::parse_session_radios`. The
Integration `interfaces` JSON is not the source.
### CLI Quirks
- **Create commands print the created entity on stdout** in the selected
`--output` format: `-o plain` emits the bare ID, `-o json | jq -r .id`
is the capture idiom. The confirmation message goes to stderr.
`sites create` prints nothing (the controller returns no record). VPN
create responses pass through session-query redaction, so generated
private keys and PSKs stay off stdout. A response that cannot be
rendered warns on stderr and still exits zero; the create succeeded,
and failing would invite duplicate-create retries.
- **`nat policies list` works in session-only auth.** The session
refresh snapshot fetches the v2 NAT inventory alongside the other
session collections.
- **Wire formats follow Ubiquiti's OpenAPI spec, not intuition**
(#26/#27). The controller sends port ranges as `start`/`stop`, the
only shape in the spec (`startPort` appears nowhere in it), and reads
accept both. Writes still send `startPort`/`endPort`, the shape a live
UDM accepted in #18; the e2e Integration lane's firewall round trip is
the check that decides whether writes can move to `start`/`stop`. DNS
record types are `*_RECORD` tokens (`A_RECORD`, not `A`). Integration
list pages decode per-item so one unparseable record cannot blank a
collection, and pagination advances by the server-reported count.
- **Default list limit is 25** with a silent truncation hint. Agents and
scripts should pass `--all` or `--limit 200+` for enumeration.
- **`events watch --types` takes `EventCategory` enum values** (Device,
Client, Network, System, Admin, Firewall, Vpn, Unknown) case-insensitive,
**not `EVT_*` glob patterns**. The matching logic is in
`cli/commands/events.rs::watch_events`.
- **`admin revoke <ADMIN>` is positional**, not a `--email` flag.
- **`nat policies update`** uses `--name` or `--description` (mutually
exclusive) for the display label. Both map to the v2 API `description`
field. The `--name` flag is the user-friendly alias.
- **`firewall policies patch`** is a fast partial-update path for toggling
`enabled`/`logging`; use it instead of `update` when only those fields
change.
- **`firewall groups`** uses the Session API (`rest/firewallgroup`), not
Integration. The `external_id` (UUID) in group responses is what
firewall policies reference. Policy create/update supports
`--dst-port-group` / `--dst-address-group` flags (and `dst_port_group`
/ `dst_address_group` shorthand fields in `--from-file` JSON) to
resolve group names to `external_id` UUIDs automatically.
- **`networks refs <id>`** is the only command that answers "what depends
on this entity before I delete it." No equivalent exists for other
entities yet.
- **`unifly api <path>`** routes through the Session client and handles
CSRF automatically, so it can reach Session v1, v2, and Integration
endpoints without caring about auth mode.
- **`clients roams` and `clients wifi`** accept any client identifier
(name, hostname, IP, or MAC). Resolution uses the in-memory snapshot,
so the client must appear in `clients list`. `roams` resolves to MAC;
`wifi` resolves to IP.
- **`wifi neighbors`** defaults to 25 results. Use `--all` or `--limit N`
to see more.
- **`devices ports / ports-export / port-set`** are the switch port
config-as-code surface. Port indices are **1-based** to match the
controller's wire format. `port-set --from-file` accepts a JSONC
payload matching `ApplyPortsRequest` (a `ports` array with per-port
`index`, `name`, `mode`, `native_network_id`/`native_vlan`,
`tagged_network_ids`/`tagged_vlans`, `tagged_all`, `poe`, `speed`,
`reset`). Splice semantics: ports not listed keep their existing
override; `"reset": true` removes that port's entry. The CLI `--poe`
flag accepts only `off`, `auto`, `pasv24`, `passthrough` (no `on`,
because `auto` IS the on/negotiate mode). Setting speed to "auto"
on the wire means `autoneg: true` with no `speed` field, not
`"speed": "auto"` (which the controller rejects). `ports
--with-clients` and `ports-export --with-clients` annotate output
with connected clients (and `// last-seen <ts>: <mac>` markers in
the export, for git-diff drift detection).
- **`settings`** (top-level) reads and writes site-level Session API
settings. Subcommands: `list`, `get <KEY>`, `set <KEY>`, `export`.
Session-only — needs `auth_mode = "hybrid"` or `"session"`.
- **`vpn` is broad now**. `vpn servers / tunnels` are read-only views
on the modern Integration API; `vpn site-to-site / remote-access /
clients / peers / magic-site-to-site / settings` are Session-backed
and most use the Session gate. `vpn connections list/get/restart`
is the legacy v2 connection inventory and restart path. `vpn
remote-access suggest-port` and `vpn remote-access download-config
<id>` are convenience helpers; `vpn peers subnets` enumerates
configured peer subnets.
- **Serde defaults to PascalCase** for enums without `#[serde(rename_all
= "...")]`. When writing JSON payload files for `--from-file`, use
`"Gateway"` not `"gateway"`, `"Wpa2Personal"` not `"wpa2_personal"`.
Exception: `FirewallAction` has a custom deserializer that accepts
lowercase.
### TUI Quirks
- **Do not `println!` or `eprintln!` in the TUI.** Stderr and stdout are
captured by the alternate screen. All TUI logging goes to a file via
`tracing_appender` with a `WorkerGuard` held by `launch()`.
- **Panic hooks must be installed before terminal setup** so that panics
restore the terminal state before crashing. `terminal::install_hooks`
handles this.
- **The data bridge auto-reconnects.** Failed connects retry with
exponential backoff (2s doubling to a 30s cap, indefinitely), emitting
`Reconnecting`/`Disconnected` actions so the status bar stays
truthful. `Ctrl+r` forces an immediate retry while disconnected by
cancelling the old bridge and spawning a fresh one. Bridge lifetimes
are serialized: each new bridge task first awaits its predecessor's
`JoinHandle`, so teardown always completes before the next connect
starts.
---
## File Organization Conventions
### Source Files
- One entity per file under `cli/args/` and `cli/commands/`.
- When a command has many subhandlers (e.g. `devices`, `clients`,
`firewall`, `config_cmd`, `acl`, `cloud`, `vpn`, `settings`), split
into a subdirectory with `mod.rs` and per-subcommand files.
- Domain types live in `unifly-api/src/model/<entity>.rs` and are
re-exported from `lib.rs`.
- Request structs for mutations live in
`unifly-api/src/command/requests/<group>.rs`.
### Documentation Files
- `README.md`: end-user install + usage
- `CONTRIBUTING.md`: contributor onboarding
- `CHANGELOG.md`: version history
- `ROADMAP.md`: forward-looking plans
- `AGENTS.md` / `CLAUDE.md`: this file (CLAUDE.md is a symlink)
- `skills/unifly/SKILL.md`: agent skill for USING the CLI
- `docs/content/`: Zola docs site content (guide, reference, architecture)
- `docs/config.toml`: Zola site configuration
- `docs/images/`: committed screenshots and GIFs
- `docs/plans/` --**scratch area, do not commit contents** (not currently
gitignored but treat as ephemeral)
The docs site deploys via `.github/workflows/docs.yml`, which triggers
only on `docs/**` changes (and edits to the workflow itself). Root
markdown changes do not redeploy the site.
### Scratch Areas
- `docs/plans/`: design docs and session tracking
- `research/`: external research, API audit notes
- `specs/`: early project specs, some outdated
- `target/`: cargo build artifacts (gitignored)
---
## When Making Changes
### Adding a New Command
1. Define clap args in `cli/args/<cmd>.rs`
2. Add `Command::<Cmd>(args)` variant in `cli/args.rs`
3. Implement `handle()` in `cli/commands/<cmd>.rs`
4. Add dispatch arm in `cli/commands/mod.rs::dispatch`
5. Call `util::access::ensure_integration_access` if Integration-only
6. Verify the generated `unifly skill commands` inventory includes the command
7. Update `crates/unifly/skill/commands.md` with operational gotchas
8. Add a happy-path test in `crates/unifly/tests/cli_test.rs`
### Adding a New API Endpoint
1. Add the request type to `unifly-api/src/command/requests/<group>.rs`
2. Add the implementation to `unifly-api/src/controller/commands/<group>.rs`
3. Add wiremock-based tests in
`crates/unifly-api/tests/integration_client_test.rs` or
`session_client_test.rs`
4. Re-export from `unifly-api/src/lib.rs` if it is part of the public API
5. Update the CLI surface if the endpoint needs a command
### Adding a New Domain Type
1. Define in `unifly-api/src/model/<entity>.rs`
2. Add conversion logic in `unifly-api/src/convert.rs` (handles both
Integration and Session field shapes)
3. Add an `EntityCollection<T>` to `unifly-api/src/store/data_store.rs`
if the type is reactive
4. Re-export from `unifly-api/src/lib.rs`
### Updating the Agent Skill
The installed `skills/unifly/SKILL.md` is a discovery stub. Keep operational
instructions in `crates/unifly/skill/`, embedded by `cli/skill.rs` so Cargo
packages and standalone binaries carry the matching guide. The command
inventory is generated from Clap; do not maintain another command table.
`unifly skill --install` writes the stub into `~/.agents/skills/unifly/`
(or `--dir`, or `--claude`) from an embedded copy at
`crates/unifly/skill/discovery-skill.md`, because Cargo cannot package
files outside the crate. Edit both copies together; a unit test fails when
they differ. The installer replaces an existing `SKILL.md` without `--force`
only when its digest is in `PUBLISHED_DIGESTS` (`cli/skill/install.rs`), so
whenever the stub changes, append its new digest (the failing test prints
it) and never remove old ones.
When behavior changes, update the relevant core, commands, concepts, or
workflows topic. Update the payload templates under `skill/examples/` when
request shapes change, and keep the example registry and deserialization
tests in sync. Runtime bundling prevents version skew; behavioral prose
still needs source review when auth, output, or mutation semantics change.
The skill command must remain offline and independent of configuration,
credentials, and controller access. Run the skill tests in both the default
and CLI-only builds, and check Cargo packaging includes every guide asset.
---
## Pointers
- **`README.md`**: end-user documentation, install, features, TUI
screenshots
- **`CONTRIBUTING.md`**: PR workflow, code style overview
- **`CHANGELOG.md`**: version history
- **`ROADMAP.md`**: planned features and known gaps
- **`skills/unifly/SKILL.md`**: agent skill for _using_ the CLI (not for
developing it)
- **`Cargo.toml`**: workspace version, lints, dependencies, profiles
- **`justfile`**: task recipes
- **`.github/workflows/`**: CI + release + docs workflows
- - **`docs/images/`**: shipped screenshots and the animated TUI tour GIF
+ - **`docs/images/`**: shipped screenshots. The README's promo video is a GitHub
+ `user-attachments` upload (a repo file cannot play inline in a README);
+ the source cuts ship as release assets
- **`aur/update-aur.sh`**: AUR package refresh script used by
`just aur-update`