diff --git a/packages/core/src/skills/identifiers.ts b/packages/core/src/skills/identifiers.ts index 59a79f78ee..2ec593122f 100644 --- a/packages/core/src/skills/identifiers.ts +++ b/packages/core/src/skills/identifiers.ts @@ -5,3 +5,7 @@ export const TEAM_SKILLS_SERVICE = Symbol.for( export const SKILLS_WORKSPACE_CLIENT = Symbol.for( "posthog.core.skills.workspaceClient", ); + +export const SKILL_GENERATOR_SERVICE = Symbol.for( + "posthog.core.skills.skillGeneratorService", +); diff --git a/packages/core/src/skills/skillGeneratorService.ts b/packages/core/src/skills/skillGeneratorService.ts new file mode 100644 index 0000000000..a649642051 --- /dev/null +++ b/packages/core/src/skills/skillGeneratorService.ts @@ -0,0 +1,77 @@ +import { LLM_GATEWAY_SERVICE } from "@posthog/core/llm-gateway/identifiers"; +import { + HELPER_GATEWAY_MODEL, + type LlmGatewayService, +} from "@posthog/core/llm-gateway/llm-gateway"; +import { inject, injectable } from "inversify"; + +@injectable() +export class SkillGeneratorService { + constructor( + @inject(LLM_GATEWAY_SERVICE) private readonly gateway: LlmGatewayService, + ) {} + + async generate( + name: string, + hint: string, + signal?: AbortSignal, + ): Promise<{ description: string; body: string }> { + const result = await this.gateway.prompt( + [ + { + role: "user", + content: `Write a skill definition for a Claude Code skill named "${name}".${hint ? `\n\nContext: ${hint}` : ""} + +Respond in this exact format — no other text: + +DESCRIPTION: + + + + +## When to use +- +- +- + +## Instructions +1. +2. +3. +`, + }, + ], + { + system: + "You write concise, actionable skill definitions for Claude Code agents. Be direct and specific. No boilerplate.", + model: HELPER_GATEWAY_MODEL, + maxTokens: 800, + signal, + }, + ); + + return parseGeneratorOutput(result.content); + } +} + +function parseGeneratorOutput(raw: string): { + description: string; + body: string; +} { + const descMatch = raw.match(/^DESCRIPTION:\s*(.+)/m); + const description = descMatch ? descMatch[1].trim() : ""; + + const bodyStart = raw.indexOf(""); + const bodyEnd = raw.indexOf(""); + let body: string; + if (bodyStart !== -1 && bodyEnd !== -1) { + body = raw.slice(bodyStart + "".length, bodyEnd).trim(); + } else { + // Fallback: everything after the DESCRIPTION line + body = descMatch + ? raw.slice(raw.indexOf(descMatch[0]) + descMatch[0].length).trimStart() + : raw; + } + + return { description, body }; +} diff --git a/packages/core/src/skills/skills.module.ts b/packages/core/src/skills/skills.module.ts index 0c3cce3b52..a4fc84d8b4 100644 --- a/packages/core/src/skills/skills.module.ts +++ b/packages/core/src/skills/skills.module.ts @@ -1,7 +1,9 @@ import { ContainerModule } from "inversify"; -import { TEAM_SKILLS_SERVICE } from "./identifiers"; +import { SKILL_GENERATOR_SERVICE, TEAM_SKILLS_SERVICE } from "./identifiers"; +import { SkillGeneratorService } from "./skillGeneratorService"; import { TeamSkillsService } from "./teamSkillsService"; export const skillsCoreModule = new ContainerModule(({ bind }) => { bind(TEAM_SKILLS_SERVICE).to(TeamSkillsService).inSingletonScope(); + bind(SKILL_GENERATOR_SERVICE).to(SkillGeneratorService).inSingletonScope(); }); diff --git a/packages/ui/src/features/skills/SkillCard.tsx b/packages/ui/src/features/skills/SkillCard.tsx index 7144820b05..9f67ae599e 100644 --- a/packages/ui/src/features/skills/SkillCard.tsx +++ b/packages/ui/src/features/skills/SkillCard.tsx @@ -1,4 +1,6 @@ import { + CaretDown, + CaretRight, Folder, Package, Robot, @@ -14,6 +16,14 @@ import type { SkillInfo, SkillSource } from "@posthog/shared"; import { Badge, Flex, Text, Tooltip } from "@radix-ui/themes"; import { useEffect, useRef } from "react"; import { SkillListCard } from "./SkillListCard"; +import type { SkillViewMode } from "./skillsViewStore"; + +export function humanizeName(name: string): string { + return name + .split("-") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); +} export const SOURCE_CONFIG: Record< SkillSource, @@ -66,7 +76,7 @@ export function SkillCard({ } - title={skill.name} + title={humanizeName(skill.name)} subtitle={skill.description || undefined} isSelected={isSelected} onClick={onClick} @@ -98,33 +108,81 @@ export function SkillCard({ ); } -interface SkillSectionProps { - title: string; +export function SkillGridCard({ + skill, + isSelected, + onClick, + scrollIntoView, + onScrolledIntoView, + issues = [], +}: SkillCardProps) { + const config = SOURCE_CONFIG[skill.source]; + const Icon = config?.icon ?? Package; + + const ref = useRef(null); + useEffect(() => { + if (!scrollIntoView) return; + ref.current?.scrollIntoView({ block: "center" }); + onScrolledIntoView?.(); + }, [scrollIntoView, onScrolledIntoView]); + + return ( + + ); +} + +interface SkillCardListProps { skills: SkillInfo[]; selectedPath: string | null; onSelect: (path: string) => void; scrollToPath: string | null; onScrolledIntoView: () => void; analysis?: SkillAnalysis; + viewMode?: SkillViewMode; } -export function SkillSection({ - title, +export function SkillCardList({ skills, selectedPath, onSelect, scrollToPath, onScrolledIntoView, analysis, -}: SkillSectionProps) { - return ( - - - {title} - - + viewMode = "list", +}: SkillCardListProps) { + if (viewMode === "grid") { + return ( +
{skills.map((skill) => ( - ))} - +
+ ); + } + return ( + + {skills.map((skill) => ( + onSelect(skill.path)} + scrollIntoView={scrollToPath === skill.path} + onScrolledIntoView={onScrolledIntoView} + issues={analysis?.[skill.path]} + /> + ))} + + ); +} + +interface SkillSectionProps { + title: string; + skills: SkillInfo[]; + selectedPath: string | null; + onSelect: (path: string) => void; + scrollToPath: string | null; + onScrolledIntoView: () => void; + analysis?: SkillAnalysis; + viewMode?: SkillViewMode; + isCollapsed?: boolean; + onToggle?: () => void; +} + +export function SkillSection({ + title, + skills, + selectedPath, + onSelect, + scrollToPath, + onScrolledIntoView, + analysis, + viewMode = "list", + isCollapsed = false, + onToggle, +}: SkillSectionProps) { + return ( + + {onToggle ? ( + + ) : ( + + {title} + + )} + {!isCollapsed && ( + + )} ); } diff --git a/packages/ui/src/features/skills/SkillDetailPanel.tsx b/packages/ui/src/features/skills/SkillDetailPanel.tsx index 6be4fd58e3..9ee164ce6c 100644 --- a/packages/ui/src/features/skills/SkillDetailPanel.tsx +++ b/packages/ui/src/features/skills/SkillDetailPanel.tsx @@ -29,7 +29,7 @@ import { TextField, Tooltip, } from "@radix-ui/themes"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ReplaceSkillDialog } from "./ReplaceSkillDialog"; import { SOURCE_CONFIG } from "./SkillCard"; import { SkillFileEditor } from "./SkillFileEditor"; @@ -52,6 +52,8 @@ interface SkillDetailPanelProps { issues?: SkillIssue[]; /** Whether team skills are available for publishing. */ canPublish?: boolean; + /** Open directly in edit mode (used when creating a new skill). */ + initialEditing?: boolean; } export function SkillDetailPanel({ @@ -59,11 +61,13 @@ export function SkillDetailPanel({ onClose, issues = [], canPublish = false, + initialEditing = false, }: SkillDetailPanelProps) { const config = SOURCE_CONFIG[skill.source]; const [selectedFile, setSelectedFile] = useState("SKILL.md"); const [isEditing, setIsEditing] = useState(false); + const hasAutoEnteredEdit = useRef(false); const [addFileOpen, setAddFileOpen] = useState(false); const [newFilePath, setNewFilePath] = useState(""); const [renameFrom, setRenameFrom] = useState(null); @@ -79,6 +83,18 @@ export function SkillDetailPanel({ selectedFile, ); + useEffect(() => { + if ( + initialEditing && + !hasAutoEnteredEdit.current && + !isLoading && + fileContent != null + ) { + hasAutoEnteredEdit.current = true; + setIsEditing(true); + } + }, [initialEditing, isLoading, fileContent]); + const saveFile = useSaveSkillFile(); const renameFile = useRenameSkillFile(); const deleteFile = useDeleteSkillFile(); @@ -303,7 +319,7 @@ export function SkillDetailPanel({ )}
- {issues.length > 0 && ( + {issues.length > 0 && !isEditing && ( {issues.map((issue) => ( (SKILL_GENERATOR_SERVICE); + const [isGenerating, setIsGenerating] = useState(false); + const [editorKey, setEditorKey] = useState(0); + const [editorContent, setEditorContent] = useState(mountedBody); + const abortRef = useRef(null); + + const handleGenerate = async () => { + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + setIsGenerating(true); + try { + const { body: generated, description: generatedDesc } = + await generator.generate(name.trim(), description.trim(), ctrl.signal); + if (generatedDesc && !description.trim()) { + setDescription(generatedDesc); + } + bodyRef.current = generated; + setEditorContent(generated); + setEditorKey((k) => k + 1); + } catch (err) { + if (ctrl.signal.aborted) return; + toast.error("Failed to generate skill content", { + description: skillErrorDescription(err), + }); + } finally { + if (!ctrl.signal.aborted) setIsGenerating(false); + } + }; + const handleSave = async () => { try { await saveManifest.mutateAsync({ @@ -61,9 +103,24 @@ export function SkillManifestEditor({ /> - - Description - + + + Description + + +