diff --git a/README.md b/README.md index ce829c90..e7fb8432 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docs](https://img.shields.io/badge/docs-allagents.dev-blue)](https://allagents.dev) -Write AI agent skills once. Sync across 25+ clients. Manage across multiple repos. +Write AI agent skills once. Sync across every configured client. Manage across multiple repos. -AllAgents keeps your AI tooling (skills, agents, hooks, MCP servers) in one workspace and syncs it everywhere your team works — including Claude Code, GitHub Copilot, Cursor, Codex, OpenCode, and 20+ more. +AllAgents keeps your AI tooling (skills, agents, hooks, MCP servers) in one workspace and syncs it everywhere your team works — including Claude Code, GitHub Copilot, Cursor, Codex, OpenCode, and dozens more. ## Quick Start @@ -55,14 +55,14 @@ npx allagents plugin install allagents@allagentsdev/allagents --scope user ## Why AllAgents? -Tools like `npx skills` and `npx plugins` install skills to one project for one or two clients. AllAgents manages your entire AI tooling stack — skills, agents, hooks, commands, and MCP servers — across multiple repos and all your clients, from a single declarative config. +`npx skills` installs skills across a broad ecosystem of destinations, while `npx plugins` installs richer plugin artifacts into its supported clients. AllAgents manages skills, agents, hooks, commands, and MCP servers across curated AI coding clients from one declarative workspace. | | `npx skills` | `npx plugins` | `npx allagents` | |---|---|---|---| | **Config** | Imperative | Imperative | Declarative (`workspace.yaml`) | | **Scope** | Single project | Single project | Multi-repo workspace | | **Artifacts** | Skills | Skills, agents, hooks, commands, MCP | Skills, agents, hooks, commands, MCP | -| **Clients** | 43 agents | 2 (Claude, Cursor) | 25+ clients simultaneously | +| **Clients** | 79 destination IDs (`skills@1.7.0`) | 2 (Claude, Cursor) | Curated coding-client registry plus compatibility aliases | | **Team sharing** | Each dev runs install | Each dev runs install | Git-versioned — clone and go | | **Ongoing sync** | One-shot install | One-shot install | `allagents update` keeps everything current | | **Workspace awareness** | None | None | WORKSPACE-RULES injected so AI knows all repos and skills | @@ -120,11 +120,11 @@ See the [full CLI reference](https://allagents.dev/docs/reference/cli/) for all ## Supported Clients -Supports **Claude Code**, **GitHub Copilot**, **Cursor**, **Codex**, **OpenCode**, and 20+ more across two tiers: - -**Universal** (share `.agents/skills/`): Copilot, Codex, OpenCode, Gemini, Amp Code, VSCode, Replit, Kimi - -**Provider-specific**: Claude, Pi, OMP, Cursor, Factory, OpenClaw, Windsurf, Cline, Continue, Roo, Kilo, Trae, Augment, Zencoder, Junie, OpenHands, Kiro +AllAgents has one canonical, capability-aware client registry. It covers shared +`.agents/skills/` destinations, provider-specific skill roots, and project-only +clients, while separately recording instruction, command, agent, hook, GitHub +overlay, MCP, native, and profile support. Compatibility aliases such as +`claude-code` and `github-copilot` normalize to their canonical AllAgents IDs. See the [client support matrix](https://allagents.dev/docs/reference/clients/) for paths, hooks, commands, and MCP support per client. diff --git a/docs/public/schemas/v1/project-workspace.schema.json b/docs/public/schemas/v1/project-workspace.schema.json index 1dc9933f..1ced8349 100644 --- a/docs/public/schemas/v1/project-workspace.schema.json +++ b/docs/public/schemas/v1/project-workspace.schema.json @@ -183,7 +183,6 @@ "items": { "type": "string", "enum": [ - "universal", "claude", "copilot", "codex", @@ -208,7 +207,64 @@ "openhands", "kiro", "replit", - "kimi" + "kimi", + "universal", + "aider-desk", + "antigravity", + "antigravity-cli", + "autohand-code", + "bob", + "codearts-agent", + "codebuddy", + "codemaker", + "codestudio", + "command-code", + "cortex", + "crush", + "deepagents", + "devin", + "dexto", + "eve", + "firebender", + "forgecode", + "fx", + "goose", + "grok", + "hermes-agent", + "iflow-cli", + "jazz", + "kimchi", + "kode", + "lingma", + "minimax-code", + "mistral-vibe", + "moxby", + "mux", + "neovate", + "ona", + "posit-assistant", + "qoder", + "qoder-cn", + "qwen-code", + "reasonix", + "rovodev", + "sarvam-code", + "tabnine-cli", + "terramind", + "trae-cn", + "warp", + "zed", + "zcode", + "zenflow", + "pochi", + "adal", + "claude-code", + "github-copilot", + "gemini-cli", + "droid", + "amp", + "kiro-cli", + "kimi-code-cli" ] } }, @@ -271,7 +327,94 @@ }, { "type": "string", - "pattern": "^(?:universal|claude|copilot|codex|pi|omp|cursor|opencode|gemini|factory|ampcode|vscode|openclaw|windsurf|cline|continue|roo|kilo|trae|augment|zencoder|junie|openhands|kiro|replit|kimi):(?:file|native)$" + "enum": [ + "claude:file", + "copilot:file", + "codex:file", + "pi:file", + "omp:file", + "cursor:file", + "opencode:file", + "gemini:file", + "factory:file", + "ampcode:file", + "vscode:file", + "openclaw:file", + "windsurf:file", + "cline:file", + "continue:file", + "roo:file", + "kilo:file", + "trae:file", + "augment:file", + "zencoder:file", + "junie:file", + "openhands:file", + "kiro:file", + "replit:file", + "kimi:file", + "universal:file", + "aider-desk:file", + "antigravity:file", + "antigravity-cli:file", + "autohand-code:file", + "bob:file", + "codearts-agent:file", + "codebuddy:file", + "codemaker:file", + "codestudio:file", + "command-code:file", + "cortex:file", + "crush:file", + "deepagents:file", + "devin:file", + "dexto:file", + "eve:file", + "firebender:file", + "forgecode:file", + "fx:file", + "goose:file", + "grok:file", + "hermes-agent:file", + "iflow-cli:file", + "jazz:file", + "kimchi:file", + "kode:file", + "lingma:file", + "minimax-code:file", + "mistral-vibe:file", + "moxby:file", + "mux:file", + "neovate:file", + "ona:file", + "posit-assistant:file", + "qoder:file", + "qoder-cn:file", + "qwen-code:file", + "reasonix:file", + "rovodev:file", + "sarvam-code:file", + "tabnine-cli:file", + "terramind:file", + "trae-cn:file", + "warp:file", + "zed:file", + "zcode:file", + "zenflow:file", + "pochi:file", + "adal:file", + "claude-code:file", + "github-copilot:file", + "gemini-cli:file", + "droid:file", + "amp:file", + "kiro-cli:file", + "kimi-code-cli:file", + "claude:native", + "pi:native", + "omp:native", + "claude-code:native" + ] }, { "type": "object", @@ -280,7 +423,8 @@ "$ref": "#/definitions/AllAgentsProjectWorkspace/properties/plugins/items/anyOf/1/properties/clients/items" }, "install": { - "$ref": "#/definitions/AllAgentsProjectWorkspace/properties/plugins/items/anyOf/1/properties/install", + "type": "string", + "const": "file", "default": "file" } }, @@ -288,6 +432,29 @@ "name" ], "additionalProperties": true + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "claude", + "pi", + "omp", + "claude-code" + ] + }, + "install": { + "type": "string", + "const": "native" + } + }, + "required": [ + "name", + "install" + ], + "additionalProperties": true } ] } @@ -324,10 +491,7 @@ "type": "object", "properties": { "proxy": { - "type": "array", - "items": { - "type": "string" - } + "$ref": "#/definitions/AllAgentsProjectWorkspace/properties/mcpProxy/properties/clients" } }, "required": [ @@ -362,10 +526,7 @@ } }, "clients": { - "type": "array", - "items": { - "$ref": "#/definitions/AllAgentsProjectWorkspace/properties/plugins/items/anyOf/1/properties/clients/items" - } + "$ref": "#/definitions/AllAgentsProjectWorkspace/properties/plugins/items/anyOf/1/properties/clients" } }, "required": [ @@ -398,10 +559,7 @@ } }, "clients": { - "type": "array", - "items": { - "$ref": "#/definitions/AllAgentsProjectWorkspace/properties/plugins/items/anyOf/1/properties/clients/items" - } + "$ref": "#/definitions/AllAgentsProjectWorkspace/properties/plugins/items/anyOf/1/properties/clients" } }, "required": [ diff --git a/docs/public/schemas/v1/user-workspace.schema.json b/docs/public/schemas/v1/user-workspace.schema.json index 067c2b2a..6d570479 100644 --- a/docs/public/schemas/v1/user-workspace.schema.json +++ b/docs/public/schemas/v1/user-workspace.schema.json @@ -184,7 +184,6 @@ "items": { "type": "string", "enum": [ - "universal", "claude", "copilot", "codex", @@ -209,7 +208,63 @@ "openhands", "kiro", "replit", - "kimi" + "kimi", + "universal", + "aider-desk", + "antigravity", + "antigravity-cli", + "autohand-code", + "bob", + "codearts-agent", + "codebuddy", + "codemaker", + "codestudio", + "command-code", + "cortex", + "crush", + "deepagents", + "devin", + "dexto", + "firebender", + "forgecode", + "fx", + "goose", + "grok", + "hermes-agent", + "iflow-cli", + "jazz", + "kimchi", + "kode", + "lingma", + "minimax-code", + "mistral-vibe", + "moxby", + "mux", + "neovate", + "ona", + "posit-assistant", + "qoder", + "qoder-cn", + "qwen-code", + "reasonix", + "rovodev", + "sarvam-code", + "tabnine-cli", + "terramind", + "trae-cn", + "warp", + "zed", + "zcode", + "zenflow", + "pochi", + "adal", + "claude-code", + "github-copilot", + "gemini-cli", + "droid", + "amp", + "kiro-cli", + "kimi-code-cli" ] } }, @@ -273,7 +328,96 @@ }, { "type": "string", - "pattern": "^(?:universal|claude|copilot|codex|pi|omp|cursor|opencode|gemini|factory|ampcode|vscode|openclaw|windsurf|cline|continue|roo|kilo|trae|augment|zencoder|junie|openhands|kiro|replit|kimi):(?:file|native)$" + "enum": [ + "claude:file", + "copilot:file", + "codex:file", + "pi:file", + "omp:file", + "cursor:file", + "opencode:file", + "gemini:file", + "factory:file", + "ampcode:file", + "vscode:file", + "openclaw:file", + "windsurf:file", + "cline:file", + "continue:file", + "roo:file", + "kilo:file", + "trae:file", + "augment:file", + "zencoder:file", + "junie:file", + "openhands:file", + "kiro:file", + "replit:file", + "kimi:file", + "universal:file", + "aider-desk:file", + "antigravity:file", + "antigravity-cli:file", + "autohand-code:file", + "bob:file", + "codearts-agent:file", + "codebuddy:file", + "codemaker:file", + "codestudio:file", + "command-code:file", + "cortex:file", + "crush:file", + "deepagents:file", + "devin:file", + "dexto:file", + "firebender:file", + "forgecode:file", + "fx:file", + "goose:file", + "grok:file", + "hermes-agent:file", + "iflow-cli:file", + "jazz:file", + "kimchi:file", + "kode:file", + "lingma:file", + "minimax-code:file", + "mistral-vibe:file", + "moxby:file", + "mux:file", + "neovate:file", + "ona:file", + "posit-assistant:file", + "qoder:file", + "qoder-cn:file", + "qwen-code:file", + "reasonix:file", + "rovodev:file", + "sarvam-code:file", + "tabnine-cli:file", + "terramind:file", + "trae-cn:file", + "warp:file", + "zed:file", + "zcode:file", + "zenflow:file", + "pochi:file", + "adal:file", + "claude-code:file", + "github-copilot:file", + "gemini-cli:file", + "droid:file", + "amp:file", + "kiro-cli:file", + "kimi-code-cli:file", + "claude:native", + "copilot:native", + "codex:native", + "pi:native", + "omp:native", + "claude-code:native", + "github-copilot:native" + ] }, { "type": "object", @@ -282,7 +426,8 @@ "$ref": "#/definitions/AllAgentsUserWorkspace/properties/plugins/items/anyOf/1/properties/clients/items" }, "install": { - "$ref": "#/definitions/AllAgentsUserWorkspace/properties/plugins/items/anyOf/1/properties/install", + "type": "string", + "const": "file", "default": "file" } }, @@ -290,6 +435,32 @@ "name" ], "additionalProperties": true + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "claude", + "copilot", + "codex", + "pi", + "omp", + "claude-code", + "github-copilot" + ] + }, + "install": { + "type": "string", + "const": "native" + } + }, + "required": [ + "name", + "install" + ], + "additionalProperties": true } ] }, @@ -327,10 +498,7 @@ "type": "object", "properties": { "proxy": { - "type": "array", - "items": { - "type": "string" - } + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/mcpProxy/properties/clients" } }, "required": [ @@ -365,10 +533,7 @@ } }, "clients": { - "type": "array", - "items": { - "$ref": "#/definitions/AllAgentsUserWorkspace/properties/plugins/items/anyOf/1/properties/clients/items" - } + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/plugins/items/anyOf/1/properties/clients" } }, "required": [ @@ -401,10 +566,7 @@ } }, "clients": { - "type": "array", - "items": { - "$ref": "#/definitions/AllAgentsUserWorkspace/properties/plugins/items/anyOf/1/properties/clients/items" - } + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/plugins/items/anyOf/1/properties/clients" } }, "required": [ @@ -441,7 +603,10 @@ "properties": { "name": { "type": "string", - "const": "claude" + "enum": [ + "claude", + "claude-code" + ] }, "install": { "$ref": "#/definitions/AllAgentsUserWorkspace/properties/plugins/items/anyOf/1/properties/install", @@ -598,7 +763,10 @@ "properties": { "name": { "type": "string", - "const": "copilot" + "enum": [ + "copilot", + "github-copilot" + ] }, "install": { "$ref": "#/definitions/AllAgentsUserWorkspace/properties/profiles/additionalProperties/properties/clients/items/anyOf/0/properties/install" @@ -860,29 +1028,7 @@ "type": "object", "properties": { "name": { - "type": "string", - "enum": [ - "universal", - "cursor", - "gemini", - "factory", - "ampcode", - "vscode", - "openclaw", - "windsurf", - "cline", - "continue", - "roo", - "kilo", - "trae", - "augment", - "zencoder", - "junie", - "openhands", - "kiro", - "replit", - "kimi" - ] + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/plugins/items/anyOf/1/properties/clients/items" }, "install": { "$ref": "#/definitions/AllAgentsUserWorkspace/properties/profiles/additionalProperties/properties/clients/items/anyOf/0/properties/install" diff --git a/docs/src/content/docs/docs/reference/clients.mdx b/docs/src/content/docs/docs/reference/clients.mdx index ee013031..51bddf37 100644 --- a/docs/src/content/docs/docs/reference/clients.mdx +++ b/docs/src/content/docs/docs/reference/clients.mdx @@ -3,27 +3,69 @@ title: Supported Clients description: AI coding assistant clients supported by AllAgents. --- -AllAgents supports 25+ AI coding clients, including **Claude Code**, -**GitHub Copilot**, **Cursor**, **Codex**, **OpenCode**, and 20+ more. The -support matrix is organized into universal clients (sharing `.agents/skills/`) -and provider-specific clients. - -## Universal Clients - -These clients primarily share the canonical `.agents/skills/` directory. The -Hooks column records artifacts AllAgents materializes; it is not a complete -inventory of each runtime's native extension APIs. - -| Client | Skills | Agent File | Hooks | Commands | GitHub Overrides | -|--------|--------|------------|-------|----------|------------------| -| Copilot | `.github/skills/` | `AGENTS.md` | `.github/hooks/` | No | `.github/` | -| Codex | `.codex/skills/` | `AGENTS.md` | `.codex/hooks.json` (generated) | No | No | -| OpenCode | `.agents/skills/` | `AGENTS.md` | No | `.opencode/commands/` | No | -| Gemini | `.agents/skills/` | `GEMINI.md` | No | No | No | -| Amp Code | `.agents/skills/` | `AGENTS.md` | No | No | No | -| VSCode | `.agents/skills/` | `AGENTS.md` | No | No | `.github/` | -| Replit | `.agents/skills/` | `AGENTS.md` | No | No | No | -| Kimi | `.agents/skills/` | `AGENTS.md` | No | No | No | +import { + AGENT_HOSTS, + CLIENT_ALIASES, +} from '../../../../../../src/models/client-mapping.ts'; + +export const namedClientCount = AGENT_HOSTS.filter( + (host) => host.id !== 'universal', +).length; + +AllAgents supports **{namedClientCount} canonical client destinations** plus the +shared `universal` destination. It also accepts the following compatibility +aliases without creating duplicate destinations: + + + +AllAgents curates the pinned `skills@1.7.0` registry to coding clients that +directly consume skills; non-coding platforms are intentionally excluded. This +compatibility does not rewrite established AllAgents paths. Aliases use their +canonical AllAgents product's existing destinations, while newly added +canonical clients use the pinned Skills paths. + +The registry separates skill destinations from each richer file or MCP +capability it owns. A path or capability shown as “No” is intentionally +unsupported rather than inferred from another client. + + + + + + + + + + + + + + + + + + {AGENT_HOSTS.map((host) => ( + + + + + + + + + + + + + ))} + +
Client IDProductProject skillsUser skillsInstructionsCommandsAgentsHooksGitHub overlaysMCP
{host.id}{host.name}{host.project.skillsPath}{host.user ? {host.user.skillsPath} : 'No'}{host.project.agentFile ? {host.project.agentFile} : 'No'}{host.project.commandsPath ? {host.project.commandsPath} : 'No'}{host.project.agentsPath ? {host.project.agentsPath} : 'No'}{host.project.hooksPath ? {host.project.hooksPath} : 'No'}{host.project.githubPath ? {host.project.githubPath} : 'No'}{host.mcp ? Object.keys(host.mcp).join(' + ') : 'No'}
+ +## Rich Client Integrations ### OpenCode @@ -140,31 +182,6 @@ profile; removing that profile intentionally removes its isolated runtime state. Ambient `~/.codex`, project files, and operating-system keyrings remain outside the cleanup boundary. -## Provider-Specific Clients - -These clients use their own skills directory. As above, Hooks lists -AllAgents-managed destinations rather than every native extension mechanism: - -| Client | Skills | Agent File | Hooks | Commands | -|--------|--------|------------|-------|----------| -| Claude | `.claude/skills/` | `CLAUDE.md` | `.claude/hooks/` | `.claude/commands/` | -| Pi | `.pi/skills/` | `AGENTS.md` | No—use Pi extensions | No | -| OMP | `.omp/skills/` | `AGENTS.md` | `.omp/hooks/` | No | -| Cursor | `.cursor/skills/` | `AGENTS.md` | No | No | -| Factory | `.factory/skills/` | `AGENTS.md` | `.factory/hooks/` | No | -| OpenClaw | `skills/` | `AGENTS.md` | No | No | -| Windsurf | `.windsurf/skills/` | `AGENTS.md` | No | No | -| Cline | `.cline/skills/` | `AGENTS.md` | No | No | -| Continue | `.continue/skills/` | `AGENTS.md` | No | No | -| Roo | `.roo/skills/` | `AGENTS.md` | No | No | -| Kilo | `.kilocode/skills/` | `AGENTS.md` | No | No | -| Trae | `.trae/skills/` | `AGENTS.md` | No | No | -| Augment | `.augment/skills/` | `AGENTS.md` | No | No | -| Zencoder | `.zencoder/skills/` | `AGENTS.md` | No | No | -| Junie | `.junie/skills/` | `AGENTS.md` | No | No | -| OpenHands | `.openhands/skills/` | `AGENTS.md` | No | No | -| Kiro | `.kiro/skills/` | `AGENTS.md` | No | No | - :::note Skills are the cross-client way to share reusable prompts. GitHub overrides (`.github/prompts/`, `.github/agents/`, `.github/hooks/`, `copilot-instructions.md`) are copied to the workspace's `.github/` folder for Copilot/VSCode; package-only `.github/plugin/` metadata is omitted. Root `agents/` and `hooks/` also map to `.github/agents/` and `.github/hooks/` for Copilot. At project scope, plugin hook declarations from `hooks.json` or `hooks/hooks.json` are combined in `.github/hooks/allagents.json`. This translation does not require marketplace registration or `plugin.json`; a missing or disabled declaration adds no managed entries. AllAgents warns and omits a declaration that cannot be read or parsed, lacks the version-1 `hooks` object envelope, or—unless disabled—has a non-array event value. Skipping a declaration does not itself suppress other eligible plugin artifacts or valid declarations from other plugins. At user scope, root `hooks/` maps to `~/.copilot/hooks/`, while repository `.github/hooks/` remains project-scoped. ::: diff --git a/src/cli/commands/mcp.ts b/src/cli/commands/mcp.ts index ddaf1159..90a69a4d 100644 --- a/src/cli/commands/mcp.ts +++ b/src/cli/commands/mcp.ts @@ -45,6 +45,7 @@ import { runManagedMcpOperation as runManagedMcpRuntimeOperation, } from '../../core/mcp-runtime.js'; import { + CLIENT_INPUT_TYPES, type ClientType, ClientTypeSchema, type McpServerConfig, @@ -149,7 +150,7 @@ function parseClientFilter(inputs: string[]): ClientType[] | undefined { const parsed = ClientTypeSchema.safeParse(item); if (!parsed.success) { throw new Error( - `Invalid client '${item}'. Valid clients: ${ClientTypeSchema.options.join(', ')}`, + `Invalid client '${item}'. Valid clients: ${CLIENT_INPUT_TYPES.join(', ')}`, ); } if (!seen.has(parsed.data)) { diff --git a/src/cli/commands/workspace.ts b/src/cli/commands/workspace.ts index afce89ba..ff6fb694 100644 --- a/src/cli/commands/workspace.ts +++ b/src/cli/commands/workspace.ts @@ -36,9 +36,11 @@ import { import { runWorkspaceSetup } from '../../core/workspace-setup.js'; import { initWorkspace } from '../../core/workspace.js'; import { + CLIENT_INPUT_TYPES, type ClientEntry, ClientEntrySchema, ClientTypeSchema, + deduplicateClientEntries, InstallModeSchema, } from '../../models/workspace-config.js'; import { formatPluginSource } from '../../utils/plugin-path.js'; @@ -90,14 +92,14 @@ export function parseClientEntries(input: string): ClientEntry[] { const colonIdx = part.indexOf(':'); if (colonIdx === -1) { throw new Error( - `Invalid client(s): ${part}\n Valid clients: ${ClientTypeSchema.options.join(', ')}`, + `Invalid client(s): ${part}\n Valid clients: ${CLIENT_INPUT_TYPES.join(', ')}`, ); } const name = part.slice(0, colonIdx); const mode = part.slice(colonIdx + 1); - if (!(ClientTypeSchema.options as readonly string[]).includes(name)) { + if (!ClientTypeSchema.safeParse(name).success) { throw new Error( - `Invalid client(s): ${name}\n Valid clients: ${ClientTypeSchema.options.join(', ')}`, + `Invalid client(s): ${name}\n Valid clients: ${CLIENT_INPUT_TYPES.join(', ')}`, ); } throw new Error( @@ -107,7 +109,7 @@ export function parseClientEntries(input: string): ClientEntry[] { entries.push(result.data); } - return entries; + return deduplicateClientEntries(entries); } const initCmd = command({ diff --git a/src/cli/install-target.ts b/src/cli/install-target.ts index c30eb7d7..985c67a3 100644 --- a/src/cli/install-target.ts +++ b/src/cli/install-target.ts @@ -10,6 +10,8 @@ import { isUserConfigPath, } from '../core/user-workspace.js'; import { + CLIENT_INPUT_TYPES, + CLIENT_TYPES, ClientTypeSchema, getClientTypes, getPluginSource, @@ -17,6 +19,7 @@ import { type ClientType, type PluginEntry, } from '../models/workspace-config.js'; +import { supportsClientScope } from '../models/client-mapping.js'; export type InstallScope = 'project' | 'user'; export type InstallTargetDisposition = 'initialize' | 'inherit' | 'override'; @@ -163,13 +166,13 @@ export function canonicalizeInstallClients( const parsed = ClientTypeSchema.safeParse(client); if (!parsed.success) { throw new InstallTargetValidationError( - `Invalid client '${client}'. Expected one of: ${ClientTypeSchema.options.join(', ')}.`, + `Invalid client '${client}'. Expected one of: ${CLIENT_INPUT_TYPES.join(', ')}.`, ); } selected.add(parsed.data); } - return ClientTypeSchema.options.filter((client) => selected.has(client)); + return CLIENT_TYPES.filter((client) => selected.has(client)); } function canonicalizeEntries( @@ -328,6 +331,17 @@ export async function resolveInstallTarget( } } + if (scope === 'user') { + const unsupported = clients.filter( + (client) => !supportsClientScope(client, 'user'), + ); + if (unsupported.length > 0) { + throw new InstallTargetValidationError( + `User scope is unavailable for: ${unsupported.join(', ')}`, + ); + } + } + const configuredClients = state ? state.clients.length === 0 ? [] diff --git a/src/cli/tui/actions/clients.ts b/src/cli/tui/actions/clients.ts index 6475f266..6c54344e 100644 --- a/src/cli/tui/actions/clients.ts +++ b/src/cli/tui/actions/clients.ts @@ -1,5 +1,5 @@ import * as p from '@clack/prompts'; -import { ClientTypeSchema, getClientTypes, type ClientType } from '../../../models/workspace-config.js'; +import { getClientTypes, type ClientType } from '../../../models/workspace-config.js'; import { setClients } from '../../../core/workspace-modify.js'; import { setUserClients, getUserWorkspaceConfig } from '../../../core/user-workspace.js'; import { syncWorkspace, syncUserWorkspace } from '../../../core/sync.js'; @@ -48,8 +48,8 @@ export async function runManageClients(context: TuiContext, cache?: TuiCache): P currentClients = userConfig?.clients ? getClientTypes(userConfig.clients) : []; } - const allClients = ClientTypeSchema.options; - const options = buildClientOptions(); + const options = buildClientOptions(scope); + const allClients = options.map((option) => option.value); const selected = await autocompleteMultiselect({ message: `Select AI clients [${scope}]`, diff --git a/src/cli/tui/actions/mcp.ts b/src/cli/tui/actions/mcp.ts index d97e9a47..e520369f 100644 --- a/src/cli/tui/actions/mcp.ts +++ b/src/cli/tui/actions/mcp.ts @@ -13,12 +13,12 @@ import { resolveMcpDestination, } from '../../../core/mcp-servers.js'; import { getUserWorkspaceConfig } from '../../../core/user-workspace.js'; -import { - type ClientType, - ClientTypeSchema, - type McpServerConfig, - type UserWorkspaceConfig, +import type { + ClientType, + McpServerConfig, + UserWorkspaceConfig, } from '../../../models/workspace-config.js'; +import { mcpClientIdsForScope } from '../../../models/client-mapping.js'; import { terminalSafe } from '../../terminal-output.js'; import type { TuiCache } from '../cache.js'; import type { TuiContext } from '../context.js'; @@ -155,7 +155,7 @@ async function buildDestinationChoices( cwd: context.workspacePath, scope: 'project', }), - clients: ClientTypeSchema.options, + clients: mcpClientIdsForScope('project'), }); } @@ -164,7 +164,7 @@ async function buildDestinationChoices( label: 'User', hint: 'global user configuration', destination: dependencies.resolveDestination({ scope: 'user' }), - clients: ClientTypeSchema.options, + clients: mcpClientIdsForScope('user'), }); const userConfig = await dependencies.getUserConfig(); diff --git a/src/cli/tui/prompt-clients.ts b/src/cli/tui/prompt-clients.ts index 53ac5740..64c925e4 100644 --- a/src/cli/tui/prompt-clients.ts +++ b/src/cli/tui/prompt-clients.ts @@ -1,11 +1,13 @@ import * as p from '@clack/prompts'; import { - ClientTypeSchema, getClientInstallMode, type ClientEntry, type ClientType, } from '../../models/workspace-config.js'; -import { getMapping } from '../../models/client-mapping.js'; +import { + clientIdsForScope, + getMapping, +} from '../../models/client-mapping.js'; import type { InstallScope } from '../install-target.js'; const { autocompleteMultiselect } = p; @@ -22,11 +24,11 @@ export function buildClientOptions( label: string; hint: string; }[] { - return ClientTypeSchema.options.map((client) => ({ + return clientIdsForScope(scope).map((client) => ({ value: client, label: client, hint: - getClientInstallMode([...clientEntries], client) === 'native' + getClientInstallMode(clientEntries, client) === 'native' ? 'Native install' : getMapping(client, scope).skillsPath, })); diff --git a/src/core/client-context.ts b/src/core/client-context.ts index 05dd5212..1fef4ccc 100644 --- a/src/core/client-context.ts +++ b/src/core/client-context.ts @@ -5,7 +5,9 @@ import { getHomeDir } from '../constants.js'; import { CLIENT_MAPPINGS, USER_CLIENT_MAPPINGS, + getMapping, type ClientMapping, + type ClientMappings, } from '../models/client-mapping.js'; import type { ClientType } from '../models/workspace-config.js'; @@ -109,7 +111,6 @@ function relocateMapping( ); const relocated: ClientMapping = { skillsPath: relocate(mapping.skillsPath, true), - agentFile: relocate(mapping.agentFile, false), }; for (const key of MAPPING_PATH_KEYS) { @@ -119,6 +120,10 @@ function relocateMapping( relocated[key] = relocate(value, true); } + if (mapping.agentFile) { + relocated.agentFile = relocate(mapping.agentFile, false); + } + if (mapping.agentFileFallback) { relocated.agentFileFallback = relocate(mapping.agentFileFallback, false); } @@ -255,7 +260,7 @@ function resolvePiContext( scope, writeRoot: agentRoot, mapping: relocateMapping( - USER_CLIENT_MAPPINGS.pi, + getMapping('pi', 'user'), operationRoot, defaultAgentRoot, agentRoot, @@ -355,7 +360,7 @@ function resolveOmpContext( scope, writeRoot: roots.agent, mapping: relocateMapping( - USER_CLIENT_MAPPINGS.omp, + getMapping('omp', 'user'), operationRoot, defaultAgentRoot, roots.agent, @@ -432,6 +437,9 @@ export function resolveClientContext( const mapping = scope === 'user' ? USER_CLIENT_MAPPINGS[client] : CLIENT_MAPPINGS[client]; + if (!mapping) { + throw new Error(`Client '${client}' does not support ${scope} scope`); + } return { client, scope, @@ -455,8 +463,8 @@ export function resolveClientContexts( export function clientMappingsFromContexts( contexts: ReadonlyMap, - fallback: Record, -): Record { + fallback: ClientMappings, +): ClientMappings { if (contexts.size === 0) return fallback; return { ...fallback, diff --git a/src/core/mcp-sync.ts b/src/core/mcp-sync.ts index d2ed85fc..b9cfdc25 100644 --- a/src/core/mcp-sync.ts +++ b/src/core/mcp-sync.ts @@ -1,6 +1,7 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { CONFIG_DIR, getHomeDir, WORKSPACE_CONFIG_FILE } from '../constants.js'; +import { mcpClientIdsForScope } from '../models/client-mapping.js'; import type { SyncState } from '../models/sync-state.js'; import type { ClientType, @@ -28,13 +29,9 @@ import { migrateWorkspaceSkillsV1toV2 } from './workspace-modify.js'; /** * Clients that support project-scoped MCP server sync. */ -const PROJECT_MCP_CLIENTS: ReadonlySet = new Set([ - 'claude', - 'codex', - 'vscode', - 'copilot', - 'universal', -]); +const PROJECT_MCP_CLIENTS: ReadonlySet = new Set( + mcpClientIdsForScope('project'), +); /** * Result of running the MCP sync pipeline across all scopes. diff --git a/src/core/native/registry.ts b/src/core/native/registry.ts index d7042c26..4ab57e65 100644 --- a/src/core/native/registry.ts +++ b/src/core/native/registry.ts @@ -2,6 +2,7 @@ import type { ClientType } from '../../models/workspace-config.js'; import type { NativeClient } from './types.js'; import { ClaudeNativeClient } from './claude.js'; import { CopilotNativeClient } from './copilot.js'; +import { CodexNativeClient } from './codex.js'; import { PiNativeClient } from './pi.js'; import { OmpNativeClient } from './omp.js'; @@ -11,6 +12,8 @@ export function getNativeClient(client: ClientType): NativeClient | null { return new ClaudeNativeClient(); case 'copilot': return new CopilotNativeClient(); + case 'codex': + return new CodexNativeClient(); case 'pi': return new PiNativeClient(); case 'omp': diff --git a/src/core/status.ts b/src/core/status.ts index 34f183f0..a37baffb 100644 --- a/src/core/status.ts +++ b/src/core/status.ts @@ -193,11 +193,12 @@ async function getNativeStatusesForScope( scope, ); const nativePlans = plans.filter((plan) => plan.nativeClients.length > 0); + // Only inspect native clients that have tracked state. Declarations describe + // desired sync work; status must not require an installed CLI before sync. const clients = [ - ...new Set([ - ...nativePlans.flatMap((plan) => plan.nativeClients), - ...stateResources.map((resource) => resource.client), - ]), + ...new Set( + stateResources.map((resource) => resource.client), + ), ]; const contexts = resolveClientContexts(clients, scope, { cwd: workspacePath, diff --git a/src/core/sync.ts b/src/core/sync.ts index 345888f0..ac75796e 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -14,13 +14,14 @@ import { getHomeDir, WORKSPACE_CONFIG_FILE, } from '../constants.js'; -import type { ClientMapping } from '../models/client-mapping.js'; import { CANONICAL_SKILLS_PATH, CLIENT_MAPPINGS, isUniversalClient, resolveClientMappings, USER_CLIENT_MAPPINGS, + type ClientMapping, + type ClientMappings, } from '../models/client-mapping.js'; import type { MarketplaceFileArtifacts } from '../models/marketplace-manifest.js'; import type { @@ -166,27 +167,31 @@ interface DeduplicatedClients { */ export function deduplicateClientsByPath( clients: ClientType[], - clientMappings: Record = CLIENT_MAPPINGS, + clientMappings: ClientMappings = CLIENT_MAPPINGS, ): DeduplicatedClients { // Group clients by their skillsPath const pathToClients = new Map(); for (const client of clients) { const mapping = clientMappings[client]; - // Use skillsPath as the grouping key, or a unique key for clients without skillsPath - const pathKey = mapping?.skillsPath || `__no_skills_${client}__`; + // Keep a missing scope mapping isolated instead of deduplicating it. + const pathKey = mapping?.skillsPath ?? `__no_mapping_${client}__`; const existing = pathToClients.get(pathKey) || []; existing.push(client); pathToClients.set(pathKey, existing); } - // Build result: use first client in each group as representative + // Universal must own its canonical path even when declared after an aliasing + // client; otherwise the symlink phase can mistake the destination for its + // own source and skip materialization. const representativeClients: ClientType[] = []; const clientGroups = new Map(); for (const clientsInGroup of pathToClients.values()) { - const representative = clientsInGroup[0]; + const representative = clientsInGroup.includes('universal') + ? 'universal' + : clientsInGroup[0]; if (representative) { representativeClients.push(representative); clientGroups.set(representative, clientsInGroup); @@ -757,11 +762,13 @@ export async function purgeWorkspace( purgedPaths.push(mapping.agentsPath); } - // Purge agent file - const agentPath = join(workspacePath, mapping.agentFile); - if (existsSync(agentPath)) { - await rm(agentPath); - purgedPaths.push(mapping.agentFile); + // Purge the instruction file when this client declares one. + if (mapping.agentFile) { + const agentPath = join(workspacePath, mapping.agentFile); + if (existsSync(agentPath)) { + await rm(agentPath); + purgedPaths.push(mapping.agentFile); + } } result.push({ client, paths: purgedPaths }); @@ -818,8 +825,11 @@ export function getPurgePaths( paths.push(mapping.agentsPath); } - // Check agent file - if (existsSync(join(workspacePath, mapping.agentFile))) { + // Check instruction file + if ( + mapping.agentFile && + existsSync(join(workspacePath, mapping.agentFile)) + ) { paths.push(mapping.agentFile); } @@ -885,7 +895,7 @@ export async function selectivePurgeWorkspace( workspacePath: string, state: SyncState | null, clients: ClientType[], - clientMappings: Record = CLIENT_MAPPINGS, + clientMappings: ClientMappings = CLIENT_MAPPINGS, clientContexts?: ReadonlyMap, ): Promise { // First sync - no state, skip purge entirely (safe overlay) @@ -1229,7 +1239,7 @@ export function collectSyncedPaths( copyResults: CopyResult[], workspacePath: string, clients: ClientType[], - clientMappings?: Record, + clientMappings?: ClientMappings, agentDedupeRecords?: AgentDedupeRecord[], clientContexts?: ReadonlyMap, ): Partial> { @@ -1302,13 +1312,13 @@ export function collectSyncedPaths( // tracked by its own CopyResult; never synthesize ownership here. if (agentDedupeRecords && agentDedupeRecords.length > 0) { for (const client of clients) { - const mapping = mappings[client]; - if (!mapping.agentsPath) continue; + const agentsPath = mappings[client]?.agentsPath; + if (!agentsPath) continue; const tracked = result[client]; if (!tracked) continue; for (const record of agentDedupeRecords) { - if (!record.removedPath.startsWith(mapping.agentsPath)) continue; + if (!record.removedPath.startsWith(agentsPath)) continue; const removedIndex = tracked.indexOf(record.removedPath); if (removedIndex !== -1) tracked.splice(removedIndex, 1); } @@ -1381,7 +1391,7 @@ export function computeDeletedArtifacts( previousState: SyncState | null, newStatePaths: Partial>, clients: ClientType[], - clientMappings: Record, + clientMappings: ClientMappings, availableSkillNames?: Set, agentDedupeRecords: AgentDedupeRecord[] = [], ): DeletedArtifact[] { @@ -1736,7 +1746,7 @@ async function copyValidatedPlugin( clients: ClientType[], dryRun: boolean, skillNameMap?: Map, - clientMappings?: Record, + clientMappings?: ClientMappings, syncMode: SyncMode = 'symlink', agentOutputs: readonly AgentOutput[] = [], agentConflicts: readonly AgentOutputConflict[] = [], @@ -2747,7 +2757,7 @@ async function syncVscodeWorkspaceFile( async function planValidatedPluginAgentOutputs( validPlugins: ValidatedPlugin[], basePath: string, - mappings: Record, + mappings: ClientMappings, ): Promise { return planAgentOutputs( validPlugins.map((plugin, validIndex) => ({ @@ -3205,18 +3215,22 @@ export async function syncWorkspace( // In non-destructive mode, only show files from state (or nothing on first sync) const purgedPaths = previousState ? syncClients - .map((client) => ({ - client, - paths: getPreviouslySyncedFiles(previousState, client).filter( - (path) => - trackedPathIsAllowed( - workspacePath, - path, - resolvedMappings[client], - clientContexts.get(client), - ), - ), - })) + .map((client) => { + const mapping = resolvedMappings[client]; + if (!mapping) return { client, paths: [] }; + return { + client, + paths: getPreviouslySyncedFiles(previousState, client).filter( + (path) => + trackedPathIsAllowed( + workspacePath, + path, + mapping, + clientContexts.get(client), + ), + ), + }; + }) .filter((entry) => entry.paths.length > 0) : []; diff --git a/src/core/transform.ts b/src/core/transform.ts index 571826e1..398cddf5 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -20,8 +20,9 @@ import { CLIENT_MAPPINGS, isUniversalClient, resolveClientMappings, + type ClientMapping, + type ClientMappings, } from '../models/client-mapping.js'; -import type { ClientMapping } from '../models/client-mapping.js'; import type { MarketplaceFileArtifacts } from '../models/marketplace-manifest.js'; import type { ClientType, @@ -105,7 +106,7 @@ export interface CopyOptions { /** Simulate copy without making changes */ dryRun?: boolean; /** Override client path mappings (defaults to CLIENT_MAPPINGS) */ - clientMappings?: Record; + clientMappings?: ClientMappings; /** Selected filesystem root that bounds this client's external writes. */ writeRoot?: string; /** @@ -241,16 +242,19 @@ export interface WorkspaceCopyOptions extends CopyOptions { } /** - * Get the client mapping, using override if provided, otherwise falling back to CLIENT_MAPPINGS + * Get the client mapping, using the supplied scope map when present. */ function getMapping( client: ClientType, - options?: { clientMappings?: Record }, + options?: { clientMappings?: ClientMappings }, ): ClientMapping { - return ( - (options?.clientMappings as Record)?.[client] ?? - CLIENT_MAPPINGS[client] - ); + const mapping = options?.clientMappings + ? options.clientMappings[client] + : CLIENT_MAPPINGS[client]; + if (!mapping) { + throw new Error(`Client '${client}' has no mapping for this scope`); + } + return mapping; } /** @@ -998,7 +1002,7 @@ async function readAgentSourceEntries( export async function planAgentOutputs( plugins: readonly AgentOutputPlugin[], workspacePath: string, - clientMappings: Record = CLIENT_MAPPINGS, + clientMappings: ClientMappings = CLIENT_MAPPINGS, ): Promise { const outputs: AgentOutput[] = []; const conflicts: AgentOutputConflict[] = []; @@ -1476,7 +1480,7 @@ async function hasIncludedFiles( interface PlannedAgentCopyOptions { dryRun: boolean; - clientMappings: Record; + clientMappings: ClientMappings; skillNameMap?: Map; writeRoot?: string; } diff --git a/src/core/user-mcp-sync.ts b/src/core/user-mcp-sync.ts index e5b6d63a..7b60ea5e 100644 --- a/src/core/user-mcp-sync.ts +++ b/src/core/user-mcp-sync.ts @@ -1,4 +1,5 @@ import type { SyncState } from '../models/sync-state.js'; +import { mcpClientIdsForScope } from '../models/client-mapping.js'; import type { ClientType, WorkspaceConfig, @@ -18,13 +19,9 @@ import { syncVscodeMcpConfig, } from './vscode-mcp.js'; -const USER_MCP_CLIENTS: Partial> = { - claude: true, - codex: true, - vscode: true, - copilot: true, - universal: true, -}; +const USER_MCP_CLIENTS: ReadonlySet = new Set( + mcpClientIdsForScope('user'), +); export interface SyncUserMcpAdaptersOptions { validPlugins: ValidatedPlugin[]; @@ -124,7 +121,7 @@ export async function syncUserMcpAdapters({ const allServers = collectMcpServers(validPlugins, config.mcpServers).servers; if (allServers.size > 0) { for (const client of syncClients) { - if (!USER_MCP_CLIENTS[client]) { + if (!USER_MCP_CLIENTS.has(client)) { warnings.push( `MCP servers not synced for ${client} (not supported at user scope)`, ); diff --git a/src/core/user-workspace.ts b/src/core/user-workspace.ts index f0c54c7b..e5076300 100644 --- a/src/core/user-workspace.ts +++ b/src/core/user-workspace.ts @@ -108,10 +108,7 @@ export async function getUserWorkspaceConfig(): Promise (typeof c === 'string' ? c : (c as { name: string }).name)); -} /** * Ensure WORKSPACE-RULES are injected into agent files for all configured clients. @@ -182,7 +181,8 @@ export async function updateAgentFiles( if (config.repositories.length === 0) return; - const clientNames = resolveClientNames(config.clients); + const clients = ClientEntryListSchema.parse(config.clients ?? []); + const clientNames = getClientTypes(clients); // Discover skills from all repositories const allSkills = await discoverWorkspaceSkills(workspacePath, config.repositories, clientNames); @@ -192,15 +192,16 @@ export async function updateAgentFiles( const { writtenFiles, refs: skillsIndexRefs } = writeSkillsIndex(workspacePath, grouped); cleanupSkillsIndex(workspacePath, writtenFiles); - // Collect unique agent files from configured clients + // Only clients with evidenced instruction destinations receive rules. const agentFiles = new Set(); - for (const client of config.clients ?? []) { - const clientName = typeof client === 'string' ? client : (client as { name: string }).name; - const mapping = CLIENT_MAPPINGS[clientName as ClientType]; + for (const client of clients) { + const clientName = normalizeClientEntry(client).name; + const mapping = CLIENT_MAPPINGS[clientName]; if (mapping?.agentFile) agentFiles.add(mapping.agentFile); + if (mapping?.agentFileFallback) { + agentFiles.add(mapping.agentFileFallback); + } } - // Always include AGENTS.md as it's the universal fallback - agentFiles.add('AGENTS.md'); for (const agentFile of agentFiles) { await ensureWorkspaceRules(join(workspacePath, agentFile), config.repositories, skillsIndexRefs); diff --git a/src/models/client-entry-schema.ts b/src/models/client-entry-schema.ts new file mode 100644 index 00000000..6ee11755 --- /dev/null +++ b/src/models/client-entry-schema.ts @@ -0,0 +1,240 @@ +import { z } from 'zod'; +import { + CLIENT_ALIASES, + CLIENT_TYPES, + PROJECT_NATIVE_CLIENT_TYPES, + USER_CLIENT_TYPES, + USER_NATIVE_CLIENT_TYPES, + canonicalizeClientId, + type CanonicalClientId, +} from './client-mapping.js'; + +export { CLIENT_TYPES, USER_CLIENT_TYPES } from './client-mapping.js'; + +export type ClientType = CanonicalClientId; + +export const CanonicalClientTypeSchema = z.enum(CLIENT_TYPES); + +const CLIENT_ALIAS_TYPES = Object.keys(CLIENT_ALIASES) as [ + keyof typeof CLIENT_ALIASES, + ...(keyof typeof CLIENT_ALIASES)[], +]; + +export const CLIENT_INPUT_TYPES = [ + ...CLIENT_TYPES, + ...CLIENT_ALIAS_TYPES, +] as const; + +function requireCanonicalClientId(input: string): ClientType { + const canonical = canonicalizeClientId(input); + if (!canonical) throw new Error(`Unknown client '${input}'`); + return canonical; +} + +/** Accepts canonical IDs and public aliases; output is always canonical. */ +export const ClientTypeSchema = z + .enum(CLIENT_INPUT_TYPES) + .transform(requireCanonicalClientId); + +function aliasesForClients( + clients: readonly CanonicalClientId[], +): (keyof typeof CLIENT_ALIASES)[] { + return CLIENT_ALIAS_TYPES.filter((alias) => + clients.includes(CLIENT_ALIASES[alias]), + ); +} + +const USER_CLIENT_ALIAS_TYPES = aliasesForClients(USER_CLIENT_TYPES) as [ + keyof typeof CLIENT_ALIASES, + ...(keyof typeof CLIENT_ALIASES)[], +]; + +const USER_CLIENT_INPUT_TYPES = [ + ...USER_CLIENT_TYPES, + ...USER_CLIENT_ALIAS_TYPES, +] as const; + +const PROJECT_NATIVE_CLIENT_ALIAS_TYPES = aliasesForClients( + PROJECT_NATIVE_CLIENT_TYPES, +); +const PROJECT_NATIVE_CLIENT_INPUT_TYPES = [ + ...PROJECT_NATIVE_CLIENT_TYPES, + ...PROJECT_NATIVE_CLIENT_ALIAS_TYPES, +] as const; +const USER_NATIVE_CLIENT_ALIAS_TYPES = aliasesForClients( + USER_NATIVE_CLIENT_TYPES, +); +const USER_NATIVE_CLIENT_INPUT_TYPES = [ + ...USER_NATIVE_CLIENT_TYPES, + ...USER_NATIVE_CLIENT_ALIAS_TYPES, +] as const; + +export const UserClientTypeSchema = z + .enum(USER_CLIENT_INPUT_TYPES, { + errorMap: (issue, context) => { + if ( + issue.code !== z.ZodIssueCode.invalid_enum_value || + typeof issue.received !== 'string' + ) { + return { message: context.defaultError }; + } + const canonical = canonicalizeClientId(issue.received); + return { + message: + canonical && !USER_CLIENT_TYPES.includes(canonical) + ? `Client '${issue.received}' does not support user scope` + : `Unknown client '${issue.received}'`, + }; + }, + }) + .transform(requireCanonicalClientId); + +/** + * Free-form selectors retain unknown values for forward compatibility while + * normalizing every client identity this version recognizes. Duplicates remain + * visible so profile validation can reject alias-equivalent declarations. + */ +export const ClientSelectorListSchema = z.array( + z.string().transform((value) => canonicalizeClientId(value) ?? value), +); + +function deduplicateClientTypes(clients: readonly ClientType[]): ClientType[] { + return [...new Set(clients)]; +} + +export const ClientTypeListSchema = z + .array(ClientTypeSchema) + .transform(deduplicateClientTypes); + +export const UserClientTypeListSchema = z + .array(UserClientTypeSchema) + .transform(deduplicateClientTypes); + +export const InstallModeSchema = z.enum(['file', 'native']); +export type InstallMode = z.infer; + +function installShorthandsFor( + clients: readonly string[], + nativeClients: readonly string[], +): [string, ...string[]] { + return [ + ...clients.map((client) => `${client}:file` as const), + ...nativeClients.map((client) => `${client}:native` as const), + ] as [string, ...string[]]; +} + +const CLIENT_INSTALL_SHORTHANDS = installShorthandsFor( + CLIENT_INPUT_TYPES, + PROJECT_NATIVE_CLIENT_INPUT_TYPES, +); + +const USER_CLIENT_INSTALL_SHORTHANDS = installShorthandsFor( + USER_CLIENT_INPUT_TYPES, + USER_NATIVE_CLIENT_INPUT_TYPES, +); + +function parseInstallShorthand(value: string): { + name: ClientType; + install: InstallMode; +} { + const separator = value.lastIndexOf(':'); + return { + name: requireCanonicalClientId(value.slice(0, separator)), + install: value.slice(separator + 1) as InstallMode, + }; +} + +const ClientInstallShorthandSchema = z + .enum(CLIENT_INSTALL_SHORTHANDS) + .transform(parseInstallShorthand); + +const UserClientInstallShorthandSchema = z + .enum(USER_CLIENT_INSTALL_SHORTHANDS) + .transform(parseInstallShorthand); + +const ProjectNativeClientTypeSchema = z + .enum(PROJECT_NATIVE_CLIENT_INPUT_TYPES) + .transform(requireCanonicalClientId); +const UserNativeClientTypeSchema = z + .enum(USER_NATIVE_CLIENT_INPUT_TYPES) + .transform(requireCanonicalClientId); + +export const ClientEntrySchema = z.union([ + ClientTypeSchema, + ClientInstallShorthandSchema, + z.object({ + name: ClientTypeSchema, + install: z.literal('file').default('file'), + }), + z.object({ + name: ProjectNativeClientTypeSchema, + install: z.literal('native'), + }), +]); + +const UserClientEntrySchema = z.union([ + UserClientTypeSchema, + UserClientInstallShorthandSchema, + z.object({ + name: UserClientTypeSchema, + install: z.literal('file').default('file'), + }), + z.object({ + name: UserNativeClientTypeSchema, + install: z.literal('native'), + }), +]); + +export type ClientEntry = z.infer; + +export function normalizeClientEntry(entry: ClientEntry): { + name: ClientType; + install: InstallMode; +} { + if (typeof entry === 'string') { + return { name: entry, install: 'file' }; + } + return { name: entry.name, install: entry.install ?? 'file' }; +} + +/** Preserve first-entry install precedence while preventing duplicate writes. */ +export function deduplicateClientEntries( + entries: readonly ClientEntry[], +): ClientEntry[] { + const seen = new Set(); + const deduplicated: ClientEntry[] = []; + for (const entry of entries) { + const { name } = normalizeClientEntry(entry); + if (seen.has(name)) continue; + seen.add(name); + deduplicated.push(entry); + } + return deduplicated; +} + +export const ClientEntryListSchema = z + .array(ClientEntrySchema) + .transform(deduplicateClientEntries); + +export const UserClientEntryListSchema = z + .array(UserClientEntrySchema) + .transform(deduplicateClientEntries); + +export function getClientTypes(entries: readonly ClientEntry[]): ClientType[] { + const clients = new Set(); + for (const entry of entries) { + clients.add(normalizeClientEntry(entry).name); + } + return [...clients]; +} + +export function getClientInstallMode( + entries: readonly ClientEntry[], + client: ClientType, +): InstallMode { + for (const entry of entries) { + const normalized = normalizeClientEntry(entry); + if (normalized.name === client) return normalized.install; + } + return 'file'; +} diff --git a/src/models/client-mapping.ts b/src/models/client-mapping.ts index 6e919566..875880c3 100644 --- a/src/models/client-mapping.ts +++ b/src/models/client-mapping.ts @@ -1,45 +1,59 @@ -import { ClientTypeSchema, type ClientType } from './workspace-config.js'; - /** - * Client-specific path and file configuration + * Filesystem destinations supported by one client at one scope. + * + * A skill destination is the only required capability. Every other artifact + * destination is independent so skill-only clients never imply instructions, + * commands, agents, hooks, or GitHub overlays. */ export interface ClientMapping { - /** Path for commands (Claude, OpenCode) */ - commandsPath?: string; skillsPath: string; + commandsPath?: string; agentsPath?: string; - agentFile: string; + agentFile?: string; agentFileFallback?: string; hooksPath?: string; - /** Path for GitHub-specific content (prompts, copilot-instructions.md) */ githubPath?: string; } -/** - * Single source of truth for every supported AI client/agent host. - * - * Each entry pairs a project-scope mapping (paths relative to the project root) - * with a user-scope mapping (paths relative to ~). Most hosts use identical - * paths in both scopes; a few — notably `copilot`, `windsurf`, and `vscode` — - * intentionally diverge. Keeping both maps on one entry surfaces those - * differences at a glance instead of forcing reviewers to diff two ~130-line - * records. - * - * The legacy `CLIENT_MAPPINGS` and `USER_CLIENT_MAPPINGS` records below are - * derived from this array so existing call sites keep working while - * `getMapping(id, scope)` becomes the preferred accessor going forward. - * - * Pattern is modelled after `cli/cli`'s `internal/skills/registry/registry.go`. - */ +export interface ScopeCapability { + project?: true; + user?: true; +} + +/** A canonical product identity and its independently evidenced capabilities. */ export interface AgentHost { - id: ClientType; - /** Display name used in help / sync output (e.g. "Windsurf"). */ + id: string; name: string; project: ClientMapping; - user: ClientMapping; + user?: ClientMapping; + mcp?: ScopeCapability; + native?: ScopeCapability; } -export const AGENT_HOSTS: readonly AgentHost[] = [ +function skillsOnlyHost( + id: Id, + name: string, + projectSkillsPath: string, + userSkillsPath?: string, +): AgentHost & { readonly id: Id } { + return { + id, + name, + project: { skillsPath: projectSkillsPath }, + ...(userSkillsPath ? { user: { skillsPath: userSkillsPath } } : {}), + }; +} + +/** + * Canonical client capability registry. + * + * Skill-only destinations are curated from the registry in `skills@1.7.0` to + * coding clients that directly consume skills. Existing AllAgents clients + * retain their richer artifact destinations and runtime capabilities. `omp` is + * an AllAgents-only product identity and `vscode` is an explicit target with + * Copilot-aware routing; neither is a Skills alias. + */ +const AGENT_HOST_DEFINITIONS = [ { id: 'claude', name: 'Claude Code', @@ -59,6 +73,8 @@ export const AGENT_HOSTS: readonly AgentHost[] = [ agentFileFallback: 'AGENTS.md', hooksPath: '.claude/hooks/', }, + mcp: { project: true, user: true }, + native: { project: true, user: true }, }, { id: 'copilot', @@ -71,20 +87,22 @@ export const AGENT_HOSTS: readonly AgentHost[] = [ githubPath: '.github/', }, user: { - // User-scope Copilot stores under `.copilot/` because `.github/` is - // owned by individual repositories. skillsPath: '.copilot/skills/', agentsPath: '.copilot/agents/', hooksPath: '.copilot/hooks/', agentFile: 'AGENTS.md', githubPath: '.copilot/', }, + mcp: { project: true, user: true }, + native: { user: true }, }, { id: 'codex', name: 'Codex', project: { skillsPath: '.codex/skills/', agentFile: 'AGENTS.md' }, user: { skillsPath: '.codex/skills/', agentFile: 'AGENTS.md' }, + mcp: { project: true, user: true }, + native: { user: true }, }, { id: 'pi', @@ -94,6 +112,7 @@ export const AGENT_HOSTS: readonly AgentHost[] = [ skillsPath: '.pi/agent/skills/', agentFile: '.pi/agent/AGENTS.md', }, + native: { project: true, user: true }, }, { id: 'omp', @@ -108,6 +127,7 @@ export const AGENT_HOSTS: readonly AgentHost[] = [ hooksPath: '.omp/agent/hooks/', agentFile: '.omp/agent/AGENTS.md', }, + native: { project: true, user: true }, }, { id: 'cursor', @@ -166,10 +186,9 @@ export const AGENT_HOSTS: readonly AgentHost[] = [ { id: 'vscode', name: 'VS Code', - // Defaults to the canonical universal location at both scopes. The - // copilot-sibling override is applied dynamically by resolveClientMappings. project: { skillsPath: '.agents/skills/', agentFile: 'AGENTS.md' }, user: { skillsPath: '.agents/skills/', agentFile: 'AGENTS.md' }, + mcp: { project: true, user: true }, }, { id: 'openclaw', @@ -181,9 +200,10 @@ export const AGENT_HOSTS: readonly AgentHost[] = [ id: 'windsurf', name: 'Windsurf', project: { skillsPath: '.windsurf/skills/', agentFile: 'AGENTS.md' }, - // Windsurf's user-scope home is the Codeium parent dir, not `.windsurf/`. - // Surfaced explicitly so the divergence is obvious in code review. - user: { skillsPath: '.codeium/windsurf/skills/', agentFile: 'AGENTS.md' }, + user: { + skillsPath: '.codeium/windsurf/skills/', + agentFile: 'AGENTS.md', + }, }, { id: 'cline', @@ -261,141 +281,244 @@ export const AGENT_HOSTS: readonly AgentHost[] = [ id: 'universal', name: 'Universal', project: { skillsPath: '.agents/skills/', agentFile: 'AGENTS.md' }, - user: { skillsPath: '.agents/skills/', agentFile: 'AGENTS.md' }, + user: { + skillsPath: '.agents/skills/', + agentFile: 'AGENTS.md', + }, + mcp: { project: true, user: true }, }, -] as const; -/** - * Look up an agent host by ClientType id. - * - * Returns the canonical entry; callers wanting the legacy `CLIENT_MAPPINGS` - * shape should use `getMapping(id, scope)` instead. - */ -export function findHostById(id: ClientType): AgentHost | undefined { - return AGENT_HOSTS.find((h) => h.id === id); + // Coding-client skill destinations curated from skills@1.7.0. These entries + // intentionally do not claim instruction or non-skill artifact support. + skillsOnlyHost('aider-desk', 'AiderDesk', '.aider-desk/skills/', '.aider-desk/skills/'), + skillsOnlyHost('antigravity', 'Antigravity', '.agents/skills/', '.gemini/antigravity/skills/'), + skillsOnlyHost( + 'antigravity-cli', + 'Antigravity CLI', + '.agents/skills/', + '.gemini/antigravity-cli/skills/', + ), + skillsOnlyHost('autohand-code', 'Autohand Code CLI', '.autohand/skills/', '.autohand/skills/'), + skillsOnlyHost('bob', 'IBM Bob', '.bob/skills/', '.bob/skills/'), + skillsOnlyHost( + 'codearts-agent', + 'CodeArts Agent', + '.codeartsdoer/skills/', + '.codeartsdoer/skills/', + ), + skillsOnlyHost('codebuddy', 'CodeBuddy', '.codebuddy/skills/', '.codebuddy/skills/'), + skillsOnlyHost('codemaker', 'Codemaker', '.codemaker/skills/', '.codemaker/skills/'), + skillsOnlyHost('codestudio', 'Code Studio', '.codestudio/skills/', '.codestudio/skills/'), + skillsOnlyHost('command-code', 'Command Code', '.commandcode/skills/', '.commandcode/skills/'), + skillsOnlyHost('cortex', 'Cortex Code', '.cortex/skills/', '.snowflake/cortex/skills/'), + skillsOnlyHost('crush', 'Crush', '.crush/skills/', '.config/crush/skills/'), + skillsOnlyHost('deepagents', 'Deep Agents', '.agents/skills/', '.deepagents/agent/skills/'), + skillsOnlyHost('devin', 'Devin for Terminal', '.devin/skills/', '.config/devin/skills/'), + skillsOnlyHost('dexto', 'Dexto', '.agents/skills/', '.agents/skills/'), + skillsOnlyHost('eve', 'Eve', 'agent/skills/'), + skillsOnlyHost('firebender', 'Firebender', '.agents/skills/', '.firebender/skills/'), + skillsOnlyHost('forgecode', 'ForgeCode', '.forge/skills/', '.forge/skills/'), + skillsOnlyHost('fx', 'fx', '.fx/skills/', '.fx/skills/'), + skillsOnlyHost('goose', 'Goose', '.agents/skills/', '.agents/skills/'), + skillsOnlyHost('grok', 'Grok Build', '.grok/skills/', '.grok/skills/'), + skillsOnlyHost('hermes-agent', 'Hermes Agent', '.hermes/skills/', '.hermes/skills/'), + skillsOnlyHost('iflow-cli', 'iFlow CLI', '.iflow/skills/', '.iflow/skills/'), + skillsOnlyHost('jazz', 'Jazz', '.jazz/skills/', '.jazz/skills/'), + skillsOnlyHost('kimchi', 'Kimchi', '.kimchi/skills/', '.config/kimchi/harness/skills/'), + skillsOnlyHost('kode', 'Kode', '.kode/skills/', '.kode/skills/'), + skillsOnlyHost('lingma', 'Lingma', '.lingma/skills/', '.lingma/skills/'), + skillsOnlyHost('minimax-code', 'MiniMax Code', '.minimax/skills/', '.minimax/skills/'), + skillsOnlyHost('mistral-vibe', 'Mistral Vibe', '.vibe/skills/', '.vibe/skills/'), + skillsOnlyHost('moxby', 'Moxby', '.moxby/skills/', '.moxby/skills/'), + skillsOnlyHost('mux', 'Mux', '.mux/skills/', '.mux/skills/'), + skillsOnlyHost('neovate', 'Neovate', '.neovate/skills/', '.neovate/skills/'), + skillsOnlyHost('ona', 'Ona', '.ona/skills/', '.ona/skills/'), + skillsOnlyHost( + 'posit-assistant', + 'Posit Assistant', + '.posit/assistant/skills/', + '.posit/assistant/skills/', + ), + skillsOnlyHost('qoder', 'Qoder', '.qoder/skills/', '.qoder/skills/'), + skillsOnlyHost('qoder-cn', 'Qoder CN', '.qoder/skills/', '.qoder-cn/skills/'), + skillsOnlyHost('qwen-code', 'Qwen Code', '.qwen/skills/', '.qwen/skills/'), + skillsOnlyHost('reasonix', 'Reasonix', '.reasonix/skills/', '.reasonix/skills/'), + skillsOnlyHost('rovodev', 'Rovo Dev', '.rovodev/skills/', '.rovodev/skills/'), + skillsOnlyHost('sarvam-code', 'Sarvam Code', '.agents/skills/', '.agents/skills/'), + skillsOnlyHost( + 'tabnine-cli', + 'Tabnine CLI', + '.tabnine/agent/skills/', + '.tabnine/agent/skills/', + ), + skillsOnlyHost('terramind', 'Terramind', '.terramind/skills/', '.terramind/skills/'), + skillsOnlyHost('trae-cn', 'Trae CN', '.trae/skills/', '.trae-cn/skills/'), + skillsOnlyHost('warp', 'Warp', '.agents/skills/', '.agents/skills/'), + skillsOnlyHost('zed', 'Zed', '.agents/skills/', '.agents/skills/'), + skillsOnlyHost('zcode', 'ZCode', '.zcode/skills/', '.zcode/skills/'), + skillsOnlyHost('zenflow', 'Zenflow', '.zencoder/skills/', '.zencoder/skills/'), + skillsOnlyHost('pochi', 'Pochi', '.pochi/skills/', '.pochi/skills/'), + skillsOnlyHost('adal', 'AdaL', '.adal/skills/', '.adal/skills/'), +] as const satisfies readonly AgentHost[]; + +export type CanonicalClientId = (typeof AGENT_HOST_DEFINITIONS)[number]['id']; + +export type CanonicalAgentHost = AgentHost & { + readonly id: CanonicalClientId; +}; + +export const AGENT_HOSTS: readonly CanonicalAgentHost[] = + AGENT_HOST_DEFINITIONS; + +export const CLIENT_TYPES = AGENT_HOSTS.map( + (host) => host.id, +) as [CanonicalClientId, ...CanonicalClientId[]]; + +export const USER_CLIENT_TYPES = AGENT_HOSTS + .filter((host) => host.user !== undefined) + .map((host) => host.id) as [CanonicalClientId, ...CanonicalClientId[]]; + +export const PROJECT_NATIVE_CLIENT_TYPES = AGENT_HOSTS + .filter((host) => host.native?.project) + .map((host) => host.id) as [CanonicalClientId, ...CanonicalClientId[]]; + +export const USER_NATIVE_CLIENT_TYPES = AGENT_HOSTS + .filter((host) => host.native?.user) + .map((host) => host.id) as [CanonicalClientId, ...CanonicalClientId[]]; + +/** Skills-compatible names that map to an existing AllAgents product identity. */ +export const CLIENT_ALIASES = Object.freeze({ + 'claude-code': 'claude', + 'github-copilot': 'copilot', + 'gemini-cli': 'gemini', + droid: 'factory', + amp: 'ampcode', + 'kiro-cli': 'kiro', + 'kimi-code-cli': 'kimi', +} as const satisfies Record); + +export type ClientAlias = keyof typeof CLIENT_ALIASES; + +const CANONICAL_CLIENT_LOOKUP: Readonly> = + Object.freeze(Object.fromEntries(CLIENT_TYPES.map((id) => [id, id]))); + +export function canonicalizeClientId( + id: string, +): CanonicalClientId | undefined { + return CANONICAL_CLIENT_LOOKUP[id] ?? CLIENT_ALIASES[id as ClientAlias]; +} + +export function findHostById(id: string): AgentHost | undefined { + const canonical = canonicalizeClientId(id); + return canonical === undefined + ? undefined + : AGENT_HOSTS.find((host) => host.id === canonical); } -/** - * Resolve the `ClientMapping` for a given (client, scope) pair. - * - * Falls back to throwing rather than returning undefined: every ClientType - * value is guaranteed to have a host entry (enforced by - * `client-mapping.test.ts`). Returning undefined would silently mask the - * "added to enum but not registered" bug we just removed. - */ export function getMapping( - id: ClientType, + id: CanonicalClientId, scope: 'project' | 'user', ): ClientMapping { const host = findHostById(id); if (!host) { throw new Error(`Unknown agent host: ${id} (no entry in AGENT_HOSTS)`); } - return scope === 'user' ? host.user : host.project; + if (scope === 'project') return host.project; + const user = host.user; + if (!user) { + throw new Error(`Client '${id}' does not support user scope`); + } + return user; +} + +export function supportsClientScope( + id: CanonicalClientId, + scope: 'project' | 'user', +): boolean { + const host = findHostById(id); + return scope === 'project' ? host !== undefined : host?.user !== undefined; +} + +export function clientIdsForScope( + scope: 'project' | 'user', +): CanonicalClientId[] { + return [...(scope === 'project' ? CLIENT_TYPES : USER_CLIENT_TYPES)]; +} + +export function mcpClientIdsForScope( + scope: 'project' | 'user', +): CanonicalClientId[] { + return AGENT_HOSTS.filter((host) => host.mcp?.[scope]).map( + (host) => host.id, + ); } -/** - * The set of distinct skills paths used at project scope. Useful for the - * dedup logic that the symlink-mode sync runs against `.agents/skills/`. - */ export function uniqueProjectSkillsPaths(): string[] { - return Array.from(new Set(AGENT_HOSTS.map((h) => h.project.skillsPath))); + return Array.from(new Set(AGENT_HOSTS.map((host) => host.project.skillsPath))); } -/** - * Render an agent help list: `""` per host, alphabetised. - * Used by user-facing help output. - */ export function agentHelpList(): string { return [...AGENT_HOSTS] - .sort((a, b) => a.id.localeCompare(b.id)) - .map((h) => `${h.id} — ${h.name}`) + .sort((left, right) => left.id.localeCompare(right.id)) + .map((host) => `${host.id} — ${host.name}`) .join('\n'); } -/** - * Project-level client path mappings for all supported AI clients. - * Paths are relative to the project root directory. - * - * Derived from `AGENT_HOSTS` so it can never drift from the user-scope record. - * Kept as a separate export for backward compatibility with existing call sites. - */ -export const CLIENT_MAPPINGS: Record = Object.freeze( - Object.fromEntries(AGENT_HOSTS.map((h) => [h.id, h.project])), -) as Record; +export type ClientMappings = Partial< + Record +>; -/** - * User-level client path mappings for all supported AI clients. - * Paths are relative to the user's home directory (~/). - * - * Derived from `AGENT_HOSTS`. See note on `CLIENT_MAPPINGS`. - */ -export const USER_CLIENT_MAPPINGS: Record = Object.freeze( - Object.fromEntries(AGENT_HOSTS.map((h) => [h.id, h.user])), -) as Record; +export const CLIENT_MAPPINGS: Record = + Object.freeze( + Object.fromEntries(AGENT_HOSTS.map((host) => [host.id, host.project])), + ) as Record; + +export const USER_CLIENT_MAPPINGS: ClientMappings = Object.freeze( + Object.fromEntries( + AGENT_HOSTS.flatMap((host) => + host.user ? [[host.id, host.user] as const] : [], + ), + ), +) as ClientMappings; -/** - * The canonical skills path used by the universal client. - * When universal is in the clients list, skills are copied here first, - * then symlinked from non-universal client paths. - */ export const CANONICAL_SKILLS_PATH = '.agents/skills/'; -/** - * Check if a client is the universal client (uses .agents/skills/). - * Only the 'universal' client type returns true. - */ -export function isUniversalClient(client: ClientType): boolean { +export function isUniversalClient(client: CanonicalClientId): boolean { return client === 'universal'; } -/** - * Resolve vscode client mapping based on sibling clients. - * When copilot is present, vscode follows copilot's paths. - * When copilot is absent, vscode defaults to .agents/ (universal behavior). - * - * Returns baseMappings unchanged if vscode is not in the clients list. - */ export function resolveClientMappings( - clients: ClientType[], - baseMappings: Record, -): Record { - if (!clients.includes('vscode')) return baseMappings; - if (!clients.includes('copilot')) return baseMappings; - - // vscode follows copilot's mapping + clients: readonly CanonicalClientId[], + baseMappings: Record, +): Record; +export function resolveClientMappings( + clients: readonly CanonicalClientId[], + baseMappings: ClientMappings, +): ClientMappings; +export function resolveClientMappings( + clients: readonly CanonicalClientId[], + baseMappings: ClientMappings, +): ClientMappings { + if (!clients.includes('vscode') || !clients.includes('copilot')) { + return baseMappings; + } + const copilot = baseMappings.copilot; + if (!copilot) return baseMappings; return { ...baseMappings, - vscode: { ...baseMappings.copilot }, + vscode: { ...copilot }, }; } -/** - * Display name aliases for CLI output. - * vscode is displayed as copilot for artifact counts since VS Code's AI features - * are delivered through GitHub Copilot and they share skill paths. - */ -export const CLIENT_DISPLAY_ALIASES: Partial> = { +/** Display grouping is distinct from accepted input aliases. */ +export const CLIENT_DISPLAY_ALIASES: Partial< + Record +> = { vscode: 'copilot', }; -/** - * Get the display name for a client type. - * Applies CLIENT_DISPLAY_ALIASES so aliased clients (e.g. vscode → copilot) - * show their canonical display name. - */ export function getDisplayName(client: string): string { - return CLIENT_DISPLAY_ALIASES[client as ClientType] ?? client; + const canonical = canonicalizeClientId(client); + if (!canonical) return client; + return CLIENT_DISPLAY_ALIASES[canonical] ?? canonical; } - -// Compile-time sanity: every ClientType value must have exactly one host entry. -// Runtime coverage is verified by `client-mapping.test.ts`. -type _HostsCoverEveryClient = Exclude< - ClientType, - (typeof AGENT_HOSTS)[number]['id'] -> extends never - ? true - : never; -const _check: _HostsCoverEveryClient = true; -void _check; -void ClientTypeSchema; diff --git a/src/models/workspace-config.ts b/src/models/workspace-config.ts index bdc358d2..c4edb52e 100644 --- a/src/models/workspace-config.ts +++ b/src/models/workspace-config.ts @@ -1,4 +1,37 @@ import { z } from 'zod'; +import { + ClientEntryListSchema, + ClientSelectorListSchema, + ClientTypeListSchema, + InstallModeSchema, + UserClientEntryListSchema, + UserClientTypeListSchema, + UserClientTypeSchema, + type ClientType, + type InstallMode, +} from './client-entry-schema.js'; + +export { + CanonicalClientTypeSchema, + CLIENT_TYPES, + CLIENT_INPUT_TYPES, + ClientEntryListSchema, + ClientEntrySchema, + ClientSelectorListSchema, + ClientTypeListSchema, + ClientTypeSchema, + InstallModeSchema, + UserClientEntryListSchema, + UserClientTypeListSchema, + UserClientTypeSchema, + deduplicateClientEntries, + getClientInstallMode, + getClientTypes, + normalizeClientEntry, + type ClientEntry, + type ClientType, + type InstallMode, +} from './client-entry-schema.js'; /** * Managed mode for repositories: @@ -77,79 +110,6 @@ export const PluginSourceSchema = z.string(); export type PluginSource = z.infer; -/** - * Supported AI client types - */ -export const ClientTypeSchema = z.enum([ - 'universal', - 'claude', - 'copilot', - 'codex', - 'pi', - 'omp', - 'cursor', - 'opencode', - 'gemini', - 'factory', - 'ampcode', - 'vscode', - 'openclaw', - 'windsurf', - 'cline', - 'continue', - 'roo', - 'kilo', - 'trae', - 'augment', - 'zencoder', - 'junie', - 'openhands', - 'kiro', - 'replit', - 'kimi', -]); - -export type ClientType = z.infer; - -/** - * Installation mode for plugins - * - 'file': Copy plugin files to client directories (default) - * - 'native': Use client's native CLI to install (e.g., `claude plugin install`) - */ -export const InstallModeSchema = z.enum(['file', 'native']); -export type InstallMode = z.infer; - -/** - * Client entry — string shorthand, colon shorthand, or object with install mode. - * - * "claude" → bare client, install defaults to "file" - * "claude:native" → colon shorthand, parsed to { name: "claude", install: "native" } - * { name, install } → explicit object form - */ -const CLIENT_INSTALL_SHORTHAND_PATTERN = new RegExp( - `^(?:${ClientTypeSchema.options.join('|')}):(?:${InstallModeSchema.options.join('|')})$`, -); - -const ClientInstallShorthandSchema = z - .string() - .regex( - CLIENT_INSTALL_SHORTHAND_PATTERN, - `Expected CLIENT:INSTALL with a known client and one of: ${InstallModeSchema.options.join(', ')}`, - ) - .transform((value) => { - const [name, install] = value.split(':') as [ClientType, InstallMode]; - return { name, install }; - }); - -export const ClientEntrySchema = z.union([ - ClientTypeSchema, - ClientInstallShorthandSchema, - z.object({ - name: ClientTypeSchema, - install: InstallModeSchema.default('file'), - }), -]); -export type ClientEntry = z.infer; /** * Skill selection config for a plugin entry. @@ -167,23 +127,32 @@ export type PluginSkillsConfig = z.infer; * Plugin entry in workspace.yaml * Supports string shorthand and object form with optional client override. */ +const PluginEntryObjectSchema = z + .object({ + source: PluginSourceSchema, + clients: ClientTypeListSchema.optional(), + install: InstallModeSchema.optional(), + exclude: z.array(z.string()).optional(), + skills: PluginSkillsConfigSchema.optional(), + /** + * Optional Git ref (tag or branch). Equivalent to passing the + * `owner/repo@` shorthand on install. When set, every sync resolves + * the plugin at this ref instead of the default branch. + */ + ref: z.string().optional(), + }) + .strict(); + export const PluginEntrySchema = z.union([ PluginSourceSchema, - z - .object({ - source: PluginSourceSchema, - clients: z.array(ClientTypeSchema).optional(), - install: InstallModeSchema.optional(), - exclude: z.array(z.string()).optional(), - skills: PluginSkillsConfigSchema.optional(), - /** - * Optional Git ref (tag or branch). Equivalent to passing the - * `owner/repo@` shorthand on install. When set, every sync resolves - * the plugin at this ref instead of the default branch. - */ - ref: z.string().optional(), - }) - .strict(), + PluginEntryObjectSchema, +]); + +export const UserPluginEntrySchema = z.union([ + PluginSourceSchema, + PluginEntryObjectSchema.extend({ + clients: UserClientTypeListSchema.optional(), + }), ]); export type PluginEntry = z.infer; @@ -262,40 +231,6 @@ export function getPluginRef(plugin: PluginEntry): string | undefined { return typeof plugin === 'string' ? undefined : plugin.ref; } -/** - * Normalize a client entry to { name, install } form. - */ -export function normalizeClientEntry(entry: ClientEntry): { - name: ClientType; - install: InstallMode; -} { - if (typeof entry === 'string') { - return { name: entry, install: 'file' }; - } - return { name: entry.name, install: entry.install ?? 'file' }; -} - -/** - * Extract ClientType values from client entries. - */ -export function getClientTypes(entries: ClientEntry[]): ClientType[] { - return entries.map((e) => (typeof e === 'string' ? e : e.name)); -} - -/** - * Get install mode for a specific client from entries. - * Returns 'file' if client not found. - */ -export function getClientInstallMode( - entries: ClientEntry[], - client: ClientType, -): InstallMode { - for (const entry of entries) { - const normalized = normalizeClientEntry(entry); - if (normalized.name === client) return normalized.install; - } - return 'file'; -} /** * Resolve effective install mode for a (plugin, client) pair. @@ -332,7 +267,7 @@ export type SyncMode = z.infer; * Per-server MCP proxy override */ export const McpProxyServerSchema = z.object({ - proxy: z.array(z.string()), + proxy: ClientSelectorListSchema, }); /** @@ -340,7 +275,7 @@ export const McpProxyServerSchema = z.object({ * built-in AllAgents HTTP proxy helper */ export const McpProxyConfigSchema = z.object({ - clients: z.array(z.string()).default([]), + clients: ClientSelectorListSchema.default([]), servers: z.record(McpProxyServerSchema).optional(), }); @@ -355,26 +290,37 @@ export type McpProxyConfig = z.infer; * When absent, the server is synced to every configured client that supports * project-scoped MCP (claude, codex, vscode, copilot). */ +const McpHttpServerConfigSchema = z + .object({ + type: z.enum(['http']).optional(), + url: z.string(), + headers: z.record(z.string()).optional(), + clients: ClientTypeListSchema.optional(), + }) + .strict(); + +const McpStdioServerConfigSchema = z + .object({ + type: z.enum(['stdio']).optional(), + command: z.string(), + args: z.array(z.string()).optional(), + env: z.record(z.string()).optional(), + clients: ClientTypeListSchema.optional(), + }) + .strict(); + export const McpServerConfigSchema = z.union([ - // HTTP transport - z - .object({ - type: z.enum(['http']).optional(), - url: z.string(), - headers: z.record(z.string()).optional(), - clients: z.array(ClientTypeSchema).optional(), - }) - .strict(), - // stdio transport - z - .object({ - type: z.enum(['stdio']).optional(), - command: z.string(), - args: z.array(z.string()).optional(), - env: z.record(z.string()).optional(), - clients: z.array(ClientTypeSchema).optional(), - }) - .strict(), + McpHttpServerConfigSchema, + McpStdioServerConfigSchema, +]); + +export const UserMcpServerConfigSchema = z.union([ + McpHttpServerConfigSchema.extend({ + clients: UserClientTypeListSchema.optional(), + }), + McpStdioServerConfigSchema.extend({ + clients: UserClientTypeListSchema.optional(), + }), ]); export type McpServerConfig = z.infer; @@ -527,10 +473,18 @@ const ProfileClientCommonShape = { launcher: ProfileNameSchema.optional(), } as const; +const ClaudeProfileClientNameSchema = z + .enum(['claude', 'claude-code']) + .transform(() => 'claude' as const); + +const CopilotProfileClientNameSchema = z + .enum(['copilot', 'github-copilot']) + .transform(() => 'copilot' as const); + export const ProfileClientSchema = z.union([ z .object({ - name: z.literal('claude'), + name: ClaudeProfileClientNameSchema, ...ProfileClientCommonShape, settings: ClaudeProfileSettingsSchema.default({}), }) @@ -544,7 +498,7 @@ export const ProfileClientSchema = z.union([ .strict(), z .object({ - name: z.literal('copilot'), + name: CopilotProfileClientNameSchema, ...ProfileClientCommonShape, settings: CopilotProfileSettingsSchema.default({}), }) @@ -565,28 +519,7 @@ export const ProfileClientSchema = z.union([ .strict(), z .object({ - name: z.enum([ - 'universal', - 'cursor', - 'gemini', - 'factory', - 'ampcode', - 'vscode', - 'openclaw', - 'windsurf', - 'cline', - 'continue', - 'roo', - 'kilo', - 'trae', - 'augment', - 'zencoder', - 'junie', - 'openhands', - 'kiro', - 'replit', - 'kimi', - ]), + name: UserClientTypeSchema, ...ProfileClientCommonShape, settings: EmptyProfileSettingsSchema.default({}), }) @@ -611,7 +544,7 @@ export const ProfilePluginEntrySchema = z.union([ source: PluginSourceSchema, ref: z.string().optional(), install: InstallModeSchema.optional(), - clients: z.array(ClientTypeSchema).optional(), + clients: z.array(UserClientTypeSchema).optional(), skills: ProfilePluginSkillsConfigSchema.optional(), }) .strict(), @@ -715,7 +648,7 @@ export const ProfileMcpServerConfigSchema = z.union([ type: z.enum(['http']).optional(), url: z.string(), headers: z.record(ProfileSecretReferenceSchema).optional(), - clients: z.array(ClientTypeSchema).optional(), + clients: z.array(UserClientTypeSchema).optional(), }) .strict(), z @@ -724,7 +657,7 @@ export const ProfileMcpServerConfigSchema = z.union([ command: z.string(), args: ProfileMcpArgumentsSchema.optional(), env: z.record(ProfileSecretReferenceSchema).optional(), - clients: z.array(ClientTypeSchema).optional(), + clients: z.array(UserClientTypeSchema).optional(), }) .strict(), ]); @@ -904,7 +837,7 @@ const WorkspaceConfigBaseSchema = z.object({ workspace: WorkspaceSchema.optional(), repositories: z.array(RepositorySchema), plugins: z.array(PluginEntrySchema), - clients: z.array(ClientEntrySchema), + clients: ClientEntryListSchema, vscode: VscodeConfigSchema.optional(), syncMode: SyncModeSchema.optional(), mcpProxy: McpProxyConfigSchema.optional(), @@ -935,8 +868,9 @@ export type ProjectWorkspaceConfig = z.infer; */ export const UserWorkspaceConfigSchema = WorkspaceConfigBaseSchema.extend({ repositories: z.array(RepositorySchema).default([]), - plugins: z.array(PluginEntrySchema).default([]), - clients: z.array(ClientEntrySchema).default([]), + plugins: z.array(UserPluginEntrySchema).default([]), + clients: UserClientEntryListSchema.default([]), + mcpServers: z.record(UserMcpServerConfigSchema).optional(), profiles: ProfilesSchema.optional(), }); diff --git a/tests/unit/cli/install-target.test.ts b/tests/unit/cli/install-target.test.ts index d3a4e3f5..8e4a9c20 100644 --- a/tests/unit/cli/install-target.test.ts +++ b/tests/unit/cli/install-target.test.ts @@ -100,6 +100,29 @@ describe('resolveInstallTarget', () => { expect(calls.confirmations).toHaveLength(0); }); + test('canonicalizes aliases and rejects project-only clients at user scope', async () => { + const aliased = await resolveInstallTarget( + options({ + scope: 'project', + clients: 'claude-code,claude,droid', + yes: true, + prompts: promptPort(makeCalls()), + }), + ); + expect(aliased?.clients).toEqual(['claude', 'factory']); + + await expect( + resolveInstallTarget( + options({ + scope: 'user', + clients: 'eve', + yes: true, + prompts: promptPort(makeCalls()), + }), + ), + ).rejects.toThrow('User scope is unavailable for: eve'); + }); + test('loads only the explicitly selected scope state', async () => { let projectLoads = 0; let userLoads = 0; diff --git a/tests/unit/cli/parse-client-entries.test.ts b/tests/unit/cli/parse-client-entries.test.ts index ad7d2592..97fc67ba 100644 --- a/tests/unit/cli/parse-client-entries.test.ts +++ b/tests/unit/cli/parse-client-entries.test.ts @@ -33,4 +33,16 @@ describe('parseClientEntries', () => { "Invalid install mode 'invalid' for client 'claude'", ); }); + + it('canonicalizes aliases and removes duplicate canonical destinations', () => { + expect( + parseClientEntries( + 'claude-code,claude,github-copilot:file,copilot,droid', + ), + ).toEqual([ + 'claude', + { name: 'copilot', install: 'file' }, + 'factory', + ]); + }); }); diff --git a/tests/unit/cli/tui/prompt-clients.test.ts b/tests/unit/cli/tui/prompt-clients.test.ts index 463def26..18d18a02 100644 --- a/tests/unit/cli/tui/prompt-clients.test.ts +++ b/tests/unit/cli/tui/prompt-clients.test.ts @@ -9,6 +9,9 @@ describe('buildClientOptions', () => { expect(options.find((option) => option.value === 'universal')?.hint).toBe( '.agents/skills/', ); + expect(options.map((option) => option.value)).toEqual( + expect.arrayContaining(['warp', 'aider-desk', 'eve']), + ); expect(options.find((option) => option.value === 'claude')?.hint).toBe( '.claude/skills/', ); @@ -24,6 +27,15 @@ describe('buildClientOptions', () => { expect(options.find((option) => option.value === 'pi')?.hint).toBe( '.pi/agent/skills/', ); + expect(options.map((option) => option.value)).toEqual( + expect.arrayContaining(['warp', 'aider-desk']), + ); + }); + + test('omits the project-only destination from user-scope choices', () => { + const values = buildClientOptions('user').map((option) => option.value); + + expect(values).not.toContain('eve'); }); test('describes configured native clients without claiming a file destination', () => { diff --git a/tests/unit/core/client-context.test.ts b/tests/unit/core/client-context.test.ts index faa473c0..925b65d6 100644 --- a/tests/unit/core/client-context.test.ts +++ b/tests/unit/core/client-context.test.ts @@ -7,8 +7,8 @@ import { import { CLIENT_MAPPINGS, USER_CLIENT_MAPPINGS, + clientIdsForScope, } from '../../../src/models/client-mapping.js'; -import { ClientTypeSchema } from '../../../src/models/workspace-config.js'; describe('resolveClientContext', () => { const homeDir = '/users/tester'; @@ -210,29 +210,40 @@ describe('resolveClientContext', () => { }); }); - it('preserves every existing client mapping and root', () => { - const existingClients = ClientTypeSchema.options.filter( + it('preserves every ordinary client mapping and root', () => { + const projectClients = clientIdsForScope('project').filter( (client) => client !== 'pi' && client !== 'omp', ); - const project = resolveClientContexts(existingClients, 'project', { + const userClients = clientIdsForScope('user').filter( + (client) => client !== 'pi' && client !== 'omp', + ); + const project = resolveClientContexts(projectClients, 'project', { homeDir, cwd: repoRoot, env: {}, }); - const user = resolveClientContexts(existingClients, 'user', { + const user = resolveClientContexts(userClients, 'user', { homeDir, cwd, env: {}, }); - for (const client of existingClients) { + for (const client of projectClients) { expect(project.get(client)?.writeRoot).toBe(resolve(repoRoot)); expect(project.get(client)?.mapping).toBe(CLIENT_MAPPINGS[client]); + } + for (const client of userClients) { expect(user.get(client)?.writeRoot).toBe(resolve(homeDir)); expect(user.get(client)?.mapping).toBe(USER_CLIENT_MAPPINGS[client]); expect(user.get(client)?.skillDiscoveryRoots).toEqual([ - resolve(homeDir, USER_CLIENT_MAPPINGS[client].skillsPath), + resolve(homeDir, USER_CLIENT_MAPPINGS[client]?.skillsPath ?? ''), ]); } }); + + it('rejects a project-only client before resolving a user destination', () => { + expect(() => + resolveClientContext('eve', 'user', { homeDir, cwd, env: {} }), + ).toThrow("Client 'eve' does not support user scope"); + }); }); diff --git a/tests/unit/core/mcp-proxy.test.ts b/tests/unit/core/mcp-proxy.test.ts index c7ccf09c..fce38cf9 100644 --- a/tests/unit/core/mcp-proxy.test.ts +++ b/tests/unit/core/mcp-proxy.test.ts @@ -6,7 +6,10 @@ import { shouldProxy, applyMcpProxy, } from '../../../src/core/mcp-proxy.js'; -import type { McpProxyConfig } from '../../../src/models/workspace-config.js'; +import { + type McpProxyConfig, + WorkspaceConfigSchema, +} from '../../../src/models/workspace-config.js'; import packageJson from '../../../package.json'; const packageRef = `allagents@${packageJson.version}`; @@ -61,6 +64,41 @@ describe('shouldProxy', () => { }; expect(shouldProxy('my-api', 'codex', config)).toBe(false); }); + + test('honors aliases after workspace parsing', () => { + const config = WorkspaceConfigSchema.parse({ + repositories: [], + plugins: [], + clients: ['copilot', 'claude'], + mcpProxy: { + clients: [ + 'github-copilot', + 'copilot', + 'future-client', + 'future-client', + ], + servers: { + 'my-api': { proxy: ['claude-code', 'claude', '*'] }, + }, + }, + }).mcpProxy!; + + expect(config.clients).toEqual([ + 'copilot', + 'copilot', + 'future-client', + 'future-client', + ]); + expect(config.servers?.['my-api']?.proxy).toEqual([ + 'claude', + 'claude', + '*', + ]); + expect(shouldProxy('other-api', 'copilot', config)).toBe(true); + expect(shouldProxy('other-api', 'future-client', config)).toBe(true); + expect(shouldProxy('my-api', 'claude', config)).toBe(true); + expect(shouldProxy('my-api', 'other-unknown-client', config)).toBe(true); + }); }); describe('applyMcpProxy', () => { diff --git a/tests/unit/core/native/registry.test.ts b/tests/unit/core/native/registry.test.ts index 162b5892..7ddaf664 100644 --- a/tests/unit/core/native/registry.test.ts +++ b/tests/unit/core/native/registry.test.ts @@ -1,28 +1,24 @@ import { describe, expect, test } from 'bun:test'; -import { PiNativeClient } from '../../../../src/core/native/pi.js'; -import { OmpNativeClient } from '../../../../src/core/native/omp.js'; +import { CodexNativeClient } from '../../../../src/core/native/codex.js'; import { getNativeClient } from '../../../../src/core/native/registry.js'; +import { AGENT_HOSTS } from '../../../../src/models/client-mapping.js'; describe('native registry', () => { - test('registers Pi through the shared native client interface', () => { - const client = getNativeClient('pi'); + test('matches every declared native scope capability', () => { + for (const host of AGENT_HOSTS) { + const client = getNativeClient(host.id); + expect(client !== null).toBe(host.native !== undefined); + if (!client) continue; - expect(client).toBeInstanceOf(PiNativeClient); - expect(client?.client).toBe('pi'); - expect(client?.supportsScope('user')).toBe(true); - expect(client?.supportsScope('project')).toBe(true); + expect(client.client).toBe(host.id); + expect(client.supportsScope('project')).toBe( + host.native?.project === true, + ); + expect(client.supportsScope('user')).toBe(host.native?.user === true); + } }); - test('registers OMP through the shared native client interface', () => { - const client = getNativeClient('omp'); - - expect(client).toBeInstanceOf(OmpNativeClient); - expect(client?.client).toBe('omp'); - expect(client?.supportsScope('user')).toBe(true); - expect(client?.supportsScope('project')).toBe(true); - }); - - test('does not register clients without native lifecycle support', () => { - expect(getNativeClient('cursor')).toBeNull(); + test('registers Codex through its native implementation', () => { + expect(getNativeClient('codex')).toBeInstanceOf(CodexNativeClient); }); }); diff --git a/tests/unit/core/repo-skills-sync.test.ts b/tests/unit/core/repo-skills-sync.test.ts index 32327ad2..46ff9cc5 100644 --- a/tests/unit/core/repo-skills-sync.test.ts +++ b/tests/unit/core/repo-skills-sync.test.ts @@ -53,6 +53,25 @@ describe('updateAgentFiles with skills', () => { expect(agentsContent).not.toContain(''); }); + it('canonicalizes client aliases before skill discovery and rule injection', async () => { + makeSkill(join(repoDir, '.claude', 'skills'), 'aliased-skill', 'Alias proof'); + writeFileSync( + join(workspaceDir, '.allagents', 'workspace.yaml'), + 'repositories:\n - path: ./my-repo\n skills: true\nplugins: []\nclients:\n - claude-code\n', + ); + + await updateAgentFiles(workspaceDir); + + const indexContent = readFileSync( + join(workspaceDir, '.allagents', 'skills-index', 'my-repo.md'), + 'utf-8', + ); + expect(indexContent).toContain('aliased-skill'); + expect(readFileSync(join(workspaceDir, 'CLAUDE.md'), 'utf-8')).toContain( + '.allagents/skills-index/my-repo.md', + ); + }); + it('uses custom skill paths from workspace.yaml', async () => { makeSkill(join(repoDir, 'plugins', 'my-plugin', 'skills'), 'custom', 'Custom skill'); diff --git a/tests/unit/core/sync-client-capabilities.test.ts b/tests/unit/core/sync-client-capabilities.test.ts new file mode 100644 index 00000000..34d5fe02 --- /dev/null +++ b/tests/unit/core/sync-client-capabilities.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { copyPluginToWorkspace } from '../../../src/core/transform.js'; + +async function createPluginFixture(pluginDir: string): Promise { + await Promise.all([ + mkdir(join(pluginDir, 'skills', 'review'), { recursive: true }), + mkdir(join(pluginDir, 'commands'), { recursive: true }), + mkdir(join(pluginDir, 'agents'), { recursive: true }), + mkdir(join(pluginDir, 'hooks'), { recursive: true }), + mkdir(join(pluginDir, '.github', 'prompts'), { recursive: true }), + ]); + await Promise.all([ + writeFile(join(pluginDir, 'skills', 'review', 'SKILL.md'), '# Review'), + writeFile(join(pluginDir, 'commands', 'review.md'), '# Command'), + writeFile(join(pluginDir, 'agents', 'review.md'), '# Agent'), + writeFile(join(pluginDir, 'hooks', 'review.js'), 'export default {}'), + writeFile(join(pluginDir, '.github', 'prompts', 'review.prompt.md'), '# Prompt'), + ]); +} + +describe('client capability-aware file sync', () => { + let testDir: string; + let pluginDir: string; + let workspaceDir: string; + + beforeEach(async () => { + testDir = await mkdtemp(join(tmpdir(), 'allagents-client-capabilities-')); + pluginDir = join(testDir, 'plugin'); + workspaceDir = join(testDir, 'workspace'); + await Promise.all([ + mkdir(pluginDir, { recursive: true }), + mkdir(workspaceDir, { recursive: true }), + ]); + await createPluginFixture(pluginDir); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + it('keeps every rich Claude artifact destination', async () => { + await copyPluginToWorkspace(pluginDir, workspaceDir, 'claude'); + + expect(existsSync(join(workspaceDir, '.claude', 'skills', 'review', 'SKILL.md'))).toBe(true); + expect(existsSync(join(workspaceDir, '.claude', 'commands', 'review.md'))).toBe(true); + expect(existsSync(join(workspaceDir, '.claude', 'agents', 'review.md'))).toBe(true); + expect(existsSync(join(workspaceDir, '.claude', 'hooks', 'review.js'))).toBe(true); + }); + + it('syncs a new universal-path client without fabricating other artifacts', async () => { + await copyPluginToWorkspace(pluginDir, workspaceDir, 'warp'); + + expect(existsSync(join(workspaceDir, '.agents', 'skills', 'review', 'SKILL.md'))).toBe(true); + expect(existsSync(join(workspaceDir, 'AGENTS.md'))).toBe(false); + expect(existsSync(join(workspaceDir, 'commands'))).toBe(false); + expect(existsSync(join(workspaceDir, 'agents'))).toBe(false); + expect(existsSync(join(workspaceDir, 'hooks'))).toBe(false); + expect(existsSync(join(workspaceDir, '.github'))).toBe(false); + }); + + it('syncs a provider-specific client only to its evidenced skill path', async () => { + await copyPluginToWorkspace(pluginDir, workspaceDir, 'aider-desk'); + + expect(existsSync(join(workspaceDir, '.aider-desk', 'skills', 'review', 'SKILL.md'))).toBe(true); + expect(existsSync(join(workspaceDir, 'AGENTS.md'))).toBe(false); + }); +}); diff --git a/tests/unit/core/sync-dedup.test.ts b/tests/unit/core/sync-dedup.test.ts index 20d61e84..7afd71c0 100644 --- a/tests/unit/core/sync-dedup.test.ts +++ b/tests/unit/core/sync-dedup.test.ts @@ -23,115 +23,65 @@ import { import type { SyncState } from '../../../src/models/sync-state.js'; describe('deduplicateClientsByPath', () => { - it('should group clients that share the same skillsPath after resolution', () => { - // After resolution, copilot and vscode both use .github/skills/ - const clients = ['copilot', 'vscode'] as const; + it('groups clients that share a resolved project skill path', () => { + const clients = ['copilot', 'vscode', 'codex'] as const; const resolvedMappings = resolveClientMappings([...clients], CLIENT_MAPPINGS); const result = deduplicateClientsByPath([...clients], resolvedMappings); - // Should have only one representative client - expect(result.representativeClients).toHaveLength(1); - expect(result.representativeClients[0]).toBe('copilot'); - - // The group should contain both clients - const group = result.clientGroups.get('copilot'); - expect(group).toBeDefined(); - expect(group).toHaveLength(2); - expect(group).toContain('copilot'); - expect(group).toContain('vscode'); + expect(result.representativeClients).toEqual(['copilot', 'codex']); + expect(result.clientGroups.get('copilot')).toEqual([ + 'copilot', + 'vscode', + ]); + expect(result.clientGroups.get('codex')).toEqual(['codex']); }); - it('should keep clients with different skillsPaths separate', () => { - // claude uses .claude/skills/, cursor uses .cursor/skills/, codex uses .codex/skills/ - const clients = ['claude', 'cursor', 'codex'] as const; + it('keeps provider-specific project skill paths separate', () => { + const clients = ['claude', 'pi', 'roo'] as const; const result = deduplicateClientsByPath([...clients], CLIENT_MAPPINGS); - expect(result.representativeClients).toHaveLength(3); - expect(result.representativeClients).toContain('claude'); - expect(result.representativeClients).toContain('cursor'); - expect(result.representativeClients).toContain('codex'); - - // Each group should have only one client + expect(result.representativeClients).toEqual(['claude', 'pi', 'roo']); expect(result.clientGroups.get('claude')).toEqual(['claude']); - expect(result.clientGroups.get('cursor')).toEqual(['cursor']); - expect(result.clientGroups.get('codex')).toEqual(['codex']); + expect(result.clientGroups.get('pi')).toEqual(['pi']); + expect(result.clientGroups.get('roo')).toEqual(['roo']); }); - it('should handle mixed unique and shared paths after resolution', () => { - // claude (unique .claude/skills/), copilot+vscode (shared .github/skills/ after resolution), codex (unique .codex/skills/) + it('handles mixed provider-specific and shared project paths', () => { const clients = ['claude', 'copilot', 'vscode', 'codex'] as const; const resolvedMappings = resolveClientMappings([...clients], CLIENT_MAPPINGS); const result = deduplicateClientsByPath([...clients], resolvedMappings); - // Should have 3 representative clients - expect(result.representativeClients).toHaveLength(3); - expect(result.representativeClients).toContain('claude'); - expect(result.representativeClients).toContain('codex'); - // copilot should be representative for the shared group - expect(result.representativeClients).toContain('copilot'); - - // copilot group should have both copilot and vscode - const copilotGroup = result.clientGroups.get('copilot'); - expect(copilotGroup).toHaveLength(2); - expect(copilotGroup).toContain('copilot'); - expect(copilotGroup).toContain('vscode'); + expect(result.representativeClients).toEqual([ + 'claude', + 'copilot', + 'codex', + ]); + expect(result.clientGroups.get('copilot')).toEqual([ + 'copilot', + 'vscode', + ]); }); - it('should work with USER_CLIENT_MAPPINGS', () => { - // copilot uses .copilot/skills/, codex uses .codex/skills/, opencode uses .opencode/skills/ + it('uses distinct declared user destinations', () => { const clients = ['copilot', 'codex', 'opencode'] as const; const result = deduplicateClientsByPath([...clients], USER_CLIENT_MAPPINGS); - // All three have different user-level paths, so no grouping - expect(result.representativeClients).toHaveLength(3); - expect(result.clientGroups.get('copilot')).toEqual(['copilot']); - expect(result.clientGroups.get('codex')).toEqual(['codex']); - expect(result.clientGroups.get('opencode')).toEqual(['opencode']); - }); - - it('should handle empty clients array', () => { - const result = deduplicateClientsByPath([], CLIENT_MAPPINGS); - - expect(result.representativeClients).toHaveLength(0); - expect(result.clientGroups.size).toBe(0); - }); - - it('should handle single client', () => { - const result = deduplicateClientsByPath(['claude'], CLIENT_MAPPINGS); - - expect(result.representativeClients).toHaveLength(1); - expect(result.representativeClients[0]).toBe('claude'); - expect(result.clientGroups.get('claude')).toEqual(['claude']); - }); - - it('should group vscode with copilot after resolution', () => { - const clients = ['copilot', 'vscode', 'codex'] as const; - const resolvedMappings = resolveClientMappings([...clients], CLIENT_MAPPINGS); - const result = deduplicateClientsByPath([...clients], resolvedMappings); - - // After resolution, copilot and vscode share .github/skills/, codex uses .codex/skills/ - expect(result.representativeClients).toHaveLength(2); - expect(result.representativeClients).toContain('copilot'); - expect(result.representativeClients).toContain('codex'); - - const copilotGroup = result.clientGroups.get('copilot'); - expect(copilotGroup).toHaveLength(2); - expect(copilotGroup).toContain('copilot'); - expect(copilotGroup).toContain('vscode'); - - const codexGroup = result.clientGroups.get('codex'); - expect(codexGroup).toHaveLength(1); - expect(codexGroup).toContain('codex'); + expect(result.representativeClients).toEqual([ + 'copilot', + 'codex', + 'opencode', + ]); }); - it('should not group vscode with copilot in unresolved CLIENT_MAPPINGS', () => { - const result = deduplicateClientsByPath(['copilot', 'vscode'], CLIENT_MAPPINGS); - // Without resolution, vscode uses .agents/skills/ and copilot uses .github/skills/ - expect(result.representativeClients).toHaveLength(2); - expect(result.representativeClients).toContain('copilot'); - expect(result.representativeClients).toContain('vscode'); + it('handles empty and single-client inputs', () => { + expect( + deduplicateClientsByPath([], CLIENT_MAPPINGS).representativeClients, + ).toEqual([]); + expect( + deduplicateClientsByPath(['claude'], CLIENT_MAPPINGS) + .representativeClients, + ).toEqual(['claude']); }); -}); it('keeps Pi and OMP materialization distinct from shared discovery paths', () => { const clients = ['pi', 'omp', 'universal'] as const; @@ -144,9 +94,22 @@ describe('deduplicateClientsByPath', () => { ]); }); + it('chooses universal when a shared-path client is declared first', () => { + const result = deduplicateClientsByPath( + ['warp', 'universal'], + CLIENT_MAPPINGS, + ); + + expect(result.representativeClients).toEqual(['universal']); + expect(result.clientGroups.get('universal')).toEqual([ + 'warp', + 'universal', + ]); + }); +}); + describe('collectSyncedPaths with shared paths', () => { - it('should track file for all clients sharing the same skillsPath after resolution', () => { - // After resolution, copilot and vscode both use .github/skills/ + it('tracks one shared skill for every client using its destination', () => { const copyResults: CopyResult[] = [ { source: '/some/plugin/skills/my-skill', @@ -154,17 +117,21 @@ describe('collectSyncedPaths with shared paths', () => { action: 'copied', }, ]; - const clients = ['copilot', 'vscode'] as const; const resolvedMappings = resolveClientMappings([...clients], CLIENT_MAPPINGS); - const result = collectSyncedPaths(copyResults, '/workspace', [...clients], resolvedMappings); + const result = collectSyncedPaths( + copyResults, + '/workspace', + [...clients], + resolvedMappings, + ); - // Both clients should track the same skill - expect(result.copilot).toContain('.github/skills/my-skill/'); - expect(result.vscode).toContain('.github/skills/my-skill/'); + for (const client of clients) { + expect(result[client]).toContain('.github/skills/my-skill/'); + } }); - it('should track files correctly when clients have different paths', () => { + it('tracks provider-specific and shared destinations independently', () => { const copyResults: CopyResult[] = [ { source: '/some/plugin/skills/skill1', @@ -177,17 +144,15 @@ describe('collectSyncedPaths with shared paths', () => { action: 'copied', }, ]; + const result = collectSyncedPaths( + copyResults, + '/workspace', + ['claude', 'copilot'], + CLIENT_MAPPINGS, + ); - const clients = ['claude', 'copilot'] as const; - const result = collectSyncedPaths(copyResults, '/workspace', [...clients], CLIENT_MAPPINGS); - - // claude should only track .claude/skills/skill1 - expect(result.claude).toContain('.claude/skills/skill1/'); - expect(result.claude).not.toContain('.github/skills/skill2/'); - - // copilot should only track .github/skills/skill2 - expect(result.copilot).toContain('.github/skills/skill2/'); - expect(result.copilot).not.toContain('.claude/skills/skill1/'); + expect(result.claude).toEqual(['.claude/skills/skill1/']); + expect(result.copilot).toEqual(['.github/skills/skill2/']); }); }); @@ -295,10 +260,10 @@ description: A test skill return pluginDir; } - it('should copy skill only once when multiple clients share .github/skills/', async () => { + it('should copy a shared .github skill only once', async () => { const pluginDir = await createPluginWithSkill('my-plugin', 'test-skill'); - // Setup workspace config with clients that share .github/skills/ + // Copilot and VS Code share Copilot's project skill destination. await mkdir(join(testDir, CONFIG_DIR), { recursive: true }); await writeFile( join(testDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE), @@ -318,7 +283,7 @@ clients: // Should only copy once (not 2 times) expect(result.totalCopied).toBe(1); - // Skill should exist in .github/skills/ + // Skill should exist in the shared destination. expect(existsSync(join(testDir, '.github', 'skills', 'test-skill', 'SKILL.md'))).toBe(true); // Verify sync state tracks the skill for both clients @@ -342,8 +307,8 @@ plugins: - ${pluginDir} clients: - claude - - cursor - - copilot + - pi + - roo `, ); @@ -355,8 +320,8 @@ clients: // Skills should exist in each client's directory expect(existsSync(join(testDir, '.claude', 'skills', 'test-skill', 'SKILL.md'))).toBe(true); - expect(existsSync(join(testDir, '.cursor', 'skills', 'test-skill', 'SKILL.md'))).toBe(true); - expect(existsSync(join(testDir, '.github', 'skills', 'test-skill', 'SKILL.md'))).toBe(true); + expect(existsSync(join(testDir, '.pi', 'skills', 'test-skill', 'SKILL.md'))).toBe(true); + expect(existsSync(join(testDir, '.roo', 'skills', 'test-skill', 'SKILL.md'))).toBe(true); }); it('materializes Pi native and universal shared skills exactly once each', async () => { @@ -387,10 +352,33 @@ syncMode: copy ).toBe(true); }); + it('materializes universal skills when a shared-path client comes first', async () => { + const pluginDir = await createPluginWithSkill('my-plugin', 'test-skill'); + await mkdir(join(testDir, CONFIG_DIR), { recursive: true }); + await writeFile( + join(testDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE), + ` +repositories: [] +plugins: + - ${pluginDir} +clients: + - warp + - universal +`, + ); + + const result = await syncWorkspace(testDir); + + expect(result.success).toBe(true); + expect( + existsSync(join(testDir, '.agents', 'skills', 'test-skill', 'SKILL.md')), + ).toBe(true); + }); + it('should properly purge when a client sharing path is removed', async () => { const pluginDir = await createPluginWithSkill('my-plugin', 'test-skill'); - // First sync with copilot and vscode (both share .github/skills/) + // First sync with Copilot and VS Code on their shared destination. await mkdir(join(testDir, CONFIG_DIR), { recursive: true }); await writeFile( join(testDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE), @@ -436,7 +424,7 @@ clients: it('should purge shared path when all clients using it are removed', async () => { const pluginDir = await createPluginWithSkill('my-plugin', 'test-skill'); - // First sync with copilot and vscode (using copy mode for predictable behavior) + // First sync with Copilot and VS Code in copy mode. await mkdir(join(testDir, CONFIG_DIR), { recursive: true }); await writeFile( join(testDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE), @@ -469,7 +457,7 @@ syncMode: copy await syncWorkspace(testDir); - // .github/skills/test-skill should be purged + // The shared skill should be purged once no configured client owns it. expect(existsSync(join(testDir, '.github', 'skills', 'test-skill'))).toBe(false); // .claude/skills/test-skill should exist @@ -520,7 +508,7 @@ clients: expect(existsSync(join(testDir, '.github', 'skills', 'test-skill', 'SKILL.md'))).toBe(false); }); - it('should place skills in .github/ when copilot and vscode are both configured', async () => { + it('should keep Copilot and VS Code skills in .github/', async () => { const pluginDir = await createPluginWithSkill('my-plugin', 'test-skill'); await mkdir(join(testDir, CONFIG_DIR), { recursive: true }); @@ -544,7 +532,7 @@ syncMode: copy expect(result.totalCopied).toBe(1); }); - it('should place skills in .agents/ with .github symlink when universal + copilot + vscode', async () => { + it('should share universal content with Copilot and VS Code', async () => { const pluginDir = await createPluginWithSkill('my-plugin', 'test-skill'); await mkdir(join(testDir, CONFIG_DIR), { recursive: true }); @@ -563,9 +551,7 @@ clients: const result = await syncWorkspace(testDir); expect(result.success).toBe(true); - // Canonical in .agents expect(existsSync(join(testDir, '.agents', 'skills', 'test-skill', 'SKILL.md'))).toBe(true); - // .github should exist (symlink or copy from copilot+vscode) expect(existsSync(join(testDir, '.github', 'skills', 'test-skill', 'SKILL.md'))).toBe(true); }); diff --git a/tests/unit/core/sync-install-mode.test.ts b/tests/unit/core/sync-install-mode.test.ts index fd17cc83..6fe23b37 100644 --- a/tests/unit/core/sync-install-mode.test.ts +++ b/tests/unit/core/sync-install-mode.test.ts @@ -4,10 +4,12 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { + buildPluginSyncPlans, nativeContextIdentity, nativeIdentityMatches, syncWorkspace, } from '../../../src/core/sync.js'; +import { UserWorkspaceConfigSchema } from '../../../src/models/workspace-config.js'; import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../../../src/constants.js'; async function createPlugin(baseDir: string, name: string, skillName: string): Promise { @@ -105,6 +107,26 @@ describe('syncWorkspace — install mode', () => { expect(existsSync(join(testDir, '.claude', 'skills', 'test-skill'))).toBe(false); expect(existsSync(join(testDir, '.github', 'skills', 'test-skill'))).toBe(false); }); + + it('plans Codex native installation through the ordinary user sync path', () => { + const config = UserWorkspaceConfigSchema.parse({ + repositories: [], + plugins: [], + clients: ['codex:native'], + }); + const result = buildPluginSyncPlans( + [{ source: 'demo@tools' }], + config.clients, + 'user', + ); + + expect(result.errors).toEqual([]); + expect(result.plans).toHaveLength(1); + expect(result.plans[0]).toMatchObject({ + clients: [], + nativeClients: ['codex'], + }); + }); }); describe('native lifecycle identity', () => { diff --git a/tests/unit/core/user-workspace.test.ts b/tests/unit/core/user-workspace.test.ts index 61f18f8c..3b25628f 100644 --- a/tests/unit/core/user-workspace.test.ts +++ b/tests/unit/core/user-workspace.test.ts @@ -111,6 +111,37 @@ describe('user-workspace', () => { expect(config!.clients).toBeInstanceOf(Array); }); + test('returns canonical deduplicated clients from alias declarations', async () => { + const configPath = getUserWorkspaceConfigPath(); + await mkdir(join(tempHome, '.allagents'), { recursive: true }); + await writeFile( + configPath, + 'clients:\n - claude-code\n - claude\n - droid\n', + 'utf-8', + ); + + const config = await getUserWorkspaceConfig(); + + expect(config?.clients).toEqual(['claude', 'factory']); + }); + + test('canonicalizes aliases in install shorthands and object entries', async () => { + const configPath = getUserWorkspaceConfigPath(); + await mkdir(join(tempHome, '.allagents'), { recursive: true }); + await writeFile( + configPath, + 'clients:\n - claude-code:native\n - name: droid\n', + 'utf-8', + ); + + const config = await getUserWorkspaceConfig(); + + expect(config?.clients).toEqual([ + { name: 'claude', install: 'native' }, + { name: 'factory', install: 'file' }, + ]); + }); + test('accepts profiles-only config and defaults ordinary arrays', async () => { const configPath = getUserWorkspaceConfigPath(); await mkdir(join(tempHome, '.allagents'), { recursive: true }); diff --git a/tests/unit/models/client-entry-schema.test.ts b/tests/unit/models/client-entry-schema.test.ts index a36a2baf..b0ff6679 100644 --- a/tests/unit/models/client-entry-schema.test.ts +++ b/tests/unit/models/client-entry-schema.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from 'bun:test'; -import { ClientEntrySchema, WorkspaceConfigSchema, normalizeClientEntry } from '../../../src/models/workspace-config.js'; +import { + ClientEntrySchema, + UserClientTypeSchema, + UserWorkspaceConfigSchema, + WorkspaceConfigSchema, + getClientTypes, + normalizeClientEntry, +} from '../../../src/models/workspace-config.js'; describe('ClientEntrySchema', () => { describe('existing behavior', () => { @@ -57,6 +64,100 @@ describe('ClientEntrySchema', () => { }); }); + it('canonicalizes every public alias in bare, shorthand, and object forms', () => { + const aliases = { + 'claude-code': 'claude', + 'github-copilot': 'copilot', + 'gemini-cli': 'gemini', + droid: 'factory', + amp: 'ampcode', + 'kiro-cli': 'kiro', + 'kimi-code-cli': 'kimi', + } as const; + + for (const [alias, canonical] of Object.entries(aliases)) { + expect(ClientEntrySchema.parse(alias)).toBe(canonical); + expect(ClientEntrySchema.parse(`${alias}:file`)).toEqual({ + name: canonical, + install: 'file', + }); + expect(ClientEntrySchema.parse({ name: alias })).toEqual({ + name: canonical, + install: 'file', + }); + } + }); + + it('deduplicates aliases and canonical IDs before sync consumers see them', () => { + const config = WorkspaceConfigSchema.parse({ + repositories: [], + plugins: [ + { + source: 'owner/plugin', + clients: ['claude-code', 'claude', 'droid', 'factory'], + }, + ], + clients: ['claude-code', 'claude', 'droid', 'factory'], + mcpServers: { + example: { + command: 'example-mcp', + clients: ['github-copilot', 'copilot'], + }, + }, + }); + + expect(getClientTypes(config.clients)).toEqual(['claude', 'factory']); + expect(config.plugins[0]).toMatchObject({ + clients: ['claude', 'factory'], + }); + expect(config.mcpServers?.example?.clients).toEqual(['copilot']); + }); + + it('distinguishes project-only clients from unknown user-scope inputs', () => { + expect(() => + UserWorkspaceConfigSchema.parse({ + repositories: [], + plugins: [], + clients: ['eve'], + }), + ).toThrow("Client 'eve' does not support user scope"); + expect(() => UserClientTypeSchema.parse('missing-client')).toThrow( + "Unknown client 'missing-client'", + ); + }); + + it('applies user scope and alias normalization to nested selectors', () => { + expect( + UserWorkspaceConfigSchema.safeParse({ + plugins: [{ source: 'owner/plugin', clients: ['eve'] }], + }).success, + ).toBe(false); + expect( + UserWorkspaceConfigSchema.safeParse({ + mcpServers: { + example: { command: 'example-mcp', clients: ['eve'] }, + }, + }).success, + ).toBe(false); + + const config = UserWorkspaceConfigSchema.parse({ + plugins: [ + { + source: 'owner/plugin', + clients: ['github-copilot', 'copilot'], + }, + ], + mcpServers: { + example: { + command: 'example-mcp', + clients: ['claude-code', 'claude'], + }, + }, + }); + expect(config.plugins[0]).toMatchObject({ clients: ['copilot'] }); + expect(config.mcpServers?.example?.clients).toEqual(['claude']); + }); + describe('colon shorthand', () => { it('parses claude:native to object', () => { expect(ClientEntrySchema.parse('claude:native')).toEqual({ @@ -95,6 +196,73 @@ describe('ClientEntrySchema', () => { it('rejects uppercase (case-sensitive)', () => { expect(() => ClientEntrySchema.parse('CLAUDE:NATIVE')).toThrow(); }); + + it('accepts the complete project native client and alias set', () => { + for (const { input, canonical } of [ + { input: 'claude', canonical: 'claude' }, + { input: 'pi', canonical: 'pi' }, + { input: 'omp', canonical: 'omp' }, + { input: 'claude-code', canonical: 'claude' }, + ] as const) { + const expected = { name: canonical, install: 'native' }; + expect(ClientEntrySchema.parse(`${input}:native`)).toEqual(expected); + expect( + ClientEntrySchema.parse({ name: input, install: 'native' }), + ).toEqual(expected); + } + }); + + it('accepts the complete user native client and alias set', () => { + for (const { input, canonical } of [ + { input: 'claude', canonical: 'claude' }, + { input: 'copilot', canonical: 'copilot' }, + { input: 'codex', canonical: 'codex' }, + { input: 'pi', canonical: 'pi' }, + { input: 'omp', canonical: 'omp' }, + { input: 'claude-code', canonical: 'claude' }, + { input: 'github-copilot', canonical: 'copilot' }, + ] as const) { + for (const entry of [ + `${input}:native`, + { name: input, install: 'native' }, + ]) { + expect( + UserWorkspaceConfigSchema.parse({ + repositories: [], + plugins: [], + clients: [entry], + }).clients, + ).toEqual([{ name: canonical, install: 'native' }]); + } + } + }); + + it('rejects native mode outside each scope capability set', () => { + for (const input of [ + 'copilot', + 'codex', + 'github-copilot', + 'cursor', + ]) { + expect(() => ClientEntrySchema.parse(`${input}:native`)).toThrow(); + expect(() => + ClientEntrySchema.parse({ name: input, install: 'native' }), + ).toThrow(); + } + + for (const entry of [ + 'cursor:native', + { name: 'cursor', install: 'native' }, + ]) { + expect(() => + UserWorkspaceConfigSchema.parse({ + repositories: [], + plugins: [], + clients: [entry], + }), + ).toThrow(); + } + }); }); describe('full WorkspaceConfigSchema with mixed client formats', () => { diff --git a/tests/unit/models/client-mapping.test.ts b/tests/unit/models/client-mapping.test.ts index 6e7e1d7d..d4036823 100644 --- a/tests/unit/models/client-mapping.test.ts +++ b/tests/unit/models/client-mapping.test.ts @@ -1,331 +1,307 @@ -import { describe, expect, it, test } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; import { AGENT_HOSTS, + CLIENT_ALIASES, CLIENT_MAPPINGS, + CLIENT_TYPES, USER_CLIENT_MAPPINGS, + canonicalizeClientId, + clientIdsForScope, findHostById, + getDisplayName, getMapping, + mcpClientIdsForScope, resolveClientMappings, - getDisplayName, uniqueProjectSkillsPaths, } from '../../../src/models/client-mapping.js'; import { ClientTypeSchema } from '../../../src/models/workspace-config.js'; -describe('CLIENT_MAPPINGS', () => { - test('defines project-level paths for all supported clients', () => { - const expectedClients = [ - 'claude', - 'copilot', - 'codex', - 'pi', - 'omp', - 'cursor', - 'opencode', - 'gemini', - 'factory', - 'ampcode', - 'vscode', - 'openclaw', - 'windsurf', - 'cline', - 'continue', - 'roo', - 'kilo', - 'trae', - 'augment', - 'zencoder', - 'junie', - 'openhands', - 'kiro', - 'replit', - 'kimi', - 'universal', - ]; - for (const client of expectedClients) { - expect(CLIENT_MAPPINGS).toHaveProperty(client); +const SKILLS_1_7_DESTINATION_IDS = [ + 'aider-desk', + 'amp', + 'antigravity', + 'antigravity-cli', + 'astrbot', + 'autohand-code', + 'augment', + 'bob', + 'claude-code', + 'openclaw', + 'cline', + 'codearts-agent', + 'codebuddy', + 'codemaker', + 'codestudio', + 'codex', + 'command-code', + 'continue', + 'cortex', + 'crush', + 'cursor', + 'deepagents', + 'devin', + 'dexto', + 'droid', + 'eve', + 'firebender', + 'forgecode', + 'fx', + 'gemini-cli', + 'github-copilot', + 'goose', + 'grok', + 'hermes-agent', + 'inference-sh', + 'iflow-cli', + 'jazz', + 'junie', + 'kilo', + 'kimchi', + 'kimi-code-cli', + 'kiro-cli', + 'kode', + 'lingma', + 'loaf', + 'mcpjam', + 'minimax-code', + 'mistral-vibe', + 'moxby', + 'mux', + 'neovate', + 'opencode', + 'openhands', + 'ona', + 'pi', + 'posit-assistant', + 'qoder', + 'qoder-cn', + 'qwen-code', + 'replit', + 'reasonix', + 'roo', + 'rovodev', + 'sarvam-code', + 'tabnine-cli', + 'terramind', + 'tinycloud', + 'trae', + 'trae-cn', + 'warp', + 'windsurf', + 'zed', + 'zcode', + 'zencoder', + 'zenflow', + 'pochi', + 'promptscript', + 'adal', + 'universal', +] as const; + +const SKILLS_1_7_EXCLUSIONS = { + astrbot: 'messaging chatbot platform', + 'inference-sh': 'general agent application platform', + loaf: 'coding-agent workflow harness', + mcpjam: 'MCP testing and evaluation platform', + promptscript: 'agent configuration compiler', + tinycloud: 'video and image agent', +} as const; + +describe('canonical client registry', () => { + it('defines every canonical identity exactly once and derives the input schema', () => { + const hostIds = AGENT_HOSTS.map((host) => host.id); + expect(new Set(hostIds).size).toBe(AGENT_HOSTS.length); + expect(hostIds).toEqual(CLIENT_TYPES); + expect(CLIENT_TYPES).toHaveLength(75); + + for (const client of CLIENT_TYPES) { + expect(ClientTypeSchema.parse(client)).toBe(client); + expect(CLIENT_MAPPINGS[client]).toBe(AGENT_HOSTS.find((host) => host.id === client)?.project); } }); - test('claude uses provider-specific .claude/skills/ path', () => { - expect(CLIENT_MAPPINGS.claude.skillsPath).toBe('.claude/skills/'); - expect(CLIENT_MAPPINGS.claude.commandsPath).toBe('.claude/commands/'); - expect(CLIENT_MAPPINGS.claude.hooksPath).toBe('.claude/hooks/'); - expect(CLIENT_MAPPINGS.claude.agentsPath).toBe('.claude/agents/'); - }); - - test('cursor uses provider-specific .cursor/skills/ path', () => { - expect(CLIENT_MAPPINGS.cursor.skillsPath).toBe('.cursor/skills/'); - }); - - test('factory uses provider-specific .factory/skills/ path', () => { - expect(CLIENT_MAPPINGS.factory.skillsPath).toBe('.factory/skills/'); - expect(CLIENT_MAPPINGS.factory.hooksPath).toBe('.factory/hooks/'); - }); - - test('copilot uses provider-specific .github/skills/ path', () => { - expect(CLIENT_MAPPINGS.copilot.skillsPath).toBe('.github/skills/'); - }); - - test('codex uses provider-specific .codex/skills/ path', () => { - expect(CLIENT_MAPPINGS.codex.skillsPath).toBe('.codex/skills/'); - }); - - test('pi and OMP use native project skill paths', () => { - expect(CLIENT_MAPPINGS.pi.skillsPath).toBe('.pi/skills/'); - expect(CLIENT_MAPPINGS.omp.skillsPath).toBe('.omp/skills/'); - expect(CLIENT_MAPPINGS.omp.hooksPath).toBe('.omp/hooks/'); - }); - - test('opencode uses provider-specific .opencode/skills/ path', () => { - expect(CLIENT_MAPPINGS.opencode.skillsPath).toBe('.opencode/skills/'); - }); - - test('gemini uses provider-specific .gemini/skills/ path', () => { - expect(CLIENT_MAPPINGS.gemini.skillsPath).toBe('.gemini/skills/'); - }); - - test('ampcode uses provider-specific .ampcode/skills/ path', () => { - expect(CLIENT_MAPPINGS.ampcode.skillsPath).toBe('.ampcode/skills/'); - }); - - test('vscode defaults to .agents/skills/ path', () => { - expect(CLIENT_MAPPINGS.vscode.skillsPath).toBe('.agents/skills/'); - expect(CLIENT_MAPPINGS.vscode.githubPath).toBeUndefined(); - }); - - test('openclaw uses root-level skills/ path (no dot prefix)', () => { - expect(CLIENT_MAPPINGS.openclaw.skillsPath).toBe('skills/'); - }); - - test('windsurf uses provider-specific .windsurf/skills/ path', () => { - expect(CLIENT_MAPPINGS.windsurf.skillsPath).toBe('.windsurf/skills/'); - }); - - test('cline uses provider-specific .cline/skills/ path', () => { - expect(CLIENT_MAPPINGS.cline.skillsPath).toBe('.cline/skills/'); - }); - - test('continue uses provider-specific .continue/skills/ path', () => { - expect(CLIENT_MAPPINGS.continue.skillsPath).toBe('.continue/skills/'); - }); - - test('roo uses provider-specific .roo/skills/ path', () => { - expect(CLIENT_MAPPINGS.roo.skillsPath).toBe('.roo/skills/'); - }); - - test('kilo uses provider-specific .kilocode/skills/ path', () => { - expect(CLIENT_MAPPINGS.kilo.skillsPath).toBe('.kilocode/skills/'); - }); - - test('replit uses provider-specific .replit/skills/ path', () => { - expect(CLIENT_MAPPINGS.replit.skillsPath).toBe('.replit/skills/'); - }); - - test('kimi uses provider-specific .kimi/skills/ path', () => { - expect(CLIENT_MAPPINGS.kimi.skillsPath).toBe('.kimi/skills/'); - }); - - test('universal uses .agents/skills/ path', () => { - expect(CLIENT_MAPPINGS.universal.skillsPath).toBe('.agents/skills/'); - }); + it('accounts for every skills@1.7.0 ID as supported or intentionally excluded', () => { + expect(SKILLS_1_7_DESTINATION_IDS).toHaveLength(79); + expect(CLIENT_ALIASES).toEqual({ + 'claude-code': 'claude', + 'github-copilot': 'copilot', + 'gemini-cli': 'gemini', + droid: 'factory', + amp: 'ampcode', + 'kiro-cli': 'kiro', + 'kimi-code-cli': 'kimi', + }); - test('project paths are relative (no leading /)', () => { - for (const [, mapping] of Object.entries(CLIENT_MAPPINGS)) { - expect(mapping.skillsPath).not.toMatch(/^\//); - if (mapping.commandsPath) expect(mapping.commandsPath).not.toMatch(/^\//); + const excludedIds = Object.keys(SKILLS_1_7_EXCLUSIONS); + const supportedIds = SKILLS_1_7_DESTINATION_IDS.filter( + (id) => !Object.hasOwn(SKILLS_1_7_EXCLUSIONS, id), + ); + expect(supportedIds).toHaveLength(73); + + for (const id of supportedIds) { + const canonical = canonicalizeClientId(id); + expect(canonical).toBeDefined(); + expect(CLIENT_TYPES).toContain(canonical); + expect(ClientTypeSchema.parse(id)).toBe(canonical); } - }); -}); -describe('USER_CLIENT_MAPPINGS', () => { - test('defines user-level paths for all clients in CLIENT_MAPPINGS', () => { - for (const client of Object.keys(CLIENT_MAPPINGS)) { - expect(USER_CLIENT_MAPPINGS).toHaveProperty(client); + for (const id of excludedIds) { + expect(SKILLS_1_7_DESTINATION_IDS).toContain(id); + expect(canonicalizeClientId(id)).toBeUndefined(); + expect(ClientTypeSchema.safeParse(id).success).toBe(false); } }); - test('claude uses ~/.claude/ paths', () => { - expect(USER_CLIENT_MAPPINGS.claude.skillsPath).toBe('.claude/skills/'); - expect(USER_CLIENT_MAPPINGS.claude.commandsPath).toBe('.claude/commands/'); - expect(USER_CLIENT_MAPPINGS.claude.hooksPath).toBe('.claude/hooks/'); - expect(USER_CLIENT_MAPPINGS.claude.agentsPath).toBe('.claude/agents/'); - }); - - test('cursor uses provider-specific ~/.cursor/skills/ path', () => { - expect(USER_CLIENT_MAPPINGS.cursor.skillsPath).toBe('.cursor/skills/'); - }); - - test('factory uses provider-specific ~/.factory/skills/ path', () => { - expect(USER_CLIENT_MAPPINGS.factory.skillsPath).toBe('.factory/skills/'); - expect(USER_CLIENT_MAPPINGS.factory.hooksPath).toBe('.factory/hooks/'); - }); - - test('copilot uses provider-specific ~/.copilot/skills/ path', () => { - expect(USER_CLIENT_MAPPINGS.copilot.skillsPath).toBe('.copilot/skills/'); - }); - - test('codex uses provider-specific ~/.codex/skills/ path', () => { - expect(USER_CLIENT_MAPPINGS.codex.skillsPath).toBe('.codex/skills/'); - }); - - test('pi and OMP use native default user skill paths', () => { - expect(USER_CLIENT_MAPPINGS.pi.skillsPath).toBe('.pi/agent/skills/'); - expect(USER_CLIENT_MAPPINGS.omp.skillsPath).toBe('.omp/agent/skills/'); - expect(USER_CLIENT_MAPPINGS.omp.hooksPath).toBe('.omp/agent/hooks/'); - }); - - test('opencode uses provider-specific ~/.opencode/skills/ path', () => { - expect(USER_CLIENT_MAPPINGS.opencode.skillsPath).toBe('.opencode/skills/'); - }); - - test('gemini uses provider-specific ~/.gemini/skills/ path', () => { - expect(USER_CLIENT_MAPPINGS.gemini.skillsPath).toBe('.gemini/skills/'); - }); - - test('ampcode uses provider-specific ~/.ampcode/skills/ path', () => { - expect(USER_CLIENT_MAPPINGS.ampcode.skillsPath).toBe('.ampcode/skills/'); - }); - - test('vscode defaults to .agents/skills/ path', () => { - expect(USER_CLIENT_MAPPINGS.vscode.skillsPath).toBe('.agents/skills/'); - expect(USER_CLIENT_MAPPINGS.vscode.githubPath).toBeUndefined(); - }); - - test('openclaw uses root-level skills/ path', () => { - expect(USER_CLIENT_MAPPINGS.openclaw.skillsPath).toBe('skills/'); - }); - - test('windsurf uses ~/.codeium/windsurf/skills/ path', () => { - expect(USER_CLIENT_MAPPINGS.windsurf.skillsPath).toBe('.codeium/windsurf/skills/'); - }); - - test('cline uses ~/.cline/skills/ path', () => { - expect(USER_CLIENT_MAPPINGS.cline.skillsPath).toBe('.cline/skills/'); - }); - - test('replit uses provider-specific ~/.replit/skills/ path', () => { - expect(USER_CLIENT_MAPPINGS.replit.skillsPath).toBe('.replit/skills/'); - }); - - test('kimi uses provider-specific ~/.kimi/skills/ path', () => { - expect(USER_CLIENT_MAPPINGS.kimi.skillsPath).toBe('.kimi/skills/'); - }); - - test('universal uses ~/.agents/skills/ path', () => { - expect(USER_CLIENT_MAPPINGS.universal.skillsPath).toBe('.agents/skills/'); - }); - - test('user paths are relative to home directory (no leading /)', () => { - for (const [, mapping] of Object.entries(USER_CLIENT_MAPPINGS)) { - expect(mapping.skillsPath).not.toMatch(/^\//); - if (mapping.commandsPath) expect(mapping.commandsPath).not.toMatch(/^\//); + it('keeps aliases out of canonical product counts and resolves host lookups', () => { + for (const [alias, canonical] of Object.entries(CLIENT_ALIASES)) { + expect(CLIENT_TYPES).not.toContain(alias); + expect(findHostById(alias)?.id).toBe(canonical); } + expect(findHostById('not-a-client')).toBeUndefined(); }); }); -describe('resolveClientMappings', () => { - describe('project-level (CLIENT_MAPPINGS)', () => { - it('should default vscode to .agents/skills/ when no copilot', () => { - const resolved = resolveClientMappings(['vscode'], CLIENT_MAPPINGS); - expect(resolved.vscode.skillsPath).toBe('.agents/skills/'); - expect(resolved.vscode.githubPath).toBeUndefined(); - }); - - it('should resolve vscode to .github/skills/ when copilot is present', () => { - const resolved = resolveClientMappings(['copilot', 'vscode'], CLIENT_MAPPINGS); - expect(resolved.vscode.skillsPath).toBe('.github/skills/'); - expect(resolved.vscode.githubPath).toBe('.github/'); - }); - - it('should resolve vscode to .github/skills/ when both copilot and universal are present', () => { - const resolved = resolveClientMappings(['universal', 'copilot', 'vscode'], CLIENT_MAPPINGS); - expect(resolved.vscode.skillsPath).toBe('.github/skills/'); - expect(resolved.vscode.githubPath).toBe('.github/'); +describe('capability-aware mappings', () => { + it('preserves Claude Code rich destinations and runtime capabilities', () => { + const claude = findHostById('claude'); + expect(claude?.project).toEqual({ + commandsPath: '.claude/commands/', + skillsPath: '.claude/skills/', + agentsPath: '.claude/agents/', + agentFile: 'CLAUDE.md', + agentFileFallback: 'AGENTS.md', + hooksPath: '.claude/hooks/', }); + expect(claude?.user).toEqual(claude?.project); + expect(claude?.mcp).toEqual({ project: true, user: true }); + }); + + it('preserves every pre-existing AllAgents name and skill destination', () => { + const expected = [ + ['claude', 'Claude Code', '.claude/skills/', '.claude/skills/'], + ['copilot', 'GitHub Copilot', '.github/skills/', '.copilot/skills/'], + ['codex', 'Codex', '.codex/skills/', '.codex/skills/'], + ['pi', 'Pi', '.pi/skills/', '.pi/agent/skills/'], + ['omp', 'OMP', '.omp/skills/', '.omp/agent/skills/'], + ['cursor', 'Cursor', '.cursor/skills/', '.cursor/skills/'], + ['opencode', 'OpenCode', '.opencode/skills/', '.opencode/skills/'], + ['gemini', 'Gemini', '.gemini/skills/', '.gemini/skills/'], + ['factory', 'Factory', '.factory/skills/', '.factory/skills/'], + ['ampcode', 'AmpCode', '.ampcode/skills/', '.ampcode/skills/'], + ['vscode', 'VS Code', '.agents/skills/', '.agents/skills/'], + ['openclaw', 'OpenClaw', 'skills/', 'skills/'], + ['windsurf', 'Windsurf', '.windsurf/skills/', '.codeium/windsurf/skills/'], + ['cline', 'Cline', '.cline/skills/', '.cline/skills/'], + ['continue', 'Continue', '.continue/skills/', '.continue/skills/'], + ['roo', 'Roo Code', '.roo/skills/', '.roo/skills/'], + ['kilo', 'Kilo Code', '.kilocode/skills/', '.kilocode/skills/'], + ['trae', 'Trae', '.trae/skills/', '.trae/skills/'], + ['augment', 'Augment', '.augment/skills/', '.augment/skills/'], + ['zencoder', 'Zencoder', '.zencoder/skills/', '.zencoder/skills/'], + ['junie', 'Junie', '.junie/skills/', '.junie/skills/'], + ['openhands', 'OpenHands', '.openhands/skills/', '.openhands/skills/'], + ['kiro', 'Kiro', '.kiro/skills/', '.kiro/skills/'], + ['replit', 'Replit', '.replit/skills/', '.replit/skills/'], + ['kimi', 'Kimi', '.kimi/skills/', '.kimi/skills/'], + ['universal', 'Universal', '.agents/skills/', '.agents/skills/'], + ] as const; + + for (const [id, name, projectSkillsPath, userSkillsPath] of expected) { + const host = findHostById(id); + expect(host?.name).toBe(name); + expect(host?.project.skillsPath).toBe(projectSkillsPath); + expect(host?.user?.skillsPath).toBe(userSkillsPath); + } - it('should resolve vscode to .agents/skills/ when universal is present but not copilot', () => { - const resolved = resolveClientMappings(['universal', 'vscode'], CLIENT_MAPPINGS); - expect(resolved.vscode.skillsPath).toBe('.agents/skills/'); - expect(resolved.vscode.githubPath).toBeUndefined(); - }); + expect(CLIENT_MAPPINGS.copilot.githubPath).toBe('.github/'); + expect(CLIENT_MAPPINGS.copilot.agentsPath).toBe('.github/agents/'); + expect(CLIENT_MAPPINGS.factory.hooksPath).toBe('.factory/hooks/'); + expect(CLIENT_MAPPINGS.opencode.commandsPath).toBe('.opencode/commands/'); + }); - it('should not modify non-vscode client mappings', () => { - const resolved = resolveClientMappings(['copilot', 'vscode', 'claude'], CLIENT_MAPPINGS); - expect(resolved.copilot).toEqual(CLIENT_MAPPINGS.copilot); - expect(resolved.claude).toEqual(CLIENT_MAPPINGS.claude); + it('represents new universal and provider-specific clients as skills-only', () => { + expect(getMapping('warp', 'project')).toEqual({ skillsPath: '.agents/skills/' }); + expect(getMapping('aider-desk', 'project')).toEqual({ + skillsPath: '.aider-desk/skills/', }); - - it('should return baseMappings unchanged when vscode is not in clients', () => { - const resolved = resolveClientMappings(['copilot', 'claude'], CLIENT_MAPPINGS); - expect(resolved).toBe(CLIENT_MAPPINGS); // same reference + expect(getMapping('aider-desk', 'user')).toEqual({ + skillsPath: '.aider-desk/skills/', }); - }); - - describe('user-level (USER_CLIENT_MAPPINGS)', () => { - it('should default vscode to .agents/skills/ when no copilot', () => { - const resolved = resolveClientMappings(['vscode'], USER_CLIENT_MAPPINGS); - expect(resolved.vscode.skillsPath).toBe('.agents/skills/'); + expect(getMapping('goose', 'project')).toEqual({ + skillsPath: '.agents/skills/', }); - - it('should resolve vscode to .copilot/skills/ when copilot is present', () => { - const resolved = resolveClientMappings(['copilot', 'vscode'], USER_CLIENT_MAPPINGS); - expect(resolved.vscode.skillsPath).toBe('.copilot/skills/'); - expect(resolved.vscode.githubPath).toBe('.copilot/'); + expect(getMapping('goose', 'user')).toEqual({ + skillsPath: '.agents/skills/', }); - }); -}); -describe('getDisplayName', () => { - it('should return copilot for vscode', () => { - expect(getDisplayName('vscode')).toBe('copilot'); - }); - - it('should return the same name for non-aliased clients', () => { - expect(getDisplayName('claude')).toBe('claude'); - expect(getDisplayName('copilot')).toBe('copilot'); - expect(getDisplayName('codex')).toBe('codex'); - }); -}); - -describe('AGENT_HOSTS (single source of truth)', () => { - it('covers every ClientType exactly once', () => { - const ids = new Set(AGENT_HOSTS.map((h) => h.id)); - expect(ids.size).toBe(AGENT_HOSTS.length); // no duplicates - const enumValues = new Set(ClientTypeSchema.options); - expect(ids.size).toBe(enumValues.size); - for (const value of enumValues) { - expect(ids.has(value)).toBe(true); + for (const client of ['warp', 'aider-desk', 'goose'] as const) { + const host = findHostById(client); + expect(host?.project.agentFile).toBeUndefined(); + expect(host?.project.commandsPath).toBeUndefined(); + expect(host?.project.agentsPath).toBeUndefined(); + expect(host?.project.hooksPath).toBeUndefined(); + expect(host?.project.githubPath).toBeUndefined(); + expect(host?.mcp).toBeUndefined(); } + expect(mcpClientIdsForScope('project')).toEqual([ + 'claude', + 'copilot', + 'codex', + 'vscode', + 'universal', + ]); + expect(mcpClientIdsForScope('user')).toEqual([ + 'claude', + 'copilot', + 'codex', + 'vscode', + 'universal', + ]); }); - it('findHostById returns the entry for known clients and undefined otherwise', () => { - expect(findHostById('claude')?.id).toBe('claude'); - // Casting to bypass the strict ClientType for the negative case. - expect(findHostById('nonexistent' as 'claude')).toBeUndefined(); + it('rejects user scope for project-only clients', () => { + expect(clientIdsForScope('project')).toContain('eve'); + expect(clientIdsForScope('user')).not.toContain('eve'); + expect(findHostById('eve')?.user).toBeUndefined(); + expect(USER_CLIENT_MAPPINGS.eve).toBeUndefined(); + expect(() => getMapping('eve', 'user')).toThrow( + "Client 'eve' does not support user scope", + ); }); - it('getMapping yields identical content to the derived legacy records for every (client, scope) pair', () => { - for (const id of ClientTypeSchema.options) { - expect(getMapping(id, 'project')).toEqual(CLIENT_MAPPINGS[id]); - expect(getMapping(id, 'user')).toEqual(USER_CLIENT_MAPPINGS[id]); + it('keeps every mapping relative to its selected root', () => { + for (const mapping of [ + ...Object.values(CLIENT_MAPPINGS), + ...Object.values(USER_CLIENT_MAPPINGS), + ]) { + expect(mapping.skillsPath).not.toMatch(/^\//); + if (mapping.commandsPath) expect(mapping.commandsPath).not.toMatch(/^\//); + if (mapping.agentFile) expect(mapping.agentFile).not.toMatch(/^\//); } }); +}); + +describe('mapping helpers', () => { + it('routes VS Code through Copilot artifacts and legacy skill destination', () => { + const project = resolveClientMappings(['copilot', 'vscode'], CLIENT_MAPPINGS); + expect(project.vscode.skillsPath).toBe('.github/skills/'); + expect(project.vscode.githubPath).toBe('.github/'); - it('preserves intentional windsurf project/user delta', () => { - expect(getMapping('windsurf', 'project').skillsPath).toBe('.windsurf/skills/'); - expect(getMapping('windsurf', 'user').skillsPath).toBe('.codeium/windsurf/skills/'); + const user = resolveClientMappings(['copilot', 'vscode'], USER_CLIENT_MAPPINGS); + expect(user.vscode?.skillsPath).toBe('.copilot/skills/'); + expect(user.vscode?.githubPath).toBe('.copilot/'); }); - it('preserves intentional copilot project/user delta', () => { - expect(getMapping('copilot', 'project').githubPath).toBe('.github/'); - expect(getMapping('copilot', 'user').githubPath).toBe('.copilot/'); + it('leaves mappings unchanged when VS Code and Copilot are not both selected', () => { + expect(resolveClientMappings(['vscode'], CLIENT_MAPPINGS)).toBe(CLIENT_MAPPINGS); + expect(resolveClientMappings(['copilot'], CLIENT_MAPPINGS)).toBe(CLIENT_MAPPINGS); }); - it('uniqueProjectSkillsPaths deduplicates shared paths (universal == vscode)', () => { - const paths = uniqueProjectSkillsPaths(); - // universal and vscode both default to .agents/skills/, so the set is - // smaller than the host count by at least one. - expect(paths.length).toBeLessThan(AGENT_HOSTS.length); - expect(paths).toContain('.agents/skills/'); + it('deduplicates shared project paths and groups VS Code display output with Copilot', () => { + expect(uniqueProjectSkillsPaths()).toContain('.agents/skills/'); + expect(uniqueProjectSkillsPaths().length).toBeLessThan(AGENT_HOSTS.length); + expect(getDisplayName('vscode')).toBe('copilot'); + expect(getDisplayName('claude-code')).toBe('claude'); + expect(getDisplayName('warp')).toBe('warp'); }); }); diff --git a/tests/unit/models/workspace-config-install-mode.test.ts b/tests/unit/models/workspace-config-install-mode.test.ts index a5312df3..8c07b78c 100644 --- a/tests/unit/models/workspace-config-install-mode.test.ts +++ b/tests/unit/models/workspace-config-install-mode.test.ts @@ -141,12 +141,12 @@ describe('WorkspaceConfigSchema with install mode', () => { const config = { repositories: [], plugins: [], - clients: ['claude', { name: 'copilot', install: 'native' }], + clients: ['claude', { name: 'pi', install: 'native' }], }; const result = WorkspaceConfigSchema.safeParse(config); expect(result.success).toBe(true); if (result.success) { - expect(result.data.clients).toEqual(['claude', { name: 'copilot', install: 'native' }]); + expect(result.data.clients).toEqual(['claude', { name: 'pi', install: 'native' }]); } }); diff --git a/tests/unit/models/workspace-config-profiles.test.ts b/tests/unit/models/workspace-config-profiles.test.ts index 53cf98c4..88a440c8 100644 --- a/tests/unit/models/workspace-config-profiles.test.ts +++ b/tests/unit/models/workspace-config-profiles.test.ts @@ -111,6 +111,33 @@ describe('profile workspace declarations', () => { ); }); + it('canonicalizes profile client aliases before applying adapter settings', () => { + const result = UserWorkspaceConfigSchema.parse( + userConfigWithProfile({ + clients: [ + { name: 'claude-code', settings: { model: 'sonnet' } }, + { + name: 'github-copilot', + settings: { model: 'claude-sonnet-4.5' }, + }, + ], + }), + ); + + expect(result.profiles?.research?.clients).toEqual([ + { + name: 'claude', + install: 'file', + settings: { model: 'sonnet' }, + }, + { + name: 'copilot', + install: 'file', + settings: { model: 'claude-sonnet-4.5' }, + }, + ]); + }); + it('keeps ordinary user and project configs backward compatible', () => { expect(UserWorkspaceConfigSchema.safeParse(ordinaryConfig).success).toBe( true, diff --git a/tests/unit/models/workspace-json-schema.test.ts b/tests/unit/models/workspace-json-schema.test.ts index effbc348..d5829f83 100644 --- a/tests/unit/models/workspace-json-schema.test.ts +++ b/tests/unit/models/workspace-json-schema.test.ts @@ -37,7 +37,7 @@ describe('published workspace JSON Schemas', () => { profiles: review: clients: - - name: claude + - name: claude-code launcher: claude-review settings: model: sonnet @@ -56,6 +56,12 @@ profiles: command: review-mcp env: REVIEW_TOKEN: \${REVIEW_TOKEN} +`); + const userWithNativeClients = parsedYaml(` +clients: + - codex:native + - name: github-copilot + install: native `); const projectWorkspace = parsedYaml(` repositories: [] @@ -65,6 +71,12 @@ plugins: clients: - name: claude install: native +`); + const projectWithNativeAlias = parsedYaml(` +repositories: [] +plugins: [] +clients: + - claude-code:native `); const projectWithProfiles = parsedYaml(` repositories: [] @@ -86,6 +98,23 @@ profiles: const userWithInvalidClientShorthand = parsedYaml(` clients: - claude:bogus +`); + const projectWithUnsupportedNativeClient = parsedYaml(` +repositories: [] +plugins: [] +clients: + - cursor:native +`); + const projectWithUserOnlyNativeClient = parsedYaml(` +repositories: [] +plugins: [] +clients: + - codex:native +`); + const userWithUnsupportedNativeClient = parsedYaml(` +clients: + - name: cursor + install: native `); const userWithInvalidProfileName = parsedYaml(` profiles: @@ -93,15 +122,48 @@ profiles: clients: - name: claude `); + const projectWithAliasesAndProjectOnlyClient = parsedYaml(` +repositories: [] +plugins: [] +clients: + - claude-code + - eve +`); + const userWithAliases = parsedYaml(` +clients: + - claude-code + - warp +`); + const userWithProjectOnlyClient = parsedYaml(` +clients: + - eve +`); + const userWithProjectOnlyNestedSelectors = parsedYaml(` +plugins: + - source: owner/plugin + clients: [eve] +mcpServers: + example: + command: example-mcp + clients: [eve] +`); expect(validateUser(userWorkspace)).toBe(true); expect(UserWorkspaceConfigSchema.safeParse(userWorkspace).success).toBe( true, ); + expect(validateUser(userWithNativeClients)).toBe(true); + expect( + UserWorkspaceConfigSchema.safeParse(userWithNativeClients).success, + ).toBe(true); expect(validateProject(projectWorkspace)).toBe(true); expect( ProjectWorkspaceConfigSchema.safeParse(projectWorkspace).success, ).toBe(true); + expect(validateProject(projectWithNativeAlias)).toBe(true); + expect( + ProjectWorkspaceConfigSchema.safeParse(projectWithNativeAlias).success, + ).toBe(true); expect(validateProject(projectWithProfiles)).toBe(false); expect( ProjectWorkspaceConfigSchema.safeParse(projectWithProfiles).success, @@ -115,9 +177,42 @@ profiles: UserWorkspaceConfigSchema.safeParse(userWithInvalidClientShorthand) .success, ).toBe(false); + for (const invalid of [ + projectWithUnsupportedNativeClient, + projectWithUserOnlyNativeClient, + ]) { + expect(validateProject(invalid)).toBe(false); + expect(ProjectWorkspaceConfigSchema.safeParse(invalid).success).toBe( + false, + ); + } + expect(validateUser(userWithUnsupportedNativeClient)).toBe(false); + expect( + UserWorkspaceConfigSchema.safeParse(userWithUnsupportedNativeClient) + .success, + ).toBe(false); expect(validateUser(userWithInvalidProfileName)).toBe(false); expect( UserWorkspaceConfigSchema.safeParse(userWithInvalidProfileName).success, ).toBe(false); + expect(validateProject(projectWithAliasesAndProjectOnlyClient)).toBe(true); + expect( + ProjectWorkspaceConfigSchema.safeParse( + projectWithAliasesAndProjectOnlyClient, + ).success, + ).toBe(true); + expect(validateUser(userWithAliases)).toBe(true); + expect(UserWorkspaceConfigSchema.safeParse(userWithAliases).success).toBe( + true, + ); + expect(validateUser(userWithProjectOnlyClient)).toBe(false); + expect( + UserWorkspaceConfigSchema.safeParse(userWithProjectOnlyClient).success, + ).toBe(false); + expect(validateUser(userWithProjectOnlyNestedSelectors)).toBe(false); + expect( + UserWorkspaceConfigSchema.safeParse(userWithProjectOnlyNestedSelectors) + .success, + ).toBe(false); }); });