slkcli 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -59,6 +59,9 @@ slk unread
59
59
  # See starred items and VIP users
60
60
  slk starred
61
61
 
62
+ # See saved for later items
63
+ slk saved
64
+
62
65
  # See pinned messages in a channel
63
66
  slk pins general
64
67
 
@@ -84,6 +87,7 @@ slk react general 1234567890.123456 thumbsup
84
87
  | `slk activity` | `a` | Show all channel activity with unread/mention counts |
85
88
  | `slk unread` | `ur` | Show only channels with unreads (excludes muted) |
86
89
  | `slk starred` | `star` | Show VIP users and starred items |
90
+ | `slk saved [count]` | `sv` | Show saved for later items (active by default, `--all` includes completed) |
87
91
  | `slk pins <channel>` | `pin` | Show pinned items in a channel |
88
92
 
89
93
  ### Flags
@@ -92,6 +96,7 @@ slk react general 1234567890.123456 thumbsup
92
96
  |------|-------------|
93
97
  | `--ts` | Show raw Slack timestamps (useful for getting ts to read threads) |
94
98
  | `--no-emoji` | Disable emoji in output (or set `NO_EMOJI=1`) |
99
+ | `--all` | Include completed items in `slk saved` |
95
100
 
96
101
  ```bash
97
102
  # Get timestamps to use with thread command
@@ -140,9 +145,9 @@ On first run, macOS will show a Keychain dialog asking whether to allow access t
140
145
 
141
146
  ### How it works
142
147
 
143
- 1. **Cookie decryption** — Reads the encrypted `d` cookie from Slack's SQLite cookie store (`~/Library/Application Support/Slack/Cookies`). Decrypts it using the "Slack Safe Storage" key from the macOS Keychain via PBKDF2 + AES-128-CBC.
148
+ 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.
144
149
 
145
- 2. **Token extraction** — Scans Slack's LevelDB storage (`~/Library/Application Support/Slack/Local Storage/leveldb/`) for `xoxc-` session tokens. Uses both direct regex scanning and a Python fallback for Snappy-compressed entries.
150
+ 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).
146
151
 
147
152
  3. **Validation** — Tests each candidate token against `auth.test` with the decrypted cookie. The first valid pair is used.
148
153
 
@@ -172,8 +177,8 @@ Validated tokens are cached to avoid re-extracting on every invocation:
172
177
  | Data | Source | Purpose |
173
178
  |------|--------|---------|
174
179
  | Keychain password | `security find-generic-password -s "Slack Safe Storage"` | Derive AES key for cookie decryption |
175
- | Encrypted cookie | `~/Library/Application Support/Slack/Cookies` (SQLite) | Decrypt the `d` session cookie (`xoxd-`) |
176
- | Session token | `~/Library/Application Support/Slack/Local Storage/leveldb/` | Extract `xoxc-` token |
180
+ | Encrypted cookie | `<slack-data-dir>/Cookies` (SQLite) | Decrypt the `d` session cookie (`xoxd-`) |
181
+ | Session token | `<slack-data-dir>/Local Storage/leveldb/` | Extract `xoxc-` token |
177
182
 
178
183
  ## Agent usage patterns
179
184
 
@@ -236,6 +241,7 @@ npm link # symlink globally for development
236
241
  ## Notes
237
242
 
238
243
  - **macOS only** — uses Keychain and Electron storage paths specific to macOS.
244
+ - **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.
239
245
  - **Slack desktop app required** — must be installed and logged in. The app does not need to be running for cached tokens.
240
246
  - **Zero dependencies** — uses only Node.js built-in modules (`crypto`, `fs`, `child_process`, `fetch`).
241
247
  - **Session-based** — uses `xoxc-` tokens (user session), not bot tokens. This means you act as yourself.
package/bin/slk.js CHANGED
@@ -28,6 +28,7 @@ Commands:
28
28
  slk activity (a) Channel activity with unread/mention counts
29
29
  slk unread (ur) Channels with unreads (excludes muted)
30
30
  slk starred (star) VIP users + starred items
31
+ slk saved [n] (sv) Saved for later items (--all includes completed)
31
32
  slk pins <ch> (pin) Pinned items in a channel
32
33
 
33
34
  Drafts (synced to Slack UI):
@@ -119,6 +120,11 @@ async function main() {
119
120
  await cmd.starred();
120
121
  break;
121
122
 
123
+ case "saved":
124
+ case "sv":
125
+ await cmd.saved(parseInt(args[1]) || 20, args.includes("--all"));
126
+ break;
127
+
122
128
  case "pins":
123
129
  case "pin":
124
130
  if (!args[1]) { console.error("Usage: slk pins <channel>"); process.exit(1); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slkcli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Slack CLI for macOS, so your agents can read and send messages",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/api.js CHANGED
@@ -29,6 +29,7 @@ export async function slackApi(method, params = {}, retried = false) {
29
29
  "conversations.open",
30
30
  "client.counts",
31
31
  "users.prefs.get",
32
+ "saved.list",
32
33
  ];
33
34
 
34
35
  const isWrite = writeMethods.some((m) => method.startsWith(m));
package/src/auth.js CHANGED
@@ -14,7 +14,27 @@ import { pbkdf2Sync } from "crypto";
14
14
 
15
15
  import { existsSync, mkdirSync } from "fs";
16
16
 
17
- const SLACK_DIR = join(homedir(), "Library", "Application Support", "Slack");
17
+ const SLACK_DIR_DIRECT = join(homedir(), "Library", "Application Support", "Slack");
18
+ const SLACK_DIR_APPSTORE = join(
19
+ homedir(),
20
+ "Library", "Containers", "com.tinyspeck.slackmacgap",
21
+ "Data", "Library", "Application Support", "Slack"
22
+ );
23
+
24
+ function resolveSlackDir() {
25
+ if (existsSync(SLACK_DIR_DIRECT)) return SLACK_DIR_DIRECT;
26
+ if (existsSync(SLACK_DIR_APPSTORE)) return SLACK_DIR_APPSTORE;
27
+ console.error(
28
+ "Could not find Slack data directory.\n" +
29
+ "Checked:\n" +
30
+ ` ${SLACK_DIR_DIRECT}\n` +
31
+ ` ${SLACK_DIR_APPSTORE}\n` +
32
+ "Is Slack installed?"
33
+ );
34
+ process.exit(1);
35
+ }
36
+
37
+ const SLACK_DIR = resolveSlackDir();
18
38
  const LEVELDB_DIR = join(SLACK_DIR, "Local Storage", "leveldb");
19
39
  const COOKIES_DB = join(SLACK_DIR, "Cookies");
20
40
  const CACHE_DIR = join(homedir(), ".local", "slk");
@@ -23,11 +43,24 @@ const TOKEN_CACHE = join(CACHE_DIR, "token-cache.json");
23
43
  let cachedCreds = null;
24
44
 
25
45
  function getKeychainKey() {
26
- return Buffer.from(
27
- execSync('security find-generic-password -s "Slack Safe Storage" -w', {
28
- encoding: "utf-8",
29
- }).trim()
30
- );
46
+ // Mac App Store Slack uses account "Slack App Store Key", direct download uses "Slack"
47
+ const accounts = SLACK_DIR === SLACK_DIR_APPSTORE
48
+ ? ["Slack App Store Key", "Slack"]
49
+ : ["Slack", "Slack App Store Key"];
50
+
51
+ for (const account of accounts) {
52
+ try {
53
+ return Buffer.from(
54
+ execSync(
55
+ `security find-generic-password -s "Slack Safe Storage" -a "${account}" -w`,
56
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
57
+ ).trim()
58
+ );
59
+ } catch {}
60
+ }
61
+
62
+ console.error("Could not find Slack Safe Storage key in Keychain.");
63
+ process.exit(1);
31
64
  }
32
65
 
33
66
  function decryptCookie() {
@@ -111,7 +144,7 @@ function extractToken() {
111
144
  try {
112
145
  const pyResult = spawnSync("python3", ["-c", `
