Browse Source

Add playwright skill (#16)

* Add playwright skill

* Refactor Playwright skill for simplicity and consistency
Alvin 2 months ago
parent
commit
ac359cedda

File diff suppressed because it is too large
+ 179 - 0
bun.lock


+ 1 - 0
package.json

@@ -42,6 +42,7 @@
   },
   "dependencies": {
     "@ast-grep/cli": "^0.40.0",
+    "@modelcontextprotocol/sdk": "^1.25.1",
     "@opencode-ai/plugin": "^1.1.19",
     "@opencode-ai/sdk": "^1.1.19",
     "zod": "^4.1.8"

+ 3 - 0
src/agents/orchestrator.ts

@@ -53,6 +53,9 @@ background_task(agent="librarian", prompt="How does library X handle Y")
 ## When to Delegate
 - Use the subagent most relevant to the task description.
 - Use background tasks for research or search while you continue working.
+
+## Skills
+- For browser-related tasks (verification, screenshots, scraping, testing), call the "skill" tool with name "playwright" before taking action. Use relative filenames for screenshots (e.g., 'screenshot.png'); they are saved within subdirectories of '/tmp/playwright-mcp-output/'. Use the "skill_mcp" tool to invoke browser actions with camelCase parameters: skillName, mcpName, toolName, and toolArgs.
 </Delegation>
 
 <Workflow>

+ 5 - 0
src/index.ts

@@ -11,6 +11,8 @@ import {
   ast_grep_search,
   ast_grep_replace,
   antigravity_quota,
+  createSkillTools,
+  SkillMcpManager,
 } from "./tools";
 import { loadPluginConfig, type TmuxConfig } from "./config";
 import { createBuiltinMcps } from "./mcp";
@@ -43,6 +45,8 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   const backgroundManager = new BackgroundTaskManager(ctx, tmuxConfig);
   const backgroundTools = createBackgroundTools(ctx, backgroundManager, tmuxConfig);
   const mcps = createBuiltinMcps(config.disabled_mcps);
+  const skillMcpManager = SkillMcpManager.getInstance();
+  const skillTools = createSkillTools(skillMcpManager);
 
   // Initialize TmuxSessionManager to handle OpenCode's built-in Task tool sessions
   const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig);
@@ -68,6 +72,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       ast_grep_search,
       ast_grep_replace,
       antigravity_quota,
+      ...skillTools,
     },
 
     mcp: mcps,

+ 4 - 4
src/mcp/index.ts

@@ -1,12 +1,12 @@
 import { websearch } from "./websearch";
 import { context7 } from "./context7";
 import { grep_app } from "./grep-app";
-import type { RemoteMcpConfig } from "./types";
+import type { McpConfig } from "./types";
 import type { McpName } from "../config";
 
-export type { RemoteMcpConfig } from "./types";
+export type { RemoteMcpConfig, LocalMcpConfig, McpConfig } from "./types";
 
