diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2438606b..34c5f941 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,7 @@ jobs: with: bun-version-file: package.json - run: bun install --frozen-lockfile --ignore-scripts + - run: bun run schema:check - run: bun run build typecheck: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f337b67..adcd124f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,11 @@ - OpenCode global profiles using additive `OPENCODE_CONFIG` and `OPENCODE_CONFIG_DIR` overrides, file-installed skills and commands, strict settings, MCP serialization, generated launchers, and ownership-safe cleanup. +- Claude Code global profiles using isolated `CLAUDE_CONFIG_DIR` roots, + additive MCP configuration, strict settings, native marketplace/plugin + lifecycle, generated launchers, and ownership-safe cleanup. +- Versioned user and project workspace JSON Schemas generated from the runtime + Zod models, with CI drift enforcement and YAML Language Server setup docs. ## [1.0.0] - 2026-03-13 diff --git a/README.md b/README.md index 81548147..e6dcd32c 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docs](https://img.shields.io/badge/docs-allagents.dev-blue)](https://allagents.dev) -Write AI agent skills once. Sync to 25 clients. Manage across multiple repos. +Write AI agent skills once. Sync across 25+ clients. Manage across multiple repos. -AllAgents keeps your AI tooling (skills, agents, hooks, MCP servers) in one workspace and syncs it to every client your team uses — Claude, Copilot, Cursor, Codex, Gemini, 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 20+ more. ## Quick Start @@ -55,7 +55,7 @@ Tools like `npx skills` and `npx plugins` install skills to one project for one | **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** | 43 agents | 2 (Claude, Cursor) | 25+ clients simultaneously | | **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 | @@ -110,7 +110,7 @@ See the [full CLI reference](https://allagents.dev/docs/reference/cli/) for all ## Supported Clients -**25 AI coding assistants** across two tiers: +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 diff --git a/bun.lock b/bun.lock index d208e73c..8778bd49 100644 --- a/bun.lock +++ b/bun.lock @@ -17,7 +17,7 @@ "micromatch": "^4.0.8", "read-cmd-shim": "^4.0.0", "simple-git": "^3.30.0", - "zod": "^3.22.4", + "zod": "^3.25.28", }, "devDependencies": { "@biomejs/biome": "^1.9.0", @@ -25,8 +25,10 @@ "@types/js-yaml": "^4.0.9", "@types/micromatch": "^4.0.10", "@types/node": "^20.11.5", + "ajv": "8.18.0", "shx": "^0.4.0", "typescript": "^5.3.3", + "zod-to-json-schema": "3.25.2", }, }, }, diff --git a/docs/public/schemas/v1/project-workspace.schema.json b/docs/public/schemas/v1/project-workspace.schema.json new file mode 100644 index 00000000..e3e35307 --- /dev/null +++ b/docs/public/schemas/v1/project-workspace.schema.json @@ -0,0 +1,441 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://allagents.dev/schemas/v1/project-workspace.schema.json", + "title": "AllAgents project workspace", + "description": "Configuration for a project .allagents/workspace.yaml. Global profiles are not accepted.", + "$ref": "#/definitions/AllAgentsProjectWorkspace", + "definitions": { + "AllAgentsProjectWorkspace": { + "type": "object", + "properties": { + "version": { + "type": "number" + }, + "setup": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "run": { + "$ref": "#/definitions/AllAgentsProjectWorkspace/properties/setup/items/anyOf/0" + }, + "platforms": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "aix", + "android", + "darwin", + "freebsd", + "haiku", + "linux", + "openbsd", + "sunos", + "win32", + "cygwin", + "netbsd" + ] + }, + "minItems": 1 + }, + "architectures": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "arm", + "arm64", + "ia32", + "loong64", + "mips", + "mipsel", + "ppc", + "ppc64", + "riscv64", + "s390", + "s390x", + "x64" + ] + }, + "minItems": 1 + } + }, + "required": [ + "run" + ], + "additionalProperties": false + } + ] + } + }, + "workspace": { + "type": "object", + "properties": { + "source": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "source": { + "type": "string" + }, + "dest": { + "type": "string" + } + }, + "additionalProperties": true + } + ] + } + } + }, + "required": [ + "files" + ], + "additionalProperties": true + }, + "repositories": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "name": { + "type": "string" + }, + "source": { + "type": "string" + }, + "repo": { + "type": "string" + }, + "description": { + "type": "string" + }, + "skills": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "managed": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "clone", + "sync" + ] + } + ] + }, + "branch": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": true + } + }, + "plugins": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "source": { + "$ref": "#/definitions/AllAgentsProjectWorkspace/properties/plugins/items/anyOf/0" + }, + "clients": { + "type": "array", + "items": { + "type": "string", + "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" + ] + } + }, + "install": { + "type": "string", + "enum": [ + "file", + "native" + ] + }, + "exclude": { + "type": "array", + "items": { + "type": "string" + } + }, + "skills": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "object", + "properties": { + "exclude": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "exclude" + ], + "additionalProperties": true + } + ] + }, + "ref": { + "type": "string" + } + }, + "required": [ + "source" + ], + "additionalProperties": false + } + ] + } + }, + "clients": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/AllAgentsProjectWorkspace/properties/plugins/items/anyOf/1/properties/clients/items" + }, + { + "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)$" + }, + { + "type": "object", + "properties": { + "name": { + "$ref": "#/definitions/AllAgentsProjectWorkspace/properties/plugins/items/anyOf/1/properties/clients/items" + }, + "install": { + "$ref": "#/definitions/AllAgentsProjectWorkspace/properties/plugins/items/anyOf/1/properties/install", + "default": "file" + } + }, + "required": [ + "name" + ], + "additionalProperties": true + } + ] + } + }, + "vscode": { + "type": "object", + "properties": { + "output": { + "type": "string" + } + }, + "additionalProperties": true + }, + "syncMode": { + "type": "string", + "enum": [ + "symlink", + "copy" + ] + }, + "mcpProxy": { + "type": "object", + "properties": { + "clients": { + "type": "array", + "items": { + "type": "string" + } + }, + "servers": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "proxy": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "proxy" + ], + "additionalProperties": true + } + } + }, + "required": [ + "clients" + ], + "additionalProperties": true + }, + "mcpServers": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "http" + ] + }, + "url": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "clients": { + "type": "array", + "items": { + "$ref": "#/definitions/AllAgentsProjectWorkspace/properties/plugins/items/anyOf/1/properties/clients/items" + } + } + }, + "required": [ + "url" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "stdio" + ] + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "clients": { + "type": "array", + "items": { + "$ref": "#/definitions/AllAgentsProjectWorkspace/properties/plugins/items/anyOf/1/properties/clients/items" + } + } + }, + "required": [ + "command" + ], + "additionalProperties": false + } + ] + } + }, + "disabledSkills": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabledSkills": { + "type": "array", + "items": { + "type": "string" + } + }, + "profiles": { + "not": {} + } + }, + "required": [ + "repositories", + "plugins", + "clients" + ], + "additionalProperties": true + } + } +} diff --git a/docs/public/schemas/v1/user-workspace.schema.json b/docs/public/schemas/v1/user-workspace.schema.json new file mode 100644 index 00000000..20c15212 --- /dev/null +++ b/docs/public/schemas/v1/user-workspace.schema.json @@ -0,0 +1,1057 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://allagents.dev/schemas/v1/user-workspace.schema.json", + "title": "AllAgents user workspace", + "description": "Configuration for ~/.allagents/workspace.yaml, including global profiles.", + "$ref": "#/definitions/AllAgentsUserWorkspace", + "definitions": { + "AllAgentsUserWorkspace": { + "type": "object", + "properties": { + "version": { + "type": "number" + }, + "setup": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "run": { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/setup/items/anyOf/0" + }, + "platforms": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "aix", + "android", + "darwin", + "freebsd", + "haiku", + "linux", + "openbsd", + "sunos", + "win32", + "cygwin", + "netbsd" + ] + }, + "minItems": 1 + }, + "architectures": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "arm", + "arm64", + "ia32", + "loong64", + "mips", + "mipsel", + "ppc", + "ppc64", + "riscv64", + "s390", + "s390x", + "x64" + ] + }, + "minItems": 1 + } + }, + "required": [ + "run" + ], + "additionalProperties": false + } + ] + } + }, + "workspace": { + "type": "object", + "properties": { + "source": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "source": { + "type": "string" + }, + "dest": { + "type": "string" + } + }, + "additionalProperties": true + } + ] + } + } + }, + "required": [ + "files" + ], + "additionalProperties": true + }, + "repositories": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "name": { + "type": "string" + }, + "source": { + "type": "string" + }, + "repo": { + "type": "string" + }, + "description": { + "type": "string" + }, + "skills": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "managed": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "clone", + "sync" + ] + } + ] + }, + "branch": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": true + }, + "default": [] + }, + "plugins": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "source": { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/plugins/items/anyOf/0" + }, + "clients": { + "type": "array", + "items": { + "type": "string", + "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" + ] + } + }, + "install": { + "type": "string", + "enum": [ + "file", + "native" + ] + }, + "exclude": { + "type": "array", + "items": { + "type": "string" + } + }, + "skills": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "object", + "properties": { + "exclude": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "exclude" + ], + "additionalProperties": true + } + ] + }, + "ref": { + "type": "string" + } + }, + "required": [ + "source" + ], + "additionalProperties": false + } + ] + }, + "default": [] + }, + "clients": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/plugins/items/anyOf/1/properties/clients/items" + }, + { + "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)$" + }, + { + "type": "object", + "properties": { + "name": { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/plugins/items/anyOf/1/properties/clients/items" + }, + "install": { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/plugins/items/anyOf/1/properties/install", + "default": "file" + } + }, + "required": [ + "name" + ], + "additionalProperties": true + } + ] + }, + "default": [] + }, + "vscode": { + "type": "object", + "properties": { + "output": { + "type": "string" + } + }, + "additionalProperties": true + }, + "syncMode": { + "type": "string", + "enum": [ + "symlink", + "copy" + ] + }, + "mcpProxy": { + "type": "object", + "properties": { + "clients": { + "type": "array", + "items": { + "type": "string" + } + }, + "servers": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "proxy": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "proxy" + ], + "additionalProperties": true + } + } + }, + "required": [ + "clients" + ], + "additionalProperties": true + }, + "mcpServers": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "http" + ] + }, + "url": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "clients": { + "type": "array", + "items": { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/plugins/items/anyOf/1/properties/clients/items" + } + } + }, + "required": [ + "url" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "stdio" + ] + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "clients": { + "type": "array", + "items": { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/plugins/items/anyOf/1/properties/clients/items" + } + } + }, + "required": [ + "command" + ], + "additionalProperties": false + } + ] + } + }, + "disabledSkills": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabledSkills": { + "type": "array", + "items": { + "type": "string" + } + }, + "profiles": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "clients": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "claude" + }, + "install": { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/plugins/items/anyOf/1/properties/install", + "default": "file" + }, + "launcher": { + "type": "string", + "pattern": "^(?!(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\\.|$))(?!.*\\.$)[a-z0-9][a-z0-9._-]{0,63}$" + }, + "settings": { + "type": "object", + "properties": { + "model": { + "type": "string", + "minLength": 1 + }, + "effortLevel": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "xhigh" + ] + }, + "fallbackModel": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + }, + "outputStyle": { + "type": "string", + "minLength": 1 + }, + "autoMemoryEnabled": { + "type": "boolean" + }, + "spinnerTipsEnabled": { + "type": "boolean" + }, + "autoUpdatesChannel": { + "type": "string", + "enum": [ + "stable", + "latest" + ] + } + }, + "additionalProperties": false, + "default": {} + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "opencode" + }, + "install": { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/profiles/additionalProperties/properties/clients/items/anyOf/0/properties/install" + }, + "launcher": { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/profiles/additionalProperties/properties/clients/items/anyOf/0/properties/launcher" + }, + "settings": { + "type": "object", + "properties": { + "model": { + "type": "string", + "minLength": 1 + }, + "small_model": { + "type": "string", + "minLength": 1 + }, + "default_agent": { + "type": "string", + "minLength": 1 + }, + "username": { + "type": "string", + "minLength": 1 + }, + "share": { + "type": "string", + "enum": [ + "manual", + "auto", + "disabled" + ] + }, + "autoupdate": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "notify" + } + ] + }, + "snapshot": { + "type": "boolean" + }, + "subagent_depth": { + "type": "integer", + "minimum": 0 + }, + "logLevel": { + "type": "string", + "enum": [ + "DEBUG", + "INFO", + "WARN", + "ERROR" + ] + }, + "disabled_providers": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "enabled_providers": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false, + "default": {} + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "copilot" + }, + "install": { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/profiles/additionalProperties/properties/clients/items/anyOf/0/properties/install" + }, + "launcher": { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/profiles/additionalProperties/properties/clients/items/anyOf/0/properties/launcher" + }, + "settings": { + "type": "object", + "properties": { + "model": { + "type": "string", + "minLength": 1 + }, + "theme": { + "type": "string", + "enum": [ + "default", + "github", + "dim", + "high-contrast", + "colorblind" + ] + }, + "autoUpdate": { + "type": "boolean" + }, + "autoUpdatesChannel": { + "type": "string", + "enum": [ + "stable", + "prerelease" + ] + }, + "banner": { + "type": "string", + "enum": [ + "always", + "once", + "never" + ] + }, + "askUser": { + "type": "boolean" + }, + "includeCoAuthoredBy": { + "type": "boolean" + }, + "stream": { + "type": "boolean" + }, + "streamerMode": { + "type": "boolean" + }, + "toolSearch": { + "type": "boolean" + }, + "updateTerminalTitle": { + "type": "boolean" + }, + "respectGitignore": { + "type": "boolean" + }, + "disableAllHooks": { + "type": "boolean" + }, + "experimental": { + "type": "boolean" + }, + "bashEnv": { + "type": "boolean" + }, + "keepAlive": { + "type": "string", + "enum": [ + "on", + "off", + "busy" + ] + }, + "commandHistoryMaxSize": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + }, + "compactPaste": { + "type": "boolean" + }, + "mouse": { + "type": "boolean" + }, + "terminalProgress": { + "type": "boolean" + }, + "remote": { + "type": "string", + "enum": [ + "on", + "off" + ] + }, + "remoteExport": { + "type": "boolean" + }, + "ide.autoConnect": { + "type": "boolean" + }, + "shellShortcut": { + "type": "boolean" + }, + "customAgents.defaultLocalOnly": { + "type": "boolean" + }, + "storeTokenPlaintext": { + "type": "boolean" + }, + "disabledMcpServers": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "enabledMcpServers": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false, + "default": {} + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "codex" + }, + "install": { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/profiles/additionalProperties/properties/clients/items/anyOf/0/properties/install" + }, + "launcher": { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/profiles/additionalProperties/properties/clients/items/anyOf/0/properties/launcher" + }, + "settings": { + "type": "object", + "properties": { + "model": { + "type": "string", + "minLength": 1 + }, + "model_reasoning_effort": { + "type": "string", + "enum": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "model_reasoning_summary": { + "type": "string", + "enum": [ + "auto", + "concise", + "detailed", + "none" + ] + }, + "model_verbosity": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "approval_policy": { + "type": "string", + "enum": [ + "on-request", + "never" + ] + }, + "sandbox_mode": { + "type": "string", + "enum": [ + "read-only", + "workspace-write", + "danger-full-access" + ] + }, + "web_search": { + "type": "string", + "enum": [ + "disabled", + "cached", + "indexed", + "live" + ] + }, + "personality": { + "type": "string", + "enum": [ + "none", + "friendly", + "pragmatic" + ] + } + }, + "additionalProperties": false, + "default": {} + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "pi", + "omp" + ] + }, + "install": { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/profiles/additionalProperties/properties/clients/items/anyOf/0/properties/install" + }, + "launcher": { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/profiles/additionalProperties/properties/clients/items/anyOf/0/properties/launcher" + }, + "settings": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "default": {} + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + { + "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" + ] + }, + "install": { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/profiles/additionalProperties/properties/clients/items/anyOf/0/properties/install" + }, + "launcher": { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/profiles/additionalProperties/properties/clients/items/anyOf/0/properties/launcher" + }, + "settings": { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/profiles/additionalProperties/properties/clients/items/anyOf/4/properties/settings", + "default": {} + } + }, + "required": [ + "name" + ], + "additionalProperties": false + } + ] + }, + "minItems": 1 + }, + "plugins": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/plugins/items/anyOf/0" + }, + { + "type": "object", + "properties": { + "source": { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/plugins/items/anyOf/0" + }, + "ref": { + "type": "string" + }, + "install": { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/plugins/items/anyOf/1/properties/install" + }, + "clients": { + "type": "array", + "items": { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/plugins/items/anyOf/1/properties/clients/items" + } + }, + "skills": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "object", + "properties": { + "exclude": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "exclude" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "source" + ], + "additionalProperties": false + } + ] + }, + "default": [] + }, + "mcpServers": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "http" + ] + }, + "url": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*\\}$" + } + }, + "clients": { + "type": "array", + "items": { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/plugins/items/anyOf/1/properties/clients/items" + } + } + }, + "required": [ + "url" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "stdio" + ] + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/profiles/additionalProperties/properties/mcpServers/additionalProperties/anyOf/0/properties/headers/additionalProperties" + } + }, + "clients": { + "type": "array", + "items": { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/plugins/items/anyOf/1/properties/clients/items" + } + } + }, + "required": [ + "command" + ], + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "clients" + ], + "additionalProperties": false + }, + "propertyNames": { + "pattern": "^(?!(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\\.|$))(?!.*\\.$)[a-z0-9][a-z0-9._-]{0,63}$" + } + } + }, + "additionalProperties": true + } + } +} diff --git a/docs/src/content/docs/docs/reference/clients.mdx b/docs/src/content/docs/docs/reference/clients.mdx index 719ca51b..ee013031 100644 --- a/docs/src/content/docs/docs/reference/clients.mdx +++ b/docs/src/content/docs/docs/reference/clients.mdx @@ -3,7 +3,10 @@ title: Supported Clients description: AI coding assistant clients supported by AllAgents. --- -AllAgents supports 25 AI coding assistants, organized into universal clients (sharing `.agents/skills/`) and provider-specific clients. +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 @@ -75,6 +78,36 @@ outside `COPILOT_HOME`, so this is configuration isolation, not a security sandbox. See GitHub's [Copilot CLI configuration directory reference](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference). +### Claude Code + +Claude Code global profiles require Claude Code 2.1.268 or newer. AllAgents sets +an absolute, owned `CLAUDE_CONFIG_DIR`, isolates `CLAUDE_CODE_PLUGIN_CACHE_DIR`, +and clears inherited plugin seed and transcript-directory selectors. The +generated launcher preserves arguments and the caller's working directory, so +the active project's `CLAUDE.md`, settings, skills, and `.mcp.json` continue to +compose with the selected global profile. + +File installation writes selected skills, commands, agents, hooks, and +`CLAUDE.md` beneath the isolated root. Strict settings are written to +`settings.json`. Profile MCP servers are written to the additive +`allagents.mcp.json` passed by the launcher without `--strict-mcp-config`; +portable `${ENV_VAR}` references remain unresolved on disk and normal project +MCP discovery stays active. + +Native installation requires one authoritative marketplace identity. AllAgents +materializes Claude's declarative marketplace and plugin settings, then +delegates registration, install, update, uninstall, inventory, and safe +marketplace cleanup to Claude's user scope inside the selected root. Native +skill filters and sparse marketplace paths are rejected; file installation is +the deterministic alternative. + +The isolated root also holds Claude-managed credentials, sessions, trust state, +plugin caches, and runtime metadata. Removing an AllAgents-created profile +intentionally removes that isolated runtime state after native cleanup. Ambient +`~/.claude`, project files, and platform credential stores remain outside the +filesystem cleanup boundary; inherited authentication environment variables +can still override the selected profile login. + ### Codex Codex global profiles require Codex CLI 0.149.0 or newer. AllAgents assigns an diff --git a/docs/src/content/docs/docs/reference/configuration.mdx b/docs/src/content/docs/docs/reference/configuration.mdx index cb69d136..65f29cfb 100644 --- a/docs/src/content/docs/docs/reference/configuration.mdx +++ b/docs/src/content/docs/docs/reference/configuration.mdx @@ -53,6 +53,38 @@ clients: - cursor ``` +### JSON Schema and editor validation + +AllAgents publishes separate, versioned schemas for the two workspace scopes: + +- [Project workspace schema](https://allagents.dev/schemas/v1/project-workspace.schema.json) + for `.allagents/workspace.yaml`. It rejects the global `profiles` field. +- [User workspace schema](https://allagents.dev/schemas/v1/user-workspace.schema.json) + for `~/.allagents/workspace.yaml`. It includes global profiles and strict + client-specific settings. + +Add the matching YAML Language Server directive as the first line of each file: + +```yaml +# yaml-language-server: $schema=https://allagents.dev/schemas/v1/project-workspace.schema.json +repositories: [] +plugins: [] +clients: [] +``` + +```yaml +# yaml-language-server: $schema=https://allagents.dev/schemas/v1/user-workspace.schema.json +profiles: + review: + clients: + - name: claude +``` + +The `v1` URLs are stable. A future breaking schema contract will use a new +versioned directory. Runtime Zod validation remains authoritative; regenerate +the committed schemas with `bun run schema:generate` after changing workspace +input models, and verify drift with `bun run schema:check`. + ### Client Install Modes A bare client name uses file sync. Native mode delegates compatible plugin @@ -159,15 +191,37 @@ profiles: command: review-mcp env: REVIEW_TOKEN: ${REVIEW_TOKEN} + + claude-review: + clients: + - name: claude + install: native + launcher: claude-review + settings: + model: sonnet + effortLevel: high + fallbackModel: [haiku] + autoUpdatesChannel: stable + plugins: + - source: ./claude-marketplace + install: native + - source: ./review-tools + install: file + skills: [review] + mcpServers: + review: + command: review-mcp + env: + REVIEW_TOKEN: ${REVIEW_TOKEN} ``` | Field | Required | Description | |-------|----------|-------------| | `profiles..clients` | Yes | One or more object-form profile clients | -| `clients[].name` | Yes | Supported profile client; currently `pi`, `omp`, `opencode`, `copilot`, or `codex` | +| `clients[].name` | Yes | Supported profile client; currently `pi`, `omp`, `opencode`, `copilot`, `codex`, or `claude` | | `clients[].install` | No | Default plugin mode, `file` by default; OpenCode rejects `native` because its CLI lacks a complete inspect/update/remove lifecycle | | `clients[].launcher` | No | Safe command basename written to the configured user bin directory | -| `clients[].settings` | No | Strict client settings object; Pi and OMP accept no settings, while OpenCode, Copilot, and Codex accept their documented profile settings | +| `clients[].settings` | No | Strict client settings object; Pi and OMP accept no settings, while OpenCode, Copilot, Codex, and Claude accept their documented profile settings | | `profiles..plugins` | No | Profile plugin declarations; defaults to an empty list | | `plugins[].source` | Yes | npm, GitHub, marketplace, or local source supported by the selected adapter | | `plugins[].ref` | No | Requested Git ref for a GitHub source | @@ -181,6 +235,15 @@ current project. Profile names and launcher names are safe command basenames; machine paths, resolved revisions, generated launcher paths, and ownership state are not declaration fields. +On Windows, every profile launcher is written as both `.cmd` and +`.ps1`. Both retain the caller's working directory, selected profile +environment, and client exit status. The PowerShell companion preserves literal +arguments—including quotes and empty values—and safely unwraps standard Node- +and Bun-backed package-manager `.cmd` shims. The Command Prompt companion keeps +normal `cmd.exe` parsing, so `%`, `&`, `|`, and `^` require the same escaping as +any other batch command. Invoke `.ps1` explicitly from PowerShell when +those characters must remain literal. + MCP credentials must remain runtime references. Environment values and HTTP headers accept exact `${ENV_VAR}` references only, and credential-bearing command arguments must use the same exact form. Resolved secret values are @@ -229,6 +292,27 @@ interpolate them. Native plugins use authoritative marketplace identities; AllAgents delegates registration, install, Git marketplace upgrade, targeted refresh, uninstall, and safe marketplace removal to Codex. +Claude profiles require Claude Code 2.1.268 or newer. Their launchers select an +absolute, isolated `CLAUDE_CONFIG_DIR`, isolate the plugin cache, and clear +inherited plugin seed and transcript-bucketing selectors. The caller's working +directory remains unchanged, so project `CLAUDE.md`, settings, skills, and +`.mcp.json` discovery continue normally. + +Claude profile settings accept `model`, `effortLevel`, `fallbackModel`, +`outputStyle`, `autoMemoryEnabled`, `spinnerTipsEnabled`, and +`autoUpdatesChannel`. All other keys fail validation. AllAgents writes +`settings.json` and an additive `allagents.mcp.json`; the launcher requires both +files and passes only the MCP file explicitly, without suppressing normal +project MCP discovery. `${ENV_VAR}` references remain unresolved until Claude +starts. + +Native Claude plugins require exactly one authoritative marketplace identity. +AllAgents writes the marketplace and enabled-plugin declarations Claude needs, +then delegates install, update, uninstall, inventory, and safe marketplace +cleanup to Claude's user scope inside the selected root. Native skill filters +and sparse marketplace paths fail before mutation; use file installation for +those cases. + Install profiles explicitly with `allagents profile install --yes`. Ordinary `allagents update` reconciles installed, still-declared profiles; repeat `--profile ` to select only installed profiles. Removing a @@ -507,6 +591,10 @@ plugins: - superpowers@obra/superpowers ``` -This file follows the same `plugins` format as the project-level workspace.yaml but does not support `workspace`, `repositories`, or `clients` fields. User-scoped plugins sync to user-level directories (`~/.claude/`, `~/.codex/`, etc.) and are available across all projects. +The user file accepts the same ordinary workspace fields as a project file, and +also accepts global `profiles`. Its `repositories`, `plugins`, and `clients` +arrays default to empty, so it may contain only profiles. User-scoped ordinary +plugins sync to user-level directories (`~/.claude/`, `~/.codex/`, etc.); +profile lifecycle remains explicit and independent. Sync state for user-scoped plugins is tracked in `~/.allagents/sync-state.json`. diff --git a/package.json b/package.json index 67f2daa8..666cd952 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "test:e2e": "bun test tests/e2e", "smoke:mcp-oauth": "bun run scripts/smoke-mcp-oauth.ts", "dev:mcp-server": "bun run scripts/dev-mcp-server.ts", + "schema:generate": "bun run scripts/generate-workspace-schemas.ts", + "schema:check": "bun run scripts/generate-workspace-schemas.ts --check", "typecheck": "tsc --noEmit", "lint": "biome lint src", "lint:fix": "biome lint --write src", @@ -67,7 +69,7 @@ "micromatch": "^4.0.8", "read-cmd-shim": "^4.0.0", "simple-git": "^3.30.0", - "zod": "^3.22.4" + "zod": "^3.25.28" }, "devDependencies": { "@biomejs/biome": "^1.9.0", @@ -75,8 +77,10 @@ "@types/js-yaml": "^4.0.9", "@types/micromatch": "^4.0.10", "@types/node": "^20.11.5", + "ajv": "8.18.0", "shx": "^0.4.0", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "zod-to-json-schema": "3.25.2" }, "overrides": { "chalk": "^4.1.2" diff --git a/scripts/generate-workspace-schemas.ts b/scripts/generate-workspace-schemas.ts new file mode 100644 index 00000000..bc556e29 --- /dev/null +++ b/scripts/generate-workspace-schemas.ts @@ -0,0 +1,95 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { zodToJsonSchema } from 'zod-to-json-schema'; +import { + ProjectWorkspaceConfigSchema, + UserWorkspaceConfigSchema, +} from '../src/models/workspace-config.js'; + +const SCHEMA_VERSION = 'v1'; +const SCHEMA_ROOT = resolve( + import.meta.dir, + '..', + 'docs', + 'public', + 'schemas', + SCHEMA_VERSION, +); +const PUBLIC_ROOT = `https://allagents.dev/schemas/${SCHEMA_VERSION}`; + +const WORKSPACE_SCHEMAS = [ + { + fileName: 'project-workspace.schema.json', + definitionName: 'AllAgentsProjectWorkspace', + title: 'AllAgents project workspace', + description: + 'Configuration for a project .allagents/workspace.yaml. Global profiles are not accepted.', + schema: ProjectWorkspaceConfigSchema, + }, + { + fileName: 'user-workspace.schema.json', + definitionName: 'AllAgentsUserWorkspace', + title: 'AllAgents user workspace', + description: + 'Configuration for ~/.allagents/workspace.yaml, including global profiles.', + schema: UserWorkspaceConfigSchema, + }, +] as const; + +export interface GeneratedWorkspaceSchema { + readonly fileName: string; + readonly path: string; + readonly url: string; + readonly content: string; +} + +export function generateWorkspaceSchemas(): readonly GeneratedWorkspaceSchema[] { + return WORKSPACE_SCHEMAS.map((entry) => { + const generated = zodToJsonSchema(entry.schema, { + name: entry.definitionName, + target: 'jsonSchema7', + effectStrategy: 'input', + removeAdditionalStrategy: 'strict', + }); + const { $schema, ...body } = generated; + const document = { + $schema, + $id: `${PUBLIC_ROOT}/${entry.fileName}`, + title: entry.title, + description: entry.description, + ...body, + }; + return { + fileName: entry.fileName, + path: resolve(SCHEMA_ROOT, entry.fileName), + url: document.$id, + content: `${JSON.stringify(document, null, 2)}\n`, + }; + }); +} + +async function writeSchemas(check: boolean): Promise { + const schemas = generateWorkspaceSchemas(); + if (check) { + const drifted: string[] = []; + for (const schema of schemas) { + const current = await readFile(schema.path, 'utf8').catch(() => null); + if (current !== schema.content) drifted.push(schema.path); + } + if (drifted.length > 0) { + throw new Error( + `Generated workspace schemas are stale:\n${drifted.map((path) => `- ${path}`).join('\n')}\nRun bun run schema:generate.`, + ); + } + return; + } + + await mkdir(SCHEMA_ROOT, { recursive: true }); + await Promise.all( + schemas.map((schema) => writeFile(schema.path, schema.content, 'utf8')), + ); +} + +if (import.meta.main) { + await writeSchemas(process.argv.includes('--check')); +} diff --git a/src/core/native/claude.ts b/src/core/native/claude.ts index fddfba97..371c1339 100644 --- a/src/core/native/claude.ts +++ b/src/core/native/claude.ts @@ -1,14 +1,54 @@ +import { randomUUID } from 'node:crypto'; +import { lstat, mkdtemp, open, readFile, rename, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; import { executeCommand, + compareNativeVersions, type NativeClient, type NativeCommandOptions, + type NativeCommandResult, type NativeInspectionResult, type NativeMutationResult, type NativeOperationContext, type NativeResource, + type NativeResourceObservation, type NativeSourceResolution, } from './types.js'; +type ClaudeCommandRunner = ( + binary: string, + args: string[], + options?: NativeCommandOptions, +) => Promise; + +export interface ClaudeNativeClientOptions { + execute?: ClaudeCommandRunner; + minimumVersion?: readonly [number, number, number]; +} + +export interface ClaudeMarketplaceRegistrationInspection { + success: boolean; + present: boolean; + source?: string; + sourceType?: string; + ref?: string; + error?: string; +} + +export interface ClaudePluginInventoryEntry { + readonly id: string; + readonly scope?: 'user' | 'project' | 'local' | 'managed'; + readonly enabled: boolean; +} + +export interface ClaudeMarketplaceInventoryEntry { + readonly name: string; + readonly sourceType: string; + readonly source: string; + readonly ref?: string; +} + function commandOptions(context: NativeOperationContext): NativeCommandOptions { return { ...(context.cwd && { cwd: context.cwd }), @@ -16,58 +56,378 @@ function commandOptions(context: NativeOperationContext): NativeCommandOptions { }; } -function commandError(result: { - error?: string; - exitCode?: number | null; - signal?: NodeJS.Signals | null; -}): string { +function commandError(result: NativeCommandResult): string { if (result.error) return result.error; if (result.signal) return `Claude CLI terminated by ${result.signal}`; return `Claude CLI exited with code ${result.exitCode ?? 'unknown'}`; } -function inventoryEntries(value: unknown): unknown[] | null { - if (Array.isArray(value)) return value; - if (!value || typeof value !== 'object') return null; - const record = value as Record; - for (const key of ['plugins', 'installedPlugins', 'installed_plugins']) { - if (Array.isArray(record[key])) return record[key] as unknown[]; +function versionTuple(output: string): readonly number[] | null { + const match = /(?:^|\s)v?(\d+)\.(\d+)\.(\d+)(?=\D|$)/.exec(output); + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null; +} + +async function profileRootExists( + context: NativeOperationContext, +): Promise { + const stats = await lstat(context.root).catch((error) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + }); + if (!stats) return false; + if (!stats.isDirectory() || stats.isSymbolicLink()) { + throw new Error( + `Claude configuration root is not a real directory: ${context.root}`, + ); + } + return true; +} + +function parseJsonRecord(output: string): Record | null { + try { + const value = JSON.parse(output); + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; + } catch { + return null; + } +} + +function lastJsonRecord(output: string): Record | null { + const lines = output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + for (let index = lines.length - 1; index >= 0; index--) { + const parsed = parseJsonRecord(lines[index] ?? ''); + if (parsed) return parsed; } return null; } -function entryIdentity( - entry: unknown, - scope: 'user' | 'project', -): string | null { - if (typeof entry === 'string') return entry; - if (!entry || typeof entry !== 'object') return null; - const record = entry as Record; +function parsePluginEntry( + value: unknown, + available: boolean, +): ClaudePluginInventoryEntry | null { + if (typeof value === 'string' && !available) { + return parseClaudePluginId(value) ? { id: value, enabled: true } : null; + } + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const entry = value as Record; + let id: string | undefined; + for (const key of ['id', 'pluginId', 'spec', 'plugin']) { + if (typeof entry[key] === 'string' && entry[key].length > 0) { + id = entry[key]; + break; + } + } + if ( + !id && + typeof entry.name === 'string' && + typeof entry.marketplaceName === 'string' + ) { + id = `${entry.name}@${entry.marketplaceName}`; + } + if ( + !id && + typeof entry.name === 'string' && + parseClaudePluginId(entry.name) + ) { + id = entry.name; + } + if (!id || !parseClaudePluginId(id)) return null; + const scope = + entry.scope === 'user' || + entry.scope === 'project' || + entry.scope === 'local' || + entry.scope === 'managed' + ? entry.scope + : undefined; + if (!available && entry.scope !== undefined && !scope) return null; if ( - typeof record.scope === 'string' && - record.scope !== scope && - !(scope === 'project' && record.scope === 'local') + !available && + entry.enabled !== undefined && + typeof entry.enabled !== 'boolean' ) { return null; } - for (const key of ['id', 'spec', 'plugin', 'name']) { - if (typeof record[key] === 'string' && record[key].length > 0) { - return record[key]; + return { + id, + ...(scope && { scope }), + enabled: available ? false : entry.enabled !== false, + }; +} + +export function parseClaudePluginInventory(output: string): { + readonly installed: readonly ClaudePluginInventoryEntry[]; + readonly available: readonly ClaudePluginInventoryEntry[]; +} | null { + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch { + return null; + } + const installedValues = Array.isArray(parsed) + ? parsed + : parsed && typeof parsed === 'object' + ? ((['installed', 'plugins', 'installedPlugins', 'installed_plugins'] + .map((key) => (parsed as Record)[key]) + .find(Array.isArray) as unknown[] | undefined) ?? null) + : null; + const availableValues = + parsed && + typeof parsed === 'object' && + !Array.isArray(parsed) && + Array.isArray((parsed as Record).available) + ? ((parsed as Record).available as unknown[]) + : []; + if (!installedValues) return null; + const installed = installedValues.map((entry) => + parsePluginEntry(entry, false), + ); + const available = availableValues.map((entry) => + parsePluginEntry(entry, true), + ); + return installed.every( + (entry): entry is ClaudePluginInventoryEntry => entry !== null, + ) && + available.every( + (entry): entry is ClaudePluginInventoryEntry => entry !== null, + ) + ? { installed, available } + : null; +} + +function parseMarketplaceEntry( + value: unknown, +): ClaudeMarketplaceInventoryEntry | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const entry = value as Record; + if (typeof entry.name !== 'string' || typeof entry.source !== 'string') { + return null; + } + let source: string | undefined; + for (const key of ['path', 'repo', 'url']) { + if (typeof entry[key] === 'string' && entry[key].length > 0) { + source = entry[key]; + break; } } - return null; + if (!source && typeof entry.installLocation === 'string') { + source = entry.installLocation; + } + if (!source) return null; + return { + name: entry.name, + sourceType: entry.source, + source, + ...(typeof entry.ref === 'string' && + entry.ref.length > 0 && { + ref: entry.ref, + }), + }; +} + +export function parseClaudeMarketplaceInventory( + output: string, +): readonly ClaudeMarketplaceInventoryEntry[] | null { + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch { + return null; + } + if (!Array.isArray(parsed)) return null; + const entries = parsed.map(parseMarketplaceEntry); + return entries.every( + (entry): entry is ClaudeMarketplaceInventoryEntry => entry !== null, + ) + ? entries + : null; +} + +export function parseClaudePluginId( + source: string, +): { plugin: string; marketplace: string } | null { + const atIndex = source.lastIndexOf('@'); + if (atIndex <= 0 || atIndex === source.length - 1) return null; + const plugin = source.slice(0, atIndex); + const marketplace = source.slice(atIndex + 1); + if ( + plugin.includes('@') || + plugin.includes('/') || + plugin.includes('\\') || + marketplace.includes('/') || + marketplace.includes('\\') || + marketplace.includes('://') + ) { + return null; + } + return { plugin, marketplace }; +} + +function parseMutationResult( + output: string, + command: 'install' | 'update' | 'uninstall', + expectedIdentity: string, + expectedScope: string, +): boolean { + const parsed = lastJsonRecord(output); + return ( + parsed?.command === command && + parsed.outcome === 'ok' && + parsed.pluginId === expectedIdentity && + parsed.scope === expectedScope + ); +} + +function cliScope(context: NativeOperationContext): string { + return context.nativeScope.startsWith('profile:') + ? 'user' + : context.nativeScope; +} + +function isProfileContext(context: NativeOperationContext): boolean { + return context.nativeScope.startsWith('profile:'); +} + +function marketplaceSourceArgument(resource: NativeResource): string | null { + const source = resource.provenance.marketplaceSource; + if (!source) return null; + const ref = resource.provenance.resolvedRef; + return ref && /^[^/:]+\/[^/]+$/.test(source) ? `${source}@${ref}` : source; +} + +interface ClaudeSettingsSnapshot { + readonly path: string; + readonly content: Uint8Array | null; + readonly mode?: number; +} + +async function captureProfileSettings( + context: NativeOperationContext, +): Promise { + if (!context.nativeScope.startsWith('profile:')) return null; + const path = join(context.roots?.config ?? context.root, 'settings.json'); + const stats = await lstat(path).catch((error) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + }); + if (!stats) return { path, content: null }; + if (!stats.isFile() || stats.isSymbolicLink()) { + throw new Error(`Claude profile settings are not a regular file: ${path}`); + } + return { + path, + content: await readFile(path), + mode: stats.mode & 0o777, + }; +} + +async function restoreProfileSettings( + snapshot: ClaudeSettingsSnapshot | null, +): Promise { + if (!snapshot) return; + if (!snapshot.content) { + await rm(snapshot.path, { force: true }); + return; + } + const temporaryPath = join( + dirname(snapshot.path), + `.allagents-settings-${randomUUID()}.tmp`, + ); + let handle: Awaited> | undefined; + try { + handle = await open(temporaryPath, 'wx', snapshot.mode ?? 0o600); + await handle.writeFile(snapshot.content); + await handle.chmod(snapshot.mode ?? 0o600); + await handle.sync(); + await handle.close(); + handle = undefined; + await rename(temporaryPath, snapshot.path); + } finally { + await handle?.close().catch(() => undefined); + await rm(temporaryPath, { force: true }); + } } export class ClaudeNativeClient implements NativeClient { readonly client = 'claude'; + private readonly run: ClaudeCommandRunner; + private readonly minimumVersion: + | readonly [number, number, number] + | undefined; + + constructor(options: ClaudeNativeClientOptions = {}) { + this.run = options.execute ?? executeCommand; + this.minimumVersion = options.minimumVersion; + } + + private async runIsolated( + args: string[], + context?: NativeOperationContext, + ): Promise { + const temporaryRoot = await mkdtemp( + join(tmpdir(), 'allagents-claude-inspection-'), + ); + try { + return await this.run('claude', args, { + ...(context?.cwd && { cwd: context.cwd }), + env: { + ...context?.env, + CLAUDE_CONFIG_DIR: temporaryRoot, + CLAUDE_CODE_PLUGIN_CACHE_DIR: join(temporaryRoot, 'plugins'), + CLAUDE_CODE_PLUGIN_SEED_DIR: undefined, + CLAUDE_CODE_PROJECT_DIR_NAME: undefined, + }, + }); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } + } + + private async runForInspection( + args: string[], + context: NativeOperationContext, + ): Promise { + return (await profileRootExists(context)) + ? this.run('claude', args, commandOptions(context)) + : this.runIsolated(args, context); + } + + private async runMutation( + args: string[], + context: NativeOperationContext, + ): Promise { + const settings = await captureProfileSettings(context); + try { + return await this.run('claude', args, commandOptions(context)); + } finally { + await restoreProfileSettings(settings); + } + } async isAvailable(context?: NativeOperationContext): Promise { - const result = await executeCommand( - 'claude', - ['--version'], - context ? commandOptions(context) : undefined, + const version = await this.runIsolated(['--version'], context); + if (!version.success) return false; + const parsedVersion = versionTuple(version.output); + if ( + this.minimumVersion && + (!parsedVersion || + compareNativeVersions(parsedVersion, this.minimumVersion) < 0) + ) { + return false; + } + if (!this.minimumVersion) return true; + const pluginHelp = await this.runIsolated(['plugin', '--help'], context); + return ( + pluginHelp.success && + ['install', 'list', 'marketplace', 'uninstall', 'update'].every( + (command) => pluginHelp.output.includes(command), + ) ); - return result.success; } supportsScope(_scope: 'user' | 'project'): boolean { @@ -75,29 +435,26 @@ export class ClaudeNativeClient implements NativeClient { } toPluginSpec(allagentsSource: string): string | null { + const exact = parseClaudePluginId(allagentsSource); + if (exact) return allagentsSource; const atIndex = allagentsSource.lastIndexOf('@'); if (atIndex <= 0 || atIndex === allagentsSource.length - 1) return null; - const pluginName = allagentsSource.slice(0, atIndex); const marketplacePart = allagentsSource.slice(atIndex + 1); - - if (marketplacePart.includes('/') && !marketplacePart.includes('://')) { - const parts = marketplacePart.split('/'); - const repoName = parts[1]; - if (!repoName) return null; - return `${pluginName}@${repoName}`; + if (!marketplacePart.includes('/') || marketplacePart.includes('://')) { + return null; } - return allagentsSource; + const [, repository] = marketplacePart.split('/'); + return repository ? `${pluginName}@${repository}` : null; } extractMarketplaceSource(pluginSpec: string): string | null { const atIndex = pluginSpec.lastIndexOf('@'); if (atIndex <= 0 || atIndex === pluginSpec.length - 1) return null; const marketplacePart = pluginSpec.slice(atIndex + 1); - if (marketplacePart.includes('/') && !marketplacePart.includes('://')) { - return marketplacePart; - } - return null; + return marketplacePart.includes('/') && !marketplacePart.includes('://') + ? marketplacePart + : null; } resolveSource( @@ -112,6 +469,7 @@ export class ClaudeNativeClient implements NativeClient { error: `Claude native install does not support source '${source}'`, }; } + const identity = parseClaudePluginId(spec); return { success: true, resource: { @@ -119,7 +477,10 @@ export class ClaudeNativeClient implements NativeClient { requestedIdentity: source, resolvedIdentity: spec, context, - provenance, + provenance: { + ...(identity && { marketplaceName: identity.marketplace }), + ...provenance, + }, }, }; } @@ -127,76 +488,246 @@ export class ClaudeNativeClient implements NativeClient { async inspect( context: NativeOperationContext, ): Promise { - const result = await executeCommand( - 'claude', + if (!(await profileRootExists(context))) { + return { success: true, resources: [] }; + } + const result = await this.runForInspection( ['plugin', 'list', '--json'], - commandOptions(context), + context, ); if (!result.success) { return { success: false, resources: [], error: commandError(result) }; } + if (!result.output.trim() && !isProfileContext(context)) { + return { success: true, resources: [] }; + } + const inventory = parseClaudePluginInventory(result.output); + if (!inventory) { + return { + success: false, + resources: [], + error: 'Could not parse Claude plugin inventory', + }; + } + const resources: NativeResource[] = []; + const observations: NativeResourceObservation[] = []; + for (const entry of inventory.installed) { + const expectedScope = cliScope(context); + if ( + entry.scope && + entry.scope !== expectedScope && + !(expectedScope === 'project' && entry.scope === 'local') + ) { + continue; + } + const pluginId = parseClaudePluginId(entry.id); + const resource: NativeResource = { + kind: 'plugin', + requestedIdentity: entry.id, + resolvedIdentity: entry.id, + context, + provenance: { + ...(pluginId && { marketplaceName: pluginId.marketplace }), + }, + }; + if (entry.enabled) resources.push(resource); + else observations.push({ resource, status: 'disabled' }); + } + return { + success: true, + resources, + ...(observations.length > 0 && { observations }), + }; + } - try { - const parsed = result.output ? JSON.parse(result.output) : []; - const entries = inventoryEntries(parsed); - if (!entries) throw new Error('expected a plugin array'); + async inspectMarketplaceRegistration( + marketplaceName: string, + context: NativeOperationContext, + ): Promise { + const result = await this.runForInspection( + ['plugin', 'marketplace', 'list', '--json'], + context, + ); + if (!result.success) { return { - success: true, - resources: entries.flatMap((entry) => { - const identity = entryIdentity(entry, context.scope); - return identity - ? [{ - kind: 'plugin' as const, - requestedIdentity: identity, - resolvedIdentity: identity, - context, - provenance: {}, - }] - : []; - }), + success: false, + present: false, + error: commandError(result), }; - } catch (error) { + } + const inventory = parseClaudeMarketplaceInventory(result.output); + if (!inventory) { return { success: false, - resources: [], - error: `Could not parse Claude plugin inventory: ${error instanceof Error ? error.message : String(error)}`, + present: false, + error: 'Could not parse Claude marketplace inventory', + }; + } + const marketplace = inventory.find( + (entry) => entry.name === marketplaceName, + ); + return { + success: true, + present: Boolean(marketplace), + ...(marketplace?.source && { source: marketplace.source }), + ...(marketplace?.sourceType && { sourceType: marketplace.sourceType }), + ...(marketplace?.ref && { ref: marketplace.ref }), + }; + } + + async inspectMarketplacePlugin( + marketplaceName: string, + pluginName: string, + context: NativeOperationContext, + ): Promise<{ success: boolean; present: boolean; error?: string }> { + const result = await this.runForInspection( + ['plugin', 'list', '--available', '--json'], + context, + ); + if (!result.success) { + return { + success: false, + present: false, + error: commandError(result), + }; + } + const inventory = parseClaudePluginInventory(result.output); + if (!inventory) { + return { + success: false, + present: false, + error: `Could not parse Claude marketplace '${marketplaceName}'`, }; } + const expected = `${pluginName}@${marketplaceName}`; + return { + success: true, + present: [...inventory.installed, ...inventory.available].some( + (entry) => entry.id === expected, + ), + }; } async install( resource: NativeResource, context: NativeOperationContext, ): Promise { - const registrations: string[] = []; - const marketplaceSource = resource.provenance.marketplaceSource; - if (marketplaceSource) { - const registration = await executeCommand( + const scope = cliScope(context); + if (!isProfileContext(context)) { + const registrations: string[] = []; + const marketplaceSource = resource.provenance.marketplaceSource; + if (marketplaceSource) { + const registration = await this.run( + 'claude', + ['plugin', 'marketplace', 'add', marketplaceSource], + commandOptions(context), + ); + if (!registration.success) { + return { success: false, error: commandError(registration) }; + } + registrations.push(marketplaceSource); + } + const result = await this.run( 'claude', - ['plugin', 'marketplace', 'add', marketplaceSource], + ['plugin', 'install', resource.resolvedIdentity, '--scope', scope], commandOptions(context), ); + return result.success + ? { + success: true, + ...(registrations.length > 0 && { registrations }), + } + : { + success: false, + error: commandError(result), + ...(registrations.length > 0 && { registrations }), + }; + } + const marketplaceName = resource.provenance.marketplaceName; + if (!marketplaceName) { + return { success: false, error: 'Claude plugin marketplace is missing' }; + } + const registrations: string[] = []; + const inspection = await this.inspectMarketplaceRegistration( + marketplaceName, + context, + ); + if (!inspection.success) { + return { + success: false, + error: + inspection.error ?? + `Could not inspect Claude marketplace '${marketplaceName}'`, + }; + } + if (!inspection.present) { + const source = marketplaceSourceArgument(resource); + if (!source) { + return { + success: false, + error: `Claude marketplace '${marketplaceName}' is not registered and has no source`, + }; + } + const args = ['plugin', 'marketplace', 'add', source, '--scope', scope]; + const sparsePath = resource.provenance.marketplaceSparsePath; + if (sparsePath) args.push('--sparse', sparsePath); + const registration = await this.runMutation(args, context); if (!registration.success) { return { success: false, error: commandError(registration) }; } - registrations.push(marketplaceSource); + registrations.push( + resource.provenance.managedMarketplaceRegistration === 'true' + ? marketplaceName + : source, + ); + const verified = await this.inspectMarketplaceRegistration( + marketplaceName, + context, + ); + if (!verified.success || !verified.present) { + return { + success: false, + error: + verified.error ?? + `Claude marketplace '${marketplaceName}' registration could not be verified`, + registrations, + }; + } + } + if ( + resource.provenance.managedMarketplaceRegistration === 'true' && + registrations.length === 0 + ) { + registrations.push(marketplaceName); } - const result = await executeCommand( - 'claude', + const result = await this.runMutation( [ 'plugin', 'install', resource.resolvedIdentity, '--scope', - context.nativeScope, + scope, + '--yes', + '--json', ], - commandOptions(context), + context, ); - return result.success - ? { success: true, ...(registrations.length > 0 && { registrations }) } + return result.success && + parseMutationResult( + result.output, + 'install', + resource.resolvedIdentity, + scope, + ) + ? { + success: true, + ...(registrations.length > 0 && { registrations }), + } : { success: false, - error: commandError(result), + error: result.success + ? `Could not parse Claude plugin '${resource.resolvedIdentity}' install result` + : commandError(result), ...(registrations.length > 0 && { registrations }), }; } @@ -206,39 +737,157 @@ export class ClaudeNativeClient implements NativeClient { _current: NativeResource, context: NativeOperationContext, ): Promise { - const result = await executeCommand( - 'claude', + const scope = cliScope(context); + if (!isProfileContext(context)) { + const result = await this.run( + 'claude', + ['plugin', 'update', resource.resolvedIdentity, '--scope', scope], + commandOptions(context), + ); + return result.success + ? { success: true } + : { success: false, error: commandError(result) }; + } + const marketplaceName = resource.provenance.marketplaceName; + if (marketplaceName) { + const marketplace = await this.runMutation( + ['plugin', 'marketplace', 'update', marketplaceName], + context, + ); + if (!marketplace.success) { + return { success: false, error: commandError(marketplace) }; + } + } + const result = await this.runMutation( [ 'plugin', 'update', resource.resolvedIdentity, '--scope', - context.nativeScope, + scope, + '--yes', + '--json', ], - commandOptions(context), + context, ); - return result.success + return result.success && + parseMutationResult( + result.output, + 'update', + resource.resolvedIdentity, + scope, + ) ? { success: true } - : { success: false, error: commandError(result) }; + : { + success: false, + error: result.success + ? `Could not parse Claude plugin '${resource.resolvedIdentity}' update result` + : commandError(result), + }; } async remove( resource: NativeResource, context: NativeOperationContext, ): Promise { - const result = await executeCommand( - 'claude', + const scope = cliScope(context); + if (!isProfileContext(context)) { + const result = await this.run( + 'claude', + ['plugin', 'uninstall', resource.resolvedIdentity, '--scope', scope], + commandOptions(context), + ); + return result.success + ? { success: true } + : { success: false, error: commandError(result) }; + } + const result = await this.runMutation( [ 'plugin', 'uninstall', resource.resolvedIdentity, '--scope', - context.nativeScope, + scope, + '--yes', + '--json', ], - commandOptions(context), + context, ); - return result.success + return result.success && + parseMutationResult( + result.output, + 'uninstall', + resource.resolvedIdentity, + scope, + ) ? { success: true } - : { success: false, error: commandError(result) }; + : { + success: false, + error: result.success + ? `Could not parse Claude plugin '${resource.resolvedIdentity}' removal result` + : commandError(result), + }; + } + + async removeMarketplaceRegistration( + marketplaceName: string, + context: NativeOperationContext, + ): Promise { + const scope = cliScope(context); + const inspection = await this.inspectMarketplaceRegistration( + marketplaceName, + context, + ); + if (!inspection.success) { + return { + success: false, + error: + inspection.error ?? + `Could not inspect Claude marketplace '${marketplaceName}'`, + }; + } + if (!inspection.present) return { success: true }; + const plugins = await this.runForInspection( + ['plugin', 'list', '--json'], + context, + ); + if (!plugins.success) { + return { success: false, error: commandError(plugins) }; + } + const inventory = parseClaudePluginInventory(plugins.output); + if (!inventory) { + return { + success: false, + error: `Could not parse Claude marketplace '${marketplaceName}' plugin inventory`, + }; + } + if ( + inventory.installed.some( + (entry) => + parseClaudePluginId(entry.id)?.marketplace === marketplaceName, + ) + ) { + return { + success: false, + error: `Claude marketplace '${marketplaceName}' is still used by installed plugins`, + }; + } + const result = await this.runMutation( + ['plugin', 'marketplace', 'remove', marketplaceName, '--scope', scope], + context, + ); + if (!result.success) return { success: false, error: commandError(result) }; + const verified = await this.inspectMarketplaceRegistration( + marketplaceName, + context, + ); + return verified.success && !verified.present + ? { success: true } + : { + success: false, + error: + verified.error ?? + `Claude marketplace '${marketplaceName}' removal could not be verified`, + }; } } diff --git a/src/core/native/index.ts b/src/core/native/index.ts index 86123c17..c56c77c5 100644 --- a/src/core/native/index.ts +++ b/src/core/native/index.ts @@ -21,7 +21,14 @@ export { sanitizeNativeProvenance, toNativeEffectData, } from './types.js'; -export { ClaudeNativeClient } from './claude.js'; +export { + ClaudeNativeClient, + parseClaudeMarketplaceInventory, + parseClaudePluginId, + parseClaudePluginInventory, + type ClaudeMarketplaceRegistrationInspection, + type ClaudeNativeClientOptions, +} from './claude.js'; export { CopilotNativeClient, parseCopilotPluginId, diff --git a/src/core/profile/adapters/claude.ts b/src/core/profile/adapters/claude.ts new file mode 100644 index 00000000..732df9cd --- /dev/null +++ b/src/core/profile/adapters/claude.ts @@ -0,0 +1,496 @@ +import { isAbsolute, join, resolve } from 'node:path'; +import { + ClaudeProfileSettingsSchema, + ProfileMcpServerConfigSchema, + ProfileNameSchema, +} from '../../../models/workspace-config.js'; +import { + ClaudeNativeClient, + type NativeSourceResolution, +} from '../../native/index.js'; +import { isGitHubUrl, parseGitHubUrl } from '../../../utils/plugin-path.js'; +import { resolveClaudeProfileMetadata } from '../native-metadata.js'; +import type { ProfileOperationKind, ProfileStepKind } from '../index.js'; +import type { + NativeProfileAdapter, + ProfileClientContext, + ProfileContextOptions, + ProfileNativeCommandRequest, + ProfileNativeMetadataOptions, + ProfilePlannedFile, + ProfileResolvedPlugin, + ProfileSerializationInput, +} from '../types.js'; +import { serializeProfileMcpServers } from './mcp.js'; + +const CLAUDE_MINIMUM_VERSION = [2, 1, 268] as const; +const FILE_MAPPING = Object.freeze({ + commandsPath: 'commands/', + skillsPath: 'skills/', + agentsPath: 'agents/', + hooksPath: 'hooks/', + agentFile: 'CLAUDE.md', +}); +const CAPABILITIES = Object.freeze({ + nativeInstall: true, + fileInstall: true, + launchers: true, + skillFilters: true, + mcp: true, + settings: true, + status: true, + cleanup: true, + recursiveRootCleanup: true, +}); + +function assertClaudeContext(context: ProfileClientContext): void { + const pluginRoot = join(context.root, 'plugins'); + if ( + context.client !== 'claude' || + context.operationContext.client !== 'claude' || + context.operationContext.nativeScope !== `profile:${context.profileName}` || + resolve(context.root) !== context.root || + context.operationContext.env?.CLAUDE_CONFIG_DIR !== context.root || + context.operationContext.env?.CLAUDE_CODE_PLUGIN_CACHE_DIR !== pluginRoot || + context.operationContext.env?.CLAUDE_CODE_PLUGIN_SEED_DIR !== undefined || + context.operationContext.env?.CLAUDE_CODE_PROJECT_DIR_NAME !== undefined + ) { + throw new Error( + 'Claude profile adapter received a mismatched or non-absolute context', + ); + } +} + +function claudeMarketplaceSetting( + plugin: ProfileResolvedPlugin, +): Readonly> { + const source = plugin.marketplaceSource; + if (!source) { + throw new Error( + `Claude marketplace '${plugin.marketplace ?? 'unknown'}' has no source`, + ); + } + if (isAbsolute(source)) { + return { + source: { + source: 'directory', + path: source, + }, + }; + } + const parsed = isGitHubUrl(source) ? parseGitHubUrl(source) : null; + if (parsed) { + return { + source: { + source: 'github', + repo: `${parsed.owner}/${parsed.repo}`, + ...((plugin.resolvedRef ?? parsed.branch) && { + ref: plugin.resolvedRef ?? parsed.branch, + }), + }, + }; + } + try { + const url = new URL(source); + return { + source: { + source: 'url', + url: url.toString(), + ...(plugin.resolvedRef && { ref: plugin.resolvedRef }), + }, + }; + } catch { + throw new Error( + `Claude marketplace registration source '${source}' cannot be serialized safely`, + ); + } +} + +function serializeClaudeMcp( + input: ProfileSerializationInput, +): Readonly> { + const selected = serializeProfileMcpServers(input, 'claude') ?? {}; + const servers: Record = {}; + for (const [name, value] of Object.entries(selected)) { + const server = ProfileMcpServerConfigSchema.parse(value); + servers[name] = + 'url' in server + ? { + type: 'http', + url: server.url, + ...(server.headers && { headers: server.headers }), + } + : { + type: 'stdio', + command: server.command, + ...(server.args && { args: server.args }), + ...(server.env && { env: server.env }), + }; + } + return Object.freeze(servers); +} + +export class ClaudeProfileAdapter implements NativeProfileAdapter { + readonly client = 'claude' as const; + readonly capabilities = CAPABILITIES; + readonly nativeClient = new ClaudeNativeClient({ + minimumVersion: CLAUDE_MINIMUM_VERSION, + }); + stepOrder(kind: ProfileStepKind, operation: ProfileOperationKind): number { + const rank: Record = + operation === 'remove' + ? { + launcher: 0, + native: 1, + marketplace: 2, + file: 3, + settings: 3, + mcp: 3, + root: 4, + } + : { + root: 0, + file: 1, + native: 2, + marketplace: 3, + settings: 4, + mcp: 5, + launcher: 6, + }; + return rank[kind]; + } + + resolveContext( + profileName: string, + options: ProfileContextOptions, + ): ProfileClientContext { + ProfileNameSchema.parse(profileName); + const homeDir = resolve(options.homeDir); + const workspaceDirectory = resolve(options.workspaceDirectory); + const root = join( + homeDir, + '.allagents', + 'profiles', + profileName, + 'clients', + 'claude', + 'config', + ); + const pluginRoot = join(root, 'plugins'); + const settingsPath = join(root, 'settings.json'); + const mcpPath = join(root, 'allagents.mcp.json'); + const selectedEnvironment = Object.freeze({ + CLAUDE_CONFIG_DIR: root, + CLAUDE_CODE_PLUGIN_CACHE_DIR: pluginRoot, + CLAUDE_CODE_PLUGIN_SEED_DIR: undefined, + CLAUDE_CODE_PROJECT_DIR_NAME: undefined, + }); + const operationContext = Object.freeze({ + client: this.client, + scope: 'user' as const, + nativeScope: `profile:${profileName}`, + root, + cwd: workspaceDirectory, + env: Object.freeze({ + ...options.environment, + ...selectedEnvironment, + }), + roots: Object.freeze({ + config: root, + agent: root, + data: root, + plugins: pluginRoot, + }), + }); + return Object.freeze({ + profileName, + client: this.client, + mechanism: 'configuration-root', + root, + operationContext, + fileMapping: FILE_MAPPING, + launcher: Object.freeze({ + command: 'claude', + args: Object.freeze(['--mcp-config', mcpPath]), + env: selectedEnvironment, + requiredFiles: Object.freeze([settingsPath, mcpPath]), + }), + }); + } + + async isRuntimeAvailable(context: ProfileClientContext): Promise { + assertClaudeContext(context); + return this.nativeClient.isAvailable(context.operationContext); + } + + resolveNativeMetadata( + plugin: ProfileResolvedPlugin, + context: ProfileClientContext, + options: ProfileNativeMetadataOptions, + ): Promise { + assertClaudeContext(context); + return resolveClaudeProfileMetadata( + plugin, + context, + options, + this.nativeClient, + ); + } + + resolveNativeSource( + plugin: ProfileResolvedPlugin, + context: ProfileClientContext, + ): NativeSourceResolution { + assertClaudeContext(context); + if (plugin.install !== 'native') { + return { + success: false, + error: + 'Claude profile native source resolution requires install mode native', + }; + } + if (plugin.skills !== undefined) { + return { + success: false, + error: 'Claude native profile skill filtering is unsupported', + }; + } + if (plugin.marketplaceSparsePath) { + return { + success: false, + error: + 'Claude native profile marketplaces cannot declaratively preserve sparse paths; use file install', + }; + } + if (!plugin.marketplace || !plugin.pluginName) { + return { + success: false, + error: + 'Claude native profile installation requires authoritative marketplace metadata', + }; + } + const resolved = this.nativeClient.resolveSource( + `${plugin.pluginName}@${plugin.marketplace}`, + context.operationContext, + { + declarationIndex: String(plugin.declarationIndex), + marketplaceName: plugin.marketplace, + ...(plugin.marketplaceSource && { + marketplaceSource: plugin.marketplaceSource, + }), + ...(plugin.marketplaceRegistrationManaged && { + managedMarketplaceRegistration: 'true', + }), + ...(plugin.requestedRef && { requestedRef: plugin.requestedRef }), + ...(plugin.resolvedRef && { resolvedRef: plugin.resolvedRef }), + ...(plugin.resolvedSha && { resolvedSha: plugin.resolvedSha }), + }, + ); + if (!resolved.resource) return resolved; + return { + ...resolved, + resource: { + ...resolved.resource, + requestedIdentity: plugin.source, + }, + }; + } + + discloseNativeCommands( + request: ProfileNativeCommandRequest, + context: ProfileClientContext, + ) { + assertClaudeContext(context); + if (!['create', 'update', 'remove'].includes(request.action)) return []; + if (request.kind === 'marketplace') { + if (request.action === 'create') { + return [ + { + command: 'claude', + args: [ + 'plugin', + 'marketplace', + 'add', + request.registration.source, + '--scope', + 'user', + ], + }, + ]; + } + if (request.action === 'remove') { + return [ + { + command: 'claude', + args: [ + 'plugin', + 'marketplace', + 'remove', + request.registration.name, + '--scope', + 'user', + ], + }, + ]; + } + return [ + { + command: 'claude', + args: ['plugin', 'marketplace', 'update', request.registration.name], + }, + ]; + } + + const commands = []; + const marketplaceName = request.resource.provenance.marketplaceName; + const marketplaceSource = request.resource.provenance.marketplaceSource; + if ( + request.action === 'create' && + marketplaceSource && + request.resource.provenance.managedMarketplaceRegistration === 'true' + ) { + const sourceArgument = + request.resource.provenance.resolvedRef && + /^[^/:]+\/[^/]+$/.test(marketplaceSource) + ? `${marketplaceSource}@${request.resource.provenance.resolvedRef}` + : marketplaceSource; + commands.push({ + command: 'claude', + args: [ + 'plugin', + 'marketplace', + 'add', + sourceArgument, + '--scope', + 'user', + ], + }); + } else if ( + request.action === 'update' && + marketplaceSource && + marketplaceName + ) { + commands.push({ + command: 'claude', + args: ['plugin', 'marketplace', 'update', marketplaceName], + }); + } + + const verb = + request.action === 'create' + ? 'install' + : request.action === 'remove' + ? 'uninstall' + : 'update'; + commands.push({ + command: 'claude', + args: [ + 'plugin', + verb, + request.resource.resolvedIdentity, + '--scope', + 'user', + '--yes', + '--json', + ], + }); + return commands; + } + + inspectMarketplaceRegistration( + marketplaceName: string, + context: ProfileClientContext, + ) { + assertClaudeContext(context); + return this.nativeClient.inspectMarketplaceRegistration( + marketplaceName, + context.operationContext, + ); + } + + removeMarketplaceRegistration( + marketplaceName: string, + context: ProfileClientContext, + ) { + assertClaudeContext(context); + return this.nativeClient.removeMarketplaceRegistration( + marketplaceName, + context.operationContext, + ); + } + + serializeSettings( + context: ProfileClientContext, + input: ProfileSerializationInput, + ): ProfilePlannedFile { + assertClaudeContext(context); + const settings = ClaudeProfileSettingsSchema.parse(input.settings ?? {}); + const nativePlugins = input.plugins + .filter( + (plugin) => + plugin.install === 'native' && + plugin.marketplace !== undefined && + plugin.pluginName !== undefined, + ) + .sort((left, right) => + `${left.pluginName}@${left.marketplace}`.localeCompare( + `${right.pluginName}@${right.marketplace}`, + ), + ); + const enabledPlugins = Object.fromEntries( + nativePlugins.map((plugin) => [ + `${plugin.pluginName}@${plugin.marketplace}`, + true, + ]), + ); + const extraKnownMarketplaces = Object.fromEntries( + nativePlugins + .filter( + ( + plugin, + ): plugin is ProfileResolvedPlugin & { marketplaceSource: string } => + plugin.marketplaceSource !== undefined, + ) + .map((plugin) => [ + plugin.marketplace, + claudeMarketplaceSetting(plugin), + ]), + ); + return Object.freeze({ + key: 'claude:settings', + client: this.client, + kind: 'settings' as const, + path: join(context.root, 'settings.json'), + content: `${JSON.stringify( + { + ...settings, + ...(Object.keys(extraKnownMarketplaces).length > 0 && { + extraKnownMarketplaces, + }), + ...(Object.keys(enabledPlugins).length > 0 && { enabledPlugins }), + }, + null, + 2, + )}\n`, + mode: 0o600, + }); + } + + serializeMcp( + context: ProfileClientContext, + input: ProfileSerializationInput, + ): ProfilePlannedFile { + assertClaudeContext(context); + const mcpServers = serializeClaudeMcp(input); + return Object.freeze({ + key: 'claude:mcp', + client: this.client, + kind: 'mcp' as const, + path: join(context.root, 'allagents.mcp.json'), + content: `${JSON.stringify({ mcpServers }, null, 2)}\n`, + mode: 0o600, + }); + } +} + +export const claudeProfileAdapter = Object.freeze(new ClaudeProfileAdapter()); diff --git a/src/core/profile/adapters/registry.ts b/src/core/profile/adapters/registry.ts index 060a575b..183fc425 100644 --- a/src/core/profile/adapters/registry.ts +++ b/src/core/profile/adapters/registry.ts @@ -1,5 +1,6 @@ import type { ClientType } from '../../../models/workspace-config.js'; import type { ProfileAdapter } from '../types.js'; +import { claudeProfileAdapter } from './claude.js'; import { copilotProfileAdapter } from './copilot.js'; import { codexProfileAdapter } from './codex.js'; import { ompProfileAdapter } from './omp.js'; @@ -8,6 +9,7 @@ import { piProfileAdapter } from './pi.js'; const PROFILE_ADAPTERS: Readonly>> = Object.freeze({ + claude: claudeProfileAdapter, copilot: copilotProfileAdapter, codex: codexProfileAdapter, pi: piProfileAdapter, @@ -19,6 +21,7 @@ export function getProfileAdapter(client: ClientType): ProfileAdapter | null { return PROFILE_ADAPTERS[client] ?? null; } +export { ClaudeProfileAdapter, claudeProfileAdapter } from './claude.js'; export { CopilotProfileAdapter, copilotProfileAdapter, diff --git a/src/core/profile/native-metadata.ts b/src/core/profile/native-metadata.ts index ed277a70..73123a67 100644 --- a/src/core/profile/native-metadata.ts +++ b/src/core/profile/native-metadata.ts @@ -2,11 +2,13 @@ import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { isAbsolute, join, resolve, sep } from 'node:path'; import type { + ClaudeNativeClient, CodexNativeClient, CopilotNativeClient, } from '../native/index.js'; import { inspectOmpMarketplaceRegistry, + parseClaudePluginId, parseCodexPluginId, parseCopilotPluginId, parseOmpPluginId, @@ -423,3 +425,126 @@ export async function resolveCodexProfileMetadata( await source.cleanup?.(); } } + +export async function resolveClaudeProfileMetadata( + plugin: ProfileResolvedPlugin, + context: ProfileClientContext, + options: ProfileNativeMetadataOptions, + nativeClient: ClaudeNativeClient, +): Promise { + const exact = parseClaudePluginId(plugin.source); + if (exact) { + if (plugin.requestedRef || plugin.resolvedRef) { + throw new Error( + `Claude plugin identity '${plugin.source}' cannot select a marketplace ref`, + ); + } + const registration = await nativeClient.inspectMarketplaceRegistration( + exact.marketplace, + context.operationContext, + ); + if (!registration.success) { + throw new Error( + registration.error ?? + 'Could not inspect the selected Claude profile marketplace registry', + ); + } + if (!registration.present) { + throw new Error( + `Claude plugin '${plugin.source}' references an unregistered marketplace`, + ); + } + const catalog = await nativeClient.inspectMarketplacePlugin( + exact.marketplace, + exact.plugin, + context.operationContext, + ); + if (!catalog.success || !catalog.present) { + throw new Error( + catalog.error ?? + `Claude plugin '${plugin.source}' is not an authoritative catalog identity`, + ); + } + return Object.freeze({ + ...plugin, + marketplace: exact.marketplace, + pluginName: exact.plugin, + ...(registration.source && { marketplaceSource: registration.source }), + ...(registration.ref && { resolvedRef: registration.ref }), + }); + } + + const source = await resolveProfileFileSource( + { ...plugin, install: 'file' }, + options, + ); + try { + const parsedSource = isGitHubUrl(source.source) + ? parseGitHubUrl(source.source) + : null; + if (parsedSource?.subpath) { + throw new Error( + 'Claude native profile marketplaces cannot preserve sparse paths; use file install', + ); + } + const catalog = await parseMarketplaceManifest(source.path); + if (!catalog.success) throw new Error(catalog.error); + if (catalog.data.plugins.length !== 1 || !catalog.data.plugins[0]) { + throw new Error( + `Claude marketplace source '${plugin.source}' must expose exactly one catalog plugin`, + ); + } + const registration = await nativeClient.inspectMarketplaceRegistration( + catalog.data.name, + context.operationContext, + ); + if (!registration.success) { + throw new Error( + registration.error ?? + 'Could not inspect the selected Claude profile marketplace registry', + ); + } + const registrationSource = parsedSource + ? `${parsedSource.owner}/${parsedSource.repo}` + : resolve(source.path); + if (registration.present) { + if ( + registration.source && + normalizedMarketplaceSource(registration.source) !== + normalizedMarketplaceSource(registrationSource) + ) { + throw new Error( + `Claude marketplace '${catalog.data.name}' is already registered from a different source`, + ); + } + if (source.resolvedRef && registration.ref !== source.resolvedRef) { + throw new Error( + `Claude marketplace '${catalog.data.name}' is registered at a different ref`, + ); + } + const liveCatalog = await nativeClient.inspectMarketplacePlugin( + catalog.data.name, + catalog.data.plugins[0].name, + context.operationContext, + ); + if (!liveCatalog.success || !liveCatalog.present) { + throw new Error( + liveCatalog.error ?? + `Claude marketplace '${catalog.data.name}' does not expose plugin '${catalog.data.plugins[0].name}'`, + ); + } + } + return Object.freeze({ + ...plugin, + marketplace: catalog.data.name, + pluginName: catalog.data.plugins[0].name, + path: source.path, + marketplaceSource: registration.source ?? registrationSource, + marketplaceRegistrationManaged: !registration.present, + ...(source.resolvedRef && { resolvedRef: source.resolvedRef }), + ...(source.resolvedSha && { resolvedSha: source.resolvedSha }), + }); + } finally { + await source.cleanup?.(); + } +} diff --git a/src/core/profile/plan.ts b/src/core/profile/plan.ts index 0e2cb3af..1af5b58e 100644 --- a/src/core/profile/plan.ts +++ b/src/core/profile/plan.ts @@ -580,6 +580,8 @@ function mcpDisclosures( + + async function planRoot( client: ClientType, context: ProfileClientContext, diff --git a/src/models/workspace-config.ts b/src/models/workspace-config.ts index 0330e49b..8880e09c 100644 --- a/src/models/workspace-config.ts +++ b/src/models/workspace-config.ts @@ -126,39 +126,24 @@ export type InstallMode = z.infer; * "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([ - z.string().transform((s, ctx) => { - const colonIdx = s.indexOf(':'); - if (colonIdx === -1) { - // Bare string — validate as client type - const result = ClientTypeSchema.safeParse(s); - if (!result.success) { - for (const issue of result.error.issues) ctx.addIssue(issue); - return z.NEVER; - } - return result.data; - } - // Colon shorthand — split on first colon - const name = s.slice(0, colonIdx); - const mode = s.slice(colonIdx + 1); - const nameResult = ClientTypeSchema.safeParse(name); - if (!nameResult.success) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Invalid client type: '${name}'`, - }); - return z.NEVER; - } - const modeResult = InstallModeSchema.safeParse(mode); - if (!modeResult.success) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Invalid install mode: '${mode}'. Valid modes: ${InstallModeSchema.options.join(', ')}`, - }); - return z.NEVER; - } - return { name: nameResult.data, install: modeResult.data }; - }), + ClientTypeSchema, + ClientInstallShorthandSchema, z.object({ name: ClientTypeSchema, install: InstallModeSchema.default('file'), @@ -414,24 +399,9 @@ export const ProfileSecretReferenceSchema = z */ export const ProfileNameSchema = z .string() - .min(1) - .max(64) .regex( - /^[a-z0-9][a-z0-9._-]{0,63}$/, - 'Expected 1-64 lowercase ASCII characters starting with a letter or number', - ) - .refine((name) => name !== '.' && name !== '..', { - message: "'.' and '..' are not valid profile or launcher names", - }) - .refine((name) => !name.endsWith('.'), { - message: 'Profile and launcher names cannot end with a dot', - }) - .refine( - (name) => - !/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name), - { - message: 'Reserved device basenames are not allowed', - }, + /^(?!(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$))(?!.*\.$)[a-z0-9][a-z0-9._-]{0,63}$/, + 'Expected 1-64 portable lowercase ASCII characters starting with a letter or number', ); export type ProfileName = z.infer; @@ -446,6 +416,22 @@ export function getLauncherCollisionKey(name: string): string { const EmptyProfileSettingsSchema = z.object({}).strict(); +export const ClaudeProfileSettingsSchema = z + .object({ + model: z.string().min(1).optional(), + effortLevel: z.enum(['low', 'medium', 'high', 'xhigh']).optional(), + fallbackModel: z.array(z.string().min(1)).min(1).optional(), + outputStyle: z.string().min(1).optional(), + autoMemoryEnabled: z.boolean().optional(), + spinnerTipsEnabled: z.boolean().optional(), + autoUpdatesChannel: z.enum(['stable', 'latest']).optional(), + }) + .strict(); + +export type ClaudeProfileSettings = z.infer< + typeof ClaudeProfileSettingsSchema +>; + export const OpenCodeProfileSettingsSchema = z .object({ model: z.string().min(1).optional(), @@ -528,38 +514,82 @@ export type CodexProfileSettings = z.infer< typeof CodexProfileSettingsSchema >; - /** - * Profile clients deliberately use object form only. Unsupported clients still - * parse with empty settings so orchestration can report an adapter capability - * error instead of misclassifying a valid public client name as bad syntax. + * Profile clients deliberately use object form only. Each adapter owns a + * structural settings schema so runtime validation and generated JSON Schema + * expose the same input contract. Clients without an adapter accept only an + * empty settings object, allowing orchestration to report capability errors. */ -export const ProfileClientSchema = z - .object({ - name: ClientTypeSchema, - install: InstallModeSchema.default('file'), - launcher: ProfileNameSchema.optional(), - settings: z.record(z.unknown()).default({}), - }) - .strict() - .superRefine((client, context) => { - const settingsSchema = - client.name === 'opencode' - ? OpenCodeProfileSettingsSchema - : client.name === 'copilot' - ? CopilotProfileSettingsSchema - : client.name === 'codex' - ? CodexProfileSettingsSchema - : EmptyProfileSettingsSchema; - const result = settingsSchema.safeParse(client.settings); - if (result.success) return; - for (const issue of result.error.issues) { - context.addIssue({ - ...issue, - path: ['settings', ...issue.path], - }); - } - }); +const ProfileClientCommonShape = { + install: InstallModeSchema.default('file'), + launcher: ProfileNameSchema.optional(), +} as const; + +export const ProfileClientSchema = z.union([ + z + .object({ + name: z.literal('claude'), + ...ProfileClientCommonShape, + settings: ClaudeProfileSettingsSchema.default({}), + }) + .strict(), + z + .object({ + name: z.literal('opencode'), + ...ProfileClientCommonShape, + settings: OpenCodeProfileSettingsSchema.default({}), + }) + .strict(), + z + .object({ + name: z.literal('copilot'), + ...ProfileClientCommonShape, + settings: CopilotProfileSettingsSchema.default({}), + }) + .strict(), + z + .object({ + name: z.literal('codex'), + ...ProfileClientCommonShape, + settings: CodexProfileSettingsSchema.default({}), + }) + .strict(), + z + .object({ + name: z.enum(['pi', 'omp']), + ...ProfileClientCommonShape, + settings: EmptyProfileSettingsSchema.default({}), + }) + .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', + ]), + ...ProfileClientCommonShape, + settings: EmptyProfileSettingsSchema.default({}), + }) + .strict(), +]); export type ProfileClient = z.infer; diff --git a/tests/unit/core/native/claude.test.ts b/tests/unit/core/native/claude.test.ts index 8bcf3bc4..8b9a92d1 100644 --- a/tests/unit/core/native/claude.test.ts +++ b/tests/unit/core/native/claude.test.ts @@ -1,55 +1,516 @@ -import { describe, expect, test } from 'bun:test'; -import { ClaudeNativeClient } from '../../../../src/core/native/claude.js'; +import { afterEach, describe, expect, test } from 'bun:test'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + ClaudeNativeClient, + parseClaudeMarketplaceInventory, + parseClaudePluginId, + parseClaudePluginInventory, +} from '../../../../src/core/native/claude.js'; +import type { + NativeCommandOptions, + NativeCommandResult, + NativeOperationContext, +} from '../../../../src/core/native/types.js'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +async function fixture(): Promise { + const parent = await mkdtemp(join(tmpdir(), 'allagents-claude-native-')); + roots.push(parent); + return { + client: 'claude', + scope: 'user', + nativeScope: 'profile:work', + root: join(parent, 'config'), + cwd: join(parent, 'project'), + env: { + CLAUDE_CONFIG_DIR: join(parent, 'config'), + CLAUDE_CODE_PLUGIN_CACHE_DIR: join(parent, 'config', 'plugins'), + CLAUDE_CODE_PLUGIN_SEED_DIR: undefined, + }, + }; +} + +function result( + output: string, + success = true, +): NativeCommandResult { + return { success, output, exitCode: success ? 0 : 1 }; +} describe('native/claude', () => { - const client = new ClaudeNativeClient(); + test('parses exact plugin and marketplace inventories', () => { + expect(parseClaudePluginId('tool@catalog')).toEqual({ + plugin: 'tool', + marketplace: 'catalog', + }); + expect(parseClaudePluginId('tool@owner/catalog')).toBeNull(); + expect( + parseClaudePluginInventory( + JSON.stringify([ + { + id: 'tool@catalog', + scope: 'user', + enabled: true, + }, + { + id: 'disabled@catalog', + scope: 'user', + enabled: false, + }, + ]), + ), + ).toEqual({ + installed: [ + { id: 'tool@catalog', scope: 'user', enabled: true }, + { id: 'disabled@catalog', scope: 'user', enabled: false }, + ], + available: [], + }); + expect( + parseClaudePluginInventory( + JSON.stringify({ + installed: [], + available: [ + { + pluginId: 'tool@catalog', + name: 'tool', + marketplaceName: 'catalog', + }, + ], + }), + )?.available, + ).toEqual([{ id: 'tool@catalog', enabled: false }]); + for (const key of [ + 'plugins', + 'installedPlugins', + 'installed_plugins', + ]) { + expect( + parseClaudePluginInventory( + JSON.stringify({ [key]: [{ name: 'legacy@catalog' }] }), + ), + ).toEqual({ + installed: [{ id: 'legacy@catalog', enabled: true }], + available: [], + }); + } + expect( + parseClaudeMarketplaceInventory( + JSON.stringify([ + { + name: 'catalog', + source: 'github', + repo: 'owner/repo', + ref: 'stable', + installLocation: '/cache/catalog', + }, + ]), + ), + ).toEqual([ + { + name: 'catalog', + sourceType: 'github', + source: 'owner/repo', + ref: 'stable', + }, + ]); + expect(parseClaudePluginInventory('{')).toBeNull(); + expect(parseClaudeMarketplaceInventory('{}')).toBeNull(); + }); - describe('toPluginSpec', () => { - test('converts marketplace spec — drops owner, keeps repo', () => { - expect(client.toPluginSpec('superpowers@obra/superpowers-marketplace')).toBe( - 'superpowers@superpowers-marketplace', - ); + test('preserves legacy marketplace source conversion for ordinary sync', () => { + const client = new ClaudeNativeClient(); + expect( + client.toPluginSpec('superpowers@obra/superpowers-marketplace'), + ).toBe('superpowers@superpowers-marketplace'); + expect(client.toPluginSpec('superpowers@superpowers-marketplace')).toBe( + 'superpowers@superpowers-marketplace', + ); + expect( + client.toPluginSpec('vercel-labs/agent-browser/skills/agent-browser'), + ).toBeNull(); + expect(client.toPluginSpec('plugin@owner/')).toBeNull(); + expect( + client.resolveSource( + 'superpowers@obra/superpowers-marketplace', + { + client: 'claude', + scope: 'project', + nativeScope: 'project', + root: '/project', + }, + { marketplaceSource: 'obra/superpowers-marketplace' }, + ).resource?.provenance, + ).toEqual({ + marketplaceName: 'superpowers-marketplace', + marketplaceSource: 'obra/superpowers-marketplace', }); + expect( + client.extractMarketplaceSource( + 'superpowers@obra/superpowers-marketplace', + ), + ).toBe('obra/superpowers-marketplace'); + expect( + client.extractMarketplaceSource('superpowers@superpowers-marketplace'), + ).toBeNull(); + expect(client.supportsScope('user')).toBe(true); + expect(client.supportsScope('project')).toBe(true); + }); - test('preserves plugin@marketplace format', () => { - expect(client.toPluginSpec('superpowers@superpowers-marketplace')).toBe( - 'superpowers@superpowers-marketplace', - ); + test('checks the supported version in a disposable config root', async () => { + const calls: Array<{ + args: string[]; + options?: NativeCommandOptions; + }> = []; + const client = new ClaudeNativeClient({ + minimumVersion: [2, 1, 268], + execute: async (_binary, args, options) => { + calls.push({ args, options }); + return args[0] === '--version' + ? result('2.1.270 (Claude Code)') + : result('install list marketplace uninstall update'); + }, }); + expect(await client.isAvailable()).toBe(true); + expect(calls).toHaveLength(2); + const root = calls[0]?.options?.env?.CLAUDE_CONFIG_DIR; + expect(root).toStartWith(join(tmpdir(), 'allagents-claude-inspection-')); + expect(calls[0]?.options?.env?.CLAUDE_CODE_PLUGIN_CACHE_DIR).toBe( + join(root as string, 'plugins'), + ); + expect(calls[0]?.options?.env?.CLAUDE_CODE_PLUGIN_SEED_DIR).toBeUndefined(); - test('returns null for direct GitHub paths', () => { - expect(client.toPluginSpec('vercel-labs/agent-browser/skills/agent-browser')).toBeNull(); + const old = new ClaudeNativeClient({ + minimumVersion: [2, 1, 268], + execute: async () => result('2.1.267 (Claude Code)'), }); + expect(await old.isAvailable()).toBe(false); + }); - test('returns null for empty string', () => { - expect(client.toPluginSpec('')).toBeNull(); + test('does not invoke Claude when an inspected profile root is absent', async () => { + const context = await fixture(); + let calls = 0; + const client = new ClaudeNativeClient({ + execute: async () => { + calls++; + return result('[]'); + }, }); + expect(await client.inspect(context)).toEqual({ + success: true, + resources: [], + }); + expect(calls).toBe(0); + }); - test('returns null for trailing slash in marketplace', () => { - expect(client.toPluginSpec('plugin@owner/')).toBeNull(); + test('reports selected user plugins and disabled observations', async () => { + const context = await fixture(); + await mkdir(context.root, { recursive: true }); + const client = new ClaudeNativeClient({ + execute: async () => + result( + JSON.stringify([ + { id: 'active@catalog', scope: 'user', enabled: true }, + { id: 'disabled@catalog', scope: 'user', enabled: false }, + { id: 'project@catalog', scope: 'project', enabled: true }, + ]), + ), }); + const inspection = await client.inspect(context); + expect( + inspection.resources.map((resource) => resource.resolvedIdentity), + ).toEqual(['active@catalog']); + expect(inspection.observations).toEqual([ + expect.objectContaining({ + status: 'disabled', + resource: expect.objectContaining({ + resolvedIdentity: 'disabled@catalog', + }), + }), + ]); }); - describe('supportsScope', () => { - test('supports both user and project scope', () => { - expect(client.supportsScope('user')).toBe(true); - expect(client.supportsScope('project')).toBe(true); + test('registers, installs, updates, and removes in isolated user scope', async () => { + const context = await fixture(); + await mkdir(context.root, { recursive: true }); + const calls: string[][] = []; + let marketplacePresent = false; + const client = new ClaudeNativeClient({ + execute: async (_binary, args) => { + calls.push(args); + if (args.join(' ') === 'plugin marketplace list --json') { + return result( + JSON.stringify( + marketplacePresent + ? [ + { + name: 'catalog', + source: 'directory', + path: '/market', + installLocation: '/market', + }, + ] + : [], + ), + ); + } + if (args[2] === 'add') { + marketplacePresent = true; + return result('Successfully added'); + } + if (args[1] === 'install') { + return result( + JSON.stringify({ + command: 'install', + outcome: 'ok', + pluginId: 'tool@catalog', + scope: 'user', + }), + ); + } + if (args[1] === 'update' && args[0] === 'plugin') { + return result( + `notice\n${JSON.stringify({ + command: 'update', + outcome: 'ok', + pluginId: 'tool@catalog', + scope: 'user', + })}`, + ); + } + if (args[1] === 'uninstall') { + return result( + JSON.stringify({ + command: 'uninstall', + outcome: 'ok', + pluginId: 'tool@catalog', + scope: 'user', + }), + ); + } + return result('updated marketplace'); + }, }); + const resource = client.resolveSource( + 'tool@catalog', + context, + { + marketplaceName: 'catalog', + marketplaceSource: '/market', + managedMarketplaceRegistration: 'true', + }, + ).resource; + expect(resource).toBeDefined(); + + expect(await client.install(resource!, context)).toEqual({ + success: true, + registrations: ['catalog'], + }); + expect(calls).toContainEqual([ + 'plugin', + 'marketplace', + 'add', + '/market', + '--scope', + 'user', + ]); + expect(calls).toContainEqual([ + 'plugin', + 'install', + 'tool@catalog', + '--scope', + 'user', + '--yes', + '--json', + ]); + + expect(await client.update(resource!, resource!, context)).toEqual({ + success: true, + }); + expect(calls).toContainEqual([ + 'plugin', + 'marketplace', + 'update', + 'catalog', + ]); + expect(await client.remove(resource!, context)).toEqual({ success: true }); + expect(calls).toContainEqual([ + 'plugin', + 'uninstall', + 'tool@catalog', + '--scope', + 'user', + '--yes', + '--json', + ]); }); - describe('extractMarketplaceSource', () => { - test('extracts owner/repo from marketplace spec', () => { - expect(client.extractMarketplaceSource('superpowers@obra/superpowers-marketplace')).toBe( - 'obra/superpowers-marketplace', - ); + test('reports a newly added marketplace when verification fails', async () => { + const context = await fixture(); + await mkdir(context.root, { recursive: true }); + let inspections = 0; + const client = new ClaudeNativeClient({ + execute: async (_binary, args) => { + if (args.join(' ') === 'plugin marketplace list --json') { + inspections++; + return inspections === 1 + ? result('[]') + : result('', false); + } + return result('Successfully added'); + }, + }); + const resource = client.resolveSource('tool@catalog', context, { + marketplaceSource: '/market', + managedMarketplaceRegistration: 'true', + }).resource; + + expect(await client.install(resource!, context)).toEqual({ + success: false, + error: 'Claude CLI exited with code 1', + registrations: ['catalog'], + }); + }); + + test('preserves ordinary project scope outside profiles', async () => { + const context = await fixture(); + const projectContext = { ...context, nativeScope: 'project' }; + await mkdir(projectContext.root, { recursive: true }); + const calls: string[][] = []; + const client = new ClaudeNativeClient({ + execute: async (_binary, args) => { + calls.push(args); + return result('ok'); + }, + }); + const resource = client.resolveSource('tool@catalog', projectContext, { + marketplaceSource: '/market', + }).resource; + + expect(await client.install(resource!, projectContext)).toEqual({ + success: true, + registrations: ['/market'], + }); + expect(await client.update(resource!, resource!, projectContext)).toEqual({ + success: true, }); + expect(await client.remove(resource!, projectContext)).toEqual({ + success: true, + }); + expect(calls).toContainEqual([ + 'plugin', + 'marketplace', + 'add', + '/market', + ]); + expect(calls).toContainEqual([ + 'plugin', + 'install', + 'tool@catalog', + '--scope', + 'project', + ]); + expect(calls).toContainEqual([ + 'plugin', + 'update', + 'tool@catalog', + '--scope', + 'project', + ]); + expect(calls).toContainEqual([ + 'plugin', + 'uninstall', + 'tool@catalog', + '--scope', + 'project', + ]); + }); - test('returns null for non-marketplace specs', () => { - expect(client.extractMarketplaceSource('vercel-labs/agent-browser/skills/agent-browser')).toBeNull(); + test('preserves declarative profile settings across Claude mutations', async () => { + const context = await fixture(); + await mkdir(context.root, { recursive: true }); + const settingsPath = join(context.root, 'settings.json'); + const declarativeSettings = + '{\n "enabledPlugins": {\n "tool@catalog": true\n }\n}\n'; + await writeFile(settingsPath, declarativeSettings, { mode: 0o600 }); + const client = new ClaudeNativeClient({ + execute: async (_binary, args) => { + if (args.join(' ') === 'plugin marketplace list --json') { + return result( + JSON.stringify([ + { + name: 'catalog', + source: 'directory', + path: '/market', + installLocation: '/market', + }, + ]), + ); + } + if (args[1] === 'install') { + await writeFile( + settingsPath, + '{"enabledPlugins":{"tool@catalog":true}}\n', + ); + return result( + JSON.stringify({ + command: 'install', + outcome: 'ok', + pluginId: 'tool@catalog', + scope: 'user', + }), + ); + } + return result(''); + }, }); + const resource = client.resolveSource('tool@catalog', context, { + marketplaceName: 'catalog', + marketplaceSource: '/market', + }).resource; - test('returns null for plain marketplace name', () => { - expect(client.extractMarketplaceSource('superpowers@superpowers-marketplace')).toBeNull(); + expect(await client.install(resource!, context)).toEqual({ success: true }); + expect(await readFile(settingsPath, 'utf8')).toBe(declarativeSettings); + }); + + test('refuses to remove a marketplace with another installed plugin', async () => { + const context = await fixture(); + await mkdir(context.root, { recursive: true }); + const client = new ClaudeNativeClient({ + execute: async (_binary, args) => { + if (args[1] === 'marketplace') { + return result( + JSON.stringify([ + { + name: 'catalog', + source: 'directory', + path: '/market', + installLocation: '/market', + }, + ]), + ); + } + return result( + JSON.stringify([ + { id: 'other@catalog', scope: 'user', enabled: false }, + ]), + ); + }, + }); + expect( + await client.removeMarketplaceRegistration('catalog', context), + ).toEqual({ + success: false, + error: "Claude marketplace 'catalog' is still used by installed plugins", }); }); }); diff --git a/tests/unit/core/profile/adapters.test.ts b/tests/unit/core/profile/adapters.test.ts index 740153fb..9647e22c 100644 --- a/tests/unit/core/profile/adapters.test.ts +++ b/tests/unit/core/profile/adapters.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { ClaudeProfileAdapter } from '../../../../src/core/profile/adapters/claude.js'; import { CopilotProfileAdapter } from '../../../../src/core/profile/adapters/copilot.js'; import { CodexProfileAdapter } from '../../../../src/core/profile/adapters/codex.js'; import { OmpProfileAdapter } from '../../../../src/core/profile/adapters/omp.js'; @@ -464,6 +465,6 @@ describe('profile adapter registry', () => { ); expect(getProfileAdapter('copilot')).toBeInstanceOf(CopilotProfileAdapter); expect(getProfileAdapter('codex')).toBeInstanceOf(CodexProfileAdapter); - expect(getProfileAdapter('claude')).toBeNull(); + expect(getProfileAdapter('claude')).toBeInstanceOf(ClaudeProfileAdapter); }); }); diff --git a/tests/unit/core/profile/claude.test.ts b/tests/unit/core/profile/claude.test.ts new file mode 100644 index 00000000..0ea4f289 --- /dev/null +++ b/tests/unit/core/profile/claude.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it } from 'bun:test'; +import { join } from 'node:path'; +import { ClaudeProfileAdapter } from '../../../../src/core/profile/adapters/claude.js'; + +describe('Claude profile adapter', () => { + it('isolates the Claude config and plugin roots while preserving cwd', () => { + const adapter = new ClaudeProfileAdapter(); + const context = adapter.resolveContext('review', { + homeDir: '/home/test', + workspaceDirectory: '/work/project', + environment: { + CLAUDE_CONFIG_DIR: '/ambient/claude', + CLAUDE_CODE_PLUGIN_CACHE_DIR: '/ambient/plugins', + CLAUDE_CODE_PLUGIN_SEED_DIR: '/ambient/seed', + CLAUDE_CODE_PROJECT_DIR_NAME: 'ambient-project', + KEEP_ME: 'yes', + }, + }); + const root = '/home/test/.allagents/profiles/review/clients/claude/config'; + const plugins = join(root, 'plugins'); + const selectedEnvironment = { + CLAUDE_CONFIG_DIR: root, + CLAUDE_CODE_PLUGIN_CACHE_DIR: plugins, + CLAUDE_CODE_PLUGIN_SEED_DIR: undefined, + CLAUDE_CODE_PROJECT_DIR_NAME: undefined, + }; + + expect(context).toEqual({ + profileName: 'review', + client: 'claude', + mechanism: 'configuration-root', + root, + operationContext: { + client: 'claude', + scope: 'user', + nativeScope: 'profile:review', + root, + cwd: '/work/project', + env: { KEEP_ME: 'yes', ...selectedEnvironment }, + roots: { + config: root, + agent: root, + data: root, + plugins, + }, + }, + fileMapping: { + commandsPath: 'commands/', + skillsPath: 'skills/', + agentsPath: 'agents/', + hooksPath: 'hooks/', + agentFile: 'CLAUDE.md', + }, + launcher: { + command: 'claude', + args: ['--mcp-config', join(root, 'allagents.mcp.json')], + env: selectedEnvironment, + requiredFiles: [ + join(root, 'settings.json'), + join(root, 'allagents.mcp.json'), + ], + }, + }); + expect(adapter.capabilities).toEqual({ + nativeInstall: true, + fileInstall: true, + launchers: true, + skillFilters: true, + mcp: true, + settings: true, + status: true, + cleanup: true, + recursiveRootCleanup: true, + }); + }); + + it('requires authoritative native identities and rejects unsupported filtering', () => { + const adapter = new ClaudeProfileAdapter(); + const context = adapter.resolveContext('review', { + homeDir: '/home/test', + workspaceDirectory: '/work/project', + }); + expect( + adapter.resolveNativeSource( + { + declarationIndex: 0, + source: 'owner/tools', + marketplace: 'tools', + pluginName: 'demo', + marketplaceSource: 'owner/tools', + marketplaceRegistrationManaged: true, + requestedRef: 'stable', + resolvedRef: 'stable', + resolvedSha: 'a'.repeat(40), + install: 'native', + }, + context, + ).resource, + ).toMatchObject({ + requestedIdentity: 'owner/tools', + resolvedIdentity: 'demo@tools', + provenance: { + marketplaceName: 'tools', + marketplaceSource: 'owner/tools', + managedMarketplaceRegistration: 'true', + requestedRef: 'stable', + resolvedRef: 'stable', + resolvedSha: 'a'.repeat(40), + }, + }); + expect( + adapter.resolveNativeSource( + { + declarationIndex: 0, + source: 'owner/tools', + install: 'native', + }, + context, + ), + ).toMatchObject({ + success: false, + error: expect.stringContaining('authoritative'), + }); + expect( + adapter.resolveNativeSource( + { + declarationIndex: 0, + source: 'owner/tools', + marketplace: 'tools', + pluginName: 'demo', + install: 'native', + skills: ['one'], + }, + context, + ), + ).toMatchObject({ + success: false, + error: expect.stringContaining('filtering'), + }); + expect( + adapter.resolveNativeSource( + { + declarationIndex: 0, + source: 'owner/tools', + marketplace: 'tools', + pluginName: 'demo', + marketplaceSparsePath: 'catalog', + install: 'native', + }, + context, + ), + ).toMatchObject({ + success: false, + error: expect.stringContaining('sparse'), + }); + }); + + it('serializes strict settings with declarative native plugin state', () => { + const adapter = new ClaudeProfileAdapter(); + const context = adapter.resolveContext('review', { + homeDir: '/home/test', + workspaceDirectory: '/work/project', + }); + const planned = adapter.serializeSettings(context, { + plugins: [ + { + declarationIndex: 0, + source: '/market', + marketplace: 'tools', + pluginName: 'demo', + marketplaceSource: '/market', + install: 'native', + }, + ], + settings: { + model: 'sonnet', + effortLevel: 'high', + fallbackModel: ['haiku'], + outputStyle: 'Explanatory', + autoMemoryEnabled: false, + spinnerTipsEnabled: false, + autoUpdatesChannel: 'stable', + }, + }); + + expect(planned.path).toBe(join(context.root, 'settings.json')); + expect(planned.mode).toBe(0o600); + expect(JSON.parse(planned.content)).toEqual({ + model: 'sonnet', + effortLevel: 'high', + fallbackModel: ['haiku'], + outputStyle: 'Explanatory', + autoMemoryEnabled: false, + spinnerTipsEnabled: false, + autoUpdatesChannel: 'stable', + extraKnownMarketplaces: { + tools: { + source: { + source: 'directory', + path: '/market', + }, + }, + }, + enabledPlugins: { + 'demo@tools': true, + }, + }); + expect(planned.content.endsWith('\n')).toBe(true); + expect( + adapter.serializeSettings(context, { plugins: [] }).content, + ).toBe('{}\n'); + }); + + it('serializes additive MCP with unresolved portable secrets', () => { + const adapter = new ClaudeProfileAdapter(); + const context = adapter.resolveContext('review', { + homeDir: '/home/test', + workspaceDirectory: '/work/project', + }); + const planned = adapter.serializeMcp(context, { + plugins: [], + mcpServers: { + local: { + command: 'node', + args: ['server.js'], + env: { LOCAL_TOKEN: '${LOCAL_TOKEN}' }, + }, + remote: { + url: 'https://mcp.example.test', + headers: { Authorization: '${REMOTE_TOKEN}' }, + }, + ignored: { command: 'ignored', clients: ['pi'] }, + }, + }); + + expect(planned.path).toBe(join(context.root, 'allagents.mcp.json')); + expect(planned.mode).toBe(0o600); + expect(JSON.parse(planned.content)).toEqual({ + mcpServers: { + local: { + type: 'stdio', + command: 'node', + args: ['server.js'], + env: { LOCAL_TOKEN: '${LOCAL_TOKEN}' }, + }, + remote: { + type: 'http', + url: 'https://mcp.example.test', + headers: { Authorization: '${REMOTE_TOKEN}' }, + }, + }, + }); + expect(planned.content).not.toContain('resolved-secret'); + expect( + adapter.serializeMcp(context, { plugins: [] }).content, + ).toBe('{\n "mcpServers": {}\n}\n'); + }); +}); diff --git a/tests/unit/models/workspace-config-profiles.test.ts b/tests/unit/models/workspace-config-profiles.test.ts index 11ff2219..6b0cf454 100644 --- a/tests/unit/models/workspace-config-profiles.test.ts +++ b/tests/unit/models/workspace-config-profiles.test.ts @@ -181,7 +181,7 @@ describe('profile workspace declarations', () => { it('accepts unsupported client names only with strict empty settings', () => { expect( UserWorkspaceConfigSchema.safeParse( - userConfigWithProfile({ clients: [{ name: 'claude', settings: {} }] }), + userConfigWithProfile({ clients: [{ name: 'cursor', settings: {} }] }), ).success, ).toBe(true); expect( @@ -200,6 +200,41 @@ describe('profile workspace declarations', () => { ).toBe(false); }); + it('accepts only conservative documented Claude profile settings', () => { + const settings = { + model: 'sonnet', + effortLevel: 'xhigh', + fallbackModel: ['haiku'], + outputStyle: 'Explanatory', + autoMemoryEnabled: false, + spinnerTipsEnabled: false, + autoUpdatesChannel: 'stable', + }; + const result = UserWorkspaceConfigSchema.parse( + userConfigWithProfile({ + clients: [{ name: 'claude', settings }], + }), + ); + expect(result.profiles?.research?.clients[0]?.settings).toEqual(settings); + + for (const invalid of [ + { unknown: true }, + { effortLevel: 'max' }, + { fallbackModel: [] }, + { fallbackModel: [''] }, + { autoUpdatesChannel: 'prerelease' }, + { autoMemoryEnabled: 'false' }, + ]) { + expect( + UserWorkspaceConfigSchema.safeParse( + userConfigWithProfile({ + clients: [{ name: 'claude', settings: invalid }], + }), + ).success, + ).toBe(false); + } + }); + it('accepts only documented OpenCode profile settings', () => { const result = UserWorkspaceConfigSchema.parse( userConfigWithProfile({ diff --git a/tests/unit/models/workspace-json-schema.test.ts b/tests/unit/models/workspace-json-schema.test.ts new file mode 100644 index 00000000..effbc348 --- /dev/null +++ b/tests/unit/models/workspace-json-schema.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from 'bun:test'; +import { readFile } from 'node:fs/promises'; +import Ajv from 'ajv'; +import { load } from 'js-yaml'; +import { generateWorkspaceSchemas } from '../../../scripts/generate-workspace-schemas.js'; +import { + ProjectWorkspaceConfigSchema, + UserWorkspaceConfigSchema, +} from '../../../src/models/workspace-config.js'; + +function parsedYaml(source: string): unknown { + return load(source); +} +describe('published workspace JSON Schemas', () => { + test('validates real user and project workspace YAML by scope', async () => { + const schemaEntries = generateWorkspaceSchemas(); + const generated = new Map( + await Promise.all( + schemaEntries.map(async (schema) => [ + schema.fileName, + JSON.parse(await readFile(schema.path, 'utf8')), + ] as const), + ), + ); + for (const schema of schemaEntries) { + expect(generated.get(schema.fileName).$id).toBe(schema.url); + } + const ajv = new Ajv({ allErrors: true, strict: false }); + const validateUser = ajv.compile( + generated.get('user-workspace.schema.json'), + ); + const validateProject = ajv.compile( + generated.get('project-workspace.schema.json'), + ); + + const userWorkspace = parsedYaml(` +profiles: + review: + clients: + - name: claude + launcher: claude-review + settings: + model: sonnet + effortLevel: xhigh + - name: codex + install: native + settings: + approval_policy: on-request + plugins: + - source: owner/review-tools + ref: stable + skills: + exclude: [legacy] + mcpServers: + review: + command: review-mcp + env: + REVIEW_TOKEN: \${REVIEW_TOKEN} +`); + const projectWorkspace = parsedYaml(` +repositories: [] +plugins: + - source: owner/project-tools + ref: main +clients: + - name: claude + install: native +`); + const projectWithProfiles = parsedYaml(` +repositories: [] +plugins: [] +clients: [] +profiles: + review: + clients: + - name: claude +`); + const userWithUnknownSettings = parsedYaml(` +profiles: + review: + clients: + - name: claude + settings: + unknownSetting: true +`); + const userWithInvalidClientShorthand = parsedYaml(` +clients: + - claude:bogus +`); + const userWithInvalidProfileName = parsedYaml(` +profiles: + ../escape: + clients: + - name: claude +`); + + expect(validateUser(userWorkspace)).toBe(true); + expect(UserWorkspaceConfigSchema.safeParse(userWorkspace).success).toBe( + true, + ); + expect(validateProject(projectWorkspace)).toBe(true); + expect( + ProjectWorkspaceConfigSchema.safeParse(projectWorkspace).success, + ).toBe(true); + expect(validateProject(projectWithProfiles)).toBe(false); + expect( + ProjectWorkspaceConfigSchema.safeParse(projectWithProfiles).success, + ).toBe(false); + expect(validateUser(userWithUnknownSettings)).toBe(false); + expect( + UserWorkspaceConfigSchema.safeParse(userWithUnknownSettings).success, + ).toBe(false); + expect(validateUser(userWithInvalidClientShorthand)).toBe(false); + expect( + UserWorkspaceConfigSchema.safeParse(userWithInvalidClientShorthand) + .success, + ).toBe(false); + expect(validateUser(userWithInvalidProfileName)).toBe(false); + expect( + UserWorkspaceConfigSchema.safeParse(userWithInvalidProfileName).success, + ).toBe(false); + }); +});