Skip to content
54 changes: 54 additions & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4932,6 +4932,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/<id>/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<AgentRevision> {
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<AgentRevision> {
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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import { type ParsedBundle, parseBundleInput } from "../utils/parseBundleInput";

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);
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
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";
import { parseBundleInput } from "../utils/parseBundleInput";

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 (
<Dialog.Root
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) close();
}}
>
<Dialog.Content maxWidth="640px">
<Dialog.Title className="text-base">Paste markdown bundle</Dialog.Title>
<Dialog.Description size="2" className="text-gray-11">
Paste one or more <code>--- path ---</code> blocks. Accepts{" "}
<code>agent.md</code> and <code>skills/[id]/SKILL.md</code>. Existing
skills are overwritten by id; new ids are added.
</Dialog.Description>
<textarea
aria-label="Markdown bundle"
value={input}
onChange={(e) => setInput(e.currentTarget.value)}
placeholder={SAMPLE}
disabled={mutation.isPending}
spellCheck={false}
className="mt-3 min-h-[280px] w-full resize-y rounded-(--radius-2) border border-border bg-(--color-panel-solid) p-3 text-[12.5px] text-gray-12 [font-family:var(--font-mono)] focus:border-(--accent-7) focus:outline-none"
/>
{parsed && !parsed.ok ? (
<Text className="mt-2 block text-(--red-11) text-[12px]">
{parsed.error}
</Text>
) : null}
{value ? (
<div className="mt-3 rounded-(--radius-2) border border-border bg-(--gray-2) px-3 py-2">
<Text className="block text-[11px] text-gray-10 uppercase tracking-wide">
Will write
</Text>
<Flex direction="column" gap="1" className="mt-1.5">
{value.agent_md !== undefined ? (
<Flex align="center" gap="2">
<code className="text-[12px] text-gray-12 [font-family:var(--font-mono)]">
agent.md
</code>
<Badge color="blue">update</Badge>
</Flex>
) : null}
{value.skills?.map((s) => (
<Flex key={s.id} align="center" gap="2">
<code className="text-[12px] text-gray-12 [font-family:var(--font-mono)]">
skills/{s.id}/SKILL.md
</code>
<Badge color={existing.has(s.id) ? "blue" : "green"}>
{existing.has(s.id) ? "update" : "new"}
</Badge>
</Flex>
))}
</Flex>
</div>
) : null}
{mutation.isError ? (
<Text className="mt-2 block text-(--red-11) text-[12px]">
{mutation.error?.message ?? "Import failed"}
</Text>
) : null}
<Flex justify="end" gap="2" mt="4">
<Button
size="1"
variant="soft"
color="gray"
disabled={mutation.isPending}
onClick={close}
>
Cancel
</Button>
<Button
size="1"
loading={mutation.isPending}
disabled={!value}
onClick={onConfirm}
>
Import
</Button>
</Flex>
</Dialog.Content>
</Dialog.Root>
);
}
Loading
Loading