From 2dab8e91accb0d7278185557db9a22764508923d Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Sat, 9 May 2026 14:30:45 +0100 Subject: [PATCH] Add bundled Codex skill and installer Bundle a Codex Skill into the package and provide installation tooling and tests. Adds skills/codex-computer-run (SKILL.md and agent metadata), a CodexSkillInstaller class to locate and install the packaged skill (with --install-codex-skill and --force flags and auto-install diagnostics), and unit/integration tests to verify installation and repository bundling. Update packaging to include the skills directory in the NuGet package, bump package/version to 1.0.0 (csproj, .mcp/server.json, version.json), and update README/.mcp/install snippets to document the skill and install commands. Also integrate TryAutoInstall on startup and adjust tests and badges accordingly. --- .mcp/install.md | 10 +- .mcp/server.json | 6 +- README.md | 67 ++++- skills/codex-computer-run/SKILL.md | 61 +++++ skills/codex-computer-run/agents/openai.yaml | 4 + .../CodexSkillInstallerTests.cs | 229 ++++++++++++++++++ .../McpIntegrationTests.cs | 15 ++ .../CodexComputerRunMCPServer.csproj | 7 +- .../CodexSkillInstaller.cs | 210 ++++++++++++++++ src/CodexComputerRunMCPServer/Program.cs | 11 + version.json | 2 +- 11 files changed, 608 insertions(+), 14 deletions(-) create mode 100644 skills/codex-computer-run/SKILL.md create mode 100644 skills/codex-computer-run/agents/openai.yaml create mode 100644 src/CodexComputerRunMCPServer.Tests/CodexSkillInstallerTests.cs create mode 100644 src/CodexComputerRunMCPServer/CodexSkillInstaller.cs diff --git a/.mcp/install.md b/.mcp/install.md index d2c7285..a6c8b87 100644 --- a/.mcp/install.md +++ b/.mcp/install.md @@ -19,7 +19,7 @@ Suggested stdio config after NuGet publication: "codex-computer-run": { "command": "dnx", "args": [ - "CP.CodexComputerRun.Mcp.Server@0.*", + "CP.CodexComputerRun.Mcp.Server@1.*", "--yes" ] } @@ -27,6 +27,14 @@ Suggested stdio config after NuGet publication: } ``` +Bundled Codex Skill install: + +```powershell +codex-computer-run-mcp-server --install-codex-skill +``` + +The server also auto-installs the bundled `codex-computer-run` skill on startup when `CODEX_HOME` is set or `%USERPROFILE%\.codex` already exists. Existing skill files are left untouched unless the installer is run with `--force`. + Alternative source-run config for development: ```json diff --git a/.mcp/server.json b/.mcp/server.json index 1509e5d..2c8721f 100644 --- a/.mcp/server.json +++ b/.mcp/server.json @@ -2,14 +2,14 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.chrispulman/codex-computer-run-mcp-server", "title": "Codex Computer Run MCP Server", - "description": "Windows-only MCP server for Codex desktop control: screenshots, mouse, keyboard, clipboard paste, cursor position, and visible window discovery.", - "version": "0.1.0", + "description": "Windows-only MCP server for Codex desktop control: screenshots, mouse, keyboard, clipboard paste, cursor position, visible window discovery, and a bundled Codex Skill.", + "version": "1.0.0", "packages": [ { "registryType": "nuget", "registryBaseUrl": "https://api.nuget.org/v3/index.json", "identifier": "CP.CodexComputerRun.Mcp.Server", - "version": "0.1.0", + "version": "1.0.0", "runtimeHint": "dnx", "transport": { "type": "stdio" diff --git a/README.md b/README.md index 2e72d76..eb0a064 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Codex Computer Run MCP Server gives Codex and other MCP-capable agents direct control over a signed-in Windows desktop session. -It exposes focused tools for screenshots, mouse movement, clicks, scrolling, keyboard shortcuts, Unicode paste, cursor position, and visible window discovery. +It exposes focused tools for screenshots, mouse movement, clicks, scrolling, keyboard shortcuts, Unicode paste, cursor position, and visible window discovery, plus a bundled Codex Skill for safe desktop-use workflows. It is implemented in C# on `net10.0` using `ModelContextProtocol` `1.2.0`. The package targets plain `net10.0` so it can be distributed as a .NET tool; all desktop operations remain Windows-only through runtime guards and Win32 interop. @@ -12,9 +12,9 @@ The package targets plain `net10.0` so it can be distributed as a .NET tool; all Click to install in your preferred environment: -[![VS Code - Install Codex Computer Run MCP](https://img.shields.io/badge/VS_Code-Install_Codex_Computer_Run_MCP-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect/mcp/install?name=codex-computer-run-mcp-server&config=%7B%22type%22%3A%22stdio%22%2C%22command%22%3A%22dnx%22%2C%22args%22%3A%5B%22CP.CodexComputerRun.Mcp.Server%400.%2A%22%2C%22--yes%22%5D%7D) -[![VS Code Insiders - Install Codex Computer Run MCP](https://img.shields.io/badge/VS_Code_Insiders-Install_Codex_Computer_Run_MCP-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=codex-computer-run-mcp-server&config=%7B%22type%22%3A%22stdio%22%2C%22command%22%3A%22dnx%22%2C%22args%22%3A%5B%22CP.CodexComputerRun.Mcp.Server%400.%2A%22%2C%22--yes%22%5D%7D&quality=insiders) -[![Visual Studio - Install Codex Computer Run MCP](https://img.shields.io/badge/Visual_Studio-Install_Codex_Computer_Run_MCP-5C2D91?style=flat-square&logo=visualstudio&logoColor=white)](https://vs-open.link/mcp-install?%7B%22name%22%3A%22CP.CodexComputerRun.Mcp.Server%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22dnx%22%2C%22args%22%3A%5B%22CP.CodexComputerRun.Mcp.Server%400.%2A%22%2C%22--yes%22%5D%7D) +[![VS Code - Install Codex Computer Run MCP](https://img.shields.io/badge/VS_Code-Install_Codex_Computer_Run_MCP-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect/mcp/install?name=codex-computer-run-mcp-server&config=%7B%22type%22%3A%22stdio%22%2C%22command%22%3A%22dnx%22%2C%22args%22%3A%5B%22CP.CodexComputerRun.Mcp.Server%401.%2A%22%2C%22--yes%22%5D%7D) +[![VS Code Insiders - Install Codex Computer Run MCP](https://img.shields.io/badge/VS_Code_Insiders-Install_Codex_Computer_Run_MCP-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=codex-computer-run-mcp-server&config=%7B%22type%22%3A%22stdio%22%2C%22command%22%3A%22dnx%22%2C%22args%22%3A%5B%22CP.CodexComputerRun.Mcp.Server%401.%2A%22%2C%22--yes%22%5D%7D&quality=insiders) +[![Visual Studio - Install Codex Computer Run MCP](https://img.shields.io/badge/Visual_Studio-Install_Codex_Computer_Run_MCP-5C2D91?style=flat-square&logo=visualstudio&logoColor=white)](https://vs-open.link/mcp-install?%7B%22name%22%3A%22CP.CodexComputerRun.Mcp.Server%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22dnx%22%2C%22args%22%3A%5B%22CP.CodexComputerRun.Mcp.Server%401.%2A%22%2C%22--yes%22%5D%7D) Note: - These install links are prepared for the intended NuGet package identity `CP.CodexComputerRun.Mcp.Server`. @@ -60,6 +60,40 @@ When this server is active, agents should follow this operating protocol: 5. Prefer `type_text` for text entry because it uses Unicode clipboard paste and is faster and more reliable than simulated per-character typing. 6. Keep screenshots small in conversation by setting `include_image` to `false` when only dimensions or a saved path are needed. +## Codex Skill + +The repository and NuGet package include a Codex Skill at `skills/codex-computer-run`. The skill teaches Codex the observation-first workflow, safety rules, and exact MCP tool names for this server. + +When the packaged server starts, it tries to install the skill into the current Codex installation if `CODEX_HOME` is set or `%USERPROFILE%\.codex` already exists. Existing skill files are not overwritten during automatic install. + +Manual install from a globally installed tool: + +```powershell +dotnet tool install --global CP.CodexComputerRun.Mcp.Server --version 1.* +codex-computer-run-mcp-server --install-codex-skill +``` + +Manual install from source: + +```powershell +dotnet run --project .\src\CodexComputerRunMCPServer\CodexComputerRunMCPServer.csproj -- --install-codex-skill +``` + +Set `CODEX_HOME` first if Codex uses a non-default location: + +```powershell +$env:CODEX_HOME = "C:\Users\you\.codex" +codex-computer-run-mcp-server --install-codex-skill +``` + +To refresh an existing installed copy with the packaged skill files, add `--force`. + +Use the skill in Codex by asking for it explicitly, for example: + +```text +Use $codex-computer-run to list visible windows, take a screenshot, and confirm the active desktop state. +``` + ## Available MCP Tools ### `screenshot` @@ -195,6 +229,9 @@ src/ .mcp/ |-- server.json # MCP registry/package metadata `-- install.md # Manual MCP install snippets + +skills/ +`-- codex-computer-run/ # Codex Skill bundled into the NuGet package ``` ## Configuration @@ -226,6 +263,22 @@ Published executable: } ``` +NuGet package through `dnx`: + +```json +{ + "mcpServers": { + "codex-computer-run": { + "command": "dnx", + "args": [ + "CP.CodexComputerRun.Mcp.Server@1.*", + "--yes" + ] + } + } +} +``` + Development source run: ```json @@ -253,6 +306,7 @@ No. They are optional convenience snippets for MCP clients that import JSON conf Required or primary MCP/Codex files are: - `.mcp/server.json` for MCP package metadata. - `.mcp/install.md` for install notes. +- `skills/codex-computer-run` for the bundled Codex Skill. - `.codex/config.toml` for this local Codex workspace. - `.mcp.json` only if your client reads repository-local MCP JSON configuration. @@ -282,8 +336,9 @@ dotnet test .\src\CodexComputerRunMCPServer.Tests\CodexComputerRunMCPServer.Test ``` Current verification: -- 23 TUnit tests passed. -- Coverage: 100% line coverage, 98.44% branch coverage for testable code. +- 32 TUnit tests passed. +- Coverage: 91.56% line coverage, 79.03% branch coverage for testable code. +- NuGet package verification confirms `skills/codex-computer-run/SKILL.md` and `skills/codex-computer-run/agents/openai.yaml` are bundled. - Native Win32 P/Invoke shims are excluded from coverage and verified through the service boundary plus live MCP tool discovery. ## Publish diff --git a/skills/codex-computer-run/SKILL.md b/skills/codex-computer-run/SKILL.md new file mode 100644 index 0000000..7829f27 --- /dev/null +++ b/skills/codex-computer-run/SKILL.md @@ -0,0 +1,61 @@ +--- +name: codex-computer-run +description: Use this skill when Codex needs to operate or inspect a signed-in Windows desktop through the Codex Computer Run MCP server, including screenshots, visible window discovery, cursor position checks, mouse movement, clicking, scrolling, keyboard shortcuts, single-key presses, or Unicode text paste into focused applications. +--- + +# Codex Computer Run + +## Overview + +Use the Codex Computer Run MCP server as an observation-first control layer for a real Windows desktop session. Its tools affect the active machine, so inspect context before acting and verify after actions that can change state. + +## Tool Discovery + +- Prefer the `mcp__codex_computer_run__` namespace when available. +- If tools are deferred, search for `ComputerRun`, `codex computer run`, or `desktop screenshot mouse keyboard` and choose the namespace that exposes the complete tool set. +- Expect these tools: `screenshot`, `list_windows`, `cursor_position`, `move_mouse`, `click`, `scroll`, `press_key`, `hotkey`, and `type_text`. +- If the MCP tools are unavailable, state that the Computer Run server is not configured in the current session instead of simulating desktop interaction with unrelated shell commands. + +## Operating Protocol + +1. Observe before acting: + - Use `list_windows` to identify visible applications and likely targets. + - Use `screenshot` when visual layout, coordinates, or UI state matters. + - Use `cursor_position` before relying on the current pointer location. +2. Plan in absolute Windows virtual-screen coordinates: + - Treat coordinates as desktop coordinates, not browser or app-relative coordinates. + - Read screenshot metadata for `left`, `top`, `width`, and `height`; multi-monitor layouts can have negative `left` or `top` values. + - Move or click only when the target application and coordinates are known. +3. Act with the narrowest tool: + - Use `move_mouse` for hover or to position before a click. + - Use `click` for buttons, menus, tabs, selections, and context menus. + - Use `scroll` for pages, lists, combo boxes, and scrollable panes. + - Use `press_key` for one key such as `enter`, `tab`, `escape`, `f5`, arrows, or a single character. + - Use `hotkey` for shortcuts such as `ctrl+l`, `ctrl+shift+p`, `alt+tab`, or `ctrl+shift+escape`. + - Use `type_text` for text entry; it pastes Unicode through the clipboard and is faster and more reliable than repeated key presses. +4. Verify after meaningful actions: + - Use `screenshot` after navigation, clicks, scrolls, or text entry when the resulting state matters. + - Use `list_windows` again after task switching or launching apps. + +## Screenshots + +- Use `screenshot` with `include_image: true` when the model needs to inspect the pixels. +- Use `include_image: false` with a `path` when only dimensions, a saved artifact, or later local inspection is needed. +- Save screenshots to an explicit temporary or workspace path when they may be referenced in the final answer. + +## Safety Rules + +- Remember that mouse, keyboard, and clipboard actions affect the user's signed-in desktop. +- Do not perform destructive UI actions, submit forms, send messages, make purchases, delete files, or change account/security settings unless the user explicitly asked for that exact outcome. +- Confirm the intended foreground app with `list_windows` or `screenshot` before typing or pressing shortcuts that could affect the wrong application. +- Treat `type_text` as clipboard-changing; use it only when pasting into the focused target is intended. +- Keep delays short but use the optional `delay` parameter after actions that trigger UI transitions. + +## Quick Checks + +For a non-destructive connectivity test: + +1. Call `list_windows` with a small limit and confirm visible window metadata is returned. +2. Call `cursor_position` and confirm JSON coordinates are returned. +3. Call `screenshot` with a saved `path` and `include_image: false`; confirm screenshot metadata includes the saved path and virtual desktop bounds. +4. Optionally move the cursor by a small, reversible amount with `move_mouse`, then call `cursor_position` again to confirm the new coordinates. diff --git a/skills/codex-computer-run/agents/openai.yaml b/skills/codex-computer-run/agents/openai.yaml new file mode 100644 index 0000000..d8dc74b --- /dev/null +++ b/skills/codex-computer-run/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Codex Computer Run" + short_description: "Control the Windows desktop through MCP" + default_prompt: "Use $codex-computer-run to inspect the desktop, list windows, and interact with Windows UI safely." diff --git a/src/CodexComputerRunMCPServer.Tests/CodexSkillInstallerTests.cs b/src/CodexComputerRunMCPServer.Tests/CodexSkillInstallerTests.cs new file mode 100644 index 0000000..99d40c4 --- /dev/null +++ b/src/CodexComputerRunMCPServer.Tests/CodexSkillInstallerTests.cs @@ -0,0 +1,229 @@ +namespace CodexComputerRunMCPServer.Tests; + +public class CodexSkillInstallerTests +{ + private static readonly object EnvironmentLock = new(); + + [Test] + public async Task Arguments_DetectInstallAndForceFlags() + { + await Assert.That(CodexSkillInstaller.IsInstallRequested(["--install-codex-skill"])).IsTrue(); + await Assert.That(CodexSkillInstaller.IsInstallRequested(["--other"])).IsFalse(); + await Assert.That(CodexSkillInstaller.IsForceRequested(["--install-codex-skill", "--force"])).IsTrue(); + } + + [Test] + public async Task InstallBundledSkill_UsesCodexHomeEnvironment() + { + var tempRoot = CreateTempRoot(); + + try + { + var codexHome = Path.Combine(tempRoot, ".codex"); + SkillInstallResult result; + lock (EnvironmentLock) + { + result = WithCodexHome(codexHome, () => CodexSkillInstaller.InstallBundledSkill(createCodexHome: true, overwrite: false)); + } + + await Assert.That(result.Success).IsTrue(); + await Assert.That(result.Installed).IsTrue(); + await Assert.That(File.Exists(Path.Combine(codexHome, "skills", CodexSkillInstaller.SkillName, "SKILL.md"))).IsTrue(); + } + finally + { + TryDeleteDirectory(tempRoot); + } + } + + [Test] + public async Task InstallBundledSkill_SkipsWhenCodexHomeDoesNotExist() + { + var tempRoot = CreateTempRoot(); + + try + { + var codexHome = Path.Combine(tempRoot, "missing-codex-home"); + SkillInstallResult result; + lock (EnvironmentLock) + { + result = WithCodexHome(codexHome, () => CodexSkillInstaller.InstallBundledSkill(createCodexHome: false, overwrite: false)); + } + + await Assert.That(result.Success).IsTrue(); + await Assert.That(result.Skipped).IsTrue(); + await Assert.That(Directory.Exists(codexHome)).IsFalse(); + } + finally + { + TryDeleteDirectory(tempRoot); + } + } + + [Test] + public async Task TryAutoInstall_WritesDiagnosticWhenSkillIsInstalled() + { + var tempRoot = CreateTempRoot(); + + try + { + var codexHome = Path.Combine(tempRoot, ".codex"); + Directory.CreateDirectory(codexHome); + using var diagnostics = new StringWriter(); + SkillInstallResult result; + lock (EnvironmentLock) + { + result = WithCodexHome(codexHome, () => CodexSkillInstaller.TryAutoInstall(diagnostics)); + } + + await Assert.That(result.Installed).IsTrue(); + await Assert.That(diagnostics.ToString()).Contains("Installed"); + } + finally + { + TryDeleteDirectory(tempRoot); + } + } + + [Test] + public async Task Install_CopiesSkillFilesIntoCodexHome() + { + var tempRoot = CreateTempRoot(); + + try + { + var sourceSkill = CreateSourceSkill(tempRoot); + var codexHome = Path.Combine(tempRoot, ".codex"); + + var result = CodexSkillInstaller.Install(sourceSkill, codexHome, overwrite: false); + + var targetSkill = Path.Combine(codexHome, "skills", CodexSkillInstaller.SkillName); + await Assert.That(result.Success).IsTrue(); + await Assert.That(result.Installed).IsTrue(); + await Assert.That(File.Exists(Path.Combine(targetSkill, "SKILL.md"))).IsTrue(); + await Assert.That(File.Exists(Path.Combine(targetSkill, "agents", "openai.yaml"))).IsTrue(); + await Assert.That(File.ReadAllText(Path.Combine(targetSkill, "SKILL.md"))).Contains("codex-computer-run"); + } + finally + { + TryDeleteDirectory(tempRoot); + } + } + + [Test] + public async Task Install_ReturnsFailureForInvalidSource() + { + var tempRoot = CreateTempRoot(); + + try + { + var missingSource = Path.Combine(tempRoot, "missing-source"); + var emptySource = Path.Combine(tempRoot, "empty-source"); + Directory.CreateDirectory(emptySource); + + var missingResult = CodexSkillInstaller.Install(missingSource, Path.Combine(tempRoot, ".codex"), overwrite: false); + var emptyResult = CodexSkillInstaller.Install(emptySource, Path.Combine(tempRoot, ".codex"), overwrite: false); + + await Assert.That(missingResult.Success).IsFalse(); + await Assert.That(emptyResult.Success).IsFalse(); + await Assert.That(emptyResult.Message).Contains("SKILL.md"); + } + finally + { + TryDeleteDirectory(tempRoot); + } + } + + [Test] + public async Task Install_DoesNotOverwriteExistingSkillFilesUnlessForced() + { + var tempRoot = CreateTempRoot(); + + try + { + var sourceSkill = CreateSourceSkill(tempRoot); + var codexHome = Path.Combine(tempRoot, ".codex"); + var targetSkill = Path.Combine(codexHome, "skills", CodexSkillInstaller.SkillName); + Directory.CreateDirectory(targetSkill); + var targetSkillFile = Path.Combine(targetSkill, "SKILL.md"); + File.WriteAllText(targetSkillFile, "user custom skill"); + + var result = CodexSkillInstaller.Install(sourceSkill, codexHome, overwrite: false); + + await Assert.That(result.Success).IsTrue(); + await Assert.That(File.ReadAllText(targetSkillFile)).IsEqualTo("user custom skill"); + } + finally + { + TryDeleteDirectory(tempRoot); + } + } + + [Test] + public async Task Install_OverwritesExistingSkillFilesWhenForced() + { + var tempRoot = CreateTempRoot(); + + try + { + var sourceSkill = CreateSourceSkill(tempRoot); + var codexHome = Path.Combine(tempRoot, ".codex"); + var targetSkill = Path.Combine(codexHome, "skills", CodexSkillInstaller.SkillName); + Directory.CreateDirectory(targetSkill); + var targetSkillFile = Path.Combine(targetSkill, "SKILL.md"); + File.WriteAllText(targetSkillFile, "user custom skill"); + + var result = CodexSkillInstaller.Install(sourceSkill, codexHome, overwrite: true); + + await Assert.That(result.Success).IsTrue(); + await Assert.That(result.Installed).IsTrue(); + await Assert.That(File.ReadAllText(targetSkillFile)).Contains("codex-computer-run"); + } + finally + { + TryDeleteDirectory(tempRoot); + } + } + + private static string CreateSourceSkill(string tempRoot) + { + var sourceSkill = Path.Combine(tempRoot, "source", CodexSkillInstaller.SkillName); + Directory.CreateDirectory(Path.Combine(sourceSkill, "agents")); + File.WriteAllText(Path.Combine(sourceSkill, "SKILL.md"), "---\nname: codex-computer-run\n---\n"); + File.WriteAllText(Path.Combine(sourceSkill, "agents", "openai.yaml"), "interface:\n display_name: \"Codex Computer Run\"\n"); + return sourceSkill; + } + + private static string CreateTempRoot() + { + var tempRoot = Path.Combine(Path.GetTempPath(), "codex-computer-run-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + return tempRoot; + } + + private static SkillInstallResult WithCodexHome(string codexHome, Func action) + { + var previousCodexHome = Environment.GetEnvironmentVariable("CODEX_HOME"); + try + { + Environment.SetEnvironmentVariable("CODEX_HOME", codexHome); + return action(); + } + finally + { + Environment.SetEnvironmentVariable("CODEX_HOME", previousCodexHome); + } + } + + private static void TryDeleteDirectory(string directory) + { + try + { + Directory.Delete(directory, recursive: true); + } + catch + { + // Test cleanup only. + } + } +} diff --git a/src/CodexComputerRunMCPServer.Tests/McpIntegrationTests.cs b/src/CodexComputerRunMCPServer.Tests/McpIntegrationTests.cs index a9b6cc1..cb47279 100644 --- a/src/CodexComputerRunMCPServer.Tests/McpIntegrationTests.cs +++ b/src/CodexComputerRunMCPServer.Tests/McpIntegrationTests.cs @@ -75,10 +75,25 @@ public async Task McpManifest_UsesNuGetPackageAndStdioTransport() var package = root.GetProperty("packages")[0]; await Assert.That(root.GetProperty("name").GetString()).IsEqualTo("io.github.chrispulman/codex-computer-run-mcp-server"); + await Assert.That(root.GetProperty("version").GetString()).IsEqualTo("1.0.0"); await Assert.That(package.GetProperty("identifier").GetString()).IsEqualTo("CP.CodexComputerRun.Mcp.Server"); + await Assert.That(package.GetProperty("version").GetString()).IsEqualTo("1.0.0"); await Assert.That(package.GetProperty("transport").GetProperty("type").GetString()).IsEqualTo("stdio"); } + [Test] + public async Task Repository_IncludesBundledCodexSkill() + { + var skillDirectory = Path.Combine(FindRepositoryRoot(), "skills", CodexSkillInstaller.SkillName); + var skillFile = Path.Combine(skillDirectory, "SKILL.md"); + var metadataFile = Path.Combine(skillDirectory, "agents", "openai.yaml"); + + await Assert.That(File.Exists(skillFile)).IsTrue(); + await Assert.That(File.Exists(metadataFile)).IsTrue(); + await Assert.That(File.ReadAllText(skillFile)).Contains("name: codex-computer-run"); + await Assert.That(File.ReadAllText(metadataFile)).Contains("$codex-computer-run"); + } + private static string FindRepositoryRoot() { var directory = new DirectoryInfo(AppContext.BaseDirectory); diff --git a/src/CodexComputerRunMCPServer/CodexComputerRunMCPServer.csproj b/src/CodexComputerRunMCPServer/CodexComputerRunMCPServer.csproj index 9d37239..c081613 100644 --- a/src/CodexComputerRunMCPServer/CodexComputerRunMCPServer.csproj +++ b/src/CodexComputerRunMCPServer/CodexComputerRunMCPServer.csproj @@ -5,14 +5,14 @@ CodexComputerRunMCPServer CodexComputerRunMCPServer CP.CodexComputerRun.Mcp.Server - 0.1.0 + 1.0.0 Codex Computer Run MCP Server - Windows-only MCP server for Codex desktop control: screenshots, mouse, keyboard, clipboard paste, cursor position, and visible window discovery. + Windows-only MCP server for Codex desktop control: screenshots, mouse, keyboard, clipboard paste, cursor position, visible window discovery, and a bundled Codex Skill. Chris Pulman https://github.com/ChrisPulman/CodexComputerRunMCPServer https://github.com/ChrisPulman/CodexComputerRunMCPServer README.md - Codex;MCP;Model Context Protocol;Windows;desktop automation;computer use;dotnet + Codex;MCP;Model Context Protocol;Windows;desktop automation;computer use;skill;dotnet MIT git true @@ -32,5 +32,6 @@ + diff --git a/src/CodexComputerRunMCPServer/CodexSkillInstaller.cs b/src/CodexComputerRunMCPServer/CodexSkillInstaller.cs new file mode 100644 index 0000000..a1d4573 --- /dev/null +++ b/src/CodexComputerRunMCPServer/CodexSkillInstaller.cs @@ -0,0 +1,210 @@ +namespace CodexComputerRunMCPServer; + +/// +/// Installs the bundled Codex skill into a user's Codex skills directory. +/// +internal static class CodexSkillInstaller +{ + public const string SkillName = "codex-computer-run"; + + private const string InstallArgument = "--install-codex-skill"; + private const string ForceArgument = "--force"; + + public static bool IsInstallRequested(IEnumerable args) + => args.Any(arg => string.Equals(arg, InstallArgument, StringComparison.OrdinalIgnoreCase)); + + public static bool IsForceRequested(IEnumerable args) + => args.Any(arg => string.Equals(arg, ForceArgument, StringComparison.OrdinalIgnoreCase)); + + public static SkillInstallResult InstallBundledSkill(bool createCodexHome, bool overwrite) + { + var sourceSkillDirectory = FindBundledSkillDirectory(); + if (sourceSkillDirectory is null) + { + return SkillInstallResult.Failure("The bundled codex-computer-run skill directory was not found."); + } + + var codexHome = ResolveCodexHome(createCodexHome); + if (codexHome is null) + { + return SkillInstallResult.SkippedWith("Codex home was not found. Set CODEX_HOME or create %USERPROFILE%\\.codex."); + } + + return Install(sourceSkillDirectory, codexHome, overwrite); + } + + public static SkillInstallResult TryAutoInstall(TextWriter diagnostics) + { + var result = InstallBundledSkill(createCodexHome: false, overwrite: false); + if (result.Installed) + { + diagnostics.WriteLine(result.Message); + } + else if (!result.Success && !result.Skipped) + { + diagnostics.WriteLine($"Codex skill auto-install skipped: {result.Message}"); + } + + return result; + } + + public static SkillInstallResult Install(string sourceSkillDirectory, string codexHome, bool overwrite) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sourceSkillDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(codexHome); + + if (!Directory.Exists(sourceSkillDirectory)) + { + return SkillInstallResult.Failure($"Source skill directory does not exist: {sourceSkillDirectory}"); + } + + if (!File.Exists(Path.Combine(sourceSkillDirectory, "SKILL.md"))) + { + return SkillInstallResult.Failure($"Source skill directory is missing SKILL.md: {sourceSkillDirectory}"); + } + + var targetSkillDirectory = Path.Combine(codexHome, "skills", SkillName); + if (AreSameDirectory(sourceSkillDirectory, targetSkillDirectory)) + { + return SkillInstallResult.SkippedWith($"Codex skill already points at {targetSkillDirectory}"); + } + + Directory.CreateDirectory(targetSkillDirectory); + + var copiedFiles = 0; + var skippedFiles = 0; + foreach (var sourceFile in Directory.EnumerateFiles(sourceSkillDirectory, "*", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(sourceSkillDirectory, sourceFile); + var targetFile = Path.Combine(targetSkillDirectory, relativePath); + var targetDirectory = Path.GetDirectoryName(targetFile); + if (!string.IsNullOrWhiteSpace(targetDirectory)) + { + Directory.CreateDirectory(targetDirectory); + } + + if (File.Exists(targetFile) && !overwrite) + { + skippedFiles++; + continue; + } + + File.Copy(sourceFile, targetFile, overwrite: true); + copiedFiles++; + } + + if (copiedFiles == 0 && skippedFiles > 0) + { + return SkillInstallResult.SkippedWith($"Codex skill already installed at {targetSkillDirectory}"); + } + + var verb = overwrite ? "Installed or updated" : "Installed"; + return SkillInstallResult.InstalledAt($"{verb} Codex skill at {targetSkillDirectory}", targetSkillDirectory); + } + + private static string? ResolveCodexHome(bool create) + { + var configuredHome = Environment.GetEnvironmentVariable("CODEX_HOME"); + var codexHome = !string.IsNullOrWhiteSpace(configuredHome) + ? configuredHome + : GetDefaultCodexHome(); + + if (string.IsNullOrWhiteSpace(codexHome)) + { + return null; + } + + var fullPath = Path.GetFullPath(Environment.ExpandEnvironmentVariables(codexHome)); + if (!Directory.Exists(fullPath)) + { + if (!create) + { + return null; + } + + Directory.CreateDirectory(fullPath); + } + + return fullPath; + } + + private static string? GetDefaultCodexHome() + { + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return string.IsNullOrWhiteSpace(userProfile) ? null : Path.Combine(userProfile, ".codex"); + } + + private static string? FindBundledSkillDirectory() + { + foreach (var root in CandidateRoots()) + { + var candidate = Path.Combine(root, "skills", SkillName); + if (File.Exists(Path.Combine(candidate, "SKILL.md"))) + { + return candidate; + } + } + + return null; + } + + private static IEnumerable CandidateRoots() + { + yield return AppContext.BaseDirectory; + yield return Environment.CurrentDirectory; + + foreach (var root in SiblingAnyDirectories(AppContext.BaseDirectory)) + { + yield return root; + } + + foreach (var root in Ancestors(AppContext.BaseDirectory)) + { + yield return root; + } + + foreach (var root in Ancestors(Environment.CurrentDirectory)) + { + yield return root; + } + } + + private static IEnumerable SiblingAnyDirectories(string startDirectory) + { + var directory = new DirectoryInfo(startDirectory); + while (directory is not null) + { + yield return Path.Combine(directory.FullName, "any"); + directory = directory.Parent; + } + } + + private static IEnumerable Ancestors(string startDirectory) + { + var directory = new DirectoryInfo(startDirectory); + while (directory.Parent is not null) + { + directory = directory.Parent; + yield return directory.FullName; + } + } + + private static bool AreSameDirectory(string left, string right) + { + var normalizedLeft = Path.GetFullPath(left).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var normalizedRight = Path.GetFullPath(right).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return string.Equals(normalizedLeft, normalizedRight, StringComparison.OrdinalIgnoreCase); + } +} + +internal sealed record SkillInstallResult(bool Success, bool Installed, bool Skipped, string Message, string? TargetDirectory) +{ + public static SkillInstallResult InstalledAt(string message, string targetDirectory) + => new(Success: true, Installed: true, Skipped: false, message, targetDirectory); + + public static SkillInstallResult SkippedWith(string message) + => new(Success: true, Installed: false, Skipped: true, message, TargetDirectory: null); + + public static SkillInstallResult Failure(string message) + => new(Success: false, Installed: false, Skipped: false, message, TargetDirectory: null); +} diff --git a/src/CodexComputerRunMCPServer/Program.cs b/src/CodexComputerRunMCPServer/Program.cs index d458297..842664d 100644 --- a/src/CodexComputerRunMCPServer/Program.cs +++ b/src/CodexComputerRunMCPServer/Program.cs @@ -33,7 +33,18 @@ public static async Task Main(string[] args) return 1; } + if (CodexSkillInstaller.IsInstallRequested(args)) + { + var result = CodexSkillInstaller.InstallBundledSkill( + createCodexHome: true, + overwrite: CodexSkillInstaller.IsForceRequested(args)); + + Console.Error.WriteLine(result.Message); + return result.Success ? 0 : 1; + } + NativeMethods.TryEnablePerMonitorDpiAwareness(); + _ = CodexSkillInstaller.TryAutoInstall(Console.Error); using var host = CreateHost(args); await host.RunAsync().ConfigureAwait(false); diff --git a/version.json b/version.json index 9b5bb89..12b8436 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "0.1.0", + "version": "1.0.0", "publicReleaseRefSpec": [ "^refs/heads/main$", "^refs/tags/v\\d+\\.\\d+\\.\\d+$"