@joe-broadhead/opencode-slack 0.0.0 → 0.0.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
4
 
5
+ ## [0.0.1] - 2026-11-03
6
+
7
+ ### Added
8
+
9
+ - A `jelly` section in `package.json`: each tool with its permission, its default effect and whether it only reads, the skill ids. Jelly reads it without loading the plugin and applies the declared asks, because its agents have no base allow rule for this plugin's own default rules to follow. `docs/jelly.md` explains it.
10
+ - `title` and `signIns` in the `jelly` section: the name Jelly shows on the plugin's card, and what the plugin signs in to, so that one card can say whether each sign-in is connected and start, renew or end it. `docs/jelly.md` lists them.
11
+ - Hosted callbacks, so sign-in works when OpenCode runs on another machine ([#1](https://github.com/joe-broadhead/opencode-slack/issues/1)): `oauth.redirect_uri` also accepts an `https:` URL with a path and no credentials, query or fragment, together with the new `oauth.callback_port`, the port of OpenCode's local callback listener, which a reverse proxy forwards the address to. The server config then carries `callback_port`, and status shows the port in `callbackPort` and its `Callback` row. `oauth.callback_port` is refused with a loopback redirect, whose own port is the listener's. The docs explain how to add a hosted address to the Slack app's Redirect URLs, and how two sign-ins on one machine avoid colliding on port 8765.
12
+ - `presetBy` on the `jelly.signIns` Slack app step: with `oauth.client_id` set in the plugin options, the step is done, so Jelly asks nobody to paste a client ID.
13
+
14
+ ### Changed
15
+
16
+ - The `oauth.redirect_uri` errors say which rule an `http:` URL broke, and their next step names both a loopback and a hosted example. The `Callback` status row says to register the URL in the app's Redirect URLs.
17
+
5
18
  ## [0.0.0] - 2026-11-01
6
19
 
7
20
  ### Added
package/README.md CHANGED
@@ -21,7 +21,7 @@ opencode plugin add @joe-broadhead/opencode-slack
21
21
 
22
22
  If your organisation already set the client ID for you (see [Organisation rollout](#organisation-rollout)), there is no **Slack app** entry; go straight to step 2.
23
23
  2. **Sign in to Slack.** Run `/connect` and choose **Slack**. In the TUI, press `o` or Enter to open the sign-in page in your browser. In a terminal, `opencode mcp auth slack` opens the browser automatically. `/mcps` → `slack` is an equivalent way in.
24
- 3. Slack shows the workspace, the account and what the app asks for. Approve it. Slack sends the browser back to `http://localhost:8765/callback`, and OpenCode finishes the sign-in.
24
+ 3. Slack shows the workspace, the account and what the app asks for. Approve it. Slack sends the browser back to the callback, `http://localhost:8765/callback` unless you set another, and OpenCode finishes the sign-in.
25
25
 
26
26
  `/slack-status` now shows `connected` and the Slack tools it found. OpenCode stores the credential and refreshes it. To sign out, run `opencode mcp logout slack`.
27
27
 
@@ -106,7 +106,7 @@ Do not put this block in the top-level `permissions`. OpenCode adds top-level ru
106
106
 
107
107
  ## Configure
108
108
 
109
- Everything works without options. Options set the client ID for everyone (instead of `/connect`), the callback URL, the scopes, a client secret for apps without PKCE, the server name and the host check. To pass them, use the object form of the `plugins` entry:
109
+ Everything works without options. Options set the client ID for everyone (instead of `/connect`), the callback URL and its local port, the scopes, a client secret for apps without PKCE, the server name and the host check. To pass them, use the object form of the `plugins` entry:
110
110
 
111
111
  ```jsonc
112
112
  {
@@ -116,6 +116,14 @@ Everything works without options. Options set the client ID for everyone (instea
116
116
  }
117
117
  ```
118
118
 
119
+ **OpenCode on another machine.** The default callback, `http://localhost:8765/callback`, works only when the browser runs on OpenCode's machine. Otherwise use a hosted callback: an `https:` address the browser can reach, which a reverse proxy forwards to OpenCode's local listener on `oauth.callback_port`:
120
+
121
+ ```jsonc
122
+ "oauth": { "client_id": "YOUR_SLACK_CLIENT_ID", "redirect_uri": "https://host.example/oauth/return", "callback_port": 8766 }
123
+ ```
124
+
125
+ Add the address to the Slack app's **OAuth & Permissions → Redirect URLs** (or `oauth_config.redirect_urls` in the [manifest](https://github.com/joe-broadhead/opencode-slack/blob/HEAD/docs/slack-app-manifest.json)). Slack matches the host and port exactly. [The callback: loopback or hosted](https://github.com/joe-broadhead/opencode-slack/blob/HEAD/docs/configuration.md#the-callback-loopback-or-hosted) explains the proxy and how two sign-ins on one machine avoid colliding on port 8765.
126
+
119
127
  [docs/configuration.md](https://github.com/joe-broadhead/opencode-slack/blob/HEAD/docs/configuration.md) lists every option, the scopes each tool needs, the permission rules and the status model.
120
128
 
121
129
  ## Organisation rollout
@@ -165,6 +173,8 @@ If status itself cannot be read, `/slack-status` says `STATUS_UNAVAILABLE`; run
165
173
  - **Slack asks you to request approval:** your workspace requires admin approval for apps or scopes. Ask an admin to approve the app, then sign in again.
166
174
  - **Slack refuses the app for MCP:** turn on **Agents → Slack Model Context Protocol (MCP) Server** in the app settings. The app must be internal to your organisation or published in the Slack Marketplace.
167
175
  - **The browser cannot open `localhost:8765` after Slack approves:** OpenCode listens for the callback on `127.0.0.1` only. If your browser tries IPv6 for `localhost`, replace `localhost` with `127.0.0.1` in the address bar. To avoid it, register `http://127.0.0.1:8765/callback` in the app and set it as `oauth.redirect_uri`.
176
+ - **The browser cannot open `localhost:8765` because OpenCode runs on another machine:** the browser's `localhost` is not OpenCode's. Use a [hosted callback](https://github.com/joe-broadhead/opencode-slack/blob/HEAD/docs/configuration.md#the-callback-loopback-or-hosted).
177
+ - **Sign-in fails because the callback port is in use:** another sign-in on this machine is waiting on port 8765. Finish it first, or give this OpenCode its own callback, such as `http://localhost:8767/callback`, registered in the Slack app too.
168
178
 
169
179
  **A Slack tool fails**
170
180
 
package/dist/index.js CHANGED
@@ -3,6 +3,8 @@ import { Plugin } from "@opencode/plugin";
3
3
 
4
4
  // src/defaults.ts
5
5
  var DEFAULT_REDIRECT_URI = "http://localhost:8765/callback";
6
+ var HOSTED_REDIRECT_EXAMPLE = "https://host.example/oauth/return";
7
+ var CALLBACK_PORT_EXAMPLE = 8766;
6
8
  var DEFAULT_SCOPES = ["channels:history", "channels:read", "search:read.public", "users:read"];
7
9
  var DEFAULT_SERVER = "slack";
8
10
  var APP_MANIFEST_URL = "https://github.com/joe-broadhead/opencode-slack/blob/HEAD/docs/slack-app-manifest.json";
@@ -96,7 +98,7 @@ function isTestedHost(version) {
96
98
 
97
99
  // src/options.ts
98
100
  var TOP_KEYS = ["oauth", "server", "allowUntestedHost"];
99
- var OAUTH_KEYS = ["client_id", "redirect_uri", "scopes", "client_secret"];
101
+ var OAUTH_KEYS = ["client_id", "redirect_uri", "callback_port", "scopes", "client_secret"];
100
102
  var SERVER_NAME = /^[A-Za-z0-9_-]+$/;
101
103
  var NO_SPACES2 = /^\S+$/;
102
104
  var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -108,14 +110,17 @@ function redirectProblem(value) {
108
110
  } catch {
109
111
  return "Must be a valid URL.";
110
112
  }
111
- if (url.protocol !== "http:") return "Must use http:.";
112
- if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1") return "Must use the host localhost or 127.0.0.1.";
113
- if (url.port === "") return "Must include an explicit port.";
113
+ if (url.protocol !== "http:" && url.protocol !== "https:") return "Must use http: or https:.";
114
+ if (url.protocol === "http:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
115
+ return "An http: URL must use the host localhost or 127.0.0.1.";
116
+ }
117
+ if (url.protocol === "http:" && url.port === "") return "An http: URL must include an explicit port.";
114
118
  if (url.username !== "" || url.password !== "") return "Must not include credentials.";
115
119
  if (value.includes("?") || value.includes("#")) return "Must not include a query or fragment.";
116
120
  if (url.pathname === "/") return "Must include a path, such as /callback.";
117
121
  return void 0;
118
122
  }
123
+ var isPort = (value) => typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535;
119
124
  function parseOptions(input) {
120
125
  const raw = input ?? {};
121
126
  if (!isRecord(raw)) return fail("options", "Plugin options must be an object.", "Set options to an object, or remove them.");
@@ -136,7 +141,18 @@ function parseOptions(input) {
136
141
  const redirectUri = oauth.redirect_uri ?? DEFAULT_REDIRECT_URI;
137
142
  const redirectError = typeof redirectUri === "string" ? redirectProblem(redirectUri) : "Must be a string.";
138
143
  if (typeof redirectUri !== "string" || redirectError !== void 0) {
139
- return fail("oauth.redirect_uri", redirectError ?? "Must be a string.", `Use the loopback URL registered in your Slack app, for example ${DEFAULT_REDIRECT_URI}.`);
144
+ return fail("oauth.redirect_uri", redirectError ?? "Must be a string.", `Use a URL listed in the app's Redirect URLs, for example ${DEFAULT_REDIRECT_URI} or ${HOSTED_REDIRECT_EXAMPLE}.`);
145
+ }
146
+ const hosted = new URL(redirectUri).protocol === "https:";
147
+ const callbackPort = oauth.callback_port;
148
+ if (callbackPort !== void 0 && !isPort(callbackPort)) {
149
+ return fail("oauth.callback_port", "Must be a whole number from 1 to 65535.", `Set it to the local port that receives the forwarded callback, for example ${CALLBACK_PORT_EXAMPLE}.`);
150
+ }
151
+ if (callbackPort !== void 0 && !hosted) {
152
+ return fail("oauth.callback_port", "Applies only to an https: oauth.redirect_uri.", "Remove it: with a loopback oauth.redirect_uri, OpenCode listens on that URL's port.");
153
+ }
154
+ if (callbackPort === void 0 && hosted) {
155
+ return fail("oauth.callback_port", "Must be set with an https: oauth.redirect_uri.", `Set it to the local port your proxy forwards the redirect to, for example ${CALLBACK_PORT_EXAMPLE}.`);
140
156
  }
141
157
  const scopes = oauth.scopes ?? DEFAULT_SCOPES;
142
158
  const isScope = (scope) => typeof scope === "string" && NO_SPACES2.test(scope);
@@ -164,6 +180,7 @@ function parseOptions(input) {
164
180
  options: {
165
181
  ...clientId === void 0 ? {} : { clientId },
166
182
  redirectUri,
183
+ ...callbackPort === void 0 ? {} : { callbackPort },
167
184
  scopes: [...scopes],
168
185
  ...clientSecret === void 0 ? {} : { clientSecret },
169
186
  server,
@@ -248,6 +265,7 @@ var status = object({
248
265
  server: object({ name: text, url: text }, ["name", "url"]),
249
266
  client: object({ id: text, mode: { enum: ["public_pkce", "confidential"] }, source: { enum: ["options", "connect"] } }, ["id", "mode", "source"]),
250
267
  callback: text,
268
+ callbackPort: { type: "integer" },
251
269
  scopes: texts,
252
270
  asksBefore: texts,
253
271
  tools: object({
@@ -280,6 +298,7 @@ function serverConfig(options, client) {
280
298
  oauth: {
281
299
  client_id: client.id,
282
300
  redirect_uri: options.redirectUri,
301
+ ...options.callbackPort === void 0 ? {} : { callback_port: options.callbackPort },
283
302
  scope: options.scopes.join(" "),
284
303
  ...client.secret === void 0 ? {} : { client_secret: client.secret }
285
304
  }
@@ -402,6 +421,7 @@ function buildStatus(input) {
402
421
  client: { id: client.id, mode: client.secret === void 0 ? "public_pkce" : "confidential", source: client.source }
403
422
  },
404
423
  callback: options.redirectUri,
424
+ ...options.callbackPort === void 0 ? {} : { callbackPort: options.callbackPort },
405
425
  scopes: options.scopes
406
426
  },
407
427
  asksBefore: ASKS_BEFORE,
@@ -443,6 +463,11 @@ function buildStatus(input) {
443
463
  const status2 = input.servers.find((item) => item.name === server)?.status;
444
464
  return { ...base, ...connection(server, status2, unguardedAgents, names.length) };
445
465
  }
466
+ function callbackRow(callback, port) {
467
+ const register = "register exactly this URL in the app's Redirect URLs";
468
+ if (port === void 0) return `${callback} (${register})`;
469
+ return `${callback} (${register}, and forward it to http://127.0.0.1:${port}${new URL(callback).pathname}, where OpenCode listens)`;
470
+ }
446
471
  var CLIENT_SOURCE = { options: "set in plugin options", connect: "saved in /connect" };
447
472
  function stateLabel(status2) {
448
473
  switch (status2.state) {
@@ -477,7 +502,7 @@ function statusRows(status2) {
477
502
  const mode = status2.client.mode === "public_pkce" ? "public, PKCE" : "confidential, secret set";
478
503
  rows.push({ label: "Client", value: `${status2.client.id} (${mode}; ${CLIENT_SOURCE[status2.client.source]})` });
479
504
  }
480
- if (status2.callback !== void 0) rows.push({ label: "Callback", value: `${status2.callback} (register exactly this URL in the Slack app)` });
505
+ if (status2.callback !== void 0) rows.push({ label: "Callback", value: callbackRow(status2.callback, status2.callbackPort) });
481
506
  if (status2.scopes !== void 0) rows.push({ label: "Scopes", value: status2.scopes.join(" ") });
482
507
  rows.push({
483
508
  label: "Tools",
package/dist/tui.js CHANGED
@@ -25,6 +25,7 @@ var status = object({
25
25
  server: object({ name: text, url: text }, ["name", "url"]),
26
26
  client: object({ id: text, mode: { enum: ["public_pkce", "confidential"] }, source: { enum: ["options", "connect"] } }, ["id", "mode", "source"]),
27
27
  callback: text,
28
+ callbackPort: { type: "integer" },
28
29
  scopes: texts,
29
30
  asksBefore: texts,
30
31
  tools: object({
@@ -97,6 +98,11 @@ function defaultRules(server) {
97
98
  // src/status.ts
98
99
  var quote = (rule) => `{ "action": "${rule.action}", "resource": "${rule.resource}", "effect": "${rule.effect}" }`;
99
100
  var remedy = (server) => `[${defaultRules(server).map(quote).join(", ")}]`;
101
+ function callbackRow(callback, port) {
102
+ const register = "register exactly this URL in the app's Redirect URLs";
103
+ if (port === void 0) return `${callback} (${register})`;
104
+ return `${callback} (${register}, and forward it to http://127.0.0.1:${port}${new URL(callback).pathname}, where OpenCode listens)`;
105
+ }
100
106
  var CLIENT_SOURCE = { options: "set in plugin options", connect: "saved in /connect" };
101
107
  function stateLabel(status2) {
102
108
  switch (status2.state) {
@@ -131,7 +137,7 @@ function statusRows(status2) {
131
137
  const mode = status2.client.mode === "public_pkce" ? "public, PKCE" : "confidential, secret set";
132
138
  rows.push({ label: "Client", value: `${status2.client.id} (${mode}; ${CLIENT_SOURCE[status2.client.source]})` });
133
139
  }
134
- if (status2.callback !== void 0) rows.push({ label: "Callback", value: `${status2.callback} (register exactly this URL in the Slack app)` });
140
+ if (status2.callback !== void 0) rows.push({ label: "Callback", value: callbackRow(status2.callback, status2.callbackPort) });
135
141
  if (status2.scopes !== void 0) rows.push({ label: "Scopes", value: status2.scopes.join(" ") });
136
142
  rows.push({
137
143
  label: "Tools",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joe-broadhead/opencode-slack",
3
- "version": "0.0.0",
3
+ "version": "0.0.1",
4
4
  "description": "Unofficial OpenCode plugin for Slack",
5
5
  "keywords": [
6
6
  "opencode",
@@ -55,5 +55,201 @@
55
55
  "esbuild": "0.28.2",
56
56
  "solid-js": "1.9.17",
57
57
  "typescript": "5.9.3"
58
+ },
59
+ "jelly": {
60
+ "title": "Slack",
61
+ "tools": [
62
+ {
63
+ "name": "slack.slack_search_public",
64
+ "permission": "slack_slack_search_public",
65
+ "effect": "allow",
66
+ "readOnly": true
67
+ },
68
+ {
69
+ "name": "slack.slack_search_channels",
70
+ "permission": "slack_slack_search_channels",
71
+ "effect": "allow",
72
+ "readOnly": true
73
+ },
74
+ {
75
+ "name": "slack.slack_search_users",
76
+ "permission": "slack_slack_search_users",
77
+ "effect": "allow",
78
+ "readOnly": true
79
+ },
80
+ {
81
+ "name": "slack.slack_read_channel",
82
+ "permission": "slack_slack_read_channel",
83
+ "effect": "allow",
84
+ "readOnly": true
85
+ },
86
+ {
87
+ "name": "slack.slack_read_thread",
88
+ "permission": "slack_slack_read_thread",
89
+ "effect": "allow",
90
+ "readOnly": true
91
+ },
92
+ {
93
+ "name": "slack.slack_read_user_profile",
94
+ "permission": "slack_slack_read_user_profile",
95
+ "effect": "allow",
96
+ "readOnly": true
97
+ },
98
+ {
99
+ "name": "slack.slack_read_canvas",
100
+ "permission": "slack_slack_read_canvas",
101
+ "effect": "allow",
102
+ "readOnly": true
103
+ },
104
+ {
105
+ "name": "slack.slack_list_channel_members",
106
+ "permission": "slack_slack_list_channel_members",
107
+ "effect": "allow",
108
+ "readOnly": true
109
+ },
110
+ {
111
+ "name": "slack.slack_get_reactions",
112
+ "permission": "slack_slack_get_reactions",
113
+ "effect": "allow",
114
+ "readOnly": true
115
+ },
116
+ {
117
+ "name": "slack.slack_list_user_channels",
118
+ "permission": "slack_slack_list_user_channels",
119
+ "effect": "allow",
120
+ "readOnly": true
121
+ },
122
+ {
123
+ "name": "slack.slack_read_list",
124
+ "permission": "slack_slack_read_list",
125
+ "effect": "allow",
126
+ "readOnly": true
127
+ },
128
+ {
129
+ "name": "slack.slack_search_emojis",
130
+ "permission": "slack_slack_search_emojis",
131
+ "effect": "allow",
132
+ "readOnly": true
133
+ },
134
+ {
135
+ "name": "slack.slack_search_public_and_private",
136
+ "permission": "slack_slack_search_public_and_private",
137
+ "effect": "ask",
138
+ "readOnly": true
139
+ },
140
+ {
141
+ "name": "slack.slack_read_file",
142
+ "permission": "slack_slack_read_file",
143
+ "effect": "ask",
144
+ "readOnly": true
145
+ },
146
+ {
147
+ "name": "slack.slack_send_message",
148
+ "permission": "slack_slack_send_message",
149
+ "effect": "ask",
150
+ "readOnly": false
151
+ },
152
+ {
153
+ "name": "slack.slack_send_message_draft",
154
+ "permission": "slack_slack_send_message_draft",
155
+ "effect": "ask",
156
+ "readOnly": false
157
+ },
158
+ {
159
+ "name": "slack.slack_schedule_message",
160
+ "permission": "slack_slack_schedule_message",
161
+ "effect": "ask",
162
+ "readOnly": false
163
+ },
164
+ {
165
+ "name": "slack.slack_create_canvas",
166
+ "permission": "slack_slack_create_canvas",
167
+ "effect": "ask",
168
+ "readOnly": false
169
+ },
170
+ {
171
+ "name": "slack.slack_update_canvas",
172
+ "permission": "slack_slack_update_canvas",
173
+ "effect": "ask",
174
+ "readOnly": false
175
+ },
176
+ {
177
+ "name": "slack.slack_add_reaction",
178
+ "permission": "slack_slack_add_reaction",
179
+ "effect": "ask",
180
+ "readOnly": false
181
+ },
182
+ {
183
+ "name": "slack.slack_get_file_upload_url",
184
+ "permission": "slack_slack_get_file_upload_url",
185
+ "effect": "ask",
186
+ "readOnly": false
187
+ },
188
+ {
189
+ "name": "slack.slack_complete_file_upload",
190
+ "permission": "slack_slack_complete_file_upload",
191
+ "effect": "ask",
192
+ "readOnly": false
193
+ },
194
+ {
195
+ "name": "slack.slack_create_conversation",
196
+ "permission": "slack_slack_create_conversation",
197
+ "effect": "ask",
198
+ "readOnly": false
199
+ },
200
+ {
201
+ "name": "slack.slack_create_list",
202
+ "permission": "slack_slack_create_list",
203
+ "effect": "ask",
204
+ "readOnly": false
205
+ },
206
+ {
207
+ "name": "slack.slack_update_list",
208
+ "permission": "slack_slack_update_list",
209
+ "effect": "ask",
210
+ "readOnly": false
211
+ },
212
+ {
213
+ "name": "slack.slack_add_list_record",
214
+ "permission": "slack_slack_add_list_record",
215
+ "effect": "ask",
216
+ "readOnly": false
217
+ },
218
+ {
219
+ "name": "slack.slack_update_list_record",
220
+ "permission": "slack_slack_update_list_record",
221
+ "effect": "ask",
222
+ "readOnly": false
223
+ }
224
+ ],
225
+ "skills": [
226
+ "slack-search"
227
+ ],
228
+ "rpc": [],
229
+ "views": [],
230
+ "signIns": [
231
+ {
232
+ "id": "app",
233
+ "label": "Slack app",
234
+ "integration": "slack-app",
235
+ "presetBy": "oauth.client_id",
236
+ "help": {
237
+ "url": "https://github.com/joe-broadhead/opencode-slack/blob/HEAD/docs/slack-app-manifest.json",
238
+ "label": "Create the Slack app from its manifest, then paste its client ID"
239
+ }
240
+ },
241
+ {
242
+ "id": "slack",
243
+ "label": "Slack",
244
+ "server": "slack",
245
+ "after": "app"
246
+ }
247
+ ],
248
+ "contributes": {
249
+ "homeFeeds": [],
250
+ "homeWidgets": [],
251
+ "workKinds": [],
252
+ "settings": []
253
+ }
58
254
  }
59
255
  }