Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions skills/linear-axi/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: linear-axi
description: "Operate Linear (linear.app) through the linear-axi CLI - list and view issues, create, update, and delete issues, nest work as sub-issues, post comments and read comment threads, list teams, list/filter projects, list cycles, and list labels. Use whenever a task touches Linear: triaging or filing issues, changing issue state, setting priority, assigning issues or managing their labels, breaking an issue into sub-issues with `issue create --parent` and reading them back with `issue view --full`, commenting on or reading an issue discussion via `comment list`, discovering team keys via `teams`, scoping work to a project via `projects` and `issues --project`, to a cycle via `cycles` and `issues --cycle`, assigning issues to a project or cycle with `issue create/update --project <name>` and `--cycle current|<number>`, or finding label names via `labels` for `issue create --label`, `issue update --label`, and `issues --label`."
description: "Operate Linear (linear.app) through the linear-axi CLI - list and view issues, create, update, and delete issues, nest work as sub-issues, post, edit, and delete comments and read comment threads, list teams, list/filter projects, list cycles, and list labels. Use whenever a task touches Linear: triaging or filing issues, changing issue state, setting priority, assigning issues or managing their labels, breaking an issue into sub-issues with `issue create --parent` and reading them back with `issue view --full`, commenting on or reading an issue discussion via `comment list`, editing or removing a comment via `comment update/delete <id>` (ids come from `comment list`), discovering team keys via `teams`, scoping work to a project via `projects` and `issues --project`, to a cycle via `cycles` and `issues --cycle`, assigning issues to a project or cycle with `issue create/update --project <name>` and `--cycle current|<number>`, or finding label names via `labels` for `issue create --label`, `issue update --label`, and `issues --label`."
user-invocable: false
author: ApexCloudWise
metadata:
Expand All @@ -20,7 +20,7 @@ linear-axi authenticates with a Linear personal API key (https://linear.app/sett

## When to use

Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing, or deleting issues; changing issue state, priority, assignee, or labels; nesting issues as sub-issues with `issue create --parent <ref>`; commenting on issues or reading their comment threads; discovering team keys; listing projects and scoping issues to one with `issues --project <name>`; listing a team's cycles and scoping issues to one with `issues --cycle current|<number> --team <KEY>`; putting issues in a project or cycle with `issue create/update --project <name>` or `--cycle current|<number>`; or listing the workspace's labels and using their names with `issues --label <name>`, `issue create --label <name>`, or `issue update --label <name>`.
Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing, or deleting issues; changing issue state, priority, assignee, or labels; nesting issues as sub-issues with `issue create --parent <ref>`; commenting on issues, editing or deleting comments by id, or reading their comment threads; discovering team keys; listing projects and scoping issues to one with `issues --project <name>`; listing a team's cycles and scoping issues to one with `issues --cycle current|<number> --team <KEY>`; putting issues in a project or cycle with `issue create/update --project <name>` or `--cycle current|<number>`; or listing the workspace's labels and using their names with `issues --label <name>`, `issue create --label <name>`, or `issue update --label <name>`.

## Workflow

Expand All @@ -29,7 +29,7 @@ Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing
3. Identify issues by Linear identifier (e.g. `LIN-123`) or UUID.
4. Create an issue with `issue create --title "..." --team <KEY>`; team keys come from `teams`, `--project "<name>"` / `--cycle current|<number>` land it in a project or cycle straight away, and `--parent LIN-123` nests it as a sub-issue.
5. Change state with `issue update <IDENTIFIER> --state "<name>"`; pass the workflow state name exactly (e.g. `"In Progress"`). Assign with `--assignee <name|me>`, manage labels with `--label <name>` / `--remove-label <name>` (repeatable), and move it into a project or cycle with `--project "<name>"` / `--cycle current|<number>` in the same command.
6. Comment with `comment <IDENTIFIER> --body "..."` or `--body-file <path>` for multi-line bodies; read an issue's thread with `comment list <IDENTIFIER>` (newest first, `--full` for untruncated bodies).
6. Comment with `comment <IDENTIFIER> --body "..."` or `--body-file <path>` for multi-line bodies; read an issue's thread with `comment list <IDENTIFIER>` (newest first, `--full` for untruncated bodies). Each row carries the comment's id - edit with `comment update <COMMENT-ID> --body "..."`, remove with `comment delete <COMMENT-ID>` (deleting a missing comment is a reported no-op).
7. Every response ends with contextual next-step hints under `help:` - follow them.

## Commands
Expand All @@ -48,12 +48,12 @@ Run `npx -y @apexcloudwise/linear-axi --help` for global flags, or `npx -y @apex
## Tips

- Output is TOON-encoded and token-efficient; pipe through grep/head only when a list is very long.
- Mutations are idempotent: updating an issue to its current state, or deleting an already-deleted issue, is a reported no-op (exit 0).
- Mutations are idempotent: updating an issue to its current state, or deleting an already-deleted issue or comment, is a reported no-op (exit 0). Updating a missing comment instead fails loud - only delete is idempotent.
- Repeated `--label` flags match issues carrying ANY of the given labels; a missing or blank value is rejected, never dropped. Label names come from `labels`.
- `issue update --label <name>` adds to an issue's existing labels and `--remove-label <name>` removes one; the last removal sends an explicit empty set, so it works too. Re-assigning the current assignee (or a label set that already matches) is a reported no-op.
- `issue create/update --project <name>` matches the project name exactly (`projects` lists them) and fails loud on a miss; re-requesting the issue's current project or cycle is a reported no-op. On `--cycle`, "current" resolves the active cycle (scoped to `--team`'s when given; on update without `--team`, the workspace's single active one - ambiguous if several teams have one) while a cycle number always requires `--team` because numbers restart per team (`cycles` lists them).
- `issues --cycle current` lists every team's active-cycle issues (add `--team <KEY>` to narrow); `--cycle <number>` also requires `--team` because cycle numbers restart per team - find them with `cycles [--team <KEY>]`.
- `issues --fields <a,b,c>` and `issue view <IDENTIFIER> --fields <a,b,c>` add opt-in extra fields (per-command sets - see each command's --help) when the default columns lack something you need.
- `issue view` truncates the description by default; pass `--full` to see it entirely and list the issue's sub-issues (one `identifier | title | state` line each). `comment list` likewise previews each comment body; `--full` shows them whole.
- `issue view` truncates the description by default; pass `--full` to see it entirely and list the issue's sub-issues (one `identifier | title | state` line each). `comment list` likewise previews each comment body; `--full` shows them whole. Every `comment list` row starts with the comment's full id - the handle `comment update`/`comment delete` take.
- `issue create --parent <IDENTIFIER|UUID>` creates a sub-issue under the referenced parent (fails loud when the parent is not found; the parent may belong to another team - Linear's hierarchy spans teams).
- Issue references accept either a UUID or a `TEAM-NUMBER` identifier like `LIN-123`.
157 changes: 148 additions & 9 deletions src/commands/comment.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import type { LinearContext } from '../context.js';
import { requireKey } from '../config.js';
import { fetchIssue, createComment, fetchComments } from '../linear.js';
import {
fetchIssue,
createComment,
fetchComments,
fetchComment,
updateComment,
deleteComment,
} from '../linear.js';
import {
assertKnownFlags,
takeFlag,
Expand All @@ -10,6 +17,7 @@ import {
import { AxiError } from '../errors.js';
import {
custom,
field,
relativeTime,
renderHelp,
renderList,
Expand All @@ -31,39 +39,56 @@ const COMMENT_BODY_PREVIEW = 200;

export const COMMENT_HELP = `usage: linear-axi comment <IDENTIFIER|UUID> --body "..." [--body-file <path>]
linear-axi comment list <IDENTIFIER|UUID> [--full]
Add a comment to a Linear issue, or read its comment thread.
linear-axi comment update <COMMENT-ID> --body "..." [--body-file <path>]
linear-axi comment delete <COMMENT-ID>
Add, read, edit, or remove comments on a Linear issue.

subcommands:
list <IDENTIFIER|UUID> read the issue's comment thread, newest first (--full disables body truncation)
update <COMMENT-ID> edit a comment's body (get ids from \`comment list\`)
delete <COMMENT-ID> remove a comment (idempotent: a missing comment is a no-op)

"list", "update", and "delete" are reserved words: a bare reference that
matches one of them cannot route to the create path (no real ref can).

flags:
--body "text" comment body (inline; create)
--body-file <path> read body from a UTF-8 file (use for multi-line; create)
--body "text" comment body (inline; create and update)
--body-file <path> read body from a UTF-8 file (use for multi-line; create and update)
--full list: show full comment bodies instead of ${COMMENT_BODY_PREVIEW}-char previews

examples:
linear-axi comment LIN-123 --body "Looks good to me"
linear-axi comment LIN-123 --body-file ./notes.md
linear-axi comment list LIN-123
linear-axi comment list LIN-123 --full
linear-axi comment update 3ba4c5d6-... --body "Edited: see the notes"
linear-axi comment delete 3ba4c5d6-...
`;

const CREATE_FLAGS = ['--body', '--body-file'];
const LIST_FLAGS = ['--full'];
const UPDATE_FLAGS = ['--body', '--body-file'];
const DELETE_FLAGS: string[] = [];

export async function commentCommand(
args: string[],
ctx: LinearContext,
): Promise<string> {
// Backward compatible dispatch: a bare `comment <ref> --body "..."` (no
// subcommand) keeps working — the create path stays the default. Only the
// literal first positional "list" selects the read subcommand, and no
// Linear issue reference (TEAM-NUMBER like LIN-123, or a UUID) can collide
// with that word, so existing invocations are unaffected.
// subcommand) keeps working — the create path stays the default. Only a
// literal first positional from the reserved set selects a subcommand, and
// no Linear issue reference (TEAM-NUMBER like LIN-123, or a UUID) can
// collide with those words, so existing invocations are unaffected.
const sub = getPositional(args, 0);
if (sub === 'list') {
return listComments(args.slice(1), ctx);
}
if (sub === 'update') {
return updateCommentCmd(args.slice(1), ctx);
}
if (sub === 'delete') {
return deleteCommentCmd(args.slice(1), ctx);
}
return createCommentCmd(args, ctx);
}

Expand Down Expand Up @@ -114,14 +139,26 @@ async function listComments(
hints.push(
`Run \`linear-axi comment ${issue.identifier} --body "..."\` to ${comments.length ? 'reply' : 'start the thread'}`,
);
if (comments.length) {
hints.push(
'Run `linear-axi comment update <id> --body "..."` to edit, or `comment delete <id>` to remove',
);
}
blocks.push(renderHelp(hints));

