Skill: therohitdas/slk (version 1.0.0)

--- name: slack-personal description: Read, send, search, and manage Slack messages and DMs via the slk CLI. Use when the user asks to check Slack, read channels or DMs, send Slack messages, search Slack, check unreads, manage drafts, view saved items, or interact with Slack workspace. Also use for heartbeat Slack checks. Triggers on "check slack", "any slack messages", "send on slack", "slack unreads", "search slack", "slack threads", "draft on slack", "read slack dms", "message on slack". homepage: https://www.npmjs.com/package/slkcli user-invocable: true metadata: {"openclaw":{"emoji":"💬","requires":{"bins":["slk"]},"install":[{"id":"npm","kind":"node","package":"slkcli","bins":["slk"],"label":"Install slk (npm)"}],"os":["darwin"]}} --- # slk — Slack CLI Session-based Slack CLI for macOS. Auto-authenticates from the Slack desktop app — no tokens, no OAuth, no app installs. Acts as your user (`xoxc-` session tokens). ## Commands ```bash # Auth slk auth # Test authentication, show user/team # Read slk channels # List channels (alias: ch) slk dms # List DM conversations with IDs (alias: dm) slk read <channel> [count] # Read recent messages, default 20 (alias: r) slk read @username [count] # Read DMs by username slk read <channel> --threads # Auto-expand all threads slk read <channel> --from 2026-02-01 # Date range filter slk thread <channel> <ts> [count] # Read thread replies, default 50 (alias: t) slk search <query> [count] # Search messages across workspace slk users # List workspace users (alias: u) # Activity slk activity # All channels with unread/mention counts (alias: a) slk unread # Only unreads, excludes muted (alias: ur) slk starred # VIP users + starred items (alias: star) slk saved [count] [--all] # Saved for later items (alias: sv) slk pins <channel> # Pinned items in a channel (alias: pin) # Write slk send <channel> <message> # Send a message (alias: s) slk react <channel> <ts> <emoji> # React to a message # Drafts (synced to Slack editor UI) slk draft <channel> <message> # Draft a channel message slk draft thread <ch> <ts> <message> # Draft a thread reply slk draft user <user_id> <message> # Draft a DM slk drafts # List active drafts slk draft drop <draft_id> # Delete a draft ``` Channel accepts name (`general`), ID (`C08A8AQ2AFP`), `@username` for DMs, or user ID (`U07RQTFCLUC`). ## Auth Automatic — extracts session token from Slack desktop app's LevelDB + decrypts cookie from macOS Keychain. **First run:** macOS will show a Keychain dialog asking to allow access to "Slack Safe Storage": - **Allow** — one-time access, prompted again next time - **Always Allow** — permanent, no future prompts (convenient but any process running as your user can extract credentials silently) - **Deny** — blocks access, slk cannot authenticate **Token cache:** `~/.local/slk/token-cache.json` — auto-validated, auto-refreshed on `invalid_auth`. If auth fails (token rotated, Slack logged out): ```bash rm ~/.local/slk/token-cache.json slk auth ``` Slack desktop app must be installed and logged in. Does not need to be running if token is cached. ## Reading Threads Threads require a Slack timestamp. Use `--ts` to get it, then read the thread: ```bash slk read general 10 --ts # Output: [1/30/2026, 11:41 AM ts:1769753479.788949] User [3 replies]: ... slk thread general 1769753479.788949 ``` ## Agent Workflow Examples - **Heartbeat/cron unread check** — `slk unread` → `slk read <channel>` for channels that need attention - **Save & pick up** — Human saves threads in Slack ("Save for later"). Agent runs `slk saved` during heartbeat, reads full threads with `slk thread`, summarizes or extracts action items - **Daily channel digest** — `slk read <channel> 100` across key channels → compile decisions, open questions, action items → `slk send daily-digest "📋 ..."` - **Weekly DM summary** — `slk read @boss 200 --from 2026-02-01 --threads` → extract action items, decisions, context - **Thread monitoring** — Watch specific threads for new replies (incidents, PR reviews, decisions) - **Draft for human review** — `slk draft <channel> "..."` posts to Slack's editor UI for human to review before sending - **Search-driven context** — `slk search "deployment process"` or `slk pins <channel>` to pull context before answering questions ## Limitations - **macOS only** — uses Keychain + Electron storage paths - **Session-based** — acts as your user, not a bot. Be mindful of what you send - **Draft drop** may fail with `draft_has_conflict` if Slack has that conversation open - **Session token** expires on logout — keep Slack app running or rely on cached token ## Missing Features & Issues Create PR or Report Issue at: https://github.com/therohitdas/slkcli

path: AGENTS.md
content: # AGENTS.md — slk Development Guide

This is the full loop for working on slk. Follow this every time you add, change, or fix something.

## Project Structure

```
slk/
├── bin/slk.js          # CLI entry point — command routing, help text, aliases
├── src/
│   ├── api.js          # Slack API wrapper — fetch, auth headers, pagination
│   ├── auth.js         # Credential extraction — Keychain, LevelDB, token cache
│   ├── commands.js     # All command implementations (read, send, saved, etc.)
│   └── drafts.js       # Draft-specific commands (create, list, drop)
├── package.json        # Version, bin mapping, metadata
├── README.md           # Full documentation for users
├── SKILL.md            # Agent skill file (used by Moltbot/ClawdBot)
├── AGENTS.md           # This file — dev guide for AI agents
└── CLAUDE.md           # Quick reference for Claude Code / Codex
```

## The Full Loop: Adding a New Feature

### 1. Write the Code

Add your command implementation in `src/commands.js`:
- Export an async function
- Use `slackApi()` or `slackPaginate()` from `api.js`
- Use existing helpers: `getUsers()`, `resolveChannel()`, `formatTs()`, `userName()`
- Follow the existing pattern: fetch data → format → console.log output
- Handle errors with `console.error` + `process.exit(1)`

If your new API endpoint needs POST (most Slack endpoints do), add it to the `writeMethods` array in `src/api.js`.

### 2. Wire Up the CLI

In `bin/slk.js`:
- Add the command to the `HELP` string (with alias and description)
- Add a `case` in the `switch` block
- Follow the pattern: validate args → call command function → handle aliases

### 3. Test It

```bash
# Run directly (no install needed)
node bin/slk.js <your-command>

# Or if globally linked:
slk <your-command>

# Test edge cases:
# - No args (should show usage)
# - Invalid channel
# - Auth expired (should auto-refresh)
```

### 4. Update Documentation

**All three must be updated:**

1. **README.md** — Add to:
   - Commands table
   - Quickstart examples (if user-facing)
   - Flags table (if new flags)
   - Agent usage patterns (if relevant)

2. **SKILL.md** — Add to the commands list under the appropriate section (Read/Activity/Write/Drafts)

3. **bin/slk.js HELP** — Already done in step 2, but double-check alignment with README

### 5. Bump Version

```bash
npm version patch --no-git-tag-version   # 0.1.2 → 0.1.3 (bugfix/small feature)
npm version minor --no-git-tag-version   # 0.1.3 → 0.2.0 (new feature)
npm version major --no-git-tag-version   # 0.2.0 → 1.0.0 (breaking change)
```

Use `patch` for most changes. Use `minor` for significant new features.

### 6. Git Commit & Push

```bash
git add -A
git commit -m "feat: short description of what changed

- Detail 1
- Detail 2
- Bumped to x.y.z"
git push
```

Commit message prefixes:
- `feat:` — new feature
- `fix:` — bug fix
- `docs:` — documentation only
- `refactor:` — code change that doesn't add/fix

