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).
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.): 28 top-level commands (27 without thetuifeature) 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 rawapiescape hatch. - TUI dashboard (
unifly tui) -- 11-screen Ratatui interface for real-time monitoring and interactive management. - Agent skill at
skills/unifly/SKILL.md: bundled documentation that teaches AI agents to drive the CLI.
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.
The project uses just for task orchestration. All recipes live in the
justfile at the repo root.
# 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 cleanAlways run just check before committing. It is the same gate CI runs.
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).
crates/unifly-api/src/ splits transport into two clients:
integration/:IntegrationClient,X-API-KEYauth, 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, plusX-API-KEYon 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.
crates/unifly-api/src/controller/ wraps everything behind a single
Controller type:
pub struct Controller(Arc<ControllerInner>);The Arc makes Controller cheaply cloneable across async tasks. Key
operations live in submodules:
lifecycle.rs: connect, disconnect, reconnectruntime.rs: background refresh loop, command processorquery.rs/query/: read-side operationscommands/: write-side operations (execute(CoreCommand))payloads.rs/payloads/: request body constructionrefresh.rs: periodic data syncsubscriptions.rs: WebSocket event fan-outsession_queries.rs: Session-specific read paths, includingraw_get/raw_postused by theunifly apicommand
crates/unifly-api/src/store/ implements lock-free reactive storage:
EntityCollection<T>:DashMapfor lookup,tokio::watch::Sender<Vec<T>>for broadcastDataStore-- aggregate of 14EntityCollections (devices, clients, networks, wifi_broadcasts, firewall_policies, firewall_zones, firewall_groups, nat_policies, acl_rules, dns_policies, traffic_matching_lists, vouchers, sites, events) pluswatch::Senderfields for site_health, last_full_refresh, and last_ws_eventEntityStream<T>: subscriber handle wrappingwatch::Receiver; providescurrent(),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.
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.
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.
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:
- Add a new variant to
Commandincli/args.rs - Create
cli/args/<new>.rswith the clap subcommand definitions - Create
cli/commands/<new>.rswithhandle(controller, args, global) - Add the dispatch arm in
cli/commands/mod.rs::dispatch() - Update
skills/unifly/SKILL.mdcommand inventory (do not skip this) - Update
skills/unifly/references/commands.mdgotchas section
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.
cli/output.rs dispatches on OutputFormat:
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).
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.
--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.
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).
Two error types, composed via From impls:
CoreError(unifly-api/src/core_error.rs): library-level, 14+ variants, oneFrom<unifly_api::Error>impl mapping transport errorsCliError(unifly/src/cli/error.rs): CLI-level,thiserror+miettefor rich terminal diagnostics, wrapsCoreError
Conventions:
- Use
thiserrorfor library errors, neveranyhowinunifly-api - CLI error messages are formatted by
miettewith color and snippets - Never
.unwrap()on user-provided data. Workspace lintclippy::unwrap_used = "deny"enforces this ?is the standard propagation. Use.map_err(CliError::from)orwrap_errwhen crossing layer boundaries
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.
- wiremock: mock HTTP servers for the Integration, Session, and Site Manager client test suites.
- insta: snapshot tests for output formatting.
just snap-reviewopens the interactive UI to approve changes. Snapshot files live next to the test as.snapor.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.
- 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::TestBackendrender harness yet. Adding one is welcomed.
Cargo.toml enforces a strict workspace-wide lint profile:
[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.
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.
All credentials flow through secrecy::SecretString. Do not:
- Log secret values via
tracingorprintln! - 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_modeconfig
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.
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.
Releases use the shared workflow at
hyperb1iss/shared-workflows/.github/workflows/rust-release.yml.
.github/workflows/release.ymlis triggered manually viaworkflow_dispatchwith aversionorbumpinput.- The shared workflow bumps the workspace version in
Cargo.toml, runs the full build + test + clippy gate, commits the bump, tagsvX.Y.Z, pushes the tag. - The tag push triggers
.github/workflows/cicd.ymlwhich:- Rebuilds and tests via the shared rust-ci workflow
- Builds release artifacts for 4 targets (linux amd64+arm64, macOS arm64, Windows gnu)
- Publishes
unifly-apianduniflyto crates.io (shared rust-publish) - Creates a GitHub Release with all artifacts and git-iris-generated notes
- Updates the Homebrew formula in
hyperb1iss/homebrew-tap
- Plugin manifest version sync.
.claude-plugin/plugin.json,.claude-plugin/marketplace.json, and.cursor-plugin/plugin.jsonmust be updated by hand to match the new workspace version. They have drifted before and caused a ClawHub publish at the wrong version.
When bumping the version, always update the plugin manifests in the same commit.
- Never
git pushwithout explicit approval. Bliss handles all pushes. Commit locally, show the result, wait. - Never force-push to main without a
--force-with-leaseand an explicit confirmation. The main branch is shared. Force pushes have gone wrong before. - Never bypass hooks with
--no-verify,--no-gpg-sign, or similar. If a hook fails, investigate. Do not route around it. - Never commit secrets, API keys, or the
token/directory. That directory is.gitignored but guard against accidentalgit add -A. Prefer adding files by name. - 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. - Never delete or modify files you did not touch. Other agents may be working in this repo simultaneously. Respect their in-progress work.
- Never run destructive operations (
cargo cleanin CI paths,git reset --hard,git clean -fd, etc.) without explicit approval. - Never
.unwrap()in production code paths. Theunwrap_usedlint will fail the build. Use?,.ok(),.unwrap_or_default(), or proper error propagation. - Never introduce
unsafecode.unsafe_code = "forbid"at the workspace level. There is no justification in this codebase. - Never remove or weaken an existing clippy lint to work around a warning. Fix the code instead.
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.
When releasing, patch them manually before the release commit, or add a CI step (not yet implemented) to patch them automatically in the shared workflow.
- CSRF tokens rotate. The Session client captures
X-CSRF-Tokenon login and updates it fromX-Updated-CSRF-Tokenon 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 thedatafield. stat/adminis controller-level, not site-scoped. Useapi_urlnotsite_urlfor admin operations.- Integration field names differ from expectations.
networks getuseshostIpAddress(nothost),prefixLength(notprefix),dhcpConfiguration.mode/leaseTimeSeconds/ipAddressRange(notdhcp.server.*). Thenetworks listendpoint returns SUMMARY data withoutipv4Configuration. Must fetch each network individually to get full config. Seeconvert.rs::parse_network_fieldswhich 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 incontroller::refresh::full_refresh. stat/rogueapuses epoch seconds, not milliseconds. Passing millisecond-style values or assuming stats-report semantics returns empty data silently.wifiman/{ip}/band field iswlan_band, notband. Values are2.4g/5g/6g(station data usesng/na/6e). Uplink devices usedisplay_name(notdevice_name/name) andexperience(notwifi_experience). Neighbor signal is nested:signal: [{"signal": -67, "signal_type": "AP_AP"}].stat/report/*.apand*.siteuse different attribute prefixes..apexpectsng-cu_total;.siteexpectsap-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 nestedparametersstructure: event details live underparameters.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-channelreturns 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 bycode(numeric, e.g."840"),key(e.g."US"), andname(e.g."United States").- Session v2 observability routes return raw JSON, not the classic
{meta, data}envelope. Useget_raw()/raw_get()patterns. - Device
radioscome from the sessionradio_table, merged withradio_table_statsbyconvert.rs::parse_session_radios. The IntegrationinterfacesJSON is not the source.
- Create commands print the created entity on stdout in the selected
--outputformat:-o plainemits the bare ID,-o json | jq -r .idis the capture idiom. The confirmation message goes to stderr.sites createprints 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 listworks 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). Port ranges are
start/stopon the wire, neverstartPort/endPort(that string appears nowhere in the spec). DNS record types are*_RECORDtokens (A_RECORD, notA). 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
--allor--limit 200+for enumeration. events watch --typestakesEventCategoryenum values (Device, Client, Network, System, Admin, Firewall, Vpn, Unknown) case-insensitive, notEVT_*glob patterns. The matching logic is incli/commands/events.rs::watch_events.admin revoke <ADMIN>is positional, not a--emailflag.nat policies updateuses--nameor--description(mutually exclusive) for the display label. Both map to the v2 APIdescriptionfield. The--nameflag is the user-friendly alias.firewall policies patchis a fast partial-update path for togglingenabled/logging; use it instead ofupdatewhen only those fields change.firewall groupsuses the Session API (rest/firewallgroup), not Integration. Theexternal_id(UUID) in group responses is what firewall policies reference. Policy create/update supports--dst-port-group/--dst-address-groupflags (anddst_port_group/dst_address_groupshorthand fields in--from-fileJSON) to resolve group names toexternal_idUUIDs 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 roamsandclients wifiaccept any client identifier (name, hostname, IP, or MAC). Resolution uses the in-memory snapshot, so the client must appear inclients list.roamsresolves to MAC;wifiresolves to IP.wifi neighborsdefaults to 25 results. Use--allor--limit Nto see more.devices ports / ports-export / port-setare the switch port config-as-code surface. Port indices are 1-based to match the controller's wire format.port-set --from-fileaccepts a JSONC payload matchingApplyPortsRequest(aportsarray with per-portindex,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": trueremoves that port's entry. The CLI--poeflag accepts onlyoff,auto,pasv24,passthrough(noon, becauseautoIS the on/negotiate mode). Setting speed to "auto" on the wire meansautoneg: truewith nospeedfield, not"speed": "auto"(which the controller rejects).ports --with-clientsandports-export --with-clientsannotate 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 — needsauth_mode = "hybrid"or"session".vpnis broad now.vpn servers / tunnelsare read-only views on the modern Integration API;vpn site-to-site / remote-access / clients / peers / magic-site-to-site / settingsare Session-backed and most use the Session gate.vpn connections list/get/restartis the legacy v2 connection inventory and restart path.vpn remote-access suggest-portandvpn remote-access download-config <id>are convenience helpers;vpn peers subnetsenumerates 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:FirewallActionhas a custom deserializer that accepts lowercase.
- Do not
println!oreprintln!in the TUI. Stderr and stdout are captured by the alternate screen. All TUI logging goes to a file viatracing_appenderwith aWorkerGuardheld bylaunch(). - Panic hooks must be installed before terminal setup so that panics
restore the terminal state before crashing.
terminal::install_hookshandles this. - The data bridge auto-reconnects. Failed connects retry with
exponential backoff (2s doubling to a 30s cap, indefinitely), emitting
Reconnecting/Disconnectedactions so the status bar stays truthful.Ctrl+rforces 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'sJoinHandle, so teardown always completes before the next connect starts.
- One entity per file under
cli/args/andcli/commands/. - When a command has many subhandlers (e.g.
devices,clients,firewall,config_cmd,acl,cloud,vpn,settings), split into a subdirectory withmod.rsand per-subcommand files. - Domain types live in
unifly-api/src/model/<entity>.rsand are re-exported fromlib.rs. - Request structs for mutations live in
unifly-api/src/command/requests/<group>.rs.
README.md: end-user install + usageCONTRIBUTING.md: contributor onboardingCHANGELOG.md: version historyROADMAP.md: forward-looking plansAGENTS.md/CLAUDE.md: this file (CLAUDE.md is a symlink)skills/unifly/SKILL.md: agent skill for USING the CLIdocs/content/: Zola docs site content (guide, reference, architecture)docs/config.toml: Zola site configurationdocs/images/: committed screenshots and GIFsdocs/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.
docs/plans/: design docs and session trackingresearch/: external research, API audit notesspecs/: early project specs, some outdatedtarget/: cargo build artifacts (gitignored)
- Define clap args in
cli/args/<cmd>.rs - Add
Command::<Cmd>(args)variant incli/args.rs - Implement
handle()incli/commands/<cmd>.rs - Add dispatch arm in
cli/commands/mod.rs::dispatch - Call
util::access::ensure_integration_accessif Integration-only - Update
skills/unifly/SKILL.mdcommand inventory (count + table) - Update
skills/unifly/references/commands.mdwith gotchas - Add a happy-path test in
crates/unifly/tests/cli_test.rs
- Add the request type to
unifly-api/src/command/requests/<group>.rs - Add the implementation to
unifly-api/src/controller/commands/<group>.rs - Add wiremock-based tests in
crates/unifly-api/tests/integration_client_test.rsorsession_client_test.rs - Re-export from
unifly-api/src/lib.rsif it is part of the public API - Update the CLI surface if the endpoint needs a command
- Define in
unifly-api/src/model/<entity>.rs - Add conversion logic in
unifly-api/src/convert.rs(handles both Integration and Session field shapes) - Add an
EntityCollection<T>tounifly-api/src/store/data_store.rsif the type is reactive - Re-export from
unifly-api/src/lib.rs
The skill at skills/unifly/SKILL.md has its own word budget and
structure (see skills/unifly/references/ for detailed content). When
adding or renaming CLI commands, update:
skills/unifly/SKILL.mdcommand inventory tableskills/unifly/references/commands.mdper-command sectionskills/unifly/references/concepts.mddual-API gate matrix if the command affects auth mode requirementsskills/unifly/references/workflows.mdif the change enables a new differentiator pattern worth highlighting
Update skills/unifly/examples/*.json if the payload shape for
--from-file changes.
README.md: end-user documentation, install, features, TUI screenshotsCONTRIBUTING.md: PR workflow, code style overviewCHANGELOG.md: version historyROADMAP.md: planned features and known gapsskills/unifly/SKILL.md: agent skill for using the CLI (not for developing it)Cargo.toml: workspace version, lints, dependencies, profilesjustfile: task recipes.github/workflows/: CI + release + docs workflowsdocs/images/: shipped screenshots and the animated TUI tour GIFaur/update-aur.sh: AUR package refresh script used byjust aur-update