linear-cli-agents 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,19 @@
1
- # linear-cli
1
+ # linear-cli-agents
2
+
3
+ [![npm version](https://img.shields.io/npm/v/linear-cli-agents.svg)](https://www.npmjs.com/package/linear-cli-agents)
4
+ [![CI](https://github.com/nchgn/linear-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/nchgn/linear-cli/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
2
6
 
3
7
  A CLI for interacting with [Linear](https://linear.app), designed for LLMs and agents.
4
8
 
5
9
  ## Features
6
10
 
7
11
  - **JSON output**: All commands return structured JSON, perfect for parsing by LLMs
12
+ - **Multiple formats**: JSON (default), table (colored), or plain text output
8
13
  - **Schema introspection**: Discover available operations programmatically
9
14
  - **Full CRUD for issues**: List, create, update, and delete issues
15
+ - **Team management**: List and browse teams
16
+ - **Browser integration**: Open issues, teams, inbox directly in Linear
10
17
  - **Raw GraphQL queries**: Execute any GraphQL query directly
11
18
 
12
19
  ## Installation
@@ -19,9 +26,10 @@ pnpm add -g linear-cli-agents
19
26
 
20
27
  ## Authentication
21
28
 
22
- Get your API key from [Linear Settings > API](https://linear.app/settings/api).
23
-
24
29
  ```bash
30
+ # Open Linear API settings in browser to create a key
31
+ linear auth login --browser
32
+
25
33
  # Login with API key
26
34
  linear auth login --key lin_api_xxxxx
27
35
 
@@ -31,6 +39,9 @@ export LINEAR_API_KEY=lin_api_xxxxx
31
39
  # Check auth status
32
40
  linear auth status
33
41
 
42
+ # View current user info
43
+ linear me
44
+
34
45
  # Logout
35
46
  linear auth logout
36
47
  ```
@@ -49,8 +60,13 @@ linear issues list --assignee me
49
60
  linear issues list --state "In Progress"
50
61
  linear issues list --filter '{"priority":{"lte":2}}'
51
62
 
63
+ # Output formats: json (default), table (colored), plain (IDs only)
64
+ linear issues list --format table
65
+ linear issues list --format plain
66
+
52
67
  # Get a specific issue
53
68
  linear issues get ENG-123
69
+ linear issues get ENG-123 --format table
54
70
 
55
71
  # Create an issue
56
72
  linear issues create --title "Bug fix" --team-id <team-id>
@@ -64,6 +80,46 @@ linear issues update ENG-123 --state-id <state-id> --assignee-id <user-id>
64
80
  linear issues delete ENG-123
65
81
  ```
66
82
 
83
+ ### Teams
84
+
85
+ ```bash
86
+ # List all teams
87
+ linear teams list
88
+
89
+ # With table format
90
+ linear teams list --format table
91
+ ```
92
+
93
+ ### Open in Browser
94
+
95
+ ```bash
96
+ # Open an issue
97
+ linear open ENG-123
98
+
99
+ # Open a team
100
+ linear open --team ENG
101
+
102
+ # Open inbox
103
+ linear open --inbox
104
+
105
+ # Open my issues
106
+ linear open --my-issues
107
+
108
+ # Open settings
109
+ linear open --settings
110
+ ```
111
+
112
+ ### User Info
113
+
114
+ ```bash
115
+ # Show current user
116
+ linear me
117
+ linear whoami
118
+
119
+ # With table format
120
+ linear me --format table
121
+ ```
122
+
67
123
  ### Schema Introspection (for LLMs)
68
124
 
69
125
  ```bash
@@ -93,7 +149,9 @@ linear query --gql "query(\$id: String!) { issue(id: \$id) { title } }" \
93
149
 
94
150
  ## Output Format
95
151
 
96
- All commands return structured JSON:
152
+ ### JSON (default)
153
+
154
+ All commands return structured JSON by default, ideal for LLMs and scripts:
97
155
 
98
156
  ```json
99
157
  // Success
@@ -122,6 +180,37 @@ All commands return structured JSON:
122
180
  }
123
181
  ```
124
182
 
183
+ ### Table (human-readable)
184
+
185
+ Use `--format table` for colored, human-readable output:
186
+
187
+ ```bash
188
+ linear issues list --format table
189
+ # ID PRI TITLE
190
+ # ENG-123 High Fix login bug
191
+ # ENG-124 Medium Add dark mode
192
+ ```
193
+
194
+ ### Plain (minimal)
195
+
196
+ Use `--format plain` for minimal output (IDs/identifiers only):
197
+
198
+ ```bash
199
+ linear issues list --format plain
200
+ # ENG-123 Fix login bug
201
+ # ENG-124 Add dark mode
202
+ ```
203
+
204
+ ### Disabling Colors
205
+
206
+ Colors are automatically disabled when piping output. You can also disable them manually:
207
+
208
+ ```bash
209
+ NO_COLOR=1 linear issues list --format table
210
+ # or
211
+ linear issues list --format table --no-color
212
+ ```
213
+
125
214
  ## For LLM Integration
126
215
 
127
216
  The CLI is designed to be easily used by LLMs and AI agents:
@@ -163,6 +252,51 @@ pnpm build
163
252
  pnpm test
164
253
  ```
165
254
 
255
+ ## Troubleshooting
256
+
257
+ ### Authentication Issues
258
+
259
+ **"Not authenticated" error:**
260
+
261
+ ```bash
262
+ # Check current auth status
263
+ linear auth status
264
+
265
+ # Re-authenticate
266
+ linear auth login --key lin_api_xxxxx
267
+ ```
268
+
269
+ **Using environment variable:**
270
+
271
+ ```bash
272
+ export LINEAR_API_KEY=lin_api_xxxxx
273
+ linear auth status # Should show source: environment
274
+ ```
275
+
276
+ ### Common Errors
277
+
278
+ | Error Code | Cause | Solution |
279
+ | ------------------- | -------------------------- | ----------------------------------------------- |
280
+ | `NOT_AUTHENTICATED` | No API key configured | Run `linear auth login` or set `LINEAR_API_KEY` |
281
+ | `INVALID_API_KEY` | API key expired or invalid | Generate a new key in Linear settings |
282
+ | `NOT_FOUND` | Resource doesn't exist | Check the issue/team identifier |
283
+ | `RATE_LIMITED` | Too many requests | Wait before retrying |
284
+
285
+ ### Getting Help
286
+
287
+ ```bash
288
+ # See all commands
289
+ linear --help
290
+
291
+ # Get help for a specific command
292
+ linear issues --help
293
+ linear issues create --help
294
+ ```
295
+
296
+ ## Contributing
297
+
298
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
299
+
166
300
  ## License
167
301
 
168
302
  MIT
package/bin/dev.js CHANGED
File without changes
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ /**
3
+ * Note: Full integration tests for auth commands require mocking
4
+ * the Linear API client, which is complex with oclif's command runner.
5
+ *
6
+ * For now, we test the output format contracts and defer
7
+ * integration testing to manual verification or E2E tests.
8
+ *
9
+ * The underlying utilities (output.ts, errors.ts) are fully tested.
10
+ */
11
+ describe('auth status command', () => {
12
+ it('should have proper command structure', async () => {
13
+ // Dynamic import to verify module loads correctly
14
+ const { default: AuthStatus } = await import('../status.js');
15
+ expect(AuthStatus.description).toBe('Check authentication status');
16
+ expect(AuthStatus.examples).toHaveLength(1);
17
+ });
18
+ it('should export as oclif Command', async () => {
19
+ const { default: AuthStatus } = await import('../status.js');
20
+ const { Command } = await import('@oclif/core');
21
+ expect(AuthStatus.prototype).toBeInstanceOf(Command.prototype.constructor);
22
+ });
23
+ });
@@ -4,6 +4,7 @@ export default class AuthLogin extends Command {
4
4
  static examples: string[];
5
5
  static flags: {
6
6
  key: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
+ browser: import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
8
  };
8
9
  static args: {
9
10
  key: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
@@ -1,12 +1,16 @@
1
1
  import { Args, Command, Flags } from '@oclif/core';
2
+ import open from 'open';
2
3
  import { createClient } from '../../lib/client.js';
3
4
  import { saveApiKey, getConfigPath } from '../../lib/config.js';
4
- import { success, print } from '../../lib/output.js';
5
- import { handleError, CliError, ErrorCodes } from '../../lib/errors.js';
5
+ import { success, error, print } from '../../lib/output.js';
6
+ import { handleError } from '../../lib/errors.js';
7
+ import { colors } from '../../lib/formatter.js';
8
+ const LINEAR_API_SETTINGS_URL = 'https://linear.app/settings/api';
6
9
  export default class AuthLogin extends Command {
7
10
  static description = 'Authenticate with Linear using an API key';
8
11
  static examples = [
9
12
  '<%= config.bin %> auth login --key lin_api_xxxxx',
13
+ '<%= config.bin %> auth login --browser',
10
14
  'LINEAR_API_KEY=lin_api_xxxxx <%= config.bin %> auth login',
11
15
  ];
12
16
  static flags = {
@@ -15,6 +19,11 @@ export default class AuthLogin extends Command {
15
19
  description: 'Linear API key (or set LINEAR_API_KEY env var)',
16
20
  env: 'LINEAR_API_KEY',
17
21
  }),
22
+ browser: Flags.boolean({
23
+ char: 'b',
24
+ description: 'Open Linear API settings in browser to create a key',
25
+ default: false,
26
+ }),
18
27
  };
19
28
  static args = {
20
29
  key: Args.string({
@@ -26,8 +35,35 @@ export default class AuthLogin extends Command {
26
35
  try {
27
36
  const { args, flags } = await this.parse(AuthLogin);
28
37
  const apiKey = flags.key || args.key;
38
+ // If --browser flag, open Linear API settings
39
+ if (flags.browser) {
40
+ await open(LINEAR_API_SETTINGS_URL);
41
+ print(success({
42
+ message: 'Opened Linear API settings in browser',
43
+ url: LINEAR_API_SETTINGS_URL,
44
+ instructions: [
45
+ '1. Click "Create key" or "New API key"',
46
+ '2. Give it a label (e.g., "CLI")',
47
+ '3. Copy the generated key (starts with lin_api_)',
48
+ '4. Run: linear auth login --key YOUR_KEY',
49
+ ],
50
+ }));
51
+ return;
52
+ }
53
+ // If no API key provided, show helpful instructions
29
54
  if (!apiKey) {
30
- throw new CliError(ErrorCodes.MISSING_REQUIRED_FIELD, 'API key is required. Use --key flag or set LINEAR_API_KEY environment variable.');
55
+ console.log(colors.bold('\nTo authenticate with Linear CLI, you need an API key.\n'));
56
+ console.log(colors.cyan('Option 1: Open browser to create a key'));
57
+ console.log(' linear auth login --browser\n');
58
+ console.log(colors.cyan('Option 2: If you already have a key'));
59
+ console.log(' linear auth login --key lin_api_xxxxx\n');
60
+ console.log(colors.cyan('Option 3: Set environment variable'));
61
+ console.log(' export LINEAR_API_KEY=lin_api_xxxxx\n');
62
+ console.log(colors.dim(`Manual URL: ${LINEAR_API_SETTINGS_URL}`));
63
+ console.log('');
64
+ print(error('MISSING_API_KEY', 'No API key provided. Use --browser to create one or --key to provide an existing key.', { url: LINEAR_API_SETTINGS_URL }));
65
+ this.exit(1);
66
+ return;
31
67
  }
32
68
  // Validate the API key by making a test request
33
69
  const client = createClient(apiKey);
@@ -48,9 +48,7 @@ export default class IssuesDelete extends Command {
48
48
  identifier,
49
49
  deleted: true,
50
50
  permanent: flags.permanent,
51
- message: flags.permanent
52
- ? `Issue ${identifier} permanently deleted`
53
- : `Issue ${identifier} moved to trash`,
51
+ message: flags.permanent ? `Issue ${identifier} permanently deleted` : `Issue ${identifier} moved to trash`,
54
52
  }));
55
53
  }
56
54
  catch (err) {
@@ -5,5 +5,8 @@ export default class IssuesGet extends Command {
5
5
  static args: {
6
6
  id: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
7
7
  };
8
+ static flags: {
9
+ format: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
+ };
8
11
  run(): Promise<void>;
9
12
  }
@@ -1,13 +1,14 @@
1
- import { Args, Command } from '@oclif/core';
1
+ import { Args, Command, Flags } from '@oclif/core';
2
2
  import { getClient } from '../../lib/client.js';
3
- import { success, print } from '../../lib/output.js';
3
+ import { success, print, printItem } from '../../lib/output.js';
4
4
  import { handleError, CliError, ErrorCodes } from '../../lib/errors.js';
5
5
  import { resolveIssueId } from '../../lib/issue-utils.js';
6
6
  export default class IssuesGet extends Command {
7
7
  static description = 'Get a single issue by ID or identifier';
8
8
  static examples = [
9
- '<%= config.bin %> issues get abc123',
10
9
  '<%= config.bin %> issues get ENG-123',
10
+ '<%= config.bin %> issues get ENG-123 --format table',
11
+ '<%= config.bin %> issues get abc123',
11
12
  ];
12
13
  static args = {
13
14
  id: Args.string({
@@ -15,9 +16,18 @@ export default class IssuesGet extends Command {
15
16
  required: true,
16
17
  }),
17
18
  };
19
+ static flags = {
20
+ format: Flags.string({
21
+ char: 'F',
22
+ description: 'Output format',
23
+ options: ['json', 'table', 'plain'],
24
+ default: 'json',
25
+ }),
26
+ };
18
27
  async run() {
19
28
  try {
20
- const { args } = await this.parse(IssuesGet);
29
+ const { args, flags } = await this.parse(IssuesGet);
30
+ const format = flags.format;
21
31
  const client = getClient();
22
32
  const issueId = await resolveIssueId(client, args.id);
23
33
  const issue = await client.issue(issueId);
@@ -32,7 +42,7 @@ export default class IssuesGet extends Command {
32
42
  issue.labels(),
33
43
  issue.comments(),
34
44
  ]);
35
- print(success({
45
+ const data = {
36
46
  id: issue.id,
37
47
  identifier: issue.identifier,
38
48
  title: issue.title,
@@ -71,7 +81,30 @@ export default class IssuesGet extends Command {
71
81
  color: label.color,
72
82
  })),
73
83
  commentsCount: comments.nodes.length,
74
- }));
84
+ };
85
+ if (format === 'json') {
86
+ print(success(data));
87
+ }
88
+ else if (format === 'table') {
89
+ printItem({
90
+ identifier: data.identifier,
91
+ title: data.title,
92
+ state: data.state?.name ?? 'N/A',
93
+ priority: data.priorityLabel,
94
+ team: data.team?.key ?? 'N/A',
95
+ assignee: data.assignee?.name ?? 'Unassigned',
96
+ labels: data.labels.map((l) => l.name).join(', ') || 'None',
97
+ estimate: data.estimate ?? 'None',
98
+ comments: data.commentsCount,
99
+ url: data.url,
100
+ createdAt: data.createdAt,
101
+ updatedAt: data.updatedAt,
102
+ }, format);
103
+ }
104
+ else {
105
+ // plain: just the identifier
106
+ console.log(data.identifier);
107
+ }
75
108
  }
76
109
  catch (err) {
77
110
  handleError(err);
@@ -3,6 +3,7 @@ export default class IssuesList extends Command {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  static flags: {
6
+ format: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
6
7
  team: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
8
  assignee: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
9
  state: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
@@ -1,17 +1,42 @@
1
1
  import { Command, Flags } from '@oclif/core';
2
2
  import { getClient } from '../../lib/client.js';
3
- import { successList, print } from '../../lib/output.js';
3
+ import { successList, print, printList } from '../../lib/output.js';
4
4
  import { handleError } from '../../lib/errors.js';
5
+ import { colors, truncate, formatPriority } from '../../lib/formatter.js';
6
+ const COLUMNS = [
7
+ {
8
+ key: 'identifier',
9
+ header: 'ID',
10
+ format: (value) => colors.cyan(String(value)),
11
+ },
12
+ {
13
+ key: 'priority',
14
+ header: 'PRI',
15
+ format: (value) => formatPriority(Number(value)),
16
+ },
17
+ {
18
+ key: 'title',
19
+ header: 'TITLE',
20
+ format: (value) => truncate(String(value), 50),
21
+ },
22
+ ];
5
23
  export default class IssuesList extends Command {
6
24
  static description = 'List issues with optional filtering';
7
25
  static examples = [
8
26
  '<%= config.bin %> issues list',
27
+ '<%= config.bin %> issues list --format table',
9
28
  '<%= config.bin %> issues list --team ENG',
10
29
  '<%= config.bin %> issues list --assignee me',
11
30
  '<%= config.bin %> issues list --filter \'{"state":{"name":{"eq":"In Progress"}}}\'',
12
31
  '<%= config.bin %> issues list --first 50 --after cursor123',
13
32
  ];
14
33
  static flags = {
34
+ format: Flags.string({
35
+ char: 'F',
36
+ description: 'Output format',
37
+ options: ['json', 'table', 'plain'],
38
+ default: 'json',
39
+ }),
15
40
  team: Flags.string({
16
41
  char: 't',
17
42
  description: 'Filter by team key (e.g., ENG)',
@@ -39,6 +64,7 @@ export default class IssuesList extends Command {
39
64
  async run() {
40
65
  try {
41
66
  const { flags } = await this.parse(IssuesList);
67
+ const format = flags.format;
42
68
  const client = getClient();
43
69
  // Build filter
44
70
  let filter = {};
@@ -78,20 +104,31 @@ export default class IssuesList extends Command {
78
104
  id: issue.id,
79
105
  identifier: issue.identifier,
80
106
  title: issue.title,
81
- description: issue.description,
107
+ description: issue.description ?? undefined,
82
108
  priority: issue.priority,
83
109
  priorityLabel: issue.priorityLabel,
84
- estimate: issue.estimate,
110
+ estimate: issue.estimate ?? undefined,
85
111
  url: issue.url,
86
112
  createdAt: issue.createdAt,
87
113
  updatedAt: issue.updatedAt,
88
114
  }));
89
- print(successList(data, {
115
+ const pageInfo = {
90
116
  hasNextPage: issues.pageInfo.hasNextPage,
91
117
  hasPreviousPage: issues.pageInfo.hasPreviousPage,
92
118
  startCursor: issues.pageInfo.startCursor,
93
119
  endCursor: issues.pageInfo.endCursor,
94
- }));
120
+ };
121
+ if (format === 'json') {
122
+ print(successList(data, pageInfo));
123
+ }
124
+ else {
125
+ printList(data, format, {
126
+ columns: COLUMNS,
127
+ primaryKey: 'identifier',
128
+ secondaryKey: 'title',
129
+ pageInfo,
130
+ });
131
+ }
95
132
  }
96
133
  catch (err) {
97
134
  handleError(err);
@@ -0,0 +1,10 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class Me extends Command {
3
+ static description: string;
4
+ static aliases: string[];
5
+ static examples: string[];
6
+ static flags: {
7
+ format: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
+ };
9
+ run(): Promise<void>;
10
+ }
@@ -0,0 +1,76 @@
1
+ import { Command, Flags } from '@oclif/core';
2
+ import { getClient } from '../lib/client.js';
3
+ import { success, print, printItem } from '../lib/output.js';
4
+ import { handleError } from '../lib/errors.js';
5
+ export default class Me extends Command {
6
+ static description = 'Show current user information';
7
+ static aliases = ['whoami'];
8
+ static examples = [
9
+ '<%= config.bin %> me',
10
+ '<%= config.bin %> me --format table',
11
+ '<%= config.bin %> whoami',
12
+ ];
13
+ static flags = {
14
+ format: Flags.string({
15
+ char: 'F',
16
+ description: 'Output format',
17
+ options: ['json', 'table', 'plain'],
18
+ default: 'json',
19
+ }),
20
+ };
21
+ async run() {
22
+ try {
23
+ const { flags } = await this.parse(Me);
24
+ const format = flags.format;
25
+ const client = getClient();
26
+ const viewer = await client.viewer;
27
+ const [teams, organization] = await Promise.all([viewer.teams(), viewer.organization]);
28
+ const data = {
29
+ id: viewer.id,
30
+ name: viewer.name,
31
+ email: viewer.email,
32
+ displayName: viewer.displayName,
33
+ active: viewer.active,
34
+ admin: viewer.admin,
35
+ timezone: viewer.timezone,
36
+ createdAt: viewer.createdAt,
37
+ organization: organization
38
+ ? {
39
+ id: organization.id,
40
+ name: organization.name,
41
+ urlKey: organization.urlKey,
42
+ }
43
+ : null,
44
+ teams: teams.nodes.map((team) => ({
45
+ id: team.id,
46
+ key: team.key,
47
+ name: team.name,
48
+ })),
49
+ };
50
+ if (format === 'json') {
51
+ print(success(data));
52
+ }
53
+ else if (format === 'table') {
54
+ printItem({
55
+ id: data.id,
56
+ name: data.name,
57
+ email: data.email,
58
+ displayName: data.displayName,
59
+ active: data.active ? 'Yes' : 'No',
60
+ admin: data.admin ? 'Yes' : 'No',
61
+ timezone: data.timezone,
62
+ organization: data.organization?.name ?? 'N/A',
63
+ teams: data.teams.map((t) => t.key).join(', '),
64
+ }, format);
65
+ }
66
+ else {
67
+ // plain: just the email
68
+ console.log(data.email);
69
+ }
70
+ }
71
+ catch (err) {
72
+ handleError(err);
73
+ this.exit(1);
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,15 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class Open extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static args: {
6
+ issue: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
7
+ };
8
+ static flags: {
9
+ team: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ inbox: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ settings: import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
+ 'my-issues': import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
+ };
14
+ run(): Promise<void>;
15
+ }