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
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ diff, prune, and validate all derive from it.
- `src/cli.ts` — commander CLI: `init`, `build`, `validate`, `diff`, `prune`,
`clean`, `docs`.
- `src/schema.ts` — **zod schemas are the source of truth for config types**;
the public types are derived with `z.infer`. Edit schemas here, not `types.ts`.
authoring types use `z.input`, and parsed config uses `z.infer`. The build
materializes these aliases as structural declarations without a Zod type
dependency. Edit schemas here, not `types.ts`.
- `src/types.ts` — non-config types; re-exports the config types from `schema.ts`.
- `src/components.ts` — `componentDirs` + `staticFiles` (shared by render/config).
- `src/config.ts` — `loadConfig` (jiti loads `pluginpack.config.ts`) and legacy
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,8 @@ rest of the artifact.

`pluginpack.config.ts` exports a config object (wrap it in `defineConfig` for types). `src/schema.ts` is the source of truth; this table enumerates every field. Paths marked _safe relative_ reject absolute paths and `..` escapes.

Keys stripped by config parsing produce a warning that names the config file and the dotted field path, once per file and key per process. Unknown keys in `plugin.pluginpack.json` (both canonical and legacy sources) and `mcp/pluginpack.json` produce manifest warnings. Fix the spelling or remove the key. The `manifest` and `entry` passthrough objects preserve arbitrary keys without warnings. Unknown package client names still fail validation.

**Top level**

| Field | Type | Required | Meaning |
Expand Down Expand Up @@ -727,8 +729,11 @@ Option objects:

The result and config types (`Artifact`, `DiffResult`/`DiffEntry`, `ValidationResult`/`ValidationIssue`, `CleanupResult`/`CleanupEntry`, `ResolvedProject`, `PluginpackConfig`, `TargetConfig`, `TargetName`, `Citation`, `InstallSnippet`, …) are all exported for use in TypeScript.

`PluginpackConfig` describes authoring input. `defineConfig` is an identity helper; it does not parse or apply defaults. `loadConfig` returns `ResolvedProject.config` as `ResolvedPluginpackConfig`, the validated schema output. The declaration build derives structural types from those same schemas, so consumers do not need Zod types or ambient Node types. `Artifact.files` values are `string | Uint8Array`, including Node buffers. Decode byte values with `Buffer.from(value).toString("utf8")` or `new TextDecoder().decode(value)`.

A few things worth knowing about this surface before depending on it:

- **Source-discovery state is unstable.** `ResolvedProject.sourceRoot`, `plugins`, `partials`, and `source` expose internal build state. They are not supported extension points, and their shapes may change.
- **The target set is closed.** `TargetName` is `"claude" | "cursor" | "antigravity" | "copilot" | "codex" | "agent-plugins"` today, with no public API for registering another target — adding one means a PR to this repo (see `PluginTargetDefinition` in `src/targets/types.ts`, not exported). There is no supported third-party target-extension mechanism.
- **The package is ESM-only.** `package.json`'s `exports` map has no `require` condition; a CommonJS consumer needs dynamic `import()`. This is a deliberate choice, not a tsup default left unexamined.
- **`Artifact.files` and `ResolvedProject.plugins` are `Map`s, not plain objects.** `JSON.stringify()` on either silently produces `{}` — iterate with `for...of`/`Object.fromEntries()` instead of serializing directly if you need to log or transport a result.
Expand Down
2 changes: 1 addition & 1 deletion REVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Rules for reviewing changes in this repository. People and review agents both fo

## Always check

