Skip to content

Commit 68c9277

Browse files
nori-sessions[bot]theahuranori-agent
authored
feat: add upload subcommand for Slack external file uploads (#5)
## Summary 🤖 Generated with [Nori](https://noriagentic.com/) - Adds a `nori-slack upload` subcommand that drives Slack's modern three-step external file upload flow: `files.getUploadURLExternal` mints an upload URL, the raw bytes are POSTed directly to Slack's upload host, and `files.completeUploadExternal` shares the file into a channel. The byte POST cannot ride the dynamic `<method>` dispatch path (it's a binary POST to a different host with no token), which is why uploads need their own subcommand. The mint and completing calls ride the transport, so in proxy mode the broker still enforces the session's channel scoping at completion. - Calls `program.enablePositionalOptions()` so the default command's `--dry-run` flag no longer shadows the upload subcommand's identically-named flag (Commander was attributing it to the program scope). - Maps a proxy-mode `ok:false` mint response (returned at HTTP 200, so the transport doesn't throw) to the Slack `slack_webapi_platform_error` shape, so `formatError` surfaces an actionable suggestion instead of a generic `unknown_error` — matching direct-mode behavior. - Bumps version 0.2.0 → 0.3.0 (in-repo only; no publish). Docs (README + noridocs) updated. ## Test Plan - [x] `npm run build` (tsc typecheck) passes - [x] Full suite green (77 tests), including 8 new black-box upload tests covering: happy path (mint → byte POST → complete), title defaulting, channel-denied 403 after bytes uploaded, missing/nonexistent `--file` (exit 2, no network), mint failure mapped to a Slack code, byte-POST HTTP failure (exit 1), and `--dry-run` - [ ] Manual smoke: `nori-slack upload --file ./report.pdf --channel C123 --dry-run` previews the plan; a real upload in a Nori session shares the file into the channel Share Nori with your team: https://www.npmjs.com/package/nori-skillsets Co-authored-by: amol <amol@tilework.tech> Co-authored-by: Nori <contact@tilework.tech>
1 parent 4b2c3a2 commit 68c9277

10 files changed

Lines changed: 453 additions & 13 deletions

File tree

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,36 @@ nori-slack list-methods --descriptions
7777
nori-slack describe chat.postMessage
7878
```
7979

80+
### File uploads
81+
82+
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:
83+
84+
```bash
85+
# Upload a local file and share it into a channel
86+
nori-slack upload --file ./report.pdf --channel C123
87+
88+
# Add a title, a message, and post it into a thread
89+
nori-slack upload --file ./report.pdf --channel C123 \
90+
--title "Q3 Report" --initial-comment "Numbers are in" --thread-ts 1700000000.000100
91+
92+
# Preview the planned upload without contacting Slack
93+
nori-slack upload --file ./report.pdf --channel C123 --dry-run
94+
```
95+
96+
| Flag | Purpose |
97+
| --- | --- |
98+
| `--file <path>` | Local file to upload (required). |
99+
| `--channel <id>` | Channel to share the file into. |
100+
| `--title <title>` | File title (defaults to the filename). |
101+
| `--filename <name>` | Filename registered with Slack (defaults to the basename of `--file`). |
102+
| `--initial-comment <text>` | Message text posted alongside the file. |
103+
| `--thread-ts <ts>` | Thread timestamp to share the file into. |
104+
| `--alt-text <text>` | Alt text for the file. |
105+
| `--snippet-type <type>` | Snippet type for text snippets. |
106+
| `--dry-run` | Print the planned upload (file, byte length, channel, transport) without contacting Slack. |
107+
108+
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.
109+
80110
### Top-level flags
81111

82112
| Flag | Purpose |

docs.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Path: @/
99
- Supports automatic cursor pagination via `--paginate`, which fetches all pages and returns a single merged JSON response
1010
- Supports `--dry-run` to preview resolved API requests without sending them -- designed as a safety net for coding agents to validate parameter resolution
1111
- 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
12+
- 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))
1213

1314
### How it fits into the larger codebase
1415
- 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`
@@ -24,6 +25,7 @@ Path: @/
2425
- 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
2526
- Two input modes: CLI flags (`--channel C123 --text "hi"`) and piped JSON via `--json-input`; when both are provided, CLI flags override stdin values
2627
- 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
28+
- 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
2729
- `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
2830
- 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
2931
- 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
@@ -53,7 +55,7 @@ dist/ (gitignored)
5355
- A standalone `--flag` with no following value (or followed by another `--flag`) is treated as boolean `true`
5456
- 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
5557
- Every error response includes a `source` field with the filesystem path to the CLI, so agents can locate the source code for debugging
56-
- 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
58+
- 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`
5759
- 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
5860
- **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)
5961

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "nori-slack-cli",
3-
"version": "0.2.0",
3+
"version": "0.3.0",
44
"description": "CLI for interacting with the Slack Web API, designed for coding agents",
55
"type": "module",
66
"bin": {

src/docs.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,33 @@
33
Path: @/src
44

55
### Overview
6-
- 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
6+
- 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))
77
- Compiles from `src/` to `dist/` via TypeScript (ES2022 target, Node16 module resolution)
88

99
### How it fits into the larger codebase
1010
- [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
1111
- [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
1212
- [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/)
13+
- [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)
1314
- [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
1415

1516
### Core Implementation
1617

1718
**Entry point (`index.ts`)**
18-
- Sets up Commander with three subcommands: `list-methods`, `describe`, and the default dynamic method handler
19+
- Sets up Commander with the named subcommands `list-methods`, `describe`, and `upload`, plus the default dynamic method handler (`program.argument('<method>')`)
1920
- The dynamic handler: optionally reads JSON from stdin, parses CLI flags, merges params (CLI flags win over stdin), then branches into three paths:
2021
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.
2122
2. `--paginate`: resolves the transport via `resolveTransport()`, then runs `mergePages(paginatePages(transport, method, params))`
2223
3. Default: resolves the transport, then makes a single `transport.call(method, params)`
2324
- 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
2425
- When no arguments are provided (`process.argv.length <= 2`), help text and error go to stderr and the process exits with code 2
2526
- 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.
27+
- 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
28+
29+
**External file upload (`upload.ts`)**
30+
- `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
31+
- 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
32+
- 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
2633

2734
**Transport selection (`transport.ts`)**
2835
- `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
@@ -66,6 +73,7 @@ Path: @/src
6673
- 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`)
6774

6875
### Things to Know
76+
- `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
6977
- `--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`
7078
- 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
7179
- When both stdin JSON and CLI flags provide the same key, the CLI flag value wins due to spread order: `{ ...stdinParams, ...cliParams }`

0 commit comments

Comments
 (0)