@alasano/pi-linear 0.3.0 → 0.4.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.
- package/README.md +19 -12
- package/extensions/client.ts +26 -5
- package/extensions/index.ts +59 -4
- package/extensions/tools/documents.ts +40 -8
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -34,27 +34,34 @@ pi install npm:@alasano/pi-linear
|
|
|
34
34
|
|
|
35
35
|
## Authentication
|
|
36
36
|
|
|
37
|
-
The extension
|
|
37
|
+
The extension can use either stored workspaces or the `LINEAR_API_KEY` environment variable. Stored workspaces are preferred by default so multi-account switching keeps working even if `LINEAR_API_KEY` is set globally.
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
Use the `/linear-auth` command to manage workspaces and choose the auth preference:
|
|
40
40
|
|
|
41
|
-
| Command
|
|
42
|
-
|
|
|
43
|
-
| `/linear-auth add <name>`
|
|
44
|
-
| `/linear-auth remove <name>`
|
|
45
|
-
| `/linear-auth switch <name>`
|
|
46
|
-
| `/linear-auth
|
|
47
|
-
| `/linear-auth`
|
|
41
|
+
| Command | Description |
|
|
42
|
+
| ------------------------------------ | -------------------------------------------------------------------- |
|
|
43
|
+
| `/linear-auth add <name>` | Add a workspace with an API key (prompts for key) |
|
|
44
|
+
| `/linear-auth remove <name>` | Remove a workspace (shows selector if name omitted) |
|
|
45
|
+
| `/linear-auth switch <name>` | Switch active workspace and prefer stored workspaces |
|
|
46
|
+
| `/linear-auth prefer workspace\|env` | Choose whether stored workspaces or `LINEAR_API_KEY` are tried first |
|
|
47
|
+
| `/linear-auth status` | Show auth preference, active source, and configured workspaces |
|
|
48
|
+
| `/linear-auth` | Same as status |
|
|
48
49
|
|
|
49
50
|
When you add your first workspace, it becomes active automatically. Adding a second workspace prompts you to switch. After a `/reload` with two or more workspaces configured, the agent gets a `linear_switch_workspace` tool and can switch between them when asked.
|
|
50
51
|
|
|
51
|
-
|
|
52
|
+
Default auth resolution order (`/linear-auth prefer workspace`):
|
|
52
53
|
|
|
53
54
|
1. Active stored workspace (if any workspaces are configured)
|
|
54
|
-
2. `LINEAR_API_KEY` environment variable (fallback when no
|
|
55
|
+
2. `LINEAR_API_KEY` environment variable (fallback when no workspace is active)
|
|
55
56
|
3. Interactive prompt (asks you to set up a key)
|
|
56
57
|
|
|
57
|
-
|
|
58
|
+
Environment auth resolution order (`/linear-auth prefer env`):
|
|
59
|
+
|
|
60
|
+
1. `LINEAR_API_KEY` environment variable
|
|
61
|
+
2. Active stored workspace (fallback when `LINEAR_API_KEY` is not set)
|
|
62
|
+
3. Interactive prompt (asks you to set up a key)
|
|
63
|
+
|
|
64
|
+
Credentials and auth preference are stored at `~/.pi/agent/extensions/linear/credentials.json`.
|
|
58
65
|
|
|
59
66
|
## Tool settings
|
|
60
67
|
|
package/extensions/client.ts
CHANGED
|
@@ -9,13 +9,20 @@ import { ISSUE_SELECTION } from './selections';
|
|
|
9
9
|
const LINEAR_GRAPHQL_ENDPOINT = 'https://api.linear.app/graphql';
|
|
10
10
|
const IDENTIFIER_PATTERN = /^([A-Z][A-Z0-9]*)-(\d+)$/i;
|
|
11
11
|
|
|
12
|
+
export type AuthPreference = 'workspace' | 'env';
|
|
13
|
+
|
|
12
14
|
export type WorkspaceCredentials = {
|
|
13
15
|
activeWorkspace: string | null;
|
|
16
|
+
authPreference: AuthPreference;
|
|
14
17
|
workspaces: Record<string, { apiKey: string }>;
|
|
15
18
|
};
|
|
16
19
|
|
|
20
|
+
function normalizeAuthPreference(value: unknown): AuthPreference {
|
|
21
|
+
return value === 'env' ? 'env' : 'workspace';
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
function emptyCredentials(): WorkspaceCredentials {
|
|
18
|
-
return { activeWorkspace: null, workspaces: {} };
|
|
25
|
+
return { activeWorkspace: null, authPreference: 'workspace', workspaces: {} };
|
|
19
26
|
}
|
|
20
27
|
|
|
21
28
|
export function getCredentialFilePath() {
|
|
@@ -38,6 +45,7 @@ export async function readCredentials(): Promise<WorkspaceCredentials> {
|
|
|
38
45
|
}
|
|
39
46
|
const activeWorkspace =
|
|
40
47
|
typeof parsed.activeWorkspace === 'string' ? parsed.activeWorkspace : null;
|
|
48
|
+
const authPreference = normalizeAuthPreference(parsed.authPreference);
|
|
41
49
|
const workspaces: Record<string, { apiKey: string }> = {};
|
|
42
50
|
for (const [name, entry] of Object.entries(parsed.workspaces)) {
|
|
43
51
|
if (
|
|
@@ -49,7 +57,7 @@ export async function readCredentials(): Promise<WorkspaceCredentials> {
|
|
|
49
57
|
workspaces[name] = { apiKey: (entry as { apiKey: string }).apiKey };
|
|
50
58
|
}
|
|
51
59
|
}
|
|
52
|
-
return { activeWorkspace, workspaces };
|
|
60
|
+
return { activeWorkspace, authPreference, workspaces };
|
|
53
61
|
} catch {
|
|
54
62
|
return emptyCredentials();
|
|
55
63
|
}
|
|
@@ -96,6 +104,14 @@ export async function switchWorkspace(name: string): Promise<WorkspaceCredential
|
|
|
96
104
|
throw new Error(`Workspace "${name}" does not exist.`);
|
|
97
105
|
}
|
|
98
106
|
creds.activeWorkspace = name;
|
|
107
|
+
creds.authPreference = 'workspace';
|
|
108
|
+
await writeCredentials(creds);
|
|
109
|
+
return creds;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function setAuthPreference(preference: AuthPreference): Promise<WorkspaceCredentials> {
|
|
113
|
+
const creds = await readCredentials();
|
|
114
|
+
creds.authPreference = preference;
|
|
99
115
|
await writeCredentials(creds);
|
|
100
116
|
return creds;
|
|
101
117
|
}
|
|
@@ -114,10 +130,15 @@ export async function resolveApiKey(
|
|
|
114
130
|
): Promise<{ apiKey?: string; source: 'env' | 'workspace' | 'none' }> {
|
|
115
131
|
const creds = await readCredentials();
|
|
116
132
|
const workspaceKey = getActiveApiKey(creds);
|
|
117
|
-
if (workspaceKey) return { apiKey: workspaceKey, source: 'workspace' };
|
|
118
|
-
|
|
119
133
|
const envApiKey = asString(process.env.LINEAR_API_KEY);
|
|
120
|
-
|
|
134
|
+
|
|
135
|
+
if (creds.authPreference === 'env') {
|
|
136
|
+
if (envApiKey) return { apiKey: envApiKey, source: 'env' };
|
|
137
|
+
if (workspaceKey) return { apiKey: workspaceKey, source: 'workspace' };
|
|
138
|
+
} else {
|
|
139
|
+
if (workspaceKey) return { apiKey: workspaceKey, source: 'workspace' };
|
|
140
|
+
if (envApiKey) return { apiKey: envApiKey, source: 'env' };
|
|
141
|
+
}
|
|
121
142
|
|
|
122
143
|
const promptIfMissing = options?.promptIfMissing ?? true;
|
|
123
144
|
if (promptIfMissing && ctx.hasUI) {
|
package/extensions/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
addWorkspace,
|
|
5
5
|
removeWorkspace,
|
|
6
6
|
switchWorkspace,
|
|
7
|
+
setAuthPreference,
|
|
7
8
|
listWorkspaceNames,
|
|
8
9
|
getActiveWorkspaceName,
|
|
9
10
|
resolveApiKey,
|
|
@@ -27,7 +28,8 @@ import { registerLinearSettings } from './settings';
|
|
|
27
28
|
|
|
28
29
|
export default async function linearExtension(pi: ExtensionAPI) {
|
|
29
30
|
pi.registerCommand('linear-auth', {
|
|
30
|
-
description:
|
|
31
|
+
description:
|
|
32
|
+
'Manage Linear workspace auth (usage: /linear-auth [add|remove|switch|prefer|status])',
|
|
31
33
|
handler: async (args, ctx) => {
|
|
32
34
|
const parts = args.trim().split(/\s+/);
|
|
33
35
|
const subcommand = parts[0]?.toLowerCase() || '';
|
|
@@ -145,7 +147,37 @@ export default async function linearExtension(pi: ExtensionAPI) {
|
|
|
145
147
|
}
|
|
146
148
|
|
|
147
149
|
await switchWorkspace(workspaceName);
|
|
148
|
-
ctx.ui.notify(`Active workspace: ${workspaceName}`, 'info');
|
|
150
|
+
ctx.ui.notify(`Active workspace: ${workspaceName}\nAuth preference: workspace`, 'info');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
case 'prefer': {
|
|
155
|
+
const preference = name.toLowerCase();
|
|
156
|
+
if (preference !== 'workspace' && preference !== 'env') {
|
|
157
|
+
ctx.ui.notify('Usage: /linear-auth prefer [workspace|env]', 'warning');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const updated = await setAuthPreference(preference);
|
|
162
|
+
const envIsSet = Boolean(asString(process.env.LINEAR_API_KEY));
|
|
163
|
+
const active = getActiveWorkspaceName(updated);
|
|
164
|
+
const lines = [`Auth preference: ${preference}`];
|
|
165
|
+
|
|
166
|
+
if (preference === 'env') {
|
|
167
|
+
if (envIsSet) {
|
|
168
|
+
lines.push('LINEAR_API_KEY will be used before stored workspaces.');
|
|
169
|
+
} else if (active) {
|
|
170
|
+
lines.push('LINEAR_API_KEY is not set; falling back to the active workspace.');
|
|
171
|
+
} else {
|
|
172
|
+
lines.push('LINEAR_API_KEY is not set and no active workspace is configured.');
|
|
173
|
+
}
|
|
174
|
+
} else if (active) {
|
|
175
|
+
lines.push(`Workspace "${active}" will be used before LINEAR_API_KEY.`);
|
|
176
|
+
} else {
|
|
177
|
+
lines.push('No active workspace configured; falling back to LINEAR_API_KEY if set.');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
ctx.ui.notify(lines.join('\n'), preference === 'env' && !envIsSet ? 'warning' : 'info');
|
|
149
181
|
return;
|
|
150
182
|
}
|
|
151
183
|
|
|
@@ -157,6 +189,7 @@ export default async function linearExtension(pi: ExtensionAPI) {
|
|
|
157
189
|
});
|
|
158
190
|
const names = listWorkspaceNames(creds);
|
|
159
191
|
const active = getActiveWorkspaceName(creds);
|
|
192
|
+
const envIsSet = Boolean(asString(process.env.LINEAR_API_KEY));
|
|
160
193
|
|
|
161
194
|
let sourceLabel: string;
|
|
162
195
|
if (source === 'workspace') {
|
|
@@ -167,7 +200,7 @@ export default async function linearExtension(pi: ExtensionAPI) {
|
|
|
167
200
|
sourceLabel = 'none';
|
|
168
201
|
}
|
|
169
202
|
|
|
170
|
-
const lines = [`Auth source: ${sourceLabel}`];
|
|
203
|
+
const lines = [`Auth preference: ${creds.authPreference}`, `Auth source: ${sourceLabel}`];
|
|
171
204
|
if (names.length > 0) {
|
|
172
205
|
lines.push(
|
|
173
206
|
`Workspaces: ${names.map((n) => (n === active ? `${n} (active)` : n)).join(', ')}`,
|
|
@@ -176,12 +209,34 @@ export default async function linearExtension(pi: ExtensionAPI) {
|
|
|
176
209
|
lines.push('No workspaces configured');
|
|
177
210
|
}
|
|
178
211
|
|
|
212
|
+
if (creds.authPreference === 'workspace' && envIsSet) {
|
|
213
|
+
if (source === 'workspace') {
|
|
214
|
+
lines.push(
|
|
215
|
+
'LINEAR_API_KEY is set but not active. Run /linear-auth prefer env to use it.',
|
|
216
|
+
);
|
|
217
|
+
} else if (source === 'env') {
|
|
218
|
+
lines.push(
|
|
219
|
+
'No active workspace is configured, so LINEAR_API_KEY is being used as fallback.',
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
} else if (creds.authPreference === 'env' && names.length > 0) {
|
|
223
|
+
if (source === 'env') {
|
|
224
|
+
lines.push(
|
|
225
|
+
'Stored workspaces are available but not active. Run /linear-auth prefer workspace or /linear-auth switch <name> to use one.',
|
|
226
|
+
);
|
|
227
|
+
} else if (source === 'workspace') {
|
|
228
|
+
lines.push(
|
|
229
|
+
'LINEAR_API_KEY is not set, so the active workspace is being used as fallback.',
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
179
234
|
ctx.ui.notify(lines.join('\n'), source === 'none' ? 'warning' : 'info');
|
|
180
235
|
return;
|
|
181
236
|
}
|
|
182
237
|
|
|
183
238
|
default: {
|
|
184
|
-
ctx.ui.notify('Usage: /linear-auth [add|remove|switch|status]', 'warning');
|
|
239
|
+
ctx.ui.notify('Usage: /linear-auth [add|remove|switch|prefer|status]', 'warning');
|
|
185
240
|
}
|
|
186
241
|
}
|
|
187
242
|
},
|
|
@@ -23,6 +23,36 @@ import {
|
|
|
23
23
|
renderLinearUpdateDocumentCall,
|
|
24
24
|
} from '../renderers/documents';
|
|
25
25
|
|
|
26
|
+
const DOCUMENT_RELATED_CONTEXT_FIELDS = [
|
|
27
|
+
'cycleId',
|
|
28
|
+
'initiativeId',
|
|
29
|
+
'issueId',
|
|
30
|
+
'projectId',
|
|
31
|
+
'releaseId',
|
|
32
|
+
'resourceFolderId',
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
type DocumentRelatedContextField = (typeof DOCUMENT_RELATED_CONTEXT_FIELDS)[number];
|
|
36
|
+
|
|
37
|
+
function hasDocumentRelatedContext(
|
|
38
|
+
params: Partial<Record<DocumentRelatedContextField, unknown>>,
|
|
39
|
+
rawInput: JsonObject,
|
|
40
|
+
): boolean {
|
|
41
|
+
return DOCUMENT_RELATED_CONTEXT_FIELDS.some((field) =>
|
|
42
|
+
Boolean(asString(params[field]) || asString(rawInput[field])),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function documentInputBase(rawInput: JsonObject, omitTeamId: boolean): JsonObject {
|
|
47
|
+
if (!omitTeamId) return rawInput;
|
|
48
|
+
|
|
49
|
+
// Linear rejects document inputs with multiple parent/context IDs (for example
|
|
50
|
+
// issueId + teamId). Treat teamId/teamKey as the document context only when no
|
|
51
|
+
// more specific relation is provided; issue/project/etc. imply their context.
|
|
52
|
+
const { teamId: _teamId, ...inputWithoutTeamId } = rawInput;
|
|
53
|
+
return inputWithoutTeamId;
|
|
54
|
+
}
|
|
55
|
+
|
|
26
56
|
export function documentTools() {
|
|
27
57
|
return [
|
|
28
58
|
defineTool({
|
|
@@ -124,7 +154,7 @@ export function documentTools() {
|
|
|
124
154
|
name: 'linear_create_document',
|
|
125
155
|
label: 'Linear Create Document',
|
|
126
156
|
description:
|
|
127
|
-
'Create a document. Supports top-level DocumentCreateInput fields and raw input.',
|
|
157
|
+
'Create a document. Supports top-level DocumentCreateInput fields and raw input. Use issueId/projectId/etc. for related documents; teamId/teamKey are only for team-scoped documents.',
|
|
128
158
|
parameters: Type.Object({
|
|
129
159
|
color: Type.Optional(Type.String()),
|
|
130
160
|
content: Type.Optional(Type.String()),
|
|
@@ -147,9 +177,10 @@ export function documentTools() {
|
|
|
147
177
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
148
178
|
return withLinearAuth(ctx, signal, async (apiKey) => {
|
|
149
179
|
const rawInput = asObject(params.input) || {};
|
|
150
|
-
const
|
|
180
|
+
const hasRelatedContext = hasDocumentRelatedContext(params, rawInput);
|
|
181
|
+
const rawInputTeamId = hasRelatedContext ? undefined : asString(rawInput.teamId);
|
|
151
182
|
const teamId =
|
|
152
|
-
params.teamId || params.teamKey || rawInputTeamId
|
|
183
|
+
!hasRelatedContext && (params.teamId || params.teamKey || rawInputTeamId)
|
|
153
184
|
? await resolveTeamId(
|
|
154
185
|
apiKey,
|
|
155
186
|
{
|
|
@@ -161,7 +192,7 @@ export function documentTools() {
|
|
|
161
192
|
: undefined;
|
|
162
193
|
|
|
163
194
|
const input = {
|
|
164
|
-
...rawInput,
|
|
195
|
+
...documentInputBase(rawInput, hasRelatedContext),
|
|
165
196
|
...compactObject({
|
|
166
197
|
color: params.color,
|
|
167
198
|
content: params.content,
|
|
@@ -218,7 +249,7 @@ export function documentTools() {
|
|
|
218
249
|
name: 'linear_update_document',
|
|
219
250
|
label: 'Linear Update Document',
|
|
220
251
|
description:
|
|
221
|
-
'Update a document by id. Supports top-level DocumentUpdateInput fields and raw input.',
|
|
252
|
+
'Update a document by id. Supports top-level DocumentUpdateInput fields and raw input. Use issueId/projectId/etc. for related documents; teamId/teamKey are only for team-scoped documents.',
|
|
222
253
|
parameters: Type.Object({
|
|
223
254
|
documentId: Type.String(),
|
|
224
255
|
color: Type.Optional(Type.String()),
|
|
@@ -243,9 +274,10 @@ export function documentTools() {
|
|
|
243
274
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
244
275
|
return withLinearAuth(ctx, signal, async (apiKey) => {
|
|
245
276
|
const rawInput = asObject(params.input) || {};
|
|
246
|
-
const
|
|
277
|
+
const hasRelatedContext = hasDocumentRelatedContext(params, rawInput);
|
|
278
|
+
const rawInputTeamId = hasRelatedContext ? undefined : asString(rawInput.teamId);
|
|
247
279
|
const teamId =
|
|
248
|
-
params.teamId || params.teamKey || rawInputTeamId
|
|
280
|
+
!hasRelatedContext && (params.teamId || params.teamKey || rawInputTeamId)
|
|
249
281
|
? await resolveTeamId(
|
|
250
282
|
apiKey,
|
|
251
283
|
{
|
|
@@ -257,7 +289,7 @@ export function documentTools() {
|
|
|
257
289
|
: undefined;
|
|
258
290
|
|
|
259
291
|
const input = {
|
|
260
|
-
...rawInput,
|
|
292
|
+
...documentInputBase(rawInput, hasRelatedContext),
|
|
261
293
|
...compactObject({
|
|
262
294
|
color: params.color,
|
|
263
295
|
content: params.content,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alasano/pi-linear",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Linear integration for pi with 55+ tools, multi-workspace auth, and per-tool settings",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"type": "module",
|
|
15
15
|
"scripts": {
|
|
16
|
+
"test": "vitest run",
|
|
16
17
|
"typecheck": "tsc --noEmit"
|
|
17
18
|
},
|
|
18
19
|
"pi": {
|