Skip to main content

max / mountaineer-sysop

Add `sysop info` — first subcommand, role→tool registry Two-screen TUI (list of roles, detail per role) backed by a compiled-in data/roles.toml registry of 33 roles spanning core, network, and the Sysadmin-only graphical layer. clap-driven CLI: \`sysop info\` opens the list, \`sysop info <role>\` jumps to detail, unknown role exits 1 with stderr guidance. Exercises every sysop-tui primitive end-to-end: flatwhite palette, footer hints, selection marker, reserved global keys, hue helpers. Runs on the macOS host with no Mountaineer infrastructure. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-18 01:19 UTC
Commit: 1b08d0aae96520a5591bd824c60d0ceb46d3849e
Parent: 37dc921
8 files changed, +736 insertions, -14 deletions
M Cargo.lock +229 -1
@@ -9,6 +9,56 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
9 9 checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
10 10
11 11 [[package]]
12 + name = "anstream"
13 + version = "1.0.0"
14 + source = "registry+https://github.com/rust-lang/crates.io-index"
15 + checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
16 + dependencies = [
17 + "anstyle",
18 + "anstyle-parse",
19 + "anstyle-query",
20 + "anstyle-wincon",
21 + "colorchoice",
22 + "is_terminal_polyfill",
23 + "utf8parse",
24 + ]
25 +
26 + [[package]]
27 + name = "anstyle"
28 + version = "1.0.14"
29 + source = "registry+https://github.com/rust-lang/crates.io-index"
30 + checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
31 +
32 + [[package]]
33 + name = "anstyle-parse"
34 + version = "1.0.0"
35 + source = "registry+https://github.com/rust-lang/crates.io-index"
36 + checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
37 + dependencies = [
38 + "utf8parse",
39 + ]
40 +
41 + [[package]]
42 + name = "anstyle-query"
43 + version = "1.1.5"
44 + source = "registry+https://github.com/rust-lang/crates.io-index"
45 + checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
46 + dependencies = [
47 + "windows-sys 0.61.2",
48 + ]
49 +
50 + [[package]]
51 + name = "anstyle-wincon"
52 + version = "3.0.11"
53 + source = "registry+https://github.com/rust-lang/crates.io-index"
54 + checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
55 + dependencies = [
56 + "anstyle",
57 + "once_cell_polyfill",
58 + "windows-sys 0.61.2",
59 + ]
60 +
61 + [[package]]
12 62 name = "anyhow"
13 63 version = "1.0.102"
14 64 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -42,6 +92,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
42 92 checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
43 93
44 94 [[package]]
95 + name = "clap"
96 + version = "4.6.1"
97 + source = "registry+https://github.com/rust-lang/crates.io-index"
98 + checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
99 + dependencies = [
100 + "clap_builder",
101 + "clap_derive",
102 + ]
103 +
104 + [[package]]
105 + name = "clap_builder"
106 + version = "4.6.0"
107 + source = "registry+https://github.com/rust-lang/crates.io-index"
108 + checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
109 + dependencies = [
110 + "anstream",
111 + "anstyle",
112 + "clap_lex",
113 + "strsim",
114 + ]
115 +
116 + [[package]]
117 + name = "clap_derive"
118 + version = "4.6.1"
119 + source = "registry+https://github.com/rust-lang/crates.io-index"
120 + checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
121 + dependencies = [
122 + "heck",
123 + "proc-macro2",
124 + "quote",
125 + "syn",
126 + ]
127 +
128 + [[package]]
129 + name = "clap_lex"
130 + version = "1.1.0"
131 + source = "registry+https://github.com/rust-lang/crates.io-index"
132 + checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
133 +
134 + [[package]]
135 + name = "colorchoice"
136 + version = "1.0.5"
137 + source = "registry+https://github.com/rust-lang/crates.io-index"
138 + checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
139 +
140 + [[package]]
45 141 name = "compact_str"
46 142 version = "0.8.1"
47 143 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -161,6 +257,12 @@ dependencies = [
161 257 ]
162 258
163 259 [[package]]
260 + name = "hashbrown"
261 + version = "0.17.1"
262 + source = "registry+https://github.com/rust-lang/crates.io-index"
263 + checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
264 +
265 + [[package]]
164 266 name = "heck"
165 267 version = "0.5.0"
166 268 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -173,6 +275,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
173 275 checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
174 276
175 277 [[package]]
278 + name = "indexmap"
279 + version = "2.14.0"
280 + source = "registry+https://github.com/rust-lang/crates.io-index"
281 + checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
282 + dependencies = [
283 + "equivalent",
284 + "hashbrown 0.17.1",
285 + ]
286 +
287 + [[package]]
176 288 name = "indoc"
177 289 version = "2.0.7"
178 290 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -195,6 +307,12 @@ dependencies = [
195 307 ]
196 308
197 309 [[package]]
310 + name = "is_terminal_polyfill"
311 + version = "1.70.2"
312 + source = "registry+https://github.com/rust-lang/crates.io-index"
313 + checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
314 +
315 + [[package]]
198 316 name = "itertools"
199 317 version = "0.13.0"
200 318 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -242,10 +360,16 @@ version = "0.12.5"
242 360 source = "registry+https://github.com/rust-lang/crates.io-index"
243 361 checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
244 362 dependencies = [
245 - "hashbrown",
363 + "hashbrown 0.15.5",
246 364 ]
247 365
248 366 [[package]]
367 + name = "memchr"
368 + version = "2.8.0"
369 + source = "registry+https://github.com/rust-lang/crates.io-index"
370 + checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
371 +
372 + [[package]]
249 373 name = "mio"
250 374 version = "1.2.0"
251 375 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -258,6 +382,12 @@ dependencies = [
258 382 ]
259 383
260 384 [[package]]
385 + name = "once_cell_polyfill"
386 + version = "1.70.2"
387 + source = "registry+https://github.com/rust-lang/crates.io-index"
388 + checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
389 +
390 + [[package]]
261 391 name = "parking_lot"
262 392 version = "0.12.5"
263 393 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -366,6 +496,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
366 496 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
367 497
368 498 [[package]]
499 + name = "serde"
500 + version = "1.0.228"
501 + source = "registry+https://github.com/rust-lang/crates.io-index"
502 + checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
503 + dependencies = [
504 + "serde_core",
505 + "serde_derive",
506 + ]
507 +
508 + [[package]]
509 + name = "serde_core"
510 + version = "1.0.228"
511 + source = "registry+https://github.com/rust-lang/crates.io-index"
512 + checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
513 + dependencies = [
514 + "serde_derive",
515 + ]
516 +
517 + [[package]]
518 + name = "serde_derive"
519 + version = "1.0.228"
520 + source = "registry+https://github.com/rust-lang/crates.io-index"
521 + checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
522 + dependencies = [
523 + "proc-macro2",
524 + "quote",
525 + "syn",
526 + ]
527 +
528 + [[package]]
529 + name = "serde_spanned"
530 + version = "0.6.9"
531 + source = "registry+https://github.com/rust-lang/crates.io-index"
532 + checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
533 + dependencies = [
534 + "serde",
535 + ]
536 +
537 + [[package]]
369 538 name = "signal-hook"
370 539 version = "0.3.18"
371 540 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -452,9 +621,12 @@ name = "sysop"
452 621 version = "0.1.0"
453 622 dependencies = [
454 623 "anyhow",
624 + "clap",
455 625 "crossterm",
456 626 "ratatui",
627 + "serde",
457 628 "sysop-tui",
629 + "toml",
458 630 ]
459 631
460 632 [[package]]
@@ -466,6 +638,47 @@ dependencies = [
466 638 ]
467 639
468 640 [[package]]
641 + name = "toml"
642 + version = "0.8.23"
643 + source = "registry+https://github.com/rust-lang/crates.io-index"
644 + checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
645 + dependencies = [
646 + "serde",
647 + "serde_spanned",
648 + "toml_datetime",
649 + "toml_edit",
650 + ]
651 +
652 + [[package]]
653 + name = "toml_datetime"
654 + version = "0.6.11"
655 + source = "registry+https://github.com/rust-lang/crates.io-index"
656 + checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
657 + dependencies = [
658 + "serde",
659 + ]
660 +
661 + [[package]]
662 + name = "toml_edit"
663 + version = "0.22.27"
664 + source = "registry+https://github.com/rust-lang/crates.io-index"
665 + checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
666 + dependencies = [
667 + "indexmap",
668 + "serde",
669 + "serde_spanned",
670 + "toml_datetime",
671 + "toml_write",
672 + "winnow",
673 + ]
674 +
675 + [[package]]
676 + name = "toml_write"
677 + version = "0.1.2"
678 + source = "registry+https://github.com/rust-lang/crates.io-index"
679 + checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
680 +
681 + [[package]]
469 682 name = "unicode-ident"
470 683 version = "1.0.24"
471 684 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -501,6 +714,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
501 714 checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
502 715
503 716 [[package]]
717 + name = "utf8parse"
718 + version = "0.2.2"
719 + source = "registry+https://github.com/rust-lang/crates.io-index"
720 + checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
721 +
722 + [[package]]
504 723 name = "wasi"
505 724 version = "0.11.1+wasi-snapshot-preview1"
506 725 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -615,3 +834,12 @@ name = "windows_x86_64_msvc"
615 834 version = "0.52.6"
616 835 source = "registry+https://github.com/rust-lang/crates.io-index"
617 836 checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
837 +
838 + [[package]]
839 + name = "winnow"
840 + version = "0.7.15"
841 + source = "registry+https://github.com/rust-lang/crates.io-index"
842 + checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
843 + dependencies = [
844 + "memchr",
845 + ]
M Cargo.toml +3
@@ -12,3 +12,6 @@ sysop-tui = { path = "crates/sysop-tui" }
12 12 ratatui = "0.29"
13 13 crossterm = "0.28"
14 14 anyhow = "1"
15 + clap = { version = "4", features = ["derive"] }
16 + serde = { version = "1", features = ["derive"] }
17 + toml = "0.8"
@@ -14,3 +14,6 @@ sysop-tui.workspace = true
14 14 ratatui.workspace = true
15 15 crossterm.workspace = true
16 16 anyhow.workspace = true
17 + clap.workspace = true
18 + serde.workspace = true
19 + toml.workspace = true
@@ -0,0 +1,254 @@
1 + # Mountaineer role → tool registry.
2 + #
3 + # Each role is a stable name for a job the OS needs done. The tool filling
4 + # the role is implementation detail and may change between releases; the
5 + # role name does not. `sysop info <role>` reads this file.
6 + #
7 + # Order matters: roles are presented in the list view in the order they
8 + # appear here. Group by concern (core → network → graphical).
9 +
10 + # --- core ---
11 +
12 + [[role]]
13 + name = "init"
14 + description = "Service supervisor and runlevel manager"
15 + tool = "s6 + s6-rc"
16 + packages = ["s6", "s6-rc"]
17 + config_paths = ["/etc/s6/", "/etc/s6-rc/"]
18 +
19 + [[role]]
20 + name = "shell-login"
21 + description = "POSIX /bin/sh"
22 + tool = "busybox ash"
23 + packages = ["busybox"]
24 + config_paths = ["/etc/profile", "/etc/profile.d/"]
25 +
26 + [[role]]
27 + name = "shell-interactive"
28 + description = "Login interactive shell with structured pipelines"
29 + tool = "nushell"
30 + packages = ["nushell"]
31 + config_paths = ["~/.config/nushell/"]
32 +
33 + [[role]]
34 + name = "editor"
35 + description = "Modal editor with LSP and tree-sitter"
36 + tool = "helix"
37 + packages = ["helix"]
38 + config_paths = ["~/.config/helix/"]
39 +
40 + [[role]]
41 + name = "multiplexer"
42 + description = "Terminal multiplexer with discoverable keybindings"
43 + tool = "zellij"
44 + packages = ["zellij"]
45 + config_paths = ["~/.config/zellij/"]
46 +
47 + [[role]]
48 + name = "container-runtime"
49 + description = "Rootless container runtime"
50 + tool = "podman"
51 + packages = ["podman"]
52 + config_paths = ["/etc/containers/", "~/.config/containers/"]
53 +
54 + [[role]]
55 + name = "filesystem"
56 + description = "Root filesystem and pool/dataset management"
57 + tool = "ZFS"
58 + packages = ["zfs", "zfs-lts"]
59 + config_paths = ["/etc/zfs/"]
60 +
61 + [[role]]
62 + name = "ssh"
63 + description = "SSH server, hardened defaults"
64 + tool = "OpenSSH"
65 + packages = ["openssh"]
66 + config_paths = ["/etc/ssh/sshd_config", "/etc/ssh/sshd_config.d/"]
67 +
68 + # --- network ---
69 +
70 + [[role]]
71 + name = "firewall"
72 + description = "Stateful packet filter, default-deny"
73 + tool = "nftables"
74 + packages = ["nftables"]
75 + config_paths = ["/etc/nftables.nft", "/etc/nftables.d/"]
76 +
77 + [[role]]
78 + name = "resolver"
79 + description = "Local validating recursive DNS resolver"
80 + tool = "unbound"
81 + packages = ["unbound"]
82 + config_paths = ["/etc/unbound/unbound.conf", "/etc/unbound/conf.d/"]
83 +
84 + [[role]]
85 + name = "time"
86 + description = "NTP client and time synchronisation"
87 + tool = "chrony"
88 + packages = ["chrony"]
89 + config_paths = ["/etc/chrony/chrony.conf"]
90 +
91 + [[role]]
92 + name = "network-wired"
93 + description = "Wired interface configuration"
94 + tool = "ifupdown-ng"
95 + packages = ["ifupdown-ng"]
96 + config_paths = ["/etc/network/interfaces", "/etc/network/interfaces.d/"]
97 +
98 + [[role]]
99 + name = "network-wireless"
100 + description = "Wireless interface daemon"
101 + tool = "iwd"
102 + packages = ["iwd"]
103 + config_paths = ["/etc/iwd/", "/var/lib/iwd/"]
104 +
105 + [[role]]
106 + name = "mesh"
107 + description = "Overlay mesh VPN with mutual auth"
108 + tool = "Nebula"
109 + packages = ["nebula"]
110 + config_paths = ["/etc/nebula/"]
111 +
112 + [[role]]
113 + name = "reverse-proxy"
114 + description = "HTTPS-terminating reverse proxy with auto-TLS"
115 + tool = "Caddy"
116 + packages = ["caddy"]
117 + config_paths = ["/etc/caddy/Caddyfile", "/etc/caddy/conf.d/"]
118 +
119 + [[role]]
120 + name = "mail-outbound"
121 + description = "Outbound mail relay (notifications, alerts)"
122 + tool = "msmtp"
123 + packages = ["msmtp"]
124 + config_paths = ["/etc/msmtprc"]
125 +
126 + # --- observability / data ---
127 +
128 + [[role]]
129 + name = "logs-router"
130 + description = "Structured log collection and routing"
131 + tool = "Vector"
132 + packages = ["vector"]
133 + config_paths = ["/etc/vector/vector.toml"]
134 +
135 + [[role]]
136 + name = "logs-viewer"
137 + description = "Interactive log browser"
138 + tool = "lnav"
139 + packages = ["lnav"]
140 + config_paths = ["~/.config/lnav/"]
141 +
142 + [[role]]
143 + name = "secrets"
144 + description = "Age-encrypted secrets with sops integration"
145 + tool = "age + sops"
146 + packages = ["age", "sops"]
147 + config_paths = ["~/.config/sops/", ".sops.yaml"]
148 +
149 + [[role]]
150 + name = "backup"
151 + description = "Encrypted, deduplicated backups"
152 + tool = "restic"
153 + packages = ["restic"]
154 + config_paths = ["~/.config/restic/"]
155 +
156 + # --- graphical layer (Sysadmin build only) ---
157 +
158 + [[role]]
159 + name = "compositor"
160 + description = "Tiling Wayland compositor"
161 + tool = "river"
162 + packages = ["river"]
163 + config_paths = ["~/.config/river/init"]
164 +
165 + [[role]]
166 + name = "terminal"
167 + description = "Wayland terminal emulator"
168 + tool = "foot"
169 + packages = ["foot"]
170 + config_paths = ["~/.config/foot/foot.ini"]
171 +
172 + [[role]]
173 + name = "bar"
174 + description = "Status bar (read-only system surface)"
175 + tool = "yambar"
176 + packages = ["yambar"]
177 + config_paths = ["~/.config/yambar/config.yml"]
178 +
179 + [[role]]
180 + name = "launcher"
181 + description = "Application launcher"
182 + tool = "fuzzel"
183 + packages = ["fuzzel"]
184 + config_paths = ["~/.config/fuzzel/fuzzel.ini"]
185 +
186 + [[role]]
187 + name = "clipboard"
188 + description = "Clipboard history"
189 + tool = "cliphist"
190 + packages = ["cliphist"]
191 + config_paths = []
192 +
193 + [[role]]
194 + name = "notifications"
195 + description = "Wayland notification daemon"
196 + tool = "fnott"
197 + packages = ["fnott"]
198 + config_paths = ["~/.config/fnott/fnott.ini"]
199 +
200 + [[role]]
201 + name = "screenshots"
202 + description = "Region capture, annotation, pipeline"
203 + tool = "grim + slurp + swappy"
204 + packages = ["grim", "slurp", "swappy"]
205 + config_paths = ["~/.config/swappy/"]
206 +
207 + [[role]]
208 + name = "lock"
209 + description = "Screen locker"
210 + tool = "swaylock"
211 + packages = ["swaylock"]
212 + config_paths = ["~/.config/swaylock/config"]
213 +
214 + [[role]]
215 + name = "idle"
216 + description = "Idle event dispatcher"
217 + tool = "swayidle"
218 + packages = ["swayidle"]
219 + config_paths = ["~/.config/swayidle/config"]
220 +
221 + [[role]]
222 + name = "wallpaper"
223 + description = "Wallpaper / background"
224 + tool = "swaybg"
225 + packages = ["swaybg"]
226 + config_paths = []
227 +
228 + [[role]]
229 + name = "audio"
230 + description = "Audio server and session manager"
231 + tool = "pipewire + wireplumber"
232 + packages = ["pipewire", "wireplumber"]
233 + config_paths = ["/etc/pipewire/", "~/.config/pipewire/", "~/.config/wireplumber/"]
234 +
235 + [[role]]
236 + name = "seat"
237 + description = "Seat / device management for non-root Wayland"
238 + tool = "seatd"
239 + packages = ["seatd"]
240 + config_paths = []
241 +
242 + [[role]]
243 + name = "brightness"
244 + description = "Backlight control"
245 + tool = "brightnessctl"
246 + packages = ["brightnessctl"]
247 + config_paths = []
248 +
249 + [[role]]
250 + name = "power"
251 + description = "Laptop power management profiles"
252 + tool = "tlp"
253 + packages = ["tlp"]
254 + config_paths = ["/etc/tlp.conf", "/etc/tlp.d/"]
@@ -0,0 +1,186 @@
1 + use crate::roles::{self, Role};
2 + use anyhow::Result;
3 + use crossterm::event::{self, Event, KeyCode};
4 + use ratatui::Frame;
5 + use ratatui::layout::{Constraint, Layout, Rect};
6 + use ratatui::style::Style;
7 + use ratatui::text::{Line, Span};
8 + use ratatui::widgets::Paragraph;
9 + use sysop_tui::{Footer, GlobalAction, classify, hint, layout, palette, selection, style};
10 +
11 + enum View {
12 + List { selected: usize },
13 + Detail { idx: usize },
14 + }
15 +
16 + pub fn run(initial: Option<String>) -> Result<i32> {
17 + let roles = roles::load()?;
18 +
19 + let mut view = match initial.as_deref() {
20 + None => View::List { selected: 0 },
21 + Some(name) => match roles::find(&roles, name) {
22 + Some(_) => View::Detail {
23 + idx: roles.iter().position(|r| r.name == name).unwrap(),
24 + },
25 + None => {
26 + eprintln!("sysop: unknown role: {name}");
27 + eprintln!(" run `sysop info` for the list");
28 + return Ok(1);
29 + }
30 + },
31 + };
32 +
33 + let mut terminal = ratatui::init();
34 + let result = event_loop(&mut terminal, &roles, &mut view);
35 + ratatui::restore();
36 + result.map(|_| 0)
37 + }
38 +
39 + fn event_loop(
40 + terminal: &mut ratatui::DefaultTerminal,
41 + roles: &[Role],
42 + view: &mut View,
43 + ) -> Result<()> {
44 + loop {
45 + terminal.draw(|frame| draw(frame, roles, view))?;
46 +
47 + let Event::Key(key) = event::read()? else {
48 + continue;
49 + };
50 + let action = classify(key);
51 +
52 + match view {
53 + View::List { selected } => match action {
54 + GlobalAction::Quit => return Ok(()),
55 + GlobalAction::Passthrough => match key.code {
56 + KeyCode::Char('j') | KeyCode::Down if *selected + 1 < roles.len() => {
57 + *selected += 1;
58 + }
59 + KeyCode::Char('k') | KeyCode::Up => {
60 + *selected = selected.saturating_sub(1);
61 + }
62 + KeyCode::Enter => {
63 + *view = View::Detail { idx: *selected };
64 + }
65 + _ => {}
66 + },
67 + _ => {}
68 + },
69 + View::Detail { idx } => match action {
70 + GlobalAction::Quit => return Ok(()),
71 + GlobalAction::Back => {
72 + *view = View::List { selected: *idx };
73 + }
74 + GlobalAction::Passthrough => match key.code {
75 + KeyCode::Char('j') | KeyCode::Down if *idx + 1 < roles.len() => {
76 + *idx += 1;
77 + }
78 + KeyCode::Char('k') | KeyCode::Up => {
79 + *idx = idx.saturating_sub(1);
80 + }
81 + _ => {}
82 + },
83 + _ => {}
84 + },
85 + }
86 + }
87 + }
88 +
89 + fn draw(frame: &mut Frame, roles: &[Role], view: &View) {
90 + let [body, foot] = layout::split(frame.area());
91 + match view {
92 + View::List { selected } => {
93 + frame.render_widget(list_widget(roles, *selected), body);
94 + frame.render_widget(
95 + &Footer::new([
96 + hint("j/k", "move"),
97 + hint("Enter", "details"),
98 + hint("q", "quit"),
99 + ]),
100 + foot,
101 + );
102 + }
103 + View::Detail { idx } => {
104 + draw_detail(frame, &roles[*idx], body);
105 + frame.render_widget(
106 + &Footer::new([
107 + hint("j/k", "next/prev role"),
108 + hint("Esc", "back"),
109 + hint("q", "quit"),
110 + ]),
111 + foot,
112 + );
113 + }
114 + }
115 + }
116 +
117 + fn list_widget(roles: &[Role], selected: usize) -> Paragraph<'_> {
118 + let name_w = roles.iter().map(|r| r.name.len()).max().unwrap_or(0);
119 + let lines: Vec<Line> = roles
120 + .iter()
121 + .enumerate()
122 + .map(|(i, r)| {
123 + let is_sel = i == selected;
124 + let marker = if is_sel { selection::MARKER } else { " " };
125 + let line = Line::from(vec![
126 + Span::raw(format!(" {marker} ")),
127 + Span::raw(format!("{:<name_w$}", r.name)),
128 + Span::raw(" "),
129 + style::dim(r.tool.clone()),
130 + ]);
131 + if is_sel {
132 + line.style(selection::selected_style())
133 + } else {
134 + line
135 + }
136 + })
137 + .collect();
138 + Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG))
139 + }
140 +
141 + fn draw_detail(frame: &mut Frame, role: &Role, area: Rect) {
142 + let [_, right] =
143 + Layout::horizontal([Constraint::Length(2), Constraint::Min(0)]).areas(area);
144 + let [_, body] =
145 + Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(right);
146 +
147 + let label_w = 12;
148 + let mut lines: Vec<Line> = Vec::new();
149 +
150 + lines.push(field_line("role", &role.name, label_w));
151 + lines.push(field_line("tool", &role.tool, label_w));
152 + lines.push(field_line(
153 + "packages",
154 + &role.packages.join(", "),
155 + label_w,
156 + ));
157 +
158 + if role.config_paths.is_empty() {
159 + lines.push(field_line("config", "(none)", label_w));
160 + } else {
161 + lines.push(field_line("config", &role.config_paths[0], label_w));
162 + for extra in &role.config_paths[1..] {
163 + lines.push(continuation_line(extra, label_w));
164 + }
165 + }
166 +
167 + lines.push(Line::from(""));
168 + lines.push(field_line("description", &role.description, label_w));
169 +
170 + let para = Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG));
171 + frame.render_widget(para, body);
172 + }
173 +
174 + fn field_line(label: &str, value: &str, label_w: usize) -> Line<'static> {
175 + Line::from(vec![
176 + style::dim(format!("{:<label_w$} ", label)),
177 + style::fg(value.to_string()),
178 + ])
179 + }
180 +
181 + fn continuation_line(value: &str, label_w: usize) -> Line<'static> {
182 + Line::from(vec![
183 + Span::raw(" ".repeat(label_w + 2)),
184 + style::fg(value.to_string()),
185 + ])
186 + }
@@ -1,3 +1,28 @@
1 + mod info;
2 + mod roles;
3 +
4 + use clap::{Parser, Subcommand};
5 +
6 + #[derive(Parser)]
7 + #[command(name = "sysop", about = "Mountaineer operator interface")]
8 + struct Cli {
9 + #[command(subcommand)]
10 + command: Command,
11 + }
12 +
13 + #[derive(Subcommand)]
14 + enum Command {
15 + /// Show which tool fills a role and where its config lives
16 + Info {
17 + /// Role name (omit for the full list)
18 + role: Option<String>,
19 + },
20 + }
21 +
1 22 fn main() -> anyhow::Result<()> {
2 - Ok(())
23 + let cli = Cli::parse();
24 + let code = match cli.command {
25 + Command::Info { role } => info::run(role)?,
26 + };
27 + std::process::exit(code);
3 28 }
@@ -0,0 +1,27 @@
1 + use anyhow::{Context, Result};
2 + use serde::Deserialize;
3 +
4 + const EMBEDDED: &str = include_str!("../data/roles.toml");
5 +
6 + #[derive(Debug, Clone, Deserialize)]
7 + pub struct Role {
8 + pub name: String,
9 + pub description: String,
10 + pub tool: String,
11 + pub packages: Vec<String>,
12 + pub config_paths: Vec<String>,
13 + }
14 +
15 + #[derive(Debug, Deserialize)]
16 + struct Registry {
17 + role: Vec<Role>,
18 + }
19 +
20 + pub fn load() -> Result<Vec<Role>> {
21 + let reg: Registry = toml::from_str(EMBEDDED).context("parse embedded roles.toml")?;
22 + Ok(reg.role)
23 + }
24 +
25 + pub fn find<'a>(roles: &'a [Role], name: &str) -> Option<&'a Role> {
26 + roles.iter().find(|r| r.name == name)
27 + }
M docs/todo.md +8 -12
@@ -1,22 +1,18 @@
1 1 # mountaineer-sysop — todo
2 2
3 - Status: Pre-v0. Active: `sysop info <role>` as first subcommand. Next: `sysop motd on/off`. Primitives crate has palette, layout, footer, selection, keys; exercised by `cargo run --example smoketest -p sysop-tui`.
3 + Status: Pre-v0. Active: `sysop motd on/off`. `sysop info` lands end-to-end (list + detail TUI, `cargo run -p sysop -- info` on host). Primitives exercised by `cargo run --example smoketest -p sysop-tui`.
4 4
5 5 ## Phase 1 — sysop-tui primitives (open items)
6 6
7 - - [ ] Visually verify the smoketest on a flatwhite-friendly terminal (Berkeley Mono / Iosevka, true-color) and tune `SELECTION_BG` if the row contrast is too soft
8 - - [ ] Decide whether `Footer` should right-align when there's a status segment on the left (deferred until first subcommand needs it)
9 - - [ ] Help overlay primitive (`?` action currently just classifies; no overlay renderer yet)
7 + - [ ] Visually verify on a flatwhite-friendly terminal (Iosevka, true-color) and tune `SELECTION_BG` if the row contrast is too soft
8 + - [ ] `Footer` right-align option (deferred until a subcommand needs a left status segment)
9 + - [ ] Help overlay primitive (`?` action classifies; no overlay renderer yet)
10 10
11 - ## Phase 2 — first subcommand: `sysop info <role>`
11 + ## Phase 2 — `sysop info` (open items)
12 12
13 - The smallest useful subcommand. Resolves the role→tool mapping (principle 15). Read-only, single screen, exercises sysop-tui end-to-end.
14 -
15 - - [ ] Role→tool table source-of-truth (location TBD — `STACK.md`? embedded TOML? generated?)
16 - - [ ] CLI parsing (clap derive, single positional arg)
17 - - [ ] TUI screen: role name, current tool, config path, package name, version
18 - - [ ] `sysop info` (no arg) → list view of all roles, selectable, Enter → detail screen
19 - - [ ] Exit code 0 on found, 1 on unknown role
13 + - [ ] Version field on the detail screen (requires per-tool version queries — shell out to `<tool> --version`, parse, cache; defer until subprocess plumbing is settled)
14 + - [ ] Runtime override: layer `/etc/mountaineer/roles.toml` on top of the compiled-in defaults (principle 2 — operator can see/edit). Currently only `include_str!` source
15 + - [ ] Server vs Sysadmin profile filter — graphical-layer roles should be hidden in `--server` mode
20 16
21 17 ## Phase 3 — `sysop motd on/off`
22 18