@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,134 @@
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 { TeamRepository } from "../../../../ports/TeamRepository.js";
8
+ import { LinearConfig } from "../../../../domain/Config.js";
9
+ import type { Team, TeamId } from "../../../../domain/Task.js";
10
+
11
+ const CREATE_NEW = "__create_new__" as const;
12
+
13
+ export const teamCommand = Command.make("team", {}, () =>
14
+ Effect.gen(function* () {
15
+ const config = yield* ConfigRepository;
16
+ const auth = yield* AuthService;
17
+ const teamRepo = yield* TeamRepository;
18
+
19
+ clack.intro("ship team");
20
+
21
+ // Check authentication
22
+ const isAuth = yield* auth.isAuthenticated();
23
+ if (!isAuth) {
24
+ clack.log.error("Not authenticated. Run 'ship login' first.");
25
+ clack.outro("Setup required");
26
+ return;
27
+ }
28
+
29
+ // Show current team
30
+ const partial = yield* config.loadPartial();
31
+ if (Option.isSome(partial.linear)) {
32
+ clack.log.info(`Current team: ${partial.linear.value.teamKey}`);
33
+ }
34
+
35
+ // Fetch teams
36
+ const spinner = clack.spinner();
37
+ spinner.start("Fetching teams...");
38
+ const teams = yield* teamRepo.getTeams();
39
+ spinner.stop("Teams loaded");
40
+
41
+ // Select team or create new
42
+ const currentTeamId = Option.isSome(partial.linear) ? partial.linear.value.teamId : null;
43
+ const teamOptions: Array<{ value: TeamId | typeof CREATE_NEW; label: string; hint?: string }> = [
44
+ ...teams.map((t) =>
45
+ currentTeamId === t.id
46
+ ? { value: t.id, label: `${t.key} - ${t.name}`, hint: "current" as const }
47
+ : { value: t.id, label: `${t.key} - ${t.name}` },
48
+ ),
49
+ { value: CREATE_NEW, label: "Create new team..." },
50
+ ];
51
+
52
+ const teamChoice = yield* Effect.tryPromise({
53
+ try: () =>
54
+ clack.select({
55
+ message: "Select a team",
56
+ options: teamOptions,
57
+ }),
58
+ catch: () => new Error("Prompt cancelled"),
59
+ });
60
+
61
+ if (clack.isCancel(teamChoice)) {
62
+ clack.cancel("Cancelled");
63
+ return;
64
+ }
65
+
66
+ let selectedTeam: Team;
67
+
68
+ if (teamChoice === CREATE_NEW) {
69
+ // Create new team
70
+ const teamName = yield* Effect.tryPromise({
71
+ try: () =>
72
+ clack.text({
73
+ message: "Team name",
74
+ placeholder: "My Team",
75
+ validate: (v) => (!v ? "Name is required" : undefined),
76
+ }),
77
+ catch: () => new Error("Prompt cancelled"),
78
+ });
79
+
80
+ if (clack.isCancel(teamName)) {
81
+ clack.cancel("Cancelled");
82
+ return;
83
+ }
84
+
85
+ const teamKey = yield* Effect.tryPromise({
86
+ try: () =>
87
+ clack.text({
88
+ message: "Team key (short identifier, e.g. ENG)",
89
+ placeholder: "ENG",
90
+ validate: (v) => {
91
+ if (!v) return "Key is required";
92
+ if (!/^[A-Z]{2,5}$/.test(v.toUpperCase())) return "Key must be 2-5 uppercase letters";
93
+ },
94
+ }),
95
+ catch: () => new Error("Prompt cancelled"),
96
+ });
97
+
98
+ if (clack.isCancel(teamKey)) {
99
+ clack.cancel("Cancelled");
100
+ return;
101
+ }
102
+
103
+ const createSpinner = clack.spinner();
104
+ createSpinner.start("Creating team...");
105
+
106
+ selectedTeam = yield* teamRepo.createTeam({
107
+ name: teamName as string,
108
+ key: (teamKey as string).toUpperCase(),
109
+ });
110
+
111
+ createSpinner.stop(`Created team: ${selectedTeam.key}`);
112
+ } else {
113
+ const found = teams.find((t) => t.id === teamChoice);
114
+ if (!found) {
115
+ clack.log.error("Selected team not found. Please try again.");
116
+ clack.outro("Error");
117
+ return;
118
+ }
119
+ selectedTeam = found;
120
+ }
121
+
122
+ // Save new team config (clears project since it's team-specific)
123
+ const linearConfig = new LinearConfig({
124
+ teamId: selectedTeam.id,
125
+ teamKey: selectedTeam.key,
126
+ projectId: Option.none(),
127
+ });
128
+
129
+ yield* config.saveLinear(linearConfig);
130
+
131
+ clack.log.success(`Switched to team: ${selectedTeam.key} - ${selectedTeam.name}`);
132
+ clack.outro("Run 'ship project' to select a project.");
133
+ }),
134
+ );
@@ -0,0 +1,63 @@
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 { ConfigRepository } from "../../../../ports/ConfigRepository.js";
7
+ import { IssueRepository } from "../../../../ports/IssueRepository.js";
8
+ import type { TaskId } from "../../../../domain/Task.js";
9
+
10
+ const blockerArg = Args.text({ name: "blocker" }).pipe(
11
+ Args.withDescription("Task that was blocking (e.g., ENG-123)"),
12
+ );
13
+
14
+ const blockedArg = Args.text({ name: "blocked" }).pipe(
15
+ Args.withDescription("Task that was blocked (e.g., ENG-456)"),
16
+ );
17
+
18
+ const jsonOption = Options.boolean("json").pipe(
19
+ Options.withDescription("Output as JSON"),
20
+ Options.withDefault(false),
21
+ );
22
+
23
+ export const unblockCommand = Command.make(
24
+ "unblock",
25
+ { blocker: blockerArg, blocked: blockedArg, json: jsonOption },
26
+ ({ blocker, blocked, json }) =>
27
+ Effect.gen(function* () {
28
+ const config = yield* ConfigRepository;
29
+ const issueRepo = yield* IssueRepository;
30
+
31
+ yield* config.load(); // Ensure initialized
32
+
33
+ // Get both tasks to get their IDs
34
+ const blockerTask = yield* issueRepo
35
+ .getTaskByIdentifier(blocker)
36
+ .pipe(Effect.catchTag("TaskNotFoundError", () => issueRepo.getTask(blocker as TaskId)));
37
+
38
+ const blockedTask = yield* issueRepo
39
+ .getTaskByIdentifier(blocked)
40
+ .pipe(Effect.catchTag("TaskNotFoundError", () => issueRepo.getTask(blocked as TaskId)));
41
+
42
+ // Remove the blocking relationship
43
+ yield* issueRepo.removeBlocker(blockedTask.id, blockerTask.id);
44
+
45
+ if (json) {
46
+ yield* Console.log(
47
+ JSON.stringify({
48
+ status: "unblocked",
49
+ blocker: {
50
+ id: blockerTask.id,
51
+ identifier: blockerTask.identifier,
52
+ },
53
+ blocked: {
54
+ id: blockedTask.id,
55
+ identifier: blockedTask.identifier,
56
+ },
57
+ }),
58
+ );
59
+ } else {
60
+ yield* Console.log(`${blockerTask.identifier} no longer blocks ${blockedTask.identifier}`);
61
+ }
62
+ }),
63
+ );
@@ -0,0 +1,70 @@
1
+ import * as Command from "@effect/cli/Command";
2
+ import * as Console from "effect/Console";
3
+ import { initCommand } from "./commands/init.js";
4
+ import { loginCommand } from "./commands/login.js";
5
+ import { teamCommand } from "./commands/team.js";
6
+ import { projectCommand } from "./commands/project.js";
7
+ import { readyCommand } from "./commands/ready.js";
8
+ import { listCommand } from "./commands/list.js";
9
+ import { showCommand } from "./commands/show.js";
10
+ import { startCommand } from "./commands/start.js";
11
+ import { doneCommand } from "./commands/done.js";
12
+ import { createCommand } from "./commands/create.js";
13
+ import { blockCommand } from "./commands/block.js";
14
+ import { unblockCommand } from "./commands/unblock.js";
15
+ import { blockedCommand } from "./commands/blocked.js";
16
+ import { primeCommand } from "./commands/prime.js";
17
+
18
+ // Root command
19
+ const ship = Command.make("ship", {}, () =>
20
+ Console.log(`ship - Linear + jj workflow CLI
21
+
22
+ Usage: ship <command> [options]
23
+
24
+ Commands:
25
+ init Initialize workspace and authenticate
26
+ login Re-authenticate with Linear
27
+ team Switch team
28
+ project Switch project
29
+
30
+ ready List tasks ready to work on (no blockers)
31
+ blocked List blocked tasks
32
+ list List all tasks with filters
33
+ show <id> Show task details
34
+ create <title> Create new task
35
+
36
+ start <id> Start working on a task
37
+ done <id> Mark task as complete
38
+
39
+ block <a> <b> Mark task A as blocking task B
40
+ unblock <a> <b> Remove blocking relationship
41
+
42
+ prime Output AI-optimized context
43
+
44
+ Run 'ship <command> --help' for more information on a command.`),
45
+ );
46
+
47
+ // Combine all commands
48
+ export const command = ship.pipe(
49
+ Command.withSubcommands([
50
+ initCommand,
51
+ loginCommand,
52
+ teamCommand,
53
+ projectCommand,
54
+ readyCommand,
55
+ listCommand,
56
+ showCommand,
57
+ startCommand,
58
+ doneCommand,
59
+ createCommand,
60
+ blockCommand,
61
+ unblockCommand,
62
+ blockedCommand,
63
+ primeCommand,
64
+ ]),
65
+ );
66
+
67
+ export const run = Command.run(command, {
68
+ name: "ship",
69
+ version: "0.0.1",
70
+ });
package/src/bin.ts ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+
3
+ import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4
+ import * as Effect from "effect/Effect";
5
+ import { run } from "./adapters/driving/cli/main.js";
6
+ import { AppLayer } from "./infrastructure/Layers.js";
7
+
8
+ // Run the CLI
9
+ run(process.argv).pipe(
10
+ Effect.provide(AppLayer),
11
+ NodeRuntime.runMain({ disableErrorReporting: false }),
12
+ );
@@ -0,0 +1,42 @@
1
+ import * as Schema from "effect/Schema";
2
+ import { ProjectId, TeamId } from "./Task.js";
3
+
4
+ // Personal API key from https://linear.app/settings/api
5
+ export class AuthConfig extends Schema.Class<AuthConfig>("AuthConfig")({
6
+ apiKey: Schema.String,
7
+ }) {}
8
+
9
+ export class LinearConfig extends Schema.Class<LinearConfig>("LinearConfig")({
10
+ teamId: TeamId,
11
+ teamKey: Schema.String,
12
+ projectId: Schema.OptionFromNullOr(ProjectId),
13
+ }) {}
14
+
15
+ export class GitConfig extends Schema.Class<GitConfig>("GitConfig")({
16
+ defaultBranch: Schema.optionalWith(Schema.String, { default: () => "main" }),
17
+ }) {}
18
+
19
+ export class PrConfig extends Schema.Class<PrConfig>("PrConfig")({
20
+ openBrowser: Schema.optionalWith(Schema.Boolean, { default: () => true }),
21
+ }) {}
22
+
23
+ export class CommitConfig extends Schema.Class<CommitConfig>("CommitConfig")({
24
+ conventionalFormat: Schema.optionalWith(Schema.Boolean, { default: () => true }),
25
+ }) {}
26
+
27
+ export class ShipConfig extends Schema.Class<ShipConfig>("ShipConfig")({
28
+ linear: LinearConfig,
29
+ auth: AuthConfig,
30
+ git: Schema.optionalWith(GitConfig, { default: () => new GitConfig({}) }),
31
+ pr: Schema.optionalWith(PrConfig, { default: () => new PrConfig({}) }),
32
+ commit: Schema.optionalWith(CommitConfig, { default: () => new CommitConfig({}) }),
33
+ }) {}
34
+
35
+ // Partial config for when we're initializing
36
+ export class PartialShipConfig extends Schema.Class<PartialShipConfig>("PartialShipConfig")({
37
+ linear: Schema.OptionFromNullOr(LinearConfig),
38
+ auth: Schema.OptionFromNullOr(AuthConfig),
39
+ git: Schema.optionalWith(GitConfig, { default: () => new GitConfig({}) }),
40
+ pr: Schema.optionalWith(PrConfig, { default: () => new PrConfig({}) }),
41
+ commit: Schema.optionalWith(CommitConfig, { default: () => new CommitConfig({}) }),
42
+ }) {}
@@ -0,0 +1,89 @@
1
+ import * as Data from "effect/Data";
2
+
3
+ // === Task Errors ===
4
+
5
+ export class TaskNotFoundError extends Data.TaggedError("TaskNotFoundError")<{
6
+ readonly taskId: string;
7
+ }> {
8
+ get message() {
9
+ return `Task not found: ${this.taskId}`;
10
+ }
11
+ }
12
+
13
+ export class TaskError extends Data.TaggedError("TaskError")<{
14
+ readonly message: string;
15
+ readonly cause?: unknown;
16
+ }> {}
17
+
18
+ // === Auth Errors ===
19
+
20
+ export class AuthError extends Data.TaggedError("AuthError")<{
21
+ readonly message: string;
22
+ readonly cause?: unknown;
23
+ }> {}
24
+
25
+ export class NotAuthenticatedError extends Data.TaggedError("NotAuthenticatedError")<{
26
+ readonly message: string;
27
+ }> {}
28
+
29
+ // === Config Errors ===
30
+
31
+ export class ConfigError extends Data.TaggedError("ConfigError")<{
32
+ readonly message: string;
33
+ readonly cause?: unknown;
34
+ }> {}
35
+
36
+ export class ConfigNotFoundError extends Data.TaggedError("ConfigNotFoundError")<{
37
+ readonly message: string;
38
+ }> {}
39
+
40
+ export class WorkspaceNotInitializedError extends Data.TaggedError("WorkspaceNotInitializedError")<{
41
+ readonly message: string;
42
+ }> {
43
+ static readonly default = new WorkspaceNotInitializedError({
44
+ message: "Workspace not initialized. Run 'ship init' first.",
45
+ });
46
+ }
47
+
48
+ // === VCS Errors ===
49
+
50
+ export class VcsError extends Data.TaggedError("VcsError")<{
51
+ readonly message: string;
52
+ readonly cause?: unknown;
53
+ }> {}
54
+
55
+ export class JjNotInstalledError extends Data.TaggedError("JjNotInstalledError")<{
56
+ readonly message: string;
57
+ }> {
58
+ static readonly default = new JjNotInstalledError({
59
+ message: "jj is not installed. Visit https://jj-vcs.github.io/jj/",
60
+ });
61
+ }
62
+
63
+ // === PR Errors ===
64
+
65
+ export class PrError extends Data.TaggedError("PrError")<{
66
+ readonly message: string;
67
+ readonly cause?: unknown;
68
+ }> {}
69
+
70
+ export class GhNotInstalledError extends Data.TaggedError("GhNotInstalledError")<{
71
+ readonly message: string;
72
+ }> {
73
+ static readonly default = new GhNotInstalledError({
74
+ message: "gh CLI is not installed. Visit https://cli.github.com/",
75
+ });
76
+ }
77
+
78
+ // === Linear API Errors ===
79
+
80
+ export class LinearApiError extends Data.TaggedError("LinearApiError")<{
81
+ readonly message: string;
82
+ readonly statusCode?: number;
83
+ readonly cause?: unknown;
84
+ }> {}
85
+
86
+ export class RateLimitError extends Data.TaggedError("RateLimitError")<{
87
+ readonly message: string;
88
+ readonly retryAfter?: number;
89
+ }> {}
@@ -0,0 +1,124 @@
1
+ import * as Schema from "effect/Schema";
2
+
3
+ // === Branded Types ===
4
+
5
+ export const TaskId = Schema.String.pipe(Schema.brand("TaskId"));
6
+ export type TaskId = typeof TaskId.Type;
7
+
8
+ export const TeamId = Schema.String.pipe(Schema.brand("TeamId"));
9
+ export type TeamId = typeof TeamId.Type;
10
+
11
+ export const ProjectId = Schema.String.pipe(Schema.brand("ProjectId"));
12
+ export type ProjectId = typeof ProjectId.Type;
13
+
14
+ // === Enums ===
15
+
16
+ // Linear's workflow state types (not custom state names)
17
+ export const WorkflowStateType = Schema.Literal(
18
+ "backlog",
19
+ "unstarted",
20
+ "started",
21
+ "completed",
22
+ "canceled",
23
+ );
24
+ export type WorkflowStateType = typeof WorkflowStateType.Type;
25
+
26
+ // For backwards compatibility - maps to Linear state types
27
+ export const TaskStatus = Schema.Literal(
28
+ "backlog",
29
+ "todo",
30
+ "in_progress",
31
+ "in_review",
32
+ "done",
33
+ "cancelled",
34
+ );
35
+ export type TaskStatus = typeof TaskStatus.Type;
36
+
37
+ export const Priority = Schema.Literal("urgent", "high", "medium", "low", "none");
38
+ export type Priority = typeof Priority.Type;
39
+
40
+ export const TaskType = Schema.Literal("bug", "feature", "task", "epic", "chore");
41
+ export type TaskType = typeof TaskType.Type;
42
+
43
+ export const DependencyType = Schema.Literal("blocks", "blocked_by", "related", "duplicate");
44
+ export type DependencyType = typeof DependencyType.Type;
45
+
46
+ // === Domain Models ===
47
+
48
+ export class Dependency extends Schema.Class<Dependency>("Dependency")({
49
+ id: Schema.String,
50
+ type: DependencyType,
51
+ relatedTaskId: TaskId,
52
+ }) {}
53
+
54
+ // Workflow state from Linear (custom states)
55
+ export class WorkflowState extends Schema.Class<WorkflowState>("WorkflowState")({
56
+ id: Schema.String,
57
+ name: Schema.String,
58
+ type: WorkflowStateType,
59
+ }) {}
60
+
61
+ export class Task extends Schema.Class<Task>("Task")({
62
+ id: TaskId,
63
+ identifier: Schema.String, // e.g., "ENG-123"
64
+ title: Schema.String,
65
+ description: Schema.OptionFromNullOr(Schema.String),
66
+ state: WorkflowState, // The actual Linear state (custom or default)
67
+ priority: Priority,
68
+ type: Schema.OptionFromNullOr(TaskType),
69
+ teamId: TeamId,
70
+ projectId: Schema.OptionFromNullOr(ProjectId),
71
+ branchName: Schema.OptionFromNullOr(Schema.String),
72
+ url: Schema.String,
73
+ labels: Schema.Array(Schema.String),
74
+ blockedBy: Schema.Array(TaskId),
75
+ blocks: Schema.Array(TaskId),
76
+ createdAt: Schema.Date,
77
+ updatedAt: Schema.Date,
78
+ }) {
79
+ // Helper to check if task is in a "done" state (completed or canceled)
80
+ get isDone(): boolean {
81
+ return this.state.type === "completed" || this.state.type === "canceled";
82
+ }
83
+
84
+ // Helper to check if task is actionable (not done)
85
+ get isActionable(): boolean {
86
+ return !this.isDone;
87
+ }
88
+ }
89
+
90
+ export class Team extends Schema.Class<Team>("Team")({
91
+ id: TeamId,
92
+ name: Schema.String,
93
+ key: Schema.String, // e.g., "ENG"
94
+ }) {}
95
+
96
+ export class Project extends Schema.Class<Project>("Project")({
97
+ id: ProjectId,
98
+ name: Schema.String,
99
+ teamId: TeamId,
100
+ }) {}
101
+
102
+ // === Input Types ===
103
+
104
+ export class CreateTaskInput extends Schema.Class<CreateTaskInput>("CreateTaskInput")({
105
+ title: Schema.String,
106
+ description: Schema.OptionFromNullOr(Schema.String),
107
+ priority: Schema.optionalWith(Priority, { default: () => "medium" as const }),
108
+ type: Schema.optionalWith(TaskType, { default: () => "task" as const }),
109
+ projectId: Schema.OptionFromNullOr(ProjectId),
110
+ }) {}
111
+
112
+ export class UpdateTaskInput extends Schema.Class<UpdateTaskInput>("UpdateTaskInput")({
113
+ title: Schema.OptionFromNullOr(Schema.String),
114
+ description: Schema.OptionFromNullOr(Schema.String),
115
+ status: Schema.OptionFromNullOr(TaskStatus),
116
+ priority: Schema.OptionFromNullOr(Priority),
117
+ }) {}
118
+
119
+ export class TaskFilter extends Schema.Class<TaskFilter>("TaskFilter")({
120
+ status: Schema.OptionFromNullOr(TaskStatus),
121
+ priority: Schema.OptionFromNullOr(Priority),
122
+ projectId: Schema.OptionFromNullOr(ProjectId),
123
+ assignedToMe: Schema.optionalWith(Schema.Boolean, { default: () => false }),
124
+ }) {}
@@ -0,0 +1,3 @@
1
+ export * from "./Task.js";
2
+ export * from "./Errors.js";
3
+ export * from "./Config.js";
@@ -0,0 +1,45 @@
1
+ import * as Layer from "effect/Layer";
2
+ import * as NodeContext from "@effect/platform-node/NodeContext";
3
+ import { ConfigRepositoryLive } from "../adapters/driven/config/ConfigRepositoryLive.js";
4
+ import { AuthServiceLive } from "../adapters/driven/auth/AuthServiceLive.js";
5
+ import { LinearClientLive } from "../adapters/driven/linear/LinearClient.js";
6
+ import { TeamRepositoryLive } from "../adapters/driven/linear/TeamRepositoryLive.js";
7
+ import { ProjectRepositoryLive } from "../adapters/driven/linear/ProjectRepositoryLive.js";
8
+ import { IssueRepositoryLive } from "../adapters/driven/linear/IssueRepositoryLive.js";
9
+
10
+ // Layer dependencies:
11
+ // ConfigRepositoryLive: FileSystem + Path -> ConfigRepository
12
+ // AuthServiceLive: ConfigRepository -> AuthService
13
+ // LinearClientLive: AuthService -> LinearClientService
14
+ // TeamRepositoryLive: LinearClientService -> TeamRepository
15
+ // ProjectRepositoryLive: LinearClientService -> ProjectRepository
16
+ // IssueRepositoryLive: LinearClientService -> IssueRepository
17
+
18
+ // Build the layer chain - each layer provides what the next needs
19
+ // ConfigRepository provides what AuthService needs
20
+ const ConfigAndAuth = AuthServiceLive.pipe(Layer.provideMerge(ConfigRepositoryLive));
21
+
22
+ // AuthService provides what LinearClient needs
23
+ const ConfigAuthAndLinear = LinearClientLive.pipe(Layer.provideMerge(ConfigAndAuth));
24
+
25
+ // LinearClientService provides what repositories need
26
+ // Merge all three repository layers together
27
+ const RepositoryLayers = Layer.mergeAll(
28
+ TeamRepositoryLive,
29
+ ProjectRepositoryLive,
30
+ IssueRepositoryLive,
31
+ );
32
+
33
+ const AllServices = RepositoryLayers.pipe(Layer.provideMerge(ConfigAuthAndLinear));
34
+
35
+ // Full application layer for Phase 1 (Linear + Config + Auth)
36
+ // Use provideMerge to:
37
+ // 1. Satisfy platform deps (FileSystem, Path) that ConfigRepositoryLive needs
38
+ // 2. Keep platform services (FileSystem, Path, Terminal) in output for @effect/cli
39
+ export const AppLayer = AllServices.pipe(Layer.provideMerge(NodeContext.layer));
40
+
41
+ // Minimal layer for init/login commands (before full config exists)
42
+ // Also includes TeamRepository and ProjectRepository for init flow
43
+ const MinimalRepositories = Layer.mergeAll(TeamRepositoryLive, ProjectRepositoryLive);
44
+ const MinimalServices = MinimalRepositories.pipe(Layer.provideMerge(ConfigAuthAndLinear));
45
+ export const MinimalLayer = MinimalServices.pipe(Layer.provideMerge(NodeContext.layer));
@@ -0,0 +1,19 @@
1
+ import * as Context from "effect/Context";
2
+ import * as Effect from "effect/Effect";
3
+ import type { AuthConfig } from "../domain/Config.js";
4
+ import type { AuthError, ConfigError, NotAuthenticatedError } from "../domain/Errors.js";
5
+
6
+ export interface AuthService {
7
+ /** Save API key after user pastes it */
8
+ readonly saveApiKey: (apiKey: string) => Effect.Effect<AuthConfig, AuthError>;
9
+ /** Validate an API key by making a test request */
10
+ readonly validateApiKey: (apiKey: string) => Effect.Effect<boolean, AuthError>;
11
+ /** Get the stored API key - may fail if config is corrupted */
12
+ readonly getApiKey: () => Effect.Effect<string, NotAuthenticatedError | ConfigError>;
13
+ /** Remove stored credentials */
14
+ readonly logout: () => Effect.Effect<void, never>;
15
+ /** Check if we have stored credentials */
16
+ readonly isAuthenticated: () => Effect.Effect<boolean, never>;
17
+ }
18
+
19
+ export const AuthService = Context.GenericTag<AuthService>("AuthService");
@@ -0,0 +1,20 @@
1
+ import * as Context from "effect/Context";
2
+ import * as Effect from "effect/Effect";
3
+ import type { AuthConfig, LinearConfig, PartialShipConfig, ShipConfig } from "../domain/Config.js";
4
+ import type { ConfigError, WorkspaceNotInitializedError } from "../domain/Errors.js";
5
+
6
+ export interface ConfigRepository {
7
+ readonly load: () => Effect.Effect<ShipConfig, WorkspaceNotInitializedError | ConfigError>;
8
+ readonly loadPartial: () => Effect.Effect<PartialShipConfig, ConfigError>;
9
+ readonly save: (config: ShipConfig) => Effect.Effect<void, ConfigError>;
10
+ readonly savePartial: (config: PartialShipConfig) => Effect.Effect<void, ConfigError>;
11
+ readonly saveAuth: (auth: AuthConfig) => Effect.Effect<void, ConfigError>;
12
+ readonly saveLinear: (linear: LinearConfig) => Effect.Effect<void, ConfigError>;
13
+ readonly exists: () => Effect.Effect<boolean, ConfigError>;
14
+ readonly getConfigDir: () => Effect.Effect<string, never>;
15
+ readonly ensureConfigDir: () => Effect.Effect<void, ConfigError>;
16
+ readonly ensureGitignore: () => Effect.Effect<void, ConfigError>;
17
+ readonly delete: () => Effect.Effect<void, ConfigError>;
18
+ }
19
+
20
+ export const ConfigRepository = Context.GenericTag<ConfigRepository>("ConfigRepository");