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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,26 @@ nori-slack upload --file ./report.pdf --channel C123 --dry-run

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.

### File downloads

Downloading a Slack file means fetching the bytes from Slack's private file host with a credential, which cannot ride the dynamic `<method>` path, so downloading gets its own subcommand:

```bash
# Download a file by ID to a local path
nori-slack download --id F123 --output ./report.pdf

# Preview the planned download without contacting Slack
nori-slack download --id F123 --output ./report.pdf --dry-run
```

| Flag | Purpose |
| --- | --- |
| `--id <id>` | Slack file ID to download (required). |
| `--output <path>` | Local path to write the bytes to (required). |
| `--dry-run` | Print the planned download (file ID, output, transport) without contacting Slack. |

In direct mode the CLI looks up the file's private download URL and fetches it with the bot token. In proxy mode the bytes come from the broker's `download` endpoint (the session has no bot token), so the broker enforces that the file is shared in the session's access grant — downloading a file outside the grant fails with a structured error.

### Top-level flags

| Flag | Purpose |
Expand Down
2 changes: 2 additions & 0 deletions docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Path: @/
- 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))
- Supports a `download` subcommand that fetches a Slack file's bytes by ID and writes them to a local path -- a distinct capability because the authenticated byte fetch from Slack's private file host cannot ride the dynamic `<method>` dispatch path (see [src/download.ts](src/download.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 @@ -26,6 +27,7 @@ Path: @/
- 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
- The `download` subcommand, orchestrated by [src/download.ts](src/download.ts), delegates the transfer to `transport.downloadFile({ fileId })` and writes the returned bytes to `--output`. Each transport implements the fetch differently: direct mode calls `files.info` for the file's `url_private_download` and GETs it with the bot token as a Bearer credential; proxy mode GETs `<proxy>/download?file=<id>` with the context token, because the session has no bot token and the broker holds the file-shared access grant. The split lives in the `Transport` interface so `download.ts` never branches on transport mode
- `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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "nori-slack-cli",
"version": "0.3.0",
"version": "0.4.0",
"description": "CLI for interacting with the Slack Web API, designed for coding agents",
"type": "module",
"bin": {
Expand All @@ -13,7 +13,7 @@
"build": "tsc",
"postbuild": "chmod +x dist/index.js",
"prepare": "npm run build",
"test": "vitest run",
"test": "vitest run --no-file-parallelism",
"test:watch": "vitest"
},
"dependencies": {
Expand Down
23 changes: 23 additions & 0 deletions src/download.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { writeFile } from 'node:fs/promises';
import type { Transport } from './transport.js';

export interface DownloadArgs {
transport: Transport;
fileId: string;
outputPath: string;
}

export async function downloadFile(args: DownloadArgs): Promise<Record<string, any>> {
const { transport, fileId, outputPath } = args;
const { bytes, contentType, filename } = await transport.downloadFile({ fileId });
await writeFile(outputPath, bytes);
return {
ok: true,
command: 'download',
file_id: fileId,
output: outputPath,
filename,
bytes: bytes.length,
content_type: contentType,
};
}
62 changes: 61 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { detectTransportMode, resolveTransport } from './transport.js';
import { getMethodMetadata, METHOD_METADATA } from './method-metadata.js';
import { findSimilarMethods } from './suggest.js';
import { uploadFile } from './upload.js';
import { downloadFile } from './download.js';
import { fileURLToPath } from 'node:url';
import { statSync } from 'node:fs';
import path from 'node:path';
Expand All @@ -27,7 +28,7 @@ program.enablePositionalOptions();
program
.name('nori-slack')
.description('CLI for the Slack Web API. Designed for coding agents.\n\nUsage: nori-slack <method> [--param value ...]\n\nExamples:\n nori-slack chat.postMessage --channel C123 --text "Hello"\n nori-slack conversations.list --limit 10\n nori-slack api.test --foo bar\n echo \'{"channel":"C123","text":"hi"}\' | nori-slack chat.postMessage --json-input')
.version('0.3.0');
.version('0.4.0');

program
.command('list-methods')
Expand Down Expand Up @@ -169,6 +170,65 @@ program
}
});

program
.command('download')
.description('Download a Slack file by ID and write its bytes to a local path. Example: nori-slack download --id F123 --output ./report.pdf')
.option('--id <id>', 'Slack file ID to download (e.g., F123)')
.option('--output <path>', 'Local path to write the downloaded bytes to')
.option('--dry-run', 'Preview the planned download without contacting Slack')
.action(async (opts: { id?: string; output?: string; dryRun?: boolean }) => {
const fileId = opts.id;
if (typeof fileId !== 'string' || fileId.length === 0) {
const error = formatError(
new Error('download requires --id <id>. Example: nori-slack download --id F123 --output ./report.pdf'),
SOURCE_DIR
);
process.stdout.write(JSON.stringify(error) + '\n');
process.exit(2);
}

const outputPath = opts.output;
if (typeof outputPath !== 'string' || outputPath.length === 0) {
const error = formatError(
new Error('download requires --output <path>. Example: nori-slack download --id F123 --output ./report.pdf'),
SOURCE_DIR
);
process.stdout.write(JSON.stringify(error) + '\n');
process.exit(2);
}

if (opts.dryRun) {
const dryRunResult = {
ok: true,
dry_run: true,
command: 'download',
file_id: fileId,
output: outputPath,
transport: detectTransportMode(),
token_present: !!process.env.SLACK_BOT_TOKEN,
};
process.stdout.write(JSON.stringify(dryRunResult) + '\n');
return;
}

const transport = resolveTransport();
if (!transport) {
const error = formatError({ code: 'no_token' }, SOURCE_DIR);
process.stdout.write(JSON.stringify(error) + '\n');
process.exit(1);
}

try {
const result = await downloadFile({ transport, fileId, outputPath });
process.stdout.write(JSON.stringify(result) + '\n');
} catch (err) {
const error = formatError(err, SOURCE_DIR);
process.stdout.write(JSON.stringify(error) + '\n');
process.stderr.write(`Error: ${error.message}\nSuggestion: ${error.suggestion}\n`);
process.exit(1);
}
});

program
.argument('<method>', 'Slack Web API method (e.g., chat.postMessage)')
.option('--json-input', 'Read parameters as JSON from stdin')
Expand Down
56 changes: 55 additions & 1 deletion src/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@ import { WebClient } from '@slack/web-api';

export type TransportMode = 'proxy' | 'direct' | 'none';

export interface DownloadResult {
bytes: Buffer;
contentType: string | null;
filename: string | null;
}

export interface Transport {
mode: 'proxy' | 'direct';
call(method: string, params: Record<string, unknown>): Promise<Record<string, any>>;
downloadFile(args: { fileId: string }): Promise<DownloadResult>;
}

export const PROXY_ERROR_CODE = 'nori_slack_proxy_error';
Expand Down Expand Up @@ -54,16 +61,63 @@ export function resolveTransport(env: NodeJS.ProcessEnv = process.env): Transpor
}
return body;
},
async downloadFile({ fileId }) {
const res = await fetch(`${baseUrl}/download?file=${encodeURIComponent(fileId)}`, {
headers: { authorization: `Bearer ${contextToken}` },
});
if (!res.ok) {
const text = await res.text();
let body: any = null;
try {
body = JSON.parse(text);
} catch {
// Non-JSON body: fall through with the raw text as the error message.
}
throw new ProxyError(res.status, typeof body?.error === 'string' ? body.error : text);
}
return {
bytes: Buffer.from(await res.arrayBuffer()),
contentType: res.headers.get('content-type'),
filename: null,
};
},
};
}

if (mode === 'direct') {
const client = new WebClient(env.SLACK_BOT_TOKEN);
const botToken = env.SLACK_BOT_TOKEN!;
const client = new WebClient(botToken);
return {
mode,
call(method, params) {
return client.apiCall(method, params) as Promise<Record<string, any>>;
},
async downloadFile({ fileId }) {
const info = await this.call('files.info', { file: fileId });
const file = info.file as { url_private_download?: string; name?: string } | undefined;
const url = file?.url_private_download;
if (!url) {
throw new Error(`files.info did not return a download URL for file ${fileId}`);
}
const res = await fetch(url, { headers: { authorization: `Bearer ${botToken}` } });
if (!res.ok) {
throw new Error(`Failed to download file ${fileId}: HTTP ${res.status}`);
}
const contentType = res.headers.get('content-type');
// Slack's file host answers an unauthenticated request with HTTP 200 and
// an HTML sign-in page rather than an error status, so a rejected bot
// token would otherwise be written to disk as a "successful" download.
if (contentType != null && contentType.includes('text/html')) {
throw new Error(
`Slack returned an HTML page instead of file ${fileId}; the bot token was not authenticated for this file`,
);
}
return {
bytes: Buffer.from(await res.arrayBuffer()),
contentType,
filename: file?.name ?? null,
};
},
};
}

Expand Down
71 changes: 71 additions & 0 deletions test/download-direct.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import { resolveTransport } from '../src/transport.js';

// Direct mode cannot make real Slack calls in tests, so the files.info metadata
// lookup (the Slack boundary) is stubbed by overriding the transport's own
// `call`, while the byte fetch runs for real against a local host. This proves
// the security-relevant behavior: the private download is fetched with the bot
// token as a Bearer credential and the exact bytes are returned uncorrupted.
describe('download (direct mode)', () => {
let fileHost: http.Server;
let fileUrl: string;
let received: { authorization?: string } = {};
let respond: (res: http.ServerResponse) => void;

const fileBytes = Buffer.from([0x25, 0x50, 0x44, 0x46, 0xc3, 0xa9, 0x00, 0xff]);

beforeEach(async () => {
received = {};
respond = (res) => {
res.writeHead(200, { 'content-type': 'application/pdf' });
res.end(fileBytes);
};
fileHost = http.createServer((req, res) => {
received.authorization = req.headers.authorization;
respond(res);
});
await new Promise<void>((resolve) => fileHost.listen(0, '127.0.0.1', resolve));
const port = (fileHost.address() as AddressInfo).port;
fileUrl = `http://127.0.0.1:${port}/private/F123`;
});

