diff --git a/CHANGELOG.md b/CHANGELOG.md index ccdd2f59..fdf597d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### Changed +- CLI help now explains how to create real Linear Markdown mentions and collapsible sections, so an agent driving the CLI without the bundled skill still gets it right. The ten commands that take a Markdown body carry the rule inline (`@name` mentions nobody; a plain Linear URL does) and point at a new `linear markdown` reference, and `team members --json` / `user list --json` say what the `url` field is for - `issue mine`, `issue query`, `issue start`, and `team states` now group statuses in the same order as the Linear app: by workflow state type, then by the team's configured position within that type. Issue listings previously ran the order backwards (canceled and done first), and every status list sorted on raw position alone, which stranded a late-positioned status such as an "In Review" at position 1002 after "Duplicate" instead of beside "In Progress" - when `--limit` truncates an issue listing, the retained issues are now the most actionable rather than the most recently closed. The Linear API cannot sort by a team's configured positions, so it still selects which issues are fetched; that selection changed from closed-first to open-first. A status this build does not recognize sorts after all known ones - an unquoted `$VAR` reference in a `LINEAR_`/`GH_`/`GITHUB_` value is now skipped with a warning rather than expanded. Expansion of an unset variable silently produced the string `"undefined"`, and a self-referential one hung. Quoted values are unaffected, since dotenv never expanded those diff --git a/skills/linear-cli/SKILL.md b/skills/linear-cli/SKILL.md index e35af0ea..c72c3d6e 100644 --- a/skills/linear-cli/SKILL.md +++ b/skills/linear-cli/SKILL.md @@ -237,6 +237,8 @@ linear label create linear label delete linear label list +linear markdown + linear milestone linear milestone create linear milestone delete @@ -281,6 +283,7 @@ linear user list - [initiative-update](references/initiative-update.md) - Manage initiative status updates (timeline posts) - [issue](references/issue.md) - Manage Linear issues - [label](references/label.md) - Manage Linear issue labels +- [markdown](references/markdown.md) - Linear-flavored Markdown: mentions and collapsible sections - [milestone](references/milestone.md) - Manage Linear project milestones - [project](references/project.md) - Manage Linear projects - [project-update](references/project-update.md) - Manage project status updates diff --git a/skills/linear-cli/references/commands.md b/skills/linear-cli/references/commands.md index 275bfeb4..27f34e56 100644 --- a/skills/linear-cli/references/commands.md +++ b/skills/linear-cli/references/commands.md @@ -11,6 +11,7 @@ - [initiative-update](./initiative-update.md) - Manage initiative status updates (timeline posts) - [issue](./issue.md) - Manage Linear issues - [label](./label.md) - Manage Linear issue labels +- [markdown](./markdown.md) - Linear-flavored Markdown: mentions and collapsible sections - [milestone](./milestone.md) - Manage Linear project milestones - [project](./project.md) - Manage Linear projects - [project-update](./project-update.md) - Manage project status updates diff --git a/skills/linear-cli/references/document.md b/skills/linear-cli/references/document.md index c4ea22a2..3b81e037 100644 --- a/skills/linear-cli/references/document.md +++ b/skills/linear-cli/references/document.md @@ -36,7 +36,12 @@ Usage: linear document create Description: - Create a new document + Create a new document + + Linear Markdown: a plain Linear URL creates a mention; `@name`, `@[Name](id)`, + and `[Name](url)` do not. Get a person's URL from the `url` field of + `linear team members --json`, or an issue's from `linear issue url `. + Run `linear markdown` for collapsible sections and the full reference. Options: @@ -112,7 +117,12 @@ Usage: linear document update Description: - Update an existing document + Update an existing document + + Linear Markdown: a plain Linear URL creates a mention; `@name`, `@[Name](id)`, + and `[Name](url)` do not. Get a person's URL from the `url` field of + `linear team members --json`, or an issue's from `linear issue url `. + Run `linear markdown` for collapsible sections and the full reference. Options: diff --git a/skills/linear-cli/references/initiative-update.md b/skills/linear-cli/references/initiative-update.md index 9d84f761..cf67b23e 100644 --- a/skills/linear-cli/references/initiative-update.md +++ b/skills/linear-cli/references/initiative-update.md @@ -33,7 +33,12 @@ Usage: linear initiative-update create Description: - Create a new status update for an initiative + Create a new status update for an initiative + + Linear Markdown: a plain Linear URL creates a mention; `@name`, `@[Name](id)`, + and `[Name](url)` do not. Get a person's URL from the `url` field of + `linear team members --json`, or an issue's from `linear issue url `. + Run `linear markdown` for collapsible sections and the full reference. Options: diff --git a/skills/linear-cli/references/issue.md b/skills/linear-cli/references/issue.md index 44591254..23fca896 100644 --- a/skills/linear-cli/references/issue.md +++ b/skills/linear-cli/references/issue.md @@ -152,7 +152,12 @@ Usage: linear issue comment add [issueId] Description: - Add a comment or reply; images uploaded with --attach render inline + Add a comment or reply; images uploaded with --attach render inline + + Linear Markdown: a plain Linear URL creates a mention; `@name`, `@[Name](id)`, + and `[Name](url)` do not. Get a person's URL from the `url` field of + `linear team members --json`, or an issue's from `linear issue url `. + Run `linear markdown` for collapsible sections and the full reference. Options: @@ -205,7 +210,12 @@ Usage: linear issue comment update Description: - Update an existing comment + Update an existing comment + + Linear Markdown: a plain Linear URL creates a mention; `@name`, `@[Name](id)`, + and `[Name](url)` do not. Get a person's URL from the `url` field of + `linear team members --json`, or an issue's from `linear issue url `. + Run `linear markdown` for collapsible sections and the full reference. Options: @@ -241,7 +251,12 @@ Usage: linear issue create Description: - Create a linear issue + Create a linear issue + + Linear Markdown: a plain Linear URL creates a mention; `@name`, `@[Name](id)`, + and `[Name](url)` do not. Get a person's URL from the `url` field of + `linear team members --json`, or an issue's from `linear issue url `. + Run `linear markdown` for collapsible sections and the full reference. Options: @@ -394,13 +409,16 @@ Description: Options: - -h, --help - Show this help. - --workspace - Target workspace (uses credentials) - --base - The branch into which you want your code merged - --draft - Create the pull request as a draft - -t, --title - Optional title for the pull request (Linear issue ID will be prefixed) - --web - Open the pull request in the browser after creating it - --head <branch> - The branch that contains commits for your pull request + -h, --help - Show this help. + --workspace <slug> - Target workspace (uses credentials) + --base <branch> - The branch into which you want your code merged + --draft - Create the pull request as a draft + -t, --title <title> - Optional title for the pull request (Linear issue ID will be prefixed) + --web - Open the pull request in the browser after creating it + --head <branch> - The branch that contains commits for your pull request + -T, --template <file> - Start the pull request body from this template file (the Linear issue URL is + appended) + --no-template - Ignore the pr_template config option for this pull request ``` ### query @@ -567,7 +585,12 @@ Usage: linear issue update [issueId] Description: - Update a linear issue + Update a linear issue + + Linear Markdown: a plain Linear URL creates a mention; `@name`, `@[Name](id)`, + and `[Name](url)` do not. Get a person's URL from the `url` field of + `linear team members <TEAM> --json`, or an issue's from `linear issue url <ID>`. + Run `linear markdown` for collapsible sections and the full reference. Options: diff --git a/skills/linear-cli/references/markdown.md b/skills/linear-cli/references/markdown.md new file mode 100644 index 00000000..8a618a77 --- /dev/null +++ b/skills/linear-cli/references/markdown.md @@ -0,0 +1,57 @@ +# markdown + +> Linear-flavored Markdown: mentions and collapsible sections + +## Usage + +``` +Usage: linear markdown + +Description: + + Linear-flavored Markdown: mentions and collapsible sections + + These rules apply to comment bodies, issue descriptions, document content, + project overviews, and status update bodies. + + MENTIONS + + A resource's plain Linear URL becomes a linked mention. A literal `@name`, an + `@[Name](id)`, or a Markdown link such as `[Name](url)` does not — it stays + plain text and notifies nobody. Put the bare URL in the body: + + https://linear.app/acme/profiles/someuser can you take a look? + + RESOLVING PEOPLE + + Look the person up in the relevant team first. The team can usually be + inferred from the issue identifier or the current directory: + + linear team members ENG --json + + Paste the selected member's `url` field verbatim. If the intended person is + not a member of that team, stop and confirm before searching the whole + workspace with `linear user list --json`; mentioning someone outside the team + is likely accidental. + + To mention an issue, use its URL the same way: + + linear issue url ENG-123 + + COLLAPSIBLE SECTIONS + + Open a section with `+++ [title]` and close it with `+++`: + + +++ [Server log] + + Markdown content that is initially hidden. + + +++ + + The square brackets around the title and the closing `+++` are both required. + +Options: + + -h, --help - Show this help. + --workspace <slug> - Target workspace (uses credentials) +``` diff --git a/skills/linear-cli/references/project-update.md b/skills/linear-cli/references/project-update.md index a23f9873..e128ecfe 100644 --- a/skills/linear-cli/references/project-update.md +++ b/skills/linear-cli/references/project-update.md @@ -33,7 +33,12 @@ Usage: linear project-update create <projectId> Description: - Create a new status update for a project + Create a new status update for a project + + Linear Markdown: a plain Linear URL creates a mention; `@name`, `@[Name](id)`, + and `[Name](url)` do not. Get a person's URL from the `url` field of + `linear team members <TEAM> --json`, or an issue's from `linear issue url <ID>`. + Run `linear markdown` for collapsible sections and the full reference. Options: diff --git a/skills/linear-cli/references/project.md b/skills/linear-cli/references/project.md index 9644da7c..af22f89a 100644 --- a/skills/linear-cli/references/project.md +++ b/skills/linear-cli/references/project.md @@ -36,7 +36,12 @@ Usage: linear project create Description: - Create a new Linear project + Create a new Linear project + + Linear Markdown: a plain Linear URL creates a mention; `@name`, `@[Name](id)`, + and `[Name](url)` do not. Get a person's URL from the `url` field of + `linear team members <TEAM> --json`, or an issue's from `linear issue url <ID>`. + Run `linear markdown` for collapsible sections and the full reference. Options: @@ -113,7 +118,12 @@ Usage: linear project update <projectId> Description: - Update a Linear project + Update a Linear project + + Linear Markdown: a plain Linear URL creates a mention; `@name`, `@[Name](id)`, + and `[Name](url)` do not. Get a person's URL from the `url` field of + `linear team members <TEAM> --json`, or an issue's from `linear issue url <ID>`. + Run `linear markdown` for collapsible sections and the full reference. Options: diff --git a/skills/linear-cli/references/team.md b/skills/linear-cli/references/team.md index a8df27ee..edd70797 100644 --- a/skills/linear-cli/references/team.md +++ b/skills/linear-cli/references/team.md @@ -136,10 +136,10 @@ Description: Options: - -h, --help - Show this help. - --workspace <slug> - Target workspace (uses credentials) - -a, --all - Include inactive members - -j, --json - Output as JSON + -h, --help - Show this help. + --workspace <slug> - Target workspace (uses credentials) + -a, --all - Include inactive members + -j, --json - Output as JSON; a member's url mentions them when pasted into Markdown ``` ### states diff --git a/skills/linear-cli/references/user.md b/skills/linear-cli/references/user.md index 64737839..9160fdfe 100644 --- a/skills/linear-cli/references/user.md +++ b/skills/linear-cli/references/user.md @@ -36,8 +36,10 @@ Description: Options: - -h, --help - Show this help. - --workspace <slug> - Target workspace (uses credentials) - -a, --all - Include inactive members - -j, --json - Output as JSON + -h, --help - Show this help. + --workspace <slug> - Target workspace (uses credentials) + -a, --all - Include inactive members + -j, --json - Output as JSON; a member's url mentions them when pasted into Markdown. This + searches the whole workspace — prefer `linear team members <TEAM>`, and + confirm before mentioning someone outside the team ``` diff --git a/src/cli.ts b/src/cli.ts index 041bcb38..22e95a74 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,6 +16,7 @@ import { documentCommand } from "./commands/document/document.ts" import { configCommand } from "./commands/config.ts" import { schemaCommand } from "./commands/schema.ts" import { apiCommand } from "./commands/api.ts" +import { markdownCommand } from "./commands/markdown.ts" import { setCliWorkspace } from "./config.ts" // Import config and credentials setup @@ -72,3 +73,4 @@ Environment Variables: .alias("configure") .command("schema", schemaCommand) .command("api", apiCommand) + .command("markdown", markdownCommand) diff --git a/src/commands/document/document-create.ts b/src/commands/document/document-create.ts index de46bdee..5d699134 100644 --- a/src/commands/document/document-create.ts +++ b/src/commands/document/document-create.ts @@ -19,6 +19,7 @@ import { resolveDocumentTarget, toDocumentTargetInput, } from "./attachment-target.ts" +import { withMarkdownHint } from "../../utils/markdown-help.ts" /** * Read content from stdin if available (piped input, with timeout) @@ -47,7 +48,7 @@ async function readContentFromStdin(): Promise<string | undefined> { export const createCommand = new Command() .name("create") - .description("Create a new document") + .description(withMarkdownHint("Create a new document")) .alias("c") .option("-t, --title <title:string>", "Document title (required)") .option("-c, --content <content:string>", "Markdown content (inline)") diff --git a/src/commands/document/document-update.ts b/src/commands/document/document-update.ts index bb2c4e21..e979f106 100644 --- a/src/commands/document/document-update.ts +++ b/src/commands/document/document-update.ts @@ -19,6 +19,7 @@ import { resolveDocumentTarget, toDocumentTargetInput, } from "./attachment-target.ts" +import { withMarkdownHint } from "../../utils/markdown-help.ts" const GetDocumentForEdit = gql(` query GetDocumentForEdit($id: String!) { @@ -177,7 +178,7 @@ async function readContentFromStdin(): Promise<string | undefined> { export const updateCommand = new Command() .name("update") - .description("Update an existing document") + .description(withMarkdownHint("Update an existing document")) .alias("u") .arguments("<documentId:string>") .option("-t, --title <title:string>", "New title for the document") diff --git a/src/commands/initiative-update/initiative-update-create.ts b/src/commands/initiative-update/initiative-update-create.ts index caffe603..00a2c493 100644 --- a/src/commands/initiative-update/initiative-update-create.ts +++ b/src/commands/initiative-update/initiative-update-create.ts @@ -11,6 +11,7 @@ import { } from "../../utils/errors.ts" import { getGraphQLClient } from "../../utils/graphql.ts" import { shouldShowSpinner } from "../../utils/hyperlink.ts" +import { withMarkdownHint } from "../../utils/markdown-help.ts" const HEALTH_VALUES = ["onTrack", "atRisk", "offTrack"] as const type HealthValue = (typeof HEALTH_VALUES)[number] @@ -98,7 +99,7 @@ async function resolveInitiativeId( export const createCommand = new Command() .name("create") - .description("Create a new status update for an initiative") + .description(withMarkdownHint("Create a new status update for an initiative")) .alias("c") .arguments("<initiativeId:string>") .option("--body <body:string>", "Update content (markdown)") diff --git a/src/commands/issue/issue-comment-add.ts b/src/commands/issue/issue-comment-add.ts index d8fa478e..7e317a06 100644 --- a/src/commands/issue/issue-comment-add.ts +++ b/src/commands/issue/issue-comment-add.ts @@ -12,6 +12,7 @@ import { } from "../../utils/upload.ts" import { shouldShowSpinner } from "../../utils/hyperlink.ts" import { CliError, handleError, ValidationError } from "../../utils/errors.ts" +import { withMarkdownHint } from "../../utils/markdown-help.ts" // Linear documents CommentCreateInput.id as "The identifier in UUID v4 format". const UUID_V4_REGEX = @@ -20,7 +21,9 @@ const UUID_V4_REGEX = export const commentAddCommand = new Command() .name("add") .description( - "Add a comment or reply; images uploaded with --attach render inline", + withMarkdownHint( + "Add a comment or reply; images uploaded with --attach render inline", + ), ) .arguments("[issueId:string]") .option("-b, --body <text:string>", "Comment body text") diff --git a/src/commands/issue/issue-comment-update.ts b/src/commands/issue/issue-comment-update.ts index 9ea6fd80..d4779a5d 100644 --- a/src/commands/issue/issue-comment-update.ts +++ b/src/commands/issue/issue-comment-update.ts @@ -3,10 +3,11 @@ import { Input } from "@cliffy/prompt" import { gql } from "../../__codegen__/gql.ts" import { getGraphQLClient } from "../../utils/graphql.ts" import { CliError, handleError, ValidationError } from "../../utils/errors.ts" +import { withMarkdownHint } from "../../utils/markdown-help.ts" export const commentUpdateCommand = new Command() .name("update") - .description("Update an existing comment") + .description(withMarkdownHint("Update an existing comment")) .arguments("<commentId:string>") .option("-b, --body <text:string>", "New comment body text") .option( diff --git a/src/commands/issue/issue-create.ts b/src/commands/issue/issue-create.ts index 8a5c8558..f2778891 100644 --- a/src/commands/issue/issue-create.ts +++ b/src/commands/issue/issue-create.ts @@ -37,6 +37,7 @@ import { NotFoundError, ValidationError, } from "../../utils/errors.ts" +import { withMarkdownHint } from "../../utils/markdown-help.ts" type IssueLabel = { id: string; name: string; color: string } type ProjectOption = { id: string; name: string } @@ -596,7 +597,7 @@ async function promptInteractiveIssueCreation( export const createCommand = new Command() .name("create") - .description("Create a linear issue") + .description(withMarkdownHint("Create a linear issue")) .option( "--start", "Start the issue after creation", diff --git a/src/commands/issue/issue-update.ts b/src/commands/issue/issue-update.ts index e1760e82..94324097 100644 --- a/src/commands/issue/issue-update.ts +++ b/src/commands/issue/issue-update.ts @@ -24,10 +24,11 @@ import { NotFoundError, ValidationError, } from "../../utils/errors.ts" +import { withMarkdownHint } from "../../utils/markdown-help.ts" export const updateCommand = new Command() .name("update") - .description("Update a linear issue") + .description(withMarkdownHint("Update a linear issue")) .arguments("[issueId:string]") .option( "-a, --assignee <assignee:string>", diff --git a/src/commands/markdown.ts b/src/commands/markdown.ts new file mode 100644 index 00000000..57543308 --- /dev/null +++ b/src/commands/markdown.ts @@ -0,0 +1,12 @@ +import { Command } from "@cliffy/command" +import { LINEAR_MARKDOWN_REFERENCE } from "../utils/markdown-help.ts" + +// The reference is both the description and the printed output: `--help` is +// what the skill-docs generator captures, while bare `linear markdown` prints +// it unindented so the `+++` syntax can be copied verbatim. +export const markdownCommand = new Command() + .name("markdown") + .description(LINEAR_MARKDOWN_REFERENCE) + .action(() => { + console.log(LINEAR_MARKDOWN_REFERENCE) + }) diff --git a/src/commands/project-update/project-update-create.ts b/src/commands/project-update/project-update-create.ts index b1c25284..b6ff23a5 100644 --- a/src/commands/project-update/project-update-create.ts +++ b/src/commands/project-update/project-update-create.ts @@ -12,6 +12,7 @@ import { NotFoundError, ValidationError, } from "../../utils/errors.ts" +import { withMarkdownHint } from "../../utils/markdown-help.ts" type ProjectUpdateHealth = "onTrack" | "atRisk" | "offTrack" @@ -55,7 +56,7 @@ const CreateProjectUpdate = gql(` export const createCommand = new Command() .name("create") - .description("Create a new status update for a project") + .description(withMarkdownHint("Create a new status update for a project")) .alias("c") .arguments("<projectId:string>") .option("--body <body:string>", "Update content (inline)") diff --git a/src/commands/project/project-create.ts b/src/commands/project/project-create.ts index a376f8a8..d88ee121 100644 --- a/src/commands/project/project-create.ts +++ b/src/commands/project/project-create.ts @@ -22,6 +22,7 @@ import { PROJECT_DESCRIPTION_MAX_LENGTH, resolveProjectDescription, } from "./project-description.ts" +import { withMarkdownHint } from "../../utils/markdown-help.ts" const CreateProject = gql(` mutation CreateProject($input: ProjectCreateInput!) { @@ -160,7 +161,7 @@ export async function resolveProjectContent( export const createCommand = new Command() .name("create") - .description("Create a new Linear project") + .description(withMarkdownHint("Create a new Linear project")) .option("-n, --name <name:string>", "Project name (required)") .option( "-d, --description <description:string>", diff --git a/src/commands/project/project-update.ts b/src/commands/project/project-update.ts index 209ff695..0a750238 100644 --- a/src/commands/project/project-update.ts +++ b/src/commands/project/project-update.ts @@ -19,6 +19,7 @@ import { PROJECT_DESCRIPTION_MAX_LENGTH, resolveProjectDescription, } from "./project-description.ts" +import { withMarkdownHint } from "../../utils/markdown-help.ts" const UpdateProject = gql(` mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) { @@ -60,7 +61,7 @@ const STATUS_TYPE_MAPPING: Record<string, string> = { export const updateCommand = new Command() .name("update") - .description("Update a Linear project") + .description(withMarkdownHint("Update a Linear project")) .arguments("<projectId:string>") .option("-n, --name <name:string>", "Project name") .option( diff --git a/src/commands/team/team-members.ts b/src/commands/team/team-members.ts index 6611c793..89a15533 100644 --- a/src/commands/team/team-members.ts +++ b/src/commands/team/team-members.ts @@ -9,7 +9,10 @@ export const membersCommand = new Command() .description("List team members") .arguments("[teamKey:string]") .option("-a, --all", "Include inactive members") - .option("-j, --json", "Output as JSON") + .option( + "-j, --json", + "Output as JSON; a member's url mentions them when pasted into Markdown", + ) .action(async ({ all, json }, teamKey?: string) => { const showSpinner = !json && shouldShowSpinner() let spinner: { start: () => void; stop: () => void } | null = null diff --git a/src/commands/user/user-list.ts b/src/commands/user/user-list.ts index 67eb653d..1069f826 100644 --- a/src/commands/user/user-list.ts +++ b/src/commands/user/user-list.ts @@ -8,7 +8,10 @@ export const listCommand = new Command() .name("list") .description("List members of the workspace") .option("-a, --all", "Include inactive members") - .option("-j, --json", "Output as JSON") + .option( + "-j, --json", + "Output as JSON; a member's url mentions them when pasted into Markdown. This searches the whole workspace \u2014 prefer `linear team members <TEAM>`, and confirm before mentioning someone outside the team", + ) .action(async ({ all, json }) => { const showSpinner = !json && shouldShowSpinner() let spinner: { start: () => void; stop: () => void } | null = null diff --git a/src/utils/markdown-help.ts b/src/utils/markdown-help.ts new file mode 100644 index 00000000..4ad3a553 --- /dev/null +++ b/src/utils/markdown-help.ts @@ -0,0 +1,71 @@ +// Linear-specific Markdown guidance, surfaced through `--help` so an agent +// driving this CLI without the bundled skill still learns it. Both strings live +// here so the ten Markdown-writing commands, the `linear markdown` reference, +// and the generated skill docs cannot drift apart. +// +// Cliffy pads description lines but does not re-wrap them, so the line breaks +// below are what renders. Keep every line under ~78 characters, and keep each +// example command whole on one line so it can be copied. + +/** + * Appended as a second paragraph to the description of every command that takes + * a rich Markdown body. It carries the rule an agent gets wrong when it has + * never been told (`@name` mentions nobody) plus the lookup it needs next, so a + * single `--help` read is enough for the common case. + */ +export const MARKDOWN_HINT = + `Linear Markdown: a plain Linear URL creates a mention; \`@name\`, \`@[Name](id)\`, +and \`[Name](url)\` do not. Get a person's URL from the \`url\` field of +\`linear team members <TEAM> --json\`, or an issue's from \`linear issue url <ID>\`. +Run \`linear markdown\` for collapsible sections and the full reference.` + +/** Joins a command's own summary line to the shared Markdown hint. */ +export function withMarkdownHint(description: string): string { + return `${description}\n\n${MARKDOWN_HINT}` +} + +// Used both as the `markdown` command's description and as what it prints. The +// description is what `deno task generate-skill-docs` captures (it reads +// `--help`), and printing it unindented keeps the `+++` block copyable. +// The first line doubles as the one-line summary in `linear --help`. +export const LINEAR_MARKDOWN_REFERENCE = + `Linear-flavored Markdown: mentions and collapsible sections + +These rules apply to comment bodies, issue descriptions, document content, +project overviews, and status update bodies. + +MENTIONS + +A resource's plain Linear URL becomes a linked mention. A literal \`@name\`, an +\`@[Name](id)\`, or a Markdown link such as \`[Name](url)\` does not — it stays +plain text and notifies nobody. Put the bare URL in the body: + +https://linear.app/acme/profiles/someuser can you take a look? + +RESOLVING PEOPLE + +Look the person up in the relevant team first. The team can usually be +inferred from the issue identifier or the current directory: + +linear team members ENG --json + +Paste the selected member's \`url\` field verbatim. If the intended person is +not a member of that team, stop and confirm before searching the whole +workspace with \`linear user list --json\`; mentioning someone outside the team +is likely accidental. + +To mention an issue, use its URL the same way: + +linear issue url ENG-123 + +COLLAPSIBLE SECTIONS + +Open a section with \`+++ [title]\` and close it with \`+++\`: + ++++ [Server log] + +Markdown content that is initially hidden. + ++++ + +The square brackets around the title and the closing \`+++\` are both required.` diff --git a/test/commands/document/__snapshots__/document-create.test.ts.snap b/test/commands/document/__snapshots__/document-create.test.ts.snap index 6411de49..d7809df9 100644 --- a/test/commands/document/__snapshots__/document-create.test.ts.snap +++ b/test/commands/document/__snapshots__/document-create.test.ts.snap @@ -7,7 +7,12 @@ Usage: create Description: - Create a new document + Create a new document + + Linear Markdown: a plain Linear URL creates a mention; \`@name\`, \`@[Name](id)\`, + and \`[Name](url)\` do not. Get a person's URL from the \`url\` field of + \`linear team members <TEAM> --json\`, or an issue's from \`linear issue url <ID>\`. + Run \`linear markdown\` for collapsible sections and the full reference. Options: diff --git a/test/commands/document/__snapshots__/document-update.test.ts.snap b/test/commands/document/__snapshots__/document-update.test.ts.snap index e2a5236c..78cfbfcb 100644 --- a/test/commands/document/__snapshots__/document-update.test.ts.snap +++ b/test/commands/document/__snapshots__/document-update.test.ts.snap @@ -7,7 +7,12 @@ Usage: update <documentId> Description: - Update an existing document + Update an existing document + + Linear Markdown: a plain Linear URL creates a mention; \`@name\`, \`@[Name](id)\`, + and \`[Name](url)\` do not. Get a person's URL from the \`url\` field of + \`linear team members <TEAM> --json\`, or an issue's from \`linear issue url <ID>\`. + Run \`linear markdown\` for collapsible sections and the full reference. Options: diff --git a/test/commands/initiative-update/__snapshots__/initiative-update-create.test.ts.snap b/test/commands/initiative-update/__snapshots__/initiative-update-create.test.ts.snap new file mode 100644 index 00000000..ade19388 --- /dev/null +++ b/test/commands/initiative-update/__snapshots__/initiative-update-create.test.ts.snap @@ -0,0 +1,28 @@ +export const snapshot = {}; + +snapshot[`Initiative Update Create Command - Help Text 1`] = ` +stdout: +" +Usage: create <initiativeId> + +Description: + + Create a new status update for an initiative + + Linear Markdown: a plain Linear URL creates a mention; \`@name\`, \`@[Name](id)\`, + and \`[Name](url)\` do not. Get a person's URL from the \`url\` field of + \`linear team members <TEAM> --json\`, or an issue's from \`linear issue url <ID>\`. + Run \`linear markdown\` for collapsible sections and the full reference. + +Options: + + -h, --help - Show this help. + --body <body> - Update content (markdown) + --body-file <path> - Read content from file + --health <health> - Health status (onTrack, atRisk, offTrack) + -i, --interactive - Interactive mode with prompts + +" +stderr: +"" +`; diff --git a/test/commands/initiative-update/initiative-update-create.test.ts b/test/commands/initiative-update/initiative-update-create.test.ts new file mode 100644 index 00000000..55acb52e --- /dev/null +++ b/test/commands/initiative-update/initiative-update-create.test.ts @@ -0,0 +1,16 @@ +import { snapshotTest } from "@cliffy/testing" +import { createCommand } from "../../../src/commands/initiative-update/initiative-update-create.ts" +import { commonDenoArgs } from "../../utils/test-helpers.ts" + +// The description carries the shared Linear Markdown guidance; this locks in how +// that second paragraph renders on a screen nothing else covers. +await snapshotTest({ + name: "Initiative Update Create Command - Help Text", + meta: import.meta, + colors: false, + args: ["--help"], + denoArgs: commonDenoArgs, + async fn() { + await createCommand.parse() + }, +}) diff --git a/test/commands/issue/__snapshots__/issue-comment-add.test.ts.snap b/test/commands/issue/__snapshots__/issue-comment-add.test.ts.snap index 33e15c8f..5c9fed07 100644 --- a/test/commands/issue/__snapshots__/issue-comment-add.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-comment-add.test.ts.snap @@ -34,7 +34,12 @@ Usage: add [issueId] Description: - Add a comment or reply; images uploaded with --attach render inline + Add a comment or reply; images uploaded with --attach render inline + + Linear Markdown: a plain Linear URL creates a mention; \`@name\`, \`@[Name](id)\`, + and \`[Name](url)\` do not. Get a person's URL from the \`url\` field of + \`linear team members <TEAM> --json\`, or an issue's from \`linear issue url <ID>\`. + Run \`linear markdown\` for collapsible sections and the full reference. Options: diff --git a/test/commands/issue/__snapshots__/issue-comment-update.test.ts.snap b/test/commands/issue/__snapshots__/issue-comment-update.test.ts.snap index cce75342..309594e9 100644 --- a/test/commands/issue/__snapshots__/issue-comment-update.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-comment-update.test.ts.snap @@ -1,5 +1,30 @@ export const snapshot = {}; +snapshot[`Issue Comment Update Command - Help Text 1`] = ` +stdout: +" +Usage: update <commentId> + +Description: + + Update an existing comment + + Linear Markdown: a plain Linear URL creates a mention; \`@name\`, \`@[Name](id)\`, + and \`[Name](url)\` do not. Get a person's URL from the \`url\` field of + \`linear team members <TEAM> --json\`, or an issue's from \`linear issue url <ID>\`. + Run \`linear markdown\` for collapsible sections and the full reference. + +Options: + + -h, --help - Show this help. + -b, --body <text> - New comment body text + --body-file <path> - Read comment body from a file (preferred for markdown content) + +" +stderr: +"" +`; + snapshot[`Issue Comment Update Command - With Body Flag 1`] = ` stdout: "✓ Comment updated diff --git a/test/commands/issue/__snapshots__/issue-create.test.ts.snap b/test/commands/issue/__snapshots__/issue-create.test.ts.snap index 6b71f5a1..29e84caa 100644 --- a/test/commands/issue/__snapshots__/issue-create.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-create.test.ts.snap @@ -7,7 +7,12 @@ Usage: create Description: - Create a linear issue + Create a linear issue + + Linear Markdown: a plain Linear URL creates a mention; \`@name\`, \`@[Name](id)\`, + and \`[Name](url)\` do not. Get a person's URL from the \`url\` field of + \`linear team members <TEAM> --json\`, or an issue's from \`linear issue url <ID>\`. + Run \`linear markdown\` for collapsible sections and the full reference. Options: diff --git a/test/commands/issue/__snapshots__/issue-update.test.ts.snap b/test/commands/issue/__snapshots__/issue-update.test.ts.snap index 8e4ce2a1..9f809584 100644 --- a/test/commands/issue/__snapshots__/issue-update.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-update.test.ts.snap @@ -7,7 +7,12 @@ Usage: update [issueId] Description: - Update a linear issue + Update a linear issue + + Linear Markdown: a plain Linear URL creates a mention; \`@name\`, \`@[Name](id)\`, + and \`[Name](url)\` do not. Get a person's URL from the \`url\` field of + \`linear team members <TEAM> --json\`, or an issue's from \`linear issue url <ID>\`. + Run \`linear markdown\` for collapsible sections and the full reference. Options: diff --git a/test/commands/issue/issue-comment-update.test.ts b/test/commands/issue/issue-comment-update.test.ts index 73b2e507..556f5307 100644 --- a/test/commands/issue/issue-comment-update.test.ts +++ b/test/commands/issue/issue-comment-update.test.ts @@ -5,6 +5,17 @@ import { setupMockLinearServer, } from "../../utils/test-helpers.ts" +await snapshotTest({ + name: "Issue Comment Update Command - Help Text", + meta: import.meta, + colors: false, + args: ["--help"], + denoArgs: commonDenoArgs, + async fn() { + await commentUpdateCommand.parse() + }, +}) + // Test updating a comment with body flag await snapshotTest({ name: "Issue Comment Update Command - With Body Flag", diff --git a/test/commands/markdown.test.ts b/test/commands/markdown.test.ts new file mode 100644 index 00000000..485cc74a --- /dev/null +++ b/test/commands/markdown.test.ts @@ -0,0 +1,53 @@ +import { assertMatch, assertStringIncludes } from "@std/assert" +import { markdownCommand } from "../../src/commands/markdown.ts" + +async function runMarkdownCommand(): Promise<string> { + const lines: string[] = [] + const originalLog = console.log + console.log = (...args: unknown[]) => { + lines.push(args.map(String).join(" ")) + } + + try { + await markdownCommand.parse([]) + } finally { + console.log = originalLog + } + + return lines.join("\n") +} + +// Asserted on substance rather than snapshotted: a snapshot of the reference +// can be regenerated away without anyone noticing a rule went missing. +Deno.test("markdown command - names every form that fails to mention", async () => { + const output = await runMarkdownCommand() + + assertStringIncludes(output, "plain Linear URL becomes a linked mention") + assertStringIncludes(output, "`@name`") + assertStringIncludes(output, "`@[Name](id)`") + assertStringIncludes(output, "`[Name](url)`") +}) + +Deno.test("markdown command - resolves people team-first, workspace on confirmation", async () => { + const output = await runMarkdownCommand() + + assertStringIncludes(output, "linear team members ENG --json") + assertStringIncludes(output, "`url` field verbatim") + assertStringIncludes( + output, + "stop and confirm before searching the whole\nworkspace with `linear user list --json`", + ) + assertStringIncludes(output, "linear issue url ENG-123") +}) + +// Matched as a whole block: asserting on `+++ [` and `+++` separately passes on +// an opener alone, which is the specific mistake the reference exists to fix. +Deno.test("markdown command - shows a complete collapsible section", async () => { + const output = await runMarkdownCommand() + + assertMatch(output, /^\+\+\+ \[.+\]\n\n.+\n\n\+\+\+$/m) + assertStringIncludes( + output, + "square brackets around the title and the closing", + ) +}) diff --git a/test/commands/project-update/__snapshots__/project-update-create.test.ts.snap b/test/commands/project-update/__snapshots__/project-update-create.test.ts.snap new file mode 100644 index 00000000..79bc63bf --- /dev/null +++ b/test/commands/project-update/__snapshots__/project-update-create.test.ts.snap @@ -0,0 +1,28 @@ +export const snapshot = {}; + +snapshot[`Project Update Create Command - Help Text 1`] = ` +stdout: +" +Usage: create <projectId> + +Description: + + Create a new status update for a project + + Linear Markdown: a plain Linear URL creates a mention; \`@name\`, \`@[Name](id)\`, + and \`[Name](url)\` do not. Get a person's URL from the \`url\` field of + \`linear team members <TEAM> --json\`, or an issue's from \`linear issue url <ID>\`. + Run \`linear markdown\` for collapsible sections and the full reference. + +Options: + + -h, --help - Show this help. + --body <body> - Update content (inline) + --body-file <path> - Read content from file + --health <health> - Project health status (onTrack, atRisk, offTrack) + -i, --interactive - Interactive mode with prompts + +" +stderr: +"" +`; diff --git a/test/commands/project-update/project-update-create.test.ts b/test/commands/project-update/project-update-create.test.ts new file mode 100644 index 00000000..fe0214e3 --- /dev/null +++ b/test/commands/project-update/project-update-create.test.ts @@ -0,0 +1,16 @@ +import { snapshotTest } from "@cliffy/testing" +import { createCommand } from "../../../src/commands/project-update/project-update-create.ts" +import { commonDenoArgs } from "../../utils/test-helpers.ts" + +// The description carries the shared Linear Markdown guidance; this locks in how +// that second paragraph renders on a screen nothing else covers. +await snapshotTest({ + name: "Project Update Create Command - Help Text", + meta: import.meta, + colors: false, + args: ["--help"], + denoArgs: commonDenoArgs, + async fn() { + await createCommand.parse() + }, +}) diff --git a/test/commands/project/__snapshots__/project-create.test.ts.snap b/test/commands/project/__snapshots__/project-create.test.ts.snap index be0c0cf3..f4fc9d2f 100644 --- a/test/commands/project/__snapshots__/project-create.test.ts.snap +++ b/test/commands/project/__snapshots__/project-create.test.ts.snap @@ -7,7 +7,12 @@ Usage: create Description: - Create a new Linear project + Create a new Linear project + + Linear Markdown: a plain Linear URL creates a mention; \`@name\`, \`@[Name](id)\`, + and \`[Name](url)\` do not. Get a person's URL from the \`url\` field of + \`linear team members <TEAM> --json\`, or an issue's from \`linear issue url <ID>\`. + Run \`linear markdown\` for collapsible sections and the full reference. Options: diff --git a/test/commands/project/__snapshots__/project-update.test.ts.snap b/test/commands/project/__snapshots__/project-update.test.ts.snap index 4f2f3aa5..20b27df9 100644 --- a/test/commands/project/__snapshots__/project-update.test.ts.snap +++ b/test/commands/project/__snapshots__/project-update.test.ts.snap @@ -7,7 +7,12 @@ Usage: update <projectId> Description: - Update a Linear project + Update a Linear project + + Linear Markdown: a plain Linear URL creates a mention; \`@name\`, \`@[Name](id)\`, + and \`[Name](url)\` do not. Get a person's URL from the \`url\` field of + \`linear team members <TEAM> --json\`, or an issue's from \`linear issue url <ID>\`. + Run \`linear markdown\` for collapsible sections and the full reference. Options: diff --git a/test/commands/team/__snapshots__/team-members.test.ts.snap b/test/commands/team/__snapshots__/team-members.test.ts.snap index 81fa3280..f0ebeadb 100644 --- a/test/commands/team/__snapshots__/team-members.test.ts.snap +++ b/test/commands/team/__snapshots__/team-members.test.ts.snap @@ -11,9 +11,9 @@ Description: Options: - -h, --help - Show this help. - -a, --all - Include inactive members - -j, --json - Output as JSON + -h, --help - Show this help. + -a, --all - Include inactive members + -j, --json - Output as JSON; a member's url mentions them when pasted into Markdown " stderr: diff --git a/test/commands/user/__snapshots__/user-list.test.ts.snap b/test/commands/user/__snapshots__/user-list.test.ts.snap index ae5f211c..41787305 100644 --- a/test/commands/user/__snapshots__/user-list.test.ts.snap +++ b/test/commands/user/__snapshots__/user-list.test.ts.snap @@ -11,9 +11,11 @@ Description: Options: - -h, --help - Show this help. - -a, --all - Include inactive members - -j, --json - Output as JSON + -h, --help - Show this help. + -a, --all - Include inactive members + -j, --json - Output as JSON; a member's url mentions them when pasted into Markdown. This + searches the whole workspace — prefer \`linear team members <TEAM>\`, and + confirm before mentioning someone outside the team " stderr: diff --git a/test/main_test.ts b/test/main_test.ts index 829b23ae..2af3c411 100644 --- a/test/main_test.ts +++ b/test/main_test.ts @@ -2,6 +2,7 @@ import { assertEquals, assertStringIncludes } from "@std/assert" import { getGraphQLClient } from "../src/utils/graphql.ts" import { cli } from "../src/cli.ts" import { configCommand } from "../src/commands/config.ts" +import { markdownCommand } from "../src/commands/markdown.ts" // Regression guard for #245: `configure` is a natural name users (and the // CLI's own help text) reach for, so it resolves to the canonical `config` @@ -10,6 +11,13 @@ Deno.test("cli - `configure` is an alias for the config command", () => { assertEquals(cli.getCommand("configure"), configCommand) }) +// An exported but unregistered command type-checks and passes its own tests +// while being unreachable from the CLI, which is the whole point of the +// Markdown reference: agents have to be able to find it. +Deno.test("cli - `markdown` reference is reachable as a top-level command", () => { + assertEquals(cli.getCommand("markdown"), markdownCommand) +}) + // Mock fetch function for testing const originalFetch = globalThis.fetch diff --git a/test/utils/markdown-help.test.ts b/test/utils/markdown-help.test.ts new file mode 100644 index 00000000..b8edaa35 --- /dev/null +++ b/test/utils/markdown-help.test.ts @@ -0,0 +1,132 @@ +import { assertEquals, assertStringIncludes } from "@std/assert" +import { cli } from "../../src/cli.ts" +import { membersCommand } from "../../src/commands/team/team-members.ts" +import { listCommand } from "../../src/commands/user/user-list.ts" + +// Cliffy's Command type carries its options in its generic parameters, so a +// concrete command is not assignable to a bare `Command`. Introspecting the +// tree only needs this much of the shape. +interface CommandOption { + flags: string[] + description: string +} + +interface IntrospectableCommand { + getName(): string + getDescription(): string + getCommands(): IntrospectableCommand[] + getOptions(hidden?: boolean): CommandOption[] + getOption(name: string): CommandOption | undefined +} + +// Declaring one of these means the command takes a body Linear renders as +// Markdown, which is exactly where the mention and collapsible rules apply. +const MARKDOWN_BODY_OPTIONS = [ + "--body-file", + "--description-file", + "--content-file", + "--content", +] + +// Pinned so that adding a Markdown-bodied command (or a --body-file flag to an +// existing one) forces a deliberate decision about the guidance rather than +// silently shipping a command an agent will misuse. +const EXPECTED_MARKDOWN_COMMANDS = [ + "document create", + "document update", + "initiative-update create", + "issue comment add", + "issue comment update", + "issue create", + "issue update", + "project create", + "project update", + "project-update create", +] + +function findMarkdownCommands( + command: IntrospectableCommand, + path: string[] = [], +): { path: string; command: IntrospectableCommand }[] { + const found: { path: string; command: IntrospectableCommand }[] = [] + + for (const sub of command.getCommands()) { + const subPath = [...path, sub.getName()] + const flags = sub.getOptions(true).flatMap((option) => option.flags) + + if (MARKDOWN_BODY_OPTIONS.some((flag) => flags.includes(flag))) { + found.push({ path: subPath.join(" "), command: sub }) + } + + found.push(...findMarkdownCommands(sub, subPath)) + } + + return found +} + +Deno.test("markdown help - every Markdown-bodied command is accounted for", () => { + const discovered = findMarkdownCommands(cli).map((entry) => entry.path).sort() + + assertEquals(discovered, EXPECTED_MARKDOWN_COMMANDS) +}) + +// Asserting the substance rather than `includes(MARKDOWN_HINT)`: comparing a +// command's description against the same constant it was built from still +// passes if the shared hint is gutted to a bare "see `linear markdown`". +Deno.test("markdown help - every Markdown-bodied command teaches real mentions", () => { + for (const { path, command } of findMarkdownCommands(cli)) { + const description = command.getDescription() + + assertStringIncludes( + description, + "a plain Linear URL creates a mention", + `${path} does not state how mentions are created`, + ) + for (const wrongForm of ["`@name`", "`@[Name](id)`", "`[Name](url)`"]) { + assertStringIncludes( + description, + wrongForm, + `${path} does not warn that ${wrongForm} fails to mention anyone`, + ) + } + assertStringIncludes( + description, + "linear team members <TEAM> --json", + `${path} does not say how to look a person's URL up`, + ) + assertStringIncludes( + description, + "linear markdown", + `${path} does not point at the full reference`, + ) + } +}) + +function jsonOptionDescription(command: IntrospectableCommand): string { + const option = command.getOption("json") + if (option == null) throw new Error("expected a --json option") + return option.description +} + +// The `url` field only exists in --json output, so this is where an agent finds +// out what it is for. +Deno.test("markdown help - member listings explain the url field", () => { + assertStringIncludes( + jsonOptionDescription(membersCommand), + "url mentions them when pasted into Markdown", + ) + assertStringIncludes( + jsonOptionDescription(listCommand), + "url mentions them when pasted into Markdown", + ) +}) + +// Mentioning someone who is not on the team is usually a mistake, so the +// workspace-wide listing must not read as an equal alternative to the team one. +Deno.test("markdown help - workspace listing keeps the team-first safeguard", () => { + const description = jsonOptionDescription(listCommand) + + assertStringIncludes(description, "searches the whole workspace") + assertStringIncludes(description, "prefer `linear team members <TEAM>`") + assertStringIncludes(description, "confirm before mentioning someone outside") +})