return renderOutput(blocks);
}

/** Field order: author, body, created. */
/**
* Field order: id, author, body, created. The id is the comment's UUID —
* rendered in full (never truncated) because it is the handle `comment
* update`/`comment delete` target (#27); a truncated id could not be
* copy-pasted back into a command. It is also short in practice relative to
* the body preview beside it.
*/
function listSchema(full: boolean): FieldDef[] {
return [
field('id'),
custom('author', (item: any) => authorName(item)),
custom('body', (item: any) => commentBodyForDisplay(item.body, full)),
relativeTime('createdAt', 'created'),
Expand Down Expand Up @@ -199,6 +236,108 @@ async function createCommentCmd(
]);
}

/**
* `comment update <COMMENT-ID> --body|--body-file` (#27). The body guards
* mirror the create path exactly (mutual exclusion + readBodyFile) so both
* write paths behave identically.
*
* Pre-fetches the comment (mirrors updateIssueCmd): update is NOT
* idempotent-by-convention — only delete is — so a missing comment fails
* LOUD with NOT_FOUND rather than silently no-oping. The pre-fetch also
* carries the parent issue's identifier (Comment.issue, nullable in the
* schema for non-issue comments), which the confirmation mentions when
* available.
*/
async function updateCommentCmd(
args: string[],
ctx: LinearContext,
): Promise<string> {
assertKnownFlags(args, UPDATE_FLAGS);
const apiKey = requireKey(ctx.apiKey);

const idRaw = getPositional(args);
if (!idRaw) {
throw new AxiError('Missing comment id', 'VALIDATION_ERROR', [
'Run `linear-axi comment update <COMMENT-ID> --body "..."` (ids come from `comment list`)',
]);
}

const body = takeFlag(args, '--body');
const bodyFile = takeFlag(args, '--body-file');
if (!body && !bodyFile) {
throw new AxiError('--body or --body-file is required', 'VALIDATION_ERROR', [
'linear-axi comment update <COMMENT-ID> --body "..."',
'linear-axi comment update <COMMENT-ID> --body-file <path>',
]);
}
if (body && bodyFile) {
throw new AxiError(
'Pass only one of --body or --body-file',
'VALIDATION_ERROR',
);
}

const text = body ?? readBodyFile(bodyFile!);

const comment = await fetchComment(apiKey, idRaw);
if (!comment) {
throw new AxiError(`Comment "${idRaw}" not found`, 'NOT_FOUND', [
'Run `linear-axi comment list <IDENTIFIER>` to see comment ids',
]);
}

await updateComment(apiKey, comment.id, text);

// Confirmation mentions the issue identifier when the pre-fetch carried
// one (the usual case); a non-issue comment (Comment.issue is nullable)
// falls back to the comment id so the line stays unambiguous.
const identifier = comment.issue?.identifier;
return renderOutput([
`comment: updated${identifier ? ` on ${identifier}` : ` ${comment.id}`}`,
renderHelp([
...(identifier
? [
`Run \`linear-axi comment list ${identifier}\` to read the thread`,
]
: []),
`Run \`linear-axi comment delete ${comment.id}\` to remove it`,
]),
]);
}

