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
9 changes: 5 additions & 4 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 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, 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 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`."
user-invocable: false
author: ApexCloudWise
metadata:
Expand All @@ -20,14 +20,14 @@ 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; 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 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

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`, `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`, and `--project "<name>"` / `--cycle current|<number>` land it in a project or cycle straight away.
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).
7. Every response ends with contextual next-step hints under `help:` - follow them.
Expand All @@ -54,5 +54,6 @@ Run `npx -y @apexcloudwise/linear-axi --help` for global flags, or `npx -y @apex
- `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. `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.
- `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`.
75 changes: 72 additions & 3 deletions src/commands/issue.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { LinearContext } from '../context.js';
import { requireKey } from '../config.js';
import type { LinearIssueChild } from '../linear.js';
import {
fetchIssue,
fetchViewer,
Expand Down Expand Up @@ -38,8 +39,8 @@ export const ISSUE_HELP = `usage: linear-axi issue <subcommand> [args]
Manage Linear issues.

subcommands:
view <IDENTIFIER|UUID> show issue details (--full disables description truncation; --fields <a,b,c> adds extra fields)
create --title "..." --team <KEY> [--description "..."] [--label <name>...] [--project <name>] [--cycle <current|number>]
view <IDENTIFIER|UUID> show issue details (--full shows the full description and sub-issues; --fields <a,b,c> adds extra fields)
create --title "..." --team <KEY> [--description "..."] [--label <name>...] [--project <name>] [--cycle <current|number>] [--parent <IDENTIFIER|UUID>]
update <ref> [--state <name>] [--title "..."] [--priority <0-4>] [--description "..."]
[--assignee <name|me>] [--label <name>] [--remove-label <name>] [--project <name>] [--cycle <current|number>]
delete <ref>
Expand All @@ -52,6 +53,11 @@ update flags:
--label <name> add a label (repeatable; names from \`linear-axi labels\`)
--remove-label <name> remove a label (repeatable); removing the last one clears labels

sub-issue flags:
--parent <IDENTIFIER|UUID> create: nest the new issue under this parent (fails loud if not found;
cross-team parents are allowed — the hierarchy spans teams)
--full (view) also lists the issue's children, one "identifier | title | state" line each

project/cycle flags (create and update):
--project <name> put the issue in a project (exact name; run \`linear-axi projects\`)
--cycle <current|number> put the issue in a cycle: "current" = the active cycle — with --team, that team's;
Expand All @@ -65,6 +71,7 @@ examples:
linear-axi issue view LIN-123 --fields dueDate,estimate
linear-axi issue create --title "Fix login" --team ENG --label bug
linear-axi issue create --title "Ship v2" --team LIN --project "Q3 launch" --cycle current
linear-axi issue create --title "Wire the toggle" --team ENG --parent LIN-42
linear-axi issue update LIN-123 --state "In Progress"
linear-axi issue update LIN-123 --assignee me --label bug
linear-axi issue update LIN-123 --remove-label bug
Expand All @@ -82,6 +89,7 @@ const CREATE_FLAGS = [
'--label',
'--project',
'--cycle',
'--parent',
];
const UPDATE_FLAGS = [
'--state',
Expand Down Expand Up @@ -174,7 +182,7 @@ async function viewIssue(
]);
}

const issue = await fetchIssue(apiKey, refRaw, extraLinearKeys);
const issue = await fetchIssue(apiKey, refRaw, extraLinearKeys, full);
if (!issue) {
throw new AxiError(
`Issue "${refRaw}" not found`,
Expand Down Expand Up @@ -202,6 +210,15 @@ async function viewIssue(
);
}

// Sub-issues (#26): the children connection was selected (under --full) —
// render it after the description. Manual block rather than renderList: one
// `identifier | title | state` line per child is a third of the tokens of a
// TOON array-of-objects while carrying exactly the three fields issue #26's
// acceptance names.
if (full) {
blocks.push(renderSubIssues(issue.children));
}

blocks.push(
renderHelp([
`Run \`linear-axi issue update ${issue.identifier} --state "<name>"\` to change state`,
Expand Down Expand Up @@ -232,6 +249,10 @@ async function createIssueCmd(
(a) => a === '--cycle' || a.startsWith('--cycle='),
);
const cycleRaw = takeFlag(args, '--cycle');
const hasParent = args.some(
(a) => a === '--parent' || a.startsWith('--parent='),
);
const parentRaw = takeFlag(args, '--parent');

if (!title || !title.trim()) {
throw new AxiError('--title is required', 'VALIDATION_ERROR', [
Expand All @@ -256,6 +277,11 @@ async function createIssueCmd(
'Use --cycle current for the active cycle, or --cycle <number> (per --team)',
]);
}
if (hasParent && (parentRaw === undefined || parentRaw.trim() === '')) {
throw new AxiError('--parent requires a value', 'VALIDATION_ERROR', [
'e.g. --parent LIN-42 (identifier or UUID of the parent issue)',
]);
}

// Resolve --project/--cycle to ids BEFORE the mutation (#25): a loud
// failure here beats an issue created without its requested project/cycle.
Expand All @@ -274,13 +300,37 @@ async function createIssueCmd(
: await requireCycleIdByNumber(apiKey, team.trim(), spec);
}

// Sub-issue parent (#26): resolve --parent to the parent issue's ID BEFORE
// the mutation, the same resolve-loud-before-write convention as
// --project/--cycle. Linear's IssueCreateInput.parentId would also accept
// the raw identifier ("Can be a UUID or issue identifier (e.g., 'LIN-123')",
// @linear/sdk v90), but pre-resolving turns an unknown parent into a clean
// NOT_FOUND instead of an opaque mutation error. Cross-team parents are
// deliberately NOT rejected client-side — Linear's sub-issue hierarchy
// spans teams (a child may live in a different team than its parent), so
// any genuine workspace-level rejection surfaces through the normal API
// error mapping.
let parentId: string | undefined;
if (parentRaw !== undefined) {
const parent = await fetchIssue(apiKey, parentRaw.trim());
if (!parent) {
throw new AxiError(
`Parent issue "${parentRaw.trim()}" not found`,
'NOT_FOUND',
['Run `linear-axi issues` to list issues'],
);
}
parentId = parent.id;
}

const issue = await createIssue(apiKey, {
title: title.trim(),
description,
teamKey: team.trim(),
labelNames: labels,
projectId,
cycleId,
parentId,
});

const blocks: string[] = [
Expand Down Expand Up @@ -606,6 +656,25 @@ function parsePriority(raw: string): number {
return n;
}

/**
* Render the sub-issues block for `issue view --full` (#26): a count header
* (the help[N]:-style bracket convention) plus one
* `identifier | title | state` line per child, in the server's order for the
* children connection. An empty list still renders the bare header — under
* --full the user asked for everything, so "no sub-issues" is information,
* not noise.
*/
function renderSubIssues(
children: { nodes: LinearIssueChild[] } | null | undefined,
): string {
const nodes = children?.nodes ?? [];
const lines = nodes.map(
(child) =>
` ${child.identifier} | ${child.title} | ${child.state?.name ?? 'unknown'}`,
);
return `sub-issues[${nodes.length}]:${lines.length ? `\n${lines.join('\n')}` : ''}`;
}

/**
* Parse a --cycle value: "current" (case-insensitive) or a positive integer
* cycle number. Same grammar as the `issues` read path (#21); anything else
Expand Down
58 changes: 57 additions & 1 deletion src/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ export const ISSUE_LIST_FIELDS = `
* detection compares the issue's CURRENT project/cycle ids against the
* resolved --project/--cycle ids before sending IssueUpdateInput.projectId/
* cycleId. Renderers read none of the extra keys, so they stay invisible.
* The sub-issue children connection is NOT part of this base selection — it
* rides along via ISSUE_CHILDREN_FIELDS only for `issue view --full` (#26).
*/
export const ISSUE_DETAIL_FIELDS = `
id
Expand Down Expand Up @@ -103,6 +105,20 @@ export function withExtraFields(base: string, extraKeys: string[]): string {
return `${base}${extraKeys.map((k) => `\n ${k}`).join('')}`;
}

/**
* Sub-issue (children) selection for `issue view --full` (#26). Linear's
* Issue type exposes its sub-issues as `children: IssueConnection`
* ("Children of the issue", @linear/sdk v90 — the field is named `children`,
* not `subIssues`); IssueConnection carries `nodes: Array<Issue>`. One detail
* row per child needs identifier, title, and the workflow state's display
* name. Appended to ISSUE_DETAIL_FIELDS only when --full requests it, so the
* default detail document stays byte-identical (same opt-in principle as
* withExtraFields).
*/
export const ISSUE_CHILDREN_FIELDS = `
children { nodes { identifier title state { name } } }
`;

/**
* Selection for the `projects` connection. We select `status { type }` rather
* than the deprecated `state` field (Project.state: "[DEPRECATED] Use
Expand Down Expand Up @@ -132,6 +148,18 @@ export const CYCLE_LIST_FIELDS = `
progress
`;

/**
* A sub-issue child row as returned by ISSUE_CHILDREN_FIELDS (#26): the
* subset of Issue fields one `issue view --full` child line renders. Shapes
* match the generated Issue type in @linear/sdk v90 (identifier/title are
* non-nullable Strings; state is the issue's WorkflowState).
*/
export interface LinearIssueChild {
identifier: string;
title: string;
state?: { name: string } | null;
}

export interface LinearIssue {
id: string;
identifier: string;
Expand All @@ -151,6 +179,9 @@ export interface LinearIssue {
// within its team).
project?: { id: string; name: string } | null;
cycle?: { id: string; number: number } | null;
// Present when selected via ISSUE_CHILDREN_FIELDS (issue view --full only,
// #26): the issue's sub-issues ("children" connection, server order).
children?: { nodes: LinearIssueChild[] } | null;
url?: string;
createdAt?: string;
updatedAt?: string;
Expand Down Expand Up @@ -557,15 +588,25 @@ export async function fetchIssues(
*
* `extraFields` is a list of opt-in extra scalar Issue keys (from `--fields`)
* appended to the selection; the default document is used when empty.
*
* `includeChildren` (#26) additionally appends ISSUE_CHILDREN_FIELDS so
* `issue view --full` can list the issue's sub-issues. Opt-in for the same
* reason as the scalar extras: every update/delete/state-resolution caller
* shares this query and would otherwise carry (and pay for) a children page
* it never reads.
*/
export async function fetchIssue(
apiKey: string,
ref: string,
extraFields: string[] = [],
includeChildren = false,
): Promise<LinearIssue | undefined> {
const selection = includeChildren
? `${withExtraFields(ISSUE_DETAIL_FIELDS, extraFields)}${ISSUE_CHILDREN_FIELDS}`
: withExtraFields(ISSUE_DETAIL_FIELDS, extraFields);
const data = await linearRequest<{ issue: LinearIssue | null }>(
apiKey,
`query Issue($id: String!) { issue(id: $id) { ${withExtraFields(ISSUE_DETAIL_FIELDS, extraFields)} } }`,
`query Issue($id: String!) { issue(id: $id) { ${selection} } }`,
{ id: ref },
);
return data.issue ?? undefined;
Expand Down Expand Up @@ -782,6 +823,15 @@ export interface IssueCreateInput {
* the issue", @linear/sdk v90).
*/
cycleId?: string;
/**
* Create the issue as a sub-issue of this parent (resolved by the caller
* from --parent via fetchIssue). IssueCreateInput.parentId is a nullable
* String ("The identifier of the parent issue. Can be a UUID or issue
* identifier (e.g., 'LIN-123')", @linear/sdk v90) — verified as the
* mechanism for #26: the generated SDK types expose NO subIssueCreate
* mutation, sub-issues are created through issueCreate + parentId.
*/
parentId?: string;
}

export async function createIssue(
Expand Down Expand Up @@ -830,6 +880,12 @@ export async function createIssue(
varDecls.push('$cycleId: String');
variables['cycleId'] = input.cycleId;
}
// parentId (#26) follows the same omit-null convention as above.
if (input.parentId !== undefined) {
inputFields.push('parentId: $parentId');
varDecls.push('$parentId: String');
variables['parentId'] = input.parentId;
}

const data = await linearRequest<{
issueCreate: { issue: LinearIssue; success: boolean };
Expand Down
Loading