@canonical/harnesses
v0.23.0
Published
AI harness detection, MCP config read/write for Claude Code, Cursor, Windsurf, Cline, Roo Code.
Maintainers
Keywords
Readme
Harnesses
Detects AI harnesses (Claude Code, Cursor, Windsurf, Cline, Roo Code) and reads/writes their MCP server configuration. All operations are @canonical/task Tasks — dry-runnable, testable, composable.
import { detectHarnesses, writeMcpConfig } from "@canonical/harnesses";
import { runTask } from "@canonical/task";
const detected = await runTask(detectHarnesses("/my/project"));
// [{ harness: { id: "claude-code", ... }, confidence: "high", configPath: "/my/project/.mcp.json" }]Installation
bun add @canonical/harnessesRequires @canonical/task as a peer dependency.
Detection
detectHarnesses() checks the filesystem for known harness signals and returns matches sorted by confidence.
import { detectHarnesses } from "@canonical/harnesses";
import { runTask } from "@canonical/task";
const detected = await runTask(detectHarnesses("/my/project"));
for (const d of detected) {
console.log(`${d.harness.name}: ${d.confidence}, config exists: ${d.configExists}`);
}Each harness defines detection signals:
| Signal type | Confidence | Example |
|-------------|------------|---------|
| directory | high | ~/.claude exists |
| file | high | .mcp.json exists |
| extension | medium | VS Code extension installed |
| process | medium | Running process name |
| env | low | Environment variable hint |
Multiple harnesses can be detected simultaneously — a developer may use both Claude Code and Cursor.
MCP Configuration
Read, write, and remove MCP server entries from harness config files:
import { findHarnessById, readMcpConfig, writeMcpConfig, removeMcpConfig } from "@canonical/harnesses";
import { runTask } from "@canonical/task";
const claude = findHarnessById("claude-code")!;
// Read existing servers
const servers = await runTask(readMcpConfig(claude, "/my/project"));
// Add or update a server entry (merges with existing config)
await runTask(writeMcpConfig(claude, "/my/project", "pragma", {
command: "pragma",
args: ["mcp"],
}));
// Remove a server entry
await runTask(removeMcpConfig(claude, "/my/project", "pragma"));Config merge behaviour:
- If the config file doesn't exist, it is created (parent directory included)
- If the file exists, the new entry is merged into the existing
mcpServersobject - Existing entries with the same name are overwritten
- All other entries and fields in the config file are preserved
Harness Registry
The registry is pure data — adding a new harness is adding an entry, not writing new code.
| Harness | ID | Config path | Format | MCP key | Skills path |
|---------|-----|------------|--------|---------|-------------|
| Claude Code | claude-code | .mcp.json | JSON | mcpServers | .claude/skills/ |
| Cursor | cursor | .cursor/mcp.json | JSON | mcpServers | .cursor/skills/ |
| Windsurf | windsurf | ~/.codeium/windsurf/mcp_config.json | JSON | mcpServers | .windsurf/skills/ |
| Cline | cline | .vscode/mcp.json | JSON | mcpServers | .agents/skills/ |
| Roo Code | roo-code | .roo/mcp.json | JSON | mcpServers | .roo/skills/ |
| OpenCode | opencode | opencode.json | JSON | mcp | .agents/skills/ |
| Gemini CLI | gemini-cli | .gemini/settings.json | JSON | mcpServers | .agents/skills/ |
| Codex | codex | .codex/config.toml | TOML | mcp_servers | .agents/skills/ |
| VS Code | vscode | .vscode/mcp.json | JSON | servers | .agents/skills/ |
Each entry includes a version field (semver range) to support config format changes across harness versions. Multiple entries can exist for the same harness ID with different version ranges.
Codex uses TOML config — config read/write operations are not yet supported for TOML-based harnesses.
import { harnesses, findHarnessById } from "@canonical/harnesses";
// All known harnesses
console.log(harnesses.map(h => h.name));
// Lookup by ID
const cursor = findHarnessById("cursor");
console.log(cursor?.configPath("/project")); // "/project/.cursor/mcp.json"
console.log(cursor?.skillsPath("/project")); // "/project/.cursor/skills"Testing
Because every function returns a Task, tests never touch the filesystem:
import { dryRunWith, collectEffects, type Effect } from "@canonical/task";
import { detectHarnesses } from "@canonical/harnesses";
test("detects Claude Code from ~/.claude directory", () => {
const mocks = new Map([
["Exists", (effect: Effect) =>
(effect as Effect & { _tag: "Exists" }).path.includes(".claude")],
]);
const result = dryRunWith(detectHarnesses("/project"), mocks);
expect(result.value[0].harness.id).toBe("claude-code");
});License
LGPL-3.0
