@eliya-oss/agent-slack 0.1.3 → 0.1.5

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.
@@ -38,6 +38,7 @@ 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
43
44
  agent-slack auth login --oauth --client-id CLIENT_ID --client-secret CLIENT_SECRET --no-open
@@ -49,9 +50,13 @@ agent-slack auth logout --profile work
49
50
 
50
51
  Auth behavior:
51
52
 
52
- - Uses Slack OAuth v2 with a localhost callback.
53
- - 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.
54
- - Bot scopes go in `scope=...`; user scopes go in `user_scope=...`.
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`.
55
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.
56
61
  - Supports multiple profiles and workspaces.
57
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,19 @@
1
1
  # @eliya-oss/agent-slack
2
2
 
3
+ ## 0.1.5
4
+
5
+ ### Patch Changes
6
+
7
+ - Update the bundled Agent Slack public Client ID used by PKCE browser login.
8
+
9
+ ## 0.1.4
10
+
11
+ ### Patch Changes
12
+
13
+ - Add Slack PKCE browser login.
14
+
15
+ 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.
16
+
3
17
  ## 0.1.3
4
18
 
5
19
  ### Patch Changes
package/README.md CHANGED
@@ -9,21 +9,44 @@ Short alias: `aslk`.
9
9
  npm install -g @eliya-oss/agent-slack
10
10
  ```
11
11
 
12
- <img src="./assets/terminal.svg" alt="Agent Slack terminal screenshot" width="820">
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 --oauth --client-id "$SLACK_CLIENT_ID" --client-secret "$SLACK_CLIENT_SECRET" --json
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
- OAuth login opens Slack in your browser. Use `--no-open` to print the URL.
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.
27
50
 
28
51
  ## Skill
29
52
 
Binary file
@@ -1,4 +1,4 @@
1
- import { randomBytes } from "node:crypto";
1
+ import { createHash, randomBytes } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
3
  import { writeFile } from "node:fs/promises";
4
4
  import { createServer } from "node:http";
@@ -13,22 +13,26 @@ export class NodeLocalhostOAuthFlow {
13
13
  const apiBaseUrl = env.AGENT_SLACK_API_BASE_URL ?? env.SLK_SLACK_API_BASE_URL;
14
14
  const emulatorBaseUrl = apiBaseUrl?.replace(/\/api\/?$/, "");
15
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;
16
17
  const redirectPath = env.AGENT_SLACK_OAUTH_REDIRECT_PATH ?? env.SLK_OAUTH_REDIRECT_PATH;
17
18
  const timeoutMs = env.AGENT_SLACK_OAUTH_TIMEOUT_MS ?? env.SLK_OAUTH_TIMEOUT_MS;
18
19
  return new NodeLocalhostOAuthFlow({
19
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`),
20
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`),
21
22
  ...(redirectHost === undefined ? {} : { defaultRedirectHost: redirectHost }),
23
+ ...(redirectPort === undefined ? {} : { defaultRedirectPort: Number(redirectPort) }),
22
24
  ...(redirectPath === undefined ? {} : { defaultRedirectPath: redirectPath }),
23
25
  ...(timeoutMs === undefined ? {} : { defaultTimeoutMs: Number(timeoutMs) })
24
26
  });
25
27
  }
