Skip to content
Merged
18 changes: 10 additions & 8 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, post comments, and list teams. Use whenever a task touches Linear: triaging or filing issues, changing issue state, setting priority, commenting, or discovering team keys via `teams`."
description: "Operate Linear (linear.app) through the linear-axi CLI - list and view issues, create, update, and delete 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, commenting 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`, or finding label names via `labels` for `issue create --label` and `issues --label`."
user-invocable: false
author: ApexCloudWise
metadata:
Expand All @@ -20,23 +20,23 @@ 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 or priority; commenting on issues; or discovering team keys.
Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing, or deleting issues; changing issue state or priority; 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>`; or listing the workspace's labels and using their names with `issues --label <name>` or `issue create --label <name>`.

## Workflow

1. Run `npx -y @apexcloudwise/linear-axi` with no arguments for a dashboard - the current viewer and your started, assigned issues.
2. Drill in command-first: `issues`, `issue view <IDENTIFIER>`, `teams`.
2. Drill in command-first: `issues`, `issue view <IDENTIFIER>`, `teams`, `projects`, `cycles`, `labels`.
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`.
5. Change state with `issue update <IDENTIFIER> --state "<name>"`; pass the workflow state name exactly (e.g. `"In Progress"`).
6. Comment with `comment <IDENTIFIER> --body "..."` or `--body-file <path>` for multi-line 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).
7. Every response ends with contextual next-step hints under `help:` - follow them.

## Commands

```
commands[6]:
(none)=dashboard, issues, issue, comment, teams, login, setup
commands[9]:
(none)=dashboard, issues, issue, comment, teams, projects, cycles, labels, login, setup
```

Installed copies also inherit the SDK built-in `update` command.
Expand All @@ -49,6 +49,8 @@ Run `npx -y @apexcloudwise/linear-axi --help` for global flags, or `npx -y @apex

- 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).
- Repeated flags (`--label`) apply once per value; a missing or blank value is rejected, never dropped.
- `issue view` truncates the description by default; pass `--full` to see it entirely.
- 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`.
- `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. `comment list` likewise previews each comment body; `--full` shows them whole.
- Issue references accept either a UUID or a `TEAM-NUMBER` identifier like `LIN-123`.
17 changes: 15 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { issuesCommand, ISSUES_HELP } from './commands/issues.js';
import { issueCommand, ISSUE_HELP } from './commands/issue.js';
import { commentCommand, COMMENT_HELP } from './commands/comment.js';
import { teamsCommand, TEAMS_HELP } from './commands/teams.js';
import { projectsCommand, PROJECTS_HELP } from './commands/projects.js';
import { cyclesCommand, CYCLES_HELP } from './commands/cycles.js';
import { labelsCommand, LABELS_HELP } from './commands/labels.js';
import { loginCommand, LOGIN_HELP } from './commands/login.js';
import { setupCommand, SETUP_HELP } from './commands/setup.js';

Expand All @@ -28,8 +31,8 @@ type LinearCommand = (
) => Promise<Renderable>;

export const TOP_HELP = `usage: linear-axi [command] [args] [flags]
commands[6]:
(none)=dashboard, issues, issue, comment, teams, login, setup
commands[9]:
(none)=dashboard, issues, issue, comment, teams, projects, cycles, labels, login, setup
globals[1]:
--key <API_KEY> (after command) or LINEAR_API_KEY env, accepted in space or = form; --help always allowed
examples:
Expand All @@ -40,7 +43,11 @@ examples:
linear-axi issue create --title "Fix login" --team ENG --label bug
linear-axi issue update LIN-123 --state "In Progress"
linear-axi comment LIN-123 --body "Looks good"
linear-axi comment list LIN-123
linear-axi teams
linear-axi projects
linear-axi cycles --team LIN
linear-axi labels
linear-axi login lin_api_xxx
linear-axi setup hooks
`;
Expand All @@ -50,6 +57,9 @@ const COMMAND_HELP: Record<string, string> = {
issue: ISSUE_HELP,
comment: COMMENT_HELP,
teams: TEAMS_HELP,
projects: PROJECTS_HELP,
cycles: CYCLES_HELP,
labels: LABELS_HELP,
login: LOGIN_HELP,
setup: SETUP_HELP,
};
Expand All @@ -66,6 +76,9 @@ const COMMANDS: Record<string, AxiCliCommand<undefined>> = {
issue: wrap(issueCommand),
comment: wrap(commentCommand),
teams: wrap(teamsCommand),
projects: wrap(projectsCommand),
cycles: wrap(cyclesCommand),
labels: wrap(labelsCommand),
login: wrap(loginCommand),
setup: wrap(setupCommand),
};
Expand Down
149 changes: 141 additions & 8 deletions src/commands/comment.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,162 @@
import type { LinearContext } from '../context.js';
import { requireKey } from '../config.js';
import { fetchIssue, createComment } from '../linear.js';
import { assertKnownFlags, takeFlag, getPositional } from '../args.js';
import { fetchIssue, createComment, fetchComments } from '../linear.js';
import {
assertKnownFlags,
takeFlag,
takeBoolFlag,
getPositional,
} from '../args.js';
import { AxiError } from '../errors.js';
import { renderHelp, renderOutput } from '../toon.js';
import {
custom,
relativeTime,
renderHelp,
renderList,
renderOutput,
truncateForDisplay,
type FieldDef,
} from '../toon.js';
import { formatCountLine } from '../format.js';
import { readFileSync } from 'node:fs';

/**
* Body preview length for `comment list` rows. Shorter than issue view's
* 1000-char description preview because a list renders one row per comment
* (author + body + time); a 200-char preview keeps a long thread scannable
* while still showing real content. `--full` disables truncation entirely
* (AXI principle 3: preview + total size, never silent omission).
*/
const COMMENT_BODY_PREVIEW = 200;

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

subcommands:
list <IDENTIFIER|UUID> read the issue's comment thread, newest first (--full disables body truncation)

flags:
--body "text" comment body (inline)
--body-file <path> read body from a UTF-8 file (use for multi-line)
--body "text" comment body (inline; create)
--body-file <path> read body from a UTF-8 file (use for multi-line; create)
--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
`;

