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
11 changes: 6 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, post comments, list teams, and list/filter projects. Use whenever a task touches Linear: triaging or filing issues, changing issue state, setting priority, commenting, discovering team keys via `teams`, or scoping work to a project via `projects` and `issues --project`."
description: "Operate Linear (linear.app) through the linear-axi CLI - list and view issues, create, update, and delete issues, post comments, list teams, list/filter projects, and list cycles. Use whenever a task touches Linear: triaging or filing issues, changing issue state, setting priority, commenting, discovering team keys via `teams`, scoping work to a project via `projects` and `issues --project`, or to a cycle via `cycles` and `issues --cycle`."
user-invocable: false
author: ApexCloudWise
metadata:
Expand All @@ -20,12 +20,12 @@ 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; discovering team keys; or listing projects and scoping issues to one with `issues --project <name>`.
Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing, or deleting issues; changing issue state or priority; commenting on issues; discovering team keys; listing projects and scoping issues to one with `issues --project <name>`; or listing a team's cycles and scoping issues to one with `issues --cycle current|<number> --team <KEY>`.

## 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`.
2. Drill in command-first: `issues`, `issue view <IDENTIFIER>`, `teams`, `projects`, `cycles`.
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"`).
Expand All @@ -35,8 +35,8 @@ Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing
## Commands

```
commands[7]:
(none)=dashboard, issues, issue, comment, teams, projects, login, setup
commands[8]:
(none)=dashboard, issues, issue, comment, teams, projects, cycles, login, setup
```

Installed copies also inherit the SDK built-in `update` command.
Expand All @@ -50,5 +50,6 @@ 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 `--label` flags match issues carrying ANY of the given labels; a missing or blank value is rejected, never dropped.
- `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>]`.
- `issue view` truncates the description by default; pass `--full` to see it entirely.
- Issue references accept either a UUID or a `TEAM-NUMBER` identifier like `LIN-123`.
8 changes: 6 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ 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 { loginCommand, LOGIN_HELP } from './commands/login.js';
import { setupCommand, SETUP_HELP } from './commands/setup.js';

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

export const TOP_HELP = `usage: linear-axi [command] [args] [flags]
commands[7]:
(none)=dashboard, issues, issue, comment, teams, projects, login, setup
commands[8]:
(none)=dashboard, issues, issue, comment, teams, projects, cycles, 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 @@ -43,6 +44,7 @@ examples:
linear-axi comment LIN-123 --body "Looks good"
linear-axi teams
linear-axi projects
linear-axi cycles --team LIN
linear-axi login lin_api_xxx
linear-axi setup hooks
`;
Expand All @@ -53,6 +55,7 @@ const COMMAND_HELP: Record<string, string> = {
comment: COMMENT_HELP,
teams: TEAMS_HELP,
projects: PROJECTS_HELP,
cycles: CYCLES_HELP,
login: LOGIN_HELP,
setup: SETUP_HELP,
};
Expand All @@ -70,6 +73,7 @@ const COMMANDS: Record<string, AxiCliCommand<undefined>> = {
comment: wrap(commentCommand),
teams: wrap(teamsCommand),
projects: wrap(projectsCommand),
cycles: wrap(cyclesCommand),
login: wrap(loginCommand),
setup: wrap(setupCommand),
};
Expand Down
121 changes: 121 additions & 0 deletions src/commands/cycles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import type { LinearContext } from '../context.js';
import { requireKey } from '../config.js';
import { assertKnownFlags, takeFlag } from '../args.js';
import { AxiError } from '../errors.js';
import { fetchCycles } from '../linear.js';
import {
field,
pluck,
custom,
renderList,
renderHelp,
renderOutput,
type FieldDef,
} from '../toon.js';
import { formatCountLine } from '../format.js';

