@capybara-hq/plugin-tools 0.1.0-rc.8 → 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
+ }
@@ -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, setupGuideFileProblem, staticManifestOf, unknownSetupGuidePlaceholders, 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";
@@ -359,6 +360,43 @@ async function contentFiles(dir, label, m) {
359
360
  }
360
361
  agentNames.set(t.template.name, rel);
361
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
+ }
362
400
  // Setup guides (spec 2026-11-08-plugin-setup-guides-design.md §2): the listed template files,
363
401
  // text within the size limit, and only placeholders the place defines (the kernel leaves an
364
402
  // unknown one visible; the toolchain refuses it so a typo never ships)
@@ -390,6 +428,7 @@ async function contentFiles(dir, label, m) {
390
428
  skills: m.skills?.length ?? scannedSkills,
391
429
  templates: m.connectorTemplates?.length ?? templates.length,
392
430
  agents: agents.length,
431
+ themes: themePaths.length,
393
432
  };
394
433
  }
395
434
  /**
@@ -513,7 +552,7 @@ async function bundleExtension(dir, label, pkgVersion) {
513
552
  * 2. when `src/index.ts` exists, bundles the extension ({@link bundleExtension}) and sets
514
553
  * `extension = { entry: "./dist/index.mjs", manifest, migrations?: "./migrations", ui?: "./ui" }`;
515
554
  * a declarative plugin (no `src/index.ts`) runs no esbuild and creates no staging dir;
516
- * 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,
517
556
  * LICENSE, NOTICE and the plain files of package.json `files` ({@link extraFiles});
518
557
  * 4. writes a package.json with only name / version / type / license / {@link METADATA_FIELDS}
519
558
  * and, with an extension, `main` / `exports` (the entry) and peerDependencies (no `capybara`
@@ -529,8 +568,12 @@ export async function packPlugin(pluginDir, outDir) {
529
568
  const source = await readSourceManifest(dir, label);
530
569
  const content = await contentFiles(dir, label, source);
531
570
  const hasExtension = existsSync(join(dir, "src/index.ts"));
532
- if (!hasExtension && content.skills === 0 && content.templates === 0 && content.agents === 0) {
533
- 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)`);
534
577
  }
535
578
  const ext = hasExtension ? await bundleExtension(dir, label, srcPkg.version) : undefined;
536
579
  if (ext === undefined && source.setupGuides?.some((g) => g.place === "channel-connection")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capybara-hq/plugin-tools",
3
- "version": "0.1.0-rc.8",
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/kernel": "^0.1.0-rc.10",
55
- "@capybara-hq/plugin-sdk": "^0.1.0-rc.7"
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.10"
59
+ "@capybara-hq/testing": "0.1.0-rc.11"
60
60
  },
61
61
  "scripts": {
62
62
  "build": "tsc -b"