Collector CLI · proposed product contract

mainskeeper command line

Mockups use the fixture clock ·

The mockups always show the incident fixture at 15:22, or the clock stated on the card, whatever demo state the rest of the prototype is in.

Proposed contract — not shipped software

Commands, flags, output layouts, JSON schemas and exit codes on this page are a design proposal for review. Every value shown is fictional demonstration data from the shared fixture.

Scope and principles

mainskeeper is the local companion to the mainskeeperd collector service. It runs on machines that have the service: macOS, Fedora, Raspberry Pi OS and Windows. ESP32 probes (illustrative) run firmware and have no CLI: their network and source settings are entered on the device’s local setup page, and the dashboard shows their status.

Monitoring only

There are no shutdown, reboot, outlet, self-test, power-setting or remote-command verbs. sources test performs one read. doctor prints fixes but never runs them. Typing mainskeeper shutdown is an unknown command (exit 2).

Local first, fleet second

Every screen answers “what does this collector see now?”. Fleet context — the site summary, open-incident count and a dashboard link — comes from the last server contact and is labelled that way.

Same truths as the dashboard

  • UPS reading age and collector contact age are separate.
  • “UPS estimate … est.” is never the same as “observed on battery”.
  • Missing values are “Unsupported” or “Not reported”, never 0.
  • History upload is shown on its own and never holds an incident open.
  • Reading freshness is relative to the source’s reporting interval I (allowance g = max(30 s, I/2)): On schedule ≤ I + g · Overdue ≤ 2I + g · Stale · last known beyond — always with the absolute age and the expected interval (“On schedule · read 7 min ago · expected every 10 min”). Thresholds come from MK.freshness, never from the CLI.
  • A network UPS is identified by namespace · protocol · host:port (· NUT UPS name). The same identity twice is “Already monitored by <collector> as <UPS>”; a trustworthy serial match at another identity is only a warning to confirm.
  • Incident times use First evidence · Grace period started · Declared · Acknowledged · Recovered · Resolved.
  • Site witnesses carry site expectations; an ordinary workstation that sleeps is “Asleep (ordinary workstation)” — no incident.

Reads by default, changes elevated

Read-only commands need no elevation. Commands that change this machine — enroll, sources add/auth/test/use/remove, config set and service actions — need sudo on macOS, Fedora and Raspberry Pi OS, and an elevated terminal (Administrator PowerShell, no sudo) on Windows. logs needs membership of the mainskeeper group, or elevation. Dashboard-managed settings are read-only here.

Commands

The complete proposed surface, generated from the single grammar in product/app/assets/config-contract.js (MK.cli.grammar) — the same rows the dashboard’s onboarding and collector pages use. Anything not listed here is not part of the contract.

Global options on every command: --json, --no-color, -q/--quiet, -h/--help. Acknowledging incidents is a dashboard action; the CLI does not acknowledge, resolve or silence anything.

Elevation

From MK.cli.elevation. Mockups show sudo wherever it is required on macOS, Fedora and Raspberry Pi OS; Windows examples run in an elevated terminal (Administrator PowerShell) and never use sudo. Running an elevated command without elevation changes nothing and exits 5.

Platforms

Every path, secret store, log location and elevation rule in the mockups comes from MK.cli.platform(id) for that host’s platform (config-contract.js); build-examples.js refuses to write an example that shows sudo, /etc or journalctl on Windows, or a Linux/Windows path on macOS. ESP32 probes have no CLI.

Configuration ownership

One owner per setting, from MK.config.contract — the same table the dashboard’s collector configuration page renders. Dashboard-managed settings are pulled at check-in (Pending → Applied, or Failed with a reason); config set refuses them with exit 1 and a dashboard link, except a local heartbeat_interval override. Source secrets are set only on the collector with a hidden prompt; the dashboard shows Configured / Missing / Rejected. Capabilities are properties of the hardware, not settings.

Exit codes

One table for every command. doctor can find several problems; it returns the lowest non-zero code it found, so the most fundamental fix comes first (3 → 4 → 5 → 6 → 7). Code 10 only applies when nothing failed outright.

Power state is data, not an error: status on a collector whose UPS is on battery exits 0. Scripts that need power state read ups[].power from status --json.

Output behaviour

stdout and stderr

  • stdout carries the result you asked for: the screen, or one JSON document.
  • stderr carries errors, warnings, progress, spinners and the enroll wait line.
  • Usage errors leave stdout empty, so pipes never receive half a document.
  • In the mockups, stderr lines have a grey bar in the left gutter. The bar is not part of the output.

Colour, NO_COLOR and TTY

  • Colour is on only when stdout is a TTY, TERM is not dumb, NO_COLOR is unset or empty, and --no-color is absent.
  • Every state is also a glyph and a word (● on utility, ■ on battery, ▲ warning, ✗ failed, ⇡ history arriving, ▨ Unsupported), so plain output loses nothing.
  • Spinners, in-place redraws and --watch need a TTY. Without one, the enroll wait prints one line per minute and --watch exits 2.

