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
13 changes: 9 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,15 @@ diff, prune, and validate all derive from it.
`targets/<name>/` override resolution) and `resolveMcpServers`.
- `src/partials.ts` — `loadPartials`/`resolvePartials`: project-level
`{{> name}}` text-reuse, wired into `collectPluginFiles` and
`withRootFiles`. Thin wrapper around the real `mustache` library (view is
always `{}` — no config/env data is ever exposed; this is not a general
templating hook), plus one custom check `mustache` doesn't provide
(circular partial reference detection at load time).
`withRootFiles`. Substitution is real `mustache` rendering (view is always
`{}` — no config/env data is ever exposed; this is not a general templating
hook), but only ever sees partial tags: every other `{{` is swapped for a
sentinel first and restored after, so text that merely looks like a template
survives byte-for-byte. An unresolvable tag is a build error, not an empty
string. Also holds two checks `mustache` doesn't provide — circular partial
reference detection at load time, and `findUnsubstitutedPartialTags`, the
guard for tags in file types substitution skips (called from `build()` and,
reading from disk, from `validate`).
- `src/targets/registry.ts` — `targets: Record<TargetName, PluginTargetDefinition>`,
one file per target (`src/targets/<name>.ts`). Everything that varies by
target — default components, manifest/marketplace builders, output paths,
Expand Down
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,11 +383,19 @@ 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 `{{> name}}` tag with no matching partial renders as nothing rather than failing the build (see Known limitation below), and a tag alone on its own line — the common case — leaves no blank line behind when it resolves to nothing.
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, `additionalFiles`, and a target's `rootFiles` — via the real [`mustache`](https://github.com/janl/mustache.js) library.

**Known limitation:** because substitution is real Mustache rendering, any _other_ `{{...}}`-looking text in the same file — documentation about Handlebars, Angular, Go templates, Jinja, or Mustache itself, or a curly-brace code sample — is also processed against an empty context and typically disappears. A skill that needs to show literal double-curly-brace syntax has to work around it, e.g. by splitting the braces across adjacent inline-code spans (`{{` + `}}`) rather than writing them as one contiguous run.
**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.

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.

## Other Shapes

Expand Down
27 changes: 0 additions & 27 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

55 changes: 43 additions & 12 deletions src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import {
writeManagedManifest,
} from "./managed.js";
import { emitTarget, targetNames } from "./adapters.js";
import {
findUnsubstitutedPartialTags,
substitutedExtensions,
} from "./partials.js";
import type {
Artifact,
BuildOptions,
Expand All @@ -33,6 +37,7 @@ export async function build(options: BuildOptions = {}): Promise<Artifact[]> {
}
const owner = assertNoCrossTargetCollisions(artifacts);
await assertNoCollisionsWithBuiltTargets(project, targets, owner);
assertNoUnsubstitutedPartialTags(artifacts);
if (!options.dryRun) {
// Write every target's new files before pruning any target's stale ones.
// If a later target's write throws, no target has had files pruned yet —
Expand All @@ -51,10 +56,34 @@ export async function build(options: BuildOptions = {}): Promise<Artifact[]> {
return artifacts;
}

// Two targets pointed at overlapping output paths would silently overwrite each
// other (and one target's prune could delete the other's files). Catch it.
// Returns the absolute-path -> owning-target map so
// `assertNoCollisionsWithBuiltTargets` can reuse it.
/**
* A `{{> name}}` tag that reaches output is broken content: whatever reads the
* file gets a template marker instead of the text it was meant to inline.
* Substitution resolves (or rejects) every tag in the file types it runs on, so
* what is left is a tag authored somewhere it never ran — a reference file, a
* script, a data file. Fail rather than ship it.
*/
function assertNoUnsubstitutedPartialTags(artifacts: Artifact[]): void {
const found = artifacts.flatMap((artifact) =>
findUnsubstitutedPartialTags(artifact.files).map(
(tag) => ` ${artifact.target}: ${tag.path} contains ${tag.tag}`,
),
);
if (found.length > 0) {
throw new Error(
`Unsubstituted partial references in emitted output:\n${found.join("\n")}\n` +
`Partial substitution only runs on ${substitutedExtensions()} files. ` +
`Move the shared text into one of those, or inline it here instead.`,
);
}
}

/**
* Two targets pointed at overlapping output paths would silently overwrite each
* other (and one target's prune could delete the other's files). Catch it.
* Returns the absolute-path -> owning-target map so
* `assertNoCollisionsWithBuiltTargets` can reuse it.
*/
function assertNoCrossTargetCollisions(
artifacts: Artifact[],
): Map<string, TargetName> {
Expand All @@ -79,14 +108,16 @@ function assertNoCrossTargetCollisions(
return owner;
}

// assertNoCrossTargetCollisions only sees artifacts built in *this*
// invocation. Running `pluginpack build --target X` after an earlier
// `pluginpack build --target Y` wrote overlapping paths would otherwise slip
// through — X's build would silently overwrite Y's files, and a later
// `clean --target Y` would then delete what are now X's live files. Guard
// against that by also checking incoming paths against every other
// *configured* target's on-disk managed manifest, not just artifacts present
// in the current invocation.
/**
* `assertNoCrossTargetCollisions` only sees artifacts built in *this*
* invocation. Running `pluginpack build --target X` after an earlier
* `pluginpack build --target Y` wrote overlapping paths would otherwise slip
* through — X's build would silently overwrite Y's files, and a later
* `clean --target Y` would then delete what are now X's live files. Guard
* against that by also checking incoming paths against every other
* *configured* target's on-disk managed manifest, not just artifacts present
* in the current invocation.
*/
async function assertNoCollisionsWithBuiltTargets(
project: ResolvedProject,
targets: TargetName[],
Expand Down
8 changes: 5 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,11 @@ async function discoverSourcePlugins(
return plugins;
}

// A source plugin dir declares a manifest or has at least one component dir.
// This keeps generated target output (e.g. plugins/cursor/ in a single-repo
// layout) from being misread as source on rebuild.
/**
* A source plugin dir declares a manifest or has at least one component dir.
* This keeps generated target output (e.g. plugins/cursor/ in a single-repo
* layout) from being misread as source on rebuild.
*/
async function isSourcePluginDir(dir: string): Promise<boolean> {
if (await exists(path.join(dir, "plugin.pluginpack.json"))) {
return true;
Expand Down
Loading