slack-term 1.7.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/ts/format.ts ADDED
@@ -0,0 +1,77 @@
1
+ // Text-formatting helpers: mention/date-markup resolution, day grouping.
2
+
3
+ import { userName } from "./slack.ts";
4
+
5
+ export async function resolveMentions(
6
+ token: string,
7
+ text: string,
8
+ cache: Map<string, string>,
9
+ ): Promise<string> {
10
+ let result = text;
11
+ const ids: string[] = [];
12
+ let search = result;
13
+ let searchOffset = 0;
14
+ while (true) {
15
+ const pos = search.indexOf("<@U", searchOffset);
16
+ if (pos === -1) break;
17
+ const rest = search.slice(pos + 2);
18
+ const endIdx = rest.search(/[>|]/);
19
+ const uid = endIdx === -1 ? rest : rest.slice(0, endIdx);
20
+ if (uid.length >= 9) ids.push(uid);
21
+ searchOffset = pos + 1;
22
+ }
23
+ for (const uid of ids) {
24
+ if (!cache.has(uid)) cache.set(uid, await userName(token, uid));
25
+ const display = cache.get(uid) ?? uid;
26
+ result = result.replaceAll(`<@${uid}>`, `@${display}`);
27
+ // Handle <@UID|label> form — drop label.
28
+ result = result.replace(new RegExp(`<@${uid}\\|[^>]*>`, "g"), `@${display}`);
29
+ }
30
+ return result;
31
+ }
32
+
33
+ export function resolveDateMarkup(text: string): string {
34
+ // <!date^EPOCH^{format}|fallback> → formatted date (or fallback)
35
+ return text.replace(/<!date\^(\d+)\^[^|>]*(?:\|([^>]*))?>/g, (_m, epochStr, fallback) => {
36
+ const epoch = Number(epochStr);
37
+ if (!Number.isFinite(epoch)) return fallback ?? "date";
38
+ const d = new Date(epoch * 1000);
39
+ if (Number.isNaN(d.getTime())) return fallback ?? "date";
40
+ const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
41
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
42
+ const dayName = days[d.getUTCDay()] ?? "";
43
+ const mon = months[d.getUTCMonth()] ?? "";
44
+ return `${dayName}, ${mon} ${String(d.getUTCDate()).padStart(2, "0")}, ${d.getUTCFullYear()}`;
45
+ });
46
+ }
47
+
48
+ export function dayLabel(epochSec: number, now: Date = new Date()): string {
49
+ const d = new Date(epochSec * 1000);
50
+ const today = dateKey(now);
51
+ const yesterday = dateKey(new Date(now.getTime() - 86400_000));
52
+ const key = dateKey(d);
53
+ if (key === today) return "Today";
54
+ if (key === yesterday) return "Yesterday";
55
+ const weekday = d.toLocaleDateString("en-US", { weekday: "long" });
56
+ const month = d.toLocaleDateString("en-US", { month: "short" });
57
+ return `${weekday}, ${month} ${String(d.getDate()).padStart(2, "0")}`;
58
+ }
59
+
60
+ function dateKey(d: Date): string {
61
+ return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
62
+ }
63
+
64
+ export function formatHm(epochSec: number): string {
65
+ const d = new Date(epochSec * 1000);
66
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
67
+ }
68
+
69
+ export function formatYmdHm(epochSec: number): string {
70
+ const d = new Date(epochSec * 1000);
71
+ const y = d.getFullYear();
72
+ const mo = String(d.getMonth() + 1).padStart(2, "0");
73
+ const da = String(d.getDate()).padStart(2, "0");
74
+ const h = String(d.getHours()).padStart(2, "0");
75
+ const mi = String(d.getMinutes()).padStart(2, "0");
76
+ return `${y}-${mo}-${da} ${h}:${mi}`;
77
+ }
package/ts/profiles.ts ADDED
@@ -0,0 +1,190 @@
1
+ // Multi-workspace profile management.
2
+ // Profiles are stored in ~/.config/slack-cli/profiles.json.
3
+ //
4
+ // Workspace selection uses lockfiles:
5
+ // Local (cwd): .slack-cli/workspace — set with: slack workspace use <name>
6
+ // Global (home): ~/.slack-cli/workspace — set with: slack workspace use -g <name>
7
+ //
8
+ // Resolution order:
9
+ // 1. --workspace=<name> flag or SLACK_WORKSPACE env var
10
+ // 2. Local lockfile (.slack-cli/workspace in cwd)
11
+ // 3. Global lockfile (~/.slack-cli/workspace)
12
+ // 4. No profiles → fall back to SLACK_MCP_XOXP_TOKEN
13
+ // 5. Otherwise → throw (no silent defaults)
14
+
15
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { join, dirname } from "node:path";
18
+
19
+ export type Profile = {
20
+ token: string;
21
+ team: string;
22
+ teamId: string;
23
+ url: string;
24
+ user: string;
25
+ cookie?: string; // xoxd session cookie for internal APIs (drafts, etc.)
26
+ };
27
+
28
+ type ProfileStore = {
29
+ profiles: Record<string, Profile>;
30
+ };
31
+
32
+ function profilesPath(): string {
33
+ return join(homedir(), ".config", "slack-cli", "profiles.json");
34
+ }
35
+
36
+ function localLockfilePath(): string {
37
+ return join(process.cwd(), ".slack-cli", "workspace");
38
+ }
39
+
40
+ function globalLockfilePath(): string {
41
+ return join(homedir(), ".slack-cli", "workspace");
42
+ }
43
+
44
+ function load(): ProfileStore {
45
+ const path = profilesPath();
46
+ if (!existsSync(path)) return { profiles: {} };
47
+ return JSON.parse(readFileSync(path, "utf8")) as ProfileStore;
48
+ }
49
+
50
+ function save(store: ProfileStore): void {
51
+ const path = profilesPath();
52
+ mkdirSync(dirname(path), { recursive: true });
53
+ writeFileSync(path, JSON.stringify(store, null, 2) + "\n");
54
+ }
55
+
56
+ function readLockfile(path: string): string | undefined {
57
+ if (!existsSync(path)) return undefined;
58
+ return readFileSync(path, "utf8").trim() || undefined;
59
+ }
60
+
61
+ function writeLockfile(path: string, name: string): void {
62
+ const dir = dirname(path);
63
+ mkdirSync(dir, { recursive: true });
64
+ // Protect the directory from accidental git commits.
65
+ const gi = join(dir, ".gitignore");
66
+ if (!existsSync(gi)) writeFileSync(gi, "*\n");
67
+ writeFileSync(path, name + "\n");
68
+ }
69
+
70
+ export function listProfiles(): { name: string; profile: Profile; current: boolean }[] {
71
+ const store = load();
72
+ const local = readLockfile(localLockfilePath());
73
+ const global_ = readLockfile(globalLockfilePath());
74
+ const current = local ?? global_;
75
+ return Object.entries(store.profiles).map(([name, profile]) => ({
76
+ name,
77
+ profile,
78
+ current: name === current,
79
+ }));
80
+ }
81
+
82
+ export function addProfile(name: string, profile: Profile): void {
83
+ const store = load();
84
+ store.profiles[name] = profile;
85
+ save(store);
86
+ }
87
+
88
+ export function setCookie(name: string, cookie: string): void {
89
+ const store = load();
90
+ if (!(name in store.profiles)) throw new Error(`Profile not found: ${name}`);
91
+ store.profiles[name]!.cookie = cookie;
92
+ save(store);
93
+ }
94
+
95
+ export function removeProfile(name: string): void {
96
+ const store = load();
97
+ if (!(name in store.profiles)) throw new Error(`Profile not found: ${name}`);
98
+ delete store.profiles[name];
99
+ save(store);
100
+ }
101
+
102
+ export function useProfile(name: string, global = false): void {
103
+ const store = load();
104
+ if (!(name in store.profiles)) throw new Error(`Profile not found: ${name}`);
105
+ if (global) {
106
+ writeLockfile(globalLockfilePath(), name);
107
+ } else {
108
+ writeLockfile(localLockfilePath(), name);
109
+ }
110
+ }
111
+
112
+ export function resolveToken(workspaceFlag?: string): string {
113
+ const store = load();
114
+ const profiles = store.profiles;
115
+ const names = Object.keys(profiles);
116
+ const envToken = process.env.SLACK_MCP_XOXP_TOKEN;
117
+
118
+ // Conflict: env token and profiles are mutually exclusive
119
+ if (envToken && names.length > 0) {
120
+ throw new Error(
121
+ "Both SLACK_MCP_XOXP_TOKEN and workspace profiles are configured — keep only one.\n" +
122
+ " • Unset SLACK_MCP_XOXP_TOKEN to use profiles\n" +
123
+ " • Or remove all profiles: slack workspace remove <name>",
124
+ );
125
+ }
126
+
127
+ // Env token only (no profiles) — highest priority when unambiguous
128
+ if (envToken) return envToken;
129
+
130
+ // Explicit per-command selection
131
+ const selected = workspaceFlag ?? process.env.SLACK_WORKSPACE;
132
+ if (selected) {
133
+ const profile = profiles[selected];
134
+ if (!profile) {
135
+ throw new Error(
136
+ `Workspace "${selected}" not found. Available: ${names.join(", ") || "(none)"}`,
137
+ );
138
+ }
139
+ return profile.token;
140
+ }
141
+
142
+ // Local lockfile (cwd)
143
+ const localName = readLockfile(localLockfilePath());
144
+ if (localName) {
145
+ const profile = profiles[localName];
146
+ if (!profile) throw new Error(`Workspace "${localName}" (from .slack-cli/workspace) not found in profiles.`);
147
+ return profile.token;
148
+ }
149
+
150
+ // Global lockfile (~/.slack-cli/workspace)
151
+ const globalName = readLockfile(globalLockfilePath());
152
+ if (globalName) {
153
+ const profile = profiles[globalName];
154
+ if (!profile) throw new Error(`Workspace "${globalName}" (from ~/.slack-cli/workspace) not found in profiles.`);
155
+ return profile.token;
156
+ }
157
+
158
+ // No profiles and no env token
159
+ if (names.length === 0) {
160
+ throw new Error("No profiles configured. Run: slack workspace add <name> <token> — or set SLACK_MCP_XOXP_TOKEN");
161
+ }
162
+
163
+ // Profiles exist but none selected — never silently pick one
164
+ throw new Error(
165
+ `Workspace not selected (${names.join(", ")} available).\n` +
166
+ ` Select locally: slack workspace use <name> (writes .slack-cli/workspace)\n` +
167
+ ` Select globally: slack workspace use -g <name> (writes ~/.slack-cli/workspace)`,
168
+ );
169
+ }
170
+
171
+ /** Resolve the xoxd session cookie for the active workspace (best-effort). */
172
+ export function resolveCookie(workspaceFlag?: string): string | undefined {
173
+ const store = load();
174
+ const profiles = store.profiles;
175
+ const envToken = process.env.SLACK_MCP_XOXP_TOKEN;
176
+
177
+ // Env-only mode has no stored cookie
178
+ if (envToken) return process.env.SLACK_MCP_XOXD_COOKIE;
179
+
180
+ const selected = workspaceFlag ?? process.env.SLACK_WORKSPACE;
181
+ if (selected) return profiles[selected]?.cookie;
182
+
183
+ const localName = readLockfile(localLockfilePath());
184
+ if (localName) return profiles[localName]?.cookie;
185
+
186
+ const globalName = readLockfile(globalLockfilePath());
187
+ if (globalName) return profiles[globalName]?.cookie;
188
+
189
+ return undefined;
190
+ }
@@ -0,0 +1,243 @@
1
+ // Extract xoxc- tokens from the Slack desktop app's LevelDB (macOS/Linux/Windows).
2
+ // Reads raw .ldb/.log files with regex — works even while Slack is running (no exclusive lock).
3
+ // Also extracts the xoxd session cookie from the Slack Cookies SQLite database (macOS only).
4
+
5
+ import { readdirSync, readFileSync, existsSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { pbkdf2Sync, createDecipheriv } from "node:crypto";
9
+ import { execSync } from "node:child_process";
10
+
11
+ export type SlackAppSession = {
12
+ token: string; // xoxc-...
13
+ cookie?: string; // xoxd-... (macOS only, from Cookies SQLite)
14
+ teamId: string;
15
+ teamName?: string;
16
+ url?: string;
17
+ };
18
+
19
+ function leveldbPath(): string {
20
+ const home = homedir();
21
+ if (process.platform === "darwin") {
22
+ return join(home, "Library", "Application Support", "Slack", "Local Storage", "leveldb");
23
+ }
24
+ if (process.platform === "linux") {
25
+ return join(home, ".config", "Slack", "Local Storage", "leveldb");
26
+ }
27
+ if (process.platform === "win32") {
28
+ const appData = process.env.APPDATA ?? join(home, "AppData", "Roaming");
29
+ return join(appData, "Slack", "Local Storage", "leveldb");
30
+ }
31
+ throw new Error(`Unsupported platform: ${process.platform}`);
32
+ }
33
+
34
+ function cookiesDbPath(): string {
35
+ const home = homedir();
36
+ if (process.platform === "darwin") {
37
+ return join(home, "Library", "Application Support", "Slack", "Cookies");
38
+ }
39
+ // Linux/Windows: cookie extraction not implemented
40
+ return "";
41
+ }
42
+
43
+ // Encrypted cookie format (macOS Electron/Chromium v10):
44
+ // bytes [0..3) = "v10"
45
+ // bytes [3..19) = 16-byte prefix (version/key-id, shared across all cookies)
46
+ // bytes [19..35) = 16-byte IV (shared across all cookies in this profile)
47
+ // bytes [35..) = AES-128-CBC ciphertext
48
+ // Key = PBKDF2(keychain_password, "saltysalt", 1003, 16, SHA1)
49
+ function decryptChromeCookie(encryptedValue: Buffer, key: Buffer): string {
50
+ if (encryptedValue.length < 35 || encryptedValue.slice(0, 3).toString() !== "v10") {
51
+ throw new Error("Not a v10 encrypted cookie");
52
+ }
53
+ const iv = encryptedValue.slice(19, 35);
54
+ const ciphertext = encryptedValue.slice(35);
55
+ const decipher = createDecipheriv("aes-128-cbc", key, iv);
56
+ decipher.setAutoPadding(true);
57
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
58
+ }
59
+
60
+ /** Extract the xoxd session cookie from Slack's Cookies SQLite (macOS only).
61
+ * Returns undefined if unavailable or decryption fails. */
62
+ export function extractXoxd(): string | undefined {
63
+ if (process.platform !== "darwin") return undefined;
64
+
65
+ const dbPath = cookiesDbPath();
66
+ if (!existsSync(dbPath)) return undefined;
67
+
68
+ let keychainPw: string;
69
+ try {
70
+ keychainPw = execSync(
71
+ `security find-generic-password -w -s "Slack Safe Storage" -a "Slack Key"`,
72
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
73
+ ).trimEnd();
74
+ } catch {
75
+ return undefined;
76
+ }
77
+ const aesKey = pbkdf2Sync(keychainPw, "saltysalt", 1003, 16, "sha1");
78
+
79
+ try {
80
+ // Use dynamic import so bun:sqlite doesn't break on non-bun runtimes
81
+ const { default: Database } = require("bun:sqlite") as typeof import("bun:sqlite");
82
+ const db = new Database(dbPath, { readonly: true });
83
+ const row = db
84
+ .prepare("SELECT encrypted_value FROM cookies WHERE name='d' AND host_key LIKE '%slack%'")
85
+ .get() as { encrypted_value: Uint8Array } | null;
86
+ db.close();
87
+ if (!row) return undefined;
88
+ return decryptChromeCookie(Buffer.from(row.encrypted_value), aesKey);
89
+ } catch {
90
+ return undefined;
91
+ }
92
+ }
93
+
94
+ // Scan raw LevelDB files for xoxc- tokens without opening the DB exclusively.
95
+ // Works while Slack is running.
96
+ //
97
+ // Strategy:
98
+ // 1. .log files (write-ahead log): values are stored as readable JSON strings.
99
+ // Scan for "token":"xoxc-..." to get complete, clean tokens + workspace URL.
100
+ // 2. .ldb files (sorted tables): values are length-prefixed with binary framing
101
+ // bytes that can split the token mid-segment. Use gap-bridging as fallback.
102
+ export async function extractSessions(): Promise<SlackAppSession[]> {
103
+ const dbPath = leveldbPath();
104
+ if (!existsSync(dbPath)) {
105
+ throw new Error(
106
+ `Slack desktop app LevelDB not found at:\n ${dbPath}\nIs Slack installed and opened at least once?`,
107
+ );
108
+ }
109
+
110
+ const files = readdirSync(dbPath).filter((f) => f.endsWith(".ldb") || f.endsWith(".log"));
111
+ if (files.length === 0) throw new Error(`No LevelDB data files found in ${dbPath}`);
112
+
113
+ const sessions = new Map<string, SlackAppSession>();
114
+
115
+ for (const file of files) {
116
+ let content: string;
117
+ try {
118
+ content = readFileSync(join(dbPath, file), "latin1");
119
+ } catch {
120
+ continue;
121
+ }
122
+
123
+ if (file.endsWith(".log")) {
124
+ // .log files store JSON values verbatim — extract complete token + URL + name in one pass.
125
+ for (const m of content.matchAll(/"token":"(xoxc-[^"]+)"/g)) {
126
+ const token = m[1]!;
127
+ const teamId = token.split("-")[1] ?? "";
128
+ if (!teamId || token.length < 40) continue;
129
+
130
+ // Look for workspace metadata near this token (within ±2KB)
131
+ const start = Math.max(0, m.index! - 2000);
132
+ const end = Math.min(content.length, m.index! + 2000);
133
+ const ctx = content.slice(start, end);
134
+ const urlMatch = ctx.match(/"url":"(https:\/\/[a-z0-9-]+\.slack\.com\/)"/);
135
+ const nameMatch = ctx.match(/"(?:team_name|name)":"([^"]{2,60})"/);
136
+
137
+ const existing = sessions.get(teamId);
138
+ if (!existing || token.length > existing.token.length) {
139
+ const entry: SlackAppSession = { token, teamId };
140
+ const url = urlMatch?.[1] ?? existing?.url;
141
+ const teamName = nameMatch?.[1] ?? existing?.teamName;
142
+ if (url) entry.url = url;
143
+ if (teamName) entry.teamName = teamName;
144
+ sessions.set(teamId, entry);
145
+ }
146
+ }
147
+ } else {
148
+ // .ldb files: binary framing bytes split the token mid-segment.
149
+ // Match the first 3 numeric segments, then bridge over binary bytes to find the rest.
150
+ for (const m of content.matchAll(/xoxc-(\d+)-(\d+)-(\d+)/g)) {
151
+ const teamId = m[1]!;
152
+ // Skip if already found in a .log file (prefer clean .log data)
153
+ if (sessions.has(teamId)) continue;
154
+
155
+ let i = m.index! + m[0].length;
156
+ const limit = Math.min(i + 10, content.length);
157
+ while (i < limit && (content.charCodeAt(i) < 0x30 || content.charCodeAt(i) > 0x39)) i++;
158
+
159
+ let seg3tail = "";
160
+ while (i < content.length && content.charCodeAt(i) >= 0x30 && content.charCodeAt(i) <= 0x39) {
161
+ seg3tail += content[i++];
162
+ }
163
+ if (content[i] !== "-") continue;
164
+ i++;
165
+ let hex = "";
166
+ while (i < content.length) {
167
+ const code = content.charCodeAt(i);
168
+ if ((code >= 0x30 && code <= 0x39) || (code >= 0x61 && code <= 0x66)) {
169
+ hex += content[i++];
170
+ } else break;
171
+ }
172
+ if (hex.length < 20) continue;
173
+
174
+ const token = `${m[0]}${seg3tail}-${hex}`;
175
+ sessions.set(teamId, { token, teamId });
176
+ }
177
+ }
178
+ }
179
+
180
+ // Second pass: for sessions still missing a name, search all file content
181
+ // (printable-transformed) for team_name/name near the numeric team ID, then
182
+ // fall back to a title-cased URL slug.
183
+ const allFiles = readdirSync(dbPath).filter((f) => f.endsWith(".ldb") || f.endsWith(".log"));
184
+ const allContent = allFiles
185
+ .map((f) => {
186
+ try {
187
+ // Replace non-printable bytes with spaces to expose readable text in binary ldb frames.
188
+ return readFileSync(join(dbPath, f), "latin1").replace(/[^\x20-\x7e]/g, " ");
189
+ } catch {
190
+ return "";
191
+ }
192
+ })
193
+ .join(" ");
194
+
195
+ for (const session of sessions.values()) {
196
+ if (session.teamName) continue;
197
+
198
+ // Strategy 1: search for "team_name":"..." or "name":"..." near the numeric team ID.
199
+ const idIdx = allContent.indexOf(session.teamId);
200
+ if (idIdx !== -1) {
201
+ const start = Math.max(0, idIdx - 500);
202
+ const end = Math.min(allContent.length, idIdx + 500);
203
+ const ctx = allContent.slice(start, end);
204
+ const nameMatch = ctx.match(/"(?:team_name|name)":"([^"]{2,60})"/);
205
+ if (nameMatch?.[1]) {
206
+ session.teamName = nameMatch[1];
207
+ continue;
208
+ }
209
+ }
210
+
211
+ // Strategy 2: search for the workspace URL slug and look for "name":"..." near it.
212
+ if (!session.teamName && session.url) {
213
+ const slug = session.url.match(/https:\/\/([a-z0-9-]+)\.slack\.com\//)?.[1];
214
+ if (slug) {
215
+ const slugIdx = allContent.indexOf(slug);
216
+ if (slugIdx !== -1) {
217
+ const start = Math.max(0, slugIdx - 500);
218
+ const end = Math.min(allContent.length, slugIdx + 500);
219
+ const ctx = allContent.slice(start, end);
220
+ const nameMatch = ctx.match(/"(?:team_name|name)":"([^"]{2,60})"/);
221
+ if (nameMatch?.[1]) {
222
+ session.teamName = nameMatch[1];
223
+ continue;
224
+ }
225
+ }
226
+
227
+ // Strategy 3: title-case the slug as a last resort.
228
+ session.teamName = slug
229
+ .split("-")
230
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
231
+ .join(" ");
232
+ }
233
+ }
234
+ }
235
+
236
+ // Attach xoxd cookie to all sessions (shared — one Slack desktop app, one cookie jar)
237
+ const xoxd = extractXoxd();
238
+ const result = [...sessions.values()];
239
+ if (xoxd) {
240
+ for (const s of result) s.cookie = xoxd;
241
+ }
242
+ return result;
243
+ }