web-skill 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dimbreak
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,194 @@
1
+ # web-skill
2
+
3
+ `web-skill` helps a web app publish task-level automation APIs for agents and browser tooling.
4
+
5
+ From one shared config it can:
6
+
7
+ - register functions under `window._web_skills`
8
+ - generate one `SKILL.md` file per published skill
9
+ - inject `<link rel="web-skill" ...>` tags into the HTML `<head>` through Vite
10
+
11
+ The package is source-first and works best in TypeScript apps that already use Vite and Zod.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install web-skill zod
17
+ ```
18
+
19
+ If you want the Vite plugin in your own project:
20
+
21
+ ```bash
22
+ npm install vite
23
+ ```
24
+
25
+ ## Quick start
26
+
27
+ Create one generator, point it at the app actions you already trust, and install it at startup:
28
+
29
+ ```ts
30
+ import { createWebSkillGenerator } from "web-skill";
31
+ import { z } from "zod";
32
+ import { useProcurementStore } from "./stores/procurement-store";
33
+
34
+ const webSkills = createWebSkillGenerator();
35
+ const procurementStore = useProcurementStore;
36
+
37
+ const procurementSkill = webSkills.newSkill({
38
+ name: "erpProcurement",
39
+ title: "ERP procurement console API for supplier lookup and purchase order preparation",
40
+ description: "Expose ERP procurement actions to browser agents and the dev console.",
41
+ });
42
+
43
+ procurementSkill.addFunction(
44
+ (input) => procurementStore.getState().findSupplierItem(input),
45
+ "findSupplierItem",
46
+ {
47
+ description: "Search supplier catalog items and return normalized matches.",
48
+ inputSchema: z.object({
49
+ supplierId: z.string().min(1).optional(),
50
+ keyword: z.string().min(1).optional(),
51
+ activeOnly: z.boolean().optional(),
52
+ limit: z.number().int().positive().optional(),
53
+ }),
54
+ outputSchema: z.array(
55
+ z.object({
56
+ itemId: z.string(),
57
+ sku: z.string(),
58
+ itemName: z.string(),
59
+ unitCost: z.number().nullable(),
60
+ }),
61
+ ),
62
+ },
63
+ );
64
+
65
+ procurementSkill
66
+ .addFunction(
67
+ (input) => procurementStore.getState().createPurchaseOrderDraft(input),
68
+ "createPurchaseOrderDraft",
69
+ {
70
+ description: "Prepare a purchase order draft and navigate to the ERP purchase order screen.",
71
+ inputSchema: z.object({
72
+ supplierId: z.string().min(1),
73
+ requesterId: z.string().min(1),
74
+ currency: z.string().min(1),
75
+ lines: z.array(
76
+ z.object({
77
+ itemId: z.string(),
78
+ quantity: z.number().positive(),
79
+ expectedUnitCost: z.number().nonnegative().optional(),
80
+ }),
81
+ ),
82
+ }),
83
+ outputSchema: z.object({
84
+ draftId: z.string(),
85
+ route: z.string(),
86
+ }),
87
+ },
88
+ )
89
+ .addFunction(
90
+ (input) => procurementStore.getState().submitPurchaseRequisition(input),
91
+ "submitPurchaseRequisition",
92
+ {
93
+ description: "Create and submit a purchase requisition from the current selection.",
94
+ inputSchema: z.object({
95
+ departmentCode: z.string().min(1),
96
+ neededByDate: z.string().min(1),
97
+ lines: z.array(
98
+ z.object({
99
+ itemId: z.string(),
100
+ quantity: z.number().int().positive().optional(),
101
+ }),
102
+ ),
103
+ }),
104
+ },
105
+ );
106
+
107
+ webSkills.install();
108
+ ```
109
+
110
+ That exposes browser-callable functions like:
111
+
112
+ ```js
113
+ window._web_skills.erpProcurement.findSupplierItem(input)
114
+ window._web_skills.erpProcurement.createPurchaseOrderDraft(input)
115
+ window._web_skills.erpProcurement.submitPurchaseRequisition(input)
116
+ window._web_skills.erpProcurement._meta
117
+ ```
118
+
119
+ ## Vite integration
120
+
121
+ Use the Vite plugin with the same generator instance:
122
+
123
+ ```ts
124
+ import { defineConfig } from "vite";
125
+ import react from "@vitejs/plugin-react";
126
+ import { webSkillVitePlugin } from "web-skill";
127
+ import { webSkills } from "./src/web-skills.ts";
128
+
129
+ export default defineConfig({
130
+ plugins: [
131
+ react(),
132
+ webSkillVitePlugin({
133
+ generator: webSkills,
134
+ }),
135
+ ],
136
+ });
137
+ ```
138
+
139
+ At dev/build time the plugin will:
140
+
141
+ - generate `/skills/<skill-slug>/SKILL.md` for every configured skill
142
+ - serve those markdown files in Vite dev
143
+ - emit those markdown files as build assets
144
+ - inject one `<link rel="web-skill" ...>` tag per skill into the HTML `<head>`
145
+
146
+ ## What gets generated
147
+
148
+ Each generated `SKILL.md` contains:
149
+
150
+ - the skill title
151
+ - the console entrypoint under `window._web_skills.<skillKey>`
152
+ - one section per function
153
+ - summarized input and output schemas
154
+
155
+ The repository also includes [`skills/use-web-skill/SKILL.md`](./skills/use-web-skill/SKILL.md), a discovery workflow for agents that inspect `<head>` before automating a page.
156
+
157
+ ## Design guidance
158
+
159
+ `web-skill` works best when you expose task-level actions instead of raw UI primitives.
160
+
161
+ Good examples:
162
+
163
+ - `findCustomer`
164
+ - `openInvoiceDraft`
165
+ - `fillPurchaseOrderForm`
166
+ - `submitCurrentApproval`
167
+
168
+ Avoid low-level wrappers like:
169
+
170
+ - `clickSaveButton`
171
+ - `setInputValue`
172
+ - `selectTableRow`
173
+
174
+ The goal is to publish a stable, browser-callable API even when the underlying page is old, DOM-heavy, or inconsistent.
175
+
176
+ ## Safety
177
+
178
+ Treat destructive actions as special cases:
179
+
180
+ - require explicit confirmation fields for submit/delete/finalize flows
181
+ - validate inputs with Zod whenever possible
182
+ - return structured results instead of relying on toast text
183
+ - avoid exposing unstable internal helpers just because they are easy to call
184
+
185
+ ## Local development
186
+
187
+ ```bash
188
+ npm install
189
+ npm run check
190
+ ```
191
+
192
+ ## License
193
+
194
+ [MIT](./LICENSE)
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "web-skill",
3
+ "version": "0.1.0",
4
+ "description": "Generate browser-advertised skills, runtime registries, and Vite head tags for agent-friendly web apps.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./vite": "./src/vite-plugin.ts"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "skills",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "sideEffects": false,
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "scripts": {
23
+ "check": "npm run typecheck",
24
+ "typecheck": "tsc --noEmit -p tsconfig.json"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/dimbreak/webskill.git"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/dimbreak/webskill/issues"
32
+ },
33
+ "homepage": "https://github.com/dimbreak/webskill#readme",
34
+ "keywords": [
35
+ "agent",
36
+ "automation",
37
+ "browser",
38
+ "skills",
39
+ "vite",
40
+ "zod"
41
+ ],
42
+ "peerDependencies": {
43
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
44
+ },
45
+ "dependencies": {
46
+ "zod": "^3.24.4"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^24.3.3",
50
+ "typescript": "^5.7.3",
51
+ "vite": "^7.0.0"
52
+ }
53
+ }
@@ -0,0 +1,41 @@
1
+ ---
2
+ name: use-web-skill
3
+ description: Discover and use browser-advertised skills exposed through HTML head tags like `<link rel="web-skill" href=".../SKILL.md" title="...">`. Use when Codex is browsing or automating a website with Browser MCP, Playwright, or other browser tooling and should check whether the page publishes a task-relevant skill before continuing with normal interaction.
4
+ ---
5
+
6
+ # use-web-skill
7
+
8
+ When working on a live webpage with browser tooling, check the document `<head>` before deeper interaction.
9
+
10
+ ## Workflow
11
+
12
+ 1. Read the page head and look for:
13
+ - `<link rel="web-skill" ...>`
14
+ - especially `href` and `title`
15
+ 2. Compare each link `title` against the current task.
16
+ 3. If a title looks relevant, fetch the linked `SKILL.md`.
17
+ 4. Read the downloaded skill and apply its instructions for the rest of the browser task.
18
+ 5. If no relevant `web-skill` link exists, continue with the normal browser workflow.
19
+
20
+ ## Selection rules
21
+
22
+ - Prefer the most task-specific title.
23
+ - Prefer exact domain or product matches over generic titles.
24
+ - If multiple links look useful, start with the closest match rather than loading everything.
25
+ - Ignore links with vague titles unless the task is also vague.
26
+
27
+ ## Fetch rules
28
+
29
+ - Resolve relative `href` values against the current page URL.
30
+ - Treat the linked file as the source of truth for browser-specific workflow.
31
+ - If the file cannot be fetched, note that and fall back to standard browsing.
32
+
33
+ ## Browser MCP guidance
34
+
35
+ - Use Browser MCP or the active browser tool to inspect `<head>` content first.
36
+ - Only switch into the linked skill workflow after confirming the title is relevant.
37
+ - Keep the fallback simple: no relevant link means no skill handoff.
38
+
39
+ ## Output expectation
40
+
41
+ After discovering a relevant `web-skill`, continue the task using that downloaded `SKILL.md` as the active operating guide.
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ export { generateSkillMarkdown, renderZodSchema } from "./markdown.ts";
2
+ export { createWebSkillGenerator } from "./runtime.ts";
3
+ export { webSkillVitePlugin } from "./vite-plugin.ts";
4
+ export type {
5
+ AddWebSkillFunctionOptions,
6
+ NewWebSkillOptions,
7
+ ResolvedWebSkillDefinition,
8
+ ResolvedWebSkillFunctionDefinition,
9
+ WebSkillBuilder,
10
+ WebSkillFunctionDefinition,
11
+ WebSkillFunctionHandler,
12
+ WebSkillGenerator,
13
+ WebSkillLinkTag,
14
+ WebSkillMetadata,
15
+ WebSkillsWindowShape,
16
+ WebSkillVitePluginOptions,
17
+ WebSkillWindowEntry,
18
+ WebSkillWindowFunction,
19
+ } from "./types.ts";
20
+ export { buildWebSkillLinkTags } from "./utils.ts";
@@ -0,0 +1,230 @@
1
+ import type { ZodTypeAny } from "zod";
2
+
3
+ import type { ResolvedWebSkillDefinition, ResolvedWebSkillFunctionDefinition } from "./types.ts";
4
+
5
+ const INDENT = " ";
6
+
7
+ interface LooseZodDefinition {
8
+ typeName?: string;
9
+ [key: string]: unknown;
10
+ }
11
+
12
+ export function generateSkillMarkdown(skill: ResolvedWebSkillDefinition): string {
13
+ const description =
14
+ skill.description
15
+ ?? `Expose browser-callable functions under \`window._web_skills.${skill.key}\`.`;
16
+
17
+ const lines: string[] = [
18
+ "---",
19
+ `name: ${skill.slug}`,
20
+ `description: ${escapeFrontmatterValue(description)}`,
21
+ "---",
22
+ "",
23
+ `# ${skill.title}`,
24
+ "",
25
+ "Use the browser console entrypoint:",
26
+ "",
27
+ "```js",
28
+ `window._web_skills.${skill.key}`,
29
+ "```",
30
+ "",
31
+ "Available functions:",
32
+ "",
33
+ ];
34
+
35
+ for (const definition of skill.functions) {
36
+ lines.push(...renderFunctionSection(skill, definition), "");
37
+ }
38
+
39
+ return `${lines.join("\n").trimEnd()}\n`;
40
+ }
41
+
42
+ function renderFunctionSection(
43
+ skill: ResolvedWebSkillDefinition,
44
+ definition: ResolvedWebSkillFunctionDefinition,
45
+ ): string[] {
46
+ const inputSchema = definition.inputSchema ? renderZodSchema(definition.inputSchema) : "unknown";
47
+ const outputSchema = definition.outputSchema ? renderZodSchema(definition.outputSchema) : "unknown";
48
+
49
+ return [
50
+ `## \`${definition.name}(input)\``,
51
+ "",
52
+ `Purpose: ${definition.description ?? `Invoke \`window._web_skills.${skill.key}.${definition.name}(input)\`.`}`,
53
+ "",
54
+ "Input:",
55
+ "",
56
+ "```ts",
57
+ inputSchema,
58
+ "```",
59
+ "",
60
+ "Output:",
61
+ "",
62
+ "```ts",
63
+ outputSchema,
64
+ "```",
65
+ ];
66
+ }
67
+
68
+ export function renderZodSchema(schema: ZodTypeAny): string {
69
+ return renderSchema(schema, 0);
70
+ }
71
+
72
+ function renderSchema(schema: ZodTypeAny, depth: number): string {
73
+ const definition = getDefinition(schema);
74
+ const typeName = definition?.typeName;
75
+
76
+ switch (typeName) {
77
+ case "ZodString":
78
+ return "string";
79
+ case "ZodNumber":
80
+ return "number";
81
+ case "ZodBoolean":
82
+ return "boolean";
83
+ case "ZodBigInt":
84
+ return "bigint";
85
+ case "ZodDate":
86
+ return "Date";
87
+ case "ZodUndefined":
88
+ return "undefined";
89
+ case "ZodNull":
90
+ return "null";
91
+ case "ZodVoid":
92
+ return "void";
93
+ case "ZodAny":
94
+ return "any";
95
+ case "ZodUnknown":
96
+ return "unknown";
97
+ case "ZodNever":
98
+ return "never";
99
+ case "ZodLiteral":
100
+ return JSON.stringify(definition?.value);
101
+ case "ZodEnum":
102
+ return Array.isArray(definition?.values)
103
+ ? definition.values.map((value) => JSON.stringify(value)).join(" | ")
104
+ : "string";
105
+ case "ZodNativeEnum":
106
+ return renderNativeEnum(
107
+ definition?.values && typeof definition.values === "object"
108
+ ? (definition.values as Record<string, string | number>)
109
+ : undefined,
110
+ );
111
+ case "ZodArray":
112
+ return `${wrapType(renderSchema(definition?.type as ZodTypeAny, depth))}[]`;
113
+ case "ZodOptional":
114
+ return `${renderSchema(definition?.innerType as ZodTypeAny, depth)} | undefined`;
115
+ case "ZodNullable":
116
+ return `${renderSchema(definition?.innerType as ZodTypeAny, depth)} | null`;
117
+ case "ZodDefault":
118
+ case "ZodCatch":
119
+ return renderSchema(definition?.innerType as ZodTypeAny, depth);
120
+ case "ZodEffects":
121
+ return renderSchema(definition?.schema as ZodTypeAny, depth);
122
+ case "ZodBranded":
123
+ return renderSchema(definition?.type as ZodTypeAny, depth);
124
+ case "ZodUnion":
125
+ return Array.isArray(definition?.options)
126
+ ? definition.options.map((option) => wrapType(renderSchema(option as ZodTypeAny, depth))).join(" | ")
127
+ : "unknown";
128
+ case "ZodDiscriminatedUnion":
129
+ return Array.from(
130
+ definition?.options instanceof Map ? definition.options.values() : [],
131
+ )
132
+ .map((option) => wrapType(renderSchema(option as ZodTypeAny, depth)))
133
+ .join(" | ");
134
+ case "ZodIntersection":
135
+ return `${wrapType(renderSchema(definition?.left as ZodTypeAny, depth))} & ${wrapType(renderSchema(definition?.right as ZodTypeAny, depth))}`;
136
+ case "ZodTuple":
137
+ return `[${Array.isArray(definition?.items)
138
+ ? definition.items.map((item) => renderSchema(item as ZodTypeAny, depth)).join(", ")
139
+ : ""}]`;
140
+ case "ZodRecord":
141
+ return `Record<${renderRecordKey(definition?.keyType as ZodTypeAny | undefined)}, ${renderSchema(definition?.valueType as ZodTypeAny, depth)}>`;
142
+ case "ZodObject":
143
+ return renderObjectSchema(schema, depth);
144
+ case "ZodLazy":
145
+ return typeof definition?.getter === "function"
146
+ ? renderSchema(definition.getter() as ZodTypeAny, depth)
147
+ : "unknown";
148
+ default:
149
+ return "unknown";
150
+ }
151
+ }
152
+
153
+ function renderObjectSchema(schema: ZodTypeAny, depth: number): string {
154
+ const definition = getDefinition(schema);
155
+ const shapeSource = definition?.shape;
156
+ const shape =
157
+ typeof shapeSource === "function"
158
+ ? (shapeSource() as Record<string, ZodTypeAny>)
159
+ : ((shapeSource ?? {}) as Record<string, ZodTypeAny>);
160
+ const entries = Object.entries(shape);
161
+
162
+ if (entries.length === 0) {
163
+ return "{}";
164
+ }
165
+
166
+ const propertyLines = entries.map(([key, propertySchema]) => {
167
+ const optional = isOptionalSchema(propertySchema);
168
+ const renderedType = renderSchema(optional ? unwrapOptionalSchema(propertySchema) : propertySchema, depth + 1);
169
+ return `${indent(depth + 1)}${quotePropertyKey(key)}${optional ? "?" : ""}: ${renderedType};`;
170
+ });
171
+
172
+ return ["{", ...propertyLines, `${indent(depth)}}`].join("\n");
173
+ }
174
+
175
+ function renderNativeEnum(values: Record<string, string | number> | undefined): string {
176
+ if (!values) {
177
+ return "string | number";
178
+ }
179
+
180
+ const rendered = Array.from(
181
+ new Set(
182
+ Object.values(values).filter(
183
+ (value): value is string | number => typeof value === "string" || typeof value === "number",
184
+ ),
185
+ ),
186
+ );
187
+
188
+ return rendered.map((value) => JSON.stringify(value)).join(" | ") || "string | number";
189
+ }
190
+
191
+ function renderRecordKey(keyType: ZodTypeAny | undefined): string {
192
+ if (!keyType) {
193
+ return "string";
194
+ }
195
+
196
+ return renderSchema(keyType, 0);
197
+ }
198
+
199
+ function isOptionalSchema(schema: ZodTypeAny): boolean {
200
+ return typeof schema.isOptional === "function" ? schema.isOptional() : getDefinition(schema)?.typeName === "ZodOptional";
201
+ }
202
+
203
+ function unwrapOptionalSchema(schema: ZodTypeAny): ZodTypeAny {
204
+ const definition = getDefinition(schema);
205
+ if (definition?.typeName === "ZodOptional") {
206
+ return definition.innerType as ZodTypeAny;
207
+ }
208
+
209
+ return schema;
210
+ }
211
+
212
+ function wrapType(value: string): string {
213
+ return /[|&\n]/u.test(value) ? `(${value})` : value;
214
+ }
215
+
216
+ function quotePropertyKey(value: string): string {
217
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(value) ? value : JSON.stringify(value);
218
+ }
219
+
220
+ function indent(depth: number): string {
221
+ return INDENT.repeat(depth);
222
+ }
223
+
224
+ function getDefinition(schema: ZodTypeAny): LooseZodDefinition | undefined {
225
+ return (schema as unknown as { _def?: LooseZodDefinition })._def;
226
+ }
227
+
228
+ function escapeFrontmatterValue(value: string): string {
229
+ return JSON.stringify(value);
230
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,185 @@
1
+ import type { ZodTypeAny } from "zod";
2
+
3
+ import type {
4
+ AddWebSkillFunctionOptions,
5
+ NewWebSkillOptions,
6
+ ResolvedWebSkillDefinition,
7
+ ResolvedWebSkillFunctionDefinition,
8
+ WebSkillBuilder,
9
+ WebSkillFunctionDefinition,
10
+ WebSkillFunctionHandler,
11
+ WebSkillGenerator,
12
+ WebSkillMetadata,
13
+ WebSkillsWindowShape,
14
+ WebSkillWindowEntry,
15
+ WebSkillWindowFunction,
16
+ } from "./types.ts";
17
+ import { ensureUniqueString, slugifySkillSegment, titleFromName, toSkillKey } from "./utils.ts";
18
+
19
+ interface MutableWebSkillDefinition {
20
+ description: string | null;
21
+ functions: WebSkillFunctionDefinition<any, any>[];
22
+ key: string;
23
+ name: string;
24
+ slug: string;
25
+ title: string;
26
+ }
27
+
28
+ class WebSkillBuilderImpl implements WebSkillBuilder {
29
+ constructor(private readonly skill: MutableWebSkillDefinition) {}
30
+
31
+ addFunction<TInput, TOutput>(
32
+ func: WebSkillFunctionHandler<TInput, TOutput>,
33
+ name: string,
34
+ options: AddWebSkillFunctionOptions<TInput, TOutput> = {},
35
+ ): WebSkillBuilder {
36
+ if (typeof func !== "function") {
37
+ throw new TypeError(`web-skill function "${name}" must be a function.`);
38
+ }
39
+
40
+ const normalizedName = name.trim();
41
+ if (!normalizedName) {
42
+ throw new TypeError("web-skill function name must not be empty.");
43
+ }
44
+ if (normalizedName === "_meta") {
45
+ throw new TypeError('web-skill function name "_meta" is reserved.');
46
+ }
47
+
48
+ const existing = this.skill.functions.find((definition) => definition.name === normalizedName);
49
+ if (existing) {
50
+ throw new Error(
51
+ `web-skill "${this.skill.key}" already contains a function named "${normalizedName}".`,
52
+ );
53
+ }
54
+
55
+ this.skill.functions.push({
56
+ func,
57
+ name: normalizedName,
58
+ description: options.description?.trim() || undefined,
59
+ inputSchema: options.inputSchema,
60
+ outputSchema: options.outputSchema,
61
+ });
62
+
63
+ return this;
64
+ }
65
+ }
66
+
67
+ class WebSkillGeneratorImpl implements WebSkillGenerator {
68
+ private readonly skills: MutableWebSkillDefinition[] = [];
69
+ private readonly usedKeys = new Set<string>();
70
+ private readonly usedNames = new Set<string>();
71
+ private readonly usedSlugs = new Set<string>();
72
+
73
+ newSkill(options: NewWebSkillOptions = {}): WebSkillBuilder {
74
+ const skillIndex = this.skills.length + 1;
75
+ const fallbackName = `web-skill-${skillIndex}`;
76
+ const requestedName = options.name?.trim();
77
+ const requestedTitle = options.title?.trim();
78
+ const requestedIdentifier = requestedName || requestedTitle || fallbackName;
79
+ const requestedKeyBase = requestedName || requestedTitle || `webSkill${skillIndex}`;
80
+ const requestedTitleBase = requestedTitle || requestedName || fallbackName;
81
+
82
+ const slug = ensureUniqueString(slugifySkillSegment(requestedIdentifier), this.usedSlugs);
83
+ const keyBase = toSkillKey(requestedKeyBase);
84
+ const key = ensureUniqueString(keyBase, this.usedKeys, (index) => `${keyBase}${index + 1}`);
85
+ const nameBase = requestedName || slug;
86
+ const name = ensureUniqueString(nameBase, this.usedNames);
87
+ const title = requestedTitle || titleFromName(requestedTitleBase);
88
+
89
+ const skill: MutableWebSkillDefinition = {
90
+ description: options.description?.trim() || null,
91
+ functions: [],
92
+ key,
93
+ name,
94
+ slug,
95
+ title,
96
+ };
97
+
98
+ this.skills.push(skill);
99
+ return new WebSkillBuilderImpl(skill);
100
+ }
101
+
102
+ getSkills(): ResolvedWebSkillDefinition[] {
103
+ return this.skills.map((skill) => ({
104
+ description: skill.description,
105
+ key: skill.key,
106
+ name: skill.name,
107
+ slug: skill.slug,
108
+ title: skill.title,
109
+ functions: skill.functions.map<ResolvedWebSkillFunctionDefinition>((definition) => ({
110
+ name: definition.name,
111
+ description: definition.description ?? undefined,
112
+ inputSchema: definition.inputSchema,
113
+ outputSchema: definition.outputSchema,
114
+ })),
115
+ }));
116
+ }
117
+
118
+ install(target?: Window): WebSkillsWindowShape {
119
+ const windowTarget = target ?? resolveWindowTarget();
120
+ const registry = (windowTarget._web_skills ??= {});
121
+
122
+ for (const skill of this.skills) {
123
+ const entry: Partial<WebSkillWindowEntry> = {};
124
+ const metadata: WebSkillMetadata = {
125
+ description: skill.description,
126
+ functions: skill.functions.map((definition) => ({
127
+ description: definition.description ?? null,
128
+ hasInputSchema: Boolean(definition.inputSchema),
129
+ hasOutputSchema: Boolean(definition.outputSchema),
130
+ name: definition.name,
131
+ })),
132
+ key: skill.key,
133
+ name: skill.name,
134
+ title: skill.title,
135
+ };
136
+
137
+ entry._meta = metadata;
138
+
139
+ for (const definition of skill.functions) {
140
+ entry[definition.name] = wrapFunction(skill.key, definition);
141
+ }
142
+
143
+ registry[skill.key] = entry as WebSkillWindowEntry;
144
+ }
145
+
146
+ return registry;
147
+ }
148
+ }
149
+
150
+ function resolveWindowTarget(): Window {
151
+ if (typeof window === "undefined") {
152
+ throw new Error("web-skill install() requires a browser window target.");
153
+ }
154
+
155
+ return window;
156
+ }
157
+
158
+ function wrapFunction(
159
+ skillKey: string,
160
+ definition: WebSkillFunctionDefinition<any, any>,
161
+ ): WebSkillWindowFunction {
162
+ return async (input: unknown) => {
163
+ const parsedInput = parseWithSchema(definition.inputSchema, input);
164
+ const result = await definition.func(parsedInput);
165
+ return parseWithSchema(definition.outputSchema, result);
166
+ };
167
+ }
168
+
169
+ function parseWithSchema(schema: ZodTypeAny | undefined, value: unknown): unknown {
170
+ if (!schema) {
171
+ return value;
172
+ }
173
+
174
+ return schema.parse(value);
175
+ }
176
+
177
+ export function createWebSkillGenerator(): WebSkillGenerator {
178
+ return new WebSkillGeneratorImpl();
179
+ }
180
+
181
+ declare global {
182
+ interface Window {
183
+ _web_skills?: WebSkillsWindowShape;
184
+ }
185
+ }
package/src/types.ts ADDED
@@ -0,0 +1,89 @@
1
+ import type { ZodType, ZodTypeAny } from "zod";
2
+
3
+ export type MaybePromise<T> = T | Promise<T>;
4
+
5
+ export type WebSkillFunctionHandler<TInput = unknown, TOutput = unknown> = (
6
+ input: TInput,
7
+ ) => MaybePromise<TOutput>;
8
+
9
+ export interface AddWebSkillFunctionOptions<TInput = unknown, TOutput = unknown> {
10
+ description?: string;
11
+ inputSchema?: ZodType<TInput>;
12
+ outputSchema?: ZodType<TOutput>;
13
+ }
14
+
15
+ export interface WebSkillFunctionDefinition<TInput = unknown, TOutput = unknown>
16
+ extends AddWebSkillFunctionOptions<TInput, TOutput> {
17
+ func: WebSkillFunctionHandler<TInput, TOutput>;
18
+ name: string;
19
+ }
20
+
21
+ export interface NewWebSkillOptions {
22
+ description?: string;
23
+ name?: string;
24
+ title?: string;
25
+ }
26
+
27
+ export interface ResolvedWebSkillFunctionDefinition<TInput = unknown, TOutput = unknown>
28
+ extends AddWebSkillFunctionOptions<TInput, TOutput> {
29
+ name: string;
30
+ }
31
+
32
+ export interface ResolvedWebSkillDefinition {
33
+ description: string | null;
34
+ functions: ResolvedWebSkillFunctionDefinition<any, any>[];
35
+ key: string;
36
+ name: string;
37
+ slug: string;
38
+ title: string;
39
+ }
40
+
41
+ export interface WebSkillFunctionMetadata {
42
+ description: string | null;
43
+ hasInputSchema: boolean;
44
+ hasOutputSchema: boolean;
45
+ name: string;
46
+ }
47
+
48
+ export interface WebSkillMetadata {
49
+ description: string | null;
50
+ functions: WebSkillFunctionMetadata[];
51
+ key: string;
52
+ name: string;
53
+ title: string;
54
+ }
55
+
56
+ export type WebSkillWindowFunction = (input: unknown) => Promise<unknown>;
57
+
58
+ export type WebSkillWindowEntry = Record<string, WebSkillWindowFunction | WebSkillMetadata> & {
59
+ _meta: WebSkillMetadata;
60
+ };
61
+
62
+ export interface WebSkillsWindowShape {
63
+ [skillKey: string]: WebSkillWindowEntry;
64
+ }
65
+
66
+ export interface WebSkillLinkTag {
67
+ href: string;
68
+ title: string;
69
+ type: "text/markdown";
70
+ }
71
+
72
+ export interface WebSkillVitePluginOptions {
73
+ generator: WebSkillGenerator;
74
+ publicBasePath?: string;
75
+ }
76
+
77
+ export interface WebSkillGenerator {
78
+ getSkills(): ResolvedWebSkillDefinition[];
79
+ install(target?: Window): WebSkillsWindowShape;
80
+ newSkill(options?: NewWebSkillOptions): WebSkillBuilder;
81
+ }
82
+
83
+ export interface WebSkillBuilder {
84
+ addFunction<TInput, TOutput>(
85
+ func: WebSkillFunctionHandler<TInput, TOutput>,
86
+ name: string,
87
+ options?: AddWebSkillFunctionOptions<TInput, TOutput>,
88
+ ): WebSkillBuilder;
89
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,90 @@
1
+ import type { ResolvedWebSkillDefinition, WebSkillLinkTag } from "./types.ts";
2
+
3
+ const NON_ALPHANUMERIC_PATTERN = /[^a-zA-Z0-9]+/gu;
4
+ const TRIM_DASHES_PATTERN = /^-+|-+$/gu;
5
+ const CAMEL_BOUNDARY_PATTERN = /[-_\s]+([a-zA-Z0-9])/gu;
6
+ const NON_IDENTIFIER_PATTERN = /[^a-zA-Z0-9_$]/gu;
7
+ const LEADING_INVALID_IDENTIFIER_PATTERN = /^[^a-zA-Z_$]+/u;
8
+
9
+ export function slugifySkillSegment(value: string): string {
10
+ const normalized = value
11
+ .trim()
12
+ .replace(NON_ALPHANUMERIC_PATTERN, "-")
13
+ .replace(TRIM_DASHES_PATTERN, "")
14
+ .toLowerCase();
15
+
16
+ return normalized || "web-skill";
17
+ }
18
+
19
+ export function toSkillKey(value: string): string {
20
+ const identifier = value
21
+ .trim()
22
+ .replace(CAMEL_BOUNDARY_PATTERN, (_match, character: string) => character.toUpperCase())
23
+ .replace(NON_IDENTIFIER_PATTERN, "")
24
+ .replace(LEADING_INVALID_IDENTIFIER_PATTERN, "");
25
+
26
+ if (!identifier) {
27
+ return "webSkill";
28
+ }
29
+
30
+ return identifier[0]!.toLowerCase() + identifier.slice(1);
31
+ }
32
+
33
+ export function titleFromName(value: string): string {
34
+ const normalized = value
35
+ .replace(/([a-z0-9])([A-Z])/gu, "$1 $2")
36
+ .replace(/[-_]+/gu, " ")
37
+ .trim();
38
+
39
+ if (!normalized) {
40
+ return "Web Skill";
41
+ }
42
+
43
+ return normalized
44
+ .split(/\s+/u)
45
+ .map((part) => part[0]!.toUpperCase() + part.slice(1))
46
+ .join(" ");
47
+ }
48
+
49
+ export function ensureUniqueString(
50
+ value: string,
51
+ used: Set<string>,
52
+ formatter: (index: number) => string = (index) => `${value}-${index + 1}`,
53
+ ): string {
54
+ if (!used.has(value)) {
55
+ used.add(value);
56
+ return value;
57
+ }
58
+
59
+ let index = 1;
60
+ while (true) {
61
+ const nextValue = formatter(index);
62
+ if (!used.has(nextValue)) {
63
+ used.add(nextValue);
64
+ return nextValue;
65
+ }
66
+ index += 1;
67
+ }
68
+ }
69
+
70
+ export function joinBasePath(basePath: string | undefined, relativePath: string): string {
71
+ const normalizedBase = (basePath ?? "/").trim();
72
+ const base = normalizedBase === "/" ? "" : `/${normalizedBase.replace(/^\/+|\/+$/gu, "")}`;
73
+ const path = relativePath.startsWith("/") ? relativePath : `/${relativePath}`;
74
+ return `${base}${path}`;
75
+ }
76
+
77
+ export function toSkillMarkdownAssetPath(skill: ResolvedWebSkillDefinition): string {
78
+ return `/skills/${skill.slug}/SKILL.md`;
79
+ }
80
+
81
+ export function buildWebSkillLinkTags(
82
+ skills: ResolvedWebSkillDefinition[],
83
+ basePath?: string,
84
+ ): WebSkillLinkTag[] {
85
+ return skills.map((skill) => ({
86
+ href: joinBasePath(basePath, toSkillMarkdownAssetPath(skill)),
87
+ title: skill.title,
88
+ type: "text/markdown",
89
+ }));
90
+ }
@@ -0,0 +1,91 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+
3
+ import type { Plugin, ResolvedConfig } from "vite";
4
+
5
+ import { generateSkillMarkdown } from "./markdown.ts";
6
+ import type { ResolvedWebSkillDefinition, WebSkillVitePluginOptions } from "./types.ts";
7
+ import { buildWebSkillLinkTags } from "./utils.ts";
8
+
9
+ export function webSkillVitePlugin(options: WebSkillVitePluginOptions): Plugin {
10
+ let resolvedConfig: ResolvedConfig | null = null;
11
+
12
+ return {
13
+ name: "web-skill",
14
+
15
+ configResolved(config): void {
16
+ resolvedConfig = config;
17
+ },
18
+
19
+ configureServer(server) {
20
+ server.middlewares.use((request, response, next) => {
21
+ if (!resolvedConfig) {
22
+ next();
23
+ return;
24
+ }
25
+
26
+ if (
27
+ !tryServeGeneratedSkillMarkdown(
28
+ request,
29
+ response,
30
+ options.generator.getSkills(),
31
+ options.publicBasePath ?? resolvedConfig.base,
32
+ )
33
+ ) {
34
+ next();
35
+ }
36
+ });
37
+ },
38
+
39
+ generateBundle() {
40
+ for (const skill of options.generator.getSkills()) {
41
+ this.emitFile({
42
+ fileName: `skills/${skill.slug}/SKILL.md`,
43
+ source: generateSkillMarkdown(skill),
44
+ type: "asset",
45
+ });
46
+ }
47
+ },
48
+
49
+ transformIndexHtml() {
50
+ const tags = buildWebSkillLinkTags(
51
+ options.generator.getSkills(),
52
+ options.publicBasePath ?? resolvedConfig?.base ?? "/",
53
+ );
54
+ return tags.map((tag) => ({
55
+ tag: "link",
56
+ attrs: {
57
+ rel: "web-skill",
58
+ href: tag.href,
59
+ title: tag.title,
60
+ type: tag.type,
61
+ },
62
+ injectTo: "head" as const,
63
+ }));
64
+ },
65
+ };
66
+ }
67
+
68
+ function tryServeGeneratedSkillMarkdown(
69
+ request: IncomingMessage,
70
+ response: ServerResponse,
71
+ skills: ResolvedWebSkillDefinition[],
72
+ basePath: string,
73
+ ): boolean {
74
+ const requestUrl = request.url;
75
+ if (!requestUrl) {
76
+ return false;
77
+ }
78
+
79
+ const pathname = new URL(requestUrl, "http://localhost").pathname;
80
+ const tags = buildWebSkillLinkTags(skills, basePath);
81
+ const matchedIndex = tags.findIndex((tag) => tag.href === pathname);
82
+
83
+ if (matchedIndex === -1) {
84
+ return false;
85
+ }
86
+
87
+ response.statusCode = 200;
88
+ response.setHeader("Content-Type", "text/markdown; charset=utf-8");
89
+ response.end(generateSkillMarkdown(skills[matchedIndex]!));
90
+ return true;
91
+ }