runline 0.8.0 → 0.9.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.
Files changed (34) hide show
  1. package/dist/commands/auth.js +4 -2
  2. package/dist/config/loader.d.ts +2 -4
  3. package/dist/config/loader.js +2 -1
  4. package/dist/core/engine.js +18 -13
  5. package/dist/index.d.ts +2 -2
  6. package/dist/plugin/api.d.ts +2 -9
  7. package/dist/plugin/api.js +1 -4
  8. package/dist/plugin/loader.js +41 -25
  9. package/dist/plugin/schema.d.ts +19 -0
  10. package/dist/plugin/schema.js +168 -0
  11. package/dist/plugin/types.d.ts +22 -8
  12. package/dist/plugins/gmail/src/index.js +14 -2
  13. package/dist/plugins/linear/src/attachments.js +87 -0
  14. package/dist/plugins/linear/src/comments.js +64 -0
  15. package/dist/plugins/linear/src/cycles.js +42 -0
  16. package/dist/plugins/linear/src/index.js +35 -1153
  17. package/dist/plugins/linear/src/initiatives.js +84 -0
  18. package/dist/plugins/linear/src/issues.js +267 -0
  19. package/dist/plugins/linear/src/labels.js +74 -0
  20. package/dist/plugins/linear/src/organization.js +13 -0
  21. package/dist/plugins/linear/src/projects.js +200 -0
  22. package/dist/plugins/linear/src/shared.js +234 -0
  23. package/dist/plugins/linear/src/states.js +41 -0
  24. package/dist/plugins/linear/src/teams.js +77 -0
  25. package/dist/plugins/linear/src/users.js +37 -0
  26. package/dist/plugins/linear/src/views.js +105 -0
  27. package/dist/plugins/linear/src/webhooks.js +61 -0
  28. package/dist/plugins/vercel/src/account.js +11 -0
  29. package/dist/plugins/vercel/src/deployments.js +79 -0
  30. package/dist/plugins/vercel/src/env.js +101 -0
  31. package/dist/plugins/vercel/src/index.js +27 -0
  32. package/dist/plugins/vercel/src/projects.js +29 -0
  33. package/dist/plugins/vercel/src/shared.js +73 -0
  34. package/package.json +3 -2
@@ -5,6 +5,7 @@ import { addConnection } from "../config/loader.js";
5
5
  import { OAUTH_CALLBACK_PORT, runOAuth } from "../core/oauth.js";
6
6
  import { loadAllPlugins } from "../plugin/loader.js";
7
7
  import { registry } from "../plugin/registry.js";
8
+ import { connectionFields } from "../plugin/schema.js";
8
9
  import { printError, printJson, printSuccess } from "../utils/output.js";
