Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,36 @@ nori-slack list-methods --descriptions
nori-slack describe chat.postMessage
```

### File uploads

Slack's modern file upload is a three-step external flow — mint an upload URL, POST the raw bytes straight to it, then complete the upload to share it into a channel. This is what Bolt exposes as `files.uploadV2`, and the middle byte-POST step cannot ride the dynamic `<method>` path, so uploading gets its own subcommand:

```bash
# Upload a local file and share it into a channel
nori-slack upload --file ./report.pdf --channel C123

# Add a title, a message, and post it into a thread
nori-slack upload --file ./report.pdf --channel C123 \
--title "Q3 Report" --initial-comment "Numbers are in" --thread-ts 1700000000.000100

# Preview the planned upload without contacting Slack
nori-slack upload --file ./report.pdf --channel C123 --dry-run
```

| Flag | Purpose |
| --- | --- |
| `--file <path>` | Local file to upload (required). |
| `--channel <id>` | Channel to share the file into. |
| `--title <title>` | File title (defaults to the filename). |
| `--filename <name>` | Filename registered with Slack (defaults to the basename of `--file`). |
| `--initial-comment <text>` | Message text posted alongside the file. |
| `--thread-ts <ts>` | Thread timestamp to share the file into. |
| `--alt-text <text>` | Alt text for the file. |
| `--snippet-type <type>` | Snippet type for text snippets. |
| `--dry-run` | Print the planned upload (file, byte length, channel, transport) without contacting Slack. |

The bytes are POSTed directly to Slack's upload host (the upload URL is itself the credential), so they never pass through the broker. The completing call rides the normal transport, so in proxy mode the broker still enforces the session's channel scoping — uploading into a channel outside the grant fails with a structured error.

### Top-level flags

| Flag | Purpose |
Expand Down
4 changes: 3 additions & 1 deletion docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Path: @/
- Supports automatic cursor pagination via `--paginate`, which fetches all pages and returns a single merged JSON response
- Supports `--dry-run` to preview resolved API requests without sending them -- designed as a safety net for coding agents to validate parameter resolution
- Supports `describe <method>` to look up parameter documentation for any Slack API method without requiring a token -- the metadata map covers all methods in `KNOWN_METHODS`, so agents always get full parameter documentation rather than a fallback
- Supports an `upload` subcommand that drives Slack's modern three-step external file upload flow -- a distinct capability because the raw byte upload cannot ride the dynamic `<method>` dispatch path (see [src/upload.ts](src/upload.ts))

### How it fits into the larger codebase
- Standalone repository (was originally imported from the `nori-integrations` monorepo and now lives on its own). Distributed via the public npm registry as `nori-slack-cli`
Expand All @@ -24,6 +25,7 @@ Path: @/
- The dynamic handler has three code paths: `--dry-run` short-circuits after param resolution (no credentials required, no API call, reports which transport would be used), `--paginate` runs the generic cursor loop `paginatePages()` + `mergePages()` from [src/paginate.ts](src/paginate.ts), and the default path makes a single `transport.call()`. The transport is resolved once per invocation and both API paths route through it, so behavior (including pagination) is identical in proxy and direct mode
- Two input modes: CLI flags (`--channel C123 --text "hi"`) and piped JSON via `--json-input`; when both are provided, CLI flags override stdin values
- Two discovery subcommands that do not require credentials: `list-methods` outputs known method names as JSON (supports `--namespace` filtering and `--descriptions` to include method descriptions), and `describe <method>` returns structured parameter documentation
- The `upload` subcommand, orchestrated by [src/upload.ts](src/upload.ts), runs Slack's external upload as three ordered steps: (1) `files.getUploadURLExternal` mints an upload URL + `file_id` via the transport, (2) the raw file bytes are POSTed DIRECTLY to Slack's upload host with no token (the URL is itself the credential, so these bytes never touch the broker proxy), and (3) `files.completeUploadExternal` shares the file via the transport. Because the completing call rides the normal transport, proxy-mode channel scoping is enforced by the broker at that step automatically. This flow is what Bolt exposes as `files.uploadV2` and is why it cannot be reached through the dynamic dispatch path
- `describe` uses [src/method-metadata.ts](src/method-metadata.ts), a hand-curated static map covering every method in `KNOWN_METHODS` -- this is static because `@slack/web-api` erases parameter type information at compile time, so runtime introspection is not possible
- For unknown methods (not in `KNOWN_METHODS`), `getMethodMetadata()` returns a fallback entry with empty params and a generated docs URL, so `describe` never errors; the `known` field in the output distinguishes curated entries from fallbacks
- When an unknown method is used, [src/suggest.ts](src/suggest.ts) provides fuzzy matching via Levenshtein distance against `KNOWN_METHODS`, surfacing "Did you mean?" suggestions; suggestions are non-blocking -- unknown methods still proceed to the API
Expand Down Expand Up @@ -53,7 +55,7 @@ dist/ (gitignored)
- A standalone `--flag` with no following value (or followed by another `--flag`) is treated as boolean `true`
- Error formatting in [src/errors.ts](src/errors.ts) maps Slack error codes to actionable suggestions (e.g., `channel_not_found` suggests running `conversations.list`); unknown errors get a generic suggestion pointing to the source directory. Broker errors from proxy mode are normalized into the same envelope, including extracting Slack platform codes embedded in broker messages
- Every error response includes a `source` field with the filesystem path to the CLI, so agents can locate the source code for debugging
- The method metadata in [src/method-metadata.ts](src/method-metadata.ts) marks `files.upload` as deprecated with a pointer to the two-step `files.getUploadURLExternal` + `files.completeUploadExternal` flow
- The method metadata in [src/method-metadata.ts](src/method-metadata.ts) marks `files.upload` as deprecated with a pointer to the `files.getUploadURLExternal` + `files.completeUploadExternal` flow -- the `upload` subcommand (see [src/upload.ts](src/upload.ts)) is the client-side orchestration of exactly that flow, so agents should reach for `upload` rather than the deprecated single-call `files.upload`
- The CLI version string is currently duplicated: once in [package.json](package.json) `version` and once as a hardcoded argument to Commander's `.version()` call in [src/index.ts](src/index.ts). Both must be bumped together on release
- **Packaging invariant**: anything that changes how the distributed artifact is produced must keep the `prepare` script, the `files` allowlist, `bin`, and [test/packaging.test.ts](test/packaging.test.ts) consistent. Concretely, any future change that removes `prepare`, removes `files`, emits generated code outside `dist/`, or adds a second bin entry needs matching updates in the allowlist and the packaging test -- otherwise `npm install -g nori-slack-cli` silently ships a broken binary (this was the exact `0.1.0` regression)

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "nori-slack-cli",
"version": "0.2.0",
"version": "0.3.0",
"description": "CLI for interacting with the Slack Web API, designed for coding agents",
"type": "module",
"bin": {
Expand Down
12 changes: 10 additions & 2 deletions src/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,33 @@
Path: @/src

### Overview
- Contains all source modules for the CLI: entry point, argument parsing, transport selection, error formatting, pagination, fuzzy method suggestion, the known-methods catalog, and the method metadata registry
- Contains all source modules for the CLI: entry point, argument parsing, transport selection, error formatting, pagination, fuzzy method suggestion, the known-methods catalog, the method metadata registry, and the external file-upload orchestrator ([upload.ts](upload.ts))
- Compiles from `src/` to `dist/` via TypeScript (ES2022 target, Node16 module resolution)

### How it fits into the larger codebase
- [index.ts](index.ts) is the CLI entry point (shebang `#!/usr/bin/env node`), compiled to `dist/index.js` and exposed as the `nori-slack` binary via the `bin` field in [../package.json](../package.json). The compiled `dist/` directory is produced at pack time by the `prepare` script and shipped to the npm registry via the `files` allowlist -- see [../docs.md](../docs.md) for the full packaging chain
- [transport.ts](transport.ts) is the only module that knows how to reach Slack. It selects between proxy mode (a Nori Sessions broker, configured by `NORI_SLACK_PROXY_URL` + `NORI_SLACK_CONTEXT_TOKEN`) and direct mode (`SLACK_BOT_TOKEN` via `@slack/web-api`); everything downstream works against its `Transport` interface
- [parse-args.ts](parse-args.ts) and [errors.ts](errors.ts) are pure utility modules with no side effects; [paginate.ts](paginate.ts) is transport-generic (no Slack SDK dependency). All are independently testable and tested in [@/test](../test/)
- [upload.ts](upload.ts) is transport-generic like [paginate.ts](paginate.ts): its first and third steps go through the `Transport` interface, while its middle step issues a raw `fetch` directly to Slack's upload host (bypassing the broker entirely). It is invoked only by the `upload` subcommand in [index.ts](index.ts)
- [methods.ts](methods.ts) is a static data file; it is only used by the `list-methods` subcommand and has no effect on which methods the CLI can actually call

