Skip to content

Commit 4b2c3a2

Browse files
nori-sessions[bot]Spritenori-agent
authored
feat: add Nori Sessions broker proxy transport (0.2.0) (#3)
## Summary - Adds a transport seam (`src/transport.ts`): direct mode (`SLACK_BOT_TOKEN` via `@slack/web-api`) and proxy mode (`NORI_SLACK_PROXY_URL` + `NORI_SLACK_CONTEXT_TOKEN`, POSTing `{method, args}` to the Nori Sessions broker's `/method` endpoint). Proxy wins when both are configured, matching the sessions bash wrapper it replaces. - All CLI features now work identically in both modes: `--json-input`, `--paginate` (now a single transport-generic cursor loop), `--dry-run` (reports the selected `transport`), kebab→snake conversion, type coercion, and error suggestions. Broker errors are normalized into the standard error envelope, including extracting Slack platform codes from broker messages. - Removes the accidental self-dependency (`nori-slack-cli@^0.1.0`) from package.json. Bumps to 0.2.0. ## Why Nori Sessions currently ships a separate hand-rolled 74-line proxy client next to this CLI, selected by a branching bash wrapper. The two implementations diverged: in proxy mode (the managed-session default) `--json-input` fails outright, kebab-case flags are silently sent unconverted, and there is no coercion/pagination/discovery. This PR makes the real CLI speak the proxy wire protocol so the sessions repo can delete the duplicate (companion PR in nori-sessions). ## Test plan - [x] 13 new blackbox subprocess tests in `test/proxy-mode.test.ts` against a real local HTTP fake broker (wire format, precedence, trailing-slash URLs, `--json-input` pass-through, coercion, error mapping incl. 401, cursor pagination, dry-run transport reporting, no-credentials envelope) — written red-first - [x] Test helpers are hermetic: Slack credential env vars are stripped so suites pass identically on dev machines and inside Nori sessions (which export the proxy vars) - [x] Full suite green: 69 tests across 9 files, `tsc --noEmit` clean - [ ] After merge: publish `nori-slack-cli@0.2.0` to npm (@amol), then land the companion sessions PR that deletes the hand-rolled client and bumps base.lock.json Co-authored-by: Sprite <noreply@sprites.dev> Co-authored-by: Nori <contact@tilework.tech>
1 parent a8ebb68 commit 4b2c3a2

13 files changed

Lines changed: 524 additions & 107 deletions

README.md

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Bolt is built for human developers writing TypeScript. This CLI is built for cod
1010

1111
- **No interactive prompts, no ASCII art.** Every successful response is a single line of JSON on stdout. Errors are JSON on stdout *and* a human-readable line on stderr.
1212
- **Exhaustive surface.** The agent has access to the full Slack Web API — not a hand-picked subset. Capability boundaries are enforced through **bot token scopes**, not through code.
13-
- **Bot tokens only.** Uses `SLACK_BOT_TOKEN` exclusively. There is no user-OAuth flow because there is no human in the loop.
13+
- **Two transports, one interface.** Direct mode calls Slack with `SLACK_BOT_TOKEN`; proxy mode routes the same `{method, args}` calls through a Nori Sessions broker using `NORI_SLACK_PROXY_URL` + `NORI_SLACK_CONTEXT_TOKEN`, so managed sessions never hold a raw bot token. There is no user-OAuth flow because there is no human in the loop.
1414
- **Self-locating errors.** Every error response includes a `source` field with the on-disk path to the CLI, so an agent can read the source code to debug.
1515
- **Install from npm.** `npm install -g nori-slack-cli` puts `nori-slack` on your `PATH`. Cloning and building from source is also supported for contributors.
1616

@@ -32,10 +32,15 @@ npm run build
3232
npm link # makes `nori-slack` available globally
3333
```
3434

35-
Then set your bot token:
35+
Then set credentials for one of the two transports (see [Authentication](#authentication)):
3636

3737
```bash
38+
# Direct mode
3839
export SLACK_BOT_TOKEN=xoxb-...
40+
41+
# Proxy mode (set automatically inside Nori Sessions)
42+
export NORI_SLACK_PROXY_URL=https://broker.example.com/api/slack-proxy
43+
export NORI_SLACK_CONTEXT_TOKEN=...
3944
```
4045

4146
## Usage
@@ -83,12 +88,21 @@ nori-slack describe chat.postMessage
8388
### Exit codes
8489

8590
- `0` — success
86-
- `1` — Slack API error or missing token
91+
- `1` — Slack API error, proxy error, or missing credentials
8792
- `2` — bad CLI usage (missing args, invalid stdin JSON)
8893

8994
## Authentication
9095

91-
Set `SLACK_BOT_TOKEN` in the environment. The CLI does not read tokens from any other source. To control what the agent can do, scope the bot token in the Slack app's OAuth & Permissions page — the CLI itself imposes no method-level restrictions.
96+
The CLI supports two transports, selected from the environment:
97+
98+
| Mode | Environment | Behavior |
99+
| --- | --- | --- |
100+
| **Proxy** | `NORI_SLACK_PROXY_URL` + `NORI_SLACK_CONTEXT_TOKEN` | POSTs `{method, args}` to `<url>/method` with the context token as a bearer token. Used inside Nori Sessions, where the broker enforces a per-session access grant and the raw bot token never reaches the machine. |
101+
| **Direct** | `SLACK_BOT_TOKEN` | Calls the Slack Web API directly via `@slack/web-api`. |
102+
103+
When both are configured, **proxy mode wins**. All CLI features (`--json-input`, `--paginate`, `--dry-run`, kebab-case conversion, type coercion, error suggestions) behave identically in both modes. `--dry-run` reports which transport would be used via the `transport` field (`proxy`, `direct`, or `none`).
104+
105+
In direct mode, capability boundaries come from the bot token's OAuth scopes. In proxy mode, the broker additionally restricts methods and channels to the session's access grant — requests outside the grant fail with a structured `proxy_error`.
92106

93107
## License
94108

docs.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,25 @@ Path: @/
55
### Overview
66
- A TypeScript CLI that exposes the entire Slack Web API as a single command: `nori-slack <method> [--param value ...]`
77
- Designed for coding agents: all output is JSON on stdout, human-readable errors go to stderr
8-
- Uses `@slack/web-api` WebClient for dynamic dispatch -- the CLI is not limited to a fixed set of methods
8+
- Supports two transports, resolved in [src/transport.ts](src/transport.ts): direct calls to Slack via the `@slack/web-api` WebClient, or a Nori Sessions broker proxy. Dispatch is dynamic in both modes -- the CLI is not limited to a fixed set of methods
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
1212

1313
### How it fits into the larger codebase
1414
- 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`
1515
- The canonical install path is `npm install -g nori-slack-cli`, which places the `nori-slack` binary on `PATH`; `npm link` from a local clone is retained for contributors
16-
- Authentication is bot-token-only via `SLACK_BOT_TOKEN` environment variable (no user OAuth flows)
16+
- Two credential modes, no user OAuth flows: direct mode via the `SLACK_BOT_TOKEN` environment variable, and proxy mode via `NORI_SLACK_PROXY_URL` + `NORI_SLACK_CONTEXT_TOKEN` (both must be set; Nori session machines export them). Proxy mode takes precedence when both credential sets are present
17+
- Proxy mode exists so Nori Sessions can route Slack calls through its broker's scoped access grants. It replaced a separate hand-rolled proxy client script in the sessions repo, consolidating two diverging implementations of the same command behind this one CLI
1718
- The CLI is a thin wrapper -- it does not contain business logic, scheduling, or state management; it translates CLI flags into Slack API calls and returns the raw JSON response
18-
- The pagination merge logic in [src/paginate.ts](src/paginate.ts) is a pure function decoupled from the Slack SDK -- it operates on any `AsyncIterable` of page objects
19+
- The pagination logic in [src/paginate.ts](src/paginate.ts) is decoupled from the Slack SDK -- the cursor loop talks only to the `Transport` interface, and the merge step operates on any `AsyncIterable` of page objects
1920
- User-facing installation and usage documentation lives in [README.md](README.md)
2021

2122
### Core Implementation
2223
- Entry point is [src/index.ts](src/index.ts), which uses Commander.js with `allowUnknownOption()` so arbitrary `--flag value` pairs pass through without Commander rejecting them
23-
- The dynamic handler has three code paths: `--dry-run` short-circuits after param resolution (no token required, no API call), `--paginate` triggers `WebClient.paginate()` + `mergePages()`, and the default path uses `WebClient.apiCall()`
24+
- 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
2425
- Two input modes: CLI flags (`--channel C123 --text "hi"`) and piped JSON via `--json-input`; when both are provided, CLI flags override stdin values
25-
- Two discovery subcommands that do not require `SLACK_BOT_TOKEN`: `list-methods` outputs known method names as JSON (supports `--namespace` filtering and `--descriptions` to include method descriptions), and `describe <method>` returns structured parameter documentation
26+
- 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
2627
- `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
2728
- 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
2829
- 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
@@ -50,7 +51,7 @@ dist/ (gitignored)
5051
- Flag parsing in [src/parse-args.ts](src/parse-args.ts) converts `--kebab-case` to `snake_case` because the Slack API uses snake_case parameter names
5152
- Type coercion in `coerceValue` handles booleans (`"true"`/`"false"`), numbers (but preserves leading-zero strings like `"007"`), and inline JSON arrays/objects
5253
- A standalone `--flag` with no following value (or followed by another `--flag`) is treated as boolean `true`
53-
- 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
54+
- 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
5455
- Every error response includes a `source` field with the filesystem path to the CLI, so agents can locate the source code for debugging
5556
- 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
5657
- 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

package-lock.json

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

package.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "nori-slack-cli",
3-
"version": "0.1.1",
3+
"version": "0.2.0",
44
"description": "CLI for interacting with the Slack Web API, designed for coding agents",
55
"type": "module",
66
"bin": {
@@ -18,8 +18,7 @@
1818
},
1919
"dependencies": {
2020
"@slack/web-api": "^7.0.0",
21-
"commander": "^13.0.0",
22-
"nori-slack-cli": "^0.1.0"
21+
"commander": "^13.0.0"
2322
},
2423
"devDependencies": {
2524
"@types/node": "^22.0.0",

src/docs.md

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

55
### Overview
6-
- Contains all source modules for the CLI: entry point, argument parsing, error formatting, pagination merging, 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, and the method metadata registry
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
11-
- [parse-args.ts](parse-args.ts), [errors.ts](errors.ts), and [paginate.ts](paginate.ts) are pure utility modules with no side effects -- they are independently testable and tested in [@/test](../test/)
11+
- [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
12+
- [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/)
1213
- [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
1314

1415
### Core Implementation
1516

1617
**Entry point (`index.ts`)**
1718
- Sets up Commander with three subcommands: `list-methods`, `describe`, and the default dynamic method handler
1819
- The dynamic handler: optionally reads JSON from stdin, parses CLI flags, merges params (CLI flags win over stdin), then branches into three paths:
19-
1. `--dry-run`: short-circuits immediately after param resolution -- outputs a JSON preview with `ok`, `dry_run`, `method`, `params`, `token_present`, `paginate`, and optionally a `warning` for unknown methods. Does not require a token. Always exits 0.
20-
2. `--paginate`: validates token, then calls `client.paginate()` + `mergePages()`
21-
3. Default: validates token, then calls `client.apiCall()`
20+
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.
21+
2. `--paginate`: resolves the transport via `resolveTransport()`, then runs `mergePages(paginatePages(transport, method, params))`
22+
3. Default: resolves the transport, then makes a single `transport.call(method, params)`
23+
- 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
2224
- When no arguments are provided (`process.argv.length <= 2`), help text and error go to stderr and the process exits with code 2
2325
- 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.
2426

25-
**Pagination merging (`paginate.ts`)**
27+
**Transport selection (`transport.ts`)**
28+
- `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
29+
- `resolveTransport(env)` returns a `Transport` (`{ mode, call(method, params) }`) or `null` when no credentials are available
30+
- Proxy `call` POSTs `{ method, args }` as JSON to `<proxy-url>/method` (trailing slashes are stripped from the configured URL first) with an `authorization: Bearer <context token>` header. A 2xx response is returned as the raw Slack JSON body; a non-2xx response throws `ProxyError` (code `nori_slack_proxy_error`) carrying the HTTP status and the broker's `error` message
31+
- Direct `call` wraps `WebClient.apiCall` from `@slack/web-api`
32+
33+
**Pagination (`paginate.ts`)**
34+
- `paginatePages(transport, method, params)` is an async generator that repeatedly calls the transport, following `response_metadata.next_cursor` and terminating when the cursor is empty or missing. It replaces the old `WebClient.paginate()` path so pagination works identically over both transports
2635
- `mergePages(pages)` takes an `AsyncIterable` of page objects and returns a single merged object
2736
- Array-valued keys are concatenated across pages; scalar/metadata keys (`ok`, `response_metadata`, `headers`, `warning`) are overwritten with the last page's value
2837
- This design means the function works generically with any Slack method's response shape -- it does not need to know which key holds the data (e.g., `channels`, `members`, `messages`)
@@ -34,8 +43,10 @@ Path: @/src
3443

3544
**Error formatting (`errors.ts`)**
3645
- `formatError(error, sourceDir)` returns a `CliError` object with fields: `ok`, `error`, `message`, `suggestion`, `source`
37-
- Handles four specific `@slack/web-api` error codes: `slack_webapi_platform_error`, `slack_webapi_rate_limited_error`, `slack_webapi_request_error`, and the custom `no_token`
38-
- The `SUGGESTIONS` map provides agent-friendly remediation text for common Slack platform errors like `channel_not_found`, `not_in_channel`, `invalid_auth`, `rate_limited`, etc.
46+
- Handles five specific error codes: the `@slack/web-api` codes `slack_webapi_platform_error`, `slack_webapi_rate_limited_error`, and `slack_webapi_request_error`, plus the custom `no_token` and the proxy transport's `nori_slack_proxy_error`
47+
- For `nori_slack_proxy_error`: broker messages of the form "An API error occurred: \<code\>" have the Slack platform code extracted and mapped through the same `SUGGESTIONS` table as direct-mode platform errors; HTTP 401 maps to `proxy_unauthorized` with a context-token rotation suggestion; any other status maps to `proxy_error` with a suggestion about the session's access grant
48+
- Broker wire contract behind those mappings: 200 returns raw Slack JSON; error statuses (e.g., 401, 403, 404) return `{ error: message }`
49+
- The `SUGGESTIONS` map provides agent-friendly remediation text for common Slack platform errors like `channel_not_found`, `not_in_channel`, `rate_limited`, etc.
3950

4051
**Fuzzy method suggestion (`suggest.ts`)**
4152
- `findSimilarMethods(input, methods?, maxResults?)` returns up to 3 similar method names from `KNOWN_METHODS` for typo correction
@@ -60,5 +71,7 @@ Path: @/src
6071
- When both stdin JSON and CLI flags provide the same key, the CLI flag value wins due to spread order: `{ ...stdinParams, ...cliParams }`
6172
- Non-flag arguments (tokens not starting with `--`) are silently skipped by `parseArgs` -- they do not cause errors
6273
- Rate limit errors extract `retryAfter` from the `@slack/web-api` error object and include the retry duration in the message
74+
- The `--dry-run` output's `token_present` field only reflects `SLACK_BOT_TOKEN`; the `transport` field is the authoritative indicator of which mode would be used (proxy wins when both credential sets are present)
75+
- The missing-credentials error keeps the error code `no_token` for backward compatibility, but its message ("No Slack credentials provided.") and suggestion cover both credential sets
6376

6477
Created and maintained by Nori.

0 commit comments

Comments
 (0)