@aaronshaf/plane 0.1.3 → 0.1.6

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
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.1.3",
6
+ "version": "0.1.6",
7
7
  "description": "CLI for the Plane project management API",
8
8
  "keywords": [
9
9
  "plane",
@@ -3,8 +3,8 @@
3
3
  import { execSync } from "child_process"
4
4
 
5
5
  const THRESHOLDS = {
6
- lines: 98,
7
- functions: 98,
6
+ lines: 95,
7
+ functions: 95,
8
8
  }
9
9
 
10
10
  console.log("Running tests with coverage...\n")
package/src/api.ts CHANGED
@@ -1,81 +1,89 @@
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"
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
5
 
6
- const CONFIG_FILE = path.join(os.homedir(), ".config", "plane", "config.json")
6
+ const CONFIG_FILE = path.join(os.homedir(), ".config", "plane", "config.json");
7
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
- }
8
+ function readConfigFile(): Partial<{
9
+ token: string;
10
+ host: string;
11
+ workspace: string;
12
+ }> {
13
+ try {
14
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
15
+ } catch {
16
+ return {};
17
+ }
14
18
  }
15
19
 
16
20
  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
- }
21
+ const file = readConfigFile();
22
+ return {
23
+ token: process.env["PLANE_API_TOKEN"] ?? file.token ?? "",
24
+ host: (
25
+ process.env["PLANE_HOST"] ??
26
+ file.host ??
27
+ "https://plane.so"
28
+ ).replace(/\/$/, ""),
29
+ workspace: process.env["PLANE_WORKSPACE"] ?? file.workspace ?? "",
30
+ };
23
31
  }
24
32
 
