@webmcp-auto-ui/sdk 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/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # @webmcp-auto-ui/sdk
2
+
3
+ HyperSkill URL format, skills CRUD registry, and Svelte 5 canvas store.
4
+
5
+ ## HyperSkill format
6
+
7
+ A skill is a serialised UI state — a list of blocks with their data, plus metadata (MCP server, LLM, tags). It encodes to a URL parameter:
8
+
9
+ ```
10
+ https://example.com/viewer?hs=base64(JSON)
11
+ ```
12
+
13
+ Skills above 6 KB are compressed with `CompressionStream` (`gz.` prefix). Each version carries a SHA-256 hash of `source_url + content` for traceability. Hashes are chainable: each version can reference the hash of the previous one.
14
+
15
+ ```ts
16
+ import { encodeHyperSkill, decodeHyperSkill, computeHash, diffSkills } from '@webmcp-auto-ui/sdk';
17
+
18
+ const url = await encodeHyperSkill(skill, 'https://example.com/viewer');
19
+ const skill = await decodeHyperSkill(url); // or pass raw ?hs= param
20
+ const hash = await computeHash(sourceUrl, skill.content);
21
+ const changed = diffSkills(prev.content, next.content); // ['blocks', 'meta']
22
+ ```
23
+
24
+ ## Skills registry
25
+
26
+ In-memory CRUD store with change listeners.
27
+
28
+ ```ts
29
+ import { createSkill, updateSkill, deleteSkill, listSkills, loadDemoSkills, onSkillsChange } from '@webmcp-auto-ui/sdk';
30
+
31
+ loadDemoSkills(); // loads 3 built-in demo skills
32
+
33
+ const skill = createSkill({
34
+ name: 'my-dashboard',
35
+ mcp: 'https://mcp.example.com/mcp',
36
+ mcpName: 'example',
37
+ llm: 'haiku',
38
+ blocks: [{ type: 'stat', data: { label: 'KPI', value: '42' } }],
39
+ tags: ['dashboard'],
40
+ });
41
+
42
+ const unsub = onSkillsChange(() => console.log('skills changed'));
43
+ ```
44
+
45
+ Each skill stores the MCP server URL and name it was designed for. Apps use this to warn the user if they load a skill while connected to a different server.
46
+
47
+ ## Canvas store
48
+
49
+ Svelte 5 runes state for the composer canvas. Import from the `/canvas` subpath to avoid issues with server-side rendering:
50
+
51
+ ```ts
52
+ import { canvas } from '@webmcp-auto-ui/sdk/canvas';
53
+
54
+ canvas.addBlock('stat', { label: 'Revenue', value: '€142K' });
55
+ canvas.setMcpConnected(true, 'my-server', tools);
56
+ const param = canvas.buildHyperskillParam(); // base64 for ?hs=
57
+ ```
58
+
59
+ The canvas store manages blocks, mode (`auto` | `drag` | `chat`), MCP connection state, chat messages, and generating flag.
60
+
61
+ ## Install
62
+
63
+ ```bash
64
+ npm install @webmcp-auto-ui/sdk
65
+ ```
66
+
67
+ Requires Svelte 5 for the canvas store. The HyperSkill utilities and registry are plain TypeScript with no framework dependency.
68
+
69
+ ## License
70
+
71
+ AGPL-3.0-or-later
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@webmcp-auto-ui/sdk",
3
+ "version": "0.1.0",
4
+ "description": "Skills CRUD, HyperSkill format, Svelte 5 stores",
5
+ "license": "AGPL-3.0-or-later",
6
+ "type": "module",
7
+ "svelte": "./src/index.ts",
8
+ "main": "./src/index.ts",
9
+ "exports": {
10
+ ".": {
11
+ "svelte": "./src/index.ts",
12
+ "import": "./src/index.ts"
13
+ },
14
+ "./canvas": {
15
+ "svelte": "./src/canvas.ts",
16
+ "import": "./src/canvas.ts"
17
+ }
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "scripts": {
23
+ "check": "svelte-check --tsconfig ./tsconfig.json",
24
+ "build": "svelte-package -i src"
25
+ },
26
+ "peerDependencies": {
27
+ "svelte": "^5.0.0"
28
+ },
29
+ "devDependencies": {
30
+ "@sveltejs/package": "^2.3.0",
31
+ "svelte": "^5.0.0",
32
+ "svelte-check": "^4.0.0",
33
+ "typescript": "^5.0.0"
34
+ }
35
+ }
@@ -0,0 +1,5 @@
1
+ <!-- @webmcp-auto-ui/sdk — browser components stub -->
2
+ <script lang="ts">
3
+ // This file exists to satisfy svelte-check tooling.
4
+ // Import canvas store via: import { canvas } from '@webmcp-auto-ui/sdk/canvas'
5
+ </script>
package/src/canvas.ts ADDED
@@ -0,0 +1,3 @@
1
+ // Canvas store — Svelte 5 runes (browser-only, must be imported in Svelte components)
2
+ export { canvas } from './stores/canvas.svelte.js';
3
+ export type { Block, BlockType, Mode, LLMId, ChatMsg, McpToolInfo } from './stores/canvas.svelte.js';
@@ -0,0 +1,129 @@
1
+ /**
2
+ * HyperSkill format — spec: https://hyperskills.net/
3
+ * URL: https://example.com/page?hs=base64(content)
4
+ * Compression: prefix "gz." for skills > 6KB
5
+ * Traceability: SHA-256(source_url + content), chainable
6
+ */
7
+
8
+ export interface HyperSkillMeta {
9
+ title?: string;
10
+ description?: string;
11
+ version?: string;
12
+ created?: string;
13
+ mcp?: string;
14
+ mcpName?: string;
15
+ llm?: string;
16
+ tags?: string[];
17
+ hash?: string;
18
+ previousHash?: string;
19
+ }
20
+
21
+ export interface HyperSkill {
22
+ meta: HyperSkillMeta;
23
+ content: unknown;
24
+ }
25
+
26
+ export interface HyperSkillVersion {
27
+ hash: string;
28
+ previousHash?: string;
29
+ timestamp: number;
30
+ skill: HyperSkill;
31
+ }
32
+
33
+ export async function encodeHyperSkill(
34
+ skill: HyperSkill,
35
+ sourceUrl?: string
36
+ ): Promise<string> {
37
+ const base = sourceUrl ?? (typeof window !== 'undefined' ? window.location.href.split('?')[0] : 'https://example.com');
38
+ const json = JSON.stringify(skill);
39
+ const bytes = new TextEncoder().encode(json);
40
+ let param: string;
41
+
42
+ if (bytes.length > 6144 && typeof CompressionStream !== 'undefined') {
43
+ try {
44
+ const cs = new CompressionStream('gzip');
45
+ const writer = cs.writable.getWriter();
46
+ writer.write(bytes);
47
+ writer.close();
48
+ const compressed = await new Response(cs.readable).arrayBuffer();
49
+ const b64 = btoa(String.fromCharCode(...new Uint8Array(compressed)));
50
+ param = 'gz.' + b64;
51
+ } catch {
52
+ param = btoa(unescape(encodeURIComponent(json)));
53
+ }
54
+ } else {
55
+ param = btoa(unescape(encodeURIComponent(json)));
56
+ }
57
+
58
+ const url = new URL(base);
59
+ url.searchParams.set('hs', param);
60
+ return url.toString();
61
+ }
62
+
63
+ export async function decodeHyperSkill(urlOrParam: string): Promise<HyperSkill> {
64
+ let param: string;
65
+ if (urlOrParam.includes('=') || urlOrParam.includes('://') || urlOrParam.includes('?')) {
66
+ try {
67
+ const url = new URL(urlOrParam, typeof window !== 'undefined' ? window.location.href : 'https://example.com');
68
+ param = url.searchParams.get('hs') ?? urlOrParam;
69
+ } catch {
70
+ param = urlOrParam;
71
+ }
72
+ } else {
73
+ param = urlOrParam;
74
+ }
75
+
76
+ if (param.startsWith('gz.') && typeof DecompressionStream !== 'undefined') {
77
+ const b64 = param.slice(3);
78
+ const binary = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
79
+ const ds = new DecompressionStream('gzip');
80
+ const writer = ds.writable.getWriter();
81
+ writer.write(binary);
82
+ writer.close();
83
+ const json = await new Response(ds.readable).text();
84
+ return JSON.parse(json) as HyperSkill;
85
+ }
86
+
87
+ const json = decodeURIComponent(escape(atob(param)));
88
+ return JSON.parse(json) as HyperSkill;
89
+ }
90
+
91
+ export async function computeHash(sourceUrl: string, content: unknown): Promise<string> {
92
+ const text = sourceUrl + JSON.stringify(content);
93
+ const bytes = new TextEncoder().encode(text);
94
+ const buf = await crypto.subtle.digest('SHA-256', bytes);
95
+ return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('');
96
+ }
97
+
98
+ export async function createVersion(
99
+ skill: HyperSkill,
100
+ sourceUrl: string,
101
+ previousHash?: string
102
+ ): Promise<HyperSkillVersion> {
103
+ const hash = await computeHash(sourceUrl, skill.content);
104
+ return {
105
+ hash,
106
+ previousHash,
107
+ timestamp: Date.now(),
108
+ skill: { ...skill, meta: { ...skill.meta, hash, previousHash } },
109
+ };
110
+ }
111
+
112
+ export function getHsParam(): string | null {
113
+ if (typeof window === 'undefined') return null;
114
+ return new URLSearchParams(window.location.search).get('hs');
115
+ }
116
+
117
+ export function diffSkills(prev: unknown, next: unknown): string[] {
118
+ if (typeof prev !== 'object' || typeof next !== 'object' || !prev || !next) {
119
+ return prev !== next ? ['(root)'] : [];
120
+ }
121
+ const p = prev as Record<string, unknown>;
122
+ const n = next as Record<string, unknown>;
123
+ const keys = new Set([...Object.keys(p), ...Object.keys(n)]);
124
+ const changed: string[] = [];
125
+ for (const k of keys) {
126
+ if (JSON.stringify(p[k]) !== JSON.stringify(n[k])) changed.push(k);
127
+ }
128
+ return changed;
129
+ }
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ // @webmcp-auto-ui/sdk — public API
2
+
3
+ // HyperSkill format
4
+ export {
5
+ encodeHyperSkill,
6
+ decodeHyperSkill,
7
+ computeHash,
8
+ createVersion,
9
+ getHsParam,
10
+ diffSkills,
11
+ } from './hyperskill/format.js';
12
+ export type {
13
+ HyperSkill,
14
+ HyperSkillMeta,
15
+ HyperSkillVersion,
16
+ } from './hyperskill/format.js';
17
+
18
+ // Skills registry
19
+ export {
20
+ createSkill,
21
+ updateSkill,
22
+ deleteSkill,
23
+ getSkill,
24
+ listSkills,
25
+ clearSkills,
26
+ loadSkills,
27
+ loadDemoSkills,
28
+ onSkillsChange,
29
+ } from './skills/registry.js';
30
+ export type { Skill, SkillBlock } from './skills/registry.js';
31
+
32
+ // Canvas store — browser-only (Svelte 5 runes), import directly from src:
33
+ // import { canvas } from '@webmcp-auto-ui/sdk/canvas'
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Skills registry — in-memory CRUD for UI recipes/skills.
3
+ * Each skill describes how to compose a UI from a set of blocks.
4
+ */
5
+
6
+ export interface SkillBlock {
7
+ type: string;
8
+ data: Record<string, unknown>;
9
+ }
10
+
11
+ export interface Skill {
12
+ id: string;
13
+ name: string;
14
+ description?: string;
15
+ mcp?: string;
16
+ mcpName?: string;
17
+ llm?: string;
18
+ tags?: string[];
19
+ blocks: SkillBlock[];
20
+ createdAt: number;
21
+ updatedAt: number;
22
+ }
23
+
24
+ const _skills = new Map<string, Skill>();
25
+ const _listeners = new Set<() => void>();
26
+
27
+ function notify() {
28
+ _listeners.forEach((fn) => fn());
29
+ }
30
+
31
+ export function onSkillsChange(fn: () => void): () => void {
32
+ _listeners.add(fn);
33
+ return () => _listeners.delete(fn);
34
+ }
35
+
36
+ export function createSkill(
37
+ data: Omit<Skill, 'id' | 'createdAt' | 'updatedAt'>
38
+ ): Skill {
39
+ const id = 'sk_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
40
+ const skill: Skill = {
41
+ ...data,
42
+ id,
43
+ createdAt: Date.now(),
44
+ updatedAt: Date.now(),
45
+ };
46
+ _skills.set(id, skill);
47
+ notify();
48
+ return skill;
49
+ }
50
+
51
+ export function updateSkill(id: string, patch: Partial<Omit<Skill, 'id' | 'createdAt'>>): Skill | null {
52
+ const existing = _skills.get(id);
53
+ if (!existing) return null;
54
+ const updated = { ...existing, ...patch, id, updatedAt: Date.now() };
55
+ _skills.set(id, updated);
56
+ notify();
57
+ return updated;
58
+ }
59
+
60
+ export function deleteSkill(id: string): boolean {
61
+ if (!_skills.has(id)) return false;
62
+ _skills.delete(id);
63
+ notify();
64
+ return true;
65
+ }
66
+
67
+ export function getSkill(id: string): Skill | undefined {
68
+ return _skills.get(id);
69
+ }
70
+
71
+ export function listSkills(): Skill[] {
72
+ return Array.from(_skills.values()).sort((a, b) => b.createdAt - a.createdAt);
73
+ }
74
+
75
+ export function clearSkills(): void {
76
+ _skills.clear();
77
+ notify();
78
+ }
79
+
80
+ /** Load a list of skills (e.g. from a HyperSkill URL) — replaces all */
81
+ export function loadSkills(skills: Skill[]): void {
82
+ _skills.clear();
83
+ for (const s of skills) _skills.set(s.id, s);
84
+ notify();
85
+ }
86
+
87
+ // Built-in demo skills
88
+ const DEMO_SKILLS: Omit<Skill, 'id' | 'createdAt' | 'updatedAt'>[] = [
89
+ {
90
+ name: 'weather-dashboard',
91
+ mcp: 'https://mcp.code4code.eu/mcp',
92
+ mcpName: 'code4code',
93
+ description: 'Météo locale avec température, conditions et prévisions',
94
+ tags: ['météo', 'dashboard'],
95
+ blocks: [
96
+ { type: 'stat', data: { label: 'Température', value: '14°C', trend: '+2°', trendDir: 'up' } },
97
+ { type: 'kv', data: { title: 'Conditions', rows: [['Humidité','72%'],['Vent','18 km/h'],['UV','3']] } },
98
+ { type: 'chart', data: { title: 'Prévisions 7j', bars: [['L',12],['M',14],['M',11],['J',15],['V',13],['S',16],['D',14]] } },
99
+ ],
100
+ },
101
+ {
102
+ name: 'kpi-overview',
103
+ mcp: 'https://mcp.code4code.eu/mcp',
104
+ mcpName: 'code4code',
105
+ description: 'Vue KPIs : revenus, utilisateurs, churn',
106
+ tags: ['kpi', 'dashboard'],
107
+ blocks: [
108
+ { type: 'stat', data: { label: 'Revenus', value: '€142K', trend: '+12.4%', trendDir: 'up' } },
109
+ { type: 'stat', data: { label: 'Utilisateurs', value: '8 204', trend: '+3.2%', trendDir: 'up' } },
110
+ { type: 'stat', data: { label: 'Churn', value: '2.1%', trend: '-0.4%', trendDir: 'down' } },
111
+ { type: 'chart', data: { title: 'Revenus Q1-Q4', bars: [['Q1',98],['Q2',112],['Q3',128],['Q4',142]] } },
112
+ ],
113
+ },
114
+ {
115
+ name: 'status-monitor',
116
+ mcp: 'https://mcp.code4code.eu/mcp',
117
+ mcpName: 'code4code',
118
+ description: 'Monitoring état des services',
119
+ tags: ['ops', 'monitoring'],
120
+ blocks: [
121
+ { type: 'alert', data: { title: 'DB degraded', message: 'Latence élevée sur primary-eu.', level: 'warn' } },
122
+ { type: 'kv', data: { title: 'État services', rows: [['API','✓ OK'],['DB','⚠ degraded'],['CDN','✓ OK'],['Queue','⚠ warning']] } },
123
+ { type: 'tags', data: { label: 'Actif', tags: [{ text:'prod', active:true},{ text:'eu-west'},{ text:'v2.4.1',active:true}] } },
124
+ ],
125
+ },
126
+ ];
127
+
128
+ export function loadDemoSkills(): void {
129
+ if (_skills.size > 0) return; // don't overwrite user skills
130
+ for (const s of DEMO_SKILLS) createSkill(s);
131
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Canvas state store — Svelte 5 runes
3
+ * Manages blocks on the canvas, mode, MCP connection, chat history
4
+ */
5
+
6
+ export type BlockType =
7
+ | 'stat' | 'kv' | 'list' | 'chart' | 'alert' | 'code' | 'text' | 'actions' | 'tags'
8
+ | 'stat-card' | 'data-table' | 'timeline' | 'profile' | 'trombinoscope' | 'json-viewer'
9
+ | 'hemicycle' | 'chart-rich' | 'cards' | 'grid-data' | 'sankey' | 'map' | 'log';
10
+
11
+ export type Mode = 'auto' | 'drag' | 'chat';
12
+ export type LLMId = 'haiku' | 'sonnet' | 'gemma-e2b';
13
+
14
+ export interface Block {
15
+ id: string;
16
+ type: BlockType;
17
+ data: Record<string, unknown>;
18
+ }
19
+
20
+ export interface ChatMsg {
21
+ id: string;
22
+ role: 'user' | 'assistant' | 'system';
23
+ content: string;
24
+ thinking?: boolean;
25
+ }
26
+
27
+ export interface McpToolInfo {
28
+ name: string;
29
+ description: string;
30
+ inputSchema?: Record<string, unknown>;
31
+ }
32
+
33
+ function uuid() {
34
+ return 'b_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
35
+ }
36
+
37
+ function msgId() {
38
+ return 'm_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
39
+ }
40
+
41
+ function createCanvas() {
42
+ // ── State ────────────────────────────────────────────────────────────────
43
+ let blocks = $state<Block[]>([]);
44
+ let mode = $state<Mode>('auto');
45
+ let llm = $state<LLMId>('haiku');
46
+ let mcpUrl = $state('');
47
+ let mcpConnected = $state(false);
48
+ let mcpConnecting = $state(false);
49
+ let mcpName = $state('');
50
+ let mcpTools = $state<McpToolInfo[]>([]);
51
+ let messages = $state<ChatMsg[]>([]);
52
+ let generating = $state(false);
53
+ let statusText = $state('● aucun MCP connecté');
54
+ let statusColor = $state('text-zinc-600');
55
+
56
+ // ── Derived ──────────────────────────────────────────────────────────────
57
+ const blockCount = $derived(blocks.length);
58
+ const isEmpty = $derived(blocks.length === 0);
59
+
60
+ // ── Block actions ────────────────────────────────────────────────────────
61
+ function addBlock(type: BlockType, data: Record<string, unknown> = {}): Block {
62
+ const block: Block = { id: uuid(), type, data };
63
+ blocks = [...blocks, block];
64
+ return block;
65
+ }
66
+
67
+ function removeBlock(id: string) {
68
+ blocks = blocks.filter((b) => b.id !== id);
69
+ }
70
+
71
+ function updateBlock(id: string, data: Partial<Record<string, unknown>>) {
72
+ blocks = blocks.map((b) => b.id === id ? { ...b, data: { ...b.data, ...data } } : b);
73
+ }
74
+
75
+ function moveBlock(fromId: string, toId: string) {
76
+ const fi = blocks.findIndex((b) => b.id === fromId);
77
+ const ti = blocks.findIndex((b) => b.id === toId);
78
+ if (fi < 0 || ti < 0 || fi === ti) return;
79
+ const next = [...blocks];
80
+ const [moved] = next.splice(fi, 1);
81
+ next.splice(ti, 0, moved);
82
+ blocks = next;
83
+ }
84
+
85
+ function clearBlocks() {
86
+ blocks = [];
87
+ }
88
+
89
+ function setBlocks(newBlocks: Block[]) {
90
+ blocks = newBlocks;
91
+ }
92
+
93
+ // ── Chat ─────────────────────────────────────────────────────────────────
94
+ function addMsg(role: ChatMsg['role'], content: string, thinking = false): ChatMsg {
95
+ const msg: ChatMsg = { id: msgId(), role, content, thinking };
96
+ messages = [...messages, msg];
97
+ return msg;
98
+ }
99
+
100
+ function updateMsg(id: string, content: string, thinking = false) {
101
+ messages = messages.map((m) => m.id === id ? { ...m, content, thinking } : m);
102
+ }
103
+
104
+ function clearMessages() {
105
+ messages = [];
106
+ }
107
+
108
+ // ── MCP ──────────────────────────────────────────────────────────────────
109
+ function setMcpConnecting(connecting: boolean) {
110
+ mcpConnecting = connecting;
111
+ if (connecting) {
112
+ statusText = '● connexion…';
113
+ statusColor = 'text-amber-400';
114
+ }
115
+ }
116
+
117
+ function setMcpConnected(
118
+ connected: boolean,
119
+ name?: string,
120
+ tools?: McpToolInfo[]
121
+ ) {
122
+ mcpConnected = connected;
123
+ if (name) mcpName = name;
124
+ if (tools) mcpTools = tools;
125
+ if (connected) {
126
+ statusText = `● ${name} · ${tools?.length ?? 0} tools`;
127
+ statusColor = 'text-teal-400';
128
+ } else {
129
+ statusText = '● aucun MCP connecté';
130
+ statusColor = 'text-zinc-600';
131
+ }
132
+ }
133
+
134
+ function setMcpError(err: string) {
135
+ mcpConnected = false;
136
+ mcpConnecting = false;
137
+ statusText = `● erreur: ${err.slice(0, 40)}`;
138
+ statusColor = 'text-red-400';
139
+ }
140
+
141
+ // ── HyperSkill ───────────────────────────────────────────────────────────
142
+ function buildSkillJSON() {
143
+ return {
144
+ version: '1.0',
145
+ name: 'skill-' + Date.now(),
146
+ created: new Date().toISOString(),
147
+ mcp: mcpUrl,
148
+ llm,
149
+ blocks: blocks.map((b) => ({ type: b.type, data: b.data })),
150
+ };
151
+ }
152
+
153
+ function buildHyperskillParam(): string {
154
+ const json = JSON.stringify(buildSkillJSON());
155
+ return btoa(unescape(encodeURIComponent(json)));
156
+ }
157
+
158
+ function loadFromParam(param: string): boolean {
159
+ try {
160
+ const json = decodeURIComponent(escape(atob(param)));
161
+ const skill = JSON.parse(json) as {
162
+ mcp?: string; llm?: LLMId;
163
+ blocks?: { type: BlockType; data: Record<string, unknown> }[];
164
+ };
165
+ if (skill.mcp) mcpUrl = skill.mcp;
166
+ if (skill.llm) llm = skill.llm;
167
+ if (skill.blocks) {
168
+ blocks = skill.blocks.map((b) => ({ id: uuid(), type: b.type, data: b.data }));
169
+ }
170
+ return true;
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+
176
+ // ── Return public API ────────────────────────────────────────────────────
177
+ return {
178
+ // State getters (reactive)
179
+ get blocks() { return blocks; },
180
+ get mode() { return mode; },
181
+ get llm() { return llm; },
182
+ get mcpUrl() { return mcpUrl; },
183
+ get mcpConnected() { return mcpConnected; },
184
+ get mcpConnecting() { return mcpConnecting; },
185
+ get mcpName() { return mcpName; },
186
+ get mcpTools() { return mcpTools; },
187
+ get messages() { return messages; },
188
+ get generating() { return generating; },
189
+ get statusText() { return statusText; },
190
+ get statusColor() { return statusColor; },
191
+ get blockCount() { return blockCount; },
192
+ get isEmpty() { return isEmpty; },
193
+
194
+ // Setters
195
+ setMode(m: Mode) { mode = m; },
196
+ setLlm(l: LLMId) { llm = l; },
197
+ setMcpUrl(u: string) { mcpUrl = u; },
198
+ setGenerating(g: boolean) { generating = g; },
199
+
200
+ // Block actions
201
+ addBlock, removeBlock, updateBlock, moveBlock, clearBlocks, setBlocks,
202
+
203
+ // Chat
204
+ addMsg, updateMsg, clearMessages,
205
+
206
+ // MCP
207
+ setMcpConnecting, setMcpConnected, setMcpError,
208
+
209
+ // HyperSkill
210
+ buildSkillJSON, buildHyperskillParam, loadFromParam,
211
+ };
212
+ }
213
+
214
+ export const canvas = createCanvas();
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { computeHash, diffSkills, decodeHyperSkill, encodeHyperSkill } from '../src/hyperskill/format.js';
3
+
4
+ describe('computeHash', () => {
5
+ it('returns a 64-char hex SHA-256', async () => {
6
+ const hash = await computeHash('https://example.com', { foo: 'bar' });
7
+ expect(hash).toMatch(/^[0-9a-f]{64}$/);
8
+ });
9
+
10
+ it('is deterministic', async () => {
11
+ const a = await computeHash('https://x.com', { n: 1 });
12
+ const b = await computeHash('https://x.com', { n: 1 });
13
+ expect(a).toBe(b);
14
+ });
15
+
16
+ it('differs for different inputs', async () => {
17
+ const a = await computeHash('https://x.com', { n: 1 });
18
+ const b = await computeHash('https://x.com', { n: 2 });
19
+ expect(a).not.toBe(b);
20
+ });
21
+ });
22
+
23
+ describe('diffSkills', () => {
24
+ it('returns empty for identical objects', () => {
25
+ expect(diffSkills({ a: 1, b: 'x' }, { a: 1, b: 'x' })).toEqual([]);
26
+ });
27
+
28
+ it('detects changed keys', () => {
29
+ const changed = diffSkills({ a: 1, b: 2 }, { a: 1, b: 3 });
30
+ expect(changed).toContain('b');
31
+ expect(changed).not.toContain('a');
32
+ });
33
+
34
+ it('detects added keys', () => {
35
+ const changed = diffSkills({ a: 1 }, { a: 1, b: 2 });
36
+ expect(changed).toContain('b');
37
+ });
38
+
39
+ it('detects removed keys', () => {
40
+ const changed = diffSkills({ a: 1, b: 2 }, { a: 1 });
41
+ expect(changed).toContain('b');
42
+ });
43
+
44
+ it('handles non-object inputs', () => {
45
+ expect(diffSkills('x', 'x')).toEqual([]);
46
+ expect(diffSkills('x', 'y')).toContain('(root)');
47
+ });
48
+ });
49
+
50
+ describe('encodeHyperSkill / decodeHyperSkill', () => {
51
+ // jsdom provides TextEncoder/TextDecoder and crypto.subtle
52
+ it('round-trips a skill', async () => {
53
+ const skill = {
54
+ meta: { title: 'test', mcp: 'https://mcp.example.com', mcpName: 'example' },
55
+ content: { blocks: [{ type: 'stat', data: { label: 'KPI', value: '42' } }] },
56
+ };
57
+ const url = await encodeHyperSkill(skill, 'https://example.com/viewer');
58
+ expect(url).toContain('?hs=');
59
+
60
+ const decoded = await decodeHyperSkill(url);
61
+ expect(decoded.meta.title).toBe('test');
62
+ expect(decoded.meta.mcp).toBe('https://mcp.example.com');
63
+ const content = decoded.content as typeof skill.content;
64
+ expect(content.blocks[0].type).toBe('stat');
65
+ });
66
+
67
+ it('decodes from raw base64 param', async () => {
68
+ const skill = { meta: { title: 'raw' }, content: { x: 1 } };
69
+ const url = await encodeHyperSkill(skill, 'https://example.com');
70
+ const param = new URL(url).searchParams.get('hs')!;
71
+ const decoded = await decodeHyperSkill(param);
72
+ expect(decoded.meta.title).toBe('raw');
73
+ });
74
+ });
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import {
3
+ createSkill, updateSkill, deleteSkill, getSkill, listSkills,
4
+ clearSkills, loadSkills, loadDemoSkills, onSkillsChange,
5
+ } from '../src/skills/registry.js';
6
+
7
+ beforeEach(() => clearSkills());
8
+
9
+ describe('createSkill', () => {
10
+ it('creates a skill with generated id', () => {
11
+ const s = createSkill({ name: 'test', blocks: [], tags: [] });
12
+ expect(s.id).toMatch(/^sk_/);
13
+ expect(s.name).toBe('test');
14
+ expect(s.createdAt).toBeGreaterThan(0);
15
+ });
16
+
17
+ it('stores mcp and mcpName', () => {
18
+ const s = createSkill({ name: 'x', mcp: 'https://mcp.example.com', mcpName: 'example', blocks: [], tags: [] });
19
+ expect(s.mcp).toBe('https://mcp.example.com');
20
+ expect(s.mcpName).toBe('example');
21
+ });
22
+ });
23
+
24
+ describe('updateSkill', () => {
25
+ it('updates an existing skill', () => {
26
+ const s = createSkill({ name: 'old', blocks: [], tags: [] });
27
+ const updated = updateSkill(s.id, { name: 'new' });
28
+ expect(updated?.name).toBe('new');
29
+ expect(updated?.updatedAt).toBeGreaterThanOrEqual(s.createdAt);
30
+ });
31
+
32
+ it('returns null for unknown id', () => {
33
+ expect(updateSkill('nonexistent', { name: 'x' })).toBeNull();
34
+ });
35
+ });
36
+
37
+ describe('deleteSkill', () => {
38
+ it('removes a skill', () => {
39
+ const s = createSkill({ name: 'del', blocks: [], tags: [] });
40
+ expect(deleteSkill(s.id)).toBe(true);
41
+ expect(getSkill(s.id)).toBeUndefined();
42
+ });
43
+
44
+ it('returns false for unknown id', () => {
45
+ expect(deleteSkill('ghost')).toBe(false);
46
+ });
47
+ });
48
+
49
+ describe('listSkills', () => {
50
+ it('returns skills sorted by createdAt desc', async () => {
51
+ createSkill({ name: 'a', blocks: [], tags: [] });
52
+ await new Promise(r => setTimeout(r, 2));
53
+ createSkill({ name: 'b', blocks: [], tags: [] });
54
+ const list = listSkills();
55
+ expect(list[0].name).toBe('b');
56
+ expect(list[1].name).toBe('a');
57
+ });
58
+ });
59
+
60
+ describe('onSkillsChange', () => {
61
+ it('fires callback on create', () => {
62
+ let count = 0;
63
+ const unsub = onSkillsChange(() => count++);
64
+ createSkill({ name: 'x', blocks: [], tags: [] });
65
+ expect(count).toBe(1);
66
+ unsub();
67
+ createSkill({ name: 'y', blocks: [], tags: [] });
68
+ expect(count).toBe(1); // unsubscribed
69
+ });
70
+ });
71
+
72
+ describe('loadDemoSkills', () => {
73
+ it('loads 3 demo skills with mcp/mcpName', () => {
74
+ loadDemoSkills();
75
+ const skills = listSkills();
76
+ expect(skills.length).toBe(3);
77
+ expect(skills.every(s => s.mcp === 'https://mcp.code4code.eu/mcp')).toBe(true);
78
+ expect(skills.every(s => s.mcpName === 'code4code')).toBe(true);
79
+ });
80
+
81
+ it('does not overwrite existing skills', () => {
82
+ createSkill({ name: 'mine', blocks: [], tags: [] });
83
+ loadDemoSkills();
84
+ expect(listSkills().length).toBe(1);
85
+ });
86
+ });
87
+
88
+ describe('loadSkills', () => {
89
+ it('replaces all skills', () => {
90
+ createSkill({ name: 'old', blocks: [], tags: [] });
91
+ const now = Date.now();
92
+ loadSkills([{ id: 'sk_x', name: 'new', blocks: [], tags: [], createdAt: now, updatedAt: now }]);
93
+ const list = listSkills();
94
+ expect(list.length).toBe(1);
95
+ expect(list[0].name).toBe('new');
96
+ });
97
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "lib": [
7
+ "ES2022",
8
+ "DOM",
9
+ "DOM.Iterable"
10
+ ],
11
+ "strict": true,
12
+ "skipLibCheck": true,
13
+ "verbatimModuleSyntax": true,
14
+ "outDir": "./dist",
15
+ "rootDir": "./src",
16
+ "declaration": true
17
+ },
18
+ "include": [
19
+ "src/**/*.ts",
20
+ "src/**/*.svelte"
21
+ ]
22
+ }