@joe-broadhead/opencode-slack 0.0.0 → 0.0.2

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,25 @@
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.2] - 2026-11-03
6
+
7
+ ### Added
8
+
9
+ - The tools RPC, `slack.workspace.tools`: a script can call Slack's twelve reads, `slack_search_public_and_private` and `slack_read_file` with no model, through `opencode api`, as a session and its agent. It gets exactly the access that agent's model has: a tool its rules hide is refused, OpenCode checks each call against the agent's and the session's rules, and an ask is refused at once because no one is there to answer it, so the private search and the file read need an allow rule. `describe` lists the methods with each tool's live input schema. Slack's writes and sends, and any tool Slack adds later, are never methods. `docs/scripting.md` explains it.
10
+
11
+ ## [0.0.1] - 2026-11-03
12
+
13
+ ### Added
14
+
15
+ - 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.
16
+ - `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.
17
+ - 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.
18
+ - `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.
19
+
20
+ ### Changed
21
+
22
+ - 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.
23
+
5
24
  ## [0.0.0] - 2026-11-01
6
25
 
7
26
  ### 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
 
@@ -42,6 +42,17 @@ The Slack tools are Slack's own. With Code Mode, which is on by default, agents
42
42
  - `OpenCode`: the version and the tested range;
43
43
  - `Next step`: exactly one, or `Nothing to do.`
44
44
 
45
+ ### Scripts
46
+
47
+ A script or a cron job can call Slack's fourteen read tools with no model, through `opencode api`. It runs as a session and its agent, under that agent's permission rules and the session's own. The private search and the file read ask by default, so a script needs an allow rule for them. Writes and sends are never available to scripts:
48
+
49
+ ```sh
50
+ opencode api post /api/rpc/slack.workspace.tools/slack_search_public \
51
+ -d '{"input":{"sessionID":"ses_…","input":{"keywords":["launch"]}}}'
52
+ ```
53
+
54
+ See [Scripting](https://github.com/joe-broadhead/opencode-slack/blob/HEAD/docs/scripting.md).
55
+
45
56
  ## Permissions
46
57
 
47
58
  **What runs without asking:** the known read tools: `slack_search_public`, `slack_search_channels`, `slack_search_users`, `slack_read_channel`, `slack_read_thread`, `slack_read_user_profile`, `slack_read_canvas`, `slack_list_channel_members`, `slack_get_reactions`, `slack_list_user_channels`, `slack_read_list` and `slack_search_emojis`.
@@ -106,7 +117,7 @@ Do not put this block in the top-level `permissions`. OpenCode adds top-level ru
106
117
 
107
118
  ## Configure
108
119
 
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:
120
+ 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
121
 
111
122
  ```jsonc
112
123
  {
@@ -116,6 +127,14 @@ Everything works without options. Options set the client ID for everyone (instea
116
127
  }
117
128
  ```
118
129
 
130
+ **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`:
131
+
132
+ ```jsonc
133
+ "oauth": { "client_id": "YOUR_SLACK_CLIENT_ID", "redirect_uri": "https://host.example/oauth/return", "callback_port": 8766 }
134
+ ```
135
+
136
+ 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.
137
+
119
138
  [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
139
 
121
140
  ## Organisation rollout
@@ -158,6 +177,8 @@ Run `/slack-status` (or `/slack` in the TUI) first. Its `Connection` row shows a
158
177
 
159
178
  If status itself cannot be read, `/slack-status` says `STATUS_UNAVAILABLE`; run it again in a moment.
160
179
 
180
+ A script's errors from the tools RPC have their own codes, listed in [Scripting](https://github.com/joe-broadhead/opencode-slack/blob/HEAD/docs/scripting.md#errors).
181
+
161
182
  **Slack shows an error during sign-in**
162
183
 
163
184
  - **`bad_redirect_uri`, or "redirect_uri did not match":** the app does not list the callback URL exactly. Add the `Callback` URL that status shows under **OAuth & Permissions → Redirect URLs**.
@@ -165,6 +186,8 @@ If status itself cannot be read, `/slack-status` says `STATUS_UNAVAILABLE`; run
165
186
  - **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
187
  - **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
188
  - **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`.
189
+ - **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).
190
+ - **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
191
 
169
192
  **A Slack tool fails**
170
193
 
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";
@@ -41,14 +43,14 @@ async function resolveClient(integration, options) {
41
43
  return { client: { id: options.clientId, ...options.clientSecret === void 0 ? {} : { secret: options.clientSecret }, source: "options" }, invalid: false };
42
44
  }
43
45
  const integrationID = appIntegrationID(options.server);
