@capybara-hq/plugin-tools 0.1.0-rc.0 → 0.1.0-rc.10

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.
Files changed (51) hide show
  1. package/README.md +26 -19
  2. package/bin/capybara-plugin.mjs +1 -1
  3. package/dist/check/applied-migrations.d.ts +23 -0
  4. package/dist/check/applied-migrations.js +86 -0
  5. package/dist/check/cli.d.ts +2 -1
  6. package/dist/check/cli.js +7 -3
  7. package/dist/check/config-forms.d.ts +13 -0
  8. package/dist/check/config-forms.js +71 -0
  9. package/dist/check/deps.d.ts +4 -2
  10. package/dist/check/deps.js +14 -2
  11. package/dist/check/index.d.ts +4 -1
  12. package/dist/check/index.js +4 -1
  13. package/dist/check/migrations.d.ts +1 -1
  14. package/dist/check/migrations.js +30 -1
  15. package/dist/check/org-override.d.ts +10 -0
  16. package/dist/check/org-override.js +62 -0
  17. package/dist/check/plugin-pack.js +2 -0
  18. package/dist/check/plugin-permissions.d.ts +34 -1
  19. package/dist/check/plugin-permissions.js +66 -6
  20. package/dist/check/plugin-tool-keys.d.ts +13 -0
  21. package/dist/check/plugin-tool-keys.js +38 -0
  22. package/dist/check/release-metadata.d.ts +5 -0
  23. package/dist/check/release-metadata.js +47 -0
  24. package/dist/check/report.d.ts +4 -3
  25. package/dist/check/report.js +13 -3
  26. package/dist/check/run.d.ts +3 -1
  27. package/dist/check/run.js +12 -1
  28. package/dist/check/source.d.ts +7 -0
  29. package/dist/check/source.js +2 -0
  30. package/dist/check/themes.d.ts +19 -0
  31. package/dist/check/themes.js +112 -0
  32. package/dist/check/tool-design.d.ts +1 -1
  33. package/dist/check/tool-design.js +3 -3
  34. package/dist/check/ui-slots.d.ts +7 -0
  35. package/dist/check/ui-slots.js +30 -0
  36. package/dist/cli.js +0 -2
  37. package/dist/marketplace/cli.d.ts +10 -7
  38. package/dist/marketplace/cli.js +20 -10
  39. package/dist/marketplace/marketplace.d.ts +46 -24
  40. package/dist/marketplace/marketplace.js +232 -60
  41. package/dist/pack/cli.d.ts +3 -10
  42. package/dist/pack/cli.js +3 -40
  43. package/dist/pack/config-schemas.d.ts +34 -0
  44. package/dist/pack/config-schemas.js +151 -0
  45. package/dist/pack/index.d.ts +2 -2
  46. package/dist/pack/index.js +2 -2
  47. package/dist/pack/pack.d.ts +8 -2
  48. package/dist/pack/pack.js +154 -38
  49. package/package.json +4 -4
  50. package/dist/pack/bootstrap.d.ts +0 -25
  51. package/dist/pack/bootstrap.js +0 -70
package/dist/pack/cli.js CHANGED
@@ -1,27 +1,16 @@
1
1
  import { relative, resolve } from "node:path";
2
2
  import { parseArgs } from "node:util";
3
3
  import { discoverPlugins } from "../discover.js";
4
- import { packBootstrap } from "./bootstrap.js";
5
- import { DEFAULT_NPM_REGISTRY } from "./npm.js";
6
4
  import { packPlugin } from "./pack.js";
