554 lines
18 KiB
TypeScript
554 lines
18 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useCallback } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { apiClient } from '@/infrastructure/api/api-client';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface Skill {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
category: 'inspection' | 'deployment' | 'maintenance' | 'security' | 'monitoring' | 'custom';
|
|
script: string;
|
|
tags: string[];
|
|
enabled: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
interface SkillFormData {
|
|
name: string;
|
|
description: string;
|
|
category: Skill['category'];
|
|
script: string;
|
|
tags: string;
|
|
enabled: boolean;
|
|
}
|
|
|
|
interface SkillsResponse {
|
|
data: Skill[];
|
|
total: number;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const SKILL_QUERY_KEY = ['skills'] as const;
|
|
|
|
const CATEGORIES: { label: string; value: Skill['category'] }[] = [
|
|
{ label: 'Inspection', value: 'inspection' },
|
|
{ label: 'Deployment', value: 'deployment' },
|
|
{ label: 'Maintenance', value: 'maintenance' },
|
|
{ label: 'Security', value: 'security' },
|
|
{ label: 'Monitoring', value: 'monitoring' },
|
|
{ label: 'Custom', value: 'custom' },
|
|
];
|
|
|
|
const CATEGORY_STYLES: Record<Skill['category'], string> = {
|
|
inspection: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
|
deployment: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
|
|
maintenance: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400',
|
|
security: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
|
monitoring: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
|
custom: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400',
|
|
};
|
|
|
|
const EMPTY_FORM: SkillFormData = {
|
|
name: '',
|
|
description: '',
|
|
category: 'custom',
|
|
script: '',
|
|
tags: '',
|
|
enabled: true,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Category badge
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function CategoryBadge({ category }: { category: Skill['category'] }) {
|
|
return (
|
|
<span
|
|
className={cn(
|
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize',
|
|
CATEGORY_STYLES[category],
|
|
)}
|
|
>
|
|
{category}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Enabled / Disabled badge
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function EnabledBadge({ enabled }: { enabled: boolean }) {
|
|
return (
|
|
<span
|
|
className={cn(
|
|
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
|
|
enabled
|
|
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
|
: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400',
|
|
)}
|
|
>
|
|
{enabled ? 'Enabled' : 'Disabled'}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Skill form dialog
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function SkillDialog({
|
|
open,
|
|
title,
|
|
form,
|
|
errors,
|
|
saving,
|
|
onClose,
|
|
onChange,
|
|
onSubmit,
|
|
}: {
|
|
open: boolean;
|
|
title: string;
|
|
form: SkillFormData;
|
|
errors: Partial<Record<keyof SkillFormData, string>>;
|
|
saving: boolean;
|
|
onClose: () => void;
|
|
onChange: (field: keyof SkillFormData, value: string | boolean) => void;
|
|
onSubmit: () => void;
|
|
}) {
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
{/* backdrop */}
|
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
|
|
|
{/* dialog */}
|
|
<div className="relative z-10 w-full max-w-lg bg-card border rounded-lg shadow-lg p-6 mx-4">
|
|
<h2 className="text-lg font-semibold mb-4">{title}</h2>
|
|
|
|
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1">
|
|
{/* name */}
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">
|
|
Name <span className="text-destructive">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={form.name}
|
|
onChange={(e) => onChange('name', e.target.value)}
|
|
className={cn(
|
|
'w-full px-3 py-2 rounded-md border bg-background text-sm',
|
|
errors.name ? 'border-destructive' : 'border-input',
|
|
)}
|
|
placeholder="check-disk-usage"
|
|
/>
|
|
{errors.name && (
|
|
<p className="text-xs text-destructive mt-1">{errors.name}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* description */}
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">Description</label>
|
|
<textarea
|
|
value={form.description}
|
|
onChange={(e) => onChange('description', e.target.value)}
|
|
rows={3}
|
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm resize-none"
|
|
placeholder="What does this skill do..."
|
|
/>
|
|
</div>
|
|
|
|
{/* category */}
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">Category</label>
|
|
<select
|
|
value={form.category}
|
|
onChange={(e) => onChange('category', e.target.value)}
|
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
|
>
|
|
{CATEGORIES.map((cat) => (
|
|
<option key={cat.value} value={cat.value}>
|
|
{cat.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* script */}
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">Script</label>
|
|
<textarea
|
|
value={form.script}
|
|
onChange={(e) => onChange('script', e.target.value)}
|
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm font-mono min-h-[200px] resize-y"
|
|
placeholder={"#!/bin/bash\n# Skill script content..."}
|
|
/>
|
|
</div>
|
|
|
|
{/* tags */}
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">Tags</label>
|
|
<input
|
|
type="text"
|
|
value={form.tags}
|
|
onChange={(e) => onChange('tags', e.target.value)}
|
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
|
placeholder="disk, health, linux (comma-separated)"
|
|
/>
|
|
</div>
|
|
|
|
{/* enabled toggle */}
|
|
<div className="flex items-center gap-3">
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={form.enabled}
|
|
onChange={(e) => onChange('enabled', e.target.checked)}
|
|
className="sr-only peer"
|
|
/>
|
|
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-ring rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:after:border-gray-600 peer-checked:bg-primary" />
|
|
</label>
|
|
<span className="text-sm font-medium">Enabled</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* actions */}
|
|
<div className="flex justify-end gap-3 mt-6 pt-4 border-t">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
|
disabled={saving}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onSubmit}
|
|
disabled={saving}
|
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
>
|
|
{saving ? 'Saving...' : 'Save'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Delete confirmation dialog
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function DeleteDialog({
|
|
open,
|
|
skillName,
|
|
deleting,
|
|
onClose,
|
|
onConfirm,
|
|
}: {
|
|
open: boolean;
|
|
skillName: string;
|
|
deleting: boolean;
|
|
onClose: () => void;
|
|
onConfirm: () => void;
|
|
}) {
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
|
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
|
|
<h2 className="text-lg font-semibold mb-2">Delete Skill</h2>
|
|
<p className="text-sm text-muted-foreground mb-6">
|
|
Are you sure you want to delete <strong>{skillName}</strong>? This action cannot be undone.
|
|
</p>
|
|
<div className="flex justify-end gap-3">
|
|
<button
|
|
onClick={onClose}
|
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
|
disabled={deleting}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={onConfirm}
|
|
disabled={deleting}
|
|
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
|
|
>
|
|
{deleting ? 'Deleting...' : 'Delete'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main page component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export default function SkillsPage() {
|
|
const queryClient = useQueryClient();
|
|
|
|
// State ----------------------------------------------------------------
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [editingSkill, setEditingSkill] = useState<Skill | null>(null);
|
|
const [deleteTarget, setDeleteTarget] = useState<Skill | null>(null);
|
|
const [form, setForm] = useState<SkillFormData>({ ...EMPTY_FORM });
|
|
const [errors, setErrors] = useState<Partial<Record<keyof SkillFormData, string>>>({});
|
|
|
|
// Queries --------------------------------------------------------------
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: SKILL_QUERY_KEY,
|
|
queryFn: () => apiClient<SkillsResponse>('/api/v1/agent/skills'),
|
|
});
|
|
|
|
const skills = data?.data ?? [];
|
|
|
|
// Mutations ------------------------------------------------------------
|
|
const createMutation = useMutation({
|
|
mutationFn: (body: Record<string, unknown>) =>
|
|
apiClient<Skill>('/api/v1/agent/skills', { method: 'POST', body }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: SKILL_QUERY_KEY });
|
|
closeDialog();
|
|
},
|
|
});
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: ({ id, body }: { id: string; body: Record<string, unknown> }) =>
|
|
apiClient<Skill>(`/api/v1/agent/skills/${id}`, { method: 'PUT', body }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: SKILL_QUERY_KEY });
|
|
closeDialog();
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (id: string) =>
|
|
apiClient<void>(`/api/v1/agent/skills/${id}`, { method: 'DELETE' }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: SKILL_QUERY_KEY });
|
|
setDeleteTarget(null);
|
|
},
|
|
});
|
|
|
|
// Helpers --------------------------------------------------------------
|
|
const validate = useCallback((): boolean => {
|
|
const next: Partial<Record<keyof SkillFormData, string>> = {};
|
|
if (!form.name.trim()) next.name = 'Name is required';
|
|
setErrors(next);
|
|
return Object.keys(next).length === 0;
|
|
}, [form]);
|
|
|
|
const closeDialog = useCallback(() => {
|
|
setDialogOpen(false);
|
|
setEditingSkill(null);
|
|
setForm({ ...EMPTY_FORM });
|
|
setErrors({});
|
|
}, []);
|
|
|
|
const openAdd = useCallback(() => {
|
|
setEditingSkill(null);
|
|
setForm({ ...EMPTY_FORM });
|
|
setErrors({});
|
|
setDialogOpen(true);
|
|
}, []);
|
|
|
|
const openEdit = useCallback((skill: Skill) => {
|
|
setEditingSkill(skill);
|
|
setForm({
|
|
name: skill.name,
|
|
description: skill.description ?? '',
|
|
category: skill.category,
|
|
script: skill.script ?? '',
|
|
tags: (skill.tags ?? []).join(', '),
|
|
enabled: skill.enabled,
|
|
});
|
|
setErrors({});
|
|
setDialogOpen(true);
|
|
}, []);
|
|
|
|
const handleChange = useCallback(
|
|
(field: keyof SkillFormData, value: string | boolean) => {
|
|
setForm((prev) => ({ ...prev, [field]: value }));
|
|
setErrors((prev) => {
|
|
const next = { ...prev };
|
|
delete next[field];
|
|
return next;
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleSubmit = useCallback(() => {
|
|
if (!validate()) return;
|
|
const body: Record<string, unknown> = {
|
|
name: form.name.trim(),
|
|
description: form.description.trim(),
|
|
category: form.category,
|
|
script: form.script,
|
|
tags: form.tags
|
|
.split(',')
|
|
.map((t) => t.trim())
|
|
.filter(Boolean),
|
|
enabled: form.enabled,
|
|
};
|
|
|
|
if (editingSkill) {
|
|
updateMutation.mutate({ id: editingSkill.id, body });
|
|
} else {
|
|
createMutation.mutate(body);
|
|
}
|
|
}, [form, editingSkill, validate, createMutation, updateMutation]);
|
|
|
|
const isSaving = createMutation.isPending || updateMutation.isPending;
|
|
|
|
// Render ---------------------------------------------------------------
|
|
return (
|
|
<div>
|
|
{/* Header */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold">Skills</h1>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
Manage Claude Code skills for the AI agent
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={openAdd}
|
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap"
|
|
>
|
|
Add Skill
|
|
</button>
|
|
</div>
|
|
|
|
{/* Error state */}
|
|
{error && (
|
|
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
|
Failed to load skills: {(error as Error).message}
|
|
</div>
|
|
)}
|
|
|
|
{/* Loading state */}
|
|
{isLoading && (
|
|
<div className="text-sm text-muted-foreground py-12 text-center">
|
|
Loading skills...
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty state */}
|
|
{!isLoading && !error && skills.length === 0 && (
|
|
<div className="text-center py-12">
|
|
<p className="text-sm text-muted-foreground mb-4">
|
|
No skills configured yet. Add your first skill to get started.
|
|
</p>
|
|
<button
|
|
onClick={openAdd}
|
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90"
|
|
>
|
|
Add Skill
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Skills grid */}
|
|
{!isLoading && !error && skills.length > 0 && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{skills.map((skill) => (
|
|
<div
|
|
key={skill.id}
|
|
className="bg-card border rounded-lg p-4 hover:shadow-sm transition-shadow"
|
|
>
|
|
{/* Header: name + enabled badge */}
|
|
<div className="flex items-start justify-between gap-2">
|
|
<h3 className="font-semibold text-sm">{skill.name}</h3>
|
|
<EnabledBadge enabled={skill.enabled} />
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<p className="text-xs text-muted-foreground line-clamp-2 mt-1">
|
|
{skill.description || 'No description'}
|
|
</p>
|
|
|
|
{/* Category badge */}
|
|
<div className="mt-2">
|
|
<CategoryBadge category={skill.category} />
|
|
</div>
|
|
|
|
{/* Tags */}
|
|
{skill.tags && skill.tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-2">
|
|
{skill.tags.map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className="px-2 py-0.5 text-xs rounded-full bg-muted"
|
|
>
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center justify-between mt-3 pt-3 border-t">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => openEdit(skill)}
|
|
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
onClick={() => setDeleteTarget(skill)}
|
|
className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Add / Edit dialog */}
|
|
<SkillDialog
|
|
open={dialogOpen}
|
|
title={editingSkill ? 'Edit Skill' : 'Add Skill'}
|
|
form={form}
|
|
errors={errors}
|
|
saving={isSaving}
|
|
onClose={closeDialog}
|
|
onChange={handleChange}
|
|
onSubmit={handleSubmit}
|
|
/>
|
|
|
|
{/* Delete confirmation dialog */}
|
|
<DeleteDialog
|
|
open={!!deleteTarget}
|
|
skillName={deleteTarget?.name ?? ''}
|
|
deleting={deleteMutation.isPending}
|
|
onClose={() => setDeleteTarget(null)}
|
|
onConfirm={() => {
|
|
if (deleteTarget) deleteMutation.mutate(deleteTarget.id);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|