Skip to main content

max / mountaineer

sysop info: version field + server/sysadmin profile filter Schema additions to roles.toml (both optional, both default-tolerant): - `profile = "server" | "sysadmin"` — absent = both builds - `version_cmd = ["bin", "--flag"]` — argv used to display a version line Twelve graphical-layer roles (compositor through power) are now tagged `profile = "sysadmin"`. `sysop info --server` / `--sysadmin` flags filter the list accordingly; default behavior is unchanged. Detail view now shows a `version` field above `packages`. Versions are queried lazily on first detail-view entry (Command::output, first non-empty line of stdout/stderr), and cached per-session. Roles without a `version_cmd` show "—".
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-19 21:07 UTC
Commit: 1f262c6642211cbf1219c3da8c59438008d1ff24
Parent: e9e14d1
4 files changed, +167 insertions, -9 deletions
@@ -6,6 +6,11 @@
6 6 #
7 7 # Order matters: roles are presented in the list view in the order they
8 8 # appear here. Group by concern (core → network → graphical).
9 + #
10 + # Optional fields:
11 + # profile = "server" | "sysadmin" — defaults to both builds if absent
12 + # version_cmd = ["bin", "--flag"] — argv used by `sysop info <role>`
13 + # to display a version string
9 14
10 15 # --- core ---
11 16
@@ -29,6 +34,7 @@ description = "Login interactive shell with structured pipelines"
29 34 tool = "nushell"
30 35 packages = ["nushell"]
31 36 config_paths = ["~/.config/nushell/"]
37 + version_cmd = ["nu", "--version"]
32 38
33 39 [[role]]
34 40 name = "editor"
@@ -36,6 +42,7 @@ description = "Modal editor with LSP and tree-sitter"
36 42 tool = "helix"
37 43 packages = ["helix"]
38 44 config_paths = ["~/.config/helix/"]
45 + version_cmd = ["hx", "--version"]
39 46
40 47 [[role]]
41 48 name = "multiplexer"
@@ -43,6 +50,7 @@ description = "Terminal multiplexer with discoverable keybindings"
43 50 tool = "zellij"
44 51 packages = ["zellij"]
45 52 config_paths = ["~/.config/zellij/"]
53 + version_cmd = ["zellij", "--version"]
46 54
47 55 [[role]]
48 56 name = "container-runtime"
@@ -50,6 +58,7 @@ description = "Rootless container runtime"
50 58 tool = "podman"
51 59 packages = ["podman"]
52 60 config_paths = ["/etc/containers/", "~/.config/containers/"]
61 + version_cmd = ["podman", "--version"]
53 62
54 63 [[role]]
55 64 name = "filesystem"
@@ -57,6 +66,7 @@ description = "Root filesystem and pool/dataset management"
57 66 tool = "ZFS"
58 67 packages = ["zfs", "zfs-lts"]
59 68 config_paths = ["/etc/zfs/"]
69 + version_cmd = ["zfs", "--version"]
60 70
61 71 [[role]]
62 72 name = "ssh"
@@ -64,6 +74,7 @@ description = "SSH server, hardened defaults"
64 74 tool = "OpenSSH"
65 75 packages = ["openssh"]
66 76 config_paths = ["/etc/ssh/sshd_config", "/etc/ssh/sshd_config.d/"]
77 + version_cmd = ["ssh", "-V"]
67 78
68 79 # --- network ---
69 80
@@ -73,6 +84,7 @@ description = "Stateful packet filter, default-deny"
73 84 tool = "nftables"
74 85 packages = ["nftables"]
75 86 config_paths = ["/etc/nftables.nft", "/etc/nftables.d/"]
87 + version_cmd = ["nft", "--version"]
76 88
77 89 [[role]]
78 90 name = "resolver"
@@ -80,6 +92,7 @@ description = "Local validating recursive DNS resolver"
80 92 tool = "unbound"
81 93 packages = ["unbound"]
82 94 config_paths = ["/etc/unbound/unbound.conf", "/etc/unbound/conf.d/"]
95 + version_cmd = ["unbound", "-V"]
83 96
84 97 [[role]]
85 98 name = "time"
@@ -87,6 +100,7 @@ description = "NTP client and time synchronisation"
87 100 tool = "chrony"
88 101 packages = ["chrony"]
89 102 config_paths = ["/etc/chrony/chrony.conf"]
103 + version_cmd = ["chronyd", "-v"]
90 104
91 105 [[role]]
92 106 name = "network-wired"
@@ -108,6 +122,7 @@ description = "Overlay mesh VPN with mutual auth"
108 122 tool = "Nebula"
109 123 packages = ["nebula"]
110 124 config_paths = ["/etc/nebula/"]
125 + version_cmd = ["nebula", "-version"]
111 126
112 127 [[role]]
113 128 name = "reverse-proxy"
@@ -115,6 +130,7 @@ description = "HTTPS-terminating reverse proxy with auto-TLS"
115 130 tool = "Caddy"
116 131 packages = ["caddy"]
117 132 config_paths = ["/etc/caddy/Caddyfile", "/etc/caddy/conf.d/"]
133 + version_cmd = ["caddy", "version"]
118 134
119 135 [[role]]
120 136 name = "mail-outbound"
@@ -122,6 +138,7 @@ description = "Outbound mail relay (notifications, alerts)"
122 138 tool = "msmtp"
123 139 packages = ["msmtp"]
124 140 config_paths = ["/etc/msmtprc"]
141 + version_cmd = ["msmtp", "--version"]
125 142
126 143 # --- observability / data ---
127 144
@@ -131,6 +148,7 @@ description = "Structured log collection and routing"
131 148 tool = "Vector"
132 149 packages = ["vector"]
133 150 config_paths = ["/etc/vector/vector.toml"]
151 + version_cmd = ["vector", "--version"]
134 152
135 153 [[role]]
136 154 name = "logs-viewer"
@@ -138,6 +156,7 @@ description = "Interactive log browser"
138 156 tool = "lnav"
139 157 packages = ["lnav"]
140 158 config_paths = ["~/.config/lnav/"]
159 + version_cmd = ["lnav", "-V"]
141 160
142 161 [[role]]
143 162 name = "secrets"
@@ -145,6 +164,7 @@ description = "Age-encrypted secrets with sops integration"
145 164 tool = "age + sops"
146 165 packages = ["age", "sops"]
147 166 config_paths = ["~/.config/sops/", ".sops.yaml"]
167 + version_cmd = ["age", "--version"]
148 168
149 169 [[role]]
150 170 name = "backup"
@@ -152,6 +172,7 @@ description = "Encrypted, deduplicated backups"
152 172 tool = "restic"
153 173 packages = ["restic"]
154 174 config_paths = ["~/.config/restic/"]
175 + version_cmd = ["restic", "version"]
155 176
156 177 # --- graphical layer (Sysadmin build only) ---
157 178
@@ -161,6 +182,8 @@ description = "Tiling Wayland compositor"
161 182 tool = "river"
162 183 packages = ["river"]
163 184 config_paths = ["~/.config/river/init"]
185 + profile = "sysadmin"
186 + version_cmd = ["river", "-version"]
164 187
165 188 [[role]]
166 189 name = "terminal"
@@ -168,6 +191,8 @@ description = "Wayland terminal emulator"
168 191 tool = "foot"
169 192 packages = ["foot"]
170 193 config_paths = ["~/.config/foot/foot.ini"]
194 + profile = "sysadmin"
195 + version_cmd = ["foot", "--version"]
171 196
172 197 [[role]]
173 198 name = "bar"
@@ -175,6 +200,8 @@ description = "Status bar (read-only system surface)"
175 200 tool = "yambar"
176 201 packages = ["yambar"]
177 202 config_paths = ["~/.config/yambar/config.yml"]
203 + profile = "sysadmin"
204 + version_cmd = ["yambar", "--version"]
178 205
179 206 [[role]]
180 207 name = "launcher"
@@ -182,6 +209,8 @@ description = "Application launcher"
182 209 tool = "fuzzel"
183 210 packages = ["fuzzel"]
184 211 config_paths = ["~/.config/fuzzel/fuzzel.ini"]
212 + profile = "sysadmin"
213 + version_cmd = ["fuzzel", "--version"]
185 214
186 215 [[role]]
187 216 name = "clipboard"
@@ -189,6 +218,7 @@ description = "Clipboard history"
189 218 tool = "cliphist"
190 219 packages = ["cliphist"]
191 220 config_paths = []
221 + profile = "sysadmin"
192 222
193 223 [[role]]
194 224 name = "notifications"
@@ -196,6 +226,8 @@ description = "Wayland notification daemon"
196 226 tool = "fnott"
197 227 packages = ["fnott"]
198 228 config_paths = ["~/.config/fnott/fnott.ini"]
229 + profile = "sysadmin"
230 + version_cmd = ["fnott", "--version"]
199 231
200 232 [[role]]
201 233 name = "screenshots"
@@ -203,6 +235,7 @@ description = "Region capture, annotation, pipeline"
203 235 tool = "grim + slurp + swappy"
204 236 packages = ["grim", "slurp", "swappy"]
205 237 config_paths = ["~/.config/swappy/"]
238 + profile = "sysadmin"
206 239
207 240 [[role]]
208 241 name = "lock"
@@ -210,6 +243,8 @@ description = "Screen locker"
210 243 tool = "swaylock"
211 244 packages = ["swaylock"]
212 245 config_paths = ["~/.config/swaylock/config"]
246 + profile = "sysadmin"
247 + version_cmd = ["swaylock", "--version"]
213 248
214 249 [[role]]
215 250 name = "idle"
@@ -217,6 +252,8 @@ description = "Idle event dispatcher"
217 252 tool = "swayidle"
218 253 packages = ["swayidle"]
219 254 config_paths = ["~/.config/swayidle/config"]
255 + profile = "sysadmin"
256 + version_cmd = ["swayidle", "-v"]
220 257
221 258 [[role]]
222 259 name = "wallpaper"
@@ -224,6 +261,7 @@ description = "Wallpaper / background"
224 261 tool = "swaybg"
225 262 packages = ["swaybg"]
226 263 config_paths = []
264 + profile = "sysadmin"
227 265
228 266 [[role]]
229 267 name = "audio"
@@ -231,6 +269,8 @@ description = "Audio server and session manager"
231 269 tool = "pipewire + wireplumber"
232 270 packages = ["pipewire", "wireplumber"]
233 271 config_paths = ["/etc/pipewire/", "~/.config/pipewire/", "~/.config/wireplumber/"]
272 + profile = "sysadmin"
273 + version_cmd = ["pipewire", "--version"]
234 274
235 275 [[role]]
236 276 name = "seat"
@@ -238,6 +278,7 @@ description = "Seat / device management for non-root Wayland"
238 278 tool = "seatd"
239 279 packages = ["seatd"]
240 280 config_paths = []
281 + profile = "sysadmin"
241 282
242 283 [[role]]
243 284 name = "brightness"
@@ -245,6 +286,8 @@ description = "Backlight control"
245 286 tool = "brightnessctl"
246 287 packages = ["brightnessctl"]
247 288 config_paths = []
289 + profile = "sysadmin"
290 + version_cmd = ["brightnessctl", "--version"]
248 291
249 292 [[role]]
250 293 name = "power"
@@ -252,3 +295,4 @@ description = "Laptop power management profiles"
252 295 tool = "tlp"
253 296 packages = ["tlp"]
254 297 config_paths = ["/etc/tlp.conf", "/etc/tlp.d/"]
298 + profile = "sysadmin"
@@ -1,4 +1,4 @@
1 - use crate::roles::{self, Role};
1 + use crate::roles::{self, Profile, Role};
2 2 use anyhow::Result;
3 3 use crossterm::event::{self, Event, KeyCode};
4 4 use ratatui::Frame;
@@ -6,6 +6,8 @@ use ratatui::layout::{Constraint, Layout, Rect};
6 6 use ratatui::style::Style;
7 7 use ratatui::text::{Line, Span};
8 8 use ratatui::widgets::Paragraph;
9 + use std::collections::HashMap;
10 + use std::process::Command;
9 11 use sysop_tui::{Footer, GlobalAction, classify, hint, layout, palette, selection, style};
10 12
11 13 enum View {
@@ -13,8 +15,17 @@ enum View {
13 15 Detail { idx: usize },
14 16 }
15 17
16 - pub fn run(initial: Option<String>) -> Result<i32> {
17 - let roles = roles::load()?;
18 + pub fn run(initial: Option<String>, profile: Option<Profile>) -> Result<i32> {
19 + let all_roles = roles::load()?;
20 + let roles: Vec<Role> = all_roles
21 + .into_iter()
22 + .filter(|r| r.included_in(profile))
23 + .collect();
24 +
25 + if roles.is_empty() {
26 + eprintln!("sysop info: no roles match the requested profile");
27 + return Ok(1);
28 + }
18 29
19 30 let mut view = match initial.as_deref() {
20 31 None => View::List { selected: 0 },
@@ -30,8 +41,10 @@ pub fn run(initial: Option<String>) -> Result<i32> {
30 41 },
31 42 };
32 43
44 + let mut versions: HashMap<String, Option<String>> = HashMap::new();
45 +
33 46 let mut terminal = ratatui::init();
34 - let result = event_loop(&mut terminal, &roles, &mut view);
47 + let result = event_loop(&mut terminal, &roles, &mut view, &mut versions);
35 48 ratatui::restore();
36 49 result.map(|_| 0)
37 50 }
@@ -40,9 +53,14 @@ fn event_loop(
40 53 terminal: &mut ratatui::DefaultTerminal,
41 54 roles: &[Role],
42 55 view: &mut View,
56 + versions: &mut HashMap<String, Option<String>>,
43 57 ) -> Result<()> {
44 58 loop {
45 - terminal.draw(|frame| draw(frame, roles, view))?;
59 + if let View::Detail { idx } = view {
60 + ensure_version(&roles[*idx], versions);
61 + }
62 +
63 + terminal.draw(|frame| draw(frame, roles, view, versions))?;
46 64
47 65 let Event::Key(key) = event::read()? else {
48 66 continue;
@@ -86,7 +104,41 @@ fn event_loop(
86 104 }
87 105 }
88 106
89 - fn draw(frame: &mut Frame, roles: &[Role], view: &View) {
107 + fn ensure_version(role: &Role, versions: &mut HashMap<String, Option<String>>) {
108 + if versions.contains_key(&role.name) {
109 + return;
110 + }
111 + versions.insert(role.name.clone(), query_version(role));
112 + }
113 +
114 + fn query_version(role: &Role) -> Option<String> {
115 + let argv = role.version_cmd.as_ref()?;
116 + let (bin, args) = argv.split_first()?;
117 + let output = Command::new(bin)
118 + .args(args)
119 + .output()
120 + .ok()?;
121 + let stdout = String::from_utf8_lossy(&output.stdout);
122 + let stderr = String::from_utf8_lossy(&output.stderr);
123 + let combined = if stdout.trim().is_empty() {
124 + stderr
125 + } else {
126 + stdout
127 + };
128 + let first = combined.lines().next()?.trim();
129 + if first.is_empty() {
130 + None
131 + } else {
132 + Some(first.to_string())
133 + }
134 + }
135 +
136 + fn draw(
137 + frame: &mut Frame,
138 + roles: &[Role],
139 + view: &View,
140 + versions: &HashMap<String, Option<String>>,
141 + ) {
90 142 let [body, foot] = layout::split(frame.area());
91 143 match view {
92 144 View::List { selected } => {
@@ -101,7 +153,7 @@ fn draw(frame: &mut Frame, roles: &[Role], view: &View) {
101 153 );
102 154 }
103 155 View::Detail { idx } => {
104 - draw_detail(frame, &roles[*idx], body);
156 + draw_detail(frame, &roles[*idx], versions.get(&roles[*idx].name), body);
105 157 frame.render_widget(
106 158 &Footer::new([
107 159 hint("j/k", "next/prev role"),
@@ -138,7 +190,12 @@ fn list_widget(roles: &[Role], selected: usize) -> Paragraph<'_> {
138 190 Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG))
139 191 }
140 192
141 - fn draw_detail(frame: &mut Frame, role: &Role, area: Rect) {
193 + fn draw_detail(
194 + frame: &mut Frame,
195 + role: &Role,
196 + version: Option<&Option<String>>,
197 + area: Rect,
198 + ) {
142 199 let [_, right] =
143 200 Layout::horizontal([Constraint::Length(2), Constraint::Min(0)]).areas(area);
144 201 let [_, body] =
@@ -149,6 +206,14 @@ fn draw_detail(frame: &mut Frame, role: &Role, area: Rect) {
149 206
150 207 lines.push(field_line("role", &role.name, label_w));
151 208 lines.push(field_line("tool", &role.tool, label_w));
209 +
210 + let version_value: String = match version {
211 + Some(Some(v)) => v.clone(),
212 + Some(None) => "—".into(),
213 + None => "…".into(),
214 + };
215 + lines.push(field_line("version", &version_value, label_w));
216 +
152 217 lines.push(field_line(
153 218 "packages",
154 219 &role.packages.join(", "),
@@ -8,6 +8,7 @@ mod storage;
8 8 mod wifi;
9 9
10 10 use clap::{Parser, Subcommand};
11 + use roles::Profile;
11 12
12 13 #[derive(Parser)]
13 14 #[command(name = "sysop", about = "Mountaineer operator interface")]
@@ -22,6 +23,12 @@ enum Command {
22 23 Info {
23 24 /// Role name (omit for the full list)
24 25 role: Option<String>,
26 + /// Limit to roles in the Server build
27 + #[arg(long, conflicts_with = "sysadmin")]
28 + server: bool,
29 + /// Limit to roles in the Sysadmin build
30 + #[arg(long)]
31 + sysadmin: bool,
25 32 },
26 33 /// Login tip rotation (on/off/print)
27 34 Motd {
@@ -72,7 +79,16 @@ enum MotdAction {
72 79 fn main() -> anyhow::Result<()> {
73 80 let cli = Cli::parse();
74 81 let code = match cli.command {
75 - Command::Info { role } => info::run(role)?,
82 + Command::Info { role, server, sysadmin } => {
83 + let profile = if server {
84 + Some(Profile::Server)
85 + } else if sysadmin {
86 + Some(Profile::Sysadmin)
87 + } else {
88 + None
89 + };
90 + info::run(role, profile)?
91 + }
76 92 Command::Motd { action } => match action {
77 93 None => motd::status()?,
78 94 Some(MotdAction::On) => motd::set(true)?,
@@ -3,6 +3,18 @@ use serde::Deserialize;
3 3
4 4 const EMBEDDED: &str = include_str!("../data/roles.toml");
5 5
6 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
7 + #[serde(rename_all = "lowercase")]
8 + pub enum Profile {
9 + /// Server build only
10 + Server,
11 + /// Sysadmin build only (graphical layer)
12 + Sysadmin,
13 + /// Both builds
14 + #[serde(other)]
15 + Both,
16 + }
17 +
6 18 #[derive(Debug, Clone, Deserialize)]
7 19 pub struct Role {
8 20 pub name: String,
@@ -10,6 +22,27 @@ pub struct Role {
10 22 pub tool: String,
11 23 pub packages: Vec<String>,
12 24 pub config_paths: Vec<String>,
25 + /// Which build(s) this role appears in. Defaults to both.
26 + #[serde(default = "default_profile")]
27 + pub profile: Profile,
28 + /// Optional argv to invoke for `--version` display.
29 + /// First element is the program, the rest are arguments.
30 + #[serde(default)]
31 + pub version_cmd: Option<Vec<String>>,
32 + }
33 +
34 + fn default_profile() -> Profile {
35 + Profile::Both
36 + }
37 +
38 + impl Role {
39 + pub fn included_in(&self, filter: Option<Profile>) -> bool {
40 + match (filter, self.profile) {
41 + (None, _) => true,
42 + (Some(_), Profile::Both) => true,
43 + (Some(f), p) => f == p,
44 + }
45 + }
13 46 }
14 47
15 48 #[derive(Debug, Deserialize)]