teams-cli 0.1.0__tar.gz → 0.1.2__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 (26) hide show
  1. {teams_cli-0.1.0 → teams_cli-0.1.2}/.github/workflows/release.yml +5 -1
  2. {teams_cli-0.1.0 → teams_cli-0.1.2}/CHANGELOG.md +23 -0
  3. {teams_cli-0.1.0 → teams_cli-0.1.2}/Cargo.lock +1 -1
  4. {teams_cli-0.1.0 → teams_cli-0.1.2}/Cargo.toml +1 -1
  5. {teams_cli-0.1.0 → teams_cli-0.1.2}/PKG-INFO +27 -8
  6. {teams_cli-0.1.0 → teams_cli-0.1.2}/PRODUCT.md +2 -1
  7. {teams_cli-0.1.0 → teams_cli-0.1.2}/README.md +26 -7
  8. {teams_cli-0.1.0 → teams_cli-0.1.2}/src/auth.rs +67 -9
  9. {teams_cli-0.1.0 → teams_cli-0.1.2}/src/cli.rs +8 -1
  10. {teams_cli-0.1.0 → teams_cli-0.1.2}/src/config.rs +5 -1
  11. {teams_cli-0.1.0 → teams_cli-0.1.2}/src/graph.rs +40 -3
  12. {teams_cli-0.1.0 → teams_cli-0.1.2}/src/main.rs +102 -55
  13. {teams_cli-0.1.0 → teams_cli-0.1.2}/src/schema.rs +42 -6
  14. {teams_cli-0.1.0 → teams_cli-0.1.2}/tests/cli.rs +39 -10
  15. {teams_cli-0.1.0 → teams_cli-0.1.2}/.github/dependabot.yml +0 -0
  16. {teams_cli-0.1.0 → teams_cli-0.1.2}/.github/workflows/ci.yml +0 -0
  17. {teams_cli-0.1.0 → teams_cli-0.1.2}/.gitignore +0 -0
  18. {teams_cli-0.1.0 → teams_cli-0.1.2}/DESIGN.md +0 -0
  19. {teams_cli-0.1.0 → teams_cli-0.1.2}/LICENSE +0 -0
  20. {teams_cli-0.1.0 → teams_cli-0.1.2}/Makefile +0 -0
  21. {teams_cli-0.1.0 → teams_cli-0.1.2}/examples/validate_schema.rs +0 -0
  22. {teams_cli-0.1.0 → teams_cli-0.1.2}/pyproject.toml +0 -0
  23. {teams_cli-0.1.0 → teams_cli-0.1.2}/src/error.rs +0 -0
  24. {teams_cli-0.1.0 → teams_cli-0.1.2}/src/lib.rs +0 -0
  25. {teams_cli-0.1.0 → teams_cli-0.1.2}/src/output.rs +0 -0
  26. {teams_cli-0.1.0 → teams_cli-0.1.2}/src/tui.rs +0 -0
@@ -179,7 +179,11 @@ jobs:
179
179
  shell: bash
180
180
  run: |
181
181
  version="${GITHUB_REF_NAME#v}"
182
- if curl -fsSL "https://crates.io/api/v1/crates/teams-cli/${version}" > /dev/null; then
182
+ response="$(curl --fail --silent --show-error --location \
183
+ --user-agent "teams-cli-release-workflow" \
184
+ "https://crates.io/api/v1/crates/teams-cli")"
185
+ if jq --exit-status --arg version "$version" \
186
+ '.versions[] | select(.num == $version)' <<< "$response" > /dev/null; then
183
187
  echo "exists=true" >> "$GITHUB_OUTPUT"
184
188
  else
185
189
  echo "exists=false" >> "$GITHUB_OUTPUT"
@@ -5,6 +5,26 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.2] - 2026-08-24
11
+
12
+ ### Added
13
+
14
+ - Bundled the maintained multitenant teams-cli Entra public-client registration for zero-ID onboarding.
15
+ - Added explicit `--channel-history` consent and machine-readable authentication capability metadata.
16
+
17
+ ### Changed
18
+
19
+ - Normal login and token refresh now use only the user-consentable delegated permission baseline.
20
+ - Diagnostics now distinguish successful Microsoft identity access from an unlicensed or unprovisioned Teams tenant.
21
+
22
+ ## [0.1.1] - 2026-08-24
23
+
24
+ ### Fixed
25
+
26
+ - Made crates.io publication idempotent by checking the registry's version list with a privacy-preserving User-Agent before requesting publish credentials.
27
+
8
28
  ## [0.1.0] - 2026-08-24
9
29
 
10
30
  ### Added
@@ -16,4 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
36
  - Guided onboarding, diagnostics, completions, bounded collections, and deterministic TUI snapshots.
17
37
  - Distribution through crates.io, PyPI binary wheels, and signed GitHub release artifacts.
18
38
 
39
+ [Unreleased]: https://github.com/rvben/teams-cli/compare/v0.1.2...HEAD
40
+ [0.1.2]: https://github.com/rvben/teams-cli/compare/v0.1.1...v0.1.2
41
+ [0.1.1]: https://github.com/rvben/teams-cli/compare/v0.1.0...v0.1.1
19
42
  [0.1.0]: https://github.com/rvben/teams-cli/releases/tag/v0.1.0
