From 0534c82d05ea4093896df2c66bbe641fd721ad1b Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Mon, 9 Nov 2026 16:04:28 -0500 Subject: [PATCH] feat(sync): add the link commands Add one link command for prompts, tools, and skills. The command reads the file, format, select, and map flags, and runs the link workflow. Co-Authored-By: Claude Fable 5.1 --- cmd/sync/attachments.go | 1 + cmd/sync/link.go | 136 ++++++++++++++++++++++ cmd/sync/prompt.go | 96 ++++++++++++++- cmd/sync/prompt_test.go | 133 ++++++++++++++++++++- cmd/sync/scope_test.go | 28 +++-- internal/sync/error.go | 25 ++++ internal/sync/prompt/link_command_test.go | 52 +++++++++ 7 files changed, 458 insertions(+), 13 deletions(-) create mode 100644 cmd/sync/link.go create mode 100644 internal/sync/prompt/link_command_test.go diff --git a/cmd/sync/attachments.go b/cmd/sync/attachments.go index 827c9cf06..dca8845b0 100644 --- a/cmd/sync/attachments.go +++ b/cmd/sync/attachments.go @@ -62,6 +62,7 @@ func newAttachmentScopeCmd(client resources.Client, kind syncdomain.AttachmentKi newWatchCmd(client, scope, help), newAttachmentAddCmd(client, kind), newAttachmentDetachCmd(client, kind), + newLinkCmd(client, syncdomain.Kind(kind)), ) cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) return cmd diff --git a/cmd/sync/link.go b/cmd/sync/link.go new file mode 100644 index 000000000..a81cd765b --- /dev/null +++ b/cmd/sync/link.go @@ -0,0 +1,136 @@ +package sync + +import ( + "errors" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/launchdarkly/ldcli/internal/resources" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncprompt "github.com/launchdarkly/ldcli/internal/sync/prompt" +) + +// linkExamples are the examples of the link command of each resource kind. +var linkExamples = map[syncdomain.Kind]string{ + syncdomain.KindVariation: ` # Link one Python agent to a prompt variation + ldcli sync prompts link \ + --file app/agents.py \ + --format python \ + --select 'agents[id="support"]' \ + --map prompt=instructions \ + --project production --config-key support --key default \ + --yes --no-input`, + syncdomain.KindTool: ` # Link one TypeScript MCP tool + ldcli sync tools link \ + --file src/server.ts \ + --format typescript \ + --select 'tools[id="lookup_order"]' \ + --map description=description \ + --project production --key lookup_order \ + --yes --no-input`, + syncdomain.KindSkill: ` # Link one Agent Skill file + ldcli sync skills link \ + --file .agents/skills/refunds/SKILL.md \ + --format agent-skill \ + --project production --key refunds \ + --yes --no-input`, +} + +// newLinkCmd creates the link command of one resource kind. Link records a +// .ref.yaml descriptor for a repository source and then syncs the resource. +func newLinkCmd(client resources.Client, kind syncdomain.Kind) *cobra.Command { + scope, noun, help := syncdomain.ScopePrompts, "prompt variation", questionFlagHelp + if kind != syncdomain.KindVariation { + scope = syncdomain.Scope(kind + "s") + noun = string(kind) + help = attachmentFlagHelp(syncdomain.AttachmentKind(kind), false) + } + cmd := &cobra.Command{ + Use: "link", + Short: fmt.Sprintf("Link a repository source to one %s", noun), + Long: fmt.Sprintf("Link a repository source to one %s. "+ + "Link reads only the named file, shows the descriptor and the sync plan, and writes the descriptor after you confirm. "+ + "It then syncs the %s.", noun, noun), + Example: linkExamples[kind], + Args: validatedArgs(cobra.NoArgs), + RunE: runCommand(client, scope, func(cmd *cobra.Command, _ []string) (syncprompt.CommandAction, error) { + id, err := sourceLinkID(cmd, kind) + if err != nil { + return nil, err + } + fieldMap, err := parseFieldMappings(mustStringArray(cmd, mapFlag)) + if err != nil { + return nil, err + } + return syncprompt.SourceLinkAction{ + ID: id, + File: mustString(cmd, fileFlag), + Format: mustString(cmd, formatFlag), + Select: mustString(cmd, selectFlag), + Map: fieldMap, + }, nil + }), + } + addTargetFlags(cmd, help) + cmd.Flags().String(fileFlag, "", "Repository source file") + cmd.Flags().String(formatFlag, "", "Source format, for example python, typescript, markdown, or json") + cmd.Flags().String(selectFlag, "", "Source value to select, for example 'agents[id=\"support\"]'") + cmd.Flags().StringArray(mapFlag, nil, "Mapped field in field=path form. Repeat for more fields.") + _ = cmd.MarkFlagRequired(fileFlag) + _ = cmd.MarkFlagRequired(formatFlag) + addApplyFlags(cmd, scope, false) + return cmd +} + +// sourceLinkID reads the resource that the target flags name. Link needs +// exactly one resource. +func sourceLinkID(cmd *cobra.Command, kind syncdomain.Kind) (syncdomain.ResourceID, error) { + filter := targetFilter(cmd) + if err := filter.Validate(kind); err != nil { + return syncdomain.ResourceID{}, err + } + if filter.Project == "" { + return syncdomain.ResourceID{}, errors.New("--project is required") + } + if len(filter.Keys) != 1 { + return syncdomain.ResourceID{}, errors.New("link requires exactly one --key") + } + if kind == syncdomain.KindVariation { + if filter.ConfigKey == "" { + return syncdomain.ResourceID{}, errors.New("--config-key is required") + } + return syncdomain.VariationID(filter.Project, filter.ConfigKey, filter.Keys[0]), nil + } + return syncdomain.ResourceID{Kind: kind, ProjectKey: filter.Project, LookupKey: filter.Keys[0]}, nil +} + +// parseFieldMappings reads the --map values. Each value is field=path. +func parseFieldMappings(values []string) (map[string]string, error) { + if len(values) == 0 { + return nil, nil + } + fieldMap := make(map[string]string, len(values)) + for _, value := range values { + field, path, ok := strings.Cut(value, "=") + if !ok || field == "" || path == "" { + return nil, fmt.Errorf("invalid --map %q; expected field=path", value) + } + if _, duplicate := fieldMap[field]; duplicate { + return nil, fmt.Errorf("--map field %q was provided more than once", field) + } + fieldMap[field] = path + } + return fieldMap, nil +} + +func mustString(cmd *cobra.Command, name string) string { + value, _ := cmd.Flags().GetString(name) + return value +} + +func mustStringArray(cmd *cobra.Command, name string) []string { + value, _ := cmd.Flags().GetStringArray(name) + return value +} diff --git a/cmd/sync/prompt.go b/cmd/sync/prompt.go index 34d73fb2f..53a3a368c 100644 --- a/cmd/sync/prompt.go +++ b/cmd/sync/prompt.go @@ -1,6 +1,7 @@ package sync import ( + "encoding/json" "fmt" "os" "strings" @@ -12,6 +13,7 @@ import ( "github.com/launchdarkly/ldcli/cmd/cliflags" resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" "github.com/launchdarkly/ldcli/cmd/validators" + lderrors "github.com/launchdarkly/ldcli/internal/errors" "github.com/launchdarkly/ldcli/internal/output" "github.com/launchdarkly/ldcli/internal/resources" syncdomain "github.com/launchdarkly/ldcli/internal/sync" @@ -26,6 +28,7 @@ const ( descriptionFlag = "description" diffFlag = "diff" dryRunFlag = "dry-run" + fileFlag = "file" formatFlag = "format" jsonSchemaFlag = "json-schema" keyFlag = "key" @@ -33,6 +36,8 @@ const ( nameFlag = "name" noInputFlag = "no-input" resolveFlag = "resolve" + selectFlag = "select" + mapFlag = "map" toFlag = "to" yesFlag = "yes" ) @@ -67,6 +72,7 @@ func NewPromptCmd(client resources.Client) *cobra.Command { newAddCmd(client), newAttachCmd(client), newDetachCmd(client), + newLinkCmd(client, syncdomain.KindVariation), ) cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) @@ -119,12 +125,100 @@ func runCommand(client resources.Client, scope syncdomain.Scope, buildAction act ErrorOutput: cmd.ErrOrStderr(), }) if err != nil { - return output.NewCmdOutputError(err, outputKind) + return syncCommandOutputError(err, outputKind) } return nil } } +// syncCommandOutputError renders a sync error with its stable fields. A plan +// error can join several problems, so the output then lists every one of them. +func syncCommandOutputError(err error, outputKind string) error { + problems := syncdomain.ProblemsFrom(err) + var report any + switch { + case len(problems) == 0: + case len(problems) == 1 && len(leafErrors(err)) == 1: + report = problems[0] + if outputKind != "json" { + report = plaintextProblem(problems[0]) + } + default: + message := err.Error() + if outputKind != "json" { + message = plaintextMessage(err) + } + report = struct { + *syncdomain.Problem + Problems []*syncdomain.Problem `json:"problems"` + }{ + Problem: syncdomain.NewProblem(syncdomain.ErrorMultipleProblems, message, ""), + Problems: problems, + } + } + if report != nil { + if data, marshalErr := json.Marshal(report); marshalErr == nil { + err = lderrors.NewError(string(data)) + } + } + return output.NewCmdOutputError(err, outputKind) +} + +// plaintextProblem puts the resource and the source location of a problem +// in its message, because plaintext output prints only the message, the +// code, and the suggestion. +func plaintextProblem(problem *syncdomain.Problem) *syncdomain.Problem { + located := *problem + var context []string + if problem.Resource != "" { + context = append(context, problem.Resource) + } + if problem.File != "" { + location := problem.File + if problem.Line > 0 { + location += fmt.Sprintf(":%d", problem.Line) + if problem.Column > 0 { + location += fmt.Sprintf(":%d", problem.Column) + } + } + context = append(context, location) + } + if len(context) != 0 { + located.Message = strings.Join(context, " ") + ": " + problem.Message + } + return &located +} + +// plaintextMessage lists the leaf errors of a joined error, one per line, +// with the resource and the source location of each problem. +func plaintextMessage(err error) string { + var lines []string + for _, leaf := range leafErrors(err) { + if problem, ok := syncdomain.ProblemFrom(leaf); ok { + lines = append(lines, plaintextProblem(problem).Error()) + continue + } + lines = append(lines, leaf.Error()) + } + return strings.Join(lines, "\n") +} + +// leafErrors returns the errors of a tree that wrap nothing else. A plan +// error joins its problems, so a join of one problem has one leaf. +func leafErrors(err error) []error { + if err == nil { + return nil + } + if joined, ok := err.(interface{ Unwrap() []error }); ok { + var leaves []error + for _, child := range joined.Unwrap() { + leaves = append(leaves, leafErrors(child)...) + } + return leaves + } + return []error{err} +} + // newWatchCmd creates the watch command of a scope. help is the help text of // the target flags of the root sync of the scope. func newWatchCmd(client resources.Client, scope syncdomain.Scope, help targetFlagHelp) *cobra.Command { diff --git a/cmd/sync/prompt_test.go b/cmd/sync/prompt_test.go index f3708811c..43ffa5994 100644 --- a/cmd/sync/prompt_test.go +++ b/cmd/sync/prompt_test.go @@ -1,6 +1,9 @@ package sync import ( + "encoding/json" + "errors" + "fmt" "strings" "testing" @@ -17,17 +20,114 @@ func TestPromptCommandUsesActionSubcommands(t *testing.T) { command := NewPromptCmd(nil) assert.Equal(t, "prompts", command.Use) - for _, name := range []string{"watch", "add", "attach", "detach"} { + for _, name := range []string{"watch", "add", "attach", "detach", "link"} { child, _, err := command.Find([]string{name}) require.NoError(t, err) assert.Equal(t, name, child.Name()) } - for _, name := range []string{"add", "attach-tool", "attach-skill", "detach", "watch"} { + for _, name := range []string{"add", "attach-tool", "attach-skill", "detach", "link", "watch"} { assert.Nil(t, command.Flags().Lookup(name), "legacy --%s should not exist", name) } add, _, err := command.Find([]string{"add"}) require.NoError(t, err) assert.Equal(t, "add [project-key/config-key/variation-key...]", add.Use) + link, _, err := command.Find([]string{"link"}) + require.NoError(t, err) + assert.Nil(t, link.Flags().Lookup(toFlag)) + assert.Nil(t, link.Flags().Lookup(modelConfigKeyFlag)) +} + +func TestSourceLinkFlagsBuildTargetAndMappings(t *testing.T) { + command := newLinkCmd(nil, syncdomain.KindVariation) + require.NoError(t, command.ParseFlags([]string{ + "--file", "agents.py", + "--format", "python", + "--select", `agents[id="support"]`, + "--map", "prompt=instructions", + "--project", "production", + "--config-key", "support", + "--key", "default", + })) + + id, err := sourceLinkID(command, syncdomain.KindVariation) + require.NoError(t, err) + assert.Equal(t, syncdomain.VariationID("production", "support", "default"), id) + mappings, err := parseFieldMappings(mustStringArray(command, mapFlag)) + require.NoError(t, err) + assert.Equal(t, map[string]string{"prompt": "instructions"}, mappings) +} + +func TestSyncProblemRendersStableFields(t *testing.T) { + problem := syncdomain.NewProblem( + syncdomain.ErrorPathNotFound, + "mapped field was not found", + "Update the map path.", + ).WithResource(syncdomain.VariationID("project", "config", "default")). + WithFile("agents.py", 4, 12) + + jsonError := syncCommandOutputError(problem, "json") + require.JSONEq(t, `{ + "code": "path_not_found", + "message": "mapped field was not found", + "resource": "variation project/config/default", + "file": "agents.py", + "line": 4, + "column": 12, + "suggestion": "Update the map path." + }`, jsonError.Error()) + + plainError := syncCommandOutputError(problem, "plaintext") + assert.Contains(t, plainError.Error(), + "variation project/config/default agents.py:4:12: mapped field was not found (code: path_not_found)", + "plaintext names the resource and the source location") + assert.Contains(t, plainError.Error(), "Suggestion: Update the map path.") + + bare := syncCommandOutputError(syncdomain.NewProblem(syncdomain.ErrorPathNotFound, "mapped field was not found", ""), "plaintext") + assert.Contains(t, bare.Error(), "mapped field was not found (code: path_not_found)") + assert.NotContains(t, bare.Error(), ": mapped field") +} + +func TestSyncJoinedErrorKeepsEveryProblem(t *testing.T) { + problem := syncdomain.NewProblem(syncdomain.ErrorPathNotFound, "mapped field was not found", "Update the map path.") + joined := errors.Join( + errors.New("cannot sync conflicted resource variation project/config/other"), + fmt.Errorf("cannot sync variation project/config/default: %w", problem), + ) + + jsonError := syncCommandOutputError(joined, "json") + + var report struct { + Code string `json:"code"` + Message string `json:"message"` + Problems []*syncdomain.Problem `json:"problems"` + } + require.NoError(t, json.Unmarshal([]byte(jsonError.Error()), &report)) + assert.Equal(t, "multiple_problems", report.Code) + assert.Contains(t, report.Message, "conflicted resource variation project/config/other") + assert.Contains(t, report.Message, "mapped field was not found") + require.Len(t, report.Problems, 1) + assert.Equal(t, syncdomain.ErrorPathNotFound, report.Problems[0].Code) + + single := syncCommandOutputError(fmt.Errorf("cannot sync: %w", problem), "json") + assert.JSONEq(t, `{"code":"path_not_found","message":"mapped field was not found","suggestion":"Update the map path."}`, single.Error()) + + // A plan joins its problems, so a join of one problem keeps its code. + joinedSingle := syncCommandOutputError(errors.Join(fmt.Errorf("cannot sync variation x: %w", problem)), "json") + assert.JSONEq(t, `{"code":"path_not_found","message":"mapped field was not found","suggestion":"Update the map path."}`, joinedSingle.Error()) +} + +func TestLinkValidatesTheTargetFlags(t *testing.T) { + command := NewSyncCmd(nil, nil) + link, _, err := command.Find([]string{"prompts", "link"}) + require.NoError(t, err) + require.NoError(t, link.Flags().Set("project", "p")) + require.NoError(t, link.Flags().Set("config-key", "c")) + require.NoError(t, link.Flags().Set("key", "..")) + + _, err = sourceLinkID(link, syncdomain.KindVariation) + + require.Error(t, err) + assert.Contains(t, err.Error(), "--key") } func TestSyncCommandIncludesExamples(t *testing.T) { @@ -53,6 +153,7 @@ func TestPromptCommandHelpIncludesExamples(t *testing.T) { "attach tool": {"attach", "tool"}, "attach skill": {"attach", "skill"}, "detach": {"detach"}, + "link": {"link"}, } for name, path := range tests { @@ -107,6 +208,7 @@ func TestPromptCommandsRegisterTargetFlags(t *testing.T) { "add": {path: []string{"add"}, help: questionFlagHelp}, "detach": {path: []string{"detach"}, help: questionFlagHelp}, "attach tool": {path: []string{"attach", "tool"}, help: targetFlagHelp{project: questionFlagHelp.project}}, + "link": {path: []string{"link"}, help: questionFlagHelp}, } for name, test := range tests { @@ -209,3 +311,30 @@ func TestConflictPolicyParsesDefaultAndOverrides(t *testing.T) { Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/variation", }]) } + +func TestSyncJoinedPlaintextNamesEachLocation(t *testing.T) { + problem := syncdomain.NewProblem(syncdomain.ErrorPathNotFound, "mapped field was not found", "Update the map path."). + WithResource(syncdomain.VariationID("project", "config", "default")). + WithFile("agents.py", 4, 12) + joined := errors.Join( + errors.New("cannot sync conflicted resource variation project/config/other"), + fmt.Errorf("cannot sync variation project/config/default: %w", problem), + ) + + plainError := syncCommandOutputError(joined, "plaintext") + + assert.Contains(t, plainError.Error(), "cannot sync conflicted resource variation project/config/other") + assert.Contains(t, plainError.Error(), + "variation project/config/default agents.py:4:12: mapped field was not found. Update the map path.") + assert.Contains(t, plainError.Error(), "(code: multiple_problems)") + + jsonError := syncCommandOutputError(joined, "json") + var report struct { + Message string `json:"message"` + Problems []*syncdomain.Problem `json:"problems"` + } + require.NoError(t, json.Unmarshal([]byte(jsonError.Error()), &report)) + assert.NotContains(t, report.Message, "agents.py:4:12", "JSON keeps the location in its own fields") + require.Len(t, report.Problems, 1) + assert.Equal(t, 4, report.Problems[0].Line) +} diff --git a/cmd/sync/scope_test.go b/cmd/sync/scope_test.go index 1d7a0ec9a..954775d35 100644 --- a/cmd/sync/scope_test.go +++ b/cmd/sync/scope_test.go @@ -25,14 +25,20 @@ var syncCommandFlags = map[string][]string{ "sync prompts attach tool": {"conflict", "diff", "no-input", "project", "resolve", "to", "yes"}, "sync prompts attach skill": {"conflict", "diff", "no-input", "project", "resolve", "to", "yes"}, "sync prompts detach": {"archive", "config-key", "key", "no-input", "project", "yes"}, - "sync tools": {"conflict", "diff", "dry-run", "key", "no-input", "project", "yes"}, - "sync tools watch": {"conflict", "diff", "key", "no-input", "project", "yes"}, - "sync tools add": {"description", "diff", "dry-run", "json-schema", "key", "no-input", "project", "yes"}, - "sync tools detach": {"key", "no-input", "project", "yes"}, - "sync skills": {"conflict", "diff", "dry-run", "key", "no-input", "project", "yes"}, - "sync skills watch": {"conflict", "diff", "key", "no-input", "project", "yes"}, - "sync skills add": {"content", "description", "diff", "dry-run", "key", "name", "no-input", "project", "yes"}, - "sync skills detach": {"key", "no-input", "project", "yes"}, + "sync prompts link": { + "config-key", "conflict", "diff", "file", "format", "key", "map", + "no-input", "project", "resolve", "select", "yes", + }, + "sync tools": {"conflict", "diff", "dry-run", "key", "no-input", "project", "yes"}, + "sync tools watch": {"conflict", "diff", "key", "no-input", "project", "yes"}, + "sync tools add": {"description", "diff", "dry-run", "json-schema", "key", "no-input", "project", "yes"}, + "sync tools detach": {"key", "no-input", "project", "yes"}, + "sync tools link": {"conflict", "diff", "file", "format", "key", "map", "no-input", "project", "select", "yes"}, + "sync skills": {"conflict", "diff", "dry-run", "key", "no-input", "project", "yes"}, + "sync skills watch": {"conflict", "diff", "key", "no-input", "project", "yes"}, + "sync skills add": {"content", "description", "diff", "dry-run", "key", "name", "no-input", "project", "yes"}, + "sync skills detach": {"key", "no-input", "project", "yes"}, + "sync skills link": {"conflict", "diff", "file", "format", "key", "map", "no-input", "project", "select", "yes"}, } func TestSyncCommandsHaveExactFlags(t *testing.T) { @@ -65,14 +71,16 @@ func TestSyncCommandsReadSyncFlagsOnlyFromTheCommandLine(t *testing.T) { t.Setenv("LD_"+strings.ToUpper(strings.ReplaceAll(name, "-", "_")), "true") } for _, name := range []string{ - "config-key", "conflict", "content", "description", "format", "json-schema", "key", "model-config-key", "name", "resolve", "to", + "config-key", "conflict", "content", "description", "file", "format", "json-schema", "key", + "map", "model-config-key", "name", "resolve", "select", "to", } { t.Setenv("LD_"+strings.ToUpper(strings.ReplaceAll(name, "-", "_")), "from-environment") } for _, command := range runnableSyncCommands(NewSyncCmd(nil, nil)) { require.NoError(t, command.ParseFlags(nil)) - require.NoError(t, command.Args(command, nil), command.CommandPath()) + var args []string + require.NoError(t, command.Args(command, args), command.CommandPath()) command.LocalNonPersistentFlags().VisitAll(func(flag *pflag.Flag) { assert.False(t, flag.Changed, "%s --%s", command.CommandPath(), flag.Name) }) diff --git a/internal/sync/error.go b/internal/sync/error.go index c5a9c5050..a256da44f 100644 --- a/internal/sync/error.go +++ b/internal/sync/error.go @@ -33,6 +33,9 @@ const ( ErrorResourceDeleted ErrorCode = "resource_deleted_in_launchdarkly" ErrorPlaceholderAmbiguous ErrorCode = "placeholder_translation_ambiguous" ErrorImportUnresolved ErrorCode = "import_unresolved" + // ErrorMultipleProblems reports several problems at once. The message + // has every problem, and the problems field has the structured ones. + ErrorMultipleProblems ErrorCode = "multiple_problems" ) // Problem describes one actionable sync error. @@ -78,6 +81,28 @@ func ProblemFrom(err error) (*Problem, bool) { return problem, ok } +// ProblemsFrom returns every sync problem in an error tree, in order. A +// joined error, such as the blocking error of a plan, holds several. +func ProblemsFrom(err error) []*Problem { + var problems []*Problem + var walk func(error) + walk = func(err error) { + switch typed := err.(type) { + case nil: + case *Problem: + problems = append(problems, typed) + case interface{ Unwrap() []error }: + for _, child := range typed.Unwrap() { + walk(child) + } + case interface{ Unwrap() error }: + walk(typed.Unwrap()) + } + } + walk(err) + return problems +} + // WithResource adds a resource identity to a problem. func (problem *Problem) WithResource(id ResourceID) *Problem { if problem != nil { diff --git a/internal/sync/prompt/link_command_test.go b/internal/sync/prompt/link_command_test.go new file mode 100644 index 000000000..55e9159c9 --- /dev/null +++ b/internal/sync/prompt/link_command_test.go @@ -0,0 +1,52 @@ +package prompt_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +func TestCLILinksAndDetachesRepositorySource(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), []byte("Local CLI prompt.\n"), 0o644)) + server := variation("Server") + api := &directAPI{ + variation: &server, + directions: map[string]syncdomain.SyncDirection{ + "variation:support/default": syncdomain.SyncDirectionBoth, + }, + } + + _, _, err := runPrompt( + t, root, api, + "link", + "--file", "prompt.md", + "--format", "markdown", + "--project", "production", + "--config-key", "support", + "--key", "default", + "--conflict", "local", + "--yes", "--no-input", + ) + require.NoError(t, err) + assert.Equal(t, "Local CLI prompt.", api.variation.Instructions) + descriptor := filepath.Join(root, ".launchdarkly", "production", "configs", "support", "default.ref.yaml") + require.FileExists(t, descriptor) + + _, _, err = runPrompt( + t, root, api, + "detach", + "--project", "production", + "--config-key", "support", + "--key", "default", + "--yes", "--no-input", + ) + require.NoError(t, err) + require.NoFileExists(t, descriptor) + require.FileExists(t, filepath.Join(root, "prompt.md")) +}