feat(FN-1986): improve skills content loading and mobile detail UX
- Normalize GET /api/skills/:id/content handling by decoding encoded IDs and returning consistent invalid-id and not-found responses - Update readSkillContent to use stat-based file vs directory detection, read SKILL.md robustly, and list non-SKILL.md entries - Refine SkillsView interactions to ignore toggle clicks, support explicit retry of failed content fetches, and show a SKILL.md fallback placeholder - Add/adjust dashboard route, component, and mobile CSS tests to cover content route behavior, retry flow, and touch-friendly detail panel styles
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react";
|
||||
import { Wrench, RefreshCw, X, ChevronRight, ChevronDown, AlertCircle, Loader2 } from "lucide-react";
|
||||
import {
|
||||
fetchDiscoveredSkills,
|
||||
@@ -134,21 +134,10 @@ 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);
|
||||
const loadSkillContent = useCallback(async (skillId: string) => {
|
||||
setIsLoadingContent(true);
|
||||
setContentError(null);
|
||||
setSkillContent(null);
|
||||
|
||||
try {
|
||||
const content = await fetchSkillContent(skillId, projectId);
|
||||
@@ -159,17 +148,34 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
|
||||
} finally {
|
||||
setIsLoadingContent(false);
|
||||
}
|
||||
}, [selectedSkillId, projectId]);
|
||||
}, [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")) {
|
||||
// Handle click on discovered skill to view content
|
||||
const handleSkillClick = useCallback((skillId: string, event?: MouseEvent<HTMLElement>) => {
|
||||
if (event) {
|
||||
const target = event.target as Element;
|
||||
if (target.closest(".skills-view-item-toggle")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedSkillId === skillId) {
|
||||
setSelectedSkillId(null);
|
||||
setSkillContent(null);
|
||||
setContentError(null);
|
||||
return;
|
||||
}
|
||||
void handleSkillClick(skillId);
|
||||
}, [handleSkillClick]);
|
||||
|
||||
setSelectedSkillId(skillId);
|
||||
void loadSkillContent(skillId);
|
||||
}, [selectedSkillId, loadSkillContent]);
|
||||
|
||||
const handleRetrySkillContent = useCallback((skillId: string) => {
|
||||
if (selectedSkillId !== skillId) {
|
||||
setSelectedSkillId(skillId);
|
||||
}
|
||||
void loadSkillContent(skillId);
|
||||
}, [loadSkillContent, selectedSkillId]);
|
||||
|
||||
|
||||
return (
|
||||
@@ -244,13 +250,13 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
|
||||
<div key={skill.id}>
|
||||
<div
|
||||
className={`skills-view-item${isSelected ? " skills-view-item--selected" : ""}`}
|
||||
onClick={(e) => handleSkillItemClick(e, skill.id)}
|
||||
onClick={(event) => handleSkillClick(skill.id, event)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
void handleSkillClick(skill.id);
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
handleSkillClick(skill.id);
|
||||
}
|
||||
}}
|
||||
aria-expanded={isSelected}
|
||||
@@ -285,7 +291,7 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
|
||||
<div className="skills-view-detail-header">
|
||||
<span className="skills-view-detail-title">{skill.name}</span>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
className="btn btn-sm skills-view-detail-close"
|
||||
onClick={() => {
|
||||
setSelectedSkillId(null);
|
||||
setSkillContent(null);
|
||||
@@ -309,18 +315,16 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
|
||||
<span>{contentError}</span>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => void handleSkillClick(skill.id)}
|
||||
onClick={() => handleRetrySkillContent(skill.id)}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : skillContent ? (
|
||||
<>
|
||||
{skillContent.skillMd && (
|
||||
<div className="skills-view-detail-content">
|
||||
<pre>{skillContent.skillMd}</pre>
|
||||
</div>
|
||||
)}
|
||||
<pre className="skills-view-detail-content">
|
||||
{skillContent.skillMd || "(No SKILL.md found)"}
|
||||
</pre>
|
||||
{skillContent.files.length > 0 && (
|
||||
<div className="skills-view-detail-files">
|
||||
<span className="skills-view-detail-files-label">Files:</span>
|
||||
@@ -332,11 +336,6 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!skillContent.skillMd && skillContent.files.length === 0 && (
|
||||
<div className="skills-view-detail-empty">
|
||||
No content available for this skill.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -739,7 +739,7 @@ describe("SkillsView", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const preElement = document.querySelector(".skills-view-detail-content pre");
|
||||
const preElement = document.querySelector(".skills-view-detail-content");
|
||||
expect(preElement).toBeTruthy();
|
||||
expect(preElement!.textContent).toContain("# Test Skill");
|
||||
expect(preElement!.textContent).toContain("This is the skill content.");
|
||||
@@ -822,7 +822,7 @@ describe("SkillsView", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const preElement = document.querySelector(".skills-view-detail-content pre");
|
||||
const preElement = document.querySelector(".skills-view-detail-content");
|
||||
expect(preElement).toBeTruthy();
|
||||
expect(preElement!.textContent).toContain("# Test Skill");
|
||||
});
|
||||
@@ -849,6 +849,39 @@ describe("SkillsView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("retries skill content fetch when retry button is clicked", async () => {
|
||||
mockFetchSkillContent
|
||||
.mockRejectedValueOnce(new Error("Failed to load content"))
|
||||
.mockResolvedValueOnce(mockSkillContent);
|
||||
|
||||
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
|
||||
|
||||
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("Retry")).toBeTruthy();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Retry"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const preElement = document.querySelector(".skills-view-detail-content");
|
||||
expect(preElement).toBeTruthy();
|
||||
expect(preElement!.textContent).toContain("# Test Skill");
|
||||
});
|
||||
|
||||
expect(mockFetchSkillContent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("collapses detail when close button is clicked", async () => {
|
||||
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
|
||||
|
||||
@@ -914,7 +947,7 @@ describe("SkillsView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows empty state when skill has no content", async () => {
|
||||
it("shows SKILL.md fallback text when skill content is empty", async () => {
|
||||
mockFetchSkillContent.mockResolvedValue({
|
||||
name: "empty-skill",
|
||||
skillMd: "",
|
||||
@@ -933,7 +966,7 @@ describe("SkillsView", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No content available for this skill.")).toBeTruthy();
|
||||
expect(screen.getByText("(No SKILL.md found)")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,11 +7,17 @@ import { resolve } from "path";
|
||||
const mockFetchDiscoveredSkills = vi.fn().mockResolvedValue([]);
|
||||
const mockFetchSkillsCatalog = vi.fn().mockResolvedValue({ entries: [] });
|
||||
const mockToggleExecutionSkill = vi.fn().mockResolvedValue(undefined);
|
||||
const mockFetchSkillContent = vi.fn().mockResolvedValue({
|
||||
name: "test-skill",
|
||||
skillMd: "",
|
||||
files: [],
|
||||
});
|
||||
|
||||
vi.mock("../../../api", () => ({
|
||||
vi.mock("../../api", () => ({
|
||||
fetchDiscoveredSkills: (...args: unknown[]) => mockFetchDiscoveredSkills(...args),
|
||||
fetchSkillsCatalog: (...args: unknown[]) => mockFetchSkillsCatalog(...args),
|
||||
toggleExecutionSkill: (...args: unknown[]) => mockToggleExecutionSkill(...args),
|
||||
fetchSkillContent: (...args: unknown[]) => mockFetchSkillContent(...args),
|
||||
}));
|
||||
|
||||
function extractRuleBlock(css: string, selector: string): string {
|
||||
@@ -200,22 +206,32 @@ describe("skills-view mobile css", () => {
|
||||
expect(block).toContain("padding: var(--space-md)");
|
||||
});
|
||||
|
||||
it("defines .skills-view-detail-content with smaller font on mobile", () => {
|
||||
it("defines .skills-view-detail-content viewport max-height on mobile", () => {
|
||||
expect(mobileMediaBlock).toContain(".skills-view-detail-content");
|
||||
const block = extractRuleBlock(mobileMediaBlock, ".skills-view-detail-content");
|
||||
expect(block).toContain("font-size: 11px");
|
||||
expect(block).toContain("max-height: calc(60dvh - 200px)");
|
||||
});
|
||||
|
||||
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-content momentum scrolling on mobile", () => {
|
||||
const block = extractRuleBlock(mobileMediaBlock, ".skills-view-detail-content");
|
||||
expect(block).toContain("-webkit-overflow-scrolling: touch");
|
||||
});
|
||||
|
||||
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("defines .skills-view-detail-close with minimum touch target on mobile", () => {
|
||||
const block = extractRuleBlock(mobileMediaBlock, ".skills-view-detail-close");
|
||||
expect(block).toContain("min-height: 36px");
|
||||
expect(block).toContain("min-width: 36px");
|
||||
});
|
||||
|
||||
it("defines .skills-view-item--selected in mobile media block", () => {
|
||||
const block = extractRuleBlock(mobileMediaBlock, ".skills-view-item--selected");
|
||||
expect(block).toContain("border-color: var(--todo)");
|
||||
});
|
||||
|
||||
it("defines .skills-view-item with min-height on mobile", () => {
|
||||
const block = extractRuleBlock(mobileMediaBlock, ".skills-view-item");
|
||||
expect(block).toContain("min-height: 36px");
|
||||
});
|
||||
|
||||
it("skill detail base styles are defined in styles.css", () => {
|
||||
|
||||
@@ -27621,6 +27621,7 @@ html .column.drag-over * {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md);
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.skills-view-item-info {
|
||||
@@ -27660,6 +27661,26 @@ html .column.drag-over * {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.skills-view-item--selected {
|
||||
border-color: var(--todo);
|
||||
}
|
||||
|
||||
.skills-view-detail {
|
||||
padding: var(--space-md);
|
||||
border-left-width: 2px;
|
||||
}
|
||||
|
||||
.skills-view-detail-content {
|
||||
font-size: 11px;
|
||||
max-height: calc(60dvh - 200px);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.skills-view-detail-close {
|
||||
min-height: 36px;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.agent-tree__indent--1 { padding-left: 16px; }
|
||||
.agent-tree__indent--2 { padding-left: 32px; }
|
||||
.agent-tree__indent--3 { padding-left: 48px; }
|
||||
@@ -29658,6 +29679,7 @@ html .column.drag-over * {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
@@ -29876,12 +29898,19 @@ html .column.drag-over * {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skills-view-detail-close {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skills-view-detail-content {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
@@ -29897,9 +29926,9 @@ html .column.drag-over * {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
gap: var(--space-xs);
|
||||
margin-top: var(--space-md);
|
||||
padding-top: var(--space-md);
|
||||
padding-top: var(--space-sm);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@@ -29919,6 +29948,7 @@ html .column.drag-over * {
|
||||
|
||||
.skills-view-detail-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md);
|
||||
@@ -29926,10 +29956,7 @@ html .column.drag-over * {
|
||||
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;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.skills-view-detail-empty {
|
||||
@@ -36491,24 +36518,6 @@ html .column.drag-over * {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/* === MemoryView === */
|
||||
|
||||
@@ -130,6 +130,23 @@ function createMockSkillsAdapter(overrides?: Partial<SkillsAdapter>): SkillsAdap
|
||||
fallbackUsed: false,
|
||||
},
|
||||
}),
|
||||
readSkillContent: vi.fn().mockImplementation(async (_rootDir: string, skillId: string) => {
|
||||
if (skillId === "npm::skills/nonexistent") {
|
||||
throw new Error(`Skill not found: ${skillId}`);
|
||||
}
|
||||
if (skillId === "invalid") {
|
||||
throw new Error(`Invalid skill ID format: ${skillId}`);
|
||||
}
|
||||
|
||||
return {
|
||||
name: "example/SKILL.md",
|
||||
skillMd: "# Example Skill\n\nDetails here.",
|
||||
files: [
|
||||
{ name: "references", relativePath: "references", type: "directory" as const },
|
||||
{ name: "notes.txt", relativePath: "notes.txt", type: "file" as const },
|
||||
],
|
||||
};
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -200,6 +217,78 @@ describe("Skills routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/skills/:id/content", () => {
|
||||
it("returns skill content for a valid skill ID", async () => {
|
||||
const mockAdapter = createMockSkillsAdapter();
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as any, { skillsAdapter: mockAdapter as SkillsAdapter });
|
||||
|
||||
const skillId = "npm%253A%2540example%252Fskill%3A%3Askills%2Fexample%2FSKILL.md";
|
||||
const res = await request(app, "GET", `/api/skills/${skillId}/content`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
content: {
|
||||
name: "example/SKILL.md",
|
||||
skillMd: "# Example Skill\n\nDetails here.",
|
||||
files: [
|
||||
{ name: "references", relativePath: "references", type: "directory" },
|
||||
{ name: "notes.txt", relativePath: "notes.txt", type: "file" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(mockAdapter.readSkillContent).toHaveBeenCalledWith(
|
||||
"/tmp/fn-skills",
|
||||
"npm:@example/skill::skills/example/SKILL.md",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 404 when skills adapter is not configured", async () => {
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as any, {});
|
||||
|
||||
const res = await request(app, "GET", "/api/skills/npm%253A%2540example%252Fskill%3A%3Askills%2Fexample%2FSKILL.md/content");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({
|
||||
error: "Skills adapter not configured",
|
||||
code: "adapter_not_configured",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 404 for non-existent skill", async () => {
|
||||
const mockAdapter = createMockSkillsAdapter({
|
||||
readSkillContent: vi.fn().mockRejectedValue(new Error("Skill not found: npm::skills/nonexistent")),
|
||||
});
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as any, { skillsAdapter: mockAdapter as SkillsAdapter });
|
||||
|
||||
const res = await request(app, "GET", "/api/skills/npm%253A%253Askills%252Fnonexistent/content");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({
|
||||
error: "Skill not found",
|
||||
code: "skill_not_found",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 400 for invalid skill IDs", async () => {
|
||||
const mockAdapter = createMockSkillsAdapter({
|
||||
readSkillContent: vi.fn().mockRejectedValue(new Error("Invalid skill ID format: invalid")),
|
||||
});
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as any, { skillsAdapter: mockAdapter as SkillsAdapter });
|
||||
|
||||
const res = await request(app, "GET", "/api/skills/invalid/content");
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({
|
||||
error: "Invalid skill ID format: invalid",
|
||||
code: "invalid_skill_id",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/skills/execution", () => {
|
||||
it("toggles skill execution successfully", async () => {
|
||||
const mockAdapter = createMockSkillsAdapter();
|
||||
|
||||
@@ -17332,6 +17332,53 @@ 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 encodedSkillId = req.params.id as string;
|
||||
let skillId = encodedSkillId;
|
||||
try {
|
||||
skillId = decodeURIComponent(encodedSkillId);
|
||||
} catch {
|
||||
res.status(400).json({ error: "Invalid skill ID", code: "invalid_skill_id" });
|
||||
return;
|
||||
}
|
||||
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const content = await skillsAdapter.readSkillContent(rootDir, skillId);
|
||||
|
||||
res.json({ content });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if (err instanceof Error && err.message.includes("Skill not found")) {
|
||||
res.status(404).json({ error: "Skill not found", code: "skill_not_found" });
|
||||
return;
|
||||
}
|
||||
if (err instanceof Error && err.message.includes("Invalid skill ID")) {
|
||||
res.status(400).json({ error: err.message, code: "invalid_skill_id" });
|
||||
return;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to read skill content");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/skills/execution
|
||||
* Toggle a skill's enabled/disabled state.
|
||||
@@ -17431,48 +17478,6 @@ 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 */
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
* by integrating with the pi-coding-agent package manager and skills.sh API.
|
||||
*/
|
||||
|
||||
import { access } from "node:fs/promises";
|
||||
import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
|
||||
import { access, readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises";
|
||||
import { join, relative, dirname } from "node:path";
|
||||
|
||||
/**
|
||||
@@ -479,69 +478,44 @@ export function createSkillsAdapter(options: {
|
||||
},
|
||||
|
||||
async readSkillContent(rootDir: string, skillId: string): Promise<SkillContent> {
|
||||
// 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);
|
||||
const skill = discovered.find((entry) => entry.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;
|
||||
}
|
||||
const skillPathStat = await stat(skill.path);
|
||||
skillDir = skillPathStat.isFile() ? dirname(skill.path) : 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
|
||||
try {
|
||||
skillMd = await readFile(skillMdPath, "utf-8");
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
const entries = await readdir(skillDir, { withFileTypes: true }).catch(() => []);
|
||||
const files: SkillFileEntry[] = entries
|
||||
.filter((entry) => entry.name !== "SKILL.md")
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
relativePath: entry.name,
|
||||
type: entry.isDirectory() ? "directory" : "file",
|
||||
}));
|
||||
|
||||
return {
|
||||
name: skill.name,
|
||||
|
||||
Reference in New Issue
Block a user