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
37 changes: 37 additions & 0 deletions .github/workflows/main-ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Runs on push to main branch (post-merge)

name: main-ci

on:
push:
branches:
- main

jobs:
ci:
strategy:
matrix:
os: [ubuntu-latest]

runs-on: ${{ matrix.os }}

steps:
- uses: actions/checkout@v3

- name: Set up root node
uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'

- name: Install dependencies
run: npm install

- name: Build
run: npm run build

- name: Run tests
run: npm test
env:
NODE_OPTIONS: --max-old-space-size=4096
37 changes: 37 additions & 0 deletions .github/workflows/pr-ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Runs on pull requests to main branch

name: pr-ci

on:
pull_request:
branches:
- main

jobs:
ci:
strategy:
matrix:
os: [ubuntu-latest]

runs-on: ${{ matrix.os }}

steps:
- uses: actions/checkout@v3

- name: Set up root node
uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'

- name: Install dependencies
run: npm install

- name: Build
run: npm run build

- name: Run tests
run: npm test
env:
NODE_OPTIONS: --max-old-space-size=4096
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,18 @@ Bolt is built for human developers writing TypeScript. This CLI is built for cod
- **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.
- **Bot tokens only.** Uses `SLACK_BOT_TOKEN` exclusively. There is no user-OAuth flow because there is no human in the loop.
- **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.
- **Distributed as source.** Build it locally; the postbuild step makes `nori-slack` available on your `PATH`.
- **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.

## Install

From npm:

```bash
npm install -g nori-slack-cli
```

From source (for contributors):

```bash
git clone https://github.com/tilework-tech/nori-slack-cli.git
cd nori-slack-cli
Expand Down
32 changes: 25 additions & 7 deletions docs.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Noridoc: nori-slack-cli

Path: @/nori-slack-cli
Path: @/

### Overview
- A TypeScript CLI that exposes the entire Slack Web API as a single command: `nori-slack <method> [--param value ...]`
Expand All @@ -11,31 +11,49 @@ Path: @/nori-slack-cli
- 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

### How it fits into the larger codebase
- Lives as a standalone tool under the `nori-integrations` monorepo, in the `slack` worktree
- Intended to be `npm link`ed or installed globally so agents can invoke `nori-slack` from any working directory
- 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`
- 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
- Authentication is bot-token-only via `SLACK_BOT_TOKEN` environment variable (no user OAuth flows)
- 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
- The project spec lives in [spec/APPLICATION-SPEC.md](spec/APPLICATION-SPEC.md)
- 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
- User-facing installation and usage documentation lives in [README.md](README.md)