7
- export const PACK_USAGE = "capybara-plugin pack [<dir>...] [--all <root>] [--out <dir>]\n" +
8
- " capybara-plugin pack --bootstrap <manifest> [--root <dir>] [--out <dir>] [--registry <url>]\n" +
9
- " [--npm-token <token>]";
5
+ export const PACK_USAGE = "capybara-plugin pack [<dir>...] [--all <root>] [--out <dir>]";
10
6
  /**
11
7
  * `capybara-plugin pack`: packs plugin directories into plugin tgz files.
12
8
  *
13
9
  * - `<dir>...`: the plugin directories to pack;
14
10
  * - `--all <root>`: also every plugin of the repository at `<root>` (`<root>/plugins/*`, or `<root>`
15
11
  * itself when it is a plugin);
16
- * - `--bootstrap <manifest>`: the bootstrap directory an image bakes: every entry of the bootstrap
17
- * manifest as `<name>-<version>.tgz`, checked against its pins (workspace entries' `path` is
18
- * relative to `--root`, default the current directory; npm entries are downloaded from
19
- * `--registry`, default `CAPYBARA_PACKAGES__NPM_REGISTRY` (the kernel's `packages.npmRegistry`)
20
- * or else the public npm registry, and must match their pinned sha512; `--npm-token`, default
21
- * `CAPYBARA_PACKAGES__NPM_TOKEN` (the kernel's `packages.npmToken`), is sent as a Bearer token to
22
- * the registry origin only);
23
- * - `--out <dir>`: output directory, default `artifacts/plugins` (`artifacts/bootstrap` with
24
- * `--bootstrap`) under the current directory. The last `--out` wins.
12
+ * - `--out <dir>`: output directory, default `artifacts/plugins` under the current directory. The
13
+ * last `--out` wins.
25
14
  *
26
15
  * Relative paths resolve against `cwd` (the current directory). Returns the exit code.
27
16
  */
@@ -33,10 +22,6 @@ export async function packCommand(args, cwd) {
33
22
  help: { type: "boolean", short: "h", default: false },
34
23
  all: { type: "string" },
35
24
  out: { type: "string" },
36
- bootstrap: { type: "string" },
37
- root: { type: "string" },
38
- registry: { type: "string" },
39
- "npm-token": { type: "string" },
40
25
  },
41
26
  });
