Skip to content
Closed
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
13 changes: 12 additions & 1 deletion packages/shared/src/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ export interface SkillInfo {
editable: boolean;
/** Size of SKILL.md in bytes (context-cost signal). */
skillMdBytes: number;
/**
* Frontmatter `disable-model-invocation: true`: the agent never invokes the
* skill on its own — only an explicit user invocation (slash command or
* composer skill tag) runs it.
*/
disableModelInvocation?: boolean;
}

export interface SkillFileEntry {
Expand Down Expand Up @@ -44,13 +50,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"] : []),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: Manual-only is not enforced for Codex sessions

This writes only Claude's disable-model-invocation frontmatter, while Codex sessions link these same skill directories into CODEX_HOME. Codex controls automatic selection through policy.allow_implicit_invocation in agents/openai.yaml, so a skill can still be selected and run without an explicit invocation even though the UI labels it Manual-only. Generate the corresponding Codex policy when preparing skills, or scope the setting and UI guarantee to agents that enforce it.

"---",
].join("\n");

Expand Down
7 changes: 7 additions & 0 deletions packages/ui/src/features/skills/SkillCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ export function SkillCard({
{skill.repoName}
</Badge>
)}
{skill.disableModelInvocation && (
<Tooltip content="The agent won't use this skill on its own — only when you invoke it">
<Badge size="1" variant="soft" color="gray" className="shrink-0">
Manual
</Badge>
</Tooltip>
)}
</>
}
/>
Expand Down
9 changes: 9 additions & 0 deletions packages/ui/src/features/skills/SkillDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
DownloadSimple,
FilePlus,
Folder,
HandTap,
LockSimple,
PencilSimple,
Trash,
Expand Down Expand Up @@ -287,6 +288,14 @@ export function SkillDetailPanel({
Read-only
</Badge>
)}
{skill.disableModelInvocation && (
<Tooltip content="The agent won't use this skill on its own — only when you invoke it">
<Badge size="1" variant="soft" color="gray">
<HandTap size={10} className="text-gray-9" />
Manual-only
</Badge>
</Tooltip>
)}
{skill.source === "codex" && (
<Button
size="1"
Expand Down
30 changes: 29 additions & 1 deletion packages/ui/src/features/skills/SkillManifestEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import type { SkillInfo } from "@posthog/shared";
import { toast } from "@posthog/ui/primitives/toast";
import { Box, Button, Flex, Text, TextArea, TextField } from "@radix-ui/themes";
import {
Box,
Button,
Flex,
Switch,
Text,
TextArea,
TextField,
} from "@radix-ui/themes";
import { useRef, useState } from "react";
import { SkillCodeEditor } from "./SkillCodeEditor";
import { skillErrorDescription } from "./skillErrors";
Expand All @@ -25,6 +33,9 @@ export function SkillManifestEditor({
}: SkillManifestEditorProps) {
const [name, setName] = useState(skill.name);
const [description, setDescription] = useState(skill.description);
const [disableModelInvocation, setDisableModelInvocation] = useState(
skill.disableModelInvocation ?? false,
);
// Captured at mount: background refetches must not reset in-flight edits.
const [mountedBody] = useState(initialBody);
const bodyRef = useRef(mountedBody);
Expand All @@ -37,6 +48,7 @@ export function SkillManifestEditor({
name,
description,
body: bodyRef.current,
disableModelInvocation,
});
onSaved();
} catch (error) {
Expand Down Expand Up @@ -72,6 +84,22 @@ export function SkillManifestEditor({
placeholder="When should an agent use this skill?"
/>
</Box>
<Flex align="center" justify="between" gap="2">
<Box>
<Text className="block text-[12px] text-gray-12">
Manual invocation only
</Text>
<Text className="block text-[11px] text-gray-10">
The agent won't use this skill on its own — only when you invoke
it
</Text>
</Box>
<Switch
size="1"
checked={disableModelInvocation}
onCheckedChange={setDisableModelInvocation}
/>
</Flex>
</Flex>

<Box className="min-h-0 flex-1 border-t border-t-(--gray-5)">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,63 @@
import { describe, expect, it } from "vitest";
import { parseSkillDependencies } from "./parse-skill-frontmatter";
import {
parseSkillDependencies,
parseSkillFrontmatter,
} from "./parse-skill-frontmatter";

describe("parseSkillFrontmatter disable-model-invocation", () => {
it.each([
["absent", `---\nname: a\ndescription: d\n---\nbody`, false],
[
"true",
`---\nname: a\ndescription: d\ndisable-model-invocation: true\n---\nbody`,
true,
],
[
"capitalized True",
`---\nname: a\ndescription: d\ndisable-model-invocation: True\n---\nbody`,
true,
],
[
"quoted true",
`---\nname: a\ndescription: d\ndisable-model-invocation: "true"\n---\nbody`,
true,
],
[
"false",
`---\nname: a\ndescription: d\ndisable-model-invocation: false\n---\nbody`,
false,
],
[
"non-boolean value",
`---\nname: a\ndescription: d\ndisable-model-invocation: maybe\n---\nbody`,
false,
],
[
"true with trailing comment",
`---\nname: a\ndescription: d\ndisable-model-invocation: true # manual only\n---\nbody`,
true,
],
[
"quoted true with trailing comment",
`---\nname: a\ndescription: d\ndisable-model-invocation: "true" # manual only\n---\nbody`,
true,
],
[
"false with trailing comment",
`---\nname: a\ndescription: d\ndisable-model-invocation: false # keep automatic\n---\nbody`,
false,
],
[
"quoted string that only starts with true",
`---\nname: a\ndescription: d\ndisable-model-invocation: "true # manual only"\n---\nbody`,
false,
],
])("parses %s", (_label, content, expected) => {
expect(parseSkillFrontmatter(content)?.disableModelInvocation).toBe(
expected,
);
});
});

describe("parseSkillDependencies", () => {
it.each([
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
/**
* Parses YAML frontmatter from a SKILL.md file.
* Extracts `name` and `description` fields.
* Extracts `name`, `description`, and `disable-model-invocation` fields.
*
* Handles:
* - Simple values: `name: my-skill`
* - Quoted strings: `description: 'Some text'` or `description: "Some text"`
* - Multi-line folded: `description: >-\n line1\n line2`
*/
export function parseSkillFrontmatter(
content: string,
): { name: string; description: string } | null {
export function parseSkillFrontmatter(content: string): {
name: string;
description: string;
disableModelInvocation: boolean;
} | null {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!match) return null;

Expand All @@ -18,7 +20,15 @@ export function parseSkillFrontmatter(
if (!name) return null;

const description = extractYamlValue(yaml, "description") ?? "";
return { name, description };
const disableModelInvocation = parseYamlBoolean(
extractYamlValue(yaml, "disable-model-invocation"),
);
return { name, description, disableModelInvocation };
}

/** YAML 1.2 core-schema booleans: `true`/`True`/`TRUE` (quoted forms too). */
function parseYamlBoolean(value: string | null): boolean {
return value !== null && value.toLowerCase() === "true";
}

/**
Expand Down Expand Up @@ -100,16 +110,19 @@ function extractYamlValue(yaml: string, key: string): string | null {
return collectIndentedLines(lines, i + 1).join("\n");
}

// Quoted string (single or double)
// Quoted string (single or double). Quoted content is literal in YAML —
// a `#` inside never starts a comment, so no comment stripping here.
if (
(rawValue.startsWith("'") && rawValue.endsWith("'")) ||
(rawValue.startsWith('"') && rawValue.endsWith('"'))
) {
return rawValue.slice(1, -1);
}

// Plain scalar
return rawValue;
// Plain scalar: a `#` preceded by whitespace starts a trailing comment.
// Stripping it may uncover a quoted scalar (`"true" # manual only`).
const withoutComment = rawValue.replace(/\s+#.*$/, "").trim();
return unquoteYamlScalar(withoutComment);
}

return null;
Expand Down
4 changes: 4 additions & 0 deletions packages/workspace-server/src/services/skills/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ export const skillInfo = z.object({
repoName: z.string().optional(),
editable: z.boolean(),
skillMdBytes: z.number(),
// Frontmatter `disable-model-invocation: true`: only explicit user
// invocation runs the skill; the agent never picks it on its own.
disableModelInvocation: z.boolean().optional(),
});

export const listSkillsOutput = z.array(skillInfo);
Expand Down Expand Up @@ -59,6 +62,7 @@ export const saveSkillManifestInput = z.object({
name: z.string(),
description: z.string(),
body: z.string(),
disableModelInvocation: z.boolean().optional(),
});

export const saveSkillFileInput = z.object({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export async function readSkillMetadataFromDir(
if (skillNames.length === 0) return [];

const results = await Promise.all(
skillNames.map(async (skillName) => {
skillNames.map(async (skillName): Promise<SkillInfo | null> => {
const skillPath = path.join(skillsDir, skillName);
try {
const content = await fs.promises.readFile(
Expand All @@ -192,6 +192,9 @@ export async function readSkillMetadataFromDir(
...(repoName ? { repoName } : {}),
editable: isEditableSource(source),
skillMdBytes: Buffer.byteLength(content, "utf-8"),
...(frontmatter?.disableModelInvocation
? { disableModelInvocation: true }
: {}),
} satisfies SkillInfo;
} catch {
return null;
Expand Down
47 changes: 47 additions & 0 deletions packages/workspace-server/src/services/skills/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,24 @@ describe("listSkills", () => {
expect(repoSkill?.editable).toBe(true);
expect(bundledSkill?.editable).toBe(false);
});

it("surfaces disable-model-invocation from frontmatter", async () => {
await createSkill(
repoSkillsDir,
"manual-skill",
`---\nname: manual-skill\ndescription: d\ndisable-model-invocation: true\n---\nbody`,
);
await createSkill(repoSkillsDir, "auto-skill");

const skills = await makeService().listSkills();

expect(
skills.find((s) => s.name === "manual-skill")?.disableModelInvocation,
).toBe(true);
expect(
skills.find((s) => s.name === "auto-skill")?.disableModelInvocation,
).toBeUndefined();
});
});

describe("getSkillContents", () => {
Expand Down Expand Up @@ -555,6 +573,35 @@ describe("skill mutations", () => {
expect(content).toContain("# Alpha");
});

it("writes and clears disable-model-invocation through manifest saves", async () => {
const skillPath = await createSkill(repoSkillsDir, "alpha");
const service = makeService();

await service.saveSkillManifest(skillPath, {
name: "alpha",
description: "d",
body: "body",
disableModelInvocation: true,
});
let skills = await service.listSkills();
expect(
skills.find((s) => s.path === skillPath)?.disableModelInvocation,
).toBe(true);

await service.saveSkillManifest(skillPath, {
name: "alpha",
description: "d",
body: "body",
disableModelInvocation: false,
});
skills = await service.listSkills();
expect(
skills.find((s) => s.path === skillPath)?.disableModelInvocation,
).toBeUndefined();
const content = await service.readSkillFile(skillPath, "SKILL.md");
expect(content).not.toContain("disable-model-invocation");
});

it("rejects manifest saves without a name", async () => {
const skillPath = await createSkill(repoSkillsDir, "alpha");

Expand Down
13 changes: 11 additions & 2 deletions packages/workspace-server/src/services/skills/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,20 @@ export class SkillsService {

async saveSkillManifest(
skillPath: string,
manifest: { name: string; description: string; body: string },
manifest: {
name: string;
description: string;
body: string;
disableModelInvocation?: boolean;
},
): Promise<void> {
const skillDir = await this.resolveWritableSkillDir(skillPath);
const content = serializeSkillMarkdown(
{ name: manifest.name.trim(), description: manifest.description.trim() },
{
name: manifest.name.trim(),
description: manifest.description.trim(),
disableModelInvocation: manifest.disableModelInvocation ?? false,
},
manifest.body,
);
// The writer and parser must agree, or the skill vanishes from the list.
Expand Down
Loading
Loading