25
33
  function request(
26
- method: string,
27
- path: string,
28
- body?: unknown,
34
+ method: string,
35
+ path: string,
36
+ body?: unknown,
29
37
  ): 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}`
38
+ return Effect.tryPromise({
39
+ try: async () => {
40
+ const { token, host, workspace } = getConfig();
41
+ let url = `${host}/api/v1/workspaces/${workspace}/${path}`;
34
42
 
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
- }
43
+ // Always expand state on issue list/get calls (not intake-issues/ or cycle-issues/)
44
+ if (method === "GET" && /(?:^|\/)(issues\/)/.test(path)) {
45
+ url += url.includes("?") ? "&expand=state" : "?expand=state";
46
+ }
39
47
 
40
- const headers: Record<string, string> = {
41
- "X-Api-Key": token,
42
- }
43
- if (body !== undefined) {
44
- headers["Content-Type"] = "application/json"
45
- }
48
+ const headers: Record<string, string> = {
49
+ "X-Api-Key": token,
50
+ };
51
+ if (body !== undefined) {
52
+ headers["Content-Type"] = "application/json";
53
+ }
46
54
 
47
- const res = await fetch(url, {
48
- method,
49
- headers,
50
- body: body !== undefined ? JSON.stringify(body) : undefined,
51
- })
55
+ const res = await fetch(url, {
56
+ method,
57
+ headers,
58
+ body: body !== undefined ? JSON.stringify(body) : undefined,
59
+ });
52
60
 
53
- if (!res.ok) {
54
- const text = await res.text()
55
- throw new Error(`HTTP ${res.status}: ${text}`)
56
- }
61
+ if (!res.ok) {
62
+ const text = await res.text();
63
+ throw new Error(`HTTP ${res.status}: ${text}`);
64
+ }
57
65
 
58
- // 204 No Content
59
- if (res.status === 204) return null
66
+ // 204 No Content
67
+ if (res.status === 204) return null;
60
68
 
61
- return res.json()
62
- },
63
- catch: (e) => (e instanceof Error ? e : new Error(String(e))),
64
- })
69
+ return res.json();
70
+ },
71
+ catch: (e) => (e instanceof Error ? e : new Error(String(e))),
72
+ });
65
73
  }
66
74
 
67
75
  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
- }
76
+ get: (path: string) => request("GET", path),
77
+ post: (path: string, body: unknown) => request("POST", path, body),
78
+ patch: (path: string, body: unknown) => request("PATCH", path, body),
79
+ delete: (path: string) => request("DELETE", path),
80
+ };
73
81
 
74
82
  export function decodeOrFail<A, I>(
75
- schema: Schema.Schema<A, I>,
76
- data: unknown,
83
+ schema: Schema.Schema<A, I>,
84
+ data: unknown,
77
85
  ): Effect.Effect<A, Error> {
78
- return Schema.decodeUnknown(schema)(data).pipe(
79
- Effect.mapError((e) => new Error(String(e))),
80
- )
86
+ return Schema.decodeUnknown(schema)(data).pipe(
87
+ Effect.mapError((e) => new Error(String(e))),
88
+ );
81
89
  }
package/src/app.ts ADDED
@@ -0,0 +1,78 @@
1
+ import { Command } from "@effect/cli";
2
+ import { issue } from "./commands/issue.js";
3
+ import { issues } from "./commands/issues.js";
4
+ import { states } from "./commands/states.js";
5
+ import { labels } from "./commands/labels.js";
6
+ import { members } from "./commands/members.js";
7
+ import { cycles } from "./commands/cycles.js";
8
+ import { modules } from "./commands/modules.js";
9
+ import { intake } from "./commands/intake.js";
10
+ import { pages } from "./commands/pages.js";
11
+ import { projects } from "./commands/projects.js";
12
+ import { init } from "./commands/init.js";
13
+
14
+ const plane = Command.make("plane").pipe(
15
+ Command.withDescription(
16
+ `CLI for the Plane project management API. Useful for humans and AI agents/bots.
17
+
18
+ CONFIGURATION
19
+ Config file: ~/.config/plane/config.json (written by 'plane init')
20
+ Env vars: PLANE_API_TOKEN, PLANE_HOST, PLANE_WORKSPACE
21
+ Env vars take priority over the config file.
22
+
23
+ QUICK START
24
+ plane init Interactive setup — saves host/workspace/token
25
+ plane projects list List projects and their identifiers
26
+ plane issues list PROJ List issues for a project
27
+ plane issue get PROJ-29 Get full JSON for an issue
28
+ plane issue create PROJ "title" Create an issue
29
+ plane issue update --state done PROJ-29
30
+ plane issue comment PROJ-29 "text" Add a comment
31
+
32
+ CONCEPTS
33
+ Project identifier Short string shown by 'plane projects list' (e.g. ACME, WEB)
34
+ Issue ref Identifier + sequence number (e.g. ACME-29, WEB-5)
35
+ State groups backlog | unstarted | started | completed | cancelled
36
+ Priorities urgent | high | medium | low | none
37
+
38
+ ALL SUBCOMMANDS
39
+ init Set up config interactively
40
+ projects list List all projects
41
+ issues list List issues (supports --state filter)
42
+ issue get | create | update | delete | comment | activity |
43
+ link | comments | worklogs
44
+ cycles list | issues (list, add)
45
+ modules list | issues (list, add, remove)
46
+ intake list | accept | reject
47
+ pages list | get
48
+ states list List workflow states for a project
49
+ labels list List labels for a project
50
+ members list List members of a project
51
+
52
+ FOR AI AGENTS / BOTS
53
+ - Add --json to any list command for JSON output (array of objects)
54
+ - Add --xml to any list command for XML output
55
+ - 'plane issue get PROJ-N' always outputs full JSON
56
+ - Use PLANE_API_TOKEN / PLANE_HOST / PLANE_WORKSPACE env vars to avoid 'plane init'
57
+ - Full Plane REST API reference (180+ endpoints):
58
+ https://developers.plane.so/api-reference/introduction`,
59
+ ),
60
+ Command.withSubcommands([
61
+ init,
62
+ projects,
63
+ issues,
64
+ issue,
65
+ states,
66
+ labels,
67
+ members,
68
+ cycles,
69
+ modules,
70
+ intake,
71
+ pages,
72
+ ]),
73
+ );
74
+
75
+ export const cli = Command.run(plane, {
76
+ name: "plane",
77
+ version: "0.1.0",
78
+ });
package/src/bin.ts CHANGED
@@ -1,73 +1,8 @@
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 / BOTS
55
- - Add --json to any list command for JSON output (array of objects)
56
- - Add --xml to any list command for XML output
57
- - 'plane issue get PROJ-N' always outputs full JSON
58
- - Use PLANE_API_TOKEN / PLANE_HOST / PLANE_WORKSPACE env vars to avoid 'plane init'
59
- - Full Plane REST API reference (180+ endpoints):
60
- https://developers.plane.so/api-reference/introduction`,
61
- ),
62
- Command.withSubcommands([init, projects, issues, issue, states, labels, members, cycles, modules, intake, pages]),
63
- )
64
-
65
- const cli = Command.run(plane, {
66
- name: "plane",
67
- version: "0.1.0",
68
- })
1
+ import { NodeContext, NodeRuntime } from "@effect/platform-node";
2
+ import { Effect, Layer } from "effect";
3
+ import { cli } from "./app.js";
69
4
 
