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/repository-marketplaces.md
Original file line number Diff line number Diff line change
@@ -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).
20 changes: 18 additions & 2 deletions packages/agent-bundle/src/api.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 {
Expand Down Expand Up @@ -265,6 +265,7 @@ export type {
} from './dev/eval/eval-service.ts';
import {
ProjectService,
resolveOutputRoots,
projectDiagnostic,
type PreparedProject,
} from './dev/project-service.ts';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1286,8 +1289,21 @@ export const build = async (options: BuildOptions): Promise<BuildProjectResult>
}]);
}
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
Comment thread
ScriptedAlchemy marked this conversation as resolved.
? (await resolveOutputRoots(root, prepared.root, [output]))[0]
: undefined;
const result = await buildArtifact({
model,
...(repositoryOutputRoot === undefined ? {} : { repositoryOutputRoot }),
outputRoot: output,
projectContext,
projectRoot: prepared.root,
Expand Down
8 changes: 8 additions & 0 deletions packages/agent-bundle/src/build/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -696,6 +699,10 @@ export const build = async (options: BuildOptions): Promise<BuildResult> => {
// 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,
Expand Down Expand Up @@ -901,6 +908,7 @@ export const build = async (options: BuildOptions): Promise<BuildResult> => {
);
}
await publishArtifact({ outputRoot, stageRoot });
await emitRepositoryMarketplaces(options.projectRoot, repositoryMarketplaces);
return Object.freeze({
compiledCliBins: Object.freeze(compiledCliBins.map((entry) => Object.freeze({
...entry,
Expand Down
113 changes: 113 additions & 0 deletions packages/agent-bundle/src/build/repository-marketplace.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<void> => {
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 });
}
}
};
1 change: 1 addition & 0 deletions packages/agent-bundle/src/config/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
12 changes: 10 additions & 2 deletions packages/agent-bundle/src/config/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-bundle/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 6 additions & 1 deletion packages/agent-bundle/src/dev/project-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -226,7 +227,7 @@ const physicalOutputRoot = async (
return physical;
};

const resolveOutputRoots = async (
export const resolveOutputRoots = async (
requestedRoot: string,
root: string,
outputRoots: readonly string[] | undefined,
Expand Down Expand Up @@ -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) ?? [])
Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/src/eval/artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] }),
Expand Down
64 changes: 64 additions & 0 deletions packages/agent-bundle/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion packages/agent-bundle/tests/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
Loading
Loading