@capybara-hq/plugin-sdk 0.0.0-stage → 0.1.0-rc.0
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/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +27 -2
- package/dist/agent-template.d.ts +47 -0
- package/dist/agent-template.js +123 -0
- package/dist/bootstrap.d.ts +67 -0
- package/dist/bootstrap.js +109 -0
- package/dist/cards.d.ts +64 -0
- package/dist/cards.js +217 -0
- package/dist/channel.d.ts +230 -0
- package/dist/channel.js +26 -0
- package/dist/connector-auth.d.ts +64 -0
- package/dist/connector-auth.js +96 -0
- package/dist/connector.d.ts +154 -0
- package/dist/connector.js +1 -0
- package/dist/ctx.d.ts +176 -0
- package/dist/ctx.js +1 -0
- package/dist/define-extension.d.ts +41 -0
- package/dist/define-extension.js +85 -0
- package/dist/directory.d.ts +29 -0
- package/dist/directory.js +1 -0
- package/dist/extension-point.d.ts +29 -0
- package/dist/extension-point.js +36 -0
- package/dist/file-preview.d.ts +64 -0
- package/dist/file-preview.js +72 -0
- package/dist/frontmatter.d.ts +12 -0
- package/dist/frontmatter.js +36 -0
- package/dist/hooks.d.ts +194 -0
- package/dist/hooks.js +14 -0
- package/dist/identity.d.ts +44 -0
- package/dist/identity.js +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +27 -0
- package/dist/marketplace.d.ts +99 -0
- package/dist/marketplace.js +256 -0
- package/dist/model-params.d.ts +82 -0
- package/dist/model-params.js +222 -0
- package/dist/model.d.ts +227 -0
- package/dist/model.js +152 -0
- package/dist/permissions.d.ts +67 -0
- package/dist/permissions.js +271 -0
- package/dist/plugin-manifest.d.ts +141 -0
- package/dist/plugin-manifest.js +322 -0
- package/dist/plugin-package.d.ts +139 -0
- package/dist/plugin-package.js +448 -0
- package/dist/sandbox.d.ts +152 -0
- package/dist/sandbox.js +96 -0
- package/dist/secret.d.ts +5 -0
- package/dist/secret.js +8 -0
- package/dist/semver-range.d.ts +6 -0
- package/dist/semver-range.js +12 -0
- package/dist/skill-invocation.d.ts +83 -0
- package/dist/skill-invocation.js +201 -0
- package/dist/skill-keys.d.ts +9 -0
- package/dist/skill-keys.js +16 -0
- package/dist/skill.d.ts +77 -0
- package/dist/skill.js +244 -0
- package/dist/storage.d.ts +96 -0
- package/dist/storage.js +9 -0
- package/dist/tool.d.ts +154 -0
- package/dist/tool.js +218 -0
- package/dist/ui.d.ts +265 -0
- package/dist/ui.js +358 -0
- package/dist/url.d.ts +2 -0
- package/dist/url.js +12 -0
- package/package.json +40 -3
- package/schemas/bootstrap.v1.schema.json +125 -0
- package/schemas/connector-template.v1.schema.json +116 -0
- package/schemas/marketplace.v1.schema.json +270 -0
- package/schemas/plugin.v1.schema.json +330 -0
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox contract `sandbox@1`: a single-select, brokered extension point (extensions may not
|
|
3
|
+
* `requires` it; the kernel's `SandboxService` consumes it). The provider implements only the
|
|
4
|
+
* primitives below; the kernel builds `glob` / `grep` / `edit` from `exec` plus `readFile` /
|
|
5
|
+
* `writeFile`.
|
|
6
|
+
*/
|
|
7
|
+
/** Sandbox extension point ref. */
|
|
8
|
+
export const SANDBOX_EXTENSION_POINT = "sandbox@1";
|
|
9
|
+
/** 沙箱内允许模型访问的根目录。 */
|
|
10
|
+
export const SANDBOX_ROOTS = ["/workspace", "/skills", "/tmp"];
|
|
11
|
+
const MAX_PATH_LENGTH = 4096;
|
|
12
|
+
/**
|
|
13
|
+
* 实例在提供方那边确定已经不在了(容器被删、E2B 沙箱过期)。`exec` / `readFile` / `writeFile` / `stat`
|
|
14
|
+
* 发现实例不在时必须抛它(而不是普通 Error),内核据此重建沙箱并重试一次;只是暂时连不上时抛普通错误。
|
|
15
|
+
*/
|
|
16
|
+
export class SandboxGoneError extends Error {
|
|
17
|
+
sandboxGone = true;
|
|
18
|
+
constructor(message, opts) {
|
|
19
|
+
super(message, opts && "cause" in opts ? { cause: opts.cause } : undefined);
|
|
20
|
+
this.name = "SandboxGoneError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** 鸭子类型判断(不用 instanceof),兼容插件自带另一份 plugin-sdk 副本的情况。 */
|
|
24
|
+
export function isSandboxGoneError(e) {
|
|
25
|
+
return (e instanceof Error &&
|
|
26
|
+
e.name === "SandboxGoneError" &&
|
|
27
|
+
e.sandboxGone === true);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A holder tag (`exec` argument `tag`, {@link SandboxProvider.killTagged}): the kernel passes its
|
|
31
|
+
* lease tokens (UUIDs). Providers check tags against it before putting them in a command line or a
|
|
32
|
+
* kill pattern.
|
|
33
|
+
*/
|
|
34
|
+
export const SANDBOX_TAG_RE = /^[a-z0-9-]{1,64}$/;
|
|
35
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: 正是要拦控制字符
|
|
36
|
+
const CONTROL = /[\u0000-\u001f\u007f]/;
|
|
37
|
+
/** 校验沙箱内路径:返回错误列表,空数组表示合法。 */
|
|
38
|
+
export function validateSandboxPath(p) {
|
|
39
|
+
if (typeof p !== "string" || p.length === 0)
|
|
40
|
+
return ["path must be a non-empty string"];
|
|
41
|
+
if (p.length > MAX_PATH_LENGTH)
|
|
42
|
+
return [`path must be at most ${MAX_PATH_LENGTH} characters`];
|
|
43
|
+
if (CONTROL.test(p))
|
|
44
|
+
return ["path must not contain control characters"];
|
|
45
|
+
if (p.includes("\\"))
|
|
46
|
+
return ["path must not contain backslashes"];
|
|
47
|
+
if (!p.startsWith("/"))
|
|
48
|
+
return ["path must be absolute"];
|
|
49
|
+
if (p.includes("//"))
|
|
50
|
+
return ["path must not contain empty segments"];
|
|
51
|
+
const errors = [];
|
|
52
|
+
for (const seg of p.split("/")) {
|
|
53
|
+
let decoded = seg;
|
|
54
|
+
try {
|
|
55
|
+
decoded = decodeURIComponent(seg);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// 非法百分号编码按字面处理
|
|
59
|
+
}
|
|
60
|
+
if (seg === ".." || decoded === ".." || decoded.includes("/") || decoded.includes("\\")) {
|
|
61
|
+
errors.push("path must not contain '..' segments");
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!SANDBOX_ROOTS.some((r) => p === r || p.startsWith(`${r}/`))) {
|
|
66
|
+
errors.push(`path must be under ${SANDBOX_ROOTS.join(", ")}`);
|
|
67
|
+
}
|
|
68
|
+
return errors;
|
|
69
|
+
}
|
|
70
|
+
const LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
71
|
+
/** 校验出站白名单主机名:小写、无协议与端口、可选前导 `*.`、IDN 只认 punycode。 */
|
|
72
|
+
export function validateAllowHost(h) {
|
|
73
|
+
if (typeof h !== "string" || h.length === 0)
|
|
74
|
+
return ["host must be a non-empty string"];
|
|
75
|
+
if (h.length > 253)
|
|
76
|
+
return ["host must be at most 253 characters"];
|
|
77
|
+
const name = h.startsWith("*.") ? h.slice(2) : h;
|
|
78
|
+
if (name.length === 0)
|
|
79
|
+
return ["wildcard must be followed by a domain"];
|
|
80
|
+
const labels = name.split(".");
|
|
81
|
+
if (labels.every((l) => LABEL.test(l))) {
|
|
82
|
+
const tld = labels[labels.length - 1];
|
|
83
|
+
if (labels.length < 2)
|
|
84
|
+
return ["host must have at least two labels (no single-label names)"];
|
|
85
|
+
// 末段必须以字母开头:挡住 IPv4 的十进制 / 八进制 / 十六进制写法
|
|
86
|
+
if (!/^[a-z]/.test(tld))
|
|
87
|
+
return ["host must not be an IP address literal"];
|
|
88
|
+
if (["local", "internal", "localhost"].includes(tld)) {
|
|
89
|
+
return ["host must not be in an internal-only domain (.local, .internal, .localhost)"];
|
|
90
|
+
}
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
return [
|
|
94
|
+
"host must be a lowercase hostname without scheme, port or path (optional leading '*.'; IDN as punycode)",
|
|
95
|
+
];
|
|
96
|
+
}
|
package/dist/secret.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
/** 标记一个 schema 为敏感字段(Zod 4 meta `{ secret: true }`)。 */
|
|
3
|
+
export declare function secret<T extends z.ZodTypeAny>(schema: T): T;
|
|
4
|
+
/** 读取 schema 的 meta,判断是否敏感;内核 `meta({secret:true})` 同样可识别。 */
|
|
5
|
+
export declare function isSecret(schema: z.ZodTypeAny): boolean;
|
package/dist/secret.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** 标记一个 schema 为敏感字段(Zod 4 meta `{ secret: true }`)。 */
|
|
2
|
+
export function secret(schema) {
|
|
3
|
+
return schema.meta({ secret: true });
|
|
4
|
+
}
|
|
5
|
+
/** 读取 schema 的 meta,判断是否敏感;内核 `meta({secret:true})` 同样可识别。 */
|
|
6
|
+
export function isSecret(schema) {
|
|
7
|
+
return schema.meta()?.secret === true;
|
|
8
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal helper (not exported from index.ts): whether a semver range has an upper bound, i.e.
|
|
3
|
+
* every comparator set (each `||` branch) has at least one `<` / `<=` or an exact version. Ranges
|
|
4
|
+
* such as `*`, `x` or `>=1.0.0` would treat a future major SDK as compatible.
|
|
5
|
+
*/
|
|
6
|
+
export declare function hasUpperBound(range: string): boolean;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import semver from "semver";
|
|
2
|
+
/**
|
|
3
|
+
* Internal helper (not exported from index.ts): whether a semver range has an upper bound, i.e.
|
|
4
|
+
* every comparator set (each `||` branch) has at least one `<` / `<=` or an exact version. Ranges
|
|
5
|
+
* such as `*`, `x` or `>=1.0.0` would treat a future major SDK as compatible.
|
|
6
|
+
*/
|
|
7
|
+
export function hasUpperBound(range) {
|
|
8
|
+
const r = new semver.Range(range);
|
|
9
|
+
return r.set.every((comparators) => comparators.some((c) => c.operator === "<" ||
|
|
10
|
+
c.operator === "<=" ||
|
|
11
|
+
((c.operator === "" || c.operator === "=") && c.value !== "")));
|
|
12
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** How a skill can be invoked, read from its SKILL.md frontmatter by {@link skillInvocation}. */
|
|
2
|
+
export interface SkillInvocation {
|
|
3
|
+
/** false: `disable-model-invocation: true` (manual-only, left out of the model's catalog). */
|
|
4
|
+
model: boolean;
|
|
5
|
+
/** false: `user-invocable: false` (hidden from the `/` menu). */
|
|
6
|
+
user: boolean;
|
|
7
|
+
/** `context: fork`: runs as a sub-agent when a person invokes it; `agent` as written. */
|
|
8
|
+
fork?: {
|
|
9
|
+
agent?: string;
|
|
10
|
+
};
|
|
11
|
+
/** `argument-hint`, shown after the skill's name in the `/` menu. */
|
|
12
|
+
argumentHint?: string;
|
|
13
|
+
/** `arguments`: the names usable as `$name` in the body, in positional order. */
|
|
14
|
+
arguments?: string[];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The invocation metadata of a SKILL.md frontmatter. Values of the wrong type are ignored here
|
|
18
|
+
* (`parseSkillMd` reports them as errors), so this never throws.
|
|
19
|
+
*/
|
|
20
|
+
export declare function skillInvocation(frontmatter: Readonly<Record<string, unknown>>): SkillInvocation;
|
|
21
|
+
/**
|
|
22
|
+
* Splits an argument string like a shell: whitespace separates values, double and single quotes
|
|
23
|
+
* group (`"hello world"` is one value), a backslash outside single quotes escapes the next
|
|
24
|
+
* character (inside double quotes only `"` and `\`). An unclosed quote runs to the end.
|
|
25
|
+
*/
|
|
26
|
+
export declare function parseSkillArguments(args: string): string[];
|
|
27
|
+
/**
|
|
28
|
+
* A skill body with its arguments substituted (see the file header), without the wrapper.
|
|
29
|
+
* `skillName` is the resolved skill's bare name: `${CLAUDE_SKILL_DIR}` becomes
|
|
30
|
+
* `/skills/<skillName>` (a qualified `<plugin>:<name>` throws a `TypeError`). `args` is trimmed;
|
|
31
|
+
* more than {@link SKILL_INVOCATION_LIMITS.argumentsMaxChars} characters throw a `RangeError`, and
|
|
32
|
+
* so does a result longer than {@link SKILL_INVOCATION_LIMITS.renderedMaxChars} (checked while
|
|
33
|
+
* substituting, before the long string is built).
|
|
34
|
+
*/
|
|
35
|
+
export declare function renderSkillBody(body: string, invocation: SkillInvocation, args: string, skillName: string): string;
|
|
36
|
+
/** Input of {@link renderSkillInvocation}. */
|
|
37
|
+
export interface RenderSkillInvocationInput {
|
|
38
|
+
/**
|
|
39
|
+
* The name the person typed after `/` (`fix-issue`, or `<plugin>:<name>`): used in the lead, the
|
|
40
|
+
* `name="…"` attribute and the `skill_load` argument (where the qualified form resolves).
|
|
41
|
+
*/
|
|
42
|
+
name: string;
|
|
43
|
+
/**
|
|
44
|
+
* The resolved skill's bare name (never `<plugin>:<name>`): its sandbox directory
|
|
45
|
+
* `/skills/<skillName>/`, used for `${CLAUDE_SKILL_DIR}` and the files line.
|
|
46
|
+
*/
|
|
47
|
+
skillName: string;
|
|
48
|
+
version: number;
|
|
49
|
+
/** The SKILL.md body (after the frontmatter). */
|
|
50
|
+
body: string;
|
|
51
|
+
invocation: SkillInvocation;
|
|
52
|
+
/** The text after `/name`. */
|
|
53
|
+
args: string;
|
|
54
|
+
/** Decided by the caller from the agent's tools and the version's manifest. */
|
|
55
|
+
lines: {
|
|
56
|
+
/** The version has files besides SKILL.md: `load` when the agent has `skill_load`. */
|
|
57
|
+
files?: "load" | "unavailable";
|
|
58
|
+
/** The body has shell injections and the agent has `sandbox_bash`. */
|
|
59
|
+
shellHint?: boolean;
|
|
60
|
+
};
|
|
61
|
+
/** false: leave out the lead sentence (a forked skill's task for the sub-agent). Default true. */
|
|
62
|
+
lead?: boolean;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Both `skill_invocation` wrapper tags made inert (`<\/skill_invocation`, `<\skill_invocation`, any
|
|
66
|
+
* case), for text outside the wrapper that quotes a person's input: the inline lead here, and the
|
|
67
|
+
* kernel's fork lead.
|
|
68
|
+
*/
|
|
69
|
+
export declare const escapeSkillInvocationTags: (s: string) => string;
|
|
70
|
+
/**
|
|
71
|
+
* The model-visible text of an inline skill invocation: the lead sentence, the rendered body inside
|
|
72
|
+
* `<skill_invocation name="…" version="…">`, then the optional files and shell lines. A closing
|
|
73
|
+
* tag inside the body is written `<\/skill_invocation` so the wrapper cannot be closed early; in the
|
|
74
|
+
* lead (which quotes the arguments as typed) both tags are made inert (`<\/skill_invocation`,
|
|
75
|
+
* `<\skill_invocation`), and the `name` attribute escapes `&`, `"` and `<`. Throws like
|
|
76
|
+
* {@link renderSkillBody}.
|
|
77
|
+
*/
|
|
78
|
+
export declare function renderSkillInvocation(i: RenderSkillInvocationInput): string;
|
|
79
|
+
/**
|
|
80
|
+
* Whether a skill body has Claude Code shell injections: `` !`command` `` at the start of a line or
|
|
81
|
+
* after whitespace, or a fenced block opened with ` ```! `.
|
|
82
|
+
*/
|
|
83
|
+
export declare function hasShellInjection(body: string): boolean;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// biome-ignore-all lint/suspicious/noTemplateCurlyInString: Claude Code variables such as ${CLAUDE_SKILL_DIR} are plain text here
|
|
2
|
+
/**
|
|
3
|
+
* Manual skill invocation (package ecosystem phase D): a skill's invocation metadata read from its
|
|
4
|
+
* SKILL.md frontmatter, and the pure rendering of `/name args` into the text the model sees.
|
|
5
|
+
*
|
|
6
|
+
* The argument rules follow Claude Code (re-checked 2026-10-27): `$ARGUMENTS` is the whole argument
|
|
7
|
+
* string, `$ARGUMENTS[N]` / `$N` are 0-based positional values (a missing one stays as written),
|
|
8
|
+
* `$name` takes its value from frontmatter `arguments` (a missing one becomes empty), `\$` is a
|
|
9
|
+
* literal `$`, `${CLAUDE_SKILL_DIR}` is `/skills/<name>` and every other `${…}` stays as written.
|
|
10
|
+
* When the body has no argument placeholder and the arguments are not empty, they are appended as
|
|
11
|
+
* `ARGUMENTS: <args>`. Substituted text is never scanned again. Nothing is executed: shell
|
|
12
|
+
* injections (`` !`cmd` ``) stay text, and the model is told how to run them.
|
|
13
|
+
*/
|
|
14
|
+
import { isSkillName, SKILL_INVOCATION_LIMITS, SKILLS_SANDBOX_ROOT } from "./skill.js";
|
|
15
|
+
import { argumentHintText, SKILL_ARGUMENT_NAME_RE } from "./skill-keys.js";
|
|
16
|
+
/**
|
|
17
|
+
* The invocation metadata of a SKILL.md frontmatter. Values of the wrong type are ignored here
|
|
18
|
+
* (`parseSkillMd` reports them as errors), so this never throws.
|
|
19
|
+
*/
|
|
20
|
+
export function skillInvocation(frontmatter) {
|
|
21
|
+
const out = {
|
|
22
|
+
model: frontmatter["disable-model-invocation"] !== true,
|
|
23
|
+
user: frontmatter["user-invocable"] !== false,
|
|
24
|
+
};
|
|
25
|
+
if (frontmatter.context === "fork") {
|
|
26
|
+
const agent = frontmatter.agent;
|
|
27
|
+
out.fork = typeof agent === "string" && agent !== "" ? { agent } : {};
|
|
28
|
+
}
|
|
29
|
+
const hint = argumentHintText(frontmatter["argument-hint"]);
|
|
30
|
+
if (hint !== undefined && hint !== "")
|
|
31
|
+
out.argumentHint = hint;
|
|
32
|
+
const names = frontmatter.arguments;
|
|
33
|
+
if (Array.isArray(names) &&
|
|
34
|
+
names.length > 0 &&
|
|
35
|
+
names.every((n) => typeof n === "string" && SKILL_ARGUMENT_NAME_RE.test(n))) {
|
|
36
|
+
out.arguments = [...names];
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Splits an argument string like a shell: whitespace separates values, double and single quotes
|
|
42
|
+
* group (`"hello world"` is one value), a backslash outside single quotes escapes the next
|
|
43
|
+
* character (inside double quotes only `"` and `\`). An unclosed quote runs to the end.
|
|
44
|
+
*/
|
|
45
|
+
export function parseSkillArguments(args) {
|
|
46
|
+
const out = [];
|
|
47
|
+
let cur = "";
|
|
48
|
+
let has = false;
|
|
49
|
+
let quote = null;
|
|
50
|
+
const s = [...args];
|
|
51
|
+
for (let i = 0; i < s.length; i++) {
|
|
52
|
+
const c = s[i];
|
|
53
|
+
if (quote === "'") {
|
|
54
|
+
if (c === "'")
|
|
55
|
+
quote = null;
|
|
56
|
+
else
|
|
57
|
+
cur += c;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (quote === '"') {
|
|
61
|
+
if (c === '"')
|
|
62
|
+
quote = null;
|
|
63
|
+
else if (c === "\\" && (s[i + 1] === '"' || s[i + 1] === "\\"))
|
|
64
|
+
cur += s[++i];
|
|
65
|
+
else
|
|
66
|
+
cur += c;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (c === "'" || c === '"') {
|
|
70
|
+
quote = c;
|
|
71
|
+
has = true;
|
|
72
|
+
}
|
|
73
|
+
else if (c === "\\" && i + 1 < s.length) {
|
|
74
|
+
cur += s[++i];
|
|
75
|
+
has = true;
|
|
76
|
+
}
|
|
77
|
+
else if (/\s/.test(c)) {
|
|
78
|
+
if (has)
|
|
79
|
+
out.push(cur);
|
|
80
|
+
cur = "";
|
|
81
|
+
has = false;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
cur += c;
|
|
85
|
+
has = true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (has)
|
|
89
|
+
out.push(cur);
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
const PLACEHOLDER_RE = /\\\$|\$ARGUMENTS\[(\d+)\]|\$ARGUMENTS|\$(\d+)|\$([A-Za-z_][A-Za-z0-9_]*)|\$\{CLAUDE_SKILL_DIR\}/g;
|
|
93
|
+
const trimmedArgs = (args) => {
|
|
94
|
+
const t = args.trim();
|
|
95
|
+
if ([...t].length > SKILL_INVOCATION_LIMITS.argumentsMaxChars) {
|
|
96
|
+
throw new RangeError(`skill arguments must be at most ${SKILL_INVOCATION_LIMITS.argumentsMaxChars} characters`);
|
|
97
|
+
}
|
|
98
|
+
return t;
|
|
99
|
+
};
|
|
100
|
+
const checkSkillName = (skillName) => {
|
|
101
|
+
if (!isSkillName(skillName)) {
|
|
102
|
+
throw new TypeError(`skillName must be a bare skill name (its directory under ${SKILLS_SANDBOX_ROOT}), got "${skillName}"`);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const tooLong = () => new RangeError(`the rendered skill must be at most ${SKILL_INVOCATION_LIMITS.renderedMaxChars} characters`);
|
|
106
|
+
/**
|
|
107
|
+
* A skill body with its arguments substituted (see the file header), without the wrapper.
|
|
108
|
+
* `skillName` is the resolved skill's bare name: `${CLAUDE_SKILL_DIR}` becomes
|
|
109
|
+
* `/skills/<skillName>` (a qualified `<plugin>:<name>` throws a `TypeError`). `args` is trimmed;
|
|
110
|
+
* more than {@link SKILL_INVOCATION_LIMITS.argumentsMaxChars} characters throw a `RangeError`, and
|
|
111
|
+
* so does a result longer than {@link SKILL_INVOCATION_LIMITS.renderedMaxChars} (checked while
|
|
112
|
+
* substituting, before the long string is built).
|
|
113
|
+
*/
|
|
114
|
+
export function renderSkillBody(body, invocation, args, skillName) {
|
|
115
|
+
checkSkillName(skillName);
|
|
116
|
+
const all = trimmedArgs(args);
|
|
117
|
+
const values = parseSkillArguments(all);
|
|
118
|
+
const named = invocation.arguments ?? [];
|
|
119
|
+
const max = SKILL_INVOCATION_LIMITS.renderedMaxChars;
|
|
120
|
+
let placeholder = false;
|
|
121
|
+
// Output length so far = body consumed up to the match end, minus the matched text, plus what
|
|
122
|
+
// replaced it.
|
|
123
|
+
let delta = 0;
|
|
124
|
+
const replace = (m, idx, pos, word) => {
|
|
125
|
+
if (m === "\\$")
|
|
126
|
+
return "$";
|
|
127
|
+
if (m === "${CLAUDE_SKILL_DIR}")
|
|
128
|
+
return `${SKILLS_SANDBOX_ROOT}/${skillName}`;
|
|
129
|
+
if (m === "$ARGUMENTS") {
|
|
130
|
+
placeholder = true;
|
|
131
|
+
return all;
|
|
132
|
+
}
|
|
133
|
+
const n = idx ?? pos;
|
|
134
|
+
if (n !== undefined) {
|
|
135
|
+
placeholder = true;
|
|
136
|
+
return values[Number(n)] ?? m;
|
|
137
|
+
}
|
|
138
|
+
const at = word === undefined ? -1 : named.indexOf(word);
|
|
139
|
+
if (at < 0)
|
|
140
|
+
return m;
|
|
141
|
+
placeholder = true;
|
|
142
|
+
return values[at] ?? "";
|
|
143
|
+
};
|
|
144
|
+
const out = body.replace(PLACEHOLDER_RE, (m, idx, pos, word, offset) => {
|
|
145
|
+
const r = replace(m, idx, pos, word);
|
|
146
|
+
delta += r.length - m.length;
|
|
147
|
+
if (offset + m.length + delta > max)
|
|
148
|
+
throw tooLong();
|
|
149
|
+
return r;
|
|
150
|
+
});
|
|
151
|
+
const result = !placeholder && all !== "" ? `${out}\n\nARGUMENTS: ${all}` : out;
|
|
152
|
+
if (result.length > max)
|
|
153
|
+
throw tooLong();
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
const CLOSE_TAG_RE = /<\/(skill_invocation)/gi;
|
|
157
|
+
const OPEN_TAG_RE = /<(skill_invocation)/gi;
|
|
158
|
+
/**
|
|
159
|
+
* Both `skill_invocation` wrapper tags made inert (`<\/skill_invocation`, `<\skill_invocation`, any
|
|
160
|
+
* case), for text outside the wrapper that quotes a person's input: the inline lead here, and the
|
|
161
|
+
* kernel's fork lead.
|
|
162
|
+
*/
|
|
163
|
+
export const escapeSkillInvocationTags = (s) => s.replace(CLOSE_TAG_RE, "<\\/$1").replace(OPEN_TAG_RE, "<\\$1");
|
|
164
|
+
const escapeTags = escapeSkillInvocationTags;
|
|
165
|
+
const escapeAttr = (s) => s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
166
|
+
/**
|
|
167
|
+
* The model-visible text of an inline skill invocation: the lead sentence, the rendered body inside
|
|
168
|
+
* `<skill_invocation name="…" version="…">`, then the optional files and shell lines. A closing
|
|
169
|
+
* tag inside the body is written `<\/skill_invocation` so the wrapper cannot be closed early; in the
|
|
170
|
+
* lead (which quotes the arguments as typed) both tags are made inert (`<\/skill_invocation`,
|
|
171
|
+
* `<\skill_invocation`), and the `name` attribute escapes `&`, `"` and `<`. Throws like
|
|
172
|
+
* {@link renderSkillBody}.
|
|
173
|
+
*/
|
|
174
|
+
export function renderSkillInvocation(i) {
|
|
175
|
+
const args = trimmedArgs(i.args);
|
|
176
|
+
const body = renderSkillBody(i.body, i.invocation, args, i.skillName).replace(CLOSE_TAG_RE, "<\\/$1");
|
|
177
|
+
const parts = [];
|
|
178
|
+
if (i.lead !== false) {
|
|
179
|
+
parts.push(escapeTags(`The user invoked the skill "${i.name}" with /${i.name}${args === "" ? "" : ` ${args}`}. Follow its instructions for this request.`));
|
|
180
|
+
}
|
|
181
|
+
parts.push(`<skill_invocation name="${escapeAttr(i.name)}" version="${i.version}">\n${body}\n</skill_invocation>`);
|
|
182
|
+
if (i.lines.files === "load") {
|
|
183
|
+
parts.push(`Its files are not in the sandbox yet: call skill_load with "${i.name}" to copy them to ${SKILLS_SANDBOX_ROOT}/${i.skillName}/ before running its scripts.`);
|
|
184
|
+
}
|
|
185
|
+
else if (i.lines.files === "unavailable") {
|
|
186
|
+
parts.push("Its other files are not available in this conversation.");
|
|
187
|
+
}
|
|
188
|
+
if (i.lines.shellHint) {
|
|
189
|
+
parts.push("Lines written as !`command` were meant to run before you read this; run those commands with sandbox_bash if you need their output.");
|
|
190
|
+
}
|
|
191
|
+
return parts.join("\n\n");
|
|
192
|
+
}
|
|
193
|
+
const INLINE_SHELL_RE = /(?:^|[ \t])!`[^`\n]+`/m;
|
|
194
|
+
const FENCE_SHELL_RE = /^[ \t]*```!/m;
|
|
195
|
+
/**
|
|
196
|
+
* Whether a skill body has Claude Code shell injections: `` !`command` `` at the start of a line or
|
|
197
|
+
* after whitespace, or a fenced block opened with ` ```! `.
|
|
198
|
+
*/
|
|
199
|
+
export function hasShellInjection(body) {
|
|
200
|
+
return INLINE_SHELL_RE.test(body) || FENCE_SHELL_RE.test(body);
|
|
201
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Helpers shared by `skill.ts` and `skill-invocation.ts`. Not exported from the package. */
|
|
2
|
+
/** A name in a SKILL.md's `arguments` (referenced as `$name` in the body). */
|
|
3
|
+
export declare const SKILL_ARGUMENT_NAME_RE: RegExp;
|
|
4
|
+
/**
|
|
5
|
+
* `argument-hint` as text. Claude Code's docs write it unquoted (`argument-hint: [issue-number]`),
|
|
6
|
+
* which YAML reads as a list: a list of strings and numbers is written back as `[a, b]`.
|
|
7
|
+
* Undefined for any other value.
|
|
8
|
+
*/
|
|
9
|
+
export declare function argumentHintText(v: unknown): string | undefined;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Helpers shared by `skill.ts` and `skill-invocation.ts`. Not exported from the package. */
|
|
2
|
+
/** A name in a SKILL.md's `arguments` (referenced as `$name` in the body). */
|
|
3
|
+
export const SKILL_ARGUMENT_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
|
4
|
+
/**
|
|
5
|
+
* `argument-hint` as text. Claude Code's docs write it unquoted (`argument-hint: [issue-number]`),
|
|
6
|
+
* which YAML reads as a list: a list of strings and numbers is written back as `[a, b]`.
|
|
7
|
+
* Undefined for any other value.
|
|
8
|
+
*/
|
|
9
|
+
export function argumentHintText(v) {
|
|
10
|
+
if (typeof v === "string")
|
|
11
|
+
return v;
|
|
12
|
+
if (Array.isArray(v) && v.every((x) => typeof x === "string" || typeof x === "number")) {
|
|
13
|
+
return `[${v.join(", ")}]`;
|
|
14
|
+
}
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
package/dist/skill.d.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** 技能包的上限。 */
|
|
2
|
+
export declare const SKILL_LIMITS: {
|
|
3
|
+
readonly nameMaxChars: 64;
|
|
4
|
+
readonly descriptionMaxChars: 1024;
|
|
5
|
+
readonly compatibilityMaxChars: 500;
|
|
6
|
+
/** 单个文件解压后的上限(字节)。 */
|
|
7
|
+
readonly fileBytes: number;
|
|
8
|
+
/** 全部文件解压后的总量上限(字节)。 */
|
|
9
|
+
readonly totalBytes: number;
|
|
10
|
+
/** 文件数上限。 */
|
|
11
|
+
readonly maxFiles: 1000;
|
|
12
|
+
/** 包内相对路径的长度上限(字符)。 */
|
|
13
|
+
readonly pathMaxChars: 512;
|
|
14
|
+
/** frontmatter(两条 `---` 之间)的长度上限(字符),超过不做 YAML 解析。 */
|
|
15
|
+
readonly frontmatterMaxChars: number;
|
|
16
|
+
};
|
|
17
|
+
/** Limits of the invocation keys of a SKILL.md (see `SkillInvocation`). */
|
|
18
|
+
export declare const SKILL_INVOCATION_LIMITS: {
|
|
19
|
+
/** `argument-hint`, in characters. */
|
|
20
|
+
readonly argumentHintMaxChars: 200;
|
|
21
|
+
/** Names in `arguments`. */
|
|
22
|
+
readonly argumentNames: 20;
|
|
23
|
+
/** The argument string of one invocation, in characters (after trimming). */
|
|
24
|
+
readonly argumentsMaxChars: 10000;
|
|
25
|
+
/**
|
|
26
|
+
* The rendered body of one invocation (substitutions and the `ARGUMENTS:` line), in UTF-16 code
|
|
27
|
+
* units: placeholders copy the arguments again for every occurrence, so this bounds the output.
|
|
28
|
+
*/
|
|
29
|
+
readonly renderedMaxChars: 200000;
|
|
30
|
+
};
|
|
31
|
+
/** 技能入口文件(包根目录,大小写敏感)。 */
|
|
32
|
+
export declare const SKILL_ENTRY_FILE = "SKILL.md";
|
|
33
|
+
/** 沙箱里放技能的根目录:每个技能在 `/skills/<name>/` 下(只读复制)。 */
|
|
34
|
+
export declare const SKILLS_SANDBOX_ROOT = "/skills";
|
|
35
|
+
/** 是否为合法的技能名(见文件头的规范说明)。 */
|
|
36
|
+
export declare function isSkillName(s: unknown): s is string;
|
|
37
|
+
/** 技能文件在沙箱里的路径:`/skills/<name>/<path>`。 */
|
|
38
|
+
export declare function skillSandboxPath(name: string, path: string): string;
|
|
39
|
+
/** {@link parseSkillMd} 成功时的结果。 */
|
|
40
|
+
export interface ParsedSkillMd {
|
|
41
|
+
name: string;
|
|
42
|
+
/** 去掉首尾空白。 */
|
|
43
|
+
description: string;
|
|
44
|
+
/** frontmatter 之后的 Markdown 正文。 */
|
|
45
|
+
body: string;
|
|
46
|
+
/**
|
|
47
|
+
* The parsed YAML frontmatter mapping: every key, values unchanged (e.g. Claude Code's
|
|
48
|
+
* `disable-model-invocation`, `context`, `allowed-tools`), for readers that translate them.
|
|
49
|
+
*/
|
|
50
|
+
frontmatter: Readonly<Record<string, unknown>>;
|
|
51
|
+
}
|
|
52
|
+
/** Options of {@link parseSkillMd}. */
|
|
53
|
+
export interface ParseSkillMdOptions {
|
|
54
|
+
/**
|
|
55
|
+
* false: do not check the types of the invocation keys (`disable-model-invocation`, `context`,
|
|
56
|
+
* ...). For readers of a SKILL.md stored before those checks existed, which must keep loading;
|
|
57
|
+
* `skillInvocation` ignores values of the wrong type. Default true.
|
|
58
|
+
*/
|
|
59
|
+
invocationKeys?: boolean;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* 解析 SKILL.md:开头是 `---` 行包住的 YAML frontmatter,之后是正文。容忍 BOM 与 CRLF(正文里的 CRLF 换成 LF)。
|
|
63
|
+
* 返回 name / description / 正文,或全部问题(英文,可直接给模型或管理员看)。
|
|
64
|
+
*/
|
|
65
|
+
export declare function parseSkillMd(text: string, options?: ParseSkillMdOptions): ParsedSkillMd | {
|
|
66
|
+
errors: string[];
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* 校验技能包的文件清单(路径相对于包根目录,只含文件、不含目录项):
|
|
70
|
+
* 根目录必须有 `SKILL.md`;单文件 ≤ {@link SKILL_LIMITS.fileBytes};总量 ≤ {@link SKILL_LIMITS.totalBytes};
|
|
71
|
+
* 文件数 ≤ {@link SKILL_LIMITS.maxFiles};路径不能越界(绝对路径、`..`、反斜杠、盘符、控制字符),不能重复
|
|
72
|
+
* (只差大小写也算重复:大小写不敏感的文件系统上会互相覆盖),文件不能与目录同名。返回问题列表,空数组表示合法。
|
|
73
|
+
*/
|
|
74
|
+
export declare function validateSkillPackage(files: {
|
|
75
|
+
path: string;
|
|
76
|
+
size: number;
|
|
77
|
+
}[]): string[];
|