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
7 changes: 7 additions & 0 deletions .changeset/plugin-logo-cursor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"agent-bundle": minor
---

Add optional `plugin.logo` so Cursor artifacts can emit a `logo` field.

The path is validated at build time (AB4012) and copied into the artifact; Cursor `.cursor-plugin/plugin.json` references it relatively. Claude and Codex manifests still have no icon field, so they omit it on purpose. Artifact validation fails with AB6025 when a declared logo is missing from the deploy tree.
3 changes: 2 additions & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ gate a build, a validation, or a dev rebuild.
| Family | Area |
| --- | --- |
| `AB30xx` | Skill documents: Markdown parsing (`AB3000`–`AB3002`: unreadable, missing or malformed frontmatter) and rendered-skill compilation (`AB3003`: module failed to load, `AB3004`: missing/invalid default component or `frontmatter` export, `AB3005`: content outside the supported Markdown element subset). |
| `AB40xx` | Plugin metadata and Skill source validation. |
| `AB40xx` | Plugin metadata and Skill source validation (`AB4000`/`AB4001`: missing name/version; `AB4002`–`AB4007`: Skill fields; `AB4008`–`AB4011`: package identity; `AB4012`: declared `plugin.logo` is missing, not a file, or outside the project). |
| `AB41xx` | Normalized model invariants (unknown targets, duplicate IDs and outputs). |
| `AB42xx` | Hook configuration and native hook sources. |
| `AB43xx` | MCP server and MCP App configuration. |
Expand All @@ -24,6 +24,7 @@ gate a build, a validation, or a dev rebuild.
| `AB473x` | Migration nudges (informational; see below). |
| `AB474x`/`AB4750` | Prebuilt payloads and prebuilt entries (see below). |
| `AB5000` | General CLI and adapter failures. |
| `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree). |
| `AB700x` | Host installation: bundle identity, host availability, scope, command failure, and collision checks. |
| `AB7xxx` | Project preparation and development rebuilds. |
| `AB7300`–`AB7315` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, and runtime endpoint health. |
Expand Down
7 changes: 5 additions & 2 deletions packages/agent-bundle/src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
type TargetArtifactLayout,
type TargetArtifactPlan,
} from './types.ts';
import { pluginLogoManifestRef, withPluginLogoEntry } from './plugin-logo.ts';
import { withInstallSurface } from '../install/surface.ts';

const cursorName = 'cursor';
Expand Down Expand Up @@ -256,6 +257,7 @@ export const cursorManifest = (
description: model.metadata.description ?? model.metadata.name,
displayName: model.metadata.name,
...(pointers.hooks === undefined ? {} : { hooks: pointers.hooks }),
...(model.metadata.logo === undefined ? {} : { logo: pluginLogoManifestRef(model.metadata.logo.path) }),
...(pointers.mcp === undefined ? {} : { mcpServers: pointers.mcp }),
name: model.metadata.name,
...(pointers.rules === undefined ? {} : { rules: pointers.rules }),
Expand Down Expand Up @@ -406,6 +408,7 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan
additionalPluginSourceInputs: [
...selectedCommands.map((command) => command.source),
...selectedRules.map((rule) => rule.source),
...(model.metadata.logo === undefined ? [] : [model.metadata.logo.source]),
],
diagnostics,
hookDocument,
Expand All @@ -426,12 +429,12 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan
});
return withInstallSurface(Object.freeze({
...basePlan,
entries: sortedEntries([
entries: sortedEntries(withPluginLogoEntry([
...basePlan.entries,
...commandWriteEntries(model, isSelected, (command) =>
command.markdown === command.body ? command.markdown : command.body),
...ruleWriteEntries(model, isSelected),
]),
], model)),
}), model, 'cursor');
};

Expand Down
28 changes: 28 additions & 0 deletions packages/agent-bundle/src/adapters/plugin-logo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { NormalizedPlugin } from '../core/types.ts';
import { sourceInputs, type TargetArtifactCopy, type TargetArtifactEntry } from './types.ts';

export const pluginLogoManifestRef = (artifactPath: string): string =>
artifactPath.startsWith('./') ? artifactPath : `./${artifactPath}`;

export const pluginLogoCopyEntry = (model: NormalizedPlugin): TargetArtifactCopy | undefined => {
const logo = model.metadata.logo;
if (logo === undefined) return undefined;
return {
bytes: logo.bytes,
kind: 'copy',
relativePath: logo.path,
source: logo.source,
sourceInputs: sourceInputs(logo.source, model.metadata.provenance.sourcePath),
};
};

export const withPluginLogoEntry = (
entries: readonly TargetArtifactEntry[],
model: NormalizedPlugin,
): TargetArtifactEntry[] => {
const logoEntry = pluginLogoCopyEntry(model);
if (logoEntry === undefined || entries.some((entry) => entry.relativePath === logoEntry.relativePath)) {
return [...entries];
}
return [...entries, logoEntry];
};
6 changes: 6 additions & 0 deletions packages/agent-bundle/src/adapters/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
planCursorMarketplace,
planCursorMcpServer,
} from './cursor.ts';
import { pluginLogoCopyEntry } from './plugin-logo.ts';
import {
encodeNativeHookPlaygroundInput,
encodeNativeHookPlaygroundOutput,
Expand Down Expand Up @@ -445,8 +446,13 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => {
model.metadata.provenance.sourcePath,
...targetSourceInputs,
...selectedRules.map((rule) => rule.source),
model.metadata.logo?.source,
),
});
const logoEntry = pluginLogoCopyEntry(model);
if (logoEntry !== undefined && !entries.some((entry) => entry.relativePath === logoEntry.relativePath)) {
entries.push(logoEntry);
}
if (cursorMcp !== undefined && cursorMcpValid) {
entries.push({
content: `${stableJson(cursorMcp)}\n`,
Expand Down
4 changes: 3 additions & 1 deletion packages/agent-bundle/src/build/artifact-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ export type ArtifactDiagnosticCode =
| 'AB6021'
| 'AB6022'
| 'AB6023'
| 'AB6024';
| 'AB6024'
| 'AB6025';

export const artifactDiagnosticRecoveries: Readonly<Record<ArtifactDiagnosticCode, string>> = Object.freeze({
AB6000: 'Restore a readable artifact root and canonical manifest, then rebuild the artifact.',
Expand Down Expand Up @@ -53,6 +54,7 @@ export const artifactDiagnosticRecoveries: Readonly<Record<ArtifactDiagnosticCod
AB6022: 'Restore a bounded Claude validator process, then rerun artifact validation.',
AB6023: 'Rebuild the artifact so every built-in target includes its generated INSTALL.md.',
AB6024: 'Rebuild the Cursor-compatible artifact so it includes its generated install.mjs.',
AB6025: 'Rebuild the artifact so every manifest-declared logo path copies into the deploy tree.',
});

const isArtifactDiagnosticCode = (code: string): code is ArtifactDiagnosticCode =>
Expand Down
41 changes: 41 additions & 0 deletions packages/agent-bundle/src/build/validate-artifact-logo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { posix } from 'node:path';

import { isContainedRelativePath, safeArtifactPath } from '../core/paths.ts';
import type { Diagnostic } from '../core/diagnostics.ts';
import { artifactDiagnostic as diagnostic } from './artifact-diagnostics.ts';
import { targetArtifactPath } from './artifact-layout.ts';

const isRemoteLogoReference = (value: string): boolean => {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
};

export const manifestLogoPathDiagnostics = (options: {
readonly files: ReadonlySet<string>;
readonly generatedPath: string;
readonly logo: string;
readonly target: string;
}): readonly Diagnostic[] => {
if (isRemoteLogoReference(options.logo)) return Object.freeze([]);
const relativePath = posix.normalize(options.logo.replace(/^\.\//u, ''));
if (!isContainedRelativePath(relativePath) || !safeArtifactPath(relativePath)) {
return Object.freeze([diagnostic(
'AB6025',
`Plugin logo ${JSON.stringify(options.logo)} escapes the artifact for target ${JSON.stringify(options.target)}.`,
options.generatedPath,
options.target,
)]);
}
const artifactPath = targetArtifactPath(options.target, relativePath);
if (options.files.has(artifactPath)) return Object.freeze([]);
return Object.freeze([diagnostic(
'AB6025',
`Plugin logo ${JSON.stringify(options.logo)} references missing artifact file ${JSON.stringify(artifactPath)}.`,
options.generatedPath,
options.target,
)]);
};
13 changes: 13 additions & 0 deletions packages/agent-bundle/src/build/validate-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import type {
} from './artifact-validation-types.ts';
import { validateJavaScriptModules } from './validate-artifact-modules.ts';
import { validateHookCoherence } from './validate-artifact-hooks.ts';
import { manifestLogoPathDiagnostics } from './validate-artifact-logo.ts';
import { validateMcpCoherence } from './validate-artifact-mcp.ts';
import { pathTarget, targetNamespaces, validateEmittedSkills } from './validate-artifact-skills.ts';
import { installSurfaceRequirements } from '../install/surface.ts';
Expand Down Expand Up @@ -394,6 +395,18 @@ const validateTargetContracts = async (options: {
target.name,
));
}
if (
document.path.endsWith('plugin.json') &&
isRecord(parsed) &&
typeof parsed.logo === 'string'
) {
diagnostics.push(...manifestLogoPathDiagnostics({
files,
generatedPath,
logo: parsed.logo,
target: target.name,
}));
}
}
}
return Object.freeze(diagnostics);
Expand Down
20 changes: 19 additions & 1 deletion packages/agent-bundle/src/config/normalize.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto';
import { existsSync, statSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { basename, extname, relative, resolve } from 'node:path';
import { basename, extname, posix, relative, resolve, sep } from 'node:path';

import { digest } from '../core/digest.ts';
import { deepFreeze } from '../core/freeze.ts';
Expand Down Expand Up @@ -45,6 +45,7 @@ import type {
NormalizedPackageBuild,
NormalizedPayload,
NormalizedPlugin,
NormalizedPluginLogo,
NormalizedRuntime,
NormalizedRule,
NormalizedScript,
Expand Down Expand Up @@ -890,6 +891,21 @@ const skillProvenance = (
sourcePath,
});

const normalizePluginLogo = (loaded: LoadedConfig): NormalizedPluginLogo | undefined => {
const declared = loaded.config.plugin.logo;
if (typeof declared !== 'string' || declared.trim().length === 0) return undefined;
const projectRoot = loaded.context.projectRoot;
const source = resolve(projectRoot, declared);
if (!isInside(projectRoot, source) || !existsSync(source)) return undefined;
const stats = statSync(source);
if (!stats.isFile()) return undefined;
return {
bytes: stats.size,
path: posix.join('assets', relative(projectRoot, source).split(sep).join(posix.sep)),
source,
};
};

const normalizeAssets = (
loaded: LoadedConfig,
discovered: DiscoveredProject,
Expand Down Expand Up @@ -1000,6 +1016,7 @@ export const normalizeProject = async (
};
});
const description = loaded.config.plugin.description;
const logo = normalizePluginLogo(loaded);
// The npm package axes are derived, never authored in config: package.json
// is authoritative for release identity (issue #94), while plugin.version
// remains the host-facing declared version during the migration.
Expand Down Expand Up @@ -1033,6 +1050,7 @@ export const normalizeProject = async (
metadata: {
...(typeof description === 'string' ? { description } : {}),
id: `plugin:${loaded.config.plugin.name}`,
...(logo === undefined ? {} : { logo }),
name: loaded.config.plugin.name,
...(packageIdentity.packageName === undefined ? {} : { packageName: packageIdentity.packageName }),
...(packageIdentity.packageVersion === undefined ? {} : { packageVersion: packageIdentity.packageVersion }),
Expand Down
28 changes: 28 additions & 0 deletions packages/agent-bundle/src/config/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,33 @@ const validateMcpServer = (
return diagnostics;
};

const validatePluginLogo = (
loaded: LoadedConfig,
pluginRecord: Record<string, unknown> | undefined,
): Diagnostic[] => {
if (pluginRecord === undefined || !Object.hasOwn(pluginRecord, 'logo')) return [];
const declared = pluginRecord.logo;
const recovery = 'Set plugin.logo to an existing file inside the project root, or omit the field.';
const fail = (message: string): Diagnostic => ({
code: 'AB4012',
message,
recovery,
severity: 'error',
sourcePath: loaded.configPath,
});
if (typeof declared !== 'string' || declared.trim().length === 0) {
return [fail('Plugin logo must be a nonempty path to an existing file inside the project.')];
}
const source = resolve(loaded.context.projectRoot, declared);
if (!isInside(loaded.context.projectRoot, source) || resolve(loaded.context.projectRoot) === source) {
return [fail(`Plugin logo ${JSON.stringify(declared)} must resolve inside the project root.`)];
}
if (!localEntryExists(loaded.context.projectRoot, declared)) {
return [fail(`Plugin logo ${JSON.stringify(declared)} must name an existing file.`)];
}
return [];
};

const validateAssets = (loaded: LoadedConfig): Diagnostic[] => {
const assets = loaded.config.assets;
if (assets === undefined) return [];
Expand Down Expand Up @@ -1736,6 +1763,7 @@ export const validateSource = (
),
);
}
diagnostics.push(...validatePluginLogo(loaded, pluginRecord));

const skillNames = new Map<string, string>();
for (const skill of discovered.skills) {
Expand Down
10 changes: 10 additions & 0 deletions packages/agent-bundle/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import type { CapabilityState } from './capabilities.ts';

export interface AgentBundlePluginConfig {
description?: string;
/** Project-relative path to a logo image copied into host artifacts that support it. */
logo?: string;
name: string;
version: string;
[key: string]: unknown;
Expand Down Expand Up @@ -262,9 +264,17 @@ export interface SourceProvenance {
readonly sourcePath: string;
}

export interface NormalizedPluginLogo {
readonly bytes: number;
/** Artifact-relative POSIX path written into host manifests that support logo. */
readonly path: string;
readonly source: string;
}

export interface NormalizedMetadata {
readonly description?: string;
readonly id: string;
readonly logo?: NormalizedPluginLogo;
readonly name: string;
/** The validated npm package name derived from the project's package.json. */
readonly packageName?: string;
Expand Down
11 changes: 11 additions & 0 deletions packages/agent-bundle/tests/adapter-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,17 @@ it('records observed capability versions and rehashes schema snapshots against p
expect(schema.sha256).toBe(provenance.schemas[fileName]?.sha256);
expect(schema.revision).toBe(metadata.observedVersion);
}

if (target === 'cursor') {
const pluginSchema = JSON.parse(await readFile(
new URL('../src/adapters/schemas/cursor/plugin.schema.json', import.meta.url),
'utf8',
)) as { readonly properties: { readonly logo?: unknown } };
expect(pluginSchema.properties.logo).toEqual({
description: 'Path to a logo image (relative to the plugin root) or an absolute URL.',
type: 'string',
});
}
}
});

Expand Down
Loading
Loading