- **Schemas are the source of truth.** Config types come from the zod schemas in `src/schema.ts` through `z.infer`. A config change edits the schema, not `src/types.ts`.
- **Schemas are the source of truth.** Authoring config types come from the zod schemas in `src/schema.ts` through `z.input`. Parsed config types use `z.infer` (`ResolvedPluginpackConfig`). A config change edits the schema, not `src/types.ts`. The declaration build materializes these aliases as structural types; do not maintain a separate handwritten config shape.
- **Targets come from evidence.** A new target, or a change to a target's layout or manifest, cites the host's official docs or a real plugin it was checked against. Guessed formats are a blocker.
- **The artifact is the seam.** Build, dry run, diff, prune and validate all derive from the in-memory `Artifact`. A change that writes to disk outside it, or makes one of those commands disagree with another, is a blocker.
- **Destructive paths stay guarded.** `prune` and `clean` remove only managed files. A change near them needs a test that proves unmanaged and source files survive.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
],
"scripts": {
"audit": "npm audit --omit=dev",
"build": "tsup",
"build": "tsup && node scripts/materialize-config-types.mjs",
"check": "npm run test:all",
"dev": "tsx src/cli.ts",
"docs": "npm run docs:check",
Expand Down
193 changes: 193 additions & 0 deletions scripts/materialize-config-types.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import ts from "typescript";

// Keep the schemas as the only config model. Ask the TypeScript checker for
// their evaluated input/output types instead of shipping Zod's implementation
// types (or maintaining a second, handwritten set of config interfaces).
const configPath = path.resolve("tsconfig.json");
const config = ts.readConfigFile(configPath, ts.sys.readFile);
if (config.error) {
throw new Error(
ts.flattenDiagnosticMessageText(config.error.messageText, "\n"),
);
}
const parsed = ts.parseJsonConfigFileContent(
config.config,
ts.sys,
process.cwd(),
);
const schemaPath = path.resolve("src/schema.ts");
const program = ts.createProgram([schemaPath], parsed.options);
const checker = program.getTypeChecker();
const schema = program.getSourceFile(schemaPath);
if (!schema) throw new Error(`Cannot read schema types from ${schemaPath}`);

const aliases = new Map();
const schemaValues = new Set();
for (const node of schema.statements) {
if (ts.isTypeAliasDeclaration(node) && schemaDerivation(node.type)) {
const type = checker.getTypeAtLocation(node);
const structuralType = checker.typeToTypeNode(
type,
undefined,
ts.NodeBuilderFlags.NoTruncation | ts.NodeBuilderFlags.InTypeAlias,
);
if (!structuralType) {
throw new Error(
`Cannot materialize schema-derived type ${node.name.text}`,
);
}
aliases.set(node.name.text, {
structuralType,
derivation: schemaDerivation(node.type),
});
} else if (ts.isVariableStatement(node)) {
for (const declaration of node.declarationList.declarations) {
if (ts.isIdentifier(declaration.name))
schemaValues.add(declaration.name.text);
}
}
}

const declarationPath = path.resolve("dist/index.d.ts");
const declaration = ts.createSourceFile(
declarationPath,
await readFile(declarationPath, "utf8"),
ts.ScriptTarget.Latest,
true,
);
const printer = ts.createPrinter();
const namedStructures = new Map();
const statements = declaration.statements.flatMap((node) => {
if (ts.isImportDeclaration(node) && node.moduleSpecifier.text === "zod") {
return [];
}
if (
ts.isVariableStatement(node) &&
node.declarationList.declarations.every(
(item) => ts.isIdentifier(item.name) && schemaValues.has(item.name.text),
)
) {
// These schema values were reachable only through the aliases below.
return [];
}
if (ts.isTypeAliasDeclaration(node) && aliases.has(node.name.text)) {
const alias = aliases.get(node.name.text);
const derivation = schemaDerivation(node.type);
if (
!derivation ||
derivation.mode !== alias.derivation.mode ||
derivation.schema !== alias.derivation.schema
) {
throw new Error(
`${declarationPath}: ${node.name.text} collides with a schema-derived alias. Rename the conflicting type or update declaration generation before publishing.`,
);
}
const transformed = ts.transform(alias.structuralType, [
(context) => {
const visit = (child) => {
if (ts.isTypeLiteralNode(child)) {
const name = namedStructures.get(structureKey(child));
if (name) return ts.factory.createTypeReferenceNode(name);
const visited = ts.visitEachChild(child, visit, context);
// Checker-created literals carry single-line emit flags. Fresh
// literals use the printer's readable, multiline form instead.
return ts.factory.createTypeLiteralNode(visited.members);
}
return ts.visitEachChild(child, visit, context);
};
return (root) => ts.visitNode(root, visit);
},
]);
const materialized = transformed.transformed[0];
transformed.dispose();
// Only reference aliases already emitted, so generated references cannot
// create cycles or name a schema alias absent from the public bundle.
if (ts.isTypeLiteralNode(alias.structuralType)) {
const key = structureKey(alias.structuralType);
if (!namedStructures.has(key)) namedStructures.set(key, node.name.text);
}
return [
ts.factory.updateTypeAliasDeclaration(
node,
node.modifiers,
node.name,
node.typeParameters,
materialized,
),
];
}
if (
ts.isExportDeclaration(node) &&
node.exportClause &&
ts.isNamedExports(node.exportClause)
) {
for (const exported of node.exportClause.elements) {
if (schemaValues.has((exported.propertyName ?? exported.name).text)) {
throw new Error(
"Schema values are not public API. Update declaration generation before exporting one.",
);
}
}
}
return [node];
});
const result = printer.printFile(
ts.factory.updateSourceFile(declaration, statements),
);
if (/\bzod\b|\bz\.(?:core|input|infer|output)\b/.test(result)) {
throw new Error(
`${declarationPath} still references Zod; update schema type materialization before publishing.`,
);
}
// Validate the exact publishable file, not the source program with ambient
// Node types and skipLibCheck enabled. A textual Zod guard cannot catch an
// unqualified implementation helper such as $brand.
const declarationOptions = {
...parsed.options,
types: [],
skipLibCheck: false,
noEmit: true,
};
const host = ts.createCompilerHost(declarationOptions);
const read = host.readFile.bind(host);
host.readFile = (file) => (file === declarationPath ? result : read(file));
const declarationProgram = ts.createProgram(
[declarationPath],
declarationOptions,
host,
);
const diagnostics = ts.getPreEmitDiagnostics(declarationProgram);
if (diagnostics.length > 0) {
throw new Error(
`Invalid declarations in ${declarationPath}; update schema type materialization before publishing.\n` +
ts.formatDiagnostics(diagnostics, {
getCanonicalFileName: (file) => file,
getCurrentDirectory: () => process.cwd(),
getNewLine: () => "\n",
}),
);
}
await writeFile(declarationPath, result);

function structureKey(node) {
return printer.printNode(ts.EmitHint.Unspecified, node, schema);
}

/** Only aliases directly derived from a schema are eligible for replacement. */
function schemaDerivation(node) {
if (
!ts.isTypeReferenceNode(node) ||
!ts.isQualifiedName(node.typeName) ||
!ts.isIdentifier(node.typeName.left) ||
node.typeName.left.text !== "z" ||
!["input", "infer", "output"].includes(node.typeName.right.text) ||
node.typeArguments?.length !== 1
)
return undefined;
const argument = node.typeArguments[0];
if (!ts.isTypeQueryNode(argument) || !ts.isIdentifier(argument.exprName))
return undefined;
return { mode: node.typeName.right.text, schema: argument.exprName.text };
}
36 changes: 36 additions & 0 deletions src/config-diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Repeated project loads in prune/diff must not repeat warnings. Track the
// file and field, not module identity: config loaders may disable their cache.
const warnedKeys = new Set<string>();

export function warnOnStrippedKeys(
input: unknown,
output: unknown,
filePath: string,
kind: "config" | "manifest" = "config",
): void {
for (const key of strippedKeys(input, output)) {
const diagnostic = JSON.stringify([filePath, kind, key]);
if (warnedKeys.has(diagnostic)) continue;
warnedKeys.add(diagnostic);
console.warn(
`Unknown ${kind} key "${key}" in ${filePath} was ignored. Remove it or use a supported ${kind} field.`,
);
}
}

/** Compare parsed data, not a second schema: record passthroughs retain keys. */
function strippedKeys(input: unknown, output: unknown, prefix = ""): string[] {
if (input === output || !isObject(input) || !isObject(output)) {
return [];
}
return Object.keys(input).flatMap((key) => {
const dotted = prefix ? `${prefix}.${key}` : key;
return Object.hasOwn(output, key)
? strippedKeys(input[key], output[key], dotted)
: [dotted];
});
}

function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
23 changes: 12 additions & 11 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { pathToFileURL } from "node:url";
import { createJiti } from "jiti";
import { z } from "zod";
import { componentDirs } from "./components.js";
import { warnOnStrippedKeys } from "./config-diagnostics.js";
import { exists, isInside, physicalKey } from "./fs.js";
import { loadPartials } from "./partials.js";
import { configSchema, sourcePluginManifestSchema } from "./schema.js";
import { createFilesystemSourceProvider } from "./source.js";
import type {
ResolvedProject,
PluginpackConfig,
ResolvedPluginpackConfig,
ResolvedProjectConfig,
SourcePlugin,
SourcePluginManifest,
Expand Down Expand Up @@ -49,7 +51,7 @@ export async function loadConfig(
*/
async function loadPartialsIfConfigured(
rootDir: string,
config: PluginpackConfig,
config: ResolvedPluginpackConfig,
): Promise<Map<string, string>> {
if (!config.source?.partials) {
return new Map();
Expand Down Expand Up @@ -77,11 +79,7 @@ export async function loadProjectConfig(
moduleCache: false,
});
const loaded = await jiti.import(resolvedConfigPath, { default: true });
const config = parseWithContext(
configSchema,
loaded,
resolvedConfigPath,
) as PluginpackConfig;
const config = parseWithContext(configSchema, loaded, resolvedConfigPath);
const rootDir = path.dirname(resolvedConfigPath);
return {
rootDir,
Expand All @@ -92,7 +90,7 @@ export async function loadProjectConfig(

async function addRootSkillsPlugin(
rootDir: string,
config: PluginpackConfig,
config: ResolvedPluginpackConfig,
plugins: Map<string, SourcePlugin>,
): Promise<void> {
if (!config.source?.skills) {
Expand Down Expand Up @@ -400,6 +398,7 @@ async function readSourceManifest(
sourcePluginManifestSchema,
JSON.parse(raw),
filePath,
"manifest",
) as SourcePluginManifest;
} catch (error) {
if (error instanceof SyntaxError) {
Expand All @@ -411,17 +410,19 @@ async function readSourceManifest(
}
}

function parseWithContext(
schema: z.ZodType,
function parseWithContext<S extends z.ZodType>(
schema: S,
value: unknown,
context: string,
): unknown {
kind: "config" | "manifest" = "config",
): z.output<S> {
const parsed = schema.safeParse(value);
if (parsed.success) {
warnOnStrippedKeys(value, parsed.data, context, kind);
return parsed.data;
}
const details = parsed.error.issues
.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
.join("; ");
throw new Error(`Invalid pluginpack config in ${context}: ${details}`);
throw new Error(`Invalid pluginpack ${kind} in ${context}: ${details}`);
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type {
FileValue,
Metadata,
PluginpackConfig,
ResolvedPluginpackConfig,
ResolvedProject,
SourcePlugin,
TargetConfig,
Expand Down
6 changes: 4 additions & 2 deletions src/partials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,8 @@ export function resolvePartials(
if (!substitutionRunsOn(relativePath)) {
return value;
}
const text = typeof value === "string" ? value : value.toString("utf8");
const text =
typeof value === "string" ? value : Buffer.from(value).toString("utf8");
if (!text.includes(PARTIAL_OPENER)) {
return value;
}
Expand Down Expand Up @@ -385,6 +386,7 @@ export function findPartialTag(value: FileValue): string | undefined {
if (typeof value !== "string" && value.includes(0)) {
return undefined;
}
const text = typeof value === "string" ? value : value.toString("utf8");
const text =
typeof value === "string" ? value : Buffer.from(value).toString("utf8");
return PARTIAL_TAG.exec(text)?.[0];
}
Loading
Loading