@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 +19 -0
- package/README.md +25 -2
- package/dist/index.js +409 -68
- package/dist/tui.js +9 -1
- package/package.json +197 -1
- package/skills/slack-search/SKILL.md +4 -0
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
|
|
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
|
|
45
|
-
const stored =
|
|
46
|
-
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") {
|
|
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:
|
|
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
|
|
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")
|
|
113
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
240
|
-
var status =
|
|
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:
|
|
249
|
-
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"]),
|
|
250
580
|
callback: text,
|
|
581
|
+
callbackPort: { type: "integer" },
|
|
251
582
|
scopes: texts,
|
|
252
583
|
asksBefore: texts,
|
|
253
|
-
tools:
|
|
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:
|
|
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 =
|
|
594
|
+
var SlackRpc = Rpc2.define({
|
|
264
595
|
id: "slack.workspace",
|
|
265
596
|
methods: {
|
|
266
597
|
status: {
|
|
267
|
-
input:
|
|
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
|
|
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, ...
|
|
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:
|
|
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:
|
|
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.
|
|
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 |
|