export const CYCLES_HELP = `usage: linear-axi cycles [--team <KEY>]
List recent cycles with their date range and progress, most recently updated first.

flags:
--team <KEY> restrict to one team (e.g. LIN); without it, cycles across all teams are listed, each tagged with its team key

columns:
number cycle number (unique within its team)
team team key (only shown without --team)
status past, active, or upcoming — derived from the date range
range cycle start..end as YYYY-MM-DD..YYYY-MM-DD
progress percent complete, 0-100

examples:
linear-axi cycles
linear-axi cycles --team LIN
linear-axi issues --cycle current --team LIN
`;

/** Field order: number, [team], status, range, progress. */
function listSchema(withTeam: boolean): FieldDef[] {
return [
field('number'),
...(withTeam ? [pluck('team', 'key', 'team')] : []),
custom('status', (item: any) => cycleStatus(item)),
custom('range', (item: any) => cycleRange(item)),
custom('progress', (item: any) => Math.round((item.progress ?? 0) * 100)),
];
}

/**
* Cycle status from its date range. upcoming = starts in the future;
* past = ended; active = in between. (Linear's Cycle.isActive also excludes
* manually early-completed cycles, which the dates alone cannot see — close
* enough for a listing, and no extra field is fetched.)
*/
function cycleStatus(item: any): string {
const now = Date.now();
const start = new Date(item.startsAt).getTime();
const end = new Date(item.endsAt).getTime();
if (!isNaN(start) && now < start) return 'upcoming';
if (!isNaN(end) && now >= end) return 'past';
return 'active';
}

/** Date range as YYYY-MM-DD..YYYY-MM-DD (DateTime date part). */
function cycleRange(item: any): string {
const start = String(item.startsAt ?? '').slice(0, 10) || 'unknown';
const end = String(item.endsAt ?? '').slice(0, 10) || 'unknown';
return `${start}..${end}`;
}

export async function cyclesCommand(
args: string[],
ctx: LinearContext,
): Promise<string> {
assertKnownFlags(args, ['--team']);
const apiKey = requireKey(ctx.apiKey);

// A missing/blank --team must fail loud, not fall back to all-teams.
const hasTeam = args.some((a) => a === '--team' || a.startsWith('--team='));
const team = takeFlag(args, '--team');
if (hasTeam && (team === undefined || team.trim() === '')) {
throw new AxiError('--team requires a value', 'VALIDATION_ERROR', [
'e.g. --team LIN',
]);
}

if (args.some((a) => !a.startsWith('--') && a !== '')) {
const stray = args.filter((a) => !a.startsWith('--'));
throw new AxiError(`Unexpected argument: ${stray[0]}`, 'VALIDATION_ERROR', [
'`cycles` takes only --team — run `linear-axi cycles --help`',
]);
}

const { cycles, hasMore } = await fetchCycles(apiKey, team);

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

if (cycles.length) {
blocks.push(renderList('cycles', cycles, listSchema(team === undefined)));
} else {
blocks.push('cycles: 0 found');
}

const hints: string[] = [];
if (cycles.length) {
const key = team ?? cycles[0]?.team?.key ?? '<KEY>';
hints.push(
`Run \`linear-axi issues --cycle current --team ${key}\` to list the active cycle's issues`,
);
} else if (team) {
hints.push(
`Team ${team} has no cycles — cycles may be disabled for it in Linear's team settings`,
);
} else {
hints.push(
"No cycles in this workspace yet — enable cycles for a team in Linear's team settings",
);
}
blocks.push(renderHelp(hints));

return renderOutput(blocks);
}
47 changes: 46 additions & 1 deletion src/commands/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
} from '../toon.js';
import { formatCountLine } from '../format.js';

export const ISSUES_HELP = `usage: linear-axi issues [--team <KEY>] [--state <type>] [--assignee <name|me>] [--label <name>] [--project <name>] [--search <text>] [--limit <n>]
export const ISSUES_HELP = `usage: linear-axi issues [--team <KEY>] [--state <type>] [--assignee <name|me>] [--label <name>] [--project <name>] [--cycle <current|number>] [--search <text>] [--limit <n>]
List Linear issues, most recently updated first (relevance-ranked when --search is given).

