From 42da2aeddee4a45639e25f4671560abbc0861678 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 16:57:15 +0000 Subject: [PATCH] feat(claude): emit LSP server configs (.lsp.json) Add a host-scoped Claude LSP surface while preserving honest composite capability reporting and conventional plugin-root discovery. --- .changeset/claude-lsp-emission.md | 5 + README.md | 2 + docs/plans/2026-08-13-agent-bundle-design.md | 6 + .../contracts/claude/capabilities.json | 1 + .../adapters/capabilities/claude-2.1.250.json | 36 +++ packages/agent-bundle/src/adapters/claude.ts | 224 +++++++++++++++++- packages/agent-bundle/src/adapters/codex.ts | 13 +- packages/agent-bundle/src/adapters/plugin.ts | 41 +++- .../adapters/schemas/claude/PROVENANCE.json | 10 +- .../adapters/schemas/claude/lsp.schema.json | 35 +++ .../schemas/claude/plugin.schema.json | 39 +++ packages/agent-bundle/src/adapters/types.ts | 23 ++ .../tests/adapter-capability-states.test.ts | 25 +- .../tests/adapter-metadata.test.ts | 11 +- .../agent-bundle/tests/host-adapters.test.ts | 195 +++++++++++++++ .../agent-bundle/tests/normalization.test.ts | 37 +++ .../agent-bundle/tests/plugin-bundle.test.ts | 38 +++ 17 files changed, 727 insertions(+), 14 deletions(-) create mode 100644 .changeset/claude-lsp-emission.md create mode 100644 packages/agent-bundle/src/adapters/schemas/claude/lsp.schema.json diff --git a/.changeset/claude-lsp-emission.md b/.changeset/claude-lsp-emission.md new file mode 100644 index 000000000..8d2ace2ff --- /dev/null +++ b/.changeset/claude-lsp-emission.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Add host-scoped Claude Code language-server configuration and emit validated plugin-root `.lsp.json` documents for Claude targets. diff --git a/README.md b/README.md index 3426b7824..9e0445b9e 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ npx agent-bundle dev --root . # local workbench with live rebu `targets: ['plugin']` emits one multi-host bundle at `dist/plugin/`: `.claude-plugin/`, `.codex-plugin/`, and `.cursor-plugin/` manifests over shared `skills/`, `hooks/`, `mcp/`, and `scripts/` directories. The bundle's generated `AGENTS.md` explains how to install it into each host. Per-host layouts are available as the `claude`, `codex`, `cursor`, and `portable` targets. +Claude Code language servers are declared under `claude.lspServers`; the `claude` target and the Claude half of `plugin` emit the record as plugin-root `.lsp.json`. Agent Bundle expands path tokens only in `command`, `args`, `env`, and `workspaceFolder`, and it does not include the language-server binary — install that separately so the declared command is available on `PATH`. Codex, Cursor, and the portable format do not currently receive this host-scoped configuration. + The same config also owns the npm package build — no second bundler config, bin shims, or hand-rolled stdio lifecycles. `bin` and `lib` entries (or the conventions `src/cli.ts`, `src/index.ts`, and `src/mcp/.ts`) emit executable `dist/bin/.js` bundles and a library output alongside the host artifacts; an MCP entry that default-exports a server factory runs under a framework-owned stdio lifecycle; `tools.rsbuild` / `tools.rspack` is the one bundler escape hatch. [Entry conventions](docs/entry-conventions.md) is the full contract, and [Framework mode](docs/framework-mode.md) is the whole authoring model on one screen: structure in config and conventions (`skills//SKILL.md` ships with no declaration at all), JSX only where something is rendered. ## Commands diff --git a/docs/plans/2026-08-13-agent-bundle-design.md b/docs/plans/2026-08-13-agent-bundle-design.md index 747a47d33..21f5b5b56 100644 --- a/docs/plans/2026-08-13-agent-bundle-design.md +++ b/docs/plans/2026-08-13-agent-bundle-design.md @@ -368,6 +368,12 @@ example (hooks plus all three targets) builds. A host that supports hooks but ca specific requested event, selector dimension, blocking decision, or handler type remains a build error unless the hook is explicitly limited to capable targets. +Claude Code LSP emission is intentionally consumer-driven and host-scoped: `claude.lspServers` +passes through the registered Claude config extension and emits plugin-root `.lsp.json` for the +Claude target (and the Claude half of the composite plugin target). It does not introduce a +portable LSP component kind or imply support in Codex, Cursor, or Agent Plugins 1.0.0; that +cross-host source model remains deferred under #100. + ### Zero runtime dependency Generated hook bundles do not import `agent-bundle` and do not detect the host dynamically. diff --git a/packages/agent-bundle/fixtures/contracts/claude/capabilities.json b/packages/agent-bundle/fixtures/contracts/claude/capabilities.json index 67304295d..520a3d5a4 100644 --- a/packages/agent-bundle/fixtures/contracts/claude/capabilities.json +++ b/packages/agent-bundle/fixtures/contracts/claude/capabilities.json @@ -8,6 +8,7 @@ "streamableHttp": true }, "nativePaths": { + "lsp": ".lsp.json", "manifest": ".claude-plugin/plugin.json", "marketplace": ".claude-plugin/marketplace.json", "mcp": ".mcp.json" diff --git a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json index c219efd15..23e14955c 100644 --- a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json +++ b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json @@ -21,6 +21,28 @@ }, "observedCliVersion": "2.1.250", "plugin": { + "lsp": { + "config": ".lsp.json", + "manifestField": "lspServers", + "manifestFieldTypes": ["array", "object", "string"], + "optionalFields": [ + "args", + "diagnostics", + "env", + "initializationOptions", + "maxRestarts", + "restartOnCrash", + "settings", + "shutdownTimeout", + "startupTimeout", + "transport", + "workspaceFolder" + ], + "pathTokenFields": ["args", "command", "env", "workspaceFolder"], + "requiredFields": ["command", "extensionToLanguage"], + "transports": ["socket", "stdio"], + "vendorsServerBinary": false + }, "manifest": ".claude-plugin/plugin.json", "marketplace": ".claude-plugin/marketplace.json", "skills": true @@ -29,5 +51,19 @@ "pluginData": "${CLAUDE_PLUGIN_DATA}", "pluginRoot": "${CLAUDE_PLUGIN_ROOT}", "workspaceRoot": "${CLAUDE_PROJECT_DIR}" + }, + "provenance": { + "observedAt": "2026-09-01", + "source": "https://docs.anthropic.com/en/docs/claude-code/plugins", + "evidence": [ + "LSP servers section: \"Location: .lsp.json in plugin root, or inline in plugin.json\"; the file-locations table lists .lsp.json as the default LSP location, alongside .mcp.json, at the plugin root rather than inside .claude-plugin/.", + "Component path fields table: lspServers is typed string|array|object with the example \"./.lsp.json\", so a path string, an array of path strings, and an inline server map are all documented forms.", + "Required per-server fields are command (\"The LSP binary to execute (must be in PATH)\") and extensionToLanguage; transport accepts socket but Claude Code runs every server over stdio.", + "restartOnCrash and shutdownTimeout require Claude Code v2.1.205 or later; before that revision either option made Claude Code skip the server entirely. The pinned 2.1.250 revision is past that floor.", + "First-registered-wins collision rule: when more than one enabled server declares the same extension in extensionToLanguage, from one plugin or from different plugins, the first registered handles the extension and the others never start.", + "The server binary is never vendored: \"You must install the language server binary separately. LSP plugins configure how Claude Code connects to a language server, but they don't include the server itself.\"", + "Placeholder substitution for LSP servers is limited to command, args, env, and workspaceFolder.", + "Codex and Cursor publish no plugin LSP surface at their pinned revisions, so the unified bundle's .lsp.json reaches Claude Code only." + ] } } diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index a809d8b17..67766a14c 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -28,6 +28,7 @@ import { } from './hook-contract.ts'; import schemaProvenance from './schemas/claude/PROVENANCE.json' with { type: 'json' }; import hooksSchema from './schemas/claude/hooks.schema.json' with { type: 'json' }; +import lspSchema from './schemas/claude/lsp.schema.json' with { type: 'json' }; import marketplaceSchema from './schemas/claude/marketplace.schema.json' with { type: 'json' }; import mcpSchema from './schemas/claude/mcp.schema.json' with { type: 'json' }; import pluginSchema from './schemas/claude/plugin.schema.json' with { type: 'json' }; @@ -35,6 +36,7 @@ import { createAdapterValidator, hasPathToken, schemaDescriptorsFrom, + sourceInputs, standardArtifactLayout, standardPluginArtifactPlan, validateJsonSchemaDocument, @@ -44,13 +46,49 @@ import { type TargetArtifactPlan, } from './types.ts'; +/** + * One Claude Code plugin LSP server. The binary is never vendored: Claude + * Code resolves `command` on the user's PATH, so the bundle only wires the + * connection. Only `command`, `args`, `env`, and `workspaceFolder` + * substitute Agent Bundle path tokens, matching the placeholder table in the + * Claude Code 2.1.x plugin reference; every other field passes through to + * `.lsp.json` untouched. + */ +export interface ClaudeLspServerConfig { + readonly args?: readonly string[]; + readonly command: string; + /** Push diagnostics into Claude's context after edits. Claude Code defaults to true. */ + readonly diagnostics?: boolean; + readonly env?: Readonly>; + /** File extension to LSP language identifier, for example `{ '.go': 'go' }`. */ + readonly extensionToLanguage: Readonly>; + readonly initializationOptions?: unknown; + readonly maxRestarts?: number; + readonly restartOnCrash?: boolean; + readonly settings?: unknown; + readonly shutdownTimeout?: number; + readonly startupTimeout?: number; + /** Claude Code accepts `socket` but runs every server over stdio. */ + readonly transport?: 'socket' | 'stdio'; + readonly workspaceFolder?: string; +} + +/** + * Claude's host config. `lspServers` lives here rather than in a portable + * top-level block because no other pinned host contract has an LSP surface; + * the portable LSP component kind stays deferred. + */ +export interface ClaudeHostConfig extends AgentBundleHostConfig { + readonly lspServers?: Readonly>; +} + export interface ClaudeConfigExtension { - claude?: AgentBundleHostConfig; + claude?: ClaudeHostConfig; } declare module '../core/types.ts' { interface AgentBundleConfigExtensions { - claude?: AgentBundleHostConfig; + claude?: ClaudeHostConfig; } } @@ -59,6 +97,7 @@ const claudeName = 'claude'; /** Claude Code's conventional artifact document paths, shared with the unified bundle adapter. */ export const claudeArtifactPaths = Object.freeze({ hooksManifest: 'hooks/hooks.json', + lsp: '.lsp.json', marketplace: '.claude-plugin/marketplace.json', mcp: '.mcp.json', plugin: '.claude-plugin/plugin.json', @@ -68,6 +107,7 @@ const validatePlugin = validator.compile(pluginSchema); const validateMcp = validator.compile(mcpSchema); const validateMarketplace = validator.compile(marketplaceSchema); const validateHooks = validator.compile(hooksSchema); +const validateLsp = validator.compile(lspSchema); /** The pinned Claude hooks validator, shared with the unified bundle adapter. */ export const claudeHooksValidator = validateHooks; @@ -83,9 +123,9 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.1.0', + adapterRevision: '1.2.0', capabilityRevision: capabilityTable.observedCliVersion, - capabilitySha256: 'a1d90db5f605e76dad541a1ba37ba06283aa24f8b55f10ce7d197b5c6b5ac9f2', + capabilitySha256: '952788d759db5152e8bcb7128ba778bb74f51fac79403011f669eecdcb1f45f3', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -94,12 +134,14 @@ const evidence = capabilityEvidence(claudeName, metadata); const artifactValidation = Object.freeze({ documents: Object.freeze([ Object.freeze({ path: 'hooks/hooks.json', required: false, schema: 'hooks' }), + Object.freeze({ path: claudeArtifactPaths.lsp, required: false, schema: 'lsp' }), Object.freeze({ path: '.claude-plugin/marketplace.json', required: false, schema: 'marketplace' }), Object.freeze({ path: '.mcp.json', required: false, schema: 'mcp' }), Object.freeze({ path: '.claude-plugin/plugin.json', required: true, schema: 'plugin' }), ]), schemas: Object.freeze([ Object.freeze({ name: 'hooks', validate: validateJsonSchemaDocument(validateHooks) }), + Object.freeze({ name: 'lsp', validate: validateJsonSchemaDocument(validateLsp) }), Object.freeze({ name: 'marketplace', validate: validateJsonSchemaDocument(validateMarketplace) }), Object.freeze({ name: 'mcp', validate: validateModernMcpDocument(validateJsonSchemaDocument(validateMcp)) }), Object.freeze({ name: 'plugin', validate: validateJsonSchemaDocument(validatePlugin) }), @@ -199,6 +241,165 @@ const planMcpServer = ( }; }; +/** + * Every field the pinned Claude LSP contract documents for one server. The + * emitted document copies this allowlist rather than the declared record, so + * a misspelled field is a build diagnostic instead of a silently shipped key + * that Claude Code would reject at startup. + */ +const lspServerFields: ReadonlySet = new Set([ + 'args', + 'command', + 'diagnostics', + 'env', + 'extensionToLanguage', + 'initializationOptions', + 'maxRestarts', + 'restartOnCrash', + 'settings', + 'shutdownTimeout', + 'startupTimeout', + 'transport', + 'workspaceFolder', +]); + +/** Normalized config extension values are already strict JSON, so a plain shape test is enough. */ +const isDataRecord = (value: unknown): value is Readonly> => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const expandLspToken = (value: unknown): unknown => + typeof value === 'string' ? expandClaudeToken(value) : value; + +const planLspServer = ( + name: string, + declared: unknown, +): { readonly diagnostics: readonly Diagnostic[]; readonly value?: Record } => { + const diagnostics: Diagnostic[] = []; + if (!isDataRecord(declared)) { + diagnostics.push(errorDiagnostic( + 'claude.lsp.server.invalid', + `Claude LSP server "${name}" must be an LSP server configuration object.`, + )); + return { diagnostics }; + } + for (const field of Object.keys(declared).sort()) { + if (lspServerFields.has(field)) continue; + diagnostics.push(errorDiagnostic( + 'claude.lsp.field.unknown', + `Claude LSP server "${name}" declares unknown field "${field}".`, + )); + } + const command = declared['command']; + if (typeof command !== 'string' || command.length === 0) { + diagnostics.push(errorDiagnostic( + 'claude.lsp.command.required', + `Claude LSP server "${name}" requires a command. Claude Code resolves it on the user's PATH; the bundle never vendors the language-server binary.`, + )); + } + const extensionToLanguage = declared['extensionToLanguage']; + if (!isDataRecord(extensionToLanguage) || Object.keys(extensionToLanguage).length === 0) { + diagnostics.push(errorDiagnostic( + 'claude.lsp.extensions.required', + `Claude LSP server "${name}" requires a nonempty extensionToLanguage map; a server that claims no extension never starts.`, + )); + } + const env = declared['env']; + if (isDataRecord(env)) { + for (const key of Object.keys(env).sort()) { + if (!hasPathToken(key)) continue; + diagnostics.push(errorDiagnostic( + 'claude.lsp.token.env.key', + `Claude LSP environment key "${key}" cannot use a path token.`, + )); + } + } + if (diagnostics.length > 0) return { diagnostics }; + + const value: Record = Object.create(null) as Record; + for (const field of Object.keys(declared)) { + if (!lspServerFields.has(field)) continue; + value[field] = declared[field]; + } + value['command'] = expandLspToken(value['command']); + if (Array.isArray(value['args'])) value['args'] = value['args'].map(expandLspToken); + if (isDataRecord(value['env'])) { + value['env'] = Object.fromEntries(Object.entries(value['env']).map(([key, entry]) => [key, expandLspToken(entry)])); + } + if (value['workspaceFolder'] !== undefined) value['workspaceFolder'] = expandLspToken(value['workspaceFolder']); + return { diagnostics, value }; +}; + +interface ClaudeLspPlan { + readonly diagnostics: readonly Diagnostic[]; + readonly document?: Record; + readonly sourceInputs: readonly string[]; +} + +const noLspPlan: ClaudeLspPlan = Object.freeze({ + diagnostics: Object.freeze([]), + sourceInputs: Object.freeze([]), +}); + +/** + * Lowers `claude.lspServers` into the plugin-root `.lsp.json` document + * Claude Code discovers by convention, the same way `.mcp.json` is + * discovered. The manifest deliberately keeps no `lspServers` pointer at + * `./.lsp.json`: both locations register servers, and Claude Code starts + * only the first server registered for a file extension, so pointing the + * manifest at the conventional file risks a self-collision for no gain. + * + * The Claude host config is the source of truth for both the `claude` + * target and the Claude half of the unified `plugin` bundle, because no + * other pinned host contract has an LSP surface to select. + */ +export const planClaudeLsp = (model: NormalizedPlugin): ClaudeLspPlan => { + const extension = model.extensions[claudeName]; + if (extension === undefined || !isDataRecord(extension.value)) return noLspPlan; + const declared = extension.value['lspServers']; + if (declared === undefined) return noLspPlan; + const diagnostics: Diagnostic[] = []; + const inputs = sourceInputs(extension.provenance.sourcePath); + if (!isDataRecord(declared) || Object.keys(declared).length === 0) { + diagnostics.push(errorDiagnostic( + 'claude.lsp.declaration.invalid', + 'Claude lspServers must be a nonempty record of server name to LSP server configuration.', + )); + return { diagnostics, sourceInputs: inputs }; + } + + const servers: Record> = Object.create(null) as Record>; + // Claude Code starts only the first server registered for an extension and + // warns about the rest, so a bundle that claims one extension twice is an + // authoring error rather than a shippable document. + const claimedExtensions = new Map(); + let conflicted = false; + for (const name of Object.keys(declared).sort()) { + const serverPlan = planLspServer(name, declared[name]); + diagnostics.push(...serverPlan.diagnostics); + if (serverPlan.value === undefined) continue; + servers[name] = serverPlan.value; + const extensions = serverPlan.value['extensionToLanguage']; + if (!isDataRecord(extensions)) continue; + for (const fileExtension of Object.keys(extensions).sort()) { + const owner = claimedExtensions.get(fileExtension); + if (owner === undefined) { + claimedExtensions.set(fileExtension, name); + continue; + } + diagnostics.push(errorDiagnostic( + 'claude.lsp.extension.conflict', + `Claude LSP servers "${owner}" and "${name}" both claim extension "${fileExtension}"; Claude Code starts only the first server registered for an extension.`, + )); + conflicted = true; + } + } + if (conflicted) return { diagnostics, sourceInputs: inputs }; + if (Object.keys(servers).length === 0) return { diagnostics, sourceInputs: inputs }; + const valid = validateLsp(servers); + diagnostics.push(...schemaDiagnostics('lsp', valid, validateLsp.errors)); + return { diagnostics, ...(valid ? { document: servers } : {}), sourceInputs: inputs }; +}; + export interface ClaudeArtifactPlanOptions { /** Target name used for selection and provenance; native hooks stay keyed to Claude. */ readonly targetName?: string; @@ -221,6 +422,8 @@ export const planClaudeArtifacts = ( const mcp = Object.keys(servers).length === 0 ? undefined : { mcpServers: servers }; const mcpValid = mcp !== undefined && validateMcp(mcp); if (mcp !== undefined) diagnostics.push(...schemaDiagnostics('mcp', mcpValid, validateMcp.errors)); + const lsp = planClaudeLsp(model); + diagnostics.push(...lsp.diagnostics); const generatedHooks = planHooks(model, targetName, hookContract); diagnostics.push(...generatedHooks.diagnostics); if (generatedHooks.document !== undefined) { @@ -258,6 +461,13 @@ export const planClaudeArtifacts = ( return standardPluginArtifactPlan({ diagnostics, + ...(lsp.document === undefined ? {} : { + hostDocuments: [{ + document: lsp.document, + relativePath: claudeArtifactPaths.lsp, + sourceInputs: sourceInputs(model.metadata.provenance.sourcePath, ...lsp.sourceInputs), + }], + }), hookDocument, hookDocumentValid, hookEntries: generatedHooks.hookEntries, @@ -281,6 +491,12 @@ export const claudeAdapter: TargetAdapter = Object.freeze({ capabilities: Object.freeze({ marketplace: supportedCapability(evidence), hooks: supportedCapability(evidence), + lsp: capabilityStateFromSupport( + capabilityTable.plugin.lsp.config === claudeArtifactPaths.lsp && + capabilityTable.plugin.lsp.manifestField === 'lspServers', + evidence, + 'The pinned Claude plugin contract does not document the plugin-root .lsp.json LSP surface.', + ), mcp: capabilityStateFromSupport( capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp, evidence, diff --git a/packages/agent-bundle/src/adapters/codex.ts b/packages/agent-bundle/src/adapters/codex.ts index fd746c73e..80e78be71 100644 --- a/packages/agent-bundle/src/adapters/codex.ts +++ b/packages/agent-bundle/src/adapters/codex.ts @@ -14,7 +14,12 @@ import { } from '../core/types.ts'; import { createMcpPathTokenResolver, standardMcpPathTokens } from '../services/mcp-path-tokens.ts'; import { createTargetMcpRuntime, resolveTargetRelativeStdioArgument } from '../services/mcp-runtime.ts'; -import { capabilityEvidence, capabilityStateFromSupport, supportedCapability } from './capability-state.ts'; +import { + capabilityEvidence, + capabilityStateFromSupport, + supportedCapability, + unavailableCapability, +} from './capability-state.ts'; import capabilityTable from './capabilities/codex-0.147.0.json' with { type: 'json' }; import { mergeHookDocuments, @@ -402,6 +407,12 @@ export const codexAdapter: TargetAdapter = Object.freeze({ capabilities: Object.freeze({ marketplace: supportedCapability(evidence), hooks: supportedCapability(evidence), + // The pinned Codex plugin contract documents no LSP surface at all, so + // this is an absent host capability rather than a degraded one: nothing + // of Claude's `.lsp.json` is copied to the Codex manifest. + lsp: unavailableCapability( + 'The pinned Codex plugin contract publishes no LSP server surface; language-server configuration reaches Claude Code only.', + ), mcp: capabilityStateFromSupport( capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp, evidence, diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 91dc6bbf3..cc5802066 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -140,6 +140,7 @@ const artifactValidation = Object.freeze({ // One shared Claude-format hook document serves both hosts; the pinned // Codex hooks schema is byte-identical apart from its $id. Object.freeze({ path: bundleHookContract.manifestPath, required: false, schema: 'claude-hooks' }), + Object.freeze({ path: claudeArtifactPaths.lsp, required: false, schema: 'claude-lsp' }), Object.freeze({ path: claudeArtifactPaths.marketplace, required: false, schema: 'claude-marketplace' }), Object.freeze({ path: claudeArtifactPaths.mcp, required: false, schema: 'claude-mcp' }), Object.freeze({ path: claudeArtifactPaths.plugin, required: true, schema: 'claude-plugin' }), @@ -163,7 +164,7 @@ const artifactValidation = Object.freeze({ }); const metadata = Object.freeze({ - adapterRevision: '1.1.0', + adapterRevision: '1.2.0', capabilityRevision: `claude ${claudeAdapter.metadata.observedVersion} + codex ${codexAdapter.metadata.observedVersion}`, capabilitySha256: claudeAdapter.metadata.capabilitySha256, observedVersion: `${claudeAdapter.metadata.observedVersion}+${codexAdapter.metadata.observedVersion}`, @@ -204,7 +205,12 @@ const artifactLayout: TargetArtifactLayout = Object.freeze({ const { errorDiagnostic, schemaDiagnostics } = createTargetDiagnostics(pluginName, 'Agent plugin bundle'); -const agentsDocument = (model: NormalizedPlugin): string => { +interface AgentsDocumentOptions { + /** True when the Claude half of this bundle emitted `.lsp.json`. */ + readonly lsp: boolean; +} + +const agentsDocument = (model: NormalizedPlugin, options: AgentsDocumentOptions): string => { const description = model.metadata.description ?? model.metadata.name; return [ `# ${model.metadata.name}`, @@ -229,10 +235,31 @@ const agentsDocument = (model: NormalizedPlugin): string => { '- `.codex-plugin/` — Codex manifest and host documents.', '- `.cursor-plugin/plugin.json` and root `mcp.json` — Cursor local-plugin manifest and MCP document.', '- `.mcp.json` — Claude Code MCP configuration (plugin-root convention).', + ...(options.lsp + ? [ + '- `.lsp.json` — Claude Code language-server configuration (plugin-root convention). Claude Code only; Codex and Cursor have no LSP surface.', + ] + : []), '- `hooks/` — one `hooks.json` with a host-detecting wrapper per hook (Claude Code and Codex), plus `hooks-cursor.json` with per-hook Cursor wrappers (`.cursor.mjs`).', '- `skills/` — agent skills (`SKILL.md` per skill), shared by every host.', '- `scripts/`, `mcp/`, `mcp-apps/`, `assets/` — compiled shared surfaces.', '', + ...(options.lsp + ? [ + '## Language servers', + '', + '`.lsp.json` wires Claude Code to a language server; it does not ship one. Per the Claude Code plugin', + 'reference: "You must install the language server binary separately. LSP plugins configure how Claude Code', + "connects to a language server, but they don't include the server itself.\" The bundle only carries", + '`command`, `extensionToLanguage`, and the optional connection fields such as `diagnostics`, so every', + 'declared `command` must already be on the user\'s PATH.', + '', + 'If a server does not come up, the `/plugin` Errors tab names the cause (`Executable not found in $PATH`', + 'when the binary is missing) and `claude --debug` prints why a server was skipped. When more than one', + 'enabled server declares the same file extension, Claude Code starts only the first one registered.', + '', + ] + : []), ].join('\n'); }; @@ -394,7 +421,9 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { } entries.push({ - content: agentsDocument(model), + content: agentsDocument(model, { + lsp: entries.some((entry) => entry.relativePath === claudeArtifactPaths.lsp), + }), kind: 'write', relativePath: 'AGENTS.md', sourceInputs: sourceInputs(model.metadata.provenance.sourcePath, ...targetSourceInputs), @@ -415,6 +444,12 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ capabilities: Object.freeze({ marketplace: intersectCapabilityStates(claudeAdapter.capabilities.marketplace!, codexAdapter.capabilities.marketplace!), hooks: intersectCapabilityStates(claudeAdapter.capabilities.hooks!, codexAdapter.capabilities.hooks!), + // Claude supports LSP and Codex has no LSP surface, so the intersection + // is honestly unavailable for the bundle as a whole. The Claude half + // still emits `.lsp.json` at the shared root from the Claude host + // config, which is exactly why this stays unavailable instead of + // supported: nothing about that document reaches Codex or Cursor. + lsp: intersectCapabilityStates(claudeAdapter.capabilities.lsp!, codexAdapter.capabilities.lsp!), mcp: intersectCapabilityStates(claudeAdapter.capabilities.mcp!, codexAdapter.capabilities.mcp!), skills: intersectCapabilityStates(claudeAdapter.capabilities.skills!, codexAdapter.capabilities.skills!), }), diff --git a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json index b145ad759..7053bb386 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json @@ -2,12 +2,18 @@ "observedCliVersion": "2.1.250", "retrievedAt": "2026-08-28", "schemaSource": "https://docs.anthropic.com/en/docs/claude-code/plugins", + "notes": "lsp.schema.json and plugin.json's `lspServers` property were pinned from the Claude Code 2.1.x plugin reference LSP servers section (retrieved 2026-09-01), which documents `.lsp.json` at the plugin root or inline `lspServers` in the manifest, required `command` / `extensionToLanguage`, and the optional `args`, `transport`, `env`, `initializationOptions`, `settings`, `workspaceFolder`, `startupTimeout`, `shutdownTimeout`, `restartOnCrash`, `maxRestarts`, and `diagnostics` fields. `restartOnCrash` and `shutdownTimeout` require Claude Code 2.1.205 or later, which the pinned 2.1.250 revision satisfies. Manifest `lspServers` keeps the documented `string|array|object` union rather than being narrowed to the one emitted form the way `hooks` is; the emitted document itself is `.lsp.json` at the plugin root. Two agent-bundle tightenings over the documented text: a server map and an `extensionToLanguage` map must both be nonempty, because an empty map claims no extension and can never start a server.", "schemas": { "hooks.schema.json": { "bytes": 1108, "sha256": "3c6f3e4391f3dca939d75bd0b200ea88e68db939a2cb885d46f0b143293efb84", "url": "https://code.claude.com/docs/en/hooks" }, + "lsp.schema.json": { + "bytes": 1369, + "sha256": "c81fd2f57c410f70f8e5c3f84483f5ec1b575ee02802b424977826f757dccd8e", + "url": "https://docs.anthropic.com/en/docs/claude-code/plugins" + }, "marketplace.schema.json": { "bytes": 1210, "sha256": "5a08f241f9e856bb59489a265d9bf4db9c905e874d720f46def59fdb6f3ca257", @@ -19,8 +25,8 @@ "url": "https://docs.anthropic.com/en/docs/claude-code/mcp" }, "plugin.schema.json": { - "bytes": 804, - "sha256": "d145d370f5ad16fb9f29a6f1b5c9cb3ae8a6b9c33b3a11513eea324e8feb17c5", + "bytes": 2279, + "sha256": "f0c503ec8bc11c2ebeade8e8feed37a6c920525b0534438c37210cc50aa66a62", "url": "https://docs.anthropic.com/en/docs/claude-code/plugins" } }, diff --git a/packages/agent-bundle/src/adapters/schemas/claude/lsp.schema.json b/packages/agent-bundle/src/adapters/schemas/claude/lsp.schema.json new file mode 100644 index 000000000..fc9ddd4f0 --- /dev/null +++ b/packages/agent-bundle/src/adapters/schemas/claude/lsp.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-bundle.dev/schemas/claude/2.1.250/lsp.schema.json", + "$defs": { + "server": { + "additionalProperties": false, + "properties": { + "args": { "items": { "type": "string" }, "type": "array" }, + "command": { "minLength": 1, "type": "string" }, + "diagnostics": { "type": "boolean" }, + "env": { "additionalProperties": { "type": "string" }, "type": "object" }, + "extensionToLanguage": { + "additionalProperties": { "minLength": 1, "type": "string" }, + "minProperties": 1, + "propertyNames": { "minLength": 1 }, + "type": "object" + }, + "initializationOptions": {}, + "maxRestarts": { "minimum": 0, "type": "integer" }, + "restartOnCrash": { "type": "boolean" }, + "settings": {}, + "shutdownTimeout": { "exclusiveMinimum": 0, "type": "integer" }, + "startupTimeout": { "exclusiveMinimum": 0, "type": "integer" }, + "transport": { "enum": ["socket", "stdio"] }, + "workspaceFolder": { "minLength": 1, "type": "string" } + }, + "required": ["command", "extensionToLanguage"], + "type": "object" + } + }, + "additionalProperties": { "$ref": "#/$defs/server" }, + "minProperties": 1, + "propertyNames": { "minLength": 1 }, + "type": "object" +} diff --git a/packages/agent-bundle/src/adapters/schemas/claude/plugin.schema.json b/packages/agent-bundle/src/adapters/schemas/claude/plugin.schema.json index 233d03203..9c7861337 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/plugin.schema.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/plugin.schema.json @@ -1,6 +1,33 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://agent-bundle.dev/schemas/claude/2.1.250/plugin.schema.json", + "$defs": { + "lspServer": { + "additionalProperties": false, + "properties": { + "args": { "items": { "type": "string" }, "type": "array" }, + "command": { "minLength": 1, "type": "string" }, + "diagnostics": { "type": "boolean" }, + "env": { "additionalProperties": { "type": "string" }, "type": "object" }, + "extensionToLanguage": { + "additionalProperties": { "minLength": 1, "type": "string" }, + "minProperties": 1, + "propertyNames": { "minLength": 1 }, + "type": "object" + }, + "initializationOptions": {}, + "maxRestarts": { "minimum": 0, "type": "integer" }, + "restartOnCrash": { "type": "boolean" }, + "settings": {}, + "shutdownTimeout": { "exclusiveMinimum": 0, "type": "integer" }, + "startupTimeout": { "exclusiveMinimum": 0, "type": "integer" }, + "transport": { "enum": ["socket", "stdio"] }, + "workspaceFolder": { "minLength": 1, "type": "string" } + }, + "required": ["command", "extensionToLanguage"], + "type": "object" + } + }, "additionalProperties": false, "properties": { "author": { @@ -11,6 +38,18 @@ }, "description": { "minLength": 1, "type": "string" }, "hooks": { "const": "./hooks/hooks.json", "type": "string" }, + "lspServers": { + "anyOf": [ + { "minLength": 1, "type": "string" }, + { "items": { "minLength": 1, "type": "string" }, "minItems": 1, "type": "array" }, + { + "additionalProperties": { "$ref": "#/$defs/lspServer" }, + "minProperties": 1, + "propertyNames": { "minLength": 1 }, + "type": "object" + } + ] + }, "name": { "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "type": "string" }, "version": { "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$", "type": "string" } }, diff --git a/packages/agent-bundle/src/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index d63112349..205147241 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -132,8 +132,22 @@ export const payloadCopyEntries = ( sourceInputs: sourceInputs(payload.provenance.sourcePath, file.source), }))); +/** One already-validated host-native document beyond the shared plugin set. */ +export interface StandardPluginHostDocument { + readonly document: Record; + readonly relativePath: string; + readonly sourceInputs: readonly string[]; +} + export interface StandardPluginArtifactsInput { readonly diagnostics: readonly Diagnostic[]; + /** + * Host-native documents a single target owns beyond the shared plugin, + * MCP, hook, and marketplace slots (Claude's `.lsp.json`). Callers pass + * only documents that already passed their pinned schema, exactly as the + * shared slots above are gated on their own validity flags. + */ + readonly hostDocuments?: readonly StandardPluginHostDocument[]; readonly hookDocument?: Record; readonly hookDocumentValid: boolean; readonly hookEntries: readonly TargetHookEntry[]; @@ -272,6 +286,15 @@ export const standardPluginArtifactPlan = (input: StandardPluginArtifactsInput): }); } + for (const hostDocument of input.hostDocuments ?? []) { + entries.push({ + content: `${stableJson(hostDocument.document)}\n`, + kind: 'write', + relativePath: hostDocument.relativePath, + sourceInputs: hostDocument.sourceInputs, + }); + } + entries.push(...(input.sharedCopyEntries === false ? [] : payloadCopyEntries(model, isSelected))); return Object.freeze({ diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index 939e7c2e1..e1aad8a88 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -20,13 +20,36 @@ const state = (value: CapabilityState): CapabilityState => Object.freeze(value); it('keeps the plugin Boolean capability view as the Claude and Codex intersection', () => { const registry = createDefaultRegistry(); - for (const capability of ['marketplace', 'hooks', 'mcp', 'skills']) { + for (const capability of ['marketplace', 'hooks', 'lsp', 'mcp', 'skills']) { expect(registry.supports('plugin', capability)).toBe( registry.supports('claude', capability) && registry.supports('codex', capability), ); } }); +it('reports Claude LSP support and honest unavailable composite coverage', () => { + const registry = createDefaultRegistry(); + + expect(registry.get('claude').capabilities.lsp).toMatchObject({ + evidence: { + observedVersion: '2.1.250', + target: 'claude', + }, + state: 'supported', + }); + expect(registry.get('codex').capabilities.lsp).toMatchObject({ + reason: expect.stringContaining('no LSP server surface'), + state: 'unavailable', + }); + expect(registry.get('plugin').capabilities.lsp).toMatchObject({ + reason: expect.stringContaining('no LSP server surface'), + state: 'unavailable', + }); + expect(registry.supports('claude', 'lsp')).toBe(true); + expect(registry.supports('codex', 'lsp')).toBe(false); + expect(registry.supports('plugin', 'lsp')).toBe(false); +}); + it('intersects supported composite capabilities and merges both evidence records', () => { const intersection = intersectCapabilityStates( supportedCapability(evidence('claude')), diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index c1c4fb216..f597a120e 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -100,9 +100,9 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'claude')).toEqual({ - adapterRevision: '1.1.0', + adapterRevision: '1.2.0', capabilityRevision: '2.1.250', - capabilitySha256: 'a1d90db5f605e76dad541a1ba37ba06283aa24f8b55f10ce7d197b5c6b5ac9f2', + capabilitySha256: '952788d759db5152e8bcb7128ba778bb74f51fac79403011f669eecdcb1f45f3', observedVersion: '2.1.250', schemas: [ { @@ -110,6 +110,11 @@ it('records exact immutable metadata for every built-in target', () => { revision: '2.1.250', sha256: '3c6f3e4391f3dca939d75bd0b200ea88e68db939a2cb885d46f0b143293efb84', }, + { + name: 'lsp', + revision: '2.1.250', + sha256: 'c81fd2f57c410f70f8e5c3f84483f5ec1b575ee02802b424977826f757dccd8e', + }, { name: 'marketplace', revision: '2.1.250', @@ -123,7 +128,7 @@ it('records exact immutable metadata for every built-in target', () => { { name: 'plugin', revision: '2.1.250', - sha256: 'd145d370f5ad16fb9f29a6f1b5c9cb3ae8a6b9c33b3a11513eea324e8feb17c5', + sha256: 'f0c503ec8bc11c2ebeade8e8feed37a6c920525b0534438c37210cc50aa66a62', }, ], }); diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index 7bf8fd826..72c979806 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -82,6 +82,23 @@ const writeEntries = (model: NormalizedPlugin, target: 'codex' | 'claude') => { const writeContents = (model: NormalizedPlugin, target: 'codex' | 'claude') => Object.fromEntries(writeEntries(model, target).map((entry) => [entry.relativePath, entry.content])); +const withClaudeLsp = ( + model: NormalizedPlugin, + lspServers: unknown, + target = 'claude', +): NormalizedPlugin => ({ + ...model, + extensions: { + claude: { + id: 'extension:claude', + key: 'claude', + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + target, + value: { lspServers }, + }, + }, +}); + const validateDocuments = async ( target: 'codex' | 'claude', documents: Readonly>, @@ -296,6 +313,184 @@ it('anchors compiled Claude MCP entries with absolute arguments, plugin-root cwd }); }); +it('emits Claude LSP configuration and expands only the four documented token fields', () => { + const model = withClaudeLsp(plugin, { + typescript: { + args: [ + `--plugin=${pathTokens.pluginRoot}`, + `--data=${pathTokens.pluginData}`, + `--workspace=${pathTokens.workspaceRoot}`, + ], + command: `${pathTokens.pluginRoot}/bin/typescript-language-server`, + diagnostics: false, + env: { + DATA: `${pathTokens.pluginData}/lsp`, + ROOT: pathTokens.pluginRoot, + WORKSPACE: pathTokens.workspaceRoot, + }, + extensionToLanguage: { '.ts': `typescript-${pathTokens.pluginRoot}` }, + initializationOptions: { token: pathTokens.pluginData }, + maxRestarts: 3, + restartOnCrash: true, + settings: { token: pathTokens.workspaceRoot }, + shutdownTimeout: 2_000, + startupTimeout: 5_000, + transport: 'socket', + workspaceFolder: `${pathTokens.workspaceRoot}/packages`, + }, + }); + const plan = createDefaultRegistry().get('claude').plan(model); + const documents = writeContents(model, 'claude'); + + expect(plan.diagnostics).toEqual([]); + expect(JSON.parse(documents['.lsp.json']!)).toEqual({ + typescript: { + args: [ + '--plugin=${CLAUDE_PLUGIN_ROOT}', + '--data=${CLAUDE_PLUGIN_DATA}', + '--workspace=${CLAUDE_PROJECT_DIR}', + ], + command: '${CLAUDE_PLUGIN_ROOT}/bin/typescript-language-server', + diagnostics: false, + env: { + DATA: '${CLAUDE_PLUGIN_DATA}/lsp', + ROOT: '${CLAUDE_PLUGIN_ROOT}', + WORKSPACE: '${CLAUDE_PROJECT_DIR}', + }, + extensionToLanguage: { '.ts': `typescript-${pathTokens.pluginRoot}` }, + initializationOptions: { token: pathTokens.pluginData }, + maxRestarts: 3, + restartOnCrash: true, + settings: { token: pathTokens.workspaceRoot }, + shutdownTimeout: 2_000, + startupTimeout: 5_000, + transport: 'socket', + workspaceFolder: '${CLAUDE_PROJECT_DIR}/packages', + }, + }); + expect(JSON.parse(documents['.claude-plugin/plugin.json']!)).not.toHaveProperty('lspServers'); +}); + +it('pins all documented Claude plugin-manifest LSP declaration forms', async () => { + const schema = (await import('../src/adapters/schemas/claude/plugin.schema.json', { + with: { type: 'json' }, + })).default; + const validator = new Ajv2020({ allErrors: true, strict: false }); + installFormats(validator); + const validate = validator.compile(schema); + const manifest = { + author: { name: 'Agent Bundle' }, + description: 'Claude LSP schema fixture.', + name: 'claude-lsp-fixture', + version: '1.0.0', + }; + const server = { + command: 'typescript-language-server', + extensionToLanguage: { '.ts': 'typescript' }, + }; + + for (const lspServers of [ + './.lsp.json', + ['./.lsp.json', './language-servers.json'], + { typescript: server }, + ]) { + expect(validate({ ...manifest, lspServers })).toBe(true); + } + for (const lspServers of [ + '', + [], + [1], + {}, + { typescript: { extensionToLanguage: { '.ts': 'typescript' } } }, + { typescript: { command: 'typescript-language-server' } }, + { typescript: { ...server, transport: 'pipe' } }, + { typescript: { ...server, undocumented: true } }, + ]) { + expect(validate({ ...manifest, lspServers })).toBe(false); + } +}); + +it.each([ + { + code: 'claude.lsp.declaration.invalid', + label: 'an empty declaration', + lspServers: [], + }, + { + code: 'claude.lsp.server.invalid', + label: 'a non-object server', + lspServers: { typescript: './typescript-lsp.json' }, + }, + { + code: 'claude.lsp.field.unknown', + label: 'an unknown server field', + lspServers: { + typescript: { + command: 'typescript-language-server', + extensionToLanguage: { '.ts': 'typescript' }, + undocumented: true, + }, + }, + }, + { + code: 'claude.lsp.command.required', + label: 'a missing command', + lspServers: { typescript: { extensionToLanguage: { '.ts': 'typescript' } } }, + }, + { + code: 'claude.lsp.extensions.required', + label: 'an empty extension map', + lspServers: { typescript: { command: 'typescript-language-server', extensionToLanguage: {} } }, + }, + { + code: 'claude.lsp.token.env.key', + label: 'a tokenized environment key', + lspServers: { + typescript: { + command: 'typescript-language-server', + env: { [`PREFIX_${pathTokens.pluginRoot}`]: 'literal' }, + extensionToLanguage: { '.ts': 'typescript' }, + }, + }, + }, + { + code: 'claude.schema.lsp', + label: 'a schema-invalid optional field', + lspServers: { + typescript: { + command: 'typescript-language-server', + extensionToLanguage: { '.ts': 'typescript' }, + maxRestarts: -1, + }, + }, + }, +])('rejects $label without emitting Claude LSP configuration', ({ code, lspServers }) => { + const model = withClaudeLsp(plugin, lspServers); + const plan = createDefaultRegistry().get('claude').plan(model); + + expect(plan.diagnostics.map((diagnostic) => diagnostic.code)).toContain(code); + expect(plan.entries.some((entry) => entry.relativePath === '.lsp.json')).toBe(false); +}); + +it('withholds Claude LSP configuration when two servers claim one extension', () => { + const model = withClaudeLsp(plugin, { + first: { + command: 'first-language-server', + extensionToLanguage: { '.ts': 'typescript' }, + }, + second: { + command: 'second-language-server', + extensionToLanguage: { '.ts': 'typescript' }, + }, + }); + const plan = createDefaultRegistry().get('claude').plan(model); + + expect(plan.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ + 'claude.lsp.extension.conflict', + ]); + expect(plan.entries.some((entry) => entry.relativePath === '.lsp.json')).toBe(false); +}); + it.each(['codex', 'claude'] as const)( 'keeps a user-declared plugin-root env anchor over the injected %s value', (target) => { diff --git a/packages/agent-bundle/tests/normalization.test.ts b/packages/agent-bundle/tests/normalization.test.ts index dedba130e..320f3426a 100644 --- a/packages/agent-bundle/tests/normalization.test.ts +++ b/packages/agent-bundle/tests/normalization.test.ts @@ -7,6 +7,7 @@ import { validateSource, type NormalizationTargetRegistry, } from '../src/config/index.ts'; +import { createDefaultRegistry } from '../src/adapters/registry.ts'; import type { AgentBundleConfig } from '../src/core/types.ts'; import type { Diagnostic } from '../src/core/diagnostics.ts'; import type { DiscoveredProject } from '../src/config/discover.ts'; @@ -167,6 +168,42 @@ it('normalizes registered extensions and validates registered script and hook ta expect(Object.isFrozen(extensions.example?.value.nested)).toBe(true); }); +it('normalizes the typed Claude LSP source surface through the strict JSON extension seam', async () => { + const model = await normalizeProject(loadedProject({ + claude: { + lspServers: { + typescript: { + args: ['--stdio'], + command: 'typescript-language-server', + extensionToLanguage: { '.ts': 'typescript' }, + workspaceFolder: '/workspace/project', + }, + }, + }, + plugin: { name: 'claude-lsp-fixture', version: '1.0.0' }, + targets: ['claude'], + }), { skills: [] }, createDefaultRegistry()); + const extension = model.extensions.claude; + + expect(extension).toMatchObject({ + id: 'extension:claude', + key: 'claude', + target: 'claude', + value: { + lspServers: { + typescript: { + args: ['--stdio'], + command: 'typescript-language-server', + extensionToLanguage: { '.ts': 'typescript' }, + workspaceFolder: '/workspace/project', + }, + }, + }, + }); + expect(Object.isFrozen(extension)).toBe(true); + expect(Object.isFrozen(extension?.value)).toBe(true); +}); + it('rejects non-JSON values in registered config extensions before normalization', async () => { class ExtensionClass { readonly enabled = true; diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts index 4c851edf6..9686dec75 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -158,6 +158,44 @@ it('lays both host manifests over one shared bundle root', () => { }); }); +it('emits Claude-only LSP configuration at the shared composite root', () => { + const model = { + ...bundleModel, + extensions: { + claude: { + id: 'extension:claude', + key: 'claude', + provenance: { kind: 'config' as const, sourcePath: configPath }, + target: 'claude', + value: { + lspServers: { + typescript: { + command: 'typescript-language-server', + extensionToLanguage: { '.ts': 'typescript' }, + }, + }, + }, + }, + }, + } satisfies NormalizedPlugin; + const plan = planBundle(model); + const documents = writeContents(model); + + expect(plan.diagnostics).toEqual([]); + expect(JSON.parse(documents['.lsp.json']!)).toEqual({ + typescript: { + command: 'typescript-language-server', + extensionToLanguage: { '.ts': 'typescript' }, + }, + }); + expect(JSON.parse(documents['.claude-plugin/plugin.json']!)).not.toHaveProperty('lspServers'); + expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('lspServers'); + expect(documents['AGENTS.md']).toContain('## Language servers'); + expect(documents['AGENTS.md']).toContain('must install the language server binary separately'); + expect(documents['AGENTS.md']).toContain('/plugin'); + expect(documents['AGENTS.md']).toContain('claude --debug'); +}); + it('emits each shared surface exactly once with no duplicate artifact paths', () => { const plan = planBundle(bundleModel); const paths = plan.entries.map((entry) => entry.relativePath);