linear-cli-agents 0.1.1 → 0.2.0

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,4 +1,8 @@
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
 
@@ -163,6 +167,51 @@ pnpm build
163
167
  pnpm test
164
168
  ```
165
169
 
170
+ ## Troubleshooting
171
+
172
+ ### Authentication Issues
173
+
174
+ **"Not authenticated" error:**
175
+
176
+ ```bash
177
+ # Check current auth status
178
+ linear auth status
179
+
180
+ # Re-authenticate
181
+ linear auth login --key lin_api_xxxxx
182
+ ```
183
+
184
+ **Using environment variable:**
185
+
186
+ ```bash
187
+ export LINEAR_API_KEY=lin_api_xxxxx
188
+ linear auth status # Should show source: environment
189
+ ```
190
+
191
+ ### Common Errors
192
+
193
+ | Error Code | Cause | Solution |
194
+ | ------------------- | -------------------------- | ----------------------------------------------- |
195
+ | `NOT_AUTHENTICATED` | No API key configured | Run `linear auth login` or set `LINEAR_API_KEY` |
196
+ | `INVALID_API_KEY` | API key expired or invalid | Generate a new key in Linear settings |
197
+ | `NOT_FOUND` | Resource doesn't exist | Check the issue/team identifier |
198
+ | `RATE_LIMITED` | Too many requests | Wait before retrying |
199
+
200
+ ### Getting Help
201
+
202
+ ```bash
203
+ # See all commands
204
+ linear --help
205
+
206
+ # Get help for a specific command
207
+ linear issues --help
208
+ linear issues create --help
209
+ ```
210
+
211
+ ## Contributing
212
+
213
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
214
+
166
215
  ## License
167
216
 
168
217
  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
+ }
@@ -0,0 +1,100 @@
1
+ import { Args, Command, Flags } from '@oclif/core';
2
+ import open from 'open';
3
+ import { getClient } from '../lib/client.js';
4
+ import { success, print } from '../lib/output.js';
5
+ import { handleError } from '../lib/errors.js';
6
+ import { parseIdentifier, isUUID, resolveIssueId } from '../lib/issue-utils.js';
7
+ export default class Open extends Command {
8
+ static description = 'Open Linear resources in browser';
9
+ static examples = [
10
+ '<%= config.bin %> open ENG-123',
11
+ '<%= config.bin %> open --team ENG',
12
+ '<%= config.bin %> open --inbox',
13
+ '<%= config.bin %> open --settings',
14
+ '<%= config.bin %> open --my-issues',
15
+ ];
16
+ static args = {
17
+ issue: Args.string({
18
+ description: 'Issue identifier (e.g., ENG-123) or ID to open',
19
+ required: false,
20
+ }),
21
+ };
22
+ static flags = {
23
+ team: Flags.string({
24
+ char: 't',
25
+ description: 'Open team page by key (e.g., ENG)',
26
+ exclusive: ['inbox', 'settings', 'my-issues'],
27
+ }),
28
+ inbox: Flags.boolean({
29
+ description: 'Open inbox',
30
+ exclusive: ['team', 'settings', 'my-issues'],
31
+ }),
32
+ settings: Flags.boolean({
33
+ description: 'Open workspace settings',
34
+ exclusive: ['team', 'inbox', 'my-issues'],
35
+ }),
36
+ 'my-issues': Flags.boolean({
37
+ description: 'Open my issues view',
38
+ exclusive: ['team', 'inbox', 'settings'],
39
+ }),
40
+ };
41
+ async run() {
42
+ try {
43
+ const { args, flags } = await this.parse(Open);
44
+ const client = getClient();
45
+ // Get organization for URL building
46
+ const viewer = await client.viewer;
47
+ const organization = await viewer.organization;
48
+ if (!organization) {
49
+ throw new Error('Could not determine organization');
50
+ }
51
+ const orgKey = organization.urlKey;
52
+ let url;
53
+ if (args.issue) {
54
+ // Open specific issue
55
+ const parsed = parseIdentifier(args.issue);
56
+ if (parsed) {
57
+ url = `https://linear.app/${orgKey}/issue/${args.issue}`;
58
+ }
59
+ else if (isUUID(args.issue)) {
60
+ // Need to fetch issue to get identifier for URL
61
+ const issueId = await resolveIssueId(client, args.issue);
62
+ const issue = await client.issue(issueId);
63
+ url = issue.url;
64
+ }
65
+ else {
66
+ throw new Error(`Invalid issue identifier: ${args.issue}`);
67
+ }
68
+ }
69
+ else if (flags.team) {
70
+ // Open team page
71
+ url = `https://linear.app/${orgKey}/team/${flags.team}`;
72
+ }
73
+ else if (flags.inbox) {
74
+ // Open inbox
75
+ url = `https://linear.app/${orgKey}/inbox`;
76
+ }
77
+ else if (flags.settings) {
78
+ // Open settings
79
+ url = `https://linear.app/${orgKey}/settings`;
80
+ }
81
+ else if (flags['my-issues']) {
82
+ // Open my issues
83
+ url = `https://linear.app/${orgKey}/my-issues`;
84
+ }
85
+ else {
86
+ // Default: open workspace home
87
+ url = `https://linear.app/${orgKey}`;
88
+ }
89
+ await open(url);
90
+ print(success({
91
+ opened: true,
92
+ url,
93
+ }));
94
+ }
95
+ catch (err) {
96
+ handleError(err);
97
+ this.exit(1);
98
+ }
99
+ }
100
+ }
@@ -0,0 +1,11 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class TeamsList extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ format: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
7
+ first: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
8
+ after: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
+ };
10
+ run(): Promise<void>;
11
+ }