From 5edd49130c509424e511857870fd233ca9de5d46 Mon Sep 17 00:00:00 2001 From: Fusion Date: Sat, 18 Apr 2026 04:18:26 -0700 Subject: [PATCH] feat(FN-1913): add skill content viewer to SkillsView - Add /api/skills/:id/content support in dashboard API routes and client exports for skill content types - Extend the skills adapter with readSkillContent() to read SKILL.md and list supplementary files while handling invalid and missing skills - Update SkillsView with expandable skill detail panels, lazy content loading, and loading/error/empty states without interfering with enable toggles - Add comprehensive SkillsView, mobile CSS, and skills-adapter tests covering interaction flows and content parsing --- packages/dashboard/app/api.ts | 12 +- .../dashboard/app/components/SkillsView.tsx | 171 ++++++++++-- .../components/__tests__/SkillsView.test.tsx | 249 +++++++++++++++++- .../__tests__/skills-view-mobile.test.tsx | 37 +++ packages/dashboard/app/styles.css | 111 ++++++++ .../src/__tests__/skills-adapter.test.ts | 219 +++++++++++++++ packages/dashboard/src/index.ts | 2 +- packages/dashboard/src/routes.ts | 42 +++ packages/dashboard/src/skills-adapter.ts | 97 ++++++- 9 files changed, 915 insertions(+), 25 deletions(-) diff --git a/packages/dashboard/app/api.ts b/packages/dashboard/app/api.ts index 64d576272..fb8004ed4 100644 --- a/packages/dashboard/app/api.ts +++ b/packages/dashboard/app/api.ts @@ -63,11 +63,11 @@ import type { } from "@fusion/core"; import type { PlanningQuestion, PlanningSummary } from "@fusion/core"; import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core"; -import type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult } from "@fusion/dashboard"; +import type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult, SkillContent, SkillFileEntry } from "@fusion/dashboard"; import type { MilestoneValidationTelemetry } from "./components/mission-types"; // Re-export skills types for use by hooks and components -export type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult }; +export type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult, SkillContent, SkillFileEntry }; function looksLikeHtml(body: string): boolean { const trimmed = body.trim(); @@ -5308,6 +5308,14 @@ export async function fetchSkillsCatalog( return api(withProjectId(`/skills/catalog${suffix}`, projectId)); } +/** Fetch the contents of a skill's SKILL.md file */ +export async function fetchSkillContent(skillId: string, projectId?: string): Promise { + const response = await api<{ content: SkillContent }>( + withProjectId(`/skills/${encodeURIComponent(skillId)}/content`, projectId) + ); + return response.content; +} + // ── Chat API ───────────────────────────────────────────────────────────────── // EnrichedChatSession is imported from @fusion/core above diff --git a/packages/dashboard/app/components/SkillsView.tsx b/packages/dashboard/app/components/SkillsView.tsx index 5516c108c..67540b66e 100644 --- a/packages/dashboard/app/components/SkillsView.tsx +++ b/packages/dashboard/app/components/SkillsView.tsx @@ -1,11 +1,12 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { Wrench, RefreshCw, X } from "lucide-react"; +import { Wrench, RefreshCw, X, ChevronRight, ChevronDown, AlertCircle, Loader2 } from "lucide-react"; import { fetchDiscoveredSkills, toggleExecutionSkill, fetchSkillsCatalog, + fetchSkillContent, } from "../api"; -import type { DiscoveredSkill, CatalogEntry } from "@fusion/dashboard"; +import type { DiscoveredSkill, CatalogEntry, SkillContent } from "@fusion/dashboard"; import type { ToastType } from "../hooks/useToast"; interface SkillsViewProps { @@ -26,6 +27,12 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) { const [catalogEntries, setCatalogEntries] = useState([]); const [searchQuery, setSearchQuery] = useState(""); + // Skill content viewing state + const [selectedSkillId, setSelectedSkillId] = useState(null); + const [skillContent, setSkillContent] = useState(null); + const [isLoadingContent, setIsLoadingContent] = useState(false); + const [contentError, setContentError] = useState(null); + // Debounce timer for catalog search const debounceRef = useRef | null>(null); const [debouncedQuery, setDebouncedQuery] = useState(""); @@ -127,6 +134,43 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) { } }, [projectId, addToast]); + // Handle click on discovered skill to view content + const handleSkillClick = useCallback(async (skillId: string) => { + // If clicking the same skill that's already selected, deselect it + if (selectedSkillId === skillId) { + setSelectedSkillId(null); + setSkillContent(null); + setContentError(null); + return; + } + + // Select the new skill and fetch its content + setSelectedSkillId(skillId); + setSkillContent(null); + setContentError(null); + setIsLoadingContent(true); + + try { + const content = await fetchSkillContent(skillId, projectId); + setSkillContent(content); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to load skill content"; + setContentError(message); + } finally { + setIsLoadingContent(false); + } + }, [selectedSkillId, projectId]); + + // Handle click on skill item, but not on toggle + const handleSkillItemClick = useCallback((e: React.MouseEvent, skillId: string) => { + // Don't trigger content fetch when clicking the toggle switch + const target = e.target as HTMLElement; + if (target.closest(".skills-view-item-toggle")) { + return; + } + void handleSkillClick(skillId); + }, [handleSkillClick]); + return (
@@ -194,25 +238,112 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
) : (
- {filteredDiscoveredSkills.map((skill) => ( -
-
- {skill.name} - {skill.relativePath} - {skill.metadata.source} + {filteredDiscoveredSkills.map((skill) => { + const isSelected = selectedSkillId === skill.id; + return ( +
+
handleSkillItemClick(e, skill.id)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + void handleSkillClick(skill.id); + } + }} + aria-expanded={isSelected} + aria-label={`View details for ${skill.name}`} + > +
+ + {isSelected ? : } + {skill.name} + + {skill.relativePath} + {skill.metadata.source} +
+ +
+ + {/* Skill Content Detail Panel */} + {isSelected && ( +
+
+ {skill.name} + +
+ + {isLoadingContent ? ( +
+ + Loading skill content... +
+ ) : contentError ? ( +
+ + {contentError} + +
+ ) : skillContent ? ( + <> + {skillContent.skillMd && ( +
+
{skillContent.skillMd}
+
+ )} + {skillContent.files.length > 0 && ( +
+ Files: + {skillContent.files.map((file) => ( + + {file.name} + {file.type === "directory" && "/"} + + ))} +
+ )} + {!skillContent.skillMd && skillContent.files.length === 0 && ( +
+ No content available for this skill. +
+ )} + + ) : null} +
+ )}
- -
- ))} + ); + })}
)} diff --git a/packages/dashboard/app/components/__tests__/SkillsView.test.tsx b/packages/dashboard/app/components/__tests__/SkillsView.test.tsx index 5e56cb161..2b0c4c200 100644 --- a/packages/dashboard/app/components/__tests__/SkillsView.test.tsx +++ b/packages/dashboard/app/components/__tests__/SkillsView.test.tsx @@ -2,18 +2,20 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { SkillsView } from "../SkillsView"; import * as apiModule from "../../api"; -import type { DiscoveredSkill, CatalogEntry } from "@fusion/dashboard"; +import type { DiscoveredSkill, CatalogEntry, SkillContent } from "@fusion/dashboard"; // Mock the API module vi.mock("../../api", () => ({ fetchDiscoveredSkills: vi.fn(), toggleExecutionSkill: vi.fn(), fetchSkillsCatalog: vi.fn(), + fetchSkillContent: vi.fn(), })); const mockFetchDiscoveredSkills = vi.mocked(apiModule.fetchDiscoveredSkills); const mockToggleExecutionSkill = vi.mocked(apiModule.toggleExecutionSkill); const mockFetchSkillsCatalog = vi.mocked(apiModule.fetchSkillsCatalog); +const mockFetchSkillContent = vi.mocked(apiModule.fetchSkillContent); describe("SkillsView", () => { const mockAddToast = vi.fn(); @@ -690,4 +692,249 @@ describe("SkillsView", () => { }); }); }); + + describe("skill content viewing", () => { + const mockSkillContent: SkillContent = { + name: "test-skill", + skillMd: "# Test Skill\n\nThis is the skill content.", + files: [ + { name: "references", relativePath: "references", type: "directory" }, + { name: "workflows", relativePath: "workflows", type: "directory" }, + ], + }; + + beforeEach(() => { + mockFetchSkillContent.mockResolvedValue(mockSkillContent); + }); + + it("calls fetchSkillContent when clicking a discovered skill item", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("test-skill")).toBeTruthy(); + }); + + // Click on the test-skill item + const testSkillItem = screen.getByText("test-skill").closest(".skills-view-item"); + expect(testSkillItem).toBeTruthy(); + + await act(async () => { + fireEvent.click(testSkillItem!); + }); + + expect(mockFetchSkillContent).toHaveBeenCalledWith("npm::skills/test-skill", undefined); + }); + + it("displays skill content when loaded", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("test-skill")).toBeTruthy(); + }); + + // Click on the test-skill item + const testSkillItem = screen.getByText("test-skill").closest(".skills-view-item"); + await act(async () => { + fireEvent.click(testSkillItem!); + }); + + await waitFor(() => { + const preElement = document.querySelector(".skills-view-detail-content pre"); + expect(preElement).toBeTruthy(); + expect(preElement!.textContent).toContain("# Test Skill"); + expect(preElement!.textContent).toContain("This is the skill content."); + const fileBadges = document.querySelectorAll(".skills-view-detail-files .badge"); + expect(fileBadges.length).toBe(2); + }); + }); + + it("collapses detail when clicking the same skill again", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("test-skill")).toBeTruthy(); + }); + + // Click on the test-skill item to expand + const testSkillItem = screen.getByText("test-skill").closest(".skills-view-item"); + await act(async () => { + fireEvent.click(testSkillItem!); + }); + + await waitFor(() => { + expect(screen.getByTestId("skill-detail")).toBeTruthy(); + }); + + // Click again to collapse + await act(async () => { + fireEvent.click(testSkillItem!); + }); + + await waitFor(() => { + expect(screen.queryByTestId("skill-detail")).toBeNull(); + }); + }); + + it("does NOT trigger content fetch when clicking toggle", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("test-skill")).toBeTruthy(); + }); + + // Find the toggle for test-skill + const toggles = screen.getAllByRole("checkbox"); + const enabledToggle = toggles.find(t => (t as HTMLInputElement).checked) as HTMLInputElement; + + await act(async () => { + fireEvent.click(enabledToggle); + }); + + // Should NOT have called fetchSkillContent + expect(mockFetchSkillContent).not.toHaveBeenCalled(); + }); + + it("shows loading state while fetching content", async () => { + let resolveContent: ((value: SkillContent) => void) | undefined; + mockFetchSkillContent.mockImplementation( + () => new Promise((resolve) => { resolveContent = resolve as unknown as (value: SkillContent) => void; }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByText("test-skill")).toBeTruthy(); + }); + + // Click on the test-skill item + const testSkillItem = screen.getByText("test-skill").closest(".skills-view-item"); + await act(async () => { + fireEvent.click(testSkillItem!); + }); + + await waitFor(() => { + expect(screen.getByText("Loading skill content...")).toBeTruthy(); + }); + + // Complete the fetch + await act(async () => { + resolveContent!(mockSkillContent); + }); + + await waitFor(() => { + const preElement = document.querySelector(".skills-view-detail-content pre"); + expect(preElement).toBeTruthy(); + expect(preElement!.textContent).toContain("# Test Skill"); + }); + }); + + it("shows error state on fetch failure", async () => { + mockFetchSkillContent.mockRejectedValue(new Error("Failed to load content")); + + render(); + + await waitFor(() => { + expect(screen.getByText("test-skill")).toBeTruthy(); + }); + + // Click on the test-skill item + const testSkillItem = screen.getByText("test-skill").closest(".skills-view-item"); + await act(async () => { + fireEvent.click(testSkillItem!); + }); + + await waitFor(() => { + expect(screen.getByText("Failed to load content")).toBeTruthy(); + expect(screen.getByText("Retry")).toBeTruthy(); + }); + }); + + it("collapses detail when close button is clicked", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("test-skill")).toBeTruthy(); + }); + + // Click on the test-skill item to expand + const testSkillItem = screen.getByText("test-skill").closest(".skills-view-item"); + await act(async () => { + fireEvent.click(testSkillItem!); + }); + + await waitFor(() => { + expect(screen.getByTestId("skill-detail")).toBeTruthy(); + }); + + // Click close button + await act(async () => { + fireEvent.click(screen.getByText("Close")); + }); + + await waitFor(() => { + expect(screen.queryByTestId("skill-detail")).toBeNull(); + }); + }); + + it("selected skill item has --selected class", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("test-skill")).toBeTruthy(); + }); + + // Click on the test-skill item + const testSkillItem = screen.getByText("test-skill").closest(".skills-view-item"); + await act(async () => { + fireEvent.click(testSkillItem!); + }); + + await waitFor(() => { + const selectedItem = document.querySelector(".skills-view-item--selected"); + expect(selectedItem).toBeTruthy(); + expect(selectedItem?.textContent).toContain("test-skill"); + }); + }); + + it("renders file badges for supplementary files", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("test-skill")).toBeTruthy(); + }); + + const testSkillItem = screen.getByText("test-skill").closest(".skills-view-item"); + await act(async () => { + fireEvent.click(testSkillItem!); + }); + + await waitFor(() => { + const fileBadges = document.querySelectorAll(".skills-view-detail-files .badge"); + expect(fileBadges.length).toBe(2); + }); + }); + + it("shows empty state when skill has no content", async () => { + mockFetchSkillContent.mockResolvedValue({ + name: "empty-skill", + skillMd: "", + files: [], + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("test-skill")).toBeTruthy(); + }); + + const testSkillItem = screen.getByText("test-skill").closest(".skills-view-item"); + await act(async () => { + fireEvent.click(testSkillItem!); + }); + + await waitFor(() => { + expect(screen.getByText("No content available for this skill.")).toBeTruthy(); + }); + }); + }); }); diff --git a/packages/dashboard/app/components/__tests__/skills-view-mobile.test.tsx b/packages/dashboard/app/components/__tests__/skills-view-mobile.test.tsx index 1e346591c..45f121bdc 100644 --- a/packages/dashboard/app/components/__tests__/skills-view-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/skills-view-mobile.test.tsx @@ -193,6 +193,43 @@ describe("skills-view mobile css", () => { expect(cssContent).toMatch(/\.skills-view-content\s*\{[^}]*flex:\s*1[^}]*\}/s); expect(cssContent).toMatch(/\.skills-view-content\s*\{[^}]*padding:\s*20px[^}]*\}/s); }); + + it("defines .skills-view-detail with reduced padding on mobile", () => { + expect(mobileMediaBlock).toContain(".skills-view-detail"); + const block = extractRuleBlock(mobileMediaBlock, ".skills-view-detail"); + expect(block).toContain("padding: var(--space-md)"); + }); + + it("defines .skills-view-detail-content with smaller font on mobile", () => { + expect(mobileMediaBlock).toContain(".skills-view-detail-content"); + const block = extractRuleBlock(mobileMediaBlock, ".skills-view-detail-content"); + expect(block).toContain("font-size: 11px"); + }); + + it("defines .skills-view-detail-header with flex-wrap on mobile", () => { + expect(mobileMediaBlock).toContain(".skills-view-detail-header"); + const block = extractRuleBlock(mobileMediaBlock, ".skills-view-detail-header"); + expect(block).toContain("flex-wrap: wrap"); + }); + + it("defines .skills-view-detail-title with smaller font on mobile", () => { + expect(mobileMediaBlock).toContain(".skills-view-detail-title"); + const block = extractRuleBlock(mobileMediaBlock, ".skills-view-detail-title"); + expect(block).toContain("font-size: 13px"); + }); + + it("skill detail base styles are defined in styles.css", () => { + expect(cssContent).toContain(".skills-view-item--selected {"); + expect(cssContent).toContain(".skills-view-detail {"); + expect(cssContent).toContain(".skills-view-detail-header {"); + expect(cssContent).toContain(".skills-view-detail-title {"); + expect(cssContent).toContain(".skills-view-detail-content {"); + expect(cssContent).toContain(".skills-view-detail-files {"); + expect(cssContent).toContain(".skills-view-detail-files-label {"); + expect(cssContent).toContain(".skills-view-detail-loading {"); + expect(cssContent).toContain(".skills-view-detail-error {"); + expect(cssContent).toContain(".skills-view-detail-empty {"); + }); }); describe("SkillsView component structure", () => { diff --git a/packages/dashboard/app/styles.css b/packages/dashboard/app/styles.css index 8c3429f8a..cd770ce6e 100644 --- a/packages/dashboard/app/styles.css +++ b/packages/dashboard/app/styles.css @@ -29776,6 +29776,98 @@ html .column.drag-over * { flex-shrink: 0; } +/* === Skill Detail Panel === */ +.skills-view-item--selected { + border-color: var(--todo); + background: var(--card-hover); +} + +.skills-view-detail { + background: var(--surface); + border: 1px solid var(--border); + border-left: 3px solid var(--todo); + border-radius: var(--radius-md); + margin: var(--space-sm) 0 var(--space-md) 0; + padding: var(--space-lg); +} + +.skills-view-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + margin-bottom: var(--space-md); + padding-bottom: var(--space-md); + border-bottom: 1px solid var(--border); +} + +.skills-view-detail-title { + font-weight: 600; + font-size: 14px; + color: var(--text); +} + +.skills-view-detail-content { + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.6; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; + color: var(--text); + background: var(--bg); + padding: var(--space-md); + border-radius: var(--radius-sm); + max-height: 400px; + overflow-y: auto; +} + +.skills-view-detail-files { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-sm); + margin-top: var(--space-md); + padding-top: var(--space-md); + border-top: 1px solid var(--border); +} + +.skills-view-detail-files-label { + font-size: 12px; + color: var(--text-muted); +} + +.skills-view-detail-loading { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-md); + color: var(--text-muted); + font-size: 13px; +} + +.skills-view-detail-error { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-md); + color: var(--color-error); + font-size: 13px; + background: color-mix(in srgb, var(--color-error) 10%, transparent); + border-radius: var(--radius-sm); +} + +.skills-view-detail-error .btn { + margin-left: auto; +} + +.skills-view-detail-empty { + padding: var(--space-md); + color: var(--text-muted); + font-size: 13px; + font-style: italic; +} + /* === Node Management === */ .nodes-management-overlay { display: flex; @@ -35951,4 +36043,23 @@ html .column.drag-over * { .chat-session-delete-btn { opacity: 1; } + + /* Skill detail panel mobile overrides */ + .skills-view-detail { + padding: var(--space-md); + } + + .skills-view-detail-content { + font-size: 11px; + max-height: 300px; + } + + .skills-view-detail-header { + flex-wrap: wrap; + gap: var(--space-sm); + } + + .skills-view-detail-title { + font-size: 13px; + } } diff --git a/packages/dashboard/src/__tests__/skills-adapter.test.ts b/packages/dashboard/src/__tests__/skills-adapter.test.ts index 69374d6d7..896f6c58e 100644 --- a/packages/dashboard/src/__tests__/skills-adapter.test.ts +++ b/packages/dashboard/src/__tests__/skills-adapter.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createSkillsAdapter } from "../skills-adapter.js"; +import { writeFile, mkdir, access } from "node:fs/promises"; +import { join, dirname } from "node:path"; +import { tmpdir } from "node:os"; +import { rm } from "node:fs/promises"; describe("createSkillsAdapter - fetchCatalog fallback behavior", () => { const originalFetch = globalThis.fetch; @@ -212,3 +216,218 @@ describe("createSkillsAdapter - fetchCatalog fallback behavior", () => { } }); }); + +describe("createSkillsAdapter - readSkillContent", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + async function createMockSkillDir(skillMdContent?: string, extraFiles?: string[]) { + const skillDir = join(tmpdir(), `skill-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(skillDir, { recursive: true }); + + if (skillMdContent !== undefined) { + await writeFile(join(skillDir, "SKILL.md"), skillMdContent, "utf-8"); + } + + if (extraFiles) { + for (const file of extraFiles) { + const filePath = join(skillDir, file); + const fileDir = dirname(filePath); + if (!await access(fileDir).then(() => true).catch(() => false)) { + await mkdir(fileDir, { recursive: true }); + } + await writeFile(filePath, `content of ${file}`, "utf-8"); + } + } + + return skillDir; + } + + async function cleanup(skillDir: string) { + try { + await rm(skillDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + } + + it("returns SKILL.md content and file listing for a valid skill", async () => { + const skillDir = await createMockSkillDir( + "# Test Skill\n\nThis is a test skill.", + ["references/ref.md", "workflows/test.sh"] + ); + + const adapter = createSkillsAdapter({ + packageManager: { + resolve: vi.fn().mockResolvedValue({ skills: [] }), + }, + getSettingsPath: vi.fn().mockReturnValue("/tmp/settings.json"), + }); + + // Spy on discoverSkills to return a controlled skill + const mockDiscoveredSkill = { + id: "npm::skills/test-skill", + name: "test-skill", + path: join(skillDir, "SKILL.md"), + relativePath: "skills/test-skill", + enabled: true, + metadata: { + source: "npm", + scope: "project" as const, + origin: "top-level" as const, + baseDir: skillDir, + }, + }; + + vi.spyOn(adapter, "discoverSkills").mockResolvedValue([mockDiscoveredSkill]); + + const result = await adapter.readSkillContent("/project", "npm::skills/test-skill"); + + expect(result.name).toBe("test-skill"); + expect(result.skillMd).toBe("# Test Skill\n\nThis is a test skill."); + expect(result.files).toHaveLength(2); + expect(result.files.map((f) => f.name).sort()).toEqual(["references", "workflows"]); + expect(result.files.find((f) => f.name === "references")!.type).toBe("directory"); + expect(result.files.find((f) => f.name === "workflows")!.type).toBe("directory"); + + await cleanup(skillDir); + }); + + it("returns empty skillMd when SKILL.md doesn't exist", async () => { + const skillDir = await createMockSkillDir(undefined, ["readme.txt"]); + + const adapter = createSkillsAdapter({ + packageManager: { + resolve: vi.fn().mockResolvedValue({ skills: [] }), + }, + getSettingsPath: vi.fn().mockReturnValue("/tmp/settings.json"), + }); + + const mockDiscoveredSkill = { + id: "npm::skills/test-skill", + name: "test-skill", + path: skillDir, + relativePath: "skills/test-skill", + enabled: true, + metadata: { + source: "npm", + scope: "project" as const, + origin: "top-level" as const, + }, + }; + + vi.spyOn(adapter, "discoverSkills").mockResolvedValue([mockDiscoveredSkill]); + + const result = await adapter.readSkillContent("/project", "npm::skills/test-skill"); + + expect(result.name).toBe("test-skill"); + expect(result.skillMd).toBe(""); + expect(result.files).toHaveLength(1); + expect(result.files[0]!.name).toBe("readme.txt"); + + await cleanup(skillDir); + }); + + it("throws error for invalid skill ID format", async () => { + const adapter = createSkillsAdapter({ + packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) }, + getSettingsPath: vi.fn().mockReturnValue("/tmp/settings.json"), + }); + + await expect(adapter.readSkillContent("/project", "invalid-skill-id")).rejects.toThrow( + "Invalid skill ID format" + ); + }); + + it("throws error for non-existent skill", async () => { + const adapter = createSkillsAdapter({ + packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) }, + getSettingsPath: vi.fn().mockReturnValue("/tmp/settings.json"), + }); + + await expect(adapter.readSkillContent("/project", "npm::skills/nonexistent")).rejects.toThrow( + "Skill not found" + ); + }); + + it("filters out SKILL.md from supplementary files listing", async () => { + const skillDir = await createMockSkillDir( + "# Test Skill", + ["SKILL.md", "readme.txt"] + ); + + const adapter = createSkillsAdapter({ + packageManager: { + resolve: vi.fn().mockResolvedValue({ skills: [] }), + }, + getSettingsPath: vi.fn().mockReturnValue("/tmp/settings.json"), + }); + + const mockDiscoveredSkill = { + id: "npm::skills/test-skill", + name: "test-skill", + path: join(skillDir, "SKILL.md"), + relativePath: "skills/test-skill", + enabled: true, + metadata: { + source: "npm", + scope: "project" as const, + origin: "top-level" as const, + }, + }; + + vi.spyOn(adapter, "discoverSkills").mockResolvedValue([mockDiscoveredSkill]); + + const result = await adapter.readSkillContent("/project", "npm::skills/test-skill"); + + // Should only have readme.txt, not SKILL.md + expect(result.files).toHaveLength(1); + expect(result.files[0]!.name).toBe("readme.txt"); + + await cleanup(skillDir); + }); + + it("handles skill path that is already a directory", async () => { + const skillDir = await createMockSkillDir( + "# Test Skill", + ["readme.txt"] + ); + + const adapter = createSkillsAdapter({ + packageManager: { + resolve: vi.fn().mockResolvedValue({ skills: [] }), + }, + getSettingsPath: vi.fn().mockReturnValue("/tmp/settings.json"), + }); + + const mockDiscoveredSkill = { + id: "npm::skills/test-skill", + name: "test-skill", + path: skillDir, // Path is already a directory + relativePath: "skills/test-skill", + enabled: true, + metadata: { + source: "npm", + scope: "project" as const, + origin: "top-level" as const, + }, + }; + + vi.spyOn(adapter, "discoverSkills").mockResolvedValue([mockDiscoveredSkill]); + + const result = await adapter.readSkillContent("/project", "npm::skills/test-skill"); + + expect(result.name).toBe("test-skill"); + expect(result.skillMd).toBe("# Test Skill"); + expect(result.files).toHaveLength(1); + + await cleanup(skillDir); + }); +}); diff --git a/packages/dashboard/src/index.ts b/packages/dashboard/src/index.ts index 00262aeeb..a1c02ed44 100644 --- a/packages/dashboard/src/index.ts +++ b/packages/dashboard/src/index.ts @@ -1,5 +1,5 @@ export { createServer, loadTlsCredentialsFromEnv, type ServerOptions } from "./server.js"; -export { createSkillsAdapter, getProjectSettingsPath, type SkillsAdapter, type DiscoveredSkill, type CatalogEntry, type CatalogFetchResult, type ToggleSkillResult, type UpstreamError, type UpstreamErrorCode } from "./skills-adapter.js"; +export { createSkillsAdapter, getProjectSettingsPath, type SkillsAdapter, type DiscoveredSkill, type CatalogEntry, type CatalogFetchResult, type ToggleSkillResult, type UpstreamError, type UpstreamErrorCode, type SkillContent, type SkillFileEntry } from "./skills-adapter.js"; export { GitHubClient, isPrMergeReady, type PrMergeStatus, type PrCheckStatus, type ReviewDecision, type MergePrParams, type FindPrParams } from "./github.js"; export { rateLimit, RATE_LIMITS, type RateLimitOptions } from "./rate-limit.js"; export { GitHubPollingService, type GitHubPollingServiceOptions, type TaskWatchInput, type WatchedBadgeType } from "./github-poll.js"; diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 902686574..345c40c6f 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -17043,6 +17043,48 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout } }); + /** + * GET /api/skills/:id/content + * Read the contents of a skill's SKILL.md file and list supplementary files. + * Params: id (URL-encoded skill ID) + * Query: projectId (optional) for multi-project context + * Response: { content: SkillContent } + * Error: 404 { error: string; code: "skill_not_found" | "adapter_not_configured" } + */ + router.get("/skills/:id/content", async (req, res) => { + try { + const scopedStore = await getScopedStore(req); + const skillsAdapter = options?.skillsAdapter; + + if (!skillsAdapter) { + res.status(404).json({ error: "Skills adapter not configured", code: "adapter_not_configured" }); + return; + } + + const rootDir = scopedStore.getRootDir(); + const skillId = req.params.id as string; + + const content = await skillsAdapter.readSkillContent(rootDir, skillId); + + res.json({ content }); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + if (err instanceof Error) { + if (err.message.includes("Invalid skill ID")) { + res.status(400).json({ error: err.message, code: "invalid_skill_id" }); + return; + } + if (err.message.includes("Skill not found")) { + res.status(404).json({ error: err.message, code: "skill_not_found" }); + return; + } + } + rethrowAsApiError(err, "Failed to read skill content"); + } + }); + // ── Remote Node Proxy Routes ─────────────────────────────────────────── /** GET /api/proxy/:nodeId/health — Forward health check to remote node */ diff --git a/packages/dashboard/src/skills-adapter.ts b/packages/dashboard/src/skills-adapter.ts index 9b808b671..3638a4705 100644 --- a/packages/dashboard/src/skills-adapter.ts +++ b/packages/dashboard/src/skills-adapter.ts @@ -6,7 +6,7 @@ */ import { access } from "node:fs/promises"; -import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { readFile, writeFile, mkdir, readdir } from "node:fs/promises"; import { join, relative, dirname } from "node:path"; /** @@ -100,6 +100,24 @@ export interface ToggleSkillResult { targetFile: string; } +/** + * A file entry in a skill directory. + */ +export interface SkillFileEntry { + name: string; + relativePath: string; + type: "file" | "directory"; +} + +/** + * Content of a skill including its SKILL.md and supplementary files. + */ +export interface SkillContent { + name: string; + skillMd: string; + files: SkillFileEntry[]; +} + /** * Upstream error codes for catalog fetch failures. */ @@ -136,6 +154,11 @@ export interface SkillsAdapter { * Fetch the skills.sh catalog with optional authentication. */ fetchCatalog(input: { limit: number; query?: string }): Promise; + + /** + * Read the contents of a skill's SKILL.md file and list supplementary files. + */ + readSkillContent(rootDir: string, skillId: string): Promise; } /** @@ -454,6 +477,78 @@ export function createSkillsAdapter(options: { }; } }, + + async readSkillContent(rootDir: string, skillId: string): Promise { + // Parse the skill ID to get source and relativePath + const parsed = parseSkillId(skillId); + if (!parsed) { + throw new Error(`Invalid skill ID format: ${skillId}`); + } + + // Find the skill in discovered skills to get its path + const discovered = await this.discoverSkills(rootDir); + const skill = discovered.find((s) => s.id === skillId); + if (!skill) { + throw new Error(`Skill not found: ${skillId}`); + } + + // Determine the skill directory + // If path points to a file (e.g., SKILL.md), use dirname(path) + // If path points to a directory, use it directly + let skillDir = skill.path; + try { + const stat = await access(skill.path); + // Path exists, check if it's a file or directory + // We can't easily check with access(), so we try to read as file first + try { + await readFile(skill.path, "utf-8"); + // It's a file, get the directory + skillDir = dirname(skill.path); + } catch { + // Not a file (might be a directory or error), use path as-is + skillDir = skill.path; + } + } catch { + // Path doesn't exist, use dirname as fallback + skillDir = dirname(skill.path); + } + + // Read SKILL.md from the skill directory + const skillMdPath = join(skillDir, "SKILL.md"); + let skillMd = ""; + if (await pathExists(skillMdPath)) { + try { + skillMd = await readFile(skillMdPath, "utf-8"); + } catch { + // Ignore read errors, keep empty string + } + } + + // List files in the skill directory (non-recursive for MVP) + const files: SkillFileEntry[] = []; + try { + const entries = await readdir(skillDir, { withFileTypes: true }); + for (const entry of entries) { + // Skip SKILL.md as it's shown separately + if (entry.name === "SKILL.md") { + continue; + } + files.push({ + name: entry.name, + relativePath: entry.name, + type: entry.isDirectory() ? "directory" : "file", + }); + } + } catch { + // Ignore readdir errors, return empty files array + } + + return { + name: skill.name, + skillMd, + files, + }; + }, }; }