const KNOWN_FLAGS = ['--body', '--body-file'];
const CREATE_FLAGS = ['--body', '--body-file'];
const LIST_FLAGS = ['--full'];

export async function commentCommand(
args: string[],
ctx: LinearContext,
): Promise<string> {
assertKnownFlags(args, KNOWN_FLAGS);
// 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.
const sub = getPositional(args, 0);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

commentCommand dispatches on getPositional(args, 0), which returns the first token not starting with '--' regardless of whether it is actually a preceding flag's value, so a --body/--body-file value equal to the literal string "list" gets misrouted to the read-thread subcommand instead of creating the comment.

linear-axi comment --body "list" LIN-123 (flags-before-positional is normal CLI usage): args=['--body','list','LIN-123'] -> getPositional skips '--body' (starts with '--') and returns 'list' as sub. sub === 'list' routes to listComments(['list','LIN-123']), which then calls getPositional(args) again on that slice and gets 'list' as the ref, so it does fetchIssue(apiKey, 'list') -> Issue "list" not found. The comment is never created and the user gets a confusing NOT_FOUND error with no mention of --body or dispatch. Verified by reading args.ts:getPositional (line 97-105: if (!args[i].startsWith('--')) return args[i];) and tracing the exact token flow.

if (sub === 'list') {
return listComments(args.slice(1), ctx);
}
return createCommentCmd(args, ctx);
}

async function listComments(
args: string[],
ctx: LinearContext,
): Promise<string> {
assertKnownFlags(args, LIST_FLAGS);
const apiKey = requireKey(ctx.apiKey);
const full = takeBoolFlag(args, '--full');

const refRaw = getPositional(args);
if (!refRaw) {
throw new AxiError('Missing issue reference', 'VALIDATION_ERROR', [
'Run `linear-axi comment list <IDENTIFIER>` (e.g. LIN-123)',
]);
}

// Resolve the ref first: the issue's id scopes the comments filter, its
// identifier seeds the hints, and an unknown ref fails loud here.
const issue = await fetchIssue(apiKey, refRaw);
if (!issue) {
throw new AxiError(`Issue "${refRaw}" not found`, 'NOT_FOUND', [
'Run `linear-axi issues` to list issues',
]);
}

const { comments, hasMore } = await fetchComments(apiKey, issue.id);

const blocks: string[] = [];
blocks.push(formatCountLine({ count: comments.length, hasMore }));

if (comments.length) {
blocks.push(renderList('comments', comments, listSchema(full)));
} else {
blocks.push('comments: 0 found');
}

const hints: string[] = [];
const anyTruncated = comments.some(
(c) => c.body.length > COMMENT_BODY_PREVIEW,
);
if (anyTruncated && !full) {
hints.push(
`Run \`linear-axi comment list ${issue.identifier} --full\` for untruncated bodies`,
);
}
hints.push(
`Run \`linear-axi comment ${issue.identifier} --body "..."\` to ${comments.length ? 'reply' : 'start the thread'}`,
);
blocks.push(renderHelp(hints));

return renderOutput(blocks);
}

/** Field order: author, body, created. */
function listSchema(full: boolean): FieldDef[] {
return [
custom('author', (item: any) => authorName(item)),
custom('body', (item: any) => commentBodyForDisplay(item.body, full)),
relativeTime('createdAt', 'created'),
];
}

/**
* Author display name. Workspace comments carry `user`; comments created
* through integrations (Slack, Intercom) carry `externalUser` instead
* (Comment.user is nullable per @linear/sdk). Neither present (bots without
* a user association) renders as "unknown".
*/
function authorName(item: {
user?: { name: string } | null;
externalUser?: { displayName: string } | null;
}): string {
return item.user?.name ?? item.externalUser?.displayName ?? 'unknown';
}

function commentBodyForDisplay(
body: string | null | undefined,
full: boolean,
): string {
const { preview, total, truncated } = truncateForDisplay(
body,
full ? Infinity : COMMENT_BODY_PREVIEW,
);
return truncated ? `${preview} (${total} chars total)` : preview;
}

async function createCommentCmd(
args: string[],
ctx: LinearContext,
): Promise<string> {
assertKnownFlags(args, CREATE_FLAGS);
const apiKey = requireKey(ctx.apiKey);

const refRaw = getPositional(args);
Expand Down Expand Up @@ -62,6 +194,7 @@ export async function commentCommand(
`comment: added to ${issue.identifier}`,
renderHelp([
`Run \`linear-axi issue view ${issue.identifier}\` to see it in context`,
`Run \`linear-axi comment list ${issue.identifier}\` to read the thread`,
]),
]);
}
Expand Down
Loading
Loading