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
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,37 @@ slack-cli scheduled list --format simple
slack-cli scheduled cancel -c general --id Q1298393284
```

### Drafts

Drafts are stored locally in `~/.slack-cli/drafts.json` (Slack does not expose a public API for drafts).

```bash
# Save a draft for a channel
slack-cli draft save -c general -m "Draft message"

# Save a draft for a DM
slack-cli draft save --user alice -m "Draft DM"

# Save a draft as a thread reply
slack-cli draft save -c general -m "Reply draft" -t 1234567890.123456

# List drafts
slack-cli draft list
slack-cli draft list --format json

# Show the full content of a draft
slack-cli draft show --id a1b2c3d4

# Send a draft (deleted after sending by default)
slack-cli draft send --id a1b2c3d4

# Send a draft but keep it
slack-cli draft send --id a1b2c3d4 --keep

# Delete a draft
slack-cli draft delete --id a1b2c3d4
```

### Canvases

```bash
Expand Down Expand Up @@ -627,6 +658,33 @@ Subcommands: `list`, `cancel`
| --channel | -c | Channel name or ID (required) |
| --id | | Scheduled message ID (required) |

### draft command

Subcommands: `save`, `list`, `show`, `send`, `delete`

#### draft save

| Option | Short | Description |
| --------- | ----- | ------------------------------------------------- |
| --channel | -c | Target channel name or ID |
| --user | | Target user for DM (exclusive with --channel) |
| --message | -m | Message content (required) |
| --thread | -t | Thread timestamp to reply to |

#### draft list

| Option | Short | Description |
| -------- | ----- | --------------------------------------------------- |
| --format | | Output format: table, simple, json (default: table) |

#### draft show / send / delete

| Option | Short | Description |
| --------- | ----- | --------------------------------------------- |
| --id | | Draft ID (required) |
| --keep | | (send only) Keep the draft after sending |
| --profile | | (send only) Use specific workspace profile |

### invite command