afterEach(async () => {
await new Promise<void>((resolve) => fileHost.close(() => resolve()));
});

const directTransport = () => {
const transport = resolveTransport({ SLACK_BOT_TOKEN: 'xoxb-test-token' } as NodeJS.ProcessEnv);
expect(transport?.mode).toBe('direct');
transport!.call = async (method: string, params: Record<string, unknown>) => {
expect(method).toBe('files.info');
expect(params).toEqual({ file: 'F123' });
return {
ok: true,
file: { id: 'F123', name: 'doc.pdf', mimetype: 'application/pdf', url_private_download: fileUrl },
};
};
return transport!;
};

it('fetches the private download URL with the bot token and returns the exact bytes', async () => {
const transport = directTransport();

const result = await transport.downloadFile({ fileId: 'F123' });

expect(received.authorization).toBe('Bearer xoxb-test-token');
expect(result.bytes.equals(fileBytes)).toBe(true);
expect(result.contentType).toBe('application/pdf');
});

it('rejects the HTML login page Slack serves with HTTP 200 when the bot token is rejected', async () => {
respond = (res) => {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end('<!DOCTYPE html><html><body>You are not signed in</body></html>');
};
const transport = directTransport();

await expect(transport.downloadFile({ fileId: 'F123' })).rejects.toThrow(/not authenticated|html|sign/i);
});
});
Loading
Loading