Skip to content
Closed
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- **Canvas create** (`canvas create`): Create a new canvas via `canvases.create`
- Content from inline `--content`, a `--file`, or `--stdin`
- Optional `--title` and `--channel` (channel required on free teams)
- `--json` output includes the new `canvas_id`
- Requires the `canvases:write` scope (standard tokens) or browser auth

## [0.2.0] - 2026-01-30

### Added
Expand Down
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ A fast, developer-friendly command-line interface tool for interacting with Slac
- 🏢 **Multi-Workspace Management**: Manage multiple Slack workspaces with ease
- 💬 **Conversation Management**: List channels, read messages, send messages
- 🎉 **Message Reactions**: Add emoji reactions to messages programmatically
- 📄 **Canvas Support**: List and read Slack canvas documents as markdown
- 📄 **Canvas Support**: List, read, and create Slack canvas documents as markdown
- 🚀 **Fast & Lightweight**: Built with Bun for blazing fast performance
- 🔄 **Auto-Update**: Built-in self-update mechanism
- 🎨 **Beautiful Output**: Colorful, user-friendly terminal output
Expand Down Expand Up @@ -245,8 +245,33 @@ slackcli canvas read F1234567890 --raw

# Read the canvas associated with a channel
slackcli canvas read --channel=C1234567890

# Create a canvas with inline markdown
slackcli canvas create --title="Sprint Notes" --content="# Sprint 42"

# Create a canvas from a file, tabbed into a channel
# (--channel is required on free Slack teams)
slackcli canvas create --title="Runbook" --file=./runbook.md --channel=C1234567890

# Create a canvas from stdin
cat notes.md | slackcli canvas create --title="Imported" --stdin

# Create and print machine-readable output (includes canvas_id)
slackcli canvas create --title="Empty start" --json
```

> **Token support for canvas commands**
>
> Due to Slack API limitations, **creating a canvas works only with Standard Slack tokens** (`xoxb`/`xoxp`) that carry the `canvases:write` scope. **Browser tokens (`xoxd`/`xoxc`) cannot create canvases** — the canvas write API rejects browser session auth. Listing and reading canvases work with **both** token types.
>
> | Command | Standard token (`xoxb`/`xoxp`) | Browser token (`xoxd`/`xoxc`) |
> |---|:---:|:---:|
> | `canvas list` | ✅ | ✅ |
> | `canvas read` | ✅ | ✅ |
> | `canvas create` | ✅ (needs `canvases:write`) | ❌ |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major: contradictory browser-token claim

Why: this table says canvas create with a browser token is not supported, but CHANGELOG.md:15 says it requires canvases:write "or browser auth" and the PR body says it works with both. The code routes through request() which dispatches to browserRequest, and only a standard token was live-tested, so the not-supported claim is unverified and inconsistent.

Fix: test canvases.create with a browser token, then make README and CHANGELOG agree.

>
> Reading a canvas additionally requires the `files:read` scope on standard tokens.

### Update Commands

```bash
Expand Down
74 changes: 74 additions & 0 deletions src/commands/canvas.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'bun:test';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { buildCreateParams, resolveCanvasMarkdown } from './canvas';

describe('buildCreateParams', () => {
it('returns empty params when nothing is provided', () => {
expect(buildCreateParams({})).toEqual({});
});

it('includes only the title when no markdown or channel', () => {
expect(buildCreateParams({ title: 'Sprint Notes' })).toEqual({ title: 'Sprint Notes' });
});

it('wraps markdown into a JSON-stringified document_content', () => {
const params = buildCreateParams({ markdown: '# Hello' });
expect(params.document_content).toBe(JSON.stringify({ type: 'markdown', markdown: '# Hello' }));
expect(params.title).toBeUndefined();
});

it('maps channel to channel_id', () => {
expect(buildCreateParams({ channel: 'C123' })).toEqual({ channel_id: 'C123' });
});

it('combines title, markdown, and channel', () => {
const params = buildCreateParams({ title: 'T', markdown: 'body', channel: 'C9' });
expect(params.title).toBe('T');
expect(params.channel_id).toBe('C9');
expect(params.document_content).toBe(JSON.stringify({ type: 'markdown', markdown: 'body' }));
});

it('omits empty-string markdown', () => {
expect(buildCreateParams({ markdown: '' })).toEqual({});
});
});

