Coverage Report

Created: 2026-11-04 02:14

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
super-tts-cli/src/main.rs
Line
Count
Source
1
// SPDX-License-Identifier: GPL-3.0-only
2
//! Super TTS CLI — talks to the daemon over the HTTP protocol on a Unix socket.
3
//!
4
//! Auth flow is delegated to `super_tts_shared::daemon::session`, the
5
//! shared client-side session helper. All client tokens (CLI, settings
6
//! app, applet) live under the same keyring service
7
//! (`super-tts-session`) keyed by per-app `AppId`. The CLI's entry is
8
//! `super-tts-cli@super-tts-session`.
9
//!
10
//! `session::with_token` handles cache-hit, consent-popup-on-miss, and
11
//! `invalid_session` retry transparently — handlers below just write
12
//! the network call and propagate the daemon's error string.
13
//!
14
//! For tests / CI: set `SUPER_TTS_AUTO_APPROVE=1` in the daemon's
15
//! environment so the consent popup is bypassed and `/auth/request`
16
//! auto-approves.
17
18
use anyhow::{Context, Result, anyhow};
19
use clap::{Arg, Command, value_parser};
20
use std::io::Read;
21
use std::path::PathBuf;
22
use super_tts_shared::daemon::http_client;
23
use super_tts_shared::daemon::session::{self, AppId};
24
use super_tts_shared::validation::get_http_socket_path;
25
26
const APP_ID: AppId = super_tts_shared::daemon::session::app_id("super-tts-cli");
27
const APP_NAME: &str = "Super TTS CLI";
28
const SCOPES: &[&str] = &["speak", "status"];
29
30
#[tokio::main]
31
7
async fn main() -> Result<()> {
32
    // The CLI previously had no logging at all; initialize it like the other
33
    // binaries (RUST_LOG wins, else Info) (Tier 2 #6).
34
7
    super_tts_shared::logging::init();
35
36
    // Honor SUPER_TTS_KEYRING_MOCK before any session-token access so
37
    // automated shells / CI (and our own integration tests) don't block on
38
    // the system secret service. No-op when the env var is unset.
39
7
    session::install_mock_keyring_if_requested();
40
41
7
    let matches = Command::new("super-tts-cli")
42
7
        .version(env!("CARGO_PKG_VERSION"))
43
7
        .about("Super TTS command-line client (HTTP protocol)")
44
7
        .arg(
45
7
            Arg::new("socket")
46
7
                .long("socket")
47
7
                .help("Path to the daemon HTTP Unix socket")
48
7
                .value_parser(value_parser!(PathBuf))
49
7
                .global(true),
50
        )
51
7
        .subcommand(Command::new("ping").about("Check that the daemon is reachable"))
52
7
        .subcommand(Command::new("status").about("Print daemon state (model + device)"))
53
7
        .subcommand(
54
7
            Command::new("speak")
55
7
                .about("Speak text with the active model")
56
7
                .arg(
57
7
                    Arg::new("text")
58
7
                        .help("The text to speak; omit to read it from stdin")
59
7
                        .num_args(0..)
60
7
                        .trailing_var_arg(true),
61
                ), // No --voice/--language/--speed/--instructions: how the
62
                   // machine speaks is the user's configuration, not a flag on the
63
                   // thing that makes it talk. Set a voice with the settings app
64
                   // or `POST /pipeline/{stage}/model/{model}/voice`.
65
        )
66
7
        .subcommand(Command::new("stop").about("Stop the utterance now playing"))
67
7
        .subcommand(
68
7
            Command::new("logout")
69
7
                .about("Forget the cached session token (forces re-consent next call)"),
70
        )
71
7
        .get_matches();
72
73
7
    let socket_path = matches
74
7
        .get_one::<PathBuf>("socket")
75
7
        .cloned()
76
7
        .unwrap_or_else(get_http_socket_path);
77
78
7
    match matches.subcommand() {
79
7
        Some(("ping", _)) => {
80
7
            
run_with_token1
(
socket_path1
.
clone1
(), |t|
cmd_ping1
(
socket_path1
.
clone1
(),
t1
))
81
7
                .
await1
82
7
                .
context1
("ping failed")
83
7
        }
84
7
        Some((
"status"6
, _)) => {
85
7
            
run_with_token1
(
socket_path1
.
clone1
(), |t|
cmd_status1
(
socket_path1
.
clone1
(),
t1
))
86
7
                .
await1
87
7
                .
context1
("status failed")
88
7
        }
89
7
        Some((
"speak"5
,
sub2
)) => {
90
7
            let 
text2
=
read_text2
(
sub2
)
?0
;
91
7
            
run_with_token2
(
socket_path2
.
clone2
(), |t|
{2
92
7
                
cmd_speak2
(
socket_path2
.
clone2
(),
t2
,
text2
.
clone2
())
93
7
            
}2
)
94
7
            .
await2
95
7
            .
context2
("speak failed")
96
7
        }
97
7
        Some((
"stop"3
, _)) => {
98
7
            
run_with_token2
(
socket_path2
.
clone2
(), |t|
cmd_stop2
(
socket_path2
.
clone2
(),
t2
))
99
7
                .
await2
100
7
                .
context2
("stop failed")
101
7
        }
102
7
        Some((
"logout"1
, _)) =>
cmd_logout1
(),
103
7
        _ => {
104
7
            
println!0
("Run with --help for usage.");
105
7
            
Ok(())0
106
7
        }
107
7
    }
108
7
}
109
110
/// Thin wrapper over `session::with_token` that adapts its
111
/// `Result<T, String>` return into `anyhow::Result<T>` and supplies
112
/// the CLI's app identity. All commands route through here.
113
6
async fn run_with_token<F, Fut, T>(socket_path: PathBuf, op: F) -> Result<T>
114
6
where
115
6
    F: Fn(String) -> Fut,
