@meetopenbot/linear 0.0.1 → 0.0.3

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
@@ -54,7 +54,7 @@ Create a personal API key at **Linear → Settings → API** and either put it i
54
54
 
55
55
  | Field | Description | Default |
56
56
  | --- | --- | --- |
57
- | `clientId` | Linear OAuth application Client ID | |
57
+ | `clientId` | Linear OAuth application Client ID (overrides OpenBot's managed application) | OpenBot managed client ID |
58
58
  | `clientSecret` | OAuth Client Secret (optional; PKCE used when omitted) | — |
59
59
  | `apiKey` | Personal API key (skips OAuth entirely) | — |
60
60
  | `oauthPort` | Local port for the OAuth callback server | `4137` |
package/dist/api.d.ts CHANGED
@@ -1,8 +1,77 @@
1
1
  /**
2
- * Minimal Linear GraphQL helpers used by the OAuth connect flow.
2
+ * Thin Linear GraphQL client over fetch. We use raw GraphQL instead of
3
+ * @linear/sdk so list queries resolve relations (state, assignee, team) in a
4
+ * single request and the plugin stays dependency-light.
3
5
  */
4
6
  export declare const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
5
- export declare function fetchViewer(accessToken: string): Promise<{
7
+ export type LinearAuth = {
8
+ kind: 'apiKey';
9
+ token: string;
10
+ } | {
11
+ kind: 'oauth';
12
+ token: string;
13
+ };
14
+ export declare class LinearApiError extends Error {
15
+ readonly status?: number | undefined;
16
+ constructor(message: string, status?: number | undefined);
17
+ }
18
+ export declare function linearRequest<T = Record<string, unknown>>(auth: LinearAuth, query: string, variables?: Record<string, unknown>): Promise<T>;
19
+ export declare const ISSUE_FIELDS = "\n id\n identifier\n title\n description\n priority\n priorityLabel\n url\n createdAt\n updatedAt\n dueDate\n state { id name type }\n assignee { id name displayName }\n team { id key name }\n project { id name }\n labels { nodes { id name } }\n";
20
+ export interface LinearIssue {
21
+ id: string;
22
+ identifier: string;
23
+ title: string;
24
+ description?: string | null;
25
+ priority: number;
26
+ priorityLabel: string;
27
+ url: string;
28
+ createdAt: string;
29
+ updatedAt: string;
30
+ dueDate?: string | null;
31
+ state?: {
32
+ id: string;
33
+ name: string;
34
+ type: string;
35
+ } | null;
36
+ assignee?: {
37
+ id: string;
38
+ name: string;
39
+ displayName: string;
40
+ } | null;
41
+ team?: {
42
+ id: string;
43
+ key: string;
44
+ name: string;
45
+ } | null;
46
+ project?: {
47
+ id: string;
48
+ name: string;
49
+ } | null;
50
+ labels?: {
51
+ nodes: Array<{
52
+ id: string;
53
+ name: string;
54
+ }>;
55
+ } | null;
56
+ }
57
+ /** Compact issue representation returned to the model/tool caller. */
58
+ export declare function formatIssue(issue: LinearIssue): {
59
+ id: string;
60
+ identifier: string;
61
+ title: string;
62
+ description: string | undefined;
63
+ state: string | undefined;
64
+ stateType: string | undefined;
65
+ priority: string;
66
+ assignee: string | undefined;
67
+ team: string | undefined;
68
+ project: string | undefined;
69
+ labels: string[] | undefined;
70
+ dueDate: string | undefined;
71
+ url: string;
72
+ updatedAt: string;
73
+ };
74
+ export declare function fetchViewer(auth: LinearAuth): Promise<{
6
75
  viewer: {
7
76
  id: string;
8
77
  name: string;
@@ -15,3 +84,11 @@ export declare function fetchViewer(accessToken: string): Promise<{
15
84
  urlKey: string;
16
85
  };
17
86
  }>;
87
+ /** Resolve a team by UUID, key (e.g. "ENG"), or exact name. */
88
+ export declare function resolveTeam(auth: LinearAuth, team: string): Promise<{
89
+ id: string;
90
+ key: string;
91
+ name: string;
92
+ } | {
93
+ id: string;
94
+ }>;
package/dist/api.js CHANGED
@@ -1,29 +1,93 @@
1
1
  /**
2
- * Minimal Linear GraphQL helpers used by the OAuth connect flow.
2
+ * Thin Linear GraphQL client over fetch. We use raw GraphQL instead of
3
+ * @linear/sdk so list queries resolve relations (state, assignee, team) in a
4
+ * single request and the plugin stays dependency-light.
3
5
  */
4
- export const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
5
- async function linearGraphql(accessToken, query, variables) {
6
+ export const LINEAR_GRAPHQL_URL = 'https://api.linear.app/graphql';
7
+ export class LinearApiError extends Error {
8
+ status;
9
+ constructor(message, status) {
10
+ super(message);
11
+ this.status = status;
12
+ this.name = 'LinearApiError';
13
+ }
14
+ }
15
+ export async function linearRequest(auth, query, variables) {
6
16
  const response = await fetch(LINEAR_GRAPHQL_URL, {
7
- method: "POST",
17
+ method: 'POST',
8
18
  headers: {
9
- "Content-Type": "application/json",
10
- Authorization: `Bearer ${accessToken}`,
19
+ 'Content-Type': 'application/json',
20
+ // Personal API keys are sent bare; OAuth tokens use the Bearer scheme.
21
+ Authorization: auth.kind === 'apiKey' ? auth.token : `Bearer ${auth.token}`,
11
22
  },
12
23
  body: JSON.stringify({ query, variables }),
13
24
  });
14
25
  if (!response.ok) {
15
- const body = await response.text().catch(() => "");
16
- throw new Error(`Linear API request failed (${response.status}): ${body.slice(0, 500)}`);
26
+ const body = await response.text().catch(() => '');
27
+ if (response.status === 401) {
28
+ throw new LinearApiError('Linear rejected the credentials (401). Reconnect with `linear_connect` or update the API key.', 401);
29
+ }
30
+ throw new LinearApiError(`Linear API request failed (${response.status}): ${body.slice(0, 500)}`, response.status);
17
31
  }
18
32
  const payload = (await response.json());
19
33
  if (payload.errors?.length) {
20
- throw new Error(payload.errors.map((e) => e.message).join("; "));
34
+ throw new LinearApiError(payload.errors.map((e) => e.message).join('; '));
21
35
  }
22
36
  if (!payload.data) {
23
- throw new Error("Linear API returned no data.");
37
+ throw new LinearApiError('Linear API returned no data.');
24
38
  }
25
39
  return payload.data;
26
40
  }
27
- export async function fetchViewer(accessToken) {
28
- return linearGraphql(accessToken, `query { viewer { id name displayName email } organization { id name urlKey } }`);
41
+ export const ISSUE_FIELDS = `
42
+ id
43
+ identifier
44
+ title
45
+ description
46
+ priority
47
+ priorityLabel
48
+ url
49
+ createdAt
50
+ updatedAt
51
+ dueDate
52
+ state { id name type }
53
+ assignee { id name displayName }
54
+ team { id key name }
55
+ project { id name }
56
+ labels { nodes { id name } }
57
+ `;
58
+ /** Compact issue representation returned to the model/tool caller. */
59
+ export function formatIssue(issue) {
60
+ return {
61
+ id: issue.id,
62
+ identifier: issue.identifier,
63
+ title: issue.title,
64
+ description: issue.description ?? undefined,
65
+ state: issue.state?.name,
66
+ stateType: issue.state?.type,
67
+ priority: issue.priorityLabel,
68
+ assignee: issue.assignee?.displayName ?? issue.assignee?.name,
69
+ team: issue.team?.key,
70
+ project: issue.project?.name,
71
+ labels: issue.labels?.nodes.map((l) => l.name),
72
+ dueDate: issue.dueDate ?? undefined,
73
+ url: issue.url,
74
+ updatedAt: issue.updatedAt,
75
+ };
76
+ }
77
+ export async function fetchViewer(auth) {
78
+ const data = await linearRequest(auth, `query { viewer { id name displayName email } organization { id name urlKey } }`);
79
+ return data;
80
+ }
81
+ /** Resolve a team by UUID, key (e.g. "ENG"), or exact name. */
82
+ export async function resolveTeam(auth, team) {
83
+ const uuidLike = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
84
+ if (uuidLike.test(team))
85
+ return { id: team };
86
+ const data = await linearRequest(auth, `query { teams(first: 100) { nodes { id key name } } }`);
87
+ const match = data.teams.nodes.find((t) => t.key.toLowerCase() === team.toLowerCase() || t.name.toLowerCase() === team.toLowerCase());
88
+ if (!match) {
89
+ const available = data.teams.nodes.map((t) => t.key).join(', ');
90
+ throw new LinearApiError(`No Linear team matches "${team}". Available team keys: ${available}`);
91
+ }
92
+ return match;
29
93
  }
package/dist/config.d.ts CHANGED
@@ -5,6 +5,8 @@ export declare const VAR_ACCESS_TOKEN = "LINEAR_ACCESS_TOKEN";
5
5
  export declare const VAR_REFRESH_TOKEN = "LINEAR_REFRESH_TOKEN";
6
6
  export declare const VAR_TOKEN_EXPIRES_AT = "LINEAR_TOKEN_EXPIRES_AT";
7
7
  export declare const VAR_CLIENT_ID = "LINEAR_CLIENT_ID";
8
+ export declare const GO_BACK_TO_OPENBOT_URL = "https://openbot.one/settings/agents";
9
+ export declare const LINEAR_OPENBOT_CLIENT_ID = "1263fc2dbbf08efa7b2c4b1b2b2582be";
8
10
  export type LinearPluginConfig = {
9
11
  clientId?: string;
10
12
  clientSecret?: string;
package/dist/config.js CHANGED
@@ -4,6 +4,8 @@ export const VAR_ACCESS_TOKEN = "LINEAR_ACCESS_TOKEN";
4
4
  export const VAR_REFRESH_TOKEN = "LINEAR_REFRESH_TOKEN";
5
5
  export const VAR_TOKEN_EXPIRES_AT = "LINEAR_TOKEN_EXPIRES_AT";
6
6
  export const VAR_CLIENT_ID = "LINEAR_CLIENT_ID";
7
+ export const GO_BACK_TO_OPENBOT_URL = "https://openbot.one/settings/agents";
8
+ export const LINEAR_OPENBOT_CLIENT_ID = "1263fc2dbbf08efa7b2c4b1b2b2582be";
7
9
  const REFRESH_MARGIN_MS = 5 * 60 * 1000;
8
10
  function variableValue(variables, key) {
9
11
  const entry = variables[key];
@@ -15,7 +17,7 @@ export function readLinearConfig(config) {
15
17
  return {
16
18
  clientId: typeof config.clientId === "string" && config.clientId.trim()
17
19
  ? config.clientId.trim()
18
- : undefined,
20
+ : LINEAR_OPENBOT_CLIENT_ID,
19
21
  clientSecret: typeof config.clientSecret === "string" && config.clientSecret.trim()
20
22
  ? config.clientSecret.trim()
21
23
  : undefined,
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ declare const _default: {
9
9
  clientId: {
10
10
  type: "string";
11
11
  description: string;
12
+ default: string;
12
13
  };
13
14
  clientSecret: {
14
15
  type: "string";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { agentOutput, definePlugin, shouldHandleInvoke, toolResult, uiWidget, webhookHttpResponse, } from "@meetopenbot/plugin-sdk";
2
- import { clearTokens, formatMissingCredentials, readLinearConfig, resolveLinearCredentials, resolveWebhookBaseUrl, saveTokens, } from "./config.js";
2
+ import { clearTokens, formatMissingCredentials, LINEAR_OPENBOT_CLIENT_ID, readLinearConfig, resolveLinearCredentials, resolveWebhookBaseUrl, saveTokens, } from "./config.js";
3
3
  import { runLinearAgent } from "./linear-agent.js";
4
4
  import { buildIssuesListWidget, isAssignedIssuesPrompt, isListIssuesPrompt, issuesListTitle, } from "./linear-issues.js";
5
5
  import { OAUTH_WEBHOOK_PROVIDER, buildOAuthRedirectUri, fetchViewer, handleWebhookOAuthCallback, startOAuthFlow, startWebhookOAuthFlow, } from "./oauth.js";
@@ -47,7 +47,7 @@ function* yieldOAuthAuthorizeWidget(agentId, authorizeUrl, modeHint, options) {
47
47
  id: OPEN_URL_ACTION_ID,
48
48
  label: "Connect Linear",
49
49
  variant: "primary",
50
- value: { url: authorizeUrl, target: "_blank" },
50
+ value: { url: authorizeUrl },
51
51
  },
52
52
  ],
53
53
  },
@@ -170,7 +170,8 @@ const linearPluginConfigSchema = {
170
170
  properties: {
171
171
  clientId: {
172
172
  type: "string",
173
- description: "Linear OAuth application Client ID (create one at linear.app/settings/api/applications)",
173
+ description: "Linear OAuth application Client ID. Defaults to OpenBot's managed Linear application.",
174
+ default: LINEAR_OPENBOT_CLIENT_ID,
174
175
  },
175
176
  clientSecret: {
176
177
  type: "string",
package/dist/oauth.js CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { createHash, randomBytes } from "node:crypto";
9
9
  import { createServer } from "node:http";
10
+ import { GO_BACK_TO_OPENBOT_URL } from "./config.js";
10
11
  import { clearPendingOAuthSession, loadPendingOAuthSession, savePendingOAuthSession, } from "./oauth-pending.js";
11
12
  export const LINEAR_AUTHORIZE_URL = "https://linear.app/oauth/authorize";
12
13
  export const LINEAR_TOKEN_URL = "https://api.linear.app/oauth/token";
@@ -31,19 +32,27 @@ export function oauthHtmlPage(title, body, ok) {
31
32
  <meta charset="utf-8" />
32
33
  <meta name="viewport" content="width=device-width, initial-scale=1" />
33
34
  <title>${title}</title>
35
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
36
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
37
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
34
38
  <style>
35
- body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #101012; color: #ededef; }
36
- .card { text-align: center; padding: 48px 56px; border-radius: 16px; background: #1b1b1f; border: 1px solid #2a2a30; max-width: 420px; }
37
- .icon { font-size: 44px; margin-bottom: 16px; }
38
- h1 { font-size: 20px; margin: 0 0 8px; }
39
+ body { font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #101012; color: #ededef; }
40
+ .card { text-align: center; padding: 40px 48px; border-radius: 14px; background: #1b1b1f; border: 1px solid #2a2a30; max-width: 420px; }
41
+ .icon { height: 28px; margin-bottom: 18px; font-size: 28px; font-weight: 500; line-height: 28px; }
42
+ .success { color: #4ade80; }
43
+ h1 { font-size: 20px; font-weight: 600; margin: 0 0 8px; }
39
44
  p { color: #9b9ba3; margin: 0; line-height: 1.5; }
45
+ .back-link { margin: 20px 0 0; font-size: 14px; }
46
+ .back-link a { color: #ededef; text-decoration: none; }
47
+ .back-link a:hover { text-decoration: underline; }
40
48
  </style>
41
49
  </head>
42
50
  <body>
43
51
  <div class="card">
44
- <div class="icon">${ok ? "" : "⚠️"}</div>
52
+ <div class="icon${ok ? " success" : ""}">${ok ? "" : ""}</div>
45
53
  <h1>${title}</h1>
46
54
  <p>${body}</p>
55
+ <p class="back-link">Go back to <a href="${GO_BACK_TO_OPENBOT_URL}">OpenBot</a></p>
47
56
  </div>
48
57
  </body>
49
58
  </html>`;
package/dist/tools.js CHANGED
@@ -3,6 +3,7 @@
3
3
  * model plus a human-readable `output` summary, and optionally a UI widget
4
4
  * spec rendered in the OpenBot client.
5
5
  */
6
+ import { z } from 'zod';
6
7
  import { ISSUE_FIELDS, LinearApiError, fetchViewer, formatIssue, linearRequest, resolveTeam, } from './api.js';
7
8
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
9
  async function resolveIssueId(auth, idOrIdentifier) {
@@ -42,7 +43,9 @@ export const linearTools = {
42
43
  linear_status: {
43
44
  definition: {
44
45
  description: 'Check whether Linear is connected and which user/workspace the credentials belong to.',
45
- inputSchema: { type: 'object', properties: {} },
46
+ // The openbot runtime plugin passes inputSchema straight to the AI SDK,
47
+ // which requires Zod schemas (plain JSON Schema objects fail).
48
+ inputSchema: z.object({}),
46
49
  },
47
50
  run: async (auth) => {
48
51
  const { viewer, organization } = await fetchViewer(auth);
@@ -55,7 +58,7 @@ export const linearTools = {
55
58
  list_teams: {
56
59
  definition: {
57
60
  description: 'List Linear teams, including their workflow states (useful for setting issue status).',
58
- inputSchema: { type: 'object', properties: {} },
61
+ inputSchema: z.object({}),
59
62
  },
60
63
  run: async (auth) => {
61
64
  const data = await linearRequest(auth, `query { teams(first: 100) { nodes { id key name states { nodes { id name type } } } } }`);
@@ -69,19 +72,15 @@ export const linearTools = {
69
72
  list_issues: {
70
73
  definition: {
71
74
  description: 'List Linear issues, optionally filtered by team (key, name, or id), assignee ("me" or a user id), and state type.',
72
- inputSchema: {
73
- type: 'object',
74
- properties: {
75
- team: { type: 'string', description: 'Team key (e.g. "ENG"), name, or id' },
76
- assignee: { type: 'string', description: '"me" for the connected user, or a Linear user id' },
77
- stateType: {
78
- type: 'string',
79
- enum: ['triage', 'backlog', 'unstarted', 'started', 'completed', 'canceled'],
80
- description: 'Filter by workflow state type',
81
- },
82
- limit: { type: 'number', description: 'Max issues to return (default 25)' },
83
- },
84
- },
75
+ inputSchema: z.object({
76
+ team: z.string().optional().describe('Team key (e.g. "ENG"), name, or id'),
77
+ assignee: z.string().optional().describe('"me" for the connected user, or a Linear user id'),
78
+ stateType: z
79
+ .enum(['triage', 'backlog', 'unstarted', 'started', 'completed', 'canceled'])
80
+ .optional()
81
+ .describe('Filter by workflow state type'),
82
+ limit: z.number().optional().describe('Max issues to return (default 25)'),
83
+ }),
85
84
  },
86
85
  run: async (auth, args) => {
87
86
  const { team, assignee, stateType, limit } = args;
@@ -111,14 +110,10 @@ export const linearTools = {
111
110
  search_issues: {
112
111
  definition: {
113
112
  description: 'Full-text search Linear issues by keyword.',
114
- inputSchema: {
115
- type: 'object',
116
- properties: {
117
- query: { type: 'string', description: 'Search term' },
118
- limit: { type: 'number', description: 'Max results (default 10)' },
119
- },
120
- required: ['query'],
121
- },
113
+ inputSchema: z.object({
114
+ query: z.string().describe('Search term'),
115
+ limit: z.number().optional().describe('Max results (default 10)'),
116
+ }),
122
117
  },
123
118
  run: async (auth, args) => {
124
119
  const { query, limit } = args;
@@ -134,13 +129,9 @@ export const linearTools = {
134
129
  get_issue: {
135
130
  definition: {
136
131
  description: 'Get full details of a Linear issue by identifier (e.g. "ENG-123") or id, including comments.',
137
- inputSchema: {
138
- type: 'object',
139
- properties: {
140
- issue: { type: 'string', description: 'Issue identifier (e.g. "ENG-123") or Linear issue id' },
141
- },
142
- required: ['issue'],
143
- },
132
+ inputSchema: z.object({
133
+ issue: z.string().describe('Issue identifier (e.g. "ENG-123") or Linear issue id'),
134
+ }),
144
135
  },
145
136
  run: async (auth, args) => {
146
137
  const { issue: issueRef } = args;
@@ -161,18 +152,14 @@ export const linearTools = {
161
152
  create_issue: {
162
153
  definition: {
163
154
  description: 'Create a new Linear issue.',
164
- inputSchema: {
165
- type: 'object',
166
- properties: {
167
- team: { type: 'string', description: 'Team key (e.g. "ENG"), name, or id' },
168
- title: { type: 'string', description: 'Issue title' },
169
- description: { type: 'string', description: 'Issue description (markdown)' },
170
- priority: { type: 'number', description: '0 none, 1 urgent, 2 high, 3 medium, 4 low' },
171
- assignee: { type: 'string', description: '"me" or a Linear user id' },
172
- stateId: { type: 'string', description: 'Workflow state id (see list_teams)' },
173
- },
174
- required: ['team', 'title'],
175
- },
155
+ inputSchema: z.object({
156
+ team: z.string().describe('Team key (e.g. "ENG"), name, or id'),
157
+ title: z.string().describe('Issue title'),
158
+ description: z.string().optional().describe('Issue description (markdown)'),
159
+ priority: z.number().optional().describe('0 none, 1 urgent, 2 high, 3 medium, 4 low'),
160
+ assignee: z.string().optional().describe('"me" or a Linear user id'),
161
+ stateId: z.string().optional().describe('Workflow state id (see list_teams)'),
162
+ }),
176
163
  },
177
164
  run: async (auth, args) => {
178
165
  const { team, title, description, priority, assignee, stateId } = args;
@@ -191,18 +178,14 @@ export const linearTools = {
191
178
  update_issue: {
192
179
  definition: {
193
180
  description: 'Update a Linear issue (title, description, priority, state, assignee).',
194
- inputSchema: {
195
- type: 'object',
196
- properties: {
197
- issue: { type: 'string', description: 'Issue identifier (e.g. "ENG-123") or id' },
198
- title: { type: 'string', description: 'New title' },
199
- description: { type: 'string', description: 'New description (markdown)' },
200
- priority: { type: 'number', description: '0 none, 1 urgent, 2 high, 3 medium, 4 low' },
201
- assignee: { type: 'string', description: '"me", a Linear user id, or "none" to unassign' },
202
- stateId: { type: 'string', description: 'Workflow state id (see list_teams)' },
203
- },
204
- required: ['issue'],
205
- },
181
+ inputSchema: z.object({
182
+ issue: z.string().describe('Issue identifier (e.g. "ENG-123") or id'),
183
+ title: z.string().optional().describe('New title'),
184
+ description: z.string().optional().describe('New description (markdown)'),
185
+ priority: z.number().optional().describe('0 none, 1 urgent, 2 high, 3 medium, 4 low'),
186
+ assignee: z.string().optional().describe('"me", a Linear user id, or "none" to unassign'),
187
+ stateId: z.string().optional().describe('Workflow state id (see list_teams)'),
188
+ }),
206
189
  },
207
190
  run: async (auth, args) => {
208
191
  const { issue: issueRef, title, description, priority, assignee, stateId } = args;
@@ -223,14 +206,10 @@ export const linearTools = {
223
206
  create_comment: {
224
207
  definition: {
225
208
  description: 'Add a comment to a Linear issue.',
226
- inputSchema: {
227
- type: 'object',
228
- properties: {
229
- issue: { type: 'string', description: 'Issue identifier (e.g. "ENG-123") or id' },
230
- body: { type: 'string', description: 'Comment body (markdown)' },
231
- },
232
- required: ['issue', 'body'],
233
- },
209
+ inputSchema: z.object({
210
+ issue: z.string().describe('Issue identifier (e.g. "ENG-123") or id'),
211
+ body: z.string().describe('Comment body (markdown)'),
212
+ }),
234
213
  },
235
214
  run: async (auth, args) => {
236
215
  const { issue: issueRef, body } = args;
@@ -245,12 +224,9 @@ export const linearTools = {
245
224
  list_projects: {
246
225
  definition: {
247
226
  description: 'List Linear projects.',
248
- inputSchema: {
249
- type: 'object',
250
- properties: {
251
- limit: { type: 'number', description: 'Max projects to return (default 25)' },
252
- },
253
- },
227
+ inputSchema: z.object({
228
+ limit: z.number().optional().describe('Max projects to return (default 25)'),
229
+ }),
254
230
  },
255
231
  run: async (auth, args) => {
256
232
  const { limit } = args;
@@ -276,7 +252,7 @@ export const linearTools = {
276
252
  list_users: {
277
253
  definition: {
278
254
  description: 'List members of the Linear workspace (useful for resolving assignee ids).',
279
- inputSchema: { type: 'object', properties: {} },
255
+ inputSchema: z.object({}),
280
256
  },
281
257
  run: async (auth) => {
282
258
  const data = await linearRequest(auth, `query { users(first: 100) { nodes { id name displayName email active } } }`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meetopenbot/linear",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Linear OAuth connect flow and MCP-backed agent for issues, projects, and comments",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/config.ts CHANGED
@@ -6,6 +6,8 @@ export const VAR_ACCESS_TOKEN = "LINEAR_ACCESS_TOKEN";
6
6
  export const VAR_REFRESH_TOKEN = "LINEAR_REFRESH_TOKEN";
7
7
  export const VAR_TOKEN_EXPIRES_AT = "LINEAR_TOKEN_EXPIRES_AT";
8
8
  export const VAR_CLIENT_ID = "LINEAR_CLIENT_ID";
9
+ export const GO_BACK_TO_OPENBOT_URL = "https://openbot.one/settings/agents";
10
+ export const LINEAR_OPENBOT_CLIENT_ID = "1263fc2dbbf08efa7b2c4b1b2b2582be";
9
11
 
10
12
  const REFRESH_MARGIN_MS = 5 * 60 * 1000;
11
13
 
@@ -47,7 +49,7 @@ export function readLinearConfig(
47
49
  clientId:
48
50
  typeof config.clientId === "string" && config.clientId.trim()
49
51
  ? config.clientId.trim()
50
- : undefined,
52
+ : LINEAR_OPENBOT_CLIENT_ID,
51
53
  clientSecret:
52
54
  typeof config.clientSecret === "string" && config.clientSecret.trim()
53
55
  ? config.clientSecret.trim()
@@ -114,7 +116,7 @@ export async function saveTokens(
114
116
 
115
117
  export async function clearTokens(storage: Storage): Promise<void> {
116
118
  for (const key of [VAR_ACCESS_TOKEN, VAR_REFRESH_TOKEN, VAR_TOKEN_EXPIRES_AT]) {
117
- await storage.deleteVariable({ key }).catch(() => {});
119
+ await storage.deleteVariable({ key }).catch(() => { });
118
120
  }
119
121
  }
120
122
 
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  import {
17
17
  clearTokens,
18
18
  formatMissingCredentials,
19
+ LINEAR_OPENBOT_CLIENT_ID,
19
20
  readLinearConfig,
20
21
  resolveLinearCredentials,
21
22
  resolveWebhookBaseUrl,
@@ -113,7 +114,7 @@ function* yieldOAuthAuthorizeWidget(
113
114
  id: OPEN_URL_ACTION_ID,
114
115
  label: "Connect Linear",
115
116
  variant: "primary",
116
- value: { url: authorizeUrl, target: "_blank" },
117
+ value: { url: authorizeUrl },
117
118
  },
118
119
  ],
119
120
  },
@@ -273,7 +274,8 @@ const linearPluginConfigSchema = {
273
274
  clientId: {
274
275
  type: "string",
275
276
  description:
276
- "Linear OAuth application Client ID (create one at linear.app/settings/api/applications)",
277
+ "Linear OAuth application Client ID. Defaults to OpenBot's managed Linear application.",
278
+ default: LINEAR_OPENBOT_CLIENT_ID,
277
279
  },
278
280
  clientSecret: {
279
281
  type: "string",
package/src/oauth.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  import { createHash, randomBytes } from "node:crypto";
10
10
  import { createServer, type Server } from "node:http";
11
11
  import type { Storage } from "@meetopenbot/plugin-sdk";
12
+ import { GO_BACK_TO_OPENBOT_URL } from "./config.js";
12
13
  import {
13
14
  clearPendingOAuthSession,
14
15
  loadPendingOAuthSession,
@@ -84,19 +85,27 @@ export function oauthHtmlPage(title: string, body: string, ok: boolean): string
84
85
  <meta charset="utf-8" />
85
86
  <meta name="viewport" content="width=device-width, initial-scale=1" />
86
87
  <title>${title}</title>
88
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
89
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
90
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
87
91
  <style>
88
- body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #101012; color: #ededef; }
89
- .card { text-align: center; padding: 48px 56px; border-radius: 16px; background: #1b1b1f; border: 1px solid #2a2a30; max-width: 420px; }
90
- .icon { font-size: 44px; margin-bottom: 16px; }
91
- h1 { font-size: 20px; margin: 0 0 8px; }
92
+ body { font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #101012; color: #ededef; }
93
+ .card { text-align: center; padding: 40px 48px; border-radius: 14px; background: #1b1b1f; border: 1px solid #2a2a30; max-width: 420px; }
94
+ .icon { height: 28px; margin-bottom: 18px; font-size: 28px; font-weight: 500; line-height: 28px; }
95
+ .success { color: #4ade80; }
96
+ h1 { font-size: 20px; font-weight: 600; margin: 0 0 8px; }
92
97
  p { color: #9b9ba3; margin: 0; line-height: 1.5; }
98
+ .back-link { margin: 20px 0 0; font-size: 14px; }
99
+ .back-link a { color: #ededef; text-decoration: none; }
100
+ .back-link a:hover { text-decoration: underline; }
93
101
  </style>
94
102
  </head>
95
103
  <body>
96
104
  <div class="card">
97
- <div class="icon">${ok ? "" : "⚠️"}</div>
105
+ <div class="icon${ok ? " success" : ""}">${ok ? "" : ""}</div>
98
106
  <h1>${title}</h1>
99
107
  <p>${body}</p>
108
+ <p class="back-link">Go back to <a href="${GO_BACK_TO_OPENBOT_URL}">OpenBot</a></p>
100
109
  </div>
101
110
  </body>
102
111
  </html>`;