describe('resolveCanvasMarkdown', () => {
it('returns undefined when no source is provided', async () => {
expect(await resolveCanvasMarkdown({})).toBeUndefined();
});

it('returns inline content', async () => {
expect(await resolveCanvasMarkdown({ content: '# Inline' })).toBe('# Inline');
});

it('rejects when more than one source is provided', async () => {
await expect(resolveCanvasMarkdown({ content: 'a', file: 'b.md' })).rejects.toThrow(
'Use only one of --content, --file, or --stdin',
);
});

it('rejects content + stdin together', async () => {
await expect(resolveCanvasMarkdown({ content: 'a', stdin: true })).rejects.toThrow(
'Use only one of',
);
});

it('reads markdown from a file', async () => {
const dir = await mkdtemp(join(tmpdir(), 'canvas-test-'));
const path = join(dir, 'doc.md');
await writeFile(path, '# From file\n- item');
try {
expect(await resolveCanvasMarkdown({ file: path })).toBe('# From file\n- item');
} finally {
await rm(dir, { recursive: true, force: true });
}
});

it('throws a clear error for a missing file', async () => {
const path = join(tmpdir(), 'canvas-test-does-not-exist-xyz.md');
await expect(resolveCanvasMarkdown({ file: path })).rejects.toThrow(`File not found: ${path}`);
});
});
126 changes: 125 additions & 1 deletion src/commands/canvas.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,77 @@
import { Command } from 'commander';
import ora from 'ora';
import { readFile, stat } from 'node:fs/promises';
import { getAuthenticatedClient } from '../lib/auth.ts';
import { error, formatCanvasList, formatCanvasContent } from '../lib/formatter.ts';
import { error, success, info, formatCanvasList, formatCanvasContent } from '../lib/formatter.ts';
import { canvasHtmlToMarkdown, isAuthPage } from '../lib/canvas-parser.ts';
import { readInteractiveInput } from '../lib/interactive-input.ts';
import type { SlackCanvas, SlackUser } from '../types/index.ts';

const CANVAS_ID_PATTERN = /^F[A-Z0-9]+$/i;
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB

/**
* Resolve canvas markdown from exactly one source: inline --content, a --file,
* or --stdin. Returns undefined when no source is given (Slack allows creating
* an empty canvas). Throws on conflicting sources or an unreadable file.
*/
export async function resolveCanvasMarkdown(options: {
content?: string;
file?: string;
stdin?: boolean;
}): Promise<string | undefined> {
const sources = [options.content, options.file, options.stdin].filter(Boolean).length;
if (sources > 1) {
throw new Error('Use only one of --content, --file, or --stdin');
}

if (options.content) return options.content;

if (options.file) {
const fileStats = await stat(options.file).catch((err: unknown) => {
if (err instanceof Error && 'code' in err && err.code === 'ENOENT') {
throw new Error(`File not found: ${options.file}`);
}
throw err;
});
if (!fileStats.isFile()) {
throw new Error(`Cannot read non-file path: ${options.file}`);
}
if (fileStats.size > MAX_FILE_SIZE) {
throw new Error(`File too large: ${fileStats.size} bytes (max ${MAX_FILE_SIZE})`);
}
return await readFile(options.file, 'utf-8');
}

if (options.stdin) {
const input = await readInteractiveInput({
prompt: 'Enter canvas markdown (press Enter twice when done):',
});
return input.trim();
}

return undefined;
}

