diff --git a/apps/web/src/app/(application)/skills/page.tsx b/apps/web/src/app/(application)/skills/page.tsx new file mode 100644 index 0000000..a81c375 --- /dev/null +++ b/apps/web/src/app/(application)/skills/page.tsx @@ -0,0 +1,114 @@ +"use client" + +import SkillCard from "@/src/components/skills/skill-card" +import SkillCardSkeleton from "@/src/components/skills/skill-card-skeleton" +import SkillDeleteDialog from "@/src/components/skills/skill-delete-dialog" +import SkillDetailDialog from "@/src/components/skills/skill-detail-dialog" +import SkillFormDialog from "@/src/components/skills/skill-form-dialog" +import SkillsEmptyState from "@/src/components/skills/skills-empty-state" +import { Button } from "@/src/components/ui/button" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/src/components/ui/tabs" +import { useGetSkills } from "@/src/hooks/skills/use-get-skills" +import type { Skill, SkillTab } from "@/src/types/skills" +import { IconPlus } from "@tabler/icons-react" +import { useState } from "react" + +const TABS: SkillTab[] = ["all", "builtin", "custom"] + +const TAB_LABELS: Record = { + all: "All", + builtin: "Built-in", + custom: "Custom", +} + +const SkillsPage = () => { + const { data: skills = [], isLoading } = useGetSkills() + const [activeTab, setActiveTab] = useState("all") + + const [formOpen, setFormOpen] = useState(false) + const [editingSkill, setEditingSkill] = useState(null) + const [detailSkill, setDetailSkill] = useState(null) + const [deleteSkillId, setDeleteSkillId] = useState(null) + + const filteredSkills = activeTab === "all" ? skills : skills.filter((s) => s.type === activeTab) + + const handleCreate = () => { + setEditingSkill(null) + setFormOpen(true) + } + + const handleEdit = (skill: Skill) => { + setEditingSkill(skill) + setFormOpen(true) + } + + return ( +
+
+
+

Skills

+

Browse, install, and create agent skills

