Linearis is a CLI tool for Linear.app that outputs structured JSON. It uses a layered architecture with strict TypeScript, GraphQL code generation, and ES modules.
- Node.js >= 22.0.0
- A Linear API token (see Authentication)
# Install dependencies (also runs GraphQL codegen)
npm install
# Run in development mode (uses tsx)
npm start issues list -l 5
# Run with explicit token
npx tsx src/main.ts --api-token <token> issues list
# Build for production
npm run build
# Run tests
npm testThe codebase is organized into five layers, each with a single responsibility:
CLI Input --> Command --> Resolver --> Service --> JSON Output
| |
SDK client GraphQL client
(ID lookup) (data operations)
| Layer | Directory | Client | Responsibility |
|---|---|---|---|
| Client | src/client/ |
-- | API client wrappers |
| Resolver | src/resolvers/ |
LinearSdkClient |
Convert human IDs to UUIDs |
| Service | src/services/ |
GraphQLClient |
Business logic and CRUD |
| Command | src/commands/ |
Both (via createContext()) |
CLI orchestration |
| Common | src/common/ |
-- | Shared utilities and types |
Two separate clients exist because the Linear SDK is convenient for ID lookups (resolvers), while direct GraphQL queries are more efficient for data operations (services). Commands get both clients through createContext().
- No
anytypes. Useunknown, codegen types, or explicit interfaces. - Strict mode is enabled in tsconfig.json.
- Explicit return types on all exported functions.
- ES module imports use
.jsextensions, even when importing.tsfiles.
Resolvers and services are stateless exported functions, not class methods. This keeps them simple and easy to test.
// Good: plain function
export async function listIssues(client: GraphQLClient, limit?: number): Promise<Issue[]> { ... }
// Avoid: class with methods
class IssueService { async listIssues(...) { ... } }Commands are thin orchestration layers. They create the client context, resolve IDs, call services, and output results. No business logic belongs here.
import { Command } from "commander";
import { createContext } from "../common/context.js";
import { handleCommand, outputSuccess } from "../common/output.js";
import { resolveTeamId } from "../resolvers/team-resolver.js";
import { createIssue } from "../services/issue-service.js";
export function setupIssuesCommands(program: Command): void {
const issues = program.command("issues");
issues
.command("create <title>")
.option("--team <id>", "Team key, name, or UUID")
.action(handleCommand(async (title, options, command) => {
const ctx = await createContext(command.parent!.parent!.opts());
const teamId = options.team
? await resolveTeamId(ctx.sdk, options.team)
: undefined;
const result = await createIssue(ctx.gql, { title, teamId });
outputSuccess(result);
}));
}Every .action() handler must be wrapped with handleCommand(), which catches errors and outputs them as JSON.
Register new command groups in src/main.ts:
import { setupEntityCommands } from "./commands/entity.js";
setupEntityCommands(program);Resolvers convert human-friendly identifiers (team keys, names, issue identifiers like ENG-123) into UUIDs. They use the LinearSdkClient and live in src/resolvers/.
import type { LinearSdkClient } from "../client/linear-client.js";
import { isUuid } from "../common/identifier.js";
export async function resolveTeamId(
client: LinearSdkClient,
keyOrNameOrId: string,
): Promise<string> {
if (isUuid(keyOrNameOrId)) return keyOrNameOrId;
const byKey = await client.sdk.teams({
filter: { key: { eq: keyOrNameOrId } },
first: 1,
});
if (byKey.nodes.length > 0) return byKey.nodes[0].id;
const byName = await client.sdk.teams({
filter: { name: { eq: keyOrNameOrId } },
first: 1,
});
if (byName.nodes.length > 0) return byName.nodes[0].id;
throw new Error(`Team "${keyOrNameOrId}" not found`);
}Rules for resolvers:
- Always accept a UUID passthrough as the first check.
- Return a UUID string, never an object.
- Use
LinearSdkClientonly (notGraphQLClient). - No CRUD operations or data transformations.
Services contain business logic and perform CRUD operations using the GraphQLClient. They accept pre-resolved UUIDs -- never human-friendly identifiers.
import type { GraphQLClient } from "../client/graphql-client.js";
import {
GetIssuesDocument,
type GetIssuesQuery,
CreateIssueDocument,
type CreateIssueMutation,
type IssueCreateInput,
} from "../gql/graphql.js";
export async function listIssues(
client: GraphQLClient,
limit: number = 25,
): Promise<Issue[]> {
const result = await client.request<GetIssuesQuery>(GetIssuesDocument, {
first: limit,
});
return result.issues.nodes;
}
export async function createIssue(
client: GraphQLClient,
input: IssueCreateInput,
): Promise<CreatedIssue> {
const result = await client.request<CreateIssueMutation>(
CreateIssueDocument,
{ input },
);
return result.issueCreate.issue;
}Rules for services:
- Use
GraphQLClientonly (notLinearSdkClient). - Accept UUIDs, not human-friendly identifiers.
- Import
DocumentNodeconstants and types fromsrc/gql/graphql.js. - Always type the
client.request<T>()call.
Linearis uses GraphQL Code Generator to produce typed query documents and result types. Never write raw GraphQL strings in TypeScript.
-
Edit the
.graphqlfile ingraphql/queries/orgraphql/mutations/:# graphql/queries/issues.graphql query GetIssues($first: Int) { issues(first: $first, orderBy: updatedAt) { nodes { id identifier title ... } } }
-
Run code generation:
npm run generate
This regenerates
src/gql/graphql.ts. Do not edit that file by hand. -
Import and use in a service:
import { GetIssuesDocument, // DocumentNode constant type GetIssuesQuery, // Result type } from "../gql/graphql.js"; const result = await client.request<GetIssuesQuery>( GetIssuesDocument, { first: 10 }, );
graphql/
queries/ # .graphql query definitions
mutations/ # .graphql mutation definitions
src/gql/ # Generated output (DO NOT EDIT)
Use the handleCommand() wrapper. It catches any thrown error and outputs it as JSON to stderr before exiting with code 1. No manual try/catch is needed in command handlers.
Throw descriptive errors using the helpers from src/common/errors.ts:
import { notFoundError, multipleMatchesError } from "../common/errors.js";
// Entity not found
throw notFoundError("Team", "ABC-123");
// Ambiguous match
throw multipleMatchesError("Cycle", "Sprint 1", ["id1", "id2"], "specify a team with --team");
// Invalid input
throw invalidParameterError("priority", "must be between 0 and 4");
// Missing required companion flag
throw requiresParameterError("--cycle", "--team");All command output is JSON:
// Success: written to stdout
outputSuccess(data); // JSON.stringify(data, null, 2)
// Error: written to stderr, exits with code 1
outputError(error); // { "error": "message" }For interactive setup, run linearis auth login — it opens Linear in the browser and stores the token encrypted in ~/.linearis/token.
The API token is resolved in this order:
--api-token <token>command-line flagLINEAR_API_TOKENenvironment variable~/.linearis/token(encrypted, set up vialinearis auth login)~/.linear_api_token(deprecated)
For local development, the interactive login is the most convenient:
linearis auth loginA typical feature addition touches four layers. Here is the sequence:
-
GraphQL operations -- Define queries and mutations in
graphql/queries/orgraphql/mutations/, then runnpm run generate. -
Resolver (if new entity types need ID resolution) -- Add a
resolve*Id()function insrc/resolvers/. UseLinearSdkClient, return a UUID string. -
Service -- Add functions in
src/services/. UseGraphQLClient, accept UUIDs, import codegen types. -
Command -- Add a
setup*Commands()function insrc/commands/. UsecreateContext(), resolve IDs, call services, output withoutputSuccess(). Register insrc/main.ts. -
Tests -- Add unit tests in
tests/unit/mirroring the source structure. Mock one layer deep (see testing docs).
| Script | Description |
|---|---|
npm start |
Run in dev mode via tsx (also runs codegen) |
npm run build |
Compile TypeScript to dist/ |
npm run clean |
Remove dist/ |
npm test |
Run tests with vitest |
npm run test:watch |
Run tests in watch mode |
npm run test:coverage |
Run tests with coverage |
npm run test:commands |
Check command coverage |
npm run generate |
Regenerate GraphQL types |
src/
main.ts # Entry point, registers all command groups
client/
graphql-client.ts # GraphQLClient - direct GraphQL execution
linear-client.ts # LinearSdkClient - SDK wrapper for resolvers
resolvers/ # Human ID to UUID resolution
team-resolver.ts
project-resolver.ts
label-resolver.ts
cycle-resolver.ts
status-resolver.ts
issue-resolver.ts
milestone-resolver.ts
services/ # Business logic and CRUD
issue-service.ts
document-service.ts
attachment-service.ts
milestone-service.ts
cycle-service.ts
team-service.ts
user-service.ts
project-service.ts
label-service.ts
comment-service.ts
file-service.ts
commands/ # CLI command definitions
auth.ts # Authentication (interactive, for humans)
issues.ts
documents.ts
project-milestones.ts
cycles.ts
teams.ts
users.ts
projects.ts
labels.ts
comments.ts
embeds.ts
common/ # Shared utilities
context.ts # CommandContext and createContext()
auth.ts # API token resolution (flag, env, encrypted, legacy)
token-storage.ts # Encrypted token storage
encryption.ts # AES-256-CBC encryption
output.ts # JSON output and handleCommand()
errors.ts # Error factory functions
identifier.ts # UUID validation and issue identifier parsing
types.ts # Type aliases from codegen
embed-parser.ts # Embed extraction utilities
usage.ts # Two-tier usage system (DomainMeta, formatOverview, formatDomainUsage)
gql/ # GraphQL codegen output (DO NOT EDIT)
graphql/
queries/ # GraphQL query definitions
mutations/ # GraphQL mutation definitions
tests/
unit/
resolvers/ # Resolver tests (mock SDK)
services/ # Service tests (mock GraphQL)
common/ # Pure function tests
Runtime:
@linear/sdk-- Linear SDK, used by resolvers for ID lookupscommander-- CLI framework
Development:
typescript-- Compilertsx-- TypeScript execution for developmentvitest-- Test runner@graphql-codegen/*-- GraphQL code generation suite