70
5
  Effect.suspend(() => cli(process.argv)).pipe(
71
- Effect.provide(Layer.mergeAll(NodeContext.layer)),
72
- NodeRuntime.runMain,
73
- )
6
+ Effect.provide(Layer.mergeAll(NodeContext.layer)),
7
+ NodeRuntime.runMain,
8
+ );
@@ -1,113 +1,132 @@
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
- import { jsonMode, xmlMode, toXml } from "../output.js"
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
+ import { jsonMode, xmlMode, toXml } from "../output.js";
7
7
 
8
8
  const projectArg = Args.text({ name: "project" }).pipe(
9
- Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"),
10
- )
9
+ Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"),
10
+ );
11
11
 
12
12
  const cycleIdArg = Args.text({ name: "cycle-id" }).pipe(
13
- Args.withDescription("Cycle UUID (from 'plane cycles list PROJECT')"),
14
- )
13
+ Args.withDescription("Cycle UUID (from 'plane cycles list PROJECT')"),
14
+ );
15
15
 
16
16
  // --- cycles list ---
17
17
 
18
- export const cyclesList = Command.make("list", { project: projectArg }, ({ project }) =>
19
- Effect.gen(function* () {
20
- const { id } = yield* resolveProject(project)
21
- const raw = yield* api.get(`projects/${id}/cycles/`)
22
- const { results } = yield* decodeOrFail(CyclesResponseSchema, raw)
23
- if (jsonMode) { yield* Console.log(JSON.stringify(results, null, 2)); return }
24
- if (xmlMode) { yield* Console.log(toXml(results)); return }
25
- if (results.length === 0) {
26
- yield* Console.log("No cycles found")
27
- return
28
- }
29
- const lines = results.map((c) => {
30
- const start = c.start_date ?? "—"
31
- const end = c.end_date ?? "—"
32
- const status = (c.status ?? "?").padEnd(10)
33
- return `${c.id} ${status} ${start} → ${end} ${c.name}`
34
- })
35
- yield* Console.log(lines.join("\n"))
36
- }),
18
+ export const cyclesList = Command.make(
19
+ "list",
20
+ { project: projectArg },
21
+ ({ project }) =>
22
+ Effect.gen(function* () {
23
+ const { id } = yield* resolveProject(project);
24
+ const raw = yield* api.get(`projects/${id}/cycles/`);
25
+ const { results } = yield* decodeOrFail(CyclesResponseSchema, raw);
26
+ if (jsonMode) {
27
+ yield* Console.log(JSON.stringify(results, null, 2));
28
+ return;
29
+ }
30
+ if (xmlMode) {
31
+ yield* Console.log(toXml(results));
32
+ return;
33
+ }
34
+ if (results.length === 0) {
35
+ yield* Console.log("No cycles found");
36
+ return;
37
+ }
38
+ const lines = results.map((c) => {
39
+ const start = c.start_date ?? "—";
40
+ const end = c.end_date ?? "—";
41
+ const status = (c.status ?? "?").padEnd(10);
42
+ return `${c.id} ${status} ${start} → ${end} ${c.name}`;
43
+ });
44
+ yield* Console.log(lines.join("\n"));
45
+ }),
37
46
  ).pipe(
38
- Command.withDescription(
39
- "List cycles for a project. Shows cycle UUID, status, date range, and name.\n\nExample:\n plane cycles list PROJ",
40
- ),
41
- )
47
+ Command.withDescription(
48
+ "List cycles for a project. Shows cycle UUID, status, date range, and name.\n\nExample:\n plane cycles list PROJ",
49
+ ),
50
+ );
42
51
 
43
52
  // --- cycles issues list ---
44
53
 
