@aaronshaf/plane 0.1.2

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 (41) hide show
  1. package/.github/workflows/ci.yml +36 -0
  2. package/.github/workflows/publish.yml +36 -0
  3. package/.husky/pre-commit +7 -0
  4. package/README.md +120 -0
  5. package/bin/plane +2 -0
  6. package/bun.lock +244 -0
  7. package/package.json +57 -0
  8. package/scripts/check-coverage.ts +54 -0
  9. package/scripts/check-file-size.ts +36 -0
  10. package/src/api.ts +81 -0
  11. package/src/bin.ts +72 -0
  12. package/src/commands/cycles.ts +108 -0
  13. package/src/commands/init.ts +72 -0
  14. package/src/commands/intake.ts +85 -0
  15. package/src/commands/issue.ts +409 -0
  16. package/src/commands/issues.ts +51 -0
  17. package/src/commands/labels.ts +54 -0
  18. package/src/commands/members.ts +20 -0
  19. package/src/commands/modules.ts +129 -0
  20. package/src/commands/pages.ts +63 -0
  21. package/src/commands/projects.ts +24 -0
  22. package/src/commands/states.ts +28 -0
  23. package/src/config.ts +217 -0
  24. package/src/format.ts +11 -0
  25. package/src/resolve.ts +76 -0
  26. package/tests/api.test.ts +169 -0
  27. package/tests/cycles-extended.test.ts +183 -0
  28. package/tests/format.test.ts +60 -0
  29. package/tests/helpers/mock-api.ts +27 -0
  30. package/tests/intake.test.ts +157 -0
  31. package/tests/issue-activity.test.ts +167 -0
  32. package/tests/issue-commands.test.ts +322 -0
  33. package/tests/issue-comments-worklogs.test.ts +291 -0
  34. package/tests/issue-links.test.ts +206 -0
  35. package/tests/modules.test.ts +255 -0
  36. package/tests/new-schemas.test.ts +201 -0
  37. package/tests/new-schemas2.test.ts +205 -0
  38. package/tests/pages.test.ts +124 -0
  39. package/tests/resolve.test.ts +178 -0
  40. package/tests/schemas.test.ts +203 -0
  41. package/tsconfig.json +21 -0
