@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 +10 -0
- package/README.md +15 -0
- package/dist/index.js +427 -110
- package/package.json +1 -1
- package/skills/linear/SKILL.md +11 -0
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
|
|
61
|
-
if (
|
|
62
|
-
const reported =
|
|
63
|
-
if (server.status.status === "needs_auth" && !
|
|
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
|
|
570
|
+
var wildcard2 = (pattern) => new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
|
|
260
571
|
function evaluate(permissions, action) {
|
|
261
|
-
return permissions.findLast((item) =>
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
...
|
|
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,
|
|
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
|
|
782
|
+
return record2({ type: "plugin", url: expected });
|
|
472
783
|
}
|
|
473
784
|
const url = existing.type === "remote" ? displayUrl(existing.url) : "a local command";
|
|
474
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
package/skills/linear/SKILL.md
CHANGED
|
@@ -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
|