-const allBuiltinMcps: Record<McpName, RemoteMcpConfig> = {
+const allBuiltinMcps: Record<McpName, McpConfig> = {
   websearch,
   context7,
   grep_app,
@@ -17,7 +17,7 @@ const allBuiltinMcps: Record<McpName, RemoteMcpConfig> = {
  */
 export function createBuiltinMcps(
   disabledMcps: readonly string[] = []
-): Record<string, RemoteMcpConfig> {
+): Record<string, McpConfig> {
   return Object.fromEntries(
     Object.entries(allBuiltinMcps).filter(([name]) => !disabledMcps.includes(name))
   );

+ 8 - 0
src/mcp/types.ts

@@ -6,3 +6,11 @@ export type RemoteMcpConfig = {
   headers?: Record<string, string>;
   oauth?: false;
 };
+
+export type LocalMcpConfig = {
+  type: "local";
+  command: string[];
+  environment?: Record<string, string>;
+};
+
+export type McpConfig = RemoteMcpConfig | LocalMcpConfig;

+ 3 - 0
src/tools/index.ts

@@ -15,3 +15,6 @@ export { ast_grep_search, ast_grep_replace } from "./ast-grep";
 
 // Antigravity quota tool
 export { antigravity_quota } from "./quota";
+
+// Skill tools
+export { createSkillTools, SkillMcpManager } from "./skill";

+ 28 - 0
src/tools/skill/builtin.ts

@@ -0,0 +1,28 @@
+import type { SkillDefinition } from "./types";
+
+const playwrightSkill: SkillDefinition = {
+  name: "playwright",
+  description:
+    "MUST USE for any browser-related tasks. Browser automation via Playwright MCP - verification, browsing, information gathering, web scraping, testing, screenshots, and all browser interactions.",
+  template: `# Playwright Browser Automation
+
+This skill provides browser automation capabilities via the Playwright MCP server.`,
+  mcpConfig: {
+    playwright: {
+      command: "npx",
+      args: ["@playwright/mcp@latest"],
+    },
+  },
+};
+
+const builtinSkillsMap = new Map<string, SkillDefinition>([
+  [playwrightSkill.name, playwrightSkill],
+]);
+
+export function getBuiltinSkills(): SkillDefinition[] {
+  return Array.from(builtinSkillsMap.values());
+}
+
+export function getSkillByName(name: string): SkillDefinition | undefined {
+  return builtinSkillsMap.get(name);
+}

+ 3 - 0
src/tools/skill/constants.ts

@@ -0,0 +1,3 @@
+export const SKILL_TOOL_DESCRIPTION = `Loads a skill and returns its instructions and available MCP tools. Use this to activate specialized capabilities like Playwright browser automation.`;
+
+export const SKILL_MCP_TOOL_DESCRIPTION = `Invokes a tool exposed by a skill's MCP server. Use after loading a skill to perform actions like browser automation.`;

+ 8 - 0
src/tools/skill/index.ts

@@ -0,0 +1,8 @@
+export { createSkillTools } from "./tools";
+export { SkillMcpManager } from "./mcp-manager";
+export type {
+  SkillDefinition,
+  SkillArgs,
+  SkillMcpArgs,
+  McpServerConfig,
+} from "./types";

+ 294 - 0
src/tools/skill/mcp-manager.ts

@@ -0,0 +1,294 @@
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
+import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js";
+import type {
+  ConnectionType,
+  ManagedClient,
+  ManagedHttpClient,
+  ManagedStdioClient,
+  McpServerConfig,
+  SkillMcpClientInfo,
+} from "./types";
+
+function getConnectionType(config: McpServerConfig): ConnectionType {
+  return "url" in config ? "http" : "stdio";
+}
+
+export class SkillMcpManager {
+  private static instance: SkillMcpManager;
+  private clients: Map<string, ManagedClient> = new Map();
+  private pendingConnections: Map<string, Promise<Client>> = new Map();
+  private cleanupInterval: ReturnType<typeof setInterval> | null = null;
+  private readonly IDLE_TIMEOUT = 5 * 60 * 1000;
+
+  private constructor() {
+    this.startCleanupTimer();
+    this.registerProcessCleanup();
+  }
+
+  static getInstance(): SkillMcpManager {
+    if (!SkillMcpManager.instance) {
+      SkillMcpManager.instance = new SkillMcpManager();
+    }
+    return SkillMcpManager.instance;
+  }
+
+  private registerProcessCleanup(): void {
+    const cleanup = () => {
+      for (const [, managed] of this.clients) {
+        try {
+          managed.client.close();
+        } catch {}
+        try {
+          managed.transport.close();
+        } catch {}
+      }
+      this.clients.clear();
+      if (this.cleanupInterval) {
+        clearInterval(this.cleanupInterval);
+        this.cleanupInterval = null;
+      }
+    };
+
+    process.on("exit", cleanup);
+    process.on("SIGINT", () => {
+      cleanup();
+      process.exit(0);
+    });
+    process.on("SIGTERM", () => {
+      cleanup();
+      process.exit(0);
+    });
+  }
+
+  private getClientKey(info: SkillMcpClientInfo): string {
+    return `${info.sessionId}:${info.skillName}:${info.serverName}`;
+  }
+
+  private async createClient(
+    info: SkillMcpClientInfo,
+    config: McpServerConfig
+  ): Promise<Client> {
+    const connectionType = getConnectionType(config);
+
+    if (connectionType === "http") {
+      return this.createHttpClient(info, config);
+    }
+
+    return this.createStdioClient(info, config);
+  }
+
+  private async createHttpClient(
+    info: SkillMcpClientInfo,
+    config: McpServerConfig
+  ): Promise<Client> {
+    if (!("url" in config)) {
+      throw new Error(
+        `MCP server "${info.serverName}" missing url for HTTP connection.`
+      );
+    }
+
+    const url = new URL(config.url);
+    const requestInit: RequestInit = {};
+    if (config.headers && Object.keys(config.headers).length > 0) {
+      requestInit.headers = config.headers;
+    }
+
+    const transport = new StreamableHTTPClientTransport(url, {
+      requestInit: Object.keys(requestInit).length > 0 ? requestInit : undefined,
+    });
+
+    const client = new Client(
+      { name: `skill-mcp-${info.skillName}-${info.serverName}`, version: "1.0.0" },
+      { capabilities: {} }
+    );
+
+    try {
+      await client.connect(transport);
+    } catch (error) {
+      try {
+        await transport.close();
+      } catch {
+        // ignore transport close errors
+      }
+      const errorMessage = error instanceof Error ? error.message : String(error);
+      throw new Error(
+        `Failed to connect to MCP server "${info.serverName}". ${errorMessage}`
+      );
+    }
+
+    const managedClient: ManagedHttpClient = {
+      client,
+      transport,
+      skillName: info.skillName,
+      lastUsedAt: Date.now(),
+      connectionType: "http",
+    };
+
+    this.clients.set(this.getClientKey(info), managedClient);
+    this.startCleanupTimer();
+    return client;
+  }
+
+  private async createStdioClient(
+    info: SkillMcpClientInfo,
+    config: McpServerConfig
+  ): Promise<Client> {
+    if (!("command" in config)) {
+      throw new Error(
+        `MCP server "${info.serverName}" missing command for stdio connection.`
+      );
+    }
+
+    const transport = new StdioClientTransport({
+      command: config.command,
+      args: config.args || [],
+      env: config.env,
+      stderr: "ignore",
+    });
+
+    const client = new Client(
+      { name: `skill-mcp-${info.skillName}-${info.serverName}`, version: "1.0.0" },
+      { capabilities: {} }
+    );
+
+    try {
+      await client.connect(transport);
+    } catch (error) {
+      try {
+        await transport.close();
+      } catch {
+        // ignore transport close errors
+      }
+      const errorMessage = error instanceof Error ? error.message : String(error);
+      throw new Error(
+        `Failed to connect to MCP server "${info.serverName}". ${errorMessage}`
+      );
+    }
+
+    const managedClient: ManagedStdioClient = {
+      client,
+      transport,
+      skillName: info.skillName,
+      lastUsedAt: Date.now(),
+      connectionType: "stdio",
+    };
+
+    this.clients.set(this.getClientKey(info), managedClient);
+    this.startCleanupTimer();
+    return client;
+  }
+
+  private async getOrCreateClient(
+    info: SkillMcpClientInfo,
+    config: McpServerConfig
+  ): Promise<Client> {
+    const key = this.getClientKey(info);
+    const existing = this.clients.get(key);
+    if (existing) {
+      existing.lastUsedAt = Date.now();
+      return existing.client;
+    }
+
+    const pending = this.pendingConnections.get(key);
+    if (pending) {
+      return pending;
+    }
+
+    const connectionPromise = this.createClient(info, config);
+    this.pendingConnections.set(key, connectionPromise);
+
+    try {
+      return await connectionPromise;
+    } finally {
+      this.pendingConnections.delete(key);
+    }
+  }
+
+  async listTools(
+    info: SkillMcpClientInfo,
+    config: McpServerConfig
+  ): Promise<Tool[]> {
+    const client = await this.getOrCreateClient(info, config);
+    const result = await client.listTools();
+    return result.tools;
+  }
+
+  async listResources(
+    info: SkillMcpClientInfo,
+    config: McpServerConfig
+  ): Promise<Resource[]> {
+    const client = await this.getOrCreateClient(info, config);
+    const result = await client.listResources();
+    return result.resources;
+  }
+
+  async listPrompts(
+    info: SkillMcpClientInfo,
+    config: McpServerConfig
+  ): Promise<Prompt[]> {
+    const client = await this.getOrCreateClient(info, config);
+    const result = await client.listPrompts();
+    return result.prompts;
+  }
+
+  async callTool(
+    info: SkillMcpClientInfo,
+    config: McpServerConfig,
+    name: string,
+    args: Record<string, unknown>
+  ): Promise<unknown> {
+    const client = await this.getOrCreateClient(info, config);
+    const result = await client.callTool({ name, arguments: args });
+    return result.content;
+  }
+
+  async readResource(
+    info: SkillMcpClientInfo,
+    config: McpServerConfig,
+    uri: string
+  ): Promise<unknown> {
+    const client = await this.getOrCreateClient(info, config);
+    const result = await client.readResource({ uri });
+    return result.contents;
+  }
+
+  async getPrompt(
+    info: SkillMcpClientInfo,
+    config: McpServerConfig,
+    name: string,
+    args: Record<string, string>
+  ): Promise<unknown> {
+    const client = await this.getOrCreateClient(info, config);
+    const result = await client.getPrompt({ name, arguments: args });
+    return result.messages;
+  }
+
+  private startCleanupTimer(): void {
+    if (this.cleanupInterval) return;
+    this.cleanupInterval = setInterval(() => {
+      this.cleanupIdleClients();
+    }, 60_000);
+    this.cleanupInterval.unref();
+  }
+
+  private async cleanupIdleClients(): Promise<void> {
+    const now = Date.now();
+    for (const [key, managed] of this.clients) {
+      if (now - managed.lastUsedAt > this.IDLE_TIMEOUT) {
+        this.clients.delete(key);
+        try {
+          await managed.client.close();
+        } catch {
+          // ignore close errors
+        }
+        try {
+          await managed.transport.close();
+        } catch {
+          // ignore transport close errors
+        }
+      }
+    }
+  }
+}

+ 197 - 0
src/tools/skill/tools.ts

@@ -0,0 +1,197 @@
+import { tool, type ToolDefinition } from "@opencode-ai/plugin";
+import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js";
+import { SKILL_MCP_TOOL_DESCRIPTION, SKILL_TOOL_DESCRIPTION } from "./constants";
+import { getSkillByName, getBuiltinSkills } from "./builtin";
+import type { SkillArgs, SkillMcpArgs, SkillDefinition } from "./types";
+import { SkillMcpManager } from "./mcp-manager";
+
+function formatSkillsXml(skills: SkillDefinition[]): string {
+  if (skills.length === 0) return "";
+
+  const skillsXml = skills
+    .map(skill => {
+      const lines = [
+        "  <skill>",
+        `    <name>${skill.name}</name>`,
+        `    <description>${skill.description}</description>`,
+        "  </skill>",
+      ];
+      return lines.join("\n");
+    })
+    .join("\n");
+
+  return `\n\n<available_skills>\n${skillsXml}\n</available_skills>`;
+}
+
+async function formatMcpCapabilities(
+  skill: SkillDefinition,
+  manager: SkillMcpManager,
+  sessionId: string
+): Promise<string | null> {
+  if (!skill.mcpConfig || Object.keys(skill.mcpConfig).length === 0) {
+    return null;
+  }
+
+  const sections: string[] = ["", "## Available MCP Servers", ""];
+
+  for (const [serverName, config] of Object.entries(skill.mcpConfig)) {
+    const info = {
+      serverName,
+      skillName: skill.name,
+      sessionId,
+    };
+
+    sections.push(`### ${serverName}`);
+    sections.push("");
+
+    try {
+      const [tools, resources, prompts] = await Promise.all([
+        manager.listTools(info, config).catch(() => []),
+        manager.listResources(info, config).catch(() => []),
+        manager.listPrompts(info, config).catch(() => []),
+      ]);
+
+      if (tools.length > 0) {
+        sections.push("**Tools:**");
+        sections.push("");
+        for (const t of tools as Tool[]) {
+          sections.push(`#### \`${t.name}\``);
+          if (t.description) {
+            sections.push(t.description);
+          }
+          sections.push("");
+          sections.push("**inputSchema:**");
+          sections.push("```json");
+          sections.push(JSON.stringify(t.inputSchema, null, 2));
+          sections.push("```");
+          sections.push("");
+        }
+      }
+
+      if (resources.length > 0) {
+        sections.push(
+          `**Resources**: ${(resources as Resource[])
+            .map(r => r.uri)
+            .join(", ")}`
+        );
+      }
+
+      if (prompts.length > 0) {
+        sections.push(
+          `**Prompts**: ${(prompts as Prompt[]).map(p => p.name).join(", ")}`
+        );
+      }
+
+      if (tools.length === 0 && resources.length === 0 && prompts.length === 0) {
+        sections.push("*No capabilities discovered*");
+      }
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : String(error);
+      sections.push(`*Failed to connect: ${errorMessage.split("\n")[0]}*`);
+    }
+
+    sections.push("");
+    sections.push(
+      `Use \`skill_mcp\` tool with \`mcp_name="${serverName}"\` to invoke.`
+    );
+    sections.push("");
+  }
+
+  return sections.join("\n");
+}
+
+export function createSkillTools(
+  manager: SkillMcpManager
+): { skill: ToolDefinition; skill_mcp: ToolDefinition } {
+  const skills = getBuiltinSkills();
+  const description =
+    SKILL_TOOL_DESCRIPTION + (skills.length > 0 ? formatSkillsXml(skills) : "");
+
+  const skill: ToolDefinition = tool({
+    description,
+    args: {
+      name: tool.schema.string().describe("The skill identifier from available_skills"),
+    },
+    async execute(args: SkillArgs, toolContext) {
+      const sessionId = toolContext?.sessionID
+        ? String(toolContext.sessionID)
+        : "unknown";
+      const skillDefinition = getSkillByName(args.name);
+      if (!skillDefinition) {
+        const available = skills.map(s => s.name).join(", ");
+        throw new Error(
+          `Skill "${args.name}" not found. Available skills: ${available || "none"}`
+        );
+      }
+
+      const output = [
+        `## Skill: ${skillDefinition.name}`,
+        "",
+        skillDefinition.template.trim(),
+      ];
+
+      if (skillDefinition.mcpConfig) {
+        const mcpInfo = await formatMcpCapabilities(
+          skillDefinition,
+          manager,
+          sessionId
+        );
+        if (mcpInfo) {
+          output.push(mcpInfo);
+        }
+      }
+
+      return output.join("\n");
+    },
+  });
+
+  const skill_mcp: ToolDefinition = tool({
+    description: SKILL_MCP_TOOL_DESCRIPTION,
+    args: {
+      skillName: tool.schema.string().describe("Skill name that provides the MCP"),
+      mcpName: tool.schema.string().describe("MCP server name"),
+      toolName: tool.schema.string().describe("Tool name to invoke"),
+      toolArgs: tool.schema.record(tool.schema.string(), tool.schema.any()).optional(),
+    },
+    async execute(args: SkillMcpArgs, toolContext) {
+      const sessionId = toolContext?.sessionID
+        ? String(toolContext.sessionID)
+        : "unknown";
+      const skillDefinition = getSkillByName(args.skillName);
+      if (!skillDefinition) {
+        const available = skills.map(s => s.name).join(", ");
+        throw new Error(
+          `Skill "${args.skillName}" not found. Available skills: ${available || "none"}`
+        );
+      }
+
+      if (!skillDefinition.mcpConfig || !skillDefinition.mcpConfig[args.mcpName]) {
+        throw new Error(
+          `Skill "${args.skillName}" has no MCP named "${args.mcpName}".`
+        );
+      }
+
+      const config = skillDefinition.mcpConfig[args.mcpName];
+      const info = {
+        serverName: args.mcpName,
+        skillName: skillDefinition.name,
+        sessionId,
+      };
+
+      const result = await manager.callTool(
+        info,
+        config,
+        args.toolName,
+        args.toolArgs || {}
+      );
+
+      if (typeof result === "string") {
+        return result;
+      }
+
+      return JSON.stringify(result);
+    },
+  });
+
+  return { skill, skill_mcp };
+}

+ 104 - 0
src/tools/skill/types.ts

@@ -0,0 +1,104 @@
+import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import type { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
+import type { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
+
+/**
+ * Stdio MCP server configuration (local process)
+ */
+export interface StdioMcpServer {
+  type?: "stdio";
+  command: string;
+  args?: string[];
+  env?: Record<string, string>;
+}
+
+/**
+ * HTTP MCP server configuration (remote server)
+ */
+export interface HttpMcpServer {
+  type: "http" | "sse";
+  url: string;
+  headers?: Record<string, string>;
+}
+
+/**
+ * MCP server configuration - either stdio or http
+ */
+export type McpServerConfig = StdioMcpServer | HttpMcpServer;
+
+/**
+ * Skill MCP configuration - map of server names to their configs
+ */
+export type SkillMcpConfig = Record<string, McpServerConfig>;
+
+/**
+ * Skill definition
+ */
+export interface SkillDefinition {
+  name: string;
+  description: string;
+  template: string;
+  mcpConfig?: SkillMcpConfig;
+}
+
+/**
+ * Info for identifying a managed MCP client
+ */
+export interface SkillMcpClientInfo {
+  serverName: string;
+  skillName: string;
+  sessionId: string;
+}
+
+/**
+ * Connection type for managed clients
+ */
+export type ConnectionType = "stdio" | "http";
+
+/**
+ * Base interface for managed MCP clients
+ */
+interface ManagedClientBase {
+  client: Client;
+  skillName: string;
+  lastUsedAt: number;
+  connectionType: ConnectionType;
+}
+
+/**
+ * Managed stdio client
+ */
+export interface ManagedStdioClient extends ManagedClientBase {
+  connectionType: "stdio";
+  transport: StdioClientTransport;
+}
+
+/**
+ * Managed HTTP client
+ */
+export interface ManagedHttpClient extends ManagedClientBase {
+  connectionType: "http";
+  transport: StreamableHTTPClientTransport;
+}
+
+/**
+ * Managed client - either stdio or http
+ */
+export type ManagedClient = ManagedStdioClient | ManagedHttpClient;
+
+/**
+ * Args for the skill tool
+ */
+export interface SkillArgs {
+  name: string;
+}
+
+/**
+ * Args for the skill_mcp tool
+ */
+export interface SkillMcpArgs {
+  skillName: string;
+  mcpName: string;
+  toolName: string;
+  toolArgs?: Record<string, unknown>;
+}