teams-cli 0.1.5__tar.gz → 0.1.7__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.5 → teams_cli-0.1.7}/.github/workflows/ci.yml +6 -6
  2. teams_cli-0.1.7/.github/workflows/dependencies.yml +28 -0
  3. {teams_cli-0.1.5 → teams_cli-0.1.7}/.github/workflows/release.yml +21 -21
  4. {teams_cli-0.1.5 → teams_cli-0.1.7}/CHANGELOG.md +16 -0
  5. {teams_cli-0.1.5 → teams_cli-0.1.7}/Cargo.lock +6 -6
  6. {teams_cli-0.1.5 → teams_cli-0.1.7}/Cargo.toml +1 -1
  7. {teams_cli-0.1.5 → teams_cli-0.1.7}/PKG-INFO +8 -2
  8. {teams_cli-0.1.5 → teams_cli-0.1.7}/README.md +7 -1
  9. {teams_cli-0.1.5 → teams_cli-0.1.7}/src/auth.rs +27 -0
  10. {teams_cli-0.1.5 → teams_cli-0.1.7}/src/cli.rs +29 -3
  11. {teams_cli-0.1.5 → teams_cli-0.1.7}/src/config.rs +56 -2
  12. {teams_cli-0.1.5 → teams_cli-0.1.7}/src/main.rs +87 -10
  13. {teams_cli-0.1.5 → teams_cli-0.1.7}/src/schema.rs +64 -18
  14. {teams_cli-0.1.5 → teams_cli-0.1.7}/tests/cli.rs +52 -0
  15. teams_cli-0.1.5/.github/dependabot.yml +0 -6
  16. {teams_cli-0.1.5 → teams_cli-0.1.7}/.gitignore +0 -0
  17. {teams_cli-0.1.5 → teams_cli-0.1.7}/.mise.toml +0 -0
  18. {teams_cli-0.1.5 → teams_cli-0.1.7}/DESIGN.md +0 -0
  19. {teams_cli-0.1.5 → teams_cli-0.1.7}/LICENSE +0 -0
  20. {teams_cli-0.1.5 → teams_cli-0.1.7}/Makefile +0 -0
  21. {teams_cli-0.1.5 → teams_cli-0.1.7}/PRODUCT.md +0 -0
  22. {teams_cli-0.1.5 → teams_cli-0.1.7}/docs/releases.md +0 -0
  23. {teams_cli-0.1.5 → teams_cli-0.1.7}/examples/validate_schema.rs +0 -0
  24. {teams_cli-0.1.5 → teams_cli-0.1.7}/pyproject.toml +0 -0
  25. {teams_cli-0.1.5 → teams_cli-0.1.7}/src/error.rs +0 -0
  26. {teams_cli-0.1.5 → teams_cli-0.1.7}/src/graph.rs +0 -0
  27. {teams_cli-0.1.5 → teams_cli-0.1.7}/src/lib.rs +0 -0
  28. {teams_cli-0.1.5 → teams_cli-0.1.7}/src/output.rs +0 -0
  29. {teams_cli-0.1.5 → teams_cli-0.1.7}/src/teams_cli_alias.rs +0 -0
  30. {teams_cli-0.1.5 → teams_cli-0.1.7}/src/tui.rs +0 -0
  31. {teams_cli-0.1.5 → teams_cli-0.1.7}/tests/package_alias.rs +0 -0
  32. {teams_cli-0.1.5 → teams_cli-0.1.7}/vership.toml +0 -0
@@ -17,24 +17,24 @@ jobs:
17
17
  os: [ubuntu-latest, macos-latest, windows-latest]
18
18
  runs-on: ${{ matrix.os }}
19
19
  steps:
20
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
20
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
21
21
  - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
22
22
  with:
23
23
  toolchain: 1.96.0
24
24
  components: rustfmt, clippy
25
- - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
25
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
26
26
  - run: cargo test --locked --all-targets
27
27
 
28
28
  quality:
29
29
  name: Formatting and lints
30
30
  runs-on: ubuntu-latest
