@ship-cli/core 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 (48) hide show
  1. package/.tsbuildinfo/src.tsbuildinfo +1 -0
  2. package/.tsbuildinfo/test.tsbuildinfo +1 -0
  3. package/LICENSE +21 -0
  4. package/dist/bin.js +49230 -0
  5. package/package.json +50 -0
  6. package/src/adapters/driven/auth/AuthServiceLive.ts +125 -0
  7. package/src/adapters/driven/config/ConfigRepositoryLive.ts +366 -0
  8. package/src/adapters/driven/linear/IssueRepositoryLive.ts +494 -0
  9. package/src/adapters/driven/linear/LinearClient.ts +33 -0
  10. package/src/adapters/driven/linear/Mapper.ts +142 -0
  11. package/src/adapters/driven/linear/ProjectRepositoryLive.ts +98 -0
  12. package/src/adapters/driven/linear/TeamRepositoryLive.ts +100 -0
  13. package/src/adapters/driving/cli/commands/block.ts +63 -0
  14. package/src/adapters/driving/cli/commands/blocked.ts +61 -0
  15. package/src/adapters/driving/cli/commands/create.ts +83 -0
  16. package/src/adapters/driving/cli/commands/done.ts +82 -0
  17. package/src/adapters/driving/cli/commands/init.ts +194 -0
  18. package/src/adapters/driving/cli/commands/list.ts +87 -0
  19. package/src/adapters/driving/cli/commands/login.ts +46 -0
  20. package/src/adapters/driving/cli/commands/prime.ts +114 -0
  21. package/src/adapters/driving/cli/commands/project.ts +155 -0
  22. package/src/adapters/driving/cli/commands/ready.ts +73 -0
  23. package/src/adapters/driving/cli/commands/show.ts +94 -0
  24. package/src/adapters/driving/cli/commands/start.ts +99 -0
  25. package/src/adapters/driving/cli/commands/team.ts +134 -0
  26. package/src/adapters/driving/cli/commands/unblock.ts +63 -0
  27. package/src/adapters/driving/cli/main.ts +70 -0
  28. package/src/bin.ts +12 -0
  29. package/src/domain/Config.ts +42 -0
  30. package/src/domain/Errors.ts +89 -0
  31. package/src/domain/Task.ts +124 -0
  32. package/src/domain/index.ts +3 -0
  33. package/src/infrastructure/Layers.ts +45 -0
  34. package/src/ports/AuthService.ts +19 -0
  35. package/src/ports/ConfigRepository.ts +20 -0
  36. package/src/ports/IssueRepository.ts +69 -0
  37. package/src/ports/PrService.ts +52 -0
  38. package/src/ports/ProjectRepository.ts +19 -0
  39. package/src/ports/TeamRepository.ts +17 -0
  40. package/src/ports/VcsService.ts +87 -0
  41. package/src/ports/index.ts +7 -0
  42. package/test/Dummy.test.ts +7 -0
  43. package/tsconfig.base.json +45 -0
  44. package/tsconfig.json +7 -0
  45. package/tsconfig.src.json +11 -0
  46. package/tsconfig.test.json +10 -0
  47. package/tsup.config.ts +14 -0
  48. package/vitest.config.ts +12 -0