Interactive and headless

  • sources auth reads a source’s secret from the terminal with echo off. Without a TTY it exits 2.
  • sources use asks one yes/no question only when the source has a possible serial match to confirm. Without a TTY it then refuses (exit 1) and changes nothing.
  • enroll opens the browser in a desktop session: macOS or Windows, or Linux with DISPLAY/WAYLAND_DISPLAY outside SSH.
  • --headless, or no display, prints https://mainskeeper.example.net/enroll and a short code such as WDJB-MJHT.
  • The device polling secret and the issued credential are never printed, logged or put in a URL.
  • Other commands never prompt and never wait for input when stdin is not a TTY.

Width

  • Width comes from the terminal, then COLUMNS. Without a TTY the default is 80.
  • Below 100 columns (around 80), each key gets its own line and its values stack under it. Tables become one block per row.
  • At 100 columns or more (around 120), keys sit in a 16-column gutter, values share a line, and tables show columns.
  • Long values wrap with a hanging indent. Data is never cut off with “…”. Commands to copy always stay on one line.

Time

  • Times are shown in the collector’s local zone with its abbreviation, e.g. 15:22 PDT. The header carries the full date.
  • Times from another day show the weekday and date, e.g. Tue 29 Sep 13:22 PDT.
  • Ages appear next to the time (“3 s ago”). Uncertain times carry ±90 s.
  • History is listed by observation time, with the receipt time when it explains a delay.

Fleet context and links

  • status ends with one dashboard link: the open incident first, then the site, then the collector.
  • The site summary and fleet incident count come from the last server contact.
  • When the server cannot be reached, it says “fleet context unavailable” with the last sync time. It never shows a stale count as current.

Secrets

  • Source secrets (NUT username/password, SNMP v3 secrets, SNMP community) are set only on the collector with mainskeeper sources auth <source> (elevated: sudo, or an elevated terminal on Windows) — a hidden prompt — and kept in that platform’s local secret store (see Platforms). ESP32 probes take them on their local setup page.
  • They never appear as arguments, in shell history, logs, JSON or the dashboard. Everywhere they are shown as Configured, Missing or Rejected (+ last test time).
  • The collector’s enrollment identity and the device polling secret are never printed or put in a URL.
  • Short enrollment codes such as WDJB-MJHT are safe to show: they work once, expire after 10 minutes and need an owner’s approval.

Wording

The CLI uses the dashboard glossary: “Contact lost — cause unknown”, “Connectivity failure confirmed by local probe”, “Last known · 15:10”, “UPS estimate … est.”, “Observed on battery”, “Received late · replayed”, “History arriving”, “No records”, “UPS communication lost”, “On schedule · Overdue · Stale · last known”, “Already monitored by bw-pi-01 as BW Office UPS”, “Possibly the same UPS as BW Rack UPS (serial match) — confirm”, “Configured — waiting for UPS role”, “Saved — monitoring starts when the UPS role is applied (managed in the dashboard)”, “Site witness”, “Asleep (ordinary workstation)”, “Configured · Missing · Rejected”, “Pending · Applied · Failed”, “Local override”, “Set locally”.

Backfill and replay

Historical backfill updates history at observation time and does not generate duplicate or obsolete notifications. Fresh evidence of an active condition remains alertable while a backlog drains. Ordinary transmission latency does not make a reading historical replay.

Replay = a record received more than 2 min after its observation time while the collector was reconnecting and draining its local buffer. Normal transmission latency (seconds) is not replay.

JSON output

--json prints exactly one UTF-8 JSON document on stdout, with no ANSI escapes, whatever the TTY or colour settings. Failures print an error document to stdout and a human message to stderr; the exit code is still set.

