Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/claude-lsp-emission.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<server-id>.ts`) emit executable `dist/bin/<name>.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/<name>/SKILL.md` ships with no declaration at all), JSX only where something is rendered.

## Commands
Expand Down
6 changes: 6 additions & 0 deletions docs/plans/2026-08-13-agent-bundle-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"streamableHttp": true
},
"nativePaths": {
"lsp": ".lsp.json",
"manifest": ".claude-plugin/plugin.json",
"marketplace": ".claude-plugin/marketplace.json",
"mcp": ".mcp.json"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."
]
}
}
224 changes: 220 additions & 4 deletions packages/agent-bundle/src/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@ 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' };
import {
createAdapterValidator,
hasPathToken,
schemaDescriptorsFrom,
sourceInputs,
standardArtifactLayout,
standardPluginArtifactPlan,
validateJsonSchemaDocument,
Expand All @@ -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<Record<string, string>>;
/** File extension to LSP language identifier, for example `{ '.go': 'go' }`. */
readonly extensionToLanguage: Readonly<Record<string, string>>;
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<Record<string, ClaudeLspServerConfig>>;
}

export interface ClaudeConfigExtension {
claude?: AgentBundleHostConfig;
claude?: ClaudeHostConfig;
}

declare module '../core/types.ts' {
interface AgentBundleConfigExtensions {
claude?: AgentBundleHostConfig;
claude?: ClaudeHostConfig;
}
}

Expand All @@ -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',
Expand All @@ -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;
Expand All @@ -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),
});
Expand All @@ -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) }),
Expand Down Expand Up @@ -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<string> = 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<Record<string, unknown>> =>
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<string, unknown> } => {
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<string, unknown> = Object.create(null) as Record<string, unknown>;
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<string, unknown>;
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<string, Record<string, unknown>> = Object.create(null) as Record<string, Record<string, unknown>>;
// 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<string, string>();
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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading
Loading