+
+ +
+ + setActiveTab(v as SkillTab)}> + + {TABS.map((tab) => ( + + {TAB_LABELS[tab]} + + ))} + + + {TABS.map((tab) => ( + + {isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) : filteredSkills.length === 0 ? ( + + ) : ( +
+ {filteredSkills.map((skill) => ( + + ))} +
+ )} +
+ ))} +
+ + + + { + if (!open) setDetailSkill(null) + }} + /> + + { + if (!open) setDeleteSkillId(null) + }} + skillId={deleteSkillId} + /> +
+ ) +} + +export default SkillsPage diff --git a/apps/web/src/app/api/agent-configs/[configId]/route.ts b/apps/web/src/app/api/agent-configs/[configId]/route.ts new file mode 100644 index 0000000..633a8ae --- /dev/null +++ b/apps/web/src/app/api/agent-configs/[configId]/route.ts @@ -0,0 +1,98 @@ +import { auth } from "@/src/lib/auth" +import { pool } from "@/src/lib/db" +import { headers } from "next/headers" +import { type NextRequest, NextResponse } from "next/server" + +type RouteParams = { params: Promise<{ configId: string }> } + +// GET /api/agent-configs/:configId +export async function GET(_request: NextRequest, { params }: RouteParams) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const { configId } = await params + + try { + const result = await pool.query("SELECT * FROM agent_configs WHERE id = $1", [configId]) + if (result.rows.length === 0) { + return NextResponse.json({ error: "Agent config not found" }, { status: 404 }) + } + return NextResponse.json({ config: result.rows[0] }) + } catch (error) { + return NextResponse.json({ error: `Failed to fetch agent config: ${(error as Error).message}` }, { status: 500 }) + } +} + +// PUT /api/agent-configs/:configId +export async function PUT(request: NextRequest, { params }: RouteParams) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const { configId } = await params + + try { + const existing = await pool.query("SELECT id FROM agent_configs WHERE id = $1", [configId]) + if (existing.rows.length === 0) { + return NextResponse.json({ error: "Agent config not found" }, { status: 404 }) + } + + const body = await request.json() + const { name, description, model, provider, systemPrompt, toolsEnabled, skills, maxTokens, temperature } = body + + const fields: string[] = [] + const values: unknown[] = [] + let paramIndex = 1 + + if (name !== undefined) { + fields.push(`name = $${paramIndex++}`) + values.push(name) + } + if (description !== undefined) { + fields.push(`description = $${paramIndex++}`) + values.push(description) + } + if (model !== undefined) { + fields.push(`model = $${paramIndex++}`) + values.push(model) + } + if (provider !== undefined) { + fields.push(`provider = $${paramIndex++}`) + values.push(provider) + } + if (systemPrompt !== undefined) { + fields.push(`system_prompt = $${paramIndex++}`) + values.push(systemPrompt) + } + if (toolsEnabled !== undefined) { + fields.push(`tools_enabled = $${paramIndex++}`) + values.push(JSON.stringify(toolsEnabled)) + } + if (skills !== undefined) { + fields.push(`skills = $${paramIndex++}`) + values.push(JSON.stringify(skills)) + } + if (maxTokens !== undefined) { + fields.push(`max_tokens = $${paramIndex++}`) + values.push(maxTokens) + } + if (temperature !== undefined) { + fields.push(`temperature = $${paramIndex++}`) + values.push(temperature) + } + + if (fields.length === 0) { + return NextResponse.json({ error: "No fields to update" }, { status: 400 }) + } + + fields.push("updated_at = now()") + values.push(configId) + + const result = await pool.query( + `UPDATE agent_configs SET ${fields.join(", ")} WHERE id = $${paramIndex} RETURNING *`, + values, + ) + return NextResponse.json({ config: result.rows[0] }) + } catch (error) { + return NextResponse.json({ error: `Failed to update agent config: ${(error as Error).message}` }, { status: 500 }) + } +} diff --git a/apps/web/src/app/api/skills/[skillId]/route.ts b/apps/web/src/app/api/skills/[skillId]/route.ts new file mode 100644 index 0000000..008a185 --- /dev/null +++ b/apps/web/src/app/api/skills/[skillId]/route.ts @@ -0,0 +1,106 @@ +import { auth } from "@/src/lib/auth" +import { pool } from "@/src/lib/db" +import { headers } from "next/headers" +import { type NextRequest, NextResponse } from "next/server" + +// GET /api/skills/:skillId +export async function GET(_request: NextRequest, { params }: { params: Promise<{ skillId: string }> }) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const { skillId } = await params + + try { + const result = await pool.query("SELECT * FROM public.skills WHERE id = $1", [skillId]) + if (result.rows.length === 0) { + return NextResponse.json({ error: "Skill not found" }, { status: 404 }) + } + return NextResponse.json({ skill: result.rows[0] }) + } catch (error) { + return NextResponse.json({ error: `Failed to fetch skill: ${(error as Error).message}` }, { status: 500 }) + } +} + +// PUT /api/skills/:skillId — update custom/marketplace skill +export async function PUT(request: NextRequest, { params }: { params: Promise<{ skillId: string }> }) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const { skillId } = await params + + try { + const existing = await pool.query("SELECT id, type FROM public.skills WHERE id = $1", [skillId]) + if (existing.rows.length === 0) { + return NextResponse.json({ error: "Skill not found" }, { status: 404 }) + } + if (existing.rows[0].type === "builtin") { + return NextResponse.json({ error: "Built-in skills cannot be modified" }, { status: 400 }) + } + + const body = await request.json() + const { name, description, content, packageSpecifier, config } = body + + const fields: string[] = [] + const values: unknown[] = [] + let paramIndex = 1 + + if (name !== undefined) { + fields.push(`name = $${paramIndex++}`) + values.push(name) + } + if (description !== undefined) { + fields.push(`description = $${paramIndex++}`) + values.push(description) + } + if (content !== undefined) { + fields.push(`content = $${paramIndex++}`) + values.push(content) + } + if (packageSpecifier !== undefined) { + fields.push(`package_specifier = $${paramIndex++}`) + values.push(packageSpecifier) + } + if (config !== undefined) { + fields.push(`config = $${paramIndex++}`) + values.push(JSON.stringify(config)) + } + + if (fields.length === 0) { + return NextResponse.json({ error: "No fields to update" }, { status: 400 }) + } + + fields.push("updated_at = now()") + values.push(skillId) + + const result = await pool.query( + `UPDATE public.skills SET ${fields.join(", ")} WHERE id = $${paramIndex} RETURNING *`, + values, + ) + return NextResponse.json({ skill: result.rows[0] }) + } catch (error) { + return NextResponse.json({ error: `Failed to update skill: ${(error as Error).message}` }, { status: 500 }) + } +} + +// DELETE /api/skills/:skillId +export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ skillId: string }> }) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const { skillId } = await params + + try { + const existing = await pool.query("SELECT id, type FROM public.skills WHERE id = $1", [skillId]) + if (existing.rows.length === 0) { + return NextResponse.json({ error: "Skill not found" }, { status: 404 }) + } + if (existing.rows[0].type === "builtin") { + return NextResponse.json({ error: "Built-in skills cannot be deleted" }, { status: 400 }) + } + + await pool.query("DELETE FROM public.skills WHERE id = $1", [skillId]) + return NextResponse.json({ success: true }) + } catch (error) { + return NextResponse.json({ error: `Failed to delete skill: ${(error as Error).message}` }, { status: 500 }) + } +} diff --git a/apps/web/src/app/api/skills/route.ts b/apps/web/src/app/api/skills/route.ts new file mode 100644 index 0000000..b03dd93 --- /dev/null +++ b/apps/web/src/app/api/skills/route.ts @@ -0,0 +1,77 @@ +import { auth } from "@/src/lib/auth" +import { pool } from "@/src/lib/db" +import { headers } from "next/headers" +import { type NextRequest, NextResponse } from "next/server" + +// GET /api/skills?type=builtin|marketplace|custom +export async function GET(request: NextRequest) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const { searchParams } = new URL(request.url) + const type = searchParams.get("type") + const installedBy = searchParams.get("installedBy") + + try { + let query = "SELECT * FROM public.skills ORDER BY created_at DESC" + const values: unknown[] = [] + + if (type) { + query = "SELECT * FROM public.skills WHERE type = $1 ORDER BY created_at DESC" + values.push(type) + } else if (installedBy) { + query = "SELECT * FROM public.skills WHERE installed_by = $1 ORDER BY created_at DESC" + values.push(installedBy) + } + + const result = await pool.query(query, values) + + const integrationsResult = await pool.query("SELECT DISTINCT type FROM public.integrations WHERE status = 'active'") + const configuredTypes = new Set(integrationsResult.rows.map((r: { type: string }) => r.type)) + + const skillsWithIntegrationStatus = result.rows.map((skill: Record) => { + const config = skill.config as { requires?: string[] } | null + const requires = config?.requires ?? [] + const missingIntegrations = requires.filter((r: string) => !configuredTypes.has(r)) + return { ...skill, missing_integrations: missingIntegrations } + }) + + return NextResponse.json({ skills: skillsWithIntegrationStatus }) + } catch (error) { + return NextResponse.json({ error: `Failed to fetch skills: ${(error as Error).message}` }, { status: 500 }) + } +} + +// POST /api/skills — create custom skill +export async function POST(request: NextRequest) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + try { + const body = await request.json() + const { name, description, source, content, packageSpecifier, config } = body + + if (!name) { + return NextResponse.json({ error: "Missing required field: name" }, { status: 400 }) + } + + const result = await pool.query( + `INSERT INTO public.skills (name, description, type, source, content, package_specifier, config, installed_by) + VALUES ($1, $2, 'custom', $3, $4, $5, $6, $7) + RETURNING *`, + [ + name, + description ?? "", + source ?? "file", + content ?? null, + packageSpecifier ?? null, + JSON.stringify(config ?? {}), + session.user.id, + ], + ) + + return NextResponse.json({ skill: result.rows[0] }, { status: 201 }) + } catch (error) { + return NextResponse.json({ error: `Failed to create skill: ${(error as Error).message}` }, { status: 500 }) + } +} diff --git a/apps/web/src/components/skills/skill-card-skeleton.tsx b/apps/web/src/components/skills/skill-card-skeleton.tsx new file mode 100644 index 0000000..7029476 --- /dev/null +++ b/apps/web/src/components/skills/skill-card-skeleton.tsx @@ -0,0 +1,19 @@ +import { Card, CardContent, CardHeader } from "@/src/components/ui/card" +import { Skeleton } from "@/src/components/ui/skeleton" + +const SkillCardSkeleton = () => ( + + + + + + +
+ + +
+
+
+) + +export default SkillCardSkeleton diff --git a/apps/web/src/components/skills/skill-card.tsx b/apps/web/src/components/skills/skill-card.tsx new file mode 100644 index 0000000..487d127 --- /dev/null +++ b/apps/web/src/components/skills/skill-card.tsx @@ -0,0 +1,67 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/src/components/ui/card" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/src/components/ui/dropdown-menu" +import type { Skill } from "@/src/types/skills" +import { IconAlertTriangle, IconDotsVertical, IconPencil, IconTrash } from "@tabler/icons-react" +import SkillSourceBadge from "./skill-source-badge" +import SkillTypeBadge from "./skill-type-badge" + +interface SkillCardProps { + skill: Skill + onViewDetail: (skill: Skill) => void + onEdit: (skill: Skill) => void + onDelete: (id: string) => void +} + +const SkillCard = ({ skill, onViewDetail, onEdit, onDelete }: SkillCardProps) => ( + onViewDetail(skill)}> + +
+ {skill.name} + {skill.type !== "builtin" && ( + + + + + e.stopPropagation()}> + onEdit(skill)}> + + Edit + + onDelete(skill.id)} className="text-destructive focus:text-destructive"> + + Delete + + + + )} +
+ {skill.description || "No description"} +
+ +
+ + +
+ {skill.missing_integrations && skill.missing_integrations.length > 0 && ( +
+ + Missing: {skill.missing_integrations.join(", ")} +
+ )} +