31
31
  steps:
32
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
32
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
33
33
  - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
34
34
  with:
35
35
  toolchain: 1.96.0
36
36
  components: rustfmt, clippy
37
- - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
37
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
38
38
  - run: cargo fmt -- --check
39
39
  - run: cargo clippy --locked --all-targets -- -D warnings
40
40
 
@@ -42,12 +42,12 @@ jobs:
42
42
  name: Dependency audit
43
43
  runs-on: ubuntu-latest
44
44
  steps:
45
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
45
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
46
46
  - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
47
47
  with:
48
48
  toolchain: 1.96.0
49
49
  components: rustfmt, clippy
50
- - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
50
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
51
51
  - name: Install cargo-audit
52
52
  run: cargo install cargo-audit --version 0.22.2 --locked
53
53
  - name: Audit Cargo.lock
@@ -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"
@@ -23,7 +23,7 @@ jobs:
23
23
  name: Verify release version
24
24
  runs-on: ubuntu-latest
25
25
  steps:
26
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
26
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
27
27
  - name: Match tag to package version
28
28
  env:
29
29
  REQUESTED_VERSION: ${{ inputs.version }}
@@ -69,13 +69,13 @@ jobs:
69
69
  id-token: write
70
70
  attestations: write
71
71
  steps:
72
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
72
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
73
73
  - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
74
74
  with:
75
75
  toolchain: 1.96.0
76
76
  components: rustfmt, clippy
77
77
  targets: ${{ matrix.target }}
78
- - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
78
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
79
79
  with:
80
80
  key: ${{ matrix.target }}
81
81
  - run: cargo build --locked --release --target ${{ matrix.target }}
@@ -89,10 +89,10 @@ jobs:
89
89
  rm -rf dist/package
90
90
  - name: Attest build provenance
91
91
  if: ${{ inputs.dry_run != true }}
92
- uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6
92
+ uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
93
93
  with:
