@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 CHANGED
@@ -34,27 +34,34 @@ pi install npm:@alasano/pi-linear
34
34
 
35
35
  ## Authentication
36
36
 
37
- The extension auto-detects the `LINEAR_API_KEY` environment variable if set globally. No setup needed in that case.
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
- For multi-workspace support or if you don't have the env var, use the `/linear-auth` command:
39
+ Use the `/linear-auth` command to manage workspaces and choose the auth preference:
40
40
 
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 (shows selector if name omitted) |
46
- | `/linear-auth status` | Show current auth source and configured workspaces |
47
- | `/linear-auth` | Same as status |
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
- Auth resolution order:
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 workspaces configured)
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
- Credentials are stored at `~/.pi/agent/state/extensions/linear/credentials.json`.
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
 
@@ -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
- if (envApiKey) return { apiKey: envApiKey, source: 'env' };
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) {
@@ -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: 'Manage Linear workspace auth (usage: /linear-auth [add|remove|switch|status])',
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 rawInputTeamId = asString(rawInput.teamId);
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 rawInputTeamId = asString(rawInput.teamId);
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.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": {