diff --git a/describe.ts b/describe.ts new file mode 100644 index 0000000..40c7637 --- /dev/null +++ b/describe.ts @@ -0,0 +1,177 @@ +import { tool } from "@opencode-ai/plugin/tool"; + +/** + * Raw DDEV describe output from `ddev describe -j` + */ +export type DdevDescribeRaw = { + shortroot?: string; + approot?: string; + status?: string; + name?: string; + type?: string; + php_version?: string; + webserver_type?: string; + database_type?: string; + database_version?: string; + router_status?: string; + docroot?: string; + mutagen_enabled?: boolean; + hostnames?: string[]; + httpurl?: string; + httpsurl?: string; + services?: { + web?: { + host_ports_mapping?: Array<{ + exposed_port: string; + host_port: string; + }>; + }; + db?: { + host_ports_mapping?: Array<{ + exposed_port: string; + host_port: string; + }>; + }; + }; + [key: string]: unknown; +}; + +/** + * Simplified DDEV project information for LLM consumption + */ +export type DdevProjectInfo = { + name: string; + status: string; + type: string | null; + domain: string; + httpsUrl: string; + httpUrl: string; + webPort: string | null; + dbPort: string | null; + phpVersion: string | null; + webserverType: string | null; + dbType: string | null; + dbVersion: string | null; +}; + +/** + * Default fields to expose in the describe tool + */ +const DEFAULT_FIELDS = [ + 'name', + 'status', + 'type', + 'domain', + 'httpsUrl', + 'httpUrl', + 'webPort', + 'dbPort', +] as const; + +/** + * Fetches and parses DDEV project data from `ddev describe -j` + * + * @param $ - Exec function for running shell commands + * @returns Parsed DDEV describe data or null if unavailable + */ +export async function getDdevDescribeData($: any): Promise { + try { + const result = await $`ddev describe -j`.quiet().nothrow(); + + if (result.exitCode !== 0) { + return null; + } + + const output = result.stdout.toString(); + + try { + const data = JSON.parse(output); + return (data?.raw as DdevDescribeRaw) ?? null; + } catch { + return null; + } + } catch { + return null; + } +} + +/** + * Extracts meaningful project information from raw DDEV describe data + * + * @param raw - Raw DDEV describe data + * @returns Simplified project information + */ +export function extractProjectInfo(raw: DdevDescribeRaw): DdevProjectInfo { + const name = raw.name ?? 'unknown'; + const status = raw.status ?? 'unknown'; + const type = raw.type ?? null; + const httpsUrl = raw.httpsurl ?? ''; + const httpUrl = raw.httpurl ?? ''; + const domain = (raw.hostnames?.[0] ?? httpsUrl.replace(/^https?:\/\//, '')) || 'localhost'; + + let webPort: string | null = null; + let dbPort: string | null = null; + + if (raw.services) { + const webMapping = raw.services.web?.host_ports_mapping?.[0]; + if (webMapping) { + webPort = webMapping.host_port; + } + + const dbMapping = raw.services.db?.host_ports_mapping?.[0]; + if (dbMapping) { + dbPort = dbMapping.host_port; + } + } + + return { + name, + status, + type, + domain, + httpsUrl, + httpUrl, + webPort, + dbPort, + phpVersion: raw.php_version ?? null, + webserverType: raw.webserver_type ?? null, + dbType: raw.database_type ?? null, + dbVersion: raw.database_version ?? null, + }; +} + +/** + * Creates a DDEV describe tool for viewing project information + * + * Provides access to core DDEV project data like domain, ports, and status. + * Uses configurable field selection to expose relevant information without overwhelming context. + */ +export const createDdevDescribeTool = ($: any) => { + return tool({ + description: "Get DDEV project information including domain, ports, and status. Use this to understand the current DDEV environment configuration.", + args: { + fields: tool.schema.array(tool.schema.string()).optional().describe(`Fields to include in the response. Available options: ${[...DEFAULT_FIELDS, 'phpVersion', 'webserverType', 'dbType', 'dbVersion'].join(', ')}. Defaults to core fields: ${DEFAULT_FIELDS.join(', ')}.`), + }, + async execute(args) { + const raw = await getDdevDescribeData($); + + if (!raw) { + throw new Error('Failed to get DDEV project data. Make sure DDEV is running.'); + } + + const info = extractProjectInfo(raw); + const requestedFields = args.fields ?? [...DEFAULT_FIELDS]; + + const result: Partial = {}; + + for (const field of requestedFields) { + if (field in info) { + const value = info[field as keyof DdevProjectInfo]; + result[field as keyof DdevProjectInfo] = value as any; + } + } + + return JSON.stringify(result, null, 2); + }, + }); +}; diff --git a/index.ts b/index.ts index 4be38e7..12da0b7 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,7 @@ import type { Plugin } from "@opencode-ai/plugin"; import { createDdevLogsTool } from "./logs"; +import { createDdevDescribeTool, getDdevDescribeData } from "./describe"; +import type { DdevDescribeRaw } from "./describe"; /** * DDEV Plugin for OpenCode @@ -9,19 +11,13 @@ import { createDdevLogsTool } from "./logs"; */ export const DDEVPlugin: Plugin = async ({ project, client, $, directory, worktree }) => { const CONTAINER_ROOT = '/var/www/html' as const; - const HOST_ONLY_COMMANDS = ['git', 'gh', 'docker', 'ddev'] as const; + const HOST_ONLY_COMMANDS = ['git', 'gh', 'docker', 'ddev', 'curl'] as const; const CACHE_DURATION_MS = 120000; // 2 minutes /** * Raw DDEV project data from `ddev describe -j` */ - type DdevRawData = { - shortroot?: string; - approot?: string; - status?: string; - name?: string; - [key: string]: unknown; - }; + type DdevRawData = DdevDescribeRaw; /** * Cached DDEV state with timestamp for cache invalidation @@ -202,6 +198,8 @@ export const DDEVPlugin: Plugin = async ({ project, client, $, directory, worktr /** * Notifies LLM about DDEV environment on first command execution + * + * @link https://docs.ddev.com/en/stable/users/extend/customization-extendibility/#environment-variables-for-containers-and-services */ const notifyDdevInSession = async (): Promise => { if (hasNotifiedSession || !currentSessionId) { @@ -209,6 +207,9 @@ export const DDEVPlugin: Plugin = async ({ project, client, $, directory, worktr } const containerWorkingDir = getContainerWorkingDir(); + const projectRoot = getProjectRoot(); + const envFilePath = projectRoot ? `${projectRoot}/.ddev/.env` : '.ddev/.env'; + const projectType = ddevCache?.raw?.type ? ` (${ddevCache.raw.type})` : ''; await client.session.prompt({ path: { id: currentSessionId }, @@ -216,7 +217,7 @@ export const DDEVPlugin: Plugin = async ({ project, client, $, directory, worktr parts: [ { type: 'text', - text: `➡️ DDEV environment is used. Execute commands inside the DDEV container like this: \`ddev exec --dir="${containerWorkingDir}" bash -c \``, + text: `➡️ DDEV is used${projectType}. Execute commands like this: \`ddev exec --dir="${containerWorkingDir}" bash -c \`. Use \`ddev_logs\` tool to view logs and \`ddev_describe\` tool to get project info (domain, ports, status). Environment variables are defined in \`${envFilePath}\` not in local .env files.`, }, ], noReply: true, @@ -252,27 +253,9 @@ export const DDEVPlugin: Plugin = async ({ project, client, $, directory, worktr } try { - const result = await $`ddev describe -j`.quiet().nothrow(); + const raw = await getDdevDescribeData($); // DDEV not available (not installed or no project) - if (result.exitCode !== 0) { - ddevCache = null; - return; - } - - const output = result.stdout.toString(); - - let data; - try { - data = JSON.parse(output); - } catch (parseError) { - await log('error', `Failed to parse DDEV JSON output: ${parseError instanceof Error ? parseError.message : String(parseError)}`); - ddevCache = null; - return; - } - - const raw = data?.raw as DdevRawData | undefined; - if (!raw) { ddevCache = null; return; @@ -280,7 +263,6 @@ export const DDEVPlugin: Plugin = async ({ project, client, $, directory, worktr // Only cache when running; stopped state should be re-checked if (raw.status !== 'running') { - // Do not cache stopped state; force re-check next time ddevCache = { timestamp: 0, raw }; return; } @@ -379,6 +361,7 @@ export const DDEVPlugin: Plugin = async ({ project, client, $, directory, worktr ...(hasProject ? { tool: { ddev_logs: createDdevLogsTool($), + ddev_describe: createDdevDescribeTool($), }, } : {}), }; diff --git a/package-lock.json b/package-lock.json index 4e09fb2..3a33998 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,36 +9,36 @@ "version": "1.0.1", "license": "MIT", "dependencies": { - "@opencode-ai/sdk": "^1.0.126" + "@opencode-ai/sdk": "^1.0.188" }, "devDependencies": { - "@opencode-ai/plugin": "^1.0.126", - "@types/node": "^24.10.1", + "@opencode-ai/plugin": "^1.0.188", + "@types/node": "^25.0.3", "typescript": "^5.9.3" }, "peerDependencies": { - "@opencode-ai/plugin": "^1.0.126" + "@opencode-ai/plugin": "^1.0.188" } }, "node_modules/@opencode-ai/plugin": { - "version": "1.0.129", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.0.129.tgz", - "integrity": "sha512-fkdSH58dIgo8h3RhgJFzJdoL2hKS4HEe0nbuBPhHqf3yGyblRprQiz/yjznzFD9e0IaJyu65amk09G033eikjw==", + "version": "1.0.188", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.0.188.tgz", + "integrity": "sha512-/ZrRQFWP89usBc6Dmyo8V8Wt3iqFJJ8w6B+E8iRf8Xp2Qx4shRLc70Hg1Y9Btz0ZFwOjy/Lw2/Rk1rcA2zT3DA==", "dev": true, "dependencies": { - "@opencode-ai/sdk": "1.0.129", + "@opencode-ai/sdk": "1.0.188", "zod": "4.1.8" } }, "node_modules/@opencode-ai/sdk": { - "version": "1.0.129", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.0.129.tgz", - "integrity": "sha512-68GoZzJo3nHwW20P7M0ScvNzyW1OVPj4hCNU6vAuCOwrJMczIHCWAg60hW1165lHMcc7SEm8BvofPpaDWNqRCA==" + "version": "1.0.188", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.0.188.tgz", + "integrity": "sha512-Abb3WfUNFPL3vgqURTTqB9pGpmM08HTpl6XeKoGjsma2hfG0HK76/5tlImGnTvYoHeNaTA1HXB1BvE4koe3X3A==" }, "node_modules/@types/node": { - "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "dev": true, "dependencies": { "undici-types": "~7.16.0" diff --git a/package.json b/package.json index d7aec37..4254c80 100644 --- a/package.json +++ b/package.json @@ -23,14 +23,14 @@ "typecheck": "tsc --noEmit" }, "peerDependencies": { - "@opencode-ai/plugin": "^1.0.126" + "@opencode-ai/plugin": "^1.0.188" }, "devDependencies": { - "@opencode-ai/plugin": "^1.0.126", - "@types/node": "^24.10.1", + "@opencode-ai/plugin": "^1.0.188", + "@types/node": "^25.0.3", "typescript": "^5.9.3" }, "dependencies": { - "@opencode-ai/sdk": "^1.0.126" + "@opencode-ai/sdk": "^1.0.188" } }