diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 5214e15..113d334 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,11 +1,11 @@ blank_issues_enabled: false contact_links: - name: Security policy - url: https://github.com/backslash-ux/plane-cli-cli/blob/main/SECURITY.md + url: https://github.com/backslash-ux/plane-cli/blob/main/SECURITY.md about: Report suspected vulnerabilities privately instead of opening a public issue. - name: Contributing guide - url: https://github.com/backslash-ux/plane-cli-cli/blob/main/CONTRIBUTING.md + url: https://github.com/backslash-ux/plane-cli/blob/main/CONTRIBUTING.md about: Review contribution expectations, quality gates, and documentation requirements first. - name: Existing issues - url: https://github.com/backslash-ux/plane-cli-cli/issues + url: https://github.com/backslash-ux/plane-cli/issues about: Check open issues and feature requests before filing a new one. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 87b5185..43a8efd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,25 @@ Earlier project history may predate this file. ## [Unreleased] +## 1.1.0 + +### Added + +- `plane modules create` with optional description, status, schedule, and lead resolution. +- `plane init --local` now prompts whether to import the SKILL.md CLI usage guide into AGENTS.md. First-time prompt defaults to `N`; subsequent runs (section already present) default to `Y`. The skill section is wrapped in idempotent HTML comment markers so repeated runs update it in place. + +### Changed + +- **Consistent project defaulting for create commands.** `issue create`, `modules create`, `labels create`, and `pages create` now use `--title`/`--name` options instead of positional args, so the project positional can be omitted to use the saved current project. +- `hasSkillSectionInAgentsFile` now requires both the start and end delimiters to be present before treating an existing skill section as complete, preventing duplicate sections in malformed files. + +### Validated + +- `plane init --local` skill import prompt exercised: accept (`y`), decline (empty/default N), and idempotent re-run paths all verified via tests. +- All 261 tests pass with line and function coverage above the 95% threshold. + +## 1.0.0 + ### Added - Public open-source repository baseline with contributor, governance, security, architecture, and release documentation. diff --git a/README.md b/README.md index 71dc7e2..0c9cef6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # plane -[![CI](https://github.com/backslash-ux/plane-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/backslash-ux/plane-cli-cli/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) +[![CI](https://github.com/backslash-ux/plane-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/backslash-ux/plane-cli/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) CLI for the [Plane](https://plane.so) project management API. @@ -103,8 +103,8 @@ plane issues list plane issues list PROJ plane issues list PROJ --state started plane issue get PROJ-29 -plane issue create PROJ "Title" -plane issue create @current "Title" +plane issue create --title "Title" +plane issue create --title "Title" PROJ plane issue update --state completed --priority high PROJ-29 plane issue delete PROJ-29 @@ -134,6 +134,7 @@ plane cycles issues add PROJ CYCLE_ID PROJ-29 # Modules plane modules list PROJ +plane modules create --name "Sprint 3" plane modules delete PROJ MODULE_ID plane modules issues list PROJ MODULE_ID plane modules issues add PROJ MODULE_ID PROJ-29 @@ -179,6 +180,8 @@ plane cycles list PROJ --json - `--description` for issue and page create or update commands is sent through to Plane as HTML in `description_html`. - `plane issue link add` accepts an optional link title via `--title`. - `plane labels delete` accepts either the label UUID or the exact label name returned by `plane labels list`. +- `plane modules create --lead` accepts a member display name, email, or UUID from `plane members list`. +- `plane modules create --status in_progress` is normalized to Plane's `in-progress` API value. - `plane modules delete` accepts either the module UUID or the exact module name returned by `plane modules list`. - `plane modules issues remove` expects the module-issue identifier returned by `plane modules issues list`, not an issue ref. - `plane members list` is workspace-scoped and does not take a project argument. @@ -197,7 +200,7 @@ bun update -g @backslash-ux/plane-cli ## Development ```bash -git clone https://github.com/backslash-ux/plane-cli-cli +git clone https://github.com/backslash-ux/plane-cli cd plane-cli bun install diff --git a/SKILL.md b/SKILL.md index d37a5d8..d8374cc 100644 --- a/SKILL.md +++ b/SKILL.md @@ -144,12 +144,12 @@ plane issue get PROJ-29 ### Create ```bash -plane issue create PROJ "Issue title" -plane issue create @current "Issue title" -plane issue create --priority high --state started PROJ "Fix lint pipeline" -plane issue create --description '

