diff --git a/README.md b/README.md
index ce829c90..e7fb8432 100644
--- a/README.md
+++ b/README.md
@@ -4,9 +4,9 @@
[](https://opensource.org/licenses/MIT)
[](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:
+
+
+ {Object.entries(CLIENT_ALIASES).map(([alias, canonical]) => (
+ {alias} → {canonical}
+ ))}
+
+
+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.
+
+
+
+
+ | Client ID |
+ Product |
+ Project skills |
+ User skills |
+ Instructions |
+ Commands |
+ Agents |
+ Hooks |
+ GitHub overlays |
+ MCP |
+
+
+
+ {AGENT_HOSTS.map((host) => (
+
+ {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);
});
});
]