diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 05bec74cd..a578f4ac3 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -4722,6 +4722,60 @@ export class PostHogAPIClient { return (await response.json()) as AgentRevision; } + /** + * Write a single bundle file on a draft revision. The server accepts + * `agent.md` and `skills//SKILL.md` paths only — tool source / schema + * stay read-only this round. Ready / live / archived revisions return 409. + */ + async updateAgentDraftBundleFile( + idOrSlug: string, + revisionId: string, + filePath: string, + content: string, + ): Promise { + const teamId = await this.getTeamId(); + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/revisions/${encodeURIComponent(revisionId)}/bundle/file/`; + const url = new URL(`${this.api.baseUrl}${path}`); + const response = await this.api.fetcher.fetch({ + method: "put", + url, + path, + overrides: { + body: JSON.stringify({ path: filePath, content }), + }, + }); + return (await response.json()) as AgentRevision; + } + + /** + * Bulk-import a set of `.md` files into a draft revision's bundle — the + * migration hatch for porting an existing multi-file agent in one paste. + * Sets `agent_md` if present and merges `skills[]` by id (adds new ids, + * overwrites bodies for existing ids; skills not mentioned are left alone). + * Draft-only; ready / live / archived return 409. + */ + async importAgentDraftBundle( + idOrSlug: string, + revisionId: string, + body: { + agent_md?: string; + skills?: { id: string; description?: string; body: string }[]; + }, + ): Promise { + const teamId = await this.getTeamId(); + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/revisions/${encodeURIComponent(revisionId)}/bundle/import/`; + const url = new URL(`${this.api.baseUrl}${path}`); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path, + overrides: { + body: JSON.stringify(body), + }, + }); + return (await response.json()) as AgentRevision; + } + /** * A revision's bundle, flattened to per-file rows. The server returns a typed * `{ bundle: { agent_md, skills[], tools[] } }`; we expand it to the canonical diff --git a/packages/ui/src/features/agent-applications/components/AgentBundleImportDialog.test.ts b/packages/ui/src/features/agent-applications/components/AgentBundleImportDialog.test.ts new file mode 100644 index 000000000..10d859819 --- /dev/null +++ b/packages/ui/src/features/agent-applications/components/AgentBundleImportDialog.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { type ParsedBundle, parseBundleInput } from "./AgentBundleImportDialog"; + +type Expected = + | { ok: true; value: ParsedBundle } + | { ok: false; errorMatch?: RegExp }; + +const cases: Array<{ name: string; input: string; expected: Expected }> = [ + { + name: "rejects empty input", + input: "", + expected: { ok: false }, + }, + { + name: "parses a single agent.md block", + input: "--- agent.md ---\nYou are the growth review agent.\n", + expected: { + ok: true, + value: { agent_md: "You are the growth review agent." }, + }, + }, + { + name: "parses multiple skill blocks", + input: [ + "--- skills/research/SKILL.md ---", + "Research body", + "--- skills/draft-post/SKILL.md ---", + "Draft body", + ].join("\n"), + expected: { + ok: true, + value: { + skills: [ + { id: "research", body: "Research body" }, + { id: "draft-post", body: "Draft body" }, + ], + }, + }, + }, + { + name: "parses agent.md plus skills together", + input: [ + "--- agent.md ---", + "Main prompt", + "", + "--- skills/research/SKILL.md ---", + "Research body", + ].join("\n"), + expected: { + ok: true, + value: { + agent_md: "Main prompt", + skills: [{ id: "research", body: "Research body" }], + }, + }, + }, + { + name: "tolerates CRLF line endings", + input: "--- agent.md ---\r\nMain prompt\r\n", + expected: { ok: true, value: { agent_md: "Main prompt" } }, + }, + { + name: "rejects an unsupported file path", + input: "--- tools/foo/source.ts ---\nconsole.log('hi')\n", + expected: { ok: false, errorMatch: /Unsupported file path/ }, + }, + { + name: "rejects skill ids with spaces", + input: "--- skills/Bad Id/SKILL.md ---\nbody\n", + expected: { ok: false }, + }, + { + name: "rejects skill ids with uppercase letters", + input: "--- skills/MySkill/SKILL.md ---\nbody\n", + expected: { ok: false }, + }, + { + name: "ignores leading content before the first header", + input: [ + "# notes for myself, not in any block", + "--- agent.md ---", + "Prompt", + ].join("\n"), + expected: { ok: true, value: { agent_md: "Prompt" } }, + }, +]; + +describe("parseBundleInput", () => { + it.each(cases)("$name", ({ input, expected }) => { + const out = parseBundleInput(input); + if (expected.ok) { + expect(out).toEqual(expected); + return; + } + expect(out.ok).toBe(false); + if (!out.ok && expected.errorMatch) { + expect(out.error).toMatch(expected.errorMatch); + } + }); +}); diff --git a/packages/ui/src/features/agent-applications/components/AgentBundleImportDialog.tsx b/packages/ui/src/features/agent-applications/components/AgentBundleImportDialog.tsx new file mode 100644 index 000000000..bb3fb5d8e --- /dev/null +++ b/packages/ui/src/features/agent-applications/components/AgentBundleImportDialog.tsx @@ -0,0 +1,216 @@ +import { Badge } from "@posthog/ui/primitives/Badge"; +import { Button } from "@posthog/ui/primitives/Button"; +import { Dialog, Flex, Text } from "@radix-ui/themes"; +import { useMemo, useState } from "react"; +import { useImportAgentDraftBundle } from "../hooks/useImportAgentDraftBundle"; + +const HEADER_RE = /^---\s*(.+?)\s*---\s*$/; +const SKILL_PATH_RE = /^skills\/([a-z0-9-]+)\/SKILL\.md$/; + +export interface ParsedBundle { + agent_md?: string; + skills?: { id: string; body: string }[]; +} + +/** + * Splits a fenced paste — alternating `--- ---` headers and bodies — + * into the import payload the server accepts. The format is deliberately + * simple so the source files can be cat'd together as-is; only `agent.md` + * and `skills//SKILL.md` are recognised. + */ +export function parseBundleInput( + input: string, +): { ok: true; value: ParsedBundle } | { ok: false; error: string } { + const lines = input.replace(/\r\n/g, "\n").split("\n"); + const value: ParsedBundle = {}; + let current: { kind: "agent" } | { kind: "skill"; id: string } | null = null; + let buf: string[] = []; + + const flush = () => { + if (!current) return; + const content = buf.join("\n").replace(/^\n+|\n+$/g, ""); + if (current.kind === "agent") { + value.agent_md = content; + } else { + if (!value.skills) value.skills = []; + value.skills.push({ id: current.id, body: content }); + } + }; + + for (const line of lines) { + const m = HEADER_RE.exec(line); + if (m) { + flush(); + buf = []; + const path = m[1]; + if (path === "agent.md") { + current = { kind: "agent" }; + } else { + const skill = SKILL_PATH_RE.exec(path); + if (!skill) { + return { + ok: false, + error: `Unsupported file path: "${path}". Use "agent.md" or "skills//SKILL.md".`, + }; + } + current = { kind: "skill", id: skill[1] }; + } + continue; + } + if (current) buf.push(line); + } + flush(); + + if (value.agent_md === undefined && !value.skills?.length) { + return { + ok: false, + error: + "Nothing to import. Add at least one `--- agent.md ---` or `--- skills//SKILL.md ---` block.", + }; + } + return { ok: true, value }; +} + +const SAMPLE = `--- agent.md --- +You are the growth review agent. … + +--- skills/research/SKILL.md --- +When asked to research, … + +--- skills/draft-post/SKILL.md --- +When asked to draft, … +`; + +/** + * Bulk-paste a markdown bundle into a draft revision. Designed for migrating + * an existing multi-file agent in one paste — concatenate the source files + * with a `--- path ---` header between each. Existing skill ids are + * overwritten; new ids are added; skills not mentioned are left alone. + */ +export function AgentBundleImportDialog({ + open, + onOpenChange, + idOrSlug, + revisionId, + existingSkillIds, + onSuccess, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + idOrSlug: string; + revisionId: string; + existingSkillIds: string[]; + onSuccess?: () => void; +}) { + const [input, setInput] = useState(""); + const mutation = useImportAgentDraftBundle(idOrSlug, revisionId); + + const parsed = useMemo(() => { + if (input.trim().length === 0) return null; + return parseBundleInput(input); + }, [input]); + + const value = parsed?.ok ? parsed.value : null; + const existing = useMemo(() => new Set(existingSkillIds), [existingSkillIds]); + + const onConfirm = () => { + if (!value) return; + mutation.mutate(value, { + onSuccess: () => { + setInput(""); + mutation.reset(); + onOpenChange(false); + onSuccess?.(); + }, + }); + }; + + const close = () => { + if (mutation.isPending) return; + setInput(""); + mutation.reset(); + onOpenChange(false); + }; + + return ( + { + if (!isOpen) close(); + }} + > + + Paste markdown bundle + + Paste one or more --- path --- blocks. Accepts{" "} + agent.md and skills/[id]/SKILL.md. Existing + skills are overwritten by id; new ids are added. + +