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 | | } |