@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.
@@ -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
- - Uses Slack OAuth v2 with a localhost callback.
52
- - 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`.
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.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
+ ## 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 ?? "127.0.0.1",
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 === "" ? 0 : Number(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 === "" ? address.port : 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
- url.searchParams.set("scope", input.scopes.join(","));
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: new URLSearchParams({
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
- ...(response.authed_user?.access_token === undefined ? {} : { userToken: response.authed_user.access_token }),
157
- 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)
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 { 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,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
- 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
- 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
- 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"));
package/dist/cli/args.js CHANGED
@@ -10,7 +10,8 @@ const booleanFlags = new Set([
10
10
  "yes",
11
11
  "no-cache",
12
12
  "inclusive",
13
- "oauth"
13
+ "oauth",
14
+ "no-open"
14
15
  ]);
15
16
  export const parseArgs = (argv) => {
16
17
  const flags = new Map();
@@ -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.1.2";
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: "Create a Slack auth profile with OAuth or a seeded token.",
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 ? "" : `Arguments: ${command.args.join(" ")}`,
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(" "));
@@ -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.2",
3
+ "version": "0.1.4",
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 OAuth or provide a seeded profile; do not invent credentials.
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>