From 4e53008513923d213f00e10f904440cc123b5153 Mon Sep 17 00:00:00 2001 From: Cliff Tawiah Date: Thu, 22 Oct 2026 22:02:28 +0000 Subject: [PATCH] feat: create a prompt variation from the content of an add An add of prompt variations can now carry the content of a new variation: its prompt, its model config key, and its name. The prompt is read with the body rules of a variation file, so it is the instructions of an agent config, or the role blocks of a completion config, where plain text is one system message. A create needs a config that exists. It uses the latest version of the model config, as link does, and refuses the key of an archived variation. If the config has the variation with the prompt, name, and model config key that the user passed, add starts syncing it through the existing add. If one of them differs, add stops and changes nothing. The create runs the same steps as a tool or skill create, so runCreate now has one driver and one builder for each kind. The driver plans the create in memory, asks for confirmation, writes the file, applies the plan of the new resource, and removes the file after a definite failure. link and add build the new variation with the same NewVariation. The command flags come in the next change. --- internal/sync/create/attachment.go | 49 +---- internal/sync/create/create.go | 59 +++++- internal/sync/create/create_test.go | 23 ++- internal/sync/create/prompt.go | 147 ++++++++++++++ internal/sync/create/prompt_test.go | 80 ++++++++ internal/sync/link/link.go | 9 +- internal/sync/local/variation.go | 6 +- internal/sync/prompt/acceptance_test.go | 26 ++- internal/sync/prompt/action.go | 9 +- internal/sync/prompt/create.go | 180 ++++++++++++------ internal/sync/prompt/create_test.go | 243 ++++++++++++++++++++++++ 11 files changed, 689 insertions(+), 142 deletions(-) diff --git a/internal/sync/create/attachment.go b/internal/sync/create/attachment.go index 04fb735a9..360bbe8fc 100644 --- a/internal/sync/create/attachment.go +++ b/internal/sync/create/attachment.go @@ -19,53 +19,6 @@ import ( // not an input schema. var definitionKeys = []string{"formatVersion", "inputSchema", "schema"} -// Check applies the content rules of a create of a tool or a skill. It reads -// nothing, so an add runs it before any request. -func (input Input) Check(kind syncdomain.AttachmentKind) error { - if err := input.checkContentFlag(kind); err != nil { - return err - } - if kind == syncdomain.AttachmentTool { - _, err := parseSchema(*input.JSONSchema) - return err - } - switch { - case strings.TrimSpace(*input.Content) == "": - return errors.New("--content cannot be blank") - case input.Name != nil && strings.TrimSpace(*input.Name) == "": - return errors.New("--name cannot be blank") - } - return nil -} - -// checkContentFlag makes sure that the flag that starts a create is set when -// another create flag is. -func (input Input) checkContentFlag(kind syncdomain.AttachmentKind) error { - flag, content := "--content", input.Content - if kind == syncdomain.AttachmentTool { - flag, content = "--json-schema", input.JSONSchema - } - if content != nil { - return nil - } - - var passed []string - if input.Name != nil { - passed = append(passed, "--name") - } - if input.Description != nil { - passed = append(passed, "--description") - } - switch len(passed) { - case 0: - return fmt.Errorf("add creates a %s only when %s is set", kind, flag) - case 1: - return fmt.Errorf("%s needs %s. add creates a %s only when %s is set.", passed[0], flag, kind, flag) - default: - return fmt.Errorf("%s need %s. add creates a %s only when %s is set.", joinFlags(passed), flag, kind, flag) - } -} - // parseSchema decodes the value of --json-schema, which must be one JSON // object. func parseSchema(value string) (map[string]any, error) { @@ -126,7 +79,7 @@ func (input Input) Ask(id syncdomain.ResourceID, questions Questions) (Input, bo // from "$(cat file)" has the fingerprint of the file. func (input Input) Attachment(id syncdomain.ResourceID) (syncdomain.Attachment, error) { kind := syncdomain.AttachmentKind(id.Kind) - if err := input.Check(kind); err != nil { + if err := input.Check(id.Kind); err != nil { return syncdomain.Attachment{}, err } diff --git a/internal/sync/create/create.go b/internal/sync/create/create.go index f2fcfa7e3..aca327912 100644 --- a/internal/sync/create/create.go +++ b/internal/sync/create/create.go @@ -4,6 +4,7 @@ package create import ( + "errors" "fmt" "io" "strings" @@ -21,9 +22,63 @@ type Input struct { Content *string // JSONSchema is the input schema of a tool. JSONSchema *string - // Name is the name of a skill. - Name *string + // Name is the name of a skill or a variation. + Name *string + // Description is the description of a tool or a skill. Description *string + // ModelConfigKey is the model config of a variation. + ModelConfigKey *string +} + +// Check applies the content rules of a create of kind. It reads nothing, so +// an add runs it before any request. +func (input Input) Check(kind syncdomain.Kind) error { + if err := input.checkContentFlag(kind); err != nil { + return err + } + if kind == syncdomain.KindTool { + _, err := parseSchema(*input.JSONSchema) + return err + } + switch { + case strings.TrimSpace(*input.Content) == "": + return errors.New("--content cannot be blank") + case input.Name != nil && strings.TrimSpace(*input.Name) == "": + return errors.New("--name cannot be blank") + case input.ModelConfigKey != nil && strings.TrimSpace(*input.ModelConfigKey) == "": + return errors.New("--model-config-key cannot be blank") + } + return nil +} + +// checkContentFlag makes sure that the flag that starts a create is set when +// another create flag is. +func (input Input) checkContentFlag(kind syncdomain.Kind) error { + flag, content := "--content", input.Content + if kind == syncdomain.KindTool { + flag, content = "--json-schema", input.JSONSchema + } + if content != nil { + return nil + } + + var passed []string + for _, option := range []struct { + flag string + value *string + }{{"--name", input.Name}, {"--description", input.Description}, {"--model-config-key", input.ModelConfigKey}} { + if option.value != nil { + passed = append(passed, option.flag) + } + } + switch len(passed) { + case 0: + return fmt.Errorf("add creates a %s only when %s is set", kind, flag) + case 1: + return fmt.Errorf("%s needs %s. add creates a %s only when %s is set.", passed[0], flag, kind, flag) + default: + return fmt.Errorf("%s need %s. add creates a %s only when %s is set.", joinFlags(passed), flag, kind, flag) + } } // Questions are the streams that ask the user for the values that the flags diff --git a/internal/sync/create/create_test.go b/internal/sync/create/create_test.go index 0accc5207..08a0f94b3 100644 --- a/internal/sync/create/create_test.go +++ b/internal/sync/create/create_test.go @@ -17,27 +17,32 @@ var ( func TestCheckNeedsTheFlagThatStartsTheCreate(t *testing.T) { tests := map[string]struct { - kind syncdomain.AttachmentKind + kind syncdomain.Kind input Input want string }{ "tool description": { - kind: syncdomain.AttachmentTool, + kind: syncdomain.KindTool, input: Input{Description: pointer("Issue a refund")}, want: "--description needs --json-schema. add creates a tool only when --json-schema is set.", }, "skill name": { - kind: syncdomain.AttachmentSkill, + kind: syncdomain.KindSkill, input: Input{Name: pointer("Triage")}, want: "--name needs --content. add creates a skill only when --content is set.", }, "skill name and description": { - kind: syncdomain.AttachmentSkill, + kind: syncdomain.KindSkill, input: Input{Name: pointer("Triage"), Description: pointer("")}, want: "--name and --description need --content. add creates a skill only when --content is set.", }, + "variation name and model config": { + kind: syncdomain.KindVariation, + input: Input{Name: pointer("Escalation"), ModelConfigKey: pointer("claude")}, + want: "--name and --model-config-key need --content. add creates a variation only when --content is set.", + }, "no flag": { - kind: syncdomain.AttachmentTool, + kind: syncdomain.KindTool, want: "add creates a tool only when --json-schema is set", }, } @@ -68,7 +73,7 @@ func TestCheckDecodesTheJSONSchemaStrictly(t *testing.T) { } for name, test := range tests { t.Run(name, func(t *testing.T) { - err := Input{JSONSchema: pointer(test.schema)}.Check(syncdomain.AttachmentTool) + err := Input{JSONSchema: pointer(test.schema)}.Check(syncdomain.KindTool) if test.want == "" { require.NoError(t, err) return @@ -79,10 +84,10 @@ func TestCheckDecodesTheJSONSchemaStrictly(t *testing.T) { } func TestCheckRejectsBlankSkillValues(t *testing.T) { - require.EqualError(t, Input{Content: pointer(" \n")}.Check(syncdomain.AttachmentSkill), "--content cannot be blank") - require.EqualError(t, Input{Content: pointer("Triage."), Name: pointer(" ")}.Check(syncdomain.AttachmentSkill), + require.EqualError(t, Input{Content: pointer(" \n")}.Check(syncdomain.KindSkill), "--content cannot be blank") + require.EqualError(t, Input{Content: pointer("Triage."), Name: pointer(" ")}.Check(syncdomain.KindSkill), "--name cannot be blank") - require.NoError(t, Input{Content: pointer("Triage."), Description: pointer("")}.Check(syncdomain.AttachmentSkill)) + require.NoError(t, Input{Content: pointer("Triage."), Description: pointer("")}.Check(syncdomain.KindSkill)) } func TestAttachmentBuildsATool(t *testing.T) { diff --git a/internal/sync/create/prompt.go b/internal/sync/create/prompt.go index 94fccf6b7..ae3780a19 100644 --- a/internal/sync/create/prompt.go +++ b/internal/sync/create/prompt.go @@ -4,10 +4,13 @@ import ( "fmt" "slices" + "github.com/charmbracelet/huh" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" syncapi "github.com/launchdarkly/ldcli/internal/sync/api" syncconsole "github.com/launchdarkly/ldcli/internal/sync/console" syncinteractive "github.com/launchdarkly/ldcli/internal/sync/interactive" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" ) // Catalog finds the project, the config, and the model config of a new @@ -80,3 +83,147 @@ func FindModelConfig(catalog Catalog, projectKey, key string) (syncapi.ModelConf } return modelConfigs[index], nil } + +// VariationTarget returns the identity of a new variation. The filter gives +// the project, the config, and the key, and a question asks for each one that +// it does not give. Without questions, the error names every missing flag, +// --model-config-key too, before any read. The bool result is true when the +// user cancels. +func (input Input) VariationTarget(catalog Catalog, filter syncdomain.Filter, questions Questions) (syncdomain.ResourceID, bool, error) { + var key string + if len(filter.Keys) == 1 { + key = filter.Keys[0] + } + var missing []string + for _, flag := range []struct { + name string + set bool + }{{"--project", filter.Project != ""}, {"--config-key", filter.ConfigKey != ""}, {"--key", key != ""}, {"--model-config-key", input.ModelConfigKey != nil}} { + if !flag.set { + missing = append(missing, flag.name) + } + } + if len(missing) != 0 && !questions.Allowed { + return syncdomain.ResourceID{}, false, fmt.Errorf("variation creation requires %s without interactive input", joinFlags(missing)) + } + if key != "" { + if err := keyValidator(syncdomain.KindVariation)(key); err != nil { + return syncdomain.ResourceID{}, false, err + } + } + + projectKey, configKey := filter.Project, filter.ConfigKey + if configKey == "" { + project, config, canceled, err := SelectConfig(questions, catalog, projectKey, nil) + if err != nil || canceled { + return syncdomain.ResourceID{}, canceled, err + } + projectKey, configKey = project.Key, config.Key + } + if key == "" { + field := huh.NewInput().Title("Variation key").Value(&key).Validate(keyValidator(syncdomain.KindVariation)) + if canceled, err := syncinteractive.RunForm(questions.Input, questions.Output, field); err != nil || canceled { + return syncdomain.ResourceID{}, canceled, err + } + } + return syncdomain.VariationID(projectKey, configKey, key), false, nil +} + +// Variation returns the variation that the input creates with id in config. +// It uses the latest version of its model config. A question asks for the +// model config and the name that the flags do not give. Without questions, +// the name comes from the key. The bool result is true when the user cancels. +func (input Input) Variation( + catalog Catalog, + id syncdomain.ResourceID, + config syncapi.Config, + questions Questions, +) (syncdomain.Variation, bool, error) { + prompt, err := input.withContent(syncdomain.Variation{Mode: config.Mode}) + if err != nil { + return syncdomain.Variation{}, false, err + } + + var modelConfig syncapi.ModelConfig + canceled := false + if input.ModelConfigKey != nil { + modelConfig, err = FindModelConfig(catalog, id.ProjectKey, *input.ModelConfigKey) + } else { + modelConfig, canceled, err = SelectModelConfig(questions, catalog, id.ProjectKey) + } + if err != nil || canceled { + return syncdomain.Variation{}, canceled, err + } + + _, key, err := id.VariationKeys() + if err != nil { + return syncdomain.Variation{}, false, err + } + name := syncdomain.DisplayName(key) + if input.Name != nil { + name = *input.Name + } else if questions.Allowed { + field := huh.NewInput().Title("Variation name").Value(&name).Validate(RequiredValue("variation name")) + if canceled, err := syncinteractive.RunForm(questions.Input, questions.Output, field); err != nil || canceled { + return syncdomain.Variation{}, canceled, err + } + } + if err := syncdomain.ValidateCreate(syncdomain.KindVariation, key, name, ""); err != nil { + return syncdomain.Variation{}, false, err + } + + variation := NewVariation(config, key, name, modelConfig) + variation.Instructions, variation.Messages = prompt.Instructions, prompt.Messages + if err := variation.Validate(); err != nil { + return syncdomain.Variation{}, false, fmt.Errorf("variation %q: %w", key, err) + } + return variation, false, nil +} + +// NewVariation returns a variation in config with no prompt. It uses the +// latest version of modelConfig. +func NewVariation(config syncapi.Config, key, name string, modelConfig syncapi.ModelConfig) syncdomain.Variation { + return syncdomain.Variation{ + Mode: config.Mode, + Key: key, + Name: name, + ModelConfigKey: modelConfig.Key, + ModelConfigVersion: modelConfig.Version, + Model: modelConfig.VariationModel(), + } +} + +// MatchesVariation reports whether LaunchDarkly already has the value of each +// flag that the user passed: the prompt, and the name and the model config key +// when they are set. id is the identity of existing. The fields without a +// flag, such as the model config version and the tools, come from +// LaunchDarkly. +func (input Input) MatchesVariation(id syncdomain.ResourceID, existing syncdomain.Variation) (bool, error) { + candidate, err := input.withContent(existing) + if err != nil { + return false, err + } + if input.Name != nil { + candidate.Name = *input.Name + } + if input.ModelConfigKey != nil { + candidate.ModelConfigKey = *input.ModelConfigKey + } + want, err := syncdomain.FingerprintVariation(id.ProjectKey, id.LookupKey, candidate) + if err != nil { + return false, err + } + have, err := syncdomain.FingerprintVariation(id.ProjectKey, id.LookupKey, existing) + return want == have, err +} + +// withContent returns variation with the prompt of --content. It reads the +// content with the body rules of a variation file: the instructions of an +// agent, or the role blocks of a completion, in which plain text is one system +// message. +func (input Input) withContent(variation syncdomain.Variation) (syncdomain.Variation, error) { + if err := synclocal.ParsePromptBody(*input.Content, &variation); err != nil { + return syncdomain.Variation{}, fmt.Errorf("--content: %w", err) + } + return variation, nil +} diff --git a/internal/sync/create/prompt_test.go b/internal/sync/create/prompt_test.go index ef59c11af..c96a642f6 100644 --- a/internal/sync/create/prompt_test.go +++ b/internal/sync/create/prompt_test.go @@ -33,6 +33,86 @@ func TestSelectConfigSkipsTheProjectQuestionAndNamesAMissingProject(t *testing.T assert.Equal(t, []string{"demo"}, catalog.configSearches) } +func TestVariationTargetWithoutQuestionsNamesEveryMissingFlag(t *testing.T) { + _, _, err := Input{Content: pointer("Escalate.")}.VariationTarget(&fakeCatalog{}, syncdomain.Filter{}, Questions{}) + require.EqualError(t, err, + "variation creation requires --project, --config-key, --key, and --model-config-key without interactive input") + + input := Input{Content: pointer("Escalate."), ModelConfigKey: pointer("claude")} + filter := syncdomain.Filter{Project: "demo", ConfigKey: "support", Keys: []string{"escalation"}} + id, canceled, err := input.VariationTarget(&fakeCatalog{}, filter, Questions{}) + require.NoError(t, err) + assert.False(t, canceled) + assert.Equal(t, syncdomain.VariationID("demo", "support", "escalation"), id) +} + +func TestVariationReadsTheContentWithTheRulesOfAVariationFile(t *testing.T) { + catalog := &fakeCatalog{modelConfigs: []syncapi.ModelConfig{{Key: "claude", ID: "claude-sonnet", Version: 3}}} + id := syncdomain.VariationID("demo", "support", "incident-escalation") + tests := map[string]struct { + mode syncdomain.VariationMode + content string + want syncdomain.Variation + }{ + "agent instructions": { + mode: syncdomain.VariationModeAgent, content: "Escalate.\n", + want: syncdomain.Variation{Instructions: "Escalate."}, + }, + "completion role blocks": { + mode: syncdomain.VariationModeCompletion, content: "Be brief.Escalate.", + want: syncdomain.Variation{Messages: []syncdomain.Message{ + {Role: syncdomain.RoleSystem, Content: "Be brief."}, {Role: "user", Content: "Escalate."}, + }}, + }, + "completion plain text": { + mode: syncdomain.VariationModeCompletion, content: "Escalate.", + want: syncdomain.Variation{Messages: []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "Escalate."}}}, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + input := Input{Content: &test.content, ModelConfigKey: pointer("claude")} + + variation, canceled, err := input.Variation(catalog, id, syncapi.Config{Key: "support", Mode: test.mode}, Questions{}) + + require.NoError(t, err) + assert.False(t, canceled) + want := test.want + want.Mode, want.Key, want.Name = test.mode, "incident-escalation", "Incident escalation" + want.ModelConfigKey, want.ModelConfigVersion = "claude", 3 + want.Model = catalog.modelConfigs[0].VariationModel() + assert.Equal(t, want, variation) + }) + } +} + +func TestMatchesVariationComparesOnlyThePassedFields(t *testing.T) { + id := syncdomain.VariationID("demo", "support", "escalation") + existing := syncdomain.Variation{ + Mode: syncdomain.VariationModeAgent, Key: "escalation", Name: "Escalation", Instructions: "Escalate.", + ModelConfigKey: "claude", ModelConfigVersion: 2, Model: map[string]any{"modelName": "claude-sonnet"}, + Tools: []syncdomain.AttachmentRef{{Key: "search", Version: 4}}, + } + tests := map[string]struct { + input Input + want bool + }{ + "prompt only": {input: Input{Content: pointer("Escalate.\n")}, want: true}, + "same name and model": {input: Input{Content: pointer("Escalate."), Name: pointer("Escalation"), ModelConfigKey: pointer("claude")}, want: true}, + "other prompt": {input: Input{Content: pointer("Escalate now.")}}, + "other name": {input: Input{Content: pointer("Escalate."), Name: pointer("Escalate")}}, + "other model config key": {input: Input{Content: pointer("Escalate."), ModelConfigKey: pointer("gpt")}}, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + matches, err := test.input.MatchesVariation(id, existing) + + require.NoError(t, err) + assert.Equal(t, test.want, matches) + }) + } +} + // fakeCatalog has model configs. It fails a project search, and answers a // config search with a 404, as LaunchDarkly does for a missing project. type fakeCatalog struct { diff --git a/internal/sync/link/link.go b/internal/sync/link/link.go index fe1c9eaf8..bbf6ed760 100644 --- a/internal/sync/link/link.go +++ b/internal/sync/link/link.go @@ -217,14 +217,7 @@ func addMissingPromptContent( // linked file, and creates the variation file. If the variation file fails, // it restores the linked file. func createLinkedPrompt(options Options, selection Selection, prompt linkedPrompt) (string, error) { - variation := syncdomain.Variation{ - Mode: selection.Config.Mode, - Key: selection.Key, - Name: selection.Name, - ModelConfigKey: selection.ModelConfig.Key, - ModelConfigVersion: selection.ModelConfig.Version, - Model: selection.ModelConfig.VariationModel(), - } + variation := synccreate.NewVariation(selection.Config, selection.Key, selection.Name, selection.ModelConfig) if err := syncreference.ApplyToVariation(prompt.reference.Format, prompt.content, &variation); err != nil { return "", err } diff --git a/internal/sync/local/variation.go b/internal/sync/local/variation.go index d31b9ea22..489b362a2 100644 --- a/internal/sync/local/variation.go +++ b/internal/sync/local/variation.go @@ -75,7 +75,7 @@ func parseVariation(file localFile) (syncdomain.SyncedResource, error) { if stem := variationStem(file.RelPath); variation.Key != stem { return syncdomain.SyncedResource{}, fmt.Errorf("referenced prompt key %q does not match filename %q", variation.Key, stem) } - } else if err := parsePromptBody(string(body), &variation); err != nil { + } else if err := ParsePromptBody(string(body), &variation); err != nil { return syncdomain.SyncedResource{}, err } @@ -118,9 +118,9 @@ func parseVariationFrontMatter(relPath string, data []byte) (variationFrontMatte return meta, body, nil } -// parsePromptBody stores an inline body as agent instructions or as +// ParsePromptBody stores an inline body as agent instructions or as // completion messages. -func parsePromptBody(body string, variation *syncdomain.Variation) error { +func ParsePromptBody(body string, variation *syncdomain.Variation) error { if variation.Mode == syncdomain.VariationModeAgent { variation.Instructions = syncdomain.NormalizePromptText(body) return nil diff --git a/internal/sync/prompt/acceptance_test.go b/internal/sync/prompt/acceptance_test.go index c0b77f97f..da1d25f97 100644 --- a/internal/sync/prompt/acceptance_test.go +++ b/internal/sync/prompt/acceptance_test.go @@ -2,6 +2,7 @@ package prompt_test import ( "bytes" + "cmp" "encoding/json" "errors" "fmt" @@ -32,12 +33,15 @@ import ( type directAPI struct { variation *syncdomain.Variation variationState string - modelConfigs []syncapi.ModelConfig - tools map[string]versionedTool - skills map[string]versionedSkill - manifest *syncapi.SyncManifest - requests []string - // createErr fails each create of a tool or a skill after onCreate runs. + // configMode is the mode of the config "support". The empty mode is agent. + configMode syncdomain.VariationMode + modelConfigs []syncapi.ModelConfig + tools map[string]versionedTool + skills map[string]versionedSkill + manifest *syncapi.SyncManifest + requests []string + // createErr fails each create of a tool, a skill, or a variation after + // onCreate runs. // onCreate can add the resource, as another working copy does. createErr error onCreate func() @@ -92,7 +96,8 @@ func (api *directAPI) MakeRequest( if strings.HasSuffix(path, "/configs/sync/manifests") { return handleManifestRequest(api.manifest, method, query, body) } - isCreate := method == http.MethodPost && (strings.HasSuffix(path, "/ai-tools") || strings.HasSuffix(path, "/ai-configs/skills")) + isCreate := method == http.MethodPost && + (strings.HasSuffix(path, "/ai-tools") || strings.HasSuffix(path, "/ai-configs/skills") || strings.HasSuffix(path, "/variations")) if isCreate && api.createErr != nil { if api.onCreate != nil { api.onCreate() @@ -155,6 +160,9 @@ func (api *directAPI) MakeRequest( } return json.Marshal(skill) } + if method == "GET" && strings.HasSuffix(path, "/model-configs") { + return json.Marshal(api.modelConfigs) + } if method == "GET" && strings.Contains(path, "/model-configs/") { for _, modelConfig := range api.modelConfigs { if strings.HasSuffix(path, "/"+modelConfig.Key) { @@ -174,14 +182,14 @@ func (api *directAPI) MakeRequest( }) } return json.Marshal(map[string]any{ - "key": "support", "name": "Support", "mode": "agent", "variations": variations, + "key": "support", "name": "Support", "mode": cmp.Or(api.configMode, syncdomain.VariationModeAgent), "variations": variations, }) case "POST": var variation syncdomain.Variation if err := json.Unmarshal(body, &variation); err != nil { return nil, err } - variation.Mode = syncdomain.VariationModeAgent + variation.Mode = cmp.Or(api.configMode, syncdomain.VariationModeAgent) api.variation = &variation api.variationState = "published" case "PATCH": diff --git a/internal/sync/prompt/action.go b/internal/sync/prompt/action.go index 1d58e69b8..9c4199b0f 100644 --- a/internal/sync/prompt/action.go +++ b/internal/sync/prompt/action.go @@ -80,13 +80,14 @@ func (action AddAction) validate(scope syncdomain.Scope) error { if action.Create == nil { return nil } - if !syncdomain.AttachmentKind(kind).Valid() { - return errors.New("add creates only tools and skills") + if len(action.Resources) != 0 { + return errors.New("a variation that add creates comes from --project, --config-key, and --key, " + + "not from project-key/config-key/variation-key arguments") } - if len(selections(action.Resources, action.Filter, kind)) > 1 { + if len(action.Filter.Keys) > 1 { return fmt.Errorf("add creates one %s at a time. Pass at most one --key.", kind) } - return action.Create.Check(syncdomain.AttachmentKind(kind)) + return action.Create.Check(kind) } func (action DetachAction) validate(scope syncdomain.Scope) error { diff --git a/internal/sync/prompt/create.go b/internal/sync/prompt/create.go index 68484e3dc..a15c96988 100644 --- a/internal/sync/prompt/create.go +++ b/internal/sync/prompt/create.go @@ -3,6 +3,7 @@ package prompt import ( "errors" "fmt" + "slices" syncdomain "github.com/launchdarkly/ldcli/internal/sync" syncapi "github.com/launchdarkly/ldcli/internal/sync/api" @@ -12,18 +13,20 @@ import ( synclocal "github.com/launchdarkly/ldcli/internal/sync/local" ) -// runCreate creates one tool or skill from the create flags of add. Add never +// runCreate creates one resource from the create flags of add. Add never // changes a resource that LaunchDarkly has. It starts syncing one that has the // content of the flags, and refuses one with other content. func (runner Runner) runCreate(options Options, workspace syncWorkspace, action AddAction) error { - client := runner.api(options) - questions := synccreate.Questions{ - // The forms write to the output stream, so check that stream. - Allowed: !options.NoInput && runner.isTerminal(options.Input, options.Output), - Input: options.Input, - Output: options.Output, - Projects: client, + if options.Scope.KeyKind() == syncdomain.KindVariation { + return runner.createVariation(options, workspace, action) } + return runner.createAttachment(options, workspace, action) +} + +// createAttachment creates a tool or a skill. +func (runner Runner) createAttachment(options Options, workspace syncWorkspace, action AddAction) error { + client := runner.api(options) + questions := runner.createQuestions(options, client) var key string if len(action.Filter.Keys) == 1 { key = action.Filter.Keys[0] @@ -45,7 +48,13 @@ func (runner Runner) runCreate(options Options, workspace syncWorkspace, action existing, err := client.ReadAttachment(id.ProjectKey, created.Kind, id.LookupKey) switch { case err == nil: - return runner.adoptAttachment(options, workspace, action, id, created, existing) + if err := runner.adopt(options, workspace, action, id, input.Matches(created, existing)); err != nil { + return err + } + if name := input.Name; name != nil && existing.Skill != nil && *name != existing.Skill.Name { + _ = syncconsole.New(options.ErrorOutput).Printf("Note: %s keeps its LaunchDarkly name %q.\n", id.Label(), existing.Skill.Name) + } + return nil case !syncapi.IsNotFound(err): return err } @@ -57,58 +66,117 @@ func (runner Runner) runCreate(options Options, workspace syncWorkspace, action if created, err = input.Attachment(id); err != nil { return err } - return runner.createAttachment(options, workspace, client, syncdomain.SyncedAttachment{ - ProjectKey: id.ProjectKey, Attachment: created, - }, input.Name != nil) + file := syncdomain.SyncedAttachment{ProjectKey: id.ProjectKey, Attachment: created} + row := planAttachment(id, &file.Attachment, nil, "", false) + if id.Kind == syncdomain.KindSkill { + row.nameSource = nameFromKey + if input.Name != nil { + row.nameSource = nameFromFlag + } + } + return runner.createResource(options, workspace, client, row, func() (synclocal.Creation, error) { + paths, err := workspace.local.ReplaceAttachments([]syncdomain.SyncedAttachment{file}) + return synclocal.Creation{CreatedPaths: paths}, err + }) } -// adoptAttachment starts syncing a tool or skill that LaunchDarkly already -// has, with the add of a resource that exists. It refuses when a flag that the -// user passed differs from LaunchDarkly. -func (runner Runner) adoptAttachment( - options Options, - workspace syncWorkspace, - action AddAction, - id ResourceID, - created, existing syncdomain.Attachment, -) error { - if !action.Create.Matches(created, existing) { +// createVariation creates a prompt variation in a config that exists. +func (runner Runner) createVariation(options Options, workspace syncWorkspace, action AddAction) error { + client := runner.api(options) + questions := runner.createQuestions(options, client) + input := *action.Create + id, canceled, err := input.VariationTarget(client, action.Filter, questions) + if err != nil || canceled { + return err + } + if err := syncbootstrap.CheckNotSynced(workspace.local, []ResourceID{id}); err != nil { + return err + } + + configKey, variationKey, err := id.VariationKeys() + if err != nil { + return err + } + config, err := client.Config(id.ProjectKey, configKey) + if err != nil { + return err + } + if index := slices.IndexFunc(config.Variations, func(existing syncdomain.Variation) bool { return existing.Key == variationKey }); index >= 0 { + matches, err := input.MatchesVariation(id, config.Variations[index]) + if err != nil { + return err + } + return runner.adopt(options, workspace, action, id, matches) + } + if slices.Contains(config.ArchivedVariationKeys, variationKey) { + return fmt.Errorf("%s is archived in LaunchDarkly, and a new variation cannot use its key. Pass another --key.", id.Label()) + } + + variation, canceled, err := input.Variation(client, id, config, questions) + if err != nil || canceled { + return err + } + file := syncdomain.SyncedResource{ + Kind: syncdomain.KindVariation, ProjectKey: id.ProjectKey, LookupKey: id.LookupKey, Upsert: true, Variation: variation, + } + row := planResource(id, &file, ServerResource{ConfigMode: config.Mode}, "", false) + return runner.createResource(options, workspace, client, row, func() (synclocal.Creation, error) { + return workspace.local.Add([]synclocal.VariationFile{{ + ProjectKey: id.ProjectKey, ConfigKey: configKey, Upsert: true, Variation: variation, + }}) + }) +} + +// createQuestions are the questions of a create. The forms write to the +// output stream, so the check for a terminal uses that stream. +func (runner Runner) createQuestions(options Options, client syncapi.Client) synccreate.Questions { + return synccreate.Questions{ + Allowed: !options.NoInput && runner.isTerminal(options.Input, options.Output), + Input: options.Input, + Output: options.Output, + Projects: client, + } +} + +// adopt starts syncing a resource that LaunchDarkly already has, with the add +// of a resource that exists. matches is false when a flag that the user passed +// differs from LaunchDarkly, and then adopt refuses. +func (runner Runner) adopt(options Options, workspace syncWorkspace, action AddAction, id ResourceID, matches bool) error { + if !matches { return fmt.Errorf("%s already exists in LaunchDarkly with different content, and add does not change it. "+ - "To sync the LaunchDarkly version, run %s, then edit the file and run ldcli sync %ss or ldcli sync all.", id.Label(), syncbootstrap.AddCommand(id), id.Kind) + "To sync the LaunchDarkly version, run %s, then edit the file and run %s or ldcli sync all.", + id.Label(), syncbootstrap.AddCommand(id), syncbootstrap.SyncCommand(id.Kind)) } - adopt := AddAction{Filter: syncdomain.Filter{Project: id.ProjectKey, Keys: []string{id.LookupKey}}, DryRun: action.DryRun} - if err := runner.runAdd(options, workspace, adopt); err != nil { + if err := runner.runAdd(options, workspace, AddAction{Filter: resourceFilter(id), DryRun: action.DryRun}); err != nil { return err } - notes := syncconsole.New(options.ErrorOutput) started := "started" if action.DryRun { started = "will start" } - _ = notes.Printf("Note: %s already exists in LaunchDarkly with this content, so add %s syncing it.\n", id.Label(), started) - if name := action.Create.Name; name != nil && existing.Skill != nil && *name != existing.Skill.Name { - _ = notes.Printf("Note: %s keeps its LaunchDarkly name %q.\n", id.Label(), existing.Skill.Name) - } + _ = syncconsole.New(options.ErrorOutput).Printf( + "Note: %s already exists in LaunchDarkly with this content, so add %s syncing it.\n", id.Label(), started, + ) return nil } -// createAttachment creates a tool or skill that LaunchDarkly does not have. It -// plans the create in memory and asks before it writes the file, so that a -// cancel leaves nothing and a watch cannot create the resource before the -// answer. named is true when the user chose the name of a skill. -func (runner Runner) createAttachment( +// createResource creates a resource that LaunchDarkly does not have. row is +// the create row of the resource, and write writes its file. It plans the +// create in memory and asks before it writes the file, so that a cancel +// leaves nothing and a watch cannot create the resource before the answer. +func (runner Runner) createResource( options Options, workspace syncWorkspace, client syncapi.Client, - created syncdomain.SyncedAttachment, - named bool, + row PlannedResource, + write func() (synclocal.Creation, error), ) error { - path, err := synclocal.ResourcePath(created.ID()) + path, err := synclocal.ResourcePath(row.ID) if err != nil { return err } - reviewed := reviewedCreate(options.Scope, created, named) + reviewed := workspaceState{projectKeys: []string{row.ID.ProjectKey}, plan: Plan{Scope: options.Scope, Resources: []PlannedResource{row}}} if options.dryRun() { if err := writePlanOutput(options.Output, options.OutputKind, reviewed.plan, options.Diff); err != nil { return err @@ -121,28 +189,14 @@ func (runner Runner) createAttachment( } // A file can appear while the question waits. - if err := syncbootstrap.CheckNotSynced(workspace.local, []ResourceID{created.ID()}); err != nil { + if err := syncbootstrap.CheckNotSynced(workspace.local, []ResourceID{row.ID}); err != nil { return err } - written, err := workspace.local.ReplaceAttachments([]syncdomain.SyncedAttachment{created}) + creation, err := write() if err != nil { return err } - return runner.applyCreate(options, workspace, client, reviewed, path, synclocal.Creation{CreatedPaths: written}) -} - -// reviewedCreate is the state that the user reviews before a create: the -// create row of the new resource, with no file yet. -func reviewedCreate(scope syncdomain.Scope, created syncdomain.SyncedAttachment, named bool) workspaceState { - id := created.ID() - row := planAttachment(id, &created.Attachment, nil, "", false) - if id.Kind == syncdomain.KindSkill { - row.nameSource = nameFromKey - if named { - row.nameSource = nameFromFlag - } - } - return workspaceState{projectKeys: []string{id.ProjectKey}, plan: Plan{Scope: scope, Resources: []PlannedResource{row}}} + return runner.applyCreate(options, workspace, client, reviewed, path, creation) } // applyCreate applies the reviewed create after it wrote the new file at path. @@ -169,14 +223,14 @@ func (runner Runner) applyCreate( } id := reviewed.plan.Resources[0].ID - current, err := workspace.loadState(client, options.Scope, syncdomain.Filter{Project: id.ProjectKey, Keys: []string{id.LookupKey}}) + current, err := workspace.loadState(client, options.Scope, resourceFilter(id)) if err == nil { err = checkUnchanged(options, reviewed, current) } if err != nil { return remove(err) } - // The new file has the name of the skill, but only the reviewed row knows + // The new file of a skill has its name, but only the reviewed row knows // where the name came from. current.plan.Resources[0].nameSource = reviewed.plan.Resources[0].nameSource @@ -189,3 +243,11 @@ func (runner Runner) applyCreate( } return err } + +// resourceFilter is the filter that keeps only one resource. +func resourceFilter(id ResourceID) syncdomain.Filter { + if configKey, variationKey, err := id.VariationKeys(); err == nil { + return syncdomain.Filter{Project: id.ProjectKey, ConfigKey: configKey, Keys: []string{variationKey}} + } + return syncdomain.Filter{Project: id.ProjectKey, Keys: []string{id.LookupKey}} +} diff --git a/internal/sync/prompt/create_test.go b/internal/sync/prompt/create_test.go index 68e0bb7fb..20fc0affb 100644 --- a/internal/sync/prompt/create_test.go +++ b/internal/sync/prompt/create_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/require" syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncapi "github.com/launchdarkly/ldcli/internal/sync/api" synccreate "github.com/launchdarkly/ldcli/internal/sync/create" syncprompt "github.com/launchdarkly/ldcli/internal/sync/prompt" ) @@ -550,3 +551,245 @@ func (reader *answerReader) Read(buffer []byte) (int, error) { } return copy(buffer, reader.answer), nil } + +var ( + escalationID = syncdomain.VariationID("production", "support", "escalation") + escalationFile = "configs/support/escalation.prompt.md" + claude = syncapi.ModelConfig{Key: "claude", ID: "claude-sonnet", Name: "Claude", Version: 3} +) + +func TestCreateMakesAnAgentVariationFromFlags(t *testing.T) { + root := initRepository(t) + api := &directAPI{modelConfigs: []syncapi.ModelConfig{{Key: "gpt", ID: "gpt-5", Version: 1}, claude}} + action := createVariationAction("escalation", synccreate.Input{ + Content: pointer("Hand the conversation to a person."), ModelConfigKey: pointer("claude"), + }) + + stdout, notes, err := runScoped(t, root, api, syncprompt.Options{Scope: syncdomain.ScopePrompts, Action: action, Yes: true}) + + require.NoError(t, err) + assert.JSONEq(t, `{"resources": [{"resourceKind": "variation", "projectKey": "production", "lookupKey": "support/escalation", + "action": "create_server", "status": "succeeded"}]}`, stdout) + assert.Equal(t, "Note: Created .launchdarkly/production/"+escalationFile+".\n", notes) + assert.Equal(t, &syncdomain.Variation{ + Mode: syncdomain.VariationModeAgent, Key: "escalation", Name: "Escalation", Instructions: "Hand the conversation to a person.", + ModelConfigKey: "claude", ModelConfigVersion: 3, Model: claude.VariationModel(), + }, api.variation) + file := readLocalFile(t, root, escalationFile) + assert.Contains(t, file, "upsert: true\nmode: agent\nkey: escalation\nname: Escalation\nmodelConfigKey: claude\nmodelConfigVersion: 3\n") + assert.NotContains(t, file, "ref:") + assert.Equal(t, []syncdomain.ResourceID{escalationID}, lockResourceIDs(readLock(t, root))) + + stdout, err = runSyncAction(t, root, api, syncdomain.ScopePrompts, syncprompt.SyncAction{DryRun: true}, false) + require.NoError(t, err) + assert.Equal(t, []string{"variation support/escalation in_sync"}, planRows(t, stdout)) +} + +func TestCreateMakesACompletionVariationFromFlags(t *testing.T) { + tests := map[string]struct { + content string + want []syncdomain.Message + }{ + "role blocks": { + content: "Be brief.\n{{question}}", + want: []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "Be brief."}, {Role: "user", Content: "{{question}}"}}, + }, + "plain text": { + content: "Be brief.", + want: []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "Be brief."}}, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + root := initRepository(t) + api := &directAPI{configMode: syncdomain.VariationModeCompletion, modelConfigs: []syncapi.ModelConfig{claude}} + action := createVariationAction("escalation", synccreate.Input{Content: &test.content, ModelConfigKey: pointer("claude")}) + + _, _, err := runScoped(t, root, api, syncprompt.Options{Scope: syncdomain.ScopePrompts, Action: action, Yes: true}) + + require.NoError(t, err) + require.NotNil(t, api.variation) + assert.Equal(t, test.want, api.variation.Messages) + assert.Empty(t, api.variation.Instructions) + }) + } +} + +func TestCreateVariationLeavesOtherPendingChangesAsTheyAre(t *testing.T) { + root := initRepository(t) + writeLocalFile(t, root, "tools/refund.json", `{"formatVersion": 1, "upsert": true, "key": "refund", "schema": {"type": "object"}}`) + api := &directAPI{modelConfigs: []syncapi.ModelConfig{claude}} + action := createVariationAction("escalation", synccreate.Input{Content: pointer("Escalate."), ModelConfigKey: pointer("claude")}) + + _, _, err := runScoped(t, root, api, syncprompt.Options{Scope: syncdomain.ScopePrompts, Action: action, Yes: true}) + + require.NoError(t, err) + require.NotNil(t, api.variation) + assert.NotContains(t, api.tools, "refund", "the new tool waits for a sync") + assert.Equal(t, []syncdomain.ResourceID{escalationID}, lockResourceIDs(readLock(t, root))) +} + +func TestCreateAdoptsAVariationWhoseModelConfigHasANewerVersion(t *testing.T) { + root := initRepository(t) + older := claude + older.Version = 2 + existing := syncdomain.Variation{ + Mode: syncdomain.VariationModeAgent, Key: "escalation", Name: "Escalation", Instructions: "Escalate.", + ModelConfigKey: "claude", ModelConfigVersion: 2, Model: older.VariationModel(), + } + api := &directAPI{variation: &existing, variationState: "published", modelConfigs: []syncapi.ModelConfig{claude}} + action := createVariationAction("escalation", synccreate.Input{ + Content: pointer("Escalate."), Name: pointer("Escalation"), ModelConfigKey: pointer("claude"), + }) + + stdout, notes, err := runScoped(t, root, api, syncprompt.Options{Scope: syncdomain.ScopePrompts, Action: action, Yes: true}) + + require.NoError(t, err) + assert.Equal(t, "Bootstrapped 1 variation file in .launchdarkly.\n", stdout) + assert.Equal(t, "Note: variation production/support/escalation already exists in LaunchDarkly with this content, "+ + "so add started syncing it.\n", notes) + requireOnlyReads(t, api.requests) + assert.Contains(t, readLocalFile(t, root, escalationFile), "modelConfigVersion: 2\n", "the file takes the version from LaunchDarkly") +} + +func TestCreateRefusesAVariationWithOtherContent(t *testing.T) { + refusal := "variation production/support/escalation already exists in LaunchDarkly with different content, " + + "and add does not change it. To sync the LaunchDarkly version, run " + + "ldcli sync prompts add --project production --config-key support --key escalation, then edit the file and run ldcli sync prompts or ldcli sync all." + tests := map[string]synccreate.Input{ + "prompt": {Content: pointer("Escalate now."), ModelConfigKey: pointer("claude")}, + "name": {Content: pointer("Escalate."), Name: pointer("Escalate"), ModelConfigKey: pointer("claude")}, + "model config": {Content: pointer("Escalate."), ModelConfigKey: pointer("gpt")}, + } + for name, input := range tests { + t.Run(name, func(t *testing.T) { + root := initRepository(t) + api := &directAPI{variation: &syncdomain.Variation{ + Mode: syncdomain.VariationModeAgent, Key: "escalation", Name: "Escalation", Instructions: "Escalate.", + ModelConfigKey: "claude", ModelConfigVersion: 3, Model: claude.VariationModel(), + }, variationState: "published", modelConfigs: []syncapi.ModelConfig{claude}} + + _, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, Action: createVariationAction("escalation", input), Yes: true, + }) + + require.EqualError(t, err, refusal) + requireOnlyReads(t, api.requests) + assert.NoDirExists(t, filepath.Join(root, syncdomain.RootDir)) + }) + } +} + +func TestCreateRefusesTheKeyOfAnArchivedVariation(t *testing.T) { + root := initRepository(t) + api := &directAPI{variation: &syncdomain.Variation{ + Mode: syncdomain.VariationModeAgent, Key: "escalation", Name: "Escalation", Instructions: "Escalate.", + }, variationState: "archived", modelConfigs: []syncapi.ModelConfig{claude}} + action := createVariationAction("escalation", synccreate.Input{Content: pointer("Escalate."), ModelConfigKey: pointer("claude")}) + + _, _, err := runScoped(t, root, api, syncprompt.Options{Scope: syncdomain.ScopePrompts, Action: action, Yes: true}) + + require.EqualError(t, err, "variation production/support/escalation is archived in LaunchDarkly, "+ + "and a new variation cannot use its key. Pass another --key.") + requireOnlyReads(t, api.requests) + assert.NoDirExists(t, filepath.Join(root, syncdomain.RootDir)) +} + +func TestCreateVariationChecksTheFlagsBeforeAnyRequest(t *testing.T) { + valid := synccreate.Input{Content: pointer("Escalate."), ModelConfigKey: pointer("claude")} + tests := map[string]struct { + action syncprompt.AddAction + want string + }{ + "no target flags": { + action: syncprompt.AddAction{Create: &valid}, + want: "variation creation requires --project, --config-key, and --key without interactive input", + }, + "no model config": { + action: createVariationAction("escalation", synccreate.Input{Content: pointer("Escalate.")}), + want: "variation creation requires --model-config-key without interactive input", + }, + "name without content": { + action: createVariationAction("escalation", synccreate.Input{Name: pointer("Escalation")}), + want: "--name needs --content. add creates a variation only when --content is set.", + }, + "blank content": { + action: createVariationAction("escalation", synccreate.Input{Content: pointer("\t"), ModelConfigKey: pointer("claude")}), + want: "--content cannot be blank", + }, + "positional selector": { + action: syncprompt.AddAction{Resources: []syncdomain.ResourceID{escalationID}, Create: &valid}, + want: "a variation that add creates comes from --project, --config-key, and --key, " + + "not from project-key/config-key/variation-key arguments", + }, + "two keys": { + action: syncprompt.AddAction{ + Filter: syncdomain.Filter{Project: "production", ConfigKey: "support", Keys: []string{"escalation", "handoff"}}, + Create: &valid, + }, + want: "add creates one variation at a time. Pass at most one --key.", + }, + "invalid key": { + action: createVariationAction("escalate now", valid), + want: `variation key "escalate now" must use only letters, digits, ".", "_", and "-", ` + + "and start with a letter or digit", + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + root := initRepository(t) + api := &directAPI{modelConfigs: []syncapi.ModelConfig{claude}} + + _, _, err := runScoped(t, root, api, syncprompt.Options{Scope: syncdomain.ScopePrompts, Action: test.action, Yes: true}) + + require.EqualError(t, err, test.want) + assert.Empty(t, api.requests) + }) + } +} + +func TestCreateVariationReadsTheContentBeforeTheModelConfig(t *testing.T) { + root := initRepository(t) + api := &directAPI{configMode: syncdomain.VariationModeCompletion, modelConfigs: []syncapi.ModelConfig{claude}} + action := createVariationAction("escalation", synccreate.Input{Content: pointer("Be brief."), ModelConfigKey: pointer("claude")}) + + _, _, err := runScoped(t, root, api, syncprompt.Options{Scope: syncdomain.ScopePrompts, Action: action, Yes: true}) + + require.EqualError(t, err, "--content: unclosed tag") + assert.Equal(t, []string{"GET https://example.test/api/v2/projects/production/ai-configs/support"}, api.requests) +} + +func TestCreateVariationNeedsAModelConfigThatExists(t *testing.T) { + root := initRepository(t) + api := &directAPI{modelConfigs: []syncapi.ModelConfig{claude}} + action := createVariationAction("escalation", synccreate.Input{Content: pointer("Escalate."), ModelConfigKey: pointer("llama")}) + + _, _, err := runScoped(t, root, api, syncprompt.Options{Scope: syncdomain.ScopePrompts, Action: action, Yes: true}) + + require.EqualError(t, err, `model config "llama" was not found`) + requireOnlyReads(t, api.requests) + assert.NoDirExists(t, filepath.Join(root, syncdomain.RootDir)) +} + +func TestCreateVariationRemovesTheFileWhenLaunchDarklyRejectsTheCreate(t *testing.T) { + root := initRepository(t) + api := &directAPI{ + modelConfigs: []syncapi.ModelConfig{claude}, + createErr: errors.New(`{"code":"invalid_request","message":"instructions are too long","statusCode":400}`), + } + action := createVariationAction("escalation", synccreate.Input{Content: pointer("Escalate."), ModelConfigKey: pointer("claude")}) + + _, notes, err := runScoped(t, root, api, syncprompt.Options{Scope: syncdomain.ScopePrompts, Action: action, Yes: true}) + + require.ErrorContains(t, err, "instructions are too long") + assert.Equal(t, "Note: Removed .launchdarkly/production/"+escalationFile+".\n", notes) + assert.NoDirExists(t, filepath.Join(root, syncdomain.RootDir)) +} + +// createVariationAction is the add that creates the variation key in the +// config "support" of the project "production". +func createVariationAction(key string, input synccreate.Input) syncprompt.AddAction { + return syncprompt.AddAction{ + Filter: syncdomain.Filter{Project: "production", ConfigKey: "support", Keys: []string{key}}, Create: &input, + } +}