@ship-cli/opencode 0.0.3 → 0.0.5

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/plugin.ts +192 -51
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ship-cli/opencode",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin for Ship - Linear task management integration",
6
6
  "license": "MIT",
package/src/plugin.ts CHANGED
@@ -14,28 +14,92 @@ import { tool as createTool } from "@opencode-ai/plugin";
14
14
 
15
15
  type OpencodeClient = PluginInput["client"];
16
16
 
17
- const SHIP_GUIDANCE = `## Ship CLI Guidance
17
+ const SHIP_GUIDANCE = `## Ship Tool Guidance
18
+
19
+ **IMPORTANT: Always use the \`ship\` tool, NEVER run \`ship\` or \`pnpm ship\` via bash/terminal.**
20
+
21
+ The \`ship\` tool is available for Linear task management. Use it instead of CLI commands.
22
+
23
+ ### Available Actions (via ship tool)
24
+ - \`ready\` - Tasks you can work on (no blockers)
25
+ - \`blocked\` - Tasks waiting on dependencies
26
+ - \`list\` - All tasks (with optional filters)
27
+ - \`show\` - Task details (requires taskId)
28
+ - \`start\` - Begin working on task (requires taskId)
29
+ - \`done\` - Mark task complete (requires taskId)
30
+ - \`create\` - Create new task (requires title)
31
+ - \`update\` - Update task (requires taskId + fields to update)
32
+ - \`block\` - Add blocking relationship (requires blocker + blocked)
33
+ - \`unblock\` - Remove blocking relationship (requires blocker + blocked)
34
+ - \`relate\` - Link tasks as related (requires taskId + relatedTaskId)
35
+ - \`prime\` - Get AI context
36
+ - \`status\` - Check configuration
18
37
 
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
38
+ ### Best Practices
39
+ 1. Use \`ship\` tool with action \`ready\` to see available work
40
+ 2. Use \`ship\` tool with action \`start\` before beginning work
41
+ 3. Use \`ship\` tool with action \`done\` when completing tasks
42
+ 4. Use \`ship\` tool with action \`block\` for dependency relationships
24
43
 
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
44
+ ### Linear Task Relationships
33
45
 
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`;
46
+ Linear has native relationship types. **Always use these instead of writing dependencies in text:**
47
+
48
+ **Blocking (for dependencies):**
49
+ - Use ship tool: action=\`block\`, blocker=\`BRI-100\`, blocked=\`BRI-101\`
50
+ - Use ship tool: action=\`unblock\` to remove relationships
51
+ - Use ship tool: action=\`blocked\` to see waiting tasks
52
+
53
+ **Related (for cross-references):**
54
+ - Use ship tool: action=\`relate\`, taskId=\`BRI-100\`, relatedTaskId=\`BRI-101\`
55
+ - Use this when tasks are conceptually related but not blocking each other
56
+
57
+ **Mentioning Tasks in Descriptions (Clickable Pills):**
58
+ To create clickable task pills in descriptions, use full markdown links:
59
+ \`[BRI-123](https://linear.app/WORKSPACE/issue/BRI-123/slug)\`
60
+
61
+ Get the full URL from ship tool (action=\`show\`, taskId=\`BRI-123\`) and use it in markdown link format.
62
+ Plain text \`BRI-123\` will NOT create clickable pills.
63
+
64
+ ### Task Description Template
65
+
66
+ \`\`\`markdown
67
+ ## Context
68
+ Brief explanation of why this task exists and where it fits.
69
+
70
+ ## Problem Statement
71
+ What specific problem does this task solve? Current vs desired behavior.
72
+
73
+ ## Implementation Notes
74
+ - Key files: \`path/to/file.ts\`
75
+ - Patterns: Reference existing implementations
76
+ - Technical constraints
77
+
78
+ ## Acceptance Criteria
79
+ - [ ] Specific, testable requirement 1
80
+ - [ ] Specific, testable requirement 2
81
+ - [ ] Tests pass
82
+
83
+ ## Out of Scope
84
+ - What NOT to include
85
+
86
+ ## Dependencies
87
+ - Blocked by: [BRI-XXX](url) (brief reason)
88
+ - Blocks: [BRI-YYY](url) (brief reason)
89
+ \`\`\`
90
+
91
+ **Important:**
92
+ 1. Set blocking relationships via ship tool action=\`block\` (appears in Linear sidebar)
93
+ 2. ALSO document in description using markdown links for context
94
+ 3. Get task URLs from ship tool action=\`show\`
95
+
96
+ ### Task Quality Checklist
97
+ - Title is actionable and specific (not "Fix bug" but "Fix null pointer in UserService.getById")
98
+ - Context explains WHY, not just WHAT
99
+ - Acceptance criteria are testable
100
+ - **Dependencies set via \`ship block\`** AND documented with markdown links
101
+ - Links use full URL format: \`[BRI-123](https://linear.app/...)\`
102
+ - Priority reflects business impact (urgent/high/medium/low)`;
39
103
 
