diff --git a/internal/sync/local/compile.go b/internal/sync/local/compile.go new file mode 100644 index 00000000..083cb447 --- /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 { + 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 errors.Is(err, fs.ErrNotExist) { + return nil, 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..00992e3b --- /dev/null +++ b/internal/sync/local/compile_test.go @@ -0,0 +1,209 @@ +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_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" +} + +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..2e4ef943 --- /dev/null +++ b/internal/sync/local/variation.go @@ -0,0 +1,268 @@ +package local + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "path" + "strings" + + 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"` +} + +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) { + front, body, err := splitFrontMatter(file.Data) + if err != nil { + return syncdomain.SyncedResource{}, err + } + + var meta variationFrontMatter + decoder := yaml.NewDecoder(bytes.NewReader(front)) + decoder.KnownFields(true) + + if err := decoder.Decode(&meta); err != nil { + return syncdomain.SyncedResource{}, fmt.Errorf("invalid front matter: %w", 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 splitFrontMatter(data []byte) (front, body []byte, err error) { + source := bytes.TrimPrefix(data, []byte("\ufeff")) + source = bytes.TrimLeft(source, "\r\n") + + if !bytes.HasPrefix(source, []byte("---")) { + return nil, nil, errors.New("missing YAML front matter") + } + + rest, ok := consumeLineEnding(source[3:]) + if !ok { + return nil, nil, errors.New("missing YAML front matter") + } + + index := bytes.Index(rest, []byte("\n---")) + if index < 0 { + return nil, nil, errors.New("unclosed YAML front matter") + } + + front = bytes.TrimSpace(rest[:index]) + after, ok := consumeLineEnding(rest[index+4:]) + if !ok { + after = nil + } + + return front, bytes.TrimSpace(after), nil +} + +func consumeLineEnding(source []byte) ([]byte, bool) { + if len(source) == 0 { + return source, true + } + if source[0] == '\n' { + return source[1:], true + } + if source[0] == '\r' { + source = source[1:] + if len(source) > 0 && source[0] == '\n' { + source = source[1:] + } + + return source, true + } + + return source, false +} + +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..e0cb7f13 --- /dev/null +++ b/internal/sync/local/variation_test.go @@ -0,0 +1,215 @@ +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_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_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:"-"` +}