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 +138 -4
- package/bin/dev.js +0 -0
- package/dist/commands/auth/__tests__/status.test.d.ts +1 -0
- package/dist/commands/auth/__tests__/status.test.js +23 -0
- package/dist/commands/auth/login.d.ts +1 -0
- package/dist/commands/auth/login.js +39 -3
- package/dist/commands/issues/delete.js +1 -3
- package/dist/commands/issues/get.d.ts +3 -0
- package/dist/commands/issues/get.js +39 -6
- package/dist/commands/issues/list.d.ts +1 -0
- package/dist/commands/issues/list.js +42 -5
- package/dist/commands/me.d.ts +10 -0
- package/dist/commands/me.js +76 -0
- package/dist/commands/open.d.ts +15 -0
- package/dist/commands/open.js +100 -0
- package/dist/commands/teams/list.d.ts +11 -0
- package/dist/commands/teams/list.js +99 -0
- package/dist/lib/__tests__/errors.test.d.ts +1 -0
- package/dist/lib/__tests__/errors.test.js +98 -0
- package/dist/lib/__tests__/output.test.d.ts +1 -0
- package/dist/lib/__tests__/output.test.js +112 -0
- package/dist/lib/formatter.d.ts +59 -0
- package/dist/lib/formatter.js +192 -0
- package/dist/lib/output.d.ts +17 -1
- package/dist/lib/output.js +43 -0
- package/dist/lib/types.d.ts +7 -0
- package/oclif.manifest.json +221 -4
- package/package.json +35 -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
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
import { getClient } from '../../lib/client.js';
|
|
3
|
+
import { successList, print, printList } from '../../lib/output.js';
|
|
4
|
+
import { handleError } from '../../lib/errors.js';
|
|
5
|
+
import { colors, truncate } from '../../lib/formatter.js';
|
|
6
|
+
const COLUMNS = [
|
|
7
|
+
{
|
|
8
|
+
key: 'key',
|
|
9
|
+
header: 'KEY',
|
|
10
|
+
format: (value) => colors.cyan(String(value)),
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
key: 'name',
|
|
14
|
+
header: 'NAME',
|
|
15
|
+
format: (value) => colors.bold(String(value)),
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
key: 'description',
|
|
19
|
+
header: 'DESCRIPTION',
|
|
20
|
+
format: (value) => truncate(String(value ?? ''), 40),
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
key: 'private',
|
|
24
|
+
header: 'PRIVATE',
|
|
25
|
+
format: (value) => (value ? colors.yellow('Yes') : colors.gray('No')),
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
key: 'issueCount',
|
|
29
|
+
header: 'ISSUES',
|
|
30
|
+
format: (value) => colors.dim(String(value)),
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
export default class TeamsList extends Command {
|
|
34
|
+
static description = 'List teams in the workspace';
|
|
35
|
+
static examples = [
|
|
36
|
+
'<%= config.bin %> teams list',
|
|
37
|
+
'<%= config.bin %> teams list --format table',
|
|
38
|
+
'<%= config.bin %> teams list --first 10',
|
|
39
|
+
];
|
|
40
|
+
static flags = {
|
|
41
|
+
format: Flags.string({
|
|
42
|
+
char: 'F',
|
|
43
|
+
description: 'Output format',
|
|
44
|
+
options: ['json', 'table', 'plain'],
|
|
45
|
+
default: 'json',
|
|
46
|
+
}),
|
|
47
|
+
first: Flags.integer({
|
|
48
|
+
description: 'Number of teams to fetch (default: 50)',
|
|
49
|
+
default: 50,
|
|
50
|
+
}),
|
|
51
|
+
after: Flags.string({
|
|
52
|
+
description: 'Cursor for pagination',
|
|
53
|
+
}),
|
|
54
|
+
};
|
|
55
|
+
async run() {
|
|
56
|
+
try {
|
|
57
|
+
const { flags } = await this.parse(TeamsList);
|
|
58
|
+
const format = flags.format;
|
|
59
|
+
const client = getClient();
|
|
60
|
+
const teams = await client.teams({
|
|
61
|
+
first: flags.first,
|
|
62
|
+
after: flags.after,
|
|
63
|
+
});
|
|
64
|
+
const data = await Promise.all(teams.nodes.map(async (team) => {
|
|
65
|
+
const issues = await team.issues({ first: 0 });
|
|
66
|
+
return {
|
|
67
|
+
id: team.id,
|
|
68
|
+
key: team.key,
|
|
69
|
+
name: team.name,
|
|
70
|
+
description: team.description ?? undefined,
|
|
71
|
+
private: team.private,
|
|
72
|
+
issueCount: issues.pageInfo.hasNextPage ? 50 : issues.nodes.length,
|
|
73
|
+
createdAt: team.createdAt,
|
|
74
|
+
};
|
|
75
|
+
}));
|
|
76
|
+
const pageInfo = {
|
|
77
|
+
hasNextPage: teams.pageInfo.hasNextPage,
|
|
78
|
+
hasPreviousPage: teams.pageInfo.hasPreviousPage,
|
|
79
|
+
startCursor: teams.pageInfo.startCursor,
|
|
80
|
+
endCursor: teams.pageInfo.endCursor,
|
|
81
|
+
};
|
|
82
|
+
if (format === 'json') {
|
|
83
|
+
print(successList(data, pageInfo));
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
printList(data, format, {
|
|
87
|
+
columns: COLUMNS,
|
|
88
|
+
primaryKey: 'key',
|
|
89
|
+
secondaryKey: 'name',
|
|
90
|
+
pageInfo,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
handleError(err);
|
|
96
|
+
this.exit(1);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { CliError, ErrorCodes, handleError } from '../errors.js';
|
|
3
|
+
describe('errors utilities', () => {
|
|
4
|
+
describe('CliError', () => {
|
|
5
|
+
it('creates error with code and message', () => {
|
|
6
|
+
const err = new CliError(ErrorCodes.NOT_FOUND, 'Issue not found');
|
|
7
|
+
expect(err.code).toBe('NOT_FOUND');
|
|
8
|
+
expect(err.message).toBe('Issue not found');
|
|
9
|
+
expect(err.details).toBeUndefined();
|
|
10
|
+
expect(err.name).toBe('CliError');
|
|
11
|
+
});
|
|
12
|
+
it('creates error with details', () => {
|
|
13
|
+
const details = { issueId: 'ENG-123' };
|
|
14
|
+
const err = new CliError(ErrorCodes.NOT_FOUND, 'Issue not found', details);
|
|
15
|
+
expect(err.details).toEqual({ issueId: 'ENG-123' });
|
|
16
|
+
});
|
|
17
|
+
it('toResponse returns error response format', () => {
|
|
18
|
+
const err = new CliError(ErrorCodes.INVALID_INPUT, 'Bad input', {
|
|
19
|
+
field: 'title',
|
|
20
|
+
});
|
|
21
|
+
const response = err.toResponse();
|
|
22
|
+
expect(response).toEqual({
|
|
23
|
+
success: false,
|
|
24
|
+
error: {
|
|
25
|
+
code: 'INVALID_INPUT',
|
|
26
|
+
message: 'Bad input',
|
|
27
|
+
details: { field: 'title' },
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe('handleError', () => {
|
|
33
|
+
let consoleSpy;
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
36
|
+
});
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
consoleSpy.mockRestore();
|
|
39
|
+
});
|
|
40
|
+
it('handles CliError', () => {
|
|
41
|
+
const err = new CliError(ErrorCodes.NOT_FOUND, 'Not found');
|
|
42
|
+
handleError(err);
|
|
43
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
44
|
+
const output = JSON.parse(consoleSpy.mock.calls[0][0]);
|
|
45
|
+
expect(output.success).toBe(false);
|
|
46
|
+
expect(output.error.code).toBe('NOT_FOUND');
|
|
47
|
+
});
|
|
48
|
+
it('handles 401 unauthorized errors', () => {
|
|
49
|
+
const err = new Error('Request failed with status 401');
|
|
50
|
+
handleError(err);
|
|
51
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
52
|
+
const output = JSON.parse(consoleSpy.mock.calls[0][0]);
|
|
53
|
+
expect(output.error.code).toBe('INVALID_API_KEY');
|
|
54
|
+
});
|
|
55
|
+
it('handles 404 not found errors', () => {
|
|
56
|
+
const err = new Error('Resource not found');
|
|
57
|
+
handleError(err);
|
|
58
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
59
|
+
const output = JSON.parse(consoleSpy.mock.calls[0][0]);
|
|
60
|
+
expect(output.error.code).toBe('NOT_FOUND');
|
|
61
|
+
});
|
|
62
|
+
it('handles rate limit errors', () => {
|
|
63
|
+
const err = new Error('rate limit exceeded');
|
|
64
|
+
handleError(err);
|
|
65
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
66
|
+
const output = JSON.parse(consoleSpy.mock.calls[0][0]);
|
|
67
|
+
expect(output.error.code).toBe('RATE_LIMITED');
|
|
68
|
+
});
|
|
69
|
+
it('handles generic Error as API_ERROR', () => {
|
|
70
|
+
const err = new Error('Something went wrong');
|
|
71
|
+
handleError(err);
|
|
72
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
73
|
+
const output = JSON.parse(consoleSpy.mock.calls[0][0]);
|
|
74
|
+
expect(output.error.code).toBe('API_ERROR');
|
|
75
|
+
expect(output.error.message).toBe('Something went wrong');
|
|
76
|
+
});
|
|
77
|
+
it('handles unknown error types', () => {
|
|
78
|
+
handleError('string error');
|
|
79
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
80
|
+
const output = JSON.parse(consoleSpy.mock.calls[0][0]);
|
|
81
|
+
expect(output.error.code).toBe('UNKNOWN_ERROR');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
describe('ErrorCodes', () => {
|
|
85
|
+
it('has all expected error codes', () => {
|
|
86
|
+
expect(ErrorCodes.NOT_AUTHENTICATED).toBe('NOT_AUTHENTICATED');
|
|
87
|
+
expect(ErrorCodes.INVALID_API_KEY).toBe('INVALID_API_KEY');
|
|
88
|
+
expect(ErrorCodes.NOT_FOUND).toBe('NOT_FOUND');
|
|
89
|
+
expect(ErrorCodes.ALREADY_EXISTS).toBe('ALREADY_EXISTS');
|
|
90
|
+
expect(ErrorCodes.INVALID_INPUT).toBe('INVALID_INPUT');
|
|
91
|
+
expect(ErrorCodes.MISSING_REQUIRED_FIELD).toBe('MISSING_REQUIRED_FIELD');
|
|
92
|
+
expect(ErrorCodes.API_ERROR).toBe('API_ERROR');
|
|
93
|
+
expect(ErrorCodes.RATE_LIMITED).toBe('RATE_LIMITED');
|
|
94
|
+
expect(ErrorCodes.CONFIG_ERROR).toBe('CONFIG_ERROR');
|
|
95
|
+
expect(ErrorCodes.UNKNOWN_ERROR).toBe('UNKNOWN_ERROR');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { success, successList, error, print, printRaw } from '../output.js';
|
|
3
|
+
describe('output utilities', () => {
|
|
4
|
+
describe('success', () => {
|
|
5
|
+
it('returns success response with data', () => {
|
|
6
|
+
const data = { id: '123', name: 'Test' };
|
|
7
|
+
const result = success(data);
|
|
8
|
+
expect(result).toEqual({
|
|
9
|
+
success: true,
|
|
10
|
+
data: { id: '123', name: 'Test' },
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
it('handles null data', () => {
|
|
14
|
+
const result = success(null);
|
|
15
|
+
expect(result).toEqual({
|
|
16
|
+
success: true,
|
|
17
|
+
data: null,
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
it('handles array data', () => {
|
|
21
|
+
const result = success([1, 2, 3]);
|
|
22
|
+
expect(result).toEqual({
|
|
23
|
+
success: true,
|
|
24
|
+
data: [1, 2, 3],
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe('successList', () => {
|
|
29
|
+
it('returns list response without pageInfo', () => {
|
|
30
|
+
const data = [{ id: '1' }, { id: '2' }];
|
|
31
|
+
const result = successList(data);
|
|
32
|
+
expect(result).toEqual({
|
|
33
|
+
success: true,
|
|
34
|
+
data: [{ id: '1' }, { id: '2' }],
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
it('returns list response with pageInfo', () => {
|
|
38
|
+
const data = [{ id: '1' }];
|
|
39
|
+
const pageInfo = { hasNextPage: true, hasPreviousPage: false, endCursor: 'cursor123' };
|
|
40
|
+
const result = successList(data, pageInfo);
|
|
41
|
+
expect(result).toEqual({
|
|
42
|
+
success: true,
|
|
43
|
+
data: [{ id: '1' }],
|
|
44
|
+
pageInfo: { hasNextPage: true, hasPreviousPage: false, endCursor: 'cursor123' },
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
it('handles empty array', () => {
|
|
48
|
+
const result = successList([]);
|
|
49
|
+
expect(result).toEqual({
|
|
50
|
+
success: true,
|
|
51
|
+
data: [],
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe('error', () => {
|
|
56
|
+
it('returns error response with code and message', () => {
|
|
57
|
+
const result = error('NOT_FOUND', 'Issue not found');
|
|
58
|
+
expect(result).toEqual({
|
|
59
|
+
success: false,
|
|
60
|
+
error: {
|
|
61
|
+
code: 'NOT_FOUND',
|
|
62
|
+
message: 'Issue not found',
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
it('returns error response with details', () => {
|
|
67
|
+
const details = { field: 'title', reason: 'required' };
|
|
68
|
+
const result = error('INVALID_INPUT', 'Validation failed', details);
|
|
69
|
+
expect(result).toEqual({
|
|
70
|
+
success: false,
|
|
71
|
+
error: {
|
|
72
|
+
code: 'INVALID_INPUT',
|
|
73
|
+
message: 'Validation failed',
|
|
74
|
+
details: { field: 'title', reason: 'required' },
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
describe('print', () => {
|
|
80
|
+
let consoleSpy;
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
83
|
+
});
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
consoleSpy.mockRestore();
|
|
86
|
+
});
|
|
87
|
+
it('prints success response as formatted JSON', () => {
|
|
88
|
+
const response = success({ id: '123' });
|
|
89
|
+
print(response);
|
|
90
|
+
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(response, null, 2));
|
|
91
|
+
});
|
|
92
|
+
it('prints error response as formatted JSON', () => {
|
|
93
|
+
const response = error('NOT_FOUND', 'Not found');
|
|
94
|
+
print(response);
|
|
95
|
+
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(response, null, 2));
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe('printRaw', () => {
|
|
99
|
+
let consoleSpy;
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
102
|
+
});
|
|
103
|
+
afterEach(() => {
|
|
104
|
+
consoleSpy.mockRestore();
|
|
105
|
+
});
|
|
106
|
+
it('prints raw data as formatted JSON', () => {
|
|
107
|
+
const data = { key: 'value', nested: { a: 1 } };
|
|
108
|
+
printRaw(data);
|
|
109
|
+
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(data, null, 2));
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { OutputFormat } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Check if colors should be disabled.
|
|
4
|
+
* Respects NO_COLOR env var and --no-color flag.
|
|
5
|
+
*/
|
|
6
|
+
export declare const shouldDisableColors: () => boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Color utilities that respect NO_COLOR.
|
|
9
|
+
*/
|
|
10
|
+
declare const colors: {
|
|
11
|
+
dim: (str: string) => string;
|
|
12
|
+
bold: (str: string) => string;
|
|
13
|
+
cyan: (str: string) => string;
|
|
14
|
+
green: (str: string) => string;
|
|
15
|
+
yellow: (str: string) => string;
|
|
16
|
+
red: (str: string) => string;
|
|
17
|
+
blue: (str: string) => string;
|
|
18
|
+
magenta: (str: string) => string;
|
|
19
|
+
gray: (str: string) => string;
|
|
20
|
+
};
|
|
21
|
+
export { colors };
|
|
22
|
+
/**
|
|
23
|
+
* Column definition for table formatting.
|
|
24
|
+
*/
|
|
25
|
+
export interface ColumnDef<T> {
|
|
26
|
+
key: keyof T | string;
|
|
27
|
+
header: string;
|
|
28
|
+
width?: number;
|
|
29
|
+
align?: 'left' | 'center' | 'right';
|
|
30
|
+
format?: (value: unknown, row: T) => string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Format data as a colored table.
|
|
34
|
+
*/
|
|
35
|
+
export declare const formatTable: <T extends Record<string, unknown>>(data: T[], columns: ColumnDef<T>[]) => string;
|
|
36
|
+
/**
|
|
37
|
+
* Format data as plain text (one item per line).
|
|
38
|
+
*/
|
|
39
|
+
export declare const formatPlain: <T extends Record<string, unknown>>(data: T[], primaryKey?: keyof T, secondaryKey?: keyof T) => string;
|
|
40
|
+
/**
|
|
41
|
+
* Format a single item as a key-value table.
|
|
42
|
+
*/
|
|
43
|
+
export declare const formatKeyValue: (data: Record<string, unknown>) => string;
|
|
44
|
+
/**
|
|
45
|
+
* Priority label formatter with colors.
|
|
46
|
+
*/
|
|
47
|
+
export declare const formatPriority: (priority: number) => string;
|
|
48
|
+
/**
|
|
49
|
+
* Truncate string to max length with ellipsis.
|
|
50
|
+
*/
|
|
51
|
+
export declare const truncate: (str: string | undefined | null, maxLength: number) => string;
|
|
52
|
+
/**
|
|
53
|
+
* Generic formatter that outputs data in the specified format.
|
|
54
|
+
*/
|
|
55
|
+
export declare const formatOutput: <T extends Record<string, unknown>>(format: OutputFormat, data: T | T[], options?: {
|
|
56
|
+
columns?: ColumnDef<T>[];
|
|
57
|
+
primaryKey?: keyof T;
|
|
58
|
+
secondaryKey?: keyof T;
|
|
59
|
+
}) => string;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import Table from 'cli-table3';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
/**
|
|
4
|
+
* Check if colors should be disabled.
|
|
5
|
+
* Respects NO_COLOR env var and --no-color flag.
|
|
6
|
+
*/
|
|
7
|
+
export const shouldDisableColors = () => {
|
|
8
|
+
return Boolean(process.env.NO_COLOR) || process.argv.includes('--no-color');
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Color utilities that respect NO_COLOR.
|
|
12
|
+
*/
|
|
13
|
+
const colors = {
|
|
14
|
+
dim: (str) => (shouldDisableColors() ? str : pc.dim(str)),
|
|
15
|
+
bold: (str) => (shouldDisableColors() ? str : pc.bold(str)),
|
|
16
|
+
cyan: (str) => (shouldDisableColors() ? str : pc.cyan(str)),
|
|
17
|
+
green: (str) => (shouldDisableColors() ? str : pc.green(str)),
|
|
18
|
+
yellow: (str) => (shouldDisableColors() ? str : pc.yellow(str)),
|
|
19
|
+
red: (str) => (shouldDisableColors() ? str : pc.red(str)),
|
|
20
|
+
blue: (str) => (shouldDisableColors() ? str : pc.blue(str)),
|
|
21
|
+
magenta: (str) => (shouldDisableColors() ? str : pc.magenta(str)),
|
|
22
|
+
gray: (str) => (shouldDisableColors() ? str : pc.gray(str)),
|
|
23
|
+
};
|
|
24
|
+
export { colors };
|
|
25
|
+
/**
|
|
26
|
+
* Format data as a colored table.
|
|
27
|
+
*/
|
|
28
|
+
export const formatTable = (data, columns) => {
|
|
29
|
+
if (data.length === 0) {
|
|
30
|
+
return colors.dim('No results found.');
|
|
31
|
+
}
|
|
32
|
+
const colWidths = columns.map((col) => col.width ?? null);
|
|
33
|
+
const table = new Table({
|
|
34
|
+
head: columns.map((col) => colors.bold(col.header)),
|
|
35
|
+
colWidths,
|
|
36
|
+
colAligns: columns.map((col) => col.align ?? 'left'),
|
|
37
|
+
style: {
|
|
38
|
+
head: [],
|
|
39
|
+
border: [],
|
|
40
|
+
},
|
|
41
|
+
chars: {
|
|
42
|
+
top: '',
|
|
43
|
+
'top-mid': '',
|
|
44
|
+
'top-left': '',
|
|
45
|
+
'top-right': '',
|
|
46
|
+
bottom: '',
|
|
47
|
+
'bottom-mid': '',
|
|
48
|
+
'bottom-left': '',
|
|
49
|
+
'bottom-right': '',
|
|
50
|
+
left: '',
|
|
51
|
+
'left-mid': '',
|
|
52
|
+
mid: '',
|
|
53
|
+
'mid-mid': '',
|
|
54
|
+
right: '',
|
|
55
|
+
'right-mid': '',
|
|
56
|
+
middle: ' ',
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
for (const row of data) {
|
|
60
|
+
const cells = columns.map((col) => {
|
|
61
|
+
const value = getNestedValue(row, col.key);
|
|
62
|
+
if (col.format) {
|
|
63
|
+
return col.format(value, row);
|
|
64
|
+
}
|
|
65
|
+
return String(value ?? '');
|
|
66
|
+
});
|
|
67
|
+
table.push(cells);
|
|
68
|
+
}
|
|
69
|
+
return table.toString();
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Format data as plain text (one item per line).
|
|
73
|
+
*/
|
|
74
|
+
export const formatPlain = (data, primaryKey = 'id', secondaryKey) => {
|
|
75
|
+
if (data.length === 0) {
|
|
76
|
+
return '';
|
|
77
|
+
}
|
|
78
|
+
return data
|
|
79
|
+
.map((item) => {
|
|
80
|
+
const primary = String(item[primaryKey] ?? '');
|
|
81
|
+
if (secondaryKey && item[secondaryKey]) {
|
|
82
|
+
return `${primary}\t${String(item[secondaryKey])}`;
|
|
83
|
+
}
|
|
84
|
+
return primary;
|
|
85
|
+
})
|
|
86
|
+
.join('\n');
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Format a single item as a key-value table.
|
|
90
|
+
*/
|
|
91
|
+
export const formatKeyValue = (data) => {
|
|
92
|
+
const table = new Table({
|
|
93
|
+
style: {
|
|
94
|
+
head: [],
|
|
95
|
+
border: [],
|
|
96
|
+
},
|
|
97
|
+
chars: {
|
|
98
|
+
top: '',
|
|
99
|
+
'top-mid': '',
|
|
100
|
+
'top-left': '',
|
|
101
|
+
'top-right': '',
|
|
102
|
+
bottom: '',
|
|
103
|
+
'bottom-mid': '',
|
|
104
|
+
'bottom-left': '',
|
|
105
|
+
'bottom-right': '',
|
|
106
|
+
left: '',
|
|
107
|
+
'left-mid': '',
|
|
108
|
+
mid: '',
|
|
109
|
+
'mid-mid': '',
|
|
110
|
+
right: '',
|
|
111
|
+
'right-mid': '',
|
|
112
|
+
middle: ' ',
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
for (const [key, value] of Object.entries(data)) {
|
|
116
|
+
if (value === null || value === undefined)
|
|
117
|
+
continue;
|
|
118
|
+
let formattedValue;
|
|
119
|
+
if (typeof value === 'object') {
|
|
120
|
+
formattedValue = colors.dim(JSON.stringify(value));
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
formattedValue = String(value);
|
|
124
|
+
}
|
|
125
|
+
table.push([colors.cyan(key), formattedValue]);
|
|
126
|
+
}
|
|
127
|
+
return table.toString();
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* Get nested value from object using dot notation.
|
|
131
|
+
*/
|
|
132
|
+
const getNestedValue = (obj, path) => {
|
|
133
|
+
return path.split('.').reduce((acc, part) => {
|
|
134
|
+
if (acc && typeof acc === 'object') {
|
|
135
|
+
return acc[part];
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
}, obj);
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* Priority label formatter with colors.
|
|
142
|
+
*/
|
|
143
|
+
export const formatPriority = (priority) => {
|
|
144
|
+
switch (priority) {
|
|
145
|
+
case 0:
|
|
146
|
+
return colors.gray('No priority');
|
|
147
|
+
case 1:
|
|
148
|
+
return colors.red('Urgent');
|
|
149
|
+
case 2:
|
|
150
|
+
return colors.yellow('High');
|
|
151
|
+
case 3:
|
|
152
|
+
return colors.blue('Medium');
|
|
153
|
+
case 4:
|
|
154
|
+
return colors.gray('Low');
|
|
155
|
+
default:
|
|
156
|
+
return String(priority);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
/**
|
|
160
|
+
* Truncate string to max length with ellipsis.
|
|
161
|
+
*/
|
|
162
|
+
export const truncate = (str, maxLength) => {
|
|
163
|
+
if (!str)
|
|
164
|
+
return '';
|
|
165
|
+
if (str.length <= maxLength)
|
|
166
|
+
return str;
|
|
167
|
+
return str.slice(0, maxLength - 1) + '\u2026';
|
|
168
|
+
};
|
|
169
|
+
/**
|
|
170
|
+
* Generic formatter that outputs data in the specified format.
|
|
171
|
+
*/
|
|
172
|
+
export const formatOutput = (format, data, options = {}) => {
|
|
173
|
+
if (format === 'json') {
|
|
174
|
+
return JSON.stringify(Array.isArray(data) ? data : data, null, 2);
|
|
175
|
+
}
|
|
176
|
+
if (format === 'plain') {
|
|
177
|
+
if (Array.isArray(data)) {
|
|
178
|
+
return formatPlain(data, options.primaryKey, options.secondaryKey);
|
|
179
|
+
}
|
|
180
|
+
return String(data[options.primaryKey ?? 'id'] ?? '');
|
|
181
|
+
}
|
|
182
|
+
if (format === 'table') {
|
|
183
|
+
if (Array.isArray(data)) {
|
|
184
|
+
if (!options.columns) {
|
|
185
|
+
throw new Error('Columns are required for table format');
|
|
186
|
+
}
|
|
187
|
+
return formatTable(data, options.columns);
|
|
188
|
+
}
|
|
189
|
+
return formatKeyValue(data);
|
|
190
|
+
}
|
|
191
|
+
return JSON.stringify(data, null, 2);
|
|
192
|
+
};
|