diff --git a/CHANGELOG.md b/CHANGELOG.md index 5de64ed..ad8ab1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 4d65ab5..f82761a 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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`) | ❌ | +> +> Reading a canvas additionally requires the `files:read` scope on standard tokens. + ### Update Commands ```bash diff --git a/src/commands/canvas.test.ts b/src/commands/canvas.test.ts new file mode 100644 index 0000000..8cb8d35 --- /dev/null +++ b/src/commands/canvas.test.ts @@ -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}`); + }); +}); diff --git a/src/commands/canvas.ts b/src/commands/canvas.ts index 68f3f34..c2fd1c5 100644 --- a/src/commands/canvas.ts +++ b/src/commands/canvas.ts @@ -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 { + 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: { + title?: string; + markdown?: string; + channel?: string; +}): Record { + const params: Record = {}; + 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'); @@ -223,5 +287,65 @@ export function createCanvasCommand(): Command { } }); + // Create a canvas + canvas + .command('create') + .description('Create a new canvas') + .option('--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; } diff --git a/src/lib/slack-client.ts b/src/lib/slack-client.ts index a734a22..bcfd7e6 100644 --- a/src/lib/slack-client.ts +++ b/src/lib/slack-client.ts @@ -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; diff --git a/src/types/index.ts b/src/types/index.ts index d7f716d..396a1ac 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -242,3 +242,18 @@ export interface CanvasReadOptions { raw?: boolean; workspace?: string; } + +export interface CanvasDocumentContent { + type: 'markdown'; + markdown: string; +} + +export interface CanvasCreateOptions { + title?: string; + content?: string; + file?: string; + stdin?: boolean; + channel?: string; + json?: boolean; + workspace?: string; +}