### 7. Publish to npm

```bash
# Create .npmrc with publish token
echo "//registry.npmjs.org/:_authToken=${NPM_PUBLISH_TOKEN}" > .npmrc
npm publish
rm .npmrc   # Clean up — don't commit the token!
```

The `NPM_PUBLISH_TOKEN` env var is set in `~/.local/keys/env.sh`.

### 8. Update Local Moltbot Skill

```bash
cp SKILL.md ~/moltbot/skills/slk/SKILL.md
```

This ensures Moltbot's local skill stays in sync with the published version.

## Quick Reference: One-liner Full Deploy

```bash
cd ~/Lab/slk
# ... make changes ...
npm version patch --no-git-tag-version
git add -A && git commit -m "feat: description" && git push
echo "//registry.npmjs.org/:_authToken=${NPM_PUBLISH_TOKEN}" > .npmrc && npm publish && rm .npmrc
cp SKILL.md ~/moltbot/skills/slk/SKILL.md
```

## API Notes

- **Undocumented endpoints:** Slack has many internal APIs not in public docs. Use POST method. Examples: `saved.list`, `client.counts`, `users.prefs.get`, `drafts.create`.
- **Auth:** All requests need both `Authorization: Bearer xoxc-...` header AND `Cookie: d=xoxd-...` cookie. The `slackApi()` wrapper handles this.
- **Pagination:** Use `slackPaginate()` for list endpoints that support cursor-based pagination.
- **Channel resolution:** `resolveChannel()` accepts names or IDs. Always use it for user-facing commands.

## Testing Auth

If auth breaks:
```bash
rm ~/.local/slk/token-cache.json
slk auth
```
Make sure Slack desktop app is running.


path: CLAUDE.md
content: # CLAUDE.md — Quick Reference for Claude Code / Codex

## What is slk?

Slack CLI for macOS. Auto-auth from Slack desktop app (session cookies, no bot install). Zero dependencies.

## Key Files

- `src/commands.js` — All command logic. Add new commands here.
- `src/api.js` — `slackApi()` and `slackPaginate()`. Add POST endpoints to `writeMethods` array.
- `src/auth.js` — Keychain + LevelDB credential extraction. Rarely needs changes.
- `src/drafts.js` — Draft commands (create/list/drop).
- `bin/slk.js` — CLI entry point. Command routing + help text.

## Adding a Feature

1. Add function in `src/commands.js` (export async)
2. If new API needs POST → add to `writeMethods` in `src/api.js`
3. Add case + alias in `bin/slk.js` switch block
4. Add to HELP string in `bin/slk.js`
5. Update `README.md` (commands table, examples, flags if any)
6. Update `SKILL.md` (commands list)
7. `npm version patch --no-git-tag-version`
8. `git add -A && git commit -m "feat: ..." && git push`
9. `echo "//registry.npmjs.org/:_authToken=${NPM_PUBLISH_TOKEN}" > .npmrc && npm publish && rm .npmrc`
10. `cp SKILL.md ~/moltbot/skills/slk/SKILL.md`

## Testing

```bash
node bin/slk.js <command>   # Direct run
slk <command>               # If globally linked
```

## Patterns

- Use `getUsers()` for user ID → name resolution (cached)
- Use `resolveChannel(nameOrId)` for channel name/ID handling
- Use `formatTs(ts)` for Slack timestamp → human date
- Errors: `console.error()` + `process.exit(1)`
- Output: `console.log()` with emoji prefixes

## Auth

Session-based (`xoxc-` token + `xoxd-` cookie). Auto-extracted from Slack desktop app on macOS.
Cache: `~/.local/slk/token-cache.json`. Delete to force re-extract.

## npm Publish Token

`NPM_PUBLISH_TOKEN` env var from `~/.local/keys/env.sh`. Use `.npmrc` trick (create before publish, delete after).


path: README.md
content: # slk 💬 — Slack CLI for macOS, so your agents can read and send messages

`slk` is a Slack command-line tool for macOS that auto-extracts auth from the Slack desktop app. Read channels, send messages, search, manage drafts, track unreads, and view pins — no tokens, no OAuth, no config.

Built for AI agents and terminal workflows. Zero dependencies. Zero setup.

> **Not affiliated with Slack.** This is an independent Slack CLI built for personal productivity and agent automation. It uses session credentials from the Slack desktop app and works only on macOS. Use at your own discretion.

## Install

```bash
npm install -g slkcli
```

One-shot (no install):

```bash
npx slkcli auth
```

**Requirements:** macOS, Slack desktop app (installed and logged in), Node.js 18+.

## Agent Skill

Add to your AI agent (Claude Code, Codex, Moltbot, etc.):

```bash
# ClawdHub
clawdhub install slack-personal

# skills.sh
npx skills add therohitdas/slkcli
```

