From 8ab75d57e373b614f62eb77b2c9f19712ad2813b Mon Sep 17 00:00:00 2001 From: Daniel Wise Date: Fri, 8 May 2026 22:23:18 -0700 Subject: [PATCH] feat(cli): show per-resource filters, examples, input field hints in subcommand help Closes #7 --- packages/cli/src/commands/resource.ts | 29 +- packages/cli/src/help/resource-help.ts | 453 +++++++++++++++++++++++++ packages/cli/tests/help.test.ts | 91 +++++ 3 files changed, 567 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/help/resource-help.ts diff --git a/packages/cli/src/commands/resource.ts b/packages/cli/src/commands/resource.ts index edf6604..1bd04cf 100644 --- a/packages/cli/src/commands/resource.ts +++ b/packages/cli/src/commands/resource.ts @@ -7,6 +7,7 @@ import type { import { errorEnvelope, normalizeError, successEnvelope } from "@wiseiodev/linear-core"; import type { Command } from "commander"; import { renderEnvelope } from "../formatters/output.js"; +import { getResourceHelpTexts } from "../help/resource-help.js"; import { getGlobalOptions } from "../runtime/options.js"; import { parseJsonInput } from "./input.js"; @@ -61,17 +62,25 @@ export function registerResourceCommand( description: string, handlers: ResourceHandlers, authManager: AuthManager, -): void { +): Command { const command = program.command(entity).description(description); + const helpTexts = getResourceHelpTexts(entity); + + if (helpTexts.resource) { + command.addHelpText("after", helpTexts.resource); + } if (handlers.list) { const listHandler = handlers.list; - command + const listCommand = command .command("list") .description(`List ${entity}`) .action(async (_, cmd) => executeAction(entity, "list", cmd, () => listHandler(authManager, cmd)), ); + if (helpTexts.list) { + listCommand.addHelpText("after", helpTexts.list); + } } if (handlers.get) { @@ -87,10 +96,10 @@ export function registerResourceCommand( if (handlers.create) { const createHandler = handlers.create; - command + const createCommand = command .command("create") .description(`Create ${entity}`) - .option("--input ", "Inline JSON payload") + .option("--input ", "Inline JSON payload (see help for accepted fields)") .option("--input-file ", "JSON payload file") .action(async (opts, cmd) => executeAction(entity, "create", cmd, async () => { @@ -98,15 +107,18 @@ export function registerResourceCommand( return createHandler(authManager, payload, cmd); }), ); + if (helpTexts.create) { + createCommand.addHelpText("after", helpTexts.create); + } } if (handlers.update) { const updateHandler = handlers.update; - command + const updateCommand = command .command("update") .description(`Update ${entity}`) .argument("", "Entity id") - .option("--input ", "Inline JSON payload") + .option("--input ", "Inline JSON payload (see help for accepted fields)") .option("--input-file ", "JSON payload file") .action(async (id, opts, cmd) => executeAction(entity, "update", cmd, async () => { @@ -114,6 +126,9 @@ export function registerResourceCommand( return updateHandler(authManager, id, payload, cmd); }), ); + if (helpTexts.update) { + updateCommand.addHelpText("after", helpTexts.update); + } } if (handlers.delete) { @@ -126,4 +141,6 @@ export function registerResourceCommand( executeAction(entity, "delete", cmd, () => deleteHandler(authManager, id, cmd)), ); } + + return command; } diff --git a/packages/cli/src/help/resource-help.ts b/packages/cli/src/help/resource-help.ts new file mode 100644 index 0000000..a636452 --- /dev/null +++ b/packages/cli/src/help/resource-help.ts @@ -0,0 +1,453 @@ +const SCHEMA_URL = "https://github.com/linear/linear/blob/master/packages/sdk/src/schema.graphql"; + +const FILTER_FLAG_DESCRIPTIONS: Record = { + team: "--team Default team key", + mine: "--mine Limit to items assigned to the authenticated user", + project: "--project Project filter", + cycle: "--cycle Cycle filter", + state: "--state State or type filter", + assignee: '--assignee Assignee filter (use "me" for the authenticated user)', + label: "--label Label filter", + priority: "--priority Priority filter", + status: "--status Status or health filter", + filter: "--filter Lightweight filter expression, e.g. estimate>2", + sort: "--sort Sort field, prefix with - for descending", +}; + +type FilterFlag = + | "team" + | "mine" + | "project" + | "cycle" + | "state" + | "assignee" + | "label" + | "priority" + | "status" + | "filter" + | "sort"; + +type Pagination = "full" | "basic" | "none"; + +const PAGINATION_LINES: Record = { + full: [ + "--limit Page size", + "--cursor Pagination cursor (returned in JSON envelope)", + "--all Drain all pages before filtering", + ], + basic: [ + "--limit Page size", + "--cursor Pagination cursor (returned in JSON envelope)", + ], + none: [], +}; + +const OUTPUT_LINES: readonly string[] = [ + "--view Human output preset: table | detail | dense", + "--fields Comma-separated field selection", + "--json Strict machine output", +]; + +function inputDocsHint(entity: string): string { + return `JSON input fields: + See Linear's GraphQL schema for accepted fields: + ${SCHEMA_URL} + Search the schema for the relevant input type, e.g. ${graphqlInputTypeFor(entity)}. + Run: linear docs --open`; +} + +function graphqlInputTypeFor(entity: string): string { + const map: Record = { + issues: "IssueCreateInput / IssueUpdateInput", + customers: "CustomerCreateInput / CustomerUpdateInput", + "customer-needs": "CustomerNeedCreateInput / CustomerNeedUpdateInput", + initiatives: "InitiativeCreateInput / InitiativeUpdateInput", + "initiative-updates": "InitiativeUpdateCreateInput / InitiativeUpdateUpdateInput", + projects: "ProjectCreateInput / ProjectUpdateInput", + milestones: "ProjectMilestoneCreateInput / ProjectMilestoneUpdateInput", + "project-updates": "ProjectUpdateCreateInput / ProjectUpdateUpdateInput", + documents: "DocumentCreateInput / DocumentUpdateInput", + cycles: "CycleCreateInput / CycleUpdateInput", + teams: "TeamCreateInput / TeamUpdateInput", + users: "UserUpdateInput", + labels: "IssueLabelCreateInput / IssueLabelUpdateInput", + comments: "CommentCreateInput / CommentUpdateInput", + attachments: "AttachmentCreateInput / AttachmentUpdateInput", + templates: "TemplateCreateInput / TemplateUpdateInput", + notifications: "NotificationUpdateInput", + states: "WorkflowStateCreateInput / WorkflowStateUpdateInput", + }; + return map[entity] ?? `${entity} input types`; +} + +interface CreateHelp { + readonly required: string; + readonly examples: readonly string[]; +} + +interface UpdateHelp { + readonly examples: readonly string[]; +} + +interface ListHelp { + readonly filters: readonly FilterFlag[]; + readonly pagination: Pagination; + readonly examples: readonly string[]; +} + +interface ResourceExamples { + readonly resource?: readonly string[]; + readonly list?: ListHelp; + readonly create?: CreateHelp; + readonly update?: UpdateHelp; +} + +const resourceExamples: Record = { + issues: { + list: { + filters: [ + "team", + "mine", + "project", + "cycle", + "state", + "assignee", + "label", + "priority", + "filter", + "sort", + ], + pagination: "full", + examples: [ + 'linear issues list --limit 10 --state "In Progress" --assignee me', + "linear issues list --mine --view detail --fields identifier,title,assigneeName", + "linear issues list --all --json", + ], + }, + create: { + required: "teamId plus (title or templateId)", + examples: [ + 'linear issues create --input \'{"teamId":"","title":"My issue"}\'', + 'linear issues create --template "Bug Report" --input \'{"teamId":""}\' --json', + ], + }, + update: { + examples: [ + "linear issues update --input '{\"priority\":2}'", + "linear issues update --input-file payload.json --json", + ], + }, + }, + customers: { + list: { + filters: ["mine", "assignee", "status", "filter", "sort"], + pagination: "full", + examples: ["linear customers list --limit 25 --json", "linear customers list --view detail"], + }, + create: { + required: "name", + examples: ['linear customers create --input \'{"name":"Acme Inc."}\''], + }, + update: { + examples: ['linear customers update --input \'{"name":"Acme Holdings"}\''], + }, + }, + "customer-needs": { + list: { + filters: ["project", "priority", "filter", "sort"], + pagination: "full", + examples: ["linear customer-needs list --limit 25 --json"], + }, + create: { + required: "any non-empty payload (e.g. customerId, body)", + examples: [ + 'linear customer-needs create --input \'{"customerId":"","body":"Wants SSO"}\'', + ], + }, + update: { + examples: ["linear customer-needs update --input '{\"priority\":1}'"], + }, + }, + initiatives: { + list: { + filters: [], + pagination: "basic", + examples: ["linear initiatives list --limit 25 --json"], + }, + create: { + required: "name", + examples: ['linear initiatives create --input \'{"name":"Q3 platform"}\''], + }, + update: { + examples: ['linear initiatives update --input \'{"description":"Updated"}\''], + }, + }, + "initiative-updates": { + list: { + filters: ["filter", "sort"], + pagination: "full", + examples: ["linear initiative-updates list --limit 25 --json"], + }, + create: { + required: "body", + examples: [ + 'linear initiative-updates create --input \'{"initiativeId":"","body":"Status update"}\'', + ], + }, + update: { + examples: ['linear initiative-updates update --input \'{"body":"Edited"}\''], + }, + }, + projects: { + list: { + filters: ["project", "status", "filter", "sort"], + pagination: "full", + examples: [ + "linear projects list --limit 25 --view detail", + 'linear projects list --status "On track" --json', + ], + }, + create: { + required: "name", + examples: ['linear projects create --input \'{"name":"Roadmap","teamIds":[""]}\''], + }, + update: { + examples: ['linear projects update --input \'{"state":"completed"}\''], + }, + }, + milestones: { + list: { + filters: ["project", "status", "filter", "sort"], + pagination: "full", + examples: ["linear milestones list --project --json"], + }, + create: { + required: "name", + examples: ['linear milestones create --input \'{"name":"Beta","projectId":""}\''], + }, + update: { + examples: ['linear milestones update --input \'{"targetDate":"2026-06-01"}\''], + }, + }, + "project-updates": { + list: { + filters: ["project", "status", "filter", "sort"], + pagination: "full", + examples: ["linear project-updates list --project --json"], + }, + create: { + required: "body", + examples: [ + 'linear project-updates create --input \'{"projectId":"","body":"Weekly update"}\'', + ], + }, + update: { + examples: ['linear project-updates update --input \'{"body":"Edited"}\''], + }, + }, + documents: { + list: { + filters: [], + pagination: "basic", + examples: ["linear documents list --limit 25 --json"], + }, + create: { + required: "title", + examples: ['linear documents create --input \'{"title":"Spec","content":"..."}\''], + }, + update: { + examples: ['linear documents update --input \'{"title":"New title"}\''], + }, + }, + cycles: { + list: { + filters: [], + pagination: "basic", + examples: ["linear cycles list --limit 25 --json"], + }, + create: { + required: "teamId", + examples: [ + 'linear cycles create --input \'{"teamId":"","startsAt":"2026-06-01","endsAt":"2026-06-15"}\'', + ], + }, + update: { + examples: ['linear cycles update --input \'{"name":"Sprint 24"}\''], + }, + }, + teams: { + list: { + filters: [], + pagination: "basic", + examples: ["linear teams list --limit 50 --json"], + }, + create: { + required: "name and key", + examples: ['linear teams create --input \'{"name":"Engineering","key":"ENG"}\''], + }, + update: { + examples: ['linear teams update --input \'{"description":"Platform team"}\''], + }, + }, + users: { + list: { + filters: [], + pagination: "basic", + examples: ["linear users list --limit 50 --json"], + }, + update: { + examples: ["linear users update --input '{\"active\":false}'"], + }, + }, + labels: { + list: { + filters: [], + pagination: "basic", + examples: ["linear labels list --limit 50 --json"], + }, + create: { + required: "name", + examples: [ + 'linear labels create --input \'{"name":"bug","color":"#ef4444","teamId":""}\'', + ], + }, + update: { + examples: ['linear labels update --input \'{"color":"#10b981"}\''], + }, + }, + comments: { + list: { + filters: [], + pagination: "basic", + examples: ["linear comments list --limit 25 --json"], + }, + create: { + required: "body", + examples: ['linear comments create --input \'{"issueId":"","body":"Looks good!"}\''], + }, + update: { + examples: ['linear comments update --input \'{"body":"Edited"}\''], + }, + }, + attachments: { + list: { + filters: [], + pagination: "basic", + examples: ["linear attachments list --limit 25 --json"], + }, + create: { + required: "title and url", + examples: [ + 'linear attachments create --input \'{"issueId":"","title":"PR","url":"https://..."}\'', + ], + }, + update: { + examples: ['linear attachments update --input \'{"title":"Renamed"}\''], + }, + }, + templates: { + list: { + filters: [], + pagination: "none", + examples: ["linear templates list --json", "linear templates list --view detail"], + }, + create: { + required: "name, type, and templateData", + examples: [ + 'linear templates create --input \'{"name":"Bug","type":"issue","templateData":{}}\'', + ], + }, + update: { + examples: ['linear templates update --input \'{"name":"Bug v2"}\''], + }, + }, + notifications: { + list: { + filters: ["state", "status", "filter", "sort"], + pagination: "full", + examples: ["linear notifications list --limit 25 --json"], + }, + update: { + examples: ['linear notifications update --input \'{"readAt":"2026-05-08T00:00:00Z"}\''], + }, + }, + states: { + list: { + filters: [], + pagination: "basic", + examples: ["linear states list --limit 50 --json"], + }, + create: { + required: "name and teamId", + examples: [ + 'linear states create --input \'{"name":"In QA","type":"started","color":"#facc15","teamId":""}\'', + ], + }, + update: { + examples: ['linear states update --input \'{"name":"In Review"}\''], + }, + }, +}; + +function joinExamples(lines: readonly string[]): string { + return lines.map((line) => ` ${line}`).join("\n"); +} + +function indentLines(lines: readonly string[]): string { + return lines.map((line) => ` ${line}`).join("\n"); +} + +function buildOptionsBlock(help: ListHelp): string { + const filterLines = help.filters.map((flag) => FILTER_FLAG_DESCRIPTIONS[flag]); + const paginationLines = PAGINATION_LINES[help.pagination]; + const allLines = [...filterLines, ...paginationLines, ...OUTPUT_LINES]; + return indentLines(allLines); +} + +function listHelp(help: ListHelp): string { + const heading = + help.filters.length > 0 + ? "Filters, pagination, and output (inherited globals):" + : help.pagination === "none" + ? "Output (inherited globals):" + : "Pagination and output (inherited globals):"; + + return `\n${heading}\n${buildOptionsBlock(help)}\n\nRun \`linear --help\` to see every global option.\n\nExamples:\n${joinExamples(help.examples)}`; +} + +function createHelp(entity: string, help: CreateHelp): string { + return `\nRequired input fields: ${help.required}\n\nExamples:\n${joinExamples(help.examples)}\n\n${inputDocsHint(entity)}`; +} + +function updateHelp(entity: string, help: UpdateHelp): string { + return `\nUpdate accepts any non-empty JSON payload.\n\nExamples:\n${joinExamples(help.examples)}\n\n${inputDocsHint(entity)}`; +} + +export interface ResourceHelpTexts { + resource?: string; + list?: string; + create?: string; + update?: string; +} + +export function getResourceHelpTexts(entity: string): ResourceHelpTexts { + const examples = resourceExamples[entity]; + if (!examples) { + return {}; + } + + const texts: ResourceHelpTexts = {}; + if (examples.list) { + texts.list = listHelp(examples.list); + } + if (examples.create) { + texts.create = createHelp(entity, examples.create); + } + if (examples.update) { + texts.update = updateHelp(entity, examples.update); + } + if (examples.resource) { + texts.resource = `\nExamples:\n${joinExamples(examples.resource)}`; + } + return texts; +} diff --git a/packages/cli/tests/help.test.ts b/packages/cli/tests/help.test.ts index 08f5244..b7c2147 100644 --- a/packages/cli/tests/help.test.ts +++ b/packages/cli/tests/help.test.ts @@ -111,4 +111,95 @@ describe("help output", () => { expect(myWorkHelp).toContain("assigned"); expect(triageHelp).toContain("triage"); }); + + test("subcommands surface filters, examples, and input field hints", () => { + const program = createProgram(); + const issuesCommand = program.commands.find((command) => command.name() === "issues"); + const issuesListCommand = issuesCommand?.commands.find((command) => command.name() === "list"); + const issuesCreateCommand = issuesCommand?.commands.find( + (command) => command.name() === "create", + ); + const issuesUpdateCommand = issuesCommand?.commands.find( + (command) => command.name() === "update", + ); + const projectsCommand = program.commands.find((command) => command.name() === "projects"); + const projectsCreateCommand = projectsCommand?.commands.find( + (command) => command.name() === "create", + ); + const customersCommand = program.commands.find((command) => command.name() === "customers"); + const customersListCommand = customersCommand?.commands.find( + (command) => command.name() === "list", + ); + + const issuesListHelp = captureRenderedHelp(issuesListCommand); + const issuesCreateHelp = captureRenderedHelp(issuesCreateCommand); + const issuesUpdateHelp = captureRenderedHelp(issuesUpdateCommand); + const projectsCreateHelp = captureRenderedHelp(projectsCreateCommand); + const customersListHelp = captureRenderedHelp(customersListCommand); + + expect(issuesListHelp).toContain("Filters, pagination, and output"); + expect(issuesListHelp).toContain("--team"); + expect(issuesListHelp).toContain("--limit"); + expect(issuesListHelp).toContain("--all"); + expect(issuesListHelp).toContain("Examples:"); + expect(issuesListHelp).toContain("linear issues list"); + + expect(issuesCreateHelp).toContain("Required input fields"); + expect(issuesCreateHelp).toContain("teamId"); + expect(issuesCreateHelp).toContain("IssueCreateInput"); + expect(issuesCreateHelp).toContain("schema.graphql"); + + expect(issuesUpdateHelp).toContain("Examples:"); + expect(issuesUpdateHelp).toContain("linear issues update"); + expect(issuesUpdateHelp).toContain("IssueUpdateInput"); + + expect(projectsCreateHelp).toContain("Required input fields: name"); + expect(projectsCreateHelp).toContain("ProjectCreateInput"); + + expect(customersListHelp).toContain("Filters, pagination, and output"); + expect(customersListHelp).toContain("linear customers list"); + }); + + test("list help only advertises options that the handler honors", () => { + const program = createProgram(); + + const findCustomHelpBlock = (entity: string): string => { + const command = program.commands.find((c) => c.name() === entity); + const list = command?.commands.find((c) => c.name() === "list"); + const rendered = captureRenderedHelp(list); + const headingMatch = rendered.match( + /(Filters[^\n]*|Pagination and output|Output)\s*\(inherited globals\):/, + ); + if (!headingMatch || headingMatch.index === undefined) { + return ""; + } + const tail = rendered.slice(headingMatch.index); + const examplesIndex = tail.indexOf("Examples:"); + return examplesIndex === -1 ? tail : tail.slice(0, examplesIndex); + }; + + const cyclesBlock = findCustomHelpBlock("cycles"); + expect(cyclesBlock).toContain("Pagination and output"); + expect(cyclesBlock).toContain("--limit"); + expect(cyclesBlock).not.toContain("--team "); + expect(cyclesBlock).not.toContain("--all"); + expect(cyclesBlock).not.toContain("--filter "); + + const templatesBlock = findCustomHelpBlock("templates"); + expect(templatesBlock).toContain("Output (inherited globals):"); + expect(templatesBlock).not.toContain("--limit"); + expect(templatesBlock).not.toContain("--cursor"); + expect(templatesBlock).not.toContain("--team "); + + const labelsBlock = findCustomHelpBlock("labels"); + expect(labelsBlock).not.toContain("--team "); + expect(labelsBlock).not.toContain("--all"); + expect(labelsBlock).toContain("--limit"); + + const projectsBlock = findCustomHelpBlock("projects"); + expect(projectsBlock).toContain("--status"); + expect(projectsBlock).toContain("--all"); + expect(projectsBlock).not.toContain("--team "); + expect(projectsBlock).not.toContain("--assignee"); + }); });