slack-term 1.14.0 → 1.16.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slack-term",
3
- "version": "1.14.0",
3
+ "version": "1.16.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
@@ -22,7 +22,7 @@
22
22
  "vitest": "^4.1.4"
23
23
  },
24
24
  "bin": {
25
- "slack": "./dist/cli.js"
25
+ "slack": "./ts/cli.ts"
26
26
  },
27
27
  "description": "Slack CLI — read activity/news/mentions, search, send messages with a confirm-hash safety gate.",
28
28
  "engines": {
@@ -49,7 +49,10 @@
49
49
  "@semantic-release/github": "^12"
50
50
  },
51
51
  "publishConfig": {
52
- "access": "public"
52
+ "access": "public",
53
+ "bin": {
54
+ "slack": "./dist/cli.js"
55
+ }
53
56
  },
54
57
  "scripts": {
55
58
  "dev": "bun run ts/cli.ts",
package/ts/auth.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  // Interactive auth setup: slack auth login / slack login
2
2
  import { createInterface, type Interface } from "node:readline/promises";
3
- import { addProfile, listProfiles } from "./profiles.ts";
3
+ import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { addProfile, listProfiles, setCookie, saveToEnvFile } from "./profiles.ts";
4
6
  import { authTest } from "./slack.ts";
5
- import { extractSessions } from "./slack-app.ts";
7
+ import { extractSessions, discoverChromeCookies, discoverFirefoxCookies } from "./slack-app.ts";
6
8
 
7
9
  const USER_SCOPES = [
8
10
  "search:read",
@@ -50,7 +52,8 @@ async function ask(rl: Interface, q: string): Promise<string> {
50
52
  return (await rl.question(q)).trim();
51
53
  }
52
54
 
53
- async function saveToken(rl: Interface | null, token: string, nameOverride?: string): Promise<string> {
55
+
56
+ async function saveToken(rl: Interface | null, token: string, nameOverride?: string, cookie?: string): Promise<string> {
54
57
  console.error("Verifying token...");
55
58
  const info = await authTest(token);
56
59
  const defaultName = slugify(info.team);
@@ -63,28 +66,67 @@ async function saveToken(rl: Interface | null, token: string, nameOverride?: str
63
66
  } else {
64
67
  name = defaultName;
65
68
  }
66
- addProfile(name, { token, ...info });
67
- console.log(`Saved workspace "${name}": ${info.team} (${info.user})`);
68
- if (process.env.SLACK_MCP_XOXP_TOKEN) {
69
+
70
+ // Non-interactive: always save to profiles.json (predictable, backward-compatible for scripting).
71
+ // Interactive: ask where to save.
72
+ let filePath: string | null = null; // null = profiles.json
73
+ if (rl) {
74
+ console.log("");
75
+ console.log("Where to save the token?");
76
+ console.log(" 1) ./.env.local (current directory) [default]");
77
+ console.log(" 2) ./.slack-term/.env.local (current directory, slack-specific)");
78
+ console.log(" 3) ~/.slack-term/.env.local (global, available everywhere)");
79
+ console.log(" 4) profiles.json (multi-workspace: slack auth use)");
69
80
  console.log("");
70
- console.log("Warning: SLACK_MCP_XOXP_TOKEN is set in your environment - it conflicts with profiles.");
71
- console.log(" Unset it so your new profile is used:");
72
- console.log(" unset SLACK_MCP_XOXP_TOKEN");
73
- console.log(" Also remove it from your shell config (~/.zshrc, ~/.bashrc, etc.)");
81
+ const choice = await ask(rl, "Choice [1/2/3/4, Enter=1]: ");
82
+ if (choice === "2") filePath = join(process.cwd(), ".slack-term", ".env.local");
83
+ else if (choice === "3") filePath = join(homedir(), ".slack-term", ".env.local");
84
+ else if (choice === "4") filePath = null;
85
+ else filePath = join(process.cwd(), ".env.local"); // 1 or Enter
86
+ }
87
+
88
+ if (filePath === null) {
89
+ addProfile(name, { token, ...info, ...(cookie ? { cookie } : {}) });
90
+ console.log(`Saved workspace "${name}" to profiles.json: ${info.team} (${info.user})`);
91
+ if (process.env.SLACK_MCP_XOXP_TOKEN) {
92
+ console.log("");
93
+ console.log("Warning: SLACK_MCP_XOXP_TOKEN is set — it conflicts with profiles.");
94
+ console.log(" Unset it: unset SLACK_MCP_XOXP_TOKEN (and remove from ~/.zshrc / ~/.bashrc)");
95
+ } else {
96
+ console.log(` Run: slack auth use -g ${name}`);
97
+ }
74
98
  } else {
75
- console.log(` Run: slack auth use -g ${name}`);
99
+ const updates: Record<string, string> = { SLACK_TOKEN: token };
100
+ if (cookie) updates.SLACK_COOKIE = cookie;
101
+ saveToEnvFile(filePath, updates);
102
+ console.log(`Saved token to ${filePath}`);
103
+ console.log(` SLACK_TOKEN will be picked up automatically in this directory tree.`);
76
104
  }
77
105
  return name;
78
106
  }
79
107
 
80
108
  /** Shared logic for importing sessions from the Slack desktop app. */
81
- export async function importFromDesktop(): Promise<void> {
109
+ export async function importFromDesktop(rl?: Interface): Promise<void> {
82
110
  console.error("Scanning Slack desktop app...");
83
111
  const sessions = await extractSessions();
84
112
  if (sessions.length === 0) {
85
113
  console.error("No sessions found. Make sure Slack is installed and you have signed in at least once.");
86
114
  process.exit(1);
87
115
  }
116
+
117
+ // Single workspace + interactive: offer save-destination choice
118
+ if (sessions.length === 1 && rl) {
119
+ const s = sessions[0]!;
120
+ const teamLabel = s.teamName ?? s.teamId;
121
+ console.log(`Found workspace: ${teamLabel}${s.cookie ? " (+ xoxd cookie)" : ""}`);
122
+ await saveToken(rl, s.token, slugify(teamLabel), s.cookie);
123
+ console.log("");
124
+ console.log("Note: desktop app tokens (xoxc-) are internal Slack tokens.");
125
+ console.log("If API calls fail, replace with an xoxp- token: slack auth login");
126
+ return;
127
+ }
128
+
129
+ // Multiple workspaces or non-interactive: save all to profiles.json
88
130
  for (const s of sessions) {
89
131
  const teamLabel = s.teamName ?? s.teamId;
90
132
  const name = slugify(teamLabel);
@@ -172,6 +214,219 @@ async function loginNewApp(rl: Interface, mode: "user" | "bot"): Promise<void> {
172
214
  await saveToken(rl, token);
173
215
  }
174
216
 
217
+ /**
218
+ * Attach the xoxd session cookie from Chrome browser to an existing workspace profile.
219
+ *
220
+ * When run interactively, macOS will show a system dialog asking for the login password
221
+ * to grant access to the "Chrome Safe Storage" keychain item — click Allow.
222
+ */
223
+ export async function cmdAuthChrome(opts: { workspace?: string } = {}): Promise<void> {
224
+ if (process.platform !== "darwin") {
225
+ console.error("Chrome cookie extraction is only supported on macOS.");
226
+ process.exit(1);
227
+ }
228
+
229
+ const profiles = listProfiles();
230
+ if (profiles.length === 0) {
231
+ console.error("No workspaces configured. Run: slack auth login");
232
+ process.exit(1);
233
+ }
234
+
235
+ let profileName: string;
236
+ if (opts.workspace) {
237
+ const found = profiles.find((p) => p.name === opts.workspace);
238
+ if (!found) {
239
+ console.error(`Workspace "${opts.workspace}" not found. Available: ${profiles.map((p) => p.name).join(", ")}`);
240
+ process.exit(1);
241
+ }
242
+ profileName = opts.workspace;
243
+ } else if (profiles.length === 1) {
244
+ profileName = profiles[0]!.name;
245
+ } else {
246
+ // Multiple profiles — ask the user to pick
247
+ const current = profiles.find((p) => p.current);
248
+ if (current) {
249
+ profileName = current.name;
250
+ console.log(`Using active workspace: ${profileName}`);
251
+ } else {
252
+ console.log("Multiple workspaces found. Choose one:");
253
+ profiles.forEach((p, i) => console.log(` ${i + 1}) ${p.name} (${p.profile.team})`));
254
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
255
+ const choice = (await rl.question("Choice: ")).trim();
256
+ rl.close();
257
+ const idx = parseInt(choice, 10) - 1;
258
+ if (isNaN(idx) || idx < 0 || idx >= profiles.length) {
259
+ console.error("Invalid choice.");
260
+ process.exit(1);
261
+ }
262
+ profileName = profiles[idx]!.name;
263
+ }
264
+ }
265
+
266
+ console.log("Scanning Chrome profiles for Slack session...");
267
+ console.log("macOS may show a dialog asking for your login password — click Allow.");
268
+
269
+ let candidates: import("./slack-app.ts").ChromeCookieCandidate[];
270
+ let totalProfiles: number;
271
+ try {
272
+ ({ candidates, totalProfiles } = discoverChromeCookies());
273
+ } catch (e: unknown) {
274
+ console.error(`Failed: ${e instanceof Error ? e.message : String(e)}`);
275
+ process.exit(1);
276
+ }
277
+
278
+ if (candidates.length === 0) {
279
+ console.error(`No Slack session found in Chrome (scanned ${totalProfiles} profile${totalProfiles !== 1 ? "s" : ""}). Possible reasons:`);
280
+ console.error(" - You denied the keychain dialog (try running again and click Allow)");
281
+ console.error(" - Chrome is not installed or has no Slack session");
282
+ console.error(" - You're not logged in to Slack in Chrome");
283
+ process.exit(1);
284
+ }
285
+
286
+ let cookie: string;
287
+ // Always prompt when multiple Chrome profiles exist, so users can confirm the right one.
288
+ if (candidates.length === 1 && totalProfiles <= 1) {
289
+ cookie = candidates[0]!.cookie;
290
+ console.log(`Found session in Chrome profile: ${candidates[0]!.profileName}`);
291
+ } else {
292
+ const label = candidates.length === 1
293
+ ? `Found 1 Slack session across ${totalProfiles} Chrome profiles:`
294
+ : `Found ${candidates.length} Slack sessions across ${totalProfiles} Chrome profiles:`;
295
+ console.log(label);
296
+ candidates.forEach((c, i) => console.log(` ${i + 1}) ${c.profileName} [${c.profileDir}]`));
297
+ console.log("");
298
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
299
+ const defaultChoice = candidates.length === 1 ? " [Enter=1]" : "";
300
+ const choice = (await rl.question(`Choice [1-${candidates.length}]${defaultChoice}: `)).trim();
301
+ rl.close();
302
+ const idx = choice === "" && candidates.length === 1 ? 0 : parseInt(choice, 10) - 1;
303
+ if (isNaN(idx) || idx < 0 || idx >= candidates.length) {
304
+ console.error("Invalid choice.");
305
+ process.exit(1);
306
+ }
307
+ cookie = candidates[idx]!.cookie;
308
+ }
309
+
310
+ setCookie(profileName, cookie);
311
+ console.log(`Saved xoxd cookie to workspace "${profileName}".`);
312
+ console.log(`RTM WebSocket mode is now available: slack tail @you`);
313
+ }
314
+
315
+ /**
316
+ * Attach the xoxd session cookie from Firefox browser to an existing workspace profile.
317
+ * Firefox stores cookies in plaintext — no keychain access needed.
318
+ */
319
+ export async function cmdAuthFirefox(opts: { workspace?: string } = {}): Promise<void> {
320
+ const profiles = listProfiles();
321
+ if (profiles.length === 0) {
322
+ console.error("No workspaces configured. Run: slack auth token");
323
+ process.exit(1);
324
+ }
325
+
326
+ let profileName: string;
327
+ if (opts.workspace) {
328
+ const found = profiles.find((p) => p.name === opts.workspace);
329
+ if (!found) {
330
+ console.error(`Workspace "${opts.workspace}" not found. Available: ${profiles.map((p) => p.name).join(", ")}`);
331
+ process.exit(1);
332
+ }
333
+ profileName = opts.workspace;
334
+ } else if (profiles.length === 1) {
335
+ profileName = profiles[0]!.name;
336
+ } else {
337
+ const current = profiles.find((p) => p.current);
338
+ if (current) {
339
+ profileName = current.name;
340
+ console.log(`Using active workspace: ${profileName}`);
341
+ } else {
342
+ console.log("Multiple workspaces found. Choose one:");
343
+ profiles.forEach((p, i) => console.log(` ${i + 1}) ${p.name} (${p.profile.team})`));
344
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
345
+ const choice = (await rl.question("Choice: ")).trim();
346
+ rl.close();
347
+ const idx = parseInt(choice, 10) - 1;
348
+ if (isNaN(idx) || idx < 0 || idx >= profiles.length) {
349
+ console.error("Invalid choice.");
350
+ process.exit(1);
351
+ }
352
+ profileName = profiles[idx]!.name;
353
+ }
354
+ }
355
+
356
+ console.log("Scanning Firefox profiles for Slack session...");
357
+ const candidates = discoverFirefoxCookies();
358
+
359
+ if (candidates.length === 0) {
360
+ console.error("No Slack session found in Firefox. Possible reasons:");
361
+ console.error(" - Firefox is not installed");
362
+ console.error(" - You are not logged in to Slack in Firefox");
363
+ process.exit(1);
364
+ }
365
+
366
+ let cookie: string;
367
+ if (candidates.length === 1) {
368
+ cookie = candidates[0]!.cookie;
369
+ console.log(`Found session in Firefox profile: ${candidates[0]!.profileName}`);
370
+ } else {
371
+ console.log("Multiple Firefox profiles have a Slack session. Choose one:");
372
+ candidates.forEach((c, i) => console.log(` ${i + 1}) ${c.profileName} [${c.profileDir}]`));
373
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
374
+ const choice = (await rl.question("Choice: ")).trim();
375
+ rl.close();
376
+ const idx = parseInt(choice, 10) - 1;
377
+ if (isNaN(idx) || idx < 0 || idx >= candidates.length) {
378
+ console.error("Invalid choice.");
379
+ process.exit(1);
380
+ }
381
+ cookie = candidates[idx]!.cookie;
382
+ }
383
+
384
+ setCookie(profileName, cookie);
385
+ console.log(`Saved xoxd cookie to workspace "${profileName}".`);
386
+ console.log(`RTM WebSocket mode is now available: slack tail @you`);
387
+ }
388
+
389
+ /** Paste an existing xoxp-/xoxb- token (non-interactive or TTY). */
390
+ export async function cmdAuthToken(opts: { token?: string; name?: string } = {}): Promise<void> {
391
+ if (opts.token) {
392
+ await saveToken(null, opts.token, opts.name);
393
+ return;
394
+ }
395
+ if (!process.stdin.isTTY) {
396
+ const chunks: Buffer[] = [];
397
+ for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
398
+ const token = Buffer.concat(chunks).toString("utf8").trim();
399
+ if (!token) { console.error("No token provided on stdin."); process.exit(1); }
400
+ await saveToken(null, token, opts.name);
401
+ return;
402
+ }
403
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
404
+ try {
405
+ await loginExisting(rl);
406
+ } finally {
407
+ rl.close();
408
+ }
409
+ }
410
+
411
+ /** Guided Slack app creation wizard (user or bot token). */
412
+ export async function cmdAuthApp(opts: { bot?: boolean } = {}): Promise<void> {
413
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
414
+ try {
415
+ if (opts.bot !== undefined) {
416
+ await loginNewApp(rl, opts.bot ? "bot" : "user");
417
+ return;
418
+ }
419
+ console.log("Which token type?");
420
+ console.log(" 1) User token (xoxp-) -full access including search [default]");
421
+ console.log(" 2) Bot token (xoxb-) -search and news unavailable");
422
+ console.log("");
423
+ const choice = await ask(rl, "Choice [1/2, Enter=1]: ");
424
+ await loginNewApp(rl, choice === "2" ? "bot" : "user");
425
+ } finally {
426
+ rl.close();
427
+ }
428
+ }
429
+
175
430
  export async function cmdAuthLogin(opts: { token?: string; name?: string } = {}): Promise<void> {
176
431
  // Show existing profiles if any
177
432
  const existing = listProfiles();
@@ -225,7 +480,7 @@ export async function cmdAuthLogin(opts: { token?: string; name?: string } = {})
225
480
  console.log("");
226
481
 
227
482
  if (choice === "1") {
228
- await importFromDesktop();
483
+ await importFromDesktop(rl);
229
484
  } else if (choice === "2") {
230
485
  await loginExisting(rl);
231
486
  } else if (choice === "3") {
@@ -0,0 +1,96 @@
1
+ // Bot messaging diagnostics.
2
+ //
3
+ // A bot can *send* a DM with chat:write + im:write, but for an escalation DM to
4
+ // be useful the user must also be able to *reply* and the bot must be able to
5
+ // *read* that reply (im:history / im:read), and the app's Messages Tab must
6
+ // allow user messages. Slack exposes granted scopes via the X-OAuth-Scopes
7
+ // header (see slack.ts authScopes); the Messages-Tab toggle is not readable via
8
+ // the API, so we surface it as a manual step whenever messaging looks incomplete.
9
+
10
+ import { authScopes, botsInfo } from "./slack.ts";
11
+
12
+ // Scopes required to DM a user as the bot.
13
+ export const DM_SEND_SCOPES = ["chat:write", "im:write"];
14
+ // Scopes required to read the user's reply to that DM.
15
+ export const DM_REPLY_SCOPES = ["im:history", "im:read"];
16
+
17
+ export type BotMessagingDiagnosis = {
18
+ ok: boolean; // can both send and receive replies
19
+ canSend: boolean;
20
+ canReceiveReplies: boolean;
21
+ botUserId: string;
22
+ appId: string;
23
+ team: string;
24
+ grantedScopes: string[];
25
+ missingSendScopes: string[];
26
+ missingReplyScopes: string[];
27
+ };
28
+
29
+ export async function diagnoseBotMessaging(token: string): Promise<BotMessagingDiagnosis> {
30
+ const { userId, botId, team, scopes } = await authScopes(token);
31
+ let appId = "";
32
+ if (botId) {
33
+ // app_id is best-effort: bots.info needs no extra scope, but never let a
34
+ // lookup failure block the diagnosis itself.
35
+ try {
36
+ appId = (await botsInfo(token, botId)).appId;
37
+ } catch {
38
+ appId = "";
39
+ }
40
+ }
41
+ const has = (s: string): boolean => scopes.includes(s);
42
+ const missingSendScopes = DM_SEND_SCOPES.filter((s) => !has(s));
43
+ const missingReplyScopes = DM_REPLY_SCOPES.filter((s) => !has(s));
44
+ return {
45
+ canSend: missingSendScopes.length === 0,
46
+ canReceiveReplies: missingReplyScopes.length === 0,
47
+ ok: missingSendScopes.length === 0 && missingReplyScopes.length === 0,
48
+ botUserId: userId,
49
+ appId,
50
+ team,
51
+ grantedScopes: scopes,
52
+ missingSendScopes,
53
+ missingReplyScopes,
54
+ };
55
+ }
56
+
57
+ function detailLines(d: BotMessagingDiagnosis): string[] {
58
+ return [
59
+ ``,
60
+ ` Bot user: ${d.botUserId || "(unknown)"}`,
61
+ ` App ID: ${d.appId || "(unknown — bots.info unavailable)"}`,
62
+ ` Granted scopes: ${d.grantedScopes.join(", ") || "(none reported)"}`,
63
+ ];
64
+ }
65
+
66
+ // Human-facing guidance. `compact` drops the trailing identity/scope dump used
67
+ // by `slack doctor`. Note `ok` only means scopes are complete — the Messages Tab
68
+ // toggle ("Allow users to send messages") is NOT readable via the API, so we
69
+ // always surface it as a manual check rather than over-claiming readiness.
70
+ export function formatDiagnosis(d: BotMessagingDiagnosis, compact = false): string[] {
71
+ const appUrl = d.appId ? `https://api.slack.com/apps/${d.appId}` : "https://api.slack.com/apps";
72
+
73
+ if (d.ok) {
74
+ const lines = [
75
+ `✓ Bot DM scopes are complete (send + read replies).`,
76
+ ` Two-way replies also require the Messages Tab, which is not API-detectable.`,
77
+ ` If recipients see "Sending messages to this app is turned off", enable it:`,
78
+ ` ${appUrl} → App Home → "Messages Tab" ON + "Allow users to send messages".`,
79
+ ];
80
+ return compact ? lines : [...lines, ...detailLines(d)];
81
+ }
82
+
83
+ const lines: string[] = [];
84
+ if (!d.canSend) {
85
+ lines.push(`⚠ This bot CANNOT send DMs — missing scope(s): ${d.missingSendScopes.join(", ")}.`);
86
+ } else {
87
+ lines.push(`⚠ This bot can send DMs, but the user's replies will NOT be readable.`);
88
+ }
89
+ lines.push(` Make escalation DMs two-way:`);
90
+ lines.push(` 1. ${appUrl} → App Home → enable "Messages Tab" and check`);
91
+ lines.push(` "Allow users to send Slash commands and messages".`);
92
+ lines.push(` 2. OAuth & Permissions → add Bot Token Scopes: ${[...d.missingSendScopes, ...d.missingReplyScopes].join(", ")}`);
93
+ lines.push(` 3. Reinstall the app to apply the new scopes.`);
94
+
95
+ return compact ? lines : [...lines, ...detailLines(d)];
96
+ }