@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.
- package/.agents/cli-api.md +295 -0
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +39 -0
- package/assets/terminal.svg +32 -0
- package/dist/adapters/catalog/BundledMethodCatalog.js +53 -0
- package/dist/adapters/http-file-downloader/HttpFileDownloader.js +20 -0
- package/dist/adapters/keychain/KeychainTokenStore.js +152 -0
- package/dist/adapters/live.js +13 -0
- package/dist/adapters/localhost-oauth/NodeLocalhostOAuthFlow.js +160 -0
- package/dist/adapters/profile-file/FileTokenStore.js +67 -0
- package/dist/adapters/slack-sdk/SlackSdkWebApi.js +48 -0
- package/dist/application/auth.js +35 -0
- package/dist/application/commands.js +579 -0
- package/dist/application/execute.js +22 -0
- package/dist/application/payload.js +40 -0
- package/dist/application/services.js +1 -0
- package/dist/application/slack-call.js +62 -0
- package/dist/cli/args.js +70 -0
- package/dist/cli/metadata.js +330 -0
- package/dist/cli/types.js +1 -0
- package/dist/domain/errors.js +68 -0
- package/dist/domain/ids.js +31 -0
- package/dist/domain/slack.js +1 -0
- package/dist/main.js +16 -0
- package/dist/output/envelope.js +60 -0
- package/dist/output/projection.js +55 -0
- package/dist/ports/FileDownloader.js +1 -0
- package/dist/ports/MethodCatalog.js +1 -0
- package/dist/ports/OAuthFlow.js +1 -0
- package/dist/ports/SlackWebApi.js +1 -0
- package/dist/ports/TokenStore.js +1 -0
- package/package.json +63 -0
- package/skills/agent-slack/SKILL.md +35 -0
- package/skills/agent-slack/agents/openai.yaml +4 -0
|
@@ -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
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
|
+
});
|