@@ -2964,7 +2964,7 @@ dependencies = [
2964
2964
 
2965
2965
  [[package]]
2966
2966
  name = "teams-cli"
2967
- version = "0.1.0"
2967
+ version = "0.1.2"
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.0"
3
+ version = "0.1.2"
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.0
3
+ Version: 0.1.2
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  License-File: LICENSE
@@ -57,28 +57,44 @@ Run `teams` in a terminal on first use, or start explicitly:
57
57
  teams init
58
58
  ```
59
59
 
60
- You need a Microsoft Entra public-client application:
60
+ `teams-cli` includes a maintained multitenant Microsoft Entra public-client registration, so normal setup does not require creating an app or copying a client ID:
61
61
 
62
- 1. In [Microsoft Entra app registrations](https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade), register an app for organizational accounts.
63
- 2. Under **Authentication**, add the **Mobile and desktop applications** redirect URI `http://localhost` and enable public client flows.
64
- 3. Under **API permissions**, add these delegated Microsoft Graph permissions (plus the standard OpenID scopes requested at sign-in):
62
+ ```console
63
+ teams init
64
+ ```
65
+
66
+ Browser PKCE is the human-friendly default. The browser talks directly to Microsoft, and the CLI never receives or stores your password. Normal sign-in requests these delegated scopes:
65
67
 
66
68
  ```text
67
69
  openid profile offline_access User.Read Team.ReadBasic.All
68
70
  Channel.ReadBasic.All Chat.Read Chat.Create ChatMessage.Send
69
- ChannelMessage.Send ChannelMessage.Read.All
71
+ ChannelMessage.Send
72
+ ```
73
+
74
+ Reading channel history is intentionally separate because `ChannelMessage.Read.All` requires administrator consent. Request it only when needed:
75
+
76
+ ```console
77
+ teams auth login --channel-history
70
78
  ```
71
79
 
72
- Reading channel messages requires the administrator-consented `ChannelMessage.Read.All` permission. If the tenant has not granted it, Microsoft may require an administrator during sign-in. The CLI surfaces missing consent as a stable `permission_denied` error instead of silently reaching for undocumented Teams APIs.
80
+ The CLI surfaces missing consent as a stable `permission_denied` error instead of silently reaching for undocumented Teams APIs. Tenant policy can still require an administrator to approve otherwise user-consentable permissions.
73
81
 
74
82
  Headless setup is explicit and never prompts:
75
83
 
76
84
  ```console
77
- teams init --client-id "$TEAMS_CLIENT_ID" --tenant organizations --no-login
85
+ teams init --no-login
78
86
  teams auth login --device-code
79
87
  teams whoami --output json
80
88
  ```
81
89
 
90
+ Organizations that prefer their own Entra registration can override the bundled application:
91
+
92
+ ```console
93
+ teams init --client-id "$TEAMS_CLIENT_ID" --tenant organizations --no-login
94
+ ```
95
+
96
+ The custom app must be a public client with `http://localhost` registered for browser login, public-client flows enabled for device-code login, and the baseline delegated permissions listed above.
97
+
82
98
  For short-lived automation, `TEAMS_ACCESS_TOKEN` overrides the stored credential. Refresh credentials are never written to the config file.
83
99
 
84
100
  ## Everyday commands
@@ -88,6 +104,8 @@ teams list
88
104
  teams channels list TEAM_ID
89
105
  teams chats list --limit 25
90
106
  teams messages list --chat CHAT_ID
107
+ teams auth login --channel-history # once, with administrator approval
108
+ teams messages list --team TEAM_ID --channel CHANNEL_ID
91
109
  teams messages send --chat CHAT_ID --body "On it."
92
110
  printf 'Status update' | teams messages send --chat CHAT_ID --body -
93
111
  teams doctor
@@ -98,6 +116,7 @@ Collection commands expose `--limit` and `--fields`; Graph-native collections us
98
116
  ## Boundaries
99
117
 
100
118
  - Microsoft 365 work or school accounts only. The relevant Teams Graph endpoints do not support personal Microsoft accounts.
119
+ - A work account can authenticate successfully before Microsoft Teams is licensed or provisioned; `teams doctor` distinguishes identity access from Teams availability.
101
120
  - Calling and screen sharing are not available as a delegated user CLI; Graph calling is built around bot/application scenarios.
102
121
  - No extracted desktop tokens, browser interception, or private Teams protocols.
103
122
  - Tenant policy and admin consent can restrict delegated capabilities.
@@ -29,12 +29,13 @@ A human-first and agent-first Microsoft Teams terminal client: a polished TUI an
29
29
  - Installed from PyPI, preferably with `pipx install teams-cli`, despite the implementation being Rust.
30
30
  - Invoked as `teams` alongside sibling tools such as `jira` and `confluence`.
31
31
  - Used interactively in a local terminal, non-interactively in shell pipelines, and by AI agents.
32
- - Authenticates a Microsoft 365 work or school account through a customer-owned Microsoft Entra public-client application.
32
+ - Authenticates a Microsoft 365 work or school account through the maintained teams-cli Entra public-client application, with an explicit override for customer-owned registrations.
33
33
  - Opens the native or web Teams client for experiences that Microsoft Graph does not expose, such as normal user calls.
34
34
 
35
35
  ## Capabilities and Constraints
36
36
 
37
37
  - Browser-based delegated OAuth with PKCE is the preferred login; device-code flow is an explicit headless fallback.
38
+ - Everyday commands use a user-consentable baseline; administrator-consented channel history is a deliberate login opt-in.
38
39
  - Refresh credentials must use OS-protected storage and must never be written as plaintext tokens.
39
40
  - Initial product surface covers onboarding, authentication diagnostics, identity, teams, channels, chats, messages, and the TUI foundation.
40
41
  - Commands offer human text and structured JSON output, with clean stdout/stderr separation.
@@ -41,28 +41,44 @@ Run `teams` in a terminal on first use, or start explicitly:
41
41
  teams init
42
42
  ```
43
43
 
44
- You need a Microsoft Entra public-client application:
44
+ `teams-cli` includes a maintained multitenant Microsoft Entra public-client registration, so normal setup does not require creating an app or copying a client ID:
45
45
 
46
- 1. In [Microsoft Entra app registrations](https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade), register an app for organizational accounts.
47
- 2. Under **Authentication**, add the **Mobile and desktop applications** redirect URI `http://localhost` and enable public client flows.
48
- 3. Under **API permissions**, add these delegated Microsoft Graph permissions (plus the standard OpenID scopes requested at sign-in):
46
+ ```console
47
+ teams init
48
+ ```
49
+
50
+ Browser PKCE is the human-friendly default. The browser talks directly to Microsoft, and the CLI never receives or stores your password. Normal sign-in requests these delegated scopes:
49
51
 
50
52
  ```text
51
53
  openid profile offline_access User.Read Team.ReadBasic.All
52
54
  Channel.ReadBasic.All Chat.Read Chat.Create ChatMessage.Send
53
- ChannelMessage.Send ChannelMessage.Read.All
55
+ ChannelMessage.Send
56
+ ```
57
+
58
+ Reading channel history is intentionally separate because `ChannelMessage.Read.All` requires administrator consent. Request it only when needed:
59
+
60
+ ```console
61
+ teams auth login --channel-history
54
62
  ```
55
63
 
56
- Reading channel messages requires the administrator-consented `ChannelMessage.Read.All` permission. If the tenant has not granted it, Microsoft may require an administrator during sign-in. The CLI surfaces missing consent as a stable `permission_denied` error instead of silently reaching for undocumented Teams APIs.
64
+ The CLI surfaces missing consent as a stable `permission_denied` error instead of silently reaching for undocumented Teams APIs. Tenant policy can still require an administrator to approve otherwise user-consentable permissions.
57
65
 
58
66
  Headless setup is explicit and never prompts:
59
67
 
60
68
  ```console
61
- teams init --client-id "$TEAMS_CLIENT_ID" --tenant organizations --no-login
69
+ teams init --no-login
62
70
  teams auth login --device-code
63
71
  teams whoami --output json
64
72
  ```
65
73
 
74
+ Organizations that prefer their own Entra registration can override the bundled application:
75
+
76
+ ```console
77
+ teams init --client-id "$TEAMS_CLIENT_ID" --tenant organizations --no-login
78
+ ```
79
+
80
+ The custom app must be a public client with `http://localhost` registered for browser login, public-client flows enabled for device-code login, and the baseline delegated permissions listed above.
81
+
66
82
  For short-lived automation, `TEAMS_ACCESS_TOKEN` overrides the stored credential. Refresh credentials are never written to the config file.
67
83
 
68
84
  ## Everyday commands
@@ -72,6 +88,8 @@ teams list
72
88
  teams channels list TEAM_ID
73
89
  teams chats list --limit 25
74
90
  teams messages list --chat CHAT_ID
91
+ teams auth login --channel-history # once, with administrator approval
92
+ teams messages list --team TEAM_ID --channel CHANNEL_ID
75
93
  teams messages send --chat CHAT_ID --body "On it."
76
94
  printf 'Status update' | teams messages send --chat CHAT_ID --body -
77
95
  teams doctor
@@ -82,6 +100,7 @@ Collection commands expose `--limit` and `--fields`; Graph-native collections us
82
100
  ## Boundaries
83
101
 
84
102
  - Microsoft 365 work or school accounts only. The relevant Teams Graph endpoints do not support personal Microsoft accounts.
103
+ - A work account can authenticate successfully before Microsoft Teams is licensed or provisioned; `teams doctor` distinguishes identity access from Teams availability.
85
104
  - Calling and screen sharing are not available as a delegated user CLI; Graph calling is built around bot/application scenarios.
86
105
  - No extracted desktop tokens, browser interception, or private Teams protocols.
87
106
  - Tenant policy and admin consent can restrict delegated capabilities.
@@ -12,7 +12,9 @@ use crate::config::Profile;
12
12
  use crate::error::AppError;
13
13
  use crate::output::Output;
14
14
 
15
- pub const SCOPES: &str = "openid profile offline_access User.Read Team.ReadBasic.All Channel.ReadBasic.All Chat.Read Chat.Create ChatMessage.Send ChannelMessage.Send ChannelMessage.Read.All";
15
+ pub const BASE_SCOPES: &str = "openid profile offline_access User.Read Team.ReadBasic.All Channel.ReadBasic.All Chat.Read Chat.Create ChatMessage.Send ChannelMessage.Send";
16
+ pub const CHANNEL_HISTORY_SCOPE: &str = "ChannelMessage.Read.All";
17
+ pub const CHANNEL_HISTORY_SCOPES: &str = "openid profile offline_access User.Read Team.ReadBasic.All Channel.ReadBasic.All Chat.Read Chat.Create ChatMessage.Send ChannelMessage.Send ChannelMessage.Read.All";
16
18
  const SERVICE: &str = "teams-cli";
17
19
 
18
20
  #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -59,6 +61,28 @@ pub fn has_token(profile_name: &str) -> bool {
59
61
  .is_ok()
60
62
  }
