diff --git a/internal/sync/create/attachment.go b/internal/sync/create/attachment.go
index 04fb735a..360bbe8f 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 f2fcfa7e..aca32791 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 0accc520..08a0f94b 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 94fccf6b..ae3780a1 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 ef59c11a..c96a642f 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 fe1c9eaf..bbf6ed76 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 d31b9ea2..489b362a 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 c0b77f97..da1d25f9 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 1d58e69b..9c4199b0 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 68484e3d..a15c9698 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 68e0bb7f..20fc0aff 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,
+ }
+}