44
- const connection2 = await integration.connection.active(integrationID);
45
- const stored = connection2 === void 0 ? void 0 : await integration.connection.resolve(connection2);
46
- if (connection2 !== void 0 && stored?.type === "key") {
46
+ const connection3 = await integration.connection.active(integrationID);
47
+ const stored = connection3 === void 0 ? void 0 : await integration.connection.resolve(connection3);
48
+ if (connection3 !== void 0 && stored?.type === "key") {
47
49
  const id = stored.key.trim();
48
50
  const valid = NO_SPACES.test(id);
49
51
  await integration.connection.status({
50
52
  integrationID,
51
- connection: connection2,
53
+ connection: connection3,
52
54
  status: valid ? void 0 : { status: "needs_auth", message: "This is not a Slack client ID: it is empty or has spaces." }
53
55
  }).catch(() => void 0);
54
56
  if (!valid) return { invalid: true };
@@ -94,13 +96,361 @@ function isTestedHost(version) {
94
96
  return compare(host, bounds.slice(0, 3)) >= 0 && compare(host, bounds.slice(3)) < 0;
95
97
  }
96
98
 
99
+ // src/host/tools-rpc.ts
100
+ import { randomUUID } from "node:crypto";
101
+ import { Rpc } from "@opencode/plugin/rpc";
102
+
103
+ // src/tools.ts
104
+ var KNOWN = {
105
+ slack_search_public: "read",
106
+ slack_search_channels: "read",
107
+ slack_search_users: "read",
108
+ slack_read_channel: "read",
109
+ slack_read_thread: "read",
110
+ slack_read_user_profile: "read",
111
+ slack_read_canvas: "read",
112
+ slack_list_channel_members: "read",
113
+ slack_get_reactions: "read",
114
+ slack_list_user_channels: "read",
115
+ slack_read_list: "read",
116
+ slack_search_emojis: "read",
117
+ slack_search_public_and_private: "private",
118
+ slack_read_file: "file",
119
+ slack_send_message: "write",
120
+ slack_send_message_draft: "write",
121
+ slack_schedule_message: "write",
122
+ slack_create_canvas: "write",
123
+ slack_update_canvas: "write",
124
+ slack_add_reaction: "write",
125
+ slack_get_file_upload_url: "write",
126
+ slack_complete_file_upload: "write",
127
+ slack_create_conversation: "write",
128
+ slack_create_list: "write",
129
+ slack_update_list: "write",
130
+ slack_add_list_record: "write",
131
+ slack_update_list_record: "write"
132
+ };
133
+ var READ_TOOLS = Object.keys(KNOWN).filter((tool) => KNOWN[tool] === "read");
134
+ var SCRIPTABLE_CLASSES = ["read", "private", "file"];
135
+ var SCRIPTABLE_TOOLS = Object.keys(KNOWN).filter((tool) => SCRIPTABLE_CLASSES.includes(KNOWN[tool]));
136
+ function classify(tool) {
137
+ return Object.hasOwn(KNOWN, tool) ? KNOWN[tool] : "unknown";
138
+ }
139
+
140
+ // src/host/tools-rpc.ts
141
+ var PLUGIN_ID = "slack.workspace";
142
+ var KNOWN_READS = SCRIPTABLE_TOOLS;
143
+ function shapeResult(_method, result) {
144
+ return result;
145
+ }
146
+ var NEXT_STEPS = {
147
+ SESSION_UNKNOWN: `Create a session that names an agent, such as POST /api/session with {"agent":"build"}, and pass its id.`,
148
+ SESSION_AGENT_REQUIRED: `Create a session that names an agent, such as POST /api/session with {"agent":"build"}, and pass its id.`,
149
+ PERMISSION_DENIED: "Add an allow rule for the tool to the session's agent or to the session, or use a session whose agent allows it.",
150
+ SERVER_CONFLICT: `Remove your own server with this plugin's server name from your OpenCode config, or set this plugin's "server" option to another name.`,
151
+ AUTH_REQUIRED: "Run /slack-status and follow its next step to sign in to Slack.",
152
+ CONNECTING: "Wait a few seconds, then call the method again.",
153
+ CONNECTION_FAILED: "Run /slack-status and follow its next step.",
154
+ DISABLED: "Enable the Slack server in /mcps, then call the method again.",
155
+ NOT_REGISTERED: "Run /slack-status and follow its next step.",
156
+ TOOL_UNAVAILABLE: "Call describe to see which methods OpenCode lists now, and run /slack-status.",
157
+ UPSTREAM_ERROR: "Check the input against the method's schema from describe, and run /slack-status if it keeps failing.",
158
+ CANCELLED: "Call the method again if you still need the result."
159
+ };
160
+ function pluginError(_error) {
161
+ return void 0;
162
+ }
163
+ var TOOLS_RPC_ID = `${PLUGIN_ID}.tools`;
164
+ var UNATTENDED = "A script cannot answer an approval prompt; this needs an allow rule for the session's agent.";
165
+ var MESSAGE_ID = "msg_script";
166
+ var UPSTREAM_LIMIT = 300;
167
+ var object = (properties, required) => ({
168
+ type: "object",
169
+ properties,
170
+ required,
171
+ additionalProperties: false
172
+ });
173
+ var STRING = { type: "string" };
174
+ var ANY_OBJECT = { type: "object" };
175
+ var NO_INPUT = object({}, []);
176
+ var FAILED = object({ code: STRING, next: STRING }, ["code", "next"]);
177
+ var mcpOutput = (output = {}) => object({ output, text: STRING }, ["text"]);
178
+ var DESCRIBED = object(
179
+ {
180
+ name: STRING,
181
+ tool: STRING,
182
+ kind: { type: "string", enum: ["mcp", "plugin"] },
183
+ action: STRING,
184
+ description: STRING,
185
+ available: { type: "boolean" },
186
+ reason: STRING,
187
+ input: ANY_OBJECT,
188
+ output: ANY_OBJECT
189
+ },
190
+ ["name", "tool", "kind", "action", "description", "available", "input", "output"]
191
+ );
192
+ var DESCRIPTION = object({ id: STRING, call: STRING, location: STRING, methods: { type: "array", items: DESCRIBED } }, [
193
+ "id",
194
+ "call",
195
+ "location",
196
+ "methods"
197
+ ]);
198
+ var plainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
199
+ function strict(schema, issues) {
200
+ const keys = Object.keys(schema["properties"]);
201
+ const validate = (value) => {
202
+ if (!plainObject(value)) return { issues: [{ message: "Expected an object" }] };
203
+ const found = [...Object.keys(value).filter((key) => !keys.includes(key)).map((key) => `"${key}" is not allowed`), ...issues(value)];
204
+ return found.length === 0 ? { value } : { issues: found.map((message) => ({ message })) };
205
+ };
206
+ return Object.defineProperty({ ...schema }, "~standard", { value: { version: 1, vendor: "tools-rpc", validate }, enumerable: false });
207
+ }
208
+ var ScriptError = class extends Error {
209
+ failure;
210
+ constructor(failure) {
211
+ super(`${failure.code}: ${failure.what.replace(/[.\s]+$/, "")}. Next step: ${failure.next}`);
212
+ this.failure = failure;
213
+ }
214
+ };
215
+ var fail = (code, what) => new ScriptError({ code, what, next: NEXT_STEPS[code] });
216
+ var record = (value) => typeof value === "object" && value !== null;
217
+ function notFound(error, tags) {
218
+ if (!record(error)) return false;
219
+ const tag = error["_tag"] ?? error["name"];
220
+ return typeof tag === "string" && tags.includes(tag) || error["status"] === 404 || error["statusCode"] === 404;
221
+ }
222
+ var toolError = (error) => record(error) && error["_tag"] === "Tool.Error";
223
+ var wildcard = (pattern, value) => new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`).test(value);
224
+ var hidden = (rules, action) => rules.findLast((rule) => wildcard(rule.action, action) && wildcard(rule.resource, "*"))?.effect === "deny";
225
+ var SECRETS = [
226
+ [/\b(bearer|basic|token)\s+[\w.~+/=-]{8,}/gi, "$1 [redacted]"],
227
+ [/\b([\w-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|token|secret|password|signature|credential)["']?\s*[:=]\s*)["']?[^\s"'&,;]+["']?/gi, "$1[redacted]"],
228
+ [/\beyJ[\w-]{4,}\.[\w-]{4,}\.[\w-]{4,}/g, "[redacted]"],
229
+ [/\b(?:tvly|sk|pk|rk|ghp|gho|ghu|ghs|ghr|github_pat|glpat|xox[abposr]|lin_api|lin_oauth)[-_][\w-]{8,}/gi, "[redacted]"],
230
+ [/[\w+=-]{40,}/g, "[redacted]"]
231
+ ];
232
+ var withoutQuery = (text2) => text2.replace(/\bhttps?:\/\/[^\s"'<>()]+/gi, (url) => {
233
+ const cut = url.search(/[?#]/);
234
+ return cut === -1 ? url : `${url.slice(0, cut)}?[redacted]`;
235
+ });
236
+ function upstreamText(text2) {
237
+ const line = text2.split(/\r?\n/).find((part) => part.trim() !== "")?.trim() ?? "no message";
238
+ const redacted = SECRETS.reduce((value, [pattern, replacement]) => value.replace(pattern, replacement), withoutQuery(line));
239
+ return redacted.length <= UPSTREAM_LIMIT ? redacted : `${redacted.slice(0, UPSTREAM_LIMIT - 1)}\u2026`;
240
+ }
241
+ var jsonSchema = (schema) => record(schema) && !("ast" in schema) && !("~standard" in schema) ? schema : void 0;
242
+ function normalise(result) {
243
+ const text2 = typeof result.content === "string" ? result.content : (result.content ?? []).flatMap((part) => part.type === "text" ? [part.text] : []).join("\n");
244
+ return result.output === void 0 ? { text: text2 } : { output: result.output, text: text2 };
245
+ }
246
+ function connection(status2) {
247
+ switch (status2) {
248
+ case "connected":
249
+ return void 0;
250
+ case "pending":
251
+ return "CONNECTING";
252
+ case "needs_auth":
253
+ return "AUTH_REQUIRED";
254
+ case "failed":
255
+ return "CONNECTION_FAILED";
256
+ case "disabled":
257
+ return "DISABLED";
258
+ default:
259
+ return "NOT_REGISTERED";
260
+ }
261
+ }
262
+ var STATUS_TEXT = {
263
+ CONNECTING: "is still connecting",
264
+ AUTH_REQUIRED: "needs a sign-in",
265
+ CONNECTION_FAILED: "could not connect",
266
+ DISABLED: "is disabled",
267
+ NOT_REGISTERED: "is not registered"
268
+ };
269
+ async function registerToolsRpc(ctx, options) {
270
+ const { mcp, owned = [] } = options;
271
+ const accounts = typeof mcp?.server === "function";
272
+ if (KNOWN_READS.length > 0 && !mcp) throw new Error("registerToolsRpc: KNOWN_READS needs the mcp option");
273
+ if (accounts && NEXT_STEPS.ACCOUNT_UNKNOWN === void 0) throw new Error("registerToolsRpc: per-account servers need NEXT_STEPS.ACCOUNT_UNKNOWN");
274
+ const names = [...KNOWN_READS, ...owned.map((method) => method.name)];
275
+ const duplicate = names.find((name, index) => name === "describe" || names.indexOf(name) !== index);
276
+ if (duplicate) throw new Error(`registerToolsRpc: the method name "${duplicate}" is taken`);
277
+ const live = /* @__PURE__ */ new Set();
278
+ const serverOf = (account) => {
279
+ const server = typeof mcp.server === "function" ? mcp.server(account) : mcp.server;
280
+ if (server !== void 0) return server;
281
+ throw new ScriptError({ code: "ACCOUNT_UNKNOWN", what: `there is no account '${account}'`, next: NEXT_STEPS.ACCOUNT_UNKNOWN });
282
+ };
283
+ const session = async (sessionID) => {
284
+ let info;
285
+ try {
286
+ info = await ctx.session.get({ sessionID });
287
+ } catch (error) {
288
+ if (notFound(error, ["Session.NotFoundError", "SessionNotFoundError"])) throw fail("SESSION_UNKNOWN", `there is no session '${sessionID}'`);
289
+ throw error;
290
+ }
291
+ if (!info.agent) throw fail("SESSION_AGENT_REQUIRED", `session '${sessionID}' names no agent`);
292
+ let agent;
293
+ try {
294
+ agent = await ctx.agent.get({ agentID: info.agent, location: info.location });
295
+ } catch (error) {
296
+ if (notFound(error, ["Agent.NotFoundError", "AgentNotFoundError"])) throw fail("SESSION_AGENT_REQUIRED", `session '${sessionID}' names the agent '${info.agent}', which does not exist`);
297
+ throw error;
298
+ }
299
+ const rules = [...agent.data.permissions, ...info.permissions ?? []];
300
+ return { agent: info.agent, rules };
301
+ };
302
+ const unavailable = async (server, tool, connected) => {
303
+ const status2 = (await ctx.mcp.list()).data.find((entry) => entry.name === server)?.status.status;
304
+ const code = connection(status2) ?? connected;
305
+ if (code === "TOOL_UNAVAILABLE") return fail(code, `OpenCode does not list ${tool} on the connected "${server}" server`);
306
+ return fail(code, `the "${server}" server ${STATUS_TEXT[code] ?? "is unavailable"}`);
307
+ };
308
+ const denied = (action, error) => {
309
+ const reason = record(error) && typeof error["message"] === "string" ? error["message"] : String(error);
310
+ return fail("PERMISSION_DENIED", `${action} was refused for this session's agent: ${upstreamText(reason)}`);
311
+ };
312
+ const mcpTarget = (name, envelope2) => {
313
+ const server = serverOf(envelope2.account);
314
+ const action = `${server}_${name}`;
315
+ return {
316
+ action,
317
+ mcp: true,
318
+ async run(call) {
319
+ if (!mcp.managed(server)) throw fail("SERVER_CONFLICT", `the "${server}" server in your config is not the one this plugin manages`);
320
+ const tool = (await ctx.tool.list()).find((entry) => entry.id === action);
321
+ if (!tool) throw await unavailable(server, action, "TOOL_UNAVAILABLE");
322
+ let result;
323
+ try {
324
+ result = await tool.execute(envelope2.input, call);
325
+ } catch (error) {
326
+ if (error instanceof Error && error.message.startsWith(`MCP server "${server}" `)) throw await unavailable(server, action, "CONNECTION_FAILED");
327
+ throw error;
328
+ }
329
+ return shapeResult(name, normalise(result));
330
+ }
331
+ };
332
+ };
333
+ const ownedTarget = (spec, envelope2) => ({
334
+ action: spec.action,
335
+ mcp: false,
336
+ run: async (call) => await spec.run(envelope2.input, call) ?? null
337
+ });
338
+ const invoke = async (target, raw, context) => {
339
+ const envelope2 = raw;
340
+ let method;
341
+ try {
342
+ if (context.signal.aborted) throw fail("CANCELLED", "the call was cancelled");
343
+ method = target(envelope2);
344
+ const { agent, rules } = await session(envelope2.sessionID);
345
+ if (hidden(rules, method.action)) throw fail("PERMISSION_DENIED", `${method.action} is hidden from this session's agent by a deny rule`);
346
+ const call = {
347
+ sessionID: envelope2.sessionID,
348
+ agent,
349
+ messageID: MESSAGE_ID,
350
+ id: `script:${randomUUID()}`,
351
+ signal: context.signal,
352
+ progress: async () => {
353
+ }
354
+ };
355
+ live.add(call.id);
356
+ try {
357
+ return JSON.parse(JSON.stringify(await method.run(call)));
358
+ } finally {
359
+ live.delete(call.id);
360
+ }
361
+ } catch (error) {
362
+ const failure = refusal(error, method, context.signal);
363
+ if (!failure) throw error;
364
+ return context.error("failed", new ScriptError(failure).message, { code: failure.code, next: failure.next });
365
+ }
366
+ };
367
+ const refusal = (error, method, signal) => {
368
+ if (signal.aborted) return fail("CANCELLED", "the call was cancelled").failure;
369
+ if (error instanceof ScriptError) return error.failure;
370
+ const own = pluginError(error);
371
+ if (own || !method || !toolError(error)) return own;
372
+ if (error.error !== void 0) return denied(method.action, error).failure;
373
+ if (method.mcp) return fail("UPSTREAM_ERROR", `${method.action} failed: ${upstreamText(error.message)}`).failure;
374
+ return void 0;
375
+ };
376
+ const describe = async () => {
377
+ const tools = mcp ? await ctx.tool.list() : [];
378
+ const methods = [];
379
+ if (mcp) {
380
+ const server = serverOf(void 0);
381
+ const managed = mcp.managed(server);
382
+ const status2 = managed ? (await ctx.mcp.list()).data.find((entry) => entry.name === server)?.status.status : void 0;
383
+ for (const name of KNOWN_READS) {
384
+ const id = `${server}_${name}`;
385
+ const tool = managed ? tools.find((entry) => entry.id === id) : void 0;
386
+ const reason = !managed ? "SERVER_CONFLICT" : tool ? void 0 : connection(status2) ?? "TOOL_UNAVAILABLE";
387
+ const input = jsonSchema(tool?.input) ?? ANY_OBJECT;
388
+ const output = jsonSchema(tool?.output);
389
+ methods.push({
390
+ name,
391
+ tool: id,
392
+ kind: "mcp",
393
+ action: id,
394
+ description: tool?.description ?? "",
395
+ available: reason === void 0,
396
+ ...reason ? { reason } : {},
397
+ input,
398
+ output: mcpOutput(output)
399
+ });
400
+ }
401
+ }
402
+ for (const spec of owned)
403
+ methods.push({ name: spec.name, tool: spec.name, kind: "plugin", action: spec.action, description: spec.description, available: true, input: spec.input, output: spec.output });
404
+ return JSON.parse(
405
+ JSON.stringify({
406
+ id: TOOLS_RPC_ID,
407
+ call: `POST /api/rpc/${TOOLS_RPC_ID}/<method> with {"input":{"sessionID":"<session>",${accounts ? `"account":"<account>",` : ""}"input":<the tool's input>}}`,
408
+ location: ctx.location.directory,
409
+ methods
410
+ })
411
+ );
412
+ };
413
+ const envelope = (input, objectInput) => strict(
414
+ object({ sessionID: { type: "string", minLength: 1 }, ...accounts ? { account: { type: "string", minLength: 1 } } : {}, input }, ["sessionID", "input"]),
415
+ (value) => [
416
+ ...typeof value["sessionID"] === "string" && value["sessionID"] !== "" ? [] : [`"sessionID" must be a non-empty string`],
417
+ ...value["account"] === void 0 || typeof value["account"] === "string" && value["account"] !== "" ? [] : [`"account" must be a non-empty string`],
418
+ ...!("input" in value) ? [`"input" is missing`] : objectInput && !plainObject(value["input"]) ? [`"input" must be an object`] : []
419
+ ]
420
+ );
421
+ const contract = Rpc.define({
422
+ id: TOOLS_RPC_ID,
423
+ methods: {
424
+ describe: { input: NO_INPUT, output: DESCRIPTION },
425
+ ...Object.fromEntries(KNOWN_READS.map((name) => [name, { input: envelope(ANY_OBJECT, true), output: mcpOutput(), errors: { failed: FAILED } }])),
426
+ ...Object.fromEntries(owned.map((spec) => [spec.name, { input: envelope(spec.input, false), output: spec.output, errors: { failed: FAILED } }]))
427
+ },
428
+ events: {}
429
+ });
430
+ const handlers = {
431
+ describe: () => describe(),
432
+ ...Object.fromEntries(KNOWN_READS.map((name) => [name, (raw, context) => invoke((envelope2) => mcpTarget(name, envelope2), raw, context)])),
433
+ ...Object.fromEntries(owned.map((spec) => [spec.name, (raw, context) => invoke((envelope2) => ownedTarget(spec, envelope2), raw, context)]))
434
+ };
435
+ const registration = await ctx.rpc.register(contract, handlers);
436
+ const hook = await ctx.permission.hook("evaluate", (evaluation) => {
437
+ if (evaluation.effect !== "ask" || evaluation.source === void 0 || !live.has(evaluation.source.id)) return;
438
+ evaluation.effect = "deny";
439
+ evaluation.message = UNATTENDED;
440
+ });
441
+ return async () => {
442
+ await hook.dispose().catch(() => void 0);
443
+ await registration.dispose().catch(() => void 0);
444
+ };
445
+ }
446
+
97
447
  // src/options.ts
