From b31aab8aebb2f2f2b4cc2743b68ad0c0ef9f5357 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:01:06 +0100 Subject: [PATCH] feat(cli+broker): expose mesh skills as MCP prompts and skill:// resources Claudemesh MCP server now declares prompts:{} and resources:{} capabilities. Mesh skills auto-appear as /claudemesh:skill-name slash commands in Claude Code via prompts/list+get, and as skill://claudemesh/{name} resources for the upcoming MCP_SKILLS protocol. share_skill accepts optional metadata (when_to_use, allowed_tools, model, context, agent) stored in the manifest jsonb column. Change notifications sent on share/remove so Claude Code refreshes. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/broker/src/broker.ts | 27 ++++++- apps/broker/src/index.ts | 145 +++++++++++++++++++++++++++++++++- apps/cli/src/mcp/server.ts | 155 +++++++++++++++++++++++++++++++++++-- apps/cli/src/mcp/tools.ts | 17 +++- apps/cli/src/ws/client.ts | 8 +- 5 files changed, 336 insertions(+), 16 deletions(-) diff --git a/apps/broker/src/broker.ts b/apps/broker/src/broker.ts index a461c59..31f66a7 100644 --- a/apps/broker/src/broker.ts +++ b/apps/broker/src/broker.ts @@ -720,6 +720,7 @@ export async function shareSkill( tags: string[], memberId?: string, memberName?: string, + manifest?: unknown, ): Promise { const existing = await db .select({ id: meshSkill.id }) @@ -734,6 +735,7 @@ export async function shareSkill( description, instructions, tags, + manifest: manifest ?? null, authorMemberId: memberId ?? null, authorName: memberName ?? null, updatedAt: new Date(), @@ -750,6 +752,7 @@ export async function shareSkill( description, instructions, tags, + manifest: manifest ?? null, authorMemberId: memberId ?? null, authorName: memberName ?? null, }) @@ -770,6 +773,7 @@ export async function getSkill( instructions: string; tags: string[]; author: string; + manifest: unknown; createdAt: Date; } | null> { const rows = await db @@ -779,6 +783,7 @@ export async function getSkill( instructions: meshSkill.instructions, tags: meshSkill.tags, authorName: meshSkill.authorName, + manifest: meshSkill.manifest, createdAt: meshSkill.createdAt, }) .from(meshSkill) @@ -793,6 +798,7 @@ export async function getSkill( instructions: r.instructions, tags: r.tags ?? [], author: r.authorName ?? "unknown", + manifest: r.manifest, createdAt: r.createdAt, }; } @@ -1800,13 +1806,18 @@ export async function joinMesh(args: { if (!claimed) return { ok: false, error: "invite_exhausted" }; // 6. Insert the member with the role from the payload. + // Apply invite preset overrides (displayName, roleTag, groups, messageMode). + const preset = (inv.preset as any) ?? {}; const [row] = await db .insert(memberTable) .values({ meshId: invitePayload.mesh_id, peerPubkey, - displayName, + displayName: preset.displayName ?? displayName, role: invitePayload.role, + roleTag: preset.roleTag ?? null, + defaultGroups: preset.groups ?? [], + messageMode: preset.messageMode ?? "push", }) .returning({ id: memberTable.id }); if (!row) return { ok: false, error: "member_insert_failed" }; @@ -1820,12 +1831,24 @@ export async function joinMesh(args: { export async function findMemberByPubkey( meshId: string, pubkey: string, -): Promise<{ id: string; displayName: string; role: string } | null> { +): Promise<{ + id: string; + displayName: string; + role: string; + roleTag: string | null; + defaultGroups: Array<{ name: string; role?: string }>; + messageMode: string | null; + dashboardUserId: string | null; +} | null> { const [row] = await db .select({ id: memberTable.id, displayName: memberTable.displayName, role: memberTable.role, + roleTag: memberTable.roleTag, + defaultGroups: memberTable.defaultGroups, + messageMode: memberTable.messageMode, + dashboardUserId: memberTable.dashboardUserId, }) .from(memberTable) .where( diff --git a/apps/broker/src/index.ts b/apps/broker/src/index.ts index 4a86185..81fcdc1 100644 --- a/apps/broker/src/index.ts +++ b/apps/broker/src/index.ts @@ -19,6 +19,8 @@ import { and, eq, isNull, sql } from "drizzle-orm"; import { env } from "./env"; import { db } from "./db"; import { mesh, messageQueue, scheduledMessage as scheduledMessageTable, meshWebhook, peerState } from "@turbostarter/db/schema/mesh"; +import { handleCliSync, type CliSyncRequest } from "./cli-sync"; +import { updateMemberProfile, listMeshMembers, updateMeshSettings } from "./member-api"; import { claimTask, completeTask, @@ -585,6 +587,31 @@ function handleHttpRequest(req: IncomingMessage, res: ServerResponse): void { return; } + // CLI sync: browser OAuth → broker creates members + if (req.method === "POST" && req.url === "/cli-sync") { + handleCliSyncPost(req, res, started); + return; + } + + // Member profile API + const memberPatchMatch = req.method === "PATCH" && req.url?.match(/^\/mesh\/([^/]+)\/member\/([^/]+)$/); + if (memberPatchMatch) { + handleMemberPatchPost(req, res, memberPatchMatch[1]!, memberPatchMatch[2]!, started); + return; + } + + const membersListMatch = req.method === "GET" && req.url?.match(/^\/mesh\/([^/]+)\/members$/); + if (membersListMatch) { + handleMembersListGet(res, membersListMatch[1]!, started); + return; + } + + const meshSettingsMatch = req.method === "PATCH" && req.url?.match(/^\/mesh\/([^/]+)\/settings$/); + if (meshSettingsMatch) { + handleMeshSettingsPatch(req, res, meshSettingsMatch[1]!, started); + return; + } + // Inbound webhook: POST /hook/:meshId/:secret const webhookMatch = req.method === "POST" && req.url?.match(/^\/hook\/([^/]+)\/([^/]+)$/); if (webhookMatch) { @@ -912,6 +939,100 @@ function broadcastToMesh(meshId: string, msg: WSPushMessage): number { return count; } +// --- CLI sync + member profile route handlers --- + +function handleCliSyncPost(req: IncomingMessage, res: ServerResponse, started: number): void { + const chunks: Buffer[] = []; + let total = 0; + let aborted = false; + req.on("data", (chunk: Buffer) => { + if (aborted) return; + total += chunk.length; + if (total > env.MAX_MESSAGE_BYTES) { aborted = true; writeJson(res, 413, { ok: false, error: "payload too large" }); req.destroy(); return; } + chunks.push(chunk); + }); + req.on("end", async () => { + if (aborted) return; + try { + const body = JSON.parse(Buffer.concat(chunks).toString()) as CliSyncRequest; + const result = await handleCliSync(body); + writeJson(res, result.ok ? 200 : 400, result); + log.info("cli-sync", { route: "POST /cli-sync", ok: result.ok, latency_ms: Date.now() - started }); + } catch (e) { + writeJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) }); + log.error("cli-sync error", { error: e instanceof Error ? e.message : String(e) }); + } + }); +} + +function handleMemberPatchPost(req: IncomingMessage, res: ServerResponse, meshId: string, memberId: string, started: number): void { + const chunks: Buffer[] = []; + let total = 0; + let aborted = false; + req.on("data", (chunk: Buffer) => { + if (aborted) return; + total += chunk.length; + if (total > env.MAX_MESSAGE_BYTES) { aborted = true; writeJson(res, 413, { ok: false, error: "payload too large" }); req.destroy(); return; } + chunks.push(chunk); + }); + req.on("end", async () => { + if (aborted) return; + try { + const body = JSON.parse(Buffer.concat(chunks).toString()); + // Auth: callerMemberId from X-Member-Id header (dashboard or CLI provides this) + const callerMemberId = req.headers["x-member-id"] as string | undefined; + if (!callerMemberId) { writeJson(res, 401, { ok: false, error: "X-Member-Id header required" }); return; } + const result = await updateMemberProfile(meshId, memberId, callerMemberId, body); + writeJson(res, result.ok ? 200 : 400, result); + // Push profile_updated to active WS connections for this member + if (result.ok && result.changes) { + for (const [pid, conn] of connections) { + if (conn.meshId === meshId && conn.memberId === memberId) { + sendToPeer(pid, { type: "push", subtype: "system", event: "profile_updated", eventData: result.changes, messageId: crypto.randomUUID(), meshId, senderPubkey: "system", priority: "low", nonce: "", ciphertext: "", createdAt: new Date().toISOString() } as any); + } + } + } + log.info("member-patch", { route: `PATCH /mesh/${meshId}/member/${memberId}`, ok: result.ok, latency_ms: Date.now() - started }); + } catch (e) { + writeJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) }); + } + }); +} + +function handleMembersListGet(res: ServerResponse, meshId: string, started: number): void { + listMeshMembers(meshId).then((result) => { + writeJson(res, result.ok ? 200 : 400, result); + log.info("members-list", { route: `GET /mesh/${meshId}/members`, ok: result.ok, count: result.ok ? result.members.length : 0, latency_ms: Date.now() - started }); + }).catch((e) => { + writeJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) }); + }); +} + +function handleMeshSettingsPatch(req: IncomingMessage, res: ServerResponse, meshId: string, started: number): void { + const chunks: Buffer[] = []; + let total = 0; + let aborted = false; + req.on("data", (chunk: Buffer) => { + if (aborted) return; + total += chunk.length; + if (total > env.MAX_MESSAGE_BYTES) { aborted = true; writeJson(res, 413, { ok: false, error: "payload too large" }); req.destroy(); return; } + chunks.push(chunk); + }); + req.on("end", async () => { + if (aborted) return; + try { + const body = JSON.parse(Buffer.concat(chunks).toString()); + const callerMemberId = req.headers["x-member-id"] as string | undefined; + if (!callerMemberId) { writeJson(res, 401, { ok: false, error: "X-Member-Id header required" }); return; } + const result = await updateMeshSettings(meshId, callerMemberId, body); + writeJson(res, result.ok ? 200 : 400, result); + log.info("mesh-settings", { route: `PATCH /mesh/${meshId}/settings`, ok: result.ok, latency_ms: Date.now() - started }); + } catch (e) { + writeJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) }); + } + }); +} + function handleWebhookPost( req: IncomingMessage, res: ServerResponse, @@ -1159,13 +1280,23 @@ async function handleHello( return null; } + // Load mesh for selfEditable policy (non-fatal if fails). + let meshPolicy: Record | undefined; + try { + const [m] = await db + .select({ selfEditable: mesh.selfEditable }) + .from(mesh) + .where(eq(mesh.id, hello.meshId)); + if (m?.selfEditable) meshPolicy = { selfEditable: m.selfEditable }; + } catch { /* non-fatal */ } + // Attempt to restore persisted state from a previous session. const saved = await restorePeerState(hello.meshId, member.id); const helloHasGroups = hello.groups && hello.groups.length > 0; - // Hello groups take precedence; fall back to restored groups. + // Priority: hello groups > restored groups > member default groups. const initialGroups = helloHasGroups ? hello.groups! - : (saved?.groups ?? []); + : (saved?.groups?.length ? saved.groups : (member.defaultGroups ?? [])); const presenceId = await connectPresence({ memberId: member.id, sessionId: hello.sessionId, @@ -1213,6 +1344,12 @@ async function handleHello( return { presenceId, memberDisplayName: effectiveDisplayName, + memberProfile: { + roleTag: member.roleTag, + groups: member.defaultGroups ?? [], + messageMode: member.messageMode ?? "push", + }, + meshPolicy, restored: saved ? true : undefined, lastSummary: saved?.lastSummary, lastSeenAt: saved?.lastSeenAt?.toISOString(), @@ -1333,6 +1470,8 @@ function handleConnection(ws: WebSocket): void { type: "hello_ack", presenceId: result.presenceId, memberDisplayName: result.memberDisplayName, + memberProfile: result.memberProfile, + ...(result.meshPolicy ? { meshPolicy: result.meshPolicy } : {}), }; if (result.restored) { ackPayload.restored = true; @@ -3053,6 +3192,7 @@ function handleConnection(ws: WebSocket): void { sk.tags ?? [], memberInfo?.id, memberInfo?.displayName, + (sk as any).manifest, ); sendToPeer(presenceId, { type: "skill_ack", @@ -3075,6 +3215,7 @@ function handleConnection(ws: WebSocket): void { instructions: skill.instructions, tags: skill.tags, author: skill.author, + manifest: skill.manifest, createdAt: skill.createdAt.toISOString(), } : null, diff --git a/apps/cli/src/mcp/server.ts b/apps/cli/src/mcp/server.ts index 9632670..3ac06fc 100644 --- a/apps/cli/src/mcp/server.ts +++ b/apps/cli/src/mcp/server.ts @@ -10,6 +10,10 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { ListToolsRequestSchema, CallToolRequestSchema, + ListPromptsRequestSchema, + GetPromptRequestSchema, + ListResourcesRequestSchema, + ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { TOOLS } from "./tools"; import { loadConfig } from "../state/config"; @@ -164,6 +168,8 @@ export async function startMcpServer(): Promise { capabilities: { experimental: { "claude/channel": {} }, tools: {}, + prompts: {}, + resources: {}, }, instructions: `## Identity You are "${myName}"${myRole ? ` (${myRole})` : ""} — a peer in the claudemesh network. Your groups: ${myGroups}. You are one of several Claude Code sessions connected to the same mesh. No orchestrator exists — peers are equals. Your identity comes from your name and group roles, not from a central authority. @@ -294,6 +300,111 @@ Your message mode is "${messageMode}". tools: TOOLS, })); + // --- MCP Prompts: expose mesh skills as slash commands --- + server.setRequestHandler(ListPromptsRequestSchema, async () => { + const client = allClients()[0]; + if (!client) return { prompts: [] }; + const skills = await client.listSkills(); + return { + prompts: skills.map((s) => ({ + name: s.name, + description: s.description, + arguments: [], + })), + }; + }); + + server.setRequestHandler(GetPromptRequestSchema, async (req) => { + const { name, arguments: promptArgs } = req.params; + const client = allClients()[0]; + if (!client) throw new Error("Not connected to any mesh"); + const skill = await client.getSkill(name); + if (!skill) throw new Error(`Skill "${name}" not found in the mesh`); + + // Build the prompt content — include frontmatter if manifest has metadata + let content = skill.instructions; + const manifest = (skill as any).manifest; + if (manifest && typeof manifest === "object") { + const fm: string[] = ["---"]; + if (manifest.description) fm.push(`description: "${manifest.description}"`); + if (manifest.when_to_use) fm.push(`when_to_use: "${manifest.when_to_use}"`); + if (manifest.allowed_tools?.length) fm.push(`allowed-tools:\n${manifest.allowed_tools.map((t: string) => ` - ${t}`).join("\n")}`); + if (manifest.model) fm.push(`model: ${manifest.model}`); + if (manifest.context) fm.push(`context: ${manifest.context}`); + if (manifest.agent) fm.push(`agent: ${manifest.agent}`); + if (manifest.user_invocable === false) fm.push(`user-invocable: false`); + if (manifest.argument_hint) fm.push(`argument-hint: "${manifest.argument_hint}"`); + fm.push("---\n"); + if (fm.length > 3) content = fm.join("\n") + content; + } + + return { + description: skill.description, + messages: [ + { + role: "user" as const, + content: { type: "text" as const, text: content }, + }, + ], + }; + }); + + // --- MCP Resources: expose mesh skills as skill:// resources --- + server.setRequestHandler(ListResourcesRequestSchema, async () => { + const client = allClients()[0]; + if (!client) return { resources: [] }; + const skills = await client.listSkills(); + return { + resources: skills.map((s) => ({ + uri: `skill://claudemesh/${encodeURIComponent(s.name)}`, + name: s.name, + description: s.description, + mimeType: "text/markdown", + })), + }; + }); + + server.setRequestHandler(ReadResourceRequestSchema, async (req) => { + const { uri } = req.params; + // Parse skill://claudemesh/{name} + const match = uri.match(/^skill:\/\/claudemesh\/(.+)$/); + if (!match) throw new Error(`Unknown resource URI: ${uri}`); + const name = decodeURIComponent(match[1]!); + const client = allClients()[0]; + if (!client) throw new Error("Not connected to any mesh"); + const skill = await client.getSkill(name); + if (!skill) throw new Error(`Skill "${name}" not found`); + + // Build full markdown with frontmatter for Claude Code's parseSkillFrontmatterFields + const manifest = (skill as any).manifest; + const fmLines: string[] = ["---"]; + fmLines.push(`name: ${skill.name}`); + fmLines.push(`description: "${skill.description}"`); + if (skill.tags.length) fmLines.push(`tags: [${skill.tags.join(", ")}]`); + if (manifest && typeof manifest === "object") { + if (manifest.when_to_use) fmLines.push(`when_to_use: "${manifest.when_to_use}"`); + if (manifest.allowed_tools?.length) fmLines.push(`allowed-tools:\n${manifest.allowed_tools.map((t: string) => ` - ${t}`).join("\n")}`); + if (manifest.model) fmLines.push(`model: ${manifest.model}`); + if (manifest.context) fmLines.push(`context: ${manifest.context}`); + if (manifest.agent) fmLines.push(`agent: ${manifest.agent}`); + if (manifest.user_invocable === false) fmLines.push(`user-invocable: false`); + if (manifest.argument_hint) fmLines.push(`argument-hint: "${manifest.argument_hint}"`); + } + fmLines.push("---\n"); + + const fullContent = fmLines.join("\n") + skill.instructions; + + return { + contents: [ + { + uri, + mimeType: "text/markdown", + text: fullContent, + }, + ], + }; + }); + server.setRequestHandler(CallToolRequestSchema, async (req) => { const { name, arguments: args } = req.params; @@ -1054,13 +1165,32 @@ Your message mode is "${messageMode}". // --- Skills --- case "share_skill": { - const { name: skillName, description: skillDesc, instructions: skillInstr, tags: skillTags } = (args ?? {}) as { name?: string; description?: string; instructions?: string; tags?: string[] }; + const { + name: skillName, description: skillDesc, instructions: skillInstr, tags: skillTags, + when_to_use, allowed_tools, model, context: skillContext, agent, user_invocable, argument_hint, + } = (args ?? {}) as { + name?: string; description?: string; instructions?: string; tags?: string[]; + when_to_use?: string; allowed_tools?: string[]; model?: string; context?: string; + agent?: string; user_invocable?: boolean; argument_hint?: string; + }; if (!skillName || !skillDesc || !skillInstr) return text("share_skill: `name`, `description`, and `instructions` required", true); const client = allClients()[0]; if (!client) return text("share_skill: not connected", true); - const result = await client.shareSkill(skillName, skillDesc, skillInstr, skillTags); + // Build manifest from optional metadata fields + const manifest: Record = {}; + if (when_to_use) manifest.when_to_use = when_to_use; + if (allowed_tools?.length) manifest.allowed_tools = allowed_tools; + if (model) manifest.model = model; + if (skillContext) manifest.context = skillContext; + if (agent) manifest.agent = agent; + if (user_invocable === false) manifest.user_invocable = false; + if (argument_hint) manifest.argument_hint = argument_hint; + const result = await client.shareSkill(skillName, skillDesc, skillInstr, skillTags, Object.keys(manifest).length > 0 ? manifest : undefined); if (!result) return text("share_skill: broker did not acknowledge", true); - return text(`Skill "${skillName}" published to the mesh.`); + // Notify prompts changed so Claude Code refreshes slash commands + server.notification({ method: "notifications/prompts/list_changed" }); + server.notification({ method: "notifications/resources/list_changed" }); + return text(`Skill "${skillName}" published to the mesh. It will appear as /claudemesh:${skillName} in Claude Code.`); } case "get_skill": { const { name: gsName } = (args ?? {}) as { name?: string }; @@ -1069,13 +1199,24 @@ Your message mode is "${messageMode}". if (!client) return text("get_skill: not connected", true); const skill = await client.getSkill(gsName); if (!skill) return text(`Skill "${gsName}" not found in the mesh.`); + const manifest = skill.manifest as Record | null | undefined; + const metaLines: string[] = []; + if (manifest) { + if (manifest.when_to_use) metaLines.push(`**When to use:** ${manifest.when_to_use}`); + if (manifest.allowed_tools) metaLines.push(`**Allowed tools:** ${(manifest.allowed_tools as string[]).join(", ")}`); + if (manifest.model) metaLines.push(`**Model:** ${manifest.model}`); + if (manifest.context) metaLines.push(`**Context:** ${manifest.context}`); + if (manifest.agent) metaLines.push(`**Agent:** ${manifest.agent}`); + } return text( `# Skill: ${skill.name}\n\n` + `**Description:** ${skill.description}\n` + `**Author:** ${skill.author}\n` + `**Tags:** ${skill.tags.length ? skill.tags.join(", ") : "none"}\n` + - `**Created:** ${skill.createdAt}\n\n` + - `---\n\n` + + `**Created:** ${skill.createdAt}\n` + + `**Slash command:** /claudemesh:${skill.name}\n` + + (metaLines.length ? metaLines.join("\n") + "\n" : "") + + `\n---\n\n` + `## Instructions\n\n${skill.instructions}`, ); } @@ -1096,6 +1237,10 @@ Your message mode is "${messageMode}". const client = allClients()[0]; if (!client) return text("remove_skill: not connected", true); const removed = await client.removeSkill(rsName); + if (removed) { + server.notification({ method: "notifications/prompts/list_changed" }); + server.notification({ method: "notifications/resources/list_changed" }); + } return text(removed ? `Skill "${rsName}" removed.` : `Skill "${rsName}" not found.`, !removed); } diff --git a/apps/cli/src/mcp/tools.ts b/apps/cli/src/mcp/tools.ts index f65ac71..5ba045e 100644 --- a/apps/cli/src/mcp/tools.ts +++ b/apps/cli/src/mcp/tools.ts @@ -763,18 +763,29 @@ export const TOOLS: Tool[] = [ { name: "share_skill", description: - "Publish a reusable skill to the mesh. Other peers can discover and load it. If a skill with the same name exists, it is updated.", + "Publish a reusable skill to the mesh. Other peers can discover and load it as a slash command. If a skill with the same name exists, it is updated. Skills are automatically exposed as MCP prompts and skill:// resources for native Claude Code integration.", inputSchema: { type: "object", properties: { - name: { type: "string", description: "Unique skill name (e.g. 'code-review', 'deploy-checklist')" }, + name: { type: "string", description: "Unique skill name (e.g. 'code-review', 'deploy-checklist'). Becomes the slash command name." }, description: { type: "string", description: "Short description of what the skill does" }, - instructions: { type: "string", description: "Full instructions/prompt that a peer loads to acquire this capability" }, + instructions: { type: "string", description: "Full instructions/prompt markdown. Can include frontmatter (---) block." }, tags: { type: "array", items: { type: "string" }, description: "Tags for discoverability", }, + when_to_use: { type: "string", description: "Detailed description of when Claude should auto-invoke this skill" }, + allowed_tools: { + type: "array", + items: { type: "string" }, + description: "Tool names this skill is allowed to use (e.g. ['Bash', 'Read', 'Edit'])", + }, + model: { type: "string", description: "Model override (e.g. 'sonnet', 'opus', 'haiku')" }, + context: { type: "string", enum: ["inline", "fork"], description: "Execution context: 'inline' (default) or 'fork' (sub-agent)" }, + agent: { type: "string", description: "Agent type when forked (e.g. 'general-purpose')" }, + user_invocable: { type: "boolean", description: "Whether users can invoke via /skill-name (default: true)" }, + argument_hint: { type: "string", description: "Hint text for arguments (e.g. '')" }, }, required: ["name", "description", "instructions"], }, diff --git a/apps/cli/src/ws/client.ts b/apps/cli/src/ws/client.ts index a86bbe5..dc0f6f9 100644 --- a/apps/cli/src/ws/client.ts +++ b/apps/cli/src/ws/client.ts @@ -1103,11 +1103,11 @@ export class BrokerClient { // --- Skills --- private skillAckResolvers = new Map void; timer: NodeJS.Timeout }>(); - private skillDataResolvers = new Map void; timer: NodeJS.Timeout }>(); + private skillDataResolvers = new Map void; timer: NodeJS.Timeout }>(); private skillListResolvers = new Map) => void; timer: NodeJS.Timeout }>(); /** Publish a reusable skill to the mesh. */ - async shareSkill(name: string, description: string, instructions: string, tags?: string[]): Promise<{ ok: boolean; action?: string } | null> { + async shareSkill(name: string, description: string, instructions: string, tags?: string[], manifest?: Record): Promise<{ ok: boolean; action?: string } | null> { if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null; return new Promise((resolve) => { const reqId = this.makeReqId(); @@ -1116,12 +1116,12 @@ export class BrokerClient { }, timer: setTimeout(() => { if (this.skillAckResolvers.delete(reqId)) resolve(null); }, 5_000) }); - this.ws!.send(JSON.stringify({ type: "share_skill", name, description, instructions, tags, _reqId: reqId })); + this.ws!.send(JSON.stringify({ type: "share_skill", name, description, instructions, tags, manifest, _reqId: reqId })); }); } /** Load a skill's full instructions by name. */ - async getSkill(name: string): Promise<{ name: string; description: string; instructions: string; tags: string[]; author: string; createdAt: string } | null> { + async getSkill(name: string): Promise<{ name: string; description: string; instructions: string; tags: string[]; author: string; manifest?: unknown; createdAt: string } | null> { if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null; return new Promise((resolve) => { const reqId = this.makeReqId();