@meetopenbot/linear 0.0.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/README.md +79 -0
- package/dist/api.d.ts +17 -0
- package/dist/api.js +29 -0
- package/dist/auth.d.ts +33 -0
- package/dist/auth.js +91 -0
- package/dist/config.d.ts +39 -0
- package/dist/config.js +152 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +318 -0
- package/dist/linear-agent.d.ts +14 -0
- package/dist/linear-agent.js +72 -0
- package/dist/linear-issues.d.ts +33 -0
- package/dist/linear-issues.js +136 -0
- package/dist/linear-mcp.d.ts +4 -0
- package/dist/linear-mcp.js +26 -0
- package/dist/oauth-pending.d.ts +13 -0
- package/dist/oauth-pending.js +40 -0
- package/dist/oauth.d.ts +95 -0
- package/dist/oauth.js +372 -0
- package/dist/tools.d.ts +17 -0
- package/dist/tools.js +290 -0
- package/package.json +41 -0
- package/src/config.ts +228 -0
- package/src/index.ts +449 -0
- package/src/linear-agent.ts +107 -0
- package/src/linear-issues.ts +188 -0
- package/src/linear-mcp.ts +33 -0
- package/src/oauth-pending.ts +68 -0
- package/src/oauth.ts +563 -0
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# @meetopenbot/linear
|
|
2
|
+
|
|
3
|
+
Connect [Linear](https://linear.app) to OpenBot. OAuth for setup, MCP-backed agent for issues, projects, and comments.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **OAuth connect flow** — ask the agent to "connect Linear", click the link, approve in the browser, come back connected. Uses PKCE, so no client secret is required. Tokens are stored as secret workspace variables and refreshed automatically.
|
|
8
|
+
- **API key fallback** — paste a personal API key into the plugin config instead.
|
|
9
|
+
- **MCP agent** — `agent:invoke` runs an OpenAI agent loop with Linear MCP tools (`mcp-server-linear`) for dynamic issue/project management.
|
|
10
|
+
- **Connect tools** — `linear_connect` / `linear_disconnect` for chat-driven OAuth.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
Install from the OpenBot plugin settings (package name `@meetopenbot/linear`), or add it to an agent's `AGENT.md`:
|
|
15
|
+
|
|
16
|
+
```yaml
|
|
17
|
+
plugins:
|
|
18
|
+
- id: '@meetopenbot/linear'
|
|
19
|
+
config:
|
|
20
|
+
clientId: 'your-linear-oauth-client-id'
|
|
21
|
+
openaiApiKey: 'your-openai-api-key'
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Auth setup
|
|
25
|
+
|
|
26
|
+
### Option A — Install-time OAuth from openbot.one (recommended)
|
|
27
|
+
|
|
28
|
+
Install the plugin from **openbot.one → Settings → Plugins**. After the install completes you're redirected to Linear's consent screen; approve, and you land back on the plugins page connected.
|
|
29
|
+
|
|
30
|
+
### Option B — Webhook OAuth (cloud / remote runtime)
|
|
31
|
+
|
|
32
|
+
1. In Linear, create an OAuth application at **Settings → API → OAuth applications**.
|
|
33
|
+
2. Set the callback URL to `https://<your-host>/api/webhooks/linear`.
|
|
34
|
+
3. Register this plugin on your webhook agent (usually `system`) in `AGENT.md`.
|
|
35
|
+
4. Set `webhookBaseUrl` in plugin config to `https://<your-host>` (or rely on the runtime's `publicBaseUrl` if provided).
|
|
36
|
+
5. Run `linear_connect` and approve in the browser.
|
|
37
|
+
|
|
38
|
+
> **Note:** the runtime must forward OAuth browser redirects (GET with `?code=` / `?state=`) to `POST /api/webhooks/linear` as `action:webhook` with query params in `event.data.query`.
|
|
39
|
+
|
|
40
|
+
### Option C — Chat-driven OAuth (local loopback)
|
|
41
|
+
|
|
42
|
+
1. In Linear, go to **Settings → API → OAuth applications** and create an application.
|
|
43
|
+
2. Set the callback URL to `http://localhost:4137/oauth/callback` (or your configured `oauthPort`).
|
|
44
|
+
3. Put the application's **Client ID** into the plugin's `clientId` config field.
|
|
45
|
+
4. Ask the agent to *"connect Linear"*. It runs `linear_connect`, shows an authorization link, and confirms once you approve in the browser.
|
|
46
|
+
|
|
47
|
+
> **Note:** loopback only works when the browser can reach the runtime on `localhost:<oauthPort>`.
|
|
48
|
+
|
|
49
|
+
### Option D — API key
|
|
50
|
+
|
|
51
|
+
Create a personal API key at **Linear → Settings → API** and either put it in the plugin's `apiKey` config field or save it as a secret workspace variable named `LINEAR_API_KEY`.
|
|
52
|
+
|
|
53
|
+
## Config
|
|
54
|
+
|
|
55
|
+
| Field | Description | Default |
|
|
56
|
+
| --- | --- | --- |
|
|
57
|
+
| `clientId` | Linear OAuth application Client ID | — |
|
|
58
|
+
| `clientSecret` | OAuth Client Secret (optional; PKCE used when omitted) | — |
|
|
59
|
+
| `apiKey` | Personal API key (skips OAuth entirely) | — |
|
|
60
|
+
| `oauthPort` | Local port for the OAuth callback server | `4137` |
|
|
61
|
+
| `scopes` | Comma-separated OAuth scopes | `read,write,issues:create,comments:create` |
|
|
62
|
+
| `webhookBaseUrl` | Public base URL for OAuth callback (`/api/webhooks/linear`) | — |
|
|
63
|
+
| `openaiApiKey` | OpenAI API key for the MCP agent loop | — |
|
|
64
|
+
| `model` | OpenAI model id | `gpt-4o-mini` |
|
|
65
|
+
|
|
66
|
+
## Credential resolution order
|
|
67
|
+
|
|
68
|
+
1. `apiKey` from plugin config
|
|
69
|
+
2. `LINEAR_API_KEY` workspace variable / env
|
|
70
|
+
3. `LINEAR_ACCESS_TOKEN` OAuth token (auto-refreshed via `LINEAR_REFRESH_TOKEN`, using `clientId` from config or the `LINEAR_CLIENT_ID` variable)
|
|
71
|
+
|
|
72
|
+
## Development
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npm install
|
|
76
|
+
npm run build # emits dist/ (the runtime loads dist/index.js)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
For local testing, copy or link the package into `~/.openbot/plugins/@meetopenbot/linear/` and attach it to an agent.
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Linear GraphQL helpers used by the OAuth connect flow.
|
|
3
|
+
*/
|
|
4
|
+
export declare const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
|
|
5
|
+
export declare function fetchViewer(accessToken: string): Promise<{
|
|
6
|
+
viewer: {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
displayName: string;
|
|
10
|
+
email: string;
|
|
11
|
+
};
|
|
12
|
+
organization: {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
urlKey: string;
|
|
16
|
+
};
|
|
17
|
+
}>;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Linear GraphQL helpers used by the OAuth connect flow.
|
|
3
|
+
*/
|
|
4
|
+
export const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
|
|
5
|
+
async function linearGraphql(accessToken, query, variables) {
|
|
6
|
+
const response = await fetch(LINEAR_GRAPHQL_URL, {
|
|
7
|
+
method: "POST",
|
|
8
|
+
headers: {
|
|
9
|
+
"Content-Type": "application/json",
|
|
10
|
+
Authorization: `Bearer ${accessToken}`,
|
|
11
|
+
},
|
|
12
|
+
body: JSON.stringify({ query, variables }),
|
|
13
|
+
});
|
|
14
|
+
if (!response.ok) {
|
|
15
|
+
const body = await response.text().catch(() => "");
|
|
16
|
+
throw new Error(`Linear API request failed (${response.status}): ${body.slice(0, 500)}`);
|
|
17
|
+
}
|
|
18
|
+
const payload = (await response.json());
|
|
19
|
+
if (payload.errors?.length) {
|
|
20
|
+
throw new Error(payload.errors.map((e) => e.message).join("; "));
|
|
21
|
+
}
|
|
22
|
+
if (!payload.data) {
|
|
23
|
+
throw new Error("Linear API returned no data.");
|
|
24
|
+
}
|
|
25
|
+
return payload.data;
|
|
26
|
+
}
|
|
27
|
+
export async function fetchViewer(accessToken) {
|
|
28
|
+
return linearGraphql(accessToken, `query { viewer { id name displayName email } organization { id name urlKey } }`);
|
|
29
|
+
}
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential resolution for the Linear plugin.
|
|
3
|
+
*
|
|
4
|
+
* Priority:
|
|
5
|
+
* 1. `apiKey` in plugin config (AGENT.md) — personal API key.
|
|
6
|
+
* 2. `LINEAR_API_KEY` workspace variable / env — personal API key.
|
|
7
|
+
* 3. `LINEAR_ACCESS_TOKEN` workspace variable — OAuth token saved by
|
|
8
|
+
* `linear_connect`, refreshed automatically via `LINEAR_REFRESH_TOKEN`.
|
|
9
|
+
*/
|
|
10
|
+
import type { PluginContext } from '@meetopenbot/plugin-sdk';
|
|
11
|
+
import type { LinearAuth } from './api.js';
|
|
12
|
+
import { type OAuthTokens } from './oauth.js';
|
|
13
|
+
export declare const VAR_API_KEY = "LINEAR_API_KEY";
|
|
14
|
+
export declare const VAR_ACCESS_TOKEN = "LINEAR_ACCESS_TOKEN";
|
|
15
|
+
export declare const VAR_REFRESH_TOKEN = "LINEAR_REFRESH_TOKEN";
|
|
16
|
+
export declare const VAR_TOKEN_EXPIRES_AT = "LINEAR_TOKEN_EXPIRES_AT";
|
|
17
|
+
/** Stored by the runtime's install-time OAuth flow; used to refresh tokens. */
|
|
18
|
+
export declare const VAR_CLIENT_ID = "LINEAR_CLIENT_ID";
|
|
19
|
+
export interface LinearPluginConfig {
|
|
20
|
+
apiKey?: string;
|
|
21
|
+
clientId?: string;
|
|
22
|
+
clientSecret?: string;
|
|
23
|
+
oauthPort?: number;
|
|
24
|
+
scopes?: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function readConfig(context: PluginContext): LinearPluginConfig;
|
|
27
|
+
export declare function saveTokens(context: PluginContext, tokens: OAuthTokens): Promise<void>;
|
|
28
|
+
export declare function clearTokens(context: PluginContext): Promise<void>;
|
|
29
|
+
export declare class NotConnectedError extends Error {
|
|
30
|
+
constructor();
|
|
31
|
+
}
|
|
32
|
+
/** Resolve usable Linear credentials, refreshing the OAuth token if needed. */
|
|
33
|
+
export declare function resolveAuth(context: PluginContext): Promise<LinearAuth>;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential resolution for the Linear plugin.
|
|
3
|
+
*
|
|
4
|
+
* Priority:
|
|
5
|
+
* 1. `apiKey` in plugin config (AGENT.md) — personal API key.
|
|
6
|
+
* 2. `LINEAR_API_KEY` workspace variable / env — personal API key.
|
|
7
|
+
* 3. `LINEAR_ACCESS_TOKEN` workspace variable — OAuth token saved by
|
|
8
|
+
* `linear_connect`, refreshed automatically via `LINEAR_REFRESH_TOKEN`.
|
|
9
|
+
*/
|
|
10
|
+
import { refreshAccessToken } from './oauth.js';
|
|
11
|
+
export const VAR_API_KEY = 'LINEAR_API_KEY';
|
|
12
|
+
export const VAR_ACCESS_TOKEN = 'LINEAR_ACCESS_TOKEN';
|
|
13
|
+
export const VAR_REFRESH_TOKEN = 'LINEAR_REFRESH_TOKEN';
|
|
14
|
+
export const VAR_TOKEN_EXPIRES_AT = 'LINEAR_TOKEN_EXPIRES_AT';
|
|
15
|
+
/** Stored by the runtime's install-time OAuth flow; used to refresh tokens. */
|
|
16
|
+
export const VAR_CLIENT_ID = 'LINEAR_CLIENT_ID';
|
|
17
|
+
/** Refresh this long before the reported expiry. */
|
|
18
|
+
const REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
|
19
|
+
export function readConfig(context) {
|
|
20
|
+
const config = (context.config ?? {});
|
|
21
|
+
return {
|
|
22
|
+
apiKey: typeof config.apiKey === 'string' && config.apiKey.trim() ? config.apiKey.trim() : undefined,
|
|
23
|
+
clientId: typeof config.clientId === 'string' && config.clientId.trim() ? config.clientId.trim() : undefined,
|
|
24
|
+
clientSecret: typeof config.clientSecret === 'string' && config.clientSecret.trim() ? config.clientSecret.trim() : undefined,
|
|
25
|
+
oauthPort: typeof config.oauthPort === 'number' ? config.oauthPort : 4137,
|
|
26
|
+
scopes: typeof config.scopes === 'string' && config.scopes.trim()
|
|
27
|
+
? config.scopes.trim()
|
|
28
|
+
: 'read,write,issues:create,comments:create',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function variableValue(variables, key) {
|
|
32
|
+
const entry = variables[key];
|
|
33
|
+
if (typeof entry === 'string')
|
|
34
|
+
return entry || undefined;
|
|
35
|
+
return entry?.value || undefined;
|
|
36
|
+
}
|
|
37
|
+
export async function saveTokens(context, tokens) {
|
|
38
|
+
await context.storage.createVariable({ key: VAR_ACCESS_TOKEN, value: tokens.accessToken, secret: true });
|
|
39
|
+
if (tokens.refreshToken) {
|
|
40
|
+
await context.storage.createVariable({ key: VAR_REFRESH_TOKEN, value: tokens.refreshToken, secret: true });
|
|
41
|
+
}
|
|
42
|
+
if (tokens.expiresAt) {
|
|
43
|
+
await context.storage.createVariable({ key: VAR_TOKEN_EXPIRES_AT, value: String(tokens.expiresAt), secret: false });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export async function clearTokens(context) {
|
|
47
|
+
for (const key of [VAR_ACCESS_TOKEN, VAR_REFRESH_TOKEN, VAR_TOKEN_EXPIRES_AT]) {
|
|
48
|
+
await context.storage.deleteVariable({ key }).catch(() => { });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export class NotConnectedError extends Error {
|
|
52
|
+
constructor() {
|
|
53
|
+
super('Linear is not connected. Use the `linear_connect` tool to connect with OAuth, or set an API key in the plugin config / LINEAR_API_KEY variable.');
|
|
54
|
+
this.name = 'NotConnectedError';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Resolve usable Linear credentials, refreshing the OAuth token if needed. */
|
|
58
|
+
export async function resolveAuth(context) {
|
|
59
|
+
const config = readConfig(context);
|
|
60
|
+
if (config.apiKey)
|
|
61
|
+
return { kind: 'apiKey', token: config.apiKey };
|
|
62
|
+
const variables = (await context.storage.getVariables().catch(() => ({})));
|
|
63
|
+
const apiKey = variableValue(variables, VAR_API_KEY) ?? process.env[VAR_API_KEY];
|
|
64
|
+
if (apiKey)
|
|
65
|
+
return { kind: 'apiKey', token: apiKey };
|
|
66
|
+
const accessToken = variableValue(variables, VAR_ACCESS_TOKEN) ?? process.env[VAR_ACCESS_TOKEN];
|
|
67
|
+
if (!accessToken)
|
|
68
|
+
throw new NotConnectedError();
|
|
69
|
+
const expiresAtRaw = variableValue(variables, VAR_TOKEN_EXPIRES_AT) ?? process.env[VAR_TOKEN_EXPIRES_AT];
|
|
70
|
+
const expiresAt = expiresAtRaw ? Number(expiresAtRaw) : undefined;
|
|
71
|
+
const refreshToken = variableValue(variables, VAR_REFRESH_TOKEN) ?? process.env[VAR_REFRESH_TOKEN];
|
|
72
|
+
// Client id from plugin config, or the variable stored by the runtime's
|
|
73
|
+
// install-time OAuth flow (openbot.one plugins page).
|
|
74
|
+
const clientId = config.clientId ?? variableValue(variables, VAR_CLIENT_ID) ?? process.env[VAR_CLIENT_ID];
|
|
75
|
+
const needsRefresh = expiresAt !== undefined && Number.isFinite(expiresAt) && Date.now() > expiresAt - REFRESH_MARGIN_MS;
|
|
76
|
+
if (needsRefresh && refreshToken && clientId) {
|
|
77
|
+
try {
|
|
78
|
+
const tokens = await refreshAccessToken({
|
|
79
|
+
refreshToken,
|
|
80
|
+
clientId,
|
|
81
|
+
clientSecret: config.clientSecret,
|
|
82
|
+
});
|
|
83
|
+
await saveTokens(context, tokens);
|
|
84
|
+
return { kind: 'oauth', token: tokens.accessToken };
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Fall through and try the stored token; a 401 will tell the user to reconnect.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { kind: 'oauth', token: accessToken };
|
|
91
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Storage } from "@meetopenbot/plugin-sdk";
|
|
2
|
+
import { type OAuthTokens } from "./oauth.js";
|
|
3
|
+
export declare const VAR_API_KEY = "LINEAR_API_KEY";
|
|
4
|
+
export declare const VAR_ACCESS_TOKEN = "LINEAR_ACCESS_TOKEN";
|
|
5
|
+
export declare const VAR_REFRESH_TOKEN = "LINEAR_REFRESH_TOKEN";
|
|
6
|
+
export declare const VAR_TOKEN_EXPIRES_AT = "LINEAR_TOKEN_EXPIRES_AT";
|
|
7
|
+
export declare const VAR_CLIENT_ID = "LINEAR_CLIENT_ID";
|
|
8
|
+
export type LinearPluginConfig = {
|
|
9
|
+
clientId?: string;
|
|
10
|
+
clientSecret?: string;
|
|
11
|
+
apiKey?: string;
|
|
12
|
+
oauthPort?: number;
|
|
13
|
+
scopes?: string;
|
|
14
|
+
openaiApiKey?: string;
|
|
15
|
+
model?: string;
|
|
16
|
+
/** Public runtime base URL, e.g. https://my-host.com (used for OAuth webhook callback). */
|
|
17
|
+
webhookBaseUrl?: string;
|
|
18
|
+
};
|
|
19
|
+
export type LinearCredentials = {
|
|
20
|
+
accessToken: string;
|
|
21
|
+
openaiApiKey: string;
|
|
22
|
+
model: string;
|
|
23
|
+
clientId?: string;
|
|
24
|
+
clientSecret?: string;
|
|
25
|
+
};
|
|
26
|
+
export declare function readLinearConfig(config: Record<string, unknown>): LinearPluginConfig;
|
|
27
|
+
export declare function resolveWebhookBaseUrl(config: LinearPluginConfig, publicBaseUrl?: string): string | undefined;
|
|
28
|
+
export declare function saveTokens(storage: Storage, tokens: OAuthTokens): Promise<void>;
|
|
29
|
+
export declare function clearTokens(storage: Storage): Promise<void>;
|
|
30
|
+
export declare function formatMissingCredentials(missing: Array<"accessToken" | "openaiApiKey">): string;
|
|
31
|
+
export declare function resolveLinearCredentials(config: LinearPluginConfig, storage: Storage, options?: {
|
|
32
|
+
requireOpenAi?: boolean;
|
|
33
|
+
}): Promise<{
|
|
34
|
+
ok: true;
|
|
35
|
+
credentials: LinearCredentials;
|
|
36
|
+
} | {
|
|
37
|
+
ok: false;
|
|
38
|
+
missing: Array<"accessToken" | "openaiApiKey">;
|
|
39
|
+
}>;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { refreshAccessToken } from "./oauth.js";
|
|
2
|
+
export const VAR_API_KEY = "LINEAR_API_KEY";
|
|
3
|
+
export const VAR_ACCESS_TOKEN = "LINEAR_ACCESS_TOKEN";
|
|
4
|
+
export const VAR_REFRESH_TOKEN = "LINEAR_REFRESH_TOKEN";
|
|
5
|
+
export const VAR_TOKEN_EXPIRES_AT = "LINEAR_TOKEN_EXPIRES_AT";
|
|
6
|
+
export const VAR_CLIENT_ID = "LINEAR_CLIENT_ID";
|
|
7
|
+
const REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
|
8
|
+
function variableValue(variables, key) {
|
|
9
|
+
const entry = variables[key];
|
|
10
|
+
if (typeof entry === "string")
|
|
11
|
+
return entry || undefined;
|
|
12
|
+
return entry?.value || undefined;
|
|
13
|
+
}
|
|
14
|
+
export function readLinearConfig(config) {
|
|
15
|
+
return {
|
|
16
|
+
clientId: typeof config.clientId === "string" && config.clientId.trim()
|
|
17
|
+
? config.clientId.trim()
|
|
18
|
+
: undefined,
|
|
19
|
+
clientSecret: typeof config.clientSecret === "string" && config.clientSecret.trim()
|
|
20
|
+
? config.clientSecret.trim()
|
|
21
|
+
: undefined,
|
|
22
|
+
apiKey: typeof config.apiKey === "string" && config.apiKey.trim()
|
|
23
|
+
? config.apiKey.trim()
|
|
24
|
+
: undefined,
|
|
25
|
+
oauthPort: typeof config.oauthPort === "number" ? config.oauthPort : 4137,
|
|
26
|
+
scopes: typeof config.scopes === "string" && config.scopes.trim()
|
|
27
|
+
? config.scopes.trim()
|
|
28
|
+
: "read,write,issues:create,comments:create",
|
|
29
|
+
openaiApiKey: typeof config.openaiApiKey === "string" && config.openaiApiKey.trim()
|
|
30
|
+
? config.openaiApiKey.trim()
|
|
31
|
+
: undefined,
|
|
32
|
+
model: typeof config.model === "string" && config.model.trim()
|
|
33
|
+
? config.model.trim()
|
|
34
|
+
: undefined,
|
|
35
|
+
webhookBaseUrl: typeof config.webhookBaseUrl === "string" && config.webhookBaseUrl.trim()
|
|
36
|
+
? config.webhookBaseUrl.trim()
|
|
37
|
+
: undefined,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function resolveWebhookBaseUrl(config, publicBaseUrl) {
|
|
41
|
+
const explicit = config.webhookBaseUrl?.replace(/\/$/, "");
|
|
42
|
+
if (explicit)
|
|
43
|
+
return explicit;
|
|
44
|
+
const host = publicBaseUrl?.trim().replace(/\/$/, "");
|
|
45
|
+
if (host)
|
|
46
|
+
return host;
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
export async function saveTokens(storage, tokens) {
|
|
50
|
+
await storage.createVariable({
|
|
51
|
+
key: VAR_ACCESS_TOKEN,
|
|
52
|
+
value: tokens.accessToken,
|
|
53
|
+
secret: true,
|
|
54
|
+
});
|
|
55
|
+
if (tokens.refreshToken) {
|
|
56
|
+
await storage.createVariable({
|
|
57
|
+
key: VAR_REFRESH_TOKEN,
|
|
58
|
+
value: tokens.refreshToken,
|
|
59
|
+
secret: true,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (tokens.expiresAt) {
|
|
63
|
+
await storage.createVariable({
|
|
64
|
+
key: VAR_TOKEN_EXPIRES_AT,
|
|
65
|
+
value: String(tokens.expiresAt),
|
|
66
|
+
secret: false,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export async function clearTokens(storage) {
|
|
71
|
+
for (const key of [VAR_ACCESS_TOKEN, VAR_REFRESH_TOKEN, VAR_TOKEN_EXPIRES_AT]) {
|
|
72
|
+
await storage.deleteVariable({ key }).catch(() => { });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function formatMissingCredentials(missing) {
|
|
76
|
+
const lines = [
|
|
77
|
+
"Linear agent setup is incomplete. Configure the following in plugin config or environment variables:",
|
|
78
|
+
];
|
|
79
|
+
if (missing.includes("accessToken")) {
|
|
80
|
+
lines.push("- Connect Linear with `linear_connect`, or set `apiKey` / `LINEAR_API_KEY`");
|
|
81
|
+
}
|
|
82
|
+
if (missing.includes("openaiApiKey")) {
|
|
83
|
+
lines.push("- `openaiApiKey` / `OPENAI_API_KEY` — OpenAI API key for the agent loop");
|
|
84
|
+
}
|
|
85
|
+
return lines.join("\n");
|
|
86
|
+
}
|
|
87
|
+
export async function resolveLinearCredentials(config, storage, options) {
|
|
88
|
+
const requireOpenAi = options?.requireOpenAi ?? true;
|
|
89
|
+
const variables = (await storage.getVariables().catch(() => ({})));
|
|
90
|
+
const resolve = (configKey, envKey) => {
|
|
91
|
+
const fromConfig = config[configKey];
|
|
92
|
+
if (typeof fromConfig === "string" && fromConfig.trim()) {
|
|
93
|
+
return fromConfig.trim();
|
|
94
|
+
}
|
|
95
|
+
if (process.env[envKey]?.trim())
|
|
96
|
+
return process.env[envKey].trim();
|
|
97
|
+
return variableValue(variables, envKey)?.trim();
|
|
98
|
+
};
|
|
99
|
+
const openaiApiKey = resolve("openaiApiKey", "OPENAI_API_KEY");
|
|
100
|
+
const model = resolve("model", "OPENAI_MODEL") ?? "gpt-4o-mini";
|
|
101
|
+
let accessToken = config.apiKey ??
|
|
102
|
+
variableValue(variables, VAR_API_KEY) ??
|
|
103
|
+
process.env[VAR_API_KEY] ??
|
|
104
|
+
variableValue(variables, VAR_ACCESS_TOKEN) ??
|
|
105
|
+
process.env[VAR_ACCESS_TOKEN];
|
|
106
|
+
const clientId = config.clientId ??
|
|
107
|
+
variableValue(variables, VAR_CLIENT_ID) ??
|
|
108
|
+
process.env[VAR_CLIENT_ID];
|
|
109
|
+
const clientSecret = config.clientSecret;
|
|
110
|
+
if (accessToken && !config.apiKey) {
|
|
111
|
+
const expiresAtRaw = variableValue(variables, VAR_TOKEN_EXPIRES_AT) ??
|
|
112
|
+
process.env[VAR_TOKEN_EXPIRES_AT];
|
|
113
|
+
const expiresAt = expiresAtRaw ? Number(expiresAtRaw) : undefined;
|
|
114
|
+
const refreshToken = variableValue(variables, VAR_REFRESH_TOKEN) ??
|
|
115
|
+
process.env[VAR_REFRESH_TOKEN];
|
|
116
|
+
const needsRefresh = expiresAt !== undefined &&
|
|
117
|
+
Number.isFinite(expiresAt) &&
|
|
118
|
+
Date.now() > expiresAt - REFRESH_MARGIN_MS;
|
|
119
|
+
if (needsRefresh && refreshToken && clientId) {
|
|
120
|
+
try {
|
|
121
|
+
const tokens = await refreshAccessToken({
|
|
122
|
+
refreshToken,
|
|
123
|
+
clientId,
|
|
124
|
+
clientSecret,
|
|
125
|
+
});
|
|
126
|
+
await saveTokens(storage, tokens);
|
|
127
|
+
accessToken = tokens.accessToken;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// Fall through; a 401 from Linear will prompt reconnect.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const missing = [];
|
|
135
|
+
if (!accessToken)
|
|
136
|
+
missing.push("accessToken");
|
|
137
|
+
if (requireOpenAi && !openaiApiKey)
|
|
138
|
+
missing.push("openaiApiKey");
|
|
139
|
+
if (missing.length > 0) {
|
|
140
|
+
return { ok: false, missing };
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
ok: true,
|
|
144
|
+
credentials: {
|
|
145
|
+
accessToken: accessToken,
|
|
146
|
+
openaiApiKey: openaiApiKey ?? "",
|
|
147
|
+
model,
|
|
148
|
+
clientId,
|
|
149
|
+
clientSecret,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type PluginContext, type ToolDefinition } from "@meetopenbot/plugin-sdk";
|
|
2
|
+
declare const _default: {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
description: string;
|
|
6
|
+
configSchema: {
|
|
7
|
+
type: "object";
|
|
8
|
+
properties: {
|
|
9
|
+
clientId: {
|
|
10
|
+
type: "string";
|
|
11
|
+
description: string;
|
|
12
|
+
};
|
|
13
|
+
clientSecret: {
|
|
14
|
+
type: "string";
|
|
15
|
+
description: string;
|
|
16
|
+
format: "password";
|
|
17
|
+
};
|
|
18
|
+
apiKey: {
|
|
19
|
+
type: "string";
|
|
20
|
+
description: string;
|
|
21
|
+
format: "password";
|
|
22
|
+
};
|
|
23
|
+
oauthPort: {
|
|
24
|
+
type: "number";
|
|
25
|
+
description: string;
|
|
26
|
+
default: number;
|
|
27
|
+
};
|
|
28
|
+
scopes: {
|
|
29
|
+
type: "string";
|
|
30
|
+
description: string;
|
|
31
|
+
default: string;
|
|
32
|
+
};
|
|
33
|
+
webhookBaseUrl: {
|
|
34
|
+
type: "string";
|
|
35
|
+
description: string;
|
|
36
|
+
format: "url";
|
|
37
|
+
};
|
|
38
|
+
openaiApiKey: {
|
|
39
|
+
type: "string";
|
|
40
|
+
description: string;
|
|
41
|
+
format: "password";
|
|
42
|
+
};
|
|
43
|
+
model: {
|
|
44
|
+
type: "string";
|
|
45
|
+
description: string;
|
|
46
|
+
default: string;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
toolDefinitions: Record<string, ToolDefinition>;
|
|
51
|
+
factory: (pluginContext: PluginContext) => (builder: import("melony").MelonyBuilder<import("@meetopenbot/plugin-sdk").OpenBotState, import("@meetopenbot/plugin-sdk").OpenBotEvent>) => void;
|
|
52
|
+
};
|
|
53
|
+
export default _default;
|