@capybara-hq/plugin-tools 0.1.0-rc.7 → 0.1.0-rc.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/check/run.js CHANGED
@@ -10,6 +10,7 @@ import { checkPluginToolKeys } from "./plugin-tool-keys.js";
10
10
  import { checkPluginReleaseMetadata } from "./release-metadata.js";
11
11
  import { printCheck } from "./report.js";
12
12
  import { checkPluginTestImports } from "./test-imports.js";
13
+ import { checkThemes } from "./themes.js";
13
14
  import { checkToolDesign } from "./tool-design.js";
14
15
  /**
15
16
  * Every plugin-scoped check over `scope`, in the order `capybara-plugin check` and core's `pnpm
@@ -34,6 +35,7 @@ export function pluginChecks(scope) {
34
35
  { name: "Plugin pack", run: () => checkPluginPack(plugins) },
35
36
  { name: "Config forms", run: () => checkConfigForms(plugins) },
36
37
  { name: "Org override", run: () => checkOrgOverride(plugins) },
38
+ { name: "Themes", run: () => checkThemes(plugins) },
37
39
  {
38
40
  name: "Tool design",
39
41
  run: () => checkToolDesign([...(scope.toolDesignDirs ?? []), ...plugins]),
@@ -0,0 +1,19 @@
1
+ import { type ParsedTheme } from "@capybara-hq/plugin-sdk";
2
+ import type { Finding } from "./source.js";
3
+ /** The theme paths of a plugin (relative, no leading `./`): `manifest.themes`, else `themes/*.json`. */
4
+ export declare function themePathsOf(dir: string): {
5
+ paths: string[];
6
+ listed: boolean;
7
+ };
8
+ /**
9
+ * The theme checks of a plugin (spec §4-§5), reading `manifest.themes` or `themes/*.json`:
10
+ * - `theme-invalid`: a theme file that is missing, over the size limit, not JSON or rejected by
11
+ * `parseThemeFile`, or more themes than {@link THEME_LIMITS}`.perPlugin`;
12
+ * - `theme-readability`: a theme whose merged scheme fails the readability pairs (`unreadable:`);
13
+ * - `theme-asset`: a font or logo that is missing or invalid (`validateThemeAssets`), and the sum
14
+ * of the distinct asset sizes over all of the plugin's themes above
15
+ * {@link THEME_LIMITS}`.assetsBytes` (the limit is per plugin; the SDK checks one theme).
16
+ */
17
+ export declare function checkThemes(pluginDirs: string[]): Finding[];
18
+ /** The asset paths a parsed theme references (fonts, logo mark and favicon). */
19
+ export declare function assetRefs(theme: ParsedTheme): string[];
@@ -0,0 +1,112 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join, posix } from "node:path";
3
+ import { PLUGIN_MANIFEST_FILE, parseThemeFile, THEME_LIMITS, themeIdOf, validateThemeAssets, } from "@capybara-hq/plugin-sdk";
4
+ const stripDot = (p) => p.replace(/^\.\//, "");
5
+ /** The theme paths of a plugin (relative, no leading `./`): `manifest.themes`, else `themes/*.json`. */
6
+ export function themePathsOf(dir) {
7
+ let listed;
8
+ try {
9
+ const m = JSON.parse(readFileSync(join(dir, PLUGIN_MANIFEST_FILE), "utf8"));
10
+ if (Array.isArray(m.themes)) {
11
+ listed = m.themes.flatMap((t) => (typeof t?.path === "string" ? [stripDot(t.path)] : []));
12
+ }
13
+ }
14
+ catch {
15
+ // a missing or broken manifest is the pack check's business
16
+ }
17
+ if (listed !== undefined)
18
+ return { paths: [...new Set(listed)], listed: true };
19
+ const root = join(dir, "themes");
20
+ const scanned = existsSync(root)
21
+ ? readdirSync(root, { withFileTypes: true })
22
+ .filter((e) => e.isFile() && e.name.endsWith(".json"))
23
+ .map((e) => `themes/${e.name}`)
24
+ .sort()
25
+ : [];
26
+ return { paths: scanned, listed: false };
27
+ }
28
+ /**
29
+ * The theme checks of a plugin (spec §4-§5), reading `manifest.themes` or `themes/*.json`:
30
+ * - `theme-invalid`: a theme file that is missing, over the size limit, not JSON or rejected by
31
+ * `parseThemeFile`, or more themes than {@link THEME_LIMITS}`.perPlugin`;
32
+ * - `theme-readability`: a theme whose merged scheme fails the readability pairs (`unreadable:`);
33
+ * - `theme-asset`: a font or logo that is missing or invalid (`validateThemeAssets`), and the sum
34
+ * of the distinct asset sizes over all of the plugin's themes above
35
+ * {@link THEME_LIMITS}`.assetsBytes` (the limit is per plugin; the SDK checks one theme).
36
+ */
37
+ export function checkThemes(pluginDirs) {
38
+ const findings = [];
39
+ for (const dir of pluginDirs) {
40
+ const { paths } = themePathsOf(dir);
41
+ if (paths.length === 0)
42
+ continue;
43
+ const manifest = join(dir, PLUGIN_MANIFEST_FILE);
44
+ if (paths.length > THEME_LIMITS.perPlugin) {
45
+ findings.push({
46
+ file: manifest,
47
+ line: 1,
48
+ rule: "theme-invalid",
49
+ message: `the plugin has ${paths.length} themes; at most ${THEME_LIMITS.perPlugin} are allowed`,
50
+ });
51
+ }
52
+ const files = new Map();
53
+ const sizes = new Map();
54
+ for (const rel of paths) {
55
+ const file = join(dir, rel);
56
+ const bad = (rule, message) => findings.push({ file, line: 1, rule, message });
57
+ if (!existsSync(file) || !statSync(file).isFile()) {
58
+ bad("theme-invalid", `theme ./${rel} does not exist`);
59
+ continue;
60
+ }
61
+ if (statSync(file).size > THEME_LIMITS.fileBytes) {
62
+ bad("theme-invalid", `theme file is larger than ${THEME_LIMITS.fileBytes} bytes`);
63
+ continue;
64
+ }
65
+ const r = parseThemeFile(themeIdOf(rel), readFileSync(file, "utf8"));
66
+ if ("errors" in r) {
67
+ for (const e of r.errors) {
68
+ bad(e.startsWith("unreadable:") ? "theme-readability" : "theme-invalid", e);
69
+ }
70
+ continue;
71
+ }
72
+ const used = assetRefs(r);
73
+ for (const ref of used) {
74
+ const key = stripDot(ref);
75
+ const p = posix.normalize(key);
76
+ if (files.has(p) || p.startsWith("..") || p.startsWith("/"))
77
+ continue;
78
+ const abs = join(dir, p);
79
+ if (existsSync(abs) && statSync(abs).isFile())
80
+ files.set(p, readFileSync(abs));
81
+ }
82
+ const { errors } = validateThemeAssets(r, files);
83
+ for (const e of errors)
84
+ bad("theme-asset", e);
85
+ for (const ref of used) {
86
+ const bytes = files.get(stripDot(ref));
87
+ if (bytes !== undefined)
88
+ sizes.set(stripDot(ref), bytes.length);
89
+ }
90
+ }
91
+ let total = 0;
92
+ for (const n of sizes.values())
93
+ total += n;
94
+ if (total > THEME_LIMITS.assetsBytes) {
95
+ findings.push({
96
+ file: manifest,
97
+ line: 1,
98
+ rule: "theme-asset",
99
+ message: `the plugin's theme assets are larger than ${THEME_LIMITS.assetsBytes} bytes in total (${total}); the limit covers all of the plugin's themes`,
100
+ });
101
+ }
102
+ }
103
+ return findings;
104
+ }
105
+ /** The asset paths a parsed theme references (fonts, logo mark and favicon). */
106
+ export function assetRefs(theme) {
107
+ const refs = (theme.fonts ?? []).map((f) => f.file);
108
+ for (const ref of [theme.logo?.mark, theme.logo?.favicon])
109
+ if (ref !== undefined)
110
+ refs.push(ref);
111
+ return refs;
112
+ }
@@ -16,7 +16,8 @@ export declare const MARKETPLACE_USAGE: string;
16
16
  * `--check` exits 1 on any finding that is not a warning (`marketplace-missing`,
17
17
  * `marketplace-display-stale`, `marketplace-integrity`, `marketplace-version-decrease`,
18
18
  * `marketplace-invalid`); `--write` writes each plugin's entry (replacing its other versions,
19
- * refreshing a listed entry's summary and displayName) and exits 1 only on `marketplace-invalid`.
19
+ * refreshing a listed entry's summary, displayName and icon) and exits 1 only on
20
+ * `marketplace-invalid`.
20
21
  * Relative paths resolve against `cwd`. Returns the exit code.
21
22
  */
22
23
  export declare function marketplaceCommand(args: string[], cwd: string): Promise<number>;
@@ -22,7 +22,8 @@ export const MARKETPLACE_USAGE = "capybara-plugin marketplace --write|--check [-
22
22
  * `--check` exits 1 on any finding that is not a warning (`marketplace-missing`,
23
23
  * `marketplace-display-stale`, `marketplace-integrity`, `marketplace-version-decrease`,
24
24
  * `marketplace-invalid`); `--write` writes each plugin's entry (replacing its other versions,
25
- * refreshing a listed entry's summary and displayName) and exits 1 only on `marketplace-invalid`.
25
+ * refreshing a listed entry's summary, displayName and icon) and exits 1 only on
26
+ * `marketplace-invalid`.
26
27
  * Relative paths resolve against `cwd`. Returns the exit code.
27
28
  */
28
29
  export async function marketplaceCommand(args, cwd) {
@@ -28,7 +28,7 @@ export interface SyncMarketplaceResult {
28
28
  /** `name@version` of the other entries of the tree's plugins (dropped with `write`). */
29
29
  removed: string[];
30
30
  /**
31
- * `name@version` of listed entries whose `summary` / `displayName` differ from plugin.json
31
+ * `name@version` of listed entries whose `summary` / `displayName` / `icon` differ from plugin.json
32
32
  * (rewritten with `write`; source and integrity never change).
33
33
  */
34
34
  refreshed: string[];
@@ -37,25 +37,33 @@ export interface SyncMarketplaceResult {
37
37
  * `marketplace-integrity` and `marketplace-version-decrease` (with a base, see
38
38
  * {@link SyncMarketplaceOptions.base}) and, without `write`, `marketplace-missing` (one per
39
39
  * plugin whose current version is not listed, or not as its only entry) and
40
- * `marketplace-display-stale` (a listed entry's summary / displayName differ from plugin.json).
40
+ * `marketplace-display-stale` (a listed entry's summary / displayName / icon differ from plugin.json).
41
41
  */
42
42
  findings: Finding[];
43
43
  }
44
+ /**
45
+ * The repository path of a plugin's icon: the plugin.json `icon` (relative to the plugin's
46
+ * directory `dir`) as `./<path from the root>` (an entry's `icon`, read at the marketplace's
47
+ * commit, so it follows every release without naming a tag).
48
+ */
49
+ export declare function repoIconPath(root: string, dir: string, icon: string): string;
44
50
  /**
45
51
  * `capybara-plugin marketplace`: keeps a plugin repository's `marketplace.json` listing one entry
46
52
  * per public plugin, its current version (plugin component management spec §13.2, replacing the
47
53
  * one-entry-per-version rule of the package ecosystem spec §5.2). Each public plugin (not
48
54
  * `"private": true`) not listed at its version is packed reproducibly with {@link packPlugin} and
49
- * gets an entry `{ name, version, summary, displayName?, homepage?, permissions, source: { npm: {
50
- * package, version, integrity } } }` (`summary` from the plugin.json `description`, a string being
51
- * `en`; `displayName` copied from plugin.json; `integrity` the pack's sha512, which the release
55
+ * gets an entry `{ name, version, summary, displayName?, icon?, homepage?, permissions, source: {
56
+ * npm: { package, version, integrity } } }` (`summary` from the plugin.json `description`, a string
57
+ * being `en`; `displayName` copied from plugin.json; `icon` the plugin.json `icon` as a path in the
58
+ * repository, `./<plugin dir>/<icon>`, which a git marketplace reads at its refreshed commit;
59
+ * `integrity` the pack's sha512, which the release
52
60
  * then publishes byte for byte; `permissions` display only). The new entry takes the place of the
53
61
  * plugin's first entry (a version that moved), the plugin's other entries are dropped (also those
54
62
  * of a file written in the older one-entry-per-version form), and a plugin not listed yet is
55
63
  * appended. Entries of plugins not in the tree are kept. A version already listed is not packed or
56
64
  * compared: once published, its bytes are the release gate's business (`release --marketplace`),
57
65
  * so a later source or toolchain change at the same version is not a finding; only its display
58
- * fields (`summary`, `displayName`) are refreshed from the source plugin.json. Every other field
66
+ * fields (`summary`, `displayName`, `icon`) are refreshed from the source plugin.json. Every other field
59
67
  * of a kept entry stays as written, among them `deprecated`, which the maintainer sets by hand on
60
68
  * a published version's entry (not in plugin.json: that would change the packed bytes); a new
61
69
  * version's entry has none, and no rule compares it with the base. With `base`, see
@@ -36,39 +36,60 @@ async function permissionsOf(bytes, hasExtension) {
36
36
  }
37
37
  /** An entry's version: `version`, else its npm source's. */
38
38
  const versionOf = (e) => e.version ?? ("npm" in e.source ? e.source.npm.version : undefined);
39
- /** The display fields of a plugin's source plugin.json; undefined when it does not parse. */
40
- function displayOf(json) {
39
+ /**
40
+ * The repository path of a plugin's icon: the plugin.json `icon` (relative to the plugin's
41
+ * directory `dir`) as `./<path from the root>` (an entry's `icon`, read at the marketplace's
42
+ * commit, so it follows every release without naming a tag).
43
+ */
44
+ export function repoIconPath(root, dir, icon) {
45
+ const rel = relative(root, dir).split("\\").join("/");
46
+ const file = icon.replace(/^\.\//, "");
47
+ return `./${rel === "" ? file : `${rel}/${file}`}`;
48
+ }
49
+ /**
50
+ * The display fields of a plugin's source plugin.json (`dir` its directory under `root`); undefined
51
+ * when it does not parse.
52
+ */
53
+ function displayOf(json, root, dir) {
41
54
  const m = parsePluginManifest(json);
42
55
  if ("errors" in m)
43
56
  return undefined;
44
57
  return {
45
58
  summary: summaryOf(m.description),
46
59
  ...(m.displayName !== undefined && { displayName: m.displayName }),
60
+ ...(m.icon !== undefined && { icon: repoIconPath(root, dir, m.icon) }),
47
61
  };
48
62
  }
63
+ /** The display fields after `summary`, in this order. */
64
+ const AFTER_SUMMARY = ["displayName", "icon"];
49
65
  /**
50
- * `raw` with its `summary` and `displayName` set to `d` (in place, `displayName` right after
51
- * `summary` when it is new, removed when plugin.json has none); undefined when they already equal.
66
+ * `raw` with its `summary`, `displayName` and `icon` set to `d` (in place; a new `displayName` /
67
+ * `icon` right after `summary`, removed when plugin.json has none); undefined when they already
68
+ * equal.
52
69
  */
53
70
  function refreshDisplay(raw, d) {
54
71
  if (!isObj(raw))
55
72
  return undefined;
56
73
  if (canonical(raw.summary) === canonical(d.summary) &&
57
- canonical(raw.displayName) === canonical(d.displayName)) {
74
+ AFTER_SUMMARY.every((k) => canonical(raw[k]) === canonical(d[k]))) {
58
75
  return undefined;
59
76
  }
77
+ const after = (out) => {
78
+ for (const k of AFTER_SUMMARY)
79
+ if (d[k] !== undefined)
80
+ out[k] = d[k];
81
+ };
60
82
  const out = {};
61
83
  for (const [k, v] of Object.entries(raw)) {
62
- if (k === "displayName")
84
+ if (AFTER_SUMMARY.includes(k))
63
85
  continue;
64
86
  out[k] = k === "summary" ? d.summary : v;
65
- if (k === "summary" && d.displayName !== undefined)
66
- out.displayName = d.displayName;
87
+ if (k === "summary")
88
+ after(out);
67
89
  }
68
90
  if (!("summary" in raw)) {
69
91
  out.summary = d.summary;
70
- if (d.displayName !== undefined)
71
- out.displayName = d.displayName;
92
+ after(out);
72
93
  }
73
94
  return out;
74
95
  }
@@ -77,16 +98,18 @@ function refreshDisplay(raw, d) {
77
98
  * per public plugin, its current version (plugin component management spec §13.2, replacing the
78
99
  * one-entry-per-version rule of the package ecosystem spec §5.2). Each public plugin (not
79
100
  * `"private": true`) not listed at its version is packed reproducibly with {@link packPlugin} and
80
- * gets an entry `{ name, version, summary, displayName?, homepage?, permissions, source: { npm: {
81
- * package, version, integrity } } }` (`summary` from the plugin.json `description`, a string being
82
- * `en`; `displayName` copied from plugin.json; `integrity` the pack's sha512, which the release
101
+ * gets an entry `{ name, version, summary, displayName?, icon?, homepage?, permissions, source: {
102
+ * npm: { package, version, integrity } } }` (`summary` from the plugin.json `description`, a string
103
+ * being `en`; `displayName` copied from plugin.json; `icon` the plugin.json `icon` as a path in the
104
+ * repository, `./<plugin dir>/<icon>`, which a git marketplace reads at its refreshed commit;
105
+ * `integrity` the pack's sha512, which the release
83
106
  * then publishes byte for byte; `permissions` display only). The new entry takes the place of the
84
107
  * plugin's first entry (a version that moved), the plugin's other entries are dropped (also those
85
108
  * of a file written in the older one-entry-per-version form), and a plugin not listed yet is
86
109
  * appended. Entries of plugins not in the tree are kept. A version already listed is not packed or
87
110
  * compared: once published, its bytes are the release gate's business (`release --marketplace`),
88
111
  * so a later source or toolchain change at the same version is not a finding; only its display
89
- * fields (`summary`, `displayName`) are refreshed from the source plugin.json. Every other field
112
+ * fields (`summary`, `displayName`, `icon`) are refreshed from the source plugin.json. Every other field
90
113
  * of a kept entry stays as written, among them `deprecated`, which the maintainer sets by hand on
91
114
  * a published version's entry (not in plugin.json: that would change the packed bytes); a new
92
115
  * version's entry has none, and no rule compares it with the base. With `base`, see
@@ -150,7 +173,7 @@ export async function syncMarketplace(opts) {
150
173
  const kept = listed[0];
151
174
  /** Keeps the listed entry: drops the other versions, refreshes its display fields. */
152
175
  const keep = (raw) => {
153
- const display = displayOf(source);
176
+ const display = displayOf(source, opts.root, dir);
154
177
  const fresh = display === undefined ? undefined : refreshDisplay(raw, display);
155
178
  if (dropped.length > 0 || fresh !== undefined) {
156
179
  const entry = fresh ?? raw;
@@ -188,6 +211,9 @@ export async function syncMarketplace(opts) {
188
211
  version: npm.version,
189
212
  summary: summaryOf(r.manifest.description),
190
213
  ...(r.manifest.displayName !== undefined && { displayName: r.manifest.displayName }),
214
+ ...(r.manifest.icon !== undefined && {
215
+ icon: repoIconPath(opts.root, dir, r.manifest.icon),
216
+ }),
191
217
  ...(r.manifest.homepage !== undefined && { homepage: r.manifest.homepage }),
192
218
  permissions: await permissionsOf(bytes, r.manifest.extension !== undefined),
193
219
  source: { npm },
@@ -38,7 +38,7 @@ export declare function importPluginExtension(pluginDir: string): Promise<Extens
38
38
  * 2. when `src/index.ts` exists, bundles the extension ({@link bundleExtension}) and sets
39
39
  * `extension = { entry: "./dist/index.mjs", manifest, migrations?: "./migrations", ui?: "./ui" }`;
40
40
  * a declarative plugin (no `src/index.ts`) runs no esbuild and creates no staging dir;
41
- * 3. ships `skills/**`, `connectors/*.json`, `agents/*.md` and the listed paths ({@link contentFiles}), README,
41
+ * 3. ships `skills/**`, `connectors/*.json`, `agents/*.md`, `themes/*.json` with their assets and the listed paths ({@link contentFiles}), README,
42
42
  * LICENSE, NOTICE and the plain files of package.json `files` ({@link extraFiles});
43
43
  * 4. writes a package.json with only name / version / type / license / {@link METADATA_FIELDS}
44
44
  * and, with an extension, `main` / `exports` (the entry) and peerDependencies (no `capybara`
package/dist/pack/pack.js CHANGED
@@ -7,10 +7,11 @@ import { dirname, join, posix, relative, resolve } from "node:path";
7
7
  import { pathToFileURL } from "node:url";
8
8
  import { promisify } from "node:util";
9
9
  import { gzip } from "node:zlib";
10
- import { AGENT_TEMPLATE_LIMITS, checkEntryImports, defineExtension, HOST_PROVIDED_PACKAGES, PLUGIN_MANIFEST_FILE, PLUGIN_MANIFEST_LIMITS, parseAgentTemplate, parseConnectorTemplateFile, parsePluginManifest, SDK_VERSION, staticManifestOf, validatePluginPackageJsonFor, } from "@capybara-hq/plugin-sdk";
10
+ import { AGENT_TEMPLATE_LIMITS, checkEntryImports, defineExtension, HOST_PROVIDED_PACKAGES, PLUGIN_MANIFEST_FILE, PLUGIN_MANIFEST_LIMITS, parseAgentTemplate, parseConnectorTemplateFile, parsePluginManifest, parseThemeFile, SDK_VERSION, setupGuideFileProblem, staticManifestOf, THEME_LIMITS, themeIdOf, unknownSetupGuidePlaceholders, validatePluginPackageJsonFor, validateThemeAssets, } from "@capybara-hq/plugin-sdk";
11
11
  import { init as lexerInit, parse as lexerParse } from "es-module-lexer";
12
12
  import { build } from "esbuild";
13
13
  import { pack as tarPack } from "tar-stream";
14
+ import { assetRefs, themePathsOf } from "../check/themes.js";
14
15
  import { extensionConfigSchemas, formatConfigSchemaProblem } from "./config-schemas.js";
15
16
  /** The extension entry the packer writes (relative to the package root). */
16
17
  const EXTENSION_ENTRY = "dist/index.mjs";
@@ -167,6 +168,8 @@ const SHIPPED_ELSEWHERE = [
167
168
  "skills",
168
169
  "connectors",
169
170
  "agents",
171
+ // setup guide templates: the listed files are shipped by contentFiles
172
+ "guides",
170
173
  ".capybara-plugin",
171
174
  "README.md",
172
175
  "LICENSE",
@@ -357,11 +360,75 @@ async function contentFiles(dir, label, m) {
357
360
  }
358
361
  agentNames.set(t.template.name, rel);
359
362
  }
363
+ // Themes: listed, else scanned from themes/*.json; each is parsed, its assets are checked and
364
+ // shipped (spec §4-§5), so a bad theme fails the pack before import
365
+ const { paths: themePaths } = themePathsOf(dir);
366
+ if (themePaths.length > THEME_LIMITS.perPlugin) {
367
+ throw new Error(`${label}: ${themePaths.length} themes; a plugin may have at most ${THEME_LIMITS.perPlugin}`);
368
+ }
369
+ const themeFiles = new Map();
370
+ const assetSizes = new Map();
371
+ for (const rel of themePaths) {
372
+ await addFile(rel, "theme");
373
+ const t = parseThemeFile(themeIdOf(rel), files.get(rel).toString("utf8"));
374
+ if ("errors" in t)
375
+ throw new Error(`${label}: ${rel}: ${t.errors.join("; ")}`);
376
+ for (const ref of assetRefs(t)) {
377
+ const key = stripDot(ref);
378
+ if (themeFiles.has(key))
379
+ continue;
380
+ const abs = join(dir, key);
381
+ if (!existsSync(abs) || !(await stat(abs)).isFile())
382
+ continue;
383
+ themeFiles.set(key, await readFile(abs));
384
+ }
385
+ const checked = validateThemeAssets(t, themeFiles);
386
+ if (checked.errors.length > 0) {
387
+ throw new Error(`${label}: ${rel}: ${checked.errors.join("; ")}`);
388
+ }
389
+ for (const key of checked.assets.keys()) {
390
+ files.set(key, Buffer.from(themeFiles.get(key)));
391
+ assetSizes.set(key, themeFiles.get(key).length);
392
+ }
393
+ }
394
+ let assetTotal = 0;
395
+ for (const n of assetSizes.values())
396
+ assetTotal += n;
397
+ if (assetTotal > THEME_LIMITS.assetsBytes) {
398
+ throw new Error(`${label}: theme assets are larger than ${THEME_LIMITS.assetsBytes} bytes in total (${assetTotal})`);
399
+ }
400
+ // Setup guides (spec 2026-11-08-plugin-setup-guides-design.md §2): the listed template files,
401
+ // text within the size limit, and only placeholders the place defines (the kernel leaves an
402
+ // unknown one visible; the toolchain refuses it so a typo never ships)
403
+ for (const g of m.setupGuides ?? []) {
404
+ const at = `${label}: setupGuides ${g.id}`;
405
+ const unknown = (text, where) => {
406
+ const names = unknownSetupGuidePlaceholders(text, g.place);
407
+ if (names.length > 0) {
408
+ throw new Error(`${at}: ${where} uses ${names.map((n) => `{{${n}}}`).join(", ")}, which place ${g.place} does not define`);
409
+ }
410
+ };
411
+ g.steps?.forEach((step, i) => {
412
+ unknown(step.en, `step ${i + 1}`);
413
+ if (step.zh !== undefined)
414
+ unknown(step.zh, `step ${i + 1} (zh)`);
415
+ });
416
+ for (const f of g.files ?? []) {
417
+ const rel = stripDot(f.path);
418
+ await addFile(rel, `setup guide ${g.id} file`);
419
+ const data = files.get(rel);
420
+ const problem = setupGuideFileProblem(f.path, new Uint8Array(data));
421
+ if (problem !== undefined)
422
+ throw new Error(`${at}: ${problem}`);
423
+ unknown(data.toString("utf8"), f.path);
424
+ }
425
+ }
360
426
  return {
361
427
  files,
362
428
  skills: m.skills?.length ?? scannedSkills,
363
429
  templates: m.connectorTemplates?.length ?? templates.length,
364
430
  agents: agents.length,
431
+ themes: themePaths.length,
365
432
  };
366
433
  }
367
434
  /**
@@ -485,7 +552,7 @@ async function bundleExtension(dir, label, pkgVersion) {
485
552
  * 2. when `src/index.ts` exists, bundles the extension ({@link bundleExtension}) and sets
486
553
  * `extension = { entry: "./dist/index.mjs", manifest, migrations?: "./migrations", ui?: "./ui" }`;
487
554
  * a declarative plugin (no `src/index.ts`) runs no esbuild and creates no staging dir;
488
- * 3. ships `skills/**`, `connectors/*.json`, `agents/*.md` and the listed paths ({@link contentFiles}), README,
555
+ * 3. ships `skills/**`, `connectors/*.json`, `agents/*.md`, `themes/*.json` with their assets and the listed paths ({@link contentFiles}), README,
489
556
  * LICENSE, NOTICE and the plain files of package.json `files` ({@link extraFiles});
490
557
  * 4. writes a package.json with only name / version / type / license / {@link METADATA_FIELDS}
491
558
  * and, with an extension, `main` / `exports` (the entry) and peerDependencies (no `capybara`
@@ -501,10 +568,17 @@ export async function packPlugin(pluginDir, outDir) {
501
568
  const source = await readSourceManifest(dir, label);
502
569
  const content = await contentFiles(dir, label, source);
503
570
  const hasExtension = existsSync(join(dir, "src/index.ts"));
504
- if (!hasExtension && content.skills === 0 && content.templates === 0 && content.agents === 0) {
505
- throw new Error(`${label}: a plugin must contain an extension, a skill, a connector template or an agent template (src/index.ts, skills/<name>/SKILL.md, connectors/<id>.json or agents/<name>.md)`);
571
+ if (!hasExtension &&
572
+ content.skills === 0 &&
573
+ content.templates === 0 &&
574
+ content.agents === 0 &&
575
+ content.themes === 0) {
576
+ throw new Error(`${label}: a plugin must contain an extension, a skill, a connector template, an agent template or a theme (src/index.ts, skills/<name>/SKILL.md, connectors/<id>.json, agents/<name>.md or themes/<id>.json)`);
506
577
  }
507
578
  const ext = hasExtension ? await bundleExtension(dir, label, srcPkg.version) : undefined;
579
+ if (ext === undefined && source.setupGuides?.some((g) => g.place === "channel-connection")) {
580
+ throw new Error(`${label}: a channel-connection setup guide needs the plugin's extension to provide channel@1 (src/index.ts)`);
581
+ }
508
582
  const files = new Map([...(ext?.files ?? []), ...content.files]);
509
583
  const extra = await extraFiles(dir, srcPkg.files);
510
584
  for (const [path, data] of extra.files)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capybara-hq/plugin-tools",
3
- "version": "0.1.0-rc.7",
3
+ "version": "0.1.0-rc.9",
4
4
  "description": "Capybara plugin toolchain: the capybara-plugin CLI to pack, check and release Capybara plugins",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -51,12 +51,12 @@
51
51
  "esbuild": "0.28.2",
52
52
  "oxc-parser": "^0.154.0",
53
53
  "tar-stream": "3.2.2",
54
- "@capybara-hq/plugin-sdk": "^0.1.0-rc.6",
55
- "@capybara-hq/kernel": "^0.1.0-rc.9"
54
+ "@capybara-hq/kernel": "^0.1.0-rc.11",
55
+ "@capybara-hq/plugin-sdk": "^0.1.0-rc.8"
56
56
  },
57
57
  "devDependencies": {
58
58
  "zod": "^4.6.7",
59
- "@capybara-hq/testing": "0.1.0-rc.9"
59
+ "@capybara-hq/testing": "0.1.0-rc.11"
60
60
  },
61
61
  "scripts": {
62
62
  "build": "tsc -b"