Created {new Date(skill.created_at).toLocaleDateString()}

+
+
+) + +export default SkillCard diff --git a/apps/web/src/components/skills/skill-delete-dialog.tsx b/apps/web/src/components/skills/skill-delete-dialog.tsx new file mode 100644 index 0000000..dd75c54 --- /dev/null +++ b/apps/web/src/components/skills/skill-delete-dialog.tsx @@ -0,0 +1,69 @@ +"use client" + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/src/components/ui/alert-dialog" +import useDeleteSkill from "@/src/hooks/skills/use-delete-skill" +import { IconLoader2, IconTrash } from "@tabler/icons-react" +import { toast } from "sonner" + +interface SkillDeleteDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + skillId: string | null +} + +const SkillDeleteDialog = ({ open, onOpenChange, skillId }: SkillDeleteDialogProps) => { + const deleteMutation = useDeleteSkill(skillId!) + + const handleDelete = async () => { + if (!skillId) return + + try { + await deleteMutation.mutateAsync() + toast.success("Skill deleted") + onOpenChange(false) + } catch (error) { + toast.error("Failed to delete skill", { + description: (error as Error).message, + }) + } + } + + return ( + + + + Delete Skill + + This will permanently delete this skill. This action cannot be undone. + + + + Cancel + + {deleteMutation.isPending ? ( + + ) : ( + + )} + {deleteMutation.isPending ? "Deleting..." : "Delete Permanently"} + + + + + ) +} + +export default SkillDeleteDialog diff --git a/apps/web/src/components/skills/skill-detail-dialog.tsx b/apps/web/src/components/skills/skill-detail-dialog.tsx new file mode 100644 index 0000000..2fc9620 --- /dev/null +++ b/apps/web/src/components/skills/skill-detail-dialog.tsx @@ -0,0 +1,80 @@ +"use client" + +import { Badge } from "@/src/components/ui/badge" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/src/components/ui/dialog" +import { ScrollArea } from "@/src/components/ui/scroll-area" +import { useGetSkill } from "@/src/hooks/skills/use-get-skill" +import type { Skill } from "@/src/types/skills" +import { IconLoader2, IconPackage } from "@tabler/icons-react" +import SkillSourceBadge from "./skill-source-badge" +import SkillTypeBadge from "./skill-type-badge" + +interface SkillDetailDialogProps { + skill: Skill | null + onOpenChange: (open: boolean) => void +} + +const SkillDetailDialog = ({ skill, onOpenChange }: SkillDetailDialogProps) => { + const { data: fullSkill, isLoading } = useGetSkill(skill?.id ?? null) + + const displaySkill = fullSkill ?? skill + + return ( + + + {displaySkill && ( + <> + + {displaySkill.name} + {displaySkill.description || "No description"} + +
+ + + {displaySkill.package_specifier && ( + + + {displaySkill.package_specifier} + + )} +
+ {displaySkill.config?.requires && displaySkill.config.requires.length > 0 && ( +
+

Required Integrations

+
+ {displaySkill.config.requires.map((req: string) => ( + + {req} + + ))} +
+
+ )} + + {isLoading ? ( +
+ +
+ ) : ( +
+ {displaySkill.content || "No content available."} +
+ )} +
+

+ Created {new Date(displaySkill.created_at).toLocaleDateString()} + {displaySkill.updated_at && displaySkill.updated_at !== displaySkill.created_at && ( + <> · Updated {new Date(displaySkill.updated_at).toLocaleDateString()} + )} +

+ + )} +
+
+ ) +} + +export default SkillDetailDialog diff --git a/apps/web/src/components/skills/skill-form-dialog.tsx b/apps/web/src/components/skills/skill-form-dialog.tsx new file mode 100644 index 0000000..4fd1740 --- /dev/null +++ b/apps/web/src/components/skills/skill-form-dialog.tsx @@ -0,0 +1,131 @@ +"use client" + +import { Button } from "@/src/components/ui/button" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/src/components/ui/dialog" +import { Input } from "@/src/components/ui/input" +import { Label } from "@/src/components/ui/label" +import { Textarea } from "@/src/components/ui/textarea" +import useCreateSkill from "@/src/hooks/skills/use-create-skill" +import useUpdateSkill from "@/src/hooks/skills/use-update-skill" +import type { Skill } from "@/src/types/skills" +import { IconCode, IconLoader2 } from "@tabler/icons-react" +import { useEffect, useState } from "react" +import { toast } from "sonner" + +interface SkillFormDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + skill: Skill | null +} + +const SkillFormDialog = ({ open, onOpenChange, skill }: SkillFormDialogProps) => { + const [name, setName] = useState("") + const [description, setDescription] = useState("") + const [content, setContent] = useState("") + const { id } = skill ?? {} + + const createMutation = useCreateSkill() + const updateMutation = useUpdateSkill(id!) + + const isEditing = !!skill + const saving = createMutation.isPending || updateMutation.isPending + + useEffect(() => { + if (open) { + setName(skill?.name ?? "") + setDescription(skill?.description ?? "") + setContent(skill?.content ?? "") + } + }, [open, skill]) + + const handleSave = async () => { + if (!name.trim()) { + toast.error("Name is required") + return + } + + try { + if (isEditing) { + await updateMutation.mutateAsync({ + name: name.trim(), + description: description.trim(), + content, + }) + toast.success("Skill updated") + } else { + await createMutation.mutateAsync({ + name: name.trim(), + description: description.trim(), + source: "file" as const, + content, + }) + toast.success("Skill created") + } + onOpenChange(false) + } catch (error) { + toast.error(`Failed to ${isEditing ? "update" : "create"} skill`, { + description: (error as Error).message, + }) + } + } + + return ( + + + + {isEditing ? "Edit Skill" : "Create Skill"} + + {isEditing + ? "Update the skill definition below." + : "Define a new custom skill. The content field accepts SKILL.md markdown."} + + + +
+
+ + setName(e.target.value)} + placeholder="My Custom Skill" + /> +
+
+ +