@fink-andreas/pi-linear-tools 0.2.0 → 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/extensions/pi-linear-tools.js +0 -9
- package/index.js +916 -6
- package/package.json +3 -3
- package/src/auth/token-store.js +5 -5
- package/src/cli.js +11 -11
- package/src/handlers.js +11 -8
- package/src/linear.js +3 -8
|
@@ -613,10 +613,6 @@ async function registerLinearTools(pi) {
|
|
|
613
613
|
type: 'string',
|
|
614
614
|
description: 'Parent comment ID for reply (for comment)',
|
|
615
615
|
},
|
|
616
|
-
branch: {
|
|
617
|
-
type: 'string',
|
|
618
|
-
description: 'Custom branch name override (for start)',
|
|
619
|
-
},
|
|
620
616
|
fromRef: {
|
|
621
617
|
type: 'string',
|
|
622
618
|
description: 'Git ref to branch from (default: HEAD, for start)',
|
|
@@ -750,11 +746,6 @@ async function registerLinearTools(pi) {
|
|
|
750
746
|
type: 'string',
|
|
751
747
|
description: 'Target completion date (ISO 8601 date)',
|
|
752
748
|
},
|
|
753
|
-
status: {
|
|
754
|
-
type: 'string',
|
|
755
|
-
enum: ['backlogged', 'planned', 'inProgress', 'paused', 'completed', 'done', 'cancelled'],
|
|
756
|
-
description: 'Milestone status',
|
|
757
|
-
},
|
|
758
749
|
},
|
|
759
750
|
required: ['action'],
|
|
760
751
|
additionalProperties: false,
|
package/index.js
CHANGED
|
@@ -1,8 +1,918 @@
|
|
|
1
|
-
|
|
1
|
+
import { loadSettings, saveSettings } from './src/settings.js';
|
|
2
|
+
import { createLinearClient } from './src/linear-client.js';
|
|
3
|
+
import { setQuietMode } from './src/logger.js';
|
|
4
|
+
import {
|
|
5
|
+
resolveProjectRef,
|
|
6
|
+
fetchTeams,
|
|
7
|
+
fetchWorkspaces,
|
|
8
|
+
} from './src/linear.js';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { pathToFileURL } from 'node:url';
|
|
2
12
|
|
|
3
|
-
|
|
13
|
+
function isPiCodingAgentRoot(dir) {
|
|
14
|
+
const pkgPath = path.join(dir, 'package.json');
|
|
15
|
+
if (!fs.existsSync(pkgPath)) return false;
|
|
16
|
+
try {
|
|
17
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
18
|
+
return pkg?.name === '@mariozechner/pi-coding-agent';
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
4
23
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
24
|
+
function findPiCodingAgentRoot() {
|
|
25
|
+
const entry = process.argv?.[1];
|
|
26
|
+
if (!entry) return null;
|
|
27
|
+
|
|
28
|
+
// Method 1: walk up from argv1 (works when argv1 is .../pi-coding-agent/dist/cli.js)
|
|
29
|
+
{
|
|
30
|
+
let dir = path.dirname(entry);
|
|
31
|
+
for (let i = 0; i < 20; i += 1) {
|
|
32
|
+
if (isPiCodingAgentRoot(dir)) {
|
|
33
|
+
return dir;
|
|
34
|
+
}
|
|
35
|
+
const parent = path.dirname(dir);
|
|
36
|
+
if (parent === dir) break;
|
|
37
|
+
dir = parent;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Method 2: npm global layout guess (works when argv1 is .../<prefix>/bin/pi)
|
|
42
|
+
// <prefix>/bin/pi -> <prefix>/lib/node_modules/@mariozechner/pi-coding-agent
|
|
43
|
+
{
|
|
44
|
+
const binDir = path.dirname(entry);
|
|
45
|
+
const prefix = path.resolve(binDir, '..');
|
|
46
|
+
const candidate = path.join(prefix, 'lib', 'node_modules', '@mariozechner', 'pi-coding-agent');
|
|
47
|
+
if (isPiCodingAgentRoot(candidate)) {
|
|
48
|
+
return candidate;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Method 3: common global node_modules locations
|
|
53
|
+
for (const candidate of [
|
|
54
|
+
'/usr/local/lib/node_modules/@mariozechner/pi-coding-agent',
|
|
55
|
+
'/usr/lib/node_modules/@mariozechner/pi-coding-agent',
|
|
56
|
+
]) {
|
|
57
|
+
if (isPiCodingAgentRoot(candidate)) {
|
|
58
|
+
return candidate;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function importFromPiRoot(relativePathFromPiRoot) {
|
|
66
|
+
const piRoot = findPiCodingAgentRoot();
|
|
67
|
+
|
|
68
|
+
if (!piRoot) throw new Error('Unable to locate @mariozechner/pi-coding-agent installation');
|
|
69
|
+
|
|
70
|
+
const absPath = path.join(piRoot, relativePathFromPiRoot);
|
|
71
|
+
return import(pathToFileURL(absPath).href);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function importPiCodingAgent() {
|
|
75
|
+
try {
|
|
76
|
+
return await import('@mariozechner/pi-coding-agent');
|
|
77
|
+
} catch {
|
|
78
|
+
return importFromPiRoot('dist/index.js');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function importPiTui() {
|
|
83
|
+
try {
|
|
84
|
+
return await import('@mariozechner/pi-tui');
|
|
85
|
+
} catch {
|
|
86
|
+
// pi-tui is a dependency of pi-coding-agent and may be nested under it
|
|
87
|
+
return importFromPiRoot('node_modules/@mariozechner/pi-tui/dist/index.js');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Optional imports for markdown rendering (provided by pi runtime)
|
|
92
|
+
let Markdown = null;
|
|
93
|
+
let Text = null;
|
|
94
|
+
let getMarkdownTheme = null;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const piTui = await importPiTui();
|
|
98
|
+
Markdown = piTui?.Markdown || null;
|
|
99
|
+
Text = piTui?.Text || null;
|
|
100
|
+
} catch {
|
|
101
|
+
// ignore
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const piCodingAgent = await importPiCodingAgent();
|
|
106
|
+
getMarkdownTheme = piCodingAgent?.getMarkdownTheme || null;
|
|
107
|
+
} catch {
|
|
108
|
+
// ignore
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
import {
|
|
112
|
+
executeIssueList,
|
|
113
|
+
executeIssueView,
|
|
114
|
+
executeIssueCreate,
|
|
115
|
+
executeIssueUpdate,
|
|
116
|
+
executeIssueComment,
|
|
117
|
+
executeIssueStart,
|
|
118
|
+
executeIssueDelete,
|
|
119
|
+
executeProjectList,
|
|
120
|
+
executeTeamList,
|
|
121
|
+
executeMilestoneList,
|
|
122
|
+
executeMilestoneView,
|
|
123
|
+
executeMilestoneCreate,
|
|
124
|
+
executeMilestoneUpdate,
|
|
125
|
+
executeMilestoneDelete,
|
|
126
|
+
} from './src/handlers.js';
|
|
127
|
+
import { authenticate, getAccessToken, logout } from './src/auth/index.js';
|
|
128
|
+
|
|
129
|
+
function parseArgs(argsString) {
|
|
130
|
+
if (!argsString || !argsString.trim()) return [];
|
|
131
|
+
const tokens = argsString.match(/"[^"]*"|'[^']*'|\S+/g) || [];
|
|
132
|
+
return tokens.map((t) => {
|
|
133
|
+
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
|
|
134
|
+
return t.slice(1, -1);
|
|
135
|
+
}
|
|
136
|
+
return t;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function readFlag(args, flag) {
|
|
141
|
+
const idx = args.indexOf(flag);
|
|
142
|
+
if (idx >= 0 && idx + 1 < args.length) return args[idx + 1];
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let cachedApiKey = null;
|
|
147
|
+
|
|
148
|
+
async function getLinearAuth() {
|
|
149
|
+
const envKey = process.env.LINEAR_API_KEY;
|
|
150
|
+
if (envKey && envKey.trim()) {
|
|
151
|
+
return { apiKey: envKey.trim() };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const settings = await loadSettings();
|
|
155
|
+
const authMethod = settings.authMethod || 'api-key';
|
|
156
|
+
|
|
157
|
+
if (authMethod === 'oauth') {
|
|
158
|
+
const accessToken = await getAccessToken();
|
|
159
|
+
if (accessToken) {
|
|
160
|
+
return { accessToken };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (cachedApiKey) {
|
|
165
|
+
return { apiKey: cachedApiKey };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const apiKey = settings.apiKey || settings.linearApiKey;
|
|
169
|
+
if (apiKey && apiKey.trim()) {
|
|
170
|
+
cachedApiKey = apiKey.trim();
|
|
171
|
+
return { apiKey: cachedApiKey };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const fallbackAccessToken = await getAccessToken();
|
|
175
|
+
if (fallbackAccessToken) {
|
|
176
|
+
return { accessToken: fallbackAccessToken };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
throw new Error(
|
|
180
|
+
'No Linear authentication configured. Use /linear-tools-config --api-key <key> or run `pi-linear-tools auth login` in CLI.'
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function createAuthenticatedClient() {
|
|
185
|
+
return createLinearClient(await getLinearAuth());
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function withMilestoneScopeHint(error) {
|
|
189
|
+
const message = String(error?.message || error || 'Unknown error');
|
|
190
|
+
|
|
191
|
+
if (/invalid scope/i.test(message) && /write/i.test(message)) {
|
|
192
|
+
return new Error(
|
|
193
|
+
`${message}\nHint: Milestone create/update/delete require Linear write scope. ` +
|
|
194
|
+
`Use API key auth for milestone management: /linear-tools-config --api-key <key>`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return error;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function resolveDefaultTeam(projectId) {
|
|
202
|
+
const settings = await loadSettings();
|
|
203
|
+
|
|
204
|
+
if (projectId && settings.projects?.[projectId]?.scope?.team) {
|
|
205
|
+
return settings.projects[projectId].scope.team;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return settings.defaultTeam || null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function runGit(pi, args) {
|
|
212
|
+
if (typeof pi.exec !== 'function') {
|
|
213
|
+
throw new Error('pi.exec is unavailable in this runtime; cannot run git operations');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const result = await pi.exec('git', args);
|
|
217
|
+
if (result?.code !== 0) {
|
|
218
|
+
const stderr = String(result?.stderr || '').trim();
|
|
219
|
+
throw new Error(`git ${args.join(' ')} failed${stderr ? `: ${stderr}` : ''}`);
|
|
220
|
+
}
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function gitBranchExists(pi, branchName) {
|
|
225
|
+
if (typeof pi.exec !== 'function') return false;
|
|
226
|
+
const result = await pi.exec('git', ['rev-parse', '--verify', branchName]);
|
|
227
|
+
return result?.code === 0;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function startGitBranchForIssue(pi, branchName, fromRef = 'HEAD', onBranchExists = 'switch') {
|
|
231
|
+
const exists = await gitBranchExists(pi, branchName);
|
|
232
|
+
|
|
233
|
+
if (!exists) {
|
|
234
|
+
await runGit(pi, ['checkout', '-b', branchName, fromRef || 'HEAD']);
|
|
235
|
+
return { action: 'created', branchName };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (onBranchExists === 'suffix') {
|
|
239
|
+
let suffix = 1;
|
|
240
|
+
let nextName = `${branchName}-${suffix}`;
|
|
241
|
+
|
|
242
|
+
// eslint-disable-next-line no-await-in-loop
|
|
243
|
+
while (await gitBranchExists(pi, nextName)) {
|
|
244
|
+
suffix += 1;
|
|
245
|
+
nextName = `${branchName}-${suffix}`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
await runGit(pi, ['checkout', '-b', nextName, fromRef || 'HEAD']);
|
|
249
|
+
return { action: 'created-suffix', branchName: nextName };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
await runGit(pi, ['checkout', branchName]);
|
|
253
|
+
return { action: 'switched', branchName };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function runInteractiveConfigFlow(ctx, pi) {
|
|
257
|
+
const settings = await loadSettings();
|
|
258
|
+
const previousAuthMethod = settings.authMethod || 'api-key';
|
|
259
|
+
const envKey = process.env.LINEAR_API_KEY?.trim();
|
|
260
|
+
|
|
261
|
+
let client;
|
|
262
|
+
let apiKey = settings.apiKey?.trim() || settings.linearApiKey?.trim() || null;
|
|
263
|
+
let accessToken = null;
|
|
264
|
+
|
|
265
|
+
setQuietMode(true);
|
|
266
|
+
try {
|
|
267
|
+
accessToken = await getAccessToken();
|
|
268
|
+
} finally {
|
|
269
|
+
setQuietMode(false);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const hasWorkingAuth = !!(envKey || apiKey || accessToken);
|
|
273
|
+
|
|
274
|
+
if (hasWorkingAuth) {
|
|
275
|
+
const source = envKey ? 'environment API key' : (accessToken ? 'OAuth token' : 'stored API key');
|
|
276
|
+
const logoutSelection = await ctx.ui.select(
|
|
277
|
+
`Existing authentication detected (${source}). Logout and re-authenticate?`,
|
|
278
|
+
['No', 'Yes']
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
if (!logoutSelection) {
|
|
282
|
+
ctx.ui.notify('Configuration cancelled', 'warning');
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (logoutSelection === 'Yes') {
|
|
287
|
+
setQuietMode(true);
|
|
288
|
+
try {
|
|
289
|
+
await logout();
|
|
290
|
+
} finally {
|
|
291
|
+
setQuietMode(false);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
settings.apiKey = null;
|
|
295
|
+
if (Object.prototype.hasOwnProperty.call(settings, 'linearApiKey')) {
|
|
296
|
+
delete settings.linearApiKey;
|
|
297
|
+
}
|
|
298
|
+
cachedApiKey = null;
|
|
299
|
+
accessToken = null;
|
|
300
|
+
apiKey = null;
|
|
301
|
+
|
|
302
|
+
await saveSettings(settings);
|
|
303
|
+
ctx.ui.notify('Stored authentication cleared.', 'info');
|
|
304
|
+
|
|
305
|
+
if (envKey) {
|
|
306
|
+
ctx.ui.notify('LINEAR_API_KEY is still set in environment and cannot be removed by this command.', 'warning');
|
|
307
|
+
}
|
|
308
|
+
} else {
|
|
309
|
+
if (envKey) {
|
|
310
|
+
client = createLinearClient(envKey);
|
|
311
|
+
} else if (accessToken) {
|
|
312
|
+
settings.authMethod = 'oauth';
|
|
313
|
+
client = createLinearClient({ accessToken });
|
|
314
|
+
} else if (apiKey) {
|
|
315
|
+
settings.authMethod = 'api-key';
|
|
316
|
+
cachedApiKey = apiKey;
|
|
317
|
+
client = createLinearClient(apiKey);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (!client) {
|
|
323
|
+
const selectedAuthMethod = await ctx.ui.select('Select authentication method', ['API Key (recommended for full functionlaity)', 'OAuth']);
|
|
324
|
+
|
|
325
|
+
if (!selectedAuthMethod) {
|
|
326
|
+
ctx.ui.notify('Configuration cancelled', 'warning');
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (selectedAuthMethod === 'OAuth') {
|
|
331
|
+
settings.authMethod = 'oauth';
|
|
332
|
+
cachedApiKey = null;
|
|
333
|
+
|
|
334
|
+
setQuietMode(true);
|
|
335
|
+
try {
|
|
336
|
+
accessToken = await getAccessToken();
|
|
337
|
+
|
|
338
|
+
if (!accessToken) {
|
|
339
|
+
ctx.ui.notify('Starting OAuth login...', 'info');
|
|
340
|
+
try {
|
|
341
|
+
await authenticate({
|
|
342
|
+
onAuthorizationUrl: async (authUrl) => {
|
|
343
|
+
pi.sendMessage({
|
|
344
|
+
customType: 'pi-linear-tools',
|
|
345
|
+
content: [
|
|
346
|
+
'### Linear OAuth login',
|
|
347
|
+
'',
|
|
348
|
+
`[Open authorization URL](${authUrl})`,
|
|
349
|
+
'',
|
|
350
|
+
'If browser did not open automatically, copy and open this URL:',
|
|
351
|
+
`\`${authUrl}\``,
|
|
352
|
+
'',
|
|
353
|
+
'After authorizing, paste the callback URL in the next prompt.',
|
|
354
|
+
].join('\n'),
|
|
355
|
+
display: true,
|
|
356
|
+
});
|
|
357
|
+
ctx.ui.notify('Complete OAuth in browser, then paste callback URL in the prompt.', 'info');
|
|
358
|
+
},
|
|
359
|
+
manualCodeInput: async () => {
|
|
360
|
+
const entered = await ctx.ui.input(
|
|
361
|
+
'Paste callback URL from browser (or type "cancel")',
|
|
362
|
+
'http://localhost:34711/callback?code=...&state=...'
|
|
363
|
+
);
|
|
364
|
+
const normalized = String(entered || '').trim();
|
|
365
|
+
if (!normalized || normalized.toLowerCase() === 'cancel') {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
return normalized;
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
} catch (error) {
|
|
372
|
+
if (String(error?.message || '').includes('cancelled by user')) {
|
|
373
|
+
ctx.ui.notify('OAuth authentication cancelled.', 'warning');
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
accessToken = await getAccessToken();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (!accessToken) {
|
|
382
|
+
throw new Error('OAuth authentication failed: no access token available after login');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
client = createLinearClient({ accessToken });
|
|
386
|
+
} finally {
|
|
387
|
+
setQuietMode(false);
|
|
388
|
+
}
|
|
389
|
+
} else {
|
|
390
|
+
setQuietMode(true);
|
|
391
|
+
try {
|
|
392
|
+
await logout();
|
|
393
|
+
} finally {
|
|
394
|
+
setQuietMode(false);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (!envKey && !apiKey) {
|
|
398
|
+
const promptedKey = await ctx.ui.input('Enter Linear API key', 'lin_xxx');
|
|
399
|
+
const normalized = String(promptedKey || '').trim();
|
|
400
|
+
if (!normalized) {
|
|
401
|
+
ctx.ui.notify('No API key provided. Aborting.', 'warning');
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
apiKey = normalized;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const selectedApiKey = envKey || apiKey;
|
|
409
|
+
settings.apiKey = selectedApiKey;
|
|
410
|
+
settings.authMethod = 'api-key';
|
|
411
|
+
cachedApiKey = selectedApiKey;
|
|
412
|
+
client = createLinearClient(selectedApiKey);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const workspaces = await fetchWorkspaces(client);
|
|
417
|
+
|
|
418
|
+
if (workspaces.length === 0) {
|
|
419
|
+
throw new Error('No workspaces available for this Linear account');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const workspaceOptions = workspaces.map((w) => `${w.name} (${w.id})`);
|
|
423
|
+
const selectedWorkspaceLabel = await ctx.ui.select('Select workspace', workspaceOptions);
|
|
424
|
+
if (!selectedWorkspaceLabel) {
|
|
425
|
+
ctx.ui.notify('Configuration cancelled', 'warning');
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const selectedWorkspace = workspaces[workspaceOptions.indexOf(selectedWorkspaceLabel)];
|
|
430
|
+
settings.defaultWorkspace = {
|
|
431
|
+
id: selectedWorkspace.id,
|
|
432
|
+
name: selectedWorkspace.name,
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
const teams = await fetchTeams(client);
|
|
436
|
+
if (teams.length === 0) {
|
|
437
|
+
throw new Error('No teams found in selected workspace');
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const teamOptions = teams.map((t) => `${t.key} - ${t.name} (${t.id})`);
|
|
441
|
+
const selectedTeamLabel = await ctx.ui.select('Select default team', teamOptions);
|
|
442
|
+
if (!selectedTeamLabel) {
|
|
443
|
+
ctx.ui.notify('Configuration cancelled', 'warning');
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const selectedTeam = teams[teamOptions.indexOf(selectedTeamLabel)];
|
|
448
|
+
settings.defaultTeam = selectedTeam.key;
|
|
449
|
+
|
|
450
|
+
await saveSettings(settings);
|
|
451
|
+
ctx.ui.notify(`Configuration saved: workspace ${selectedWorkspace.name}, team ${selectedTeam.key}`, 'info');
|
|
452
|
+
|
|
453
|
+
if (previousAuthMethod !== settings.authMethod) {
|
|
454
|
+
ctx.ui.notify(
|
|
455
|
+
'Authentication method changed. Please restart pi to refresh and make the correct tools available.',
|
|
456
|
+
'warning'
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function shouldExposeMilestoneTool() {
|
|
462
|
+
const settings = await loadSettings();
|
|
463
|
+
const authMethod = settings.authMethod || 'api-key';
|
|
464
|
+
const apiKeyFromSettings = (settings.apiKey || settings.linearApiKey || '').trim();
|
|
465
|
+
const apiKeyFromEnv = (process.env.LINEAR_API_KEY || '').trim();
|
|
466
|
+
const hasApiKey = !!(apiKeyFromEnv || apiKeyFromSettings);
|
|
467
|
+
|
|
468
|
+
return authMethod === 'api-key' || hasApiKey;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Render tool result as markdown
|
|
473
|
+
*/
|
|
474
|
+
function renderMarkdownResult(result, _options, _theme) {
|
|
475
|
+
const text = result.content?.[0]?.text || '';
|
|
476
|
+
|
|
477
|
+
// Fall back to plain text if markdown packages not available
|
|
478
|
+
if (!Markdown || !getMarkdownTheme) {
|
|
479
|
+
const lines = text.split('\n');
|
|
480
|
+
return {
|
|
481
|
+
render: (width) => lines.map((line) => (width && line.length > width ? line.slice(0, width) : line)),
|
|
482
|
+
invalidate: () => {},
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Return Markdown component directly - the TUI will call its render() method
|
|
487
|
+
try {
|
|
488
|
+
const mdTheme = getMarkdownTheme();
|
|
489
|
+
return new Markdown(text, 0, 0, mdTheme, _theme ? { color: (t) => _theme.fg('toolOutput', t) } : undefined);
|
|
490
|
+
} catch (error) {
|
|
491
|
+
// If markdown rendering fails for any reason, show a visible error so we can diagnose.
|
|
492
|
+
const msg = `[pi-linear-tools] Markdown render failed: ${String(error?.message || error)}`;
|
|
493
|
+
if (Text) {
|
|
494
|
+
return new Text((_theme ? _theme.fg('error', msg) : msg) + `\n\n` + text, 0, 0);
|
|
495
|
+
}
|
|
496
|
+
const lines = (msg + '\n\n' + text).split('\n');
|
|
497
|
+
return {
|
|
498
|
+
render: (width) => lines.map((line) => (width && line.length > width ? line.slice(0, width) : line)),
|
|
499
|
+
invalidate: () => {},
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function registerLinearTools(pi) {
|
|
505
|
+
if (typeof pi.registerTool !== 'function') return;
|
|
506
|
+
|
|
507
|
+
pi.registerTool({
|
|
508
|
+
name: 'linear_issue',
|
|
509
|
+
label: 'Linear Issue',
|
|
510
|
+
description: 'Interact with Linear issues. Actions: list, view, create, update, comment, start, delete',
|
|
511
|
+
parameters: {
|
|
512
|
+
type: 'object',
|
|
513
|
+
properties: {
|
|
514
|
+
action: {
|
|
515
|
+
type: 'string',
|
|
516
|
+
enum: ['list', 'view', 'create', 'update', 'comment', 'start', 'delete'],
|
|
517
|
+
description: 'Action to perform on issue(s)',
|
|
518
|
+
},
|
|
519
|
+
issue: {
|
|
520
|
+
type: 'string',
|
|
521
|
+
description: 'Issue key (ABC-123) or Linear issue ID (for view, update, comment, start, delete)',
|
|
522
|
+
},
|
|
523
|
+
project: {
|
|
524
|
+
type: 'string',
|
|
525
|
+
description: 'Project name or ID for listing/creating issues (default: current repo directory name)',
|
|
526
|
+
},
|
|
527
|
+
states: {
|
|
528
|
+
type: 'array',
|
|
529
|
+
items: { type: 'string' },
|
|
530
|
+
description: 'Filter by state names for listing',
|
|
531
|
+
},
|
|
532
|
+
assignee: {
|
|
533
|
+
type: 'string',
|
|
534
|
+
description: 'For list: "me" or "all". For create/update: "me" or assignee ID.',
|
|
535
|
+
},
|
|
536
|
+
assigneeId: {
|
|
537
|
+
type: 'string',
|
|
538
|
+
description: 'Optional explicit assignee ID alias for update/create debugging/compatibility.',
|
|
539
|
+
},
|
|
540
|
+
limit: {
|
|
541
|
+
type: 'number',
|
|
542
|
+
description: 'Maximum number of issues to list (default: 50)',
|
|
543
|
+
},
|
|
544
|
+
includeComments: {
|
|
545
|
+
type: 'boolean',
|
|
546
|
+
description: 'Include comments when viewing issue (default: true)',
|
|
547
|
+
},
|
|
548
|
+
title: {
|
|
549
|
+
type: 'string',
|
|
550
|
+
description: 'Issue title (required for create, optional for update)',
|
|
551
|
+
},
|
|
552
|
+
description: {
|
|
553
|
+
type: 'string',
|
|
554
|
+
description: 'Issue description in markdown (for create, update)',
|
|
555
|
+
},
|
|
556
|
+
priority: {
|
|
557
|
+
type: 'number',
|
|
558
|
+
description: 'Priority 0..4 (for create, update)',
|
|
559
|
+
},
|
|
560
|
+
state: {
|
|
561
|
+
type: 'string',
|
|
562
|
+
description: 'Target state name or ID (for create, update)',
|
|
563
|
+
},
|
|
564
|
+
milestone: {
|
|
565
|
+
type: 'string',
|
|
566
|
+
description: 'For update: milestone name/ID, or "none" to clear milestone assignment.',
|
|
567
|
+
},
|
|
568
|
+
projectMilestoneId: {
|
|
569
|
+
type: 'string',
|
|
570
|
+
description: 'Optional explicit milestone ID alias for update.',
|
|
571
|
+
},
|
|
572
|
+
subIssueOf: {
|
|
573
|
+
type: 'string',
|
|
574
|
+
description: 'For update: set this issue as sub-issue of the given issue key/ID, or "none" to clear parent.',
|
|
575
|
+
},
|
|
576
|
+
parentOf: {
|
|
577
|
+
type: 'array',
|
|
578
|
+
items: { type: 'string' },
|
|
579
|
+
description: 'For update: set listed issues as children of this issue.',
|
|
580
|
+
},
|
|
581
|
+
blockedBy: {
|
|
582
|
+
type: 'array',
|
|
583
|
+
items: { type: 'string' },
|
|
584
|
+
description: 'For update: add "blocked by" dependencies (issues that block this issue).',
|
|
585
|
+
},
|
|
586
|
+
blocking: {
|
|
587
|
+
type: 'array',
|
|
588
|
+
items: { type: 'string' },
|
|
589
|
+
description: 'For update: add "blocking" dependencies (issues this issue blocks).',
|
|
590
|
+
},
|
|
591
|
+
relatedTo: {
|
|
592
|
+
type: 'array',
|
|
593
|
+
items: { type: 'string' },
|
|
594
|
+
description: 'For update: add related issue links.',
|
|
595
|
+
},
|
|
596
|
+
duplicateOf: {
|
|
597
|
+
type: 'string',
|
|
598
|
+
description: 'For update: mark this issue as duplicate of the given issue key/ID.',
|
|
599
|
+
},
|
|
600
|
+
team: {
|
|
601
|
+
type: 'string',
|
|
602
|
+
description: 'Team key (e.g. ENG) or name (optional if default team configured)',
|
|
603
|
+
},
|
|
604
|
+
parentId: {
|
|
605
|
+
type: 'string',
|
|
606
|
+
description: 'Parent issue ID for sub-issues (for create)',
|
|
607
|
+
},
|
|
608
|
+
body: {
|
|
609
|
+
type: 'string',
|
|
610
|
+
description: 'Comment body in markdown (for comment)',
|
|
611
|
+
},
|
|
612
|
+
parentCommentId: {
|
|
613
|
+
type: 'string',
|
|
614
|
+
description: 'Parent comment ID for reply (for comment)',
|
|
615
|
+
},
|
|
616
|
+
fromRef: {
|
|
617
|
+
type: 'string',
|
|
618
|
+
description: 'Git ref to branch from (default: HEAD, for start)',
|
|
619
|
+
},
|
|
620
|
+
onBranchExists: {
|
|
621
|
+
type: 'string',
|
|
622
|
+
enum: ['switch', 'suffix'],
|
|
623
|
+
description: 'When branch exists: switch to it or create suffixed branch (for start)',
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
required: ['action'],
|
|
627
|
+
additionalProperties: false,
|
|
628
|
+
},
|
|
629
|
+
renderResult: renderMarkdownResult,
|
|
630
|
+
async execute(_toolCallId, params) {
|
|
631
|
+
const client = await createAuthenticatedClient();
|
|
632
|
+
|
|
633
|
+
switch (params.action) {
|
|
634
|
+
case 'list':
|
|
635
|
+
return executeIssueList(client, params);
|
|
636
|
+
case 'view':
|
|
637
|
+
return executeIssueView(client, params);
|
|
638
|
+
case 'create':
|
|
639
|
+
return executeIssueCreate(client, params, { resolveDefaultTeam });
|
|
640
|
+
case 'update':
|
|
641
|
+
return executeIssueUpdate(client, params);
|
|
642
|
+
case 'comment':
|
|
643
|
+
return executeIssueComment(client, params);
|
|
644
|
+
case 'start':
|
|
645
|
+
return executeIssueStart(client, params, {
|
|
646
|
+
gitExecutor: async (branchName, fromRef, onBranchExists) => {
|
|
647
|
+
return startGitBranchForIssue(pi, branchName, fromRef, onBranchExists);
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
case 'delete':
|
|
651
|
+
return executeIssueDelete(client, params);
|
|
652
|
+
default:
|
|
653
|
+
throw new Error(`Unknown action: ${params.action}`);
|
|
654
|
+
}
|
|
655
|
+
},
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
pi.registerTool({
|
|
659
|
+
name: 'linear_project',
|
|
660
|
+
label: 'Linear Project',
|
|
661
|
+
description: 'Interact with Linear projects. Actions: list',
|
|
662
|
+
parameters: {
|
|
663
|
+
type: 'object',
|
|
664
|
+
properties: {
|
|
665
|
+
action: {
|
|
666
|
+
type: 'string',
|
|
667
|
+
enum: ['list'],
|
|
668
|
+
description: 'Action to perform on project(s)',
|
|
669
|
+
},
|
|
670
|
+
},
|
|
671
|
+
required: ['action'],
|
|
672
|
+
additionalProperties: false,
|
|
673
|
+
},
|
|
674
|
+
renderResult: renderMarkdownResult,
|
|
675
|
+
async execute(_toolCallId, params) {
|
|
676
|
+
const client = await createAuthenticatedClient();
|
|
677
|
+
|
|
678
|
+
switch (params.action) {
|
|
679
|
+
case 'list':
|
|
680
|
+
return executeProjectList(client);
|
|
681
|
+
default:
|
|
682
|
+
throw new Error(`Unknown action: ${params.action}`);
|
|
683
|
+
}
|
|
684
|
+
},
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
pi.registerTool({
|
|
688
|
+
name: 'linear_team',
|
|
689
|
+
label: 'Linear Team',
|
|
690
|
+
description: 'Interact with Linear teams. Actions: list',
|
|
691
|
+
parameters: {
|
|
692
|
+
type: 'object',
|
|
693
|
+
properties: {
|
|
694
|
+
action: {
|
|
695
|
+
type: 'string',
|
|
696
|
+
enum: ['list'],
|
|
697
|
+
description: 'Action to perform on team(s)',
|
|
698
|
+
},
|
|
699
|
+
},
|
|
700
|
+
required: ['action'],
|
|
701
|
+
additionalProperties: false,
|
|
702
|
+
},
|
|
703
|
+
renderResult: renderMarkdownResult,
|
|
704
|
+
async execute(_toolCallId, params) {
|
|
705
|
+
const client = await createAuthenticatedClient();
|
|
706
|
+
|
|
707
|
+
switch (params.action) {
|
|
708
|
+
case 'list':
|
|
709
|
+
return executeTeamList(client);
|
|
710
|
+
default:
|
|
711
|
+
throw new Error(`Unknown action: ${params.action}`);
|
|
712
|
+
}
|
|
713
|
+
},
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
if (await shouldExposeMilestoneTool()) {
|
|
717
|
+
pi.registerTool({
|
|
718
|
+
name: 'linear_milestone',
|
|
719
|
+
label: 'Linear Milestone',
|
|
720
|
+
description: 'Interact with Linear project milestones. Actions: list, view, create, update, delete',
|
|
721
|
+
parameters: {
|
|
722
|
+
type: 'object',
|
|
723
|
+
properties: {
|
|
724
|
+
action: {
|
|
725
|
+
type: 'string',
|
|
726
|
+
enum: ['list', 'view', 'create', 'update', 'delete'],
|
|
727
|
+
description: 'Action to perform on milestone(s)',
|
|
728
|
+
},
|
|
729
|
+
milestone: {
|
|
730
|
+
type: 'string',
|
|
731
|
+
description: 'Milestone ID (for view, update, delete)',
|
|
732
|
+
},
|
|
733
|
+
project: {
|
|
734
|
+
type: 'string',
|
|
735
|
+
description: 'Project name or ID (for list, create)',
|
|
736
|
+
},
|
|
737
|
+
name: {
|
|
738
|
+
type: 'string',
|
|
739
|
+
description: 'Milestone name (required for create, optional for update)',
|
|
740
|
+
},
|
|
741
|
+
description: {
|
|
742
|
+
type: 'string',
|
|
743
|
+
description: 'Milestone description in markdown',
|
|
744
|
+
},
|
|
745
|
+
targetDate: {
|
|
746
|
+
type: 'string',
|
|
747
|
+
description: 'Target completion date (ISO 8601 date)',
|
|
748
|
+
},
|
|
749
|
+
},
|
|
750
|
+
required: ['action'],
|
|
751
|
+
additionalProperties: false,
|
|
752
|
+
},
|
|
753
|
+
renderResult: renderMarkdownResult,
|
|
754
|
+
async execute(_toolCallId, params) {
|
|
755
|
+
const client = await createAuthenticatedClient();
|
|
756
|
+
|
|
757
|
+
try {
|
|
758
|
+
switch (params.action) {
|
|
759
|
+
case 'list':
|
|
760
|
+
return await executeMilestoneList(client, params);
|
|
761
|
+
case 'view':
|
|
762
|
+
return await executeMilestoneView(client, params);
|
|
763
|
+
case 'create':
|
|
764
|
+
return await executeMilestoneCreate(client, params);
|
|
765
|
+
case 'update':
|
|
766
|
+
return await executeMilestoneUpdate(client, params);
|
|
767
|
+
case 'delete':
|
|
768
|
+
return await executeMilestoneDelete(client, params);
|
|
769
|
+
default:
|
|
770
|
+
throw new Error(`Unknown action: ${params.action}`);
|
|
771
|
+
}
|
|
772
|
+
} catch (error) {
|
|
773
|
+
throw withMilestoneScopeHint(error);
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
export default async function piLinearToolsExtension(pi) {
|
|
781
|
+
pi.registerCommand('linear-tools-config', {
|
|
782
|
+
description: 'Configure pi-linear-tools settings (API key and default team mappings)',
|
|
783
|
+
handler: async (argsText, ctx) => {
|
|
784
|
+
const args = parseArgs(argsText);
|
|
785
|
+
const apiKey = readFlag(args, '--api-key');
|
|
786
|
+
const defaultTeam = readFlag(args, '--default-team');
|
|
787
|
+
const projectTeam = readFlag(args, '--team');
|
|
788
|
+
const projectName = readFlag(args, '--project');
|
|
789
|
+
|
|
790
|
+
if (apiKey) {
|
|
791
|
+
const settings = await loadSettings();
|
|
792
|
+
const previousAuthMethod = settings.authMethod || 'api-key';
|
|
793
|
+
settings.apiKey = apiKey;
|
|
794
|
+
settings.authMethod = 'api-key';
|
|
795
|
+
await saveSettings(settings);
|
|
796
|
+
cachedApiKey = null;
|
|
797
|
+
if (ctx?.hasUI) {
|
|
798
|
+
ctx.ui.notify('LINEAR_API_KEY saved to settings', 'info');
|
|
799
|
+
if (previousAuthMethod !== settings.authMethod) {
|
|
800
|
+
ctx.ui.notify(
|
|
801
|
+
'Authentication method changed. Please restart pi to refresh and make the correct tools available.',
|
|
802
|
+
'warning'
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (defaultTeam) {
|
|
810
|
+
const settings = await loadSettings();
|
|
811
|
+
settings.defaultTeam = defaultTeam;
|
|
812
|
+
await saveSettings(settings);
|
|
813
|
+
if (ctx?.hasUI) {
|
|
814
|
+
ctx.ui.notify(`Default team set to: ${defaultTeam}`, 'info');
|
|
815
|
+
}
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (projectTeam && projectName) {
|
|
820
|
+
const settings = await loadSettings();
|
|
821
|
+
|
|
822
|
+
let projectId = projectName;
|
|
823
|
+
try {
|
|
824
|
+
const client = await createAuthenticatedClient();
|
|
825
|
+
const resolved = await resolveProjectRef(client, projectName);
|
|
826
|
+
projectId = resolved.id;
|
|
827
|
+
} catch {
|
|
828
|
+
// keep provided value as project ID/name key
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (!settings.projects[projectId]) {
|
|
832
|
+
settings.projects[projectId] = {
|
|
833
|
+
scope: {
|
|
834
|
+
team: null,
|
|
835
|
+
},
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
if (!settings.projects[projectId].scope) {
|
|
840
|
+
settings.projects[projectId].scope = { team: null };
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
settings.projects[projectId].scope.team = projectTeam;
|
|
844
|
+
await saveSettings(settings);
|
|
845
|
+
|
|
846
|
+
if (ctx?.hasUI) {
|
|
847
|
+
ctx.ui.notify(`Team for project "${projectName}" set to: ${projectTeam}`, 'info');
|
|
848
|
+
}
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if (!apiKey && !defaultTeam && !projectTeam && !projectName && ctx?.hasUI && ctx?.ui) {
|
|
853
|
+
await runInteractiveConfigFlow(ctx, pi);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const settings = await loadSettings();
|
|
858
|
+
const hasKey = !!(settings.apiKey || settings.linearApiKey || process.env.LINEAR_API_KEY);
|
|
859
|
+
const keySource = process.env.LINEAR_API_KEY ? 'environment' : (settings.apiKey || settings.linearApiKey ? 'settings' : 'not set');
|
|
860
|
+
|
|
861
|
+
pi.sendMessage({
|
|
862
|
+
customType: 'pi-linear-tools',
|
|
863
|
+
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.`,
|
|
864
|
+
display: true,
|
|
865
|
+
});
|
|
866
|
+
},
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
pi.registerCommand('linear-tools-reload', {
|
|
870
|
+
description: 'Reload extension runtime (extensions, skills, prompts, themes)',
|
|
871
|
+
handler: async (_args, ctx) => {
|
|
872
|
+
if (ctx?.hasUI) {
|
|
873
|
+
ctx.ui.notify('Reloading runtime...', 'info');
|
|
874
|
+
}
|
|
875
|
+
await ctx.reload();
|
|
876
|
+
},
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
pi.registerCommand('linear-tools-help', {
|
|
880
|
+
description: 'Show pi-linear-tools commands and tools',
|
|
881
|
+
handler: async (_args, ctx) => {
|
|
882
|
+
if (ctx?.hasUI) {
|
|
883
|
+
ctx.ui.notify('pi-linear-tools extension commands available', 'info');
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const showMilestoneTool = await shouldExposeMilestoneTool();
|
|
887
|
+
const toolLines = [
|
|
888
|
+
'LLM-callable tools:',
|
|
889
|
+
' linear_issue (list/view/create/update/comment/start/delete)',
|
|
890
|
+
' linear_project (list)',
|
|
891
|
+
' linear_team (list)',
|
|
892
|
+
];
|
|
893
|
+
|
|
894
|
+
if (showMilestoneTool) {
|
|
895
|
+
toolLines.push(' linear_milestone (list/view/create/update/delete)');
|
|
896
|
+
} else {
|
|
897
|
+
toolLines.push(' linear_milestone hidden: requires API key auth');
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
pi.sendMessage({
|
|
901
|
+
customType: 'pi-linear-tools',
|
|
902
|
+
content: [
|
|
903
|
+
'Commands:',
|
|
904
|
+
' /linear-tools-config --api-key <key>',
|
|
905
|
+
' /linear-tools-config --default-team <team-key>',
|
|
906
|
+
' /linear-tools-config --team <team-key> --project <project-name-or-id>',
|
|
907
|
+
' /linear-tools-help',
|
|
908
|
+
' /linear-tools-reload',
|
|
909
|
+
'',
|
|
910
|
+
...toolLines,
|
|
911
|
+
].join('\n'),
|
|
912
|
+
display: true,
|
|
913
|
+
});
|
|
914
|
+
},
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
await registerLinearTools(pi);
|
|
918
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fink-andreas/pi-linear-tools",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Pi extension with Linear SDK tools and configuration commands",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"scripts": {
|
|
29
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",
|
|
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 && node tests/test-branch-param.js",
|
|
31
31
|
"dev:sync-local-extension": "node scripts/dev-sync-local-extension.mjs",
|
|
32
32
|
"release:check": "npm test && npm pack --dry-run"
|
|
33
33
|
},
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
],
|
|
41
41
|
"pi": {
|
|
42
42
|
"extensions": [
|
|
43
|
-
"./
|
|
43
|
+
"./index.js"
|
|
44
44
|
]
|
|
45
45
|
},
|
|
46
46
|
"license": "MIT",
|
package/src/auth/token-store.js
CHANGED
|
@@ -73,7 +73,7 @@ async function writeTokensToFile(tokenData) {
|
|
|
73
73
|
await mkdir(parentDir, { recursive: true, mode: 0o700 });
|
|
74
74
|
await writeFile(tokenFilePath, tokenData, { encoding: 'utf-8', mode: 0o600 });
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
debug('Stored OAuth tokens in fallback file storage because keychain is unavailable', {
|
|
77
77
|
path: tokenFilePath,
|
|
78
78
|
});
|
|
79
79
|
}
|
|
@@ -94,7 +94,7 @@ async function isKeytarAvailable() {
|
|
|
94
94
|
debug('keytar module loaded successfully');
|
|
95
95
|
return true;
|
|
96
96
|
} catch (error) {
|
|
97
|
-
|
|
97
|
+
debug('keytar module not available, using fallback storage', { error: error.message });
|
|
98
98
|
keytarModule = false;
|
|
99
99
|
return false;
|
|
100
100
|
}
|
|
@@ -153,7 +153,7 @@ export async function storeTokens(tokens) {
|
|
|
153
153
|
// Clean up fallback file if keychain works again
|
|
154
154
|
await unlink(getTokenFilePath()).catch(() => {});
|
|
155
155
|
} catch (error) {
|
|
156
|
-
|
|
156
|
+
debug('Failed to store tokens in keychain, falling back to file storage', {
|
|
157
157
|
error: error.message,
|
|
158
158
|
});
|
|
159
159
|
await writeTokensToFile(tokenData);
|
|
@@ -235,7 +235,7 @@ export async function getTokens() {
|
|
|
235
235
|
|
|
236
236
|
debug('No tokens found in keychain');
|
|
237
237
|
} catch (error) {
|
|
238
|
-
|
|
238
|
+
debug('Failed to retrieve tokens from keychain, trying fallback storage', {
|
|
239
239
|
error: error.message,
|
|
240
240
|
});
|
|
241
241
|
}
|
|
@@ -277,7 +277,7 @@ export async function clearTokens() {
|
|
|
277
277
|
const message = String(error?.message || 'Unknown error');
|
|
278
278
|
// Keychain providers (e.g. DBus Secret Service) may be unavailable at runtime.
|
|
279
279
|
// Clearing is best-effort and we still clear fallback file/in-memory tokens below.
|
|
280
|
-
|
|
280
|
+
debug('Skipping keychain token clear; keychain backend unavailable', {
|
|
281
281
|
error: message,
|
|
282
282
|
});
|
|
283
283
|
if (/org\.freedesktop\.secrets/i.test(message)) {
|
package/src/cli.js
CHANGED
|
@@ -143,15 +143,17 @@ Usage:
|
|
|
143
143
|
pi-linear-tools <command> [options]
|
|
144
144
|
|
|
145
145
|
Commands:
|
|
146
|
+
issue <action> [options] Manage issues
|
|
147
|
+
project <action> [options] Manage projects
|
|
148
|
+
team <action> [options] Manage teams
|
|
149
|
+
milestone <action> [options] Manage milestones
|
|
150
|
+
|
|
151
|
+
Other commands:
|
|
146
152
|
help Show this help message
|
|
147
153
|
auth <action> Manage authentication (OAuth 2.0)
|
|
148
154
|
config Show current configuration
|
|
149
155
|
config --api-key <key> Set Linear API key (legacy)
|
|
150
156
|
config --default-team <key> Set default team
|
|
151
|
-
issue <action> [options] Manage issues
|
|
152
|
-
project <action> [options] Manage projects
|
|
153
|
-
team <action> [options] Manage teams
|
|
154
|
-
milestone <action> [options] Manage milestones
|
|
155
157
|
|
|
156
158
|
Auth Actions:
|
|
157
159
|
login Authenticate with Linear via OAuth 2.0
|
|
@@ -165,7 +167,7 @@ Issue Actions:
|
|
|
165
167
|
update <issue> [--title X] [--description X] [--state X] [--priority 0-4]
|
|
166
168
|
[--assignee me|ID] [--milestone X] [--sub-issue-of X]
|
|
167
169
|
comment <issue> --body X
|
|
168
|
-
start <issue> [--
|
|
170
|
+
start <issue> [--from-ref X] [--on-branch-exists switch|suffix]
|
|
169
171
|
delete <issue>
|
|
170
172
|
|
|
171
173
|
Project Actions:
|
|
@@ -201,10 +203,10 @@ Examples:
|
|
|
201
203
|
pi-linear-tools config --api-key lin_xxx
|
|
202
204
|
|
|
203
205
|
Authentication:
|
|
204
|
-
|
|
205
|
-
Run 'pi-linear-tools
|
|
206
|
-
For CI/headless environments, set environment
|
|
207
|
-
|
|
206
|
+
API key is the recommended authentication method (supports milestones).
|
|
207
|
+
Run 'pi-linear-tools config --api-key <key>' to authenticate.
|
|
208
|
+
For CI/headless environments, set the LINEAR_API_KEY environment variable.
|
|
209
|
+
OAuth 2.0 is also available via 'pi-linear-tools auth login'.
|
|
208
210
|
`);
|
|
209
211
|
}
|
|
210
212
|
|
|
@@ -258,7 +260,6 @@ Comment Options:
|
|
|
258
260
|
|
|
259
261
|
Start Options:
|
|
260
262
|
<issue> Issue key or ID
|
|
261
|
-
--branch X Custom branch name (default: issue's branch name)
|
|
262
263
|
--from-ref X Git ref to branch from (default: HEAD)
|
|
263
264
|
--on-branch-exists X "switch" or "suffix" (default: switch)
|
|
264
265
|
|
|
@@ -642,7 +643,6 @@ async function handleIssueStart(args) {
|
|
|
642
643
|
|
|
643
644
|
const params = {
|
|
644
645
|
issue: positional[0],
|
|
645
|
-
branch: readFlag(args, '--branch'),
|
|
646
646
|
fromRef: readFlag(args, '--from-ref'),
|
|
647
647
|
onBranchExists: readFlag(args, '--on-branch-exists'),
|
|
648
648
|
};
|
package/src/handlers.js
CHANGED
|
@@ -447,28 +447,31 @@ export async function executeIssueStart(client, params, options = {}) {
|
|
|
447
447
|
const issue = ensureNonEmpty(params.issue, 'issue');
|
|
448
448
|
const prepared = await prepareIssueStart(client, issue);
|
|
449
449
|
|
|
450
|
-
|
|
451
|
-
|
|
450
|
+
// Always use Linear's suggested branchName - it cannot be changed via API
|
|
451
|
+
// and using a custom branch would break Linear's branch-to-issue linking
|
|
452
|
+
const branchName = prepared.branchName;
|
|
453
|
+
if (!branchName) {
|
|
452
454
|
throw new Error(
|
|
453
|
-
`No branch name
|
|
455
|
+
`No branch name available for issue ${prepared.issue.identifier}. The issue may not have a team assigned.`
|
|
454
456
|
);
|
|
455
457
|
}
|
|
456
458
|
|
|
457
459
|
let gitResult;
|
|
458
460
|
if (gitExecutor) {
|
|
459
461
|
// Use provided git executor (e.g., pi.exec)
|
|
460
|
-
gitResult = await gitExecutor(
|
|
462
|
+
gitResult = await gitExecutor(branchName, params.fromRef || 'HEAD', params.onBranchExists || 'switch');
|
|
461
463
|
} else {
|
|
462
464
|
// Use built-in child_process git operations
|
|
463
|
-
gitResult = await startGitBranch(
|
|
465
|
+
gitResult = await startGitBranch(branchName, params.fromRef || 'HEAD', params.onBranchExists || 'switch');
|
|
464
466
|
}
|
|
465
467
|
|
|
466
468
|
const updatedIssue = await setIssueState(client, prepared.issue.id, prepared.startedState.id);
|
|
467
469
|
|
|
470
|
+
const identifier = updatedIssue.identifier || prepared.issue.identifier;
|
|
468
471
|
const compactTitle = String(updatedIssue.title || prepared.issue?.title || '').trim().toLowerCase();
|
|
469
472
|
const summary = compactTitle
|
|
470
|
-
? `Started issue ${
|
|
471
|
-
: `Started issue ${
|
|
473
|
+
? `Started issue ${identifier} (${compactTitle})`
|
|
474
|
+
: `Started issue ${identifier}`;
|
|
472
475
|
|
|
473
476
|
return toTextResult(summary, {
|
|
474
477
|
issueId: updatedIssue.id,
|
|
@@ -731,11 +734,11 @@ export async function executeMilestoneCreate(client, params) {
|
|
|
731
734
|
export async function executeMilestoneUpdate(client, params) {
|
|
732
735
|
const milestoneId = ensureNonEmpty(params.milestone, 'milestone');
|
|
733
736
|
|
|
737
|
+
// Note: status is not included as it's a computed/read-only field in Linear's API
|
|
734
738
|
const result = await updateProjectMilestone(client, milestoneId, {
|
|
735
739
|
name: params.name,
|
|
736
740
|
description: params.description,
|
|
737
741
|
targetDate: params.targetDate,
|
|
738
|
-
status: params.status,
|
|
739
742
|
});
|
|
740
743
|
|
|
741
744
|
const friendlyChanges = result.changed;
|
package/src/linear.js
CHANGED
|
@@ -1136,14 +1136,9 @@ export async function updateProjectMilestone(client, milestoneId, patch = {}) {
|
|
|
1136
1136
|
updateInput.targetDate = patch.targetDate;
|
|
1137
1137
|
}
|
|
1138
1138
|
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
if (!validStatuses.includes(status)) {
|
|
1143
|
-
throw new Error(`Invalid status: ${status}. Valid values: ${validStatuses.join(', ')}`);
|
|
1144
|
-
}
|
|
1145
|
-
updateInput.status = status;
|
|
1146
|
-
}
|
|
1139
|
+
// Note: status is a computed/read-only field in Linear's API (ProjectMilestoneStatus enum)
|
|
1140
|
+
// It cannot be set via ProjectMilestoneUpdateInput. The status values (done, next, overdue, unstarted)
|
|
1141
|
+
// are automatically determined by Linear based on milestone progress and dates.
|
|
1147
1142
|
|
|
1148
1143
|
if (Object.keys(updateInput).length === 0) {
|
|
1149
1144
|
throw new Error('No update fields provided');
|