@eliya-oss/agent-slack 0.1.2 → 0.1.4
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 +9 -2
- package/CHANGELOG.md +16 -0
- package/README.md +27 -2
- package/assets/terminal.webp +0 -0
- package/dist/adapters/localhost-oauth/NodeLocalhostOAuthFlow.js +71 -18
- package/dist/application/commands.js +16 -8
- package/dist/application/execute.js +16 -3
- package/dist/cli/args.js +2 -1
- package/dist/cli/metadata.js +48 -8
- package/dist/output/envelope.js +5 -3
- package/dist/output/human.js +15 -0
- package/package.json +1 -1
- package/skills/agent-slack/SKILL.md +31 -1
- package/assets/terminal.svg +0 -32
package/.agents/cli-api.md
CHANGED
|
@@ -38,8 +38,10 @@ Defaults:
|
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
40
|
agent-slack auth login
|
|
41
|
+
agent-slack auth login --token xoxb-... --scopes channels:read,channels:history,users:read
|
|
41
42
|
agent-slack auth login --profile work --scopes channels:read,channels:history --user-scopes search:read.public,search:read.private
|
|
42
43
|
agent-slack auth login --oauth --client-id CLIENT_ID --client-secret CLIENT_SECRET --auth-url-out /tmp/agent-slack-auth-url
|
|
44
|
+
agent-slack auth login --oauth --client-id CLIENT_ID --client-secret CLIENT_SECRET --no-open
|
|
43
45
|
agent-slack auth status
|
|
44
46
|
agent-slack auth scopes
|
|
45
47
|
agent-slack auth profiles list
|
|
@@ -48,8 +50,13 @@ agent-slack auth logout --profile work
|
|
|
48
50
|
|
|
49
51
|
Auth behavior:
|
|
50
52
|
|
|
51
|
-
-
|
|
52
|
-
-
|
|
53
|
+
- Primary setup: `agent-slack auth login` uses the bundled Agent Slack public Client ID, opens Slack in the browser with PKCE, and stores a local user-token profile.
|
|
54
|
+
- Users can also provide an existing Bot User OAuth Token with `--token`.
|
|
55
|
+
- A normal user should not create a Slack app or provide client credentials.
|
|
56
|
+
- Developer fallback: OAuth mode can use BYO Slack app credentials. The **Client ID** and **Client Secret** come from the Slack app's **Basic Information -> App Credentials** section.
|
|
57
|
+
- OAuth opens the Slack OAuth URL in the default browser by default. Use `--no-open` to print the URL only, or `--auth-url-out PATH` for headless agents and tests.
|
|
58
|
+
- PKCE uses `user_scope=...`; BYO OAuth bot scopes go in `scope=...`.
|
|
59
|
+
- Default local callback: `http://localhost:45454/oauth/slack/callback`.
|
|
53
60
|
- Stores profiles outside the project directory. The default store is `~/.config/agent-slack/profiles.json`; set `AGENT_SLACK_TOKEN_STORE=keychain` on macOS to keep token secrets in Keychain and only profile metadata on disk.
|
|
54
61
|
- Supports multiple profiles and workspaces.
|
|
55
62
|
- `auth status --json` returns token type, team, enterprise, user/bot identity, granted scopes, and missing recommended scopes.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @eliya-oss/agent-slack
|
|
2
2
|
|
|
3
|
+
## 0.1.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Add Slack PKCE browser login.
|
|
8
|
+
|
|
9
|
+
No-flag `auth login` now uses the bundled Agent Slack public Client ID with Slack OAuth PKCE for browser-based user-token login without a client secret. Token setup and BYO OAuth remain available for bot-token, development, and self-hosted workflows.
|
|
10
|
+
|
|
11
|
+
## 0.1.3
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- Open Slack OAuth login in the default browser by default.
|
|
16
|
+
|
|
17
|
+
Headless flows can still use `--auth-url-out PATH` or `--no-open`, and the CLI now reads its displayed version from package metadata so releases stay in sync.
|
|
18
|
+
|
|
3
19
|
## 0.1.2
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -9,20 +9,45 @@ Short alias: `aslk`.
|
|
|
9
9
|
npm install -g @eliya-oss/agent-slack
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
<img src="./assets/terminal.
|
|
12
|
+
<img src="./assets/terminal.webp" alt="Agent Slack terminal screenshot" width="820">
|
|
13
13
|
|
|
14
14
|
</div>
|
|
15
15
|
|
|
16
16
|
## Usage
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
|
-
agent-slack auth login --
|
|
19
|
+
agent-slack auth login --json
|
|
20
20
|
agent-slack conversation history C123 --limit 50 --json
|
|
21
21
|
agent-slack thread get --channel C123 --ts 1710000000.000100 --include users,permalinks --json
|
|
22
22
|
agent-slack conversation context C123 --include users,threads,permalinks --format ndjson
|
|
23
23
|
agent-slack api call conversations.info --payload '{"channel":"C123"}' --json
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
+
## Auth
|
|
27
|
+
|
|
28
|
+
Browser login:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
agent-slack auth login
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Agent Slack opens Slack in the browser with PKCE and stores a local user-token profile. Users do not create Slack apps or handle Slack client secrets.
|
|
35
|
+
|
|
36
|
+
Token setup:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
agent-slack auth login --token "$SLACK_BOT_TOKEN" --scopes channels:read,channels:history,users:read
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Developer/self-hosted fallback:
|
|
43
|
+
|
|
44
|
+
1. Create a Slack app at <https://api.slack.com/apps>.
|
|
45
|
+
2. Add the needed scopes under **OAuth & Permissions**.
|
|
46
|
+
3. Install it to your workspace and copy the **Bot User OAuth Token**.
|
|
47
|
+
4. Run `agent-slack auth login --token "$SLACK_BOT_TOKEN" --scopes ...`.
|
|
48
|
+
|
|
49
|
+
OAuth with `--client-id` and `--client-secret` is only for bring-your-own-app development and self-hosted setups.
|
|
50
|
+
|
|
26
51
|
## Skill
|
|
27
52
|
|
|
28
53
|
```bash
|
|
Binary file
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
2
3
|
import { writeFile } from "node:fs/promises";
|
|
3
4
|
import { createServer } from "node:http";
|
|
4
5
|
import { PermissionDenied, SlackApiFailed, UsageError } from "../../domain/errors.js";
|
|
@@ -12,22 +13,26 @@ export class NodeLocalhostOAuthFlow {
|
|
|
12
13
|
const apiBaseUrl = env.AGENT_SLACK_API_BASE_URL ?? env.SLK_SLACK_API_BASE_URL;
|
|
13
14
|
const emulatorBaseUrl = apiBaseUrl?.replace(/\/api\/?$/, "");
|
|
14
15
|
const redirectHost = env.AGENT_SLACK_OAUTH_REDIRECT_HOST ?? env.SLK_OAUTH_REDIRECT_HOST;
|
|
16
|
+
const redirectPort = env.AGENT_SLACK_OAUTH_REDIRECT_PORT ?? env.SLK_OAUTH_REDIRECT_PORT;
|
|
15
17
|
const redirectPath = env.AGENT_SLACK_OAUTH_REDIRECT_PATH ?? env.SLK_OAUTH_REDIRECT_PATH;
|
|
16
18
|
const timeoutMs = env.AGENT_SLACK_OAUTH_TIMEOUT_MS ?? env.SLK_OAUTH_TIMEOUT_MS;
|
|
17
19
|
return new NodeLocalhostOAuthFlow({
|
|
18
20
|
authorizeUrl: env.AGENT_SLACK_OAUTH_AUTHORIZE_URL ?? env.SLK_SLACK_OAUTH_AUTHORIZE_URL ?? (emulatorBaseUrl === undefined ? "https://slack.com/oauth/v2/authorize" : `${emulatorBaseUrl}/oauth/v2/authorize`),
|
|
19
21
|
tokenUrl: env.AGENT_SLACK_OAUTH_ACCESS_URL ?? env.SLK_SLACK_OAUTH_ACCESS_URL ?? (apiBaseUrl === undefined ? "https://slack.com/api/oauth.v2.access" : `${apiBaseUrl.replace(/\/?$/, "/")}oauth.v2.access`),
|
|
20
22
|
...(redirectHost === undefined ? {} : { defaultRedirectHost: redirectHost }),
|
|
23
|
+
...(redirectPort === undefined ? {} : { defaultRedirectPort: Number(redirectPort) }),
|
|
21
24
|
...(redirectPath === undefined ? {} : { defaultRedirectPath: redirectPath }),
|
|
22
25
|
...(timeoutMs === undefined ? {} : { defaultTimeoutMs: Number(timeoutMs) })
|
|
23
26
|
});
|
|
24
27
|
}
|
|
25
28
|
async login(input) {
|
|
26
29
|
const state = randomBytes(24).toString("base64url");
|
|
30
|
+
const codeVerifier = input.pkce === true ? randomBytes(32).toString("base64url") : undefined;
|
|
27
31
|
const timeoutMs = input.timeoutMs ?? this.options.defaultTimeoutMs ?? 120_000;
|
|
28
32
|
const server = createServer();
|
|
29
33
|
const started = await listen(server, input.redirectUri, {
|
|
30
|
-
host: this.options.defaultRedirectHost ??
|
|
34
|
+
host: this.options.defaultRedirectHost ?? defaultRedirectHost,
|
|
35
|
+
port: this.options.defaultRedirectPort ?? defaultRedirectPort,
|
|
31
36
|
path: this.options.defaultRedirectPath ?? "/oauth/slack/callback"
|
|
32
37
|
});
|
|
33
38
|
const authorizationUrl = buildAuthorizationUrl({
|
|
@@ -36,11 +41,18 @@ export class NodeLocalhostOAuthFlow {
|
|
|
36
41
|
redirectUri: started.redirectUri,
|
|
37
42
|
scopes: input.scopes,
|
|
38
43
|
userScopes: input.userScopes,
|
|
39
|
-
state
|
|
44
|
+
state,
|
|
45
|
+
...(codeVerifier === undefined ? {} : { codeChallenge: codeChallengeFor(codeVerifier) })
|
|
40
46
|
});
|
|
41
47
|
if (input.authUrlOut !== undefined) {
|
|
42
48
|
await writeFile(input.authUrlOut, authorizationUrl, "utf8");
|
|
43
49
|
}
|
|
50
|
+
else if (input.openBrowser !== false) {
|
|
51
|
+
const opened = await (this.options.openBrowser ?? openSystemBrowser)(authorizationUrl);
|
|
52
|
+
process.stderr.write(opened
|
|
53
|
+
? `Opening Slack OAuth in your browser.\nIf it did not open, visit:\n${authorizationUrl}\n`
|
|
54
|
+
: `Could not open a browser automatically. Visit this Slack OAuth URL:\n${authorizationUrl}\n`);
|
|
55
|
+
}
|
|
44
56
|
else {
|
|
45
57
|
process.stderr.write(`Open this Slack OAuth URL:\n${authorizationUrl}\n`);
|
|
46
58
|
}
|
|
@@ -72,9 +84,10 @@ export class NodeLocalhostOAuthFlow {
|
|
|
72
84
|
code,
|
|
73
85
|
clientId: input.clientId,
|
|
74
86
|
clientSecret: input.clientSecret,
|
|
87
|
+
codeVerifier,
|
|
75
88
|
redirectUri: started.redirectUri
|
|
76
89
|
});
|
|
77
|
-
const profile = toAuthProfile(input.profileName, access);
|
|
90
|
+
const profile = toAuthProfile(input.profileName, access, input.pkce === true ? "user" : "bot");
|
|
78
91
|
response.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end("<html><body>Slack auth complete. You can close this tab.</body></html>");
|
|
79
92
|
clearTimeout(timer);
|
|
80
93
|
closeServer(server);
|
|
@@ -94,7 +107,7 @@ const listen = (server, redirectUri, fallback) => new Promise((resolve, reject)
|
|
|
94
107
|
const parsed = redirectUri === undefined ? null : new URL(redirectUri);
|
|
95
108
|
const host = parsed?.hostname ?? fallback.host;
|
|
96
109
|
const path = parsed?.pathname ?? fallback.path;
|
|
97
|
-
const port = parsed === null || parsed.port === "" ?
|
|
110
|
+
const port = parsed === null || parsed.port === "" ? fallback.port : Number(parsed.port);
|
|
98
111
|
if (!Number.isInteger(port) || port < 0) {
|
|
99
112
|
reject(new UsageError("OAuth redirect URI must include a valid port", { redirectUri }));
|
|
100
113
|
return;
|
|
@@ -102,34 +115,48 @@ const listen = (server, redirectUri, fallback) => new Promise((resolve, reject)
|
|
|
102
115
|
server.once("error", reject);
|
|
103
116
|
server.listen(port, host, () => {
|
|
104
117
|
const address = server.address();
|
|
105
|
-
const actualPort = parsed === null || parsed.port === "" ?
|
|
118
|
+
const actualPort = parsed === null || parsed.port === "" ? port : address.port;
|
|
106
119
|
resolve({
|
|
107
120
|
redirectUri: redirectUri ?? `http://${host}:${actualPort}${path}`,
|
|
108
121
|
path
|
|
109
122
|
});
|
|
110
123
|
});
|
|
111
124
|
});
|
|
125
|
+
const defaultRedirectHost = "localhost";
|
|
126
|
+
const defaultRedirectPort = 45454;
|
|
112
127
|
const buildAuthorizationUrl = (input) => {
|
|
113
128
|
const url = new URL(input.authorizeUrl);
|
|
114
129
|
url.searchParams.set("client_id", input.clientId);
|
|
115
|
-
|
|
130
|
+
if (input.scopes.length > 0) {
|
|
131
|
+
url.searchParams.set("scope", input.scopes.join(","));
|
|
132
|
+
}
|
|
116
133
|
if (input.userScopes.length > 0) {
|
|
117
134
|
url.searchParams.set("user_scope", input.userScopes.join(","));
|
|
118
135
|
}
|
|
119
136
|
url.searchParams.set("redirect_uri", input.redirectUri);
|
|
120
137
|
url.searchParams.set("state", input.state);
|
|
138
|
+
if (input.codeChallenge !== undefined) {
|
|
139
|
+
url.searchParams.set("code_challenge", input.codeChallenge);
|
|
140
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
141
|
+
}
|
|
121
142
|
return url.toString();
|
|
122
143
|
};
|
|
123
144
|
const exchangeCode = async (input) => {
|
|
145
|
+
const requestBody = new URLSearchParams({
|
|
146
|
+
code: input.code,
|
|
147
|
+
client_id: input.clientId,
|
|
148
|
+
redirect_uri: input.redirectUri
|
|
149
|
+
});
|
|
150
|
+
if (input.clientSecret !== undefined) {
|
|
151
|
+
requestBody.set("client_secret", input.clientSecret);
|
|
152
|
+
}
|
|
153
|
+
if (input.codeVerifier !== undefined) {
|
|
154
|
+
requestBody.set("code_verifier", input.codeVerifier);
|
|
155
|
+
}
|
|
124
156
|
const response = await fetch(input.tokenUrl, {
|
|
125
157
|
method: "POST",
|
|
126
158
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
127
|
-
body:
|
|
128
|
-
code: input.code,
|
|
129
|
-
client_id: input.clientId,
|
|
130
|
-
client_secret: input.clientSecret,
|
|
131
|
-
redirect_uri: input.redirectUri
|
|
132
|
-
})
|
|
159
|
+
body: requestBody
|
|
133
160
|
});
|
|
134
161
|
const body = await response.json();
|
|
135
162
|
if (body.ok === false) {
|
|
@@ -142,22 +169,48 @@ const exchangeCode = async (input) => {
|
|
|
142
169
|
}
|
|
143
170
|
return body;
|
|
144
171
|
};
|
|
145
|
-
const toAuthProfile = (profileName, response) => {
|
|
172
|
+
const toAuthProfile = (profileName, response, preferredTokenType) => {
|
|
146
173
|
const botScopes = splitScopes(response.scope);
|
|
147
174
|
const userScopes = splitScopes(response.authed_user?.scope);
|
|
175
|
+
const rootTokenIsUser = preferredTokenType === "user" && response.access_token !== undefined && response.token_type !== "bot";
|
|
176
|
+
const userToken = response.authed_user?.access_token ?? (rootTokenIsUser ? response.access_token : undefined);
|
|
177
|
+
const useUserToken = preferredTokenType === "user" && userToken !== undefined;
|
|
178
|
+
const scopes = useUserToken && userScopes.length > 0 ? userScopes : [...botScopes, ...userScopes];
|
|
148
179
|
return {
|
|
149
180
|
name: ProfileName.make(profileName),
|
|
150
|
-
tokenType: response.access_token === undefined ? "user" : "bot",
|
|
181
|
+
tokenType: useUserToken || response.access_token === undefined ? "user" : "bot",
|
|
151
182
|
enterpriseId: response.enterprise?.id ?? null,
|
|
152
183
|
...(response.team?.id === undefined ? {} : { teamId: response.team.id }),
|
|
153
184
|
...(response.authed_user?.id === undefined ? {} : { userId: response.authed_user.id }),
|
|
154
185
|
...(response.bot_user_id === undefined ? {} : { botId: response.bot_user_id }),
|
|
155
|
-
...(response.access_token === undefined ? {} : { botToken: response.access_token }),
|
|
156
|
-
...(
|
|
157
|
-
scopes: [...new Set(
|
|
186
|
+
...(response.access_token === undefined || useUserToken ? {} : { botToken: response.access_token }),
|
|
187
|
+
...(userToken === undefined ? {} : { userToken }),
|
|
188
|
+
scopes: [...new Set(scopes)].map(Scope.make)
|
|
158
189
|
};
|
|
159
190
|
};
|
|
191
|
+
const codeChallengeFor = (codeVerifier) => createHash("sha256").update(codeVerifier).digest("base64url");
|
|
160
192
|
const splitScopes = (value) => value === undefined || value.trim() === "" ? [] : value.split(",").map((scope) => scope.trim()).filter(Boolean);
|
|
161
193
|
const closeServer = (server) => {
|
|
162
194
|
server.close();
|
|
163
195
|
};
|
|
196
|
+
const openSystemBrowser = (url) => new Promise((resolve) => {
|
|
197
|
+
const command = browserCommand(url);
|
|
198
|
+
const child = spawn(command.file, command.args, {
|
|
199
|
+
detached: true,
|
|
200
|
+
stdio: "ignore"
|
|
201
|
+
});
|
|
202
|
+
child.once("error", () => resolve(false));
|
|
203
|
+
child.once("spawn", () => {
|
|
204
|
+
child.unref();
|
|
205
|
+
resolve(true);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
const browserCommand = (url) => {
|
|
209
|
+
if (process.platform === "darwin") {
|
|
210
|
+
return { file: "open", args: [url] };
|
|
211
|
+
}
|
|
212
|
+
if (process.platform === "win32") {
|
|
213
|
+
return { file: "cmd", args: ["/c", "start", "", url] };
|
|
214
|
+
}
|
|
215
|
+
return { file: "xdg-open", args: [url] };
|
|
216
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { flagBoolean, flagString, requireFlag, splitCsv } from "../cli/args.js";
|
|
2
2
|
import { PRIMARY_COMMAND_NAME, describeAllCommands, describeCommandGroup, findCommandMetadata, renderCompletion, renderHumanHelp } from "../cli/metadata.js";
|
|
3
|
-
import {
|
|
3
|
+
import { ResourceNotFound, UnsupportedMethod, UsageError } from "../domain/errors.js";
|
|
4
4
|
import { ProfileName, Scope } from "../domain/ids.js";
|
|
5
5
|
import { pagingFrom, successEnvelope, toNdjson } from "../output/envelope.js";
|
|
6
6
|
import { renderHumanEnvelope } from "../output/human.js";
|
|
@@ -196,21 +196,27 @@ const authCommand = async (parsed, services) => {
|
|
|
196
196
|
await services.tokenStore.setProfile(profile);
|
|
197
197
|
return { method: "auth.login", profile, stdoutValue: sanitizeProfile(profile) };
|
|
198
198
|
}
|
|
199
|
-
const clientId = flagString(parsed, "client-id") ?? process.env.AGENT_SLACK_CLIENT_ID ?? process.env.SLK_CLIENT_ID ?? process.env.SLACK_CLIENT_ID;
|
|
199
|
+
const clientId = flagString(parsed, "client-id") ?? process.env.AGENT_SLACK_CLIENT_ID ?? process.env.AGENT_SLACK_PUBLIC_CLIENT_ID ?? process.env.SLK_CLIENT_ID ?? process.env.SLACK_CLIENT_ID ?? defaultPkceClientId;
|
|
200
200
|
const clientSecret = flagString(parsed, "client-secret") ?? process.env.AGENT_SLACK_CLIENT_SECRET ?? process.env.SLK_CLIENT_SECRET ?? process.env.SLACK_CLIENT_SECRET;
|
|
201
|
-
|
|
202
|
-
|
|
201
|
+
const byoOAuth = flagBoolean(parsed, "oauth");
|
|
202
|
+
if (byoOAuth && (clientId === undefined || clientSecret === undefined)) {
|
|
203
|
+
throw new UsageError("BYO Slack OAuth needs both --client-id and --client-secret.", {
|
|
204
|
+
suggestion: "Use --token with an existing Bot User OAuth Token, or pass both BYO Slack app credentials from Basic Information -> App Credentials."
|
|
205
|
+
});
|
|
203
206
|
}
|
|
204
207
|
const scopes = splitCsv(flagString(parsed, "scopes"));
|
|
208
|
+
const userScopes = splitCsv(flagString(parsed, "user-scopes"));
|
|
209
|
+
const pkceUserScopes = userScopes.length > 0 ? userScopes : scopes.length > 0 ? scopes : defaultPkceUserScopes;
|
|
205
210
|
const profile = await services.oauthFlow.login({
|
|
206
211
|
profileName,
|
|
207
212
|
clientId,
|
|
208
|
-
clientSecret,
|
|
209
|
-
scopes: scopes.length === 0 ? defaultOAuthScopes : scopes,
|
|
210
|
-
userScopes:
|
|
213
|
+
...(byoOAuth ? { clientSecret } : { pkce: true }),
|
|
214
|
+
scopes: byoOAuth ? (scopes.length === 0 ? defaultOAuthScopes : scopes) : [],
|
|
215
|
+
userScopes: byoOAuth ? userScopes : pkceUserScopes,
|
|
211
216
|
redirectUri: flagString(parsed, "redirect-uri"),
|
|
212
217
|
authUrlOut: flagString(parsed, "auth-url-out"),
|
|
213
|
-
timeoutMs: numberFlag(parsed, "timeout-ms")
|
|
218
|
+
timeoutMs: numberFlag(parsed, "timeout-ms"),
|
|
219
|
+
openBrowser: !flagBoolean(parsed, "no-open")
|
|
214
220
|
});
|
|
215
221
|
await services.tokenStore.setProfile(profile);
|
|
216
222
|
return { method: "auth.login", profile, stdoutValue: sanitizeProfile(profile) };
|
|
@@ -233,6 +239,8 @@ const defaultOAuthScopes = [
|
|
|
233
239
|
"bookmarks:read",
|
|
234
240
|
"team:read"
|
|
235
241
|
];
|
|
242
|
+
const defaultPkceUserScopes = defaultOAuthScopes;
|
|
243
|
+
const defaultPkceClientId = "8263309029475.11499738391475";
|
|
236
244
|
const apiCall = async (parsed, services) => {
|
|
237
245
|
const method = requirePositional(parsed.positionals, 2, "METHOD");
|
|
238
246
|
const payload = await parseJsonPayload({
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { parseArgs } from "../cli/args.js";
|
|
1
|
+
import { flagBoolean, flagString, parseArgs } from "../cli/args.js";
|
|
2
2
|
import { errorEnvelope } from "../output/envelope.js";
|
|
3
|
+
import { renderHumanErrorEnvelope } from "../output/human.js";
|
|
3
4
|
import { dispatch, renderDispatchResult } from "./commands.js";
|
|
4
5
|
export const executeCli = async (argv, services, options = {}) => {
|
|
6
|
+
let parsed = null;
|
|
5
7
|
try {
|
|
6
|
-
|
|
8
|
+
parsed = parseArgs(argv);
|
|
7
9
|
const result = await dispatch(parsed, services);
|
|
8
10
|
return {
|
|
9
11
|
exitCode: 0,
|
|
@@ -13,10 +15,21 @@ export const executeCli = async (argv, services, options = {}) => {
|
|
|
13
15
|
}
|
|
14
16
|
catch (error) {
|
|
15
17
|
const { envelope, exitCode } = errorEnvelope(error);
|
|
18
|
+
const stderr = shouldRenderJsonError(argv, parsed, options)
|
|
19
|
+
? `${JSON.stringify(envelope, null, 2)}\n`
|
|
20
|
+
: renderHumanErrorEnvelope(envelope, {
|
|
21
|
+
color: shouldColorError(argv, parsed, options)
|
|
22
|
+
});
|
|
16
23
|
return {
|
|
17
24
|
exitCode,
|
|
18
25
|
stdout: "",
|
|
19
|
-
stderr
|
|
26
|
+
stderr
|
|
20
27
|
};
|
|
21
28
|
}
|
|
22
29
|
};
|
|
30
|
+
const shouldRenderJsonError = (argv, parsed, options) => parsed === null
|
|
31
|
+
? argv.includes("--json") || options.stdoutIsTty !== true
|
|
32
|
+
: flagBoolean(parsed, "json") || flagString(parsed, "format") === "json" || options.stdoutIsTty !== true;
|
|
33
|
+
const shouldColorError = (argv, parsed, options) => options.stdoutIsTty === true &&
|
|
34
|
+
options.env?.NO_COLOR === undefined &&
|
|
35
|
+
(parsed === null ? !argv.includes("--no-color") : !flagBoolean(parsed, "no-color"));
|
package/dist/cli/args.js
CHANGED
package/dist/cli/metadata.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
const require = createRequire(import.meta.url);
|
|
3
|
+
const packageJson = require("../../package.json");
|
|
1
4
|
export const PRIMARY_COMMAND_NAME = "agent-slack";
|
|
2
5
|
export const SHORT_COMMAND_NAME = "aslk";
|
|
3
6
|
export const COMMAND_NAMES = [PRIMARY_COMMAND_NAME, SHORT_COMMAND_NAME];
|
|
4
|
-
export const CLI_VERSION = "0.
|
|
7
|
+
export const CLI_VERSION = packageJson.version ?? "0.0.0";
|
|
5
8
|
export const commandMetadata = [
|
|
6
9
|
{
|
|
7
10
|
path: ["describe"],
|
|
@@ -29,13 +32,14 @@ export const commandMetadata = [
|
|
|
29
32
|
},
|
|
30
33
|
{
|
|
31
34
|
path: ["auth", "login"],
|
|
32
|
-
summary: "
|
|
33
|
-
flags: ["--profile", "--oauth", "--client-id", "--client-secret", "--scopes", "--user-scopes", "--redirect-uri", "--auth-url-out", "--timeout-ms", "--token", "--json"],
|
|
35
|
+
summary: "Connect a Slack workspace profile.",
|
|
36
|
+
flags: ["--profile", "--oauth", "--client-id", "--client-secret", "--scopes", "--user-scopes", "--redirect-uri", "--auth-url-out", "--timeout-ms", "--no-open", "--token", "--json"],
|
|
34
37
|
safety: "read",
|
|
35
38
|
output: "profile status",
|
|
36
39
|
examples: [
|
|
40
|
+
"agent-slack auth login --json",
|
|
41
|
+
"AGENT_SLACK_TOKEN=xoxb-... agent-slack auth login --profile work --scopes channels:read --json",
|
|
37
42
|
"agent-slack auth login --oauth --client-id 123.456 --client-secret secret --scopes channels:read,channels:history --json",
|
|
38
|
-
"AGENT_SLACK_TOKEN=xoxb-... agent-slack auth login --profile work --scopes channels:read --json"
|
|
39
43
|
]
|
|
40
44
|
},
|
|
41
45
|
{
|
|
@@ -287,6 +291,9 @@ export const describeCommandGroup = (group) => ({
|
|
|
287
291
|
commands: commandMetadata.filter((command) => command.path[0] === group)
|
|
288
292
|
});
|
|
289
293
|
export const renderHumanHelp = (path) => {
|
|
294
|
+
if (path.join(" ") === "auth login") {
|
|
295
|
+
return renderAuthLoginHelp();
|
|
296
|
+
}
|
|
290
297
|
const command = findCommandMetadata(path);
|
|
291
298
|
if (command !== null) {
|
|
292
299
|
return [
|
|
@@ -294,11 +301,8 @@ export const renderHumanHelp = (path) => {
|
|
|
294
301
|
"",
|
|
295
302
|
command.summary,
|
|
296
303
|
"",
|
|
297
|
-
command.args === undefined ? "" : `
|
|
298
|
-
command.flags === undefined ? "" : `Flags: ${command.flags.join(", ")}`,
|
|
304
|
+
command.args === undefined ? "" : `Usage: ${PRIMARY_COMMAND_NAME} ${command.path.join(" ")} ${command.args.join(" ")}`,
|
|
299
305
|
command.scopes === undefined ? "" : `Scopes: ${command.scopes.join(", ")}`,
|
|
300
|
-
`Safety: ${command.safety}`,
|
|
301
|
-
`Output: ${command.output}`,
|
|
302
306
|
"",
|
|
303
307
|
"Examples:",
|
|
304
308
|
...command.examples.map((example) => ` ${example}`)
|
|
@@ -314,6 +318,42 @@ export const renderHumanHelp = (path) => {
|
|
|
314
318
|
...commands.map((item) => ` ${item.path.join(" ").padEnd(28)} ${item.summary}`)
|
|
315
319
|
].join("\n") + "\n";
|
|
316
320
|
};
|
|
321
|
+
const renderAuthLoginHelp = () => [
|
|
322
|
+
"agent-slack auth login",
|
|
323
|
+
"",
|
|
324
|
+
"Connect a Slack workspace profile.",
|
|
325
|
+
"",
|
|
326
|
+
"Browser login",
|
|
327
|
+
" agent-slack auth login",
|
|
328
|
+
"",
|
|
329
|
+
" Opens Slack in the browser with PKCE and stores a local user-token profile.",
|
|
330
|
+
" No Slack app secret is stored in the CLI.",
|
|
331
|
+
"",
|
|
332
|
+
"Token setup",
|
|
333
|
+
" agent-slack auth login --token \"$SLACK_BOT_TOKEN\" --scopes channels:read,channels:history,users:read",
|
|
334
|
+
"",
|
|
335
|
+
" Stores an existing Slack Bot User OAuth Token as a local profile.",
|
|
336
|
+
"",
|
|
337
|
+
"Developer fallback: bring your own Slack app OAuth credentials",
|
|
338
|
+
" agent-slack auth login --oauth --client-id \"$SLACK_CLIENT_ID\" --client-secret \"$SLACK_CLIENT_SECRET\"",
|
|
339
|
+
"",
|
|
340
|
+
"Options",
|
|
341
|
+
" --profile NAME Save as a named profile.",
|
|
342
|
+
" --token TOKEN Store an existing Bot User OAuth Token.",
|
|
343
|
+
" --scopes LIST Token scopes, or PKCE user scopes when --oauth is not used.",
|
|
344
|
+
" --user-scopes LIST Explicit Slack user scopes for OAuth.",
|
|
345
|
+
" --oauth Use Slack OAuth with BYO app credentials.",
|
|
346
|
+
" --client-id VALUE Override the Slack app Client ID.",
|
|
347
|
+
" --client-secret VALUE Slack app Client Secret.",
|
|
348
|
+
" --no-open Print OAuth URL instead of opening the browser.",
|
|
349
|
+
" --auth-url-out PATH Write OAuth URL for headless flows.",
|
|
350
|
+
" --json Emit a parseable JSON envelope.",
|
|
351
|
+
"",
|
|
352
|
+
"Notes",
|
|
353
|
+
" Normal users should not create a Slack app or handle client credentials.",
|
|
354
|
+
" BYO OAuth credentials are only for development and self-hosted setups.",
|
|
355
|
+
""
|
|
356
|
+
].join("\n");
|
|
317
357
|
export const renderCompletion = (shell) => {
|
|
318
358
|
const words = [...new Set(commandMetadata.flatMap((command) => command.path))];
|
|
319
359
|
const commandWords = commandMetadata.map((command) => command.path.join(" "));
|
package/dist/output/envelope.js
CHANGED
|
@@ -22,10 +22,12 @@ export const successEnvelope = (input) => ({
|
|
|
22
22
|
});
|
|
23
23
|
export const errorEnvelope = (error) => {
|
|
24
24
|
const normalized = normalizeUnknownError(error);
|
|
25
|
-
const
|
|
25
|
+
const details = Object.fromEntries(Object.entries(normalized.details).filter(([key]) => key !== "suggestion"));
|
|
26
|
+
const detailsSlackError = typeof details.slackError === "string" ? details.slackError : undefined;
|
|
26
27
|
const slackError = "slackError" in normalized ? normalized.slackError ?? detailsSlackError : detailsSlackError;
|
|
27
28
|
const retryAfterSeconds = "retryAfterSeconds" in normalized ? normalized.retryAfterSeconds : undefined;
|
|
28
|
-
const
|
|
29
|
+
const detailSuggestion = typeof normalized.details.suggestion === "string" ? normalized.details.suggestion : undefined;
|
|
30
|
+
const suggestion = detailSuggestion ?? suggestionFor(normalized._tag);
|
|
29
31
|
const envelope = {
|
|
30
32
|
ok: false,
|
|
31
33
|
error: {
|
|
@@ -36,7 +38,7 @@ export const errorEnvelope = (error) => {
|
|
|
36
38
|
...(retryAfterSeconds === undefined ? {} : { retry_after_seconds: retryAfterSeconds }),
|
|
37
39
|
...(suggestion === undefined ? {} : { suggestion }),
|
|
38
40
|
trace_id: `slk_${randomUUID()}`,
|
|
39
|
-
...(Object.keys(
|
|
41
|
+
...(Object.keys(details).length === 0 ? {} : { details })
|
|
40
42
|
}
|
|
41
43
|
};
|
|
42
44
|
return { envelope, exitCode: normalized.exitCode };
|
package/dist/output/human.js
CHANGED
|
@@ -3,6 +3,7 @@ const codes = {
|
|
|
3
3
|
cyan: [36, 39],
|
|
4
4
|
dim: [2, 22],
|
|
5
5
|
green: [32, 39],
|
|
6
|
+
red: [31, 39],
|
|
6
7
|
yellow: [33, 39]
|
|
7
8
|
};
|
|
8
9
|
export const renderHumanEnvelope = (envelope, options) => {
|
|
@@ -21,6 +22,20 @@ export const renderHumanEnvelope = (envelope, options) => {
|
|
|
21
22
|
];
|
|
22
23
|
return `${lines.join("\n")}\n`;
|
|
23
24
|
};
|
|
25
|
+
export const renderHumanErrorEnvelope = (envelope, options) => {
|
|
26
|
+
const paint = (name, value) => options.color ? `\u001b[${codes[name][0]}m${value}\u001b[${codes[name][1]}m` : value;
|
|
27
|
+
const error = envelope.error;
|
|
28
|
+
const lines = [
|
|
29
|
+
`${paint("red", "Error")} ${paint("bold", error.type)}`,
|
|
30
|
+
error.title,
|
|
31
|
+
...(error.slack_error === undefined ? [] : ["", `${paint("dim", "Slack")} ${error.slack_error}`]),
|
|
32
|
+
...(error.retry_after_seconds === undefined ? [] : [`${paint("dim", "Retry after")} ${error.retry_after_seconds}s`]),
|
|
33
|
+
...(error.suggestion === undefined ? [] : ["", paint("yellow", "Next"), ` ${error.suggestion}`]),
|
|
34
|
+
"",
|
|
35
|
+
`${paint("dim", "Trace")} ${error.trace_id}`
|
|
36
|
+
];
|
|
37
|
+
return `${lines.join("\n")}\n`;
|
|
38
|
+
};
|
|
24
39
|
const isCommandCatalog = (value) => {
|
|
25
40
|
if (!isRecord(value) || typeof value.name !== "string" || !Array.isArray(value.commands)) {
|
|
26
41
|
return false;
|
package/package.json
CHANGED
|
@@ -11,11 +11,41 @@ Use `agent-slack` as the Slack context boundary. `aslk` is the short alias. Pref
|
|
|
11
11
|
|
|
12
12
|
1. Check availability: `agent-slack describe --json`.
|
|
13
13
|
2. Check auth: `agent-slack auth status --json`.
|
|
14
|
-
3. If auth is missing, ask the user to run
|
|
14
|
+
3. If auth is missing, ask the user to run `agent-slack auth login`. It opens Slack in the browser with PKCE and stores a local user-token profile.
|
|
15
15
|
4. Use specific read commands before raw API calls.
|
|
16
16
|
5. Use `--json` for bounded results and `--format ndjson` for large context streams.
|
|
17
17
|
6. Read structured errors from stderr; do not parse progress text from stdout.
|
|
18
18
|
|
|
19
|
+
## Onboarding
|
|
20
|
+
|
|
21
|
+
Browser login:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
agent-slack auth login
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Agent Slack opens Slack in the browser with PKCE and stores a local user-token profile. Users should not create a Slack app or handle `client-id`/`client-secret`.
|
|
28
|
+
|
|
29
|
+
Token setup:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
agent-slack auth login --token "$SLACK_BOT_TOKEN" --scopes channels:read,channels:history,users:read --json
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
This stores an existing Slack Bot User OAuth Token as a local profile.
|
|
36
|
+
|
|
37
|
+
Developer/self-hosted fallback:
|
|
38
|
+
|
|
39
|
+
1. Create or open a Slack app at https://api.slack.com/apps.
|
|
40
|
+
2. Go to **OAuth & Permissions**, add the needed bot scopes, install the app to the workspace, and copy the **Bot User OAuth Token**.
|
|
41
|
+
3. For OAuth auth, get the **Client ID** and **Client Secret** from **Basic Information -> App Credentials** in that Slack app, then run:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
agent-slack auth login --oauth --client-id "$SLACK_CLIENT_ID" --client-secret "$SLACK_CLIENT_SECRET" --json
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Treat Slack app creation and client credentials as an advanced developer fallback only.
|
|
48
|
+
|
|
19
49
|
## Common Commands
|
|
20
50
|
|
|
21
51
|
```bash
|
package/assets/terminal.svg
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
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">aslk auth status</text>
|
|
17
|
-
<text x="78" y="196" fill="#86EFAC" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="16">ok auth.status profile work | team 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">aslk conversation history C123 --limit 3</text>
|
|
21
|
-
<text x="78" y="284" fill="#FDE68A" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="16">ts user text</text>
|
|
22
|
-
<text x="78" y="314" fill="#FDE68A" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="16">1710000000.000100 U123 Ship status?</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">agent-slack 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">agent-slack 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">--json stays parseable</text>
|
|
32
|
-
</svg>
|