Skip to content

Commit c477788

Browse files
authored
refactor(skills): apply simplify-pass cleanups across the stack (#2612)
## Problem Several small pieces of logic were duplicated or misplaced across the skills feature, making it harder to maintain and extend. Specifically: the "already installed locally" marking was coupled to the team skills fetch, `stripFrontmatter` existed only in the UI package, error-handling patterns were repeated inline across many components, and the card row layout was copy-pasted across three different list surfaces. ## Changes **Decouple local-install marking from the team skills fetch** `listTeamSkills` no longer accepts `localSkillNames` and no longer marks skills as installed. A new pure function `markInstalledTeamSkills` handles that separately. `useTeamSkills` now fetches the team listing once and applies `markInstalledTeamSkills` in a `useMemo`, so local skill changes never trigger a refetch of the remote listing. The query key structure is also cleaned up with a shared `teamSkillsKeys.all` root so invalidations are consistent. **Move `stripFrontmatter` to `@posthog/shared`** The UI-local `stripFrontmatter.ts` is deleted. A CRLF-aware version now lives in `packages/shared/src/skills.ts` and is exported from the package index. All consumers (`SkillDetailPanel`, `MarketplaceSkillPanel`, `TeamSkillDetailPanel`, the workspace-server export path) import from `@posthog/shared`. **Centralise skill error helpers** A new `skillErrors.ts` module exposes `isSkillExistsError` and `skillErrorDescription`. All the scattered `error instanceof Error ? error.message : ""` / `.includes("already exists")` patterns across `SkillDetailPanel`, `TeamSkillDetailPanel`, `MarketplaceSkillPanel`, `SkillFileEditor`, `SkillManifestEditor`, `NewSkillDialog`, and `SkillFileEditor` are replaced with calls to these helpers. **Extract `SkillListCard` shared component** The identical card-row markup (icon box, title/subtitle text block, trailing slot, selection highlight) was duplicated in `SkillCard`, `TeamSkillsSection`, and `MarketplaceBrowse`. It is now a single `SkillListCard` component with `icon`, `title`, `subtitle`, `isSelected`, `onClick`, and `trailing` props. **Extract `ReplaceSkillDialog` shared component** The confirm-overwrite `AlertDialog` was duplicated in `MarketplaceSkillPanel`, `TeamSkillDetailPanel`, and `SkillDetailPanel`. It is now a single `ReplaceSkillDialog` component parameterised by `skillName`, `verb` ("Reinstalling" / "Importing"), and `onConfirm`. **Consolidate `getUserSkillsDir` and `isProbablyText`** Both helpers were defined inline in multiple workspace-server files. They now live in `skill-discovery.ts` and are imported wherever needed, removing the repeated `os.homedir()` path construction. **Parallelise file I/O in the workspace server** Sequential `for` loops in `mirrorUserSkillsToCodex`, `exportSkill`, and `installSkill` are replaced with `Promise.all` calls. **Move `installsFormatter` to `useMarketplace.ts`** The `Intl.NumberFormat` instance was duplicated in `MarketplaceBrowse` and `MarketplaceSkillPanel`; it is now exported once from `useMarketplace.ts`. **Remove unused `resolveLlmSkill` API method** `resolveLlmSkill` and its `LlmSkillResolveResponse` type are removed from the API client as they are no longer called anywhere. ## How did you test this? - Updated unit tests for `TeamSkillsService.listTeamSkills` to reflect the removed `localSkillNames` parameter. - Added a new `markInstalledTeamSkills` unit test covering name-based matching and unrelated local names. - Existing tests for `publishSkill` and other service methods continue to pass. ## Automatic notifications - [ ] Publish to changelog? - [ ] Alert Sales and Marketing teams?
1 parent 3e78bd5 commit c477788

28 files changed

Lines changed: 518 additions & 494 deletions

packages/api-client/src/posthog-client.ts

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -189,18 +189,6 @@ export interface LlmSkillFileInput {
189189
content_type?: string;
190190
}
191191

192-
export interface LlmSkillResolveResponse {
193-
skill: LlmSkill;
194-
versions: Array<{
195-
id: string;
196-
version: number;
197-
created_by: LlmSkillCreatedBy | null;
198-
created_at: string;
199-
is_latest: boolean;
200-
}>;
201-
has_more: boolean;
202-
}
203-
204192
export interface SignalSourceConfig {
205193
id: string;
206194
source_product:
@@ -3724,22 +3712,6 @@ export class PostHogAPIClient {
37243712
return (await response.json()) as LlmSkill;
37253713
}
37263714

3727-
/** Resolves a team skill plus its version history. */
3728-
async resolveLlmSkill(name: string): Promise<LlmSkillResolveResponse> {
3729-
const teamId = await this.getTeamId();
3730-
const urlPath = `/api/environments/${teamId}/llm_skills/resolve/name/${encodeURIComponent(name)}`;
3731-
const url = new URL(`${this.api.baseUrl}${urlPath}`);
3732-
const response = await this.api.fetcher.fetch({
3733-
method: "get",
3734-
url,
3735-
path: urlPath,
3736-
});
3737-
if (!response.ok) {
3738-
throw new Error(`Failed to resolve team skill: ${response.statusText}`);
3739-
}
3740-
return (await response.json()) as LlmSkillResolveResponse;
3741-
}
3742-
37433715
/** Creates a brand-new team skill (version 1). */
37443716
async createLlmSkill(input: {
37453717
name: string;

packages/core/src/skills/teamSkillsService.test.ts

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import type {
44
} from "@posthog/api-client/posthog-client";
55
import { describe, expect, it, vi } from "vitest";
66
import type { SkillsWorkspaceClient } from "./teamSkillsService";
7-
import { TeamSkillsService } from "./teamSkillsService";
7+
import {
8+
markInstalledTeamSkills,
9+
TeamSkillsService,
10+
} from "./teamSkillsService";
811

912
function makeService(
1013
workspace: Partial<SkillsWorkspaceClient> = {},
@@ -39,21 +42,18 @@ function makeClient(result: LlmSkillListItem[] | null): PostHogAPIClient {
3942

4043
describe("TeamSkillsService.listTeamSkills", () => {
4144
it("reports the feature as unavailable when the API returns null", async () => {
42-
const listing = await makeService().listTeamSkills(makeClient(null), []);
45+
const listing = await makeService().listTeamSkills(makeClient(null));
4346

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

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

53-
const listing = await makeService().listTeamSkills(client, [
54-
"release-notes",
55-
"unrelated-local",
56-
]);
56+
const listing = await makeService().listTeamSkills(client);
5757

5858
expect(listing.available).toBe(true);
5959
expect(listing.skills).toEqual([
@@ -66,11 +66,7 @@ describe("TeamSkillsService.listTeamSkills", () => {
6666
createdByEmail: "dev@posthog.com",
6767
installedLocally: false,
6868
},
69-
expect.objectContaining({
70-
name: "release-notes",
71-
createdByEmail: null,
72-
installedLocally: true,
73-
}),
69+
expect.objectContaining({ name: "release-notes", createdByEmail: null }),
7470
]);
7571
});
7672

@@ -80,13 +76,34 @@ describe("TeamSkillsService.listTeamSkills", () => {
8076
makeItem({ id: "skill-1b", version: 2 }),
8177
]);
8278

83-
const listing = await makeService().listTeamSkills(client, []);
79+
const listing = await makeService().listTeamSkills(client);
8480

8581
expect(listing.skills).toHaveLength(1);
8682
expect(listing.skills[0]?.id).toBe("skill-1b");
8783
});
8884
});
8985

86+
describe("markInstalledTeamSkills", () => {
87+
it("marks team skills that exist locally by name", async () => {
88+
const listing = await makeService().listTeamSkills(
89+
makeClient([
90+
makeItem({}),
91+
makeItem({ id: "skill-2", name: "release-notes" }),
92+
]),
93+
);
94+
95+
const marked = markInstalledTeamSkills(listing, [
96+
"release-notes",
97+
"unrelated-local",
98+
]);
99+
100+
expect(marked.skills.map((s) => [s.name, s.installedLocally])).toEqual([
101+
["pr-shepherd", false],
102+
["release-notes", true],
103+
]);
104+
});
105+
});
106+
90107
describe("TeamSkillsService.publishSkill", () => {
91108
const exported = {
92109
name: "pr-shepherd",

packages/core/src/skills/teamSkillsService.ts

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,24 @@ export interface SkillsWorkspaceClient {
3535
): Promise<{ path: string }>;
3636
}
3737

38+
/**
39+
* Marks team skills that already exist locally by name. Pure, so callers
40+
* can re-apply it when the local listing changes without refetching.
41+
*/
42+
export function markInstalledTeamSkills(
43+
listing: TeamSkillsListing,
44+
localSkillNames: string[],
45+
): TeamSkillsListing {
46+
const localNames = new Set(localSkillNames);
47+
return {
48+
...listing,
49+
skills: listing.skills.map((skill) => ({
50+
...skill,
51+
installedLocally: localNames.has(skill.name),
52+
})),
53+
};
54+
}
55+
3856
@injectable()
3957
export class TeamSkillsService {
4058
constructor(
@@ -63,24 +81,18 @@ export class TeamSkillsService {
6381
}
6482

6583
/**
66-
* Lists team skills merged with the local listing: the availability
67-
* decision (flag off → absent group, no errors) and the "already
68-
* installed locally" marking both live here, so the UI keeps one hook.
84+
* Lists the team's latest skills, owning the availability decision
85+
* (flag off → absent group, no errors). Combine with
86+
* markInstalledTeamSkills for the merged local+team view.
6987
*/
70-
async listTeamSkills(
71-
client: PostHogAPIClient,
72-
localSkillNames: string[],
73-
): Promise<TeamSkillsListing> {
88+
async listTeamSkills(client: PostHogAPIClient): Promise<TeamSkillsListing> {
7489
const items = await client.listLlmSkills();
7590
if (items === null) {
7691
return { available: false, skills: [] };
7792
}
78-
const localNames = new Set(localSkillNames);
7993
return {
8094
available: true,
81-
skills: items
82-
.filter((item) => item.is_latest)
83-
.map((item) => toTeamSkillInfo(item, localNames)),
95+
skills: items.filter((item) => item.is_latest).map(toTeamSkillInfo),
8496
};
8597
}
8698

@@ -151,17 +163,14 @@ export class TeamSkillsService {
151163
}
152164
}
153165

154-
function toTeamSkillInfo(
155-
item: LlmSkillListItem,
156-
localNames: Set<string>,
157-
): TeamSkillInfo {
166+
function toTeamSkillInfo(item: LlmSkillListItem): TeamSkillInfo {
158167
return {
159168
id: item.id,
160169
name: item.name,
161170
description: item.description,
162171
version: item.latest_version ?? item.version,
163172
updatedAt: item.updated_at,
164173
createdByEmail: item.created_by?.email ?? null,
165-
installedLocally: localNames.has(item.name),
174+
installedLocally: false,
166175
};
167176
}

packages/host-router/src/routers/skills.router.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,7 @@ export const skillsRouter = router({
6363
.mutation(({ ctx, input }) =>
6464
ctx.container
6565
.get<SkillsService>(SKILLS_SERVICE)
66-
.saveSkillManifest(input.skillPath, {
67-
name: input.name,
68-
description: input.description,
69-
body: input.body,
70-
}),
66+
.saveSkillManifest(input.skillPath, input),
7167
),
7268
saveFile: publicProcedure
7369
.input(saveSkillFileInput)

packages/shared/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ export type {
166166
SkillInfo,
167167
SkillSource,
168168
} from "./skills";
169+
export { SKILL_EXISTS_MARKER, stripFrontmatter } from "./skills";
169170
export type {
170171
ArtifactType,
171172
PostHogAPIConfig,

packages/shared/src/skills.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,19 @@ export interface ExportedSkill {
3131
body: string;
3232
files: ExportedSkillFile[];
3333
}
34+
35+
/**
36+
* Server "skill already exists" messages must include this marker verbatim;
37+
* the UI keys its overwrite-confirmation flow on it.
38+
*/
39+
export const SKILL_EXISTS_MARKER = "already exists";
40+
41+
/**
42+
* Strips a leading YAML frontmatter block from a SKILL.md document.
43+
* CRLF-aware so render (UI) and export (workspace-server) agree on the body.
44+
*/
45+
export function stripFrontmatter(content: string): string {
46+
const match = content.match(/^---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n?/);
47+
if (!match) return content;
48+
return content.slice(match[0].length).replace(/^(?:[ \t]*\r?\n)+/, "");
49+
}

packages/ui/src/features/skills/MarketplaceBrowse.tsx

Lines changed: 31 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,14 @@ import {
1111
} from "@radix-ui/themes";
1212
import { useState } from "react";
1313
import { MarketplaceSkillPanel } from "./MarketplaceSkillPanel";
14+
import { SkillListCard } from "./SkillListCard";
1415
import { useSkillsSidebarStore } from "./skillsSidebarStore";
1516
import {
17+
installsFormatter,
1618
type MarketplaceSkillSummary,
1719
useMarketplaceSearch,
1820
} from "./useMarketplace";
1921

20-
const installsFormatter = new Intl.NumberFormat(undefined, {
21-
notation: "compact",
22-
});
23-
2422
export function MarketplaceBrowse() {
2523
const [query, setQuery] = useState("");
2624
const { debounced: debouncedQuery } = useDebouncedValue(query, 300);
@@ -68,52 +66,41 @@ export function MarketplaceBrowse() {
6866
) : (
6967
<Flex direction="column" gap="1">
7068
{results.map((result) => (
71-
<Flex
69+
<SkillListCard
7270
key={result.id}
73-
align="center"
74-
gap="2"
75-
px="3"
76-
py="2"
77-
className={`cursor-pointer rounded-lg border transition-colors ${
78-
selected?.id === result.id
79-
? "border-accent-8 bg-accent-3"
80-
: "border-gray-6 bg-gray-2 hover:border-gray-8 hover:bg-gray-3"
81-
}`}
82-
onClick={() =>
83-
setSelected((prev) =>
84-
prev?.id === result.id ? null : result,
85-
)
86-
}
87-
>
88-
<Box className="flex shrink-0 items-center justify-center rounded bg-gray-4 p-1.5">
71+
icon={
8972
<Storefront
9073
size={14}
9174
weight="duotone"
9275
className="text-gray-11"
9376
/>
94-
</Box>
95-
<Flex direction="column" gap="0" className="min-w-0 flex-1">
96-
<Text className="truncate font-medium text-[13px] text-gray-12">
97-
{result.name}
98-
</Text>
99-
<Text className="truncate text-[12px] text-gray-10">
100-
{result.source}
101-
</Text>
102-
</Flex>
103-
{result.installed && (
104-
<Badge
105-
size="1"
106-
variant="soft"
107-
color="green"
108-
className="shrink-0"
109-
>
110-
Installed
111-
</Badge>
112-
)}
113-
<Text className="shrink-0 text-[12px] text-gray-9 tabular-nums">
114-
{installsFormatter.format(result.installs)}
115-
</Text>
116-
</Flex>
77+
}
78+
title={result.name}
79+
subtitle={result.source}
80+
isSelected={selected?.id === result.id}
81+
onClick={() =>
82+
setSelected((prev) =>
83+
prev?.id === result.id ? null : result,
84+
)
85+
}
86+
trailing={
87+
<>
88+
{result.installed && (
89+
<Badge
90+
size="1"
91+
variant="soft"
92+
color="green"
93+
className="shrink-0"
94+
>
95+
Installed
96+
</Badge>
97+
)}
98+
<Text className="shrink-0 text-[12px] text-gray-9 tabular-nums">
99+
{installsFormatter.format(result.installs)}
100+
</Text>
101+
</>
102+
}
103+
/>
117104
))}
118105
</Flex>
119106
)}

0 commit comments

Comments
 (0)