@eliya-oss/agent-slack 0.1.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.
@@ -0,0 +1,295 @@
1
+ # Agent Slack CLI API
2
+
3
+ Technical contract derived from `.agents/prd/001-agent-readable-slack-interface.md`. Product behavior changes belong in PRD first.
4
+
5
+ Working binary name: `slk`.
6
+
7
+ Goal: OAuth into Slack, expose everything Slack Web API permissions allow, and give agents stable commands for reading channels, threads, files, users, and search context.
8
+
9
+ Non-goal for v0: agent-side summarization. The CLI returns clean context; agents summarize it.
10
+
11
+ ## Design Rules
12
+
13
+ - Noun-verb commands: `slk conversation history`, `slk thread get`, `slk api call`.
14
+ - JSON is always available with `--json`; non-TTY stdout defaults to JSON.
15
+ - Data goes to stdout. Progress, warnings, and errors go to stderr.
16
+ - Every command supports `--help --json`; every group supports `describe --json`.
17
+ - Large reads support `--cursor`, `--limit`, `--all`, and `--format ndjson`.
18
+ - Raw Slack Web API access is first-class through `slk api call`.
19
+ - Read commands are safe by default. Any future write/destructive command needs `--yes`; `api call` blocks known write methods unless `--allow-write`.
20
+
21
+ ## Global Flags
22
+
23
+ ```bash
24
+ slk [--profile NAME] [--team TEAM_ID] [--token user|bot|admin|app]
25
+ [--json] [--format json|ndjson|table] [--fields FIELD[,FIELD...]]
26
+ [--limit N] [--cursor CURSOR] [--all]
27
+ [--include users,reactions,files,permalinks,threads]
28
+ [--no-cache] [--trace]
29
+ ```
30
+
31
+ Defaults:
32
+
33
+ - `--token user` for user-visible data.
34
+ - `--format json` when stdout is not a TTY.
35
+ - `--limit` uses Slack's method default unless a command defines a smaller agent-safe default.
36
+
37
+ ## Auth
38
+
39
+ ```bash
40
+ slk auth login
41
+ slk auth login --profile work --scopes channels:read,channels:history --user-scopes search:read.public,search:read.private
42
+ slk auth login --oauth --client-id CLIENT_ID --client-secret CLIENT_SECRET --auth-url-out /tmp/slk-auth-url
43
+ slk auth status
44
+ slk auth scopes
45
+ slk auth profiles list
46
+ slk auth logout --profile work
47
+ ```
48
+
49
+ Auth behavior:
50
+
51
+ - Uses Slack OAuth v2 with a localhost callback.
52
+ - Bot scopes go in `scope=...`; user scopes go in `user_scope=...`.
53
+ - Stores profiles outside the project directory. The default store is `~/.config/slk/profiles.json`; set `SLK_TOKEN_STORE=keychain` on macOS to keep token secrets in Keychain and only profile metadata on disk.
54
+ - Supports multiple profiles and workspaces.
55
+ - `auth status --json` returns token type, team, enterprise, user/bot identity, granted scopes, and missing recommended scopes.
56
+
57
+ Scope presets:
58
+
59
+ ```bash
60
+ slk auth login --preset read-user
61
+ slk auth login --preset read-bot
62
+ slk auth login --preset assistant-search
63
+ slk auth login --preset admin-read
64
+ ```
65
+
66
+ Preset intent:
67
+
68
+ - `read-user`: read what the installing user can access.
69
+ - `read-bot`: read conversations where the bot is present.
70
+ - `assistant-search`: use Slack's Real-time Search API for agent context retrieval.
71
+ - `admin-read`: Enterprise Grid admin read surfaces when installed by an org admin.
72
+
73
+ Important Slack boundary: there is no token that means "read all Slack." Slack only returns data allowed by the granted scopes, token type, workspace/org install, channel membership, and Slack plan/admin policy.
74
+
75
+ ## Core Read Commands
76
+
77
+ ### Workspaces and identity
78
+
79
+ ```bash
80
+ slk auth test
81
+ slk team get
82
+ slk team profile get
83
+ slk enterprise get
84
+ ```
85
+
86
+ ### Users
87
+
88
+ ```bash
89
+ slk user list [--all]
90
+ slk user get USER_ID
91
+ slk user lookup --email EMAIL
92
+ slk user presence get USER_ID
93
+ slk usergroups list
94
+ slk usergroups users list USERGROUP_ID
95
+ ```
96
+
97
+ ### Conversations
98
+
99
+ ```bash
100
+ slk conversation list --types public_channel,private_channel,mpim,im [--all]
101
+ slk conversation get CHANNEL_ID
102
+ slk conversation members CHANNEL_ID [--all]
103
+ slk conversation history CHANNEL_ID [--oldest TS] [--latest TS] [--all]
104
+ slk conversation context CHANNEL_ID [--since 24h] [--include users,reactions,files,threads]
105
+ ```
106
+
107
+ `conversation context` is the agent-friendly form: normalized messages, hydrated users, thread refs, file refs, reactions, and permalinks in one deterministic payload.
108
+
109
+ ### Threads and messages
110
+
111
+ ```bash
112
+ slk thread get --channel CHANNEL_ID --ts MESSAGE_TS [--include users,reactions,files,permalinks]
113
+ slk message get --channel CHANNEL_ID --ts MESSAGE_TS
114
+ slk message permalink --channel CHANNEL_ID --ts MESSAGE_TS
115
+ ```
116
+
117
+ `message get` wraps the Slack history/replies calls needed to fetch one timestamp.
118
+
119
+ ### Search
120
+
121
+ ```bash
122
+ slk search context --query QUERY [--channel-types public_channel,private_channel,mpim,im] [--content-types messages,files,channels]
123
+ slk search messages --query QUERY
124
+ slk search files --query QUERY
125
+ ```
126
+
127
+ Prefer `search context` for agents. It maps to Slack's Real-time Search API where available. `search messages` and `search files` are legacy-compatible convenience commands.
128
+
129
+ ### Files and attached context
130
+
131
+ ```bash
132
+ slk file list [--channel CHANNEL_ID] [--user USER_ID] [--all]
133
+ slk file get FILE_ID
134
+ slk file download FILE_ID --out PATH
135
+ slk file comments list FILE_ID
136
+ ```
137
+
138
+ ### Other readable Slack surfaces
139
+
140
+ ```bash
141
+ slk reaction get --channel CHANNEL_ID --ts MESSAGE_TS
142
+ slk pin list CHANNEL_ID
143
+ slk bookmark list CHANNEL_ID
144
+ slk canvas list [--channel CHANNEL_ID]
145
+ slk canvas get CANVAS_ID
146
+ slk list list [--channel CHANNEL_ID]
147
+ slk list get LIST_ID
148
+ slk emoji list
149
+ slk reminder list
150
+ slk star list
151
+ slk dnd status [USER_ID]
152
+ ```
153
+
154
+ ## Generic Web API Escape Hatch
155
+
156
+ This is what gives full Web API coverage.
157
+
158
+ ```bash
159
+ slk api methods list [--family conversations]
160
+ slk api method describe conversations.history
161
+ slk api call conversations.history --payload '{"channel":"C123","limit":50}'
162
+ slk api call conversations.history --payload @payload.json
163
+ cat payload.json | slk api call conversations.history -
164
+ ```
165
+
166
+ Rules:
167
+
168
+ - `--payload`, `@file`, and `-` pass raw JSON matching Slack's API fields.
169
+ - Convenience flags may be added later, but raw payload always wins coverage.
170
+ - `--raw` returns Slack's exact response body.
171
+ - Without `--raw`, responses use the CLI envelope below.
172
+ - `--all --format ndjson` streams item records for cursor-paginated methods.
173
+ - Known write/admin-mutating methods require `--allow-write --yes`.
174
+
175
+ ## Output Contract
176
+
177
+ Default JSON envelope:
178
+
179
+ ```json
180
+ {
181
+ "ok": true,
182
+ "method": "conversations.history",
183
+ "team_id": "T123",
184
+ "enterprise_id": null,
185
+ "profile": "work",
186
+ "data": {},
187
+ "paging": {
188
+ "next_cursor": null,
189
+ "has_more": false
190
+ },
191
+ "warnings": []
192
+ }
193
+ ```
194
+
195
+ NDJSON streaming record:
196
+
197
+ ```json
198
+ {"ok":true,"type":"slack.message","team_id":"T123","channel_id":"C123","ts":"1710000000.000100","data":{}}
199
+ ```
200
+
201
+ Structured error on stderr:
202
+
203
+ ```json
204
+ {
205
+ "ok": false,
206
+ "error": {
207
+ "type": "rate_limited",
208
+ "title": "Slack rate limit reached",
209
+ "slack_error": "ratelimited",
210
+ "retriable": true,
211
+ "retry_after_seconds": 60,
212
+ "suggestion": "Retry after the provided delay or use --limit with a smaller page size.",
213
+ "trace_id": "slk_..."
214
+ }
215
+ }
216
+ ```
217
+
218
+ Exit codes:
219
+
220
+ - `0`: success
221
+ - `1`: runtime/API failure
222
+ - `2`: usage or validation error
223
+ - `3`: not found
224
+ - `4`: auth or permission denied
225
+ - `5`: conflict or invalid Slack state
226
+ - `6`: rate limited
227
+
228
+ Rate-limit behavior:
229
+
230
+ - Honor Slack `Retry-After` headers.
231
+ - Return exit code `6` with `retry_after_seconds` when Slack throttles.
232
+ - `conversation history`, `thread get`, and `api call conversations.replies` must support resumable pagination.
233
+ - Commercial non-Marketplace apps can have much stricter `conversations.history` and `conversations.replies` limits than internal or Marketplace apps.
234
+
235
+ ## Schema Introspection
236
+
237
+ ```bash
238
+ slk describe --json
239
+ slk conversation describe --json
240
+ slk conversation history --help --json
241
+ slk api method describe conversations.replies --json
242
+ ```
243
+
244
+ Schema output includes:
245
+
246
+ - command description and examples
247
+ - positional args and flags
248
+ - raw payload schema when known
249
+ - required Slack method/scopes
250
+ - output schema
251
+ - pagination behavior
252
+ - safety classification: `read`, `write`, `destructive`, or `admin`
253
+
254
+ ## Agent Workflows
255
+
256
+ Read a thread:
257
+
258
+ ```bash
259
+ slk thread get --channel C123 --ts 1710000000.000100 --include users,reactions,files,permalinks --json
260
+ ```
261
+
262
+ Prepare channel-summary input:
263
+
264
+ ```bash
265
+ slk conversation context C123 --since 24h --include users,reactions,files,threads --format ndjson
266
+ ```
267
+
268
+ Search before reading:
269
+
270
+ ```bash
271
+ slk search context --query "latest on project atlas" --content-types messages,files --json
272
+ ```
273
+
274
+ Call a new Slack method before the CLI has a wrapper:
275
+
276
+ ```bash
277
+ slk api call some.newMethod --payload @request.json --json
278
+ ```
279
+
280
+ ## Build Order
281
+
282
+ 1. Auth profiles, keychain storage, and `auth test/status/scopes`.
283
+ 2. Generic `api call`, raw payload input, JSON envelope, structured errors.
284
+ 3. `describe --json` and generated method catalog.
285
+ 4. Conversation, thread, user, file, and search convenience commands.
286
+ 5. Agent context commands with hydration and NDJSON streaming.
287
+ 6. Admin/read-extra surfaces and write-gated API calls.
288
+
289
+ ## References
290
+
291
+ - Agent Surface CLI design: https://agentsurface.dev/docs/cli-design
292
+ - Agent Surface schema introspection: https://agentsurface.dev/docs/cli-design/schema-introspection
293
+ - Slack OAuth: https://docs.slack.dev/authentication/installing-with-oauth
294
+ - Slack Web API methods: https://docs.slack.dev/reference/methods/
295
+ - Slack Real-time Search API: https://docs.slack.dev/apis/web-api/real-time-search-api
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # @eliya-oss/agent-slack
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Initial public release of the Slack agent CLI.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 agent-slack contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,39 @@
1
+ <div align="center">
2
+
3
+ # Agent Slack
4
+
5
+ Slack context for AI agents, exposed through the `slk` CLI.
6
+
7
+ ```bash
8
+ npm install -g @eliya-oss/agent-slack
9
+ ```
10
+
11
+ <img src="./assets/terminal.svg" alt="Agent Slack terminal screenshot" width="820">
12
+
13
+ </div>
14
+
15
+ ## Usage
16
+
17
+ ```bash
18
+ slk auth login --oauth --client-id "$SLACK_CLIENT_ID" --client-secret "$SLACK_CLIENT_SECRET" --json
19
+ slk conversation history C123 --limit 50 --json
20
+ slk thread get --channel C123 --ts 1710000000.000100 --include users,permalinks --json
21
+ slk conversation context C123 --include users,threads,permalinks --format ndjson
22
+ slk api call conversations.info --payload '{"channel":"C123"}' --json
23
+ ```
24
+
25
+ ## Skill
26
+
27
+ ```bash
28
+ npx skills add Newbie012/agent-slack --skill agent-slack
29
+ ```
30
+
31
+ ## Notes
32
+
33
+ Agent Slack respects Slack permissions: it only returns data allowed by the active token, scopes, channel membership, workspace policy, and Slack plan.
34
+
35
+ Project docs live in `.agents/`.
36
+
37
+ ## License
38
+
39
+ MIT
@@ -0,0 +1,32 @@
1
+ <svg width="980" height="560" viewBox="0 0 980 560" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
2
+ <title id="title">Agent Slack terminal screenshot</title>
3
+ <desc id="desc">A terminal showing Agent Slack commands that read Slack context as JSON and NDJSON.</desc>
4
+ <rect width="980" height="560" rx="18" fill="#111827"/>
5
+ <rect x="22" y="22" width="936" height="516" rx="14" fill="#0B1020" stroke="#263244"/>
6
+ <rect x="22" y="22" width="936" height="48" rx="14" fill="#151D2E"/>
7
+ <circle cx="52" cy="46" r="7" fill="#FF5F57"/>
8
+ <circle cx="76" cy="46" r="7" fill="#FFBD2E"/>
9
+ <circle cx="100" cy="46" r="7" fill="#28C840"/>
10
+ <text x="490" y="51" text-anchor="middle" fill="#94A3B8" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="14">agent-slack</text>
11
+
12
+ <text x="54" y="116" fill="#22C55E" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">$</text>
13
+ <text x="78" y="116" fill="#E5E7EB" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">npm install -g @eliya-oss/agent-slack</text>
14
+
15
+ <text x="54" y="162" fill="#22C55E" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">$</text>
16
+ <text x="78" y="162" fill="#E5E7EB" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">slk auth status --json</text>
17
+ <text x="78" y="196" fill="#93C5FD" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="16">{ "ok": true, "profile": "work", "data": { "teamId": "T123" } }</text>
18
+
19
+ <text x="54" y="250" fill="#22C55E" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">$</text>
20
+ <text x="78" y="250" fill="#E5E7EB" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">slk conversation context C123 --include users,threads --format ndjson</text>
21
+ <text x="78" y="284" fill="#FDE68A" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="16">{"type":"slack.message","channel_id":"C123","ts":"1710000000.000100","text":"Ship status?"}</text>
22
+ <text x="78" y="314" fill="#FDE68A" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="16">{"type":"slack.message","channel_id":"C123","thread_ts":"1710000000.000100","text":"Green."}</text>
23
+
24
+ <text x="54" y="368" fill="#22C55E" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">$</text>
25
+ <text x="78" y="368" fill="#E5E7EB" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">slk thread get --channel C123 --ts 1710000000.000100 --include users --json</text>
26
+ <text x="78" y="402" fill="#C4B5FD" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="16">{ "ok": true, "method": "conversations.replies", "data": { "messages": [ ... ] } }</text>
27
+
28
+ <text x="54" y="456" fill="#22C55E" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">$</text>
29
+ <text x="78" y="456" fill="#E5E7EB" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">slk api call conversations.info --payload '{"channel":"C123"}' --json</text>
30
+ <rect x="54" y="488" width="210" height="28" rx="6" fill="#122034"/>
31
+ <text x="70" y="508" fill="#67E8F9" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="14">stdout is parseable JSON</text>
32
+ </svg>
@@ -0,0 +1,53 @@
1
+ const methods = [
2
+ { method: "api.test", family: "api", summary: "Test Slack API reachability.", safety: "read", scopes: [], cursorPaginated: false },
3
+ { method: "auth.test", family: "auth", summary: "Test authentication.", safety: "read", scopes: [], cursorPaginated: false },
4
+ { method: "team.info", family: "team", summary: "Workspace info.", safety: "read", scopes: ["team:read"], cursorPaginated: false },
5
+ { method: "team.profile.get", family: "team", summary: "Workspace profile fields.", safety: "read", scopes: ["users.profile:read"], cursorPaginated: false },
6
+ { method: "conversations.list", family: "conversations", summary: "List conversations.", safety: "read", scopes: ["channels:read", "groups:read", "im:read", "mpim:read"], itemKey: "channels", cursorPaginated: true },
7
+ { method: "conversations.info", family: "conversations", summary: "Conversation info.", safety: "read", scopes: ["channels:read", "groups:read", "im:read", "mpim:read"], cursorPaginated: false },
8
+ { method: "conversations.members", family: "conversations", summary: "Conversation members.", safety: "read", scopes: ["channels:read", "groups:read", "im:read", "mpim:read"], itemKey: "members", cursorPaginated: true },
9
+ { method: "conversations.history", family: "conversations", summary: "Conversation history.", safety: "read", scopes: ["channels:history", "groups:history", "im:history", "mpim:history"], itemKey: "messages", cursorPaginated: true },
10
+ { method: "conversations.replies", family: "conversations", summary: "Thread replies.", safety: "read", scopes: ["channels:history", "groups:history", "im:history", "mpim:history"], itemKey: "messages", cursorPaginated: true },
11
+ { method: "chat.getPermalink", family: "chat", summary: "Message permalink.", safety: "read", scopes: [], cursorPaginated: false },
12
+ { method: "users.list", family: "users", summary: "List users.", safety: "read", scopes: ["users:read"], itemKey: "members", cursorPaginated: true },
13
+ { method: "users.info", family: "users", summary: "User info.", safety: "read", scopes: ["users:read"], cursorPaginated: false },
14
+ { method: "users.lookupByEmail", family: "users", summary: "Find user by email.", safety: "read", scopes: ["users:read.email"], cursorPaginated: false },
15
+ { method: "users.getPresence", family: "users", summary: "User presence.", safety: "read", scopes: ["users:read"], cursorPaginated: false },
16
+ { method: "usergroups.list", family: "usergroups", summary: "List user groups.", safety: "read", scopes: ["usergroups:read"], itemKey: "usergroups", cursorPaginated: false },
17
+ { method: "usergroups.users.list", family: "usergroups", summary: "List users in a user group.", safety: "read", scopes: ["usergroups:read"], itemKey: "users", cursorPaginated: false },
18
+ { method: "files.list", family: "files", summary: "List files.", safety: "read", scopes: ["files:read"], itemKey: "files", cursorPaginated: true },
19
+ { method: "files.info", family: "files", summary: "File info.", safety: "read", scopes: ["files:read"], cursorPaginated: false },
20
+ { method: "reactions.get", family: "reactions", summary: "Message reactions.", safety: "read", scopes: ["reactions:read"], cursorPaginated: false },
21
+ { method: "pins.list", family: "pins", summary: "Pinned items.", safety: "read", scopes: ["pins:read"], itemKey: "items", cursorPaginated: false },
22
+ { method: "bookmarks.list", family: "bookmarks", summary: "Channel bookmarks.", safety: "read", scopes: ["bookmarks:read"], itemKey: "bookmarks", cursorPaginated: false },
23
+ { method: "emoji.list", family: "emoji", summary: "Workspace emoji.", safety: "read", scopes: ["emoji:read"], cursorPaginated: false },
24
+ { method: "dnd.info", family: "dnd", summary: "DND status.", safety: "read", scopes: ["dnd:read"], cursorPaginated: false },
25
+ { method: "search.messages", family: "search", summary: "Legacy message search.", safety: "read", scopes: ["search:read.public", "search:read.private"], itemKey: "matches", cursorPaginated: false },
26
+ { method: "search.files", family: "search", summary: "Legacy file search.", safety: "read", scopes: ["search:read.files"], itemKey: "matches", cursorPaginated: false },
27
+ { method: "assistant.search.context", family: "assistant", summary: "Real-time Search API context.", safety: "read", scopes: ["search:read.public", "search:read.private"], itemKey: "results", cursorPaginated: false },
28
+ { method: "chat.postMessage", family: "chat", summary: "Post message.", safety: "write", scopes: ["chat:write"], cursorPaginated: false },
29
+ { method: "chat.delete", family: "chat", summary: "Delete message.", safety: "destructive", scopes: ["chat:write"], cursorPaginated: false },
30
+ { method: "files.delete", family: "files", summary: "Delete file.", safety: "destructive", scopes: ["files:write"], cursorPaginated: false }
31
+ ];
32
+ const unsafePrefixes = ["admin.", "chat.post", "chat.update", "chat.delete", "files.delete", "conversations.archive", "conversations.kick"];
33
+ export class BundledMethodCatalog {
34
+ listMethods(family) {
35
+ return family === undefined ? methods : methods.filter((method) => method.family === family);
36
+ }
37
+ describeMethod(method) {
38
+ return methods.find((item) => item.method === method) ?? null;
39
+ }
40
+ safetyFor(method) {
41
+ const found = this.describeMethod(method);
42
+ if (found !== null) {
43
+ return found.safety;
44
+ }
45
+ if (unsafePrefixes.some((prefix) => method.startsWith(prefix))) {
46
+ return method.startsWith("admin.") ? "admin" : "write";
47
+ }
48
+ return "unknown";
49
+ }
50
+ itemKeyFor(method) {
51
+ return this.describeMethod(method)?.itemKey ?? null;
52
+ }
53
+ }
@@ -0,0 +1,20 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { SlackApiFailed } from "../../domain/errors.js";
4
+ export class HttpFileDownloader {
5
+ async download(input) {
6
+ const response = await fetch(input.url, {
7
+ headers: { authorization: `Bearer ${input.token}` }
8
+ });
9
+ if (!response.ok) {
10
+ throw new SlackApiFailed(`Slack file download failed with HTTP ${response.status}`, {
11
+ status: response.status,
12
+ url: input.url
13
+ });
14
+ }
15
+ const data = Buffer.from(await response.arrayBuffer());
16
+ await mkdir(dirname(input.outPath), { recursive: true });
17
+ await writeFile(input.outPath, data, { mode: 0o600 });
18
+ return { path: input.outPath, bytes: data.byteLength };
19
+ }
20
+ }
@@ -0,0 +1,152 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { UsageError } from "../../domain/errors.js";
6
+ import { ProfileName, Scope } from "../../domain/ids.js";
7
+ const execFileAsync = promisify(execFile);
8
+ export class KeychainTokenStore {
9
+ filePath;
10
+ secrets;
11
+ constructor(filePath, secrets = new MacOSSecurityKeychainSecrets()) {
12
+ this.filePath = filePath;
13
+ this.secrets = secrets;
14
+ }
15
+ static fromEnv(env = process.env) {
16
+ const configDir = env.SLK_CONFIG_DIR ?? join(env.HOME ?? process.cwd(), ".config", "slk");
17
+ return new KeychainTokenStore(join(configDir, "profiles.keychain.json"));
18
+ }
19
+ async getProfile(name) {
20
+ const stored = (await this.readProfiles()).find((profile) => profile.name === name);
21
+ return stored === undefined ? null : this.hydrate(stored);
22
+ }
23
+ async setProfile(profile) {
24
+ const profiles = await this.readProfiles();
25
+ const previous = profiles.find((item) => item.name === profile.name);
26
+ const stored = await this.dehydrate(profile);
27
+ await this.deleteRemovedTokenAccounts(previous, stored);
28
+ await this.writeProfiles([stored, ...profiles.filter((item) => item.name !== profile.name)]);
29
+ }
30
+ async listProfiles() {
31
+ const profiles = await this.readProfiles();
32
+ return Promise.all(profiles.map((profile) => this.hydrate(profile)));
33
+ }
34
+ async deleteProfile(name) {
35
+ const profiles = await this.readProfiles();
36
+ const found = profiles.find((profile) => profile.name === name);
37
+ if (found === undefined) {
38
+ return false;
39
+ }
40
+ await Promise.all(Object.values(found.tokenAccounts).map((account) => account === undefined ? Promise.resolve() : this.secrets.delete(account)));
41
+ const next = profiles.filter((profile) => profile.name !== name);
42
+ if (next.length === 0) {
43
+ await rm(this.filePath, { force: true });
44
+ }
45
+ else {
46
+ await this.writeProfiles(next);
47
+ }
48
+ return true;
49
+ }
50
+ async dehydrate(profile) {
51
+ const tokenAccounts = {};
52
+ for (const field of tokenFields) {
53
+ const token = profile[field];
54
+ if (token !== undefined) {
55
+ const account = accountFor(profile.name, field);
56
+ await this.secrets.set(account, token);
57
+ tokenAccounts[field] = account;
58
+ }
59
+ }
60
+ return {
61
+ name: profile.name,
62
+ tokenType: profile.tokenType,
63
+ scopes: profile.scopes,
64
+ tokenAccounts,
65
+ ...(profile.teamId === undefined ? {} : { teamId: profile.teamId }),
66
+ ...(profile.enterpriseId === undefined ? {} : { enterpriseId: profile.enterpriseId }),
67
+ ...(profile.userId === undefined ? {} : { userId: profile.userId }),
68
+ ...(profile.botId === undefined ? {} : { botId: profile.botId })
69
+ };
70
+ }
71
+ async hydrate(profile) {
72
+ const tokens = Object.fromEntries(await Promise.all(tokenFields.map(async (field) => {
73
+ const account = profile.tokenAccounts[field];
74
+ return [field, account === undefined ? undefined : await this.secrets.get(account)];
75
+ })));
76
+ return {
77
+ name: ProfileName.make(profile.name),
78
+ tokenType: profile.tokenType,
79
+ scopes: profile.scopes.map(Scope.make),
80
+ ...(profile.teamId === undefined ? {} : { teamId: profile.teamId }),
81
+ ...(profile.enterpriseId === undefined ? {} : { enterpriseId: profile.enterpriseId }),
82
+ ...(profile.userId === undefined ? {} : { userId: profile.userId }),
83
+ ...(profile.botId === undefined ? {} : { botId: profile.botId }),
84
+ ...(tokens.botToken == null ? {} : { botToken: tokens.botToken }),
85
+ ...(tokens.userToken == null ? {} : { userToken: tokens.userToken }),
86
+ ...(tokens.adminToken == null ? {} : { adminToken: tokens.adminToken }),
87
+ ...(tokens.appToken == null ? {} : { appToken: tokens.appToken })
88
+ };
89
+ }
90
+ async deleteRemovedTokenAccounts(previous, next) {
91
+ if (previous === undefined) {
92
+ return;
93
+ }
94
+ const nextAccounts = new Set(Object.values(next.tokenAccounts));
95
+ await Promise.all(Object.values(previous.tokenAccounts).map((account) => {
96
+ if (account === undefined || nextAccounts.has(account)) {
97
+ return Promise.resolve();
98
+ }
99
+ return this.secrets.delete(account);
100
+ }));
101
+ }
102
+ async readProfiles() {
103
+ try {
104
+ const raw = await readFile(this.filePath, "utf8");
105
+ const parsed = JSON.parse(raw);
106
+ return parsed.profiles ?? [];
107
+ }
108
+ catch (error) {
109
+ if (error.code === "ENOENT") {
110
+ return [];
111
+ }
112
+ throw error;
113
+ }
114
+ }
115
+ async writeProfiles(profiles) {
116
+ await mkdir(dirname(this.filePath), { recursive: true, mode: 0o700 });
117
+ await writeFile(this.filePath, JSON.stringify({ profiles }, null, 2), { mode: 0o600 });
118
+ }
119
+ }
120
+ class MacOSSecurityKeychainSecrets {
121
+ service = "agent-slack";
122
+ async get(account) {
123
+ this.assertDarwin();
124
+ try {
125
+ const { stdout } = await execFileAsync("security", ["find-generic-password", "-s", this.service, "-a", account, "-w"]);
126
+ return stdout.trimEnd();
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ }
132
+ async set(account, value) {
133
+ this.assertDarwin();
134
+ await execFileAsync("security", ["add-generic-password", "-s", this.service, "-a", account, "-w", value, "-U"]);
135
+ }
136
+ async delete(account) {
137
+ this.assertDarwin();
138
+ try {
139
+ await execFileAsync("security", ["delete-generic-password", "-s", this.service, "-a", account]);
140
+ }
141
+ catch {
142
+ // Deleting a missing keychain item is idempotent for TokenStore semantics.
143
+ }
144
+ }
145
+ assertDarwin() {
146
+ if (process.platform !== "darwin") {
147
+ throw new UsageError("Keychain token storage is only supported on macOS in this adapter");
148
+ }
149
+ }
150
+ }
151
+ const tokenFields = ["botToken", "userToken", "adminToken", "appToken"];
152
+ const accountFor = (profileName, field) => `profile:${profileName}:${field}`;
@@ -0,0 +1,13 @@
1
+ import { BundledMethodCatalog } from "./catalog/BundledMethodCatalog.js";
2
+ import { HttpFileDownloader } from "./http-file-downloader/HttpFileDownloader.js";
3
+ import { KeychainTokenStore } from "./keychain/KeychainTokenStore.js";
4
+ import { NodeLocalhostOAuthFlow } from "./localhost-oauth/NodeLocalhostOAuthFlow.js";
5
+ import { FileTokenStore } from "./profile-file/FileTokenStore.js";
6
+ import { SlackSdkWebApi } from "./slack-sdk/SlackSdkWebApi.js";
7
+ export const createLiveServices = (env = process.env) => ({
8
+ tokenStore: env.SLK_TOKEN_STORE === "keychain" ? KeychainTokenStore.fromEnv(env) : FileTokenStore.fromEnv(env),
9
+ oauthFlow: NodeLocalhostOAuthFlow.fromEnv(env),
10
+ fileDownloader: new HttpFileDownloader(),
11
+ slackWebApi: new SlackSdkWebApi(env.SLK_SLACK_API_BASE_URL === undefined ? {} : { slackApiUrl: env.SLK_SLACK_API_BASE_URL }),
12
+ methodCatalog: new BundledMethodCatalog()
13
+ });