@joe-broadhead/opencode-slack 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,646 @@
1
+ // src/index.ts
2
+ import { Plugin } from "@opencode/plugin";
3
+
4
+ // src/defaults.ts
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;
8
+ var DEFAULT_SCOPES = ["channels:history", "channels:read", "search:read.public", "users:read"];
9
+ var DEFAULT_SERVER = "slack";
10
+ var APP_MANIFEST_URL = "https://github.com/joe-broadhead/opencode-slack/blob/HEAD/docs/slack-app-manifest.json";
11
+ var SLACK_MCP_URL = "https://mcp.slack.com/mcp";
12
+ var TESTED_HOST_RANGE = ">=2.2.3 <2.3.0";
13
+
14
+ // src/connect.ts
15
+ var NO_SPACES = /^\S+$/;
16
+ var appIntegrationID = (server) => `${server}-app`;
17
+ var appDisplayName = (server) => server === DEFAULT_SERVER ? "Slack app" : `Slack app (${server})`;
18
+ var serverDisplayName = (server) => server === DEFAULT_SERVER ? "Slack" : `Slack (${server})`;
19
+ function registerAppIntegration(integration, server) {
20
+ const id = appIntegrationID(server);
21
+ return integration.transform((editor) => {
22
+ editor.update(id, (draft) => {
23
+ draft.name = appDisplayName(server);
24
+ });
25
+ editor.method.update({
26
+ integrationID: id,
27
+ method: {
28
+ type: "key",
29
+ label: "Slack app client ID",
30
+ form: [{
31
+ key: "client_secret",
32
+ type: "string",
33
+ title: "Client secret",
34
+ description: "Only for a Slack app without PKCE. Skip it for an app made from the plugin's manifest.",
35
+ required: false
36
+ }]
37
+ }
38
+ });
39
+ });
40
+ }
41
+ async function resolveClient(integration, options) {
42
+ if (options.clientId !== void 0) {
43
+ return { client: { id: options.clientId, ...options.clientSecret === void 0 ? {} : { secret: options.clientSecret }, source: "options" }, invalid: false };
44
+ }
45
+ const integrationID = appIntegrationID(options.server);
46
+ const connection2 = await integration.connection.active(integrationID);
47
+ const stored = connection2 === void 0 ? void 0 : await integration.connection.resolve(connection2);
48
+ if (connection2 !== void 0 && stored?.type === "key") {
49
+ const id = stored.key.trim();
50
+ const valid = NO_SPACES.test(id);
51
+ await integration.connection.status({
52
+ integrationID,
53
+ connection: connection2,
54
+ status: valid ? void 0 : { status: "needs_auth", message: "This is not a Slack client ID: it is empty or has spaces." }
55
+ }).catch(() => void 0);
56
+ if (!valid) return { invalid: true };
57
+ const secret = typeof stored.metadata?.client_secret === "string" ? stored.metadata.client_secret.trim() : "";
58
+ return { client: { id, ...secret === "" ? {} : { secret }, source: "connect" }, invalid: false };
59
+ }
60
+ return { invalid: false };
61
+ }
62
+ var sameClient = (a, b) => JSON.stringify(a) === JSON.stringify(b);
63
+ function serverNamer(ctx, server, owned) {
64
+ let registration;
65
+ return {
66
+ /** True once the integration carries the display name. */
67
+ async apply() {
68
+ if (!owned()) return false;
69
+ const id = (await ctx.mcp.list()).data.find((item) => item.name === server)?.integrationID;
70
+ if (id === void 0) return false;
71
+ const name = serverDisplayName(server);
72
+ if ((await ctx.integration.get({ integrationID: id })).data.name === name) return true;
73
+ const previous = registration;
74
+ registration = await ctx.integration.transform((editor) => {
75
+ if (editor.get(id) !== void 0) editor.update(id, (draft) => void (draft.name = name));
76
+ });
77
+ await previous?.dispose();
78
+ return false;
79
+ },
80
+ async dispose() {
81
+ await registration?.dispose();
82
+ registration = void 0;
83
+ }
84
+ };
85
+ }
86
+
87
+ // src/host-version.ts
88
+ var RELEASE = /^(\d+)\.(\d+)\.(\d+)$/;
89
+ var RANGE = /^>=(\d+)\.(\d+)\.(\d+) <(\d+)\.(\d+)\.(\d+)$/;
90
+ var compare = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
91
+ function isTestedHost(version) {
92
+ const found = RELEASE.exec(version);
93
+ if (!found) return false;
94
+ const bounds = RANGE.exec(TESTED_HOST_RANGE).slice(1).map(Number);
95
+ const host = found.slice(1).map(Number);
96
+ return compare(host, bounds.slice(0, 3)) >= 0 && compare(host, bounds.slice(3)) < 0;
97
+ }
98
+
99
+ // src/options.ts
100
+ var TOP_KEYS = ["oauth", "server", "allowUntestedHost"];
101
+ var OAUTH_KEYS = ["client_id", "redirect_uri", "callback_port", "scopes", "client_secret"];
102
+ var SERVER_NAME = /^[A-Za-z0-9_-]+$/;
103
+ var NO_SPACES2 = /^\S+$/;
104
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
105
+ var fail = (field, message, next) => ({ ok: false, error: { code: "OPTIONS_INVALID", field, message, next } });
106
+ function redirectProblem(value) {
107
+ let url;
108
+ try {
109
+ url = new URL(value);
110
+ } catch {
111
+ return "Must be a valid URL.";
112
+ }
113
+ if (url.protocol !== "http:" && url.protocol !== "https:") return "Must use http: or https:.";
114
+ if (url.protocol === "http:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
115
+ return "An http: URL must use the host localhost or 127.0.0.1.";
116
+ }
117
+ if (url.protocol === "http:" && url.port === "") return "An http: URL must include an explicit port.";
118
+ if (url.username !== "" || url.password !== "") return "Must not include credentials.";
119
+ if (value.includes("?") || value.includes("#")) return "Must not include a query or fragment.";
120
+ if (url.pathname === "/") return "Must include a path, such as /callback.";
121
+ return void 0;
122
+ }
123
+ var isPort = (value) => typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535;
124
+ function parseOptions(input) {
125
+ const raw = input ?? {};
126
+ if (!isRecord(raw)) return fail("options", "Plugin options must be an object.", "Set options to an object, or remove them.");
127
+ const unknownKey = Object.keys(raw).find((key) => !TOP_KEYS.includes(key));
128
+ if (unknownKey !== void 0) {
129
+ return fail(unknownKey, "This option is not supported.", `Remove it. Supported options: ${TOP_KEYS.join(", ")}.`);
130
+ }
131
+ const oauth = raw.oauth ?? {};
132
+ if (!isRecord(oauth)) return fail("oauth", "Must be an object.", 'Set oauth to an object such as { "client_id": "..." }.');
133
+ const unknownOAuthKey = Object.keys(oauth).find((key) => !OAUTH_KEYS.includes(key));
134
+ if (unknownOAuthKey !== void 0) {
135
+ return fail(`oauth.${unknownOAuthKey}`, "This option is not supported.", unknownOAuthKey === "redirect_port" ? `Use oauth.redirect_uri instead, for example ${DEFAULT_REDIRECT_URI}.` : `Remove it. Supported oauth options: ${OAUTH_KEYS.join(", ")}.`);
136
+ }
137
+ const clientId = oauth.client_id;
138
+ if (clientId !== void 0 && (typeof clientId !== "string" || !NO_SPACES2.test(clientId))) {
139
+ return fail("oauth.client_id", "Must be a non-empty string without spaces.", "Copy the client ID from the Basic Information page of your Slack app.");
140
+ }
141
+ const redirectUri = oauth.redirect_uri ?? DEFAULT_REDIRECT_URI;
142
+ const redirectError = typeof redirectUri === "string" ? redirectProblem(redirectUri) : "Must be a string.";
143
+ if (typeof redirectUri !== "string" || redirectError !== void 0) {
144
+ return fail("oauth.redirect_uri", redirectError ?? "Must be a string.", `Use a URL listed in the app's Redirect URLs, for example ${DEFAULT_REDIRECT_URI} or ${HOSTED_REDIRECT_EXAMPLE}.`);
145
+ }
146
+ const hosted = new URL(redirectUri).protocol === "https:";
147
+ const callbackPort = oauth.callback_port;
148
+ if (callbackPort !== void 0 && !isPort(callbackPort)) {
149
+ return fail("oauth.callback_port", "Must be a whole number from 1 to 65535.", `Set it to the local port that receives the forwarded callback, for example ${CALLBACK_PORT_EXAMPLE}.`);
150
+ }
151
+ if (callbackPort !== void 0 && !hosted) {
152
+ return fail("oauth.callback_port", "Applies only to an https: oauth.redirect_uri.", "Remove it: with a loopback oauth.redirect_uri, OpenCode listens on that URL's port.");
153
+ }
154
+ if (callbackPort === void 0 && hosted) {
155
+ return fail("oauth.callback_port", "Must be set with an https: oauth.redirect_uri.", `Set it to the local port your proxy forwards the redirect to, for example ${CALLBACK_PORT_EXAMPLE}.`);
156
+ }
157
+ const scopes = oauth.scopes ?? DEFAULT_SCOPES;
158
+ const isScope = (scope) => typeof scope === "string" && NO_SPACES2.test(scope);
159
+ if (!Array.isArray(scopes) || scopes.length === 0 || !scopes.every(isScope)) {
160
+ return fail("oauth.scopes", "Must be a non-empty list of scope names without spaces.", `List Slack user scopes, for example ${JSON.stringify(DEFAULT_SCOPES)}.`);
161
+ }
162
+ if (new Set(scopes).size !== scopes.length) return fail("oauth.scopes", "Lists a scope more than once.", "Remove the duplicate scope.");
163
+ const clientSecret = oauth.client_secret;
164
+ if (clientSecret !== void 0 && (typeof clientSecret !== "string" || clientSecret === "")) {
165
+ return fail("oauth.client_secret", "Must be a non-empty string.", "Set it to {env:SLACK_CLIENT_SECRET} and export that variable, or remove it to use PKCE.");
166
+ }
167
+ if (clientSecret !== void 0 && clientId === void 0) {
168
+ return fail("oauth.client_secret", "Needs oauth.client_id.", "Set oauth.client_id to your Slack app's client ID, or remove oauth.client_secret.");
169
+ }
170
+ const server = raw.server ?? DEFAULT_SERVER;
171
+ if (typeof server !== "string" || !SERVER_NAME.test(server)) {
172
+ return fail("server", "Must contain only letters, digits, _ and -.", `Use a name such as "${DEFAULT_SERVER}".`);
173
+ }
174
+ const allowUntestedHost = raw.allowUntestedHost ?? false;
175
+ if (typeof allowUntestedHost !== "boolean") {
176
+ return fail("allowUntestedHost", "Must be true or false.", "Set it to true only to run on an OpenCode version this plugin is not tested with.");
177
+ }
178
+ return {
179
+ ok: true,
180
+ options: {
181
+ ...clientId === void 0 ? {} : { clientId },
182
+ redirectUri,
183
+ ...callbackPort === void 0 ? {} : { callbackPort },
184
+ scopes: [...scopes],
185
+ ...clientSecret === void 0 ? {} : { clientSecret },
186
+ server,
187
+ allowUntestedHost
188
+ }
189
+ };
190
+ }
191
+
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
+ // src/permissions.ts
228
+ function defaultRules(server) {
229
+ return [
230
+ { action: `${server}_*`, resource: "*", effect: "ask" },
231
+ ...READ_TOOLS.map((tool) => ({ action: `${server}_${tool}`, resource: "*", effect: "allow" }))
232
+ ];
233
+ }
234
+ var same = (a, b) => a.action === b.action && a.resource === b.resource && a.effect === b.effect;
235
+ var BASE_ALLOW = { action: "*", resource: "*", effect: "allow" };
236
+ function insertDefaultRules(editor, server) {
237
+ const block = defaultRules(server);
238
+ for (const agent of editor.list()) {
239
+ const at = agent.permissions.findIndex((rule) => same(rule, BASE_ALLOW));
240
+ if (at === -1) continue;
241
+ const present = agent.permissions.some((_rule, start) => start > at && block.every((rule, offset) => {
242
+ const existing = agent.permissions[start + offset];
243
+ return existing !== void 0 && same(existing, rule);
244
+ }));
245
+ if (present) continue;
246
+ editor.update(agent.id, (draft) => {
247
+ draft.permissions.splice(at + 1, 0, ...block);
248
+ });
249
+ }
250
+ }
251
+
252
+ // src/rpc.ts
253
+ import { Rpc } from "@opencode/plugin/rpc";
254
+ var text = { type: "string" };
255
+ var texts = { type: "array", items: text };
256
+ var object = (properties, required) => ({ type: "object", properties, required, additionalProperties: false });
257
+ var status = object({
258
+ state: { enum: ["connected", "not_signed_in", "connecting", "disabled", "failed", "not_registered", "no_client_id", "config_invalid", "host_untested"] },
259
+ code: {
260
+ enum: ["OPTIONS_INVALID", "SERVER_NAME_IN_USE", "HOST_UNTESTED", "CLIENT_ID_MISSING", "CLIENT_ID_INVALID", "AUTH_REQUIRED", "CONNECTION_FAILED", "SERVER_DISABLED"]
261
+ },
262
+ message: text,
263
+ detail: text,
264
+ next: text,
265
+ server: object({ name: text, url: text }, ["name", "url"]),
266
+ client: object({ id: text, mode: { enum: ["public_pkce", "confidential"] }, source: { enum: ["options", "connect"] } }, ["id", "mode", "source"]),
267
+ callback: text,
268
+ callbackPort: { type: "integer" },
269
+ scopes: texts,
270
+ asksBefore: texts,
271
+ tools: object({
272
+ read: { type: "integer" },
273
+ private: { type: "integer" },
274
+ file: { type: "integer" },
275
+ write: { type: "integer" },
276
+ unknown: texts
277
+ }, ["read", "private", "file", "write", "unknown"]),
278
+ unguardedAgents: texts,
279
+ host: object({ version: text, range: text, tested: { type: "boolean" } }, ["version", "range", "tested"])
280
+ }, ["state", "message", "next", "server", "asksBefore", "tools", "unguardedAgents", "host"]);
281
+ var SlackRpc = Rpc.define({
282
+ id: "slack.workspace",
283
+ methods: {
284
+ status: {
285
+ input: object({}, []),
286
+ output: status,
287
+ errors: { unavailable: {} }
288
+ }
289
+ },
290
+ events: {}
291
+ });
292
+
293
+ // src/server.ts
294
+ function serverConfig(options, client) {
295
+ return {
296
+ type: "remote",
297
+ url: SLACK_MCP_URL,
298
+ oauth: {
299
+ client_id: client.id,
300
+ redirect_uri: options.redirectUri,
301
+ ...options.callbackPort === void 0 ? {} : { callback_port: options.callbackPort },
302
+ scope: options.scopes.join(" "),
303
+ ...client.secret === void 0 ? {} : { client_secret: client.secret }
304
+ }
305
+ };
306
+ }
307
+ function registerServer(editor, options, client) {
308
+ if (editor.get(options.server) !== void 0) return false;
309
+ if (client !== void 0) editor.set(options.server, serverConfig(options, client));
310
+ return true;
311
+ }
312
+
313
+ // src/skills.ts
314
+ import { readFile } from "node:fs/promises";
315
+ import { fileURLToPath } from "node:url";
316
+ import { Skill } from "@opencode/plugin";
317
+ var SKILLS = ["slack-search"];
318
+ var FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/;
319
+ function parseSkill(id, path, text2) {
320
+ const match = FRONTMATTER.exec(text2);
321
+ if (match === null) return void 0;
322
+ const fields = /* @__PURE__ */ new Map();
323
+ for (const line of match[1].split(/\r?\n/)) {
324
+ const field = /^([a-z]+):\s*(.*?)\s*$/.exec(line);
325
+ if (field) fields.set(field[1], field[2].replace(/^(["'])(.*)\1$/, "$2"));
326
+ }
327
+ const description = fields.get("description");
328
+ if (fields.get("name") !== id || !description) return void 0;
329
+ return {
330
+ id: Skill.ID.make(id),
331
+ name: Skill.Name.make(id),
332
+ description,
333
+ path: Skill.Info.fields.path.make(path),
334
+ content: match[2].trim()
335
+ };
336
+ }
337
+ async function loadSkills(root = new URL("../skills/", import.meta.url)) {
338
+ const skills = [];
339
+ for (const id of SKILLS) {
340
+ const path = fileURLToPath(new URL(`${id}/SKILL.md`, root));
341
+ const text2 = await readFile(path, "utf8").catch(() => {
342
+ throw new Error(`SKILLS_INVALID: Bundled skill ${id} is missing. Next step: Reinstall the plugin.`);
343
+ });
344
+ const skill = parseSkill(id, path, text2);
345
+ if (skill === void 0) throw new Error(`SKILLS_INVALID: Bundled skill ${id} has invalid frontmatter. Next step: Reinstall the plugin.`);
346
+ skills.push(skill);
347
+ }
348
+ return skills;
349
+ }
350
+
351
+ // src/status.ts
352
+ var UNAVAILABLE = "STATUS_UNAVAILABLE: Slack status is unavailable. Next step: Run /slack-status again in a moment.";
353
+ var ASKS_BEFORE = [
354
+ "searching private channels",
355
+ "reading files",
356
+ "writes: messages, drafts, schedules, reactions, canvases, lists, new conversations and file uploads",
357
+ "Slack tools this plugin does not know yet"
358
+ ];
359
+ function matches(value, pattern) {
360
+ const source = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
361
+ return new RegExp(`^${source}$`, "s").test(value);
362
+ }
363
+ function effect(rules, action) {
364
+ return rules.findLast((rule) => matches(action, rule.action) && matches("*", rule.resource))?.effect ?? "ask";
365
+ }
366
+ var unknownAction = (server) => `${server}_.`;
367
+ var NOTHING_TO_DO = "Nothing to do.";
368
+ var quote = (rule) => `{ "action": "${rule.action}", "resource": "${rule.resource}", "effect": "${rule.effect}" }`;
369
+ var remedy = (server) => `[${defaultRules(server).map(quote).join(", ")}]`;
370
+ function connection(server, status2, unguarded, toolCount) {
371
+ if (status2 === void 0) {
372
+ return { state: "not_registered", message: `The ${server} server is not registered.`, next: "Run opencode reload, then check again." };
373
+ }
374
+ switch (status2.status) {
375
+ case "pending":
376
+ return { state: "connecting", message: "Connecting to Slack.", next: "Wait a few seconds, then refresh." };
377
+ case "disabled":
378
+ return { state: "disabled", code: "SERVER_DISABLED", message: `The ${server} server is disabled.`, next: `Enable ${server} in /mcps.` };
379
+ case "needs_auth":
380
+ return {
381
+ state: "not_signed_in",
382
+ code: "AUTH_REQUIRED",
383
+ message: "Sign in to Slack to use it in OpenCode.",
384
+ detail: status2.error,
385
+ next: `Run /connect and choose ${serverDisplayName(server)}, then press o or Enter to open the sign-in page (in a terminal, opencode mcp auth ${server} opens the browser automatically).`
386
+ };
387
+ case "failed":
388
+ return {
389
+ state: "failed",
390
+ code: "CONNECTION_FAILED",
391
+ message: "Could not connect to Slack.",
392
+ detail: status2.error,
393
+ next: `Fix the error shown, then reconnect ${server} in /mcps.`
394
+ };
395
+ case "connected":
396
+ return {
397
+ state: "connected",
398
+ message: `Connected. Slack tools available: ${toolCount}.`,
399
+ next: unguarded.length > 0 ? `Put the default Slack rules shown under "Agents without default rules" first in each of these agents' own permissions (agents.<name>.permissions), so they ask before Slack writes: ${unguarded.join(", ")}.` : NOTHING_TO_DO
400
+ };
401
+ }
402
+ }
403
+ function buildStatus(input) {
404
+ const options = input.parsed.ok ? input.parsed.options : void 0;
405
+ const server = options?.server ?? DEFAULT_SERVER;
406
+ const host = { version: input.hostVersion, range: TESTED_HOST_RANGE, tested: input.hostTested };
407
+ const names = input.tools.filter((tool) => tool.options?.namespace === server).map((tool) => tool.name);
408
+ const tools = {
409
+ read: names.filter((name) => classify(name) === "read").length,
410
+ private: names.filter((name) => classify(name) === "private").length,
411
+ file: names.filter((name) => classify(name) === "file").length,
412
+ write: names.filter((name) => classify(name) === "write").length,
413
+ unknown: names.filter((name) => classify(name) === "unknown").sort()
414
+ };
415
+ const unguardedAgents = input.active ? input.agents.filter((agent) => effect(agent.permissions, unknownAction(server)) === "allow").map((agent) => agent.id) : [];
416
+ const client = input.client.client;
417
+ const base = {
418
+ server: { name: server, url: SLACK_MCP_URL },
419
+ ...options === void 0 || !input.active ? {} : {
420
+ ...client === void 0 ? {} : {
421
+ client: { id: client.id, mode: client.secret === void 0 ? "public_pkce" : "confidential", source: client.source }
422
+ },
423
+ callback: options.redirectUri,
424
+ ...options.callbackPort === void 0 ? {} : { callbackPort: options.callbackPort },
425
+ scopes: options.scopes
426
+ },
427
+ asksBefore: ASKS_BEFORE,
428
+ tools,
429
+ unguardedAgents,
430
+ host
431
+ };
432
+ if (!input.parsed.ok) {
433
+ const { error } = input.parsed;
434
+ return { ...base, state: "config_invalid", code: error.code, message: `Option ${error.field}: ${error.message}`, next: error.next };
435
+ }
436
+ if (!input.active && !input.hostTested) {
437
+ return {
438
+ ...base,
439
+ state: "host_untested",
440
+ code: "HOST_UNTESTED",
441
+ message: `OpenCode ${input.hostVersion} is outside the tested range ${TESTED_HOST_RANGE}, so Slack is not set up.`,
442
+ next: `Use OpenCode ${TESTED_HOST_RANGE}, or set "allowUntestedHost": true in this plugin's options to run anyway.`
443
+ };
444
+ }
445
+ if (input.nameInUse) {
446
+ return {
447
+ ...base,
448
+ state: "not_registered",
449
+ code: "SERVER_NAME_IN_USE",
450
+ message: `Another MCP server is already named "${server}", so Slack was not added.`,
451
+ next: `Remove mcp.servers.${server} from your OpenCode config, or set this plugin's "server" option to another name.`
452
+ };
453
+ }
454
+ if (client === void 0) {
455
+ const app = appDisplayName(server);
456
+ return {
457
+ ...base,
458
+ state: "no_client_id",
459
+ ...input.client.invalid ? { code: "CLIENT_ID_INVALID", message: `The client ID saved in the /connect ${app} entry is empty or has spaces, so Slack is not added.` } : { code: "CLIENT_ID_MISSING", message: `No Slack app client ID is set, so Slack is not added. To create the app, use the manifest at ${APP_MANIFEST_URL}.` },
460
+ next: `Run /connect and choose ${app}, then paste your Slack app's client ID.`
461
+ };
462
+ }
463
+ const status2 = input.servers.find((item) => item.name === server)?.status;
464
+ return { ...base, ...connection(server, status2, unguardedAgents, names.length) };
465
+ }
466
+ function callbackRow(callback, port) {
467
+ const register = "register exactly this URL in the app's Redirect URLs";
468
+ if (port === void 0) return `${callback} (${register})`;
469
+ return `${callback} (${register}, and forward it to http://127.0.0.1:${port}${new URL(callback).pathname}, where OpenCode listens)`;
470
+ }
471
+ var CLIENT_SOURCE = { options: "set in plugin options", connect: "saved in /connect" };
472
+ function stateLabel(status2) {
473
+ switch (status2.state) {
474
+ case "not_signed_in":
475
+ return "sign-in required";
476
+ case "not_registered":
477
+ return status2.code === "SERVER_NAME_IN_USE" ? "conflict" : "failed";
478
+ case "no_client_id":
479
+ case "config_invalid":
480
+ return "not configured";
481
+ case "host_untested":
482
+ return "host untested";
483
+ default:
484
+ return status2.state;
485
+ }
486
+ }
487
+ function statusRows(status2) {
488
+ const { tools } = status2;
489
+ const rows = [
490
+ {
491
+ label: "Connection",
492
+ value: `${stateLabel(status2)}. ${status2.code === void 0 ? "" : `${status2.code}: `}${status2.message}${status2.detail === void 0 ? "" : ` ${status2.detail}`}`
493
+ },
494
+ { label: "Account", value: "Slack shows the account on its sign-in page; OpenCode does not store it." },
495
+ {
496
+ label: "Access",
497
+ value: `Reads run without asking. Asks before ${status2.asksBefore.join("; ")}. To allow one, choose "Always allow" when asked, or add a permissions rule such as ${quote({ action: `${status2.server.name}_slack_read_file`, resource: "*", effect: "allow" })}.`
498
+ },
499
+ { label: "Server", value: `${status2.server.name} at ${status2.server.url}` }
500
+ ];
501
+ if (status2.client !== void 0) {
502
+ const mode = status2.client.mode === "public_pkce" ? "public, PKCE" : "confidential, secret set";
503
+ rows.push({ label: "Client", value: `${status2.client.id} (${mode}; ${CLIENT_SOURCE[status2.client.source]})` });
504
+ }
505
+ if (status2.callback !== void 0) rows.push({ label: "Callback", value: callbackRow(status2.callback, status2.callbackPort) });
506
+ if (status2.scopes !== void 0) rows.push({ label: "Scopes", value: status2.scopes.join(" ") });
507
+ rows.push({
508
+ label: "Tools",
509
+ value: `${tools.read} read, ${tools.private} private search, ${tools.file} file, ${tools.write} write` + (tools.unknown.length > 0 ? `; unknown (ask first): ${tools.unknown.join(", ")}` : "")
510
+ });
511
+ if (status2.unguardedAgents.length > 0) {
512
+ rows.push({
513
+ label: "Agents without default rules",
514
+ value: `These agents run Slack writes and unknown tools without asking: ${status2.unguardedAgents.join(", ")}. In your OpenCode config, put these rules first in each agent's own "permissions" list (agents.<name>.permissions): ${remedy(status2.server.name)}. Follow them with your top-level rules for ${status2.server.name}_ actions, so yours keep winning. Do not add them to the top-level "permissions": there they would loosen stricter agents such as explore.`
515
+ });
516
+ }
517
+ rows.push(
518
+ { label: "OpenCode", value: `${status2.host.version} (${status2.host.tested ? "tested" : "untested"}; tested range ${status2.host.range})` },
519
+ { label: "Next step", value: status2.next }
520
+ );
521
+ return rows;
522
+ }
523
+ function formatStatus(status2) {
524
+ return ["Slack", ...statusRows(status2).map((row) => `${row.label}: ${row.value}`)].join("\n");
525
+ }
526
+
527
+ // src/index.ts
528
+ var STARTUP_POLL_MS = 250;
529
+ var STARTUP_WINDOW_MS = 1e4;
530
+ var RESUBSCRIBE_MS = 1e3;
531
+ var delay = (ms, signal) => new Promise((resolve) => {
532
+ if (signal.aborted) return resolve();
533
+ const timer = setTimeout(resolve, ms);
534
+ signal.addEventListener("abort", () => {
535
+ clearTimeout(timer);
536
+ resolve();
537
+ }, { once: true });
538
+ });
539
+ async function forEach(events, signal, handle) {
540
+ const iterator = events[Symbol.asyncIterator]();
541
+ const aborted = new Promise((resolve) => signal.addEventListener("abort", () => resolve(void 0), { once: true }));
542
+ try {
543
+ while (!signal.aborted) {
544
+ const result = await Promise.race([iterator.next(), aborted]);
545
+ if (result === void 0 || result.done === true) return;
546
+ handle(result.value);
547
+ }
548
+ } finally {
549
+ void iterator.return?.()?.catch(() => void 0);
550
+ }
551
+ }
552
+ var src_default = Plugin.define({
553
+ id: "slack.workspace",
554
+ async setup(ctx) {
555
+ const hostVersion = ctx.app.version;
556
+ const hostTested = isTestedHost(hostVersion);
557
+ const parsed = parseOptions(ctx.options);
558
+ const options = parsed.ok && (hostTested || parsed.options.allowUntestedHost) ? parsed.options : void 0;
559
+ const stopped = new AbortController();
560
+ let nameInUse = false;
561
+ let client = { invalid: false };
562
+ let cleanup = async () => {
563
+ };
564
+ if (options !== void 0) {
565
+ const fromConnect = options.clientId === void 0;
566
+ if (fromConnect) await registerAppIntegration(ctx.integration, options.server);
567
+ client = await resolveClient(ctx.integration, options).catch(() => client);
568
+ await ctx.mcp.transform((editor) => {
569
+ nameInUse = !registerServer(editor, options, client.client);
570
+ });
571
+ await ctx.agent.transform((editor) => insertDefaultRules(editor, options.server));
572
+ const skills = await loadSkills();
573
+ await ctx.skill.transform((editor) => {
574
+ for (const skill of skills) editor.add(skill);
575
+ });
576
+ const namer = serverNamer(ctx, options.server, () => !nameInUse && client.client !== void 0);
577
+ const updateClient = async () => {
578
+ const next = await resolveClient(ctx.integration, options);
579
+ if (sameClient(next, client)) return;
580
+ client = next;
581
+ await ctx.mcp.reload();
582
+ };
583
+ let queue = Promise.resolve();
584
+ const handle = (work) => {
585
+ queue = queue.then(() => stopped.signal.aborted ? void 0 : work()).then(() => void 0, () => void 0);
586
+ };
587
+ const listen = (async () => {
588
+ while (!stopped.signal.aborted) {
589
+ const events = ctx.event.subscribe({ types: ["integration.updated", "credential.updated", "credential.switched"], signal: stopped.signal });
590
+ await forEach(events, stopped.signal, (event) => {
591
+ if (event.type === "integration.updated") handle(() => namer.apply());
592
+ else if (fromConnect) handle(updateClient);
593
+ }).catch(() => void 0);
594
+ await delay(RESUBSCRIBE_MS, stopped.signal);
595
+ }
596
+ })();
597
+ const startup = (async () => {
598
+ for (const until = Date.now() + STARTUP_WINDOW_MS; !stopped.signal.aborted && Date.now() < until; ) {
599
+ if (await namer.apply().catch(() => false)) break;
600
+ await delay(STARTUP_POLL_MS, stopped.signal);
601
+ }
602
+ })();
603
+ cleanup = async () => {
604
+ await Promise.all([listen, startup, queue]);
605
+ await namer.dispose();
606
+ };
607
+ }
608
+ const status2 = async () => buildStatus({
609
+ hostVersion,
610
+ hostTested,
611
+ parsed,
612
+ active: options !== void 0,
613
+ nameInUse,
614
+ client,
615
+ servers: (await ctx.mcp.list()).data,
616
+ tools: await ctx.tool.list(),
617
+ agents: (await ctx.agent.list()).data
618
+ });
619
+ await ctx.rpc.register(SlackRpc, {
620
+ status: async (_input, call) => {
621
+ try {
622
+ return await status2();
623
+ } catch {
624
+ return call.error("unavailable", UNAVAILABLE, void 0);
625
+ }
626
+ }
627
+ });
628
+ await ctx.command.transform((editor) => {
629
+ editor.add({
630
+ name: "slack-status",
631
+ description: "Show Slack sign-in, connection and permission status",
632
+ execute: async ({ sessionID, delivery }) => {
633
+ const text2 = await status2().then(formatStatus, () => UNAVAILABLE);
634
+ await ctx.session.synthetic({ sessionID, delivery, text: text2, description: "Slack status", resume: false });
635
+ }
636
+ });
637
+ });
638
+ return async () => {
639
+ stopped.abort();
640
+ await cleanup();
641
+ };
642
+ }
643
+ });
644
+ export {
645
+ src_default as default
646
+ };