@joe-broadhead/opencode-slack 0.0.1 → 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,12 @@
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
+
5
11
  ## [0.0.1] - 2026-11-03
6
12
 
7
13
  ### Added
package/README.md CHANGED
@@ -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`.
@@ -166,6 +177,8 @@ Run `/slack-status` (or `/slack` in the TUI) first. Its `Connection` row shows a
166
177
 
167
178
  If status itself cannot be read, `/slack-status` says `STATUS_UNAVAILABLE`; run it again in a moment.
168
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
+
169
182
  **Slack shows an error during sign-in**
170
183
 
171
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**.
package/dist/index.js CHANGED
@@ -43,14 +43,14 @@ async function resolveClient(integration, options) {
43
43
  return { client: { id: options.clientId, ...options.clientSecret === void 0 ? {} : { secret: options.clientSecret }, source: "options" }, invalid: false };
44
44
  }
45
45
  const integrationID = appIntegrationID(options.server);
46
- const connection2 = await integration.connection.active(integrationID);
47
- const stored = connection2 === void 0 ? void 0 : await integration.connection.resolve(connection2);
48
- 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") {
49
49
  const id = stored.key.trim();
50
50
  const valid = NO_SPACES.test(id);
51
51
  await integration.connection.status({
52
52
  integrationID,
53
- connection: connection2,
53
+ connection: connection3,
54
54
  status: valid ? void 0 : { status: "needs_auth", message: "This is not a Slack client ID: it is empty or has spaces." }
55
55
  }).catch(() => void 0);
56
56
  if (!valid) return { invalid: true };
@@ -96,13 +96,361 @@ function isTestedHost(version) {
96
96
  return compare(host, bounds.slice(0, 3)) >= 0 && compare(host, bounds.slice(3)) < 0;
97
97
  }
98
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
+
99
447
  // src/options.ts
100
448
  var TOP_KEYS = ["oauth", "server", "allowUntestedHost"];
101
449
  var OAUTH_KEYS = ["client_id", "redirect_uri", "callback_port", "scopes", "client_secret"];
102
450
  var SERVER_NAME = /^[A-Za-z0-9_-]+$/;
103
451
  var NO_SPACES2 = /^\S+$/;
104
452
  var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
105
- 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 } });
106
454
  function redirectProblem(value) {
107
455
  let url;
108
456
  try {
@@ -123,57 +471,57 @@ function redirectProblem(value) {
123
471
  var isPort = (value) => typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535;
124
472
  function parseOptions(input) {
125
473
  const raw = input ?? {};
126
- 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.");
127
475
  const unknownKey = Object.keys(raw).find((key) => !TOP_KEYS.includes(key));
128
476
  if (unknownKey !== void 0) {
129
- 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(", ")}.`);
130
478
  }
131
479
  const oauth = raw.oauth ?? {};
132
- 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": "..." }.');
133
481
  const unknownOAuthKey = Object.keys(oauth).find((key) => !OAUTH_KEYS.includes(key));
134
482
  if (unknownOAuthKey !== void 0) {
135
- 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(", ")}.`);
136
484
  }
137
485
  const clientId = oauth.client_id;
138
486
  if (clientId !== void 0 && (typeof clientId !== "string" || !NO_SPACES2.test(clientId))) {
139
- 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.");
140
488
  }
141
489
  const redirectUri = oauth.redirect_uri ?? DEFAULT_REDIRECT_URI;
142
490
  const redirectError = typeof redirectUri === "string" ? redirectProblem(redirectUri) : "Must be a string.";
143
491
  if (typeof redirectUri !== "string" || redirectError !== void 0) {
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}.`);
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}.`);
145
493
  }
146
494
  const hosted = new URL(redirectUri).protocol === "https:";
147
495
  const callbackPort = oauth.callback_port;
148
496
  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}.`);
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}.`);
150
498
  }
151
499
  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.");
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.");
153
501
  }
154
502
  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}.`);
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}.`);
156
504
  }
157
505
  const scopes = oauth.scopes ?? DEFAULT_SCOPES;
158
506
  const isScope = (scope) => typeof scope === "string" && NO_SPACES2.test(scope);
159
507
  if (!Array.isArray(scopes) || scopes.length === 0 || !scopes.every(isScope)) {
160
- 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)}.`);
161
509
  }
162
- 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.");
163
511
  const clientSecret = oauth.client_secret;
164
512
  if (clientSecret !== void 0 && (typeof clientSecret !== "string" || clientSecret === "")) {
165
- 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.");
166
514
  }
167
515
  if (clientSecret !== void 0 && clientId === void 0) {
168
- 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.");
169
517
  }
170
518
  const server = raw.server ?? DEFAULT_SERVER;
171
519
  if (typeof server !== "string" || !SERVER_NAME.test(server)) {
172
- 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}".`);
173
521
  }
174
522
  const allowUntestedHost = raw.allowUntestedHost ?? false;
175
523
  if (typeof allowUntestedHost !== "boolean") {
176
- 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.");
177
525
  }
178
526
  return {
179
527
  ok: true,
@@ -189,41 +537,6 @@ function parseOptions(input) {
189
537
  };
190
538
  }
191
539
 
