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, 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, 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`."
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; 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; discovering team keys; or listing projects and scoping issues to one with `issues --project <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`.
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[6]:
(none)=dashboard, issues, issue, comment, teams, login, setup
commands[7]:
(none)=dashboard, issues, issue, comment, teams, projects, login, setup
```

Installed copies also inherit the SDK built-in `update` command.
Expand Down
8 changes: 6 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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 { loginCommand, LOGIN_HELP } from './commands/login.js';
import { setupCommand, SETUP_HELP } from './commands/setup.js';

Expand All @@ -28,8 +29,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[7]:
(none)=dashboard, issues, issue, comment, teams, projects, 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 @@ -41,6 +42,7 @@ examples:
linear-axi issue update LIN-123 --state "In Progress"
linear-axi comment LIN-123 --body "Looks good"
linear-axi teams
linear-axi projects
linear-axi login lin_api_xxx
linear-axi setup hooks
`;
Expand All @@ -50,6 +52,7 @@ const COMMAND_HELP: Record<string, string> = {
issue: ISSUE_HELP,
comment: COMMENT_HELP,
teams: TEAMS_HELP,
projects: PROJECTS_HELP,
login: LOGIN_HELP,
setup: SETUP_HELP,
};
Expand All @@ -66,6 +69,7 @@ const COMMANDS: Record<string, AxiCliCommand<undefined>> = {
issue: wrap(issueCommand),
comment: wrap(commentCommand),
teams: wrap(teamsCommand),
projects: wrap(projectsCommand),
login: wrap(loginCommand),
setup: wrap(setupCommand),
};
Expand Down
19 changes: 18 additions & 1 deletion src/commands/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@ 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>] [--search <text>] [--limit <n>]
export const ISSUES_HELP = `usage: linear-axi issues [--team <KEY>] [--state <type>] [--assignee <name|me>] [--label <name>] [--project <name>] [--search <text>] [--limit <n>]
List Linear issues, most recently updated first (relevance-ranked when --search is given).

flags:
--team <KEY> filter by team key (e.g. LIN)
--state <type> workflow state type: backlog, unstarted, started, completed, canceled, triage
--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)
--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 @@ -31,6 +32,7 @@ examples:
linear-axi issues --assignee me --state started
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 --search "onboarding" --team LIN --state started
`;

Expand All @@ -39,6 +41,7 @@ const KNOWN_FLAGS = [
'--state',
'--assignee',
'--label',
'--project',
'--search',
'--limit',
];
Expand Down Expand Up @@ -79,6 +82,14 @@ export async function issuesCommand(
'e.g. --search "onboarding"',
]);
}
const hasProject = args.some((a) => a === '--project' || a.startsWith('--project='));
const project = takeFlag(args, '--project');
if (hasProject && (project === undefined || project.trim() === '')) {
// Same guard as --search: a missing value must fail loud, not drop the filter.
throw new AxiError('--project requires a value', 'VALIDATION_ERROR', [
'e.g. --project "Mobile app"',
]);
}
const limitRaw = takeFlag(args, '--limit');
const limit = limitRaw ? parseLimit(limitRaw) : 25;

Expand Down Expand Up @@ -125,6 +136,7 @@ export async function issuesCommand(
// Every repeated --label is applied: Linear matches issues carrying at
// least one of the names ("any of" semantics, documented in --help).
if (labels.length) filter.labels = labels;
if (project) filter.project = project;
if (search !== undefined) filter.search = search;

const { issues, hasMore } = await fetchIssues(apiKey, filter, limit);
Expand Down Expand Up @@ -157,6 +169,11 @@ export async function issuesCommand(
if (search !== undefined && issues.length === 0) {
hints.push(`No matches for "${search}" — try a shorter term or drop a filter`);
}
if (project !== undefined && issues.length === 0) {
hints.push(
`No issues in project "${project}" — check the exact name with \`linear-axi projects\``,
);
}
blocks.push(renderHelp(hints));

return renderOutput(blocks);
Expand Down
71 changes: 71 additions & 0 deletions src/commands/projects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { LinearContext } from '../context.js';
import { requireKey } from '../config.js';
import { assertKnownFlags } from '../args.js';
import { fetchProjects } from '../linear.js';
import {
field,
custom,
renderList,
renderHelp,
renderOutput,
type FieldDef,
} from '../toon.js';
import { formatCountLine } from '../format.js';

export const PROJECTS_HELP = `usage: linear-axi projects
List the projects in your Linear workspace, most recently updated first.

columns:
name project name
state lifecycle state: backlog, planned, started, paused, completed, canceled
progress percent complete, 0-100
lead project lead (none if unassigned)
target target date as YYYY-MM-DD (none if unset)

examples:
linear-axi projects
linear-axi issues --project "Mobile app" --state started
`;

const listSchema: FieldDef[] = [
field('name'),
// status.type is the raw lowercase lifecycle value (planned, started, ...);
// Project.state is deprecated in favor of Project.status (see linear.ts).
custom('state', (item: any) => item.status?.type ?? ''),
custom('progress', (item: any) => Math.round((item.progress ?? 0) * 100)),
custom('lead', (item: any) => item.lead?.name ?? 'none'),
custom('target', (item: any) => item.targetDate ?? 'none'),
];

export async function projectsCommand(
args: string[],
ctx: LinearContext,
): Promise<string> {
// `projects` takes no flags — reject any rather than silently dropping it.
assertKnownFlags(args, []);
const apiKey = requireKey(ctx.apiKey);
const { projects, hasMore } = await fetchProjects(apiKey);

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

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

const hints: string[] = [];
if (projects.length) {
hints.push(
'Run `linear-axi issues --project "<name>"` to list a project\'s issues',
);
} else {
hints.push(
'Create a project in the Linear app — none exist in this workspace yet',
);
}
blocks.push(renderHelp(hints));

return renderOutput(blocks);
}
120 changes: 120 additions & 0 deletions src/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,21 @@ export const ISSUE_DETAIL_FIELDS = `
updatedAt
`;

