From 58ebeed969abfeaa43aface5d3466fab0549c35b Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Wed, 9 Sep 2026 18:14:58 -0400 Subject: [PATCH 1/3] feat(sync): parse local Agent Control files into SyncedResource Walk .launchdarkly and compile prompt variations and versioned tools into a common resource with a stable fingerprint. Top-level system, user, and assistant tags become messages; nested tags stay in the prompt body. --- internal/sync/compile.go | 127 +++++++++++++++ internal/sync/compile_test.go | 279 ++++++++++++++++++++++++++++++++ internal/sync/parser.go | 20 +++ internal/sync/resource.go | 47 ++++++ internal/sync/tool.go | 90 +++++++++++ internal/sync/tool_test.go | 78 +++++++++ internal/sync/variation.go | 276 +++++++++++++++++++++++++++++++ internal/sync/variation_test.go | 117 ++++++++++++++ 8 files changed, 1034 insertions(+) create mode 100644 internal/sync/compile.go create mode 100644 internal/sync/compile_test.go create mode 100644 internal/sync/parser.go create mode 100644 internal/sync/resource.go create mode 100644 internal/sync/tool.go create mode 100644 internal/sync/tool_test.go create mode 100644 internal/sync/variation.go create mode 100644 internal/sync/variation_test.go diff --git a/internal/sync/compile.go b/internal/sync/compile.go new file mode 100644 index 00000000..5ed6bea8 --- /dev/null +++ b/internal/sync/compile.go @@ -0,0 +1,127 @@ +package sync + +import ( + "cmp" + "errors" + "io/fs" + "path" + "slices" + "strings" +) + +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) ([]SyncedResource, error) { + return compile(fsys, DefaultParsers()) +} + +func compile(fsys fs.FS, parsers []Parser) ([]SyncedResource, error) { + entries, err := fs.ReadDir(fsys, RootDir) + if errors.Is(err, fs.ErrNotExist) { + return nil, ErrNoDirectory + } + if err != nil { + return nil, err + } + + var resources []SyncedResource + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + parsed, err := compileProject(fsys, entry.Name(), parsers) + if err != nil { + return nil, err + } + + resources = append(resources, parsed...) + } + + slices.SortFunc(resources, compareResources) + + return resources, nil +} + +func compileProject(fsys fs.FS, projectKey string, parsers []Parser) ([]SyncedResource, error) { + var resources []SyncedResource + + for _, parser := range parsers { + parsed, err := compileKind(fsys, projectKey, parser) + if err != nil { + return nil, err + } + + resources = append(resources, parsed...) + } + + return resources, nil +} + +func compileKind(fsys fs.FS, projectKey string, parser Parser) ([]SyncedResource, error) { + dir := path.Join(RootDir, projectKey, parser.Dir()) + + var resources []SyncedResource + + err := fs.WalkDir(fsys, dir, func(name string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + + rel := strings.TrimPrefix(name, dir+"/") + if rel == name || !parser.Accept(rel) { + return nil + } + + data, err := fs.ReadFile(fsys, name) + if err != nil { + return err + } + + resource, err := parser.Parse(File{ + ProjectKey: projectKey, + RelPath: rel, + 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 SyncedResource) int { + return cmp.Or( + cmp.Compare(a.ProjectKey, b.ProjectKey), + cmp.Compare(a.Kind, b.Kind), + cmp.Compare(a.LookupKey, b.LookupKey), + ) +} diff --git a/internal/sync/compile_test.go b/internal/sync/compile_test.go new file mode 100644 index 00000000..dee58011 --- /dev/null +++ b/internal/sync/compile_test.go @@ -0,0 +1,279 @@ +package sync + +import ( + "encoding/json" + "errors" + "io/fs" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const specPrompt = `--- +formatVersion: 1 +upsert: true + +key: my-first-variation +name: This is the prompt name + +modelConfigKey: anthropic-default + +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." + +tools: + - key: test-tool + version: 13 + +--- + + +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. + +` + +const specTool = `{ + "key": "test-tool", + "version": 13, + "schema": { + "type": "object", + "properties": { + "query": { "type": "string" } + } + } +} +` + +func specRepo() fstest.MapFS { + return fstest.MapFS{ + ".launchdarkly/proj-key/configs/my-config-key/my-first-variation.prompt": &fstest.MapFile{ + Data: []byte(specPrompt), + }, + ".launchdarkly/proj-key/tools/test-tool.v13.json": &fstest.MapFile{ + Data: []byte(specTool), + }, + } +} + +func TestCompile(t *testing.T) { + resources, err := Compile(specRepo()) + require.NoError(t, err) + require.Len(t, resources, 2) + + variation := mustResource(t, resources, KindVariation, "my-config-key/my-first-variation") + assert.Equal(t, "proj-key", variation.ProjectKey) + assert.True(t, variation.Upsert) + assert.Equal(t, Hash(variation.Payload), variation.Fingerprint) + + var payload variationPayload + require.NoError(t, json.Unmarshal(variation.Payload, &payload)) + 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, []toolRef{{Key: "test-tool", Version: 13}}, payload.Tools) + 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) + + tool := mustResource(t, resources, KindTool, "test-tool/13") + assert.Equal(t, "proj-key", tool.ProjectKey) + assert.False(t, tool.Upsert) + assert.Equal(t, Hash(tool.Payload), tool.Fingerprint) + + var toolPayload toolFile + require.NoError(t, json.Unmarshal(tool.Payload, &toolPayload)) + assert.Equal(t, "test-tool", toolPayload.Key) + assert.Equal(t, 13, toolPayload.Version) + require.NotNil(t, toolPayload.Schema) +} + +func TestCompile_StableFingerprint(t *testing.T) { + first, err := Compile(specRepo()) + require.NoError(t, err) + + second, err := Compile(specRepo()) + require.NoError(t, err) + + require.Len(t, first, 2) + require.Len(t, second, 2) + assert.Equal(t, first[0].Fingerprint, second[0].Fingerprint) + assert.Equal(t, first[1].Fingerprint, second[1].Fingerprint) +} + +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_SkipsUnknownFiles(t *testing.T) { + fsys := specRepo() + fsys[".launchdarkly/proj-key/configs/README.md"] = &fstest.MapFile{Data: []byte("notes")} + fsys[".launchdarkly/proj-key/tools/notes.txt"] = &fstest.MapFile{Data: []byte("notes")} + + resources, err := Compile(fsys) + require.NoError(t, err) + assert.Len(t, resources, 2) +} + +func TestCompile_SortsByProjectKindAndKey(t *testing.T) { + fsys := fstest.MapFS{ + ".launchdarkly/zeta/tools/zeta-tool.v1.json": &fstest.MapFile{ + Data: []byte(`{"key":"zeta-tool","version":1,"schema":{}}`), + }, + ".launchdarkly/alpha/configs/cfg/b.prompt": &fstest.MapFile{ + Data: []byte(minimalPrompt("b", "B")), + }, + ".launchdarkly/alpha/configs/cfg/a.prompt": &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, []Kind{KindVariation, KindVariation, KindTool}, kinds(resources)) + assert.Equal(t, []string{"cfg/a", "cfg/b", "zeta-tool/1"}, lookupKeys(resources)) +} + +func TestCompile_ParseErrorIncludesPath(t *testing.T) { + fsys := fstest.MapFS{ + ".launchdarkly/proj-key/configs/my-config-key/wrong-name.prompt": &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", 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": &fstest.MapFile{ + Data: []byte("just a prompt"), + }, + } + + _, err := Compile(fsys) + require.ErrorContains(t, err, "missing YAML front matter") +} + +func TestHashPrefix(t *testing.T) { + fp := Hash([]byte(`{"key":"x"}`)) + assert.Regexp(t, `^sha256\.[0-9a-f]{64}$`, string(fp)) +} + +func TestHash_DiffersForDifferentPayloads(t *testing.T) { + assert.NotEqual(t, Hash([]byte(`{"a":1}`)), Hash([]byte(`{"a":2}`))) +} + +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\nkey: " + key + "\nname: " + name + "\n---\n" +} + +func projectKeys(resources []SyncedResource) []string { + keys := make([]string, len(resources)) + for i, r := range resources { + keys[i] = r.ProjectKey + } + + return keys +} + +func kinds(resources []SyncedResource) []Kind { + out := make([]Kind, len(resources)) + for i, r := range resources { + out[i] = r.Kind + } + + return out +} + +func lookupKeys(resources []SyncedResource) []string { + keys := make([]string, len(resources)) + for i, r := range resources { + keys[i] = r.LookupKey + } + + return keys +} + +func mustResource(t *testing.T, resources []SyncedResource, kind Kind, lookupKey string) SyncedResource { + t.Helper() + + for _, resource := range resources { + if resource.Kind == kind && resource.LookupKey == lookupKey { + return resource + } + } + + t.Fatalf("resource %s %s not found", kind, lookupKey) + + return SyncedResource{} +} diff --git a/internal/sync/parser.go b/internal/sync/parser.go new file mode 100644 index 00000000..11cc09f7 --- /dev/null +++ b/internal/sync/parser.go @@ -0,0 +1,20 @@ +package sync + +type File struct { + ProjectKey string + RelPath string + Data []byte +} + +type Parser interface { + Dir() string + Accept(relPath string) bool + Parse(file File) (SyncedResource, error) +} + +func DefaultParsers() []Parser { + return []Parser{ + variationParser{}, + toolParser{}, + } +} diff --git a/internal/sync/resource.go b/internal/sync/resource.go new file mode 100644 index 00000000..b186ed44 --- /dev/null +++ b/internal/sync/resource.go @@ -0,0 +1,47 @@ +package sync + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" +) + +const RootDir = ".launchdarkly" + +type Kind string + +const ( + KindVariation Kind = "variation" + KindTool Kind = "tool" +) + +type Fingerprint string + +func Hash(payload []byte) Fingerprint { + sum := sha256.Sum256(payload) + + return Fingerprint("sha256." + hex.EncodeToString(sum[:])) +} + +type SyncedResource struct { + Kind Kind + ProjectKey string + LookupKey string + Payload json.RawMessage + Fingerprint Fingerprint + Upsert bool +} + +func marshalPayload(v any) (json.RawMessage, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + + if err := enc.Encode(v); err != nil { + return nil, fmt.Errorf("marshal payload: %w", err) + } + + return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil +} diff --git a/internal/sync/tool.go b/internal/sync/tool.go new file mode 100644 index 00000000..d0001384 --- /dev/null +++ b/internal/sync/tool.go @@ -0,0 +1,90 @@ +package sync + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "path" + "regexp" + "strconv" +) + +const toolsDir = "tools" + +var toolFileName = regexp.MustCompile(`^([^/]+)\.v(\d+)\.json$`) + +type toolParser struct{} + +func (toolParser) Dir() string { + return toolsDir +} + +func (toolParser) Accept(relPath string) bool { + return toolFileName.MatchString(relPath) +} + +type toolFile struct { + Key string `json:"key"` + Version int `json:"version"` + Schema any `json:"schema"` +} + +func (toolParser) Parse(file File) (SyncedResource, error) { + var parsed toolFile + dec := json.NewDecoder(bytes.NewReader(file.Data)) + dec.DisallowUnknownFields() + + if err := dec.Decode(&parsed); err != nil { + return SyncedResource{}, fmt.Errorf("invalid tool file: %w", err) + } + + stemKey, stemVersion, err := parseToolFileName(path.Base(file.RelPath)) + if err != nil { + return SyncedResource{}, err + } + + switch { + case parsed.Key == "": + return SyncedResource{}, errors.New("key is required") + case parsed.Version == 0: + return SyncedResource{}, errors.New("version is required") + case parsed.Key != stemKey: + return SyncedResource{}, fmt.Errorf("key %q does not match filename %q", parsed.Key, stemKey) + case parsed.Version != stemVersion: + return SyncedResource{}, fmt.Errorf("version %d does not match filename v%d", parsed.Version, stemVersion) + case parsed.Schema == nil: + return SyncedResource{}, errors.New("schema is required") + } + + payload, err := marshalPayload(toolFile{ + Key: parsed.Key, + Version: parsed.Version, + Schema: parsed.Schema, + }) + if err != nil { + return SyncedResource{}, err + } + + return SyncedResource{ + Kind: KindTool, + ProjectKey: file.ProjectKey, + LookupKey: fmt.Sprintf("%s/%d", parsed.Key, parsed.Version), + Payload: payload, + Fingerprint: Hash(payload), + }, nil +} + +func parseToolFileName(name string) (string, int, error) { + matches := toolFileName.FindStringSubmatch(name) + if matches == nil { + return "", 0, fmt.Errorf("invalid tool filename %q", name) + } + + version, err := strconv.Atoi(matches[2]) + if err != nil { + return "", 0, fmt.Errorf("invalid tool filename %q", name) + } + + return matches[1], version, nil +} diff --git a/internal/sync/tool_test.go b/internal/sync/tool_test.go new file mode 100644 index 00000000..a3bd3877 --- /dev/null +++ b/internal/sync/tool_test.go @@ -0,0 +1,78 @@ +package sync + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestToolParser_Accept(t *testing.T) { + p := toolParser{} + + assert.True(t, p.Accept("test-tool.v13.json")) + assert.False(t, p.Accept("nested/test-tool.v13.json")) + assert.False(t, p.Accept("test-tool.json")) + assert.False(t, p.Accept("test-tool.v13.yaml")) +} + +func TestToolParser_CanonicalizesSchema(t *testing.T) { + file := File{ + ProjectKey: "proj", + RelPath: "search.v2.json", + Data: []byte(`{ + "key": "search", + "version": 2, + "schema": { "b": 1, "a": 2 } +}`), + } + + first, err := toolParser{}.Parse(file) + require.NoError(t, err) + + second, err := toolParser{}.Parse(File{ + ProjectKey: "proj", + RelPath: "search.v2.json", + Data: []byte(`{"schema":{"a":2,"b":1},"version":2,"key":"search"}`), + }) + require.NoError(t, err) + + assert.Equal(t, first.Fingerprint, second.Fingerprint) + assert.JSONEq(t, `{"key":"search","version":2,"schema":{"a":2,"b":1}}`, string(first.Payload)) +} + +func TestToolParser_KeyMismatch(t *testing.T) { + _, err := toolParser{}.Parse(File{ + ProjectKey: "proj", + RelPath: "search.v2.json", + Data: []byte(`{"key":"other","version":2,"schema":{}}`), + }) + require.ErrorContains(t, err, `key "other" does not match filename "search"`) +} + +func TestToolParser_VersionMismatch(t *testing.T) { + _, err := toolParser{}.Parse(File{ + ProjectKey: "proj", + RelPath: "search.v2.json", + Data: []byte(`{"key":"search","version":3,"schema":{}}`), + }) + require.ErrorContains(t, err, "version 3 does not match filename v2") +} + +func TestToolParser_RejectsUnknownFields(t *testing.T) { + _, err := toolParser{}.Parse(File{ + ProjectKey: "proj", + RelPath: "search.v2.json", + Data: []byte(`{"key":"search","version":2,"schema":{},"extra":true}`), + }) + require.ErrorContains(t, err, "invalid tool file") +} + +func TestToolParser_RequiresSchema(t *testing.T) { + _, err := toolParser{}.Parse(File{ + ProjectKey: "proj", + RelPath: "search.v2.json", + Data: []byte(`{"key":"search","version":2}`), + }) + require.ErrorContains(t, err, "schema is required") +} diff --git a/internal/sync/variation.go b/internal/sync/variation.go new file mode 100644 index 00000000..58063fa3 --- /dev/null +++ b/internal/sync/variation.go @@ -0,0 +1,276 @@ +package sync + +import ( + "bytes" + "errors" + "fmt" + "path" + "strings" + + "gopkg.in/yaml.v3" +) + +const configsDir = "configs" + +type variationParser struct{} + +func (variationParser) Dir() string { + return configsDir +} + +func (variationParser) Accept(relPath string) bool { + if path.Ext(relPath) != ".prompt" { + return false + } + + dir, file := path.Split(relPath) + dir = strings.TrimSuffix(dir, "/") + + return dir != "" && !strings.Contains(dir, "/") && file != "" +} + +type variationFrontMatter struct { + FormatVersion int `yaml:"formatVersion"` + Upsert bool `yaml:"upsert"` + Key string `yaml:"key"` + Name string `yaml:"name"` + ModelConfigKey string `yaml:"modelConfigKey"` + Model map[string]any `yaml:"model"` + OutputFormat map[string]any `yaml:"outputFormat"` + Tools []toolRef `yaml:"tools"` +} + +type toolRef struct { + Key string `json:"key" yaml:"key"` + Version int `json:"version" yaml:"version"` +} + +type message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type variationPayload struct { + Key string `json:"key"` + Name string `json:"name"` + ModelConfigKey string `json:"modelConfigKey,omitempty"` + Model map[string]any `json:"model,omitempty"` + OutputFormat map[string]any `json:"outputFormat,omitempty"` + Tools []toolRef `json:"tools,omitempty"` + Messages []message `json:"messages,omitempty"` +} + +func (variationParser) Parse(file File) (SyncedResource, error) { + front, body, err := splitFrontMatter(file.Data) + if err != nil { + return SyncedResource{}, err + } + + var meta variationFrontMatter + dec := yaml.NewDecoder(bytes.NewReader(front)) + dec.KnownFields(true) + + if err := dec.Decode(&meta); err != nil { + return SyncedResource{}, fmt.Errorf("invalid front matter: %w", err) + } + + if err := validateVariation(file.RelPath, meta); err != nil { + return SyncedResource{}, err + } + + messages, err := parseMessages(string(body)) + if err != nil { + return SyncedResource{}, err + } + + payload, err := marshalPayload(variationPayload{ + Key: meta.Key, + Name: meta.Name, + ModelConfigKey: meta.ModelConfigKey, + Model: meta.Model, + OutputFormat: meta.OutputFormat, + Tools: meta.Tools, + Messages: messages, + }) + if err != nil { + return SyncedResource{}, err + } + + configKey := path.Dir(file.RelPath) + + return SyncedResource{ + Kind: KindVariation, + ProjectKey: file.ProjectKey, + LookupKey: configKey + "/" + meta.Key, + Payload: payload, + Fingerprint: Hash(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.Key == "": + return errors.New("key is required") + case meta.Name == "": + return errors.New("name is required") + } + + stem := strings.TrimSuffix(path.Base(relPath), ".prompt") + if stem != meta.Key { + return fmt.Errorf("key %q does not match filename %q", meta.Key, stem) + } + + return nil +} + +func splitFrontMatter(data []byte) (front, body []byte, err error) { + // Drop a leading BOM and blank lines so --- is the first real token. + s := bytes.TrimPrefix(data, []byte("\ufeff")) + s = bytes.TrimLeft(s, "\r\n") + + // Opening fence must be --- on its own line, not ---key: value. + if !bytes.HasPrefix(s, []byte("---")) { + return nil, nil, errors.New("missing YAML front matter") + } + + rest, ok := consumeLineEnding(s[3:]) + if !ok { + return nil, nil, errors.New("missing YAML front matter") + } + + // Closing fence is the first \n--- after the YAML block. + idx := bytes.Index(rest, []byte("\n---")) + if idx < 0 { + return nil, nil, errors.New("unclosed YAML front matter") + } + + front = bytes.TrimSpace(rest[:idx]) + // Skip the line ending after the closing ---; leftover bytes are the prompt body. + after, ok := consumeLineEnding(rest[idx+4:]) + if !ok { + after = nil + } + + return front, bytes.TrimSpace(after), nil +} + +func consumeLineEnding(s []byte) ([]byte, bool) { + // EOF after --- is a valid end of line (file ends on the fence). + if len(s) == 0 { + return s, true + } + if s[0] == '\n' { + return s[1:], true + } + // Accept \r and \r\n so Windows and old Mac files parse the same way. + if s[0] == '\r' { + s = s[1:] + if len(s) > 0 && s[0] == '\n' { + s = s[1:] + } + + return s, true + } + + // Next byte is content, so --- was not a fence on its own line. + return s, false +} + +var messageRoles = []string{"system", "user", "assistant"} + +func parseMessages(body string) ([]message, error) { + if strings.TrimSpace(body) == "" { + return nil, nil + } + + if _, _, _, ok := nextOpenTag(body, 0); !ok { + return []message{{Role: "system", Content: strings.TrimSpace(body)}}, nil + } + + var messages []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, 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 + ">" + i := strings.Index(body[from:], tag) + if i < 0 { + continue + } + + abs := from + i + if start < 0 || abs < start { + start = abs + role = candidate + contentStart = abs + 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 + i := from + + for i < len(body) { + relOpen := strings.Index(body[i:], open) + relClose := strings.Index(body[i:], close) + if relClose < 0 { + return 0, 0, false + } + + if relOpen >= 0 && relOpen < relClose { + depth++ + i += relOpen + len(open) + continue + } + + depth-- + closeAt := i + relClose + if depth == 0 { + return closeAt, closeAt + len(close), true + } + + i = closeAt + len(close) + } + + return 0, 0, false +} diff --git a/internal/sync/variation_test.go b/internal/sync/variation_test.go new file mode 100644 index 00000000..2a10eb1e --- /dev/null +++ b/internal/sync/variation_test.go @@ -0,0 +1,117 @@ +package sync + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVariationParser_Accept(t *testing.T) { + p := variationParser{} + + assert.True(t, p.Accept("my-config/my-variation.prompt")) + assert.False(t, p.Accept("my-variation.prompt")) + assert.False(t, p.Accept("my-config/nested/my-variation.prompt")) + assert.False(t, p.Accept("my-config/my-variation.md")) +} + +func TestVariationParser_UntaggedBodyIsSystemMessage(t *testing.T) { + file := File{ + ProjectKey: "proj", + RelPath: "cfg/plain.prompt", + Data: []byte(`--- +formatVersion: 1 +key: plain +name: Plain +--- + +Just say hello. +`), + } + + resource, err := variationParser{}.Parse(file) + require.NoError(t, err) + + var payload variationPayload + require.NoError(t, unmarshalPayload(resource, &payload)) + require.Equal(t, []message{{Role: "system", Content: "Just say hello."}}, payload.Messages) +} + +func TestVariationParser_MismatchedTags(t *testing.T) { + file := File{ + ProjectKey: "proj", + RelPath: "cfg/bad.prompt", + Data: []byte(`--- +formatVersion: 1 +key: bad +name: Bad +--- + + +oops + +`), + } + + _, err := variationParser{}.Parse(file) + require.ErrorContains(t, err, "unclosed tag") +} + +func TestVariationParser_TextOutsideTags(t *testing.T) { + file := File{ + ProjectKey: "proj", + RelPath: "cfg/bad.prompt", + Data: []byte(`--- +formatVersion: 1 +key: bad +name: Bad +--- + +hello + +hi + +`), + } + + _, err := variationParser{}.Parse(file) + require.ErrorContains(t, err, "unexpected text outside message tags") +} + +func TestVariationParser_RequiresFormatVersion(t *testing.T) { + file := File{ + ProjectKey: "proj", + RelPath: "cfg/v.prompt", + Data: []byte(`--- +key: v +name: V +--- +`), + } + + _, err := variationParser{}.Parse(file) + require.ErrorContains(t, err, "formatVersion is required") +} + +func TestVariationParser_RejectsUnknownFrontMatter(t *testing.T) { + file := File{ + ProjectKey: "proj", + RelPath: "cfg/v.prompt", + Data: []byte(`--- +formatVersion: 1 +key: v +name: V +mystery: true +--- +`), + } + + _, err := variationParser{}.Parse(file) + require.ErrorContains(t, err, "invalid front matter") +} + +func unmarshalPayload(resource SyncedResource, dest any) error { + return json.Unmarshal(resource.Payload, dest) +} From dea9847d11c9f003976f97850b3e2f44497102f5 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Fri, 11 Sep 2026 18:55:09 -0400 Subject: [PATCH 2/3] refactor(sync): parse prompt variation files --- internal/sync/compile.go | 127 ------------ internal/sync/compile_test.go | 279 -------------------------- internal/sync/local/variation.go | 268 +++++++++++++++++++++++++ internal/sync/local/variation_test.go | 215 ++++++++++++++++++++ internal/sync/parser.go | 20 -- internal/sync/resource.go | 64 +++--- internal/sync/tool.go | 90 --------- internal/sync/tool_test.go | 78 ------- internal/sync/variation.go | 276 ------------------------- internal/sync/variation_test.go | 117 ----------- 10 files changed, 518 insertions(+), 1016 deletions(-) delete mode 100644 internal/sync/compile.go delete mode 100644 internal/sync/compile_test.go create mode 100644 internal/sync/local/variation.go create mode 100644 internal/sync/local/variation_test.go delete mode 100644 internal/sync/parser.go delete mode 100644 internal/sync/tool.go delete mode 100644 internal/sync/tool_test.go delete mode 100644 internal/sync/variation.go delete mode 100644 internal/sync/variation_test.go diff --git a/internal/sync/compile.go b/internal/sync/compile.go deleted file mode 100644 index 5ed6bea8..00000000 --- a/internal/sync/compile.go +++ /dev/null @@ -1,127 +0,0 @@ -package sync - -import ( - "cmp" - "errors" - "io/fs" - "path" - "slices" - "strings" -) - -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) ([]SyncedResource, error) { - return compile(fsys, DefaultParsers()) -} - -func compile(fsys fs.FS, parsers []Parser) ([]SyncedResource, error) { - entries, err := fs.ReadDir(fsys, RootDir) - if errors.Is(err, fs.ErrNotExist) { - return nil, ErrNoDirectory - } - if err != nil { - return nil, err - } - - var resources []SyncedResource - - for _, entry := range entries { - if !entry.IsDir() { - continue - } - - parsed, err := compileProject(fsys, entry.Name(), parsers) - if err != nil { - return nil, err - } - - resources = append(resources, parsed...) - } - - slices.SortFunc(resources, compareResources) - - return resources, nil -} - -func compileProject(fsys fs.FS, projectKey string, parsers []Parser) ([]SyncedResource, error) { - var resources []SyncedResource - - for _, parser := range parsers { - parsed, err := compileKind(fsys, projectKey, parser) - if err != nil { - return nil, err - } - - resources = append(resources, parsed...) - } - - return resources, nil -} - -func compileKind(fsys fs.FS, projectKey string, parser Parser) ([]SyncedResource, error) { - dir := path.Join(RootDir, projectKey, parser.Dir()) - - var resources []SyncedResource - - err := fs.WalkDir(fsys, dir, func(name string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - return nil - } - - rel := strings.TrimPrefix(name, dir+"/") - if rel == name || !parser.Accept(rel) { - return nil - } - - data, err := fs.ReadFile(fsys, name) - if err != nil { - return err - } - - resource, err := parser.Parse(File{ - ProjectKey: projectKey, - RelPath: rel, - 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 SyncedResource) int { - return cmp.Or( - cmp.Compare(a.ProjectKey, b.ProjectKey), - cmp.Compare(a.Kind, b.Kind), - cmp.Compare(a.LookupKey, b.LookupKey), - ) -} diff --git a/internal/sync/compile_test.go b/internal/sync/compile_test.go deleted file mode 100644 index dee58011..00000000 --- a/internal/sync/compile_test.go +++ /dev/null @@ -1,279 +0,0 @@ -package sync - -import ( - "encoding/json" - "errors" - "io/fs" - "testing" - "testing/fstest" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -const specPrompt = `--- -formatVersion: 1 -upsert: true - -key: my-first-variation -name: This is the prompt name - -modelConfigKey: anthropic-default - -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." - -tools: - - key: test-tool - version: 13 - ---- - - -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. - -` - -const specTool = `{ - "key": "test-tool", - "version": 13, - "schema": { - "type": "object", - "properties": { - "query": { "type": "string" } - } - } -} -` - -func specRepo() fstest.MapFS { - return fstest.MapFS{ - ".launchdarkly/proj-key/configs/my-config-key/my-first-variation.prompt": &fstest.MapFile{ - Data: []byte(specPrompt), - }, - ".launchdarkly/proj-key/tools/test-tool.v13.json": &fstest.MapFile{ - Data: []byte(specTool), - }, - } -} - -func TestCompile(t *testing.T) { - resources, err := Compile(specRepo()) - require.NoError(t, err) - require.Len(t, resources, 2) - - variation := mustResource(t, resources, KindVariation, "my-config-key/my-first-variation") - assert.Equal(t, "proj-key", variation.ProjectKey) - assert.True(t, variation.Upsert) - assert.Equal(t, Hash(variation.Payload), variation.Fingerprint) - - var payload variationPayload - require.NoError(t, json.Unmarshal(variation.Payload, &payload)) - 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, []toolRef{{Key: "test-tool", Version: 13}}, payload.Tools) - 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) - - tool := mustResource(t, resources, KindTool, "test-tool/13") - assert.Equal(t, "proj-key", tool.ProjectKey) - assert.False(t, tool.Upsert) - assert.Equal(t, Hash(tool.Payload), tool.Fingerprint) - - var toolPayload toolFile - require.NoError(t, json.Unmarshal(tool.Payload, &toolPayload)) - assert.Equal(t, "test-tool", toolPayload.Key) - assert.Equal(t, 13, toolPayload.Version) - require.NotNil(t, toolPayload.Schema) -} - -func TestCompile_StableFingerprint(t *testing.T) { - first, err := Compile(specRepo()) - require.NoError(t, err) - - second, err := Compile(specRepo()) - require.NoError(t, err) - - require.Len(t, first, 2) - require.Len(t, second, 2) - assert.Equal(t, first[0].Fingerprint, second[0].Fingerprint) - assert.Equal(t, first[1].Fingerprint, second[1].Fingerprint) -} - -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_SkipsUnknownFiles(t *testing.T) { - fsys := specRepo() - fsys[".launchdarkly/proj-key/configs/README.md"] = &fstest.MapFile{Data: []byte("notes")} - fsys[".launchdarkly/proj-key/tools/notes.txt"] = &fstest.MapFile{Data: []byte("notes")} - - resources, err := Compile(fsys) - require.NoError(t, err) - assert.Len(t, resources, 2) -} - -func TestCompile_SortsByProjectKindAndKey(t *testing.T) { - fsys := fstest.MapFS{ - ".launchdarkly/zeta/tools/zeta-tool.v1.json": &fstest.MapFile{ - Data: []byte(`{"key":"zeta-tool","version":1,"schema":{}}`), - }, - ".launchdarkly/alpha/configs/cfg/b.prompt": &fstest.MapFile{ - Data: []byte(minimalPrompt("b", "B")), - }, - ".launchdarkly/alpha/configs/cfg/a.prompt": &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, []Kind{KindVariation, KindVariation, KindTool}, kinds(resources)) - assert.Equal(t, []string{"cfg/a", "cfg/b", "zeta-tool/1"}, lookupKeys(resources)) -} - -func TestCompile_ParseErrorIncludesPath(t *testing.T) { - fsys := fstest.MapFS{ - ".launchdarkly/proj-key/configs/my-config-key/wrong-name.prompt": &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", 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": &fstest.MapFile{ - Data: []byte("just a prompt"), - }, - } - - _, err := Compile(fsys) - require.ErrorContains(t, err, "missing YAML front matter") -} - -func TestHashPrefix(t *testing.T) { - fp := Hash([]byte(`{"key":"x"}`)) - assert.Regexp(t, `^sha256\.[0-9a-f]{64}$`, string(fp)) -} - -func TestHash_DiffersForDifferentPayloads(t *testing.T) { - assert.NotEqual(t, Hash([]byte(`{"a":1}`)), Hash([]byte(`{"a":2}`))) -} - -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\nkey: " + key + "\nname: " + name + "\n---\n" -} - -func projectKeys(resources []SyncedResource) []string { - keys := make([]string, len(resources)) - for i, r := range resources { - keys[i] = r.ProjectKey - } - - return keys -} - -func kinds(resources []SyncedResource) []Kind { - out := make([]Kind, len(resources)) - for i, r := range resources { - out[i] = r.Kind - } - - return out -} - -func lookupKeys(resources []SyncedResource) []string { - keys := make([]string, len(resources)) - for i, r := range resources { - keys[i] = r.LookupKey - } - - return keys -} - -func mustResource(t *testing.T, resources []SyncedResource, kind Kind, lookupKey string) SyncedResource { - t.Helper() - - for _, resource := range resources { - if resource.Kind == kind && resource.LookupKey == lookupKey { - return resource - } - } - - t.Fatalf("resource %s %s not found", kind, lookupKey) - - return SyncedResource{} -} 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..77bbead8 --- /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_RejectsUnknownFrontMatter(t *testing.T) { + file := localFile{ + ProjectKey: "proj", + RelPath: "cfg/v.prompt.md", + Data: []byte(`--- +formatVersion: 1 +mode: completion +key: v +name: V +mystery: true +--- +`), + } + + _, 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/parser.go b/internal/sync/parser.go deleted file mode 100644 index 11cc09f7..00000000 --- a/internal/sync/parser.go +++ /dev/null @@ -1,20 +0,0 @@ -package sync - -type File struct { - ProjectKey string - RelPath string - Data []byte -} - -type Parser interface { - Dir() string - Accept(relPath string) bool - Parse(file File) (SyncedResource, error) -} - -func DefaultParsers() []Parser { - return []Parser{ - variationParser{}, - toolParser{}, - } -} diff --git a/internal/sync/resource.go b/internal/sync/resource.go index b186ed44..c87adc0f 100644 --- a/internal/sync/resource.go +++ b/internal/sync/resource.go @@ -1,12 +1,6 @@ package sync -import ( - "bytes" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" -) +import "encoding/json" const RootDir = ".launchdarkly" @@ -14,34 +8,46 @@ type Kind string const ( KindVariation Kind = "variation" - KindTool Kind = "tool" ) -type Fingerprint string - -func Hash(payload []byte) Fingerprint { - sum := sha256.Sum256(payload) - - return Fingerprint("sha256." + hex.EncodeToString(sum[:])) -} - type SyncedResource struct { - Kind Kind - ProjectKey string - LookupKey string - Payload json.RawMessage - Fingerprint Fingerprint - Upsert bool + Kind Kind + ProjectKey string + LookupKey string + Payload json.RawMessage + Upsert bool } -func marshalPayload(v any) (json.RawMessage, error) { - var buf bytes.Buffer - enc := json.NewEncoder(&buf) - enc.SetEscapeHTML(false) +type VariationMode string - if err := enc.Encode(v); err != nil { - return nil, fmt.Errorf("marshal payload: %w", err) +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"` +} - return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil +type Variation struct { + Mode VariationMode `json:"mode" yaml:"mode"` + Key string `json:"key" yaml:"key"` + Name string `json:"name" yaml:"name"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + 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:"-"` } diff --git a/internal/sync/tool.go b/internal/sync/tool.go deleted file mode 100644 index d0001384..00000000 --- a/internal/sync/tool.go +++ /dev/null @@ -1,90 +0,0 @@ -package sync - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "path" - "regexp" - "strconv" -) - -const toolsDir = "tools" - -var toolFileName = regexp.MustCompile(`^([^/]+)\.v(\d+)\.json$`) - -type toolParser struct{} - -func (toolParser) Dir() string { - return toolsDir -} - -func (toolParser) Accept(relPath string) bool { - return toolFileName.MatchString(relPath) -} - -type toolFile struct { - Key string `json:"key"` - Version int `json:"version"` - Schema any `json:"schema"` -} - -func (toolParser) Parse(file File) (SyncedResource, error) { - var parsed toolFile - dec := json.NewDecoder(bytes.NewReader(file.Data)) - dec.DisallowUnknownFields() - - if err := dec.Decode(&parsed); err != nil { - return SyncedResource{}, fmt.Errorf("invalid tool file: %w", err) - } - - stemKey, stemVersion, err := parseToolFileName(path.Base(file.RelPath)) - if err != nil { - return SyncedResource{}, err - } - - switch { - case parsed.Key == "": - return SyncedResource{}, errors.New("key is required") - case parsed.Version == 0: - return SyncedResource{}, errors.New("version is required") - case parsed.Key != stemKey: - return SyncedResource{}, fmt.Errorf("key %q does not match filename %q", parsed.Key, stemKey) - case parsed.Version != stemVersion: - return SyncedResource{}, fmt.Errorf("version %d does not match filename v%d", parsed.Version, stemVersion) - case parsed.Schema == nil: - return SyncedResource{}, errors.New("schema is required") - } - - payload, err := marshalPayload(toolFile{ - Key: parsed.Key, - Version: parsed.Version, - Schema: parsed.Schema, - }) - if err != nil { - return SyncedResource{}, err - } - - return SyncedResource{ - Kind: KindTool, - ProjectKey: file.ProjectKey, - LookupKey: fmt.Sprintf("%s/%d", parsed.Key, parsed.Version), - Payload: payload, - Fingerprint: Hash(payload), - }, nil -} - -func parseToolFileName(name string) (string, int, error) { - matches := toolFileName.FindStringSubmatch(name) - if matches == nil { - return "", 0, fmt.Errorf("invalid tool filename %q", name) - } - - version, err := strconv.Atoi(matches[2]) - if err != nil { - return "", 0, fmt.Errorf("invalid tool filename %q", name) - } - - return matches[1], version, nil -} diff --git a/internal/sync/tool_test.go b/internal/sync/tool_test.go deleted file mode 100644 index a3bd3877..00000000 --- a/internal/sync/tool_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package sync - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestToolParser_Accept(t *testing.T) { - p := toolParser{} - - assert.True(t, p.Accept("test-tool.v13.json")) - assert.False(t, p.Accept("nested/test-tool.v13.json")) - assert.False(t, p.Accept("test-tool.json")) - assert.False(t, p.Accept("test-tool.v13.yaml")) -} - -func TestToolParser_CanonicalizesSchema(t *testing.T) { - file := File{ - ProjectKey: "proj", - RelPath: "search.v2.json", - Data: []byte(`{ - "key": "search", - "version": 2, - "schema": { "b": 1, "a": 2 } -}`), - } - - first, err := toolParser{}.Parse(file) - require.NoError(t, err) - - second, err := toolParser{}.Parse(File{ - ProjectKey: "proj", - RelPath: "search.v2.json", - Data: []byte(`{"schema":{"a":2,"b":1},"version":2,"key":"search"}`), - }) - require.NoError(t, err) - - assert.Equal(t, first.Fingerprint, second.Fingerprint) - assert.JSONEq(t, `{"key":"search","version":2,"schema":{"a":2,"b":1}}`, string(first.Payload)) -} - -func TestToolParser_KeyMismatch(t *testing.T) { - _, err := toolParser{}.Parse(File{ - ProjectKey: "proj", - RelPath: "search.v2.json", - Data: []byte(`{"key":"other","version":2,"schema":{}}`), - }) - require.ErrorContains(t, err, `key "other" does not match filename "search"`) -} - -func TestToolParser_VersionMismatch(t *testing.T) { - _, err := toolParser{}.Parse(File{ - ProjectKey: "proj", - RelPath: "search.v2.json", - Data: []byte(`{"key":"search","version":3,"schema":{}}`), - }) - require.ErrorContains(t, err, "version 3 does not match filename v2") -} - -func TestToolParser_RejectsUnknownFields(t *testing.T) { - _, err := toolParser{}.Parse(File{ - ProjectKey: "proj", - RelPath: "search.v2.json", - Data: []byte(`{"key":"search","version":2,"schema":{},"extra":true}`), - }) - require.ErrorContains(t, err, "invalid tool file") -} - -func TestToolParser_RequiresSchema(t *testing.T) { - _, err := toolParser{}.Parse(File{ - ProjectKey: "proj", - RelPath: "search.v2.json", - Data: []byte(`{"key":"search","version":2}`), - }) - require.ErrorContains(t, err, "schema is required") -} diff --git a/internal/sync/variation.go b/internal/sync/variation.go deleted file mode 100644 index 58063fa3..00000000 --- a/internal/sync/variation.go +++ /dev/null @@ -1,276 +0,0 @@ -package sync - -import ( - "bytes" - "errors" - "fmt" - "path" - "strings" - - "gopkg.in/yaml.v3" -) - -const configsDir = "configs" - -type variationParser struct{} - -func (variationParser) Dir() string { - return configsDir -} - -func (variationParser) Accept(relPath string) bool { - if path.Ext(relPath) != ".prompt" { - return false - } - - dir, file := path.Split(relPath) - dir = strings.TrimSuffix(dir, "/") - - return dir != "" && !strings.Contains(dir, "/") && file != "" -} - -type variationFrontMatter struct { - FormatVersion int `yaml:"formatVersion"` - Upsert bool `yaml:"upsert"` - Key string `yaml:"key"` - Name string `yaml:"name"` - ModelConfigKey string `yaml:"modelConfigKey"` - Model map[string]any `yaml:"model"` - OutputFormat map[string]any `yaml:"outputFormat"` - Tools []toolRef `yaml:"tools"` -} - -type toolRef struct { - Key string `json:"key" yaml:"key"` - Version int `json:"version" yaml:"version"` -} - -type message struct { - Role string `json:"role"` - Content string `json:"content"` -} - -type variationPayload struct { - Key string `json:"key"` - Name string `json:"name"` - ModelConfigKey string `json:"modelConfigKey,omitempty"` - Model map[string]any `json:"model,omitempty"` - OutputFormat map[string]any `json:"outputFormat,omitempty"` - Tools []toolRef `json:"tools,omitempty"` - Messages []message `json:"messages,omitempty"` -} - -func (variationParser) Parse(file File) (SyncedResource, error) { - front, body, err := splitFrontMatter(file.Data) - if err != nil { - return SyncedResource{}, err - } - - var meta variationFrontMatter - dec := yaml.NewDecoder(bytes.NewReader(front)) - dec.KnownFields(true) - - if err := dec.Decode(&meta); err != nil { - return SyncedResource{}, fmt.Errorf("invalid front matter: %w", err) - } - - if err := validateVariation(file.RelPath, meta); err != nil { - return SyncedResource{}, err - } - - messages, err := parseMessages(string(body)) - if err != nil { - return SyncedResource{}, err - } - - payload, err := marshalPayload(variationPayload{ - Key: meta.Key, - Name: meta.Name, - ModelConfigKey: meta.ModelConfigKey, - Model: meta.Model, - OutputFormat: meta.OutputFormat, - Tools: meta.Tools, - Messages: messages, - }) - if err != nil { - return SyncedResource{}, err - } - - configKey := path.Dir(file.RelPath) - - return SyncedResource{ - Kind: KindVariation, - ProjectKey: file.ProjectKey, - LookupKey: configKey + "/" + meta.Key, - Payload: payload, - Fingerprint: Hash(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.Key == "": - return errors.New("key is required") - case meta.Name == "": - return errors.New("name is required") - } - - stem := strings.TrimSuffix(path.Base(relPath), ".prompt") - if stem != meta.Key { - return fmt.Errorf("key %q does not match filename %q", meta.Key, stem) - } - - return nil -} - -func splitFrontMatter(data []byte) (front, body []byte, err error) { - // Drop a leading BOM and blank lines so --- is the first real token. - s := bytes.TrimPrefix(data, []byte("\ufeff")) - s = bytes.TrimLeft(s, "\r\n") - - // Opening fence must be --- on its own line, not ---key: value. - if !bytes.HasPrefix(s, []byte("---")) { - return nil, nil, errors.New("missing YAML front matter") - } - - rest, ok := consumeLineEnding(s[3:]) - if !ok { - return nil, nil, errors.New("missing YAML front matter") - } - - // Closing fence is the first \n--- after the YAML block. - idx := bytes.Index(rest, []byte("\n---")) - if idx < 0 { - return nil, nil, errors.New("unclosed YAML front matter") - } - - front = bytes.TrimSpace(rest[:idx]) - // Skip the line ending after the closing ---; leftover bytes are the prompt body. - after, ok := consumeLineEnding(rest[idx+4:]) - if !ok { - after = nil - } - - return front, bytes.TrimSpace(after), nil -} - -func consumeLineEnding(s []byte) ([]byte, bool) { - // EOF after --- is a valid end of line (file ends on the fence). - if len(s) == 0 { - return s, true - } - if s[0] == '\n' { - return s[1:], true - } - // Accept \r and \r\n so Windows and old Mac files parse the same way. - if s[0] == '\r' { - s = s[1:] - if len(s) > 0 && s[0] == '\n' { - s = s[1:] - } - - return s, true - } - - // Next byte is content, so --- was not a fence on its own line. - return s, false -} - -var messageRoles = []string{"system", "user", "assistant"} - -func parseMessages(body string) ([]message, error) { - if strings.TrimSpace(body) == "" { - return nil, nil - } - - if _, _, _, ok := nextOpenTag(body, 0); !ok { - return []message{{Role: "system", Content: strings.TrimSpace(body)}}, nil - } - - var messages []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, 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 + ">" - i := strings.Index(body[from:], tag) - if i < 0 { - continue - } - - abs := from + i - if start < 0 || abs < start { - start = abs - role = candidate - contentStart = abs + 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 - i := from - - for i < len(body) { - relOpen := strings.Index(body[i:], open) - relClose := strings.Index(body[i:], close) - if relClose < 0 { - return 0, 0, false - } - - if relOpen >= 0 && relOpen < relClose { - depth++ - i += relOpen + len(open) - continue - } - - depth-- - closeAt := i + relClose - if depth == 0 { - return closeAt, closeAt + len(close), true - } - - i = closeAt + len(close) - } - - return 0, 0, false -} diff --git a/internal/sync/variation_test.go b/internal/sync/variation_test.go deleted file mode 100644 index 2a10eb1e..00000000 --- a/internal/sync/variation_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package sync - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestVariationParser_Accept(t *testing.T) { - p := variationParser{} - - assert.True(t, p.Accept("my-config/my-variation.prompt")) - assert.False(t, p.Accept("my-variation.prompt")) - assert.False(t, p.Accept("my-config/nested/my-variation.prompt")) - assert.False(t, p.Accept("my-config/my-variation.md")) -} - -func TestVariationParser_UntaggedBodyIsSystemMessage(t *testing.T) { - file := File{ - ProjectKey: "proj", - RelPath: "cfg/plain.prompt", - Data: []byte(`--- -formatVersion: 1 -key: plain -name: Plain ---- - -Just say hello. -`), - } - - resource, err := variationParser{}.Parse(file) - require.NoError(t, err) - - var payload variationPayload - require.NoError(t, unmarshalPayload(resource, &payload)) - require.Equal(t, []message{{Role: "system", Content: "Just say hello."}}, payload.Messages) -} - -func TestVariationParser_MismatchedTags(t *testing.T) { - file := File{ - ProjectKey: "proj", - RelPath: "cfg/bad.prompt", - Data: []byte(`--- -formatVersion: 1 -key: bad -name: Bad ---- - - -oops - -`), - } - - _, err := variationParser{}.Parse(file) - require.ErrorContains(t, err, "unclosed tag") -} - -func TestVariationParser_TextOutsideTags(t *testing.T) { - file := File{ - ProjectKey: "proj", - RelPath: "cfg/bad.prompt", - Data: []byte(`--- -formatVersion: 1 -key: bad -name: Bad ---- - -hello - -hi - -`), - } - - _, err := variationParser{}.Parse(file) - require.ErrorContains(t, err, "unexpected text outside message tags") -} - -func TestVariationParser_RequiresFormatVersion(t *testing.T) { - file := File{ - ProjectKey: "proj", - RelPath: "cfg/v.prompt", - Data: []byte(`--- -key: v -name: V ---- -`), - } - - _, err := variationParser{}.Parse(file) - require.ErrorContains(t, err, "formatVersion is required") -} - -func TestVariationParser_RejectsUnknownFrontMatter(t *testing.T) { - file := File{ - ProjectKey: "proj", - RelPath: "cfg/v.prompt", - Data: []byte(`--- -formatVersion: 1 -key: v -name: V -mystery: true ---- -`), - } - - _, err := variationParser{}.Parse(file) - require.ErrorContains(t, err, "invalid front matter") -} - -func unmarshalPayload(resource SyncedResource, dest any) error { - return json.Unmarshal(resource.Payload, dest) -} From 93f1cc80df829b5f38add12c237e835a626bd391 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Fri, 11 Sep 2026 18:56:57 -0400 Subject: [PATCH 3/3] feat(sync): compile local prompt variations --- internal/sync/local/compile.go | 112 +++++++++++++++ internal/sync/local/compile_test.go | 211 ++++++++++++++++++++++++++++ 2 files changed, 323 insertions(+) create mode 100644 internal/sync/local/compile.go create mode 100644 internal/sync/local/compile_test.go 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..0982378a --- /dev/null +++ b/internal/sync/local/compile_test.go @@ -0,0 +1,211 @@ +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 +description: This variation answers a question. + +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, "This variation answers a question.", payload.Description) + 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 +}