Detailed context

' PROJ "Add dark mode" -plane issue create --assignee "Jane Doe" PROJ "Onboarding bug" -plane issue create --label "bug" PROJ "Regression in login flow" +plane issue create --title "Issue title" +plane issue create --title "Issue title" PROJ +plane issue create --priority high --state started --title "Fix lint pipeline" +plane issue create --description '

Detailed context

' --title "Add dark mode" PROJ +plane issue create --assignee "Jane Doe" --title "Onboarding bug" PROJ +plane issue create --label "bug" --title "Regression in login flow" PROJ ``` ### Update @@ -233,8 +233,8 @@ State IDs are UUIDs unique per project. Always fetch live — never hardcode. plane labels list plane labels list PROJ plane labels list PROJ --xml -plane labels create PROJ "bug" -plane labels create --color "#ff0000" PROJ "critical" +plane labels create --name "bug" +plane labels create --name "critical" --color "#ff0000" PROJ plane labels delete PROJ bug ``` @@ -271,6 +271,7 @@ Cycle IDs are UUIDs. Fetch them from `plane cycles list PROJ`. plane modules list plane modules list PROJ plane modules list PROJ --xml +plane modules create --name "Sprint 3" plane modules delete PROJ plane modules issues list PROJ plane modules issues add PROJ PROJ-29 @@ -299,7 +300,7 @@ plane pages list plane pages list PROJ plane pages list PROJ --xml plane pages get PROJ # full JSON including description_html -plane pages create --name "My Page" PROJ +plane pages create --name "My Page" plane pages create --name "My Page" --description '

Content here

' PROJ plane pages update --name "New Title" PROJ plane pages update --description '

New content

