teams-cli 0.1.3__tar.gz → 0.1.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {teams_cli-0.1.3 → teams_cli-0.1.5}/CHANGELOG.md +12 -0
  2. {teams_cli-0.1.3 → teams_cli-0.1.5}/Cargo.lock +1 -1
  3. {teams_cli-0.1.3 → teams_cli-0.1.5}/Cargo.toml +5 -1
  4. {teams_cli-0.1.3 → teams_cli-0.1.5}/PKG-INFO +9 -2
  5. {teams_cli-0.1.3 → teams_cli-0.1.5}/README.md +8 -1
  6. {teams_cli-0.1.3 → teams_cli-0.1.5}/src/config.rs +20 -5
  7. {teams_cli-0.1.3 → teams_cli-0.1.5}/src/main.rs +111 -1
  8. teams_cli-0.1.5/src/teams_cli_alias.rs +30 -0
  9. {teams_cli-0.1.3 → teams_cli-0.1.5}/src/tui.rs +179 -14
  10. teams_cli-0.1.5/tests/package_alias.rs +20 -0
  11. {teams_cli-0.1.3 → teams_cli-0.1.5}/.github/dependabot.yml +0 -0
  12. {teams_cli-0.1.3 → teams_cli-0.1.5}/.github/workflows/ci.yml +0 -0
  13. {teams_cli-0.1.3 → teams_cli-0.1.5}/.github/workflows/release.yml +0 -0
  14. {teams_cli-0.1.3 → teams_cli-0.1.5}/.gitignore +0 -0
  15. {teams_cli-0.1.3 → teams_cli-0.1.5}/.mise.toml +0 -0
  16. {teams_cli-0.1.3 → teams_cli-0.1.5}/DESIGN.md +0 -0
  17. {teams_cli-0.1.3 → teams_cli-0.1.5}/LICENSE +0 -0
  18. {teams_cli-0.1.3 → teams_cli-0.1.5}/Makefile +0 -0
  19. {teams_cli-0.1.3 → teams_cli-0.1.5}/PRODUCT.md +0 -0
  20. {teams_cli-0.1.3 → teams_cli-0.1.5}/docs/releases.md +0 -0
  21. {teams_cli-0.1.3 → teams_cli-0.1.5}/examples/validate_schema.rs +0 -0
  22. {teams_cli-0.1.3 → teams_cli-0.1.5}/pyproject.toml +0 -0
  23. {teams_cli-0.1.3 → teams_cli-0.1.5}/src/auth.rs +0 -0
  24. {teams_cli-0.1.3 → teams_cli-0.1.5}/src/cli.rs +0 -0
  25. {teams_cli-0.1.3 → teams_cli-0.1.5}/src/error.rs +0 -0
  26. {teams_cli-0.1.3 → teams_cli-0.1.5}/src/graph.rs +0 -0
  27. {teams_cli-0.1.3 → teams_cli-0.1.5}/src/lib.rs +0 -0
  28. {teams_cli-0.1.3 → teams_cli-0.1.5}/src/output.rs +0 -0
  29. {teams_cli-0.1.3 → teams_cli-0.1.5}/src/schema.rs +0 -0
  30. {teams_cli-0.1.3 → teams_cli-0.1.5}/tests/cli.rs +0 -0
  31. {teams_cli-0.1.3 → teams_cli-0.1.5}/vership.toml +0 -0
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.5](https://github.com/rvben/teams-cli/compare/v0.1.4...v0.1.5) - 2026-08-26
11
+
12
+ ### Added
13
+
14
+ - **tui**: recover Microsoft sign-in in place ([8266887](https://github.com/rvben/teams-cli/commit/8266887feef3feb651fa4ae5348752be2703d36b))
15
+
16
+ ## [0.1.4](https://github.com/rvben/teams-cli/compare/v0.1.3...v0.1.4) - 2026-08-26
17
+
18
+ ### Added
19
+
20
+ - **packaging**: add package-named launcher ([35a14d3](https://github.com/rvben/teams-cli/commit/35a14d329c5ca5a24f0e47c81cf29f30bad8773c))
21
+
10
22
  ## [0.1.3](https://github.com/rvben/teams-cli/compare/v0.1.2...v0.1.3) - 2026-08-26
11
23
 
12
24
  ### Added
@@ -2964,7 +2964,7 @@ dependencies = [
2964
2964
 
2965
2965
  [[package]]
2966
2966
  name = "teams-cli"
2967
- version = "0.1.3"
2967
+ version = "0.1.5"
2968
2968
  dependencies = [
2969
2969
  "assert_cmd",
2970
2970
  "base64 0.23.1",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "teams-cli"
3
- version = "0.1.3"
3
+ version = "0.1.5"
4
4
  edition = "2024"
5
5
  rust-version = "1.90"
6
6
  description = "A Microsoft Teams CLI and TUI designed equally for humans and agents"
@@ -16,6 +16,10 @@ categories = ["command-line-utilities"]
16
16
  name = "teams"
17
17
  path = "src/main.rs"
18
18
 
19
+ [[bin]]
20
+ name = "teams-cli"
21
+ path = "src/teams_cli_alias.rs"
22
+
19
23
  [dependencies]
20
24
  base64 = "0.23"
21
25
  clap = { version = "4", features = ["derive", "env"] }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: teams-cli
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  License-File: LICENSE
@@ -28,9 +28,11 @@ For humans, no arguments open guided onboarding on first use and the TUI afterwa
28
28
  pipx install teams-cli
29
29
  # or
30
30
  cargo install teams-cli --locked
31
+ # or run from PyPI without installing
32
+ uvx teams-cli --help
31
33
  ```
32
34
 
33
- The PyPI distribution is named `teams-cli`; the executable is `teams`, matching the `jira-cli` → `jira` and `confluence-cli` → `confluence` family.
35
+ The PyPI distribution provides both `teams` and `teams-cli` as executable names.
34
36
 
35
37
  To work from source:
36
38
 
@@ -71,6 +73,11 @@ Channel.ReadBasic.All Chat.Read Chat.Create ChatMessage.Send
71
73
  ChannelMessage.Send
72
74
  ```
73
75
 
76
+ The TUI is also the recovery path: an unconfigured profile, missing credential,
77
+ expired refresh session, or Microsoft `401` opens a focused connection screen.
78
+ Press `Enter` or `a` to restore the normal terminal, complete Microsoft sign-in,
79
+ and return directly to the inbox; `q` exits without changing anything.
80
+
74
81
  Reading channel history is intentionally separate because `ChannelMessage.Read.All` requires administrator consent. Request it only when needed:
75
82
 
76
83
  ```console
@@ -12,9 +12,11 @@ For humans, no arguments open guided onboarding on first use and the TUI afterwa
12
12
  pipx install teams-cli
13
13
  # or
14
14
  cargo install teams-cli --locked
15
+ # or run from PyPI without installing
16
+ uvx teams-cli --help
15
17
  ```
16
18
 
17
- The PyPI distribution is named `teams-cli`; the executable is `teams`, matching the `jira-cli` → `jira` and `confluence-cli` → `confluence` family.
19
+ The PyPI distribution provides both `teams` and `teams-cli` as executable names.
18
20
 
19
21
  To work from source:
20
22
 
@@ -55,6 +57,11 @@ Channel.ReadBasic.All Chat.Read Chat.Create ChatMessage.Send
55
57
  ChannelMessage.Send
56
58
  ```
57
59
 
60
+ The TUI is also the recovery path: an unconfigured profile, missing credential,
61
+ expired refresh session, or Microsoft `401` opens a focused connection screen.
62
+ Press `Enter` or `a` to restore the normal terminal, complete Microsoft sign-in,
63
+ and return directly to the inbox; `q` exits without changing anything.
64
+
58
65
  Reading channel history is intentionally separate because `ChannelMessage.Read.All` requires administrator consent. Request it only when needed:
59
66
 
60
67
  ```console
@@ -83,11 +83,7 @@ pub fn save(profile_name: &str, profile: Profile) -> Result<PathBuf, AppError> {
83
83
 
84
84
  pub fn load(requested: Option<&str>) -> Result<(String, Profile), AppError> {
85
85
  let config = read_file()?;
86
- let name = requested
87
- .map(str::to_owned)
88
- .or_else(|| std::env::var("TEAMS_PROFILE").ok())
89
- .or(config.active_profile)
90
- .unwrap_or_else(|| "default".into());
86
+ let name = selected_profile_name_from(requested, config.active_profile.as_deref());
91
87
  let stored = config.profiles.get(&name);
92
88
  let client_id = std::env::var("TEAMS_CLIENT_ID")
93
89
  .ok()
@@ -118,6 +114,25 @@ pub fn load(requested: Option<&str>) -> Result<(String, Profile), AppError> {
118
114
  Ok((name, profile))
119
115
  }
120
116
 
117
+ /// Resolve the profile a command would use without requiring that profile to
118
+ /// be configured. Interactive recovery screens need the name even on first
119
+ /// use, before there is a profile for [`load`] to return.
120
+ pub fn selected_profile_name(requested: Option<&str>) -> Result<String, AppError> {
121
+ let config = read_file()?;
122
+ Ok(selected_profile_name_from(
123
+ requested,
124
+ config.active_profile.as_deref(),
125
+ ))
126
+ }
127
+
128
+ fn selected_profile_name_from(requested: Option<&str>, active: Option<&str>) -> String {
129
+ requested
130
+ .map(str::to_owned)
131
+ .or_else(|| std::env::var("TEAMS_PROFILE").ok())
132
+ .or_else(|| active.map(str::to_owned))
133
+ .unwrap_or_else(|| "default".into())
134
+ }
135
+
121
136
  fn parse_bool(name: &str, value: &str) -> Result<bool, AppError> {
122
137
  match value.trim().to_ascii_lowercase().as_str() {
123
138
  "1" | "true" | "yes" | "on" => Ok(true),
@@ -393,6 +393,59 @@ async fn tui_command(profile_arg: Option<&str>, args: TuiArgs) -> Result<(), App
393
393
  if args.demo {
394
394
  return tui::run(tui::demo_data());
395
395
  }
396
+ let profile_name = config::selected_profile_name(profile_arg)?;
397
+ let mut reason_override = None;
398
+ loop {
399
+ match load_tui_data(profile_arg).await {
400
+ Ok(data) => return tui::run(data),
401
+ Err(error) if tui_auth_recoverable(&error) => {
402
+ let configured = config::configured_profile(profile_arg).is_some();
403
+ let reason = reason_override
404
+ .take()
405
+ .unwrap_or_else(|| tui_connection_reason(&error));
406
+ if tui::request_authentication(&profile_name, configured, &reason)?
407
+ == tui::TuiExit::Quit
408
+ {
409
+ return Ok(());
410
+ }
411
+
412
+ let auth_output = Output {
413
+ format: OutputFormat::Text,
414
+ quiet: false,
415
+ };
416
+ let result = if configured {
417
+ let (_, profile) = config::load(profile_arg)?;
418
+ auth::login(&profile_name, &profile, false, false, &auth_output)
419
+ .await
420
+ .map(|_| ())
421
+ } else {
422
+ init(
423
+ &profile_name,
424
+ InitArgs {
425
+ client_id: None,
426
+ tenant: config::DEFAULT_TENANT.into(),
427
+ no_login: false,
428
+ device_code: false,
429
+ channel_history: false,
430
+ read_only: false,
431
+ },
432
+ auth_output,
433
+ )
434
+ .await
435
+ };
436
+ if let Err(error) = result {
437
+ reason_override = Some(format!(
438
+ "Sign-in did not complete: {} You can try again.",
439
+ concise_error(&error)
440
+ ));
441
+ }
442
+ }
443
+ Err(error) => return Err(error),
444
+ }
445
+ }
446
+ }
447
+
448
+ async fn load_tui_data(profile_arg: Option<&str>) -> Result<tui::TuiData, AppError> {
396
449
  let graph = client(profile_arg).await?;
397
450
  let me = graph.me().await?;
398
451
  let teams = graph.teams(50, 0).await?;
@@ -416,7 +469,7 @@ async fn tui_command(profile_arg: Option<&str>, args: TuiArgs) -> Result<(), App
416
469
  messages: vec![],
417
470
  });
418
471
  }
419
- tui::run(tui::TuiData {
472
+ Ok(tui::TuiData {
420
473
  account: string_at(&me, "/displayName")
421
474
  .unwrap_or("Microsoft 365")
422
475
  .into(),
@@ -424,6 +477,63 @@ async fn tui_command(profile_arg: Option<&str>, args: TuiArgs) -> Result<(), App
424
477
  })
425
478
  }
426
479
 
480
+ fn tui_auth_recoverable(error: &AppError) -> bool {
481
+ matches!(error, AppError::Auth(_))
482
+ || matches!(error, AppError::InvalidInput(message) if message.contains("is not configured"))
483
+ }
484
+
485
+ fn tui_connection_reason(error: &AppError) -> String {
486
+ match error {
487
+ AppError::InvalidInput(message) if message.contains("is not configured") => {
488
+ "This profile has not been connected yet.".into()
489
+ }
490
+ AppError::Auth(message)
491
+ if message.contains("not signed in") || message.contains("unreadable") =>
492
+ {
493
+ "This profile does not have a usable Microsoft sign-in yet.".into()
494
+ }
495
+ AppError::Auth(message) if message.contains("expired") => {
496
+ "Your saved Microsoft session has expired.".into()
497
+ }
498
+ _ => "Microsoft rejected the current session.".into(),
499
+ }
500
+ }
501
+
502
+ fn concise_error(error: &AppError) -> String {
503
+ error.to_string().trim_end_matches('.').to_string()
504
+ }
505
+
506
+ #[cfg(test)]
507
+ mod tests {
508
+ use super::*;
509
+
510
+ #[test]
511
+ fn tui_recovers_configuration_and_authentication_only() {
512
+ assert!(tui_auth_recoverable(&AppError::InvalidInput(
513
+ "profile 'work' is not configured; run `teams init`".into()
514
+ )));
515
+ assert!(tui_auth_recoverable(&AppError::Auth(
516
+ "session expired".into()
517
+ )));
518
+ assert!(!tui_auth_recoverable(&AppError::InvalidInput(
519
+ "invalid field selection".into()
520
+ )));
521
+ assert!(!tui_auth_recoverable(&AppError::RateLimit(None)));
522
+ }
523
+
524
+ #[test]
525
+ fn tui_connection_copy_is_actionable_without_shell_instructions() {
526
+ let reason = tui_connection_reason(&AppError::Auth(
527
+ "not signed in; run `teams auth login`".into(),
528
+ ));
529
+ assert_eq!(
530
+ reason,
531
+ "This profile does not have a usable Microsoft sign-in yet."
532
+ );
533
+ assert!(!reason.contains("teams auth login"));
534
+ }
535
+ }
536
+
427
537
  #[derive(Serialize)]
428
538
  struct Check {
429
539
  name: &'static str,
@@ -0,0 +1,30 @@
1
+ use std::env;
2
+ use std::fmt::Display;
3
+ use std::process::{self, Command};
4
+
5
+ const COMMAND: &str = "teams";
6
+
7
+ fn fail(error: impl Display) -> ! {
8
+ eprintln!("teams-cli: failed to launch {COMMAND}: {error}");
9
+ process::exit(126);
10
+ }
11
+
12
+ fn main() {
13
+ let mut executable = env::current_exe().unwrap_or_else(|error| fail(error));
14
+ executable.set_file_name(format!("{COMMAND}{}", env::consts::EXE_SUFFIX));
15
+
16
+ let mut command = Command::new(executable);
17
+ command.args(env::args_os().skip(1));
18
+
19
+ #[cfg(unix)]
20
+ {
21
+ use std::os::unix::process::CommandExt;
22
+ fail(command.exec());
23
+ }
24
+
25
+ #[cfg(not(unix))]
26
+ match command.status() {
27
+ Ok(status) => process::exit(status.code().unwrap_or(1)),
28
+ Err(error) => fail(error),
29
+ }
30
+ }
@@ -1,7 +1,7 @@
1
1
  use std::io::{self, IsTerminal};
2
2
  use std::time::Duration;
3
3
 
4
- use crossterm::event::{self, Event, KeyCode, KeyEventKind};
4
+ use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
5
5
  use crossterm::execute;
6
6
  use crossterm::terminal::{
7
7
  EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
@@ -21,6 +21,32 @@ const FOCUS: Color = Color::Rgb(102, 194, 255);
21
21
  const LIVE: Color = Color::Rgb(255, 119, 107);
22
22
  const SURFACE: Color = Color::Rgb(20, 23, 30);
23
23
 
24
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
25
+ pub enum TuiExit {
26
+ Quit,
27
+ Authenticate,
28
+ }
29
+
30
+ struct TerminalGuard;
31
+
32
+ impl TerminalGuard {
33
+ fn enter() -> Result<Self, AppError> {
34
+ enable_raw_mode()?;
35
+ if let Err(error) = execute!(io::stdout(), EnterAlternateScreen) {
36
+ let _ = disable_raw_mode();
37
+ return Err(error.into());
38
+ }
39
+ Ok(Self)
40
+ }
41
+ }
42
+
43
+ impl Drop for TerminalGuard {
44
+ fn drop(&mut self) {
45
+ let _ = disable_raw_mode();
46
+ let _ = execute!(io::stdout(), LeaveAlternateScreen);
47
+ }
48
+ }
49
+
24
50
  #[derive(Debug, Clone)]
25
51
  pub struct TuiData {
26
52
  pub account: String,
@@ -119,17 +145,46 @@ pub fn snapshot(width: u16, height: u16) -> Result<String, AppError> {
119
145
  Ok(output)
120
146
  }
121
147
 
148
+ pub fn connection_snapshot(
149
+ width: u16,
150
+ height: u16,
151
+ profile: &str,
152
+ configured: bool,
153
+ reason: &str,
154
+ ) -> Result<String, AppError> {
155
+ let backend = TestBackend::new(width.max(48), height.max(18));
156
+ let mut terminal = Terminal::new(backend).expect("test backend is infallible");
157
+ terminal
158
+ .draw(|frame| draw_connection(frame, profile, configured, reason))
159
+ .expect("test backend is infallible");
160
+ Ok(buffer_text(terminal.backend().buffer()))
161
+ }
162
+
163
+ fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String {
164
+ let mut output = String::new();
165
+ for y in 0..buffer.area.height {
166
+ let mut line = String::new();
167
+ for x in 0..buffer.area.width {
168
+ line.push_str(buffer[(x, y)].symbol());
169
+ }
170
+ output.push_str(line.trim_end());
171
+ output.push('\n');
172
+ }
173
+ while output.ends_with("\n\n") {
174
+ output.pop();
175
+ }
176
+ output
177
+ }
178
+
122
179
  pub fn run(data: TuiData) -> Result<(), AppError> {
123
180
  if !io::stdout().is_terminal() || !io::stdin().is_terminal() {
124
181
  return Err(AppError::NonInteractive("`teams tui` needs a terminal; use `teams tui --demo --snapshot` for an inspectable frame".into()));
125
182
  }
126
- enable_raw_mode()?;
127
- let mut stdout = io::stdout();
128
- execute!(stdout, EnterAlternateScreen)?;
129
- let backend = CrosstermBackend::new(stdout);
183
+ let _terminal_guard = TerminalGuard::enter()?;
184
+ let backend = CrosstermBackend::new(io::stdout());
130
185
  let mut terminal = Terminal::new(backend)?;
131
186
  let mut app = App { data, selected: 0 };
132
- let result = (|| -> Result<(), AppError> {
187
+ (|| -> Result<(), AppError> {
133
188
  loop {
134
189
  terminal.draw(|frame| draw(frame, &app))?;
135
190
  if event::poll(Duration::from_millis(150))?
@@ -140,6 +195,7 @@ pub fn run(data: TuiData) -> Result<(), AppError> {
140
195
  }
141
196
  match key.code {
142
197
  KeyCode::Char('q') | KeyCode::Esc => break,
198
+ KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break,
143
199
  KeyCode::Down | KeyCode::Char('j') => {
144
200
  app.selected =
145
201
  (app.selected + 1).min(app.data.conversations.len().saturating_sub(1))
@@ -152,14 +208,111 @@ pub fn run(data: TuiData) -> Result<(), AppError> {
152
208
  }
153
209
  }
154
210
  Ok(())
155
- })();
156
- let cleanup = (|| -> Result<(), AppError> {
157
- disable_raw_mode()?;
158
- execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
159
- terminal.show_cursor()?;
160
- Ok(())
161
- })();
162
- result.and(cleanup)
211
+ })()
212
+ }
213
+
214
+ /// Present an actionable connection state inside the same terminal experience.
215
+ /// Returning first restores the alternate screen before the caller opens a
216
+ /// browser or prints device-code instructions.
217
+ pub fn request_authentication(
218
+ profile: &str,
219
+ configured: bool,
220
+ reason: &str,
221
+ ) -> Result<TuiExit, AppError> {
222
+ if !io::stdout().is_terminal() || !io::stdin().is_terminal() {
223
+ return Err(AppError::NonInteractive(
224
+ "`teams tui` needs a terminal; run `teams auth login --device-code` for headless sign-in"
225
+ .into(),
226
+ ));
227
+ }
228
+ let _terminal_guard = TerminalGuard::enter()?;
229
+ let backend = CrosstermBackend::new(io::stdout());
230
+ let mut terminal = Terminal::new(backend)?;
231
+ loop {
232
+ terminal.draw(|frame| draw_connection(frame, profile, configured, reason))?;
233
+ if event::poll(Duration::from_millis(150))?
234
+ && let Event::Key(key) = event::read()?
235
+ {
236
+ if key.kind != KeyEventKind::Press {
237
+ continue;
238
+ }
239
+ match key.code {
240
+ KeyCode::Enter | KeyCode::Char('a') => return Ok(TuiExit::Authenticate),
241
+ KeyCode::Char('q') | KeyCode::Esc => return Ok(TuiExit::Quit),
242
+ KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
243
+ return Ok(TuiExit::Quit);
244
+ }
245
+ _ => {}
246
+ }
247
+ }
248
+ }
249
+ }
250
+
251
+ fn draw_connection(frame: &mut ratatui::Frame, profile: &str, configured: bool, reason: &str) {
252
+ let area = frame.area();
253
+ frame.render_widget(Block::default().style(Style::default().bg(SURFACE)), area);
254
+ let panel_width = area.width.saturating_sub(2).clamp(1, 74);
255
+ let panel_height = area.height.saturating_sub(2).clamp(1, 12);
256
+ let panel = Rect {
257
+ x: area.x + area.width.saturating_sub(panel_width) / 2,
258
+ y: area.y + area.height.saturating_sub(panel_height) / 2,
259
+ width: panel_width,
260
+ height: panel_height,
261
+ };
262
+ let title = if configured {
263
+ " SIGN-IN REQUIRED "
264
+ } else {
265
+ " CONNECT MICROSOFT TEAMS "
266
+ };
267
+ let action = if configured {
268
+ "Continue with Microsoft sign-in. Your password is never shared with teams-cli."
269
+ } else {
270
+ "Set up this profile and continue with secure Microsoft sign-in."
271
+ };
272
+ let content = vec![
273
+ Line::styled(
274
+ if configured {
275
+ "Your Teams session needs attention"
276
+ } else {
277
+ "Bring your Teams workspace into focus"
278
+ },
279
+ Style::default().fg(INK).add_modifier(Modifier::BOLD),
280
+ ),
281
+ Line::styled(format!("Profile: {profile}"), Style::default().fg(MUTED)),
282
+ Line::raw(""),
283
+ Line::styled(reason, Style::default().fg(INK)),
284
+ Line::styled(action, Style::default().fg(MUTED)),
285
+ Line::raw(""),
286
+ Line::from(vec![
287
+ Span::styled(
288
+ " enter / a ",
289
+ Style::default()
290
+ .fg(Color::Black)
291
+ .bg(FOCUS)
292
+ .add_modifier(Modifier::BOLD),
293
+ ),
294
+ Span::styled(
295
+ if configured {
296
+ " sign in "
297
+ } else {
298
+ " set up "
299
+ },
300
+ Style::default().fg(MUTED),
301
+ ),
302
+ Span::styled("q", Style::default().fg(INK)),
303
+ Span::styled(" quit", Style::default().fg(MUTED)),
304
+ ]),
305
+ ];
306
+ frame.render_widget(
307
+ Paragraph::new(content).wrap(Wrap { trim: true }).block(
308
+ Block::default()
309
+ .title(title)
310
+ .borders(Borders::ALL)
311
+ .border_style(Style::default().fg(FOCUS))
312
+ .padding(ratatui::widgets::Padding::horizontal(2)),
313
+ ),
314
+ panel,
315
+ );
163
316
  }
164
317
 
165
318
  fn draw(frame: &mut ratatui::Frame, app: &App) {
@@ -312,4 +465,16 @@ mod tests {
312
465
  assert!(frame.contains("release candidate is green"));
313
466
  assert!(frame.contains("q quit"));
314
467
  }
468
+
469
+ #[test]
470
+ fn connection_snapshot_has_a_clear_recovery_action() {
471
+ let frame =
472
+ super::connection_snapshot(80, 22, "work", true, "Your saved session has expired.")
473
+ .unwrap();
474
+ assert!(frame.contains("SIGN-IN REQUIRED"));
475
+ assert!(frame.contains("Profile: work"));
476
+ assert!(frame.contains("enter / a"));
477
+ assert!(frame.contains("sign in"));
478
+ assert!(frame.contains("q quit"));
479
+ }
315
480
  }
@@ -0,0 +1,20 @@
1
+ use std::process::{Command, Output};
2
+
3
+ fn run(executable: &str, argument: &str) -> Output {
4
+ Command::new(executable)
5
+ .arg(argument)
6
+ .output()
7
+ .expect("CLI should start")
8
+ }
9
+
10
+ #[test]
11
+ fn package_named_alias_forwards_arguments_output_and_status() {
12
+ for argument in ["--version", "--__alias-test-invalid"] {
13
+ let primary = run(env!("CARGO_BIN_EXE_teams"), argument);
14
+ let alias = run(env!("CARGO_BIN_EXE_teams-cli"), argument);
15
+
16
+ assert_eq!(alias.status.code(), primary.status.code());
17
+ assert_eq!(alias.stdout, primary.stdout);
18
+ assert_eq!(alias.stderr, primary.stderr);
19
+ }
20
+ }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes