@capybara-hq/plugin-sdk 0.1.0-rc.6 → 0.1.0-rc.7

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.
@@ -30,6 +30,7 @@ The file is strict. Top-level fields:
30
30
  | `displayName` | object | yes |
31
31
  | `description` | string | no |
32
32
  | `icon` | string | no |
33
+ | `defaultEnabled` | boolean | no |
33
34
  | `template` | object | yes |
34
35
  <!-- /generated -->
35
36
 
@@ -37,6 +37,7 @@ file is strict: an unknown key is an error. Fields (agent templates are listed u
37
37
  | `skills` | array of object | no |
38
38
  | `connectorTemplates` | array of object | no |
39
39
  | `agents` | array of object | no |
40
+ | `setupGuides` | array of object | no |
40
41
  | `extension` | object | no |
41
42
  <!-- /generated -->
42
43
 
@@ -51,6 +52,16 @@ Rules that the schema cannot show:
51
52
  Omit them to scan the default folders. Names and ids are unique within the plugin.
52
53
  - `dependencies`: plugin name plus semver range; no self-dependency.
53
54
  - `agents`: at most 64 templates (see `agent-templates`).
55
+ - `setupGuides` (at most 8): setup material shown where a person configures what the plugin
56
+ brings. Each guide has an `id`, a `place` (`channel-connection`: 新建渠道 and the channel detail;
57
+ the plugin's extension must provide `channel@1`), a `title` and `steps` (`{ en, zh? }`, plain
58
+ text, a backtick pair shows as inline code) and `files` (`./` paths ending in `.tmpl`, `.yaml`,
59
+ `.yml`, `.json` or `.txt`, at most 64 KiB of text, with a `title` and a `language` of `yaml`,
60
+ `json` or `text`). A step or file may carry `mode` (`long_connection` or `webhook`) to apply to
61
+ that inbound mode only; ship one file per mode instead of conditionals. The kernel fills
62
+ `{{publicUrl}}`, `{{webhookUrl}}`, `{{signInCallbackUrl}}`, `{{connectionName}}`, `{{orgName}}`,
63
+ `{{pluginName}}`, `{{pluginVersion}}` and `{{mode}}` (never a secret); in YAML and JSON put a
64
+ placeholder inside double quotes. `capybara-plugin pack` refuses any other placeholder.
54
65
  - `extension` is written by the packer; never write it by hand.
55
66
 
56
67
  ## Importing
package/dist/cards.d.ts CHANGED
@@ -10,6 +10,13 @@ export type ChannelCard = {
10
10
  toolName: string;
11
11
  appName?: string;
12
12
  agentName: string;
13
+ /**
14
+ * One line saying what the call does, as the web shows it: the tool's confirmation summary
15
+ * (e.g. a Platform operation's), else the tool's title and key argument; may be empty.
16
+ * Never the raw call input (it may hold a whole prompt, and a group sees the card); the
17
+ * full input is behind `webUrl`. Plain text written by the kernel from model-controlled
18
+ * values: render it as text, never as markup. The name is kept from when it held the input.
19
+ */
13
20
  inputPreview: string;
14
21
  scopes: ("once" | "session" | "always")[];
15
22
  expiresAt: string;
package/dist/cards.js CHANGED
@@ -92,7 +92,7 @@ export function validateChannelCard(card) {
92
92
  }
93
93
  }
94
94
  /**
95
- * 把模型可控的值(审批标题、输入预览、提问标题、回答摘要)放进代码里,退回文本里它们不会变成链接、强调或 HTML:
95
+ * 把模型可控的值(审批标题、调用摘要、提问标题、回答摘要)放进代码里,退回文本里它们不会变成链接、强调或 HTML:
96
96
  * 单行用行内代码,多行用围栏代码块(以换行开头),分隔符都比值里最长的反引号串更长(CommonMark 6.1 / 4.5)。
97
97
  * 选代码而不是给标点加反斜杠:代码是 CommonMark / GFM 里唯一保证内容按原样显示的写法(反斜杠挡不住 GFM 的裸网址
98
98
  * 自动链接,各渠道转换器对反斜杠的处理也不一致)。
@@ -119,7 +119,7 @@ const TEXT = {
119
119
  connect: (app) => `[Connect] ${app} needs to be connected.`,
120
120
  tool: "Tool",
121
121
  agent: "Agent",
122
- input: "Input",
122
+ summary: "Summary",
123
123
  status: "Status",
124
124
  reason: "Reason",
125
125
  expires: "Expires",
@@ -149,7 +149,7 @@ const TEXT = {
149
149
  connect: (app) => `【连接】需要连接 ${app}。`,
150
150
  tool: "工具",
151
151
  agent: "Agent",
152
- input: "输入",
152
+ summary: "摘要",
153
153
  status: "状态",
154
154
  reason: "原因",
155
155
  expires: "过期时间",
@@ -187,7 +187,7 @@ export function renderCardAsText(card, locale) {
187
187
  withCode(t.approval, asCode(card.title)),
188
188
  line(t.tool, card.appName ? `${card.appName} / ${card.toolName}` : card.toolName),
189
189
  line(t.agent, card.agentName),
190
- line(t.input, asCode(card.inputPreview)),
190
+ ...(card.inputPreview !== "" ? [line(t.summary, asCode(card.inputPreview))] : []),
191
191
  line(t.status, card.decidedBy ? `${state} (${card.decidedBy})` : state),
192
192
  ];
193
193
  if (card.state === "denied" && card.denyReason)
package/dist/channel.d.ts CHANGED
@@ -21,6 +21,21 @@ export interface InboundSender {
21
21
  tenantId: string;
22
22
  displayName?: string;
23
23
  avatarUrl?: string;
24
+ /**
25
+ * The sender's time zone on the platform, an IANA name (`Asia/Shanghai`; Slack `users.info`
26
+ * `user.tz`), when the platform gives one. Include it on `message` events when you can get it
27
+ * within the acknowledgement deadline (cache it per user); leave it out otherwise. It wins over
28
+ * the person's account setting and the org default for the run's date and zone (run context
29
+ * spec §3.4); a name the kernel does not know is ignored.
30
+ */
31
+ timeZone?: string;
32
+ /**
33
+ * The sender's language on the platform: `zh` / `en` or a BCP 47 tag (`zh-CN`, `en-US`; Slack
34
+ * `users.info` with `include_locale=true`, `user.locale`), when the platform gives one. The
35
+ * kernel normalizes it (`zh` and `zh-*` are Chinese, any other tag English); it wins over the
36
+ * person's latest web UI language and the org default (run context spec §3.4).
37
+ */
38
+ locale?: string;
24
39
  }
25
40
  /**
26
41
  * 渠道交给内核的入站事件。插件的约定:
@@ -76,7 +91,11 @@ export interface ChannelSendArgs {
76
91
  */
77
92
  message: OutboundMessage;
78
93
  signal: AbortSignal;
79
- /** 连接所属组织的界面语言:插件自己渲染的文案(卡片按钮、降级文本里的固定词)按它选择。 */
94
+ /**
95
+ * The recipient's language (the sender's platform language, else their latest web language,
96
+ * else the org's): the wording the extension renders itself (card buttons, fixed words in the
97
+ * text fallback) follows it.
98
+ */
80
99
  locale: Locale;
81
100
  /**
82
101
  * 幂等键(可选):不超过 40 个 `[A-Za-z0-9_-]` 字符,同一条逻辑消息的每次重试(崩溃后其他副本续投、
@@ -50,6 +50,7 @@ export declare const ConnectorAuthSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
50
50
  client_secret_basic: "client_secret_basic";
51
51
  client_secret_post: "client_secret_post";
52
52
  }>>;
53
+ tokenTypes: z.ZodOptional<z.ZodArray<z.ZodString>>;
53
54
  }, z.core.$strict>, z.ZodObject<{
54
55
  type: z.ZodLiteral<"oauth_client">;
55
56
  ownership: z.ZodLiteral<"org">;
@@ -59,6 +60,7 @@ export declare const ConnectorAuthSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
59
60
  client_secret_basic: "client_secret_basic";
60
61
  client_secret_post: "client_secret_post";
61
62
  }>>;
63
+ tokenTypes: z.ZodOptional<z.ZodArray<z.ZodString>>;
62
64
  }, z.core.$strict>], "type">;
63
65
  /** 认证预设({@link ConnectorAuthSchema} 的输出)。 */
64
66
  export type ConnectorAuthPreset = z.infer<typeof ConnectorAuthSchema>;
@@ -23,6 +23,19 @@ const Scopes = z
23
23
  .max(200)
24
24
  .regex(/^[\x21\x23-\x5B\x5D-\x7E]+$/, "Invalid scope"))
25
25
  .max(50);
26
+ /**
27
+ * More `token_type` values the token endpoint may return for a bearer token (case-insensitive;
28
+ * `Bearer` and Slack's `user` are always accepted). The token is still sent as
29
+ * `Authorization: Bearer`. Never `DPoP`: such a token is bound to a key the kernel does not hold.
30
+ */
31
+ const TokenTypes = z
32
+ .array(z
33
+ .string()
34
+ .min(1)
35
+ .max(32)
36
+ .regex(/^[A-Za-z][A-Za-z0-9_.-]*$/, "Invalid token type")
37
+ .refine((t) => t.toLowerCase() !== "dpop", "DPoP tokens are not bearer tokens"))
38
+ .max(5);
26
39
  /** 认证方式:只有类型与非密钥字段;凭据值走内核的 `credential` / 凭据接口。 */
27
40
  export const ConnectorAuthSchema = z.discriminatedUnion("type", [
28
41
  z.object({ type: z.literal("none") }).strict(),
@@ -57,6 +70,7 @@ export const ConnectorAuthSchema = z.discriminatedUnion("type", [
57
70
  scopes: Scopes.optional(),
58
71
  client: z.enum(["dcr", "manual"]),
59
72
  tokenAuthMethod: TokenAuthMethod.optional(),
73
+ tokenTypes: TokenTypes.optional(),
60
74
  })
61
75
  .strict()
62
76
  .superRefine((a, ctx) => {
@@ -91,6 +105,7 @@ export const ConnectorAuthSchema = z.discriminatedUnion("type", [
91
105
  tokenUrl: OAuthUrl,
92
106
  scopes: Scopes.optional(),
93
107
  tokenAuthMethod: TokenAuthMethod.optional(),
108
+ tokenTypes: TokenTypes.optional(),
94
109
  })
95
110
  .strict(),
96
111
  ]);
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const SDK_VERSION = "0.1.0-rc.6";
1
+ export declare const SDK_VERSION = "0.1.0-rc.7";
2
2
  export * from "./agent-template.js";
3
3
  export * from "./bootstrap.js";
4
4
  export * from "./cards.js";
@@ -23,6 +23,7 @@ export * from "./plugin-manifest.js";
23
23
  export * from "./plugin-package.js";
24
24
  export * from "./sandbox.js";
25
25
  export * from "./secret.js";
26
+ export * from "./setup-guide.js";
26
27
  export * from "./skill.js";
27
28
  export * from "./skill-invocation.js";
28
29
  export * from "./storage.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export const SDK_VERSION = "0.1.0-rc.6";
1
+ export const SDK_VERSION = "0.1.0-rc.7";
2
2
  export * from "./agent-template.js";
3
3
  export * from "./bootstrap.js";
4
4
  export * from "./cards.js";
@@ -23,6 +23,7 @@ export * from "./plugin-manifest.js";
23
23
  export * from "./plugin-package.js";
24
24
  export * from "./sandbox.js";
25
25
  export * from "./secret.js";
26
+ export * from "./setup-guide.js";
26
27
  export * from "./skill.js";
27
28
  export * from "./skill-invocation.js";
28
29
  export * from "./storage.js";
@@ -28,6 +28,8 @@ export declare const MARKETPLACE_LIMITS: {
28
28
  readonly plugins: 5000;
29
29
  readonly permissionKeys: 500;
30
30
  readonly urlChars: 2000;
31
+ /** Bytes of an entry icon (the plugin icon limit, {@link CONNECTOR_TEMPLATE_LIMITS}). */
32
+ readonly iconBytes: number;
31
33
  };
32
34
  /** Where a marketplace entry's plugin comes from. */
33
35
  export type MarketplaceSource = {
@@ -75,6 +77,18 @@ export interface MarketplaceEntry {
75
77
  };
76
78
  /** https URL. */
77
79
  homepage?: string;
80
+ /**
81
+ * The plugin's icon for marketplace lists, before the plugin is imported (display only; an
82
+ * imported plugin shows its own icon). Either an absolute `https://` URL whose path ends in
83
+ * `.svg` / `.png`, or `./<path>.svg|png` inside the marketplace's repository, read at the commit
84
+ * the marketplace was refreshed at (git marketplaces only; ignored in a marketplace fetched over
85
+ * http). The kernel downloads it on refresh (network rules of the marketplace downloads, never
86
+ * with the marketplace token), keeps it when it really is an svg / png of at most
87
+ * {@link MARKETPLACE_LIMITS}`.iconBytes`, and treats a URL's content as fixed: a new icon needs a
88
+ * new URL. A missing or invalid icon never fails the refresh. `capybara-plugin marketplace
89
+ * --write` fills it with the repository path of the plugin's `plugin.json` `icon`.
90
+ */
91
+ icon?: string;
78
92
  /**
79
93
  * This version is deprecated (`true`, or with a reason shown to admins): browsing lists it under
80
94
  * the plugin's other versions, never as the one to import, and the kernel refuses importing it
@@ -103,6 +117,8 @@ export interface MarketplaceParseOptions {
103
117
  /** Tests only: allow http on loopback addresses for `git` / `archive` URLs. */
104
118
  allowInsecureLoopback?: boolean;
105
119
  }
120
+ /** An entry icon's file extension (of the URL's path, or of the repository path). */
121
+ export declare const MARKETPLACE_ICON_RE: RegExp;
106
122
  /**
107
123
  * The zod schema of `marketplace.json` (https only; non-strict: unknown fields dropped). The JSON
108
124
  * Schema is generated with `z.toJSONSchema(MarketplaceManifestSchema, { io: "input" })`.
@@ -117,6 +133,16 @@ export declare function parseMarketplaceManifest(json: unknown, opts?: Marketpla
117
133
  };
118
134
  /** The entry's version: `version`, else the npm source's version, else `null` (the plugin's own). */
119
135
  export declare function marketplaceEntryVersion(e: MarketplaceEntry): string | null;
136
+ /**
137
+ * The entry's icon (see {@link MarketplaceEntry.icon}): `{ url }` for an absolute URL, `{ path }`
138
+ * (normalized, without the leading `./`) for a path inside the marketplace's repository, `null`
139
+ * without one.
140
+ */
141
+ export declare function marketplaceEntryIcon(e: Pick<MarketplaceEntry, "icon">): {
142
+ url: string;
143
+ } | {
144
+ path: string;
145
+ } | null;
120
146
  /**
121
147
  * The entry's deprecation (see {@link MarketplaceEntry.deprecated}): `null` when it is not
122
148
  * deprecated (absent or `false`), else its reason (`null` without one).
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { CATALOG_ITEM_NAME_RE, isSafePackagePath, NPM_PACKAGE_NAME_RE, SEMVER_VERSION_RE, SHA512_INTEGRITY_RE, } from "./plugin-package.js";
2
+ import { CATALOG_ITEM_NAME_RE, CONNECTOR_TEMPLATE_LIMITS, isSafePackagePath, NPM_PACKAGE_NAME_RE, SEMVER_VERSION_RE, SHA512_INTEGRITY_RE, } from "./plugin-package.js";
3
3
  /**
4
4
  * The marketplace manifest `marketplace.json` (package ecosystem spec §5.2): a list of plugins,
5
5
  * each with one source. One entry is one version. `capybara-plugin marketplace --write` lists one
@@ -29,6 +29,8 @@ export const MARKETPLACE_LIMITS = {
29
29
  plugins: 5000,
30
30
  permissionKeys: 500,
31
31
  urlChars: 2000,
32
+ /** Bytes of an entry icon (the plugin icon limit, {@link CONNECTOR_TEMPLATE_LIMITS}). */
33
+ iconBytes: CONNECTOR_TEMPLATE_LIMITS.iconBytes,
32
34
  };
33
35
  const M = MARKETPLACE_LIMITS;
34
36
  const MAX_ERRORS = 50;
@@ -37,6 +39,13 @@ const PERMISSION_KEY_RE = /^[a-z][a-z.]{0,62}(?::[^\r\n]{1,256})?$/;
37
39
  const SHA256_INTEGRITY_RE = /^sha256-[A-Za-z0-9+/]{43}=$/;
38
40
  const COMMIT_RE = /^[0-9a-f]{40}$/;
39
41
  const HTTPS_URL_RE = /^https:\/\/\S+$/;
42
+ /** An entry icon's file extension (of the URL's path, or of the repository path). */
43
+ export const MARKETPLACE_ICON_RE = /\.(?:svg|png)$/i;
44
+ // Spelled without the `i` flag so the generated JSON Schema keeps the rule
45
+ const ICON_EXT = "\\.(?:[sS][vV][gG]|[pP][nN][gG])";
46
+ const ICON_URL_RE = new RegExp(`^https://[^\\s?#]+${ICON_EXT}(?:\\?[^\\s#]*)?$`);
47
+ const ICON_URL_OR_LOOPBACK_RE = new RegExp(`^(?:https://[^\\s?#]+|http://(?:127\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|localhost|\\[::1\\])(?::\\d{1,5})?/[^\\s?#]*)${ICON_EXT}(?:\\?[^\\s#]*)?$`);
48
+ const ICON_PATH_RE = new RegExp(`^\\./[^\\s?#]+${ICON_EXT}$`);
40
49
  const HTTPS_OR_LOOPBACK_RE = /^(?:https:\/\/\S+|http:\/\/(?:127\.\d{1,3}\.\d{1,3}\.\d{1,3}|localhost|\[::1\])(?::\d{1,5})?(?:\/\S*)?)$/;
41
50
  const urlString = (re) => z
42
51
  .string()
@@ -55,6 +64,15 @@ const exactVersion = (what) => z
55
64
  .string()
56
65
  .max(256)
57
66
  .regex(SEMVER_VERSION_RE, { message: `${what} must be an exact semver version` });
67
+ /** The path of a URL (empty when it does not parse). */
68
+ const urlPathname = (s) => {
69
+ try {
70
+ return new URL(s).pathname;
71
+ }
72
+ catch {
73
+ return "";
74
+ }
75
+ };
58
76
  /** `./<safe path>` inside the marketplace repo. */
59
77
  const repoPath = z
60
78
  .string()
@@ -144,6 +162,12 @@ function buildSchema(opts) {
144
162
  })
145
163
  .optional(),
146
164
  homepage: urlString(HTTPS_URL_RE).optional(),
165
+ icon: z
166
+ .union([
167
+ urlString(opts.allowInsecureLoopback ? ICON_URL_OR_LOOPBACK_RE : ICON_URL_RE).refine((s) => MARKETPLACE_ICON_RE.test(urlPathname(s)), { message: "an icon URL's path must end in .svg or .png" }),
168
+ repoPath.regex(ICON_PATH_RE, { message: "icon must be a .svg or .png file" }),
169
+ ])
170
+ .optional(),
147
171
  deprecated: z
148
172
  .union([
149
173
  z.boolean(),
@@ -248,6 +272,14 @@ const SOURCE_FORMS = ["path", "git", "npm", "archive"];
248
272
  * form the source uses instead, or that it must use exactly one form.
249
273
  */
250
274
  function explainSource(json, issue) {
275
+ if (issue.code === "invalid_union" && issue.path[issue.path.length - 1] === "icon") {
276
+ return [
277
+ {
278
+ path: issue.path,
279
+ message: 'icon must be an https URL whose path ends in .svg or .png, or a "./" path inside the repository ending in .svg or .png',
280
+ },
281
+ ];
282
+ }
251
283
  if (issue.code !== "invalid_union" || issue.path[issue.path.length - 1] !== "source") {
252
284
  return [issue];
253
285
  }
@@ -272,6 +304,16 @@ export function marketplaceEntryVersion(e) {
272
304
  return e.source.npm.version;
273
305
  return null;
274
306
  }
307
+ /**
308
+ * The entry's icon (see {@link MarketplaceEntry.icon}): `{ url }` for an absolute URL, `{ path }`
309
+ * (normalized, without the leading `./`) for a path inside the marketplace's repository, `null`
310
+ * without one.
311
+ */
312
+ export function marketplaceEntryIcon(e) {
313
+ if (e.icon === undefined)
314
+ return null;
315
+ return e.icon.startsWith("./") ? { path: e.icon.slice(2) } : { url: e.icon };
316
+ }
275
317
  /**
276
318
  * The entry's deprecation (see {@link MarketplaceEntry.deprecated}): `null` when it is not
277
319
  * deprecated (absent or `false`), else its reason (`null` without one).
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { type JsonSchema } from "./config-ui.js";
3
3
  import { type ConnectorTemplateSpec, type LocalizedName, type StaticExtensionManifest } from "./plugin-package.js";
4
+ import { type PluginSetupGuide } from "./setup-guide.js";
4
5
  /**
5
6
  * The plugin manifest `.capybara-plugin/plugin.json` (package ecosystem spec §4.1). A plugin is a
6
7
  * bundle of at most one extension, skills and connector templates, packed as an npm-pack tgz:
@@ -123,6 +124,11 @@ export interface PluginManifest {
123
124
  connectorTemplates?: PluginConnectorTemplateRef[];
124
125
  /** Agent templates; omitted = scan `agents/*.md`. */
125
126
  agents?: PluginAgentRef[];
127
+ /**
128
+ * Setup guides (spec `2026-11-08-plugin-setup-guides-design.md`): steps and template files the
129
+ * kernel renders for a place (`channel-connection`); see {@link PluginSetupGuide}.
130
+ */
131
+ setupGuides?: PluginSetupGuide[];
126
132
  extension?: PluginExtensionSection;
127
133
  }
128
134
  /** A connector template file `connectors/<id>.json`; the template id is the file's base name. */
@@ -131,6 +137,12 @@ export interface ConnectorTemplateFile {
131
137
  description?: string;
132
138
  /** Safe path relative to the plugin root (an optional leading `./`), .svg / .png. */
133
139
  icon?: string;
140
+ /**
141
+ * `false`: the template starts switched off in every org, even where its plugin is on, until an
142
+ * org admin switches it on (it then needs no org input while off). Default `true`: on wherever
143
+ * its plugin is. Ignored for a builtin plugin.
144
+ */
145
+ defaultEnabled?: boolean;
134
146
  /** Validated by {@link validateConnectorTemplate}. */
135
147
  template: ConnectorTemplateSpec;
136
148
  }
@@ -5,6 +5,7 @@ import { CONFIG_UI_LIMITS } from "./config-ui.js";
5
5
  import { defineExtension, EXTENSION_NAME_RE } from "./define-extension.js";
6
6
  import { CATALOG_ITEM_NAME_RE, CONNECTOR_TEMPLATE_LIMITS, isSafePackagePath, SEMVER_VERSION_RE, validateConnectorTemplate, } from "./plugin-package.js";
7
7
  import { hasUpperBound } from "./semver-range.js";
8
+ import { SETUP_GUIDE_FILE_RE, SETUP_GUIDE_LANGUAGES, SETUP_GUIDE_LIMITS, SETUP_GUIDE_MODES, SETUP_GUIDE_PLACES, } from "./setup-guide.js";
8
9
  /**
9
10
  * The plugin manifest `.capybara-plugin/plugin.json` (package ecosystem spec §4.1). A plugin is a
10
11
  * bundle of at most one extension, skills and connector templates, packed as an npm-pack tgz:
@@ -97,6 +98,33 @@ const configSchemaJson = z
97
98
  .refine((v) => new TextEncoder().encode(JSON.stringify(v)).length <= CONFIG_UI_LIMITS.configSchemaBytes, {
98
99
  message: `must be at most ${CONFIG_UI_LIMITS.configSchemaBytes} bytes of JSON (${CONFIG_UI_LIMITS.configSchemaBytes / 1024} KiB)`,
99
100
  });
101
+ const guideText = (max, what) => z.strictObject({ en: text(max, `${what}.en`), zh: text(max, `${what}.zh`).optional() });
102
+ const setupGuide = z.strictObject({
103
+ id: z.string().regex(CATALOG_ITEM_NAME_RE, {
104
+ message: "id must match ^[a-z0-9][a-z0-9-]{0,63}$",
105
+ }),
106
+ place: z.enum(SETUP_GUIDE_PLACES),
107
+ title: guideText(SETUP_GUIDE_LIMITS.titleChars, "title"),
108
+ steps: z
109
+ .array(z.strictObject({
110
+ en: text(SETUP_GUIDE_LIMITS.stepChars, "en"),
111
+ zh: text(SETUP_GUIDE_LIMITS.stepChars, "zh").optional(),
112
+ mode: z.string().max(64).optional(),
113
+ }))
114
+ .max(SETUP_GUIDE_LIMITS.steps)
115
+ .optional(),
116
+ files: z
117
+ .array(z.strictObject({
118
+ path: dotPath("path").regex(SETUP_GUIDE_FILE_RE, {
119
+ message: "path must be a .tmpl, .yaml, .yml, .json or .txt file",
120
+ }),
121
+ title: guideText(SETUP_GUIDE_LIMITS.titleChars, "title"),
122
+ language: z.enum(SETUP_GUIDE_LANGUAGES),
123
+ mode: z.string().max(64).optional(),
124
+ }))
125
+ .max(SETUP_GUIDE_LIMITS.files)
126
+ .optional(),
127
+ });
100
128
  const extensionSection = z.strictObject({
101
129
  entry: dotPath("entry").refine((p) => p.endsWith(".mjs"), {
102
130
  message: "entry must be a .mjs file",
@@ -168,6 +196,7 @@ const schema = z
168
196
  }))
169
197
  .max(L.agents)
170
198
  .optional(),
199
+ setupGuides: z.array(setupGuide).max(SETUP_GUIDE_LIMITS.guides).optional(),
171
200
  extension: extensionSection.optional(),
172
201
  })
173
202
  .superRefine((m, ctx) => {
@@ -206,6 +235,37 @@ const schema = z
206
235
  issue(["agents", i, "path"], `duplicate agent template path ${a.path}`);
207
236
  agentPaths.add(a.path);
208
237
  });
238
+ const guideIds = new Set();
239
+ m.setupGuides?.forEach((g, i) => {
240
+ if (guideIds.has(g.id))
241
+ issue(["setupGuides", i, "id"], `duplicate setup guide id ${g.id}`);
242
+ guideIds.add(g.id);
243
+ if ((g.steps?.length ?? 0) === 0 && (g.files?.length ?? 0) === 0) {
244
+ issue(["setupGuides", i], "a setup guide needs at least one step or file");
245
+ }
246
+ const modes = SETUP_GUIDE_MODES[g.place];
247
+ const badMode = (mode, path) => {
248
+ if (mode !== undefined && !modes.includes(mode)) {
249
+ issue(path, `mode must be one of ${modes.join(", ")} for place ${g.place}`);
250
+ }
251
+ };
252
+ g.steps?.forEach((s, j) => {
253
+ badMode(s.mode, ["setupGuides", i, "steps", j, "mode"]);
254
+ });
255
+ const paths = new Set();
256
+ g.files?.forEach((f, j) => {
257
+ badMode(f.mode, ["setupGuides", i, "files", j, "mode"]);
258
+ if (paths.has(f.path)) {
259
+ issue(["setupGuides", i, "files", j, "path"], `duplicate file ${f.path} in the guide`);
260
+ }
261
+ paths.add(f.path);
262
+ });
263
+ if (g.place === "channel-connection" &&
264
+ m.extension !== undefined &&
265
+ !m.extension.manifest.provides.some((p) => p.startsWith("channel@"))) {
266
+ issue(["setupGuides", i, "place"], "a channel-connection guide needs the plugin's extension to provide channel@1");
267
+ }
268
+ });
209
269
  if (m.extension === undefined &&
210
270
  m.skills?.length === 0 &&
211
271
  m.connectorTemplates?.length === 0 &&
@@ -281,6 +341,7 @@ const templateFile = z
281
341
  message: "icon must be a relative .svg or .png path inside the plugin",
282
342
  })
283
343
  .optional(),
344
+ defaultEnabled: z.boolean().optional(),
284
345
  template,
285
346
  })
286
347
  // Field rules (no credentials, auth presets, editableEndpoint, ...) belong to validateConnectorTemplate.
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Plugin setup guides (spec `2026-11-08-plugin-setup-guides-design.md`): setup material a plugin
3
+ * ships (steps and template files such as Slack's app manifest) that the kernel renders with a
4
+ * fixed set of safe, deterministic variables and the web shows where the person needs it. The
5
+ * format lives in `plugin.json` `setupGuides` ({@link PluginSetupGuide}); this module holds the
6
+ * places, their modes and variables, the limits and the pure renderer.
7
+ *
8
+ * Placeholders are `{{name}}` (spaces inside the braces allowed) with `name` matching
9
+ * `[A-Za-z][A-Za-z0-9]*`. There is no other syntax: no conditionals, loops, filters or escapes. A
10
+ * placeholder the place does not define, or one without a value, stays as written.
11
+ */
12
+ /** Where a guide is shown. `channel-connection`: 新建渠道 and the channel detail page. */
13
+ export declare const SETUP_GUIDE_PLACES: readonly ["channel-connection"];
14
+ export type SetupGuidePlace = (typeof SETUP_GUIDE_PLACES)[number];
15
+ /**
16
+ * The modes of each place: a step or file with `mode` applies to that mode only.
17
+ * `channel-connection`: the connection's `inboundMode`.
18
+ */
19
+ export declare const SETUP_GUIDE_MODES: Readonly<Record<SetupGuidePlace, readonly string[]>>;
20
+ /**
21
+ * The variables of each place. None is a secret: the kernel fills them from public URLs, names and
22
+ * versions only (`publicUrl`, `webhookUrl`, `signInCallbackUrl`, `connectionName`, `orgName`,
23
+ * `pluginName`, `pluginVersion`, `mode`).
24
+ */
25
+ export declare const SETUP_GUIDE_VARIABLES: Readonly<Record<SetupGuidePlace, readonly string[]>>;
26
+ /** Languages of a template file: the code block's label and how values are escaped. */
27
+ export declare const SETUP_GUIDE_LANGUAGES: readonly ["yaml", "json", "text"];
28
+ export type SetupGuideLanguage = (typeof SETUP_GUIDE_LANGUAGES)[number];
29
+ /** Limits of setup guides. */
30
+ export declare const SETUP_GUIDE_LIMITS: {
31
+ /** Guides per plugin. */
32
+ readonly guides: 8;
33
+ /** Steps per guide. */
34
+ readonly steps: 20;
35
+ /** Files per guide. */
36
+ readonly files: 8;
37
+ /** Characters of a step (each language). */
38
+ readonly stepChars: 2000;
39
+ /** Characters of a title (each language). */
40
+ readonly titleChars: 100;
41
+ /** Bytes of a template file. */
42
+ readonly fileBytes: number;
43
+ };
44
+ /** File names a template file may have (checked on the path). */
45
+ export declare const SETUP_GUIDE_FILE_RE: RegExp;
46
+ /** Localized text: English, optional Chinese (falls back to English). */
47
+ export interface SetupGuideText {
48
+ en: string;
49
+ zh?: string;
50
+ }
51
+ /** A step: plain text (a backtick pair is shown as inline code), optionally for one mode. */
52
+ export interface SetupGuideStep extends SetupGuideText {
53
+ mode?: string;
54
+ }
55
+ /** A template file of a guide. */
56
+ export interface SetupGuideFile {
57
+ /** `./…` inside the plugin, `.tmpl` / `.yaml` / `.yml` / `.json` / `.txt`. */
58
+ path: string;
59
+ title: SetupGuideText;
60
+ language: SetupGuideLanguage;
61
+ mode?: string;
62
+ }
63
+ /** A setup guide in `plugin.json` `setupGuides`. */
64
+ export interface PluginSetupGuide {
65
+ /** `^[a-z0-9][a-z0-9-]{0,63}$`, unique in the plugin. */
66
+ id: string;
67
+ place: SetupGuidePlace;
68
+ title: SetupGuideText;
69
+ steps?: SetupGuideStep[];
70
+ files?: SetupGuideFile[];
71
+ }
72
+ /** The placeholder names a text uses, each once, in order of first use. */
73
+ export declare function setupGuidePlaceholders(text: string): string[];
74
+ /** The placeholders of a text that the place does not define (the toolchain refuses them). */
75
+ export declare function unknownSetupGuidePlaceholders(text: string, place: SetupGuidePlace): string[];
76
+ /** The modes a guide's steps and files use, in the place's order (empty: no mode switch). */
77
+ export declare function setupGuideModes(guide: PluginSetupGuide): string[];
78
+ /**
79
+ * Renders a guide text: each `{{name}}` with a value in `values` is replaced by it, escaped for
80
+ * `language` (`yaml` / `json`: JSON-string-escaped without quotes, so a placeholder inside double
81
+ * quotes stays one string; `text`: control characters become spaces). A placeholder whose value is
82
+ * null or undefined stays as written and is listed in `unresolved` when `known` holds it; one not
83
+ * in `known` stays as written and is not listed. Never runs code; deterministic.
84
+ */
85
+ export declare function renderSetupGuideText(text: string, values: Readonly<Record<string, string | null | undefined>>, language: SetupGuideLanguage, known?: readonly string[]): {
86
+ text: string;
87
+ unresolved: string[];
88
+ };
89
+ /**
90
+ * Whether a guide's template file is usable: present, at most {@link SETUP_GUIDE_LIMITS}`.fileBytes`,
91
+ * valid UTF-8 text without NUL. The problem (English, naming the path), or undefined when it is.
92
+ * Shared by the packer and the kernel's package reader.
93
+ */
94
+ export declare function setupGuideFileProblem(path: string, data: Uint8Array | undefined): string | undefined;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Plugin setup guides (spec `2026-11-08-plugin-setup-guides-design.md`): setup material a plugin
3
+ * ships (steps and template files such as Slack's app manifest) that the kernel renders with a
4
+ * fixed set of safe, deterministic variables and the web shows where the person needs it. The
5
+ * format lives in `plugin.json` `setupGuides` ({@link PluginSetupGuide}); this module holds the
6
+ * places, their modes and variables, the limits and the pure renderer.
7
+ *
8
+ * Placeholders are `{{name}}` (spaces inside the braces allowed) with `name` matching
9
+ * `[A-Za-z][A-Za-z0-9]*`. There is no other syntax: no conditionals, loops, filters or escapes. A
10
+ * placeholder the place does not define, or one without a value, stays as written.
11
+ */
12
+ /** Where a guide is shown. `channel-connection`: 新建渠道 and the channel detail page. */
13
+ export const SETUP_GUIDE_PLACES = ["channel-connection"];
14
+ /**
15
+ * The modes of each place: a step or file with `mode` applies to that mode only.
16
+ * `channel-connection`: the connection's `inboundMode`.
17
+ */
18
+ export const SETUP_GUIDE_MODES = {
19
+ "channel-connection": ["long_connection", "webhook"],
20
+ };
21
+ /**
22
+ * The variables of each place. None is a secret: the kernel fills them from public URLs, names and
23
+ * versions only (`publicUrl`, `webhookUrl`, `signInCallbackUrl`, `connectionName`, `orgName`,
24
+ * `pluginName`, `pluginVersion`, `mode`).
25
+ */
26
+ export const SETUP_GUIDE_VARIABLES = {
27
+ "channel-connection": [
28
+ "publicUrl",
29
+ "webhookUrl",
30
+ "signInCallbackUrl",
31
+ "connectionName",
32
+ "orgName",
33
+ "pluginName",
34
+ "pluginVersion",
35
+ "mode",
36
+ ],
37
+ };
38
+ /** Languages of a template file: the code block's label and how values are escaped. */
39
+ export const SETUP_GUIDE_LANGUAGES = ["yaml", "json", "text"];
40
+ /** Limits of setup guides. */
41
+ export const SETUP_GUIDE_LIMITS = {
42
+ /** Guides per plugin. */
43
+ guides: 8,
44
+ /** Steps per guide. */
45
+ steps: 20,
46
+ /** Files per guide. */
47
+ files: 8,
48
+ /** Characters of a step (each language). */
49
+ stepChars: 2000,
50
+ /** Characters of a title (each language). */
51
+ titleChars: 100,
52
+ /** Bytes of a template file. */
53
+ fileBytes: 64 * 1024,
54
+ };
55
+ /** File names a template file may have (checked on the path). */
56
+ export const SETUP_GUIDE_FILE_RE = /\.(?:tmpl|ya?ml|json|txt)$/i;
57
+ const PLACEHOLDER_RE = /\{\{\s*([A-Za-z][A-Za-z0-9]*)\s*\}\}/g;
58
+ /** The placeholder names a text uses, each once, in order of first use. */
59
+ export function setupGuidePlaceholders(text) {
60
+ const out = [];
61
+ for (const m of text.matchAll(PLACEHOLDER_RE)) {
62
+ const name = m[1];
63
+ if (!out.includes(name))
64
+ out.push(name);
65
+ }
66
+ return out;
67
+ }
68
+ /** The placeholders of a text that the place does not define (the toolchain refuses them). */
69
+ export function unknownSetupGuidePlaceholders(text, place) {
70
+ const known = SETUP_GUIDE_VARIABLES[place];
71
+ return setupGuidePlaceholders(text).filter((n) => !known.includes(n));
72
+ }
73
+ /** The modes a guide's steps and files use, in the place's order (empty: no mode switch). */
74
+ export function setupGuideModes(guide) {
75
+ const used = new Set();
76
+ for (const s of guide.steps ?? [])
77
+ if (s.mode !== undefined)
78
+ used.add(s.mode);
79
+ for (const f of guide.files ?? [])
80
+ if (f.mode !== undefined)
81
+ used.add(f.mode);
82
+ return SETUP_GUIDE_MODES[guide.place].filter((m) => used.has(m));
83
+ }
84
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what is replaced
85
+ const CONTROL_RE = /[\u0000-\u001f\u007f]/g;
86
+ /** A value as inserted into a text of the language (see {@link renderSetupGuideText}). */
87
+ function escapeValue(value, language) {
88
+ if (language === "text")
89
+ return value.replace(CONTROL_RE, " ");
90
+ // JSON string escaping without the quotes: valid inside a JSON string and a YAML double-quoted
91
+ // scalar, whatever the value holds
92
+ return JSON.stringify(value).slice(1, -1);
93
+ }
94
+ /**
95
+ * Renders a guide text: each `{{name}}` with a value in `values` is replaced by it, escaped for
96
+ * `language` (`yaml` / `json`: JSON-string-escaped without quotes, so a placeholder inside double
97
+ * quotes stays one string; `text`: control characters become spaces). A placeholder whose value is
98
+ * null or undefined stays as written and is listed in `unresolved` when `known` holds it; one not
99
+ * in `known` stays as written and is not listed. Never runs code; deterministic.
100
+ */
101
+ export function renderSetupGuideText(text, values, language, known = Object.keys(values)) {
102
+ const unresolved = [];
103
+ const out = text.replace(PLACEHOLDER_RE, (whole, name) => {
104
+ if (!known.includes(name))
105
+ return whole;
106
+ const v = Object.hasOwn(values, name) ? values[name] : undefined;
107
+ if (v === null || v === undefined) {
108
+ if (!unresolved.includes(name))
109
+ unresolved.push(name);
110
+ return whole;
111
+ }
112
+ return escapeValue(v, language);
113
+ });
114
+ return { text: out, unresolved };
115
+ }
116
+ /**
117
+ * Whether a guide's template file is usable: present, at most {@link SETUP_GUIDE_LIMITS}`.fileBytes`,
118
+ * valid UTF-8 text without NUL. The problem (English, naming the path), or undefined when it is.
119
+ * Shared by the packer and the kernel's package reader.
120
+ */
121
+ export function setupGuideFileProblem(path, data) {
122
+ if (data === undefined)
123
+ return `${path} not found in the package`;
124
+ if (data.byteLength > SETUP_GUIDE_LIMITS.fileBytes) {
125
+ return `${path} is larger than ${SETUP_GUIDE_LIMITS.fileBytes} bytes`;
126
+ }
127
+ let text;
128
+ try {
129
+ text = new TextDecoder("utf-8", { fatal: true }).decode(data);
130
+ }
131
+ catch {
132
+ return `${path} is not valid UTF-8 text`;
133
+ }
134
+ return text.includes("\u0000") ? `${path} is not a text file (it contains NUL)` : undefined;
135
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capybara-hq/plugin-sdk",
3
- "version": "0.1.0-rc.6",
3
+ "version": "0.1.0-rc.7",
4
4
  "description": "Capybara plugin SDK: the types and pure functions plugins are written against",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -36,6 +36,9 @@
36
36
  "type": "string",
37
37
  "maxLength": 512
38
38
  },
39
+ "defaultEnabled": {
40
+ "type": "boolean"
41
+ },
39
42
  "template": {
40
43
  "type": "object",
41
44
  "properties": {
@@ -129,6 +129,20 @@
129
129
  "maxLength": 2000,
130
130
  "pattern": "^https:\\/\\/\\S+$"
131
131
  },
132
+ "icon": {
133
+ "anyOf": [
134
+ {
135
+ "type": "string",
136
+ "maxLength": 2000,
137
+ "pattern": "^https:\\/\\/[^\\s?#]+\\.(?:[sS][vV][gG]|[pP][nN][gG])(?:\\?[^\\s#]*)?$"
138
+ },
139
+ {
140
+ "type": "string",
141
+ "maxLength": 512,
142
+ "pattern": "^\\.\\/[^\\s?#]+\\.(?:[sS][vV][gG]|[pP][nN][gG])$"
143
+ }
144
+ ]
145
+ },
132
146
  "deprecated": {
133
147
  "anyOf": [
134
148
  {
@@ -194,6 +194,142 @@
194
194
  "additionalProperties": false
195
195
  }
196
196
  },
197
+ "setupGuides": {
198
+ "maxItems": 8,
199
+ "type": "array",
200
+ "items": {
201
+ "type": "object",
202
+ "properties": {
203
+ "id": {
204
+ "type": "string",
205
+ "pattern": "^[a-z0-9][a-z0-9-]{0,63}$"
206
+ },
207
+ "place": {
208
+ "type": "string",
209
+ "enum": [
210
+ "channel-connection"
211
+ ]
212
+ },
213
+ "title": {
214
+ "type": "object",
215
+ "properties": {
216
+ "en": {
217
+ "type": "string",
218
+ "minLength": 1,
219
+ "maxLength": 100,
220
+ "pattern": "\\S"
221
+ },
222
+ "zh": {
223
+ "type": "string",
224
+ "minLength": 1,
225
+ "maxLength": 100,
226
+ "pattern": "\\S"
227
+ }
228
+ },
229
+ "required": [
230
+ "en"
231
+ ],
232
+ "additionalProperties": false
233
+ },
234
+ "steps": {
235
+ "maxItems": 20,
236
+ "type": "array",
237
+ "items": {
238
+ "type": "object",
239
+ "properties": {
240
+ "en": {
241
+ "type": "string",
242
+ "minLength": 1,
243
+ "maxLength": 2000,
244
+ "pattern": "\\S"
245
+ },
246
+ "zh": {
247
+ "type": "string",
248
+ "minLength": 1,
249
+ "maxLength": 2000,
250
+ "pattern": "\\S"
251
+ },
252
+ "mode": {
253
+ "type": "string",
254
+ "maxLength": 64
255
+ }
256
+ },
257
+ "required": [
258
+ "en"
259
+ ],
260
+ "additionalProperties": false
261
+ }
262
+ },
263
+ "files": {
264
+ "maxItems": 8,
265
+ "type": "array",
266
+ "items": {
267
+ "type": "object",
268
+ "properties": {
269
+ "path": {
270
+ "type": "string",
271
+ "minLength": 3,
272
+ "maxLength": 512,
273
+ "allOf": [
274
+ {
275
+ "pattern": "^\\.\\/"
276
+ },
277
+ {
278
+ "pattern": "\\.(?:tmpl|ya?ml|json|txt)$"
279
+ }
280
+ ]
281
+ },
282
+ "title": {
283
+ "type": "object",
284
+ "properties": {
285
+ "en": {
286
+ "type": "string",
287
+ "minLength": 1,
288
+ "maxLength": 100,
289
+ "pattern": "\\S"
290
+ },
291
+ "zh": {
292
+ "type": "string",
293
+ "minLength": 1,
294
+ "maxLength": 100,
295
+ "pattern": "\\S"
296
+ }
297
+ },
298
+ "required": [
299
+ "en"
300
+ ],
301
+ "additionalProperties": false
302
+ },
303
+ "language": {
304
+ "type": "string",
305
+ "enum": [
306
+ "yaml",
307
+ "json",
308
+ "text"
309
+ ]
310
+ },
311
+ "mode": {
312
+ "type": "string",
313
+ "maxLength": 64
314
+ }
315
+ },
316
+ "required": [
317
+ "path",
318
+ "title",
319
+ "language"
320
+ ],
321
+ "additionalProperties": false
322
+ }
323
+ }
324
+ },
325
+ "required": [
326
+ "id",
327
+ "place",
328
+ "title"
329
+ ],
330
+ "additionalProperties": false
331
+ }
332
+ },
197
333
  "extension": {
198
334
  "type": "object",
199
335
  "properties": {