61
63
 
64
+ pub fn granted_scopes(profile_name: &str) -> Option<Vec<String>> {
65
+ if std::env::var("TEAMS_ACCESS_TOKEN").is_ok() {
66
+ return None;
67
+ }
68
+ load(profile_name).ok().and_then(|token| {
69
+ token.scope.map(|scope| {
70
+ scope
71
+ .split_whitespace()
72
+ .map(str::to_owned)
73
+ .collect::<Vec<_>>()
74
+ })
75
+ })
76
+ }
77
+
78
+ pub fn channel_history_granted(profile_name: &str) -> Option<bool> {
79
+ granted_scopes(profile_name).map(|scopes| {
80
+ scopes
81
+ .iter()
82
+ .any(|scope| scope.eq_ignore_ascii_case(CHANNEL_HISTORY_SCOPE))
83
+ })
84
+ }
85
+
62
86
  fn store(profile_name: &str, token: &TokenBundle) -> Result<(), AppError> {
63
87
  let encoded = serde_json::to_string(token).map_err(|e| AppError::Unexpected(e.to_string()))?;
64
88
  entry(profile_name)?.set_password(&encoded).map_err(|e| {
@@ -105,7 +129,7 @@ pub async fn access_token(profile_name: &str, profile: &Profile) -> Result<Strin
105
129
  ("client_id", profile.client_id.as_str()),
106
130
  ("grant_type", "refresh_token"),
107
131
  ("refresh_token", refresh),
108
- ("scope", SCOPES),
132
+ ("scope", bundle.scope.as_deref().unwrap_or(BASE_SCOPES)),
109
133
  ])
110
134
  .send()
111
135
  .await
@@ -123,18 +147,32 @@ pub async fn login(
123
147
  profile_name: &str,
124
148
  profile: &Profile,
125
149
  device_code: bool,
150
+ channel_history: bool,
126
151
  out: &Output,
127
152
  ) -> Result<TokenBundle, AppError> {
153
+ let scopes = scopes(channel_history);
128
154
  let bundle = if device_code {
129
- device_login(profile, out).await?
155
+ device_login(profile, scopes, out).await?
130
156
  } else {
131
- browser_login(profile, out).await?
157
+ browser_login(profile, scopes, out).await?
132
158
  };
133
159
  store(profile_name, &bundle)?;
134
160
  Ok(bundle)
135
161
  }
136
162
 
137
- async fn browser_login(profile: &Profile, out: &Output) -> Result<TokenBundle, AppError> {
163
+ fn scopes(channel_history: bool) -> &'static str {
164
+ if channel_history {
165
+ CHANNEL_HISTORY_SCOPES
166
+ } else {
167
+ BASE_SCOPES
168
+ }
169
+ }
170
+
171
+ async fn browser_login(
172
+ profile: &Profile,
173
+ scopes: &str,
174
+ out: &Output,
175
+ ) -> Result<TokenBundle, AppError> {
138
176
  let listener = TcpListener::bind("127.0.0.1:0").await?;
139
177
  let redirect = format!("http://localhost:{}", listener.local_addr()?.port());
140
178
  let mut verifier_bytes = [0u8; 32];
@@ -156,7 +194,7 @@ async fn browser_login(profile: &Profile, out: &Output) -> Result<TokenBundle, A
156
194
  .append_pair("response_type", "code")
157
195
  .append_pair("redirect_uri", &redirect)
158
196
  .append_pair("response_mode", "query")
159
- .append_pair("scope", SCOPES)
197
+ .append_pair("scope", scopes)
160
198
  .append_pair("code_challenge", &challenge)
161
199
  .append_pair("code_challenge_method", "S256")
162
200
  .append_pair("state", &state);
@@ -217,7 +255,7 @@ async fn browser_login(profile: &Profile, out: &Output) -> Result<TokenBundle, A
217
255
  ("code", code.as_str()),
218
256
  ("redirect_uri", redirect.as_str()),
219
257
  ("code_verifier", verifier.as_str()),
220
- ("scope", SCOPES),
258
+ ("scope", scopes),
221
259
  ])