' PROJ @@ -337,6 +338,8 @@ Some deployments do not expose page endpoints even when the project advertises p - No server-side text search — fetch all issues and filter locally. - No epics — use labels or modules to group related issues. - `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. +- `plane modules create --lead` accepts a member display name, email, or UUID from `plane members list`. +- `plane modules create --status in_progress` is normalized to Plane's `in-progress` API value. - 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/package.json b/package.json index e0318f9..24db4e6 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "1.0.0", + "version": "1.1.0", "description": "CLI for the Plane project management API", "author": "Gabriel Reynold and Contributors", "license": "MIT", @@ -33,12 +33,12 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/backslash-ux/plane-cli-cli.git" + "url": "git+https://github.com/backslash-ux/plane-cli.git" }, "bugs": { - "url": "https://github.com/backslash-ux/plane-cli-cli/issues" + "url": "https://github.com/backslash-ux/plane-cli/issues" }, - "homepage": "https://github.com/backslash-ux/plane-cli-cli#readme", + "homepage": "https://github.com/backslash-ux/plane-cli#readme", "engines": { "bun": ">=1.0.0" }, diff --git a/src/app.ts b/src/app.ts index 34a7c53..1fae5dc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -36,8 +36,9 @@ QUICK START 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 create --title "title" Create an issue in the saved current project + plane issue create --title "title" PROJ + plane modules create --name "Sprint 3" plane issue update --state done PROJ-29 plane issue comment PROJ-29 "text" Add a comment @@ -55,7 +56,7 @@ ALL SUBCOMMANDS issue get | create | update | delete | comment | activity | link | comments | worklogs cycles list | issues (list, add) - modules list | delete | issues (list, add, remove) + modules list | create | 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 @@ -94,5 +95,5 @@ FOR AI AGENTS / BOTS export const cli = Command.run(plane, { name: "plane", - version: "1.0.0", + version: "1.1.0", }); diff --git a/src/commands/init.ts b/src/commands/init.ts index 98b5e4a..8356818 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -13,6 +13,9 @@ import { } from "../config.js"; import { getLocalAgentsFilePath, + hasSkillSectionInAgentsFile, + importSkillIntoAgentsFile, + readPackageSkillContent, writeLocalProjectAgentsFile, } from "../project-agents.js"; import { @@ -545,6 +548,34 @@ export function initHandler( yield* Console.log(" Estimate: disabled"); } yield* Console.log(`Local AGENTS.md updated at ${agentsPath}`); + + const skillContent = readPackageSkillContent(); + if (skillContent) { + const alreadyHasSkill = hasSkillSectionInAgentsFile(); + const skillPromptText = alreadyHasSkill + ? "Update SKILL.md (CLI usage guide) in AGENTS.md? [Y/n]: " + : "Import SKILL.md (CLI usage guide) into AGENTS.md? [y/N]: "; + const skillRl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + let skillAnswer: string; + try { + skillAnswer = yield* Effect.promise(() => + prompt(skillRl, skillPromptText), + ); + } finally { + skillRl.close(); + } + const trimmed = skillAnswer.trim().toLowerCase(); + const shouldImport = alreadyHasSkill + ? trimmed !== "n" && trimmed !== "no" + : trimmed === "y" || trimmed === "yes"; + if (shouldImport) { + importSkillIntoAgentsFile(skillContent); + yield* Console.log(" SKILL.md imported into AGENTS.md"); + } + } } else { yield* Console.log( `\nWarning: could not load project helper data for ${selectedProject.identifier}: ${projectHelper.left.message}`, @@ -569,6 +600,6 @@ export const localInit = Command.make("init", {}, () => initHandler({ global: false, local: true }, "local"), ).pipe( Command.withDescription( - "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.", + "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, updates AGENTS.md with project-context guidance for AI agents, and optionally imports the SKILL.md CLI usage guide into AGENTS.md.", ), ); diff --git a/src/commands/issue.ts b/src/commands/issue.ts index 8f3efef..eb1c52c 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -204,13 +204,14 @@ export const issueComment = Command.make( ), ); // --- issue create --- -const titleArg = Args.text({ name: "title" }).pipe( - Args.withDescription("Issue title"), +const createTitleOption = Options.text("title").pipe( + Options.withDescription("Issue title"), ); -const projectRefArg = Args.text({ name: "project" }).pipe( +const createProjectArg = Args.text({ name: "project" }).pipe( Args.withDescription( - "Project identifier (e.g. PROJ). Use '@current' for the saved default project.", + "Project identifier (e.g. PROJ). Omit to use the saved current project.", ), + Args.withDefault(""), ); const createPriorityOption = Options.optional( @@ -285,13 +286,13 @@ export const issueCreate = Command.make( description: createDescriptionOption, assignee: createAssigneeOption, label: createLabelOption, - project: projectRefArg, - title: titleArg, + title: createTitleOption, + project: createProjectArg, }, issueCreateHandler, ).pipe( Command.withDescription( - '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"', + 'Create a new issue in a project. Omit PROJECT to use the saved current project.\n\nExamples:\n plane issue create --title "Migrate Button component"\n plane issue create --title "Migrate Button component" PROJ\n plane issue create --priority high --state started --title "Fix lint pipeline"\n plane issue create --description "Detailed context here" --title "Add dark mode" PROJ\n plane issue create --assignee "Jane Doe" --title "Onboarding bug" PROJ', ), ); // --- issue activity --- diff --git a/src/commands/labels.ts b/src/commands/labels.ts index 55397d9..48e46ea 100644 --- a/src/commands/labels.ts +++ b/src/commands/labels.ts @@ -1,5 +1,5 @@ import { Args, Command, Options } from "@effect/cli"; -import { Console, Effect } from "effect"; +import { Console, Effect, Option } from "effect"; import { api, decodeOrFail } from "../api.js"; import { LabelSchema, LabelsResponseSchema } from "../config.js"; import { jsonMode, toXml, xmlMode } from "../output.js"; @@ -47,8 +47,8 @@ export const labelsList = Command.make( // --- labels create --- -const nameArg = Args.text({ name: "name" }).pipe( - Args.withDescription("Label name"), +const createNameOption = Options.text("name").pipe( + Options.withDescription("Label name"), ); const colorOption = Options.optional(Options.text("color")).pipe( Options.withDescription("Hex color e.g. #ff0000"), @@ -61,8 +61,12 @@ const labelArg = Args.text({ name: "label" }).pipe( export const labelsCreate = Command.make( "create", - { color: colorOption, project: projectArg, name: nameArg }, + { color: colorOption, project: listProjectArg, name: createNameOption }, labelsCreateHandler, +).pipe( + Command.withDescription( + 'Create a new label in a project. Omit PROJECT to use the saved current project.\n\nExamples:\n plane labels create --name bug\n plane labels create --name bug --color "#ff0000" PROJ', + ), ); export function labelsCreateHandler({ @@ -72,7 +76,7 @@ export function labelsCreateHandler({ }: { project: string; name: string; - color: { _tag: "Some"; value: string } | { _tag: "None" }; + color: Option.Option; }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); @@ -81,7 +85,7 @@ export function labelsCreateHandler({ color?: string; } const body: LabelPayload = { name }; - if (color._tag === "Some") body.color = color.value; + if (Option.isSome(color)) 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})`); diff --git a/src/commands/modules.ts b/src/commands/modules.ts index 9d9baa7..a12213c 100644 --- a/src/commands/modules.ts +++ b/src/commands/modules.ts @@ -1,13 +1,15 @@ -import { Args, Command } from "@effect/cli"; -import { Console, Effect } from "effect"; +import { Args, Command, Options } from "@effect/cli"; +import { Console, Effect, Option } from "effect"; import { api, decodeOrFail } from "../api.js"; import { ModuleIssuesResponseSchema, + ModuleSchema, ModulesResponseSchema, } from "../config.js"; import { jsonMode, toXml, xmlMode } from "../output.js"; import { findIssueBySeq, + getMemberId, parseIssueRef, requireProjectFeature, resolveModule, @@ -30,6 +32,82 @@ const moduleArg = Args.text({ name: "module" }).pipe( "Module UUID or exact name (from 'plane modules list PROJECT')", ), ); +const createNameOption = Options.text("name").pipe( + Options.withDescription("Module name"), +); + +const descriptionOption = Options.optional(Options.text("description")).pipe( + Options.withDescription("Module description as plain text"), +); + +const statusOption = Options.optional( + Options.choice("status", [ + "backlog", + "planned", + "in-progress", + "in_progress", + "paused", + "completed", + "cancelled", + ]), +).pipe( + Options.withDescription( + "Module status: backlog, planned, in-progress, paused, completed, or cancelled", + ), +); + +const startDateOption = Options.optional(Options.text("start-date")).pipe( + Options.withDescription("Module start date in YYYY-MM-DD format"), +); + +const targetDateOption = Options.optional(Options.text("target-date")).pipe( + Options.withDescription("Module target date in YYYY-MM-DD format"), +); + +const leadOption = Options.optional(Options.text("lead")).pipe( + Options.withDescription( + "Module lead (display name, email, or UUID from 'plane members list')", + ), +); + +interface ModuleCreatePayload { + name: string; + description?: string; + status?: string; + start_date?: string; + target_date?: string; + lead?: string; +} + +function normalizeModuleStatus(status: string): string { + return status === "in_progress" ? "in-progress" : status; +} + +function validateModuleDateInput( + value: string, + flagName: "--start-date" | "--target-date", +): Effect.Effect { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return Effect.fail( + new Error(`${flagName} must be a valid date in YYYY-MM-DD format`), + ); + } + + const [year, month, day] = value.split("-").map(Number); + const parsed = new Date(Date.UTC(year, month - 1, day)); + const isValidDate = + parsed.getUTCFullYear() === year && + parsed.getUTCMonth() === month - 1 && + parsed.getUTCDate() === day; + + if (!isValidDate) { + return Effect.fail( + new Error(`${flagName} must be a valid date in YYYY-MM-DD format`), + ); + } + + return Effect.succeed(value); +} // --- modules list --- @@ -69,6 +147,75 @@ export const modulesList = Command.make( ), ); +// --- modules create --- + +export function modulesCreateHandler({ + project, + name, + description, + status, + startDate, + targetDate, + lead, +}: { + project: string; + name: string; + description: Option.Option; + status: Option.Option; + startDate: Option.Option; + targetDate: Option.Option; + lead: Option.Option; +}) { + return Effect.gen(function* () { + const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "module_view"); + const body: ModuleCreatePayload = { name }; + if (Option.isSome(description)) { + body.description = description.value; + } + if (Option.isSome(status)) { + body.status = normalizeModuleStatus(status.value); + } + if (Option.isSome(startDate)) { + body.start_date = yield* validateModuleDateInput( + startDate.value, + "--start-date", + ); + } + if (Option.isSome(targetDate)) { + body.target_date = yield* validateModuleDateInput( + targetDate.value, + "--target-date", + ); + } + if (Option.isSome(lead)) { + body.lead = yield* getMemberId(lead.value); + } + + const raw = yield* api.post(`projects/${id}/modules/`, body); + const module = yield* decodeOrFail(ModuleSchema, raw); + yield* Console.log(`Created module: ${module.name} (${module.id})`); + }); +} + +export const modulesCreate = Command.make( + "create", + { + name: createNameOption, + description: descriptionOption, + status: statusOption, + startDate: startDateOption, + targetDate: targetDateOption, + lead: leadOption, + project: listProjectArg, + }, + modulesCreateHandler, +).pipe( + Command.withDescription( + 'Create a new module in a project. Omit PROJECT to use the saved current project.\n\nExamples:\n plane modules create --name "Sprint 3"\n plane modules create --name "Sprint 3" PROJ\n plane modules create --name "Design System Rollout" --status planned PROJ\n plane modules create --name "Mobile Launch" --lead "Jane Doe" --start-date 2026-04-01 --target-date 2026-04-30', + ), +); + // --- modules delete --- export function modulesDeleteHandler({ @@ -255,7 +402,12 @@ export const moduleIssues = Command.make("issues").pipe( export const modules = Command.make("modules").pipe( Command.withDescription( - "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", + 'Manage modules (groups of related issues). Subcommands: list, create, delete, issues\n\nExamples:\n plane modules list PROJ\n plane modules create --name "Sprint 3"\n plane modules delete PROJ \n plane modules issues list PROJ \n plane modules issues add PROJ PROJ-29', ), - Command.withSubcommands([modulesList, modulesDelete, moduleIssues]), + Command.withSubcommands([ + modulesList, + modulesCreate, + modulesDelete, + moduleIssues, + ]), ); diff --git a/src/commands/pages.ts b/src/commands/pages.ts index 21815ab..5862073 100644 --- a/src/commands/pages.ts +++ b/src/commands/pages.ts @@ -154,11 +154,11 @@ export function pagesCreateHandler({ export const pagesCreate = Command.make( "create", - { project: projectArg, name: nameOption, description: descriptionOption }, + { project: listProjectArg, name: nameOption, description: descriptionOption }, pagesCreateHandler, ).pipe( Command.withDescription( - 'Create a new page.\n\nExample:\n plane pages create --name "My Page" PROJ', + 'Create a new page. Omit PROJECT to use the saved current project.\n\nExamples:\n plane pages create --name "My Page"\n plane pages create --name "My Page" PROJ', ), ); diff --git a/src/config.ts b/src/config.ts index 400c54b..ebe6e13 100644 --- a/src/config.ts +++ b/src/config.ts @@ -159,6 +159,10 @@ export const ModuleSchema = Schema.Struct({ name: Schema.String, status: Schema.optional(Schema.String), description: Schema.optional(Schema.NullOr(Schema.String)), + identifier: Schema.optional(Schema.String), + created_at: Schema.optional(Schema.String), + start_date: Schema.optional(Schema.NullOr(Schema.String)), + target_date: Schema.optional(Schema.NullOr(Schema.String)), }); export type Module = typeof ModuleSchema.Type; diff --git a/src/project-agents.ts b/src/project-agents.ts index 1bdbc89..9b27eac 100644 --- a/src/project-agents.ts +++ b/src/project-agents.ts @@ -1,5 +1,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; +import { fileURLToPath } from "node:url"; import type { ProjectContextSnapshot } from "./project-context.js"; import { getLocalConfigDir } from "./user-config.js"; @@ -81,3 +82,74 @@ export function writeLocalProjectAgentsFile( ); fs.writeFileSync(filePath, nextContent, "utf8"); } + +// --------------------------------------------------------------------------- +// SKILL section – embeds SKILL.md content into AGENTS.md so agents have the +// full CLI usage guide inline. +// --------------------------------------------------------------------------- + +const SKILL_SECTION_START = ""; +const SKILL_SECTION_END = ""; + +function buildSkillSection(skillContent: string): string { + return [ + SKILL_SECTION_START, + skillContent.trimEnd(), + SKILL_SECTION_END, + "", + ].join("\n"); +} + +function upsertSkillSection( + existingContent: string, + skillContent: string, +): string { + const skillPattern = new RegExp( + `${escapeRegExp(SKILL_SECTION_START)}[\\s\\S]*?${escapeRegExp(SKILL_SECTION_END)}\\n?`, + "m", + ); + const section = buildSkillSection(skillContent); + if (skillPattern.test(existingContent)) { + return existingContent.replace(skillPattern, section); + } + const trimmed = existingContent.trimEnd(); + if (!trimmed) { + return section; + } + return `${trimmed}\n\n${section}`; +} + +export function getPackageSkillPath(): string { + // src/project-agents.ts -> package root is one directory up + const srcDir = path.dirname(fileURLToPath(import.meta.url)); + return path.join(srcDir, "..", "SKILL.md"); +} + +export function readPackageSkillContent(): string | null { + const skillPath = getPackageSkillPath(); + if (!fs.existsSync(skillPath)) { + return null; + } + return fs.readFileSync(skillPath, "utf8"); +} + +export function importSkillIntoAgentsFile( + skillContent: string, + cwd = process.cwd(), +): void { + const filePath = getLocalAgentsFilePath(cwd); + const existingContent = fs.existsSync(filePath) + ? fs.readFileSync(filePath, "utf8") + : ""; + const nextContent = upsertSkillSection(existingContent, skillContent); + fs.writeFileSync(filePath, nextContent, "utf8"); +} + +export function hasSkillSectionInAgentsFile(cwd = process.cwd()): boolean { + const filePath = getLocalAgentsFilePath(cwd); + if (!fs.existsSync(filePath)) return false; + const content = fs.readFileSync(filePath, "utf8"); + return ( + content.includes(SKILL_SECTION_START) && content.includes(SKILL_SECTION_END) + ); +} diff --git a/tests/issue-commands.test.ts b/tests/issue-commands.test.ts index 29e7e9c..b6b6a44 100644 --- a/tests/issue-commands.test.ts +++ b/tests/issue-commands.test.ts @@ -1064,8 +1064,9 @@ describe("--description argv parsing", () => { "create", "--description", "Hello world", - "ACME", + "--title", "Argv test issue", + "ACME", ]); expect(logs.join("\n")).toContain("Created"); expect((postedBody as { description_html?: string }).description_html).toBe( @@ -1096,8 +1097,9 @@ describe("--description argv parsing", () => { "create", "--description", "