/**
* Selection for the `projects` connection. We select `status { type }` rather
* than the deprecated `state` field (Project.state: "[DEPRECATED] Use
* project.status instead" per @linear/sdk): status.type is a ProjectStatusType
* enum yielding the same raw lowercase lifecycle values.
*/
export const PROJECT_LIST_FIELDS = `
id
name
status { type }
progress
lead { name }
targetDate
`;

export interface LinearIssue {
id: string;
identifier: string;
Expand All @@ -102,6 +117,22 @@ export interface LinearTeam {
name: string;
}

/**
* A Linear project as returned by PROJECT_LIST_FIELDS. `status.type` carries
* the lifecycle state (backlog, planned, started, paused, completed,
* canceled); `progress` is a float 0-1; `targetDate` is a TimelessDate
* serialized as YYYY-MM-DD (null when unset). Field shapes verified against
* the generated types in @linear/sdk (Project, ProjectStatus).
*/
export interface LinearProject {
id: string;
name: string;
status?: { type: string } | null;
progress?: number;
lead?: { name: string } | null;
targetDate?: string | null;
}

export interface LinearViewer {
id: string;
name: string;
Expand All @@ -128,12 +159,75 @@ export async function fetchTeams(apiKey: string): Promise<LinearTeam[]> {
return data.teams.nodes;
}

export interface ProjectListResult {
projects: LinearProject[];
/**
* True when the last fetched page reported `hasNextPage` — more projects
* exist beyond the returned slice (the fetch limit was hit mid-cursor).
*/
hasMore: boolean;
}

interface ProjectListPage {
nodes: LinearProject[];
pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } | null;
}

/**
* List workspace projects, most recently updated first, auto-paginated in
* PAGE_SIZE (50) batches like fetchIssues until `limit` is satisfied or the
* server reports no more pages.
*/
export async function fetchProjects(
apiKey: string,
limit = 100,
): Promise<ProjectListResult> {
const query = `query Projects($first: Int!, $after: String) {
projects(first: $first, after: $after, orderBy: updatedAt) {
nodes { ${PROJECT_LIST_FIELDS} }
pageInfo { hasNextPage endCursor }
}
}`;

const projects: LinearProject[] = [];
let cursor: string | undefined;
let hasMore = false;

while (projects.length < limit) {
const first = Math.min(limit - projects.length, PAGE_SIZE);
const variables: Record<string, unknown> = { first };
if (cursor !== undefined) {
variables['after'] = cursor;
}

const data = await linearRequest<{ projects: ProjectListPage }>(
apiKey,
query,
variables,
);

projects.push(...data.projects.nodes);

hasMore = data.projects.pageInfo?.hasNextPage ?? false;
const nextCursor = data.projects.pageInfo?.endCursor ?? undefined;

// Same loop guards as fetchIssues: stop on server exhaustion and on a
// non-advancing cursor or empty page that would otherwise spin forever.
if (!hasMore || nextCursor === undefined) break;
if (data.projects.nodes.length === 0 || nextCursor === cursor) break;
cursor = nextCursor;
}

return { projects: projects.slice(0, limit), hasMore };
}

export interface IssueListFilter {
team?: string; // team key, e.g. "LIN"
stateType?: string; // backlog | unstarted | started | completed | canceled | triage
assigneeEmail?: string; // exact email (use viewer email for "me")
assigneeName?: string; // exact display name
labels?: string[]; // label names — matches issues carrying ANY of them
project?: string; // project name — exact match
search?: string; // ranked full-text search term (Linear app search ranking)
}

Expand Down Expand Up @@ -162,6 +256,7 @@ interface IssueListPage {
* labels: { some: { name: { in: ["Bug", "Regression"] } } }
* state: { type: { eq: "started" } }
* team: { key: { eq: "LIN" } }
* project: { name: { eq: "Mobile app" } }
*
* `labels` uses `some: { name: { in: [...] } }` — an issue matches when at
* least one of its labels is in the list ("any of" semantics). Shape verified
Expand Down Expand Up @@ -204,6 +299,9 @@ export async function fetchIssues(
if (filter.stateType) {
where.push(`state: { type: { eq: ${jsonStr(filter.stateType)} } }`);
}
if (filter.project) {
where.push(`project: { name: { eq: ${jsonStr(filter.project)} } }`);
}
if (filter.labels?.length) {
where.push(`labels: { some: { name: { in: ${jsonStr(filter.labels)} } } }`);
}
Expand Down Expand Up @@ -468,6 +566,28 @@ export async function resolveTeamId(
return data.teams.nodes[0]?.id;
}

/**
* Resolve a project name (exact match) to its id. Mirrors resolveTeamId;
* currently exercised by tests and reserved for v0.3 write-side project
* assignment (e.g. issue create --project). ProjectFilter.name is a
* StringComparator with `eq` (verified against @linear/sdk).
*/
export async function resolveProjectId(
apiKey: string,
name: string,
): Promise<string | undefined> {
const data = await linearRequest<{
projects: { nodes: Array<{ id: string; name: string }> };
}>(
apiKey,
`query ProjectByName($name: String!) {
projects(filter: { name: { eq: $name } }) { nodes { id name } }
}`,
{ name },
);
return data.projects.nodes[0]?.id;
}

async function resolveLabelIds(
apiKey: string,
names: string[],
Expand Down
Loading