Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/core/src/skills/identifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
77 changes: 77 additions & 0 deletions packages/core/src/skills/skillGeneratorService.ts
Original file line number Diff line number Diff line change
@@ -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: <one sentence: when should an agent invoke this skill and what does it accomplish>

<body>
<lead paragraph — 1-2 sentences expanding on when/what, no heading>

## When to use
- <specific trigger situation>
- <specific trigger situation>
- <specific trigger situation>

## Instructions
1. <step>
2. <step>
3. <step>
</body>`,
},
],
{
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("<body>");
const bodyEnd = raw.indexOf("</body>");
let body: string;
if (bodyStart !== -1 && bodyEnd !== -1) {
body = raw.slice(bodyStart + "<body>".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 };
}
4 changes: 3 additions & 1 deletion packages/core/src/skills/skills.module.ts
Original file line number Diff line number Diff line change
@@ -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();
});
164 changes: 150 additions & 14 deletions packages/ui/src/features/skills/SkillCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {
CaretDown,
CaretRight,
Folder,
Package,
Robot,
Expand All @@ -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,
Expand Down Expand Up @@ -66,7 +76,7 @@ export function SkillCard({
<SkillListCard
cardRef={ref}
icon={<Icon size={14} weight="duotone" className="text-gray-11" />}
title={skill.name}
title={humanizeName(skill.name)}
subtitle={skill.description || undefined}
isSelected={isSelected}
onClick={onClick}
Expand Down Expand Up @@ -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<HTMLButtonElement>(null);
useEffect(() => {
if (!scrollIntoView) return;
ref.current?.scrollIntoView({ block: "center" });
onScrolledIntoView?.();
}, [scrollIntoView, onScrolledIntoView]);

return (
<button
ref={ref}
type="button"
onClick={onClick}
className={`flex w-full cursor-pointer flex-col gap-2 rounded-lg border p-2.5 text-left transition-colors ${
isSelected
? "border-accent-8 bg-accent-3"
: "border-gray-6 bg-gray-2 hover:border-gray-8 hover:bg-gray-3"
}`}
>
<div className="flex items-start justify-between">
<div className="flex items-center justify-center rounded bg-gray-4 p-1.5">
<Icon size={16} weight="duotone" className="text-gray-11" />
</div>
{issues.length > 0 && (
<Warning size={12} className="shrink-0 text-amber-11" />
)}
</div>
<div className="min-w-0">
<p className="line-clamp-2 font-medium text-[12px] text-gray-12 leading-tight">
{humanizeName(skill.name)}
</p>
{skill.description && (
<p className="mt-0.5 line-clamp-2 text-[11px] text-gray-9 leading-tight">
{skill.description}
</p>
)}
</div>
</button>
);
}

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 (
<Flex direction="column" gap="1">
<Text className="mb-1 font-medium text-[12px] text-gray-9 uppercase tracking-wider">
{title}
</Text>
<Flex direction="column" gap="1">
viewMode = "list",
}: SkillCardListProps) {
if (viewMode === "grid") {
return (
<div className="grid grid-cols-2 gap-2">
{skills.map((skill) => (
<SkillCard
<SkillGridCard
key={skill.path}
skill={skill}
isSelected={selectedPath === skill.path}
Expand All @@ -134,7 +192,85 @@ export function SkillSection({
issues={analysis?.[skill.path]}
/>
))}
</Flex>
</div>
);
}
return (
<Flex direction="column" gap="1">
{skills.map((skill) => (
<SkillCard
key={skill.path}
skill={skill}
isSelected={selectedPath === skill.path}
onClick={() => onSelect(skill.path)}
scrollIntoView={scrollToPath === skill.path}
onScrolledIntoView={onScrolledIntoView}
issues={analysis?.[skill.path]}
/>
))}
</Flex>
);
}

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 (
<Flex direction="column" gap="1">
{onToggle ? (
<button
type="button"
onClick={onToggle}
className="mb-1 flex items-center gap-1.5 text-left text-gray-9 hover:text-gray-11"
>
{isCollapsed ? (
<CaretRight size={11} className="shrink-0" />
) : (
<CaretDown size={11} className="shrink-0" />
)}
<span className="font-medium text-[12px] uppercase tracking-wider">
{title}
</span>
<span className="text-[11px] text-gray-7">{skills.length}</span>
</button>
) : (
<Text className="mb-1 font-medium text-[12px] text-gray-9 uppercase tracking-wider">
{title}
</Text>
)}
{!isCollapsed && (
<SkillCardList
skills={skills}
selectedPath={selectedPath}
onSelect={onSelect}
scrollToPath={scrollToPath}
onScrolledIntoView={onScrolledIntoView}
analysis={analysis}
viewMode={viewMode}
/>
)}
</Flex>
);
}
20 changes: 18 additions & 2 deletions packages/ui/src/features/skills/SkillDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -52,18 +52,22 @@ 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({
skill,
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<string | null>(null);
Expand All @@ -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();
Expand Down Expand Up @@ -303,7 +319,7 @@ export function SkillDetailPanel({
)}
</Flex>

{issues.length > 0 && (
{issues.length > 0 && !isEditing && (
<Flex direction="column" gap="1">
{issues.map((issue) => (
<Callout.Root
Expand Down
Loading