94
94
  subject-path: dist/*.tar.gz
95
- - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
95
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
96
96
  with:
97
97
  name: binary-${{ matrix.target }}
98
98
  path: dist/*.tar.gz
@@ -119,7 +119,7 @@ jobs:
119
119
  manylinux: auto
120
120
  runs-on: ${{ matrix.os }}
121
121
  steps:
122
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
122
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
123
123
  - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
124
124
  with:
125
125
  maturin-version: v1.14.1
@@ -127,12 +127,12 @@ jobs:
127
127
  target: ${{ matrix.target }}
128
128
  manylinux: ${{ matrix.manylinux }}
129
129
  args: --release --locked --compatibility pypi --out dist
130
- - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d
130
+ - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
131
131
  if: ${{ inputs.dry_run == true }}
132
132
  - name: Validate wheel metadata
133
133
  if: ${{ inputs.dry_run == true }}
134
134
  run: uvx --from twine==6.2.0 twine check --strict dist/*.whl
135
- - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
135
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
136
136
  with:
137
137
  name: wheel-${{ matrix.target }}
138
138
  path: dist/*.whl
@@ -143,7 +143,7 @@ jobs:
143
143
  needs: [version, verify]
144
144
  runs-on: ubuntu-latest
145
145
  steps:
146
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
146
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
147
147
  - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
148
148
  with:
149
149
  toolchain: 1.96.0
@@ -158,12 +158,12 @@ jobs:
158
158
  command: sdist
159
159
  args: --out dist
160
160
  - run: cp target/package/*.crate dist/
161
- - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d
161
+ - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
162
162
  if: ${{ inputs.dry_run == true }}
163
163
  - name: Validate source distribution metadata
164
164
  if: ${{ inputs.dry_run == true }}
165
165
  run: uvx --from twine==6.2.0 twine check --strict dist/*.tar.gz
166
- - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
166
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
167
167
  with:
168
168
  name: source-distributions
169
169
  path: dist/*
@@ -178,13 +178,13 @@ jobs:
178
178
  id-token: write
179
179
  attestations: write
180
180
  steps:
181
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
182
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
181
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
182
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
183
183
  with:
184
184
  pattern: binary-*
185
185
  path: dist
186
186
  merge-multiple: true
187
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
187
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
188
188
  with:
189
189
  name: source-distributions
190
190
  path: dist
@@ -198,7 +198,7 @@ jobs:
198
198
  run: sha256sum ./*.tar.gz ./*.crate ./*.json > SHA256SUMS
199
199
  - name: Attest artifacts and SBOM
200
200
  if: ${{ inputs.dry_run != true }}
201
- uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6
201
+ uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
202
202
  with:
203
203
  subject-path: dist/*
204
204
  sbom-path: dist/teams-cli.cdx.json
@@ -217,7 +217,7 @@ jobs:
217
217
  permissions:
218
218
  id-token: write
219
219
  steps:
220
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
220
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
221
221
  - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
222
222
  with:
223
223
  toolchain: 1.96.0
@@ -239,7 +239,7 @@ jobs:
239
239
  - name: Get temporary crates.io token
240
240
  if: steps.crate.outputs.exists != 'true'
241
241
  id: crates-auth
242
- uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18
242
+ uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5
243
243
  - if: steps.crate.outputs.exists != 'true'
244
244
  run: cargo publish --locked
245
245
  env:
@@ -258,18 +258,18 @@ jobs:
258
258
  permissions:
259
259
  id-token: write
260
260
  steps:
261
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
261
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
262
262
  with:
263
263
  pattern: wheel-*
264
264
  path: dist
265
265
  merge-multiple: true
266
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
266
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
267
267
  with:
268
268
  name: source-distributions
269
269
  path: source
270
270
  - name: Collect Python distributions
271
271
  run: find source -name '*.tar.gz' -exec cp {} dist/ \;
272
- - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33
272
+ - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
273
273
  with:
274
274
  packages-dir: dist
275
275
 
@@ -283,7 +283,7 @@ jobs:
283
283
  with:
284
284
  toolchain: 1.96.0
285
285
  components: rustfmt, clippy
286
- - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
286
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
287
287
  with:
288
288
  python-version: "3.x"
289
289
  - name: Install and run the crates.io package
@@ -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.7](https://github.com/rvben/teams-cli/compare/v0.1.6...v0.1.7) - 2026-09-27
11
+
12
+ ### Fixed
13
+
14
+ - **deps**: update rustls to 0.23.45 for RUSTSEC-2026-0285 ([d2338ff](https://github.com/rvben/teams-cli/commit/d2338ff8abf676501931c5ba7225097bb154f39c))
15
+
16
+ ## [0.1.6](https://github.com/rvben/teams-cli/compare/v0.1.5...v0.1.6) - 2026-09-03
17
+
18
+ ### Added
19
+
20
+ - **auth**: standardize authentication workflow ([bb0019f](https://github.com/rvben/teams-cli/commit/bb0019f0ceb86dbb963f3499348c58a95922081e))
21
+
22
+ ### Fixed
23
+
24
+ - **auth**: tolerate missing Linux credential service ([7764383](https://github.com/rvben/teams-cli/commit/776438359f3c913d0514de921042d770c6a88bf0))
25
+
10
26
  ## [0.1.5](https://github.com/rvben/teams-cli/compare/v0.1.4...v0.1.5) - 2026-08-26
11
27
 
12
28
  ### Added
@@ -316,9 +316,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
316
316
 
317
317
  [[package]]
318
318
  name = "chacha20"
319
- version = "0.10.1"
319
+ version = "0.10.2"
320
320
  source = "registry+https://github.com/rust-lang/crates.io-index"
321
- checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
321
+ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
322
322
  dependencies = [
323
323
  "cfg-if",
324
324
  "cpufeatures 0.3.0",
@@ -2557,9 +2557,9 @@ dependencies = [
2557
2557
 
2558
2558
  [[package]]
2559
2559
  name = "rustls"
2560
- version = "0.23.43"
2560
+ version = "0.23.45"
2561
2561
  source = "registry+https://github.com/rust-lang/crates.io-index"
2562
- checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
2562
+ checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
2563
2563
  dependencies = [
2564
2564
  "aws-lc-rs",
2565
2565
  "once_cell",
@@ -2964,7 +2964,7 @@ dependencies = [
2964
2964
 
2965
2965
  [[package]]
2966
2966
  name = "teams-cli"
2967
- version = "0.1.5"
2967
+ version = "0.1.7"
2968
2968
  dependencies = [
2969
2969
  "assert_cmd",
2970
2970
  "base64 0.23.1",
@@ -2997,7 +2997,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2997
2997
  checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
2998
2998
  dependencies = [
2999
2999
  "fastrand",
3000
- "getrandom 0.4.3",
3000
+ "getrandom 0.3.4",
3001
3001
  "once_cell",
3002
3002
  "rustix",
3003
3003
  "windows-sys 0.61.2",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "teams-cli"
3
- version = "0.1.5"
3
+ version = "0.1.7"
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.5
3
+ Version: 0.1.7
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  License-File: LICENSE
@@ -115,8 +115,14 @@ teams auth login --channel-history # once, with administrator approval
115
115
  teams messages list --team TEAM_ID --channel CHANNEL_ID
116
116
  teams messages send --chat CHAT_ID --body "On it."
117
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
118
123
  teams config show
119
- teams doctor
124
+ teams config path
125
+ teams doctor --offline
120
126
  ```
121
127
 
122
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`.
@@ -99,8 +99,14 @@ teams auth login --channel-history # once, with administrator approval
99
99
  teams messages list --team TEAM_ID --channel CHANNEL_ID
100
100
  teams messages send --chat CHAT_ID --body "On it."
101
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
102
107
  teams config show
103
- teams doctor
108
+ teams config path
109
+ teams doctor --offline
104
110
  ```
105
111
 
106
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,8 +91,8 @@ 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> {
@@ -159,6 +173,46 @@ pub fn configured_profile(requested: Option<&str>) -> Option<(String, Profile)>
159
173
  load(requested).ok()
160
174
  }
161
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
+
162
216
  fn read_file() -> Result<ConfigFile, AppError> {
163
217
  match std::fs::read_to_string(path()) {
164
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?;
@@ -541,7 +601,7 @@ struct Check {
541
601
  detail: String,
542
602
  }
543
603
 
544
- 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> {
545
605
  let mut checks = Vec::new();
546
606
  let configured = config::configured_profile(profile_arg);
547
607
  checks.push(Check {
@@ -593,7 +653,7 @@ async fn doctor(profile_arg: Option<&str>, out: Output) -> Result<(), AppError>
593
653
  });
594
654
  let mut identity_ok = false;
595
655
  let mut teams_ok = false;
596
- let (identity_detail, teams_detail) = if configured.is_some() && signed_in {
656
+ let (identity_detail, teams_detail) = if !offline && configured.is_some() && signed_in {
597
657
  match client(profile_arg).await {
598
658
  Ok(client) => {
599
659
  let identity_detail = match client.me().await {
@@ -623,21 +683,38 @@ async fn doctor(profile_arg: Option<&str>, out: Output) -> Result<(), AppError>
623
683
  }
624
684
  }
625
685
  } else {
626
- 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();
627
692
  (detail.clone(), detail)
628
693
  };
629
694
  checks.push(Check {
630
695
  name: "microsoft_identity",
631
- 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
+ },
632
703
  detail: identity_detail,
633
704
  });
634
705
  checks.push(Check {
635
706
  name: "microsoft_teams",
636
- 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
+ },
637
714
  detail: teams_detail,
638
715
  });
639
716
  let healthy = checks.iter().all(|check| check.status != "fail");
640
- let value = serde_json::json!({"checks":checks,"healthy":healthy});
717
+ let value = serde_json::json!({"checks":checks,"healthy":healthy,"offline":offline});
641
718
  out.value(&value, || {
642
719
  checks
643
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<_>>(),
@@ -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
File without changes