@meetopenbot/linear 0.0.2 → 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 CHANGED
@@ -1,79 +1,36 @@
1
1
  # @meetopenbot/linear
2
2
 
3
- Connect [Linear](https://linear.app) to OpenBot. OAuth for setup, MCP-backed agent for issues, projects, and comments.
3
+ Linear specialist agent for OpenBot: MCP-backed issue, project, and comment management.
4
4
 
5
- ## Features
5
+ ## Features (MVP)
6
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.
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
- ## Install
10
+ ## Setup
13
11
 
14
- Install from the OpenBot plugin settings (package name `@meetopenbot/linear`), or add it to an agent's `AGENT.md`:
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: '@meetopenbot/linear'
18
+ - id: "@meetopenbot/linear"
19
19
  config:
20
- clientId: 'your-linear-oauth-client-id'
21
- openaiApiKey: 'your-openai-api-key'
20
+ apiKey: lin_api_your_key_here
21
+ authMode: credits
22
+ model: openai/gpt-4o-mini
22
23
  ```
23
24
 
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
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
- 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)
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
- ## Development
29
+ ## Build
73
30
 
74
31
  ```bash
75
32
  npm install
76
- npm run build # emits dist/ (the runtime loads dist/index.js)
33
+ npm run build
77
34
  ```
78
35
 
79
- For local testing, copy or link the package into `~/.openbot/plugins/@meetopenbot/linear/` and attach it to an agent.
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,10 +1,5 @@
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;
1
+ import { resolveAuthMode } from "./cloud-mode.js";
2
+ import { shouldUseCreditsAuth } from "./credits-auth.js";
8
3
  function variableValue(variables, key) {
9
4
  const entry = variables[key];
10
5
  if (typeof entry === "string")
@@ -13,79 +8,35 @@ function variableValue(variables, key) {
13
8
  }
14
9
  export function readLinearConfig(config) {
15
10
  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
11
  apiKey: typeof config.apiKey === "string" && config.apiKey.trim()
23
12
  ? config.apiKey.trim()
24
13
  : 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,
14
+ authMode: resolveAuthMode(config),
32
15
  model: typeof config.model === "string" && config.model.trim()
33
16
  ? config.model.trim()
34
17
  : undefined,
35
- webhookBaseUrl: typeof config.webhookBaseUrl === "string" && config.webhookBaseUrl.trim()
36
- ? config.webhookBaseUrl.trim()
37
- : undefined,
38
18
  };
39
19
  }
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) {
20
+ export function formatMissingCredentials(missing, authMode) {
76
21
  const lines = [
77
- "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:",
78
23
  ];
79
- if (missing.includes("accessToken")) {
80
- lines.push("- Connect Linear with `linear_connect`, or set `apiKey` / `LINEAR_API_KEY`");
24
+ if (missing.includes("apiKey")) {
25
+ lines.push("- `apiKey` / `LINEAR_API_KEY` — Linear personal API key (Settings Account → Security & Access → Personal API keys)");
81
26
  }
82
27
  if (missing.includes("openaiApiKey")) {
83
- lines.push("- `openaiApiKey` / `OPENAI_API_KEY` — OpenAI API key for the agent loop");
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
+ }
84
34
  }
85
35
  return lines.join("\n");
86
36
  }
87
- export async function resolveLinearCredentials(config, storage, options) {
88
- const requireOpenAi = options?.requireOpenAi ?? true;
37
+ export async function resolveLinearCredentials(config, storage) {
38
+ const authMode = config.authMode ?? resolveAuthMode({});
39
+ const useCredits = shouldUseCreditsAuth({ authMode });
89
40
  const variables = (await storage.getVariables().catch(() => ({})));
90
41
  const resolve = (configKey, envKey) => {
91
42
  const fromConfig = config[configKey];
@@ -96,45 +47,14 @@ export async function resolveLinearCredentials(config, storage, options) {
96
47
  return process.env[envKey].trim();
97
48
  return variableValue(variables, envKey)?.trim();
98
49
  };
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
- }
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";
134
54
  const missing = [];
135
- if (!accessToken)
136
- missing.push("accessToken");
137
- if (requireOpenAi && !openaiApiKey)
55
+ if (!apiKey)
56
+ missing.push("apiKey");
57
+ if (!useCredits && !openaiApiKey)
138
58
  missing.push("openaiApiKey");
139
59
  if (missing.length > 0) {
140
60
  return { ok: false, missing };
@@ -142,11 +62,10 @@ export async function resolveLinearCredentials(config, storage, options) {
142
62
  return {
143
63
  ok: true,
144
64
  credentials: {
145
- accessToken: accessToken,
146
- openaiApiKey: openaiApiKey ?? "",
65
+ apiKey: apiKey,
66
+ authMode,
67
+ openaiApiKey: openaiApiKey || undefined,
147
68
  model,
148
- clientId,
149
- clientSecret,
150
69
  },
151
70
  };
152
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
+ }