@fink-andreas/pi-linear-tools 0.1.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.
@@ -0,0 +1,582 @@
1
+ import { loadSettings, saveSettings } from '../src/settings.js';
2
+ import { createLinearClient } from '../src/linear-client.js';
3
+ import { debug } from '../src/logger.js';
4
+ import {
5
+ resolveProjectRef,
6
+ fetchTeams,
7
+ fetchWorkspaces,
8
+ } from '../src/linear.js';
9
+ import {
10
+ executeIssueList,
11
+ executeIssueView,
12
+ executeIssueCreate,
13
+ executeIssueUpdate,
14
+ executeIssueComment,
15
+ executeIssueStart,
16
+ executeIssueDelete,
17
+ executeProjectList,
18
+ executeTeamList,
19
+ executeMilestoneList,
20
+ executeMilestoneView,
21
+ executeMilestoneCreate,
22
+ executeMilestoneUpdate,
23
+ executeMilestoneDelete,
24
+ } from '../src/handlers.js';
25
+
26
+ function parseArgs(argsString) {
27
+ if (!argsString || !argsString.trim()) return [];
28
+ const tokens = argsString.match(/"[^"]*"|'[^']*'|\S+/g) || [];
29
+ return tokens.map((t) => {
30
+ if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
31
+ return t.slice(1, -1);
32
+ }
33
+ return t;
34
+ });
35
+ }
36
+
37
+ function readFlag(args, flag) {
38
+ const idx = args.indexOf(flag);
39
+ if (idx >= 0 && idx + 1 < args.length) return args[idx + 1];
40
+ return undefined;
41
+ }
42
+
43
+ let cachedApiKey = null;
44
+
45
+ async function getLinearApiKey() {
46
+ const envKey = process.env.LINEAR_API_KEY;
47
+ if (envKey && envKey.trim()) {
48
+ return envKey.trim();
49
+ }
50
+
51
+ if (cachedApiKey) {
52
+ return cachedApiKey;
53
+ }
54
+
55
+ try {
56
+ const settings = await loadSettings();
57
+ if (settings.linearApiKey && settings.linearApiKey.trim()) {
58
+ cachedApiKey = settings.linearApiKey.trim();
59
+ return cachedApiKey;
60
+ }
61
+ } catch {
62
+ // ignore, error below
63
+ }
64
+
65
+ throw new Error('LINEAR_API_KEY not set. Use /linear-tools-config --api-key <key> or set environment variable.');
66
+ }
67
+
68
+ async function resolveDefaultTeam(projectId) {
69
+ const settings = await loadSettings();
70
+
71
+ if (projectId && settings.projects?.[projectId]?.scope?.team) {
72
+ return settings.projects[projectId].scope.team;
73
+ }
74
+
75
+ return settings.defaultTeam || null;
76
+ }
77
+
78
+ async function runGit(pi, args) {
79
+ if (typeof pi.exec !== 'function') {
80
+ throw new Error('pi.exec is unavailable in this runtime; cannot run git operations');
81
+ }
82
+
83
+ const result = await pi.exec('git', args);
84
+ if (result?.code !== 0) {
85
+ const stderr = String(result?.stderr || '').trim();
86
+ throw new Error(`git ${args.join(' ')} failed${stderr ? `: ${stderr}` : ''}`);
87
+ }
88
+ return result;
89
+ }
90
+
91
+ async function gitBranchExists(pi, branchName) {
92
+ if (typeof pi.exec !== 'function') return false;
93
+ const result = await pi.exec('git', ['rev-parse', '--verify', branchName]);
94
+ return result?.code === 0;
95
+ }
96
+
97
+ async function startGitBranchForIssue(pi, branchName, fromRef = 'HEAD', onBranchExists = 'switch') {
98
+ const exists = await gitBranchExists(pi, branchName);
99
+
100
+ if (!exists) {
101
+ await runGit(pi, ['checkout', '-b', branchName, fromRef || 'HEAD']);
102
+ return { action: 'created', branchName };
103
+ }
104
+
105
+ if (onBranchExists === 'suffix') {
106
+ let suffix = 1;
107
+ let nextName = `${branchName}-${suffix}`;
108
+
109
+ // eslint-disable-next-line no-await-in-loop
110
+ while (await gitBranchExists(pi, nextName)) {
111
+ suffix += 1;
112
+ nextName = `${branchName}-${suffix}`;
113
+ }
114
+
115
+ await runGit(pi, ['checkout', '-b', nextName, fromRef || 'HEAD']);
116
+ return { action: 'created-suffix', branchName: nextName };
117
+ }
118
+
119
+ await runGit(pi, ['checkout', branchName]);
120
+ return { action: 'switched', branchName };
121
+ }
122
+
123
+ async function runInteractiveConfigFlow(ctx) {
124
+ const settings = await loadSettings();
125
+ const envKey = process.env.LINEAR_API_KEY?.trim();
126
+
127
+ let apiKey = envKey || settings.linearApiKey?.trim() || null;
128
+
129
+ if (!apiKey) {
130
+ const authMethod = await ctx.ui.select('Select authentication method', ['OAuth', 'API Key']);
131
+
132
+ if (!authMethod) {
133
+ ctx.ui.notify('Configuration cancelled', 'warning');
134
+ return;
135
+ }
136
+
137
+ if (authMethod === 'OAuth') {
138
+ ctx.ui.notify('OAuth setup is not implemented yet. Aborting.', 'warning');
139
+ return;
140
+ }
141
+
142
+ const promptedKey = await ctx.ui.input('Enter Linear API key', 'lin_xxx');
143
+ const normalized = String(promptedKey || '').trim();
144
+ if (!normalized) {
145
+ ctx.ui.notify('No API key provided. Aborting.', 'warning');
146
+ return;
147
+ }
148
+
149
+ apiKey = normalized;
150
+ settings.linearApiKey = normalized;
151
+ cachedApiKey = normalized;
152
+ }
153
+
154
+ const client = createLinearClient(apiKey);
155
+ const workspaces = await fetchWorkspaces(client);
156
+
157
+ if (workspaces.length === 0) {
158
+ throw new Error('No workspaces available for this Linear account');
159
+ }
160
+
161
+ const workspaceOptions = workspaces.map((w) => `${w.name} (${w.id})`);
162
+ const selectedWorkspaceLabel = await ctx.ui.select('Select workspace', workspaceOptions);
163
+ if (!selectedWorkspaceLabel) {
164
+ ctx.ui.notify('Configuration cancelled', 'warning');
165
+ return;
166
+ }
167
+
168
+ const selectedWorkspace = workspaces[workspaceOptions.indexOf(selectedWorkspaceLabel)];
169
+ settings.defaultWorkspace = {
170
+ id: selectedWorkspace.id,
171
+ name: selectedWorkspace.name,
172
+ };
173
+
174
+ const teams = await fetchTeams(client);
175
+ if (teams.length === 0) {
176
+ throw new Error('No teams found in selected workspace');
177
+ }
178
+
179
+ const teamOptions = teams.map((t) => `${t.key} - ${t.name} (${t.id})`);
180
+ const selectedTeamLabel = await ctx.ui.select('Select default team', teamOptions);
181
+ if (!selectedTeamLabel) {
182
+ ctx.ui.notify('Configuration cancelled', 'warning');
183
+ return;
184
+ }
185
+
186
+ const selectedTeam = teams[teamOptions.indexOf(selectedTeamLabel)];
187
+ settings.defaultTeam = selectedTeam.key;
188
+
189
+ await saveSettings(settings);
190
+ ctx.ui.notify(`Configuration saved: workspace ${selectedWorkspace.name}, team ${selectedTeam.key}`, 'info');
191
+ }
192
+
193
+ function registerLinearTools(pi) {
194
+ if (typeof pi.registerTool !== 'function') return;
195
+
196
+ pi.registerTool({
197
+ name: 'linear_issue',
198
+ label: 'Linear Issue',
199
+ description: 'Interact with Linear issues. Actions: list, view, create, update, comment, start, delete',
200
+ parameters: {
201
+ type: 'object',
202
+ properties: {
203
+ action: {
204
+ type: 'string',
205
+ enum: ['list', 'view', 'create', 'update', 'comment', 'start', 'delete'],
206
+ description: 'Action to perform on issue(s)',
207
+ },
208
+ issue: {
209
+ type: 'string',
210
+ description: 'Issue key (ABC-123) or Linear issue ID (for view, update, comment, start, delete)',
211
+ },
212
+ project: {
213
+ type: 'string',
214
+ description: 'Project name or ID for listing/creating issues (default: current repo directory name)',
215
+ },
216
+ states: {
217
+ type: 'array',
218
+ items: { type: 'string' },
219
+ description: 'Filter by state names for listing',
220
+ },
221
+ assignee: {
222
+ type: 'string',
223
+ description: 'For list: "me" or "all". For create/update: "me" or assignee ID.',
224
+ },
225
+ assigneeId: {
226
+ type: 'string',
227
+ description: 'Optional explicit assignee ID alias for update/create debugging/compatibility.',
228
+ },
229
+ limit: {
230
+ type: 'number',
231
+ description: 'Maximum number of issues to list (default: 50)',
232
+ },
233
+ includeComments: {
234
+ type: 'boolean',
235
+ description: 'Include comments when viewing issue (default: true)',
236
+ },
237
+ title: {
238
+ type: 'string',
239
+ description: 'Issue title (required for create, optional for update)',
240
+ },
241
+ description: {
242
+ type: 'string',
243
+ description: 'Issue description in markdown (for create, update)',
244
+ },
245
+ priority: {
246
+ type: 'number',
247
+ description: 'Priority 0..4 (for create, update)',
248
+ },
249
+ state: {
250
+ type: 'string',
251
+ description: 'Target state name or ID (for create, update)',
252
+ },
253
+ milestone: {
254
+ type: 'string',
255
+ description: 'For update: milestone name/ID, or "none" to clear milestone assignment.',
256
+ },
257
+ projectMilestoneId: {
258
+ type: 'string',
259
+ description: 'Optional explicit milestone ID alias for update.',
260
+ },
261
+ subIssueOf: {
262
+ type: 'string',
263
+ description: 'For update: set this issue as sub-issue of the given issue key/ID, or "none" to clear parent.',
264
+ },
265
+ parentOf: {
266
+ type: 'array',
267
+ items: { type: 'string' },
268
+ description: 'For update: set listed issues as children of this issue.',
269
+ },
270
+ blockedBy: {
271
+ type: 'array',
272
+ items: { type: 'string' },
273
+ description: 'For update: add "blocked by" dependencies (issues that block this issue).',
274
+ },
275
+ blocking: {
276
+ type: 'array',
277
+ items: { type: 'string' },
278
+ description: 'For update: add "blocking" dependencies (issues this issue blocks).',
279
+ },
280
+ relatedTo: {
281
+ type: 'array',
282
+ items: { type: 'string' },
283
+ description: 'For update: add related issue links.',
284
+ },
285
+ duplicateOf: {
286
+ type: 'string',
287
+ description: 'For update: mark this issue as duplicate of the given issue key/ID.',
288
+ },
289
+ team: {
290
+ type: 'string',
291
+ description: 'Team key (e.g. ENG) or name (optional if default team configured)',
292
+ },
293
+ parentId: {
294
+ type: 'string',
295
+ description: 'Parent issue ID for sub-issues (for create)',
296
+ },
297
+ body: {
298
+ type: 'string',
299
+ description: 'Comment body in markdown (for comment)',
300
+ },
301
+ parentCommentId: {
302
+ type: 'string',
303
+ description: 'Parent comment ID for reply (for comment)',
304
+ },
305
+ branch: {
306
+ type: 'string',
307
+ description: 'Custom branch name override (for start)',
308
+ },
309
+ fromRef: {
310
+ type: 'string',
311
+ description: 'Git ref to branch from (default: HEAD, for start)',
312
+ },
313
+ onBranchExists: {
314
+ type: 'string',
315
+ enum: ['switch', 'suffix'],
316
+ description: 'When branch exists: switch to it or create suffixed branch (for start)',
317
+ },
318
+ },
319
+ required: ['action'],
320
+ additionalProperties: false,
321
+ },
322
+ async execute(_toolCallId, params) {
323
+ const apiKey = await getLinearApiKey();
324
+ const client = createLinearClient(apiKey);
325
+
326
+ switch (params.action) {
327
+ case 'list':
328
+ return executeIssueList(client, params);
329
+ case 'view':
330
+ return executeIssueView(client, params);
331
+ case 'create':
332
+ return executeIssueCreate(client, params, { resolveDefaultTeam });
333
+ case 'update':
334
+ return executeIssueUpdate(client, params);
335
+ case 'comment':
336
+ return executeIssueComment(client, params);
337
+ case 'start':
338
+ return executeIssueStart(client, params, {
339
+ gitExecutor: async (branchName, fromRef, onBranchExists) => {
340
+ return startGitBranchForIssue(pi, branchName, fromRef, onBranchExists);
341
+ },
342
+ });
343
+ case 'delete':
344
+ return executeIssueDelete(client, params);
345
+ default:
346
+ throw new Error(`Unknown action: ${params.action}`);
347
+ }
348
+ },
349
+ });
350
+
351
+ pi.registerTool({
352
+ name: 'linear_project',
353
+ label: 'Linear Project',
354
+ description: 'Interact with Linear projects. Actions: list',
355
+ parameters: {
356
+ type: 'object',
357
+ properties: {
358
+ action: {
359
+ type: 'string',
360
+ enum: ['list'],
361
+ description: 'Action to perform on project(s)',
362
+ },
363
+ },
364
+ required: ['action'],
365
+ additionalProperties: false,
366
+ },
367
+ async execute(_toolCallId, params) {
368
+ const apiKey = await getLinearApiKey();
369
+ const client = createLinearClient(apiKey);
370
+
371
+ switch (params.action) {
372
+ case 'list':
373
+ return executeProjectList(client);
374
+ default:
375
+ throw new Error(`Unknown action: ${params.action}`);
376
+ }
377
+ },
378
+ });
379
+
380
+ pi.registerTool({
381
+ name: 'linear_team',
382
+ label: 'Linear Team',
383
+ description: 'Interact with Linear teams. Actions: list',
384
+ parameters: {
385
+ type: 'object',
386
+ properties: {
387
+ action: {
388
+ type: 'string',
389
+ enum: ['list'],
390
+ description: 'Action to perform on team(s)',
391
+ },
392
+ },
393
+ required: ['action'],
394
+ additionalProperties: false,
395
+ },
396
+ async execute(_toolCallId, params) {
397
+ const apiKey = await getLinearApiKey();
398
+ const client = createLinearClient(apiKey);
399
+
400
+ switch (params.action) {
401
+ case 'list':
402
+ return executeTeamList(client);
403
+ default:
404
+ throw new Error(`Unknown action: ${params.action}`);
405
+ }
406
+ },
407
+ });
408
+
409
+ pi.registerTool({
410
+ name: 'linear_milestone',
411
+ label: 'Linear Milestone',
412
+ description: 'Interact with Linear project milestones. Actions: list, view, create, update, delete',
413
+ parameters: {
414
+ type: 'object',
415
+ properties: {
416
+ action: {
417
+ type: 'string',
418
+ enum: ['list', 'view', 'create', 'update', 'delete'],
419
+ description: 'Action to perform on milestone(s)',
420
+ },
421
+ milestone: {
422
+ type: 'string',
423
+ description: 'Milestone ID (for view, update, delete)',
424
+ },
425
+ project: {
426
+ type: 'string',
427
+ description: 'Project name or ID (for list, create)',
428
+ },
429
+ name: {
430
+ type: 'string',
431
+ description: 'Milestone name (required for create, optional for update)',
432
+ },
433
+ description: {
434
+ type: 'string',
435
+ description: 'Milestone description in markdown',
436
+ },
437
+ targetDate: {
438
+ type: 'string',
439
+ description: 'Target completion date (ISO 8601 date)',
440
+ },
441
+ status: {
442
+ type: 'string',
443
+ enum: ['backlogged', 'planned', 'inProgress', 'paused', 'completed', 'done', 'cancelled'],
444
+ description: 'Milestone status',
445
+ },
446
+ },
447
+ required: ['action'],
448
+ additionalProperties: false,
449
+ },
450
+ async execute(_toolCallId, params) {
451
+ const apiKey = await getLinearApiKey();
452
+ const client = createLinearClient(apiKey);
453
+
454
+ switch (params.action) {
455
+ case 'list':
456
+ return executeMilestoneList(client, params);
457
+ case 'view':
458
+ return executeMilestoneView(client, params);
459
+ case 'create':
460
+ return executeMilestoneCreate(client, params);
461
+ case 'update':
462
+ return executeMilestoneUpdate(client, params);
463
+ case 'delete':
464
+ return executeMilestoneDelete(client, params);
465
+ default:
466
+ throw new Error(`Unknown action: ${params.action}`);
467
+ }
468
+ },
469
+ });
470
+ }
471
+
472
+ export default function piLinearToolsExtension(pi) {
473
+ registerLinearTools(pi);
474
+
475
+ pi.registerCommand('linear-tools-config', {
476
+ description: 'Configure pi-linear-tools settings (API key and default team mappings)',
477
+ handler: async (argsText, ctx) => {
478
+ const args = parseArgs(argsText);
479
+ const apiKey = readFlag(args, '--api-key');
480
+ const defaultTeam = readFlag(args, '--default-team');
481
+ const projectTeam = readFlag(args, '--team');
482
+ const projectName = readFlag(args, '--project');
483
+
484
+ if (apiKey) {
485
+ const settings = await loadSettings();
486
+ settings.linearApiKey = apiKey;
487
+ await saveSettings(settings);
488
+ cachedApiKey = null;
489
+ if (ctx?.hasUI) {
490
+ ctx.ui.notify('LINEAR_API_KEY saved to settings', 'info');
491
+ }
492
+ return;
493
+ }
494
+
495
+ if (defaultTeam) {
496
+ const settings = await loadSettings();
497
+ settings.defaultTeam = defaultTeam;
498
+ await saveSettings(settings);
499
+ if (ctx?.hasUI) {
500
+ ctx.ui.notify(`Default team set to: ${defaultTeam}`, 'info');
501
+ }
502
+ return;
503
+ }
504
+
505
+ if (projectTeam && projectName) {
506
+ const settings = await loadSettings();
507
+
508
+ let projectId = projectName;
509
+ try {
510
+ const resolvedKey = await getLinearApiKey();
511
+ const client = createLinearClient(resolvedKey);
512
+ const resolved = await resolveProjectRef(client, projectName);
513
+ projectId = resolved.id;
514
+ } catch {
515
+ // keep provided value as project ID/name key
516
+ }
517
+
518
+ if (!settings.projects[projectId]) {
519
+ settings.projects[projectId] = {
520
+ scope: {
521
+ team: null,
522
+ },
523
+ };
524
+ }
525
+
526
+ if (!settings.projects[projectId].scope) {
527
+ settings.projects[projectId].scope = { team: null };
528
+ }
529
+
530
+ settings.projects[projectId].scope.team = projectTeam;
531
+ await saveSettings(settings);
532
+
533
+ if (ctx?.hasUI) {
534
+ ctx.ui.notify(`Team for project "${projectName}" set to: ${projectTeam}`, 'info');
535
+ }
536
+ return;
537
+ }
538
+
539
+ if (!apiKey && !defaultTeam && !projectTeam && !projectName && ctx?.hasUI && ctx?.ui) {
540
+ await runInteractiveConfigFlow(ctx);
541
+ return;
542
+ }
543
+
544
+ const settings = await loadSettings();
545
+ const hasKey = !!(settings.linearApiKey || process.env.LINEAR_API_KEY);
546
+ const keySource = process.env.LINEAR_API_KEY ? 'environment' : (settings.linearApiKey ? 'settings' : 'not set');
547
+
548
+ pi.sendMessage({
549
+ customType: 'pi-linear-tools',
550
+ content: `Configuration:\n LINEAR_API_KEY: ${hasKey ? 'configured' : 'not set'} (source: ${keySource})\n Default workspace: ${settings.defaultWorkspace?.name || 'not set'}\n Default team: ${settings.defaultTeam || 'not set'}\n Project team mappings: ${Object.keys(settings.projects || {}).length}\n\nCommands:\n /linear-tools-config --api-key lin_xxx\n /linear-tools-config --default-team ENG\n /linear-tools-config --team ENG --project MyProject\n\nNote: environment LINEAR_API_KEY takes precedence over settings file.`,
551
+ display: true,
552
+ });
553
+ },
554
+ });
555
+
556
+ pi.registerCommand('linear-tools-help', {
557
+ description: 'Show pi-linear-tools commands and tools',
558
+ handler: async (_args, ctx) => {
559
+ if (ctx?.hasUI) {
560
+ ctx.ui.notify('pi-linear-tools extension commands available', 'info');
561
+ }
562
+
563
+ pi.sendMessage({
564
+ customType: 'pi-linear-tools',
565
+ content: [
566
+ 'Commands:',
567
+ ' /linear-tools-config --api-key <key>',
568
+ ' /linear-tools-config --default-team <team-key>',
569
+ ' /linear-tools-config --team <team-key> --project <project-name-or-id>',
570
+ ' /linear-tools-help',
571
+ '',
572
+ 'LLM-callable tools:',
573
+ ' linear_issue (list/view/create/update/comment/start/delete)',
574
+ ' linear_project (list)',
575
+ ' linear_team (list)',
576
+ ' linear_milestone (list/view/create/update/delete)',
577
+ ].join('\n'),
578
+ display: true,
579
+ });
580
+ },
581
+ });
582
+ }
package/index.js ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runCli } from './src/cli.js';
4
+
5
+ runCli().catch((error) => {
6
+ console.error('pi-linear-tools error:', error?.message || error);
7
+ process.exit(1);
8
+ });
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@fink-andreas/pi-linear-tools",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension with Linear SDK tools and configuration commands",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=18.0.0"
8
+ },
9
+ "main": "index.js",
10
+ "bin": {
11
+ "pi-linear-tools": "bin/pi-linear-tools.js"
12
+ },
13
+ "files": [
14
+ "bin/",
15
+ "src/",
16
+ "extensions/",
17
+ "index.js",
18
+ "README.md",
19
+ "CHANGELOG.md",
20
+ "RELEASE.md",
21
+ "POST_RELEASE_CHECKLIST.md",
22
+ "FUNCTIONALITY.md",
23
+ "settings.json.example"
24
+ ],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "scripts": {
29
+ "start": "node index.js",
30
+ "test": "node tests/test-package-manifest.js && node tests/test-extension-registration.js && node tests/test-settings.js && node tests/test-assignee-update.js && node tests/test-full-assignee-flow.js",
31
+ "release:check": "npm test && npm pack --dry-run"
32
+ },
33
+ "keywords": [
34
+ "linear",
35
+ "pi",
36
+ "pi-package",
37
+ "tools",
38
+ "sdk"
39
+ ],
40
+ "pi": {
41
+ "extensions": [
42
+ "./extensions"
43
+ ]
44
+ },
45
+ "license": "MIT",
46
+ "dependencies": {
47
+ "@linear/sdk": "^75.0.0"
48
+ }
49
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "linearApiKey": "lin_xxx",
4
+ "defaultTeam": "ENG",
5
+ "projects": {
6
+ "97ec7cae-e252-493d-94d3-6910aa28cacf": {
7
+ "scope": {
8
+ "team": "ENG"
9
+ }
10
+ }
11
+ }
12
+ }