### Core Implementation

**Entry point (`index.ts`)**
- Sets up Commander with three subcommands: `list-methods`, `describe`, and the default dynamic method handler
- Sets up Commander with the named subcommands `list-methods`, `describe`, and `upload`, plus the default dynamic method handler (`program.argument('<method>')`)
- The dynamic handler: optionally reads JSON from stdin, parses CLI flags, merges params (CLI flags win over stdin), then branches into three paths:
1. `--dry-run`: short-circuits immediately after param resolution -- outputs a JSON preview with `ok`, `dry_run`, `method`, `params`, `transport`, `token_present`, `paginate`, and optionally a `warning` for unknown methods. Does not require credentials. Always exits 0.
2. `--paginate`: resolves the transport via `resolveTransport()`, then runs `mergePages(paginatePages(transport, method, params))`
3. Default: resolves the transport, then makes a single `transport.call(method, params)`
- If `resolveTransport()` returns null (no credentials in either mode), the handler emits the `no_token` error envelope and exits 1 before any API path runs
- When no arguments are provided (`process.argv.length <= 2`), help text and error go to stderr and the process exits with code 2
- The `list-methods` subcommand supports two options that compose together: `--namespace <ns>` filters the method list to those starting with the given prefix (e.g., `chat.`), and `--descriptions` changes the output shape from `string[]` to `Array<{ method, description }>` by pulling descriptions from `getMethodMetadata()`. When `--namespace` is provided, a `namespace` field is added to the response JSON.
- The `upload` subcommand wires CLI flags (`--file` required; `--channel`, `--title`, `--filename`, `--initial-comment`, `--thread-ts`, `--alt-text`, `--snippet-type`, `--dry-run` optional) into `uploadFile()` from [upload.ts](upload.ts). Validation precedes any network access: a missing/empty `--file` exits 2; a `statSync` existence check exits 2 with "Cannot read file: \<path\>" if unreadable. `--dry-run` prints the plan (`ok`, `dry_run`, `command`, `file`, `filename`, `length`, `channel`, `title`, `transport`, `token_present`) and returns without contacting Slack -- `length` here is the on-disk file size from `statSync`. Missing credentials emit the `no_token` envelope and exit 1; upload failures are run through `formatError` and exit 1 (JSON on stdout plus "Error:"/"Suggestion:" on stderr), mirroring the default handler's error path

