Skip to content
Draft
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
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 @@ -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/<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 "./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);
}
});
});
Original file line number Diff line number Diff line change
@@ -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 `--- <path> ---` 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/<id>/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/<id>/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/<id>/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 (
<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
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