@alasano/pi-linear 0.0.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.
@@ -0,0 +1,415 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import type { ExtensionAPI, ExtensionContext } from '@mariozechner/pi-coding-agent';
4
+ import { getAgentDir, getSettingsListTheme } from '@mariozechner/pi-coding-agent';
5
+ import { type SettingItem, SettingsList } from '@mariozechner/pi-tui';
6
+
7
+ const SETTINGS_PATH = join(getAgentDir(), 'state', 'extensions', 'linear', 'tool-settings.json');
8
+ const OVERLAY_MAX_INNER = 60;
9
+ const GOLD_FG = '\x1b[38;2;212;162;46m';
10
+ const RESET_FG = '\x1b[39m';
11
+
12
+ function gold(text: string): string {
13
+ return `${GOLD_FG}${text}${RESET_FG}`;
14
+ }
15
+
16
+ const ANSI_RE = new RegExp(String.fromCharCode(0x1b) + '\\[[0-9;]*m', 'g');
17
+
18
+ function stripAnsi(text: string): string {
19
+ return text.replace(ANSI_RE, '');
20
+ }
21
+
22
+ function visibleWidth(text: string): number {
23
+ return stripAnsi(text).length;
24
+ }
25
+
26
+ function padVisible(text: string, width: number): string {
27
+ const deficit = width - visibleWidth(text);
28
+ if (deficit <= 0) return text;
29
+ return `${text}${' '.repeat(deficit)}`;
30
+ }
31
+
32
+ const TOOL_CATEGORIES = [
33
+ {
34
+ id: 'issues',
35
+ label: 'Issues',
36
+ tools: [
37
+ 'linear_list_issues',
38
+ 'linear_get_issue',
39
+ 'linear_create_issue',
40
+ 'linear_update_issue',
41
+ 'linear_delete_issue',
42
+ 'linear_archive_issue',
43
+ 'linear_unarchive_issue',
44
+ 'linear_search_issues',
45
+ ],
46
+ },
47
+ {
48
+ id: 'issueLabels',
49
+ label: 'Issue Labels',
50
+ tools: [
51
+ 'linear_list_issue_labels',
52
+ 'linear_create_issue_label',
53
+ 'linear_update_issue_label',
54
+ 'linear_delete_issue_label',
55
+ ],
56
+ },
57
+ {
58
+ id: 'issueStatuses',
59
+ label: 'Issue Statuses',
60
+ tools: ['linear_list_issue_statuses'],
61
+ },
62
+ {
63
+ id: 'issueRelations',
64
+ label: 'Issue Relations',
65
+ tools: [
66
+ 'linear_list_issue_relations',
67
+ 'linear_create_issue_relation',
68
+ 'linear_update_issue_relation',
69
+ 'linear_delete_issue_relation',
70
+ ],
71
+ },
72
+ {
73
+ id: 'comments',
74
+ label: 'Comments',
75
+ tools: [
76
+ 'linear_list_comments',
77
+ 'linear_create_comment',
78
+ 'linear_update_comment',
79
+ 'linear_delete_comment',
80
+ ],
81
+ },
82
+ {
83
+ id: 'projects',
84
+ label: 'Projects',
85
+ tools: [
86
+ 'linear_list_projects',
87
+ 'linear_get_project',
88
+ 'linear_save_project',
89
+ 'linear_delete_project',
90
+ 'linear_archive_project',
91
+ 'linear_unarchive_project',
92
+ ],
93
+ },
94
+ {
95
+ id: 'projectLabels',
96
+ label: 'Project Labels',
97
+ tools: [
98
+ 'linear_list_project_labels',
99
+ 'linear_create_project_label',
100
+ 'linear_update_project_label',
101
+ 'linear_delete_project_label',
102
+ ],
103
+ },
104
+ {
105
+ id: 'projectRelations',
106
+ label: 'Project Relations',
107
+ tools: [
108
+ 'linear_list_project_relations',
109
+ 'linear_create_project_relation',
110
+ 'linear_update_project_relation',
111
+ 'linear_delete_project_relation',
112
+ ],
113
+ },
114
+ {
115
+ id: 'documents',
116
+ label: 'Documents',
117
+ tools: [
118
+ 'linear_list_documents',
119
+ 'linear_get_document',
120
+ 'linear_create_document',
121
+ 'linear_update_document',
122
+ 'linear_delete_document',
123
+ 'linear_unarchive_document',
124
+ ],
125
+ },
126
+ {
127
+ id: 'initiatives',
128
+ label: 'Initiatives',
129
+ tools: [
130
+ 'linear_list_initiatives',
131
+ 'linear_get_initiative',
132
+ 'linear_save_initiative',
133
+ 'linear_delete_initiative',
134
+ 'linear_archive_initiative',
135
+ 'linear_unarchive_initiative',
136
+ ],
137
+ },
138
+ {
139
+ id: 'milestones',
140
+ label: 'Milestones',
141
+ tools: [
142
+ 'linear_list_milestones',
143
+ 'linear_get_milestone',
144
+ 'linear_save_milestone',
145
+ 'linear_delete_milestone',
146
+ ],
147
+ },
148
+ {
149
+ id: 'teams',
150
+ label: 'Teams',
151
+ tools: ['linear_list_teams', 'linear_get_team'],
152
+ },
153
+ {
154
+ id: 'users',
155
+ label: 'Users',
156
+ tools: ['linear_list_users', 'linear_get_user'],
157
+ },
158
+ {
159
+ id: 'workspaces',
160
+ label: 'Workspaces',
161
+ tools: ['linear_switch_workspace'],
162
+ },
163
+ ] as const;
164
+
165
+ const ALL_LINEAR_TOOLS = TOOL_CATEGORIES.flatMap((c) => c.tools);
166
+
167
+ type ToolSettings = {
168
+ disabledTools: string[];
169
+ };
170
+
171
+ function createDefaultSettings(): ToolSettings {
172
+ return { disabledTools: [] };
173
+ }
174
+
175
+ function loadSettings(): ToolSettings {
176
+ if (!existsSync(SETTINGS_PATH)) {
177
+ return createDefaultSettings();
178
+ }
179
+ try {
180
+ const raw = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'));
181
+ if (!raw || typeof raw !== 'object' || !Array.isArray(raw.disabledTools)) {
182
+ return createDefaultSettings();
183
+ }
184
+ return {
185
+ disabledTools: raw.disabledTools.filter((t: unknown) => typeof t === 'string'),
186
+ };
187
+ } catch {
188
+ return createDefaultSettings();
189
+ }
190
+ }
191
+
192
+ function saveSettings(settings: ToolSettings): boolean {
193
+ try {
194
+ mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
195
+ writeFileSync(SETTINGS_PATH, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
196
+ return true;
197
+ } catch {
198
+ return false;
199
+ }
200
+ }
201
+
202
+ function applySettings(pi: ExtensionAPI, settings: ToolSettings): void {
203
+ const currentTools = pi.getActiveTools();
204
+ const nonLinearTools = currentTools.filter(
205
+ (t) => !(ALL_LINEAR_TOOLS as readonly string[]).includes(t),
206
+ );
207
+ const enabledLinearTools = ALL_LINEAR_TOOLS.filter((t) => !settings.disabledTools.includes(t));
208
+ pi.setActiveTools([...nonLinearTools, ...enabledLinearTools]);
209
+ }
210
+
211
+ function isToolEnabled(settings: ToolSettings, tool: string): boolean {
212
+ return !settings.disabledTools.includes(tool);
213
+ }
214
+
215
+ function checkboxValue(enabled: boolean): string {
216
+ return enabled ? '[x]' : '[ ]';
217
+ }
218
+
219
+ function categoryValue(settings: ToolSettings, tools: readonly string[]): string {
220
+ const enabledCount = tools.filter((t) => isToolEnabled(settings, t)).length;
221
+ if (enabledCount === tools.length) return '[x]';
222
+ if (enabledCount === 0) return '[ ]';
223
+ return '[~]';
224
+ }
225
+
226
+ function categoryLabelText(
227
+ label: string,
228
+ settings: ToolSettings,
229
+ tools: readonly string[],
230
+ ): string {
231
+ const enabledCount = tools.filter((t) => isToolEnabled(settings, t)).length;
232
+ return `${label} (${enabledCount}/${tools.length})`;
233
+ }
234
+
235
+ function getCategoryForTool(tool: string): (typeof TOOL_CATEGORIES)[number] | undefined {
236
+ return TOOL_CATEGORIES.find((c) => c.tools.includes(tool as never));
237
+ }
238
+
239
+ function computeOverlayInner(bodyLines: string[], availableWidth: number): number {
240
+ const maxInner = Math.max(24, Math.min(availableWidth - 2, OVERLAY_MAX_INNER));
241
+ return Math.max(
242
+ 24,
243
+ Math.min(
244
+ maxInner,
245
+ Math.max(...bodyLines.map((line) => visibleWidth(line)), visibleWidth('─ LINEAR TOOLS ')) + 2,
246
+ ),
247
+ );
248
+ }
249
+
250
+ function frameBody(title: string, bodyLines: string[], inner: number): string[] {
251
+ const leftHeader = `─ ${title} `;
252
+ const fill = Math.max(1, inner - leftHeader.length);
253
+ const top = gold('╭') + gold(leftHeader) + gold('─'.repeat(fill)) + gold('╮');
254
+ const bottom = gold('╰') + gold('─'.repeat(inner)) + gold('╯');
255
+ const contentWidth = Math.max(8, inner - 2);
256
+ const framedBody = bodyLines.map(
257
+ (line) => gold('│ ') + padVisible(line, contentWidth) + gold(' │'),
258
+ );
259
+ return [top, ...framedBody, bottom];
260
+ }
261
+
262
+ function buildItems(settings: ToolSettings): SettingItem[] {
263
+ const items: SettingItem[] = [];
264
+ for (const category of TOOL_CATEGORIES) {
265
+ items.push({
266
+ id: `category:${category.id}`,
267
+ label: categoryLabelText(category.label, settings, category.tools),
268
+ currentValue: categoryValue(settings, category.tools),
269
+ values: ['[x]', '[ ]'],
270
+ });
271
+ for (const tool of category.tools) {
272
+ items.push({
273
+ id: tool,
274
+ label: ` ${tool}`,
275
+ currentValue: checkboxValue(isToolEnabled(settings, tool)),
276
+ values: ['[x]', '[ ]'],
277
+ });
278
+ }
279
+ }
280
+ return items;
281
+ }
282
+
283
+ function refreshCategoryItem(
284
+ items: SettingItem[],
285
+ settingsList: SettingsList,
286
+ settings: ToolSettings,
287
+ category: (typeof TOOL_CATEGORIES)[number],
288
+ ): void {
289
+ const categoryItemId = `category:${category.id}`;
290
+ const item = items.find((i) => i.id === categoryItemId);
291
+ if (item) {
292
+ item.label = categoryLabelText(category.label, settings, category.tools);
293
+ }
294
+ settingsList.updateValue(categoryItemId, categoryValue(settings, category.tools));
295
+ }
296
+
297
+ async function showToolSettingsOverlay(
298
+ pi: ExtensionAPI,
299
+ ctx: ExtensionContext,
300
+ settings: ToolSettings,
301
+ ): Promise<void> {
302
+ const items = buildItems(settings);
303
+ const settingsTheme = getSettingsListTheme();
304
+ const maxVisibleItems = Math.min(items.length + 2, 20);
305
+
306
+ const probeList = new SettingsList(
307
+ items,
308
+ maxVisibleItems,
309
+ settingsTheme,
310
+ () => {},
311
+ () => {},
312
+ );
313
+ const probeLines = probeList.render(Math.max(8, OVERLAY_MAX_INNER - 2));
314
+ const overlayBodyLines = ['Toggle Linear tools by category or individually', '', ...probeLines];
315
+ const overlayWidth = computeOverlayInner(overlayBodyLines, OVERLAY_MAX_INNER + 2) + 2;
316
+
317
+ await ctx.ui.custom(
318
+ (_tui, theme, _kb, done) => {
319
+ const settingsList = new SettingsList(
320
+ items,
321
+ maxVisibleItems,
322
+ settingsTheme,
323
+ (id, newValue) => {
324
+ const nextEnabled = newValue === '[x]';
325
+
326
+ if (id.startsWith('category:')) {
327
+ const categoryId = id.slice('category:'.length);
328
+ const category = TOOL_CATEGORIES.find((c) => c.id === categoryId);
329
+ if (!category) return;
330
+
331
+ if (nextEnabled) {
332
+ settings.disabledTools = settings.disabledTools.filter(
333
+ (t) => !category.tools.includes(t as never),
334
+ );
335
+ } else {
336
+ const toDisable = category.tools.filter((t) => !settings.disabledTools.includes(t));
337
+ settings.disabledTools = [...settings.disabledTools, ...toDisable];
338
+ }
339
+
340
+ for (const tool of category.tools) {
341
+ settingsList.updateValue(tool, checkboxValue(isToolEnabled(settings, tool)));
342
+ }
343
+ refreshCategoryItem(items, settingsList, settings, category);
344
+ } else {
345
+ if (nextEnabled) {
346
+ settings.disabledTools = settings.disabledTools.filter((t) => t !== id);
347
+ } else {
348
+ settings.disabledTools = [...settings.disabledTools, id];
349
+ }
350
+
351
+ const category = getCategoryForTool(id);
352
+ if (category) {
353
+ refreshCategoryItem(items, settingsList, settings, category);
354
+ }
355
+ }
356
+
357
+ saveSettings(settings);
358
+ applySettings(pi, settings);
359
+ },
360
+ () => done(undefined),
361
+ { enableSearch: true },
362
+ );
363
+
364
+ return {
365
+ render(width: number) {
366
+ const safeWidth = Math.max(24, width);
367
+ const provisionalInner = Math.max(24, Math.min(safeWidth - 2, OVERLAY_MAX_INNER));
368
+ const listLines = settingsList.render(Math.max(8, provisionalInner - 2));
369
+ const bodyLines = [
370
+ theme.fg('muted', 'Toggle Linear tools by category or individually'),
371
+ '',
372
+ ...listLines,
373
+ ];
374
+ const naturalInner = computeOverlayInner(bodyLines, safeWidth);
375
+ return frameBody('LINEAR TOOLS', bodyLines, naturalInner);
376
+ },
377
+ invalidate() {
378
+ settingsList.invalidate();
379
+ },
380
+ handleInput(data: string) {
381
+ settingsList.handleInput?.(data);
382
+ },
383
+ };
384
+ },
385
+ {
386
+ overlay: true,
387
+ overlayOptions: {
388
+ anchor: 'center',
389
+ width: overlayWidth,
390
+ },
391
+ },
392
+ );
393
+ }
394
+
395
+ export function registerLinearSettings(pi: ExtensionAPI): void {
396
+ let settings = loadSettings();
397
+
398
+ pi.registerCommand('linear-settings', {
399
+ description: 'Open Linear tool settings',
400
+ handler: async (_args, ctx) => {
401
+ settings = loadSettings();
402
+ await showToolSettingsOverlay(pi, ctx, settings);
403
+ },
404
+ });
405
+
406
+ pi.on('session_start', async (_event, _ctx) => {
407
+ settings = loadSettings();
408
+ applySettings(pi, settings);
409
+ });
410
+
411
+ pi.on('session_before_switch', async (_event, _ctx) => {
412
+ settings = loadSettings();
413
+ applySettings(pi, settings);
414
+ });
415
+ }
@@ -0,0 +1,237 @@
1
+ import { defineTool } from '@mariozechner/pi-coding-agent';
2
+ import { Type } from '@sinclair/typebox';
3
+ import { withLinearAuth, linearGraphQL } from '../client';
4
+ import { PaginationParams, FilterParam, RawInputParam } from '../params';
5
+ import { COMMENT_SELECTION } from '../selections';
6
+ import type { JsonObject } from '../types';
7
+ import { compactObject, asObject, asString, GenericObjectSchema } from '../util';
8
+
9
+ export function commentTools() {
10
+ return [
11
+ defineTool({
12
+ name: 'linear_list_comments',
13
+ label: 'Linear List Comments',
14
+ description: 'List comments. Supports full comments query args.',
15
+ parameters: Type.Object({
16
+ ...PaginationParams,
17
+ ...FilterParam,
18
+ }),
19
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
20
+ return withLinearAuth(ctx, signal, async (apiKey) => {
21
+ const variables = compactObject({
22
+ after: params.after,
23
+ before: params.before,
24
+ filter: asObject(params.filter),
25
+ first: params.first ?? 20,
26
+ includeArchived: params.includeArchived,
27
+ last: params.last,
28
+ orderBy: params.orderBy,
29
+ });
30
+
31
+ const data = await linearGraphQL<{
32
+ comments: { nodes: Array<JsonObject> };
33
+ }>(
34
+ apiKey,
35
+ `query ListComments(
36
+ $after: String
37
+ $before: String
38
+ $filter: CommentFilter
39
+ $first: Int
40
+ $includeArchived: Boolean
41
+ $last: Int
42
+ $orderBy: PaginationOrderBy
43
+ ) {
44
+ comments(
45
+ after: $after
46
+ before: $before
47
+ filter: $filter
48
+ first: $first
49
+ includeArchived: $includeArchived
50
+ last: $last
51
+ orderBy: $orderBy
52
+ ) {
53
+ nodes {
54
+ ${COMMENT_SELECTION}
55
+ }
56
+ }
57
+ }`,
58
+ variables,
59
+ signal,
60
+ );
61
+
62
+ const comments = data.comments.nodes;
63
+ return {
64
+ content: [{ type: 'text', text: JSON.stringify({ comments }, null, 2) }],
65
+ details: { comments },
66
+ };
67
+ });
68
+ },
69
+ }),
70
+ defineTool({
71
+ name: 'linear_create_comment',
72
+ label: 'Linear Create Comment',
73
+ description: 'Create a comment via commentCreate using top-level fields and/or raw input.',
74
+ parameters: Type.Object({
75
+ body: Type.Optional(Type.String()),
76
+ bodyData: Type.Optional(GenericObjectSchema),
77
+ createAsUser: Type.Optional(Type.String()),
78
+ createOnSyncedSlackThread: Type.Optional(Type.Boolean()),
79
+ createdAt: Type.Optional(Type.String()),
80
+ displayIconUrl: Type.Optional(Type.String()),
81
+ doNotSubscribeToIssue: Type.Optional(Type.Boolean()),
82
+ documentContentId: Type.Optional(Type.String()),
83
+ id: Type.Optional(Type.String()),
84
+ initiativeUpdateId: Type.Optional(Type.String()),
85
+ issueId: Type.Optional(Type.String()),
86
+ parentId: Type.Optional(Type.String()),
87
+ postId: Type.Optional(Type.String()),
88
+ projectUpdateId: Type.Optional(Type.String()),
89
+ quotedText: Type.Optional(Type.String()),
90
+ subscriberIds: Type.Optional(Type.Array(Type.String())),
91
+ ...RawInputParam,
92
+ }),
93
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
94
+ return withLinearAuth(ctx, signal, async (apiKey) => {
95
+ const rawInput = asObject(params.input) || {};
96
+ const input = {
97
+ ...rawInput,
98
+ ...compactObject({
99
+ body: params.body,
100
+ bodyData: asObject(params.bodyData),
101
+ createAsUser: params.createAsUser,
102
+ createOnSyncedSlackThread: params.createOnSyncedSlackThread,
103
+ createdAt: params.createdAt,
104
+ displayIconUrl: params.displayIconUrl,
105
+ doNotSubscribeToIssue: params.doNotSubscribeToIssue,
106
+ documentContentId: params.documentContentId,
107
+ id: params.id,
108
+ initiativeUpdateId: params.initiativeUpdateId,
109
+ issueId: params.issueId,
110
+ parentId: params.parentId,
111
+ postId: params.postId,
112
+ projectUpdateId: params.projectUpdateId,
113
+ quotedText: params.quotedText,
114
+ subscriberIds: params.subscriberIds,
115
+ }),
116
+ };
117
+
118
+ if (!asString(input.body) && !asObject(input.bodyData)) {
119
+ throw new Error('Comment body or bodyData is required for commentCreate.');
120
+ }
121
+
122
+ const data = await linearGraphQL<{
123
+ commentCreate: { success: boolean; comment?: JsonObject | null };
124
+ }>(
125
+ apiKey,
126
+ `mutation CreateComment($input: CommentCreateInput!) {
127
+ commentCreate(input: $input) {
128
+ success
129
+ comment {
130
+ ${COMMENT_SELECTION}
131
+ }
132
+ }
133
+ }`,
134
+ { input },
135
+ signal,
136
+ );
137
+
138
+ if (!data.commentCreate.success || !data.commentCreate.comment) {
139
+ throw new Error('Linear commentCreate did not succeed.');
140
+ }
141
+
142
+ const comment = data.commentCreate.comment;
143
+ return {
144
+ content: [{ type: 'text', text: JSON.stringify({ comment }, null, 2) }],
145
+ details: { comment },
146
+ };
147
+ });
148
+ },
149
+ }),
150
+ defineTool({
151
+ name: 'linear_update_comment',
152
+ label: 'Linear Update Comment',
153
+ description: 'Update a comment by id.',
154
+ parameters: Type.Object({
155
+ id: Type.String(),
156
+ body: Type.Optional(Type.String()),
157
+ quotedText: Type.Optional(Type.String()),
158
+ ...RawInputParam,
159
+ }),
160
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
161
+ return withLinearAuth(ctx, signal, async (apiKey) => {
162
+ const rawInput = asObject(params.input) || {};
163
+ const input = {
164
+ ...rawInput,
165
+ ...compactObject({
166
+ body: params.body,
167
+ quotedText: params.quotedText,
168
+ }),
169
+ };
170
+
171
+ if (Object.keys(input).length === 0) {
172
+ throw new Error('No update fields were provided.');
173
+ }
174
+
175
+ const data = await linearGraphQL<{
176
+ commentUpdate: { success: boolean; comment?: JsonObject | null };
177
+ }>(
178
+ apiKey,
179
+ `mutation UpdateComment($id: String!, $input: CommentUpdateInput!) {
180
+ commentUpdate(id: $id, input: $input) {
181
+ success
182
+ comment {
183
+ ${COMMENT_SELECTION}
184
+ }
185
+ }
186
+ }`,
187
+ { id: params.id, input },
188
+ signal,
189
+ );
190
+
191
+ if (!data.commentUpdate.success || !data.commentUpdate.comment) {
192
+ throw new Error('Linear commentUpdate did not succeed.');
193
+ }
194
+
195
+ const comment = data.commentUpdate.comment;
196
+ return {
197
+ content: [{ type: 'text', text: JSON.stringify({ comment }, null, 2) }],
198
+ details: { comment },
199
+ };
200
+ });
201
+ },
202
+ }),
203
+ defineTool({
204
+ name: 'linear_delete_comment',
205
+ label: 'Linear Delete Comment',
206
+ description: 'Delete a comment by id.',
207
+ parameters: Type.Object({
208
+ id: Type.String(),
209
+ }),
210
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
211
+ return withLinearAuth(ctx, signal, async (apiKey) => {
212
+ const data = await linearGraphQL<{
213
+ commentDelete: { success: boolean };
214
+ }>(
215
+ apiKey,
216
+ `mutation DeleteComment($id: String!) {
217
+ commentDelete(id: $id) {
218
+ success
219
+ }
220
+ }`,
221
+ { id: params.id },
222
+ signal,
223
+ );
224
+
225
+ if (!data.commentDelete.success) {
226
+ throw new Error('Linear commentDelete did not succeed.');
227
+ }
228
+
229
+ return {
230
+ content: [{ type: 'text', text: JSON.stringify({ success: true }, null, 2) }],
231
+ details: { success: true },
232
+ };
233
+ });
234
+ },
235
+ }),
236
+ ];
237
+ }