40
104
  /**
41
105
  * Get the current model/agent context for a session by querying messages.
@@ -66,45 +130,45 @@ async function getSessionContext(
66
130
  return undefined;
67
131
  }
68
132
 
133
+ /**
134
+ * Get the ship command based on NODE_ENV.
135
+ *
136
+ * - NODE_ENV=development: Use "pnpm ship" (for developing the CLI in this repo)
137
+ * - Otherwise: Use "ship" (globally installed CLI)
138
+ */
139
+ function getShipCommand(): string[] {
140
+ if (process.env.NODE_ENV === "development") {
141
+ return ["pnpm", "ship"];
142
+ }
143
+ return ["ship"];
144
+ }
145
+
69
146
  /**
70
147
  * Inject ship context into a session.
71
148
  *
72
- * Runs `ship prime` and injects the output along with CLI guidance.
73
- * Silently skips if ship is not installed or not initialized.
149
+ * Injects static guidance for using the ship tool. Does NOT fetch live data
150
+ * from Linear - the AI should use the ship tool to get task data.
151
+ * This ensures instant response on first message.
74
152
  */
75
153
  async function injectShipContext(
76
154
  client: OpencodeClient,
77
- $: PluginInput["$"],
78
155
  sessionID: string,
79
156
  context?: { model?: { providerID: string; modelID: string }; agent?: string }
80
157
  ): Promise<void> {
81
158
  try {
82
- const primeOutput = await $`ship prime`.text();
83
-
84
- if (!primeOutput || primeOutput.trim() === "") {
85
- return;
86
- }
87
-
88
- // Wrap the plain markdown output with XML tags (like beads plugin does)
89
- const shipContext = `<ship-context>
90
- ${primeOutput.trim()}
91
- </ship-context>
92
-
93
- ${SHIP_GUIDANCE}`;
94
-
95
- // Inject content via noReply + synthetic
96
- // Must pass model and agent to prevent mode/model switching
159
+ // Inject only the static guidance - no API calls
160
+ // The AI will use the ship tool to fetch live data when needed
97
161
  await client.session.prompt({
98
162
  path: { id: sessionID },
99
163
  body: {
100
164
  noReply: true,
101
165
  model: context?.model,
102
166
  agent: context?.agent,
103
- parts: [{ type: "text", text: shipContext, synthetic: true }],
167
+ parts: [{ type: "text", text: SHIP_GUIDANCE, synthetic: true }],
104
168
  },
105
169
  });
106
170
  } catch {
107
- // Silent skip if ship prime fails (not installed or not initialized)
171
+ // Silent skip on error
108
172
  }
109
173
  }
110
174
 