Browse on [ClawdHub](https://www.clawhub.ai/therohitdas/slack-personal).

## Quickstart

```bash
# Verify your session works
slk auth

# List channels
slk channels

# Read the last 20 messages in a channel
slk read general
slk read C08A8AQ2AFP        # by channel ID

# Send a message
slk send general "Hello from slk"

# Search across the workspace
slk search "deployment failed"

# Check what's unread
slk unread

# See starred items and VIP users
slk starred

# See saved for later items
slk saved

# See pinned messages in a channel
slk pins general

# Read a thread
slk thread general 1234567890.123456

# React to a message
slk react general 1234567890.123456 thumbsup
```

## Commands

| Command | Alias | Description |
|---------|-------|-------------|
| `slk auth` | | Test authentication, show user/team info |
| `slk channels` | `ch` | List all channels with member counts |
| `slk dms` | `dm` | List DM conversations with IDs |
| `slk users` | `u` | List workspace users with statuses |
| `slk read <channel> [count]` | `r` | Read recent messages (default: 20) |
| `slk send <channel> <message>` | `s` | Send a message to a channel |
| `slk search <query> [count]` | | Search messages across the workspace |
| `slk thread <channel> <ts> [count]` | `t` | Read thread replies (default: 50) |
| `slk react <channel> <ts> <emoji>` | | Add an emoji reaction to a message |
| `slk activity` | `a` | Show all channel activity with unread/mention counts |
| `slk unread` | `ur` | Show only channels with unreads (excludes muted) |
| `slk starred` | `star` | Show VIP users and starred items |
| `slk saved [count]` | `sv` | Show saved for later items (active by default, `--all` includes completed) |
| `slk pins <channel>` | `pin` | Show pinned items in a channel |

### Flags

| Flag | Description |
|------|-------------|
| `--ts` | Show raw Slack timestamps (useful for getting ts to read threads) |
| `--threads` | Auto-expand all threads when reading messages |
| `--from YYYY-MM-DD` | Read messages from this date onwards |
| `--to YYYY-MM-DD` | Read messages until this date |
| `--no-emoji` | Disable emoji in output (or set `NO_EMOJI=1`) |
| `--all` | Include completed items in `slk saved` |

```bash
# Get timestamps to use with thread command
slk read general 10 --ts
# Output: [1/30/2026, 11:41:19 AM ts:1769753479.788949] User [3 replies]:

# Then read that thread
slk thread general 1769753479.788949
```

### Drafts

Drafts sync to Slack — they appear in the Slack editor UI.

| Command | Description |
|---------|-------------|
| `slk draft <channel> <message>` | Draft a channel message |
| `slk draft thread <channel> <ts> <message>` | Draft a thread reply |
| `slk draft user <user_id> <message>` | Draft a DM |
| `slk drafts` | List all active drafts |
| `slk draft drop <draft_id>` | Delete a draft |

### Channel resolution

Channels can be specified by **name** or **ID** in any command:

```bash
slk read general           # by name
slk read ai-coding         # by name
slk read C08A8AQ2AFP       # by ID
```

### DMs

Read, send, and react to DMs using `@username` or user ID:

```bash
# List all DM conversations
slk dms

# Read DMs by username
slk read @andrej 50
slk read @nikhil 100 --threads    # auto-expand threads

# Read DMs with date range
slk read @andrej 100 --from 2026-02-01 --to 2026-02-07 --threads

# Send DM
slk send @andrej "hey, check this out"

# React to DM message
slk react @andrej 1769753479.788949 fire

# By user ID (U...)
slk read U07RQTFCLUC 50
```

## Authentication

`slk` uses the credentials already stored by the Slack desktop app. No OAuth flows, no manual token management.

### Keychain access prompt

On first run, macOS will show a Keychain dialog asking whether to allow access to "Slack Safe Storage":

- **Allow** — grants one-time access. You'll be prompted again next time slk needs to decrypt the cookie.
- **Always Allow** — grants permanent access for this binary. No future prompts.
- **Deny** — blocks access. slk cannot authenticate.

> **Caution:** Choosing "Always Allow" means any process running as your user that invokes the `slk` binary (or the `security` command targeting "Slack Safe Storage") can read the encryption key without a prompt. This is convenient but reduces the security boundary — any code running in your terminal (scripts, agents, other CLI tools) could trigger credential extraction silently. On a personal machine this is a reasonable trade-off. On a shared or managed machine, prefer "Allow" so you get prompted each time and maintain visibility into access.

### How it works

1. **Cookie decryption** — Reads the encrypted `d` cookie from Slack's SQLite cookie store (`Cookies` file). Decrypts it using the "Slack Safe Storage" key from the macOS Keychain via PBKDF2 + AES-128-CBC. Supports both direct-download and Mac App Store keychain account names.

2. **Token extraction** — Scans Slack's LevelDB storage (`Local Storage/leveldb/`) for `xoxc-` session tokens. Uses both direct regex scanning and a Python fallback for Snappy-compressed entries. The Slack data directory is auto-detected (direct download or App Store sandbox).

3. **Validation** — Tests each candidate token against `auth.test` with the decrypted cookie. The first valid pair is used.

4. **Auto-refresh** — On `invalid_auth`, credentials are re-extracted and the request is retried once automatically.

### Token caching

Validated tokens are cached to avoid re-extracting on every invocation:

| | |
|---|---|
| **Cache file** | `~/.local/slk/token-cache.json` |
| **Format** | `{ "token": "xoxc-...", "ts": 1706000000000 }` |
| **Behavior** | Load cache → validate with Slack API → use if valid, otherwise re-extract from LevelDB |
| **In-memory** | Within a single process, credentials are cached in memory after first load |

### Credential resolution order

```
1. In-memory cache (same process)
2. Disk cache (~/.local/slk/token-cache.json) → validate → use if ok
3. Fresh extraction from Slack desktop app → validate → cache → use
```

### What it reads from your system

| Data | Source | Purpose |
|------|--------|---------|
| Keychain password | `security find-generic-password -s "Slack Safe Storage"` | Derive AES key for cookie decryption |
| Encrypted cookie | `<slack-data-dir>/Cookies` (SQLite) | Decrypt the `d` session cookie (`xoxd-`) |
| Session token | `<slack-data-dir>/Local Storage/leveldb/` | Extract `xoxc-` token |

## Agent usage patterns

`slk` is designed to be used by AI agents. Common patterns:

```bash
# Check auth before doing anything
slk auth

# Get channel list, find the right one
slk channels

# Read recent context from a channel
slk read engineering 50

# Search for something specific
slk search "PR review needed"

# Check what needs attention
slk unread

# See pinned context in a channel
slk pins engineering

# Send a message
slk send engineering "Build passed on main"

# Read a thread for full context
slk thread engineering 1706000000.000000

# Draft a message for human review (appears in Slack UI)
slk draft engineering "Here's the summary of today's standup..."
```

**Exit codes:** `0` on success, `1` on error. Errors are printed to stderr.

## How it was installed

The `bin` field in `package.json` maps `slk` to `./bin/slk.js`:

```json
{ "bin": { "slk": "./bin/slk.js" } }
```

Running `npm install -g` creates a symlink in your PATH:

```
/opt/homebrew/bin/slk -> ../lib/node_modules/slkcli/bin/slk.js
```

## Development

```bash
git clone https://github.com/therohitdas/slk.git
cd slk
node bin/slk.js auth       # run directly
npm link                   # symlink globally for development
```

## Notes

- **macOS only** — uses Keychain and Electron storage paths specific to macOS.
- **Both Slack variants supported** — works with the direct download (`~/Library/Application Support/Slack/`) and the Mac App Store version (`~/Library/Containers/com.tinyspeck.slackmacgap/.../Slack/`). The correct path is auto-detected at runtime.
- **Slack desktop app required** — must be installed and logged in. The app does not need to be running for cached tokens.
- **Zero dependencies** — uses only Node.js built-in modules (`crypto`, `fs`, `child_process`, `fetch`).
- **Session-based** — uses `xoxc-` tokens (user session), not bot tokens. This means you act as yourself.
- **Mute-aware** — `activity` and `unread` commands respect your mute settings.


path: bin/slk.js
content: #!/usr/bin/env node

/**
 * slk — Slack CLI with auto-auth from macOS Slack desktop app.
 */

import * as cmd from "../src/commands.js";
import * as drafts from "../src/drafts.js";

const args = process.argv.slice(2);
const command = args[0];

const supportsEmoji = !process.env.NO_EMOJI && !process.argv.includes("--no-emoji");
const showTs = process.argv.includes("--ts");
const e = (emoji, fallback = "") => supportsEmoji ? emoji + " " : fallback;

const HELP = `${e("💬")}slk — Slack CLI for macOS (auto-auth from Slack desktop app)

Commands:
  slk auth                                Test auth, show user/team info
  slk channels          (ch)              List channels with member counts
  slk dms               (dm)              List DM conversations with IDs
  slk users             (u)               List workspace users with statuses
  slk read <ch> [n]     (r)               Read last n messages (default: 20)
  slk send <ch> <msg>   (s)               Send a message
  slk search <query> [n]                  Search messages across workspace
  slk thread <ch> <ts> [n]  (t)           Read thread replies (default: 50)
  slk react <ch> <ts> <emoji>             Add emoji reaction
  slk activity          (a)               Channel activity with unread/mention counts
  slk unread            (ur)              Channels with unreads (excludes muted)
  slk starred           (star)            VIP users + starred items
  slk saved [n]         (sv)              Saved for later items (--all includes completed)
  slk pins <ch>         (pin)             Pinned items in a channel

Drafts (synced to Slack UI):
  slk draft <ch> <msg>                    Draft a channel message
  slk draft thread <ch> <ts> <msg>        Draft a thread reply
  slk draft user <user_id> <msg>          Draft a DM
  slk drafts                              List active drafts
  slk draft drop <id>                     Delete a draft

Settings:
  --ts                                    Show raw Slack timestamps (for thread commands)
  --threads                               Auto-expand threads when reading
  --from YYYY-MM-DD                       Read messages from this date
  --to YYYY-MM-DD                         Read messages until this date
  --no-emoji                              Disable emoji output (or set NO_EMOJI=1)

Channels: name ("general"), ID ("C08A8AQ2AFP"), @username, or user ID ("U...").
DMs: use @username or user ID to send/read DMs. Aliases shown in parens.

Examples:
  slk read general 50                     Last 50 messages from #general
  slk read @andrej 100 --threads          Read DM with Andrej, expand all threads
  slk read @nikhil --from 2026-02-01      Read DM from Feb 1st onwards
  slk send @andrej "hey!"                 Send DM to Andrej
  slk send engineering "build passed"     Send to #engineering
  slk search "deploy failed" 10           Search with limit
  slk thread general 1706000000.000000    Read a thread
  slk react @andrej 1706000000.000000 eyes  React to DM message
  slk draft general "PR summary..."       Save draft in Slack UI
  slk dms                                 List all DM conversations
  slk unread                              What needs attention?

Auth: reads credentials from the Slack desktop app automatically.
Cache: ~/.local/slk/token-cache.json (auto-validated, auto-refreshed).
Docs:  https://github.com/therohitdas/slkcli`;

async function main() {
  try {
    switch (command) {
      case "auth":
        await cmd.auth();
        break;

      case "channels":
      case "ch":
        await cmd.channels();
        break;

      case "dms":
      case "dm":
        await cmd.dms();
        break;

      case "read":
      case "r": {
        if (!args[1]) { console.error("Usage: slk read <channel|@user> [count] [--ts] [--threads] [--from YYYY-MM-DD] [--to YYYY-MM-DD]"); process.exit(1); }
        const expandThreads = args.includes("--threads");
        const fromIdx = args.indexOf("--from");
        const toIdx = args.indexOf("--to");
        let oldest = null, latest = null;
        if (fromIdx > -1 && args[fromIdx + 1]) {
          oldest = String(new Date(args[fromIdx + 1]).getTime() / 1000);
        }
        if (toIdx > -1 && args[toIdx + 1]) {
          latest = String(new Date(args[toIdx + 1]).getTime() / 1000);
        }
        // Find count (first numeric arg after channel)
        let count = 20;
        for (let i = 2; i < args.length; i++) {
          if (/^\d+$/.test(args[i])) { count = parseInt(args[i]); break; }
        }
        await cmd.read(args[1], count, { showTs, oldest, latest, expandThreads });
        break;
      }

      case "send":
      case "s":
        if (!args[1] || !args[2]) { console.error("Usage: slk send <channel> <message>"); process.exit(1); }
        await cmd.send(args[1], args.slice(2).join(" "));
        break;

      case "search":
        if (!args[1]) { console.error("Usage: slk search <query> [count]"); process.exit(1); }
        await cmd.search(args.slice(1).join(" "), parseInt(args[args.length - 1]) || 20);
        break;

      case "thread":
      case "t":
        if (!args[1] || !args[2]) { console.error("Usage: slk thread <channel> <ts>"); process.exit(1); }
        await cmd.thread(args[1], args[2], parseInt(args[3]) || 50);
        break;

      case "users":
      case "u":
        await cmd.users();
        break;

      case "react":
        if (!args[1] || !args[2] || !args[3]) {
          console.error("Usage: slk react <channel> <ts> <emoji>");
          process.exit(1);
        }
        await cmd.react(args[1], args[2], args[3]);
        break;

      case "activity":
      case "a":
        await cmd.activity(false);
        break;

      case "unread":
      case "ur":
        await cmd.activity(true);
        break;

      case "starred":
      case "star":
        await cmd.starred();
        break;

      case "saved":
      case "sv":
        await cmd.saved(parseInt(args[1]) || 20, args.includes("--all"));
        break;

      case "pins":
      case "pin":
        if (!args[1]) { console.error("Usage: slk pins <channel>"); process.exit(1); }
        await cmd.pins(args[1]);
        break;

      case "drafts":
        await drafts.listDrafts();
        break;

      case "draft": {
        const sub = args[1];
        if (sub === "thread") {
          if (!args[2] || !args[3] || !args[4]) {
            console.error("Usage: slk draft thread <channel> <ts> <message>");
            process.exit(1);
          }
          await drafts.draftThread(args[2], args[3], args.slice(4).join(" "));
        } else if (sub === "user") {
          if (!args[2] || !args[3]) {
            console.error("Usage: slk draft user <user_id> <message>");
            process.exit(1);
          }
          await drafts.draftUser(args[2], args.slice(3).join(" "));
        } else if (sub === "drop") {
          if (!args[2]) { console.error("Usage: slk draft drop <draft_id>"); process.exit(1); }
          await drafts.dropDraft(args[2]);
        } else {
          // slk draft <channel> <message>
          if (!sub || !args[2]) {
            console.error("Usage: slk draft <channel> <message>");
            process.exit(1);
          }
          await drafts.draftChannel(sub, args.slice(2).join(" "));
        }
        break;
      }

      case "help":
      case "-h":
      case "--help":
      case undefined:
        console.log(HELP);
        break;

      default:
        console.error(`Unknown command: ${command}`);
        console.log(HELP);
        process.exit(1);
    }
  } catch (err) {
    console.error(`Error: ${err.message}`);
    process.exit(1);
  }
}

main();


path: package.json
content: {
  "name": "slkcli",
  "version": "0.1.6",
  "description": "Slack CLI for macOS, so your agents can read and send messages",
  "type": "module",
  "license": "MIT",
  "author": "Rohit Das",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/therohitdas/slkcli.git"
  },
  "homepage": "https://github.com/therohitdas/slkcli",
  "bugs": {
    "url": "https://github.com/therohitdas/slkcli/issues"
  },
  "keywords": [
    "slack",
    "slack-cli",
    "cli",
    "macos",
    "slack-api",
    "slack-bot",
    "agent",
    "terminal",
    "messaging"
  ],
  "bin": {
    "slk": "bin/slk.js"
  },
  "files": [
    "bin/",
    "src/",
    "LICENSE",
    "README.md"
  ],
  "engines": {
    "node": ">=18"
  },
  "scripts": {
    "dev": "node bin/slk.js"
  },
  "dependencies": {},
  "devDependencies": {}
}


