apteva 0.2.11 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,871 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { useAuth, useProjects } from "../../context";
3
+ import { useConfirm, useAlert } from "../common/Modal";
4
+ import { Select } from "../common/Select";
5
+
6
+ interface Skill {
7
+ id: string;
8
+ name: string;
9
+ description: string;
10
+ content: string;
11
+ license: string | null;
12
+ compatibility: string | null;
13
+ metadata: Record<string, string>;
14
+ allowed_tools: string[];
15
+ source: "local" | "skillsmp" | "github" | "import";
16
+ source_url: string | null;
17
+ enabled: boolean;
18
+ project_id: string | null; // null = global
19
+ created_at: string;
20
+ updated_at: string;
21
+ }
22
+
23
+ interface MarketplaceSkill {
24
+ id: string;
25
+ name: string;
26
+ description: string;
27
+ content: string;
28
+ author: string;
29
+ version: string;
30
+ license: string | null;
31
+ compatibility: string | null;
32
+ tags: string[];
33
+ downloads: number;
34
+ rating: number;
35
+ repository: string | null;
36
+ }
37
+
38
+ export function SkillsPage() {
39
+ const { authFetch } = useAuth();
40
+ const { projects, currentProjectId } = useProjects();
41
+ const [skills, setSkills] = useState<Skill[]>([]);
42
+ const [loading, setLoading] = useState(true);
43
+ const [activeTab, setActiveTab] = useState<"installed" | "marketplace">("installed");
44
+ const [showCreate, setShowCreate] = useState(false);
45
+ const [showImport, setShowImport] = useState(false);
46
+ const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
47
+ const { confirm, ConfirmDialog } = useConfirm();
48
+ const { alert, AlertDialog } = useAlert();
49
+
50
+ const hasProjects = projects.length > 0;
51
+
52
+ // Marketplace state
53
+ const [searchQuery, setSearchQuery] = useState("");
54
+ const [marketplaceSkills, setMarketplaceSkills] = useState<MarketplaceSkill[]>([]);
55
+ const [marketplaceLoading, setMarketplaceLoading] = useState(false);
56
+ const [installing, setInstalling] = useState<string | null>(null);
57
+
58
+ // Filter skills based on global project selector
59
+ // When a project is selected, show global + that project's skills
60
+ const filteredSkills = skills.filter(skill => {
61
+ if (!currentProjectId) return true; // "All Projects" - show everything
62
+ if (currentProjectId === "unassigned") return skill.project_id === null; // Only global
63
+ // Project selected: show global + project-specific
64
+ return skill.project_id === null || skill.project_id === currentProjectId;
65
+ });
66
+
67
+ const fetchSkills = async () => {
68
+ try {
69
+ const res = await authFetch("/api/skills");
70
+ const data = await res.json();
71
+ setSkills(data.skills || []);
72
+ } catch (e) {
73
+ console.error("Failed to fetch skills:", e);
74
+ }
75
+ setLoading(false);
76
+ };
77
+
78
+ const searchMarketplace = async (query?: string) => {
79
+ setMarketplaceLoading(true);
80
+ try {
81
+ const q = query !== undefined ? query : searchQuery;
82
+ const endpoint = q
83
+ ? `/api/skills/marketplace/search?q=${encodeURIComponent(q)}`
84
+ : "/api/skills/marketplace/featured";
85
+ const res = await authFetch(endpoint);
86
+ const data = await res.json();
87
+ setMarketplaceSkills(data.skills || []);
88
+ } catch (e) {
89
+ console.error("Failed to search marketplace:", e);
90
+ }
91
+ setMarketplaceLoading(false);
92
+ };
93
+
94
+ useEffect(() => {
95
+ fetchSkills();
96
+ }, [authFetch]);
97
+
98
+ useEffect(() => {
99
+ if (activeTab === "marketplace" && marketplaceSkills.length === 0) {
100
+ searchMarketplace("");
101
+ }
102
+ }, [activeTab]);
103
+
104
+ const toggleSkill = async (id: string) => {
105
+ try {
106
+ await authFetch(`/api/skills/${id}/toggle`, { method: "POST" });
107
+ fetchSkills();
108
+ } catch (e) {
109
+ console.error("Failed to toggle skill:", e);
110
+ }
111
+ };
112
+
113
+ const deleteSkill = async (id: string) => {
114
+ const confirmed = await confirm("Delete this skill?", { confirmText: "Delete", title: "Delete Skill" });
115
+ if (!confirmed) return;
116
+ try {
117
+ await authFetch(`/api/skills/${id}`, { method: "DELETE" });
118
+ if (selectedSkill?.id === id) {
119
+ setSelectedSkill(null);
120
+ }
121
+ fetchSkills();
122
+ } catch (e) {
123
+ console.error("Failed to delete skill:", e);
124
+ }
125
+ };
126
+
127
+ const installFromMarketplace = async (skill: MarketplaceSkill) => {
128
+ setInstalling(skill.id);
129
+ try {
130
+ const res = await authFetch(`/api/skills/marketplace/${skill.id}/install`, { method: "POST" });
131
+ const data = await res.json();
132
+ if (res.ok) {
133
+ await alert(`Installed "${skill.name}" successfully!`, { title: "Skill Installed" });
134
+ fetchSkills();
135
+ setActiveTab("installed");
136
+ } else {
137
+ await alert(data.error || "Failed to install skill", { title: "Installation Failed" });
138
+ }
139
+ } catch (e) {
140
+ console.error("Failed to install skill:", e);
141
+ await alert("Failed to install skill", { title: "Error" });
142
+ }
143
+ setInstalling(null);
144
+ };
145
+
146
+ const isInstalled = (name: string) => skills.some((s) => s.name === name);
147
+
148
+ return (
149
+ <>
150
+ {ConfirmDialog}
151
+ {AlertDialog}
152
+ <div className="flex-1 overflow-auto p-6">
153
+ <div className="max-w-6xl">
154
+ {/* Header */}
155
+ <div className="flex items-center justify-between mb-6">
156
+ <div>
157
+ <h1 className="text-2xl font-semibold mb-1">Skills</h1>
158
+ <p className="text-[#666]">
159
+ Manage agent skills - instructions that teach agents how to perform tasks.
160
+ </p>
161
+ </div>
162
+ {activeTab === "installed" && (
163
+ <div className="flex gap-2">
164
+ <button
165
+ onClick={() => setShowImport(true)}
166
+ className="bg-[#1a1a1a] hover:bg-[#222] text-white px-4 py-2 rounded font-medium transition border border-[#333]"
167
+ >
168
+ Import
169
+ </button>
170
+ <button
171
+ onClick={() => setShowCreate(true)}
172
+ className="bg-[#f97316] hover:bg-[#fb923c] text-black px-4 py-2 rounded font-medium transition"
173
+ >
174
+ + Create Skill
175
+ </button>
176
+ </div>
177
+ )}
178
+ </div>
179
+
180
+ {/* Tabs */}
181
+ <div className="flex gap-1 mb-6 bg-[#111] border border-[#1a1a1a] rounded-lg p-1 w-fit">
182
+ <button
183
+ onClick={() => setActiveTab("installed")}
184
+ className={`px-4 py-2 rounded text-sm font-medium transition ${
185
+ activeTab === "installed"
186
+ ? "bg-[#1a1a1a] text-white"
187
+ : "text-[#666] hover:text-[#888]"
188
+ }`}
189
+ >
190
+ Installed ({skills.length})
191
+ </button>
192
+ <button
193
+ onClick={() => setActiveTab("marketplace")}
194
+ className={`px-4 py-2 rounded text-sm font-medium transition ${
195
+ activeTab === "marketplace"
196
+ ? "bg-[#1a1a1a] text-white"
197
+ : "text-[#666] hover:text-[#888]"
198
+ }`}
199
+ >
200
+ Marketplace
201
+ </button>
202
+ </div>
203
+
204
+ {/* Installed Tab */}
205
+ {activeTab === "installed" && (
206
+ <>
207
+ {loading ? (
208
+ <div className="text-[#666]">Loading skills...</div>
209
+ ) : skills.length === 0 ? (
210
+ <div className="text-center py-20 text-[#666]">
211
+ <p className="text-lg">No skills installed</p>
212
+ <p className="text-sm mt-1">Create a skill or browse the marketplace</p>
213
+ <button
214
+ onClick={() => setActiveTab("marketplace")}
215
+ className="mt-4 bg-[#f97316] hover:bg-[#fb923c] text-black px-4 py-2 rounded font-medium transition"
216
+ >
217
+ Browse Marketplace
218
+ </button>
219
+ </div>
220
+ ) : filteredSkills.length === 0 ? (
221
+ <div className="bg-[#111] border border-[#1a1a1a] rounded-lg p-6 text-center">
222
+ <p className="text-[#666]">No skills match this filter.</p>
223
+ </div>
224
+ ) : (
225
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
226
+ {filteredSkills.map((skill) => {
227
+ const project = hasProjects && skill.project_id
228
+ ? projects.find(p => p.id === skill.project_id)
229
+ : null;
230
+ return (
231
+ <SkillCard
232
+ key={skill.id}
233
+ skill={skill}
234
+ project={project}
235
+ onToggle={() => toggleSkill(skill.id)}
236
+ onDelete={() => deleteSkill(skill.id)}
237
+ onView={() => setSelectedSkill(skill)}
238
+ />
239
+ );
240
+ })}
241
+ </div>
242
+ )}
243
+ </>
244
+ )}
245
+
246
+ {/* Marketplace Tab */}
247
+ {activeTab === "marketplace" && (
248
+ <>
249
+ {/* Search */}
250
+ <div className="mb-6">
251
+ <div className="flex gap-2">
252
+ <input
253
+ type="text"
254
+ value={searchQuery}
255
+ onChange={(e) => setSearchQuery(e.target.value)}
256
+ onKeyDown={(e) => e.key === "Enter" && searchMarketplace()}
257
+ placeholder="Search skills..."
258
+ className="flex-1 bg-[#111] border border-[#1a1a1a] rounded px-4 py-2 focus:outline-none focus:border-[#f97316]"
259
+ />
260
+ <button
261
+ onClick={() => searchMarketplace()}
262
+ disabled={marketplaceLoading}
263
+ className="bg-[#1a1a1a] hover:bg-[#222] text-white px-4 py-2 rounded font-medium transition border border-[#333]"
264
+ >
265
+ {marketplaceLoading ? "..." : "Search"}
266
+ </button>
267
+ </div>
268
+ </div>
269
+
270
+ {marketplaceLoading ? (
271
+ <div className="text-[#666]">Loading...</div>
272
+ ) : marketplaceSkills.length === 0 ? (
273
+ <div className="text-center py-20 text-[#666]">
274
+ <p className="text-lg">No skills found</p>
275
+ <p className="text-sm mt-1">Try a different search term</p>
276
+ </div>
277
+ ) : (
278
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
279
+ {marketplaceSkills.map((skill) => (
280
+ <MarketplaceSkillCard
281
+ key={skill.id}
282
+ skill={skill}
283
+ installed={isInstalled(skill.name)}
284
+ installing={installing === skill.id}
285
+ onInstall={() => installFromMarketplace(skill)}
286
+ />
287
+ ))}
288
+ </div>
289
+ )}
290
+ </>
291
+ )}
292
+ </div>
293
+ </div>
294
+
295
+ {/* Create Modal */}
296
+ {showCreate && (
297
+ <CreateSkillModal
298
+ authFetch={authFetch}
299
+ onClose={() => setShowCreate(false)}
300
+ onCreated={() => {
301
+ setShowCreate(false);
302
+ fetchSkills();
303
+ }}
304
+ projects={hasProjects ? projects : undefined}
305
+ defaultProjectId={currentProjectId && currentProjectId !== "unassigned" ? currentProjectId : null}
306
+ />
307
+ )}
308
+
309
+ {/* Import Modal */}
310
+ {showImport && (
311
+ <ImportSkillModal
312
+ authFetch={authFetch}
313
+ onClose={() => setShowImport(false)}
314
+ onImported={() => {
315
+ setShowImport(false);
316
+ fetchSkills();
317
+ }}
318
+ />
319
+ )}
320
+
321
+ {/* View/Edit Modal */}
322
+ {selectedSkill && (
323
+ <ViewSkillModal
324
+ skill={selectedSkill}
325
+ authFetch={authFetch}
326
+ onClose={() => setSelectedSkill(null)}
327
+ onUpdated={() => {
328
+ setSelectedSkill(null);
329
+ fetchSkills();
330
+ }}
331
+ />
332
+ )}
333
+ </>
334
+ );
335
+ }
336
+
337
+ function SkillCard({
338
+ skill,
339
+ project,
340
+ onToggle,
341
+ onDelete,
342
+ onView,
343
+ }: {
344
+ skill: Skill;
345
+ project?: { id: string; name: string; color: string } | null;
346
+ onToggle: () => void;
347
+ onDelete: () => void;
348
+ onView: () => void;
349
+ }) {
350
+ const sourceLabel = {
351
+ local: "Local",
352
+ skillsmp: "SkillsMP",
353
+ github: "GitHub",
354
+ import: "Imported",
355
+ }[skill.source];
356
+
357
+ // Scope badge: Global or Project name
358
+ const getScopeBadge = () => {
359
+ if (project) {
360
+ return (
361
+ <span
362
+ className="text-xs px-1.5 py-0.5 rounded"
363
+ style={{ backgroundColor: `${project.color}20`, color: project.color }}
364
+ >
365
+ {project.name}
366
+ </span>
367
+ );
368
+ }
369
+ if (skill.project_id === null) {
370
+ return (
371
+ <span className="text-xs text-[#666] bg-[#1a1a1a] px-1.5 py-0.5 rounded">
372
+ Global
373
+ </span>
374
+ );
375
+ }
376
+ return null;
377
+ };
378
+
379
+ return (
380
+ <div
381
+ className={`bg-[#111] rounded-lg p-5 border transition cursor-pointer ${
382
+ skill.enabled ? "border-[#1a1a1a]" : "border-[#1a1a1a] opacity-60"
383
+ } hover:border-[#333]`}
384
+ onClick={onView}
385
+ >
386
+ <div className="flex items-start justify-between mb-3">
387
+ <div className="flex-1 min-w-0">
388
+ <div className="flex items-center gap-2">
389
+ <h3 className="font-semibold text-lg truncate">{skill.name}</h3>
390
+ {getScopeBadge()}
391
+ </div>
392
+ <p className="text-xs text-[#666] flex items-center gap-2 mt-0.5">
393
+ <span className={`px-1.5 py-0.5 rounded text-[10px] ${
394
+ skill.source === "skillsmp" ? "bg-purple-500/20 text-purple-400" :
395
+ skill.source === "github" ? "bg-blue-500/20 text-blue-400" :
396
+ "bg-[#222] text-[#888]"
397
+ }`}>
398
+ {sourceLabel}
399
+ </span>
400
+ {skill.metadata?.version && <span>v{skill.metadata.version}</span>}
401
+ </p>
402
+ </div>
403
+ <button
404
+ onClick={(e) => {
405
+ e.stopPropagation();
406
+ onToggle();
407
+ }}
408
+ className={`w-10 h-5 rounded-full transition-colors relative ${
409
+ skill.enabled ? "bg-[#f97316]" : "bg-[#333]"
410
+ }`}
411
+ >
412
+ <span
413
+ className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
414
+ skill.enabled ? "left-5" : "left-0.5"
415
+ }`}
416
+ />
417
+ </button>
418
+ </div>
419
+
420
+ <p className="text-sm text-[#888] line-clamp-2 mb-4">{skill.description}</p>
421
+
422
+ <div className="flex items-center justify-between">
423
+ <div className="flex gap-1 flex-wrap">
424
+ {skill.allowed_tools.slice(0, 2).map((tool) => (
425
+ <span key={tool} className="text-xs bg-[#222] px-2 py-0.5 rounded text-[#666]">
426
+ {tool}
427
+ </span>
428
+ ))}
429
+ {skill.allowed_tools.length > 2 && (
430
+ <span className="text-xs text-[#666]">+{skill.allowed_tools.length - 2}</span>
431
+ )}
432
+ </div>
433
+ <button
434
+ onClick={(e) => {
435
+ e.stopPropagation();
436
+ onDelete();
437
+ }}
438
+ className="text-red-400 hover:text-red-300 text-sm"
439
+ >
440
+ Delete
441
+ </button>
442
+ </div>
443
+ </div>
444
+ );
445
+ }
446
+
447
+ function MarketplaceSkillCard({
448
+ skill,
449
+ installed,
450
+ installing,
451
+ onInstall,
452
+ }: {
453
+ skill: MarketplaceSkill;
454
+ installed: boolean;
455
+ installing: boolean;
456
+ onInstall: () => void;
457
+ }) {
458
+ return (
459
+ <div className="bg-[#111] rounded-lg p-5 border border-[#1a1a1a] hover:border-[#333] transition">
460
+ <div className="flex items-start justify-between mb-3">
461
+ <div className="flex-1 min-w-0">
462
+ <h3 className="font-semibold text-lg truncate">{skill.name}</h3>
463
+ <p className="text-xs text-[#666] mt-0.5">
464
+ by {skill.author} · v{skill.version}
465
+ </p>
466
+ </div>
467
+ <div className="flex items-center gap-1 text-yellow-500 text-sm">
468
+ ★ {skill.rating.toFixed(1)}
469
+ </div>
470
+ </div>
471
+
472
+ <p className="text-sm text-[#888] line-clamp-2 mb-4">{skill.description}</p>
473
+
474
+ <div className="flex items-center justify-between">
475
+ <div className="flex gap-1 flex-wrap">
476
+ {skill.tags.slice(0, 3).map((tag) => (
477
+ <span key={tag} className="text-xs bg-[#222] px-2 py-0.5 rounded text-[#666]">
478
+ {tag}
479
+ </span>
480
+ ))}
481
+ </div>
482
+ {installed ? (
483
+ <span className="text-green-400 text-sm">✓ Installed</span>
484
+ ) : (
485
+ <button
486
+ onClick={onInstall}
487
+ disabled={installing}
488
+ className="bg-[#f97316] hover:bg-[#fb923c] disabled:opacity-50 text-black px-3 py-1 rounded text-sm font-medium transition"
489
+ >
490
+ {installing ? "Installing..." : "Install"}
491
+ </button>
492
+ )}
493
+ </div>
494
+
495
+ <div className="mt-3 text-xs text-[#555]">
496
+ {skill.downloads.toLocaleString()} downloads
497
+ </div>
498
+ </div>
499
+ );
500
+ }
501
+
502
+ function CreateSkillModal({
503
+ authFetch,
504
+ onClose,
505
+ onCreated,
506
+ projects,
507
+ defaultProjectId,
508
+ }: {
509
+ authFetch: (url: string, options?: RequestInit) => Promise<Response>;
510
+ onClose: () => void;
511
+ onCreated: () => void;
512
+ projects?: Array<{ id: string; name: string; color: string }>;
513
+ defaultProjectId?: string | null;
514
+ }) {
515
+ const [name, setName] = useState("");
516
+ const [description, setDescription] = useState("");
517
+ const [content, setContent] = useState("");
518
+ const [projectId, setProjectId] = useState<string | null>(defaultProjectId || null);
519
+ const [saving, setSaving] = useState(false);
520
+ const [error, setError] = useState<string | null>(null);
521
+
522
+ const hasProjects = projects && projects.length > 0;
523
+
524
+ const handleSave = async () => {
525
+ if (!name || !description || !content) {
526
+ setError("All fields are required");
527
+ return;
528
+ }
529
+
530
+ setSaving(true);
531
+ setError(null);
532
+
533
+ try {
534
+ const body: Record<string, unknown> = {
535
+ name,
536
+ description,
537
+ content, // Just the instructions, not wrapped in frontmatter
538
+ source: "local",
539
+ };
540
+
541
+ // Add project_id if selected
542
+ if (projectId) {
543
+ body.project_id = projectId;
544
+ }
545
+
546
+ const res = await authFetch("/api/skills", {
547
+ method: "POST",
548
+ headers: { "Content-Type": "application/json" },
549
+ body: JSON.stringify(body),
550
+ });
551
+
552
+ const data = await res.json();
553
+ if (!res.ok) {
554
+ setError(data.error || "Failed to create skill");
555
+ setSaving(false);
556
+ return;
557
+ }
558
+
559
+ onCreated();
560
+ } catch (e) {
561
+ setError("Failed to create skill");
562
+ setSaving(false);
563
+ }
564
+ };
565
+
566
+ return (
567
+ <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" onClick={onClose}>
568
+ <div
569
+ className="bg-[#111] border border-[#1a1a1a] rounded-lg w-full max-w-2xl max-h-[90vh] overflow-auto"
570
+ onClick={(e) => e.stopPropagation()}
571
+ >
572
+ <div className="p-6 border-b border-[#1a1a1a]">
573
+ <h2 className="text-xl font-semibold">Create Skill</h2>
574
+ </div>
575
+
576
+ <div className="p-6 space-y-4">
577
+ {error && (
578
+ <div className="bg-red-500/10 border border-red-500/30 rounded p-3 text-red-400 text-sm">
579
+ {error}
580
+ </div>
581
+ )}
582
+
583
+ <div>
584
+ <label className="block text-sm text-[#888] mb-1">Name</label>
585
+ <input
586
+ type="text"
587
+ value={name}
588
+ onChange={(e) => setName(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "-"))}
589
+ placeholder="my-skill-name"
590
+ className="w-full bg-[#0a0a0a] border border-[#222] rounded px-3 py-2 focus:outline-none focus:border-[#f97316]"
591
+ />
592
+ <p className="text-xs text-[#555] mt-1">Lowercase letters, numbers, and hyphens only</p>
593
+ </div>
594
+
595
+ <div>
596
+ <label className="block text-sm text-[#888] mb-1">Description</label>
597
+ <input
598
+ type="text"
599
+ value={description}
600
+ onChange={(e) => setDescription(e.target.value)}
601
+ placeholder="What this skill does and when to use it..."
602
+ className="w-full bg-[#0a0a0a] border border-[#222] rounded px-3 py-2 focus:outline-none focus:border-[#f97316]"
603
+ />
604
+ </div>
605
+
606
+ {/* Project Scope - only show when projects exist */}
607
+ {hasProjects && (
608
+ <div>
609
+ <label className="block text-sm text-[#888] mb-1">Scope</label>
610
+ <Select
611
+ value={projectId || ""}
612
+ onChange={(value) => setProjectId(value || null)}
613
+ options={[
614
+ { value: "", label: "Global (all projects)" },
615
+ ...projects!.map(p => ({ value: p.id, label: p.name }))
616
+ ]}
617
+ placeholder="Select scope..."
618
+ />
619
+ <p className="text-xs text-[#555] mt-1">
620
+ Global skills are available to all agents. Project-scoped skills are only available to agents in that project.
621
+ </p>
622
+ </div>
623
+ )}
624
+
625
+ <div>
626
+ <label className="block text-sm text-[#888] mb-1">Instructions (Markdown)</label>
627
+ <textarea
628
+ value={content}
629
+ onChange={(e) => setContent(e.target.value)}
630
+ placeholder="# Skill Instructions&#10;&#10;Write detailed instructions here..."
631
+ rows={12}
632
+ className="w-full bg-[#0a0a0a] border border-[#222] rounded px-3 py-2 focus:outline-none focus:border-[#f97316] font-mono text-sm"
633
+ />
634
+ </div>
635
+ </div>
636
+
637
+ <div className="p-6 border-t border-[#1a1a1a] flex justify-end gap-3">
638
+ <button
639
+ onClick={onClose}
640
+ className="px-4 py-2 text-[#888] hover:text-white transition"
641
+ >
642
+ Cancel
643
+ </button>
644
+ <button
645
+ onClick={handleSave}
646
+ disabled={saving}
647
+ className="bg-[#f97316] hover:bg-[#fb923c] disabled:opacity-50 text-black px-4 py-2 rounded font-medium transition"
648
+ >
649
+ {saving ? "Creating..." : "Create Skill"}
650
+ </button>
651
+ </div>
652
+ </div>
653
+ </div>
654
+ );
655
+ }
656
+
657
+ function ImportSkillModal({
658
+ authFetch,
659
+ onClose,
660
+ onImported,
661
+ }: {
662
+ authFetch: (url: string, options?: RequestInit) => Promise<Response>;
663
+ onClose: () => void;
664
+ onImported: () => void;
665
+ }) {
666
+ const [content, setContent] = useState("");
667
+ const [importing, setImporting] = useState(false);
668
+ const [error, setError] = useState<string | null>(null);
669
+
670
+ const handleImport = async () => {
671
+ if (!content.trim()) {
672
+ setError("Paste SKILL.md content");
673
+ return;
674
+ }
675
+
676
+ setImporting(true);
677
+ setError(null);
678
+
679
+ try {
680
+ const res = await authFetch("/api/skills/import", {
681
+ method: "POST",
682
+ headers: { "Content-Type": "application/json" },
683
+ body: JSON.stringify({ content }),
684
+ });
685
+
686
+ const data = await res.json();
687
+ if (!res.ok) {
688
+ setError(data.error || "Failed to import skill");
689
+ setImporting(false);
690
+ return;
691
+ }
692
+
693
+ onImported();
694
+ } catch (e) {
695
+ setError("Failed to import skill");
696
+ setImporting(false);
697
+ }
698
+ };
699
+
700
+ return (
701
+ <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" onClick={onClose}>
702
+ <div
703
+ className="bg-[#111] border border-[#1a1a1a] rounded-lg w-full max-w-2xl max-h-[90vh] overflow-auto"
704
+ onClick={(e) => e.stopPropagation()}
705
+ >
706
+ <div className="p-6 border-b border-[#1a1a1a]">
707
+ <h2 className="text-xl font-semibold">Import Skill</h2>
708
+ <p className="text-sm text-[#666] mt-1">Paste the contents of a SKILL.md file</p>
709
+ </div>
710
+
711
+ <div className="p-6 space-y-4">
712
+ {error && (
713
+ <div className="bg-red-500/10 border border-red-500/30 rounded p-3 text-red-400 text-sm">
714
+ {error}
715
+ </div>
716
+ )}
717
+
718
+ <textarea
719
+ value={content}
720
+ onChange={(e) => setContent(e.target.value)}
721
+ placeholder={`---
722
+ name: skill-name
723
+ description: What this skill does...
724
+ ---
725
+
726
+ # Instructions
727
+
728
+ Your skill instructions here...`}
729
+ rows={16}
730
+ className="w-full bg-[#0a0a0a] border border-[#222] rounded px-3 py-2 focus:outline-none focus:border-[#f97316] font-mono text-sm"
731
+ />
732
+ </div>
733
+
734
+ <div className="p-6 border-t border-[#1a1a1a] flex justify-end gap-3">
735
+ <button
736
+ onClick={onClose}
737
+ className="px-4 py-2 text-[#888] hover:text-white transition"
738
+ >
739
+ Cancel
740
+ </button>
741
+ <button
742
+ onClick={handleImport}
743
+ disabled={importing}
744
+ className="bg-[#f97316] hover:bg-[#fb923c] disabled:opacity-50 text-black px-4 py-2 rounded font-medium transition"
745
+ >
746
+ {importing ? "Importing..." : "Import Skill"}
747
+ </button>
748
+ </div>
749
+ </div>
750
+ </div>
751
+ );
752
+ }
753
+
754
+ function ViewSkillModal({
755
+ skill,
756
+ authFetch,
757
+ onClose,
758
+ onUpdated,
759
+ }: {
760
+ skill: Skill;
761
+ authFetch: (url: string, options?: RequestInit) => Promise<Response>;
762
+ onClose: () => void;
763
+ onUpdated: () => void;
764
+ }) {
765
+ const [editing, setEditing] = useState(false);
766
+ const [content, setContent] = useState(skill.content);
767
+ const [saving, setSaving] = useState(false);
768
+
769
+ const handleSave = async () => {
770
+ setSaving(true);
771
+ try {
772
+ await authFetch(`/api/skills/${skill.id}`, {
773
+ method: "PUT",
774
+ headers: { "Content-Type": "application/json" },
775
+ body: JSON.stringify({ content }),
776
+ });
777
+ onUpdated();
778
+ } catch (e) {
779
+ console.error("Failed to save:", e);
780
+ }
781
+ setSaving(false);
782
+ };
783
+
784
+ const handleExport = async () => {
785
+ try {
786
+ const res = await authFetch(`/api/skills/${skill.id}/export`);
787
+ const text = await res.text();
788
+ const blob = new Blob([text], { type: "text/markdown" });
789
+ const url = URL.createObjectURL(blob);
790
+ const a = document.createElement("a");
791
+ a.href = url;
792
+ a.download = `${skill.name}-SKILL.md`;
793
+ a.click();
794
+ URL.revokeObjectURL(url);
795
+ } catch (e) {
796
+ console.error("Failed to export:", e);
797
+ }
798
+ };
799
+
800
+ return (
801
+ <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" onClick={onClose}>
802
+ <div
803
+ className="bg-[#111] border border-[#1a1a1a] rounded-lg w-full max-w-3xl max-h-[90vh] overflow-auto"
804
+ onClick={(e) => e.stopPropagation()}
805
+ >
806
+ <div className="p-6 border-b border-[#1a1a1a] flex items-center justify-between">
807
+ <div>
808
+ <h2 className="text-xl font-semibold">{skill.name}</h2>
809
+ <p className="text-sm text-[#666] mt-0.5">{skill.description}</p>
810
+ </div>
811
+ <div className="flex gap-2">
812
+ <button
813
+ onClick={handleExport}
814
+ className="text-sm text-[#888] hover:text-white transition px-3 py-1 rounded border border-[#333]"
815
+ >
816
+ Export
817
+ </button>
818
+ <button
819
+ onClick={() => setEditing(!editing)}
820
+ className="text-sm text-[#888] hover:text-white transition px-3 py-1 rounded border border-[#333]"
821
+ >
822
+ {editing ? "View" : "Edit"}
823
+ </button>
824
+ </div>
825
+ </div>
826
+
827
+ <div className="p-6">
828
+ {editing ? (
829
+ <textarea
830
+ value={content}
831
+ onChange={(e) => setContent(e.target.value)}
832
+ rows={20}
833
+ className="w-full bg-[#0a0a0a] border border-[#222] rounded px-3 py-2 focus:outline-none focus:border-[#f97316] font-mono text-sm"
834
+ />
835
+ ) : (
836
+ <pre className="bg-[#0a0a0a] border border-[#222] rounded p-4 font-mono text-sm overflow-auto max-h-[60vh] whitespace-pre-wrap">
837
+ {skill.content}
838
+ </pre>
839
+ )}
840
+ </div>
841
+
842
+ <div className="p-6 border-t border-[#1a1a1a] flex justify-between">
843
+ <div className="text-xs text-[#555]">
844
+ {skill.source !== "local" && skill.source_url && (
845
+ <a href={skill.source_url} target="_blank" rel="noopener noreferrer" className="text-[#f97316] hover:underline">
846
+ View source →
847
+ </a>
848
+ )}
849
+ </div>
850
+ <div className="flex gap-3">
851
+ <button
852
+ onClick={onClose}
853
+ className="px-4 py-2 text-[#888] hover:text-white transition"
854
+ >
855
+ Close
856
+ </button>
857
+ {editing && (
858
+ <button
859
+ onClick={handleSave}
860
+ disabled={saving}
861
+ className="bg-[#f97316] hover:bg-[#fb923c] disabled:opacity-50 text-black px-4 py-2 rounded font-medium transition"
862
+ >
863
+ {saving ? "Saving..." : "Save Changes"}
864
+ </button>
865
+ )}
866
+ </div>
867
+ </div>
868
+ </div>
869
+ </div>
870
+ );
871
+ }