/**
* `comment delete <COMMENT-ID>` (#27). Idempotent no-op when the comment is
* already gone — mirroring deleteIssueCmd's pre-fetch pattern (chosen over
* mapping Linear's delete-not-found error so the convention lives in one
* place in the command layer). Like issue delete, ANY missing id no-ops:
* delete is safe to re-run after a prior delete or with a stale id.
*/
async function deleteCommentCmd(
args: string[],
ctx: LinearContext,
): Promise<string> {
assertKnownFlags(args, DELETE_FLAGS);
const apiKey = requireKey(ctx.apiKey);

const idRaw = getPositional(args);
if (!idRaw) {
throw new AxiError('Missing comment id', 'VALIDATION_ERROR', [
'Run `linear-axi comment delete <COMMENT-ID>` (ids come from `comment list`)',
]);
}

const comment = await fetchComment(apiKey, idRaw);
if (!comment) {
// Idempotent: already gone.
return renderOutput([`comment: "${idRaw}" not found (no-op)`]);
}

await deleteComment(apiKey, comment.id);
return renderOutput([
`deleted: comment${comment.issue ? ` on ${comment.issue.identifier}` : ` ${comment.id}`}`,
]);
}

function readBodyFile(path: string): string {
try {
return readFileSync(path, 'utf-8');
Expand Down
85 changes: 85 additions & 0 deletions src/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,36 @@ export async function fetchComments(
return { comments: comments.slice(0, limit), hasMore };
}

/**
* A single comment fetched by id via fetchComment: the id plus just enough
* context for the write paths' confirmations — the parent issue's identifier
* (Comment.issue is nullable per @linear/sdk: "Null if the comment belongs to
* a different parent entity type", e.g. project comments).
*/
export interface LinearCommentRef {
id: string;
issue?: { identifier: string } | null;
}

/**
* Fetch one comment by id (#27). The root `comment(id:)` query exists in
* @linear/sdk v90 (QueryCommentArgs: { hash?: String, id?: String }, returns
* Maybe<Comment>) — like `issue(id:)`, a missing id resolves to null rather
* than an error, which is exactly what the delete path's idempotent no-op and
* the update path's loud NOT_FOUND branch on.
*/
export async function fetchComment(
apiKey: string,
id: string,
): Promise<LinearCommentRef | undefined> {
const data = await linearRequest<{ comment: LinearCommentRef | null }>(
apiKey,
`query Comment($id: String!) { comment(id: $id) { id issue { identifier } } }`,
{ id },
);
return data.comment ?? undefined;
}

/** Selection for label nodes. `color` is a raw HEX string (see LinearLabel). */
export const LABEL_LIST_FIELDS = `
id
Expand Down Expand Up @@ -1064,6 +1094,61 @@ export async function createComment(
}
}

/**
* Update a comment's body (#27). Mutation shape verified against the generated
* documents in @linear/sdk v90 (UpdateCommentMutationVariables): the root
* mutation is `commentUpdate(id: String!, input: CommentUpdateInput, ...)`
* returning CommentPayload { comment, lastSyncId, success }. CommentUpdateInput
* exposes exactly one non-internal field — `body: String` ("The comment
* content") — so no omit-null input builder is needed: body is the single
* required value and is always sent.
*/
export async function updateComment(
apiKey: string,
id: string,
body: string,
): Promise<void> {
const data = await linearRequest<{
commentUpdate: { success: boolean };
}>(
apiKey,
`mutation UpdateComment($id: String!, $body: String!) {
commentUpdate(id: $id, input: { body: $body }) {
success
}
}`,
{ id, body },
);
if (!data.commentUpdate.success) {
throw new AxiError('Linear rejected the comment update', 'UNKNOWN');
}
}

/**
* Delete a comment (#27). Mutation shape verified against the generated
* documents in @linear/sdk v90 (DeleteCommentMutationVariables): the root
* mutation is `commentDelete(id: String!)` returning DeletePayload
* { entityId, lastSyncId, success } — success is the only field the CLI
* needs, mirroring deleteIssue.
*/
export async function deleteComment(
apiKey: string,
id: string,
): Promise<void> {
const data = await linearRequest<{
commentDelete: { success: boolean };
}>(
apiKey,
`mutation DeleteComment($id: String!) {
commentDelete(id: $id) { success }
}`,
{ id },
);
if (!data.commentDelete.success) {
throw new AxiError('Linear rejected the comment delete', 'UNKNOWN');
}
}

// ---------------------------------------------------------------------------
// Resolution helpers
// ---------------------------------------------------------------------------
Expand Down
Loading