flags:
Expand All @@ -24,6 +24,7 @@ flags:
--assignee <name> "me" for yourself, or a user name
--label <name> filter by label name; repeat to match ANY of the given labels (issues carrying at least one)
--project <name> filter by project name (exact match; run \`linear-axi projects\` for names)
--cycle <current|number> filter by cycle: "current" matches issues in any team's active cycle (with --team, just that team's); a cycle number requires --team because numbers restart per team (run \`linear-axi cycles\` for numbers)
--search <text> full-text search (same ranking as Linear's app search); composable with the filters above
--limit <n> max issues to return (default 25, max 500; fetched in pages of 50)

Expand All @@ -33,6 +34,7 @@ examples:
linear-axi issues --team LIN --label bug
linear-axi issues --label bug --label regression
linear-axi issues --project "Mobile app" --state started
linear-axi issues --cycle current --team LIN
linear-axi issues --search "onboarding" --team LIN --state started
`;

Expand All @@ -42,6 +44,7 @@ const KNOWN_FLAGS = [
'--assignee',
'--label',
'--project',
'--cycle',
'--search',
'--limit',
];
Expand Down Expand Up @@ -90,6 +93,13 @@ export async function issuesCommand(
'e.g. --project "Mobile app"',
]);
}
const hasCycle = args.some((a) => a === '--cycle' || a.startsWith('--cycle='));
const cycle = takeFlag(args, '--cycle');
if (hasCycle && (cycle === undefined || cycle.trim() === '')) {
throw new AxiError('--cycle requires a value', 'VALIDATION_ERROR', [
'Use --cycle current for the active cycle, or --cycle <number> --team <KEY>',
]);
}
const limitRaw = takeFlag(args, '--limit');
const limit = limitRaw ? parseLimit(limitRaw) : 25;

Expand All @@ -103,6 +113,33 @@ export async function issuesCommand(
);
}

// "current" filters to the active cycle(s); a number is a per-team cycle
// number and is ambiguous without --team (cycle 42 exists once per team),
// so the bare-number form fails loud instead of guessing a team.
let cycleFilter: 'current' | number | undefined;
if (cycle !== undefined) {
const value = cycle.trim().toLowerCase();
if (value === 'current') {
cycleFilter = 'current';
} else if (/^\d+$/.test(value) && Number(value) >= 1) {
if (team === undefined) {
throw new AxiError(
`--cycle <number> requires --team (cycle numbers restart per team)`,
'VALIDATION_ERROR',
[
`e.g. --cycle ${value} --team LIN`,
'Or use --cycle current without --team for every team\'s active cycle',
],
);
}
cycleFilter = Number(value);
} else {
throw new AxiError(`Invalid --cycle: ${cycle}`, 'VALIDATION_ERROR', [
'Use "current" for the active cycle, or a positive cycle number with --team',
]);
}
}

if (args.some((a) => !a.startsWith('--') && a !== '')) {
const stray = args.filter((a) => !a.startsWith('--'));
throw new AxiError(
Expand Down Expand Up @@ -137,6 +174,7 @@ export async function issuesCommand(
// least one of the names ("any of" semantics, documented in --help).
if (labels.length) filter.labels = labels;
if (project) filter.project = project;
if (cycleFilter !== undefined) filter.cycle = cycleFilter;
if (search !== undefined) filter.search = search;

const { issues, hasMore } = await fetchIssues(apiKey, filter, limit);
Expand Down Expand Up @@ -174,6 +212,13 @@ export async function issuesCommand(
`No issues in project "${project}" — check the exact name with \`linear-axi projects\``,
);
}
if (cycleFilter !== undefined && issues.length === 0) {
hints.push(
cycleFilter === 'current'
? 'No issues in an active cycle — run `linear-axi cycles` to see cycles'
: `No issues in cycle ${cycleFilter} — run \`linear-axi cycles --team ${team}\` to see the team's cycles`,
);
}
blocks.push(renderHelp(hints));

return renderOutput(blocks);
Expand Down
Loading