diff --git a/go.mod b/go.mod
index 87c1277d..25483824 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module github.com/launchdarkly/ldcli
go 1.25
require (
+ github.com/adrg/frontmatter v0.2.0
github.com/adrg/xdg v0.5.3
github.com/atotto/clipboard v0.1.4
github.com/blacktop/go-dwarf v1.0.14
@@ -42,6 +43,7 @@ require (
)
require (
+ github.com/BurntSushi/toml v1.3.2 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@@ -106,4 +108,5 @@ require (
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.42.0 // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
)
diff --git a/go.sum b/go.sum
index 7045f51d..d22e401d 100644
--- a/go.sum
+++ b/go.sum
@@ -32,8 +32,12 @@ cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RX
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
+github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
+github.com/adrg/frontmatter v0.2.0 h1:/DgnNe82o03riBd1S+ZDjd43wAmC6W35q67NHeLkPd4=
+github.com/adrg/frontmatter v0.2.0/go.mod h1:93rQCj3z3ZlwyxxpQioRKC1wDLto4aXHrbqIsnH9wmE=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
@@ -648,6 +652,7 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/sync/local/compile.go b/internal/sync/local/compile.go
new file mode 100644
index 00000000..82d1d281
--- /dev/null
+++ b/internal/sync/local/compile.go
@@ -0,0 +1,112 @@
+package local
+
+import (
+ "cmp"
+ "errors"
+ "io/fs"
+ "path"
+ "slices"
+ "strings"
+
+ syncdomain "github.com/launchdarkly/ldcli/internal/sync"
+)
+
+var ErrNoDirectory = errors.New(".launchdarkly directory not found")
+
+type ParseError struct {
+ Path string
+ Err error
+}
+
+func (e ParseError) Error() string {
+ return e.Path + ": " + e.Err.Error()
+}
+
+func (e ParseError) Unwrap() error {
+ return e.Err
+}
+
+func Compile(fsys fs.FS) ([]syncdomain.SyncedResource, error) {
+ entries, err := fs.ReadDir(fsys, syncdomain.RootDir)
+ if errors.Is(err, fs.ErrNotExist) {
+ return nil, ErrNoDirectory
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ var resources []syncdomain.SyncedResource
+
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+
+ variations, err := compileProjectVariations(fsys, entry.Name())
+ if err != nil {
+ return nil, err
+ }
+
+ resources = append(resources, variations...)
+ }
+
+ slices.SortFunc(resources, compareResources)
+
+ return resources, nil
+}
+
+func compileProjectVariations(
+ fsys fs.FS,
+ projectKey string,
+) ([]syncdomain.SyncedResource, error) {
+ dir := path.Join(syncdomain.RootDir, projectKey, configsDir)
+
+ var resources []syncdomain.SyncedResource
+
+ err := fs.WalkDir(fsys, dir, func(name string, entry fs.DirEntry, err error) error {
+ if err != nil {
+ if name == dir && errors.Is(err, fs.ErrNotExist) {
+ return nil
+ }
+ return err
+ }
+ if entry.IsDir() {
+ return nil
+ }
+
+ relPath := strings.TrimPrefix(name, dir+"/")
+ if relPath == name || !isVariationFile(relPath) {
+ return nil
+ }
+
+ data, err := fs.ReadFile(fsys, name)
+ if err != nil {
+ return err
+ }
+
+ resource, err := parseVariation(localFile{
+ ProjectKey: projectKey,
+ RelPath: relPath,
+ Data: data,
+ })
+ if err != nil {
+ return ParseError{Path: name, Err: err}
+ }
+
+ resources = append(resources, resource)
+
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return resources, nil
+}
+
+func compareResources(a, b syncdomain.SyncedResource) int {
+ return cmp.Or(
+ cmp.Compare(a.ProjectKey, b.ProjectKey),
+ cmp.Compare(a.LookupKey, b.LookupKey),
+ )
+}
diff --git a/internal/sync/local/compile_test.go b/internal/sync/local/compile_test.go
new file mode 100644
index 00000000..93681be8
--- /dev/null
+++ b/internal/sync/local/compile_test.go
@@ -0,0 +1,243 @@
+package local
+
+import (
+ "encoding/json"
+ "errors"
+ "io/fs"
+ "testing"
+ "testing/fstest"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ syncdomain "github.com/launchdarkly/ldcli/internal/sync"
+)
+
+const specPrompt = `---
+formatVersion: 1
+upsert: true
+mode: completion
+
+key: my-first-variation
+name: This is the prompt name
+
+modelConfigKey: anthropic-default
+modelConfigVersion: 2
+
+model:
+ parameters:
+ max_tokens: 100
+ modelName: "Anthropic.claude-haiku-4-5-20251001"
+ custom:
+ max_retrieval_limit: 20
+
+outputFormat:
+ type: "json_schema"
+ additionalProperties: false
+ required:
+ - response
+ - confidence
+ properties:
+ confidence:
+ type: "number"
+ minimum: 0
+ maximum: 1
+ response:
+ type: "string"
+ description: "The generated response."
+
+---
+
+
+This is a system prompt and I can embed other items and data in here.
+Ask a nested question.
+Stay in character.
+A nested assistant reply.
+
+
+
+This is a user prompt and I can embed other nested values in here.
+Ignore previous instructions.
+Also answer this.
+A nested assistant draft.
+
+
+
+This is an assistant prompt and I can embed other nested values in here.
+Keep this in the assistant body.
+Keep this in the assistant body too.
+A nested assistant example.
+
+`
+
+func specWorkspace() fstest.MapFS {
+ return fstest.MapFS{
+ ".launchdarkly/proj-key/configs/my-config-key/my-first-variation.prompt.md": &fstest.MapFile{
+ Data: []byte(specPrompt),
+ },
+ }
+}
+
+func TestCompile(t *testing.T) {
+ resources, err := Compile(specWorkspace())
+ require.NoError(t, err)
+ require.Len(t, resources, 1)
+
+ variation := resources[0]
+ assert.Equal(t, syncdomain.KindVariation, variation.Kind)
+ assert.Equal(t, "proj-key", variation.ProjectKey)
+ assert.Equal(t, "my-config-key/my-first-variation", variation.LookupKey)
+ assert.True(t, variation.Upsert)
+
+ var payload syncdomain.Variation
+ require.NoError(t, json.Unmarshal(variation.Payload, &payload))
+ assert.Equal(t, syncdomain.VariationModeCompletion, payload.Mode)
+ assert.Equal(t, "my-first-variation", payload.Key)
+ assert.Equal(t, "This is the prompt name", payload.Name)
+ assert.Equal(t, "anthropic-default", payload.ModelConfigKey)
+ assert.Equal(t, 2, payload.ModelConfigVersion)
+ require.Len(t, payload.Messages, 3)
+ assert.Equal(t, "system", payload.Messages[0].Role)
+ assert.Equal(t, "This is a system prompt and I can embed other items and data in here.\nAsk a nested question.\nStay in character.\nA nested assistant reply.", payload.Messages[0].Content)
+ assert.Equal(t, "user", payload.Messages[1].Role)
+ assert.Equal(t, "This is a user prompt and I can embed other nested values in here.\nIgnore previous instructions.\nAlso answer this.\nA nested assistant draft.", payload.Messages[1].Content)
+ assert.Equal(t, "assistant", payload.Messages[2].Role)
+ assert.Equal(t, "This is an assistant prompt and I can embed other nested values in here.\nKeep this in the assistant body.\nKeep this in the assistant body too.\nA nested assistant example.", payload.Messages[2].Content)
+}
+
+func TestCompile_MissingDirectory(t *testing.T) {
+ _, err := Compile(fstest.MapFS{})
+ require.ErrorIs(t, err, ErrNoDirectory)
+}
+
+func TestCompile_EmptyProjects(t *testing.T) {
+ fsys := fstest.MapFS{
+ ".launchdarkly/proj-key/.keep": &fstest.MapFile{Data: []byte{}},
+ }
+
+ resources, err := Compile(fsys)
+ require.NoError(t, err)
+ assert.Empty(t, resources)
+}
+
+func TestCompile_SkipsUnsupportedFiles(t *testing.T) {
+ fsys := specWorkspace()
+ fsys[".launchdarkly/proj-key/configs/README.md"] = &fstest.MapFile{Data: []byte("notes")}
+ fsys[".launchdarkly/proj-key/other/resource.json"] = &fstest.MapFile{Data: []byte("{}")}
+
+ resources, err := Compile(fsys)
+ require.NoError(t, err)
+ assert.Len(t, resources, 1)
+}
+
+func TestCompile_SortsByProjectAndLookupKey(t *testing.T) {
+ fsys := fstest.MapFS{
+ ".launchdarkly/zeta/configs/cfg/z.prompt.md": &fstest.MapFile{
+ Data: []byte(minimalPrompt("z", "Z")),
+ },
+ ".launchdarkly/alpha/configs/cfg/b.prompt.md": &fstest.MapFile{
+ Data: []byte(minimalPrompt("b", "B")),
+ },
+ ".launchdarkly/alpha/configs/cfg/a.prompt.md": &fstest.MapFile{
+ Data: []byte(minimalPrompt("a", "A")),
+ },
+ }
+
+ resources, err := Compile(fsys)
+ require.NoError(t, err)
+ require.Len(t, resources, 3)
+ assert.Equal(t, []string{"alpha", "alpha", "zeta"}, projectKeys(resources))
+ assert.Equal(t, []string{"cfg/a", "cfg/b", "cfg/z"}, lookupKeys(resources))
+}
+
+func TestCompile_ReturnsFileMissingDuringWalk(t *testing.T) {
+ const missingPath = ".launchdarkly/proj-key/configs/cfg/z.prompt.md"
+ fsys := missingReadFileFS{
+ FS: fstest.MapFS{
+ ".launchdarkly/proj-key/configs/cfg/a.prompt.md": &fstest.MapFile{
+ Data: []byte(minimalPrompt("a", "A")),
+ },
+ missingPath: &fstest.MapFile{
+ Data: []byte(minimalPrompt("z", "Z")),
+ },
+ },
+ path: missingPath,
+ }
+
+ _, err := Compile(fsys)
+
+ require.ErrorIs(t, err, fs.ErrNotExist)
+ var pathErr *fs.PathError
+ require.ErrorAs(t, err, &pathErr)
+ assert.Equal(t, missingPath, pathErr.Path)
+}
+
+func TestCompile_ParseErrorIncludesPath(t *testing.T) {
+ fsys := fstest.MapFS{
+ ".launchdarkly/proj-key/configs/my-config-key/wrong-name.prompt.md": &fstest.MapFile{
+ Data: []byte(minimalPrompt("my-first-variation", "Name")),
+ },
+ }
+
+ _, err := Compile(fsys)
+ require.Error(t, err)
+
+ var parseErr ParseError
+ require.ErrorAs(t, err, &parseErr)
+ assert.Equal(t, ".launchdarkly/proj-key/configs/my-config-key/wrong-name.prompt.md", parseErr.Path)
+ assert.ErrorContains(t, parseErr.Err, `key "my-first-variation" does not match filename "wrong-name"`)
+}
+
+func TestCompile_MissingFrontMatter(t *testing.T) {
+ fsys := fstest.MapFS{
+ ".launchdarkly/proj-key/configs/cfg/var.prompt.md": &fstest.MapFile{
+ Data: []byte("just a prompt"),
+ },
+ }
+
+ _, err := Compile(fsys)
+ require.ErrorContains(t, err, "missing YAML front matter")
+}
+
+func TestErrNoDirectory_Is(t *testing.T) {
+ _, err := Compile(fstest.MapFS{
+ "README.md": &fstest.MapFile{Data: []byte("nope")},
+ })
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, ErrNoDirectory))
+ assert.False(t, errors.Is(err, fs.ErrNotExist))
+}
+
+func minimalPrompt(key, name string) string {
+ return "---\nformatVersion: 1\nmode: completion\nkey: " + key + "\nname: " + name + "\n---\n"
+}
+
+type missingReadFileFS struct {
+ fs.FS
+ path string
+}
+
+func (f missingReadFileFS) ReadFile(name string) ([]byte, error) {
+ if name == f.path {
+ return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
+ }
+ return fs.ReadFile(f.FS, name)
+}
+
+func projectKeys(resources []syncdomain.SyncedResource) []string {
+ keys := make([]string, len(resources))
+ for index, resource := range resources {
+ keys[index] = resource.ProjectKey
+ }
+
+ return keys
+}
+
+func lookupKeys(resources []syncdomain.SyncedResource) []string {
+ keys := make([]string, len(resources))
+ for index, resource := range resources {
+ keys[index] = resource.LookupKey
+ }
+
+ return keys
+}
diff --git a/internal/sync/local/variation.go b/internal/sync/local/variation.go
new file mode 100644
index 00000000..d199eb77
--- /dev/null
+++ b/internal/sync/local/variation.go
@@ -0,0 +1,243 @@
+package local
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "path"
+ "strings"
+
+ "github.com/adrg/frontmatter"
+ syncdomain "github.com/launchdarkly/ldcli/internal/sync"
+ "gopkg.in/yaml.v3"
+)
+
+const (
+ configsDir = "configs"
+ variationFileSuffix = ".prompt.md"
+)
+
+type localFile struct {
+ ProjectKey string
+ RelPath string
+ Data []byte
+}
+
+type variationFrontMatter struct {
+ FormatVersion int `yaml:"formatVersion"`
+ Upsert bool `yaml:"upsert"`
+ syncdomain.Variation `yaml:",inline"`
+}
+
+var yamlFrontMatter = frontmatter.NewFormat("---", "---", func(data []byte, destination any) error {
+ decoder := yaml.NewDecoder(bytes.NewReader(data))
+ decoder.KnownFields(true)
+
+ return decoder.Decode(destination)
+})
+
+func isVariationFile(relPath string) bool {
+ if !strings.HasSuffix(relPath, variationFileSuffix) {
+ return false
+ }
+
+ dir, file := path.Split(relPath)
+ dir = strings.TrimSuffix(dir, "/")
+
+ return dir != "" && !strings.Contains(dir, "/") && file != ""
+}
+
+func parseVariation(file localFile) (syncdomain.SyncedResource, error) {
+ var meta variationFrontMatter
+ body, err := parseYAMLFrontMatter(file.Data, &meta)
+ if err != nil {
+ return syncdomain.SyncedResource{}, err
+ }
+
+ if err := validateVariation(file.RelPath, meta); err != nil {
+ return syncdomain.SyncedResource{}, err
+ }
+
+ variation := meta.Variation
+ switch variation.Mode {
+ case syncdomain.VariationModeAgent:
+ variation.Instructions = strings.TrimSpace(string(body))
+ case syncdomain.VariationModeCompletion:
+ messages, err := parseCompletionMessages(string(body))
+ if err != nil {
+ return syncdomain.SyncedResource{}, err
+ }
+ variation.Messages = messages
+ }
+
+ payload, err := marshalPayload(variation)
+ if err != nil {
+ return syncdomain.SyncedResource{}, err
+ }
+
+ configKey := path.Dir(file.RelPath)
+
+ return syncdomain.SyncedResource{
+ Kind: syncdomain.KindVariation,
+ ProjectKey: file.ProjectKey,
+ LookupKey: configKey + "/" + meta.Key,
+ Payload: payload,
+ Upsert: meta.Upsert,
+ }, nil
+}
+
+func validateVariation(relPath string, meta variationFrontMatter) error {
+ switch {
+ case meta.FormatVersion == 0:
+ return errors.New("formatVersion is required")
+ case meta.FormatVersion != 1:
+ return fmt.Errorf("unsupported formatVersion %d", meta.FormatVersion)
+ case meta.Mode == "":
+ return errors.New("mode is required")
+ case !meta.Mode.Valid():
+ return fmt.Errorf("unsupported mode %q", meta.Mode)
+ case meta.Key == "":
+ return errors.New("key is required")
+ case meta.Name == "":
+ return errors.New("name is required")
+ }
+
+ stem := strings.TrimSuffix(path.Base(relPath), variationFileSuffix)
+ if stem != meta.Key {
+ return fmt.Errorf("key %q does not match filename %q", meta.Key, stem)
+ }
+
+ return nil
+}
+
+func marshalPayload(value any) (json.RawMessage, error) {
+ var buf bytes.Buffer
+ encoder := json.NewEncoder(&buf)
+ encoder.SetEscapeHTML(false)
+
+ if err := encoder.Encode(value); err != nil {
+ return nil, fmt.Errorf("marshal payload: %w", err)
+ }
+
+ return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil
+}
+
+func parseYAMLFrontMatter(data []byte, destination any) ([]byte, error) {
+ source := bytes.TrimLeft(bytes.TrimPrefix(data, []byte("\ufeff")), "\r\n")
+ hasStart := bytes.Equal(source, []byte("---")) ||
+ bytes.HasPrefix(source, []byte("---\n")) ||
+ bytes.HasPrefix(source, []byte("---\r\n"))
+ if !hasStart {
+ return nil, errors.New("missing YAML front matter")
+ }
+
+ body, err := frontmatter.MustParse(bytes.NewReader(source), destination, yamlFrontMatter)
+ if errors.Is(err, frontmatter.ErrNotFound) {
+ return nil, errors.New("unclosed YAML front matter")
+ }
+ if err != nil {
+ return nil, fmt.Errorf("invalid front matter: %w", err)
+ }
+
+ return body, nil
+}
+
+var messageRoles = []string{"system", "user", "assistant"}
+
+func parseCompletionMessages(body string) ([]syncdomain.Message, error) {
+ if strings.TrimSpace(body) == "" {
+ return nil, nil
+ }
+
+ if _, _, _, ok := nextOpenTag(body, 0); !ok {
+ return []syncdomain.Message{{
+ Role: "system",
+ Content: strings.TrimSpace(body),
+ }}, nil
+ }
+
+ var messages []syncdomain.Message
+ cursor := 0
+
+ for cursor < len(body) {
+ start, role, contentStart, ok := nextOpenTag(body, cursor)
+ if !ok {
+ if strings.TrimSpace(body[cursor:]) != "" {
+ return nil, errors.New("unexpected text outside message tags")
+ }
+
+ break
+ }
+ if strings.TrimSpace(body[cursor:start]) != "" {
+ return nil, errors.New("unexpected text outside message tags")
+ }
+
+ contentEnd, closeEnd, found := matchingClose(body, contentStart, role)
+ if !found {
+ return nil, fmt.Errorf("unclosed <%s> tag", role)
+ }
+
+ messages = append(messages, syncdomain.Message{
+ Role: role,
+ Content: strings.TrimSpace(body[contentStart:contentEnd]),
+ })
+ cursor = closeEnd
+ }
+
+ return messages, nil
+}
+
+func nextOpenTag(body string, from int) (start int, role string, contentStart int, ok bool) {
+ start = -1
+
+ for _, candidate := range messageRoles {
+ tag := "<" + candidate + ">"
+ index := strings.Index(body[from:], tag)
+ if index < 0 {
+ continue
+ }
+
+ absolute := from + index
+ if start < 0 || absolute < start {
+ start = absolute
+ role = candidate
+ contentStart = absolute + len(tag)
+ ok = true
+ }
+ }
+
+ return start, role, contentStart, ok
+}
+
+func matchingClose(body string, from int, role string) (contentEnd, closeEnd int, ok bool) {
+ open := "<" + role + ">"
+ close := "" + role + ">"
+ depth := 1
+ index := from
+
+ for index < len(body) {
+ relativeOpen := strings.Index(body[index:], open)
+ relativeClose := strings.Index(body[index:], close)
+ if relativeClose < 0 {
+ return 0, 0, false
+ }
+
+ if relativeOpen >= 0 && relativeOpen < relativeClose {
+ depth++
+ index += relativeOpen + len(open)
+
+ continue
+ }
+
+ depth--
+ closeAt := index + relativeClose
+ if depth == 0 {
+ return closeAt, closeAt + len(close), true
+ }
+
+ index = closeAt + len(close)
+ }
+
+ return 0, 0, false
+}
diff --git a/internal/sync/local/variation_test.go b/internal/sync/local/variation_test.go
new file mode 100644
index 00000000..6b8f949e
--- /dev/null
+++ b/internal/sync/local/variation_test.go
@@ -0,0 +1,256 @@
+package local
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ syncdomain "github.com/launchdarkly/ldcli/internal/sync"
+)
+
+func TestIsVariationFile(t *testing.T) {
+ assert.True(t, isVariationFile("my-config/my-variation.prompt.md"))
+ assert.False(t, isVariationFile("my-variation.prompt.md"))
+ assert.False(t, isVariationFile("my-config/nested/my-variation.prompt.md"))
+ assert.False(t, isVariationFile("my-config/my-variation.prompt"))
+ assert.False(t, isVariationFile("my-config/my-variation.md"))
+}
+
+func TestParseVariation_UntaggedBodyIsSystemMessage(t *testing.T) {
+ file := localFile{
+ ProjectKey: "proj",
+ RelPath: "cfg/plain.prompt.md",
+ Data: []byte(`---
+formatVersion: 1
+mode: completion
+key: plain
+name: Plain
+---
+
+Just say hello.
+`),
+ }
+
+ resource, err := parseVariation(file)
+ require.NoError(t, err)
+
+ var payload syncdomain.Variation
+ require.NoError(t, unmarshalPayload(resource, &payload))
+ require.Equal(
+ t,
+ []syncdomain.Message{{Role: "system", Content: "Just say hello."}},
+ payload.Messages,
+ )
+}
+
+func TestParseVariation_AgentBodyIsInstructions(t *testing.T) {
+ file := localFile{
+ ProjectKey: "proj",
+ RelPath: "cfg/agent.prompt.md",
+ Data: []byte(`---
+formatVersion: 1
+mode: agent
+key: agent
+name: Agent
+---
+
+Use the available capabilities.
+
+This tag is part of the instructions.
+`),
+ }
+
+ resource, err := parseVariation(file)
+ require.NoError(t, err)
+
+ var payload syncdomain.Variation
+ require.NoError(t, unmarshalPayload(resource, &payload))
+ assert.Equal(
+ t,
+ "Use the available capabilities.\n\nThis tag is part of the instructions.",
+ payload.Instructions,
+ )
+ assert.Empty(t, payload.Messages)
+}
+
+func TestParseVariation_ParsesBOMAndCRLF(t *testing.T) {
+ file := localFile{
+ ProjectKey: "proj",
+ RelPath: "cfg/plain.prompt.md",
+ Data: []byte("\ufeff\r\n---\r\n" +
+ "formatVersion: 1\r\n" +
+ "mode: completion\r\n" +
+ "key: plain\r\n" +
+ "name: Plain\r\n" +
+ "---\r\n\r\n" +
+ "Just say hello.\r\n"),
+ }
+
+ resource, err := parseVariation(file)
+ require.NoError(t, err)
+
+ var payload syncdomain.Variation
+ require.NoError(t, unmarshalPayload(resource, &payload))
+ require.Equal(
+ t,
+ []syncdomain.Message{{Role: "system", Content: "Just say hello."}},
+ payload.Messages,
+ )
+}
+
+func TestParseVariation_RejectsBodyFieldsInFrontMatter(t *testing.T) {
+ for _, field := range []string{
+ "instructions: Use the available capabilities.",
+ "messages: []",
+ } {
+ t.Run(field, func(t *testing.T) {
+ file := localFile{
+ ProjectKey: "proj",
+ RelPath: "cfg/agent.prompt.md",
+ Data: []byte(`---
+formatVersion: 1
+mode: agent
+key: agent
+name: Agent
+` + field + `
+---
+`),
+ }
+
+ _, err := parseVariation(file)
+ require.ErrorContains(t, err, "invalid front matter")
+ })
+ }
+}
+
+func TestParseVariation_RejectsUnclosedFrontMatter(t *testing.T) {
+ file := localFile{
+ ProjectKey: "proj",
+ RelPath: "cfg/v.prompt.md",
+ Data: []byte(`---
+formatVersion: 1
+mode: completion
+key: v
+name: V
+`),
+ }
+
+ _, err := parseVariation(file)
+ require.ErrorContains(t, err, "unclosed YAML front matter")
+}
+
+func TestParseVariation_MismatchedTags(t *testing.T) {
+ file := localFile{
+ ProjectKey: "proj",
+ RelPath: "cfg/bad.prompt.md",
+ Data: []byte(`---
+formatVersion: 1
+mode: completion
+key: bad
+name: Bad
+---
+
+
+oops
+
+`),
+ }
+
+ _, err := parseVariation(file)
+ require.ErrorContains(t, err, "unclosed tag")
+}
+
+func TestParseVariation_TextOutsideTags(t *testing.T) {
+ file := localFile{
+ ProjectKey: "proj",
+ RelPath: "cfg/bad.prompt.md",
+ Data: []byte(`---
+formatVersion: 1
+mode: completion
+key: bad
+name: Bad
+---
+
+hello
+
+hi
+
+`),
+ }
+
+ _, err := parseVariation(file)
+ require.ErrorContains(t, err, "unexpected text outside message tags")
+}
+
+func TestParseVariation_RequiresFormatVersion(t *testing.T) {
+ file := localFile{
+ ProjectKey: "proj",
+ RelPath: "cfg/v.prompt.md",
+ Data: []byte(`---
+mode: completion
+key: v
+name: V
+---
+`),
+ }
+
+ _, err := parseVariation(file)
+ require.ErrorContains(t, err, "formatVersion is required")
+}
+
+func TestParseVariation_RequiresMode(t *testing.T) {
+ file := localFile{
+ ProjectKey: "proj",
+ RelPath: "cfg/v.prompt.md",
+ Data: []byte(`---
+formatVersion: 1
+key: v
+name: V
+---
+`),
+ }
+
+ _, err := parseVariation(file)
+ require.ErrorContains(t, err, "mode is required")
+}
+
+func TestParseVariation_RejectsUnsupportedMode(t *testing.T) {
+ file := localFile{
+ ProjectKey: "proj",
+ RelPath: "cfg/v.prompt.md",
+ Data: []byte(`---
+formatVersion: 1
+mode: other
+key: v
+name: V
+---
+`),
+ }
+
+ _, err := parseVariation(file)
+ require.ErrorContains(t, err, `unsupported mode "other"`)
+}
+
+func TestParseVariation_RejectsVariationDescription(t *testing.T) {
+ file := localFile{
+ ProjectKey: "proj",
+ RelPath: "cfg/v.prompt.md",
+ Data: []byte(`---
+formatVersion: 1
+mode: completion
+key: v
+name: V
+description: Variation description
+---
+`),
+ }
+
+ _, err := parseVariation(file)
+ require.ErrorContains(t, err, "invalid front matter")
+}
+
+func unmarshalPayload(resource syncdomain.SyncedResource, destination any) error {
+ return json.Unmarshal(resource.Payload, destination)
+}
diff --git a/internal/sync/resource.go b/internal/sync/resource.go
new file mode 100644
index 00000000..d75c8fb9
--- /dev/null
+++ b/internal/sync/resource.go
@@ -0,0 +1,52 @@
+package sync
+
+import "encoding/json"
+
+const RootDir = ".launchdarkly"
+
+type Kind string
+
+const (
+ KindVariation Kind = "variation"
+)
+
+type SyncedResource struct {
+ Kind Kind
+ ProjectKey string
+ LookupKey string
+ Payload json.RawMessage
+ Upsert bool
+}
+
+type VariationMode string
+
+const (
+ VariationModeAgent VariationMode = "agent"
+ VariationModeCompletion VariationMode = "completion"
+)
+
+func (m VariationMode) Valid() bool {
+ switch m {
+ case VariationModeAgent, VariationModeCompletion:
+ return true
+ default:
+ return false
+ }
+}
+
+type Message struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+}
+
+type Variation struct {
+ Mode VariationMode `json:"mode" yaml:"mode"`
+ Key string `json:"key" yaml:"key"`
+ Name string `json:"name" yaml:"name"`
+ Instructions string `json:"instructions,omitempty" yaml:"-"`
+ ModelConfigKey string `json:"modelConfigKey,omitempty" yaml:"modelConfigKey,omitempty"`
+ ModelConfigVersion int `json:"modelConfigVersion,omitempty" yaml:"modelConfigVersion,omitempty"`
+ Model map[string]any `json:"model,omitempty" yaml:"model,omitempty"`
+ OutputFormat map[string]any `json:"outputFormat,omitempty" yaml:"outputFormat,omitempty"`
+ Messages []Message `json:"messages,omitempty" yaml:"-"`
+}