**External file upload (`upload.ts`)**
- `uploadFile(args)` orchestrates Slack's three-step external upload: (1) `transport.call('files.getUploadURLExternal', { filename, length, alt_text?, snippet_type? })` where `length` is the true byte count from reading the file (`bytes.length`), not a character count; (2) a raw `fetch(uploadUrl, { method: 'POST', headers: { 'content-type': 'application/octet-stream' }, body: bytes })` straight to Slack's upload host -- the URL is the credential, so no token is sent and the bytes never traverse the broker; (3) `transport.call('files.completeUploadExternal', { files: [{ id, title }], channel_id?, initial_comment?, thread_ts? })` to share the file
- It has no try/catch: it throws if `files.getUploadURLExternal` reports `ok:false`, does not return a string `upload_url` + `file_id`, or if the byte POST returns a non-2xx status, letting failures bubble up to the subcommand's boundary handler. The `ok:false` throw is shaped as a `slack_webapi_platform_error` (carrying `data.error`) so `formatError` maps the Slack code to a suggestion -- proxy mode returns Slack's `ok:false` body at HTTP 200 without the transport throwing, so without this the mint failure would fall through to the generic `unknown_error` envelope that direct mode never hits
- Because steps 1 and 3 ride the transport but step 2 does not, proxy-mode channel scoping is enforced by the broker only at the completing call -- the bytes are already uploaded by the time the channel gate is checked

**Transport selection (`transport.ts`)**
- `detectTransportMode(env)` returns `'proxy' | 'direct' | 'none'`: proxy when both `NORI_SLACK_PROXY_URL` and `NORI_SLACK_CONTEXT_TOKEN` are non-empty, otherwise direct when `SLACK_BOT_TOKEN` is set, otherwise none. Proxy takes precedence over a bot token
Expand Down Expand Up @@ -66,6 +73,7 @@ Path: @/src
- The `describe` command in [index.ts](index.ts) wraps `getMethodMetadata` output with `ok`, `method`, and `known` fields (where `known` is `true` only when the method has a curated entry in `METHOD_METADATA`)

### Things to Know
- `program.enablePositionalOptions()` is called immediately after `new Command()`. Without it, both the default command and the `upload` subcommand declare a `--dry-run` option, and Commander parses the program-level `--dry-run` instead of the subcommand's identically-named flag (so `upload`'s `opts.dryRun` came back undefined). Positional options scope each command's options to the tokens that follow its own name, fixing the collision while keeping the default-command paths (interleaved unknown options plus `--dry-run`/`--paginate`/`--json-input`) working
- `--json-input`, `--paginate`, and `--dry-run` are consumed by Commander as known options; all other flags pass through via `allowUnknownOption()` and are parsed by `parseArgs` from `process.argv`
- The raw args filter explicitly strips `--json-input`, `--paginate`, and `--dry-run` before passing to `parseArgs`, preventing them from being sent as Slack API parameters
- When both stdin JSON and CLI flags provide the same key, the CLI flag value wins due to spread order: `{ ...stdinParams, ...cliParams }`
Expand Down
Loading
Loading