116
6
    Fut: std::future::Future<Output = super_tts_shared::daemon::http_client::HttpResult<T>>,
117
6
{
118
6
    session::with_token(socket_path, APP_ID, APP_NAME, SCOPES, op)
119
6
        .await
120
6
        .map_err(|e| 
anyhow!2
(
e2
))
121
6
}
122
123
// -----------------------------------------------------------------------------
124
// Per-subcommand handlers
125
// -----------------------------------------------------------------------------
126
//
127
// Handlers return `Result<(), String>` to match the shape `session::with_token`
128
// expects. On `invalid_session` from the daemon the shared helper transparently
129
// re-runs consent and retries the op, so handlers don't need to classify
130
// auth-vs-non-auth errors themselves.
131
132
1
async fn cmd_ping(
133
1
    socket_path: PathBuf,
134
1
    token: String,
135
1
) -> super_tts_shared::daemon::http_client::HttpResult<()> {
136
1
    let msg = http_client::ping(socket_path, &token).await
?0
;
137
1
    println!("{msg}");
138
1
    Ok(())
139
1
}
140
141
1
async fn cmd_status(
142
1
    socket_path: PathBuf,
143
1
    token: String,
144
1
) -> super_tts_shared::daemon::http_client::HttpResult<()> {
145
1
    let resp = http_client::status(socket_path, &token).await
?0
;
146
1
    if resp.status != "success" {
147
        return Err(http_client::HttpError::Other(
148
0
            resp.message.unwrap_or_else(|| "unknown error".to_string()),
149
        ));
150
1
    }
151
1
    println!(
152
        "Model:  {}",
153
1
        resp.current_model.as_deref().unwrap_or("(none loaded)")
154
    );
155
1
    println!("Device: {}", resp.device.as_deref().unwrap_or("unknown"));
156
1
    println!(
157
        "State:  {}",
158
1
        if resp.busy.unwrap_or(false) {
159
0
            "speaking"
160
        } else {
161
1
            "idle"
162
        }
163
    );
164
1
    Ok(())
165
1
}
166
167
/// The text to speak: the trailing arguments, or all of stdin when none were
168
/// given. Reading stdin is what makes `... | super-tts-cli speak` work, which is
169
/// how a shell pipeline reaches the daemon without a client of its own.
170
2
fn read_text(sub: &clap::ArgMatches) -> Result<String> {
171
2
    let words: Vec<&str> = sub
172
2
        .get_many::<String>("text")
173
2
        .map(|v| 
v1
.
map1
(String::as_str).
collect1
())
174
2
        .unwrap_or_default();
175
2
    if !words.is_empty() {
176
1
        return Ok(words.join(" "));
177
1
    }
178
1
    let mut buf = String::new();
179
1
    std::io::stdin()
180
1
        .read_to_string(&mut buf)
181
1
        .context("reading text from stdin")
?0
;
182
1
    if buf.trim().is_empty() {
183
0
        return Err(anyhow!("no text given: pass it as arguments or on stdin"));
184
1
    }
185
1
    Ok(buf)
186
2
}
187
188
2
async fn cmd_speak(
189
2
    socket_path: PathBuf,
190
2
    token: String,
191
2
    text: String,
192
2
) -> super_tts_shared::daemon::http_client::HttpResult<()> {
193
    // Not a toggle: a second `speak` deliberately preempts the first, which is
194
    // what "say this instead" means. Use `stop` to fall silent.
195
2
    let 
resp0
= http_client::speak(socket_path, &token, &text).await?;
196
0
    if resp.status != "success" {
197
        return Err(http_client::HttpError::Other(
198
0
            resp.message.unwrap_or_else(|| "speak failed".to_string()),
199
        ));
200
0
    }
201
    // The daemon answers once the utterance is queued, while the device is
202
    // still playing it out, so the id is all there is to report.
203
0
    if let Some(id) = resp.utterance_id {
204
0
        println!("Speaking ({id}).");
205
0
    } else {
206
0
        println!("Speaking.");
207
0
    }
208
0
    Ok(())
209
2
}
210
211
2
async fn cmd_stop(
212
2
    socket_path: PathBuf,
213
2
    token: String,
214
2
) -> super_tts_shared::daemon::http_client::HttpResult<()> {
215
2
    let resp = http_client::speak_stop(socket_path, &token).await
?0
;
216
2
    if resp.status != "success" {
217
        return Err(http_client::HttpError::Other(
218
0
            resp.message.unwrap_or_else(|| "stop failed".to_string()),
219
        ));
220
2
    }
221
    // Stopping when nothing is speaking is a success, not an error — so say
222
    // which of the two happened rather than claiming to have stopped something.
223
2
    match resp.utterance_id {
224
0
        Some(id) => println!("Stopped ({id})."),
225
2
        None => println!("Nothing was speaking."),
226
    }
227
2
    Ok(())
228
2
}
229
230
1
fn cmd_logout() -> Result<()> {
231
1
    session::forget(APP_ID).map_err(|e| 
anyhow!0
(
e0
))
?0
;
232
1
    println!("Cached session token removed. Next invocation will trigger re-consent.");
233
1
    Ok(())
234
1
}
235
236
#[cfg(test)]
237
mod tests {
238
    use super::SCOPES;
239
    use super_tts_shared::daemon::scopes::is_known_scope;
240
241
    /// The CLI's requested scope set must be a subset of the daemon's shared
242
    /// catalog. Without this, a wire-scope rename in `scopes::known_scopes`
243
    /// would leave `SCOPES` requesting a token the daemon rejects, and the
244
    /// break would only surface at runtime as a failed `/auth/request`. Mirrors
245
    /// the consent binary's `scope_conformance` guard (audit 2 Tier 3 #33).
246
    #[test]
247
1
    fn requested_scopes_are_known() {
248
2
        for scope in SCOPES {
249
2
            assert!(
250
2
                is_known_scope(scope),
251
                "CLI requests scope `{scope}` that is not in scopes::known_scopes"
252
            );
253
        }
254
1
    }
255
}