From 163dfb5474ab0b6dceae5b3eda562602ddfd24b8 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Thu, 11 Jun 2026 12:48:05 +0100 Subject: [PATCH 1/3] feat(skills): render full skill directories Skills become directories in the UI, not a single markdown file: - SkillsService.getSkillContents(skillPath) returns the skill's file tree (relative paths + sizes); readSkillFile(skillPath, relPath) returns file contents. Both validate the target resolves to a skill directory directly under a discovery root, so the endpoints cannot be used for arbitrary filesystem reads. Symlinked files are skipped. - skills.contents / skills.readFile host-router queries (one-line forwards with Zod output schemas). - SkillInfo gains a derived editable flag (user/repo true, bundled/marketplace false), computed server-side. - SkillDetailPanel shows a file tree for multi-file skills; SKILL.md renders as markdown (frontmatter stripped) as before, other files open in read-only CodeMirror. Non-editable skills get a lock badge. - Unit tests cover discovery, the file walker (symlink skipping, file cap) and ../ traversal rejection on both new endpoints. Generated-By: PostHog Code Task-Id: f4e84f1a-19c9-490c-9b98-47787a7dddcf --- .../host-router/src/routers/skills.router.ts | 24 ++- packages/shared/src/index.ts | 2 +- packages/shared/src/skills.ts | 8 + .../src/features/skills/SkillDetailPanel.tsx | 99 ++++++++---- .../ui/src/features/skills/SkillFileTree.tsx | 91 +++++++++++ .../ui/src/features/skills/SkillsView.tsx | 1 + .../src/features/skills/useSkillContents.ts | 19 +++ .../src/services/skills/schemas.ts | 24 +++ .../services/skills/skill-discovery.test.ts | 68 +++++++- .../src/services/skills/skill-discovery.ts | 42 ++++- .../src/services/skills/skills.test.ts | 147 ++++++++++++++++++ .../src/services/skills/skills.ts | 98 +++++++++--- 12 files changed, 572 insertions(+), 51 deletions(-) create mode 100644 packages/ui/src/features/skills/SkillFileTree.tsx create mode 100644 packages/ui/src/features/skills/useSkillContents.ts create mode 100644 packages/workspace-server/src/services/skills/skills.test.ts diff --git a/packages/host-router/src/routers/skills.router.ts b/packages/host-router/src/routers/skills.router.ts index c5bd7e55af..1c50301a16 100644 --- a/packages/host-router/src/routers/skills.router.ts +++ b/packages/host-router/src/routers/skills.router.ts @@ -1,6 +1,12 @@ import { publicProcedure, router } from "@posthog/host-trpc/trpc"; import { SKILLS_SERVICE } from "@posthog/workspace-server/services/skills/identifiers"; -import { listSkillsOutput } from "@posthog/workspace-server/services/skills/schemas"; +import { + listSkillsOutput, + readSkillFileInput, + readSkillFileOutput, + skillContentsInput, + skillContentsOutput, +} from "@posthog/workspace-server/services/skills/schemas"; import type { SkillsService } from "@posthog/workspace-server/services/skills/skills"; export const skillsRouter = router({ @@ -9,4 +15,20 @@ export const skillsRouter = router({ .query(({ ctx }) => ctx.container.get(SKILLS_SERVICE).listSkills(), ), + contents: publicProcedure + .input(skillContentsInput) + .output(skillContentsOutput) + .query(({ ctx, input }) => + ctx.container + .get(SKILLS_SERVICE) + .getSkillContents(input.skillPath), + ), + readFile: publicProcedure + .input(readSkillFileInput) + .output(readSkillFileOutput) + .query(({ ctx, input }) => + ctx.container + .get(SKILLS_SERVICE) + .readSkillFile(input.skillPath, input.filePath), + ), }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 55fb5bedf9..45a3bac823 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -159,7 +159,7 @@ export type { SignalReportOrderingField, SignalReportStatus, } from "./signal-types"; -export type { SkillInfo, SkillSource } from "./skills"; +export type { SkillFileEntry, SkillInfo, SkillSource } from "./skills"; export type { ArtifactType, PostHogAPIConfig, diff --git a/packages/shared/src/skills.ts b/packages/shared/src/skills.ts index 3d4a300e05..23b5be5d08 100644 --- a/packages/shared/src/skills.ts +++ b/packages/shared/src/skills.ts @@ -6,4 +6,12 @@ export interface SkillInfo { source: SkillSource; path: string; repoName?: string; + /** Whether the skill lives in a directory we own on the user's behalf. */ + editable: boolean; +} + +export interface SkillFileEntry { + /** Path relative to the skill directory, using "/" separators. */ + path: string; + size: number; } diff --git a/packages/ui/src/features/skills/SkillDetailPanel.tsx b/packages/ui/src/features/skills/SkillDetailPanel.tsx index e82042bea6..275f2422cb 100644 --- a/packages/ui/src/features/skills/SkillDetailPanel.tsx +++ b/packages/ui/src/features/skills/SkillDetailPanel.tsx @@ -1,10 +1,13 @@ -import { Folder, X } from "@phosphor-icons/react"; +import { Folder, LockSimple, X } from "@phosphor-icons/react"; import type { SkillInfo } from "@posthog/shared"; +import { CodeMirrorEditor } from "@posthog/ui/features/code-editor/components/CodeMirrorEditor"; import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; import { ExternalAppsOpener } from "@posthog/ui/features/task-detail/components/ExternalAppsOpener"; import { Badge, Box, Flex, ScrollArea, Text } from "@radix-ui/themes"; -import { useAbsoluteFileContent } from "../code-editor/hooks/useFileContent"; +import { useState } from "react"; import { SOURCE_CONFIG } from "./SkillCard"; +import { SkillFileTree } from "./SkillFileTree"; +import { useSkillContents, useSkillFile } from "./useSkillContents"; function stripFrontmatter(content: string): string { const match = content.match(/^---\s*\n[\s\S]*?\n---\s*\n/); @@ -19,13 +22,16 @@ interface SkillDetailPanelProps { export function SkillDetailPanel({ skill, onClose }: SkillDetailPanelProps) { const config = SOURCE_CONFIG[skill.source]; - const skillMdPath = `${skill.path}/SKILL.md`; - const { data: fileContent, isLoading } = useAbsoluteFileContent( - skillMdPath, - true, + const [selectedFile, setSelectedFile] = useState("SKILL.md"); + const { data: contents } = useSkillContents(skill.path); + const { data: fileContent, isLoading } = useSkillFile( + skill.path, + selectedFile, ); - const body = fileContent ? stripFrontmatter(fileContent) : null; + const files = contents?.files ?? []; + const isSkillMd = selectedFile === "SKILL.md"; + const body = isSkillMd && fileContent ? stripFrontmatter(fileContent) : null; return ( <> @@ -59,37 +65,74 @@ export function SkillDetailPanel({ skill, onClose }: SkillDetailPanelProps) { {skill.repoName} )} + {!skill.editable && ( + + + Read-only + + )} {skill.source !== "bundled" && ( )} - - - {skill.description && ( - - {skill.description} - - )} + {files.length > 1 && ( + + + + )} + + + {isSkillMd ? ( + + + {skill.description && ( + + {skill.description} + + )} - {isLoading ? ( + {isLoading ? ( + Loading... + ) : body ? ( + + + + ) : ( + + No content in SKILL.md + + )} + + + ) : isLoading ? ( + Loading... - ) : body ? ( - - - - ) : ( + + ) : fileContent != null ? ( + + ) : ( + - No content in SKILL.md + Unable to display this file - )} - - + + )} + ); } diff --git a/packages/ui/src/features/skills/SkillFileTree.tsx b/packages/ui/src/features/skills/SkillFileTree.tsx new file mode 100644 index 0000000000..d5b6b35099 --- /dev/null +++ b/packages/ui/src/features/skills/SkillFileTree.tsx @@ -0,0 +1,91 @@ +import type { SkillFileEntry } from "@posthog/shared"; +import { + TreeDirectoryRow, + TreeFileRow, +} from "@posthog/ui/primitives/TreeDirectoryRow"; +import { Flex } from "@radix-ui/themes"; +import { useMemo, useState } from "react"; + +interface TreeDir { + name: string; + path: string; + dirs: TreeDir[]; + files: { name: string; path: string }[]; +} + +function buildTree(files: SkillFileEntry[]): TreeDir { + const root: TreeDir = { name: "", path: "", dirs: [], files: [] }; + for (const file of files) { + const parts = file.path.split("/"); + let node = root; + for (let i = 0; i < parts.length - 1; i++) { + const dirPath = parts.slice(0, i + 1).join("/"); + let child = node.dirs.find((d) => d.path === dirPath); + if (!child) { + child = { name: parts[i] ?? "", path: dirPath, dirs: [], files: [] }; + node.dirs.push(child); + } + node = child; + } + node.files.push({ name: parts[parts.length - 1] ?? "", path: file.path }); + } + return root; +} + +interface SkillFileTreeProps { + files: SkillFileEntry[]; + selectedPath: string | null; + onSelect: (path: string) => void; +} + +export function SkillFileTree({ + files, + selectedPath, + onSelect, +}: SkillFileTreeProps) { + const tree = useMemo(() => buildTree(files), [files]); + const [collapsed, setCollapsed] = useState>(new Set()); + + const toggleDir = (dirPath: string) => { + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(dirPath)) { + next.delete(dirPath); + } else { + next.add(dirPath); + } + return next; + }); + }; + + const renderDir = (dir: TreeDir, depth: number): React.ReactNode => ( + + {dir.dirs.map((child) => { + const isExpanded = !collapsed.has(child.path); + return ( + + toggleDir(child.path)} + /> + {isExpanded && renderDir(child, depth + 1)} + + ); + })} + {dir.files.map((file) => ( + onSelect(file.path)} + /> + ))} + + ); + + return renderDir(tree, 0); +} diff --git a/packages/ui/src/features/skills/SkillsView.tsx b/packages/ui/src/features/skills/SkillsView.tsx index 04e7cce3ae..969688b638 100644 --- a/packages/ui/src/features/skills/SkillsView.tsx +++ b/packages/ui/src/features/skills/SkillsView.tsx @@ -146,6 +146,7 @@ export function SkillsView() { > {selectedSkill && ( diff --git a/packages/ui/src/features/skills/useSkillContents.ts b/packages/ui/src/features/skills/useSkillContents.ts new file mode 100644 index 0000000000..09418f5411 --- /dev/null +++ b/packages/ui/src/features/skills/useSkillContents.ts @@ -0,0 +1,19 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import { useQuery } from "@tanstack/react-query"; + +export function useSkillContents(skillPath: string) { + const trpc = useHostTRPC(); + return useQuery( + trpc.skills.contents.queryOptions({ skillPath }, { staleTime: 30_000 }), + ); +} + +export function useSkillFile(skillPath: string, filePath: string | null) { + const trpc = useHostTRPC(); + return useQuery( + trpc.skills.readFile.queryOptions( + { skillPath, filePath: filePath ?? "" }, + { enabled: filePath !== null, staleTime: 30_000 }, + ), + ); +} diff --git a/packages/workspace-server/src/services/skills/schemas.ts b/packages/workspace-server/src/services/skills/schemas.ts index 76f459c7a7..615fc80442 100644 --- a/packages/workspace-server/src/services/skills/schemas.ts +++ b/packages/workspace-server/src/services/skills/schemas.ts @@ -8,9 +8,33 @@ export const skillInfo = z.object({ source: skillSource, path: z.string(), repoName: z.string().optional(), + editable: z.boolean(), }); export const listSkillsOutput = z.array(skillInfo); +export const skillFileEntry = z.object({ + // Path relative to the skill directory, using "/" separators. + path: z.string(), + size: z.number(), +}); + +export const skillContentsInput = z.object({ + skillPath: z.string(), +}); + +export const skillContentsOutput = z.object({ + files: z.array(skillFileEntry), +}); + +export const readSkillFileInput = z.object({ + skillPath: z.string(), + filePath: z.string(), +}); + +export const readSkillFileOutput = z.string().nullable(); + export type SkillInfo = z.infer; export type SkillSource = z.infer; +export type SkillFileEntry = z.infer; +export type SkillContents = z.infer; diff --git a/packages/workspace-server/src/services/skills/skill-discovery.test.ts b/packages/workspace-server/src/services/skills/skill-discovery.test.ts index 0b52eda4d7..ec267020e1 100644 --- a/packages/workspace-server/src/services/skills/skill-discovery.test.ts +++ b/packages/workspace-server/src/services/skills/skill-discovery.test.ts @@ -2,7 +2,11 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { findSkillDirs, readSkillMetadataFromDir } from "./skill-discovery"; +import { + findSkillDirs, + listSkillFiles, + readSkillMetadataFromDir, +} from "./skill-discovery"; let root: string; @@ -64,10 +68,25 @@ describe("readSkillMetadataFromDir", () => { source: "repo", path: path.join(skillsDir, "my-skill"), repoName: "my-repo", + editable: true, }, ]); }); + it.each([ + ["user", true], + ["repo", true], + ["bundled", false], + ["marketplace", false], + ] as const)("marks %s skills editable=%s", async (source, editable) => { + const skillsDir = path.join(root, "skills"); + await createSkill(skillsDir, "a-skill"); + + const result = await readSkillMetadataFromDir(skillsDir, source); + + expect(result[0]?.editable).toBe(editable); + }); + it("falls back to the directory name when frontmatter is absent", async () => { const skillsDir = path.join(root, "skills"); await createSkill(skillsDir, "bare-skill", "no frontmatter here"); @@ -83,3 +102,50 @@ describe("readSkillMetadataFromDir", () => { expect(result[0]).not.toHaveProperty("repoName"); }); }); + +describe("listSkillFiles", () => { + it("walks nested directories and reports sizes", async () => { + const skillsDir = path.join(root, "skills"); + await createSkill(skillsDir, "alpha", "hello"); + const skillPath = path.join(skillsDir, "alpha"); + await mkdir(path.join(skillPath, "references", "deep"), { + recursive: true, + }); + await writeFile(path.join(skillPath, "references", "deep", "x.md"), "xx"); + + const files = await listSkillFiles(skillPath, 500); + + expect(files).toEqual([ + { path: "SKILL.md", size: 5 }, + { path: "references/deep/x.md", size: 2 }, + ]); + }); + + it("skips symlinks", async () => { + const skillsDir = path.join(root, "skills"); + await createSkill(skillsDir, "alpha"); + const skillPath = path.join(skillsDir, "alpha"); + const { symlink } = await import("node:fs/promises"); + await writeFile(path.join(root, "outside.txt"), "outside"); + await symlink( + path.join(root, "outside.txt"), + path.join(skillPath, "link.txt"), + ); + + const files = await listSkillFiles(skillPath, 500); + + expect(files.map((f) => f.path)).toEqual(["SKILL.md"]); + }); + + it("stops at the file cap", async () => { + const skillsDir = path.join(root, "skills"); + await createSkill(skillsDir, "alpha"); + const skillPath = path.join(skillsDir, "alpha"); + await writeFile(path.join(skillPath, "a.txt"), "a"); + await writeFile(path.join(skillPath, "b.txt"), "b"); + + const files = await listSkillFiles(skillPath, 2); + + expect(files).toHaveLength(2); + }); +}); diff --git a/packages/workspace-server/src/services/skills/skill-discovery.ts b/packages/workspace-server/src/services/skills/skill-discovery.ts index fbe1b43618..92421044e3 100644 --- a/packages/workspace-server/src/services/skills/skill-discovery.ts +++ b/packages/workspace-server/src/services/skills/skill-discovery.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { parseSkillFrontmatter } from "./parse-skill-frontmatter"; -import type { SkillInfo, SkillSource } from "./schemas"; +import type { SkillFileEntry, SkillInfo, SkillSource } from "./schemas"; interface InstalledPluginEntry { scope: string; @@ -15,6 +15,11 @@ interface InstalledPluginsFile { plugins: Record; } +/** Sources whose directories we own on the user's behalf and may mutate. */ +export function isEditableSource(source: SkillSource): boolean { + return source === "user" || source === "repo"; +} + export async function findSkillDirs( sourceSkillsDir: string, ): Promise { @@ -68,6 +73,40 @@ export async function getMarketplaceInstallPaths(): Promise { } } +/** + * Recursively lists regular files inside a skill directory. Symlinks are + * skipped so a crafted skill cannot expose files outside its directory. + */ +export async function listSkillFiles( + skillDir: string, + maxFiles: number, +): Promise { + const files: SkillFileEntry[] = []; + + const walk = async (dir: string, prefix: string): Promise => { + const entries = await fs.promises.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (files.length >= maxFiles) return; + const relPath = prefix ? `${prefix}/${entry.name}` : entry.name; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + await walk(fullPath, relPath); + } else if (entry.isFile()) { + const stat = await fs.promises.stat(fullPath); + files.push({ path: relPath, size: stat.size }); + } + } + }; + + await walk(skillDir, ""); + return files.sort((a, b) => { + // The manifest always leads; everything else is alphabetical. + if (a.path === "SKILL.md") return -1; + if (b.path === "SKILL.md") return 1; + return a.path.localeCompare(b.path); + }); +} + export async function readSkillMetadataFromDir( skillsDir: string, source: SkillSource, @@ -91,6 +130,7 @@ export async function readSkillMetadataFromDir( source, path: skillPath, ...(repoName ? { repoName } : {}), + editable: isEditableSource(source), } satisfies SkillInfo; } catch { return null; diff --git a/packages/workspace-server/src/services/skills/skills.test.ts b/packages/workspace-server/src/services/skills/skills.test.ts new file mode 100644 index 0000000000..53cceb46b7 --- /dev/null +++ b/packages/workspace-server/src/services/skills/skills.test.ts @@ -0,0 +1,147 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { FoldersService } from "../folders/folders"; +import type { PosthogPluginService } from "../posthog-plugin/posthog-plugin"; +import { SkillsService } from "./skills"; + +let root: string; +let pluginPath: string; +let folderPath: string; +let repoSkillsDir: string; + +function makeService(): SkillsService { + const plugin = { + getPluginPath: () => pluginPath, + } as unknown as PosthogPluginService; + const folders = { + getFolders: async () => [{ path: folderPath, name: "my-repo" }], + } as unknown as FoldersService; + return new SkillsService(plugin, folders); +} + +async function createSkill( + skillsDir: string, + name: string, + content = `---\nname: ${name}\ndescription: about ${name}\n---\nbody`, +): Promise { + const skillPath = path.join(skillsDir, name); + await mkdir(skillPath, { recursive: true }); + await writeFile(path.join(skillPath, "SKILL.md"), content); + return skillPath; +} + +beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), "skills-service-test-")); + pluginPath = path.join(root, "plugin"); + folderPath = path.join(root, "repo"); + repoSkillsDir = path.join(folderPath, ".claude", "skills"); + await mkdir(path.join(pluginPath, "skills"), { recursive: true }); + await mkdir(repoSkillsDir, { recursive: true }); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +describe("listSkills", () => { + it("marks repo skills editable and bundled skills read-only", async () => { + await createSkill(repoSkillsDir, "repo-skill"); + await createSkill(path.join(pluginPath, "skills"), "bundled-skill"); + + const skills = await makeService().listSkills(); + + const repoSkill = skills.find((s) => s.name === "repo-skill"); + const bundledSkill = skills.find((s) => s.name === "bundled-skill"); + expect(repoSkill?.editable).toBe(true); + expect(bundledSkill?.editable).toBe(false); + }); +}); + +describe("getSkillContents", () => { + it("lists every file in the skill directory with relative paths", async () => { + const skillPath = await createSkill(repoSkillsDir, "alpha"); + await mkdir(path.join(skillPath, "references"), { recursive: true }); + await writeFile(path.join(skillPath, "references", "guide.md"), "guide"); + await mkdir(path.join(skillPath, "scripts"), { recursive: true }); + await writeFile(path.join(skillPath, "scripts", "run.sh"), "echo hi"); + + const contents = await makeService().getSkillContents(skillPath); + + expect(contents.files.map((f) => f.path)).toEqual([ + "SKILL.md", + "references/guide.md", + "scripts/run.sh", + ]); + for (const file of contents.files) { + expect(file.size).toBeGreaterThan(0); + } + }); + + it("rejects directories outside the discovery roots", async () => { + const rogue = path.join(root, "rogue-skill"); + await mkdir(rogue, { recursive: true }); + await writeFile(path.join(rogue, "SKILL.md"), "rogue"); + + await expect(makeService().getSkillContents(rogue)).rejects.toThrow( + "not a known skill directory", + ); + }); + + it("rejects path traversal in the skill path", async () => { + await createSkill(repoSkillsDir, "alpha"); + + await expect( + makeService().getSkillContents( + path.join(repoSkillsDir, "alpha", "..", "..", "..", ".."), + ), + ).rejects.toThrow("not a known skill directory"); + }); +}); + +describe("readSkillFile", () => { + it("reads a nested file inside the skill directory", async () => { + const skillPath = await createSkill(repoSkillsDir, "alpha"); + await mkdir(path.join(skillPath, "references"), { recursive: true }); + await writeFile(path.join(skillPath, "references", "guide.md"), "guide!"); + + const content = await makeService().readSkillFile( + skillPath, + "references/guide.md", + ); + + expect(content).toBe("guide!"); + }); + + it("rejects ../ traversal out of the skill directory", async () => { + const skillPath = await createSkill(repoSkillsDir, "alpha"); + await createSkill(repoSkillsDir, "beta", "secret"); + + await expect( + makeService().readSkillFile(skillPath, "../beta/SKILL.md"), + ).rejects.toThrow("path outside skill directory"); + }); + + it("rejects absolute file paths", async () => { + const skillPath = await createSkill(repoSkillsDir, "alpha"); + + await expect( + makeService().readSkillFile(skillPath, "/etc/passwd"), + ).rejects.toThrow("path outside skill directory"); + }); + + it("rejects an empty relative path", async () => { + const skillPath = await createSkill(repoSkillsDir, "alpha"); + + await expect(makeService().readSkillFile(skillPath, "")).rejects.toThrow( + "path outside skill directory", + ); + }); + + it("returns null for missing files", async () => { + const skillPath = await createSkill(repoSkillsDir, "alpha"); + + expect(await makeService().readSkillFile(skillPath, "nope.md")).toBeNull(); + }); +}); diff --git a/packages/workspace-server/src/services/skills/skills.ts b/packages/workspace-server/src/services/skills/skills.ts index 80b86c7912..3b43bce89e 100644 --- a/packages/workspace-server/src/services/skills/skills.ts +++ b/packages/workspace-server/src/services/skills/skills.ts @@ -1,3 +1,4 @@ +import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { inject, injectable } from "inversify"; @@ -5,12 +6,22 @@ import type { FoldersService } from "../folders/folders"; import { FOLDERS_SERVICE } from "../folders/identifiers"; import { POSTHOG_PLUGIN_SERVICE } from "../posthog-plugin/identifiers"; import type { PosthogPluginService } from "../posthog-plugin/posthog-plugin"; -import type { SkillInfo } from "./schemas"; +import type { SkillContents, SkillInfo, SkillSource } from "./schemas"; import { getMarketplaceInstallPaths, + listSkillFiles, readSkillMetadataFromDir, } from "./skill-discovery"; +const MAX_SKILL_FILES = 500; +const MAX_SKILL_FILE_BYTES = 2 * 1024 * 1024; + +interface SkillRoot { + dir: string; + source: SkillSource; + repoName?: string; +} + @injectable() export class SkillsService { constructor( @@ -21,28 +32,77 @@ export class SkillsService { ) {} async listSkills(): Promise { + const roots = await this.getSkillRoots(); + const results = await Promise.all( + roots.map((root) => + readSkillMetadataFromDir(root.dir, root.source, root.repoName), + ), + ); + return results.flat(); + } + + async getSkillContents(skillPath: string): Promise { + const skillDir = await this.resolveKnownSkillDir(skillPath); + const files = await listSkillFiles(skillDir, MAX_SKILL_FILES); + return { files }; + } + + async readSkillFile( + skillPath: string, + filePath: string, + ): Promise { + const skillDir = await this.resolveKnownSkillDir(skillPath); + const resolved = path.resolve(skillDir, filePath); + if (resolved === skillDir || !resolved.startsWith(skillDir + path.sep)) { + throw new Error("Access denied: path outside skill directory"); + } + try { + const stat = await fs.promises.stat(resolved); + if (!stat.isFile() || stat.size > MAX_SKILL_FILE_BYTES) return null; + return await fs.promises.readFile(resolved, "utf-8"); + } catch { + return null; + } + } + + private async getSkillRoots(): Promise { const pluginPath = this.plugin.getPluginPath(); const folders = await this.folders.getFolders(); const marketplacePaths = await getMarketplaceInstallPaths(); - const results = await Promise.all([ - readSkillMetadataFromDir(path.join(pluginPath, "skills"), "bundled"), - readSkillMetadataFromDir( - path.join(os.homedir(), ".claude", "skills"), - "user", - ), - ...folders.map((f) => - readSkillMetadataFromDir( - path.join(f.path, ".claude", "skills"), - "repo", - f.name, - ), - ), - ...marketplacePaths.map((p) => - readSkillMetadataFromDir(path.join(p, "skills"), "marketplace"), - ), - ]); + return [ + { dir: path.join(pluginPath, "skills"), source: "bundled" as const }, + { + dir: path.join(os.homedir(), ".claude", "skills"), + source: "user" as const, + }, + ...folders.map((f) => ({ + dir: path.join(f.path, ".claude", "skills"), + source: "repo" as const, + repoName: f.name, + })), + ...marketplacePaths.map((p) => ({ + dir: path.join(p, "skills"), + source: "marketplace" as const, + })), + ]; + } - return results.flat(); + /** + * Validates that the given path is a skill directory directly under one of + * the discovery roots. This keeps the contents/readFile endpoints from + * becoming arbitrary-filesystem reads. + */ + private async resolveKnownSkillDir(skillPath: string): Promise { + const resolved = path.resolve(skillPath); + const roots = await this.getSkillRoots(); + const parent = path.dirname(resolved); + const isUnderKnownRoot = roots.some( + (root) => path.resolve(root.dir) === parent, + ); + if (!isUnderKnownRoot || !fs.existsSync(path.join(resolved, "SKILL.md"))) { + throw new Error("Access denied: not a known skill directory"); + } + return resolved; } } From 8125d409843f9374f396808ed9f0f92940e255bc Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Thu, 11 Jun 2026 14:21:03 +0100 Subject: [PATCH 2/3] docs: add the Skills tab v2 implementation plan The plan behind the skills-01..07 stack: goals, non-goals, source/ editability model, per-PR scope, and security notes. Generated-By: PostHog Code Task-Id: f4e84f1a-19c9-490c-9b98-47787a7dddcf --- docs/plans/skills-tab.md | 398 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 398 insertions(+) create mode 100644 docs/plans/skills-tab.md diff --git a/docs/plans/skills-tab.md b/docs/plans/skills-tab.md new file mode 100644 index 0000000000..19f1f56303 --- /dev/null +++ b/docs/plans/skills-tab.md @@ -0,0 +1,398 @@ +# Skills Tab v2 — Implementation Plan + +Owner: Peter Kirkham +Status: Ready to build +Last updated: 2026-06-11 + +## Summary + +The Skills tab today is a read-only list: `SkillsService.listSkills()` scans four +sources (bundled, user, repo, marketplace plugins), one tRPC query feeds +`SkillsView`, and the detail panel renders only `SKILL.md`. There is no create, +edit, delete, live refresh, marketplace browsing, cloud sync, or visibility into +the rest of a skill directory (`references/`, `scripts/`). + +This plan turns the tab into a full skill manager across seven stacked PRs: +full multi-file rendering, CRUD for user/repo skills, live refresh, validation, +skills.sh marketplace browse + install, PostHog cloud team skills via the +existing `LLMSkill` API, and Codex skills unification. + +## Goals + +- Create, edit, and delete user (`~/.claude/skills`) and repo + (`{repo}/.claude/skills`) skills entirely in-app. +- Render skills as what they are: directories (`SKILL.md` + `references/` + + `scripts/` + assets), not a single markdown file. +- Browse and install community skills from skills.sh without leaving the app. +- Publish skills to the team and consume team skills via the existing PostHog + cloud `LLMSkill` API. +- Unify with Codex: read the user's Codex skills, import them, and mirror + user skills out so they work in any agent. Bring your skills, use them + anywhere. + +## Non-goals (explicitly out of scope) + +- **No changes to the official PostHog skills pipeline.** The + `agent-skills-latest` release, `skills.zip`, the context-mill omnibus zips, + `update-skills-saga.ts`, and the 30-minute refresh stay exactly as they are. + No manifest, no versioning added to that channel. +- **No editing of bundled or plugin-marketplace skills.** Read-only, enforced + server-side, forever. +- **No upstream version tracking for installed skills.** Install is a copy. + Once installed, a skill is a local user skill; we forget where it came from + except for an "Installed" badge in the marketplace. No hashes, no diffs, no + update notifications. Reinstall (confirm + overwrite) is the update story. +- No new npm dependencies and no bundle-size impact. Marketplace browsing is + runtime HTTP; install uses the existing `fflate`-based `extract-zip.ts`. + +## Design principles + +1. **A skill is a directory, not a file.** `SKILL.md` is the manifest; the + directory is the unit of install, edit, share, and render. +2. **Editability follows directory ownership.** Directories we own on the + user's behalf (`~/.claude/skills`, `{repo}/.claude/skills`) are writable. + Directories owned by other systems (runtime plugin dir, plugin-manager + install paths, the Codex dir for skills we didn't put there) are read-only. +3. **Install = copy, then it's yours.** No tethering to upstream. +4. **Write-path guards live in workspace-server**, not the UI. Every mutation + validates the target resolves under a writable skills root. +5. **Standard layering** (per `AGENTS.md`): fs/zip/watchers/HTTP-download in + `workspace-server` behind one-line tRPC forwards; PostHog cloud API calls in + a `core` service via injected `api-client`; pure decisions (validation, + shadowing, list merging) in `core`; UI renders and calls one hook per + query/mutation. + +## Key findings (already verified) + +### PostHog cloud already has the skills model and API + +No new Django models needed. In `PostHog/posthog`: + +- `LLMSkill` (`products/ai_observability/backend/models/skills.py`): `name`, + `description`, `body` (the SKILL.md markdown), `allowed_tools`, `metadata`, + versioned via `version` + `is_latest`, soft-delete, unique on + `(team, name, version)`. +- `LLMSkillFile`: companion files (`path`, `content`) — multi-file skill + directories are supported. +- `LLMSkillViewSet` at `/api/projects/:id/llm_skills/` (and + `/api/environments/:id/...`): CRUD, name-addressed routes, `resolve`, + `archive`, `duplicate`, file get/delete. API scopes `llm_skill:read|write`. +- Currently **team-scoped only** and behind the `LLM_ANALYTICS_SKILLS` beta + flag. The only posthog-side work in this plan is flag/scope rollout + (see PR 6a). + +### Codex skills dir + +`update-skills-saga.ts` already syncs bundled skills to `~/.agents/skills` +(`CODEX_SKILLS_DIR`). PR 7 reads that directory back and mirrors user skills +into it. + +### Reusable seams in this repo + +| Need | Existing seam | +| --- | --- | +| Zip extraction | `packages/workspace-server/src/services/posthog-plugin/extract-zip.ts` (`fflate`) | +| File writes | `packages/workspace-server/src/services/fs/service.ts` (`writeRepoFile` pattern) | +| File watching | `packages/workspace-server/src/services/watcher/service.ts` (`@parcel/watcher`, debounced) | +| tRPC subscription pattern | `packages/host-router/src/routers/workspace.router.ts` (async-generator `subscription`) | +| Mutation pattern | `packages/host-router/src/routers/fs.router.ts` | +| Editor | `packages/ui/src/features/code-editor/` (CodeMirror, `useCodeMirror`) | +| Versioned JSON state file | `~/.claude/plugins/installed_plugins.json` reader in `skill-discovery.ts` | +| API resource client pattern | MCP installation methods in `packages/api-client/src/posthog-client.ts` (~L2671–2798) | + +## Skill sources after this plan + +| Source | Path | Listed | Editable | Notes | +| --- | --- | --- | --- | --- | +| `bundled` | runtime plugin dir | yes | **no** | Untouched official pipeline | +| `user` | `~/.claude/skills` | yes | yes | Includes marketplace installs | +| `repo` | `{repo}/.claude/skills` | yes | yes | Per workspace folder | +| `marketplace` | plugin install paths | yes | **no** | Claude plugin manager owns these | +| `codex` (new) | `~/.agents/skills` | yes | **no** (import to edit) | Deduped against bundled + mirrored names | +| `team` (new) | PostHog cloud `LLMSkill` | yes | via publish | Install materializes a local copy | + +--- + +## The PR stack (Graphite) + +Build this as a Graphite stack so review can proceed top-down while later PRs +are in flight. Suggested layout: + +``` +main +└── skills-01-full-rendering # PR 1 + └── skills-02-crud # PR 2 + └── skills-03-live-refresh # PR 3 + └── skills-04-validation # PR 4 + └── skills-05-marketplace # PR 5 + └── skills-07-codex # PR 7 +└── skills-06a-team-read # PR 6a (independent stack off main; rebase onto 01 if it lands first) + └── skills-06b-team-publish # PR 6b +``` + +Workflow: + +```bash +gt create skills-01-full-rendering -m "feat(skills): render full skill directories" +# ...work, commit... +gt create skills-02-crud -m "feat(skills): create/edit/delete user and repo skills" +# ... +gt submit --stack # open/refresh PRs for the whole stack +gt sync && gt restack # after a PR lands or main moves +``` + +Notes: +- PRs 1→5→7 are a true dependency chain (each builds on the previous one's + schemas/components). Keep them in one stack. +- PR 6a/6b only touch `api-client`, `core`, and a UI group; they can be a + second stack worked in parallel and restacked when convenient. +- Each PR must pass `pnpm typecheck`, `pnpm lint`, `pnpm test`, and + `node scripts/check-host-boundaries.mjs` independently — every PR is + shippable on its own. + +--- + +### PR 1 — Render full skill directories (read-only foundation) + +Everything later builds on this. Skills become directories in the UI. + +**Backend** +- `SkillsService.getSkillContents(skillPath)` → file tree (relative paths, + sizes) + `readSkillFile(skillPath, relPath)`. Both validate the path resolves + inside a directory returned by skill discovery — this must not become an + arbitrary-filesystem-read endpoint. +- `host-router` `skills.contents` / `skills.readFile` queries (one-line + forwards). Zod output schemas in `workspace-server/src/services/skills/schemas.ts`. + +**Shared** +- Add derived `editable: boolean` to `SkillInfo` (`user`/`repo` → true, + `bundled`/`marketplace` → false). Computed in `SkillsService`, not the UI. + +**UI** +- `SkillDetailPanel`: file list/tree for the skill directory. `SKILL.md` + renders as today (frontmatter stripped, markdown); other files open in + read-only CodeMirror via `useCodeMirror`. Lock badge on non-editable skills. + +**Acceptance** +- A bundled skill with `references/` and `scripts/` shows every file and its + contents, read-only. +- Path traversal attempts (`../`) on the new endpoints are rejected (unit + tested). + +--- + +### PR 2 — Create / edit / delete for user and repo skills + +**Backend** +- `SkillsService` mutations: `createSkill({scope, repoPath?, name})` (scaffolds + directory + templated `SKILL.md`), `saveSkillFile`, `deleteSkillFile`, + `deleteSkill`, `renameSkill`. +- Hard guard on every mutation: resolved target must be under + `~/.claude/skills` or a workspace folder's `.claude/skills`. Anything else + (bundled, plugin install paths, runtime plugin dir, Codex dir) is rejected + server-side. Unit-test the guard directly. +- `host-router` mutations, one-line forwards. + +**UI** +- "New skill" button → scope picker (Your skills / This repository) → scaffold + and open in edit mode. +- Edit mode in the detail panel: small form for frontmatter `name` / + `description` (users never hand-edit YAML), CodeMirror for the body, add / + rename / delete files under the skill directory. +- Delete skill with confirmation. Non-editable sources show no edit affordances. + +**Acceptance** +- Round-trip: create → edit body + frontmatter → file on disk is correct → + delete. Frontmatter writer output re-parses with `parse-skill-frontmatter.ts`. +- Mutations against a bundled skill path fail with a clear error. + +--- + +### PR 3 — Live refresh + +**Backend** +- Watch `~/.claude/skills` and each workspace folder's `.claude/skills` using + `WatcherService`. Expose `skills.watch` tRPC subscription (async-generator + pattern from `workspace.router.ts`), emitting a debounced "skills changed" + event. + +**UI** +- Subscribe once (contribution or hook at the SkillsView boundary) and + invalidate the `skills.list` / `skills.contents` queries on events. Drop the + 30s stale-time reliance. + +**Acceptance** +- `touch ~/.claude/skills/foo/SKILL.md` from a terminal updates the open tab + within ~1s. Edits made by an agent session appear without manual refresh. + +--- + +### PR 4 — Validation + shadowing signals + +**Core (pure decisions, no I/O)** +- `analyzeSkills(skills: SkillInfo[])` in `@posthog/core`: missing/empty + description, frontmatter-name vs directory-name mismatch, oversized + `SKILL.md` (context-cost warning), and name collisions across sources with + explicit "which one wins" resolution. + +**UI** +- Health badge on `SkillCard`; callouts in the detail panel (e.g. "shadowed by + your user skill of the same name"). + +**Acceptance** +- Unit tests on `analyzeSkills` cover each rule and the shadowing precedence. + +--- + +### PR 5 — skills.sh marketplace: browse + install + +Install is **copy-and-forget**: download, extract into `~/.claude/skills/`, +done. From that moment it is an ordinary, editable user skill. No upstream +tracking, no hashes, no diffs, no update checks. + +**Backend (`workspace-server`)** +- New `SkillsMarketplaceService`: + - `search(query)` → queries the skills.sh index at runtime (fallback: GitHub + repo search for `SKILL.md` directories). Nothing cached to disk. + - `preview(ref)` → fetches the skill's full file list + contents for + pre-install rendering (reuses PR 1's tree/file UI). + - `install(ref)` → downloads the repo tarball (GitHub codeload), extracts the + skill directory with `extract-zip.ts`, copies into `~/.claude/skills/`. + - `installed.json` in `~/.claude/skills/` (versioned-JSON pattern from + `installed_plugins.json`): `{ version, installed: { [name]: { repo } } }`. + Its **only** purpose is the "Installed" badge in browse results. +- Name collision or reinstall → caller must pass `overwrite: true` (UI confirms + "this will replace your local version"). +- `host-router` `skills.marketplace.*` routes, one-line forwards. + +**UI** +- "Browse" tab inside SkillsView: search, results with install counts / + publisher, full file-tree preview **before** install (PR 1 components), + explicit warning chip when a skill contains `scripts/` (installed skills can + execute code — never install sight-unseen). +- Install / Reinstall buttons with the overwrite confirm. Installed skills + appear under "Your skills" like any other user skill. + +**Acceptance** +- Search → preview (all files visible) → install → skill is listed under "Your + skills", editable; browse shows "Installed"; reinstall after a local edit + prompts and then overwrites. +- No new package.json dependencies; bundle size unchanged. + +Split into 5a (backend) / 5b (UI) if review size demands. + +--- + +### PR 6a — PostHog cloud team skills: read path + +**posthog/posthog (separate small PR, the only non-Code work in this plan)** +- Roll out / gate `LLM_ANALYTICS_SKILLS` for PostHog Code usage and confirm the + desktop OAuth token carries `llm_skill:read` and `llm_skill:write` scopes. + No model or endpoint changes. + +**api-client** +- `llm_skills` methods following the MCP-installation pattern + (`posthog-client.ts` ~L2671): `listLlmSkills()`, `getLlmSkillByName(name)`, + `resolveLlmSkill(name)`, `listLlmSkillFiles(id)`. Zod schemas alongside. + +**core** +- New `TeamSkillsService` (`@posthog/core`, api-client injected per the + placement rules) exposing list/read, plus the merged local+team listing + decision so the UI keeps one hook. + +**UI** +- "Team" group in SkillsView (new `team` source), read-only detail view + rendering `body` + `LLMSkillFile`s through the PR 1 components. + +**Acceptance** +- With the flag on, team skills list and render; with it off, the group is + absent and nothing errors. + +--- + +### PR 6b — Team skills: publish + install locally + +**Publish** +- "Publish to team" on any user/repo skill: workspace-server reads the + directory; `TeamSkillsService` creates a new `LLMSkill` version (+ + `LLMSkillFile` rows). Versioning comes free from the model's + `version`/`is_latest`. Re-publishing bumps the version. + +**Install locally** +- "Install" on a team skill materializes it into `~/.claude/skills` (agents + need files on disk), then it follows the same copy-and-forget rule as + marketplace installs: it's a local user skill, reinstall-to-update via the + Team group with a confirm-overwrite. + +**Acceptance** +- Publish a multi-file skill → teammate sees it in the Team group → installs → + identical directory on their disk → original author edits + republishes → + teammate reinstalls and gets the new version. + +--- + +### PR 7 — Codex unification ("bring your skills, use them anywhere") + +**Read: Codex → tab** +- Add `~/.agents/skills` as a discovery source in `SkillsService`; new `codex` + value in `SkillSource`. Same `findSkillDirs` call, one more root. +- Dedupe by name: skip anything matching a bundled skill (our saga put those + copies there) or a mirrored user skill (below). What remains is genuinely the + user's Codex-only skills. +- UI: "Codex" group with an **Import** action — copies the directory into + `~/.claude/skills`, after which it's an ordinary editable user skill. + +**Write: your skills → Codex** +- Extend the existing bundled→Codex sync to also mirror `~/.claude/skills` + into `~/.agents/skills` (one-way, ours out). Skills created, edited, or + installed in PostHog Code become available to Codex sessions automatically. +- Safety rule: never overwrite a skill in `~/.agents/skills` we didn't put + there. Track mirrored names (small state file next to the existing sync), and + on collision skip + surface the conflict in the tab. + +**Acceptance** +- A skill authored in Codex (`~/.agents/skills/foo`) appears in the Codex + group; Import makes it editable under "Your skills"; the mirror then carries + the imported copy back without clobbering or duplicating. +- A user skill created in the tab appears in `~/.agents/skills` after the next + sync. + +--- + +## Security notes + +- Skills can contain `scripts/` that agents execute, and `SKILL.md` content is + injected into agent context. **A marketplace install is code execution, not a + content download.** Hence: full file preview before install (PR 5), the + `scripts/` warning chip, and no silent/automatic installs or updates anywhere + in this plan. +- All write endpoints validate the resolved path against the writable roots in + workspace-server (PR 2). The read endpoints from PR 1 validate against + discovered skill directories. Both get direct unit tests including `../` + traversal. +- Team skills inherit PostHog access control (`AccessControlPermission`, API + scopes) — nothing custom on our side. + +## Testing + +- Unit (Vitest, colocated): frontmatter round-trip, path guards, `analyzeSkills` + rules, lockfile/`installed.json` read-write, dedupe + mirror rules, api-client + schema parsing. Fake injected dependencies per `docs/testing.md`. +- E2E (Playwright, `tests/e2e/`): one flow per PR — render a multi-file skill; + create/edit/delete; live-refresh on external edit; marketplace + search→preview→install; team publish→install; codex import. +- After `@posthog/shared` / `@posthog/platform` changes, rebuild/typecheck + dist; after `core` changes, `biome lint packages/core` with zero + `noRestrictedImports`. + +## Rollout order + +| Phase | PRs | Outcome | +| --- | --- | --- | +| 1 | 1, 2, 3 | The tab is a real skill manager (view all files, CRUD, live) | +| 2 | 4, 5 | Quality signals + community marketplace | +| 3 | 6a, 6b, 7 | Team sharing + Codex unification | + +PRs 1–4 carry no external dependencies and can start immediately. PR 5 depends +only on skills.sh's public index. PR 6 needs the posthog flag/scopes PR +(file it when 6a starts). PR 7 has no external dependencies. From abb385bf1e88652be6fd14d553c34d418a1a7333 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Fri, 12 Jun 2026 08:01:00 +0100 Subject: [PATCH 3/3] fix(skills): block symlink escapes in readSkillFile and drop sync existsSync Review feedback on #2604: realpath containment check covers both direct and intermediate-directory symlink escapes; resolveKnownSkillDir now uses async fs.promises.access. Generated-By: PostHog Code Task-Id: f4e84f1a-19c9-490c-9b98-47787a7dddcf --- .../src/services/skills/skills.test.ts | 36 ++++++++++++++++++- .../src/services/skills/skills.ts | 18 ++++++++-- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/packages/workspace-server/src/services/skills/skills.test.ts b/packages/workspace-server/src/services/skills/skills.test.ts index 53cceb46b7..b4bc883599 100644 --- a/packages/workspace-server/src/services/skills/skills.test.ts +++ b/packages/workspace-server/src/services/skills/skills.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -144,4 +144,38 @@ describe("readSkillFile", () => { expect(await makeService().readSkillFile(skillPath, "nope.md")).toBeNull(); }); + + it("returns null for symlinks escaping the skill directory", async () => { + const skillPath = await createSkill(repoSkillsDir, "alpha"); + const secret = path.join(root, "secret.txt"); + await writeFile(secret, "top secret"); + await symlink(secret, path.join(skillPath, "leak.md")); + + expect(await makeService().readSkillFile(skillPath, "leak.md")).toBeNull(); + }); + + it("returns null for files reached through a symlinked directory", async () => { + const skillPath = await createSkill(repoSkillsDir, "alpha"); + const outside = path.join(root, "outside"); + await mkdir(outside, { recursive: true }); + await writeFile(path.join(outside, "secret.txt"), "top secret"); + await symlink(outside, path.join(skillPath, "evil")); + + expect( + await makeService().readSkillFile(skillPath, "evil/secret.txt"), + ).toBeNull(); + }); + + it("reads symlinks that stay inside the skill directory", async () => { + const skillPath = await createSkill(repoSkillsDir, "alpha"); + await writeFile(path.join(skillPath, "real.md"), "real content"); + await symlink( + path.join(skillPath, "real.md"), + path.join(skillPath, "alias.md"), + ); + + expect(await makeService().readSkillFile(skillPath, "alias.md")).toBe( + "real content", + ); + }); }); diff --git a/packages/workspace-server/src/services/skills/skills.ts b/packages/workspace-server/src/services/skills/skills.ts index 3b43bce89e..c06fa0c7d6 100644 --- a/packages/workspace-server/src/services/skills/skills.ts +++ b/packages/workspace-server/src/services/skills/skills.ts @@ -57,9 +57,15 @@ export class SkillsService { throw new Error("Access denied: path outside skill directory"); } try { - const stat = await fs.promises.stat(resolved); + // realpath also catches escapes via symlinked intermediate directories. + const [realFile, realDir] = await Promise.all([ + fs.promises.realpath(resolved), + fs.promises.realpath(skillDir), + ]); + if (!realFile.startsWith(realDir + path.sep)) return null; + const stat = await fs.promises.stat(realFile); if (!stat.isFile() || stat.size > MAX_SKILL_FILE_BYTES) return null; - return await fs.promises.readFile(resolved, "utf-8"); + return await fs.promises.readFile(realFile, "utf-8"); } catch { return null; } @@ -100,7 +106,13 @@ export class SkillsService { const isUnderKnownRoot = roots.some( (root) => path.resolve(root.dir) === parent, ); - if (!isUnderKnownRoot || !fs.existsSync(path.join(resolved, "SKILL.md"))) { + const hasSkillMd = + isUnderKnownRoot && + (await fs.promises + .access(path.join(resolved, "SKILL.md")) + .then(() => true) + .catch(() => false)); + if (!hasSkillMd) { throw new Error("Access denied: not a known skill directory"); } return resolved;