teams-cli 0.1.4__tar.gz → 0.1.6__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 (32) hide show
  1. teams_cli-0.1.6/.github/workflows/dependencies.yml +28 -0
  2. {teams_cli-0.1.4 → teams_cli-0.1.6}/CHANGELOG.md +16 -0
  3. {teams_cli-0.1.4 → teams_cli-0.1.6}/Cargo.lock +1 -1
  4. {teams_cli-0.1.4 → teams_cli-0.1.6}/Cargo.toml +1 -1
  5. {teams_cli-0.1.4 → teams_cli-0.1.6}/PKG-INFO +13 -2
  6. {teams_cli-0.1.4 → teams_cli-0.1.6}/README.md +12 -1
  7. {teams_cli-0.1.4 → teams_cli-0.1.6}/src/auth.rs +27 -0
  8. {teams_cli-0.1.4 → teams_cli-0.1.6}/src/cli.rs +29 -3
  9. {teams_cli-0.1.4 → teams_cli-0.1.6}/src/config.rs +76 -7
  10. {teams_cli-0.1.4 → teams_cli-0.1.6}/src/main.rs +198 -11
  11. {teams_cli-0.1.4 → teams_cli-0.1.6}/src/schema.rs +64 -18
  12. {teams_cli-0.1.4 → teams_cli-0.1.6}/src/tui.rs +179 -14
  13. {teams_cli-0.1.4 → teams_cli-0.1.6}/tests/cli.rs +52 -0
  14. teams_cli-0.1.4/.github/dependabot.yml +0 -6
  15. {teams_cli-0.1.4 → teams_cli-0.1.6}/.github/workflows/ci.yml +0 -0
  16. {teams_cli-0.1.4 → teams_cli-0.1.6}/.github/workflows/release.yml +0 -0
  17. {teams_cli-0.1.4 → teams_cli-0.1.6}/.gitignore +0 -0
  18. {teams_cli-0.1.4 → teams_cli-0.1.6}/.mise.toml +0 -0
  19. {teams_cli-0.1.4 → teams_cli-0.1.6}/DESIGN.md +0 -0
  20. {teams_cli-0.1.4 → teams_cli-0.1.6}/LICENSE +0 -0
  21. {teams_cli-0.1.4 → teams_cli-0.1.6}/Makefile +0 -0
  22. {teams_cli-0.1.4 → teams_cli-0.1.6}/PRODUCT.md +0 -0
  23. {teams_cli-0.1.4 → teams_cli-0.1.6}/docs/releases.md +0 -0
  24. {teams_cli-0.1.4 → teams_cli-0.1.6}/examples/validate_schema.rs +0 -0
  25. {teams_cli-0.1.4 → teams_cli-0.1.6}/pyproject.toml +0 -0
  26. {teams_cli-0.1.4 → teams_cli-0.1.6}/src/error.rs +0 -0
  27. {teams_cli-0.1.4 → teams_cli-0.1.6}/src/graph.rs +0 -0
  28. {teams_cli-0.1.4 → teams_cli-0.1.6}/src/lib.rs +0 -0
  29. {teams_cli-0.1.4 → teams_cli-0.1.6}/src/output.rs +0 -0
  30. {teams_cli-0.1.4 → teams_cli-0.1.6}/src/teams_cli_alias.rs +0 -0
  31. {teams_cli-0.1.4 → teams_cli-0.1.6}/tests/package_alias.rs +0 -0
  32. {teams_cli-0.1.4 → teams_cli-0.1.6}/vership.toml +0 -0
