@joe-broadhead/opencode-linear 0.0.0 → 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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
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.1] - 2026-11-03
6
+
7
+ ### Added
8
+
9
+ - The tools RPC, `linear.workspace.tools`: a script can call Linear's 42 known read tools with no model, through
10
+ `opencode api`, as a session and its agent, with either access. It gets exactly the access that agent's model has:
11
+ a tool its rules hide is refused, OpenCode checks each call against the agent's and the session's rules, and an ask
12
+ is refused at once because no one is there to answer it. `describe` lists the methods with each tool's live input
13
+ schema. Linear's write tools and tools Linear adds later are never methods. `docs/scripting.md` explains it.
14
+
5
15
  ## [0.0.0] - 2026-11-01
6
16
 
7
17
  ### Added
package/README.md CHANGED
@@ -58,6 +58,18 @@ The plugin connects to Linear's **read-only** server by default (`https://mcp.li
58
58
  only tools that read, and Linear refuses changes through it. To let the model make changes, set the option
59
59
  `"access": "write"`; see [Configure](#configure).
60
60
 
61
+ ### Scripts
62
+
63
+ A script or a cron job can call Linear's read tools with no model, through `opencode api`. It runs as a session and its
64
+ agent, under that agent's permission rules and the session's own. Write tools are never available to scripts:
65
+
66
+ ```sh
67
+ opencode api post /api/rpc/linear.workspace.tools/get_issue \
68
+ -d '{"input":{"sessionID":"ses_…","input":{"id":"ENG-123"}}}'
69
+ ```
70
+
71
+ See [Scripting](https://github.com/joe-broadhead/opencode-linear/blob/HEAD/docs/scripting.md).
72
+
61
73
  ## Permissions
62
74
 
63
75
  The plugin adds these rules to every agent, directly after the agent's base rule that allows everything:
@@ -142,6 +154,9 @@ Run `/linear-status` first. Its last line is the next step.
142
154
  | `STATUS_UNAVAILABLE` | The status could not be read, or the plugin is not running. Follow the next step in the message. |
143
155
  | `SKILLS_INVALID` | A skill file in the package is missing or broken. Reinstall the plugin. |
144
156
 
157
+ A script's errors from the tools RPC have their own codes, listed in
158
+ [Scripting](https://github.com/joe-broadhead/opencode-linear/blob/HEAD/docs/scripting.md#errors).
159
+
145
160
  ## Uninstall
146
161
 
147
162
  ```sh
package/dist/index.js CHANGED
@@ -37,6 +37,406 @@ function checkHost(version, allowUntestedHost) {
37
37
  return { version, tested: false, warning };
38
38
  }
39
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
+
40
440
  // src/integration.ts
41
441
  var SIGN_IN_AGAIN = "Linear needs a new sign-in. Choose this account to sign in again.";
42
442
  var STARTUP_POLL_MS = 250;
@@ -57,13 +457,13 @@ function watchIntegration(ctx, owned) {
57
457
  });
58
458
  await previous?.dispose();
59
459
  }
60
- const connection = await ctx.integration.connection.active(id);
61
- if (connection?.type !== "credential") return;
62
- const reported = connection.status?.message === SIGN_IN_AGAIN;
63
- if (server.status.status === "needs_auth" && !connection.status) {
64
- await ctx.integration.connection.status({ integrationID: id, connection, status: { status: "needs_auth", message: SIGN_IN_AGAIN } });
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 } });
65
465
  } else if (server.status.status === "connected" && reported) {
66
- await ctx.integration.connection.status({ integrationID: id, connection, status: void 0 });
466
+ await ctx.integration.connection.status({ integrationID: id, connection: connection2, status: void 0 });
67
467
  }
68
468
  };
69
469
  let running;
@@ -138,95 +538,6 @@ function parseOptions(raw) {
138
538
  }
139
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);
140
540
 
141
- // src/linear-tools.ts
142
- var READ_TOOLS = [
143
- "extract_images",
144
- "get_agent_skill",
145
- "get_attachment",
146
- "get_diff",
147
- "get_diff_threads",
148
- "get_document",
149
- "get_initiative",
150
- "get_issue",
151
- "get_issue_status",
152
- "get_milestone",
153
- "get_notifications",
154
- "get_project",
155
- "get_release",
156
- "get_release_note",
157
- "get_status_updates",
158
- "get_team",
159
- "get_template",
160
- "get_triage_responsibility",
161
- "get_user",
162
- "get_workspace",
163
- "list_agent_skills",
164
- "list_comments",
165
- "list_custom_views",
166
- "list_cycles",
167
- "list_diffs",
168
- "list_documents",
169
- "list_initiative_labels",
170
- "list_initiatives",
171
- "list_issue_labels",
172
- "list_issue_statuses",
173
- "list_issues",
174
- "list_milestones",
175
- "list_project_labels",
176
- "list_projects",
177
- "list_release_notes",
178
- "list_release_pipelines",
179
- "list_releases",
180
- "list_teams",
181
- "list_templates",
182
- "list_users",
183
- "search_documentation",
184
- "whoami"
185
- ];
186
- var WRITE_TOOLS = [
187
- "create_attachment",
188
- "create_attachment_from_upload",
189
- "create_initiative_label",
190
- "create_issue_label",
191
- "delete_attachment",
192
- "delete_comment",
193
- "delete_diff_comment",
194
- "delete_status_update",
195
- "mark_notification",
196
- "merge_diff",
197
- "prepare_attachment_upload",
198
- "prepare_file_upload",
199
- "resolve_diff_thread",
200
- "restore_initiative_label",
201
- "restore_issue_label",
202
- "restore_project_label",
203
- "retire_initiative_label",
204
- "retire_issue_label",
205
- "retire_project_label",
206
- "save_comment",
207
- "save_diff_comment",
208
- "save_document",
209
- "save_initiative",
210
- "save_initiative_label",
211
- "save_issue",
212
- "save_issue_label",
213
- "save_milestone",
214
- "save_project",
215
- "save_project_label",
216
- "save_release",
217
- "save_release_note",
218
- "save_status_update",
219
- "share_issue",
220
- "submit_diff_review",
221
- "unshare_issue",
222
- "update_diff"
223
- ];
224
- function classify(name) {
225
- if (READ_TOOLS.includes(name)) return "read";
226
- if (WRITE_TOOLS.includes(name)) return "write";
227
- return "unknown";
228
- }
229
-
230
541
  // src/permissions.ts
231
542
  var actionOf = (tool) => `${SERVER_NAME}_${tool}`;
232
543
  var ASK_ALL = { action: actionOf("*"), resource: "*", effect: "ask" };
@@ -256,9 +567,9 @@ function registerDefaultRules(agent, rules, report) {
256
567
  }
257
568
  });
258
569
  }
259
- var wildcard = (pattern) => new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
570
+ var wildcard2 = (pattern) => new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
260
571
  function evaluate(permissions, action) {
261
- return permissions.findLast((item) => wildcard(item.action).test(action) && wildcard(item.resource).test("*"))?.effect ?? "ask";
572
+ return permissions.findLast((item) => wildcard2(item.action).test(action) && wildcard2(item.resource).test("*"))?.effect ?? "ask";
262
573
  }
263
574
  var unguardedAgents = (agents) => agents.filter((agent) => !hasDefaultRules(agent.permissions) && evaluate(agent.permissions, actionOf("new_tool")) === "allow").map((agent) => agent.id);
264
575
 
@@ -272,15 +583,15 @@ var skillNames = (access) => access === "write" ? ["linear", "linear-write"] : [
272
583
  var notLoaded = (name, problem) => `The "${name}" skill was not loaded: skills/${name}/SKILL.md ${problem}.`;
273
584
  var REINSTALL = "Reinstall the plugin.";
274
585
  function parseSkill(name, path, text) {
275
- const fail = (problem) => linearError("SKILLS_INVALID", notLoaded(name, problem), REINSTALL);
586
+ const fail2 = (problem) => linearError("SKILLS_INVALID", notLoaded(name, problem), REINSTALL);
276
587
  const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(text.replace(/\r\n/g, "\n"));
277
- if (!match) throw fail("has no frontmatter");
588
+ if (!match) throw fail2("has no frontmatter");
278
589
  const fields = new Map(
279
590
  match[1].split("\n").map((line) => [line.slice(0, line.indexOf(":")).trim(), line.slice(line.indexOf(":") + 1).trim().replace(/^(["'])(.*)\1$/, "$2")])
280
591
  );
281
- if (fields.get("name") !== name) throw fail(`must be named "${name}" in its frontmatter`);
592
+ if (fields.get("name") !== name) throw fail2(`must be named "${name}" in its frontmatter`);
282
593
  const description = fields.get("description");
283
- if (!description) throw fail("has no description");
594
+ if (!description) throw fail2("has no description");
284
595
  return { id: Skill.ID.make(name), name: Skill.Name.make(name), description, path: Skill.Info.fields.path.make(path), content: match[2].trim() };
285
596
  }
286
597
  async function loadSkills(access, directory = SKILLS_DIR) {
@@ -311,12 +622,12 @@ var AGENTS_FIX = [
311
622
  ].join("\n");
312
623
  var NOTHING_TO_DO = "Nothing to do.";
313
624
  function buildStatus(facts) {
314
- const connection = !facts.server ? "not_configured" : facts.server.status === "needs_auth" ? "needs_sign_in" : facts.server.status;
625
+ const connection2 = !facts.server ? "not_configured" : facts.server.status === "needs_auth" ? "needs_sign_in" : facts.server.status;
315
626
  const unknownTools = (facts.tools ?? []).filter((tool) => classify(tool) === "unknown");
316
627
  const agentsWithoutRules = unguardedAgents(facts.agents ?? []);
317
628
  const problems = facts.problems ?? [];
318
629
  const status = {
319
- connection,
630
+ connection: connection2,
320
631
  ...facts.server?.error ? { error: facts.server.error } : {},
321
632
  ...facts.account ? { account: facts.account } : {},
322
633
  access: facts.access,
@@ -388,11 +699,11 @@ function formatStatus(status) {
388
699
  async function readStatus(ctx, facts) {
389
700
  const [servers, agents, tools] = await Promise.all([ctx.mcp.list(), ctx.agent.list(), ctx.tool.list()]);
390
701
  const server = servers.data.find((entry) => entry.name === SERVER_NAME);
391
- const connection = server?.integrationID ? await ctx.integration.connection.active(server.integrationID).catch(() => void 0) : void 0;
702
+ const connection2 = server?.integrationID ? await ctx.integration.connection.active(server.integrationID).catch(() => void 0) : void 0;
392
703
  return buildStatus({
393
704
  ...facts,
394
705
  ...server ? { server: server.status } : {},
395
- ...connection?.type === "credential" ? { account: connection.label } : {},
706
+ ...connection2?.type === "credential" ? { account: connection2.label } : {},
396
707
  tools: tools.filter((tool) => tool.options?.namespace === SERVER_NAME).map((tool) => tool.name),
397
708
  agents: agents.data
398
709
  });
@@ -461,20 +772,20 @@ function displayUrl(url) {
461
772
  return "an invalid URL";
462
773
  }
463
774
  }
464
- function registerServer(mcp, config, record) {
775
+ function registerServer(mcp, config, record2) {
465
776
  return mcp.transform((editor) => {
466
777
  try {
467
778
  const existing = editor.get(SERVER_NAME);
468
779
  const expected = displayUrl(config.url);
469
780
  if (!existing) {
470
781
  editor.set(SERVER_NAME, config);
471
- return record({ type: "plugin", url: expected });
782
+ return record2({ type: "plugin", url: expected });
472
783
  }
473
784
  const url = existing.type === "remote" ? displayUrl(existing.url) : "a local command";
474
- record(url === expected ? { type: "adopted", url } : { type: "conflict", url, expected });
785
+ record2(url === expected ? { type: "adopted", url } : { type: "conflict", url, expected });
475
786
  } catch (error) {
476
787
  const reason = error instanceof Error ? error.message : String(error);
477
- record(void 0, errorText("SERVER_NOT_REGISTERED", `The ${SERVER_NAME} MCP server could not be registered (${reason}).`, "Restart OpenCode."));
788
+ record2(void 0, errorText("SERVER_NOT_REGISTERED", `The ${SERVER_NAME} MCP server could not be registered (${reason}).`, "Restart OpenCode."));
478
789
  }
479
790
  });
480
791
  }
@@ -497,7 +808,13 @@ function createLinearPlugin(seam) {
497
808
  const read = () => readStatus(ctx, { ...source ? { source } : {}, access: options.access, host, problems: [...problems.values()] });
498
809
  await registerStatusCommand(ctx, read);
499
810
  await registerStatusRpc(ctx.rpc, read);
500
- return watchIntegration(ctx, () => source?.type === "plugin" || source?.type === "adopted");
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
+ };
501
818
  }
502
819
  });
503
820
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joe-broadhead/opencode-linear",
3
- "version": "0.0.0",
3
+ "version": "0.0.1",
4
4
  "description": "Unofficial OpenCode plugin for Linear: find, read and cite issues, projects and documents through Linear's official MCP server",
5
5
  "keywords": [
6
6
  "opencode",
@@ -61,6 +61,17 @@ build links yourself.
61
61
  - By default the plugin connects to Linear's read-only server, which offers no tools that change anything. To allow
62
62
  changes, the user sets the plugin option `"access": "write"` and signs in again.
63
63
 
64
+ ## Scripts
65
+
66
+ When the user wants a job that runs without you, such as a cron job or a shell script, it can call Linear's read tools
67
+ through the OpenCode server, with no model. The RPC `linear.workspace.tools` has one method per read tool, named
68
+ without the `linear_` prefix, such as `list_issues` or `get_issue`, with the tool's own input, and `describe`, which
69
+ lists them with their input schemas. Each call is `opencode api post /api/rpc/linear.workspace.tools/<method>` with
70
+ the body `{"input":{"sessionID":"<session>","input":<the tool's input>}}`. The script runs as that session's agent,
71
+ under its permission rules, so have the script create its session once with `opencode api post /api/session` and a
72
+ body such as `{"title":"triage digest","agent":"build"}`. An ask is refused, and write tools are never available to
73
+ scripts. The details are in the plugin's `docs/scripting.md`.
74
+
64
75
  ## When Linear is not available
65
76
 
66
77
  If no Linear tools appear, or a call says that Linear needs sign-in, ask the user to run `/linear-status`. It shows