path: src/api.js
content: /**
 * Slack API wrapper — handles auth, retries on invalid_auth, and pagination.
 */

import { getCredentials, refresh } from "./auth.js";

const BASE = "https://slack.com/api";

/**
 * Make an authenticated Slack API call.
 * Auto-refreshes credentials on invalid_auth (once).
 */
export async function slackApi(method, params = {}, retried = false) {
  const { token, cookie } = getCredentials();

  const url = new URL(`${BASE}/${method}`);

  // GET for read methods, POST for write methods
  const writeMethods = [
    "chat.postMessage",
    "chat.update",
    "chat.delete",
    "reactions.add",
    "reactions.remove",
    "files.upload",
    "drafts.create",
    "drafts.delete",
    "drafts.update",
    "conversations.open",
    "client.counts",
    "users.prefs.get",
    "saved.list",
  ];

  const isWrite = writeMethods.some((m) => method.startsWith(m));
  let res;

  if (isWrite) {
    res = await fetch(url, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${token}`,
        Cookie: `d=${cookie}`,
        "Content-Type": "application/json; charset=utf-8",
      },
      body: JSON.stringify(params),
    });
  } else {
    for (const [k, v] of Object.entries(params)) {
      if (v !== undefined && v !== null) url.searchParams.set(k, v);
    }
    res = await fetch(url, {
      headers: {
        Authorization: `Bearer ${token}`,
        Cookie: `d=${cookie}`,
      },
    });
  }

  const data = await res.json();

  if (!data.ok && data.error === "invalid_auth" && !retried) {
    refresh();
    return slackApi(method, params, true);
  }

  return data;
}

/**
 * Paginate through a Slack API method using cursor-based pagination.
 */
export async function slackPaginate(method, params = {}, key = "channels") {
  const results = [];
  let cursor;

  do {
    const data = await slackApi(method, { ...params, cursor, limit: params.limit || 200 });
    if (!data.ok) return data; // return error as-is

    if (data[key]) results.push(...data[key]);
    cursor = data.response_metadata?.next_cursor;
  } while (cursor);

  return { ok: true, [key]: results };
}


path: src/auth.js
content: /**
 * Slack auth — extracts session credentials from the Slack desktop app on macOS.
 *
 * 1. Keychain → "Slack Safe Storage" password
 * 2. Cookies SQLite → encrypted `d` cookie → AES-128-CBC decrypt
 * 3. LevelDB files → `xoxc-` token (string scan)
 */

import { execSync, spawnSync } from "child_process";
import { readFileSync, readdirSync, copyFileSync, unlinkSync, writeFileSync } from "fs";
import { join } from "path";
import { homedir, tmpdir } from "os";
import { pbkdf2Sync } from "crypto";

import { existsSync, mkdirSync } from "fs";

const SLACK_DIR_DIRECT = join(homedir(), "Library", "Application Support", "Slack");
const SLACK_DIR_APPSTORE = join(
  homedir(),
  "Library", "Containers", "com.tinyspeck.slackmacgap",
  "Data", "Library", "Application Support", "Slack"
);

function resolveSlackDir() {
  if (existsSync(SLACK_DIR_DIRECT)) return SLACK_DIR_DIRECT;
  if (existsSync(SLACK_DIR_APPSTORE)) return SLACK_DIR_APPSTORE;
  console.error(
    "Could not find Slack data directory.\n" +
    "Checked:\n" +
    `  ${SLACK_DIR_DIRECT}\n` +
    `  ${SLACK_DIR_APPSTORE}\n` +
    "Is Slack installed?"
  );
  process.exit(1);
}

const SLACK_DIR = resolveSlackDir();
const LEVELDB_DIR = join(SLACK_DIR, "Local Storage", "leveldb");
const COOKIES_DB = join(SLACK_DIR, "Cookies");
const CACHE_DIR = join(homedir(), ".local", "slk");
const TOKEN_CACHE = join(CACHE_DIR, "token-cache.json");

let cachedCreds = null;

function getKeychainKey() {
  // Mac App Store Slack uses account "Slack App Store Key", direct download uses "Slack" or "Slack Key"
  const accounts = SLACK_DIR === SLACK_DIR_APPSTORE
    ? ["Slack App Store Key", "Slack Key", "Slack"]
    : ["Slack Key", "Slack", "Slack App Store Key"];

  for (const account of accounts) {
    try {
      return Buffer.from(
        execSync(
          `security find-generic-password -s "Slack Safe Storage" -a "${account}" -w`,
          { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
        ).trim()
      );
    } catch {}
  }

  console.error("Could not find Slack Safe Storage key in Keychain.");
  process.exit(1);
}

function decryptCookie() {
  const tmpDb = join(tmpdir(), `slk_cookies_${Date.now()}.db`);
  copyFileSync(COOKIES_DB, tmpDb);

  try {
    const hex = execSync(
      `sqlite3 "${tmpDb}" "SELECT hex(encrypted_value) FROM cookies WHERE name='d' AND host_key='.slack.com' LIMIT 1;"`,
      { encoding: "utf-8" }
    ).trim();

    if (!hex) throw new Error("No 'd' cookie found in Slack cookie store");

    const encrypted = Buffer.from(hex, "hex");

    if (encrypted.subarray(0, 3).toString() !== "v10") {
      throw new Error("Unknown cookie encryption format");
    }

    const data = encrypted.subarray(3);
    const aesKey = pbkdf2Sync(getKeychainKey(), "saltysalt", 1003, 16, "sha1");
    const iv = Buffer.alloc(16, " ");

    // Decrypt via openssl using spawnSync for clean binary output
    const tmpEnc = join(tmpdir(), `slk_enc_${Date.now()}.bin`);
    writeFileSync(tmpEnc, data);

    const result = spawnSync("openssl", [
      "enc", "-aes-128-cbc", "-d", "-nopad",
      "-K", aesKey.toString("hex"),
      "-iv", iv.toString("hex"),
      "-in", tmpEnc,
    ]);
    const decrypted = result.stdout;

    unlinkSync(tmpEnc);

    if (!decrypted || decrypted.length === 0) {
      throw new Error("Cookie decryption failed");
    }

    // Remove PKCS7 padding
    const padLen = decrypted[decrypted.length - 1];
    const unpadded = padLen <= 16 ? decrypted.subarray(0, -padLen) : decrypted;
    const text = unpadded.toString("utf-8");

    const idx = text.indexOf("xoxd-");
    if (idx < 0) throw new Error("No xoxd- found in decrypted cookie");
    return text.substring(idx);
  } finally {
    try { unlinkSync(tmpDb); } catch {}
  }
}

function extractToken() {
  const files = readdirSync(LEVELDB_DIR).filter(
    (f) => f.endsWith(".ldb") || f.endsWith(".log")
  );

  const tokens = new Set();

  for (const file of files) {
    try {
      const raw = readFileSync(join(LEVELDB_DIR, file));
      const content = raw.toString("latin1");

      // Method 1: direct regex (works for uncompressed entries)
      for (const m of content.matchAll(/xoxc-[a-zA-Z0-9_-]{20,}/g)) {
        tokens.add(m[0]);
      }

      // Method 2: Snappy-compressed LevelDB blocks mangle tokens.
      // Use Python to properly decompress and extract from the JSON structure.
      // Skip here — handled in extractTokenPython() below.
    } catch {}
  }

  // Method 2: Use Python to extract tokens from Snappy-compressed LevelDB
  // Python's regex on binary-stripped data handles compression artifacts better
  try {
    const pyResult = spawnSync("python3", ["-c", `
import os, re
path = ${JSON.stringify(LEVELDB_DIR)}
for f in os.listdir(path):
    if not (f.endswith(".ldb") or f.endswith(".log")): continue
    data = open(os.path.join(path, f), "rb").read()
    # Find all xoxc- positions and extract by reading the hex tail
    pos = 0
    while True:
        idx = data.find(b"xoxc-", pos)
        if idx < 0: break
        pos = idx + 5
        chunk = data[idx:idx+200]
        # Find the 64-char hex tail
        text = chunk.decode("latin1")
        hm = re.search(r'[a-f0-9]{64}', text)
        if not hm: continue
        # Get all bytes from xoxc- to end of hex tail
        end = text.index(hm.group()) + 64
        raw = chunk[:end]
        # Keep only printable token chars
        clean = bytes(b for b in raw if chr(b) in '0123456789abcdef-xoc').decode()
        # Validate structure
        if re.match(r'^xoxc-\\d+-\\d+-\\d+-[a-f0-9]{64}$', clean):
            print(clean)
`], { encoding: "utf-8", timeout: 5000 });
    if (pyResult.stdout) {
      for (const line of pyResult.stdout.trim().split("\n")) {
        if (line.startsWith("xoxc-")) tokens.add(line);
      }
    }
  } catch {}

  if (tokens.size === 0) {
    throw new Error("No xoxc- token found. Is Slack running?");
  }

  // Return all candidates sorted by length desc; caller will validate
  return [...tokens]
    .filter((t) => t.length > 50) // filter truncated tokens
    .sort((a, b) => b.length - a.length);
}

function loadTokenCache() {
  try {
    if (existsSync(TOKEN_CACHE)) {
      return JSON.parse(readFileSync(TOKEN_CACHE, "utf-8"));
    }
  } catch {}
  return null;
}

function saveTokenCache(token) {
  try {
    mkdirSync(CACHE_DIR, { recursive: true });
    writeFileSync(TOKEN_CACHE, JSON.stringify({ token, ts: Date.now() }));
  } catch {}
}

function validateToken(token, cookie) {
  try {
    const result = spawnSync("curl", [
      "-s", "https://slack.com/api/auth.test",
      "-H", `Authorization: Bearer ${token}`,
      "-b", `d=${cookie}`,
    ], { encoding: "utf-8", timeout: 10000 });
    const data = JSON.parse(result.stdout);
    return data.ok;
  } catch {
    return false;
  }
}

export function getCredentials(forceRefresh = false) {
  if (cachedCreds && !forceRefresh) return cachedCreds;

  const cookie = decryptCookie();

  // Try cached token first (fastest path)
  if (!forceRefresh) {
    const cache = loadTokenCache();
    if (cache?.token && validateToken(cache.token, cookie)) {
      cachedCreds = { [REDACTED_SECRET], cookie };
      return cachedCreds;
    }
  }

  // Extract fresh tokens from LevelDB
  const candidates = extractToken();

  // Validate each candidate
  for (const token of candidates) {
    if (validateToken(token, cookie)) {
      saveTokenCache(token);
      cachedCreds = { token, cookie };
      return cachedCreds;
    }
  }

  // Fallback: return first candidate
  cachedCreds = { [REDACTED_SECRET], cookie };
  return cachedCreds;
}

export function refresh() {
  cachedCreds = null;
  return getCredentials(true);
}


path: src/commands.js
content: /**
 * CLI command implementations.
 */

import { slackApi, slackPaginate } from "./api.js";
import { getCredentials } from "./auth.js";

// ── Helpers ──────────────────────────────────────────────

let userCache = null;

async function getUsers() {
  if (userCache) return userCache;
  const data = await slackPaginate("users.list", {}, "members");
  if (!data.ok) return {};
  userCache = {};
  for (const u of data.members) {
    userCache[u.id] = u.real_name || u.profile?.display_name || u.name;
  }
  return userCache;
}

function userName(users, id) {
  return users[id] || id;
}

async function resolveChannel(nameOrId) {
  // Already a channel/DM/group ID
  if (nameOrId.startsWith("C") || nameOrId.startsWith("D") || nameOrId.startsWith("G")) {
    return nameOrId;
  }
  
  // User ID → open DM and return channel ID
  if (nameOrId.startsWith("U")) {
    const dm = await slackApi("conversations.open", { users: nameOrId });
    if (!dm.ok) throw new Error(`Failed to open DM with ${nameOrId}: ${dm.error}`);
    return dm.channel.id;
  }
  
  // @username or username → find user, open DM
  if (nameOrId.startsWith("@") || !nameOrId.includes("#")) {
    const username = nameOrId.replace(/^@/, "").toLowerCase();
    const users = await getUsers();
    const usersData = await slackPaginate("users.list", {}, "members");
    if (usersData.ok) {
      const user = usersData.members.find(
        (u) => u.name?.toLowerCase() === username || 
               u.real_name?.toLowerCase() === username ||
               u.profile?.display_name?.toLowerCase() === username
      );
      if (user) {
        const dm = await slackApi("conversations.open", { users: user.id });
        if (dm.ok) return dm.channel.id;
      }
    }
  }
  
  // Channel name
  const name = nameOrId.replace(/^#/, "");
  const data = await slackPaginate("conversations.list", {
    types: "public_channel,private_channel,mpim,im",
  });
  if (!data.ok) throw new Error(`Failed to list channels: ${data.error}`);
  const ch = data.channels.find(
    (c) => c.name === name || c.name_normalized === name
  );
  if (!ch) throw new Error(`Channel not found: ${nameOrId}`);
  return ch.id;
}

function formatTs(ts) {
  return new Date(parseFloat(ts) * 1000).toLocaleString();
}

// ── Commands ─────────────────────────────────────────────

export async function auth() {
  const data = await slackApi("auth.test");
  if (data.ok) {
    console.log(`✅ Authenticated as ${data.user} @ ${data.team}`);
    console.log(`   Team ID: ${data.team_id}`);
    console.log(`   User ID: ${data.user_id}`);
    console.log(`   URL: ${data.url}`);
  } else {
    console.error(`❌ Auth failed: ${data.error}`);
    process.exit(1);
  }
}

export async function channels() {
  const data = await slackPaginate("conversations.list", {
    types: "public_channel,private_channel",
    exclude_archived: true,
  });
  if (!data.ok) {
    console.error(`Error: ${data.error}`);
    process.exit(1);
  }
  for (const ch of data.channels) {
    const prefix = ch.is_private ? "🔒" : "#";
    const members = ch.num_members || 0;
    console.log(`${prefix} ${ch.name}  (${members} members, id: ${ch.id})`);
  }
}

export async function dms() {
  const users = await getUsers();
  const data = await slackPaginate("conversations.list", {
    types: "im",
    exclude_archived: true,
  });
  if (!data.ok) {
    console.error(`Error: ${data.error}`);
    process.exit(1);
  }
  for (const ch of data.channels) {
    const name = userName(users, ch.user);
    console.log(`💬 ${name}  (${ch.id})`);
  }
}

export async function read(channelRef, count = 20, options = {}) {
  const { showTs = false, oldest = null, latest = null, expandThreads = false } = options;
  const channel = await resolveChannel(channelRef);
  const users = await getUsers();
  
  const params = { channel, limit: count };
  if (oldest) params.oldest = oldest;
  if (latest) params.latest = latest;
  
  const data = await slackApi("conversations.history", params);
  if (!data.ok) {
    console.error(`Error: ${data.error}`);
    process.exit(1);
  }

  const messages = data.messages.reverse();
  for (const msg of messages) {
    const who = userName(users, msg.user);
    const time = formatTs(msg.ts);
    const tsStr = showTs ? ` ts:${msg.ts}` : "";
    const thread = msg.reply_count ? ` [${msg.reply_count} replies]` : "";
    console.log(`[${time}${tsStr}] ${who}${thread}:`);
    console.log(`  ${msg.text}`);
    if (msg.files?.length) {
      for (const f of msg.files) {
        console.log(`  📎 ${f.name} (${f.mimetype})`);
      }
    }
    
    // Auto-expand threads
    if (expandThreads && msg.reply_count > 0) {
      const replies = await slackApi("conversations.replies", {
        channel,
        ts: msg.ts,
        limit: 100,
      });
      if (replies.ok && replies.messages?.length > 1) {
        for (const reply of replies.messages.slice(1)) { // skip parent
          const replyWho = userName(users, reply.user);
          const replyTime = formatTs(reply.ts);
          const replyTsStr = showTs ? ` ts:${reply.ts}` : "";
          console.log(`    ↳ [${replyTime}${replyTsStr}] ${replyWho}:`);
          console.log(`      ${reply.text}`);
          if (reply.files?.length) {
            for (const f of reply.files) {
              console.log(`      📎 ${f.name} (${f.mimetype})`);
            }
          }
        }
      }
    }
    console.log();
  }
}

export async function send(channelRef, text) {
  const channel = await resolveChannel(channelRef);
  const data = await slackApi("chat.postMessage", { channel, text });
  if (data.ok) {
    console.log(`✅ Sent to ${channelRef} (ts: ${data.ts})`);
  } else {
    console.error(`❌ Failed: ${data.error}`);
    process.exit(1);
  }
}

export async function search(query, count = 20) {
  const data = await slackApi("search.messages", { query, count });
  if (!data.ok) {
    console.error(`Error: ${data.error}`);
    process.exit(1);
  }

  const matches = data.messages?.matches || [];
  console.log(`Found ${data.messages?.total || 0} results\n`);

  const users = await getUsers();
  for (const msg of matches) {
    const who = userName(users, msg.user);
    const time = formatTs(msg.ts);
    const ch = msg.channel?.name || msg.channel?.id || "?";
    console.log(`[${time}] #${ch} — ${who}:`);
    console.log(`  ${msg.text}`);
    console.log();
  }
}

export async function thread(channelRef, ts, count = 50) {
  const channel = await resolveChannel(channelRef);
  const users = await getUsers();
  const data = await slackApi("conversations.replies", {
    channel,
    ts,
    limit: count,
  });
  if (!data.ok) {
    console.error(`Error: ${data.error}`);
    process.exit(1);
  }

  for (const msg of data.messages) {
    const who = userName(users, msg.user);
    const time = formatTs(msg.ts);
    console.log(`[${time}] ${who}:`);
    console.log(`  ${msg.text}`);
    console.log();
  }
}

export async function users() {
  const data = await slackPaginate("users.list", {}, "members");
  if (!data.ok) {
    console.error(`Error: ${data.error}`);
    process.exit(1);
  }

  for (const u of data.members) {
    if (u.deleted || u.is_bot) continue;
    const name = u.real_name || u.name;
    const display = u.profile?.display_name || "";
    const status = u.profile?.status_text ? ` — ${u.profile.status_text}` : "";
    console.log(`${name}${display ? ` (@${display})` : ""} (${u.id})${status}`);
  }
}

async function getMutedChannels() {
  const prefs = await slackApi("users.prefs.get", {});
  if (!prefs.ok) return new Set();

  const allNotifs = prefs.prefs?.all_notifications_prefs;
  if (!allNotifs) return new Set();

  const parsed = typeof allNotifs === "string" ? JSON.parse(allNotifs) : allNotifs;
  const muted = new Set();
  for (const [chId, chPrefs] of Object.entries(parsed.channels || {})) {
    if (chPrefs.muted) muted.add(chId);
  }
  return muted;
}

export async function activity(unreadOnly = false) {
  const users = await getUsers();

  // Get unread counts + muted channels in parallel
  const [counts, mutedSet] = await Promise.all([
    slackApi("client.counts", {}),
    getMutedChannels(),
  ]);

  if (!counts.ok) {
    console.error(`Error: ${counts.error}`);
    process.exit(1);
  }

  // Build channel name map
  const chData = await slackPaginate("conversations.list", {
    types: "public_channel,private_channel,mpim,im",
    exclude_archived: true,
  });
  const chMap = {};
  if (chData.ok) {
    for (const ch of chData.channels) {
      chMap[ch.id] = ch.name || (ch.user ? `DM:${userName(users, ch.user)}` : ch.id);
    }
  }

  // Merge all conversation types
  const all = [
    ...(counts.channels || []).map((c) => ({ ...c, type: "channel" })),
    ...(counts.mpims || []).map((c) => ({ ...c, type: "group" })),
    ...(counts.ims || []).map((c) => ({ ...c, type: "dm" })),
  ];

  // Threads summary
  if (counts.threads?.has_unreads || counts.threads?.mention_count > 0) {
    console.log(`🧵 Threads — ${counts.threads.mention_count} mentions, unreads: ${counts.threads.has_unreads}`);
    console.log();
  }

  // Filter: unreads only, and exclude muted channels
  let filtered = all;
  if (unreadOnly) {
    filtered = filtered.filter(
      (c) => (c.has_unreads || c.mention_count > 0) && !mutedSet.has(c.id)
    );
  }

  if (filtered.length === 0) {
    console.log(unreadOnly ? "No unreads! 🎉" : "No activity.");
    return;
  }

  for (const ch of filtered) {
    const name = chMap[ch.id] || ch.id;
    const isMuted = mutedSet.has(ch.id);
    const prefix = ch.type === "dm" ? "💬" : ch.type === "group" ? "👥" : "#";
    const mentions = ch.mention_count > 0 ? ` (${ch.mention_count} mentions)` : "";
    const unread = ch.has_unreads ? " •" : "";
    const muted = isMuted ? " 🔇" : "";
    console.log(`${prefix} ${name}${unread}${mentions}${muted}`);
  }
}

export async function starred() {
  const users = await getUsers();

  // Get VIP users from prefs
  const prefs = await slackApi("users.prefs.get", {});
  const vipIds = prefs.ok ? (prefs.prefs?.vip_users || "").split(",").filter(Boolean) : [];

  if (vipIds.length > 0) {
    console.log("👑 VIP Users:");
    for (const uid of vipIds) {
      console.log(`   ${userName(users, uid)} (${uid})`);
    }
    console.log();
  }

  // Build channel name map
  const chData = await slackPaginate("conversations.list", {
    types: "public_channel,private_channel,mpim,im",
    exclude_archived: true,
  });
  const chMap = {};
  if (chData.ok) {
    for (const ch of chData.channels) {
      chMap[ch.id] = ch.name || (ch.user ? `DM:${userName(users, ch.user)}` : ch.id);
    }
  }

  // Get starred items
  const stars = await slackApi("stars.list", { count: 50 });
  if (!stars.ok) {
    console.error(`Error: ${stars.error}`);
    process.exit(1);
  }

  if (stars.items?.length > 0) {
    console.log("⭐ Starred:");
    for (const item of stars.items) {
      if (item.type === "message") {
        const msg = item.message || {};
        const ch = chMap[item.channel] || item.channel;
        const who = userName(users, msg.user);
        console.log(`   #${ch} — ${who}: ${(msg.text || "").substring(0, 100)}`);
      } else if (item.type === "channel") {
        console.log(`   #${chMap[item.channel] || item.channel}`);
      } else if (item.type === "im") {
        console.log(`   💬 ${chMap[item.channel] || item.channel}`);
      } else if (item.type === "file") {
        console.log(`   📎 ${item.file?.name || "?"}`);
      }
    }
  } else {
    console.log("⭐ No starred items.");
  }
}

export async function pins(channelRef) {
  const channel = await resolveChannel(channelRef);
  const users = await getUsers();

  const data = await slackApi("pins.list", { channel });
  if (!data.ok) {
    console.error(`Error: ${data.error}`);
    process.exit(1);
  }

  if (!data.items?.length) {
    console.log("No pinned items.");
    return;
  }

  console.log(`📌 ${data.items.length} pinned items:\n`);
  for (const item of data.items) {
    const msg = item.message || {};
    const who = userName(users, msg.user);
    const time = formatTs(msg.ts);
    console.log(`[${time}] ${who}:`);
    console.log(`  ${(msg.text || "").substring(0, 200)}`);
    console.log();
  }
}

export async function saved(count = 20, includeCompleted = false) {
  const users = await getUsers();

  // Build channel name map
  const chData = await slackPaginate("conversations.list", {
    types: "public_channel,private_channel,mpim,im",
    exclude_archived: true,
  });
  const chMap = {};
  if (chData.ok) {
    for (const ch of chData.channels) {
      chMap[ch.id] = ch.name || (ch.user ? `DM:${userName(users, ch.user)}` : ch.id);
    }
  }

  const data = await slackApi("saved.list", { count });
  if (!data.ok) {
    console.error(`Error: ${data.error}`);
    process.exit(1);
  }

  const items = data.saved_items || [];
  const counts = data.counts || {};
  console.log(`📑 Saved for Later — ${counts.uncompleted_count || 0} active, ${counts.completed_count || 0} completed\n`);

  if (!items.length) {
    console.log("No saved items.");
    return;
  }

  for (const item of items) {
    if (!includeCompleted && item.state === "completed") continue;

    const chName = chMap[item.item_id] || item.item_id;
    const savedAt = formatTs(item.date_created);
    const state = item.state === "completed" ? " ✅" : "";

    // Fetch the actual message
    try {
      const msgData = await slackApi("conversations.history", {
        channel: item.item_id,
        latest: item.ts,
        inclusive: true,
        limit: 1,
      });
      if (msgData.ok && msgData.messages?.[0]) {
        const msg = msgData.messages[0];
        const who = userName(users, msg.user);
        const msgTime = formatTs(msg.ts);
        console.log(`[saved ${savedAt}]${state} #${chName} — ${who} (${msgTime}):`);
        console.log(`  ${(msg.text || "").substring(0, 300)}`);
        if (msg.files?.length) {
          for (const f of msg.files) {
            console.log(`  📎 ${f.name} (${f.mimetype})`);
          }
        }
      } else {
        console.log(`[saved ${savedAt}]${state} #${chName} (ts: ${item.ts}) — could not fetch message`);
      }
    } catch {
      console.log(`[saved ${savedAt}]${state} #${chName} (ts: ${item.ts}) — access denied or channel not found`);
    }
    console.log();
  }
}

export async function react(channelRef, ts, emoji) {
  const channel = await resolveChannel(channelRef);
  const data = await slackApi("reactions.add", {
    channel,
    timestamp: ts,
    name: emoji.replace(/:/g, ""),
  });
  if (data.ok) {
    console.log(`✅ Reacted with :${emoji.replace(/:/g, "")}:`);
  } else {
    console.error(`❌ Failed: ${data.error}`);
    process.exit(1);
  }
}


path: src/drafts.js
content: /**
 * Slack drafts — uses the undocumented drafts.* API to create real Slack drafts
 * that appear in the Slack editor UI.
 */

import { randomUUID } from "crypto";
import { slackApi } from "./api.js";

/**
 * Create a draft for a channel.
 */
export async function draftChannel(channel, message) {
  const channelId = await resolveChannelId(channel);
  const data = await createDraft(channelId, null, null, message);
  if (data.ok) {
    console.log(`📝 Draft saved → #${channel} (${data.draft.id})`);
    console.log(`   Check Slack — draft icon should appear.`);
  } else {
    console.error(`❌ Failed: ${data.error}`);
    process.exit(1);
  }
}

/**
 * Create a draft thread reply.
 */
export async function draftThread(channel, threadTs, message) {
  const channelId = await resolveChannelId(channel);
  const data = await createDraft(channelId, threadTs, null, message);
  if (data.ok) {
    console.log(`📝 Draft saved → #${channel} thread ${threadTs} (${data.draft.id})`);
    console.log(`   Check Slack — draft icon should appear.`);
  } else {
    console.error(`❌ Failed: ${data.error}`);
    process.exit(1);
  }
}

/**
 * Create a draft DM.
 */
export async function draftUser(userId, message) {
  // Open a DM conversation to get the channel ID
  const conv = await slackApi("conversations.open", { users: userId });
  if (!conv.ok) {
    console.error(`❌ Can't open DM with ${userId}: ${conv.error}`);
    process.exit(1);
  }
  const channelId = conv.channel.id;
  const data = await createDraft(channelId, null, null, message);
  if (data.ok) {
    console.log(`📝 Draft saved → DM @${userId} (${data.draft.id})`);
    console.log(`   Check Slack — draft icon should appear.`);
  } else {
    console.error(`❌ Failed: ${data.error}`);
    process.exit(1);
  }
}

/**
 * List all drafts.
 */
export async function listDrafts() {
  const data = await slackApi("drafts.list");
  if (!data.ok) {
    console.error(`Error: ${data.error}`);
    process.exit(1);
  }

  const drafts = (data.drafts || []).filter((d) => !d.is_deleted && !d.is_sent);
  if (drafts.length === 0) {
    console.log("No active drafts.");
    return;
  }

  for (const d of drafts) {
    const dest = d.destinations?.[0];
    const target = dest?.channel_id || "?";
    const thread = dest?.thread_ts ? ` (thread ${dest.thread_ts})` : "";
    const text = extractText(d.blocks);
    const age = timeSince(d.date_created * 1000);
    console.log(`${d.id}  → ${target}${thread}  (${age})`);
    console.log(`   ${text.substring(0, 120)}${text.length > 120 ? "..." : ""}`);
    console.log();
  }
}

/**
 * Delete a draft.
 */
export async function dropDraft(draftId) {
  // Need client_last_updated_ts to delete — fetch it first
  const list = await slackApi("drafts.list", {});
  if (!list.ok) {
    console.error(`❌ Failed to list drafts: ${list.error}`);
    process.exit(1);
  }

  const draft = (list.drafts || []).find((d) => d.id === draftId);
  if (!draft) {
    console.error(`❌ Draft ${draftId} not found.`);
    process.exit(1);
  }

  const data = await slackApi("drafts.delete", {
    draft_id: draftId,
    client_last_updated_ts: draft.last_updated_ts,
  });
  if (data.ok) {
    console.log(`🗑  Draft ${draftId} deleted.`);
  } else {
    console.error(`❌ Failed: ${data.error}`);
    process.exit(1);
  }
}

// ── Helpers ──────────────────────────────────────────────

async function resolveChannelId(nameOrId) {
  if (/^[CDG]/.test(nameOrId)) return nameOrId;

  const name = nameOrId.replace(/^#/, "");
  const data = await slackApi("conversations.list", {
    types: "public_channel,private_channel,mpim,im",
    limit: 200,
  });
  if (!data.ok) throw new Error(`Failed to list channels: ${data.error}`);
  const ch = data.channels.find(
    (c) => c.name === name || c.name_normalized === name
  );
  if (!ch) throw new Error(`Channel not found: ${nameOrId}`);
  return ch.id;
}

async function createDraft(channelId, threadTs, userIds, text) {
  const destination = { channel_id: channelId };
  if (threadTs) {
    destination.thread_ts = threadTs;
    destination.broadcast = false;
  }
  if (userIds) {
    destination.user_ids = userIds;
  }

  return slackApi("drafts.create", {
    client_msg_id: randomUUID(),
    is_from_composer: false,
    file_ids: [],
    destinations: [destination],
    blocks: [
      {
        type: "rich_text",
        elements: [
          {
            type: "rich_text_section",
            elements: [{ type: "text", text }],
          },
        ],
      },
    ],
  });
}

function extractText(blocks) {
  if (!blocks?.length) return "(empty)";
  const parts = [];
  for (const block of blocks) {
    for (const el of block.elements || []) {
      for (const item of el.elements || []) {
        if (item.type === "text") parts.push(item.text);
        if (item.type === "emoji") parts.push(`:${item.name}:`);
        if (item.type === "link") parts.push(item.url);
      }
    }
  }
  return parts.join("") || "(empty)";
}

function timeSince(ms) {
  const diff = Date.now() - ms;
  const mins = Math.floor(diff / 60000);
  if (mins < 1) return "just now";
  if (mins < 60) return `${mins}m ago`;
  const hrs = Math.floor(mins / 60);
  if (hrs < 24) return `${hrs}h ago`;
  return `${Math.floor(hrs / 24)}d ago`;
}


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)