@@ -0,0 +1,87 @@
1
+ import * as Command from "@effect/cli/Command";
2
+ import * as Options from "@effect/cli/Options";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Console from "effect/Console";
5
+ import * as Option from "effect/Option";
6
+ import { ConfigRepository } from "../../../../ports/ConfigRepository.js";
7
+ import { IssueRepository } from "../../../../ports/IssueRepository.js";
8
+ import { TaskFilter, type TaskStatus, type Priority, type Task } from "../../../../domain/Task.js";
9
+
10
+ const jsonOption = Options.boolean("json").pipe(
11
+ Options.withDescription("Output as JSON"),
12
+ Options.withDefault(false),
13
+ );
14
+
15
+ const statusOption = Options.choice("status", [
16
+ "backlog",
17
+ "todo",
18
+ "in_progress",
19
+ "in_review",
20
+ "done",
21
+ "cancelled",
22
+ ]).pipe(Options.withAlias("s"), Options.withDescription("Filter by status"), Options.optional);
23
+
24
+ const priorityOption = Options.choice("priority", ["urgent", "high", "medium", "low", "none"]).pipe(
25
+ Options.withAlias("P"),
26
+ Options.withDescription("Filter by priority"),
27
+ Options.optional,
28
+ );
29
+
30
+ const mineOption = Options.boolean("mine").pipe(
31
+ Options.withAlias("m"),
32
+ Options.withDescription("Show only tasks assigned to me"),
33
+ Options.withDefault(false),
34
+ );
35
+
36
+ const formatTask = (task: Task): string => {
37
+ const priority = task.priority === "urgent" ? "[!]" : task.priority === "high" ? "[^]" : " ";
38
+ const stateName = task.state.name.padEnd(11);
39
+ return `${priority} ${task.identifier.padEnd(10)} ${stateName} ${task.title}`;
40
+ };
41
+
42
+ export const listCommand = Command.make(
43
+ "list",
44
+ { json: jsonOption, status: statusOption, priority: priorityOption, mine: mineOption },
45
+ ({ json, status, priority, mine }) =>
46
+ Effect.gen(function* () {
47
+ const config = yield* ConfigRepository;
48
+ const issueRepo = yield* IssueRepository;
49
+
50
+ const cfg = yield* config.load();
51
+
52
+ const filter = new TaskFilter({
53
+ status: Option.isSome(status) ? Option.some(status.value as TaskStatus) : Option.none(),
54
+ priority: Option.isSome(priority) ? Option.some(priority.value as Priority) : Option.none(),
55
+ projectId: cfg.linear.projectId,
56
+ assignedToMe: mine,
57
+ });
58
+
59
+ const taskList = yield* issueRepo.listTasks(cfg.linear.teamId, filter);
60
+
61
+ if (json) {
62
+ const output = taskList.map((t) => ({
63
+ id: t.id,
64
+ identifier: t.identifier,
65
+ title: t.title,
66
+ priority: t.priority,
67
+ state: t.state.name,
68
+ stateType: t.state.type,
69
+ labels: t.labels,
70
+ url: t.url,
71
+ branchName: Option.getOrNull(t.branchName),
72
+ }));
73
+ yield* Console.log(JSON.stringify(output, null, 2));
74
+ } else {
75
+ if (taskList.length === 0) {
76
+ yield* Console.log("No tasks found matching the filter.");
77
+ } else {
78
+ yield* Console.log(`Found ${taskList.length} task(s):\n`);
79
+ yield* Console.log("PRI IDENTIFIER STATUS TITLE");
80
+ yield* Console.log("─".repeat(60));
81
+ for (const task of taskList) {
82
+ yield* Console.log(formatTask(task));
83
+ }
84
+ }
85
+ }
86
+ }),
87
+ );
@@ -0,0 +1,46 @@
1
+ import * as Command from "@effect/cli/Command";
2
+ import * as Effect from "effect/Effect";
3
+ import * as clack from "@clack/prompts";
4
+ import { AuthService } from "../../../../ports/AuthService.js";
5
+
6
+ export const loginCommand = Command.make("login", {}, () =>
7
+ Effect.gen(function* () {
8
+ const auth = yield* AuthService;
9
+
10
+ clack.intro("ship login");
11
+
12
+ clack.note(
13
+ "Create a personal API key at:\nhttps://linear.app/settings/api",
14
+ "Linear Authentication",
15
+ );
16
+
17
+ const apiKey = yield* Effect.tryPromise({
18
+ try: () =>
19
+ clack.text({
20
+ message: "Paste your API key",
21
+ placeholder: "lin_api_...",
22
+ validate: (value) => {
23
+ if (!value) return "API key is required";
24
+ if (!value.startsWith("lin_api_")) return "API key should start with lin_api_";
25
+ },
26
+ }),
27
+ catch: () => new Error("Prompt cancelled"),
28
+ });
29
+
30
+ if (clack.isCancel(apiKey)) {
31
+ clack.cancel("Login cancelled");
32
+ return;
33
+ }
34
+
35
+ const spinner = clack.spinner();
36
+ spinner.start("Validating API key...");
37
+
38
+ yield* auth
39
+ .saveApiKey(apiKey as string)
40
+ .pipe(Effect.tapError(() => Effect.sync(() => spinner.stop("Invalid API key"))));
41
+
42
+ spinner.stop("API key validated");
43
+
44
+ clack.outro("Run 'ship init' to select your team and project.");
45
+ }),
46
+ );
@@ -0,0 +1,114 @@
1
+ import * as Command from "@effect/cli/Command";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Console from "effect/Console";
4
+ import * as Option from "effect/Option";
5
+ import { ConfigRepository } from "../../../../ports/ConfigRepository.js";
6
+ import { IssueRepository } from "../../../../ports/IssueRepository.js";
7
+ import { TaskFilter, type Task } from "../../../../domain/Task.js";
8
+
9
+ const AGENT_GUIDANCE = `## Agent Restrictions
10
+
11
+ 1. **Never create issues without user confirmation**
12
+ 2. **Check blockers before starting work** - blocked tasks should be surfaced
13
+ 3. **Small, focused tasks only** - if a task seems too large, suggest splitting
14
+ 4. **Always update status** - in_progress when starting, done when complete
15
+ 5. **Stack blocking tasks** - work on blockers first, stack changes appropriately
16
+ 6. **Use conventional commits** - format: type(TASK-ID): description
17
+
18
+ ## CLI Commands
19
+
20
+ - \`ship ready --json\` - List tasks with no blockers
21
+ - \`ship show <id> --json\` - Show task details
22
+ - \`ship start <id>\` - Begin work (sets status to in_progress)
23
+ - \`ship done <id> --reason "msg"\` - Complete task
24
+ - \`ship blocked --json\` - Show blocked tasks
25
+ - \`ship block <blocker> <blocked>\` - Create blocking relationship
26
+ - \`ship unblock <blocker> <blocked>\` - Remove blocking relationship
27
+ - \`ship list --json\` - List all tasks
28
+ - \`ship create "title" -p priority -t type\` - Create new task
29
+
30
+ Always use \`--json\` flag for programmatic output.`;
31
+
32
+ const formatTaskCompact = (task: Task): string => {
33
+ const priority = task.priority === "urgent" ? "!" : task.priority === "high" ? "^" : "";
34
+ return `${priority}${task.identifier}: ${task.title} [${task.state.name}]`;
35
+ };
36
+
37
+ export const primeCommand = Command.make("prime", {}, () =>
38
+ Effect.gen(function* () {
39
+ const config = yield* ConfigRepository;
40
+ const issueRepo = yield* IssueRepository;
41
+
42
+ const cfg = yield* config.load();
43
+ const projectId = Option.getOrUndefined(cfg.linear.projectId);
44
+
45
+ // Get ready and blocked tasks
46
+ const [readyTasks, blockedTasks] = yield* Effect.all([
47
+ issueRepo.getReadyTasks(cfg.linear.teamId, projectId),
48
+ issueRepo.getBlockedTasks(cfg.linear.teamId, projectId),
49
+ ]);
50
+
51
+ // Get in-progress tasks (filter by "started" state type)
52
+ const filter = new TaskFilter({
53
+ status: Option.some("in_progress"),
54
+ priority: Option.none(),
55
+ projectId: cfg.linear.projectId,
56
+ assignedToMe: false,
57
+ });
58
+ const allTasks = yield* issueRepo.listTasks(cfg.linear.teamId, filter);
59
+
60
+ // Filter to only "started" state type tasks
61
+ const inProgressTasks = allTasks.filter((t: Task) => t.state.type === "started");
62
+
63
+ // Build context output
64
+ const lines: string[] = [];
65
+
66
+ lines.push("<ship-context>");
67
+ lines.push(`Team: ${cfg.linear.teamKey}`);
68
+ if (Option.isSome(cfg.linear.projectId)) {
69
+ lines.push(`Project: ${cfg.linear.projectId.value}`);
70
+ }
71
+ lines.push("");
72
+
73
+ if (inProgressTasks.length > 0) {
74
+ lines.push("## In Progress");
75
+ for (const task of inProgressTasks) {
76
+ lines.push(`- ${formatTaskCompact(task)}`);
77
+ }
78
+ lines.push("");
79
+ }
80
+
81
+ if (readyTasks.length > 0) {
82
+ lines.push("## Ready to Work");
83
+ for (const task of readyTasks.slice(0, 10)) {
84
+ lines.push(`- ${formatTaskCompact(task)}`);
85
+ }
86
+ if (readyTasks.length > 10) {
87
+ lines.push(` ... and ${readyTasks.length - 10} more`);
88
+ }
89
+ lines.push("");
90
+ }
91
+
92
+ if (blockedTasks.length > 0) {
93
+ lines.push("## Blocked");
94
+ for (const task of blockedTasks.slice(0, 5)) {
95
+ lines.push(`- ${formatTaskCompact(task)}`);
96
+ if (task.blockedBy.length > 0) {
97
+ lines.push(` Blocked by: ${task.blockedBy.join(", ")}`);
98
+ }
99
+ }
100
+ if (blockedTasks.length > 5) {
101
+ lines.push(` ... and ${blockedTasks.length - 5} more`);
102
+ }
103
+ lines.push("");
104
+ }
105
+
106
+ lines.push("</ship-context>");
107
+ lines.push("");
108
+ lines.push("<ship-guidance>");
109
+ lines.push(AGENT_GUIDANCE);
110
+ lines.push("</ship-guidance>");
111
+
112
+ yield* Console.log(lines.join("\n"));
113
+ }),
114
+ );
@@ -0,0 +1,155 @@
1
+ import * as Command from "@effect/cli/Command";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Option from "effect/Option";
4
+ import * as clack from "@clack/prompts";
5
+ import { ConfigRepository } from "../../../../ports/ConfigRepository.js";
6
+ import { AuthService } from "../../../../ports/AuthService.js";
7
+ import { ProjectRepository } from "../../../../ports/ProjectRepository.js";
8
+ import { LinearConfig } from "../../../../domain/Config.js";
9
+ import type { Project, ProjectId } from "../../../../domain/Task.js";
10
+
11
+ const CREATE_NEW = "__create_new__" as const;
12
+ const NO_PROJECT = null;
13
+
14
+ export const projectCommand = Command.make("project", {}, () =>
15
+ Effect.gen(function* () {
16
+ const config = yield* ConfigRepository;
17
+ const auth = yield* AuthService;
18
+ const projectRepo = yield* ProjectRepository;
19
+
20
+ clack.intro("ship project");
21
+
22
+ // Check authentication
23
+ const isAuth = yield* auth.isAuthenticated();
24
+ if (!isAuth) {
25
+ clack.log.error("Not authenticated. Run 'ship login' first.");
26
+ clack.outro("Setup required");
27
+ return;
28
+ }
29
+
30
+ // Check team is configured
31
+ const partial = yield* config.loadPartial();
32
+ if (Option.isNone(partial.linear)) {
33
+ clack.log.error("No team configured. Run 'ship init' first.");
34
+ clack.outro("Setup required");
35
+ return;
36
+ }
37
+
38
+ const currentConfig = partial.linear.value;
39
+ clack.log.info(`Team: ${currentConfig.teamKey}`);
40
+
41
+ if (Option.isSome(currentConfig.projectId)) {
42
+ clack.log.info(`Current project: ${currentConfig.projectId.value}`);
43
+ }
44
+
45
+ // Fetch projects
46
+ const spinner = clack.spinner();
47
+ spinner.start("Fetching projects...");
48
+ const projects = yield* projectRepo.getProjects(currentConfig.teamId);
49
+ spinner.stop("Projects loaded");
50
+
51
+ // Select project or create new
52
+ const currentProjectId = Option.isSome(currentConfig.projectId)
53
+ ? currentConfig.projectId.value
54
+ : null;
55
+
56
+ const projectOptions: Array<{
57
+ value: ProjectId | typeof CREATE_NEW | typeof NO_PROJECT;
58
+ label: string;
59
+ hint?: string;
60
+ }> = [
61
+ { value: NO_PROJECT, label: "No project filter", hint: "show all team tasks" },
62
+ ...projects.map((p) =>
63
+ currentProjectId === p.id
64
+ ? { value: p.id, label: p.name, hint: "current" as const }
65
+ : { value: p.id, label: p.name },
66
+ ),
67
+ { value: CREATE_NEW, label: "Create new project..." },
68
+ ];
69
+
70
+ const projectChoice = yield* Effect.tryPromise({
71
+ try: () =>
72
+ clack.select({
73
+ message: "Select a project",
74
+ options: projectOptions,
75
+ }),
76
+ catch: () => new Error("Prompt cancelled"),
77
+ });
78
+
79
+ if (clack.isCancel(projectChoice)) {
80
+ clack.cancel("Cancelled");
81
+ return;
82
+ }
83
+
84
+ let selectedProject: Project | undefined;
85
+
86
+ if (projectChoice === CREATE_NEW) {
87
+ // Create new project
88
+ const projectName = yield* Effect.tryPromise({
89
+ try: () =>
90
+ clack.text({
91
+ message: "Project name",
92
+ placeholder: "My Project",
93
+ validate: (v) => (!v ? "Name is required" : undefined),
94
+ }),
95
+ catch: () => new Error("Prompt cancelled"),
96
+ });
97
+
98
+ if (clack.isCancel(projectName)) {
99
+ clack.cancel("Cancelled");
100
+ return;
101
+ }
102
+
103
+ const projectDesc = yield* Effect.tryPromise({
104
+ try: () =>
105
+ clack.text({
106
+ message: "Description (optional)",
107
+ placeholder: "A brief description of the project",
108
+ }),
109
+ catch: () => new Error("Prompt cancelled"),
110
+ });
111
+
112
+ if (clack.isCancel(projectDesc)) {
113
+ clack.cancel("Cancelled");
114
+ return;
115
+ }
116
+
117
+ const createSpinner = clack.spinner();
118
+ createSpinner.start("Creating project...");
119
+
120
+ const createInput = { name: projectName as string } as { name: string; description?: string };
121
+ if (projectDesc) {
122
+ createInput.description = projectDesc as string;
123
+ }
124
+
125
+ selectedProject = yield* projectRepo.createProject(currentConfig.teamId, createInput);
126
+
127
+ createSpinner.stop(`Created project: ${selectedProject.name}`);
128
+ } else if (projectChoice !== NO_PROJECT) {
129
+ const found = projects.find((p) => p.id === projectChoice);
130
+ if (!found) {
131
+ clack.log.error("Selected project not found. Please try again.");
132
+ clack.outro("Error");
133
+ return;
134
+ }
135
+ selectedProject = found;
136
+ }
137
+
138
+ // Save updated config
139
+ const linearConfig = new LinearConfig({
140
+ teamId: currentConfig.teamId,
141
+ teamKey: currentConfig.teamKey,
142
+ projectId: selectedProject ? Option.some(selectedProject.id) : Option.none(),
143
+ });
144
+
145
+ yield* config.saveLinear(linearConfig);
146
+
147
+ if (selectedProject) {
148
+ clack.log.success(`Switched to project: ${selectedProject.name}`);
149
+ } else {
150
+ clack.log.success("Cleared project filter");
151
+ }
152
+
153
+ clack.outro("Run 'ship ready' to see available tasks.");
154
+ }),
155
+ );
@@ -0,0 +1,73 @@
1
+ import * as Command from "@effect/cli/Command";
2
+ import * as Options from "@effect/cli/Options";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Console from "effect/Console";
5
+ import * as Option from "effect/Option";
6
+ import { ConfigRepository } from "../../../../ports/ConfigRepository.js";
7
+ import { IssueRepository } from "../../../../ports/IssueRepository.js";
8
+ import type { Task } from "../../../../domain/Task.js";
9
+
10
+ const jsonOption = Options.boolean("json").pipe(
11
+ Options.withDescription("Output as JSON"),
12
+ Options.withDefault(false),
13
+ );
14
+
15
+ const formatTaskDetailed = (task: Task): string[] => {
16
+ const lines: string[] = [];
17
+ const priority =
18
+ task.priority === "urgent"
19
+ ? "URGENT"
20
+ : task.priority === "high"
21
+ ? "HIGH"
22
+ : task.priority === "medium"
23
+ ? "MEDIUM"
24
+ : task.priority === "low"
25
+ ? "LOW"
26
+ : "";
27
+
28
+ lines.push(`${task.identifier}: ${task.title}`);
29
+ if (priority) lines.push(` Priority: ${priority}`);
30
+ if (task.labels.length > 0) lines.push(` Labels: ${task.labels.join(", ")}`);
31
+ if (Option.isSome(task.branchName)) lines.push(` Branch: ${task.branchName.value}`);
32
+ lines.push(` URL: ${task.url}`);
33
+ return lines;
34
+ };
35
+
36
+ export const readyCommand = Command.make("ready", { json: jsonOption }, ({ json }) =>
37
+ Effect.gen(function* () {
38
+ const config = yield* ConfigRepository;
39
+ const issueRepo = yield* IssueRepository;
40
+
41
+ const cfg = yield* config.load();
42
+ const projectId = Option.getOrUndefined(cfg.linear.projectId);
43
+ const readyTasks = yield* issueRepo.getReadyTasks(cfg.linear.teamId, projectId);
44
+
45
+ if (json) {
46
+ const output = readyTasks.map((t) => ({
47
+ id: t.id,
48
+ identifier: t.identifier,
49
+ title: t.title,
50
+ priority: t.priority,
51
+ state: t.state.name,
52
+ stateType: t.state.type,
53
+ labels: t.labels,
54
+ url: t.url,
55
+ branchName: Option.getOrNull(t.branchName),
56
+ }));
57
+ yield* Console.log(JSON.stringify(output, null, 2));
58
+ } else {
59
+ if (readyTasks.length === 0) {
60
+ yield* Console.log("No ready tasks found.");
61
+ yield* Console.log("All tasks may be blocked or completed.");
62
+ } else {
63
+ yield* Console.log(`Found ${readyTasks.length} ready task(s):\n`);
64
+ for (const task of readyTasks) {
65
+ for (const line of formatTaskDetailed(task)) {
66
+ yield* Console.log(line);
67
+ }
68
+ yield* Console.log("");
69
+ }
70
+ }
71
+ }
72
+ }),
73
+ );
@@ -0,0 +1,94 @@
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 type { Task, TaskId } from "../../../../domain/Task.js";
9
+
10
+ const taskIdArg = Args.text({ name: "task-id" }).pipe(
11
+ Args.withDescription("Task identifier (e.g., ENG-123)"),
12
+ );
13
+
14
+ const jsonOption = Options.boolean("json").pipe(
15
+ Options.withDescription("Output as JSON"),
16
+ Options.withDefault(false),
17
+ );
18
+
19
+ const formatTask = (task: Task): string[] => {
20
+ const lines: string[] = [];
21
+
22
+ lines.push(`${task.identifier}: ${task.title}`);
23
+ lines.push("─".repeat(50));
24
+ lines.push(`Status: ${task.state.name}`);
25
+ lines.push(`Priority: ${task.priority}`);
26
+
27
+ if (task.labels.length > 0) {
28
+ lines.push(`Labels: ${task.labels.join(", ")}`);
29
+ }
30
+
31
+ if (Option.isSome(task.branchName)) {
32
+ lines.push(`Branch: ${task.branchName.value}`);
33
+ }
34
+
35
+ lines.push(`URL: ${task.url}`);
36
+
37
+ if (Option.isSome(task.description)) {
38
+ lines.push("");
39
+ lines.push("Description:");
40
+ lines.push(task.description.value);
41
+ }
42
+
43
+ if (task.blockedBy.length > 0) {
44
+ lines.push("");
45
+ lines.push(`Blocked by: ${task.blockedBy.join(", ")}`);
46
+ }
47
+
48
+ if (task.blocks.length > 0) {
49
+ lines.push(`Blocks: ${task.blocks.join(", ")}`);
50
+ }
51
+
52
+ return lines;
53
+ };
54
+
55
+ export const showCommand = Command.make(
56
+ "show",
57
+ { taskId: taskIdArg, json: jsonOption },
58
+ ({ taskId, json }) =>
59
+ Effect.gen(function* () {
60
+ const issueRepo = yield* IssueRepository;
61
+
62
+ // Try to get by identifier first (ENG-123 format)
63
+ const task = yield* issueRepo.getTaskByIdentifier(taskId).pipe(
64
+ Effect.catchTag("TaskNotFoundError", () =>
65
+ // Fallback to ID lookup
66
+ issueRepo.getTask(taskId as TaskId),
67
+ ),
68
+ );
69
+
70
+ if (json) {
71
+ const output = {
72
+ id: task.id,
73
+ identifier: task.identifier,
74
+ title: task.title,
75
+ description: Option.getOrNull(task.description),
76
+ priority: task.priority,
77
+ state: task.state.name,
78
+ stateType: task.state.type,
79
+ labels: task.labels,
80
+ url: task.url,
81
+ branchName: Option.getOrNull(task.branchName),
82
+ blockedBy: task.blockedBy,
83
+ blocks: task.blocks,
84
+ createdAt: task.createdAt.toISOString(),
85
+ updatedAt: task.updatedAt.toISOString(),
86
+ };
87
+ yield* Console.log(JSON.stringify(output, null, 2));
88
+ } else {
89
+ for (const line of formatTask(task)) {
90
+ yield* Console.log(line);
91
+ }
92
+ }
93
+ }),
94
+ );
@@ -0,0 +1,99 @@
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 { ConfigRepository } from "../../../../ports/ConfigRepository.js";
8
+ import { IssueRepository } from "../../../../ports/IssueRepository.js";
9
+ import { UpdateTaskInput, type TaskId } from "../../../../domain/Task.js";
10
+
11
+ const taskIdArg = Args.text({ name: "task-id" }).pipe(
12
+ Args.withDescription("Task identifier (e.g., ENG-123)"),
13
+ );
14
+
15
+ const jsonOption = Options.boolean("json").pipe(
16
+ Options.withDescription("Output as JSON"),
17
+ Options.withDefault(false),
18
+ );
19
+
20
+ export const startCommand = Command.make(
21
+ "start",
22
+ { taskId: taskIdArg, json: jsonOption },
23
+ ({ taskId, json }) =>
24
+ Effect.gen(function* () {
25
+ const config = yield* ConfigRepository;
26
+ const issueRepo = yield* IssueRepository;
27
+
28
+ yield* config.load(); // Ensure initialized
29
+
30
+ // Get the task
31
+ const task = yield* issueRepo
32
+ .getTaskByIdentifier(taskId)
33
+ .pipe(Effect.catchTag("TaskNotFoundError", () => issueRepo.getTask(taskId as TaskId)));
34
+
35
+ // Check if already in progress (Linear's "started" state type)
36
+ if (task.state.type === "started") {
37
+ if (json) {
38
+ yield* Console.log(JSON.stringify({ status: "already_in_progress", task: taskId }));
39
+ } else {
40
+ yield* Console.log(`Task ${task.identifier} is already in progress (${task.state.name}).`);
41
+ }
42
+ return;
43
+ }
44
+
45
+ // Check if blocked
46
+ if (task.blockedBy.length > 0) {
47
+ if (json) {
48
+ yield* Console.log(
49
+ JSON.stringify({
50
+ status: "blocked",
51
+ task: taskId,
52
+ blockedBy: task.blockedBy,
53
+ }),
54
+ );
55
+ } else {
56
+ yield* Console.log(
57
+ `Warning: Task ${task.identifier} is blocked by: ${task.blockedBy.join(", ")}`,
58
+ );
59
+ yield* Console.log("Consider working on the blocking tasks first.");
60
+ }
61
+ // Continue anyway - user might want to start despite blockers
62
+ }
63
+
64
+ // Update status to in_progress
65
+ const updateInput = new UpdateTaskInput({
66
+ title: Option.none(),
67
+ description: Option.none(),
68
+ status: Option.some("in_progress"),
69
+ priority: Option.none(),
70
+ });
71
+
72
+ const updatedTask = yield* issueRepo.updateTask(task.id, updateInput);
73
+
74
+ // Get branch name
75
+ const branchName = yield* issueRepo.getBranchName(task.id);
76
+
77
+ if (json) {
78
+ yield* Console.log(
79
+ JSON.stringify({
80
+ status: "started",
81
+ task: {
82
+ id: updatedTask.id,
83
+ identifier: updatedTask.identifier,
84
+ title: updatedTask.title,
85
+ state: updatedTask.state.name,
86
+ branchName,
87
+ },
88
+ }),
89
+ );
90
+ } else {
91
+ yield* Console.log(`Started: ${updatedTask.identifier} - ${updatedTask.title}`);
92
+ yield* Console.log(`Status: ${updatedTask.state.name}`);
93
+ yield* Console.log(`\nBranch name: ${branchName}`);
94
+ yield* Console.log("\nTo create a jj change with this branch (Phase 2):");
95
+ yield* Console.log(` jj new -m "${updatedTask.identifier}: ${updatedTask.title}"`);
96
+ yield* Console.log(` jj bookmark create ${branchName}`);
97
+ }
98
+ }),
99
+ );