@ship-cli/opencode 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.
Files changed (3) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +43 -0
  3. package/src/plugin.ts +511 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-present <PLACEHOLDER>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@ship-cli/opencode",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "description": "OpenCode plugin for Ship - Linear task management integration",
6
+ "license": "MIT",
7
+ "main": "src/plugin.ts",
8
+ "files": [
9
+ "src",
10
+ "README.md"
11
+ ],
12
+ "keywords": [
13
+ "opencode",
14
+ "plugin",
15
+ "ship",
16
+ "linear",
17
+ "task-management",
18
+ "ai"
19
+ ],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/user/ship",
23
+ "directory": "packages/opencode-plugin"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "dependencies": {
29
+ "@opencode-ai/plugin": "^1.0.143",
30
+ "@opencode-ai/sdk": "^1.0.143"
31
+ },
32
+ "devDependencies": {
33
+ "@types/bun": "latest",
34
+ "@types/node": "^22.0.0",
35
+ "typescript": "~5.6.2"
36
+ },
37
+ "engines": {
38
+ "bun": ">=1.0.0"
39
+ },
40
+ "scripts": {
41
+ "typecheck": "tsc --noEmit"
42
+ }
43
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,511 @@
1
+ /**
2
+ * Ship OpenCode Plugin
3
+ *
4
+ * Integrates the Ship CLI (Linear task management) with OpenCode.
5
+ *
6
+ * Features:
7
+ * - Context injection via `ship prime` on session start and after compaction
8
+ * - Ship tool for task management operations
9
+ * - Task agent for autonomous issue completion
10
+ */
11
+
12
+ import type { Plugin, PluginInput } from "@opencode-ai/plugin";
13
+ import { tool as createTool } from "@opencode-ai/plugin";
14
+
15
+ type OpencodeClient = PluginInput["client"];
16
+
17
+ const SHIP_GUIDANCE = `## Ship CLI Guidance
18
+
19
+ Ship integrates Linear issue tracking with your development workflow. Use it to:
20
+ - View and manage tasks assigned to you
21
+ - Track task dependencies (blockers)
22
+ - Start/complete work on tasks
23
+ - Create new tasks
24
+
25
+ ### Quick Commands
26
+ - \`ship ready\` - Tasks you can work on (no blockers)
27
+ - \`ship blocked\` - Tasks waiting on dependencies
28
+ - \`ship list\` - All tasks
29
+ - \`ship show <ID>\` - Task details
30
+ - \`ship start <ID>\` - Begin working on task
31
+ - \`ship done <ID>\` - Mark task complete
32
+ - \`ship create "title"\` - Create new task
33
+
34
+ ### Best Practices
35
+ 1. Check \`ship ready\` to see what can be worked on
36
+ 2. Use \`ship start\` before beginning work
37
+ 3. Use \`ship done\` when completing tasks
38
+ 4. Check blockers before starting dependent tasks`;
39
+
40
+ /**
41
+ * Get the current model/agent context for a session by querying messages.
42
+ */
43
+ async function getSessionContext(
44
+ client: OpencodeClient,
45
+ sessionID: string
46
+ ): Promise<
47
+ { model?: { providerID: string; modelID: string }; agent?: string } | undefined
48
+ > {
49
+ try {
50
+ const response = await client.session.messages({
51
+ path: { id: sessionID },
52
+ query: { limit: 50 },
53
+ });
54
+
55
+ if (response.data) {
56
+ for (const msg of response.data) {
57
+ if (msg.info.role === "user" && "model" in msg.info && msg.info.model) {
58
+ return { model: msg.info.model, agent: msg.info.agent };
59
+ }
60
+ }
61
+ }
62
+ } catch {
63
+ // On error, return undefined (let opencode use its default)
64
+ }
65
+
66
+ return undefined;
67
+ }
68
+
69
+ /**
70
+ * Inject ship context into a session.
71
+ *
72
+ * Runs `ship prime` and injects the output along with CLI guidance.
73
+ * Silently skips if ship is not installed or not initialized.
74
+ */
75
+ async function injectShipContext(
76
+ client: OpencodeClient,
77
+ $: PluginInput["$"],
78
+ sessionID: string,
79
+ context?: { model?: { providerID: string; modelID: string }; agent?: string }
80
+ ): Promise<void> {
81
+ try {
82
+ const primeOutput = await $`ship prime`.text();
83
+
84
+ if (!primeOutput || primeOutput.trim() === "") {
85
+ return;
86
+ }
87
+
88
+ const shipContext = `<ship-context>
89
+ ${primeOutput.trim()}
90
+ </ship-context>
91
+
92
+ ${SHIP_GUIDANCE}`;
93
+
94
+ // Inject content via noReply + synthetic
95
+ // Must pass model and agent to prevent mode/model switching
96
+ await client.session.prompt({
97
+ path: { id: sessionID },
98
+ body: {
99
+ noReply: true,
100
+ model: context?.model,
101
+ agent: context?.agent,
102
+ parts: [{ type: "text", text: shipContext, synthetic: true }],
103
+ },
104
+ });
105
+ } catch {
106
+ // Silent skip if ship prime fails (not installed or not initialized)
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Execute ship CLI command and return output
112
+ */
113
+ async function runShip(
114
+ $: PluginInput["$"],
115
+ args: string[]
116
+ ): Promise<{ success: boolean; output: string }> {
117
+ try {
118
+ const result = await $`ship ${args}`.nothrow();
119
+ const stdout = await new Response(result.stdout).text();
120
+ const stderr = await new Response(result.stderr).text();
121
+
122
+ if (result.exitCode !== 0) {
123
+ return { success: false, output: stderr || stdout };
124
+ }
125
+
126
+ return { success: true, output: stdout };
127
+ } catch (error) {
128
+ return {
129
+ success: false,
130
+ output: `Failed to run ship: ${error instanceof Error ? error.message : String(error)}`,
131
+ };
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Check if ship is configured in the given directory
137
+ */
138
+ async function isShipConfigured($: PluginInput["$"]): Promise<boolean> {
139
+ try {
140
+ const result = await $`test -f .ship/config.yaml`.nothrow();
141
+ return result.exitCode === 0;
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Format a list of tasks for display
149
+ */
150
+ function formatTaskList(
151
+ tasks: Array<{
152
+ identifier: string;
153
+ title: string;
154
+ priority: string;
155
+ status: string;
156
+ url: string;
157
+ }>
158
+ ): string {
159
+ return tasks
160
+ .map((t) => {
161
+ const priority =
162
+ t.priority === "urgent"
163
+ ? "[!]"
164
+ : t.priority === "high"
165
+ ? "[^]"
166
+ : " ";
167
+ return `${priority} ${t.identifier.padEnd(10)} ${t.status.padEnd(12)} ${t.title}`;
168
+ })
169
+ .join("\n");
170
+ }
171
+
172
+ /**
173
+ * Format task details for display
174
+ */
175
+ function formatTaskDetails(task: {
176
+ identifier: string;
177
+ title: string;
178
+ description?: string;
179
+ priority: string;
180
+ status: string;
181
+ labels: string[];
182
+ url: string;
183
+ branchName?: string;
184
+ }): string {
185
+ let output = `# ${task.identifier}: ${task.title}
186
+
187
+ **Status:** ${task.status}
188
+ **Priority:** ${task.priority}
189
+ **Labels:** ${task.labels.length > 0 ? task.labels.join(", ") : "none"}
190
+ **URL:** ${task.url}`;
191
+
192
+ if (task.branchName) {
193
+ output += `\n**Branch:** ${task.branchName}`;
194
+ }
195
+
196
+ if (task.description) {
197
+ output += `\n\n## Description\n\n${task.description}`;
198
+ }
199
+
200
+ return output;
201
+ }
202
+
203
+ /**
204
+ * Ship tool for task management
205
+ */
206
+ const shipTool = createTool({
207
+ description: `Linear task management for the current project.
208
+
209
+ Use this tool to:
210
+ - List tasks ready to work on (no blockers)
211
+ - View task details
212
+ - Start/complete tasks
213
+ - Create new tasks
214
+ - Manage task dependencies (blocking relationships)
215
+ - Get AI-optimized context about current work
216
+
217
+ Requires ship to be configured in the project (.ship/config.yaml).
218
+ Run 'ship init' in the terminal first if not configured.`,
219
+
220
+ args: {
221
+ action: createTool.schema
222
+ .enum([
223
+ "ready",
224
+ "list",
225
+ "blocked",
226
+ "show",
227
+ "start",
228
+ "done",
229
+ "create",
230
+ "block",
231
+ "unblock",
232
+ "prime",
233
+ "status",
234
+ ])
235
+ .describe(
236
+ "Action to perform: ready (unblocked tasks), list (all tasks), blocked (blocked tasks), show (task details), start (begin task), done (complete task), create (new task), block/unblock (dependencies), prime (AI context), status (current config)"
237
+ ),
238
+ taskId: createTool.schema
239
+ .string()
240
+ .optional()
241
+ .describe("Task identifier (e.g., BRI-123) - required for show, start, done"),
242
+ title: createTool.schema
243
+ .string()
244
+ .optional()
245
+ .describe("Task title - required for create"),
246
+ description: createTool.schema
247
+ .string()
248
+ .optional()
249
+ .describe("Task description - optional for create"),
250
+ priority: createTool.schema
251
+ .enum(["urgent", "high", "medium", "low", "none"])
252
+ .optional()
253
+ .describe("Task priority - optional for create"),
254
+ blocker: createTool.schema
255
+ .string()
256
+ .optional()
257
+ .describe("Blocker task ID - required for block/unblock"),
258
+ blocked: createTool.schema
259
+ .string()
260
+ .optional()
261
+ .describe("Blocked task ID - required for block/unblock"),
262
+ filter: createTool.schema
263
+ .object({
264
+ status: createTool.schema
265
+ .enum(["backlog", "todo", "in_progress", "in_review", "done", "cancelled"])
266
+ .optional(),
267
+ priority: createTool.schema.enum(["urgent", "high", "medium", "low", "none"]).optional(),
268
+ mine: createTool.schema.boolean().optional(),
269
+ })
270
+ .optional()
271
+ .describe("Filters for list action"),
272
+ },
273
+
274
+ async execute(args, ctx) {
275
+ const $ = (ctx as any).$ as PluginInput["$"];
276
+
277
+ // Check if ship is configured
278
+ if (args.action !== "status") {
279
+ const configured = await isShipConfigured($);
280
+ if (!configured) {
281
+ return `Ship is not configured in this project.
282
+
283
+ Run 'ship init' in the terminal to:
284
+ 1. Authenticate with Linear (paste your API key from https://linear.app/settings/api)
285
+ 2. Select your team
286
+ 3. Optionally select a project
287
+
288
+ After that, you can use this tool to manage tasks.`;
289
+ }
290
+ }
291
+
292
+ switch (args.action) {
293
+ case "status": {
294
+ const configured = await isShipConfigured($);
295
+ if (!configured) {
296
+ return "Ship is not configured. Run 'ship init' first.";
297
+ }
298
+ return "Ship is configured in this project.";
299
+ }
300
+
301
+ case "ready": {
302
+ const result = await runShip($, ["ready", "--json"]);
303
+ if (!result.success) {
304
+ return `Failed to get ready tasks: ${result.output}`;
305
+ }
306
+ try {
307
+ const tasks = JSON.parse(result.output);
308
+ if (tasks.length === 0) {
309
+ return "No tasks ready to work on (all tasks are either blocked or completed).";
310
+ }
311
+ return `Ready tasks (no blockers):\n\n${formatTaskList(tasks)}`;
312
+ } catch {
313
+ return result.output;
314
+ }
315
+ }
316
+
317
+ case "list": {
318
+ const listArgs = ["list", "--json"];
319
+ if (args.filter?.status) listArgs.push("--status", args.filter.status);
320
+ if (args.filter?.priority) listArgs.push("--priority", args.filter.priority);
321
+ if (args.filter?.mine) listArgs.push("--mine");
322
+
323
+ const result = await runShip($, listArgs);
324
+ if (!result.success) {
325
+ return `Failed to list tasks: ${result.output}`;
326
+ }
327
+ try {
328
+ const tasks = JSON.parse(result.output);
329
+ if (tasks.length === 0) {
330
+ return "No tasks found matching the filter.";
331
+ }
332
+ return `Tasks:\n\n${formatTaskList(tasks)}`;
333
+ } catch {
334
+ return result.output;
335
+ }
336
+ }
337
+
338
+ case "blocked": {
339
+ const result = await runShip($, ["blocked", "--json"]);
340
+ if (!result.success) {
341
+ return `Failed to get blocked tasks: ${result.output}`;
342
+ }
343
+ try {
344
+ const tasks = JSON.parse(result.output);
345
+ if (tasks.length === 0) {
346
+ return "No blocked tasks.";
347
+ }
348
+ return `Blocked tasks:\n\n${formatTaskList(tasks)}`;
349
+ } catch {
350
+ return result.output;
351
+ }
352
+ }
353
+
354
+ case "show": {
355
+ if (!args.taskId) {
356
+ return "Error: taskId is required for show action";
357
+ }
358
+ const result = await runShip($, ["show", args.taskId, "--json"]);
359
+ if (!result.success) {
360
+ return `Failed to get task: ${result.output}`;
361
+ }
362
+ try {
363
+ const task = JSON.parse(result.output);
364
+ return formatTaskDetails(task);
365
+ } catch {
366
+ return result.output;
367
+ }
368
+ }
369
+
370
+ case "start": {
371
+ if (!args.taskId) {
372
+ return "Error: taskId is required for start action";
373
+ }
374
+ const result = await runShip($, ["start", args.taskId]);
375
+ if (!result.success) {
376
+ return `Failed to start task: ${result.output}`;
377
+ }
378
+ return `Started working on ${args.taskId}`;
379
+ }
380
+
381
+ case "done": {
382
+ if (!args.taskId) {
383
+ return "Error: taskId is required for done action";
384
+ }
385
+ const result = await runShip($, ["done", args.taskId]);
386
+ if (!result.success) {
387
+ return `Failed to complete task: ${result.output}`;
388
+ }
389
+ return `Completed ${args.taskId}`;
390
+ }
391
+
392
+ case "create": {
393
+ if (!args.title) {
394
+ return "Error: title is required for create action";
395
+ }
396
+ const createArgs = ["create", args.title, "--json"];
397
+ if (args.description) createArgs.push("--description", args.description);
398
+ if (args.priority) createArgs.push("--priority", args.priority);
399
+
400
+ const result = await runShip($, createArgs);
401
+ if (!result.success) {
402
+ return `Failed to create task: ${result.output}`;
403
+ }
404
+ try {
405
+ const task = JSON.parse(result.output);
406
+ return `Created task ${task.identifier}: ${task.title}\nURL: ${task.url}`;
407
+ } catch {
408
+ return result.output;
409
+ }
410
+ }
411
+
412
+ case "block": {
413
+ if (!args.blocker || !args.blocked) {
414
+ return "Error: both blocker and blocked task IDs are required";
415
+ }
416
+ const result = await runShip($, ["block", args.blocker, args.blocked]);
417
+ if (!result.success) {
418
+ return `Failed to add blocker: ${result.output}`;
419
+ }
420
+ return `${args.blocker} now blocks ${args.blocked}`;
421
+ }
422
+
423
+ case "unblock": {
424
+ if (!args.blocker || !args.blocked) {
425
+ return "Error: both blocker and blocked task IDs are required";
426
+ }
427
+ const result = await runShip($, ["unblock", args.blocker, args.blocked]);
428
+ if (!result.success) {
429
+ return `Failed to remove blocker: ${result.output}`;
430
+ }
431
+ return `Removed ${args.blocker} as blocker of ${args.blocked}`;
432
+ }
433
+
434
+ case "prime": {
435
+ const result = await runShip($, ["prime"]);
436
+ if (!result.success) {
437
+ return `Failed to get context: ${result.output}`;
438
+ }
439
+ return result.output;
440
+ }
441
+
442
+ default:
443
+ return `Unknown action: ${args.action}`;
444
+ }
445
+ },
446
+ });
447
+
448
+ /**
449
+ * Ship OpenCode Plugin
450
+ */
451
+ export const ShipPlugin: Plugin = async ({ client, $ }) => {
452
+ const injectedSessions = new Set<string>();
453
+
454
+ return {
455
+ "chat.message": async (_input, output) => {
456
+ const sessionID = output.message.sessionID;
457
+
458
+ // Skip if already injected this session
459
+ if (injectedSessions.has(sessionID)) return;
460
+
461
+ // Check if ship-context was already injected (handles plugin reload/reconnection)
462
+ try {
463
+ const existing = await client.session.messages({
464
+ path: { id: sessionID },
465
+ });
466
+
467
+ if (existing.data) {
468
+ const hasShipContext = existing.data.some((msg) => {
469
+ const parts = (msg as any).parts || (msg.info as any).parts;
470
+ if (!parts) return false;
471
+ return parts.some(
472
+ (part: any) =>
473
+ part.type === "text" && part.text?.includes("<ship-context>")
474
+ );
475
+ });
476
+
477
+ if (hasShipContext) {
478
+ injectedSessions.add(sessionID);
479
+ return;
480
+ }
481
+ }
482
+ } catch {
483
+ // On error, proceed with injection
484
+ }
485
+
486
+ injectedSessions.add(sessionID);
487
+
488
+ // Use output.message which has the resolved model/agent values
489
+ await injectShipContext(client, $, sessionID, {
490
+ model: output.message.model,
491
+ agent: output.message.agent,
492
+ });
493
+ },
494
+
495
+ event: async ({ event }) => {
496
+ if (event.type === "session.compacted") {
497
+ const sessionID = event.properties.sessionID;
498
+ const context = await getSessionContext(client, sessionID);
499
+ await injectShipContext(client, $, sessionID, context);
500
+ }
501
+ },
502
+
503
+ // Register the ship tool
504
+ tool: {
505
+ ship: shipTool,
506
+ },
507
+ };
508
+ };
509
+
510
+ // Default export for OpenCode
511
+ export default ShipPlugin;