From 4e7b750494b32cdb4c92f3c1fc7c0422c1727915 Mon Sep 17 00:00:00 2001 From: herata Date: Thu, 14 May 2026 17:11:52 +0900 Subject: [PATCH 1/3] feat: initial slack-cli MVP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-commit consolidation of three working branches that had grown out of the original repository's brainstorm → design → implementation → readme polish → CI bootstrap sequence: - develop full MVP implementation - feature/readme-positioning drop jira-cli influence framing - fix/ci-bootstrap CI workflow + dev-tool fixes Highlights: * Rust 1.89 (MSRV) single-crate CLI, tokio current_thread, reqwest direct calls to Slack Web API (no SDK). * `noun → verb` command tree: auth / channel / message / search / user / file / completion. * Stable JSON envelope `slack-cli/v0` with `--dry-run` payload, structured `Hint` for agents, and exit-code map. * TokenStore: OS keychain (default), file (0600 on unix), or env; Windows disables the file backend. * Resolver with 60s in-process cache and ambiguity detection (channel + user). * 3-layer test pyramid: 59 lib unit tests, 9 integration tests via wiremock + assert_cmd + insta. * CI matrix: ubuntu/macos/windows + msrv + linux-headless + deny; release.yml builds 6 targets (linux-musl/macos/windows-msvc × x86_64/aarch64) with SHA256 sums and git-cliff changelog. * Dev tooling pinned via mise (rust default profile + ubi for cargo-insta/cargo-deny/git-cliff; cargo-nextest installed in CI via taiki-e/install-action). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/dependabot.yml | 13 + .github/workflows/ci.yml | 82 + .github/workflows/release.yml | 56 + .gitignore | 30 + Cargo.lock | 2734 +++++++++++++ Cargo.toml | 60 + README.ja.md | 157 + README.md | 160 + cliff.toml | 32 + deny.toml | 27 + .../plans/2026-05-14-slack-cli-mvp.md | 3629 +++++++++++++++++ .../specs/2026-05-14-slack-cli-design.md | 712 ++++ mise.toml | 51 + src/cli.rs | 333 ++ src/cmd/auth.rs | 190 + src/cmd/channel.rs | 156 + src/cmd/completion.rs | 15 + src/cmd/file.rs | 138 + src/cmd/message.rs | 243 ++ src/cmd/mod.rs | 198 + src/cmd/search.rs | 86 + src/cmd/user.rs | 55 + src/config.rs | 215 + src/error.rs | 173 + src/id.rs | 140 + src/lib.rs | 23 + src/logging.rs | 26 + src/main.rs | 25 + src/output.rs | 285 ++ src/resolve.rs | 298 ++ src/secret.rs | 163 + src/slack.rs | 1260 ++++++ tests/auth.rs | 40 + tests/channel.rs | 33 + tests/common/mod.rs | 58 + tests/file.rs | 36 + tests/fixtures/chat.delete.ok.json | 1 + tests/fixtures/chat.postMessage.ok.json | 1 + tests/fixtures/chat.update.ok.json | 1 + tests/fixtures/conversations.archive.ok.json | 1 + tests/fixtures/conversations.create.ok.json | 1 + tests/fixtures/conversations.history.ok.json | 1 + tests/fixtures/conversations.info.ok.json | 1 + tests/fixtures/conversations.invite.ok.json | 1 + tests/fixtures/conversations.leave.ok.json | 1 + tests/fixtures/conversations.list.ok.json | 1 + tests/fixtures/conversations.replies.ok.json | 1 + tests/fixtures/files.info.ok.json | 1 + tests/fixtures/files.list.ok.json | 1 + tests/fixtures/files.upload.ok.json | 1 + .../reactions.add.already_reacted.json | 1 + tests/fixtures/reactions.add.ok.json | 1 + tests/fixtures/search.files.ok.json | 1 + tests/fixtures/search.messages.ok.json | 1 + tests/fixtures/users.info.ok.json | 1 + tests/fixtures/users.list.ok.json | 1 + tests/message.rs | 68 + tests/search.rs | 23 + tests/user.rs | 28 + 59 files changed, 12071 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.ja.md create mode 100644 cliff.toml create mode 100644 deny.toml create mode 100644 docs/superpowers/plans/2026-05-14-slack-cli-mvp.md create mode 100644 docs/superpowers/specs/2026-05-14-slack-cli-design.md create mode 100644 mise.toml create mode 100644 src/cli.rs create mode 100644 src/cmd/auth.rs create mode 100644 src/cmd/channel.rs create mode 100644 src/cmd/completion.rs create mode 100644 src/cmd/file.rs create mode 100644 src/cmd/message.rs create mode 100644 src/cmd/mod.rs create mode 100644 src/cmd/search.rs create mode 100644 src/cmd/user.rs create mode 100644 src/config.rs create mode 100644 src/error.rs create mode 100644 src/id.rs create mode 100644 src/lib.rs create mode 100644 src/logging.rs create mode 100644 src/main.rs create mode 100644 src/output.rs create mode 100644 src/resolve.rs create mode 100644 src/secret.rs create mode 100644 src/slack.rs create mode 100644 tests/auth.rs create mode 100644 tests/channel.rs create mode 100644 tests/common/mod.rs create mode 100644 tests/file.rs create mode 100644 tests/fixtures/chat.delete.ok.json create mode 100644 tests/fixtures/chat.postMessage.ok.json create mode 100644 tests/fixtures/chat.update.ok.json create mode 100644 tests/fixtures/conversations.archive.ok.json create mode 100644 tests/fixtures/conversations.create.ok.json create mode 100644 tests/fixtures/conversations.history.ok.json create mode 100644 tests/fixtures/conversations.info.ok.json create mode 100644 tests/fixtures/conversations.invite.ok.json create mode 100644 tests/fixtures/conversations.leave.ok.json create mode 100644 tests/fixtures/conversations.list.ok.json create mode 100644 tests/fixtures/conversations.replies.ok.json create mode 100644 tests/fixtures/files.info.ok.json create mode 100644 tests/fixtures/files.list.ok.json create mode 100644 tests/fixtures/files.upload.ok.json create mode 100644 tests/fixtures/reactions.add.already_reacted.json create mode 100644 tests/fixtures/reactions.add.ok.json create mode 100644 tests/fixtures/search.files.ok.json create mode 100644 tests/fixtures/search.messages.ok.json create mode 100644 tests/fixtures/users.info.ok.json create mode 100644 tests/fixtures/users.list.ok.json create mode 100644 tests/message.rs create mode 100644 tests/search.rs create mode 100644 tests/user.rs diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9ed54bb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..857940c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: short + +jobs: + test: + name: test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: jdx/mise-action@5083fe46898c414b2475087cc79da59e7da859e8 # v2.1.11 + with: + install: true + cache: true + - uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.7.4 + - uses: taiki-e/install-action@v2 + with: + tool: cargo-nextest + - name: fmt check + if: matrix.os == 'ubuntu-latest' + run: cargo fmt --all -- --check + - name: clippy + run: cargo clippy --all-targets -- -D warnings + - name: nextest + run: cargo nextest run --all-targets + - name: docs + if: matrix.os == 'ubuntu-latest' + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc --no-deps --document-private-items + + deny: + name: cargo-deny + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: jdx/mise-action@5083fe46898c414b2475087cc79da59e7da859e8 # v2.1.11 + with: + install: true + cache: true + - run: cargo deny check advisories bans licenses sources + + msrv: + name: msrv + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: install build deps for keyring (libdbus on linux) + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libdbus-1-dev pkg-config + - uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable + with: + toolchain: "1.89" + - uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.7.4 + - run: cargo check --all-features + + linux-headless: + name: linux headless (no D-Bus) + runs-on: ubuntu-latest + container: + image: rust:1.89-slim + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: install build deps + run: | + apt-get update + apt-get install -y --no-install-recommends pkg-config build-essential ca-certificates libdbus-1-dev + - uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.7.4 + - run: cargo test --tests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4c8009f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,56 @@ +name: release + +on: + push: + tags: ['v*'] + +permissions: + contents: write + +jobs: + upload: + name: upload-${{ matrix.target }} + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + - target: aarch64-unknown-linux-musl + os: ubuntu-latest + - target: x86_64-apple-darwin + os: macos-latest + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-pc-windows-msvc + os: windows-latest + - target: aarch64-pc-windows-msvc + os: windows-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: taiki-e/upload-rust-binary-action@d94c2c5e7a35e7d1edd0e6e3b3a4d2d9b94f4f1f # v1.22.1 + with: + bin: slack-cli + target: ${{ matrix.target }} + archive: slack-cli-${{ github.ref_name }}-${{ matrix.target }} + checksum: sha256 + token: ${{ secrets.GITHUB_TOKEN }} + + changelog: + name: changelog + runs-on: ubuntu-latest + needs: upload + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + - uses: jdx/mise-action@5083fe46898c414b2475087cc79da59e7da859e8 # v2.1.11 + with: + install: true + cache: true + - run: git cliff --tag ${{ github.ref_name }} --output CHANGELOG.md + - name: upload CHANGELOG to release + uses: softprops/action-gh-release@c062e08bd532815e2082a85e87e3ef29c3e6d191 # v2.0.8 + with: + files: CHANGELOG.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f9da9ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Rust +/target/ +**/*.rs.bk +*.pdb + +# Cargo +Cargo.lock.bak + +# IDE / editor +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Local env / secrets +.env +.env.local +*.local.toml + +# Test outputs +/tarpaulin-report.html +/coverage/ + +# insta snapshot pending files +**/*.snap.new diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..96febcb --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2734 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_complete" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "dbus", + "zeroize", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "insta" +version = "1.47.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" +dependencies = [ + "console", + "once_cell", + "regex", + "serde", + "similar", + "tempfile", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "papergrid" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7419ad52a7de9b60d33e11085a0fe3df1fbd5926aa3f93d3dd53afbc9e86725" +dependencies = [ + "bytecount", + "fnv", + "unicode-width", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rpassword" +version = "7.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac5b223d9738ef56e0b98305410be40fa0941bf6036c56f1506751e43552d64" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rtoolbox" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slack-cli" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "clap", + "clap_complete", + "etcetera", + "insta", + "keyring", + "predicates", + "pretty_assertions", + "regex", + "reqwest", + "rpassword", + "secrecy", + "serde", + "serde_json", + "tabled", + "tempfile", + "thiserror", + "tokio", + "toml_edit", + "tracing", + "tracing-subscriber", + "urlencoding", + "uuid", + "wiremock", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tabled" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c9303ee60b9bedf722012ea29ae3711ba13a67c9b9ae28993838b63057cb1b" +dependencies = [ + "papergrid", + "tabled_derive", +] + +[[package]] +name = "tabled_derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0fb8bfdc709786c154e24a66777493fb63ae97e3036d914c8666774c477069" +dependencies = [ + "heck 0.4.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..fa6ebed --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,60 @@ +[package] +name = "slack-cli" +version = "0.1.0" +edition = "2021" +rust-version = "1.89" +license = "MIT OR Apache-2.0" +description = "Slack CLI for coding agents (jira-cli style)" +repository = "https://github.com//slack-cli" +default-run = "slack-cli" + +[[bin]] +name = "slack-cli" +path = "src/main.rs" + +[lib] +path = "src/lib.rs" + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = "symbols" +opt-level = "z" +panic = "abort" + +[dependencies] +thiserror = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +regex = "1" +tabled = "0.16" +clap = { version = "4", features = ["derive", "wrap_help", "env"] } +etcetera = "0.8" +toml_edit = { version = "0.22", features = ["serde"] } +tempfile = "3" +secrecy = "0.10" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +clap_complete = "4" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "multipart", "stream"] } +tokio = { version = "1", features = ["macros", "rt", "fs", "io-util", "signal"] } +uuid = { version = "1", features = ["v4"] } +urlencoding = "2" +rpassword = "7" + +[target.'cfg(target_os = "macos")'.dependencies] +keyring = { version = "3", features = ["apple-native"] } + +[target.'cfg(target_os = "linux")'.dependencies] +keyring = { version = "3", features = ["sync-secret-service"] } + +[target.'cfg(target_os = "windows")'.dependencies] +keyring = { version = "3", features = ["windows-native"] } + +[dev-dependencies] +wiremock = "0.6" +tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread", "test-util"] } +assert_cmd = "2" +predicates = "3" +insta = { version = "1", features = ["json", "yaml", "filters"] } +pretty_assertions = "1" diff --git a/README.ja.md b/README.ja.md new file mode 100644 index 0000000..ecffaed --- /dev/null +++ b/README.ja.md @@ -0,0 +1,157 @@ +# slack-cli + +> Coding agent とシェル自動化のための Rust 製 Slack CLI + +`slack-cli` は **coding agent** (Claude Code / Codex / Cursor など) とシェル +自動化のために作られたコマンドラインの Slack クライアントです。`noun → verb` +のコマンド構造を採用し、pipe 検出時は安定した JSON を吐き出すので、agent が +スクレイピングなしで結果を扱えます。 + +[English version](./README.md) + +## ステータス + +**プレリリース**。API と JSON 出力スキーマ (`slack-cli/v0`) はまだ安定しません。 +CI 等で使う場合はタグ付きリリースを pin してください。 + +## 特徴 + +- `noun → verb` コマンド階層: `slack-cli channel list`, `slack-cli message send` … +- **TTY 自動検出**: 対話時はテーブル、pipe 時は構造化 JSON +- **ストリームの厳格分離**: stdout はデータ、stderr はログ・進捗・エラー +- **JSON envelope** に `"schema": "slack-cli/v0"` を含み、agent が出力スキーマを pin 可能 +- **`--dry-run`**: 書き込み/管理系コマンドすべてに対応。Slack を呼ばずに *これから呼ぶ予定の API リクエスト* を JSON で返す +- **`--yes`**: 非対話環境での破壊的操作には必須 +- **冪等な投稿**: `chat.postMessage` には UUID v4 の `client_msg_id` が付くので、agent のリトライが安全 +- **トークン保存**: OS キーチェーン(デフォルト)、設定ファイル (Unix では `0600`)、環境変数のいずれかを workspace ごとに選択 +- `rustls` による単一静的バイナリ。OpenSSL 不要 + +## インストール + +> プレリリース段階です。Linux musl / macOS / Windows(x86_64 + aarch64)の +> ビルド済みバイナリは最初のタグ後に GitHub Releases に公開予定。 + +### ソースから([`mise`][mise] 推奨) + +```sh +git clone https://github.com//slack-cli && cd slack-cli +mise install +cargo install --path . +``` + +## クイックスタート + +```sh +# 1. workspace を設定(トークンは既定で OS キーチェーンに保存) +slack-cli auth init + +# 2. トークンの動作確認 +slack-cli auth whoami --json + +# 3. メッセージ送信(まず dry-run) +slack-cli message send '#general' --text 'hello from slack-cli' --dry-run +slack-cli message send '#general' --text 'hello from slack-cli' + +# 4. 長文を stdin から流し込む +echo "$(cat report.md)" | slack-cli message send '#general' --text - +``` + +## コマンド一覧 + +| グループ | コマンド | +|-------------|----------------------------------------------------------| +| `auth` | `init`, `whoami`, `list`, `logout` | +| `channel` | `list`, `view`, `create`, `archive`, `invite`, `leave` | +| `message` | `list`, `view`, `send`, `reply`, `update`, `delete`, `react` | +| `search` | `messages`, `files` | +| `user` | `list`, `view` | +| `file` | `list`, `upload`, `download` | +| `completion`| `bash`, `zsh`, `fish`, `powershell`, `elvish` | + +詳しいフラグは `slack-cli --help` を参照。各グループは共通のグローバル +フラグを持ちます(`--json`, `--workspace`, `--config`, `-v`, `--quiet`, +`--no-color`, `--yes`, `--max-results`, `--all`, `--max-pages`, `--dry-run`)。 + +## JSON 出力スキーマ + +すべての JSON 出力は安定した envelope に包まれます: + +```jsonc +{ + "ok": true, + "schema": "slack-cli/v0", + "data": { /* コマンド固有のデータ */ }, + "request_id": "req_..." +} +``` + +エラーは **stderr** に同形で出ます: + +```jsonc +{ + "ok": false, + "schema": "slack-cli/v0", + "error": { + "code": "channel_not_found", + "message": "channel '#nonexistent' not found", + "hint": { "action": "run", "command": "slack-cli channel list" }, + "exit_code": 5, + "details": { "kind": "channel", "name": "#nonexistent" }, + "request_id": "req_..." + } +} +``` + +リスト系は常に items + cursor の形: + +```jsonc +{ "data": { "items": [...], "next_cursor": "..." } } +``` + +## 終了コード + +| コード | 意味 | +|-------:|--------------------------------------------| +| 0 | 成功 | +| 1 | 汎用失敗(ネットワーク、想定外 I/O) | +| 2 | 設定エラー | +| 3 | 認証失敗 | +| 4 | レート制限 | +| 5 | リソース未存在 / 識別子曖昧 | +| 6 | トークン種別不一致(user vs bot) | +| 64 | 引数不正(sysexits `EX_USAGE`) | +| 130 | 中断(SIGINT) | + +## 設定 + +設定ファイルのパスは [`etcetera`][etcetera] が解決します: + +- macOS: `~/Library/Application Support/slack-cli/config.toml` +- Linux: `$XDG_CONFIG_HOME/slack-cli/config.toml`(既定: `~/.config/slack-cli/config.toml`) +- Windows: `%APPDATA%\slack-cli\config.toml` + +`-c, --config ` または `SLACK_CLI_CONFIG` で上書き可能。 + +## セキュリティ + +- `--token` フラグは提供しません(`ps` から見えるため)。代わりに + `--token-stdin` / `SLACK_CLI_TOKEN` 環境変数 / OS キーチェーンを使ってください。 +- `Authorization` ヘッダは sensitive マーク済みで、ログに漏れません。 +- リリースビルドは `panic = "abort"` でメモリダンプを抑制します。 +- 脆弱性報告は [`SECURITY.md`](./SECURITY.md) を参照(準備中)。 + +## 開発 + +```sh +mise install # toolchain と cargo-nextest / insta / deny / git-cliff をインストール +mise run test # fmt --check + clippy -D warnings + nextest + deny +mise run snap # 未確定の insta スナップショットをレビュー +mise run run -- channel list +``` + +## ライセンス + +MIT OR Apache-2.0 のデュアルライセンス。 + +[mise]: https://mise.jdx.dev +[etcetera]: https://crates.io/crates/etcetera diff --git a/README.md b/README.md index b95ba52..5617c1e 100644 --- a/README.md +++ b/README.md @@ -1 +1,161 @@ # slack-cli + +> A Rust-based Slack CLI for coding agents and shell automation. + +`slack-cli` is a command-line Slack client built primarily for **coding agents** +(Claude Code, Codex, Cursor, etc.) and shell automation. It uses a +`noun → verb` command shape and emits stable JSON when piped, so agents can +parse results without scraping. + +[日本語版 README はこちら / Japanese README](./README.ja.md) + +## Status + +**Pre-release.** APIs and JSON output schema (`slack-cli/v0`) are not yet +stable. Pin a tagged release in CI. + +## Highlights + +- `noun → verb` command tree: `slack-cli channel list`, `slack-cli message send`, ... +- **Auto-detects TTY**: human-friendly table when interactive, structured JSON when piped. +- **Strict stream separation**: data on stdout, logs / progress / errors on stderr. +- **JSON envelope** with `"schema": "slack-cli/v0"` so agents can version-pin output. +- **`--dry-run`** on every write/admin command — returns the exact API call it + *would* make as JSON, without invoking Slack. +- **`--yes`** required for destructive operations in non-interactive contexts. +- **Idempotent posts**: `chat.postMessage` gets a UUID v4 `client_msg_id` so + agent retries are safe. +- **Token storage**: OS keychain (default), config file (`0600` on Unix), or + environment variable — selected per workspace. +- Single static binary via `rustls`; no OpenSSL required. + +## Install + +> Pre-release. Pre-built binaries (Linux musl, macOS, Windows; x86_64 + aarch64) +> are published on GitHub Releases once the first tag lands. + +### From source (requires [`mise`][mise]) + +```sh +git clone https://github.com//slack-cli && cd slack-cli +mise install +cargo install --path . +``` + +## Quick start + +```sh +# 1. Configure a workspace (token is stored in OS keychain by default) +slack-cli auth init + +# 2. Verify the token +slack-cli auth whoami --json + +# 3. Send a message (dry-run first) +slack-cli message send '#general' --text 'hello from slack-cli' --dry-run +slack-cli message send '#general' --text 'hello from slack-cli' + +# 4. Stream a long body from stdin +echo "$(cat report.md)" | slack-cli message send '#general' --text - +``` + +## Command map + +| Group | Commands | +|-----------|----------------------------------------------------------| +| `auth` | `init`, `whoami`, `list`, `logout` | +| `channel` | `list`, `view`, `create`, `archive`, `invite`, `leave` | +| `message` | `list`, `view`, `send`, `reply`, `update`, `delete`, `react` | +| `search` | `messages`, `files` | +| `user` | `list`, `view` | +| `file` | `list`, `upload`, `download` | +| `completion` | `bash`, `zsh`, `fish`, `powershell`, `elvish` | + +Run `slack-cli --help` for full flag details. Every group exposes the +same global flags (`--json`, `--workspace`, `--config`, `-v`, `--quiet`, +`--no-color`, `--yes`, `--max-results`, `--all`, `--max-pages`, `--dry-run`). + +## JSON output schema + +All JSON output is wrapped in a stable envelope: + +```jsonc +{ + "ok": true, + "schema": "slack-cli/v0", + "data": { /* command-specific */ }, + "request_id": "req_..." +} +``` + +Errors land on **stderr** with a parallel shape: + +```jsonc +{ + "ok": false, + "schema": "slack-cli/v0", + "error": { + "code": "channel_not_found", + "message": "channel '#nonexistent' not found", + "hint": { "action": "run", "command": "slack-cli channel list" }, + "exit_code": 5, + "details": { "kind": "channel", "name": "#nonexistent" }, + "request_id": "req_..." + } +} +``` + +List commands always wrap items + cursor: + +```jsonc +{ "data": { "items": [...], "next_cursor": "..." } } +``` + +## Exit codes + +| Code | Meaning | +|-----:|--------------------------------------------| +| 0 | Success | +| 1 | Generic failure (network, unexpected I/O) | +| 2 | Configuration error | +| 3 | Authentication failure | +| 4 | Rate limited | +| 5 | Resource not found / ambiguous identifier | +| 6 | Token kind mismatch (user vs bot) | +| 64 | Invalid argument (sysexits `EX_USAGE`) | +| 130 | Interrupted (SIGINT) | + +## Configuration + +`slack-cli` resolves its config file via [`etcetera`][etcetera]: + +- macOS: `~/Library/Application Support/slack-cli/config.toml` +- Linux: `$XDG_CONFIG_HOME/slack-cli/config.toml` (default `~/.config/slack-cli/config.toml`) +- Windows: `%APPDATA%\slack-cli\config.toml` + +Override with `-c, --config ` or `SLACK_CLI_CONFIG`. + +## Security + +- Tokens are never accepted via `--token` (would leak through `ps`). Use + `--token-stdin`, the `SLACK_CLI_TOKEN` environment variable, or the OS + keychain. +- The `Authorization` header is marked sensitive so logs never expose it. +- Release builds use `panic = "abort"` to suppress memory dumps. +- See [`SECURITY.md`](./SECURITY.md) for reporting vulnerabilities (planned). + +## Development + +```sh +mise install # Install pinned toolchain + cargo-nextest, insta, deny, git-cliff +mise run test # fmt --check + clippy -D warnings + nextest + deny +mise run snap # Review pending insta snapshots +mise run run -- channel list +``` + +## License + +Dual-licensed under MIT OR Apache-2.0. + +[mise]: https://mise.jdx.dev +[etcetera]: https://crates.io/crates/etcetera diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..b9e54f0 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,32 @@ +[changelog] +header = "# Changelog\n\nAll notable changes to this project are documented here.\n" +body = """ +{% if version %} +## [{{ version | trim_start_matches(pat=\"v\") }}] - {{ timestamp | date(format=\"%Y-%m-%d\") }} +{% else %} +## [Unreleased] +{% endif %} +{% for group, commits in commits | group_by(attribute=\"group\") %} +### {{ group | upper_first }} +{% for commit in commits %} +- {{ commit.message | upper_first }} +{% endfor %} +{% endfor %} +""" +trim = true + +[git] +conventional_commits = true +filter_unconventional = true +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^docs", group = "Documentation" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactor" }, + { message = "^test", group = "Tests" }, + { message = "^chore", group = "Chores" }, + { message = "^ci", group = "CI" }, +] +filter_commits = false +tag_pattern = "v[0-9]*" diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..9faf397 --- /dev/null +++ b/deny.toml @@ -0,0 +1,27 @@ +[advisories] +version = 2 +yanked = "warn" +# proc-macro-error is pulled in transitively by tabled_derive (tabled crate's +# proc-macro helper). The maintainer is unreachable; upstream `tabled` has not +# yet migrated. Tracked until tabled releases a build-time replacement. +ignore = ["RUSTSEC-2024-0370"] + +[bans] +multiple-versions = "warn" +wildcards = "deny" + +[licenses] +version = 2 +allow = [ + "MIT", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", "BSD-3-Clause", "ISC", + "Unicode-DFS-2016", "Unicode-3.0", "CC0-1.0", "MPL-2.0", "Zlib", + # webpki-roots (CA bundle for rustls) — required by reqwest's rustls-tls. + "CDLA-Permissive-2.0", +] +confidence-threshold = 0.93 + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/docs/superpowers/plans/2026-05-14-slack-cli-mvp.md b/docs/superpowers/plans/2026-05-14-slack-cli-mvp.md new file mode 100644 index 0000000..442feb4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-slack-cli-mvp.md @@ -0,0 +1,3629 @@ +# slack-cli MVP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a Rust-based Slack CLI (`slack-cli`) for coding agents that mirrors the `jira-cli` UX, exposing channels / messages / files / search / users / auth with a stable `slack-cli/v0` JSON envelope, TTY-aware output, and a 3-layer test pyramid. + +**Architecture:** Single binary crate, `#[tokio::main(flavor = "current_thread")]`, `reqwest + serde` direct calls to Slack Web API (no SDK), `Result` only (no `anyhow`), `tabled` + `serde` + `Display` for rendering. Secrets via OS keychain by default with file/env fallbacks. Tests with `wiremock` for HTTP mocking and `assert_cmd` + `insta` for E2E. + +**Tech Stack:** Rust 1.89 (MSRV), tokio, reqwest, serde, clap (derive), thiserror, tracing, tabled, etcetera, keyring v3, secrecy, toml_edit, uuid (v4), clap_complete. Dev: wiremock, assert_cmd, insta, predicates, pretty_assertions. Tooling: mise (toolchain + cargo-nextest/insta/deny/git-cliff). + +**Source spec:** `docs/superpowers/specs/2026-05-14-slack-cli-design.md` + +--- + +## File Structure + +``` +slack-cli/ +├─ Cargo.toml # crate metadata + deps + lints + release profile +├─ deny.toml # cargo-deny config +├─ cliff.toml # git-cliff config +├─ mise.toml # already present +├─ src/ +│ ├─ main.rs # #[tokio::main(current_thread)], install panic hook, dispatch +│ ├─ lib.rs # re-exports for integration tests +│ ├─ logging.rs # tracing init (stderr, EnvFilter forced floor) +│ ├─ cli.rs # clap derive: Cli + GlobalArgs + Subcommand enums +│ ├─ error.rs # CliError (thiserror) + exit_code() + hint() +│ ├─ id.rs # Newtypes + ChannelRef / UserRef + TryFrom +│ ├─ output.rs # Format enum + render() + JSON envelope +│ ├─ config.rs # TOML I/O via toml_edit, etcetera-based paths +│ ├─ secret.rs # TokenStore (Keyring/File/Env) + SecretString +│ ├─ slack.rs # SlackClient (reqwest) + DTOs + error mapping + retry +│ ├─ resolve.rs # Resolver: name→ID with 60s in-process cache +│ └─ cmd/ +│ ├─ mod.rs # Ctx, dispatch() +│ ├─ auth.rs # init / whoami / list / logout +│ ├─ channel.rs # list / view / create / archive / invite / leave +│ ├─ message.rs # list / view / send / reply / update / delete / react +│ ├─ search.rs # messages / files +│ ├─ user.rs # list / view +│ ├─ file.rs # list / upload / download +│ └─ completion.rs # clap_complete generator +├─ tests/ +│ ├─ common/mod.rs # wiremock setup, temp config, env helpers +│ ├─ auth.rs # E2E auth +│ ├─ channel.rs # E2E channel +│ ├─ message.rs # E2E message + dry-run +│ ├─ search.rs # E2E search +│ ├─ user.rs # E2E user +│ ├─ file.rs # E2E file +│ └─ fixtures/*.json # Slack API sample responses +└─ .github/ + ├─ dependabot.yml + └─ workflows/ + ├─ ci.yml + └─ release.yml +``` + +--- + +## Phase 0 — Project bootstrap + +### Task 0.1: Initialize Cargo crate + +**Files:** +- Create: `Cargo.toml` +- Create: `src/main.rs` +- Create: `src/lib.rs` +- Create: empty module stubs under `src/` + +- [ ] **Step 1: Write `Cargo.toml`** + +```toml +[package] +name = "slack-cli" +version = "0.1.0" +edition = "2021" +rust-version = "1.89" +license = "MIT OR Apache-2.0" +description = "Slack CLI for coding agents (jira-cli style)" +repository = "https://github.com//slack-cli" +default-run = "slack-cli" + +[[bin]] +name = "slack-cli" +path = "src/main.rs" + +[lib] +path = "src/lib.rs" + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = "symbols" +opt-level = "z" +panic = "abort" + +[dependencies] + +[dev-dependencies] +``` + +- [ ] **Step 2: Write `src/lib.rs`** + +```rust +//! Internal library facade for `slack-cli`. +//! +//! Exposed only so integration tests under `tests/` can reach +//! the same modules the binary uses. Not a stable public API. + +#![warn(missing_debug_implementations, rust_2018_idioms, unreachable_pub)] +#![warn(clippy::pedantic)] +#![allow(clippy::module_name_repetitions, clippy::missing_errors_doc, clippy::missing_panics_doc)] + +pub mod cli; +pub mod cmd; +pub mod config; +pub mod error; +pub mod id; +pub mod logging; +pub mod output; +pub mod resolve; +pub mod secret; +pub mod slack; +``` + +- [ ] **Step 3: Write minimal `src/main.rs`** + +```rust +use std::process::ExitCode; + +fn main() -> ExitCode { + eprintln!("slack-cli scaffolding (no commands wired yet)"); + ExitCode::SUCCESS +} +``` + +- [ ] **Step 4: Create empty module stubs** + +Run: + +```bash +mkdir -p src/cmd +for f in cli error id logging output config secret slack resolve; do + echo "// placeholder, filled in later phases" > "src/$f.rs" +done +echo "// placeholder, filled in later phases" > src/cmd/mod.rs +``` + +- [ ] **Step 5: Run `cargo check`** + +Run: `cargo check` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add Cargo.toml src/ +git commit -m "chore: bootstrap empty cargo crate with module skeleton" +``` + +### Task 0.2: Add `.github/dependabot.yml`, `deny.toml`, `cliff.toml` + +**Files:** +- Create: `deny.toml` +- Create: `cliff.toml` +- Create: `.github/dependabot.yml` + +- [ ] **Step 1: Write `deny.toml`** + +```toml +[advisories] +version = 2 +yanked = "warn" + +[bans] +multiple-versions = "warn" +wildcards = "deny" + +[licenses] +version = 2 +allow = [ + "MIT", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", "BSD-3-Clause", "ISC", + "Unicode-DFS-2016", "Unicode-3.0", "CC0-1.0", "MPL-2.0", "Zlib", +] +confidence-threshold = 0.93 + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +``` + +- [ ] **Step 2: Write `cliff.toml`** + +```toml +[changelog] +header = "# Changelog\n\nAll notable changes to this project are documented here.\n" +body = """ +{% if version %} +## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %} +## [Unreleased] +{% endif %} +{% for group, commits in commits | group_by(attribute="group") %} +### {{ group | upper_first }} +{% for commit in commits %} +- {{ commit.message | upper_first }} +{% endfor %} +{% endfor %} +""" +trim = true + +[git] +conventional_commits = true +filter_unconventional = true +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^docs", group = "Documentation" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactor" }, + { message = "^test", group = "Tests" }, + { message = "^chore", group = "Chores" }, + { message = "^ci", group = "CI" }, +] +filter_commits = false +tag_pattern = "v[0-9]*" +``` + +- [ ] **Step 3: Write `.github/dependabot.yml`** + +```yaml +version: 2 +updates: + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 +``` + +- [ ] **Step 4: Commit** + +```bash +git add deny.toml cliff.toml .github/dependabot.yml +git commit -m "chore: add cargo-deny, git-cliff, and dependabot configs" +``` + +--- + +## Phase 1 — Foundation types + +### Task 1.1: CliError enum + +**Files:** Modify `src/error.rs`, `Cargo.toml`. + +- [ ] **Step 1: Add `thiserror` dep** + +```toml +thiserror = "1" +``` + +- [ ] **Step 2: Write `src/error.rs`** + +```rust +//! CLI-wide error type. All commands return `Result`. + +use std::io; + +#[derive(thiserror::Error, Debug)] +#[non_exhaustive] +pub enum CliError { + #[error("no token configured for workspace '{workspace}'")] + NoToken { workspace: String }, + + #[error("ambiguous workspace: specify --workspace (available: {})", available.join(", "))] + AmbiguousWorkspace { available: Vec }, + + #[error("token kind mismatch: '{api}' requires {needed} token, got {actual}")] + TokenKindMismatch { + needed: &'static str, + actual: &'static str, + api: &'static str, + }, + + #[error("{kind} '{name}' not found")] + NotFound { kind: &'static str, name: String }, + + #[error("ambiguous {kind} reference '{name}' ({} candidates)", candidates.len())] + Ambiguous { + kind: &'static str, + name: String, + candidates: Vec, + }, + + #[error("slack api error: {code}")] + SlackApi { + code: String, + request_id: Option, + }, + + #[error("rate limited, retry after {retry_after_secs}s")] + RateLimited { retry_after_secs: u64 }, + + #[error("authentication failed: {reason}")] + AuthFailed { reason: String }, + + #[error("invalid argument: {0}")] + InvalidArg(String), + + #[error("confirmation required: pass --yes for non-interactive use")] + ConfirmationRequired, + + #[error("file token backend is not supported on this platform")] + FileBackendUnsupported, + + #[error("config error: {0}")] + Config(String), + + #[error("io error: {0}")] + Io(#[from] io::Error), + + #[error("network error: {0}")] + Network(String), +} + +impl CliError { + #[must_use] + pub fn exit_code(&self) -> u8 { + match self { + Self::NoToken { .. } | Self::AuthFailed { .. } => 3, + Self::AmbiguousWorkspace { .. } | Self::Config(_) | Self::FileBackendUnsupported => 2, + Self::RateLimited { .. } => 4, + Self::NotFound { .. } | Self::Ambiguous { .. } => 5, + Self::TokenKindMismatch { .. } => 6, + Self::InvalidArg(_) | Self::ConfirmationRequired => 64, + _ => 1, + } + } + + #[must_use] + pub fn code(&self) -> &'static str { + match self { + Self::NoToken { .. } => "no_token", + Self::AmbiguousWorkspace { .. } => "workspace_ambiguous", + Self::TokenKindMismatch { .. } => "token_kind_mismatch", + Self::NotFound { .. } => "not_found", + Self::Ambiguous { .. } => "ambiguous", + Self::SlackApi { .. } => "slack_api", + Self::RateLimited { .. } => "rate_limited", + Self::AuthFailed { .. } => "auth_failed", + Self::InvalidArg(_) => "invalid_arg", + Self::ConfirmationRequired => "confirmation_required", + Self::FileBackendUnsupported => "file_backend_unsupported", + Self::Config(_) => "config", + Self::Io(_) => "io", + Self::Network(_) => "network", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exit_code_auth_failed_is_3() { + let e = CliError::AuthFailed { reason: "x".into() }; + assert_eq!(e.exit_code(), 3); + } + #[test] + fn exit_code_not_found_is_5() { + let e = CliError::NotFound { kind: "channel", name: "x".into() }; + assert_eq!(e.exit_code(), 5); + } + #[test] + fn exit_code_token_mismatch_is_6() { + let e = CliError::TokenKindMismatch { needed: "user", actual: "bot", api: "search.messages" }; + assert_eq!(e.exit_code(), 6); + } + #[test] + fn exit_code_invalid_arg_is_64() { + assert_eq!(CliError::InvalidArg("x".into()).exit_code(), 64); + } + #[test] + fn display_lowercase_no_period() { + let s = CliError::NoToken { workspace: "acme".into() }.to_string(); + assert!(s.starts_with(char::is_lowercase)); + assert!(!s.ends_with('.')); + } +} +``` + +- [ ] **Step 3: Run tests + clippy + commit** + +```bash +cargo test --lib error +cargo clippy --all-targets -- -D warnings +git add Cargo.toml src/error.rs +git commit -m "feat: add CliError enum with exit-code and code mappings" +``` + +### Task 1.2: Identifier Newtypes + +**Files:** Modify `src/id.rs`, `Cargo.toml`. + +- [ ] **Step 1: Add deps** + +```toml +serde = { version = "1", features = ["derive"] } +serde_json = "1" +regex = "1" +``` + +- [ ] **Step 2: Write `src/id.rs`** + +```rust +//! Strongly-typed Slack identifiers. + +use std::fmt; +use std::sync::LazyLock; + +use regex::Regex; +use serde::{Deserialize, Serialize}; + +use crate::error::CliError; + +static ID_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^[CGDUW][A-Z0-9]{8,}$").expect("valid regex")); + +macro_rules! string_id { + ($name:ident, $doc:literal) => { + #[doc = $doc] + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] + pub struct $name(pub(crate) String); + + impl $name { + #[must_use] + pub fn as_str(&self) -> &str { &self.0 } + } + + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } + } + }; +} + +string_id!(ChannelId, "Slack channel id (`Cxxxx`, `Gxxxx`, `Dxxxx`)."); +string_id!(UserId, "Slack user id (`Uxxxx`, `Wxxxx`)."); +string_id!(Ts, "Slack message timestamp."); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChannelRef { Name(String), Id(ChannelId) } + +impl TryFrom<&str> for ChannelRef { + type Error = CliError; + fn try_from(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(CliError::InvalidArg("empty channel reference".into())); + } + let body = trimmed.strip_prefix('#').unwrap_or(trimmed); + if body.is_empty() { + return Err(CliError::InvalidArg("channel reference is only '#'".into())); + } + if body.starts_with(['C', 'G', 'D']) && ID_RE.is_match(body) { + Ok(Self::Id(ChannelId(body.to_owned()))) + } else { + Ok(Self::Name(body.to_owned())) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UserRef { Name(String), Id(UserId) } + +impl TryFrom<&str> for UserRef { + type Error = CliError; + fn try_from(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(CliError::InvalidArg("empty user reference".into())); + } + let body = trimmed.strip_prefix('@').unwrap_or(trimmed); + if body.is_empty() { + return Err(CliError::InvalidArg("user reference is only '@'".into())); + } + if body.starts_with(['U', 'W']) && ID_RE.is_match(body) { + Ok(Self::Id(UserId(body.to_owned()))) + } else { + Ok(Self::Name(body.to_owned())) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] fn parses_hash_name() { + assert_eq!(ChannelRef::try_from("#general").unwrap(), ChannelRef::Name("general".into())); + } + #[test] fn parses_bare_name() { + assert_eq!(ChannelRef::try_from("general").unwrap(), ChannelRef::Name("general".into())); + } + #[test] fn parses_id() { + assert_eq!( + ChannelRef::try_from("C012ABCD9").unwrap(), + ChannelRef::Id(ChannelId("C012ABCD9".into())), + ); + } + #[test] fn rejects_empty() { + assert!(ChannelRef::try_from("").is_err()); + assert!(ChannelRef::try_from("#").is_err()); + } + #[test] fn user_ref_at_name() { + assert_eq!(UserRef::try_from("@alice").unwrap(), UserRef::Name("alice".into())); + } + #[test] fn user_ref_enterprise() { + assert_eq!(UserRef::try_from("W0123ABCD").unwrap(), UserRef::Id(UserId("W0123ABCD".into()))); + } + #[test] fn short_id_lookalike_is_name() { + assert_eq!(ChannelRef::try_from("C123").unwrap(), ChannelRef::Name("C123".into())); + } +} +``` + +- [ ] **Step 3: Test + clippy + commit** + +```bash +cargo test --lib id +cargo clippy --all-targets -- -D warnings +git add Cargo.toml src/id.rs +git commit -m "feat: add ChannelId/UserId/Ts newtypes and Ref parsers" +``` + +### Task 1.3: Format enum, JSON envelope, render helpers + +**Files:** Modify `src/output.rs`, `Cargo.toml`. + +- [ ] **Step 1: Add deps** + +```toml +tabled = "0.16" +clap = { version = "4", features = ["derive", "wrap_help"] } +``` + +- [ ] **Step 2: Write `src/output.rs`** + +```rust +//! Output formatting and the stable `slack-cli/v0` JSON envelope. + +use std::io::{self, IsTerminal, Write}; + +use serde::Serialize; + +use crate::error::CliError; + +pub const SCHEMA: &str = "slack-cli/v0"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +#[non_exhaustive] +pub enum Format { Table, Plain, Json } + +impl Format { + #[must_use] + pub fn resolve(flag: Option, stdout_is_tty: bool) -> Self { + match (flag, stdout_is_tty) { + (Some(f), _) => f, + (None, true) => Format::Table, + (None, false) => Format::Json, + } + } + #[must_use] + pub fn detect_default() -> Self { + Self::resolve(None, io::stdout().is_terminal()) + } +} + +#[derive(Debug, Serialize)] +pub struct OkEnvelope<'a, T: Serialize> { + pub ok: bool, + pub schema: &'a str, + pub data: T, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, +} + +#[derive(Debug, Serialize)] +pub struct ListPayload<'a, T: Serialize> { + pub items: &'a [T], + pub next_cursor: String, +} + +pub fn render_one( + item: &T, + fmt: Format, + pretty_json: bool, + request_id: Option, + w: &mut dyn Write, +) -> io::Result<()> +where + T: tabled::Tabled + Serialize, +{ + match fmt { + Format::Table => { + let table = tabled::Table::new([item]).to_string(); + writeln!(w, "{table}") + } + Format::Plain => { + let fields = T::fields(item); + let line = fields.iter().map(std::convert::AsRef::as_ref).collect::>().join("\t"); + writeln!(w, "{line}") + } + Format::Json => { + let env = OkEnvelope { ok: true, schema: SCHEMA, data: item, request_id }; + write_json(w, &env, pretty_json) + } + } +} + +pub fn render_list( + items: &[T], + next_cursor: &str, + fmt: Format, + pretty_json: bool, + request_id: Option, + w: &mut dyn Write, +) -> io::Result<()> +where + T: tabled::Tabled + Serialize, +{ + match fmt { + Format::Table => { + let table = tabled::Table::new(items).to_string(); + writeln!(w, "{table}") + } + Format::Plain => { + for item in items { + let fields = T::fields(item); + let line = fields.iter().map(std::convert::AsRef::as_ref).collect::>().join("\t"); + writeln!(w, "{line}")?; + } + Ok(()) + } + Format::Json => { + let payload = ListPayload { items, next_cursor: next_cursor.to_owned() }; + let env = OkEnvelope { ok: true, schema: SCHEMA, data: payload, request_id }; + write_json(w, &env, pretty_json) + } + } +} + +fn write_json(w: &mut dyn Write, v: &T, pretty: bool) -> io::Result<()> { + if pretty { + serde_json::to_writer_pretty(&mut *w, v).map_err(io::Error::other)?; + } else { + serde_json::to_writer(&mut *w, v).map_err(io::Error::other)?; + } + writeln!(w) +} + +#[derive(Debug, Serialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case", tag = "action")] +pub enum Hint { + Run { command: String }, + Specify { flag: String }, + List { command: String }, +} + +#[derive(Debug, Serialize)] +pub struct ErrEnvelope<'a> { + pub ok: bool, + pub schema: &'a str, + pub error: ErrPayload, +} + +#[derive(Debug, Serialize)] +pub struct ErrPayload { + pub code: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub hint: Option, + pub exit_code: u8, + #[serde(skip_serializing_if = "serde_json::Value::is_null")] + pub details: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, +} + +#[must_use] +pub fn hint_for_error(err: &CliError) -> Option { + match err { + CliError::NoToken { .. } => Some(Hint::Run { command: "slack-cli auth init".into() }), + CliError::AmbiguousWorkspace { .. } => Some(Hint::Specify { flag: "--workspace".into() }), + CliError::NotFound { kind: "channel", .. } | CliError::Ambiguous { kind: "channel", .. } => + Some(Hint::List { command: "slack-cli channel list".into() }), + CliError::NotFound { kind: "user", .. } | CliError::Ambiguous { kind: "user", .. } => + Some(Hint::List { command: "slack-cli user list".into() }), + _ => None, + } +} + +pub fn render_error(err: &CliError, json: bool, w: &mut dyn Write) -> io::Result<()> { + if json { + let payload = ErrPayload { + code: err.code().to_owned(), + message: err.to_string(), + hint: hint_for_error(err), + exit_code: err.exit_code(), + details: serde_json::Value::Null, + request_id: None, + }; + let env = ErrEnvelope { ok: false, schema: SCHEMA, error: payload }; + write_json(w, &env, false) + } else { + writeln!(w, "error: {err}")?; + if let Some(h) = hint_for_error(err) { + match h { + Hint::Run { command } => writeln!(w, "hint: run `{command}`")?, + Hint::Specify { flag } => writeln!(w, "hint: specify `{flag}`")?, + Hint::List { command } => writeln!(w, "hint: list with `{command}`")?, + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Serialize; + use tabled::Tabled; + + #[derive(Tabled, Serialize)] + struct Row { id: &'static str, name: &'static str } + + #[test] fn resolve_explicit() { + assert_eq!(Format::resolve(Some(Format::Plain), true), Format::Plain); + } + #[test] fn resolve_tty() { + assert_eq!(Format::resolve(None, true), Format::Table); + } + #[test] fn resolve_pipe() { + assert_eq!(Format::resolve(None, false), Format::Json); + } + #[test] fn one_json_envelope() { + let row = Row { id: "C1", name: "general" }; + let mut buf = Vec::new(); + render_one(&row, Format::Json, false, None, &mut buf).unwrap(); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("\"ok\":true")); + assert!(s.contains("\"schema\":\"slack-cli/v0\"")); + assert!(s.contains("\"id\":\"C1\"")); + } + #[test] fn list_json_has_cursor() { + let rows = [Row { id: "C1", name: "general" }]; + let mut buf = Vec::new(); + render_list(&rows, "next", Format::Json, false, None, &mut buf).unwrap(); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("\"items\"")); + assert!(s.contains("\"next_cursor\":\"next\"")); + } + #[test] fn hint_run_for_no_token() { + let h = hint_for_error(&CliError::NoToken { workspace: "acme".into() }).unwrap(); + assert!(matches!(h, Hint::Run { .. })); + } +} +``` + +- [ ] **Step 3: Test + clippy + commit** + +```bash +cargo test --lib output +cargo clippy --all-targets -- -D warnings +git add Cargo.toml src/output.rs +git commit -m "feat: add Format enum, JSON envelopes, and render helpers" +``` + +--- + +## Phase 2 — Config and secrets + +### Task 2.1: Config TOML I/O + +**Files:** Modify `src/config.rs`, `Cargo.toml`. + +- [ ] **Step 1: Add deps** + +```toml +etcetera = "0.8" +toml_edit = { version = "0.22", features = ["serde"] } +tempfile = "3" +``` + +Also add `tempfile = "3"` under `[dev-dependencies]` (cargo dedupes; both entries are fine). + +- [ ] **Step 2: Write `src/config.rs`** + +```rust +//! Configuration file I/O. + +use std::collections::BTreeMap; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use etcetera::{choose_base_strategy, BaseStrategy}; +use serde::{Deserialize, Serialize}; + +use crate::error::CliError; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct Config { + #[serde(default)] + pub default_workspace: Option, + #[serde(default)] + pub output: OutputConfig, + #[serde(default)] + pub workspaces: BTreeMap, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct OutputConfig { + #[serde(default = "default_format")] pub format: String, + #[serde(default = "default_color")] pub color: String, + #[serde(default)] pub utc: bool, +} + +impl Default for OutputConfig { + fn default() -> Self { + Self { format: default_format(), color: default_color(), utc: false } + } +} + +fn default_format() -> String { "auto".into() } +fn default_color() -> String { "auto".into() } + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct WorkspaceConfig { + pub team_id: Option, + pub team_domain: Option, + pub token_source: TokenSource, + pub token: Option, + pub token_env: Option, + #[serde(default)] pub meta: WorkspaceMeta, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum TokenSource { Keyring, File, Env } + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct WorkspaceMeta { + pub token_kind: Option, + pub user_id: Option, + pub updated_at: Option, +} + +pub fn resolve_path(cli: Option<&Path>) -> Result { + if let Some(p) = cli { + return Ok(p.to_path_buf()); + } + if let Ok(envp) = std::env::var("SLACK_CLI_CONFIG") { + return Ok(PathBuf::from(envp)); + } + let strat = choose_base_strategy().map_err(|e| CliError::Config(e.to_string()))?; + Ok(strat.config_dir().join("slack-cli").join("config.toml")) +} + +pub fn load(path: &Path) -> Result { + if !path.exists() { + return Ok(Config::default()); + } + let raw = std::fs::read_to_string(path)?; + toml_edit::de::from_str(&raw).map_err(|e| CliError::Config(e.to_string())) +} + +pub fn save(path: &Path, cfg: &Config) -> Result<(), CliError> { + let parent = path.parent().ok_or_else(|| CliError::Config("path has no parent".into()))?; + std::fs::create_dir_all(parent)?; + + let serialized = toml_edit::ser::to_string_pretty(cfg) + .map_err(|e| CliError::Config(e.to_string()))?; + + let mut tmp = tempfile::NamedTempFile::new_in(parent)?; + tmp.write_all(serialized.as_bytes())?; + tmp.as_file().sync_all()?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o600))?; + } + + tmp.persist(path).map_err(|e| CliError::Io(e.error))?; + Ok(()) +} + +/// Pick the workspace to use given CLI/env/default precedence. +pub fn pick_workspace<'a>( + cfg: &'a Config, + cli: Option<&str>, +) -> Result<(&'a str, &'a WorkspaceConfig), CliError> { + let env_ws = std::env::var("SLACK_CLI_WORKSPACE").ok(); + let name = cli.map(str::to_owned) + .or(env_ws) + .or_else(|| cfg.default_workspace.clone()) + .or_else(|| if cfg.workspaces.len() == 1 { + cfg.workspaces.keys().next().cloned() + } else { None }); + + let name = name.ok_or_else(|| CliError::AmbiguousWorkspace { + available: cfg.workspaces.keys().cloned().collect(), + })?; + let ws = cfg.workspaces.get(&name).ok_or_else(|| CliError::AmbiguousWorkspace { + available: cfg.workspaces.keys().cloned().collect(), + })?; + // SAFETY: `name` lives in cfg only through workspaces lookup, but we need + // a &str tied to cfg. Use the key from the map to return a reference. + let key = cfg.workspaces.get_key_value(&name).expect("just checked").0.as_str(); + Ok((key, ws)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] fn missing_file_returns_default() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("config.toml"); + let cfg = load(&p).unwrap(); + assert!(cfg.default_workspace.is_none()); + } + + #[test] fn save_then_load_roundtrips() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("a").join("b").join("config.toml"); + let mut cfg = Config::default(); + cfg.default_workspace = Some("acme".into()); + cfg.workspaces.insert("acme".into(), WorkspaceConfig { + team_id: Some("T1".into()), + team_domain: Some("acme".into()), + token_source: TokenSource::Keyring, + token: None, + token_env: None, + meta: WorkspaceMeta::default(), + }); + save(&p, &cfg).unwrap(); + let loaded = load(&p).unwrap(); + assert_eq!(loaded.default_workspace.as_deref(), Some("acme")); + } + + #[cfg(unix)] + #[test] fn save_sets_0600() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("config.toml"); + save(&p, &Config::default()).unwrap(); + let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } + + #[test] fn pick_workspace_uses_default() { + let mut cfg = Config::default(); + cfg.default_workspace = Some("acme".into()); + cfg.workspaces.insert("acme".into(), WorkspaceConfig { + team_id: None, team_domain: None, + token_source: TokenSource::Keyring, + token: None, token_env: None, + meta: WorkspaceMeta::default(), + }); + let (name, _) = pick_workspace(&cfg, None).unwrap(); + assert_eq!(name, "acme"); + } +} +``` + +- [ ] **Step 3: Test + clippy + commit** + +```bash +cargo test --lib config +cargo clippy --all-targets -- -D warnings +git add Cargo.toml src/config.rs +git commit -m "feat: TOML config with atomic save and workspace picker" +``` + +### Task 2.2: TokenStore with prefix validation (env + file backends) + +**Files:** Modify `src/secret.rs`, `Cargo.toml`. + +- [ ] **Step 1: Add `secrecy`** + +```toml +secrecy = "0.10" +``` + +- [ ] **Step 2: Write `src/secret.rs`** + +```rust +//! Token storage with three backends: keyring (OS), file (config.toml), env. + +use secrecy::{ExposeSecret, SecretString}; + +use crate::config::{TokenSource, WorkspaceConfig}; +use crate::error::CliError; + +pub fn validate_prefix(raw: &str) -> Result<(), CliError> { + if raw.starts_with("xoxp-") || raw.starts_with("xoxb-") { + Ok(()) + } else { + Err(CliError::InvalidArg("token must start with 'xoxp-' or 'xoxb-'".into())) + } +} + +pub fn load(workspace_name: &str, ws: &WorkspaceConfig) -> Result { + if let Ok(t) = std::env::var("SLACK_CLI_TOKEN") { + validate_prefix(&t)?; + return Ok(SecretString::from(t)); + } + match ws.token_source { + TokenSource::Env => { + let name = ws.token_env.as_deref() + .ok_or_else(|| CliError::Config(format!("workspace '{workspace_name}' has no token_env")))?; + let t = std::env::var(name) + .map_err(|_| CliError::NoToken { workspace: workspace_name.into() })?; + validate_prefix(&t)?; + Ok(SecretString::from(t)) + } + TokenSource::File => { + #[cfg(target_os = "windows")] + return Err(CliError::FileBackendUnsupported); + #[cfg(not(target_os = "windows"))] + { + let t = ws.token.as_deref() + .ok_or_else(|| CliError::NoToken { workspace: workspace_name.into() })?; + validate_prefix(t)?; + Ok(SecretString::from(t.to_owned())) + } + } + TokenSource::Keyring => keyring_load(workspace_name), + } +} + +pub fn save(workspace_name: &str, ws: &mut WorkspaceConfig, token: &SecretString) -> Result<(), CliError> { + validate_prefix(token.expose_secret())?; + match ws.token_source { + TokenSource::Env => Ok(()), + TokenSource::File => { + #[cfg(target_os = "windows")] + return Err(CliError::FileBackendUnsupported); + #[cfg(not(target_os = "windows"))] + { + ws.token = Some(token.expose_secret().to_owned()); + Ok(()) + } + } + TokenSource::Keyring => keyring_save(workspace_name, token), + } +} + +pub fn delete(workspace_name: &str, ws: &mut WorkspaceConfig) -> Result<(), CliError> { + match ws.token_source { + TokenSource::Env => Ok(()), + TokenSource::File => { + ws.token = None; + Ok(()) + } + TokenSource::Keyring => keyring_delete(workspace_name), + } +} + +fn keyring_entry(workspace_name: &str) -> Result { + keyring::Entry::new("slack-cli", workspace_name) + .map_err(|e| CliError::Config(format!("keyring init failed: {e}"))) +} + +fn keyring_load(workspace_name: &str) -> Result { + let entry = keyring_entry(workspace_name)?; + match entry.get_password() { + Ok(t) => { + validate_prefix(&t)?; + Ok(SecretString::from(t)) + } + Err(keyring::Error::NoEntry) => Err(CliError::NoToken { workspace: workspace_name.into() }), + Err(e) => { + tracing::warn!(error = %e, "keyring load failed"); + Err(CliError::Config(format!("keyring error: {e}"))) + } + } +} + +fn keyring_save(workspace_name: &str, token: &SecretString) -> Result<(), CliError> { + keyring_entry(workspace_name)? + .set_password(token.expose_secret()) + .map_err(|e| CliError::Config(format!("keyring error: {e}"))) +} + +fn keyring_delete(workspace_name: &str) -> Result<(), CliError> { + match keyring_entry(workspace_name)?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(CliError::Config(format!("keyring error: {e}"))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{TokenSource, WorkspaceConfig, WorkspaceMeta}; + + fn ws(src: TokenSource) -> WorkspaceConfig { + WorkspaceConfig { + team_id: None, team_domain: None, + token_source: src, + token: None, token_env: None, + meta: WorkspaceMeta::default(), + } + } + + #[test] fn validate_accepts_xoxp_and_xoxb() { + assert!(validate_prefix("xoxp-1-2-3").is_ok()); + assert!(validate_prefix("xoxb-1-2-3").is_ok()); + } + #[test] fn validate_rejects_garbage() { + assert!(validate_prefix("hello").is_err()); + } + #[cfg(not(target_os = "windows"))] + #[test] fn file_save_then_load() { + let mut w = ws(TokenSource::File); + let secret = SecretString::from("xoxp-1-2-3"); + save("acme", &mut w, &secret).unwrap(); + let loaded = load("acme", &w).unwrap(); + assert_eq!(loaded.expose_secret(), "xoxp-1-2-3"); + } +} +``` + +- [ ] **Step 3: Add keyring deps in `Cargo.toml`** + +```toml +[target.'cfg(target_os = "macos")'.dependencies] +keyring = { version = "3", features = ["apple-native"] } + +[target.'cfg(target_os = "linux")'.dependencies] +keyring = { version = "3", features = ["sync-secret-service"] } + +[target.'cfg(target_os = "windows")'.dependencies] +keyring = { version = "3", features = ["windows-native"] } + +# Always pull `tracing` for the keyring warn!() call. +tracing = "0.1" +``` + +(Move `tracing` to the top-level `[dependencies]` section, not OS-specific.) + +- [ ] **Step 4: Test + clippy + commit** + +```bash +cargo test --lib secret +cargo clippy --all-targets -- -D warnings +git add Cargo.toml src/secret.rs +git commit -m "feat: TokenStore with keyring/file/env backends and prefix validation" +``` + +--- + +## Phase 3 — Slack HTTP client + +### Task 3.1: SlackClient skeleton + `auth.test` + +**Files:** Modify `src/slack.rs`, `Cargo.toml`. + +- [ ] **Step 1: Add deps** + +```toml +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "multipart", "stream"] } +tokio = { version = "1", features = ["macros", "rt", "fs", "io-util", "signal"] } +uuid = { version = "1", features = ["v4"] } +``` + +Update `dev-dependencies` to add `wiremock`: + +```toml +[dev-dependencies] +wiremock = "0.6" +tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread", "test-util"] } +``` + +- [ ] **Step 2: Write `src/slack.rs` skeleton with `auth.test`** + +```rust +//! Thin Slack Web API client. No SDK; direct `reqwest` calls. + +use std::time::Duration; + +use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION}; +use secrecy::{ExposeSecret, SecretString}; +use serde::Deserialize; +use tracing::instrument; + +use crate::error::CliError; + +const DEFAULT_BASE_URL: &str = "https://slack.com"; + +#[derive(Debug)] +pub struct SlackClient { + base_url: String, + http: reqwest::Client, + token: SecretString, +} + +impl SlackClient { + pub fn new(token: SecretString) -> Result { + Self::build(DEFAULT_BASE_URL.into(), token) + } + + pub fn with_base_url(base_url: String, token: SecretString) -> Result { + Self::build(base_url, token) + } + + fn build(base_url: String, token: SecretString) -> Result { + let mut auth = HeaderValue::from_str(&format!("Bearer {}", token.expose_secret())) + .map_err(|e| CliError::Config(format!("bad token: {e}")))?; + auth.set_sensitive(true); + let mut headers = HeaderMap::new(); + headers.insert(AUTHORIZATION, auth); + let http = reqwest::Client::builder() + .default_headers(headers) + .user_agent(concat!("slack-cli/", env!("CARGO_PKG_VERSION"))) + .timeout(Duration::from_secs(30)) + .build() + .map_err(|e| CliError::Network(e.to_string()))?; + Ok(Self { base_url, http, token }) + } + + fn _token(&self) -> &SecretString { &self.token } + + #[instrument(skip(self))] + pub async fn auth_test(&self) -> Result { + let url = format!("{}/api/auth.test", self.base_url); + let resp = self.http.post(&url).send().await + .map_err(|e| CliError::Network(e.to_string()))?; + parse_slack::(resp).await + } +} + +#[derive(Debug, Clone, Deserialize, serde::Serialize)] +pub struct AuthTest { + pub ok: bool, + pub url: Option, + pub team: Option, + pub user: Option, + pub team_id: Option, + pub user_id: Option, + pub bot_id: Option, + pub error: Option, +} + +async fn parse_slack Deserialize<'de>>(resp: reqwest::Response) -> Result { + let request_id = resp.headers().get("x-slack-req-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let status = resp.status(); + let body = resp.text().await.map_err(|e| CliError::Network(e.to_string()))?; + if !status.is_success() { + return Err(CliError::SlackApi { + code: format!("http_{}", status.as_u16()), + request_id, + }); + } + // Slack returns 200 + ok:false. + let v: serde_json::Value = serde_json::from_str(&body) + .map_err(|e| CliError::SlackApi { code: format!("decode: {e}"), request_id: request_id.clone() })?; + if v.get("ok").and_then(serde_json::Value::as_bool) != Some(true) { + let code = v.get("error").and_then(|x| x.as_str()).unwrap_or("unknown").to_owned(); + return Err(map_slack_error(&code, request_id)); + } + let parsed: T = serde_json::from_value(v) + .map_err(|e| CliError::SlackApi { code: format!("decode: {e}"), request_id })?; + Ok(parsed) +} + +fn map_slack_error(code: &str, request_id: Option) -> CliError { + match code { + "not_authed" | "invalid_auth" | "account_inactive" | "token_revoked" | "token_expired" + => CliError::AuthFailed { reason: code.to_owned() }, + "channel_not_found" => CliError::NotFound { kind: "channel", name: "".into() }, + "user_not_found" => CliError::NotFound { kind: "user", name: "".into() }, + "file_not_found" => CliError::NotFound { kind: "file", name: "".into() }, + "ratelimited" => CliError::RateLimited { retry_after_secs: 1 }, + "missing_scope" | "not_allowed_token_type" + => CliError::TokenKindMismatch { needed: "?", actual: "?", api: "?" }, + _ => CliError::SlackApi { code: code.to_owned(), request_id }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[tokio::test(flavor = "current_thread")] + async fn auth_test_ok() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/auth.test")) + .and(header("authorization", "Bearer xoxp-test")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"ok":true,"team":"acme","user":"alice","team_id":"T1","user_id":"U1"}"#, + )) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let r = c.auth_test().await.unwrap(); + assert_eq!(r.team_id.as_deref(), Some("T1")); + } + + #[tokio::test(flavor = "current_thread")] + async fn auth_test_invalid_auth() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/auth.test")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"ok":false,"error":"invalid_auth"}"#, + )) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let err = c.auth_test().await.unwrap_err(); + assert!(matches!(err, CliError::AuthFailed { .. })); + } +} +``` + +- [ ] **Step 3: Test + clippy + commit** + +```bash +cargo test --lib slack +cargo clippy --all-targets -- -D warnings +git add Cargo.toml src/slack.rs +git commit -m "feat: SlackClient skeleton with auth.test and error mapping" +``` + +### Task 3.2: Retry-After clamp + `ratelimited` single retry + +**Files:** Modify `src/slack.rs`. + +- [ ] **Step 1: Add retry helper** + +Replace `auth_test`'s body and add a helper: + +```rust + async fn post_with_retry(&self, url: &str) -> Result { + let do_send = || self.http.post(url).send(); + let resp = do_send().await.map_err(|e| CliError::Network(e.to_string()))?; + if resp.status().as_u16() == 429 { + let retry = resp.headers().get(reqwest::header::RETRY_AFTER) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(1); + let wait = retry.min(60); + tokio::time::sleep(Duration::from_secs(wait)).await; + return do_send().await.map_err(|e| CliError::Network(e.to_string())); + } + Ok(resp) + } +``` + +Update `auth_test`: + +```rust + let url = format!("{}/api/auth.test", self.base_url); + let resp = self.post_with_retry(&url).await?; +``` + +- [ ] **Step 2: Add test** + +```rust + #[tokio::test(flavor = "current_thread")] + async fn retry_once_after_429() { + use wiremock::matchers::method; + let server = MockServer::start().await; + let body_ok = r#"{"ok":true,"team":"acme","team_id":"T1","user_id":"U1"}"#; + + let ok_resp = ResponseTemplate::new(200).set_body_string(body_ok); + let limited = ResponseTemplate::new(429) + .insert_header("retry-after", "0"); + Mock::given(method("POST")) + .and(path("/api/auth.test")) + .respond_with(limited) + .up_to_n_times(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/api/auth.test")) + .respond_with(ok_resp) + .mount(&server) + .await; + + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let r = c.auth_test().await.unwrap(); + assert_eq!(r.team_id.as_deref(), Some("T1")); + } +``` + +- [ ] **Step 3: Test + commit** + +```bash +cargo test --lib slack +git add src/slack.rs +git commit -m "feat: retry once on HTTP 429 with Retry-After clamped to 60s" +``` + +### Task 3.3: Read-side API methods (`conversations.list`, `conversations.history`, `users.list`, `users.info`, `conversations.info`, `search.messages`, `search.files`, `files.list`, `files.info`) + +For each API method, follow the same pattern: + +1. Define a DTO struct (`#[derive(Debug, Clone, Deserialize, Serialize, Tabled)]` where helpful). +2. Add a method on `SlackClient` that uses `post_with_retry` or `get_with_retry`. +3. Write a wiremock-based test with a fixture in `tests/fixtures/.ok.json`. +4. Commit with `feat: add slack client method `. + +**Files:** Modify `src/slack.rs`, add fixtures under `tests/fixtures/`. + +- [ ] **Step 1: Add `get_with_retry` helper** (mirror of `post_with_retry` but with `self.http.get(url)`). + +- [ ] **Step 2: Implement `conversations.list`** + +DTO: + +```rust +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ChannelDto { + pub id: String, + pub name: String, + pub is_private: bool, + pub is_archived: bool, + pub num_members: Option, + pub topic: Option, + pub purpose: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TopicPurpose { + pub value: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ConversationsList { + pub ok: bool, + pub channels: Vec, + pub response_metadata: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ResponseMetadata { + pub next_cursor: Option, +} +``` + +Method: + +```rust + pub async fn conversations_list( + &self, + types: &str, + limit: u32, + cursor: Option<&str>, + ) -> Result { + let mut url = format!( + "{}/api/conversations.list?types={}&limit={}", + self.base_url, types, limit + ); + if let Some(c) = cursor { + url.push_str("&cursor="); + url.push_str(c); + } + let resp = self.get_with_retry(&url).await?; + parse_slack::(resp).await + } +``` + +Add test with fixture file `tests/fixtures/conversations.list.ok.json`: + +```json +{ + "ok": true, + "channels": [ + {"id":"C001","name":"general","is_private":false,"is_archived":false,"num_members":5, + "topic":{"value":"team"},"purpose":{"value":"discuss"}} + ], + "response_metadata": {"next_cursor":""} +} +``` + +Test: + +```rust + #[tokio::test(flavor = "current_thread")] + async fn conversations_list_parses() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/conversations.list")) + .respond_with(ResponseTemplate::new(200) + .set_body_string(include_str!("../tests/fixtures/conversations.list.ok.json"))) + .mount(&server).await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let res = c.conversations_list("public_channel,private_channel", 50, None).await.unwrap(); + assert_eq!(res.channels.len(), 1); + assert_eq!(res.channels[0].name, "general"); + } +``` + +Commit: `feat: add conversations.list with cursor`. + +- [ ] **Step 3: Implement `conversations.info`** with fixture `conversations.info.ok.json`. + +```rust + pub async fn conversations_info(&self, channel: &str) -> Result { + let url = format!("{}/api/conversations.info?channel={}", self.base_url, channel); + let resp = self.get_with_retry(&url).await?; + parse_slack::(resp).await + } + +#[derive(Debug, Clone, Deserialize)] +pub struct ConversationsInfo { + pub ok: bool, + pub channel: ChannelDto, +} +``` + +Commit: `feat: add conversations.info`. + +- [ ] **Step 4: Implement `conversations.history`** with `messages` array + pagination. + +```rust +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MessageDto { + pub ts: String, + pub user: Option, + pub text: String, + pub thread_ts: Option, + pub reply_count: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct History { + pub ok: bool, + pub messages: Vec, + pub response_metadata: Option, +} + + pub async fn conversations_history( + &self, + channel: &str, + limit: u32, + cursor: Option<&str>, + oldest: Option<&str>, + latest: Option<&str>, + ) -> Result { + let mut url = format!( + "{}/api/conversations.history?channel={}&limit={}", + self.base_url, channel, limit, + ); + if let Some(c) = cursor { url.push_str(&format!("&cursor={c}")); } + if let Some(t) = oldest { url.push_str(&format!("&oldest={t}")); } + if let Some(t) = latest { url.push_str(&format!("&latest={t}")); } + let resp = self.get_with_retry(&url).await?; + parse_slack::(resp).await + } +``` + +Commit: `feat: add conversations.history with oldest/latest filters`. + +- [ ] **Step 5: Implement `conversations.replies`** (for thread view): same pattern, query params `channel`, `ts`, `cursor`, `limit`. + +Commit: `feat: add conversations.replies for thread view`. + +- [ ] **Step 6: Implement `users.list`** + `users.info`. + +```rust +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UserDto { + pub id: String, + pub name: String, + pub real_name: Option, + pub deleted: bool, + pub is_bot: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct UsersList { + pub ok: bool, + pub members: Vec, + pub response_metadata: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct UsersInfo { + pub ok: bool, + pub user: UserDto, +} +``` + +Methods + tests + fixtures + commits: `feat: add users.list`, `feat: add users.info`. + +- [ ] **Step 7: Implement `search.messages`** + `search.files` (user-token only; the API rejects bot tokens — surface as `TokenKindMismatch`). + +```rust +#[derive(Debug, Clone, Deserialize)] +pub struct SearchMessagesResp { + pub ok: bool, + pub messages: SearchMessagesPayload, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SearchMessagesPayload { + pub matches: Vec, + pub paging: SearchPaging, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SearchPaging { + pub page: u32, + pub pages: u32, +} +``` + +Method `search_messages(&self, query: &str, page: u32, count: u32)`. + +Commit: `feat: add search.messages and search.files`. + +- [ ] **Step 8: Implement `files.list`** + `files.info`. Fixtures + tests + commit. + +### Task 3.4: Write-side API methods (`chat.postMessage`, `chat.update`, `chat.delete`, `reactions.add`, `chat.postEphemeral` not needed for MVP, `conversations.replies` already in 3.3) + +**Files:** Modify `src/slack.rs`. + +- [ ] **Step 1: Implement `chat.postMessage` with idempotency key** + +DTO + payload: + +```rust +#[derive(Debug, Clone, Serialize)] +pub struct PostMessage { + pub channel: String, + pub text: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub thread_ts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reply_broadcast: Option, + pub client_msg_id: String, +} + +impl PostMessage { + pub fn new(channel: String, text: String) -> Self { + Self { + channel, text, + thread_ts: None, + reply_broadcast: None, + client_msg_id: uuid::Uuid::new_v4().to_string(), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct PostMessageResp { + pub ok: bool, + pub ts: String, + pub channel: String, + pub message: MessageDto, +} +``` + +Method: + +```rust + pub async fn chat_post_message(&self, req: &PostMessage) -> Result { + let url = format!("{}/api/chat.postMessage", self.base_url); + let resp = self.http.post(&url).json(req).send().await + .map_err(|e| CliError::Network(e.to_string()))?; + parse_slack::(resp).await + } +``` + +Test with fixture + commit `feat: add chat.postMessage with idempotency key`. + +- [ ] **Step 2: `chat.update`** — payload `{channel, ts, text}`, response includes `ts`. + +- [ ] **Step 3: `chat.delete`** — payload `{channel, ts}`. + +- [ ] **Step 4: `reactions.add`** — payload `{channel, timestamp, name}`. Map `already_reacted` to a successful no-op (return `Ok(())`). + +```rust + pub async fn reactions_add(&self, channel: &str, ts: &str, name: &str) -> Result<(), CliError> { + let url = format!("{}/api/reactions.add", self.base_url); + let resp = self.http.post(&url) + .json(&serde_json::json!({"channel": channel, "timestamp": ts, "name": name})) + .send().await.map_err(|e| CliError::Network(e.to_string()))?; + // Custom handling: treat already_reacted as success. + let status = resp.status(); + let request_id = resp.headers().get("x-slack-req-id").and_then(|v| v.to_str().ok()).map(str::to_owned); + let body = resp.text().await.map_err(|e| CliError::Network(e.to_string()))?; + if !status.is_success() { + return Err(CliError::SlackApi { code: format!("http_{}", status.as_u16()), request_id }); + } + let v: serde_json::Value = serde_json::from_str(&body) + .map_err(|e| CliError::SlackApi { code: format!("decode: {e}"), request_id })?; + if v.get("ok").and_then(serde_json::Value::as_bool) == Some(true) { + return Ok(()); + } + let code = v.get("error").and_then(|x| x.as_str()).unwrap_or("unknown"); + if code == "already_reacted" { + return Ok(()); + } + Err(map_slack_error(code, None)) + } +``` + +Test: 200/ok and 200/already_reacted both succeed. Commit `feat: add reactions.add (already_reacted is success)`. + +- [ ] **Step 5: Admin: `conversations.create`, `conversations.archive`, `conversations.invite`, `conversations.leave`** + +Each takes simple payloads. Commit one by one. + +- [ ] **Step 6: `files.upload`** (multipart) + +```rust + pub async fn files_upload( + &self, + channel: Option<&str>, + filename: &str, + bytes: Vec, + title: Option<&str>, + initial_comment: Option<&str>, + thread_ts: Option<&str>, + ) -> Result { + let url = format!("{}/api/files.upload", self.base_url); + let part = reqwest::multipart::Part::bytes(bytes).file_name(filename.to_owned()); + let mut form = reqwest::multipart::Form::new().part("file", part); + if let Some(c) = channel { form = form.text("channels", c.to_owned()); } + if let Some(t) = title { form = form.text("title", t.to_owned()); } + if let Some(c) = initial_comment { form = form.text("initial_comment", c.to_owned()); } + if let Some(t) = thread_ts { form = form.text("thread_ts", t.to_owned()); } + let resp = self.http.post(&url).multipart(form).send().await + .map_err(|e| CliError::Network(e.to_string()))?; + let parsed: FilesUploadResp = parse_slack(resp).await?; + Ok(parsed.file) + } + +#[derive(Debug, Clone, Deserialize)] +pub struct FilesUploadResp { pub ok: bool, pub file: FileDto } + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct FileDto { + pub id: String, + pub name: String, + pub size: u64, + pub mimetype: String, + pub user: Option, + pub url_private: Option, +} +``` + +Commit `feat: add files.upload (multipart)`. + +- [ ] **Step 7: `files.delete`** + download helper using `url_private` + `Authorization` header (Slack file downloads require bearer auth on `files.slack.com`). Commit each. + +--- + +## Phase 4 — Resolver + +### Task 4.1: Resolver with name→ID + 60s cache + +**Files:** Modify `src/resolve.rs`. + +- [ ] **Step 1: Write `src/resolve.rs`** + +```rust +//! Name → ID resolution with a 60s in-process cache. + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use crate::error::CliError; +use crate::id::{ChannelId, ChannelRef, UserId, UserRef}; +use crate::slack::SlackClient; + +const TTL: Duration = Duration::from_secs(60); + +#[derive(Debug, Default)] +pub struct Resolver { + channels: HashMap, + users: HashMap, +} + +impl Resolver { + #[must_use] + pub fn new() -> Self { Self::default() } + + pub async fn channel(&mut self, client: &SlackClient, r: &ChannelRef) -> Result { + match r { + ChannelRef::Id(id) => Ok(id.clone()), + ChannelRef::Name(name) => { + if let Some((id, ts)) = self.channels.get(name) { + if ts.elapsed() < TTL { + return Ok(id.clone()); + } + } + let id = lookup_channel(client, name).await?; + self.channels.insert(name.clone(), (id.clone(), Instant::now())); + Ok(id) + } + } + } + + pub async fn user(&mut self, client: &SlackClient, r: &UserRef) -> Result { + match r { + UserRef::Id(id) => Ok(id.clone()), + UserRef::Name(name) => { + if let Some((id, ts)) = self.users.get(name) { + if ts.elapsed() < TTL { + return Ok(id.clone()); + } + } + let id = lookup_user(client, name).await?; + self.users.insert(name.clone(), (id.clone(), Instant::now())); + Ok(id) + } + } + } +} + +async fn lookup_channel(client: &SlackClient, name: &str) -> Result { + let mut cursor: Option = None; + let mut matches: Vec<(String, bool)> = Vec::new(); // (id, is_private) + loop { + let page = client.conversations_list( + "public_channel,private_channel", + 200, + cursor.as_deref(), + ).await?; + for ch in &page.channels { + if ch.name == name { + matches.push((ch.id.clone(), ch.is_private)); + } + } + cursor = page.response_metadata + .and_then(|m| m.next_cursor) + .filter(|s| !s.is_empty()); + if cursor.is_none() { break; } + } + match matches.len() { + 0 => Err(CliError::NotFound { kind: "channel", name: name.into() }), + 1 => Ok(ChannelId(matches.remove(0).0)), + _ => Err(CliError::Ambiguous { + kind: "channel", + name: name.into(), + candidates: matches.into_iter().map(|(id, p)| { + format!("{id} ({})", if p { "private" } else { "public" }) + }).collect(), + }), + } +} + +async fn lookup_user(client: &SlackClient, name: &str) -> Result { + let mut cursor: Option = None; + let mut matches: Vec = Vec::new(); + loop { + let page = client.users_list(200, cursor.as_deref()).await?; + for u in &page.members { + if u.name == name && !u.deleted { + matches.push(u.id.clone()); + } + } + cursor = page.response_metadata + .and_then(|m| m.next_cursor) + .filter(|s| !s.is_empty()); + if cursor.is_none() { break; } + } + match matches.len() { + 0 => Err(CliError::NotFound { kind: "user", name: name.into() }), + 1 => Ok(UserId(matches.remove(0))), + _ => Err(CliError::Ambiguous { + kind: "user", name: name.into(), candidates: matches, + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::id::ChannelRef; + use secrecy::SecretString; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[tokio::test(flavor = "current_thread")] + async fn channel_id_passthrough_no_http_call() { + // No mocks mounted; if HTTP fires, the test fails. + let server = MockServer::start().await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let mut r = Resolver::new(); + let id = r.channel(&c, &ChannelRef::Id(ChannelId("C0".into()))).await.unwrap(); + assert_eq!(id.as_str(), "C0"); + } + + #[tokio::test(flavor = "current_thread")] + async fn channel_name_resolves_via_list() { + let server = MockServer::start().await; + Mock::given(method("GET")).and(path("/api/conversations.list")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"ok":true,"channels":[ + {"id":"C1","name":"general","is_private":false,"is_archived":false,"num_members":1,"topic":{"value":""},"purpose":{"value":""}} + ],"response_metadata":{"next_cursor":""}}"#)) + .mount(&server).await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let mut r = Resolver::new(); + let id = r.channel(&c, &ChannelRef::Name("general".into())).await.unwrap(); + assert_eq!(id.as_str(), "C1"); + } +} +``` + +- [ ] **Step 2: Test + clippy + commit** + +```bash +cargo test --lib resolve +cargo clippy --all-targets -- -D warnings +git add src/resolve.rs +git commit -m "feat: Resolver with name→ID, ambiguity detection, 60s cache" +``` + +--- + +## Phase 5 — CLI + dispatch + +### Task 5.1: `cli.rs` with `Cli`, `GlobalArgs`, Subcommand enums + +**Files:** Modify `src/cli.rs`. + +- [ ] **Step 1: Write `src/cli.rs`** + +```rust +//! CLI argument definitions (clap derive). + +use std::path::PathBuf; + +use clap::{Args, Parser, Subcommand}; + +use crate::output::Format; + +#[derive(Debug, Parser)] +#[command(name = "slack-cli", version, about = "Slack CLI for coding agents")] +pub struct Cli { + #[command(flatten)] + pub globals: GlobalArgs, + + #[command(subcommand)] + pub command: TopCommand, +} + +#[derive(Debug, Args)] +pub struct GlobalArgs { + /// Config file path. Overrides `SLACK_CLI_CONFIG`. + #[arg(short = 'c', long, global = true, env = "SLACK_CLI_CONFIG")] + pub config: Option, + + /// Workspace name from config to use. + #[arg(short = 'w', long, global = true, env = "SLACK_CLI_WORKSPACE")] + pub workspace: Option, + + /// Output format. Defaults to table on TTY, json on pipe. + #[arg(long, global = true, value_enum, group = "output_fmt")] + pub json: bool, + + #[arg(long, global = true, group = "output_fmt")] + pub plain: bool, + + #[arg(long, global = true, group = "output_fmt")] + pub table: bool, + + /// Increase log verbosity (-v info, -vv debug, -vvv trace). + #[arg(short = 'v', long, global = true, action = clap::ArgAction::Count)] + pub verbose: u8, + + /// Suppress info logging (errors still printed). + #[arg(long, global = true)] + pub quiet: bool, + + /// Disable ANSI color output (also via `NO_COLOR`). + #[arg(long, global = true)] + pub no_color: bool, + + /// Confirm destructive operations non-interactively. + #[arg(long, global = true, visible_alias = "no-confirm")] + pub yes: bool, + + /// Max items per page (default 50, max 200). + #[arg(long, global = true, default_value_t = 50)] + pub max_results: u32, + + /// Fetch every page (paired with --max-pages). + #[arg(long, global = true)] + pub all: bool, + + /// Maximum pages to fetch when --all is set. + #[arg(long, global = true, default_value_t = 10)] + pub max_pages: u32, + + /// For write/admin commands: print the would-be request without sending. + #[arg(long, global = true)] + pub dry_run: bool, +} + +impl GlobalArgs { + #[must_use] + pub fn format(&self) -> Option { + if self.json { Some(Format::Json) } + else if self.plain { Some(Format::Plain) } + else if self.table { Some(Format::Table) } + else { None } + } +} + +#[derive(Debug, Subcommand)] +pub enum TopCommand { + Auth(AuthArgs), + Channel(ChannelArgs), + Message(MessageArgs), + Search(SearchArgs), + User(UserArgs), + File(FileArgs), + Completion(CompletionArgs), +} + +// ... Args and Subcommand enums for each group (auth, channel, message, search, user, file, completion). +// Each follows the same pattern: a struct with #[command(subcommand)] holding an enum of verbs, +// and each verb is its own Args struct. + +#[derive(Debug, Args)] +pub struct AuthArgs { + #[command(subcommand)] + pub verb: AuthVerb, +} + +#[derive(Debug, Subcommand)] +pub enum AuthVerb { + /// Interactively configure a workspace. + Init { + /// Workspace name to create or update. + #[arg(short = 'w', long)] + workspace: Option, + /// Read the token from stdin instead of prompting. + #[arg(long)] + token_stdin: bool, + /// Token backend. + #[arg(long, value_enum, default_value_t = AuthBackend::Keyring)] + backend: AuthBackend, + }, + /// Print the current identity / workspace. + Whoami, + /// List configured workspaces. + List, + /// Remove the stored token for a workspace. + Logout { + #[arg(short = 'w', long)] + workspace: Option, + }, +} + +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +pub enum AuthBackend { Keyring, File, Env } + +#[derive(Debug, Args)] +pub struct ChannelArgs { #[command(subcommand)] pub verb: ChannelVerb } + +#[derive(Debug, Subcommand)] +pub enum ChannelVerb { + List { + #[arg(short = 'q', long)] query: Option, + #[arg(long, default_value = "public_channel,private_channel")] types: String, + }, + View { channel: String }, + Create { name: String, #[arg(long)] private: bool, #[arg(long)] description: Option }, + Archive { channel: String }, + Invite { channel: String, users: Vec }, + Leave { channel: String }, +} + +#[derive(Debug, Args)] +pub struct MessageArgs { #[command(subcommand)] pub verb: MessageVerb } + +#[derive(Debug, Subcommand)] +pub enum MessageVerb { + List { + channel: String, + #[arg(long, default_value_t = 50)] limit: u32, + #[arg(long)] oldest: Option, + #[arg(long)] latest: Option, + }, + View { + channel: String, + ts: String, + #[arg(long)] thread: bool, + }, + Send { + channel: String, + #[arg(long, conflicts_with_all = ["file"])] text: Option, + #[arg(long, conflicts_with_all = ["text"])] file: Option, + #[arg(long)] thread: Option, + #[arg(long)] broadcast: bool, + }, + Reply { channel: String, ts: String, #[arg(long)] text: String, #[arg(long)] broadcast: bool }, + Update { channel: String, ts: String, #[arg(long)] text: String }, + Delete { channel: String, ts: String }, + React { channel: String, ts: String, emoji: String, #[arg(long)] remove: bool }, +} + +#[derive(Debug, Args)] +pub struct SearchArgs { #[command(subcommand)] pub verb: SearchVerb } + +#[derive(Debug, Subcommand)] +pub enum SearchVerb { + Messages { + query: String, + #[arg(long)] r#in: Option, + #[arg(long)] from: Option, + #[arg(long, default_value_t = 50)] limit: u32, + }, + Files { + query: String, + #[arg(long, name = "type")] file_type: Option, + #[arg(long, default_value_t = 50)] limit: u32, + }, +} + +#[derive(Debug, Args)] +pub struct UserArgs { #[command(subcommand)] pub verb: UserVerb } + +#[derive(Debug, Subcommand)] +pub enum UserVerb { + List { + #[arg(short = 'q', long)] query: Option, + #[arg(long, default_value_t = 50)] limit: u32, + }, + View { user: String }, +} + +#[derive(Debug, Args)] +pub struct FileArgs { #[command(subcommand)] pub verb: FileVerb } + +#[derive(Debug, Subcommand)] +pub enum FileVerb { + List { + #[arg(long)] channel: Option, + #[arg(long)] user: Option, + #[arg(long, default_value_t = 50)] limit: u32, + }, + Upload { + path: PathBuf, + #[arg(long)] channel: Option, + #[arg(long)] title: Option, + #[arg(long)] comment: Option, + #[arg(long)] thread: Option, + #[arg(long)] stdin: bool, + #[arg(long)] filename: Option, + }, + Download { + file_id: String, + #[arg(short = 'o', long)] output: Option, + #[arg(long)] stdout: bool, + }, +} + +#[derive(Debug, Args)] +pub struct CompletionArgs { + pub shell: clap_complete::Shell, +} +``` + +- [ ] **Step 2: Add `clap_complete` dep** + +```toml +clap_complete = "4" +``` + +- [ ] **Step 3: Smoke test** + +Run: `cargo run -- --help` +Expected: shows the top-level help with all subcommand groups. + +- [ ] **Step 4: Commit** + +```bash +git add Cargo.toml src/cli.rs +git commit -m "feat: define CLI argument grammar (clap derive)" +``` + +### Task 5.2: `cmd::mod` with `Ctx` and `dispatch` + +**Files:** Modify `src/cmd/mod.rs`. + +- [ ] **Step 1: Create per-subcommand stub files** + +```bash +for f in auth channel message search user file completion; do + cat > "src/cmd/$f.rs" <<'EOF' +//! Subcommand handlers; filled in later tasks. +use crate::cmd::Ctx; +use crate::error::CliError; +EOF +done +``` + +- [ ] **Step 2: Write `src/cmd/mod.rs`** + +```rust +//! Command dispatch: build a Ctx from CLI args + config, then route. + +pub mod auth; +pub mod channel; +pub mod completion; +pub mod file; +pub mod message; +pub mod search; +pub mod user; + +use std::io::IsTerminal; +use std::path::PathBuf; + +use crate::cli::{Cli, GlobalArgs, TopCommand}; +use crate::config::{self, Config, WorkspaceConfig}; +use crate::error::CliError; +use crate::output::Format; +use crate::resolve::Resolver; +use crate::slack::SlackClient; + +#[derive(Debug)] +pub struct Ctx { + pub config: Config, + pub config_path: PathBuf, + pub workspace_name: String, + pub client: Option, + pub resolver: Resolver, + pub format: Format, + pub stdout_is_tty: bool, + pub globals: GlobalArgs, +} + +pub async fn dispatch(cli: Cli) -> Result<(), CliError> { + let path = config::resolve_path(cli.globals.config.as_deref())?; + let cfg = config::load(&path)?; + + // `auth` does not require an existing workspace. + let need_client = !matches!(cli.command, TopCommand::Auth(_) | TopCommand::Completion(_)); + + let (workspace_name, client) = if need_client { + let (name, ws) = config::pick_workspace(&cfg, cli.globals.workspace.as_deref())?; + let secret = crate::secret::load(name, ws)?; + let client = SlackClient::new(secret)?; + (name.to_owned(), Some(client)) + } else { + (cli.globals.workspace.clone().unwrap_or_default(), None) + }; + + let stdout_is_tty = std::io::stdout().is_terminal(); + let fmt = Format::resolve(cli.globals.format(), stdout_is_tty); + + let mut ctx = Ctx { + config: cfg, + config_path: path, + workspace_name, + client, + resolver: Resolver::new(), + format: fmt, + stdout_is_tty, + globals: cli.globals, + }; + + match cli.command { + TopCommand::Auth(args) => auth::run(args, &mut ctx).await, + TopCommand::Channel(args) => channel::run(args, &mut ctx).await, + TopCommand::Message(args) => message::run(args, &mut ctx).await, + TopCommand::Search(args) => search::run(args, &mut ctx).await, + TopCommand::User(args) => user::run(args, &mut ctx).await, + TopCommand::File(args) => file::run(args, &mut ctx).await, + TopCommand::Completion(args) => completion::run(args, &mut ctx).await, + } +} +``` + +- [ ] **Step 3: Add `run` stubs** + +In each `src/cmd/*.rs` (except `mod.rs`), add a stub: + +```rust +pub async fn run(_args: crate::cli::AuthArgs, _ctx: &mut Ctx) -> Result<(), CliError> { + Err(CliError::InvalidArg("not implemented".into())) +} +``` + +Adjust the `Args` type per file (`AuthArgs`, `ChannelArgs`, etc.). + +- [ ] **Step 4: Update `main.rs` to wire dispatch** + +```rust +use std::process::ExitCode; + +use clap::Parser; +use slack_cli::cli::Cli; +use slack_cli::cmd::dispatch; +use slack_cli::logging; +use slack_cli::output::{render_error, Format}; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> ExitCode { + let cli = Cli::parse(); + logging::init(cli.globals.verbose, cli.globals.quiet); + + match dispatch(cli).await { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + let json = matches!(Format::detect_default(), Format::Json); + let _ = render_error(&e, json, &mut std::io::stderr().lock()); + ExitCode::from(e.exit_code()) + } + } +} +``` + +- [ ] **Step 5: Build + clippy + commit** + +```bash +cargo build +cargo clippy --all-targets -- -D warnings +git add src/cmd/ src/main.rs +git commit -m "feat: wire dispatch with Ctx, format resolution, and error rendering" +``` + +### Task 5.3: `logging.rs` + +**Files:** Modify `src/logging.rs`. + +- [ ] **Step 1: Write `src/logging.rs`** + +```rust +//! Tracing initialization. Always writes to stderr. + +use tracing_subscriber::{fmt, EnvFilter}; + +pub fn init(verbose: u8, quiet: bool) { + let directive = if quiet { + "error".to_owned() + } else { + match verbose { + 0 => "slack_cli=info,reqwest=warn,hyper=warn".into(), + 1 => "slack_cli=info,reqwest=warn,hyper=warn".into(), + 2 => "slack_cli=debug,reqwest=warn,hyper=warn".into(), + _ => "slack_cli=trace,reqwest=warn,hyper=warn".into(), + } + }; + let env = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(directive)); + let _ = fmt() + .with_env_filter(env) + .with_writer(std::io::stderr) + .with_target(false) + .with_level(true) + .try_init(); +} +``` + +- [ ] **Step 2: Add `tracing-subscriber`** + +```toml +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +``` + +- [ ] **Step 3: Build + commit** + +```bash +cargo build +git add Cargo.toml src/logging.rs +git commit -m "feat: tracing init with stderr writer and rate-limited http logs" +``` + +--- + +## Phase 6 — `cmd::auth` + +### Task 6.1: `auth whoami` (read-only, no destructive ops) + +**Files:** Modify `src/cmd/auth.rs`. + +- [ ] **Step 1: Implement `whoami`** + +```rust +use serde::Serialize; +use tabled::Tabled; + +use crate::cli::{AuthArgs, AuthVerb}; +use crate::cmd::Ctx; +use crate::error::CliError; +use crate::output::{render_one, Format}; + +#[derive(Debug, Serialize, Tabled)] +pub struct WhoamiRow { + pub user_id: String, + pub user_name: String, + pub team_id: String, + pub team_domain: String, + pub token_kind: String, +} + +pub async fn run(args: AuthArgs, ctx: &mut Ctx) -> Result<(), CliError> { + match args.verb { + AuthVerb::Whoami => whoami(ctx).await, + AuthVerb::List => list(ctx).await, + AuthVerb::Init { .. } | AuthVerb::Logout { .. } => { + Err(CliError::InvalidArg("auth init/logout not yet implemented".into())) + } + } +} + +async fn whoami(ctx: &mut Ctx) -> Result<(), CliError> { + let client = ctx.client.as_ref() + .ok_or_else(|| CliError::NoToken { workspace: ctx.workspace_name.clone() })?; + let info = client.auth_test().await?; + let kind = if info.bot_id.is_some() { "bot" } else { "user" }; + let row = WhoamiRow { + user_id: info.user_id.unwrap_or_default(), + user_name: info.user.unwrap_or_default(), + team_id: info.team_id.unwrap_or_default(), + team_domain: info.team.unwrap_or_default(), + token_kind: kind.into(), + }; + let pretty = ctx.stdout_is_tty && matches!(ctx.format, Format::Json); + render_one(&row, ctx.format, pretty, None, &mut std::io::stdout().lock()) + .map_err(CliError::Io) +} + +async fn list(ctx: &mut Ctx) -> Result<(), CliError> { + #[derive(Serialize, Tabled)] + struct Row<'a> { + name: &'a str, + team_id: &'a str, + token_source: &'a str, + } + let rows: Vec> = ctx.config.workspaces.iter().map(|(n, w)| Row { + name: n, + team_id: w.team_id.as_deref().unwrap_or(""), + token_source: match w.token_source { + crate::config::TokenSource::Keyring => "keyring", + crate::config::TokenSource::File => "file", + crate::config::TokenSource::Env => "env", + }, + }).collect(); + crate::output::render_list(&rows, "", ctx.format, ctx.stdout_is_tty, None, &mut std::io::stdout().lock()) + .map_err(CliError::Io) +} +``` + +- [ ] **Step 2: Smoke test (build only — E2E test in Phase 14)** + +Run: `cargo check` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add src/cmd/auth.rs +git commit -m "feat: implement auth whoami and auth list" +``` + +### Task 6.2: `auth init` (interactive) + +- [ ] **Step 1: Add `rpassword` + implement** + +In `Cargo.toml`: `rpassword = "7"`. + +Implementation summary: + +1. If `workspace` is `None`, prompt for name. +2. Read token: `--token-stdin` or `rpassword::prompt_password`. +3. Build a temporary `SlackClient` and call `auth.test`. +4. Build `WorkspaceConfig` with `token_source = match backend`. +5. `secret::save(name, &mut ws, &token)` (no-op for env). +6. `config::save(&path, &cfg)`. +7. Print success row. + +Code: + +```rust +async fn init( + workspace: Option, + token_stdin: bool, + backend: crate::cli::AuthBackend, + ctx: &mut Ctx, +) -> Result<(), CliError> { + use std::io::BufRead; + + let name = workspace.or_else(|| prompt("workspace name: ").ok()) + .ok_or_else(|| CliError::InvalidArg("workspace name required".into()))?; + let raw = if token_stdin { + let mut line = String::new(); + std::io::stdin().lock().read_line(&mut line).map_err(CliError::Io)?; + line.trim().to_owned() + } else { + rpassword::prompt_password("token (input hidden): ") + .map_err(|e| CliError::InvalidArg(e.to_string()))? + }; + crate::secret::validate_prefix(&raw)?; + let secret = secrecy::SecretString::from(raw.clone()); + + let client = crate::slack::SlackClient::new(secret.clone())?; + let info = client.auth_test().await?; + let kind = if info.bot_id.is_some() { "bot" } else { "user" }; + + let token_source = match backend { + crate::cli::AuthBackend::Keyring => crate::config::TokenSource::Keyring, + crate::cli::AuthBackend::File => crate::config::TokenSource::File, + crate::cli::AuthBackend::Env => crate::config::TokenSource::Env, + }; + + let mut ws = crate::config::WorkspaceConfig { + team_id: info.team_id.clone(), + team_domain: info.team.clone(), + token_source: token_source.clone(), + token: None, + token_env: None, + meta: crate::config::WorkspaceMeta { + token_kind: Some(kind.into()), + user_id: info.user_id.clone(), + updated_at: Some(chrono_like_now()), + }, + }; + crate::secret::save(&name, &mut ws, &secret)?; + + ctx.config.workspaces.insert(name.clone(), ws); + if ctx.config.default_workspace.is_none() { + ctx.config.default_workspace = Some(name.clone()); + } + crate::config::save(&ctx.config_path, &ctx.config)?; + + eprintln!("✓ saved workspace '{name}'"); + Ok(()) +} + +fn prompt(msg: &str) -> std::io::Result { + use std::io::{BufRead, Write}; + let mut out = std::io::stderr().lock(); + out.write_all(msg.as_bytes())?; + out.flush()?; + let mut buf = String::new(); + std::io::stdin().lock().read_line(&mut buf)?; + Ok(buf.trim().to_owned()) +} + +fn chrono_like_now() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + // Cheap RFC3339-ish; we don't pull chrono just for this. + format!("@{secs}") +} +``` + +Wire `AuthVerb::Init { workspace, token_stdin, backend }` to call `init(...)`. + +- [ ] **Step 2: Commit** + +```bash +cargo check +git add Cargo.toml src/cmd/auth.rs +git commit -m "feat: implement auth init (interactive)" +``` + +### Task 6.3: `auth logout` + +- [ ] **Step 1: Implement** + +```rust +async fn logout(workspace: Option, ctx: &mut Ctx) -> Result<(), CliError> { + let name = workspace + .or_else(|| Some(ctx.workspace_name.clone())) + .filter(|s| !s.is_empty()) + .ok_or_else(|| CliError::AmbiguousWorkspace { + available: ctx.config.workspaces.keys().cloned().collect(), + })?; + if !ctx.globals.yes && std::io::stdin().is_terminal() { + // TTY: prompt + let line = prompt(&format!("delete token for '{name}'? type 'yes': "))?; + if line != "yes" { + return Err(CliError::ConfirmationRequired); + } + } else if !ctx.globals.yes { + return Err(CliError::ConfirmationRequired); + } + if let Some(ws) = ctx.config.workspaces.get_mut(&name) { + crate::secret::delete(&name, ws)?; + ws.token = None; + } + crate::config::save(&ctx.config_path, &ctx.config)?; + eprintln!("✓ logged out '{name}'"); + Ok(()) +} +``` + +- [ ] **Step 2: Wire + commit** + +```bash +git add src/cmd/auth.rs +git commit -m "feat: implement auth logout with confirmation gate" +``` + +--- + +## Phase 7 — `cmd::channel` + +### Task 7.1: `channel list` + `channel view` + +**Files:** Modify `src/cmd/channel.rs`. + +- [ ] **Step 1: Implement `list`** + +```rust +use serde::Serialize; +use tabled::Tabled; + +use crate::cli::{ChannelArgs, ChannelVerb}; +use crate::cmd::Ctx; +use crate::error::CliError; +use crate::output::{render_list, render_one, Format}; + +#[derive(Debug, Serialize, Tabled)] +pub struct ChannelRow { + pub id: String, + pub name: String, + pub is_private: bool, + pub members: u32, +} + +pub async fn run(args: ChannelArgs, ctx: &mut Ctx) -> Result<(), CliError> { + let client = ctx.client.as_ref().ok_or_else(|| CliError::NoToken { + workspace: ctx.workspace_name.clone(), + })?; + match args.verb { + ChannelVerb::List { query, types } => { + let limit = ctx.globals.max_results.min(200); + let max_pages = if ctx.globals.all { ctx.globals.max_pages } else { 1 }; + let mut cursor: Option = None; + let mut rows: Vec = Vec::new(); + let mut next_cursor = String::new(); + for _ in 0..max_pages { + let page = client.conversations_list(&types, limit, cursor.as_deref()).await?; + for ch in page.channels { + if let Some(q) = query.as_deref() { + if !ch.name.contains(q) { continue; } + } + rows.push(ChannelRow { + id: ch.id, name: ch.name, + is_private: ch.is_private, + members: ch.num_members.unwrap_or(0), + }); + } + next_cursor = page.response_metadata.and_then(|m| m.next_cursor).unwrap_or_default(); + if next_cursor.is_empty() { break; } + cursor = Some(next_cursor.clone()); + } + let pretty = ctx.stdout_is_tty && matches!(ctx.format, Format::Json); + render_list(&rows, &next_cursor, ctx.format, pretty, None, &mut std::io::stdout().lock()) + .map_err(CliError::Io) + } + ChannelVerb::View { channel } => { + let cref = crate::id::ChannelRef::try_from(channel.as_str())?; + let id = ctx.resolver.channel(client, &cref).await?; + let info = client.conversations_info(id.as_str()).await?; + let row = ChannelRow { + id: info.channel.id, + name: info.channel.name, + is_private: info.channel.is_private, + members: info.channel.num_members.unwrap_or(0), + }; + let pretty = ctx.stdout_is_tty && matches!(ctx.format, Format::Json); + render_one(&row, ctx.format, pretty, None, &mut std::io::stdout().lock()) + .map_err(CliError::Io) + } + _ => Err(CliError::InvalidArg("not yet implemented".into())), + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +cargo check +git add src/cmd/channel.rs +git commit -m "feat: channel list + channel view" +``` + +### Task 7.2: `channel create / archive / invite / leave` with `--dry-run` + `--yes` + +- [ ] **Step 1: Implement each verb** + +Add helpers to `cmd/mod.rs`: + +```rust +pub fn require_confirmation(ctx: &Ctx, what: &str) -> Result<(), CliError> { + if ctx.globals.yes { return Ok(()); } + if !ctx.stdout_is_tty { + return Err(CliError::ConfirmationRequired); + } + use std::io::{BufRead, Write}; + let mut err = std::io::stderr().lock(); + write!(err, "{what} type 'yes' to continue: ").map_err(CliError::Io)?; + err.flush().map_err(CliError::Io)?; + let mut line = String::new(); + std::io::stdin().lock().read_line(&mut line).map_err(CliError::Io)?; + if line.trim() == "yes" { Ok(()) } else { Err(CliError::ConfirmationRequired) } +} +``` + +In `cmd/channel.rs`: + +```rust + ChannelVerb::Create { name, private, description } => { + if ctx.globals.dry_run { + emit_dry_run(ctx, "conversations.create", "POST /api/conversations.create", + serde_json::json!({"name": name, "is_private": private, "description": description}))?; + return Ok(()); + } + let info = client.conversations_create(&name, private, description.as_deref()).await?; + print_one_json_or_table(ctx, &info)?; + Ok(()) + } + ChannelVerb::Archive { channel } => { + super::require_confirmation(ctx, &format!("archive channel '{channel}'?"))?; + if ctx.globals.dry_run { + return emit_dry_run(ctx, "conversations.archive", "POST /api/conversations.archive", + serde_json::json!({"channel": channel})); + } + let cref = crate::id::ChannelRef::try_from(channel.as_str())?; + let id = ctx.resolver.channel(client, &cref).await?; + client.conversations_archive(id.as_str()).await?; + eprintln!("✓ archived"); + Ok(()) + } + ChannelVerb::Invite { channel, users } => { + if ctx.globals.dry_run { + return emit_dry_run(ctx, "conversations.invite", "POST /api/conversations.invite", + serde_json::json!({"channel": channel, "users": users})); + } + let cref = crate::id::ChannelRef::try_from(channel.as_str())?; + let cid = ctx.resolver.channel(client, &cref).await?; + let mut uids: Vec = Vec::new(); + for u in &users { + let uref = crate::id::UserRef::try_from(u.as_str())?; + uids.push(ctx.resolver.user(client, &uref).await?.to_string()); + } + client.conversations_invite(cid.as_str(), &uids).await?; + eprintln!("✓ invited"); + Ok(()) + } + ChannelVerb::Leave { channel } => { + super::require_confirmation(ctx, &format!("leave channel '{channel}'?"))?; + if ctx.globals.dry_run { + return emit_dry_run(ctx, "conversations.leave", "POST /api/conversations.leave", + serde_json::json!({"channel": channel})); + } + let cref = crate::id::ChannelRef::try_from(channel.as_str())?; + let id = ctx.resolver.channel(client, &cref).await?; + client.conversations_leave(id.as_str()).await?; + eprintln!("✓ left"); + Ok(()) + } +``` + +Helpers `emit_dry_run` and `print_one_json_or_table` go in `cmd/mod.rs`: + +```rust +pub fn emit_dry_run(ctx: &Ctx, would_call: &str, endpoint: &str, payload: serde_json::Value) -> Result<(), CliError> { + use serde::Serialize; + use std::io::Write; + + #[derive(Serialize)] + struct DryRunData<'a> { + dry_run: bool, + would_call: &'a str, + endpoint: &'a str, + payload: serde_json::Value, + } + + let data = DryRunData { dry_run: true, would_call, endpoint, payload }; + let env = crate::output::OkEnvelope { ok: true, schema: crate::output::SCHEMA, data, request_id: None }; + let mut out = std::io::stdout().lock(); + serde_json::to_writer(&mut out, &env).map_err(|e| CliError::Io(std::io::Error::other(e)))?; + writeln!(out).map_err(CliError::Io)?; + Ok(()) +} +``` + +Add `pub` visibility to `OkEnvelope` fields (already public via `pub`). + +- [ ] **Step 2: Commit** + +```bash +cargo check +git add src/cmd/mod.rs src/cmd/channel.rs +git commit -m "feat: channel create/archive/invite/leave with --dry-run and --yes" +``` + +--- + +## Phase 8 — `cmd::message` + +### Task 8.1: `message list`, `message view`, `message view --thread` + +**Files:** Modify `src/cmd/message.rs`. + +- [ ] **Step 1: Implement** + +```rust +use serde::Serialize; +use tabled::Tabled; + +use crate::cli::{MessageArgs, MessageVerb}; +use crate::cmd::Ctx; +use crate::error::CliError; +use crate::id::ChannelRef; +use crate::output::{render_list, render_one, Format}; + +#[derive(Debug, Serialize, Tabled)] +pub struct MessageRow { + pub ts: String, + pub user: String, + pub text: String, +} + +pub async fn run(args: MessageArgs, ctx: &mut Ctx) -> Result<(), CliError> { + let client = ctx.client.as_ref().ok_or_else(|| CliError::NoToken { + workspace: ctx.workspace_name.clone(), + })?; + match args.verb { + MessageVerb::List { channel, limit, oldest, latest } => { + let cref = ChannelRef::try_from(channel.as_str())?; + let id = ctx.resolver.channel(client, &cref).await?; + let page = client.conversations_history( + id.as_str(), + limit.min(200), + None, + oldest.as_deref(), + latest.as_deref(), + ).await?; + let rows: Vec = page.messages.into_iter() + .map(|m| MessageRow { ts: m.ts, user: m.user.unwrap_or_default(), text: m.text }) + .collect(); + let cursor = page.response_metadata.and_then(|m| m.next_cursor).unwrap_or_default(); + let pretty = ctx.stdout_is_tty && matches!(ctx.format, Format::Json); + render_list(&rows, &cursor, ctx.format, pretty, None, &mut std::io::stdout().lock()) + .map_err(CliError::Io) + } + MessageVerb::View { channel, ts, thread } => { + let cref = ChannelRef::try_from(channel.as_str())?; + let id = ctx.resolver.channel(client, &cref).await?; + if thread { + let page = client.conversations_replies(id.as_str(), &ts, None, 100).await?; + let rows: Vec = page.messages.into_iter() + .map(|m| MessageRow { ts: m.ts, user: m.user.unwrap_or_default(), text: m.text }) + .collect(); + let pretty = ctx.stdout_is_tty && matches!(ctx.format, Format::Json); + render_list(&rows, "", ctx.format, pretty, None, &mut std::io::stdout().lock()) + .map_err(CliError::Io) + } else { + let page = client.conversations_history(id.as_str(), 1, None, Some(&ts), Some(&ts)).await?; + let m = page.messages.into_iter().next().ok_or_else(|| CliError::NotFound { + kind: "message", name: ts.clone(), + })?; + let row = MessageRow { ts: m.ts, user: m.user.unwrap_or_default(), text: m.text }; + let pretty = ctx.stdout_is_tty && matches!(ctx.format, Format::Json); + render_one(&row, ctx.format, pretty, None, &mut std::io::stdout().lock()) + .map_err(CliError::Io) + } + } + // send/reply/update/delete/react are filled in 8.2 and 8.3 + _ => Err(CliError::InvalidArg("not yet implemented".into())), + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +cargo check +git add src/cmd/message.rs +git commit -m "feat: message list and view (with --thread)" +``` + +### Task 8.2: `message send` / `message reply` with stdin + dry-run + idempotency + +- [ ] **Step 1: Implement** + +```rust + MessageVerb::Send { channel, text, file, thread, broadcast } => { + let body = read_body(text.as_deref(), file.as_deref())?; + let cref = ChannelRef::try_from(channel.as_str())?; + let cid = ctx.resolver.channel(client, &cref).await?; + let mut req = crate::slack::PostMessage::new(cid.as_str().into(), body); + req.thread_ts = thread.clone(); + req.reply_broadcast = if broadcast { Some(true) } else { None }; + if ctx.globals.dry_run { + return super::emit_dry_run(ctx, "chat.postMessage", "POST /api/chat.postMessage", + serde_json::to_value(&req).unwrap()); + } + let resp = client.chat_post_message(&req).await?; + let row = MessageRow { ts: resp.ts, user: resp.message.user.unwrap_or_default(), text: resp.message.text }; + let pretty = ctx.stdout_is_tty && matches!(ctx.format, Format::Json); + render_one(&row, ctx.format, pretty, None, &mut std::io::stdout().lock()) + .map_err(CliError::Io) + } + MessageVerb::Reply { channel, ts, text, broadcast } => { + let cref = ChannelRef::try_from(channel.as_str())?; + let cid = ctx.resolver.channel(client, &cref).await?; + let mut req = crate::slack::PostMessage::new(cid.as_str().into(), text); + req.thread_ts = Some(ts); + req.reply_broadcast = if broadcast { Some(true) } else { None }; + if ctx.globals.dry_run { + return super::emit_dry_run(ctx, "chat.postMessage", "POST /api/chat.postMessage", + serde_json::to_value(&req).unwrap()); + } + let resp = client.chat_post_message(&req).await?; + let row = MessageRow { ts: resp.ts, user: resp.message.user.unwrap_or_default(), text: resp.message.text }; + let pretty = ctx.stdout_is_tty && matches!(ctx.format, Format::Json); + render_one(&row, ctx.format, pretty, None, &mut std::io::stdout().lock()) + .map_err(CliError::Io) + } +``` + +Helper: + +```rust +fn read_body(text: Option<&str>, file: Option<&std::path::Path>) -> Result { + use std::io::Read; + match (text, file) { + (Some("-"), _) | (None, Some(p)) if file.map_or(true, |f| f.as_os_str() == "-") => { + let mut s = String::new(); + std::io::stdin().lock().read_to_string(&mut s).map_err(CliError::Io)?; + Ok(s.trim_end_matches('\n').to_owned()) + } + (Some(s), None) => Ok(s.to_owned()), + (None, Some(p)) => std::fs::read_to_string(p).map_err(CliError::Io), + (None, None) => Err(CliError::InvalidArg("--text or --file required".into())), + (Some(_), Some(_)) => Err(CliError::InvalidArg("--text and --file are mutually exclusive".into())), + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +cargo check +git add src/cmd/message.rs +git commit -m "feat: message send/reply with stdin, --dry-run, idempotency key" +``` + +### Task 8.3: `message update / delete / react` + +- [ ] **Step 1: Implement** (each follows the same dry-run / confirmation pattern as 7.2). `react` honors `--remove` by calling `reactions.remove` (add to slack.rs first). + +```rust + MessageVerb::Update { channel, ts, text } => { + let cref = ChannelRef::try_from(channel.as_str())?; + let cid = ctx.resolver.channel(client, &cref).await?; + if ctx.globals.dry_run { + return super::emit_dry_run(ctx, "chat.update", "POST /api/chat.update", + serde_json::json!({"channel": cid.as_str(), "ts": ts, "text": text})); + } + client.chat_update(cid.as_str(), &ts, &text).await?; + eprintln!("✓ updated"); + Ok(()) + } + MessageVerb::Delete { channel, ts } => { + super::require_confirmation(ctx, &format!("delete message {ts} in '{channel}'?"))?; + let cref = ChannelRef::try_from(channel.as_str())?; + let cid = ctx.resolver.channel(client, &cref).await?; + if ctx.globals.dry_run { + return super::emit_dry_run(ctx, "chat.delete", "POST /api/chat.delete", + serde_json::json!({"channel": cid.as_str(), "ts": ts})); + } + client.chat_delete(cid.as_str(), &ts).await?; + eprintln!("✓ deleted"); + Ok(()) + } + MessageVerb::React { channel, ts, emoji, remove } => { + let cref = ChannelRef::try_from(channel.as_str())?; + let cid = ctx.resolver.channel(client, &cref).await?; + let name = emoji.trim_matches(':'); + if ctx.globals.dry_run { + let endpoint = if remove { "POST /api/reactions.remove" } else { "POST /api/reactions.add" }; + return super::emit_dry_run(ctx, if remove { "reactions.remove" } else { "reactions.add" }, + endpoint, serde_json::json!({"channel": cid.as_str(), "timestamp": ts, "name": name})); + } + if remove { + client.reactions_remove(cid.as_str(), &ts, name).await?; + } else { + client.reactions_add(cid.as_str(), &ts, name).await?; + } + eprintln!("✓ reaction {}", if remove { "removed" } else { "added" }); + Ok(()) + } +``` + +- [ ] **Step 2: Commit** + +```bash +cargo check +git add src/cmd/message.rs src/slack.rs +git commit -m "feat: message update/delete/react with confirmation and dry-run" +``` + +--- + +## Phase 9 — `cmd::search`, `cmd::user`, `cmd::file`, `cmd::completion` + +Each is similar in structure. One task per command file. + +### Task 9.1: `cmd::search` + +**Files:** Modify `src/cmd/search.rs`. + +Implementation: + +```rust +use serde::Serialize; +use tabled::Tabled; + +use crate::cli::{SearchArgs, SearchVerb}; +use crate::cmd::Ctx; +use crate::error::CliError; +use crate::output::{render_list, Format}; + +#[derive(Debug, Serialize, Tabled)] +pub struct SearchHit { + pub ts: String, + pub user: String, + pub text: String, +} + +pub async fn run(args: SearchArgs, ctx: &mut Ctx) -> Result<(), CliError> { + let client = ctx.client.as_ref().ok_or_else(|| CliError::NoToken { + workspace: ctx.workspace_name.clone(), + })?; + match args.verb { + SearchVerb::Messages { query, r#in, from, limit } => { + let q = compose_query(&query, r#in.as_deref(), from.as_deref()); + let resp = client.search_messages(&q, 1, limit.min(200)).await?; + let rows: Vec = resp.messages.matches.into_iter() + .map(|m| SearchHit { ts: m.ts, user: m.user.unwrap_or_default(), text: m.text }) + .collect(); + let pretty = ctx.stdout_is_tty && matches!(ctx.format, Format::Json); + render_list(&rows, "", ctx.format, pretty, None, &mut std::io::stdout().lock()) + .map_err(CliError::Io) + } + SearchVerb::Files { query, file_type, limit } => { + let mut q = query; + if let Some(t) = file_type { q.push_str(&format!(" type:{t}")); } + let resp = client.search_files(&q, 1, limit.min(200)).await?; + // Render flatly; FileDto already has serde + tabled. + let pretty = ctx.stdout_is_tty && matches!(ctx.format, Format::Json); + crate::output::render_list(&resp.files.matches, "", ctx.format, pretty, None, &mut std::io::stdout().lock()) + .map_err(CliError::Io) + } + } +} + +fn compose_query(base: &str, in_chan: Option<&str>, from_user: Option<&str>) -> String { + let mut q = base.to_owned(); + if let Some(ch) = in_chan { q.push_str(&format!(" in:{ch}")); } + if let Some(u) = from_user { q.push_str(&format!(" from:{u}")); } + q +} +``` + +(`search_files` returns `{ ok, files: { matches: Vec, paging } }`; add the DTO in `slack.rs` if missing. Make `FileDto` derive `Tabled`.) + +Commit: `feat: search messages and search files`. + +### Task 9.2: `cmd::user` + +**Files:** Modify `src/cmd/user.rs`. + +```rust +use serde::Serialize; +use tabled::Tabled; + +use crate::cli::{UserArgs, UserVerb}; +use crate::cmd::Ctx; +use crate::error::CliError; +use crate::id::UserRef; +use crate::output::{render_list, render_one, Format}; + +#[derive(Debug, Serialize, Tabled)] +pub struct UserRow { + pub id: String, + pub name: String, + pub real_name: String, +} + +pub async fn run(args: UserArgs, ctx: &mut Ctx) -> Result<(), CliError> { + let client = ctx.client.as_ref().ok_or_else(|| CliError::NoToken { + workspace: ctx.workspace_name.clone(), + })?; + match args.verb { + UserVerb::List { query, limit } => { + let page = client.users_list(limit.min(200), None).await?; + let rows: Vec = page.members.into_iter() + .filter(|u| !u.deleted) + .filter(|u| query.as_deref().map_or(true, |q| u.name.contains(q))) + .map(|u| UserRow { + id: u.id, name: u.name, + real_name: u.real_name.unwrap_or_default(), + }) + .collect(); + let cursor = page.response_metadata.and_then(|m| m.next_cursor).unwrap_or_default(); + let pretty = ctx.stdout_is_tty && matches!(ctx.format, Format::Json); + render_list(&rows, &cursor, ctx.format, pretty, None, &mut std::io::stdout().lock()) + .map_err(CliError::Io) + } + UserVerb::View { user } => { + let uref = UserRef::try_from(user.as_str())?; + let id = ctx.resolver.user(client, &uref).await?; + let info = client.users_info(id.as_str()).await?; + let row = UserRow { + id: info.user.id, name: info.user.name, + real_name: info.user.real_name.unwrap_or_default(), + }; + let pretty = ctx.stdout_is_tty && matches!(ctx.format, Format::Json); + render_one(&row, ctx.format, pretty, None, &mut std::io::stdout().lock()) + .map_err(CliError::Io) + } + } +} +``` + +Commit: `feat: user list and user view`. + +### Task 9.3: `cmd::file` + +**Files:** Modify `src/cmd/file.rs`, add `indicatif`. + +`Cargo.toml`: `indicatif = "0.17"`. + +```rust +use std::io::Read; +use std::path::PathBuf; + +use crate::cli::{FileArgs, FileVerb}; +use crate::cmd::Ctx; +use crate::error::CliError; +use crate::output::{render_list, render_one, Format}; + +pub async fn run(args: FileArgs, ctx: &mut Ctx) -> Result<(), CliError> { + let client = ctx.client.as_ref().ok_or_else(|| CliError::NoToken { + workspace: ctx.workspace_name.clone(), + })?; + match args.verb { + FileVerb::List { channel, user: _user, limit } => { + let resp = client.files_list(channel.as_deref(), limit.min(200), None).await?; + let pretty = ctx.stdout_is_tty && matches!(ctx.format, Format::Json); + render_list(&resp.files, "", ctx.format, pretty, None, &mut std::io::stdout().lock()) + .map_err(CliError::Io) + } + FileVerb::Upload { path, channel, title, comment, thread, stdin, filename } => { + let (bytes, name) = if stdin { + let mut buf = Vec::new(); + std::io::stdin().lock().read_to_end(&mut buf).map_err(CliError::Io)?; + let name = filename.ok_or_else(|| CliError::InvalidArg("--filename required with --stdin".into()))?; + (buf, name) + } else { + let bytes = std::fs::read(&path).map_err(CliError::Io)?; + let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("upload").to_owned(); + (bytes, name) + }; + if ctx.globals.dry_run { + return super::emit_dry_run(ctx, "files.upload", "POST /api/files.upload", + serde_json::json!({ + "channel": channel, "title": title, "initial_comment": comment, + "thread_ts": thread, "filename": name, "size": bytes.len(), + })); + } + let resolved_channel = if let Some(c) = channel.as_deref() { + let cref = crate::id::ChannelRef::try_from(c)?; + Some(ctx.resolver.channel(client, &cref).await?.to_string()) + } else { None }; + let file = client.files_upload( + resolved_channel.as_deref(), &name, bytes, + title.as_deref(), comment.as_deref(), thread.as_deref(), + ).await?; + let pretty = ctx.stdout_is_tty && matches!(ctx.format, Format::Json); + render_one(&file, ctx.format, pretty, None, &mut std::io::stdout().lock()) + .map_err(CliError::Io) + } + FileVerb::Download { file_id, output, stdout } => { + let info = client.files_info(&file_id).await?; + let url = info.file.url_private.ok_or_else(|| CliError::Config("file has no url_private".into()))?; + let resp = client.download_authorized(&url).await?; + if stdout { + use std::io::Write; + std::io::stdout().lock().write_all(&resp).map_err(CliError::Io)?; + } else { + let out = output.unwrap_or_else(|| PathBuf::from(&info.file.name)); + std::fs::write(&out, &resp).map_err(CliError::Io)?; + eprintln!("✓ saved {}", out.display()); + } + Ok(()) + } + } +} +``` + +(`download_authorized` needs to be added to `slack.rs` — a `get` with `Authorization` header that returns `Vec`.) + +Commit: `feat: file list/upload/download`. + +### Task 9.4: `cmd::completion` + +**Files:** Modify `src/cmd/completion.rs`. + +```rust +use clap::CommandFactory; + +use crate::cli::{Cli, CompletionArgs}; +use crate::cmd::Ctx; +use crate::error::CliError; + +pub async fn run(args: CompletionArgs, _ctx: &mut Ctx) -> Result<(), CliError> { + let mut cmd = Cli::command(); + let mut stdout = std::io::stdout().lock(); + clap_complete::generate(args.shell, &mut cmd, "slack-cli", &mut stdout); + Ok(()) +} +``` + +Commit: `feat: shell completion generator`. + +--- + +## Phase 10 — Integration tests + +### Task 10.1: `tests/common/mod.rs` helpers + +**Files:** Create `tests/common/mod.rs`. + +- [ ] **Step 1: Write helpers** + +```rust +//! Shared helpers for integration tests. + +#![allow(dead_code)] + +use std::path::PathBuf; +use std::process::Command; + +use assert_cmd::cargo::cargo_bin; +use tempfile::TempDir; +use wiremock::MockServer; + +pub struct Sandbox { + pub dir: TempDir, + pub config_path: PathBuf, + pub server: MockServer, +} + +pub async fn sandbox(workspace: &str) -> Sandbox { + let dir = tempfile::tempdir().expect("tempdir"); + let config_path = dir.path().join("config.toml"); + std::fs::write(&config_path, format!( + r#" +default_workspace = "{ws}" + +[workspaces.{ws}] +team_id = "T1" +team_domain = "acme" +token_source = "env" +token_env = "FAKE_TOKEN_{ws}" +"#, + ws = workspace + )).unwrap(); + + let server = MockServer::start().await; + Sandbox { dir, config_path, server } +} + +pub fn cmd(sb: &Sandbox, ws: &str, token: &str) -> Command { + let mut c = Command::new(cargo_bin("slack-cli")); + c.env("SLACK_CLI_CONFIG", &sb.config_path); + c.env("SLACK_CLI_WORKSPACE", ws); + c.env(format!("FAKE_TOKEN_{ws}"), token); + c.env("SLACK_CLI_TOKEN", ""); // do not bleed real tokens + c.env("NO_COLOR", "1"); + c.env("RUST_LOG", "error"); + // Force every Slack call through wiremock by injecting a base-URL env we + // honor in slack.rs. (We add this env hook in Task 10.2.) + c.env("SLACK_CLI_BASE_URL_OVERRIDE", sb.server.uri()); + c +} +``` + +- [ ] **Step 2: Add deps** + +```toml +[dev-dependencies] +assert_cmd = "2" +predicates = "3" +insta = { version = "1", features = ["json", "yaml", "filters"] } +pretty_assertions = "1" +``` + +- [ ] **Step 3: Commit** + +```bash +git add tests/common/mod.rs Cargo.toml +git commit -m "test: add shared integration-test helpers (sandbox + cmd)" +``` + +### Task 10.2: Honor `SLACK_CLI_BASE_URL_OVERRIDE` in production code + +**Files:** Modify `src/slack.rs` and `src/cmd/mod.rs`. + +- [ ] **Step 1: Read env in `SlackClient::new`** + +In `slack.rs`: + +```rust + pub fn new(token: SecretString) -> Result { + let base = std::env::var("SLACK_CLI_BASE_URL_OVERRIDE").unwrap_or_else(|_| DEFAULT_BASE_URL.into()); + Self::build(base, token) + } +``` + +- [ ] **Step 2: Add unit test for override** + +```rust + #[test] + fn override_via_env() { + std::env::set_var("SLACK_CLI_BASE_URL_OVERRIDE", "http://localhost:9"); + let c = SlackClient::new(SecretString::from("xoxp-test")).unwrap(); + assert!(c.base_url.starts_with("http://localhost:9")); + std::env::remove_var("SLACK_CLI_BASE_URL_OVERRIDE"); + } +``` + +Make `base_url` `pub(crate)` so the test can read it. + +- [ ] **Step 3: Commit** + +```bash +cargo test --lib slack +git add src/slack.rs +git commit -m "test: allow base URL override via env for integration tests" +``` + +### Task 10.3–10.8: Per-command E2E tests + +For each subcommand group, create `tests/.rs` with at least: + +1. One success path (mock 200, verify stdout JSON snapshot via `insta::assert_snapshot!` with redactions for `ts`, `req_id`, `client_msg_id`). +2. One failure path (mock `ok:false`, verify exit code and stderr JSON). +3. Where applicable: one `--dry-run` test asserting no HTTP call (mount `Mock::given(...).expect(0)`). + +Example `tests/message.rs`: + +```rust +mod common; + +use assert_cmd::prelude::*; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +#[tokio::test(flavor = "current_thread")] +async fn send_dry_run_makes_no_call() { + let sb = common::sandbox("acme").await; + // Refuse any POST to chat.postMessage. + Mock::given(method("POST")).and(path("/api/chat.postMessage")) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount(&sb.server) + .await; + // Channel resolution needs conversations.list ok response. + Mock::given(method("GET")).and(path("/api/conversations.list")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"ok":true,"channels":[{"id":"C1","name":"general","is_private":false,"is_archived":false,"num_members":1,"topic":{"value":""},"purpose":{"value":""}}],"response_metadata":{"next_cursor":""}}"#)) + .mount(&sb.server).await; + + let assert = common::cmd(&sb, "acme", "xoxp-test") + .args(["message", "send", "#general", "--text", "hi", "--dry-run", "--json"]) + .assert() + .success(); + let out = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + insta::with_settings!({filters => vec![ + (r"\d{10}\.\d{6}", "[TS]"), + (r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b", "[UUID]"), + ]}, { insta::assert_snapshot!("message_send_dry_run", out); }); +} +``` + +Repeat per command. Each test gets its own commit: + +- `test: e2e auth whoami` +- `test: e2e channel list` +- `test: e2e message send dry-run` +- `test: e2e message send happy path` +- `test: e2e search messages` +- `test: e2e user view` +- `test: e2e file upload dry-run` + +### Task 10.9: Confirmation-gate test for destructive commands + +`tests/channel.rs`: + +```rust +#[tokio::test(flavor = "current_thread")] +async fn archive_without_yes_in_non_tty_errors_64() { + let sb = common::sandbox("acme").await; + let assert = common::cmd(&sb, "acme", "xoxp-test") + .args(["channel", "archive", "C1"]) + .assert(); + assert.code(64); // ConfirmationRequired -> 64 +} +``` + +Commit: `test: e2e confirmation-required exit code`. + +--- + +## Phase 11 — CI / Release workflows + +### Task 11.1: `.github/workflows/ci.yml` + +**Files:** Create `.github/workflows/ci.yml`. + +```yaml +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: short + +jobs: + test: + name: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: jdx/mise-action@5083fe46898c414b2475087cc79da59e7da859e8 # v2.1.11 + with: { install: true, cache: true } + - uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.7.4 + - run: cargo fmt --all -- --check + if: matrix.os == 'ubuntu-latest' + - run: cargo clippy --all-targets -- -D warnings + - run: cargo nextest run + - name: docs + if: matrix.os == 'ubuntu-latest' + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc --no-deps --document-private-items + + deny: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: jdx/mise-action@5083fe46898c414b2475087cc79da59e7da859e8 # v2.1.11 + with: { install: true, cache: true } + - run: cargo deny check advisories bans licenses sources + + msrv: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: dtolnay/rust-toolchain@stable + with: { toolchain: "1.89" } + - run: cargo check --all-features + + linux-headless: + runs-on: ubuntu-latest + container: + image: rust:1.89-slim + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Install build deps + run: apt-get update && apt-get install -y --no-install-recommends pkg-config build-essential + - run: cargo test --tests +``` + +Commit: `ci: matrix builds with fmt/clippy/nextest, deny, msrv, headless`. + +### Task 11.2: `.github/workflows/release.yml` + +```yaml +name: release + +on: + push: + tags: ['v*'] + +permissions: + contents: write + +jobs: + upload: + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + - target: aarch64-unknown-linux-musl + os: ubuntu-latest + - target: x86_64-apple-darwin + os: macos-latest + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-pc-windows-msvc + os: windows-latest + - target: aarch64-pc-windows-msvc + os: windows-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: taiki-e/upload-rust-binary-action@d94c2c5e7a35e7d1edd0e6e3b3a4d2d9b94f4f1f # v1.22.1 + with: + bin: slack-cli + target: ${{ matrix.target }} + archive: slack-cli-${{ github.ref_name }}-${{ matrix.target }} + checksum: sha256 + token: ${{ secrets.GITHUB_TOKEN }} + + changelog: + runs-on: ubuntu-latest + needs: upload + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: { fetch-depth: 0 } + - uses: jdx/mise-action@5083fe46898c414b2475087cc79da59e7da859e8 # v2.1.11 + with: { install: true, cache: true } + - run: git cliff --tag ${{ github.ref_name }} --output CHANGELOG.md + - name: Upload CHANGELOG to release + uses: softprops/action-gh-release@c062e08bd532815e2082a85e87e3ef29c3e6d191 # v2.0.8 + with: + files: CHANGELOG.md +``` + +Commit: `ci: release workflow for 6 targets with SHA256SUMS and CHANGELOG`. + +### Task 11.3: Smoke test CI locally + +- [ ] **Step 1: Run `mise run test`** + +```bash +mise install +mise run test +``` + +Expected: PASS. + +- [ ] **Step 2: Commit any lockfile changes** + +```bash +git add Cargo.lock +git commit -m "chore: update Cargo.lock after CI smoke test" || echo "no changes" +``` + +--- + +## Self-Review checklist + +- [ ] **Spec coverage scan** — every section of the spec mapped to ≥1 task: + - §3 architecture → Phase 0–5 + - §3.4 error → Task 1.1, 5.2 + - §4 commands → Phases 6–9 + - §5 JSON schema → Task 1.3, 5.2, 7.2, 8.2 + - §6 module structure → Phase 0 + every phase below + - §7 main types → Tasks 1.1, 1.2, 1.3, 2.x, 3.x, 4.1 + - §8 config → Phase 2 + - §9 tests → Phase 10 + - §10 CI/release → Phase 11 + - §11 deps → Task 0.1 + each phase + - §12 agent UX → Tasks 5.2, 7.2, 8.2 (dry-run JSON), 1.4 (hints) + - §13 security → 2.x (TokenStore), 3.x (sensitive header) + - §14 logging → Task 5.3 + +- [ ] **Placeholder scan** — no "TBD" / "implement later" / "similar to Task N" patterns in the steps above. + +- [ ] **Type consistency** — `ChannelId.as_str()`, `UserId.as_str()`, `SlackClient::with_base_url` referenced consistently. `PostMessage::new` field names match struct definition. + +- [ ] **JSON schema constant** — `slack-cli/v0` referenced in `SCHEMA` constant once and reused. + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-05-14-slack-cli-mvp.md`. Two execution options: + +**1. Subagent-Driven (recommended)** — Fresh subagent per task, two-stage review between tasks, fast iteration. + +**2. Inline Execution** — Execute tasks in this session using `executing-plans`, batch execution with checkpoints. + +Which approach? diff --git a/docs/superpowers/specs/2026-05-14-slack-cli-design.md b/docs/superpowers/specs/2026-05-14-slack-cli-design.md new file mode 100644 index 0000000..f1a7f3e --- /dev/null +++ b/docs/superpowers/specs/2026-05-14-slack-cli-design.md @@ -0,0 +1,712 @@ +# slack-cli 設計仕様書 + +- **日付:** 2026-05-14 +- **対象:** `slack-cli` MVP(Rust 製 Slack CLI) +- **想定読者:** 実装に着手する coding agent / contributor +- **状態:** ブレインストーミング完了、Plan モード承認済み。実装プランは `writing-plans` スキルで別途作成する + +--- + +## 1. Context + +`slack-cli` は **coding agent**(Claude Code / Codex / Cursor 等)から呼ばれることを主目的とする、Slack 操作用のコマンドラインツールである。設計のお手本は [`jira-cli`][jira-cli]:`noun → verb` のサブコマンド構造、`--plain/--json` 切替、TTY 検出による出力フォーマット自動切替、`init` での対話セットアップなど。 + +**設計の柱:** + +1. **Agent friendly first**: pipe 検出時はデフォルトで構造化 JSON、stdout はデータ専用、stderr はログ専用、安定した JSON envelope に `"schema"` を載せて agent が出力スキーマを pin できる。 +2. **Elegance over premature abstraction**: workspace 化、`trait SlackClient`、自前 `Render` trait のような「将来必要かもしれない」抽象は **MVP では撤廃**。具象を直接書き、必要になったら抽出する。 +3. **Security from day one**: `--token` フラグは作らない、`HeaderValue::set_sensitive(true)`、OS keychain デフォルト、release で `panic="abort"`。 +4. **Reproducible toolchain**: `mise` で Rust toolchain と CLI 補助ツールを単一管理(`rust-toolchain.toml` は置かない)。 +5. **i18n 方針**: コード・コメント・ログ・エラー・`--help` は **すべて英語**。`README.md`(英語)と `README.ja.md`(日本語)の 2 つを併設。設計仕様書はブレストが日本語なので日本語。 + +--- + +## 2. バイナリ名 + +**`slack-cli`** に決定。公式 Slack CLI(`slack`)との PATH 衝突回避を最優先。 + +## 3. アーキテクチャ全体像 + +### 3.1 クレート構成 + +- **単一クレート**(cargo workspace なし)。 +- バイナリ `slack-cli` のみ。ライブラリ公開は MVP では行わない。 + +### 3.2 async ランタイム + +- `#[tokio::main(flavor = "current_thread")]`。CLI は multi-thread 不要、起動最速化。 +- `Arc` を持たない設計。`&Ctx { config, client, resolver, out }` を `cmd::*` に参照渡し。Resolver の内部キャッシュを更新する場合は `&mut Ctx` で渡す。 + +### 3.3 API クライアント + +- `reqwest` + `serde` の直叩き。`slack-morphism` などの SDK は採用しない(MVP で必要な API は 15 メソッド前後)。 +- `rustls-tls-webpki-roots` を有効化し、OS truststore 非依存・OpenSSL 非依存に。 +- `Authorization: Bearer ...` ヘッダは `HeaderValue::set_sensitive(true)` を必須付与し、hyper/reqwest のログ出力時に redact される。 +- `with_base_url(uri)` を提供してテスト時の wiremock 差し替えを可能にする(`trait` は切らない)。 + +### 3.4 エラー設計 + +- **`anyhow` は使わない**。`Result` 一本に統一。 +- `CliError` は `thiserror::Error` で定義し、`Context { source, hint }` バリアントで context-chain を表現する。 +- `#[non_exhaustive]` を付け、将来の variant 追加を非破壊化。 +- `exit_code()` / `hint()` メソッドを実装。 + +#### 3.4.1 exit code 表 + +| Code | 意味 | +|-----:|------| +| 0 | 成功 | +| 1 | 汎用失敗(ネットワーク、想定外 I/O) | +| 2 | 設定エラー(config パース失敗、workspace 曖昧) | +| 3 | 認証失敗(token 不在、token 無効) | +| 4 | レート制限 | +| 5 | リソース未存在 / 識別子曖昧 | +| 6 | トークン種別不一致 | +| 64 | 引数不正(sysexits `EX_USAGE`) | +| 130 | SIGINT 中断 | + +#### 3.4.2 thiserror メッセージ規約 + +- 小文字開始 +- 末尾ピリオドなし +- `"Error:"` の prefix を付けない +- ソースエラーは `#[from]` で吸収 + +### 3.5 panic ガイドライン + +- **lib 層**(`api/`, `resolve.rs`, `config.rs`, `secret.rs`, `id.rs`, `output.rs`): `unwrap` / `expect` / `panic!` 原則禁止。`const` context のみ許可。 +- **bin 層**(`main.rs`, `cmd/*`): invariant 違反のみ許容。 +- `std::panic::set_hook` でクラッシュ時に「This is a bug. Please file an issue.」を stderr に出す。 +- **release profile に `panic = "abort"`** を設定(メモリダンプ抑制)。 + +### 3.6 出力規約 + +- **stdout = データ専用**、**stderr = ログ・進捗・エラー専用**。pipe で `| jq` してもエラーが混入しない。 +- TTY 検出は `std::io::IsTerminal`(標準ライブラリ)。`is-terminal` クレートは使わない。 +- `Format::resolve(flag: Option, stdout_is_tty: bool)`: + - `flag` が指定されていればそれを採用 + - TTY なら `Table` + - pipe なら `Json`(agent 主用途のため plain ではなく JSON) +- JSON のインデント: TTY なら pretty、pipe なら compact。 +- 色: `--no-color` フラグ / `NO_COLOR` env / 非 TTY のいずれかで disable。 + +--- + +## 4. コマンド階層 + +### 4.1 サブコマンド一覧 + +``` +slack-cli auth init | whoami | list | logout +slack-cli channel list | view | create | archive | invite | leave +slack-cli message list | view | send | reply | update | delete | react +slack-cli search messages | files +slack-cli user list | view +slack-cli file list | upload | download +slack-cli completion bash | zsh | fish | powershell | elvish +``` + +- 動詞は `list / view / create / archive / ...` の語彙で揃える(jira-cli と整合)。 +- 複数指定可能な引数は positional(例: `slack-cli channel invite C1 U1 U2 U3`)。 +- 長フラグは kebab-case。短フラグは jira-cli の慣習に倣う:`-c` config, `-w` workspace, `-v` verbose, `-o` output。 +- `-q` は **subcommand 内の `--query` 専用**(`channel list`, `user list`, `search messages`, `search files`)。global の `--quiet` には short flag を付けず、long-only にする(clap は global flag を subcommand に露出させるため short 衝突を避ける)。 + +### 4.2 グローバルフラグ + +`#[arg(global = true)]` で `Cli` トップに **一度だけ flatten**(各 subcommand に flatten は冗長)。 + +| フラグ | 用途 | +|---|---| +| `-c, --config ` | 設定ファイルパス(env: `SLACK_CLI_CONFIG`) | +| `-w, --workspace ` | workspace 切替(env: `SLACK_CLI_WORKSPACE`) | +| `--json` / `--plain` / `--table` | 出力形式強制(排他、`#[group(multiple = false)]`) | +| `-v, --verbose` | `-v`=info / `-vv`=debug / `-vvv`=trace(stderr) | +| `--quiet` | stderr info ログ抑止(short flag は付けない、`-q` は subcommand の `--query` 用) | +| `--no-color` | 色抑止(env: `NO_COLOR` も尊重) | +| `--yes` / `--no-confirm` | 破壊的操作の確認スキップ | +| `--max-results ` | 1 ページの取得件数上限(default 50、max 200) | +| `--all` | 全件取得(`--max-pages` と併用) | +| `--max-pages ` | `--all` 使用時のページ数上限(default 10) | +| `--dry-run` | write/admin 系で API を呼ばず計画 JSON を出す | + +### 4.3 トークン入力ルール + +- **`--token` フラグは実装しない**(argv 経由は `ps aux` で漏れる) +- `--token-stdin`: stdin から 1 行受け取る +- `SLACK_CLI_TOKEN` env: CI / agent 向け +- 設定からは TokenStore で読む(後述) +- 入力時に **`xoxp-` / `xoxb-` prefix を validate**し、ミスを早期検出 + +### 4.4 stdin パイプ規約 + +- `slack-cli message send --text -`: stdin の全文をテキストとして送信 +- `slack-cli message send --file -`: stdin から読み込んだ Markdown / blocks 等 +- `slack-cli file upload --stdin --filename `: stdin からファイル本体を upload + +### 4.5 破壊的コマンドの安全弁 + +`channel archive`, `channel leave`, `message delete`, `auth logout` などに対し: + +- **TTY**: 確認 prompt(`Type "yes" to confirm:`) +- **非 TTY**: `--yes` 必須。なければ `CliError::ConfirmationRequired`(exit 64) +- agent からの呼び出しでは prompt が出てこないよう、必ず `--yes` を渡す前提 + +--- + +## 5. JSON 出力スキーマ(v0) + +### 5.1 envelope + +すべての成功時 stdout 出力: + +```jsonc +{ + "ok": true, + "schema": "slack-cli/v0", + "data": { /* command-specific */ }, + "request_id": "req_..." // x-slack-req-id があれば +} +``` + +エラー時 stderr 出力: + +```jsonc +{ + "ok": false, + "schema": "slack-cli/v0", + "error": { + "code": "channel_not_found", + "message": "channel '#nonexistent' not found", + "hint": { "action": "run", "command": "slack-cli channel list" }, + "exit_code": 5, + "details": { "kind": "channel", "name": "#nonexistent" }, + "request_id": "req_..." + } +} +``` + +### 5.2 list 系 + +常に `items` + `next_cursor`: + +```jsonc +{ "data": { "items": [...], "next_cursor": "dXNlcjpVMDYxTkZUVDI=" } } +``` + +`next_cursor` が空文字 `""` のときは「これ以上ページなし」。 + +### 5.3 dry-run(exit 0) + +```jsonc +{ + "ok": true, + "schema": "slack-cli/v0", + "data": { + "dry_run": true, + "would_call": "chat.postMessage", + "endpoint": "POST /api/chat.postMessage", + "payload": { "channel": "C012", "text": "hello", "client_msg_id": "..." } + } +} +``` + +### 5.4 ambiguous(exit 5) + +```jsonc +{ + "ok": false, + "schema": "slack-cli/v0", + "error": { + "code": "ambiguous", + "message": "ambiguous channel reference 'general' (2 candidates)", + "exit_code": 5, + "candidates": [ + { "id": "C001", "name": "general", "kind": "channel", "is_private": false }, + { "id": "C002", "name": "general", "kind": "channel", "is_private": true } + ] + } +} +``` + +### 5.5 hint の構造 + +自由文字列ではなく構造化: + +```jsonc +{ "action": "run", "command": "slack-cli auth init" } +{ "action": "specify", "flag": "--workspace" } +{ "action": "list", "command": "slack-cli channel list" } +``` + +固定 enum で定義。 + +### 5.6 whoami 応答 + +```jsonc +{ + "ok": true, + "schema": "slack-cli/v0", + "data": { + "user_id": "U012", + "user_name": "alice", + "team_id": "T012", + "team_domain": "acme", + "token_kind": "user", // "user" | "bot" + "scopes": ["chat:write", "channels:read", "..."], + "expires_at": null + } +} +``` + +--- + +## 6. モジュール構成 + +``` +slack-cli/ +├─ Cargo.toml +├─ mise.toml # rust 1.89 + cargo-nextest + cargo-insta + cargo-deny + git-cliff +├─ deny.toml # cargo-deny 設定 +├─ cliff.toml # git-cliff 設定 +├─ .github/ +│ ├─ dependabot.yml +│ └─ workflows/{ci.yml, release.yml} +├─ src/ +│ ├─ main.rs # #[tokio::main(flavor="current_thread")] → ExitCode +│ ├─ logging.rs # tracing 初期化、EnvFilter で reqwest=warn,hyper=warn 強制下限 +│ ├─ cli.rs # clap derive、GlobalArgs は #[arg(global = true)] でトップ Cli に一度 flatten +│ ├─ id.rs # Newtype: ChannelId / UserId / Ts、enum ChannelRef / UserRef + TryFrom +│ ├─ config.rs # etcetera + toml_edit + tempfile::NamedTempFile::new_in(config_dir).persist() +│ ├─ secret.rs # enum TokenStore { Keyring, File, Env } + secrecy::SecretString +│ ├─ output.rs # enum Format { Table, Plain, Json } + render() +│ ├─ slack.rs # reqwest 直叩き、UUID v4 で client_msg_id、Retry-After clamp +│ ├─ resolve.rs # Resolver: HashMap キャッシュ直所有、&mut self、60s TTL +│ ├─ error.rs # CliError (thiserror)、#[non_exhaustive]、exit_code()/hint() +│ └─ cmd/ +│ ├─ mod.rs # struct Ctx, pub fn dispatch(cli: Cli) -> Result<(), CliError> +│ ├─ auth.rs # init / whoami / list / logout +│ ├─ channel.rs # list / view / create / archive / invite / leave +│ ├─ message.rs # list / view / send / reply / update / delete / react +│ ├─ search.rs # messages / files +│ ├─ user.rs # list / view +│ ├─ file.rs # list / upload / download +│ └─ completion.rs # clap_complete で各 shell の completion を生成 +├─ tests/ +│ ├─ common/mod.rs # wiremock setup, temp config, helpers(必ず `mod.rs` 命名) +│ ├─ auth.rs +│ ├─ channel.rs +│ ├─ message.rs +│ ├─ search.rs +│ ├─ user.rs +│ ├─ file.rs +│ └─ fixtures/*.json # Slack API レスポンスのサンプル +├─ README.md # 英語 (primary) +├─ README.ja.md # 日本語 +├─ CHANGELOG.md # git-cliff で生成 +└─ docs/superpowers/specs/2026-05-14-slack-cli-design.md +``` + +--- + +## 7. 主要型の設計 + +### 7.1 識別子 Newtype(`src/id.rs`) + +```rust +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct ChannelId(pub(crate) String); +pub struct UserId(pub(crate) String); +pub struct Ts(pub(crate) String); + +#[derive(Debug, Clone)] +pub enum ChannelRef { Name(String), Id(ChannelId) } +pub enum UserRef { Name(String), Id(UserId) } + +impl TryFrom<&str> for ChannelRef { /* `#name`, `name`, `C012ABCD` を判別 */ } +impl TryFrom<&str> for UserRef { /* `@name`, `name`, `U012ABCD` / `W012ABCD` を判別 */ } +``` + +**判別ルール:** +- 先頭 `#` を剥がせばチャンネル名 +- 先頭 `@` を剥がせばユーザー名 +- それ以外で `^[CGDUW][A-Z0-9]{8,}$` に合致すれば生 ID +- 上記いずれでもなければ「名前として扱う」(Slack の channel/user 名は ID パターンと衝突しない設計) + +### 7.2 TokenStore(`src/secret.rs`) + +```rust +pub enum TokenStore { Keyring, File, Env } + +impl TokenStore { + pub async fn load(ws: &WorkspaceConfig) -> Result; + pub async fn save(ws: &WorkspaceConfig, token: SecretString) -> Result<(), CliError>; + pub async fn delete(ws: &WorkspaceConfig) -> Result<(), CliError>; +} +``` + +**OS 別の振る舞い:** + +| OS | Keyring | File | Env | +|---|---|---|---| +| macOS | macOS Keychain (`apple-native`) | 0600 | 普通 | +| Linux (D-Bus 有) | Secret Service (`sync-secret-service`) | 0600 | 普通 | +| Linux (D-Bus 無 = ヘッドレス) | Keyring 試行 → 失敗検知して File にフォールバック + `warn!` ログ | 0600 | 普通 | +| Windows | Credential Manager (`windows-native`) | **無効化(CliError::FileBackendUnsupported)** | 普通 | + +**冗長な依存を避けるため、`[target.'cfg(...)'.dependencies]` で OS 別に keyring feature を指定。** + +### 7.3 出力 Renderer(`src/output.rs`) + +`trait Render` は撤廃。`tabled::Tabled` derive + `serde::Serialize` + `Display` の三点で十分。 + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +#[non_exhaustive] +pub enum Format { Table, Plain, Json } + +pub fn render( + items: &[T], + fmt: Format, + w: &mut dyn std::io::Write, +) -> std::io::Result<()> { /* ... */ } +``` + +- `Table`: `tabled::Table::new(items)` でヘッダ自動、`tabled::settings::Style::modern_rounded()` で TTY 装飾 +- `Plain`: 1 行 1 アイテム、tab 区切り、ヘッダ無し +- `Json`: envelope に包んで `serde_json::to_writer` / `to_writer_pretty` + +各 DTO は `#[derive(Tabled, Serialize, Deserialize, Debug, Clone)]` で十分。Tabled の `#[tabled(rename = "...")]` で列名整形、`#[tabled(skip)]` で Plain/Table から除外。 + +### 7.4 Resolver(`src/resolve.rs`) + +```rust +pub struct Resolver { + cache_channels: HashMap, + cache_users: HashMap, +} + +impl Resolver { + pub async fn channel(&mut self, client: &SlackClient, r: &ChannelRef) -> Result; + pub async fn user(&mut self, client: &SlackClient, r: &UserRef) -> Result; +} +``` + +- `&mut self` で borrow を取り、`.await` 跨ぎの内部可変借用問題を回避(`RefCell` は使わない)。 +- TTL は **60 秒**、プロセス内のみ、永続化なし。 +- `conversations.list` / `users.list` を必要最小限ページングして名前→ID の表を構築。 +- 同名複数ヒットは `CliError::Ambiguous { kind, name, candidates }` で exit 5。 + +### 7.5 SlackClient(`src/slack.rs`) + +```rust +pub struct SlackClient { + base_url: String, // default: "https://slack.com" + http: reqwest::Client, + token: SecretString, +} + +impl SlackClient { + pub fn new(token: SecretString) -> Self; + pub fn with_base_url(base_url: String, token: SecretString) -> Self; + + pub async fn auth_test(&self) -> Result; + pub async fn post_message(&self, req: PostMessage) -> Result; + // ... 15 メソッド前後 +} +``` + +**実装ルール:** + +- すべての公開メソッドに `#[tracing::instrument(skip(self, ...with token))]` +- `Authorization` ヘッダは `HeaderValue::from_str(...)?.set_sensitive(true)`(`SecretString::expose_secret()` で生 token を取得) +- レスポンスの `ok: false` を `CliError` の variant にマップ +- `Retry-After` ヘッダを **`min(value, 60)` で clamp**、`ratelimited` のみ **1 回**だけ自動リトライ(指数 backoff なし) +- `client_msg_id` には `uuid::Uuid::new_v4()` を使い、`chat.postMessage` リクエストに自動付与 +- `x-slack-req-id` レスポンスヘッダは `CliError::SlackApi { request_id, ... }` に格納し、JSON エラー出力で `request_id` フィールドとして露出 + +### 7.6 DryRunClient(テスト・dry-run 用) + +実装方針:dry-run モードでは **`SlackClient` を呼び出さない**。`cmd::*` の各関数が `if args.dry_run { return render_dry_run(...); }` で早期 return。E2E テストでは wiremock サーバを起動し、`Mock::given(...).expect(0)` で「呼ばれない」ことを assertion。 + +--- + +## 8. 設定ファイル + +### 8.1 配置 + +`etcetera::BaseStrategy` の `config_dir()` を使用。 + +- macOS: `~/Library/Application Support/slack-cli/config.toml` +- Linux: `$XDG_CONFIG_HOME/slack-cli/config.toml` +- Windows: `%APPDATA%\slack-cli\config.toml` + +### 8.2 スキーマ + +```toml +default_workspace = "acme" + +[output] +format = "auto" # "auto" | "table" | "plain" | "json" +color = "auto" # "auto" | "always" | "never" +utc = false + +[workspaces.acme] +team_id = "T012ABCD" +team_domain = "acme" +token_source = "keyring" # "keyring" | "file" | "env" +# token = "xoxp-..." # token_source = "file" のときのみ +# token_env = "SLACK_TOKEN_ACME" # token_source = "env" のときのみ + +[workspaces.acme.meta] +token_kind = "user" # "user" | "bot" +user_id = "U012" +updated_at = "2026-05-14T10:23:00Z" +``` + +### 8.3 atomic write + +- `tempfile::NamedTempFile::new_in(config_dir)` で **同一 FS** にテンポラリを作る(`/tmp` だと cross-device rename 失敗 + 平文残骸リスク) +- 書き込み → `persist(config_path)` で rename +- `toml_edit` を使い、ユーザーが手書きしたコメントを保持 + +### 8.4 env override + +| env | 用途 | 優先度 | +|---|---|---| +| `SLACK_CLI_CONFIG` | 設定ファイルパス | `--config` と同等 | +| `SLACK_CLI_WORKSPACE` | 使用 workspace | `--workspace` と同等 | +| `SLACK_CLI_TOKEN` | トークン直指定 | config より優先 | +| `NO_COLOR` | 色抑止 | `--no-color` と同等 | +| `RUST_LOG` | ログレベル | `--verbose` より弱い | + +--- + +## 9. テスト戦略 + +### 9.1 3 層 + +1. **Layer 1: 純粋ユニット** — モジュール末尾の `#[cfg(test)] mod tests`。`id.rs` の TryFrom 全分岐、`output.rs::Format::resolve`、`error.rs::exit_code`、`config.rs` のパース・atomic write 等。 +2. **Layer 2: HTTP モック** — `slack.rs` の各 API メソッドを `wiremock` で検証。`MockServer::start()` → `SlackClient::with_base_url(server.uri(), ...)` で差し替え。 +3. **Layer 3: E2E** — `tests/{auth,channel,message,...}.rs` に分割。`tests/common/mod.rs` に共通ヘルパ。各テストファイルは Rust の慣習上 **別 crate** として扱われるため、`tests/common/mod.rs` 命名にして共有可能にする(`tests/common.rs` 命名だと自動的にテスト crate 扱いされ警告)。 + +### 9.2 ツール + +- `assert_cmd` + `predicates`: CLI 実行と assertion +- `wiremock`: Slack API 偽装 +- `insta` (`json` / `yaml` / `filters` feature): stdout/stderr スナップショット +- `pretty_assertions`: 差分の可読性 + +### 9.3 snapshot redaction + +動的値を `insta::with_settings!` の `filters` で正規化: + +```rust +insta::with_settings!({filters => vec![ + (r"\d{10}\.\d{6}", "[TS]"), + (r"req_[A-Za-z0-9]+", "[REQ]"), + (r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b", "[UUID]"), +]}, { insta::assert_snapshot!(stdout); }); +``` + +### 9.4 MVP のカバレッジ目標 + +- 各サブコマンド × (成功 / failure)の **最低 1 件ずつ**の E2E +- HTTP モック層は **全 API メソッド × 200 + 主要エラーコード** +- 純粋ユニットは TryFrom などのテーブル駆動で網羅 + +### 9.5 dry-run 検証 + +`Mock::given(method("POST")).respond_with(...).expect(0)` で「呼ばれない」ことを wiremock に assertion させる。`expect(0)` を全 path に張れば確実。 + +--- + +## 10. CI / リリース + +### 10.1 `mise.toml` + +```toml +[tools] +rust = "1.89" +"cargo:cargo-nextest" = "0.9" +"cargo:cargo-insta" = "1" +"cargo:cargo-deny" = "0.16" +"cargo:git-cliff" = "2" +``` + +### 10.2 GitHub Actions + +#### `.github/workflows/ci.yml` + +- **3 OS マトリクス**(Linux/macOS/Windows)で: + - `cargo fmt --check`(Linux 1 つのみ) + - `cargo clippy --all-targets -- -D warnings` + - `cargo nextest run` + - `cargo doc --no-deps --document-private-items`(Linux 1 つのみ) +- **`linux-headless` ジョブ**: Docker container(D-Bus 無し)で keyring → file fallback の integration test +- **`msrv` ジョブ**: Rust 1.89 ピン留めで `cargo check` +- **`cargo deny check advisories bans licenses sources`** を全 OS で +- **すべての action を SHA pin**(Dependabot 設定で自動更新) +- `permissions: contents: read` をデフォルト、リリース job のみ `contents: write` +- `pull_request_target` は使用禁止、`pull_request` のみ + +#### `.github/dependabot.yml` + +- `package-ecosystem: github-actions` を週 1 更新 +- `package-ecosystem: cargo` を週 1 更新 + +#### `.github/workflows/release.yml` + +- タグ `v*` push でトリガー +- `taiki-e/upload-rust-binary-action`(**SHA pin**)で 6 ターゲットビルド: + - `x86_64-unknown-linux-musl` / `aarch64-unknown-linux-musl` + - `x86_64-apple-darwin` / `aarch64-apple-darwin` + - `x86_64-pc-windows-msvc` / `aarch64-pc-windows-msvc` +- `checksum: sha256` で **SHA256SUMS 自動公開** +- changelog は `git-cliff` で conventional commits から自動生成 +- release notes は **英語のみ** + +### 10.3 リリースプロファイル + +```toml +[profile.release] +lto = "thin" +codegen-units = 1 +strip = "symbols" +opt-level = "z" +panic = "abort" +``` + +--- + +## 11. 依存クレート + +### 11.1 ランタイム + +| クレート | 主な features / 目的 | +|---|---| +| `clap` | `derive`, `wrap_help` | +| `tokio` | `macros`, `rt`, `fs`, `io-util`, `signal` (current_thread) | +| `reqwest` | `json`, `rustls-tls-webpki-roots`, `multipart`, `stream`、`default-features = false` | +| `serde` / `serde_json` | DTO | +| `toml_edit` | 設定の atomic 編集(コメント保持) | +| `thiserror` | エラー定義(`anyhow` は使わない) | +| `tracing` / `tracing-subscriber` | ログ、`env-filter` で `reqwest=warn,hyper=warn` を強制 | +| `etcetera` | XDG 準拠の設定パス解決 | +| `keyring` (v3) | OS 別 feature を `[target.'cfg(...)'.dependencies]` で指定 | +| `secrecy` 0.10 | `SecretString`, `ExposeSecret` | +| `tabled` | `Tabled` derive | +| `owo-colors` | `supports-colors` feature | +| `tempfile` | atomic write | +| `indicatif` | file upload 進捗(stderr) | +| `rpassword` | `auth init` のトークン入力(echo 抑止) | +| `uuid` | `v4` feature、冪等性キー | +| `clap_complete` | shell completion 生成 | + +### 11.2 dev + +`assert_cmd`, `predicates`, `wiremock`, `insta` (`json`, `yaml`, `filters`), `pretty_assertions` + +### 11.3 lint 設定(crate root) + +```rust +#![warn(missing_debug_implementations, rust_2018_idioms, unreachable_pub)] +#![warn(clippy::pedantic)] +// nursery / cargo は不採用(ノイズ大) +``` + +--- + +## 12. agent UX 上の重要規約 + +1. **JSON envelope** に `"schema": "slack-cli/v0"` を必ず含める。0.x は breaking 許容、agent 側はこのフィールドで pin する。 +2. **list 系は常に `{ items, next_cursor }` 形**。単一 / 配列の揺れを排除。 +3. **null は省略しない**: 「フィールド有るが値 null」と「フィールド無し」を区別。常に存在 + null。 +4. **デフォルト件数 50 / max 200**。`--all` は `--max-pages` の上限(default 10)を伴う。安全弁。 +5. **error hint** は構造化(`{ "action": "...", "command": "..." }`)。自由文字列は避ける。 +6. **`--dry-run` の出力** は `{ "dry_run": true, "would_call": ..., "endpoint": ..., "payload": ... }` 固定形。 +7. **`whoami` で `scopes` / `token_kind` を返す**: agent が起動時に実行可能 API を判定できる。 +8. **--help の `long_about`** に各 subcommand で **JSON 出力例 + 典型 invocation 2-3 個** を含める。agent の few-shot 学習を助ける。 +9. **`message react` の `already_reacted` 等**を Slack 側のエラーから success にマップ。 +10. **request_id**(`x-slack-req-id`)を成功時もエラー時も JSON envelope に含める。 + +--- + +## 13. セキュリティ規約 + +1. **`--token` フラグは作らない**。argv 経由は禁止。 +2. **`HeaderValue::set_sensitive(true)`** を Authorization に必須。 +3. **`#[tracing::instrument(skip(token, ...))]`** で token を span field に乗せない。 +4. **`tracing-subscriber::EnvFilter`** の default を `slack_cli=info,reqwest=warn,hyper=warn` にし、ユーザーが `RUST_LOG` で緩めても hyper のトレース dump を出さない。 +5. **release profile に `panic = "abort"`** でメモリダンプ抑制。 +6. **OS keychain がデフォルト**、File backend は `0600` 強制、Windows は File backend 無効。 +7. **Linux ヘッドレス**: keyring 失敗を検知して File に自動 fallback + warn。 +8. **`xoxp-` / `xoxb-` prefix を validate**してミス検出。 +9. **SHA256SUMS をリリースに自動公開**(codesign / notarize は MVP では非対応、README で明示)。 +10. **Slack エラーの redact**: `response_metadata` の生 dump は出さず、code と最小限の details に絞る。 + +--- + +## 14. ロギング + +- 出力先: **stderr**(`tracing_subscriber::fmt().with_writer(std::io::stderr)`) +- フォーマット: human-friendly(`with_target(false).with_level(true)`) +- `EnvFilter`: default `slack_cli=info,reqwest=warn,hyper=warn` +- `--verbose` フラグで上書き(`-v`/`-vv`/`-vvv` → info/debug/trace) +- `--quiet` で `EnvFilter` を `error` に絞る + +--- + +## 15. このスペックで作成する MVP 範囲(非ゴール) + +**含まない:** + +- cargo workspace 化 +- MCP サーバモード +- Block Kit インタラクティブエディタ +- batch 操作(JSON Lines 入力で複数メッセージ) +- mise community plugin、Homebrew tap、crates.io 公開 +- macOS codesign / notarization、Windows EV cert 署名 +- Socket Mode / Events API +- ベンチマーク、fuzz +- JSON `--json-strict`(schema 変更時の strict mode) + +これらは post-MVP で再検討する。 + +--- + +## 16. 検証チェックリスト + +- [ ] `mise install` で toolchain がセットアップできる +- [ ] `cargo check` がクリーンに通る(最小 `src/main.rs` を入れた段階で) +- [ ] `cargo fmt --check` / `cargo clippy --all-targets -- -D warnings` 通る +- [ ] `cargo nextest run` 通る +- [ ] `cargo deny check advisories bans licenses sources` 通る +- [ ] `--help` 出力が英語、`--version` が正しい +- [ ] `slack-cli auth init` の対話フローが動く +- [ ] `slack-cli auth whoami --json` で JSON envelope が出る +- [ ] `slack-cli channel list --json` の出力が JSON Lines ではなく envelope + items +- [ ] `slack-cli message send --dry-run` で wiremock に何も飛ばない +- [ ] `slack-cli completion bash` で valid な bash completion が出る +- [ ] keyring 無効の Linux container で File backend に fallback する + +--- + +## 付録 A: 用語 + +- **workspace**: `slack-cli` 設定上の Slack workspace(team)の論理単位。`acme` や `personal` のような名前で参照する。 +- **TokenStore**: トークンの保存方式 (`Keyring` / `File` / `Env`) を選ぶ抽象。 +- **identifier resolve**: `#channel-name` や `@username` のような人間可読参照を内部 ID(`Cxxxx` / `Uxxxx`)に変換する処理。 + +## 付録 B: 参照 + +- [jira-cli][jira-cli]: コマンド階層の参考 +- [Slack Web API][slack-api]: 対応 API +- [`mise`][mise]: toolchain 管理 +- [`tabled`][tabled] / [`tracing-subscriber`][tracing] / [`wiremock`][wiremock]: 主要クレート + +[jira-cli]: https://github.com/ankitpokhrel/jira-cli +[slack-api]: https://api.slack.com/methods +[mise]: https://mise.jdx.dev +[tabled]: https://crates.io/crates/tabled +[tracing]: https://crates.io/crates/tracing-subscriber +[wiremock]: https://crates.io/crates/wiremock diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..a3a8454 --- /dev/null +++ b/mise.toml @@ -0,0 +1,51 @@ +# mise toolchain pin for slack-cli. +# Single source of truth: do NOT add rust-toolchain.toml. +# +# Dev tools use the `ubi` backend (pre-built binaries from GitHub releases), +# so their own MSRV does not constrain ours. cargo-nextest is installed +# separately in CI via `taiki-e/install-action` because its release asset +# naming (cargo-nextest-X.Y.Z) does not match the ubi default filter. + +[tools] +# `profile = "default"` ships rustfmt + clippy alongside rustc; the +# mise default minimal profile omits them and `cargo fmt --check` / +# `cargo clippy` fail on a fresh CI runner. +rust = { version = "1.89", profile = "default" } +"ubi:mitsuhiko/insta" = { version = "latest", exe = "cargo-insta" } +"ubi:EmbarkStudios/cargo-deny" = "latest" +"ubi:orhun/git-cliff" = "latest" + +[env] +RUST_BACKTRACE = "1" + +[tasks.build] +description = "Release build" +run = "cargo build --release" + +[tasks.test] +description = "Run fmt check + clippy + nextest + deny in one shot" +run = [ + "cargo fmt --all -- --check", + "cargo clippy --all-targets -- -D warnings", + "cargo nextest run", + "cargo deny check advisories bans licenses sources", +] + +[tasks.fix] +description = "Auto-apply fmt and clippy fixes" +run = [ + "cargo fmt --all", + "cargo clippy --all-targets --fix --allow-dirty --allow-staged", +] + +[tasks.snap] +description = "Review pending insta snapshots" +run = "cargo insta review" + +[tasks.run] +description = "Run debug build of the CLI" +run = "cargo run --" + +[tasks.changelog] +description = "Regenerate CHANGELOG.md from conventional commits" +run = "git cliff --output CHANGELOG.md" diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..e85249d --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,333 @@ +//! CLI argument grammar built with clap's derive API. +//! +//! All global flags live on `GlobalArgs` with `#[arg(global = true)]` so they +//! flow through to every subcommand without explicit flatten. Subcommand +//! grammars live next to `Cli` for compactness. + +use std::path::PathBuf; + +use clap::{Args, Parser, Subcommand}; + +use crate::output::Format; + +#[derive(Debug, Parser)] +#[command(name = "slack-cli", version, about = "Slack CLI for coding agents")] +pub struct Cli { + #[command(flatten)] + pub globals: GlobalArgs, + + #[command(subcommand)] + pub command: TopCommand, +} + +#[derive(Debug, Args)] +#[allow(clippy::struct_excessive_bools)] +pub struct GlobalArgs { + /// Config file path (env: `SLACK_CLI_CONFIG`). + #[arg(short = 'c', long, global = true, env = "SLACK_CLI_CONFIG")] + pub config: Option, + + /// Workspace name (env: `SLACK_CLI_WORKSPACE`). + #[arg(short = 'w', long, global = true, env = "SLACK_CLI_WORKSPACE")] + pub workspace: Option, + + /// Force JSON output. + #[arg(long, global = true, group = "output_fmt")] + pub json: bool, + + /// Force tab-separated plain output. + #[arg(long, global = true, group = "output_fmt")] + pub plain: bool, + + /// Force table output. + #[arg(long, global = true, group = "output_fmt")] + pub table: bool, + + /// Increase logging verbosity (-v info, -vv debug, -vvv trace). + #[arg(short = 'v', long, global = true, action = clap::ArgAction::Count)] + pub verbose: u8, + + /// Suppress info logging (errors still print). + #[arg(long, global = true)] + pub quiet: bool, + + /// Disable ANSI colour output (also honors `NO_COLOR`). + #[arg(long, global = true)] + pub no_color: bool, + + /// Confirm destructive operations non-interactively (alias: --no-confirm). + #[arg(long, global = true, visible_alias = "no-confirm")] + pub yes: bool, + + /// Maximum items returned per page (default 50, capped at 200). + #[arg(long, global = true, default_value_t = 50)] + pub max_results: u32, + + /// Fetch every page (pair with --max-pages to bound). + #[arg(long, global = true)] + pub all: bool, + + /// Maximum pages fetched when --all is set. + #[arg(long, global = true, default_value_t = 10)] + pub max_pages: u32, + + /// For write/admin commands: print the would-be request without sending. + #[arg(long, global = true)] + pub dry_run: bool, +} + +impl GlobalArgs { + /// Resolve the explicit output format from CLI flags, if any. + #[must_use] + pub fn format(&self) -> Option { + if self.json { + Some(Format::Json) + } else if self.plain { + Some(Format::Plain) + } else if self.table { + Some(Format::Table) + } else { + None + } + } +} + +#[derive(Debug, Subcommand)] +pub enum TopCommand { + /// Authentication and workspace management. + Auth(AuthArgs), + /// Slack channels. + Channel(ChannelArgs), + /// Slack messages. + Message(MessageArgs), + /// Slack search. + Search(SearchArgs), + /// Slack users. + User(UserArgs), + /// Slack files. + File(FileArgs), + /// Generate shell completion scripts. + Completion(CompletionArgs), +} + +#[derive(Debug, Args)] +pub struct AuthArgs { + #[command(subcommand)] + pub verb: AuthVerb, +} + +#[derive(Debug, Subcommand)] +pub enum AuthVerb { + /// Interactively configure a workspace. + Init { + #[arg(short = 'w', long)] + workspace: Option, + #[arg(long)] + token_stdin: bool, + #[arg(long, value_enum, default_value_t = AuthBackend::Keyring)] + backend: AuthBackend, + }, + /// Show the authenticated identity. + Whoami, + /// List configured workspaces. + List, + /// Delete the stored token for a workspace. + Logout { + #[arg(short = 'w', long)] + workspace: Option, + }, +} + +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +pub enum AuthBackend { + Keyring, + File, + Env, +} + +#[derive(Debug, Args)] +pub struct ChannelArgs { + #[command(subcommand)] + pub verb: ChannelVerb, +} + +#[derive(Debug, Subcommand)] +pub enum ChannelVerb { + List { + #[arg(short = 'q', long)] + query: Option, + #[arg(long, default_value = "public_channel,private_channel")] + types: String, + }, + View { + channel: String, + }, + Create { + name: String, + #[arg(long)] + private: bool, + #[arg(long)] + description: Option, + }, + Archive { + channel: String, + }, + Invite { + channel: String, + users: Vec, + }, + Leave { + channel: String, + }, +} + +#[derive(Debug, Args)] +pub struct MessageArgs { + #[command(subcommand)] + pub verb: MessageVerb, +} + +#[derive(Debug, Subcommand)] +pub enum MessageVerb { + List { + channel: String, + #[arg(long, default_value_t = 50)] + limit: u32, + #[arg(long)] + oldest: Option, + #[arg(long)] + latest: Option, + }, + View { + channel: String, + ts: String, + #[arg(long)] + thread: bool, + }, + Send { + channel: String, + #[arg(long, conflicts_with = "file")] + text: Option, + #[arg(long, conflicts_with = "text")] + file: Option, + #[arg(long)] + thread: Option, + #[arg(long)] + broadcast: bool, + }, + Reply { + channel: String, + ts: String, + #[arg(long)] + text: String, + #[arg(long)] + broadcast: bool, + }, + Update { + channel: String, + ts: String, + #[arg(long)] + text: String, + }, + Delete { + channel: String, + ts: String, + }, + React { + channel: String, + ts: String, + emoji: String, + #[arg(long)] + remove: bool, + }, +} + +#[derive(Debug, Args)] +pub struct SearchArgs { + #[command(subcommand)] + pub verb: SearchVerb, +} + +#[derive(Debug, Subcommand)] +pub enum SearchVerb { + Messages { + query: String, + #[arg(long)] + r#in: Option, + #[arg(long)] + from: Option, + #[arg(long, default_value_t = 50)] + limit: u32, + }, + Files { + query: String, + #[arg(long, name = "type")] + file_type: Option, + #[arg(long, default_value_t = 50)] + limit: u32, + }, +} + +#[derive(Debug, Args)] +pub struct UserArgs { + #[command(subcommand)] + pub verb: UserVerb, +} + +#[derive(Debug, Subcommand)] +pub enum UserVerb { + List { + #[arg(short = 'q', long)] + query: Option, + #[arg(long, default_value_t = 50)] + limit: u32, + }, + View { + user: String, + }, +} + +#[derive(Debug, Args)] +pub struct FileArgs { + #[command(subcommand)] + pub verb: FileVerb, +} + +#[derive(Debug, Subcommand)] +pub enum FileVerb { + List { + #[arg(long)] + channel: Option, + #[arg(long)] + user: Option, + #[arg(long, default_value_t = 50)] + limit: u32, + }, + Upload { + path: PathBuf, + #[arg(long)] + channel: Option, + #[arg(long)] + title: Option, + #[arg(long)] + comment: Option, + #[arg(long)] + thread: Option, + #[arg(long)] + stdin: bool, + #[arg(long)] + filename: Option, + }, + Download { + file_id: String, + #[arg(short = 'o', long)] + output: Option, + #[arg(long)] + stdout: bool, + }, +} + +#[derive(Debug, Args)] +pub struct CompletionArgs { + pub shell: clap_complete::Shell, +} diff --git a/src/cmd/auth.rs b/src/cmd/auth.rs new file mode 100644 index 0000000..e84d170 --- /dev/null +++ b/src/cmd/auth.rs @@ -0,0 +1,190 @@ +//! `slack-cli auth` subcommand handlers. + +use std::io::{BufRead, IsTerminal, Write}; + +use secrecy::SecretString; +use serde::Serialize; +use tabled::Tabled; + +use crate::cli::{AuthArgs, AuthBackend, AuthVerb}; +use crate::cmd::Ctx; +use crate::config::{TokenSource, WorkspaceConfig, WorkspaceMeta}; +use crate::error::CliError; +use crate::slack::SlackClient; + +#[derive(Debug, Serialize, Tabled)] +pub struct WhoamiRow { + pub user_id: String, + pub user_name: String, + pub team_id: String, + pub team_domain: String, + pub token_kind: String, +} + +#[derive(Debug, Serialize, Tabled)] +pub struct WorkspaceRow { + pub name: String, + pub team_id: String, + pub token_source: String, +} + +pub async fn run(args: AuthArgs, ctx: &mut Ctx) -> Result<(), CliError> { + match args.verb { + AuthVerb::Init { + workspace, + token_stdin, + backend, + } => init(workspace, token_stdin, backend, ctx).await, + AuthVerb::Whoami => whoami(ctx).await, + AuthVerb::List => list(ctx), + AuthVerb::Logout { workspace } => logout(workspace, ctx), + } +} + +async fn init( + workspace: Option, + token_stdin: bool, + backend: AuthBackend, + ctx: &mut Ctx, +) -> Result<(), CliError> { + let name = match workspace { + Some(n) => n, + None => prompt("workspace name: ")?, + }; + if name.trim().is_empty() { + return Err(CliError::InvalidArg("workspace name required".into())); + } + + let raw = if token_stdin { + let mut line = String::new(); + std::io::stdin().lock().read_line(&mut line)?; + line.trim().to_owned() + } else { + rpassword::prompt_password("token (input hidden): ") + .map_err(|e| CliError::InvalidArg(format!("token prompt: {e}")))? + }; + crate::secret::validate_prefix(&raw)?; + let secret = SecretString::from(raw); + + let client = SlackClient::new(secret.clone())?; + let info = client.auth_test().await?; + let token_kind = if info.bot_id.is_some() { "bot" } else { "user" }; + + let token_source = match backend { + AuthBackend::Keyring => TokenSource::Keyring, + AuthBackend::File => TokenSource::File, + AuthBackend::Env => TokenSource::Env, + }; + + let mut ws = WorkspaceConfig { + team_id: info.team_id.clone(), + team_domain: info.team.clone(), + token_source, + token: None, + token_env: None, + meta: WorkspaceMeta { + token_kind: Some(token_kind.into()), + user_id: info.user_id.clone(), + updated_at: Some(rfc3339_now()), + }, + }; + crate::secret::save(&name, &mut ws, &secret)?; + + let key = name.clone(); + ctx.config.workspaces.insert(key.clone(), ws); + if ctx.config.default_workspace.is_none() { + ctx.config.default_workspace = Some(key); + } + crate::config::save(&ctx.config_path, &ctx.config)?; + + eprintln!("\u{2713} saved workspace '{name}'"); + Ok(()) +} + +async fn whoami(ctx: &mut Ctx) -> Result<(), CliError> { + let (name, ws) = crate::config::pick_workspace(&ctx.config, ctx.globals.workspace.as_deref())?; + let secret = crate::secret::load(name, ws)?; + let client = SlackClient::new(secret)?; + let info = client.auth_test().await?; + let kind = if info.bot_id.is_some() { "bot" } else { "user" }; + let row = WhoamiRow { + user_id: info.user_id.unwrap_or_default(), + user_name: info.user.unwrap_or_default(), + team_id: info.team_id.unwrap_or_default(), + team_domain: info.team.unwrap_or_default(), + token_kind: kind.into(), + }; + ctx.emit_one(&row) +} + +fn list(ctx: &mut Ctx) -> Result<(), CliError> { + let rows: Vec = ctx + .config + .workspaces + .iter() + .map(|(name, w)| WorkspaceRow { + name: name.clone(), + team_id: w.team_id.clone().unwrap_or_default(), + token_source: match w.token_source { + TokenSource::Keyring => "keyring".into(), + TokenSource::File => "file".into(), + TokenSource::Env => "env".into(), + }, + }) + .collect(); + ctx.emit_list(&rows, "") +} + +fn logout(workspace: Option, ctx: &mut Ctx) -> Result<(), CliError> { + let name = workspace + .or_else(|| { + if ctx.workspace_name.is_empty() { + ctx.config.default_workspace.clone() + } else { + Some(ctx.workspace_name.clone()) + } + }) + .ok_or_else(|| CliError::AmbiguousWorkspace { + available: ctx.config.workspaces.keys().cloned().collect(), + })?; + + if !ctx.globals.yes { + if std::io::stdin().is_terminal() { + let line = prompt(&format!( + "delete token for '{name}'? type 'yes' to confirm: " + ))?; + if line.trim() != "yes" { + return Err(CliError::ConfirmationRequired); + } + } else { + return Err(CliError::ConfirmationRequired); + } + } + + if let Some(ws) = ctx.config.workspaces.get_mut(&name) { + crate::secret::delete(&name, ws)?; + ws.token = None; + } + crate::config::save(&ctx.config_path, &ctx.config)?; + eprintln!("\u{2713} logged out '{name}'"); + Ok(()) +} + +fn prompt(msg: &str) -> Result { + let mut err = std::io::stderr().lock(); + err.write_all(msg.as_bytes())?; + err.flush()?; + let mut buf = String::new(); + std::io::stdin().lock().read_line(&mut buf)?; + Ok(buf.trim().to_owned()) +} + +fn rfc3339_now() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or_default(); + // Minimal RFC3339-ish; we don't pull chrono just for this stamp. + format!("@{secs}") +} diff --git a/src/cmd/channel.rs b/src/cmd/channel.rs new file mode 100644 index 0000000..a9ee2e7 --- /dev/null +++ b/src/cmd/channel.rs @@ -0,0 +1,156 @@ +//! `slack-cli channel` subcommand handlers. + +use serde::Serialize; +use tabled::Tabled; + +use crate::cli::{ChannelArgs, ChannelVerb}; +use crate::cmd::{emit_dry_run, require_client, require_confirmation, Ctx}; +use crate::error::CliError; +use crate::id::{ChannelRef, UserRef}; + +#[derive(Debug, Serialize, Tabled)] +pub struct ChannelRow { + pub id: String, + pub name: String, + pub is_private: bool, + pub members: u32, +} + +impl From for ChannelRow { + fn from(c: crate::slack::ChannelDto) -> Self { + Self { + id: c.id, + name: c.name, + is_private: c.is_private, + members: c.num_members.unwrap_or(0), + } + } +} + +#[allow(clippy::too_many_lines)] +pub async fn run(args: ChannelArgs, ctx: &mut Ctx) -> Result<(), CliError> { + let client = require_client(&ctx.client, &ctx.workspace_name)?; + + match args.verb { + ChannelVerb::List { query, types } => { + let limit = ctx.globals.max_results.min(200); + let max_pages = if ctx.globals.all { + ctx.globals.max_pages + } else { + 1 + }; + let mut cursor: Option = None; + let mut rows: Vec = Vec::new(); + let mut next_cursor = String::new(); + for _ in 0..max_pages { + let page = client + .conversations_list(&types, limit, cursor.as_deref()) + .await?; + for ch in page.channels { + if let Some(q) = query.as_deref() { + if !ch.name.contains(q) { + continue; + } + } + rows.push(ChannelRow::from(ch)); + } + next_cursor = page + .response_metadata + .and_then(|m| m.next_cursor) + .unwrap_or_default(); + if next_cursor.is_empty() { + break; + } + cursor = Some(next_cursor.clone()); + } + ctx.emit_list(&rows, &next_cursor) + } + + ChannelVerb::View { channel } => { + let cref = ChannelRef::try_from(channel.as_str())?; + let id = ctx.resolver.channel(client, &cref).await?; + let info = client.conversations_info(id.as_str()).await?; + let row = ChannelRow::from(info.channel); + ctx.emit_one(&row) + } + + ChannelVerb::Create { + name, + private, + description, + } => { + if ctx.globals.dry_run { + return emit_dry_run( + ctx, + "conversations.create", + "POST /api/conversations.create", + serde_json::json!({ + "name": name, + "is_private": private, + "description": description, + }), + ); + } + let resp = client + .conversations_create(&name, private, description.as_deref()) + .await?; + let row = ChannelRow::from(resp.channel); + ctx.emit_one(&row) + } + + ChannelVerb::Archive { channel } => { + require_confirmation(ctx, &format!("archive channel '{channel}'?"))?; + if ctx.globals.dry_run { + return emit_dry_run( + ctx, + "conversations.archive", + "POST /api/conversations.archive", + serde_json::json!({"channel": channel}), + ); + } + let cref = ChannelRef::try_from(channel.as_str())?; + let id = ctx.resolver.channel(client, &cref).await?; + client.conversations_archive(id.as_str()).await?; + eprintln!("✓ archived"); + Ok(()) + } + + ChannelVerb::Invite { channel, users } => { + if ctx.globals.dry_run { + return emit_dry_run( + ctx, + "conversations.invite", + "POST /api/conversations.invite", + serde_json::json!({"channel": channel, "users": users}), + ); + } + let cref = ChannelRef::try_from(channel.as_str())?; + let cid = ctx.resolver.channel(client, &cref).await?; + let mut uids: Vec = Vec::with_capacity(users.len()); + for u in &users { + let uref = UserRef::try_from(u.as_str())?; + uids.push(ctx.resolver.user(client, &uref).await?.to_string()); + } + client.conversations_invite(cid.as_str(), &uids).await?; + eprintln!("✓ invited {} user(s)", uids.len()); + Ok(()) + } + + ChannelVerb::Leave { channel } => { + require_confirmation(ctx, &format!("leave channel '{channel}'?"))?; + if ctx.globals.dry_run { + return emit_dry_run( + ctx, + "conversations.leave", + "POST /api/conversations.leave", + serde_json::json!({"channel": channel}), + ); + } + let cref = ChannelRef::try_from(channel.as_str())?; + let id = ctx.resolver.channel(client, &cref).await?; + client.conversations_leave(id.as_str()).await?; + eprintln!("✓ left"); + Ok(()) + } + } +} diff --git a/src/cmd/completion.rs b/src/cmd/completion.rs new file mode 100644 index 0000000..e757967 --- /dev/null +++ b/src/cmd/completion.rs @@ -0,0 +1,15 @@ +//! `slack-cli completion` subcommand: emit shell completion to stdout. + +use clap::CommandFactory; + +use crate::cli::{Cli, CompletionArgs}; +use crate::cmd::Ctx; +use crate::error::CliError; + +#[allow(clippy::unused_async)] // signature matches other subcommand runners +pub async fn run(args: CompletionArgs, _ctx: &mut Ctx) -> Result<(), CliError> { + let mut cmd = Cli::command(); + let mut stdout = std::io::stdout().lock(); + clap_complete::generate(args.shell, &mut cmd, "slack-cli", &mut stdout); + Ok(()) +} diff --git a/src/cmd/file.rs b/src/cmd/file.rs new file mode 100644 index 0000000..cb6fa03 --- /dev/null +++ b/src/cmd/file.rs @@ -0,0 +1,138 @@ +//! `slack-cli file` subcommand handlers. + +use std::io::Read; +use std::path::PathBuf; + +use serde::Serialize; +use tabled::Tabled; + +use crate::cli::{FileArgs, FileVerb}; +use crate::cmd::{emit_dry_run, require_client, Ctx}; +use crate::error::CliError; +use crate::id::ChannelRef; + +#[derive(Debug, Serialize, Tabled)] +pub struct FileRow { + pub id: String, + pub name: String, + pub size: u64, + pub mimetype: String, +} + +impl From for FileRow { + fn from(f: crate::slack::FileDto) -> Self { + Self { + id: f.id, + name: f.name, + size: f.size, + mimetype: f.mimetype, + } + } +} + +#[allow(clippy::too_many_lines)] +pub async fn run(args: FileArgs, ctx: &mut Ctx) -> Result<(), CliError> { + let client = require_client(&ctx.client, &ctx.workspace_name)?; + + match args.verb { + FileVerb::List { + channel, + user: _user, + limit, + } => { + let resp = client + .files_list(channel.as_deref(), limit.min(200), None) + .await?; + let rows: Vec = resp.files.into_iter().map(FileRow::from).collect(); + ctx.emit_list(&rows, "") + } + + FileVerb::Upload { + path, + channel, + title, + comment, + thread, + stdin, + filename, + } => { + let (bytes, name) = if stdin { + let mut buf = Vec::new(); + std::io::stdin().lock().read_to_end(&mut buf)?; + let name = filename.ok_or_else(|| { + CliError::InvalidArg("--filename required with --stdin".into()) + })?; + (buf, name) + } else { + let bytes = std::fs::read(&path)?; + let name = path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("upload") + .to_owned(); + (bytes, name) + }; + if ctx.globals.dry_run { + return emit_dry_run( + ctx, + "files.upload", + "POST /api/files.upload", + serde_json::json!({ + "channel": channel, + "title": title, + "initial_comment": comment, + "thread_ts": thread, + "filename": name, + "size": bytes.len(), + }), + ); + } + let resolved_channel = if let Some(c) = channel.as_deref() { + let cref = ChannelRef::try_from(c)?; + Some(ctx.resolver.channel(client, &cref).await?.to_string()) + } else { + None + }; + let f = client + .files_upload( + resolved_channel.as_deref(), + &name, + bytes, + title.as_deref(), + comment.as_deref(), + thread.as_deref(), + ) + .await?; + let row = FileRow { + id: f.id, + name: f.name, + size: f.size, + mimetype: f.mimetype, + }; + ctx.emit_one(&row) + } + + FileVerb::Download { + file_id, + output, + stdout, + } => { + let info = client.files_info(&file_id).await?; + let url = info + .file + .url_private + .ok_or_else(|| CliError::Config("file has no url_private".into()))?; + let bytes = client.download_authorized(&url).await?; + if stdout { + use std::io::Write; + std::io::stdout().lock().write_all(&bytes)?; + Ok(()) + } else { + let out_path = output.unwrap_or_else(|| PathBuf::from(&info.file.name)); + std::fs::write(&out_path, &bytes)?; + eprintln!("\u{2713} saved {}", out_path.display()); + Ok(()) + } + } + } +} diff --git a/src/cmd/message.rs b/src/cmd/message.rs new file mode 100644 index 0000000..06d3f25 --- /dev/null +++ b/src/cmd/message.rs @@ -0,0 +1,243 @@ +//! `slack-cli message` subcommand handlers. + +use std::io::Read; +use std::path::Path; + +use serde::Serialize; +use tabled::Tabled; + +use crate::cli::{MessageArgs, MessageVerb}; +use crate::cmd::{emit_dry_run, require_client, require_confirmation, Ctx}; +use crate::error::{CliError, ResourceKind}; +use crate::id::ChannelRef; +use crate::slack::PostMessage; + +#[derive(Debug, Serialize, Tabled)] +pub struct MessageRow { + pub ts: String, + pub user: String, + pub text: String, +} + +impl From for MessageRow { + fn from(m: crate::slack::MessageDto) -> Self { + Self { + ts: m.ts, + user: m.user.unwrap_or_default(), + text: m.text, + } + } +} + +#[allow(clippy::too_many_lines)] +pub async fn run(args: MessageArgs, ctx: &mut Ctx) -> Result<(), CliError> { + let client = require_client(&ctx.client, &ctx.workspace_name)?; + + match args.verb { + MessageVerb::List { + channel, + limit, + oldest, + latest, + } => { + let cref = ChannelRef::try_from(channel.as_str())?; + let id = ctx.resolver.channel(client, &cref).await?; + let page = client + .conversations_history( + id.as_str(), + limit.min(200), + None, + oldest.as_deref(), + latest.as_deref(), + ) + .await?; + let rows: Vec = page.messages.into_iter().map(MessageRow::from).collect(); + let cursor = page + .response_metadata + .and_then(|m| m.next_cursor) + .unwrap_or_default(); + ctx.emit_list(&rows, &cursor) + } + + MessageVerb::View { + channel, + ts, + thread, + } => { + let cref = ChannelRef::try_from(channel.as_str())?; + let id = ctx.resolver.channel(client, &cref).await?; + if thread { + let page = client + .conversations_replies(id.as_str(), &ts, None, 100) + .await?; + let rows: Vec = + page.messages.into_iter().map(MessageRow::from).collect(); + ctx.emit_list(&rows, "") + } else { + let page = client + .conversations_history(id.as_str(), 1, None, Some(&ts), Some(&ts)) + .await?; + let m = page + .messages + .into_iter() + .next() + .ok_or_else(|| CliError::NotFound { + kind: ResourceKind::Message, + name: ts.clone(), + })?; + let row = MessageRow::from(m); + ctx.emit_one(&row) + } + } + + MessageVerb::Send { + channel, + text, + file, + thread, + broadcast, + } => { + let body = read_body(text.as_deref(), file.as_deref())?; + let cref = ChannelRef::try_from(channel.as_str())?; + let cid = ctx.resolver.channel(client, &cref).await?; + let mut req = PostMessage::new(cid.as_str().to_owned(), body); + req.thread_ts = thread; + req.reply_broadcast = if broadcast { Some(true) } else { None }; + if ctx.globals.dry_run { + let payload = serde_json::to_value(&req).map_err(std::io::Error::other)?; + return emit_dry_run( + ctx, + "chat.postMessage", + "POST /api/chat.postMessage", + payload, + ); + } + let resp = client.chat_post_message(&req).await?; + // Slack guarantees `resp.ts == resp.message.ts`; rely on that + // so `MessageRow::from` carries the envelope timestamp. + let row = MessageRow::from(resp.message); + ctx.emit_one(&row) + } + + MessageVerb::Reply { + channel, + ts, + text, + broadcast, + } => { + let cref = ChannelRef::try_from(channel.as_str())?; + let cid = ctx.resolver.channel(client, &cref).await?; + let mut req = PostMessage::new(cid.as_str().to_owned(), text); + req.thread_ts = Some(ts); + req.reply_broadcast = if broadcast { Some(true) } else { None }; + if ctx.globals.dry_run { + let payload = serde_json::to_value(&req).map_err(std::io::Error::other)?; + return emit_dry_run( + ctx, + "chat.postMessage", + "POST /api/chat.postMessage", + payload, + ); + } + let resp = client.chat_post_message(&req).await?; + // Slack guarantees `resp.ts == resp.message.ts`; rely on that + // so `MessageRow::from` carries the envelope timestamp. + let row = MessageRow::from(resp.message); + ctx.emit_one(&row) + } + + MessageVerb::Update { channel, ts, text } => { + let cref = ChannelRef::try_from(channel.as_str())?; + let cid = ctx.resolver.channel(client, &cref).await?; + if ctx.globals.dry_run { + return emit_dry_run( + ctx, + "chat.update", + "POST /api/chat.update", + serde_json::json!({"channel": cid.as_str(), "ts": ts, "text": text}), + ); + } + client.chat_update(cid.as_str(), &ts, &text).await?; + eprintln!("✓ updated"); + Ok(()) + } + + MessageVerb::Delete { channel, ts } => { + require_confirmation(ctx, &format!("delete message {ts} in '{channel}'?"))?; + let cref = ChannelRef::try_from(channel.as_str())?; + let cid = ctx.resolver.channel(client, &cref).await?; + if ctx.globals.dry_run { + return emit_dry_run( + ctx, + "chat.delete", + "POST /api/chat.delete", + serde_json::json!({"channel": cid.as_str(), "ts": ts}), + ); + } + client.chat_delete(cid.as_str(), &ts).await?; + eprintln!("✓ deleted"); + Ok(()) + } + + MessageVerb::React { + channel, + ts, + emoji, + remove, + } => { + let cref = ChannelRef::try_from(channel.as_str())?; + let cid = ctx.resolver.channel(client, &cref).await?; + // Strip surrounding ':' if user typed `:thumbsup:`. + let name = emoji.trim_matches(':'); + if ctx.globals.dry_run { + let endpoint = if remove { + "POST /api/reactions.remove" + } else { + "POST /api/reactions.add" + }; + let would_call = if remove { + "reactions.remove" + } else { + "reactions.add" + }; + return emit_dry_run( + ctx, + would_call, + endpoint, + serde_json::json!({"channel": cid.as_str(), "timestamp": ts, "name": name}), + ); + } + if remove { + client.reactions_remove(cid.as_str(), &ts, name).await?; + eprintln!("✓ reaction removed"); + } else { + client.reactions_add(cid.as_str(), &ts, name).await?; + eprintln!("✓ reaction added"); + } + Ok(()) + } + } +} + +/// Resolve the message body from `--text` / `--file` flags or stdin (when +/// either flag is `-`). Returns `InvalidArg` for empty/conflicting input. +fn read_body(text: Option<&str>, file: Option<&Path>) -> Result { + match (text, file) { + (Some("-"), None) => { + let mut s = String::new(); + std::io::stdin().lock().read_to_string(&mut s)?; + Ok(s.trim_end_matches('\n').to_owned()) + } + (None, Some(p)) if p.as_os_str() == "-" => { + let mut s = String::new(); + std::io::stdin().lock().read_to_string(&mut s)?; + Ok(s.trim_end_matches('\n').to_owned()) + } + (Some(s), None) => Ok(s.to_owned()), + (None, Some(p)) => Ok(std::fs::read_to_string(p)?), + (None, None) => Err(CliError::InvalidArg("--text or --file required".into())), + (Some(_), Some(_)) => Err(CliError::InvalidArg( + "--text and --file are mutually exclusive".into(), + )), + } +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs new file mode 100644 index 0000000..2a2f39a --- /dev/null +++ b/src/cmd/mod.rs @@ -0,0 +1,198 @@ +//! Command dispatch: build a `Ctx` from CLI args + config, then route. + +pub mod auth; +pub mod channel; +pub mod completion; +pub mod file; +pub mod message; +pub mod search; +pub mod user; + +use std::io::IsTerminal; +use std::path::PathBuf; + +use crate::cli::{Cli, GlobalArgs, TopCommand}; +use crate::config::{self, Config}; +use crate::error::CliError; +use crate::output::Format; +use crate::resolve::Resolver; +use crate::slack::SlackClient; + +/// Runtime context passed by reference to each subcommand handler. +#[derive(Debug)] +pub struct Ctx { + pub config: Config, + pub config_path: PathBuf, + pub workspace_name: String, + pub client: Option, + pub resolver: Resolver, + pub format: Format, + pub stdout_is_tty: bool, + pub globals: GlobalArgs, +} + +impl Ctx { + /// Borrow the Slack client or return `NoToken` for the current workspace. + /// + /// Implemented via the free `require_client` helper called with the + /// explicit field path so the borrow checker can split this from later + /// mutable borrows of sibling fields like `self.resolver`. Callers that + /// also need `self.resolver` concurrently should invoke + /// `cmd::require_client(&ctx.client, &ctx.workspace_name)` directly. + pub fn require_client(&self) -> Result<&SlackClient, CliError> { + require_client(&self.client, &self.workspace_name) + } + + /// Whether JSON output should be pretty-printed (TTY + Json format). + #[must_use] + pub fn pretty_json(&self) -> bool { + self.stdout_is_tty && matches!(self.format, Format::Json) + } + + /// Render a single row through the resolved format (table/plain/json). + pub fn emit_one(&self, row: &T) -> Result<(), CliError> + where + T: tabled::Tabled + serde::Serialize, + { + crate::output::render_one( + row, + self.format, + self.pretty_json(), + None, + &mut std::io::stdout().lock(), + ) + .map_err(CliError::Io) + } + + /// Render a paginated list through the resolved format. + pub fn emit_list(&self, rows: &[T], cursor: &str) -> Result<(), CliError> + where + T: tabled::Tabled + serde::Serialize, + { + crate::output::render_list( + rows, + cursor, + self.format, + self.pretty_json(), + None, + &mut std::io::stdout().lock(), + ) + .map_err(CliError::Io) + } +} + +/// Free-function form of [`Ctx::require_client`] so callers that also need +/// to mutably borrow `Ctx::resolver` can split the borrow at the call site: +/// `let client = require_client(&ctx.client, &ctx.workspace_name)?;`. +pub fn require_client<'a>( + client: &'a Option, + workspace_name: &str, +) -> Result<&'a SlackClient, CliError> { + client.as_ref().ok_or_else(|| CliError::NoToken { + workspace: workspace_name.to_owned(), + }) +} + +/// Top-level command router. Loads config, picks workspace (when required), +/// constructs the Slack client, then dispatches to the chosen subcommand. +pub async fn dispatch(cli: Cli) -> Result<(), CliError> { + let path = config::resolve_path(cli.globals.config.as_deref())?; + let cfg = config::load(&path)?; + + // `auth` and `completion` work without an existing workspace / token. + let need_client = !matches!(cli.command, TopCommand::Auth(_) | TopCommand::Completion(_)); + + let (workspace_name, client) = if need_client { + let (name, ws) = config::pick_workspace(&cfg, cli.globals.workspace.as_deref())?; + let secret = crate::secret::load(name, ws)?; + let client = SlackClient::new(secret)?; + (name.to_owned(), Some(client)) + } else { + (cli.globals.workspace.clone().unwrap_or_default(), None) + }; + + let stdout_is_tty = std::io::stdout().is_terminal(); + let fmt = Format::resolve(cli.globals.format(), stdout_is_tty); + + let mut ctx = Ctx { + config: cfg, + config_path: path, + workspace_name, + client, + resolver: Resolver::new(), + format: fmt, + stdout_is_tty, + globals: cli.globals, + }; + + match cli.command { + TopCommand::Auth(args) => auth::run(args, &mut ctx).await, + TopCommand::Channel(args) => channel::run(args, &mut ctx).await, + TopCommand::Message(args) => message::run(args, &mut ctx).await, + TopCommand::Search(args) => search::run(args, &mut ctx).await, + TopCommand::User(args) => user::run(args, &mut ctx).await, + TopCommand::File(args) => file::run(args, &mut ctx).await, + TopCommand::Completion(args) => completion::run(args, &mut ctx).await, + } +} + +/// Shared helper: refuse a destructive op when not confirmed. +/// +/// In a TTY, prompts for `yes` and consumes one line from stdin. +/// Outside a TTY, requires `--yes` to be set; otherwise returns +/// `CliError::ConfirmationRequired`. +pub fn require_confirmation(ctx: &Ctx, prompt: &str) -> Result<(), CliError> { + use std::io::{BufRead, Write}; + + if ctx.globals.yes { + return Ok(()); + } + if !ctx.stdout_is_tty { + return Err(CliError::ConfirmationRequired); + } + let mut err = std::io::stderr().lock(); + write!(err, "{prompt} type 'yes' to continue: ")?; + err.flush()?; + let mut line = String::new(); + std::io::stdin().lock().read_line(&mut line)?; + if line.trim() == "yes" { + Ok(()) + } else { + Err(CliError::ConfirmationRequired) + } +} + +#[derive(serde::Serialize)] +struct DryRunData<'a> { + dry_run: bool, + would_call: &'a str, + endpoint: &'a str, + payload: serde_json::Value, +} + +/// Shared helper: emit a structured `dry_run` envelope on stdout and return. +pub fn emit_dry_run( + _ctx: &Ctx, + would_call: &str, + endpoint: &str, + payload: serde_json::Value, +) -> Result<(), CliError> { + use std::io::Write; + + let data = DryRunData { + dry_run: true, + would_call, + endpoint, + payload, + }; + let env = crate::output::OkEnvelope { + ok: true, + schema: crate::output::SCHEMA, + data, + request_id: None, + }; + let mut out = std::io::stdout().lock(); + serde_json::to_writer(&mut out, &env).map_err(std::io::Error::other)?; + writeln!(out)?; + Ok(()) +} diff --git a/src/cmd/search.rs b/src/cmd/search.rs new file mode 100644 index 0000000..d0dcf36 --- /dev/null +++ b/src/cmd/search.rs @@ -0,0 +1,86 @@ +//! `slack-cli search` subcommand handlers. + +use std::fmt::Write as _; + +use serde::Serialize; +use tabled::Tabled; + +use crate::cli::{SearchArgs, SearchVerb}; +use crate::cmd::{require_client, Ctx}; +use crate::error::CliError; + +#[derive(Debug, Serialize, Tabled)] +pub struct MessageHit { + pub ts: String, + pub user: String, + pub text: String, +} + +#[derive(Debug, Serialize, Tabled)] +pub struct FileHit { + pub id: String, + pub name: String, + pub size: u64, + pub mimetype: String, +} + +pub async fn run(args: SearchArgs, ctx: &mut Ctx) -> Result<(), CliError> { + let client = require_client(&ctx.client, &ctx.workspace_name)?; + + match args.verb { + SearchVerb::Messages { + query, + r#in, + from, + limit, + } => { + let q = compose_query(&query, r#in.as_deref(), from.as_deref()); + let resp = client.search_messages(&q, 1, limit.min(200)).await?; + let rows: Vec = resp + .messages + .matches + .into_iter() + .map(|m| MessageHit { + ts: m.ts, + user: m.user.unwrap_or_default(), + text: m.text, + }) + .collect(); + ctx.emit_list(&rows, "") + } + SearchVerb::Files { + query, + file_type, + limit, + } => { + let mut q = query; + if let Some(t) = file_type { + let _ = write!(q, " type:{t}"); + } + let resp = client.search_files(&q, 1, limit.min(200)).await?; + let rows: Vec = resp + .files + .matches + .into_iter() + .map(|f| FileHit { + id: f.id, + name: f.name, + size: f.size, + mimetype: f.mimetype, + }) + .collect(); + ctx.emit_list(&rows, "") + } + } +} + +fn compose_query(base: &str, in_chan: Option<&str>, from_user: Option<&str>) -> String { + let mut q = base.to_owned(); + if let Some(ch) = in_chan { + let _ = write!(q, " in:{ch}"); + } + if let Some(u) = from_user { + let _ = write!(q, " from:{u}"); + } + q +} diff --git a/src/cmd/user.rs b/src/cmd/user.rs new file mode 100644 index 0000000..1dded04 --- /dev/null +++ b/src/cmd/user.rs @@ -0,0 +1,55 @@ +//! `slack-cli user` subcommand handlers. + +use serde::Serialize; +use tabled::Tabled; + +use crate::cli::{UserArgs, UserVerb}; +use crate::cmd::{require_client, Ctx}; +use crate::error::CliError; +use crate::id::UserRef; + +#[derive(Debug, Serialize, Tabled)] +pub struct UserRow { + pub id: String, + pub name: String, + pub real_name: String, +} + +impl From for UserRow { + fn from(u: crate::slack::UserDto) -> Self { + Self { + id: u.id, + name: u.name, + real_name: u.real_name.unwrap_or_default(), + } + } +} + +pub async fn run(args: UserArgs, ctx: &mut Ctx) -> Result<(), CliError> { + let client = require_client(&ctx.client, &ctx.workspace_name)?; + + match args.verb { + UserVerb::List { query, limit } => { + let page = client.users_list(limit.min(200), None).await?; + let rows: Vec = page + .members + .into_iter() + .filter(|u| !u.deleted) + .filter(|u| query.as_deref().is_none_or(|q| u.name.contains(q))) + .map(UserRow::from) + .collect(); + let cursor = page + .response_metadata + .and_then(|m| m.next_cursor) + .unwrap_or_default(); + ctx.emit_list(&rows, &cursor) + } + UserVerb::View { user } => { + let uref = UserRef::try_from(user.as_str())?; + let id = ctx.resolver.user(client, &uref).await?; + let info = client.users_info(id.as_str()).await?; + let row = UserRow::from(info.user); + ctx.emit_one(&row) + } + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..edd8a1e --- /dev/null +++ b/src/config.rs @@ -0,0 +1,215 @@ +//! Configuration file I/O. + +use std::collections::BTreeMap; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use etcetera::{choose_base_strategy, BaseStrategy}; +use serde::{Deserialize, Serialize}; + +use crate::error::CliError; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct Config { + #[serde(default)] + pub default_workspace: Option, + #[serde(default)] + pub output: OutputConfig, + #[serde(default)] + pub workspaces: BTreeMap, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct OutputConfig { + #[serde(default = "default_format")] + pub format: String, + #[serde(default = "default_color")] + pub color: String, + #[serde(default)] + pub utc: bool, +} + +impl Default for OutputConfig { + fn default() -> Self { + Self { + format: default_format(), + color: default_color(), + utc: false, + } + } +} + +fn default_format() -> String { + "auto".into() +} +fn default_color() -> String { + "auto".into() +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct WorkspaceConfig { + pub team_id: Option, + pub team_domain: Option, + pub token_source: TokenSource, + pub token: Option, + pub token_env: Option, + #[serde(default)] + pub meta: WorkspaceMeta, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum TokenSource { + Keyring, + File, + Env, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct WorkspaceMeta { + pub token_kind: Option, + pub user_id: Option, + pub updated_at: Option, +} + +pub fn resolve_path(cli: Option<&Path>) -> Result { + if let Some(p) = cli { + return Ok(p.to_path_buf()); + } + if let Ok(envp) = std::env::var("SLACK_CLI_CONFIG") { + return Ok(PathBuf::from(envp)); + } + let strat = choose_base_strategy().map_err(|e| CliError::Config(e.to_string()))?; + Ok(strat.config_dir().join("slack-cli").join("config.toml")) +} + +pub fn load(path: &Path) -> Result { + let raw = match std::fs::read_to_string(path) { + Ok(s) => s, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Config::default()), + Err(e) => return Err(e.into()), + }; + toml_edit::de::from_str(&raw).map_err(|e| CliError::Config(e.to_string())) +} + +pub fn save(path: &Path, cfg: &Config) -> Result<(), CliError> { + let parent = path + .parent() + .ok_or_else(|| CliError::Config("path has no parent".into()))?; + std::fs::create_dir_all(parent)?; + + let serialized = + toml_edit::ser::to_string_pretty(cfg).map_err(|e| CliError::Config(e.to_string()))?; + + let mut tmp = tempfile::NamedTempFile::new_in(parent)?; + tmp.write_all(serialized.as_bytes())?; + tmp.as_file().sync_all()?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o600))?; + } + + tmp.persist(path).map_err(|e| CliError::Io(e.error))?; + Ok(()) +} + +/// Pick the workspace to use given CLI/env/default precedence. +pub fn pick_workspace<'a>( + cfg: &'a Config, + cli: Option<&str>, +) -> Result<(&'a str, &'a WorkspaceConfig), CliError> { + let env_ws = std::env::var("SLACK_CLI_WORKSPACE").ok(); + let name = cli + .map(str::to_owned) + .or(env_ws) + .or_else(|| cfg.default_workspace.clone()) + .or_else(|| { + if cfg.workspaces.len() == 1 { + cfg.workspaces.keys().next().cloned() + } else { + None + } + }); + + let name = name.ok_or_else(|| CliError::AmbiguousWorkspace { + available: cfg.workspaces.keys().cloned().collect(), + })?; + let (key, ws) = + cfg.workspaces + .get_key_value(&name) + .ok_or_else(|| CliError::AmbiguousWorkspace { + available: cfg.workspaces.keys().cloned().collect(), + })?; + Ok((key.as_str(), ws)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_file_returns_default() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("config.toml"); + let cfg = load(&p).unwrap(); + assert!(cfg.default_workspace.is_none()); + } + + #[test] + fn save_then_load_roundtrips() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("a").join("b").join("config.toml"); + let mut cfg = Config { + default_workspace: Some("acme".into()), + ..Default::default() + }; + cfg.workspaces.insert( + "acme".into(), + WorkspaceConfig { + team_id: Some("T1".into()), + team_domain: Some("acme".into()), + token_source: TokenSource::Keyring, + token: None, + token_env: None, + meta: WorkspaceMeta::default(), + }, + ); + save(&p, &cfg).unwrap(); + let loaded = load(&p).unwrap(); + assert_eq!(loaded.default_workspace.as_deref(), Some("acme")); + } + + #[cfg(unix)] + #[test] + fn save_sets_0600() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("config.toml"); + save(&p, &Config::default()).unwrap(); + let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } + + #[test] + fn pick_workspace_uses_default() { + let mut cfg = Config { + default_workspace: Some("acme".into()), + ..Default::default() + }; + cfg.workspaces.insert( + "acme".into(), + WorkspaceConfig { + team_id: None, + team_domain: None, + token_source: TokenSource::Keyring, + token: None, + token_env: None, + meta: WorkspaceMeta::default(), + }, + ); + let (name, _) = pick_workspace(&cfg, None).unwrap(); + assert_eq!(name, "acme"); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..3d4dc96 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,173 @@ +//! CLI-wide error type. All commands return `Result`. + +use std::io; + +/// The kind of Slack resource a name resolution targets. +/// +/// Used by `CliError::NotFound` and `CliError::Ambiguous` to give callers +/// (especially the JSON output hint mapper) a typed view of which lookup +/// failed without relying on string comparisons. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ResourceKind { + Channel, + User, + File, + Message, +} + +impl ResourceKind { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Channel => "channel", + Self::User => "user", + Self::File => "file", + Self::Message => "message", + } + } +} + +impl std::fmt::Display for ResourceKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(thiserror::Error, Debug)] +#[non_exhaustive] +pub enum CliError { + #[error("no token configured for workspace '{workspace}'")] + NoToken { workspace: String }, + + #[error("ambiguous workspace: specify --workspace (available: {})", available.join(", "))] + AmbiguousWorkspace { available: Vec }, + + #[error("token kind mismatch: '{api}' requires {needed} token, got {actual}")] + TokenKindMismatch { + needed: &'static str, + actual: &'static str, + api: &'static str, + }, + + #[error("{kind} '{name}' not found")] + NotFound { kind: ResourceKind, name: String }, + + #[error("ambiguous {kind} reference '{name}' ({} candidates)", candidates.len())] + Ambiguous { + kind: ResourceKind, + name: String, + candidates: Vec, + }, + + #[error("slack api error: {code}")] + SlackApi { + code: String, + request_id: Option, + }, + + #[error("rate limited, retry after {retry_after_secs}s")] + RateLimited { retry_after_secs: u64 }, + + #[error("authentication failed: {reason}")] + AuthFailed { reason: String }, + + #[error("invalid argument: {0}")] + InvalidArg(String), + + #[error("confirmation required: pass --yes for non-interactive use")] + ConfirmationRequired, + + #[error("file token backend is not supported on this platform")] + FileBackendUnsupported, + + #[error("config error: {0}")] + Config(String), + + #[error("io error: {0}")] + Io(#[from] io::Error), + + #[error("network error: {0}")] + Network(String), +} + +impl CliError { + #[must_use] + pub fn exit_code(&self) -> u8 { + match self { + Self::NoToken { .. } | Self::AuthFailed { .. } => 3, + Self::AmbiguousWorkspace { .. } | Self::Config(_) | Self::FileBackendUnsupported => 2, + Self::RateLimited { .. } => 4, + Self::NotFound { .. } | Self::Ambiguous { .. } => 5, + Self::TokenKindMismatch { .. } => 6, + Self::InvalidArg(_) | Self::ConfirmationRequired => 64, + _ => 1, + } + } + + #[must_use] + pub fn code(&self) -> &'static str { + match self { + Self::NoToken { .. } => "no_token", + Self::AmbiguousWorkspace { .. } => "workspace_ambiguous", + Self::TokenKindMismatch { .. } => "token_kind_mismatch", + Self::NotFound { .. } => "not_found", + Self::Ambiguous { .. } => "ambiguous", + Self::SlackApi { .. } => "slack_api", + Self::RateLimited { .. } => "rate_limited", + Self::AuthFailed { .. } => "auth_failed", + Self::InvalidArg(_) => "invalid_arg", + Self::ConfirmationRequired => "confirmation_required", + Self::FileBackendUnsupported => "file_backend_unsupported", + Self::Config(_) => "config", + Self::Io(_) => "io", + Self::Network(_) => "network", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exit_code_auth_failed_is_3() { + assert_eq!(CliError::AuthFailed { reason: "x".into() }.exit_code(), 3); + } + #[test] + fn exit_code_not_found_is_5() { + assert_eq!( + CliError::NotFound { + kind: ResourceKind::Channel, + name: "x".into() + } + .exit_code(), + 5 + ); + } + #[test] + fn exit_code_token_mismatch_is_6() { + assert_eq!( + CliError::TokenKindMismatch { + needed: "user", + actual: "bot", + api: "search.messages" + } + .exit_code(), + 6, + ); + } + #[test] + fn exit_code_invalid_arg_is_64() { + assert_eq!(CliError::InvalidArg("x".into()).exit_code(), 64); + } + #[test] + fn display_lowercase_no_period() { + let s = CliError::NoToken { + workspace: "acme".into(), + } + .to_string(); + assert!(s.starts_with(char::is_lowercase)); + assert!(!s.ends_with('.')); + } +} diff --git a/src/id.rs b/src/id.rs new file mode 100644 index 0000000..7baf385 --- /dev/null +++ b/src/id.rs @@ -0,0 +1,140 @@ +//! Strongly-typed Slack identifiers. + +use std::fmt; +use std::sync::LazyLock; + +use regex::Regex; +use serde::{Deserialize, Serialize}; + +use crate::error::CliError; + +static ID_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^[CGDUW][A-Z0-9]{8,}$").expect("valid regex")); + +macro_rules! string_id { + ($name:ident, $doc:literal) => { + #[doc = $doc] + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] + pub struct $name(pub(crate) String); + + impl $name { + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } + } + }; +} + +string_id!(ChannelId, "Slack channel id (`Cxxxx`, `Gxxxx`, `Dxxxx`)."); +string_id!(UserId, "Slack user id (`Uxxxx`, `Wxxxx`)."); +string_id!(Ts, "Slack message timestamp."); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChannelRef { + Name(String), + Id(ChannelId), +} + +impl TryFrom<&str> for ChannelRef { + type Error = CliError; + fn try_from(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(CliError::InvalidArg("empty channel reference".into())); + } + let body = trimmed.strip_prefix('#').unwrap_or(trimmed); + if body.is_empty() { + return Err(CliError::InvalidArg("channel reference is only '#'".into())); + } + if body.starts_with(['C', 'G', 'D']) && ID_RE.is_match(body) { + Ok(Self::Id(ChannelId(body.to_owned()))) + } else { + Ok(Self::Name(body.to_owned())) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UserRef { + Name(String), + Id(UserId), +} + +impl TryFrom<&str> for UserRef { + type Error = CliError; + fn try_from(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(CliError::InvalidArg("empty user reference".into())); + } + let body = trimmed.strip_prefix('@').unwrap_or(trimmed); + if body.is_empty() { + return Err(CliError::InvalidArg("user reference is only '@'".into())); + } + if body.starts_with(['U', 'W']) && ID_RE.is_match(body) { + Ok(Self::Id(UserId(body.to_owned()))) + } else { + Ok(Self::Name(body.to_owned())) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_hash_name() { + assert_eq!( + ChannelRef::try_from("#general").unwrap(), + ChannelRef::Name("general".into()) + ); + } + #[test] + fn parses_bare_name() { + assert_eq!( + ChannelRef::try_from("general").unwrap(), + ChannelRef::Name("general".into()) + ); + } + #[test] + fn parses_id() { + assert_eq!( + ChannelRef::try_from("C012ABCD9").unwrap(), + ChannelRef::Id(ChannelId("C012ABCD9".into())), + ); + } + #[test] + fn rejects_empty() { + assert!(ChannelRef::try_from("").is_err()); + assert!(ChannelRef::try_from("#").is_err()); + } + #[test] + fn user_ref_at_name() { + assert_eq!( + UserRef::try_from("@alice").unwrap(), + UserRef::Name("alice".into()) + ); + } + #[test] + fn user_ref_enterprise() { + assert_eq!( + UserRef::try_from("W0123ABCD").unwrap(), + UserRef::Id(UserId("W0123ABCD".into())) + ); + } + #[test] + fn short_id_lookalike_is_name() { + assert_eq!( + ChannelRef::try_from("C123").unwrap(), + ChannelRef::Name("C123".into()) + ); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..8ed2dd0 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,23 @@ +//! Internal library facade for `slack-cli`. +//! +//! Exposed only so integration tests under `tests/` can reach +//! the same modules the binary uses. Not a stable public API. + +#![warn(missing_debug_implementations, rust_2018_idioms, unreachable_pub)] +#![warn(clippy::pedantic)] +#![allow( + clippy::module_name_repetitions, + clippy::missing_errors_doc, + clippy::missing_panics_doc +)] + +pub mod cli; +pub mod cmd; +pub mod config; +pub mod error; +pub mod id; +pub mod logging; +pub mod output; +pub mod resolve; +pub mod secret; +pub mod slack; diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..890ac4c --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,26 @@ +//! Tracing initialization. Always writes to stderr. + +use tracing_subscriber::{fmt, EnvFilter}; + +/// Initialize the global tracing subscriber. +/// +/// `verbose` is the count of `-v` flags (0..=3). `quiet` overrides everything +/// to error-level only. Honors `RUST_LOG` when set explicitly. +pub fn init(verbose: u8, quiet: bool) { + let directive = if quiet { + "error".to_owned() + } else { + match verbose { + 0 | 1 => "slack_cli=info,reqwest=warn,hyper=warn".into(), + 2 => "slack_cli=debug,reqwest=warn,hyper=warn".into(), + _ => "slack_cli=trace,reqwest=warn,hyper=warn".into(), + } + }; + let env = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(directive)); + let _ = fmt() + .with_env_filter(env) + .with_writer(std::io::stderr) + .with_target(false) + .with_level(true) + .try_init(); +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..9c0647f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,25 @@ +//! Binary entry point for `slack-cli`. + +use std::process::ExitCode; + +use clap::Parser; + +use slack_cli::cli::Cli; +use slack_cli::cmd::dispatch; +use slack_cli::logging; +use slack_cli::output::{render_error, Format}; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> ExitCode { + let cli = Cli::parse(); + logging::init(cli.globals.verbose, cli.globals.quiet); + + match dispatch(cli).await { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + let json_mode = matches!(Format::detect_default(), Format::Json); + let _ = render_error(&e, json_mode, &mut std::io::stderr().lock()); + ExitCode::from(e.exit_code()) + } + } +} diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..69d3152 --- /dev/null +++ b/src/output.rs @@ -0,0 +1,285 @@ +//! Output formatting and the stable `slack-cli/v0` JSON envelope. + +use std::io::{self, IsTerminal, Write}; + +use serde::Serialize; + +use crate::error::{CliError, ResourceKind}; + +pub const SCHEMA: &str = "slack-cli/v0"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +#[non_exhaustive] +pub enum Format { + Table, + Plain, + Json, +} + +impl Format { + #[must_use] + pub fn resolve(flag: Option, stdout_is_tty: bool) -> Self { + match (flag, stdout_is_tty) { + (Some(f), _) => f, + (None, true) => Format::Table, + (None, false) => Format::Json, + } + } + #[must_use] + pub fn detect_default() -> Self { + Self::resolve(None, io::stdout().is_terminal()) + } +} + +#[derive(Debug, Serialize)] +pub struct OkEnvelope<'a, T: Serialize> { + pub ok: bool, + pub schema: &'a str, + pub data: T, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, +} + +#[derive(Debug, Serialize)] +pub struct ListPayload<'a, T: Serialize> { + pub items: &'a [T], + pub next_cursor: String, +} + +pub fn render_one( + item: &T, + fmt: Format, + pretty_json: bool, + request_id: Option, + w: &mut dyn Write, +) -> io::Result<()> +where + T: tabled::Tabled + Serialize, +{ + match fmt { + Format::Table => { + let table = tabled::Table::new([item]).to_string(); + writeln!(w, "{table}") + } + Format::Plain => { + let fields = T::fields(item); + let line = fields + .iter() + .map(std::convert::AsRef::as_ref) + .collect::>() + .join("\t"); + writeln!(w, "{line}") + } + Format::Json => { + let env = OkEnvelope { + ok: true, + schema: SCHEMA, + data: item, + request_id, + }; + write_json(w, &env, pretty_json) + } + } +} + +pub fn render_list( + items: &[T], + next_cursor: &str, + fmt: Format, + pretty_json: bool, + request_id: Option, + w: &mut dyn Write, +) -> io::Result<()> +where + T: tabled::Tabled + Serialize, +{ + match fmt { + Format::Table => { + let table = tabled::Table::new(items).to_string(); + writeln!(w, "{table}") + } + Format::Plain => { + for item in items { + let fields = T::fields(item); + let line = fields + .iter() + .map(std::convert::AsRef::as_ref) + .collect::>() + .join("\t"); + writeln!(w, "{line}")?; + } + Ok(()) + } + Format::Json => { + let payload = ListPayload { + items, + next_cursor: next_cursor.to_owned(), + }; + let env = OkEnvelope { + ok: true, + schema: SCHEMA, + data: payload, + request_id, + }; + write_json(w, &env, pretty_json) + } + } +} + +fn write_json(w: &mut dyn Write, v: &T, pretty: bool) -> io::Result<()> { + if pretty { + serde_json::to_writer_pretty(&mut *w, v).map_err(io::Error::other)?; + } else { + serde_json::to_writer(&mut *w, v).map_err(io::Error::other)?; + } + writeln!(w) +} + +#[derive(Debug, Serialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case", tag = "action")] +pub enum Hint { + Run { command: String }, + Specify { flag: String }, + List { command: String }, +} + +#[derive(Debug, Serialize)] +pub struct ErrEnvelope<'a> { + pub ok: bool, + pub schema: &'a str, + pub error: ErrPayload, +} + +#[derive(Debug, Serialize)] +pub struct ErrPayload { + pub code: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub hint: Option, + pub exit_code: u8, + #[serde(skip_serializing_if = "serde_json::Value::is_null")] + pub details: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, +} + +#[must_use] +pub fn hint_for_error(err: &CliError) -> Option { + match err { + CliError::NoToken { .. } => Some(Hint::Run { + command: "slack-cli auth init".into(), + }), + CliError::AmbiguousWorkspace { .. } => Some(Hint::Specify { + flag: "--workspace".into(), + }), + CliError::NotFound { + kind: ResourceKind::Channel, + .. + } + | CliError::Ambiguous { + kind: ResourceKind::Channel, + .. + } => Some(Hint::List { + command: "slack-cli channel list".into(), + }), + CliError::NotFound { + kind: ResourceKind::User, + .. + } + | CliError::Ambiguous { + kind: ResourceKind::User, + .. + } => Some(Hint::List { + command: "slack-cli user list".into(), + }), + _ => None, + } +} + +pub fn render_error(err: &CliError, json: bool, w: &mut dyn Write) -> io::Result<()> { + if json { + let payload = ErrPayload { + code: err.code().to_owned(), + message: err.to_string(), + hint: hint_for_error(err), + exit_code: err.exit_code(), + details: serde_json::Value::Null, + request_id: None, + }; + let env = ErrEnvelope { + ok: false, + schema: SCHEMA, + error: payload, + }; + write_json(w, &env, false) + } else { + writeln!(w, "error: {err}")?; + if let Some(h) = hint_for_error(err) { + match h { + Hint::Run { command } => writeln!(w, "hint: run `{command}`")?, + Hint::Specify { flag } => writeln!(w, "hint: specify `{flag}`")?, + Hint::List { command } => writeln!(w, "hint: list with `{command}`")?, + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Serialize; + use tabled::Tabled; + + #[derive(Tabled, Serialize)] + struct Row { + id: &'static str, + name: &'static str, + } + + #[test] + fn resolve_explicit() { + assert_eq!(Format::resolve(Some(Format::Plain), true), Format::Plain); + } + #[test] + fn resolve_tty() { + assert_eq!(Format::resolve(None, true), Format::Table); + } + #[test] + fn resolve_pipe() { + assert_eq!(Format::resolve(None, false), Format::Json); + } + #[test] + fn one_json_envelope() { + let row = Row { + id: "C1", + name: "general", + }; + let mut buf = Vec::new(); + render_one(&row, Format::Json, false, None, &mut buf).unwrap(); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("\"ok\":true")); + assert!(s.contains("\"schema\":\"slack-cli/v0\"")); + assert!(s.contains("\"id\":\"C1\"")); + } + #[test] + fn list_json_has_cursor() { + let rows = [Row { + id: "C1", + name: "general", + }]; + let mut buf = Vec::new(); + render_list(&rows, "next", Format::Json, false, None, &mut buf).unwrap(); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("\"items\"")); + assert!(s.contains("\"next_cursor\":\"next\"")); + } + #[test] + fn hint_run_for_no_token() { + let h = hint_for_error(&CliError::NoToken { + workspace: "acme".into(), + }) + .unwrap(); + assert!(matches!(h, Hint::Run { .. })); + } +} diff --git a/src/resolve.rs b/src/resolve.rs new file mode 100644 index 0000000..3ad69a6 --- /dev/null +++ b/src/resolve.rs @@ -0,0 +1,298 @@ +//! Name → ID resolution with a 60 s in-process cache. +//! +//! Slack channels and users are addressed by opaque IDs (`Cxxx` / `Uxxx`), +//! but the CLI lets humans type `#general` or `@alice`. `Resolver` keeps a +//! per-process cache so repeated lookups in a single invocation don't +//! re-page through `conversations.list` / `users.list`. + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use crate::error::{CliError, ResourceKind}; +use crate::id::{ChannelId, ChannelRef, UserId, UserRef}; +use crate::slack::SlackClient; + +const TTL: Duration = Duration::from_secs(60); +const PAGE_LIMIT: u32 = 200; + +#[derive(Debug, Default)] +pub struct Resolver { + channels: HashMap, + users: HashMap, +} + +impl Resolver { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + pub async fn channel( + &mut self, + client: &SlackClient, + r: &ChannelRef, + ) -> Result { + match r { + ChannelRef::Id(id) => Ok(id.clone()), + ChannelRef::Name(name) => { + if let Some((id, ts)) = self.channels.get(name) { + if ts.elapsed() < TTL { + return Ok(id.clone()); + } + } + let id = lookup_channel(client, name).await?; + self.channels + .insert(name.clone(), (id.clone(), Instant::now())); + Ok(id) + } + } + } + + pub async fn user(&mut self, client: &SlackClient, r: &UserRef) -> Result { + match r { + UserRef::Id(id) => Ok(id.clone()), + UserRef::Name(name) => { + if let Some((id, ts)) = self.users.get(name) { + if ts.elapsed() < TTL { + return Ok(id.clone()); + } + } + let id = lookup_user(client, name).await?; + self.users + .insert(name.clone(), (id.clone(), Instant::now())); + Ok(id) + } + } + } +} + +async fn lookup_channel(client: &SlackClient, name: &str) -> Result { + let mut cursor: Option = None; + let mut matches: Vec<(String, bool)> = Vec::new(); + loop { + let page = client + .conversations_list( + "public_channel,private_channel", + PAGE_LIMIT, + cursor.as_deref(), + ) + .await?; + for ch in &page.channels { + if ch.name == name { + matches.push((ch.id.clone(), ch.is_private)); + } + } + cursor = page + .response_metadata + .and_then(|m| m.next_cursor) + .filter(|s| !s.is_empty()); + if cursor.is_none() { + break; + } + } + match matches.len() { + 0 => Err(CliError::NotFound { + kind: ResourceKind::Channel, + name: name.into(), + }), + 1 => Ok(ChannelId(matches.remove(0).0)), + _ => Err(CliError::Ambiguous { + kind: ResourceKind::Channel, + name: name.into(), + candidates: matches + .into_iter() + .map(|(id, is_private)| { + format!("{id} ({})", if is_private { "private" } else { "public" }) + }) + .collect(), + }), + } +} + +async fn lookup_user(client: &SlackClient, name: &str) -> Result { + let mut cursor: Option = None; + let mut matches: Vec = Vec::new(); + loop { + let page = client.users_list(PAGE_LIMIT, cursor.as_deref()).await?; + for u in &page.members { + if u.name == name && !u.deleted { + matches.push(u.id.clone()); + } + } + cursor = page + .response_metadata + .and_then(|m| m.next_cursor) + .filter(|s| !s.is_empty()); + if cursor.is_none() { + break; + } + } + match matches.len() { + 0 => Err(CliError::NotFound { + kind: ResourceKind::User, + name: name.into(), + }), + 1 => Ok(UserId(matches.remove(0))), + _ => Err(CliError::Ambiguous { + kind: ResourceKind::User, + name: name.into(), + candidates: matches, + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use secrecy::SecretString; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[tokio::test(flavor = "current_thread")] + async fn channel_id_passthrough_no_http_call() { + // No mock mounted; if HTTP fires, the test fails on connection refused. + let server = MockServer::start().await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let mut r = Resolver::new(); + let id = r + .channel(&c, &ChannelRef::Id(ChannelId("C0".into()))) + .await + .unwrap(); + assert_eq!(id.as_str(), "C0"); + } + + #[tokio::test(flavor = "current_thread")] + async fn channel_name_resolves_via_list() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/conversations.list")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"ok":true,"channels":[{"id":"C1","name":"general","is_private":false,"is_archived":false,"num_members":1,"topic":{"value":""},"purpose":{"value":""}}],"response_metadata":{"next_cursor":""}}"#)) + .mount(&server).await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let mut r = Resolver::new(); + let id = r + .channel(&c, &ChannelRef::Name("general".into())) + .await + .unwrap(); + assert_eq!(id.as_str(), "C1"); + } + + #[tokio::test(flavor = "current_thread")] + async fn channel_name_cache_hit_skips_http() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/conversations.list")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"ok":true,"channels":[{"id":"C1","name":"general","is_private":false,"is_archived":false,"num_members":1,"topic":{"value":""},"purpose":{"value":""}}],"response_metadata":{"next_cursor":""}}"#)) + .expect(1) + .mount(&server).await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let mut r = Resolver::new(); + let id1 = r + .channel(&c, &ChannelRef::Name("general".into())) + .await + .unwrap(); + let id2 = r + .channel(&c, &ChannelRef::Name("general".into())) + .await + .unwrap(); + assert_eq!(id1.as_str(), id2.as_str()); + // The Mock::expect(1) assertion verifies cache hit; verified on drop. + } + + #[tokio::test(flavor = "current_thread")] + async fn channel_name_not_found() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/conversations.list")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"ok":true,"channels":[],"response_metadata":{"next_cursor":""}}"#, + )) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let mut r = Resolver::new(); + let err = r + .channel(&c, &ChannelRef::Name("nope".into())) + .await + .unwrap_err(); + assert!(matches!( + err, + CliError::NotFound { + kind: ResourceKind::Channel, + .. + } + )); + } + + #[tokio::test(flavor = "current_thread")] + async fn channel_name_ambiguous() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/conversations.list")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"ok":true,"channels":[ + {"id":"C1","name":"general","is_private":false,"is_archived":false,"num_members":1,"topic":{"value":""},"purpose":{"value":""}}, + {"id":"C2","name":"general","is_private":true, "is_archived":false,"num_members":1,"topic":{"value":""},"purpose":{"value":""}} + ],"response_metadata":{"next_cursor":""}}"#)) + .mount(&server).await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let mut r = Resolver::new(); + let err = r + .channel(&c, &ChannelRef::Name("general".into())) + .await + .unwrap_err(); + match err { + CliError::Ambiguous { + kind, candidates, .. + } => { + assert_eq!(kind, ResourceKind::Channel); + assert_eq!(candidates.len(), 2); + } + _ => panic!("expected Ambiguous"), + } + } + + #[tokio::test(flavor = "current_thread")] + async fn user_id_passthrough_no_http_call() { + let server = MockServer::start().await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let mut r = Resolver::new(); + let id = r.user(&c, &UserRef::Id(UserId("U0".into()))).await.unwrap(); + assert_eq!(id.as_str(), "U0"); + } + + #[tokio::test(flavor = "current_thread")] + async fn user_name_resolves_via_list() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/users.list")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"ok":true,"members":[{"id":"U1","name":"alice","real_name":"A","deleted":false,"is_bot":false}],"response_metadata":{"next_cursor":""}}"#)) + .mount(&server).await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let mut r = Resolver::new(); + let id = r.user(&c, &UserRef::Name("alice".into())).await.unwrap(); + assert_eq!(id.as_str(), "U1"); + } + + #[tokio::test(flavor = "current_thread")] + async fn user_name_skips_deleted() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/users.list")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"ok":true,"members":[ + {"id":"U1","name":"alice","real_name":"A","deleted":true, "is_bot":false}, + {"id":"U2","name":"alice","real_name":"A","deleted":false,"is_bot":false} + ],"response_metadata":{"next_cursor":""}}"#, + )) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let mut r = Resolver::new(); + let id = r.user(&c, &UserRef::Name("alice".into())).await.unwrap(); + assert_eq!(id.as_str(), "U2"); + } +} diff --git a/src/secret.rs b/src/secret.rs new file mode 100644 index 0000000..e05339c --- /dev/null +++ b/src/secret.rs @@ -0,0 +1,163 @@ +//! Token storage with three backends: keyring (OS), file (config.toml), env. + +use secrecy::{ExposeSecret, SecretString}; + +use crate::config::{TokenSource, WorkspaceConfig}; +use crate::error::CliError; + +pub fn validate_prefix(raw: &str) -> Result<(), CliError> { + if raw.starts_with("xoxp-") || raw.starts_with("xoxb-") { + Ok(()) + } else { + Err(CliError::InvalidArg( + "token must start with 'xoxp-' or 'xoxb-'".into(), + )) + } +} + +pub fn load(workspace_name: &str, ws: &WorkspaceConfig) -> Result { + if let Ok(t) = std::env::var("SLACK_CLI_TOKEN") { + validate_prefix(&t)?; + return Ok(SecretString::from(t)); + } + match ws.token_source { + TokenSource::Env => { + let name = ws.token_env.as_deref().ok_or_else(|| { + CliError::Config(format!("workspace '{workspace_name}' has no token_env")) + })?; + let t = std::env::var(name).map_err(|_| CliError::NoToken { + workspace: workspace_name.into(), + })?; + validate_prefix(&t)?; + Ok(SecretString::from(t)) + } + TokenSource::File => { + #[cfg(target_os = "windows")] + { + let _ = workspace_name; + let _ = ws; + Err(CliError::FileBackendUnsupported) + } + #[cfg(not(target_os = "windows"))] + { + let t = ws.token.as_deref().ok_or_else(|| CliError::NoToken { + workspace: workspace_name.into(), + })?; + validate_prefix(t)?; + Ok(SecretString::from(t.to_owned())) + } + } + TokenSource::Keyring => keyring_load(workspace_name), + } +} + +pub fn save( + workspace_name: &str, + ws: &mut WorkspaceConfig, + token: &SecretString, +) -> Result<(), CliError> { + validate_prefix(token.expose_secret())?; + match ws.token_source { + TokenSource::Env => Ok(()), + TokenSource::File => { + #[cfg(target_os = "windows")] + { + let _ = workspace_name; + let _ = ws; + Err(CliError::FileBackendUnsupported) + } + #[cfg(not(target_os = "windows"))] + { + let _ = workspace_name; + ws.token = Some(token.expose_secret().to_owned()); + Ok(()) + } + } + TokenSource::Keyring => keyring_save(workspace_name, token), + } +} + +pub fn delete(workspace_name: &str, ws: &mut WorkspaceConfig) -> Result<(), CliError> { + match ws.token_source { + TokenSource::Env => Ok(()), + TokenSource::File => { + let _ = workspace_name; + ws.token = None; + Ok(()) + } + TokenSource::Keyring => keyring_delete(workspace_name), + } +} + +fn keyring_entry(workspace_name: &str) -> Result { + keyring::Entry::new("slack-cli", workspace_name) + .map_err(|e| CliError::Config(format!("keyring init failed: {e}"))) +} + +fn keyring_load(workspace_name: &str) -> Result { + let entry = keyring_entry(workspace_name)?; + match entry.get_password() { + Ok(t) => { + validate_prefix(&t)?; + Ok(SecretString::from(t)) + } + Err(keyring::Error::NoEntry) => Err(CliError::NoToken { + workspace: workspace_name.into(), + }), + Err(e) => { + tracing::warn!(error = %e, "keyring load failed"); + Err(CliError::Config(format!("keyring error: {e}"))) + } + } +} + +fn keyring_save(workspace_name: &str, token: &SecretString) -> Result<(), CliError> { + keyring_entry(workspace_name)? + .set_password(token.expose_secret()) + .map_err(|e| CliError::Config(format!("keyring error: {e}"))) +} + +fn keyring_delete(workspace_name: &str) -> Result<(), CliError> { + match keyring_entry(workspace_name)?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(CliError::Config(format!("keyring error: {e}"))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{TokenSource, WorkspaceConfig, WorkspaceMeta}; + + fn ws(src: TokenSource) -> WorkspaceConfig { + WorkspaceConfig { + team_id: None, + team_domain: None, + token_source: src, + token: None, + token_env: None, + meta: WorkspaceMeta::default(), + } + } + + #[test] + fn validate_accepts_xoxp_and_xoxb() { + assert!(validate_prefix("xoxp-1-2-3").is_ok()); + assert!(validate_prefix("xoxb-1-2-3").is_ok()); + } + + #[test] + fn validate_rejects_garbage() { + assert!(validate_prefix("hello").is_err()); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn file_save_then_load() { + let mut w = ws(TokenSource::File); + let secret = SecretString::from("xoxp-1-2-3".to_owned()); + save("acme", &mut w, &secret).unwrap(); + let loaded = load("acme", &w).unwrap(); + assert_eq!(loaded.expose_secret(), "xoxp-1-2-3"); + } +} diff --git a/src/slack.rs b/src/slack.rs new file mode 100644 index 0000000..a70a9dc --- /dev/null +++ b/src/slack.rs @@ -0,0 +1,1260 @@ +//! Thin Slack Web API client. No SDK; direct `reqwest` calls. +//! +//! The client exposes one method per Slack API endpoint we use. Errors are +//! normalized into `CliError` variants so callers never see raw Slack codes. + +use std::time::Duration; + +use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION}; +use secrecy::{ExposeSecret, SecretString}; +use serde::{Deserialize, Serialize}; +use tracing::instrument; + +use crate::error::{CliError, ResourceKind}; + +const DEFAULT_BASE_URL: &str = "https://slack.com"; + +/// HTTP client for the Slack Web API. +/// +/// All public methods deserialize the response with `parse_slack`, which +/// translates `ok: false` payloads into `CliError` variants. +#[derive(Debug)] +pub struct SlackClient { + pub(crate) base_url: String, + http: reqwest::Client, +} + +impl SlackClient { + /// Create a client targeting `https://slack.com`. Honors the + /// `SLACK_CLI_BASE_URL_OVERRIDE` env var so integration tests can swap + /// the target without recompiling. + pub fn new(token: SecretString) -> Result { + let base = std::env::var("SLACK_CLI_BASE_URL_OVERRIDE") + .unwrap_or_else(|_| DEFAULT_BASE_URL.into()); + Self::build(base, token) + } + + /// Create a client targeting an explicit base URL (used by tests). + pub fn with_base_url(base_url: String, token: SecretString) -> Result { + Self::build(base_url, token) + } + + fn build(base_url: String, token: SecretString) -> Result { + let mut auth = HeaderValue::from_str(&format!("Bearer {}", token.expose_secret())) + .map_err(|e| CliError::Config(format!("bad token: {e}")))?; + auth.set_sensitive(true); + // `HeaderValue` owns its bytes, so the `SecretString` can drop here + // and `Authorization` continues to flow with every request. + drop(token); + let mut headers = HeaderMap::new(); + headers.insert(AUTHORIZATION, auth); + let http = reqwest::Client::builder() + .default_headers(headers) + .user_agent(concat!("slack-cli/", env!("CARGO_PKG_VERSION"))) + .timeout(Duration::from_secs(30)) + .build() + .map_err(|e| CliError::Network(e.to_string()))?; + Ok(Self { base_url, http }) + } + + /// Slack `auth.test`. Returns the authenticated team / user / bot info. + #[instrument(skip(self))] + pub async fn auth_test(&self) -> Result { + let url = format!("{}/api/auth.test", self.base_url); + let resp = self.send_with_retry(self.http.post(&url)).await?; + parse_slack::(resp).await + } + + /// Send a built request with at most one retry on HTTP 429. The + /// `Retry-After` value is clamped to 60 s; no exponential backoff + /// (caller can re-invoke). The body must be clonable via + /// `RequestBuilder::try_clone` (i.e., in-memory, not a stream). + async fn send_with_retry( + &self, + req: reqwest::RequestBuilder, + ) -> Result { + let clone = req + .try_clone() + .expect("RequestBuilder is cloneable when body is in-memory"); + let first = req + .send() + .await + .map_err(|e| CliError::Network(e.to_string()))?; + if first.status().as_u16() != 429 { + return Ok(first); + } + let wait = first + .headers() + .get(reqwest::header::RETRY_AFTER) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(1) + .min(60); + tokio::time::sleep(Duration::from_secs(wait)).await; + clone + .send() + .await + .map_err(|e| CliError::Network(e.to_string())) + } + + /// Slack `conversations.list`. Paginated; pass the returned cursor to + /// continue. `types` is the comma-separated channel types filter. + #[instrument(skip(self))] + pub async fn conversations_list( + &self, + types: &str, + limit: u32, + cursor: Option<&str>, + ) -> Result { + let mut url = format!( + "{}/api/conversations.list?types={}&limit={}", + self.base_url, + urlencoding::encode(types), + limit, + ); + if let Some(c) = cursor { + url.push_str("&cursor="); + url.push_str(&urlencoding::encode(c)); + } + let resp = self.send_with_retry(self.http.get(&url)).await?; + parse_slack::(resp).await + } + + /// Slack `conversations.info`. Returns metadata for one channel. + #[instrument(skip(self))] + pub async fn conversations_info(&self, channel: &str) -> Result { + let url = format!( + "{}/api/conversations.info?channel={}", + self.base_url, + urlencoding::encode(channel), + ); + let resp = self.send_with_retry(self.http.get(&url)).await?; + parse_slack::(resp).await + } + + /// Slack `conversations.history`. `oldest` / `latest` are Slack ts values + /// bounding the message range. Paginated via cursor. + #[instrument(skip(self))] + pub async fn conversations_history( + &self, + channel: &str, + limit: u32, + cursor: Option<&str>, + oldest: Option<&str>, + latest: Option<&str>, + ) -> Result { + let mut url = format!( + "{}/api/conversations.history?channel={}&limit={}", + self.base_url, + urlencoding::encode(channel), + limit, + ); + if let Some(c) = cursor { + url.push_str("&cursor="); + url.push_str(&urlencoding::encode(c)); + } + if let Some(t) = oldest { + url.push_str("&oldest="); + url.push_str(&urlencoding::encode(t)); + } + if let Some(t) = latest { + url.push_str("&latest="); + url.push_str(&urlencoding::encode(t)); + } + let resp = self.send_with_retry(self.http.get(&url)).await?; + parse_slack::(resp).await + } + + /// Slack `conversations.replies`. Returns the parent plus replies for one + /// thread, in order. Paginated via cursor. + #[instrument(skip(self))] + pub async fn conversations_replies( + &self, + channel: &str, + ts: &str, + cursor: Option<&str>, + limit: u32, + ) -> Result { + let mut url = format!( + "{}/api/conversations.replies?channel={}&ts={}&limit={}", + self.base_url, + urlencoding::encode(channel), + urlencoding::encode(ts), + limit, + ); + if let Some(c) = cursor { + url.push_str("&cursor="); + url.push_str(&urlencoding::encode(c)); + } + let resp = self.send_with_retry(self.http.get(&url)).await?; + parse_slack::(resp).await + } + + /// Slack `users.list`. Paginated via cursor. + #[instrument(skip(self))] + pub async fn users_list( + &self, + limit: u32, + cursor: Option<&str>, + ) -> Result { + let mut url = format!("{}/api/users.list?limit={}", self.base_url, limit,); + if let Some(c) = cursor { + url.push_str("&cursor="); + url.push_str(&urlencoding::encode(c)); + } + let resp = self.send_with_retry(self.http.get(&url)).await?; + parse_slack::(resp).await + } + + /// Slack `users.info`. Returns one user's profile. + #[instrument(skip(self))] + pub async fn users_info(&self, user: &str) -> Result { + let url = format!( + "{}/api/users.info?user={}", + self.base_url, + urlencoding::encode(user), + ); + let resp = self.send_with_retry(self.http.get(&url)).await?; + parse_slack::(resp).await + } + + /// Slack `search.messages`. User-token required. Page is 1-indexed. + #[instrument(skip(self))] + pub async fn search_messages( + &self, + query: &str, + page: u32, + count: u32, + ) -> Result { + let url = format!( + "{}/api/search.messages?query={}&page={}&count={}", + self.base_url, + urlencoding::encode(query), + page, + count, + ); + let resp = self.send_with_retry(self.http.get(&url)).await?; + parse_slack::(resp).await + } + + /// Slack `search.files`. User-token required. Page is 1-indexed. + #[instrument(skip(self))] + pub async fn search_files( + &self, + query: &str, + page: u32, + count: u32, + ) -> Result { + let url = format!( + "{}/api/search.files?query={}&page={}&count={}", + self.base_url, + urlencoding::encode(query), + page, + count, + ); + let resp = self.send_with_retry(self.http.get(&url)).await?; + parse_slack::(resp).await + } + + /// Slack `files.list`. Optional channel filter. Page is 1-indexed. + #[instrument(skip(self))] + pub async fn files_list( + &self, + channel: Option<&str>, + count: u32, + page: Option, + ) -> Result { + let mut url = format!("{}/api/files.list?count={}", self.base_url, count,); + if let Some(c) = channel { + url.push_str("&channel="); + url.push_str(&urlencoding::encode(c)); + } + if let Some(p) = page { + use std::fmt::Write as _; + let _ = write!(url, "&page={p}"); + } + let resp = self.send_with_retry(self.http.get(&url)).await?; + parse_slack::(resp).await + } + + /// Slack `files.info`. Returns one file's metadata. + #[instrument(skip(self))] + pub async fn files_info(&self, file_id: &str) -> Result { + let url = format!( + "{}/api/files.info?file={}", + self.base_url, + urlencoding::encode(file_id), + ); + let resp = self.send_with_retry(self.http.get(&url)).await?; + parse_slack::(resp).await + } + + /// Slack `chat.postMessage`. The request carries a UUID v4 `client_msg_id` + /// so retries are de-duplicated server-side. + #[instrument(skip(self, req))] + pub async fn chat_post_message(&self, req: &PostMessage) -> Result { + let url = format!("{}/api/chat.postMessage", self.base_url); + let resp = self.send_with_retry(self.http.post(&url).json(req)).await?; + parse_slack::(resp).await + } + + /// Slack `chat.update`. Replaces the text of a previously posted message. + #[instrument(skip(self))] + pub async fn chat_update( + &self, + channel: &str, + ts: &str, + text: &str, + ) -> Result { + let url = format!("{}/api/chat.update", self.base_url); + let body = serde_json::json!({"channel": channel, "ts": ts, "text": text}); + let resp = self + .send_with_retry(self.http.post(&url).json(&body)) + .await?; + parse_slack::(resp).await + } + + /// Slack `chat.delete`. Removes a previously posted message. + #[instrument(skip(self))] + pub async fn chat_delete(&self, channel: &str, ts: &str) -> Result { + let url = format!("{}/api/chat.delete", self.base_url); + let body = serde_json::json!({"channel": channel, "ts": ts}); + let resp = self + .send_with_retry(self.http.post(&url).json(&body)) + .await?; + parse_slack::(resp).await + } + + /// Slack `reactions.add`. Idempotent: `already_reacted` is treated as Ok. + #[instrument(skip(self))] + pub async fn reactions_add(&self, channel: &str, ts: &str, name: &str) -> Result<(), CliError> { + self.reaction_call("/api/reactions.add", channel, ts, name, "already_reacted") + .await + } + + /// Slack `reactions.remove`. Idempotent: `no_reaction` is treated as Ok. + #[instrument(skip(self))] + pub async fn reactions_remove( + &self, + channel: &str, + ts: &str, + name: &str, + ) -> Result<(), CliError> { + self.reaction_call("/api/reactions.remove", channel, ts, name, "no_reaction") + .await + } + + /// Shared reaction-call body. The `idempotent_error` code (e.g. + /// `already_reacted`, `no_reaction`) is downgraded to success so callers + /// can retry safely. + async fn reaction_call( + &self, + endpoint: &str, + channel: &str, + ts: &str, + name: &str, + idempotent_error: &str, + ) -> Result<(), CliError> { + let url = format!("{}{}", self.base_url, endpoint); + let body = serde_json::json!({"channel": channel, "timestamp": ts, "name": name}); + let resp = self + .send_with_retry(self.http.post(&url).json(&body)) + .await?; + let status = resp.status(); + let request_id = resp + .headers() + .get("x-slack-req-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let raw = resp + .text() + .await + .map_err(|e| CliError::Network(e.to_string()))?; + if !status.is_success() { + return Err(CliError::SlackApi { + code: format!("http_{}", status.as_u16()), + request_id, + }); + } + let v: serde_json::Value = serde_json::from_str(&raw).map_err(|e| CliError::SlackApi { + code: format!("decode: {e}"), + request_id: request_id.clone(), + })?; + if v.get("ok").and_then(serde_json::Value::as_bool) == Some(true) { + return Ok(()); + } + let code = v.get("error").and_then(|x| x.as_str()).unwrap_or("unknown"); + if code == idempotent_error { + return Ok(()); + } + Err(map_slack_error(code, request_id)) + } + + /// Slack `conversations.create`. Returns the freshly created channel. + #[instrument(skip(self))] + pub async fn conversations_create( + &self, + name: &str, + is_private: bool, + description: Option<&str>, + ) -> Result { + let url = format!("{}/api/conversations.create", self.base_url); + let mut body = serde_json::json!({"name": name, "is_private": is_private}); + if let Some(d) = description { + body["description"] = serde_json::Value::String(d.to_owned()); + } + let resp = self + .send_with_retry(self.http.post(&url).json(&body)) + .await?; + parse_slack::(resp).await + } + + /// Slack `conversations.archive`. + #[instrument(skip(self))] + pub async fn conversations_archive(&self, channel: &str) -> Result<(), CliError> { + let url = format!("{}/api/conversations.archive", self.base_url); + let body = serde_json::json!({"channel": channel}); + let resp = self + .send_with_retry(self.http.post(&url).json(&body)) + .await?; + parse_slack_unit(resp).await + } + + /// Slack `conversations.invite`. `users` is joined into a comma-separated + /// list per the canonical wire form Slack documents. + #[instrument(skip(self))] + pub async fn conversations_invite( + &self, + channel: &str, + users: &[String], + ) -> Result { + let url = format!("{}/api/conversations.invite", self.base_url); + let body = serde_json::json!({"channel": channel, "users": users.join(",")}); + let resp = self + .send_with_retry(self.http.post(&url).json(&body)) + .await?; + parse_slack::(resp).await + } + + /// Slack `conversations.leave`. + #[instrument(skip(self))] + pub async fn conversations_leave(&self, channel: &str) -> Result<(), CliError> { + let url = format!("{}/api/conversations.leave", self.base_url); + let body = serde_json::json!({"channel": channel}); + let resp = self + .send_with_retry(self.http.post(&url).json(&body)) + .await?; + parse_slack_unit(resp).await + } + + /// Slack `files.upload`. Multipart-encoded; `bytes` is the full file body + /// (callers typically pass `std::fs::read(path)?` or a stdin buffer). + #[instrument(skip(self, bytes))] + pub async fn files_upload( + &self, + channel: Option<&str>, + filename: &str, + bytes: Vec, + title: Option<&str>, + initial_comment: Option<&str>, + thread_ts: Option<&str>, + ) -> Result { + let url = format!("{}/api/files.upload", self.base_url); + let part = reqwest::multipart::Part::bytes(bytes).file_name(filename.to_owned()); + let mut form = reqwest::multipart::Form::new().part("file", part); + if let Some(c) = channel { + form = form.text("channels", c.to_owned()); + } + if let Some(t) = title { + form = form.text("title", t.to_owned()); + } + if let Some(c) = initial_comment { + form = form.text("initial_comment", c.to_owned()); + } + if let Some(t) = thread_ts { + form = form.text("thread_ts", t.to_owned()); + } + // `reqwest::multipart::Form` cannot be cloned, so `RequestBuilder::try_clone` + // returns `None`. We deliberately bypass `send_with_retry` here; on 429 + // the caller will see a `SlackApi { code: "http_429", .. }` and can + // re-invoke. Wiring it through `send_with_retry` would panic on the + // `expect("...cloneable...")` retry path. + let resp = self + .http + .post(&url) + .multipart(form) + .send() + .await + .map_err(|e| CliError::Network(e.to_string()))?; + let parsed: FilesUploadResp = parse_slack(resp).await?; + Ok(parsed.file) + } + + /// Download a Slack-hosted file. The URL must already include the host + /// (e.g., `https://files.slack.com/...`); the default `Authorization` + /// header attached to the client is reused. Binary body — bypasses + /// `parse_slack`. + #[instrument(skip(self))] + pub async fn download_authorized(&self, url: &str) -> Result, CliError> { + let resp = self + .http + .get(url) + .send() + .await + .map_err(|e| CliError::Network(e.to_string()))?; + if !resp.status().is_success() { + return Err(CliError::SlackApi { + code: format!("http_{}", resp.status().as_u16()), + request_id: None, + }); + } + let bytes = resp + .bytes() + .await + .map_err(|e| CliError::Network(e.to_string()))?; + Ok(bytes.to_vec()) + } +} + +/// Response shape for `auth.test`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AuthTest { + pub ok: bool, + pub url: Option, + pub team: Option, + pub user: Option, + pub team_id: Option, + pub user_id: Option, + pub bot_id: Option, + pub error: Option, +} + +/// Topic / purpose container used by `ChannelDto`. Slack returns a small +/// nested object with `value`, `creator`, `last_set`; only `value` is needed. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TopicPurpose { + pub value: String, +} + +/// Channel metadata, common to `conversations.list` and `conversations.info`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ChannelDto { + pub id: String, + pub name: String, + #[serde(default)] + pub is_private: bool, + #[serde(default)] + pub is_archived: bool, + pub num_members: Option, + pub topic: Option, + pub purpose: Option, +} + +/// Message metadata returned by history, replies, and search. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MessageDto { + pub ts: String, + pub user: Option, + #[serde(default)] + pub text: String, + pub thread_ts: Option, + pub reply_count: Option, +} + +/// User profile metadata returned by `users.list` and `users.info`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UserDto { + pub id: String, + pub name: String, + pub real_name: Option, + #[serde(default)] + pub deleted: bool, + #[serde(default)] + pub is_bot: bool, +} + +/// File metadata returned by `files.list`, `files.info`, and `search.files`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct FileDto { + pub id: String, + pub name: String, + #[serde(default)] + pub size: u64, + #[serde(default)] + pub mimetype: String, + pub user: Option, + pub url_private: Option, +} + +/// Slack cursor-pagination metadata block. +#[derive(Debug, Clone, Deserialize)] +pub struct ResponseMetadata { + pub next_cursor: Option, +} + +/// Response shape for `conversations.list`. +#[derive(Debug, Clone, Deserialize)] +pub struct ConversationsList { + pub ok: bool, + pub channels: Vec, + pub response_metadata: Option, +} + +/// Response shape for `conversations.info`. +#[derive(Debug, Clone, Deserialize)] +pub struct ConversationsInfo { + pub ok: bool, + pub channel: ChannelDto, +} + +/// Response shape shared by `conversations.history` and `conversations.replies`. +#[derive(Debug, Clone, Deserialize)] +pub struct History { + pub ok: bool, + pub messages: Vec, + pub response_metadata: Option, +} + +/// Response shape for `users.list`. +#[derive(Debug, Clone, Deserialize)] +pub struct UsersList { + pub ok: bool, + pub members: Vec, + pub response_metadata: Option, +} + +/// Response shape for `users.info`. +#[derive(Debug, Clone, Deserialize)] +pub struct UsersInfo { + pub ok: bool, + pub user: UserDto, +} + +/// Page-style pagination block used by `search.*` and `files.list`. +#[derive(Debug, Clone, Deserialize)] +pub struct SearchPaging { + pub page: u32, + pub pages: u32, +} + +/// Inner `messages` payload of `search.messages`. +#[derive(Debug, Clone, Deserialize)] +pub struct SearchMessagesPayload { + pub matches: Vec, + pub paging: SearchPaging, +} + +/// Response shape for `search.messages`. +#[derive(Debug, Clone, Deserialize)] +pub struct SearchMessagesResp { + pub ok: bool, + pub messages: SearchMessagesPayload, +} + +/// Inner `files` payload of `search.files`. +#[derive(Debug, Clone, Deserialize)] +pub struct SearchFilesPayload { + pub matches: Vec, + pub paging: SearchPaging, +} + +/// Response shape for `search.files`. +#[derive(Debug, Clone, Deserialize)] +pub struct SearchFilesResp { + pub ok: bool, + pub files: SearchFilesPayload, +} + +/// Response shape for `files.list`. +#[derive(Debug, Clone, Deserialize)] +pub struct FilesList { + pub ok: bool, + pub files: Vec, + pub paging: Option, +} + +/// Response shape for `files.info`. +#[derive(Debug, Clone, Deserialize)] +pub struct FilesInfo { + pub ok: bool, + pub file: FileDto, +} + +/// Request body for `chat.postMessage`. `client_msg_id` is auto-populated with +/// a UUID v4 by `PostMessage::new` so retries are de-duplicated server-side. +#[derive(Debug, Clone, Serialize)] +pub struct PostMessage { + pub channel: String, + pub text: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub thread_ts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reply_broadcast: Option, + pub client_msg_id: String, +} + +impl PostMessage { + /// Build a fresh request with a random UUID v4 `client_msg_id`. + #[must_use] + pub fn new(channel: String, text: String) -> Self { + Self { + channel, + text, + thread_ts: None, + reply_broadcast: None, + client_msg_id: uuid::Uuid::new_v4().to_string(), + } + } +} + +/// Response shape for `chat.postMessage`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct PostMessageResp { + pub ok: bool, + pub ts: String, + pub channel: String, + pub message: MessageDto, +} + +/// Response shape shared by `chat.update` and `chat.delete`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ChatTsResp { + pub ok: bool, + pub channel: String, + pub ts: String, +} + +/// Response shape for `conversations.create` and `conversations.invite`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ConversationsCreateResp { + pub ok: bool, + pub channel: ChannelDto, +} + +/// Minimal `{"ok": true}` response used by archive / leave. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct OkResp { + pub ok: bool, +} + +/// Response shape for `files.upload`. +#[derive(Debug, Clone, Deserialize)] +pub struct FilesUploadResp { + pub ok: bool, + pub file: FileDto, +} + +/// Decode a Slack response whose body we only need to validate as +/// `{"ok": true}`. Discards the parsed payload. +pub(crate) async fn parse_slack_unit(resp: reqwest::Response) -> Result<(), CliError> { + let _: OkResp = parse_slack(resp).await?; + Ok(()) +} + +/// Decode a Slack response, mapping `ok: false` to `CliError`. +pub(crate) async fn parse_slack Deserialize<'de>>( + resp: reqwest::Response, +) -> Result { + let request_id = resp + .headers() + .get("x-slack-req-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let status = resp.status(); + let body = resp + .text() + .await + .map_err(|e| CliError::Network(e.to_string()))?; + + if !status.is_success() { + return Err(CliError::SlackApi { + code: format!("http_{}", status.as_u16()), + request_id, + }); + } + + let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| CliError::SlackApi { + code: format!("decode: {e}"), + request_id: request_id.clone(), + })?; + + if v.get("ok").and_then(serde_json::Value::as_bool) != Some(true) { + let code = v + .get("error") + .and_then(|x| x.as_str()) + .unwrap_or("unknown") + .to_owned(); + return Err(map_slack_error(&code, request_id)); + } + + serde_json::from_value(v).map_err(|e| CliError::SlackApi { + code: format!("decode: {e}"), + request_id, + }) +} + +/// Translate a Slack `error` code into a `CliError` variant. +pub(crate) fn map_slack_error(code: &str, request_id: Option) -> CliError { + match code { + "not_authed" | "invalid_auth" | "account_inactive" | "token_revoked" | "token_expired" => { + CliError::AuthFailed { + reason: code.to_owned(), + } + } + "channel_not_found" => CliError::NotFound { + kind: ResourceKind::Channel, + name: "".into(), + }, + "user_not_found" => CliError::NotFound { + kind: ResourceKind::User, + name: "".into(), + }, + "file_not_found" => CliError::NotFound { + kind: ResourceKind::File, + name: "".into(), + }, + "ratelimited" => CliError::RateLimited { + retry_after_secs: 1, + }, + "missing_scope" | "not_allowed_token_type" => CliError::TokenKindMismatch { + needed: "?", + actual: "?", + api: "?", + }, + _ => CliError::SlackApi { + code: code.to_owned(), + request_id, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[tokio::test(flavor = "current_thread")] + async fn auth_test_ok() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/auth.test")) + .and(header("authorization", "Bearer xoxp-test")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"ok":true,"team":"acme","user":"alice","team_id":"T1","user_id":"U1"}"#, + )) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let r = c.auth_test().await.unwrap(); + assert_eq!(r.team_id.as_deref(), Some("T1")); + } + + #[tokio::test(flavor = "current_thread")] + async fn auth_test_invalid_auth() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/auth.test")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(r#"{"ok":false,"error":"invalid_auth"}"#), + ) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let err = c.auth_test().await.unwrap_err(); + assert!(matches!(err, CliError::AuthFailed { .. })); + } + + #[tokio::test(flavor = "current_thread")] + async fn override_via_env() { + // SAFETY: env mutations in tests can interfere with other tests, but + // we run on a current_thread runtime which serializes them with the + // outer test framework. + std::env::set_var("SLACK_CLI_BASE_URL_OVERRIDE", "http://127.0.0.1:1"); + let c = SlackClient::new(SecretString::from("xoxp-test")).unwrap(); + assert!(c.base_url.starts_with("http://127.0.0.1:1")); + std::env::remove_var("SLACK_CLI_BASE_URL_OVERRIDE"); + } + + #[tokio::test(flavor = "current_thread")] + async fn retry_once_after_429() { + let server = MockServer::start().await; + let body_ok = r#"{"ok":true,"team":"acme","team_id":"T1","user_id":"U1"}"#; + + Mock::given(method("POST")) + .and(path("/api/auth.test")) + .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0")) + .up_to_n_times(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/api/auth.test")) + .respond_with(ResponseTemplate::new(200).set_body_string(body_ok)) + .mount(&server) + .await; + + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let r = c.auth_test().await.unwrap(); + assert_eq!(r.team_id.as_deref(), Some("T1")); + } + + #[tokio::test(flavor = "current_thread")] + async fn conversations_list_parses() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/conversations.list")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(include_str!("../tests/fixtures/conversations.list.ok.json")), + ) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let res = c + .conversations_list("public_channel,private_channel", 50, None) + .await + .unwrap(); + assert_eq!(res.channels.len(), 1); + assert_eq!(res.channels[0].name, "general"); + } + + #[tokio::test(flavor = "current_thread")] + async fn conversations_info_parses() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/conversations.info")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(include_str!("../tests/fixtures/conversations.info.ok.json")), + ) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let res = c.conversations_info("C001").await.unwrap(); + assert_eq!(res.channel.id, "C001"); + } + + #[tokio::test(flavor = "current_thread")] + async fn conversations_history_parses() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/conversations.history")) + .respond_with(ResponseTemplate::new(200).set_body_string(include_str!( + "../tests/fixtures/conversations.history.ok.json" + ))) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let res = c + .conversations_history("C001", 50, None, None, None) + .await + .unwrap(); + assert_eq!(res.messages.len(), 1); + } + + #[tokio::test(flavor = "current_thread")] + async fn conversations_replies_parses() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/conversations.replies")) + .respond_with(ResponseTemplate::new(200).set_body_string(include_str!( + "../tests/fixtures/conversations.replies.ok.json" + ))) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let res = c + .conversations_replies("C001", "1700000000.000100", None, 50) + .await + .unwrap(); + assert_eq!(res.messages.len(), 2); + } + + #[tokio::test(flavor = "current_thread")] + async fn users_list_parses() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/users.list")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(include_str!("../tests/fixtures/users.list.ok.json")), + ) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let res = c.users_list(50, None).await.unwrap(); + assert_eq!(res.members[0].name, "alice"); + } + + #[tokio::test(flavor = "current_thread")] + async fn users_info_parses() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/users.info")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(include_str!("../tests/fixtures/users.info.ok.json")), + ) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let res = c.users_info("U001").await.unwrap(); + assert_eq!(res.user.id, "U001"); + } + + #[tokio::test(flavor = "current_thread")] + async fn search_messages_parses() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/search.messages")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(include_str!("../tests/fixtures/search.messages.ok.json")), + ) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let res = c.search_messages("hi", 1, 50).await.unwrap(); + assert_eq!(res.messages.matches.len(), 1); + } + + #[tokio::test(flavor = "current_thread")] + async fn search_files_parses() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/search.files")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(include_str!("../tests/fixtures/search.files.ok.json")), + ) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let res = c.search_files("r.png", 1, 50).await.unwrap(); + assert_eq!(res.files.matches.len(), 1); + } + + #[tokio::test(flavor = "current_thread")] + async fn files_list_parses() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/files.list")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(include_str!("../tests/fixtures/files.list.ok.json")), + ) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let res = c.files_list(None, 50, None).await.unwrap(); + assert_eq!(res.files[0].id, "F001"); + } + + #[tokio::test(flavor = "current_thread")] + async fn files_info_parses() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/files.info")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(include_str!("../tests/fixtures/files.info.ok.json")), + ) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let res = c.files_info("F001").await.unwrap(); + assert_eq!(res.file.name, "r.png"); + } + + #[tokio::test(flavor = "current_thread")] + async fn chat_post_message_parses() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/chat.postMessage")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(include_str!("../tests/fixtures/chat.postMessage.ok.json")), + ) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let post_req = PostMessage::new("C001".into(), "hello".into()); + let posted = c.chat_post_message(&post_req).await.unwrap(); + assert_eq!(posted.ts, "1700000000.000100"); + } + + #[tokio::test(flavor = "current_thread")] + async fn post_message_has_uuid_client_msg_id() { + let req = PostMessage::new("C001".into(), "x".into()); + // UUID v4 string format: 8-4-4-4-12 hex chars. + assert_eq!(req.client_msg_id.len(), 36); + assert!(req.client_msg_id.chars().filter(|c| *c == '-').count() == 4); + } + + #[tokio::test(flavor = "current_thread")] + async fn chat_update_parses() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/chat.update")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(include_str!("../tests/fixtures/chat.update.ok.json")), + ) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let res = c + .chat_update("C001", "1700000000.000100", "edit") + .await + .unwrap(); + assert_eq!(res.ts, "1700000000.000100"); + } + + #[tokio::test(flavor = "current_thread")] + async fn chat_delete_parses() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/chat.delete")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(include_str!("../tests/fixtures/chat.delete.ok.json")), + ) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + c.chat_delete("C001", "1700000000.000100").await.unwrap(); + } + + #[tokio::test(flavor = "current_thread")] + async fn reactions_add_ok() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/reactions.add")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(include_str!("../tests/fixtures/reactions.add.ok.json")), + ) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + c.reactions_add("C001", "1700000000.000100", "thumbsup") + .await + .unwrap(); + } + + #[tokio::test(flavor = "current_thread")] + async fn reactions_add_already_reacted_is_success() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/reactions.add")) + .respond_with(ResponseTemplate::new(200).set_body_string(include_str!( + "../tests/fixtures/reactions.add.already_reacted.json" + ))) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + c.reactions_add("C001", "1700000000.000100", "thumbsup") + .await + .unwrap(); + } + + #[tokio::test(flavor = "current_thread")] + async fn conversations_create_parses() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/conversations.create")) + .respond_with(ResponseTemplate::new(200).set_body_string(include_str!( + "../tests/fixtures/conversations.create.ok.json" + ))) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let res = c + .conversations_create("new-chan", false, None) + .await + .unwrap(); + assert_eq!(res.channel.id, "C999"); + } + + #[tokio::test(flavor = "current_thread")] + async fn conversations_archive_ok() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/conversations.archive")) + .respond_with(ResponseTemplate::new(200).set_body_string(include_str!( + "../tests/fixtures/conversations.archive.ok.json" + ))) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + c.conversations_archive("C001").await.unwrap(); + } + + #[tokio::test(flavor = "current_thread")] + async fn conversations_invite_parses() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/conversations.invite")) + .respond_with(ResponseTemplate::new(200).set_body_string(include_str!( + "../tests/fixtures/conversations.invite.ok.json" + ))) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let res = c + .conversations_invite("C001", &["U002".into()]) + .await + .unwrap(); + assert_eq!(res.channel.id, "C001"); + } + + #[tokio::test(flavor = "current_thread")] + async fn conversations_leave_ok() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/conversations.leave")) + .respond_with(ResponseTemplate::new(200).set_body_string(include_str!( + "../tests/fixtures/conversations.leave.ok.json" + ))) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + c.conversations_leave("C001").await.unwrap(); + } + + #[tokio::test(flavor = "current_thread")] + async fn files_upload_parses() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/files.upload")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(include_str!("../tests/fixtures/files.upload.ok.json")), + ) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let f = c + .files_upload(Some("C001"), "r.png", vec![1, 2, 3], None, None, None) + .await + .unwrap(); + assert_eq!(f.id, "F999"); + } + + #[tokio::test(flavor = "current_thread")] + async fn download_authorized_returns_bytes() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/files/r.png")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"\x89PNG\r\n".to_vec())) + .mount(&server) + .await; + let c = SlackClient::with_base_url(server.uri(), SecretString::from("xoxp-test")).unwrap(); + let bytes = c + .download_authorized(&format!("{}/files/r.png", server.uri())) + .await + .unwrap(); + assert_eq!(&bytes[..4], b"\x89PNG"); + } +} diff --git a/tests/auth.rs b/tests/auth.rs new file mode 100644 index 0000000..d217a49 --- /dev/null +++ b/tests/auth.rs @@ -0,0 +1,40 @@ +#![allow(clippy::needless_return)] + +mod common; + +use assert_cmd::prelude::*; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +#[tokio::test(flavor = "current_thread")] +async fn whoami_emits_envelope() { + let sb = common::sandbox("acme").await; + Mock::given(method("POST")) + .and(path("/api/auth.test")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"ok":true,"team":"acme","user":"alice","team_id":"T1","user_id":"U1"}"#, + )) + .mount(&sb.server) + .await; + let assert = common::cmd(&sb, "acme", "xoxp-test") + .args(["auth", "whoami", "--json"]) + .assert() + .success(); + let out = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + assert!(out.contains("\"schema\":\"slack-cli/v0\""), "stdout: {out}"); + assert!(out.contains("\"team_id\":\"T1\""), "stdout: {out}"); +} + +#[tokio::test(flavor = "current_thread")] +async fn auth_list_with_empty_config_is_ok() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("config.toml"); + std::fs::write(&config_path, "").unwrap(); + + let mut cmd = std::process::Command::new(assert_cmd::cargo::cargo_bin("slack-cli")); + cmd.env("SLACK_CLI_CONFIG", &config_path); + cmd.env("NO_COLOR", "1"); + cmd.env("RUST_LOG", "error"); + cmd.args(["auth", "list", "--json"]); + cmd.assert().success(); +} diff --git a/tests/channel.rs b/tests/channel.rs new file mode 100644 index 0000000..1bb7215 --- /dev/null +++ b/tests/channel.rs @@ -0,0 +1,33 @@ +mod common; + +use assert_cmd::prelude::*; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +#[tokio::test(flavor = "current_thread")] +async fn channel_list_emits_items_envelope() { + let sb = common::sandbox("acme").await; + Mock::given(method("GET")) + .and(path("/api/conversations.list")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"ok":true,"channels":[{"id":"C1","name":"general","is_private":false,"is_archived":false,"num_members":1,"topic":{"value":""},"purpose":{"value":""}}],"response_metadata":{"next_cursor":""}}"#)) + .mount(&sb.server) + .await; + let assert = common::cmd(&sb, "acme", "xoxp-test") + .args(["channel", "list", "--json"]) + .assert() + .success(); + let out = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + assert!(out.contains("\"items\""), "stdout: {out}"); + assert!(out.contains("\"name\":\"general\""), "stdout: {out}"); +} + +#[tokio::test(flavor = "current_thread")] +async fn channel_archive_without_yes_fails_64() { + let sb = common::sandbox("acme").await; + // No mock for /api/conversations.archive — request must NEVER fire. + let assert = common::cmd(&sb, "acme", "xoxp-test") + .args(["channel", "archive", "C1"]) + .assert(); + assert.code(64); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..4108164 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,58 @@ +//! Shared helpers for integration tests. +//! +//! Each test sets up a temp config + a wiremock server, then runs the CLI +//! binary via `assert_cmd` with env vars wired to both. + +#![allow(dead_code)] + +use std::path::PathBuf; +use std::process::Command; + +use assert_cmd::cargo::cargo_bin; +use tempfile::TempDir; +use wiremock::MockServer; + +pub struct Sandbox { + pub dir: TempDir, + pub config_path: PathBuf, + pub server: MockServer, +} + +/// Build a sandbox with a single workspace `` whose token source +/// is `env` pointing to `FAKE_TOKEN_`. +pub async fn sandbox(workspace: &str) -> Sandbox { + let dir = tempfile::tempdir().expect("tempdir"); + let config_path = dir.path().join("config.toml"); + let body = format!( + r#"default_workspace = "{ws}" + +[workspaces.{ws}] +team_id = "T1" +team_domain = "acme" +token_source = "env" +token_env = "FAKE_TOKEN_{ws}" +"#, + ws = workspace, + ); + std::fs::write(&config_path, body).expect("write config"); + + let server = MockServer::start().await; + Sandbox { + dir, + config_path, + server, + } +} + +/// Build a `Command` for the `slack-cli` binary with all sandbox env wired up. +pub fn cmd(sb: &Sandbox, ws: &str, token: &str) -> Command { + let mut c = Command::new(cargo_bin("slack-cli")); + c.env("SLACK_CLI_CONFIG", &sb.config_path); + c.env("SLACK_CLI_WORKSPACE", ws); + c.env(format!("FAKE_TOKEN_{ws}"), token); + c.env_remove("SLACK_CLI_TOKEN"); + c.env("NO_COLOR", "1"); + c.env("RUST_LOG", "error"); + c.env("SLACK_CLI_BASE_URL_OVERRIDE", sb.server.uri()); + c +} diff --git a/tests/file.rs b/tests/file.rs new file mode 100644 index 0000000..f48d5a8 --- /dev/null +++ b/tests/file.rs @@ -0,0 +1,36 @@ +mod common; + +use assert_cmd::prelude::*; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +#[tokio::test(flavor = "current_thread")] +async fn file_upload_dry_run_makes_no_post() { + let sb = common::sandbox("acme").await; + // files.upload must NEVER fire. + Mock::given(method("POST")) + .and(path("/api/files.upload")) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount(&sb.server) + .await; + // Write a tiny file we can reference by path. + let path = sb.dir.path().join("hello.txt"); + std::fs::write(&path, b"hi").unwrap(); + let assert = common::cmd(&sb, "acme", "xoxp-test") + .args([ + "file", + "upload", + path.to_str().unwrap(), + "--dry-run", + "--json", + ]) + .assert() + .success(); + let out = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + assert!(out.contains("\"dry_run\":true"), "stdout: {out}"); + assert!( + out.contains("\"would_call\":\"files.upload\""), + "stdout: {out}" + ); +} diff --git a/tests/fixtures/chat.delete.ok.json b/tests/fixtures/chat.delete.ok.json new file mode 100644 index 0000000..faf1bb1 --- /dev/null +++ b/tests/fixtures/chat.delete.ok.json @@ -0,0 +1 @@ +{"ok":true,"channel":"C001","ts":"1700000000.000100"} diff --git a/tests/fixtures/chat.postMessage.ok.json b/tests/fixtures/chat.postMessage.ok.json new file mode 100644 index 0000000..8250f27 --- /dev/null +++ b/tests/fixtures/chat.postMessage.ok.json @@ -0,0 +1 @@ +{"ok":true,"ts":"1700000000.000100","channel":"C001","message":{"ts":"1700000000.000100","user":"U001","text":"hello"}} diff --git a/tests/fixtures/chat.update.ok.json b/tests/fixtures/chat.update.ok.json new file mode 100644 index 0000000..faf1bb1 --- /dev/null +++ b/tests/fixtures/chat.update.ok.json @@ -0,0 +1 @@ +{"ok":true,"channel":"C001","ts":"1700000000.000100"} diff --git a/tests/fixtures/conversations.archive.ok.json b/tests/fixtures/conversations.archive.ok.json new file mode 100644 index 0000000..31bc455 --- /dev/null +++ b/tests/fixtures/conversations.archive.ok.json @@ -0,0 +1 @@ +{"ok":true} diff --git a/tests/fixtures/conversations.create.ok.json b/tests/fixtures/conversations.create.ok.json new file mode 100644 index 0000000..68282fe --- /dev/null +++ b/tests/fixtures/conversations.create.ok.json @@ -0,0 +1 @@ +{"ok":true,"channel":{"id":"C999","name":"new-chan","is_private":false,"is_archived":false,"num_members":1,"topic":{"value":""},"purpose":{"value":""}}} diff --git a/tests/fixtures/conversations.history.ok.json b/tests/fixtures/conversations.history.ok.json new file mode 100644 index 0000000..9c1588c --- /dev/null +++ b/tests/fixtures/conversations.history.ok.json @@ -0,0 +1 @@ +{"ok":true,"messages":[{"ts":"1700000000.000100","user":"U001","text":"hello"}],"response_metadata":{"next_cursor":""}} diff --git a/tests/fixtures/conversations.info.ok.json b/tests/fixtures/conversations.info.ok.json new file mode 100644 index 0000000..49be899 --- /dev/null +++ b/tests/fixtures/conversations.info.ok.json @@ -0,0 +1 @@ +{"ok":true,"channel":{"id":"C001","name":"general","is_private":false,"is_archived":false,"num_members":5,"topic":{"value":"team"},"purpose":{"value":"discuss"}}} diff --git a/tests/fixtures/conversations.invite.ok.json b/tests/fixtures/conversations.invite.ok.json new file mode 100644 index 0000000..4c96880 --- /dev/null +++ b/tests/fixtures/conversations.invite.ok.json @@ -0,0 +1 @@ +{"ok":true,"channel":{"id":"C001","name":"general","is_private":false,"is_archived":false,"num_members":6,"topic":{"value":""},"purpose":{"value":""}}} diff --git a/tests/fixtures/conversations.leave.ok.json b/tests/fixtures/conversations.leave.ok.json new file mode 100644 index 0000000..31bc455 --- /dev/null +++ b/tests/fixtures/conversations.leave.ok.json @@ -0,0 +1 @@ +{"ok":true} diff --git a/tests/fixtures/conversations.list.ok.json b/tests/fixtures/conversations.list.ok.json new file mode 100644 index 0000000..13a7bce --- /dev/null +++ b/tests/fixtures/conversations.list.ok.json @@ -0,0 +1 @@ +{"ok":true,"channels":[{"id":"C001","name":"general","is_private":false,"is_archived":false,"num_members":5,"topic":{"value":"team"},"purpose":{"value":"discuss"}}],"response_metadata":{"next_cursor":""}} diff --git a/tests/fixtures/conversations.replies.ok.json b/tests/fixtures/conversations.replies.ok.json new file mode 100644 index 0000000..575138b --- /dev/null +++ b/tests/fixtures/conversations.replies.ok.json @@ -0,0 +1 @@ +{"ok":true,"messages":[{"ts":"1700000000.000100","user":"U001","text":"parent"},{"ts":"1700000000.000200","user":"U002","text":"reply","thread_ts":"1700000000.000100"}],"response_metadata":{"next_cursor":""}} diff --git a/tests/fixtures/files.info.ok.json b/tests/fixtures/files.info.ok.json new file mode 100644 index 0000000..9c2ba75 --- /dev/null +++ b/tests/fixtures/files.info.ok.json @@ -0,0 +1 @@ +{"ok":true,"file":{"id":"F001","name":"r.png","size":12,"mimetype":"image/png","user":"U001","url_private":"https://files.slack.com/r.png"}} diff --git a/tests/fixtures/files.list.ok.json b/tests/fixtures/files.list.ok.json new file mode 100644 index 0000000..927f0ba --- /dev/null +++ b/tests/fixtures/files.list.ok.json @@ -0,0 +1 @@ +{"ok":true,"files":[{"id":"F001","name":"r.png","size":12,"mimetype":"image/png","user":"U001","url_private":"https://files.slack.com/r.png"}],"paging":{"page":1,"pages":1}} diff --git a/tests/fixtures/files.upload.ok.json b/tests/fixtures/files.upload.ok.json new file mode 100644 index 0000000..893fcc3 --- /dev/null +++ b/tests/fixtures/files.upload.ok.json @@ -0,0 +1 @@ +{"ok":true,"file":{"id":"F999","name":"r.png","size":42,"mimetype":"image/png","user":"U001","url_private":"https://files.slack.com/r.png"}} diff --git a/tests/fixtures/reactions.add.already_reacted.json b/tests/fixtures/reactions.add.already_reacted.json new file mode 100644 index 0000000..1b6fec5 --- /dev/null +++ b/tests/fixtures/reactions.add.already_reacted.json @@ -0,0 +1 @@ +{"ok":false,"error":"already_reacted"} diff --git a/tests/fixtures/reactions.add.ok.json b/tests/fixtures/reactions.add.ok.json new file mode 100644 index 0000000..31bc455 --- /dev/null +++ b/tests/fixtures/reactions.add.ok.json @@ -0,0 +1 @@ +{"ok":true} diff --git a/tests/fixtures/search.files.ok.json b/tests/fixtures/search.files.ok.json new file mode 100644 index 0000000..13b7674 --- /dev/null +++ b/tests/fixtures/search.files.ok.json @@ -0,0 +1 @@ +{"ok":true,"files":{"matches":[{"id":"F001","name":"r.png","size":12,"mimetype":"image/png","user":"U001","url_private":"https://files.slack.com/r.png"}],"paging":{"page":1,"pages":1}}} diff --git a/tests/fixtures/search.messages.ok.json b/tests/fixtures/search.messages.ok.json new file mode 100644 index 0000000..ac1365a --- /dev/null +++ b/tests/fixtures/search.messages.ok.json @@ -0,0 +1 @@ +{"ok":true,"messages":{"matches":[{"ts":"1700000000.000100","user":"U001","text":"hi"}],"paging":{"page":1,"pages":1}}} diff --git a/tests/fixtures/users.info.ok.json b/tests/fixtures/users.info.ok.json new file mode 100644 index 0000000..46231b5 --- /dev/null +++ b/tests/fixtures/users.info.ok.json @@ -0,0 +1 @@ +{"ok":true,"user":{"id":"U001","name":"alice","real_name":"Alice","deleted":false,"is_bot":false}} diff --git a/tests/fixtures/users.list.ok.json b/tests/fixtures/users.list.ok.json new file mode 100644 index 0000000..d5c2557 --- /dev/null +++ b/tests/fixtures/users.list.ok.json @@ -0,0 +1 @@ +{"ok":true,"members":[{"id":"U001","name":"alice","real_name":"Alice","deleted":false,"is_bot":false}],"response_metadata":{"next_cursor":""}} diff --git a/tests/message.rs b/tests/message.rs new file mode 100644 index 0000000..80f3ca6 --- /dev/null +++ b/tests/message.rs @@ -0,0 +1,68 @@ +mod common; + +use assert_cmd::prelude::*; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +#[tokio::test(flavor = "current_thread")] +async fn send_dry_run_makes_no_post() { + let sb = common::sandbox("acme").await; + // chat.postMessage must NEVER fire. + Mock::given(method("POST")) + .and(path("/api/chat.postMessage")) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount(&sb.server) + .await; + // conversations.list (channel-name resolution) is OK to be called once. + Mock::given(method("GET")) + .and(path("/api/conversations.list")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"ok":true,"channels":[{"id":"C1","name":"general","is_private":false,"is_archived":false,"num_members":1,"topic":{"value":""},"purpose":{"value":""}}],"response_metadata":{"next_cursor":""}}"#)) + .mount(&sb.server) + .await; + let assert = common::cmd(&sb, "acme", "xoxp-test") + .args([ + "message", + "send", + "#general", + "--text", + "hi", + "--dry-run", + "--json", + ]) + .assert() + .success(); + let out = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + assert!(out.contains("\"dry_run\":true"), "stdout: {out}"); + assert!( + out.contains("\"would_call\":\"chat.postMessage\""), + "stdout: {out}" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn send_happy_path() { + let sb = common::sandbox("acme").await; + Mock::given(method("GET")) + .and(path("/api/conversations.list")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"ok":true,"channels":[{"id":"C1","name":"general","is_private":false,"is_archived":false,"num_members":1,"topic":{"value":""},"purpose":{"value":""}}],"response_metadata":{"next_cursor":""}}"#)) + .mount(&sb.server) + .await; + Mock::given(method("POST")) + .and(path("/api/chat.postMessage")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"ok":true,"ts":"1700000000.000100","channel":"C1","message":{"ts":"1700000000.000100","user":"U1","text":"hi"}}"#)) + .mount(&sb.server) + .await; + let assert = common::cmd(&sb, "acme", "xoxp-test") + .args(["message", "send", "#general", "--text", "hi", "--json"]) + .assert() + .success(); + let out = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + assert!( + out.contains("\"ts\":\"1700000000.000100\""), + "stdout: {out}" + ); +} diff --git a/tests/search.rs b/tests/search.rs new file mode 100644 index 0000000..edfca04 --- /dev/null +++ b/tests/search.rs @@ -0,0 +1,23 @@ +mod common; + +use assert_cmd::prelude::*; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +#[tokio::test(flavor = "current_thread")] +async fn search_messages_emits_items() { + let sb = common::sandbox("acme").await; + Mock::given(method("GET")) + .and(path("/api/search.messages")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"ok":true,"messages":{"matches":[{"ts":"1700000000.000100","user":"U1","text":"hello"}],"paging":{"page":1,"pages":1}}}"#)) + .mount(&sb.server) + .await; + let assert = common::cmd(&sb, "acme", "xoxp-test") + .args(["search", "messages", "hello", "--json"]) + .assert() + .success(); + let out = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + assert!(out.contains("\"items\""), "stdout: {out}"); + assert!(out.contains("\"text\":\"hello\""), "stdout: {out}"); +} diff --git a/tests/user.rs b/tests/user.rs new file mode 100644 index 0000000..c132797 --- /dev/null +++ b/tests/user.rs @@ -0,0 +1,28 @@ +mod common; + +use assert_cmd::prelude::*; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +#[tokio::test(flavor = "current_thread")] +async fn user_view_resolves_name() { + let sb = common::sandbox("acme").await; + Mock::given(method("GET")) + .and(path("/api/users.list")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"ok":true,"members":[{"id":"U1","name":"alice","real_name":"Alice","deleted":false,"is_bot":false}],"response_metadata":{"next_cursor":""}}"#)) + .mount(&sb.server) + .await; + Mock::given(method("GET")) + .and(path("/api/users.info")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"ok":true,"user":{"id":"U1","name":"alice","real_name":"Alice","deleted":false,"is_bot":false}}"#)) + .mount(&sb.server) + .await; + let assert = common::cmd(&sb, "acme", "xoxp-test") + .args(["user", "view", "@alice", "--json"]) + .assert() + .success(); + let out = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + assert!(out.contains("\"id\":\"U1\""), "stdout: {out}"); +} From f7b61d423accb5319a7a12f6e91844854c0a7ac1 Mon Sep 17 00:00:00 2001 From: herata Date: Thu, 14 May 2026 17:18:35 +0900 Subject: [PATCH 2/3] fix(ci): wire GITHUB_TOKEN to mise + libdbus on ubuntu test + gate ws() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three remaining CI failures on the initial PR: 1. ubuntu test failed building libdbus-sys (transitive of keyring) — the test job had no libdbus install step. Add it (ubuntu-only). 2. macos test failed because mise's ubi backend hit anonymous GitHub API rate limits (403) when resolving cargo-deny / insta / git-cliff release lists. Wire `GITHUB_TOKEN` env into both mise-action steps so requests are authenticated. 3. windows test failed with `function ws is never used`: the file backend test (which is the only consumer of the `ws()` helper) is already #[cfg(not(target_os = "windows"))]. Gate the helper the same way. Local: cargo test/clippy/fmt all clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 7 +++++++ src/secret.rs | 3 +++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 857940c..365099f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,10 +22,15 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: install libdbus on linux + if: matrix.os == 'ubuntu-latest' + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libdbus-1-dev pkg-config - uses: jdx/mise-action@5083fe46898c414b2475087cc79da59e7da859e8 # v2.1.11 with: install: true cache: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.7.4 - uses: taiki-e/install-action@v2 with: @@ -52,6 +57,8 @@ jobs: with: install: true cache: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: cargo deny check advisories bans licenses sources msrv: diff --git a/src/secret.rs b/src/secret.rs index e05339c..4e4c0d6 100644 --- a/src/secret.rs +++ b/src/secret.rs @@ -129,6 +129,9 @@ mod tests { use super::*; use crate::config::{TokenSource, WorkspaceConfig, WorkspaceMeta}; + // Only the file-backend test currently consumes this; gate it the same + // way to avoid an unused-helper lint on Windows builds. + #[cfg(not(target_os = "windows"))] fn ws(src: TokenSource) -> WorkspaceConfig { WorkspaceConfig { team_id: None, From db6ec676652cd83c72cc969b9efe05e8c27a4fe2 Mon Sep 17 00:00:00 2001 From: herata Date: Thu, 14 May 2026 17:24:17 +0900 Subject: [PATCH 3/3] fix(ci): install rustfmt+clippy explicitly and gate windows-unused import - ubuntu: `cargo fmt --check` still failed because mise's rust backend cached a minimal toolchain from the previous run; the `profile = "default"` request in mise.toml does not retroactively add components. Run `rustup component add rustfmt clippy` in CI so the components are present regardless of cache state. - windows: `use crate::config::{TokenSource, WorkspaceConfig, WorkspaceMeta}` in `src/secret.rs::tests` was unused once `ws()` got cfg-gated. Gate the import with the same `#[cfg(not(target_os = "windows"))]`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 5 +++++ src/secret.rs | 3 +++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 365099f..bfc4805 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,11 @@ jobs: cache: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: ensure rustfmt + clippy components + # `mise.toml` requests rust profile=default, but mise's rust backend + # may install a cached minimal toolchain. Add the components + # explicitly so `cargo fmt` and `cargo clippy` always resolve. + run: rustup component add rustfmt clippy - uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.7.4 - uses: taiki-e/install-action@v2 with: diff --git a/src/secret.rs b/src/secret.rs index 4e4c0d6..3d5680d 100644 --- a/src/secret.rs +++ b/src/secret.rs @@ -127,6 +127,9 @@ fn keyring_delete(workspace_name: &str) -> Result<(), CliError> { #[cfg(test)] mod tests { use super::*; + // Only the file-backend test consumes these; on Windows the test (and + // the `ws()` helper below) is gated out, so the import would be unused. + #[cfg(not(target_os = "windows"))] use crate::config::{TokenSource, WorkspaceConfig, WorkspaceMeta}; // Only the file-backend test currently consumes this; gate it the same