package/src/api.ts ADDED
@@ -0,0 +1,81 @@
1
+ import { Effect, Schema } from "effect"
2
+ import * as fs from "node:fs"
3
+ import * as path from "node:path"
4
+ import * as os from "node:os"
5
+
6
+ const CONFIG_FILE = path.join(os.homedir(), ".config", "plane", "config.json")
7
+
8
+ function readConfigFile(): Partial<{ token: string; host: string; workspace: string }> {
9
+ try {
10
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"))
11
+ } catch {
12
+ return {}
13
+ }
14
+ }
15
+
16
+ function getConfig() {
17
+ const file = readConfigFile()
18
+ return {
19
+ token: process.env["PLANE_API_TOKEN"] ?? file.token ?? "",
20
+ host: (process.env["PLANE_HOST"] ?? file.host ?? "https://plane.so").replace(/\/$/, ""),
21
+ workspace: process.env["PLANE_WORKSPACE"] ?? file.workspace ?? "",
22
+ }
23
+ }
24
+
25
+ function request(
26
+ method: string,
27
+ path: string,
28
+ body?: unknown,
29
+ ): Effect.Effect<unknown, Error> {
30
+ return Effect.tryPromise({
31
+ try: async () => {
32
+ const { token, host, workspace } = getConfig()
33
+ let url = `${host}/api/v1/workspaces/${workspace}/${path}`
34
+
35
+ // Always expand state on issue list/get calls (not intake-issues/ or cycle-issues/)
36
+ if (method === "GET" && /(?:^|\/)(issues\/)/.test(path)) {
37
+ url += url.includes("?") ? "&expand=state" : "?expand=state"
38
+ }
39
+
40
+ const headers: Record<string, string> = {
41
+ "X-Api-Key": token,
42
+ }
43
+ if (body !== undefined) {
44
+ headers["Content-Type"] = "application/json"
45
+ }
46
+
47
+ const res = await fetch(url, {
48
+ method,
49
+ headers,
50
+ body: body !== undefined ? JSON.stringify(body) : undefined,
51
+ })
52
+
53
+ if (!res.ok) {
54
+ const text = await res.text()
55
+ throw new Error(`HTTP ${res.status}: ${text}`)
56
+ }
57
+
58
+ // 204 No Content
59
+ if (res.status === 204) return null
60
+
61
+ return res.json()
62
+ },
63
+ catch: (e) => (e instanceof Error ? e : new Error(String(e))),
64
+ })
65
+ }
66
+
67
+ export const api = {
68
+ get: (path: string) => request("GET", path),
69
+ post: (path: string, body: unknown) => request("POST", path, body),
70
+ patch: (path: string, body: unknown) => request("PATCH", path, body),
71
+ delete: (path: string) => request("DELETE", path),
72
+ }
73
+
74
+ export function decodeOrFail<A, I>(
75
+ schema: Schema.Schema<A, I>,
76
+ data: unknown,
77
+ ): Effect.Effect<A, Error> {
78
+ return Schema.decodeUnknown(schema)(data).pipe(
79
+ Effect.mapError((e) => new Error(String(e))),
80
+ )
81
+ }
package/src/bin.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { Command } from "@effect/cli"
2
+ import { NodeContext, NodeRuntime } from "@effect/platform-node"
3
+ import { Effect, Layer } from "effect"
4
+ import { issue } from "./commands/issue.js"
5
+ import { issues } from "./commands/issues.js"
6
+ import { states } from "./commands/states.js"
7
+ import { labels } from "./commands/labels.js"
8
+ import { members } from "./commands/members.js"
9
+ import { cycles } from "./commands/cycles.js"
10
+ import { modules } from "./commands/modules.js"
11
+ import { intake } from "./commands/intake.js"
12
+ import { pages } from "./commands/pages.js"
13
+ import { projects } from "./commands/projects.js"
14
+ import { init } from "./commands/init.js"
15
+
16
+ const plane = Command.make("plane").pipe(
17
+ Command.withDescription(
18
+ `CLI for the Plane project management API. Useful for humans and AI agents/bots.
19
+
20
+ CONFIGURATION
21
+ Config file: ~/.config/plane/config.json (written by 'plane init')
22
+ Env vars: PLANE_API_TOKEN, PLANE_HOST, PLANE_WORKSPACE
23
+ Env vars take priority over the config file.
24
+
25
+ QUICK START
26
+ plane init Interactive setup — saves host/workspace/token
27
+ plane projects list List projects and their identifiers
28
+ plane issues list PROJ List issues for a project
29
+ plane issue get PROJ-29 Get full JSON for an issue
30
+ plane issue create PROJ "title" Create an issue
31
+ plane issue update --state done PROJ-29
32
+ plane issue comment PROJ-29 "text" Add a comment
33
+
34
+ CONCEPTS
35
+ Project identifier Short string shown by 'plane projects list' (e.g. ACME, WEB)
36
+ Issue ref Identifier + sequence number (e.g. ACME-29, WEB-5)
37
+ State groups backlog | unstarted | started | completed | cancelled
38
+ Priorities urgent | high | medium | low | none
39
+
40
+ ALL SUBCOMMANDS
41
+ init Set up config interactively
42
+ projects list List all projects
43
+ issues list List issues (supports --state filter)
44
+ issue get | create | update | delete | comment | activity |
45
+ link | comments | worklogs
46
+ cycles list | issues (list, add)
47
+ modules list | issues (list, add, remove)
48
+ intake list | accept | reject
49
+ pages list | get
50
+ states list List workflow states for a project
51
+ labels list List labels for a project
52
+ members list List members of a project
53
+
54
+ FOR AI AGENTS
55
+ - All list commands output one record per line, tab-separated
56
+ - 'plane issue get PROJ-N' outputs full JSON — pipe to jq for field extraction
57
+ - Use PLANE_API_TOKEN / PLANE_HOST / PLANE_WORKSPACE env vars to avoid 'plane init'
58
+ - Full Plane REST API reference (180+ endpoints):
59
+ https://developers.plane.so/api-reference/introduction`,
60
+ ),
61
+ Command.withSubcommands([init, projects, issues, issue, states, labels, members, cycles, modules, intake, pages]),
62
+ )
63
+
64
+ const cli = Command.run(plane, {
65
+ name: "plane",
66
+ version: "0.1.0",
67
+ })
68
+
69
+ Effect.suspend(() => cli(process.argv)).pipe(
70
+ Effect.provide(Layer.mergeAll(NodeContext.layer)),
71
+ NodeRuntime.runMain,
72
+ )
@@ -0,0 +1,108 @@
1
+ import { Command, Args } from "@effect/cli"
2
+ import { Console, Effect } from "effect"
3
+ import { api, decodeOrFail } from "../api.js"
4
+ import { CyclesResponseSchema, CycleIssuesResponseSchema } from "../config.js"
5
+ import { resolveProject, parseIssueRef, findIssueBySeq } from "../resolve.js"
6
+
7
+ const projectArg = Args.text({ name: "project" }).pipe(
8
+ Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"),
9
+ )
10
+
11
+ const cycleIdArg = Args.text({ name: "cycle-id" }).pipe(
12
+ Args.withDescription("Cycle UUID (from 'plane cycles list PROJECT')"),
13
+ )
14
+
15
+ // --- cycles list ---
16
+
17
+ export const cyclesList = Command.make("list", { project: projectArg }, ({ project }) =>
18
+ Effect.gen(function* () {
19
+ const { id } = yield* resolveProject(project)
20
+ const raw = yield* api.get(`projects/${id}/cycles/`)
21
+ const { results } = yield* decodeOrFail(CyclesResponseSchema, raw)
22
+ if (results.length === 0) {
23
+ yield* Console.log("No cycles found")
24
+ return
25
+ }
26
+ const lines = results.map((c) => {
27
+ const start = c.start_date ?? "—"
28
+ const end = c.end_date ?? "—"
29
+ const status = (c.status ?? "?").padEnd(10)
30
+ return `${c.id} ${status} ${start} → ${end} ${c.name}`
31
+ })
32
+ yield* Console.log(lines.join("\n"))
33
+ }),
34
+ ).pipe(
35
+ Command.withDescription(
36
+ "List cycles for a project. Shows cycle UUID, status, date range, and name.\n\nExample:\n plane cycles list PROJ",
37
+ ),
38
+ )
39
+
40
+ // --- cycles issues list ---
41
+
42
+ export const cycleIssuesList = Command.make(
43
+ "list",
44
+ { project: projectArg, cycleId: cycleIdArg },
45
+ ({ project, cycleId }) =>
46
+ Effect.gen(function* () {
47
+ const { key, id } = yield* resolveProject(project)
48
+ const raw = yield* api.get(`projects/${id}/cycles/${cycleId}/cycle-issues/`)
49
+ const { results } = yield* decodeOrFail(CycleIssuesResponseSchema, raw)
50
+ if (results.length === 0) {
51
+ yield* Console.log("No issues in cycle")
52
+ return
53
+ }
54
+ const lines = results.map((ci) => {
55
+ if (ci.issue_detail) {
56
+ const seq = String(ci.issue_detail.sequence_id).padStart(3, " ")
57
+ return `${key}-${seq} ${ci.issue_detail.name} (${ci.id})`
58
+ }
59
+ return `${ci.issue} (cycle-issue: ${ci.id})`
60
+ })
61
+ yield* Console.log(lines.join("\n"))
62
+ }),
63
+ ).pipe(
64
+ Command.withDescription(
65
+ "List issues in a cycle.\n\nExample:\n plane cycles issues list PROJ <cycle-id>",
66
+ ),
67
+ )
68
+
69
+ // --- cycles issues add ---
70
+
71
+ const issueRefArg = Args.text({ name: "ref" }).pipe(
72
+ Args.withDescription("Issue reference to add (e.g. PROJ-29)"),
73
+ )
74
+
75
+ export const cycleIssuesAdd = Command.make(
76
+ "add",
77
+ { project: projectArg, cycleId: cycleIdArg, ref: issueRefArg },
78
+ ({ project, cycleId, ref }) =>
79
+ Effect.gen(function* () {
80
+ const { id: projectId } = yield* resolveProject(project)
81
+ const { seq } = yield* parseIssueRef(ref)
82
+ const issue = yield* findIssueBySeq(projectId, seq)
83
+ yield* api.post(`projects/${projectId}/cycles/${cycleId}/cycle-issues/`, {
84
+ issues: [issue.id],
85
+ })
86
+ yield* Console.log(`Added ${ref} to cycle ${cycleId}`)
87
+ }),
88
+ ).pipe(
89
+ Command.withDescription(
90
+ "Add an issue to a cycle.\n\nExample:\n plane cycles issues add PROJ <cycle-id> PROJ-29",
91
+ ),
92
+ )
93
+
94
+ // --- cycles issues (parent) ---
95
+
96
+ export const cycleIssues = Command.make("issues").pipe(
97
+ Command.withDescription("Manage issues within a cycle. Subcommands: list, add"),
98
+ Command.withSubcommands([cycleIssuesList, cycleIssuesAdd]),
99
+ )
100
+
101
+ // --- cycles (parent) ---
102
+
103
+ export const cycles = Command.make("cycles").pipe(
104
+ Command.withDescription(
105
+ "Manage cycles (sprints). Subcommands: list, issues\n\nExamples:\n plane cycles list PROJ\n plane cycles issues list PROJ <cycle-id>\n plane cycles issues add PROJ <cycle-id> PROJ-29",
106
+ ),
107
+ Command.withSubcommands([cyclesList, cycleIssues]),
108
+ )
@@ -0,0 +1,72 @@
1
+ import { Command } from "@effect/cli"
2
+ import { Console, Effect } from "effect"
3
+ import * as readline from "node:readline"
4
+ import * as fs from "node:fs"
5
+ import * as path from "node:path"
6
+ import * as os from "node:os"
7
+
8
+ export const CONFIG_DIR = path.join(os.homedir(), ".config", "plane")
9
+ export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json")
10
+
11
+ export interface PlaneConfig {
12
+ token: string
13
+ host: string
14
+ workspace: string
15
+ }
16
+
17
+ function prompt(rl: readline.Interface, question: string): Promise<string> {
18
+ return new Promise((resolve) => rl.question(question, resolve))
19
+ }
20
+
21
+ export const init = Command.make("init", {}, () =>
22
+ Effect.gen(function* () {
23
+ let existing: Partial<PlaneConfig> = {}
24
+ try {
25
+ existing = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"))
26
+ } catch {
27
+ // no existing config
28
+ }
29
+
30
+ const rl = readline.createInterface({
31
+ input: process.stdin,
32
+ output: process.stdout,
33
+ })
34
+
35
+ const host = yield* Effect.promise(() =>
36
+ prompt(rl, `Plane host [${existing.host ?? "https://plane.so"}]: `),
37
+ )
38
+ const workspace = yield* Effect.promise(() =>
39
+ prompt(rl, `Workspace slug [${existing.workspace ?? ""}]: `),
40
+ )
41
+ const token = yield* Effect.promise(() =>
42
+ prompt(rl, `API token [${existing.token ? "***" : ""}]: `),
43
+ )
44
+
45
+ rl.close()
46
+
47
+ const config: PlaneConfig = {
48
+ host: host.trim() || existing.host || "https://plane.so",
49
+ workspace: workspace.trim() || existing.workspace || "",
50
+ token: token.trim() || existing.token || "",
51
+ }
52
+
53
+ if (!config.token) {
54
+ yield* Effect.fail(new Error("API token is required"))
55
+ }
56
+ if (!config.workspace) {
57
+ yield* Effect.fail(new Error("Workspace slug is required"))
58
+ }
59
+
60
+ fs.mkdirSync(CONFIG_DIR, { recursive: true })
61
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 })
62
+
63
+ yield* Console.log(`\nConfig saved to ${CONFIG_FILE}`)
64
+ yield* Console.log(` Host: ${config.host}`)
65
+ yield* Console.log(` Workspace: ${config.workspace}`)
66
+ yield* Console.log(` Token: ***`)
67
+ }),
68
+ ).pipe(
69
+ Command.withDescription(
70
+ "Interactive setup. Prompts for host, workspace slug, and API token, then saves to ~/.config/plane/config.json (mode 0600). Safe to re-run — existing values shown as defaults.",
71
+ ),
72
+ )
@@ -0,0 +1,85 @@
1
+ import { Command, Options, Args } from "@effect/cli"
2
+ import { Console, Effect } from "effect"
3
+ import { api, decodeOrFail } from "../api.js"
4
+ import { IntakeIssuesResponseSchema } from "../config.js"
5
+ import { resolveProject } from "../resolve.js"
6
+
7
+ const projectArg = Args.text({ name: "project" }).pipe(
8
+ Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"),
9
+ )
10
+
11
+ // Intake status codes: -2=rejected, -1=snoozed, 0=pending, 1=accepted, 2=duplicate
12
+ const STATUS_LABELS: Record<number, string> = {
13
+ [-2]: "rejected",
14
+ [-1]: "snoozed",
15
+ [0]: "pending",
16
+ [1]: "accepted",
17
+ [2]: "duplicate",
18
+ }
19
+
20
+ // --- intake list ---
21
+
22
+ export const intakeList = Command.make("list", { project: projectArg }, ({ project }) =>
23
+ Effect.gen(function* () {
24
+ const { id } = yield* resolveProject(project)
25
+ const raw = yield* api.get(`projects/${id}/intake-issues/`)
26
+ const { results } = yield* decodeOrFail(IntakeIssuesResponseSchema, raw)
27
+ if (results.length === 0) {
28
+ yield* Console.log("No intake issues")
29
+ return
30
+ }
31
+ const lines = results.map((i) => {
32
+ const status = STATUS_LABELS[i.status ?? 0] ?? String(i.status ?? "?")
33
+ const statusPad = status.padEnd(10)
34
+ if (i.issue_detail) {
35
+ const seq = String(i.issue_detail.sequence_id).padStart(3, " ")
36
+ return `${i.id} [${statusPad}] ${i.issue_detail.name}`
37
+ }
38
+ return `${i.id} [${statusPad}]`
39
+ })
40
+ yield* Console.log(lines.join("\n"))
41
+ }),
42
+ ).pipe(
43
+ Command.withDescription(
44
+ "List intake (triage) issues for a project. Shows status: pending, accepted, rejected, snoozed, duplicate.\n\nExample:\n plane intake list PROJ",
45
+ ),
46
+ )
47
+
48
+ // --- intake accept ---
49
+
50
+ const intakeIdArg = Args.text({ name: "intake-id" }).pipe(
51
+ Args.withDescription("Intake issue ID (from 'plane intake list')"),
52
+ )
53
+
54
+ export const intakeAccept = Command.make(
55
+ "accept",
56
+ { project: projectArg, intakeId: intakeIdArg },
57
+ ({ project, intakeId }) =>
58
+ Effect.gen(function* () {
59
+ const { id } = yield* resolveProject(project)
60
+ yield* api.patch(`projects/${id}/intake-issues/${intakeId}/`, { status: 1 })
61
+ yield* Console.log(`Intake issue ${intakeId} accepted`)
62
+ }),
63
+ ).pipe(Command.withDescription("Accept an intake issue, creating it as a tracked work item."))
64
+
65
+ // --- intake reject ---
66
+
67
+ export const intakeReject = Command.make(
68
+ "reject",
69
+ { project: projectArg, intakeId: intakeIdArg },
70
+ ({ project, intakeId }) =>
71
+ Effect.gen(function* () {
72
+ const { id } = yield* resolveProject(project)
73
+ yield* api.patch(`projects/${id}/intake-issues/${intakeId}/`, { status: -2 })
74
+ yield* Console.log(`Intake issue ${intakeId} rejected`)
75
+ }),
76
+ ).pipe(Command.withDescription("Reject an intake issue."))
77
+
78
+ // --- intake (parent) ---
79
+
80
+ export const intake = Command.make("intake").pipe(
81
+ Command.withDescription(
82
+ "Manage intake (incoming request triage). Subcommands: list, accept, reject\n\nExamples:\n plane intake list PROJ\n plane intake accept PROJ <intake-id>\n plane intake reject PROJ <intake-id>",
83
+ ),
84
+ Command.withSubcommands([intakeList, intakeAccept, intakeReject]),
85
+ )