Skip to content
Merged
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
2 changes: 2 additions & 0 deletions apps/code/src/renderer/desktop-contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { inboxCoreModule } from "@posthog/core/inbox/inbox.module";
import { githubConnectModule } from "@posthog/core/integrations/githubConnect.module";
import { onboardingModule } from "@posthog/core/onboarding/onboarding.module";
import { setupCoreModule } from "@posthog/core/setup/setup.module";
import { skillsCoreModule } from "@posthog/core/skills/skills.module";
import { CONTRIBUTION } from "@posthog/di/contribution";
import { agentUiModule } from "@posthog/ui/features/agent/agent.module";
import { authUiModule } from "@posthog/ui/features/auth/auth.module";
Expand Down Expand Up @@ -38,6 +39,7 @@ export function registerDesktopContributions(): void {
provisioningUiModule,
setupCoreModule,
setupUiModule,
skillsCoreModule,
workspaceUiModule,
]) {
container.load(module);
Expand Down
125 changes: 125 additions & 0 deletions packages/api-client/src/posthog-client.ts
Comment thread
k11kirky marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,58 @@ export interface UserGitHubIntegration {
created_at?: string;
}

export interface LlmSkillCreatedBy {
id?: number;
email?: string | null;
first_name?: string | null;
last_name?: string | null;
}

export interface LlmSkillFileManifest {
path: string;
content_type: string;
}

export interface LlmSkillFile {
path: string;
content: string;
content_type: string;
}

export interface LlmSkillListItem {
id: string;
name: string;
description: string;
allowed_tools: unknown[];
metadata: Record<string, unknown>;
version: number;
is_latest: boolean;
latest_version?: number | null;
version_count?: number | null;
created_by: LlmSkillCreatedBy | null;
created_at: string;
updated_at: string;
}

export interface LlmSkill extends LlmSkillListItem {
/** The SKILL.md markdown content. */
body: string;
/** Companion file manifest (paths only; fetch contents separately). */
files: LlmSkillFileManifest[];
}

export interface LlmSkillResolveResponse {
skill: LlmSkill;
versions: Array<{
id: string;
version: number;
created_by: LlmSkillCreatedBy | null;
created_at: string;
is_latest: boolean;
}>;
has_more: boolean;
}

export interface SignalSourceConfig {
id: string;
source_product:
Expand Down Expand Up @@ -3627,4 +3679,77 @@ export class PostHogAPIClient {
}
return (await response.json()) as SpendAnalysisResponse;
}

/**
* Lists the team's LLM skills (latest versions, no bodies).
* Returns null when the feature is unavailable for this org (the
* llm-analytics-skills flag gates the endpoint server-side with a 403).
*/
async listLlmSkills(): Promise<LlmSkillListItem[] | null> {
const teamId = await this.getTeamId();
const urlPath = `/api/environments/${teamId}/llm_skills/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});
if (response.status === 403) return null;
if (!response.ok) {
throw new Error(`Failed to fetch team skills: ${response.statusText}`);
}
const data = (await response.json()) as { results?: LlmSkillListItem[] };
return data.results ?? [];
}

/** Fetches the latest version of a team skill, including body and file manifest. */
async getLlmSkillByName(name: string): Promise<LlmSkill> {
const teamId = await this.getTeamId();
const urlPath = `/api/environments/${teamId}/llm_skills/name/${encodeURIComponent(name)}`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});
if (!response.ok) {
throw new Error(`Failed to fetch team skill: ${response.statusText}`);
}
return (await response.json()) as LlmSkill;
}

/** Resolves a team skill plus its version history. */
async resolveLlmSkill(name: string): Promise<LlmSkillResolveResponse> {
const teamId = await this.getTeamId();
const urlPath = `/api/environments/${teamId}/llm_skills/resolve/name/${encodeURIComponent(name)}`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});
if (!response.ok) {
throw new Error(`Failed to resolve team skill: ${response.statusText}`);
}
return (await response.json()) as LlmSkillResolveResponse;
}

/** Fetches one companion file of a team skill. */
async getLlmSkillFile(name: string, filePath: string): Promise<LlmSkillFile> {
const teamId = await this.getTeamId();
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
const urlPath = `/api/environments/${teamId}/llm_skills/name/${encodeURIComponent(name)}/files/${encodedPath}`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});
if (!response.ok) {
throw new Error(
`Failed to fetch team skill file: ${response.statusText}`,
);
}
return (await response.json()) as LlmSkillFile;
}
}
3 changes: 3 additions & 0 deletions packages/core/src/skills/identifiers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const TEAM_SKILLS_SERVICE = Symbol.for(
"posthog.core.skills.teamSkillsService",
);
7 changes: 7 additions & 0 deletions packages/core/src/skills/skills.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ContainerModule } from "inversify";
import { TEAM_SKILLS_SERVICE } from "./identifiers";
import { TeamSkillsService } from "./teamSkillsService";

export const skillsCoreModule = new ContainerModule(({ bind }) => {
bind(TEAM_SKILLS_SERVICE).to(TeamSkillsService).inSingletonScope();
});
84 changes: 84 additions & 0 deletions packages/core/src/skills/teamSkillsService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import type {
LlmSkillListItem,
PostHogAPIClient,
} from "@posthog/api-client/posthog-client";
import { describe, expect, it, vi } from "vitest";
import { TeamSkillsService } from "./teamSkillsService";

function makeItem(overrides: Partial<LlmSkillListItem>): LlmSkillListItem {
return {
id: "skill-1",
name: "pr-shepherd",
description: "Shepherds PRs",
allowed_tools: [],
metadata: {},
version: 2,
is_latest: true,
latest_version: 2,
created_by: { email: "dev@posthog.com" },
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-02-01T00:00:00Z",
...overrides,
};
}

function makeClient(result: LlmSkillListItem[] | null): PostHogAPIClient {
return {
listLlmSkills: vi
.fn<PostHogAPIClient["listLlmSkills"]>()
.mockResolvedValue(result),
} satisfies Partial<PostHogAPIClient> as unknown as PostHogAPIClient;
}
Comment thread
k11kirky marked this conversation as resolved.

describe("TeamSkillsService.listTeamSkills", () => {
it("reports the feature as unavailable when the API returns null", async () => {
const listing = await new TeamSkillsService().listTeamSkills(
makeClient(null),
[],
);

expect(listing).toEqual({ available: false, skills: [] });
});

it("maps team skills and marks ones that exist locally", async () => {
const client = makeClient([
makeItem({}),
makeItem({ id: "skill-2", name: "release-notes", created_by: null }),
]);

const listing = await new TeamSkillsService().listTeamSkills(client, [
"release-notes",
"unrelated-local",
]);

expect(listing.available).toBe(true);
expect(listing.skills).toEqual([
{
id: "skill-1",
name: "pr-shepherd",
description: "Shepherds PRs",
version: 2,
updatedAt: "2026-02-01T00:00:00Z",
createdByEmail: "dev@posthog.com",
installedLocally: false,
},
expect.objectContaining({
name: "release-notes",
createdByEmail: null,
installedLocally: true,
}),
]);
});

it("drops non-latest versions", async () => {
const client = makeClient([
makeItem({ is_latest: false, version: 1 }),
makeItem({ id: "skill-1b", version: 2 }),
]);

const listing = await new TeamSkillsService().listTeamSkills(client, []);

expect(listing.skills).toHaveLength(1);
expect(listing.skills[0]?.id).toBe("skill-1b");
});
});
62 changes: 62 additions & 0 deletions packages/core/src/skills/teamSkillsService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type {
LlmSkillListItem,
PostHogAPIClient,
} from "@posthog/api-client/posthog-client";
import { injectable } from "inversify";

export interface TeamSkillInfo {
id: string;
name: string;
description: string;
version: number;
updatedAt: string;
createdByEmail: string | null;
/** A local skill with the same name already exists on this machine. */
installedLocally: boolean;
}

export interface TeamSkillsListing {
/** False when the org does not have the team-skills feature enabled. */
available: boolean;
skills: TeamSkillInfo[];
}

@injectable()
export class TeamSkillsService {
/**
* Lists team skills merged with the local listing: the availability
* decision (flag off → absent group, no errors) and the "already
* installed locally" marking both live here, so the UI keeps one hook.
*/
async listTeamSkills(
client: PostHogAPIClient,
localSkillNames: string[],
): Promise<TeamSkillsListing> {
const items = await client.listLlmSkills();
if (items === null) {
return { available: false, skills: [] };
}
const localNames = new Set(localSkillNames);
return {
available: true,
skills: items
.filter((item) => item.is_latest)
.map((item) => toTeamSkillInfo(item, localNames)),
};
}
}

function toTeamSkillInfo(
item: LlmSkillListItem,
localNames: Set<string>,
): TeamSkillInfo {
return {
id: item.id,
name: item.name,
description: item.description,
version: item.latest_version ?? item.version,
updatedAt: item.updated_at,
createdByEmail: item.created_by?.email ?? null,
installedLocally: localNames.has(item.name),
};
}
23 changes: 20 additions & 3 deletions packages/ui/src/features/skills/SkillsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,16 @@ import {
useSkillsSelectionActions,
} from "./skillsSelectionStore";
import { useSkillsSidebarStore } from "./skillsSidebarStore";
import { TeamSkillsTab } from "./TeamSkillsTab";
import { useSkills } from "./useSkills";
import { useSkillsWatcher } from "./useSkillsWatcher";
import { useTeamSkills } from "./useTeamSkills";

const SOURCE_ORDER: SkillSource[] = ["user", "marketplace", "repo", "bundled"];

type SkillsTab = "installed" | "marketplace";
// Installed = on disk, usable by agents right now. Team and Marketplace are
// remote catalogs; installing materializes a skill into Installed.
type SkillsTab = "installed" | "team" | "marketplace";

export function SkillsView() {
const { data: skills = [], isLoading } = useSkills();
Expand All @@ -39,6 +43,12 @@ export function SkillsView() {
const [searchQuery, setSearchQuery] = useState("");
const [newSkillOpen, setNewSkillOpen] = useState(false);

const { data: teamListing } = useTeamSkills(skills);
const teamAvailable = teamListing?.available ?? false;
// Team access revoked mid-session: fall back to Installed.
const activeTab: SkillsTab =
tab === "team" && !teamAvailable ? "installed" : tab;

const {
width: sidebarWidth,
setWidth: setSidebarWidth,
Expand Down Expand Up @@ -120,22 +130,29 @@ export function SkillsView() {
<Flex direction="column" height="100%" className="overflow-hidden">
<Box px="4" className="shrink-0 border-b border-b-(--gray-5)">
<Tabs
value={tab}
value={activeTab}
onValueChange={(value: string) => setTab(value as SkillsTab)}
>
<TabsList variant="line" className="h-auto gap-0.5">
<TabsTrigger value="installed" className="gap-1.5 px-2.5 py-2">
<span className="font-medium text-[13px]">Installed</span>
</TabsTrigger>
{teamAvailable && (
<TabsTrigger value="team" className="gap-1.5 px-2.5 py-2">
<span className="font-medium text-[13px]">Team</span>
</TabsTrigger>
)}
<TabsTrigger value="marketplace" className="gap-1.5 px-2.5 py-2">
<span className="font-medium text-[13px]">Marketplace</span>
</TabsTrigger>
</TabsList>
</Tabs>
</Box>

{tab === "marketplace" ? (
{activeTab === "marketplace" ? (
<MarketplaceBrowse />
) : activeTab === "team" ? (
<TeamSkillsTab skills={teamListing?.skills ?? []} />
Comment thread
k11kirky marked this conversation as resolved.
) : (
<Flex className="min-h-0 flex-1">
<Box flexGrow="1" className="min-w-0">
Expand Down
Loading
Loading