@alasano/pi-linear 0.1.1 → 0.3.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.
Files changed (38) hide show
  1. package/README.md +16 -2
  2. package/assets/linear_list_issues.png +0 -0
  3. package/assets/screenshot.png +0 -0
  4. package/extensions/params.ts +40 -1
  5. package/extensions/renderers/comments.ts +323 -0
  6. package/extensions/renderers/common.ts +305 -0
  7. package/extensions/renderers/documents.ts +326 -0
  8. package/extensions/renderers/initiatives.ts +344 -0
  9. package/extensions/renderers/issue-labels.ts +294 -0
  10. package/extensions/renderers/issue-relations.ts +318 -0
  11. package/extensions/renderers/issue-statuses.ts +199 -0
  12. package/extensions/renderers/issues.ts +373 -0
  13. package/extensions/renderers/milestones.ts +294 -0
  14. package/extensions/renderers/project-labels.ts +279 -0
  15. package/extensions/renderers/project-relations.ts +344 -0
  16. package/extensions/renderers/projects.ts +437 -0
  17. package/extensions/renderers/state.ts +35 -0
  18. package/extensions/renderers/teams.ts +246 -0
  19. package/extensions/renderers/users.ts +242 -0
  20. package/extensions/renderers/workspaces.ts +44 -0
  21. package/extensions/selections.ts +10 -3
  22. package/extensions/settings.ts +40 -7
  23. package/extensions/tools/comments.ts +30 -11
  24. package/extensions/tools/documents.ts +42 -11
  25. package/extensions/tools/initiatives.ts +43 -11
  26. package/extensions/tools/issue-labels.ts +36 -11
  27. package/extensions/tools/issue-relations.ts +32 -13
  28. package/extensions/tools/issue-statuses.ts +19 -11
  29. package/extensions/tools/issues.ts +53 -19
  30. package/extensions/tools/milestones.ts +31 -11
  31. package/extensions/tools/project-labels.ts +30 -11
  32. package/extensions/tools/project-relations.ts +32 -13
  33. package/extensions/tools/projects.ts +48 -16
  34. package/extensions/tools/teams.ts +23 -11
  35. package/extensions/tools/users.ts +23 -11
  36. package/extensions/tools/workspaces.ts +6 -0
  37. package/extensions/types.ts +12 -0
  38. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,18 @@
1
1
  # pi-linear
2
2
 
3
- ![pi-linear screenshot](assets/screenshot.png)
3
+ <p align="center">
4
+ <img src="assets/screenshot.png" alt="pi-linear settings overlay" />
5
+ </p>
6
+
7
+ <p align="center"><em>Configure Linear tools and output defaults from the settings overlay.</em></p>
8
+
9
+ ---
10
+
11
+ <p align="center">
12
+ <img src="assets/linear_list_issues.png" alt="Human-readable Linear issue list" />
13
+ </p>
14
+
15
+ <p align="center"><em>Human-readable issue results keep screenshots clean while full JSON remains available with Ctrl+O.</em></p>
4
16
 