| Option | Short | Description |
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@mimo-3/slack-cli",
"version": "0.22.1",
"version": "0.23.0",
"description": "A command-line tool for sending messages to Slack",
"main": "dist/index.js",
"bin": {
Expand Down
190 changes: 190 additions & 0 deletions src/commands/draft.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import chalk from 'chalk';
import { Command } from 'commander';
import { renderByFormat, withSlackClient } from '../utils/command-support';
import { wrapCommand } from '../utils/command-wrapper';
import { type Draft, DraftStore } from '../utils/draft-store';
import { ValidationError } from '../utils/errors';
import { sanitizeTerminalData, sanitizeTerminalText } from '../utils/terminal-sanitizer';
import { createValidationHook, optionValidators } from '../utils/validators';

interface DraftSaveOptions {
channel?: string;
user?: string;
message?: string;
thread?: string;
}

interface DraftListOptions {
format?: string;
}

interface DraftIdOptions {
id: string;
}

interface DraftSendOptions extends DraftIdOptions {
keep?: boolean;
profile?: string;
}

function formatTarget(draft: Draft): string {
return draft.user ? `@${draft.user}` : `#${draft.channel}`;
}

function renderTable(drafts: Draft[]) {
const rows = drafts.map((draft) => ({
id: sanitizeTerminalText(draft.id),
target: sanitizeTerminalText(formatTarget(draft)),
created_at: draft.createdAt,
message: sanitizeTerminalText(
draft.message.length > 60 ? `${draft.message.slice(0, 60)}...` : draft.message
),
}));

console.table(sanitizeTerminalData(rows));
}

function renderSimple(drafts: Draft[]) {
for (const draft of drafts) {
console.log(
`${sanitizeTerminalText(draft.id)} ${draft.createdAt} ${sanitizeTerminalText(formatTarget(draft))} ${sanitizeTerminalText(draft.message)}`
);
}
}

export function setupDraftCommand(): Command {
const draftCommand = new Command('draft').description(
'Manage message drafts (save, list, show, send, delete)'
);

const saveCommand = new Command('save')
.description('Save a message as a local draft')
.option('-c, --channel <channel>', 'Target channel name or ID')
.option('--user <username>', 'Target user for DM')
.option('-m, --message <message>', 'Message content')
.option('-t, --thread <thread>', 'Thread timestamp to reply to')
.hook('preAction', createValidationHook([optionValidators.threadTimestamp]))
.action(
wrapCommand(async (options: DraftSaveOptions) => {
if (!options.channel && !options.user) {
throw new ValidationError('Either --channel or --user must be specified');
}
if (options.channel && options.user) {
throw new ValidationError('Cannot specify both --channel and --user');
}
if (!options.message) {
throw new ValidationError('--message is required');
}

const store = new DraftStore();
const draft = await store.save({
channel: options.channel,
user: options.user,
message: options.message,
thread: options.thread,
});

console.log(chalk.green(`✓ Draft saved (id: ${draft.id}, target: ${formatTarget(draft)})`));
})
);

const listCommand = new Command('list')
.description('List saved drafts')
.option('--format <format>', 'Output format: table, simple, json', 'table')
.hook('preAction', createValidationHook([optionValidators.format]))
.action(
wrapCommand(async (options: DraftListOptions) => {
const store = new DraftStore();
const drafts = await store.list();

if (drafts.length === 0) {
console.log('No drafts found');
return;
}

renderByFormat(options, drafts, {
table: renderTable,
simple: renderSimple,
});
})
);

const showCommand = new Command('show')
.description('Show the full content of a draft')
.requiredOption('--id <draftId>', 'Draft ID')
.action(
wrapCommand(async (options: DraftIdOptions) => {
const store = new DraftStore();
const draft = await store.get(options.id);
if (!draft) {
throw new ValidationError(`Draft not found: ${options.id}`);
}

console.log(`id: ${sanitizeTerminalText(draft.id)}`);
console.log(`target: ${sanitizeTerminalText(formatTarget(draft))}`);
if (draft.thread) {
console.log(`thread: ${sanitizeTerminalText(draft.thread)}`);
}
console.log(`created_at: ${draft.createdAt}`);
console.log('---');
console.log(sanitizeTerminalText(draft.message));
})
);

const sendCommand = new Command('send')
.description('Send a saved draft')
.requiredOption('--id <draftId>', 'Draft ID')
.option('--keep', 'Keep the draft after sending')
.option('--profile <profile>', 'Use specific workspace profile')
.action(
wrapCommand(async (options: DraftSendOptions) => {
const store = new DraftStore();
const draft = await store.get(options.id);
if (!draft) {
throw new ValidationError(`Draft not found: ${options.id}`);
}

await withSlackClient(options, async (client) => {
let targetChannel: string;
if (draft.user) {
const userId = await client.resolveUserIdByName(draft.user);
targetChannel = await client.openDmChannel(userId);
} else {
targetChannel = draft.channel!;
}

await client.sendMessage(targetChannel, draft.message, draft.thread);
});

console.log(chalk.green(`✓ Draft sent to ${formatTarget(draft)}`));

if (!options.keep) {
try {
await store.delete(draft.id);
console.log(chalk.gray(`Draft ${draft.id} deleted`));
} catch {
console.log(chalk.yellow(`⚠ Message sent, but failed to delete draft ${draft.id}`));
}
}
})
);

const deleteCommand = new Command('delete')
.description('Delete a saved draft')
.requiredOption('--id <draftId>', 'Draft ID')
.action(
wrapCommand(async (options: DraftIdOptions) => {
const store = new DraftStore();
await store.delete(options.id);
console.log(chalk.green(`✓ Draft ${options.id} deleted`));
})
);

draftCommand.addCommand(saveCommand);
draftCommand.addCommand(listCommand);
draftCommand.addCommand(showCommand);
draftCommand.addCommand(sendCommand);
draftCommand.addCommand(deleteCommand);

return draftCommand;
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { setupChannelsCommand } from './commands/channels';
import { setupConfigCommand } from './commands/config';
import { setupDeleteCommand } from './commands/delete';
import { setupDownloadCommand } from './commands/download';
import { setupDraftCommand } from './commands/draft';
import { setupEditCommand } from './commands/edit';
import { setupHistoryCommand } from './commands/history';
import { setupInviteCommand } from './commands/invite';
Expand Down Expand Up @@ -70,6 +71,7 @@ export function createProgram(): Command {
program.addCommand(setupReminderCommand());
program.addCommand(setupBookmarkCommand());
program.addCommand(setupCanvasCommand());
program.addCommand(setupDraftCommand());

return program;
}
Expand Down
Loading
Loading