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
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -531,17 +531,27 @@ export default defineConfig({

Partials are project-level (shared across every source plugin, not scoped to one), and may reference other partials — nested composition resolves in one pass, though a circular reference (A includes B includes A) is a build-time error. A tag alone on its own line — the common case — leaves no blank line behind.

Substitution runs on every `.md`/`.mdc`/`.markdown`/`.txt` file pluginpack emits — skills, agents, commands, rules, declared shipping files, and a target's `repositoryFiles` — via the real [`mustache`](https://github.com/janl/mustache.js) library.
Substitution runs on every `.md`/`.mdc`/`.markdown`/`.txt` file pluginpack emits, whatever the case of the extension (`NOTES.MD` counts) — skills, agents, commands, rules, declared shipping files, and a target's `repositoryFiles` — via the real [`mustache`](https://github.com/janl/mustache.js) library.

**A tag that cannot be resolved fails the build.** A `{{> name}}` reference naming a partial that does not exist is an error listing the available partials and the nearest match, rather than rendering as nothing — silently dropping a section out of a shipped skill file is worse than a red build. The same applies to a malformed reference (`{{> }}`), and to a tag inside a partial's own body.
**A tag that cannot be resolved fails the build.** A `{{> name}}` reference naming a partial that does not exist is an error listing the available partials and the nearest match, rather than rendering as nothing — silently dropping a section out of a shipped skill file is worse than a red build. The same applies to a malformed reference (`{{> }}`), to a tag inside a partial's own body, and to a tag that substitution itself would assemble from neighboring text, such as a partial that ends in `{` placed before `{> name}}`.

Substitution keeps the bytes of the file intact:

- A file that contains a partial tag, and every partial, must be valid UTF-8. Otherwise the build fails, instead of replacing the invalid bytes with U+FFFD. A file with no partial tag is copied byte for byte, whatever its encoding.
- A partial's leading byte-order mark is dropped, so it does not appear in the middle of the including file.
- A partial takes the line endings of the file that includes it. pluginpack reads them from that file's first line break, so a CRLF partial in an LF file emits LF, and an LF partial in a CRLF file emits CRLF.

Only partial tags are substituted. Any other `{{...}}`-shaped text — documentation about Handlebars, Jinja, Go templates, or Mustache itself, or a curly-brace code sample — is emitted exactly as authored, including text Mustache could not parse as a template at all. To write a literal partial tag, escape it with a backslash:

```md
Reference a partial by writing \{{> auth}} in a skill file.
```

That emits `{{> auth}}` verbatim. Because substitution does not run on other file types, a tag authored in (say) a `.yaml` reference file or a `.py` script would otherwise ship through untouched; `build` fails on any such tag left in emitted output, and `validate` reports it in an already-generated target repo.
That emits `{{> auth}}` verbatim. A backslash directly before a tag always escapes it, so `\\{{> auth}}` emits `\{{> auth}}`. To emit a literal backslash followed by the partial's content, put the backslash in a partial of its own (for example `partials/backslash.md`, holding only `\` with no trailing newline) and write `{{> backslash}}{{> auth}}`.

Because substitution does not run on other file types, a tag authored in (say) a `.yaml` reference file or a `.py` script would otherwise ship through untouched; `build` fails on any such tag left in emitted output, and `validate` reports it in an already-generated target repo. `validate` does not report tags in `.md`/`.mdc`/`.markdown`/`.txt` files: there, the only tag a build leaves is an escaped one.

For a plugin emitted at the output root (`path: "."`), `validate` does not walk the whole root, because authored sources and dependencies live there too. It checks the files listed in the target's managed manifest (`.pluginpack/<target>.json`) and the files the app reads from the plugin root, such as the plugin manifest and MCP config. Without a managed manifest, it checks the recognized component directories (`skills/`, `scripts/`, and so on) instead.

## Other Shapes

Expand Down
2 changes: 1 addition & 1 deletion src/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export async function validateOutput(
const root = path.resolve(dir);
const issues: ValidationIssue[] = [];
const managed = await readManagedManifest(root, target);
const managedPaths = (managed?.files ?? []).map((file) =>
const managedPaths = managed?.files.map((file) =>
path.resolve(root, normalizeManagedPath(file)),
);
await validateFromDefinition(root, issues, registry[target], managedPaths);
Expand Down
129 changes: 99 additions & 30 deletions src/partials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,21 @@ const ESCAPE = "\\";
/**
* Stands in for a `{{` that is not a partial tag while Mustache renders, so
* Mustache never treats it as a tag of its own. A private-use code point:
* never legitimately present in an authored skill file, and
* `assertNoSentinel` rejects the file outright if it somehow is.
* never legitimately present in an authored skill file, and `prepare`
* rejects the file outright if it somehow is.
*/
const SENTINEL = "";
const SENTINEL = "\uE000";

/**
* Stands in for the `{{` of an escaped `\{{> name}}` tag. It is kept apart
* from `SENTINEL` so that, after rendering, every `{{>` tag still in the text
* is known not to come from an escape (see `substitute`).
*/
const ESCAPED_SENTINEL = "\uE001";

const RESERVED_CODE_POINTS = [SENTINEL, ESCAPED_SENTINEL];

const BYTE_ORDER_MARK = "\uFEFF";

/**
* Loads every file under `partialsDir` into a name -> content map, keyed by
Expand All @@ -53,12 +64,37 @@ export async function loadPartials(
if (partials.has(key)) {
throw new Error(`Duplicate partial name "${key}" (from "${relative}").`);
}
partials.set(key, await fs.readFile(file, "utf8"));
const bytes = await fs.readFile(file);
const text = decodeUtf8(bytes);
if (text === undefined) {
throw new Error(
`Partial "${key}" (${relative}) is not valid UTF-8, so inlining it would replace its invalid bytes with U+FFFD. Re-save it as UTF-8.`,
);
}
partials.set(key, normalizePartial(text));
}
assertNoPartialCycles(partials);
return partials;
}

/**
* A partial is inlined mid-file, so a leading byte-order mark would become a
* stray U+FEFF in the middle of the including file; it is dropped. Line
* endings are stored as LF and converted to the including file's line ending
* at substitution time (see `substitute`), so a CRLF partial does not leave
* mixed line endings in an LF file, or the reverse.
*/
function normalizePartial(text: string): string {
const body = text.startsWith(BYTE_ORDER_MARK) ? text.slice(1) : text;
return body.replaceAll("\r\n", "\n");
}

/** Decodes UTF-8, or returns `undefined` if the bytes do not survive a decode/encode round trip. */
function decodeUtf8(bytes: Uint8Array): string | undefined {
const text = Buffer.from(bytes).toString("utf8");
return Buffer.from(text, "utf8").equals(bytes) ? text : undefined;
}

/**
* Mustache.js has no protection against a circular partial reference (A
* includes B includes A) — its own docs just say "avoid infinite loops."
Expand Down Expand Up @@ -120,8 +156,10 @@ class UnknownPartialError extends Error {
*
* Non-textual paths, and files that never mention a partial tag at all, are
* returned completely untouched (the original `FileValue`, byte-identical) —
* so a file that does not use partials cannot be changed by this at all, and
* pays no lossy UTF-8 round-trip.
* so a file that does not use partials cannot be changed by this at all.
* A file that does use partials must be valid UTF-8: substituting means
* decoding and re-encoding it, which would silently turn every invalid byte
* into U+FFFD, so such a file fails the build instead.
*/
export function resolvePartials(
relativePath: string,
Expand All @@ -135,6 +173,11 @@ export function resolvePartials(
if (!text.includes(PARTIAL_OPENER)) {
return value;
}
if (typeof value !== "string" && decodeUtf8(value) === undefined) {
throw new Error(
`Partial substitution failed in "${relativePath}": the file is not valid UTF-8, so substituting its partial tags would replace the invalid bytes with U+FFFD. Re-save it as UTF-8, or remove the partial tags from it.`,
);
}
try {
return substitute(text, partials);
} catch (error) {
Expand All @@ -145,9 +188,9 @@ export function resolvePartials(
}
}

/** Whether partial substitution runs on files at this path (by extension). */
/** Whether partial substitution runs on files at this path (by extension, ignoring case). */
export function substitutionRunsOn(relativePath: string): boolean {
return TEXTUAL_EXTENSIONS.includes(path.extname(relativePath));
return TEXTUAL_EXTENSIONS.includes(path.extname(relativePath).toLowerCase());
}

/** The extensions substitution runs on, for error messages (`.md, .mdc, ...`). */
Expand All @@ -168,25 +211,48 @@ export function substitutedExtensions(): string {
*
* An empty view (`{}`) is always passed, so no config or environment data is
* ever exposed to interpolation.
*
* Rendering can still assemble a tag out of pieces: a partial ending in `{`
* followed by `{> name}}`, a partial ending in `{{` followed by `> name}}`,
* or an extra `{` in front of a tag (`{{{> name}}}`). None of those pieces is
* a tag on its own, so a tag built from them would ship unsubstituted. After
* rendering, every non-partial `{{` is restored but escaped tags stay behind
* `ESCAPED_SENTINEL`, so any `{{> name}}` left in the text is such an
* assembled tag, and it fails the build.
*
* Partials are stored with LF line endings (`normalizePartial`) and written
* with the including file's line ending, taken from its first line break.
*/
function substitute(text: string, partials: Map<string, string>): string {
const rendered = mustache.render(prepare(text), {}, (name: string) => {
const partial = partials.get(name);
if (partial === undefined) {
throw new UnknownPartialError(name);
}
// A partial's own body goes through Mustache too, so it needs the same
// treatment as the file including it — otherwise a `{{ var }}` written
// inside a partial would still be rendered away.
return prepare(partial);
});
return rendered.replaceAll(SENTINEL, "{{");
const lineEnding = /^[^\n]*\r\n/.test(text) ? "\r\n" : "\n";
const rendered = mustache
.render(prepare(text), {}, (name: string) => {
const partial = partials.get(name);
if (partial === undefined) {
throw new UnknownPartialError(name);
}
// A partial's own body goes through Mustache too, so it needs the same
// treatment as the file including it — otherwise a `{{ var }}` written
// inside a partial would still be rendered away.
return prepare(
lineEnding === "\n" ? partial : partial.replaceAll("\n", lineEnding),
);
})
.replaceAll(SENTINEL, "{{");
const assembled = PARTIAL_TAG.exec(rendered)?.[0];
if (assembled) {
throw new Error(
`substituting partials produced the tag "${assembled}", which would ship unsubstituted. It was built from neighboring text, such as a partial ending in "{" or "{{" followed by the rest of a tag, or an extra "{" before a tag. Separate the pieces, or write \\${assembled} to emit the tag literally.`,
);
}
return rendered.replaceAll(ESCAPED_SENTINEL, "{{");
}

function prepare(text: string): string {
if (text.includes(SENTINEL)) {
const reserved = RESERVED_CODE_POINTS.find((point) => text.includes(point));
if (reserved) {
throw new Error(
"content contains the reserved code point U+E000, which pluginpack uses internally while substituting partials",
`content contains the reserved code point U+${reserved.codePointAt(0)?.toString(16).toUpperCase()}, which pluginpack uses internally while substituting partials`,
);
}
return neutralize(text);
Expand Down Expand Up @@ -221,7 +287,7 @@ function neutralize(text: string): string {
);
}
out += text.slice(index, isPartialTag ? open - ESCAPE.length : open);
out += SENTINEL;
out += isPartialTag ? ESCAPED_SENTINEL : SENTINEL;
index = open + 2;
}
return out;
Expand Down Expand Up @@ -286,14 +352,17 @@ export type UnsubstitutedPartialTag = {
};

/**
* Finds `{{> name}}` tags in emitted files that substitution never ran on.
* Substitution resolves — or rejects — every tag in the file types it covers
* (`TEXTUAL_EXTENSIONS`), so the tags left to find are ones authored in (say)
* a `.yaml` reference file or a `.py` script, which ship through verbatim and
* silently break whatever reads them. Files substitution *did* run on are
* excluded deliberately: a tag there can only be the documented `\{{>`
* escape, which is meant to reach output. Binary files are skipped — a NUL
* byte is the standard "not text" signal.
* Finds `{{> name}}` tags in emitted files that substitution never ran on:
* ones authored in (say) a `.yaml` reference file or a `.py` script, which
* ship through verbatim and silently break whatever reads them.
*
* Files substitution ran on are skipped. That is safe only because
* `substitute` checks its own output: it fails on any tag left after
* rendering that did not come from a `\{{>` escape, including one assembled
* from neighboring text. A file with a `.md`-style extension that reaches
* the artifact without passing through `resolvePartials` is not covered by
* either check. Binary files are skipped — a NUL byte is the standard "not
* text" signal.
*/
export function findUnsubstitutedPartialTags(
files: Iterable<readonly [string, FileValue]>,
Expand Down
20 changes: 17 additions & 3 deletions src/targets/agent-plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
CLIENT_ONLY_CONTENT_KINDS,
PACKAGE_CLIENT_CONTENT_KINDS,
} from "../schema.js";
import { error } from "./validation-shared.js";
import { error, pluginScanPaths } from "./validation-shared.js";
import type { ValidationIssue } from "../types.js";
import type { PluginTargetDefinition } from "./types.js";

Expand All @@ -28,6 +28,18 @@ import type { PluginTargetDefinition } from "./types.js";
*/
const noProfile = {};

/**
* Plugin-relative files and directories a client reads from a package,
* whoever wrote them: the manifest, the MCP config, the hooks file, and each
* client's extension directory.
*/
const NATIVE_FILES = [
packageLayout.manifest,
packageLayout.mcp,
"hooks/hooks.json",
...Object.values(PACKAGE_CLIENTS).map((client) => client.namespace),
];

/**
* Agent Plugins target: one portable package per plugin under
* `plugins/<name>/`. With no `clients`, it writes packages only — no
Expand Down Expand Up @@ -78,7 +90,7 @@ export const agentPlugins: PluginTargetDefinition = {
// Validates every package, then each client's marketplace and extension
// directory. Output carries no config, so a client is validated when its
// marketplace or extension directory is present.
validateOutput: async (root, issues) => {
validateOutput: async (root, issues, context) => {
const pluginDirs = await findPackages(root);
if (pluginDirs.length === 0) {
error(
Expand All @@ -90,7 +102,9 @@ export const agentPlugins: PluginTargetDefinition = {
await validatePackage(pluginDir, path.basename(pluginDir), issues);
await rejectClientOnlyContent(pluginDir, issues);
}
const owned = [...pluginDirs];
const owned = pluginDirs.flatMap((pluginDir) =>
pluginScanPaths(root, pluginDir, NATIVE_FILES, context),
);
for (const client of Object.values(PACKAGE_CLIENTS)) {
owned.push(...(await client.validate(root, pluginDirs, issues)));
}
Expand Down
14 changes: 12 additions & 2 deletions src/targets/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import path from "node:path";
import { stripUndefined } from "./shared.js";
import {
error,
pluginScanPaths,
readJson,
validateBareStringSourceEntry,
validateFrontmatter,
Expand All @@ -12,6 +13,13 @@ import type { PluginTargetDefinition } from "./types.js";

const pluginNamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/;

/** Plugin-relative files Claude Code reads from a plugin, whoever wrote them. */
const NATIVE_FILES = [
".claude-plugin/plugin.json",
".mcp.json",
"hooks/hooks.json",
];

/**
* Claude Code agent-plugin target. `validateManifest`'s required fields
* (`version`, `description`, `author.name`) were verified directly against
Expand Down Expand Up @@ -99,7 +107,7 @@ export const claude: PluginTargetDefinition = {
pluginNamePattern,
),

validateOutput: async (root, issues) => {
validateOutput: async (root, issues, context) => {
const marketplacePath = path.join(
root,
".claude-plugin",
Expand Down Expand Up @@ -133,7 +141,9 @@ export const claude: PluginTargetDefinition = {
continue;
}
const pluginDir = path.join(root, entry.source);
ownedPaths.push(pluginDir);
ownedPaths.push(
...pluginScanPaths(root, pluginDir, NATIVE_FILES, context),
);
const manifest = await readJson(
path.join(pluginDir, ".claude-plugin", "plugin.json"),
`${pluginName} plugin manifest`,
Expand Down
20 changes: 18 additions & 2 deletions src/targets/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { stripUndefined } from "./shared.js";
import {
error,
pathExistsSync,
pluginScanPaths,
readJson,
validateFrontmatter,
validateHooksShape,
Expand All @@ -23,6 +24,19 @@ import type { PluginTargetDefinition } from "./types.js";
const pluginNamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/;
const sourceKinds = new Set(["local", "url", "git-subdir", "npm"]);

/**
* Plugin-relative files Codex reads from a plugin, whoever wrote them, in
* either layout: the legacy `.codex-plugin/` manifest and `.mcp.json`, or
* the Agent Plugins package's `plugin.json` and `mcp.json`.
*/
const NATIVE_FILES = [
".codex-plugin/plugin.json",
".mcp.json",
packageLayout.manifest,
packageLayout.mcp,
"hooks/hooks.json",
];

/**
* Resolves a marketplace entry's local plugin directory, or `null` when the
* entry points somewhere pluginpack's own output doesn't contain (a remote
Expand Down Expand Up @@ -219,7 +233,7 @@ export const codexMarketplace = {
validateManifest: requireName,
validateMarketplaceEntry: validateCodexEntry,

validateOutput: async (root, issues) => {
validateOutput: async (root, issues, context) => {
const marketplacePath = path.join(
root,
".agents",
Expand Down Expand Up @@ -252,7 +266,9 @@ export const codexMarketplace = {
if (!pluginDir) {
continue;
}
ownedPaths.push(pluginDir);
ownedPaths.push(
...pluginScanPaths(root, pluginDir, NATIVE_FILES, context),
);
const manifest = await validateCodexPlugin(pluginDir, pluginName, issues);
if (!manifest) {
continue;
Expand Down
Loading
Loading