42
27
  if (values.help) {
@@ -48,28 +33,6 @@ export async function packCommand(args, cwd) {
48
33
  const rel = relative(cwd, p);
49
34
  return rel === "" ? "." : rel.startsWith("..") ? p : rel;
50
35
  };
51
- if (values.bootstrap !== undefined) {
52
- if (values.all !== undefined || positionals.length > 0) {
53
- return usage("--bootstrap takes no plugin directory");
54
- }
55
- try {
56
- const root = values.root === undefined ? cwd : resolve(cwd, values.root);
57
- const manifest = resolve(cwd, values.bootstrap);
58
- const registry = values.registry ?? (process.env.CAPYBARA_PACKAGES__NPM_REGISTRY || DEFAULT_NPM_REGISTRY);
59
- const token = values["npm-token"] ?? process.env.CAPYBARA_PACKAGES__NPM_TOKEN;
60
- for (const p of await packBootstrap(manifest, root, outOf("artifacts/bootstrap"), {
61
- registry,
62
- ...(token !== undefined && token !== "" && { token }),
63
- })) {
64
- console.log(`${shown(p.file)} ${p.integrity}`);
65
- }
66
- return 0;
67
- }
68
- catch (e) {
69
- console.error(e instanceof Error ? e.message : String(e));
70
- return 1;
71
- }
72
- }
73
36
  const plugins = positionals.map((d) => resolve(cwd, d));
74
37
  if (values.all !== undefined) {
75
38
  const root = resolve(cwd, values.all);
@@ -0,0 +1,34 @@
1
+ import { type ConfigUiProblem, type ExtensionManifest, type JsonSchema } from "@capybara-hq/plugin-sdk";
2
+ /**
3
+ * The config JSON Schemas an extension's package carries (extension config forms spec §4.5) and
4
+ * the checks the packer and `capybara-plugin check` ("Config forms") run on them.
5
+ */
6
+ /** The config levels an extension declares a schema for. */
7
+ export type ConfigSchemaLevel = "deployment" | "org";
8
+ /** A problem of one level's schema (`path`: dot path of the node, `""` the root). */
9
+ export interface ConfigSchemaProblem extends ConfigUiProblem {
10
+ level: ConfigSchemaLevel;
11
+ }
12
+ /** The JSON Schemas of an extension's config levels and the problems of their hints. */
13
+ export interface ExtensionConfigSchemas {
14
+ schemas: {
15
+ deployment?: JsonSchema;
16
+ org?: JsonSchema;
17
+ };
18
+ problems: ConfigSchemaProblem[];
19
+ }
20
+ /**
21
+ * The JSON Schemas (`configJsonSchema`) of an extension's deployment and org config, with every
22
+ * problem of their hints: `validateConfigUi` at the level, plus value hints on secrets
23
+ * ({@link secretValueHints}). The packer refuses a plugin with problems.
24
+ */
25
+ export declare function extensionConfigSchemas(config: ExtensionManifest["config"] | undefined): ExtensionConfigSchemas;
26
+ /** `<level> config: <path>: <message>` (the root as `(root)`). */
27
+ export declare const formatConfigSchemaProblem: (p: ConfigSchemaProblem) => string;
28
+ /**
29
+ * The rendered fields of a form schema without a label in both languages (`plugin-config-labels`):
30
+ * every property node the form draws, in nested objects, array items and record values. A secret
31
+ * record named by some `x-ui.secrets[].path` is skipped (it is drawn inside each item with that
32
+ * hint's own label); a secret node is drawn as one input, so its own properties are not visited.
33
+ */
34
+ export declare function missingConfigLabels(schema: JsonSchema): ConfigUiProblem[];
@@ -0,0 +1,151 @@
1
+ import { configJsonSchema, validateConfigUi, } from "@capybara-hq/plugin-sdk";
2
+ const LEVELS = ["deployment", "org"];
3
+ const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
4
+ /** Keywords whose value is data, not a schema: never recursed into. */
5
+ const VALUE_KEYWORDS = new Set(["default", "const", "enum", "examples"]);
6
+ /** Hints that would reveal values; `configJsonSchema` drops them under a secret. */
7
+ const VALUE_HINTS = ["options", "presets"];
8
+ const join = (path, seg) => (path === "" ? seg : `${path}.${seg}`);
9
+ /**
10
+ * Value hints (`options`, `presets`) on a secret node or under one. `configJsonSchema` removes
11
+ * them before `validateConfigUi` could see them, so a plugin would ship a hint that silently does
12
+ * nothing; this reads the zod schema's own JSON Schema (meta `secret` / `ui` kept as written) with
13
+ * the schema's own zod, so plugin-tools needs no zod at run time.
14
+ */
15
+ function secretValueHints(schema) {
16
+ const toJson = schema?.toJSONSchema;
17
+ if (typeof toJson !== "function")
18
+ return [];
19
+ const raw = toJson.call(schema, { io: "input", unrepresentable: "any" });
20
+ const out = new Map();
21
+ const walk = (node, path, inSecret) => {
22
+ if (Array.isArray(node)) {
23
+ for (const n of node)
24
+ walk(n, path, inSecret);
25
+ return;
26
+ }
27
+ if (!isObj(node))
28
+ return;
29
+ const secret = inSecret || node.secret === true;
30
+ if (secret && isObj(node.ui)) {
31
+ for (const k of VALUE_HINTS) {
32
+ if (node.ui[k] === undefined)
33
+ continue;
34
+ const message = `x-ui.${k} on a secret field: secrets publish no values, so the form never shows them (remove ${k})`;
35
+ out.set(`${path}\n${message}`, { path, message });
36
+ }
37
+ }
38
+ for (const [k, v] of Object.entries(node)) {
39
+ if (k === "ui" || VALUE_KEYWORDS.has(k))
40
+ continue;
41
+ if (k === "properties" && isObj(v)) {
42
+ for (const [name, child] of Object.entries(v))
43
+ walk(child, join(path, name), secret);
44
+ }
45
+ else if (k === "items" || k === "additionalProperties") {
46
+ walk(v, join(path, k), secret);
47
+ }
48
+ else {
49
+ walk(v, path, secret);
50
+ }
51
+ }
52
+ };
53
+ walk(raw, "", false);
54
+ return [...out.values()];
55
+ }
56
+ /**
57
+ * The JSON Schemas (`configJsonSchema`) of an extension's deployment and org config, with every
58
+ * problem of their hints: `validateConfigUi` at the level, plus value hints on secrets
59
+ * ({@link secretValueHints}). The packer refuses a plugin with problems.
60
+ */
61
+ export function extensionConfigSchemas(config) {
62
+ const result = { schemas: {}, problems: [] };
63
+ for (const level of LEVELS) {
64
+ const zod = config?.[level];
65
+ if (zod === undefined)
66
+ continue;
67
+ const schema = configJsonSchema(zod);
68
+ result.schemas[level] = schema;
69
+ for (const p of [...validateConfigUi(schema, level), ...secretValueHints(zod)]) {
70
+ result.problems.push({ level, ...p });
71
+ }
72
+ }
73
+ return result;
74
+ }
75
+ /** `<level> config: <path>: <message>` (the root as `(root)`). */
76
+ export const formatConfigSchemaProblem = (p) => `${p.level} config: ${p.path === "" ? "(root)" : p.path}: ${p.message}`;
77
+ /** The non-null shape of a node: itself, or the one non-null branch of a nullable `anyOf`. */
78
+ function shapeOf(node) {
79
+ const branches = (node.anyOf ?? node.oneOf);
80
+ if (node.type === undefined && Array.isArray(branches)) {
81
+ const rest = branches.filter((b) => isObj(b) && b.type !== "null");
82
+ if (rest.length === 1 && rest[0] !== undefined)
83
+ return rest[0];
84
+ }
85
+ return node;
86
+ }
87
+ const hasText = (v) => typeof v === "string" && v.trim() !== "";
88
+ /** Does the node (or its non-null shape) carry `x-ui.label` with both zh and en? */
89
+ function hasLabel(node) {
90
+ for (const n of [node, shapeOf(node)]) {
91
+ const label = isObj(n["x-ui"]) ? n["x-ui"].label : undefined;
92
+ if (isObj(label) && hasText(label.zh) && hasText(label.en))
93
+ return true;
94
+ }
95
+ return false;
96
+ }
97
+ /** Every `x-ui.secrets[].path` of a form schema (dot paths from the root). */
98
+ function itemSecretPaths(node, out = new Set()) {
99
+ if (Array.isArray(node)) {
100
+ for (const n of node)
101
+ itemSecretPaths(n, out);
102
+ return out;
103
+ }
104
+ if (!isObj(node))
105
+ return out;
106
+ const hints = node["x-ui"];
107
+ if (isObj(hints) && Array.isArray(hints.secrets)) {
108
+ for (const s of hints.secrets)
109
+ if (isObj(s) && typeof s.path === "string")
110
+ out.add(s.path);
111
+ }
112
+ for (const [k, v] of Object.entries(node)) {
113
+ if (k !== "x-ui" && !VALUE_KEYWORDS.has(k))
114
+ itemSecretPaths(v, out);
115
+ }
116
+ return out;
117
+ }
118
+ /**
119
+ * The rendered fields of a form schema without a label in both languages (`plugin-config-labels`):
120
+ * every property node the form draws, in nested objects, array items and record values. A secret
121
+ * record named by some `x-ui.secrets[].path` is skipped (it is drawn inside each item with that
122
+ * hint's own label); a secret node is drawn as one input, so its own properties are not visited.
123
+ */
124
+ export function missingConfigLabels(schema) {
125
+ const skip = itemSecretPaths(schema);
126
+ const out = [];
127
+ const visit = (node, path) => {
128
+ const shape = shapeOf(node);
129
+ if (isObj(shape.properties)) {
130
+ for (const [k, child] of Object.entries(shape.properties)) {
131
+ if (!isObj(child))
132
+ continue;
133
+ const p = join(path, k);
134
+ if (skip.has(p))
135
+ continue;
136
+ if (!hasLabel(child)) {
137
+ out.push({ path: p, message: "has no label (x-ui.label with zh and en)" });
138
+ }
139
+ if (child["x-secret"] !== true)
140
+ visit(child, p);
141
+ }
142
+ }
143
+ if (isObj(shape.items))
144
+ visit(shape.items, join(path, "items"));
145
+ if (isObj(shape.additionalProperties)) {
146
+ visit(shape.additionalProperties, join(path, "additionalProperties"));
147
+ }
148
+ };
149
+ visit(schema, "");
150
+ return out;
151
+ }
@@ -1,4 +1,4 @@
1
- export { type BootstrapPack, packBootstrap } from "./bootstrap.js";
2
1
  export { PACK_USAGE, packCommand } from "./cli.js";
2
+ export { type ConfigSchemaLevel, type ConfigSchemaProblem, type ExtensionConfigSchemas, extensionConfigSchemas, formatConfigSchemaProblem, missingConfigLabels, } from "./config-schemas.js";
3
3
  export { assertNpmTarballLabel, DEFAULT_NPM_REGISTRY, downloadPinnedNpmPackage, fetchNpmTarball, NPM_TARBALL_MAX_BYTES, type NpmFetchOptions, NpmPackageError, type NpmPackageErrorKind, } from "./npm.js";
4
- export { METADATA_FIELDS, NOTICES_FILE, type PackResult, packPlugin, STAGING_PREFIX, } from "./pack.js";
4
+ export { importPluginExtension, METADATA_FIELDS, NOTICES_FILE, type PackResult, packPlugin, STAGING_PREFIX, } from "./pack.js";
@@ -1,4 +1,4 @@
1
- export { packBootstrap } from "./bootstrap.js";
2
1
  export { PACK_USAGE, packCommand } from "./cli.js";
2
+ export { extensionConfigSchemas, formatConfigSchemaProblem, missingConfigLabels, } from "./config-schemas.js";
3
3
  export { assertNpmTarballLabel, DEFAULT_NPM_REGISTRY, downloadPinnedNpmPackage, fetchNpmTarball, NPM_TARBALL_MAX_BYTES, NpmPackageError, } from "./npm.js";
4
- export { METADATA_FIELDS, NOTICES_FILE, packPlugin, STAGING_PREFIX, } from "./pack.js";
4
+ export { importPluginExtension, METADATA_FIELDS, NOTICES_FILE, packPlugin, STAGING_PREFIX, } from "./pack.js";
@@ -1,4 +1,4 @@
1
- import { type PluginManifest } from "@capybara-hq/plugin-sdk";
1
+ import { type ExtensionManifest, type PluginManifest } from "@capybara-hq/plugin-sdk";
2
2
  /**
3
3
  * The result of packing: the tgz path, its hex sha256 and the `.capybara-plugin/plugin.json`
4
4
  * written into it (the extension's static manifest is `manifest.extension?.manifest`).
@@ -25,6 +25,12 @@ export declare const STAGING_PREFIX = ".capybara-pack-";
25
25
  export declare const METADATA_FIELDS: readonly ["description", "repository", "homepage", "keywords"];
26
26
  /** Third-party license notices of the bundled dependencies (written only when there are any). */
27
27
  export declare const NOTICES_FILE = "THIRD-PARTY-NOTICES.txt";
28
+ /**
29
+ * The extension a plugin directory's `src/index.ts` defines, bundled and imported the way the
30
+ * packer does (in a `.capybara-pack-*` staging dir under the system temp dir, removed afterwards).
31
+ * For checks that need the module (config forms); throws what packing would throw.
32
+ */
33
+ export declare function importPluginExtension(pluginDir: string): Promise<ExtensionManifest>;
28
34
  /**
29
35
  * Packs a plugin directory into a plugin package (spec §4.1):
30
36
  * 1. reads and validates the source `.capybara-plugin/plugin.json` (it must not carry
@@ -32,7 +38,7 @@ export declare const NOTICES_FILE = "THIRD-PARTY-NOTICES.txt";
32
38
  * 2. when `src/index.ts` exists, bundles the extension ({@link bundleExtension}) and sets
33
39
  * `extension = { entry: "./dist/index.mjs", manifest, migrations?: "./migrations", ui?: "./ui" }`;
34
40
  * a declarative plugin (no `src/index.ts`) runs no esbuild and creates no staging dir;
35
- * 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,
36
42
  * LICENSE, NOTICE and the plain files of package.json `files` ({@link extraFiles});
37
43
  * 4. writes a package.json with only name / version / type / license / {@link METADATA_FIELDS}
38
44
  * and, with an extension, `main` / `exports` (the entry) and peerDependencies (no `capybara`
package/dist/pack/pack.js CHANGED
@@ -7,10 +7,12 @@ 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, 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";
15
+ import { extensionConfigSchemas, formatConfigSchemaProblem } from "./config-schemas.js";
14
16
  /** The extension entry the packer writes (relative to the package root). */
15
17
  const EXTENSION_ENTRY = "dist/index.mjs";
16
18
  /**
@@ -166,6 +168,8 @@ const SHIPPED_ELSEWHERE = [
166
168
  "skills",
167
169
  "connectors",
168
170
  "agents",
171
+ // setup guide templates: the listed files are shipped by contentFiles
172
+ "guides",
169
173
  ".capybara-plugin",
170
174
  "README.md",
171
175
  "LICENSE",
@@ -356,13 +360,132 @@ async function contentFiles(dir, label, m) {
356
360
  }
357
361
  agentNames.set(t.template.name, rel);
358
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
+ }
359
426
  return {
360
427
  files,
361
428
  skills: m.skills?.length ?? scannedSkills,
362
429
  templates: m.connectorTemplates?.length ?? templates.length,
363
430
  agents: agents.length,
431
+ themes: themePaths.length,
364
432
  };
365
433
  }
434
+ /**
435
+ * Bundles `src/index.ts` into `<stage>/package/dist/index.mjs` (esbuild, single-file ESM), checks
436
+ * the bundle's imports, imports it and runs its default export through defineExtension. Returns the
437
+ * bundle, the defined manifest and the bundled inputs (for the notices).
438
+ */
439
+ async function buildAndImport(dir, label, stage) {
440
+ await linkHostPackages(dir, stage);
441
+ const entryOut = join(stage, "package", EXTENSION_ENTRY);
442
+ const result = await build({
443
+ absWorkingDir: dir,
444
+ entryPoints: [join(dir, "src/index.ts")],
445
+ outfile: entryOut,
446
+ bundle: true,
447
+ platform: "node",
448
+ format: "esm",
449
+ target: "node24",
450
+ external: [...HOST_PROVIDED_PACKAGES],
451
+ plugins: [nodeBuiltins],
452
+ banner: { js: BANNER },
453
+ define: DEFINE,
454
+ charset: "utf8",
455
+ logLevel: "silent",
456
+ metafile: true,
457
+ // Notices of bundled dependencies go to THIRD-PARTY-NOTICES.txt (built from the metafile).
458
+ legalComments: "none",
459
+ });
460
+ if (result.errors.length > 0) {
461
+ throw new Error(`${label}: esbuild failed: ${result.errors.map((e) => e.text).join("; ")}`);
462
+ }
463
+ const code = await readFile(entryOut);
464
+ const issues = await importIssues(code.toString("utf8"));
465
+ if (issues.length > 0)
466
+ throw new Error(`${label}: ${issues.join("; ")}`);
467
+ const mod = await importBundle(entryOut);
468
+ if (typeof mod.default !== "object" || mod.default === null) {
469
+ throw new Error(`${label}: the bundle has no default export object`);
470
+ }
471
+ const m = defineExtension(mod.default);
472
+ return { code, m, inputs: Object.keys(result.metafile?.inputs ?? {}) };
473
+ }
474
+ /**
475
+ * The extension a plugin directory's `src/index.ts` defines, bundled and imported the way the
476
+ * packer does (in a `.capybara-pack-*` staging dir under the system temp dir, removed afterwards).
477
+ * For checks that need the module (config forms); throws what packing would throw.
478
+ */
479
+ export async function importPluginExtension(pluginDir) {
480
+ const dir = resolve(pluginDir);
481
+ const stage = await realpath(await mkdtemp(join(tmpdir(), STAGING_PREFIX)));
482
+ try {
483
+ return (await buildAndImport(dir, dir, stage)).m;
484
+ }
485
+ finally {
486
+ await rm(stage, { recursive: true, force: true });
487
+ }
488
+ }
366
489
  /**
367
490
  * Bundles the extension `src/index.ts`:
368
491
  * 1. esbuild bundles it into the single-file ESM `dist/index.mjs` (platform node, target node24),
@@ -371,6 +494,8 @@ async function contentFiles(dir, label, m) {
371
494
  * 2. the bundle's imports are checked (allowlist, no non-literal dynamic import), the bundle is
372
495
  * imported and its default export goes through defineExtension; `staticManifestOf` gives the
373
496
  * static manifest, whose version must equal the package.json version;
497
+ * the config schemas become `extension.configSchemas` (`configJsonSchema`; refused when their
498
+ * hints have problems, {@link extensionConfigSchemas});
374
499
  * 3. `migrations/` (when the manifest declares migrations, which must resolve to the plugin's
375
500
  * `migrations/`), `ui/` (when present) and THIRD-PARTY-NOTICES.txt join the files.
376
501
  *
@@ -379,41 +504,14 @@ async function contentFiles(dir, label, m) {
379
504
  async function bundleExtension(dir, label, pkgVersion) {
380
505
  const stage = await realpath(await mkdtemp(join(tmpdir(), STAGING_PREFIX)));
381
506
  try {
382
- await linkHostPackages(dir, stage);
383
- const entryOut = join(stage, "package", EXTENSION_ENTRY);
384
- const result = await build({
385
- absWorkingDir: dir,
386
- entryPoints: [join(dir, "src/index.ts")],
387
- outfile: entryOut,
388
- bundle: true,
389
- platform: "node",
390
- format: "esm",
391
- target: "node24",
392
- external: [...HOST_PROVIDED_PACKAGES],
393
- plugins: [nodeBuiltins],
394
- banner: { js: BANNER },
395
- define: DEFINE,
396
- charset: "utf8",
397
- logLevel: "silent",
398
- metafile: true,
399
- // Notices of bundled dependencies go to THIRD-PARTY-NOTICES.txt (built from the metafile).
400
- legalComments: "none",
401
- });
402
- if (result.errors.length > 0) {
403
- throw new Error(`${label}: esbuild failed: ${result.errors.map((e) => e.text).join("; ")}`);
404
- }
405
- const code = await readFile(entryOut);
406
- const issues = await importIssues(code.toString("utf8"));
407
- if (issues.length > 0)
408
- throw new Error(`${label}: ${issues.join("; ")}`);
409
- const mod = await importBundle(entryOut);
410
- if (typeof mod.default !== "object" || mod.default === null) {
411
- throw new Error(`${label}: the bundle has no default export object`);
412
- }
413
- const m = defineExtension(mod.default);
507
+ const { code, m, inputs } = await buildAndImport(dir, label, stage);
414
508
  if (m.version !== pkgVersion) {
415
509
  throw new Error(`${label}: manifest version ${m.version} does not match package.json version ${String(pkgVersion)}`);
416
510
  }
511
+ const config = extensionConfigSchemas(m.config);
512
+ if (config.problems.length > 0) {
513
+ throw new Error(`${label}: invalid config UI hints: ${config.problems.map(formatConfigSchemaProblem).join("; ")}`);
514
+ }
417
515
  const files = new Map([[EXTENSION_ENTRY, code]]);
418
516
  const extension = {
419
517
  entry: `./${EXTENSION_ENTRY}`,
@@ -435,7 +533,10 @@ async function bundleExtension(dir, label, pkgVersion) {
435
533
  }
436
534
  extension.ui = "./ui";
437
535
  }
438
- const notices = await thirdPartyNotices(dir, Object.keys(result.metafile?.inputs ?? {}));
536
+ if (config.schemas.deployment !== undefined || config.schemas.org !== undefined) {
537
+ extension.configSchemas = config.schemas;
538
+ }
539
+ const notices = await thirdPartyNotices(dir, inputs);
439
540
  if (notices !== undefined)
440
541
  files.set(NOTICES_FILE, Buffer.from(notices));
441
542
  return { files, extension };
@@ -451,7 +552,7 @@ async function bundleExtension(dir, label, pkgVersion) {
451
552
  * 2. when `src/index.ts` exists, bundles the extension ({@link bundleExtension}) and sets
452
553
  * `extension = { entry: "./dist/index.mjs", manifest, migrations?: "./migrations", ui?: "./ui" }`;
453
554
  * a declarative plugin (no `src/index.ts`) runs no esbuild and creates no staging dir;
454
- * 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,
455
556
  * LICENSE, NOTICE and the plain files of package.json `files` ({@link extraFiles});
456
557
  * 4. writes a package.json with only name / version / type / license / {@link METADATA_FIELDS}
457
558
  * and, with an extension, `main` / `exports` (the entry) and peerDependencies (no `capybara`
@@ -467,10 +568,17 @@ export async function packPlugin(pluginDir, outDir) {
467
568
  const source = await readSourceManifest(dir, label);
468
569
  const content = await contentFiles(dir, label, source);
469
570
  const hasExtension = existsSync(join(dir, "src/index.ts"));
470
- if (!hasExtension && content.skills === 0 && content.templates === 0 && content.agents === 0) {
471
- 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)`);
472
577
  }
473
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
+ }
474
582
  const files = new Map([...(ext?.files ?? []), ...content.files]);
475
583
  const extra = await extraFiles(dir, srcPkg.files);
476
584
  for (const [path, data] of extra.files)
@@ -504,7 +612,15 @@ export async function packPlugin(pluginDir, outDir) {
504
612
  if (pkgIssues.length > 0)
505
613
  throw new Error(`${label}: ${pkgIssues.join("; ")}`);
506
614
  files.set("package.json", Buffer.from(`${JSON.stringify(pkg, null, 2)}\n`));
507
- files.set(PLUGIN_MANIFEST_FILE, Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`));
615
+ // The packed text is pretty-printed while the config schema caps measure compact JSON: the text
616
+ // itself must stay under the reader's limit, or the kernel would refuse the package unread.
617
+ const manifestText = `${JSON.stringify(manifest, null, 2)}\n`;
618
+ if (manifestText.length > PLUGIN_MANIFEST_LIMITS.manifestChars) {
619
+ throw new Error(`${label}: packed ${PLUGIN_MANIFEST_FILE} is ${manifestText.length} characters, over the ` +
620
+ `${PLUGIN_MANIFEST_LIMITS.manifestChars} limit (PLUGIN_MANIFEST_LIMITS.manifestChars); ` +
621
+ "make the config schemas smaller");
622
+ }
623
+ files.set(PLUGIN_MANIFEST_FILE, Buffer.from(manifestText));
508
624
  const tgz = await makeTgz(files);
509
625
  await mkdir(outDir, { recursive: true });
510
626
  const file = resolve(outDir, tgzName(String(srcPkg.name), String(srcPkg.version)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capybara-hq/plugin-tools",
3
- "version": "0.1.0-rc.0",
3
+ "version": "0.1.0-rc.10",
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.0",
55
- "@capybara-hq/plugin-sdk": "^0.1.0-rc.0"
54
+ "@capybara-hq/kernel": "^0.1.0-rc.13",
55
+ "@capybara-hq/plugin-sdk": "^0.1.0-rc.9"
56
56
  },
57
57
  "devDependencies": {
58
58
  "zod": "^4.6.7",
59
- "@capybara-hq/testing": "0.1.0-rc.0"
59
+ "@capybara-hq/testing": "0.1.0-rc.12"
60
60
  },
61
61
  "scripts": {
62
62
  "build": "tsc -b"
@@ -1,25 +0,0 @@
1
- import { type NpmFetchOptions } from "./npm.js";
2
- /** One package {@link packBootstrap} wrote. */
3
- export interface BootstrapPack {
4
- name: string;
5
- version: string;
6
- file: string;
7
- integrity: string;
8
- }
9
- /**
10
- * Produces the bootstrap directory the image bakes (package ecosystem spec §7): every entry of the
11
- * bootstrap manifest (`packages/kernel/bootstrap.json`) as `<outDir>/<name>-<version>.tgz`. A
12
- * `source: "workspace"` entry is packed from `<repoRoot>/<path>` with {@link packPlugin}
13
- * (byte-reproducible) and must equal the entry: the plugin name, the version and the pinned sha512,
14
- * otherwise this throws naming the plugin and writes nothing for it (the image build fails rather
15
- * than baking a package `capybara bootstrap` would refuse). A `source: "npm"` entry is downloaded
16
- * from `opts.registry` (default the public npm registry, the kernel's `packages.npmRegistry`
17
- * default; `opts.token` goes to the registry origin only) and written only when its bytes match
18
- * the pinned sha512 and its package.json names the entry's package and version; a tampered,
19
- * mislabelled or oversized (`bootstrap.too_large`) tarball or an unpublished package or version
20
- * throws naming the plugin. So npm entries need the network at
21
- * build time only: init installs from the baked directory and never downloads.
22
- */
23
- export declare function packBootstrap(manifestFile: string, repoRoot: string, outDir: string, opts?: {
24
- registry?: string;
25
- } & NpmFetchOptions): Promise<BootstrapPack[]>;