JSON conventions
Field or ruleContract
schemamainskeeper.status/v1, mainskeeper.queue/v1, mainskeeper.error/v1 (e.g. error-enrollment-revoked.json) … Within v1, changes only add fields. A breaking change becomes v2.
generatedAt, *AtISO 8601 with offset (2026-09-30T15:22:00-07:00), plus timeZone (IANA)
Durations and sizesIntegers whose unit is in the name: contactAgeSec, drainEstimateMin, pendingBytes
Readings{value, unit, kind: "measured" | "estimate", support}. Unsupported means value: null with support: "unsupported". A supported value that is missing has support: "supported", value: null and state: "not-reported". Never 0.
Power vs. collectorups[].power (utility | battery | unknown), observedOnBatterySec (measured) and readings.runtime (estimate) are separate from health.exitCode. status exits 0 whenever the query succeeds.
ups[].freshness{state: "on-schedule" | "overdue" | "stale", ageSeconds, expectedIntervalSeconds, allowanceSeconds} = MK.freshness.reading(readingAgeSec, reportingIntervalSec).json. BW Office UPS (600 s cadence, 420 s old): {"state":"on-schedule","ageSeconds":420,"expectedIntervalSeconds":600,"allowanceSeconds":300}. The rule in words is in freshnessRule.
ups[].identityNetwork UPSs only: {namespace, protocol, host, port, upsName, key}, key = namespace|protocol|host|port|upsName (e.g. broadwater-lan|snmp|10.0.20.15|161|)
IncidentsfirstEvidenceAt, graceStartAt, declaredAt, acknowledgment {by, at, note}, recoveredAt, resolvedAt — the dashboard’s six labels. status, recovery, notifications and sync are separate attributes.
queue.gaps[].kindno-records (none ever existed, e.g. probe offline) vs dropped (records existed and were lost at the cap)
fictionalPresent only in these mockups. It marks fixture data.

Mockup controls

Every mockup can be shown at 80 or 120 columns in a dark, light or NO_COLOR terminal. These controls set every mockup at once; each card also has its own controls. Terminal blocks scroll sideways inside their frame on narrow screens.

stderr line (gutter marker, not output) Fixture — the shared fictional fleet at the stated clock Illustrative — placeholder host (lab-pi, lab-win) outside the fleet, for faults the fixture never shows Catalogue / account — rack-fedora-01, den-win-01, studio-mbp after their enrollment codes are approved

Help and command discovery

Enroll

This is the same request the dashboard shows on its approval page: studio-mbp, code WDJB-MJHT, issued 15:19:30 PDT, expiring 15:29:30 PDT. --site and --roles are proposals; the approver decides. The outcomes are alternatives to one another. Re-enrolling (--reenroll) uses the fixture’s “revoked” scenario.

Status

Three collectors at 15:22: Duplex recovered with history arriving, Broadwater with a UPS on battery (still exit 0), and a connectivity-only ordinary workstation. status exits 0 whenever the query succeeds; power and freshness are in the output and in ups[].power / ups[].freshness. Broadwater shows both cadences: BW Rack UPS every 30 s (4 s old) and BW Office UPS every 600 s (420 s old) — both On schedule.

Sources

Discovery and tests only read. Each screen explains why a source is used, unused, busy or failing, and shows secret status as Configured, Missing or Rejected.

Network UPS (NUT / SNMP)

The CLI side of the dashboard’s network-UPS journey: add the endpoint → set the secret locally with a hidden prompt → test read access and identify the UPS → use it. Steps 1–4 are one proposed journey on duplex-pi-probe (duplex-lan). Every screen prints the identity: namespace · protocol · host:port · UPS name. The other cards: one NUT server with two UPSs — rackups and deskups both register on bw-pi-01 (exit 0 each); the same identity again — “Already monitored by bw-pi-01 as BW Office UPS” (exit 1, no second asset); unreachable (exit 6); a possible serial match on rack-fedora-01 (warning, exit 10 — confirm, never blocked); secret rejected (exit 6); unsupported capabilities (exit 10). NUT ships with MainsKeeper.

UPS role and collector gating

Account path on studio-mbp (macOS ordinary workstation, connectivity role, site Home → namespace home-lan). Saving and testing never need the UPS role; the test runs on the collector, so an unreachable cloud does not block it (the result uploads later). sources use without the UPS role applied is saved — “Saved — monitoring starts when the UPS role is applied (managed in the dashboard)”, exit 0 — and roles show then shows desired on, applied pending until the next check-in. Readings start at or after that check-in.

Platform examples

Windows (elevated terminal, ProgramData, DPAPI, Event Viewer), macOS (Application Support, System keychain, /Library/Logs) and the ESP32 explanation. Fedora and Raspberry Pi OS (sudo, /etc/mainskeeper, journalctl) appear throughout the other groups.

Roles and config

Roles are dashboard-managed: roles show shows desired and applied, roles set explains and links. config set accepts local-owned keys only (plus the heartbeat override); one example runs in an elevated Windows terminal.

Doctor

One finding per line with a glyph and a word. Every failure ends with an exact next command. The fixes only touch the local service, files and permissions — never equipment.

Queue

Draining is the fixture’s duplex-pi-probe. Stalled, pressure and dropped use the fixture’s example buffers on a placeholder host. queue exits 0 whenever the buffer can be read; the backlog state is in the output and in state (JSON).

Service, logs and version

Variant matrix

mainskeeper status on duplex-pi-probe in all six combinations of width (80, 120) and terminal (dark, light, NO_COLOR). This view ignores the controls above.

Machine-readable examples

Valid JSON, written by build-examples.js from the same source as these mockups. The files below are exactly what --json prints. The plain-text transcripts are the NO_COLOR output at both widths.