diff --git a/products/desktop/apps/web/src/web-skill-bundler.ts b/products/desktop/apps/web/src/web-skill-bundler.ts index 3444d6a40173..cab9ff97037f 100644 --- a/products/desktop/apps/web/src/web-skill-bundler.ts +++ b/products/desktop/apps/web/src/web-skill-bundler.ts @@ -51,7 +51,11 @@ export async function bundleExportedSkill( // split), matching how desktop's installTeamSkill writes it to disk. files["SKILL.md"] = strToU8( serializeSkillMarkdown( - { name: exported.name, description: exported.description }, + { + name: exported.name, + description: exported.description, + disableModelInvocation: exported.disableModelInvocation, + }, exported.body, ), ); diff --git a/products/desktop/packages/api-client/src/posthog-client.ts b/products/desktop/packages/api-client/src/posthog-client.ts index 5007ffc495a3..814bec71c1f1 100644 --- a/products/desktop/packages/api-client/src/posthog-client.ts +++ b/products/desktop/packages/api-client/src/posthog-client.ts @@ -5622,6 +5622,7 @@ export class PostHogAPIClient { description: string; body: string; files?: LlmSkillFileInput[]; + metadata?: Record; }): Promise { const teamId = await this.getTeamId(); const urlPath = `/api/environments/${teamId}/llm_skills/`; @@ -5654,6 +5655,7 @@ export class PostHogAPIClient { body: string; description?: string; files?: LlmSkillFileInput[]; + metadata?: Record; base_version: number; }, ): Promise { diff --git a/products/desktop/packages/core/src/skills/teamSkillsService.test.ts b/products/desktop/packages/core/src/skills/teamSkillsService.test.ts index 4aaf6fbc8937..66348c395fad 100644 --- a/products/desktop/packages/core/src/skills/teamSkillsService.test.ts +++ b/products/desktop/packages/core/src/skills/teamSkillsService.test.ts @@ -147,11 +147,71 @@ describe("TeamSkillsService.publishSkill", () => { body: "# Body", description: "Shepherds PRs", files: exported.files, + metadata: {}, base_version: 2, }); expect(result).toEqual({ version: 3 }); }); + it("stores disable-model-invocation in metadata on first publish", async () => { + const createLlmSkill = vi.fn().mockResolvedValue(makeItem({ version: 1 })); + const client = { + listLlmSkills: vi.fn().mockResolvedValue([]), + createLlmSkill, + } as unknown as PostHogAPIClient; + + await makeService().publishSkill(client, { + ...exported, + disableModelInvocation: true, + }); + + expect(createLlmSkill).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: { "disable-model-invocation": true }, + }), + ); + }); + + it.each([ + { + case: "sets the key and keeps other metadata", + disableModelInvocation: true as const, + existingMetadata: { author: "dev" }, + expected: { author: "dev", "disable-model-invocation": true }, + }, + { + case: "clears the key when the flag is gone", + disableModelInvocation: undefined, + existingMetadata: { author: "dev", "disable-model-invocation": true }, + expected: { author: "dev" }, + }, + ])( + "republish $case", + async ({ disableModelInvocation, existingMetadata, expected }) => { + const publishLlmSkillVersion = vi + .fn() + .mockResolvedValue(makeItem({ version: 3 })); + const client = { + listLlmSkills: vi + .fn() + .mockResolvedValue([ + makeItem({ version: 2, metadata: existingMetadata }), + ]), + publishLlmSkillVersion, + } as unknown as PostHogAPIClient; + + await makeService().publishSkill(client, { + ...exported, + disableModelInvocation, + }); + + expect(publishLlmSkillVersion).toHaveBeenCalledWith( + "pr-shepherd", + expect.objectContaining({ metadata: expected }), + ); + }, + ); + it("rejects publishing without a description", async () => { await expect( makeService().publishSkill(makeClient([]), { @@ -207,6 +267,26 @@ describe("TeamSkillsService.fetchSkillForInstall", () => { ], }); }); + + it("maps disable-model-invocation metadata onto the exported skill", async () => { + const client = { + getLlmSkillByName: vi.fn().mockResolvedValue({ + name: "pr-shepherd", + description: "Shepherds PRs", + body: "# Body", + metadata: { "disable-model-invocation": true }, + files: [], + }), + getLlmSkillFile: vi.fn(), + } as unknown as PostHogAPIClient; + + const skill = await makeService().fetchSkillForInstall( + client, + "pr-shepherd", + ); + + expect(skill.disableModelInvocation).toBe(true); + }); }); describe("TeamSkillsService.publishLocalSkill", () => { diff --git a/products/desktop/packages/core/src/skills/teamSkillsService.ts b/products/desktop/packages/core/src/skills/teamSkillsService.ts index df8557afa7de..69f2152267ec 100644 --- a/products/desktop/packages/core/src/skills/teamSkillsService.ts +++ b/products/desktop/packages/core/src/skills/teamSkillsService.ts @@ -2,7 +2,10 @@ import type { LlmSkillListItem, PostHogAPIClient, } from "@posthog/api-client/posthog-client"; -import type { ExportedSkill } from "@posthog/shared"; +import { + DISABLE_MODEL_INVOCATION_METADATA_KEY, + type ExportedSkill, +} from "@posthog/shared"; import { inject, injectable } from "inversify"; import { SKILLS_WORKSPACE_CLIENT } from "./identifiers"; @@ -127,6 +130,10 @@ export class TeamSkillsService { body: exported.body, description: exported.description, files: exported.files, + metadata: withDisableModelInvocation( + existing.metadata, + exported.disableModelInvocation, + ), base_version: existing.latest_version ?? existing.version, }) : await client.createLlmSkill({ @@ -134,6 +141,9 @@ export class TeamSkillsService { description: exported.description, body: exported.body, files: exported.files, + ...(exported.disableModelInvocation + ? { metadata: { [DISABLE_MODEL_INVOCATION_METADATA_KEY]: true } } + : {}), }); return { version: published.version }; @@ -158,11 +168,26 @@ export class TeamSkillsService { name: detail.name, description: detail.description, body: detail.body, + ...(detail.metadata?.[DISABLE_MODEL_INVOCATION_METADATA_KEY] === true + ? { disableModelInvocation: true } + : {}), files, }; } } +// Clearing the key keeps a republish that dropped the frontmatter from staying manual-only. +function withDisableModelInvocation( + metadata: Record | undefined, + disableModelInvocation: boolean | undefined, +): Record { + const { [DISABLE_MODEL_INVOCATION_METADATA_KEY]: _removed, ...rest } = + metadata ?? {}; + return disableModelInvocation + ? { ...rest, [DISABLE_MODEL_INVOCATION_METADATA_KEY]: true } + : rest; +} + function toTeamSkillInfo(item: LlmSkillListItem): TeamSkillInfo { return { id: item.id, diff --git a/products/desktop/packages/shared/src/index.ts b/products/desktop/packages/shared/src/index.ts index 45fb5b0e27d6..2bd3900781c5 100644 --- a/products/desktop/packages/shared/src/index.ts +++ b/products/desktop/packages/shared/src/index.ts @@ -337,6 +337,7 @@ export type { UploadableSkillSource, } from "./skills"; export { + DISABLE_MODEL_INVOCATION_METADATA_KEY, SKILL_EXISTS_MARKER, serializeSkillMarkdown, stripFrontmatter, diff --git a/products/desktop/packages/shared/src/skills.ts b/products/desktop/packages/shared/src/skills.ts index 540b92eba505..9932101128ef 100644 --- a/products/desktop/packages/shared/src/skills.ts +++ b/products/desktop/packages/shared/src/skills.ts @@ -11,6 +11,8 @@ export interface SkillInfo { editable: boolean; /** Size of SKILL.md in bytes (context-cost signal). */ skillMdBytes: number; + /** Frontmatter `disable-model-invocation: true`: only an explicit user invocation runs the skill, never the agent on its own. */ + disableModelInvocation?: boolean; } export interface SkillFileEntry { @@ -31,8 +33,11 @@ export interface ExportedSkill { description: string; body: string; files: ExportedSkillFile[]; + disableModelInvocation?: boolean; } +export const DISABLE_MODEL_INVOCATION_METADATA_KEY = "disable-model-invocation"; + /** * Serializes a SKILL.md file from frontmatter metadata plus a markdown body. * @@ -44,13 +49,18 @@ export interface ExportedSkill { * sandbox, so it must not drift between hosts. */ export function serializeSkillMarkdown( - meta: { name: string; description: string }, + meta: { + name: string; + description: string; + disableModelInvocation?: boolean; + }, body: string, ): string { const frontmatter = [ "---", `name: ${serializeSkillScalar(meta.name)}`, `description: ${serializeSkillScalar(meta.description)}`, + ...(meta.disableModelInvocation ? ["disable-model-invocation: true"] : []), "---", ].join("\n"); diff --git a/products/desktop/packages/ui/src/features/skills/SkillCard.tsx b/products/desktop/packages/ui/src/features/skills/SkillCard.tsx index 5f896404a910..8ba28b62ab0c 100644 --- a/products/desktop/packages/ui/src/features/skills/SkillCard.tsx +++ b/products/desktop/packages/ui/src/features/skills/SkillCard.tsx @@ -92,6 +92,13 @@ export function SkillCard({ {skill.repoName} )} + {skill.disableModelInvocation && ( + + + Manual + + + )} } /> diff --git a/products/desktop/packages/ui/src/features/skills/SkillDetailPanel.tsx b/products/desktop/packages/ui/src/features/skills/SkillDetailPanel.tsx index 0feb3da4d341..78a8cf1ef9eb 100644 --- a/products/desktop/packages/ui/src/features/skills/SkillDetailPanel.tsx +++ b/products/desktop/packages/ui/src/features/skills/SkillDetailPanel.tsx @@ -3,6 +3,7 @@ import { DownloadSimple, FilePlus, Folder, + HandTap, LockSimple, PencilSimple, Trash, @@ -286,6 +287,14 @@ export function SkillDetailPanel({ Read-only )} + {skill.disableModelInvocation && ( + + + + Manual-only + + + )} {skill.source === "codex" && (