113
146
  import os, re
114
- path = os.path.expanduser("~/Library/Application Support/Slack/Local Storage/leveldb")
147
+ path = ${JSON.stringify(LEVELDB_DIR)}
115
148
  for f in os.listdir(path):
116
149
  if not (f.endswith(".ldb") or f.endswith(".log")): continue
117
150
  data = open(os.path.join(path, f), "rb").read()
package/src/commands.js CHANGED
@@ -333,6 +333,72 @@ export async function pins(channelRef) {
333
333
  }
334
334
  }
335
335
 
336
+ export async function saved(count = 20, includeCompleted = false) {
337
+ const users = await getUsers();
338
+
339
+ // Build channel name map
340
+ const chData = await slackPaginate("conversations.list", {
341
+ types: "public_channel,private_channel,mpim,im",
342
+ exclude_archived: true,
343
+ });
344
+ const chMap = {};
345
+ if (chData.ok) {
346
+ for (const ch of chData.channels) {
347
+ chMap[ch.id] = ch.name || (ch.user ? `DM:${userName(users, ch.user)}` : ch.id);
348
+ }
349
+ }
350
+
351
+ const data = await slackApi("saved.list", { count });
352
+ if (!data.ok) {
353
+ console.error(`Error: ${data.error}`);
354
+ process.exit(1);
355
+ }
356
+
357
+ const items = data.saved_items || [];
358
+ const counts = data.counts || {};
359
+ console.log(`📑 Saved for Later — ${counts.uncompleted_count || 0} active, ${counts.completed_count || 0} completed\n`);
360
+
361
+ if (!items.length) {
362
+ console.log("No saved items.");
363
+ return;
364
+ }
365
+
366
+ for (const item of items) {
367
+ if (!includeCompleted && item.state === "completed") continue;
368
+
369
+ const chName = chMap[item.item_id] || item.item_id;
370
+ const savedAt = formatTs(item.date_created);
371
+ const state = item.state === "completed" ? " ✅" : "";
372
+
373
+ // Fetch the actual message
374
+ try {
375
+ const msgData = await slackApi("conversations.history", {
376
+ channel: item.item_id,
377
+ latest: item.ts,
378
+ inclusive: true,
379
+ limit: 1,
380
+ });
381
+ if (msgData.ok && msgData.messages?.[0]) {
382
+ const msg = msgData.messages[0];
383
+ const who = userName(users, msg.user);
384
+ const msgTime = formatTs(msg.ts);
385
+ console.log(`[saved ${savedAt}]${state} #${chName} — ${who} (${msgTime}):`);
386
+ console.log(` ${(msg.text || "").substring(0, 300)}`);
387
+ if (msg.files?.length) {
388
+ for (const f of msg.files) {
389
+ console.log(` 📎 ${f.name} (${f.mimetype})`);
390
+ }
391
+ }
392
+ } else {
393
+ console.log(`[saved ${savedAt}]${state} #${chName} (ts: ${item.ts}) — could not fetch message`);
394
+ }
395
+ } catch {
396
+ console.log(`[saved ${savedAt}]${state} #${chName} (ts: ${item.ts}) — access denied or channel not found`);
397
+ }
398
+ console.log();
399
+ }
400
+ }
401
+
336
402
  export async function react(channelRef, ts, emoji) {
337
403
  const channel = await resolveChannel(channelRef);
338
404
  const data = await slackApi("reactions.add", {