@meetopenbot/linear 0.0.3 → 0.0.4
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 +17 -60
- package/dist/cloud-mode.js +10 -0
- package/dist/config.js +26 -109
- package/dist/credits-auth.js +53 -0
- package/dist/index.js +60 -262
- package/dist/linear-agent.js +142 -61
- package/dist/linear-mcp.js +9 -21
- package/dist/mcp-errors.js +40 -0
- package/dist/mcp-tool-args.js +38 -0
- package/dist/model-registry.js +40 -0
- package/dist/model.js +24 -0
- package/dist/thread-context.js +72 -0
- package/package.json +12 -23
- package/dist/api.d.ts +0 -94
- package/dist/api.js +0 -93
- package/dist/auth.d.ts +0 -33
- package/dist/auth.js +0 -91
- package/dist/config.d.ts +0 -41
- package/dist/index.d.ts +0 -54
- package/dist/linear-agent.d.ts +0 -14
- package/dist/linear-issues.d.ts +0 -33
- package/dist/linear-issues.js +0 -136
- package/dist/linear-mcp.d.ts +0 -4
- package/dist/oauth-pending.d.ts +0 -13
- package/dist/oauth-pending.js +0 -40
- package/dist/oauth.d.ts +0 -95
- package/dist/oauth.js +0 -381
- package/dist/tools.d.ts +0 -17
- package/dist/tools.js +0 -266
- package/src/config.ts +0 -230
- package/src/index.ts +0 -451
- package/src/linear-agent.ts +0 -107
- package/src/linear-issues.ts +0 -188
- package/src/linear-mcp.ts +0 -33
- package/src/oauth-pending.ts +0 -68
- package/src/oauth.ts +0 -572
package/README.md
CHANGED
|
@@ -1,79 +1,36 @@
|
|
|
1
1
|
# @meetopenbot/linear
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Linear specialist agent for OpenBot: MCP-backed issue, project, and comment management.
|
|
4
4
|
|
|
5
|
-
## Features
|
|
5
|
+
## Features (MVP)
|
|
6
6
|
|
|
7
|
-
- **
|
|
8
|
-
- **
|
|
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.
|
|
7
|
+
- **Agent** — OpenAI + Linear's official MCP (`https://mcp.linear.app/mcp`)
|
|
8
|
+
- **Auth** — personal API key via plugin config or `LINEAR_API_KEY` secret
|
|
11
9
|
|
|
12
|
-
##
|
|
10
|
+
## Setup
|
|
13
11
|
|
|
14
|
-
|
|
12
|
+
1. Create a personal API key at [Linear → Settings → API](https://linear.app/settings/api).
|
|
13
|
+
|
|
14
|
+
2. Add the plugin to your agent's `AGENT.md`:
|
|
15
15
|
|
|
16
16
|
```yaml
|
|
17
17
|
plugins:
|
|
18
|
-
- id:
|
|
18
|
+
- id: "@meetopenbot/linear"
|
|
19
19
|
config:
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
apiKey: lin_api_your_key_here
|
|
21
|
+
authMode: credits
|
|
22
|
+
model: openai/gpt-4o-mini
|
|
22
23
|
```
|
|
23
24
|
|
|
24
|
-
|
|
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 (overrides OpenBot's managed application) | OpenBot managed 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
|
|
25
|
+
On cloud, `authMode: credits` uses your workspace credit balance. For BYOK (bring your own key), set `authMode: byok` and add `OPENAI_API_KEY` under workspace settings.
|
|
67
26
|
|
|
68
|
-
|
|
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)
|
|
27
|
+
You can also store the API key as a secret workspace variable named `LINEAR_API_KEY` instead of putting it in plugin config.
|
|
71
28
|
|
|
72
|
-
##
|
|
29
|
+
## Build
|
|
73
30
|
|
|
74
31
|
```bash
|
|
75
32
|
npm install
|
|
76
|
-
npm run build
|
|
33
|
+
npm run build
|
|
77
34
|
```
|
|
78
35
|
|
|
79
|
-
|
|
36
|
+
Restart the OpenBot runtime to load the plugin.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** True when this runtime is a platform-managed cloud deployment. */
|
|
2
|
+
export const isCloudMode = () => process.env.OPENBOT_CLOUD_MODE === "1";
|
|
3
|
+
/** Default auth mode: Credits on cloud, BYOK locally. */
|
|
4
|
+
export const defaultAuthMode = () => isCloudMode() ? "credits" : "byok";
|
|
5
|
+
export function resolveAuthMode(config) {
|
|
6
|
+
if (config.authMode === "byok" || config.authMode === "credits") {
|
|
7
|
+
return config.authMode;
|
|
8
|
+
}
|
|
9
|
+
return defaultAuthMode();
|
|
10
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -1,12 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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
|
-
export const GO_BACK_TO_OPENBOT_URL = "https://openbot.one/settings/agents";
|
|
8
|
-
export const LINEAR_OPENBOT_CLIENT_ID = "1263fc2dbbf08efa7b2c4b1b2b2582be";
|
|
9
|
-
const REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
|
1
|
+
import { resolveAuthMode } from "./cloud-mode.js";
|
|
2
|
+
import { shouldUseCreditsAuth } from "./credits-auth.js";
|
|
10
3
|
function variableValue(variables, key) {
|
|
11
4
|
const entry = variables[key];
|
|
12
5
|
if (typeof entry === "string")
|
|
@@ -15,79 +8,35 @@ function variableValue(variables, key) {
|
|
|
15
8
|
}
|
|
16
9
|
export function readLinearConfig(config) {
|
|
17
10
|
return {
|
|
18
|
-
clientId: typeof config.clientId === "string" && config.clientId.trim()
|
|
19
|
-
? config.clientId.trim()
|
|
20
|
-
: LINEAR_OPENBOT_CLIENT_ID,
|
|
21
|
-
clientSecret: typeof config.clientSecret === "string" && config.clientSecret.trim()
|
|
22
|
-
? config.clientSecret.trim()
|
|
23
|
-
: undefined,
|
|
24
11
|
apiKey: typeof config.apiKey === "string" && config.apiKey.trim()
|
|
25
12
|
? config.apiKey.trim()
|
|
26
13
|
: undefined,
|
|
27
|
-
|
|
28
|
-
scopes: typeof config.scopes === "string" && config.scopes.trim()
|
|
29
|
-
? config.scopes.trim()
|
|
30
|
-
: "read,write,issues:create,comments:create",
|
|
31
|
-
openaiApiKey: typeof config.openaiApiKey === "string" && config.openaiApiKey.trim()
|
|
32
|
-
? config.openaiApiKey.trim()
|
|
33
|
-
: undefined,
|
|
14
|
+
authMode: resolveAuthMode(config),
|
|
34
15
|
model: typeof config.model === "string" && config.model.trim()
|
|
35
16
|
? config.model.trim()
|
|
36
17
|
: undefined,
|
|
37
|
-
webhookBaseUrl: typeof config.webhookBaseUrl === "string" && config.webhookBaseUrl.trim()
|
|
38
|
-
? config.webhookBaseUrl.trim()
|
|
39
|
-
: undefined,
|
|
40
18
|
};
|
|
41
19
|
}
|
|
42
|
-
export function
|
|
43
|
-
const explicit = config.webhookBaseUrl?.replace(/\/$/, "");
|
|
44
|
-
if (explicit)
|
|
45
|
-
return explicit;
|
|
46
|
-
const host = publicBaseUrl?.trim().replace(/\/$/, "");
|
|
47
|
-
if (host)
|
|
48
|
-
return host;
|
|
49
|
-
return undefined;
|
|
50
|
-
}
|
|
51
|
-
export async function saveTokens(storage, tokens) {
|
|
52
|
-
await storage.createVariable({
|
|
53
|
-
key: VAR_ACCESS_TOKEN,
|
|
54
|
-
value: tokens.accessToken,
|
|
55
|
-
secret: true,
|
|
56
|
-
});
|
|
57
|
-
if (tokens.refreshToken) {
|
|
58
|
-
await storage.createVariable({
|
|
59
|
-
key: VAR_REFRESH_TOKEN,
|
|
60
|
-
value: tokens.refreshToken,
|
|
61
|
-
secret: true,
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
if (tokens.expiresAt) {
|
|
65
|
-
await storage.createVariable({
|
|
66
|
-
key: VAR_TOKEN_EXPIRES_AT,
|
|
67
|
-
value: String(tokens.expiresAt),
|
|
68
|
-
secret: false,
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
export async function clearTokens(storage) {
|
|
73
|
-
for (const key of [VAR_ACCESS_TOKEN, VAR_REFRESH_TOKEN, VAR_TOKEN_EXPIRES_AT]) {
|
|
74
|
-
await storage.deleteVariable({ key }).catch(() => { });
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
export function formatMissingCredentials(missing) {
|
|
20
|
+
export function formatMissingCredentials(missing, authMode) {
|
|
78
21
|
const lines = [
|
|
79
|
-
"Linear agent setup is incomplete. Configure the following in plugin config or environment variables:",
|
|
22
|
+
"Linear agent setup is incomplete. Configure the following in plugin config, workspace settings, or environment variables:",
|
|
80
23
|
];
|
|
81
|
-
if (missing.includes("
|
|
82
|
-
lines.push("-
|
|
24
|
+
if (missing.includes("apiKey")) {
|
|
25
|
+
lines.push("- `apiKey` / `LINEAR_API_KEY` — Linear personal API key (Settings → Account → Security & Access → Personal API keys)");
|
|
83
26
|
}
|
|
84
27
|
if (missing.includes("openaiApiKey")) {
|
|
85
|
-
|
|
28
|
+
if (authMode === "credits") {
|
|
29
|
+
lines.push("- OpenAI API key is required in BYOK mode — add `OPENAI_API_KEY` under workspace settings, or switch `authMode` to `credits` on cloud");
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
lines.push("- `OPENAI_API_KEY` — OpenAI API key for the agent loop (BYOK mode), or switch `authMode` to `credits` on cloud");
|
|
33
|
+
}
|
|
86
34
|
}
|
|
87
35
|
return lines.join("\n");
|
|
88
36
|
}
|
|
89
|
-
export async function resolveLinearCredentials(config, storage
|
|
90
|
-
const
|
|
37
|
+
export async function resolveLinearCredentials(config, storage) {
|
|
38
|
+
const authMode = config.authMode ?? resolveAuthMode({});
|
|
39
|
+
const useCredits = shouldUseCreditsAuth({ authMode });
|
|
91
40
|
const variables = (await storage.getVariables().catch(() => ({})));
|
|
92
41
|
const resolve = (configKey, envKey) => {
|
|
93
42
|
const fromConfig = config[configKey];
|
|
@@ -98,45 +47,14 @@ export async function resolveLinearCredentials(config, storage, options) {
|
|
|
98
47
|
return process.env[envKey].trim();
|
|
99
48
|
return variableValue(variables, envKey)?.trim();
|
|
100
49
|
};
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
process.env[VAR_API_KEY] ??
|
|
106
|
-
variableValue(variables, VAR_ACCESS_TOKEN) ??
|
|
107
|
-
process.env[VAR_ACCESS_TOKEN];
|
|
108
|
-
const clientId = config.clientId ??
|
|
109
|
-
variableValue(variables, VAR_CLIENT_ID) ??
|
|
110
|
-
process.env[VAR_CLIENT_ID];
|
|
111
|
-
const clientSecret = config.clientSecret;
|
|
112
|
-
if (accessToken && !config.apiKey) {
|
|
113
|
-
const expiresAtRaw = variableValue(variables, VAR_TOKEN_EXPIRES_AT) ??
|
|
114
|
-
process.env[VAR_TOKEN_EXPIRES_AT];
|
|
115
|
-
const expiresAt = expiresAtRaw ? Number(expiresAtRaw) : undefined;
|
|
116
|
-
const refreshToken = variableValue(variables, VAR_REFRESH_TOKEN) ??
|
|
117
|
-
process.env[VAR_REFRESH_TOKEN];
|
|
118
|
-
const needsRefresh = expiresAt !== undefined &&
|
|
119
|
-
Number.isFinite(expiresAt) &&
|
|
120
|
-
Date.now() > expiresAt - REFRESH_MARGIN_MS;
|
|
121
|
-
if (needsRefresh && refreshToken && clientId) {
|
|
122
|
-
try {
|
|
123
|
-
const tokens = await refreshAccessToken({
|
|
124
|
-
refreshToken,
|
|
125
|
-
clientId,
|
|
126
|
-
clientSecret,
|
|
127
|
-
});
|
|
128
|
-
await saveTokens(storage, tokens);
|
|
129
|
-
accessToken = tokens.accessToken;
|
|
130
|
-
}
|
|
131
|
-
catch {
|
|
132
|
-
// Fall through; a 401 from Linear will prompt reconnect.
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
50
|
+
const apiKey = resolve("apiKey", "LINEAR_API_KEY");
|
|
51
|
+
const openaiApiKey = process.env.OPENAI_API_KEY?.trim() ||
|
|
52
|
+
variableValue(variables, "OPENAI_API_KEY");
|
|
53
|
+
const model = resolve("model", "OPENAI_MODEL") ?? "openai/gpt-4o-mini";
|
|
136
54
|
const missing = [];
|
|
137
|
-
if (!
|
|
138
|
-
missing.push("
|
|
139
|
-
if (
|
|
55
|
+
if (!apiKey)
|
|
56
|
+
missing.push("apiKey");
|
|
57
|
+
if (!useCredits && !openaiApiKey)
|
|
140
58
|
missing.push("openaiApiKey");
|
|
141
59
|
if (missing.length > 0) {
|
|
142
60
|
return { ok: false, missing };
|
|
@@ -144,11 +62,10 @@ export async function resolveLinearCredentials(config, storage, options) {
|
|
|
144
62
|
return {
|
|
145
63
|
ok: true,
|
|
146
64
|
credentials: {
|
|
147
|
-
|
|
148
|
-
|
|
65
|
+
apiKey: apiKey,
|
|
66
|
+
authMode,
|
|
67
|
+
openaiApiKey: openaiApiKey || undefined,
|
|
149
68
|
model,
|
|
150
|
-
clientId,
|
|
151
|
-
clientSecret,
|
|
152
69
|
},
|
|
153
70
|
};
|
|
154
71
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { isCloudMode } from "./cloud-mode.js";
|
|
2
|
+
export const INTEGRATIONS_TOKEN_HEADER = "x-openbot-integrations-token";
|
|
3
|
+
export const CREDITS_API_KEY_PLACEHOLDER = "openbot-credits";
|
|
4
|
+
/** Cloud host injects these when routing LLM calls through OpenBot Credits. */
|
|
5
|
+
export function resolveCreditsAuthConfig() {
|
|
6
|
+
const baseUrl = process.env.OPENBOT_INTEGRATIONS_BASE_URL?.trim();
|
|
7
|
+
const token = process.env.OPENBOT_INTEGRATIONS_TOKEN?.trim();
|
|
8
|
+
if (!baseUrl || !token)
|
|
9
|
+
return undefined;
|
|
10
|
+
return { baseUrl: baseUrl.replace(/\/$/, ""), token };
|
|
11
|
+
}
|
|
12
|
+
export function creditsProviderBaseUrl(config) {
|
|
13
|
+
return `${config.baseUrl}/openai/v1`;
|
|
14
|
+
}
|
|
15
|
+
export function shouldUseCreditsAuth(options) {
|
|
16
|
+
if (options?.authMode === "byok")
|
|
17
|
+
return false;
|
|
18
|
+
if (options?.authMode === "credits")
|
|
19
|
+
return true;
|
|
20
|
+
return isCloudMode() && resolveCreditsAuthConfig() !== undefined;
|
|
21
|
+
}
|
|
22
|
+
export function isCreditsErrorMessage(message) {
|
|
23
|
+
const lower = message.toLowerCase();
|
|
24
|
+
return (lower.includes("insufficient_credits") ||
|
|
25
|
+
lower.includes("insufficient credits") ||
|
|
26
|
+
lower.includes("402"));
|
|
27
|
+
}
|
|
28
|
+
export function isAuthErrorMessage(message) {
|
|
29
|
+
const lower = message.toLowerCase();
|
|
30
|
+
return (lower.includes("api key") ||
|
|
31
|
+
lower.includes("401") ||
|
|
32
|
+
lower.includes("unauthorized") ||
|
|
33
|
+
lower.includes("authentication"));
|
|
34
|
+
}
|
|
35
|
+
export function isIntegrationsProviderError(message) {
|
|
36
|
+
const lower = message.toLowerCase();
|
|
37
|
+
return (lower.includes("provider api key not configured") ||
|
|
38
|
+
(lower.includes("503") && lower.includes("provider")));
|
|
39
|
+
}
|
|
40
|
+
export const CREDITS_NOT_CONFIGURED_MESSAGE = "OpenBot Credits is not configured on this runtime. The cloud host must set OPENBOT_INTEGRATIONS_BASE_URL and OPENBOT_INTEGRATIONS_TOKEN (try redeploying the workspace).";
|
|
41
|
+
export const CREDITS_PROVIDER_UNAVAILABLE_MESSAGE = "OpenBot Credits could not reach OpenAI — the platform provider API key is not configured yet. Try again later or switch this agent to BYOK mode.";
|
|
42
|
+
export const CREDITS_AUTH_FAILED_MESSAGE = "Linear could not authenticate via OpenBot Credits. Check your workspace credit balance in settings, or switch this agent to BYOK mode.";
|
|
43
|
+
export function creditsErrorMessage(message) {
|
|
44
|
+
if (isIntegrationsProviderError(message)) {
|
|
45
|
+
return CREDITS_PROVIDER_UNAVAILABLE_MESSAGE;
|
|
46
|
+
}
|
|
47
|
+
if (isCreditsErrorMessage(message)) {
|
|
48
|
+
return "Insufficient workspace credits. Add credits in workspace settings or switch this agent to BYOK mode.";
|
|
49
|
+
}
|
|
50
|
+
if (isAuthErrorMessage(message))
|
|
51
|
+
return CREDITS_AUTH_FAILED_MESSAGE;
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|