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 := "" + 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:"-"` +}