@ship-cli/core 0.0.2 → 0.0.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ship-cli/core",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Linear + jj workflow CLI for AI agents",
@@ -453,6 +453,34 @@ const make = Effect.gen(function* () {
453
453
  "Removing blocker",
454
454
  );
455
455
 
456
+ const addRelated = (
457
+ taskId: TaskId,
458
+ relatedTaskId: TaskId,
459
+ ): Effect.Effect<void, TaskNotFoundError | TaskError | LinearApiError> =>
460
+ withRetryAndTimeout(
461
+ Effect.gen(function* () {
462
+ const client = yield* linearClient.client();
463
+
464
+ const result = yield* Effect.tryPromise({
465
+ try: () =>
466
+ client.createIssueRelation({
467
+ issueId: taskId,
468
+ relatedIssueId: relatedTaskId,
469
+ type: LinearDocument.IssueRelationType.Related,
470
+ }),
471
+ catch: (e) =>
472
+ new LinearApiError({ message: `Failed to create relation: ${e}`, cause: e }),
473
+ });
474
+
475
+ if (!result.success) {
476
+ return yield* Effect.fail(
477
+ new TaskError({ message: "Failed to create related relation" }),
478
+ );
479
+ }
480
+ }),
481
+ "Adding related",
482
+ );
483
+
456
484
  const getBranchName = (id: TaskId): Effect.Effect<string, TaskNotFoundError | LinearApiError> =>
457
485
  withRetryAndTimeout(
458
486
  Effect.gen(function* () {
@@ -492,6 +520,7 @@ const make = Effect.gen(function* () {
492
520
  getBlockedTasks,
493
521
  addBlocker,
494
522
  removeBlocker,
523
+ addRelated,
495
524
  getBranchName,
496
525
  };
497
526
  });
@@ -11,6 +11,39 @@ const formatTaskCompact = (task: Task): string => {
11
11
  return `${priority}${task.identifier}: ${task.title} [${task.state.name}]`;
12
12
  };
13
13
 
14
+ const formatTaskFull = (task: Task): string[] => {
15
+ const lines: string[] = [];
16
+ const priority = task.priority === "urgent" ? "!" : task.priority === "high" ? "^" : "";
17
+
18
+ lines.push(`### ${priority}${task.identifier}: ${task.title}`);
19
+ lines.push(`**Status:** ${task.state.name} | **Priority:** ${task.priority}`);
20
+
21
+ if (task.labels.length > 0) {
22
+ lines.push(`**Labels:** ${task.labels.join(", ")}`);
23
+ }
24
+
25
+ if (Option.isSome(task.branchName)) {
26
+ lines.push(`**Branch:** \`${task.branchName.value}\``);
27
+ }
28
+
29
+ if (task.blockedBy.length > 0) {
30
+ lines.push(`**Blocked by:** ${task.blockedBy.join(", ")}`);
31
+ }
32
+
33
+ if (task.blocks.length > 0) {
34
+ lines.push(`**Blocks:** ${task.blocks.join(", ")}`);
35
+ }
36
+
37
+ if (Option.isSome(task.description) && task.description.value.trim()) {
38
+ lines.push("");
39
+ lines.push(task.description.value);
40
+ }
41
+
42
+ lines.push(`**URL:** ${task.url}`);
43
+
44
+ return lines;
45
+ };
46
+
14
47
  export const primeCommand = Command.make("prime", {}, () =>
15
48
  Effect.gen(function* () {
16
49
  const config = yield* ConfigRepository;
@@ -45,14 +78,20 @@ export const primeCommand = Command.make("prime", {}, () =>
45
78
  lines.push(`Project: ${cfg.linear.projectId.value}`);
46
79
  }
47
80
 
81
+ // Show in-progress tasks with FULL details (description, acceptance criteria, etc.)
48
82
  if (inProgressTasks.length > 0) {
49
83
  lines.push("");
50
- lines.push("## In Progress");
84
+ lines.push("## Current Work (In Progress)");
85
+ lines.push("");
51
86
  for (const task of inProgressTasks) {
52
- lines.push(`- ${formatTaskCompact(task)}`);
87
+ lines.push(...formatTaskFull(task));
88
+ lines.push("");
89
+ lines.push("---");
90
+ lines.push("");
53
91
  }
54
92
  }
55
93
 
94
+ // Show ready tasks with compact format (just titles)
56
95
  if (readyTasks.length > 0) {
57
96
  lines.push("");
58
97
  lines.push("## Ready to Work");
@@ -64,6 +103,7 @@ export const primeCommand = Command.make("prime", {}, () =>
64
103
  }
65
104
  }
66
105
 
106
+ // Show blocked tasks with blockers info
67
107
  if (blockedTasks.length > 0) {
68
108
  lines.push("");
69
109
  lines.push("## Blocked");
@@ -0,0 +1,56 @@
1
+ import * as Command from "@effect/cli/Command";
2
+ import * as Args from "@effect/cli/Args";
3
+ import * as Options from "@effect/cli/Options";
4
+ import * as Effect from "effect/Effect";
5
+ import * as Console from "effect/Console";
6
+ import { IssueRepository } from "../../../../ports/IssueRepository.js";
7
+ import type { TaskId } from "../../../../domain/Task.js";
8
+
9
+ const taskAArg = Args.text({ name: "task-a" }).pipe(
10
+ Args.withDescription("First task identifier (e.g., BRI-123)"),
11
+ );
12
+
13
+ const taskBArg = Args.text({ name: "task-b" }).pipe(
14
+ Args.withDescription("Second task identifier (e.g., BRI-456)"),
15
+ );
16
+
17
+ const jsonOption = Options.boolean("json").pipe(
18
+ Options.withDescription("Output as JSON"),
19
+ Options.withDefault(false),
20
+ );
21
+
22
+ export const relateCommand = Command.make(
23
+ "relate",
24
+ { taskA: taskAArg, taskB: taskBArg, json: jsonOption },
25
+ ({ taskA, taskB, json }) =>
26
+ Effect.gen(function* () {
27
+ const issueRepo = yield* IssueRepository;
28
+
29
+ // Resolve identifiers to IDs
30
+ const [resolvedA, resolvedB] = yield* Effect.all([
31
+ issueRepo
32
+ .getTaskByIdentifier(taskA)
33
+ .pipe(Effect.catchTag("TaskNotFoundError", () => issueRepo.getTask(taskA as TaskId))),
34
+ issueRepo
35
+ .getTaskByIdentifier(taskB)
36
+ .pipe(Effect.catchTag("TaskNotFoundError", () => issueRepo.getTask(taskB as TaskId))),
37
+ ]);
38
+
39
+ yield* issueRepo.addRelated(resolvedA.id, resolvedB.id);
40
+
41
+ if (json) {
42
+ yield* Console.log(
43
+ JSON.stringify({
44
+ status: "related",
45
+ taskA: resolvedA.identifier,
46
+ taskB: resolvedB.identifier,
47
+ }),
48
+ );
49
+ } else {
50
+ yield* Console.log(`Linked ${resolvedA.identifier} ↔ ${resolvedB.identifier} as related`);
51
+ yield* Console.log(
52
+ `\nMentioning ${resolvedA.identifier} in ${resolvedB.identifier}'s description (or vice versa) will now auto-link.`,
53
+ );
54
+ }
55
+ }),
56
+ );
@@ -0,0 +1,125 @@
1
+ import * as Command from "@effect/cli/Command";
2
+ import * as Args from "@effect/cli/Args";
3
+ import * as Options from "@effect/cli/Options";
4
+ import * as Effect from "effect/Effect";
5
+ import * as Console from "effect/Console";
6
+ import * as Option from "effect/Option";
7
+ import { IssueRepository } from "../../../../ports/IssueRepository.js";
8
+ import {
9
+ UpdateTaskInput,
10
+ type TaskId,
11
+ type Priority,
12
+ type TaskStatus,
13
+ } from "../../../../domain/Task.js";
14
+
15
+ const taskIdArg = Args.text({ name: "task-id" }).pipe(
16
+ Args.withDescription("Task identifier (e.g., ENG-123)"),
17
+ );
18
+
19
+ const titleOption = Options.text("title").pipe(
20
+ Options.withAlias("t"),
21
+ Options.withDescription("New task title"),
22
+ Options.optional,
23
+ );
24
+
25
+ const descriptionOption = Options.text("description").pipe(
26
+ Options.withAlias("d"),
27
+ Options.withDescription("New task description (use - to read from stdin)"),
28
+ Options.optional,
29
+ );
30
+
31
+ const priorityOption = Options.choice("priority", ["urgent", "high", "medium", "low", "none"]).pipe(
32
+ Options.withAlias("p"),
33
+ Options.withDescription("New task priority"),
34
+ Options.optional,
35
+ );
36
+
37
+ const statusOption = Options.choice("status", [
38
+ "backlog",
39
+ "todo",
40
+ "in_progress",
41
+ "in_review",
42
+ "done",
43
+ "cancelled",
44
+ ]).pipe(Options.withAlias("s"), Options.withDescription("New task status"), Options.optional);
45
+
46
+ const jsonOption = Options.boolean("json").pipe(
47
+ Options.withDescription("Output as JSON"),
48
+ Options.withDefault(false),
49
+ );
50
+
51
+ export const updateCommand = Command.make(
52
+ "update",
53
+ {
54
+ taskId: taskIdArg,
55
+ title: titleOption,
56
+ description: descriptionOption,
57
+ priority: priorityOption,
58
+ status: statusOption,
59
+ json: jsonOption,
60
+ },
61
+ ({ taskId, title, description, priority, status, json }) =>
62
+ Effect.gen(function* () {
63
+ const issueRepo = yield* IssueRepository;
64
+
65
+ // Check if any update was provided
66
+ const hasUpdate =
67
+ Option.isSome(title) ||
68
+ Option.isSome(description) ||
69
+ Option.isSome(priority) ||
70
+ Option.isSome(status);
71
+
72
+ if (!hasUpdate) {
73
+ yield* Console.error(
74
+ "No updates provided. Use --title, --description, --priority, or --status.",
75
+ );
76
+ return;
77
+ }
78
+
79
+ // Get the task first to resolve identifier to ID
80
+ const existingTask = yield* issueRepo
81
+ .getTaskByIdentifier(taskId)
82
+ .pipe(Effect.catchTag("TaskNotFoundError", () => issueRepo.getTask(taskId as TaskId)));
83
+
84
+ // Build update input
85
+ const input = new UpdateTaskInput({
86
+ title: Option.isSome(title) ? Option.some(title.value) : Option.none(),
87
+ description: Option.isSome(description) ? Option.some(description.value) : Option.none(),
88
+ priority: Option.isSome(priority) ? Option.some(priority.value as Priority) : Option.none(),
89
+ status: Option.isSome(status) ? Option.some(status.value as TaskStatus) : Option.none(),
90
+ });
91
+
92
+ const task = yield* issueRepo.updateTask(existingTask.id, input);
93
+
94
+ if (json) {
95
+ const output = {
96
+ status: "updated",
97
+ task: {
98
+ id: task.id,
99
+ identifier: task.identifier,
100
+ title: task.title,
101
+ description: Option.getOrNull(task.description),
102
+ priority: task.priority,
103
+ state: task.state.name,
104
+ url: task.url,
105
+ },
106
+ };
107
+ yield* Console.log(JSON.stringify(output, null, 2));
108
+ } else {
109
+ yield* Console.log(`Updated: ${task.identifier} - ${task.title}`);
110
+ if (Option.isSome(title)) {
111
+ yield* Console.log(`Title: ${task.title}`);
112
+ }
113
+ if (Option.isSome(description)) {
114
+ yield* Console.log(`Description updated`);
115
+ }
116
+ if (Option.isSome(priority)) {
117
+ yield* Console.log(`Priority: ${task.priority}`);
118
+ }
119
+ if (Option.isSome(status)) {
120
+ yield* Console.log(`Status: ${task.state.name}`);
121
+ }
122
+ yield* Console.log(`URL: ${task.url}`);
123
+ }
124
+ }),
125
+ );
@@ -14,6 +14,8 @@ import { blockCommand } from "./commands/block.js";
14
14
  import { unblockCommand } from "./commands/unblock.js";
15
15
  import { blockedCommand } from "./commands/blocked.js";
16
16
  import { primeCommand } from "./commands/prime.js";
17
+ import { updateCommand } from "./commands/update.js";
18
+ import { relateCommand } from "./commands/relate.js";
17
19
 
18
20
  // Root command
19
21
  const ship = Command.make("ship", {}, () =>
@@ -35,9 +37,11 @@ Commands:
35
37
 
36
38
  start <id> Start working on a task
37
39
  done <id> Mark task as complete
40
+ update <id> Update task details
38
41
 
39
42
  block <a> <b> Mark task A as blocking task B
40
43
  unblock <a> <b> Remove blocking relationship
44
+ relate <a> <b> Link two tasks as related
41
45
 
42
46
  prime Output AI-optimized context
43
47
 
@@ -56,9 +60,11 @@ export const command = ship.pipe(
56
60
  showCommand,
57
61
  startCommand,
58
62
  doneCommand,
63
+ updateCommand,
59
64
  createCommand,
60
65
  blockCommand,
61
66
  unblockCommand,
67
+ relateCommand,
62
68
  blockedCommand,
63
69
  primeCommand,
64
70
  ]),
@@ -62,6 +62,12 @@ export interface IssueRepository {
62
62
  blockerId: TaskId,
63
63
  ) => Effect.Effect<void, TaskNotFoundError | TaskError | LinearApiError>;
64
64
 
65
+ /** Add a "related" relationship between tasks (enables auto-linking in Linear) */
66
+ readonly addRelated: (
67
+ taskId: TaskId,
68
+ relatedTaskId: TaskId,
69
+ ) => Effect.Effect<void, TaskNotFoundError | TaskError | LinearApiError>;
70
+
65
71
  /** Get the suggested branch name for a task */
66
72
  readonly getBranchName: (id: TaskId) => Effect.Effect<string, TaskNotFoundError | LinearApiError>;
67
73
  }
@@ -0,0 +1 @@
1
+ {"fileNames":[],"fileInfos":[],"root":[],"options":{"allowJs":false,"checkJs":false,"composite":true,"declaration":true,"declarationMap":true,"downlevelIteration":true,"emitDecoratorMetadata":false,"esModuleInterop":false,"exactOptionalPropertyTypes":true,"experimentalDecorators":true,"module":199,"noEmitOnError":false,"noErrorTruncation":false,"noFallthroughCasesInSwitch":true,"noImplicitAny":true,"noImplicitReturns":false,"noImplicitThis":true,"noUncheckedIndexedAccess":false,"noUnusedLocals":true,"noUnusedParameters":false,"removeComments":false,"skipLibCheck":true,"sourceMap":true,"strict":true,"strictNullChecks":true,"stripInternal":true,"target":9},"version":"5.6.3"}