5
17
  Linear integration for [pi](https://pi.dev) with 55+ tools covering issues, projects, documents, initiatives, comments, relations, and more. Includes multi-workspace auth and a per-tool settings overlay.
6
18
 
@@ -46,7 +58,7 @@ Credentials are stored at `~/.pi/agent/state/extensions/linear/credentials.json`
46
58
 
47
59
  ## Tool settings
48
60
 
49
- Run `/linear-settings` to open an overlay where you can enable or disable tools by category or individually. Disabled tools are removed from the LLM's context entirely. Preferences persist across sessions.
61
+ Run `/linear-settings` to open an overlay where you can choose the default output view and enable or disable tools by category or individually. Linear results default to a human-readable view, with full JSON available via `Ctrl+O`; you can flip the default to full JSON if you prefer. Disabled tools are removed from the LLM's context entirely. Preferences persist across sessions.
50
62
 
51
63
  ## Tools (55)
52
64
 
@@ -98,6 +110,8 @@ Run `/linear-settings` to open an overlay where you can enable or disable tools
98
110
  | `linear_archive_project` | Archive or trash a project |
99
111
  | `linear_unarchive_project` | Restore an archived project |
100
112
 
113
+ List and search results include `pageInfo` in the JSON output for cursor pagination. Project list results use a bounded summary selection, and `linear_get_project` plus project save results include the project's markdown `content` body.
114
+
101
115
  ### Project Labels
102
116
 
103
117
  | Tool | Description |
Binary file
Binary file
@@ -1,5 +1,5 @@
1
1
  import { Type } from '@sinclair/typebox';
2
- import { GenericObjectSchema } from './util';
2
+ import { compactObject, GenericObjectSchema } from './util';
3
3
 
4
4
  export const PaginationParams = {
5
5
  after: Type.Optional(Type.String({ description: 'Pagination cursor.' })),
@@ -26,6 +26,45 @@ export const PaginationParams = {
26
26
  ),
27
27
  };
28
28
 
29
+ type PaginationVariableParams = {
30
+ after?: string;
31
+ before?: string;
32
+ first?: number;
33
+ includeArchived?: boolean;
34
+ last?: number;
35
+ orderBy?: string;
36
+ };
37
+
38
+ export function paginationVariables(
39
+ params: PaginationVariableParams,
40
+ defaultPageSize: number,
41
+ ): Partial<PaginationVariableParams> {
42
+ const hasForwardPagination = params.after !== undefined || params.first !== undefined;
43
+ const hasBackwardPagination = params.before !== undefined || params.last !== undefined;
44
+
45
+ if (hasForwardPagination && hasBackwardPagination) {
46
+ throw new Error(
47
+ 'Use either forward pagination (first/after) or backward pagination (last/before), not both.',
48
+ );
49
+ }
50
+
51
+ if (hasBackwardPagination) {
52
+ return compactObject({
53
+ before: params.before,
54
+ includeArchived: params.includeArchived,
55
+ last: params.last ?? defaultPageSize,
56
+ orderBy: params.orderBy,
57
+ });
58
+ }
59
+
60
+ return compactObject({
61
+ after: params.after,
62
+ first: params.first ?? defaultPageSize,
63
+ includeArchived: params.includeArchived,
64
+ orderBy: params.orderBy,
65
+ });
66
+ }
67
+
29
68
  export const SortParam = {
30
69
  sort: Type.Optional(Type.Array(GenericObjectSchema, { description: 'Sort input array.' })),
31
70
  };
@@ -0,0 +1,323 @@
1
+ import {
2
+ type AgentToolResult,
3
+ type Theme,
4
+ type ToolRenderResultOptions,
5
+ } from '@mariozechner/pi-coding-agent';
6
+ import { Text } from '@mariozechner/pi-tui';
7
+ import {
8
+ accentStyle,
9
+ asString,
10
+ cleanOneLine,
11
+ dimStyle,
12
+ expandedJson,
13
+ shouldShowJson,
14
+ jsonHint,
15
+ LinearListResultComponent,
16
+ mutedStyle,
17
+ renderLinearToolCall,
18
+ renderResponsiveTable,
19
+ toolOutputStyle,
20
+ truncate,
21
+ truncateLine,
22
+ type LinearToolRenderContext,
23
+ type TableColumn,
24
+ type ToolArgs,
25
+ } from './common';
26
+
27
+ type NamedRef = {
28
+ id?: string;
29
+ identifier?: string | null;
30
+ title?: string | null;
31
+ };
32
+
33
+ type CommentUser = {
34
+ id?: string;
35
+ name?: string | null;
36
+ email?: string | null;
37
+ };
38
+
39
+ type CommentLike = {
40
+ id?: string;
41
+ body?: string | null;
42
+ quotedText?: string | null;
43
+ createdAt?: string | null;
44
+ updatedAt?: string | null;
45
+ editedAt?: string | null;
46
+ resolvedAt?: string | null;
47
+ url?: string | null;
48
+ issue?: NamedRef | null;
49
+ parent?: { id?: string | null } | null;
50
+ user?: CommentUser | null;
51
+ };
52
+
53
+ type CommentResultDetails = {
54
+ comment?: CommentLike | null;
55
+ comments?: CommentLike[];
56
+ success?: boolean;
57
+ };
58
+
59
+ const COMMENT_LIST_PREVIEW_LIMIT = 20;
60
+ const BODY_LIMIT = 180;
61
+ const QUOTED_LIMIT = 90;
62
+ const ISSUE_TITLE_LIMIT = 80;
63
+ const TABLE_BODY_MIN_WIDTH = 24;
64
+
65
+ function commentDetails(result: AgentToolResult<any>): CommentResultDetails {
66
+ return (result.details ?? {}) as CommentResultDetails;
67
+ }
68
+
69
+ function argsObject(context: { args?: unknown }): ToolArgs {
70
+ return context.args && typeof context.args === 'object' && !Array.isArray(context.args)
71
+ ? (context.args as ToolArgs)
72
+ : {};
73
+ }
74
+
75
+ function dateText(value: unknown): string | undefined {
76
+ const date = asString(value);
77
+ if (!date) return undefined;
78
+ const [datePart] = date.split('T');
79
+ return datePart || date;
80
+ }
81
+
82
+ function issueIdentifier(comment: CommentLike): string | undefined {
83
+ return asString(comment.issue?.identifier) ?? asString(comment.issue?.id);
84
+ }
85
+
86
+ function issueTitle(comment: CommentLike): string | undefined {
87
+ const title = asString(comment.issue?.title);
88
+ return title ? truncate(cleanOneLine(title), ISSUE_TITLE_LIMIT) : undefined;
89
+ }
90
+
91
+ function issueText(comment: CommentLike): string | undefined {
92
+ const identifier = issueIdentifier(comment);
93
+ const title = issueTitle(comment);
94
+ if (identifier && title) return `${identifier} ${title}`;
95
+ return identifier ?? title;
96
+ }
97
+
98
+ function formatIssueText(comment: CommentLike, theme: Theme): string {
99
+ const identifier = issueIdentifier(comment);
100
+ const title = issueTitle(comment);
101
+ if (identifier && title)
102
+ return `${theme.fg('accent', identifier)} ${theme.fg('toolOutput', title)}`;
103
+ if (identifier) return theme.fg('accent', identifier);
104
+ if (title) return theme.fg('toolOutput', title);
105
+ return theme.fg('dim', 'No issue');
106
+ }
107
+
108
+ function authorText(comment: CommentLike): string | undefined {
109
+ return (
110
+ asString(comment.user?.name) ?? asString(comment.user?.email) ?? asString(comment.user?.id)
111
+ );
112
+ }
113
+
114
+ function bodySnippet(comment: CommentLike, limit = BODY_LIMIT): string | undefined {
115
+ const body = asString(comment.body);
116
+ if (!body) return undefined;
117
+ return truncate(cleanOneLine(body), limit);
118
+ }
119
+
120
+ function quotedSnippet(comment: CommentLike, limit = QUOTED_LIMIT): string | undefined {
121
+ const quoted = asString(comment.quotedText);
122
+ if (!quoted) return undefined;
123
+ return truncate(cleanOneLine(quoted), limit);
124
+ }
125
+
126
+ function bodyPreview(comment: CommentLike): string {
127
+ const body = bodySnippet(comment);
128
+ const quoted = quotedSnippet(comment);
129
+
130
+ if (body && quoted && body !== quoted) return truncate(`${body} — quoted: ${quoted}`, BODY_LIMIT);
131
+ return body ?? (quoted ? `quoted: ${quoted}` : '(empty comment)');
132
+ }
133
+
134
+ function listMetadataParts(comment: CommentLike): string[] {
135
+ const issue = issueText(comment);
136
+ const author = authorText(comment);
137
+ const updated = dateText(comment.updatedAt);
138
+
139
+ return [
140
+ issue ? `issue: ${issue}` : undefined,
141
+ author ? `author: ${author}` : undefined,
142
+ updated ? `updated: ${updated}` : undefined,
143
+ ].filter((part): part is string => !!part);
144
+ }
145
+
146
+ function cardMetadataParts(comment: CommentLike): string[] {
147
+ const author = authorText(comment);
148
+ const updated = dateText(comment.updatedAt);
149
+ const edited = dateText(comment.editedAt);
150
+ const resolved = dateText(comment.resolvedAt);
151
+
152
+ return [
153
+ author ? `author: ${author}` : undefined,
154
+ updated ? `updated: ${updated}` : undefined,
155
+ edited ? `edited: ${edited}` : undefined,
156
+ resolved ? `resolved: ${resolved}` : undefined,
157
+ ].filter((part): part is string => !!part);
158
+ }
159
+
160
+ function formatCommentListLine(comment: CommentLike, theme: Theme, width: number): string {
161
+ const body = bodyPreview(comment);
162
+ const metadata = listMetadataParts(comment);
163
+ const suffix = metadata.length ? theme.fg('dim', ` · ${metadata.join(' · ')}`) : '';
164
+
165
+ return truncateLine(` ${theme.fg('toolOutput', body)}${suffix}`, width);
166
+ }
167
+
168
+ const COMMENT_TABLE_COLUMNS: TableColumn<CommentLike>[] = [
169
+ {
170
+ id: 'issue',
171
+ label: 'Issue',
172
+ width: 16,
173
+ value: (comment) => issueText(comment) ?? '—',
174
+ style: (theme) => accentStyle(theme),
175
+ },
176
+ {
177
+ id: 'author',
178
+ label: 'Author',
179
+ width: 18,
180
+ value: (comment) => authorText(comment) ?? '—',
181
+ style: (theme) => mutedStyle(theme),
182
+ },
183
+ {
184
+ id: 'updated',
185
+ label: 'Updated',
186
+ width: 10,
187
+ value: (comment) => dateText(comment.updatedAt) ?? '—',
188
+ style: (theme) => dimStyle(theme),
189
+ },
190
+ ];
191
+
192
+ function renderCommentTable(comments: CommentLike[], theme: Theme, width: number): string[] {
193
+ return renderResponsiveTable(comments, theme, width, {
194
+ columns: COMMENT_TABLE_COLUMNS,
195
+ primary: {
196
+ label: 'Body',
197
+ minWidth: TABLE_BODY_MIN_WIDTH,
198
+ value: bodyPreview,
199
+ style: (theme) => toolOutputStyle(theme),
200
+ },
201
+ dropOrder: ['updated', 'author', 'issue'],
202
+ fallback: formatCommentListLine,
203
+ });
204
+ }
205
+
206
+ function renderCommentCard(
207
+ actionLabel: string,
208
+ comment: CommentLike | null | undefined,
209
+ theme: Theme,
210
+ ): Text {
211
+ if (!comment) {
212
+ return new Text(`\n${theme.fg('dim', 'Comment not found')}\n\n${jsonHint()}`, 0, 0);
213
+ }
214
+
215
+ const metadata = cardMetadataParts(comment);
216
+ const quoted = quotedSnippet(comment);
217
+ const body = bodySnippet(comment);
218
+ const url = asString(comment.url);
219
+
220
+ let text = `\n${theme.fg('success', `✓ ${actionLabel}`)} ${formatIssueText(comment, theme)}`;
221
+ if (metadata.length) text += `\n ${theme.fg('dim', metadata.join(' · '))}`;
222
+ if (quoted) text += `\n ${theme.fg('dim', `quoted: ${quoted}`)}`;
223
+ if (body) text += `\n ${theme.fg('muted', body)}`;
224
+ if (url) text += `\n ${theme.fg('dim', url)}`;
225
+ text += `\n\n${jsonHint()}`;
226
+
227
+ return new Text(text, 0, 0);
228
+ }
229
+
230
+ export function renderLinearCommentListCall(args: ToolArgs | undefined, theme: Theme): Text {
231
+ return renderLinearToolCall('linear_list_comments', args, theme, [
232
+ ['first', 'first'],
233
+ ['last', 'last'],
234
+ ['orderBy', 'order'],
235
+ ['includeArchived', 'archived'],
236
+ ['filter', 'filter'],
237
+ ]);
238
+ }
239
+
240
+ export function renderLinearCreateCommentCall(args: ToolArgs | undefined, theme: Theme): Text {
241
+ return renderLinearToolCall('linear_create_comment', args, theme, [
242
+ ['id', 'id'],
243
+ ['body', 'body'],
244
+ ['issueId', 'issueId'],
245
+ ['parentId', 'parentId'],
246
+ ['quotedText', 'quote'],
247
+ ]);
248
+ }
249
+
250
+ export function renderLinearUpdateCommentCall(args: ToolArgs | undefined, theme: Theme): Text {
251
+ return renderLinearToolCall('linear_update_comment', args, theme, [
252
+ ['id', 'id'],
253
+ ['body', 'body'],
254
+ ['quotedText', 'quote'],
255
+ ]);
256
+ }
257
+
258
+ export function renderLinearDeleteCommentCall(args: ToolArgs | undefined, theme: Theme): Text {
259
+ return renderLinearToolCall('linear_delete_comment', args, theme, [['id', 'id']]);
260
+ }
261
+
262
+ export function renderLinearCommentListResult(
263
+ result: AgentToolResult<any>,
264
+ options: ToolRenderResultOptions,
265
+ theme: Theme,
266
+ context: LinearToolRenderContext,
267
+ ): Text | LinearListResultComponent<CommentLike> {
268
+ if (options.isPartial) return new Text(theme.fg('warning', 'Loading comments…'), 0, 0);
269
+ if (shouldShowJson(options, context)) return expandedJson(result, theme);
270
+
271
+ const comments = Array.isArray(commentDetails(result).comments)
272
+ ? (commentDetails(result).comments as CommentLike[])
273
+ : [];
274
+
275
+ return new LinearListResultComponent(comments, theme, {
276
+ noun: 'comment',
277
+ emptyLabel: 'No comments found',
278
+ previewLimit: COMMENT_LIST_PREVIEW_LIMIT,
279
+ renderItems: renderCommentTable,
280
+ });
281
+ }
282
+
283
+ export function renderLinearCommentResult(actionLabel: string) {
284
+ return (
285
+ result: AgentToolResult<any>,
286
+ options: ToolRenderResultOptions,
287
+ theme: Theme,
288
+ context: LinearToolRenderContext,
289
+ ): Text => {
290
+ if (options.isPartial) return new Text(theme.fg('warning', `${actionLabel}…`), 0, 0);
291
+ if (shouldShowJson(options, context)) return expandedJson(result, theme);
292
+
293
+ return renderCommentCard(actionLabel, commentDetails(result).comment, theme);
294
+ };
295
+ }
296
+
297
+ export function renderLinearDeleteCommentResult(
298
+ result: AgentToolResult<any>,
299
+ options: ToolRenderResultOptions,
300
+ theme: Theme,
301
+ context: { args?: unknown },
302
+ ): Text {
303
+ if (options.isPartial) return new Text(theme.fg('warning', 'Deleting comment…'), 0, 0);
304
+ if (shouldShowJson(options, context)) return expandedJson(result, theme);
305
+
306
+ const details = commentDetails(result);
307
+ const args = argsObject(context);
308
+ const id = asString(args.id) ?? 'comment';
309
+
310
+ if (details.success !== true) {
311
+ return new Text(
312
+ `\n${theme.fg('warning', 'Deleted comment status unknown')}\n\n${jsonHint()}`,
313
+ 0,
314
+ 0,
315
+ );
316
+ }
317
+
318
+ return new Text(
319
+ `\n${theme.fg('success', '✓ Deleted comment')} ${theme.fg('accent', id)}\n\n${jsonHint()}`,
320
+ 0,
321
+ 0,
322
+ );
323
+ }