Skip to content

Commit 22472ae

Browse files
committed
Warn on malformed data-only manifests and accept Claude metadata
A broken native manifest was treated as missing, so data-only plugins inferred kind with no warning. Claude marketplace metadata is name and description, not a corbits schema, so validating it produced false id and kind warnings on untrusted loads.
1 parent 82a8a5a commit 22472ae

3 files changed

Lines changed: 145 additions & 27 deletions

File tree

‎src/plugins/data-only.ts‎

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { readFile } from "node:fs/promises";
22
import { basename, join } from "node:path";
3-
import { parsePluginManifest, type PluginManifest } from "./manifest.js";
3+
import { type } from "arktype";
4+
import { PluginManifestSchema, type PluginManifest } from "./manifest.js";
45
import type {
56
CommandDefinition,
67
CommandPlugin,
@@ -25,13 +26,45 @@ export interface DataOnlyPlugin {
2526
commandPlugin?: CommandPlugin;
2627
}
2728

28-
async function readManifestJson(dir: string): Promise<PluginManifest | null> {
29+
function isENOENT(err: unknown): boolean {
30+
return (
31+
typeof err === "object" &&
32+
err !== null &&
33+
"code" in err &&
34+
(err as { code?: unknown }).code === "ENOENT"
35+
);
36+
}
37+
38+
function errorText(err: unknown): string {
39+
return err instanceof Error ? err.message : String(err);
40+
}
41+
42+
async function readManifestJson(
43+
dir: string,
44+
onWarning: (msg: string) => void,
45+
): Promise<PluginManifest | null> {
46+
const manifestPath = join(dir, "manifest.json");
47+
let raw: string;
2948
try {
30-
const raw = await readFile(join(dir, "manifest.json"), "utf8");
31-
return parsePluginManifest(JSON.parse(raw));
32-
} catch {
49+
raw = await readFile(manifestPath, "utf8");
50+
} catch (err) {
51+
if (isENOENT(err)) return null;
52+
onWarning(`failed to read ${manifestPath}: ${errorText(err)}`);
53+
return null;
54+
}
55+
let parsed: unknown;
56+
try {
57+
parsed = JSON.parse(raw);
58+
} catch (err) {
59+
onWarning(`failed to parse ${manifestPath}: ${errorText(err)}`);
60+
return null;
61+
}
62+
const result = PluginManifestSchema(parsed);
63+
if (result instanceof type.errors) {
64+
onWarning(`invalid plugin manifest at ${manifestPath}: ${result.summary}`);
3365
return null;
3466
}
67+
return result as PluginManifest;
3568
}
3669

3770
// Claude Code marketplace plugins self-describe via `.claude-plugin/plugin.json`
@@ -106,7 +139,7 @@ export async function loadDataOnlyPlugin(
106139

107140
const [nativeManifest, claudeManifest, agents, commands, skillCmds] =
108141
await Promise.all([
109-
readManifestJson(pluginDir),
142+
readManifestJson(pluginDir, onWarning),
110143
readClaudePluginManifest(pluginDir),
111144
loadDataOnlyAgentPlugin(pluginDir, { cwd, onWarning }),
112145
loadDataOnlyCommands(pluginDir, { onWarning }),

‎src/plugins/loader.test.ts‎

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,7 @@ describe("readManifestJson malformed vs missing", () => {
134134
expect(warnings.some((w) => w.includes(manifestPath))).toBe(true);
135135
expect(
136136
warnings.some(
137-
(w) =>
138-
w.includes(manifestPath) &&
139-
(w.toLowerCase().includes("parse") ||
140-
w.toLowerCase().includes("json")),
137+
(w) => w.includes(manifestPath) && w.includes("failed to parse"),
141138
),
142139
).toBe(true);
143140
});
@@ -180,10 +177,7 @@ describe("readManifestJson malformed vs missing", () => {
180177
expect(diag.warnings.some((w) => w.includes(manifestPath))).toBe(true);
181178
expect(
182179
diag.warnings.some(
183-
(w) =>
184-
w.includes(manifestPath) &&
185-
(w.toLowerCase().includes("parse") ||
186-
w.toLowerCase().includes("json")),
180+
(w) => w.includes(manifestPath) && w.includes("failed to parse"),
187181
),
188182
).toBe(true);
189183
});
@@ -199,4 +193,52 @@ describe("readManifestJson malformed vs missing", () => {
199193
expect(mods).toEqual([]);
200194
expect(diag.warnings).toEqual([]);
201195
});
196+
197+
test("malformed native manifest.json on data-only plugin warns and does not silently infer kind", async () => {
198+
const dir = await makeJsPlugin({
199+
"agents/a.md": "---\nname: a\n---\nbody\n",
200+
"manifest.json": "{not-json",
201+
});
202+
const warnings: string[] = [];
203+
const mod = await loadPluginEntry(dir, {
204+
onWarning: (msg) => warnings.push(msg),
205+
});
206+
const manifestPath = join(dir, "manifest.json");
207+
expect(
208+
warnings.some(
209+
(w) => w.includes(manifestPath) && w.includes("failed to parse"),
210+
),
211+
).toBe(true);
212+
expect(mod).not.toBeNull();
213+
expect(mod?.agentPlugin).toBeDefined();
214+
});
215+
216+
test("Claude-format .claude-plugin/manifest.json does not warn missing id/kind on metadata-only load", async () => {
217+
const dir = await makeJsPlugin({
218+
".claude-plugin/manifest.json": JSON.stringify({
219+
name: "cmo",
220+
description: "Marketing ops",
221+
}),
222+
});
223+
const diag = createPluginLoadDiagnostics();
224+
const cwd = await mkdtemp(join(tmpdir(), "manifest-cwd-"));
225+
const mods = await loadPluginsFromPaths([dir], cwd, {
226+
isPluginTrusted: () => false,
227+
diagnostics: diag,
228+
});
229+
expect(
230+
diag.warnings.some((w) => w.includes("invalid plugin manifest")),
231+
).toBe(false);
232+
expect(
233+
diag.warnings.some(
234+
(w) =>
235+
w.includes(join(dir, ".claude-plugin", "manifest.json")) &&
236+
(w.includes("id") || w.includes("kind")),
237+
),
238+
).toBe(false);
239+
const mod = mods.find((m) => m.manifest?.id === "cmo");
240+
expect(mod?.metadataOnly).toBe(true);
241+
expect(mod?.manifest?.name).toBe("cmo");
242+
expect(mod?.manifest?.description).toBe("Marketing ops");
243+
});
202244
});

‎src/plugins/loader.ts‎

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -93,26 +93,33 @@ function errorText(err: unknown): string {
9393
// their manifest as a JS export (mod.manifest) or a sibling manifest.json file;
9494
// this covers the JSON path so plugins that are pure data + commands work too.
9595
// Missing file stays silent; parse or schema failure warns and still skips.
96-
async function readManifestJson(
97-
dir: string,
96+
async function readJsonFile(
97+
path: string,
9898
onWarning: (msg: string) => void,
99-
): Promise<PluginManifest | null> {
100-
const manifestPath = join(dir, "manifest.json");
99+
): Promise<unknown | undefined> {
101100
let raw: string;
102101
try {
103-
raw = await readFile(manifestPath, "utf8");
102+
raw = await readFile(path, "utf8");
104103
} catch (err) {
105-
if (isENOENT(err)) return null;
106-
onWarning(`failed to read ${manifestPath}: ${errorText(err)}`);
107-
return null;
104+
if (isENOENT(err)) return undefined;
105+
onWarning(`failed to read ${path}: ${errorText(err)}`);
106+
return undefined;
108107
}
109-
let parsed: unknown;
110108
try {
111-
parsed = JSON.parse(raw);
109+
return JSON.parse(raw) as unknown;
112110
} catch (err) {
113-
onWarning(`failed to parse ${manifestPath}: ${errorText(err)}`);
114-
return null;
111+
onWarning(`failed to parse ${path}: ${errorText(err)}`);
112+
return undefined;
115113
}
114+
}
115+
116+
async function readManifestJson(
117+
dir: string,
118+
onWarning: (msg: string) => void,
119+
): Promise<PluginManifest | null> {
120+
const manifestPath = join(dir, "manifest.json");
121+
const parsed = await readJsonFile(manifestPath, onWarning);
122+
if (parsed === undefined) return null;
116123
const result = PluginManifestSchema(parsed);
117124
if (result instanceof type.errors) {
118125
onWarning(`invalid plugin manifest at ${manifestPath}: ${result.summary}`);
@@ -121,6 +128,39 @@ async function readManifestJson(
121128
return result as PluginManifest;
122129
}
123130

131+
// Claude marketplace `.claude-plugin/manifest.json` is `{name, description?}`,
132+
// not a corbits PluginManifest. Parse it without PluginManifestSchema so a
133+
// valid Claude layout does not warn about missing id/kind. Malformed JSON
134+
// still warns. Kind is filled in so the metadata-only module can list.
135+
async function readClaudeFormatManifestJson(
136+
dir: string,
137+
onWarning: (msg: string) => void,
138+
): Promise<PluginManifest | null> {
139+
const manifestPath = join(dir, "manifest.json");
140+
const parsed = await readJsonFile(manifestPath, onWarning);
141+
if (parsed === undefined) return null;
142+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
143+
return null;
144+
}
145+
const obj = parsed as Record<string, unknown>;
146+
const nameRaw =
147+
typeof obj.name === "string" && obj.name.trim().length > 0
148+
? obj.name.trim()
149+
: typeof obj.id === "string" && obj.id.trim().length > 0
150+
? obj.id.trim()
151+
: null;
152+
if (nameRaw === null) return null;
153+
const manifest: PluginManifest = {
154+
id: nameRaw,
155+
name: nameRaw,
156+
kind: "command",
157+
};
158+
if (typeof obj.description === "string") {
159+
manifest.description = obj.description;
160+
}
161+
return manifest;
162+
}
163+
124164
// Safe metadata-only view: never import()s and never loads markdown agents/commands.
125165
async function readPluginMetadataOnly(
126166
entryPath: string,
@@ -138,7 +178,10 @@ async function readPluginMetadataOnly(
138178
const abs = resolve(dir);
139179
const manifest =
140180
(await readManifestJson(abs, onWarning)) ??
141-
(await readManifestJson(join(abs, ".claude-plugin"), onWarning));
181+
(await readClaudeFormatManifestJson(
182+
join(abs, ".claude-plugin"),
183+
onWarning,
184+
));
142185
if (manifest === null) {
143186
return null;
144187
}

0 commit comments

Comments
 (0)