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
+}