diff --git a/.changeset/repository-marketplaces.md b/.changeset/repository-marketplaces.md new file mode 100644 index 000000000..01506e4c6 --- /dev/null +++ b/.changeset/repository-marketplaces.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Generate repository-root host marketplaces with `output.repositoryMarketplace` so committed artifacts install directly from GitHub without hand-written manifests (#827). diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index d253cd063..2c51df559 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -1,5 +1,5 @@ import { execFile as executeFile } from 'node:child_process'; -import { mkdtemp, rm } from 'node:fs/promises'; +import { lstat, mkdtemp, rm } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { promisify } from 'node:util'; @@ -26,7 +26,7 @@ import { featureCapabilityName, type AgentComponentKind, } from './core/components.ts'; -import { errorMessage } from './core/errors.ts'; +import { errorMessage, isErrno } from './core/errors.ts'; import { resolveProcessNpmCliJs } from './core/npm-cli.ts'; import { isInsideOrEqual } from './core/paths.ts'; import { @@ -265,6 +265,7 @@ export type { } from './dev/eval/eval-service.ts'; import { ProjectService, + resolveOutputRoots, projectDiagnostic, type PreparedProject, } from './dev/project-service.ts'; @@ -619,6 +620,8 @@ export interface InvalidInspectResult { export type InspectResult = ReadyInspectResult | InvalidInspectResult; export interface BuildOptions extends ProjectOptions { + /** Set false for temporary builds that must not replace configured repository marketplaces. */ + readonly repositoryMarketplaces?: boolean; /** * After the artifact is written, run the installed Claude developer * validator (`claude plugin validate --strict` against the emitted @@ -1286,8 +1289,21 @@ export const build = async (options: BuildOptions): Promise }]); } log(options.logger, 'artifact.build', { output, root: prepared.root }); + if (model.repositoryMarketplace === true && options.repositoryMarketplaces !== false) { + try { + if ((await lstat(output)).isSymbolicLink()) { + throw new Error('Repository marketplaces require a real artifact output directory, not a symlink.'); + } + } catch (error) { + if (!isErrno(error, 'ENOENT')) throw error; + } + } + const repositoryOutputRoot = model.repositoryMarketplace === true && options.repositoryMarketplaces !== false + ? (await resolveOutputRoots(root, prepared.root, [output]))[0] + : undefined; const result = await buildArtifact({ model, + ...(repositoryOutputRoot === undefined ? {} : { repositoryOutputRoot }), outputRoot: output, projectContext, projectRoot: prepared.root, diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index 478220490..9ecbee8c6 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -30,6 +30,7 @@ import { type CompiledCliBin, } from './cli-bins.ts'; import { composeProjections, type CompositePlan } from './compose.ts'; +import { checkRepositoryMarketplacePaths, emitRepositoryMarketplaces, planRepositoryMarketplaces } from './repository-marketplace.ts'; import { projectMeta } from './meta.ts'; import { compileMcpApps, @@ -107,6 +108,8 @@ export interface BuildResult { } export interface BuildOptions { + /** Only an explicit project build publishes repository files; dev/eval staging does not. */ + readonly repositoryOutputRoot?: string; /** * The MCP App view compile profile; defaults to `production`. Only the * Workbench dev loop passes `development` (readable output, inline source @@ -696,6 +699,10 @@ export const build = async (options: BuildOptions): Promise => { // together and staged as one tree at the artifact root, never one // subdirectory per target. const composite = composeProjections(options.model, options.registry); + const repositoryMarketplaces = options.repositoryOutputRoot !== undefined + ? planRepositoryMarketplaces(composite, options.registry, options.projectRoot, options.repositoryOutputRoot) + : []; + await checkRepositoryMarketplacePaths(options.projectRoot, repositoryMarketplaces); const preflight = planStagedRoot({ composite, model: options.model, @@ -901,6 +908,7 @@ export const build = async (options: BuildOptions): Promise => { ); } await publishArtifact({ outputRoot, stageRoot }); + await emitRepositoryMarketplaces(options.projectRoot, repositoryMarketplaces); return Object.freeze({ compiledCliBins: Object.freeze(compiledCliBins.map((entry) => Object.freeze({ ...entry, diff --git a/packages/agent-bundle/src/build/repository-marketplace.ts b/packages/agent-bundle/src/build/repository-marketplace.ts new file mode 100644 index 000000000..e7eb1aa79 --- /dev/null +++ b/packages/agent-bundle/src/build/repository-marketplace.ts @@ -0,0 +1,113 @@ +import { randomUUID } from 'node:crypto'; +import { lstat, mkdir, rename, rm, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; + +import type { TargetRegistry } from '../adapters/registry.ts'; +import type { TargetArtifactWrite } from '../adapters/types.ts'; +import { stableJson } from '../core/digest.ts'; +import { isErrno } from '../core/errors.ts'; +import { assertInside, isInside, isInsideOrEqual, toPosixRelative } from '../core/paths.ts'; +import { isPlainDataRecord, parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; +import type { CompositePlan } from './compose.ts'; + +/** Native marketplace paths come from the registered schema contracts. */ +export const repositoryMarketplacePaths = (registry: TargetRegistry, targets: readonly string[]): string[] => + [...new Set(targets.filter((target) => registry.has(target)).flatMap((target) => + registry.artifactValidation(target).documents + .filter((document) => document.schema === 'marketplace') + .map((document) => document.path)))]; + +export const planRepositoryMarketplaces = ( + composite: CompositePlan, + registry: TargetRegistry, + projectRoot: string, + outputRoot: string, +): readonly TargetArtifactWrite[] => { + if (!isInside(projectRoot, outputRoot)) { + throw new Error('Repository marketplaces require an artifact output inside the project root.'); + } + const source = `./${toPosixRelative(projectRoot, outputRoot)}`; + const entries: TargetArtifactWrite[] = []; + for (const projection of composite.projections) { + const path = projection.plan.documents?.marketplace?.path; + if (path === undefined) continue; + const destination = assertInside(projectRoot, resolve(projectRoot, path)); + if (isInsideOrEqual(outputRoot, destination) || isInsideOrEqual(destination, outputRoot)) { + throw new Error(`Repository marketplace ${path} overlaps the artifact output; choose a different output.distPath.`); + } + const host = registry.builtInHost(projection.name); + if (host !== 'claude' && host !== 'codex' && host !== 'cursor') { + throw new Error(`Repository marketplace emission is unsupported for ${projection.name}.`); + } + const entry = projection.plan.entries.find((candidate) => candidate.relativePath === path); + if (entry?.kind !== 'write') throw new Error(`Missing generated marketplace ${path}.`); + const document = parseJsonWithoutDuplicateKeys(entry.content); + if (!isPlainDataRecord(document) || !Array.isArray(document['plugins'])) { + throw new Error(`Invalid generated marketplace ${path}.`); + } + for (const plugin of document['plugins']) { + if (!isPlainDataRecord(plugin)) throw new Error(`Invalid marketplace plugin in ${path}.`); + const original = plugin['source']; + if (host === 'codex' && isPlainDataRecord(original) && original['source'] === 'local' && original['path'] === './') { + plugin['source'] = { ...original, path: source }; + } else if (host !== 'codex' && original === './') { + plugin['source'] = source; + } else { + throw new Error(`Repository marketplace ${path} requires the generated local plugin source; remove the authored source override.`); + } + } + const metadata = document['metadata']; + if (isPlainDataRecord(metadata) && metadata['pluginRoot'] !== undefined) { + throw new Error(`Repository marketplace ${path} cannot use metadata.pluginRoot; remove that override.`); + } + const schema = registry.artifactValidation(projection.name).schemas.find((candidate) => candidate.name === 'marketplace'); + if (schema === undefined || schema.validate(document).length > 0) { + throw new Error(`Repository marketplace ${path} does not satisfy its host schema.`); + } + entries.push({ ...entry, content: `${stableJson(document)}\n` }); + } + if (entries.length === 0) { + throw new Error('No selected host emits a marketplace; for Cursor, set marketplace: true.'); + } + return entries; +}; + +/** Refuse symlinks at every component before writing outside the artifact tree. */ +export const checkRepositoryMarketplacePaths = async ( + root: string, + entries: readonly TargetArtifactWrite[], +): Promise => { + for (const entry of entries) { + const destination = assertInside(root, resolve(root, entry.relativePath)); + let path = destination; + while (isInside(root, path)) { + try { + const metadata = await lstat(path); + if (metadata.isSymbolicLink() || (path === destination ? !metadata.isFile() : !metadata.isDirectory())) { + throw new Error(`Repository marketplace output cannot replace or traverse ${path}.`); + } + } catch (error) { + if (!isErrno(error, 'ENOENT')) throw error; + } + path = dirname(path); + } + } +}; + +export const emitRepositoryMarketplaces = async ( + root: string, + entries: readonly TargetArtifactWrite[], +): Promise => { + await checkRepositoryMarketplacePaths(root, entries); + for (const entry of entries) { + const destination = assertInside(root, resolve(root, entry.relativePath)); + await mkdir(dirname(destination), { recursive: true }); + const temporary = join(dirname(destination), `.marketplace-${randomUUID()}.tmp`); + try { + await writeFile(temporary, entry.content, { flag: 'wx' }); + await rename(temporary, destination); + } finally { + await rm(temporary, { force: true }); + } + } +}; diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index d3e2fdece..6180c95a8 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -1354,6 +1354,7 @@ export const normalizeProject = async ( ...(assets.length === 0 ? {} : { assets }), ...(commands.length === 0 ? {} : { commands }), ...(loaded.config.marketplace === true ? { marketplace: true as const } : {}), + ...(loaded.config.output?.repositoryMarketplace === true ? { repositoryMarketplace: true as const } : {}), extensions, ...(hostBins.length === 0 ? {} : { hostBins }), ...(hostOutputStyles.length === 0 ? {} : { hostOutputStyles }), diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 76d3731ce..71ce98d3b 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -1282,7 +1282,7 @@ const validateBin = (loaded: LoadedConfig): Diagnostic[] => { }; const outputShapeRecovery = - 'Declare output.distPath as a non-empty project-root-relative path string, output.sourceMap as a boolean, or remove the output block.'; + 'Declare output.distPath as a non-empty project-root-relative path string, output.sourceMap and output.repositoryMarketplace as booleans, or remove the output block.'; const outputPathRecovery = 'Use a project-root-contained relative POSIX path; pass the CLI --output flag for per-invocation absolute locations.'; const outputReservedRecovery = @@ -1294,12 +1294,20 @@ const validateOutput = (loaded: LoadedConfig): Diagnostic[] => { if (!isArtifactOutputConfig(output)) { return [sourceDiagnostic( 'AB4707', - 'Output configuration must be an object with optional distPath and sourceMap fields.', + 'Output configuration must be an object with optional distPath, sourceMap, and repositoryMarketplace fields.', loaded.configPath, outputShapeRecovery, )]; } const diagnostics: Diagnostic[] = []; + if (Object.hasOwn(output, 'repositoryMarketplace') && typeof output.repositoryMarketplace !== 'boolean') { + diagnostics.push(sourceDiagnostic( + 'AB4707', + 'Output repositoryMarketplace must be a boolean when declared.', + loaded.configPath, + 'Set output.repositoryMarketplace to true to emit repository-root marketplaces, or remove it.', + )); + } if (Object.hasOwn(output, 'sourceMap') && typeof output.sourceMap !== 'boolean') { diagnostics.push(sourceDiagnostic( 'AB4707', diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index 64cc92967..5ff9e6d5a 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -186,6 +186,8 @@ export interface AgentBundleWebConfig { /** Optional artifact output location config, inspired by Rsbuild's `output.distPath`. */ export interface AgentBundleOutputConfig { + /** Emit selected hosts' marketplace documents at the project root, pointing to distPath. These files are generated and replaced by build. */ + repositoryMarketplace?: boolean; /** * The artifact output directory of `agent-bundle build`, relative to the * project root. Defaults to `dist`. The per-invocation CLI `--output` flag @@ -836,6 +838,7 @@ export interface NormalizedPlugin { * profile in. Absent means source maps stay off. */ readonly sourceMap?: true; + readonly repositoryMarketplace?: true; readonly scripts: readonly NormalizedScript[]; readonly skills: readonly NormalizedSkill[]; readonly state?: NormalizedStateDefinition; diff --git a/packages/agent-bundle/src/dev/project-service.ts b/packages/agent-bundle/src/dev/project-service.ts index b2c14e37b..b6b26741f 100644 --- a/packages/agent-bundle/src/dev/project-service.ts +++ b/packages/agent-bundle/src/dev/project-service.ts @@ -13,6 +13,7 @@ import { } from '../config/dev-contracts.ts'; import { configuredPayloadRoots, discoverProject } from '../config/discover.ts'; import { isProjectPathIgnored, readProjectIgnoreRules } from '../config/ignore.ts'; +import { repositoryMarketplacePaths } from '../build/repository-marketplace.ts'; import { loadConfig } from '../config/load.ts'; import { normalizeEvalConfig } from '../eval/config.ts'; import { @@ -226,7 +227,7 @@ const physicalOutputRoot = async ( return physical; }; -const resolveOutputRoots = async ( +export const resolveOutputRoots = async ( requestedRoot: string, root: string, outputRoots: readonly string[] | undefined, @@ -849,6 +850,10 @@ export class ProjectService { const targetNames = loaded.context.selectedTargets.length > 0 ? loaded.context.selectedTargets : (loaded.config.targets ?? registry.defaultTargetNames()); + if (loaded.config.output?.repositoryMarketplace === true) { + outputRoots = Object.freeze([...outputRoots, ...repositoryMarketplacePaths(registry, targetNames) + .map((path) => resolve(root, path))]); + } const hostBinRoots = (registry.binSources?.(loaded.config, targetNames) ?? []) .flatMap((source) => 'source' in source ? [resolve(dirname(loaded.configPath), source.source)] : []); const hostOutputStyleRoots = (registry.outputStyleSources?.(loaded.config, targetNames) ?? []) diff --git a/packages/agent-bundle/src/eval/artifact.ts b/packages/agent-bundle/src/eval/artifact.ts index f0bc18918..deacf94eb 100644 --- a/packages/agent-bundle/src/eval/artifact.ts +++ b/packages/agent-bundle/src/eval/artifact.ts @@ -110,6 +110,7 @@ export const prepareEvalArtifact = async ( await build({ ...(options.configPath === undefined ? {} : { configPath: options.configPath }), output: artifactRoot, + repositoryMarketplaces: false, registry, root: options.projectRoot, ...(options.targets === undefined ? {} : { targets: [...options.targets] }), diff --git a/packages/agent-bundle/tests/api.test.ts b/packages/agent-bundle/tests/api.test.ts index a76eb2376..ef75c592c 100644 --- a/packages/agent-bundle/tests/api.test.ts +++ b/packages/agent-bundle/tests/api.test.ts @@ -374,6 +374,70 @@ it('reports one modern-MCP source diagnostic for a legacy SSE declaration', asyn } }); +it('emits repository marketplaces from the selected host plans without making outputs source inputs', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-repository-')); + try { + await writeFile(join(root, 'agent-bundle.config.ts'), `export default { + plugin: { name: 'repository-fixture', version: '1.0.0' }, + marketplace: true, + output: { distPath: 'artifact', repositoryMarketplace: true }, + targets: ['claude', 'codex', 'cursor'], + };`); + const first = await build({ root }); + const second = await build({ root }); + expect(second.projectContext.sourceInputs).toEqual(first.projectContext.sourceInputs); + for (const path of ['.claude-plugin/marketplace.json', '.cursor-plugin/marketplace.json', '.agents/plugins/marketplace.json']) { + const repository = JSON.parse(await readFile(join(root, path), 'utf8')); + const artifact = JSON.parse(await readFile(join(root, 'artifact', path), 'utf8')); + expect(repository.plugins[0].source).toEqual(path.startsWith('.agents') + ? { source: 'local', path: './artifact' } + : './artifact'); + expect(artifact.plugins[0].source).toEqual(path.startsWith('.agents') + ? { source: 'local', path: './' } + : './'); + repository.plugins[0].source = artifact.plugins[0].source; + expect(repository).toEqual(artifact); + expect(second.projectContext.sourceInputs.some((input) => input.path === path)).toBe(false); + } + await expect(validate({ artifact: join(root, 'artifact'), root })).resolves.toEqual({ diagnostics: [] }); + await build({ output: 'release/plugin', root }); + expect(JSON.parse(await readFile(join(root, '.cursor-plugin/marketplace.json'), 'utf8')).plugins[0].source) + .toBe('./release/plugin'); + await build({ output: '.agent-bundle/eval/artifact', repositoryMarketplaces: false, root }); + expect(JSON.parse(await readFile(join(root, '.cursor-plugin/marketplace.json'), 'utf8')).plugins[0].source) + .toBe('./release/plugin'); + await expect(build({ output: '.cursor-plugin', root })).rejects.toThrow('overlaps the artifact output'); + await symlink(join(root, 'artifact'), join(root, 'linked-artifact'), 'dir'); + await expect(build({ output: 'linked-artifact', root })).rejects.toThrow('real artifact output directory'); + await symlink(root, join(root, 'alias'), 'dir'); + await expect(build({ output: 'alias/.cursor-plugin', root })).rejects.toThrow('overlaps the artifact output'); + await build({ output: 'alias/release/plugin', root }); + expect(JSON.parse(await readFile(join(root, '.cursor-plugin/marketplace.json'), 'utf8')).plugins[0].source) + .toBe('./release/plugin'); + } finally { + await removeTree(root); + } +}); + +it('leaves repository marketplaces alone by default and refuses symlinked output parents when enabled', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-repository-symlink-')); + try { + const config = { plugin: { name: 'repository-fixture', version: '1.0.0' }, marketplace: true, targets: ['cursor'] }; + await writeFile(join(root, 'agent-bundle.config.ts'), `export default ${JSON.stringify(config)};`); + await build({ root }); + await expect(stat(join(root, '.cursor-plugin'))).rejects.toMatchObject({ code: 'ENOENT' }); + const other = join(root, 'authored'); + await mkdir(other); + await writeFile(join(other, 'marketplace.json'), 'preserve me'); + await symlink(other, join(root, '.cursor-plugin'), 'dir'); + await writeFile(join(root, 'agent-bundle.config.ts'), `export default ${JSON.stringify({ ...config, output: { repositoryMarketplace: true } })};`); + await expect(build({ root })).rejects.toThrow('cannot replace or traverse'); + expect(await readFile(join(other, 'marketplace.json'), 'utf8')).toBe('preserve me'); + } finally { + await removeTree(root); + } +}); + it('resolves artifact output with CLI, config, and default precedence', async () => { const root = await createProject(); try { diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index d56ba33cf..49eed6c45 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -1165,10 +1165,11 @@ it('validates --from Codex bytes without running the live schema generator', asy hosts: ['codex'], }); - // Read-only inventory only: the version probe and the pinned `plugin list --json`, never the schema generator. + // Read-only version and inventory probes, never the schema generator. expect(calls).toEqual([ expect.objectContaining({ args: ['--version'], executable: 'codex' }), expect.objectContaining({ args: ['plugin', 'list', '--json'], cwd: bundle, executable: 'codex' }), + expect.objectContaining({ args: ['plugin', 'marketplace', 'list', '--json'], cwd: bundle, executable: 'codex' }), ]); expect(hostReport(report, 'codex').bundle?.state).toBe('corrupt'); expect(report.diagnostics).toEqual(expect.arrayContaining([ diff --git a/packages/agent-bundle/tests/eval-harness.test.ts b/packages/agent-bundle/tests/eval-harness.test.ts index 083c5f0d5..59d19369a 100644 --- a/packages/agent-bundle/tests/eval-harness.test.ts +++ b/packages/agent-bundle/tests/eval-harness.test.ts @@ -121,7 +121,13 @@ it('validates and reads an explicit artifact exactly and builds one run-owned co await withWorkspace(async () => { try { const output = join(project.root, 'explicit-artifact'); + await writeFile(join(project.root, 'agent-bundle.config.ts'), `export default { + plugin: { name: 'eval-repository-fixture', version: '1.0.0' }, + targets: ['cursor'], marketplace: true, + output: { repositoryMarketplace: true }, + };`); await build({ output, root: project.root }); + const repositoryMarketplace = await readFile(join(project.root, '.cursor-plugin/marketplace.json'), 'utf8'); const explicitWriter = await createEvalRun({ artifact: { manifestPath: 'pending', source: 'explicit', targetDigests: { portable: 'pending' } }, @@ -154,6 +160,7 @@ it('validates and reads an explicit artifact exactly and builds one run-owned co expect(current.binding.source).toBe('run-owned'); expect(current.root).toBe(join(sourceWriter.directory, 'artifacts', 'target')); expect(current.binding.targetDigests).toEqual(explicit.binding.targetDigests); + expect(await readFile(join(project.root, '.cursor-plugin/marketplace.json'), 'utf8')).toBe(repositoryMarketplace); await expect(prepareEvalArtifact({ artifact: join(project.root, 'absent'), projectRoot: project.root, diff --git a/packages/agent-bundle/tests/package-conventions.test.ts b/packages/agent-bundle/tests/package-conventions.test.ts index 9119f2a0c..61a5bc072 100644 --- a/packages/agent-bundle/tests/package-conventions.test.ts +++ b/packages/agent-bundle/tests/package-conventions.test.ts @@ -469,6 +469,7 @@ describe('artifact output validation', () => { { code: 'AB4707', label: 'an undefined block', output: undefined }, { code: 'AB4707', label: 'an array block', output: [] }, { code: 'AB4707', label: 'a string block', output: 'artifact' }, + { code: 'AB4707', label: 'a non-boolean repository marketplace option', output: { repositoryMarketplace: 'yes' } }, { code: 'AB4707', label: 'an undefined path', output: { distPath: undefined } }, { code: 'AB4707', label: 'a non-string path', output: { distPath: 7 } }, { code: 'AB4707', label: 'an empty path', output: { distPath: '' } }, diff --git a/packages/agent-bundle/tests/packed-install-bin.test.ts b/packages/agent-bundle/tests/packed-install-bin.test.ts index 0ca2fe4b1..e739eadc4 100644 --- a/packages/agent-bundle/tests/packed-install-bin.test.ts +++ b/packages/agent-bundle/tests/packed-install-bin.test.ts @@ -108,7 +108,8 @@ beforeAll(async () => { writeFile(join(project, 'agent-bundle.config.ts'), [ 'export default {', ` bin: { '${binName}': './src/install-bin.ts' },`, - " output: { distPath: 'artifact' },", + " marketplace: true,", + " output: { distPath: 'artifact', repositoryMarketplace: true },", ` plugin: { description: 'Installs itself through agent-bundle/install.', name: '${packageName}' },`, " targets: ['cursor'],", '};', @@ -138,6 +139,8 @@ beforeAll(async () => { cwd: project, env: installEnv, }); + const marketplace = JSON.parse(await readFile(join(project, '.cursor-plugin/marketplace.json'), 'utf8')); + expect(marketplace.plugins).toEqual([expect.objectContaining({ name: packageName, source: './artifact' })]); const tarballs = join(consumer, 'tarballs'); const installed = join(consumer, 'installed'); diff --git a/website/docs/en/guide/distribution/index.mdx b/website/docs/en/guide/distribution/index.mdx index cb28ab69c..d3445264e 100644 --- a/website/docs/en/guide/distribution/index.mdx +++ b/website/docs/en/guide/distribution/index.mdx @@ -21,6 +21,23 @@ project. They deliberately do not fetch a package by the unqualified `agent-bund ## The pipeline +For installation from a source repository, configure the compiler to emit its root marketplace: + +```ts +export default defineConfig({ + plugin: { name: 'my-plugin' }, + targets: ['cursor', 'portable'], + marketplace: true, + output: { distPath: 'artifact', repositoryMarketplace: true }, +}); +``` + +After building, commit `artifact/` and `.cursor-plugin/marketplace.json` (use `git add -f` +for ignored generated output). The root marketplace points to `./artifact`; every plugin +manifest, script, skill, and installer is emitted by Agent Bundle. Consumers do not build. +The same option emits `.claude-plugin/marketplace.json` and `.agents/plugins/marketplace.json` +when Claude and Codex are selected. CI only needs to build, validate, and commit the output. + ```sh npx --no-install agent-bundle build --output artifact npx --no-install agent-bundle validate --artifact artifact --strict diff --git a/website/docs/en/reference/configuration.mdx b/website/docs/en/reference/configuration.mdx index 1acb69aac..510c353fb 100644 --- a/website/docs/en/reference/configuration.mdx +++ b/website/docs/en/reference/configuration.mdx @@ -30,7 +30,7 @@ export default defineConfig({ | `bin` | `false \| Record` | The `src/cli.ts` convention. | | `lib` | `false \| string \| { entry, dts? }` | The `src/index.ts` convention. | | `routes` | Route-graph policy (`cli`, `mcpCommands`, `servers`) | Convention-derived. A tool with a colocated `.cli.ts` is excluded from `routes.mcpCommands`. | -| `output` | `{ distPath?, sourceMap? }` | `artifact` from the CLI; `dist` from `build()` without `packageOutputs`. `sourceMap` defaults to `false`. | +| `output` | `{ distPath?, sourceMap?, repositoryMarketplace? }` | `artifact` from the CLI; `dist` from `build()` without `packageOutputs`. Both boolean options default to `false`. | | `runtime` | `{ node }` | Node 22.12. | | `payload` | `Record` | None. | | `state` | `false` | The `src/state.ts` convention. | @@ -154,6 +154,15 @@ without a reason fails. ## output and runtime +`output.repositoryMarketplace: true` makes an explicit `build` also generate the selected +hosts' marketplace documents at the project root, with local plugin sources pointing to the +artifact output directory. Cursor additionally requires `marketplace: true`. Commit these +files and the artifact directory for installation directly from GitHub. The compiler replaces +the selected marketplace files; do not hand-edit them. It refuses symlinked destinations, +paths that overlap the artifact, custom adapters, and authored marketplace source or +`metadata.pluginRoot` overrides. These generated files are excluded from source snapshots. +Development and evaluation staging do not publish repository marketplaces. + `output.distPath` is the artifact output directory relative to the project root — the composite plugin root every selected host reads, with no per-host subdirectory beneath it. The CLI (`build`, `prepack`, `dev`) defaults it to `artifact`, because the CLI also runs the package build diff --git a/website/docs/zh/guide/distribution/index.mdx b/website/docs/zh/guide/distribution/index.mdx index 6086b0206..11ab00a8c 100644 --- a/website/docs/zh/guide/distribution/index.mdx +++ b/website/docs/zh/guide/distribution/index.mdx @@ -19,6 +19,23 @@ description: '构建并校验组合插件,按需打包生成的 npm 根目录 ## 流水线 +要从源码仓库直接安装,可配置编译器生成仓库根目录市场清单: + +```ts +export default defineConfig({ + plugin: { name: 'my-plugin' }, + targets: ['cursor', 'portable'], + marketplace: true, + output: { distPath: 'artifact', repositoryMarketplace: true }, +}); +``` + +构建后提交 `artifact/` 和 `.cursor-plugin/marketplace.json`(对于被忽略的生成文件,使用 +`git add -f`)。根目录市场指向 `./artifact`;插件清单、脚本、技能和安装器均由 Agent Bundle +生成,使用者无需构建。选择 Claude 和 Codex 时,同一选项也会生成 +`.claude-plugin/marketplace.json` 和 `.agents/plugins/marketplace.json`。 +CI 只需构建、校验并提交输出。 + ```sh npx --no-install agent-bundle build --output artifact npx --no-install agent-bundle validate --artifact artifact --strict diff --git a/website/docs/zh/reference/configuration.mdx b/website/docs/zh/reference/configuration.mdx index 1c73f0ba4..f17998f7d 100644 --- a/website/docs/zh/reference/configuration.mdx +++ b/website/docs/zh/reference/configuration.mdx @@ -30,7 +30,7 @@ export default defineConfig({ | `bin` | `false \| Record` | `src/cli.ts` 约定。 | | `lib` | `false \| string \| { entry, dts? }` | `src/index.ts` 约定。 | | `routes` | 路由图策略(`cli`、`mcpCommands`、`servers`) | 由约定推导。同位置有 `.cli.ts` 的工具会从 `routes.mcpCommands` 中排除。 | -| `output` | `{ distPath?, sourceMap? }` | 命令行下为 `artifact`;不带 `packageOutputs` 的 `build()` 下为 `dist`。`sourceMap` 默认为 `false`。 | +| `output` | `{ distPath?, sourceMap?, repositoryMarketplace? }` | 命令行下为 `artifact`;不带 `packageOutputs` 的 `build()` 下为 `dist`。两个布尔选项均默认为 `false`。 | | `runtime` | `{ node }` | Node 22.12。 | | `payload` | `Record` | 无。 | | `state` | `false` | `src/state.ts` 约定。 | @@ -136,6 +136,12 @@ Claude Code 的 `.claude-plugin/plugin.json` 不携带描述字段——其固 ## output 与 runtime +`output.repositoryMarketplace: true` 让显式 `build` 在项目根目录生成所选宿主的市场清单, +其中本地插件来源指向产物输出目录。Cursor 还需要 `marketplace: true`。将这些文件与产物目录 +一起提交后,即可从 GitHub 直接安装。编译器会替换所选市场清单,请勿手工编辑。它会拒绝符号链接、 +与产物目录重叠的路径、自定义适配器,以及手写的市场来源或 `metadata.pluginRoot` 覆盖。 +这些生成文件不计入源码快照。开发与评估的暂存构建不会发布项目根目录市场清单。 + `output.distPath` 是相对项目根目录的产物输出目录——每个所选宿主都读取的组合插件根目录,其下没有逐宿主 子目录。命令行(`build`、`prepack`、`dev`)把它默认为 `artifact`, 因为命令行同时运行包构建,而包构建拥有 `dist/`;编程式 `build()` 除非传入 `packageOutputs: true`,否则默认为