Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions apps/web/src/app/(application)/skills/page.tsx
Original file line number Diff line number Diff line change
@@ -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<SkillTab, string> = {
all: "All",
builtin: "Built-in",
custom: "Custom",
}

const SkillsPage = () => {
const { data: skills = [], isLoading } = useGetSkills()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: for queries use isPending instead of isLoading as flag. Link: TanStack/query#6297 (comment)

const [activeTab, setActiveTab] = useState<SkillTab>("all")

const [formOpen, setFormOpen] = useState(false)
const [editingSkill, setEditingSkill] = useState<Skill | null>(null)
const [detailSkill, setDetailSkill] = useState<Skill | null>(null)
const [deleteSkillId, setDeleteSkillId] = useState<string | null>(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 (
<div className="flex flex-col w-full h-full gap-6">
<div className="flex flex-row w-full justify-between items-center">
<div>
<h4 className="text-xl font-semibold">Skills</h4>
<p className="text-sm text-muted-foreground">Browse, install, and create agent skills</p>
</div>
<Button onClick={handleCreate}>
<IconPlus className="size-4" />
Create Skill
</Button>
</div>

<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as SkillTab)}>
<TabsList>
{TABS.map((tab) => (
<TabsTrigger key={tab} value={tab}>
{TAB_LABELS[tab]}
</TabsTrigger>
))}
</TabsList>

{TABS.map((tab) => (
<TabsContent key={tab} value={tab}>
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<SkillCardSkeleton key={i} />
))}
</div>
) : filteredSkills.length === 0 ? (
<SkillsEmptyState tab={tab} onCreateClick={handleCreate} />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filteredSkills.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
onViewDetail={setDetailSkill}
onEdit={handleEdit}
onDelete={setDeleteSkillId}
/>
))}
</div>
)}
</TabsContent>
))}
</Tabs>

<SkillFormDialog open={formOpen} onOpenChange={setFormOpen} skill={editingSkill} />

<SkillDetailDialog
skill={detailSkill}
onOpenChange={(open) => {
if (!open) setDetailSkill(null)
}}
/>

<SkillDeleteDialog
open={!!deleteSkillId}
onOpenChange={(open) => {
if (!open) setDeleteSkillId(null)
}}
skillId={deleteSkillId}
/>
</div>
)
}

export default SkillsPage
98 changes: 98 additions & 0 deletions apps/web/src/app/api/agent-configs/[configId]/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
106 changes: 106 additions & 0 deletions apps/web/src/app/api/skills/[skillId]/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
77 changes: 77 additions & 0 deletions apps/web/src/app/api/skills/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => {
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 })
}
}
Loading
Loading