/**
* Build the canvases.create request params from resolved inputs. Pure so it can
* be unit-tested without a network call. document_content is JSON-stringified
* to match how the Slack API expects nested objects over form-encoding.
*/
export function buildCreateParams(input: {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major: tested function is not the one that runs

Why: buildCreateParams duplicates the param logic in SlackClient.createCanvas (slack-client.ts:461-470), but the create action only calls createCanvas. The 6 unit tests assert on buildCreateParams, so the production builder is untested and the suite stays green even if it breaks. Two copies will drift.

Fix: keep one builder (in the client), test that one, and remove buildCreateParams.

title?: string;
markdown?: string;
channel?: string;
}): Record<string, any> {
const params: Record<string, any> = {};
if (input.title) params.title = input.title;
if (input.markdown) {
params.document_content = JSON.stringify({ type: 'markdown', markdown: input.markdown });
}
if (input.channel) params.channel_id = input.channel;
return params;
}

export function createCanvasCommand(): Command {
const canvas = new Command('canvas')
.description('List and read Slack canvas documents');
Expand Down Expand Up @@ -223,5 +287,65 @@ export function createCanvasCommand(): Command {
}
});

// Create a canvas
canvas
.command('create')
.description('Create a new canvas')
.option('--title <title>', 'Canvas title')
.option('--content <markdown>', 'Canvas body as markdown')
.option('--file <path>', 'Read canvas markdown from a file')
.option('--stdin', 'Read canvas markdown from stdin', false)
.option('--channel <id>', 'Channel to tab the canvas into (required on free teams)')
.option('--workspace <id|name>', 'Workspace to use')
.option('--json', 'Output in JSON format', false)
.action(async (options) => {
// Resolve content before starting the spinner so prompts/errors are clean
let markdown: string | undefined;
try {
markdown = await resolveCanvasMarkdown(options);
} catch (err: any) {
error(err.message);
process.exit(1);
}

const spinner = ora('Creating canvas...').start();

try {
const client = await getAuthenticatedClient(options.workspace);

const response = await client.createCanvas({
title: options.title,
markdown,
channel: options.channel,
});

const canvasId = response.canvas_id;
if (!canvasId) {
spinner.fail('Canvas creation failed');
error('Slack did not return a canvas ID.');
process.exit(1);
}

spinner.succeed(`Created canvas ${canvasId}`);

if (options.json) {
console.log(JSON.stringify({
ok: true,
canvas_id: canvasId,
title: options.title,
channel: options.channel,
}, null, 2));
return;
}

success(`Canvas created: ${canvasId}`);
info('Read it back with: slackcli canvas read ' + canvasId);
} catch (err: any) {
spinner.fail('Failed to create canvas');
error(err.message);
process.exit(1);
}
});

return canvas;
}
15 changes: 15 additions & 0 deletions src/lib/slack-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,21 @@ export class SlackClient {
return null;
}

// Create a canvas. document_content is markdown wrapped per Slack's schema.
async createCanvas(options: {
title?: string;
markdown?: string;
channel?: string;
} = {}): Promise<any> {
const params: Record<string, any> = {};
if (options.title) params.title = options.title;
if (options.markdown) {
params.document_content = JSON.stringify({ type: 'markdown', markdown: options.markdown });
}
if (options.channel) params.channel_id = options.channel;
return this.request('canvases.create', params);
}

// Check auth type
get authType(): string {
return this.config.auth_type;
Expand Down
15 changes: 15 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,3 +242,18 @@ export interface CanvasReadOptions {
raw?: boolean;
workspace?: string;
}

export interface CanvasDocumentContent {
type: 'markdown';
markdown: string;
}

export interface CanvasCreateOptions {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: unused types

Why: CanvasCreateOptions and CanvasDocumentContent are never imported. The action's options is untyped and both builders inline JSON.stringify({type:'markdown', markdown}) instead of CanvasDocumentContent.

Fix: type the action param as CanvasCreateOptions and use CanvasDocumentContent, or remove the types.

title?: string;
content?: string;
file?: string;
stdin?: boolean;
channel?: string;
json?: boolean;
workspace?: string;
}