26
28
  async login(input) {
27
29
  const state = randomBytes(24).toString("base64url");
30
+ const codeVerifier = input.pkce === true ? randomBytes(32).toString("base64url") : undefined;
28
31
  const timeoutMs = input.timeoutMs ?? this.options.defaultTimeoutMs ?? 120_000;
29
32
  const server = createServer();
30
33
  const started = await listen(server, input.redirectUri, {
31
- host: this.options.defaultRedirectHost ?? "127.0.0.1",
34
+ host: this.options.defaultRedirectHost ?? defaultRedirectHost,
35
+ port: this.options.defaultRedirectPort ?? defaultRedirectPort,
32
36
  path: this.options.defaultRedirectPath ?? "/oauth/slack/callback"
33
37
  });
34
38
  const authorizationUrl = buildAuthorizationUrl({
@@ -37,7 +41,8 @@ export class NodeLocalhostOAuthFlow {
37
41
  redirectUri: started.redirectUri,
38
42
  scopes: input.scopes,
39
43
  userScopes: input.userScopes,
40
- state
44
+ state,
45
+ ...(codeVerifier === undefined ? {} : { codeChallenge: codeChallengeFor(codeVerifier) })
41
46
  });
42
47
  if (input.authUrlOut !== undefined) {
43
48
  await writeFile(input.authUrlOut, authorizationUrl, "utf8");
@@ -79,9 +84,10 @@ export class NodeLocalhostOAuthFlow {
79
84
  code,
80
85
  clientId: input.clientId,
81
86
  clientSecret: input.clientSecret,
87
+ codeVerifier,
82
88
  redirectUri: started.redirectUri
83
89
  });
84
- const profile = toAuthProfile(input.profileName, access);
90
+ const profile = toAuthProfile(input.profileName, access, input.pkce === true ? "user" : "bot");
85
91
  response.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end("<html><body>Slack auth complete. You can close this tab.</body></html>");
86
92
  clearTimeout(timer);
87
93
  closeServer(server);
@@ -101,7 +107,7 @@ const listen = (server, redirectUri, fallback) => new Promise((resolve, reject)
101
107
  const parsed = redirectUri === undefined ? null : new URL(redirectUri);
102
108
  const host = parsed?.hostname ?? fallback.host;
103
109
  const path = parsed?.pathname ?? fallback.path;
104
- const port = parsed === null || parsed.port === "" ? 0 : Number(parsed.port);
110
+ const port = parsed === null || parsed.port === "" ? fallback.port : Number(parsed.port);
105
111
  if (!Number.isInteger(port) || port < 0) {
106
112
  reject(new UsageError("OAuth redirect URI must include a valid port", { redirectUri }));
107
113
  return;
@@ -109,34 +115,48 @@ const listen = (server, redirectUri, fallback) => new Promise((resolve, reject)
109
115
  server.once("error", reject);
110
116
  server.listen(port, host, () => {
111
117
  const address = server.address();
112
- const actualPort = parsed === null || parsed.port === "" ? address.port : port;
118
+ const actualPort = parsed === null || parsed.port === "" ? port : address.port;
113
119
  resolve({
114
120
  redirectUri: redirectUri ?? `http://${host}:${actualPort}${path}`,
115
121
  path
116
122
  });
117
123
  });
118
124
  });
125
+ const defaultRedirectHost = "localhost";
126
+ const defaultRedirectPort = 45454;
119
127
  const buildAuthorizationUrl = (input) => {
120
128
  const url = new URL(input.authorizeUrl);
121
129
  url.searchParams.set("client_id", input.clientId);
122
- url.searchParams.set("scope", input.scopes.join(","));
130
+ if (input.scopes.length > 0) {
131
+ url.searchParams.set("scope", input.scopes.join(","));
132
+ }
123
133
  if (input.userScopes.length > 0) {
124
134
  url.searchParams.set("user_scope", input.userScopes.join(","));
125
135
  }
126
136
  url.searchParams.set("redirect_uri", input.redirectUri);
127
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
+ }
128
142
  return url.toString();
129
143
  };
130
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
+ }
131
156
  const response = await fetch(input.tokenUrl, {
132
157
  method: "POST",
133
158
  headers: { "content-type": "application/x-www-form-urlencoded" },
134
- body: new URLSearchParams({
135
- code: input.code,
136
- client_id: input.clientId,
137
- client_secret: input.clientSecret,
138
- redirect_uri: input.redirectUri
139
- })
159
+ body: requestBody
140
160
  });
141
161
  const body = await response.json();
142
162
  if (body.ok === false) {
@@ -149,21 +169,26 @@ const exchangeCode = async (input) => {
149
169
  }
150
170
  return body;
151
171
  };
152
- const toAuthProfile = (profileName, response) => {
172
+ const toAuthProfile = (profileName, response, preferredTokenType) => {
153
173
  const botScopes = splitScopes(response.scope);
154
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];
155
179
  return {
156
180
  name: ProfileName.make(profileName),
157
- tokenType: response.access_token === undefined ? "user" : "bot",
181
+ tokenType: useUserToken || response.access_token === undefined ? "user" : "bot",
158
182
  enterpriseId: response.enterprise?.id ?? null,
159
183
  ...(response.team?.id === undefined ? {} : { teamId: response.team.id }),
160
184
  ...(response.authed_user?.id === undefined ? {} : { userId: response.authed_user.id }),
161
185
  ...(response.bot_user_id === undefined ? {} : { botId: response.bot_user_id }),
162
- ...(response.access_token === undefined ? {} : { botToken: response.access_token }),
163
- ...(response.authed_user?.access_token === undefined ? {} : { userToken: response.authed_user.access_token }),
164
- scopes: [...new Set([...botScopes, ...userScopes])].map(Scope.make)
186
+ ...(response.access_token === undefined || useUserToken ? {} : { botToken: response.access_token }),
187
+ ...(userToken === undefined ? {} : { userToken }),
188
+ scopes: [...new Set(scopes)].map(Scope.make)
165
189
  };
166
190
  };
191
+ const codeChallengeFor = (codeVerifier) => createHash("sha256").update(codeVerifier).digest("base64url");
167
192
  const splitScopes = (value) => value === undefined || value.trim() === "" ? [] : value.split(",").map((scope) => scope.trim()).filter(Boolean);
168
193
  const closeServer = (server) => {
169
194
  server.close();
@@ -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 { NotAuthenticated, ResourceNotFound, UnsupportedMethod, UsageError } from "../domain/errors.js";
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,18 +196,23 @@ 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
- if (clientId === undefined || clientSecret === undefined) {
202
- throw new NotAuthenticated("OAuth login needs --client-id/--client-secret or AGENT_SLACK_CLIENT_ID/AGENT_SLACK_CLIENT_SECRET");
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: splitCsv(flagString(parsed, "user-scopes")),
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
218
  timeoutMs: numberFlag(parsed, "timeout-ms"),
@@ -234,6 +239,8 @@ const defaultOAuthScopes = [
234
239
  "bookmarks:read",
235
240
  "team:read"
236
241
  ];
242
+ const defaultPkceUserScopes = defaultOAuthScopes;
243
+ const defaultPkceClientId = "11499810382723.11506074725874";
237
244
  const apiCall = async (parsed, services) => {
238
245
  const method = requirePositional(parsed.positionals, 2, "METHOD");
239
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
- const parsed = parseArgs(argv);
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: `${JSON.stringify(envelope, null, 2)}\n`
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"));
@@ -32,13 +32,14 @@ export const commandMetadata = [
32
32
  },
33
33
  {
34
34
  path: ["auth", "login"],
35
- summary: "Create a Slack auth profile with OAuth or a seeded token.",
35
+ summary: "Connect a Slack workspace profile.",
36
36
  flags: ["--profile", "--oauth", "--client-id", "--client-secret", "--scopes", "--user-scopes", "--redirect-uri", "--auth-url-out", "--timeout-ms", "--no-open", "--token", "--json"],
37
37
  safety: "read",
38
38
  output: "profile status",
39
39
  examples: [
40
+ "agent-slack auth login --json",
41
+ "AGENT_SLACK_TOKEN=xoxb-... agent-slack auth login --profile work --scopes channels:read --json",
40
42
  "agent-slack auth login --oauth --client-id 123.456 --client-secret secret --scopes channels:read,channels:history --json",
41
- "AGENT_SLACK_TOKEN=xoxb-... agent-slack auth login --profile work --scopes channels:read --json"
42
43
  ]
43
44
  },
44
45
  {
@@ -290,6 +291,9 @@ export const describeCommandGroup = (group) => ({
290
291
  commands: commandMetadata.filter((command) => command.path[0] === group)
291
292
  });
292
293
  export const renderHumanHelp = (path) => {
294
+ if (path.join(" ") === "auth login") {
295
+ return renderAuthLoginHelp();
296
+ }
293
297
  const command = findCommandMetadata(path);
294
298
  if (command !== null) {
295
299
  return [
@@ -297,11 +301,8 @@ export const renderHumanHelp = (path) => {
297
301
  "",
298
302
  command.summary,
299
303
  "",
300
- command.args === undefined ? "" : `Arguments: ${command.args.join(" ")}`,
301
- command.flags === undefined ? "" : `Flags: ${command.flags.join(", ")}`,
304
+ command.args === undefined ? "" : `Usage: ${PRIMARY_COMMAND_NAME} ${command.path.join(" ")} ${command.args.join(" ")}`,
302
305
  command.scopes === undefined ? "" : `Scopes: ${command.scopes.join(", ")}`,
303
- `Safety: ${command.safety}`,
304
- `Output: ${command.output}`,
305
306
  "",
306
307
  "Examples:",
307
308
  ...command.examples.map((example) => ` ${example}`)
@@ -317,6 +318,42 @@ export const renderHumanHelp = (path) => {
317
318
  ...commands.map((item) => ` ${item.path.join(" ").padEnd(28)} ${item.summary}`)
318
319
  ].join("\n") + "\n";
319
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");
320
357
  export const renderCompletion = (shell) => {
321
358
  const words = [...new Set(commandMetadata.flatMap((command) => command.path))];
322
359
  const commandWords = commandMetadata.map((command) => command.path.join(" "));
@@ -22,10 +22,12 @@ export const successEnvelope = (input) => ({
22
22
  });
23
23
  export const errorEnvelope = (error) => {
24
24
  const normalized = normalizeUnknownError(error);
25
- const detailsSlackError = typeof normalized.details.slackError === "string" ? normalized.details.slackError : undefined;
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 suggestion = suggestionFor(normalized._tag);
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(normalized.details).length === 0 ? {} : { details: normalized.details })
41
+ ...(Object.keys(details).length === 0 ? {} : { details })
40
42
  }
41
43
  };
42
44
  return { envelope, exitCode: normalized.exitCode };
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eliya-oss/agent-slack",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Slack context CLI for AI agents.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -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 `agent-slack auth login --oauth`; it opens Slack OAuth in the browser by default. For headless flows use `--auth-url-out PATH` or `--no-open`.
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
@@ -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>