### Core Implementation
- 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
- 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()`
- 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 `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 (required params, optional params, pagination support, deprecation notices, and docs URL)
- 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
- `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 up to 3 "Did you mean?" suggestions in both `--dry-run` JSON output and stderr warnings before API calls; suggestions are non-blocking -- unknown methods still proceed to the API
- 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
- Successful API responses and error responses both go to stdout as JSON; errors additionally write a human-readable line to stderr
- Exit codes: `0` for success, `1` for API/token errors, `2` for missing args or invalid stdin JSON

### Packaging and distribution

The published npm artifact is assembled at pack time, not committed to git. The relevant `package.json` fields form a single chain that must stay in sync:

```
dist/ (gitignored)
└─ produced by `prepare: "npm run build"` (runs on npm pack / npm publish / npm install from tarball)
└─ made executable by `postbuild: "chmod +x dist/index.js"`
└─ included in the tarball by `files: ["dist"]`
└─ exposed as `nori-slack` via `bin: { "nori-slack": "./dist/index.js" }`
└─ verified end-to-end by test/packaging.test.ts on every `npm test`
```

- `test/packaging.test.ts` runs `npm pack`, installs the resulting tarball into a tmpdir, and executes the installed `nori-slack` binary to confirm `dist/` actually ships. See [test/docs.md](test/docs.md) for test-level detail
- CI is defined in [.github/workflows/pr-ci.yaml](.github/workflows/pr-ci.yaml) (on pull requests to `main`) and [.github/workflows/main-ci.yaml](.github/workflows/main-ci.yaml) (on push to `main`). Both mirror `nori-registrar` conventions: checkout, `actions/setup-node` reading Node version from [.nvmrc](.nvmrc), `npm install`, `npm run build`, `npm test`
- [.nvmrc](.nvmrc) pins Node 22 to match the `nori-registrar` baseline

### Things to Know
- 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
- Type coercion in `coerceValue` handles booleans (`"true"`/`"false"`), numbers (but preserves leading-zero strings like `"007"`), and inline JSON arrays/objects
- 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
- 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 `postbuild` script runs `chmod +x` on the output and `npm link` to make the binary available immediately after build
- 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)

Created and maintained by Nori.
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
{
"name": "nori-slack-cli",
"version": "0.1.0",
"version": "0.1.1",
"description": "CLI for interacting with the Slack Web API, designed for coding agents",
"type": "module",
"bin": {
"nori-slack": "./dist/index.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"postbuild": "chmod +x dist/index.js",
"prepare": "npm run build",
"test": "vitest run",
"test:watch": "vitest"
},
Expand Down
8 changes: 4 additions & 4 deletions src/docs.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# Noridoc: nori-slack-cli/src
# Noridoc: src

Path: @/nori-slack-cli/src
Path: @/src

### Overview
- 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
- 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 `package.json` `bin` field
- [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 [@/nori-slack-cli/test](../test/)
- [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
- [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/)
- [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
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const program = new Command();
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.1.0');
.version('0.1.1');

program
.command('list-methods')
Expand Down
21 changes: 15 additions & 6 deletions test/docs.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
# Noridoc: nori-slack-cli/test
# Noridoc: test

Path: @/nori-slack-cli/test
Path: @/test

### Overview
- Unit tests for `parseArgs`, `formatError`, `mergePages`, and method metadata coverage, plus integration tests that invoke the CLI as a subprocess
- Uses Vitest as the test runner; integration tests in `cli.test.ts` use `tsx` to run TypeScript source directly, while `build.test.ts` compiles via `tsc` and runs the built `dist/index.js` artifact
- Unit tests for `parseArgs`, `formatError`, `mergePages`, and method metadata coverage, plus integration tests that invoke the CLI as a subprocess, plus an end-to-end packaging test that installs the npm tarball
- Uses Vitest as the test runner; integration tests in `cli.test.ts` use `tsx` to run TypeScript source directly, `build.test.ts` compiles via `tsc` and runs the built `dist/index.js` artifact, and `packaging.test.ts` runs `npm pack` and installs the tarball into a tmpdir

### How it fits into the larger codebase
- Tests cover the pure utility modules in [@/nori-slack-cli/src](../src/): argument parsing, error formatting, pagination merging, and method metadata
- Tests cover the pure utility modules in [@/src](../src/): argument parsing, error formatting, pagination merging, and method metadata
- Integration tests in [cli.test.ts](cli.test.ts) exercise the full CLI binary by spawning `npx tsx src/index.ts` as a child process, verifying end-to-end behavior including exit codes, stdout JSON structure, and stderr output
- [packaging.test.ts](packaging.test.ts) closes the loop on the npm distribution path documented in [@/docs.md](../docs.md) -- it is the guard against the `0.1.0` regression where `dist/` was missing from the published tarball
- All tests run on every PR and on every push to `main` via the workflows in [@/.github/workflows](../.github/workflows/)
- The test directory is excluded from TypeScript compilation via `tsconfig.json`

### Core Implementation
Expand Down Expand Up @@ -45,12 +47,19 @@ Path: @/nori-slack-cli/test

**`build.test.ts`** -- Build verification tests that exercise the compiled output:
- `beforeAll` runs `tsc` once; all tests share the resulting `dist/index.js`
- Uses `node dist/index.js` directly (unlike `cli.test.ts` which uses `npx tsx src/index.ts`), verifying the actual build artifact that `npm link` would expose
- Uses `node dist/index.js` directly (unlike `cli.test.ts` which uses `npx tsx src/index.ts`), verifying the actual build artifact that a global install would expose
- Validates `--version` output, `list-methods` JSON structure, and no-args usage error exit code

**`packaging.test.ts`** -- End-to-end packaging test that validates the npm distribution path:
- `beforeAll` creates two tmpdirs, runs `npm pack` on the project root to produce a `.tgz`, then `npm init -y` + `npm install --no-save <tarball>` in the second tmpdir to simulate a downstream install
- Asserts the installed `node_modules/.bin/nori-slack` binary exists and that running it with `list-methods --namespace chat` returns exit 0 with JSON containing `chat.postMessage`
- Runs on every `npm test` invocation (not gated) so the tarball contents are continuously verified; the `beforeAll` has a 180s timeout because `npm pack` + `npm install` of the tarball is slow
- This test is the enforcement mechanism for the packaging invariant documented in [@/docs.md](../docs.md): if `prepare`, `files`, or `bin` regress, this test fails before a broken version can be published

### Things to Know
- Integration tests make real HTTP calls to Slack's API (with invalid tokens), so they require network access
- The `runCli` helper sets a 10-second timeout to prevent hangs
- Tests intentionally verify structure (JSON shape, field presence, field types) rather than exact string values, making them resilient to Slack API message changes
- `packaging.test.ts` shells out to `npm` and writes into `os.tmpdir()`, so CI runners must have npm available and writable tmp space

Created and maintained by Nori.
73 changes: 73 additions & 0 deletions test/packaging.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execFileSync } from 'node:child_process';
import {
mkdtempSync,
rmSync,
readdirSync,
existsSync,
copyFileSync,
cpSync,
symlinkSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.resolve(__dirname, '..');

describe('Package install from tarball', () => {
let sourceDir: string;
let packDir: string;
let installDir: string;
let tarballPath: string;

beforeAll(() => {
sourceDir = mkdtempSync(path.join(tmpdir(), 'nori-slack-source-'));
packDir = mkdtempSync(path.join(tmpdir(), 'nori-slack-pack-'));
installDir = mkdtempSync(path.join(tmpdir(), 'nori-slack-install-'));

copyFileSync(path.join(PROJECT_ROOT, 'package.json'), path.join(sourceDir, 'package.json'));
copyFileSync(path.join(PROJECT_ROOT, 'tsconfig.json'), path.join(sourceDir, 'tsconfig.json'));
copyFileSync(path.join(PROJECT_ROOT, 'README.md'), path.join(sourceDir, 'README.md'));
copyFileSync(path.join(PROJECT_ROOT, 'LICENSE'), path.join(sourceDir, 'LICENSE'));
cpSync(path.join(PROJECT_ROOT, 'src'), path.join(sourceDir, 'src'), { recursive: true });
symlinkSync(path.join(PROJECT_ROOT, 'node_modules'), path.join(sourceDir, 'node_modules'));

execFileSync('npm', ['pack', '--pack-destination', packDir], {
cwd: sourceDir,
stdio: 'pipe',
});

const tarball = readdirSync(packDir).find((f) => f.endsWith('.tgz'));
if (!tarball) throw new Error('npm pack did not produce a .tgz');
tarballPath = path.join(packDir, tarball);

execFileSync('npm', ['init', '-y'], { cwd: installDir, stdio: 'pipe' });
execFileSync('npm', ['install', '--no-save', tarballPath], {
cwd: installDir,
stdio: 'pipe',
});
}, 180000);

afterAll(() => {
if (sourceDir) rmSync(sourceDir, { recursive: true, force: true });
if (packDir) rmSync(packDir, { recursive: true, force: true });
if (installDir) rmSync(installDir, { recursive: true, force: true });
});

it('installs a working nori-slack binary that lists methods', { timeout: 30000 }, () => {
const binPath = path.join(installDir, 'node_modules', '.bin', 'nori-slack');
expect(existsSync(binPath)).toBe(true);

const output = execFileSync(binPath, ['list-methods', '--namespace', 'chat'], {
cwd: installDir,
env: { ...process.env, SLACK_BOT_TOKEN: '' },
encoding: 'utf-8',
});

const parsed = JSON.parse(output);
expect(parsed.namespace).toBe('chat');
expect(parsed.methods).toContain('chat.postMessage');
});
});
Loading