98
448
  var TOP_KEYS = ["oauth", "server", "allowUntestedHost"];
99
- var OAUTH_KEYS = ["client_id", "redirect_uri", "scopes", "client_secret"];
449
+ var OAUTH_KEYS = ["client_id", "redirect_uri", "callback_port", "scopes", "client_secret"];
100
450
  var SERVER_NAME = /^[A-Za-z0-9_-]+$/;
101
451
  var NO_SPACES2 = /^\S+$/;
102
452
  var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
103
- var fail = (field, message, next) => ({ ok: false, error: { code: "OPTIONS_INVALID", field, message, next } });
453
+ var fail2 = (field, message, next) => ({ ok: false, error: { code: "OPTIONS_INVALID", field, message, next } });
104
454
  function redirectProblem(value) {
105
455
  let url;
106
456
  try {
@@ -108,62 +458,77 @@ function redirectProblem(value) {
108
458
  } catch {
109
459
  return "Must be a valid URL.";
110
460
  }
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.";
461
+ if (url.protocol !== "http:" && url.protocol !== "https:") return "Must use http: or https:.";
462
+ if (url.protocol === "http:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
463
+ return "An http: URL must use the host localhost or 127.0.0.1.";
464
+ }
465
+ if (url.protocol === "http:" && url.port === "") return "An http: URL must include an explicit port.";
114
466
  if (url.username !== "" || url.password !== "") return "Must not include credentials.";
115
467
  if (value.includes("?") || value.includes("#")) return "Must not include a query or fragment.";
116
468
  if (url.pathname === "/") return "Must include a path, such as /callback.";
117
469
  return void 0;
118
470
  }
471
+ var isPort = (value) => typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535;
119
472
  function parseOptions(input) {
120
473
  const raw = input ?? {};
121
- if (!isRecord(raw)) return fail("options", "Plugin options must be an object.", "Set options to an object, or remove them.");
474
+ if (!isRecord(raw)) return fail2("options", "Plugin options must be an object.", "Set options to an object, or remove them.");
122
475
  const unknownKey = Object.keys(raw).find((key) => !TOP_KEYS.includes(key));
123
476
  if (unknownKey !== void 0) {
124
- return fail(unknownKey, "This option is not supported.", `Remove it. Supported options: ${TOP_KEYS.join(", ")}.`);
477
+ return fail2(unknownKey, "This option is not supported.", `Remove it. Supported options: ${TOP_KEYS.join(", ")}.`);
125
478
  }
126
479
  const oauth = raw.oauth ?? {};
127
- if (!isRecord(oauth)) return fail("oauth", "Must be an object.", 'Set oauth to an object such as { "client_id": "..." }.');
480
+ if (!isRecord(oauth)) return fail2("oauth", "Must be an object.", 'Set oauth to an object such as { "client_id": "..." }.');
128
481
  const unknownOAuthKey = Object.keys(oauth).find((key) => !OAUTH_KEYS.includes(key));
129
482
  if (unknownOAuthKey !== void 0) {
130
- return fail(`oauth.${unknownOAuthKey}`, "This option is not supported.", unknownOAuthKey === "redirect_port" ? `Use oauth.redirect_uri instead, for example ${DEFAULT_REDIRECT_URI}.` : `Remove it. Supported oauth options: ${OAUTH_KEYS.join(", ")}.`);
483
+ return fail2(`oauth.${unknownOAuthKey}`, "This option is not supported.", unknownOAuthKey === "redirect_port" ? `Use oauth.redirect_uri instead, for example ${DEFAULT_REDIRECT_URI}.` : `Remove it. Supported oauth options: ${OAUTH_KEYS.join(", ")}.`);
131
484
  }
132
485
  const clientId = oauth.client_id;
133
486
  if (clientId !== void 0 && (typeof clientId !== "string" || !NO_SPACES2.test(clientId))) {
134
- return fail("oauth.client_id", "Must be a non-empty string without spaces.", "Copy the client ID from the Basic Information page of your Slack app.");
487
+ return fail2("oauth.client_id", "Must be a non-empty string without spaces.", "Copy the client ID from the Basic Information page of your Slack app.");
135
488
  }
136
489
  const redirectUri = oauth.redirect_uri ?? DEFAULT_REDIRECT_URI;
137
490
  const redirectError = typeof redirectUri === "string" ? redirectProblem(redirectUri) : "Must be a string.";
138
491
  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}.`);
492
+ return fail2("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}.`);
493
+ }
494
+ const hosted = new URL(redirectUri).protocol === "https:";
495
+ const callbackPort = oauth.callback_port;
496
+ if (callbackPort !== void 0 && !isPort(callbackPort)) {
497
+ return fail2("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}.`);
498
+ }
499
+ if (callbackPort !== void 0 && !hosted) {
500
+ return fail2("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.");
501
+ }
502
+ if (callbackPort === void 0 && hosted) {
503
+ return fail2("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
504
  }
141
505
  const scopes = oauth.scopes ?? DEFAULT_SCOPES;
142
506
  const isScope = (scope) => typeof scope === "string" && NO_SPACES2.test(scope);
143
507
  if (!Array.isArray(scopes) || scopes.length === 0 || !scopes.every(isScope)) {
144
- return fail("oauth.scopes", "Must be a non-empty list of scope names without spaces.", `List Slack user scopes, for example ${JSON.stringify(DEFAULT_SCOPES)}.`);
508
+ return fail2("oauth.scopes", "Must be a non-empty list of scope names without spaces.", `List Slack user scopes, for example ${JSON.stringify(DEFAULT_SCOPES)}.`);
145
509
  }
146
- if (new Set(scopes).size !== scopes.length) return fail("oauth.scopes", "Lists a scope more than once.", "Remove the duplicate scope.");
510
+ if (new Set(scopes).size !== scopes.length) return fail2("oauth.scopes", "Lists a scope more than once.", "Remove the duplicate scope.");
147
511
  const clientSecret = oauth.client_secret;
148
512
  if (clientSecret !== void 0 && (typeof clientSecret !== "string" || clientSecret === "")) {
149
- return fail("oauth.client_secret", "Must be a non-empty string.", "Set it to {env:SLACK_CLIENT_SECRET} and export that variable, or remove it to use PKCE.");
513
+ return fail2("oauth.client_secret", "Must be a non-empty string.", "Set it to {env:SLACK_CLIENT_SECRET} and export that variable, or remove it to use PKCE.");
150
514
  }
151
515
  if (clientSecret !== void 0 && clientId === void 0) {
152
- return fail("oauth.client_secret", "Needs oauth.client_id.", "Set oauth.client_id to your Slack app's client ID, or remove oauth.client_secret.");
516
+ return fail2("oauth.client_secret", "Needs oauth.client_id.", "Set oauth.client_id to your Slack app's client ID, or remove oauth.client_secret.");
153
517
  }
154
518
  const server = raw.server ?? DEFAULT_SERVER;
155
519
  if (typeof server !== "string" || !SERVER_NAME.test(server)) {
156
- return fail("server", "Must contain only letters, digits, _ and -.", `Use a name such as "${DEFAULT_SERVER}".`);
520
+ return fail2("server", "Must contain only letters, digits, _ and -.", `Use a name such as "${DEFAULT_SERVER}".`);
157
521
  }
158
522
  const allowUntestedHost = raw.allowUntestedHost ?? false;
159
523
  if (typeof allowUntestedHost !== "boolean") {
160
- return fail("allowUntestedHost", "Must be true or false.", "Set it to true only to run on an OpenCode version this plugin is not tested with.");
524
+ return fail2("allowUntestedHost", "Must be true or false.", "Set it to true only to run on an OpenCode version this plugin is not tested with.");
161
525
  }
162
526
  return {
163
527
  ok: true,
164
528
  options: {
165
529
  ...clientId === void 0 ? {} : { clientId },
166
530
  redirectUri,
531
+ ...callbackPort === void 0 ? {} : { callbackPort },
167
532
  scopes: [...scopes],
168
533
  ...clientSecret === void 0 ? {} : { clientSecret },
169
534
  server,
@@ -172,41 +537,6 @@ function parseOptions(input) {
172
537
  };
173
538
  }
174
539
 
175
- // src/tools.ts
176
- var KNOWN = {
177
- slack_search_public: "read",
178
- slack_search_channels: "read",
179
- slack_search_users: "read",
180
- slack_read_channel: "read",
181
- slack_read_thread: "read",
182
- slack_read_user_profile: "read",
183
- slack_read_canvas: "read",
184
- slack_list_channel_members: "read",
185
- slack_get_reactions: "read",
186
- slack_list_user_channels: "read",
187
- slack_read_list: "read",
188
- slack_search_emojis: "read",
189
- slack_search_public_and_private: "private",
190
- slack_read_file: "file",
191
- slack_send_message: "write",
192
- slack_send_message_draft: "write",
193
- slack_schedule_message: "write",
194
- slack_create_canvas: "write",
195
- slack_update_canvas: "write",
196
- slack_add_reaction: "write",
197
- slack_get_file_upload_url: "write",
198
- slack_complete_file_upload: "write",
199
- slack_create_conversation: "write",
200
- slack_create_list: "write",
201
- slack_update_list: "write",
202
- slack_add_list_record: "write",
203
- slack_update_list_record: "write"
204
- };
205
- var READ_TOOLS = Object.keys(KNOWN).filter((tool) => KNOWN[tool] === "read");
206
- function classify(tool) {
207
- return Object.hasOwn(KNOWN, tool) ? KNOWN[tool] : "unknown";
208
- }
209
-
210
540
  // src/permissions.ts
211
541
  function defaultRules(server) {
212
542
  return [
@@ -233,11 +563,11 @@ function insertDefaultRules(editor, server) {
233
563
  }
234
564
 
235
565
  // src/rpc.ts
236
- import { Rpc } from "@opencode/plugin/rpc";
566
+ import { Rpc as Rpc2 } from "@opencode/plugin/rpc";
237
567
  var text = { type: "string" };
238
568
  var texts = { type: "array", items: text };
239
- var object = (properties, required) => ({ type: "object", properties, required, additionalProperties: false });
240
- var status = object({
569
+ var object2 = (properties, required) => ({ type: "object", properties, required, additionalProperties: false });
570
+ var status = object2({
241
571
  state: { enum: ["connected", "not_signed_in", "connecting", "disabled", "failed", "not_registered", "no_client_id", "config_invalid", "host_untested"] },
242
572
  code: {
243
573
  enum: ["OPTIONS_INVALID", "SERVER_NAME_IN_USE", "HOST_UNTESTED", "CLIENT_ID_MISSING", "CLIENT_ID_INVALID", "AUTH_REQUIRED", "CONNECTION_FAILED", "SERVER_DISABLED"]
@@ -245,12 +575,13 @@ var status = object({
245
575
  message: text,
246
576
  detail: text,
247
577
  next: text,
248
- server: object({ name: text, url: text }, ["name", "url"]),
249
- client: object({ id: text, mode: { enum: ["public_pkce", "confidential"] }, source: { enum: ["options", "connect"] } }, ["id", "mode", "source"]),
578
+ server: object2({ name: text, url: text }, ["name", "url"]),
579
+ client: object2({ id: text, mode: { enum: ["public_pkce", "confidential"] }, source: { enum: ["options", "connect"] } }, ["id", "mode", "source"]),
250
580
  callback: text,
581
+ callbackPort: { type: "integer" },
251
582
  scopes: texts,
252
583
  asksBefore: texts,
253
- tools: object({
584
+ tools: object2({
254
585
  read: { type: "integer" },
255
586
  private: { type: "integer" },
256
587
  file: { type: "integer" },
@@ -258,13 +589,13 @@ var status = object({
258
589
  unknown: texts
259
590
  }, ["read", "private", "file", "write", "unknown"]),
260
591
  unguardedAgents: texts,
261
- host: object({ version: text, range: text, tested: { type: "boolean" } }, ["version", "range", "tested"])
592
+ host: object2({ version: text, range: text, tested: { type: "boolean" } }, ["version", "range", "tested"])
262
593
  }, ["state", "message", "next", "server", "asksBefore", "tools", "unguardedAgents", "host"]);
263
- var SlackRpc = Rpc.define({
594
+ var SlackRpc = Rpc2.define({
264
595
  id: "slack.workspace",
265
596
  methods: {
266
597
  status: {
267
- input: object({}, []),
598
+ input: object2({}, []),
268
599
  output: status,
269
600
  errors: { unavailable: {} }
270
601
  }
@@ -280,6 +611,7 @@ function serverConfig(options, client) {
280
611
  oauth: {
281
612
  client_id: client.id,
282
613
  redirect_uri: options.redirectUri,
614
+ ...options.callbackPort === void 0 ? {} : { callback_port: options.callbackPort },
283
615
  scope: options.scopes.join(" "),
284
616
  ...client.secret === void 0 ? {} : { client_secret: client.secret }
285
617
  }
@@ -348,7 +680,7 @@ var unknownAction = (server) => `${server}_.`;
348
680
  var NOTHING_TO_DO = "Nothing to do.";
349
681
  var quote = (rule) => `{ "action": "${rule.action}", "resource": "${rule.resource}", "effect": "${rule.effect}" }`;
350
682
  var remedy = (server) => `[${defaultRules(server).map(quote).join(", ")}]`;
351
- function connection(server, status2, unguarded, toolCount) {
683
+ function connection2(server, status2, unguarded, toolCount) {
352
684
  if (status2 === void 0) {
353
685
  return { state: "not_registered", message: `The ${server} server is not registered.`, next: "Run opencode reload, then check again." };
354
686
  }
@@ -402,6 +734,7 @@ function buildStatus(input) {
402
734
  client: { id: client.id, mode: client.secret === void 0 ? "public_pkce" : "confidential", source: client.source }
403
735
  },
404
736
  callback: options.redirectUri,
737
+ ...options.callbackPort === void 0 ? {} : { callbackPort: options.callbackPort },
405
738
  scopes: options.scopes
406
739
  },
407
740
  asksBefore: ASKS_BEFORE,
@@ -441,7 +774,12 @@ function buildStatus(input) {
441
774
  };
442
775
  }
443
776
  const status2 = input.servers.find((item) => item.name === server)?.status;
444
- return { ...base, ...connection(server, status2, unguardedAgents, names.length) };
777
+ return { ...base, ...connection2(server, status2, unguardedAgents, names.length) };
778
+ }
779
+ function callbackRow(callback, port) {
780
+ const register = "register exactly this URL in the app's Redirect URLs";
781
+ if (port === void 0) return `${callback} (${register})`;
782
+ return `${callback} (${register}, and forward it to http://127.0.0.1:${port}${new URL(callback).pathname}, where OpenCode listens)`;
445
783
  }
446
784
  var CLIENT_SOURCE = { options: "set in plugin options", connect: "saved in /connect" };
447
785
  function stateLabel(status2) {
@@ -477,7 +815,7 @@ function statusRows(status2) {
477
815
  const mode = status2.client.mode === "public_pkce" ? "public, PKCE" : "confidential, secret set";
478
816
  rows.push({ label: "Client", value: `${status2.client.id} (${mode}; ${CLIENT_SOURCE[status2.client.source]})` });
479
817
  }
480
- if (status2.callback !== void 0) rows.push({ label: "Callback", value: `${status2.callback} (register exactly this URL in the Slack app)` });
818
+ if (status2.callback !== void 0) rows.push({ label: "Callback", value: callbackRow(status2.callback, status2.callbackPort) });
481
819
  if (status2.scopes !== void 0) rows.push({ label: "Scopes", value: status2.scopes.join(" ") });
482
820
  rows.push({
483
821
  label: "Tools",
@@ -600,6 +938,8 @@ var src_default = Plugin.define({
600
938
  }
601
939
  }
602
940
  });
941
+ const stopTools = options === void 0 ? async () => {
942
+ } : await registerToolsRpc(ctx, { mcp: { server: options.server, managed: () => !nameInUse } });
603
943
  await ctx.command.transform((editor) => {
604
944
  editor.add({
605
945
  name: "slack-status",
@@ -613,6 +953,7 @@ var src_default = Plugin.define({
613
953
  return async () => {
614
954
  stopped.abort();
615
955
  await cleanup();
956
+ await stopTools();
616
957
  };
617
958
  }
618
959
  });
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({
@@ -85,6 +86,8 @@ var KNOWN = {
85
86
  slack_update_list_record: "write"
86
87
  };
87
88
  var READ_TOOLS = Object.keys(KNOWN).filter((tool) => KNOWN[tool] === "read");
89
+ var SCRIPTABLE_CLASSES = ["read", "private", "file"];
90
+ var SCRIPTABLE_TOOLS = Object.keys(KNOWN).filter((tool) => SCRIPTABLE_CLASSES.includes(KNOWN[tool]));
88
91
 
89
92
  // src/permissions.ts
90
93
  function defaultRules(server) {
@@ -97,6 +100,11 @@ function defaultRules(server) {
97
100
  // src/status.ts
98
101
  var quote = (rule) => `{ "action": "${rule.action}", "resource": "${rule.resource}", "effect": "${rule.effect}" }`;
99
102
  var remedy = (server) => `[${defaultRules(server).map(quote).join(", ")}]`;
103
+ function callbackRow(callback, port) {
104
+ const register = "register exactly this URL in the app's Redirect URLs";
105
+ if (port === void 0) return `${callback} (${register})`;
106
+ return `${callback} (${register}, and forward it to http://127.0.0.1:${port}${new URL(callback).pathname}, where OpenCode listens)`;
107
+ }
100
108
  var CLIENT_SOURCE = { options: "set in plugin options", connect: "saved in /connect" };
101
109
  function stateLabel(status2) {
102
110
  switch (status2.state) {
@@ -131,7 +139,7 @@ function statusRows(status2) {
131
139
  const mode = status2.client.mode === "public_pkce" ? "public, PKCE" : "confidential, secret set";
132
140
  rows.push({ label: "Client", value: `${status2.client.id} (${mode}; ${CLIENT_SOURCE[status2.client.source]})` });
133
141
  }
134
- if (status2.callback !== void 0) rows.push({ label: "Callback", value: `${status2.callback} (register exactly this URL in the Slack app)` });
142
+ if (status2.callback !== void 0) rows.push({ label: "Callback", value: callbackRow(status2.callback, status2.callbackPort) });
135
143
  if (status2.scopes !== void 0) rows.push({ label: "Scopes", value: status2.scopes.join(" ") });
136
144
  rows.push({
137
145
  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.2",
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
  }
@@ -23,6 +23,10 @@ If the Slack tools are not in the listed catalog, find them with `search({ names
23
23
 
24
24
  The parameters below are the common ones. The schema that Slack serves for each tool is the source of truth; follow it if it differs.
25
25
 
26
+ ## Scripts
27
+
28
+ When the user wants a job that runs without you, such as a cron job or a shell script, it can call the read tools through the OpenCode server, with no model. The RPC `slack.workspace.tools` has one method per read tool, named as the tool (for example `slack_search_public` or `slack_read_thread`), with the tool's own input, and `describe`, which lists them with their input schemas. Each call is `opencode api post /api/rpc/slack.workspace.tools/<method>` with the body `{"input":{"sessionID":"<session>","input":<the tool's input>}}`. The script runs as that session's agent, under its permission rules, so have the script create its session once with `opencode api post /api/session` and a body such as `{"title":"release digest","agent":"build"}`. An ask is refused, so `slack_search_public_and_private` and `slack_read_file` need an allow rule in the agent's or the session's `permissions`. Scripts cannot send or change anything in Slack. The details are in the plugin's `docs/scripting.md`.
29
+
26
30
  ## 1. Find
27
31
 
28
32
  | Need | Tool | Main parameters |