222
260
  .send()
223
261
  .await
@@ -235,13 +273,17 @@ struct DeviceCode {
235
273
  message: Option<String>,
236
274
  }
237
275
 
238
- async fn device_login(profile: &Profile, out: &Output) -> Result<TokenBundle, AppError> {
276
+ async fn device_login(
277
+ profile: &Profile,
278
+ scopes: &str,
279
+ out: &Output,
280
+ ) -> Result<TokenBundle, AppError> {
239
281
  let response = reqwest::Client::new()
240
282
  .post(format!(
241
283
  "https://login.microsoftonline.com/{}/oauth2/v2.0/devicecode",
242
284
  profile.tenant
243
285
  ))
244
- .form(&[("client_id", profile.client_id.as_str()), ("scope", SCOPES)])
286
+ .form(&[("client_id", profile.client_id.as_str()), ("scope", scopes)])
245
287
  .send()
246
288
  .await
247
289
  .map_err(|e| AppError::Unexpected(format!("sign-in service is unreachable: {e}")))?;
@@ -328,3 +370,19 @@ async fn oauth_response_error(response: reqwest::Response) -> AppError {
328
370
  .unwrap_or_else(|| format!("Microsoft sign-in failed with {status}")),
329
371
  )
330
372
  }
373
+
374
+ #[cfg(test)]
375
+ mod tests {
376
+ use super::{BASE_SCOPES, CHANNEL_HISTORY_SCOPE, scopes};
377
+
378
+ #[test]
379
+ fn default_login_is_least_privilege() {
380
+ assert!(!BASE_SCOPES.contains(CHANNEL_HISTORY_SCOPE));
381
+ assert_eq!(scopes(false), BASE_SCOPES);
382
+ }
383
+
384
+ #[test]
385
+ fn channel_history_is_explicitly_opted_in() {
386
+ assert!(scopes(true).contains(CHANNEL_HISTORY_SCOPE));
387
+ }
388
+ }
@@ -72,7 +72,7 @@ pub enum Command {
72
72
 
73
73
  #[derive(Debug, Args)]
74
74
  pub struct InitArgs {
75
- /// Public-client application (client) ID
75
+ /// Override the maintained teams-cli public-client application ID
76
76
  #[arg(long, env = "TEAMS_CLIENT_ID")]
77
77
  pub client_id: Option<String>,
78
78
  /// Tenant ID/domain, or `organizations`
@@ -84,14 +84,21 @@ pub struct InitArgs {
84
84
  /// Use device-code sign-in instead of browser PKCE
85
85
  #[arg(long)]
86
86
  pub device_code: bool,
87
+ /// Request admin-consented access to read channel history
88
+ #[arg(long)]
89
+ pub channel_history: bool,
87
90
  }
88
91
 
89
92
  #[derive(Debug, Subcommand)]
90
93
  pub enum AuthCommand {
91
94
  /// Sign in with delegated OAuth
92
95
  Login {
96
+ /// Use device-code sign-in for headless and remote environments
93
97
  #[arg(long)]
94
98
  device_code: bool,
99
+ /// Request admin-consented access to read channel history
100
+ #[arg(long)]
101
+ channel_history: bool,
95
102
  },
96
103
  /// Remove locally stored credentials
97
104
  Logout,
@@ -5,6 +5,10 @@ use serde::{Deserialize, Serialize};
5
5
 
6
6
  use crate::error::AppError;
7
7
 
8
+ /// The maintained, multitenant public-client registration shipped with teams-cli.
9
+ pub const DEFAULT_CLIENT_ID: &str = "66ebad71-1604-48fc-a086-0d4caa24988b";
10
+ pub const DEFAULT_TENANT: &str = "organizations";
11
+
8
12
  #[derive(Debug, Clone, Serialize, Deserialize)]
9
13
  pub struct Profile {
10
14
  pub client_id: String,
@@ -13,7 +17,7 @@ pub struct Profile {
13
17
  }
14
18
 
15
19
  fn default_tenant() -> String {
16
- "organizations".into()
20
+ DEFAULT_TENANT.into()
17
21
  }
18
22
 
19
23
  #[derive(Debug, Default, Serialize, Deserialize)]
@@ -47,6 +47,12 @@ impl GraphClient {
47
47
  .await
48
48
  }
49
49
 
50
+ pub async fn teams_available(&self) -> Result<(), AppError> {
51
+ self.get_value(&format!("{GRAPH}/me/joinedTeams"))
52
+ .await
53
+ .map(|_| ())
54
+ }
55
+
50
56
  pub async fn channels(&self, team: &str, limit: u16, offset: usize) -> Result<Page, AppError> {
51
57
  self.get_offset_page(
52
58
  &format!(
@@ -242,15 +248,33 @@ impl GraphClient {
242
248
  .unwrap_or("Microsoft Graph rejected the request");
243
249
  match status.as_u16() {
244
250
  401 => Err(AppError::Auth(format!("{message}; run `teams auth login`"))),
245
- 403 => Err(AppError::Permission(format!(
246
- "{message}; check delegated scopes and tenant consent with `teams doctor`"
247
- ))),
251
+ 403 => Err(permission_error(message)),
248
252
  404 => Err(AppError::NotFound(message.into())),
249
253
  _ => Err(AppError::Api(format!("{message} ({status})"))),
250
254
  }
251
255
  }
252
256
  }
253
257
 
258
+ fn permission_error(message: &str) -> AppError {
259
+ let message = message.trim();
260
+ let lower = message.to_ascii_lowercase();
261
+ let hint = if lower.contains("hasn't been provisioned")
262
+ || lower.contains("has not been provisioned")
263
+ || lower.contains("valid office365 subscription")
264
+ || lower.contains("valid microsoft 365 subscription")
265
+ {
266
+ "sign in with a work or school account that has Microsoft Teams enabled"
267
+ } else {
268
+ "check delegated scopes and tenant consent with `teams doctor`"
269
+ };
270
+ let separator = if matches!(message.chars().last(), Some('.' | '!' | '?')) {
271
+ " "
272
+ } else {
273
+ "; "
274
+ };
275
+ AppError::Permission(format!("{message}{separator}{hint}"))
276
+ }
277
+
254
278
  fn is_graph_url(raw: &str) -> bool {
255
279
  url::Url::parse(raw).is_ok_and(|url| {
256
280
  url.scheme() == "https"
@@ -305,4 +329,17 @@ mod tests {
305
329
  "http://graph.microsoft.com/v1.0/me/chats"
306
330
  ));
307
331
  }
332
+
333
+ #[test]
334
+ fn unprovisioned_tenants_get_a_teams_specific_hint() {
335
+ let error = super::permission_error(
336
+ "Microsoft Teams hasn't been provisioned on the tenant. Ensure the tenant has a valid Office365 subscription.",
337
+ );
338
+ assert!(
339
+ error
340
+ .to_string()
341
+ .contains("account that has Microsoft Teams enabled")
342
+ );
343
+ assert!(!error.to_string().contains(".;"));
344
+ }
308
345
  }
@@ -1,4 +1,4 @@
1
- use std::io::{self, IsTerminal, Read, Write};
1
+ use std::io::{self, IsTerminal, Read};
2
2
 
3
3
  use clap::{CommandFactory, Parser};
4
4
  use serde::Serialize;
@@ -59,6 +59,7 @@ async fn dispatch(cli: Cli, out: Output) -> Result<(), AppError> {
59
59
  tenant: "organizations".into(),
60
60
  no_login: false,
61
61
  device_code: false,
62
+ channel_history: false,
62
63
  })
63
64
  }
64
65
  None if io::stdin().is_terminal() && io::stdout().is_terminal() => Command::Tui(TuiArgs {
@@ -178,24 +179,13 @@ async fn init(profile_name: &str, args: InitArgs, out: Output) -> Result<(), App
178
179
  let interactive = io::stdin().is_terminal() && io::stderr().is_terminal();
179
180
  if interactive && !out.json() {
180
181
  eprintln!("\n teams\n Microsoft Teams, without leaving your flow.\n");
181
- eprintln!(" Before we sign in, create a Microsoft Entra public-client app.");
182
- eprintln!(
183
- " 1. Open https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade"
184
- );
185
- eprintln!(" 2. Register a multitenant app for organizational accounts.");
186
- eprintln!(" 3. Add Mobile and desktop redirect URI: http://localhost");
187
- eprintln!(
188
- " 4. Enable public client flows and grant the delegated permissions in README.md."
189
- );
190
- eprintln!(
191
- " Work or school accounts are supported; personal Microsoft accounts are not.\n"
192
- );
182
+ eprintln!(" We’ll open Microsoft sign-in in your browser.");
183
+ eprintln!(" No password or client secret is shared with teams-cli.");
184
+ eprintln!(" Work or school accounts are supported; personal accounts are not.\n");
193
185
  }
194
- let client_id = match args.client_id {
195
- Some(value) => value,
196
- None if interactive => prompt("Application (client) ID")?,
197
- None => return Err(AppError::NonInteractive("--client-id is required without a terminal; example: `teams init --client-id <uuid> --no-login`".into())),
198
- };
186
+ let client_id = args
187
+ .client_id
188
+ .unwrap_or_else(|| config::DEFAULT_CLIENT_ID.into());
199
189
  if client_id.trim().is_empty() {
200
190
  return Err(AppError::InvalidInput("client ID cannot be empty".into()));
201
191
  }
@@ -207,14 +197,21 @@ async fn init(profile_name: &str, args: InitArgs, out: Output) -> Result<(), App
207
197
  let path = config::save(
208
198
  profile_name,
209
199
  Profile {
210
- client_id,
211
- tenant: args.tenant,
200
+ client_id: client_id.clone(),
201
+ tenant: args.tenant.clone(),
212
202
  },
213
203
  )?;
214
204
  let mut signed_in = false;
215
205
  if !args.no_login {
216
206
  let (_, profile) = config::load(Some(profile_name))?;
217
- auth::login(profile_name, &profile, args.device_code, &out).await?;
207
+ auth::login(
208
+ profile_name,
209
+ &profile,
210
+ args.device_code,
211
+ args.channel_history,
212
+ &out,
213
+ )
214
+ .await?;
218
215
  signed_in = true;
219
216
  }
220
217
  #[derive(Serialize)]
@@ -222,11 +219,17 @@ async fn init(profile_name: &str, args: InitArgs, out: Output) -> Result<(), App
222
219
  profile: &'a str,
223
220
  config_path: String,
224
221
  signed_in: bool,
222
+ client_id: &'a str,
223
+ tenant: &'a str,
224
+ channel_history_requested: bool,
225
225
  }
226
226
  let result = InitResult {
227
227
  profile: profile_name,
228
228
  config_path: path.display().to_string(),
229
229
  signed_in,
230
+ client_id: &client_id,
231
+ tenant: &args.tenant,
232
+ channel_history_requested: args.channel_history,
230
233
  };
231
234
  out.value(&result, || {
232
235
  if signed_in {
@@ -243,7 +246,10 @@ async fn auth_command(
243
246
  out: Output,
244
247
  ) -> Result<(), AppError> {
245
248
  match command {
246
- AuthCommand::Login { device_code } => {
249
+ AuthCommand::Login {
250
+ device_code,
251
+ channel_history,
252
+ } => {
247
253
  if !device_code && (!io::stdin().is_terminal() || !io::stderr().is_terminal()) {
248
254
  return Err(AppError::NonInteractive(
249
255
  "browser sign-in requires a terminal; use `teams auth login --device-code` for headless sign-in"
@@ -251,8 +257,8 @@ async fn auth_command(
251
257
  ));
252
258
  }
253
259
  let (name, profile) = config::load(profile_arg)?;
254
- let token = auth::login(&name, &profile, device_code, &out).await?;
255
- let value = serde_json::json!({"profile":name,"expires_at":token.expires_at,"scope":token.scope});
260
+ let token = auth::login(&name, &profile, device_code, channel_history, &out).await?;
261
+ let value = serde_json::json!({"profile":name,"expires_at":token.expires_at,"scope":token.scope,"channel_history_requested":channel_history});
256
262
  out.value(&value, || format!("Signed in profile '{name}'."))
257
263
  }
258
264
  AuthCommand::Logout => {
@@ -268,12 +274,15 @@ async fn auth_command(
268
274
  .map(|(n, _)| n.as_str())
269
275
  .unwrap_or(profile_arg.unwrap_or("default"));
270
276
  let signed_in = auth::has_token(name);
271
- let value = serde_json::json!({"profile":name,"configured":configured.is_some(),"signed_in":signed_in,"config_path":config::path()});
277
+ let scopes = auth::granted_scopes(name);
278
+ let channel_history = auth::channel_history_granted(name);
279
+ let value = serde_json::json!({"profile":name,"configured":configured.is_some(),"signed_in":signed_in,"config_path":config::path(),"granted_scopes":scopes,"channel_history":channel_history});
272
280
  out.value(&value, || {
273
281
  format!(
274
- "Profile: {name}\nConfigured: {}\nSigned in: {}\nConfig: {}",
282
+ "Profile: {name}\nConfigured: {}\nSigned in: {}\nChannel history: {}\nConfig: {}",
275
283
  yes_no(configured.is_some()),
276
284
  yes_no(signed_in),
285
+ optional_access(channel_history),
277
286
  config::path().display()
278
287
  )
279
288
  })
@@ -401,32 +410,71 @@ async fn doctor(profile_arg: Option<&str>, out: Output) -> Result<(), AppError>
401
410
  "run `teams auth login`".into()
402
411
  },
403
412
  });
404
- let mut graph_ok = false;
405
- let detail = if configured.is_some() && signed_in {
413
+ let channel_history = auth::channel_history_granted(
414
+ configured
415
+ .as_ref()
416
+ .map(|(name, _)| name.as_str())
417
+ .unwrap_or("default"),
418
+ );
419
+ checks.push(Check {
420
+ name: "channel_history",
421
+ status: if channel_history == Some(true) {
422
+ "pass"
423
+ } else {
424
+ "optional"
425
+ },
426
+ detail: if channel_history == Some(true) {
427
+ "admin-consented channel reading is available".into()
428
+ } else {
429
+ "optional; run `teams auth login --channel-history` if an admin approves it".into()
430
+ },
431
+ });
432
+ let mut identity_ok = false;
433
+ let mut teams_ok = false;
434
+ let (identity_detail, teams_detail) = if configured.is_some() && signed_in {
406
435
  match client(profile_arg).await {
407
- Ok(client) => match client.me().await {
408
- Ok(value) => {
409
- graph_ok = true;
410
- format!(
411
- "signed in as {}",
412
- string_at(&value, "/userPrincipalName")
413
- .or_else(|| string_at(&value, "/displayName"))
414
- .unwrap_or("unknown user")
415
- )
416
- }
417
- Err(error) => error.to_string(),
418
- },
419
- Err(error) => error.to_string(),
436
+ Ok(client) => {
437
+ let identity_detail = match client.me().await {
438
+ Ok(value) => {
439
+ identity_ok = true;
440
+ format!(
441
+ "signed in as {}",
442
+ string_at(&value, "/userPrincipalName")
443
+ .or_else(|| string_at(&value, "/displayName"))
444
+ .unwrap_or("unknown user")
445
+ )
446
+ }
447
+ Err(error) => error.to_string(),
448
+ };
449
+ let teams_detail = match client.teams_available().await {
450
+ Ok(_) => {
451
+ teams_ok = true;
452
+ "Microsoft Teams is available".into()
453
+ }
454
+ Err(error) => error.to_string(),
455
+ };
456
+ (identity_detail, teams_detail)
457
+ }
458
+ Err(error) => {
459
+ let detail = error.to_string();
460
+ (detail.clone(), detail)
461
+ }
420
462
  }
421
463
  } else {
422
- "skipped until setup is complete".into()
464
+ let detail = "skipped until setup is complete".to_string();
465
+ (detail.clone(), detail)
423
466
  };
424
467
  checks.push(Check {
425
- name: "microsoft_graph",
426
- status: if graph_ok { "pass" } else { "fail" },
427
- detail,
468
+ name: "microsoft_identity",
469
+ status: if identity_ok { "pass" } else { "fail" },
470
+ detail: identity_detail,
471
+ });
472
+ checks.push(Check {
473
+ name: "microsoft_teams",
474
+ status: if teams_ok { "pass" } else { "fail" },
475
+ detail: teams_detail,
428
476
  });
429
- let healthy = checks.iter().all(|check| check.status == "pass");
477
+ let healthy = checks.iter().all(|check| check.status != "fail");
430
478
  let value = serde_json::json!({"checks":checks,"healthy":healthy});
431
479
  out.value(&value, || {
432
480
  checks
@@ -443,19 +491,11 @@ fn capabilities(out: Output) -> Result<(), AppError> {
443
491
  "unsupported":["personal Microsoft accounts","user calls and screen sharing","private Teams protocols","tenant administration"],
444
492
  "account_types":["Microsoft 365 work or school"],
445
493
  "api":"Microsoft Graph v1.0",
446
- "notes":["Reading channel messages can require admin consent for ChannelMessage.Read.All.","Calls require bot/application APIs and are outside this user client."]
494
+ "notes":["Normal sign-in uses only user-consentable delegated permissions.","Reading channel messages requires `teams auth login --channel-history` and admin consent for ChannelMessage.Read.All.","Calls require bot/application APIs and are outside this user client."]
447
495
  });
448
496
  out.value(&value, || "Supported\n Work/school delegated sign-in; teams, channels, chats, messages; send as yourself; TUI\n\nNot supported\n Personal Microsoft accounts; calls or screen sharing; private Teams protocols; tenant administration\n\nRun `teams schema` for the machine contract.".into())
449
497
  }
450
498
 
451
- fn prompt(label: &str) -> Result<String, AppError> {
452
- eprint!(" {label}: ");
453
- io::stderr().flush()?;
454
- let mut value = String::new();
455
- io::stdin().read_line(&mut value)?;
456
- Ok(value.trim().into())
457
- }
458
-
459
499
  fn read_body(body: &str) -> Result<String, AppError> {
460
500
  if body != "-" {
461
501
  return Ok(body.into());
@@ -471,6 +511,13 @@ fn string_at<'a>(value: &'a Value, pointer: &str) -> Option<&'a str> {
471
511
  fn yes_no(value: bool) -> &'static str {
472
512
  if value { "yes" } else { "no" }
473
513
  }
514
+ fn optional_access(value: Option<bool>) -> &'static str {
515
+ match value {
516
+ Some(true) => "granted",
517
+ Some(false) => "not granted (optional)",
518
+ None => "unknown",
519
+ }
520
+ }
474
521
  fn identity_text(value: &Value) -> String {
475
522
  format!(
476
523
  "{}\n{}",
@@ -11,6 +11,9 @@ fn array_field(name: &str, item_kind: &str) -> Value {
11
11
  fn nullable_field(name: &str, kind: &str) -> Value {
12
12
  json!({"name": name, "type": kind, "nullable": true})
13
13
  }
14
+ fn nullable_array_field(name: &str, item_kind: &str) -> Value {
15
+ json!({"name": name, "type": "array", "items": {"type": item_kind}, "nullable": true})
16
+ }
14
17
  fn arg(name: &str, kind: &str, description: &str) -> Value {
15
18
  json!({"name": name, "type": kind, "description": description})
16
19
  }
@@ -67,17 +70,29 @@ pub fn generate(command_filter: Option<&str>) -> Value {
67
70
  field("profile", "string"),
68
71
  field("config_path", "string"),
69
72
  field("signed_in", "boolean"),
73
+ field("client_id", "string"),
74
+ field("tenant", "string"),
75
+ field("channel_history_requested", "boolean"),
70
76
  ],
71
77
  );
72
78
  value["args"] = json!([
73
- arg("--client-id", "string", "Public-client application ID"),
79
+ arg(
80
+ "--client-id",
81
+ "string",
82
+ "Override the maintained teams-cli public-client application ID"
83
+ ),
74
84
  arg("--tenant", "string", "Tenant ID, domain, or organizations"),
75
85
  arg(
76
86
  "--no-login",
77
87
  "boolean",
78
88
  "Save configuration without signing in"
79
89
  ),
80
- arg("--device-code", "boolean", "Use device-code sign-in")
90
+ arg("--device-code", "boolean", "Use device-code sign-in"),
91
+ arg(
92
+ "--channel-history",
93
+ "boolean",
94
+ "Request admin-consented channel-history access"
95
+ )
81
96
  ]);
82
97
  value
83
98
  },
@@ -90,9 +105,21 @@ pub fn generate(command_filter: Option<&str>) -> Value {
90
105
  field("profile", "string"),
91
106
  field("expires_at", "integer"),
92
107
  nullable_field("scope", "string"),
108
+ field("channel_history_requested", "boolean"),
93
109
  ],
94
110
  );
95
- value["args"] = json!([arg("--device-code", "boolean", "Use device-code sign-in")]);
111
+ value["args"] = json!([
112
+ arg(
113
+ "--device-code",
114
+ "boolean",
115
+ "Use device-code sign-in for headless and remote environments"
116
+ ),
117
+ arg(
118
+ "--channel-history",
119
+ "boolean",
120
+ "Request admin-consented channel-history access"
121
+ )
122
+ ]);
96
123
  value
97
124
  },
98
125
  single(
@@ -110,6 +137,8 @@ pub fn generate(command_filter: Option<&str>) -> Value {
110
137
  field("configured", "boolean"),
111
138
  field("signed_in", "boolean"),
112
139
  field("config_path", "string"),
140
+ nullable_array_field("granted_scopes", "string"),
141
+ nullable_field("channel_history", "boolean"),
113
142
  ],
114
143
  ),
115
144
  single(
@@ -157,7 +186,7 @@ pub fn generate(command_filter: Option<&str>) -> Value {
157
186
  {
158
187
  let mut value = paged(
159
188
  "messages list",
160
- "List messages from one chat or channel",
189
+ "List messages from one chat, or from a channel with optional admin-consented access",
161
190
  vec![
162
191
  field("id", "string"),
163
192
  field("createdDateTime", "string"),
@@ -198,7 +227,7 @@ pub fn generate(command_filter: Option<&str>) -> Value {
198
227
  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")]}),
199
228
  single(
200
229
  "doctor",
201
- "Check configuration, credential storage, and Graph access",
230
+ "Check configuration, credential storage, Microsoft identity, and Teams provisioning",
202
231
  "read_only",
203
232
  vec![array_field("checks", "object"), field("healthy", "boolean")],
204
233
  ),
@@ -241,6 +270,13 @@ pub fn generate(command_filter: Option<&str>) -> Value {
241
270
  ],
242
271
  "commands": commands,
243
272
  "errors": error::ALL.iter().map(|e| json!({"kind":e.kind,"exit_code":e.exit_code,"retryable":e.retryable,"description":e.description})).collect::<Vec<_>>(),
244
- "extensions": {"authentication":"delegated_oauth", "api":"Microsoft Graph v1.0"}
273
+ "extensions": {
274
+ "authentication":"delegated_oauth",
275
+ "api":"Microsoft Graph v1.0",
276
+ "default_client_id": crate::config::DEFAULT_CLIENT_ID,
277
+ "login_modes":["browser_pkce","device_code"],
278
+ "baseline_scopes": crate::auth::BASE_SCOPES.split_whitespace().collect::<Vec<_>>(),
279
+ "privileged_scopes":[crate::auth::CHANNEL_HISTORY_SCOPE]
280
+ }
245
281
  })
246
282
  }
@@ -106,13 +106,20 @@ fn no_args_never_prompts_when_piped() {
106
106
  }
107
107
 
108
108
  #[test]
109
- fn noninteractive_init_requires_client_id() {
110
- Command::cargo_bin("teams")
109
+ fn noninteractive_init_uses_the_maintained_client_id() {
110
+ let temp = tempfile::tempdir().unwrap();
111
+ let output = Command::cargo_bin("teams")
111
112
  .unwrap()
113
+ .env("XDG_CONFIG_HOME", temp.path())
112
114
  .args(["init", "--no-login"])
113
- .assert()
114
- .code(8)
115
- .stderr(predicate::str::contains("--client-id is required"));
115
+ .output()
116
+ .unwrap();
117
+ assert!(output.status.success());
118
+ let result: Value = serde_json::from_slice(&output.stdout).unwrap();
119
+ assert_eq!(result["client_id"], "66ebad71-1604-48fc-a086-0d4caa24988b");
120
+ assert_eq!(result["tenant"], "organizations");
121
+ assert_eq!(result["channel_history_requested"], false);
122
+ assert!(temp.path().join("teams/config.toml").is_file());
116
123
  }
117
124
 
118
125
  #[test]
@@ -121,11 +128,7 @@ fn headless_browser_login_refuses_before_writing_config() {
121
128
  Command::cargo_bin("teams")
122
129
  .unwrap()
123
130
  .env("XDG_CONFIG_HOME", temp.path())
124
- .args([
125
- "init",
126
- "--client-id",
127
- "00000000-0000-0000-0000-000000000000",
128
- ])
131
+ .arg("init")
129
132
  .assert()
130
133
  .code(8)
131
134
  .stderr(predicate::str::contains(
@@ -133,3 +136,29 @@ fn headless_browser_login_refuses_before_writing_config() {
133
136
  ));
134
137
  assert!(!temp.path().join("teams/config.toml").exists());
135
138
  }
139
+
140
+ #[test]
141
+ fn schema_declares_default_app_and_privileged_scope() {
142
+ let output = Command::cargo_bin("teams")
143
+ .unwrap()
144
+ .arg("schema")
145
+ .output()
146
+ .unwrap();
147
+ assert!(output.status.success());
148
+ let schema: Value = serde_json::from_slice(&output.stdout).unwrap();
149
+ assert_eq!(
150
+ schema["extensions"]["default_client_id"],
151
+ "66ebad71-1604-48fc-a086-0d4caa24988b"
152
+ );
153
+ assert!(
154
+ schema["extensions"]["baseline_scopes"]
155
+ .as_array()
156
+ .unwrap()
157
+ .iter()
158
+ .all(|scope| scope != "ChannelMessage.Read.All")
159
+ );
160
+ assert_eq!(
161
+ schema["extensions"]["privileged_scopes"],
162
+ serde_json::json!(["ChannelMessage.Read.All"])
163
+ );
164
+ }
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