Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 1 addition & 48 deletions internal/sync/create/attachment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}

Expand Down
59 changes: 57 additions & 2 deletions internal/sync/create/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package create

import (
"errors"
"fmt"
"io"
"strings"
Expand All @@ -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
Expand Down
23 changes: 14 additions & 9 deletions internal/sync/create/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
}
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down
147 changes: 147 additions & 0 deletions internal/sync/create/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Loading
Loading