@joe-broadhead/opencode-linear 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,826 @@
1
+ // src/constants.ts
2
+ var PLUGIN_ID = "linear.workspace";
3
+ var SERVER_NAME = "linear";
4
+ var DISPLAY_NAME = "Linear";
5
+ var ENDPOINTS = {
6
+ read: "https://mcp.linear.app/mcp/readonly",
7
+ write: "https://mcp.linear.app/mcp"
8
+ };
9
+ var SUPPORTED_HOST = ">=2.2.3 <2.3.0";
10
+ var errorText = (code, message, nextStep) => `${code}: ${message} Next step: ${nextStep}`;
11
+ function linearError(code, message, nextStep) {
12
+ return Object.assign(new Error(errorText(code, message, nextStep)), { code, nextStep });
13
+ }
14
+
15
+ // src/plugin.ts
16
+ import { Plugin } from "@opencode/plugin";
17
+
18
+ // src/host-version.ts
19
+ var RELEASE = /^(\d+)\.(\d+)\.(\d+)$/;
20
+ var parse = (version) => RELEASE.exec(version)?.slice(1).map(Number);
21
+ var [LOWEST, ABOVE] = SUPPORTED_HOST.split(" ").map((bound) => parse(bound.replace(/^[<>]=?/, "")));
22
+ var compare = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
23
+ function isTestedHost(version) {
24
+ const parts = parse(version);
25
+ return parts !== void 0 && compare(parts, LOWEST) >= 0 && compare(parts, ABOVE) < 0;
26
+ }
27
+ function checkHost(version, allowUntestedHost) {
28
+ if (isTestedHost(version)) return { version, tested: true };
29
+ if (!allowUntestedHost) {
30
+ throw linearError(
31
+ "HOST_UNTESTED",
32
+ `This plugin supports OpenCode ${SUPPORTED_HOST}, found ${version}.`,
33
+ "Install a plugin version made for your OpenCode, or set the plugin option allowUntestedHost: true to try anyway."
34
+ );
35
+ }
36
+ const warning = `OpenCode ${version} is outside the tested line ${SUPPORTED_HOST}. The plugin runs because allowUntestedHost is true; if Linear misbehaves, use a tested OpenCode release.`;
37
+ return { version, tested: false, warning };
38
+ }
39
+
40
+ // src/host/tools-rpc.ts
41
+ import { randomUUID } from "node:crypto";
42
+ import { Rpc } from "@opencode/plugin/rpc";
43
+
44
+ // src/linear-tools.ts
45
+ var READ_TOOLS = [
46
+ "extract_images",
47
+ "get_agent_skill",
48
+ "get_attachment",
49
+ "get_diff",
50
+ "get_diff_threads",
51
+ "get_document",
52
+ "get_initiative",
53
+ "get_issue",
54
+ "get_issue_status",
55
+ "get_milestone",
56
+ "get_notifications",
57
+ "get_project",
58
+ "get_release",
59
+ "get_release_note",
60
+ "get_status_updates",
61
+ "get_team",
62
+ "get_template",
63
+ "get_triage_responsibility",
64
+ "get_user",
65
+ "get_workspace",
66
+ "list_agent_skills",
67
+ "list_comments",
68
+ "list_custom_views",
69
+ "list_cycles",
70
+ "list_diffs",
71
+ "list_documents",
72
+ "list_initiative_labels",
73
+ "list_initiatives",
74
+ "list_issue_labels",
75
+ "list_issue_statuses",
76
+ "list_issues",
77
+ "list_milestones",
78
+ "list_project_labels",
79
+ "list_projects",
80
+ "list_release_notes",
81
+ "list_release_pipelines",
82
+ "list_releases",
83
+ "list_teams",
84
+ "list_templates",
85
+ "list_users",
86
+ "search_documentation",
87
+ "whoami"
88
+ ];
89
+ var WRITE_TOOLS = [
90
+ "create_attachment",
91
+ "create_attachment_from_upload",
92
+ "create_initiative_label",
93
+ "create_issue_label",
94
+ "delete_attachment",
95
+ "delete_comment",
96
+ "delete_diff_comment",
97
+ "delete_status_update",
98
+ "mark_notification",
99
+ "merge_diff",
100
+ "prepare_attachment_upload",
101
+ "prepare_file_upload",
102
+ "resolve_diff_thread",
103
+ "restore_initiative_label",
104
+ "restore_issue_label",
105
+ "restore_project_label",
106
+ "retire_initiative_label",
107
+ "retire_issue_label",
108
+ "retire_project_label",
109
+ "save_comment",
110
+ "save_diff_comment",
111
+ "save_document",
112
+ "save_initiative",
113
+ "save_initiative_label",
114
+ "save_issue",
115
+ "save_issue_label",
116
+ "save_milestone",
117
+ "save_project",
118
+ "save_project_label",
119
+ "save_release",
120
+ "save_release_note",
121
+ "save_status_update",
122
+ "share_issue",
123
+ "submit_diff_review",
124
+ "unshare_issue",
125
+ "update_diff"
126
+ ];
127
+ function classify(name) {
128
+ if (READ_TOOLS.includes(name)) return "read";
129
+ if (WRITE_TOOLS.includes(name)) return "write";
130
+ return "unknown";
131
+ }
132
+
133
+ // src/host/tools-rpc.ts
134
+ var PLUGIN_ID2 = PLUGIN_ID;
135
+ var KNOWN_READS = READ_TOOLS;
136
+ function shapeResult(_method, result) {
137
+ return result;
138
+ }
139
+ var NEXT_STEPS = {
140
+ SESSION_UNKNOWN: `Create a session that names an agent, such as POST /api/session with {"agent":"build"}, and pass its id.`,
141
+ SESSION_AGENT_REQUIRED: `Create a session that names an agent, such as POST /api/session with {"agent":"build"}, and pass its id.`,
142
+ 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.",
143
+ SERVER_CONFLICT: `Run /linear-status, then point your own mcp.servers.${SERVER_NAME} in opencode.json at the URL it names or remove it, and restart OpenCode.`,
144
+ AUTH_REQUIRED: `Run /connect and choose Linear, then press o or Enter to open the link (in a terminal, opencode mcp auth ${SERVER_NAME} opens it automatically).`,
145
+ CONNECTING: "Wait a few seconds, then call the method again.",
146
+ CONNECTION_FAILED: "Check your network, then run /linear-status and follow its next step.",
147
+ DISABLED: `Enable the ${SERVER_NAME} server in /mcps, or remove "disabled": true from mcp.servers.${SERVER_NAME} in opencode.json.`,
148
+ NOT_REGISTERED: "Run /linear-status and follow its next step.",
149
+ TOOL_UNAVAILABLE: "Call describe to see which methods OpenCode lists now, and run /linear-status.",
150
+ UPSTREAM_ERROR: "Check the input against the method's schema from describe, and run /linear-status if it keeps failing.",
151
+ CANCELLED: "Call the method again if you still need the result."
152
+ };
153
+ function pluginError(_error) {
154
+ return void 0;
155
+ }
156
+ var TOOLS_RPC_ID = `${PLUGIN_ID2}.tools`;
157
+ var UNATTENDED = "A script cannot answer an approval prompt; this needs an allow rule for the session's agent.";
158
+ var MESSAGE_ID = "msg_script";
159
+ var UPSTREAM_LIMIT = 300;
160
+ var object = (properties, required) => ({
161
+ type: "object",
162
+ properties,
163
+ required,
164
+ additionalProperties: false
165
+ });
166
+ var STRING = { type: "string" };
167
+ var ANY_OBJECT = { type: "object" };
168
+ var NO_INPUT = object({}, []);
169
+ var FAILED = object({ code: STRING, next: STRING }, ["code", "next"]);
170
+ var mcpOutput = (output = {}) => object({ output, text: STRING }, ["text"]);
171
+ var DESCRIBED = object(
172
+ {
173
+ name: STRING,
174
+ tool: STRING,
175
+ kind: { type: "string", enum: ["mcp", "plugin"] },
176
+ action: STRING,
177
+ description: STRING,
178
+ available: { type: "boolean" },
179
+ reason: STRING,
180
+ input: ANY_OBJECT,
181
+ output: ANY_OBJECT
182
+ },
183
+ ["name", "tool", "kind", "action", "description", "available", "input", "output"]
184
+ );
185
+ var DESCRIPTION = object({ id: STRING, call: STRING, location: STRING, methods: { type: "array", items: DESCRIBED } }, [
186
+ "id",
187
+ "call",
188
+ "location",
189
+ "methods"
190
+ ]);
191
+ var plainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
192
+ function strict(schema, issues) {
193
+ const keys = Object.keys(schema["properties"]);
194
+ const validate = (value) => {
195
+ if (!plainObject(value)) return { issues: [{ message: "Expected an object" }] };
196
+ const found = [...Object.keys(value).filter((key) => !keys.includes(key)).map((key) => `"${key}" is not allowed`), ...issues(value)];
197
+ return found.length === 0 ? { value } : { issues: found.map((message) => ({ message })) };
198
+ };
199
+ return Object.defineProperty({ ...schema }, "~standard", { value: { version: 1, vendor: "tools-rpc", validate }, enumerable: false });
200
+ }
201
+ var ScriptError = class extends Error {
202
+ failure;
203
+ constructor(failure) {
204
+ super(`${failure.code}: ${failure.what.replace(/[.\s]+$/, "")}. Next step: ${failure.next}`);
205
+ this.failure = failure;
206
+ }
207
+ };
208
+ var fail = (code, what) => new ScriptError({ code, what, next: NEXT_STEPS[code] });
209
+ var record = (value) => typeof value === "object" && value !== null;
210
+ function notFound(error, tags) {
211
+ if (!record(error)) return false;
212
+ const tag = error["_tag"] ?? error["name"];
213
+ return typeof tag === "string" && tags.includes(tag) || error["status"] === 404 || error["statusCode"] === 404;
214
+ }
215
+ var toolError = (error) => record(error) && error["_tag"] === "Tool.Error";
216
+ var wildcard = (pattern, value) => new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`).test(value);
217
+ var hidden = (rules, action) => rules.findLast((rule2) => wildcard(rule2.action, action) && wildcard(rule2.resource, "*"))?.effect === "deny";
218
+ var SECRETS = [
219
+ [/\b(bearer|basic|token)\s+[\w.~+/=-]{8,}/gi, "$1 [redacted]"],
220
+ [/\b([\w-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|token|secret|password|signature|credential)["']?\s*[:=]\s*)["']?[^\s"'&,;]+["']?/gi, "$1[redacted]"],
221
+ [/\beyJ[\w-]{4,}\.[\w-]{4,}\.[\w-]{4,}/g, "[redacted]"],
222
+ [/\b(?:tvly|sk|pk|rk|ghp|gho|ghu|ghs|ghr|github_pat|glpat|xox[abposr]|lin_api|lin_oauth)[-_][\w-]{8,}/gi, "[redacted]"],
223
+ [/[\w+=-]{40,}/g, "[redacted]"]
224
+ ];
225
+ var withoutQuery = (text) => text.replace(/\bhttps?:\/\/[^\s"'<>()]+/gi, (url) => {
226
+ const cut = url.search(/[?#]/);
227
+ return cut === -1 ? url : `${url.slice(0, cut)}?[redacted]`;
228
+ });
229
+ function upstreamText(text) {
230
+ const line = text.split(/\r?\n/).find((part) => part.trim() !== "")?.trim() ?? "no message";
231
+ const redacted = SECRETS.reduce((value, [pattern, replacement]) => value.replace(pattern, replacement), withoutQuery(line));
232
+ return redacted.length <= UPSTREAM_LIMIT ? redacted : `${redacted.slice(0, UPSTREAM_LIMIT - 1)}\u2026`;
233
+ }
234
+ var jsonSchema = (schema) => record(schema) && !("ast" in schema) && !("~standard" in schema) ? schema : void 0;
235
+ function normalise(result) {
236
+ const text = typeof result.content === "string" ? result.content : (result.content ?? []).flatMap((part) => part.type === "text" ? [part.text] : []).join("\n");
237
+ return result.output === void 0 ? { text } : { output: result.output, text };
238
+ }
239
+ function connection(status) {
240
+ switch (status) {
241
+ case "connected":
242
+ return void 0;
243
+ case "pending":
244
+ return "CONNECTING";
245
+ case "needs_auth":
246
+ return "AUTH_REQUIRED";
247
+ case "failed":
248
+ return "CONNECTION_FAILED";
249
+ case "disabled":
250
+ return "DISABLED";
251
+ default:
252
+ return "NOT_REGISTERED";
253
+ }
254
+ }
255
+ var STATUS_TEXT = {
256
+ CONNECTING: "is still connecting",
257
+ AUTH_REQUIRED: "needs a sign-in",
258
+ CONNECTION_FAILED: "could not connect",
259
+ DISABLED: "is disabled",
260
+ NOT_REGISTERED: "is not registered"
261
+ };
262
+ async function registerToolsRpc(ctx, options) {
263
+ const { mcp, owned = [] } = options;
264
+ const accounts = typeof mcp?.server === "function";
265
+ if (KNOWN_READS.length > 0 && !mcp) throw new Error("registerToolsRpc: KNOWN_READS needs the mcp option");
266
+ if (accounts && NEXT_STEPS.ACCOUNT_UNKNOWN === void 0) throw new Error("registerToolsRpc: per-account servers need NEXT_STEPS.ACCOUNT_UNKNOWN");
267
+ const names = [...KNOWN_READS, ...owned.map((method) => method.name)];
268
+ const duplicate = names.find((name, index) => name === "describe" || names.indexOf(name) !== index);
269
+ if (duplicate) throw new Error(`registerToolsRpc: the method name "${duplicate}" is taken`);
270
+ const live = /* @__PURE__ */ new Set();
271
+ const serverOf = (account) => {
272
+ const server = typeof mcp.server === "function" ? mcp.server(account) : mcp.server;
273
+ if (server !== void 0) return server;
274
+ throw new ScriptError({ code: "ACCOUNT_UNKNOWN", what: `there is no account '${account}'`, next: NEXT_STEPS.ACCOUNT_UNKNOWN });
275
+ };
276
+ const session = async (sessionID) => {
277
+ let info;
278
+ try {
279
+ info = await ctx.session.get({ sessionID });
280
+ } catch (error) {
281
+ if (notFound(error, ["Session.NotFoundError", "SessionNotFoundError"])) throw fail("SESSION_UNKNOWN", `there is no session '${sessionID}'`);
282
+ throw error;
283
+ }
284
+ if (!info.agent) throw fail("SESSION_AGENT_REQUIRED", `session '${sessionID}' names no agent`);
285
+ let agent;
286
+ try {
287
+ agent = await ctx.agent.get({ agentID: info.agent, location: info.location });
288
+ } catch (error) {
289
+ if (notFound(error, ["Agent.NotFoundError", "AgentNotFoundError"])) throw fail("SESSION_AGENT_REQUIRED", `session '${sessionID}' names the agent '${info.agent}', which does not exist`);
290
+ throw error;
291
+ }
292
+ const rules = [...agent.data.permissions, ...info.permissions ?? []];
293
+ return { agent: info.agent, rules };
294
+ };
295
+ const unavailable = async (server, tool, connected) => {
296
+ const status = (await ctx.mcp.list()).data.find((entry) => entry.name === server)?.status.status;
297
+ const code = connection(status) ?? connected;
298
+ if (code === "TOOL_UNAVAILABLE") return fail(code, `OpenCode does not list ${tool} on the connected "${server}" server`);
299
+ return fail(code, `the "${server}" server ${STATUS_TEXT[code] ?? "is unavailable"}`);
300
+ };
301
+ const denied = (action, error) => {
302
+ const reason = record(error) && typeof error["message"] === "string" ? error["message"] : String(error);
303
+ return fail("PERMISSION_DENIED", `${action} was refused for this session's agent: ${upstreamText(reason)}`);
304
+ };
305
+ const mcpTarget = (name, envelope2) => {
306
+ const server = serverOf(envelope2.account);
307
+ const action = `${server}_${name}`;
308
+ return {
309
+ action,
310
+ mcp: true,
311
+ async run(call) {
312
+ if (!mcp.managed(server)) throw fail("SERVER_CONFLICT", `the "${server}" server in your config is not the one this plugin manages`);
313
+ const tool = (await ctx.tool.list()).find((entry) => entry.id === action);
314
+ if (!tool) throw await unavailable(server, action, "TOOL_UNAVAILABLE");
315
+ let result;
316
+ try {
317
+ result = await tool.execute(envelope2.input, call);
318
+ } catch (error) {
319
+ if (error instanceof Error && error.message.startsWith(`MCP server "${server}" `)) throw await unavailable(server, action, "CONNECTION_FAILED");
320
+ throw error;
321
+ }
322
+ return shapeResult(name, normalise(result));
323
+ }
324
+ };
325
+ };
326
+ const ownedTarget = (spec, envelope2) => ({
327
+ action: spec.action,
328
+ mcp: false,
329
+ run: async (call) => await spec.run(envelope2.input, call) ?? null
330
+ });
331
+ const invoke = async (target, raw, context) => {
332
+ const envelope2 = raw;
333
+ let method;
334
+ try {
335
+ if (context.signal.aborted) throw fail("CANCELLED", "the call was cancelled");
336
+ method = target(envelope2);
337
+ const { agent, rules } = await session(envelope2.sessionID);
338
+ if (hidden(rules, method.action)) throw fail("PERMISSION_DENIED", `${method.action} is hidden from this session's agent by a deny rule`);
339
+ const call = {
340
+ sessionID: envelope2.sessionID,
341
+ agent,
342
+ messageID: MESSAGE_ID,
343
+ id: `script:${randomUUID()}`,
344
+ signal: context.signal,
345
+ progress: async () => {
346
+ }
347
+ };
348
+ live.add(call.id);
349
+ try {
350
+ return JSON.parse(JSON.stringify(await method.run(call)));
351
+ } finally {
352
+ live.delete(call.id);
353
+ }
354
+ } catch (error) {
355
+ const failure = refusal(error, method, context.signal);
356
+ if (!failure) throw error;
357
+ return context.error("failed", new ScriptError(failure).message, { code: failure.code, next: failure.next });
358
+ }
359
+ };
360
+ const refusal = (error, method, signal) => {
361
+ if (signal.aborted) return fail("CANCELLED", "the call was cancelled").failure;
362
+ if (error instanceof ScriptError) return error.failure;
363
+ const own = pluginError(error);
364
+ if (own || !method || !toolError(error)) return own;
365
+ if (error.error !== void 0) return denied(method.action, error).failure;
366
+ if (method.mcp) return fail("UPSTREAM_ERROR", `${method.action} failed: ${upstreamText(error.message)}`).failure;
367
+ return void 0;
368
+ };
369
+ const describe2 = async () => {
370
+ const tools = mcp ? await ctx.tool.list() : [];
371
+ const methods = [];
372
+ if (mcp) {
373
+ const server = serverOf(void 0);
374
+ const managed = mcp.managed(server);
375
+ const status = managed ? (await ctx.mcp.list()).data.find((entry) => entry.name === server)?.status.status : void 0;
376
+ for (const name of KNOWN_READS) {
377
+ const id = `${server}_${name}`;
378
+ const tool = managed ? tools.find((entry) => entry.id === id) : void 0;
379
+ const reason = !managed ? "SERVER_CONFLICT" : tool ? void 0 : connection(status) ?? "TOOL_UNAVAILABLE";
380
+ const input = jsonSchema(tool?.input) ?? ANY_OBJECT;
381
+ const output = jsonSchema(tool?.output);
382
+ methods.push({
383
+ name,
384
+ tool: id,
385
+ kind: "mcp",
386
+ action: id,
387
+ description: tool?.description ?? "",
388
+ available: reason === void 0,
389
+ ...reason ? { reason } : {},
390
+ input,
391
+ output: mcpOutput(output)
392
+ });
393
+ }
394
+ }
395
+ for (const spec of owned)
396
+ methods.push({ name: spec.name, tool: spec.name, kind: "plugin", action: spec.action, description: spec.description, available: true, input: spec.input, output: spec.output });
397
+ return JSON.parse(
398
+ JSON.stringify({
399
+ id: TOOLS_RPC_ID,
400
+ call: `POST /api/rpc/${TOOLS_RPC_ID}/<method> with {"input":{"sessionID":"<session>",${accounts ? `"account":"<account>",` : ""}"input":<the tool's input>}}`,
401
+ location: ctx.location.directory,
402
+ methods
403
+ })
404
+ );
405
+ };
406
+ const envelope = (input, objectInput) => strict(
407
+ object({ sessionID: { type: "string", minLength: 1 }, ...accounts ? { account: { type: "string", minLength: 1 } } : {}, input }, ["sessionID", "input"]),
408
+ (value) => [
409
+ ...typeof value["sessionID"] === "string" && value["sessionID"] !== "" ? [] : [`"sessionID" must be a non-empty string`],
410
+ ...value["account"] === void 0 || typeof value["account"] === "string" && value["account"] !== "" ? [] : [`"account" must be a non-empty string`],
411
+ ...!("input" in value) ? [`"input" is missing`] : objectInput && !plainObject(value["input"]) ? [`"input" must be an object`] : []
412
+ ]
413
+ );
414
+ const contract = Rpc.define({
415
+ id: TOOLS_RPC_ID,
416
+ methods: {
417
+ describe: { input: NO_INPUT, output: DESCRIPTION },
418
+ ...Object.fromEntries(KNOWN_READS.map((name) => [name, { input: envelope(ANY_OBJECT, true), output: mcpOutput(), errors: { failed: FAILED } }])),
419
+ ...Object.fromEntries(owned.map((spec) => [spec.name, { input: envelope(spec.input, false), output: spec.output, errors: { failed: FAILED } }]))
420
+ },
421
+ events: {}
422
+ });
423
+ const handlers = {
424
+ describe: () => describe2(),
425
+ ...Object.fromEntries(KNOWN_READS.map((name) => [name, (raw, context) => invoke((envelope2) => mcpTarget(name, envelope2), raw, context)])),
426
+ ...Object.fromEntries(owned.map((spec) => [spec.name, (raw, context) => invoke((envelope2) => ownedTarget(spec, envelope2), raw, context)]))
427
+ };
428
+ const registration = await ctx.rpc.register(contract, handlers);
429
+ const hook = await ctx.permission.hook("evaluate", (evaluation) => {
430
+ if (evaluation.effect !== "ask" || evaluation.source === void 0 || !live.has(evaluation.source.id)) return;
431
+ evaluation.effect = "deny";
432
+ evaluation.message = UNATTENDED;
433
+ });
434
+ return async () => {
435
+ await hook.dispose().catch(() => void 0);
436
+ await registration.dispose().catch(() => void 0);
437
+ };
438
+ }
439
+
440
+ // src/integration.ts
441
+ var SIGN_IN_AGAIN = "Linear needs a new sign-in. Choose this account to sign in again.";
442
+ var STARTUP_POLL_MS = 250;
443
+ var STARTUP_WINDOW_MS = 1e4;
444
+ var RESUBSCRIBE_MS = 5e3;
445
+ function watchIntegration(ctx, owned) {
446
+ const stopped = new AbortController();
447
+ let registration;
448
+ const sync = async () => {
449
+ const server = owned() ? (await ctx.mcp.list()).data.find((item) => item.name === SERVER_NAME) : void 0;
450
+ const id = server?.integrationID;
451
+ if (!server || !id) return;
452
+ const { data } = await ctx.integration.get({ integrationID: id });
453
+ if (data.name !== DISPLAY_NAME && !stopped.signal.aborted) {
454
+ const previous = registration;
455
+ registration = await ctx.integration.transform((editor) => {
456
+ if (editor.get(id)) editor.update(id, (integration) => void (integration.name = DISPLAY_NAME));
457
+ });
458
+ await previous?.dispose();
459
+ }
460
+ const connection2 = await ctx.integration.connection.active(id);
461
+ if (connection2?.type !== "credential") return;
462
+ const reported = connection2.status?.message === SIGN_IN_AGAIN;
463
+ if (server.status.status === "needs_auth" && !connection2.status) {
464
+ await ctx.integration.connection.status({ integrationID: id, connection: connection2, status: { status: "needs_auth", message: SIGN_IN_AGAIN } });
465
+ } else if (server.status.status === "connected" && reported) {
466
+ await ctx.integration.connection.status({ integrationID: id, connection: connection2, status: void 0 });
467
+ }
468
+ };
469
+ let running;
470
+ let again = false;
471
+ const schedule = () => {
472
+ if (running) return void (again = true);
473
+ running = (async () => {
474
+ do {
475
+ again = false;
476
+ if (!stopped.signal.aborted) await sync().catch(() => void 0);
477
+ } while (again);
478
+ })().finally(() => running = void 0);
479
+ };
480
+ void (async () => {
481
+ while (!stopped.signal.aborted) {
482
+ try {
483
+ const events = ctx.event.subscribe({ types: ["integration.updated", "mcp.status.changed", "credential.updated", "credential.switched"], signal: stopped.signal });
484
+ for await (const event of events) if (event.type !== "mcp.status.changed" || event.data.server === SERVER_NAME) schedule();
485
+ } catch {
486
+ }
487
+ if (!stopped.signal.aborted) schedule();
488
+ await delay(RESUBSCRIBE_MS, stopped.signal);
489
+ }
490
+ })();
491
+ const startup = (async () => {
492
+ for (const until = Date.now() + STARTUP_WINDOW_MS; Date.now() < until && !stopped.signal.aborted; ) {
493
+ schedule();
494
+ await delay(STARTUP_POLL_MS, stopped.signal);
495
+ }
496
+ })();
497
+ return async () => {
498
+ stopped.abort();
499
+ await startup;
500
+ await running;
501
+ await registration?.dispose();
502
+ registration = void 0;
503
+ };
504
+ }
505
+ var delay = (ms, signal) => new Promise((resolve) => {
506
+ if (signal.aborted) return resolve();
507
+ const done = () => {
508
+ clearTimeout(timer);
509
+ signal.removeEventListener("abort", done);
510
+ resolve();
511
+ };
512
+ const timer = setTimeout(done, ms);
513
+ signal.addEventListener("abort", done, { once: true });
514
+ });
515
+
516
+ // src/options.ts
517
+ var EXAMPLE = `"plugins": [{ "package": "@joe-broadhead/opencode-linear", "options": { "access": "write" } }]`;
518
+ var ACCEPTED = {
519
+ access: `"read" (the default) or "write"`,
520
+ allowUntestedHost: "true or false (the default)"
521
+ };
522
+ var invalid = (problem) => linearError("OPTIONS_INVALID", `${problem}.`, `Fix the plugin options in opencode.json, for example ${EXAMPLE}.`);
523
+ function parseOptions(raw) {
524
+ if (raw === void 0 || raw === null) return { access: "read", allowUntestedHost: false };
525
+ if (typeof raw !== "object" || Array.isArray(raw)) throw invalid(`The plugin options must be an object, found ${describe(raw)}`);
526
+ const input = raw;
527
+ for (const key of Object.keys(input)) {
528
+ if (!Object.hasOwn(ACCEPTED, key)) {
529
+ throw invalid(`Unknown option "${key}". Accepted options: ${Object.keys(ACCEPTED).map((name) => `"${name}"`).join(", ")}`);
530
+ }
531
+ }
532
+ const { access = "read", allowUntestedHost = false } = input;
533
+ if (access !== "read" && access !== "write") throw invalid(`The option "access" must be ${ACCEPTED.access}, found ${describe(access)}`);
534
+ if (typeof allowUntestedHost !== "boolean") {
535
+ throw invalid(`The option "allowUntestedHost" must be ${ACCEPTED.allowUntestedHost}, found ${describe(allowUntestedHost)}`);
536
+ }
537
+ return { access, allowUntestedHost };
538
+ }
539
+ var describe = (value) => value === null ? "null" : Array.isArray(value) ? "an array" : typeof value === "string" ? JSON.stringify(value) : typeof value === "object" ? "an object" : String(value);
540
+
541
+ // src/permissions.ts
542
+ var actionOf = (tool) => `${SERVER_NAME}_${tool}`;
543
+ var ASK_ALL = { action: actionOf("*"), resource: "*", effect: "ask" };
544
+ var rule = (tool, effect) => ({ action: actionOf(tool), resource: "*", effect });
545
+ var defaultRules = () => [{ ...ASK_ALL }, ...READ_TOOLS.map((tool) => rule(tool, "allow"))];
546
+ var same = (a, b) => a.action === b.action && a.resource === b.resource && a.effect === b.effect;
547
+ var BASE_ALLOW = { action: "*", resource: "*", effect: "allow" };
548
+ function hasDefaultRules(permissions) {
549
+ const base = permissions.findIndex((item) => same(item, BASE_ALLOW));
550
+ return permissions.some((item, index) => index > base && same(item, ASK_ALL));
551
+ }
552
+ function insertRules(permissions, rules) {
553
+ if (hasDefaultRules(permissions)) return false;
554
+ const base = permissions.findIndex((item) => same(item, BASE_ALLOW));
555
+ if (base === -1) return false;
556
+ permissions.splice(base + 1, 0, ...rules.map((item) => ({ ...item })));
557
+ return true;
558
+ }
559
+ function registerDefaultRules(agent, rules, report) {
560
+ return agent.transform((editor) => {
561
+ try {
562
+ for (const { id } of editor.list()) editor.update(id, (info) => void insertRules(info.permissions, rules));
563
+ report(void 0);
564
+ } catch (error) {
565
+ const reason = error instanceof Error ? error.message : String(error);
566
+ report(errorText("RULES_NOT_ADDED", `Linear's default permission rules could not be added (${reason}), so Linear tools may run without asking.`, "Restart OpenCode."));
567
+ }
568
+ });
569
+ }
570
+ var wildcard2 = (pattern) => new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
571
+ function evaluate(permissions, action) {
572
+ return permissions.findLast((item) => wildcard2(item.action).test(action) && wildcard2(item.resource).test("*"))?.effect ?? "ask";
573
+ }
574
+ var unguardedAgents = (agents) => agents.filter((agent) => !hasDefaultRules(agent.permissions) && evaluate(agent.permissions, actionOf("new_tool")) === "allow").map((agent) => agent.id);
575
+
576
+ // src/skills.ts
577
+ import { readFile } from "node:fs/promises";
578
+ import { join } from "node:path";
579
+ import { fileURLToPath } from "node:url";
580
+ import { Skill } from "@opencode/plugin";
581
+ var SKILLS_DIR = fileURLToPath(new URL("../skills/", import.meta.url));
582
+ var skillNames = (access) => access === "write" ? ["linear", "linear-write"] : ["linear"];
583
+ var notLoaded = (name, problem) => `The "${name}" skill was not loaded: skills/${name}/SKILL.md ${problem}.`;
584
+ var REINSTALL = "Reinstall the plugin.";
585
+ function parseSkill(name, path, text) {
586
+ const fail2 = (problem) => linearError("SKILLS_INVALID", notLoaded(name, problem), REINSTALL);
587
+ const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(text.replace(/\r\n/g, "\n"));
588
+ if (!match) throw fail2("has no frontmatter");
589
+ const fields = new Map(
590
+ match[1].split("\n").map((line) => [line.slice(0, line.indexOf(":")).trim(), line.slice(line.indexOf(":") + 1).trim().replace(/^(["'])(.*)\1$/, "$2")])
591
+ );
592
+ if (fields.get("name") !== name) throw fail2(`must be named "${name}" in its frontmatter`);
593
+ const description = fields.get("description");
594
+ if (!description) throw fail2("has no description");
595
+ return { id: Skill.ID.make(name), name: Skill.Name.make(name), description, path: Skill.Info.fields.path.make(path), content: match[2].trim() };
596
+ }
597
+ async function loadSkills(access, directory = SKILLS_DIR) {
598
+ const skills = [];
599
+ const problems = [];
600
+ for (const name of skillNames(access)) {
601
+ const path = join(directory, name, "SKILL.md");
602
+ try {
603
+ skills.push(parseSkill(name, path, await readFile(path, "utf8")));
604
+ } catch (error) {
605
+ const code = error.code;
606
+ problems.push(code === "SKILLS_INVALID" ? error.message : errorText("SKILLS_INVALID", notLoaded(name, code === "ENOENT" ? "is missing" : `cannot be read (${String(code)})`), REINSTALL));
607
+ }
608
+ }
609
+ return { skills, problems };
610
+ }
611
+ function registerSkills(skill, skills) {
612
+ return skill.transform((editor) => {
613
+ for (const info of skills) editor.add(info);
614
+ });
615
+ }
616
+
617
+ // src/status.ts
618
+ var SIGN_IN = `Run /connect and choose Linear, then press o or Enter to open the link (in a terminal, opencode mcp auth ${SERVER_NAME} opens it automatically).`;
619
+ var AGENTS_FIX = [
620
+ `Put this block first in each listed agent's own "permissions" (agents.<id>.permissions in opencode.json, or the agent file's frontmatter), followed by your top-level "permissions" rules for linear_* tools:`,
621
+ ...defaultRules().map((rule2, index, rules) => ` ${JSON.stringify(rule2)}${index < rules.length - 1 ? "," : ""}`)
622
+ ].join("\n");
623
+ var NOTHING_TO_DO = "Nothing to do.";
624
+ function buildStatus(facts) {
625
+ const connection2 = !facts.server ? "not_configured" : facts.server.status === "needs_auth" ? "needs_sign_in" : facts.server.status;
626
+ const unknownTools = (facts.tools ?? []).filter((tool) => classify(tool) === "unknown");
627
+ const agentsWithoutRules = unguardedAgents(facts.agents ?? []);
628
+ const problems = facts.problems ?? [];
629
+ const status = {
630
+ connection: connection2,
631
+ ...facts.server?.error ? { error: facts.server.error } : {},
632
+ ...facts.account ? { account: facts.account } : {},
633
+ access: facts.access,
634
+ ...facts.source ? { source: facts.source } : {},
635
+ tools: facts.tools?.length ?? 0,
636
+ unknownTools,
637
+ agentsWithoutRules,
638
+ host: facts.host,
639
+ problems
640
+ };
641
+ return { ...status, nextStep: nextStepOf(status) };
642
+ }
643
+ function nextStepOf(status) {
644
+ if (status.source?.type === "conflict") {
645
+ const { url, expected } = status.source;
646
+ return `Your OpenCode config defines mcp.servers.${SERVER_NAME} with ${url}, but access "${status.access}" uses ${expected}. Point it at ${expected} or remove it, then restart OpenCode.`;
647
+ }
648
+ switch (status.connection) {
649
+ case "not_configured":
650
+ return `OpenCode has no ${SERVER_NAME} MCP server. Check that no organisation policy (mcp.use) or other plugin removes it, then restart OpenCode.`;
651
+ case "disabled":
652
+ return `The ${SERVER_NAME} MCP server is disabled. Enable it in /mcps, or remove "disabled": true from mcp.servers.${SERVER_NAME} in opencode.json.`;
653
+ case "pending":
654
+ return "Linear is still connecting. Run /linear-status again in a few seconds.";
655
+ case "needs_sign_in":
656
+ return SIGN_IN;
657
+ case "failed":
658
+ return "Check your network, then run /linear-status again.";
659
+ }
660
+ if (status.agentsWithoutRules.length) return `Add the block above to each listed agent's own "permissions".`;
661
+ const problem = status.problems[0]?.split(" Next step: ")[1];
662
+ if (problem) return problem;
663
+ if (!status.host.tested) return `If Linear misbehaves, use OpenCode ${SUPPORTED_HOST}.`;
664
+ return NOTHING_TO_DO;
665
+ }
666
+ var CONNECTION_TEXT = {
667
+ connected: "connected",
668
+ pending: "connecting",
669
+ needs_sign_in: "sign-in required",
670
+ failed: "failed",
671
+ disabled: "disabled",
672
+ not_configured: "not configured"
673
+ };
674
+ var ACCESS_TEXT = {
675
+ read: "read-only (Linear's read-only server; changes are not available)",
676
+ write: "read and write (each change asks before it runs)"
677
+ };
678
+ function serverText(source) {
679
+ if (source.type === "plugin") return `${source.url}, registered by this plugin`;
680
+ if (source.type === "adopted") return `${source.url}, your own mcp.servers.${SERVER_NAME}, used unchanged`;
681
+ return `your mcp.servers.${SERVER_NAME} points at ${source.url}`;
682
+ }
683
+ function formatStatus(status) {
684
+ const { source, host, unknownTools, agentsWithoutRules } = status;
685
+ const state = source?.type === "conflict" ? "conflict" : CONNECTION_TEXT[status.connection];
686
+ const details = [status.error, source && serverText(source)].filter(Boolean).join("; ");
687
+ const notes = [host.warning, ...status.problems].filter(Boolean);
688
+ return [
689
+ "Linear status",
690
+ `- Connection: ${state}${details ? ` (${details})` : ""}`,
691
+ `- Account: ${status.account ?? "account name not reported by Linear's MCP"}`,
692
+ `- Access: ${ACCESS_TEXT[status.access]}`,
693
+ `- Tools: ${status.tools} available, ${unknownTools.length}${unknownTools.length ? ` unknown: ${unknownTools.join(", ")} (unknown tools ask before running)` : " unknown"}`,
694
+ `- Agents without default rules: ${agentsWithoutRules.length ? `${agentsWithoutRules.join(", ")}. Linear tools run there without asking. ${AGENTS_FIX}` : "none"}`,
695
+ `- OpenCode: ${host.version} (${host.tested ? "tested" : "host untested"})${notes.length ? `. ${notes.join(" ")}` : ""}`,
696
+ `- Next step: ${status.nextStep}`
697
+ ].join("\n");
698
+ }
699
+ async function readStatus(ctx, facts) {
700
+ const [servers, agents, tools] = await Promise.all([ctx.mcp.list(), ctx.agent.list(), ctx.tool.list()]);
701
+ const server = servers.data.find((entry) => entry.name === SERVER_NAME);
702
+ const connection2 = server?.integrationID ? await ctx.integration.connection.active(server.integrationID).catch(() => void 0) : void 0;
703
+ return buildStatus({
704
+ ...facts,
705
+ ...server ? { server: server.status } : {},
706
+ ...connection2?.type === "credential" ? { account: connection2.label } : {},
707
+ tools: tools.filter((tool) => tool.options?.namespace === SERVER_NAME).map((tool) => tool.name),
708
+ agents: agents.data
709
+ });
710
+ }
711
+ var strings = { type: "array", items: { type: "string" } };
712
+ var StatusRpc = {
713
+ id: PLUGIN_ID,
714
+ methods: {
715
+ status: {
716
+ input: { type: "object" },
717
+ output: {
718
+ type: "object",
719
+ required: ["connection", "access", "tools", "unknownTools", "agentsWithoutRules", "host", "problems", "nextStep"],
720
+ properties: {
721
+ connection: { enum: ["connected", "pending", "needs_sign_in", "failed", "disabled", "not_configured"] },
722
+ error: { type: "string" },
723
+ account: { type: "string" },
724
+ access: { enum: ["read", "write"] },
725
+ source: {
726
+ type: "object",
727
+ required: ["type", "url"],
728
+ properties: { type: { enum: ["plugin", "adopted", "conflict"] }, url: { type: "string" }, expected: { type: "string" } }
729
+ },
730
+ tools: { type: "integer", minimum: 0 },
731
+ unknownTools: strings,
732
+ agentsWithoutRules: strings,
733
+ host: { type: "object", required: ["version", "tested"], properties: { version: { type: "string" }, tested: { type: "boolean" }, warning: { type: "string" } } },
734
+ problems: strings,
735
+ nextStep: { type: "string" }
736
+ }
737
+ }
738
+ }
739
+ },
740
+ events: {}
741
+ };
742
+ function registerStatusRpc(rpc, read) {
743
+ return rpc.register(StatusRpc, { status: () => read() });
744
+ }
745
+ function registerStatusCommand(ctx, read) {
746
+ return ctx.command.transform(
747
+ (editor) => editor.add({
748
+ name: "linear-status",
749
+ description: "Show Linear's connection, sign-in, access mode and the next step",
750
+ execute: async ({ sessionID }) => {
751
+ const text = await read().then(
752
+ formatStatus,
753
+ (error) => errorText("STATUS_UNAVAILABLE", `Could not read Linear's status (${error instanceof Error ? error.message : String(error)}).`, "Restart OpenCode, then run /linear-status again.")
754
+ );
755
+ await ctx.session.synthetic({ sessionID, text, description: "Linear status", resume: false });
756
+ }
757
+ })
758
+ );
759
+ }
760
+
761
+ // src/plugin.ts
762
+ var serverConfig = (seam, access) => ({
763
+ type: "remote",
764
+ url: seam.endpoints[access],
765
+ oauth: seam.oauth ?? { scope: access === "write" ? "read write" : "read" }
766
+ });
767
+ function displayUrl(url) {
768
+ try {
769
+ const parsed = new URL(url);
770
+ return `${parsed.origin}${parsed.pathname.replace(/\/+$/, "")}`;
771
+ } catch {
772
+ return "an invalid URL";
773
+ }
774
+ }
775
+ function registerServer(mcp, config, record2) {
776
+ return mcp.transform((editor) => {
777
+ try {
778
+ const existing = editor.get(SERVER_NAME);
779
+ const expected = displayUrl(config.url);
780
+ if (!existing) {
781
+ editor.set(SERVER_NAME, config);
782
+ return record2({ type: "plugin", url: expected });
783
+ }
784
+ const url = existing.type === "remote" ? displayUrl(existing.url) : "a local command";
785
+ record2(url === expected ? { type: "adopted", url } : { type: "conflict", url, expected });
786
+ } catch (error) {
787
+ const reason = error instanceof Error ? error.message : String(error);
788
+ record2(void 0, errorText("SERVER_NOT_REGISTERED", `The ${SERVER_NAME} MCP server could not be registered (${reason}).`, "Restart OpenCode."));
789
+ }
790
+ });
791
+ }
792
+ function createLinearPlugin(seam) {
793
+ return Plugin.define({
794
+ id: PLUGIN_ID,
795
+ async setup(ctx) {
796
+ const options = parseOptions(ctx.options);
797
+ const host = checkHost(ctx.app.version, options.allowUntestedHost);
798
+ const { skills, problems: skillProblems } = await loadSkills(options.access, seam.skillsDir);
799
+ const problems = new Map(skillProblems.map((problem, index) => [`skill:${index}`, problem]));
800
+ const report = (area) => (problem) => void (problem ? problems.set(area, problem) : problems.delete(area));
801
+ let source;
802
+ await registerServer(ctx.mcp, serverConfig(seam, options.access), (next, problem) => {
803
+ source = next;
804
+ report("server")(problem);
805
+ });
806
+ await registerDefaultRules(ctx.agent, defaultRules(), report("rules"));
807
+ await registerSkills(ctx.skill, skills);
808
+ const read = () => readStatus(ctx, { ...source ? { source } : {}, access: options.access, host, problems: [...problems.values()] });
809
+ await registerStatusCommand(ctx, read);
810
+ await registerStatusRpc(ctx.rpc, read);
811
+ const owned = () => source?.type === "plugin" || source?.type === "adopted";
812
+ const stopTools = await registerToolsRpc(ctx, { mcp: { server: SERVER_NAME, managed: owned } });
813
+ const stopWatch = watchIntegration(ctx, owned);
814
+ return async () => {
815
+ await stopWatch();
816
+ await stopTools();
817
+ };
818
+ }
819
+ });
820
+ }
821
+
822
+ // src/index.ts
823
+ var src_default = createLinearPlugin({ endpoints: ENDPOINTS });
824
+ export {
825
+ src_default as default
826
+ };