9
10
  export async function auth(plugin, options) {
10
11
  await loadAllPlugins();
@@ -20,8 +21,9 @@ export async function auth(plugin, options) {
20
21
  // Client credentials: CLI flag > env > interactive prompt.
21
22
  // Env var names follow the plugin's own convention when declared
22
23
  // on its connection schema; fall back to generic names otherwise.
23
- const envIdVar = def.connectionConfigSchema?.clientId?.env;
24
- const envSecretVar = def.connectionConfigSchema?.clientSecret?.env;
24
+ const connectionSchema = connectionFields(def.connectionConfigSchema);
25
+ const envIdVar = connectionSchema.clientId?.env;
26
+ const envSecretVar = connectionSchema.clientSecret?.env;
25
27
  const resolvedClientId = options.clientId ?? (envIdVar ? process.env[envIdVar] : undefined);
26
28
  const resolvedClientSecret = options.clientSecret ??
27
29
  (envSecretVar ? process.env[envSecretVar] : undefined);
@@ -1,4 +1,4 @@
1
- import type { ConnectionConfig } from "../plugin/types.js";
1
+ import type { ConnectionConfig, ConnectionSchema } from "../plugin/types.js";
2
2
  import { type RunlineConfig } from "./types.js";
3
3
  export declare function findConfigDir(): string | null;
4
4
  export declare function loadConfig(): RunlineConfig;
@@ -17,6 +17,4 @@ export declare function removeConnection(name: string): boolean;
17
17
  */
18
18
  export declare function updateConnectionConfig(name: string, patch: Record<string, unknown>): Promise<void>;
19
19
  export declare function getConnection(plugin: string, name?: string): ConnectionConfig | undefined;
20
- export declare function applyEnvOverrides(conn: ConnectionConfig, schema?: Record<string, {
21
- env?: string;
22
- }>): ConnectionConfig;
20
+ export declare function applyEnvOverrides(conn: ConnectionConfig, schema?: ConnectionSchema): ConnectionConfig;
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import lockfile from "proper-lockfile";
4
+ import { connectionFields } from "../plugin/schema.js";
4
5
  import { DEFAULT_CONFIG } from "./types.js";
5
6
  const CONFIG_DIR_NAME = ".runline";
6
7
  const CONFIG_FILE = "config.json";
@@ -117,7 +118,7 @@ export function applyEnvOverrides(conn, schema) {
117
118
  if (!schema)
118
119
  return conn;
119
120
  const config = { ...conn.config };
120
- for (const [key, field] of Object.entries(schema)) {
121
+ for (const [key, field] of Object.entries(connectionFields(schema))) {
121
122
  if (field.env && !config[key]) {
122
123
  const envVal = process.env[field.env];
123
124
  if (envVal)
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { getQuickJS, shouldInterruptAfterDeadline, } from "quickjs-emscripten";
3
3
  import { applyEnvOverrides, updateConnectionConfig } from "../config/loader.js";
4
+ import { formatValidationError, helpInputs, isTypedInputSchema, validateTypedInput, } from "../plugin/schema.js";
4
5
  export class ExecutionEngine {
5
6
  registry;
6
7
  config;
@@ -146,6 +147,12 @@ export class ExecutionEngine {
146
147
  await updateConnectionConfig(connection.name, patch);
147
148
  },
148
149
  };
150
+ if (isTypedInputSchema(action.inputSchema)) {
151
+ const validation = validateTypedInput(action.inputSchema, args);
152
+ if (!validation.ok) {
153
+ throw new Error(formatValidationError(path, validation));
154
+ }
155
+ }
149
156
  return action.execute(args, ctx);
150
157
  }
151
158
  resolveConnection(plugin) {
@@ -230,14 +237,7 @@ function buildHelpData(plugins) {
230
237
  data[p.name] = p.actions.map((a) => ({
231
238
  action: a.name,
232
239
  description: a.description,
233
- inputs: Object.fromEntries(Object.entries(a.inputSchema ?? {}).map(([k, v]) => [
234
- k,
235
- {
236
- type: v.type,
237
- required: !!v.required,
238
- description: v.description,
239
- },
240
- ])),
240
+ inputs: helpInputs(a.inputSchema),
241
241
  }));
242
242
  }
243
243
  return data;
@@ -291,7 +291,7 @@ const __index = (() => {
291
291
 
292
292
  const __formatSignature = (plugin, entry) => {
293
293
  const fields = Object.entries(entry.inputs || {})
294
- .map(([k, v]) => k + (v.required ? '' : '?') + ': ' + v.type)
294
+ .map(([k, v]) => k + (v.required ? '' : '?') + ': ' + (v.displayType || v.type))
295
295
  .join(', ');
296
296
  return plugin + '.' + entry.action + (fields ? '({ ' + fields + ' })' : '()');
297
297
  };
@@ -370,10 +370,15 @@ const __actionsApi = {
370
370
  for (const k of Object.keys(provided)) {
371
371
  if (!(k in inputs)) unknown.push(k);
372
372
  else {
373
- const expected = inputs[k].type;
374
- const actual = Array.isArray(provided[k]) ? 'array' : typeof provided[k];
375
- if (expected !== actual && !(provided[k] === null || provided[k] === undefined)) {
376
- typeErrors.push({ field: k, expected, actual });
373
+ const spec = inputs[k];
374
+ const expected = spec.type;
375
+ const actual = Array.isArray(provided[k]) ? 'array' : provided[k] === null ? 'null' : typeof provided[k];
376
+ if (provided[k] !== null && provided[k] !== undefined && expected !== actual) {
377
+ typeErrors.push({ field: k, expected: spec.displayType || expected, actual });
378
+ } else if (spec.enum && !spec.enum.includes(provided[k])) {
379
+ typeErrors.push({ field: k, expected: spec.enum.map(String).join(' | '), actual: __fmt(provided[k]) });
380
+ } else if ('const' in spec && provided[k] !== spec.const) {
381
+ typeErrors.push({ field: k, expected: __fmt(spec.const), actual: __fmt(provided[k]) });
377
382
  }
378
383
  }
379
384
  }
package/dist/index.d.ts CHANGED
@@ -5,13 +5,13 @@ export type { EngineOptions, ExecuteResult } from "./core/engine.js";
5
5
  export { ExecutionEngine } from "./core/engine.js";
6
6
  export type { BuildAuthUrlOptions, ExchangeCodeOptions, OAuthTokens, PKCEPair, RunOAuthOptions, } from "./core/oauth.js";
7
7
  export { buildAuthUrl, exchangeAuthCode, generatePKCE, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_URI, runOAuth, } from "./core/oauth.js";
8
- export type { ActionDefinition, PluginFunction, RunlinePluginAPI, SchemaField, } from "./plugin/api.js";
8
+ export type { ActionDefinition, PluginFunction, RunlinePluginAPI, } from "./plugin/api.js";
9
9
  export { createPluginAPI, isPluginFunction, resolvePluginExport, } from "./plugin/api.js";
10
10
  export type { InstalledPlugin, PluginSource } from "./plugin/installer.js";
11
11
  export { installPlugin, listInstalled, parsePluginSource, removePlugin, } from "./plugin/installer.js";
12
12
  export { discoverPlugins, loadAllPlugins, loadPluginFromPath, loadPluginsFromConfig, } from "./plugin/loader.js";
13
13
  export { PluginRegistry, registry } from "./plugin/registry.js";
14
- export type { ActionContext, ActionDef, ConnectionConfig, InputField, InputSchema, OAuthConfig, PluginDef, } from "./plugin/types.js";
14
+ export type { ActionContext, ActionDef, ConnectionConfig, ConnectionSchema, ConnectionSchemaField, InputField, InputSchema, LegacyConnectionSchema, OAuthConfig, PluginDef, } from "./plugin/types.js";
15
15
  export type { RunlineOptions } from "./sdk.js";
16
16
  export { Runline } from "./sdk.js";
17
17
  export type { ExecOptions, ExecResult, OutputParser } from "./utils/cli.js";
@@ -1,11 +1,4 @@
1
- import type { ActionDef, InputSchema, OAuthConfig, PluginDef } from "./types.js";
2
- export interface SchemaField {
3
- type: "string" | "number" | "boolean";
4
- required?: boolean;
5
- description?: string;
6
- default?: unknown;
7
- env?: string;
8
- }
1
+ import type { ActionDef, ConnectionSchema, InputSchema, OAuthConfig, PluginDef } from "./types.js";
9
2
  export interface ActionDefinition {
10
3
  description?: string;
11
4
  inputSchema?: InputSchema;
@@ -13,7 +6,7 @@ export interface ActionDefinition {
13
6
  }
14
7
  export interface RunlinePluginAPI {
15
8
  registerAction(name: string, def: ActionDefinition): void;
16
- setConnectionSchema(schema: Record<string, SchemaField>): void;
9
+ setConnectionSchema(schema: ConnectionSchema): void;
17
10
  setName(name: string): void;
18
11
  setVersion(version: string): void;
19
12
  /**
@@ -19,10 +19,7 @@ export function createPluginAPI(pluginId) {
19
19
  actions.push({ name: actionName, ...def });
20
20
  },
21
21
  setConnectionSchema(schema) {
22
- connectionConfigSchema = {};
23
- for (const [key, field] of Object.entries(schema)) {
24
- connectionConfigSchema[key] = { ...field };
25
- }
22
+ connectionConfigSchema = { ...schema };
26
23
  },
27
24
  setOAuth(cfg) {
28
25
  oauth = { ...cfg };
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { dirname, join, resolve } from "node:path";
3
+ import { dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { createJiti } from "jiti";
6
6
  import { findConfigDir } from "../config/loader.js";
@@ -81,6 +81,19 @@ async function loadFromDirectory(dir) {
81
81
  join(fullPath, "src", "index.ts"),
82
82
  join(fullPath, "src", "index.js"),
83
83
  ];
84
+ const pkgJson = join(fullPath, "package.json");
85
+ let packagePluginPaths = [];
86
+ if (existsSync(pkgJson)) {
87
+ try {
88
+ const pkg = JSON.parse(readFileSync(pkgJson, "utf-8"));
89
+ if (pkg.main)
90
+ candidates.unshift(join(fullPath, pkg.main));
91
+ packagePluginPaths = pkg.runline?.plugins ?? [];
92
+ }
93
+ catch (err) {
94
+ console.error(`[runline] Failed to parse ${pkgJson}:`, err.message);
95
+ }
96
+ }
84
97
  const found = candidates.find((c) => existsSync(c));
85
98
  if (found) {
86
99
  try {
@@ -90,22 +103,15 @@ async function loadFromDirectory(dir) {
90
103
  console.error(`[runline] Failed to load plugin from ${found}:`, err.message);
91
104
  }
92
105
  }
93
- const pkgJson = join(fullPath, "package.json");
94
- if (existsSync(pkgJson)) {
106
+ else if (packagePluginPaths.length === 0) {
107
+ console.error(`[runline] Failed to load plugin from ${fullPath}: No entry point found`);
108
+ }
109
+ for (const p of packagePluginPaths) {
95
110
  try {
96
- const pkg = JSON.parse(readFileSync(pkgJson, "utf-8"));
97
- const pluginPaths = pkg.runline?.plugins ?? [];
98
- for (const p of pluginPaths) {
99
- try {
100
- plugins.push(await loadPluginFromPath(join(fullPath, p)));
101
- }
102
- catch (err) {
103
- console.error(`[runline] Failed to load plugin from ${join(fullPath, p)}:`, err.message);
104
- }
105
- }
111
+ plugins.push(await loadPluginFromPath(join(fullPath, p)));
106
112
  }
107
113
  catch (err) {
108
- console.error(`[runline] Failed to parse ${pkgJson}:`, err.message);
114
+ console.error(`[runline] Failed to load plugin from ${join(fullPath, p)}:`, err.message);
109
115
  }
110
116
  }
111
117
  }
@@ -120,13 +126,21 @@ export async function loadPluginsFromConfig(configDir) {
120
126
  try {
121
127
  const data = JSON.parse(readFileSync(pluginsFile, "utf-8"));
122
128
  const entries = data.plugins ?? data;
129
+ if (!Array.isArray(entries)) {
130
+ throw new Error("Expected an array or { plugins: [...] }");
131
+ }
123
132
  for (const entry of entries) {
124
- const p = typeof entry === "string" ? entry : entry.path;
133
+ const p = typeof entry === "string" ? entry : entry?.path;
134
+ if (typeof p !== "string" || p.length === 0) {
135
+ console.error(`[runline] Invalid plugin entry in ${pluginsFile}: expected string path or { path }`);
136
+ continue;
137
+ }
138
+ const pluginPath = isAbsolute(p) ? p : join(configDir, p);
125
139
  try {
126
- plugins.push(await loadPluginFromPath(p));
140
+ plugins.push(await loadPluginFromPath(pluginPath));
127
141
  }
128
142
  catch (err) {
129
- console.error(`[runline] Failed to load plugin from ${p}:`, err.message);
143
+ console.error(`[runline] Failed to load plugin from ${pluginPath}:`, err.message);
130
144
  }
131
145
  }
132
146
  }
@@ -146,32 +160,34 @@ export function defaultBuiltinDir() {
146
160
  export async function discoverPlugins(configDir, options = {}) {
147
161
  const loaded = new Set();
148
162
  const result = [];
149
- function addIfNew(plugin) {
150
- if (!loaded.has(plugin.name)) {
151
- result.push(plugin);
152
- loaded.add(plugin.name);
163
+ function addIfNew(plugin, source) {
164
+ if (loaded.has(plugin.name)) {
165
+ console.error(`[runline] Skipping duplicate plugin "${plugin.name}" from ${source}: a plugin with that name is already loaded`);
166
+ return;
153
167
  }
168
+ result.push(plugin);
169
+ loaded.add(plugin.name);
154
170
  }
155
171
  if (configDir) {
156
172
  const projectPluginsDir = join(configDir, "plugins");
157
173
  const projectPlugins = await loadFromDirectory(projectPluginsDir);
158
174
  for (const p of projectPlugins)
159
- addIfNew(p);
175
+ addIfNew(p, projectPluginsDir);
160
176
  const configPlugins = await loadPluginsFromConfig(configDir);
161
177
  for (const p of configPlugins)
162
- addIfNew(p);
178
+ addIfNew(p, join(configDir, "plugins.json"));
163
179
  }
164
180
  const globalDir = join(homedir(), ".runline", "plugins");
165
181
  const globalPlugins = await loadFromDirectory(globalDir);
166
182
  for (const p of globalPlugins)
167
- addIfNew(p);
183
+ addIfNew(p, globalDir);
168
184
  const builtinDir = options.builtinDir ?? defaultBuiltinDir();
169
185
  const builtinPlugins = await loadFromDirectory(builtinDir);
170
186
  for (const p of builtinPlugins) {
171
187
  if (options.builtinAllowlist && !options.builtinAllowlist.has(p.name)) {
172
188
  continue;
173
189
  }
174
- addIfNew(p);
190
+ addIfNew(p, builtinDir);
175
191
  }
176
192
  return result;
177
193
  }
@@ -0,0 +1,19 @@
1
+ import type { ConnectionSchema, HelpInput, InputSchema, LegacyConnectionSchema, LegacyInputSchema, TypedInputSchema } from "./types.js";
2
+ export interface ValidationResult {
3
+ ok: boolean;
4
+ missing: string[];
5
+ unknown: string[];
6
+ typeErrors: Array<{
7
+ field: string;
8
+ expected: string;
9
+ actual: string;
10
+ }>;
11
+ errors: string[];
12
+ }
13
+ export declare function isTypedInputSchema(schema: InputSchema | undefined): schema is TypedInputSchema;
14
+ export declare function legacyFields(schema: InputSchema | undefined): LegacyInputSchema;
15
+ export declare function connectionFields(schema: ConnectionSchema | undefined): LegacyConnectionSchema;
16
+ export declare function helpInputs(schema: InputSchema | undefined): Record<string, HelpInput>;
17
+ export declare function validateLegacyInput(schema: LegacyInputSchema, input: unknown): ValidationResult;
18
+ export declare function validateTypedInput(schema: TypedInputSchema, input: unknown): ValidationResult;
19
+ export declare function formatValidationError(path: string, result: ValidationResult): string;
@@ -0,0 +1,168 @@
1
+ import { Check, Errors } from "typebox/value";
2
+ export function isTypedInputSchema(schema) {
3
+ if (!schema || typeof schema !== "object")
4
+ return false;
5
+ const candidate = schema;
6
+ return typeof candidate.type === "string" || Array.isArray(candidate.anyOf);
7
+ }
8
+ export function legacyFields(schema) {
9
+ if (!schema || isTypedInputSchema(schema))
10
+ return {};
11
+ return schema;
12
+ }
13
+ export function connectionFields(schema) {
14
+ if (!schema)
15
+ return {};
16
+ if (!isTypedInputSchema(schema))
17
+ return schema;
18
+ const metadata = schema;
19
+ if (metadata.type !== "object")
20
+ return {};
21
+ const required = new Set(metadata.required ?? []);
22
+ return Object.fromEntries(Object.entries(metadata.properties ?? {}).map(([key, field]) => [
23
+ key,
24
+ {
25
+ type: baseType(field),
26
+ required: required.has(key),
27
+ description: field.description,
28
+ default: field.default,
29
+ env: field.env,
30
+ },
31
+ ]));
32
+ }
33
+ export function helpInputs(schema) {
34
+ if (!schema)
35
+ return {};
36
+ if (!isTypedInputSchema(schema)) {
37
+ const legacy = schema;
38
+ return Object.fromEntries(Object.entries(legacy).map(([key, field]) => [
39
+ key,
40
+ {
41
+ type: field.type,
42
+ required: !!field.required,
43
+ description: field.description,
44
+ },
45
+ ]));
46
+ }
47
+ const metadata = schema;
48
+ if (metadata.type !== "object")
49
+ return {};
50
+ const required = new Set(metadata.required ?? []);
51
+ return Object.fromEntries(Object.entries(metadata.properties ?? {}).map(([key, field]) => [
52
+ key,
53
+ {
54
+ type: baseType(field),
55
+ displayType: displayType(field),
56
+ required: required.has(key),
57
+ description: field.description,
58
+ enum: enumValues(field),
59
+ const: field.const,
60
+ },
61
+ ]));
62
+ }
63
+ export function validateLegacyInput(schema, input) {
64
+ const provided = input && typeof input === "object"
65
+ ? input
66
+ : {};
67
+ const missing = [];
68
+ const unknown = [];
69
+ const typeErrors = [];
70
+ for (const [key, spec] of Object.entries(schema)) {
71
+ if (spec.required && !(key in provided))
72
+ missing.push(key);
73
+ }
74
+ for (const key of Object.keys(provided)) {
75
+ if (!(key in schema)) {
76
+ unknown.push(key);
77
+ continue;
78
+ }
79
+ const expected = schema[key].type;
80
+ const actual = valueType(provided[key]);
81
+ if (provided[key] !== null &&
82
+ provided[key] !== undefined &&
83
+ expected !== actual) {
84
+ typeErrors.push({ field: key, expected, actual });
85
+ }
86
+ }
87
+ return validationResult({ missing, unknown, typeErrors });
88
+ }
89
+ export function validateTypedInput(schema, input) {
90
+ if (Check(schema, input))
91
+ return validationResult({});
92
+ return validationResult({
93
+ errors: [...Errors(schema, input)].map((error) => {
94
+ const path = error.instancePath || "/";
95
+ return `${path} ${error.message}`;
96
+ }),
97
+ });
98
+ }
99
+ export function formatValidationError(path, result) {
100
+ const parts = [`Invalid input for ${path}.`];
101
+ if (result.missing.length > 0) {
102
+ parts.push(`Missing required fields: ${result.missing.join(", ")}.`);
103
+ }
104
+ if (result.unknown.length > 0) {
105
+ parts.push(`Unknown fields: ${result.unknown.join(", ")}.`);
106
+ }
107
+ if (result.typeErrors.length > 0) {
108
+ parts.push(`Type errors: ${result.typeErrors
109
+ .map((e) => `${e.field} expected ${e.expected}, got ${e.actual}`)
110
+ .join("; ")}.`);
111
+ }
112
+ if (result.errors.length > 0) {
113
+ parts.push(`Validation errors: ${result.errors.join("; ")}.`);
114
+ }
115
+ return parts.join(" ");
116
+ }
117
+ function validationResult(input) {
118
+ const missing = input.missing ?? [];
119
+ const unknown = input.unknown ?? [];
120
+ const typeErrors = input.typeErrors ?? [];
121
+ const errors = input.errors ?? [];
122
+ return {
123
+ ok: missing.length === 0 &&
124
+ unknown.length === 0 &&
125
+ typeErrors.length === 0 &&
126
+ errors.length === 0,
127
+ missing,
128
+ unknown,
129
+ typeErrors,
130
+ errors,
131
+ };
132
+ }
133
+ function displayType(schema) {
134
+ if (schema.anyOf?.length)
135
+ return schema.anyOf.map(displayType).join(" | ");
136
+ if (schema.enum?.length)
137
+ return schema.enum.map(String).join(" | ");
138
+ if (schema.const !== undefined)
139
+ return JSON.stringify(schema.const);
140
+ return schema.type ?? "unknown";
141
+ }
142
+ function baseType(schema) {
143
+ if (schema.anyOf?.length) {
144
+ const types = [...new Set(schema.anyOf.map(baseType))];
145
+ return types.length === 1 ? types[0] : types.join(" | ");
146
+ }
147
+ if (schema.type)
148
+ return schema.type;
149
+ if (schema.const !== undefined)
150
+ return valueType(schema.const);
151
+ return "unknown";
152
+ }
153
+ function enumValues(schema) {
154
+ if (schema.enum?.length)
155
+ return schema.enum;
156
+ if (schema.anyOf?.length &&
157
+ schema.anyOf.every((s) => s.const !== undefined)) {
158
+ return schema.anyOf.map((s) => s.const);
159
+ }
160
+ return undefined;
161
+ }
162
+ function valueType(value) {
163
+ if (value === null)
164
+ return "null";
165
+ if (Array.isArray(value))
166
+ return "array";
167
+ return typeof value;
168
+ }
@@ -1,10 +1,30 @@
1
+ import type { TSchema } from "typebox";
1
2
  export interface InputField {
2
3
  type: "string" | "number" | "boolean" | "object" | "array";
3
4
  description?: string;
4
5
  required?: boolean;
5
6
  default?: unknown;
6
7
  }
7
- export type InputSchema = Record<string, InputField>;
8
+ export type LegacyInputSchema = Record<string, InputField>;
9
+ export type TypedInputSchema = TSchema;
10
+ export type InputSchema = LegacyInputSchema | TypedInputSchema;
11
+ export interface ConnectionSchemaField {
12
+ type: string;
13
+ required?: boolean;
14
+ description?: string;
15
+ default?: unknown;
16
+ env?: string;
17
+ }
18
+ export type LegacyConnectionSchema = Record<string, ConnectionSchemaField>;
19
+ export type ConnectionSchema = LegacyConnectionSchema | TSchema;
20
+ export interface HelpInput {
21
+ type: string;
22
+ displayType?: string;
23
+ required: boolean;
24
+ description?: string;
25
+ enum?: unknown[];
26
+ const?: unknown;
27
+ }
8
28
  export interface ActionDef {
9
29
  name: string;
10
30
  description?: string;
@@ -73,13 +93,7 @@ export interface PluginDef {
73
93
  name: string;
74
94
  version: string;
75
95
  actions: ActionDef[];
76
- connectionConfigSchema?: Record<string, {
77
- type: string;
78
- required?: boolean;
79
- description?: string;
80
- default?: unknown;
81
- env?: string;
82
- }>;
96
+ connectionConfigSchema?: ConnectionSchema;
83
97
  /** OAuth2 config for `runline auth <plugin>`. */
84
98
  oauth?: OAuthConfig;
85
99
  /** @internal */
@@ -27,6 +27,11 @@ async function accessToken(ctx) {
27
27
  }
28
28
  // ─── Request ─────────────────────────────────────────────────────
29
29
  const API_BASE = "https://gmail.googleapis.com/gmail/v1/users/me";
30
+ function sendFailureRetryHint(method, path) {
31
+ if (method !== "POST" || !path.endsWith("/send"))
32
+ return "";
33
+ return " Before retrying, check whether the email was already sent using gmail.message.list({ q: 'in:sent to:<recipient> subject:\"<subject>\"', maxResults: 10 }) and inspect likely matches with gmail.message.get({ id, format: 'metadata' }).";
34
+ }
30
35
  async function gmailRequest(ctx, method, path, body, qs) {
31
36
  const token = await accessToken(ctx);
32
37
  const url = new URL(`${API_BASE}${path}`);
@@ -55,12 +60,19 @@ async function gmailRequest(ctx, method, path, body, qs) {
55
60
  "application/json";
56
61
  init.body = JSON.stringify(body);
57
62
  }
58
- const res = await fetch(url.toString(), init);
63
+ let res;
64
+ try {
65
+ res = await fetch(url.toString(), init);
66
+ }
67
+ catch (err) {
68
+ const msg = err instanceof Error ? err.message : String(err);
69
+ throw new Error(`gmail: ${method} ${path} failed: ${msg}.${sendFailureRetryHint(method, path)}`);
70
+ }
59
71
  if (res.status === 204)
60
72
  return { success: true };
61
73
  const text = await res.text();
62
74
  if (!res.ok) {
63
- throw new Error(`gmail: ${method} ${path} → ${res.status} ${text}`);
75
+ throw new Error(`gmail: ${method} ${path} → ${res.status} ${text}${sendFailureRetryHint(method, path)}`);
64
76
  }
65
77
  return text ? JSON.parse(text) : { success: true };
66
78
  }
@@ -0,0 +1,87 @@
1
+ import * as t from "typebox";
2
+ import { ATTACHMENT_FIELDS, assertAttachmentInScope, assertIssueInScope, gql, key, requireUnscoped } from "./shared.js";
3
+ export function registerAttachmentActions(rl) {
4
+ rl.registerAction("attachment.list", {
5
+ description: "List issue attachments. Disabled for scoped Linear connections.",
6
+ inputSchema: t.Object({ limit: t.Optional(t.Number()) }),
7
+ async execute(input, ctx) {
8
+ requireUnscoped(ctx, "attachment.list");
9
+ const limit = input?.limit ?? 50;
10
+ const data = await gql(key(ctx), `query($first: Int) { attachments(first: $first) { nodes { ${ATTACHMENT_FIELDS} } pageInfo { hasNextPage endCursor } } }`, { first: limit });
11
+ return data.attachments;
12
+ },
13
+ });
14
+ rl.registerAction("attachment.get", {
15
+ description: "Get an attachment by ID.",
16
+ inputSchema: t.Object({ id: t.String() }),
17
+ async execute(input, ctx) {
18
+ const id = input.id;
19
+ await assertAttachmentInScope(ctx, id);
20
+ const data = await gql(key(ctx), `query($id: String!) { attachment(id: $id) { ${ATTACHMENT_FIELDS} } }`, { id });
21
+ return data.attachment;
22
+ },
23
+ });
24
+ rl.registerAction("attachment.create", {
25
+ description: "Create an attachment on an issue.",
26
+ inputSchema: t.Object({
27
+ issueId: t.String({ description: "The issue to associate the attachment with. UUID or issue identifier (e.g., 'LIN-123')" }),
28
+ title: t.String({ description: "The attachment title" }),
29
+ url: t.String({ description: "Attachment location, also used as a unique identifier. Re-creating with the same url updates the existing record" }),
30
+ subtitle: t.Optional(t.String({ description: "The attachment subtitle" })),
31
+ iconUrl: t.Optional(t.String({ description: "An icon url to display with the attachment (jpg or png, max 1MB, ideally 20x20px)" })),
32
+ commentBody: t.Optional(t.String({ description: "Create a linked comment with markdown body" })),
33
+ groupBySource: t.Optional(t.Boolean({ description: "Whether attachments for the same source application should be grouped in the Linear UI" })),
34
+ metadata: t.Optional(t.Object({}, { description: "Attachment metadata object with string and number values (JSONObject)" })),
35
+ id: t.Optional(t.String({ description: "The identifier in UUID v4 format. If none is provided, the backend will generate one" })),
36
+ }),
37
+ async execute(input, ctx) {
38
+ const fields = input;
39
+ await assertIssueInScope(ctx, String(fields.issueId));
40
+ const data = await gql(key(ctx), `mutation($input: AttachmentCreateInput!) { attachmentCreate(input: $input) { success attachment { ${ATTACHMENT_FIELDS} } } }`, { input: fields });
41
+ return data.attachmentCreate?.attachment;
42
+ },
43
+ });
44
+ rl.registerAction("attachment.update", {
45
+ description: "Update an attachment. title is required.",
46
+ inputSchema: t.Object({
47
+ id: t.String({ description: "The identifier of the attachment to update" }),
48
+ title: t.String({ description: "The attachment title" }),
49
+ subtitle: t.Optional(t.String({ description: "The attachment subtitle" })),
50
+ iconUrl: t.Optional(t.String({ description: "An icon url to display with the attachment" })),
51
+ metadata: t.Optional(t.Object({}, { description: "Attachment metadata object with string and number values (JSONObject)" })),
52
+ }),
53
+ async execute(input, ctx) {
54
+ const { id, ...fields } = input;
55
+ await assertAttachmentInScope(ctx, String(id));
56
+ const data = await gql(key(ctx), `mutation($id: String!, $input: AttachmentUpdateInput!) { attachmentUpdate(id: $id, input: $input) { success attachment { ${ATTACHMENT_FIELDS} } } }`, { id, input: fields });
57
+ return data.attachmentUpdate?.attachment;
58
+ },
59
+ });
60
+ rl.registerAction("attachment.linkURL", {
61
+ description: "Link any URL to an issue. If a workspace integration matches the URL (Zendesk, GitHub, Slack, etc.) a rich attachment is created; otherwise a basic one.",
62
+ inputSchema: t.Object({
63
+ issueId: t.String({ description: "The issue for which to link the url. UUID or issue identifier (e.g., 'LIN-123')" }),
64
+ url: t.String({ description: "The url to link" }),
65
+ title: t.Optional(t.String({ description: "The title to use for the attachment" })),
66
+ id: t.Optional(t.String({ description: "The id for the attachment (optional UUID override)" })),
67
+ }),
68
+ async execute(input, ctx) {
69
+ const { issueId, url, title, id } = input;
70
+ await assertIssueInScope(ctx, String(issueId));
71
+ const data = await gql(key(ctx), `mutation($issueId: String!, $url: String!, $title: String, $id: String) {
72
+ attachmentLinkURL(issueId: $issueId, url: $url, title: $title, id: $id) { success attachment { ${ATTACHMENT_FIELDS} } }
73
+ }`, { issueId, url, title: title ?? null, id: id ?? null });
74
+ return data.attachmentLinkURL?.attachment;
75
+ },
76
+ });
77
+ rl.registerAction("attachment.delete", {
78
+ description: "Delete an attachment.",
79
+ inputSchema: t.Object({ id: t.String({ description: "The identifier of the attachment to delete" }) }),
80
+ async execute(input, ctx) {
81
+ const id = input.id;
82
+ await assertAttachmentInScope(ctx, id);
83
+ const data = await gql(key(ctx), `mutation($id: String!) { attachmentDelete(id: $id) { success } }`, { id });
84
+ return data.attachmentDelete;
85
+ },
86
+ });
87
+ }