@@ -0,0 +1,28 @@
1
+ name: Dependency health
2
+
3
+ on:
4
+ schedule:
5
+ - cron: "7 6 * * 1"
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+ id-token: write
11
+ packages: read
12
+ pull-requests: read
13
+
14
+ jobs:
15
+ update:
16
+ uses: rvben/upd/.github/workflows/dependency-health.yml@ecb605d32c4edc8dee4091aeb30b3d37ebdd290a # v0.10.0
17
+ with:
18
+ langs: rust,actions
19
+ lock: true
20
+ min-age: 7d
21
+ max-bump: minor
22
+ prepare-command: >-
23
+ rustup toolchain install 1.96.0 --profile minimal --component clippy,rustfmt &&
24
+ rustup default 1.96.0
25
+ validation-command: make check
26
+ fail-on-blocked: false
27
+ branch: deps/upd
28
+ commit-message: "chore(deps): weekly dependency update"
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.6](https://github.com/rvben/teams-cli/compare/v0.1.5...v0.1.6) - 2026-09-03
11
+
12
+ ### Added
13
+
14
+ - **auth**: standardize authentication workflow ([bb0019f](https://github.com/rvben/teams-cli/commit/bb0019f0ceb86dbb963f3499348c58a95922081e))
15
+
16
+ ### Fixed
17
+
18
+ - **auth**: tolerate missing Linux credential service ([7764383](https://github.com/rvben/teams-cli/commit/776438359f3c913d0514de921042d770c6a88bf0))
19
+
20
+ ## [0.1.5](https://github.com/rvben/teams-cli/compare/v0.1.4...v0.1.5) - 2026-08-26
21
+
22
+ ### Added
23
+
24
+ - **tui**: recover Microsoft sign-in in place ([8266887](https://github.com/rvben/teams-cli/commit/8266887feef3feb651fa4ae5348752be2703d36b))
25
+
10
26
  ## [0.1.4](https://github.com/rvben/teams-cli/compare/v0.1.3...v0.1.4) - 2026-08-26
11
27
 
12
28
  ### Added
@@ -2964,7 +2964,7 @@ dependencies = [
2964
2964
 
2965
2965
  [[package]]
2966
2966
  name = "teams-cli"
2967
- version = "0.1.4"
2967
+ version = "0.1.6"
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.4"
3
+ version = "0.1.6"
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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: teams-cli
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  License-File: LICENSE
@@ -73,6 +73,11 @@ Channel.ReadBasic.All Chat.Read Chat.Create ChatMessage.Send
73
73
  ChannelMessage.Send
74
74
  ```
75
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
+
76
81
  Reading channel history is intentionally separate because `ChannelMessage.Read.All` requires administrator consent. Request it only when needed:
77
82
 
78
83
  ```console
@@ -110,8 +115,14 @@ teams auth login --channel-history # once, with administrator approval
110
115
  teams messages list --team TEAM_ID --channel CHANNEL_ID
111
116
  teams messages send --chat CHAT_ID --body "On it."
112
117
  printf 'Status update' | teams messages send --chat CHAT_ID --body -
118
+ teams auth status
119
+ teams auth status --offline
120
+ teams profile list
121
+ teams profile use work
122
+ teams profile remove old --yes
113
123
  teams config show
114
- teams doctor
124
+ teams config path
125
+ teams doctor --offline
115
126
  ```
116
127
 
117
128
  Collection commands expose `--limit` and `--fields`; Graph-native collections use `--cursor`, while joined teams and channels use `--offset` because those endpoints do not accept `$top`. With a terminal they render concise text; when stdout is piped they emit JSON envelopes with continuation metadata and `truncated`.
@@ -57,6 +57,11 @@ Channel.ReadBasic.All Chat.Read Chat.Create ChatMessage.Send
57
57
  ChannelMessage.Send
58
58
  ```
59
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
+
60
65
  Reading channel history is intentionally separate because `ChannelMessage.Read.All` requires administrator consent. Request it only when needed:
61
66
 
62
67
  ```console
@@ -94,8 +99,14 @@ teams auth login --channel-history # once, with administrator approval
94
99
  teams messages list --team TEAM_ID --channel CHANNEL_ID
95
100
  teams messages send --chat CHAT_ID --body "On it."
96
101
  printf 'Status update' | teams messages send --chat CHAT_ID --body -
102
+ teams auth status
103
+ teams auth status --offline
104
+ teams profile list
105
+ teams profile use work
106
+ teams profile remove old --yes
97
107
  teams config show
98
- teams doctor
108
+ teams config path
109
+ teams doctor --offline
99
110
  ```
100
111
 
101
112
  Collection commands expose `--limit` and `--fields`; Graph-native collections use `--cursor`, while joined teams and channels use `--offset` because those endpoints do not accept `$top`. With a terminal they render concise text; when stdout is piped they emit JSON envelopes with continuation metadata and `truncated`.
@@ -104,12 +104,29 @@ fn load(profile_name: &str) -> Result<TokenBundle, AppError> {
104
104
  pub fn logout(profile_name: &str) -> Result<(), AppError> {
105
105
  match entry(profile_name)?.delete_credential() {
106
106
  Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
107
+ Err(e) if secret_service_is_unavailable(&e) => Ok(()),
107
108
  Err(e) => Err(AppError::Unexpected(format!(
108
109
  "could not remove stored credentials: {e}"
109
110
  ))),
110
111
  }
111
112
  }
112
113
 
114
+ #[cfg(target_os = "linux")]
115
+ fn secret_service_is_unavailable(error: &keyring::Error) -> bool {
116
+ match error {
117
+ keyring::Error::PlatformFailure(source) => {
118
+ let message = source.to_string();
119
+ message.contains("org.freedesktop.secrets") && message.contains("not provided")
120
+ }
121
+ _ => false,
122
+ }
123
+ }
124
+
125
+ #[cfg(not(target_os = "linux"))]
126
+ fn secret_service_is_unavailable(_error: &keyring::Error) -> bool {
127
+ false
128
+ }
129
+
113
130
  pub async fn access_token(profile_name: &str, profile: &Profile) -> Result<String, AppError> {
114
131
  if let Ok(token) = std::env::var("TEAMS_ACCESS_TOKEN") {
115
132
  return Ok(token);
@@ -385,4 +402,14 @@ mod tests {
385
402
  fn channel_history_is_explicitly_opted_in() {
386
403
  assert!(scopes(true).contains(CHANNEL_HISTORY_SCOPE));
387
404
  }
405
+
406
+ #[cfg(target_os = "linux")]
407
+ #[test]
408
+ fn missing_secret_service_is_treated_as_an_empty_store() {
409
+ let error = keyring::Error::PlatformFailure(Box::new(std::io::Error::other(
410
+ "The name org.freedesktop.secrets was not provided by any .service files",
411
+ )));
412
+
413
+ assert!(super::secret_service_is_unavailable(&error));
414
+ }
388
415
  }
@@ -20,6 +20,9 @@ pub struct Cli {
20
20
  /// Suppress progress and informational messages
21
21
  #[arg(long, global = true)]
22
22
  pub quiet: bool,
23
+ /// Skip confirmation prompts for destructive operations
24
+ #[arg(long, short = 'y', global = true)]
25
+ pub yes: bool,
23
26
  /// Alias for --output json
24
27
  #[arg(long, global = true, hide = true)]
25
28
  pub json: bool,
@@ -36,6 +39,11 @@ pub enum Command {
36
39
  #[command(subcommand)]
37
40
  command: AuthCommand,
38
41
  },
42
+ /// List, select, or remove configuration profiles
43
+ Profile {
44
+ #[command(subcommand)]
45
+ command: ProfileCommand,
46
+ },
39
47
  /// Inspect resolved, secret-free configuration
40
48
  Config {
41
49
  #[command(subcommand)]
@@ -63,7 +71,11 @@ pub enum Command {
63
71
  /// Open the keyboard-first terminal interface
64
72
  Tui(TuiArgs),
65
73
  /// Check configuration, credential storage, and Graph access
66
- Doctor,
74
+ Doctor {
75
+ /// Check local state without contacting Microsoft Graph
76
+ #[arg(long)]
77
+ offline: bool,
78
+ },
67
79
  /// Describe supported and deliberately unsupported capabilities
68
80
  Capabilities,
69
81
  /// Emit the offline CLI Spec v0.3 contract
@@ -118,8 +130,22 @@ pub enum AuthCommand {
118
130
  },
119
131
  /// Remove locally stored credentials
120
132
  Logout,
121
- /// Show local authentication status without network access
122
- Status,
133
+ /// Verify authentication and show the selected profile's status
134
+ Status {
135
+ /// Inspect local credential state without contacting Microsoft Graph
136
+ #[arg(long)]
137
+ offline: bool,
138
+ },
139
+ }
140
+
141
+ #[derive(Debug, Subcommand)]
142
+ pub enum ProfileCommand {
143
+ /// List configured profiles and identify the active one
144
+ List,
145
+ /// Select the default profile for future commands
146
+ Use { name: String },
147
+ /// Remove a profile and its stored credential
148
+ Remove { name: String },
123
149
  }
124
150
 
125
151
  #[derive(Debug, Subcommand)]
@@ -44,6 +44,15 @@ struct ConfigFile {
44
44
  profiles: BTreeMap<String, Profile>,
45
45
  }
46
46
 
47
+ #[derive(Debug, Serialize)]
48
+ pub struct ProfileSummary {
49
+ pub name: String,
50
+ pub active: bool,
51
+ pub tenant: String,
52
+ pub client_id: String,
53
+ pub read_only: bool,
54
+ }
55
+
47
56
  pub fn path() -> PathBuf {
48
57
  if let Some(base) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
49
58
  PathBuf::from(base).join("teams").join("config.toml")
@@ -61,6 +70,11 @@ pub fn save(profile_name: &str, profile: Profile) -> Result<PathBuf, AppError> {
61
70
  let mut config = read_file()?;
62
71
  config.profiles.insert(profile_name.to_string(), profile);
63
72
  config.active_profile = Some(profile_name.to_string());
73
+ write_file(&path, &config)?;
74
+ Ok(path)
75
+ }
76
+
77
+ fn write_file(path: &std::path::Path, config: &ConfigFile) -> Result<(), AppError> {
64
78
  let parent = path
65
79
  .parent()
66
80
  .ok_or_else(|| AppError::Unexpected("configuration path has no parent".into()))?;
@@ -77,17 +91,13 @@ pub fn save(profile_name: &str, profile: Profile) -> Result<PathBuf, AppError> {
77
91
  use std::os::unix::fs::PermissionsExt;
78
92
  std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o600))?;
79
93
  }
80
- temp.persist(&path).map_err(|error| error.error)?;
81
- Ok(path)
94
+ temp.persist(path).map_err(|error| error.error)?;
95
+ Ok(())
82
96
  }
83
97
 
84
98
  pub fn load(requested: Option<&str>) -> Result<(String, Profile), AppError> {
85
99
  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());
100
+ let name = selected_profile_name_from(requested, config.active_profile.as_deref());
91
101
  let stored = config.profiles.get(&name);
92
102
  let client_id = std::env::var("TEAMS_CLIENT_ID")
93
103
  .ok()
@@ -118,6 +128,25 @@ pub fn load(requested: Option<&str>) -> Result<(String, Profile), AppError> {
118
128
  Ok((name, profile))
119
129
  }
120
130
 
131
+ /// Resolve the profile a command would use without requiring that profile to
132
+ /// be configured. Interactive recovery screens need the name even on first
133
+ /// use, before there is a profile for [`load`] to return.
134
+ pub fn selected_profile_name(requested: Option<&str>) -> Result<String, AppError> {
135
+ let config = read_file()?;
136
+ Ok(selected_profile_name_from(
137
+ requested,
138
+ config.active_profile.as_deref(),
139
+ ))
140
+ }
141
+
142
+ fn selected_profile_name_from(requested: Option<&str>, active: Option<&str>) -> String {
143
+ requested
144
+ .map(str::to_owned)
145
+ .or_else(|| std::env::var("TEAMS_PROFILE").ok())
146
+ .or_else(|| active.map(str::to_owned))
147
+ .unwrap_or_else(|| "default".into())
148
+ }
149
+
121
150
  fn parse_bool(name: &str, value: &str) -> Result<bool, AppError> {
122
151
  match value.trim().to_ascii_lowercase().as_str() {
123
152
  "1" | "true" | "yes" | "on" => Ok(true),
@@ -144,6 +173,46 @@ pub fn configured_profile(requested: Option<&str>) -> Option<(String, Profile)>
144
173
  load(requested).ok()
145
174
  }
146
175
 
176
+ pub fn profile_summaries() -> Result<Vec<ProfileSummary>, AppError> {
177
+ let config = read_file()?;
178
+ Ok(config
179
+ .profiles
180
+ .iter()
181
+ .map(|(name, profile)| ProfileSummary {
182
+ name: name.clone(),
183
+ active: config.active_profile.as_deref() == Some(name.as_str()),
184
+ tenant: profile.tenant.clone(),
185
+ client_id: profile.client_id.clone(),
186
+ read_only: profile.read_only,
187
+ })
188
+ .collect())
189
+ }
190
+
191
+ pub fn use_profile(name: &str) -> Result<(), AppError> {
192
+ let path = path();
193
+ let mut config = read_file()?;
194
+ if !config.profiles.contains_key(name) {
195
+ return Err(AppError::InvalidInput(format!(
196
+ "profile '{name}' is not configured"
197
+ )));
198
+ }
199
+ config.active_profile = Some(name.into());
200
+ write_file(&path, &config)
201
+ }
202
+
203
+ pub fn remove_profile(name: &str) -> Result<bool, AppError> {
204
+ let path = path();
205
+ let mut config = read_file()?;
206
+ let removed = config.profiles.remove(name).is_some();
207
+ if removed {
208
+ if config.active_profile.as_deref() == Some(name) {
209
+ config.active_profile = config.profiles.keys().next().cloned();
210
+ }
211
+ write_file(&path, &config)?;
212
+ }
213
+ Ok(removed)
214
+ }
215
+
147
216
  fn read_file() -> Result<ConfigFile, AppError> {
148
217
  match std::fs::read_to_string(path()) {
149
218
  Ok(body) => toml::from_str(&body)
@@ -7,7 +7,7 @@ use serde_json::Value;
7
7
  use teams_cli::auth;
8
8
  use teams_cli::cli::{
9
9
  AuthCommand, ChannelsCommand, ChatsCommand, Cli, Command, ConfigCommand, InitArgs,
10
- MessagesCommand, OffsetPageArgs, PageArgs, TuiArgs,
10
+ MessagesCommand, OffsetPageArgs, PageArgs, ProfileCommand, TuiArgs,
11
11
  };
12
12
  use teams_cli::config::{self, Profile};
13
13
  use teams_cli::error::AppError;
@@ -51,6 +51,7 @@ async fn main() {
51
51
 
52
52
  async fn dispatch(cli: Cli, out: Output) -> Result<(), AppError> {
53
53
  let profile_arg = cli.profile.as_deref();
54
+ let yes = cli.yes;
54
55
  let command = match cli.command {
55
56
  Some(command) => command,
56
57
  None if io::stdin().is_terminal() && io::stdout().is_terminal() && !config::exists() => {
@@ -78,6 +79,7 @@ async fn dispatch(cli: Cli, out: Output) -> Result<(), AppError> {
78
79
  match command {
79
80
  Command::Init(args) => init(profile_arg.unwrap_or("default"), args, out).await,
80
81
  Command::Auth { command } => auth_command(profile_arg, command, out).await,
82
+ Command::Profile { command } => profile_command(command, yes, out),
81
83
  Command::Config { command } => config_command(profile_arg, command, out),
82
84
  Command::Whoami => {
83
85
  let client = client(profile_arg).await?;
@@ -154,7 +156,7 @@ async fn dispatch(cli: Cli, out: Output) -> Result<(), AppError> {
154
156
  })
155
157
  }
156
158
  Command::Tui(args) => tui_command(profile_arg, args).await,
157
- Command::Doctor => doctor(profile_arg, out).await,
159
+ Command::Doctor { offline } => doctor(profile_arg, offline, out).await,
158
160
  Command::Capabilities => capabilities(out),
159
161
  Command::Schema { command } => {
160
162
  let value = schema::generate(command.as_deref());
@@ -307,7 +309,7 @@ async fn auth_command(
307
309
  let value = serde_json::json!({"profile":name,"signed_in":false});
308
310
  out.value(&value, || format!("Signed out profile '{name}'."))
309
311
  }
310
- AuthCommand::Status => {
312
+ AuthCommand::Status { offline } => {
311
313
  let configured = config::configured_profile(profile_arg);
312
314
  let name = configured
313
315
  .as_ref()
@@ -316,7 +318,12 @@ async fn auth_command(
316
318
  let signed_in = auth::has_token(name);
317
319
  let scopes = auth::granted_scopes(name);
318
320
  let channel_history = auth::channel_history_granted(name);
319
- let value = serde_json::json!({"profile":name,"configured":configured.is_some(),"signed_in":signed_in,"config_path":config::path(),"read_only":configured.as_ref().is_some_and(|(_, profile)| profile.read_only),"granted_scopes":scopes,"channel_history":channel_history});
321
+ let identity = if !offline && configured.is_some() && signed_in {
322
+ Some(client(Some(name)).await?.me().await?)
323
+ } else {
324
+ None
325
+ };
326
+ let value = serde_json::json!({"profile":name,"configured":configured.is_some(),"signed_in":signed_in,"verified":identity.is_some(),"identity":identity,"config_path":config::path(),"read_only":configured.as_ref().is_some_and(|(_, profile)| profile.read_only),"granted_scopes":scopes,"channel_history":channel_history});
320
327
  out.value(&value, || {
321
328
  format!(
322
329
  "Profile: {name}\nConfigured: {}\nSigned in: {}\nRead only: {}\nChannel history: {}\nConfig: {}",
@@ -331,6 +338,59 @@ async fn auth_command(
331
338
  }
332
339
  }
333
340
 
341
+ fn profile_command(command: ProfileCommand, yes: bool, out: Output) -> Result<(), AppError> {
342
+ match command {
343
+ ProfileCommand::List => {
344
+ let profiles = config::profile_summaries()?;
345
+ out.value(
346
+ &serde_json::json!({"items": profiles, "total": profiles.len()}),
347
+ || {
348
+ if profiles.is_empty() {
349
+ "No profiles configured. Run `teams init`.".into()
350
+ } else {
351
+ profiles
352
+ .iter()
353
+ .map(|profile| {
354
+ format!(
355
+ "{} {} {}",
356
+ if profile.active { "*" } else { " " },
357
+ profile.name,
358
+ profile.tenant
359
+ )
360
+ })
361
+ .collect::<Vec<_>>()
362
+ .join("\n")
363
+ }
364
+ },
365
+ )
366
+ }
367
+ ProfileCommand::Use { name } => {
368
+ config::use_profile(&name)?;
369
+ out.value(
370
+ &serde_json::json!({"profile": name, "active": true}),
371
+ || format!("Active profile set to '{name}'."),
372
+ )
373
+ }
374
+ ProfileCommand::Remove { name } => {
375
+ if !yes {
376
+ return Err(AppError::InvalidInput(
377
+ "profile removal requires --yes".into(),
378
+ ));
379
+ }
380
+ auth::logout(&name)?;
381
+ if !config::remove_profile(&name)? {
382
+ return Err(AppError::InvalidInput(format!(
383
+ "profile '{name}' is not configured"
384
+ )));
385
+ }
386
+ out.value(
387
+ &serde_json::json!({"profile": name, "removed": true}),
388
+ || format!("Removed profile '{name}'."),
389
+ )
390
+ }
391
+ }
392
+ }
393
+
334
394
  async fn client(profile_arg: Option<&str>) -> Result<GraphClient, AppError> {
335
395
  let (name, profile) = config::load(profile_arg)?;
336
396
  let token = auth::access_token(&name, &profile).await?;
@@ -393,6 +453,59 @@ async fn tui_command(profile_arg: Option<&str>, args: TuiArgs) -> Result<(), App
393
453
  if args.demo {
394
454
  return tui::run(tui::demo_data());
395
455
  }
456
+ let profile_name = config::selected_profile_name(profile_arg)?;
457
+ let mut reason_override = None;
458
+ loop {
459
+ match load_tui_data(profile_arg).await {
460
+ Ok(data) => return tui::run(data),
461
+ Err(error) if tui_auth_recoverable(&error) => {
462
+ let configured = config::configured_profile(profile_arg).is_some();
463
+ let reason = reason_override
464
+ .take()
465
+ .unwrap_or_else(|| tui_connection_reason(&error));
466
+ if tui::request_authentication(&profile_name, configured, &reason)?
467
+ == tui::TuiExit::Quit
468
+ {
469
+ return Ok(());
470
+ }
471
+
472
+ let auth_output = Output {
473
+ format: OutputFormat::Text,
474
+ quiet: false,
475
+ };
476
+ let result = if configured {
477
+ let (_, profile) = config::load(profile_arg)?;
478
+ auth::login(&profile_name, &profile, false, false, &auth_output)
479
+ .await
480
+ .map(|_| ())
481
+ } else {
482
+ init(
483
+ &profile_name,
484
+ InitArgs {
485
+ client_id: None,
486
+ tenant: config::DEFAULT_TENANT.into(),
487
+ no_login: false,
488
+ device_code: false,
489
+ channel_history: false,
490
+ read_only: false,
491
+ },
492
+ auth_output,
493
+ )
494
+ .await
495
+ };
496
+ if let Err(error) = result {
497
+ reason_override = Some(format!(
498
+ "Sign-in did not complete: {} You can try again.",
499
+ concise_error(&error)
500
+ ));
501
+ }
502
+ }
503
+ Err(error) => return Err(error),
504
+ }
505
+ }
506
+ }
507
+
508
+ async fn load_tui_data(profile_arg: Option<&str>) -> Result<tui::TuiData, AppError> {
396
509
  let graph = client(profile_arg).await?;
397
510
  let me = graph.me().await?;
398
511
  let teams = graph.teams(50, 0).await?;
@@ -416,7 +529,7 @@ async fn tui_command(profile_arg: Option<&str>, args: TuiArgs) -> Result<(), App
416
529
  messages: vec![],
417
530
  });
418
531
  }
419
- tui::run(tui::TuiData {
532
+ Ok(tui::TuiData {
420
533
  account: string_at(&me, "/displayName")
421
534
  .unwrap_or("Microsoft 365")
422
535
  .into(),
@@ -424,6 +537,63 @@ async fn tui_command(profile_arg: Option<&str>, args: TuiArgs) -> Result<(), App
424
537
  })
425
538
  }
426
539
 
540
+ fn tui_auth_recoverable(error: &AppError) -> bool {
541
+ matches!(error, AppError::Auth(_))
542
+ || matches!(error, AppError::InvalidInput(message) if message.contains("is not configured"))
543
+ }
544
+
545
+ fn tui_connection_reason(error: &AppError) -> String {
546
+ match error {
547
+ AppError::InvalidInput(message) if message.contains("is not configured") => {
548
+ "This profile has not been connected yet.".into()
549
+ }
550
+ AppError::Auth(message)
551
+ if message.contains("not signed in") || message.contains("unreadable") =>
552
+ {
553
+ "This profile does not have a usable Microsoft sign-in yet.".into()
554
+ }
555
+ AppError::Auth(message) if message.contains("expired") => {
556
+ "Your saved Microsoft session has expired.".into()
557
+ }
558
+ _ => "Microsoft rejected the current session.".into(),
559
+ }
560
+ }
561
+
562
+ fn concise_error(error: &AppError) -> String {
563
+ error.to_string().trim_end_matches('.').to_string()
564
+ }
565
+
566
+ #[cfg(test)]
567
+ mod tests {
568
+ use super::*;
569
+
570
+ #[test]
571
+ fn tui_recovers_configuration_and_authentication_only() {
572
+ assert!(tui_auth_recoverable(&AppError::InvalidInput(
573
+ "profile 'work' is not configured; run `teams init`".into()
574
+ )));
575
+ assert!(tui_auth_recoverable(&AppError::Auth(
576
+ "session expired".into()
577
+ )));
578
+ assert!(!tui_auth_recoverable(&AppError::InvalidInput(
579
+ "invalid field selection".into()
580
+ )));
581
+ assert!(!tui_auth_recoverable(&AppError::RateLimit(None)));
582
+ }
583
+
584
+ #[test]
585
+ fn tui_connection_copy_is_actionable_without_shell_instructions() {
586
+ let reason = tui_connection_reason(&AppError::Auth(
587
+ "not signed in; run `teams auth login`".into(),
588
+ ));
589
+ assert_eq!(
590
+ reason,
591
+ "This profile does not have a usable Microsoft sign-in yet."
592
+ );
593
+ assert!(!reason.contains("teams auth login"));
594
+ }
595
+ }
596
+
427
597
  #[derive(Serialize)]
428
598
  struct Check {
429
599
  name: &'static str,
@@ -431,7 +601,7 @@ struct Check {
431
601
  detail: String,
432
602
  }
433
603
 
434
- async fn doctor(profile_arg: Option<&str>, out: Output) -> Result<(), AppError> {
604
+ async fn doctor(profile_arg: Option<&str>, offline: bool, out: Output) -> Result<(), AppError> {
435
605
  let mut checks = Vec::new();
436
606
  let configured = config::configured_profile(profile_arg);
437
607
  checks.push(Check {
@@ -483,7 +653,7 @@ async fn doctor(profile_arg: Option<&str>, out: Output) -> Result<(), AppError>
483
653
  });
484
654
  let mut identity_ok = false;
485
655
  let mut teams_ok = false;
486
- let (identity_detail, teams_detail) = if configured.is_some() && signed_in {
656
+ let (identity_detail, teams_detail) = if !offline && configured.is_some() && signed_in {
487
657
  match client(profile_arg).await {
488
658
  Ok(client) => {
489
659
  let identity_detail = match client.me().await {
@@ -513,21 +683,38 @@ async fn doctor(profile_arg: Option<&str>, out: Output) -> Result<(), AppError>
513
683
  }
514
684
  }
515
685
  } else {
516
- let detail = "skipped until setup is complete".to_string();
686
+ let detail = if offline {
687
+ "network check skipped"
688
+ } else {
689
+ "skipped until setup is complete"
690
+ }
691
+ .to_string();
517
692
  (detail.clone(), detail)
518
693
  };
519
694
  checks.push(Check {
520
695
  name: "microsoft_identity",
521
- status: if identity_ok { "pass" } else { "fail" },
696
+ status: if offline {
697
+ "skip"
698
+ } else if identity_ok {
699
+ "pass"
700
+ } else {
701
+ "fail"
702
+ },
522
703
  detail: identity_detail,
523
704
  });
524
705
  checks.push(Check {
525
706
  name: "microsoft_teams",
526
- status: if teams_ok { "pass" } else { "fail" },
707
+ status: if offline {
708
+ "skip"
709
+ } else if teams_ok {
710
+ "pass"
711
+ } else {
712
+ "fail"
713
+ },
527
714
  detail: teams_detail,
528
715
  });
529
716
  let healthy = checks.iter().all(|check| check.status != "fail");
530
- let value = serde_json::json!({"checks":checks,"healthy":healthy});
717
+ let value = serde_json::json!({"checks":checks,"healthy":healthy,"offline":offline});
531
718
  out.value(&value, || {
532
719
  checks
533
720
  .iter()
@@ -130,20 +130,57 @@ pub fn generate(command_filter: Option<&str>) -> Value {
130
130
  "idempotent",
131
131
  vec![field("profile", "string"), field("signed_in", "boolean")],
132
132
  ),
133
+ {
134
+ let mut value = single(
135
+ "auth status",
136
+ "Verify authentication and inspect credential state",
137
+ "read_only",
138
+ vec![
139
+ field("profile", "string"),
140
+ field("configured", "boolean"),
141
+ field("signed_in", "boolean"),
142
+ field("verified", "boolean"),
143
+ nullable_field("identity", "object"),
144
+ field("config_path", "string"),
145
+ field("read_only", "boolean"),
146
+ nullable_array_field("granted_scopes", "string"),
147
+ nullable_field("channel_history", "boolean"),
148
+ ],
149
+ );
150
+ value["args"] = json!([arg(
151
+ "--offline",
152
+ "boolean",
153
+ "Inspect local credential state without contacting Microsoft Graph"
154
+ )]);
155
+ value
156
+ },
133
157
  single(
134
- "auth status",
135
- "Inspect local configuration and credential presence without network access",
158
+ "profile list",
159
+ "List configured profiles and identify the active one",
136
160
  "read_only",
137
- vec![
138
- field("profile", "string"),
139
- field("configured", "boolean"),
140
- field("signed_in", "boolean"),
141
- field("config_path", "string"),
142
- field("read_only", "boolean"),
143
- nullable_array_field("granted_scopes", "string"),
144
- nullable_field("channel_history", "boolean"),
145
- ],
161
+ vec![array_field("items", "object"), field("total", "integer")],
146
162
  ),
163
+ {
164
+ let mut value = single(
165
+ "profile use",
166
+ "Select the default profile for future commands",
167
+ "idempotent",
168
+ vec![field("profile", "string"), field("active", "boolean")],
169
+ );
170
+ value["args"] = json!([required_arg("name", "string", "Profile name")]);
171
+ value
172
+ },
173
+ {
174
+ let mut value = single(
175
+ "profile remove",
176
+ "Remove a profile and its stored credential",
177
+ "idempotent",
178
+ vec![field("profile", "string"), field("removed", "boolean")],
179
+ );
180
+ value["args"] = json!([required_arg("name", "string", "Profile name")]);
181
+ value["confirmation_bypass_arg"] = json!("--yes");
182
+ value
183
+ },
147
184
  single(
148
185
  "config show",
149
186
  "Print the resolved profile without credentials",
@@ -246,12 +283,20 @@ pub fn generate(command_filter: Option<&str>) -> Value {
246
283
  value
247
284
  },
248
285
  json!({"name":"tui", "description":"Open the keyboard-first terminal interface", "effects":"read_only", "output_kind":"opaque", "media_type":"text/plain", "requires_tty":true, "args":[arg("--demo", "boolean", "Use deterministic sample data"), arg("--snapshot", "boolean", "Print one ANSI-free demo frame")]}),
249
- single(
250
- "doctor",
251
- "Check configuration, credential storage, Microsoft identity, and Teams provisioning",
252
- "read_only",
253
- vec![array_field("checks", "object"), field("healthy", "boolean")],
254
- ),
286
+ {
287
+ let mut value = single(
288
+ "doctor",
289
+ "Check configuration, credential storage, Microsoft identity, and Teams provisioning",
290
+ "read_only",
291
+ vec![array_field("checks", "object"), field("healthy", "boolean")],
292
+ );
293
+ value["args"] = json!([arg(
294
+ "--offline",
295
+ "boolean",
296
+ "Check local state without contacting Microsoft Graph"
297
+ )]);
298
+ value
299
+ },
255
300
  single(
256
301
  "capabilities",
257
302
  "Describe supported and deliberately unsupported capabilities",
@@ -287,7 +332,8 @@ pub fn generate(command_filter: Option<&str>) -> Value {
287
332
  "global_args": [
288
333
  {"name":"--output", "short":"-o", "type":"string", "enum":["auto","text","json"], "default":"auto", "description":"Output format"},
289
334
  {"name":"--profile", "type":"string", "description":"Configuration profile"},
290
- {"name":"--quiet", "type":"boolean", "description":"Suppress informational stderr output"}
335
+ {"name":"--quiet", "type":"boolean", "description":"Suppress informational stderr output"},
336
+ {"name":"--yes", "short":"-y", "type":"boolean", "description":"Skip confirmation prompts for destructive operations"}
291
337
  ],
292
338
  "commands": commands,
293
339
  "errors": error::ALL.iter().map(|e| json!({"kind":e.kind,"exit_code":e.exit_code,"retryable":e.retryable,"description":e.description})).collect::<Vec<_>>(),
@@ -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
  }
@@ -142,6 +142,58 @@ fn init_can_create_a_read_only_profile_and_config_show_resolves_it() {
142
142
  .stdout(predicate::str::contains("\"read_only\": true"));
143
143
  }
144
144
 
145
+ #[test]
146
+ fn profile_lifecycle_and_offline_auth_status_are_local() {
147
+ let temp = tempfile::tempdir().unwrap();
148
+ for name in ["work", "sandbox"] {
149
+ Command::cargo_bin("teams")
150
+ .unwrap()
151
+ .env("XDG_CONFIG_HOME", temp.path())
152
+ .args(["--profile", name, "init", "--no-login"])
153
+ .assert()
154
+ .success();
155
+ }
156
+
157
+ Command::cargo_bin("teams")
158
+ .unwrap()
159
+ .env("XDG_CONFIG_HOME", temp.path())
160
+ .args(["profile", "use", "work"])
161
+ .assert()
162
+ .success();
163
+
164
+ let list = Command::cargo_bin("teams")
165
+ .unwrap()
166
+ .env("XDG_CONFIG_HOME", temp.path())
167
+ .args(["--output", "json", "profile", "list"])
168
+ .output()
169
+ .unwrap();
170
+ assert!(list.status.success());
171
+ let value: Value = serde_json::from_slice(&list.stdout).unwrap();
172
+ assert_eq!(value["total"], 2);
173
+ assert!(
174
+ value["items"]
175
+ .as_array()
176
+ .unwrap()
177
+ .iter()
178
+ .any(|profile| { profile["name"] == "work" && profile["active"] == true })
179
+ );
180
+
181
+ Command::cargo_bin("teams")
182
+ .unwrap()
183
+ .env("XDG_CONFIG_HOME", temp.path())
184
+ .args(["auth", "status", "--offline"])
185
+ .assert()
186
+ .success()
187
+ .stdout(predicate::str::contains("\"verified\": false"));
188
+
189
+ Command::cargo_bin("teams")
190
+ .unwrap()
191
+ .env("XDG_CONFIG_HOME", temp.path())
192
+ .args(["profile", "remove", "sandbox", "--yes"])
193
+ .assert()
194
+ .success();
195
+ }
196
+
145
197
  #[test]
146
198
  fn suite_exit_codes_and_error_kinds_are_stable() {
147
199
  let schema: Value = serde_json::from_slice(
@@ -1,6 +0,0 @@
1
- version: 2
2
- updates:
3
- - package-ecosystem: github-actions
4
- directory: /
5
- schedule:
6
- interval: weekly
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