45
54
  export const cycleIssuesList = Command.make(
46
- "list",
47
- { project: projectArg, cycleId: cycleIdArg },
48
- ({ project, cycleId }) =>
49
- Effect.gen(function* () {
50
- const { key, id } = yield* resolveProject(project)
51
- const raw = yield* api.get(`projects/${id}/cycles/${cycleId}/cycle-issues/`)
52
- const { results } = yield* decodeOrFail(CycleIssuesResponseSchema, raw)
53
- if (jsonMode) { yield* Console.log(JSON.stringify(results, null, 2)); return }
54
- if (xmlMode) { yield* Console.log(toXml(results)); return }
55
- if (results.length === 0) {
56
- yield* Console.log("No issues in cycle")
57
- return
58
- }
59
- const lines = results.map((ci) => {
60
- if (ci.issue_detail) {
61
- const seq = String(ci.issue_detail.sequence_id).padStart(3, " ")
62
- return `${key}-${seq} ${ci.issue_detail.name} (${ci.id})`
63
- }
64
- return `${ci.issue} (cycle-issue: ${ci.id})`
65
- })
66
- yield* Console.log(lines.join("\n"))
67
- }),
55
+ "list",
56
+ { project: projectArg, cycleId: cycleIdArg },
57
+ ({ project, cycleId }) =>
58
+ Effect.gen(function* () {
59
+ const { key, id } = yield* resolveProject(project);
60
+ const raw = yield* api.get(
61
+ `projects/${id}/cycles/${cycleId}/cycle-issues/`,
62
+ );
63
+ const { results } = yield* decodeOrFail(CycleIssuesResponseSchema, raw);
64
+ if (jsonMode) {
65
+ yield* Console.log(JSON.stringify(results, null, 2));
66
+ return;
67
+ }
68
+ if (xmlMode) {
69
+ yield* Console.log(toXml(results));
70
+ return;
71
+ }
72
+ if (results.length === 0) {
73
+ yield* Console.log("No issues in cycle");
74
+ return;
75
+ }
76
+ const lines = results.map((ci) => {
77
+ if (ci.issue_detail) {
78
+ const seq = String(ci.issue_detail.sequence_id).padStart(3, " ");
79
+ return `${key}-${seq} ${ci.issue_detail.name} (${ci.id})`;
80
+ }
81
+ return `${ci.issue} (cycle-issue: ${ci.id})`;
82
+ });
83
+ yield* Console.log(lines.join("\n"));
84
+ }),
68
85
  ).pipe(
69
- Command.withDescription(
70
- "List issues in a cycle.\n\nExample:\n plane cycles issues list PROJ <cycle-id>",
71
- ),
72
- )
86
+ Command.withDescription(
87
+ "List issues in a cycle.\n\nExample:\n plane cycles issues list PROJ <cycle-id>",
88
+ ),
89
+ );
73
90
 
74
91
  // --- cycles issues add ---
75
92
 
76
93
  const issueRefArg = Args.text({ name: "ref" }).pipe(
77
- Args.withDescription("Issue reference to add (e.g. PROJ-29)"),
78
- )
94
+ Args.withDescription("Issue reference to add (e.g. PROJ-29)"),
95
+ );
79
96
 
80
97
  export const cycleIssuesAdd = Command.make(
81
- "add",
82
- { project: projectArg, cycleId: cycleIdArg, ref: issueRefArg },
83
- ({ project, cycleId, ref }) =>
84
- Effect.gen(function* () {
85
- const { id: projectId } = yield* resolveProject(project)
86
- const { seq } = yield* parseIssueRef(ref)
87
- const issue = yield* findIssueBySeq(projectId, seq)
88
- yield* api.post(`projects/${projectId}/cycles/${cycleId}/cycle-issues/`, {
89
- issues: [issue.id],
90
- })
91
- yield* Console.log(`Added ${ref} to cycle ${cycleId}`)
92
- }),
98
+ "add",
99
+ { project: projectArg, cycleId: cycleIdArg, ref: issueRefArg },
100
+ ({ project, cycleId, ref }) =>
101
+ Effect.gen(function* () {
102
+ const { id: projectId } = yield* resolveProject(project);
103
+ const { seq } = yield* parseIssueRef(ref);
104
+ const issue = yield* findIssueBySeq(projectId, seq);
105
+ yield* api.post(`projects/${projectId}/cycles/${cycleId}/cycle-issues/`, {
106
+ issues: [issue.id],
107
+ });
108
+ yield* Console.log(`Added ${ref} to cycle ${cycleId}`);
109
+ }),
93
110
  ).pipe(
94
- Command.withDescription(
95
- "Add an issue to a cycle.\n\nExample:\n plane cycles issues add PROJ <cycle-id> PROJ-29",
96
- ),
97
- )
111
+ Command.withDescription(
112
+ "Add an issue to a cycle.\n\nExample:\n plane cycles issues add PROJ <cycle-id> PROJ-29",
113
+ ),
114
+ );
98
115
 
99
116
  // --- cycles issues (parent) ---
100
117
 
101
118
  export const cycleIssues = Command.make("issues").pipe(
102
- Command.withDescription("Manage issues within a cycle. Subcommands: list, add"),
103
- Command.withSubcommands([cycleIssuesList, cycleIssuesAdd]),
104
- )
119
+ Command.withDescription(
120
+ "Manage issues within a cycle. Subcommands: list, add",
121
+ ),
122
+ Command.withSubcommands([cycleIssuesList, cycleIssuesAdd]),
123
+ );
105
124
 
106
125
  // --- cycles (parent) ---
107
126
 
108
127
  export const cycles = Command.make("cycles").pipe(
109
- Command.withDescription(
110
- "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",
111
- ),
112
- Command.withSubcommands([cyclesList, cycleIssues]),
113
- )
128
+ Command.withDescription(
129
+ "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",
130
+ ),
131
+ Command.withSubcommands([cyclesList, cycleIssues]),
132
+ );