diff --git a/package-lock.json b/package-lock.json index 53f3c4a2..ef363a05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -76,7 +76,7 @@ }, "devDependencies": { "@eslint/js": "^9.32.0", - "@lovable.dev/vite-tanstack-config": "2.3.2", + "@lovable.dev/vite-tanstack-config": "2.6.4", "@types/node": "^22.16.5", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", @@ -1923,13 +1923,13 @@ "license": "MIT" }, "node_modules/@lovable.dev/vite-plugin-dev-server-bridge": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@lovable.dev/vite-plugin-dev-server-bridge/-/vite-plugin-dev-server-bridge-1.0.0.tgz", - "integrity": "sha512-oywyk9NGTKXyyEQNJ+NjAY7pFly6+p9e+rbdaIr58Hc3UULnNH2UPSAovbIfB2NTi2W/INPoVK/k2d5THYKy4g==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lovable.dev/vite-plugin-dev-server-bridge/-/vite-plugin-dev-server-bridge-1.0.3.tgz", + "integrity": "sha512-b+SDAim/8n2WHt7ZRXlfc8JS75vXD2H454ntuXrQe0QTUY8x0/lsS7rTxYESoRHnuvhwE3Cw7Io5bRnO3Gz1PA==", "dev": true, "license": "MIT", "peerDependencies": { - "vite": ">=5.0.0 <8.0.0" + "vite": ">=5.0.0 <9.0.0" }, "peerDependenciesMeta": { "vite": { @@ -1938,13 +1938,13 @@ } }, "node_modules/@lovable.dev/vite-plugin-hmr-gate": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@lovable.dev/vite-plugin-hmr-gate/-/vite-plugin-hmr-gate-1.0.0.tgz", - "integrity": "sha512-gWpiqRKwyE68ul7xlryGmoMJC3jJEWg/Pl80/Dl8h1Ajfw6VB3Quq8Mq17D5bAPL6NjxKlmA1g8GWJEjK2fDYg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@lovable.dev/vite-plugin-hmr-gate/-/vite-plugin-hmr-gate-1.1.2.tgz", + "integrity": "sha512-m3tOTEm78rPAFq6+ELNMS10pX786mFLdRYFnU/xIra+aJSSalnueVhdeIgmictfoqBQw8GZLgmpeASOXVI6w9g==", "dev": true, "license": "MIT", "peerDependencies": { - "vite": ">=5.0.0 <8.0.0" + "vite": ">=5.0.0 <9.0.0" }, "peerDependenciesMeta": { "vite": { @@ -1953,14 +1953,14 @@ } }, "node_modules/@lovable.dev/vite-tanstack-config": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@lovable.dev/vite-tanstack-config/-/vite-tanstack-config-2.3.2.tgz", - "integrity": "sha512-pxS3Saco2KwTYGxgD4krBu25jGXNjT4m7hnAXYRq/j2xNSPBXkfEOu47GIx8cySHlnyjKgw3nRdkwnEkJzWldw==", + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/@lovable.dev/vite-tanstack-config/-/vite-tanstack-config-2.6.4.tgz", + "integrity": "sha512-eav5blPqeu6n054y+7UtbtwIOiIexB2U7gGvHJIo2rsLyDFkJL1QL3qKnmmax1FLIProfoYXuC0lq1QczFR+dw==", "dev": true, "license": "MIT", "dependencies": { - "@lovable.dev/vite-plugin-dev-server-bridge": "^1.0.0", - "@lovable.dev/vite-plugin-hmr-gate": "^1.0.0", + "@lovable.dev/vite-plugin-dev-server-bridge": "^1.0.2", + "@lovable.dev/vite-plugin-hmr-gate": "^1.1.1", "lightningcss": "^1.30.0", "lovable-tagger": "1.2.0" }, diff --git a/src/lib/cloud-skills/cloud-skills.functions.ts b/src/lib/cloud-skills/cloud-skills.functions.ts new file mode 100644 index 00000000..9001f7cf --- /dev/null +++ b/src/lib/cloud-skills/cloud-skills.functions.ts @@ -0,0 +1,468 @@ +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; +import { requirePaidSubscription } from "./subscription-guard"; +import { + CreateCloudSkillInput, + UpdateCloudSkillInput, + ListCloudSkillsInput, + ExportFormat, +} from "./schemas"; + +// --------------------------------------------------------------------------- +// List +// --------------------------------------------------------------------------- +export const listCloudSkills = createServerFn({ method: "GET" }) + .middleware([requirePaidSubscription]) + .inputValidator((d: unknown) => ListCloudSkillsInput.parse(d ?? {})) + .handler(async ({ data, context }) => { + const { supabase: sb, userId } = context as any; + const supabase = sb as any; + + let q = supabase + .from("cloud_skills") + .select("id, slug, name, description, category, tags, is_public, version, created_at, updated_at", { count: "exact" }); + + if (data.include_public) { + q = q.or(`user_id.eq.${userId},is_public.eq.true`); + } else { + q = q.eq("user_id", userId); + } + + if (data.category) q = q.eq("category", data.category); + if (data.tag) q = q.contains("tags", [data.tag]); + if (data.query) { + const safe = data.query.replace(/[%,()]/g, " ").trim(); + if (safe) { + q = q.or(`name.ilike.%${safe}%,description.ilike.%${safe}%`); + } + } + + q = q.order("updated_at", { ascending: false }).range(data.offset, data.offset + data.limit - 1); + + const { data: skills, error, count } = await q; + if (error) throw new Response(error.message, { status: 500 }); + + return { skills: skills ?? [], total: count ?? 0 }; + }); + +// --------------------------------------------------------------------------- +// Get single +// --------------------------------------------------------------------------- +export const getCloudSkill = createServerFn({ method: "GET" }) + .middleware([requirePaidSubscription]) + .inputValidator((d: unknown) => z.object({ id: z.string().uuid() }).parse(d)) + .handler(async ({ data, context }) => { + const { supabase: sb, userId } = context as any; + const supabase = sb as any; + + const { data: skill, error } = await supabase + .from("cloud_skills") + .select("*") + .eq("id", data.id) + .or(`user_id.eq.${userId},is_public.eq.true`) + .maybeSingle(); + + if (error) throw new Response(error.message, { status: 500 }); + if (!skill) throw new Response("Not found", { status: 404 }); + + const { data: versions } = await supabase + .from("cloud_skill_versions") + .select("id, version, changelog, created_at") + .eq("cloud_skill_id", skill.id) + .order("version", { ascending: false }) + .limit(20); + + return { skill, versions: versions ?? [] }; + }); + +// --------------------------------------------------------------------------- +// Create +// --------------------------------------------------------------------------- +export const createCloudSkill = createServerFn({ method: "POST" }) + .middleware([requirePaidSubscription]) + .inputValidator((d: unknown) => CreateCloudSkillInput.parse(d)) + .handler(async ({ data, context }) => { + const { supabase: sb, userId } = context as any; + const supabase = sb as any; + + const { data: existing } = await supabase + .from("cloud_skills") + .select("id") + .eq("user_id", userId) + .eq("slug", data.slug) + .maybeSingle(); + if (existing) throw new Response("A skill with this slug already exists", { status: 409 }); + + const { data: skill, error } = await supabase + .from("cloud_skills") + .insert({ + user_id: userId, + slug: data.slug, + name: data.name, + description: data.description ?? null, + category: data.category, + tags: data.tags, + content: data.content, + variables: data.variables, + is_public: data.is_public, + version: 1, + }) + .select() + .single(); + if (error) throw new Response(error.message, { status: 500 }); + + await supabase.from("cloud_skill_versions").insert({ + cloud_skill_id: skill.id, + version: 1, + content: data.content, + variables: data.variables, + changelog: "Initial version", + }); + + return { skill }; + }); + +// --------------------------------------------------------------------------- +// Update +// --------------------------------------------------------------------------- +export const updateCloudSkill = createServerFn({ method: "POST" }) + .middleware([requirePaidSubscription]) + .inputValidator((d: unknown) => UpdateCloudSkillInput.parse(d)) + .handler(async ({ data, context }) => { + const { supabase: sb, userId } = context as any; + const supabase = sb as any; + + const { data: current, error: fetchErr } = await supabase + .from("cloud_skills") + .select("id, version, content, variables") + .eq("id", data.id) + .eq("user_id", userId) + .maybeSingle(); + if (fetchErr) throw new Response(fetchErr.message, { status: 500 }); + if (!current) throw new Response("Not found or not owned by you", { status: 404 }); + + const contentChanged = data.content && data.content !== current.content; + const newVersion = contentChanged ? current.version + 1 : current.version; + + const update: Record = { updated_at: new Date().toISOString() }; + if (data.name !== undefined) update.name = data.name; + if (data.description !== undefined) update.description = data.description; + if (data.category !== undefined) update.category = data.category; + if (data.tags !== undefined) update.tags = data.tags; + if (data.content !== undefined) update.content = data.content; + if (data.variables !== undefined) update.variables = data.variables; + if (data.is_public !== undefined) update.is_public = data.is_public; + if (contentChanged) update.version = newVersion; + + const { data: skill, error } = await supabase + .from("cloud_skills") + .update(update) + .eq("id", data.id) + .eq("user_id", userId) + .select() + .single(); + if (error) throw new Response(error.message, { status: 500 }); + + if (contentChanged) { + await supabase.from("cloud_skill_versions").insert({ + cloud_skill_id: skill.id, + version: newVersion, + content: data.content, + variables: data.variables ?? current.variables, + changelog: data.changelog ?? null, + }); + } + + return { skill, version_bumped: !!contentChanged }; + }); + +// --------------------------------------------------------------------------- +// Delete +// --------------------------------------------------------------------------- +export const deleteCloudSkill = createServerFn({ method: "POST" }) + .middleware([requirePaidSubscription]) + .inputValidator((d: unknown) => z.object({ id: z.string().uuid() }).parse(d)) + .handler(async ({ data, context }) => { + const { supabase: sb, userId } = context as any; + const supabase = sb as any; + + const { error } = await supabase + .from("cloud_skills") + .delete() + .eq("id", data.id) + .eq("user_id", userId); + if (error) throw new Response(error.message, { status: 500 }); + + return { ok: true }; + }); + +// --------------------------------------------------------------------------- +// Fork a public skill into your own library +// --------------------------------------------------------------------------- +export const forkCloudSkill = createServerFn({ method: "POST" }) + .middleware([requirePaidSubscription]) + .inputValidator((d: unknown) => + z.object({ + source_id: z.string().uuid(), + new_slug: z.string().regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/).optional(), + }).parse(d), + ) + .handler(async ({ data, context }) => { + const { supabase: sb, userId } = context as any; + const supabase = sb as any; + + const { data: source, error: srcErr } = await supabase + .from("cloud_skills") + .select("*") + .eq("id", data.source_id) + .maybeSingle(); + if (srcErr || !source) throw new Response("Source skill not found", { status: 404 }); + if (source.user_id === userId) throw new Response("Cannot fork your own skill", { status: 400 }); + if (!source.is_public) throw new Response("Only public skills can be forked", { status: 403 }); + + const newSlug = data.new_slug ?? `${source.slug}-fork-${Math.random().toString(36).slice(2, 6)}`; + + const { data: skill, error } = await supabase + .from("cloud_skills") + .insert({ + user_id: userId, + slug: newSlug, + name: `${source.name} (fork)`, + description: source.description, + category: source.category, + tags: source.tags, + content: source.content, + variables: source.variables, + is_public: false, + version: 1, + forked_from: source.id, + }) + .select() + .single(); + if (error) throw new Response(error.message, { status: 500 }); + + await supabase.from("cloud_skill_versions").insert({ + cloud_skill_id: skill.id, + version: 1, + content: source.content, + variables: source.variables, + changelog: `Forked from ${source.slug}`, + }); + + return { skill, forked_from: { id: source.id, slug: source.slug } }; + }); + +// --------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------- +export const exportCloudSkill = createServerFn({ method: "GET" }) + .middleware([requirePaidSubscription]) + .inputValidator((d: unknown) => + z.object({ + id: z.string().uuid(), + format: ExportFormat.default("markdown"), + }).parse(d), + ) + .handler(async ({ data, context }) => { + const { supabase: sb, userId } = context as any; + const supabase = sb as any; + + const { data: skill } = await supabase + .from("cloud_skills") + .select("*") + .eq("id", data.id) + .or(`user_id.eq.${userId},is_public.eq.true`) + .maybeSingle(); + if (!skill) throw new Response("Not found", { status: 404 }); + + if (data.format === "json") { + return { + format: "json", + data: { + slug: skill.slug, + name: skill.name, + description: skill.description, + category: skill.category, + tags: skill.tags, + variables: skill.variables, + content: skill.content, + version: skill.version, + }, + }; + } + + if (data.format === "yaml") { + const lines = [ + `name: ${skill.name}`, + `slug: ${skill.slug}`, + skill.description ? `description: ${skill.description}` : null, + `category: ${skill.category}`, + skill.tags.length ? `tags: ${skill.tags.join(", ")}` : null, + `version: ${skill.version}`, + ].filter(Boolean); + return { + format: "yaml", + data: `---\n${lines.join("\n")}\n---\n\n${skill.content}`, + }; + } + + // markdown (default) + const frontmatter = [ + "---", + `name: ${skill.name}`, + `slug: ${skill.slug}`, + skill.description ? `description: ${skill.description}` : null, + `category: ${skill.category}`, + skill.tags.length ? `tags: ${skill.tags.join(", ")}` : null, + `version: ${skill.version}`, + "---", + ].filter(Boolean).join("\n"); + + return { format: "markdown", data: `${frontmatter}\n\n${skill.content}` }; + }); + +// --------------------------------------------------------------------------- +// Import from markdown/YAML content +// --------------------------------------------------------------------------- +export const importCloudSkill = createServerFn({ method: "POST" }) + .middleware([requirePaidSubscription]) + .inputValidator((d: unknown) => + z.object({ + filename: z.string().max(200), + content: z.string().min(10).max(200_000), + }).parse(d), + ) + .handler(async ({ data, context }) => { + const { supabase: sb, userId } = context as any; + const supabase = sb as any; + + const { filename, content } = data; + + // Parse YAML frontmatter if present + const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/); + let meta: Record = {}; + let body = content; + + if (frontmatterMatch) { + const fmLines = frontmatterMatch[1].split("\n"); + for (const line of fmLines) { + const colonIdx = line.indexOf(":"); + if (colonIdx > 0) { + const key = line.slice(0, colonIdx).trim(); + const val = line.slice(colonIdx + 1).trim(); + meta[key] = val; + } + } + body = frontmatterMatch[2]; + } + + const baseName = filename.replace(/\.(md|yaml|yml|json|txt|skill)$/i, ""); + const slug = (meta.slug ?? baseName) + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 64) || "imported-skill"; + const name = meta.name ?? baseName; + const description = meta.description ?? null; + const category = meta.category ?? "imported"; + const tags = meta.tags ? meta.tags.split(",").map((t: string) => t.trim()).filter(Boolean) : []; + + // Deduplicate slug + let finalSlug = slug; + let n = 1; + while (true) { + const { data: existing } = await supabase + .from("cloud_skills") + .select("id") + .eq("user_id", userId) + .eq("slug", finalSlug) + .maybeSingle(); + if (!existing) break; + n += 1; + finalSlug = `${slug}-${n}`; + if (n > 50) break; + } + + const { data: skill, error } = await supabase + .from("cloud_skills") + .insert({ + user_id: userId, + slug: finalSlug, + name, + description, + category, + tags, + content: body.trim(), + variables: {}, + is_public: false, + version: 1, + }) + .select() + .single(); + if (error) throw new Response(error.message, { status: 500 }); + + await supabase.from("cloud_skill_versions").insert({ + cloud_skill_id: skill.id, + version: 1, + content: body.trim(), + variables: {}, + changelog: `Imported from ${filename}`, + }); + + return { skill, parsed_meta: meta }; + }); + +// --------------------------------------------------------------------------- +// Get version content (for diff/rollback) +// --------------------------------------------------------------------------- +export const getCloudSkillVersion = createServerFn({ method: "GET" }) + .middleware([requirePaidSubscription]) + .inputValidator((d: unknown) => + z.object({ + cloud_skill_id: z.string().uuid(), + version: z.number().int().min(1), + }).parse(d), + ) + .handler(async ({ data, context }) => { + const { supabase: sb, userId } = context as any; + const supabase = sb as any; + + // Verify ownership + const { data: skill } = await supabase + .from("cloud_skills") + .select("id") + .eq("id", data.cloud_skill_id) + .eq("user_id", userId) + .maybeSingle(); + if (!skill) throw new Response("Not found", { status: 404 }); + + const { data: ver, error } = await supabase + .from("cloud_skill_versions") + .select("*") + .eq("cloud_skill_id", data.cloud_skill_id) + .eq("version", data.version) + .maybeSingle(); + if (error) throw new Response(error.message, { status: 500 }); + if (!ver) throw new Response("Version not found", { status: 404 }); + + return { version: ver }; + }); + +// --------------------------------------------------------------------------- +// Categories (for filter dropdowns) +// --------------------------------------------------------------------------- +export const listCloudSkillCategories = createServerFn({ method: "GET" }) + .middleware([requirePaidSubscription]) + .handler(async ({ context }) => { + const { supabase: sb, userId } = context as any; + const supabase = sb as any; + + const { data } = await supabase + .from("cloud_skills") + .select("category") + .eq("user_id", userId); + + const categories = [...new Set((data ?? []).map((r: any) => r.category))].sort(); + return { categories }; + }); diff --git a/src/lib/cloud-skills/schemas.ts b/src/lib/cloud-skills/schemas.ts new file mode 100644 index 00000000..c255eaaa --- /dev/null +++ b/src/lib/cloud-skills/schemas.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; + +export const CloudSkillSlug = z + .string() + .min(2) + .max(64) + .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/, "lowercase, hyphens, numbers; no leading/trailing hyphen"); + +export const TemplateVariable = z.object({ + type: z.enum(["string", "number", "boolean", "select"]).default("string"), + default: z.union([z.string(), z.number(), z.boolean()]).optional(), + description: z.string().max(200).optional(), + options: z.array(z.string()).optional(), +}); + +export const CreateCloudSkillInput = z.object({ + slug: CloudSkillSlug, + name: z.string().min(2).max(120), + description: z.string().max(500).optional(), + category: z.string().min(1).max(60).default("general"), + tags: z.array(z.string().max(40)).max(10).default([]), + content: z.string().min(10).max(200_000), + variables: z.record(z.string(), TemplateVariable).default({}), + is_public: z.boolean().default(false), +}); + +export const UpdateCloudSkillInput = z.object({ + id: z.string().uuid(), + name: z.string().min(2).max(120).optional(), + description: z.string().max(500).optional(), + category: z.string().min(1).max(60).optional(), + tags: z.array(z.string().max(40)).max(10).optional(), + content: z.string().min(10).max(200_000).optional(), + variables: z.record(z.string(), TemplateVariable).optional(), + is_public: z.boolean().optional(), + changelog: z.string().max(500).optional(), +}); + +export const ListCloudSkillsInput = z.object({ + category: z.string().optional(), + tag: z.string().optional(), + query: z.string().optional(), + include_public: z.boolean().default(false), + limit: z.number().int().min(1).max(100).default(50), + offset: z.number().int().min(0).default(0), +}); + +export const ExportFormat = z.enum(["markdown", "json", "yaml"]); + +export type CreateCloudSkillData = z.infer; +export type UpdateCloudSkillData = z.infer; +export type ListCloudSkillsData = z.infer; diff --git a/src/lib/cloud-skills/subscription-guard.ts b/src/lib/cloud-skills/subscription-guard.ts new file mode 100644 index 00000000..d9e51d61 --- /dev/null +++ b/src/lib/cloud-skills/subscription-guard.ts @@ -0,0 +1,34 @@ +import { createMiddleware } from "@tanstack/react-start"; +import { requireSupabaseAuth } from "@/integrations/supabase/auth-middleware"; + +export const requirePaidSubscription = createMiddleware({ type: "function" }) + .middleware([requireSupabaseAuth]) + .server(async ({ next, context }) => { + const { supabase, userId } = context as any; + + const { data: sub } = await supabase + .from("subscriptions") + .select("status, current_period_end") + .eq("user_id", userId) + .in("status", ["active", "trialing", "past_due"]) + .order("created_at", { ascending: false }) + .limit(1) + .maybeSingle(); + + const isActive = + !!sub && + (!sub.current_period_end || new Date(sub.current_period_end) > new Date()); + + if (!isActive) { + throw new Response( + JSON.stringify({ + error: "subscription_required", + message: + "Cloud Skill Manager is available on Agent Pass and Enterprise plans. Upgrade at /pricing to unlock cloud storage, sync, and reuse of your skills.", + }), + { status: 403, headers: { "Content-Type": "application/json" } }, + ); + } + + return next({ context: { ...context, subscriptionActive: true } }); + }); diff --git a/src/lib/mcp/tools/cloud-skills.ts b/src/lib/mcp/tools/cloud-skills.ts new file mode 100644 index 00000000..40c76a77 --- /dev/null +++ b/src/lib/mcp/tools/cloud-skills.ts @@ -0,0 +1,324 @@ +import { defineTool } from "mcp-tanstack-start"; +import { z } from "zod"; +import { supabaseAdmin as _supabaseAdmin } from "@/integrations/supabase/client.server"; +import { hashToken } from "@/lib/account/tokens.server"; + +const supabaseAdmin = _supabaseAdmin as any; +const json = (v: unknown) => JSON.stringify(v, null, 2); + +async function resolveUserFromToken(token: string): Promise { + const { data } = await supabaseAdmin + .from("mcp_tokens") + .select("user_id,id") + .eq("token_hash", hashToken(token)) + .maybeSingle(); + if (!data) return null; + await supabaseAdmin.from("mcp_tokens").update({ last_used_at: new Date().toISOString() }).eq("id", data.id); + return data.user_id; +} + +async function requirePaidUser(ctx: any): Promise { + const sessionUserId = (ctx?.auth?.claims as { user_id?: string } | undefined)?.user_id ?? null; + const userId = sessionUserId; + if (!userId) { + throw new Error(json({ + error: "unauthorized", + hint: "Connect via OAuth to use Cloud Skill Manager.", + })); + } + + const { data: sub } = await supabaseAdmin + .from("subscriptions") + .select("status, current_period_end") + .eq("user_id", userId) + .in("status", ["active", "trialing", "past_due"]) + .order("created_at", { ascending: false }) + .limit(1) + .maybeSingle(); + + const isActive = !!sub && (!sub.current_period_end || new Date(sub.current_period_end) > new Date()); + if (!isActive) { + throw new Error(json({ + error: "subscription_required", + message: "Cloud Skill Manager requires Agent Pass or Enterprise. Upgrade at superagentskill.com/pricing.", + })); + } + + return userId; +} + +export const cloudSkillsListTool = defineTool({ + name: "cloud_skills_list", + description: + "[CLOUD] List your saved cloud skills. Requires OAuth + paid subscription. Filter by category, tag, or search query.", + parameters: z.object({ + category: z.string().optional(), + tag: z.string().optional(), + query: z.string().optional(), + limit: z.number().int().min(1).max(100).default(50), + }), + execute: async (input, ctx) => { + const userId = await requirePaidUser(ctx); + + let q = supabaseAdmin + .from("cloud_skills") + .select("id, slug, name, description, category, tags, version, updated_at") + .eq("user_id", userId) + .order("updated_at", { ascending: false }) + .limit(input.limit); + + if (input.category) q = q.eq("category", input.category); + if (input.tag) q = q.contains("tags", [input.tag]); + if (input.query) { + const safe = input.query.replace(/[%,()]/g, " ").trim(); + if (safe) q = q.or(`name.ilike.%${safe}%,description.ilike.%${safe}%`); + } + + const { data, error } = await q; + if (error) return json({ error: error.message }); + return json({ count: data?.length ?? 0, skills: data ?? [] }); + }, +}); + +export const cloudSkillsGetTool = defineTool({ + name: "cloud_skills_get", + description: + "[CLOUD] Get the full content of a cloud skill by slug. Requires OAuth + paid subscription.", + parameters: z.object({ + slug: z.string().min(1), + }), + execute: async ({ slug }, ctx) => { + const userId = await requirePaidUser(ctx); + + const { data: skill, error } = await supabaseAdmin + .from("cloud_skills") + .select("*") + .eq("user_id", userId) + .eq("slug", slug) + .maybeSingle(); + if (error) return json({ error: error.message }); + if (!skill) return json({ error: "not_found" }); + return json({ skill }); + }, +}); + +export const cloudSkillsSaveTool = defineTool({ + name: "cloud_skills_save", + description: + "[CLOUD] Save a skill to your cloud library. Creates or updates by slug. Requires OAuth + paid subscription. Use this to save a local skill to the cloud for reuse across projects.", + parameters: z.object({ + slug: z.string().min(2).max(64).regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/), + name: z.string().min(2).max(120), + content: z.string().min(10).max(200_000), + description: z.string().max(500).optional(), + category: z.string().max(60).default("general"), + tags: z.array(z.string().max(40)).max(10).default([]), + variables: z.record(z.string(), z.any()).default({}), + changelog: z.string().max(500).optional(), + }), + execute: async (input, ctx) => { + const userId = await requirePaidUser(ctx); + + const { data: existing } = await supabaseAdmin + .from("cloud_skills") + .select("id, version, content") + .eq("user_id", userId) + .eq("slug", input.slug) + .maybeSingle(); + + if (existing) { + const contentChanged = input.content !== existing.content; + const newVersion = contentChanged ? existing.version + 1 : existing.version; + + const { data: skill, error } = await supabaseAdmin + .from("cloud_skills") + .update({ + name: input.name, + description: input.description ?? null, + category: input.category, + tags: input.tags, + content: input.content, + variables: input.variables, + version: newVersion, + updated_at: new Date().toISOString(), + }) + .eq("id", existing.id) + .select("id, slug, name, version") + .single(); + if (error) return json({ error: error.message }); + + if (contentChanged) { + await supabaseAdmin.from("cloud_skill_versions").insert({ + cloud_skill_id: existing.id, + version: newVersion, + content: input.content, + variables: input.variables, + changelog: input.changelog ?? null, + }); + } + + return json({ action: "updated", skill, version_bumped: contentChanged }); + } + + const { data: skill, error } = await supabaseAdmin + .from("cloud_skills") + .insert({ + user_id: userId, + slug: input.slug, + name: input.name, + description: input.description ?? null, + category: input.category, + tags: input.tags, + content: input.content, + variables: input.variables, + is_public: false, + version: 1, + }) + .select("id, slug, name, version") + .single(); + if (error) return json({ error: error.message }); + + await supabaseAdmin.from("cloud_skill_versions").insert({ + cloud_skill_id: skill.id, + version: 1, + content: input.content, + variables: input.variables, + changelog: input.changelog ?? "Initial save", + }); + + return json({ action: "created", skill }); + }, +}); + +export const cloudSkillsDeleteTool = defineTool({ + name: "cloud_skills_delete", + description: + "[CLOUD] Delete a cloud skill by slug. Requires OAuth + paid subscription.", + parameters: z.object({ + slug: z.string().min(1), + }), + execute: async ({ slug }, ctx) => { + const userId = await requirePaidUser(ctx); + + const { error } = await supabaseAdmin + .from("cloud_skills") + .delete() + .eq("user_id", userId) + .eq("slug", slug); + if (error) return json({ error: error.message }); + return json({ ok: true, deleted: slug }); + }, +}); + +export const cloudSkillsExportTool = defineTool({ + name: "cloud_skills_export", + description: + "[CLOUD] Export a cloud skill as Markdown with YAML frontmatter (SKILL.md format), ready to paste into any project. Requires OAuth + paid subscription.", + parameters: z.object({ + slug: z.string().min(1), + }), + execute: async ({ slug }, ctx) => { + const userId = await requirePaidUser(ctx); + + const { data: skill } = await supabaseAdmin + .from("cloud_skills") + .select("*") + .eq("user_id", userId) + .eq("slug", slug) + .maybeSingle(); + if (!skill) return json({ error: "not_found" }); + + const frontmatter = [ + "---", + `name: ${skill.name}`, + `slug: ${skill.slug}`, + skill.description ? `description: ${skill.description}` : null, + `category: ${skill.category}`, + skill.tags.length ? `tags: ${skill.tags.join(", ")}` : null, + `version: ${skill.version}`, + "---", + ].filter(Boolean).join("\n"); + + return `${frontmatter}\n\n${skill.content}`; + }, +}); + +export const cloudSkillsImportTool = defineTool({ + name: "cloud_skills_import", + description: + "[CLOUD] Import a skill file (Markdown with optional YAML frontmatter) into your cloud library. Parses name, category, tags from frontmatter. Requires OAuth + paid subscription.", + parameters: z.object({ + filename: z.string().min(1).max(200), + content: z.string().min(10).max(200_000), + }), + execute: async ({ filename, content }, ctx) => { + const userId = await requirePaidUser(ctx); + + const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/); + let meta: Record = {}; + let body = content; + + if (frontmatterMatch) { + for (const line of frontmatterMatch[1].split("\n")) { + const colonIdx = line.indexOf(":"); + if (colonIdx > 0) { + meta[line.slice(0, colonIdx).trim()] = line.slice(colonIdx + 1).trim(); + } + } + body = frontmatterMatch[2]; + } + + const baseName = filename.replace(/\.(md|yaml|yml|json|txt|skill)$/i, ""); + const slug = (meta.slug ?? baseName) + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 64) || "imported-skill"; + + let finalSlug = slug; + let n = 1; + while (true) { + const { data: existing } = await supabaseAdmin + .from("cloud_skills") + .select("id") + .eq("user_id", userId) + .eq("slug", finalSlug) + .maybeSingle(); + if (!existing) break; + n += 1; + finalSlug = `${slug}-${n}`; + if (n > 50) break; + } + + const tags = meta.tags ? meta.tags.split(",").map((t: string) => t.trim()).filter(Boolean) : []; + + const { data: skill, error } = await supabaseAdmin + .from("cloud_skills") + .insert({ + user_id: userId, + slug: finalSlug, + name: meta.name ?? baseName, + description: meta.description ?? null, + category: meta.category ?? "imported", + tags, + content: body.trim(), + variables: {}, + is_public: false, + version: 1, + }) + .select("id, slug, name, version") + .single(); + if (error) return json({ error: error.message }); + + await supabaseAdmin.from("cloud_skill_versions").insert({ + cloud_skill_id: skill.id, + version: 1, + content: body.trim(), + variables: {}, + changelog: `Imported from ${filename}`, + }); + + return json({ action: "imported", skill, parsed_meta: meta }); + }, +}); diff --git a/src/lib/mcp/tools/skills.ts b/src/lib/mcp/tools/skills.ts index f2adc94e..bbb3e218 100644 --- a/src/lib/mcp/tools/skills.ts +++ b/src/lib/mcp/tools/skills.ts @@ -66,6 +66,19 @@ export const overviewTool = defineTool({ ], tools: ["upload_packages", "request_primitive"], }, + cloud_skill_manager: { + description: + "Save, sync and reuse YOUR skills across projects, CLIs and devices. Your personal cloud library. Requires OAuth + paid subscription (Agent Pass or Enterprise).", + workflow: [ + "1. cloud_skills_save — push a local skill to the cloud (creates or updates by slug).", + "2. cloud_skills_list — browse your saved skills (filter by category, tag, query).", + "3. cloud_skills_get — fetch the full content of a saved skill by slug.", + "4. cloud_skills_export — export as SKILL.md ready to paste into any project.", + "5. cloud_skills_import — import a SKILL.md file into your cloud library.", + "6. cloud_skills_delete — remove a skill from your library.", + ], + tools: ["cloud_skills_list", "cloud_skills_get", "cloud_skills_save", "cloud_skills_delete", "cloud_skills_export", "cloud_skills_import"], + }, }, primitive_types: { skill: "A focused capability the agent can invoke (e.g. 'write-cold-outreach', 'audit-rls-policies').", @@ -75,7 +88,8 @@ export const overviewTool = defineTool({ }, auth: { anonymous_ok: ["overview", "get_methodology", "review_skill", "review_skills_batch", "search_registry", "list_packages", "get_package", "get_skill_trust"], - oauth_required: ["upload_packages", "request_primitive", "report_execution"], + oauth_required: ["upload_packages", "request_primitive", "report_execution", "cloud_skills_*"], + paid_subscription_required: ["cloud_skills_list", "cloud_skills_get", "cloud_skills_save", "cloud_skills_delete", "cloud_skills_export", "cloud_skills_import"], anonymous_dry_run: ["upload_packages", "request_primitive"], anonymous_dry_run_note: "Pass dry_run:true to upload_packages / request_primitive to validate the flow anonymously — no persistence, no model budget, no OAuth.", oauth_endpoint: "https://superagentskill.com/oauth/authorize", diff --git a/src/routes/account.cloud-skills.tsx b/src/routes/account.cloud-skills.tsx new file mode 100644 index 00000000..0de0cb2a --- /dev/null +++ b/src/routes/account.cloud-skills.tsx @@ -0,0 +1,651 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useServerFn } from "@tanstack/react-start"; +import { useState } from "react"; +import { toast } from "sonner"; +import { SitePage } from "@/components/site/SitePage"; +import { useSubscription } from "@/hooks/use-subscription"; +import { useAuth } from "@/hooks/use-auth"; +import { + listCloudSkills, + createCloudSkill, + updateCloudSkill, + deleteCloudSkill, + exportCloudSkill, + importCloudSkill, + getCloudSkill, +} from "@/lib/cloud-skills/cloud-skills.functions"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Textarea } from "@/components/ui/textarea"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +export const Route = createFileRoute("/account/cloud-skills")({ + head: () => ({ + meta: [ + { title: "Cloud Skill Manager — Super Agent Skill" }, + { + name: "description", + content: + "Save, sync and reuse your AI skills across projects, CLIs and devices. Premium feature.", + }, + ], + }), + component: CloudSkillsPage, +}); + +type CloudSkill = { + id: string; + slug: string; + name: string; + description: string | null; + category: string; + tags: string[]; + is_public: boolean; + version: number; + created_at: string; + updated_at: string; +}; + +function CloudSkillsPage() { + const { user, loading: authLoading } = useAuth(); + const { isActive, loading: subLoading } = useSubscription(); + const qc = useQueryClient(); + + const listFn = useServerFn(listCloudSkills); + const createFn = useServerFn(createCloudSkill); + const updateFn = useServerFn(updateCloudSkill); + const deleteFn = useServerFn(deleteCloudSkill); + const exportFn = useServerFn(exportCloudSkill); + const importFn = useServerFn(importCloudSkill); + const getFn = useServerFn(getCloudSkill); + + const [search, setSearch] = useState(""); + const [category, setCategory] = useState(""); + const [showCreate, setShowCreate] = useState(false); + const [showImport, setShowImport] = useState(false); + const [viewing, setViewing] = useState(null); + const [editing, setEditing] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + // Create form state + const [newSlug, setNewSlug] = useState(""); + const [newName, setNewName] = useState(""); + const [newDesc, setNewDesc] = useState(""); + const [newCategory, setNewCategory] = useState("general"); + const [newTags, setNewTags] = useState(""); + const [newContent, setNewContent] = useState(""); + + // Import state + const [importContent, setImportContent] = useState(""); + const [importFilename, setImportFilename] = useState(""); + + // Edit state + const [editName, setEditName] = useState(""); + const [editDesc, setEditDesc] = useState(""); + const [editContent, setEditContent] = useState(""); + const [editCategory, setEditCategory] = useState(""); + const [editTags, setEditTags] = useState(""); + const [editChangelog, setEditChangelog] = useState(""); + + const q = useQuery({ + queryKey: ["cloud-skills", search, category], + queryFn: () => + listFn({ + data: { + query: search || undefined, + category: category || undefined, + limit: 50, + offset: 0, + include_public: false, + }, + }), + enabled: !!user && isActive, + }); + + const createMut = useMutation({ + mutationFn: (input: any) => createFn({ data: input }), + onSuccess: () => { + toast.success("Skill saved to cloud"); + qc.invalidateQueries({ queryKey: ["cloud-skills"] }); + setShowCreate(false); + resetCreateForm(); + }, + onError: (e: any) => toast.error(e?.message ?? "Failed to create skill"), + }); + + const importMut = useMutation({ + mutationFn: (input: { filename: string; content: string }) => + importFn({ data: input }), + onSuccess: (r: any) => { + toast.success(`Imported as "${r.skill.slug}"`); + qc.invalidateQueries({ queryKey: ["cloud-skills"] }); + setShowImport(false); + setImportContent(""); + setImportFilename(""); + }, + onError: (e: any) => toast.error(e?.message ?? "Import failed"), + }); + + const updateMut = useMutation({ + mutationFn: (input: any) => updateFn({ data: input }), + onSuccess: (r: any) => { + toast.success(r.version_bumped ? "Updated (new version)" : "Updated"); + qc.invalidateQueries({ queryKey: ["cloud-skills"] }); + setEditing(null); + }, + onError: (e: any) => toast.error(e?.message ?? "Update failed"), + }); + + const deleteMut = useMutation({ + mutationFn: (id: string) => deleteFn({ data: { id } }), + onSuccess: () => { + toast.success("Skill deleted"); + qc.invalidateQueries({ queryKey: ["cloud-skills"] }); + setDeleteTarget(null); + }, + onError: (e: any) => toast.error(e?.message ?? "Delete failed"), + }); + + function resetCreateForm() { + setNewSlug(""); + setNewName(""); + setNewDesc(""); + setNewCategory("general"); + setNewTags(""); + setNewContent(""); + } + + async function handleExport(skill: CloudSkill) { + try { + const result = await exportFn({ data: { id: skill.id, format: "markdown" } }); + await navigator.clipboard.writeText(result.data); + toast.success("Copied to clipboard as SKILL.md"); + } catch (e: any) { + toast.error(e?.message ?? "Export failed"); + } + } + + async function openEdit(skill: CloudSkill) { + try { + const result = await getFn({ data: { id: skill.id } }); + setEditing(skill); + setEditName(result.skill.name); + setEditDesc(result.skill.description ?? ""); + setEditContent(result.skill.content); + setEditCategory(result.skill.category); + setEditTags(result.skill.tags?.join(", ") ?? ""); + setEditChangelog(""); + } catch (e: any) { + toast.error(e?.message ?? "Failed to load skill"); + } + } + + if (authLoading || subLoading) { + return ( + +
+

Loading...

+
+
+ ); + } + + if (!user) { + return ( + +
+

Cloud Skill Manager

+

+ Sign in to manage your cloud skills.{" "} + + Sign in + +

+
+
+ ); + } + + if (!isActive) { + return ( + +
+ + Premium Feature + +

+ Cloud Skill Manager +

+

+ Save, sync and reuse your AI skills across projects, CLIs and devices. + Cloud Skill Manager is available on Agent Pass and{" "} + Enterprise plans. +

+
+

What you get

+
    +
  • Save skills to the cloud and sync across all your projects
  • +
  • Version history with changelog for every update
  • +
  • Import/export in SKILL.md format (compatible with 60+ agents)
  • +
  • Template variables for reusable, customizable skills
  • +
  • Fork public skills into your library
  • +
  • Full MCP API access from any CLI (Claude, Cursor, Codex...)
  • +
+
+ + + +
+
+
+
+ ); + } + + const skills = (q.data?.skills ?? []) as CloudSkill[]; + + return ( + +
+ + Account + +

+ Cloud Skill Manager +

+

+ Your personal cloud library. Save skills here and reuse them in any + project, on any device, from any CLI. +

+ + {/* Actions bar */} +
+ setSearch(e.target.value)} + className="max-w-xs" + /> + + +
+ + {/* Skills list */} +
+
+ Your cloud skills + {q.data && ( + {q.data.total} + )} +
+ + {q.isLoading && ( +
Loading...
+ )} + + {q.isError && ( +
+ Failed to load skills. Try refreshing. +
+ )} + + {skills.length === 0 && !q.isLoading && ( +
+ No skills yet. Create one or import from a SKILL.md file. +
+ )} + + {skills.length > 0 && ( +
    + {skills.map((s) => ( +
  • +
    +
    + {s.name} + {s.category} + {s.is_public && ( + + Public + + )} + v{s.version} +
    +
    + {s.slug} +
    + {s.description && ( +

    + {s.description} +

    + )} + {s.tags.length > 0 && ( +
    + {s.tags.map((t) => ( + + {t} + + ))} +
    + )} +
    +
    + + + +
    +
  • + ))} +
+ )} +
+ +

+ Cloud skills are also accessible via the MCP API. Use{" "} + + cloud_skills_save + {" "} + from Claude Code, Cursor, Codex or any MCP-compatible agent to push + skills directly from your terminal. +

+
+ + {/* Create dialog */} + + + + New cloud skill + + Create a new skill in your cloud library. + + +
+
+
+ + setNewSlug(e.target.value)} + /> +
+
+ + setNewName(e.target.value)} + /> +
+
+
+ + setNewDesc(e.target.value)} + /> +
+
+
+ + setNewCategory(e.target.value)} + /> +
+
+ + setNewTags(e.target.value)} + /> +
+
+
+ +