Raw HTML

", - "ACME", + "--title", "HTML test", + "ACME", ]); expect((postedBody as { description_html?: string }).description_html).toBe( "

Raw HTML

", diff --git a/tests/modules.test.ts b/tests/modules.test.ts index 5170c5a..2c68726 100644 --- a/tests/modules.test.ts +++ b/tests/modules.test.ts @@ -7,7 +7,9 @@ import { expect, it, } from "bun:test"; -import { Effect } from "effect"; +import { Command } from "@effect/cli"; +import { NodeContext } from "@effect/platform-node"; +import { Effect, Option } from "effect"; import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { _clearProjectCache } from "@/resolve"; @@ -41,6 +43,9 @@ const MODULES = [ { id: "mod1", name: "Sprint 1", status: "in_progress" }, { id: "mod2", name: "Sprint 2", status: "backlog" }, ]; +const MEMBERS = [ + { id: "mem1", display_name: "Jane Doe", email: "jane@example.com" }, +]; const MODULE_ISSUES = [ { id: "i1", @@ -59,6 +64,9 @@ 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}/members/`, () => + HttpResponse.json(MEMBERS), + ), http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/`, () => HttpResponse.json({ results: MODULES }), ), @@ -83,8 +91,32 @@ afterEach(() => { delete process.env.PLANE_HOST; delete process.env.PLANE_WORKSPACE; delete process.env.PLANE_API_TOKEN; + delete process.env.PLANE_PROJECT; }); +async function runModulesCli(argv: string[]): Promise<{ logs: string[] }> { + const { modules } = await import("@/commands/modules"); + + const root = Command.make("plane").pipe(Command.withSubcommands([modules])); + const cli = Command.run(root, { name: "plane", version: "0.0.0" }); + + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise( + cli(["_", "_", ...argv]).pipe(Effect.provide(NodeContext.layer)), + ); + } catch (error) { + logs.push(`ERROR: ${String(error)}`); + } finally { + console.log = orig; + } + + return { logs }; +} + describe("modulesList", () => { it("lists modules for a project", async () => { const { modulesListHandler } = await import("@/commands/modules"); @@ -154,6 +186,177 @@ describe("modulesList", () => { }); }); +describe("modulesCreate", () => { + it("parses the public CLI entrypoint and maps create flags to the API payload", async () => { + let postedBody: unknown; + server.use( + http.post( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/`, + async ({ request }) => { + postedBody = await request.json(); + return HttpResponse.json( + { + id: "mod-cli", + name: "Design System Rollout", + status: "in-progress", + description: "Ship tokens and docs", + }, + { status: 201 }, + ); + }, + ), + ); + + const { logs } = await runModulesCli([ + "modules", + "create", + "--name", + "Design System Rollout", + "--description", + "Ship tokens and docs", + "--status", + "in_progress", + "--start-date", + "2026-04-01", + "--target-date", + "2026-04-30", + "--lead", + "Jane Doe", + "ACME", + ]); + + expect(postedBody).toEqual({ + name: "Design System Rollout", + description: "Ship tokens and docs", + status: "in-progress", + start_date: "2026-04-01", + target_date: "2026-04-30", + lead: "mem1", + }); + expect(logs.join("\n")).toContain( + "Created module: Design System Rollout (mod-cli)", + ); + }); + + it("creates a module with only a name", async () => { + let postedBody: unknown; + server.use( + http.post( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/`, + async ({ request }) => { + postedBody = await request.json(); + return HttpResponse.json( + { + id: "mod3", + name: "Sprint 3", + status: "planned", + identifier: "ACME", + created_at: "2026-03-31T00:00:00Z", + }, + { status: 201 }, + ); + }, + ), + ); + + const { modulesCreateHandler } = await import("@/commands/modules"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise( + modulesCreateHandler({ + project: "ACME", + name: "Sprint 3", + description: Option.none(), + status: Option.none(), + startDate: Option.none(), + targetDate: Option.none(), + lead: Option.none(), + }), + ); + } finally { + console.log = orig; + } + + expect(postedBody).toEqual({ name: "Sprint 3" }); + expect(logs.join("\n")).toContain("Created module: Sprint 3 (mod3)"); + }); + + it("creates a module with optional fields and resolves the lead", async () => { + let postedBody: unknown; + server.use( + http.post( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/`, + async ({ request }) => { + postedBody = await request.json(); + return HttpResponse.json( + { + id: "mod4", + name: "Design System Rollout", + status: "in-progress", + description: "Ship tokens and docs", + }, + { status: 201 }, + ); + }, + ), + ); + + const { modulesCreateHandler } = await import("@/commands/modules"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise( + modulesCreateHandler({ + project: "ACME", + name: "Design System Rollout", + description: Option.some("Ship tokens and docs"), + status: Option.some("in_progress"), + startDate: Option.some("2026-04-01"), + targetDate: Option.some("2026-04-30"), + lead: Option.some("Jane Doe"), + }), + ); + } finally { + console.log = orig; + } + + expect(postedBody).toEqual({ + name: "Design System Rollout", + description: "Ship tokens and docs", + status: "in-progress", + start_date: "2026-04-01", + target_date: "2026-04-30", + lead: "mem1", + }); + expect(logs.join("\n")).toContain( + "Created module: Design System Rollout (mod4)", + ); + }); + + it("fails fast on invalid create dates before calling the API", async () => { + const { modulesCreateHandler } = await import("@/commands/modules"); + + await expect( + Effect.runPromise( + modulesCreateHandler({ + project: "ACME", + name: "Bad Dates", + description: Option.none(), + status: Option.none(), + startDate: Option.some("2026-02-31"), + targetDate: Option.none(), + lead: Option.none(), + }), + ), + ).rejects.toThrow("--start-date must be a valid date in YYYY-MM-DD format"); + }); +}); + describe("modulesDelete", () => { it("deletes a module by UUID", async () => { let deleted = false; diff --git a/tests/project-features.test.ts b/tests/project-features.test.ts index c11c1fc..72db6b1 100644 --- a/tests/project-features.test.ts +++ b/tests/project-features.test.ts @@ -279,3 +279,94 @@ describe("feature gates", () => { expect(agentsContent).toContain("plane projects current"); }); }); + +describe("SKILL.md import into AGENTS.md", () => { + it("imports SKILL.md when user answers 'y'", 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); + // host, workspace, token, project selection, skill import + promptResponses = ["", "", "", "1", "y"]; + await Effect.runPromise( + initHandler({ global: false, local: true }, "local"), + ); + const agentsPath = getLocalAgentsFilePath(repoDir); + const agentsContent = fs.readFileSync(agentsPath, "utf8"); + expect(agentsContent).toContain(""); + expect(agentsContent).toContain(""); + }); + + it("does not import SKILL.md when user declines (default N)", 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); + // empty response = default "N" + promptResponses = ["", "", "", "1", ""]; + await Effect.runPromise( + initHandler({ global: false, local: true }, "local"), + ); + const agentsPath = getLocalAgentsFilePath(repoDir); + const agentsContent = fs.readFileSync(agentsPath, "utf8"); + expect(agentsContent).not.toContain(""); + }); + + it("idempotently updates existing SKILL section on re-run when user confirms", 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); + // First run: import skill + promptResponses = ["", "", "", "1", "y"]; + await Effect.runPromise( + initHandler({ global: false, local: true }, "local"), + ); + // Second run: user confirms update (default Y when already present) + promptResponses = ["", "", "", "1", ""]; + await Effect.runPromise( + initHandler({ global: false, local: true }, "local"), + ); + const agentsPath = getLocalAgentsFilePath(repoDir); + const agentsContent = fs.readFileSync(agentsPath, "utf8"); + expect(agentsContent.match(//g)?.length).toBe( + 1, + ); + expect(agentsContent.match(//g)?.length).toBe( + 1, + ); + }); + + it("importSkillIntoAgentsFile creates section in a new file", async () => { + const { importSkillIntoAgentsFile, getLocalAgentsFilePath } = await import( + "@/project-agents" + ); + const repoDir = path.join(tempHome, "repo"); + fs.mkdirSync(repoDir, { recursive: true }); + importSkillIntoAgentsFile("# CLI Guide\nAll commands here.", repoDir); + const filePath = getLocalAgentsFilePath(repoDir); + const content = fs.readFileSync(filePath, "utf8"); + expect(content).toContain(""); + expect(content).toContain("# CLI Guide"); + expect(content).toContain(""); + }); + + it("hasSkillSectionInAgentsFile returns false before import", async () => { + const { hasSkillSectionInAgentsFile } = await import("@/project-agents"); + const repoDir = path.join(tempHome, "repo"); + fs.mkdirSync(repoDir, { recursive: true }); + expect(hasSkillSectionInAgentsFile(repoDir)).toBe(false); + }); + + it("hasSkillSectionInAgentsFile returns true after import", async () => { + const { importSkillIntoAgentsFile, hasSkillSectionInAgentsFile } = + await import("@/project-agents"); + const repoDir = path.join(tempHome, "repo"); + fs.mkdirSync(repoDir, { recursive: true }); + importSkillIntoAgentsFile("# CLI Guide", repoDir); + expect(hasSkillSectionInAgentsFile(repoDir)).toBe(true); + }); +});