192
- // src/tools.ts
193
- var KNOWN = {
194
- slack_search_public: "read",
195
- slack_search_channels: "read",
196
- slack_search_users: "read",
197
- slack_read_channel: "read",
198
- slack_read_thread: "read",
199
- slack_read_user_profile: "read",
200
- slack_read_canvas: "read",
201
- slack_list_channel_members: "read",
202
- slack_get_reactions: "read",
203
- slack_list_user_channels: "read",
204
- slack_read_list: "read",
205
- slack_search_emojis: "read",
206
- slack_search_public_and_private: "private",
207
- slack_read_file: "file",
208
- slack_send_message: "write",
209
- slack_send_message_draft: "write",
210
- slack_schedule_message: "write",
211
- slack_create_canvas: "write",
212
- slack_update_canvas: "write",
213
- slack_add_reaction: "write",
214
- slack_get_file_upload_url: "write",
215
- slack_complete_file_upload: "write",
216
- slack_create_conversation: "write",
217
- slack_create_list: "write",
218
- slack_update_list: "write",
219
- slack_add_list_record: "write",
220
- slack_update_list_record: "write"
221
- };
222
- var READ_TOOLS = Object.keys(KNOWN).filter((tool) => KNOWN[tool] === "read");
223
- function classify(tool) {
224
- return Object.hasOwn(KNOWN, tool) ? KNOWN[tool] : "unknown";
225
- }
226
-
227
540
  // src/permissions.ts
228
541
  function defaultRules(server) {
229
542
  return [
@@ -250,11 +563,11 @@ function insertDefaultRules(editor, server) {
250
563
  }
251
564
 
252
565
  // src/rpc.ts
253
- import { Rpc } from "@opencode/plugin/rpc";
566
+ import { Rpc as Rpc2 } from "@opencode/plugin/rpc";
254
567
  var text = { type: "string" };
255
568
  var texts = { type: "array", items: text };
256
- var object = (properties, required) => ({ type: "object", properties, required, additionalProperties: false });
257
- var status = object({
569
+ var object2 = (properties, required) => ({ type: "object", properties, required, additionalProperties: false });
570
+ var status = object2({
258
571
  state: { enum: ["connected", "not_signed_in", "connecting", "disabled", "failed", "not_registered", "no_client_id", "config_invalid", "host_untested"] },
259
572
  code: {
260
573
  enum: ["OPTIONS_INVALID", "SERVER_NAME_IN_USE", "HOST_UNTESTED", "CLIENT_ID_MISSING", "CLIENT_ID_INVALID", "AUTH_REQUIRED", "CONNECTION_FAILED", "SERVER_DISABLED"]
@@ -262,13 +575,13 @@ var status = object({
262
575
  message: text,
263
576
  detail: text,
264
577
  next: text,
265
- server: object({ name: text, url: text }, ["name", "url"]),
266
- 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"]),
267
580
  callback: text,
268
581
  callbackPort: { type: "integer" },
269
582
  scopes: texts,
270
583
  asksBefore: texts,
271
- tools: object({
584
+ tools: object2({
272
585
  read: { type: "integer" },
273
586
  private: { type: "integer" },
274
587
  file: { type: "integer" },
@@ -276,13 +589,13 @@ var status = object({
276
589
  unknown: texts
277
590
  }, ["read", "private", "file", "write", "unknown"]),
278
591
  unguardedAgents: texts,
279
- 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"])
280
593
  }, ["state", "message", "next", "server", "asksBefore", "tools", "unguardedAgents", "host"]);
281
- var SlackRpc = Rpc.define({
594
+ var SlackRpc = Rpc2.define({
282
595
  id: "slack.workspace",
283
596
  methods: {
284
597
  status: {
285
- input: object({}, []),
598
+ input: object2({}, []),
286
599
  output: status,
287
600
  errors: { unavailable: {} }
288
601
  }
@@ -367,7 +680,7 @@ var unknownAction = (server) => `${server}_.`;
367
680
  var NOTHING_TO_DO = "Nothing to do.";
368
681
  var quote = (rule) => `{ "action": "${rule.action}", "resource": "${rule.resource}", "effect": "${rule.effect}" }`;
369
682
  var remedy = (server) => `[${defaultRules(server).map(quote).join(", ")}]`;
370
- function connection(server, status2, unguarded, toolCount) {
683
+ function connection2(server, status2, unguarded, toolCount) {
371
684
  if (status2 === void 0) {
372
685
  return { state: "not_registered", message: `The ${server} server is not registered.`, next: "Run opencode reload, then check again." };
373
686
  }
@@ -461,7 +774,7 @@ function buildStatus(input) {
461
774
  };
462
775
  }
463
776
  const status2 = input.servers.find((item) => item.name === server)?.status;
464
- return { ...base, ...connection(server, status2, unguardedAgents, names.length) };
777
+ return { ...base, ...connection2(server, status2, unguardedAgents, names.length) };
465
778
  }
466
779
  function callbackRow(callback, port) {
467
780
  const register = "register exactly this URL in the app's Redirect URLs";
@@ -625,6 +938,8 @@ var src_default = Plugin.define({
625
938
  }
626
939
  }
627
940
  });
941
+ const stopTools = options === void 0 ? async () => {
942
+ } : await registerToolsRpc(ctx, { mcp: { server: options.server, managed: () => !nameInUse } });
628
943
  await ctx.command.transform((editor) => {
629
944
  editor.add({
630
945
  name: "slack-status",
@@ -638,6 +953,7 @@ var src_default = Plugin.define({
638
953
  return async () => {
639
954
  stopped.abort();
640
955
  await cleanup();
956
+ await stopTools();
641
957
  };
642
958
  }
643
959
  });
package/dist/tui.js CHANGED
@@ -86,6 +86,8 @@ var KNOWN = {
86
86
  slack_update_list_record: "write"
87
87
  };
88
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]));
89
91
 
90
92
  // src/permissions.ts
91
93
  function defaultRules(server) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joe-broadhead/opencode-slack",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Unofficial OpenCode plugin for Slack",
5
5
  "keywords": [
6
6
  "opencode",
@@ -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 |