diff --git a/README.md b/README.md index 2598a3d..ef9471d 100644 --- a/README.md +++ b/README.md @@ -1,112 +1,94 @@ # conductor-plugins -> Official plugin registry for [Conductor](https://github.com/thegreatalxx/conductor) — the AI integration hub. +> Official plugin marketplace for [Conductor](https://github.com/thegreatalxx/conductor) — the AI integration hub. -Install any plugin in one command: +This marketplace extends Conductor with plugins **not included in the core**. Core plugins (GitHub, Slack, Stripe, Notion, Gmail, Google Calendar, and others) ship built-in with Conductor and do not need to be installed separately. + +Install any marketplace plugin in one command: ```bash -conductor plugins install slack -conductor plugins install github -conductor plugins install stripe +conductor plugins install discord +conductor plugins install shopify +conductor plugins install supabase ``` Browse all plugins at [conductor.thealxlabs.ca/marketplace](https://conductor.thealxlabs.ca/marketplace). --- -## Available Plugins (31) +## Available Plugins (15) ### Developer Tools | Plugin | Tools | Description | Install | |--------|-------|-------------|---------| -| [github](./plugins/github) | 5 | Repositories, user profiles, trending — public data, no token needed | `conductor plugins install github` | -| [github-actions](./plugins/github-actions) | 16 | Trigger workflows, manage PRs, issues, releases, and notifications | `conductor plugins install github-actions` | -| [gitlab](./plugins/gitlab) | — | Projects, issues, merge requests, and pipelines | `conductor plugins install gitlab` | -| [vercel](./plugins/vercel) | 15 | Deployments, logs, domains, and environment variables | `conductor plugins install vercel` | -| [linear](./plugins/linear) | — | Issues, cycles, projects, and team management | `conductor plugins install linear` | -| [jira](./plugins/jira) | — | Issues, sprints, projects, and boards | `conductor plugins install jira` | -| [figma](./plugins/figma) | — | Files, nodes, components, and comments | `conductor plugins install figma` | +| [figma](./plugins/figma) | 7 | Files, nodes, components, and comments | `conductor plugins install figma` | +| [gitlab](./plugins/gitlab) | 12 | Projects, issues, merge requests, and pipelines | `conductor plugins install gitlab` | ### Communication | Plugin | Tools | Description | Install | |--------|-------|-------------|---------| -| [slack](./plugins/slack) | 7 | Send messages, read channels, search, manage workspaces | `conductor plugins install slack` | -| [discord](./plugins/discord) | — | Send messages, manage channels and servers | `conductor plugins install discord` | -| [gmail](./plugins/gmail) | 8 | Read, search, send, and manage Gmail | `conductor plugins install gmail` | -| [twilio](./plugins/twilio) | — | Send SMS, make calls, manage phone numbers | `conductor plugins install twilio` | -| [sendgrid](./plugins/sendgrid) | — | Send transactional and marketing email | `conductor plugins install sendgrid` | -| [x](./plugins/x) | 7 | Post tweets, search, get timelines and user info | `conductor plugins install x` | - -### Google Workspace - -| Plugin | Tools | Description | Install | -|--------|-------|-------------|---------| -| [gcal](./plugins/gcal) | 7 | Read and manage Google Calendar events | `conductor plugins install gcal` | -| [gdrive](./plugins/gdrive) | 8 | List, search, read, and upload files in Google Drive | `conductor plugins install gdrive` | -| [gmail](./plugins/gmail) | 8 | Read, search, send, and manage Gmail | `conductor plugins install gmail` | +| [discord](./plugins/discord) | 8 | Send messages, manage channels and servers | `conductor plugins install discord` | +| [twilio](./plugins/twilio) | 6 | Send SMS, make calls, manage phone numbers | `conductor plugins install twilio` | +| [sendgrid](./plugins/sendgrid) | 6 | Send transactional and marketing email | `conductor plugins install sendgrid` | ### Productivity | Plugin | Tools | Description | Install | |--------|-------|-------------|---------| -| [notion](./plugins/notion) | 7 | Read, search, and create pages and databases | `conductor plugins install notion` | -| [airtable](./plugins/airtable) | — | Bases, tables, records, and field management | `conductor plugins install airtable` | -| [monday](./plugins/monday) | — | Boards, items, columns, and updates | `conductor plugins install monday` | -| [asana](./plugins/asana) | — | Projects, tasks, teams, and workspaces | `conductor plugins install asana` | -| [trello](./plugins/trello) | — | Boards, lists, cards, and members | `conductor plugins install trello` | +| [airtable](./plugins/airtable) | 8 | Bases, tables, records, and field management | `conductor plugins install airtable` | +| [asana](./plugins/asana) | 10 | Projects, tasks, teams, and workspaces | `conductor plugins install asana` | +| [monday](./plugins/monday) | 8 | Boards, items, columns, and updates | `conductor plugins install monday` | +| [trello](./plugins/trello) | 8 | Boards, lists, cards, and members | `conductor plugins install trello` | ### Finance & Commerce | Plugin | Tools | Description | Install | |--------|-------|-------------|---------| -| [stripe](./plugins/stripe) | — | Charges, customers, subscriptions, and payment intents | `conductor plugins install stripe` | -| [shopify](./plugins/shopify) | — | Products, orders, customers, and inventory | `conductor plugins install shopify` | +| [shopify](./plugins/shopify) | 10 | Products, orders, customers, and inventory | `conductor plugins install shopify` | ### AI & ML | Plugin | Tools | Description | Install | |--------|-------|-------------|---------| -| [openai](./plugins/openai) | — | Chat completions, embeddings, and fine-tuning | `conductor plugins install openai` | -| [anthropic](./plugins/anthropic) | — | Claude completions and model management | `conductor plugins install anthropic` | +| [openai](./plugins/openai) | 8 | Chat completions, embeddings, image generation, and fine-tuning | `conductor plugins install openai` | +| [anthropic](./plugins/anthropic) | 4 | Claude completions and model management | `conductor plugins install anthropic` | ### Analytics | Plugin | Tools | Description | Install | |--------|-------|-------------|---------| -| [posthog](./plugins/posthog) | — | Events, feature flags, cohorts, and insights | `conductor plugins install posthog` | +| [posthog](./plugins/posthog) | 8 | Events, feature flags, cohorts, and insights | `conductor plugins install posthog` | ### Databases | Plugin | Tools | Description | Install | |--------|-------|-------------|---------| -| [redis](./plugins/redis) | — | Get, set, delete, list, and pub/sub operations | `conductor plugins install redis` | -| [supabase](./plugins/supabase) | — | Tables, auth, storage, and edge functions | `conductor plugins install supabase` | - -### Automation - -| Plugin | Tools | Description | Install | -|--------|-------|-------------|---------| -| [n8n](./plugins/n8n) | 13 | Trigger workflows, manage executions, fire webhooks | `conductor plugins install n8n` | - -### Social & Entertainment - -| Plugin | Tools | Description | Install | -|--------|-------|-------------|---------| -| [spotify](./plugins/spotify) | 15 | Playback control, search, playlists, and recommendations | `conductor plugins install spotify` | - -### Utilities - -| Plugin | Tools | Description | Install | -|--------|-------|-------------|---------| -| [weather](./plugins/weather) | 3 | Current conditions and 7-day forecasts — no API key needed | `conductor plugins install weather` | +| [redis](./plugins/redis) | 10 | Get, set, delete, list, and pub/sub operations | `conductor plugins install redis` | +| [supabase](./plugins/supabase) | 10 | Tables, auth, storage, and edge functions | `conductor plugins install supabase` | -### Smart Home +--- -| Plugin | Tools | Description | Install | -|--------|-------|-------------|---------| -| [homekit](./plugins/homekit) | 7 | Control HomeKit smart home devices via Homebridge | `conductor plugins install homekit` | +## Core Plugins (Built-in) + +The following plugins are included with Conductor and do **not** need to be installed from the marketplace: + +- **GitHub** — repositories, issues, pull requests, actions +- **Slack** — messaging, channels, search +- **Stripe** — charges, customers, subscriptions +- **Gmail** — read, send, search email +- **Google Calendar** — events and calendar management +- **Google Drive** — files and folder management +- **Notion** — pages and database queries +- **Spotify** — playback and music search +- **Vercel** — deployments and project management +- **Linear** — issues and project tracking +- **Jira** — issues, sprints, and boards +- **n8n** — workflow automation +- **HomeKit** — smart home control +- **Weather** — current conditions and forecasts +- **X (Twitter)** — tweets, search, timelines --- @@ -130,13 +112,14 @@ Each plugin folder follows this layout: ``` plugins/ -└── slack/ +└── discord/ + ├── README.md # Setup and tool reference ├── package.json # Plugin metadata ├── tsconfig.json # TypeScript config ├── src/ │ └── index.ts # Plugin source (implements Plugin interface) └── dist/ - └── slack.js # Compiled output — this is what Conductor downloads + └── discord.js # Compiled output — this is what Conductor downloads ``` The compiled `dist/.js` must export a class implementing the Conductor `Plugin` interface: diff --git a/plugins/airtable/README.md b/plugins/airtable/README.md new file mode 100644 index 0000000..b85c8d9 --- /dev/null +++ b/plugins/airtable/README.md @@ -0,0 +1,26 @@ +# Airtable Plugin + +Manage Airtable bases, tables, records, and fields from Conductor. + +## Setup + +1. Go to [https://airtable.com/create/tokens](https://airtable.com/create/tokens) and create a Personal Access Token. +2. Grant the `data.records:read`, `data.records:write`, `schema.bases:read`, and `schema.bases:write` scopes. +3. Configure the plugin: + +```bash +conductor config set airtable api_key YOUR_PERSONAL_ACCESS_TOKEN +``` + +## Available Tools + +| Tool | Description | +|------|-------------| +| `airtable_list_bases` | List all accessible bases | +| `airtable_list_tables` | List tables in a base | +| `airtable_list_records` | Query records with optional filter/sort | +| `airtable_get_record` | Get a single record by ID | +| `airtable_create_record` | Create a new record | +| `airtable_update_record` | Update an existing record | +| `airtable_delete_record` | Delete a record | +| `airtable_list_fields` | List fields in a table | diff --git a/plugins/anthropic/README.md b/plugins/anthropic/README.md new file mode 100644 index 0000000..8034517 --- /dev/null +++ b/plugins/anthropic/README.md @@ -0,0 +1,21 @@ +# Anthropic Plugin + +Send messages to Claude models via the Anthropic API from Conductor. + +## Setup + +1. Go to [https://console.anthropic.com/settings/keys](https://console.anthropic.com/settings/keys) and create an API key. +2. Configure the plugin: + +```bash +conductor config set anthropic api_key YOUR_API_KEY +``` + +## Available Tools + +| Tool | Description | +|------|-------------| +| `anthropic_complete` | Send a message to a Claude model and get a completion | +| `anthropic_list_models` | List available Claude models | +| `anthropic_count_tokens` | Count tokens for a given prompt | +| `anthropic_stream` | Stream a completion response token by token | diff --git a/plugins/asana/README.md b/plugins/asana/README.md new file mode 100644 index 0000000..61a6524 --- /dev/null +++ b/plugins/asana/README.md @@ -0,0 +1,27 @@ +# Asana Plugin + +Manage Asana workspaces, projects, tasks, and teams from Conductor. + +## Setup + +1. Go to [https://app.asana.com/0/my-apps](https://app.asana.com/0/my-apps) and create a Personal Access Token. +2. Configure the plugin: + +```bash +conductor config set asana access_token YOUR_PERSONAL_ACCESS_TOKEN +``` + +## Available Tools + +| Tool | Description | +|------|-------------| +| `asana_workspaces` | List all workspaces | +| `asana_projects` | List projects in a workspace | +| `asana_tasks` | List tasks in a project | +| `asana_get_task` | Get details for a task | +| `asana_create_task` | Create a new task | +| `asana_update_task` | Update an existing task | +| `asana_add_subtask` | Add a subtask to an existing task | +| `asana_add_comment` | Add a comment to a task | +| `asana_sections` | List sections in a project | +| `asana_teams` | List teams in a workspace | diff --git a/plugins/discord/README.md b/plugins/discord/README.md new file mode 100644 index 0000000..21323dc --- /dev/null +++ b/plugins/discord/README.md @@ -0,0 +1,27 @@ +# Discord Plugin + +Send messages, manage channels, and interact with Discord servers from Conductor. + +## Setup + +1. Go to [https://discord.com/developers/applications](https://discord.com/developers/applications) and create an application. +2. Under **Bot**, create a bot and copy the token. +3. Invite the bot to your server with the appropriate permissions. +4. Configure the plugin: + +```bash +conductor config set discord bot_token YOUR_BOT_TOKEN +``` + +## Available Tools + +| Tool | Description | +|------|-------------| +| `discord_send_message` | Send a message to a channel | +| `discord_read_channel` | Read recent messages from a channel | +| `discord_list_channels` | List channels in a server | +| `discord_create_channel` | Create a new channel | +| `discord_delete_channel` | Delete a channel | +| `discord_list_members` | List members in a server | +| `discord_add_reaction` | Add an emoji reaction to a message | +| `discord_delete_message` | Delete a message | diff --git a/plugins/figma/README.md b/plugins/figma/README.md new file mode 100644 index 0000000..db83b0b --- /dev/null +++ b/plugins/figma/README.md @@ -0,0 +1,25 @@ +# Figma Plugin + +Access Figma files, nodes, components, comments, and exports from Conductor. + +## Setup + +1. In Figma, go to **Account Settings** and find the **Personal access tokens** section. +2. Create a new token at [https://www.figma.com/developers/api#access-tokens](https://www.figma.com/developers/api#access-tokens). +3. Configure the plugin: + +```bash +conductor config set figma access_token YOUR_PERSONAL_ACCESS_TOKEN +``` + +## Available Tools + +| Tool | Description | +|------|-------------| +| `figma_get_file` | Get file metadata and top-level structure | +| `figma_get_node` | Get details for a specific node by ID | +| `figma_list_components` | List all components in a file | +| `figma_list_styles` | List all styles in a file | +| `figma_get_comments` | Get comments on a file | +| `figma_post_comment` | Post a comment on a file | +| `figma_export_image` | Export a node as a PNG/SVG/JPG | diff --git a/plugins/gcal/README.md b/plugins/gcal/README.md deleted file mode 100644 index af5e2a3..0000000 --- a/plugins/gcal/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Google Calendar Plugin for Conductor - -Install: `conductor install gcal` - -## Setup - -**Authentication:** Google OAuth - -```bash -conductor auth google -`conductor plugins enable gcal` -``` - -Get credentials at: https://developers.google.com/calendar - -## Tools - -``` -gcal_list, gcal_get, gcal_create, gcal_update, gcal_delete, gcal_list_calendars -``` - -Each tool is documented inline — ask Conductor what tools are available after installing. - -## Source - -Part of [thealxlabs/conductor-plugins](https://github.com/thealxlabs/conductor-plugins/tree/main/plugins/gcal). diff --git a/plugins/gcal/dist/gcal.js b/plugins/gcal/dist/gcal.js deleted file mode 100644 index ceb2025..0000000 --- a/plugins/gcal/dist/gcal.js +++ /dev/null @@ -1,265 +0,0 @@ -/** - * Google Calendar Plugin - * - * Read, create, update, and delete calendar events. - * Requires Google OAuth — stores access token as google / access_token. - * - * Scopes needed: - * https://www.googleapis.com/auth/calendar - * https://www.googleapis.com/auth/calendar.events - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service, _account, _value) { } - async delete(_service, _account) { } -} -const CAL_BASE = 'https://www.googleapis.com/calendar/v3'; -export class GoogleCalendarPlugin { - name = 'gcal'; - description = 'Read and manage Google Calendar events — requires Google OAuth'; - version = '1.0.0'; - configSchema = { - fields: [ - { - key: 'access_token', - label: 'Google Access Token', - type: 'password', - required: true, - secret: true, - service: 'google', - description: 'Shared with Gmail plugin. Run "conductor auth google" to setup.' - } - ] - }; - keychain; - async initialize(conductor) { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - isConfigured() { return true; } - async getToken() { - const token = await this.keychain.get('google', 'access_token'); - if (!token) - throw new Error('Google not authenticated. Run: conductor auth google'); - return token; - } - async calFetch(path, options = {}) { - const token = await this.getToken(); - const res = await fetch(`${CAL_BASE}${path}`, { - method: options.method ?? 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: options.body ? JSON.stringify(options.body) : undefined, - }); - if (!res.ok) { - const err = await res.text().catch(() => res.statusText); - if (res.status === 401) - throw new Error('Google token expired. Re-authenticate: conductor auth google'); - throw new Error(`Google Calendar API ${res.status}: ${err}`); - } - if (res.status === 204) - return {}; - return res.json(); - } - /** Format a GCal event for output */ - formatEvent(e) { - return { - id: e.id, - summary: e.summary ?? '(no title)', - description: e.description ?? '', - location: e.location ?? '', - start: e.start?.dateTime ?? e.start?.date ?? '', - end: e.end?.dateTime ?? e.end?.date ?? '', - allDay: !!e.start?.date, - attendees: (e.attendees ?? []).map((a) => ({ - email: a.email, - name: a.displayName ?? '', - status: a.responseStatus ?? 'needsAction', - })), - htmlLink: e.htmlLink ?? '', - status: e.status ?? 'confirmed', - }; - } - getTools() { - return [ - // ── gcal_list_calendars ───────────────────────────────────────────────── - { - name: 'gcal_list_calendars', - description: 'List all Google Calendars accessible to the user', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const res = await this.calFetch('/users/me/calendarList'); - return { - count: res.items?.length ?? 0, - calendars: (res.items ?? []).map((c) => ({ - id: c.id, - summary: c.summary, - primary: c.primary ?? false, - timeZone: c.timeZone ?? '', - color: c.colorId ?? '', - })), - }; - }, - }, - // ── gcal_list_events ──────────────────────────────────────────────────── - { - name: 'gcal_list_events', - description: 'List upcoming calendar events', - inputSchema: { - type: 'object', - properties: { - calendarId: { - type: 'string', - description: 'Calendar ID (default: "primary")', - }, - timeMin: { - type: 'string', - description: 'Start of time range (ISO 8601, default: now)', - }, - timeMax: { - type: 'string', - description: 'End of time range (ISO 8601)', - }, - maxResults: { - type: 'number', - description: 'Max events to return (default: 10)', - }, - q: { - type: 'string', - description: 'Free text search in event fields', - }, - }, - }, - handler: async ({ calendarId = 'primary', timeMin, timeMax, maxResults = 10, q }) => { - const params = new URLSearchParams({ - singleEvents: 'true', - orderBy: 'startTime', - maxResults: String(Math.min(maxResults, 100)), - timeMin: timeMin ?? new Date().toISOString(), - }); - if (timeMax) - params.set('timeMax', timeMax); - if (q) - params.set('q', q); - const res = await this.calFetch(`/calendars/${encodeURIComponent(calendarId)}/events?${params}`); - return { - count: res.items?.length ?? 0, - timeZone: res.timeZone ?? '', - events: (res.items ?? []).map(this.formatEvent.bind(this)), - }; - }, - }, - // ── gcal_get_event ────────────────────────────────────────────────────── - { - name: 'gcal_get_event', - description: 'Get full details of a specific calendar event', - inputSchema: { - type: 'object', - properties: { - eventId: { type: 'string', description: 'Event ID from gcal_list_events' }, - calendarId: { type: 'string', description: 'Calendar ID (default: "primary")' }, - }, - required: ['eventId'], - }, - handler: async ({ eventId, calendarId = 'primary' }) => { - const e = await this.calFetch(`/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`); - return this.formatEvent(e); - }, - }, - // ── gcal_create_event ─────────────────────────────────────────────────── - { - name: 'gcal_create_event', - description: 'Create a new Google Calendar event', - inputSchema: { - type: 'object', - properties: { - summary: { type: 'string', description: 'Event title' }, - start: { type: 'string', description: 'Start time (ISO 8601)' }, - end: { type: 'string', description: 'End time (ISO 8601)' }, - description: { type: 'string', description: 'Event description' }, - location: { type: 'string', description: 'Physical location or meeting link' }, - attendees: { - type: 'array', - items: { type: 'string' }, - description: 'List of attendee email addresses', - }, - calendarId: { type: 'string', description: 'Calendar ID (default: "primary")' }, - allDay: { type: 'boolean', description: 'Whether this is an all-day event' }, - }, - required: ['summary', 'start', 'end'], - }, - handler: async ({ summary, start, end, description, location, attendees = [], calendarId = 'primary', allDay = false, }) => { - const body = { - summary, - description, - location, - attendees: attendees.map((email) => ({ email })), - start: allDay ? { date: start.split('T')[0] } : { dateTime: start }, - end: allDay ? { date: end.split('T')[0] } : { dateTime: end }, - }; - const e = await this.calFetch(`/calendars/${encodeURIComponent(calendarId)}/events`, { method: 'POST', body }); - return { created: true, ...this.formatEvent(e) }; - }, - }, - // ── gcal_update_event ─────────────────────────────────────────────────── - { - name: 'gcal_update_event', - description: 'Update an existing Google Calendar event', - inputSchema: { - type: 'object', - properties: { - eventId: { type: 'string', description: 'Event ID to update' }, - calendarId: { type: 'string', description: 'Calendar ID (default: "primary")' }, - summary: { type: 'string', description: 'New title' }, - start: { type: 'string', description: 'New start time (ISO 8601)' }, - end: { type: 'string', description: 'New end time (ISO 8601)' }, - description: { type: 'string', description: 'New description' }, - location: { type: 'string', description: 'New location' }, - }, - required: ['eventId'], - }, - handler: async ({ eventId, calendarId = 'primary', ...updates }) => { - // PATCH — only send provided fields - const patch = {}; - if (updates.summary !== undefined) - patch.summary = updates.summary; - if (updates.description !== undefined) - patch.description = updates.description; - if (updates.location !== undefined) - patch.location = updates.location; - if (updates.start !== undefined) - patch.start = { dateTime: updates.start }; - if (updates.end !== undefined) - patch.end = { dateTime: updates.end }; - const e = await this.calFetch(`/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`, { method: 'PATCH', body: patch }); - return { updated: true, ...this.formatEvent(e) }; - }, - }, - // ── gcal_delete_event ─────────────────────────────────────────────────── - { - name: 'gcal_delete_event', - description: 'Delete a Google Calendar event', - inputSchema: { - type: 'object', - properties: { - eventId: { type: 'string', description: 'Event ID to delete' }, - calendarId: { type: 'string', description: 'Calendar ID (default: "primary")' }, - }, - required: ['eventId'], - }, - handler: async ({ eventId, calendarId = 'primary' }) => { - await this.calFetch(`/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`, { method: 'DELETE' }); - return { deleted: true, eventId }; - }, - }, - ]; - } -} diff --git a/plugins/gcal/dist/index.d.ts b/plugins/gcal/dist/index.d.ts deleted file mode 100644 index 0065117..0000000 --- a/plugins/gcal/dist/index.d.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Google Calendar Plugin - * - * Read, create, update, and delete calendar events. - * Requires Google OAuth — stores access token as google / access_token. - * - * Scopes needed: - * https://www.googleapis.com/auth/calendar - * https://www.googleapis.com/auth/calendar.events - */ -interface PluginConfigField { - key: string; - label: string; - type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; - secret?: boolean; - service?: string; - description?: string; - options?: string[]; -} -interface PluginConfigSchema { - fields: PluginConfigField[]; - setupInstructions?: string; -} -interface PluginTool { - name: string; - description: string; - inputSchema: Record; - handler: (input: Record) => Promise; - requiresApproval?: boolean; -} -interface Plugin { - name: string; - description: string; - version: string; - configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - getTools(): PluginTool[]; - getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; - set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { - getConfig(): ConfigManagerLike; -} -export declare class GoogleCalendarPlugin implements Plugin { - name: string; - description: string; - version: string; - configSchema: { - fields: { - key: string; - label: string; - type: "password"; - required: boolean; - secret: boolean; - service: string; - description: string; - }[]; - }; - private keychain; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - private getToken; - private calFetch; - /** Format a GCal event for output */ - private formatEvent; - getTools(): PluginTool[]; -} -export {}; diff --git a/plugins/gcal/dist/index.js b/plugins/gcal/dist/index.js deleted file mode 100644 index ceb2025..0000000 --- a/plugins/gcal/dist/index.js +++ /dev/null @@ -1,265 +0,0 @@ -/** - * Google Calendar Plugin - * - * Read, create, update, and delete calendar events. - * Requires Google OAuth — stores access token as google / access_token. - * - * Scopes needed: - * https://www.googleapis.com/auth/calendar - * https://www.googleapis.com/auth/calendar.events - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service, _account, _value) { } - async delete(_service, _account) { } -} -const CAL_BASE = 'https://www.googleapis.com/calendar/v3'; -export class GoogleCalendarPlugin { - name = 'gcal'; - description = 'Read and manage Google Calendar events — requires Google OAuth'; - version = '1.0.0'; - configSchema = { - fields: [ - { - key: 'access_token', - label: 'Google Access Token', - type: 'password', - required: true, - secret: true, - service: 'google', - description: 'Shared with Gmail plugin. Run "conductor auth google" to setup.' - } - ] - }; - keychain; - async initialize(conductor) { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - isConfigured() { return true; } - async getToken() { - const token = await this.keychain.get('google', 'access_token'); - if (!token) - throw new Error('Google not authenticated. Run: conductor auth google'); - return token; - } - async calFetch(path, options = {}) { - const token = await this.getToken(); - const res = await fetch(`${CAL_BASE}${path}`, { - method: options.method ?? 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: options.body ? JSON.stringify(options.body) : undefined, - }); - if (!res.ok) { - const err = await res.text().catch(() => res.statusText); - if (res.status === 401) - throw new Error('Google token expired. Re-authenticate: conductor auth google'); - throw new Error(`Google Calendar API ${res.status}: ${err}`); - } - if (res.status === 204) - return {}; - return res.json(); - } - /** Format a GCal event for output */ - formatEvent(e) { - return { - id: e.id, - summary: e.summary ?? '(no title)', - description: e.description ?? '', - location: e.location ?? '', - start: e.start?.dateTime ?? e.start?.date ?? '', - end: e.end?.dateTime ?? e.end?.date ?? '', - allDay: !!e.start?.date, - attendees: (e.attendees ?? []).map((a) => ({ - email: a.email, - name: a.displayName ?? '', - status: a.responseStatus ?? 'needsAction', - })), - htmlLink: e.htmlLink ?? '', - status: e.status ?? 'confirmed', - }; - } - getTools() { - return [ - // ── gcal_list_calendars ───────────────────────────────────────────────── - { - name: 'gcal_list_calendars', - description: 'List all Google Calendars accessible to the user', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const res = await this.calFetch('/users/me/calendarList'); - return { - count: res.items?.length ?? 0, - calendars: (res.items ?? []).map((c) => ({ - id: c.id, - summary: c.summary, - primary: c.primary ?? false, - timeZone: c.timeZone ?? '', - color: c.colorId ?? '', - })), - }; - }, - }, - // ── gcal_list_events ──────────────────────────────────────────────────── - { - name: 'gcal_list_events', - description: 'List upcoming calendar events', - inputSchema: { - type: 'object', - properties: { - calendarId: { - type: 'string', - description: 'Calendar ID (default: "primary")', - }, - timeMin: { - type: 'string', - description: 'Start of time range (ISO 8601, default: now)', - }, - timeMax: { - type: 'string', - description: 'End of time range (ISO 8601)', - }, - maxResults: { - type: 'number', - description: 'Max events to return (default: 10)', - }, - q: { - type: 'string', - description: 'Free text search in event fields', - }, - }, - }, - handler: async ({ calendarId = 'primary', timeMin, timeMax, maxResults = 10, q }) => { - const params = new URLSearchParams({ - singleEvents: 'true', - orderBy: 'startTime', - maxResults: String(Math.min(maxResults, 100)), - timeMin: timeMin ?? new Date().toISOString(), - }); - if (timeMax) - params.set('timeMax', timeMax); - if (q) - params.set('q', q); - const res = await this.calFetch(`/calendars/${encodeURIComponent(calendarId)}/events?${params}`); - return { - count: res.items?.length ?? 0, - timeZone: res.timeZone ?? '', - events: (res.items ?? []).map(this.formatEvent.bind(this)), - }; - }, - }, - // ── gcal_get_event ────────────────────────────────────────────────────── - { - name: 'gcal_get_event', - description: 'Get full details of a specific calendar event', - inputSchema: { - type: 'object', - properties: { - eventId: { type: 'string', description: 'Event ID from gcal_list_events' }, - calendarId: { type: 'string', description: 'Calendar ID (default: "primary")' }, - }, - required: ['eventId'], - }, - handler: async ({ eventId, calendarId = 'primary' }) => { - const e = await this.calFetch(`/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`); - return this.formatEvent(e); - }, - }, - // ── gcal_create_event ─────────────────────────────────────────────────── - { - name: 'gcal_create_event', - description: 'Create a new Google Calendar event', - inputSchema: { - type: 'object', - properties: { - summary: { type: 'string', description: 'Event title' }, - start: { type: 'string', description: 'Start time (ISO 8601)' }, - end: { type: 'string', description: 'End time (ISO 8601)' }, - description: { type: 'string', description: 'Event description' }, - location: { type: 'string', description: 'Physical location or meeting link' }, - attendees: { - type: 'array', - items: { type: 'string' }, - description: 'List of attendee email addresses', - }, - calendarId: { type: 'string', description: 'Calendar ID (default: "primary")' }, - allDay: { type: 'boolean', description: 'Whether this is an all-day event' }, - }, - required: ['summary', 'start', 'end'], - }, - handler: async ({ summary, start, end, description, location, attendees = [], calendarId = 'primary', allDay = false, }) => { - const body = { - summary, - description, - location, - attendees: attendees.map((email) => ({ email })), - start: allDay ? { date: start.split('T')[0] } : { dateTime: start }, - end: allDay ? { date: end.split('T')[0] } : { dateTime: end }, - }; - const e = await this.calFetch(`/calendars/${encodeURIComponent(calendarId)}/events`, { method: 'POST', body }); - return { created: true, ...this.formatEvent(e) }; - }, - }, - // ── gcal_update_event ─────────────────────────────────────────────────── - { - name: 'gcal_update_event', - description: 'Update an existing Google Calendar event', - inputSchema: { - type: 'object', - properties: { - eventId: { type: 'string', description: 'Event ID to update' }, - calendarId: { type: 'string', description: 'Calendar ID (default: "primary")' }, - summary: { type: 'string', description: 'New title' }, - start: { type: 'string', description: 'New start time (ISO 8601)' }, - end: { type: 'string', description: 'New end time (ISO 8601)' }, - description: { type: 'string', description: 'New description' }, - location: { type: 'string', description: 'New location' }, - }, - required: ['eventId'], - }, - handler: async ({ eventId, calendarId = 'primary', ...updates }) => { - // PATCH — only send provided fields - const patch = {}; - if (updates.summary !== undefined) - patch.summary = updates.summary; - if (updates.description !== undefined) - patch.description = updates.description; - if (updates.location !== undefined) - patch.location = updates.location; - if (updates.start !== undefined) - patch.start = { dateTime: updates.start }; - if (updates.end !== undefined) - patch.end = { dateTime: updates.end }; - const e = await this.calFetch(`/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`, { method: 'PATCH', body: patch }); - return { updated: true, ...this.formatEvent(e) }; - }, - }, - // ── gcal_delete_event ─────────────────────────────────────────────────── - { - name: 'gcal_delete_event', - description: 'Delete a Google Calendar event', - inputSchema: { - type: 'object', - properties: { - eventId: { type: 'string', description: 'Event ID to delete' }, - calendarId: { type: 'string', description: 'Calendar ID (default: "primary")' }, - }, - required: ['eventId'], - }, - handler: async ({ eventId, calendarId = 'primary' }) => { - await this.calFetch(`/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`, { method: 'DELETE' }); - return { deleted: true, eventId }; - }, - }, - ]; - } -} diff --git a/plugins/gcal/package.json b/plugins/gcal/package.json deleted file mode 100644 index 1ff6dd2..0000000 --- a/plugins/gcal/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@conductor-plugins/gcal", - "version": "1.0.0", - "type": "module", - "main": "dist/gcal.js", - "scripts": { - "build": "tsc", - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "typescript": "^5.0.0", - "@types/node": "^20.0.0" - } -} diff --git a/plugins/gcal/src/index.ts b/plugins/gcal/src/index.ts deleted file mode 100644 index 76e4761..0000000 --- a/plugins/gcal/src/index.ts +++ /dev/null @@ -1,314 +0,0 @@ -/** - * Google Calendar Plugin - * - * Read, create, update, and delete calendar events. - * Requires Google OAuth — stores access token as google / access_token. - * - * Scopes needed: - * https://www.googleapis.com/auth/calendar - * https://www.googleapis.com/auth/calendar.events - */ - -// ── Inlined types (no external dependencies) ───────────────────────────────── - -interface PluginConfigField { - key: string; label: string; type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; secret?: boolean; service?: string; description?: string; options?: string[]; -} -interface PluginConfigSchema { fields: PluginConfigField[]; setupInstructions?: string; } -interface PluginTool { - name: string; description: string; inputSchema: Record; - handler: (input: Record) => Promise; requiresApproval?: boolean; -} -interface Plugin { - name: string; description: string; version: string; configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; isConfigured(): boolean; - getTools(): PluginTool[]; getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { getConfig(): ConfigManagerLike; } -class Keychain { - constructor(private dir: string) {} - async get(service: string, account: string): Promise { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service: string, _account: string, _value: string): Promise {} - async delete(_service: string, _account: string): Promise {} -} - -const CAL_BASE = 'https://www.googleapis.com/calendar/v3'; - -export class GoogleCalendarPlugin implements Plugin { - name = 'gcal'; - description = 'Read and manage Google Calendar events — requires Google OAuth'; - version = '1.0.0'; - - configSchema = { - fields: [ - { - key: 'access_token', - label: 'Google Access Token', - type: 'password' as const, - required: true, - secret: true, - service: 'google', - description: 'Shared with Gmail plugin. Run "conductor auth google" to setup.' - } - ] - }; - - private keychain!: Keychain; - - async initialize(conductor: Conductor): Promise { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - - isConfigured(): boolean { return true; } - - private async getToken(): Promise { - const token = await this.keychain.get('google', 'access_token'); - if (!token) throw new Error('Google not authenticated. Run: conductor auth google'); - return token; - } - - private async calFetch(path: string, options: { method?: string; body?: any } = {}): Promise { - const token = await this.getToken(); - const res = await fetch(`${CAL_BASE}${path}`, { - method: options.method ?? 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: options.body ? JSON.stringify(options.body) : undefined, - }); - if (!res.ok) { - const err = await res.text().catch(() => res.statusText); - if (res.status === 401) throw new Error('Google token expired. Re-authenticate: conductor auth google'); - throw new Error(`Google Calendar API ${res.status}: ${err}`); - } - if (res.status === 204) return {}; - return res.json(); - } - - /** Format a GCal event for output */ - private formatEvent(e: any) { - return { - id: e.id, - summary: e.summary ?? '(no title)', - description: e.description ?? '', - location: e.location ?? '', - start: e.start?.dateTime ?? e.start?.date ?? '', - end: e.end?.dateTime ?? e.end?.date ?? '', - allDay: !!e.start?.date, - attendees: (e.attendees ?? []).map((a: any) => ({ - email: a.email, - name: a.displayName ?? '', - status: a.responseStatus ?? 'needsAction', - })), - htmlLink: e.htmlLink ?? '', - status: e.status ?? 'confirmed', - }; - } - - getTools(): PluginTool[] { - return [ - // ── gcal_list_calendars ───────────────────────────────────────────────── - { - name: 'gcal_list_calendars', - description: 'List all Google Calendars accessible to the user', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const res = await this.calFetch('/users/me/calendarList'); - return { - count: res.items?.length ?? 0, - calendars: (res.items ?? []).map((c: any) => ({ - id: c.id, - summary: c.summary, - primary: c.primary ?? false, - timeZone: c.timeZone ?? '', - color: c.colorId ?? '', - })), - }; - }, - }, - - // ── gcal_list_events ──────────────────────────────────────────────────── - { - name: 'gcal_list_events', - description: 'List upcoming calendar events', - inputSchema: { - type: 'object', - properties: { - calendarId: { - type: 'string', - description: 'Calendar ID (default: "primary")', - }, - timeMin: { - type: 'string', - description: 'Start of time range (ISO 8601, default: now)', - }, - timeMax: { - type: 'string', - description: 'End of time range (ISO 8601)', - }, - maxResults: { - type: 'number', - description: 'Max events to return (default: 10)', - }, - q: { - type: 'string', - description: 'Free text search in event fields', - }, - }, - }, - handler: async ({ calendarId = 'primary', timeMin, timeMax, maxResults = 10, q }: any) => { - const params = new URLSearchParams({ - singleEvents: 'true', - orderBy: 'startTime', - maxResults: String(Math.min(maxResults, 100)), - timeMin: timeMin ?? new Date().toISOString(), - }); - if (timeMax) params.set('timeMax', timeMax); - if (q) params.set('q', q); - - const res = await this.calFetch( - `/calendars/${encodeURIComponent(calendarId)}/events?${params}` - ); - return { - count: res.items?.length ?? 0, - timeZone: res.timeZone ?? '', - events: (res.items ?? []).map(this.formatEvent.bind(this)), - }; - }, - }, - - // ── gcal_get_event ────────────────────────────────────────────────────── - { - name: 'gcal_get_event', - description: 'Get full details of a specific calendar event', - inputSchema: { - type: 'object', - properties: { - eventId: { type: 'string', description: 'Event ID from gcal_list_events' }, - calendarId: { type: 'string', description: 'Calendar ID (default: "primary")' }, - }, - required: ['eventId'], - }, - handler: async ({ eventId, calendarId = 'primary' }: any) => { - const e = await this.calFetch( - `/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}` - ); - return this.formatEvent(e); - }, - }, - - // ── gcal_create_event ─────────────────────────────────────────────────── - { - name: 'gcal_create_event', - description: 'Create a new Google Calendar event', - inputSchema: { - type: 'object', - properties: { - summary: { type: 'string', description: 'Event title' }, - start: { type: 'string', description: 'Start time (ISO 8601)' }, - end: { type: 'string', description: 'End time (ISO 8601)' }, - description: { type: 'string', description: 'Event description' }, - location: { type: 'string', description: 'Physical location or meeting link' }, - attendees: { - type: 'array', - items: { type: 'string' }, - description: 'List of attendee email addresses', - }, - calendarId: { type: 'string', description: 'Calendar ID (default: "primary")' }, - allDay: { type: 'boolean', description: 'Whether this is an all-day event' }, - }, - required: ['summary', 'start', 'end'], - }, - handler: async ({ - summary, - start, - end, - description, - location, - attendees = [], - calendarId = 'primary', - allDay = false, - }: any) => { - const body: any = { - summary, - description, - location, - attendees: attendees.map((email: string) => ({ email })), - start: allDay ? { date: start.split('T')[0] } : { dateTime: start }, - end: allDay ? { date: end.split('T')[0] } : { dateTime: end }, - }; - - const e = await this.calFetch( - `/calendars/${encodeURIComponent(calendarId)}/events`, - { method: 'POST', body } - ); - return { created: true, ...this.formatEvent(e) }; - }, - }, - - // ── gcal_update_event ─────────────────────────────────────────────────── - { - name: 'gcal_update_event', - description: 'Update an existing Google Calendar event', - inputSchema: { - type: 'object', - properties: { - eventId: { type: 'string', description: 'Event ID to update' }, - calendarId: { type: 'string', description: 'Calendar ID (default: "primary")' }, - summary: { type: 'string', description: 'New title' }, - start: { type: 'string', description: 'New start time (ISO 8601)' }, - end: { type: 'string', description: 'New end time (ISO 8601)' }, - description: { type: 'string', description: 'New description' }, - location: { type: 'string', description: 'New location' }, - }, - required: ['eventId'], - }, - handler: async ({ eventId, calendarId = 'primary', ...updates }: any) => { - // PATCH — only send provided fields - const patch: any = {}; - if (updates.summary !== undefined) patch.summary = updates.summary; - if (updates.description !== undefined) patch.description = updates.description; - if (updates.location !== undefined) patch.location = updates.location; - if (updates.start !== undefined) patch.start = { dateTime: updates.start }; - if (updates.end !== undefined) patch.end = { dateTime: updates.end }; - - const e = await this.calFetch( - `/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`, - { method: 'PATCH', body: patch } - ); - return { updated: true, ...this.formatEvent(e) }; - }, - }, - - // ── gcal_delete_event ─────────────────────────────────────────────────── - { - name: 'gcal_delete_event', - description: 'Delete a Google Calendar event', - inputSchema: { - type: 'object', - properties: { - eventId: { type: 'string', description: 'Event ID to delete' }, - calendarId: { type: 'string', description: 'Calendar ID (default: "primary")' }, - }, - required: ['eventId'], - }, - handler: async ({ eventId, calendarId = 'primary' }: any) => { - await this.calFetch( - `/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`, - { method: 'DELETE' } - ); - return { deleted: true, eventId }; - }, - }, - ]; - } -} diff --git a/plugins/gcal/tsconfig.json b/plugins/gcal/tsconfig.json deleted file mode 100644 index dce5a7d..0000000 --- a/plugins/gcal/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "skipLibCheck": true, - "declaration": true - }, - "include": ["src/**/*"] -} diff --git a/plugins/gdrive/README.md b/plugins/gdrive/README.md deleted file mode 100644 index a5dd9ac..0000000 --- a/plugins/gdrive/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Google Drive Plugin for Conductor - -Install: `conductor install gdrive` - -## Setup - -**Authentication:** Google OAuth - -```bash -conductor auth google -`conductor plugins enable gdrive` -``` - -Get credentials at: https://developers.google.com/drive - -## Tools - -``` -gdrive_list, gdrive_search, gdrive_get, gdrive_create, gdrive_upload, gdrive_delete, gdrive_share -``` - -Each tool is documented inline — ask Conductor what tools are available after installing. - -## Source - -Part of [thealxlabs/conductor-plugins](https://github.com/thealxlabs/conductor-plugins/tree/main/plugins/gdrive). diff --git a/plugins/gdrive/dist/gdrive.js b/plugins/gdrive/dist/gdrive.js deleted file mode 100644 index 01ba942..0000000 --- a/plugins/gdrive/dist/gdrive.js +++ /dev/null @@ -1,304 +0,0 @@ -/** - * Google Drive Plugin - * - * List, search, read, upload, and manage Google Drive files. - * Requires Google OAuth — google / access_token in keychain. - * - * Scopes needed: - * https://www.googleapis.com/auth/drive.readonly (for read-only) - * https://www.googleapis.com/auth/drive (for full access) - * https://www.googleapis.com/auth/drive.file (for files created by the app) - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service, _account, _value) { } - async delete(_service, _account) { } -} -const DRIVE_BASE = 'https://www.googleapis.com/drive/v3'; -const DRIVE_UPLOAD = 'https://www.googleapis.com/upload/drive/v3'; -export class GoogleDrivePlugin { - name = 'gdrive'; - description = 'List, search, read, and upload files in Google Drive — requires Google OAuth'; - version = '1.0.0'; - keychain; - async initialize(conductor) { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - isConfigured() { return true; } - async getToken() { - const token = await this.keychain.get('google', 'access_token'); - if (!token) - throw new Error('Google not authenticated. Run: conductor auth google'); - return token; - } - async driveFetch(path, options = {}) { - const token = await this.getToken(); - const base = options.base ?? DRIVE_BASE; - const isRaw = options.rawBody !== undefined; - const res = await fetch(`${base}${path}`, { - method: options.method ?? 'GET', - headers: { - Authorization: `Bearer ${token}`, - ...(isRaw - ? { 'Content-Type': options.contentType ?? 'text/plain' } - : { 'Content-Type': 'application/json' }), - }, - body: isRaw ? options.rawBody : options.body ? JSON.stringify(options.body) : undefined, - }); - if (!res.ok) { - const err = await res.text().catch(() => res.statusText); - if (res.status === 401) - throw new Error('Google token expired. Re-authenticate: conductor auth google'); - throw new Error(`Google Drive API ${res.status}: ${err}`); - } - if (res.status === 204) - return {}; - const ct = res.headers.get('content-type') ?? ''; - if (ct.includes('application/json')) - return res.json(); - return res.text(); - } - formatFile(f) { - return { - id: f.id, - name: f.name, - mimeType: f.mimeType, - size: f.size ? Number(f.size) : null, - modifiedTime: f.modifiedTime ?? '', - createdTime: f.createdTime ?? '', - webViewLink: f.webViewLink ?? '', - parents: f.parents ?? [], - shared: f.shared ?? false, - trashed: f.trashed ?? false, - }; - } - getTools() { - return [ - // ── gdrive_list ───────────────────────────────────────────────────────── - { - name: 'gdrive_list', - description: 'List files and folders in Google Drive', - inputSchema: { - type: 'object', - properties: { - folderId: { - type: 'string', - description: 'Folder ID to list contents of (default: root)', - }, - maxResults: { type: 'number', description: 'Max files to return (default: 20)' }, - orderBy: { - type: 'string', - description: 'Sort order e.g. "modifiedTime desc", "name"', - }, - }, - }, - handler: async ({ folderId = 'root', maxResults = 20, orderBy = 'modifiedTime desc' }) => { - const q = `'${folderId}' in parents and trashed = false`; - const fields = 'files(id,name,mimeType,size,modifiedTime,webViewLink,shared)'; - const params = new URLSearchParams({ - q, - fields, - pageSize: String(Math.min(maxResults, 100)), - orderBy, - }); - const res = await this.driveFetch(`/files?${params}`); - return { - count: res.files?.length ?? 0, - files: (res.files ?? []).map(this.formatFile.bind(this)), - }; - }, - }, - // ── gdrive_search ─────────────────────────────────────────────────────── - { - name: 'gdrive_search', - description: 'Search Google Drive files by name or content. Supports Drive query syntax e.g. name contains "budget" mimeType="application/vnd.google-apps.spreadsheet"', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Search query (file name or Drive query)' }, - maxResults: { type: 'number', description: 'Max results (default: 10)' }, - }, - required: ['query'], - }, - handler: async ({ query, maxResults = 10 }) => { - // If query looks like a plain name search (no Drive operators), wrap it - const q = query.includes('=') || query.includes(' and ') || query.includes(' or ') - ? `(${query}) and trashed = false` - : `name contains '${query.replace(/'/g, "\\'")}' and trashed = false`; - const fields = 'files(id,name,mimeType,size,modifiedTime,webViewLink,parents)'; - const params = new URLSearchParams({ - q, - fields, - pageSize: String(Math.min(maxResults, 50)), - }); - const res = await this.driveFetch(`/files?${params}`); - return { - count: res.files?.length ?? 0, - files: (res.files ?? []).map(this.formatFile.bind(this)), - }; - }, - }, - // ── gdrive_get ────────────────────────────────────────────────────────── - { - name: 'gdrive_get', - description: 'Get metadata about a specific Drive file', - inputSchema: { - type: 'object', - properties: { - fileId: { type: 'string', description: 'Google Drive file ID' }, - }, - required: ['fileId'], - }, - handler: async ({ fileId }) => { - const fields = 'id,name,mimeType,size,modifiedTime,createdTime,webViewLink,parents,shared,trashed,owners'; - const f = await this.driveFetch(`/files/${encodeURIComponent(fileId)}?fields=${fields}`); - return { - ...this.formatFile(f), - owners: (f.owners ?? []).map((o) => ({ email: o.emailAddress, name: o.displayName })), - }; - }, - }, - // ── gdrive_read ───────────────────────────────────────────────────────── - { - name: 'gdrive_read', - description: 'Read the text content of a Drive file. ' + - 'Google Docs/Sheets/Slides are exported as plain text. ' + - 'Binary files return an error.', - inputSchema: { - type: 'object', - properties: { - fileId: { type: 'string', description: 'Google Drive file ID' }, - maxChars: { type: 'number', description: 'Max characters to return (default: 10000)' }, - }, - required: ['fileId'], - }, - handler: async ({ fileId, maxChars = 10000 }) => { - // Get metadata first to determine how to export - const meta = await this.driveFetch(`/files/${encodeURIComponent(fileId)}?fields=name,mimeType,size`); - let content; - const mimeType = meta.mimeType ?? ''; - const GOOGLE_EXPORTS = { - 'application/vnd.google-apps.document': 'text/plain', - 'application/vnd.google-apps.spreadsheet': 'text/csv', - 'application/vnd.google-apps.presentation': 'text/plain', - }; - if (GOOGLE_EXPORTS[mimeType]) { - content = await this.driveFetch(`/files/${encodeURIComponent(fileId)}/export?mimeType=${encodeURIComponent(GOOGLE_EXPORTS[mimeType])}`); - } - else if (mimeType.startsWith('text/') || mimeType === 'application/json') { - content = await this.driveFetch(`/files/${encodeURIComponent(fileId)}?alt=media`); - } - else { - return { - error: `Cannot read binary file (${mimeType}). Use gdrive_get for metadata.`, - name: meta.name, - mimeType, - }; - } - const text = String(content); - return { - name: meta.name, - mimeType, - length: text.length, - truncated: text.length > maxChars, - content: text.slice(0, maxChars), - }; - }, - }, - // ── gdrive_create_folder ──────────────────────────────────────────────── - { - name: 'gdrive_create_folder', - description: 'Create a new folder in Google Drive', - inputSchema: { - type: 'object', - properties: { - name: { type: 'string', description: 'Folder name' }, - parentId: { type: 'string', description: 'Parent folder ID (default: root)' }, - }, - required: ['name'], - }, - handler: async ({ name, parentId = 'root' }) => { - const f = await this.driveFetch('/files', { - method: 'POST', - body: { - name, - mimeType: 'application/vnd.google-apps.folder', - parents: [parentId], - }, - }); - return { created: true, id: f.id, name: f.name, webViewLink: f.webViewLink ?? '' }; - }, - }, - // ── gdrive_upload_text ────────────────────────────────────────────────── - { - name: 'gdrive_upload_text', - description: 'Upload a text file to Google Drive', - inputSchema: { - type: 'object', - properties: { - name: { type: 'string', description: 'File name including extension' }, - content: { type: 'string', description: 'Text content to upload' }, - parentId: { type: 'string', description: 'Parent folder ID (default: root)' }, - mimeType: { - type: 'string', - description: 'MIME type (default: text/plain)', - }, - }, - required: ['name', 'content'], - }, - handler: async ({ name, content, parentId = 'root', mimeType = 'text/plain' }) => { - // Multipart upload - const boundary = `conductor_${Date.now()}`; - const metadata = JSON.stringify({ name, parents: [parentId] }); - const body = [ - `--${boundary}`, - 'Content-Type: application/json; charset=UTF-8', - '', - metadata, - `--${boundary}`, - `Content-Type: ${mimeType}`, - '', - content, - `--${boundary}--`, - ].join('\r\n'); - const token = await this.getToken(); - const res = await fetch(`${DRIVE_UPLOAD}/files?uploadType=multipart`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': `multipart/related; boundary=${boundary}`, - }, - body, - }); - if (!res.ok) - throw new Error(`Drive upload failed: ${res.status} ${res.statusText}`); - const f = await res.json(); - return { uploaded: true, id: f.id, name: f.name }; - }, - }, - // ── gdrive_delete ─────────────────────────────────────────────────────── - { - name: 'gdrive_delete', - description: 'Permanently delete a file from Google Drive', - inputSchema: { - type: 'object', - properties: { - fileId: { type: 'string', description: 'File ID to delete' }, - }, - required: ['fileId'], - }, - handler: async ({ fileId }) => { - await this.driveFetch(`/files/${encodeURIComponent(fileId)}`, { method: 'DELETE' }); - return { deleted: true, fileId }; - }, - }, - ]; - } -} diff --git a/plugins/gdrive/dist/index.d.ts b/plugins/gdrive/dist/index.d.ts deleted file mode 100644 index 780d936..0000000 --- a/plugins/gdrive/dist/index.d.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Google Drive Plugin - * - * List, search, read, upload, and manage Google Drive files. - * Requires Google OAuth — google / access_token in keychain. - * - * Scopes needed: - * https://www.googleapis.com/auth/drive.readonly (for read-only) - * https://www.googleapis.com/auth/drive (for full access) - * https://www.googleapis.com/auth/drive.file (for files created by the app) - */ -interface PluginConfigField { - key: string; - label: string; - type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; - secret?: boolean; - service?: string; - description?: string; - options?: string[]; -} -interface PluginConfigSchema { - fields: PluginConfigField[]; - setupInstructions?: string; -} -interface PluginTool { - name: string; - description: string; - inputSchema: Record; - handler: (input: Record) => Promise; - requiresApproval?: boolean; -} -interface Plugin { - name: string; - description: string; - version: string; - configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - getTools(): PluginTool[]; - getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; - set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { - getConfig(): ConfigManagerLike; -} -export declare class GoogleDrivePlugin implements Plugin { - name: string; - description: string; - version: string; - private keychain; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - private getToken; - private driveFetch; - private formatFile; - getTools(): PluginTool[]; -} -export {}; diff --git a/plugins/gdrive/dist/index.js b/plugins/gdrive/dist/index.js deleted file mode 100644 index 01ba942..0000000 --- a/plugins/gdrive/dist/index.js +++ /dev/null @@ -1,304 +0,0 @@ -/** - * Google Drive Plugin - * - * List, search, read, upload, and manage Google Drive files. - * Requires Google OAuth — google / access_token in keychain. - * - * Scopes needed: - * https://www.googleapis.com/auth/drive.readonly (for read-only) - * https://www.googleapis.com/auth/drive (for full access) - * https://www.googleapis.com/auth/drive.file (for files created by the app) - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service, _account, _value) { } - async delete(_service, _account) { } -} -const DRIVE_BASE = 'https://www.googleapis.com/drive/v3'; -const DRIVE_UPLOAD = 'https://www.googleapis.com/upload/drive/v3'; -export class GoogleDrivePlugin { - name = 'gdrive'; - description = 'List, search, read, and upload files in Google Drive — requires Google OAuth'; - version = '1.0.0'; - keychain; - async initialize(conductor) { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - isConfigured() { return true; } - async getToken() { - const token = await this.keychain.get('google', 'access_token'); - if (!token) - throw new Error('Google not authenticated. Run: conductor auth google'); - return token; - } - async driveFetch(path, options = {}) { - const token = await this.getToken(); - const base = options.base ?? DRIVE_BASE; - const isRaw = options.rawBody !== undefined; - const res = await fetch(`${base}${path}`, { - method: options.method ?? 'GET', - headers: { - Authorization: `Bearer ${token}`, - ...(isRaw - ? { 'Content-Type': options.contentType ?? 'text/plain' } - : { 'Content-Type': 'application/json' }), - }, - body: isRaw ? options.rawBody : options.body ? JSON.stringify(options.body) : undefined, - }); - if (!res.ok) { - const err = await res.text().catch(() => res.statusText); - if (res.status === 401) - throw new Error('Google token expired. Re-authenticate: conductor auth google'); - throw new Error(`Google Drive API ${res.status}: ${err}`); - } - if (res.status === 204) - return {}; - const ct = res.headers.get('content-type') ?? ''; - if (ct.includes('application/json')) - return res.json(); - return res.text(); - } - formatFile(f) { - return { - id: f.id, - name: f.name, - mimeType: f.mimeType, - size: f.size ? Number(f.size) : null, - modifiedTime: f.modifiedTime ?? '', - createdTime: f.createdTime ?? '', - webViewLink: f.webViewLink ?? '', - parents: f.parents ?? [], - shared: f.shared ?? false, - trashed: f.trashed ?? false, - }; - } - getTools() { - return [ - // ── gdrive_list ───────────────────────────────────────────────────────── - { - name: 'gdrive_list', - description: 'List files and folders in Google Drive', - inputSchema: { - type: 'object', - properties: { - folderId: { - type: 'string', - description: 'Folder ID to list contents of (default: root)', - }, - maxResults: { type: 'number', description: 'Max files to return (default: 20)' }, - orderBy: { - type: 'string', - description: 'Sort order e.g. "modifiedTime desc", "name"', - }, - }, - }, - handler: async ({ folderId = 'root', maxResults = 20, orderBy = 'modifiedTime desc' }) => { - const q = `'${folderId}' in parents and trashed = false`; - const fields = 'files(id,name,mimeType,size,modifiedTime,webViewLink,shared)'; - const params = new URLSearchParams({ - q, - fields, - pageSize: String(Math.min(maxResults, 100)), - orderBy, - }); - const res = await this.driveFetch(`/files?${params}`); - return { - count: res.files?.length ?? 0, - files: (res.files ?? []).map(this.formatFile.bind(this)), - }; - }, - }, - // ── gdrive_search ─────────────────────────────────────────────────────── - { - name: 'gdrive_search', - description: 'Search Google Drive files by name or content. Supports Drive query syntax e.g. name contains "budget" mimeType="application/vnd.google-apps.spreadsheet"', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Search query (file name or Drive query)' }, - maxResults: { type: 'number', description: 'Max results (default: 10)' }, - }, - required: ['query'], - }, - handler: async ({ query, maxResults = 10 }) => { - // If query looks like a plain name search (no Drive operators), wrap it - const q = query.includes('=') || query.includes(' and ') || query.includes(' or ') - ? `(${query}) and trashed = false` - : `name contains '${query.replace(/'/g, "\\'")}' and trashed = false`; - const fields = 'files(id,name,mimeType,size,modifiedTime,webViewLink,parents)'; - const params = new URLSearchParams({ - q, - fields, - pageSize: String(Math.min(maxResults, 50)), - }); - const res = await this.driveFetch(`/files?${params}`); - return { - count: res.files?.length ?? 0, - files: (res.files ?? []).map(this.formatFile.bind(this)), - }; - }, - }, - // ── gdrive_get ────────────────────────────────────────────────────────── - { - name: 'gdrive_get', - description: 'Get metadata about a specific Drive file', - inputSchema: { - type: 'object', - properties: { - fileId: { type: 'string', description: 'Google Drive file ID' }, - }, - required: ['fileId'], - }, - handler: async ({ fileId }) => { - const fields = 'id,name,mimeType,size,modifiedTime,createdTime,webViewLink,parents,shared,trashed,owners'; - const f = await this.driveFetch(`/files/${encodeURIComponent(fileId)}?fields=${fields}`); - return { - ...this.formatFile(f), - owners: (f.owners ?? []).map((o) => ({ email: o.emailAddress, name: o.displayName })), - }; - }, - }, - // ── gdrive_read ───────────────────────────────────────────────────────── - { - name: 'gdrive_read', - description: 'Read the text content of a Drive file. ' + - 'Google Docs/Sheets/Slides are exported as plain text. ' + - 'Binary files return an error.', - inputSchema: { - type: 'object', - properties: { - fileId: { type: 'string', description: 'Google Drive file ID' }, - maxChars: { type: 'number', description: 'Max characters to return (default: 10000)' }, - }, - required: ['fileId'], - }, - handler: async ({ fileId, maxChars = 10000 }) => { - // Get metadata first to determine how to export - const meta = await this.driveFetch(`/files/${encodeURIComponent(fileId)}?fields=name,mimeType,size`); - let content; - const mimeType = meta.mimeType ?? ''; - const GOOGLE_EXPORTS = { - 'application/vnd.google-apps.document': 'text/plain', - 'application/vnd.google-apps.spreadsheet': 'text/csv', - 'application/vnd.google-apps.presentation': 'text/plain', - }; - if (GOOGLE_EXPORTS[mimeType]) { - content = await this.driveFetch(`/files/${encodeURIComponent(fileId)}/export?mimeType=${encodeURIComponent(GOOGLE_EXPORTS[mimeType])}`); - } - else if (mimeType.startsWith('text/') || mimeType === 'application/json') { - content = await this.driveFetch(`/files/${encodeURIComponent(fileId)}?alt=media`); - } - else { - return { - error: `Cannot read binary file (${mimeType}). Use gdrive_get for metadata.`, - name: meta.name, - mimeType, - }; - } - const text = String(content); - return { - name: meta.name, - mimeType, - length: text.length, - truncated: text.length > maxChars, - content: text.slice(0, maxChars), - }; - }, - }, - // ── gdrive_create_folder ──────────────────────────────────────────────── - { - name: 'gdrive_create_folder', - description: 'Create a new folder in Google Drive', - inputSchema: { - type: 'object', - properties: { - name: { type: 'string', description: 'Folder name' }, - parentId: { type: 'string', description: 'Parent folder ID (default: root)' }, - }, - required: ['name'], - }, - handler: async ({ name, parentId = 'root' }) => { - const f = await this.driveFetch('/files', { - method: 'POST', - body: { - name, - mimeType: 'application/vnd.google-apps.folder', - parents: [parentId], - }, - }); - return { created: true, id: f.id, name: f.name, webViewLink: f.webViewLink ?? '' }; - }, - }, - // ── gdrive_upload_text ────────────────────────────────────────────────── - { - name: 'gdrive_upload_text', - description: 'Upload a text file to Google Drive', - inputSchema: { - type: 'object', - properties: { - name: { type: 'string', description: 'File name including extension' }, - content: { type: 'string', description: 'Text content to upload' }, - parentId: { type: 'string', description: 'Parent folder ID (default: root)' }, - mimeType: { - type: 'string', - description: 'MIME type (default: text/plain)', - }, - }, - required: ['name', 'content'], - }, - handler: async ({ name, content, parentId = 'root', mimeType = 'text/plain' }) => { - // Multipart upload - const boundary = `conductor_${Date.now()}`; - const metadata = JSON.stringify({ name, parents: [parentId] }); - const body = [ - `--${boundary}`, - 'Content-Type: application/json; charset=UTF-8', - '', - metadata, - `--${boundary}`, - `Content-Type: ${mimeType}`, - '', - content, - `--${boundary}--`, - ].join('\r\n'); - const token = await this.getToken(); - const res = await fetch(`${DRIVE_UPLOAD}/files?uploadType=multipart`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': `multipart/related; boundary=${boundary}`, - }, - body, - }); - if (!res.ok) - throw new Error(`Drive upload failed: ${res.status} ${res.statusText}`); - const f = await res.json(); - return { uploaded: true, id: f.id, name: f.name }; - }, - }, - // ── gdrive_delete ─────────────────────────────────────────────────────── - { - name: 'gdrive_delete', - description: 'Permanently delete a file from Google Drive', - inputSchema: { - type: 'object', - properties: { - fileId: { type: 'string', description: 'File ID to delete' }, - }, - required: ['fileId'], - }, - handler: async ({ fileId }) => { - await this.driveFetch(`/files/${encodeURIComponent(fileId)}`, { method: 'DELETE' }); - return { deleted: true, fileId }; - }, - }, - ]; - } -} diff --git a/plugins/gdrive/package.json b/plugins/gdrive/package.json deleted file mode 100644 index 7dae905..0000000 --- a/plugins/gdrive/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@conductor-plugins/gdrive", - "version": "1.0.0", - "type": "module", - "main": "dist/gdrive.js", - "scripts": { - "build": "tsc", - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "typescript": "^5.0.0", - "@types/node": "^20.0.0" - } -} diff --git a/plugins/gdrive/src/index.ts b/plugins/gdrive/src/index.ts deleted file mode 100644 index 96cc35b..0000000 --- a/plugins/gdrive/src/index.ts +++ /dev/null @@ -1,350 +0,0 @@ -/** - * Google Drive Plugin - * - * List, search, read, upload, and manage Google Drive files. - * Requires Google OAuth — google / access_token in keychain. - * - * Scopes needed: - * https://www.googleapis.com/auth/drive.readonly (for read-only) - * https://www.googleapis.com/auth/drive (for full access) - * https://www.googleapis.com/auth/drive.file (for files created by the app) - */ - -// ── Inlined types (no external dependencies) ───────────────────────────────── - -interface PluginConfigField { - key: string; label: string; type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; secret?: boolean; service?: string; description?: string; options?: string[]; -} -interface PluginConfigSchema { fields: PluginConfigField[]; setupInstructions?: string; } -interface PluginTool { - name: string; description: string; inputSchema: Record; - handler: (input: Record) => Promise; requiresApproval?: boolean; -} -interface Plugin { - name: string; description: string; version: string; configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; isConfigured(): boolean; - getTools(): PluginTool[]; getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { getConfig(): ConfigManagerLike; } -class Keychain { - constructor(private dir: string) {} - async get(service: string, account: string): Promise { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service: string, _account: string, _value: string): Promise {} - async delete(_service: string, _account: string): Promise {} -} - -const DRIVE_BASE = 'https://www.googleapis.com/drive/v3'; -const DRIVE_UPLOAD = 'https://www.googleapis.com/upload/drive/v3'; - -export class GoogleDrivePlugin implements Plugin { - name = 'gdrive'; - description = 'List, search, read, and upload files in Google Drive — requires Google OAuth'; - version = '1.0.0'; - - private keychain!: Keychain; - - async initialize(conductor: Conductor): Promise { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - - isConfigured(): boolean { return true; } - - private async getToken(): Promise { - const token = await this.keychain.get('google', 'access_token'); - if (!token) throw new Error('Google not authenticated. Run: conductor auth google'); - return token; - } - - private async driveFetch(path: string, options: { - method?: string; - body?: any; - base?: string; - rawBody?: Buffer | string; - contentType?: string; - } = {}): Promise { - const token = await this.getToken(); - const base = options.base ?? DRIVE_BASE; - const isRaw = options.rawBody !== undefined; - - const res = await fetch(`${base}${path}`, { - method: options.method ?? 'GET', - headers: { - Authorization: `Bearer ${token}`, - ...(isRaw - ? { 'Content-Type': options.contentType ?? 'text/plain' } - : { 'Content-Type': 'application/json' }), - }, - body: isRaw ? (options.rawBody as BodyInit) : options.body ? JSON.stringify(options.body) : undefined, - }); - if (!res.ok) { - const err = await res.text().catch(() => res.statusText); - if (res.status === 401) throw new Error('Google token expired. Re-authenticate: conductor auth google'); - throw new Error(`Google Drive API ${res.status}: ${err}`); - } - if (res.status === 204) return {}; - const ct = res.headers.get('content-type') ?? ''; - if (ct.includes('application/json')) return res.json(); - return res.text(); - } - - private formatFile(f: any) { - return { - id: f.id, - name: f.name, - mimeType: f.mimeType, - size: f.size ? Number(f.size) : null, - modifiedTime: f.modifiedTime ?? '', - createdTime: f.createdTime ?? '', - webViewLink: f.webViewLink ?? '', - parents: f.parents ?? [], - shared: f.shared ?? false, - trashed: f.trashed ?? false, - }; - } - - getTools(): PluginTool[] { - return [ - // ── gdrive_list ───────────────────────────────────────────────────────── - { - name: 'gdrive_list', - description: 'List files and folders in Google Drive', - inputSchema: { - type: 'object', - properties: { - folderId: { - type: 'string', - description: 'Folder ID to list contents of (default: root)', - }, - maxResults: { type: 'number', description: 'Max files to return (default: 20)' }, - orderBy: { - type: 'string', - description: 'Sort order e.g. "modifiedTime desc", "name"', - }, - }, - }, - handler: async ({ folderId = 'root', maxResults = 20, orderBy = 'modifiedTime desc' }: any) => { - const q = `'${folderId}' in parents and trashed = false`; - const fields = 'files(id,name,mimeType,size,modifiedTime,webViewLink,shared)'; - const params = new URLSearchParams({ - q, - fields, - pageSize: String(Math.min(maxResults, 100)), - orderBy, - }); - const res = await this.driveFetch(`/files?${params}`); - return { - count: res.files?.length ?? 0, - files: (res.files ?? []).map(this.formatFile.bind(this)), - }; - }, - }, - - // ── gdrive_search ─────────────────────────────────────────────────────── - { - name: 'gdrive_search', - description: - 'Search Google Drive files by name or content. Supports Drive query syntax e.g. name contains "budget" mimeType="application/vnd.google-apps.spreadsheet"', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Search query (file name or Drive query)' }, - maxResults: { type: 'number', description: 'Max results (default: 10)' }, - }, - required: ['query'], - }, - handler: async ({ query, maxResults = 10 }: any) => { - // If query looks like a plain name search (no Drive operators), wrap it - const q = query.includes('=') || query.includes(' and ') || query.includes(' or ') - ? `(${query}) and trashed = false` - : `name contains '${query.replace(/'/g, "\\'")}' and trashed = false`; - - const fields = 'files(id,name,mimeType,size,modifiedTime,webViewLink,parents)'; - const params = new URLSearchParams({ - q, - fields, - pageSize: String(Math.min(maxResults, 50)), - }); - const res = await this.driveFetch(`/files?${params}`); - return { - count: res.files?.length ?? 0, - files: (res.files ?? []).map(this.formatFile.bind(this)), - }; - }, - }, - - // ── gdrive_get ────────────────────────────────────────────────────────── - { - name: 'gdrive_get', - description: 'Get metadata about a specific Drive file', - inputSchema: { - type: 'object', - properties: { - fileId: { type: 'string', description: 'Google Drive file ID' }, - }, - required: ['fileId'], - }, - handler: async ({ fileId }: any) => { - const fields = 'id,name,mimeType,size,modifiedTime,createdTime,webViewLink,parents,shared,trashed,owners'; - const f = await this.driveFetch(`/files/${encodeURIComponent(fileId)}?fields=${fields}`); - return { - ...this.formatFile(f), - owners: (f.owners ?? []).map((o: any) => ({ email: o.emailAddress, name: o.displayName })), - }; - }, - }, - - // ── gdrive_read ───────────────────────────────────────────────────────── - { - name: 'gdrive_read', - description: - 'Read the text content of a Drive file. ' + - 'Google Docs/Sheets/Slides are exported as plain text. ' + - 'Binary files return an error.', - inputSchema: { - type: 'object', - properties: { - fileId: { type: 'string', description: 'Google Drive file ID' }, - maxChars: { type: 'number', description: 'Max characters to return (default: 10000)' }, - }, - required: ['fileId'], - }, - handler: async ({ fileId, maxChars = 10000 }: any) => { - // Get metadata first to determine how to export - const meta = await this.driveFetch( - `/files/${encodeURIComponent(fileId)}?fields=name,mimeType,size` - ); - - let content: string; - const mimeType: string = meta.mimeType ?? ''; - - const GOOGLE_EXPORTS: Record = { - 'application/vnd.google-apps.document': 'text/plain', - 'application/vnd.google-apps.spreadsheet': 'text/csv', - 'application/vnd.google-apps.presentation': 'text/plain', - }; - - if (GOOGLE_EXPORTS[mimeType]) { - content = await this.driveFetch( - `/files/${encodeURIComponent(fileId)}/export?mimeType=${encodeURIComponent(GOOGLE_EXPORTS[mimeType])}` - ); - } else if (mimeType.startsWith('text/') || mimeType === 'application/json') { - content = await this.driveFetch(`/files/${encodeURIComponent(fileId)}?alt=media`); - } else { - return { - error: `Cannot read binary file (${mimeType}). Use gdrive_get for metadata.`, - name: meta.name, - mimeType, - }; - } - - const text = String(content); - return { - name: meta.name, - mimeType, - length: text.length, - truncated: text.length > maxChars, - content: text.slice(0, maxChars), - }; - }, - }, - - // ── gdrive_create_folder ──────────────────────────────────────────────── - { - name: 'gdrive_create_folder', - description: 'Create a new folder in Google Drive', - inputSchema: { - type: 'object', - properties: { - name: { type: 'string', description: 'Folder name' }, - parentId: { type: 'string', description: 'Parent folder ID (default: root)' }, - }, - required: ['name'], - }, - handler: async ({ name, parentId = 'root' }: any) => { - const f = await this.driveFetch('/files', { - method: 'POST', - body: { - name, - mimeType: 'application/vnd.google-apps.folder', - parents: [parentId], - }, - }); - return { created: true, id: f.id, name: f.name, webViewLink: f.webViewLink ?? '' }; - }, - }, - - // ── gdrive_upload_text ────────────────────────────────────────────────── - { - name: 'gdrive_upload_text', - description: 'Upload a text file to Google Drive', - inputSchema: { - type: 'object', - properties: { - name: { type: 'string', description: 'File name including extension' }, - content: { type: 'string', description: 'Text content to upload' }, - parentId: { type: 'string', description: 'Parent folder ID (default: root)' }, - mimeType: { - type: 'string', - description: 'MIME type (default: text/plain)', - }, - }, - required: ['name', 'content'], - }, - handler: async ({ name, content, parentId = 'root', mimeType = 'text/plain' }: any) => { - // Multipart upload - const boundary = `conductor_${Date.now()}`; - const metadata = JSON.stringify({ name, parents: [parentId] }); - const body = [ - `--${boundary}`, - 'Content-Type: application/json; charset=UTF-8', - '', - metadata, - `--${boundary}`, - `Content-Type: ${mimeType}`, - '', - content, - `--${boundary}--`, - ].join('\r\n'); - - const token = await this.getToken(); - const res = await fetch(`${DRIVE_UPLOAD}/files?uploadType=multipart`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': `multipart/related; boundary=${boundary}`, - }, - body, - }); - if (!res.ok) throw new Error(`Drive upload failed: ${res.status} ${res.statusText}`); - const f = await res.json() as any; - return { uploaded: true, id: f.id, name: f.name }; - }, - }, - - // ── gdrive_delete ─────────────────────────────────────────────────────── - { - name: 'gdrive_delete', - description: 'Permanently delete a file from Google Drive', - inputSchema: { - type: 'object', - properties: { - fileId: { type: 'string', description: 'File ID to delete' }, - }, - required: ['fileId'], - }, - handler: async ({ fileId }: any) => { - await this.driveFetch(`/files/${encodeURIComponent(fileId)}`, { method: 'DELETE' }); - return { deleted: true, fileId }; - }, - }, - ]; - } -} diff --git a/plugins/gdrive/tsconfig.json b/plugins/gdrive/tsconfig.json deleted file mode 100644 index dce5a7d..0000000 --- a/plugins/gdrive/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "skipLibCheck": true, - "declaration": true - }, - "include": ["src/**/*"] -} diff --git a/plugins/github-actions/README.md b/plugins/github-actions/README.md deleted file mode 100644 index 9706b33..0000000 --- a/plugins/github-actions/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# GitHub Actions Plugin for Conductor - -Install: `conductor install github-actions` - -## Setup - -**Authentication:** Personal Access Token - -```bash -conductor plugins config github token \ -`conductor plugins enable github-actions` -``` - -Get credentials at: https://docs.github.com/en/rest/actions - -## Tools - -``` -actions_list_workflows, actions_list_runs, actions_get_run, actions_trigger, actions_cancel, actions_rerun, actions_logs -``` - -Each tool is documented inline — ask Conductor what tools are available after installing. - -## Source - -Part of [thealxlabs/conductor-plugins](https://github.com/thealxlabs/conductor-plugins/tree/main/plugins/github-actions). diff --git a/plugins/github-actions/dist/github-actions.js b/plugins/github-actions/dist/github-actions.js deleted file mode 100644 index c72d989..0000000 --- a/plugins/github-actions/dist/github-actions.js +++ /dev/null @@ -1,633 +0,0 @@ -/** - * GitHub Actions Plugin — TheAlxLabs / Conductor - * - * Full GitHub CI/CD and project management: - * - Workflow runs: trigger, monitor, cancel, get logs - * - Pull requests: list, create, review, merge - * - Issues: create, update, comment, assign, label - * - Releases: list, create, publish - * - Notifications: check what needs attention - * - Code search across your repos - * - * Extends the existing github plugin (which handles public data). - * This plugin focuses on authenticated, write, and Actions operations. - * - * Setup: - * 1. https://github.com/settings/tokens → Fine-grained or classic PAT - * 2. Scopes needed: repo, workflow, read:user, notifications - * 3. Run: conductor plugins config github_actions token - * - * Keychain: github / token - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service, _account, _value) { } - async delete(_service, _account) { } -} -const GH_BASE = 'https://api.github.com'; -const GH_ACCEPT = 'application/vnd.github+json'; -const GH_API_VERSION = '2022-11-28'; -export class GitHubActionsPlugin { - name = 'github_actions'; - description = 'GitHub CI/CD, PRs, issues, releases, notifications — full write access, requires PAT'; - version = '1.0.0'; - keychain; - async initialize(conductor) { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - isConfigured() { - return true; - } - async getToken() { - const token = await this.keychain.get('github', 'token'); - if (!token) { - throw new Error('GitHub token not configured.\n' + - 'Create a PAT at https://github.com/settings/tokens\n' + - 'Then run: conductor plugins config github_actions token '); - } - return token; - } - async ghFetch(path, options = {}) { - const token = await this.getToken(); - const url = new URL(`${GH_BASE}${path}`); - if (options.params) { - for (const [k, v] of Object.entries(options.params)) - url.searchParams.set(k, v); - } - const res = await fetch(url.toString(), { - method: options.method ?? 'GET', - headers: { - Authorization: `Bearer ${token}`, - Accept: GH_ACCEPT, - 'X-GitHub-Api-Version': GH_API_VERSION, - 'Content-Type': 'application/json', - }, - body: options.body ? JSON.stringify(options.body) : undefined, - }); - if (res.status === 204) - return {}; - if (!res.ok) { - const err = (await res.json().catch(() => ({ message: res.statusText }))); - throw new Error(`GitHub API ${res.status}: ${err.message ?? res.statusText}`); - } - return res.json(); - } - // ── Formatters ────────────────────────────────────────────────────────────── - formatRun(r) { - return { - id: r.id, - name: r.name, - workflow: r.workflow_id, - status: r.status, - conclusion: r.conclusion ?? 'pending', - branch: r.head_branch, - commit: r.head_sha?.slice(0, 8), - triggeredBy: r.triggering_actor?.login ?? r.actor?.login ?? '', - event: r.event, - createdAt: r.created_at, - updatedAt: r.updated_at, - duration: r.updated_at && r.created_at - ? `${Math.round((new Date(r.updated_at).getTime() - new Date(r.created_at).getTime()) / 1000)}s` - : null, - url: r.html_url, - }; - } - formatPR(pr) { - return { - number: pr.number, - title: pr.title, - state: pr.state, - draft: pr.draft ?? false, - author: pr.user?.login ?? '', - base: pr.base?.ref ?? '', - head: pr.head?.ref ?? '', - additions: pr.additions ?? 0, - deletions: pr.deletions ?? 0, - changedFiles: pr.changed_files ?? 0, - mergeable: pr.mergeable, - labels: (pr.labels ?? []).map((l) => l.name), - reviewers: (pr.requested_reviewers ?? []).map((r) => r.login), - createdAt: pr.created_at, - updatedAt: pr.updated_at, - url: pr.html_url, - body: pr.body?.slice(0, 500) ?? '', - }; - } - formatIssue(i) { - return { - number: i.number, - title: i.title, - state: i.state, - author: i.user?.login ?? '', - assignees: (i.assignees ?? []).map((a) => a.login), - labels: (i.labels ?? []).map((l) => l.name), - comments: i.comments ?? 0, - createdAt: i.created_at, - updatedAt: i.updated_at, - url: i.html_url, - body: i.body?.slice(0, 500) ?? '', - }; - } - // ── Tools ─────────────────────────────────────────────────────────────────── - getTools() { - return [ - // ── gh_my_repos ──────────────────────────────────────────────────────── - { - name: 'gh_my_repos', - description: "List the authenticated user's own repositories", - inputSchema: { - type: 'object', - properties: { - sort: { - type: 'string', - enum: ['updated', 'created', 'pushed', 'full_name'], - description: 'Sort order (default: pushed)', - }, - limit: { type: 'number', description: 'Max repos (default: 30)' }, - visibility: { - type: 'string', - enum: ['all', 'public', 'private'], - description: 'Filter by visibility (default: all)', - }, - }, - }, - handler: async ({ sort = 'pushed', limit = 30, visibility = 'all' }) => { - const data = await this.ghFetch('/user/repos', { - params: { - sort, - per_page: String(Math.min(limit, 100)), - visibility, - affiliation: 'owner', - }, - }); - return { - count: data.length, - repos: data.map((r) => ({ - name: r.name, - fullName: r.full_name, - description: r.description ?? '', - language: r.language ?? '', - private: r.private, - stars: r.stargazers_count, - openIssues: r.open_issues_count, - defaultBranch: r.default_branch, - pushedAt: r.pushed_at, - url: r.html_url, - })), - }; - }, - }, - // ── gh_workflow_runs ─────────────────────────────────────────────────── - { - name: 'gh_workflow_runs', - description: 'List recent workflow runs for a repository', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string', description: 'Repo owner' }, - repo: { type: 'string', description: 'Repo name' }, - status: { - type: 'string', - enum: ['completed', 'in_progress', 'queued', 'failure', 'success'], - description: 'Filter by status', - }, - branch: { type: 'string', description: 'Filter by branch name' }, - limit: { type: 'number', description: 'Max runs (default: 10)' }, - }, - required: ['owner', 'repo'], - }, - handler: async ({ owner, repo, status, branch, limit = 10 }) => { - const params = { per_page: String(Math.min(limit, 100)) }; - if (status) - params.status = status; - if (branch) - params.branch = branch; - const data = await this.ghFetch(`/repos/${owner}/${repo}/actions/runs`, { params }); - return { - totalCount: data.total_count ?? 0, - runs: (data.workflow_runs ?? []).map(this.formatRun.bind(this)), - }; - }, - }, - // ── gh_run_status ────────────────────────────────────────────────────── - { - name: 'gh_run_status', - description: 'Get the status and jobs of a specific workflow run', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - runId: { type: 'number', description: 'Workflow run ID' }, - }, - required: ['owner', 'repo', 'runId'], - }, - handler: async ({ owner, repo, runId }) => { - const [run, jobs] = await Promise.all([ - this.ghFetch(`/repos/${owner}/${repo}/actions/runs/${runId}`), - this.ghFetch(`/repos/${owner}/${repo}/actions/runs/${runId}/jobs`), - ]); - return { - ...this.formatRun(run), - jobs: (jobs.jobs ?? []).map((j) => ({ - id: j.id, - name: j.name, - status: j.status, - conclusion: j.conclusion ?? 'pending', - startedAt: j.started_at, - completedAt: j.completed_at, - steps: (j.steps ?? []).map((s) => ({ - name: s.name, - status: s.status, - conclusion: s.conclusion, - number: s.number, - })), - })), - }; - }, - }, - // ── gh_trigger_workflow ──────────────────────────────────────────────── - { - name: 'gh_trigger_workflow', - description: 'Manually trigger a GitHub Actions workflow (workflow_dispatch)', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - workflow: { - type: 'string', - description: 'Workflow file name (e.g. "deploy.yml") or ID', - }, - ref: { type: 'string', description: 'Branch or tag to run on (default: main)' }, - inputs: { - type: 'object', - description: 'Workflow input parameters (key-value pairs)', - }, - }, - required: ['owner', 'repo', 'workflow'], - }, - handler: async ({ owner, repo, workflow, ref = 'main', inputs = {} }) => { - await this.ghFetch(`/repos/${owner}/${repo}/actions/workflows/${encodeURIComponent(workflow)}/dispatches`, { method: 'POST', body: { ref, inputs } }); - // Give GitHub a moment then fetch the latest run - await new Promise((r) => setTimeout(r, 2000)); - const runs = await this.ghFetch(`/repos/${owner}/${repo}/actions/runs`, { - params: { per_page: '1', event: 'workflow_dispatch' }, - }); - const latestRun = runs.workflow_runs?.[0]; - return { - triggered: true, - run: latestRun ? this.formatRun(latestRun) : null, - }; - }, - }, - // ── gh_cancel_run ────────────────────────────────────────────────────── - { - name: 'gh_cancel_run', - description: 'Cancel a running GitHub Actions workflow run', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - runId: { type: 'number' }, - }, - required: ['owner', 'repo', 'runId'], - }, - handler: async ({ owner, repo, runId }) => { - await this.ghFetch(`/repos/${owner}/${repo}/actions/runs/${runId}/cancel`, { - method: 'POST', - }); - return { cancelled: true, runId }; - }, - }, - // ── gh_list_prs ──────────────────────────────────────────────────────── - { - name: 'gh_list_prs', - description: 'List pull requests for a repository', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - state: { - type: 'string', - enum: ['open', 'closed', 'all'], - description: 'PR state filter (default: open)', - }, - limit: { type: 'number', description: 'Max PRs (default: 20)' }, - }, - required: ['owner', 'repo'], - }, - handler: async ({ owner, repo, state = 'open', limit = 20 }) => { - const data = await this.ghFetch(`/repos/${owner}/${repo}/pulls`, { - params: { state, per_page: String(Math.min(limit, 100)), sort: 'updated' }, - }); - return { - count: data.length, - prs: data.map(this.formatPR.bind(this)), - }; - }, - }, - // ── gh_create_pr ─────────────────────────────────────────────────────── - { - name: 'gh_create_pr', - description: 'Create a new pull request', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - title: { type: 'string', description: 'PR title' }, - body: { type: 'string', description: 'PR description' }, - head: { type: 'string', description: 'Source branch (your feature branch)' }, - base: { type: 'string', description: 'Target branch (default: main)' }, - draft: { type: 'boolean', description: 'Create as draft PR (default: false)' }, - }, - required: ['owner', 'repo', 'title', 'head'], - }, - handler: async ({ owner, repo, title, body = '', head, base = 'main', draft = false }) => { - const pr = await this.ghFetch(`/repos/${owner}/${repo}/pulls`, { - method: 'POST', - body: { title, body, head, base, draft }, - }); - return { created: true, ...this.formatPR(pr) }; - }, - }, - // ── gh_merge_pr ──────────────────────────────────────────────────────── - { - name: 'gh_merge_pr', - description: 'Merge a pull request', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - prNumber: { type: 'number', description: 'PR number' }, - method: { - type: 'string', - enum: ['merge', 'squash', 'rebase'], - description: 'Merge strategy (default: merge)', - }, - commitTitle: { type: 'string', description: 'Custom commit title' }, - }, - required: ['owner', 'repo', 'prNumber'], - }, - handler: async ({ owner, repo, prNumber, method = 'merge', commitTitle }) => { - const body = { merge_method: method }; - if (commitTitle) - body.commit_title = commitTitle; - const res = await this.ghFetch(`/repos/${owner}/${repo}/pulls/${prNumber}/merge`, { - method: 'PUT', - body, - }); - return { merged: true, sha: res.sha, message: res.message }; - }, - }, - // ── gh_list_issues ───────────────────────────────────────────────────── - { - name: 'gh_list_issues', - description: 'List issues for a repository', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - state: { - type: 'string', - enum: ['open', 'closed', 'all'], - description: 'Issue state (default: open)', - }, - labels: { type: 'string', description: 'Comma-separated label filter' }, - assignee: { type: 'string', description: 'Filter by assignee username' }, - limit: { type: 'number', description: 'Max issues (default: 20)' }, - }, - required: ['owner', 'repo'], - }, - handler: async ({ owner, repo, state = 'open', labels, assignee, limit = 20 }) => { - const params = { - state, - per_page: String(Math.min(limit, 100)), - sort: 'updated', - }; - if (labels) - params.labels = labels; - if (assignee) - params.assignee = assignee; - const data = await this.ghFetch(`/repos/${owner}/${repo}/issues`, { params }); - // Filter out pull requests (they appear in issues endpoint too) - const issues = data.filter((i) => !i.pull_request); - return { - count: issues.length, - issues: issues.map(this.formatIssue.bind(this)), - }; - }, - }, - // ── gh_create_issue ──────────────────────────────────────────────────── - { - name: 'gh_create_issue', - description: 'Create a new GitHub issue', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - title: { type: 'string', description: 'Issue title' }, - body: { type: 'string', description: 'Issue body (markdown)' }, - labels: { - type: 'array', - items: { type: 'string' }, - description: 'Label names to apply', - }, - assignees: { - type: 'array', - items: { type: 'string' }, - description: 'GitHub usernames to assign', - }, - }, - required: ['owner', 'repo', 'title'], - }, - handler: async ({ owner, repo, title, body = '', labels = [], assignees = [] }) => { - const issue = await this.ghFetch(`/repos/${owner}/${repo}/issues`, { - method: 'POST', - body: { title, body, labels, assignees }, - }); - return { created: true, ...this.formatIssue(issue) }; - }, - }, - // ── gh_comment ───────────────────────────────────────────────────────── - { - name: 'gh_comment', - description: 'Add a comment to a GitHub issue or pull request', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - number: { type: 'number', description: 'Issue or PR number' }, - body: { type: 'string', description: 'Comment text (markdown)' }, - }, - required: ['owner', 'repo', 'number', 'body'], - }, - handler: async ({ owner, repo, number, body }) => { - const comment = await this.ghFetch(`/repos/${owner}/${repo}/issues/${number}/comments`, { - method: 'POST', - body: { body }, - }); - return { - created: true, - id: comment.id, - url: comment.html_url, - }; - }, - }, - // ── gh_releases ──────────────────────────────────────────────────────── - { - name: 'gh_releases', - description: 'List releases for a repository', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - limit: { type: 'number', description: 'Max releases (default: 10)' }, - }, - required: ['owner', 'repo'], - }, - handler: async ({ owner, repo, limit = 10 }) => { - const data = await this.ghFetch(`/repos/${owner}/${repo}/releases`, { - params: { per_page: String(Math.min(limit, 100)) }, - }); - return { - count: data.length, - releases: data.map((r) => ({ - id: r.id, - tag: r.tag_name, - name: r.name ?? r.tag_name, - draft: r.draft, - prerelease: r.prerelease, - author: r.author?.login ?? '', - publishedAt: r.published_at, - body: r.body?.slice(0, 500) ?? '', - assets: (r.assets ?? []).map((a) => ({ - name: a.name, - size: a.size, - downloads: a.download_count, - })), - url: r.html_url, - })), - }; - }, - }, - // ── gh_create_release ────────────────────────────────────────────────── - { - name: 'gh_create_release', - description: 'Create a new GitHub release', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - tag: { type: 'string', description: 'Tag name (e.g. v1.2.3)' }, - name: { type: 'string', description: 'Release title' }, - body: { type: 'string', description: 'Release notes (markdown)' }, - draft: { type: 'boolean', description: 'Create as draft (default: false)' }, - prerelease: { type: 'boolean', description: 'Mark as pre-release (default: false)' }, - generateNotes: { - type: 'boolean', - description: 'Auto-generate release notes from PRs/commits (default: false)', - }, - }, - required: ['owner', 'repo', 'tag'], - }, - handler: async ({ owner, repo, tag, name, body = '', draft = false, prerelease = false, generateNotes = false, }) => { - const release = await this.ghFetch(`/repos/${owner}/${repo}/releases`, { - method: 'POST', - body: { - tag_name: tag, - name: name ?? tag, - body, - draft, - prerelease, - generate_release_notes: generateNotes, - }, - }); - return { - created: true, - id: release.id, - tag: release.tag_name, - name: release.name, - url: release.html_url, - draft: release.draft, - }; - }, - }, - // ── gh_notifications ─────────────────────────────────────────────────── - { - name: 'gh_notifications', - description: 'Get unread GitHub notifications (mentions, CI failures, reviews needed)', - inputSchema: { - type: 'object', - properties: { - limit: { type: 'number', description: 'Max notifications (default: 20)' }, - all: { type: 'boolean', description: 'Include read notifications (default: false)' }, - }, - }, - handler: async ({ limit = 20, all = false }) => { - const data = await this.ghFetch('/notifications', { - params: { - all: String(all), - per_page: String(Math.min(limit, 50)), - }, - }); - return { - count: data.length, - notifications: data.map((n) => ({ - id: n.id, - unread: n.unread, - reason: n.reason, - type: n.subject?.type ?? '', - title: n.subject?.title ?? '', - repo: n.repository?.full_name ?? '', - updatedAt: n.updated_at, - })), - }; - }, - }, - // ── gh_code_search ───────────────────────────────────────────────────── - { - name: 'gh_code_search', - description: 'Search code across GitHub repositories', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Search query (supports repo:, lang:, path: operators)' }, - limit: { type: 'number', description: 'Max results (default: 10)' }, - }, - required: ['query'], - }, - handler: async ({ query, limit = 10 }) => { - const data = await this.ghFetch('/search/code', { - params: { q: query, per_page: String(Math.min(limit, 30)) }, - }); - return { - totalCount: data.total_count ?? 0, - results: (data.items ?? []).map((i) => ({ - repo: i.repository?.full_name ?? '', - path: i.path, - name: i.name, - url: i.html_url, - })), - }; - }, - }, - ]; - } -} diff --git a/plugins/github-actions/dist/index.d.ts b/plugins/github-actions/dist/index.d.ts deleted file mode 100644 index f282fbf..0000000 --- a/plugins/github-actions/dist/index.d.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * GitHub Actions Plugin — TheAlxLabs / Conductor - * - * Full GitHub CI/CD and project management: - * - Workflow runs: trigger, monitor, cancel, get logs - * - Pull requests: list, create, review, merge - * - Issues: create, update, comment, assign, label - * - Releases: list, create, publish - * - Notifications: check what needs attention - * - Code search across your repos - * - * Extends the existing github plugin (which handles public data). - * This plugin focuses on authenticated, write, and Actions operations. - * - * Setup: - * 1. https://github.com/settings/tokens → Fine-grained or classic PAT - * 2. Scopes needed: repo, workflow, read:user, notifications - * 3. Run: conductor plugins config github_actions token - * - * Keychain: github / token - */ -interface PluginConfigField { - key: string; - label: string; - type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; - secret?: boolean; - service?: string; - description?: string; - options?: string[]; -} -interface PluginConfigSchema { - fields: PluginConfigField[]; - setupInstructions?: string; -} -interface PluginTool { - name: string; - description: string; - inputSchema: Record; - handler: (input: Record) => Promise; - requiresApproval?: boolean; -} -interface Plugin { - name: string; - description: string; - version: string; - configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - getTools(): PluginTool[]; - getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; - set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { - getConfig(): ConfigManagerLike; -} -export declare class GitHubActionsPlugin implements Plugin { - name: string; - description: string; - version: string; - private keychain; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - private getToken; - private ghFetch; - private formatRun; - private formatPR; - private formatIssue; - getTools(): PluginTool[]; -} -export {}; diff --git a/plugins/github-actions/dist/index.js b/plugins/github-actions/dist/index.js deleted file mode 100644 index c72d989..0000000 --- a/plugins/github-actions/dist/index.js +++ /dev/null @@ -1,633 +0,0 @@ -/** - * GitHub Actions Plugin — TheAlxLabs / Conductor - * - * Full GitHub CI/CD and project management: - * - Workflow runs: trigger, monitor, cancel, get logs - * - Pull requests: list, create, review, merge - * - Issues: create, update, comment, assign, label - * - Releases: list, create, publish - * - Notifications: check what needs attention - * - Code search across your repos - * - * Extends the existing github plugin (which handles public data). - * This plugin focuses on authenticated, write, and Actions operations. - * - * Setup: - * 1. https://github.com/settings/tokens → Fine-grained or classic PAT - * 2. Scopes needed: repo, workflow, read:user, notifications - * 3. Run: conductor plugins config github_actions token - * - * Keychain: github / token - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service, _account, _value) { } - async delete(_service, _account) { } -} -const GH_BASE = 'https://api.github.com'; -const GH_ACCEPT = 'application/vnd.github+json'; -const GH_API_VERSION = '2022-11-28'; -export class GitHubActionsPlugin { - name = 'github_actions'; - description = 'GitHub CI/CD, PRs, issues, releases, notifications — full write access, requires PAT'; - version = '1.0.0'; - keychain; - async initialize(conductor) { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - isConfigured() { - return true; - } - async getToken() { - const token = await this.keychain.get('github', 'token'); - if (!token) { - throw new Error('GitHub token not configured.\n' + - 'Create a PAT at https://github.com/settings/tokens\n' + - 'Then run: conductor plugins config github_actions token '); - } - return token; - } - async ghFetch(path, options = {}) { - const token = await this.getToken(); - const url = new URL(`${GH_BASE}${path}`); - if (options.params) { - for (const [k, v] of Object.entries(options.params)) - url.searchParams.set(k, v); - } - const res = await fetch(url.toString(), { - method: options.method ?? 'GET', - headers: { - Authorization: `Bearer ${token}`, - Accept: GH_ACCEPT, - 'X-GitHub-Api-Version': GH_API_VERSION, - 'Content-Type': 'application/json', - }, - body: options.body ? JSON.stringify(options.body) : undefined, - }); - if (res.status === 204) - return {}; - if (!res.ok) { - const err = (await res.json().catch(() => ({ message: res.statusText }))); - throw new Error(`GitHub API ${res.status}: ${err.message ?? res.statusText}`); - } - return res.json(); - } - // ── Formatters ────────────────────────────────────────────────────────────── - formatRun(r) { - return { - id: r.id, - name: r.name, - workflow: r.workflow_id, - status: r.status, - conclusion: r.conclusion ?? 'pending', - branch: r.head_branch, - commit: r.head_sha?.slice(0, 8), - triggeredBy: r.triggering_actor?.login ?? r.actor?.login ?? '', - event: r.event, - createdAt: r.created_at, - updatedAt: r.updated_at, - duration: r.updated_at && r.created_at - ? `${Math.round((new Date(r.updated_at).getTime() - new Date(r.created_at).getTime()) / 1000)}s` - : null, - url: r.html_url, - }; - } - formatPR(pr) { - return { - number: pr.number, - title: pr.title, - state: pr.state, - draft: pr.draft ?? false, - author: pr.user?.login ?? '', - base: pr.base?.ref ?? '', - head: pr.head?.ref ?? '', - additions: pr.additions ?? 0, - deletions: pr.deletions ?? 0, - changedFiles: pr.changed_files ?? 0, - mergeable: pr.mergeable, - labels: (pr.labels ?? []).map((l) => l.name), - reviewers: (pr.requested_reviewers ?? []).map((r) => r.login), - createdAt: pr.created_at, - updatedAt: pr.updated_at, - url: pr.html_url, - body: pr.body?.slice(0, 500) ?? '', - }; - } - formatIssue(i) { - return { - number: i.number, - title: i.title, - state: i.state, - author: i.user?.login ?? '', - assignees: (i.assignees ?? []).map((a) => a.login), - labels: (i.labels ?? []).map((l) => l.name), - comments: i.comments ?? 0, - createdAt: i.created_at, - updatedAt: i.updated_at, - url: i.html_url, - body: i.body?.slice(0, 500) ?? '', - }; - } - // ── Tools ─────────────────────────────────────────────────────────────────── - getTools() { - return [ - // ── gh_my_repos ──────────────────────────────────────────────────────── - { - name: 'gh_my_repos', - description: "List the authenticated user's own repositories", - inputSchema: { - type: 'object', - properties: { - sort: { - type: 'string', - enum: ['updated', 'created', 'pushed', 'full_name'], - description: 'Sort order (default: pushed)', - }, - limit: { type: 'number', description: 'Max repos (default: 30)' }, - visibility: { - type: 'string', - enum: ['all', 'public', 'private'], - description: 'Filter by visibility (default: all)', - }, - }, - }, - handler: async ({ sort = 'pushed', limit = 30, visibility = 'all' }) => { - const data = await this.ghFetch('/user/repos', { - params: { - sort, - per_page: String(Math.min(limit, 100)), - visibility, - affiliation: 'owner', - }, - }); - return { - count: data.length, - repos: data.map((r) => ({ - name: r.name, - fullName: r.full_name, - description: r.description ?? '', - language: r.language ?? '', - private: r.private, - stars: r.stargazers_count, - openIssues: r.open_issues_count, - defaultBranch: r.default_branch, - pushedAt: r.pushed_at, - url: r.html_url, - })), - }; - }, - }, - // ── gh_workflow_runs ─────────────────────────────────────────────────── - { - name: 'gh_workflow_runs', - description: 'List recent workflow runs for a repository', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string', description: 'Repo owner' }, - repo: { type: 'string', description: 'Repo name' }, - status: { - type: 'string', - enum: ['completed', 'in_progress', 'queued', 'failure', 'success'], - description: 'Filter by status', - }, - branch: { type: 'string', description: 'Filter by branch name' }, - limit: { type: 'number', description: 'Max runs (default: 10)' }, - }, - required: ['owner', 'repo'], - }, - handler: async ({ owner, repo, status, branch, limit = 10 }) => { - const params = { per_page: String(Math.min(limit, 100)) }; - if (status) - params.status = status; - if (branch) - params.branch = branch; - const data = await this.ghFetch(`/repos/${owner}/${repo}/actions/runs`, { params }); - return { - totalCount: data.total_count ?? 0, - runs: (data.workflow_runs ?? []).map(this.formatRun.bind(this)), - }; - }, - }, - // ── gh_run_status ────────────────────────────────────────────────────── - { - name: 'gh_run_status', - description: 'Get the status and jobs of a specific workflow run', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - runId: { type: 'number', description: 'Workflow run ID' }, - }, - required: ['owner', 'repo', 'runId'], - }, - handler: async ({ owner, repo, runId }) => { - const [run, jobs] = await Promise.all([ - this.ghFetch(`/repos/${owner}/${repo}/actions/runs/${runId}`), - this.ghFetch(`/repos/${owner}/${repo}/actions/runs/${runId}/jobs`), - ]); - return { - ...this.formatRun(run), - jobs: (jobs.jobs ?? []).map((j) => ({ - id: j.id, - name: j.name, - status: j.status, - conclusion: j.conclusion ?? 'pending', - startedAt: j.started_at, - completedAt: j.completed_at, - steps: (j.steps ?? []).map((s) => ({ - name: s.name, - status: s.status, - conclusion: s.conclusion, - number: s.number, - })), - })), - }; - }, - }, - // ── gh_trigger_workflow ──────────────────────────────────────────────── - { - name: 'gh_trigger_workflow', - description: 'Manually trigger a GitHub Actions workflow (workflow_dispatch)', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - workflow: { - type: 'string', - description: 'Workflow file name (e.g. "deploy.yml") or ID', - }, - ref: { type: 'string', description: 'Branch or tag to run on (default: main)' }, - inputs: { - type: 'object', - description: 'Workflow input parameters (key-value pairs)', - }, - }, - required: ['owner', 'repo', 'workflow'], - }, - handler: async ({ owner, repo, workflow, ref = 'main', inputs = {} }) => { - await this.ghFetch(`/repos/${owner}/${repo}/actions/workflows/${encodeURIComponent(workflow)}/dispatches`, { method: 'POST', body: { ref, inputs } }); - // Give GitHub a moment then fetch the latest run - await new Promise((r) => setTimeout(r, 2000)); - const runs = await this.ghFetch(`/repos/${owner}/${repo}/actions/runs`, { - params: { per_page: '1', event: 'workflow_dispatch' }, - }); - const latestRun = runs.workflow_runs?.[0]; - return { - triggered: true, - run: latestRun ? this.formatRun(latestRun) : null, - }; - }, - }, - // ── gh_cancel_run ────────────────────────────────────────────────────── - { - name: 'gh_cancel_run', - description: 'Cancel a running GitHub Actions workflow run', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - runId: { type: 'number' }, - }, - required: ['owner', 'repo', 'runId'], - }, - handler: async ({ owner, repo, runId }) => { - await this.ghFetch(`/repos/${owner}/${repo}/actions/runs/${runId}/cancel`, { - method: 'POST', - }); - return { cancelled: true, runId }; - }, - }, - // ── gh_list_prs ──────────────────────────────────────────────────────── - { - name: 'gh_list_prs', - description: 'List pull requests for a repository', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - state: { - type: 'string', - enum: ['open', 'closed', 'all'], - description: 'PR state filter (default: open)', - }, - limit: { type: 'number', description: 'Max PRs (default: 20)' }, - }, - required: ['owner', 'repo'], - }, - handler: async ({ owner, repo, state = 'open', limit = 20 }) => { - const data = await this.ghFetch(`/repos/${owner}/${repo}/pulls`, { - params: { state, per_page: String(Math.min(limit, 100)), sort: 'updated' }, - }); - return { - count: data.length, - prs: data.map(this.formatPR.bind(this)), - }; - }, - }, - // ── gh_create_pr ─────────────────────────────────────────────────────── - { - name: 'gh_create_pr', - description: 'Create a new pull request', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - title: { type: 'string', description: 'PR title' }, - body: { type: 'string', description: 'PR description' }, - head: { type: 'string', description: 'Source branch (your feature branch)' }, - base: { type: 'string', description: 'Target branch (default: main)' }, - draft: { type: 'boolean', description: 'Create as draft PR (default: false)' }, - }, - required: ['owner', 'repo', 'title', 'head'], - }, - handler: async ({ owner, repo, title, body = '', head, base = 'main', draft = false }) => { - const pr = await this.ghFetch(`/repos/${owner}/${repo}/pulls`, { - method: 'POST', - body: { title, body, head, base, draft }, - }); - return { created: true, ...this.formatPR(pr) }; - }, - }, - // ── gh_merge_pr ──────────────────────────────────────────────────────── - { - name: 'gh_merge_pr', - description: 'Merge a pull request', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - prNumber: { type: 'number', description: 'PR number' }, - method: { - type: 'string', - enum: ['merge', 'squash', 'rebase'], - description: 'Merge strategy (default: merge)', - }, - commitTitle: { type: 'string', description: 'Custom commit title' }, - }, - required: ['owner', 'repo', 'prNumber'], - }, - handler: async ({ owner, repo, prNumber, method = 'merge', commitTitle }) => { - const body = { merge_method: method }; - if (commitTitle) - body.commit_title = commitTitle; - const res = await this.ghFetch(`/repos/${owner}/${repo}/pulls/${prNumber}/merge`, { - method: 'PUT', - body, - }); - return { merged: true, sha: res.sha, message: res.message }; - }, - }, - // ── gh_list_issues ───────────────────────────────────────────────────── - { - name: 'gh_list_issues', - description: 'List issues for a repository', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - state: { - type: 'string', - enum: ['open', 'closed', 'all'], - description: 'Issue state (default: open)', - }, - labels: { type: 'string', description: 'Comma-separated label filter' }, - assignee: { type: 'string', description: 'Filter by assignee username' }, - limit: { type: 'number', description: 'Max issues (default: 20)' }, - }, - required: ['owner', 'repo'], - }, - handler: async ({ owner, repo, state = 'open', labels, assignee, limit = 20 }) => { - const params = { - state, - per_page: String(Math.min(limit, 100)), - sort: 'updated', - }; - if (labels) - params.labels = labels; - if (assignee) - params.assignee = assignee; - const data = await this.ghFetch(`/repos/${owner}/${repo}/issues`, { params }); - // Filter out pull requests (they appear in issues endpoint too) - const issues = data.filter((i) => !i.pull_request); - return { - count: issues.length, - issues: issues.map(this.formatIssue.bind(this)), - }; - }, - }, - // ── gh_create_issue ──────────────────────────────────────────────────── - { - name: 'gh_create_issue', - description: 'Create a new GitHub issue', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - title: { type: 'string', description: 'Issue title' }, - body: { type: 'string', description: 'Issue body (markdown)' }, - labels: { - type: 'array', - items: { type: 'string' }, - description: 'Label names to apply', - }, - assignees: { - type: 'array', - items: { type: 'string' }, - description: 'GitHub usernames to assign', - }, - }, - required: ['owner', 'repo', 'title'], - }, - handler: async ({ owner, repo, title, body = '', labels = [], assignees = [] }) => { - const issue = await this.ghFetch(`/repos/${owner}/${repo}/issues`, { - method: 'POST', - body: { title, body, labels, assignees }, - }); - return { created: true, ...this.formatIssue(issue) }; - }, - }, - // ── gh_comment ───────────────────────────────────────────────────────── - { - name: 'gh_comment', - description: 'Add a comment to a GitHub issue or pull request', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - number: { type: 'number', description: 'Issue or PR number' }, - body: { type: 'string', description: 'Comment text (markdown)' }, - }, - required: ['owner', 'repo', 'number', 'body'], - }, - handler: async ({ owner, repo, number, body }) => { - const comment = await this.ghFetch(`/repos/${owner}/${repo}/issues/${number}/comments`, { - method: 'POST', - body: { body }, - }); - return { - created: true, - id: comment.id, - url: comment.html_url, - }; - }, - }, - // ── gh_releases ──────────────────────────────────────────────────────── - { - name: 'gh_releases', - description: 'List releases for a repository', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - limit: { type: 'number', description: 'Max releases (default: 10)' }, - }, - required: ['owner', 'repo'], - }, - handler: async ({ owner, repo, limit = 10 }) => { - const data = await this.ghFetch(`/repos/${owner}/${repo}/releases`, { - params: { per_page: String(Math.min(limit, 100)) }, - }); - return { - count: data.length, - releases: data.map((r) => ({ - id: r.id, - tag: r.tag_name, - name: r.name ?? r.tag_name, - draft: r.draft, - prerelease: r.prerelease, - author: r.author?.login ?? '', - publishedAt: r.published_at, - body: r.body?.slice(0, 500) ?? '', - assets: (r.assets ?? []).map((a) => ({ - name: a.name, - size: a.size, - downloads: a.download_count, - })), - url: r.html_url, - })), - }; - }, - }, - // ── gh_create_release ────────────────────────────────────────────────── - { - name: 'gh_create_release', - description: 'Create a new GitHub release', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - tag: { type: 'string', description: 'Tag name (e.g. v1.2.3)' }, - name: { type: 'string', description: 'Release title' }, - body: { type: 'string', description: 'Release notes (markdown)' }, - draft: { type: 'boolean', description: 'Create as draft (default: false)' }, - prerelease: { type: 'boolean', description: 'Mark as pre-release (default: false)' }, - generateNotes: { - type: 'boolean', - description: 'Auto-generate release notes from PRs/commits (default: false)', - }, - }, - required: ['owner', 'repo', 'tag'], - }, - handler: async ({ owner, repo, tag, name, body = '', draft = false, prerelease = false, generateNotes = false, }) => { - const release = await this.ghFetch(`/repos/${owner}/${repo}/releases`, { - method: 'POST', - body: { - tag_name: tag, - name: name ?? tag, - body, - draft, - prerelease, - generate_release_notes: generateNotes, - }, - }); - return { - created: true, - id: release.id, - tag: release.tag_name, - name: release.name, - url: release.html_url, - draft: release.draft, - }; - }, - }, - // ── gh_notifications ─────────────────────────────────────────────────── - { - name: 'gh_notifications', - description: 'Get unread GitHub notifications (mentions, CI failures, reviews needed)', - inputSchema: { - type: 'object', - properties: { - limit: { type: 'number', description: 'Max notifications (default: 20)' }, - all: { type: 'boolean', description: 'Include read notifications (default: false)' }, - }, - }, - handler: async ({ limit = 20, all = false }) => { - const data = await this.ghFetch('/notifications', { - params: { - all: String(all), - per_page: String(Math.min(limit, 50)), - }, - }); - return { - count: data.length, - notifications: data.map((n) => ({ - id: n.id, - unread: n.unread, - reason: n.reason, - type: n.subject?.type ?? '', - title: n.subject?.title ?? '', - repo: n.repository?.full_name ?? '', - updatedAt: n.updated_at, - })), - }; - }, - }, - // ── gh_code_search ───────────────────────────────────────────────────── - { - name: 'gh_code_search', - description: 'Search code across GitHub repositories', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Search query (supports repo:, lang:, path: operators)' }, - limit: { type: 'number', description: 'Max results (default: 10)' }, - }, - required: ['query'], - }, - handler: async ({ query, limit = 10 }) => { - const data = await this.ghFetch('/search/code', { - params: { q: query, per_page: String(Math.min(limit, 30)) }, - }); - return { - totalCount: data.total_count ?? 0, - results: (data.items ?? []).map((i) => ({ - repo: i.repository?.full_name ?? '', - path: i.path, - name: i.name, - url: i.html_url, - })), - }; - }, - }, - ]; - } -} diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json deleted file mode 100644 index 1e5269e..0000000 --- a/plugins/github-actions/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@conductor-plugins/github-actions", - "version": "1.0.0", - "type": "module", - "main": "dist/github-actions.js", - "scripts": { - "build": "tsc", - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "typescript": "^5.0.0", - "@types/node": "^20.0.0" - } -} diff --git a/plugins/github-actions/src/index.ts b/plugins/github-actions/src/index.ts deleted file mode 100644 index 5e4e66a..0000000 --- a/plugins/github-actions/src/index.ts +++ /dev/null @@ -1,690 +0,0 @@ -/** - * GitHub Actions Plugin — TheAlxLabs / Conductor - * - * Full GitHub CI/CD and project management: - * - Workflow runs: trigger, monitor, cancel, get logs - * - Pull requests: list, create, review, merge - * - Issues: create, update, comment, assign, label - * - Releases: list, create, publish - * - Notifications: check what needs attention - * - Code search across your repos - * - * Extends the existing github plugin (which handles public data). - * This plugin focuses on authenticated, write, and Actions operations. - * - * Setup: - * 1. https://github.com/settings/tokens → Fine-grained or classic PAT - * 2. Scopes needed: repo, workflow, read:user, notifications - * 3. Run: conductor plugins config github_actions token - * - * Keychain: github / token - */ - -// ── Inlined types (no external dependencies) ───────────────────────────────── - -interface PluginConfigField { - key: string; label: string; type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; secret?: boolean; service?: string; description?: string; options?: string[]; -} -interface PluginConfigSchema { fields: PluginConfigField[]; setupInstructions?: string; } -interface PluginTool { - name: string; description: string; inputSchema: Record; - handler: (input: Record) => Promise; requiresApproval?: boolean; -} -interface Plugin { - name: string; description: string; version: string; configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; isConfigured(): boolean; - getTools(): PluginTool[]; getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { getConfig(): ConfigManagerLike; } -class Keychain { - constructor(private dir: string) {} - async get(service: string, account: string): Promise { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service: string, _account: string, _value: string): Promise {} - async delete(_service: string, _account: string): Promise {} -} - -const GH_BASE = 'https://api.github.com'; -const GH_ACCEPT = 'application/vnd.github+json'; -const GH_API_VERSION = '2022-11-28'; - -export class GitHubActionsPlugin implements Plugin { - name = 'github_actions'; - description = - 'GitHub CI/CD, PRs, issues, releases, notifications — full write access, requires PAT'; - version = '1.0.0'; - - private keychain!: Keychain; - - async initialize(conductor: Conductor): Promise { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - - isConfigured(): boolean { - return true; - } - - private async getToken(): Promise { - const token = await this.keychain.get('github', 'token'); - if (!token) { - throw new Error( - 'GitHub token not configured.\n' + - 'Create a PAT at https://github.com/settings/tokens\n' + - 'Then run: conductor plugins config github_actions token ' - ); - } - return token; - } - - private async ghFetch( - path: string, - options: { method?: string; body?: any; params?: Record } = {} - ): Promise { - const token = await this.getToken(); - const url = new URL(`${GH_BASE}${path}`); - if (options.params) { - for (const [k, v] of Object.entries(options.params)) url.searchParams.set(k, v); - } - const res = await fetch(url.toString(), { - method: options.method ?? 'GET', - headers: { - Authorization: `Bearer ${token}`, - Accept: GH_ACCEPT, - 'X-GitHub-Api-Version': GH_API_VERSION, - 'Content-Type': 'application/json', - }, - body: options.body ? JSON.stringify(options.body) : undefined, - }); - if (res.status === 204) return {}; - if (!res.ok) { - const err = (await res.json().catch(() => ({ message: res.statusText }))) as any; - throw new Error(`GitHub API ${res.status}: ${err.message ?? res.statusText}`); - } - return res.json(); - } - - // ── Formatters ────────────────────────────────────────────────────────────── - - private formatRun(r: any) { - return { - id: r.id, - name: r.name, - workflow: r.workflow_id, - status: r.status, - conclusion: r.conclusion ?? 'pending', - branch: r.head_branch, - commit: r.head_sha?.slice(0, 8), - triggeredBy: r.triggering_actor?.login ?? r.actor?.login ?? '', - event: r.event, - createdAt: r.created_at, - updatedAt: r.updated_at, - duration: r.updated_at && r.created_at - ? `${Math.round((new Date(r.updated_at).getTime() - new Date(r.created_at).getTime()) / 1000)}s` - : null, - url: r.html_url, - }; - } - - private formatPR(pr: any) { - return { - number: pr.number, - title: pr.title, - state: pr.state, - draft: pr.draft ?? false, - author: pr.user?.login ?? '', - base: pr.base?.ref ?? '', - head: pr.head?.ref ?? '', - additions: pr.additions ?? 0, - deletions: pr.deletions ?? 0, - changedFiles: pr.changed_files ?? 0, - mergeable: pr.mergeable, - labels: (pr.labels ?? []).map((l: any) => l.name), - reviewers: (pr.requested_reviewers ?? []).map((r: any) => r.login), - createdAt: pr.created_at, - updatedAt: pr.updated_at, - url: pr.html_url, - body: pr.body?.slice(0, 500) ?? '', - }; - } - - private formatIssue(i: any) { - return { - number: i.number, - title: i.title, - state: i.state, - author: i.user?.login ?? '', - assignees: (i.assignees ?? []).map((a: any) => a.login), - labels: (i.labels ?? []).map((l: any) => l.name), - comments: i.comments ?? 0, - createdAt: i.created_at, - updatedAt: i.updated_at, - url: i.html_url, - body: i.body?.slice(0, 500) ?? '', - }; - } - - // ── Tools ─────────────────────────────────────────────────────────────────── - - getTools(): PluginTool[] { - return [ - // ── gh_my_repos ──────────────────────────────────────────────────────── - { - name: 'gh_my_repos', - description: "List the authenticated user's own repositories", - inputSchema: { - type: 'object', - properties: { - sort: { - type: 'string', - enum: ['updated', 'created', 'pushed', 'full_name'], - description: 'Sort order (default: pushed)', - }, - limit: { type: 'number', description: 'Max repos (default: 30)' }, - visibility: { - type: 'string', - enum: ['all', 'public', 'private'], - description: 'Filter by visibility (default: all)', - }, - }, - }, - handler: async ({ sort = 'pushed', limit = 30, visibility = 'all' }: any) => { - const data = await this.ghFetch('/user/repos', { - params: { - sort, - per_page: String(Math.min(limit, 100)), - visibility, - affiliation: 'owner', - }, - }); - return { - count: data.length, - repos: data.map((r: any) => ({ - name: r.name, - fullName: r.full_name, - description: r.description ?? '', - language: r.language ?? '', - private: r.private, - stars: r.stargazers_count, - openIssues: r.open_issues_count, - defaultBranch: r.default_branch, - pushedAt: r.pushed_at, - url: r.html_url, - })), - }; - }, - }, - - // ── gh_workflow_runs ─────────────────────────────────────────────────── - { - name: 'gh_workflow_runs', - description: 'List recent workflow runs for a repository', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string', description: 'Repo owner' }, - repo: { type: 'string', description: 'Repo name' }, - status: { - type: 'string', - enum: ['completed', 'in_progress', 'queued', 'failure', 'success'], - description: 'Filter by status', - }, - branch: { type: 'string', description: 'Filter by branch name' }, - limit: { type: 'number', description: 'Max runs (default: 10)' }, - }, - required: ['owner', 'repo'], - }, - handler: async ({ owner, repo, status, branch, limit = 10 }: any) => { - const params: Record = { per_page: String(Math.min(limit, 100)) }; - if (status) params.status = status; - if (branch) params.branch = branch; - const data = await this.ghFetch(`/repos/${owner}/${repo}/actions/runs`, { params }); - return { - totalCount: data.total_count ?? 0, - runs: (data.workflow_runs ?? []).map(this.formatRun.bind(this)), - }; - }, - }, - - // ── gh_run_status ────────────────────────────────────────────────────── - { - name: 'gh_run_status', - description: 'Get the status and jobs of a specific workflow run', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - runId: { type: 'number', description: 'Workflow run ID' }, - }, - required: ['owner', 'repo', 'runId'], - }, - handler: async ({ owner, repo, runId }: any) => { - const [run, jobs] = await Promise.all([ - this.ghFetch(`/repos/${owner}/${repo}/actions/runs/${runId}`), - this.ghFetch(`/repos/${owner}/${repo}/actions/runs/${runId}/jobs`), - ]); - return { - ...this.formatRun(run), - jobs: (jobs.jobs ?? []).map((j: any) => ({ - id: j.id, - name: j.name, - status: j.status, - conclusion: j.conclusion ?? 'pending', - startedAt: j.started_at, - completedAt: j.completed_at, - steps: (j.steps ?? []).map((s: any) => ({ - name: s.name, - status: s.status, - conclusion: s.conclusion, - number: s.number, - })), - })), - }; - }, - }, - - // ── gh_trigger_workflow ──────────────────────────────────────────────── - { - name: 'gh_trigger_workflow', - description: 'Manually trigger a GitHub Actions workflow (workflow_dispatch)', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - workflow: { - type: 'string', - description: 'Workflow file name (e.g. "deploy.yml") or ID', - }, - ref: { type: 'string', description: 'Branch or tag to run on (default: main)' }, - inputs: { - type: 'object', - description: 'Workflow input parameters (key-value pairs)', - }, - }, - required: ['owner', 'repo', 'workflow'], - }, - handler: async ({ owner, repo, workflow, ref = 'main', inputs = {} }: any) => { - await this.ghFetch( - `/repos/${owner}/${repo}/actions/workflows/${encodeURIComponent(workflow)}/dispatches`, - { method: 'POST', body: { ref, inputs } } - ); - // Give GitHub a moment then fetch the latest run - await new Promise((r) => setTimeout(r, 2000)); - const runs = await this.ghFetch(`/repos/${owner}/${repo}/actions/runs`, { - params: { per_page: '1', event: 'workflow_dispatch' }, - }); - const latestRun = runs.workflow_runs?.[0]; - return { - triggered: true, - run: latestRun ? this.formatRun(latestRun) : null, - }; - }, - }, - - // ── gh_cancel_run ────────────────────────────────────────────────────── - { - name: 'gh_cancel_run', - description: 'Cancel a running GitHub Actions workflow run', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - runId: { type: 'number' }, - }, - required: ['owner', 'repo', 'runId'], - }, - handler: async ({ owner, repo, runId }: any) => { - await this.ghFetch(`/repos/${owner}/${repo}/actions/runs/${runId}/cancel`, { - method: 'POST', - }); - return { cancelled: true, runId }; - }, - }, - - // ── gh_list_prs ──────────────────────────────────────────────────────── - { - name: 'gh_list_prs', - description: 'List pull requests for a repository', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - state: { - type: 'string', - enum: ['open', 'closed', 'all'], - description: 'PR state filter (default: open)', - }, - limit: { type: 'number', description: 'Max PRs (default: 20)' }, - }, - required: ['owner', 'repo'], - }, - handler: async ({ owner, repo, state = 'open', limit = 20 }: any) => { - const data = await this.ghFetch(`/repos/${owner}/${repo}/pulls`, { - params: { state, per_page: String(Math.min(limit, 100)), sort: 'updated' }, - }); - return { - count: data.length, - prs: data.map(this.formatPR.bind(this)), - }; - }, - }, - - // ── gh_create_pr ─────────────────────────────────────────────────────── - { - name: 'gh_create_pr', - description: 'Create a new pull request', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - title: { type: 'string', description: 'PR title' }, - body: { type: 'string', description: 'PR description' }, - head: { type: 'string', description: 'Source branch (your feature branch)' }, - base: { type: 'string', description: 'Target branch (default: main)' }, - draft: { type: 'boolean', description: 'Create as draft PR (default: false)' }, - }, - required: ['owner', 'repo', 'title', 'head'], - }, - handler: async ({ owner, repo, title, body = '', head, base = 'main', draft = false }: any) => { - const pr = await this.ghFetch(`/repos/${owner}/${repo}/pulls`, { - method: 'POST', - body: { title, body, head, base, draft }, - }); - return { created: true, ...this.formatPR(pr) }; - }, - }, - - // ── gh_merge_pr ──────────────────────────────────────────────────────── - { - name: 'gh_merge_pr', - description: 'Merge a pull request', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - prNumber: { type: 'number', description: 'PR number' }, - method: { - type: 'string', - enum: ['merge', 'squash', 'rebase'], - description: 'Merge strategy (default: merge)', - }, - commitTitle: { type: 'string', description: 'Custom commit title' }, - }, - required: ['owner', 'repo', 'prNumber'], - }, - handler: async ({ owner, repo, prNumber, method = 'merge', commitTitle }: any) => { - const body: any = { merge_method: method }; - if (commitTitle) body.commit_title = commitTitle; - const res = await this.ghFetch(`/repos/${owner}/${repo}/pulls/${prNumber}/merge`, { - method: 'PUT', - body, - }); - return { merged: true, sha: res.sha, message: res.message }; - }, - }, - - // ── gh_list_issues ───────────────────────────────────────────────────── - { - name: 'gh_list_issues', - description: 'List issues for a repository', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - state: { - type: 'string', - enum: ['open', 'closed', 'all'], - description: 'Issue state (default: open)', - }, - labels: { type: 'string', description: 'Comma-separated label filter' }, - assignee: { type: 'string', description: 'Filter by assignee username' }, - limit: { type: 'number', description: 'Max issues (default: 20)' }, - }, - required: ['owner', 'repo'], - }, - handler: async ({ owner, repo, state = 'open', labels, assignee, limit = 20 }: any) => { - const params: Record = { - state, - per_page: String(Math.min(limit, 100)), - sort: 'updated', - }; - if (labels) params.labels = labels; - if (assignee) params.assignee = assignee; - const data = await this.ghFetch(`/repos/${owner}/${repo}/issues`, { params }); - // Filter out pull requests (they appear in issues endpoint too) - const issues = data.filter((i: any) => !i.pull_request); - return { - count: issues.length, - issues: issues.map(this.formatIssue.bind(this)), - }; - }, - }, - - // ── gh_create_issue ──────────────────────────────────────────────────── - { - name: 'gh_create_issue', - description: 'Create a new GitHub issue', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - title: { type: 'string', description: 'Issue title' }, - body: { type: 'string', description: 'Issue body (markdown)' }, - labels: { - type: 'array', - items: { type: 'string' }, - description: 'Label names to apply', - }, - assignees: { - type: 'array', - items: { type: 'string' }, - description: 'GitHub usernames to assign', - }, - }, - required: ['owner', 'repo', 'title'], - }, - handler: async ({ owner, repo, title, body = '', labels = [], assignees = [] }: any) => { - const issue = await this.ghFetch(`/repos/${owner}/${repo}/issues`, { - method: 'POST', - body: { title, body, labels, assignees }, - }); - return { created: true, ...this.formatIssue(issue) }; - }, - }, - - // ── gh_comment ───────────────────────────────────────────────────────── - { - name: 'gh_comment', - description: 'Add a comment to a GitHub issue or pull request', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - number: { type: 'number', description: 'Issue or PR number' }, - body: { type: 'string', description: 'Comment text (markdown)' }, - }, - required: ['owner', 'repo', 'number', 'body'], - }, - handler: async ({ owner, repo, number, body }: any) => { - const comment = await this.ghFetch(`/repos/${owner}/${repo}/issues/${number}/comments`, { - method: 'POST', - body: { body }, - }); - return { - created: true, - id: comment.id, - url: comment.html_url, - }; - }, - }, - - // ── gh_releases ──────────────────────────────────────────────────────── - { - name: 'gh_releases', - description: 'List releases for a repository', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - limit: { type: 'number', description: 'Max releases (default: 10)' }, - }, - required: ['owner', 'repo'], - }, - handler: async ({ owner, repo, limit = 10 }: any) => { - const data = await this.ghFetch(`/repos/${owner}/${repo}/releases`, { - params: { per_page: String(Math.min(limit, 100)) }, - }); - return { - count: data.length, - releases: data.map((r: any) => ({ - id: r.id, - tag: r.tag_name, - name: r.name ?? r.tag_name, - draft: r.draft, - prerelease: r.prerelease, - author: r.author?.login ?? '', - publishedAt: r.published_at, - body: r.body?.slice(0, 500) ?? '', - assets: (r.assets ?? []).map((a: any) => ({ - name: a.name, - size: a.size, - downloads: a.download_count, - })), - url: r.html_url, - })), - }; - }, - }, - - // ── gh_create_release ────────────────────────────────────────────────── - { - name: 'gh_create_release', - description: 'Create a new GitHub release', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string' }, - repo: { type: 'string' }, - tag: { type: 'string', description: 'Tag name (e.g. v1.2.3)' }, - name: { type: 'string', description: 'Release title' }, - body: { type: 'string', description: 'Release notes (markdown)' }, - draft: { type: 'boolean', description: 'Create as draft (default: false)' }, - prerelease: { type: 'boolean', description: 'Mark as pre-release (default: false)' }, - generateNotes: { - type: 'boolean', - description: 'Auto-generate release notes from PRs/commits (default: false)', - }, - }, - required: ['owner', 'repo', 'tag'], - }, - handler: async ({ - owner, - repo, - tag, - name, - body = '', - draft = false, - prerelease = false, - generateNotes = false, - }: any) => { - const release = await this.ghFetch(`/repos/${owner}/${repo}/releases`, { - method: 'POST', - body: { - tag_name: tag, - name: name ?? tag, - body, - draft, - prerelease, - generate_release_notes: generateNotes, - }, - }); - return { - created: true, - id: release.id, - tag: release.tag_name, - name: release.name, - url: release.html_url, - draft: release.draft, - }; - }, - }, - - // ── gh_notifications ─────────────────────────────────────────────────── - { - name: 'gh_notifications', - description: 'Get unread GitHub notifications (mentions, CI failures, reviews needed)', - inputSchema: { - type: 'object', - properties: { - limit: { type: 'number', description: 'Max notifications (default: 20)' }, - all: { type: 'boolean', description: 'Include read notifications (default: false)' }, - }, - }, - handler: async ({ limit = 20, all = false }: any) => { - const data = await this.ghFetch('/notifications', { - params: { - all: String(all), - per_page: String(Math.min(limit, 50)), - }, - }); - return { - count: data.length, - notifications: data.map((n: any) => ({ - id: n.id, - unread: n.unread, - reason: n.reason, - type: n.subject?.type ?? '', - title: n.subject?.title ?? '', - repo: n.repository?.full_name ?? '', - updatedAt: n.updated_at, - })), - }; - }, - }, - - // ── gh_code_search ───────────────────────────────────────────────────── - { - name: 'gh_code_search', - description: 'Search code across GitHub repositories', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Search query (supports repo:, lang:, path: operators)' }, - limit: { type: 'number', description: 'Max results (default: 10)' }, - }, - required: ['query'], - }, - handler: async ({ query, limit = 10 }: any) => { - const data = await this.ghFetch('/search/code', { - params: { q: query, per_page: String(Math.min(limit, 30)) }, - }); - return { - totalCount: data.total_count ?? 0, - results: (data.items ?? []).map((i: any) => ({ - repo: i.repository?.full_name ?? '', - path: i.path, - name: i.name, - url: i.html_url, - })), - }; - }, - }, - ]; - } -} diff --git a/plugins/github-actions/tsconfig.json b/plugins/github-actions/tsconfig.json deleted file mode 100644 index dce5a7d..0000000 --- a/plugins/github-actions/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "skipLibCheck": true, - "declaration": true - }, - "include": ["src/**/*"] -} diff --git a/plugins/github/README.md b/plugins/github/README.md deleted file mode 100644 index 88b17b2..0000000 --- a/plugins/github/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# GitHub Plugin for Conductor - -Install: `conductor install github` - -## Setup - -**Authentication:** Personal Access Token - -```bash -conductor plugins config github token \ -`conductor plugins enable github` -``` - -Get credentials at: https://docs.github.com/en/rest - -## Tools - -``` -github_get_user, github_get_repo, github_list_repos, github_search_code, github_list_issues, github_create_issue, github_list_prs, github_get_file -``` - -Each tool is documented inline — ask Conductor what tools are available after installing. - -## Source - -Part of [thealxlabs/conductor-plugins](https://github.com/thealxlabs/conductor-plugins/tree/main/plugins/github). diff --git a/plugins/github/dist/github.js b/plugins/github/dist/github.js deleted file mode 100644 index 5f6f593..0000000 --- a/plugins/github/dist/github.js +++ /dev/null @@ -1,119 +0,0 @@ -// ── Inlined types (no external dependencies) ───────────────────────────────── -export class GitHubPlugin { - name = 'github'; - description = 'GitHub repositories, issues, stars, user info (public data free, private needs token)'; - version = '1.0.0'; - configSchema = { - fields: [ - { - key: 'token', - label: 'GitHub PAT (Personal Access Token)', - type: 'password', - required: true, - secret: true, - service: 'github', - description: 'Create a PAT with "repo" and "workflow" scopes.' - } - ], - setupInstructions: 'GitHub integration requires a Personal Access Token. You can create one in your GitHub Settings > Developer Settings > Personal Access Tokens.' - }; - async initialize(_conductor) { } - isConfigured() { return true; } // Works for public data without token - async ghFetch(path, token) { - const headers = { 'Accept': 'application/vnd.github+json' }; - if (token) - headers['Authorization'] = `Bearer ${token}`; - const res = await fetch(`https://api.github.com${path}`, { headers }); - if (!res.ok) - throw new Error(`GitHub API: ${res.status} ${res.statusText}`); - return res.json(); - } - getTools() { - return [ - { - name: 'github_user', - description: 'Get GitHub user profile info', - inputSchema: { - type: 'object', - properties: { - username: { type: 'string', description: 'GitHub username' }, - }, - required: ['username'], - }, - handler: async (input) => { - const u = await this.ghFetch(`/users/${encodeURIComponent(input.username)}`); - return { - login: u.login, name: u.name, bio: u.bio, - public_repos: u.public_repos, followers: u.followers, following: u.following, - created: u.created_at, url: u.html_url, avatar: u.avatar_url, - }; - }, - }, - { - name: 'github_repo', - description: 'Get repository details', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string', description: 'Repository owner' }, - repo: { type: 'string', description: 'Repository name' }, - }, - required: ['owner', 'repo'], - }, - handler: async (input) => { - const r = await this.ghFetch(`/repos/${encodeURIComponent(input.owner)}/${encodeURIComponent(input.repo)}`); - return { - name: r.full_name, description: r.description, language: r.language, - stars: r.stargazers_count, forks: r.forks_count, open_issues: r.open_issues_count, - license: r.license?.spdx_id, created: r.created_at, updated: r.updated_at, - default_branch: r.default_branch, url: r.html_url, - topics: r.topics, - }; - }, - }, - { - name: 'github_repos', - description: 'List repositories for a user', - inputSchema: { - type: 'object', - properties: { - username: { type: 'string', description: 'GitHub username' }, - sort: { type: 'string', description: 'Sort by: stars, updated, created, name', default: 'updated' }, - }, - required: ['username'], - }, - handler: async (input) => { - const sort = input.sort || 'updated'; - const repos = await this.ghFetch(`/users/${encodeURIComponent(input.username)}/repos?sort=${sort}&per_page=20`); - return repos.map((r) => ({ - name: r.name, description: r.description, language: r.language, - stars: r.stargazers_count, forks: r.forks_count, updated: r.updated_at, - url: r.html_url, - })); - }, - }, - { - name: 'github_trending', - description: 'Search trending/popular repositories', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Search query (e.g. "machine learning", "typescript cli")' }, - language: { type: 'string', description: 'Filter by language (e.g. typescript, python)' }, - }, - required: ['query'], - }, - handler: async (input) => { - let q = encodeURIComponent(input.query); - if (input.language) - q += `+language:${encodeURIComponent(input.language)}`; - const data = await this.ghFetch(`/search/repositories?q=${q}&sort=stars&per_page=10`); - return data.items.map((r) => ({ - name: r.full_name, description: r.description, language: r.language, - stars: r.stargazers_count, forks: r.forks_count, url: r.html_url, - })); - }, - }, - ]; - } -} diff --git a/plugins/github/dist/index.d.ts b/plugins/github/dist/index.d.ts deleted file mode 100644 index 846e072..0000000 --- a/plugins/github/dist/index.d.ts +++ /dev/null @@ -1,61 +0,0 @@ -interface PluginConfigField { - key: string; - label: string; - type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; - secret?: boolean; - service?: string; - description?: string; - options?: string[]; -} -interface PluginConfigSchema { - fields: PluginConfigField[]; - setupInstructions?: string; -} -interface PluginTool { - name: string; - description: string; - inputSchema: Record; - handler: (input: Record) => Promise; - requiresApproval?: boolean; -} -interface Plugin { - name: string; - description: string; - version: string; - configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - getTools(): PluginTool[]; - getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; - set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { - getConfig(): ConfigManagerLike; -} -export declare class GitHubPlugin implements Plugin { - name: string; - description: string; - version: string; - configSchema: { - fields: { - key: string; - label: string; - type: "password"; - required: boolean; - secret: boolean; - service: string; - description: string; - }[]; - setupInstructions: string; - }; - initialize(_conductor: Conductor): Promise; - isConfigured(): boolean; - private ghFetch; - getTools(): PluginTool[]; -} -export {}; diff --git a/plugins/github/dist/index.js b/plugins/github/dist/index.js deleted file mode 100644 index 5f6f593..0000000 --- a/plugins/github/dist/index.js +++ /dev/null @@ -1,119 +0,0 @@ -// ── Inlined types (no external dependencies) ───────────────────────────────── -export class GitHubPlugin { - name = 'github'; - description = 'GitHub repositories, issues, stars, user info (public data free, private needs token)'; - version = '1.0.0'; - configSchema = { - fields: [ - { - key: 'token', - label: 'GitHub PAT (Personal Access Token)', - type: 'password', - required: true, - secret: true, - service: 'github', - description: 'Create a PAT with "repo" and "workflow" scopes.' - } - ], - setupInstructions: 'GitHub integration requires a Personal Access Token. You can create one in your GitHub Settings > Developer Settings > Personal Access Tokens.' - }; - async initialize(_conductor) { } - isConfigured() { return true; } // Works for public data without token - async ghFetch(path, token) { - const headers = { 'Accept': 'application/vnd.github+json' }; - if (token) - headers['Authorization'] = `Bearer ${token}`; - const res = await fetch(`https://api.github.com${path}`, { headers }); - if (!res.ok) - throw new Error(`GitHub API: ${res.status} ${res.statusText}`); - return res.json(); - } - getTools() { - return [ - { - name: 'github_user', - description: 'Get GitHub user profile info', - inputSchema: { - type: 'object', - properties: { - username: { type: 'string', description: 'GitHub username' }, - }, - required: ['username'], - }, - handler: async (input) => { - const u = await this.ghFetch(`/users/${encodeURIComponent(input.username)}`); - return { - login: u.login, name: u.name, bio: u.bio, - public_repos: u.public_repos, followers: u.followers, following: u.following, - created: u.created_at, url: u.html_url, avatar: u.avatar_url, - }; - }, - }, - { - name: 'github_repo', - description: 'Get repository details', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string', description: 'Repository owner' }, - repo: { type: 'string', description: 'Repository name' }, - }, - required: ['owner', 'repo'], - }, - handler: async (input) => { - const r = await this.ghFetch(`/repos/${encodeURIComponent(input.owner)}/${encodeURIComponent(input.repo)}`); - return { - name: r.full_name, description: r.description, language: r.language, - stars: r.stargazers_count, forks: r.forks_count, open_issues: r.open_issues_count, - license: r.license?.spdx_id, created: r.created_at, updated: r.updated_at, - default_branch: r.default_branch, url: r.html_url, - topics: r.topics, - }; - }, - }, - { - name: 'github_repos', - description: 'List repositories for a user', - inputSchema: { - type: 'object', - properties: { - username: { type: 'string', description: 'GitHub username' }, - sort: { type: 'string', description: 'Sort by: stars, updated, created, name', default: 'updated' }, - }, - required: ['username'], - }, - handler: async (input) => { - const sort = input.sort || 'updated'; - const repos = await this.ghFetch(`/users/${encodeURIComponent(input.username)}/repos?sort=${sort}&per_page=20`); - return repos.map((r) => ({ - name: r.name, description: r.description, language: r.language, - stars: r.stargazers_count, forks: r.forks_count, updated: r.updated_at, - url: r.html_url, - })); - }, - }, - { - name: 'github_trending', - description: 'Search trending/popular repositories', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Search query (e.g. "machine learning", "typescript cli")' }, - language: { type: 'string', description: 'Filter by language (e.g. typescript, python)' }, - }, - required: ['query'], - }, - handler: async (input) => { - let q = encodeURIComponent(input.query); - if (input.language) - q += `+language:${encodeURIComponent(input.language)}`; - const data = await this.ghFetch(`/search/repositories?q=${q}&sort=stars&per_page=10`); - return data.items.map((r) => ({ - name: r.full_name, description: r.description, language: r.language, - stars: r.stargazers_count, forks: r.forks_count, url: r.html_url, - })); - }, - }, - ]; - } -} diff --git a/plugins/github/package.json b/plugins/github/package.json deleted file mode 100644 index ea312f7..0000000 --- a/plugins/github/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@conductor-plugins/github", - "version": "1.0.0", - "type": "module", - "main": "dist/github.js", - "scripts": { - "build": "tsc", - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "typescript": "^5.0.0", - "@types/node": "^20.0.0" - } -} diff --git a/plugins/github/src/index.ts b/plugins/github/src/index.ts deleted file mode 100644 index 84b4fd2..0000000 --- a/plugins/github/src/index.ts +++ /dev/null @@ -1,141 +0,0 @@ -// ── Inlined types (no external dependencies) ───────────────────────────────── - -interface PluginConfigField { - key: string; label: string; type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; secret?: boolean; service?: string; description?: string; options?: string[]; -} -interface PluginConfigSchema { fields: PluginConfigField[]; setupInstructions?: string; } -interface PluginTool { - name: string; description: string; inputSchema: Record; - handler: (input: Record) => Promise; requiresApproval?: boolean; -} -interface Plugin { - name: string; description: string; version: string; configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; isConfigured(): boolean; - getTools(): PluginTool[]; getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { getConfig(): ConfigManagerLike; } - -export class GitHubPlugin implements Plugin { - name = 'github'; - description = 'GitHub repositories, issues, stars, user info (public data free, private needs token)'; - version = '1.0.0'; - - configSchema = { - fields: [ - { - key: 'token', - label: 'GitHub PAT (Personal Access Token)', - type: 'password' as const, - required: true, - secret: true, - service: 'github', - description: 'Create a PAT with "repo" and "workflow" scopes.' - } - ], - setupInstructions: 'GitHub integration requires a Personal Access Token. You can create one in your GitHub Settings > Developer Settings > Personal Access Tokens.' - }; - - async initialize(_conductor: Conductor): Promise { } - isConfigured(): boolean { return true; } // Works for public data without token - - private async ghFetch(path: string, token?: string): Promise { - const headers: Record = { 'Accept': 'application/vnd.github+json' }; - if (token) headers['Authorization'] = `Bearer ${token}`; - const res = await fetch(`https://api.github.com${path}`, { headers }); - if (!res.ok) throw new Error(`GitHub API: ${res.status} ${res.statusText}`); - return res.json(); - } - - getTools(): PluginTool[] { - return [ - { - name: 'github_user', - description: 'Get GitHub user profile info', - inputSchema: { - type: 'object', - properties: { - username: { type: 'string', description: 'GitHub username' }, - }, - required: ['username'], - }, - handler: async (input: any) => { - const u = await this.ghFetch(`/users/${encodeURIComponent(input.username)}`); - return { - login: u.login, name: u.name, bio: u.bio, - public_repos: u.public_repos, followers: u.followers, following: u.following, - created: u.created_at, url: u.html_url, avatar: u.avatar_url, - }; - }, - }, - { - name: 'github_repo', - description: 'Get repository details', - inputSchema: { - type: 'object', - properties: { - owner: { type: 'string', description: 'Repository owner' }, - repo: { type: 'string', description: 'Repository name' }, - }, - required: ['owner', 'repo'], - }, - handler: async (input: any) => { - const r = await this.ghFetch(`/repos/${encodeURIComponent(input.owner)}/${encodeURIComponent(input.repo)}`); - return { - name: r.full_name, description: r.description, language: r.language, - stars: r.stargazers_count, forks: r.forks_count, open_issues: r.open_issues_count, - license: r.license?.spdx_id, created: r.created_at, updated: r.updated_at, - default_branch: r.default_branch, url: r.html_url, - topics: r.topics, - }; - }, - }, - { - name: 'github_repos', - description: 'List repositories for a user', - inputSchema: { - type: 'object', - properties: { - username: { type: 'string', description: 'GitHub username' }, - sort: { type: 'string', description: 'Sort by: stars, updated, created, name', default: 'updated' }, - }, - required: ['username'], - }, - handler: async (input: any) => { - const sort = input.sort || 'updated'; - const repos = await this.ghFetch(`/users/${encodeURIComponent(input.username)}/repos?sort=${sort}&per_page=20`); - return repos.map((r: any) => ({ - name: r.name, description: r.description, language: r.language, - stars: r.stargazers_count, forks: r.forks_count, updated: r.updated_at, - url: r.html_url, - })); - }, - }, - { - name: 'github_trending', - description: 'Search trending/popular repositories', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Search query (e.g. "machine learning", "typescript cli")' }, - language: { type: 'string', description: 'Filter by language (e.g. typescript, python)' }, - }, - required: ['query'], - }, - handler: async (input: any) => { - let q = encodeURIComponent(input.query); - if (input.language) q += `+language:${encodeURIComponent(input.language)}`; - const data = await this.ghFetch(`/search/repositories?q=${q}&sort=stars&per_page=10`); - return data.items.map((r: any) => ({ - name: r.full_name, description: r.description, language: r.language, - stars: r.stargazers_count, forks: r.forks_count, url: r.html_url, - })); - }, - }, - ]; - } -} diff --git a/plugins/github/tsconfig.json b/plugins/github/tsconfig.json deleted file mode 100644 index dce5a7d..0000000 --- a/plugins/github/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "skipLibCheck": true, - "declaration": true - }, - "include": ["src/**/*"] -} diff --git a/plugins/gitlab/README.md b/plugins/gitlab/README.md new file mode 100644 index 0000000..72b1128 --- /dev/null +++ b/plugins/gitlab/README.md @@ -0,0 +1,36 @@ +# GitLab Plugin + +Manage GitLab projects, issues, merge requests, and CI/CD pipelines from Conductor. + +## Setup + +1. Go to [https://gitlab.com/-/user_settings/personal_access_tokens](https://gitlab.com/-/user_settings/personal_access_tokens) and create a Personal Access Token. +2. Grant the `api` scope (or narrower scopes as needed). +3. Configure the plugin: + +```bash +conductor config set gitlab token YOUR_PERSONAL_ACCESS_TOKEN +``` + +For self-hosted GitLab, set the base URL: + +```bash +conductor config set gitlab base_url https://gitlab.yourcompany.com +``` + +## Available Tools + +| Tool | Description | +|------|-------------| +| `gitlab_list_projects` | List accessible projects | +| `gitlab_get_project` | Get project details | +| `gitlab_list_issues` | List issues in a project | +| `gitlab_create_issue` | Create a new issue | +| `gitlab_update_issue` | Update an existing issue | +| `gitlab_list_mrs` | List merge requests | +| `gitlab_create_mr` | Create a merge request | +| `gitlab_list_pipelines` | List CI/CD pipelines | +| `gitlab_trigger_pipeline` | Trigger a new pipeline | +| `gitlab_get_job_log` | Get job logs from a pipeline | +| `gitlab_list_branches` | List branches in a project | +| `gitlab_get_file` | Get a file from the repository | diff --git a/plugins/gmail/README.md b/plugins/gmail/README.md deleted file mode 100644 index e656545..0000000 --- a/plugins/gmail/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Gmail Plugin for Conductor - -Install: `conductor install gmail` - -## Setup - -**Authentication:** Google OAuth - -```bash -conductor auth google -`conductor plugins enable gmail` -``` - -Get credentials at: https://developers.google.com/gmail/api - -## Tools - -``` -gmail_list, gmail_read, gmail_search, gmail_send, gmail_reply, gmail_mark_read, gmail_trash -``` - -Each tool is documented inline — ask Conductor what tools are available after installing. - -## Source - -Part of [thealxlabs/conductor-plugins](https://github.com/thealxlabs/conductor-plugins/tree/main/plugins/gmail). diff --git a/plugins/gmail/dist/gmail.js b/plugins/gmail/dist/gmail.js deleted file mode 100644 index a605938..0000000 --- a/plugins/gmail/dist/gmail.js +++ /dev/null @@ -1,326 +0,0 @@ -/** - * Gmail Plugin - * - * Read, search, send, and manage Gmail via the Gmail REST API. - * Requires a Google OAuth access token stored in keychain as - * google / access_token - * - * Run `conductor ai setup google` or the OAuth flow to authenticate. - * - * Scopes needed: - * https://www.googleapis.com/auth/gmail.readonly - * https://www.googleapis.com/auth/gmail.send - * https://www.googleapis.com/auth/gmail.modify - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service, _account, _value) { } - async delete(_service, _account) { } -} -const GMAIL_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me'; -export class GmailPlugin { - name = 'gmail'; - description = 'Read, search, send, and manage Gmail — requires Google OAuth'; - version = '1.0.0'; - configSchema = { - fields: [ - { - key: 'access_token', - label: 'Google Access Token', - type: 'password', - required: true, - secret: true, - service: 'google', - description: 'Run "conductor auth google" to obtain this automatically.' - } - ], - setupInstructions: 'Authentication for Google services is best handled via the CLI command `conductor auth google` which manages OAuth flows securely.' - }; - keychain; - configDir; - async initialize(conductor) { - this.configDir = conductor.getConfig().getConfigDir(); - this.keychain = new Keychain(this.configDir); - } - isConfigured() { - return true; // checked at tool call time - } - async getToken() { - const token = await this.keychain.get('google', 'access_token'); - if (!token) { - throw new Error('Google not authenticated. Run: conductor auth google'); - } - return token; - } - async gmailFetch(path, options = {}) { - const token = await this.getToken(); - const res = await fetch(`${GMAIL_BASE}${path}`, { - method: options.method ?? 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: options.body ? JSON.stringify(options.body) : undefined, - }); - if (!res.ok) { - const err = await res.text().catch(() => res.statusText); - if (res.status === 401) { - throw new Error('Google token expired. Re-authenticate: conductor auth google'); - } - throw new Error(`Gmail API ${res.status}: ${err}`); - } - return res.json(); - } - /** Decode base64url to string */ - b64decode(s) { - const b64 = s.replace(/-/g, '+').replace(/_/g, '/'); - return Buffer.from(b64, 'base64').toString('utf-8'); - } - /** Extract plain text body from a message payload */ - extractBody(payload) { - if (!payload) - return ''; - if (payload.body?.data) - return this.b64decode(payload.body.data); - if (payload.parts) { - for (const part of payload.parts) { - if (part.mimeType === 'text/plain' && part.body?.data) { - return this.b64decode(part.body.data); - } - } - // Fallback: first part with data - for (const part of payload.parts) { - const body = this.extractBody(part); - if (body) - return body; - } - } - return ''; - } - /** Get header value from message headers */ - getHeader(headers, name) { - return headers?.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ?? ''; - } - /** Encode email as RFC 2822 base64url for Gmail API */ - encodeEmail(opts) { - const lines = [ - `To: ${opts.to}`, - `Subject: ${opts.subject}`, - opts.cc ? `Cc: ${opts.cc}` : null, - opts.inReplyTo ? `In-Reply-To: ${opts.inReplyTo}` : null, - 'Content-Type: text/plain; charset=UTF-8', - '', - opts.body, - ] - .filter((l) => l !== null) - .join('\r\n'); - return Buffer.from(lines).toString('base64').replace(/\+/g, '-').replace(/\//g, '_'); - } - getTools() { - return [ - // ── gmail_list ────────────────────────────────────────────────────────── - { - name: 'gmail_list', - description: 'List recent emails from Gmail inbox', - inputSchema: { - type: 'object', - properties: { - maxResults: { type: 'number', description: 'Number of emails to return (default: 10, max: 50)' }, - labelIds: { - type: 'array', - items: { type: 'string' }, - description: 'Filter by labels e.g. ["INBOX","UNREAD"]', - }, - q: { type: 'string', description: 'Gmail search query e.g. "from:alex is:unread"' }, - }, - }, - handler: async ({ maxResults = 10, labelIds, q }) => { - const params = new URLSearchParams({ - maxResults: String(Math.min(maxResults, 50)), - }); - if (labelIds?.length) - params.set('labelIds', labelIds.join(',')); - if (q) - params.set('q', q); - const list = await this.gmailFetch(`/messages?${params}`); - if (!list.messages?.length) - return { count: 0, emails: [] }; - // Fetch minimal metadata for each message in parallel - const emails = await Promise.all(list.messages.map(async (msg) => { - const m = await this.gmailFetch(`/messages/${msg.id}?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date`); - const h = m.payload?.headers ?? []; - return { - id: m.id, - threadId: m.threadId, - from: this.getHeader(h, 'From'), - subject: this.getHeader(h, 'Subject'), - date: this.getHeader(h, 'Date'), - snippet: m.snippet, - unread: m.labelIds?.includes('UNREAD') ?? false, - }; - })); - return { count: emails.length, emails }; - }, - }, - // ── gmail_read ────────────────────────────────────────────────────────── - { - name: 'gmail_read', - description: 'Read the full content of a Gmail message by ID', - inputSchema: { - type: 'object', - properties: { - messageId: { type: 'string', description: 'Gmail message ID from gmail_list' }, - }, - required: ['messageId'], - }, - handler: async ({ messageId }) => { - const m = await this.gmailFetch(`/messages/${messageId}?format=full`); - const h = m.payload?.headers ?? []; - const body = this.extractBody(m.payload); - return { - id: m.id, - threadId: m.threadId, - from: this.getHeader(h, 'From'), - to: this.getHeader(h, 'To'), - subject: this.getHeader(h, 'Subject'), - date: this.getHeader(h, 'Date'), - body: body.slice(0, 8000), // cap to avoid huge context - snippet: m.snippet, - labels: m.labelIds ?? [], - }; - }, - }, - // ── gmail_search ──────────────────────────────────────────────────────── - { - name: 'gmail_search', - description: 'Search Gmail using Gmail search operators — e.g. "from:boss@company.com is:unread", "subject:invoice after:2024/01/01"', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Gmail search query string' }, - maxResults: { type: 'number', description: 'Max results (default: 10)' }, - }, - required: ['query'], - }, - handler: async ({ query, maxResults = 10 }) => { - const params = new URLSearchParams({ - q: query, - maxResults: String(Math.min(maxResults, 50)), - }); - const list = await this.gmailFetch(`/messages?${params}`); - if (!list.messages?.length) - return { count: 0, emails: [] }; - const emails = await Promise.all(list.messages.map(async (msg) => { - const m = await this.gmailFetch(`/messages/${msg.id}?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date`); - const h = m.payload?.headers ?? []; - return { - id: m.id, - threadId: m.threadId, - from: this.getHeader(h, 'From'), - subject: this.getHeader(h, 'Subject'), - date: this.getHeader(h, 'Date'), - snippet: m.snippet, - unread: m.labelIds?.includes('UNREAD') ?? false, - }; - })); - return { count: emails.length, emails }; - }, - }, - // ── gmail_send ────────────────────────────────────────────────────────── - { - name: 'gmail_send', - description: 'Send an email via Gmail', - inputSchema: { - type: 'object', - properties: { - to: { type: 'string', description: 'Recipient email address' }, - subject: { type: 'string', description: 'Email subject' }, - body: { type: 'string', description: 'Plain text email body' }, - cc: { type: 'string', description: 'CC email addresses (comma-separated)' }, - }, - required: ['to', 'subject', 'body'], - }, - handler: async ({ to, subject, body, cc }) => { - const raw = this.encodeEmail({ to, subject, body, cc }); - const result = await this.gmailFetch('/messages/send', { - method: 'POST', - body: { raw }, - }); - return { sent: true, messageId: result.id, threadId: result.threadId }; - }, - }, - // ── gmail_reply ───────────────────────────────────────────────────────── - { - name: 'gmail_reply', - description: 'Reply to an existing Gmail message/thread', - inputSchema: { - type: 'object', - properties: { - messageId: { type: 'string', description: 'Message ID to reply to' }, - body: { type: 'string', description: 'Reply body text' }, - }, - required: ['messageId', 'body'], - }, - handler: async ({ messageId, body }) => { - // Fetch original to get headers for reply - const orig = await this.gmailFetch(`/messages/${messageId}?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Message-ID`); - const h = orig.payload?.headers ?? []; - const to = this.getHeader(h, 'From'); - const subject = `Re: ${this.getHeader(h, 'Subject').replace(/^Re:\s*/i, '')}`; - const inReplyTo = this.getHeader(h, 'Message-ID'); - const raw = this.encodeEmail({ to, subject, body, inReplyTo }); - const result = await this.gmailFetch('/messages/send', { - method: 'POST', - body: { raw, threadId: orig.threadId }, - }); - return { sent: true, messageId: result.id, threadId: result.threadId }; - }, - }, - // ── gmail_mark_read ───────────────────────────────────────────────────── - { - name: 'gmail_mark_read', - description: 'Mark Gmail messages as read or unread', - inputSchema: { - type: 'object', - properties: { - messageId: { type: 'string', description: 'Message ID to modify' }, - read: { type: 'boolean', description: 'true = mark read, false = mark unread' }, - }, - required: ['messageId', 'read'], - }, - handler: async ({ messageId, read }) => { - await this.gmailFetch(`/messages/${messageId}/modify`, { - method: 'POST', - body: read - ? { removeLabelIds: ['UNREAD'] } - : { addLabelIds: ['UNREAD'] }, - }); - return { success: true, messageId, read }; - }, - }, - // ── gmail_trash ───────────────────────────────────────────────────────── - { - name: 'gmail_trash', - description: 'Move a Gmail message to trash', - inputSchema: { - type: 'object', - properties: { - messageId: { type: 'string', description: 'Message ID to trash' }, - }, - required: ['messageId'], - }, - handler: async ({ messageId }) => { - await this.gmailFetch(`/messages/${messageId}/trash`, { method: 'POST' }); - return { trashed: true, messageId }; - }, - }, - ]; - } -} diff --git a/plugins/gmail/dist/index.d.ts b/plugins/gmail/dist/index.d.ts deleted file mode 100644 index c5a45e3..0000000 --- a/plugins/gmail/dist/index.d.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Gmail Plugin - * - * Read, search, send, and manage Gmail via the Gmail REST API. - * Requires a Google OAuth access token stored in keychain as - * google / access_token - * - * Run `conductor ai setup google` or the OAuth flow to authenticate. - * - * Scopes needed: - * https://www.googleapis.com/auth/gmail.readonly - * https://www.googleapis.com/auth/gmail.send - * https://www.googleapis.com/auth/gmail.modify - */ -interface PluginConfigField { - key: string; - label: string; - type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; - secret?: boolean; - service?: string; - description?: string; - options?: string[]; -} -interface PluginConfigSchema { - fields: PluginConfigField[]; - setupInstructions?: string; -} -interface PluginTool { - name: string; - description: string; - inputSchema: Record; - handler: (input: Record) => Promise; - requiresApproval?: boolean; -} -interface Plugin { - name: string; - description: string; - version: string; - configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - getTools(): PluginTool[]; - getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; - set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { - getConfig(): ConfigManagerLike; -} -export declare class GmailPlugin implements Plugin { - name: string; - description: string; - version: string; - configSchema: { - fields: { - key: string; - label: string; - type: "password"; - required: boolean; - secret: boolean; - service: string; - description: string; - }[]; - setupInstructions: string; - }; - private keychain; - private configDir; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - private getToken; - private gmailFetch; - /** Decode base64url to string */ - private b64decode; - /** Extract plain text body from a message payload */ - private extractBody; - /** Get header value from message headers */ - private getHeader; - /** Encode email as RFC 2822 base64url for Gmail API */ - private encodeEmail; - getTools(): PluginTool[]; -} -export {}; diff --git a/plugins/gmail/dist/index.js b/plugins/gmail/dist/index.js deleted file mode 100644 index a605938..0000000 --- a/plugins/gmail/dist/index.js +++ /dev/null @@ -1,326 +0,0 @@ -/** - * Gmail Plugin - * - * Read, search, send, and manage Gmail via the Gmail REST API. - * Requires a Google OAuth access token stored in keychain as - * google / access_token - * - * Run `conductor ai setup google` or the OAuth flow to authenticate. - * - * Scopes needed: - * https://www.googleapis.com/auth/gmail.readonly - * https://www.googleapis.com/auth/gmail.send - * https://www.googleapis.com/auth/gmail.modify - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service, _account, _value) { } - async delete(_service, _account) { } -} -const GMAIL_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me'; -export class GmailPlugin { - name = 'gmail'; - description = 'Read, search, send, and manage Gmail — requires Google OAuth'; - version = '1.0.0'; - configSchema = { - fields: [ - { - key: 'access_token', - label: 'Google Access Token', - type: 'password', - required: true, - secret: true, - service: 'google', - description: 'Run "conductor auth google" to obtain this automatically.' - } - ], - setupInstructions: 'Authentication for Google services is best handled via the CLI command `conductor auth google` which manages OAuth flows securely.' - }; - keychain; - configDir; - async initialize(conductor) { - this.configDir = conductor.getConfig().getConfigDir(); - this.keychain = new Keychain(this.configDir); - } - isConfigured() { - return true; // checked at tool call time - } - async getToken() { - const token = await this.keychain.get('google', 'access_token'); - if (!token) { - throw new Error('Google not authenticated. Run: conductor auth google'); - } - return token; - } - async gmailFetch(path, options = {}) { - const token = await this.getToken(); - const res = await fetch(`${GMAIL_BASE}${path}`, { - method: options.method ?? 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: options.body ? JSON.stringify(options.body) : undefined, - }); - if (!res.ok) { - const err = await res.text().catch(() => res.statusText); - if (res.status === 401) { - throw new Error('Google token expired. Re-authenticate: conductor auth google'); - } - throw new Error(`Gmail API ${res.status}: ${err}`); - } - return res.json(); - } - /** Decode base64url to string */ - b64decode(s) { - const b64 = s.replace(/-/g, '+').replace(/_/g, '/'); - return Buffer.from(b64, 'base64').toString('utf-8'); - } - /** Extract plain text body from a message payload */ - extractBody(payload) { - if (!payload) - return ''; - if (payload.body?.data) - return this.b64decode(payload.body.data); - if (payload.parts) { - for (const part of payload.parts) { - if (part.mimeType === 'text/plain' && part.body?.data) { - return this.b64decode(part.body.data); - } - } - // Fallback: first part with data - for (const part of payload.parts) { - const body = this.extractBody(part); - if (body) - return body; - } - } - return ''; - } - /** Get header value from message headers */ - getHeader(headers, name) { - return headers?.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ?? ''; - } - /** Encode email as RFC 2822 base64url for Gmail API */ - encodeEmail(opts) { - const lines = [ - `To: ${opts.to}`, - `Subject: ${opts.subject}`, - opts.cc ? `Cc: ${opts.cc}` : null, - opts.inReplyTo ? `In-Reply-To: ${opts.inReplyTo}` : null, - 'Content-Type: text/plain; charset=UTF-8', - '', - opts.body, - ] - .filter((l) => l !== null) - .join('\r\n'); - return Buffer.from(lines).toString('base64').replace(/\+/g, '-').replace(/\//g, '_'); - } - getTools() { - return [ - // ── gmail_list ────────────────────────────────────────────────────────── - { - name: 'gmail_list', - description: 'List recent emails from Gmail inbox', - inputSchema: { - type: 'object', - properties: { - maxResults: { type: 'number', description: 'Number of emails to return (default: 10, max: 50)' }, - labelIds: { - type: 'array', - items: { type: 'string' }, - description: 'Filter by labels e.g. ["INBOX","UNREAD"]', - }, - q: { type: 'string', description: 'Gmail search query e.g. "from:alex is:unread"' }, - }, - }, - handler: async ({ maxResults = 10, labelIds, q }) => { - const params = new URLSearchParams({ - maxResults: String(Math.min(maxResults, 50)), - }); - if (labelIds?.length) - params.set('labelIds', labelIds.join(',')); - if (q) - params.set('q', q); - const list = await this.gmailFetch(`/messages?${params}`); - if (!list.messages?.length) - return { count: 0, emails: [] }; - // Fetch minimal metadata for each message in parallel - const emails = await Promise.all(list.messages.map(async (msg) => { - const m = await this.gmailFetch(`/messages/${msg.id}?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date`); - const h = m.payload?.headers ?? []; - return { - id: m.id, - threadId: m.threadId, - from: this.getHeader(h, 'From'), - subject: this.getHeader(h, 'Subject'), - date: this.getHeader(h, 'Date'), - snippet: m.snippet, - unread: m.labelIds?.includes('UNREAD') ?? false, - }; - })); - return { count: emails.length, emails }; - }, - }, - // ── gmail_read ────────────────────────────────────────────────────────── - { - name: 'gmail_read', - description: 'Read the full content of a Gmail message by ID', - inputSchema: { - type: 'object', - properties: { - messageId: { type: 'string', description: 'Gmail message ID from gmail_list' }, - }, - required: ['messageId'], - }, - handler: async ({ messageId }) => { - const m = await this.gmailFetch(`/messages/${messageId}?format=full`); - const h = m.payload?.headers ?? []; - const body = this.extractBody(m.payload); - return { - id: m.id, - threadId: m.threadId, - from: this.getHeader(h, 'From'), - to: this.getHeader(h, 'To'), - subject: this.getHeader(h, 'Subject'), - date: this.getHeader(h, 'Date'), - body: body.slice(0, 8000), // cap to avoid huge context - snippet: m.snippet, - labels: m.labelIds ?? [], - }; - }, - }, - // ── gmail_search ──────────────────────────────────────────────────────── - { - name: 'gmail_search', - description: 'Search Gmail using Gmail search operators — e.g. "from:boss@company.com is:unread", "subject:invoice after:2024/01/01"', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Gmail search query string' }, - maxResults: { type: 'number', description: 'Max results (default: 10)' }, - }, - required: ['query'], - }, - handler: async ({ query, maxResults = 10 }) => { - const params = new URLSearchParams({ - q: query, - maxResults: String(Math.min(maxResults, 50)), - }); - const list = await this.gmailFetch(`/messages?${params}`); - if (!list.messages?.length) - return { count: 0, emails: [] }; - const emails = await Promise.all(list.messages.map(async (msg) => { - const m = await this.gmailFetch(`/messages/${msg.id}?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date`); - const h = m.payload?.headers ?? []; - return { - id: m.id, - threadId: m.threadId, - from: this.getHeader(h, 'From'), - subject: this.getHeader(h, 'Subject'), - date: this.getHeader(h, 'Date'), - snippet: m.snippet, - unread: m.labelIds?.includes('UNREAD') ?? false, - }; - })); - return { count: emails.length, emails }; - }, - }, - // ── gmail_send ────────────────────────────────────────────────────────── - { - name: 'gmail_send', - description: 'Send an email via Gmail', - inputSchema: { - type: 'object', - properties: { - to: { type: 'string', description: 'Recipient email address' }, - subject: { type: 'string', description: 'Email subject' }, - body: { type: 'string', description: 'Plain text email body' }, - cc: { type: 'string', description: 'CC email addresses (comma-separated)' }, - }, - required: ['to', 'subject', 'body'], - }, - handler: async ({ to, subject, body, cc }) => { - const raw = this.encodeEmail({ to, subject, body, cc }); - const result = await this.gmailFetch('/messages/send', { - method: 'POST', - body: { raw }, - }); - return { sent: true, messageId: result.id, threadId: result.threadId }; - }, - }, - // ── gmail_reply ───────────────────────────────────────────────────────── - { - name: 'gmail_reply', - description: 'Reply to an existing Gmail message/thread', - inputSchema: { - type: 'object', - properties: { - messageId: { type: 'string', description: 'Message ID to reply to' }, - body: { type: 'string', description: 'Reply body text' }, - }, - required: ['messageId', 'body'], - }, - handler: async ({ messageId, body }) => { - // Fetch original to get headers for reply - const orig = await this.gmailFetch(`/messages/${messageId}?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Message-ID`); - const h = orig.payload?.headers ?? []; - const to = this.getHeader(h, 'From'); - const subject = `Re: ${this.getHeader(h, 'Subject').replace(/^Re:\s*/i, '')}`; - const inReplyTo = this.getHeader(h, 'Message-ID'); - const raw = this.encodeEmail({ to, subject, body, inReplyTo }); - const result = await this.gmailFetch('/messages/send', { - method: 'POST', - body: { raw, threadId: orig.threadId }, - }); - return { sent: true, messageId: result.id, threadId: result.threadId }; - }, - }, - // ── gmail_mark_read ───────────────────────────────────────────────────── - { - name: 'gmail_mark_read', - description: 'Mark Gmail messages as read or unread', - inputSchema: { - type: 'object', - properties: { - messageId: { type: 'string', description: 'Message ID to modify' }, - read: { type: 'boolean', description: 'true = mark read, false = mark unread' }, - }, - required: ['messageId', 'read'], - }, - handler: async ({ messageId, read }) => { - await this.gmailFetch(`/messages/${messageId}/modify`, { - method: 'POST', - body: read - ? { removeLabelIds: ['UNREAD'] } - : { addLabelIds: ['UNREAD'] }, - }); - return { success: true, messageId, read }; - }, - }, - // ── gmail_trash ───────────────────────────────────────────────────────── - { - name: 'gmail_trash', - description: 'Move a Gmail message to trash', - inputSchema: { - type: 'object', - properties: { - messageId: { type: 'string', description: 'Message ID to trash' }, - }, - required: ['messageId'], - }, - handler: async ({ messageId }) => { - await this.gmailFetch(`/messages/${messageId}/trash`, { method: 'POST' }); - return { trashed: true, messageId }; - }, - }, - ]; - } -} diff --git a/plugins/gmail/package.json b/plugins/gmail/package.json deleted file mode 100644 index abcf9a7..0000000 --- a/plugins/gmail/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@conductor-plugins/gmail", - "version": "1.0.0", - "type": "module", - "main": "dist/gmail.js", - "scripts": { - "build": "tsc", - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "typescript": "^5.0.0", - "@types/node": "^20.0.0" - } -} diff --git a/plugins/gmail/src/index.ts b/plugins/gmail/src/index.ts deleted file mode 100644 index 410193c..0000000 --- a/plugins/gmail/src/index.ts +++ /dev/null @@ -1,389 +0,0 @@ -/** - * Gmail Plugin - * - * Read, search, send, and manage Gmail via the Gmail REST API. - * Requires a Google OAuth access token stored in keychain as - * google / access_token - * - * Run `conductor ai setup google` or the OAuth flow to authenticate. - * - * Scopes needed: - * https://www.googleapis.com/auth/gmail.readonly - * https://www.googleapis.com/auth/gmail.send - * https://www.googleapis.com/auth/gmail.modify - */ - -// ── Inlined types (no external dependencies) ───────────────────────────────── - -interface PluginConfigField { - key: string; label: string; type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; secret?: boolean; service?: string; description?: string; options?: string[]; -} -interface PluginConfigSchema { fields: PluginConfigField[]; setupInstructions?: string; } -interface PluginTool { - name: string; description: string; inputSchema: Record; - handler: (input: Record) => Promise; requiresApproval?: boolean; -} -interface Plugin { - name: string; description: string; version: string; configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; isConfigured(): boolean; - getTools(): PluginTool[]; getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { getConfig(): ConfigManagerLike; } -class Keychain { - constructor(private dir: string) {} - async get(service: string, account: string): Promise { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service: string, _account: string, _value: string): Promise {} - async delete(_service: string, _account: string): Promise {} -} - -const GMAIL_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me'; - -export class GmailPlugin implements Plugin { - name = 'gmail'; - description = 'Read, search, send, and manage Gmail — requires Google OAuth'; - version = '1.0.0'; - - configSchema = { - fields: [ - { - key: 'access_token', - label: 'Google Access Token', - type: 'password' as const, - required: true, - secret: true, - service: 'google', - description: 'Run "conductor auth google" to obtain this automatically.' - } - ], - setupInstructions: 'Authentication for Google services is best handled via the CLI command `conductor auth google` which manages OAuth flows securely.' - }; - - private keychain!: Keychain; - private configDir!: string; - - async initialize(conductor: Conductor): Promise { - this.configDir = conductor.getConfig().getConfigDir(); - this.keychain = new Keychain(this.configDir); - } - - isConfigured(): boolean { - return true; // checked at tool call time - } - - private async getToken(): Promise { - const token = await this.keychain.get('google', 'access_token'); - if (!token) { - throw new Error( - 'Google not authenticated. Run: conductor auth google' - ); - } - return token; - } - - private async gmailFetch( - path: string, - options: { method?: string; body?: any } = {} - ): Promise { - const token = await this.getToken(); - const res = await fetch(`${GMAIL_BASE}${path}`, { - method: options.method ?? 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: options.body ? JSON.stringify(options.body) : undefined, - }); - if (!res.ok) { - const err = await res.text().catch(() => res.statusText); - if (res.status === 401) { - throw new Error('Google token expired. Re-authenticate: conductor auth google'); - } - throw new Error(`Gmail API ${res.status}: ${err}`); - } - return res.json(); - } - - /** Decode base64url to string */ - private b64decode(s: string): string { - const b64 = s.replace(/-/g, '+').replace(/_/g, '/'); - return Buffer.from(b64, 'base64').toString('utf-8'); - } - - /** Extract plain text body from a message payload */ - private extractBody(payload: any): string { - if (!payload) return ''; - if (payload.body?.data) return this.b64decode(payload.body.data); - if (payload.parts) { - for (const part of payload.parts) { - if (part.mimeType === 'text/plain' && part.body?.data) { - return this.b64decode(part.body.data); - } - } - // Fallback: first part with data - for (const part of payload.parts) { - const body = this.extractBody(part); - if (body) return body; - } - } - return ''; - } - - /** Get header value from message headers */ - private getHeader(headers: any[], name: string): string { - return headers?.find((h: any) => h.name.toLowerCase() === name.toLowerCase())?.value ?? ''; - } - - /** Encode email as RFC 2822 base64url for Gmail API */ - private encodeEmail(opts: { - to: string; - subject: string; - body: string; - from?: string; - cc?: string; - inReplyTo?: string; - threadId?: string; - }): string { - const lines = [ - `To: ${opts.to}`, - `Subject: ${opts.subject}`, - opts.cc ? `Cc: ${opts.cc}` : null, - opts.inReplyTo ? `In-Reply-To: ${opts.inReplyTo}` : null, - 'Content-Type: text/plain; charset=UTF-8', - '', - opts.body, - ] - .filter((l) => l !== null) - .join('\r\n'); - - return Buffer.from(lines).toString('base64').replace(/\+/g, '-').replace(/\//g, '_'); - } - - getTools(): PluginTool[] { - return [ - // ── gmail_list ────────────────────────────────────────────────────────── - { - name: 'gmail_list', - description: 'List recent emails from Gmail inbox', - inputSchema: { - type: 'object', - properties: { - maxResults: { type: 'number', description: 'Number of emails to return (default: 10, max: 50)' }, - labelIds: { - type: 'array', - items: { type: 'string' }, - description: 'Filter by labels e.g. ["INBOX","UNREAD"]', - }, - q: { type: 'string', description: 'Gmail search query e.g. "from:alex is:unread"' }, - }, - }, - handler: async ({ maxResults = 10, labelIds, q }: any) => { - const params = new URLSearchParams({ - maxResults: String(Math.min(maxResults, 50)), - }); - if (labelIds?.length) params.set('labelIds', labelIds.join(',')); - if (q) params.set('q', q); - - const list = await this.gmailFetch(`/messages?${params}`); - if (!list.messages?.length) return { count: 0, emails: [] }; - - // Fetch minimal metadata for each message in parallel - const emails = await Promise.all( - list.messages.map(async (msg: any) => { - const m = await this.gmailFetch( - `/messages/${msg.id}?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date` - ); - const h = m.payload?.headers ?? []; - return { - id: m.id, - threadId: m.threadId, - from: this.getHeader(h, 'From'), - subject: this.getHeader(h, 'Subject'), - date: this.getHeader(h, 'Date'), - snippet: m.snippet, - unread: m.labelIds?.includes('UNREAD') ?? false, - }; - }) - ); - - return { count: emails.length, emails }; - }, - }, - - // ── gmail_read ────────────────────────────────────────────────────────── - { - name: 'gmail_read', - description: 'Read the full content of a Gmail message by ID', - inputSchema: { - type: 'object', - properties: { - messageId: { type: 'string', description: 'Gmail message ID from gmail_list' }, - }, - required: ['messageId'], - }, - handler: async ({ messageId }: any) => { - const m = await this.gmailFetch(`/messages/${messageId}?format=full`); - const h = m.payload?.headers ?? []; - const body = this.extractBody(m.payload); - - return { - id: m.id, - threadId: m.threadId, - from: this.getHeader(h, 'From'), - to: this.getHeader(h, 'To'), - subject: this.getHeader(h, 'Subject'), - date: this.getHeader(h, 'Date'), - body: body.slice(0, 8000), // cap to avoid huge context - snippet: m.snippet, - labels: m.labelIds ?? [], - }; - }, - }, - - // ── gmail_search ──────────────────────────────────────────────────────── - { - name: 'gmail_search', - description: - 'Search Gmail using Gmail search operators — e.g. "from:boss@company.com is:unread", "subject:invoice after:2024/01/01"', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Gmail search query string' }, - maxResults: { type: 'number', description: 'Max results (default: 10)' }, - }, - required: ['query'], - }, - handler: async ({ query, maxResults = 10 }: any) => { - const params = new URLSearchParams({ - q: query, - maxResults: String(Math.min(maxResults, 50)), - }); - const list = await this.gmailFetch(`/messages?${params}`); - if (!list.messages?.length) return { count: 0, emails: [] }; - - const emails = await Promise.all( - list.messages.map(async (msg: any) => { - const m = await this.gmailFetch( - `/messages/${msg.id}?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date` - ); - const h = m.payload?.headers ?? []; - return { - id: m.id, - threadId: m.threadId, - from: this.getHeader(h, 'From'), - subject: this.getHeader(h, 'Subject'), - date: this.getHeader(h, 'Date'), - snippet: m.snippet, - unread: m.labelIds?.includes('UNREAD') ?? false, - }; - }) - ); - - return { count: emails.length, emails }; - }, - }, - - // ── gmail_send ────────────────────────────────────────────────────────── - { - name: 'gmail_send', - description: 'Send an email via Gmail', - inputSchema: { - type: 'object', - properties: { - to: { type: 'string', description: 'Recipient email address' }, - subject: { type: 'string', description: 'Email subject' }, - body: { type: 'string', description: 'Plain text email body' }, - cc: { type: 'string', description: 'CC email addresses (comma-separated)' }, - }, - required: ['to', 'subject', 'body'], - }, - handler: async ({ to, subject, body, cc }: any) => { - const raw = this.encodeEmail({ to, subject, body, cc }); - const result = await this.gmailFetch('/messages/send', { - method: 'POST', - body: { raw }, - }); - return { sent: true, messageId: result.id, threadId: result.threadId }; - }, - }, - - // ── gmail_reply ───────────────────────────────────────────────────────── - { - name: 'gmail_reply', - description: 'Reply to an existing Gmail message/thread', - inputSchema: { - type: 'object', - properties: { - messageId: { type: 'string', description: 'Message ID to reply to' }, - body: { type: 'string', description: 'Reply body text' }, - }, - required: ['messageId', 'body'], - }, - handler: async ({ messageId, body }: any) => { - // Fetch original to get headers for reply - const orig = await this.gmailFetch( - `/messages/${messageId}?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Message-ID` - ); - const h = orig.payload?.headers ?? []; - const to = this.getHeader(h, 'From'); - const subject = `Re: ${this.getHeader(h, 'Subject').replace(/^Re:\s*/i, '')}`; - const inReplyTo = this.getHeader(h, 'Message-ID'); - - const raw = this.encodeEmail({ to, subject, body, inReplyTo }); - const result = await this.gmailFetch('/messages/send', { - method: 'POST', - body: { raw, threadId: orig.threadId }, - }); - return { sent: true, messageId: result.id, threadId: result.threadId }; - }, - }, - - // ── gmail_mark_read ───────────────────────────────────────────────────── - { - name: 'gmail_mark_read', - description: 'Mark Gmail messages as read or unread', - inputSchema: { - type: 'object', - properties: { - messageId: { type: 'string', description: 'Message ID to modify' }, - read: { type: 'boolean', description: 'true = mark read, false = mark unread' }, - }, - required: ['messageId', 'read'], - }, - handler: async ({ messageId, read }: any) => { - await this.gmailFetch(`/messages/${messageId}/modify`, { - method: 'POST', - body: read - ? { removeLabelIds: ['UNREAD'] } - : { addLabelIds: ['UNREAD'] }, - }); - return { success: true, messageId, read }; - }, - }, - - // ── gmail_trash ───────────────────────────────────────────────────────── - { - name: 'gmail_trash', - description: 'Move a Gmail message to trash', - inputSchema: { - type: 'object', - properties: { - messageId: { type: 'string', description: 'Message ID to trash' }, - }, - required: ['messageId'], - }, - handler: async ({ messageId }: any) => { - await this.gmailFetch(`/messages/${messageId}/trash`, { method: 'POST' }); - return { trashed: true, messageId }; - }, - }, - ]; - } -} diff --git a/plugins/gmail/tsconfig.json b/plugins/gmail/tsconfig.json deleted file mode 100644 index dce5a7d..0000000 --- a/plugins/gmail/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "skipLibCheck": true, - "declaration": true - }, - "include": ["src/**/*"] -} diff --git a/plugins/homekit/README.md b/plugins/homekit/README.md deleted file mode 100644 index 64f9a66..0000000 --- a/plugins/homekit/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# HomeKit Plugin for Conductor - -Install: `conductor install homekit` - -## Setup - -**Authentication:** Homebridge URL - -```bash -conductor plugins config homekit base_url http://homebridge.local:8581 -`conductor plugins enable homekit` -``` - -Get credentials at: https://github.com/homebridge/homebridge - -## Tools - -``` -homekit_status, homekit_rooms, homekit_accessories, homekit_get_accessory, homekit_set, homekit_toggle -``` - -Each tool is documented inline — ask Conductor what tools are available after installing. - -## Source - -Part of [thealxlabs/conductor-plugins](https://github.com/thealxlabs/conductor-plugins/tree/main/plugins/homekit). diff --git a/plugins/homekit/dist/homekit.js b/plugins/homekit/dist/homekit.js deleted file mode 100644 index a0cffc3..0000000 --- a/plugins/homekit/dist/homekit.js +++ /dev/null @@ -1,360 +0,0 @@ -/** - * HomeKit Plugin — TheAlxLabs / Conductor - * - * Control HomeKit smart home devices via the Homebridge UI REST API - * (homebridge-config-ui-x). Requires Homebridge with the UI plugin installed. - * - * Setup: - * 1. Install Homebridge: https://homebridge.io - * (homebridge-config-ui-x is included by default with most install methods) - * 2. Run: conductor plugins config homekit base_url http://homebridge.local:8581 - * 3. Run: conductor plugins config homekit username admin - * 4. Run: conductor plugins config homekit password - * - * Keychain entries: homekit/base_url, homekit/username, homekit/password - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service, _account, _value) { } - async delete(_service, _account) { } -} -export class HomeKitPlugin { - name = 'homekit'; - description = 'Control HomeKit smart home devices via Homebridge — list, get, and control accessories'; - version = '1.0.0'; - configSchema = { - fields: [ - { - key: 'base_url', - label: 'Homebridge URL', - type: 'string', - required: true, - secret: false, - description: 'e.g. http://homebridge.local:8581 or http://192.168.1.100:8581', - }, - { - key: 'username', - label: 'Homebridge Username', - type: 'string', - required: true, - secret: false, - description: 'Your Homebridge UI login username (default: admin)', - }, - { - key: 'password', - label: 'Homebridge Password', - type: 'password', - required: true, - secret: true, - service: 'homekit', - }, - ], - setupInstructions: 'Install Homebridge (https://homebridge.io) on your local network. ' + - 'The homebridge-config-ui-x plugin must be installed (it is by default with most install methods). ' + - 'Find your Homebridge URL by opening the Homebridge UI in a browser (usually http://homebridge.local:8581 ' + - 'or http://:8581).', - }; - keychain; - cachedToken = null; - tokenExpiry = 0; - async initialize(conductor) { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - isConfigured() { - return true; - } - // ── Credential helpers ─────────────────────────────────────────────────────── - async getCredentials() { - const rawUrl = await this.keychain.get('homekit', 'base_url'); - const username = await this.keychain.get('homekit', 'username'); - const password = await this.keychain.get('homekit', 'password'); - if (!rawUrl) { - throw new Error('Homebridge URL not configured.\n' + - 'Run: conductor plugins config homekit base_url http://homebridge.local:8581'); - } - if (!username) { - throw new Error('Homebridge username not configured.\n' + - 'Run: conductor plugins config homekit username admin'); - } - if (!password) { - throw new Error('Homebridge password not configured.\n' + - 'Run: conductor plugins config homekit password '); - } - return { baseUrl: rawUrl.replace(/\/$/, ''), username, password }; - } - /** Authenticate with Homebridge UI and return a JWT token (cached). */ - async getToken() { - if (this.cachedToken && Date.now() < this.tokenExpiry - 5 * 60 * 1000) { - return this.cachedToken; - } - const { baseUrl, username, password } = await this.getCredentials(); - const res = await fetch(`${baseUrl}/api/auth/sign-in`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }), - }); - if (!res.ok) { - const text = await res.text().catch(() => res.statusText); - throw new Error(`Homebridge auth failed (${res.status}): ${text}`); - } - const data = (await res.json()); - if (!data.access_token) { - throw new Error('Homebridge did not return an access token. Check credentials.'); - } - this.cachedToken = data.access_token; - this.tokenExpiry = Date.now() + (data.expires_in ?? 28_800) * 1000; - return this.cachedToken; - } - // ── API fetch wrapper ──────────────────────────────────────────────────────── - async homebridgeFetch(path, options = {}) { - const { baseUrl } = await this.getCredentials(); - const token = await this.getToken(); - const res = await fetch(`${baseUrl}${path}`, { - method: options.method ?? 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: options.body ? JSON.stringify(options.body) : undefined, - }); - if (res.status === 204) - return {}; - if (res.status === 401) { - this.cachedToken = null; - throw new Error('Homebridge session expired. Please retry the request.'); - } - if (!res.ok) { - const err = (await res.json().catch(() => ({ message: res.statusText }))); - throw new Error(`Homebridge API ${res.status}: ${err.message ?? res.statusText}`); - } - return res.json(); - } - // ── Formatting helpers ─────────────────────────────────────────────────────── - formatAccessory(acc) { - return { - uniqueId: acc.uniqueId, - name: acc.serviceName ?? acc.displayName ?? 'Unknown', - type: acc.humanType ?? acc.type ?? 'Unknown', - room: acc.roomName ?? null, - values: acc.values ?? {}, - on: acc.values?.On ?? null, - reachable: acc.reachable ?? true, - }; - } - // ── Tools ──────────────────────────────────────────────────────────────────── - getTools() { - return [ - // ── homekit_status ──────────────────────────────────────────────────── - { - name: 'homekit_status', - description: 'Check Homebridge connection status, version, and a summary of all accessories', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const [accessoriesResult, serverResult] = await Promise.allSettled([ - this.homebridgeFetch('/api/accessories'), - this.homebridgeFetch('/api/server/version'), - ]); - const accessories = accessoriesResult.status === 'fulfilled' - ? accessoriesResult.value - : []; - const server = serverResult.status === 'fulfilled' ? serverResult.value : {}; - const byType = {}; - for (const acc of accessories) { - const type = acc.humanType ?? 'Unknown'; - byType[type] = (byType[type] ?? 0) + 1; - } - const { baseUrl } = await this.getCredentials(); - return { - connected: accessoriesResult.status === 'fulfilled', - homebridgeUrl: baseUrl, - homebridgeVersion: server.homebridgeVersion ?? server.currentVersion ?? 'unknown', - nodeVersion: server.nodeVersion ?? 'unknown', - totalAccessories: accessories.length, - byType, - }; - }, - }, - // ── homekit_accessories ─────────────────────────────────────────────── - { - name: 'homekit_accessories', - description: 'List all HomeKit accessories with their current state', - inputSchema: { - type: 'object', - properties: { - type: { - type: 'string', - description: 'Filter by accessory type (e.g. "Lightbulb", "Switch", "Thermostat", "Lock", "Fan")', - }, - room: { - type: 'string', - description: 'Filter by room name (partial match, case-insensitive)', - }, - }, - }, - handler: async ({ type, room }) => { - const accessories = (await this.homebridgeFetch('/api/accessories')); - let filtered = accessories; - if (type) { - const q = type.toLowerCase(); - filtered = filtered.filter((a) => (a.humanType ?? a.type ?? '').toLowerCase().includes(q)); - } - if (room) { - const q = room.toLowerCase(); - filtered = filtered.filter((a) => (a.roomName ?? '').toLowerCase().includes(q)); - } - return { - count: filtered.length, - accessories: filtered.map(this.formatAccessory.bind(this)), - }; - }, - }, - // ── homekit_get_accessory ───────────────────────────────────────────── - { - name: 'homekit_get_accessory', - description: 'Get the current state and all characteristics of a specific HomeKit accessory', - inputSchema: { - type: 'object', - properties: { - uniqueId: { - type: 'string', - description: 'Accessory uniqueId (from homekit_accessories)', - }, - }, - required: ['uniqueId'], - }, - handler: async ({ uniqueId }) => { - const acc = await this.homebridgeFetch(`/api/accessories/${encodeURIComponent(uniqueId)}`); - return { - ...this.formatAccessory(acc), - serviceCharacteristics: (acc.serviceCharacteristics ?? []).map((c) => ({ - type: c.type, - description: c.description, - value: c.value, - format: c.format, - unit: c.unit ?? null, - minValue: c.minValue ?? null, - maxValue: c.maxValue ?? null, - canRead: c.canRead ?? true, - canWrite: c.canWrite ?? false, - })), - }; - }, - }, - // ── homekit_set ─────────────────────────────────────────────────────── - { - name: 'homekit_set', - description: 'Set a characteristic on a HomeKit accessory. ' + - 'Common examples: On (true/false), Brightness (0-100), ' + - 'TargetTemperature (degrees), Hue (0-360), Saturation (0-100), ' + - 'LockTargetState (0=unsecured, 1=secured).', - inputSchema: { - type: 'object', - properties: { - uniqueId: { - type: 'string', - description: 'Accessory uniqueId (from homekit_accessories)', - }, - characteristicType: { - type: 'string', - description: 'The characteristic to set (e.g. "On", "Brightness", "TargetTemperature", "Hue")', - }, - value: { - description: 'The value to set (boolean, number, or string)', - }, - }, - required: ['uniqueId', 'characteristicType', 'value'], - }, - requiresApproval: true, - handler: async ({ uniqueId, characteristicType, value }) => { - await this.homebridgeFetch(`/api/accessories/${encodeURIComponent(uniqueId)}`, { - method: 'PUT', - body: { characteristicType, value }, - }); - const updated = await this.homebridgeFetch(`/api/accessories/${encodeURIComponent(uniqueId)}`); - return { - success: true, - set: { characteristicType, value }, - accessory: this.formatAccessory(updated), - }; - }, - }, - // ── homekit_toggle ──────────────────────────────────────────────────── - { - name: 'homekit_toggle', - description: 'Toggle a HomeKit accessory on or off by name. ' + - 'Finds the accessory by partial name match and flips (or sets) its On state.', - inputSchema: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'Accessory name (partial match, case-insensitive)', - }, - on: { - type: 'boolean', - description: 'Force on (true) or off (false). If omitted, toggles the current state.', - }, - }, - required: ['name'], - }, - requiresApproval: true, - handler: async ({ name, on }) => { - const accessories = (await this.homebridgeFetch('/api/accessories')); - const q = name.toLowerCase(); - const acc = accessories.find((a) => (a.serviceName ?? a.displayName ?? '').toLowerCase().includes(q)); - if (!acc) { - const available = accessories - .map((a) => a.serviceName ?? a.displayName) - .filter(Boolean) - .join(', '); - return { - error: `No accessory found matching "${name}".`, - available, - }; - } - const currentOn = acc.values?.On ?? false; - const targetOn = on !== undefined ? on : !currentOn; - await this.homebridgeFetch(`/api/accessories/${encodeURIComponent(acc.uniqueId)}`, { - method: 'PUT', - body: { characteristicType: 'On', value: targetOn }, - }); - return { - success: true, - accessory: acc.serviceName ?? acc.displayName, - uniqueId: acc.uniqueId, - previousState: currentOn, - on: targetOn, - }; - }, - }, - // ── homekit_rooms ───────────────────────────────────────────────────── - { - name: 'homekit_rooms', - description: 'Get the room layout from Homebridge — shows which accessories are in which rooms', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const layout = (await this.homebridgeFetch('/api/accessories/layout')); - return { - count: layout.length, - rooms: layout.map((room) => ({ - name: room.name ?? 'Default Room', - accessories: (room.services ?? []).map((s) => ({ - uniqueId: s.uniqueId, - name: s.customName ?? s.serviceName ?? s.displayName, - type: s.humanType ?? s.type, - })), - })), - }; - }, - }, - ]; - } -} diff --git a/plugins/homekit/dist/index.d.ts b/plugins/homekit/dist/index.d.ts deleted file mode 100644 index 9af81f3..0000000 --- a/plugins/homekit/dist/index.d.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * HomeKit Plugin — TheAlxLabs / Conductor - * - * Control HomeKit smart home devices via the Homebridge UI REST API - * (homebridge-config-ui-x). Requires Homebridge with the UI plugin installed. - * - * Setup: - * 1. Install Homebridge: https://homebridge.io - * (homebridge-config-ui-x is included by default with most install methods) - * 2. Run: conductor plugins config homekit base_url http://homebridge.local:8581 - * 3. Run: conductor plugins config homekit username admin - * 4. Run: conductor plugins config homekit password - * - * Keychain entries: homekit/base_url, homekit/username, homekit/password - */ -interface PluginConfigField { - key: string; - label: string; - type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; - secret?: boolean; - service?: string; - description?: string; - options?: string[]; -} -interface PluginConfigSchema { - fields: PluginConfigField[]; - setupInstructions?: string; -} -interface PluginTool { - name: string; - description: string; - inputSchema: Record; - handler: (input: Record) => Promise; - requiresApproval?: boolean; -} -interface Plugin { - name: string; - description: string; - version: string; - configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - getTools(): PluginTool[]; - getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; - set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { - getConfig(): ConfigManagerLike; -} -export declare class HomeKitPlugin implements Plugin { - name: string; - description: string; - version: string; - configSchema: { - fields: ({ - key: string; - label: string; - type: "string"; - required: boolean; - secret: boolean; - description: string; - service?: undefined; - } | { - key: string; - label: string; - type: "password"; - required: boolean; - secret: boolean; - service: string; - description?: undefined; - })[]; - setupInstructions: string; - }; - private keychain; - private cachedToken; - private tokenExpiry; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - private getCredentials; - /** Authenticate with Homebridge UI and return a JWT token (cached). */ - private getToken; - private homebridgeFetch; - private formatAccessory; - getTools(): PluginTool[]; -} -export {}; diff --git a/plugins/homekit/dist/index.js b/plugins/homekit/dist/index.js deleted file mode 100644 index a0cffc3..0000000 --- a/plugins/homekit/dist/index.js +++ /dev/null @@ -1,360 +0,0 @@ -/** - * HomeKit Plugin — TheAlxLabs / Conductor - * - * Control HomeKit smart home devices via the Homebridge UI REST API - * (homebridge-config-ui-x). Requires Homebridge with the UI plugin installed. - * - * Setup: - * 1. Install Homebridge: https://homebridge.io - * (homebridge-config-ui-x is included by default with most install methods) - * 2. Run: conductor plugins config homekit base_url http://homebridge.local:8581 - * 3. Run: conductor plugins config homekit username admin - * 4. Run: conductor plugins config homekit password - * - * Keychain entries: homekit/base_url, homekit/username, homekit/password - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service, _account, _value) { } - async delete(_service, _account) { } -} -export class HomeKitPlugin { - name = 'homekit'; - description = 'Control HomeKit smart home devices via Homebridge — list, get, and control accessories'; - version = '1.0.0'; - configSchema = { - fields: [ - { - key: 'base_url', - label: 'Homebridge URL', - type: 'string', - required: true, - secret: false, - description: 'e.g. http://homebridge.local:8581 or http://192.168.1.100:8581', - }, - { - key: 'username', - label: 'Homebridge Username', - type: 'string', - required: true, - secret: false, - description: 'Your Homebridge UI login username (default: admin)', - }, - { - key: 'password', - label: 'Homebridge Password', - type: 'password', - required: true, - secret: true, - service: 'homekit', - }, - ], - setupInstructions: 'Install Homebridge (https://homebridge.io) on your local network. ' + - 'The homebridge-config-ui-x plugin must be installed (it is by default with most install methods). ' + - 'Find your Homebridge URL by opening the Homebridge UI in a browser (usually http://homebridge.local:8581 ' + - 'or http://:8581).', - }; - keychain; - cachedToken = null; - tokenExpiry = 0; - async initialize(conductor) { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - isConfigured() { - return true; - } - // ── Credential helpers ─────────────────────────────────────────────────────── - async getCredentials() { - const rawUrl = await this.keychain.get('homekit', 'base_url'); - const username = await this.keychain.get('homekit', 'username'); - const password = await this.keychain.get('homekit', 'password'); - if (!rawUrl) { - throw new Error('Homebridge URL not configured.\n' + - 'Run: conductor plugins config homekit base_url http://homebridge.local:8581'); - } - if (!username) { - throw new Error('Homebridge username not configured.\n' + - 'Run: conductor plugins config homekit username admin'); - } - if (!password) { - throw new Error('Homebridge password not configured.\n' + - 'Run: conductor plugins config homekit password '); - } - return { baseUrl: rawUrl.replace(/\/$/, ''), username, password }; - } - /** Authenticate with Homebridge UI and return a JWT token (cached). */ - async getToken() { - if (this.cachedToken && Date.now() < this.tokenExpiry - 5 * 60 * 1000) { - return this.cachedToken; - } - const { baseUrl, username, password } = await this.getCredentials(); - const res = await fetch(`${baseUrl}/api/auth/sign-in`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }), - }); - if (!res.ok) { - const text = await res.text().catch(() => res.statusText); - throw new Error(`Homebridge auth failed (${res.status}): ${text}`); - } - const data = (await res.json()); - if (!data.access_token) { - throw new Error('Homebridge did not return an access token. Check credentials.'); - } - this.cachedToken = data.access_token; - this.tokenExpiry = Date.now() + (data.expires_in ?? 28_800) * 1000; - return this.cachedToken; - } - // ── API fetch wrapper ──────────────────────────────────────────────────────── - async homebridgeFetch(path, options = {}) { - const { baseUrl } = await this.getCredentials(); - const token = await this.getToken(); - const res = await fetch(`${baseUrl}${path}`, { - method: options.method ?? 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: options.body ? JSON.stringify(options.body) : undefined, - }); - if (res.status === 204) - return {}; - if (res.status === 401) { - this.cachedToken = null; - throw new Error('Homebridge session expired. Please retry the request.'); - } - if (!res.ok) { - const err = (await res.json().catch(() => ({ message: res.statusText }))); - throw new Error(`Homebridge API ${res.status}: ${err.message ?? res.statusText}`); - } - return res.json(); - } - // ── Formatting helpers ─────────────────────────────────────────────────────── - formatAccessory(acc) { - return { - uniqueId: acc.uniqueId, - name: acc.serviceName ?? acc.displayName ?? 'Unknown', - type: acc.humanType ?? acc.type ?? 'Unknown', - room: acc.roomName ?? null, - values: acc.values ?? {}, - on: acc.values?.On ?? null, - reachable: acc.reachable ?? true, - }; - } - // ── Tools ──────────────────────────────────────────────────────────────────── - getTools() { - return [ - // ── homekit_status ──────────────────────────────────────────────────── - { - name: 'homekit_status', - description: 'Check Homebridge connection status, version, and a summary of all accessories', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const [accessoriesResult, serverResult] = await Promise.allSettled([ - this.homebridgeFetch('/api/accessories'), - this.homebridgeFetch('/api/server/version'), - ]); - const accessories = accessoriesResult.status === 'fulfilled' - ? accessoriesResult.value - : []; - const server = serverResult.status === 'fulfilled' ? serverResult.value : {}; - const byType = {}; - for (const acc of accessories) { - const type = acc.humanType ?? 'Unknown'; - byType[type] = (byType[type] ?? 0) + 1; - } - const { baseUrl } = await this.getCredentials(); - return { - connected: accessoriesResult.status === 'fulfilled', - homebridgeUrl: baseUrl, - homebridgeVersion: server.homebridgeVersion ?? server.currentVersion ?? 'unknown', - nodeVersion: server.nodeVersion ?? 'unknown', - totalAccessories: accessories.length, - byType, - }; - }, - }, - // ── homekit_accessories ─────────────────────────────────────────────── - { - name: 'homekit_accessories', - description: 'List all HomeKit accessories with their current state', - inputSchema: { - type: 'object', - properties: { - type: { - type: 'string', - description: 'Filter by accessory type (e.g. "Lightbulb", "Switch", "Thermostat", "Lock", "Fan")', - }, - room: { - type: 'string', - description: 'Filter by room name (partial match, case-insensitive)', - }, - }, - }, - handler: async ({ type, room }) => { - const accessories = (await this.homebridgeFetch('/api/accessories')); - let filtered = accessories; - if (type) { - const q = type.toLowerCase(); - filtered = filtered.filter((a) => (a.humanType ?? a.type ?? '').toLowerCase().includes(q)); - } - if (room) { - const q = room.toLowerCase(); - filtered = filtered.filter((a) => (a.roomName ?? '').toLowerCase().includes(q)); - } - return { - count: filtered.length, - accessories: filtered.map(this.formatAccessory.bind(this)), - }; - }, - }, - // ── homekit_get_accessory ───────────────────────────────────────────── - { - name: 'homekit_get_accessory', - description: 'Get the current state and all characteristics of a specific HomeKit accessory', - inputSchema: { - type: 'object', - properties: { - uniqueId: { - type: 'string', - description: 'Accessory uniqueId (from homekit_accessories)', - }, - }, - required: ['uniqueId'], - }, - handler: async ({ uniqueId }) => { - const acc = await this.homebridgeFetch(`/api/accessories/${encodeURIComponent(uniqueId)}`); - return { - ...this.formatAccessory(acc), - serviceCharacteristics: (acc.serviceCharacteristics ?? []).map((c) => ({ - type: c.type, - description: c.description, - value: c.value, - format: c.format, - unit: c.unit ?? null, - minValue: c.minValue ?? null, - maxValue: c.maxValue ?? null, - canRead: c.canRead ?? true, - canWrite: c.canWrite ?? false, - })), - }; - }, - }, - // ── homekit_set ─────────────────────────────────────────────────────── - { - name: 'homekit_set', - description: 'Set a characteristic on a HomeKit accessory. ' + - 'Common examples: On (true/false), Brightness (0-100), ' + - 'TargetTemperature (degrees), Hue (0-360), Saturation (0-100), ' + - 'LockTargetState (0=unsecured, 1=secured).', - inputSchema: { - type: 'object', - properties: { - uniqueId: { - type: 'string', - description: 'Accessory uniqueId (from homekit_accessories)', - }, - characteristicType: { - type: 'string', - description: 'The characteristic to set (e.g. "On", "Brightness", "TargetTemperature", "Hue")', - }, - value: { - description: 'The value to set (boolean, number, or string)', - }, - }, - required: ['uniqueId', 'characteristicType', 'value'], - }, - requiresApproval: true, - handler: async ({ uniqueId, characteristicType, value }) => { - await this.homebridgeFetch(`/api/accessories/${encodeURIComponent(uniqueId)}`, { - method: 'PUT', - body: { characteristicType, value }, - }); - const updated = await this.homebridgeFetch(`/api/accessories/${encodeURIComponent(uniqueId)}`); - return { - success: true, - set: { characteristicType, value }, - accessory: this.formatAccessory(updated), - }; - }, - }, - // ── homekit_toggle ──────────────────────────────────────────────────── - { - name: 'homekit_toggle', - description: 'Toggle a HomeKit accessory on or off by name. ' + - 'Finds the accessory by partial name match and flips (or sets) its On state.', - inputSchema: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'Accessory name (partial match, case-insensitive)', - }, - on: { - type: 'boolean', - description: 'Force on (true) or off (false). If omitted, toggles the current state.', - }, - }, - required: ['name'], - }, - requiresApproval: true, - handler: async ({ name, on }) => { - const accessories = (await this.homebridgeFetch('/api/accessories')); - const q = name.toLowerCase(); - const acc = accessories.find((a) => (a.serviceName ?? a.displayName ?? '').toLowerCase().includes(q)); - if (!acc) { - const available = accessories - .map((a) => a.serviceName ?? a.displayName) - .filter(Boolean) - .join(', '); - return { - error: `No accessory found matching "${name}".`, - available, - }; - } - const currentOn = acc.values?.On ?? false; - const targetOn = on !== undefined ? on : !currentOn; - await this.homebridgeFetch(`/api/accessories/${encodeURIComponent(acc.uniqueId)}`, { - method: 'PUT', - body: { characteristicType: 'On', value: targetOn }, - }); - return { - success: true, - accessory: acc.serviceName ?? acc.displayName, - uniqueId: acc.uniqueId, - previousState: currentOn, - on: targetOn, - }; - }, - }, - // ── homekit_rooms ───────────────────────────────────────────────────── - { - name: 'homekit_rooms', - description: 'Get the room layout from Homebridge — shows which accessories are in which rooms', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const layout = (await this.homebridgeFetch('/api/accessories/layout')); - return { - count: layout.length, - rooms: layout.map((room) => ({ - name: room.name ?? 'Default Room', - accessories: (room.services ?? []).map((s) => ({ - uniqueId: s.uniqueId, - name: s.customName ?? s.serviceName ?? s.displayName, - type: s.humanType ?? s.type, - })), - })), - }; - }, - }, - ]; - } -} diff --git a/plugins/homekit/package.json b/plugins/homekit/package.json deleted file mode 100644 index 1d2ef47..0000000 --- a/plugins/homekit/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@conductor-plugins/homekit", - "version": "1.0.0", - "type": "module", - "main": "dist/homekit.js", - "scripts": { - "build": "tsc", - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "typescript": "^5.0.0", - "@types/node": "^20.0.0" - } -} diff --git a/plugins/homekit/src/index.ts b/plugins/homekit/src/index.ts deleted file mode 100644 index 40c9f0d..0000000 --- a/plugins/homekit/src/index.ts +++ /dev/null @@ -1,454 +0,0 @@ -/** - * HomeKit Plugin — TheAlxLabs / Conductor - * - * Control HomeKit smart home devices via the Homebridge UI REST API - * (homebridge-config-ui-x). Requires Homebridge with the UI plugin installed. - * - * Setup: - * 1. Install Homebridge: https://homebridge.io - * (homebridge-config-ui-x is included by default with most install methods) - * 2. Run: conductor plugins config homekit base_url http://homebridge.local:8581 - * 3. Run: conductor plugins config homekit username admin - * 4. Run: conductor plugins config homekit password - * - * Keychain entries: homekit/base_url, homekit/username, homekit/password - */ - -// ── Inlined types (no external dependencies) ───────────────────────────────── - -interface PluginConfigField { - key: string; label: string; type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; secret?: boolean; service?: string; description?: string; options?: string[]; -} -interface PluginConfigSchema { fields: PluginConfigField[]; setupInstructions?: string; } -interface PluginTool { - name: string; description: string; inputSchema: Record; - handler: (input: Record) => Promise; requiresApproval?: boolean; -} -interface Plugin { - name: string; description: string; version: string; configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; isConfigured(): boolean; - getTools(): PluginTool[]; getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { getConfig(): ConfigManagerLike; } -class Keychain { - constructor(private dir: string) {} - async get(service: string, account: string): Promise { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service: string, _account: string, _value: string): Promise {} - async delete(_service: string, _account: string): Promise {} -} - -export class HomeKitPlugin implements Plugin { - name = 'homekit'; - description = - 'Control HomeKit smart home devices via Homebridge — list, get, and control accessories'; - version = '1.0.0'; - - configSchema = { - fields: [ - { - key: 'base_url', - label: 'Homebridge URL', - type: 'string' as const, - required: true, - secret: false, - description: 'e.g. http://homebridge.local:8581 or http://192.168.1.100:8581', - }, - { - key: 'username', - label: 'Homebridge Username', - type: 'string' as const, - required: true, - secret: false, - description: 'Your Homebridge UI login username (default: admin)', - }, - { - key: 'password', - label: 'Homebridge Password', - type: 'password' as const, - required: true, - secret: true, - service: 'homekit', - }, - ], - setupInstructions: - 'Install Homebridge (https://homebridge.io) on your local network. ' + - 'The homebridge-config-ui-x plugin must be installed (it is by default with most install methods). ' + - 'Find your Homebridge URL by opening the Homebridge UI in a browser (usually http://homebridge.local:8581 ' + - 'or http://:8581).', - }; - - private keychain!: Keychain; - private cachedToken: string | null = null; - private tokenExpiry: number = 0; - - async initialize(conductor: Conductor): Promise { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - - isConfigured(): boolean { - return true; - } - - // ── Credential helpers ─────────────────────────────────────────────────────── - - private async getCredentials(): Promise<{ baseUrl: string; username: string; password: string }> { - const rawUrl = await this.keychain.get('homekit', 'base_url'); - const username = await this.keychain.get('homekit', 'username'); - const password = await this.keychain.get('homekit', 'password'); - - if (!rawUrl) { - throw new Error( - 'Homebridge URL not configured.\n' + - 'Run: conductor plugins config homekit base_url http://homebridge.local:8581' - ); - } - if (!username) { - throw new Error( - 'Homebridge username not configured.\n' + - 'Run: conductor plugins config homekit username admin' - ); - } - if (!password) { - throw new Error( - 'Homebridge password not configured.\n' + - 'Run: conductor plugins config homekit password ' - ); - } - - return { baseUrl: rawUrl.replace(/\/$/, ''), username, password }; - } - - /** Authenticate with Homebridge UI and return a JWT token (cached). */ - private async getToken(): Promise { - if (this.cachedToken && Date.now() < this.tokenExpiry - 5 * 60 * 1000) { - return this.cachedToken; - } - - const { baseUrl, username, password } = await this.getCredentials(); - - const res = await fetch(`${baseUrl}/api/auth/sign-in`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }), - }); - - if (!res.ok) { - const text = await res.text().catch(() => res.statusText); - throw new Error(`Homebridge auth failed (${res.status}): ${text}`); - } - - const data = (await res.json()) as any; - if (!data.access_token) { - throw new Error('Homebridge did not return an access token. Check credentials.'); - } - - this.cachedToken = data.access_token; - this.tokenExpiry = Date.now() + (data.expires_in ?? 28_800) * 1000; - return this.cachedToken!; - } - - // ── API fetch wrapper ──────────────────────────────────────────────────────── - - private async homebridgeFetch( - path: string, - options: { method?: string; body?: any } = {} - ): Promise { - const { baseUrl } = await this.getCredentials(); - const token = await this.getToken(); - - const res = await fetch(`${baseUrl}${path}`, { - method: options.method ?? 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: options.body ? JSON.stringify(options.body) : undefined, - }); - - if (res.status === 204) return {}; - - if (res.status === 401) { - this.cachedToken = null; - throw new Error('Homebridge session expired. Please retry the request.'); - } - - if (!res.ok) { - const err = (await res.json().catch(() => ({ message: res.statusText }))) as any; - throw new Error(`Homebridge API ${res.status}: ${err.message ?? res.statusText}`); - } - - return res.json(); - } - - // ── Formatting helpers ─────────────────────────────────────────────────────── - - private formatAccessory(acc: any) { - return { - uniqueId: acc.uniqueId, - name: acc.serviceName ?? acc.displayName ?? 'Unknown', - type: acc.humanType ?? acc.type ?? 'Unknown', - room: acc.roomName ?? null, - values: acc.values ?? {}, - on: acc.values?.On ?? null, - reachable: acc.reachable ?? true, - }; - } - - // ── Tools ──────────────────────────────────────────────────────────────────── - - getTools(): PluginTool[] { - return [ - - // ── homekit_status ──────────────────────────────────────────────────── - { - name: 'homekit_status', - description: - 'Check Homebridge connection status, version, and a summary of all accessories', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const [accessoriesResult, serverResult] = await Promise.allSettled([ - this.homebridgeFetch('/api/accessories'), - this.homebridgeFetch('/api/server/version'), - ]); - - const accessories = - accessoriesResult.status === 'fulfilled' - ? (accessoriesResult.value as any[]) - : []; - const server = - serverResult.status === 'fulfilled' ? (serverResult.value as any) : {}; - - const byType: Record = {}; - for (const acc of accessories) { - const type = acc.humanType ?? 'Unknown'; - byType[type] = (byType[type] ?? 0) + 1; - } - - const { baseUrl } = await this.getCredentials(); - - return { - connected: accessoriesResult.status === 'fulfilled', - homebridgeUrl: baseUrl, - homebridgeVersion: server.homebridgeVersion ?? server.currentVersion ?? 'unknown', - nodeVersion: server.nodeVersion ?? 'unknown', - totalAccessories: accessories.length, - byType, - }; - }, - }, - - // ── homekit_accessories ─────────────────────────────────────────────── - { - name: 'homekit_accessories', - description: 'List all HomeKit accessories with their current state', - inputSchema: { - type: 'object', - properties: { - type: { - type: 'string', - description: - 'Filter by accessory type (e.g. "Lightbulb", "Switch", "Thermostat", "Lock", "Fan")', - }, - room: { - type: 'string', - description: 'Filter by room name (partial match, case-insensitive)', - }, - }, - }, - handler: async ({ type, room }: any) => { - const accessories = (await this.homebridgeFetch('/api/accessories')) as any[]; - let filtered = accessories; - - if (type) { - const q = type.toLowerCase(); - filtered = filtered.filter((a: any) => - (a.humanType ?? a.type ?? '').toLowerCase().includes(q) - ); - } - - if (room) { - const q = room.toLowerCase(); - filtered = filtered.filter((a: any) => - (a.roomName ?? '').toLowerCase().includes(q) - ); - } - - return { - count: filtered.length, - accessories: filtered.map(this.formatAccessory.bind(this)), - }; - }, - }, - - // ── homekit_get_accessory ───────────────────────────────────────────── - { - name: 'homekit_get_accessory', - description: - 'Get the current state and all characteristics of a specific HomeKit accessory', - inputSchema: { - type: 'object', - properties: { - uniqueId: { - type: 'string', - description: 'Accessory uniqueId (from homekit_accessories)', - }, - }, - required: ['uniqueId'], - }, - handler: async ({ uniqueId }: any) => { - const acc = await this.homebridgeFetch( - `/api/accessories/${encodeURIComponent(uniqueId)}` - ); - return { - ...this.formatAccessory(acc), - serviceCharacteristics: (acc.serviceCharacteristics ?? []).map((c: any) => ({ - type: c.type, - description: c.description, - value: c.value, - format: c.format, - unit: c.unit ?? null, - minValue: c.minValue ?? null, - maxValue: c.maxValue ?? null, - canRead: c.canRead ?? true, - canWrite: c.canWrite ?? false, - })), - }; - }, - }, - - // ── homekit_set ─────────────────────────────────────────────────────── - { - name: 'homekit_set', - description: - 'Set a characteristic on a HomeKit accessory. ' + - 'Common examples: On (true/false), Brightness (0-100), ' + - 'TargetTemperature (degrees), Hue (0-360), Saturation (0-100), ' + - 'LockTargetState (0=unsecured, 1=secured).', - inputSchema: { - type: 'object', - properties: { - uniqueId: { - type: 'string', - description: 'Accessory uniqueId (from homekit_accessories)', - }, - characteristicType: { - type: 'string', - description: - 'The characteristic to set (e.g. "On", "Brightness", "TargetTemperature", "Hue")', - }, - value: { - description: 'The value to set (boolean, number, or string)', - }, - }, - required: ['uniqueId', 'characteristicType', 'value'], - }, - requiresApproval: true, - handler: async ({ uniqueId, characteristicType, value }: any) => { - await this.homebridgeFetch(`/api/accessories/${encodeURIComponent(uniqueId)}`, { - method: 'PUT', - body: { characteristicType, value }, - }); - - const updated = await this.homebridgeFetch( - `/api/accessories/${encodeURIComponent(uniqueId)}` - ); - - return { - success: true, - set: { characteristicType, value }, - accessory: this.formatAccessory(updated), - }; - }, - }, - - // ── homekit_toggle ──────────────────────────────────────────────────── - { - name: 'homekit_toggle', - description: - 'Toggle a HomeKit accessory on or off by name. ' + - 'Finds the accessory by partial name match and flips (or sets) its On state.', - inputSchema: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'Accessory name (partial match, case-insensitive)', - }, - on: { - type: 'boolean', - description: - 'Force on (true) or off (false). If omitted, toggles the current state.', - }, - }, - required: ['name'], - }, - requiresApproval: true, - handler: async ({ name, on }: any) => { - const accessories = (await this.homebridgeFetch('/api/accessories')) as any[]; - const q = name.toLowerCase(); - const acc = accessories.find((a: any) => - (a.serviceName ?? a.displayName ?? '').toLowerCase().includes(q) - ); - - if (!acc) { - const available = accessories - .map((a: any) => a.serviceName ?? a.displayName) - .filter(Boolean) - .join(', '); - return { - error: `No accessory found matching "${name}".`, - available, - }; - } - - const currentOn = acc.values?.On ?? false; - const targetOn = on !== undefined ? on : !currentOn; - - await this.homebridgeFetch(`/api/accessories/${encodeURIComponent(acc.uniqueId)}`, { - method: 'PUT', - body: { characteristicType: 'On', value: targetOn }, - }); - - return { - success: true, - accessory: acc.serviceName ?? acc.displayName, - uniqueId: acc.uniqueId, - previousState: currentOn, - on: targetOn, - }; - }, - }, - - // ── homekit_rooms ───────────────────────────────────────────────────── - { - name: 'homekit_rooms', - description: 'Get the room layout from Homebridge — shows which accessories are in which rooms', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const layout = (await this.homebridgeFetch('/api/accessories/layout')) as any[]; - return { - count: layout.length, - rooms: layout.map((room: any) => ({ - name: room.name ?? 'Default Room', - accessories: (room.services ?? []).map((s: any) => ({ - uniqueId: s.uniqueId, - name: s.customName ?? s.serviceName ?? s.displayName, - type: s.humanType ?? s.type, - })), - })), - }; - }, - }, - - ]; - } -} diff --git a/plugins/homekit/tsconfig.json b/plugins/homekit/tsconfig.json deleted file mode 100644 index dce5a7d..0000000 --- a/plugins/homekit/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "skipLibCheck": true, - "declaration": true - }, - "include": ["src/**/*"] -} diff --git a/plugins/jira/dist/index.d.ts b/plugins/jira/dist/index.d.ts deleted file mode 100644 index bce52d7..0000000 --- a/plugins/jira/dist/index.d.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Jira Plugin — Conductor - * Jira project management via Atlassian REST API v3. - */ -interface PluginConfigField { - key: string; - label: string; - type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; - secret?: boolean; - service?: string; - description?: string; - options?: string[]; -} -interface PluginConfigSchema { - fields: PluginConfigField[]; - setupInstructions?: string; -} -interface PluginTool { - name: string; - description: string; - inputSchema: Record; - handler: (input: Record) => Promise; - requiresApproval?: boolean; -} -interface Plugin { - name: string; - description: string; - version: string; - configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - getTools(): PluginTool[]; - getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; - set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { - getConfig(): ConfigManagerLike; -} -export declare class JiraPlugin implements Plugin { - name: string; - description: string; - version: string; - private keychain; - private config; - configSchema: PluginConfigSchema; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - private getAuth; - private jiraFetch; - getTools(): PluginTool[]; -} -export {}; diff --git a/plugins/jira/dist/index.js b/plugins/jira/dist/index.js deleted file mode 100644 index 9b4d13c..0000000 --- a/plugins/jira/dist/index.js +++ /dev/null @@ -1,239 +0,0 @@ -/** - * Jira Plugin — Conductor - * Jira project management via Atlassian REST API v3. - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const k = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[k] ?? null; - } - async set(_s, _a, _v) { } - async delete(_s, _a) { } -} -export class JiraPlugin { - name = 'jira'; - description = 'Jira project management — issues, projects, transitions, and comments'; - version = '1.0.0'; - keychain; - config; - configSchema = { - fields: [ - { key: 'domain', label: 'Jira Domain (e.g. mycompany)', type: 'string', required: true, service: 'jira', description: 'Your Atlassian subdomain (mycompany in mycompany.atlassian.net)' }, - { key: 'email', label: 'Atlassian Account Email', type: 'string', required: true, service: 'jira', description: 'Email address associated with your Atlassian account' }, - { key: 'api_token', label: 'Jira API Token', type: 'password', required: true, secret: true, service: 'jira', description: 'API token from https://id.atlassian.com/manage-profile/security/api-tokens' }, - ], - setupInstructions: 'Create an API token at https://id.atlassian.com/manage-profile/security/api-tokens', - }; - async initialize(conductor) { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - this.config = conductor.getConfig(); - } - isConfigured() { return true; } - async getAuth() { - const domain = this.config.get('jira.domain'); - const email = this.config.get('jira.email'); - const token = await this.keychain.get('jira', 'api_token'); - if (!domain || !email || !token) - throw new Error('Jira not configured. Run: conductor jira setup'); - const base64 = Buffer.from(`${email}:${token}`).toString('base64'); - return { - base: `https://${domain}.atlassian.net/rest/api/3`, - headers: { Authorization: `Basic ${base64}`, 'Content-Type': 'application/json', Accept: 'application/json' }, - }; - } - async jiraFetch(path, options = {}) { - const { base, headers } = await this.getAuth(); - const res = await fetch(`${base}${path}`, { - ...options, - headers: { ...headers, ...(options.headers ?? {}) }, - }); - if (res.status === 204) - return { ok: true }; - const text = await res.text(); - if (!res.ok) - throw new Error(`Jira HTTP ${res.status}: ${text}`); - return JSON.parse(text); - } - getTools() { - return [ - { - name: 'jira_issues', - description: 'Search Jira issues using JQL', - inputSchema: { - type: 'object', - properties: { - jql: { type: 'string', description: 'JQL query e.g. "project = ENG AND status = Open" (default: assigned to me)' }, - max_results: { type: 'number', description: 'Max issues to return (default 20)' }, - }, - }, - handler: async ({ jql = 'assignee = currentUser() ORDER BY updated DESC', max_results = 20 }) => { - const data = await this.jiraFetch(`/search?jql=${encodeURIComponent(jql)}&maxResults=${max_results}&fields=summary,status,priority,assignee,reporter,project,created,updated`); - return { - total: data.total, count: data.issues.length, - issues: data.issues.map((i) => ({ - id: i.id, key: i.key, summary: i.fields.summary, - status: i.fields.status?.name, priority: i.fields.priority?.name, - assignee: i.fields.assignee?.displayName ?? 'Unassigned', - project: i.fields.project?.name, updated: i.fields.updated, - })), - }; - }, - }, - { - name: 'jira_issue', - description: 'Get full details of a Jira issue by key (e.g. ENG-123)', - inputSchema: { - type: 'object', - properties: { key: { type: 'string', description: 'Issue key like ENG-123' } }, - required: ['key'], - }, - handler: async ({ key }) => { - const data = await this.jiraFetch(`/issue/${key}`); - return { - id: data.id, key: data.key, - summary: data.fields.summary, description: data.fields.description?.content?.[0]?.content?.[0]?.text ?? null, - status: data.fields.status?.name, priority: data.fields.priority?.name, - assignee: data.fields.assignee?.displayName, reporter: data.fields.reporter?.displayName, - project: data.fields.project?.name, labels: data.fields.labels, - created: data.fields.created, updated: data.fields.updated, - comments: data.fields.comment?.comments?.slice(-5).map((c) => ({ author: c.author?.displayName, body: c.body?.content?.[0]?.content?.[0]?.text, created: c.created })) ?? [], - }; - }, - }, - { - name: 'jira_create_issue', - description: 'Create a new Jira issue', - inputSchema: { - type: 'object', - properties: { - project_key: { type: 'string', description: 'Project key (e.g. ENG)' }, - summary: { type: 'string', description: 'Issue title/summary' }, - issue_type: { type: 'string', description: 'Issue type: Bug, Story, Task, Epic (default: Task)' }, - description: { type: 'string', description: 'Issue description (plain text)' }, - priority: { type: 'string', description: 'Priority: Highest, High, Medium, Low, Lowest (optional)' }, - assignee_account_id: { type: 'string', description: 'Assignee account ID (optional)' }, - }, - required: ['project_key', 'summary'], - }, - handler: async ({ project_key, summary, issue_type = 'Task', description, priority, assignee_account_id }) => { - const fields = { - project: { key: project_key }, - summary, - issuetype: { name: issue_type }, - }; - if (description) - fields.description = { type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text: description }] }] }; - if (priority) - fields.priority = { name: priority }; - if (assignee_account_id) - fields.assignee = { accountId: assignee_account_id }; - const data = await this.jiraFetch('/issue', { method: 'POST', body: JSON.stringify({ fields }) }); - return { id: data.id, key: data.key, url: data.self }; - }, - }, - { - name: 'jira_update_issue', - description: 'Update a Jira issue', - inputSchema: { - type: 'object', - properties: { - key: { type: 'string', description: 'Issue key (e.g. ENG-123)' }, - summary: { type: 'string', description: 'New summary (optional)' }, - description: { type: 'string', description: 'New description (optional)' }, - priority: { type: 'string', description: 'New priority (optional)' }, - assignee_account_id: { type: 'string', description: 'New assignee account ID (optional)' }, - }, - required: ['key'], - }, - handler: async ({ key, summary, description, priority, assignee_account_id }) => { - const fields = {}; - if (summary) - fields.summary = summary; - if (description) - fields.description = { type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text: description }] }] }; - if (priority) - fields.priority = { name: priority }; - if (assignee_account_id) - fields.assignee = { accountId: assignee_account_id }; - await this.jiraFetch(`/issue/${key}`, { method: 'PUT', body: JSON.stringify({ fields }) }); - return { ok: true, key }; - }, - }, - { - name: 'jira_comment', - description: 'Add a comment to a Jira issue', - inputSchema: { - type: 'object', - properties: { - key: { type: 'string', description: 'Issue key (e.g. ENG-123)' }, - body: { type: 'string', description: 'Comment text' }, - }, - required: ['key', 'body'], - }, - handler: async ({ key, body }) => { - const data = await this.jiraFetch(`/issue/${key}/comment`, { - method: 'POST', - body: JSON.stringify({ body: { type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text: body }] }] } }), - }); - return { id: data.id, author: data.author?.displayName, created: data.created }; - }, - }, - { - name: 'jira_projects', - description: 'List accessible Jira projects', - inputSchema: { - type: 'object', - properties: { max_results: { type: 'number', description: 'Max projects to return (default 50)' } }, - }, - handler: async ({ max_results = 50 }) => { - const data = await this.jiraFetch(`/project/search?maxResults=${max_results}`); - return { - total: data.total, - projects: data.values.map((p) => ({ id: p.id, key: p.key, name: p.name, type: p.projectTypeKey })), - }; - }, - }, - { - name: 'jira_transitions', - description: 'List available transitions for a Jira issue (for moving between statuses)', - inputSchema: { - type: 'object', - properties: { key: { type: 'string', description: 'Issue key (e.g. ENG-123)' } }, - required: ['key'], - }, - handler: async ({ key }) => { - const data = await this.jiraFetch(`/issue/${key}/transitions`); - return { transitions: data.transitions.map((t) => ({ id: t.id, name: t.name, to: t.to?.name })) }; - }, - }, - { - name: 'jira_my_issues', - description: 'Get issues assigned to the authenticated user', - inputSchema: { - type: 'object', - properties: { - status: { type: 'string', description: 'Filter by status name (optional)' }, - limit: { type: 'number', description: 'Max issues (default 20)' }, - }, - }, - handler: async ({ status, limit = 20 }) => { - let jql = 'assignee = currentUser() ORDER BY updated DESC'; - if (status) - jql = `assignee = currentUser() AND status = "${status}" ORDER BY updated DESC`; - const data = await this.jiraFetch(`/search?jql=${encodeURIComponent(jql)}&maxResults=${limit}&fields=summary,status,priority,project,updated`); - return { - total: data.total, count: data.issues.length, - issues: data.issues.map((i) => ({ - key: i.key, summary: i.fields.summary, status: i.fields.status?.name, - priority: i.fields.priority?.name, project: i.fields.project?.name, updated: i.fields.updated, - })), - }; - }, - }, - ]; - } -} diff --git a/plugins/jira/dist/jira.js b/plugins/jira/dist/jira.js deleted file mode 100644 index 9b4d13c..0000000 --- a/plugins/jira/dist/jira.js +++ /dev/null @@ -1,239 +0,0 @@ -/** - * Jira Plugin — Conductor - * Jira project management via Atlassian REST API v3. - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const k = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[k] ?? null; - } - async set(_s, _a, _v) { } - async delete(_s, _a) { } -} -export class JiraPlugin { - name = 'jira'; - description = 'Jira project management — issues, projects, transitions, and comments'; - version = '1.0.0'; - keychain; - config; - configSchema = { - fields: [ - { key: 'domain', label: 'Jira Domain (e.g. mycompany)', type: 'string', required: true, service: 'jira', description: 'Your Atlassian subdomain (mycompany in mycompany.atlassian.net)' }, - { key: 'email', label: 'Atlassian Account Email', type: 'string', required: true, service: 'jira', description: 'Email address associated with your Atlassian account' }, - { key: 'api_token', label: 'Jira API Token', type: 'password', required: true, secret: true, service: 'jira', description: 'API token from https://id.atlassian.com/manage-profile/security/api-tokens' }, - ], - setupInstructions: 'Create an API token at https://id.atlassian.com/manage-profile/security/api-tokens', - }; - async initialize(conductor) { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - this.config = conductor.getConfig(); - } - isConfigured() { return true; } - async getAuth() { - const domain = this.config.get('jira.domain'); - const email = this.config.get('jira.email'); - const token = await this.keychain.get('jira', 'api_token'); - if (!domain || !email || !token) - throw new Error('Jira not configured. Run: conductor jira setup'); - const base64 = Buffer.from(`${email}:${token}`).toString('base64'); - return { - base: `https://${domain}.atlassian.net/rest/api/3`, - headers: { Authorization: `Basic ${base64}`, 'Content-Type': 'application/json', Accept: 'application/json' }, - }; - } - async jiraFetch(path, options = {}) { - const { base, headers } = await this.getAuth(); - const res = await fetch(`${base}${path}`, { - ...options, - headers: { ...headers, ...(options.headers ?? {}) }, - }); - if (res.status === 204) - return { ok: true }; - const text = await res.text(); - if (!res.ok) - throw new Error(`Jira HTTP ${res.status}: ${text}`); - return JSON.parse(text); - } - getTools() { - return [ - { - name: 'jira_issues', - description: 'Search Jira issues using JQL', - inputSchema: { - type: 'object', - properties: { - jql: { type: 'string', description: 'JQL query e.g. "project = ENG AND status = Open" (default: assigned to me)' }, - max_results: { type: 'number', description: 'Max issues to return (default 20)' }, - }, - }, - handler: async ({ jql = 'assignee = currentUser() ORDER BY updated DESC', max_results = 20 }) => { - const data = await this.jiraFetch(`/search?jql=${encodeURIComponent(jql)}&maxResults=${max_results}&fields=summary,status,priority,assignee,reporter,project,created,updated`); - return { - total: data.total, count: data.issues.length, - issues: data.issues.map((i) => ({ - id: i.id, key: i.key, summary: i.fields.summary, - status: i.fields.status?.name, priority: i.fields.priority?.name, - assignee: i.fields.assignee?.displayName ?? 'Unassigned', - project: i.fields.project?.name, updated: i.fields.updated, - })), - }; - }, - }, - { - name: 'jira_issue', - description: 'Get full details of a Jira issue by key (e.g. ENG-123)', - inputSchema: { - type: 'object', - properties: { key: { type: 'string', description: 'Issue key like ENG-123' } }, - required: ['key'], - }, - handler: async ({ key }) => { - const data = await this.jiraFetch(`/issue/${key}`); - return { - id: data.id, key: data.key, - summary: data.fields.summary, description: data.fields.description?.content?.[0]?.content?.[0]?.text ?? null, - status: data.fields.status?.name, priority: data.fields.priority?.name, - assignee: data.fields.assignee?.displayName, reporter: data.fields.reporter?.displayName, - project: data.fields.project?.name, labels: data.fields.labels, - created: data.fields.created, updated: data.fields.updated, - comments: data.fields.comment?.comments?.slice(-5).map((c) => ({ author: c.author?.displayName, body: c.body?.content?.[0]?.content?.[0]?.text, created: c.created })) ?? [], - }; - }, - }, - { - name: 'jira_create_issue', - description: 'Create a new Jira issue', - inputSchema: { - type: 'object', - properties: { - project_key: { type: 'string', description: 'Project key (e.g. ENG)' }, - summary: { type: 'string', description: 'Issue title/summary' }, - issue_type: { type: 'string', description: 'Issue type: Bug, Story, Task, Epic (default: Task)' }, - description: { type: 'string', description: 'Issue description (plain text)' }, - priority: { type: 'string', description: 'Priority: Highest, High, Medium, Low, Lowest (optional)' }, - assignee_account_id: { type: 'string', description: 'Assignee account ID (optional)' }, - }, - required: ['project_key', 'summary'], - }, - handler: async ({ project_key, summary, issue_type = 'Task', description, priority, assignee_account_id }) => { - const fields = { - project: { key: project_key }, - summary, - issuetype: { name: issue_type }, - }; - if (description) - fields.description = { type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text: description }] }] }; - if (priority) - fields.priority = { name: priority }; - if (assignee_account_id) - fields.assignee = { accountId: assignee_account_id }; - const data = await this.jiraFetch('/issue', { method: 'POST', body: JSON.stringify({ fields }) }); - return { id: data.id, key: data.key, url: data.self }; - }, - }, - { - name: 'jira_update_issue', - description: 'Update a Jira issue', - inputSchema: { - type: 'object', - properties: { - key: { type: 'string', description: 'Issue key (e.g. ENG-123)' }, - summary: { type: 'string', description: 'New summary (optional)' }, - description: { type: 'string', description: 'New description (optional)' }, - priority: { type: 'string', description: 'New priority (optional)' }, - assignee_account_id: { type: 'string', description: 'New assignee account ID (optional)' }, - }, - required: ['key'], - }, - handler: async ({ key, summary, description, priority, assignee_account_id }) => { - const fields = {}; - if (summary) - fields.summary = summary; - if (description) - fields.description = { type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text: description }] }] }; - if (priority) - fields.priority = { name: priority }; - if (assignee_account_id) - fields.assignee = { accountId: assignee_account_id }; - await this.jiraFetch(`/issue/${key}`, { method: 'PUT', body: JSON.stringify({ fields }) }); - return { ok: true, key }; - }, - }, - { - name: 'jira_comment', - description: 'Add a comment to a Jira issue', - inputSchema: { - type: 'object', - properties: { - key: { type: 'string', description: 'Issue key (e.g. ENG-123)' }, - body: { type: 'string', description: 'Comment text' }, - }, - required: ['key', 'body'], - }, - handler: async ({ key, body }) => { - const data = await this.jiraFetch(`/issue/${key}/comment`, { - method: 'POST', - body: JSON.stringify({ body: { type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text: body }] }] } }), - }); - return { id: data.id, author: data.author?.displayName, created: data.created }; - }, - }, - { - name: 'jira_projects', - description: 'List accessible Jira projects', - inputSchema: { - type: 'object', - properties: { max_results: { type: 'number', description: 'Max projects to return (default 50)' } }, - }, - handler: async ({ max_results = 50 }) => { - const data = await this.jiraFetch(`/project/search?maxResults=${max_results}`); - return { - total: data.total, - projects: data.values.map((p) => ({ id: p.id, key: p.key, name: p.name, type: p.projectTypeKey })), - }; - }, - }, - { - name: 'jira_transitions', - description: 'List available transitions for a Jira issue (for moving between statuses)', - inputSchema: { - type: 'object', - properties: { key: { type: 'string', description: 'Issue key (e.g. ENG-123)' } }, - required: ['key'], - }, - handler: async ({ key }) => { - const data = await this.jiraFetch(`/issue/${key}/transitions`); - return { transitions: data.transitions.map((t) => ({ id: t.id, name: t.name, to: t.to?.name })) }; - }, - }, - { - name: 'jira_my_issues', - description: 'Get issues assigned to the authenticated user', - inputSchema: { - type: 'object', - properties: { - status: { type: 'string', description: 'Filter by status name (optional)' }, - limit: { type: 'number', description: 'Max issues (default 20)' }, - }, - }, - handler: async ({ status, limit = 20 }) => { - let jql = 'assignee = currentUser() ORDER BY updated DESC'; - if (status) - jql = `assignee = currentUser() AND status = "${status}" ORDER BY updated DESC`; - const data = await this.jiraFetch(`/search?jql=${encodeURIComponent(jql)}&maxResults=${limit}&fields=summary,status,priority,project,updated`); - return { - total: data.total, count: data.issues.length, - issues: data.issues.map((i) => ({ - key: i.key, summary: i.fields.summary, status: i.fields.status?.name, - priority: i.fields.priority?.name, project: i.fields.project?.name, updated: i.fields.updated, - })), - }; - }, - }, - ]; - } -} diff --git a/plugins/jira/package.json b/plugins/jira/package.json deleted file mode 100644 index 540f643..0000000 --- a/plugins/jira/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@conductor-plugins/jira", - "version": "1.0.0", - "type": "module", - "main": "dist/jira.js", - "scripts": { "build": "tsc", "typecheck": "tsc --noEmit" }, - "devDependencies": { "typescript": "^5.0.0", "@types/node": "^20.0.0" } -} diff --git a/plugins/jira/src/index.ts b/plugins/jira/src/index.ts deleted file mode 100644 index e9541f4..0000000 --- a/plugins/jira/src/index.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** - * Jira Plugin — Conductor - * Jira project management via Atlassian REST API v3. - */ - -// ── Inlined types ──────────────────────────────────────────────────────────── -interface PluginConfigField { - key: string; label: string; type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; secret?: boolean; service?: string; description?: string; options?: string[]; -} -interface PluginConfigSchema { fields: PluginConfigField[]; setupInstructions?: string; } -interface PluginTool { - name: string; description: string; inputSchema: Record; - handler: (input: Record) => Promise; requiresApproval?: boolean; -} -interface Plugin { - name: string; description: string; version: string; configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; isConfigured(): boolean; - getTools(): PluginTool[]; getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { getConfig(): ConfigManagerLike; } -class Keychain { - constructor(private dir: string) {} - async get(service: string, account: string): Promise { - const k = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[k] ?? null; - } - async set(_s: string, _a: string, _v: string): Promise {} - async delete(_s: string, _a: string): Promise {} -} - -export class JiraPlugin implements Plugin { - name = 'jira'; - description = 'Jira project management — issues, projects, transitions, and comments'; - version = '1.0.0'; - - private keychain!: Keychain; - private config!: ConfigManagerLike; - - configSchema: PluginConfigSchema = { - fields: [ - { key: 'domain', label: 'Jira Domain (e.g. mycompany)', type: 'string', required: true, service: 'jira', description: 'Your Atlassian subdomain (mycompany in mycompany.atlassian.net)' }, - { key: 'email', label: 'Atlassian Account Email', type: 'string', required: true, service: 'jira', description: 'Email address associated with your Atlassian account' }, - { key: 'api_token', label: 'Jira API Token', type: 'password', required: true, secret: true, service: 'jira', description: 'API token from https://id.atlassian.com/manage-profile/security/api-tokens' }, - ], - setupInstructions: 'Create an API token at https://id.atlassian.com/manage-profile/security/api-tokens', - }; - - async initialize(conductor: Conductor): Promise { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - this.config = conductor.getConfig(); - } - - isConfigured(): boolean { return true; } - - private async getAuth(): Promise<{ base: string; headers: Record }> { - const domain = this.config.get('jira.domain'); - const email = this.config.get('jira.email'); - const token = await this.keychain.get('jira', 'api_token'); - if (!domain || !email || !token) throw new Error('Jira not configured. Run: conductor jira setup'); - const base64 = Buffer.from(`${email}:${token}`).toString('base64'); - return { - base: `https://${domain}.atlassian.net/rest/api/3`, - headers: { Authorization: `Basic ${base64}`, 'Content-Type': 'application/json', Accept: 'application/json' }, - }; - } - - private async jiraFetch(path: string, options: RequestInit = {}): Promise { - const { base, headers } = await this.getAuth(); - const res = await fetch(`${base}${path}`, { - ...options, - headers: { ...headers, ...(options.headers ?? {}) }, - }); - if (res.status === 204) return { ok: true }; - const text = await res.text(); - if (!res.ok) throw new Error(`Jira HTTP ${res.status}: ${text}`); - return JSON.parse(text); - } - - getTools(): PluginTool[] { - return [ - { - name: 'jira_issues', - description: 'Search Jira issues using JQL', - inputSchema: { - type: 'object', - properties: { - jql: { type: 'string', description: 'JQL query e.g. "project = ENG AND status = Open" (default: assigned to me)' }, - max_results: { type: 'number', description: 'Max issues to return (default 20)' }, - }, - }, - handler: async ({ jql = 'assignee = currentUser() ORDER BY updated DESC', max_results = 20 }: any) => { - const data = await this.jiraFetch(`/search?jql=${encodeURIComponent(jql)}&maxResults=${max_results}&fields=summary,status,priority,assignee,reporter,project,created,updated`); - return { - total: data.total, count: data.issues.length, - issues: data.issues.map((i: any) => ({ - id: i.id, key: i.key, summary: i.fields.summary, - status: i.fields.status?.name, priority: i.fields.priority?.name, - assignee: i.fields.assignee?.displayName ?? 'Unassigned', - project: i.fields.project?.name, updated: i.fields.updated, - })), - }; - }, - }, - { - name: 'jira_issue', - description: 'Get full details of a Jira issue by key (e.g. ENG-123)', - inputSchema: { - type: 'object', - properties: { key: { type: 'string', description: 'Issue key like ENG-123' } }, - required: ['key'], - }, - handler: async ({ key }: any) => { - const data = await this.jiraFetch(`/issue/${key}`); - return { - id: data.id, key: data.key, - summary: data.fields.summary, description: data.fields.description?.content?.[0]?.content?.[0]?.text ?? null, - status: data.fields.status?.name, priority: data.fields.priority?.name, - assignee: data.fields.assignee?.displayName, reporter: data.fields.reporter?.displayName, - project: data.fields.project?.name, labels: data.fields.labels, - created: data.fields.created, updated: data.fields.updated, - comments: data.fields.comment?.comments?.slice(-5).map((c: any) => ({ author: c.author?.displayName, body: c.body?.content?.[0]?.content?.[0]?.text, created: c.created })) ?? [], - }; - }, - }, - { - name: 'jira_create_issue', - description: 'Create a new Jira issue', - inputSchema: { - type: 'object', - properties: { - project_key: { type: 'string', description: 'Project key (e.g. ENG)' }, - summary: { type: 'string', description: 'Issue title/summary' }, - issue_type: { type: 'string', description: 'Issue type: Bug, Story, Task, Epic (default: Task)' }, - description: { type: 'string', description: 'Issue description (plain text)' }, - priority: { type: 'string', description: 'Priority: Highest, High, Medium, Low, Lowest (optional)' }, - assignee_account_id: { type: 'string', description: 'Assignee account ID (optional)' }, - }, - required: ['project_key', 'summary'], - }, - handler: async ({ project_key, summary, issue_type = 'Task', description, priority, assignee_account_id }: any) => { - const fields: Record = { - project: { key: project_key }, - summary, - issuetype: { name: issue_type }, - }; - if (description) fields.description = { type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text: description }] }] }; - if (priority) fields.priority = { name: priority }; - if (assignee_account_id) fields.assignee = { accountId: assignee_account_id }; - const data = await this.jiraFetch('/issue', { method: 'POST', body: JSON.stringify({ fields }) }); - return { id: data.id, key: data.key, url: data.self }; - }, - }, - { - name: 'jira_update_issue', - description: 'Update a Jira issue', - inputSchema: { - type: 'object', - properties: { - key: { type: 'string', description: 'Issue key (e.g. ENG-123)' }, - summary: { type: 'string', description: 'New summary (optional)' }, - description: { type: 'string', description: 'New description (optional)' }, - priority: { type: 'string', description: 'New priority (optional)' }, - assignee_account_id: { type: 'string', description: 'New assignee account ID (optional)' }, - }, - required: ['key'], - }, - handler: async ({ key, summary, description, priority, assignee_account_id }: any) => { - const fields: Record = {}; - if (summary) fields.summary = summary; - if (description) fields.description = { type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text: description }] }] }; - if (priority) fields.priority = { name: priority }; - if (assignee_account_id) fields.assignee = { accountId: assignee_account_id }; - await this.jiraFetch(`/issue/${key}`, { method: 'PUT', body: JSON.stringify({ fields }) }); - return { ok: true, key }; - }, - }, - { - name: 'jira_comment', - description: 'Add a comment to a Jira issue', - inputSchema: { - type: 'object', - properties: { - key: { type: 'string', description: 'Issue key (e.g. ENG-123)' }, - body: { type: 'string', description: 'Comment text' }, - }, - required: ['key', 'body'], - }, - handler: async ({ key, body }: any) => { - const data = await this.jiraFetch(`/issue/${key}/comment`, { - method: 'POST', - body: JSON.stringify({ body: { type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text: body }] }] } }), - }); - return { id: data.id, author: data.author?.displayName, created: data.created }; - }, - }, - { - name: 'jira_projects', - description: 'List accessible Jira projects', - inputSchema: { - type: 'object', - properties: { max_results: { type: 'number', description: 'Max projects to return (default 50)' } }, - }, - handler: async ({ max_results = 50 }: any) => { - const data = await this.jiraFetch(`/project/search?maxResults=${max_results}`); - return { - total: data.total, - projects: data.values.map((p: any) => ({ id: p.id, key: p.key, name: p.name, type: p.projectTypeKey })), - }; - }, - }, - { - name: 'jira_transitions', - description: 'List available transitions for a Jira issue (for moving between statuses)', - inputSchema: { - type: 'object', - properties: { key: { type: 'string', description: 'Issue key (e.g. ENG-123)' } }, - required: ['key'], - }, - handler: async ({ key }: any) => { - const data = await this.jiraFetch(`/issue/${key}/transitions`); - return { transitions: data.transitions.map((t: any) => ({ id: t.id, name: t.name, to: t.to?.name })) }; - }, - }, - { - name: 'jira_my_issues', - description: 'Get issues assigned to the authenticated user', - inputSchema: { - type: 'object', - properties: { - status: { type: 'string', description: 'Filter by status name (optional)' }, - limit: { type: 'number', description: 'Max issues (default 20)' }, - }, - }, - handler: async ({ status, limit = 20 }: any) => { - let jql = 'assignee = currentUser() ORDER BY updated DESC'; - if (status) jql = `assignee = currentUser() AND status = "${status}" ORDER BY updated DESC`; - const data = await this.jiraFetch(`/search?jql=${encodeURIComponent(jql)}&maxResults=${limit}&fields=summary,status,priority,project,updated`); - return { - total: data.total, count: data.issues.length, - issues: data.issues.map((i: any) => ({ - key: i.key, summary: i.fields.summary, status: i.fields.status?.name, - priority: i.fields.priority?.name, project: i.fields.project?.name, updated: i.fields.updated, - })), - }; - }, - }, - ]; - } -} diff --git a/plugins/jira/tsconfig.json b/plugins/jira/tsconfig.json deleted file mode 100644 index 9af0c8a..0000000 --- a/plugins/jira/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", - "outDir": "dist", "declaration": true, "strict": true, "esModuleInterop": true - }, - "include": ["src/**/*"] -} diff --git a/plugins/linear/dist/index.d.ts b/plugins/linear/dist/index.d.ts deleted file mode 100644 index 60c92ba..0000000 --- a/plugins/linear/dist/index.d.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Linear Plugin — Conductor - * Linear issue tracker via GraphQL API. - */ -interface PluginConfigField { - key: string; - label: string; - type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; - secret?: boolean; - service?: string; - description?: string; - options?: string[]; -} -interface PluginConfigSchema { - fields: PluginConfigField[]; - setupInstructions?: string; -} -interface PluginTool { - name: string; - description: string; - inputSchema: Record; - handler: (input: Record) => Promise; - requiresApproval?: boolean; -} -interface Plugin { - name: string; - description: string; - version: string; - configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - getTools(): PluginTool[]; - getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; - set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { - getConfig(): ConfigManagerLike; -} -export declare class LinearPlugin implements Plugin { - name: string; - description: string; - version: string; - private keychain; - configSchema: PluginConfigSchema; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - private getKey; - private gql; - getTools(): PluginTool[]; -} -export {}; diff --git a/plugins/linear/dist/index.js b/plugins/linear/dist/index.js deleted file mode 100644 index 40d7f66..0000000 --- a/plugins/linear/dist/index.js +++ /dev/null @@ -1,246 +0,0 @@ -/** - * Linear Plugin — Conductor - * Linear issue tracker via GraphQL API. - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const k = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[k] ?? null; - } - async set(_s, _a, _v) { } - async delete(_s, _a) { } -} -const LINEAR_API = 'https://api.linear.app/graphql'; -export class LinearPlugin { - name = 'linear'; - description = 'Linear issue tracker — manage issues, projects, teams, and comments'; - version = '1.0.0'; - keychain; - configSchema = { - fields: [ - { key: 'api_key', label: 'Linear API Key', type: 'password', required: true, secret: true, service: 'linear', description: 'Your Linear personal API key' }, - ], - setupInstructions: 'Go to https://linear.app/settings/api and create a Personal API Key.', - }; - async initialize(conductor) { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - isConfigured() { return true; } - async getKey() { - const key = await this.keychain.get('linear', 'api_key'); - if (!key) - throw new Error('Linear API key not configured. Run: conductor linear setup'); - return key; - } - async gql(query, variables = {}) { - const key = await this.getKey(); - const res = await fetch(LINEAR_API, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: key }, - body: JSON.stringify({ query, variables }), - }); - if (!res.ok) - throw new Error(`Linear HTTP ${res.status}: ${res.statusText}`); - const json = await res.json(); - if (json.errors?.length) - throw new Error(`Linear API error: ${json.errors[0].message}`); - return json.data; - } - getTools() { - return [ - { - name: 'linear_issues', - description: 'List issues assigned to the authenticated user or filtered by team/state', - inputSchema: { - type: 'object', - properties: { - team_id: { type: 'string', description: 'Filter by team ID (optional)' }, - state: { type: 'string', description: 'Filter by state name e.g. "In Progress" (optional)' }, - limit: { type: 'number', description: 'Max issues to return (default 20)' }, - }, - }, - handler: async ({ team_id, state, limit = 20 }) => { - const filter = {}; - if (team_id) - filter.team = { id: { eq: team_id } }; - if (state) - filter.state = { name: { eq: state } }; - const data = await this.gql(` - query($filter: IssueFilter, $first: Int) { - issues(filter: $filter, first: $first, orderBy: updatedAt) { - nodes { id identifier title priority state { name } assignee { name } team { name } createdAt updatedAt url } - } - } - `, { filter: Object.keys(filter).length ? filter : undefined, first: limit }); - return { count: data.issues.nodes.length, issues: data.issues.nodes }; - }, - }, - { - name: 'linear_issue', - description: 'Get details for a specific Linear issue by ID or identifier (e.g. ENG-123)', - inputSchema: { - type: 'object', - properties: { id: { type: 'string', description: 'Issue ID or identifier like ENG-123' } }, - required: ['id'], - }, - handler: async ({ id }) => { - // Try by identifier first, then by UUID - const isUUID = /^[0-9a-f-]{36}$/i.test(id); - if (isUUID) { - const data = await this.gql(` - query($id: String!) { - issue(id: $id) { id identifier title description priority state { name } assignee { name } team { name } comments { nodes { body user { name } createdAt } } url createdAt updatedAt } - } - `, { id }); - return data.issue; - } - else { - const data = await this.gql(` - query($filter: IssueFilter) { - issues(filter: $filter, first: 1) { - nodes { id identifier title description priority state { name } assignee { name } team { name } comments { nodes { body user { name } createdAt } } url createdAt updatedAt } - } - } - `, { filter: { identifier: { eq: id } } }); - if (!data.issues.nodes.length) - throw new Error(`Issue "${id}" not found`); - return data.issues.nodes[0]; - } - }, - }, - { - name: 'linear_create_issue', - description: 'Create a new issue in Linear', - inputSchema: { - type: 'object', - properties: { - title: { type: 'string', description: 'Issue title' }, - team_id: { type: 'string', description: 'Team ID to create issue in' }, - description: { type: 'string', description: 'Issue description (markdown)' }, - priority: { type: 'number', description: '0=none, 1=urgent, 2=high, 3=medium, 4=low' }, - assignee_id: { type: 'string', description: 'User ID to assign (optional)' }, - }, - required: ['title', 'team_id'], - }, - handler: async ({ title, team_id, description, priority, assignee_id }) => { - const data = await this.gql(` - mutation($input: IssueCreateInput!) { - issueCreate(input: $input) { success issue { id identifier title url } } - } - `, { input: { title, teamId: team_id, description, priority, assigneeId: assignee_id } }); - return data.issueCreate; - }, - }, - { - name: 'linear_update_issue', - description: 'Update an existing Linear issue', - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'Issue ID (UUID)' }, - title: { type: 'string', description: 'New title (optional)' }, - description: { type: 'string', description: 'New description (optional)' }, - state_id: { type: 'string', description: 'New state ID (optional)' }, - priority: { type: 'number', description: 'New priority 0-4 (optional)' }, - assignee_id: { type: 'string', description: 'New assignee user ID (optional)' }, - }, - required: ['id'], - }, - handler: async ({ id, title, description, state_id, priority, assignee_id }) => { - const input = {}; - if (title) - input.title = title; - if (description) - input.description = description; - if (state_id) - input.stateId = state_id; - if (priority !== undefined) - input.priority = priority; - if (assignee_id) - input.assigneeId = assignee_id; - const data = await this.gql(` - mutation($id: String!, $input: IssueUpdateInput!) { - issueUpdate(id: $id, input: $input) { success issue { id identifier title state { name } url } } - } - `, { id, input }); - return data.issueUpdate; - }, - }, - { - name: 'linear_comment', - description: 'Add a comment to a Linear issue', - inputSchema: { - type: 'object', - properties: { - issue_id: { type: 'string', description: 'Issue ID (UUID)' }, - body: { type: 'string', description: 'Comment body (markdown)' }, - }, - required: ['issue_id', 'body'], - }, - handler: async ({ issue_id, body }) => { - const data = await this.gql(` - mutation($input: CommentCreateInput!) { - commentCreate(input: $input) { success comment { id body createdAt } } - } - `, { input: { issueId: issue_id, body } }); - return data.commentCreate; - }, - }, - { - name: 'linear_teams', - description: 'List all teams in the Linear workspace', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const data = await this.gql(` - query { teams { nodes { id name key description memberCount } } } - `); - return { count: data.teams.nodes.length, teams: data.teams.nodes }; - }, - }, - { - name: 'linear_me', - description: 'Get the authenticated user\'s profile and assigned issues', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const data = await this.gql(` - query { - viewer { - id name email - assignedIssues(first: 20, orderBy: updatedAt) { - nodes { id identifier title state { name } priority url } - } - } - } - `); - return data.viewer; - }, - }, - { - name: 'linear_projects', - description: 'List projects in Linear, optionally filtered by team', - inputSchema: { - type: 'object', - properties: { - team_id: { type: 'string', description: 'Filter by team ID (optional)' }, - limit: { type: 'number', description: 'Max projects to return (default 20)' }, - }, - }, - handler: async ({ team_id, limit = 20 }) => { - const filter = team_id ? { teams: { some: { id: { eq: team_id } } } } : undefined; - const data = await this.gql(` - query($filter: ProjectFilter, $first: Int) { - projects(filter: $filter, first: $first) { - nodes { id name description state progress startDate targetDate } - } - } - `, { filter, first: limit }); - return { count: data.projects.nodes.length, projects: data.projects.nodes }; - }, - }, - ]; - } -} diff --git a/plugins/linear/dist/linear.js b/plugins/linear/dist/linear.js deleted file mode 100644 index 40d7f66..0000000 --- a/plugins/linear/dist/linear.js +++ /dev/null @@ -1,246 +0,0 @@ -/** - * Linear Plugin — Conductor - * Linear issue tracker via GraphQL API. - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const k = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[k] ?? null; - } - async set(_s, _a, _v) { } - async delete(_s, _a) { } -} -const LINEAR_API = 'https://api.linear.app/graphql'; -export class LinearPlugin { - name = 'linear'; - description = 'Linear issue tracker — manage issues, projects, teams, and comments'; - version = '1.0.0'; - keychain; - configSchema = { - fields: [ - { key: 'api_key', label: 'Linear API Key', type: 'password', required: true, secret: true, service: 'linear', description: 'Your Linear personal API key' }, - ], - setupInstructions: 'Go to https://linear.app/settings/api and create a Personal API Key.', - }; - async initialize(conductor) { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - isConfigured() { return true; } - async getKey() { - const key = await this.keychain.get('linear', 'api_key'); - if (!key) - throw new Error('Linear API key not configured. Run: conductor linear setup'); - return key; - } - async gql(query, variables = {}) { - const key = await this.getKey(); - const res = await fetch(LINEAR_API, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: key }, - body: JSON.stringify({ query, variables }), - }); - if (!res.ok) - throw new Error(`Linear HTTP ${res.status}: ${res.statusText}`); - const json = await res.json(); - if (json.errors?.length) - throw new Error(`Linear API error: ${json.errors[0].message}`); - return json.data; - } - getTools() { - return [ - { - name: 'linear_issues', - description: 'List issues assigned to the authenticated user or filtered by team/state', - inputSchema: { - type: 'object', - properties: { - team_id: { type: 'string', description: 'Filter by team ID (optional)' }, - state: { type: 'string', description: 'Filter by state name e.g. "In Progress" (optional)' }, - limit: { type: 'number', description: 'Max issues to return (default 20)' }, - }, - }, - handler: async ({ team_id, state, limit = 20 }) => { - const filter = {}; - if (team_id) - filter.team = { id: { eq: team_id } }; - if (state) - filter.state = { name: { eq: state } }; - const data = await this.gql(` - query($filter: IssueFilter, $first: Int) { - issues(filter: $filter, first: $first, orderBy: updatedAt) { - nodes { id identifier title priority state { name } assignee { name } team { name } createdAt updatedAt url } - } - } - `, { filter: Object.keys(filter).length ? filter : undefined, first: limit }); - return { count: data.issues.nodes.length, issues: data.issues.nodes }; - }, - }, - { - name: 'linear_issue', - description: 'Get details for a specific Linear issue by ID or identifier (e.g. ENG-123)', - inputSchema: { - type: 'object', - properties: { id: { type: 'string', description: 'Issue ID or identifier like ENG-123' } }, - required: ['id'], - }, - handler: async ({ id }) => { - // Try by identifier first, then by UUID - const isUUID = /^[0-9a-f-]{36}$/i.test(id); - if (isUUID) { - const data = await this.gql(` - query($id: String!) { - issue(id: $id) { id identifier title description priority state { name } assignee { name } team { name } comments { nodes { body user { name } createdAt } } url createdAt updatedAt } - } - `, { id }); - return data.issue; - } - else { - const data = await this.gql(` - query($filter: IssueFilter) { - issues(filter: $filter, first: 1) { - nodes { id identifier title description priority state { name } assignee { name } team { name } comments { nodes { body user { name } createdAt } } url createdAt updatedAt } - } - } - `, { filter: { identifier: { eq: id } } }); - if (!data.issues.nodes.length) - throw new Error(`Issue "${id}" not found`); - return data.issues.nodes[0]; - } - }, - }, - { - name: 'linear_create_issue', - description: 'Create a new issue in Linear', - inputSchema: { - type: 'object', - properties: { - title: { type: 'string', description: 'Issue title' }, - team_id: { type: 'string', description: 'Team ID to create issue in' }, - description: { type: 'string', description: 'Issue description (markdown)' }, - priority: { type: 'number', description: '0=none, 1=urgent, 2=high, 3=medium, 4=low' }, - assignee_id: { type: 'string', description: 'User ID to assign (optional)' }, - }, - required: ['title', 'team_id'], - }, - handler: async ({ title, team_id, description, priority, assignee_id }) => { - const data = await this.gql(` - mutation($input: IssueCreateInput!) { - issueCreate(input: $input) { success issue { id identifier title url } } - } - `, { input: { title, teamId: team_id, description, priority, assigneeId: assignee_id } }); - return data.issueCreate; - }, - }, - { - name: 'linear_update_issue', - description: 'Update an existing Linear issue', - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'Issue ID (UUID)' }, - title: { type: 'string', description: 'New title (optional)' }, - description: { type: 'string', description: 'New description (optional)' }, - state_id: { type: 'string', description: 'New state ID (optional)' }, - priority: { type: 'number', description: 'New priority 0-4 (optional)' }, - assignee_id: { type: 'string', description: 'New assignee user ID (optional)' }, - }, - required: ['id'], - }, - handler: async ({ id, title, description, state_id, priority, assignee_id }) => { - const input = {}; - if (title) - input.title = title; - if (description) - input.description = description; - if (state_id) - input.stateId = state_id; - if (priority !== undefined) - input.priority = priority; - if (assignee_id) - input.assigneeId = assignee_id; - const data = await this.gql(` - mutation($id: String!, $input: IssueUpdateInput!) { - issueUpdate(id: $id, input: $input) { success issue { id identifier title state { name } url } } - } - `, { id, input }); - return data.issueUpdate; - }, - }, - { - name: 'linear_comment', - description: 'Add a comment to a Linear issue', - inputSchema: { - type: 'object', - properties: { - issue_id: { type: 'string', description: 'Issue ID (UUID)' }, - body: { type: 'string', description: 'Comment body (markdown)' }, - }, - required: ['issue_id', 'body'], - }, - handler: async ({ issue_id, body }) => { - const data = await this.gql(` - mutation($input: CommentCreateInput!) { - commentCreate(input: $input) { success comment { id body createdAt } } - } - `, { input: { issueId: issue_id, body } }); - return data.commentCreate; - }, - }, - { - name: 'linear_teams', - description: 'List all teams in the Linear workspace', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const data = await this.gql(` - query { teams { nodes { id name key description memberCount } } } - `); - return { count: data.teams.nodes.length, teams: data.teams.nodes }; - }, - }, - { - name: 'linear_me', - description: 'Get the authenticated user\'s profile and assigned issues', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const data = await this.gql(` - query { - viewer { - id name email - assignedIssues(first: 20, orderBy: updatedAt) { - nodes { id identifier title state { name } priority url } - } - } - } - `); - return data.viewer; - }, - }, - { - name: 'linear_projects', - description: 'List projects in Linear, optionally filtered by team', - inputSchema: { - type: 'object', - properties: { - team_id: { type: 'string', description: 'Filter by team ID (optional)' }, - limit: { type: 'number', description: 'Max projects to return (default 20)' }, - }, - }, - handler: async ({ team_id, limit = 20 }) => { - const filter = team_id ? { teams: { some: { id: { eq: team_id } } } } : undefined; - const data = await this.gql(` - query($filter: ProjectFilter, $first: Int) { - projects(filter: $filter, first: $first) { - nodes { id name description state progress startDate targetDate } - } - } - `, { filter, first: limit }); - return { count: data.projects.nodes.length, projects: data.projects.nodes }; - }, - }, - ]; - } -} diff --git a/plugins/linear/package.json b/plugins/linear/package.json deleted file mode 100644 index c8859d9..0000000 --- a/plugins/linear/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@conductor-plugins/linear", - "version": "1.0.0", - "type": "module", - "main": "dist/linear.js", - "scripts": { "build": "tsc", "typecheck": "tsc --noEmit" }, - "devDependencies": { "typescript": "^5.0.0", "@types/node": "^20.0.0" } -} diff --git a/plugins/linear/src/index.ts b/plugins/linear/src/index.ts deleted file mode 100644 index a60fc58..0000000 --- a/plugins/linear/src/index.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** - * Linear Plugin — Conductor - * Linear issue tracker via GraphQL API. - */ - -// ── Inlined types ──────────────────────────────────────────────────────────── -interface PluginConfigField { - key: string; label: string; type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; secret?: boolean; service?: string; description?: string; options?: string[]; -} -interface PluginConfigSchema { fields: PluginConfigField[]; setupInstructions?: string; } -interface PluginTool { - name: string; description: string; inputSchema: Record; - handler: (input: Record) => Promise; requiresApproval?: boolean; -} -interface Plugin { - name: string; description: string; version: string; configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; isConfigured(): boolean; - getTools(): PluginTool[]; getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { getConfig(): ConfigManagerLike; } -class Keychain { - constructor(private dir: string) {} - async get(service: string, account: string): Promise { - const k = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[k] ?? null; - } - async set(_s: string, _a: string, _v: string): Promise {} - async delete(_s: string, _a: string): Promise {} -} - -const LINEAR_API = 'https://api.linear.app/graphql'; - -export class LinearPlugin implements Plugin { - name = 'linear'; - description = 'Linear issue tracker — manage issues, projects, teams, and comments'; - version = '1.0.0'; - - private keychain!: Keychain; - - configSchema: PluginConfigSchema = { - fields: [ - { key: 'api_key', label: 'Linear API Key', type: 'password', required: true, secret: true, service: 'linear', description: 'Your Linear personal API key' }, - ], - setupInstructions: 'Go to https://linear.app/settings/api and create a Personal API Key.', - }; - - async initialize(conductor: Conductor): Promise { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - - isConfigured(): boolean { return true; } - - private async getKey(): Promise { - const key = await this.keychain.get('linear', 'api_key'); - if (!key) throw new Error('Linear API key not configured. Run: conductor linear setup'); - return key; - } - - private async gql(query: string, variables: Record = {}): Promise { - const key = await this.getKey(); - const res = await fetch(LINEAR_API, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: key }, - body: JSON.stringify({ query, variables }), - }); - if (!res.ok) throw new Error(`Linear HTTP ${res.status}: ${res.statusText}`); - const json = await res.json() as any; - if (json.errors?.length) throw new Error(`Linear API error: ${json.errors[0].message}`); - return json.data; - } - - getTools(): PluginTool[] { - return [ - { - name: 'linear_issues', - description: 'List issues assigned to the authenticated user or filtered by team/state', - inputSchema: { - type: 'object', - properties: { - team_id: { type: 'string', description: 'Filter by team ID (optional)' }, - state: { type: 'string', description: 'Filter by state name e.g. "In Progress" (optional)' }, - limit: { type: 'number', description: 'Max issues to return (default 20)' }, - }, - }, - handler: async ({ team_id, state, limit = 20 }: any) => { - const filter: Record = {}; - if (team_id) filter.team = { id: { eq: team_id } }; - if (state) filter.state = { name: { eq: state } }; - const data = await this.gql(` - query($filter: IssueFilter, $first: Int) { - issues(filter: $filter, first: $first, orderBy: updatedAt) { - nodes { id identifier title priority state { name } assignee { name } team { name } createdAt updatedAt url } - } - } - `, { filter: Object.keys(filter).length ? filter : undefined, first: limit }); - return { count: data.issues.nodes.length, issues: data.issues.nodes }; - }, - }, - { - name: 'linear_issue', - description: 'Get details for a specific Linear issue by ID or identifier (e.g. ENG-123)', - inputSchema: { - type: 'object', - properties: { id: { type: 'string', description: 'Issue ID or identifier like ENG-123' } }, - required: ['id'], - }, - handler: async ({ id }: any) => { - // Try by identifier first, then by UUID - const isUUID = /^[0-9a-f-]{36}$/i.test(id); - if (isUUID) { - const data = await this.gql(` - query($id: String!) { - issue(id: $id) { id identifier title description priority state { name } assignee { name } team { name } comments { nodes { body user { name } createdAt } } url createdAt updatedAt } - } - `, { id }); - return data.issue; - } else { - const data = await this.gql(` - query($filter: IssueFilter) { - issues(filter: $filter, first: 1) { - nodes { id identifier title description priority state { name } assignee { name } team { name } comments { nodes { body user { name } createdAt } } url createdAt updatedAt } - } - } - `, { filter: { identifier: { eq: id } } }); - if (!data.issues.nodes.length) throw new Error(`Issue "${id}" not found`); - return data.issues.nodes[0]; - } - }, - }, - { - name: 'linear_create_issue', - description: 'Create a new issue in Linear', - inputSchema: { - type: 'object', - properties: { - title: { type: 'string', description: 'Issue title' }, - team_id: { type: 'string', description: 'Team ID to create issue in' }, - description: { type: 'string', description: 'Issue description (markdown)' }, - priority: { type: 'number', description: '0=none, 1=urgent, 2=high, 3=medium, 4=low' }, - assignee_id: { type: 'string', description: 'User ID to assign (optional)' }, - }, - required: ['title', 'team_id'], - }, - handler: async ({ title, team_id, description, priority, assignee_id }: any) => { - const data = await this.gql(` - mutation($input: IssueCreateInput!) { - issueCreate(input: $input) { success issue { id identifier title url } } - } - `, { input: { title, teamId: team_id, description, priority, assigneeId: assignee_id } }); - return data.issueCreate; - }, - }, - { - name: 'linear_update_issue', - description: 'Update an existing Linear issue', - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'Issue ID (UUID)' }, - title: { type: 'string', description: 'New title (optional)' }, - description: { type: 'string', description: 'New description (optional)' }, - state_id: { type: 'string', description: 'New state ID (optional)' }, - priority: { type: 'number', description: 'New priority 0-4 (optional)' }, - assignee_id: { type: 'string', description: 'New assignee user ID (optional)' }, - }, - required: ['id'], - }, - handler: async ({ id, title, description, state_id, priority, assignee_id }: any) => { - const input: Record = {}; - if (title) input.title = title; - if (description) input.description = description; - if (state_id) input.stateId = state_id; - if (priority !== undefined) input.priority = priority; - if (assignee_id) input.assigneeId = assignee_id; - const data = await this.gql(` - mutation($id: String!, $input: IssueUpdateInput!) { - issueUpdate(id: $id, input: $input) { success issue { id identifier title state { name } url } } - } - `, { id, input }); - return data.issueUpdate; - }, - }, - { - name: 'linear_comment', - description: 'Add a comment to a Linear issue', - inputSchema: { - type: 'object', - properties: { - issue_id: { type: 'string', description: 'Issue ID (UUID)' }, - body: { type: 'string', description: 'Comment body (markdown)' }, - }, - required: ['issue_id', 'body'], - }, - handler: async ({ issue_id, body }: any) => { - const data = await this.gql(` - mutation($input: CommentCreateInput!) { - commentCreate(input: $input) { success comment { id body createdAt } } - } - `, { input: { issueId: issue_id, body } }); - return data.commentCreate; - }, - }, - { - name: 'linear_teams', - description: 'List all teams in the Linear workspace', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const data = await this.gql(` - query { teams { nodes { id name key description memberCount } } } - `); - return { count: data.teams.nodes.length, teams: data.teams.nodes }; - }, - }, - { - name: 'linear_me', - description: 'Get the authenticated user\'s profile and assigned issues', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const data = await this.gql(` - query { - viewer { - id name email - assignedIssues(first: 20, orderBy: updatedAt) { - nodes { id identifier title state { name } priority url } - } - } - } - `); - return data.viewer; - }, - }, - { - name: 'linear_projects', - description: 'List projects in Linear, optionally filtered by team', - inputSchema: { - type: 'object', - properties: { - team_id: { type: 'string', description: 'Filter by team ID (optional)' }, - limit: { type: 'number', description: 'Max projects to return (default 20)' }, - }, - }, - handler: async ({ team_id, limit = 20 }: any) => { - const filter = team_id ? { teams: { some: { id: { eq: team_id } } } } : undefined; - const data = await this.gql(` - query($filter: ProjectFilter, $first: Int) { - projects(filter: $filter, first: $first) { - nodes { id name description state progress startDate targetDate } - } - } - `, { filter, first: limit }); - return { count: data.projects.nodes.length, projects: data.projects.nodes }; - }, - }, - ]; - } -} diff --git a/plugins/linear/tsconfig.json b/plugins/linear/tsconfig.json deleted file mode 100644 index 9af0c8a..0000000 --- a/plugins/linear/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", - "outDir": "dist", "declaration": true, "strict": true, "esModuleInterop": true - }, - "include": ["src/**/*"] -} diff --git a/plugins/monday/README.md b/plugins/monday/README.md new file mode 100644 index 0000000..be0c9e7 --- /dev/null +++ b/plugins/monday/README.md @@ -0,0 +1,26 @@ +# Monday.com Plugin + +Manage Monday.com boards, items, columns, and updates from Conductor. + +## Setup + +1. Log in to Monday.com and go to **Developers** → **My Access Tokens** at [https://monday.com/settings/api](https://monday.com/settings/api). +2. Copy your personal API token. +3. Configure the plugin: + +```bash +conductor config set monday api_token YOUR_API_TOKEN +``` + +## Available Tools + +| Tool | Description | +|------|-------------| +| `monday_list_boards` | List all accessible boards | +| `monday_get_board` | Get board details and columns | +| `monday_list_items` | List items on a board | +| `monday_get_item` | Get a single item with its column values | +| `monday_create_item` | Create a new item on a board | +| `monday_update_item` | Update column values on an item | +| `monday_archive_item` | Archive an item | +| `monday_add_update` | Add a comment/update to an item | diff --git a/plugins/n8n/README.md b/plugins/n8n/README.md deleted file mode 100644 index 2399a7e..0000000 --- a/plugins/n8n/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# n8n Plugin for Conductor - -Install: `conductor install n8n` - -## Setup - -**Authentication:** API Key - -```bash -conductor plugins config n8n api_key \ -`conductor plugins enable n8n` -``` - -Get credentials at: https://docs.n8n.io/api - -## Tools - -``` -n8n_list_workflows, n8n_get_workflow, n8n_trigger_webhook, n8n_list_executions, n8n_get_execution, n8n_activate_workflow, n8n_deactivate_workflow -``` - -Each tool is documented inline — ask Conductor what tools are available after installing. - -## Source - -Part of [thealxlabs/conductor-plugins](https://github.com/thealxlabs/conductor-plugins/tree/main/plugins/n8n). diff --git a/plugins/n8n/dist/index.d.ts b/plugins/n8n/dist/index.d.ts deleted file mode 100644 index b516fa4..0000000 --- a/plugins/n8n/dist/index.d.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * n8n Plugin — TheAlxLabs / Conductor - * - * Full n8n automation platform integration: - * - Workflows: list, activate/deactivate, get structure, trigger manually - * - Executions: list, inspect, retry, delete - * - Webhooks: trigger webhook-based workflows with custom payloads - * - Credentials: list (no secrets exposed) - * - Tags: organize and filter workflows by tag - * - Health: instance status and queue metrics - * - * Works with both self-hosted n8n and n8n Cloud. - * - * Setup: - * 1. n8n → Settings → API → Create API Key - * 2. Run: conductor plugins config n8n api_key - * 3. Run: conductor plugins config n8n base_url - * e.g. https://n8n.yourdomain.com or https://app.n8n.cloud/api - * - * Keychain: n8n / api_key, n8n / base_url - * - * Note: Your n8n instance at n8n-alxstuff.zeabur.app is already supported. - */ -interface PluginConfigField { - key: string; - label: string; - type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; - secret?: boolean; - service?: string; - description?: string; - options?: string[]; -} -interface PluginConfigSchema { - fields: PluginConfigField[]; - setupInstructions?: string; -} -interface PluginTool { - name: string; - description: string; - inputSchema: Record; - handler: (input: Record) => Promise; - requiresApproval?: boolean; -} -interface Plugin { - name: string; - description: string; - version: string; - configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - getTools(): PluginTool[]; - getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; - set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { - getConfig(): ConfigManagerLike; -} -export declare class N8nPlugin implements Plugin { - name: string; - description: string; - version: string; - configSchema: { - fields: ({ - key: string; - label: string; - type: "password"; - required: boolean; - secret: boolean; - service: string; - description?: undefined; - } | { - key: string; - label: string; - type: "string"; - required: boolean; - secret: boolean; - description: string; - service?: undefined; - })[]; - setupInstructions: string; - }; - private keychain; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - private getConfig; - private n8nFetch; - /** Fire a webhook URL directly — used for webhook-triggered workflows */ - private webhookFetch; - private formatWorkflow; - private formatExecution; - private detectTriggerType; - private extractWebhookPath; - getTools(): PluginTool[]; -} -export {}; diff --git a/plugins/n8n/dist/index.js b/plugins/n8n/dist/index.js deleted file mode 100644 index e596703..0000000 --- a/plugins/n8n/dist/index.js +++ /dev/null @@ -1,546 +0,0 @@ -/** - * n8n Plugin — TheAlxLabs / Conductor - * - * Full n8n automation platform integration: - * - Workflows: list, activate/deactivate, get structure, trigger manually - * - Executions: list, inspect, retry, delete - * - Webhooks: trigger webhook-based workflows with custom payloads - * - Credentials: list (no secrets exposed) - * - Tags: organize and filter workflows by tag - * - Health: instance status and queue metrics - * - * Works with both self-hosted n8n and n8n Cloud. - * - * Setup: - * 1. n8n → Settings → API → Create API Key - * 2. Run: conductor plugins config n8n api_key - * 3. Run: conductor plugins config n8n base_url - * e.g. https://n8n.yourdomain.com or https://app.n8n.cloud/api - * - * Keychain: n8n / api_key, n8n / base_url - * - * Note: Your n8n instance at n8n-alxstuff.zeabur.app is already supported. - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service, _account, _value) { } - async delete(_service, _account) { } -} -export class N8nPlugin { - name = 'n8n'; - description = 'Trigger and manage n8n workflows, inspect executions, fire webhooks — requires n8n API key'; - version = '1.0.0'; - configSchema = { - fields: [ - { - key: 'api_key', - label: 'n8n API Key', - type: 'password', - required: true, - secret: true, - service: 'n8n' - }, - { - key: 'base_url', - label: 'n8n Instance URL', - type: 'string', - required: true, - secret: false, - description: 'e.g. https://n8n.yourdomain.com' - } - ], - setupInstructions: 'Create an API Key in your n8n instance: Settings > API > Create Key.' - }; - keychain; - async initialize(conductor) { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - isConfigured() { - return true; - } - async getConfig() { - const apiKey = await this.keychain.get('n8n', 'api_key'); - if (!apiKey) { - throw new Error('n8n API key not configured.\n' + - 'Get one from your n8n instance: Settings → API → Create Key\n' + - 'Then run: conductor plugins config n8n api_key '); - } - const rawUrl = await this.keychain.get('n8n', 'base_url'); - // Normalise: strip trailing slash, ensure /api/v1 suffix - let baseUrl = (rawUrl ?? 'http://localhost:5678').replace(/\/$/, ''); - if (!baseUrl.endsWith('/api/v1')) - baseUrl = `${baseUrl}/api/v1`; - return { apiKey, baseUrl }; - } - async n8nFetch(path, options = {}) { - const { apiKey, baseUrl } = await this.getConfig(); - const url = new URL(`${baseUrl}${path}`); - if (options.params) { - for (const [k, v] of Object.entries(options.params)) - url.searchParams.set(k, v); - } - const res = await fetch(url.toString(), { - method: options.method ?? 'GET', - headers: { - 'X-N8N-API-KEY': apiKey, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: options.body ? JSON.stringify(options.body) : undefined, - }); - if (res.status === 204) - return {}; - if (!res.ok) { - const err = (await res.json().catch(() => ({ message: res.statusText }))); - throw new Error(`n8n API ${res.status}: ${err.message ?? res.statusText}`); - } - return res.json(); - } - /** Fire a webhook URL directly — used for webhook-triggered workflows */ - async webhookFetch(webhookUrl, method, body, headers) { - const res = await fetch(webhookUrl, { - method, - headers: { 'Content-Type': 'application/json', ...(headers ?? {}) }, - body: body ? JSON.stringify(body) : undefined, - }); - if (!res.ok) { - throw new Error(`Webhook ${res.status}: ${res.statusText}`); - } - const ct = res.headers.get('content-type') ?? ''; - if (ct.includes('application/json')) - return res.json(); - return { response: await res.text() }; - } - // ── Formatters ────────────────────────────────────────────────────────────── - formatWorkflow(w) { - return { - id: w.id, - name: w.name, - active: w.active ?? false, - nodeCount: (w.nodes ?? []).length, - triggerType: this.detectTriggerType(w.nodes ?? []), - webhookPath: this.extractWebhookPath(w.nodes ?? []), - tags: (w.tags ?? []).map((t) => (typeof t === 'string' ? t : t.name)), - createdAt: w.createdAt ?? null, - updatedAt: w.updatedAt ?? null, - }; - } - formatExecution(e) { - const duration = e.startedAt && e.stoppedAt - ? `${Math.round((new Date(e.stoppedAt).getTime() - new Date(e.startedAt).getTime()) / 1000)}s` - : null; - return { - id: e.id, - workflowId: e.workflowId, - workflowName: e.workflowData?.name ?? null, - status: e.status ?? e.finished ? 'success' : 'running', - mode: e.mode ?? 'unknown', - startedAt: e.startedAt ?? null, - stoppedAt: e.stoppedAt ?? null, - duration, - nodeCount: Object.keys(e.data?.resultData?.runData ?? {}).length, - error: e.data?.resultData?.error?.message ?? null, - }; - } - detectTriggerType(nodes) { - const triggerNode = nodes.find((n) => n.type?.includes('Trigger') || n.type?.includes('Webhook') || n.type?.includes('Cron')); - if (!triggerNode) - return 'manual'; - if (triggerNode.type?.includes('Webhook')) - return 'webhook'; - if (triggerNode.type?.includes('Cron') || triggerNode.type?.includes('Schedule')) - return 'schedule'; - if (triggerNode.type?.includes('EmailImap')) - return 'email'; - return triggerNode.type?.split('.').pop()?.replace('Trigger', '') ?? 'trigger'; - } - extractWebhookPath(nodes) { - const webhookNode = nodes.find((n) => n.type === 'n8n-nodes-base.webhook' || n.type?.includes('Webhook')); - return webhookNode?.parameters?.path ?? webhookNode?.parameters?.webhookId ?? null; - } - // ── Tools ─────────────────────────────────────────────────────────────────── - getTools() { - return [ - // ── n8n_workflows ─────────────────────────────────────────────────────── - { - name: 'n8n_workflows', - description: 'List all n8n workflows with their active status and trigger type', - inputSchema: { - type: 'object', - properties: { - active: { - type: 'boolean', - description: 'Filter to only active or only inactive workflows', - }, - tag: { type: 'string', description: 'Filter by tag name' }, - limit: { type: 'number', description: 'Max workflows (default: 50)' }, - search: { type: 'string', description: 'Search by workflow name' }, - }, - }, - handler: async ({ active, tag, limit = 50, search }) => { - const params = { - limit: String(Math.min(limit, 250)), - }; - if (active !== undefined) - params.active = String(active); - if (tag) - params.tags = tag; - const data = await this.n8nFetch('/workflows', { params }); - let workflows = (data.data ?? data); - if (search) { - const q = search.toLowerCase(); - workflows = workflows.filter((w) => w.name?.toLowerCase().includes(q)); - } - const formatted = workflows.map(this.formatWorkflow.bind(this)); - const activeCount = formatted.filter((w) => w.active).length; - return { - total: formatted.length, - active: activeCount, - inactive: formatted.length - activeCount, - workflows: formatted, - }; - }, - }, - // ── n8n_workflow ──────────────────────────────────────────────────────── - { - name: 'n8n_workflow', - description: 'Get full details of a specific n8n workflow including all nodes and connections', - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'Workflow ID' }, - }, - required: ['id'], - }, - handler: async ({ id }) => { - const w = await this.n8nFetch(`/workflows/${id}`); - return { - ...this.formatWorkflow(w), - nodes: (w.nodes ?? []).map((n) => ({ - name: n.name, - type: n.type?.split('.').pop() ?? n.type, - position: n.position, - disabled: n.disabled ?? false, - hasCredentials: !!n.credentials, - })), - connections: Object.keys(w.connections ?? {}).length, - settings: w.settings ?? {}, - }; - }, - }, - // ── n8n_activate ──────────────────────────────────────────────────────── - { - name: 'n8n_activate', - description: 'Activate or deactivate an n8n workflow', - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'Workflow ID' }, - active: { type: 'boolean', description: 'true = activate, false = deactivate' }, - }, - required: ['id', 'active'], - }, - handler: async ({ id, active }) => { - await this.n8nFetch(`/workflows/${id}`, { - method: 'PATCH', - body: { active }, - }); - return { id, active, message: `Workflow ${active ? 'activated' : 'deactivated'}` }; - }, - }, - // ── n8n_trigger ───────────────────────────────────────────────────────── - { - name: 'n8n_trigger', - description: 'Manually trigger an n8n workflow execution. ' + - 'For webhook workflows, fires the webhook URL. ' + - 'For others, uses the n8n execute API.', - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'Workflow ID' }, - payload: { - type: 'object', - description: 'Data to pass to the workflow (for webhook triggers)', - }, - waitForResult: { - type: 'boolean', - description: 'Wait for execution to complete and return result (default: false)', - }, - }, - required: ['id'], - }, - handler: async ({ id, payload = {}, waitForResult = false }) => { - // Get workflow to check trigger type - const workflow = await this.n8nFetch(`/workflows/${id}`); - const triggerType = this.detectTriggerType(workflow.nodes ?? []); - const webhookPath = this.extractWebhookPath(workflow.nodes ?? []); - if (triggerType === 'webhook' && webhookPath) { - const { baseUrl } = await this.getConfig(); - const n8nBase = baseUrl.replace('/api/v1', ''); - const webhookUrl = `${n8nBase}/webhook/${webhookPath}`; - const result = await this.webhookFetch(webhookUrl, 'POST', payload); - return { - triggered: true, - method: 'webhook', - webhookUrl, - workflowId: id, - workflowName: workflow.name, - response: result, - }; - } - // Use execute API for non-webhook workflows - const result = await this.n8nFetch(`/workflows/${id}/run`, { - method: 'POST', - body: { runData: payload }, - }); - if (waitForResult) { - // Poll for completion (max 30s) - const execId = result.data?.executionId; - if (execId) { - for (let i = 0; i < 15; i++) { - await new Promise((r) => setTimeout(r, 2000)); - const exec = await this.n8nFetch(`/executions/${execId}`).catch(() => null); - if (exec?.finished || exec?.status === 'error') { - return { - triggered: true, - method: 'execute', - workflowId: id, - execution: this.formatExecution(exec), - }; - } - } - } - } - return { - triggered: true, - method: 'execute', - workflowId: id, - workflowName: workflow.name, - executionId: result.data?.executionId ?? null, - }; - }, - }, - // ── n8n_webhook ───────────────────────────────────────────────────────── - { - name: 'n8n_webhook', - description: 'Fire an n8n webhook directly by its path or full URL. ' + - 'Use this when you know the webhook path but not the workflow ID.', - inputSchema: { - type: 'object', - properties: { - path: { - type: 'string', - description: 'Webhook path (e.g. "my-hook") or full URL', - }, - payload: { type: 'object', description: 'JSON body to send' }, - method: { - type: 'string', - enum: ['GET', 'POST', 'PUT', 'PATCH'], - description: 'HTTP method (default: POST)', - }, - headers: { - type: 'object', - description: 'Additional headers', - }, - }, - required: ['path'], - }, - handler: async ({ path, payload, method = 'POST', headers }) => { - let url; - if (path.startsWith('http')) { - url = path; - } - else { - const { baseUrl } = await this.getConfig(); - const n8nBase = baseUrl.replace('/api/v1', ''); - url = `${n8nBase}/webhook/${path.replace(/^\//, '')}`; - } - const result = await this.webhookFetch(url, method, payload, headers); - return { fired: true, url, method, response: result }; - }, - }, - // ── n8n_executions ────────────────────────────────────────────────────── - { - name: 'n8n_executions', - description: 'List recent workflow executions with status and duration', - inputSchema: { - type: 'object', - properties: { - workflowId: { type: 'string', description: 'Filter by workflow ID' }, - status: { - type: 'string', - enum: ['error', 'success', 'waiting', 'running'], - description: 'Filter by execution status', - }, - limit: { type: 'number', description: 'Max executions (default: 20)' }, - }, - }, - handler: async ({ workflowId, status, limit = 20 }) => { - const params = { limit: String(Math.min(limit, 100)) }; - if (workflowId) - params.workflowId = workflowId; - if (status) - params.status = status; - const data = await this.n8nFetch('/executions', { params }); - const executions = (data.data ?? data); - const formatted = executions.map(this.formatExecution.bind(this)); - const errorCount = formatted.filter((e) => e.status === 'error').length; - return { - count: formatted.length, - errors: errorCount, - executions: formatted, - }; - }, - }, - // ── n8n_execution ─────────────────────────────────────────────────────── - { - name: 'n8n_execution', - description: 'Get detailed results of a specific workflow execution including node outputs', - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'Execution ID' }, - }, - required: ['id'], - }, - handler: async ({ id }) => { - const e = await this.n8nFetch(`/executions/${id}`); - const runData = e.data?.resultData?.runData ?? {}; - const nodeResults = Object.entries(runData).map(([nodeName, runs]) => { - const lastRun = Array.isArray(runs) ? runs[runs.length - 1] : runs; - const outputData = lastRun?.data?.main?.[0] ?? []; - return { - node: nodeName, - itemCount: outputData.length, - error: lastRun?.error?.message ?? null, - // Show first item as sample, capped - sample: outputData[0]?.json - ? JSON.stringify(outputData[0].json).slice(0, 500) - : null, - }; - }); - return { - ...this.formatExecution(e), - nodeResults, - triggerData: e.data?.triggerData ?? null, - }; - }, - }, - // ── n8n_retry ─────────────────────────────────────────────────────────── - { - name: 'n8n_retry', - description: 'Retry a failed workflow execution', - inputSchema: { - type: 'object', - properties: { - executionId: { type: 'string', description: 'Execution ID to retry' }, - loadWorkflow: { - type: 'boolean', - description: 'Load latest workflow version before retrying (default: false)', - }, - }, - required: ['executionId'], - }, - handler: async ({ executionId, loadWorkflow = false }) => { - const result = await this.n8nFetch(`/executions/${executionId}/retry`, { - method: 'POST', - body: { loadWorkflow }, - }); - return { - retried: true, - originalId: executionId, - newExecutionId: result.data ?? result, - }; - }, - }, - // ── n8n_delete_execution ──────────────────────────────────────────────── - { - name: 'n8n_delete_execution', - description: 'Delete a workflow execution from n8n history', - inputSchema: { - type: 'object', - properties: { - executionId: { type: 'string', description: 'Execution ID to delete' }, - }, - required: ['executionId'], - }, - handler: async ({ executionId }) => { - await this.n8nFetch(`/executions/${executionId}`, { method: 'DELETE' }); - return { deleted: true, executionId }; - }, - }, - // ── n8n_credentials ───────────────────────────────────────────────────── - { - name: 'n8n_credentials', - description: 'List credential types configured in n8n (names only — no secrets exposed)', - inputSchema: { - type: 'object', - properties: { - type: { type: 'string', description: 'Filter by credential type name' }, - }, - }, - handler: async ({ type }) => { - const params = {}; - if (type) - params.filter = JSON.stringify({ type }); - const data = await this.n8nFetch('/credentials', { params }); - const creds = (data.data ?? data); - return { - count: creds.length, - credentials: creds.map((c) => ({ - id: c.id, - name: c.name, - type: c.type, - createdAt: c.createdAt ?? null, - updatedAt: c.updatedAt ?? null, - })), - }; - }, - }, - // ── n8n_tags ──────────────────────────────────────────────────────────── - { - name: 'n8n_tags', - description: 'List all workflow tags in n8n', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const data = await this.n8nFetch('/tags'); - const tags = (data.data ?? data); - return { - count: tags.length, - tags: tags.map((t) => ({ id: t.id, name: t.name })), - }; - }, - }, - // ── n8n_health ────────────────────────────────────────────────────────── - { - name: 'n8n_health', - description: 'Check n8n instance health, version, and queue metrics', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const [health, version] = await Promise.allSettled([ - this.n8nFetch('/health'), - this.n8nFetch('/version'), - ]); - const { baseUrl } = await this.getConfig(); - return { - instanceUrl: baseUrl.replace('/api/v1', ''), - healthy: health.status === 'fulfilled', - version: version.status === 'fulfilled' - ? version.value.version ?? 'unknown' - : 'unknown', - status: health.status === 'fulfilled' - ? (health.value.status ?? 'ok') - : 'unreachable', - }; - }, - }, - ]; - } -} diff --git a/plugins/n8n/dist/n8n.js b/plugins/n8n/dist/n8n.js deleted file mode 100644 index e596703..0000000 --- a/plugins/n8n/dist/n8n.js +++ /dev/null @@ -1,546 +0,0 @@ -/** - * n8n Plugin — TheAlxLabs / Conductor - * - * Full n8n automation platform integration: - * - Workflows: list, activate/deactivate, get structure, trigger manually - * - Executions: list, inspect, retry, delete - * - Webhooks: trigger webhook-based workflows with custom payloads - * - Credentials: list (no secrets exposed) - * - Tags: organize and filter workflows by tag - * - Health: instance status and queue metrics - * - * Works with both self-hosted n8n and n8n Cloud. - * - * Setup: - * 1. n8n → Settings → API → Create API Key - * 2. Run: conductor plugins config n8n api_key - * 3. Run: conductor plugins config n8n base_url - * e.g. https://n8n.yourdomain.com or https://app.n8n.cloud/api - * - * Keychain: n8n / api_key, n8n / base_url - * - * Note: Your n8n instance at n8n-alxstuff.zeabur.app is already supported. - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service, _account, _value) { } - async delete(_service, _account) { } -} -export class N8nPlugin { - name = 'n8n'; - description = 'Trigger and manage n8n workflows, inspect executions, fire webhooks — requires n8n API key'; - version = '1.0.0'; - configSchema = { - fields: [ - { - key: 'api_key', - label: 'n8n API Key', - type: 'password', - required: true, - secret: true, - service: 'n8n' - }, - { - key: 'base_url', - label: 'n8n Instance URL', - type: 'string', - required: true, - secret: false, - description: 'e.g. https://n8n.yourdomain.com' - } - ], - setupInstructions: 'Create an API Key in your n8n instance: Settings > API > Create Key.' - }; - keychain; - async initialize(conductor) { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - isConfigured() { - return true; - } - async getConfig() { - const apiKey = await this.keychain.get('n8n', 'api_key'); - if (!apiKey) { - throw new Error('n8n API key not configured.\n' + - 'Get one from your n8n instance: Settings → API → Create Key\n' + - 'Then run: conductor plugins config n8n api_key '); - } - const rawUrl = await this.keychain.get('n8n', 'base_url'); - // Normalise: strip trailing slash, ensure /api/v1 suffix - let baseUrl = (rawUrl ?? 'http://localhost:5678').replace(/\/$/, ''); - if (!baseUrl.endsWith('/api/v1')) - baseUrl = `${baseUrl}/api/v1`; - return { apiKey, baseUrl }; - } - async n8nFetch(path, options = {}) { - const { apiKey, baseUrl } = await this.getConfig(); - const url = new URL(`${baseUrl}${path}`); - if (options.params) { - for (const [k, v] of Object.entries(options.params)) - url.searchParams.set(k, v); - } - const res = await fetch(url.toString(), { - method: options.method ?? 'GET', - headers: { - 'X-N8N-API-KEY': apiKey, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: options.body ? JSON.stringify(options.body) : undefined, - }); - if (res.status === 204) - return {}; - if (!res.ok) { - const err = (await res.json().catch(() => ({ message: res.statusText }))); - throw new Error(`n8n API ${res.status}: ${err.message ?? res.statusText}`); - } - return res.json(); - } - /** Fire a webhook URL directly — used for webhook-triggered workflows */ - async webhookFetch(webhookUrl, method, body, headers) { - const res = await fetch(webhookUrl, { - method, - headers: { 'Content-Type': 'application/json', ...(headers ?? {}) }, - body: body ? JSON.stringify(body) : undefined, - }); - if (!res.ok) { - throw new Error(`Webhook ${res.status}: ${res.statusText}`); - } - const ct = res.headers.get('content-type') ?? ''; - if (ct.includes('application/json')) - return res.json(); - return { response: await res.text() }; - } - // ── Formatters ────────────────────────────────────────────────────────────── - formatWorkflow(w) { - return { - id: w.id, - name: w.name, - active: w.active ?? false, - nodeCount: (w.nodes ?? []).length, - triggerType: this.detectTriggerType(w.nodes ?? []), - webhookPath: this.extractWebhookPath(w.nodes ?? []), - tags: (w.tags ?? []).map((t) => (typeof t === 'string' ? t : t.name)), - createdAt: w.createdAt ?? null, - updatedAt: w.updatedAt ?? null, - }; - } - formatExecution(e) { - const duration = e.startedAt && e.stoppedAt - ? `${Math.round((new Date(e.stoppedAt).getTime() - new Date(e.startedAt).getTime()) / 1000)}s` - : null; - return { - id: e.id, - workflowId: e.workflowId, - workflowName: e.workflowData?.name ?? null, - status: e.status ?? e.finished ? 'success' : 'running', - mode: e.mode ?? 'unknown', - startedAt: e.startedAt ?? null, - stoppedAt: e.stoppedAt ?? null, - duration, - nodeCount: Object.keys(e.data?.resultData?.runData ?? {}).length, - error: e.data?.resultData?.error?.message ?? null, - }; - } - detectTriggerType(nodes) { - const triggerNode = nodes.find((n) => n.type?.includes('Trigger') || n.type?.includes('Webhook') || n.type?.includes('Cron')); - if (!triggerNode) - return 'manual'; - if (triggerNode.type?.includes('Webhook')) - return 'webhook'; - if (triggerNode.type?.includes('Cron') || triggerNode.type?.includes('Schedule')) - return 'schedule'; - if (triggerNode.type?.includes('EmailImap')) - return 'email'; - return triggerNode.type?.split('.').pop()?.replace('Trigger', '') ?? 'trigger'; - } - extractWebhookPath(nodes) { - const webhookNode = nodes.find((n) => n.type === 'n8n-nodes-base.webhook' || n.type?.includes('Webhook')); - return webhookNode?.parameters?.path ?? webhookNode?.parameters?.webhookId ?? null; - } - // ── Tools ─────────────────────────────────────────────────────────────────── - getTools() { - return [ - // ── n8n_workflows ─────────────────────────────────────────────────────── - { - name: 'n8n_workflows', - description: 'List all n8n workflows with their active status and trigger type', - inputSchema: { - type: 'object', - properties: { - active: { - type: 'boolean', - description: 'Filter to only active or only inactive workflows', - }, - tag: { type: 'string', description: 'Filter by tag name' }, - limit: { type: 'number', description: 'Max workflows (default: 50)' }, - search: { type: 'string', description: 'Search by workflow name' }, - }, - }, - handler: async ({ active, tag, limit = 50, search }) => { - const params = { - limit: String(Math.min(limit, 250)), - }; - if (active !== undefined) - params.active = String(active); - if (tag) - params.tags = tag; - const data = await this.n8nFetch('/workflows', { params }); - let workflows = (data.data ?? data); - if (search) { - const q = search.toLowerCase(); - workflows = workflows.filter((w) => w.name?.toLowerCase().includes(q)); - } - const formatted = workflows.map(this.formatWorkflow.bind(this)); - const activeCount = formatted.filter((w) => w.active).length; - return { - total: formatted.length, - active: activeCount, - inactive: formatted.length - activeCount, - workflows: formatted, - }; - }, - }, - // ── n8n_workflow ──────────────────────────────────────────────────────── - { - name: 'n8n_workflow', - description: 'Get full details of a specific n8n workflow including all nodes and connections', - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'Workflow ID' }, - }, - required: ['id'], - }, - handler: async ({ id }) => { - const w = await this.n8nFetch(`/workflows/${id}`); - return { - ...this.formatWorkflow(w), - nodes: (w.nodes ?? []).map((n) => ({ - name: n.name, - type: n.type?.split('.').pop() ?? n.type, - position: n.position, - disabled: n.disabled ?? false, - hasCredentials: !!n.credentials, - })), - connections: Object.keys(w.connections ?? {}).length, - settings: w.settings ?? {}, - }; - }, - }, - // ── n8n_activate ──────────────────────────────────────────────────────── - { - name: 'n8n_activate', - description: 'Activate or deactivate an n8n workflow', - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'Workflow ID' }, - active: { type: 'boolean', description: 'true = activate, false = deactivate' }, - }, - required: ['id', 'active'], - }, - handler: async ({ id, active }) => { - await this.n8nFetch(`/workflows/${id}`, { - method: 'PATCH', - body: { active }, - }); - return { id, active, message: `Workflow ${active ? 'activated' : 'deactivated'}` }; - }, - }, - // ── n8n_trigger ───────────────────────────────────────────────────────── - { - name: 'n8n_trigger', - description: 'Manually trigger an n8n workflow execution. ' + - 'For webhook workflows, fires the webhook URL. ' + - 'For others, uses the n8n execute API.', - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'Workflow ID' }, - payload: { - type: 'object', - description: 'Data to pass to the workflow (for webhook triggers)', - }, - waitForResult: { - type: 'boolean', - description: 'Wait for execution to complete and return result (default: false)', - }, - }, - required: ['id'], - }, - handler: async ({ id, payload = {}, waitForResult = false }) => { - // Get workflow to check trigger type - const workflow = await this.n8nFetch(`/workflows/${id}`); - const triggerType = this.detectTriggerType(workflow.nodes ?? []); - const webhookPath = this.extractWebhookPath(workflow.nodes ?? []); - if (triggerType === 'webhook' && webhookPath) { - const { baseUrl } = await this.getConfig(); - const n8nBase = baseUrl.replace('/api/v1', ''); - const webhookUrl = `${n8nBase}/webhook/${webhookPath}`; - const result = await this.webhookFetch(webhookUrl, 'POST', payload); - return { - triggered: true, - method: 'webhook', - webhookUrl, - workflowId: id, - workflowName: workflow.name, - response: result, - }; - } - // Use execute API for non-webhook workflows - const result = await this.n8nFetch(`/workflows/${id}/run`, { - method: 'POST', - body: { runData: payload }, - }); - if (waitForResult) { - // Poll for completion (max 30s) - const execId = result.data?.executionId; - if (execId) { - for (let i = 0; i < 15; i++) { - await new Promise((r) => setTimeout(r, 2000)); - const exec = await this.n8nFetch(`/executions/${execId}`).catch(() => null); - if (exec?.finished || exec?.status === 'error') { - return { - triggered: true, - method: 'execute', - workflowId: id, - execution: this.formatExecution(exec), - }; - } - } - } - } - return { - triggered: true, - method: 'execute', - workflowId: id, - workflowName: workflow.name, - executionId: result.data?.executionId ?? null, - }; - }, - }, - // ── n8n_webhook ───────────────────────────────────────────────────────── - { - name: 'n8n_webhook', - description: 'Fire an n8n webhook directly by its path or full URL. ' + - 'Use this when you know the webhook path but not the workflow ID.', - inputSchema: { - type: 'object', - properties: { - path: { - type: 'string', - description: 'Webhook path (e.g. "my-hook") or full URL', - }, - payload: { type: 'object', description: 'JSON body to send' }, - method: { - type: 'string', - enum: ['GET', 'POST', 'PUT', 'PATCH'], - description: 'HTTP method (default: POST)', - }, - headers: { - type: 'object', - description: 'Additional headers', - }, - }, - required: ['path'], - }, - handler: async ({ path, payload, method = 'POST', headers }) => { - let url; - if (path.startsWith('http')) { - url = path; - } - else { - const { baseUrl } = await this.getConfig(); - const n8nBase = baseUrl.replace('/api/v1', ''); - url = `${n8nBase}/webhook/${path.replace(/^\//, '')}`; - } - const result = await this.webhookFetch(url, method, payload, headers); - return { fired: true, url, method, response: result }; - }, - }, - // ── n8n_executions ────────────────────────────────────────────────────── - { - name: 'n8n_executions', - description: 'List recent workflow executions with status and duration', - inputSchema: { - type: 'object', - properties: { - workflowId: { type: 'string', description: 'Filter by workflow ID' }, - status: { - type: 'string', - enum: ['error', 'success', 'waiting', 'running'], - description: 'Filter by execution status', - }, - limit: { type: 'number', description: 'Max executions (default: 20)' }, - }, - }, - handler: async ({ workflowId, status, limit = 20 }) => { - const params = { limit: String(Math.min(limit, 100)) }; - if (workflowId) - params.workflowId = workflowId; - if (status) - params.status = status; - const data = await this.n8nFetch('/executions', { params }); - const executions = (data.data ?? data); - const formatted = executions.map(this.formatExecution.bind(this)); - const errorCount = formatted.filter((e) => e.status === 'error').length; - return { - count: formatted.length, - errors: errorCount, - executions: formatted, - }; - }, - }, - // ── n8n_execution ─────────────────────────────────────────────────────── - { - name: 'n8n_execution', - description: 'Get detailed results of a specific workflow execution including node outputs', - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'Execution ID' }, - }, - required: ['id'], - }, - handler: async ({ id }) => { - const e = await this.n8nFetch(`/executions/${id}`); - const runData = e.data?.resultData?.runData ?? {}; - const nodeResults = Object.entries(runData).map(([nodeName, runs]) => { - const lastRun = Array.isArray(runs) ? runs[runs.length - 1] : runs; - const outputData = lastRun?.data?.main?.[0] ?? []; - return { - node: nodeName, - itemCount: outputData.length, - error: lastRun?.error?.message ?? null, - // Show first item as sample, capped - sample: outputData[0]?.json - ? JSON.stringify(outputData[0].json).slice(0, 500) - : null, - }; - }); - return { - ...this.formatExecution(e), - nodeResults, - triggerData: e.data?.triggerData ?? null, - }; - }, - }, - // ── n8n_retry ─────────────────────────────────────────────────────────── - { - name: 'n8n_retry', - description: 'Retry a failed workflow execution', - inputSchema: { - type: 'object', - properties: { - executionId: { type: 'string', description: 'Execution ID to retry' }, - loadWorkflow: { - type: 'boolean', - description: 'Load latest workflow version before retrying (default: false)', - }, - }, - required: ['executionId'], - }, - handler: async ({ executionId, loadWorkflow = false }) => { - const result = await this.n8nFetch(`/executions/${executionId}/retry`, { - method: 'POST', - body: { loadWorkflow }, - }); - return { - retried: true, - originalId: executionId, - newExecutionId: result.data ?? result, - }; - }, - }, - // ── n8n_delete_execution ──────────────────────────────────────────────── - { - name: 'n8n_delete_execution', - description: 'Delete a workflow execution from n8n history', - inputSchema: { - type: 'object', - properties: { - executionId: { type: 'string', description: 'Execution ID to delete' }, - }, - required: ['executionId'], - }, - handler: async ({ executionId }) => { - await this.n8nFetch(`/executions/${executionId}`, { method: 'DELETE' }); - return { deleted: true, executionId }; - }, - }, - // ── n8n_credentials ───────────────────────────────────────────────────── - { - name: 'n8n_credentials', - description: 'List credential types configured in n8n (names only — no secrets exposed)', - inputSchema: { - type: 'object', - properties: { - type: { type: 'string', description: 'Filter by credential type name' }, - }, - }, - handler: async ({ type }) => { - const params = {}; - if (type) - params.filter = JSON.stringify({ type }); - const data = await this.n8nFetch('/credentials', { params }); - const creds = (data.data ?? data); - return { - count: creds.length, - credentials: creds.map((c) => ({ - id: c.id, - name: c.name, - type: c.type, - createdAt: c.createdAt ?? null, - updatedAt: c.updatedAt ?? null, - })), - }; - }, - }, - // ── n8n_tags ──────────────────────────────────────────────────────────── - { - name: 'n8n_tags', - description: 'List all workflow tags in n8n', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const data = await this.n8nFetch('/tags'); - const tags = (data.data ?? data); - return { - count: tags.length, - tags: tags.map((t) => ({ id: t.id, name: t.name })), - }; - }, - }, - // ── n8n_health ────────────────────────────────────────────────────────── - { - name: 'n8n_health', - description: 'Check n8n instance health, version, and queue metrics', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const [health, version] = await Promise.allSettled([ - this.n8nFetch('/health'), - this.n8nFetch('/version'), - ]); - const { baseUrl } = await this.getConfig(); - return { - instanceUrl: baseUrl.replace('/api/v1', ''), - healthy: health.status === 'fulfilled', - version: version.status === 'fulfilled' - ? version.value.version ?? 'unknown' - : 'unknown', - status: health.status === 'fulfilled' - ? (health.value.status ?? 'ok') - : 'unreachable', - }; - }, - }, - ]; - } -} diff --git a/plugins/n8n/package.json b/plugins/n8n/package.json deleted file mode 100644 index 08e16e8..0000000 --- a/plugins/n8n/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@conductor-plugins/n8n", - "version": "1.0.0", - "type": "module", - "main": "dist/n8n.js", - "scripts": { - "build": "tsc", - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "typescript": "^5.0.0", - "@types/node": "^20.0.0" - } -} diff --git a/plugins/n8n/src/index.ts b/plugins/n8n/src/index.ts deleted file mode 100644 index e8626c9..0000000 --- a/plugins/n8n/src/index.ts +++ /dev/null @@ -1,616 +0,0 @@ -/** - * n8n Plugin — TheAlxLabs / Conductor - * - * Full n8n automation platform integration: - * - Workflows: list, activate/deactivate, get structure, trigger manually - * - Executions: list, inspect, retry, delete - * - Webhooks: trigger webhook-based workflows with custom payloads - * - Credentials: list (no secrets exposed) - * - Tags: organize and filter workflows by tag - * - Health: instance status and queue metrics - * - * Works with both self-hosted n8n and n8n Cloud. - * - * Setup: - * 1. n8n → Settings → API → Create API Key - * 2. Run: conductor plugins config n8n api_key - * 3. Run: conductor plugins config n8n base_url - * e.g. https://n8n.yourdomain.com or https://app.n8n.cloud/api - * - * Keychain: n8n / api_key, n8n / base_url - * - * Note: Your n8n instance at n8n-alxstuff.zeabur.app is already supported. - */ - -// ── Inlined types (no external dependencies) ───────────────────────────────── - -interface PluginConfigField { - key: string; label: string; type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; secret?: boolean; service?: string; description?: string; options?: string[]; -} -interface PluginConfigSchema { fields: PluginConfigField[]; setupInstructions?: string; } -interface PluginTool { - name: string; description: string; inputSchema: Record; - handler: (input: Record) => Promise; requiresApproval?: boolean; -} -interface Plugin { - name: string; description: string; version: string; configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; isConfigured(): boolean; - getTools(): PluginTool[]; getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { getConfig(): ConfigManagerLike; } -class Keychain { - constructor(private dir: string) {} - async get(service: string, account: string): Promise { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service: string, _account: string, _value: string): Promise {} - async delete(_service: string, _account: string): Promise {} -} - -export class N8nPlugin implements Plugin { - name = 'n8n'; - description = - 'Trigger and manage n8n workflows, inspect executions, fire webhooks — requires n8n API key'; - version = '1.0.0'; - - configSchema = { - fields: [ - { - key: 'api_key', - label: 'n8n API Key', - type: 'password' as const, - required: true, - secret: true, - service: 'n8n' - }, - { - key: 'base_url', - label: 'n8n Instance URL', - type: 'string' as const, - required: true, - secret: false, - description: 'e.g. https://n8n.yourdomain.com' - } - ], - setupInstructions: 'Create an API Key in your n8n instance: Settings > API > Create Key.' - }; - - private keychain!: Keychain; - - async initialize(conductor: Conductor): Promise { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - - isConfigured(): boolean { - return true; - } - - private async getConfig(): Promise<{ apiKey: string; baseUrl: string }> { - const apiKey = await this.keychain.get('n8n', 'api_key'); - if (!apiKey) { - throw new Error( - 'n8n API key not configured.\n' + - 'Get one from your n8n instance: Settings → API → Create Key\n' + - 'Then run: conductor plugins config n8n api_key ' - ); - } - const rawUrl = await this.keychain.get('n8n', 'base_url'); - // Normalise: strip trailing slash, ensure /api/v1 suffix - let baseUrl = (rawUrl ?? 'http://localhost:5678').replace(/\/$/, ''); - if (!baseUrl.endsWith('/api/v1')) baseUrl = `${baseUrl}/api/v1`; - return { apiKey, baseUrl }; - } - - private async n8nFetch( - path: string, - options: { method?: string; body?: any; params?: Record } = {} - ): Promise { - const { apiKey, baseUrl } = await this.getConfig(); - const url = new URL(`${baseUrl}${path}`); - if (options.params) { - for (const [k, v] of Object.entries(options.params)) url.searchParams.set(k, v); - } - - const res = await fetch(url.toString(), { - method: options.method ?? 'GET', - headers: { - 'X-N8N-API-KEY': apiKey, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: options.body ? JSON.stringify(options.body) : undefined, - }); - - if (res.status === 204) return {}; - if (!res.ok) { - const err = (await res.json().catch(() => ({ message: res.statusText }))) as any; - throw new Error(`n8n API ${res.status}: ${err.message ?? res.statusText}`); - } - return res.json(); - } - - /** Fire a webhook URL directly — used for webhook-triggered workflows */ - private async webhookFetch( - webhookUrl: string, - method: string, - body?: any, - headers?: Record - ): Promise { - const res = await fetch(webhookUrl, { - method, - headers: { 'Content-Type': 'application/json', ...(headers ?? {}) }, - body: body ? JSON.stringify(body) : undefined, - }); - if (!res.ok) { - throw new Error(`Webhook ${res.status}: ${res.statusText}`); - } - const ct = res.headers.get('content-type') ?? ''; - if (ct.includes('application/json')) return res.json(); - return { response: await res.text() }; - } - - // ── Formatters ────────────────────────────────────────────────────────────── - - private formatWorkflow(w: any) { - return { - id: w.id, - name: w.name, - active: w.active ?? false, - nodeCount: (w.nodes ?? []).length, - triggerType: this.detectTriggerType(w.nodes ?? []), - webhookPath: this.extractWebhookPath(w.nodes ?? []), - tags: (w.tags ?? []).map((t: any) => (typeof t === 'string' ? t : t.name)), - createdAt: w.createdAt ?? null, - updatedAt: w.updatedAt ?? null, - }; - } - - private formatExecution(e: any) { - const duration = - e.startedAt && e.stoppedAt - ? `${Math.round((new Date(e.stoppedAt).getTime() - new Date(e.startedAt).getTime()) / 1000)}s` - : null; - return { - id: e.id, - workflowId: e.workflowId, - workflowName: e.workflowData?.name ?? null, - status: e.status ?? e.finished ? 'success' : 'running', - mode: e.mode ?? 'unknown', - startedAt: e.startedAt ?? null, - stoppedAt: e.stoppedAt ?? null, - duration, - nodeCount: Object.keys(e.data?.resultData?.runData ?? {}).length, - error: e.data?.resultData?.error?.message ?? null, - }; - } - - private detectTriggerType(nodes: any[]): string { - const triggerNode = nodes.find((n: any) => - n.type?.includes('Trigger') || n.type?.includes('Webhook') || n.type?.includes('Cron') - ); - if (!triggerNode) return 'manual'; - if (triggerNode.type?.includes('Webhook')) return 'webhook'; - if (triggerNode.type?.includes('Cron') || triggerNode.type?.includes('Schedule')) return 'schedule'; - if (triggerNode.type?.includes('EmailImap')) return 'email'; - return triggerNode.type?.split('.').pop()?.replace('Trigger', '') ?? 'trigger'; - } - - private extractWebhookPath(nodes: any[]): string | null { - const webhookNode = nodes.find( - (n: any) => n.type === 'n8n-nodes-base.webhook' || n.type?.includes('Webhook') - ); - return webhookNode?.parameters?.path ?? webhookNode?.parameters?.webhookId ?? null; - } - - // ── Tools ─────────────────────────────────────────────────────────────────── - - getTools(): PluginTool[] { - return [ - // ── n8n_workflows ─────────────────────────────────────────────────────── - { - name: 'n8n_workflows', - description: 'List all n8n workflows with their active status and trigger type', - inputSchema: { - type: 'object', - properties: { - active: { - type: 'boolean', - description: 'Filter to only active or only inactive workflows', - }, - tag: { type: 'string', description: 'Filter by tag name' }, - limit: { type: 'number', description: 'Max workflows (default: 50)' }, - search: { type: 'string', description: 'Search by workflow name' }, - }, - }, - handler: async ({ active, tag, limit = 50, search }: any) => { - const params: Record = { - limit: String(Math.min(limit, 250)), - }; - if (active !== undefined) params.active = String(active); - if (tag) params.tags = tag; - - const data = await this.n8nFetch('/workflows', { params }); - let workflows = (data.data ?? data) as any[]; - - if (search) { - const q = search.toLowerCase(); - workflows = workflows.filter((w: any) => w.name?.toLowerCase().includes(q)); - } - - const formatted = workflows.map(this.formatWorkflow.bind(this)); - const activeCount = formatted.filter((w) => w.active).length; - - return { - total: formatted.length, - active: activeCount, - inactive: formatted.length - activeCount, - workflows: formatted, - }; - }, - }, - - // ── n8n_workflow ──────────────────────────────────────────────────────── - { - name: 'n8n_workflow', - description: 'Get full details of a specific n8n workflow including all nodes and connections', - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'Workflow ID' }, - }, - required: ['id'], - }, - handler: async ({ id }: any) => { - const w = await this.n8nFetch(`/workflows/${id}`); - return { - ...this.formatWorkflow(w), - nodes: (w.nodes ?? []).map((n: any) => ({ - name: n.name, - type: n.type?.split('.').pop() ?? n.type, - position: n.position, - disabled: n.disabled ?? false, - hasCredentials: !!n.credentials, - })), - connections: Object.keys(w.connections ?? {}).length, - settings: w.settings ?? {}, - }; - }, - }, - - // ── n8n_activate ──────────────────────────────────────────────────────── - { - name: 'n8n_activate', - description: 'Activate or deactivate an n8n workflow', - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'Workflow ID' }, - active: { type: 'boolean', description: 'true = activate, false = deactivate' }, - }, - required: ['id', 'active'], - }, - handler: async ({ id, active }: any) => { - await this.n8nFetch(`/workflows/${id}`, { - method: 'PATCH', - body: { active }, - }); - return { id, active, message: `Workflow ${active ? 'activated' : 'deactivated'}` }; - }, - }, - - // ── n8n_trigger ───────────────────────────────────────────────────────── - { - name: 'n8n_trigger', - description: - 'Manually trigger an n8n workflow execution. ' + - 'For webhook workflows, fires the webhook URL. ' + - 'For others, uses the n8n execute API.', - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'Workflow ID' }, - payload: { - type: 'object', - description: 'Data to pass to the workflow (for webhook triggers)', - }, - waitForResult: { - type: 'boolean', - description: 'Wait for execution to complete and return result (default: false)', - }, - }, - required: ['id'], - }, - handler: async ({ id, payload = {}, waitForResult = false }: any) => { - // Get workflow to check trigger type - const workflow = await this.n8nFetch(`/workflows/${id}`); - const triggerType = this.detectTriggerType(workflow.nodes ?? []); - const webhookPath = this.extractWebhookPath(workflow.nodes ?? []); - - if (triggerType === 'webhook' && webhookPath) { - const { baseUrl } = await this.getConfig(); - const n8nBase = baseUrl.replace('/api/v1', ''); - const webhookUrl = `${n8nBase}/webhook/${webhookPath}`; - - const result = await this.webhookFetch(webhookUrl, 'POST', payload); - return { - triggered: true, - method: 'webhook', - webhookUrl, - workflowId: id, - workflowName: workflow.name, - response: result, - }; - } - - // Use execute API for non-webhook workflows - const result = await this.n8nFetch(`/workflows/${id}/run`, { - method: 'POST', - body: { runData: payload }, - }); - - if (waitForResult) { - // Poll for completion (max 30s) - const execId = result.data?.executionId; - if (execId) { - for (let i = 0; i < 15; i++) { - await new Promise((r) => setTimeout(r, 2000)); - const exec = await this.n8nFetch(`/executions/${execId}`).catch(() => null); - if (exec?.finished || exec?.status === 'error') { - return { - triggered: true, - method: 'execute', - workflowId: id, - execution: this.formatExecution(exec), - }; - } - } - } - } - - return { - triggered: true, - method: 'execute', - workflowId: id, - workflowName: workflow.name, - executionId: result.data?.executionId ?? null, - }; - }, - }, - - // ── n8n_webhook ───────────────────────────────────────────────────────── - { - name: 'n8n_webhook', - description: - 'Fire an n8n webhook directly by its path or full URL. ' + - 'Use this when you know the webhook path but not the workflow ID.', - inputSchema: { - type: 'object', - properties: { - path: { - type: 'string', - description: 'Webhook path (e.g. "my-hook") or full URL', - }, - payload: { type: 'object', description: 'JSON body to send' }, - method: { - type: 'string', - enum: ['GET', 'POST', 'PUT', 'PATCH'], - description: 'HTTP method (default: POST)', - }, - headers: { - type: 'object', - description: 'Additional headers', - }, - }, - required: ['path'], - }, - handler: async ({ path, payload, method = 'POST', headers }: any) => { - let url: string; - if (path.startsWith('http')) { - url = path; - } else { - const { baseUrl } = await this.getConfig(); - const n8nBase = baseUrl.replace('/api/v1', ''); - url = `${n8nBase}/webhook/${path.replace(/^\//, '')}`; - } - - const result = await this.webhookFetch(url, method, payload, headers); - return { fired: true, url, method, response: result }; - }, - }, - - // ── n8n_executions ────────────────────────────────────────────────────── - { - name: 'n8n_executions', - description: 'List recent workflow executions with status and duration', - inputSchema: { - type: 'object', - properties: { - workflowId: { type: 'string', description: 'Filter by workflow ID' }, - status: { - type: 'string', - enum: ['error', 'success', 'waiting', 'running'], - description: 'Filter by execution status', - }, - limit: { type: 'number', description: 'Max executions (default: 20)' }, - }, - }, - handler: async ({ workflowId, status, limit = 20 }: any) => { - const params: Record = { limit: String(Math.min(limit, 100)) }; - if (workflowId) params.workflowId = workflowId; - if (status) params.status = status; - - const data = await this.n8nFetch('/executions', { params }); - const executions = (data.data ?? data) as any[]; - - const formatted = executions.map(this.formatExecution.bind(this)); - const errorCount = formatted.filter((e) => e.status === 'error').length; - - return { - count: formatted.length, - errors: errorCount, - executions: formatted, - }; - }, - }, - - // ── n8n_execution ─────────────────────────────────────────────────────── - { - name: 'n8n_execution', - description: 'Get detailed results of a specific workflow execution including node outputs', - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'Execution ID' }, - }, - required: ['id'], - }, - handler: async ({ id }: any) => { - const e = await this.n8nFetch(`/executions/${id}`); - const runData = e.data?.resultData?.runData ?? {}; - - const nodeResults = Object.entries(runData).map(([nodeName, runs]: [string, any]) => { - const lastRun = Array.isArray(runs) ? runs[runs.length - 1] : runs; - const outputData = lastRun?.data?.main?.[0] ?? []; - return { - node: nodeName, - itemCount: outputData.length, - error: lastRun?.error?.message ?? null, - // Show first item as sample, capped - sample: outputData[0]?.json - ? JSON.stringify(outputData[0].json).slice(0, 500) - : null, - }; - }); - - return { - ...this.formatExecution(e), - nodeResults, - triggerData: e.data?.triggerData ?? null, - }; - }, - }, - - // ── n8n_retry ─────────────────────────────────────────────────────────── - { - name: 'n8n_retry', - description: 'Retry a failed workflow execution', - inputSchema: { - type: 'object', - properties: { - executionId: { type: 'string', description: 'Execution ID to retry' }, - loadWorkflow: { - type: 'boolean', - description: 'Load latest workflow version before retrying (default: false)', - }, - }, - required: ['executionId'], - }, - handler: async ({ executionId, loadWorkflow = false }: any) => { - const result = await this.n8nFetch(`/executions/${executionId}/retry`, { - method: 'POST', - body: { loadWorkflow }, - }); - return { - retried: true, - originalId: executionId, - newExecutionId: result.data ?? result, - }; - }, - }, - - // ── n8n_delete_execution ──────────────────────────────────────────────── - { - name: 'n8n_delete_execution', - description: 'Delete a workflow execution from n8n history', - inputSchema: { - type: 'object', - properties: { - executionId: { type: 'string', description: 'Execution ID to delete' }, - }, - required: ['executionId'], - }, - handler: async ({ executionId }: any) => { - await this.n8nFetch(`/executions/${executionId}`, { method: 'DELETE' }); - return { deleted: true, executionId }; - }, - }, - - // ── n8n_credentials ───────────────────────────────────────────────────── - { - name: 'n8n_credentials', - description: 'List credential types configured in n8n (names only — no secrets exposed)', - inputSchema: { - type: 'object', - properties: { - type: { type: 'string', description: 'Filter by credential type name' }, - }, - }, - handler: async ({ type }: any) => { - const params: Record = {}; - if (type) params.filter = JSON.stringify({ type }); - const data = await this.n8nFetch('/credentials', { params }); - const creds = (data.data ?? data) as any[]; - return { - count: creds.length, - credentials: creds.map((c: any) => ({ - id: c.id, - name: c.name, - type: c.type, - createdAt: c.createdAt ?? null, - updatedAt: c.updatedAt ?? null, - })), - }; - }, - }, - - // ── n8n_tags ──────────────────────────────────────────────────────────── - { - name: 'n8n_tags', - description: 'List all workflow tags in n8n', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const data = await this.n8nFetch('/tags'); - const tags = (data.data ?? data) as any[]; - return { - count: tags.length, - tags: tags.map((t: any) => ({ id: t.id, name: t.name })), - }; - }, - }, - - // ── n8n_health ────────────────────────────────────────────────────────── - { - name: 'n8n_health', - description: 'Check n8n instance health, version, and queue metrics', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const [health, version] = await Promise.allSettled([ - this.n8nFetch('/health'), - this.n8nFetch('/version'), - ]); - - const { baseUrl } = await this.getConfig(); - - return { - instanceUrl: baseUrl.replace('/api/v1', ''), - healthy: health.status === 'fulfilled', - version: - version.status === 'fulfilled' - ? (version.value as any).version ?? 'unknown' - : 'unknown', - status: - health.status === 'fulfilled' - ? ((health.value as any).status ?? 'ok') - : 'unreachable', - }; - }, - }, - ]; - } -} diff --git a/plugins/n8n/tsconfig.json b/plugins/n8n/tsconfig.json deleted file mode 100644 index dce5a7d..0000000 --- a/plugins/n8n/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "skipLibCheck": true, - "declaration": true - }, - "include": ["src/**/*"] -} diff --git a/plugins/notion/README.md b/plugins/notion/README.md deleted file mode 100644 index 761306d..0000000 --- a/plugins/notion/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Notion Plugin for Conductor - -Install: `conductor install notion` - -## Setup - -**Authentication:** Integration Token - -```bash -conductor plugins config notion api_key \ -`conductor plugins enable notion` -``` - -Get credentials at: https://developers.notion.com - -## Tools - -``` -notion_search, notion_get_page, notion_create_page, notion_update_page, notion_query_database, notion_create_database_item -``` - -Each tool is documented inline — ask Conductor what tools are available after installing. - -## Source - -Part of [thealxlabs/conductor-plugins](https://github.com/thealxlabs/conductor-plugins/tree/main/plugins/notion). diff --git a/plugins/notion/dist/index.d.ts b/plugins/notion/dist/index.d.ts deleted file mode 100644 index 254e72d..0000000 --- a/plugins/notion/dist/index.d.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Notion Plugin - * - * Search, read, and create pages and databases in Notion. - * Requires a Notion Integration API key. - * - * Setup: - * 1. Go to https://www.notion.so/my-integrations and create an integration - * 2. Copy the "Internal Integration Token" (starts with ntn_) - * 3. Share each workspace page/db you want to access with your integration - * 4. Run: conductor plugins config notion token - * OR set it during install when prompted - * - * Stored in keychain as: notion / api_key - */ -interface PluginConfigField { - key: string; - label: string; - type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; - secret?: boolean; - service?: string; - description?: string; - options?: string[]; -} -interface PluginConfigSchema { - fields: PluginConfigField[]; - setupInstructions?: string; -} -interface PluginTool { - name: string; - description: string; - inputSchema: Record; - handler: (input: Record) => Promise; - requiresApproval?: boolean; -} -interface Plugin { - name: string; - description: string; - version: string; - configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - getTools(): PluginTool[]; - getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; - set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { - getConfig(): ConfigManagerLike; -} -export declare class NotionPlugin implements Plugin { - name: string; - description: string; - version: string; - configSchema: { - fields: { - key: string; - label: string; - type: "password"; - required: boolean; - secret: boolean; - service: string; - description: string; - }[]; - setupInstructions: string; - }; - private keychain; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - private getToken; - private notionFetch; - /** Extract plain text from Notion rich_text blocks */ - private richText; - /** Extract plain text from a block's content */ - private blockText; - /** Format a page for clean output */ - private formatPage; - getTools(): PluginTool[]; -} -export {}; diff --git a/plugins/notion/dist/index.js b/plugins/notion/dist/index.js deleted file mode 100644 index 04cc8ab..0000000 --- a/plugins/notion/dist/index.js +++ /dev/null @@ -1,330 +0,0 @@ -/** - * Notion Plugin - * - * Search, read, and create pages and databases in Notion. - * Requires a Notion Integration API key. - * - * Setup: - * 1. Go to https://www.notion.so/my-integrations and create an integration - * 2. Copy the "Internal Integration Token" (starts with ntn_) - * 3. Share each workspace page/db you want to access with your integration - * 4. Run: conductor plugins config notion token - * OR set it during install when prompted - * - * Stored in keychain as: notion / api_key - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service, _account, _value) { } - async delete(_service, _account) { } -} -const NOTION_BASE = 'https://api.notion.com/v1'; -const NOTION_VERSION = '2022-06-28'; -export class NotionPlugin { - name = 'notion'; - description = 'Read, search, and create Notion pages and databases — requires Notion API key'; - version = '1.0.0'; - configSchema = { - fields: [ - { - key: 'api_key', - label: 'Internal Integration Token', - type: 'password', - required: true, - secret: true, - service: 'notion', - description: 'Copy your token (starts with ntn_) from Notion Developer portal.' - } - ], - setupInstructions: '1. Visit Notion Settings > My integrations. 2. Create a new "Internal Integration". 3. Copy the token. 4. Ensure you "Connect" the integration to the pages you want Conductor to access.' - }; - keychain; - async initialize(conductor) { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - isConfigured() { return true; } - async getToken() { - const token = await this.keychain.get('notion', 'api_key'); - if (!token) { - throw new Error('Notion not configured. Get your integration token from https://www.notion.so/my-integrations\n' + - 'Then run: conductor plugins config notion token '); - } - return token; - } - async notionFetch(path, options = {}) { - const token = await this.getToken(); - const res = await fetch(`${NOTION_BASE}${path}`, { - method: options.method ?? 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'Notion-Version': NOTION_VERSION, - }, - body: options.body ? JSON.stringify(options.body) : undefined, - }); - if (!res.ok) { - const err = await res.json().catch(() => ({ message: res.statusText })); - throw new Error(`Notion API ${res.status}: ${err.message ?? res.statusText}`); - } - return res.json(); - } - /** Extract plain text from Notion rich_text blocks */ - richText(blocks) { - return (blocks ?? []).map((b) => b.plain_text ?? '').join(''); - } - /** Extract plain text from a block's content */ - blockText(block) { - const type = block.type; - const content = block[type]; - if (!content) - return ''; - if (content.rich_text) - return this.richText(content.rich_text); - return ''; - } - /** Format a page for clean output */ - formatPage(page) { - const props = page.properties ?? {}; - const titleProp = props.title ?? - props.Name ?? - Object.values(props).find((p) => p.type === 'title') ?? - {}; - const title = this.richText(titleProp.title ?? []); - return { - id: page.id, - title: title || '(Untitled)', - url: page.url ?? '', - createdTime: page.created_time ?? '', - lastEditedTime: page.last_edited_time ?? '', - archived: page.archived ?? false, - parent: page.parent?.type === 'database_id' - ? { type: 'database', id: page.parent.database_id } - : page.parent?.type === 'page_id' - ? { type: 'page', id: page.parent.page_id } - : { type: 'workspace' }, - }; - } - getTools() { - return [ - // ── notion_search ─────────────────────────────────────────────────────── - { - name: 'notion_search', - description: 'Search Notion pages and databases by title', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Search term' }, - filter: { - type: 'string', - enum: ['page', 'database'], - description: 'Filter to only pages or only databases', - }, - maxResults: { type: 'number', description: 'Max results (default: 10)' }, - }, - required: ['query'], - }, - handler: async ({ query, filter, maxResults = 10 }) => { - const body = { query, page_size: Math.min(maxResults, 100) }; - if (filter) - body.filter = { value: filter, property: 'object' }; - const res = await this.notionFetch('/search', { method: 'POST', body }); - return { - count: res.results?.length ?? 0, - results: (res.results ?? []).map((r) => ({ - ...this.formatPage(r), - type: r.object, - })), - }; - }, - }, - // ── notion_get_page ───────────────────────────────────────────────────── - { - name: 'notion_get_page', - description: 'Get metadata and properties of a Notion page', - inputSchema: { - type: 'object', - properties: { - pageId: { - type: 'string', - description: 'Notion page ID or URL', - }, - }, - required: ['pageId'], - }, - handler: async ({ pageId }) => { - // Accept full URLs — extract ID from them - const id = pageId.includes('notion.so') - ? (pageId.split('/').pop()?.split('?')[0]?.replace(/-/g, '').slice(-32) ?? pageId) - : pageId; - const page = await this.notionFetch(`/pages/${id}`); - return this.formatPage(page); - }, - }, - // ── notion_read_page ──────────────────────────────────────────────────── - { - name: 'notion_read_page', - description: 'Read the full text content of a Notion page (blocks)', - inputSchema: { - type: 'object', - properties: { - pageId: { type: 'string', description: 'Notion page ID or URL' }, - maxChars: { type: 'number', description: 'Max characters (default: 8000)' }, - }, - required: ['pageId'], - }, - handler: async ({ pageId, maxChars = 8000 }) => { - const id = pageId.includes('notion.so') - ? (pageId.split('/').pop()?.split('?')[0]?.replace(/-/g, '').slice(-32) ?? pageId) - : pageId; - const [page, blocks] = await Promise.all([ - this.notionFetch(`/pages/${id}`), - this.notionFetch(`/blocks/${id}/children?page_size=100`), - ]); - const lines = (blocks.results ?? []) - .map((b) => { - const text = this.blockText(b); - const type = b.type; - if (type === 'heading_1') - return `# ${text}`; - if (type === 'heading_2') - return `## ${text}`; - if (type === 'heading_3') - return `### ${text}`; - if (type === 'bulleted_list_item') - return `• ${text}`; - if (type === 'numbered_list_item') - return `1. ${text}`; - if (type === 'to_do') - return `[${b.to_do?.checked ? 'x' : ' '}] ${text}`; - if (type === 'divider') - return '---'; - if (type === 'code') - return `\`\`\`${b.code?.language ?? ''}\n${this.richText(b.code?.rich_text ?? [])}\n\`\`\``; - return text; - }) - .filter(Boolean) - .join('\n'); - return { - ...this.formatPage(page), - content: lines.slice(0, maxChars), - truncated: lines.length > maxChars, - blockCount: blocks.results?.length ?? 0, - }; - }, - }, - // ── notion_create_page ────────────────────────────────────────────────── - { - name: 'notion_create_page', - description: 'Create a new Notion page', - inputSchema: { - type: 'object', - properties: { - title: { type: 'string', description: 'Page title' }, - content: { type: 'string', description: 'Plain text content for the page body' }, - parentPageId: { - type: 'string', - description: 'Parent page ID — page will be created as a child', - }, - parentDatabaseId: { - type: 'string', - description: 'Parent database ID — page will be created as a database entry', - }, - }, - required: ['title'], - }, - handler: async ({ title, content, parentPageId, parentDatabaseId }) => { - if (!parentPageId && !parentDatabaseId) { - throw new Error('Provide either parentPageId or parentDatabaseId.'); - } - const parent = parentDatabaseId - ? { database_id: parentDatabaseId } - : { page_id: parentPageId }; - const titleProp = parentDatabaseId - ? { Name: { title: [{ text: { content: title } }] } } - : { title: { title: [{ text: { content: title } }] } }; - const children = content - ? content - .split('\n') - .filter(Boolean) - .map((line) => ({ - object: 'block', - type: 'paragraph', - paragraph: { rich_text: [{ text: { content: line } }] }, - })) - : []; - const page = await this.notionFetch('/pages', { - method: 'POST', - body: { parent, properties: titleProp, children }, - }); - return { created: true, ...this.formatPage(page) }; - }, - }, - // ── notion_append_to_page ─────────────────────────────────────────────── - { - name: 'notion_append_to_page', - description: 'Append text content to the end of an existing Notion page', - inputSchema: { - type: 'object', - properties: { - pageId: { type: 'string', description: 'Page ID to append to' }, - content: { type: 'string', description: 'Text to append' }, - }, - required: ['pageId', 'content'], - }, - handler: async ({ pageId, content }) => { - const children = content - .split('\n') - .filter(Boolean) - .map((line) => ({ - object: 'block', - type: 'paragraph', - paragraph: { rich_text: [{ text: { content: line } }] }, - })); - await this.notionFetch(`/blocks/${pageId}/children`, { - method: 'PATCH', - body: { children }, - }); - return { appended: true, pageId, linesAdded: children.length }; - }, - }, - // ── notion_query_database ─────────────────────────────────────────────── - { - name: 'notion_query_database', - description: 'Query a Notion database and return its entries', - inputSchema: { - type: 'object', - properties: { - databaseId: { type: 'string', description: 'Database ID' }, - maxResults: { type: 'number', description: 'Max entries to return (default: 20)' }, - filter: { - type: 'object', - description: 'Notion filter object (optional)', - }, - }, - required: ['databaseId'], - }, - handler: async ({ databaseId, maxResults = 20, filter }) => { - const body = { page_size: Math.min(maxResults, 100) }; - if (filter) - body.filter = filter; - const res = await this.notionFetch(`/databases/${databaseId}/query`, { - method: 'POST', - body, - }); - return { - count: res.results?.length ?? 0, - hasMore: res.has_more ?? false, - entries: (res.results ?? []).map(this.formatPage.bind(this)), - }; - }, - }, - ]; - } -} diff --git a/plugins/notion/dist/notion.js b/plugins/notion/dist/notion.js deleted file mode 100644 index 04cc8ab..0000000 --- a/plugins/notion/dist/notion.js +++ /dev/null @@ -1,330 +0,0 @@ -/** - * Notion Plugin - * - * Search, read, and create pages and databases in Notion. - * Requires a Notion Integration API key. - * - * Setup: - * 1. Go to https://www.notion.so/my-integrations and create an integration - * 2. Copy the "Internal Integration Token" (starts with ntn_) - * 3. Share each workspace page/db you want to access with your integration - * 4. Run: conductor plugins config notion token - * OR set it during install when prompted - * - * Stored in keychain as: notion / api_key - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service, _account, _value) { } - async delete(_service, _account) { } -} -const NOTION_BASE = 'https://api.notion.com/v1'; -const NOTION_VERSION = '2022-06-28'; -export class NotionPlugin { - name = 'notion'; - description = 'Read, search, and create Notion pages and databases — requires Notion API key'; - version = '1.0.0'; - configSchema = { - fields: [ - { - key: 'api_key', - label: 'Internal Integration Token', - type: 'password', - required: true, - secret: true, - service: 'notion', - description: 'Copy your token (starts with ntn_) from Notion Developer portal.' - } - ], - setupInstructions: '1. Visit Notion Settings > My integrations. 2. Create a new "Internal Integration". 3. Copy the token. 4. Ensure you "Connect" the integration to the pages you want Conductor to access.' - }; - keychain; - async initialize(conductor) { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - isConfigured() { return true; } - async getToken() { - const token = await this.keychain.get('notion', 'api_key'); - if (!token) { - throw new Error('Notion not configured. Get your integration token from https://www.notion.so/my-integrations\n' + - 'Then run: conductor plugins config notion token '); - } - return token; - } - async notionFetch(path, options = {}) { - const token = await this.getToken(); - const res = await fetch(`${NOTION_BASE}${path}`, { - method: options.method ?? 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'Notion-Version': NOTION_VERSION, - }, - body: options.body ? JSON.stringify(options.body) : undefined, - }); - if (!res.ok) { - const err = await res.json().catch(() => ({ message: res.statusText })); - throw new Error(`Notion API ${res.status}: ${err.message ?? res.statusText}`); - } - return res.json(); - } - /** Extract plain text from Notion rich_text blocks */ - richText(blocks) { - return (blocks ?? []).map((b) => b.plain_text ?? '').join(''); - } - /** Extract plain text from a block's content */ - blockText(block) { - const type = block.type; - const content = block[type]; - if (!content) - return ''; - if (content.rich_text) - return this.richText(content.rich_text); - return ''; - } - /** Format a page for clean output */ - formatPage(page) { - const props = page.properties ?? {}; - const titleProp = props.title ?? - props.Name ?? - Object.values(props).find((p) => p.type === 'title') ?? - {}; - const title = this.richText(titleProp.title ?? []); - return { - id: page.id, - title: title || '(Untitled)', - url: page.url ?? '', - createdTime: page.created_time ?? '', - lastEditedTime: page.last_edited_time ?? '', - archived: page.archived ?? false, - parent: page.parent?.type === 'database_id' - ? { type: 'database', id: page.parent.database_id } - : page.parent?.type === 'page_id' - ? { type: 'page', id: page.parent.page_id } - : { type: 'workspace' }, - }; - } - getTools() { - return [ - // ── notion_search ─────────────────────────────────────────────────────── - { - name: 'notion_search', - description: 'Search Notion pages and databases by title', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Search term' }, - filter: { - type: 'string', - enum: ['page', 'database'], - description: 'Filter to only pages or only databases', - }, - maxResults: { type: 'number', description: 'Max results (default: 10)' }, - }, - required: ['query'], - }, - handler: async ({ query, filter, maxResults = 10 }) => { - const body = { query, page_size: Math.min(maxResults, 100) }; - if (filter) - body.filter = { value: filter, property: 'object' }; - const res = await this.notionFetch('/search', { method: 'POST', body }); - return { - count: res.results?.length ?? 0, - results: (res.results ?? []).map((r) => ({ - ...this.formatPage(r), - type: r.object, - })), - }; - }, - }, - // ── notion_get_page ───────────────────────────────────────────────────── - { - name: 'notion_get_page', - description: 'Get metadata and properties of a Notion page', - inputSchema: { - type: 'object', - properties: { - pageId: { - type: 'string', - description: 'Notion page ID or URL', - }, - }, - required: ['pageId'], - }, - handler: async ({ pageId }) => { - // Accept full URLs — extract ID from them - const id = pageId.includes('notion.so') - ? (pageId.split('/').pop()?.split('?')[0]?.replace(/-/g, '').slice(-32) ?? pageId) - : pageId; - const page = await this.notionFetch(`/pages/${id}`); - return this.formatPage(page); - }, - }, - // ── notion_read_page ──────────────────────────────────────────────────── - { - name: 'notion_read_page', - description: 'Read the full text content of a Notion page (blocks)', - inputSchema: { - type: 'object', - properties: { - pageId: { type: 'string', description: 'Notion page ID or URL' }, - maxChars: { type: 'number', description: 'Max characters (default: 8000)' }, - }, - required: ['pageId'], - }, - handler: async ({ pageId, maxChars = 8000 }) => { - const id = pageId.includes('notion.so') - ? (pageId.split('/').pop()?.split('?')[0]?.replace(/-/g, '').slice(-32) ?? pageId) - : pageId; - const [page, blocks] = await Promise.all([ - this.notionFetch(`/pages/${id}`), - this.notionFetch(`/blocks/${id}/children?page_size=100`), - ]); - const lines = (blocks.results ?? []) - .map((b) => { - const text = this.blockText(b); - const type = b.type; - if (type === 'heading_1') - return `# ${text}`; - if (type === 'heading_2') - return `## ${text}`; - if (type === 'heading_3') - return `### ${text}`; - if (type === 'bulleted_list_item') - return `• ${text}`; - if (type === 'numbered_list_item') - return `1. ${text}`; - if (type === 'to_do') - return `[${b.to_do?.checked ? 'x' : ' '}] ${text}`; - if (type === 'divider') - return '---'; - if (type === 'code') - return `\`\`\`${b.code?.language ?? ''}\n${this.richText(b.code?.rich_text ?? [])}\n\`\`\``; - return text; - }) - .filter(Boolean) - .join('\n'); - return { - ...this.formatPage(page), - content: lines.slice(0, maxChars), - truncated: lines.length > maxChars, - blockCount: blocks.results?.length ?? 0, - }; - }, - }, - // ── notion_create_page ────────────────────────────────────────────────── - { - name: 'notion_create_page', - description: 'Create a new Notion page', - inputSchema: { - type: 'object', - properties: { - title: { type: 'string', description: 'Page title' }, - content: { type: 'string', description: 'Plain text content for the page body' }, - parentPageId: { - type: 'string', - description: 'Parent page ID — page will be created as a child', - }, - parentDatabaseId: { - type: 'string', - description: 'Parent database ID — page will be created as a database entry', - }, - }, - required: ['title'], - }, - handler: async ({ title, content, parentPageId, parentDatabaseId }) => { - if (!parentPageId && !parentDatabaseId) { - throw new Error('Provide either parentPageId or parentDatabaseId.'); - } - const parent = parentDatabaseId - ? { database_id: parentDatabaseId } - : { page_id: parentPageId }; - const titleProp = parentDatabaseId - ? { Name: { title: [{ text: { content: title } }] } } - : { title: { title: [{ text: { content: title } }] } }; - const children = content - ? content - .split('\n') - .filter(Boolean) - .map((line) => ({ - object: 'block', - type: 'paragraph', - paragraph: { rich_text: [{ text: { content: line } }] }, - })) - : []; - const page = await this.notionFetch('/pages', { - method: 'POST', - body: { parent, properties: titleProp, children }, - }); - return { created: true, ...this.formatPage(page) }; - }, - }, - // ── notion_append_to_page ─────────────────────────────────────────────── - { - name: 'notion_append_to_page', - description: 'Append text content to the end of an existing Notion page', - inputSchema: { - type: 'object', - properties: { - pageId: { type: 'string', description: 'Page ID to append to' }, - content: { type: 'string', description: 'Text to append' }, - }, - required: ['pageId', 'content'], - }, - handler: async ({ pageId, content }) => { - const children = content - .split('\n') - .filter(Boolean) - .map((line) => ({ - object: 'block', - type: 'paragraph', - paragraph: { rich_text: [{ text: { content: line } }] }, - })); - await this.notionFetch(`/blocks/${pageId}/children`, { - method: 'PATCH', - body: { children }, - }); - return { appended: true, pageId, linesAdded: children.length }; - }, - }, - // ── notion_query_database ─────────────────────────────────────────────── - { - name: 'notion_query_database', - description: 'Query a Notion database and return its entries', - inputSchema: { - type: 'object', - properties: { - databaseId: { type: 'string', description: 'Database ID' }, - maxResults: { type: 'number', description: 'Max entries to return (default: 20)' }, - filter: { - type: 'object', - description: 'Notion filter object (optional)', - }, - }, - required: ['databaseId'], - }, - handler: async ({ databaseId, maxResults = 20, filter }) => { - const body = { page_size: Math.min(maxResults, 100) }; - if (filter) - body.filter = filter; - const res = await this.notionFetch(`/databases/${databaseId}/query`, { - method: 'POST', - body, - }); - return { - count: res.results?.length ?? 0, - hasMore: res.has_more ?? false, - entries: (res.results ?? []).map(this.formatPage.bind(this)), - }; - }, - }, - ]; - } -} diff --git a/plugins/notion/package.json b/plugins/notion/package.json deleted file mode 100644 index 1d84c84..0000000 --- a/plugins/notion/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@conductor-plugins/notion", - "version": "1.0.0", - "type": "module", - "main": "dist/notion.js", - "scripts": { - "build": "tsc", - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "typescript": "^5.0.0", - "@types/node": "^20.0.0" - } -} diff --git a/plugins/notion/src/index.ts b/plugins/notion/src/index.ts deleted file mode 100644 index 88113d5..0000000 --- a/plugins/notion/src/index.ts +++ /dev/null @@ -1,373 +0,0 @@ -/** - * Notion Plugin - * - * Search, read, and create pages and databases in Notion. - * Requires a Notion Integration API key. - * - * Setup: - * 1. Go to https://www.notion.so/my-integrations and create an integration - * 2. Copy the "Internal Integration Token" (starts with ntn_) - * 3. Share each workspace page/db you want to access with your integration - * 4. Run: conductor plugins config notion token - * OR set it during install when prompted - * - * Stored in keychain as: notion / api_key - */ - -// ── Inlined types (no external dependencies) ───────────────────────────────── - -interface PluginConfigField { - key: string; label: string; type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; secret?: boolean; service?: string; description?: string; options?: string[]; -} -interface PluginConfigSchema { fields: PluginConfigField[]; setupInstructions?: string; } -interface PluginTool { - name: string; description: string; inputSchema: Record; - handler: (input: Record) => Promise; requiresApproval?: boolean; -} -interface Plugin { - name: string; description: string; version: string; configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; isConfigured(): boolean; - getTools(): PluginTool[]; getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { getConfig(): ConfigManagerLike; } -class Keychain { - constructor(private dir: string) {} - async get(service: string, account: string): Promise { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service: string, _account: string, _value: string): Promise {} - async delete(_service: string, _account: string): Promise {} -} - -const NOTION_BASE = 'https://api.notion.com/v1'; -const NOTION_VERSION = '2022-06-28'; - -export class NotionPlugin implements Plugin { - name = 'notion'; - description = 'Read, search, and create Notion pages and databases — requires Notion API key'; - version = '1.0.0'; - - configSchema = { - fields: [ - { - key: 'api_key', - label: 'Internal Integration Token', - type: 'password' as const, - required: true, - secret: true, - service: 'notion', - description: 'Copy your token (starts with ntn_) from Notion Developer portal.' - } - ], - setupInstructions: '1. Visit Notion Settings > My integrations. 2. Create a new "Internal Integration". 3. Copy the token. 4. Ensure you "Connect" the integration to the pages you want Conductor to access.' - }; - - private keychain!: Keychain; - - async initialize(conductor: Conductor): Promise { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - - isConfigured(): boolean { return true; } - - private async getToken(): Promise { - const token = await this.keychain.get('notion', 'api_key'); - if (!token) { - throw new Error( - 'Notion not configured. Get your integration token from https://www.notion.so/my-integrations\n' + - 'Then run: conductor plugins config notion token ' - ); - } - return token; - } - - private async notionFetch(path: string, options: { - method?: string; - body?: any; - } = {}): Promise { - const token = await this.getToken(); - const res = await fetch(`${NOTION_BASE}${path}`, { - method: options.method ?? 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'Notion-Version': NOTION_VERSION, - }, - body: options.body ? JSON.stringify(options.body) : undefined, - }); - if (!res.ok) { - const err = await res.json().catch(() => ({ message: res.statusText })) as any; - throw new Error(`Notion API ${res.status}: ${err.message ?? res.statusText}`); - } - return res.json(); - } - - /** Extract plain text from Notion rich_text blocks */ - private richText(blocks: any[]): string { - return (blocks ?? []).map((b: any) => b.plain_text ?? '').join(''); - } - - /** Extract plain text from a block's content */ - private blockText(block: any): string { - const type = block.type; - const content = block[type]; - if (!content) return ''; - if (content.rich_text) return this.richText(content.rich_text); - return ''; - } - - /** Format a page for clean output */ - private formatPage(page: any) { - const props = page.properties ?? {}; - const titleProp: any = - props.title ?? - props.Name ?? - Object.values(props).find((p: any) => p.type === 'title') ?? - {}; - const title = this.richText(titleProp.title ?? []); - return { - id: page.id, - title: title || '(Untitled)', - url: page.url ?? '', - createdTime: page.created_time ?? '', - lastEditedTime: page.last_edited_time ?? '', - archived: page.archived ?? false, - parent: page.parent?.type === 'database_id' - ? { type: 'database', id: page.parent.database_id } - : page.parent?.type === 'page_id' - ? { type: 'page', id: page.parent.page_id } - : { type: 'workspace' }, - }; - } - - getTools(): PluginTool[] { - return [ - // ── notion_search ─────────────────────────────────────────────────────── - { - name: 'notion_search', - description: 'Search Notion pages and databases by title', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Search term' }, - filter: { - type: 'string', - enum: ['page', 'database'], - description: 'Filter to only pages or only databases', - }, - maxResults: { type: 'number', description: 'Max results (default: 10)' }, - }, - required: ['query'], - }, - handler: async ({ query, filter, maxResults = 10 }: any) => { - const body: any = { query, page_size: Math.min(maxResults, 100) }; - if (filter) body.filter = { value: filter, property: 'object' }; - - const res = await this.notionFetch('/search', { method: 'POST', body }); - return { - count: res.results?.length ?? 0, - results: (res.results ?? []).map((r: any) => ({ - ...this.formatPage(r), - type: r.object, - })), - }; - }, - }, - - // ── notion_get_page ───────────────────────────────────────────────────── - { - name: 'notion_get_page', - description: 'Get metadata and properties of a Notion page', - inputSchema: { - type: 'object', - properties: { - pageId: { - type: 'string', - description: 'Notion page ID or URL', - }, - }, - required: ['pageId'], - }, - handler: async ({ pageId }: any) => { - // Accept full URLs — extract ID from them - const id = pageId.includes('notion.so') - ? (pageId.split('/').pop()?.split('?')[0]?.replace(/-/g, '').slice(-32) ?? pageId) - : pageId; - const page = await this.notionFetch(`/pages/${id}`); - return this.formatPage(page); - }, - }, - - // ── notion_read_page ──────────────────────────────────────────────────── - { - name: 'notion_read_page', - description: 'Read the full text content of a Notion page (blocks)', - inputSchema: { - type: 'object', - properties: { - pageId: { type: 'string', description: 'Notion page ID or URL' }, - maxChars: { type: 'number', description: 'Max characters (default: 8000)' }, - }, - required: ['pageId'], - }, - handler: async ({ pageId, maxChars = 8000 }: any) => { - const id = pageId.includes('notion.so') - ? (pageId.split('/').pop()?.split('?')[0]?.replace(/-/g, '').slice(-32) ?? pageId) - : pageId; - - const [page, blocks] = await Promise.all([ - this.notionFetch(`/pages/${id}`), - this.notionFetch(`/blocks/${id}/children?page_size=100`), - ]); - - const lines = (blocks.results ?? []) - .map((b: any) => { - const text = this.blockText(b); - const type = b.type; - if (type === 'heading_1') return `# ${text}`; - if (type === 'heading_2') return `## ${text}`; - if (type === 'heading_3') return `### ${text}`; - if (type === 'bulleted_list_item') return `• ${text}`; - if (type === 'numbered_list_item') return `1. ${text}`; - if (type === 'to_do') return `[${b.to_do?.checked ? 'x' : ' '}] ${text}`; - if (type === 'divider') return '---'; - if (type === 'code') return `\`\`\`${b.code?.language ?? ''}\n${this.richText(b.code?.rich_text ?? [])}\n\`\`\``; - return text; - }) - .filter(Boolean) - .join('\n'); - - return { - ...this.formatPage(page), - content: lines.slice(0, maxChars), - truncated: lines.length > maxChars, - blockCount: blocks.results?.length ?? 0, - }; - }, - }, - - // ── notion_create_page ────────────────────────────────────────────────── - { - name: 'notion_create_page', - description: 'Create a new Notion page', - inputSchema: { - type: 'object', - properties: { - title: { type: 'string', description: 'Page title' }, - content: { type: 'string', description: 'Plain text content for the page body' }, - parentPageId: { - type: 'string', - description: 'Parent page ID — page will be created as a child', - }, - parentDatabaseId: { - type: 'string', - description: 'Parent database ID — page will be created as a database entry', - }, - }, - required: ['title'], - }, - handler: async ({ title, content, parentPageId, parentDatabaseId }: any) => { - if (!parentPageId && !parentDatabaseId) { - throw new Error('Provide either parentPageId or parentDatabaseId.'); - } - - const parent = parentDatabaseId - ? { database_id: parentDatabaseId } - : { page_id: parentPageId }; - - const titleProp = parentDatabaseId - ? { Name: { title: [{ text: { content: title } }] } } - : { title: { title: [{ text: { content: title } }] } }; - - const children = content - ? content - .split('\n') - .filter(Boolean) - .map((line: string) => ({ - object: 'block', - type: 'paragraph', - paragraph: { rich_text: [{ text: { content: line } }] }, - })) - : []; - - const page = await this.notionFetch('/pages', { - method: 'POST', - body: { parent, properties: titleProp, children }, - }); - - return { created: true, ...this.formatPage(page) }; - }, - }, - - // ── notion_append_to_page ─────────────────────────────────────────────── - { - name: 'notion_append_to_page', - description: 'Append text content to the end of an existing Notion page', - inputSchema: { - type: 'object', - properties: { - pageId: { type: 'string', description: 'Page ID to append to' }, - content: { type: 'string', description: 'Text to append' }, - }, - required: ['pageId', 'content'], - }, - handler: async ({ pageId, content }: any) => { - const children = content - .split('\n') - .filter(Boolean) - .map((line: string) => ({ - object: 'block', - type: 'paragraph', - paragraph: { rich_text: [{ text: { content: line } }] }, - })); - - await this.notionFetch(`/blocks/${pageId}/children`, { - method: 'PATCH', - body: { children }, - }); - - return { appended: true, pageId, linesAdded: children.length }; - }, - }, - - // ── notion_query_database ─────────────────────────────────────────────── - { - name: 'notion_query_database', - description: 'Query a Notion database and return its entries', - inputSchema: { - type: 'object', - properties: { - databaseId: { type: 'string', description: 'Database ID' }, - maxResults: { type: 'number', description: 'Max entries to return (default: 20)' }, - filter: { - type: 'object', - description: 'Notion filter object (optional)', - }, - }, - required: ['databaseId'], - }, - handler: async ({ databaseId, maxResults = 20, filter }: any) => { - const body: any = { page_size: Math.min(maxResults, 100) }; - if (filter) body.filter = filter; - - const res = await this.notionFetch(`/databases/${databaseId}/query`, { - method: 'POST', - body, - }); - - return { - count: res.results?.length ?? 0, - hasMore: res.has_more ?? false, - entries: (res.results ?? []).map(this.formatPage.bind(this)), - }; - }, - }, - ]; - } -} diff --git a/plugins/notion/tsconfig.json b/plugins/notion/tsconfig.json deleted file mode 100644 index dce5a7d..0000000 --- a/plugins/notion/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "skipLibCheck": true, - "declaration": true - }, - "include": ["src/**/*"] -} diff --git a/plugins/openai/README.md b/plugins/openai/README.md new file mode 100644 index 0000000..7bcc322 --- /dev/null +++ b/plugins/openai/README.md @@ -0,0 +1,25 @@ +# OpenAI Plugin + +Use OpenAI APIs for chat completions, embeddings, image generation, and more from Conductor. + +## Setup + +1. Go to [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys) and create an API key. +2. Configure the plugin: + +```bash +conductor config set openai api_key YOUR_API_KEY +``` + +## Available Tools + +| Tool | Description | +|------|-------------| +| `openai_chat` | Create a chat completion (GPT-4, GPT-3.5, etc.) | +| `openai_embeddings` | Generate embeddings for text | +| `openai_image` | Generate an image with DALL-E | +| `openai_transcribe` | Transcribe audio with Whisper | +| `openai_list_models` | List all available models | +| `openai_fine_tune` | Create a fine-tuning job | +| `openai_list_fine_tunes` | List fine-tuning jobs | +| `openai_moderation` | Check text against content policy | diff --git a/plugins/posthog/README.md b/plugins/posthog/README.md new file mode 100644 index 0000000..883becd --- /dev/null +++ b/plugins/posthog/README.md @@ -0,0 +1,29 @@ +# PostHog Plugin + +Query PostHog analytics, manage feature flags, and inspect product insights from Conductor. + +## Setup + +1. Go to **PostHog** → **Settings** → **User API Keys** at [https://app.posthog.com/settings/user-api-keys](https://app.posthog.com/settings/user-api-keys). +2. Create a Personal API Key. +3. Configure the plugin: + +```bash +conductor config set posthog api_key YOUR_PERSONAL_API_KEY +conductor config set posthog host https://app.posthog.com +``` + +For self-hosted PostHog, replace the host with your instance URL. + +## Available Tools + +| Tool | Description | +|------|-------------| +| `posthog_events` | Query recent events | +| `posthog_actions` | List defined actions | +| `posthog_feature_flags` | List all feature flags | +| `posthog_get_flag` | Get details for a feature flag | +| `posthog_cohorts` | List cohorts | +| `posthog_insights` | Get funnel and trend insights | +| `posthog_recordings` | List session recordings metadata | +| `posthog_persons` | Search persons/users | diff --git a/plugins/redis/README.md b/plugins/redis/README.md new file mode 100644 index 0000000..b27b41e --- /dev/null +++ b/plugins/redis/README.md @@ -0,0 +1,33 @@ +# Redis Plugin + +Read and write Redis keys, lists, hashes, sets, and pub/sub channels from Conductor. + +## Setup + +1. Have a Redis instance running (local or remote). +2. Configure the plugin with your connection URL: + +```bash +conductor config set redis url redis://localhost:6379 +``` + +For TLS/auth: + +```bash +conductor config set redis url rediss://user:password@your-redis-host:6380 +``` + +## Available Tools + +| Tool | Description | +|------|-------------| +| `redis_get` | Get a key's value | +| `redis_set` | Set a key with optional TTL | +| `redis_delete` | Delete one or more keys | +| `redis_scan` | Scan keys matching a pattern | +| `redis_hget` | Get a hash field value | +| `redis_hset` | Set a hash field | +| `redis_lpush` / `redis_rpush` | Push items to a list | +| `redis_lpop` / `redis_rpop` | Pop items from a list | +| `redis_sadd` / `redis_smembers` | Manage set members | +| `redis_publish` | Publish a message to a channel | diff --git a/plugins/sendgrid/README.md b/plugins/sendgrid/README.md new file mode 100644 index 0000000..7acae03 --- /dev/null +++ b/plugins/sendgrid/README.md @@ -0,0 +1,24 @@ +# SendGrid Plugin + +Send transactional and marketing email via SendGrid from Conductor. + +## Setup + +1. Go to [https://app.sendgrid.com/settings/api_keys](https://app.sendgrid.com/settings/api_keys) and create an API key. +2. Grant at least **Mail Send** permissions (add **Marketing** permissions for contact management). +3. Configure the plugin: + +```bash +conductor config set sendgrid api_key YOUR_API_KEY +``` + +## Available Tools + +| Tool | Description | +|------|-------------| +| `sendgrid_send` | Send a single transactional email | +| `sendgrid_send_bulk` | Send to multiple recipients | +| `sendgrid_send_template` | Send using a dynamic template | +| `sendgrid_list_contacts` | List marketing contacts | +| `sendgrid_add_contact` | Add a contact to a list | +| `sendgrid_stats` | Get email delivery statistics | diff --git a/plugins/shopify/README.md b/plugins/shopify/README.md new file mode 100644 index 0000000..4053b45 --- /dev/null +++ b/plugins/shopify/README.md @@ -0,0 +1,30 @@ +# Shopify Plugin + +Manage Shopify products, orders, customers, and inventory from Conductor. + +## Setup + +1. In your Shopify Admin, go to **Apps** → **Develop apps** and create a custom app. +2. Under **Configuration**, grant the Admin API scopes you need (at minimum `read_products`, `read_orders`, `read_customers`). +3. Install the app and copy the **Admin API access token**. +4. Configure the plugin: + +```bash +conductor config set shopify access_token YOUR_ACCESS_TOKEN +conductor config set shopify shop_domain your-store.myshopify.com +``` + +## Available Tools + +| Tool | Description | +|------|-------------| +| `shopify_list_products` | List products with optional search | +| `shopify_get_product` | Get product details and variants | +| `shopify_list_orders` | List orders with optional filters | +| `shopify_get_order` | Get details for a specific order | +| `shopify_list_customers` | List customers | +| `shopify_get_customer` | Get customer details | +| `shopify_inventory_levels` | Check inventory levels | +| `shopify_list_fulfillments` | List fulfillments for an order | +| `shopify_create_fulfillment` | Create a fulfillment | +| `shopify_metafields` | Get store metafields | diff --git a/plugins/slack/README.md b/plugins/slack/README.md deleted file mode 100644 index 1f04a22..0000000 --- a/plugins/slack/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Slack Plugin for Conductor - -Install: `conductor install slack` - -## Setup - -**Authentication:** Bot Token - -```bash -conductor slack setup -`conductor plugins enable slack` -``` - -Get credentials at: https://api.slack.com - -## Tools - -``` -slack_send_message, slack_channels, slack_read_channel, slack_search, slack_users, slack_add_reaction -``` - -Each tool is documented inline — ask Conductor what tools are available after installing. - -## Source - -Part of [thealxlabs/conductor-plugins](https://github.com/thealxlabs/conductor-plugins/tree/main/plugins/slack). diff --git a/plugins/slack/dist/index.d.ts b/plugins/slack/dist/index.d.ts deleted file mode 100644 index fe2e91d..0000000 --- a/plugins/slack/dist/index.d.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Slack Plugin — TheAlxLabs / Conductor - * - * Send messages, read channels, search, and manage Slack workspaces. - * Uses Slack Web API with a Bot User OAuth Token. - * - * Setup: - * 1. Go to https://api.slack.com/apps and create an app - * 2. Under "OAuth & Permissions", add these bot scopes: - * chat:write, channels:read, channels:history, users:read, - * search:read, files:write, reactions:write, im:write - * 3. Install the app to your workspace and copy the Bot User OAuth Token - * 4. Run: conductor slack setup - * (or: conductor plugins config slack bot_token xoxb-...) - * - * Keychain entry: slack/bot_token - */ -interface PluginConfigField { - key: string; - label: string; - type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; - secret?: boolean; - service?: string; - description?: string; - options?: string[]; -} -interface PluginConfigSchema { - fields: PluginConfigField[]; - setupInstructions?: string; -} -interface PluginTool { - name: string; - description: string; - inputSchema: Record; - handler: (input: Record) => Promise; - requiresApproval?: boolean; -} -interface Plugin { - name: string; - description: string; - version: string; - configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - getTools(): PluginTool[]; - getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; - set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { - getConfig(): ConfigManagerLike; -} -export declare class SlackPlugin implements Plugin { - name: string; - description: string; - version: string; - private keychain; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - private getToken; - private slackFetch; - getTools(): PluginTool[]; -} -export {}; diff --git a/plugins/slack/dist/index.js b/plugins/slack/dist/index.js deleted file mode 100644 index 1f60ddf..0000000 --- a/plugins/slack/dist/index.js +++ /dev/null @@ -1,305 +0,0 @@ -/** - * Slack Plugin — TheAlxLabs / Conductor - * - * Send messages, read channels, search, and manage Slack workspaces. - * Uses Slack Web API with a Bot User OAuth Token. - * - * Setup: - * 1. Go to https://api.slack.com/apps and create an app - * 2. Under "OAuth & Permissions", add these bot scopes: - * chat:write, channels:read, channels:history, users:read, - * search:read, files:write, reactions:write, im:write - * 3. Install the app to your workspace and copy the Bot User OAuth Token - * 4. Run: conductor slack setup - * (or: conductor plugins config slack bot_token xoxb-...) - * - * Keychain entry: slack/bot_token - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service, _account, _value) { } - async delete(_service, _account) { } -} -export class SlackPlugin { - name = 'slack'; - description = 'Send messages, read channels, search, and manage Slack workspaces'; - version = '1.0.0'; - keychain; - async initialize(conductor) { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - isConfigured() { - return true; - } - async getToken() { - const token = await this.keychain.get('slack', 'bot_token'); - if (!token) { - throw new Error('Slack bot token not configured.\nRun: conductor slack setup'); - } - return token; - } - async slackFetch(method, params = {}, httpMethod = 'GET') { - const token = await this.getToken(); - const base = 'https://slack.com/api'; - let url; - let init; - if (httpMethod === 'POST') { - url = `${base}/${method}`; - init = { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json; charset=utf-8', - }, - body: JSON.stringify(params), - }; - } - else { - const qs = new URLSearchParams(Object.fromEntries(Object.entries(params) - .filter(([, v]) => v !== undefined && v !== null) - .map(([k, v]) => [k, String(v)]))).toString(); - url = `${base}/${method}${qs ? '?' + qs : ''}`; - init = { - headers: { Authorization: `Bearer ${token}` }, - }; - } - const res = await fetch(url, init); - if (!res.ok) - throw new Error(`Slack HTTP ${res.status}: ${res.statusText}`); - const data = (await res.json()); - if (!data.ok) { - throw new Error(`Slack API error: ${data.error ?? JSON.stringify(data)}`); - } - return data; - } - getTools() { - return [ - // ── slack_send_message ───────────────────────────────────────────────── - { - name: 'slack_send_message', - description: 'Send a message to a Slack channel or user (DM)', - inputSchema: { - type: 'object', - properties: { - channel: { - type: 'string', - description: 'Channel name (e.g. #general), channel ID, or user ID for DM', - }, - text: { - type: 'string', - description: 'Message text (supports Slack markdown)', - }, - thread_ts: { - type: 'string', - description: 'Thread timestamp to reply to (optional)', - }, - }, - required: ['channel', 'text'], - }, - requiresApproval: true, - handler: async ({ channel, text, thread_ts }) => { - const data = await this.slackFetch('chat.postMessage', { channel, text, ...(thread_ts ? { thread_ts } : {}) }, 'POST'); - return { - ok: true, - channel: data.channel, - ts: data.ts, - message: data.message?.text, - }; - }, - }, - // ── slack_channels ───────────────────────────────────────────────────── - { - name: 'slack_channels', - description: 'List all public channels in the workspace', - inputSchema: { - type: 'object', - properties: { - limit: { - type: 'number', - description: 'Max channels to return (default 50)', - }, - }, - }, - handler: async ({ limit = 50 }) => { - const data = await this.slackFetch('conversations.list', { - limit, - types: 'public_channel', - exclude_archived: true, - }); - return { - count: data.channels.length, - channels: data.channels.map((c) => ({ - id: c.id, - name: c.name, - topic: c.topic?.value || '', - memberCount: c.num_members, - isPrivate: c.is_private, - })), - }; - }, - }, - // ── slack_read_channel ───────────────────────────────────────────────── - { - name: 'slack_read_channel', - description: 'Read recent messages from a Slack channel', - inputSchema: { - type: 'object', - properties: { - channel: { - type: 'string', - description: 'Channel ID or name (e.g. C01234 or general)', - }, - limit: { - type: 'number', - description: 'Number of messages to fetch (default 20)', - }, - }, - required: ['channel'], - }, - handler: async ({ channel, limit = 20 }) => { - // Resolve name to ID if needed - let channelId = channel; - if (!channel.startsWith('C') && !channel.startsWith('D')) { - const list = await this.slackFetch('conversations.list', { - limit: 200, - types: 'public_channel,private_channel', - }); - const name = channel.replace(/^#/, ''); - const found = list.channels.find((c) => c.name === name); - if (!found) - throw new Error(`Channel "${channel}" not found`); - channelId = found.id; - } - const data = await this.slackFetch('conversations.history', { - channel: channelId, - limit, - }); - return { - channel: channelId, - count: data.messages.length, - messages: data.messages.map((m) => ({ - ts: m.ts, - user: m.user ?? m.bot_id ?? 'unknown', - text: m.text, - threadReplies: m.reply_count ?? 0, - reactions: (m.reactions ?? []).map((r) => `${r.name}×${r.count}`), - })), - }; - }, - }, - // ── slack_search ─────────────────────────────────────────────────────── - { - name: 'slack_search', - description: 'Search messages across all Slack channels', - inputSchema: { - type: 'object', - properties: { - query: { - type: 'string', - description: 'Search query (supports Slack modifiers like in:#channel, from:@user)', - }, - limit: { - type: 'number', - description: 'Max results (default 10)', - }, - }, - required: ['query'], - }, - handler: async ({ query, limit = 10 }) => { - const data = await this.slackFetch('search.messages', { - query, - count: limit, - sort: 'timestamp', - sort_dir: 'desc', - }); - const messages = data.messages?.matches ?? []; - return { - total: data.messages?.total ?? 0, - count: messages.length, - results: messages.map((m) => ({ - channel: m.channel?.name ?? m.channel?.id, - user: m.username ?? m.user, - text: m.text, - ts: m.ts, - permalink: m.permalink, - })), - }; - }, - }, - // ── slack_users ──────────────────────────────────────────────────────── - { - name: 'slack_users', - description: 'List workspace members or look up a specific user', - inputSchema: { - type: 'object', - properties: { - query: { - type: 'string', - description: 'Filter by name or email (optional)', - }, - limit: { - type: 'number', - description: 'Max users to return (default 50)', - }, - }, - }, - handler: async ({ query, limit = 50 }) => { - const data = await this.slackFetch('users.list', { limit: 200 }); - let members = data.members.filter((u) => !u.is_bot && !u.deleted && u.id !== 'USLACKBOT'); - if (query) { - const q = query.toLowerCase(); - members = members.filter((u) => (u.real_name ?? '').toLowerCase().includes(q) || - (u.name ?? '').toLowerCase().includes(q) || - (u.profile?.email ?? '').toLowerCase().includes(q)); - } - return { - count: Math.min(members.length, limit), - users: members.slice(0, limit).map((u) => ({ - id: u.id, - name: u.real_name ?? u.name, - username: u.name, - email: u.profile?.email ?? null, - title: u.profile?.title ?? null, - timezone: u.tz ?? null, - })), - }; - }, - }, - // ── slack_add_reaction ───────────────────────────────────────────────── - { - name: 'slack_add_reaction', - description: 'Add an emoji reaction to a Slack message', - inputSchema: { - type: 'object', - properties: { - channel: { - type: 'string', - description: 'Channel ID containing the message', - }, - timestamp: { - type: 'string', - description: 'Message timestamp (ts field)', - }, - emoji: { - type: 'string', - description: 'Emoji name without colons (e.g. "thumbsup", "white_check_mark")', - }, - }, - required: ['channel', 'timestamp', 'emoji'], - }, - requiresApproval: true, - handler: async ({ channel, timestamp, emoji }) => { - await this.slackFetch('reactions.add', { channel, timestamp, name: emoji.replace(/:/g, '') }, 'POST'); - return { ok: true, emoji, channel, timestamp }; - }, - }, - ]; - } -} diff --git a/plugins/slack/dist/slack.js b/plugins/slack/dist/slack.js deleted file mode 100644 index 1f60ddf..0000000 --- a/plugins/slack/dist/slack.js +++ /dev/null @@ -1,305 +0,0 @@ -/** - * Slack Plugin — TheAlxLabs / Conductor - * - * Send messages, read channels, search, and manage Slack workspaces. - * Uses Slack Web API with a Bot User OAuth Token. - * - * Setup: - * 1. Go to https://api.slack.com/apps and create an app - * 2. Under "OAuth & Permissions", add these bot scopes: - * chat:write, channels:read, channels:history, users:read, - * search:read, files:write, reactions:write, im:write - * 3. Install the app to your workspace and copy the Bot User OAuth Token - * 4. Run: conductor slack setup - * (or: conductor plugins config slack bot_token xoxb-...) - * - * Keychain entry: slack/bot_token - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service, _account, _value) { } - async delete(_service, _account) { } -} -export class SlackPlugin { - name = 'slack'; - description = 'Send messages, read channels, search, and manage Slack workspaces'; - version = '1.0.0'; - keychain; - async initialize(conductor) { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - isConfigured() { - return true; - } - async getToken() { - const token = await this.keychain.get('slack', 'bot_token'); - if (!token) { - throw new Error('Slack bot token not configured.\nRun: conductor slack setup'); - } - return token; - } - async slackFetch(method, params = {}, httpMethod = 'GET') { - const token = await this.getToken(); - const base = 'https://slack.com/api'; - let url; - let init; - if (httpMethod === 'POST') { - url = `${base}/${method}`; - init = { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json; charset=utf-8', - }, - body: JSON.stringify(params), - }; - } - else { - const qs = new URLSearchParams(Object.fromEntries(Object.entries(params) - .filter(([, v]) => v !== undefined && v !== null) - .map(([k, v]) => [k, String(v)]))).toString(); - url = `${base}/${method}${qs ? '?' + qs : ''}`; - init = { - headers: { Authorization: `Bearer ${token}` }, - }; - } - const res = await fetch(url, init); - if (!res.ok) - throw new Error(`Slack HTTP ${res.status}: ${res.statusText}`); - const data = (await res.json()); - if (!data.ok) { - throw new Error(`Slack API error: ${data.error ?? JSON.stringify(data)}`); - } - return data; - } - getTools() { - return [ - // ── slack_send_message ───────────────────────────────────────────────── - { - name: 'slack_send_message', - description: 'Send a message to a Slack channel or user (DM)', - inputSchema: { - type: 'object', - properties: { - channel: { - type: 'string', - description: 'Channel name (e.g. #general), channel ID, or user ID for DM', - }, - text: { - type: 'string', - description: 'Message text (supports Slack markdown)', - }, - thread_ts: { - type: 'string', - description: 'Thread timestamp to reply to (optional)', - }, - }, - required: ['channel', 'text'], - }, - requiresApproval: true, - handler: async ({ channel, text, thread_ts }) => { - const data = await this.slackFetch('chat.postMessage', { channel, text, ...(thread_ts ? { thread_ts } : {}) }, 'POST'); - return { - ok: true, - channel: data.channel, - ts: data.ts, - message: data.message?.text, - }; - }, - }, - // ── slack_channels ───────────────────────────────────────────────────── - { - name: 'slack_channels', - description: 'List all public channels in the workspace', - inputSchema: { - type: 'object', - properties: { - limit: { - type: 'number', - description: 'Max channels to return (default 50)', - }, - }, - }, - handler: async ({ limit = 50 }) => { - const data = await this.slackFetch('conversations.list', { - limit, - types: 'public_channel', - exclude_archived: true, - }); - return { - count: data.channels.length, - channels: data.channels.map((c) => ({ - id: c.id, - name: c.name, - topic: c.topic?.value || '', - memberCount: c.num_members, - isPrivate: c.is_private, - })), - }; - }, - }, - // ── slack_read_channel ───────────────────────────────────────────────── - { - name: 'slack_read_channel', - description: 'Read recent messages from a Slack channel', - inputSchema: { - type: 'object', - properties: { - channel: { - type: 'string', - description: 'Channel ID or name (e.g. C01234 or general)', - }, - limit: { - type: 'number', - description: 'Number of messages to fetch (default 20)', - }, - }, - required: ['channel'], - }, - handler: async ({ channel, limit = 20 }) => { - // Resolve name to ID if needed - let channelId = channel; - if (!channel.startsWith('C') && !channel.startsWith('D')) { - const list = await this.slackFetch('conversations.list', { - limit: 200, - types: 'public_channel,private_channel', - }); - const name = channel.replace(/^#/, ''); - const found = list.channels.find((c) => c.name === name); - if (!found) - throw new Error(`Channel "${channel}" not found`); - channelId = found.id; - } - const data = await this.slackFetch('conversations.history', { - channel: channelId, - limit, - }); - return { - channel: channelId, - count: data.messages.length, - messages: data.messages.map((m) => ({ - ts: m.ts, - user: m.user ?? m.bot_id ?? 'unknown', - text: m.text, - threadReplies: m.reply_count ?? 0, - reactions: (m.reactions ?? []).map((r) => `${r.name}×${r.count}`), - })), - }; - }, - }, - // ── slack_search ─────────────────────────────────────────────────────── - { - name: 'slack_search', - description: 'Search messages across all Slack channels', - inputSchema: { - type: 'object', - properties: { - query: { - type: 'string', - description: 'Search query (supports Slack modifiers like in:#channel, from:@user)', - }, - limit: { - type: 'number', - description: 'Max results (default 10)', - }, - }, - required: ['query'], - }, - handler: async ({ query, limit = 10 }) => { - const data = await this.slackFetch('search.messages', { - query, - count: limit, - sort: 'timestamp', - sort_dir: 'desc', - }); - const messages = data.messages?.matches ?? []; - return { - total: data.messages?.total ?? 0, - count: messages.length, - results: messages.map((m) => ({ - channel: m.channel?.name ?? m.channel?.id, - user: m.username ?? m.user, - text: m.text, - ts: m.ts, - permalink: m.permalink, - })), - }; - }, - }, - // ── slack_users ──────────────────────────────────────────────────────── - { - name: 'slack_users', - description: 'List workspace members or look up a specific user', - inputSchema: { - type: 'object', - properties: { - query: { - type: 'string', - description: 'Filter by name or email (optional)', - }, - limit: { - type: 'number', - description: 'Max users to return (default 50)', - }, - }, - }, - handler: async ({ query, limit = 50 }) => { - const data = await this.slackFetch('users.list', { limit: 200 }); - let members = data.members.filter((u) => !u.is_bot && !u.deleted && u.id !== 'USLACKBOT'); - if (query) { - const q = query.toLowerCase(); - members = members.filter((u) => (u.real_name ?? '').toLowerCase().includes(q) || - (u.name ?? '').toLowerCase().includes(q) || - (u.profile?.email ?? '').toLowerCase().includes(q)); - } - return { - count: Math.min(members.length, limit), - users: members.slice(0, limit).map((u) => ({ - id: u.id, - name: u.real_name ?? u.name, - username: u.name, - email: u.profile?.email ?? null, - title: u.profile?.title ?? null, - timezone: u.tz ?? null, - })), - }; - }, - }, - // ── slack_add_reaction ───────────────────────────────────────────────── - { - name: 'slack_add_reaction', - description: 'Add an emoji reaction to a Slack message', - inputSchema: { - type: 'object', - properties: { - channel: { - type: 'string', - description: 'Channel ID containing the message', - }, - timestamp: { - type: 'string', - description: 'Message timestamp (ts field)', - }, - emoji: { - type: 'string', - description: 'Emoji name without colons (e.g. "thumbsup", "white_check_mark")', - }, - }, - required: ['channel', 'timestamp', 'emoji'], - }, - requiresApproval: true, - handler: async ({ channel, timestamp, emoji }) => { - await this.slackFetch('reactions.add', { channel, timestamp, name: emoji.replace(/:/g, '') }, 'POST'); - return { ok: true, emoji, channel, timestamp }; - }, - }, - ]; - } -} diff --git a/plugins/slack/package.json b/plugins/slack/package.json deleted file mode 100644 index 20810a0..0000000 --- a/plugins/slack/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@conductor-plugins/slack", - "version": "1.0.0", - "type": "module", - "main": "dist/slack.js", - "scripts": { - "build": "tsc", - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "typescript": "^5.0.0", - "@types/node": "^20.0.0" - } -} diff --git a/plugins/slack/src/index.ts b/plugins/slack/src/index.ts deleted file mode 100644 index 8e99159..0000000 --- a/plugins/slack/src/index.ts +++ /dev/null @@ -1,365 +0,0 @@ -/** - * Slack Plugin — TheAlxLabs / Conductor - * - * Send messages, read channels, search, and manage Slack workspaces. - * Uses Slack Web API with a Bot User OAuth Token. - * - * Setup: - * 1. Go to https://api.slack.com/apps and create an app - * 2. Under "OAuth & Permissions", add these bot scopes: - * chat:write, channels:read, channels:history, users:read, - * search:read, files:write, reactions:write, im:write - * 3. Install the app to your workspace and copy the Bot User OAuth Token - * 4. Run: conductor slack setup - * (or: conductor plugins config slack bot_token xoxb-...) - * - * Keychain entry: slack/bot_token - */ - -// ── Inlined types (no external dependencies) ───────────────────────────────── - -interface PluginConfigField { - key: string; label: string; type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; secret?: boolean; service?: string; description?: string; options?: string[]; -} -interface PluginConfigSchema { fields: PluginConfigField[]; setupInstructions?: string; } -interface PluginTool { - name: string; description: string; inputSchema: Record; - handler: (input: Record) => Promise; requiresApproval?: boolean; -} -interface Plugin { - name: string; description: string; version: string; configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; isConfigured(): boolean; - getTools(): PluginTool[]; getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { getConfig(): ConfigManagerLike; } - -class Keychain { - constructor(private dir: string) {} - async get(service: string, account: string): Promise { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service: string, _account: string, _value: string): Promise {} - async delete(_service: string, _account: string): Promise {} -} - -export class SlackPlugin implements Plugin { - name = 'slack'; - description = 'Send messages, read channels, search, and manage Slack workspaces'; - version = '1.0.0'; - - private keychain!: Keychain; - - async initialize(conductor: Conductor): Promise { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - - isConfigured(): boolean { - return true; - } - - private async getToken(): Promise { - const token = await this.keychain.get('slack', 'bot_token'); - if (!token) { - throw new Error( - 'Slack bot token not configured.\nRun: conductor slack setup' - ); - } - return token; - } - - private async slackFetch( - method: string, - params: Record = {}, - httpMethod: 'GET' | 'POST' = 'GET' - ): Promise { - const token = await this.getToken(); - const base = 'https://slack.com/api'; - - let url: string; - let init: RequestInit; - - if (httpMethod === 'POST') { - url = `${base}/${method}`; - init = { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json; charset=utf-8', - }, - body: JSON.stringify(params), - }; - } else { - const qs = new URLSearchParams( - Object.fromEntries( - Object.entries(params) - .filter(([, v]) => v !== undefined && v !== null) - .map(([k, v]) => [k, String(v)]) - ) - ).toString(); - url = `${base}/${method}${qs ? '?' + qs : ''}`; - init = { - headers: { Authorization: `Bearer ${token}` }, - }; - } - - const res = await fetch(url, init); - if (!res.ok) throw new Error(`Slack HTTP ${res.status}: ${res.statusText}`); - - const data = (await res.json()) as any; - if (!data.ok) { - throw new Error(`Slack API error: ${data.error ?? JSON.stringify(data)}`); - } - return data; - } - - getTools(): PluginTool[] { - return [ - - // ── slack_send_message ───────────────────────────────────────────────── - { - name: 'slack_send_message', - description: 'Send a message to a Slack channel or user (DM)', - inputSchema: { - type: 'object', - properties: { - channel: { - type: 'string', - description: 'Channel name (e.g. #general), channel ID, or user ID for DM', - }, - text: { - type: 'string', - description: 'Message text (supports Slack markdown)', - }, - thread_ts: { - type: 'string', - description: 'Thread timestamp to reply to (optional)', - }, - }, - required: ['channel', 'text'], - }, - requiresApproval: true, - handler: async ({ channel, text, thread_ts }: any) => { - const data = await this.slackFetch( - 'chat.postMessage', - { channel, text, ...(thread_ts ? { thread_ts } : {}) }, - 'POST' - ); - return { - ok: true, - channel: data.channel, - ts: data.ts, - message: data.message?.text, - }; - }, - }, - - // ── slack_channels ───────────────────────────────────────────────────── - { - name: 'slack_channels', - description: 'List all public channels in the workspace', - inputSchema: { - type: 'object', - properties: { - limit: { - type: 'number', - description: 'Max channels to return (default 50)', - }, - }, - }, - handler: async ({ limit = 50 }: any) => { - const data = await this.slackFetch('conversations.list', { - limit, - types: 'public_channel', - exclude_archived: true, - }); - return { - count: data.channels.length, - channels: data.channels.map((c: any) => ({ - id: c.id, - name: c.name, - topic: c.topic?.value || '', - memberCount: c.num_members, - isPrivate: c.is_private, - })), - }; - }, - }, - - // ── slack_read_channel ───────────────────────────────────────────────── - { - name: 'slack_read_channel', - description: 'Read recent messages from a Slack channel', - inputSchema: { - type: 'object', - properties: { - channel: { - type: 'string', - description: 'Channel ID or name (e.g. C01234 or general)', - }, - limit: { - type: 'number', - description: 'Number of messages to fetch (default 20)', - }, - }, - required: ['channel'], - }, - handler: async ({ channel, limit = 20 }: any) => { - // Resolve name to ID if needed - let channelId = channel; - if (!channel.startsWith('C') && !channel.startsWith('D')) { - const list = await this.slackFetch('conversations.list', { - limit: 200, - types: 'public_channel,private_channel', - }); - const name = channel.replace(/^#/, ''); - const found = list.channels.find((c: any) => c.name === name); - if (!found) throw new Error(`Channel "${channel}" not found`); - channelId = found.id; - } - - const data = await this.slackFetch('conversations.history', { - channel: channelId, - limit, - }); - - return { - channel: channelId, - count: data.messages.length, - messages: data.messages.map((m: any) => ({ - ts: m.ts, - user: m.user ?? m.bot_id ?? 'unknown', - text: m.text, - threadReplies: m.reply_count ?? 0, - reactions: (m.reactions ?? []).map((r: any) => `${r.name}×${r.count}`), - })), - }; - }, - }, - - // ── slack_search ─────────────────────────────────────────────────────── - { - name: 'slack_search', - description: 'Search messages across all Slack channels', - inputSchema: { - type: 'object', - properties: { - query: { - type: 'string', - description: 'Search query (supports Slack modifiers like in:#channel, from:@user)', - }, - limit: { - type: 'number', - description: 'Max results (default 10)', - }, - }, - required: ['query'], - }, - handler: async ({ query, limit = 10 }: any) => { - const data = await this.slackFetch('search.messages', { - query, - count: limit, - sort: 'timestamp', - sort_dir: 'desc', - }); - const messages = data.messages?.matches ?? []; - return { - total: data.messages?.total ?? 0, - count: messages.length, - results: messages.map((m: any) => ({ - channel: m.channel?.name ?? m.channel?.id, - user: m.username ?? m.user, - text: m.text, - ts: m.ts, - permalink: m.permalink, - })), - }; - }, - }, - - // ── slack_users ──────────────────────────────────────────────────────── - { - name: 'slack_users', - description: 'List workspace members or look up a specific user', - inputSchema: { - type: 'object', - properties: { - query: { - type: 'string', - description: 'Filter by name or email (optional)', - }, - limit: { - type: 'number', - description: 'Max users to return (default 50)', - }, - }, - }, - handler: async ({ query, limit = 50 }: any) => { - const data = await this.slackFetch('users.list', { limit: 200 }); - let members = (data.members as any[]).filter( - (u: any) => !u.is_bot && !u.deleted && u.id !== 'USLACKBOT' - ); - if (query) { - const q = query.toLowerCase(); - members = members.filter( - (u: any) => - (u.real_name ?? '').toLowerCase().includes(q) || - (u.name ?? '').toLowerCase().includes(q) || - (u.profile?.email ?? '').toLowerCase().includes(q) - ); - } - return { - count: Math.min(members.length, limit), - users: members.slice(0, limit).map((u: any) => ({ - id: u.id, - name: u.real_name ?? u.name, - username: u.name, - email: u.profile?.email ?? null, - title: u.profile?.title ?? null, - timezone: u.tz ?? null, - })), - }; - }, - }, - - // ── slack_add_reaction ───────────────────────────────────────────────── - { - name: 'slack_add_reaction', - description: 'Add an emoji reaction to a Slack message', - inputSchema: { - type: 'object', - properties: { - channel: { - type: 'string', - description: 'Channel ID containing the message', - }, - timestamp: { - type: 'string', - description: 'Message timestamp (ts field)', - }, - emoji: { - type: 'string', - description: 'Emoji name without colons (e.g. "thumbsup", "white_check_mark")', - }, - }, - required: ['channel', 'timestamp', 'emoji'], - }, - requiresApproval: true, - handler: async ({ channel, timestamp, emoji }: any) => { - await this.slackFetch( - 'reactions.add', - { channel, timestamp, name: emoji.replace(/:/g, '') }, - 'POST' - ); - return { ok: true, emoji, channel, timestamp }; - }, - }, - - ]; - } -} diff --git a/plugins/slack/tsconfig.json b/plugins/slack/tsconfig.json deleted file mode 100644 index dce5a7d..0000000 --- a/plugins/slack/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "skipLibCheck": true, - "declaration": true - }, - "include": ["src/**/*"] -} diff --git a/plugins/spotify/README.md b/plugins/spotify/README.md deleted file mode 100644 index 799487b..0000000 --- a/plugins/spotify/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Spotify Plugin for Conductor - -Install: `conductor install spotify` - -## Setup - -**Authentication:** Client ID + Secret - -```bash -conductor plugins config spotify client_id \ then client_secret \ -`conductor plugins enable spotify` -``` - -Get credentials at: https://developer.spotify.com/documentation/web-api - -## Tools - -``` -spotify_now_playing, spotify_play, spotify_pause, spotify_next, spotify_previous, spotify_search, spotify_queue, spotify_playlists, spotify_create_playlist -``` - -Each tool is documented inline — ask Conductor what tools are available after installing. - -## Source - -Part of [thealxlabs/conductor-plugins](https://github.com/thealxlabs/conductor-plugins/tree/main/plugins/spotify). diff --git a/plugins/spotify/dist/index.d.ts b/plugins/spotify/dist/index.d.ts deleted file mode 100644 index 426d0a6..0000000 --- a/plugins/spotify/dist/index.d.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Spotify Plugin — TheAlxLabs / Conductor - * - * Full Spotify control: playback, search, playlists, queue, recommendations. - * Uses Spotify Web API with OAuth 2.0 (Authorization Code + PKCE). - * - * Setup: - * 1. https://developer.spotify.com/dashboard → Create App - * 2. Add redirect URI: http://localhost:4839/spotify/callback - * 3. Copy Client ID - * 4. Run: conductor plugins auth spotify - * (opens browser for one-click OAuth — no client secret needed with PKCE) - * - * Keychain entries: spotify/access_token, spotify/refresh_token, spotify/client_id - * - * Auto-refreshes expired tokens transparently. - */ -interface PluginConfigField { - key: string; - label: string; - type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; - secret?: boolean; - service?: string; - description?: string; - options?: string[]; -} -interface PluginConfigSchema { - fields: PluginConfigField[]; - setupInstructions?: string; -} -interface PluginTool { - name: string; - description: string; - inputSchema: Record; - handler: (input: Record) => Promise; - requiresApproval?: boolean; -} -interface Plugin { - name: string; - description: string; - version: string; - configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - getTools(): PluginTool[]; - getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; - set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { - getConfig(): ConfigManagerLike; -} -export declare class SpotifyPlugin implements Plugin { - name: string; - description: string; - version: string; - configSchema: { - fields: ({ - key: string; - label: string; - type: "string"; - required: boolean; - secret: boolean; - service?: undefined; - } | { - key: string; - label: string; - type: "password"; - required: boolean; - secret: boolean; - service: string; - })[]; - setupInstructions: string; - }; - private keychain; - private configDir; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - private getToken; - /** Refresh the access token using the stored refresh token. */ - private refreshToken; - private spotifyFetch; - private formatTrack; - private formatPlaylist; - private msToTime; - getTools(): PluginTool[]; -} -export {}; diff --git a/plugins/spotify/dist/index.js b/plugins/spotify/dist/index.js deleted file mode 100644 index 4a9167b..0000000 --- a/plugins/spotify/dist/index.js +++ /dev/null @@ -1,635 +0,0 @@ -/** - * Spotify Plugin — TheAlxLabs / Conductor - * - * Full Spotify control: playback, search, playlists, queue, recommendations. - * Uses Spotify Web API with OAuth 2.0 (Authorization Code + PKCE). - * - * Setup: - * 1. https://developer.spotify.com/dashboard → Create App - * 2. Add redirect URI: http://localhost:4839/spotify/callback - * 3. Copy Client ID - * 4. Run: conductor plugins auth spotify - * (opens browser for one-click OAuth — no client secret needed with PKCE) - * - * Keychain entries: spotify/access_token, spotify/refresh_token, spotify/client_id - * - * Auto-refreshes expired tokens transparently. - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service, _account, _value) { } - async delete(_service, _account) { } -} -const SPOTIFY_BASE = 'https://api.spotify.com/v1'; -const SPOTIFY_AUTH = 'https://accounts.spotify.com'; -const SCOPES = [ - 'user-read-playback-state', - 'user-modify-playback-state', - 'user-read-currently-playing', - 'playlist-read-private', - 'playlist-read-collaborative', - 'playlist-modify-public', - 'playlist-modify-private', - 'user-library-read', - 'user-library-modify', - 'user-top-read', - 'user-read-recently-played', - 'user-follow-read', -].join(' '); -export class SpotifyPlugin { - name = 'spotify'; - description = 'Full Spotify control — playback, search, playlists, queue, recommendations, top tracks'; - version = '1.0.0'; - configSchema = { - fields: [ - { - key: 'client_id', - label: 'Spotify Client ID', - type: 'string', - required: true, - secret: false - }, - { - key: 'client_secret', - label: 'Spotify Client Secret', - type: 'password', - required: true, - secret: true, - service: 'spotify' - } - ], - setupInstructions: 'Create an app in the Spotify Developer Dashboard. Set Redirect URI to http://localhost:8888/callback' - }; - keychain; - configDir; - async initialize(conductor) { - this.configDir = conductor.getConfig().getConfigDir(); - this.keychain = new Keychain(this.configDir); - } - isConfigured() { - return true; - } - // ── Auth helpers ──────────────────────────────────────────────────────────── - async getToken() { - let token = await this.keychain.get('spotify', 'access_token'); - if (!token) { - throw new Error('Spotify not authenticated.\n' + - 'Run: conductor plugins auth spotify\n' + - 'Or manually set: conductor plugins config spotify access_token '); - } - return token; - } - /** Refresh the access token using the stored refresh token. */ - async refreshToken() { - const refreshToken = await this.keychain.get('spotify', 'refresh_token'); - const clientId = await this.keychain.get('spotify', 'client_id'); - if (!refreshToken || !clientId) - return null; - const res = await fetch(`${SPOTIFY_AUTH}/api/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_id: clientId, - }), - }); - if (!res.ok) - return null; - const data = (await res.json()); - if (data.access_token) { - await this.keychain.set('spotify', 'access_token', data.access_token); - if (data.refresh_token) { - await this.keychain.set('spotify', 'refresh_token', data.refresh_token); - } - return data.access_token; - } - return null; - } - // ── API fetch with auto-refresh ───────────────────────────────────────────── - async spotifyFetch(path, options = {}, retry = true) { - const token = await this.getToken(); - const url = new URL(`${SPOTIFY_BASE}${path}`); - if (options.params) { - for (const [k, v] of Object.entries(options.params)) - url.searchParams.set(k, v); - } - const res = await fetch(url.toString(), { - method: options.method ?? 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: options.body ? JSON.stringify(options.body) : undefined, - }); - // Auto-refresh on 401 - if (res.status === 401 && retry) { - const newToken = await this.refreshToken(); - if (newToken) { - return this.spotifyFetch(path, options, false); - } - throw new Error('Spotify token expired and refresh failed. Run: conductor plugins auth spotify'); - } - if (res.status === 204 || res.status === 202) - return {}; - if (!res.ok) { - const err = (await res.json().catch(() => ({}))); - throw new Error(`Spotify API ${res.status}: ${err.error?.message ?? res.statusText}`); - } - return res.json(); - } - // ── Formatting helpers ────────────────────────────────────────────────────── - formatTrack(t) { - return { - id: t.id, - name: t.name, - artists: (t.artists ?? []).map((a) => a.name).join(', '), - album: t.album?.name ?? '', - duration: this.msToTime(t.duration_ms), - popularity: t.popularity ?? 0, - uri: t.uri, - url: t.external_urls?.spotify ?? '', - explicit: t.explicit ?? false, - }; - } - formatPlaylist(p) { - return { - id: p.id, - name: p.name, - description: p.description ?? '', - owner: p.owner?.display_name ?? '', - tracks: p.tracks?.total ?? 0, - public: p.public ?? false, - url: p.external_urls?.spotify ?? '', - uri: p.uri, - }; - } - msToTime(ms) { - const total = Math.floor(ms / 1000); - const m = Math.floor(total / 60); - const s = total % 60; - return `${m}:${s.toString().padStart(2, '0')}`; - } - // ── Tools ─────────────────────────────────────────────────────────────────── - getTools() { - return [ - // ── spotify_now_playing ──────────────────────────────────────────────── - { - name: 'spotify_now_playing', - description: 'Get the currently playing track and playback state', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const data = await this.spotifyFetch('/me/player'); - if (!data || !data.item) - return { playing: false, message: 'Nothing currently playing.' }; - return { - playing: data.is_playing, - track: this.formatTrack(data.item), - device: { - name: data.device?.name ?? 'Unknown', - type: data.device?.type ?? '', - volume: data.device?.volume_percent ?? 0, - }, - shuffle: data.shuffle_state, - repeat: data.repeat_state, - progress: this.msToTime(data.progress_ms ?? 0), - context: data.context?.type ?? null, - }; - }, - }, - // ── spotify_search ───────────────────────────────────────────────────── - { - name: 'spotify_search', - description: 'Search Spotify for tracks, albums, artists, or playlists', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Search query' }, - type: { - type: 'string', - enum: ['track', 'album', 'artist', 'playlist'], - description: 'Type of result (default: track)', - }, - limit: { type: 'number', description: 'Max results (default: 10)' }, - }, - required: ['query'], - }, - handler: async ({ query, type = 'track', limit = 10 }) => { - const data = await this.spotifyFetch('/search', { - params: { q: query, type, limit: String(Math.min(limit, 50)) }, - }); - if (type === 'track') { - return { - count: data.tracks?.items?.length ?? 0, - tracks: (data.tracks?.items ?? []).map(this.formatTrack.bind(this)), - }; - } - if (type === 'artist') { - return { - count: data.artists?.items?.length ?? 0, - artists: (data.artists?.items ?? []).map((a) => ({ - id: a.id, - name: a.name, - genres: a.genres ?? [], - followers: a.followers?.total ?? 0, - popularity: a.popularity ?? 0, - uri: a.uri, - url: a.external_urls?.spotify ?? '', - })), - }; - } - if (type === 'album') { - return { - count: data.albums?.items?.length ?? 0, - albums: (data.albums?.items ?? []).map((a) => ({ - id: a.id, - name: a.name, - artists: (a.artists ?? []).map((x) => x.name).join(', '), - releaseDate: a.release_date ?? '', - tracks: a.total_tracks ?? 0, - uri: a.uri, - url: a.external_urls?.spotify ?? '', - })), - }; - } - if (type === 'playlist') { - return { - count: data.playlists?.items?.length ?? 0, - playlists: (data.playlists?.items ?? []).filter(Boolean).map(this.formatPlaylist.bind(this)), - }; - } - return data; - }, - }, - // ── spotify_play ─────────────────────────────────────────────────────── - { - name: 'spotify_play', - description: 'Start or resume playback. Can play a specific track, album, playlist, or artist by URI or search query.', - inputSchema: { - type: 'object', - properties: { - uri: { - type: 'string', - description: 'Spotify URI (e.g. spotify:track:xxx, spotify:album:xxx)', - }, - query: { - type: 'string', - description: 'Search for and play this track/artist/album (used if no URI given)', - }, - deviceId: { type: 'string', description: 'Target device ID (optional)' }, - }, - }, - handler: async ({ uri, query, deviceId }) => { - let playUri = uri; - // Auto-search if only query given - if (!playUri && query) { - const results = await this.spotifyFetch('/search', { - params: { q: query, type: 'track', limit: '1' }, - }); - const track = results.tracks?.items?.[0]; - if (!track) - return { error: `No track found for: "${query}"` }; - playUri = track.uri; - } - const body = {}; - if (playUri) { - if (playUri.startsWith('spotify:track:')) { - body.uris = [playUri]; - } - else { - body.context_uri = playUri; - } - } - const params = {}; - if (deviceId) - params.device_id = deviceId; - await this.spotifyFetch('/me/player/play', { - method: 'PUT', - body, - params, - }); - // Fetch what's now playing to confirm - await new Promise((r) => setTimeout(r, 500)); - const now = await this.spotifyFetch('/me/player/currently-playing').catch(() => null); - return { - playing: true, - track: now?.item ? this.formatTrack(now.item) : null, - }; - }, - }, - // ── spotify_pause ────────────────────────────────────────────────────── - { - name: 'spotify_pause', - description: 'Pause Spotify playback', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - await this.spotifyFetch('/me/player/pause', { method: 'PUT' }); - return { paused: true }; - }, - }, - // ── spotify_skip ─────────────────────────────────────────────────────── - { - name: 'spotify_skip', - description: 'Skip to next or previous track', - inputSchema: { - type: 'object', - properties: { - direction: { - type: 'string', - enum: ['next', 'previous'], - description: 'next or previous (default: next)', - }, - }, - }, - handler: async ({ direction = 'next' }) => { - const endpoint = direction === 'previous' ? '/me/player/previous' : '/me/player/next'; - await this.spotifyFetch(endpoint, { method: 'POST' }); - await new Promise((r) => setTimeout(r, 600)); - const now = await this.spotifyFetch('/me/player/currently-playing').catch(() => null); - return { - skipped: direction, - now: now?.item ? this.formatTrack(now.item) : null, - }; - }, - }, - // ── spotify_volume ───────────────────────────────────────────────────── - { - name: 'spotify_volume', - description: 'Set Spotify playback volume (0–100)', - inputSchema: { - type: 'object', - properties: { - volume: { type: 'number', description: 'Volume 0–100' }, - }, - required: ['volume'], - }, - handler: async ({ volume }) => { - const vol = Math.max(0, Math.min(100, Math.round(volume))); - await this.spotifyFetch('/me/player/volume', { - method: 'PUT', - params: { volume_percent: String(vol) }, - }); - return { volume: vol }; - }, - }, - // ── spotify_queue ────────────────────────────────────────────────────── - { - name: 'spotify_queue', - description: 'Add a track to the playback queue by URI or search query', - inputSchema: { - type: 'object', - properties: { - uri: { type: 'string', description: 'Spotify track URI' }, - query: { type: 'string', description: 'Search for and queue this track' }, - }, - }, - handler: async ({ uri, query }) => { - let trackUri = uri; - if (!trackUri && query) { - const results = await this.spotifyFetch('/search', { - params: { q: query, type: 'track', limit: '1' }, - }); - const track = results.tracks?.items?.[0]; - if (!track) - return { error: `No track found for: "${query}"` }; - trackUri = track.uri; - } - if (!trackUri) - return { error: 'Provide uri or query.' }; - await this.spotifyFetch('/me/player/queue', { - method: 'POST', - params: { uri: trackUri }, - }); - return { queued: true, uri: trackUri }; - }, - }, - // ── spotify_playlists ────────────────────────────────────────────────── - { - name: 'spotify_playlists', - description: "Get the current user's playlists", - inputSchema: { - type: 'object', - properties: { - limit: { type: 'number', description: 'Max playlists to return (default: 20)' }, - }, - }, - handler: async ({ limit = 20 }) => { - const data = await this.spotifyFetch('/me/playlists', { - params: { limit: String(Math.min(limit, 50)) }, - }); - return { - count: data.items?.length ?? 0, - total: data.total ?? 0, - playlists: (data.items ?? []).map(this.formatPlaylist.bind(this)), - }; - }, - }, - // ── spotify_playlist_tracks ──────────────────────────────────────────── - { - name: 'spotify_playlist_tracks', - description: 'Get tracks from a playlist', - inputSchema: { - type: 'object', - properties: { - playlistId: { type: 'string', description: 'Spotify playlist ID' }, - limit: { type: 'number', description: 'Max tracks (default: 20)' }, - }, - required: ['playlistId'], - }, - handler: async ({ playlistId, limit = 20 }) => { - const data = await this.spotifyFetch(`/playlists/${playlistId}/tracks`, { - params: { - limit: String(Math.min(limit, 100)), - fields: 'items(track(id,name,artists,album,duration_ms,popularity,uri,external_urls)),total', - }, - }); - return { - total: data.total ?? 0, - tracks: (data.items ?? []) - .filter((i) => i.track) - .map((i) => this.formatTrack(i.track)), - }; - }, - }, - // ── spotify_top_tracks ───────────────────────────────────────────────── - { - name: 'spotify_top_tracks', - description: "Get the user's top tracks or artists", - inputSchema: { - type: 'object', - properties: { - type: { - type: 'string', - enum: ['tracks', 'artists'], - description: 'tracks or artists (default: tracks)', - }, - timeRange: { - type: 'string', - enum: ['short_term', 'medium_term', 'long_term'], - description: 'short_term=4wk, medium_term=6mo, long_term=all time (default: medium_term)', - }, - limit: { type: 'number', description: 'Max results (default: 10)' }, - }, - }, - handler: async ({ type = 'tracks', timeRange = 'medium_term', limit = 10 }) => { - const data = await this.spotifyFetch(`/me/top/${type}`, { - params: { time_range: timeRange, limit: String(Math.min(limit, 50)) }, - }); - if (type === 'artists') { - return { - count: data.items?.length ?? 0, - timeRange, - artists: (data.items ?? []).map((a) => ({ - rank: data.items.indexOf(a) + 1, - name: a.name, - genres: a.genres?.slice(0, 3) ?? [], - popularity: a.popularity, - url: a.external_urls?.spotify ?? '', - })), - }; - } - return { - count: data.items?.length ?? 0, - timeRange, - tracks: (data.items ?? []).map((t, i) => ({ - rank: i + 1, - ...this.formatTrack(t), - })), - }; - }, - }, - // ── spotify_recommendations ──────────────────────────────────────────── - { - name: 'spotify_recommendations', - description: 'Get personalized track recommendations based on seed tracks, artists, or genres', - inputSchema: { - type: 'object', - properties: { - seedTracks: { - type: 'array', - items: { type: 'string' }, - description: 'Up to 2 Spotify track IDs as seeds', - }, - seedArtists: { - type: 'array', - items: { type: 'string' }, - description: 'Up to 2 Spotify artist IDs as seeds', - }, - seedGenres: { - type: 'array', - items: { type: 'string' }, - description: 'Up to 2 genre strings (e.g. "pop", "hip-hop", "indie")', - }, - limit: { type: 'number', description: 'Number of recommendations (default: 10)' }, - energy: { type: 'number', description: 'Target energy 0.0–1.0' }, - valence: { type: 'number', description: 'Target positivity/mood 0.0–1.0' }, - tempo: { type: 'number', description: 'Target BPM' }, - }, - }, - handler: async ({ seedTracks = [], seedArtists = [], seedGenres = [], limit = 10, energy, valence, tempo, }) => { - const totalSeeds = seedTracks.length + seedArtists.length + seedGenres.length; - if (totalSeeds === 0) { - // Use user's current top track as seed - const top = await this.spotifyFetch('/me/top/tracks', { - params: { limit: '1', time_range: 'short_term' }, - }); - const topTrack = top.items?.[0]; - if (topTrack) - seedTracks = [topTrack.id]; - else - return { error: 'Provide at least one seed (track, artist, or genre).' }; - } - const params = { - limit: String(Math.min(limit, 100)), - }; - if (seedTracks.length) - params.seed_tracks = seedTracks.slice(0, 2).join(','); - if (seedArtists.length) - params.seed_artists = seedArtists.slice(0, 2).join(','); - if (seedGenres.length) - params.seed_genres = seedGenres.slice(0, 2).join(','); - if (energy !== undefined) - params.target_energy = String(energy); - if (valence !== undefined) - params.target_valence = String(valence); - if (tempo !== undefined) - params.target_tempo = String(tempo); - const data = await this.spotifyFetch('/recommendations', { params }); - return { - count: data.tracks?.length ?? 0, - tracks: (data.tracks ?? []).map(this.formatTrack.bind(this)), - }; - }, - }, - // ── spotify_devices ──────────────────────────────────────────────────── - { - name: 'spotify_devices', - description: 'List available Spotify playback devices', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const data = await this.spotifyFetch('/me/player/devices'); - return { - count: data.devices?.length ?? 0, - devices: (data.devices ?? []).map((d) => ({ - id: d.id, - name: d.name, - type: d.type, - active: d.is_active, - volume: d.volume_percent, - restricted: d.is_restricted, - })), - }; - }, - }, - // ── spotify_shuffle ──────────────────────────────────────────────────── - { - name: 'spotify_shuffle', - description: 'Toggle shuffle on or off', - inputSchema: { - type: 'object', - properties: { - state: { type: 'boolean', description: 'true = shuffle on, false = shuffle off' }, - }, - required: ['state'], - }, - handler: async ({ state }) => { - await this.spotifyFetch('/me/player/shuffle', { - method: 'PUT', - params: { state: String(state) }, - }); - return { shuffle: state }; - }, - }, - // ── spotify_recently_played ──────────────────────────────────────────── - { - name: 'spotify_recently_played', - description: "Get the user's recently played tracks", - inputSchema: { - type: 'object', - properties: { - limit: { type: 'number', description: 'Number of tracks (default: 10, max: 50)' }, - }, - }, - handler: async ({ limit = 10 }) => { - const data = await this.spotifyFetch('/me/player/recently-played', { - params: { limit: String(Math.min(limit, 50)) }, - }); - return { - count: data.items?.length ?? 0, - tracks: (data.items ?? []).map((i) => ({ - ...this.formatTrack(i.track), - playedAt: i.played_at, - })), - }; - }, - }, - ]; - } -} diff --git a/plugins/spotify/dist/spotify.js b/plugins/spotify/dist/spotify.js deleted file mode 100644 index 4a9167b..0000000 --- a/plugins/spotify/dist/spotify.js +++ /dev/null @@ -1,635 +0,0 @@ -/** - * Spotify Plugin — TheAlxLabs / Conductor - * - * Full Spotify control: playback, search, playlists, queue, recommendations. - * Uses Spotify Web API with OAuth 2.0 (Authorization Code + PKCE). - * - * Setup: - * 1. https://developer.spotify.com/dashboard → Create App - * 2. Add redirect URI: http://localhost:4839/spotify/callback - * 3. Copy Client ID - * 4. Run: conductor plugins auth spotify - * (opens browser for one-click OAuth — no client secret needed with PKCE) - * - * Keychain entries: spotify/access_token, spotify/refresh_token, spotify/client_id - * - * Auto-refreshes expired tokens transparently. - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service, _account, _value) { } - async delete(_service, _account) { } -} -const SPOTIFY_BASE = 'https://api.spotify.com/v1'; -const SPOTIFY_AUTH = 'https://accounts.spotify.com'; -const SCOPES = [ - 'user-read-playback-state', - 'user-modify-playback-state', - 'user-read-currently-playing', - 'playlist-read-private', - 'playlist-read-collaborative', - 'playlist-modify-public', - 'playlist-modify-private', - 'user-library-read', - 'user-library-modify', - 'user-top-read', - 'user-read-recently-played', - 'user-follow-read', -].join(' '); -export class SpotifyPlugin { - name = 'spotify'; - description = 'Full Spotify control — playback, search, playlists, queue, recommendations, top tracks'; - version = '1.0.0'; - configSchema = { - fields: [ - { - key: 'client_id', - label: 'Spotify Client ID', - type: 'string', - required: true, - secret: false - }, - { - key: 'client_secret', - label: 'Spotify Client Secret', - type: 'password', - required: true, - secret: true, - service: 'spotify' - } - ], - setupInstructions: 'Create an app in the Spotify Developer Dashboard. Set Redirect URI to http://localhost:8888/callback' - }; - keychain; - configDir; - async initialize(conductor) { - this.configDir = conductor.getConfig().getConfigDir(); - this.keychain = new Keychain(this.configDir); - } - isConfigured() { - return true; - } - // ── Auth helpers ──────────────────────────────────────────────────────────── - async getToken() { - let token = await this.keychain.get('spotify', 'access_token'); - if (!token) { - throw new Error('Spotify not authenticated.\n' + - 'Run: conductor plugins auth spotify\n' + - 'Or manually set: conductor plugins config spotify access_token '); - } - return token; - } - /** Refresh the access token using the stored refresh token. */ - async refreshToken() { - const refreshToken = await this.keychain.get('spotify', 'refresh_token'); - const clientId = await this.keychain.get('spotify', 'client_id'); - if (!refreshToken || !clientId) - return null; - const res = await fetch(`${SPOTIFY_AUTH}/api/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_id: clientId, - }), - }); - if (!res.ok) - return null; - const data = (await res.json()); - if (data.access_token) { - await this.keychain.set('spotify', 'access_token', data.access_token); - if (data.refresh_token) { - await this.keychain.set('spotify', 'refresh_token', data.refresh_token); - } - return data.access_token; - } - return null; - } - // ── API fetch with auto-refresh ───────────────────────────────────────────── - async spotifyFetch(path, options = {}, retry = true) { - const token = await this.getToken(); - const url = new URL(`${SPOTIFY_BASE}${path}`); - if (options.params) { - for (const [k, v] of Object.entries(options.params)) - url.searchParams.set(k, v); - } - const res = await fetch(url.toString(), { - method: options.method ?? 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: options.body ? JSON.stringify(options.body) : undefined, - }); - // Auto-refresh on 401 - if (res.status === 401 && retry) { - const newToken = await this.refreshToken(); - if (newToken) { - return this.spotifyFetch(path, options, false); - } - throw new Error('Spotify token expired and refresh failed. Run: conductor plugins auth spotify'); - } - if (res.status === 204 || res.status === 202) - return {}; - if (!res.ok) { - const err = (await res.json().catch(() => ({}))); - throw new Error(`Spotify API ${res.status}: ${err.error?.message ?? res.statusText}`); - } - return res.json(); - } - // ── Formatting helpers ────────────────────────────────────────────────────── - formatTrack(t) { - return { - id: t.id, - name: t.name, - artists: (t.artists ?? []).map((a) => a.name).join(', '), - album: t.album?.name ?? '', - duration: this.msToTime(t.duration_ms), - popularity: t.popularity ?? 0, - uri: t.uri, - url: t.external_urls?.spotify ?? '', - explicit: t.explicit ?? false, - }; - } - formatPlaylist(p) { - return { - id: p.id, - name: p.name, - description: p.description ?? '', - owner: p.owner?.display_name ?? '', - tracks: p.tracks?.total ?? 0, - public: p.public ?? false, - url: p.external_urls?.spotify ?? '', - uri: p.uri, - }; - } - msToTime(ms) { - const total = Math.floor(ms / 1000); - const m = Math.floor(total / 60); - const s = total % 60; - return `${m}:${s.toString().padStart(2, '0')}`; - } - // ── Tools ─────────────────────────────────────────────────────────────────── - getTools() { - return [ - // ── spotify_now_playing ──────────────────────────────────────────────── - { - name: 'spotify_now_playing', - description: 'Get the currently playing track and playback state', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const data = await this.spotifyFetch('/me/player'); - if (!data || !data.item) - return { playing: false, message: 'Nothing currently playing.' }; - return { - playing: data.is_playing, - track: this.formatTrack(data.item), - device: { - name: data.device?.name ?? 'Unknown', - type: data.device?.type ?? '', - volume: data.device?.volume_percent ?? 0, - }, - shuffle: data.shuffle_state, - repeat: data.repeat_state, - progress: this.msToTime(data.progress_ms ?? 0), - context: data.context?.type ?? null, - }; - }, - }, - // ── spotify_search ───────────────────────────────────────────────────── - { - name: 'spotify_search', - description: 'Search Spotify for tracks, albums, artists, or playlists', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Search query' }, - type: { - type: 'string', - enum: ['track', 'album', 'artist', 'playlist'], - description: 'Type of result (default: track)', - }, - limit: { type: 'number', description: 'Max results (default: 10)' }, - }, - required: ['query'], - }, - handler: async ({ query, type = 'track', limit = 10 }) => { - const data = await this.spotifyFetch('/search', { - params: { q: query, type, limit: String(Math.min(limit, 50)) }, - }); - if (type === 'track') { - return { - count: data.tracks?.items?.length ?? 0, - tracks: (data.tracks?.items ?? []).map(this.formatTrack.bind(this)), - }; - } - if (type === 'artist') { - return { - count: data.artists?.items?.length ?? 0, - artists: (data.artists?.items ?? []).map((a) => ({ - id: a.id, - name: a.name, - genres: a.genres ?? [], - followers: a.followers?.total ?? 0, - popularity: a.popularity ?? 0, - uri: a.uri, - url: a.external_urls?.spotify ?? '', - })), - }; - } - if (type === 'album') { - return { - count: data.albums?.items?.length ?? 0, - albums: (data.albums?.items ?? []).map((a) => ({ - id: a.id, - name: a.name, - artists: (a.artists ?? []).map((x) => x.name).join(', '), - releaseDate: a.release_date ?? '', - tracks: a.total_tracks ?? 0, - uri: a.uri, - url: a.external_urls?.spotify ?? '', - })), - }; - } - if (type === 'playlist') { - return { - count: data.playlists?.items?.length ?? 0, - playlists: (data.playlists?.items ?? []).filter(Boolean).map(this.formatPlaylist.bind(this)), - }; - } - return data; - }, - }, - // ── spotify_play ─────────────────────────────────────────────────────── - { - name: 'spotify_play', - description: 'Start or resume playback. Can play a specific track, album, playlist, or artist by URI or search query.', - inputSchema: { - type: 'object', - properties: { - uri: { - type: 'string', - description: 'Spotify URI (e.g. spotify:track:xxx, spotify:album:xxx)', - }, - query: { - type: 'string', - description: 'Search for and play this track/artist/album (used if no URI given)', - }, - deviceId: { type: 'string', description: 'Target device ID (optional)' }, - }, - }, - handler: async ({ uri, query, deviceId }) => { - let playUri = uri; - // Auto-search if only query given - if (!playUri && query) { - const results = await this.spotifyFetch('/search', { - params: { q: query, type: 'track', limit: '1' }, - }); - const track = results.tracks?.items?.[0]; - if (!track) - return { error: `No track found for: "${query}"` }; - playUri = track.uri; - } - const body = {}; - if (playUri) { - if (playUri.startsWith('spotify:track:')) { - body.uris = [playUri]; - } - else { - body.context_uri = playUri; - } - } - const params = {}; - if (deviceId) - params.device_id = deviceId; - await this.spotifyFetch('/me/player/play', { - method: 'PUT', - body, - params, - }); - // Fetch what's now playing to confirm - await new Promise((r) => setTimeout(r, 500)); - const now = await this.spotifyFetch('/me/player/currently-playing').catch(() => null); - return { - playing: true, - track: now?.item ? this.formatTrack(now.item) : null, - }; - }, - }, - // ── spotify_pause ────────────────────────────────────────────────────── - { - name: 'spotify_pause', - description: 'Pause Spotify playback', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - await this.spotifyFetch('/me/player/pause', { method: 'PUT' }); - return { paused: true }; - }, - }, - // ── spotify_skip ─────────────────────────────────────────────────────── - { - name: 'spotify_skip', - description: 'Skip to next or previous track', - inputSchema: { - type: 'object', - properties: { - direction: { - type: 'string', - enum: ['next', 'previous'], - description: 'next or previous (default: next)', - }, - }, - }, - handler: async ({ direction = 'next' }) => { - const endpoint = direction === 'previous' ? '/me/player/previous' : '/me/player/next'; - await this.spotifyFetch(endpoint, { method: 'POST' }); - await new Promise((r) => setTimeout(r, 600)); - const now = await this.spotifyFetch('/me/player/currently-playing').catch(() => null); - return { - skipped: direction, - now: now?.item ? this.formatTrack(now.item) : null, - }; - }, - }, - // ── spotify_volume ───────────────────────────────────────────────────── - { - name: 'spotify_volume', - description: 'Set Spotify playback volume (0–100)', - inputSchema: { - type: 'object', - properties: { - volume: { type: 'number', description: 'Volume 0–100' }, - }, - required: ['volume'], - }, - handler: async ({ volume }) => { - const vol = Math.max(0, Math.min(100, Math.round(volume))); - await this.spotifyFetch('/me/player/volume', { - method: 'PUT', - params: { volume_percent: String(vol) }, - }); - return { volume: vol }; - }, - }, - // ── spotify_queue ────────────────────────────────────────────────────── - { - name: 'spotify_queue', - description: 'Add a track to the playback queue by URI or search query', - inputSchema: { - type: 'object', - properties: { - uri: { type: 'string', description: 'Spotify track URI' }, - query: { type: 'string', description: 'Search for and queue this track' }, - }, - }, - handler: async ({ uri, query }) => { - let trackUri = uri; - if (!trackUri && query) { - const results = await this.spotifyFetch('/search', { - params: { q: query, type: 'track', limit: '1' }, - }); - const track = results.tracks?.items?.[0]; - if (!track) - return { error: `No track found for: "${query}"` }; - trackUri = track.uri; - } - if (!trackUri) - return { error: 'Provide uri or query.' }; - await this.spotifyFetch('/me/player/queue', { - method: 'POST', - params: { uri: trackUri }, - }); - return { queued: true, uri: trackUri }; - }, - }, - // ── spotify_playlists ────────────────────────────────────────────────── - { - name: 'spotify_playlists', - description: "Get the current user's playlists", - inputSchema: { - type: 'object', - properties: { - limit: { type: 'number', description: 'Max playlists to return (default: 20)' }, - }, - }, - handler: async ({ limit = 20 }) => { - const data = await this.spotifyFetch('/me/playlists', { - params: { limit: String(Math.min(limit, 50)) }, - }); - return { - count: data.items?.length ?? 0, - total: data.total ?? 0, - playlists: (data.items ?? []).map(this.formatPlaylist.bind(this)), - }; - }, - }, - // ── spotify_playlist_tracks ──────────────────────────────────────────── - { - name: 'spotify_playlist_tracks', - description: 'Get tracks from a playlist', - inputSchema: { - type: 'object', - properties: { - playlistId: { type: 'string', description: 'Spotify playlist ID' }, - limit: { type: 'number', description: 'Max tracks (default: 20)' }, - }, - required: ['playlistId'], - }, - handler: async ({ playlistId, limit = 20 }) => { - const data = await this.spotifyFetch(`/playlists/${playlistId}/tracks`, { - params: { - limit: String(Math.min(limit, 100)), - fields: 'items(track(id,name,artists,album,duration_ms,popularity,uri,external_urls)),total', - }, - }); - return { - total: data.total ?? 0, - tracks: (data.items ?? []) - .filter((i) => i.track) - .map((i) => this.formatTrack(i.track)), - }; - }, - }, - // ── spotify_top_tracks ───────────────────────────────────────────────── - { - name: 'spotify_top_tracks', - description: "Get the user's top tracks or artists", - inputSchema: { - type: 'object', - properties: { - type: { - type: 'string', - enum: ['tracks', 'artists'], - description: 'tracks or artists (default: tracks)', - }, - timeRange: { - type: 'string', - enum: ['short_term', 'medium_term', 'long_term'], - description: 'short_term=4wk, medium_term=6mo, long_term=all time (default: medium_term)', - }, - limit: { type: 'number', description: 'Max results (default: 10)' }, - }, - }, - handler: async ({ type = 'tracks', timeRange = 'medium_term', limit = 10 }) => { - const data = await this.spotifyFetch(`/me/top/${type}`, { - params: { time_range: timeRange, limit: String(Math.min(limit, 50)) }, - }); - if (type === 'artists') { - return { - count: data.items?.length ?? 0, - timeRange, - artists: (data.items ?? []).map((a) => ({ - rank: data.items.indexOf(a) + 1, - name: a.name, - genres: a.genres?.slice(0, 3) ?? [], - popularity: a.popularity, - url: a.external_urls?.spotify ?? '', - })), - }; - } - return { - count: data.items?.length ?? 0, - timeRange, - tracks: (data.items ?? []).map((t, i) => ({ - rank: i + 1, - ...this.formatTrack(t), - })), - }; - }, - }, - // ── spotify_recommendations ──────────────────────────────────────────── - { - name: 'spotify_recommendations', - description: 'Get personalized track recommendations based on seed tracks, artists, or genres', - inputSchema: { - type: 'object', - properties: { - seedTracks: { - type: 'array', - items: { type: 'string' }, - description: 'Up to 2 Spotify track IDs as seeds', - }, - seedArtists: { - type: 'array', - items: { type: 'string' }, - description: 'Up to 2 Spotify artist IDs as seeds', - }, - seedGenres: { - type: 'array', - items: { type: 'string' }, - description: 'Up to 2 genre strings (e.g. "pop", "hip-hop", "indie")', - }, - limit: { type: 'number', description: 'Number of recommendations (default: 10)' }, - energy: { type: 'number', description: 'Target energy 0.0–1.0' }, - valence: { type: 'number', description: 'Target positivity/mood 0.0–1.0' }, - tempo: { type: 'number', description: 'Target BPM' }, - }, - }, - handler: async ({ seedTracks = [], seedArtists = [], seedGenres = [], limit = 10, energy, valence, tempo, }) => { - const totalSeeds = seedTracks.length + seedArtists.length + seedGenres.length; - if (totalSeeds === 0) { - // Use user's current top track as seed - const top = await this.spotifyFetch('/me/top/tracks', { - params: { limit: '1', time_range: 'short_term' }, - }); - const topTrack = top.items?.[0]; - if (topTrack) - seedTracks = [topTrack.id]; - else - return { error: 'Provide at least one seed (track, artist, or genre).' }; - } - const params = { - limit: String(Math.min(limit, 100)), - }; - if (seedTracks.length) - params.seed_tracks = seedTracks.slice(0, 2).join(','); - if (seedArtists.length) - params.seed_artists = seedArtists.slice(0, 2).join(','); - if (seedGenres.length) - params.seed_genres = seedGenres.slice(0, 2).join(','); - if (energy !== undefined) - params.target_energy = String(energy); - if (valence !== undefined) - params.target_valence = String(valence); - if (tempo !== undefined) - params.target_tempo = String(tempo); - const data = await this.spotifyFetch('/recommendations', { params }); - return { - count: data.tracks?.length ?? 0, - tracks: (data.tracks ?? []).map(this.formatTrack.bind(this)), - }; - }, - }, - // ── spotify_devices ──────────────────────────────────────────────────── - { - name: 'spotify_devices', - description: 'List available Spotify playback devices', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const data = await this.spotifyFetch('/me/player/devices'); - return { - count: data.devices?.length ?? 0, - devices: (data.devices ?? []).map((d) => ({ - id: d.id, - name: d.name, - type: d.type, - active: d.is_active, - volume: d.volume_percent, - restricted: d.is_restricted, - })), - }; - }, - }, - // ── spotify_shuffle ──────────────────────────────────────────────────── - { - name: 'spotify_shuffle', - description: 'Toggle shuffle on or off', - inputSchema: { - type: 'object', - properties: { - state: { type: 'boolean', description: 'true = shuffle on, false = shuffle off' }, - }, - required: ['state'], - }, - handler: async ({ state }) => { - await this.spotifyFetch('/me/player/shuffle', { - method: 'PUT', - params: { state: String(state) }, - }); - return { shuffle: state }; - }, - }, - // ── spotify_recently_played ──────────────────────────────────────────── - { - name: 'spotify_recently_played', - description: "Get the user's recently played tracks", - inputSchema: { - type: 'object', - properties: { - limit: { type: 'number', description: 'Number of tracks (default: 10, max: 50)' }, - }, - }, - handler: async ({ limit = 10 }) => { - const data = await this.spotifyFetch('/me/player/recently-played', { - params: { limit: String(Math.min(limit, 50)) }, - }); - return { - count: data.items?.length ?? 0, - tracks: (data.items ?? []).map((i) => ({ - ...this.formatTrack(i.track), - playedAt: i.played_at, - })), - }; - }, - }, - ]; - } -} diff --git a/plugins/spotify/package.json b/plugins/spotify/package.json deleted file mode 100644 index 04b269c..0000000 --- a/plugins/spotify/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@conductor-plugins/spotify", - "version": "1.0.0", - "type": "module", - "main": "dist/spotify.js", - "scripts": { - "build": "tsc", - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "typescript": "^5.0.0", - "@types/node": "^20.0.0" - } -} diff --git a/plugins/spotify/src/index.ts b/plugins/spotify/src/index.ts deleted file mode 100644 index a254f2e..0000000 --- a/plugins/spotify/src/index.ts +++ /dev/null @@ -1,700 +0,0 @@ -/** - * Spotify Plugin — TheAlxLabs / Conductor - * - * Full Spotify control: playback, search, playlists, queue, recommendations. - * Uses Spotify Web API with OAuth 2.0 (Authorization Code + PKCE). - * - * Setup: - * 1. https://developer.spotify.com/dashboard → Create App - * 2. Add redirect URI: http://localhost:4839/spotify/callback - * 3. Copy Client ID - * 4. Run: conductor plugins auth spotify - * (opens browser for one-click OAuth — no client secret needed with PKCE) - * - * Keychain entries: spotify/access_token, spotify/refresh_token, spotify/client_id - * - * Auto-refreshes expired tokens transparently. - */ - -// ── Inlined types (no external dependencies) ───────────────────────────────── - -interface PluginConfigField { - key: string; label: string; type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; secret?: boolean; service?: string; description?: string; options?: string[]; -} -interface PluginConfigSchema { fields: PluginConfigField[]; setupInstructions?: string; } -interface PluginTool { - name: string; description: string; inputSchema: Record; - handler: (input: Record) => Promise; requiresApproval?: boolean; -} -interface Plugin { - name: string; description: string; version: string; configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; isConfigured(): boolean; - getTools(): PluginTool[]; getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { getConfig(): ConfigManagerLike; } -class Keychain { - constructor(private dir: string) {} - async get(service: string, account: string): Promise { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service: string, _account: string, _value: string): Promise {} - async delete(_service: string, _account: string): Promise {} -} - -import crypto from 'crypto'; -import fs from 'fs/promises'; -import http from 'http'; -import path from 'path'; - -const SPOTIFY_BASE = 'https://api.spotify.com/v1'; -const SPOTIFY_AUTH = 'https://accounts.spotify.com'; -const SCOPES = [ - 'user-read-playback-state', - 'user-modify-playback-state', - 'user-read-currently-playing', - 'playlist-read-private', - 'playlist-read-collaborative', - 'playlist-modify-public', - 'playlist-modify-private', - 'user-library-read', - 'user-library-modify', - 'user-top-read', - 'user-read-recently-played', - 'user-follow-read', -].join(' '); - -export class SpotifyPlugin implements Plugin { - name = 'spotify'; - description = - 'Full Spotify control — playback, search, playlists, queue, recommendations, top tracks'; - version = '1.0.0'; - - configSchema = { - fields: [ - { - key: 'client_id', - label: 'Spotify Client ID', - type: 'string' as const, - required: true, - secret: false - }, - { - key: 'client_secret', - label: 'Spotify Client Secret', - type: 'password' as const, - required: true, - secret: true, - service: 'spotify' - } - ], - setupInstructions: 'Create an app in the Spotify Developer Dashboard. Set Redirect URI to http://localhost:8888/callback' - }; - - private keychain!: Keychain; - private configDir!: string; - - async initialize(conductor: Conductor): Promise { - this.configDir = conductor.getConfig().getConfigDir(); - this.keychain = new Keychain(this.configDir); - } - - isConfigured(): boolean { - return true; - } - - // ── Auth helpers ──────────────────────────────────────────────────────────── - - private async getToken(): Promise { - let token = await this.keychain.get('spotify', 'access_token'); - if (!token) { - throw new Error( - 'Spotify not authenticated.\n' + - 'Run: conductor plugins auth spotify\n' + - 'Or manually set: conductor plugins config spotify access_token ' - ); - } - return token; - } - - /** Refresh the access token using the stored refresh token. */ - private async refreshToken(): Promise { - const refreshToken = await this.keychain.get('spotify', 'refresh_token'); - const clientId = await this.keychain.get('spotify', 'client_id'); - if (!refreshToken || !clientId) return null; - - const res = await fetch(`${SPOTIFY_AUTH}/api/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_id: clientId, - }), - }); - if (!res.ok) return null; - const data = (await res.json()) as any; - if (data.access_token) { - await this.keychain.set('spotify', 'access_token', data.access_token); - if (data.refresh_token) { - await this.keychain.set('spotify', 'refresh_token', data.refresh_token); - } - return data.access_token; - } - return null; - } - - // ── API fetch with auto-refresh ───────────────────────────────────────────── - - private async spotifyFetch( - path: string, - options: { method?: string; body?: any; params?: Record } = {}, - retry = true - ): Promise { - const token = await this.getToken(); - const url = new URL(`${SPOTIFY_BASE}${path}`); - if (options.params) { - for (const [k, v] of Object.entries(options.params)) url.searchParams.set(k, v); - } - - const res = await fetch(url.toString(), { - method: options.method ?? 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: options.body ? JSON.stringify(options.body) : undefined, - }); - - // Auto-refresh on 401 - if (res.status === 401 && retry) { - const newToken = await this.refreshToken(); - if (newToken) { - return this.spotifyFetch(path, options, false); - } - throw new Error('Spotify token expired and refresh failed. Run: conductor plugins auth spotify'); - } - - if (res.status === 204 || res.status === 202) return {}; - if (!res.ok) { - const err = (await res.json().catch(() => ({}))) as any; - throw new Error(`Spotify API ${res.status}: ${err.error?.message ?? res.statusText}`); - } - return res.json(); - } - - // ── Formatting helpers ────────────────────────────────────────────────────── - - private formatTrack(t: any) { - return { - id: t.id, - name: t.name, - artists: (t.artists ?? []).map((a: any) => a.name).join(', '), - album: t.album?.name ?? '', - duration: this.msToTime(t.duration_ms), - popularity: t.popularity ?? 0, - uri: t.uri, - url: t.external_urls?.spotify ?? '', - explicit: t.explicit ?? false, - }; - } - - private formatPlaylist(p: any) { - return { - id: p.id, - name: p.name, - description: p.description ?? '', - owner: p.owner?.display_name ?? '', - tracks: p.tracks?.total ?? 0, - public: p.public ?? false, - url: p.external_urls?.spotify ?? '', - uri: p.uri, - }; - } - - private msToTime(ms: number): string { - const total = Math.floor(ms / 1000); - const m = Math.floor(total / 60); - const s = total % 60; - return `${m}:${s.toString().padStart(2, '0')}`; - } - - // ── Tools ─────────────────────────────────────────────────────────────────── - - getTools(): PluginTool[] { - return [ - // ── spotify_now_playing ──────────────────────────────────────────────── - { - name: 'spotify_now_playing', - description: 'Get the currently playing track and playback state', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const data = await this.spotifyFetch('/me/player'); - if (!data || !data.item) return { playing: false, message: 'Nothing currently playing.' }; - return { - playing: data.is_playing, - track: this.formatTrack(data.item), - device: { - name: data.device?.name ?? 'Unknown', - type: data.device?.type ?? '', - volume: data.device?.volume_percent ?? 0, - }, - shuffle: data.shuffle_state, - repeat: data.repeat_state, - progress: this.msToTime(data.progress_ms ?? 0), - context: data.context?.type ?? null, - }; - }, - }, - - // ── spotify_search ───────────────────────────────────────────────────── - { - name: 'spotify_search', - description: 'Search Spotify for tracks, albums, artists, or playlists', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Search query' }, - type: { - type: 'string', - enum: ['track', 'album', 'artist', 'playlist'], - description: 'Type of result (default: track)', - }, - limit: { type: 'number', description: 'Max results (default: 10)' }, - }, - required: ['query'], - }, - handler: async ({ query, type = 'track', limit = 10 }: any) => { - const data = await this.spotifyFetch('/search', { - params: { q: query, type, limit: String(Math.min(limit, 50)) }, - }); - - if (type === 'track') { - return { - count: data.tracks?.items?.length ?? 0, - tracks: (data.tracks?.items ?? []).map(this.formatTrack.bind(this)), - }; - } - if (type === 'artist') { - return { - count: data.artists?.items?.length ?? 0, - artists: (data.artists?.items ?? []).map((a: any) => ({ - id: a.id, - name: a.name, - genres: a.genres ?? [], - followers: a.followers?.total ?? 0, - popularity: a.popularity ?? 0, - uri: a.uri, - url: a.external_urls?.spotify ?? '', - })), - }; - } - if (type === 'album') { - return { - count: data.albums?.items?.length ?? 0, - albums: (data.albums?.items ?? []).map((a: any) => ({ - id: a.id, - name: a.name, - artists: (a.artists ?? []).map((x: any) => x.name).join(', '), - releaseDate: a.release_date ?? '', - tracks: a.total_tracks ?? 0, - uri: a.uri, - url: a.external_urls?.spotify ?? '', - })), - }; - } - if (type === 'playlist') { - return { - count: data.playlists?.items?.length ?? 0, - playlists: (data.playlists?.items ?? []).filter(Boolean).map(this.formatPlaylist.bind(this)), - }; - } - return data; - }, - }, - - // ── spotify_play ─────────────────────────────────────────────────────── - { - name: 'spotify_play', - description: - 'Start or resume playback. Can play a specific track, album, playlist, or artist by URI or search query.', - inputSchema: { - type: 'object', - properties: { - uri: { - type: 'string', - description: 'Spotify URI (e.g. spotify:track:xxx, spotify:album:xxx)', - }, - query: { - type: 'string', - description: 'Search for and play this track/artist/album (used if no URI given)', - }, - deviceId: { type: 'string', description: 'Target device ID (optional)' }, - }, - }, - handler: async ({ uri, query, deviceId }: any) => { - let playUri = uri; - - // Auto-search if only query given - if (!playUri && query) { - const results = await this.spotifyFetch('/search', { - params: { q: query, type: 'track', limit: '1' }, - }); - const track = results.tracks?.items?.[0]; - if (!track) return { error: `No track found for: "${query}"` }; - playUri = track.uri; - } - - const body: any = {}; - if (playUri) { - if (playUri.startsWith('spotify:track:')) { - body.uris = [playUri]; - } else { - body.context_uri = playUri; - } - } - - const params: Record = {}; - if (deviceId) params.device_id = deviceId; - - await this.spotifyFetch('/me/player/play', { - method: 'PUT', - body, - params, - }); - - // Fetch what's now playing to confirm - await new Promise((r) => setTimeout(r, 500)); - const now = await this.spotifyFetch('/me/player/currently-playing').catch(() => null); - return { - playing: true, - track: now?.item ? this.formatTrack(now.item) : null, - }; - }, - }, - - // ── spotify_pause ────────────────────────────────────────────────────── - { - name: 'spotify_pause', - description: 'Pause Spotify playback', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - await this.spotifyFetch('/me/player/pause', { method: 'PUT' }); - return { paused: true }; - }, - }, - - // ── spotify_skip ─────────────────────────────────────────────────────── - { - name: 'spotify_skip', - description: 'Skip to next or previous track', - inputSchema: { - type: 'object', - properties: { - direction: { - type: 'string', - enum: ['next', 'previous'], - description: 'next or previous (default: next)', - }, - }, - }, - handler: async ({ direction = 'next' }: any) => { - const endpoint = direction === 'previous' ? '/me/player/previous' : '/me/player/next'; - await this.spotifyFetch(endpoint, { method: 'POST' }); - await new Promise((r) => setTimeout(r, 600)); - const now = await this.spotifyFetch('/me/player/currently-playing').catch(() => null); - return { - skipped: direction, - now: now?.item ? this.formatTrack(now.item) : null, - }; - }, - }, - - // ── spotify_volume ───────────────────────────────────────────────────── - { - name: 'spotify_volume', - description: 'Set Spotify playback volume (0–100)', - inputSchema: { - type: 'object', - properties: { - volume: { type: 'number', description: 'Volume 0–100' }, - }, - required: ['volume'], - }, - handler: async ({ volume }: any) => { - const vol = Math.max(0, Math.min(100, Math.round(volume))); - await this.spotifyFetch('/me/player/volume', { - method: 'PUT', - params: { volume_percent: String(vol) }, - }); - return { volume: vol }; - }, - }, - - // ── spotify_queue ────────────────────────────────────────────────────── - { - name: 'spotify_queue', - description: 'Add a track to the playback queue by URI or search query', - inputSchema: { - type: 'object', - properties: { - uri: { type: 'string', description: 'Spotify track URI' }, - query: { type: 'string', description: 'Search for and queue this track' }, - }, - }, - handler: async ({ uri, query }: any) => { - let trackUri = uri; - if (!trackUri && query) { - const results = await this.spotifyFetch('/search', { - params: { q: query, type: 'track', limit: '1' }, - }); - const track = results.tracks?.items?.[0]; - if (!track) return { error: `No track found for: "${query}"` }; - trackUri = track.uri; - } - if (!trackUri) return { error: 'Provide uri or query.' }; - await this.spotifyFetch('/me/player/queue', { - method: 'POST', - params: { uri: trackUri }, - }); - return { queued: true, uri: trackUri }; - }, - }, - - // ── spotify_playlists ────────────────────────────────────────────────── - { - name: 'spotify_playlists', - description: "Get the current user's playlists", - inputSchema: { - type: 'object', - properties: { - limit: { type: 'number', description: 'Max playlists to return (default: 20)' }, - }, - }, - handler: async ({ limit = 20 }: any) => { - const data = await this.spotifyFetch('/me/playlists', { - params: { limit: String(Math.min(limit, 50)) }, - }); - return { - count: data.items?.length ?? 0, - total: data.total ?? 0, - playlists: (data.items ?? []).map(this.formatPlaylist.bind(this)), - }; - }, - }, - - // ── spotify_playlist_tracks ──────────────────────────────────────────── - { - name: 'spotify_playlist_tracks', - description: 'Get tracks from a playlist', - inputSchema: { - type: 'object', - properties: { - playlistId: { type: 'string', description: 'Spotify playlist ID' }, - limit: { type: 'number', description: 'Max tracks (default: 20)' }, - }, - required: ['playlistId'], - }, - handler: async ({ playlistId, limit = 20 }: any) => { - const data = await this.spotifyFetch(`/playlists/${playlistId}/tracks`, { - params: { - limit: String(Math.min(limit, 100)), - fields: 'items(track(id,name,artists,album,duration_ms,popularity,uri,external_urls)),total', - }, - }); - return { - total: data.total ?? 0, - tracks: (data.items ?? []) - .filter((i: any) => i.track) - .map((i: any) => this.formatTrack(i.track)), - }; - }, - }, - - // ── spotify_top_tracks ───────────────────────────────────────────────── - { - name: 'spotify_top_tracks', - description: "Get the user's top tracks or artists", - inputSchema: { - type: 'object', - properties: { - type: { - type: 'string', - enum: ['tracks', 'artists'], - description: 'tracks or artists (default: tracks)', - }, - timeRange: { - type: 'string', - enum: ['short_term', 'medium_term', 'long_term'], - description: 'short_term=4wk, medium_term=6mo, long_term=all time (default: medium_term)', - }, - limit: { type: 'number', description: 'Max results (default: 10)' }, - }, - }, - handler: async ({ type = 'tracks', timeRange = 'medium_term', limit = 10 }: any) => { - const data = await this.spotifyFetch(`/me/top/${type}`, { - params: { time_range: timeRange, limit: String(Math.min(limit, 50)) }, - }); - if (type === 'artists') { - return { - count: data.items?.length ?? 0, - timeRange, - artists: (data.items ?? []).map((a: any) => ({ - rank: (data.items as any[]).indexOf(a) + 1, - name: a.name, - genres: a.genres?.slice(0, 3) ?? [], - popularity: a.popularity, - url: a.external_urls?.spotify ?? '', - })), - }; - } - return { - count: data.items?.length ?? 0, - timeRange, - tracks: (data.items ?? []).map((t: any, i: number) => ({ - rank: i + 1, - ...this.formatTrack(t), - })), - }; - }, - }, - - // ── spotify_recommendations ──────────────────────────────────────────── - { - name: 'spotify_recommendations', - description: - 'Get personalized track recommendations based on seed tracks, artists, or genres', - inputSchema: { - type: 'object', - properties: { - seedTracks: { - type: 'array', - items: { type: 'string' }, - description: 'Up to 2 Spotify track IDs as seeds', - }, - seedArtists: { - type: 'array', - items: { type: 'string' }, - description: 'Up to 2 Spotify artist IDs as seeds', - }, - seedGenres: { - type: 'array', - items: { type: 'string' }, - description: 'Up to 2 genre strings (e.g. "pop", "hip-hop", "indie")', - }, - limit: { type: 'number', description: 'Number of recommendations (default: 10)' }, - energy: { type: 'number', description: 'Target energy 0.0–1.0' }, - valence: { type: 'number', description: 'Target positivity/mood 0.0–1.0' }, - tempo: { type: 'number', description: 'Target BPM' }, - }, - }, - handler: async ({ - seedTracks = [], - seedArtists = [], - seedGenres = [], - limit = 10, - energy, - valence, - tempo, - }: any) => { - const totalSeeds = seedTracks.length + seedArtists.length + seedGenres.length; - if (totalSeeds === 0) { - // Use user's current top track as seed - const top = await this.spotifyFetch('/me/top/tracks', { - params: { limit: '1', time_range: 'short_term' }, - }); - const topTrack = top.items?.[0]; - if (topTrack) seedTracks = [topTrack.id]; - else return { error: 'Provide at least one seed (track, artist, or genre).' }; - } - - const params: Record = { - limit: String(Math.min(limit, 100)), - }; - if (seedTracks.length) params.seed_tracks = seedTracks.slice(0, 2).join(','); - if (seedArtists.length) params.seed_artists = seedArtists.slice(0, 2).join(','); - if (seedGenres.length) params.seed_genres = seedGenres.slice(0, 2).join(','); - if (energy !== undefined) params.target_energy = String(energy); - if (valence !== undefined) params.target_valence = String(valence); - if (tempo !== undefined) params.target_tempo = String(tempo); - - const data = await this.spotifyFetch('/recommendations', { params }); - return { - count: data.tracks?.length ?? 0, - tracks: (data.tracks ?? []).map(this.formatTrack.bind(this)), - }; - }, - }, - - // ── spotify_devices ──────────────────────────────────────────────────── - { - name: 'spotify_devices', - description: 'List available Spotify playback devices', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const data = await this.spotifyFetch('/me/player/devices'); - return { - count: data.devices?.length ?? 0, - devices: (data.devices ?? []).map((d: any) => ({ - id: d.id, - name: d.name, - type: d.type, - active: d.is_active, - volume: d.volume_percent, - restricted: d.is_restricted, - })), - }; - }, - }, - - // ── spotify_shuffle ──────────────────────────────────────────────────── - { - name: 'spotify_shuffle', - description: 'Toggle shuffle on or off', - inputSchema: { - type: 'object', - properties: { - state: { type: 'boolean', description: 'true = shuffle on, false = shuffle off' }, - }, - required: ['state'], - }, - handler: async ({ state }: any) => { - await this.spotifyFetch('/me/player/shuffle', { - method: 'PUT', - params: { state: String(state) }, - }); - return { shuffle: state }; - }, - }, - - // ── spotify_recently_played ──────────────────────────────────────────── - { - name: 'spotify_recently_played', - description: "Get the user's recently played tracks", - inputSchema: { - type: 'object', - properties: { - limit: { type: 'number', description: 'Number of tracks (default: 10, max: 50)' }, - }, - }, - handler: async ({ limit = 10 }: any) => { - const data = await this.spotifyFetch('/me/player/recently-played', { - params: { limit: String(Math.min(limit, 50)) }, - }); - return { - count: data.items?.length ?? 0, - tracks: (data.items ?? []).map((i: any) => ({ - ...this.formatTrack(i.track), - playedAt: i.played_at, - })), - }; - }, - }, - ]; - } -} diff --git a/plugins/spotify/tsconfig.json b/plugins/spotify/tsconfig.json deleted file mode 100644 index dce5a7d..0000000 --- a/plugins/spotify/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "skipLibCheck": true, - "declaration": true - }, - "include": ["src/**/*"] -} diff --git a/plugins/stripe/dist/index.d.ts b/plugins/stripe/dist/index.d.ts deleted file mode 100644 index 4a474ec..0000000 --- a/plugins/stripe/dist/index.d.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Stripe Plugin — Conductor - * Payments, customers, subscriptions, invoices, and more via Stripe API. - */ -interface PluginConfigField { - key: string; - label: string; - type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; - secret?: boolean; - service?: string; - description?: string; - options?: string[]; -} -interface PluginConfigSchema { - fields: PluginConfigField[]; - setupInstructions?: string; -} -interface PluginTool { - name: string; - description: string; - inputSchema: Record; - handler: (input: Record) => Promise; - requiresApproval?: boolean; -} -interface Plugin { - name: string; - description: string; - version: string; - configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - getTools(): PluginTool[]; - getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; - set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { - getConfig(): ConfigManagerLike; -} -export declare class StripePlugin implements Plugin { - name: string; - description: string; - version: string; - private keychain; - configSchema: PluginConfigSchema; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - private getKey; - private stripeFetch; - private toFormData; - getTools(): PluginTool[]; -} -export {}; diff --git a/plugins/stripe/dist/index.js b/plugins/stripe/dist/index.js deleted file mode 100644 index b8589b9..0000000 --- a/plugins/stripe/dist/index.js +++ /dev/null @@ -1,276 +0,0 @@ -/** - * Stripe Plugin — Conductor - * Payments, customers, subscriptions, invoices, and more via Stripe API. - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const k = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[k] ?? null; - } - async set(_s, _a, _v) { } - async delete(_s, _a) { } -} -const STRIPE_API = 'https://api.stripe.com/v1'; -export class StripePlugin { - name = 'stripe'; - description = 'Stripe payments — customers, payments, subscriptions, invoices, products, balance'; - version = '1.0.0'; - keychain; - configSchema = { - fields: [ - { key: 'secret_key', label: 'Stripe Secret Key', type: 'password', required: true, secret: true, service: 'stripe', description: 'Stripe secret key (sk_live_... or sk_test_...)' }, - ], - setupInstructions: 'Find your secret key at https://dashboard.stripe.com/apikeys. Use test keys (sk_test_...) for development.', - }; - async initialize(conductor) { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - isConfigured() { return true; } - async getKey() { - const key = await this.keychain.get('stripe', 'secret_key'); - if (!key) - throw new Error('Stripe secret key not configured. Run: conductor stripe setup'); - return key; - } - async stripeFetch(path, options = {}) { - const key = await this.getKey(); - const res = await fetch(`${STRIPE_API}${path}`, { - ...options, - headers: { - Authorization: `Bearer ${key}`, - 'Content-Type': 'application/x-www-form-urlencoded', - ...(options.headers ?? {}), - }, - }); - const text = await res.text(); - const data = JSON.parse(text); - if (!res.ok) - throw new Error(`Stripe error: ${data.error?.message ?? text}`); - return data; - } - toFormData(obj) { - const parts = []; - const encode = (key, val) => { - if (val === undefined || val === null) - return; - if (typeof val === 'object' && !Array.isArray(val)) { - Object.entries(val).forEach(([k, v]) => encode(`${key}[${k}]`, v)); - } - else if (Array.isArray(val)) { - val.forEach((v, i) => encode(`${key}[${i}]`, v)); - } - else { - parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(val))}`); - } - }; - Object.entries(obj).forEach(([k, v]) => encode(k, v)); - return parts.join('&'); - } - getTools() { - return [ - { - name: 'stripe_customers', - description: 'List Stripe customers, optionally filtered by email', - inputSchema: { - type: 'object', - properties: { - email: { type: 'string', description: 'Filter by exact email (optional)' }, - limit: { type: 'number', description: 'Max customers to return (default 20, max 100)' }, - }, - }, - handler: async ({ email, limit = 20 }) => { - const params = new URLSearchParams({ limit: String(Math.min(limit, 100)) }); - if (email) - params.set('email', email); - const data = await this.stripeFetch(`/customers?${params}`); - return { - count: data.data.length, has_more: data.has_more, - customers: data.data.map((c) => ({ - id: c.id, email: c.email, name: c.name, phone: c.phone, - balance: c.balance, currency: c.currency, created: new Date(c.created * 1000).toISOString(), - subscriptions: c.subscriptions?.total_count ?? 0, - })), - }; - }, - }, - { - name: 'stripe_customer', - description: 'Get a Stripe customer by ID', - inputSchema: { - type: 'object', - properties: { id: { type: 'string', description: 'Customer ID (cus_...)' } }, - required: ['id'], - }, - handler: async ({ id }) => { - return await this.stripeFetch(`/customers/${id}`); - }, - }, - { - name: 'stripe_payments', - description: 'List recent payment intents', - inputSchema: { - type: 'object', - properties: { - limit: { type: 'number', description: 'Max payments to return (default 20, max 100)' }, - customer: { type: 'string', description: 'Filter by customer ID (optional)' }, - }, - }, - handler: async ({ limit = 20, customer }) => { - const params = new URLSearchParams({ limit: String(Math.min(limit, 100)) }); - if (customer) - params.set('customer', customer); - const data = await this.stripeFetch(`/payment_intents?${params}`); - return { - count: data.data.length, has_more: data.has_more, - payments: data.data.map((p) => ({ - id: p.id, amount: p.amount, currency: p.currency, status: p.status, - customer: p.customer, description: p.description, - created: new Date(p.created * 1000).toISOString(), - })), - }; - }, - }, - { - name: 'stripe_payment', - description: 'Get a single payment intent by ID', - inputSchema: { - type: 'object', - properties: { id: { type: 'string', description: 'Payment intent ID (pi_...)' } }, - required: ['id'], - }, - handler: async ({ id }) => { - return await this.stripeFetch(`/payment_intents/${id}`); - }, - }, - { - name: 'stripe_subscriptions', - description: 'List Stripe subscriptions', - inputSchema: { - type: 'object', - properties: { - customer: { type: 'string', description: 'Filter by customer ID (optional)' }, - status: { type: 'string', description: 'Filter by status: active, canceled, past_due, trialing, all (optional)' }, - limit: { type: 'number', description: 'Max subscriptions to return (default 20)' }, - }, - }, - handler: async ({ customer, status, limit = 20 }) => { - const params = new URLSearchParams({ limit: String(limit) }); - if (customer) - params.set('customer', customer); - if (status) - params.set('status', status); - const data = await this.stripeFetch(`/subscriptions?${params}`); - return { - count: data.data.length, - subscriptions: data.data.map((s) => ({ - id: s.id, status: s.status, customer: s.customer, - current_period_end: new Date(s.current_period_end * 1000).toISOString(), - items: s.items.data.map((i) => ({ price_id: i.price.id, quantity: i.quantity })), - })), - }; - }, - }, - { - name: 'stripe_invoices', - description: 'List Stripe invoices', - inputSchema: { - type: 'object', - properties: { - customer: { type: 'string', description: 'Filter by customer ID (optional)' }, - status: { type: 'string', description: 'Filter by status: draft, open, paid, void, uncollectible (optional)' }, - limit: { type: 'number', description: 'Max invoices to return (default 20)' }, - }, - }, - handler: async ({ customer, status, limit = 20 }) => { - const params = new URLSearchParams({ limit: String(limit) }); - if (customer) - params.set('customer', customer); - if (status) - params.set('status', status); - const data = await this.stripeFetch(`/invoices?${params}`); - return { - count: data.data.length, - invoices: data.data.map((inv) => ({ - id: inv.id, number: inv.number, status: inv.status, - amount_due: inv.amount_due, amount_paid: inv.amount_paid, currency: inv.currency, - customer: inv.customer, customer_email: inv.customer_email, - created: new Date(inv.created * 1000).toISOString(), - due_date: inv.due_date ? new Date(inv.due_date * 1000).toISOString() : null, - hosted_invoice_url: inv.hosted_invoice_url, - })), - }; - }, - }, - { - name: 'stripe_products', - description: 'List Stripe products', - inputSchema: { - type: 'object', - properties: { - active: { type: 'boolean', description: 'Filter by active status (optional)' }, - limit: { type: 'number', description: 'Max products to return (default 20)' }, - }, - }, - handler: async ({ active, limit = 20 }) => { - const params = new URLSearchParams({ limit: String(limit) }); - if (active !== undefined) - params.set('active', String(active)); - const data = await this.stripeFetch(`/products?${params}`); - return { - count: data.data.length, - products: data.data.map((p) => ({ - id: p.id, name: p.name, description: p.description, - active: p.active, created: new Date(p.created * 1000).toISOString(), - })), - }; - }, - }, - { - name: 'stripe_balance', - description: 'Get the current Stripe account balance', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const data = await this.stripeFetch('/balance'); - return { - available: data.available.map((b) => ({ amount: b.amount, currency: b.currency })), - pending: data.pending.map((b) => ({ amount: b.amount, currency: b.currency })), - }; - }, - }, - { - name: 'stripe_refund', - description: 'Issue a refund for a payment intent or charge', - inputSchema: { - type: 'object', - properties: { - payment_intent: { type: 'string', description: 'Payment intent ID to refund (pi_...)' }, - charge: { type: 'string', description: 'Charge ID to refund (ch_...) — alternative to payment_intent' }, - amount: { type: 'number', description: 'Amount to refund in smallest currency unit (optional, refunds full amount if omitted)' }, - reason: { type: 'string', description: 'Reason: duplicate, fraudulent, or requested_by_customer (optional)' }, - }, - }, - requiresApproval: true, - handler: async ({ payment_intent, charge, amount, reason }) => { - const body = {}; - if (payment_intent) - body.payment_intent = payment_intent; - if (charge) - body.charge = charge; - if (amount) - body.amount = amount; - if (reason) - body.reason = reason; - const data = await this.stripeFetch('/refunds', { - method: 'POST', - body: this.toFormData(body), - }); - return { id: data.id, amount: data.amount, currency: data.currency, status: data.status }; - }, - }, - ]; - } -} diff --git a/plugins/stripe/dist/stripe.js b/plugins/stripe/dist/stripe.js deleted file mode 100644 index b8589b9..0000000 --- a/plugins/stripe/dist/stripe.js +++ /dev/null @@ -1,276 +0,0 @@ -/** - * Stripe Plugin — Conductor - * Payments, customers, subscriptions, invoices, and more via Stripe API. - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const k = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[k] ?? null; - } - async set(_s, _a, _v) { } - async delete(_s, _a) { } -} -const STRIPE_API = 'https://api.stripe.com/v1'; -export class StripePlugin { - name = 'stripe'; - description = 'Stripe payments — customers, payments, subscriptions, invoices, products, balance'; - version = '1.0.0'; - keychain; - configSchema = { - fields: [ - { key: 'secret_key', label: 'Stripe Secret Key', type: 'password', required: true, secret: true, service: 'stripe', description: 'Stripe secret key (sk_live_... or sk_test_...)' }, - ], - setupInstructions: 'Find your secret key at https://dashboard.stripe.com/apikeys. Use test keys (sk_test_...) for development.', - }; - async initialize(conductor) { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - isConfigured() { return true; } - async getKey() { - const key = await this.keychain.get('stripe', 'secret_key'); - if (!key) - throw new Error('Stripe secret key not configured. Run: conductor stripe setup'); - return key; - } - async stripeFetch(path, options = {}) { - const key = await this.getKey(); - const res = await fetch(`${STRIPE_API}${path}`, { - ...options, - headers: { - Authorization: `Bearer ${key}`, - 'Content-Type': 'application/x-www-form-urlencoded', - ...(options.headers ?? {}), - }, - }); - const text = await res.text(); - const data = JSON.parse(text); - if (!res.ok) - throw new Error(`Stripe error: ${data.error?.message ?? text}`); - return data; - } - toFormData(obj) { - const parts = []; - const encode = (key, val) => { - if (val === undefined || val === null) - return; - if (typeof val === 'object' && !Array.isArray(val)) { - Object.entries(val).forEach(([k, v]) => encode(`${key}[${k}]`, v)); - } - else if (Array.isArray(val)) { - val.forEach((v, i) => encode(`${key}[${i}]`, v)); - } - else { - parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(val))}`); - } - }; - Object.entries(obj).forEach(([k, v]) => encode(k, v)); - return parts.join('&'); - } - getTools() { - return [ - { - name: 'stripe_customers', - description: 'List Stripe customers, optionally filtered by email', - inputSchema: { - type: 'object', - properties: { - email: { type: 'string', description: 'Filter by exact email (optional)' }, - limit: { type: 'number', description: 'Max customers to return (default 20, max 100)' }, - }, - }, - handler: async ({ email, limit = 20 }) => { - const params = new URLSearchParams({ limit: String(Math.min(limit, 100)) }); - if (email) - params.set('email', email); - const data = await this.stripeFetch(`/customers?${params}`); - return { - count: data.data.length, has_more: data.has_more, - customers: data.data.map((c) => ({ - id: c.id, email: c.email, name: c.name, phone: c.phone, - balance: c.balance, currency: c.currency, created: new Date(c.created * 1000).toISOString(), - subscriptions: c.subscriptions?.total_count ?? 0, - })), - }; - }, - }, - { - name: 'stripe_customer', - description: 'Get a Stripe customer by ID', - inputSchema: { - type: 'object', - properties: { id: { type: 'string', description: 'Customer ID (cus_...)' } }, - required: ['id'], - }, - handler: async ({ id }) => { - return await this.stripeFetch(`/customers/${id}`); - }, - }, - { - name: 'stripe_payments', - description: 'List recent payment intents', - inputSchema: { - type: 'object', - properties: { - limit: { type: 'number', description: 'Max payments to return (default 20, max 100)' }, - customer: { type: 'string', description: 'Filter by customer ID (optional)' }, - }, - }, - handler: async ({ limit = 20, customer }) => { - const params = new URLSearchParams({ limit: String(Math.min(limit, 100)) }); - if (customer) - params.set('customer', customer); - const data = await this.stripeFetch(`/payment_intents?${params}`); - return { - count: data.data.length, has_more: data.has_more, - payments: data.data.map((p) => ({ - id: p.id, amount: p.amount, currency: p.currency, status: p.status, - customer: p.customer, description: p.description, - created: new Date(p.created * 1000).toISOString(), - })), - }; - }, - }, - { - name: 'stripe_payment', - description: 'Get a single payment intent by ID', - inputSchema: { - type: 'object', - properties: { id: { type: 'string', description: 'Payment intent ID (pi_...)' } }, - required: ['id'], - }, - handler: async ({ id }) => { - return await this.stripeFetch(`/payment_intents/${id}`); - }, - }, - { - name: 'stripe_subscriptions', - description: 'List Stripe subscriptions', - inputSchema: { - type: 'object', - properties: { - customer: { type: 'string', description: 'Filter by customer ID (optional)' }, - status: { type: 'string', description: 'Filter by status: active, canceled, past_due, trialing, all (optional)' }, - limit: { type: 'number', description: 'Max subscriptions to return (default 20)' }, - }, - }, - handler: async ({ customer, status, limit = 20 }) => { - const params = new URLSearchParams({ limit: String(limit) }); - if (customer) - params.set('customer', customer); - if (status) - params.set('status', status); - const data = await this.stripeFetch(`/subscriptions?${params}`); - return { - count: data.data.length, - subscriptions: data.data.map((s) => ({ - id: s.id, status: s.status, customer: s.customer, - current_period_end: new Date(s.current_period_end * 1000).toISOString(), - items: s.items.data.map((i) => ({ price_id: i.price.id, quantity: i.quantity })), - })), - }; - }, - }, - { - name: 'stripe_invoices', - description: 'List Stripe invoices', - inputSchema: { - type: 'object', - properties: { - customer: { type: 'string', description: 'Filter by customer ID (optional)' }, - status: { type: 'string', description: 'Filter by status: draft, open, paid, void, uncollectible (optional)' }, - limit: { type: 'number', description: 'Max invoices to return (default 20)' }, - }, - }, - handler: async ({ customer, status, limit = 20 }) => { - const params = new URLSearchParams({ limit: String(limit) }); - if (customer) - params.set('customer', customer); - if (status) - params.set('status', status); - const data = await this.stripeFetch(`/invoices?${params}`); - return { - count: data.data.length, - invoices: data.data.map((inv) => ({ - id: inv.id, number: inv.number, status: inv.status, - amount_due: inv.amount_due, amount_paid: inv.amount_paid, currency: inv.currency, - customer: inv.customer, customer_email: inv.customer_email, - created: new Date(inv.created * 1000).toISOString(), - due_date: inv.due_date ? new Date(inv.due_date * 1000).toISOString() : null, - hosted_invoice_url: inv.hosted_invoice_url, - })), - }; - }, - }, - { - name: 'stripe_products', - description: 'List Stripe products', - inputSchema: { - type: 'object', - properties: { - active: { type: 'boolean', description: 'Filter by active status (optional)' }, - limit: { type: 'number', description: 'Max products to return (default 20)' }, - }, - }, - handler: async ({ active, limit = 20 }) => { - const params = new URLSearchParams({ limit: String(limit) }); - if (active !== undefined) - params.set('active', String(active)); - const data = await this.stripeFetch(`/products?${params}`); - return { - count: data.data.length, - products: data.data.map((p) => ({ - id: p.id, name: p.name, description: p.description, - active: p.active, created: new Date(p.created * 1000).toISOString(), - })), - }; - }, - }, - { - name: 'stripe_balance', - description: 'Get the current Stripe account balance', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const data = await this.stripeFetch('/balance'); - return { - available: data.available.map((b) => ({ amount: b.amount, currency: b.currency })), - pending: data.pending.map((b) => ({ amount: b.amount, currency: b.currency })), - }; - }, - }, - { - name: 'stripe_refund', - description: 'Issue a refund for a payment intent or charge', - inputSchema: { - type: 'object', - properties: { - payment_intent: { type: 'string', description: 'Payment intent ID to refund (pi_...)' }, - charge: { type: 'string', description: 'Charge ID to refund (ch_...) — alternative to payment_intent' }, - amount: { type: 'number', description: 'Amount to refund in smallest currency unit (optional, refunds full amount if omitted)' }, - reason: { type: 'string', description: 'Reason: duplicate, fraudulent, or requested_by_customer (optional)' }, - }, - }, - requiresApproval: true, - handler: async ({ payment_intent, charge, amount, reason }) => { - const body = {}; - if (payment_intent) - body.payment_intent = payment_intent; - if (charge) - body.charge = charge; - if (amount) - body.amount = amount; - if (reason) - body.reason = reason; - const data = await this.stripeFetch('/refunds', { - method: 'POST', - body: this.toFormData(body), - }); - return { id: data.id, amount: data.amount, currency: data.currency, status: data.status }; - }, - }, - ]; - } -} diff --git a/plugins/stripe/package.json b/plugins/stripe/package.json deleted file mode 100644 index 5cad3b9..0000000 --- a/plugins/stripe/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@conductor-plugins/stripe", - "version": "1.0.0", - "type": "module", - "main": "dist/stripe.js", - "scripts": { "build": "tsc", "typecheck": "tsc --noEmit" }, - "devDependencies": { "typescript": "^5.0.0", "@types/node": "^20.0.0" } -} diff --git a/plugins/stripe/src/index.ts b/plugins/stripe/src/index.ts deleted file mode 100644 index d5af231..0000000 --- a/plugins/stripe/src/index.ts +++ /dev/null @@ -1,288 +0,0 @@ -/** - * Stripe Plugin — Conductor - * Payments, customers, subscriptions, invoices, and more via Stripe API. - */ - -// ── Inlined types ──────────────────────────────────────────────────────────── -interface PluginConfigField { - key: string; label: string; type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; secret?: boolean; service?: string; description?: string; options?: string[]; -} -interface PluginConfigSchema { fields: PluginConfigField[]; setupInstructions?: string; } -interface PluginTool { - name: string; description: string; inputSchema: Record; - handler: (input: Record) => Promise; requiresApproval?: boolean; -} -interface Plugin { - name: string; description: string; version: string; configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; isConfigured(): boolean; - getTools(): PluginTool[]; getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { getConfig(): ConfigManagerLike; } -class Keychain { - constructor(private dir: string) {} - async get(service: string, account: string): Promise { - const k = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[k] ?? null; - } - async set(_s: string, _a: string, _v: string): Promise {} - async delete(_s: string, _a: string): Promise {} -} - -const STRIPE_API = 'https://api.stripe.com/v1'; - -export class StripePlugin implements Plugin { - name = 'stripe'; - description = 'Stripe payments — customers, payments, subscriptions, invoices, products, balance'; - version = '1.0.0'; - - private keychain!: Keychain; - - configSchema: PluginConfigSchema = { - fields: [ - { key: 'secret_key', label: 'Stripe Secret Key', type: 'password', required: true, secret: true, service: 'stripe', description: 'Stripe secret key (sk_live_... or sk_test_...)' }, - ], - setupInstructions: 'Find your secret key at https://dashboard.stripe.com/apikeys. Use test keys (sk_test_...) for development.', - }; - - async initialize(conductor: Conductor): Promise { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - - isConfigured(): boolean { return true; } - - private async getKey(): Promise { - const key = await this.keychain.get('stripe', 'secret_key'); - if (!key) throw new Error('Stripe secret key not configured. Run: conductor stripe setup'); - return key; - } - - private async stripeFetch(path: string, options: RequestInit = {}): Promise { - const key = await this.getKey(); - const res = await fetch(`${STRIPE_API}${path}`, { - ...options, - headers: { - Authorization: `Bearer ${key}`, - 'Content-Type': 'application/x-www-form-urlencoded', - ...(options.headers ?? {}), - }, - }); - const text = await res.text(); - const data = JSON.parse(text); - if (!res.ok) throw new Error(`Stripe error: ${data.error?.message ?? text}`); - return data; - } - - private toFormData(obj: Record): string { - const parts: string[] = []; - const encode = (key: string, val: any) => { - if (val === undefined || val === null) return; - if (typeof val === 'object' && !Array.isArray(val)) { - Object.entries(val).forEach(([k, v]) => encode(`${key}[${k}]`, v)); - } else if (Array.isArray(val)) { - val.forEach((v, i) => encode(`${key}[${i}]`, v)); - } else { - parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(val))}`); - } - }; - Object.entries(obj).forEach(([k, v]) => encode(k, v)); - return parts.join('&'); - } - - getTools(): PluginTool[] { - return [ - { - name: 'stripe_customers', - description: 'List Stripe customers, optionally filtered by email', - inputSchema: { - type: 'object', - properties: { - email: { type: 'string', description: 'Filter by exact email (optional)' }, - limit: { type: 'number', description: 'Max customers to return (default 20, max 100)' }, - }, - }, - handler: async ({ email, limit = 20 }: any) => { - const params = new URLSearchParams({ limit: String(Math.min(limit, 100)) }); - if (email) params.set('email', email); - const data = await this.stripeFetch(`/customers?${params}`); - return { - count: data.data.length, has_more: data.has_more, - customers: data.data.map((c: any) => ({ - id: c.id, email: c.email, name: c.name, phone: c.phone, - balance: c.balance, currency: c.currency, created: new Date(c.created * 1000).toISOString(), - subscriptions: c.subscriptions?.total_count ?? 0, - })), - }; - }, - }, - { - name: 'stripe_customer', - description: 'Get a Stripe customer by ID', - inputSchema: { - type: 'object', - properties: { id: { type: 'string', description: 'Customer ID (cus_...)' } }, - required: ['id'], - }, - handler: async ({ id }: any) => { - return await this.stripeFetch(`/customers/${id}`); - }, - }, - { - name: 'stripe_payments', - description: 'List recent payment intents', - inputSchema: { - type: 'object', - properties: { - limit: { type: 'number', description: 'Max payments to return (default 20, max 100)' }, - customer: { type: 'string', description: 'Filter by customer ID (optional)' }, - }, - }, - handler: async ({ limit = 20, customer }: any) => { - const params = new URLSearchParams({ limit: String(Math.min(limit, 100)) }); - if (customer) params.set('customer', customer); - const data = await this.stripeFetch(`/payment_intents?${params}`); - return { - count: data.data.length, has_more: data.has_more, - payments: data.data.map((p: any) => ({ - id: p.id, amount: p.amount, currency: p.currency, status: p.status, - customer: p.customer, description: p.description, - created: new Date(p.created * 1000).toISOString(), - })), - }; - }, - }, - { - name: 'stripe_payment', - description: 'Get a single payment intent by ID', - inputSchema: { - type: 'object', - properties: { id: { type: 'string', description: 'Payment intent ID (pi_...)' } }, - required: ['id'], - }, - handler: async ({ id }: any) => { - return await this.stripeFetch(`/payment_intents/${id}`); - }, - }, - { - name: 'stripe_subscriptions', - description: 'List Stripe subscriptions', - inputSchema: { - type: 'object', - properties: { - customer: { type: 'string', description: 'Filter by customer ID (optional)' }, - status: { type: 'string', description: 'Filter by status: active, canceled, past_due, trialing, all (optional)' }, - limit: { type: 'number', description: 'Max subscriptions to return (default 20)' }, - }, - }, - handler: async ({ customer, status, limit = 20 }: any) => { - const params = new URLSearchParams({ limit: String(limit) }); - if (customer) params.set('customer', customer); - if (status) params.set('status', status); - const data = await this.stripeFetch(`/subscriptions?${params}`); - return { - count: data.data.length, - subscriptions: data.data.map((s: any) => ({ - id: s.id, status: s.status, customer: s.customer, - current_period_end: new Date(s.current_period_end * 1000).toISOString(), - items: s.items.data.map((i: any) => ({ price_id: i.price.id, quantity: i.quantity })), - })), - }; - }, - }, - { - name: 'stripe_invoices', - description: 'List Stripe invoices', - inputSchema: { - type: 'object', - properties: { - customer: { type: 'string', description: 'Filter by customer ID (optional)' }, - status: { type: 'string', description: 'Filter by status: draft, open, paid, void, uncollectible (optional)' }, - limit: { type: 'number', description: 'Max invoices to return (default 20)' }, - }, - }, - handler: async ({ customer, status, limit = 20 }: any) => { - const params = new URLSearchParams({ limit: String(limit) }); - if (customer) params.set('customer', customer); - if (status) params.set('status', status); - const data = await this.stripeFetch(`/invoices?${params}`); - return { - count: data.data.length, - invoices: data.data.map((inv: any) => ({ - id: inv.id, number: inv.number, status: inv.status, - amount_due: inv.amount_due, amount_paid: inv.amount_paid, currency: inv.currency, - customer: inv.customer, customer_email: inv.customer_email, - created: new Date(inv.created * 1000).toISOString(), - due_date: inv.due_date ? new Date(inv.due_date * 1000).toISOString() : null, - hosted_invoice_url: inv.hosted_invoice_url, - })), - }; - }, - }, - { - name: 'stripe_products', - description: 'List Stripe products', - inputSchema: { - type: 'object', - properties: { - active: { type: 'boolean', description: 'Filter by active status (optional)' }, - limit: { type: 'number', description: 'Max products to return (default 20)' }, - }, - }, - handler: async ({ active, limit = 20 }: any) => { - const params = new URLSearchParams({ limit: String(limit) }); - if (active !== undefined) params.set('active', String(active)); - const data = await this.stripeFetch(`/products?${params}`); - return { - count: data.data.length, - products: data.data.map((p: any) => ({ - id: p.id, name: p.name, description: p.description, - active: p.active, created: new Date(p.created * 1000).toISOString(), - })), - }; - }, - }, - { - name: 'stripe_balance', - description: 'Get the current Stripe account balance', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const data = await this.stripeFetch('/balance'); - return { - available: data.available.map((b: any) => ({ amount: b.amount, currency: b.currency })), - pending: data.pending.map((b: any) => ({ amount: b.amount, currency: b.currency })), - }; - }, - }, - { - name: 'stripe_refund', - description: 'Issue a refund for a payment intent or charge', - inputSchema: { - type: 'object', - properties: { - payment_intent: { type: 'string', description: 'Payment intent ID to refund (pi_...)' }, - charge: { type: 'string', description: 'Charge ID to refund (ch_...) — alternative to payment_intent' }, - amount: { type: 'number', description: 'Amount to refund in smallest currency unit (optional, refunds full amount if omitted)' }, - reason: { type: 'string', description: 'Reason: duplicate, fraudulent, or requested_by_customer (optional)' }, - }, - }, - requiresApproval: true, - handler: async ({ payment_intent, charge, amount, reason }: any) => { - const body: Record = {}; - if (payment_intent) body.payment_intent = payment_intent; - if (charge) body.charge = charge; - if (amount) body.amount = amount; - if (reason) body.reason = reason; - const data = await this.stripeFetch('/refunds', { - method: 'POST', - body: this.toFormData(body), - }); - return { id: data.id, amount: data.amount, currency: data.currency, status: data.status }; - }, - }, - ]; - } -} diff --git a/plugins/stripe/tsconfig.json b/plugins/stripe/tsconfig.json deleted file mode 100644 index 9af0c8a..0000000 --- a/plugins/stripe/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", - "outDir": "dist", "declaration": true, "strict": true, "esModuleInterop": true - }, - "include": ["src/**/*"] -} diff --git a/plugins/supabase/README.md b/plugins/supabase/README.md new file mode 100644 index 0000000..93f9982 --- /dev/null +++ b/plugins/supabase/README.md @@ -0,0 +1,31 @@ +# Supabase Plugin + +Query tables, manage auth users, handle storage, and invoke edge functions in Supabase from Conductor. + +## Setup + +1. Open your project in the [Supabase Dashboard](https://supabase.com/dashboard). +2. Go to **Settings** → **API** to find your Project URL and service role key. +3. Configure the plugin: + +```bash +conductor config set supabase url https://your-project.supabase.co +conductor config set supabase service_role_key YOUR_SERVICE_ROLE_KEY +``` + +> The service role key bypasses Row Level Security. Keep it secret and use it only from server-side contexts. + +## Available Tools + +| Tool | Description | +|------|-------------| +| `supabase_select` | Query rows from a table | +| `supabase_insert` | Insert rows into a table | +| `supabase_update` | Update rows matching a filter | +| `supabase_delete` | Delete rows matching a filter | +| `supabase_list_users` | List auth users | +| `supabase_get_user` | Get a single auth user | +| `supabase_list_buckets` | List storage buckets | +| `supabase_upload_file` | Upload a file to storage | +| `supabase_invoke_function` | Invoke a Supabase Edge Function | +| `supabase_logs` | Read recent realtime/function logs | diff --git a/plugins/trello/README.md b/plugins/trello/README.md new file mode 100644 index 0000000..51ba703 --- /dev/null +++ b/plugins/trello/README.md @@ -0,0 +1,28 @@ +# Trello Plugin + +Manage Trello boards, lists, cards, and members from Conductor. + +## Setup + +1. Go to [https://trello.com/power-ups/admin](https://trello.com/power-ups/admin) and create a Power-Up to get an API key. +2. Generate a token for your account by visiting: + `https://trello.com/1/authorize?expiration=never&scope=read,write&response_type=token&key=YOUR_API_KEY` +3. Configure the plugin: + +```bash +conductor config set trello api_key YOUR_API_KEY +conductor config set trello token YOUR_TOKEN +``` + +## Available Tools + +| Tool | Description | +|------|-------------| +| `trello_list_boards` | List all boards for the authenticated user | +| `trello_get_board` | Get board details and lists | +| `trello_list_cards` | List cards on a board or list | +| `trello_get_card` | Get a single card's details | +| `trello_create_card` | Create a new card | +| `trello_update_card` | Update a card (title, description, due date, etc.) | +| `trello_move_card` | Move a card to a different list | +| `trello_archive_card` | Archive a card | diff --git a/plugins/twilio/README.md b/plugins/twilio/README.md new file mode 100644 index 0000000..b4b714f --- /dev/null +++ b/plugins/twilio/README.md @@ -0,0 +1,24 @@ +# Twilio Plugin + +Send SMS, make calls, and manage phone numbers via Twilio from Conductor. + +## Setup + +1. Log in to the [Twilio Console](https://console.twilio.com) and find your **Account SID** and **Auth Token** on the dashboard. +2. Configure the plugin: + +```bash +conductor config set twilio account_sid YOUR_ACCOUNT_SID +conductor config set twilio auth_token YOUR_AUTH_TOKEN +``` + +## Available Tools + +| Tool | Description | +|------|-------------| +| `twilio_send_sms` | Send an SMS message | +| `twilio_send_mms` | Send an MMS with media | +| `twilio_make_call` | Initiate an outbound call | +| `twilio_list_messages` | List recent messages | +| `twilio_lookup_number` | Look up a phone number | +| `twilio_account_balance` | Check account balance | diff --git a/plugins/vercel/README.md b/plugins/vercel/README.md deleted file mode 100644 index 63a87b4..0000000 --- a/plugins/vercel/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Vercel Plugin for Conductor - -Install: `conductor install vercel` - -## Setup - -**Authentication:** API Token - -```bash -conductor plugins config vercel token \ -`conductor plugins enable vercel` -``` - -Get credentials at: https://vercel.com/docs/rest-api - -## Tools - -``` -vercel_list_projects, vercel_list_deployments, vercel_get_deployment, vercel_get_logs, vercel_create_deployment, vercel_list_env, vercel_add_env -``` - -Each tool is documented inline — ask Conductor what tools are available after installing. - -## Source - -Part of [thealxlabs/conductor-plugins](https://github.com/thealxlabs/conductor-plugins/tree/main/plugins/vercel). diff --git a/plugins/vercel/dist/index.d.ts b/plugins/vercel/dist/index.d.ts deleted file mode 100644 index 7e088ea..0000000 --- a/plugins/vercel/dist/index.d.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Vercel Plugin — TheAlxLabs / Conductor - * - * Full Vercel project and deployment management: - * - Deployments: list, inspect, redeploy, cancel, rollback - * - Projects: list, create, update settings - * - Domains: list, add, verify, check DNS - * - Environment variables: read, add, update, delete (all environments) - * - Logs: stream deployment build logs - * - Teams: list projects across teams - * - Aliases: manage custom domains on deployments - * - * Setup: - * 1. https://vercel.com/account/tokens → Create Token - * 2. Run: conductor plugins config vercel token - * - * Keychain: vercel / token - * Optional: vercel / team_id (for team scoping) - */ -interface PluginConfigField { - key: string; - label: string; - type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; - secret?: boolean; - service?: string; - description?: string; - options?: string[]; -} -interface PluginConfigSchema { - fields: PluginConfigField[]; - setupInstructions?: string; -} -interface PluginTool { - name: string; - description: string; - inputSchema: Record; - handler: (input: Record) => Promise; - requiresApproval?: boolean; -} -interface Plugin { - name: string; - description: string; - version: string; - configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - getTools(): PluginTool[]; - getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; - set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { - getConfig(): ConfigManagerLike; -} -export declare class VercelPlugin implements Plugin { - name: string; - description: string; - version: string; - configSchema: { - fields: ({ - key: string; - label: string; - type: "password"; - required: boolean; - secret: boolean; - service: string; - description: string; - } | { - key: string; - label: string; - type: "string"; - required: boolean; - secret: boolean; - description: string; - service?: undefined; - })[]; - setupInstructions: string; - }; - private keychain; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - private getAuth; - private vercelFetch; - private formatDeployment; - private formatProject; - getTools(): PluginTool[]; -} -export {}; diff --git a/plugins/vercel/dist/index.js b/plugins/vercel/dist/index.js deleted file mode 100644 index 08752e7..0000000 --- a/plugins/vercel/dist/index.js +++ /dev/null @@ -1,524 +0,0 @@ -/** - * Vercel Plugin — TheAlxLabs / Conductor - * - * Full Vercel project and deployment management: - * - Deployments: list, inspect, redeploy, cancel, rollback - * - Projects: list, create, update settings - * - Domains: list, add, verify, check DNS - * - Environment variables: read, add, update, delete (all environments) - * - Logs: stream deployment build logs - * - Teams: list projects across teams - * - Aliases: manage custom domains on deployments - * - * Setup: - * 1. https://vercel.com/account/tokens → Create Token - * 2. Run: conductor plugins config vercel token - * - * Keychain: vercel / token - * Optional: vercel / team_id (for team scoping) - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service, _account, _value) { } - async delete(_service, _account) { } -} -const VERCEL_BASE = 'https://api.vercel.com'; -export class VercelPlugin { - name = 'vercel'; - description = 'Manage Vercel deployments, projects, domains, and environment variables — requires Vercel token'; - version = '1.0.0'; - configSchema = { - fields: [ - { - key: 'token', - label: 'Vercel API Token', - type: 'password', - required: true, - secret: true, - service: 'vercel', - description: 'Copy your token from Vercel Account Settings > Tokens.' - }, - { - key: 'team_id', - label: 'Vercel Team ID (Optional)', - type: 'string', - required: false, - secret: false, - description: 'Enter your Team ID to scope API calls to a specific team.' - } - ], - setupInstructions: '1. Go to vercel.com/account/tokens and create a new token. 2. If you are part of a team, copy the Team ID from your team settings page.' - }; - keychain; - async initialize(conductor) { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - isConfigured() { - return true; - } - async getAuth() { - const token = await this.keychain.get('vercel', 'token'); - if (!token) { - throw new Error('Vercel token not configured.\n' + - 'Get one at https://vercel.com/account/tokens\n' + - 'Then run: conductor plugins config vercel token '); - } - const teamId = await this.keychain.get('vercel', 'team_id'); - return { token, teamId }; - } - async vercelFetch(path, options = {}) { - const { token, teamId } = await this.getAuth(); - const url = new URL(`${VERCEL_BASE}${path}`); - if (options.params) { - for (const [k, v] of Object.entries(options.params)) - url.searchParams.set(k, v); - } - if (teamId) - url.searchParams.set('teamId', teamId); - const res = await fetch(url.toString(), { - method: options.method ?? 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: options.body ? JSON.stringify(options.body) : undefined, - }); - if (res.status === 204) - return {}; - if (!res.ok) { - const err = (await res.json().catch(() => ({}))); - throw new Error(`Vercel API ${res.status}: ${err.error?.message ?? err.message ?? res.statusText}`); - } - return res.json(); - } - // ── Formatters ────────────────────────────────────────────────────────────── - formatDeployment(d) { - const stateEmoji = { - READY: '✅', - ERROR: '❌', - BUILDING: '🔨', - QUEUED: '⏳', - CANCELED: '🚫', - INITIALIZING: '🔄', - }; - return { - id: d.uid ?? d.id, - name: d.name, - url: d.url ? `https://${d.url}` : null, - state: d.readyState ?? d.state ?? 'UNKNOWN', - stateIcon: stateEmoji[d.readyState ?? d.state] ?? '❓', - target: d.target ?? 'preview', - branch: d.meta?.githubCommitRef ?? d.gitSource?.ref ?? null, - commit: { - sha: (d.meta?.githubCommitSha ?? d.gitSource?.sha ?? '').slice(0, 8), - message: d.meta?.githubCommitMessage ?? null, - author: d.meta?.githubCommitAuthorLogin ?? null, - }, - createdAt: d.createdAt ? new Date(d.createdAt).toISOString() : null, - buildingAt: d.buildingAt ? new Date(d.buildingAt).toISOString() : null, - ready: d.ready ? new Date(d.ready).toISOString() : null, - buildDuration: d.buildingAt && d.ready - ? `${Math.round((d.ready - d.buildingAt) / 1000)}s` - : null, - aliases: d.aliases ?? [], - inspectUrl: `https://vercel.com/deployments/${d.uid ?? d.id}`, - }; - } - formatProject(p) { - return { - id: p.id, - name: p.name, - framework: p.framework ?? 'unknown', - nodeVersion: p.nodeVersion ?? null, - latestDeployment: p.latestDeployments?.[0] - ? { - url: `https://${p.latestDeployments[0].url}`, - state: p.latestDeployments[0].readyState, - target: p.latestDeployments[0].target, - } - : null, - productionUrl: p.alias?.[0]?.domain ? `https://${p.alias[0].domain}` : null, - createdAt: p.createdAt ? new Date(p.createdAt).toISOString() : null, - updatedAt: p.updatedAt ? new Date(p.updatedAt).toISOString() : null, - gitRepo: p.link - ? { - provider: p.link.type, - repo: p.link.repo ?? p.link.projectName, - branch: p.link.productionBranch ?? 'main', - } - : null, - }; - } - // ── Tools ─────────────────────────────────────────────────────────────────── - getTools() { - return [ - // ── vercel_projects ──────────────────────────────────────────────────── - { - name: 'vercel_projects', - description: 'List all Vercel projects with their latest deployment status', - inputSchema: { - type: 'object', - properties: { - limit: { type: 'number', description: 'Max projects to return (default: 20)' }, - search: { type: 'string', description: 'Filter by project name' }, - }, - }, - handler: async ({ limit = 20, search }) => { - const params = { limit: String(Math.min(limit, 100)) }; - if (search) - params.search = search; - const data = await this.vercelFetch('/v9/projects', { params }); - return { - count: data.projects?.length ?? 0, - projects: (data.projects ?? []).map(this.formatProject.bind(this)), - }; - }, - }, - // ── vercel_project ───────────────────────────────────────────────────── - { - name: 'vercel_project', - description: 'Get details about a specific Vercel project', - inputSchema: { - type: 'object', - properties: { - nameOrId: { type: 'string', description: 'Project name or ID' }, - }, - required: ['nameOrId'], - }, - handler: async ({ nameOrId }) => { - const data = await this.vercelFetch(`/v9/projects/${encodeURIComponent(nameOrId)}`); - return this.formatProject(data); - }, - }, - // ── vercel_deployments ───────────────────────────────────────────────── - { - name: 'vercel_deployments', - description: 'List recent deployments, optionally filtered by project or state', - inputSchema: { - type: 'object', - properties: { - projectId: { type: 'string', description: 'Filter by project name or ID' }, - state: { - type: 'string', - enum: ['BUILDING', 'ERROR', 'INITIALIZING', 'QUEUED', 'READY', 'CANCELED'], - description: 'Filter by deployment state', - }, - target: { - type: 'string', - enum: ['production', 'preview'], - description: 'Filter by deployment target', - }, - limit: { type: 'number', description: 'Max deployments (default: 10)' }, - }, - }, - handler: async ({ projectId, state, target, limit = 10 }) => { - const params = { limit: String(Math.min(limit, 100)) }; - if (projectId) - params.projectId = projectId; - if (state) - params.state = state; - if (target) - params.target = target; - const data = await this.vercelFetch('/v6/deployments', { params }); - return { - count: data.deployments?.length ?? 0, - deployments: (data.deployments ?? []).map(this.formatDeployment.bind(this)), - }; - }, - }, - // ── vercel_deployment ────────────────────────────────────────────────── - { - name: 'vercel_deployment', - description: 'Get full details of a specific deployment by ID or URL', - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'Deployment ID (dpl_xxx) or URL' }, - }, - required: ['id'], - }, - handler: async ({ id }) => { - // Accept full URLs - const deployId = id.includes('vercel.app') || id.includes('vercel.com') - ? id.split('/').pop() - : id; - const data = await this.vercelFetch(`/v13/deployments/${encodeURIComponent(deployId)}`); - return this.formatDeployment(data); - }, - }, - // ── vercel_redeploy ──────────────────────────────────────────────────── - { - name: 'vercel_redeploy', - description: 'Redeploy an existing deployment (great for retrying failed builds)', - inputSchema: { - type: 'object', - properties: { - deploymentId: { type: 'string', description: 'Deployment ID to redeploy' }, - target: { - type: 'string', - enum: ['production', 'preview'], - description: 'Override target environment', - }, - }, - required: ['deploymentId'], - }, - handler: async ({ deploymentId, target }) => { - const body = {}; - if (target) - body.target = target; - const data = await this.vercelFetch(`/v13/deployments/${deploymentId}/redeploy`, { - method: 'POST', - body, - }); - return { - redeployed: true, - newDeployment: this.formatDeployment(data), - }; - }, - }, - // ── vercel_cancel ────────────────────────────────────────────────────── - { - name: 'vercel_cancel', - description: 'Cancel a deployment that is currently building or queued', - inputSchema: { - type: 'object', - properties: { - deploymentId: { type: 'string', description: 'Deployment ID to cancel' }, - }, - required: ['deploymentId'], - }, - handler: async ({ deploymentId }) => { - await this.vercelFetch(`/v12/deployments/${deploymentId}/cancel`, { method: 'PATCH' }); - return { cancelled: true, deploymentId }; - }, - }, - // ── vercel_logs ──────────────────────────────────────────────────────── - { - name: 'vercel_logs', - description: 'Get build logs for a deployment', - inputSchema: { - type: 'object', - properties: { - deploymentId: { type: 'string', description: 'Deployment ID' }, - limit: { type: 'number', description: 'Max log lines (default: 100)' }, - direction: { - type: 'string', - enum: ['forward', 'backward'], - description: 'Log order — backward = most recent first (default)', - }, - }, - required: ['deploymentId'], - }, - handler: async ({ deploymentId, limit = 100, direction = 'backward' }) => { - const data = await this.vercelFetch(`/v2/deployments/${deploymentId}/events`, { params: { limit: String(Math.min(limit, 2000)), direction } }); - const events = (Array.isArray(data) ? data : data.events ?? []); - const lines = events - .filter((e) => e.type === 'stdout' || e.type === 'stderr' || e.type === 'command') - .map((e) => ({ - type: e.type, - text: e.payload?.text ?? e.text ?? '', - date: e.date ? new Date(e.date).toISOString() : null, - })); - return { - count: lines.length, - deploymentId, - logs: lines, - }; - }, - }, - // ── vercel_env_list ──────────────────────────────────────────────────── - { - name: 'vercel_env_list', - description: 'List environment variables for a Vercel project', - inputSchema: { - type: 'object', - properties: { - projectId: { type: 'string', description: 'Project name or ID' }, - }, - required: ['projectId'], - }, - handler: async ({ projectId }) => { - const data = await this.vercelFetch(`/v9/projects/${encodeURIComponent(projectId)}/env`); - return { - count: data.envs?.length ?? 0, - envs: (data.envs ?? []).map((e) => ({ - id: e.id, - key: e.key, - // Values are redacted by default unless decrypted separately - value: e.value ?? '[encrypted]', - type: e.type, - targets: e.target ?? [], - createdAt: e.createdAt ? new Date(e.createdAt).toISOString() : null, - updatedAt: e.updatedAt ? new Date(e.updatedAt).toISOString() : null, - })), - }; - }, - }, - // ── vercel_env_add ───────────────────────────────────────────────────── - { - name: 'vercel_env_add', - description: 'Add or update an environment variable on a Vercel project', - inputSchema: { - type: 'object', - properties: { - projectId: { type: 'string', description: 'Project name or ID' }, - key: { type: 'string', description: 'Environment variable name' }, - value: { type: 'string', description: 'Environment variable value' }, - targets: { - type: 'array', - items: { type: 'string', enum: ['production', 'preview', 'development'] }, - description: 'Deployment targets (default: all three)', - }, - type: { - type: 'string', - enum: ['plain', 'secret', 'encrypted'], - description: 'Variable type (default: encrypted)', - }, - }, - required: ['projectId', 'key', 'value'], - }, - handler: async ({ projectId, key, value, targets = ['production', 'preview', 'development'], type = 'encrypted', }) => { - const data = await this.vercelFetch(`/v10/projects/${encodeURIComponent(projectId)}/env`, { - method: 'POST', - body: { key, value, target: targets, type }, - }); - const created = Array.isArray(data) ? data[0] : data; - return { - added: true, - id: created?.id, - key, - targets, - type, - }; - }, - }, - // ── vercel_env_delete ────────────────────────────────────────────────── - { - name: 'vercel_env_delete', - description: 'Delete an environment variable from a Vercel project', - inputSchema: { - type: 'object', - properties: { - projectId: { type: 'string', description: 'Project name or ID' }, - envId: { type: 'string', description: 'Env var ID (from vercel_env_list)' }, - }, - required: ['projectId', 'envId'], - }, - handler: async ({ projectId, envId }) => { - await this.vercelFetch(`/v9/projects/${encodeURIComponent(projectId)}/env/${envId}`, { method: 'DELETE' }); - return { deleted: true, envId }; - }, - }, - // ── vercel_domains ───────────────────────────────────────────────────── - { - name: 'vercel_domains', - description: 'List domains for a project or your entire account', - inputSchema: { - type: 'object', - properties: { - projectId: { - type: 'string', - description: 'Project name or ID (omit for account-level domains)', - }, - }, - }, - handler: async ({ projectId }) => { - let data; - if (projectId) { - data = await this.vercelFetch(`/v9/projects/${encodeURIComponent(projectId)}/domains`); - } - else { - data = await this.vercelFetch('/v5/domains'); - } - const domains = data.domains ?? data; - return { - count: domains.length, - domains: domains.map((d) => ({ - name: d.name, - apexName: d.apexName ?? d.name, - verified: d.verified ?? false, - configured: d.misconfigured === false, - redirect: d.redirect ?? null, - createdAt: d.createdAt ? new Date(d.createdAt).toISOString() : null, - })), - }; - }, - }, - // ── vercel_add_domain ────────────────────────────────────────────────── - { - name: 'vercel_add_domain', - description: 'Add a custom domain to a Vercel project', - inputSchema: { - type: 'object', - properties: { - projectId: { type: 'string', description: 'Project name or ID' }, - domain: { type: 'string', description: 'Domain name to add (e.g. app.yourdomain.com)' }, - }, - required: ['projectId', 'domain'], - }, - handler: async ({ projectId, domain }) => { - const data = await this.vercelFetch(`/v10/projects/${encodeURIComponent(projectId)}/domains`, { method: 'POST', body: { name: domain } }); - return { - added: true, - name: data.name, - verified: data.verified, - verification: data.verification ?? [], - }; - }, - }, - // ── vercel_team_info ─────────────────────────────────────────────────── - { - name: 'vercel_team_info', - description: 'Get your Vercel account or team info and usage', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const { teamId } = await this.getAuth(); - const data = teamId - ? await this.vercelFetch(`/v2/teams/${teamId}`) - : await this.vercelFetch('/v2/user'); - return { - id: data.id ?? data.uid, - name: data.name ?? data.username, - email: data.email ?? null, - plan: data.plan?.id ?? data.subscription?.plan ?? null, - avatar: data.avatar ?? null, - createdAt: data.createdAt ? new Date(data.createdAt).toISOString() : null, - }; - }, - }, - // ── vercel_set_team ──────────────────────────────────────────────────── - { - name: 'vercel_set_team', - description: 'Set the active Vercel team scope for all API calls', - inputSchema: { - type: 'object', - properties: { - teamId: { - type: 'string', - description: 'Team ID or slug (set to empty string to use personal account)', - }, - }, - required: ['teamId'], - }, - handler: async ({ teamId }) => { - if (teamId) { - await this.keychain.set('vercel', 'team_id', teamId); - return { set: true, teamId, scope: 'team' }; - } - else { - // Clear team scope - await this.keychain.set('vercel', 'team_id', ''); - return { set: true, teamId: null, scope: 'personal' }; - } - }, - }, - ]; - } -} diff --git a/plugins/vercel/dist/vercel.js b/plugins/vercel/dist/vercel.js deleted file mode 100644 index 08752e7..0000000 --- a/plugins/vercel/dist/vercel.js +++ /dev/null @@ -1,524 +0,0 @@ -/** - * Vercel Plugin — TheAlxLabs / Conductor - * - * Full Vercel project and deployment management: - * - Deployments: list, inspect, redeploy, cancel, rollback - * - Projects: list, create, update settings - * - Domains: list, add, verify, check DNS - * - Environment variables: read, add, update, delete (all environments) - * - Logs: stream deployment build logs - * - Teams: list projects across teams - * - Aliases: manage custom domains on deployments - * - * Setup: - * 1. https://vercel.com/account/tokens → Create Token - * 2. Run: conductor plugins config vercel token - * - * Keychain: vercel / token - * Optional: vercel / team_id (for team scoping) - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service, _account, _value) { } - async delete(_service, _account) { } -} -const VERCEL_BASE = 'https://api.vercel.com'; -export class VercelPlugin { - name = 'vercel'; - description = 'Manage Vercel deployments, projects, domains, and environment variables — requires Vercel token'; - version = '1.0.0'; - configSchema = { - fields: [ - { - key: 'token', - label: 'Vercel API Token', - type: 'password', - required: true, - secret: true, - service: 'vercel', - description: 'Copy your token from Vercel Account Settings > Tokens.' - }, - { - key: 'team_id', - label: 'Vercel Team ID (Optional)', - type: 'string', - required: false, - secret: false, - description: 'Enter your Team ID to scope API calls to a specific team.' - } - ], - setupInstructions: '1. Go to vercel.com/account/tokens and create a new token. 2. If you are part of a team, copy the Team ID from your team settings page.' - }; - keychain; - async initialize(conductor) { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - isConfigured() { - return true; - } - async getAuth() { - const token = await this.keychain.get('vercel', 'token'); - if (!token) { - throw new Error('Vercel token not configured.\n' + - 'Get one at https://vercel.com/account/tokens\n' + - 'Then run: conductor plugins config vercel token '); - } - const teamId = await this.keychain.get('vercel', 'team_id'); - return { token, teamId }; - } - async vercelFetch(path, options = {}) { - const { token, teamId } = await this.getAuth(); - const url = new URL(`${VERCEL_BASE}${path}`); - if (options.params) { - for (const [k, v] of Object.entries(options.params)) - url.searchParams.set(k, v); - } - if (teamId) - url.searchParams.set('teamId', teamId); - const res = await fetch(url.toString(), { - method: options.method ?? 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: options.body ? JSON.stringify(options.body) : undefined, - }); - if (res.status === 204) - return {}; - if (!res.ok) { - const err = (await res.json().catch(() => ({}))); - throw new Error(`Vercel API ${res.status}: ${err.error?.message ?? err.message ?? res.statusText}`); - } - return res.json(); - } - // ── Formatters ────────────────────────────────────────────────────────────── - formatDeployment(d) { - const stateEmoji = { - READY: '✅', - ERROR: '❌', - BUILDING: '🔨', - QUEUED: '⏳', - CANCELED: '🚫', - INITIALIZING: '🔄', - }; - return { - id: d.uid ?? d.id, - name: d.name, - url: d.url ? `https://${d.url}` : null, - state: d.readyState ?? d.state ?? 'UNKNOWN', - stateIcon: stateEmoji[d.readyState ?? d.state] ?? '❓', - target: d.target ?? 'preview', - branch: d.meta?.githubCommitRef ?? d.gitSource?.ref ?? null, - commit: { - sha: (d.meta?.githubCommitSha ?? d.gitSource?.sha ?? '').slice(0, 8), - message: d.meta?.githubCommitMessage ?? null, - author: d.meta?.githubCommitAuthorLogin ?? null, - }, - createdAt: d.createdAt ? new Date(d.createdAt).toISOString() : null, - buildingAt: d.buildingAt ? new Date(d.buildingAt).toISOString() : null, - ready: d.ready ? new Date(d.ready).toISOString() : null, - buildDuration: d.buildingAt && d.ready - ? `${Math.round((d.ready - d.buildingAt) / 1000)}s` - : null, - aliases: d.aliases ?? [], - inspectUrl: `https://vercel.com/deployments/${d.uid ?? d.id}`, - }; - } - formatProject(p) { - return { - id: p.id, - name: p.name, - framework: p.framework ?? 'unknown', - nodeVersion: p.nodeVersion ?? null, - latestDeployment: p.latestDeployments?.[0] - ? { - url: `https://${p.latestDeployments[0].url}`, - state: p.latestDeployments[0].readyState, - target: p.latestDeployments[0].target, - } - : null, - productionUrl: p.alias?.[0]?.domain ? `https://${p.alias[0].domain}` : null, - createdAt: p.createdAt ? new Date(p.createdAt).toISOString() : null, - updatedAt: p.updatedAt ? new Date(p.updatedAt).toISOString() : null, - gitRepo: p.link - ? { - provider: p.link.type, - repo: p.link.repo ?? p.link.projectName, - branch: p.link.productionBranch ?? 'main', - } - : null, - }; - } - // ── Tools ─────────────────────────────────────────────────────────────────── - getTools() { - return [ - // ── vercel_projects ──────────────────────────────────────────────────── - { - name: 'vercel_projects', - description: 'List all Vercel projects with their latest deployment status', - inputSchema: { - type: 'object', - properties: { - limit: { type: 'number', description: 'Max projects to return (default: 20)' }, - search: { type: 'string', description: 'Filter by project name' }, - }, - }, - handler: async ({ limit = 20, search }) => { - const params = { limit: String(Math.min(limit, 100)) }; - if (search) - params.search = search; - const data = await this.vercelFetch('/v9/projects', { params }); - return { - count: data.projects?.length ?? 0, - projects: (data.projects ?? []).map(this.formatProject.bind(this)), - }; - }, - }, - // ── vercel_project ───────────────────────────────────────────────────── - { - name: 'vercel_project', - description: 'Get details about a specific Vercel project', - inputSchema: { - type: 'object', - properties: { - nameOrId: { type: 'string', description: 'Project name or ID' }, - }, - required: ['nameOrId'], - }, - handler: async ({ nameOrId }) => { - const data = await this.vercelFetch(`/v9/projects/${encodeURIComponent(nameOrId)}`); - return this.formatProject(data); - }, - }, - // ── vercel_deployments ───────────────────────────────────────────────── - { - name: 'vercel_deployments', - description: 'List recent deployments, optionally filtered by project or state', - inputSchema: { - type: 'object', - properties: { - projectId: { type: 'string', description: 'Filter by project name or ID' }, - state: { - type: 'string', - enum: ['BUILDING', 'ERROR', 'INITIALIZING', 'QUEUED', 'READY', 'CANCELED'], - description: 'Filter by deployment state', - }, - target: { - type: 'string', - enum: ['production', 'preview'], - description: 'Filter by deployment target', - }, - limit: { type: 'number', description: 'Max deployments (default: 10)' }, - }, - }, - handler: async ({ projectId, state, target, limit = 10 }) => { - const params = { limit: String(Math.min(limit, 100)) }; - if (projectId) - params.projectId = projectId; - if (state) - params.state = state; - if (target) - params.target = target; - const data = await this.vercelFetch('/v6/deployments', { params }); - return { - count: data.deployments?.length ?? 0, - deployments: (data.deployments ?? []).map(this.formatDeployment.bind(this)), - }; - }, - }, - // ── vercel_deployment ────────────────────────────────────────────────── - { - name: 'vercel_deployment', - description: 'Get full details of a specific deployment by ID or URL', - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'Deployment ID (dpl_xxx) or URL' }, - }, - required: ['id'], - }, - handler: async ({ id }) => { - // Accept full URLs - const deployId = id.includes('vercel.app') || id.includes('vercel.com') - ? id.split('/').pop() - : id; - const data = await this.vercelFetch(`/v13/deployments/${encodeURIComponent(deployId)}`); - return this.formatDeployment(data); - }, - }, - // ── vercel_redeploy ──────────────────────────────────────────────────── - { - name: 'vercel_redeploy', - description: 'Redeploy an existing deployment (great for retrying failed builds)', - inputSchema: { - type: 'object', - properties: { - deploymentId: { type: 'string', description: 'Deployment ID to redeploy' }, - target: { - type: 'string', - enum: ['production', 'preview'], - description: 'Override target environment', - }, - }, - required: ['deploymentId'], - }, - handler: async ({ deploymentId, target }) => { - const body = {}; - if (target) - body.target = target; - const data = await this.vercelFetch(`/v13/deployments/${deploymentId}/redeploy`, { - method: 'POST', - body, - }); - return { - redeployed: true, - newDeployment: this.formatDeployment(data), - }; - }, - }, - // ── vercel_cancel ────────────────────────────────────────────────────── - { - name: 'vercel_cancel', - description: 'Cancel a deployment that is currently building or queued', - inputSchema: { - type: 'object', - properties: { - deploymentId: { type: 'string', description: 'Deployment ID to cancel' }, - }, - required: ['deploymentId'], - }, - handler: async ({ deploymentId }) => { - await this.vercelFetch(`/v12/deployments/${deploymentId}/cancel`, { method: 'PATCH' }); - return { cancelled: true, deploymentId }; - }, - }, - // ── vercel_logs ──────────────────────────────────────────────────────── - { - name: 'vercel_logs', - description: 'Get build logs for a deployment', - inputSchema: { - type: 'object', - properties: { - deploymentId: { type: 'string', description: 'Deployment ID' }, - limit: { type: 'number', description: 'Max log lines (default: 100)' }, - direction: { - type: 'string', - enum: ['forward', 'backward'], - description: 'Log order — backward = most recent first (default)', - }, - }, - required: ['deploymentId'], - }, - handler: async ({ deploymentId, limit = 100, direction = 'backward' }) => { - const data = await this.vercelFetch(`/v2/deployments/${deploymentId}/events`, { params: { limit: String(Math.min(limit, 2000)), direction } }); - const events = (Array.isArray(data) ? data : data.events ?? []); - const lines = events - .filter((e) => e.type === 'stdout' || e.type === 'stderr' || e.type === 'command') - .map((e) => ({ - type: e.type, - text: e.payload?.text ?? e.text ?? '', - date: e.date ? new Date(e.date).toISOString() : null, - })); - return { - count: lines.length, - deploymentId, - logs: lines, - }; - }, - }, - // ── vercel_env_list ──────────────────────────────────────────────────── - { - name: 'vercel_env_list', - description: 'List environment variables for a Vercel project', - inputSchema: { - type: 'object', - properties: { - projectId: { type: 'string', description: 'Project name or ID' }, - }, - required: ['projectId'], - }, - handler: async ({ projectId }) => { - const data = await this.vercelFetch(`/v9/projects/${encodeURIComponent(projectId)}/env`); - return { - count: data.envs?.length ?? 0, - envs: (data.envs ?? []).map((e) => ({ - id: e.id, - key: e.key, - // Values are redacted by default unless decrypted separately - value: e.value ?? '[encrypted]', - type: e.type, - targets: e.target ?? [], - createdAt: e.createdAt ? new Date(e.createdAt).toISOString() : null, - updatedAt: e.updatedAt ? new Date(e.updatedAt).toISOString() : null, - })), - }; - }, - }, - // ── vercel_env_add ───────────────────────────────────────────────────── - { - name: 'vercel_env_add', - description: 'Add or update an environment variable on a Vercel project', - inputSchema: { - type: 'object', - properties: { - projectId: { type: 'string', description: 'Project name or ID' }, - key: { type: 'string', description: 'Environment variable name' }, - value: { type: 'string', description: 'Environment variable value' }, - targets: { - type: 'array', - items: { type: 'string', enum: ['production', 'preview', 'development'] }, - description: 'Deployment targets (default: all three)', - }, - type: { - type: 'string', - enum: ['plain', 'secret', 'encrypted'], - description: 'Variable type (default: encrypted)', - }, - }, - required: ['projectId', 'key', 'value'], - }, - handler: async ({ projectId, key, value, targets = ['production', 'preview', 'development'], type = 'encrypted', }) => { - const data = await this.vercelFetch(`/v10/projects/${encodeURIComponent(projectId)}/env`, { - method: 'POST', - body: { key, value, target: targets, type }, - }); - const created = Array.isArray(data) ? data[0] : data; - return { - added: true, - id: created?.id, - key, - targets, - type, - }; - }, - }, - // ── vercel_env_delete ────────────────────────────────────────────────── - { - name: 'vercel_env_delete', - description: 'Delete an environment variable from a Vercel project', - inputSchema: { - type: 'object', - properties: { - projectId: { type: 'string', description: 'Project name or ID' }, - envId: { type: 'string', description: 'Env var ID (from vercel_env_list)' }, - }, - required: ['projectId', 'envId'], - }, - handler: async ({ projectId, envId }) => { - await this.vercelFetch(`/v9/projects/${encodeURIComponent(projectId)}/env/${envId}`, { method: 'DELETE' }); - return { deleted: true, envId }; - }, - }, - // ── vercel_domains ───────────────────────────────────────────────────── - { - name: 'vercel_domains', - description: 'List domains for a project or your entire account', - inputSchema: { - type: 'object', - properties: { - projectId: { - type: 'string', - description: 'Project name or ID (omit for account-level domains)', - }, - }, - }, - handler: async ({ projectId }) => { - let data; - if (projectId) { - data = await this.vercelFetch(`/v9/projects/${encodeURIComponent(projectId)}/domains`); - } - else { - data = await this.vercelFetch('/v5/domains'); - } - const domains = data.domains ?? data; - return { - count: domains.length, - domains: domains.map((d) => ({ - name: d.name, - apexName: d.apexName ?? d.name, - verified: d.verified ?? false, - configured: d.misconfigured === false, - redirect: d.redirect ?? null, - createdAt: d.createdAt ? new Date(d.createdAt).toISOString() : null, - })), - }; - }, - }, - // ── vercel_add_domain ────────────────────────────────────────────────── - { - name: 'vercel_add_domain', - description: 'Add a custom domain to a Vercel project', - inputSchema: { - type: 'object', - properties: { - projectId: { type: 'string', description: 'Project name or ID' }, - domain: { type: 'string', description: 'Domain name to add (e.g. app.yourdomain.com)' }, - }, - required: ['projectId', 'domain'], - }, - handler: async ({ projectId, domain }) => { - const data = await this.vercelFetch(`/v10/projects/${encodeURIComponent(projectId)}/domains`, { method: 'POST', body: { name: domain } }); - return { - added: true, - name: data.name, - verified: data.verified, - verification: data.verification ?? [], - }; - }, - }, - // ── vercel_team_info ─────────────────────────────────────────────────── - { - name: 'vercel_team_info', - description: 'Get your Vercel account or team info and usage', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const { teamId } = await this.getAuth(); - const data = teamId - ? await this.vercelFetch(`/v2/teams/${teamId}`) - : await this.vercelFetch('/v2/user'); - return { - id: data.id ?? data.uid, - name: data.name ?? data.username, - email: data.email ?? null, - plan: data.plan?.id ?? data.subscription?.plan ?? null, - avatar: data.avatar ?? null, - createdAt: data.createdAt ? new Date(data.createdAt).toISOString() : null, - }; - }, - }, - // ── vercel_set_team ──────────────────────────────────────────────────── - { - name: 'vercel_set_team', - description: 'Set the active Vercel team scope for all API calls', - inputSchema: { - type: 'object', - properties: { - teamId: { - type: 'string', - description: 'Team ID or slug (set to empty string to use personal account)', - }, - }, - required: ['teamId'], - }, - handler: async ({ teamId }) => { - if (teamId) { - await this.keychain.set('vercel', 'team_id', teamId); - return { set: true, teamId, scope: 'team' }; - } - else { - // Clear team scope - await this.keychain.set('vercel', 'team_id', ''); - return { set: true, teamId: null, scope: 'personal' }; - } - }, - }, - ]; - } -} diff --git a/plugins/vercel/package.json b/plugins/vercel/package.json deleted file mode 100644 index 29bcbab..0000000 --- a/plugins/vercel/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@conductor-plugins/vercel", - "version": "1.0.0", - "type": "module", - "main": "dist/vercel.js", - "scripts": { - "build": "tsc", - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "typescript": "^5.0.0", - "@types/node": "^20.0.0" - } -} diff --git a/plugins/vercel/src/index.ts b/plugins/vercel/src/index.ts deleted file mode 100644 index a95ea9a..0000000 --- a/plugins/vercel/src/index.ts +++ /dev/null @@ -1,591 +0,0 @@ -/** - * Vercel Plugin — TheAlxLabs / Conductor - * - * Full Vercel project and deployment management: - * - Deployments: list, inspect, redeploy, cancel, rollback - * - Projects: list, create, update settings - * - Domains: list, add, verify, check DNS - * - Environment variables: read, add, update, delete (all environments) - * - Logs: stream deployment build logs - * - Teams: list projects across teams - * - Aliases: manage custom domains on deployments - * - * Setup: - * 1. https://vercel.com/account/tokens → Create Token - * 2. Run: conductor plugins config vercel token - * - * Keychain: vercel / token - * Optional: vercel / team_id (for team scoping) - */ - -// ── Inlined types (no external dependencies) ───────────────────────────────── - -interface PluginConfigField { - key: string; label: string; type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; secret?: boolean; service?: string; description?: string; options?: string[]; -} -interface PluginConfigSchema { fields: PluginConfigField[]; setupInstructions?: string; } -interface PluginTool { - name: string; description: string; inputSchema: Record; - handler: (input: Record) => Promise; requiresApproval?: boolean; -} -interface Plugin { - name: string; description: string; version: string; configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; isConfigured(): boolean; - getTools(): PluginTool[]; getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { getConfig(): ConfigManagerLike; } -class Keychain { - constructor(private dir: string) {} - async get(service: string, account: string): Promise { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service: string, _account: string, _value: string): Promise {} - async delete(_service: string, _account: string): Promise {} -} - -const VERCEL_BASE = 'https://api.vercel.com'; - -export class VercelPlugin implements Plugin { - name = 'vercel'; - description = - 'Manage Vercel deployments, projects, domains, and environment variables — requires Vercel token'; - version = '1.0.0'; - - configSchema = { - fields: [ - { - key: 'token', - label: 'Vercel API Token', - type: 'password' as const, - required: true, - secret: true, - service: 'vercel', - description: 'Copy your token from Vercel Account Settings > Tokens.' - }, - { - key: 'team_id', - label: 'Vercel Team ID (Optional)', - type: 'string' as const, - required: false, - secret: false, - description: 'Enter your Team ID to scope API calls to a specific team.' - } - ], - setupInstructions: '1. Go to vercel.com/account/tokens and create a new token. 2. If you are part of a team, copy the Team ID from your team settings page.' - }; - - private keychain!: Keychain; - - async initialize(conductor: Conductor): Promise { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - - isConfigured(): boolean { - return true; - } - - private async getAuth(): Promise<{ token: string; teamId: string | null }> { - const token = await this.keychain.get('vercel', 'token'); - if (!token) { - throw new Error( - 'Vercel token not configured.\n' + - 'Get one at https://vercel.com/account/tokens\n' + - 'Then run: conductor plugins config vercel token ' - ); - } - const teamId = await this.keychain.get('vercel', 'team_id'); - return { token, teamId }; - } - - private async vercelFetch( - path: string, - options: { method?: string; body?: any; params?: Record } = {} - ): Promise { - const { token, teamId } = await this.getAuth(); - const url = new URL(`${VERCEL_BASE}${path}`); - if (options.params) { - for (const [k, v] of Object.entries(options.params)) url.searchParams.set(k, v); - } - if (teamId) url.searchParams.set('teamId', teamId); - - const res = await fetch(url.toString(), { - method: options.method ?? 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: options.body ? JSON.stringify(options.body) : undefined, - }); - - if (res.status === 204) return {}; - if (!res.ok) { - const err = (await res.json().catch(() => ({}))) as any; - throw new Error( - `Vercel API ${res.status}: ${err.error?.message ?? err.message ?? res.statusText}` - ); - } - return res.json(); - } - - // ── Formatters ────────────────────────────────────────────────────────────── - - private formatDeployment(d: any) { - const stateEmoji: Record = { - READY: '✅', - ERROR: '❌', - BUILDING: '🔨', - QUEUED: '⏳', - CANCELED: '🚫', - INITIALIZING: '🔄', - }; - return { - id: d.uid ?? d.id, - name: d.name, - url: d.url ? `https://${d.url}` : null, - state: d.readyState ?? d.state ?? 'UNKNOWN', - stateIcon: stateEmoji[d.readyState ?? d.state] ?? '❓', - target: d.target ?? 'preview', - branch: d.meta?.githubCommitRef ?? d.gitSource?.ref ?? null, - commit: { - sha: (d.meta?.githubCommitSha ?? d.gitSource?.sha ?? '').slice(0, 8), - message: d.meta?.githubCommitMessage ?? null, - author: d.meta?.githubCommitAuthorLogin ?? null, - }, - createdAt: d.createdAt ? new Date(d.createdAt).toISOString() : null, - buildingAt: d.buildingAt ? new Date(d.buildingAt).toISOString() : null, - ready: d.ready ? new Date(d.ready).toISOString() : null, - buildDuration: d.buildingAt && d.ready - ? `${Math.round((d.ready - d.buildingAt) / 1000)}s` - : null, - aliases: d.aliases ?? [], - inspectUrl: `https://vercel.com/deployments/${d.uid ?? d.id}`, - }; - } - - private formatProject(p: any) { - return { - id: p.id, - name: p.name, - framework: p.framework ?? 'unknown', - nodeVersion: p.nodeVersion ?? null, - latestDeployment: p.latestDeployments?.[0] - ? { - url: `https://${p.latestDeployments[0].url}`, - state: p.latestDeployments[0].readyState, - target: p.latestDeployments[0].target, - } - : null, - productionUrl: p.alias?.[0]?.domain ? `https://${p.alias[0].domain}` : null, - createdAt: p.createdAt ? new Date(p.createdAt).toISOString() : null, - updatedAt: p.updatedAt ? new Date(p.updatedAt).toISOString() : null, - gitRepo: p.link - ? { - provider: p.link.type, - repo: p.link.repo ?? p.link.projectName, - branch: p.link.productionBranch ?? 'main', - } - : null, - }; - } - - // ── Tools ─────────────────────────────────────────────────────────────────── - - getTools(): PluginTool[] { - return [ - // ── vercel_projects ──────────────────────────────────────────────────── - { - name: 'vercel_projects', - description: 'List all Vercel projects with their latest deployment status', - inputSchema: { - type: 'object', - properties: { - limit: { type: 'number', description: 'Max projects to return (default: 20)' }, - search: { type: 'string', description: 'Filter by project name' }, - }, - }, - handler: async ({ limit = 20, search }: any) => { - const params: Record = { limit: String(Math.min(limit, 100)) }; - if (search) params.search = search; - const data = await this.vercelFetch('/v9/projects', { params }); - return { - count: data.projects?.length ?? 0, - projects: (data.projects ?? []).map(this.formatProject.bind(this)), - }; - }, - }, - - // ── vercel_project ───────────────────────────────────────────────────── - { - name: 'vercel_project', - description: 'Get details about a specific Vercel project', - inputSchema: { - type: 'object', - properties: { - nameOrId: { type: 'string', description: 'Project name or ID' }, - }, - required: ['nameOrId'], - }, - handler: async ({ nameOrId }: any) => { - const data = await this.vercelFetch(`/v9/projects/${encodeURIComponent(nameOrId)}`); - return this.formatProject(data); - }, - }, - - // ── vercel_deployments ───────────────────────────────────────────────── - { - name: 'vercel_deployments', - description: 'List recent deployments, optionally filtered by project or state', - inputSchema: { - type: 'object', - properties: { - projectId: { type: 'string', description: 'Filter by project name or ID' }, - state: { - type: 'string', - enum: ['BUILDING', 'ERROR', 'INITIALIZING', 'QUEUED', 'READY', 'CANCELED'], - description: 'Filter by deployment state', - }, - target: { - type: 'string', - enum: ['production', 'preview'], - description: 'Filter by deployment target', - }, - limit: { type: 'number', description: 'Max deployments (default: 10)' }, - }, - }, - handler: async ({ projectId, state, target, limit = 10 }: any) => { - const params: Record = { limit: String(Math.min(limit, 100)) }; - if (projectId) params.projectId = projectId; - if (state) params.state = state; - if (target) params.target = target; - const data = await this.vercelFetch('/v6/deployments', { params }); - return { - count: data.deployments?.length ?? 0, - deployments: (data.deployments ?? []).map(this.formatDeployment.bind(this)), - }; - }, - }, - - // ── vercel_deployment ────────────────────────────────────────────────── - { - name: 'vercel_deployment', - description: 'Get full details of a specific deployment by ID or URL', - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'Deployment ID (dpl_xxx) or URL' }, - }, - required: ['id'], - }, - handler: async ({ id }: any) => { - // Accept full URLs - const deployId = id.includes('vercel.app') || id.includes('vercel.com') - ? id.split('/').pop() - : id; - const data = await this.vercelFetch(`/v13/deployments/${encodeURIComponent(deployId)}`); - return this.formatDeployment(data); - }, - }, - - // ── vercel_redeploy ──────────────────────────────────────────────────── - { - name: 'vercel_redeploy', - description: 'Redeploy an existing deployment (great for retrying failed builds)', - inputSchema: { - type: 'object', - properties: { - deploymentId: { type: 'string', description: 'Deployment ID to redeploy' }, - target: { - type: 'string', - enum: ['production', 'preview'], - description: 'Override target environment', - }, - }, - required: ['deploymentId'], - }, - handler: async ({ deploymentId, target }: any) => { - const body: any = {}; - if (target) body.target = target; - const data = await this.vercelFetch(`/v13/deployments/${deploymentId}/redeploy`, { - method: 'POST', - body, - }); - return { - redeployed: true, - newDeployment: this.formatDeployment(data), - }; - }, - }, - - // ── vercel_cancel ────────────────────────────────────────────────────── - { - name: 'vercel_cancel', - description: 'Cancel a deployment that is currently building or queued', - inputSchema: { - type: 'object', - properties: { - deploymentId: { type: 'string', description: 'Deployment ID to cancel' }, - }, - required: ['deploymentId'], - }, - handler: async ({ deploymentId }: any) => { - await this.vercelFetch(`/v12/deployments/${deploymentId}/cancel`, { method: 'PATCH' }); - return { cancelled: true, deploymentId }; - }, - }, - - // ── vercel_logs ──────────────────────────────────────────────────────── - { - name: 'vercel_logs', - description: 'Get build logs for a deployment', - inputSchema: { - type: 'object', - properties: { - deploymentId: { type: 'string', description: 'Deployment ID' }, - limit: { type: 'number', description: 'Max log lines (default: 100)' }, - direction: { - type: 'string', - enum: ['forward', 'backward'], - description: 'Log order — backward = most recent first (default)', - }, - }, - required: ['deploymentId'], - }, - handler: async ({ deploymentId, limit = 100, direction = 'backward' }: any) => { - const data = await this.vercelFetch( - `/v2/deployments/${deploymentId}/events`, - { params: { limit: String(Math.min(limit, 2000)), direction } } - ); - const events = (Array.isArray(data) ? data : data.events ?? []); - const lines = events - .filter((e: any) => e.type === 'stdout' || e.type === 'stderr' || e.type === 'command') - .map((e: any) => ({ - type: e.type, - text: e.payload?.text ?? e.text ?? '', - date: e.date ? new Date(e.date).toISOString() : null, - })); - return { - count: lines.length, - deploymentId, - logs: lines, - }; - }, - }, - - // ── vercel_env_list ──────────────────────────────────────────────────── - { - name: 'vercel_env_list', - description: 'List environment variables for a Vercel project', - inputSchema: { - type: 'object', - properties: { - projectId: { type: 'string', description: 'Project name or ID' }, - }, - required: ['projectId'], - }, - handler: async ({ projectId }: any) => { - const data = await this.vercelFetch( - `/v9/projects/${encodeURIComponent(projectId)}/env` - ); - return { - count: data.envs?.length ?? 0, - envs: (data.envs ?? []).map((e: any) => ({ - id: e.id, - key: e.key, - // Values are redacted by default unless decrypted separately - value: e.value ?? '[encrypted]', - type: e.type, - targets: e.target ?? [], - createdAt: e.createdAt ? new Date(e.createdAt).toISOString() : null, - updatedAt: e.updatedAt ? new Date(e.updatedAt).toISOString() : null, - })), - }; - }, - }, - - // ── vercel_env_add ───────────────────────────────────────────────────── - { - name: 'vercel_env_add', - description: 'Add or update an environment variable on a Vercel project', - inputSchema: { - type: 'object', - properties: { - projectId: { type: 'string', description: 'Project name or ID' }, - key: { type: 'string', description: 'Environment variable name' }, - value: { type: 'string', description: 'Environment variable value' }, - targets: { - type: 'array', - items: { type: 'string', enum: ['production', 'preview', 'development'] }, - description: 'Deployment targets (default: all three)', - }, - type: { - type: 'string', - enum: ['plain', 'secret', 'encrypted'], - description: 'Variable type (default: encrypted)', - }, - }, - required: ['projectId', 'key', 'value'], - }, - handler: async ({ - projectId, - key, - value, - targets = ['production', 'preview', 'development'], - type = 'encrypted', - }: any) => { - const data = await this.vercelFetch( - `/v10/projects/${encodeURIComponent(projectId)}/env`, - { - method: 'POST', - body: { key, value, target: targets, type }, - } - ); - const created = Array.isArray(data) ? data[0] : data; - return { - added: true, - id: created?.id, - key, - targets, - type, - }; - }, - }, - - // ── vercel_env_delete ────────────────────────────────────────────────── - { - name: 'vercel_env_delete', - description: 'Delete an environment variable from a Vercel project', - inputSchema: { - type: 'object', - properties: { - projectId: { type: 'string', description: 'Project name or ID' }, - envId: { type: 'string', description: 'Env var ID (from vercel_env_list)' }, - }, - required: ['projectId', 'envId'], - }, - handler: async ({ projectId, envId }: any) => { - await this.vercelFetch( - `/v9/projects/${encodeURIComponent(projectId)}/env/${envId}`, - { method: 'DELETE' } - ); - return { deleted: true, envId }; - }, - }, - - // ── vercel_domains ───────────────────────────────────────────────────── - { - name: 'vercel_domains', - description: 'List domains for a project or your entire account', - inputSchema: { - type: 'object', - properties: { - projectId: { - type: 'string', - description: 'Project name or ID (omit for account-level domains)', - }, - }, - }, - handler: async ({ projectId }: any) => { - let data: any; - if (projectId) { - data = await this.vercelFetch( - `/v9/projects/${encodeURIComponent(projectId)}/domains` - ); - } else { - data = await this.vercelFetch('/v5/domains'); - } - const domains = data.domains ?? data; - return { - count: domains.length, - domains: domains.map((d: any) => ({ - name: d.name, - apexName: d.apexName ?? d.name, - verified: d.verified ?? false, - configured: d.misconfigured === false, - redirect: d.redirect ?? null, - createdAt: d.createdAt ? new Date(d.createdAt).toISOString() : null, - })), - }; - }, - }, - - // ── vercel_add_domain ────────────────────────────────────────────────── - { - name: 'vercel_add_domain', - description: 'Add a custom domain to a Vercel project', - inputSchema: { - type: 'object', - properties: { - projectId: { type: 'string', description: 'Project name or ID' }, - domain: { type: 'string', description: 'Domain name to add (e.g. app.yourdomain.com)' }, - }, - required: ['projectId', 'domain'], - }, - handler: async ({ projectId, domain }: any) => { - const data = await this.vercelFetch( - `/v10/projects/${encodeURIComponent(projectId)}/domains`, - { method: 'POST', body: { name: domain } } - ); - return { - added: true, - name: data.name, - verified: data.verified, - verification: data.verification ?? [], - }; - }, - }, - - // ── vercel_team_info ─────────────────────────────────────────────────── - { - name: 'vercel_team_info', - description: 'Get your Vercel account or team info and usage', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const { teamId } = await this.getAuth(); - const data = teamId - ? await this.vercelFetch(`/v2/teams/${teamId}`) - : await this.vercelFetch('/v2/user'); - return { - id: data.id ?? data.uid, - name: data.name ?? data.username, - email: data.email ?? null, - plan: data.plan?.id ?? data.subscription?.plan ?? null, - avatar: data.avatar ?? null, - createdAt: data.createdAt ? new Date(data.createdAt).toISOString() : null, - }; - }, - }, - - // ── vercel_set_team ──────────────────────────────────────────────────── - { - name: 'vercel_set_team', - description: 'Set the active Vercel team scope for all API calls', - inputSchema: { - type: 'object', - properties: { - teamId: { - type: 'string', - description: 'Team ID or slug (set to empty string to use personal account)', - }, - }, - required: ['teamId'], - }, - handler: async ({ teamId }: any) => { - if (teamId) { - await this.keychain.set('vercel', 'team_id', teamId); - return { set: true, teamId, scope: 'team' }; - } else { - // Clear team scope - await this.keychain.set('vercel', 'team_id', ''); - return { set: true, teamId: null, scope: 'personal' }; - } - }, - }, - ]; - } -} diff --git a/plugins/vercel/tsconfig.json b/plugins/vercel/tsconfig.json deleted file mode 100644 index dce5a7d..0000000 --- a/plugins/vercel/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "skipLibCheck": true, - "declaration": true - }, - "include": ["src/**/*"] -} diff --git a/plugins/weather/README.md b/plugins/weather/README.md deleted file mode 100644 index 366ae86..0000000 --- a/plugins/weather/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Weather Plugin for Conductor - -Install: `conductor install weather` - -## Setup - -**Authentication:** OpenWeatherMap API Key - -```bash -conductor plugins config weather api_key \ -`conductor plugins enable weather` -``` - -Get credentials at: https://openweathermap.org/api - -## Tools - -``` -weather_current, weather_forecast, weather_hourly, weather_uv_index, weather_alerts -``` - -Each tool is documented inline — ask Conductor what tools are available after installing. - -## Source - -Part of [thealxlabs/conductor-plugins](https://github.com/thealxlabs/conductor-plugins/tree/main/plugins/weather). diff --git a/plugins/weather/dist/index.d.ts b/plugins/weather/dist/index.d.ts deleted file mode 100644 index 1e46fc4..0000000 --- a/plugins/weather/dist/index.d.ts +++ /dev/null @@ -1,52 +0,0 @@ -interface PluginConfigField { - key: string; - label: string; - type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; - secret?: boolean; - service?: string; - description?: string; - options?: string[]; -} -interface PluginConfigSchema { - fields: PluginConfigField[]; - setupInstructions?: string; -} -interface PluginTool { - name: string; - description: string; - inputSchema: Record; - handler: (input: Record) => Promise; - requiresApproval?: boolean; -} -interface Plugin { - name: string; - description: string; - version: string; - configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - getTools(): PluginTool[]; - getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; - set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { - getConfig(): ConfigManagerLike; -} -export declare class WeatherPlugin implements Plugin { - name: string; - description: string; - version: string; - initialize(_conductor: Conductor): Promise; - isConfigured(): boolean; - /** Geocode a city name to lat/lon. */ - private geocode; - /** Convert WMO weather code to description. */ - private wmoCode; - getTools(): PluginTool[]; -} -export {}; diff --git a/plugins/weather/dist/index.js b/plugins/weather/dist/index.js deleted file mode 100644 index 39d82a8..0000000 --- a/plugins/weather/dist/index.js +++ /dev/null @@ -1,86 +0,0 @@ -// ── Inlined types (no external dependencies) ───────────────────────────────── -export class WeatherPlugin { - name = 'weather'; - description = 'Current weather and forecasts (powered by Open-Meteo, no API key needed)'; - version = '1.0.0'; - async initialize(_conductor) { } - isConfigured() { return true; } - /** Geocode a city name to lat/lon. */ - async geocode(city) { - const res = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`); - const data = await res.json(); - if (!data.results?.length) - throw new Error(`City not found: ${city}`); - const r = data.results[0]; - return { lat: r.latitude, lon: r.longitude, name: r.name, country: r.country }; - } - /** Convert WMO weather code to description. */ - wmoCode(code) { - const codes = { - 0: 'Clear sky', 1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast', - 45: 'Fog', 48: 'Rime fog', 51: 'Light drizzle', 53: 'Moderate drizzle', - 55: 'Dense drizzle', 61: 'Slight rain', 63: 'Moderate rain', 65: 'Heavy rain', - 71: 'Slight snow', 73: 'Moderate snow', 75: 'Heavy snow', 77: 'Snow grains', - 80: 'Slight showers', 81: 'Moderate showers', 82: 'Violent showers', - 85: 'Slight snow showers', 86: 'Heavy snow showers', - 95: 'Thunderstorm', 96: 'Thunderstorm w/ slight hail', 99: 'Thunderstorm w/ heavy hail', - }; - return codes[code] || `Unknown (${code})`; - } - getTools() { - return [ - { - name: 'weather_current', - description: 'Get current weather for a city', - inputSchema: { - type: 'object', - properties: { - city: { type: 'string', description: 'City name (e.g. "Toronto", "London")' }, - }, - required: ['city'], - }, - handler: async (input) => { - const geo = await this.geocode(input.city); - const res = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${geo.lat}&longitude=${geo.lon}¤t=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=celsius`); - const data = await res.json(); - const c = data.current; - return { - location: `${geo.name}, ${geo.country}`, - temperature: `${c.temperature_2m}°C`, - feels_like: `${c.apparent_temperature}°C`, - humidity: `${c.relative_humidity_2m}%`, - wind: `${c.wind_speed_10m} km/h`, - condition: this.wmoCode(c.weather_code), - }; - }, - }, - { - name: 'weather_forecast', - description: 'Get 7-day weather forecast for a city', - inputSchema: { - type: 'object', - properties: { - city: { type: 'string', description: 'City name' }, - }, - required: ['city'], - }, - handler: async (input) => { - const geo = await this.geocode(input.city); - const res = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${geo.lat}&longitude=${geo.lon}&daily=temperature_2m_max,temperature_2m_min,weather_code,precipitation_sum&temperature_unit=celsius&forecast_days=7`); - const data = await res.json(); - const d = data.daily; - return { - location: `${geo.name}, ${geo.country}`, - forecast: d.time.map((date, i) => ({ - date, - high: `${d.temperature_2m_max[i]}°C`, - low: `${d.temperature_2m_min[i]}°C`, - condition: this.wmoCode(d.weather_code[i]), - precipitation: `${d.precipitation_sum[i]} mm`, - })), - }; - }, - }, - ]; - } -} diff --git a/plugins/weather/dist/weather.js b/plugins/weather/dist/weather.js deleted file mode 100644 index 39d82a8..0000000 --- a/plugins/weather/dist/weather.js +++ /dev/null @@ -1,86 +0,0 @@ -// ── Inlined types (no external dependencies) ───────────────────────────────── -export class WeatherPlugin { - name = 'weather'; - description = 'Current weather and forecasts (powered by Open-Meteo, no API key needed)'; - version = '1.0.0'; - async initialize(_conductor) { } - isConfigured() { return true; } - /** Geocode a city name to lat/lon. */ - async geocode(city) { - const res = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`); - const data = await res.json(); - if (!data.results?.length) - throw new Error(`City not found: ${city}`); - const r = data.results[0]; - return { lat: r.latitude, lon: r.longitude, name: r.name, country: r.country }; - } - /** Convert WMO weather code to description. */ - wmoCode(code) { - const codes = { - 0: 'Clear sky', 1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast', - 45: 'Fog', 48: 'Rime fog', 51: 'Light drizzle', 53: 'Moderate drizzle', - 55: 'Dense drizzle', 61: 'Slight rain', 63: 'Moderate rain', 65: 'Heavy rain', - 71: 'Slight snow', 73: 'Moderate snow', 75: 'Heavy snow', 77: 'Snow grains', - 80: 'Slight showers', 81: 'Moderate showers', 82: 'Violent showers', - 85: 'Slight snow showers', 86: 'Heavy snow showers', - 95: 'Thunderstorm', 96: 'Thunderstorm w/ slight hail', 99: 'Thunderstorm w/ heavy hail', - }; - return codes[code] || `Unknown (${code})`; - } - getTools() { - return [ - { - name: 'weather_current', - description: 'Get current weather for a city', - inputSchema: { - type: 'object', - properties: { - city: { type: 'string', description: 'City name (e.g. "Toronto", "London")' }, - }, - required: ['city'], - }, - handler: async (input) => { - const geo = await this.geocode(input.city); - const res = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${geo.lat}&longitude=${geo.lon}¤t=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=celsius`); - const data = await res.json(); - const c = data.current; - return { - location: `${geo.name}, ${geo.country}`, - temperature: `${c.temperature_2m}°C`, - feels_like: `${c.apparent_temperature}°C`, - humidity: `${c.relative_humidity_2m}%`, - wind: `${c.wind_speed_10m} km/h`, - condition: this.wmoCode(c.weather_code), - }; - }, - }, - { - name: 'weather_forecast', - description: 'Get 7-day weather forecast for a city', - inputSchema: { - type: 'object', - properties: { - city: { type: 'string', description: 'City name' }, - }, - required: ['city'], - }, - handler: async (input) => { - const geo = await this.geocode(input.city); - const res = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${geo.lat}&longitude=${geo.lon}&daily=temperature_2m_max,temperature_2m_min,weather_code,precipitation_sum&temperature_unit=celsius&forecast_days=7`); - const data = await res.json(); - const d = data.daily; - return { - location: `${geo.name}, ${geo.country}`, - forecast: d.time.map((date, i) => ({ - date, - high: `${d.temperature_2m_max[i]}°C`, - low: `${d.temperature_2m_min[i]}°C`, - condition: this.wmoCode(d.weather_code[i]), - precipitation: `${d.precipitation_sum[i]} mm`, - })), - }; - }, - }, - ]; - } -} diff --git a/plugins/weather/package.json b/plugins/weather/package.json deleted file mode 100644 index 3bd0a91..0000000 --- a/plugins/weather/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@conductor-plugins/weather", - "version": "1.0.0", - "type": "module", - "main": "dist/weather.js", - "scripts": { - "build": "tsc", - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "typescript": "^5.0.0", - "@types/node": "^20.0.0" - } -} diff --git a/plugins/weather/src/index.ts b/plugins/weather/src/index.ts deleted file mode 100644 index 6d41858..0000000 --- a/plugins/weather/src/index.ts +++ /dev/null @@ -1,116 +0,0 @@ -// ── Inlined types (no external dependencies) ───────────────────────────────── - -interface PluginConfigField { - key: string; label: string; type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; secret?: boolean; service?: string; description?: string; options?: string[]; -} -interface PluginConfigSchema { fields: PluginConfigField[]; setupInstructions?: string; } -interface PluginTool { - name: string; description: string; inputSchema: Record; - handler: (input: Record) => Promise; requiresApproval?: boolean; -} -interface Plugin { - name: string; description: string; version: string; configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; isConfigured(): boolean; - getTools(): PluginTool[]; getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { getConfig(): ConfigManagerLike; } - -export class WeatherPlugin implements Plugin { - name = 'weather'; - description = 'Current weather and forecasts (powered by Open-Meteo, no API key needed)'; - version = '1.0.0'; - - async initialize(_conductor: Conductor): Promise {} - isConfigured(): boolean { return true; } - - /** Geocode a city name to lat/lon. */ - private async geocode(city: string): Promise<{ lat: number; lon: number; name: string; country: string }> { - const res = await fetch( - `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1` - ); - const data = await res.json() as any; - if (!data.results?.length) throw new Error(`City not found: ${city}`); - const r = data.results[0]; - return { lat: r.latitude, lon: r.longitude, name: r.name, country: r.country }; - } - - /** Convert WMO weather code to description. */ - private wmoCode(code: number): string { - const codes: Record = { - 0: 'Clear sky', 1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast', - 45: 'Fog', 48: 'Rime fog', 51: 'Light drizzle', 53: 'Moderate drizzle', - 55: 'Dense drizzle', 61: 'Slight rain', 63: 'Moderate rain', 65: 'Heavy rain', - 71: 'Slight snow', 73: 'Moderate snow', 75: 'Heavy snow', 77: 'Snow grains', - 80: 'Slight showers', 81: 'Moderate showers', 82: 'Violent showers', - 85: 'Slight snow showers', 86: 'Heavy snow showers', - 95: 'Thunderstorm', 96: 'Thunderstorm w/ slight hail', 99: 'Thunderstorm w/ heavy hail', - }; - return codes[code] || `Unknown (${code})`; - } - - getTools(): PluginTool[] { - return [ - { - name: 'weather_current', - description: 'Get current weather for a city', - inputSchema: { - type: 'object', - properties: { - city: { type: 'string', description: 'City name (e.g. "Toronto", "London")' }, - }, - required: ['city'], - }, - handler: async (input: any) => { - const geo = await this.geocode(input.city); - const res = await fetch( - `https://api.open-meteo.com/v1/forecast?latitude=${geo.lat}&longitude=${geo.lon}¤t=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=celsius` - ); - const data = await res.json() as any; - const c = data.current; - return { - location: `${geo.name}, ${geo.country}`, - temperature: `${c.temperature_2m}°C`, - feels_like: `${c.apparent_temperature}°C`, - humidity: `${c.relative_humidity_2m}%`, - wind: `${c.wind_speed_10m} km/h`, - condition: this.wmoCode(c.weather_code), - }; - }, - }, - { - name: 'weather_forecast', - description: 'Get 7-day weather forecast for a city', - inputSchema: { - type: 'object', - properties: { - city: { type: 'string', description: 'City name' }, - }, - required: ['city'], - }, - handler: async (input: any) => { - const geo = await this.geocode(input.city); - const res = await fetch( - `https://api.open-meteo.com/v1/forecast?latitude=${geo.lat}&longitude=${geo.lon}&daily=temperature_2m_max,temperature_2m_min,weather_code,precipitation_sum&temperature_unit=celsius&forecast_days=7` - ); - const data = await res.json() as any; - const d = data.daily; - return { - location: `${geo.name}, ${geo.country}`, - forecast: d.time.map((date: string, i: number) => ({ - date, - high: `${d.temperature_2m_max[i]}°C`, - low: `${d.temperature_2m_min[i]}°C`, - condition: this.wmoCode(d.weather_code[i]), - precipitation: `${d.precipitation_sum[i]} mm`, - })), - }; - }, - }, - ]; - } -} diff --git a/plugins/weather/tsconfig.json b/plugins/weather/tsconfig.json deleted file mode 100644 index dce5a7d..0000000 --- a/plugins/weather/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "skipLibCheck": true, - "declaration": true - }, - "include": ["src/**/*"] -} diff --git a/plugins/x/README.md b/plugins/x/README.md deleted file mode 100644 index bfeff3b..0000000 --- a/plugins/x/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# X (Twitter) Plugin for Conductor - -Install: `conductor install x` - -## Setup - -**Authentication:** Bearer Token + OAuth - -```bash -conductor plugins config x bearer_token \ -`conductor plugins enable x` -``` - -Get credentials at: https://developer.twitter.com/en/docs - -## Tools - -``` -x_get_user, x_get_timeline, x_search, x_post_tweet, x_reply, x_like, x_retweet, x_delete_tweet -``` - -Each tool is documented inline — ask Conductor what tools are available after installing. - -## Source - -Part of [thealxlabs/conductor-plugins](https://github.com/thealxlabs/conductor-plugins/tree/main/plugins/x). diff --git a/plugins/x/dist/index.d.ts b/plugins/x/dist/index.d.ts deleted file mode 100644 index 2509b5b..0000000 --- a/plugins/x/dist/index.d.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * X (Twitter) Plugin - * - * Post tweets, search X, get user info, manage timeline. - * Uses X API v2 with a Bearer Token (read) and OAuth 1.0a (write). - * - * Setup: - * For read-only (search, timeline, user lookup): - * 1. Go to https://developer.x.com and create a project + app - * 2. Copy the Bearer Token - * 3. Run: conductor plugins config x bearer_token - * - * For posting (tweets, likes, follows): - * Also needed: API Key, API Secret, Access Token, Access Token Secret - * These use OAuth 1.0a for user-level write access. - * Run: conductor plugins config x api_key - * conductor plugins config x api_secret - * conductor plugins config x access_token - * conductor plugins config x access_secret - * - * Stored in keychain as: x / bearer_token, x / api_key, etc. - * - * Note: X API free tier allows ~500k tweet reads/month and posting. - * Basic plan ($100/mo) removes most limits. - */ -interface PluginConfigField { - key: string; - label: string; - type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; - secret?: boolean; - service?: string; - description?: string; - options?: string[]; -} -interface PluginConfigSchema { - fields: PluginConfigField[]; - setupInstructions?: string; -} -interface PluginTool { - name: string; - description: string; - inputSchema: Record; - handler: (input: Record) => Promise; - requiresApproval?: boolean; -} -interface Plugin { - name: string; - description: string; - version: string; - configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - getTools(): PluginTool[]; - getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; - set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { - getConfig(): ConfigManagerLike; -} -export declare class XPlugin implements Plugin { - name: string; - description: string; - version: string; - configSchema: { - fields: { - key: string; - label: string; - type: "password"; - required: boolean; - secret: boolean; - service: string; - }[]; - setupInstructions: string; - }; - private keychain; - initialize(conductor: Conductor): Promise; - isConfigured(): boolean; - private getBearerToken; - private getOAuthCreds; - /** Bearer token fetch (read operations) */ - private xFetch; - /** OAuth 1.0a HMAC-SHA1 signature for write operations */ - private buildOAuthHeader; - /** OAuth 1.0a signed POST (write operations) */ - private xPost; - /** Format tweet fields for output */ - private formatTweet; - getTools(): PluginTool[]; -} -export {}; diff --git a/plugins/x/dist/index.js b/plugins/x/dist/index.js deleted file mode 100644 index e7a9bc2..0000000 --- a/plugins/x/dist/index.js +++ /dev/null @@ -1,441 +0,0 @@ -/** - * X (Twitter) Plugin - * - * Post tweets, search X, get user info, manage timeline. - * Uses X API v2 with a Bearer Token (read) and OAuth 1.0a (write). - * - * Setup: - * For read-only (search, timeline, user lookup): - * 1. Go to https://developer.x.com and create a project + app - * 2. Copy the Bearer Token - * 3. Run: conductor plugins config x bearer_token - * - * For posting (tweets, likes, follows): - * Also needed: API Key, API Secret, Access Token, Access Token Secret - * These use OAuth 1.0a for user-level write access. - * Run: conductor plugins config x api_key - * conductor plugins config x api_secret - * conductor plugins config x access_token - * conductor plugins config x access_secret - * - * Stored in keychain as: x / bearer_token, x / api_key, etc. - * - * Note: X API free tier allows ~500k tweet reads/month and posting. - * Basic plan ($100/mo) removes most limits. - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service, _account, _value) { } - async delete(_service, _account) { } -} -async function withRetry(fn, maxAttempts = 3, baseDelayMs = 500) { - let lastError; - for (let attempt = 0; attempt < maxAttempts; attempt++) { - try { - return await fn(); - } - catch (err) { - lastError = err; - if (err?.status && err.status >= 400 && err.status < 500 && err.status !== 429) { - throw err; - } - if (attempt < maxAttempts - 1) { - await new Promise((r) => setTimeout(r, baseDelayMs * Math.pow(2, attempt))); - } - } - } - throw lastError; -} -import crypto from 'crypto'; -const X_BASE = 'https://api.twitter.com/2'; -export class XPlugin { - name = 'x'; - description = 'Post tweets, search X, get timelines and user info — requires X API credentials'; - version = '1.0.0'; - configSchema = { - fields: [ - { - key: 'bearer_token', - label: 'X Bearer Token', - type: 'password', - required: true, - secret: true, - service: 'x' - }, - { - key: 'api_key', - label: 'X API Key (Consumer)', - type: 'password', - required: true, - secret: true, - service: 'x' - }, - { - key: 'api_secret', - label: 'X API Secret (Consumer)', - type: 'password', - required: true, - secret: true, - service: 'x' - }, - { - key: 'access_token', - label: 'X Access Token', - type: 'password', - required: true, - secret: true, - service: 'x' - }, - { - key: 'access_secret', - label: 'X Access Secret', - type: 'password', - required: true, - secret: true, - service: 'x' - } - ], - setupInstructions: 'Create a Project and App in developer.x.com. Enable "User authentication settings" with OAuth 1.0a permissions for write access.' - }; - keychain; - async initialize(conductor) { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - isConfigured() { return true; } - async getBearerToken() { - const token = await this.keychain.get('x', 'bearer_token'); - if (!token) { - throw new Error('X Bearer Token not configured.\n' + - 'Get one at https://developer.x.com and run:\n' + - ' conductor plugins config x bearer_token '); - } - return token; - } - async getOAuthCreds() { - const [apiKey, apiSecret, accessToken, accessSecret] = await Promise.all([ - this.keychain.get('x', 'api_key'), - this.keychain.get('x', 'api_secret'), - this.keychain.get('x', 'access_token'), - this.keychain.get('x', 'access_secret'), - ]); - if (!apiKey || !apiSecret || !accessToken || !accessSecret) { - throw new Error('X write credentials not fully configured. Run:\n' + - ' conductor plugins config x api_key \n' + - ' conductor plugins config x api_secret \n' + - ' conductor plugins config x access_token \n' + - ' conductor plugins config x access_secret '); - } - return { apiKey, apiSecret, accessToken, accessSecret }; - } - /** Bearer token fetch (read operations) */ - async xFetch(path, params) { - const token = await this.getBearerToken(); - const url = new URL(`${X_BASE}${path}`); - if (params) { - for (const [k, v] of Object.entries(params)) - url.searchParams.set(k, v); - } - return withRetry(async () => { - const res = await fetch(url.toString(), { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!res.ok) { - let errStr = res.statusText; - try { - const errJSON = await res.json(); - errStr = errJSON.detail ?? errJSON.title ?? res.statusText; - } - catch { } - const error = new Error(`X API ${res.status}: ${errStr}`); - error.status = res.status; - throw error; - } - return res.json(); - }); - } - /** OAuth 1.0a HMAC-SHA1 signature for write operations */ - buildOAuthHeader(method, url, creds) { - const nonce = crypto.randomBytes(16).toString('hex'); - const timestamp = Math.floor(Date.now() / 1000).toString(); - const oauthParams = { - oauth_consumer_key: creds.apiKey, - oauth_nonce: nonce, - oauth_signature_method: 'HMAC-SHA1', - oauth_timestamp: timestamp, - oauth_token: creds.accessToken, - oauth_version: '1.0', - }; - const allParams = { ...oauthParams }; - const sortedParams = Object.keys(allParams) - .sort() - .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(allParams[k])}`) - .join('&'); - const baseString = [ - method.toUpperCase(), - encodeURIComponent(url), - encodeURIComponent(sortedParams), - ].join('&'); - const signingKey = `${encodeURIComponent(creds.apiSecret)}&${encodeURIComponent(creds.accessSecret)}`; - const signature = crypto - .createHmac('sha1', signingKey) - .update(baseString) - .digest('base64'); - oauthParams['oauth_signature'] = signature; - const headerValue = Object.keys(oauthParams) - .map((k) => `${encodeURIComponent(k)}="${encodeURIComponent(oauthParams[k])}"`) - .join(', '); - return `OAuth ${headerValue}`; - } - /** OAuth 1.0a signed POST (write operations) */ - async xPost(path, body) { - const creds = await this.getOAuthCreds(); - const url = `${X_BASE}${path}`; - const authHeader = this.buildOAuthHeader('POST', url, creds); - return withRetry(async () => { - const res = await fetch(url, { - method: 'POST', - headers: { - Authorization: authHeader, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - if (!res.ok) { - let errStr = res.statusText; - try { - const errJSON = await res.json(); - errStr = errJSON.detail ?? errJSON.title ?? res.statusText; - } - catch { } - const error = new Error(`X API ${res.status}: ${errStr}`); - error.status = res.status; - throw error; - } - return res.json(); - }); - } - /** Format tweet fields for output */ - formatTweet(t, includes) { - const author = includes?.users?.find((u) => u.id === t.author_id); - return { - id: t.id, - text: t.text, - author: author - ? { id: author.id, username: author.username, name: author.name } - : { id: t.author_id }, - createdAt: t.created_at ?? '', - publicMetrics: t.public_metrics ?? {}, - url: t.author_id ? `https://x.com/i/web/status/${t.id}` : '', - }; - } - getTools() { - return [ - // ── x_search ──────────────────────────────────────────────────────────── - { - name: 'x_search', - description: 'Search recent tweets on X. Supports operators like from:user, #hashtag, -filter:retweets', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'X search query' }, - maxResults: { - type: 'number', - description: 'Max tweets to return (10–100, default: 10)', - }, - sortOrder: { - type: 'string', - enum: ['recency', 'relevancy'], - description: 'Sort by recency or relevancy (default: recency)', - }, - }, - required: ['query'], - }, - handler: async ({ query, maxResults = 10, sortOrder = 'recency' }) => { - const res = await this.xFetch('/tweets/search/recent', { - query, - max_results: String(Math.min(Math.max(maxResults, 10), 100)), - sort_order: sortOrder, - 'tweet.fields': 'created_at,public_metrics,author_id', - expansions: 'author_id', - 'user.fields': 'username,name', - }); - return { - count: res.data?.length ?? 0, - tweets: (res.data ?? []).map((t) => this.formatTweet(t, res.includes)), - }; - }, - }, - // ── x_get_user ────────────────────────────────────────────────────────── - { - name: 'x_get_user', - description: 'Get an X user profile by username or ID', - inputSchema: { - type: 'object', - properties: { - username: { type: 'string', description: 'X username (without @)' }, - userId: { type: 'string', description: 'X user ID (alternative to username)' }, - }, - }, - handler: async ({ username, userId }) => { - if (!username && !userId) - throw new Error('Provide username or userId.'); - const path = userId ? `/users/${userId}` : `/users/by/username/${username}`; - const res = await this.xFetch(path, { - 'user.fields': 'name,username,description,public_metrics,created_at,verified,location,url', - }); - const u = res.data; - return { - id: u.id, - username: u.username, - name: u.name, - bio: u.description ?? '', - location: u.location ?? '', - url: u.url ?? '', - followers: u.public_metrics?.followers_count ?? 0, - following: u.public_metrics?.following_count ?? 0, - tweetCount: u.public_metrics?.tweet_count ?? 0, - verified: u.verified ?? false, - createdAt: u.created_at ?? '', - xUrl: `https://x.com/${u.username}`, - }; - }, - }, - // ── x_get_timeline ────────────────────────────────────────────────────── - { - name: 'x_get_timeline', - description: "Get a user's recent tweets", - inputSchema: { - type: 'object', - properties: { - username: { type: 'string', description: 'X username (without @)' }, - maxResults: { type: 'number', description: 'Max tweets (5–100, default: 10)' }, - excludeReplies: { - type: 'boolean', - description: 'Exclude reply tweets (default: false)', - }, - excludeRetweets: { - type: 'boolean', - description: 'Exclude retweets (default: false)', - }, - }, - required: ['username'], - }, - handler: async ({ username, maxResults = 10, excludeReplies = false, excludeRetweets = false }) => { - // First get user ID - const userRes = await this.xFetch(`/users/by/username/${username}`); - const userId = userRes.data?.id; - if (!userId) - throw new Error(`User not found: ${username}`); - const exclude = []; - if (excludeReplies) - exclude.push('replies'); - if (excludeRetweets) - exclude.push('retweets'); - const params = { - max_results: String(Math.min(Math.max(maxResults, 5), 100)), - 'tweet.fields': 'created_at,public_metrics', - }; - if (exclude.length) - params.exclude = exclude.join(','); - const res = await this.xFetch(`/users/${userId}/tweets`, params); - return { - username, - count: res.data?.length ?? 0, - tweets: (res.data ?? []).map((t) => ({ - id: t.id, - text: t.text, - createdAt: t.created_at ?? '', - likes: t.public_metrics?.like_count ?? 0, - retweets: t.public_metrics?.retweet_count ?? 0, - replies: t.public_metrics?.reply_count ?? 0, - url: `https://x.com/${username}/status/${t.id}`, - })), - }; - }, - }, - // ── x_post_tweet ──────────────────────────────────────────────────────── - { - name: 'x_post_tweet', - description: 'Post a new tweet to X (requires OAuth 1.0a write credentials)', - requiresApproval: true, - inputSchema: { - type: 'object', - properties: { - text: { type: 'string', description: 'Tweet text (max 280 chars)' }, - replyToId: { - type: 'string', - description: 'Tweet ID to reply to (optional)', - }, - }, - required: ['text'], - }, - handler: async ({ text, replyToId }) => { - if (text.length > 280) { - return { error: `Tweet too long: ${text.length} chars (max 280).` }; - } - const body = { text }; - if (replyToId) - body.reply = { in_reply_to_tweet_id: replyToId }; - const res = await this.xPost('/tweets', body); - return { - posted: true, - id: res.data?.id, - text: res.data?.text, - url: `https://x.com/i/web/status/${res.data?.id}`, - }; - }, - }, - // ── x_delete_tweet ────────────────────────────────────────────────────── - { - name: 'x_delete_tweet', - description: 'Delete one of your tweets (requires OAuth write credentials)', - requiresApproval: true, - inputSchema: { - type: 'object', - properties: { - tweetId: { type: 'string', description: 'Tweet ID to delete' }, - }, - required: ['tweetId'], - }, - handler: async ({ tweetId }) => { - const creds = await this.getOAuthCreds(); - const url = `${X_BASE}/tweets/${tweetId}`; - const authHeader = this.buildOAuthHeader('DELETE', url, creds); - const res = await fetch(url, { - method: 'DELETE', - headers: { Authorization: authHeader }, - }); - if (!res.ok) - throw new Error(`Delete failed: ${res.status} ${res.statusText}`); - const data = await res.json(); - return { deleted: data.data?.deleted ?? true, tweetId }; - }, - }, - // ── x_like_tweet ──────────────────────────────────────────────────────── - { - name: 'x_like_tweet', - description: 'Like a tweet (requires OAuth write credentials and your user ID)', - requiresApproval: true, - inputSchema: { - type: 'object', - properties: { - tweetId: { type: 'string', description: 'Tweet ID to like' }, - userId: { type: 'string', description: 'Your X user ID (required for liking)' }, - }, - required: ['tweetId', 'userId'], - }, - handler: async ({ tweetId, userId }) => { - const res = await this.xPost(`/users/${userId}/likes`, { tweet_id: tweetId }); - return { liked: res.data?.liked ?? true, tweetId }; - }, - }, - ]; - } -} diff --git a/plugins/x/dist/x.js b/plugins/x/dist/x.js deleted file mode 100644 index e7a9bc2..0000000 --- a/plugins/x/dist/x.js +++ /dev/null @@ -1,441 +0,0 @@ -/** - * X (Twitter) Plugin - * - * Post tweets, search X, get user info, manage timeline. - * Uses X API v2 with a Bearer Token (read) and OAuth 1.0a (write). - * - * Setup: - * For read-only (search, timeline, user lookup): - * 1. Go to https://developer.x.com and create a project + app - * 2. Copy the Bearer Token - * 3. Run: conductor plugins config x bearer_token - * - * For posting (tweets, likes, follows): - * Also needed: API Key, API Secret, Access Token, Access Token Secret - * These use OAuth 1.0a for user-level write access. - * Run: conductor plugins config x api_key - * conductor plugins config x api_secret - * conductor plugins config x access_token - * conductor plugins config x access_secret - * - * Stored in keychain as: x / bearer_token, x / api_key, etc. - * - * Note: X API free tier allows ~500k tweet reads/month and posting. - * Basic plan ($100/mo) removes most limits. - */ -class Keychain { - dir; - constructor(dir) { - this.dir = dir; - } - async get(service, account) { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service, _account, _value) { } - async delete(_service, _account) { } -} -async function withRetry(fn, maxAttempts = 3, baseDelayMs = 500) { - let lastError; - for (let attempt = 0; attempt < maxAttempts; attempt++) { - try { - return await fn(); - } - catch (err) { - lastError = err; - if (err?.status && err.status >= 400 && err.status < 500 && err.status !== 429) { - throw err; - } - if (attempt < maxAttempts - 1) { - await new Promise((r) => setTimeout(r, baseDelayMs * Math.pow(2, attempt))); - } - } - } - throw lastError; -} -import crypto from 'crypto'; -const X_BASE = 'https://api.twitter.com/2'; -export class XPlugin { - name = 'x'; - description = 'Post tweets, search X, get timelines and user info — requires X API credentials'; - version = '1.0.0'; - configSchema = { - fields: [ - { - key: 'bearer_token', - label: 'X Bearer Token', - type: 'password', - required: true, - secret: true, - service: 'x' - }, - { - key: 'api_key', - label: 'X API Key (Consumer)', - type: 'password', - required: true, - secret: true, - service: 'x' - }, - { - key: 'api_secret', - label: 'X API Secret (Consumer)', - type: 'password', - required: true, - secret: true, - service: 'x' - }, - { - key: 'access_token', - label: 'X Access Token', - type: 'password', - required: true, - secret: true, - service: 'x' - }, - { - key: 'access_secret', - label: 'X Access Secret', - type: 'password', - required: true, - secret: true, - service: 'x' - } - ], - setupInstructions: 'Create a Project and App in developer.x.com. Enable "User authentication settings" with OAuth 1.0a permissions for write access.' - }; - keychain; - async initialize(conductor) { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - isConfigured() { return true; } - async getBearerToken() { - const token = await this.keychain.get('x', 'bearer_token'); - if (!token) { - throw new Error('X Bearer Token not configured.\n' + - 'Get one at https://developer.x.com and run:\n' + - ' conductor plugins config x bearer_token '); - } - return token; - } - async getOAuthCreds() { - const [apiKey, apiSecret, accessToken, accessSecret] = await Promise.all([ - this.keychain.get('x', 'api_key'), - this.keychain.get('x', 'api_secret'), - this.keychain.get('x', 'access_token'), - this.keychain.get('x', 'access_secret'), - ]); - if (!apiKey || !apiSecret || !accessToken || !accessSecret) { - throw new Error('X write credentials not fully configured. Run:\n' + - ' conductor plugins config x api_key \n' + - ' conductor plugins config x api_secret \n' + - ' conductor plugins config x access_token \n' + - ' conductor plugins config x access_secret '); - } - return { apiKey, apiSecret, accessToken, accessSecret }; - } - /** Bearer token fetch (read operations) */ - async xFetch(path, params) { - const token = await this.getBearerToken(); - const url = new URL(`${X_BASE}${path}`); - if (params) { - for (const [k, v] of Object.entries(params)) - url.searchParams.set(k, v); - } - return withRetry(async () => { - const res = await fetch(url.toString(), { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!res.ok) { - let errStr = res.statusText; - try { - const errJSON = await res.json(); - errStr = errJSON.detail ?? errJSON.title ?? res.statusText; - } - catch { } - const error = new Error(`X API ${res.status}: ${errStr}`); - error.status = res.status; - throw error; - } - return res.json(); - }); - } - /** OAuth 1.0a HMAC-SHA1 signature for write operations */ - buildOAuthHeader(method, url, creds) { - const nonce = crypto.randomBytes(16).toString('hex'); - const timestamp = Math.floor(Date.now() / 1000).toString(); - const oauthParams = { - oauth_consumer_key: creds.apiKey, - oauth_nonce: nonce, - oauth_signature_method: 'HMAC-SHA1', - oauth_timestamp: timestamp, - oauth_token: creds.accessToken, - oauth_version: '1.0', - }; - const allParams = { ...oauthParams }; - const sortedParams = Object.keys(allParams) - .sort() - .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(allParams[k])}`) - .join('&'); - const baseString = [ - method.toUpperCase(), - encodeURIComponent(url), - encodeURIComponent(sortedParams), - ].join('&'); - const signingKey = `${encodeURIComponent(creds.apiSecret)}&${encodeURIComponent(creds.accessSecret)}`; - const signature = crypto - .createHmac('sha1', signingKey) - .update(baseString) - .digest('base64'); - oauthParams['oauth_signature'] = signature; - const headerValue = Object.keys(oauthParams) - .map((k) => `${encodeURIComponent(k)}="${encodeURIComponent(oauthParams[k])}"`) - .join(', '); - return `OAuth ${headerValue}`; - } - /** OAuth 1.0a signed POST (write operations) */ - async xPost(path, body) { - const creds = await this.getOAuthCreds(); - const url = `${X_BASE}${path}`; - const authHeader = this.buildOAuthHeader('POST', url, creds); - return withRetry(async () => { - const res = await fetch(url, { - method: 'POST', - headers: { - Authorization: authHeader, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - if (!res.ok) { - let errStr = res.statusText; - try { - const errJSON = await res.json(); - errStr = errJSON.detail ?? errJSON.title ?? res.statusText; - } - catch { } - const error = new Error(`X API ${res.status}: ${errStr}`); - error.status = res.status; - throw error; - } - return res.json(); - }); - } - /** Format tweet fields for output */ - formatTweet(t, includes) { - const author = includes?.users?.find((u) => u.id === t.author_id); - return { - id: t.id, - text: t.text, - author: author - ? { id: author.id, username: author.username, name: author.name } - : { id: t.author_id }, - createdAt: t.created_at ?? '', - publicMetrics: t.public_metrics ?? {}, - url: t.author_id ? `https://x.com/i/web/status/${t.id}` : '', - }; - } - getTools() { - return [ - // ── x_search ──────────────────────────────────────────────────────────── - { - name: 'x_search', - description: 'Search recent tweets on X. Supports operators like from:user, #hashtag, -filter:retweets', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'X search query' }, - maxResults: { - type: 'number', - description: 'Max tweets to return (10–100, default: 10)', - }, - sortOrder: { - type: 'string', - enum: ['recency', 'relevancy'], - description: 'Sort by recency or relevancy (default: recency)', - }, - }, - required: ['query'], - }, - handler: async ({ query, maxResults = 10, sortOrder = 'recency' }) => { - const res = await this.xFetch('/tweets/search/recent', { - query, - max_results: String(Math.min(Math.max(maxResults, 10), 100)), - sort_order: sortOrder, - 'tweet.fields': 'created_at,public_metrics,author_id', - expansions: 'author_id', - 'user.fields': 'username,name', - }); - return { - count: res.data?.length ?? 0, - tweets: (res.data ?? []).map((t) => this.formatTweet(t, res.includes)), - }; - }, - }, - // ── x_get_user ────────────────────────────────────────────────────────── - { - name: 'x_get_user', - description: 'Get an X user profile by username or ID', - inputSchema: { - type: 'object', - properties: { - username: { type: 'string', description: 'X username (without @)' }, - userId: { type: 'string', description: 'X user ID (alternative to username)' }, - }, - }, - handler: async ({ username, userId }) => { - if (!username && !userId) - throw new Error('Provide username or userId.'); - const path = userId ? `/users/${userId}` : `/users/by/username/${username}`; - const res = await this.xFetch(path, { - 'user.fields': 'name,username,description,public_metrics,created_at,verified,location,url', - }); - const u = res.data; - return { - id: u.id, - username: u.username, - name: u.name, - bio: u.description ?? '', - location: u.location ?? '', - url: u.url ?? '', - followers: u.public_metrics?.followers_count ?? 0, - following: u.public_metrics?.following_count ?? 0, - tweetCount: u.public_metrics?.tweet_count ?? 0, - verified: u.verified ?? false, - createdAt: u.created_at ?? '', - xUrl: `https://x.com/${u.username}`, - }; - }, - }, - // ── x_get_timeline ────────────────────────────────────────────────────── - { - name: 'x_get_timeline', - description: "Get a user's recent tweets", - inputSchema: { - type: 'object', - properties: { - username: { type: 'string', description: 'X username (without @)' }, - maxResults: { type: 'number', description: 'Max tweets (5–100, default: 10)' }, - excludeReplies: { - type: 'boolean', - description: 'Exclude reply tweets (default: false)', - }, - excludeRetweets: { - type: 'boolean', - description: 'Exclude retweets (default: false)', - }, - }, - required: ['username'], - }, - handler: async ({ username, maxResults = 10, excludeReplies = false, excludeRetweets = false }) => { - // First get user ID - const userRes = await this.xFetch(`/users/by/username/${username}`); - const userId = userRes.data?.id; - if (!userId) - throw new Error(`User not found: ${username}`); - const exclude = []; - if (excludeReplies) - exclude.push('replies'); - if (excludeRetweets) - exclude.push('retweets'); - const params = { - max_results: String(Math.min(Math.max(maxResults, 5), 100)), - 'tweet.fields': 'created_at,public_metrics', - }; - if (exclude.length) - params.exclude = exclude.join(','); - const res = await this.xFetch(`/users/${userId}/tweets`, params); - return { - username, - count: res.data?.length ?? 0, - tweets: (res.data ?? []).map((t) => ({ - id: t.id, - text: t.text, - createdAt: t.created_at ?? '', - likes: t.public_metrics?.like_count ?? 0, - retweets: t.public_metrics?.retweet_count ?? 0, - replies: t.public_metrics?.reply_count ?? 0, - url: `https://x.com/${username}/status/${t.id}`, - })), - }; - }, - }, - // ── x_post_tweet ──────────────────────────────────────────────────────── - { - name: 'x_post_tweet', - description: 'Post a new tweet to X (requires OAuth 1.0a write credentials)', - requiresApproval: true, - inputSchema: { - type: 'object', - properties: { - text: { type: 'string', description: 'Tweet text (max 280 chars)' }, - replyToId: { - type: 'string', - description: 'Tweet ID to reply to (optional)', - }, - }, - required: ['text'], - }, - handler: async ({ text, replyToId }) => { - if (text.length > 280) { - return { error: `Tweet too long: ${text.length} chars (max 280).` }; - } - const body = { text }; - if (replyToId) - body.reply = { in_reply_to_tweet_id: replyToId }; - const res = await this.xPost('/tweets', body); - return { - posted: true, - id: res.data?.id, - text: res.data?.text, - url: `https://x.com/i/web/status/${res.data?.id}`, - }; - }, - }, - // ── x_delete_tweet ────────────────────────────────────────────────────── - { - name: 'x_delete_tweet', - description: 'Delete one of your tweets (requires OAuth write credentials)', - requiresApproval: true, - inputSchema: { - type: 'object', - properties: { - tweetId: { type: 'string', description: 'Tweet ID to delete' }, - }, - required: ['tweetId'], - }, - handler: async ({ tweetId }) => { - const creds = await this.getOAuthCreds(); - const url = `${X_BASE}/tweets/${tweetId}`; - const authHeader = this.buildOAuthHeader('DELETE', url, creds); - const res = await fetch(url, { - method: 'DELETE', - headers: { Authorization: authHeader }, - }); - if (!res.ok) - throw new Error(`Delete failed: ${res.status} ${res.statusText}`); - const data = await res.json(); - return { deleted: data.data?.deleted ?? true, tweetId }; - }, - }, - // ── x_like_tweet ──────────────────────────────────────────────────────── - { - name: 'x_like_tweet', - description: 'Like a tweet (requires OAuth write credentials and your user ID)', - requiresApproval: true, - inputSchema: { - type: 'object', - properties: { - tweetId: { type: 'string', description: 'Tweet ID to like' }, - userId: { type: 'string', description: 'Your X user ID (required for liking)' }, - }, - required: ['tweetId', 'userId'], - }, - handler: async ({ tweetId, userId }) => { - const res = await this.xPost(`/users/${userId}/likes`, { tweet_id: tweetId }); - return { liked: res.data?.liked ?? true, tweetId }; - }, - }, - ]; - } -} diff --git a/plugins/x/package.json b/plugins/x/package.json deleted file mode 100644 index b48a948..0000000 --- a/plugins/x/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@conductor-plugins/x", - "version": "1.0.0", - "type": "module", - "main": "dist/x.js", - "scripts": { - "build": "tsc", - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "typescript": "^5.0.0", - "@types/node": "^20.0.0" - } -} diff --git a/plugins/x/src/index.ts b/plugins/x/src/index.ts deleted file mode 100644 index eccc3d3..0000000 --- a/plugins/x/src/index.ts +++ /dev/null @@ -1,500 +0,0 @@ -/** - * X (Twitter) Plugin - * - * Post tweets, search X, get user info, manage timeline. - * Uses X API v2 with a Bearer Token (read) and OAuth 1.0a (write). - * - * Setup: - * For read-only (search, timeline, user lookup): - * 1. Go to https://developer.x.com and create a project + app - * 2. Copy the Bearer Token - * 3. Run: conductor plugins config x bearer_token - * - * For posting (tweets, likes, follows): - * Also needed: API Key, API Secret, Access Token, Access Token Secret - * These use OAuth 1.0a for user-level write access. - * Run: conductor plugins config x api_key - * conductor plugins config x api_secret - * conductor plugins config x access_token - * conductor plugins config x access_secret - * - * Stored in keychain as: x / bearer_token, x / api_key, etc. - * - * Note: X API free tier allows ~500k tweet reads/month and posting. - * Basic plan ($100/mo) removes most limits. - */ - -// ── Inlined types (no external dependencies) ───────────────────────────────── - -interface PluginConfigField { - key: string; label: string; type: 'string' | 'password' | 'boolean' | 'select'; - required?: boolean; secret?: boolean; service?: string; description?: string; options?: string[]; -} -interface PluginConfigSchema { fields: PluginConfigField[]; setupInstructions?: string; } -interface PluginTool { - name: string; description: string; inputSchema: Record; - handler: (input: Record) => Promise; requiresApproval?: boolean; -} -interface Plugin { - name: string; description: string; version: string; configSchema?: PluginConfigSchema; - initialize(conductor: Conductor): Promise; isConfigured(): boolean; - getTools(): PluginTool[]; getContext?(): Promise; -} -interface ConfigManagerLike { - get(key: string): T | undefined; set(key: string, value: unknown): Promise; - getConfigDir(): string; -} -interface Conductor { getConfig(): ConfigManagerLike; } -class Keychain { - constructor(private dir: string) {} - async get(service: string, account: string): Promise { - const key = `CONDUCTOR_${service.toUpperCase()}_${account.toUpperCase().replace(/-/g, '_')}`; - return process.env[key] ?? null; - } - async set(_service: string, _account: string, _value: string): Promise {} - async delete(_service: string, _account: string): Promise {} -} -async function withRetry( - fn: () => Promise, - maxAttempts = 3, - baseDelayMs = 500 -): Promise { - let lastError: unknown; - for (let attempt = 0; attempt < maxAttempts; attempt++) { - try { - return await fn(); - } catch (err: any) { - lastError = err; - if (err?.status && err.status >= 400 && err.status < 500 && err.status !== 429) { - throw err; - } - if (attempt < maxAttempts - 1) { - await new Promise((r) => setTimeout(r, baseDelayMs * Math.pow(2, attempt))); - } - } - } - throw lastError; -} - -import crypto from 'crypto'; - -const X_BASE = 'https://api.twitter.com/2'; - -export class XPlugin implements Plugin { - name = 'x'; - description = 'Post tweets, search X, get timelines and user info — requires X API credentials'; - version = '1.0.0'; - - configSchema = { - fields: [ - { - key: 'bearer_token', - label: 'X Bearer Token', - type: 'password' as const, - required: true, - secret: true, - service: 'x' - }, - { - key: 'api_key', - label: 'X API Key (Consumer)', - type: 'password' as const, - required: true, - secret: true, - service: 'x' - }, - { - key: 'api_secret', - label: 'X API Secret (Consumer)', - type: 'password' as const, - required: true, - secret: true, - service: 'x' - }, - { - key: 'access_token', - label: 'X Access Token', - type: 'password' as const, - required: true, - secret: true, - service: 'x' - }, - { - key: 'access_secret', - label: 'X Access Secret', - type: 'password' as const, - required: true, - secret: true, - service: 'x' - } - ], - setupInstructions: 'Create a Project and App in developer.x.com. Enable "User authentication settings" with OAuth 1.0a permissions for write access.' - }; - - private keychain!: Keychain; - - async initialize(conductor: Conductor): Promise { - this.keychain = new Keychain(conductor.getConfig().getConfigDir()); - } - - isConfigured(): boolean { return true; } - - private async getBearerToken(): Promise { - const token = await this.keychain.get('x', 'bearer_token'); - if (!token) { - throw new Error( - 'X Bearer Token not configured.\n' + - 'Get one at https://developer.x.com and run:\n' + - ' conductor plugins config x bearer_token ' - ); - } - return token; - } - - private async getOAuthCreds(): Promise<{ - apiKey: string; - apiSecret: string; - accessToken: string; - accessSecret: string; - }> { - const [apiKey, apiSecret, accessToken, accessSecret] = await Promise.all([ - this.keychain.get('x', 'api_key'), - this.keychain.get('x', 'api_secret'), - this.keychain.get('x', 'access_token'), - this.keychain.get('x', 'access_secret'), - ]); - if (!apiKey || !apiSecret || !accessToken || !accessSecret) { - throw new Error( - 'X write credentials not fully configured. Run:\n' + - ' conductor plugins config x api_key \n' + - ' conductor plugins config x api_secret \n' + - ' conductor plugins config x access_token \n' + - ' conductor plugins config x access_secret ' - ); - } - return { apiKey, apiSecret, accessToken, accessSecret }; - } - - /** Bearer token fetch (read operations) */ - private async xFetch(path: string, params?: Record): Promise { - const token = await this.getBearerToken(); - const url = new URL(`${X_BASE}${path}`); - if (params) { - for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); - } - - return withRetry(async () => { - const res = await fetch(url.toString(), { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!res.ok) { - let errStr = res.statusText; - try { - const errJSON = await res.json() as any; - errStr = errJSON.detail ?? errJSON.title ?? res.statusText; - } catch { } - const error = new Error(`X API ${res.status}: ${errStr}`); - (error as any).status = res.status; - throw error; - } - return res.json(); - }); - } - - /** OAuth 1.0a HMAC-SHA1 signature for write operations */ - private buildOAuthHeader( - method: string, - url: string, - creds: { apiKey: string; apiSecret: string; accessToken: string; accessSecret: string } - ): string { - const nonce = crypto.randomBytes(16).toString('hex'); - const timestamp = Math.floor(Date.now() / 1000).toString(); - - const oauthParams: Record = { - oauth_consumer_key: creds.apiKey, - oauth_nonce: nonce, - oauth_signature_method: 'HMAC-SHA1', - oauth_timestamp: timestamp, - oauth_token: creds.accessToken, - oauth_version: '1.0', - }; - - const allParams = { ...oauthParams }; - const sortedParams = Object.keys(allParams) - .sort() - .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(allParams[k])}`) - .join('&'); - - const baseString = [ - method.toUpperCase(), - encodeURIComponent(url), - encodeURIComponent(sortedParams), - ].join('&'); - - const signingKey = `${encodeURIComponent(creds.apiSecret)}&${encodeURIComponent(creds.accessSecret)}`; - const signature = crypto - .createHmac('sha1', signingKey) - .update(baseString) - .digest('base64'); - - oauthParams['oauth_signature'] = signature; - - const headerValue = Object.keys(oauthParams) - .map((k) => `${encodeURIComponent(k)}="${encodeURIComponent(oauthParams[k])}"`) - .join(', '); - - return `OAuth ${headerValue}`; - } - - /** OAuth 1.0a signed POST (write operations) */ - private async xPost(path: string, body: any): Promise { - const creds = await this.getOAuthCreds(); - const url = `${X_BASE}${path}`; - const authHeader = this.buildOAuthHeader('POST', url, creds); - - return withRetry(async () => { - const res = await fetch(url, { - method: 'POST', - headers: { - Authorization: authHeader, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - if (!res.ok) { - let errStr = res.statusText; - try { - const errJSON = await res.json() as any; - errStr = errJSON.detail ?? errJSON.title ?? res.statusText; - } catch { } - const error = new Error(`X API ${res.status}: ${errStr}`); - (error as any).status = res.status; - throw error; - } - return res.json(); - }); - } - - /** Format tweet fields for output */ - private formatTweet(t: any, includes?: any) { - const author = includes?.users?.find((u: any) => u.id === t.author_id); - return { - id: t.id, - text: t.text, - author: author - ? { id: author.id, username: author.username, name: author.name } - : { id: t.author_id }, - createdAt: t.created_at ?? '', - publicMetrics: t.public_metrics ?? {}, - url: t.author_id ? `https://x.com/i/web/status/${t.id}` : '', - }; - } - - getTools(): PluginTool[] { - return [ - // ── x_search ──────────────────────────────────────────────────────────── - { - name: 'x_search', - description: - 'Search recent tweets on X. Supports operators like from:user, #hashtag, -filter:retweets', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'X search query' }, - maxResults: { - type: 'number', - description: 'Max tweets to return (10–100, default: 10)', - }, - sortOrder: { - type: 'string', - enum: ['recency', 'relevancy'], - description: 'Sort by recency or relevancy (default: recency)', - }, - }, - required: ['query'], - }, - handler: async ({ query, maxResults = 10, sortOrder = 'recency' }: any) => { - const res = await this.xFetch('/tweets/search/recent', { - query, - max_results: String(Math.min(Math.max(maxResults, 10), 100)), - sort_order: sortOrder, - 'tweet.fields': 'created_at,public_metrics,author_id', - expansions: 'author_id', - 'user.fields': 'username,name', - }); - return { - count: res.data?.length ?? 0, - tweets: (res.data ?? []).map((t: any) => this.formatTweet(t, res.includes)), - }; - }, - }, - - // ── x_get_user ────────────────────────────────────────────────────────── - { - name: 'x_get_user', - description: 'Get an X user profile by username or ID', - inputSchema: { - type: 'object', - properties: { - username: { type: 'string', description: 'X username (without @)' }, - userId: { type: 'string', description: 'X user ID (alternative to username)' }, - }, - }, - handler: async ({ username, userId }: any) => { - if (!username && !userId) throw new Error('Provide username or userId.'); - const path = userId ? `/users/${userId}` : `/users/by/username/${username}`; - const res = await this.xFetch(path, { - 'user.fields': 'name,username,description,public_metrics,created_at,verified,location,url', - }); - const u = res.data; - return { - id: u.id, - username: u.username, - name: u.name, - bio: u.description ?? '', - location: u.location ?? '', - url: u.url ?? '', - followers: u.public_metrics?.followers_count ?? 0, - following: u.public_metrics?.following_count ?? 0, - tweetCount: u.public_metrics?.tweet_count ?? 0, - verified: u.verified ?? false, - createdAt: u.created_at ?? '', - xUrl: `https://x.com/${u.username}`, - }; - }, - }, - - // ── x_get_timeline ────────────────────────────────────────────────────── - { - name: 'x_get_timeline', - description: "Get a user's recent tweets", - inputSchema: { - type: 'object', - properties: { - username: { type: 'string', description: 'X username (without @)' }, - maxResults: { type: 'number', description: 'Max tweets (5–100, default: 10)' }, - excludeReplies: { - type: 'boolean', - description: 'Exclude reply tweets (default: false)', - }, - excludeRetweets: { - type: 'boolean', - description: 'Exclude retweets (default: false)', - }, - }, - required: ['username'], - }, - handler: async ({ username, maxResults = 10, excludeReplies = false, excludeRetweets = false }: any) => { - // First get user ID - const userRes = await this.xFetch(`/users/by/username/${username}`); - const userId = userRes.data?.id; - if (!userId) throw new Error(`User not found: ${username}`); - - const exclude: string[] = []; - if (excludeReplies) exclude.push('replies'); - if (excludeRetweets) exclude.push('retweets'); - - const params: Record = { - max_results: String(Math.min(Math.max(maxResults, 5), 100)), - 'tweet.fields': 'created_at,public_metrics', - }; - if (exclude.length) params.exclude = exclude.join(','); - - const res = await this.xFetch(`/users/${userId}/tweets`, params); - return { - username, - count: res.data?.length ?? 0, - tweets: (res.data ?? []).map((t: any) => ({ - id: t.id, - text: t.text, - createdAt: t.created_at ?? '', - likes: t.public_metrics?.like_count ?? 0, - retweets: t.public_metrics?.retweet_count ?? 0, - replies: t.public_metrics?.reply_count ?? 0, - url: `https://x.com/${username}/status/${t.id}`, - })), - }; - }, - }, - - // ── x_post_tweet ──────────────────────────────────────────────────────── - { - name: 'x_post_tweet', - description: 'Post a new tweet to X (requires OAuth 1.0a write credentials)', - requiresApproval: true, - inputSchema: { - type: 'object', - properties: { - text: { type: 'string', description: 'Tweet text (max 280 chars)' }, - replyToId: { - type: 'string', - description: 'Tweet ID to reply to (optional)', - }, - }, - required: ['text'], - }, - handler: async ({ text, replyToId }: any) => { - if (text.length > 280) { - return { error: `Tweet too long: ${text.length} chars (max 280).` }; - } - const body: any = { text }; - if (replyToId) body.reply = { in_reply_to_tweet_id: replyToId }; - - const res = await this.xPost('/tweets', body); - return { - posted: true, - id: res.data?.id, - text: res.data?.text, - url: `https://x.com/i/web/status/${res.data?.id}`, - }; - }, - }, - - // ── x_delete_tweet ────────────────────────────────────────────────────── - { - name: 'x_delete_tweet', - description: 'Delete one of your tweets (requires OAuth write credentials)', - requiresApproval: true, - inputSchema: { - type: 'object', - properties: { - tweetId: { type: 'string', description: 'Tweet ID to delete' }, - }, - required: ['tweetId'], - }, - handler: async ({ tweetId }: any) => { - const creds = await this.getOAuthCreds(); - const url = `${X_BASE}/tweets/${tweetId}`; - const authHeader = this.buildOAuthHeader('DELETE', url, creds); - - const res = await fetch(url, { - method: 'DELETE', - headers: { Authorization: authHeader }, - }); - if (!res.ok) throw new Error(`Delete failed: ${res.status} ${res.statusText}`); - const data = await res.json() as any; - return { deleted: data.data?.deleted ?? true, tweetId }; - }, - }, - - // ── x_like_tweet ──────────────────────────────────────────────────────── - { - name: 'x_like_tweet', - description: 'Like a tweet (requires OAuth write credentials and your user ID)', - requiresApproval: true, - inputSchema: { - type: 'object', - properties: { - tweetId: { type: 'string', description: 'Tweet ID to like' }, - userId: { type: 'string', description: 'Your X user ID (required for liking)' }, - }, - required: ['tweetId', 'userId'], - }, - handler: async ({ tweetId, userId }: any) => { - const res = await this.xPost(`/users/${userId}/likes`, { tweet_id: tweetId }); - return { liked: res.data?.liked ?? true, tweetId }; - }, - }, - ]; - } -} diff --git a/plugins/x/tsconfig.json b/plugins/x/tsconfig.json deleted file mode 100644 index dce5a7d..0000000 --- a/plugins/x/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "skipLibCheck": true, - "declaration": true - }, - "include": ["src/**/*"] -} diff --git a/registry.json b/registry.json index 6bb4770..d253315 100644 --- a/registry.json +++ b/registry.json @@ -1,530 +1,7 @@ { "version": "1", - "updatedAt": "2026-04-04T00:00:00Z", + "updatedAt": "2026-04-08T00:00:00Z", "plugins": [ - { - "id": "slack", - "name": "Slack", - "description": "Send messages, read channels, search, and manage Slack workspaces", - "longDescription": "Full Slack integration via the Slack Web API. Send messages, read channel history, search across your workspace, list members, and add emoji reactions. Requires a Bot User OAuth Token with appropriate scopes.", - "category": "Communication", - "author": "TheAlxLabs", - "version": "1.0.0", - "requiresConfig": true, - "toolCount": 6, - "source": "community", - "repo": "thegreatalxx/conductor-plugins", - "path": "plugins/slack", - "asset": "plugins/slack/dist/slack.js", - "icon": "💬", - "tags": ["messaging", "team", "notifications", "slack"], - "tools": [ - "slack_send_message", - "slack_channels", - "slack_read_channel", - "slack_search", - "slack_users", - "slack_add_reaction" - ], - "credentials": [ - { - "service": "slack", - "key": "bot_token", - "label": "Bot User OAuth Token", - "setup": "https://api.slack.com/apps" - } - ] - }, - { - "id": "github", - "name": "GitHub", - "description": "GitHub repositories, issues, stars, and user info (public data free, private needs token)", - "longDescription": "Read-focused GitHub integration. Get user profiles, repository details, list repos, and search trending repositories. Works for public data without a token; provide a PAT for private repositories.", - "category": "Developer Tools", - "author": "TheAlxLabs", - "version": "1.0.0", - "requiresConfig": false, - "toolCount": 4, - "source": "community", - "repo": "thegreatalxx/conductor-plugins", - "path": "plugins/github", - "asset": "plugins/github/dist/github.js", - "icon": "🐙", - "tags": ["github", "repositories", "code", "developer"], - "tools": [ - "github_user", - "github_repo", - "github_repos", - "github_trending" - ], - "credentials": [ - { - "service": "github", - "key": "token", - "label": "GitHub Personal Access Token (optional for public data)", - "setup": "https://github.com/settings/tokens" - } - ] - }, - { - "id": "github_actions", - "name": "GitHub Actions", - "description": "GitHub CI/CD, PRs, issues, releases, and notifications — full write access", - "longDescription": "Full GitHub authenticated integration. Trigger and monitor workflow runs, manage pull requests (create, merge, review), handle issues, create releases, check notifications, and search code across repos. Requires a PAT with repo and workflow scopes.", - "category": "Developer Tools", - "author": "TheAlxLabs", - "version": "1.0.0", - "requiresConfig": true, - "toolCount": 15, - "source": "community", - "repo": "thegreatalxx/conductor-plugins", - "path": "plugins/github-actions", - "asset": "plugins/github-actions/dist/github-actions.js", - "icon": "⚙️", - "tags": ["github", "ci-cd", "actions", "pull-requests", "issues", "developer"], - "tools": [ - "gh_my_repos", - "gh_workflow_runs", - "gh_run_status", - "gh_trigger_workflow", - "gh_cancel_run", - "gh_list_prs", - "gh_create_pr", - "gh_merge_pr", - "gh_list_issues", - "gh_create_issue", - "gh_comment", - "gh_releases", - "gh_create_release", - "gh_notifications", - "gh_code_search" - ], - "credentials": [ - { - "service": "github", - "key": "token", - "label": "GitHub Personal Access Token (repo, workflow scopes)", - "setup": "https://github.com/settings/tokens" - } - ] - }, - { - "id": "gcal", - "name": "Google Calendar", - "description": "Read and manage Google Calendar events — requires Google OAuth", - "longDescription": "Full Google Calendar integration. List calendars, browse events, get event details, create new events with attendees, update existing events, and delete events. Requires a Google OAuth access token.", - "category": "Productivity", - "author": "TheAlxLabs", - "version": "1.0.0", - "requiresConfig": true, - "toolCount": 6, - "source": "community", - "repo": "thegreatalxx/conductor-plugins", - "path": "plugins/gcal", - "asset": "plugins/gcal/dist/gcal.js", - "icon": "📅", - "tags": ["calendar", "google", "events", "scheduling"], - "tools": [ - "gcal_list_calendars", - "gcal_list_events", - "gcal_get_event", - "gcal_create_event", - "gcal_update_event", - "gcal_delete_event" - ], - "credentials": [ - { - "service": "google", - "key": "access_token", - "label": "Google OAuth Access Token", - "setup": "https://console.cloud.google.com/apis/credentials" - } - ] - }, - { - "id": "gdrive", - "name": "Google Drive", - "description": "List, search, read, and upload files in Google Drive — requires Google OAuth", - "longDescription": "Full Google Drive integration. Browse files and folders, search by name or Drive query syntax, read text file contents (including Google Docs/Sheets), create folders, upload text files, and delete files. Requires a Google OAuth access token.", - "category": "Productivity", - "author": "TheAlxLabs", - "version": "1.0.0", - "requiresConfig": true, - "toolCount": 7, - "source": "community", - "repo": "thegreatalxx/conductor-plugins", - "path": "plugins/gdrive", - "asset": "plugins/gdrive/dist/gdrive.js", - "icon": "📁", - "tags": ["google", "drive", "files", "storage", "documents"], - "tools": [ - "gdrive_list", - "gdrive_search", - "gdrive_get", - "gdrive_read", - "gdrive_create_folder", - "gdrive_upload_text", - "gdrive_delete" - ], - "credentials": [ - { - "service": "google", - "key": "access_token", - "label": "Google OAuth Access Token", - "setup": "https://console.cloud.google.com/apis/credentials" - } - ] - }, - { - "id": "gmail", - "name": "Gmail", - "description": "Read, search, send, and manage Gmail — requires Google OAuth", - "longDescription": "Full Gmail integration. List inbox, read messages, search with Gmail operators, send emails, reply to threads, mark messages as read/unread, and trash messages. Requires a Google OAuth access token with Gmail scopes.", - "category": "Communication", - "author": "TheAlxLabs", - "version": "1.0.0", - "requiresConfig": true, - "toolCount": 7, - "source": "community", - "repo": "thegreatalxx/conductor-plugins", - "path": "plugins/gmail", - "asset": "plugins/gmail/dist/gmail.js", - "icon": "📧", - "tags": ["gmail", "google", "email", "communication"], - "tools": [ - "gmail_list", - "gmail_read", - "gmail_search", - "gmail_send", - "gmail_reply", - "gmail_mark_read", - "gmail_trash" - ], - "credentials": [ - { - "service": "google", - "key": "access_token", - "label": "Google OAuth Access Token", - "setup": "https://console.cloud.google.com/apis/credentials" - } - ] - }, - { - "id": "homekit", - "name": "HomeKit", - "description": "Control HomeKit smart home devices via Homebridge", - "longDescription": "Control your HomeKit smart home devices through the Homebridge UI REST API. List all accessories, get device status, set characteristics (on/off, brightness, temperature, etc.), toggle devices by name, and get room layout. Requires a running Homebridge instance.", - "category": "Smart Home", - "author": "TheAlxLabs", - "version": "1.0.0", - "requiresConfig": true, - "toolCount": 6, - "source": "community", - "repo": "thegreatalxx/conductor-plugins", - "path": "plugins/homekit", - "asset": "plugins/homekit/dist/homekit.js", - "icon": "🏠", - "tags": ["homekit", "smart-home", "iot", "homebridge", "automation"], - "tools": [ - "homekit_status", - "homekit_accessories", - "homekit_get_accessory", - "homekit_set", - "homekit_toggle", - "homekit_rooms" - ], - "credentials": [ - { - "service": "homekit", - "key": "base_url", - "label": "Homebridge URL (e.g. http://homebridge.local:8581)", - "setup": "https://homebridge.io" - }, - { - "service": "homekit", - "key": "username", - "label": "Homebridge Username", - "setup": "https://homebridge.io" - }, - { - "service": "homekit", - "key": "password", - "label": "Homebridge Password", - "setup": "https://homebridge.io" - } - ] - }, - { - "id": "n8n", - "name": "n8n", - "description": "Trigger and manage n8n workflows, inspect executions, fire webhooks", - "longDescription": "Full n8n automation platform integration. List and activate/deactivate workflows, trigger executions (including webhook-based), inspect execution results, retry failures, manage credentials (names only), organize with tags, and check instance health. Works with self-hosted and n8n Cloud.", - "category": "Automation", - "author": "TheAlxLabs", - "version": "1.0.0", - "requiresConfig": true, - "toolCount": 12, - "source": "community", - "repo": "thegreatalxx/conductor-plugins", - "path": "plugins/n8n", - "asset": "plugins/n8n/dist/n8n.js", - "icon": "🔄", - "tags": ["n8n", "automation", "workflows", "webhooks"], - "tools": [ - "n8n_workflows", - "n8n_workflow", - "n8n_activate", - "n8n_trigger", - "n8n_webhook", - "n8n_executions", - "n8n_execution", - "n8n_retry", - "n8n_delete_execution", - "n8n_credentials", - "n8n_tags", - "n8n_health" - ], - "credentials": [ - { - "service": "n8n", - "key": "api_key", - "label": "n8n API Key", - "setup": "https://docs.n8n.io/api/authentication/" - }, - { - "service": "n8n", - "key": "base_url", - "label": "n8n Instance URL (e.g. https://n8n.yourdomain.com)", - "setup": "https://docs.n8n.io" - } - ] - }, - { - "id": "notion", - "name": "Notion", - "description": "Read, search, and create Notion pages and databases", - "longDescription": "Full Notion integration. Search pages and databases, get page metadata, read page content as formatted text, create new pages (in page or database parents), append content to existing pages, and query database entries with filters. Requires a Notion Internal Integration token.", - "category": "Productivity", - "author": "TheAlxLabs", - "version": "1.0.0", - "requiresConfig": true, - "toolCount": 6, - "source": "community", - "repo": "thegreatalxx/conductor-plugins", - "path": "plugins/notion", - "asset": "plugins/notion/dist/notion.js", - "icon": "📝", - "tags": ["notion", "notes", "documents", "database", "productivity"], - "tools": [ - "notion_search", - "notion_get_page", - "notion_read_page", - "notion_create_page", - "notion_append_to_page", - "notion_query_database" - ], - "credentials": [ - { - "service": "notion", - "key": "api_key", - "label": "Notion Internal Integration Token (starts with ntn_)", - "setup": "https://www.notion.so/my-integrations" - } - ] - }, - { - "id": "spotify", - "name": "Spotify", - "description": "Full Spotify control — playback, search, playlists, queue, recommendations", - "longDescription": "Complete Spotify integration. Get currently playing track, search for tracks/albums/artists/playlists, control playback (play/pause/skip/volume/shuffle), manage queue, browse your playlists, get top tracks and artists, get personalized recommendations, and list available devices. Requires Spotify OAuth.", - "category": "Entertainment", - "author": "TheAlxLabs", - "version": "1.0.0", - "requiresConfig": true, - "toolCount": 14, - "source": "community", - "repo": "thegreatalxx/conductor-plugins", - "path": "plugins/spotify", - "asset": "plugins/spotify/dist/spotify.js", - "icon": "🎵", - "tags": ["spotify", "music", "playback", "entertainment"], - "tools": [ - "spotify_now_playing", - "spotify_search", - "spotify_play", - "spotify_pause", - "spotify_skip", - "spotify_volume", - "spotify_queue", - "spotify_playlists", - "spotify_playlist_tracks", - "spotify_top_tracks", - "spotify_recommendations", - "spotify_devices", - "spotify_shuffle", - "spotify_recently_played" - ], - "credentials": [ - { - "service": "spotify", - "key": "client_id", - "label": "Spotify Client ID", - "setup": "https://developer.spotify.com/dashboard" - }, - { - "service": "spotify", - "key": "client_secret", - "label": "Spotify Client Secret", - "setup": "https://developer.spotify.com/dashboard" - } - ] - }, - { - "id": "vercel", - "name": "Vercel", - "description": "Manage Vercel deployments, projects, domains, and environment variables", - "longDescription": "Full Vercel project and deployment management. List projects, inspect deployments, redeploy or cancel builds, stream build logs, manage environment variables (add/list/delete), manage custom domains, get account/team info, and switch team scope. Requires a Vercel API token.", - "category": "Developer Tools", - "author": "TheAlxLabs", - "version": "1.0.0", - "requiresConfig": true, - "toolCount": 14, - "source": "community", - "repo": "thegreatalxx/conductor-plugins", - "path": "plugins/vercel", - "asset": "plugins/vercel/dist/vercel.js", - "icon": "▲", - "tags": ["vercel", "deployments", "hosting", "developer", "ci-cd"], - "tools": [ - "vercel_projects", - "vercel_project", - "vercel_deployments", - "vercel_deployment", - "vercel_redeploy", - "vercel_cancel", - "vercel_logs", - "vercel_env_list", - "vercel_env_add", - "vercel_env_delete", - "vercel_domains", - "vercel_add_domain", - "vercel_team_info", - "vercel_set_team" - ], - "credentials": [ - { - "service": "vercel", - "key": "token", - "label": "Vercel API Token", - "setup": "https://vercel.com/account/tokens" - } - ] - }, - { - "id": "weather", - "name": "Weather", - "description": "Current weather and forecasts — powered by Open-Meteo, no API key needed", - "longDescription": "Free weather data powered by Open-Meteo. Get current conditions (temperature, humidity, wind speed, weather description) and 7-day forecasts for any city worldwide. No API key or account required.", - "category": "Utilities", - "author": "TheAlxLabs", - "version": "1.0.0", - "requiresConfig": false, - "toolCount": 2, - "source": "community", - "repo": "thegreatalxx/conductor-plugins", - "path": "plugins/weather", - "asset": "plugins/weather/dist/weather.js", - "icon": "🌤️", - "tags": ["weather", "forecast", "free", "utilities"], - "tools": [ - "weather_current", - "weather_forecast" - ], - "credentials": [] - }, - { - "id": "x", - "name": "X (Twitter)", - "description": "Post tweets, search X, get timelines and user info — requires X API credentials", - "longDescription": "Full X (Twitter) integration via X API v2. Search recent tweets, get user profiles, fetch timelines, post new tweets, delete tweets, and like tweets. Read operations require a Bearer Token; write operations additionally require OAuth 1.0a credentials.", - "category": "Communication", - "author": "TheAlxLabs", - "version": "1.0.0", - "requiresConfig": true, - "toolCount": 6, - "source": "community", - "repo": "thegreatalxx/conductor-plugins", - "path": "plugins/x", - "asset": "plugins/x/dist/x.js", - "icon": "𝕏", - "tags": ["twitter", "x", "social-media", "tweets"], - "tools": [ - "x_search", - "x_get_user", - "x_get_timeline", - "x_post_tweet", - "x_delete_tweet", - "x_like_tweet" - ], - "credentials": [ - { - "service": "x", - "key": "bearer_token", - "label": "X Bearer Token (for read access)", - "setup": "https://developer.x.com" - }, - { - "service": "x", - "key": "api_key", - "label": "X API Key (for write access)", - "setup": "https://developer.x.com" - }, - { - "service": "x", - "key": "api_secret", - "label": "X API Secret (for write access)", - "setup": "https://developer.x.com" - }, - { - "service": "x", - "key": "access_token", - "label": "X Access Token (for write access)", - "setup": "https://developer.x.com" - }, - { - "service": "x", - "key": "access_secret", - "label": "X Access Secret (for write access)", - "setup": "https://developer.x.com" - } - ] - }, - { - "id": "linear", - "name": "Linear", - "description": "Issues, cycles, projects, and team management for Linear", - "longDescription": "Full Linear integration. Manage issues, cycles, and projects. Create and update issues, search across your workspace, get team and project overviews. Requires a Linear API key.", - "category": "Developer Tools", - "author": "TheAlxLabs", - "version": "1.0.0", - "requiresConfig": true, - "toolCount": 8, - "source": "community", - "repo": "thegreatalxx/conductor-plugins", - "path": "plugins/linear", - "asset": "plugins/linear/dist/linear.js", - "icon": "📋", - "tags": ["linear", "issues", "project-management", "developer"], - "tools": [], - "credentials": [ - { - "service": "linear", - "key": "api_key", - "label": "Linear API Key", - "setup": "https://linear.app/settings/api" - } - ] - }, { "id": "discord", "name": "Discord", @@ -577,70 +54,6 @@ } ] }, - { - "id": "stripe", - "name": "Stripe", - "description": "Charges, customers, subscriptions, and payment intents via Stripe", - "longDescription": "Full Stripe integration. List and retrieve customers, charges, subscriptions, and payment intents. Create charges, manage subscription plans, issue refunds, and inspect webhook events. Requires a Stripe secret key.", - "category": "Finance & Commerce", - "author": "TheAlxLabs", - "version": "1.0.0", - "requiresConfig": true, - "toolCount": 10, - "source": "community", - "repo": "thegreatalxx/conductor-plugins", - "path": "plugins/stripe", - "asset": "plugins/stripe/dist/stripe.js", - "icon": "💳", - "tags": ["stripe", "payments", "billing", "finance"], - "tools": [], - "credentials": [ - { - "service": "stripe", - "key": "secret_key", - "label": "Stripe Secret Key (sk_live_... or sk_test_...)", - "setup": "https://dashboard.stripe.com/apikeys" - } - ] - }, - { - "id": "jira", - "name": "Jira", - "description": "Issues, sprints, projects, and boards via Jira Cloud", - "longDescription": "Full Jira Cloud integration. Search issues with JQL, create and update issues, manage sprints and boards, get project details, transition issue status, and add comments. Requires Jira Cloud API credentials.", - "category": "Developer Tools", - "author": "TheAlxLabs", - "version": "1.0.0", - "requiresConfig": true, - "toolCount": 8, - "source": "community", - "repo": "thegreatalxx/conductor-plugins", - "path": "plugins/jira", - "asset": "plugins/jira/dist/jira.js", - "icon": "🔵", - "tags": ["jira", "issues", "project-management", "atlassian", "developer"], - "tools": [], - "credentials": [ - { - "service": "jira", - "key": "email", - "label": "Jira Account Email", - "setup": "https://id.atlassian.com/manage-profile/security/api-tokens" - }, - { - "service": "jira", - "key": "api_token", - "label": "Jira API Token", - "setup": "https://id.atlassian.com/manage-profile/security/api-tokens" - }, - { - "service": "jira", - "key": "base_url", - "label": "Jira Cloud URL (e.g. https://yourorg.atlassian.net)", - "setup": "https://id.atlassian.com/manage-profile/security/api-tokens" - } - ] - }, { "id": "figma", "name": "Figma", @@ -668,92 +81,106 @@ ] }, { - "id": "shopify", - "name": "Shopify", - "description": "Products, orders, customers, and inventory via the Shopify Admin API", - "longDescription": "Full Shopify Admin API integration. List and search products and variants, manage orders and fulfillments, look up customer records, check inventory levels, and retrieve store metafields. Requires a Shopify Admin API access token.", - "category": "Finance & Commerce", + "id": "gitlab", + "name": "GitLab", + "description": "Projects, issues, merge requests, and CI/CD pipelines via GitLab", + "longDescription": "Full GitLab integration. List and search projects, manage issues and merge requests, trigger and monitor CI/CD pipelines, get job logs, manage branches and tags, and handle repository files. Requires a GitLab Personal Access Token.", + "category": "Developer Tools", "author": "TheAlxLabs", "version": "1.0.0", "requiresConfig": true, - "toolCount": 10, + "toolCount": 12, "source": "community", "repo": "thegreatalxx/conductor-plugins", - "path": "plugins/shopify", - "asset": "plugins/shopify/dist/shopify.js", - "icon": "🛍️", - "tags": ["shopify", "ecommerce", "orders", "products", "finance"], + "path": "plugins/gitlab", + "asset": "plugins/gitlab/dist/gitlab.js", + "icon": "🦊", + "tags": ["gitlab", "git", "ci-cd", "developer"], "tools": [], "credentials": [ { - "service": "shopify", - "key": "access_token", - "label": "Shopify Admin API Access Token", - "setup": "https://shopify.dev/docs/apps/auth/admin-app-access-tokens" - }, - { - "service": "shopify", - "key": "shop_domain", - "label": "Shopify Store Domain (e.g. your-store.myshopify.com)", - "setup": "https://shopify.dev/docs/apps/auth/admin-app-access-tokens" + "service": "gitlab", + "key": "token", + "label": "GitLab Personal Access Token", + "setup": "https://gitlab.com/-/user_settings/personal_access_tokens" } ] }, { - "id": "twilio", - "name": "Twilio", - "description": "Send SMS, make calls, and manage phone numbers via Twilio", - "longDescription": "Full Twilio integration. Send SMS and MMS messages, initiate outbound calls, look up phone numbers, list message history, and check account balance. Requires a Twilio Account SID and Auth Token.", - "category": "Communication", + "id": "monday", + "name": "Monday.com", + "description": "Boards, items, columns, and updates via the Monday.com API", + "longDescription": "Full Monday.com integration. List and search boards, create and update items, manage column values, add updates and comments, archive items, and subscribe to board changes. Requires a Monday.com API token.", + "category": "Productivity", "author": "TheAlxLabs", "version": "1.0.0", "requiresConfig": true, - "toolCount": 6, + "toolCount": 8, "source": "community", "repo": "thegreatalxx/conductor-plugins", - "path": "plugins/twilio", - "asset": "plugins/twilio/dist/twilio.js", - "icon": "📱", - "tags": ["twilio", "sms", "voice", "communication"], + "path": "plugins/monday", + "asset": "plugins/monday/dist/monday.js", + "icon": "📌", + "tags": ["monday", "project-management", "productivity", "tasks"], "tools": [], "credentials": [ { - "service": "twilio", - "key": "account_sid", - "label": "Twilio Account SID", - "setup": "https://console.twilio.com" - }, + "service": "monday", + "key": "api_token", + "label": "Monday.com API Token", + "setup": "https://monday.com/settings/api" + } + ] + }, + { + "id": "openai", + "name": "OpenAI", + "description": "Chat completions, embeddings, image generation, and fine-tuning via OpenAI", + "longDescription": "Full OpenAI API integration. Create chat completions, generate embeddings, produce images with DALL-E, transcribe audio with Whisper, manage fine-tuning jobs, and list available models. Requires an OpenAI API key.", + "category": "AI & ML", + "author": "TheAlxLabs", + "version": "1.0.0", + "requiresConfig": true, + "toolCount": 8, + "source": "community", + "repo": "thegreatalxx/conductor-plugins", + "path": "plugins/openai", + "asset": "plugins/openai/dist/openai.js", + "icon": "🤖", + "tags": ["openai", "ai", "gpt", "llm", "embeddings"], + "tools": [], + "credentials": [ { - "service": "twilio", - "key": "auth_token", - "label": "Twilio Auth Token", - "setup": "https://console.twilio.com" + "service": "openai", + "key": "api_key", + "label": "OpenAI API Key", + "setup": "https://platform.openai.com/api-keys" } ] }, { - "id": "sendgrid", - "name": "SendGrid", - "description": "Send transactional and marketing email via SendGrid", - "longDescription": "Full SendGrid integration. Send single and bulk emails, use dynamic templates, manage contacts and lists, check email statistics, and validate email addresses. Requires a SendGrid API key.", - "category": "Communication", + "id": "anthropic", + "name": "Anthropic", + "description": "Claude completions and model management via the Anthropic API", + "longDescription": "Full Anthropic API integration. Create Claude completions with streaming support, list available models, manage system prompts, and inspect token usage. Requires an Anthropic API key.", + "category": "AI & ML", "author": "TheAlxLabs", "version": "1.0.0", "requiresConfig": true, - "toolCount": 6, + "toolCount": 4, "source": "community", "repo": "thegreatalxx/conductor-plugins", - "path": "plugins/sendgrid", - "asset": "plugins/sendgrid/dist/sendgrid.js", - "icon": "✉️", - "tags": ["sendgrid", "email", "marketing", "communication"], + "path": "plugins/anthropic", + "asset": "plugins/anthropic/dist/anthropic.js", + "icon": "🧠", + "tags": ["anthropic", "claude", "ai", "llm"], "tools": [], "credentials": [ { - "service": "sendgrid", + "service": "anthropic", "key": "api_key", - "label": "SendGrid API Key", - "setup": "https://app.sendgrid.com/settings/api_keys" + "label": "Anthropic API Key", + "setup": "https://console.anthropic.com/settings/keys" } ] }, @@ -848,106 +275,92 @@ ] }, { - "id": "openai", - "name": "OpenAI", - "description": "Chat completions, embeddings, image generation, and fine-tuning via OpenAI", - "longDescription": "Full OpenAI API integration. Create chat completions, generate embeddings, produce images with DALL-E, transcribe audio with Whisper, manage fine-tuning jobs, and list available models. Requires an OpenAI API key.", - "category": "AI & ML", + "id": "shopify", + "name": "Shopify", + "description": "Products, orders, customers, and inventory via the Shopify Admin API", + "longDescription": "Full Shopify Admin API integration. List and search products and variants, manage orders and fulfillments, look up customer records, check inventory levels, and retrieve store metafields. Requires a Shopify Admin API access token.", + "category": "Finance & Commerce", "author": "TheAlxLabs", "version": "1.0.0", "requiresConfig": true, - "toolCount": 8, + "toolCount": 10, "source": "community", "repo": "thegreatalxx/conductor-plugins", - "path": "plugins/openai", - "asset": "plugins/openai/dist/openai.js", - "icon": "🤖", - "tags": ["openai", "ai", "gpt", "llm", "embeddings"], + "path": "plugins/shopify", + "asset": "plugins/shopify/dist/shopify.js", + "icon": "🛍️", + "tags": ["shopify", "ecommerce", "orders", "products", "finance"], "tools": [], "credentials": [ { - "service": "openai", - "key": "api_key", - "label": "OpenAI API Key", - "setup": "https://platform.openai.com/api-keys" - } - ] - }, - { - "id": "anthropic", - "name": "Anthropic", - "description": "Claude completions and model management via the Anthropic API", - "longDescription": "Full Anthropic API integration. Create Claude completions with streaming support, list available models, manage system prompts, and inspect token usage. Requires an Anthropic API key.", - "category": "AI & ML", - "author": "TheAlxLabs", - "version": "1.0.0", - "requiresConfig": true, - "toolCount": 4, - "source": "community", - "repo": "thegreatalxx/conductor-plugins", - "path": "plugins/anthropic", - "asset": "plugins/anthropic/dist/anthropic.js", - "icon": "🧠", - "tags": ["anthropic", "claude", "ai", "llm"], - "tools": [], - "credentials": [ + "service": "shopify", + "key": "access_token", + "label": "Shopify Admin API Access Token", + "setup": "https://shopify.dev/docs/apps/auth/admin-app-access-tokens" + }, { - "service": "anthropic", - "key": "api_key", - "label": "Anthropic API Key", - "setup": "https://console.anthropic.com/settings/keys" + "service": "shopify", + "key": "shop_domain", + "label": "Shopify Store Domain (e.g. your-store.myshopify.com)", + "setup": "https://shopify.dev/docs/apps/auth/admin-app-access-tokens" } ] }, { - "id": "gitlab", - "name": "GitLab", - "description": "Projects, issues, merge requests, and CI/CD pipelines via GitLab", - "longDescription": "Full GitLab integration. List and search projects, manage issues and merge requests, trigger and monitor CI/CD pipelines, get job logs, manage branches and tags, and handle repository files. Requires a GitLab Personal Access Token.", - "category": "Developer Tools", + "id": "twilio", + "name": "Twilio", + "description": "Send SMS, make calls, and manage phone numbers via Twilio", + "longDescription": "Full Twilio integration. Send SMS and MMS messages, initiate outbound calls, look up phone numbers, list message history, and check account balance. Requires a Twilio Account SID and Auth Token.", + "category": "Communication", "author": "TheAlxLabs", "version": "1.0.0", "requiresConfig": true, - "toolCount": 12, + "toolCount": 6, "source": "community", "repo": "thegreatalxx/conductor-plugins", - "path": "plugins/gitlab", - "asset": "plugins/gitlab/dist/gitlab.js", - "icon": "🦊", - "tags": ["gitlab", "git", "ci-cd", "developer"], + "path": "plugins/twilio", + "asset": "plugins/twilio/dist/twilio.js", + "icon": "📱", + "tags": ["twilio", "sms", "voice", "communication"], "tools": [], "credentials": [ { - "service": "gitlab", - "key": "token", - "label": "GitLab Personal Access Token", - "setup": "https://gitlab.com/-/user_settings/personal_access_tokens" + "service": "twilio", + "key": "account_sid", + "label": "Twilio Account SID", + "setup": "https://console.twilio.com" + }, + { + "service": "twilio", + "key": "auth_token", + "label": "Twilio Auth Token", + "setup": "https://console.twilio.com" } ] }, { - "id": "monday", - "name": "Monday.com", - "description": "Boards, items, columns, and updates via the Monday.com API", - "longDescription": "Full Monday.com integration. List and search boards, create and update items, manage column values, add updates and comments, archive items, and subscribe to board changes. Requires a Monday.com API token.", - "category": "Productivity", + "id": "sendgrid", + "name": "SendGrid", + "description": "Send transactional and marketing email via SendGrid", + "longDescription": "Full SendGrid integration. Send single and bulk emails, use dynamic templates, manage contacts and lists, check email statistics, and validate email addresses. Requires a SendGrid API key.", + "category": "Communication", "author": "TheAlxLabs", "version": "1.0.0", "requiresConfig": true, - "toolCount": 8, + "toolCount": 6, "source": "community", "repo": "thegreatalxx/conductor-plugins", - "path": "plugins/monday", - "asset": "plugins/monday/dist/monday.js", - "icon": "📌", - "tags": ["monday", "project-management", "productivity", "tasks"], + "path": "plugins/sendgrid", + "asset": "plugins/sendgrid/dist/sendgrid.js", + "icon": "✉️", + "tags": ["sendgrid", "email", "marketing", "communication"], "tools": [], "credentials": [ { - "service": "monday", - "key": "api_token", - "label": "Monday.com API Token", - "setup": "https://monday.com/settings/api" + "service": "sendgrid", + "key": "api_key", + "label": "SendGrid API Key", + "setup": "https://app.sendgrid.com/settings/api_keys" } ] },