feat(cli): every command explains itself

`punktfunk help` was one usage blob, and `punktfunk pair --help` was worse than
nothing: the flag fell through to the verb, which read "--help" as its subject,
printed a terse usage line to stderr and exited 5. For the command that is about to
be the documented door for scripts and plugins (Playnite shells to `library --json`),
"self-documenting" has to actually hold.

Now `punktfunk help <command>` — and `--help`/`-h` after any verb, caught BEFORE
dispatch so no verb can mistake the flag for its subject — prints that command's own
page: flags, what lands on stdout vs stderr, and which exit code means what, which is
the part a script author actually needs. Help goes to stdout and exits 0; an unknown
topic refuses with 5. A unit test walks USAGE and asserts every advertised verb has a
help page that leads with its own invocation, so the overview and the pages cannot
drift apart, and an integration test runs the REAL binary over both spellings.

`reachable` also stops scolding: probing an unsaved address is that verb's documented
use, but it resolved through the saved-host path first, whose "pair it first" advice
printed before the probe ran. It resolves quietly now — same lookup, no lecture.

Verified on the Windows CI runner (clippy -D warnings, fmt, 8/8 tests, and the built
punktfunk.exe by hand: overview, per-verb pages, quiet `reachable` exit 2) and in the
Linux CI image (same gates, 8/8). One field note from the hand run: the exe needs the
FFmpeg DLLs beside it or on PATH — true of the session binary already, and the MSIX
ships them next to both, so packaging is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 10:58:44 +02:00
co-authored by Claude Fable 5
parent ff01db67ff
commit 8e396b8391
2 changed files with 254 additions and 18 deletions
+185 -18
View File
@@ -59,7 +59,131 @@ punktfunk — the Punktfunk client, headless
A <host-ref> is a saved host's id, its name, or an address — the same reference a
punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not found,
6 needs a person.";
6 needs a person.
\"punktfunk help <command>\" (or any command with --help) explains that command.";
/// The long help for one verb — `punktfunk help <verb>`, or `--help` after the verb.
/// Each entry documents its flags and the behaviour a script would need to know
/// (what goes to stdout vs stderr, and which exit codes mean what).
fn verb_help(verb: &str) -> Option<&'static str> {
Some(match verb {
"pair" => {
"\
punktfunk pair <host[:port]> — enrol this device with a host (PIN ceremony)
--pin N the PIN the host is showing; without it the command asks, and
refuses (exit 6) when there is no terminal to ask on
--name LABEL the label the host files this device under
(default: this machine's name)
Pairing verifies the host end-to-end and pins its fingerprint in the saved-hosts
store, so every later connect — here, in the desktop client or the console — is
silent. The port defaults to 9777. Prints `paired <addr>:<port> fp=<hex>` on
success; exit 3 if the host refuses or the PIN is wrong."
}
"hosts" => {
"\
punktfunk hosts — the saved-hosts store (shared with the desktop client)
punktfunk hosts list [--probe] [--json]
Every saved host, name TAB addr:port TAB paired/trusted TAB state.
--probe asks each host directly (no mDNS, so routed/VPN hosts answer
too); --json emits one object with per-host detail, profiles included.
punktfunk hosts add <host[:port]> [--name LABEL] [--fp HEX]
Save a host by address — the door for a box mDNS never sees (Tailscale,
another subnet). Without --fp it is a placeholder to pair later; with a
64-hex fingerprint it is pinned immediately (still unpaired).
punktfunk hosts forget <host-ref>
Remove a saved host, its pinned fingerprint included. A later connect
must pair or trust it again."
}
"wake" => {
"\
punktfunk wake <host-ref> [--wait] — Wake-on-LAN
Sends a magic packet to a saved host's MAC (learned from its advert while it
was awake; exit 5 if none is known yet). With --wait, keeps sending every 6 s
and polls presence every second for up to 90 s, exiting 0 the moment the host
answers — the same cadence every graphical shell uses."
}
"library" => {
"\
punktfunk library <host-ref> [--json] — the host's game library
TSV on stdout by default (id TAB store TAB title), one game per line; --json
emits {\"games\":[…]} for tools — the Playnite importer shells to exactly
this. Needs a paired host (exit 6 otherwise)."
}
"launch" => {
"\
punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec] [--fullscreen]
Start a stream — waking the host first if it is asleep and its MAC is known.
The stream runs in the punktfunk-session renderer; this command supervises it
and relays its lifecycle to stderr.
--game ID ask the host to launch this library title into the stream
--profile REF use a settings profile (id or name) for this connect only;
without it the host's own binding applies
--fullscreen start the stream window fullscreen
--exec become the session process instead of supervising it — the
gamescope-wrapper mode, where the launched process must BE
the streaming one for focus and lifecycle to work
Exit 0 when the stream ends cleanly, 2 connect failed, 3 the host no longer
trusts this device (re-pair), 4 the renderer could not start."
}
"open" => {
"\
punktfunk open <punktfunk://…> — follow a punktfunk:// link, headless
Same parser and same refusal rules as clicking the link in a shell: a
contradicted fingerprint refuses and says so, an ambiguous name refuses
rather than guessing, and an unknown host is never trusted from a URL —
that is a decision for a person, at a surface that can show the fingerprint
(exit 6 points at `punktfunk pair`). --exec as in launch."
}
"reachable" => {
"\
punktfunk reachable <host-ref> — one bounded reachability probe
Asks the host directly (no mDNS), so routed/VPN hosts answer too. The
reference may be an unsaved address — this verb answers \"can I reach it\",
not \"do I know it\". Exit 0 reachable, 2 not; one line either way."
}
"speed-test" => {
"\
punktfunk speed-test <host-ref> [--json] — measure the real data plane
Runs the host's bandwidth probe over an actual session connect and prints the
measured throughput, loss, and the bitrate it recommends. Deliberately does
NOT apply the result: which layer a bitrate belongs in (a bound profile, the
global default) is a decision the GUI makes with the user, and a CLI silently
rewriting settings would be exactly the surprise that rule exists to prevent."
}
"profiles" => {
"\
punktfunk profiles list [--json] — the settings profiles on this device
One line per profile: id TAB name TAB how many settings it overrides.
Profiles are created and edited in the desktop client; a connect uses one via
`punktfunk launch --profile` or a punktfunk:// link that names it."
}
"reset" => {
"\
punktfunk reset — forget every saved host and reset stream settings
Asks for confirmation, and refuses (exit 6) when there is no terminal to ask
on. This device's identity keypair is deliberately kept, so hosts that knew
this machine still recognise it after re-pairing; delete the identity files
from the config directory for a true factory reset."
}
_ => return None,
})
}
/// The value after `--flag`, if any.
fn value(args: &[String], flag: &str) -> Option<String> {
@@ -138,6 +262,12 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
return OK;
};
let rest: Vec<String> = args[1..].to_vec();
// `--help`/`-h` after any verb prints that verb's help — before dispatch, so a verb
// never mistakes the flag for its subject (`punktfunk pair -h` must not dial "-h").
if rest.iter().any(|a| a == "--help" || a == "-h") {
println!("{}", verb_help(&verb).unwrap_or(USAGE));
return OK;
}
match verb.as_str() {
"pair" => pair(&rest),
"hosts" => hosts(&rest),
@@ -149,10 +279,22 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
"speed-test" => speed_test(&rest),
"profiles" => profiles(&rest),
"reset" => reset(),
"-h" | "--help" | "help" => {
println!("{USAGE}");
OK
}
"-h" | "--help" | "help" => match positional(&rest, 0) {
None => {
println!("{USAGE}");
OK
}
Some(topic) => match verb_help(&topic) {
Some(h) => {
println!("{h}");
OK
}
None => {
eprintln!("no command called \"{topic}\"\n\n{USAGE}");
UNRESOLVED
}
},
},
"--version" | "version" => {
println!("punktfunk {}", env!("CARGO_PKG_VERSION"));
OK
@@ -600,10 +742,17 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
return UNRESOLVED;
};
// An address that isn't saved is still a legitimate thing to probe — this verb answers
// "can I reach this?", not "do I know this?".
let (addr, port) = match resolve(&reference) {
Ok((known, i)) => (known.hosts[i].addr.clone(), known.hosts[i].port),
Err(_) => split_host_port(&reference),
// "can I reach this?", not "do I know this?". Resolved QUIETLY for the same reason:
// `resolve`'s "pair it first" advice is for verbs that need a saved host, and printing
// it here would scold the exact usage this verb documents.
let known = KnownHosts::load();
let link = DeepLink {
host_ref: reference.clone(),
..Default::default()
};
let (addr, port) = match deeplink::resolve_host(&link, &known) {
HostResolution::Known(i) => (known.hosts[i].addr.clone(), known.hosts[i].port),
_ => split_host_port(&reference),
};
if punktfunk_core::client::NativeClient::probe(&addr, port, PROBE_TIMEOUT) {
println!("reachable {addr}:{port}");
@@ -769,15 +918,7 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
/// Is stdin a terminal? Decides whether a verb may ask a question or must refuse with
/// [`NEEDS_INTERACTION`] — a CLI that blocks a CI job on a prompt is a hang, not a UX.
fn is_tty() -> bool {
#[cfg(unix)]
{
// SAFETY-free check: `isatty` via the std file descriptor, without libc.
std::io::IsTerminal::is_terminal(&std::io::stdin())
}
#[cfg(not(unix))]
{
std::io::IsTerminal::is_terminal(&std::io::stdin())
}
std::io::IsTerminal::is_terminal(&std::io::stdin())
}
#[cfg(test)]
@@ -820,6 +961,32 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
assert_eq!(split_host_port("desk:nope"), ("desk:nope".into(), 9777));
}
/// Every advertised verb documents itself — a USAGE line without a help entry is a
/// promise `help <verb>` breaks. The overview and each entry must also name the verb.
#[test]
fn every_usage_verb_has_help() {
for verb in [
"pair",
"hosts",
"wake",
"library",
"launch",
"open",
"reachable",
"speed-test",
"profiles",
"reset",
] {
let h = verb_help(verb).unwrap_or_else(|| panic!("no help for {verb}"));
assert!(
h.starts_with(&format!("punktfunk {verb}")),
"help for {verb} must lead with its own invocation"
);
assert!(USAGE.contains(verb), "USAGE must advertise {verb}");
}
assert!(verb_help("bogus").is_none());
}
#[test]
fn value_reads_the_argument_after_its_flag() {
let a = argv(&["--game", "steam:570", "--exec"]);
+69
View File
@@ -0,0 +1,69 @@
//! Runs the REAL `punktfunk` binary and pins its self-documentation contract: help goes
//! to stdout and exits 0, an unknown verb refuses on stderr with the not-found code.
//!
//! This exists so CI *executes* the shipped binary at least once per platform. A binary
//! that compiles but is the wrong program passes every build/clippy/fmt gate — that is
//! exactly how 0.22.0 shipped a stub as `punktfunk-session` — and only a gate that runs
//! the thing catches the class. The help paths are the right probe: they touch no config
//! stores and no network, so they are safe on any runner.
#![cfg(any(target_os = "linux", windows))]
use std::process::Command;
fn punktfunk(args: &[&str]) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_punktfunk"))
.args(args)
.output()
.expect("run punktfunk")
}
#[test]
fn bare_and_help_print_the_overview_on_stdout() {
for args in [&[][..], &["help"][..], &["--help"][..], &["-h"][..]] {
let out = punktfunk(args);
assert!(out.status.success(), "{args:?} must exit 0");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("punktfunk pair"),
"{args:?} overview lists the verbs"
);
assert!(
stdout.contains("help <command>"),
"{args:?} overview points at per-verb help"
);
}
}
#[test]
fn per_verb_help_answers_both_spellings() {
for args in [
&["help", "launch"][..],
&["launch", "--help"][..],
&["launch", "-h"][..],
] {
let out = punktfunk(args);
assert!(out.status.success(), "{args:?} must exit 0");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("--exec"),
"{args:?} documents launch's flags"
);
}
}
#[test]
fn version_prints_the_crate_version() {
let out = punktfunk(&["--version"]);
assert!(out.status.success());
assert!(String::from_utf8_lossy(&out.stdout).contains(env!("CARGO_PKG_VERSION")));
}
#[test]
fn unknown_verbs_refuse_with_the_not_found_code() {
let out = punktfunk(&["frobnicate"]);
assert_eq!(out.status.code(), Some(5), "unknown verb exits 5");
assert!(String::from_utf8_lossy(&out.stderr).contains("unknown command"));
let out = punktfunk(&["help", "frobnicate"]);
assert_eq!(out.status.code(), Some(5), "unknown help topic exits 5");
}