@@ -116,7 +180,9 @@ async function runShip(
116
180
  args: string[]
117
181
  ): Promise<{ success: boolean; output: string }> {
118
182
  try {
119
- const result = await $`ship ${args}`.nothrow();
183
+ const cmd = getShipCommand();
184
+ // Use quiet() to prevent output from bleeding into TUI
185
+ const result = await $`${cmd} ${args}`.quiet().nothrow();
120
186
  const stdout = await new Response(result.stdout).text();
121
187
  const stderr = await new Response(result.stderr).text();
122
188
 
@@ -139,8 +205,10 @@ async function runShip(
139
205
  */
140
206
  async function isShipConfigured($: PluginInput["$"]): Promise<boolean> {
141
207
  try {
208
+ const cmd = getShipCommand();
142
209
  // Try running ship prime - it will fail if not configured
143
- const result = await $`ship prime`.nothrow();
210
+ // Use quiet() to suppress stdout/stderr from bleeding into TUI
211
+ const result = await $`${cmd} prime`.quiet().nothrow();
144
212
  return result.exitCode === 0;
145
213
  } catch {
146
214
  return false;
@@ -231,30 +299,36 @@ Run 'ship init' in the terminal first if not configured.`,
231
299
  "start",
232
300
  "done",
233
301
  "create",
302
+ "update",
234
303
  "block",
235
304
  "unblock",
305
+ "relate",
236
306
  "prime",
237
307
  "status",
238
308
  ])
239
309
  .describe(
240
- "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)"
310
+ "Action to perform: ready (unblocked tasks), list (all tasks), blocked (blocked tasks), show (task details), start (begin task), done (complete task), create (new task), update (modify task), block/unblock (dependencies), relate (link related tasks), prime (AI context), status (current config)"
241
311
  ),
242
312
  taskId: createTool.schema
243
313
  .string()
244
314
  .optional()
245
- .describe("Task identifier (e.g., BRI-123) - required for show, start, done"),
315
+ .describe("Task identifier (e.g., BRI-123) - required for show, start, done, update"),
246
316
  title: createTool.schema
247
317
  .string()
248
318
  .optional()
249
- .describe("Task title - required for create"),
319
+ .describe("Task title - required for create, optional for update"),
250
320
  description: createTool.schema
251
321
  .string()
252
322
  .optional()
253
- .describe("Task description - optional for create"),
323
+ .describe("Task description - optional for create/update"),
254
324
  priority: createTool.schema
255
325
  .enum(["urgent", "high", "medium", "low", "none"])
256
326
  .optional()
257
- .describe("Task priority - optional for create"),
327
+ .describe("Task priority - optional for create/update"),
328
+ status: createTool.schema
329
+ .enum(["backlog", "todo", "in_progress", "in_review", "done", "cancelled"])
330
+ .optional()
331
+ .describe("Task status - optional for update"),
258
332
  blocker: createTool.schema
259
333
  .string()
260
334
  .optional()
@@ -263,6 +337,10 @@ Run 'ship init' in the terminal first if not configured.`,
263
337
  .string()
264
338
  .optional()
265
339
  .describe("Blocked task ID - required for block/unblock"),
340
+ relatedTaskId: createTool.schema
341
+ .string()
342
+ .optional()
343
+ .describe("Related task ID - required for relate (use with taskId)"),
266
344
  filter: createTool.schema
267
345
  .object({
268
346
  status: createTool.schema
@@ -357,7 +435,7 @@ After that, you can use this tool to manage tasks.`;
357
435
  if (!args.taskId) {
358
436
  return "Error: taskId is required for show action";
359
437
  }
360
- const result = await runShip($, ["show", args.taskId, "--json"]);
438
+ const result = await runShip($, ["show", "--json", args.taskId]);
361
439
  if (!result.success) {
362
440
  return `Failed to get task: ${result.output}`;
363
441
  }
@@ -395,22 +473,51 @@ After that, you can use this tool to manage tasks.`;
395
473
  if (!args.title) {
396
474
  return "Error: title is required for create action";
397
475
  }
398
- const createArgs = ["create", args.title, "--json"];
476
+ const createArgs = ["create", "--json"];
399
477
  if (args.description) createArgs.push("--description", args.description);
400
478
  if (args.priority) createArgs.push("--priority", args.priority);
479
+ createArgs.push(args.title);
401
480
 
402
481
  const result = await runShip($, createArgs);
403
482
  if (!result.success) {
404
483
  return `Failed to create task: ${result.output}`;
405
484
  }
406
485
  try {
407
- const task = JSON.parse(result.output);
486
+ const response = JSON.parse(result.output);
487
+ const task = response.task;
408
488
  return `Created task ${task.identifier}: ${task.title}\nURL: ${task.url}`;
409
489
  } catch {
410
490
  return result.output;
411
491
  }
412
492
  }
413
493
 
494
+ case "update": {
495
+ if (!args.taskId) {
496
+ return "Error: taskId is required for update action";
497
+ }
498
+ if (!args.title && !args.description && !args.priority && !args.status) {
499
+ return "Error: at least one of title, description, priority, or status is required for update";
500
+ }
501
+ const updateArgs = ["update", "--json"];
502
+ if (args.title) updateArgs.push("--title", args.title);
503
+ if (args.description) updateArgs.push("--description", args.description);
504
+ if (args.priority) updateArgs.push("--priority", args.priority);
505
+ if (args.status) updateArgs.push("--status", args.status);
506
+ updateArgs.push(args.taskId);
507
+
508
+ const result = await runShip($, updateArgs);
509
+ if (!result.success) {
510
+ return `Failed to update task: ${result.output}`;
511
+ }
512
+ try {
513
+ const response = JSON.parse(result.output);
514
+ const task = response.task;
515
+ return `Updated task ${task.identifier}: ${task.title}\nURL: ${task.url}`;
516
+ } catch {
517
+ return result.output;
518
+ }
519
+ }
520
+
414
521
  case "block": {
415
522
  if (!args.blocker || !args.blocked) {
416
523
  return "Error: both blocker and blocked task IDs are required";
@@ -433,6 +540,17 @@ After that, you can use this tool to manage tasks.`;
433
540
  return `Removed ${args.blocker} as blocker of ${args.blocked}`;
434
541
  }
435
542
 
543
+ case "relate": {
544
+ if (!args.taskId || !args.relatedTaskId) {
545
+ return "Error: both taskId and relatedTaskId are required for relate action";
546
+ }
547
+ const result = await runShip($, ["relate", args.taskId, args.relatedTaskId]);
548
+ if (!result.success) {
549
+ return `Failed to relate tasks: ${result.output}`;
550
+ }
551
+ return `Linked ${args.taskId} ↔ ${args.relatedTaskId} as related`;
552
+ }
553
+
436
554
  case "prime": {
437
555
  const result = await runShip($, ["prime"]);
438
556
  if (!result.success) {
@@ -451,6 +569,24 @@ After that, you can use this tool to manage tasks.`;
451
569
  /**
452
570
  * Ship OpenCode Plugin
453
571
  */
572
+ // Pre-define commands (loaded at plugin init, not lazily in config hook)
573
+ const SHIP_COMMANDS = {
574
+ ready: {
575
+ description: "Find ready-to-work tasks with no blockers",
576
+ template: `Use the \`ship\` tool with action \`ready\` to find tasks that are ready to work on (no blocking dependencies).
577
+
578
+ Present the results in a clear format showing:
579
+ - Task ID (e.g., BRI-123)
580
+ - Title
581
+ - Priority
582
+ - URL
583
+
584
+ If there are ready tasks, ask the user which one they'd like to work on. If they choose one, use the \`ship\` tool with action \`start\` to begin work on it.
585
+
586
+ If there are no ready tasks, suggest checking blocked tasks (action \`blocked\`) or creating a new task (action \`create\`).`,
587
+ },
588
+ };
589
+
454
590
  export const ShipPlugin: Plugin = async ({ client, $ }) => {
455
591
  const injectedSessions = new Set<string>();
456
592
 
@@ -492,7 +628,7 @@ export const ShipPlugin: Plugin = async ({ client, $ }) => {
492
628
  injectedSessions.add(sessionID);
493
629
 
494
630
  // Use output.message which has the resolved model/agent values
495
- await injectShipContext(client, $, sessionID, {
631
+ await injectShipContext(client, sessionID, {
496
632
  model: output.message.model,
497
633
  agent: output.message.agent,
498
634
  });
@@ -502,10 +638,15 @@ export const ShipPlugin: Plugin = async ({ client, $ }) => {
502
638
  if (event.type === "session.compacted") {
503
639
  const sessionID = event.properties.sessionID;
504
640
  const context = await getSessionContext(client, sessionID);
505
- await injectShipContext(client, $, sessionID, context);
641
+ await injectShipContext(client, sessionID, context);
506
642
  }
507
643
  },
508
644
 
645
+ config: async (config) => {
646
+ // Register commands (using pre-defined SHIP_COMMANDS for reliability)
647
+ config.command = { ...config.command, ...SHIP_COMMANDS };
648
+ },
649
+
509
650
  // Register the ship tool
510
651
  tool: {
511
652
  ship: shipTool,