```
+Some deployments do not expose page endpoints even when the project advertises page support. Expect an explicit compatibility error in that case.
+
---
## Issue Fields Reference
@@ -279,7 +336,7 @@ plane pages duplicate PROJ
- No server-side text search — fetch all issues and filter locally.
- No epics — use labels or modules to group related issues.
-- `description` in `issue create`/`update` is plain text; the CLI wraps it in `` tags automatically.
+- `description` in issue or page create and update flows is passed through to `description_html`; send HTML such as `
Details
` when you want formatted output.
- Always fetch state/label/member IDs live — never hardcode UUIDs across workspaces.
- `plane issue get PROJ-N` is the fastest way to inspect all fields on a single issue.
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
index 1647e02..1c027f0 100644
--- a/docs/RELEASING.md
+++ b/docs/RELEASING.md
@@ -4,6 +4,30 @@
This repository publishes from Git tags that match `v*` through [`.github/workflows/publish.yml`](../.github/workflows/publish.yml).
+## One-Time Maintainer Setup
+
+Before the first public release, make sure the publication path itself is ready:
+
+1. Verify the npm package name is available and that the publishing account or npm organization has access to `@backslash-ux/plane-cli`.
+2. Enable npm account 2FA for maintainers. For CI publishing, either:
+ - keep using the current `NPM_CONFIG_TOKEN` secret with a token that is allowed to publish this package, or
+ - migrate the workflow to npm trusted publishing so long-lived tokens are no longer required.
+3. Add the `NPM_CONFIG_TOKEN` repository secret in GitHub if the token-based workflow remains in use.
+4. Confirm GitHub Actions is enabled for the repository and that the publish workflow can create releases. The current workflow already requests `contents: write`.
+5. Verify the default branch is healthy before tagging: CI should pass on `main` and the version in `package.json` should match the intended release.
+6. Confirm the repository URLs in `package.json` and the install instructions in `README.md` and `SKILL.md` point at the maintained fork.
+
+## Recommended Preflight Checks
+
+Run these checks before cutting a release:
+
+```bash
+bun run check:all
+bun publish --dry-run
+```
+
+The dry run confirms the package contents and publish metadata without pushing a release to npm.
+
## Before Releasing
1. Update [CHANGELOG.md](../CHANGELOG.md) with user-facing changes.
@@ -32,6 +56,18 @@ git push origin vX.Y.Z
- publish the package to npm
- create a GitHub release with generated notes
+## After Releasing
+
+1. Confirm the GitHub release was created from the pushed tag.
+2. Verify the package is visible on npm and that the published version matches `package.json`.
+3. Smoke-test installation from the public registry:
+
+```bash
+bunx @backslash-ux/plane-cli --help
+```
+
+4. If the release changes agent workflows, confirm `README.md`, `SKILL.md`, and `AGENTS.md` guidance still matches the shipped package.
+
## Required Repository Secrets
- `NPM_CONFIG_TOKEN` for package publication.
@@ -39,4 +75,5 @@ git push origin vX.Y.Z
## Notes
- If the release changes command behavior, keep related GitHub issues, release notes, and docs aligned as part of the same change.
-- If a release uncovers a workflow gap, document it here instead of relying on maintainer memory.
\ No newline at end of file
+- If a release uncovers a workflow gap, document it here instead of relying on maintainer memory.
+- npm currently recommends trusted publishing for GitHub Actions when possible. This repository still uses `NPM_CONFIG_TOKEN`, so moving to trusted publishing plus provenance is a useful follow-up when maintainers are ready.
\ No newline at end of file
diff --git a/package.json b/package.json
index 6e37257..e0318f9 100644
--- a/package.json
+++ b/package.json
@@ -1,5 +1,5 @@
{
- "name": "@backslash-ux/plane",
+ "name": "@backslash-ux/plane-cli",
"publishConfig": {
"access": "public"
},
@@ -33,12 +33,12 @@
},
"repository": {
"type": "git",
- "url": "git+https://github.com/backslash-ux/plane-cli.git"
+ "url": "git+https://github.com/backslash-ux/plane-cli-cli.git"
},
"bugs": {
- "url": "https://github.com/backslash-ux/plane-cli/issues"
+ "url": "https://github.com/backslash-ux/plane-cli-cli/issues"
},
- "homepage": "https://github.com/backslash-ux/plane-cli#readme",
+ "homepage": "https://github.com/backslash-ux/plane-cli-cli#readme",
"engines": {
"bun": ">=1.0.0"
},
diff --git a/scripts/check-coverage.ts b/scripts/check-coverage.ts
index 9d270ff..bbc3102 100644
--- a/scripts/check-coverage.ts
+++ b/scripts/check-coverage.ts
@@ -10,7 +10,7 @@ const THRESHOLDS = {
console.log("Running tests with coverage...\n")
try {
- const output = execSync("bun test --coverage 2>&1", { encoding: "utf8" })
+ const output = execSync(`"${process.execPath}" test --coverage 2>&1`, { encoding: "utf8" })
console.log(output)
const coverageMatch = output.match(/All files\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)\s*\|/)
diff --git a/src/api.ts b/src/api.ts
index d396582..7212f50 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -1,34 +1,5 @@
import { Effect, Schema } from "effect";
-import * as fs from "node:fs";
-import * as path from "node:path";
-import * as os from "node:os";
-
-const CONFIG_FILE = path.join(os.homedir(), ".config", "plane", "config.json");
-
-function readConfigFile(): Partial<{
- token: string;
- host: string;
- workspace: string;
-}> {
- try {
- return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
- } catch {
- return {};
- }
-}
-
-function getConfig() {
- const file = readConfigFile();
- return {
- token: process.env["PLANE_API_TOKEN"] ?? file.token ?? "",
- host: (
- process.env["PLANE_HOST"] ??
- file.host ??
- "https://plane.so"
- ).replace(/\/$/, ""),
- workspace: process.env["PLANE_WORKSPACE"] ?? file.workspace ?? "",
- };
-}
+import { getConfig } from "./user-config.js";
function request(
method: string,
@@ -38,8 +9,14 @@ function request(
return Effect.tryPromise({
try: async () => {
const { token, host, workspace } = getConfig();
- if (!token) throw new Error("No API token configured. Run 'plane init' or set PLANE_API_TOKEN.");
- if (!workspace) throw new Error("No workspace configured. Run 'plane init' or set PLANE_WORKSPACE.");
+ if (!token)
+ throw new Error(
+ "No API token configured. Run 'plane init', 'plane init --local', 'plane . init', or set PLANE_API_TOKEN.",
+ );
+ if (!workspace)
+ throw new Error(
+ "No workspace configured. Run 'plane init', 'plane init --local', 'plane . init', or set PLANE_WORKSPACE.",
+ );
let url = `${host}/api/v1/workspaces/${workspace}/${path}`;
// Always expand state on issue list/get calls (not intake-issues/ or cycle-issues/)
@@ -75,10 +52,13 @@ function request(
return JSON.parse(text);
} catch {
// Escape bare control characters inside JSON string values and retry.
- const sanitized = text.replace(
- /"(?:[^"\\]|\\.)*"/g,
- (match) => match.replace(/[\x00-\x1F]/g, (c) => {
- const hex = c.charCodeAt(0).toString(16).padStart(4, "0");
+ const sanitized = text.replace(/"(?:[^"\\]|\\.)*"/g, (match) =>
+ match.replace(/./gsu, (c) => {
+ const code = c.charCodeAt(0);
+ if (code > 0x1f) {
+ return c;
+ }
+ const hex = code.toString(16).padStart(4, "0");
return `\\u${hex}`;
}),
);
diff --git a/src/app.ts b/src/app.ts
index 23fabed..34a7c53 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -1,31 +1,43 @@
import { Command } from "@effect/cli";
+import { cycles } from "./commands/cycles.js";
+import { init } from "./commands/init.js";
+import { intake } from "./commands/intake.js";
import { issue } from "./commands/issue.js";
import { issues } from "./commands/issues.js";
-import { states } from "./commands/states.js";
import { labels } from "./commands/labels.js";
+import { local } from "./commands/local.js";
import { members } from "./commands/members.js";
-import { cycles } from "./commands/cycles.js";
import { modules } from "./commands/modules.js";
-import { intake } from "./commands/intake.js";
import { pages } from "./commands/pages.js";
import { projects } from "./commands/projects.js";
-import { init } from "./commands/init.js";
+import { states } from "./commands/states.js";
const plane = Command.make("plane").pipe(
Command.withDescription(
`CLI for the Plane project management API. Useful for humans and AI agents/bots.
CONFIGURATION
- Config file: ~/.config/plane/config.json (written by 'plane init')
- Env vars: PLANE_API_TOKEN, PLANE_HOST, PLANE_WORKSPACE
- Env vars take priority over the config file.
+ Global config: ~/.config/plane/config.json
+ Local config: nearest .plane/config.json from the current directory upward
+ Env vars: PLANE_API_TOKEN
+ PLANE_HOST
+ PLANE_WORKSPACE
+ PLANE_PROJECT for a default project identifier
+ Precedence: env vars > local config > global config
QUICK START
- plane init Interactive setup — saves host/workspace/token
+ plane init -g Interactive global setup
+ plane init --local Interactive local setup in the current directory
+ plane . init Local setup alias for the current directory
plane projects list List projects and their identifiers
+ plane projects use PROJ Save a current project in the active config scope
+ plane projects use PROJ --global Force the saved current project into global config
+ plane projects use PROJ --local Force the saved current project into local config
+ plane issues list List issues for the saved current project
plane issues list PROJ List issues for a project
plane issue get PROJ-29 Get full JSON for an issue
plane issue create PROJ "title" Create an issue
+ plane issue create @current "title" Create an issue in the saved current project
plane issue update --state done PROJ-29
plane issue comment PROJ-29 "text" Add a comment
@@ -36,28 +48,36 @@ CONCEPTS
Priorities urgent | high | medium | low | none
ALL SUBCOMMANDS
- init Set up config interactively
- projects list List all projects
+ init Set up global or local config interactively
+ . local init
+ projects list | current | use
issues list List issues (supports --state, --assignee, --priority)
issue get | create | update | delete | comment | activity |
link | comments | worklogs
cycles list | issues (list, add)
- modules list | issues (list, add, remove)
+ modules list | delete | issues (list, add, remove)
intake list | accept | reject
pages list | get | create | update | delete | archive | unarchive | lock | unlock | duplicate
states list List workflow states for a project
- labels list List labels for a project
- members list List members of a project
+ labels list | create | delete
+ members list List workspace members
FOR AI AGENTS / BOTS
- Add --json to any list command for JSON output (array of objects)
- Add --xml to any list command for XML output
- 'plane issue get PROJ-N' always outputs full JSON
- - Use PLANE_API_TOKEN / PLANE_HOST / PLANE_WORKSPACE env vars to avoid 'plane init'
+ - Use PLANE_API_TOKEN to avoid 'plane init'
+ - Use PLANE_HOST for self-hosted Plane instances
+ - Use PLANE_WORKSPACE to select the workspace
+ - Use PLANE_PROJECT or 'plane projects use PROJ' to persist a current project
+ - Local config lives in '.plane/config.json' and is resolved from the current directory upward
+ - 'plane init --local' also writes '.plane/project-context.json' with existing states, labels, and estimate points for the selected project
+ - 'plane init --local' also creates or updates 'AGENTS.md' so local AI agents reuse '.plane/project-context.json' for project-specific context
- Full Plane REST API reference (180+ endpoints):
https://developers.plane.so/api-reference/introduction`,
),
Command.withSubcommands([
+ local,
init,
projects,
issues,
@@ -74,5 +94,5 @@ FOR AI AGENTS / BOTS
export const cli = Command.run(plane, {
name: "plane",
- version: "0.1.11",
+ version: "1.0.0",
});
diff --git a/src/commands/cycles.ts b/src/commands/cycles.ts
index 0700711..e1ec34a 100644
--- a/src/commands/cycles.ts
+++ b/src/commands/cycles.ts
@@ -1,14 +1,23 @@
-import { Command, Args } from "@effect/cli";
+import { Args, Command } from "@effect/cli";
import { Console, Effect } from "effect";
import { api, decodeOrFail } from "../api.js";
-import { CyclesResponseSchema, CycleIssuesResponseSchema } from "../config.js";
-import { resolveProject, parseIssueRef, findIssueBySeq } from "../resolve.js";
-import { jsonMode, xmlMode, toXml } from "../output.js";
+import { CycleIssuesResponseSchema, CyclesResponseSchema } from "../config.js";
+import { jsonMode, toXml, xmlMode } from "../output.js";
+import {
+ findIssueBySeq,
+ parseIssueRef,
+ requireProjectFeature,
+ resolveProject,
+} from "../resolve.js";
const projectArg = Args.text({ name: "project" }).pipe(
- Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"),
+ Args.withDescription(
+ "Project identifier (e.g. PROJ, WEB, OPS). Use '@current' for the saved default project.",
+ ),
);
+const listProjectArg = projectArg.pipe(Args.withDefault(""));
+
const cycleIdArg = Args.text({ name: "cycle-id" }).pipe(
Args.withDescription("Cycle UUID (from 'plane cycles list PROJECT')"),
);
@@ -18,6 +27,7 @@ const cycleIdArg = Args.text({ name: "cycle-id" }).pipe(
export function cyclesListHandler({ project }: { project: string }) {
return Effect.gen(function* () {
const { id } = yield* resolveProject(project);
+ yield* requireProjectFeature(id, "cycle_view");
const raw = yield* api.get(`projects/${id}/cycles/`);
const { results } = yield* decodeOrFail(CyclesResponseSchema, raw);
if (jsonMode) {
@@ -44,11 +54,11 @@ export function cyclesListHandler({ project }: { project: string }) {
export const cyclesList = Command.make(
"list",
- { project: projectArg },
+ { project: listProjectArg },
cyclesListHandler,
).pipe(
Command.withDescription(
- "List cycles for a project. Shows cycle UUID, status, date range, and name.\n\nExample:\n plane cycles list PROJ",
+ "List cycles for a project. Shows cycle UUID, status, date range, and name. Omit PROJECT to use the saved current project.\n\nExample:\n plane cycles list PROJ",
),
);
@@ -63,6 +73,7 @@ export function cycleIssuesListHandler({
}) {
return Effect.gen(function* () {
const { key, id } = yield* resolveProject(project);
+ yield* requireProjectFeature(id, "cycle_view");
const raw = yield* api.get(
`projects/${id}/cycles/${cycleId}/cycle-issues/`,
);
@@ -80,11 +91,15 @@ export function cycleIssuesListHandler({
return;
}
const lines = results.map((ci) => {
+ if ("sequence_id" in ci) {
+ const seq = String(ci.sequence_id).padStart(3, " ");
+ return `${key}-${seq} ${ci.name}`;
+ }
if (ci.issue_detail) {
const seq = String(ci.issue_detail.sequence_id).padStart(3, " ");
- return `${key}-${seq} ${ci.issue_detail.name} (${ci.id})`;
+ return `${key}-${seq} ${ci.issue_detail.name}`;
}
- return `${ci.issue} (cycle-issue: ${ci.id})`;
+ return `${ci.issue}`;
});
yield* Console.log(lines.join("\n"));
});
@@ -117,6 +132,7 @@ export function cycleIssuesAddHandler({
}) {
return Effect.gen(function* () {
const { id: projectId } = yield* resolveProject(project);
+ yield* requireProjectFeature(projectId, "cycle_view");
const { seq } = yield* parseIssueRef(ref);
const issue = yield* findIssueBySeq(projectId, seq);
yield* api.post(`projects/${projectId}/cycles/${cycleId}/cycle-issues/`, {
diff --git a/src/commands/init.ts b/src/commands/init.ts
index 1ff097c..98b5e4a 100644
--- a/src/commands/init.ts
+++ b/src/commands/init.ts
@@ -1,74 +1,574 @@
-import { Command } from "@effect/cli";
-import { Console, Effect } from "effect";
import * as readline from "node:readline";
-import * as fs from "node:fs";
-import * as path from "node:path";
-import * as os from "node:os";
+import { Command, Options } from "@effect/cli";
+import { Console, Effect, type Schema } from "effect";
+import { decodeOrFail } from "../api.js";
+import {
+ EstimatePointsResponseSchema,
+ EstimateSchema,
+ isProjectIntakeEnabled,
+ LabelsResponseSchema,
+ ProjectDetailSchema,
+ ProjectsResponseSchema,
+ StatesResponseSchema,
+} from "../config.js";
+import {
+ getLocalAgentsFilePath,
+ writeLocalProjectAgentsFile,
+} from "../project-agents.js";
+import {
+ buildProjectContextSnapshot,
+ getLocalProjectContextFilePath,
+ writeLocalProjectContextSnapshot,
+} from "../project-context.js";
+import {
+ type ConfigScope,
+ getConfigDetails,
+ getGlobalConfigFilePath,
+ getLocalConfigFilePath,
+ normalizeHost,
+ readGlobalStoredConfig,
+ readLocalStoredConfigAtPath,
+ writeGlobalStoredConfig,
+ writeLocalStoredConfig,
+} from "../user-config.js";
-export const CONFIG_DIR = path.join(os.homedir(), ".config", "plane");
-export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
-
-export interface PlaneConfig {
- token: string;
- host: string;
- workspace: string;
+interface ProjectFeatureSummary {
+ label: string;
+ enabled: boolean;
}
function prompt(rl: readline.Interface, question: string): Promise {
return new Promise((resolve) => rl.question(question, resolve));
}
-export const init = Command.make("init", {}, () =>
- Effect.gen(function* () {
- let existing: Partial = {};
- try {
- existing = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
- } catch {
- // no existing config
+function resolveProjectSelection(
+ input: string,
+ projects: ReadonlyArray<{ identifier: string; name: string }>,
+ existingDefaultProject: string | undefined,
+ scope: ConfigScope,
+): string | undefined {
+ const trimmed = input.trim();
+ if (!trimmed) {
+ return existingDefaultProject;
+ }
+ if (trimmed === "-") {
+ return scope === "global" ? "" : undefined;
+ }
+ const byNumber = Number.parseInt(trimmed, 10);
+ if (
+ Number.isInteger(byNumber) &&
+ byNumber >= 1 &&
+ byNumber <= projects.length
+ ) {
+ return projects[byNumber - 1].identifier;
+ }
+ const byIdentifier = projects.find(
+ (project) => project.identifier.toUpperCase() === trimmed.toUpperCase(),
+ );
+ if (byIdentifier) {
+ return byIdentifier.identifier;
+ }
+ throw new Error(
+ `Unknown project selection: ${trimmed}. Enter a number, identifier, or '-' to clear.`,
+ );
+}
+
+function resolveScope(
+ { global, local }: { global: boolean; local: boolean },
+ defaultScope: ConfigScope,
+): Effect.Effect {
+ if (global && local) {
+ return Effect.fail(
+ new Error("Choose either --global or --local, not both."),
+ );
+ }
+ if (local) {
+ return Effect.succeed("local");
+ }
+ if (global) {
+ return Effect.succeed("global");
+ }
+ return Effect.succeed(defaultScope);
+}
+
+function promptLabel(
+ label: string,
+ scope: ConfigScope,
+ existingValue: string | undefined,
+ effectiveValue: string,
+ options?: { hidden?: boolean; clearHint?: boolean },
+): string {
+ const displayValue = (value: string) =>
+ options?.hidden && value ? "***" : value;
+
+ if (scope === "local") {
+ const currentValue = existingValue?.trim();
+ const inheritedValue = effectiveValue.trim();
+ const shownValue = currentValue
+ ? displayValue(currentValue)
+ : inheritedValue
+ ? `inherit: ${displayValue(inheritedValue)}`
+ : "inherit";
+ const clearHint = options?.clearHint
+ ? " ('-' to clear)"
+ : " ('-' to inherit)";
+ return `${label} [${shownValue}]${clearHint}: `;
+ }
+
+ return `${label} [${displayValue(existingValue?.trim() ?? "")}]: `;
+}
+
+function resolveLocalValue(
+ input: string,
+ existingValue: string | undefined,
+): string | undefined {
+ const trimmed = input.trim();
+ if (!trimmed) {
+ return existingValue?.trim() || undefined;
+ }
+ if (trimmed === "-") {
+ return undefined;
+ }
+ return trimmed;
+}
+
+function resolveGlobalValue(
+ input: string,
+ existingValue: string | undefined,
+): string {
+ const trimmed = input.trim();
+ return trimmed || existingValue?.trim() || "";
+}
+
+function describeValue(
+ scope: ConfigScope,
+ localValue: string | undefined,
+ effectiveValue: string,
+ options?: { hidden?: boolean },
+): string {
+ const displayValue = (value: string) =>
+ options?.hidden && value ? "***" : value;
+
+ if (scope === "local") {
+ if (localValue?.trim()) {
+ return displayValue(localValue.trim());
}
+ return effectiveValue
+ ? `inherit (${displayValue(effectiveValue)})`
+ : "inherit";
+ }
- const rl = readline.createInterface({
- input: process.stdin,
- output: process.stdout,
+ return displayValue(effectiveValue);
+}
+
+const globalOption = Options.boolean("global").pipe(
+ Options.withAlias("g"),
+ Options.withDescription("Save to ~/.config/plane/config.json"),
+ Options.withDefault(false),
+);
+
+const localOption = Options.boolean("local").pipe(
+ Options.withAlias("l"),
+ Options.withDescription(
+ "Save to ./.plane/config.json in the current directory",
+ ),
+ Options.withDefault(false),
+);
+
+function fetchProjectsForConfig(config: {
+ host: string;
+ workspace: string;
+ token: string;
+}) {
+ return fetchDecodedFromConfig(
+ ProjectsResponseSchema,
+ config,
+ "projects/",
+ ).pipe(Effect.map(({ results }) => results));
+}
+
+function requestJsonFromConfig(
+ config: {
+ host: string;
+ workspace: string;
+ token: string;
+ },
+ path: string,
+) {
+ return Effect.gen(function* () {
+ const response = yield* Effect.tryPromise({
+ try: () =>
+ fetch(`${config.host}/api/v1/workspaces/${config.workspace}/${path}`, {
+ headers: { "X-Api-Key": config.token },
+ }),
+ catch: (error) =>
+ error instanceof Error ? error : new Error(String(error)),
+ });
+ if (!response.ok) {
+ const text = yield* Effect.tryPromise({
+ try: () => response.text(),
+ catch: (error) =>
+ error instanceof Error ? error : new Error(String(error)),
+ });
+ return yield* Effect.fail(new Error(`HTTP ${response.status}: ${text}`));
+ }
+ const raw = yield* Effect.tryPromise({
+ try: () => response.json(),
+ catch: (error) =>
+ error instanceof Error ? error : new Error(String(error)),
});
+ return raw;
+ });
+}
+
+function fetchDecodedFromConfig(
+ schema: Schema.Schema,
+ config: {
+ host: string;
+ workspace: string;
+ token: string;
+ },
+ path: string,
+) {
+ return requestJsonFromConfig(config, path).pipe(
+ Effect.flatMap((raw) => decodeOrFail(schema, raw)),
+ );
+}
- const host = yield* Effect.promise(() =>
- prompt(rl, `Plane host [${existing.host ?? "https://plane.so"}]: `),
+function fetchLocalProjectHelperForConfig(
+ config: {
+ host: string;
+ workspace: string;
+ token: string;
+ },
+ project: { id: string; identifier: string; name: string },
+) {
+ return Effect.gen(function* () {
+ const detail = yield* fetchDecodedFromConfig(
+ ProjectDetailSchema,
+ config,
+ `projects/${project.id}/`,
);
- const workspace = yield* Effect.promise(() =>
- prompt(rl, `Workspace slug [${existing.workspace ?? ""}]: `),
+ const { results: states } = yield* fetchDecodedFromConfig(
+ StatesResponseSchema,
+ config,
+ `projects/${project.id}/states/`,
);
- const token = yield* Effect.promise(() =>
- prompt(rl, `API token [${existing.token ? "***" : ""}]: `),
+ const { results: labels } = yield* fetchDecodedFromConfig(
+ LabelsResponseSchema,
+ config,
+ `projects/${project.id}/labels/`,
);
- rl.close();
+ let estimate = null;
+ let estimatePoints: readonly import("../config.js").EstimatePoint[] = [];
+ if (detail.estimate) {
+ estimate = yield* fetchDecodedFromConfig(
+ EstimateSchema,
+ config,
+ `projects/${project.id}/estimates/`,
+ );
+ estimatePoints = yield* fetchDecodedFromConfig(
+ EstimatePointsResponseSchema,
+ config,
+ `projects/${project.id}/estimates/${estimate.id}/estimate-points/`,
+ );
+ }
- const config: PlaneConfig = {
- host: host.trim() || existing.host || "https://plane.so",
- workspace: workspace.trim() || existing.workspace || "",
- token: token.trim() || existing.token || "",
+ return {
+ detail,
+ snapshot: buildProjectContextSnapshot({
+ project,
+ detail,
+ states,
+ labels,
+ estimate,
+ estimatePoints,
+ }),
};
+ });
+}
+
+function summarizeProjectFeatures(project: {
+ cycle_view: boolean;
+ module_view: boolean;
+ issue_views_view: boolean;
+ page_view: boolean;
+ inbox_view?: boolean;
+ intake_view?: boolean;
+}): ProjectFeatureSummary[] {
+ return [
+ { label: "Cycles", enabled: project.cycle_view },
+ { label: "Modules", enabled: project.module_view },
+ { label: "Views", enabled: project.issue_views_view },
+ { label: "Pages", enabled: project.page_view },
+ { label: "Intake", enabled: isProjectIntakeEnabled(project) },
+ ];
+}
+
+export function initHandler(
+ { global, local }: { global: boolean; local: boolean },
+ defaultScope: ConfigScope = "global",
+) {
+ return Effect.gen(function* () {
+ const scope = yield* resolveScope({ global, local }, defaultScope);
+ const effective = getConfigDetails();
+ const existing =
+ scope === "global"
+ ? readGlobalStoredConfig()
+ : readLocalStoredConfigAtPath();
+ const savePath =
+ scope === "global" ? getGlobalConfigFilePath() : getLocalConfigFilePath();
+
+ const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ });
+
+ let savedHost: string | undefined;
+ let savedWorkspace: string | undefined;
+ let savedToken: string | undefined;
+ let savedDefaultProject = existing.defaultProject;
+ let normalizedHost = "";
+ let mergedWorkspace = "";
+ let mergedToken = "";
+ let projectsResult: import("effect").Either.Either<
+ ReadonlyArray<{ id: string; identifier: string; name: string }>,
+ Error
+ >;
+
+ try {
+ const host = yield* Effect.promise(() =>
+ prompt(
+ rl,
+ promptLabel("Plane host URL", scope, existing.host, effective.host),
+ ),
+ );
+ const workspace = yield* Effect.promise(() =>
+ prompt(
+ rl,
+ promptLabel(
+ "Workspace",
+ scope,
+ existing.workspace,
+ effective.workspace,
+ ),
+ ),
+ );
+ const token = yield* Effect.promise(() =>
+ prompt(
+ rl,
+ promptLabel("API token", scope, existing.token, effective.token, {
+ hidden: true,
+ }),
+ ),
+ );
+
+ savedHost =
+ scope === "global"
+ ? resolveGlobalValue(
+ host,
+ existing.host || effective.host || "https://plane.so",
+ )
+ : resolveLocalValue(host, existing.host);
+ savedWorkspace =
+ scope === "global"
+ ? resolveGlobalValue(
+ workspace,
+ existing.workspace || effective.workspace,
+ )
+ : resolveLocalValue(workspace, existing.workspace);
+ savedToken =
+ scope === "global"
+ ? resolveGlobalValue(token, existing.token || effective.token)
+ : resolveLocalValue(token, existing.token);
+
+ const mergedHost = savedHost ?? effective.host ?? "https://plane.so";
+ normalizedHost = normalizeHost(mergedHost);
+ mergedWorkspace = savedWorkspace ?? effective.workspace;
+ mergedToken = savedToken ?? effective.token;
- if (!config.token) {
- yield* Effect.fail(new Error("API token is required"));
+ if (!mergedToken) {
+ yield* Effect.fail(new Error("API token is required"));
+ }
+ if (!mergedWorkspace) {
+ yield* Effect.fail(new Error("Workspace is required"));
+ }
+
+ projectsResult = yield* Effect.either(
+ fetchProjectsForConfig({
+ host: normalizedHost,
+ workspace: mergedWorkspace,
+ token: mergedToken,
+ }),
+ );
+ if (projectsResult._tag === "Right" && projectsResult.right.length > 0) {
+ yield* Console.log("\nAvailable projects:");
+ yield* Console.log(
+ projectsResult.right
+ .map(
+ (project, index) =>
+ `${index + 1}. ${project.identifier} ${project.name}`,
+ )
+ .join("\n"),
+ );
+ const selectedProject = yield* Effect.promise(() =>
+ prompt(
+ rl,
+ promptLabel(
+ "Default project number or identifier",
+ scope,
+ existing.defaultProject,
+ effective.defaultProject,
+ { clearHint: true },
+ ),
+ ),
+ );
+ savedDefaultProject = resolveProjectSelection(
+ selectedProject,
+ projectsResult.right,
+ existing.defaultProject,
+ scope,
+ );
+ } else if (projectsResult._tag === "Left") {
+ yield* Console.log(
+ `\nWarning: could not load projects for selection (${projectsResult.left.message}). Continuing without changing the current-project override.`,
+ );
+ }
+ } finally {
+ rl.close();
}
- if (!config.workspace) {
- yield* Effect.fail(new Error("Workspace slug is required"));
+
+ if (scope === "global") {
+ writeGlobalStoredConfig({
+ host: normalizedHost,
+ workspace: mergedWorkspace,
+ token: mergedToken,
+ defaultProject: savedDefaultProject,
+ });
+ } else {
+ writeLocalStoredConfig(
+ {
+ host: savedHost,
+ workspace: savedWorkspace,
+ token: savedToken,
+ defaultProject: savedDefaultProject,
+ },
+ { target: "cwd" },
+ );
}
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", {
- mode: 0o600,
- });
+ const hostForDisplay =
+ scope === "global"
+ ? normalizedHost
+ : savedHost
+ ? normalizeHost(savedHost)
+ : normalizedHost;
- yield* Console.log(`\nConfig saved to ${CONFIG_FILE}`);
- yield* Console.log(` Host: ${config.host}`);
- yield* Console.log(` Workspace: ${config.workspace}`);
+ yield* Console.log(
+ `\n${scope === "global" ? "Global" : "Local"} config saved to ${savePath}`,
+ );
+ yield* Console.log(
+ ` Host: ${describeValue(scope, savedHost, hostForDisplay)}`,
+ );
+ yield* Console.log(
+ ` Workspace: ${describeValue(scope, savedWorkspace, mergedWorkspace)}`,
+ );
yield* Console.log(` Token: ***`);
- }),
+ if ((savedDefaultProject ?? effective.defaultProject).trim()) {
+ yield* Console.log(
+ ` Project: ${describeValue(
+ scope,
+ savedDefaultProject,
+ savedDefaultProject ?? effective.defaultProject,
+ )}`,
+ );
+ }
+
+ const activeDefaultProject =
+ savedDefaultProject ?? effective.defaultProject;
+ if (scope === "local" && activeDefaultProject) {
+ const selectedProject =
+ projectsResult?._tag === "Right"
+ ? projectsResult?.right.find(
+ (project) =>
+ project.identifier.toUpperCase() ===
+ activeDefaultProject.toUpperCase(),
+ )
+ : undefined;
+ if (selectedProject) {
+ const projectHelper = yield* Effect.either(
+ fetchLocalProjectHelperForConfig(
+ {
+ host: normalizedHost,
+ workspace: mergedWorkspace,
+ token: mergedToken,
+ },
+ selectedProject,
+ ),
+ );
+ if (projectHelper._tag === "Right") {
+ yield* Console.log("\nProject feature flags:");
+ const featureSummary = summarizeProjectFeatures(
+ projectHelper.right.detail,
+ );
+ for (const feature of featureSummary) {
+ yield* Console.log(
+ ` ${feature.label}: ${feature.enabled ? "enabled" : "disabled"}`,
+ );
+ }
+ const disabled = featureSummary
+ .filter((feature) => !feature.enabled)
+ .map((feature) => feature.label);
+ if (disabled.length > 0) {
+ yield* Console.log(
+ ` Disabled features will fail with explicit errors until Plane enables them: ${disabled.join(", ")}`,
+ );
+ }
+
+ writeLocalProjectContextSnapshot(projectHelper.right.snapshot);
+ const helperPath = getLocalProjectContextFilePath();
+ writeLocalProjectAgentsFile(projectHelper.right.snapshot);
+ const agentsPath = getLocalAgentsFilePath();
+ yield* Console.log(`\nProject helper saved to ${helperPath}`);
+ yield* Console.log(
+ ` States: ${projectHelper.right.snapshot.helpers.states.total}`,
+ );
+ yield* Console.log(
+ ` Labels: ${projectHelper.right.snapshot.helpers.labels.total}`,
+ );
+ if (projectHelper.right.snapshot.helpers.estimate.enabled) {
+ yield* Console.log(
+ ` Estimate: ${projectHelper.right.snapshot.helpers.estimate.name} (${projectHelper.right.snapshot.helpers.estimate.points.length} points)`,
+ );
+ } else {
+ yield* Console.log(" Estimate: disabled");
+ }
+ yield* Console.log(`Local AGENTS.md updated at ${agentsPath}`);
+ } else {
+ yield* Console.log(
+ `\nWarning: could not load project helper data for ${selectedProject.identifier}: ${projectHelper.left.message}`,
+ );
+ }
+ }
+ }
+ });
+}
+
+export const init = Command.make(
+ "init",
+ { global: globalOption, local: localOption },
+ (options) => initHandler(options, "global"),
+).pipe(
+ Command.withDescription(
+ "Interactive setup. Defaults to global config, supports --global/-g and --local/-l, and can save an optional current-project override.",
+ ),
+);
+
+export const localInit = Command.make("init", {}, () =>
+ initHandler({ global: false, local: true }, "local"),
).pipe(
Command.withDescription(
- "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.",
+ "Interactive local setup. Saves overrides to ./.plane/config.json in the current directory, reports project feature flags, writes a local project helper snapshot for states, labels, and estimate points, and updates AGENTS.md with project-context guidance for AI agents.",
),
);
diff --git a/src/commands/intake.ts b/src/commands/intake.ts
index b382029..bb859f5 100644
--- a/src/commands/intake.ts
+++ b/src/commands/intake.ts
@@ -1,28 +1,50 @@
-import { Command, Options, Args } from "@effect/cli";
+import { Args, Command } from "@effect/cli";
import { Console, Effect } from "effect";
import { api, decodeOrFail } from "../api.js";
import { IntakeIssuesResponseSchema } from "../config.js";
-import { resolveProject } from "../resolve.js";
-import { jsonMode, xmlMode, toXml } from "../output.js";
+import { jsonMode, toXml, xmlMode } from "../output.js";
+import { requireProjectFeature, resolveProject } from "../resolve.js";
const projectArg = Args.text({ name: "project" }).pipe(
- Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"),
+ Args.withDescription(
+ "Project identifier (e.g. PROJ, WEB, OPS). Use '@current' for the saved default project.",
+ ),
);
-// Intake status codes: -2=rejected, -1=snoozed, 0=pending, 1=accepted, 2=duplicate
+const listProjectArg = projectArg.pipe(Args.withDefault(""));
+
+// Intake status codes: -2=pending, -1=rejected, 0=snoozed, 1=accepted, 2=duplicate
const STATUS_LABELS: Record = {
- [-2]: "rejected",
- [-1]: "snoozed",
- [0]: "pending",
- [1]: "accepted",
- [2]: "duplicate",
+ [-2]: "pending",
+ [-1]: "rejected",
+ 0: "snoozed",
+ 1: "accepted",
+ 2: "duplicate",
};
+function resolveIntakeMutationId(projectId: string, intakeId: string) {
+ return Effect.gen(function* () {
+ const raw = yield* api.get(`projects/${projectId}/intake-issues/`);
+ const { results } = yield* decodeOrFail(IntakeIssuesResponseSchema, raw);
+ const match = results.find(
+ (item) =>
+ item.id === intakeId ||
+ item.issue === intakeId ||
+ item.issue_detail?.id === intakeId,
+ );
+ if (!match) {
+ return yield* Effect.fail(new Error(`Unknown intake issue: ${intakeId}`));
+ }
+ return match.issue ?? match.issue_detail?.id ?? match.id;
+ });
+}
+
// --- intake list ---
export function intakeListHandler({ project }: { project: string }) {
return Effect.gen(function* () {
const { id } = yield* resolveProject(project);
+ yield* requireProjectFeature(id, "intake_view");
const raw = yield* api.get(`projects/${id}/intake-issues/`);
const { results } = yield* decodeOrFail(IntakeIssuesResponseSchema, raw);
if (jsonMode) {
@@ -38,7 +60,10 @@ export function intakeListHandler({ project }: { project: string }) {
return;
}
const lines = results.map((i) => {
- const status = STATUS_LABELS[i.status ?? 0] ?? String(i.status ?? "?");
+ const status =
+ i.status != null
+ ? (STATUS_LABELS[i.status] ?? String(i.status))
+ : "unknown";
const statusPad = status.padEnd(10);
if (i.issue_detail) {
return `${i.id} [${statusPad}] ${i.issue_detail.name}`;
@@ -51,11 +76,11 @@ export function intakeListHandler({ project }: { project: string }) {
export const intakeList = Command.make(
"list",
- { project: projectArg },
+ { project: listProjectArg },
intakeListHandler,
).pipe(
Command.withDescription(
- "List intake (triage) issues for a project. Shows status: pending, accepted, rejected, snoozed, duplicate.\n\nExample:\n plane intake list PROJ",
+ "List intake (triage) issues for a project. Shows status: pending, accepted, rejected, snoozed, duplicate. Omit PROJECT to use the saved current project.\n\nExample:\n plane intake list PROJ",
),
);
@@ -74,7 +99,9 @@ export function intakeAcceptHandler({
}) {
return Effect.gen(function* () {
const { id } = yield* resolveProject(project);
- yield* api.patch(`projects/${id}/intake-issues/${intakeId}/`, {
+ yield* requireProjectFeature(id, "intake_view");
+ const mutationId = yield* resolveIntakeMutationId(id, intakeId);
+ yield* api.patch(`projects/${id}/intake-issues/${mutationId}/`, {
status: 1,
});
yield* Console.log(`Intake issue ${intakeId} accepted`);
@@ -102,8 +129,10 @@ export function intakeRejectHandler({
}) {
return Effect.gen(function* () {
const { id } = yield* resolveProject(project);
- yield* api.patch(`projects/${id}/intake-issues/${intakeId}/`, {
- status: -2,
+ yield* requireProjectFeature(id, "intake_view");
+ const mutationId = yield* resolveIntakeMutationId(id, intakeId);
+ yield* api.patch(`projects/${id}/intake-issues/${mutationId}/`, {
+ status: -1,
});
yield* Console.log(`Intake issue ${intakeId} rejected`);
});
diff --git a/src/commands/issue.ts b/src/commands/issue.ts
index c474514..8f3efef 100644
--- a/src/commands/issue.ts
+++ b/src/commands/issue.ts
@@ -1,15 +1,25 @@
-import { Command, Options, Args } from "@effect/cli";
+import { Args, Command, Options } from "@effect/cli";
import { Console, Effect, Option } from "effect";
import { api, decodeOrFail } from "../api.js";
import {
- IssueSchema,
ActivitiesResponseSchema,
- IssueLinksResponseSchema,
- IssueLinkSchema,
CommentsResponseSchema,
- WorklogsResponseSchema,
+ IssueLinkSchema,
+ IssueLinksResponseSchema,
+ IssueSchema,
WorklogSchema,
+ WorklogsResponseSchema,
} from "../config.js";
+import { escapeHtmlText } from "../format.js";
+import {
+ type IssueCreatePayload,
+ type IssueUpdatePayload,
+ issueLinkPaths,
+ issueWorklogPaths,
+ requestWithFallback,
+ type WorklogPayload,
+} from "../issue-support.js";
+import { jsonMode, toXml, xmlMode } from "../output.js";
import {
findIssueBySeq,
getLabelId,
@@ -18,35 +28,10 @@ import {
parseIssueRef,
resolveProject,
} from "../resolve.js";
-import { jsonMode, xmlMode, toXml } from "../output.js";
-import { escapeHtmlText } from "../format.js";
const refArg = Args.text({ name: "ref" }).pipe(
Args.withDescription("Issue reference, e.g. PROJ-29"),
);
-// --- Typed payload interfaces ---
-interface IssueUpdatePayload {
- state?: string;
- priority?: string;
- name?: string;
- description_html?: string;
- assignees?: string[];
- label_ids?: string[];
-}
-
-interface IssueCreatePayload {
- name: string;
- priority?: string;
- state?: string;
- description_html?: string;
- assignees?: string[];
- label_ids?: string[];
-}
-
-interface WorklogPayload {
- duration: number;
- description?: string;
-}
// --- issue get ---
export function issueGetHandler({ ref }: { ref: string }) {
return Effect.gen(function* () {
@@ -155,7 +140,11 @@ export function issueUpdateHandler({
`projects/${projectId}/issues/${issue.id}/`,
body,
);
- const updated = yield* decodeOrFail(IssueSchema, raw);
+ yield* decodeOrFail(IssueSchema, raw);
+ const refreshedRaw = yield* api.get(
+ `projects/${projectId}/issues/${issue.id}/`,
+ );
+ const updated = yield* decodeOrFail(IssueSchema, refreshedRaw);
const stateName =
typeof updated.state === "object" ? updated.state.name : updated.state;
yield* Console.log(
@@ -219,7 +208,9 @@ const titleArg = Args.text({ name: "title" }).pipe(
Args.withDescription("Issue title"),
);
const projectRefArg = Args.text({ name: "project" }).pipe(
- Args.withDescription("Project identifier (e.g. PROJ)"),
+ Args.withDescription(
+ "Project identifier (e.g. PROJ). Use '@current' for the saved default project.",
+ ),
);
const createPriorityOption = Options.optional(
@@ -300,7 +291,7 @@ export const issueCreate = Command.make(
issueCreateHandler,
).pipe(
Command.withDescription(
- 'Create a new issue in a project.\n\nExamples:\n plane issue create PROJ "Migrate Button component"\n plane issue create --priority high --state started PROJ "Fix lint pipeline"\n plane issue create --description "Detailed context here" PROJ "Add dark mode"\n plane issue create --assignee "Jane Doe" PROJ "Onboarding bug"',
+ 'Create a new issue in a project. Use @current to target the saved default project.\n\nExamples:\n plane issue create PROJ "Migrate Button component"\n plane issue create @current "Migrate Button component"\n plane issue create --priority high --state started PROJ "Fix lint pipeline"\n plane issue create --description "Detailed context here" PROJ "Add dark mode"\n plane issue create --assignee "Jane Doe" PROJ "Onboarding bug"',
),
);
// --- issue activity ---
@@ -352,8 +343,10 @@ export function issueLinkListHandler({ ref }: { ref: string }) {
return Effect.gen(function* () {
const { projectId, seq } = yield* parseIssueRef(ref);
const issue = yield* findIssueBySeq(projectId, seq);
- const raw = yield* api.get(
- `projects/${projectId}/issues/${issue.id}/issue-links/`,
+ const raw = yield* requestWithFallback(
+ issueLinkPaths(projectId, issue.id),
+ (path) => api.get(path),
+ `Issue links are not available for ${ref} on this Plane instance or API version.`,
);
const { results } = yield* decodeOrFail(IssueLinksResponseSchema, raw);
if (jsonMode) {
@@ -401,10 +394,11 @@ export function issueLinkAddHandler({
const { projectId, seq } = yield* parseIssueRef(ref);
const issue = yield* findIssueBySeq(projectId, seq);
const body: Record = { url };
- if (Option.isSome(title)) body["title"] = title.value;
- const raw = yield* api.post(
- `projects/${projectId}/issues/${issue.id}/issue-links/`,
- body,
+ if (Option.isSome(title)) body.title = title.value;
+ const raw = yield* requestWithFallback(
+ issueLinkPaths(projectId, issue.id),
+ (path) => api.post(path, body),
+ `Issue links are not available for ${ref} on this Plane instance or API version.`,
);
const link = yield* decodeOrFail(IssueLinkSchema, raw);
yield* Console.log(`Link added: ${link.id} ${link.url}`);
@@ -435,9 +429,14 @@ export function issueLinkRemoveHandler({
return Effect.gen(function* () {
const { projectId, seq } = yield* parseIssueRef(ref);
const issue = yield* findIssueBySeq(projectId, seq);
- yield* api.delete(
- `projects/${projectId}/issues/${issue.id}/issue-links/${linkId}/`,
+ // Resolve the correct base path for issue links via a safe probe,
+ // then delete once so a missing link ID results in a proper not-found error.
+ const basePath = yield* requestWithFallback(
+ issueLinkPaths(projectId, issue.id),
+ (path) => api.get(path).pipe(Effect.as(path)),
+ `Issue links are not available for ${ref} on this Plane instance or API version.`,
);
+ yield* api.delete(`${basePath}${linkId}/`);
yield* Console.log(`Link ${linkId} removed from ${ref}`);
});
}
@@ -568,8 +567,10 @@ export function issueWorklogsListHandler({ ref }: { ref: string }) {
return Effect.gen(function* () {
const { projectId, seq } = yield* parseIssueRef(ref);
const issue = yield* findIssueBySeq(projectId, seq);
- const raw = yield* api.get(
- `projects/${projectId}/issues/${issue.id}/worklogs/`,
+ const raw = yield* requestWithFallback(
+ issueWorklogPaths(projectId, issue.id),
+ (path) => api.get(path),
+ `Issue worklogs are not available for ${ref} on this Plane instance or API version.`,
);
const { results } = yield* decodeOrFail(WorklogsResponseSchema, raw);
if (jsonMode) {
@@ -626,9 +627,10 @@ export function issueWorklogsAddHandler({
const issue = yield* findIssueBySeq(projectId, seq);
const body: WorklogPayload = { duration };
if (Option.isSome(description)) body.description = description.value;
- const raw = yield* api.post(
- `projects/${projectId}/issues/${issue.id}/worklogs/`,
- body,
+ const raw = yield* requestWithFallback(
+ issueWorklogPaths(projectId, issue.id),
+ (path) => api.post(path, body),
+ `Issue worklogs are not available for ${ref} on this Plane instance or API version.`,
);
const log = yield* decodeOrFail(WorklogSchema, raw);
const hrs = (log.duration / 60).toFixed(1);
diff --git a/src/commands/issues.ts b/src/commands/issues.ts
index fb35ba5..e3b7c15 100644
--- a/src/commands/issues.ts
+++ b/src/commands/issues.ts
@@ -1,18 +1,20 @@
-import { Command, Options, Args } from "@effect/cli";
-import { Console, Effect, Option } from "effect";
+import { Args, Command, Options } from "@effect/cli";
+import { Console, Effect, type Option } from "effect";
import { api, decodeOrFail } from "../api.js";
+import type { State } from "../config.js";
import { IssuesResponseSchema } from "../config.js";
import { formatIssue } from "../format.js";
+import { jsonMode, toXml, xmlMode } from "../output.js";
import { getMemberId, resolveProject } from "../resolve.js";
-import type { State } from "../config.js";
-import { jsonMode, xmlMode, toXml } from "../output.js";
const projectArg = Args.text({ name: "project" }).pipe(
Args.withDescription(
- "Project identifier — see 'plane projects list' for available identifiers",
+ "Project identifier — see 'plane projects list' for available identifiers. Use '@current' for the saved default project.",
),
);
+const listProjectArg = projectArg.pipe(Args.withDefault(""));
+
const stateOption = Options.optional(Options.text("state")).pipe(
Options.withDescription(
"Filter by state group (backlog | unstarted | started | completed | cancelled) or exact state name",
@@ -91,12 +93,12 @@ export const issuesList = Command.make(
state: stateOption,
assignee: assigneeOption,
priority: priorityOption,
- project: projectArg,
+ project: listProjectArg,
},
issuesListHandler,
).pipe(
Command.withDescription(
- "List issues for a project ordered by sequence ID. Each line shows: REF [state-group] state-name title",
+ "List issues for a project ordered by sequence ID. Each line shows: REF [state-group] state-name title. Omit PROJECT to use the saved current project.",
),
);
diff --git a/src/commands/labels.ts b/src/commands/labels.ts
index e23aff3..55397d9 100644
--- a/src/commands/labels.ts
+++ b/src/commands/labels.ts
@@ -1,41 +1,48 @@
-import { Command, Options, Args } from "@effect/cli";
+import { Args, Command, Options } from "@effect/cli";
import { Console, Effect } from "effect";
import { api, decodeOrFail } from "../api.js";
-import { LabelsResponseSchema, LabelSchema } from "../config.js";
-import { resolveProject } from "../resolve.js";
-import { jsonMode, xmlMode, toXml } from "../output.js";
+import { LabelSchema, LabelsResponseSchema } from "../config.js";
+import { jsonMode, toXml, xmlMode } from "../output.js";
+import { resolveLabel, resolveProject } from "../resolve.js";
const projectArg = Args.text({ name: "project" }).pipe(
- Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"),
+ Args.withDescription(
+ "Project identifier (e.g. PROJ, WEB, OPS). Use '@current' for the saved default project.",
+ ),
);
+const listProjectArg = projectArg.pipe(Args.withDefault(""));
+
// --- labels list ---
+export function labelsListHandler({ project }: { project: string }) {
+ return Effect.gen(function* () {
+ const { id } = yield* resolveProject(project);
+ const raw = yield* api.get(`projects/${id}/labels/`);
+ const { results } = yield* decodeOrFail(LabelsResponseSchema, raw);
+ if (jsonMode) {
+ yield* Console.log(JSON.stringify(results, null, 2));
+ return;
+ }
+ if (xmlMode) {
+ yield* Console.log(toXml(results));
+ return;
+ }
+ if (results.length === 0) {
+ yield* Console.log("No labels found");
+ return;
+ }
+ const lines = results.map(
+ (l) => `${l.id} ${(l.color ?? "").padEnd(8)} ${l.name}`,
+ );
+ yield* Console.log(lines.join("\n"));
+ });
+}
+
export const labelsList = Command.make(
"list",
- { project: projectArg },
- ({ project }) =>
- Effect.gen(function* () {
- const { id } = yield* resolveProject(project);
- const raw = yield* api.get(`projects/${id}/labels/`);
- const { results } = yield* decodeOrFail(LabelsResponseSchema, raw);
- if (jsonMode) {
- yield* Console.log(JSON.stringify(results, null, 2));
- return;
- }
- if (xmlMode) {
- yield* Console.log(toXml(results));
- return;
- }
- if (results.length === 0) {
- yield* Console.log("No labels found");
- return;
- }
- const lines = results.map(
- (l) => `${l.id} ${(l.color ?? "").padEnd(8)} ${l.name}`,
- );
- yield* Console.log(lines.join("\n"));
- }),
+ { project: listProjectArg },
+ labelsListHandler,
);
// --- labels create ---
@@ -46,25 +53,71 @@ const nameArg = Args.text({ name: "name" }).pipe(
const colorOption = Options.optional(Options.text("color")).pipe(
Options.withDescription("Hex color e.g. #ff0000"),
);
+const labelArg = Args.text({ name: "label" }).pipe(
+ Args.withDescription(
+ "Label UUID or exact name (from 'plane labels list PROJECT')",
+ ),
+);
export const labelsCreate = Command.make(
"create",
{ color: colorOption, project: projectArg, name: nameArg },
- ({ project, name, color }) =>
- Effect.gen(function* () {
- const { id } = yield* resolveProject(project);
- interface LabelPayload {
- name: string;
- color?: string;
- }
- const body: LabelPayload = { name };
- if (color._tag === "Some") body.color = color.value;
- const raw = yield* api.post(`projects/${id}/labels/`, body);
- const label = yield* decodeOrFail(LabelSchema, raw);
- yield* Console.log(`Created label: ${label.name} (${label.id})`);
- }),
+ labelsCreateHandler,
+);
+
+export function labelsCreateHandler({
+ project,
+ name,
+ color,
+}: {
+ project: string;
+ name: string;
+ color: { _tag: "Some"; value: string } | { _tag: "None" };
+}) {
+ return Effect.gen(function* () {
+ const { id } = yield* resolveProject(project);
+ interface LabelPayload {
+ name: string;
+ color?: string;
+ }
+ const body: LabelPayload = { name };
+ if (color._tag === "Some") body.color = color.value;
+ const raw = yield* api.post(`projects/${id}/labels/`, body);
+ const label = yield* decodeOrFail(LabelSchema, raw);
+ yield* Console.log(`Created label: ${label.name} (${label.id})`);
+ });
+}
+
+export function labelsDeleteHandler({
+ project,
+ label,
+}: {
+ project: string;
+ label: string;
+}) {
+ return Effect.gen(function* () {
+ const { id } = yield* resolveProject(project);
+ const resolvedLabel = yield* resolveLabel(id, label);
+ yield* api.delete(`projects/${id}/labels/${resolvedLabel.id}/`);
+ yield* Console.log(
+ `Deleted label: ${resolvedLabel.name} (${resolvedLabel.id})`,
+ );
+ });
+}
+
+export const labelsDelete = Command.make(
+ "delete",
+ { project: projectArg, label: labelArg },
+ labelsDeleteHandler,
+).pipe(
+ Command.withDescription(
+ `Delete a label by UUID or exact name.
+
+Example:
+ plane labels delete PROJ bug`,
+ ),
);
export const labels = Command.make("labels").pipe(
- Command.withSubcommands([labelsList, labelsCreate]),
+ Command.withSubcommands([labelsList, labelsCreate, labelsDelete]),
);
diff --git a/src/commands/local.ts b/src/commands/local.ts
new file mode 100644
index 0000000..24805cf
--- /dev/null
+++ b/src/commands/local.ts
@@ -0,0 +1,9 @@
+import { Command } from "@effect/cli";
+import { localInit } from "./init.js";
+
+export const local = Command.make(".").pipe(
+ Command.withDescription(
+ "Manage path-local Plane config for the current directory.",
+ ),
+ Command.withSubcommands([localInit]),
+);
diff --git a/src/commands/members.ts b/src/commands/members.ts
index 0dce79d..788e327 100644
--- a/src/commands/members.ts
+++ b/src/commands/members.ts
@@ -2,7 +2,7 @@ import { Command } from "@effect/cli";
import { Console, Effect } from "effect";
import { api, decodeOrFail } from "../api.js";
import { MembersResponseSchema } from "../config.js";
-import { jsonMode, xmlMode, toXml } from "../output.js";
+import { jsonMode, toXml, xmlMode } from "../output.js";
export const membersList = Command.make("list", {}, () =>
Effect.gen(function* () {
diff --git a/src/commands/modules.ts b/src/commands/modules.ts
index 03d6b21..9d9baa7 100644
--- a/src/commands/modules.ts
+++ b/src/commands/modules.ts
@@ -1,26 +1,42 @@
-import { Command, Args } from "@effect/cli";
+import { Args, Command } from "@effect/cli";
import { Console, Effect } from "effect";
import { api, decodeOrFail } from "../api.js";
import {
- ModulesResponseSchema,
ModuleIssuesResponseSchema,
+ ModulesResponseSchema,
} from "../config.js";
-import { resolveProject, parseIssueRef, findIssueBySeq } from "../resolve.js";
-import { jsonMode, xmlMode, toXml } from "../output.js";
+import { jsonMode, toXml, xmlMode } from "../output.js";
+import {
+ findIssueBySeq,
+ parseIssueRef,
+ requireProjectFeature,
+ resolveModule,
+ resolveProject,
+} from "../resolve.js";
const projectArg = Args.text({ name: "project" }).pipe(
- Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"),
+ Args.withDescription(
+ "Project identifier (e.g. PROJ, WEB, OPS). Use '@current' for the saved default project.",
+ ),
);
+const listProjectArg = projectArg.pipe(Args.withDefault(""));
+
const moduleIdArg = Args.text({ name: "module-id" }).pipe(
Args.withDescription("Module UUID (from 'plane modules list PROJECT')"),
);
+const moduleArg = Args.text({ name: "module" }).pipe(
+ Args.withDescription(
+ "Module UUID or exact name (from 'plane modules list PROJECT')",
+ ),
+);
// --- modules list ---
export function modulesListHandler({ project }: { project: string }) {
return Effect.gen(function* () {
const { id } = yield* resolveProject(project);
+ yield* requireProjectFeature(id, "module_view");
const raw = yield* api.get(`projects/${id}/modules/`);
const { results } = yield* decodeOrFail(ModulesResponseSchema, raw);
if (jsonMode) {
@@ -45,11 +61,44 @@ export function modulesListHandler({ project }: { project: string }) {
export const modulesList = Command.make(
"list",
- { project: projectArg },
+ { project: listProjectArg },
modulesListHandler,
).pipe(
Command.withDescription(
- "List modules for a project. Shows module UUID, status, and name.\n\nExample:\n plane modules list PROJ",
+ "List modules for a project. Shows module UUID, status, and name. Omit PROJECT to use the saved current project.\n\nExample:\n plane modules list PROJ",
+ ),
+);
+
+// --- modules delete ---
+
+export function modulesDeleteHandler({
+ project,
+ module,
+}: {
+ project: string;
+ module: string;
+}) {
+ return Effect.gen(function* () {
+ const { id } = yield* resolveProject(project);
+ yield* requireProjectFeature(id, "module_view");
+ const resolvedModule = yield* resolveModule(id, module);
+ yield* api.delete(`projects/${id}/modules/${resolvedModule.id}/`);
+ yield* Console.log(
+ `Deleted module: ${resolvedModule.name} (${resolvedModule.id})`,
+ );
+ });
+}
+
+export const modulesDelete = Command.make(
+ "delete",
+ { project: projectArg, module: moduleArg },
+ modulesDeleteHandler,
+).pipe(
+ Command.withDescription(
+ `Delete a module by UUID or exact name.
+
+Example:
+ plane modules delete PROJ `,
),
);
@@ -64,6 +113,7 @@ export function moduleIssuesListHandler({
}) {
return Effect.gen(function* () {
const { key, id } = yield* resolveProject(project);
+ yield* requireProjectFeature(id, "module_view");
const raw = yield* api.get(
`projects/${id}/modules/${moduleId}/module-issues/`,
);
@@ -81,10 +131,14 @@ export function moduleIssuesListHandler({
return;
}
const lines = results.map((mi) => {
- if (mi.issue_detail) {
+ if ("issue_detail" in mi && mi.issue_detail) {
const seq = String(mi.issue_detail.sequence_id).padStart(3, " ");
return `${key}-${seq} ${mi.issue_detail.name} (${mi.id})`;
}
+ if ("sequence_id" in mi) {
+ const seq = String(mi.sequence_id).padStart(3, " ");
+ return `${key}-${seq} ${mi.name} (${mi.id})`;
+ }
return `${mi.issue} (module-issue: ${mi.id})`;
});
yield* Console.log(lines.join("\n"));
@@ -118,6 +172,7 @@ export function moduleIssuesAddHandler({
}) {
return Effect.gen(function* () {
const { id: projectId } = yield* resolveProject(project);
+ yield* requireProjectFeature(projectId, "module_view");
const { seq } = yield* parseIssueRef(ref);
const issue = yield* findIssueBySeq(projectId, seq);
yield* api.post(
@@ -144,7 +199,7 @@ export const moduleIssuesAdd = Command.make(
const moduleIssueIdArg = Args.text({ name: "module-issue-id" }).pipe(
Args.withDescription(
- "Module-issue join ID (from 'plane modules issues list')",
+ "Module issue identifier from 'plane modules issues list' (legacy join ID or live raw issue ID)",
),
);
@@ -159,6 +214,7 @@ export function moduleIssuesRemoveHandler({
}) {
return Effect.gen(function* () {
const { id } = yield* resolveProject(project);
+ yield* requireProjectFeature(id, "module_view");
yield* api.delete(
`projects/${id}/modules/${moduleId}/module-issues/${moduleIssueId}/`,
);
@@ -178,7 +234,7 @@ export const moduleIssuesRemove = Command.make(
moduleIssuesRemoveHandler,
).pipe(
Command.withDescription(
- "Remove an issue from a module using the module-issue join ID.\n\nExample:\n plane modules issues remove PROJ ",
+ "Remove an issue from a module using the identifier returned by 'plane modules issues list'.\n\nExample:\n plane modules issues remove PROJ ",
),
);
@@ -199,7 +255,7 @@ export const moduleIssues = Command.make("issues").pipe(
export const modules = Command.make("modules").pipe(
Command.withDescription(
- "Manage modules (groups of related issues). Subcommands: list, issues\n\nExamples:\n plane modules list PROJ\n plane modules issues list PROJ \n plane modules issues add PROJ PROJ-29",
+ "Manage modules (groups of related issues). Subcommands: list, delete, issues\n\nExamples:\n plane modules list PROJ\n plane modules delete PROJ \n plane modules issues list PROJ \n plane modules issues add PROJ PROJ-29",
),
- Command.withSubcommands([modulesList, moduleIssues]),
+ Command.withSubcommands([modulesList, modulesDelete, moduleIssues]),
);
diff --git a/src/commands/pages.ts b/src/commands/pages.ts
index 68d63dc..21815ab 100644
--- a/src/commands/pages.ts
+++ b/src/commands/pages.ts
@@ -1,14 +1,18 @@
-import { Command, Args, Options } from "@effect/cli";
+import { Args, Command, Options } from "@effect/cli";
import { Console, Effect, Option } from "effect";
import { api, decodeOrFail } from "../api.js";
-import { PagesResponseSchema, PageSchema } from "../config.js";
-import { resolveProject } from "../resolve.js";
-import { jsonMode, xmlMode, toXml } from "../output.js";
+import { PageSchema, PagesResponseSchema } from "../config.js";
+import { jsonMode, toXml, xmlMode } from "../output.js";
+import { requireProjectFeature, resolveProject } from "../resolve.js";
const projectArg = Args.text({ name: "project" }).pipe(
- Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"),
+ Args.withDescription(
+ "Project identifier (e.g. PROJ, WEB, OPS). Use '@current' for the saved default project.",
+ ),
);
+const listProjectArg = projectArg.pipe(Args.withDefault(""));
+
const pageIdArg = Args.text({ name: "page-id" }).pipe(
Args.withDescription("Page UUID (from 'plane pages list')"),
);
@@ -25,15 +29,43 @@ const descriptionOption = Options.optional(Options.text("description")).pipe(
Options.withDescription("Page description as HTML (e.g. 'Hello
')"),
);
-interface PageCreatePayload { name: string; description_html?: string; }
-interface PageUpdatePayload { name?: string; description_html?: string; }
+interface PageCreatePayload {
+ name: string;
+ description_html?: string;
+}
+interface PageUpdatePayload {
+ name?: string;
+ description_html?: string;
+}
+
+function isNotFoundError(error: Error): boolean {
+ return /^HTTP 404:/.test(error.message);
+}
+
+function mapPageAvailabilityError(
+ effect: Effect.Effect,
+ message: string,
+): Effect.Effect {
+ return effect.pipe(
+ Effect.catchAll((error) => {
+ if (isNotFoundError(error)) {
+ return Effect.fail(new Error(message));
+ }
+ return Effect.fail(error);
+ }),
+ );
+}
// --- pages list ---
export function pagesListHandler({ project }: { project: string }) {
return Effect.gen(function* () {
- const { id } = yield* resolveProject(project);
- const raw = yield* api.get(`projects/${id}/pages/`);
+ const { key, id } = yield* resolveProject(project);
+ yield* requireProjectFeature(id, "page_view");
+ const raw = yield* mapPageAvailabilityError(
+ api.get(`projects/${id}/pages/`),
+ `Project pages are not available for ${key} on this Plane instance or API version.`,
+ );
const { results } = yield* decodeOrFail(PagesResponseSchema, raw);
if (jsonMode) {
yield* Console.log(JSON.stringify(results, null, 2));
@@ -57,11 +89,11 @@ export function pagesListHandler({ project }: { project: string }) {
export const pagesList = Command.make(
"list",
- { project: projectArg },
+ { project: listProjectArg },
pagesListHandler,
).pipe(
Command.withDescription(
- "List pages for a project. Shows page UUID, last updated date, and title.\n\nExample:\n plane pages list PROJ",
+ "List pages for a project. Shows page UUID, last updated date, and title. Omit PROJECT to use the saved current project.\n\nExample:\n plane pages list PROJ",
),
);
@@ -76,6 +108,7 @@ export function pagesGetHandler({
}) {
return Effect.gen(function* () {
const { id } = yield* resolveProject(project);
+ yield* requireProjectFeature(id, "page_view");
const raw = yield* api.get(`projects/${id}/pages/${pageId}/`);
const page = yield* decodeOrFail(PageSchema, raw);
yield* Console.log(JSON.stringify(page, null, 2));
@@ -104,12 +137,16 @@ export function pagesCreateHandler({
description: Option.Option;
}) {
return Effect.gen(function* () {
- const { id } = yield* resolveProject(project);
+ const { key, id } = yield* resolveProject(project);
+ yield* requireProjectFeature(id, "page_view");
const body: PageCreatePayload = { name };
if (Option.isSome(description)) {
body.description_html = description.value;
}
- const raw = yield* api.post(`projects/${id}/pages/`, body);
+ const raw = yield* mapPageAvailabilityError(
+ api.post(`projects/${id}/pages/`, body),
+ `Project pages are not available for ${key} on this Plane instance or API version.`,
+ );
const page = yield* decodeOrFail(PageSchema, raw);
yield* Console.log(`Created page ${page.id}: ${page.name}`);
});
@@ -121,7 +158,7 @@ export const pagesCreate = Command.make(
pagesCreateHandler,
).pipe(
Command.withDescription(
- "Create a new page.\n\nExample:\n plane pages create --name \"My Page\" PROJ",
+ 'Create a new page.\n\nExample:\n plane pages create --name "My Page" PROJ',
),
);
@@ -143,6 +180,7 @@ export function pagesUpdateHandler({
yield* Effect.fail(new Error("provide at least --name or --description"));
}
const { id } = yield* resolveProject(project);
+ yield* requireProjectFeature(id, "page_view");
const body: PageUpdatePayload = {};
if (Option.isSome(name)) body.name = name.value;
if (Option.isSome(description)) body.description_html = description.value;
@@ -154,11 +192,16 @@ export function pagesUpdateHandler({
export const pagesUpdate = Command.make(
"update",
- { project: projectArg, pageId: pageIdArg, name: nameOptionalOption, description: descriptionOption },
+ {
+ project: projectArg,
+ pageId: pageIdArg,
+ name: nameOptionalOption,
+ description: descriptionOption,
+ },
pagesUpdateHandler,
).pipe(
Command.withDescription(
- "Update a page's name or description.\n\nExample:\n plane pages update --name \"New Title\" PROJ ",
+ 'Update a page\'s name or description.\n\nExample:\n plane pages update --name "New Title" PROJ ',
),
);
@@ -173,6 +216,7 @@ export function pagesDeleteHandler({
}) {
return Effect.gen(function* () {
const { id } = yield* resolveProject(project);
+ yield* requireProjectFeature(id, "page_view");
yield* api.delete(`projects/${id}/pages/${pageId}/`);
yield* Console.log(`Deleted page ${pageId}`);
});
@@ -199,6 +243,7 @@ export function pagesArchiveHandler({
}) {
return Effect.gen(function* () {
const { id } = yield* resolveProject(project);
+ yield* requireProjectFeature(id, "page_view");
yield* api.post(`projects/${id}/pages/${pageId}/archive/`, {});
yield* Console.log(`Archived page ${pageId}`);
});
@@ -225,6 +270,7 @@ export function pagesUnarchiveHandler({
}) {
return Effect.gen(function* () {
const { id } = yield* resolveProject(project);
+ yield* requireProjectFeature(id, "page_view");
yield* api.delete(`projects/${id}/pages/${pageId}/archive/`);
yield* Console.log(`Unarchived page ${pageId}`);
});
@@ -251,6 +297,7 @@ export function pagesLockHandler({
}) {
return Effect.gen(function* () {
const { id } = yield* resolveProject(project);
+ yield* requireProjectFeature(id, "page_view");
yield* api.post(`projects/${id}/pages/${pageId}/lock/`, {});
yield* Console.log(`Locked page ${pageId}`);
});
@@ -277,6 +324,7 @@ export function pagesUnlockHandler({
}) {
return Effect.gen(function* () {
const { id } = yield* resolveProject(project);
+ yield* requireProjectFeature(id, "page_view");
yield* api.delete(`projects/${id}/pages/${pageId}/lock/`);
yield* Console.log(`Unlocked page ${pageId}`);
});
@@ -303,7 +351,11 @@ export function pagesDuplicateHandler({
}) {
return Effect.gen(function* () {
const { id } = yield* resolveProject(project);
- const raw = yield* api.post(`projects/${id}/pages/${pageId}/duplicate/`, {});
+ yield* requireProjectFeature(id, "page_view");
+ const raw = yield* api.post(
+ `projects/${id}/pages/${pageId}/duplicate/`,
+ {},
+ );
const page = yield* decodeOrFail(PageSchema, raw);
yield* Console.log(`Duplicated page ${page.id}: ${page.name}`);
});
@@ -326,7 +378,15 @@ export const pages = Command.make("pages").pipe(
"Manage project pages (documentation). Subcommands: list, get, create, update, delete, archive, unarchive, lock, unlock, duplicate\n\nExamples:\n plane pages list PROJ\n plane pages get PROJ ",
),
Command.withSubcommands([
- pagesList, pagesGet, pagesCreate, pagesUpdate, pagesDelete,
- pagesArchive, pagesUnarchive, pagesLock, pagesUnlock, pagesDuplicate,
+ pagesList,
+ pagesGet,
+ pagesCreate,
+ pagesUpdate,
+ pagesDelete,
+ pagesArchive,
+ pagesUnarchive,
+ pagesLock,
+ pagesUnlock,
+ pagesDuplicate,
]),
);
diff --git a/src/commands/projects.ts b/src/commands/projects.ts
index 2860455..0e9cb3c 100644
--- a/src/commands/projects.ts
+++ b/src/commands/projects.ts
@@ -1,13 +1,74 @@
-import { Command } from "@effect/cli";
+import { Args, Command, Options } from "@effect/cli";
import { Console, Effect } from "effect";
import { api, decodeOrFail } from "../api.js";
import { ProjectsResponseSchema } from "../config.js";
-import { jsonMode, xmlMode, toXml } from "../output.js";
+import { jsonMode, toXml, xmlMode } from "../output.js";
+import { resolveProject } from "../resolve.js";
+import {
+ type ConfigScope,
+ getConfigDetails,
+ getDefaultConfigWriteScope,
+ readLocalStoredConfig,
+ readStoredConfig,
+ writeLocalStoredConfig,
+ writeStoredConfig,
+} from "../user-config.js";
-export const projectsList = Command.make("list", {}, () =>
- Effect.gen(function* () {
+const projectArg = Args.text({ name: "project" }).pipe(
+ Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"),
+);
+
+const globalOption = Options.boolean("global").pipe(
+ Options.withAlias("g"),
+ Options.withDescription("Write the current project to global config"),
+ Options.withDefault(false),
+);
+
+const localOption = Options.boolean("local").pipe(
+ Options.withAlias("l"),
+ Options.withDescription("Write the current project to local config"),
+ Options.withDefault(false),
+);
+
+function resolveWriteScope({
+ global,
+ local,
+}: {
+ global: boolean;
+ local: boolean;
+}): Effect.Effect {
+ if (global && local) {
+ return Effect.fail(
+ new Error("Choose either --global or --local, not both."),
+ );
+ }
+ if (local) {
+ return Effect.succeed("local");
+ }
+ if (global) {
+ return Effect.succeed("global");
+ }
+ return Effect.succeed(getDefaultConfigWriteScope());
+}
+
+function describeProjectSource(source: string): string {
+ switch (source) {
+ case "env":
+ return "env";
+ case "local":
+ return "local";
+ case "global":
+ return "global";
+ default:
+ return "config";
+ }
+}
+
+export function projectsListHandler() {
+ return Effect.gen(function* () {
const raw = yield* api.get("projects/");
const { results } = yield* decodeOrFail(ProjectsResponseSchema, raw);
+ const currentProject = getConfigDetails().defaultProject.toUpperCase();
if (jsonMode) {
yield* Console.log(JSON.stringify(results, null, 2));
return;
@@ -16,18 +77,100 @@ export const projectsList = Command.make("list", {}, () =>
yield* Console.log(toXml(results));
return;
}
- const lines = results.map(
- (p) => `${p.identifier.padEnd(6)} ${p.id} ${p.name}`,
- );
+ const lines = results.map((project) => {
+ const marker =
+ currentProject === project.identifier.toUpperCase() ? "*" : " ";
+ return `${marker} ${project.identifier.padEnd(6)} ${project.id} ${project.name}`;
+ });
yield* Console.log(lines.join("\n"));
- }),
+ });
+}
+
+export const projectsList = Command.make("list", {}, projectsListHandler).pipe(
+ Command.withDescription(
+ "List all projects in the workspace. The IDENTIFIER column is what you pass to other commands. A leading '*' marks the saved current project.",
+ ),
+);
+
+export function projectsCurrentHandler() {
+ return Effect.gen(function* () {
+ const config = getConfigDetails();
+ const configuredProject = config.defaultProject;
+ if (!configuredProject) {
+ return yield* Effect.fail(
+ new Error(
+ "No default project configured. Run 'plane init', 'plane init --local', 'plane . init', or 'plane projects use PROJ'.",
+ ),
+ );
+ }
+ const source = describeProjectSource(config.sources.defaultProject);
+ const { key, id } = yield* resolveProject("@current");
+ const raw = yield* api.get("projects/");
+ const { results } = yield* decodeOrFail(ProjectsResponseSchema, raw);
+ const project = results.find((candidate) => candidate.id === id);
+ if (!project) {
+ yield* Console.log(`${key} (${source})`);
+ return;
+ }
+ yield* Console.log(
+ `${project.identifier} ${project.id} ${project.name} (${source})`,
+ );
+ });
+}
+
+export const projectsCurrent = Command.make(
+ "current",
+ {},
+ projectsCurrentHandler,
+).pipe(
+ Command.withDescription(
+ "Show the effective current project and whether it came from env, local config, or global config.",
+ ),
+);
+
+export function projectsUseHandler({
+ project,
+ global,
+ local,
+}: {
+ project: string;
+ global: boolean;
+ local: boolean;
+}) {
+ return Effect.gen(function* () {
+ const scope = yield* resolveWriteScope({ global, local });
+ const { key } = yield* resolveProject(project);
+ if (scope === "local") {
+ const existing = readLocalStoredConfig();
+ writeLocalStoredConfig(
+ {
+ ...existing,
+ defaultProject: key,
+ },
+ { target: "active" },
+ );
+ } else {
+ const existing = readStoredConfig();
+ writeStoredConfig({
+ ...existing,
+ defaultProject: key,
+ });
+ }
+ yield* Console.log(`Current project set to ${key} (${scope})`);
+ });
+}
+
+export const projectsUse = Command.make(
+ "use",
+ { project: projectArg, global: globalOption, local: localOption },
+ projectsUseHandler,
).pipe(
Command.withDescription(
- "List all projects in the workspace. The IDENTIFIER column is what you pass to other commands (e.g. 'plane issues list PROJ').",
+ "Persist a current project. Defaults to local scope when a local config is active in the current path; use --global or --local to force the target scope.",
),
);
export const projects = Command.make("projects").pipe(
Command.withDescription("Manage projects."),
- Command.withSubcommands([projectsList]),
+ Command.withSubcommands([projectsList, projectsCurrent, projectsUse]),
);
diff --git a/src/commands/states.ts b/src/commands/states.ts
index 092a747..b3a67d6 100644
--- a/src/commands/states.ts
+++ b/src/commands/states.ts
@@ -1,17 +1,21 @@
-import { Command, Options, Args } from "@effect/cli";
+import { Args, Command } from "@effect/cli";
import { Console, Effect } from "effect";
import { api, decodeOrFail } from "../api.js";
import { StatesResponseSchema } from "../config.js";
+import { jsonMode, toXml, xmlMode } from "../output.js";
import { resolveProject } from "../resolve.js";
-import { jsonMode, xmlMode, toXml } from "../output.js";
const projectArg = Args.text({ name: "project" }).pipe(
- Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"),
+ Args.withDescription(
+ "Project identifier (e.g. PROJ, WEB, OPS). Use '@current' for the saved default project.",
+ ),
);
+const listProjectArg = projectArg.pipe(Args.withDefault(""));
+
export const statesList = Command.make(
"list",
- { project: projectArg },
+ { project: listProjectArg },
({ project }) =>
Effect.gen(function* () {
const { id } = yield* resolveProject(project);
diff --git a/src/config.ts b/src/config.ts
index 0ff9bf9..400c54b 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -73,6 +73,50 @@ export const ProjectSchema = Schema.Struct({
});
export type Project = typeof ProjectSchema.Type;
+export const ProjectDetailSchema = Schema.Struct({
+ id: Schema.String,
+ identifier: Schema.String,
+ name: Schema.String,
+ module_view: Schema.Boolean,
+ cycle_view: Schema.Boolean,
+ issue_views_view: Schema.Boolean,
+ page_view: Schema.Boolean,
+ inbox_view: Schema.optional(Schema.Boolean),
+ intake_view: Schema.optional(Schema.Boolean),
+ estimate: Schema.optional(Schema.NullOr(Schema.String)),
+});
+export type ProjectDetail = typeof ProjectDetailSchema.Type;
+
+export function isProjectIntakeEnabled(
+ project: Pick,
+): boolean {
+ return (project.inbox_view || project.intake_view) ?? false;
+}
+
+export const EstimateSchema = Schema.Struct({
+ id: Schema.String,
+ name: Schema.String,
+ description: Schema.optional(Schema.NullOr(Schema.String)),
+ type: Schema.String,
+ last_used: Schema.optional(Schema.Boolean),
+ project: Schema.String,
+ workspace: Schema.String,
+});
+export type Estimate = typeof EstimateSchema.Type;
+
+export const EstimatePointSchema = Schema.Struct({
+ id: Schema.String,
+ estimate: Schema.String,
+ key: Schema.optional(Schema.Number),
+ value: Schema.String,
+ description: Schema.optional(Schema.NullOr(Schema.String)),
+ project: Schema.String,
+ workspace: Schema.String,
+});
+export type EstimatePoint = typeof EstimatePointSchema.Type;
+
+export const EstimatePointsResponseSchema = Schema.Array(EstimatePointSchema);
+
export const ProjectsResponseSchema = Schema.Struct({
results: Schema.Array(ProjectSchema),
});
@@ -122,7 +166,7 @@ export const ModulesResponseSchema = Schema.Struct({
results: Schema.Array(ModuleSchema),
});
-export const ModuleIssueSchema = Schema.Struct({
+export const ModuleIssueRelationSchema = Schema.Struct({
id: Schema.String,
issue: Schema.String,
issue_detail: Schema.optional(
@@ -133,6 +177,17 @@ export const ModuleIssueSchema = Schema.Struct({
}),
),
});
+
+export const ModuleIssueRawSchema = Schema.Struct({
+ id: Schema.String,
+ sequence_id: Schema.Number,
+ name: Schema.String,
+});
+
+export const ModuleIssueSchema = Schema.Union(
+ ModuleIssueRelationSchema,
+ ModuleIssueRawSchema,
+);
export type ModuleIssue = typeof ModuleIssueSchema.Type;
export const ModuleIssuesResponseSchema = Schema.Struct({
@@ -199,7 +254,7 @@ export const CommentsResponseSchema = Schema.Struct({
results: Schema.Array(CommentSchema),
});
-export const CycleIssueSchema = Schema.Struct({
+export const CycleIssueRelationSchema = Schema.Struct({
id: Schema.String,
issue: Schema.String,
issue_detail: Schema.optional(
@@ -210,6 +265,17 @@ export const CycleIssueSchema = Schema.Struct({
}),
),
});
+
+export const CycleIssueRawSchema = Schema.Struct({
+ id: Schema.String,
+ sequence_id: Schema.Number,
+ name: Schema.String,
+});
+
+export const CycleIssueSchema = Schema.Union(
+ CycleIssueRelationSchema,
+ CycleIssueRawSchema,
+);
export type CycleIssue = typeof CycleIssueSchema.Type;
export const CycleIssuesResponseSchema = Schema.Struct({
diff --git a/src/issue-support.ts b/src/issue-support.ts
new file mode 100644
index 0000000..2c44e47
--- /dev/null
+++ b/src/issue-support.ts
@@ -0,0 +1,72 @@
+import { Effect } from "effect";
+
+export interface IssueUpdatePayload {
+ state?: string;
+ priority?: string;
+ name?: string;
+ description_html?: string;
+ assignees?: string[];
+ label_ids?: string[];
+}
+
+export interface IssueCreatePayload {
+ name: string;
+ priority?: string;
+ state?: string;
+ description_html?: string;
+ assignees?: string[];
+ label_ids?: string[];
+}
+
+export interface WorklogPayload {
+ duration: number;
+ description?: string;
+}
+
+function isNotFoundError(error: Error): boolean {
+ return /^HTTP 404:/.test(error.message);
+}
+
+export function requestWithFallback(
+ paths: ReadonlyArray,
+ request: (path: string) => Effect.Effect,
+ notFoundMessage: string,
+): Effect.Effect {
+ const [current, ...rest] = paths;
+ if (!current) {
+ return Effect.fail(new Error(notFoundMessage));
+ }
+ return request(current).pipe(
+ Effect.catchAll((error) => {
+ if (!isNotFoundError(error)) {
+ return Effect.fail(error);
+ }
+ if (rest.length === 0) {
+ return Effect.fail(new Error(notFoundMessage));
+ }
+ return requestWithFallback(rest, request, notFoundMessage);
+ }),
+ );
+}
+
+export function issueLinkPaths(
+ projectId: string,
+ issueId: string,
+): ReadonlyArray {
+ return [
+ `projects/${projectId}/work-items/${issueId}/links/`,
+ `projects/${projectId}/issues/${issueId}/links/`,
+ `projects/${projectId}/issues/${issueId}/issue-links/`,
+ ];
+}
+
+export function issueWorklogPaths(
+ projectId: string,
+ issueId: string,
+): ReadonlyArray {
+ return [
+ `projects/${projectId}/work-items/${issueId}/worklogs/`,
+ `projects/${projectId}/issues/${issueId}/worklogs/`,
+ `projects/${projectId}/issues/${issueId}/issue-worklogs/`,
+ ];
+}
diff --git a/src/output.ts b/src/output.ts
index f911b04..8f6af21 100644
--- a/src/output.ts
+++ b/src/output.ts
@@ -34,9 +34,9 @@ function toXmlItem(obj: Record, tag = "item"): string {
: toXmlItem(v as Record, k),
)
.join("");
- return `<${tag}${attrs ? " " + attrs : ""}>${children}${tag}>`;
+ return `<${tag}${attrs ? ` ${attrs}` : ""}>${children}${tag}>`;
}
export function toXml(results: readonly unknown[]): string {
- return `\n${results.map((r) => " " + toXmlItem(r as Record)).join("\n")}\n`;
+ return `\n${results.map((r) => ` ${toXmlItem(r as Record)}`).join("\n")}\n`;
}
diff --git a/src/project-agents.ts b/src/project-agents.ts
new file mode 100644
index 0000000..1bdbc89
--- /dev/null
+++ b/src/project-agents.ts
@@ -0,0 +1,83 @@
+import * as fs from "node:fs";
+import * as path from "node:path";
+import type { ProjectContextSnapshot } from "./project-context.js";
+import { getLocalConfigDir } from "./user-config.js";
+
+const MANAGED_SECTION_START = "";
+const MANAGED_SECTION_END = "";
+
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+function buildManagedSection(snapshot: ProjectContextSnapshot): string {
+ return [
+ MANAGED_SECTION_START,
+ "## Plane Project Context",
+ `This directory is scoped to Plane project ${snapshot.project.identifier} (${snapshot.project.name}).`,
+ "",
+ "When working as an AI agent in this directory:",
+ "- Read `./.plane/project-context.json` before planning or applying Plane project changes.",
+ "- Reuse the existing states, labels, and estimate points in that snapshot instead of creating duplicates.",
+ "- Respect the feature flags in that snapshot before using cycles, modules, pages, intake, or estimates.",
+ "- Prefer the `plane` CLI from this repository root for Plane project work instead of direct API calls.",
+ "- Use `@current` as the default project selector once local init has been run.",
+ "- If the shell may contain inherited `PLANE_*` variables, clear them before relying on `./.plane/config.json`.",
+ "",
+ "Common agent commands:",
+ "",
+ "```sh",
+ "unset PLANE_HOST PLANE_WORKSPACE PLANE_API_TOKEN PLANE_PROJECT",
+ "plane projects current",
+ "plane issues list @current",
+ `plane issue get ${snapshot.project.identifier}-12`,
+ `plane issue update --state started ${snapshot.project.identifier}-12`,
+ "```",
+ "",
+ "- Rerun `plane init --local` from this directory whenever the Plane project configuration changes so this context stays current.",
+ "",
+ "This section is managed by `plane-cli` and is updated by `plane init --local`.",
+ MANAGED_SECTION_END,
+ "",
+ ].join("\n");
+}
+
+function upsertManagedSection(
+ existingContent: string,
+ managedSection: string,
+): string {
+ const managedPattern = new RegExp(
+ `${escapeRegExp(MANAGED_SECTION_START)}[\\s\\S]*?${escapeRegExp(MANAGED_SECTION_END)}\\n?`,
+ "m",
+ );
+
+ if (managedPattern.test(existingContent)) {
+ return existingContent.replace(managedPattern, managedSection);
+ }
+
+ const trimmed = existingContent.trimEnd();
+ if (!trimmed) {
+ return managedSection;
+ }
+
+ return `${trimmed}\n\n${managedSection}`;
+}
+
+export function getLocalAgentsFilePath(cwd = process.cwd()): string {
+ return path.join(path.dirname(getLocalConfigDir(cwd)), "AGENTS.md");
+}
+
+export function writeLocalProjectAgentsFile(
+ snapshot: ProjectContextSnapshot,
+ cwd = process.cwd(),
+): void {
+ const filePath = getLocalAgentsFilePath(cwd);
+ const existingContent = fs.existsSync(filePath)
+ ? fs.readFileSync(filePath, "utf8")
+ : "";
+ const nextContent = upsertManagedSection(
+ existingContent,
+ buildManagedSection(snapshot),
+ );
+ fs.writeFileSync(filePath, nextContent, "utf8");
+}
diff --git a/src/project-context.ts b/src/project-context.ts
new file mode 100644
index 0000000..6b8df64
--- /dev/null
+++ b/src/project-context.ts
@@ -0,0 +1,176 @@
+import * as fs from "node:fs";
+import * as path from "node:path";
+import type {
+ Estimate,
+ EstimatePoint,
+ Label,
+ ProjectDetail,
+ State,
+} from "./config.js";
+import { isProjectIntakeEnabled } from "./config.js";
+import { getLocalConfigDir } from "./user-config.js";
+
+interface ProjectSummary {
+ id: string;
+ identifier: string;
+ name: string;
+}
+
+interface ProjectFeaturesSummary {
+ cycles: boolean;
+ modules: boolean;
+ views: boolean;
+ pages: boolean;
+ intake: boolean;
+ estimates: boolean;
+}
+
+interface ProjectStateHelperEntry {
+ id: string;
+ name: string;
+ group: string;
+ color?: string;
+}
+
+interface ProjectLabelHelperEntry {
+ id: string;
+ name: string;
+ color?: string | null;
+ parent?: string | null;
+}
+
+interface ProjectEstimatePointHelperEntry {
+ id: string;
+ key?: number;
+ value: string;
+ description?: string | null;
+}
+
+export interface ProjectContextSnapshot {
+ generatedAt: string;
+ project: ProjectSummary;
+ features: ProjectFeaturesSummary;
+ helpers: {
+ states: {
+ total: number;
+ byName: Record;
+ byGroup: Record;
+ };
+ labels: {
+ total: number;
+ byName: Record;
+ };
+ estimate: {
+ enabled: boolean;
+ id?: string;
+ name?: string;
+ type?: string;
+ points: ProjectEstimatePointHelperEntry[];
+ pointsByValue: Record;
+ };
+ };
+}
+
+function normalizeLookupKey(value: string): string {
+ return value.trim().toLowerCase();
+}
+
+export function buildProjectContextSnapshot({
+ project,
+ detail,
+ states,
+ labels,
+ estimate,
+ estimatePoints,
+}: {
+ project: ProjectSummary;
+ detail: ProjectDetail;
+ states: readonly State[];
+ labels: readonly Label[];
+ estimate: Estimate | null;
+ estimatePoints: readonly EstimatePoint[];
+}): ProjectContextSnapshot {
+ const statesByName: Record = {};
+ const statesByGroup: Record = {};
+ for (const state of states) {
+ const entry: ProjectStateHelperEntry = {
+ id: state.id,
+ name: state.name,
+ group: state.group,
+ color: state.color,
+ };
+ statesByName[normalizeLookupKey(state.name)] = entry;
+ statesByGroup[state.group] ??= [];
+ statesByGroup[state.group].push(entry);
+ }
+
+ const labelsByName: Record = {};
+ for (const label of labels) {
+ labelsByName[normalizeLookupKey(label.name)] = {
+ id: label.id,
+ name: label.name,
+ color: label.color,
+ parent: label.parent,
+ };
+ }
+
+ const points = estimatePoints
+ .map((point) => ({
+ id: point.id,
+ key: point.key,
+ value: point.value,
+ description: point.description,
+ }))
+ .sort((left, right) => (left.key ?? 0) - (right.key ?? 0));
+ const pointsByValue = Object.fromEntries(
+ points.map((point) => [normalizeLookupKey(point.value), point]),
+ );
+
+ return {
+ generatedAt: new Date().toISOString(),
+ project,
+ features: {
+ cycles: detail.cycle_view,
+ modules: detail.module_view,
+ views: detail.issue_views_view,
+ pages: detail.page_view,
+ intake: isProjectIntakeEnabled(detail),
+ estimates: estimate !== null,
+ },
+ helpers: {
+ states: {
+ total: states.length,
+ byName: statesByName,
+ byGroup: statesByGroup,
+ },
+ labels: {
+ total: labels.length,
+ byName: labelsByName,
+ },
+ estimate: {
+ enabled: estimate !== null,
+ id: estimate?.id,
+ name: estimate?.name,
+ type: estimate?.type,
+ points,
+ pointsByValue,
+ },
+ },
+ };
+}
+
+export function getLocalProjectContextFilePath(cwd = process.cwd()): string {
+ return path.join(getLocalConfigDir(cwd), "project-context.json");
+}
+
+export function writeLocalProjectContextSnapshot(
+ snapshot: ProjectContextSnapshot,
+ cwd = process.cwd(),
+): void {
+ const filePath = getLocalProjectContextFilePath(cwd);
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
+ fs.writeFileSync(filePath, `${JSON.stringify(snapshot, null, 2)}\n`, {
+ mode: 0o600,
+ });
+ fs.chmodSync(filePath, 0o600);
+}
diff --git a/src/resolve.ts b/src/resolve.ts
index 7f77c91..11192d4 100644
--- a/src/resolve.ts
+++ b/src/resolve.ts
@@ -1,20 +1,75 @@
import { Effect } from "effect";
import { api, decodeOrFail } from "./api.js";
+import type { Issue, ProjectDetail } from "./config.js";
import {
IssuesResponseSchema,
+ isProjectIntakeEnabled,
LabelsResponseSchema,
MembersResponseSchema,
+ ModulesResponseSchema,
+ ProjectDetailSchema,
ProjectsResponseSchema,
StatesResponseSchema,
} from "./config.js";
-import type { Issue } from "./config.js";
+import { getConfig } from "./user-config.js";
// Cache project list within a process invocation
let _projectCache: Record | null = null;
+let _projectDetailCache: Record | null = null;
+
+type ProjectFeatureKey =
+ | "cycle_view"
+ | "module_view"
+ | "page_view"
+ | "intake_view";
+
+const FEATURE_LABELS: Record = {
+ cycle_view: "Cycles",
+ module_view: "Modules",
+ page_view: "Pages",
+ intake_view: "Intake",
+};
+
+const FEATURE_HINTS: Record = {
+ cycle_view: "Enable Cycles in the Plane project settings.",
+ module_view: "Enable Modules in the Plane project settings.",
+ page_view: "Enable Pages in the Plane project settings.",
+ intake_view: "Enable Intake in the Plane project settings.",
+};
+
+function isProjectFeatureEnabled(
+ project: ProjectDetail,
+ feature: ProjectFeatureKey,
+): boolean {
+ if (feature === "intake_view") {
+ return isProjectIntakeEnabled(project);
+ }
+ return project[feature];
+}
+
+function getConfiguredProject(identifier: string): string {
+ const trimmed = identifier.trim();
+ if (
+ trimmed &&
+ trimmed !== "." &&
+ trimmed.toLowerCase() !== "@current" &&
+ trimmed.toLowerCase() !== "@default"
+ ) {
+ return trimmed;
+ }
+ const defaultProject = getConfig().defaultProject.trim();
+ if (defaultProject) {
+ return defaultProject;
+ }
+ throw new Error(
+ "No default project configured. Run 'plane init', 'plane init --local', 'plane . init', 'plane projects use PROJ', or set PLANE_PROJECT.",
+ );
+}
/** Clear the project cache — for use in tests only */
export function _clearProjectCache(): void {
_projectCache = null;
+ _projectDetailCache = null;
}
function getProjectMap(): Effect.Effect, Error> {
@@ -29,10 +84,60 @@ function getProjectMap(): Effect.Effect, Error> {
});
}
+function getProjectDetail(
+ projectId: string,
+): Effect.Effect {
+ if (_projectDetailCache?.[projectId]) {
+ return Effect.succeed(_projectDetailCache[projectId]);
+ }
+ return Effect.gen(function* () {
+ const raw = yield* api.get(`projects/${projectId}/`);
+ const project = yield* decodeOrFail(ProjectDetailSchema, raw);
+ _projectDetailCache ??= {};
+ _projectDetailCache[projectId] = project;
+ return project;
+ });
+}
+
+export function getProjectFeatureDetails(projectId: string) {
+ return getProjectDetail(projectId).pipe(
+ Effect.map((project) => ({
+ project,
+ features: {
+ Cycles: project.cycle_view,
+ Modules: project.module_view,
+ Views: project.issue_views_view,
+ Pages: project.page_view,
+ Intake: isProjectIntakeEnabled(project),
+ },
+ })),
+ );
+}
+
+export function requireProjectFeature(
+ projectId: string,
+ feature: ProjectFeatureKey,
+): Effect.Effect {
+ return getProjectDetail(projectId).pipe(
+ Effect.flatMap((project) => {
+ if (isProjectFeatureEnabled(project, feature)) {
+ return Effect.succeed(void 0);
+ }
+ const featureLabel = FEATURE_LABELS[feature];
+ const featureFlag = feature === "intake_view" ? "intake_view" : feature;
+ return Effect.fail(
+ new Error(
+ `Project ${project.identifier} has ${featureLabel} disabled (${featureFlag}=false). ${FEATURE_HINTS[feature]}`,
+ ),
+ );
+ }),
+ );
+}
+
export function resolveProject(
identifier: string,
): Effect.Effect<{ key: string; id: string }, Error> {
- const key = identifier.toUpperCase();
+ const key = getConfiguredProject(identifier).toUpperCase();
return getProjectMap().pipe(
Effect.flatMap((map) => {
const id = map[key];
@@ -124,13 +229,39 @@ export function getLabelId(
projectId: string,
name: string,
): Effect.Effect {
+ return resolveLabel(projectId, name).pipe(Effect.map((label) => label.id));
+}
+
+export function resolveLabel(
+ projectId: string,
+ nameOrId: string,
+): Effect.Effect<{ id: string; name: string }, Error> {
return Effect.gen(function* () {
const raw = yield* api.get(`projects/${projectId}/labels/`);
const { results } = yield* decodeOrFail(LabelsResponseSchema, raw);
- const lower = name.toLowerCase();
- const label = results.find((l) => l.name.toLowerCase() === lower);
+ const lower = nameOrId.toLowerCase();
+ const label = results.find(
+ (l) => l.id === nameOrId || l.name.toLowerCase() === lower,
+ );
if (!label)
- return yield* Effect.fail(new Error(`Label not found: ${name}`));
- return label.id;
+ return yield* Effect.fail(new Error(`Label not found: ${nameOrId}`));
+ return { id: label.id, name: label.name };
+ });
+}
+
+export function resolveModule(
+ projectId: string,
+ nameOrId: string,
+): Effect.Effect<{ id: string; name: string }, Error> {
+ return Effect.gen(function* () {
+ const raw = yield* api.get(`projects/${projectId}/modules/`);
+ const { results } = yield* decodeOrFail(ModulesResponseSchema, raw);
+ const lower = nameOrId.toLowerCase();
+ const module = results.find(
+ (m) => m.id === nameOrId || m.name.toLowerCase() === lower,
+ );
+ if (!module)
+ return yield* Effect.fail(new Error(`Module not found: ${nameOrId}`));
+ return { id: module.id, name: module.name };
});
}
diff --git a/src/user-config.ts b/src/user-config.ts
new file mode 100644
index 0000000..c34d6a7
--- /dev/null
+++ b/src/user-config.ts
@@ -0,0 +1,270 @@
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+
+export type ConfigScope = "global" | "local";
+
+type ConfigSource = "env" | "local" | "global" | "default" | "none";
+
+export interface StoredPlaneConfig {
+ token?: string;
+ host?: string;
+ workspace?: string;
+ defaultProject?: string;
+}
+
+export interface PlaneConfig {
+ token: string;
+ host: string;
+ workspace: string;
+ defaultProject: string;
+}
+
+export interface PlaneConfigDetails extends PlaneConfig {
+ sources: {
+ token: ConfigSource;
+ host: ConfigSource;
+ workspace: ConfigSource;
+ defaultProject: ConfigSource;
+ };
+ paths: {
+ globalConfigFile: string;
+ localConfigFile: string | null;
+ localConfigTargetFile: string;
+ };
+}
+
+const DEFAULT_HOST = "https://plane.so";
+
+export function normalizeHost(host: string): string {
+ const trimmed = host.trim().replace(/\/$/, "");
+ if (!trimmed) {
+ return trimmed;
+ }
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
+ return trimmed;
+ }
+ return `https://${trimmed}`;
+}
+
+function cleanConfig(config: StoredPlaneConfig): StoredPlaneConfig {
+ const cleaned: StoredPlaneConfig = {};
+
+ if (config.token?.trim()) {
+ cleaned.token = config.token.trim();
+ }
+ if (config.host?.trim()) {
+ cleaned.host = normalizeHost(config.host.trim());
+ }
+ if (config.workspace?.trim()) {
+ cleaned.workspace = config.workspace.trim();
+ }
+ if (config.defaultProject?.trim()) {
+ cleaned.defaultProject = config.defaultProject.trim();
+ }
+
+ return cleaned;
+}
+
+function readConfigFile(filePath: string): StoredPlaneConfig {
+ try {
+ return cleanConfig(JSON.parse(fs.readFileSync(filePath, "utf8")));
+ } catch {
+ return {};
+ }
+}
+
+function writeConfigFile(filePath: string, config: StoredPlaneConfig): void {
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
+ fs.writeFileSync(
+ filePath,
+ `${JSON.stringify(cleanConfig(config), null, 2)}\n`,
+ {
+ mode: 0o600,
+ },
+ );
+ fs.chmodSync(filePath, 0o600);
+}
+
+export function getGlobalConfigDir(): string {
+ return path.join(os.homedir(), ".config", "plane");
+}
+
+export function getConfigDir(): string {
+ return getGlobalConfigDir();
+}
+
+export function getGlobalConfigFilePath(): string {
+ return path.join(getGlobalConfigDir(), "config.json");
+}
+
+export function getConfigFilePath(): string {
+ return getGlobalConfigFilePath();
+}
+
+export function getLocalConfigDir(cwd = process.cwd()): string {
+ return path.join(path.resolve(cwd), ".plane");
+}
+
+export function getLocalConfigFilePath(cwd = process.cwd()): string {
+ return path.join(getLocalConfigDir(cwd), "config.json");
+}
+
+export function findNearestLocalConfigFilePath(
+ cwd = process.cwd(),
+): string | null {
+ let currentDir = path.resolve(cwd);
+
+ for (;;) {
+ const candidate = getLocalConfigFilePath(currentDir);
+ if (fs.existsSync(candidate)) {
+ return candidate;
+ }
+
+ const parentDir = path.dirname(currentDir);
+ if (parentDir === currentDir) {
+ return null;
+ }
+ currentDir = parentDir;
+ }
+}
+
+export function getDefaultConfigWriteScope(cwd = process.cwd()): ConfigScope {
+ return findNearestLocalConfigFilePath(cwd) ? "local" : "global";
+}
+
+export function getLocalConfigTargetFilePath(
+ cwd = process.cwd(),
+ target: "active" | "cwd" = "active",
+): string {
+ if (target === "cwd") {
+ return getLocalConfigFilePath(cwd);
+ }
+
+ return findNearestLocalConfigFilePath(cwd) ?? getLocalConfigFilePath(cwd);
+}
+
+export function readGlobalStoredConfig(): StoredPlaneConfig {
+ return readConfigFile(getGlobalConfigFilePath());
+}
+
+export function readStoredConfig(): StoredPlaneConfig {
+ return readGlobalStoredConfig();
+}
+
+export function readLocalStoredConfig(cwd = process.cwd()): StoredPlaneConfig {
+ const filePath = findNearestLocalConfigFilePath(cwd);
+ return filePath ? readConfigFile(filePath) : {};
+}
+
+export function readLocalStoredConfigAtPath(
+ cwd = process.cwd(),
+): StoredPlaneConfig {
+ return readConfigFile(getLocalConfigFilePath(cwd));
+}
+
+export function writeGlobalStoredConfig(config: StoredPlaneConfig): void {
+ writeConfigFile(getGlobalConfigFilePath(), config);
+}
+
+export function writeStoredConfig(config: StoredPlaneConfig): void {
+ writeGlobalStoredConfig(config);
+}
+
+export function writeLocalStoredConfig(
+ config: StoredPlaneConfig,
+ options?: {
+ cwd?: string;
+ target?: "active" | "cwd";
+ },
+): void {
+ writeConfigFile(
+ getLocalConfigTargetFilePath(options?.cwd, options?.target),
+ config,
+ );
+}
+
+export function getConfigDetails(cwd = process.cwd()): PlaneConfigDetails {
+ const globalConfig = readGlobalStoredConfig();
+ const localConfigFile = findNearestLocalConfigFilePath(cwd);
+ const localConfig = localConfigFile ? readConfigFile(localConfigFile) : {};
+
+ const envToken = process.env.PLANE_API_TOKEN?.trim() || undefined;
+ const envHost = process.env.PLANE_HOST?.trim() || undefined;
+ const envWorkspace = process.env.PLANE_WORKSPACE?.trim() || undefined;
+ const envProject = process.env.PLANE_PROJECT?.trim() || undefined;
+
+ const hostSource: ConfigSource = envHost
+ ? "env"
+ : localConfig.host
+ ? "local"
+ : globalConfig.host
+ ? "global"
+ : "default";
+ const tokenSource: ConfigSource = envToken
+ ? "env"
+ : localConfig.token
+ ? "local"
+ : globalConfig.token
+ ? "global"
+ : "none";
+
+ // Security: if the host comes from local config but the token comes from
+ // global config, an untrusted repo could redirect a real token to an
+ // attacker-controlled host. In that case, fall back to the global host.
+ const safeHostSource =
+ hostSource === "local" && tokenSource === "global" ? "global" : hostSource;
+
+ const token = envToken ?? localConfig.token ?? globalConfig.token ?? "";
+ const host = normalizeHost(
+ safeHostSource === "local"
+ ? (localConfig.host ?? globalConfig.host ?? DEFAULT_HOST)
+ : safeHostSource === "env"
+ ? (envHost ?? DEFAULT_HOST)
+ : safeHostSource === "global"
+ ? (globalConfig.host ?? DEFAULT_HOST)
+ : DEFAULT_HOST,
+ );
+ const workspace =
+ envWorkspace ?? localConfig.workspace ?? globalConfig.workspace ?? "";
+ const defaultProject =
+ envProject ??
+ localConfig.defaultProject ??
+ globalConfig.defaultProject ??
+ "";
+
+ return {
+ token,
+ host,
+ workspace,
+ defaultProject,
+ sources: {
+ token: tokenSource,
+ host: safeHostSource,
+ workspace: envWorkspace
+ ? "env"
+ : localConfig.workspace
+ ? "local"
+ : globalConfig.workspace
+ ? "global"
+ : "none",
+ defaultProject: envProject
+ ? "env"
+ : localConfig.defaultProject
+ ? "local"
+ : globalConfig.defaultProject
+ ? "global"
+ : "none",
+ },
+ paths: {
+ globalConfigFile: getGlobalConfigFilePath(),
+ localConfigFile,
+ localConfigTargetFile: getLocalConfigTargetFilePath(cwd),
+ },
+ };
+}
+
+export function getConfig(): PlaneConfig {
+ const { sources: _sources, paths: _paths, ...config } = getConfigDetails();
+ return config;
+}
diff --git a/tests/api.test.ts b/tests/api.test.ts
index 18101c1..1c8e1b4 100644
--- a/tests/api.test.ts
+++ b/tests/api.test.ts
@@ -7,11 +7,10 @@ import {
expect,
it,
} from "bun:test";
-import { Effect } from "effect";
-import { http, HttpResponse } from "msw";
+import { Effect, Schema } from "effect";
+import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import { api, decodeOrFail } from "@/api";
-import { Schema } from "effect";
const BASE = "http://api-test.local";
const WS = "testws";
@@ -22,16 +21,16 @@ beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterAll(() => server.close());
beforeEach(() => {
- process.env["PLANE_HOST"] = BASE;
- process.env["PLANE_WORKSPACE"] = WS;
- process.env["PLANE_API_TOKEN"] = "test-token";
+ process.env.PLANE_HOST = BASE;
+ process.env.PLANE_WORKSPACE = WS;
+ process.env.PLANE_API_TOKEN = "test-token";
});
afterEach(() => {
server.resetHandlers();
- delete process.env["PLANE_HOST"];
- delete process.env["PLANE_WORKSPACE"];
- delete process.env["PLANE_API_TOKEN"];
+ delete process.env.PLANE_HOST;
+ delete process.env.PLANE_WORKSPACE;
+ delete process.env.PLANE_API_TOKEN;
});
describe("api.get", () => {
@@ -50,7 +49,7 @@ describe("api.get", () => {
});
it("strips trailing slash from PLANE_HOST", async () => {
- process.env["PLANE_HOST"] = `${BASE}/`;
+ process.env.PLANE_HOST = `${BASE}/`;
server.use(
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
HttpResponse.json({ results: [] }),
diff --git a/tests/cycles-extended.test.ts b/tests/cycles-extended.test.ts
index c1ddee6..755e587 100644
--- a/tests/cycles-extended.test.ts
+++ b/tests/cycles-extended.test.ts
@@ -8,7 +8,7 @@ import {
it,
} from "bun:test";
import { Effect } from "effect";
-import { http, HttpResponse } from "msw";
+import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import { _clearProjectCache } from "@/resolve";
@@ -18,6 +18,16 @@ const WS = "testws";
const PROJECTS = [
{ id: "proj-acme", identifier: "ACME", name: "Acme Project" },
];
+const PROJECT_DETAIL = {
+ id: "proj-acme",
+ identifier: "ACME",
+ name: "Acme Project",
+ module_view: true,
+ cycle_view: true,
+ issue_views_view: true,
+ page_view: true,
+ inbox_view: true,
+};
const ISSUES = [
{
id: "i1",
@@ -39,9 +49,9 @@ const CYCLES = [
];
const CYCLE_ISSUES = [
{
- id: "ci1",
- issue: "i1",
- issue_detail: { id: "i1", sequence_id: 29, name: "Migrate Button" },
+ id: "i1",
+ sequence_id: 29,
+ name: "Migrate Button",
},
];
@@ -49,6 +59,9 @@ const server = setupServer(
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
HttpResponse.json({ results: PROJECTS }),
),
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/`, () =>
+ HttpResponse.json(PROJECT_DETAIL),
+ ),
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, () =>
HttpResponse.json({ results: ISSUES }),
),
@@ -66,16 +79,16 @@ afterAll(() => server.close());
beforeEach(() => {
_clearProjectCache();
- process.env["PLANE_HOST"] = BASE;
- process.env["PLANE_WORKSPACE"] = WS;
- process.env["PLANE_API_TOKEN"] = "test-token";
+ process.env.PLANE_HOST = BASE;
+ process.env.PLANE_WORKSPACE = WS;
+ process.env.PLANE_API_TOKEN = "test-token";
});
afterEach(() => {
server.resetHandlers();
- delete process.env["PLANE_HOST"];
- delete process.env["PLANE_WORKSPACE"];
- delete process.env["PLANE_API_TOKEN"];
+ delete process.env.PLANE_HOST;
+ delete process.env.PLANE_WORKSPACE;
+ delete process.env.PLANE_API_TOKEN;
});
describe("cyclesList", () => {
@@ -148,6 +161,40 @@ describe("cycleIssuesList", () => {
expect(output).toContain("Migrate Button");
});
+ it("accepts legacy cycle-issue join payloads", async () => {
+ server.use(
+ http.get(
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/cyc1/cycle-issues/`,
+ () =>
+ HttpResponse.json({
+ results: [
+ {
+ id: "ci1",
+ issue: "i1",
+ issue_detail: {
+ id: "i1",
+ sequence_id: 29,
+ name: "Migrate Button",
+ },
+ },
+ ],
+ }),
+ ),
+ );
+ const { cycleIssuesListHandler } = await import("@/commands/cycles");
+ const logs: string[] = [];
+ const orig = console.log;
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
+ try {
+ await Effect.runPromise(
+ cycleIssuesListHandler({ project: "ACME", cycleId: "cyc1" }),
+ );
+ } finally {
+ console.log = orig;
+ }
+ expect(logs.join("\n")).toContain("Migrate Button");
+ });
+
it("falls back to issue UUID without detail", async () => {
server.use(
http.get(
diff --git a/tests/format.test.ts b/tests/format.test.ts
index 999d480..3d22fdc 100644
--- a/tests/format.test.ts
+++ b/tests/format.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test";
-import { escapeHtmlText, formatIssue } from "@/format";
import type { Issue } from "@/config";
+import { escapeHtmlText, formatIssue } from "@/format";
const stateObj = { id: "s1", name: "In Progress", group: "started" };
diff --git a/tests/helpers/mock-api.ts b/tests/helpers/mock-api.ts
index 3046f76..43922dc 100644
--- a/tests/helpers/mock-api.ts
+++ b/tests/helpers/mock-api.ts
@@ -1,4 +1,4 @@
-import { http, HttpResponse } from "msw";
+import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
export const BASE = "http://localhost:3737";
diff --git a/tests/intake.test.ts b/tests/intake.test.ts
index 639d1e0..d4bf21e 100644
--- a/tests/intake.test.ts
+++ b/tests/intake.test.ts
@@ -8,7 +8,7 @@ import {
it,
} from "bun:test";
import { Effect } from "effect";
-import { http, HttpResponse } from "msw";
+import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import { _clearProjectCache } from "@/resolve";
@@ -18,20 +18,32 @@ const WS = "testws";
const PROJECTS = [
{ id: "proj-acme", identifier: "ACME", name: "Acme Project" },
];
+const PROJECT_DETAIL = {
+ id: "proj-acme",
+ identifier: "ACME",
+ name: "Acme Project",
+ module_view: true,
+ cycle_view: true,
+ issue_views_view: true,
+ page_view: true,
+ intake_view: true,
+};
const INTAKE_ISSUES = [
{
id: "int1",
+ issue: "i1",
issue_detail: {
id: "i1",
sequence_id: 5,
name: "Bug report",
priority: "high",
},
- status: 0,
+ status: -2,
created_at: "2025-01-15T10:00:00Z",
},
{
id: "int2",
+ issue: "i2",
issue_detail: {
id: "i2",
sequence_id: 6,
@@ -43,6 +55,7 @@ const INTAKE_ISSUES = [
},
{
id: "int3",
+ issue: "i3",
created_at: "2025-01-13T10:00:00Z",
},
];
@@ -51,6 +64,9 @@ const server = setupServer(
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
HttpResponse.json({ results: PROJECTS }),
),
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/`, () =>
+ HttpResponse.json(PROJECT_DETAIL),
+ ),
http.get(
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/intake-issues/`,
() => HttpResponse.json({ results: INTAKE_ISSUES }),
@@ -62,16 +78,16 @@ afterAll(() => server.close());
beforeEach(() => {
_clearProjectCache();
- process.env["PLANE_HOST"] = BASE;
- process.env["PLANE_WORKSPACE"] = WS;
- process.env["PLANE_API_TOKEN"] = "test-token";
+ process.env.PLANE_HOST = BASE;
+ process.env.PLANE_WORKSPACE = WS;
+ process.env.PLANE_API_TOKEN = "test-token";
});
afterEach(() => {
server.resetHandlers();
- delete process.env["PLANE_HOST"];
- delete process.env["PLANE_WORKSPACE"];
- delete process.env["PLANE_API_TOKEN"];
+ delete process.env.PLANE_HOST;
+ delete process.env.PLANE_WORKSPACE;
+ delete process.env.PLANE_API_TOKEN;
});
describe("intakeList", () => {
@@ -131,7 +147,7 @@ describe("intakeAccept", () => {
let patchedBody: unknown;
server.use(
http.patch(
- `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/intake-issues/int1/`,
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/intake-issues/i1/`,
async ({ request }) => {
patchedBody = await request.json();
return HttpResponse.json({
@@ -163,12 +179,12 @@ describe("intakeReject", () => {
let patchedBody: unknown;
server.use(
http.patch(
- `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/intake-issues/int1/`,
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/intake-issues/i1/`,
async ({ request }) => {
patchedBody = await request.json();
return HttpResponse.json({
id: "int1",
- status: -2,
+ status: -1,
created_at: "2025-01-15T10:00:00Z",
});
},
@@ -185,7 +201,7 @@ describe("intakeReject", () => {
} finally {
console.log = orig;
}
- expect((patchedBody as { status?: number }).status).toBe(-2);
+ expect((patchedBody as { status?: number }).status).toBe(-1);
expect(logs.join("\n")).toContain("rejected");
});
});
diff --git a/tests/issue-activity.test.ts b/tests/issue-activity.test.ts
index fe7dc63..2abd76d 100644
--- a/tests/issue-activity.test.ts
+++ b/tests/issue-activity.test.ts
@@ -6,10 +6,9 @@ import {
describe,
expect,
it,
- mock,
} from "bun:test";
import { Effect } from "effect";
-import { http, HttpResponse } from "msw";
+import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import { _clearProjectCache } from "@/resolve";
@@ -67,16 +66,16 @@ afterAll(() => server.close());
beforeEach(() => {
_clearProjectCache();
- process.env["PLANE_HOST"] = BASE;
- process.env["PLANE_WORKSPACE"] = WS;
- process.env["PLANE_API_TOKEN"] = "test-token";
+ process.env.PLANE_HOST = BASE;
+ process.env.PLANE_WORKSPACE = WS;
+ process.env.PLANE_API_TOKEN = "test-token";
});
afterEach(() => {
server.resetHandlers();
- delete process.env["PLANE_HOST"];
- delete process.env["PLANE_WORKSPACE"];
- delete process.env["PLANE_API_TOKEN"];
+ delete process.env.PLANE_HOST;
+ delete process.env.PLANE_WORKSPACE;
+ delete process.env.PLANE_API_TOKEN;
});
describe("issueActivity command handler", () => {
diff --git a/tests/issue-commands.test.ts b/tests/issue-commands.test.ts
index 58548f2..29e7e9c 100644
--- a/tests/issue-commands.test.ts
+++ b/tests/issue-commands.test.ts
@@ -10,7 +10,7 @@ import {
import { Command } from "@effect/cli";
import { NodeContext } from "@effect/platform-node";
import { Effect, Layer, Option } from "effect";
-import { http, HttpResponse } from "msw";
+import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import { _clearProjectCache } from "@/resolve";
@@ -57,6 +57,14 @@ const server = setupServer(
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, () =>
HttpResponse.json({ results: ISSUES }),
),
+ http.get(
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/:issueId/`,
+ ({ params }) => {
+ const issue = ISSUES.find((i) => i.id === params.issueId);
+ if (issue) return HttpResponse.json(issue);
+ return new HttpResponse(null, { status: 404 });
+ },
+ ),
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/states/`, () =>
HttpResponse.json({ results: STATES }),
),
@@ -76,16 +84,17 @@ afterAll(() => server.close());
beforeEach(() => {
_clearProjectCache();
- process.env["PLANE_HOST"] = BASE;
- process.env["PLANE_WORKSPACE"] = WS;
- process.env["PLANE_API_TOKEN"] = "test-token";
+ process.env.PLANE_HOST = BASE;
+ process.env.PLANE_WORKSPACE = WS;
+ process.env.PLANE_API_TOKEN = "test-token";
});
afterEach(() => {
server.resetHandlers();
- delete process.env["PLANE_HOST"];
- delete process.env["PLANE_WORKSPACE"];
- delete process.env["PLANE_API_TOKEN"];
+ delete process.env.PLANE_HOST;
+ delete process.env.PLANE_WORKSPACE;
+ delete process.env.PLANE_API_TOKEN;
+ delete process.env.PLANE_PROJECT;
});
describe("issueGet", () => {
@@ -261,6 +270,31 @@ describe("issuesList", () => {
expect(output).toContain("Urgent fix");
expect(output).not.toContain("Low cleanup");
});
+
+ it("uses the saved current project when the project input is blank", async () => {
+ process.env.PLANE_PROJECT = "ACME";
+ const { issuesListHandler } = await import("@/commands/issues");
+ const logs: string[] = [];
+ const orig = console.log;
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
+
+ try {
+ await Effect.runPromise(
+ issuesListHandler({
+ project: "",
+ state: Option.none(),
+ assignee: Option.none(),
+ priority: Option.none(),
+ }),
+ );
+ } finally {
+ console.log = orig;
+ }
+
+ const output = logs.join("\n");
+ expect(output).toContain("ACME-");
+ expect(output).toContain("Migrate Button");
+ });
});
describe("issueUpdate", () => {
@@ -321,6 +355,17 @@ describe("issueUpdate", () => {
});
},
),
+ http.get(
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/`,
+ () =>
+ HttpResponse.json({
+ id: "i1",
+ sequence_id: 29,
+ name: "Migrate Button",
+ priority: "urgent",
+ state: "s1",
+ }),
+ ),
);
const { issueUpdateHandler } = await import("@/commands/issue");
@@ -630,9 +675,9 @@ describe("issueCreate description", () => {
}),
);
- expect(
- (postedBody as { description_html?: string }).description_html,
- ).toBe("Raw HTML
");
+ expect((postedBody as { description_html?: string }).description_html).toBe(
+ "Raw HTML
",
+ );
});
});
diff --git a/tests/issue-comments-worklogs.test.ts b/tests/issue-comments-worklogs.test.ts
index d86d77a..e81028f 100644
--- a/tests/issue-comments-worklogs.test.ts
+++ b/tests/issue-comments-worklogs.test.ts
@@ -8,7 +8,7 @@ import {
it,
} from "bun:test";
import { Effect, Option } from "effect";
-import { http, HttpResponse } from "msw";
+import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import { _clearProjectCache } from "@/resolve";
@@ -62,6 +62,10 @@ const server = setupServer(
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/comments/`,
() => HttpResponse.json({ results: COMMENTS }),
),
+ http.get(
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/worklogs/`,
+ () => new HttpResponse('{"error":"Page not found."}', { status: 404 }),
+ ),
http.get(
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/worklogs/`,
() => HttpResponse.json({ results: WORKLOGS }),
@@ -73,16 +77,16 @@ afterAll(() => server.close());
beforeEach(() => {
_clearProjectCache();
- process.env["PLANE_HOST"] = BASE;
- process.env["PLANE_WORKSPACE"] = WS;
- process.env["PLANE_API_TOKEN"] = "test-token";
+ process.env.PLANE_HOST = BASE;
+ process.env.PLANE_WORKSPACE = WS;
+ process.env.PLANE_API_TOKEN = "test-token";
});
afterEach(() => {
server.resetHandlers();
- delete process.env["PLANE_HOST"];
- delete process.env["PLANE_WORKSPACE"];
- delete process.env["PLANE_API_TOKEN"];
+ delete process.env.PLANE_HOST;
+ delete process.env.PLANE_WORKSPACE;
+ delete process.env.PLANE_API_TOKEN;
});
describe("issueCommentsList", () => {
@@ -241,6 +245,10 @@ describe("issueWorklogsList", () => {
it("shows 'No worklogs' when empty", async () => {
server.use(
+ http.get(
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/worklogs/`,
+ () => new HttpResponse('{"error":"Page not found."}', { status: 404 }),
+ ),
http.get(
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/worklogs/`,
() => HttpResponse.json({ results: [] }),
@@ -262,6 +270,10 @@ describe("issueWorklogsList", () => {
describe("issueWorklogsAdd", () => {
it("logs time without description", async () => {
server.use(
+ http.post(
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/worklogs/`,
+ () => new HttpResponse('{"error":"Page not found."}', { status: 404 }),
+ ),
http.post(
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/worklogs/`,
async ({ request }) => {
@@ -295,6 +307,10 @@ describe("issueWorklogsAdd", () => {
it("logs time with description", async () => {
server.use(
+ http.post(
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/worklogs/`,
+ () => new HttpResponse('{"error":"Page not found."}', { status: 404 }),
+ ),
http.post(
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/worklogs/`,
async ({ request }) => {
@@ -331,6 +347,10 @@ describe("issueWorklogsAdd", () => {
it("handles missing logged_by_detail in worklogs list", async () => {
server.use(
+ http.get(
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/worklogs/`,
+ () => new HttpResponse('{"error":"Page not found."}', { status: 404 }),
+ ),
http.get(
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/worklogs/`,
() =>
diff --git a/tests/issue-links.test.ts b/tests/issue-links.test.ts
index 0a81e00..8e37fb4 100644
--- a/tests/issue-links.test.ts
+++ b/tests/issue-links.test.ts
@@ -8,7 +8,7 @@ import {
it,
} from "bun:test";
import { Effect, Option } from "effect";
-import { http, HttpResponse } from "msw";
+import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import { _clearProjectCache } from "@/resolve";
@@ -50,7 +50,7 @@ const server = setupServer(
HttpResponse.json({ results: ISSUES }),
),
http.get(
- `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/issue-links/`,
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/links/`,
() => HttpResponse.json({ results: LINKS }),
),
);
@@ -60,16 +60,16 @@ afterAll(() => server.close());
beforeEach(() => {
_clearProjectCache();
- process.env["PLANE_HOST"] = BASE;
- process.env["PLANE_WORKSPACE"] = WS;
- process.env["PLANE_API_TOKEN"] = "test-token";
+ process.env.PLANE_HOST = BASE;
+ process.env.PLANE_WORKSPACE = WS;
+ process.env.PLANE_API_TOKEN = "test-token";
});
afterEach(() => {
server.resetHandlers();
- delete process.env["PLANE_HOST"];
- delete process.env["PLANE_WORKSPACE"];
- delete process.env["PLANE_API_TOKEN"];
+ delete process.env.PLANE_HOST;
+ delete process.env.PLANE_WORKSPACE;
+ delete process.env.PLANE_API_TOKEN;
});
describe("issueLinkList", () => {
@@ -109,7 +109,7 @@ describe("issueLinkList", () => {
it("shows 'No links' when empty", async () => {
server.use(
http.get(
- `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/issue-links/`,
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/links/`,
() => HttpResponse.json({ results: [] }),
),
);
@@ -133,7 +133,7 @@ describe("issueLinkAdd", () => {
it("adds a link without title", async () => {
server.use(
http.post(
- `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/issue-links/`,
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/links/`,
async ({ request }) => {
const body = (await request.json()) as { url?: string };
return HttpResponse.json({
@@ -170,7 +170,7 @@ describe("issueLinkAdd", () => {
it("adds a link with title", async () => {
server.use(
http.post(
- `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/issue-links/`,
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/links/`,
async ({ request }) => {
const body = (await request.json()) as {
url?: string;
@@ -212,7 +212,7 @@ describe("issueLinkRemove", () => {
let deleted = false;
server.use(
http.delete(
- `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/issue-links/lnk1/`,
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/links/lnk1/`,
() => {
deleted = true;
return new HttpResponse(null, { status: 204 });
diff --git a/tests/json-output.test.ts b/tests/json-output.test.ts
index 6c30604..bc08630 100644
--- a/tests/json-output.test.ts
+++ b/tests/json-output.test.ts
@@ -10,13 +10,13 @@ import {
describe,
expect,
it,
+ mock,
} from "bun:test";
-import { Effect } from "effect";
-import { http, HttpResponse } from "msw";
+import { Effect, Option } from "effect";
+import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
-import { mock } from "bun:test";
-import { _clearProjectCache } from "@/resolve";
import { toXml } from "@/output";
+import { _clearProjectCache } from "@/resolve";
// Set jsonMode=true for this entire test file before command modules load
mock.module("@/output", () => ({
@@ -29,6 +29,16 @@ const BASE = "http://json-output-test.local";
const WS = "testws";
const PROJECTS = [{ id: "proj-acme", identifier: "ACME", name: "Acme" }];
+const PROJECT_DETAIL = {
+ id: "proj-acme",
+ identifier: "ACME",
+ name: "Acme",
+ module_view: true,
+ cycle_view: true,
+ issue_views_view: true,
+ page_view: true,
+ inbox_view: true,
+};
const ISSUES = [
{
id: "i1",
@@ -49,17 +59,17 @@ const CYCLES = [
];
const CYCLE_ISSUES = [
{
- id: "ci1",
- issue: "i1",
- issue_detail: { id: "i1", sequence_id: 1, name: "Issue One" },
+ id: "i1",
+ sequence_id: 1,
+ name: "Issue One",
},
];
const MODULES = [{ id: "mod1", name: "Module Alpha", status: "in-progress" }];
const MODULE_ISSUES = [
{
- id: "mi1",
- issue: "i1",
- issue_detail: { id: "i1", sequence_id: 1, name: "Issue One" },
+ id: "i1",
+ sequence_id: 1,
+ name: "Issue One",
},
];
const INTAKE_ISSUES = [
@@ -124,6 +134,9 @@ const server = setupServer(
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
HttpResponse.json({ results: PROJECTS }),
),
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/`, () =>
+ HttpResponse.json(PROJECT_DETAIL),
+ ),
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, () =>
HttpResponse.json({ results: ISSUES }),
),
@@ -153,13 +166,17 @@ const server = setupServer(
() => HttpResponse.json({ results: ACTIVITIES }),
),
http.get(
- `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/issue-links/`,
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/links/`,
() => HttpResponse.json({ results: LINKS }),
),
http.get(
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/comments/`,
() => HttpResponse.json({ results: COMMENTS }),
),
+ http.get(
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/worklogs/`,
+ () => new HttpResponse('{"error":"Page not found."}', { status: 404 }),
+ ),
http.get(
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/worklogs/`,
() => HttpResponse.json({ results: WORKLOGS }),
@@ -182,16 +199,16 @@ afterAll(() => {
beforeEach(() => {
_clearProjectCache();
- process.env["PLANE_HOST"] = BASE;
- process.env["PLANE_WORKSPACE"] = WS;
- process.env["PLANE_API_TOKEN"] = "test-token";
+ process.env.PLANE_HOST = BASE;
+ process.env.PLANE_WORKSPACE = WS;
+ process.env.PLANE_API_TOKEN = "test-token";
});
afterEach(() => {
server.resetHandlers();
- delete process.env["PLANE_HOST"];
- delete process.env["PLANE_WORKSPACE"];
- delete process.env["PLANE_API_TOKEN"];
+ delete process.env.PLANE_HOST;
+ delete process.env.PLANE_WORKSPACE;
+ delete process.env.PLANE_API_TOKEN;
});
async function captureLogs(fn: () => Promise): Promise {
@@ -208,9 +225,9 @@ async function captureLogs(fn: () => Promise): Promise {
describe("cyclesList --json", () => {
it("outputs JSON array of cycles", async () => {
- const { cyclesList } = await import("@/commands/cycles");
+ const { cyclesListHandler } = await import("@/commands/cycles");
const output = await captureLogs(() =>
- Effect.runPromise((cyclesList as any).handler({ project: "ACME" })),
+ Effect.runPromise(cyclesListHandler({ project: "ACME" })),
);
const parsed = JSON.parse(output);
expect(Array.isArray(parsed)).toBe(true);
@@ -220,23 +237,23 @@ describe("cyclesList --json", () => {
describe("cycleIssuesList --json", () => {
it("outputs JSON array of cycle issues", async () => {
- const { cycleIssuesList } = await import("@/commands/cycles");
+ const { cycleIssuesListHandler } = await import("@/commands/cycles");
const output = await captureLogs(() =>
Effect.runPromise(
- (cycleIssuesList as any).handler({ project: "ACME", cycleId: "cyc1" }),
+ cycleIssuesListHandler({ project: "ACME", cycleId: "cyc1" }),
),
);
const parsed = JSON.parse(output);
expect(Array.isArray(parsed)).toBe(true);
- expect(parsed[0].id).toBe("ci1");
+ expect(parsed[0].id).toBe("i1");
});
});
describe("modulesList --json", () => {
it("outputs JSON array of modules", async () => {
- const { modulesList } = await import("@/commands/modules");
+ const { modulesListHandler } = await import("@/commands/modules");
const output = await captureLogs(() =>
- Effect.runPromise((modulesList as any).handler({ project: "ACME" })),
+ Effect.runPromise(modulesListHandler({ project: "ACME" })),
);
const parsed = JSON.parse(output);
expect(Array.isArray(parsed)).toBe(true);
@@ -246,10 +263,10 @@ describe("modulesList --json", () => {
describe("moduleIssuesList --json", () => {
it("outputs JSON array of module issues", async () => {
- const { moduleIssuesList } = await import("@/commands/modules");
+ const { moduleIssuesListHandler } = await import("@/commands/modules");
const output = await captureLogs(() =>
Effect.runPromise(
- (moduleIssuesList as any).handler({
+ moduleIssuesListHandler({
project: "ACME",
moduleId: "mod1",
}),
@@ -257,15 +274,16 @@ describe("moduleIssuesList --json", () => {
);
const parsed = JSON.parse(output);
expect(Array.isArray(parsed)).toBe(true);
- expect(parsed[0].id).toBe("mi1");
+ expect(parsed[0].id).toBe("i1");
+ expect(parsed[0].sequence_id).toBe(1);
});
});
describe("intakeList --json", () => {
it("outputs JSON array of intake issues", async () => {
- const { intakeList } = await import("@/commands/intake");
+ const { intakeListHandler } = await import("@/commands/intake");
const output = await captureLogs(() =>
- Effect.runPromise((intakeList as any).handler({ project: "ACME" })),
+ Effect.runPromise(intakeListHandler({ project: "ACME" })),
);
const parsed = JSON.parse(output);
expect(Array.isArray(parsed)).toBe(true);
@@ -275,9 +293,9 @@ describe("intakeList --json", () => {
describe("pagesList --json", () => {
it("outputs JSON array of pages", async () => {
- const { pagesList } = await import("@/commands/pages");
+ const { pagesListHandler } = await import("@/commands/pages");
const output = await captureLogs(() =>
- Effect.runPromise((pagesList as any).handler({ project: "ACME" })),
+ Effect.runPromise(pagesListHandler({ project: "ACME" })),
);
const parsed = JSON.parse(output);
expect(Array.isArray(parsed)).toBe(true);
@@ -287,9 +305,9 @@ describe("pagesList --json", () => {
describe("issueActivity --json", () => {
it("outputs JSON array of activities", async () => {
- const { issueActivity } = await import("@/commands/issue");
+ const { issueActivityHandler } = await import("@/commands/issue");
const output = await captureLogs(() =>
- Effect.runPromise((issueActivity as any).handler({ ref: "ACME-1" })),
+ Effect.runPromise(issueActivityHandler({ ref: "ACME-1" })),
);
const parsed = JSON.parse(output);
expect(Array.isArray(parsed)).toBe(true);
@@ -299,9 +317,9 @@ describe("issueActivity --json", () => {
describe("issueLinkList --json", () => {
it("outputs JSON array of links", async () => {
- const { issueLinkList } = await import("@/commands/issue");
+ const { issueLinkListHandler } = await import("@/commands/issue");
const output = await captureLogs(() =>
- Effect.runPromise((issueLinkList as any).handler({ ref: "ACME-1" })),
+ Effect.runPromise(issueLinkListHandler({ ref: "ACME-1" })),
);
const parsed = JSON.parse(output);
expect(Array.isArray(parsed)).toBe(true);
@@ -311,9 +329,9 @@ describe("issueLinkList --json", () => {
describe("issueCommentsList --json", () => {
it("outputs JSON array of comments", async () => {
- const { issueCommentsList } = await import("@/commands/issue");
+ const { issueCommentsListHandler } = await import("@/commands/issue");
const output = await captureLogs(() =>
- Effect.runPromise((issueCommentsList as any).handler({ ref: "ACME-1" })),
+ Effect.runPromise(issueCommentsListHandler({ ref: "ACME-1" })),
);
const parsed = JSON.parse(output);
expect(Array.isArray(parsed)).toBe(true);
@@ -323,9 +341,9 @@ describe("issueCommentsList --json", () => {
describe("issueWorklogsList --json", () => {
it("outputs JSON array of worklogs", async () => {
- const { issueWorklogsList } = await import("@/commands/issue");
+ const { issueWorklogsListHandler } = await import("@/commands/issue");
const output = await captureLogs(() =>
- Effect.runPromise((issueWorklogsList as any).handler({ ref: "ACME-1" })),
+ Effect.runPromise(issueWorklogsListHandler({ ref: "ACME-1" })),
);
const parsed = JSON.parse(output);
expect(Array.isArray(parsed)).toBe(true);
@@ -335,14 +353,14 @@ describe("issueWorklogsList --json", () => {
describe("issuesList --json", () => {
it("outputs JSON array of issues", async () => {
- const { issuesList } = await import("@/commands/issues");
+ const { issuesListHandler } = await import("@/commands/issues");
const output = await captureLogs(() =>
Effect.runPromise(
- (issuesList as any).handler({
+ issuesListHandler({
project: "ACME",
- state: { _tag: "None" },
- assignee: { _tag: "None" },
- priority: { _tag: "None" },
+ state: Option.none(),
+ assignee: Option.none(),
+ priority: Option.none(),
}),
),
);
diff --git a/tests/labels.test.ts b/tests/labels.test.ts
new file mode 100644
index 0000000..9ce6b1a
--- /dev/null
+++ b/tests/labels.test.ts
@@ -0,0 +1,196 @@
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+} from "bun:test";
+import { Effect, Option } from "effect";
+import { HttpResponse, http } from "msw";
+import { setupServer } from "msw/node";
+import { _clearProjectCache } from "@/resolve";
+
+const BASE = "http://labels-test.local";
+const WS = "testws";
+
+const PROJECTS = [
+ { id: "proj-acme", identifier: "ACME", name: "Acme Project" },
+];
+const LABELS = [
+ { id: "l-bug", name: "bug", color: "#ff0000" },
+ { id: "l-ready", name: "ready", color: "#00ff00" },
+];
+
+const server = setupServer(
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
+ HttpResponse.json({ results: PROJECTS }),
+ ),
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/labels/`, () =>
+ HttpResponse.json({ results: LABELS }),
+ ),
+);
+
+beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
+afterAll(() => server.close());
+
+beforeEach(() => {
+ _clearProjectCache();
+ process.env.PLANE_HOST = BASE;
+ process.env.PLANE_WORKSPACE = WS;
+ process.env.PLANE_API_TOKEN = "test-token";
+});
+
+afterEach(() => {
+ server.resetHandlers();
+ delete process.env.PLANE_HOST;
+ delete process.env.PLANE_WORKSPACE;
+ delete process.env.PLANE_API_TOKEN;
+ delete process.env.PLANE_PROJECT;
+});
+
+describe("labelsDelete", () => {
+ it("deletes a label by exact name", async () => {
+ let deleted = false;
+ server.use(
+ http.delete(
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/labels/l-bug/`,
+ () => {
+ deleted = true;
+ return new HttpResponse(null, { status: 204 });
+ },
+ ),
+ );
+
+ const { labelsDeleteHandler } = await import("@/commands/labels");
+ const logs: string[] = [];
+ const orig = console.log;
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
+
+ try {
+ await Effect.runPromise(
+ labelsDeleteHandler({ project: "ACME", label: "bug" }),
+ );
+ } finally {
+ console.log = orig;
+ }
+
+ expect(deleted).toBe(true);
+ expect(logs.join("\n")).toContain("Deleted label: bug (l-bug)");
+ });
+
+ it("deletes a label by UUID", async () => {
+ let deleted = false;
+ server.use(
+ http.delete(
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/labels/l-ready/`,
+ () => {
+ deleted = true;
+ return new HttpResponse(null, { status: 204 });
+ },
+ ),
+ );
+
+ const { labelsDeleteHandler } = await import("@/commands/labels");
+ const logs: string[] = [];
+ const orig = console.log;
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
+
+ try {
+ await Effect.runPromise(
+ labelsDeleteHandler({ project: "ACME", label: "l-ready" }),
+ );
+ } finally {
+ console.log = orig;
+ }
+
+ expect(deleted).toBe(true);
+ expect(logs.join("\n")).toContain("Deleted label: ready (l-ready)");
+ });
+});
+
+describe("labelsList", () => {
+ it("lists labels for a project", async () => {
+ const { labelsListHandler } = await import("@/commands/labels");
+ const logs: string[] = [];
+ const orig = console.log;
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
+
+ try {
+ await Effect.runPromise(labelsListHandler({ project: "ACME" }));
+ } finally {
+ console.log = orig;
+ }
+
+ const output = logs.join("\n");
+ expect(output).toContain("l-bug");
+ expect(output).toContain("bug");
+ expect(output).toContain("l-ready");
+ });
+
+ it("shows 'No labels found' when empty", async () => {
+ server.use(
+ http.get(
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/labels/`,
+ () => HttpResponse.json({ results: [] }),
+ ),
+ );
+
+ const { labelsListHandler } = await import("@/commands/labels");
+ const logs: string[] = [];
+ const orig = console.log;
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
+
+ try {
+ await Effect.runPromise(labelsListHandler({ project: "ACME" }));
+ } finally {
+ console.log = orig;
+ }
+
+ expect(logs.join("\n")).toBe("No labels found");
+ });
+});
+
+describe("labelsCreate", () => {
+ it("creates a label with color", async () => {
+ let postedBody: unknown;
+ server.use(
+ http.post(
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/labels/`,
+ async ({ request }) => {
+ postedBody = await request.json();
+ return HttpResponse.json(
+ { id: "l-new", name: "critical", color: "#ff0000" },
+ { status: 201 },
+ );
+ },
+ ),
+ );
+
+ const { labelsCreateHandler } = await import("@/commands/labels");
+ const logs: string[] = [];
+ const orig = console.log;
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
+
+ try {
+ await Effect.runPromise(
+ labelsCreateHandler({
+ project: "ACME",
+ name: "critical",
+ color: Option.some("#ff0000"),
+ }),
+ );
+ } finally {
+ console.log = orig;
+ }
+
+ expect((postedBody as { name?: string; color?: string }).name).toBe(
+ "critical",
+ );
+ expect((postedBody as { name?: string; color?: string }).color).toBe(
+ "#ff0000",
+ );
+ expect(logs.join("\n")).toContain("Created label: critical (l-new)");
+ });
+});
diff --git a/tests/modules.test.ts b/tests/modules.test.ts
index 0094fc6..5170c5a 100644
--- a/tests/modules.test.ts
+++ b/tests/modules.test.ts
@@ -8,7 +8,7 @@ import {
it,
} from "bun:test";
import { Effect } from "effect";
-import { http, HttpResponse } from "msw";
+import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import { _clearProjectCache } from "@/resolve";
@@ -18,6 +18,16 @@ const WS = "testws";
const PROJECTS = [
{ id: "proj-acme", identifier: "ACME", name: "Acme Project" },
];
+const PROJECT_DETAIL = {
+ id: "proj-acme",
+ identifier: "ACME",
+ name: "Acme Project",
+ module_view: true,
+ cycle_view: true,
+ issue_views_view: true,
+ page_view: true,
+ inbox_view: true,
+};
const ISSUES = [
{
id: "i1",
@@ -33,9 +43,9 @@ const MODULES = [
];
const MODULE_ISSUES = [
{
- id: "mi1",
- issue: "i1",
- issue_detail: { id: "i1", sequence_id: 29, name: "Migrate Button" },
+ id: "i1",
+ sequence_id: 29,
+ name: "Migrate Button",
},
];
@@ -43,6 +53,9 @@ const server = setupServer(
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
HttpResponse.json({ results: PROJECTS }),
),
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/`, () =>
+ HttpResponse.json(PROJECT_DETAIL),
+ ),
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, () =>
HttpResponse.json({ results: ISSUES }),
),
@@ -60,16 +73,16 @@ afterAll(() => server.close());
beforeEach(() => {
_clearProjectCache();
- process.env["PLANE_HOST"] = BASE;
- process.env["PLANE_WORKSPACE"] = WS;
- process.env["PLANE_API_TOKEN"] = "test-token";
+ process.env.PLANE_HOST = BASE;
+ process.env.PLANE_WORKSPACE = WS;
+ process.env.PLANE_API_TOKEN = "test-token";
});
afterEach(() => {
server.resetHandlers();
- delete process.env["PLANE_HOST"];
- delete process.env["PLANE_WORKSPACE"];
- delete process.env["PLANE_API_TOKEN"];
+ delete process.env.PLANE_HOST;
+ delete process.env.PLANE_WORKSPACE;
+ delete process.env.PLANE_API_TOKEN;
});
describe("modulesList", () => {
@@ -141,6 +154,72 @@ describe("modulesList", () => {
});
});
+describe("modulesDelete", () => {
+ it("deletes a module by UUID", async () => {
+ let deleted = false;
+ server.use(
+ http.delete(
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/mod1/`,
+ () => {
+ deleted = true;
+ return new HttpResponse(null, { status: 204 });
+ },
+ ),
+ );
+
+ const { modulesDeleteHandler } = await import("@/commands/modules");
+ const logs: string[] = [];
+ const orig = console.log;
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
+
+ try {
+ await Effect.runPromise(
+ modulesDeleteHandler({
+ project: "ACME",
+ module: "mod1",
+ }),
+ );
+ } finally {
+ console.log = orig;
+ }
+
+ expect(deleted).toBe(true);
+ expect(logs.join("\n")).toContain("Deleted module: Sprint 1 (mod1)");
+ });
+
+ it("deletes a module by exact name", async () => {
+ let deleted = false;
+ server.use(
+ http.delete(
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/mod2/`,
+ () => {
+ deleted = true;
+ return new HttpResponse(null, { status: 204 });
+ },
+ ),
+ );
+
+ const { modulesDeleteHandler } = await import("@/commands/modules");
+ const logs: string[] = [];
+ const orig = console.log;
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
+
+ try {
+ await Effect.runPromise(
+ modulesDeleteHandler({
+ project: "ACME",
+ module: "Sprint 2",
+ }),
+ );
+ } finally {
+ console.log = orig;
+ }
+
+ expect(deleted).toBe(true);
+ expect(logs.join("\n")).toContain("Deleted module: Sprint 2 (mod2)");
+ });
+});
+
describe("moduleIssuesList", () => {
it("lists issues in a module with detail", async () => {
const { moduleIssuesListHandler } = await import("@/commands/modules");
@@ -165,6 +244,36 @@ describe("moduleIssuesList", () => {
expect(output).toContain("Migrate Button");
});
+ it("lists raw issue payloads returned by newer Plane APIs", async () => {
+ server.use(
+ http.get(
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/mod1/module-issues/`,
+ () =>
+ HttpResponse.json({
+ results: [{ id: "i1", sequence_id: 29, name: "Migrate Button" }],
+ }),
+ ),
+ );
+
+ const { moduleIssuesListHandler } = await import("@/commands/modules");
+ const logs: string[] = [];
+ const orig = console.log;
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
+
+ try {
+ await Effect.runPromise(
+ moduleIssuesListHandler({
+ project: "ACME",
+ moduleId: "mod1",
+ }),
+ );
+ } finally {
+ console.log = orig;
+ }
+
+ expect(logs.join("\n")).toContain("ACME- 29 Migrate Button");
+ });
+
it("falls back to issue UUID when no issue_detail", async () => {
server.use(
http.get(
diff --git a/tests/new-schemas.test.ts b/tests/new-schemas.test.ts
index 7e51f4f..b6d42df 100644
--- a/tests/new-schemas.test.ts
+++ b/tests/new-schemas.test.ts
@@ -1,14 +1,14 @@
import { describe, expect, it } from "bun:test";
import { Effect, Schema } from "effect";
import {
- ActivitySchema,
ActivitiesResponseSchema,
+ ActivitySchema,
IssueLinkSchema,
IssueLinksResponseSchema,
- ModuleSchema,
- ModulesResponseSchema,
ModuleIssueSchema,
ModuleIssuesResponseSchema,
+ ModuleSchema,
+ ModulesResponseSchema,
} from "@/config";
async function decode(
@@ -194,7 +194,19 @@ describe("ModuleIssueSchema", () => {
},
});
expect(mi.id).toBe("mi1");
- expect(mi.issue_detail?.sequence_id).toBe(29);
+ expect(
+ "issue_detail" in mi ? mi.issue_detail?.sequence_id : undefined,
+ ).toBe(29);
+ });
+
+ it("decodes a raw issue payload", async () => {
+ const mi = await decode(ModuleIssueSchema, {
+ id: "issue-uuid",
+ sequence_id: 29,
+ name: "Migrate Button",
+ });
+ expect(mi.id).toBe("issue-uuid");
+ expect("sequence_id" in mi ? mi.sequence_id : undefined).toBe(29);
});
it("decodes without issue_detail", async () => {
@@ -202,11 +214,11 @@ describe("ModuleIssueSchema", () => {
id: "mi1",
issue: "issue-uuid",
});
- expect(mi.issue).toBe("issue-uuid");
- expect(mi.issue_detail).toBeUndefined();
+ expect("issue" in mi ? mi.issue : undefined).toBe("issue-uuid");
+ expect("issue_detail" in mi ? mi.issue_detail : undefined).toBeUndefined();
});
- it("rejects missing issue", async () => {
+ it("rejects payloads missing both issue relation and raw issue fields", async () => {
await expect(decode(ModuleIssueSchema, { id: "mi1" })).rejects.toThrow();
});
});
@@ -214,7 +226,7 @@ describe("ModuleIssueSchema", () => {
describe("ModuleIssuesResponseSchema", () => {
it("decodes results", async () => {
const resp = await decode(ModuleIssuesResponseSchema, {
- results: [{ id: "mi1", issue: "uuid1" }],
+ results: [{ id: "issue-uuid", sequence_id: 29, name: "Migrate Button" }],
});
expect(resp.results).toHaveLength(1);
});
diff --git a/tests/new-schemas2.test.ts b/tests/new-schemas2.test.ts
index 3ad4d1c..1c9d280 100644
--- a/tests/new-schemas2.test.ts
+++ b/tests/new-schemas2.test.ts
@@ -1,16 +1,16 @@
import { describe, expect, it } from "bun:test";
import { Effect, Schema } from "effect";
import {
- WorklogSchema,
- WorklogsResponseSchema,
- IntakeIssueSchema,
- IntakeIssuesResponseSchema,
- PageSchema,
- PagesResponseSchema,
CommentSchema,
CommentsResponseSchema,
CycleIssueSchema,
CycleIssuesResponseSchema,
+ IntakeIssueSchema,
+ IntakeIssuesResponseSchema,
+ PageSchema,
+ PagesResponseSchema,
+ WorklogSchema,
+ WorklogsResponseSchema,
} from "@/config";
async function decode(
@@ -194,14 +194,33 @@ describe("CycleIssueSchema", () => {
issue: "i1",
issue_detail: { id: "i1", sequence_id: 5, name: "Fix bug" },
});
+ if (!("issue" in ci)) {
+ throw new Error("Expected legacy cycle-issue payload");
+ }
expect(ci.issue_detail?.sequence_id).toBe(5);
});
it("decodes without detail", async () => {
const ci = await decode(CycleIssueSchema, { id: "ci2", issue: "i2" });
+ if (!("issue" in ci)) {
+ throw new Error("Expected legacy cycle-issue payload");
+ }
expect(ci.issue).toBe("i2");
});
+ it("decodes raw issue payloads", async () => {
+ const ci = await decode(CycleIssueSchema, {
+ id: "i3",
+ sequence_id: 9,
+ name: "Ship campaign",
+ });
+ if (!("sequence_id" in ci)) {
+ throw new Error("Expected raw issue payload");
+ }
+ expect(ci.sequence_id).toBe(9);
+ expect(ci.name).toBe("Ship campaign");
+ });
+
it("rejects missing issue", async () => {
await expect(decode(CycleIssueSchema, { id: "ci3" })).rejects.toThrow();
});
diff --git a/tests/output.test.ts b/tests/output.test.ts
index d7906be..ff61cb9 100644
--- a/tests/output.test.ts
+++ b/tests/output.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it, beforeEach, afterEach } from "bun:test";
+import { describe, expect, it } from "bun:test";
import { toXml } from "@/output";
describe("toXml", () => {
diff --git a/tests/pages.test.ts b/tests/pages.test.ts
index 898422d..99a0622 100644
--- a/tests/pages.test.ts
+++ b/tests/pages.test.ts
@@ -8,7 +8,7 @@ import {
it,
} from "bun:test";
import { Effect, Option } from "effect";
-import { http, HttpResponse } from "msw";
+import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import { _clearProjectCache } from "@/resolve";
@@ -18,6 +18,16 @@ const WS = "testws";
const PROJECTS = [
{ id: "proj-acme", identifier: "ACME", name: "Acme Project" },
];
+const PROJECT_DETAIL = {
+ id: "proj-acme",
+ identifier: "ACME",
+ name: "Acme Project",
+ module_view: true,
+ cycle_view: true,
+ issue_views_view: true,
+ page_view: true,
+ inbox_view: true,
+};
const PAGES = [
{
id: "pg1",
@@ -46,6 +56,9 @@ const server = setupServer(
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
HttpResponse.json({ results: PROJECTS }),
),
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/`, () =>
+ HttpResponse.json(PROJECT_DETAIL),
+ ),
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/`, () =>
HttpResponse.json({ results: PAGES }),
),
@@ -60,16 +73,16 @@ afterAll(() => server.close());
beforeEach(() => {
_clearProjectCache();
- process.env["PLANE_HOST"] = BASE;
- process.env["PLANE_WORKSPACE"] = WS;
- process.env["PLANE_API_TOKEN"] = "test-token";
+ process.env.PLANE_HOST = BASE;
+ process.env.PLANE_WORKSPACE = WS;
+ process.env.PLANE_API_TOKEN = "test-token";
});
afterEach(() => {
server.resetHandlers();
- delete process.env["PLANE_HOST"];
- delete process.env["PLANE_WORKSPACE"];
- delete process.env["PLANE_API_TOKEN"];
+ delete process.env.PLANE_HOST;
+ delete process.env.PLANE_WORKSPACE;
+ delete process.env.PLANE_API_TOKEN;
});
describe("pagesList", () => {
@@ -123,6 +136,21 @@ describe("pagesList", () => {
}
expect(logs.join("\n")).toBe("No pages");
});
+
+ it("returns a definitive error when the page API is unavailable", async () => {
+ server.use(
+ http.get(
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/`,
+ () => new HttpResponse('{"error":"Page not found."}', { status: 404 }),
+ ),
+ );
+ const { pagesListHandler } = await import("@/commands/pages");
+ await expect(
+ Effect.runPromise(pagesListHandler({ project: "ACME" })),
+ ).rejects.toThrow(
+ "Project pages are not available for ACME on this Plane instance or API version.",
+ );
+ });
});
describe("pagesGet", () => {
@@ -364,7 +392,12 @@ describe("pagesDuplicate", () => {
server.use(
http.post(
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/pg1/duplicate/`,
- () => HttpResponse.json({ ...NEW_PAGE, id: "pg-dup", name: "New Page (copy)" }),
+ () =>
+ HttpResponse.json({
+ ...NEW_PAGE,
+ id: "pg-dup",
+ name: "New Page (copy)",
+ }),
),
);
const { pagesDuplicateHandler } = await import("@/commands/pages");
diff --git a/tests/project-features.test.ts b/tests/project-features.test.ts
new file mode 100644
index 0000000..c11c1fc
--- /dev/null
+++ b/tests/project-features.test.ts
@@ -0,0 +1,281 @@
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ mock,
+} from "bun:test";
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { Effect } from "effect";
+import { HttpResponse, http } from "msw";
+import { setupServer } from "msw/node";
+import { _clearProjectCache } from "@/resolve";
+
+const BASE = "http://feature-gates-test.local";
+const WS = "testws";
+const ORIGINAL_HOME = process.env.HOME;
+const ORIGINAL_CWD = process.cwd();
+
+const PROJECTS = [
+ { id: "proj-acme", identifier: "ACME", name: "Acme Project" },
+];
+
+const PROJECT_DETAIL = {
+ id: "proj-acme",
+ identifier: "ACME",
+ name: "Acme Project",
+ module_view: true,
+ cycle_view: false,
+ issue_views_view: true,
+ page_view: true,
+ intake_view: true,
+ estimate: "est1",
+};
+
+const STATES = [
+ { id: "st-backlog", name: "Backlog", group: "backlog", color: "#888888" },
+ {
+ id: "st-progress",
+ name: "In Progress",
+ group: "started",
+ color: "#ffaa00",
+ },
+];
+
+const LABELS = [
+ { id: "lbl-ready", name: "Ready to Deploy", color: "#00aa88", parent: null },
+ { id: "lbl-backend", name: "Backend", color: "#00bb66", parent: null },
+];
+
+const ESTIMATE = {
+ id: "est1",
+ name: "Story Points",
+ description: "Default scale",
+ type: "points",
+ last_used: true,
+ project: "proj-acme",
+ workspace: "ws1",
+};
+
+const ESTIMATE_POINTS = [
+ {
+ id: "ep1",
+ estimate: "est1",
+ key: 1,
+ value: "1",
+ description: "Tiny",
+ project: "proj-acme",
+ workspace: "ws1",
+ },
+ {
+ id: "ep2",
+ estimate: "est1",
+ key: 2,
+ value: "2",
+ description: "Small",
+ project: "proj-acme",
+ workspace: "ws1",
+ },
+];
+
+const server = setupServer(
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
+ HttpResponse.json({ results: PROJECTS }),
+ ),
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/`, () =>
+ HttpResponse.json(PROJECT_DETAIL),
+ ),
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/states/`, () =>
+ HttpResponse.json({ results: STATES }),
+ ),
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/labels/`, () =>
+ HttpResponse.json({ results: LABELS }),
+ ),
+ http.get(
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/estimates/`,
+ () => HttpResponse.json(ESTIMATE),
+ ),
+ http.get(
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/estimates/est1/estimate-points/`,
+ () => HttpResponse.json(ESTIMATE_POINTS),
+ ),
+);
+
+beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
+afterAll(() => server.close());
+
+let tempHome = "";
+let promptResponses: string[] = [];
+
+mock.module("node:readline", () => ({
+ createInterface: () => ({
+ question: (_question: string, callback: (answer: string) => void) => {
+ callback(promptResponses.shift() ?? "");
+ },
+ close: () => undefined,
+ }),
+}));
+
+beforeEach(() => {
+ _clearProjectCache();
+ tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "plane-cli-features-"));
+ process.env.HOME = tempHome;
+ process.chdir(tempHome);
+ process.env.PLANE_HOST = BASE;
+ process.env.PLANE_WORKSPACE = WS;
+ process.env.PLANE_API_TOKEN = "test-token";
+ delete process.env.PLANE_PROJECT;
+ promptResponses = [];
+});
+
+afterEach(() => {
+ server.resetHandlers();
+ delete process.env.PLANE_HOST;
+ delete process.env.PLANE_WORKSPACE;
+ delete process.env.PLANE_API_TOKEN;
+ delete process.env.PLANE_PROJECT;
+ if (ORIGINAL_HOME === undefined) {
+ delete process.env.HOME;
+ } else {
+ process.env.HOME = ORIGINAL_HOME;
+ }
+ process.chdir(ORIGINAL_CWD);
+ fs.rmSync(tempHome, { force: true, recursive: true });
+});
+
+describe("feature gates", () => {
+ it("fails with a definitive error when cycles are disabled", async () => {
+ const { cyclesListHandler } = await import("@/commands/cycles");
+ const result = await Effect.runPromise(
+ Effect.either(cyclesListHandler({ project: "ACME" })),
+ );
+ expect(result._tag).toBe("Left");
+ if (result._tag === "Left") {
+ expect(result.left.message).toContain("Project ACME has Cycles disabled");
+ expect(result.left.message).toContain("cycle_view=false");
+ expect(result.left.message).toContain("Enable Cycles");
+ }
+ });
+
+ it("reports project feature flags during local init", async () => {
+ const { initHandler } = await import("@/commands/init");
+ const { getLocalAgentsFilePath } = await import("@/project-agents");
+ const { getLocalProjectContextFilePath } = await import(
+ "@/project-context"
+ );
+ const repoDir = path.join(tempHome, "repo");
+ fs.mkdirSync(repoDir, { recursive: true });
+ process.chdir(repoDir);
+ promptResponses = ["", "", "", "1"];
+ const logs: string[] = [];
+ const orig = console.log;
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
+
+ try {
+ await Effect.runPromise(
+ initHandler({ global: false, local: true }, "local"),
+ );
+ } finally {
+ console.log = orig;
+ }
+
+ const output = logs.join("\n");
+ expect(output).toContain("Project feature flags:");
+ expect(output).toContain("Cycles: disabled");
+ expect(output).toContain("Modules: enabled");
+ expect(output).toContain("Project helper saved to");
+ expect(output).toContain("States: 2");
+ expect(output).toContain("Labels: 2");
+ expect(output).toContain("Estimate: Story Points (2 points)");
+ expect(output).toContain("Local AGENTS.md updated at");
+ expect(output).toContain(
+ "Disabled features will fail with explicit errors until Plane enables them",
+ );
+ expect(fs.existsSync(path.join(repoDir, ".plane", "config.json"))).toBe(
+ true,
+ );
+ const helperPath = getLocalProjectContextFilePath(repoDir);
+ expect(fs.existsSync(helperPath)).toBe(true);
+ const agentsPath = getLocalAgentsFilePath(repoDir);
+ expect(fs.existsSync(agentsPath)).toBe(true);
+ const agentsContent = fs.readFileSync(agentsPath, "utf8");
+ expect(agentsContent).toContain("## Plane Project Context");
+ expect(agentsContent).toContain("Plane project ACME (Acme Project)");
+ expect(agentsContent).toContain("./.plane/project-context.json");
+ expect(agentsContent).toContain(
+ "Prefer the `plane` CLI from this repository root for Plane project work instead of direct API calls.",
+ );
+ expect(agentsContent).toContain("plane issues list @current");
+ expect(agentsContent).toContain("plane issue get ACME-12");
+ const helper = JSON.parse(fs.readFileSync(helperPath, "utf8")) as {
+ features: { estimates: boolean };
+ helpers: {
+ states: { byName: Record };
+ labels: { byName: Record };
+ estimate: {
+ enabled: boolean;
+ pointsByValue: Record;
+ };
+ };
+ };
+ expect(helper.features.estimates).toBe(true);
+ expect(helper.helpers.states.byName.backlog.id).toBe("st-backlog");
+ expect(helper.helpers.labels.byName["ready to deploy"].id).toBe(
+ "lbl-ready",
+ );
+ expect(helper.helpers.estimate.enabled).toBe(true);
+ expect(helper.helpers.estimate.pointsByValue["1"].id).toBe("ep1");
+ });
+
+ it("preserves user AGENTS content and refreshes the managed project section", async () => {
+ const { initHandler } = await import("@/commands/init");
+ const { getLocalAgentsFilePath } = await import("@/project-agents");
+ const repoDir = path.join(tempHome, "repo");
+ fs.mkdirSync(repoDir, { recursive: true });
+ process.chdir(repoDir);
+ const existingAgents = [
+ "# Team Instructions",
+ "",
+ "Keep release notes short.",
+ ].join("\n");
+ fs.writeFileSync(
+ path.join(repoDir, "AGENTS.md"),
+ `${existingAgents}\n`,
+ "utf8",
+ );
+
+ promptResponses = ["", "", "", "1"];
+ await Effect.runPromise(
+ initHandler({ global: false, local: true }, "local"),
+ );
+
+ promptResponses = ["", "", "", ""];
+ await Effect.runPromise(
+ initHandler({ global: false, local: true }, "local"),
+ );
+
+ const agentsPath = getLocalAgentsFilePath(repoDir);
+ const agentsContent = fs.readFileSync(agentsPath, "utf8");
+ expect(agentsContent).toContain(existingAgents);
+ expect(
+ agentsContent
+ .trimEnd()
+ .endsWith(""),
+ ).toBe(true);
+ expect(
+ agentsContent.match(/plane-cli local project context start/g)?.length,
+ ).toBe(1);
+ expect(agentsContent).toContain(
+ "Read `./.plane/project-context.json` before planning or applying Plane project changes.",
+ );
+ expect(agentsContent).toContain(
+ "If the shell may contain inherited `PLANE_*` variables, clear them before relying on `./.plane/config.json`.",
+ );
+ expect(agentsContent).toContain("plane projects current");
+ });
+});
diff --git a/tests/projects.test.ts b/tests/projects.test.ts
new file mode 100644
index 0000000..00ccf4d
--- /dev/null
+++ b/tests/projects.test.ts
@@ -0,0 +1,235 @@
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+} from "bun:test";
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { Command } from "@effect/cli";
+import { NodeContext } from "@effect/platform-node";
+import { Effect, Layer } from "effect";
+import { HttpResponse, http } from "msw";
+import { setupServer } from "msw/node";
+import { _clearProjectCache } from "@/resolve";
+
+const BASE = "http://projects-test.local";
+const WS = "testws";
+const ORIGINAL_HOME = process.env.HOME;
+const ORIGINAL_CWD = process.cwd();
+
+const PROJECTS = [
+ { id: "proj-acme", identifier: "ACME", name: "Acme Project" },
+ { id: "proj-web", identifier: "WEB", name: "Web Project" },
+];
+
+const server = setupServer(
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
+ HttpResponse.json({ results: PROJECTS }),
+ ),
+);
+
+beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
+afterAll(() => server.close());
+
+let tempHome = "";
+
+beforeEach(() => {
+ _clearProjectCache();
+ tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "plane-cli-projects-"));
+ process.env.HOME = tempHome;
+ process.chdir(tempHome);
+ process.env.PLANE_HOST = BASE;
+ process.env.PLANE_WORKSPACE = WS;
+ process.env.PLANE_API_TOKEN = "test-token";
+ delete process.env.PLANE_PROJECT;
+});
+
+afterEach(() => {
+ server.resetHandlers();
+ delete process.env.PLANE_HOST;
+ delete process.env.PLANE_WORKSPACE;
+ delete process.env.PLANE_API_TOKEN;
+ delete process.env.PLANE_PROJECT;
+ if (ORIGINAL_HOME === undefined) {
+ delete process.env.HOME;
+ } else {
+ process.env.HOME = ORIGINAL_HOME;
+ }
+ process.chdir(ORIGINAL_CWD);
+ fs.rmSync(tempHome, { force: true, recursive: true });
+});
+
+describe("projectsUse", () => {
+ it("routes through the root CLI for local project persistence", async () => {
+ const { projects } = await import("@/commands/projects");
+ const { getLocalConfigFilePath } = await import("@/user-config");
+ const repoDir = path.join(tempHome, "repo");
+ fs.mkdirSync(repoDir, { recursive: true });
+ process.chdir(repoDir);
+ const root = Command.make("plane").pipe(
+ Command.withSubcommands([projects]),
+ );
+ const cli = Command.run(root, { name: "plane", version: "0.0.0" });
+
+ await Effect.runPromise(
+ cli(["_", "_", "projects", "use", "--local", "WEB"]).pipe(
+ Effect.provide(Layer.mergeAll(NodeContext.layer)),
+ ),
+ );
+
+ const saved = JSON.parse(
+ fs.readFileSync(getLocalConfigFilePath(repoDir), "utf8"),
+ ) as {
+ defaultProject?: string;
+ };
+ expect(saved.defaultProject).toBe("WEB");
+ });
+
+ it("persists the current project in global config by default", async () => {
+ const { projectsUseHandler } = await import("@/commands/projects");
+ const { getConfigFilePath } = await import("@/user-config");
+ const logs: string[] = [];
+ const orig = console.log;
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
+
+ try {
+ await Effect.runPromise(
+ projectsUseHandler({ project: "ACME", global: false, local: false }),
+ );
+ } finally {
+ console.log = orig;
+ }
+
+ const saved = JSON.parse(fs.readFileSync(getConfigFilePath(), "utf8")) as {
+ defaultProject?: string;
+ };
+ expect(saved.defaultProject).toBe("ACME");
+ expect(logs.join("\n")).toContain("Current project set to ACME (global)");
+ });
+
+ it("persists the current project in local config when a local config is active", async () => {
+ const { projectsUseHandler } = await import("@/commands/projects");
+ const { getLocalConfigFilePath, writeLocalStoredConfig } = await import(
+ "@/user-config"
+ );
+ const repoDir = path.join(tempHome, "repo");
+ const nestedDir = path.join(repoDir, "packages", "web");
+ fs.mkdirSync(nestedDir, { recursive: true });
+ writeLocalStoredConfig(
+ { workspace: WS, token: "test-token", host: BASE },
+ { cwd: repoDir, target: "cwd" },
+ );
+ process.chdir(nestedDir);
+
+ await Effect.runPromise(
+ projectsUseHandler({ project: "WEB", global: false, local: false }),
+ );
+
+ const saved = JSON.parse(
+ fs.readFileSync(getLocalConfigFilePath(repoDir), "utf8"),
+ ) as {
+ defaultProject?: string;
+ };
+ expect(saved.defaultProject).toBe("WEB");
+ });
+
+ it("allows forcing a global current project even when local config is active", async () => {
+ const { projectsUseHandler } = await import("@/commands/projects");
+ const { getConfigFilePath, writeLocalStoredConfig } = await import(
+ "@/user-config"
+ );
+ const repoDir = path.join(tempHome, "repo");
+ const nestedDir = path.join(repoDir, "apps", "cli");
+ fs.mkdirSync(nestedDir, { recursive: true });
+ writeLocalStoredConfig(
+ { workspace: WS, token: "test-token", host: BASE },
+ { cwd: repoDir, target: "cwd" },
+ );
+ process.chdir(nestedDir);
+
+ await Effect.runPromise(
+ projectsUseHandler({ project: "ACME", global: true, local: false }),
+ );
+
+ const saved = JSON.parse(fs.readFileSync(getConfigFilePath(), "utf8")) as {
+ defaultProject?: string;
+ };
+ expect(saved.defaultProject).toBe("ACME");
+ });
+});
+
+describe("projectsCurrent", () => {
+ it("prints the saved current project", async () => {
+ const { writeStoredConfig } = await import("@/user-config");
+ writeStoredConfig({ defaultProject: "WEB" });
+ const { projectsCurrentHandler } = await import("@/commands/projects");
+ const logs: string[] = [];
+ const orig = console.log;
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
+
+ try {
+ await Effect.runPromise(projectsCurrentHandler());
+ } finally {
+ console.log = orig;
+ }
+
+ expect(logs.join("\n")).toContain("WEB");
+ expect(logs.join("\n")).toContain("proj-web");
+ expect(logs.join("\n")).toContain("Web Project");
+ expect(logs.join("\n")).toContain("(global)");
+ });
+
+ it("reports when the effective current project comes from local config", async () => {
+ const { writeLocalStoredConfig } = await import("@/user-config");
+ const repoDir = path.join(tempHome, "repo");
+ const nestedDir = path.join(repoDir, "services", "api");
+ fs.mkdirSync(nestedDir, { recursive: true });
+ writeLocalStoredConfig(
+ {
+ workspace: WS,
+ token: "test-token",
+ host: BASE,
+ defaultProject: "ACME",
+ },
+ { cwd: repoDir, target: "cwd" },
+ );
+ process.chdir(nestedDir);
+ const { projectsCurrentHandler } = await import("@/commands/projects");
+ const logs: string[] = [];
+ const orig = console.log;
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
+
+ try {
+ await Effect.runPromise(projectsCurrentHandler());
+ } finally {
+ console.log = orig;
+ }
+
+ expect(logs.join("\n")).toContain("ACME");
+ expect(logs.join("\n")).toContain("(local)");
+ });
+});
+
+describe("projectsList", () => {
+ it("marks the saved current project", async () => {
+ const { writeStoredConfig } = await import("@/user-config");
+ writeStoredConfig({ defaultProject: "WEB" });
+ const { projectsListHandler } = await import("@/commands/projects");
+ const logs: string[] = [];
+ const orig = console.log;
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
+
+ try {
+ await Effect.runPromise(projectsListHandler());
+ } finally {
+ console.log = orig;
+ }
+
+ expect(logs.join("\n")).toContain("* WEB");
+ });
+});
diff --git a/tests/resolve.test.ts b/tests/resolve.test.ts
index b1d2293..1f3175a 100644
--- a/tests/resolve.test.ts
+++ b/tests/resolve.test.ts
@@ -8,15 +8,15 @@ import {
it,
} from "bun:test";
import { Effect } from "effect";
-import { http, HttpResponse } from "msw";
+import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import {
- resolveProject,
- parseIssueRef,
+ _clearProjectCache,
findIssueBySeq,
- getStateId,
getMemberId,
- _clearProjectCache,
+ getStateId,
+ parseIssueRef,
+ resolveProject,
} from "@/resolve";
const BASE = "http://test.local";
@@ -75,16 +75,17 @@ afterAll(() => server.close());
beforeEach(() => {
_clearProjectCache();
- process.env["PLANE_HOST"] = BASE;
- process.env["PLANE_WORKSPACE"] = WS;
- process.env["PLANE_API_TOKEN"] = "test-token";
+ process.env.PLANE_HOST = BASE;
+ process.env.PLANE_WORKSPACE = WS;
+ process.env.PLANE_API_TOKEN = "test-token";
});
afterEach(() => {
server.resetHandlers();
- delete process.env["PLANE_HOST"];
- delete process.env["PLANE_WORKSPACE"];
- delete process.env["PLANE_API_TOKEN"];
+ delete process.env.PLANE_HOST;
+ delete process.env.PLANE_WORKSPACE;
+ delete process.env.PLANE_API_TOKEN;
+ delete process.env.PLANE_PROJECT;
});
describe("resolveProject", () => {
@@ -126,6 +127,20 @@ describe("resolveProject", () => {
const result = await Effect.runPromise(resolveProject("WEB"));
expect(result.id).toBe("proj-web"); // still from cache
});
+
+ it("resolves @current from PLANE_PROJECT", async () => {
+ process.env.PLANE_PROJECT = "WEB";
+ const result = await Effect.runPromise(resolveProject("@current"));
+ expect(result.key).toBe("WEB");
+ expect(result.id).toBe("proj-web");
+ });
+
+ it("resolves blank project from PLANE_PROJECT", async () => {
+ process.env.PLANE_PROJECT = "ACME";
+ const result = await Effect.runPromise(resolveProject(""));
+ expect(result.key).toBe("ACME");
+ expect(result.id).toBe("proj-acme");
+ });
});
describe("parseIssueRef", () => {
diff --git a/tests/schemas.test.ts b/tests/schemas.test.ts
index 4e279f1..5bb481a 100644
--- a/tests/schemas.test.ts
+++ b/tests/schemas.test.ts
@@ -1,18 +1,18 @@
import { describe, expect, it } from "bun:test";
import { Effect, Schema } from "effect";
import {
- StateSchema,
+ CycleSchema,
+ CyclesResponseSchema,
IssueSchema,
IssuesResponseSchema,
- StatesResponseSchema,
- ProjectSchema,
- ProjectsResponseSchema,
LabelSchema,
LabelsResponseSchema,
MemberSchema,
MembersResponseSchema,
- CycleSchema,
- CyclesResponseSchema,
+ ProjectSchema,
+ ProjectsResponseSchema,
+ StateSchema,
+ StatesResponseSchema,
} from "@/config";
async function decode(
diff --git a/tests/user-config.test.ts b/tests/user-config.test.ts
new file mode 100644
index 0000000..5d04aac
--- /dev/null
+++ b/tests/user-config.test.ts
@@ -0,0 +1,204 @@
+import { afterEach, beforeEach, describe, expect, it } from "bun:test";
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+
+const ORIGINAL_HOME = process.env.HOME;
+const ORIGINAL_CWD = process.cwd();
+const ORIGINAL_PLANE_API_TOKEN = process.env.PLANE_API_TOKEN;
+const ORIGINAL_PLANE_HOST = process.env.PLANE_HOST;
+const ORIGINAL_PLANE_WORKSPACE = process.env.PLANE_WORKSPACE;
+const ORIGINAL_PLANE_PROJECT = process.env.PLANE_PROJECT;
+
+let tempHome = "";
+
+beforeEach(() => {
+ tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "plane-cli-config-"));
+ process.env.HOME = tempHome;
+ delete process.env.PLANE_API_TOKEN;
+ delete process.env.PLANE_HOST;
+ delete process.env.PLANE_WORKSPACE;
+ delete process.env.PLANE_PROJECT;
+ process.chdir(tempHome);
+});
+
+afterEach(() => {
+ if (ORIGINAL_PLANE_API_TOKEN === undefined) {
+ delete process.env.PLANE_API_TOKEN;
+ } else {
+ process.env.PLANE_API_TOKEN = ORIGINAL_PLANE_API_TOKEN;
+ }
+ if (ORIGINAL_PLANE_HOST === undefined) {
+ delete process.env.PLANE_HOST;
+ } else {
+ process.env.PLANE_HOST = ORIGINAL_PLANE_HOST;
+ }
+ if (ORIGINAL_PLANE_WORKSPACE === undefined) {
+ delete process.env.PLANE_WORKSPACE;
+ } else {
+ process.env.PLANE_WORKSPACE = ORIGINAL_PLANE_WORKSPACE;
+ }
+ if (ORIGINAL_PLANE_PROJECT === undefined) {
+ delete process.env.PLANE_PROJECT;
+ } else {
+ process.env.PLANE_PROJECT = ORIGINAL_PLANE_PROJECT;
+ }
+ if (ORIGINAL_HOME === undefined) {
+ delete process.env.HOME;
+ } else {
+ process.env.HOME = ORIGINAL_HOME;
+ }
+ process.chdir(ORIGINAL_CWD);
+ fs.rmSync(tempHome, { force: true, recursive: true });
+});
+
+describe("user config layering", () => {
+ it("uses the nearest local config over global config", async () => {
+ const {
+ findNearestLocalConfigFilePath,
+ getConfigDetails,
+ getLocalConfigFilePath,
+ writeGlobalStoredConfig,
+ writeLocalStoredConfig,
+ } = await import("@/user-config");
+ const repoDir = path.join(tempHome, "repo");
+ const appDir = path.join(repoDir, "apps", "web");
+ const nestedDir = path.join(appDir, "src");
+ fs.mkdirSync(nestedDir, { recursive: true });
+
+ writeGlobalStoredConfig({
+ token: "global-token",
+ host: "https://global.plane.local",
+ workspace: "global-workspace",
+ defaultProject: "GLOBAL",
+ });
+ writeLocalStoredConfig(
+ {
+ workspace: "repo-workspace",
+ defaultProject: "REPO",
+ },
+ { cwd: repoDir, target: "cwd" },
+ );
+ writeLocalStoredConfig(
+ {
+ host: "https://app.plane.local/",
+ defaultProject: "APP",
+ },
+ { cwd: appDir, target: "cwd" },
+ );
+
+ const config = getConfigDetails(nestedDir);
+
+ expect(findNearestLocalConfigFilePath(nestedDir)).toBe(
+ getLocalConfigFilePath(appDir),
+ );
+ expect(config.token).toBe("global-token");
+ expect(config.workspace).toBe("global-workspace");
+ // Security: local host is ignored when the token comes from global
+ // to prevent an untrusted repo from redirecting a real token.
+ expect(config.host).toBe("https://global.plane.local");
+ expect(config.defaultProject).toBe("APP");
+ expect(config.sources.token).toBe("global");
+ expect(config.sources.host).toBe("global");
+ expect(config.sources.workspace).toBe("global");
+ expect(config.sources.defaultProject).toBe("local");
+ });
+
+ it("uses local host when the local config also provides a token", async () => {
+ const {
+ getConfigDetails,
+ writeGlobalStoredConfig,
+ writeLocalStoredConfig,
+ } = await import("@/user-config");
+ const repoDir = path.join(tempHome, "repo");
+ fs.mkdirSync(repoDir, { recursive: true });
+
+ writeGlobalStoredConfig({
+ token: "global-token",
+ host: "https://global.plane.local",
+ workspace: "global-workspace",
+ });
+ writeLocalStoredConfig(
+ {
+ token: "local-token",
+ host: "https://local.plane.local",
+ workspace: "local-workspace",
+ },
+ { cwd: repoDir, target: "cwd" },
+ );
+
+ const config = getConfigDetails(repoDir);
+
+ expect(config.token).toBe("local-token");
+ expect(config.host).toBe("https://local.plane.local");
+ expect(config.workspace).toBe("local-workspace");
+ expect(config.sources.token).toBe("local");
+ expect(config.sources.host).toBe("local");
+ expect(config.sources.workspace).toBe("local");
+ });
+
+ it("applies canonical env vars above local and global config", async () => {
+ const {
+ getConfigDetails,
+ writeGlobalStoredConfig,
+ writeLocalStoredConfig,
+ } = await import("@/user-config");
+ const repoDir = path.join(tempHome, "repo");
+ const nestedDir = path.join(repoDir, "packages", "sdk");
+ fs.mkdirSync(nestedDir, { recursive: true });
+
+ writeGlobalStoredConfig({
+ token: "global-token",
+ host: "https://global.plane.local",
+ workspace: "global-workspace",
+ defaultProject: "GLOBAL",
+ });
+ writeLocalStoredConfig(
+ {
+ token: "local-token",
+ host: "https://local.plane.local",
+ workspace: "local-workspace",
+ defaultProject: "LOCAL",
+ },
+ { cwd: repoDir, target: "cwd" },
+ );
+
+ process.env.PLANE_API_TOKEN = "env-token";
+ process.env.PLANE_HOST = "https://env.plane.local/";
+ process.env.PLANE_WORKSPACE = "env-workspace";
+ process.env.PLANE_PROJECT = "ENV";
+
+ const config = getConfigDetails(nestedDir);
+
+ expect(config.token).toBe("env-token");
+ expect(config.host).toBe("https://env.plane.local");
+ expect(config.workspace).toBe("env-workspace");
+ expect(config.defaultProject).toBe("ENV");
+ expect(config.sources.token).toBe("env");
+ expect(config.sources.host).toBe("env");
+ expect(config.sources.workspace).toBe("env");
+ expect(config.sources.defaultProject).toBe("env");
+ });
+
+ it("normalizes inherited hosts without an explicit scheme", async () => {
+ const { getConfigDetails, writeGlobalStoredConfig } = await import(
+ "@/user-config"
+ );
+
+ writeGlobalStoredConfig({
+ host: "plane.domain.com/",
+ workspace: "workspace-1",
+ token: "token-1",
+ });
+
+ const config = getConfigDetails(tempHome);
+
+ expect(config.host).toBe("https://plane.domain.com");
+ expect(config.sources.host).toBe("global");
+
+ process.env.PLANE_HOST = "api.plane.local";
+ const envConfig = getConfigDetails(tempHome);
+ expect(envConfig.host).toBe("https://api.plane.local");
+ expect(envConfig.sources.host).toBe("env");
+ });
+});
diff --git a/tests/xml-output.test.ts b/tests/xml-output.test.ts
index 5f42e5d..bc6f452 100644
--- a/tests/xml-output.test.ts
+++ b/tests/xml-output.test.ts
@@ -10,13 +10,13 @@ import {
describe,
expect,
it,
+ mock,
} from "bun:test";
-import { Effect } from "effect";
-import { http, HttpResponse } from "msw";
+import { Effect, Option } from "effect";
+import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
-import { mock } from "bun:test";
-import { _clearProjectCache } from "@/resolve";
import { toXml } from "@/output";
+import { _clearProjectCache } from "@/resolve";
// Set xmlMode=true for this entire test file before command modules load
mock.module("@/output", () => ({
@@ -29,6 +29,16 @@ const BASE = "http://xml-output-test.local";
const WS = "testws";
const PROJECTS = [{ id: "proj-acme", identifier: "ACME", name: "Acme" }];
+const PROJECT_DETAIL = {
+ id: "proj-acme",
+ identifier: "ACME",
+ name: "Acme",
+ module_view: true,
+ cycle_view: true,
+ issue_views_view: true,
+ page_view: true,
+ inbox_view: true,
+};
const ISSUES = [
{
id: "i1",
@@ -49,17 +59,17 @@ const CYCLES = [
];
const CYCLE_ISSUES = [
{
- id: "ci1",
- issue: "i1",
- issue_detail: { id: "i1", sequence_id: 1, name: "Issue One" },
+ id: "i1",
+ sequence_id: 1,
+ name: "Issue One",
},
];
const MODULES = [{ id: "mod1", name: "Module Alpha", status: "in-progress" }];
const MODULE_ISSUES = [
{
- id: "mi1",
- issue: "i1",
- issue_detail: { id: "i1", sequence_id: 1, name: "Issue One" },
+ id: "i1",
+ sequence_id: 1,
+ name: "Issue One",
},
];
const INTAKE_ISSUES = [
@@ -124,6 +134,9 @@ const server = setupServer(
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
HttpResponse.json({ results: PROJECTS }),
),
+ http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/`, () =>
+ HttpResponse.json(PROJECT_DETAIL),
+ ),
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, () =>
HttpResponse.json({ results: ISSUES }),
),
@@ -153,13 +166,17 @@ const server = setupServer(
() => HttpResponse.json({ results: ACTIVITIES }),
),
http.get(
- `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/issue-links/`,
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/links/`,
() => HttpResponse.json({ results: LINKS }),
),
http.get(
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/comments/`,
() => HttpResponse.json({ results: COMMENTS }),
),
+ http.get(
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/worklogs/`,
+ () => new HttpResponse('{"error":"Page not found."}', { status: 404 }),
+ ),
http.get(
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/worklogs/`,
() => HttpResponse.json({ results: WORKLOGS }),
@@ -182,16 +199,16 @@ afterAll(() => {
beforeEach(() => {
_clearProjectCache();
- process.env["PLANE_HOST"] = BASE;
- process.env["PLANE_WORKSPACE"] = WS;
- process.env["PLANE_API_TOKEN"] = "test-token";
+ process.env.PLANE_HOST = BASE;
+ process.env.PLANE_WORKSPACE = WS;
+ process.env.PLANE_API_TOKEN = "test-token";
});
afterEach(() => {
server.resetHandlers();
- delete process.env["PLANE_HOST"];
- delete process.env["PLANE_WORKSPACE"];
- delete process.env["PLANE_API_TOKEN"];
+ delete process.env.PLANE_HOST;
+ delete process.env.PLANE_WORKSPACE;
+ delete process.env.PLANE_API_TOKEN;
});
async function captureLogs(fn: () => Promise): Promise {
@@ -208,9 +225,9 @@ async function captureLogs(fn: () => Promise): Promise {
describe("cyclesList --xml", () => {
it("outputs XML of cycles", async () => {
- const { cyclesList } = await import("@/commands/cycles");
+ const { cyclesListHandler } = await import("@/commands/cycles");
const output = await captureLogs(() =>
- Effect.runPromise((cyclesList as any).handler({ project: "ACME" })),
+ Effect.runPromise(cyclesListHandler({ project: "ACME" })),
);
expect(output).toContain("");
expect(output).toContain("cyc1");
@@ -219,22 +236,22 @@ describe("cyclesList --xml", () => {
describe("cycleIssuesList --xml", () => {
it("outputs XML of cycle issues", async () => {
- const { cycleIssuesList } = await import("@/commands/cycles");
+ const { cycleIssuesListHandler } = await import("@/commands/cycles");
const output = await captureLogs(() =>
Effect.runPromise(
- (cycleIssuesList as any).handler({ project: "ACME", cycleId: "cyc1" }),
+ cycleIssuesListHandler({ project: "ACME", cycleId: "cyc1" }),
),
);
expect(output).toContain("");
- expect(output).toContain("ci1");
+ expect(output).toContain("Issue One");
});
});
describe("modulesList --xml", () => {
it("outputs XML of modules", async () => {
- const { modulesList } = await import("@/commands/modules");
+ const { modulesListHandler } = await import("@/commands/modules");
const output = await captureLogs(() =>
- Effect.runPromise((modulesList as any).handler({ project: "ACME" })),
+ Effect.runPromise(modulesListHandler({ project: "ACME" })),
);
expect(output).toContain("");
expect(output).toContain("mod1");
@@ -243,25 +260,26 @@ describe("modulesList --xml", () => {
describe("moduleIssuesList --xml", () => {
it("outputs XML of module issues", async () => {
- const { moduleIssuesList } = await import("@/commands/modules");
+ const { moduleIssuesListHandler } = await import("@/commands/modules");
const output = await captureLogs(() =>
Effect.runPromise(
- (moduleIssuesList as any).handler({
+ moduleIssuesListHandler({
project: "ACME",
moduleId: "mod1",
}),
),
);
expect(output).toContain("");
- expect(output).toContain("mi1");
+ expect(output).toContain('id="i1"');
+ expect(output).toContain('sequence_id="1"');
});
});
describe("intakeList --xml", () => {
it("outputs XML of intake issues", async () => {
- const { intakeList } = await import("@/commands/intake");
+ const { intakeListHandler } = await import("@/commands/intake");
const output = await captureLogs(() =>
- Effect.runPromise((intakeList as any).handler({ project: "ACME" })),
+ Effect.runPromise(intakeListHandler({ project: "ACME" })),
);
expect(output).toContain("");
expect(output).toContain("int1");
@@ -270,9 +288,9 @@ describe("intakeList --xml", () => {
describe("pagesList --xml", () => {
it("outputs XML of pages", async () => {
- const { pagesList } = await import("@/commands/pages");
+ const { pagesListHandler } = await import("@/commands/pages");
const output = await captureLogs(() =>
- Effect.runPromise((pagesList as any).handler({ project: "ACME" })),
+ Effect.runPromise(pagesListHandler({ project: "ACME" })),
);
expect(output).toContain("");
expect(output).toContain("pg1");
@@ -281,9 +299,9 @@ describe("pagesList --xml", () => {
describe("issueActivity --xml", () => {
it("outputs XML of activities", async () => {
- const { issueActivity } = await import("@/commands/issue");
+ const { issueActivityHandler } = await import("@/commands/issue");
const output = await captureLogs(() =>
- Effect.runPromise((issueActivity as any).handler({ ref: "ACME-1" })),
+ Effect.runPromise(issueActivityHandler({ ref: "ACME-1" })),
);
expect(output).toContain("");
expect(output).toContain("act1");
@@ -292,9 +310,9 @@ describe("issueActivity --xml", () => {
describe("issueLinkList --xml", () => {
it("outputs XML of links", async () => {
- const { issueLinkList } = await import("@/commands/issue");
+ const { issueLinkListHandler } = await import("@/commands/issue");
const output = await captureLogs(() =>
- Effect.runPromise((issueLinkList as any).handler({ ref: "ACME-1" })),
+ Effect.runPromise(issueLinkListHandler({ ref: "ACME-1" })),
);
expect(output).toContain("");
expect(output).toContain("lnk1");
@@ -303,9 +321,9 @@ describe("issueLinkList --xml", () => {
describe("issueCommentsList --xml", () => {
it("outputs XML of comments", async () => {
- const { issueCommentsList } = await import("@/commands/issue");
+ const { issueCommentsListHandler } = await import("@/commands/issue");
const output = await captureLogs(() =>
- Effect.runPromise((issueCommentsList as any).handler({ ref: "ACME-1" })),
+ Effect.runPromise(issueCommentsListHandler({ ref: "ACME-1" })),
);
expect(output).toContain("");
expect(output).toContain("cmt1");
@@ -314,9 +332,9 @@ describe("issueCommentsList --xml", () => {
describe("issueWorklogsList --xml", () => {
it("outputs XML of worklogs", async () => {
- const { issueWorklogsList } = await import("@/commands/issue");
+ const { issueWorklogsListHandler } = await import("@/commands/issue");
const output = await captureLogs(() =>
- Effect.runPromise((issueWorklogsList as any).handler({ ref: "ACME-1" })),
+ Effect.runPromise(issueWorklogsListHandler({ ref: "ACME-1" })),
);
expect(output).toContain("");
expect(output).toContain("wl1");
@@ -325,14 +343,14 @@ describe("issueWorklogsList --xml", () => {
describe("issuesList --xml", () => {
it("outputs XML of issues", async () => {
- const { issuesList } = await import("@/commands/issues");
+ const { issuesListHandler } = await import("@/commands/issues");
const output = await captureLogs(() =>
Effect.runPromise(
- (issuesList as any).handler({
+ issuesListHandler({
project: "ACME",
- state: { _tag: "None" },
- assignee: { _tag: "None" },
- priority: { _tag: "None" },
+ state: Option.none(),
+ assignee: Option.none(),
+ priority: Option.none(),
}),
),
);