@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 +6 -0
- package/README.md +13 -0
- package/dist/index.js +382 -66
- package/dist/tui.js +2 -0
- package/package.json +1 -1
- package/skills/slack-search/SKILL.md +4 -0
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
|
|
47
|
-
const stored =
|
|
48
|
-
if (
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
257
|
-
var status =
|
|
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:
|
|
266
|
-
client:
|
|
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:
|
|
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:
|
|
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 =
|
|
594
|
+
var SlackRpc = Rpc2.define({
|
|
282
595
|
id: "slack.workspace",
|
|
283
596
|
methods: {
|
|
284
597
|
status: {
|
|
285
|
-
input:
|
|
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
|
|
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, ...
|
|
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
|
@@ -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 |
|