Skip to content
Draft
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
53 changes: 53 additions & 0 deletions cmd/sync/attachments.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,64 @@ func newAttachmentScopeCmd(client resources.Client, kind syncdomain.AttachmentKi
newWatchCmd(client, scope, help),
newAttachmentAddCmd(client, kind),
newAttachmentDetachCmd(client, kind),
newAttachmentLinkCmd(client, kind),
)
cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate())
return cmd
}

func newAttachmentLinkCmd(client resources.Client, kind syncdomain.AttachmentKind) *cobra.Command {
scope := syncdomain.Scope(kind + "s")
cmd := &cobra.Command{
Use: "link",
Short: fmt.Sprintf("Link a repository source to one %s", kind),
Example: fmt.Sprintf(` ldcli sync %[1]ss link \
--file src/resources.ts \
--format typescript \
--select '%[1]ss[id="example"]' \
--map description=description \
--project production --key example \
--yes --no-input`, kind),
Args: validatedArgs(cobra.NoArgs),
RunE: runCommand(client, scope, func(cmd *cobra.Command, _ []string) (syncprompt.CommandAction, error) {
id, err := sourceLinkID(cmd, syncdomain.Kind(kind))
if err != nil {
return nil, err
}
fieldMap, err := parseFieldMappings(mustStringArray(cmd, mapFlag))
if err != nil {
return nil, err
}
return syncprompt.SourceLinkAction{
ID: id,
File: mustString(cmd, fileFlag),
Format: mustString(cmd, formatFlag),
Select: mustString(cmd, selectFlag),
Map: fieldMap,
}, nil
}),
}
addTargetFlags(cmd, attachmentFlagHelp(kind, false))
cmd.Flags().String(fileFlag, "", "Repository source file")
cmd.Flags().String(formatFlag, "", "Source format")
cmd.Flags().String(selectFlag, "", "Source value to select")
cmd.Flags().StringArray(mapFlag, nil, "Mapped field in field=path form. Repeat for more fields.")
_ = cmd.MarkFlagRequired(fileFlag)
_ = cmd.MarkFlagRequired(formatFlag)
addApplyFlags(cmd, scope, false)
return cmd
}

func mustString(cmd *cobra.Command, name string) string {
value, _ := cmd.Flags().GetString(name)
return value
}

func mustStringArray(cmd *cobra.Command, name string) []string {
value, _ := cmd.Flags().GetStringArray(name)
return value
}

// attachmentScopeLong is the description of sync tools or sync skills.
func attachmentScopeLong(kind syncdomain.AttachmentKind) string {
upsert := `set "upsert": true in its file`
Expand Down
96 changes: 95 additions & 1 deletion cmd/sync/prompt.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package sync

import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
Expand All @@ -12,6 +14,7 @@ import (
"github.com/launchdarkly/ldcli/cmd/cliflags"
resourcescmd "github.com/launchdarkly/ldcli/cmd/resources"
"github.com/launchdarkly/ldcli/cmd/validators"
lderrors "github.com/launchdarkly/ldcli/internal/errors"
"github.com/launchdarkly/ldcli/internal/output"
"github.com/launchdarkly/ldcli/internal/resources"
syncdomain "github.com/launchdarkly/ldcli/internal/sync"
Expand All @@ -26,13 +29,16 @@ const (
descriptionFlag = "description"
diffFlag = "diff"
dryRunFlag = "dry-run"
fileFlag = "file"
formatFlag = "format"
jsonSchemaFlag = "json-schema"
keyFlag = "key"
modelConfigKeyFlag = "model-config-key"
nameFlag = "name"
noInputFlag = "no-input"
resolveFlag = "resolve"
selectFlag = "select"
mapFlag = "map"
toFlag = "to"
yesFlag = "yes"
)
Expand Down Expand Up @@ -67,6 +73,7 @@ func NewPromptCmd(client resources.Client) *cobra.Command {
newAddCmd(client),
newAttachCmd(client),
newDetachCmd(client),
newLinkCmd(client),
)
cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate())

Expand Down Expand Up @@ -119,12 +126,22 @@ func runCommand(client resources.Client, scope syncdomain.Scope, buildAction act
ErrorOutput: cmd.ErrOrStderr(),
})
if err != nil {
return output.NewCmdOutputError(err, outputKind)
return syncCommandOutputError(err, outputKind)
}
return nil
}
}

func syncCommandOutputError(err error, outputKind string) error {
if problem, ok := syncdomain.ProblemFrom(err); ok {
data, marshalErr := json.Marshal(problem)
if marshalErr == nil {
err = lderrors.NewError(string(data))
}
}
return output.NewCmdOutputError(err, outputKind)
}

// newWatchCmd creates the watch command of a scope. help is the help text of
// the target flags of the root sync of the scope.
func newWatchCmd(client resources.Client, scope syncdomain.Scope, help targetFlagHelp) *cobra.Command {
Expand Down Expand Up @@ -268,6 +285,83 @@ func newDetachCmd(client resources.Client) *cobra.Command {
return cmd
}

func newLinkCmd(client resources.Client) *cobra.Command {
cmd := &cobra.Command{
Use: "link",
Short: "Link a repository source to one prompt variation",
Example: ` # Link one Python agent
ldcli sync prompts link \
--file app/agents.py \
--format python \
--select 'agents[id="support"]' \
--map prompt=instructions \
--project production --config-key support --key default \
--yes --no-input`,
Args: validatedArgs(cobra.NoArgs),
RunE: runCommand(client, syncdomain.ScopePrompts, func(cmd *cobra.Command, _ []string) (syncprompt.CommandAction, error) {
format, _ := cmd.Flags().GetString(formatFlag)
sourceFile, _ := cmd.Flags().GetString(fileFlag)
selectPath, _ := cmd.Flags().GetString(selectFlag)
mappings, _ := cmd.Flags().GetStringArray(mapFlag)
id, err := sourceLinkID(cmd, syncdomain.KindVariation)
if err != nil {
return nil, err
}
fieldMap, err := parseFieldMappings(mappings)
if err != nil {
return nil, err
}
return syncprompt.SourceLinkAction{
ID: id, File: sourceFile, Format: format, Select: selectPath, Map: fieldMap,
}, nil
}),
}
addTargetFlags(cmd, questionFlagHelp)
cmd.Flags().String(fileFlag, "", "Repository source file")
cmd.Flags().String(formatFlag, "", "Source format")
cmd.Flags().String(selectFlag, "", "Source value to select")
cmd.Flags().StringArray(mapFlag, nil, "Mapped field in field=path form. Repeat for more fields.")
_ = cmd.MarkFlagRequired(fileFlag)
_ = cmd.MarkFlagRequired(formatFlag)
addApplyFlags(cmd, syncdomain.ScopePrompts, false)
return cmd
}

func sourceLinkID(cmd *cobra.Command, kind syncdomain.Kind) (syncdomain.ResourceID, error) {
filter := targetFilter(cmd)
if filter.Project == "" {
return syncdomain.ResourceID{}, errors.New("--project is required")
}
if len(filter.Keys) != 1 {
return syncdomain.ResourceID{}, errors.New("link requires exactly one --key")
}
if kind == syncdomain.KindVariation {
if filter.ConfigKey == "" {
return syncdomain.ResourceID{}, errors.New("--config-key is required")
}
return syncdomain.VariationID(filter.Project, filter.ConfigKey, filter.Keys[0]), nil
}
return syncdomain.ResourceID{Kind: kind, ProjectKey: filter.Project, LookupKey: filter.Keys[0]}, nil
}

func parseFieldMappings(values []string) (map[string]string, error) {
if len(values) == 0 {
return nil, nil
}
fieldMap := make(map[string]string, len(values))
for _, value := range values {
field, path, ok := strings.Cut(value, "=")
if !ok || field == "" || path == "" {
return nil, fmt.Errorf("invalid --map %q; expected field=path", value)
}
if _, duplicate := fieldMap[field]; duplicate {
return nil, fmt.Errorf("--map field %q was provided more than once", field)
}
fieldMap[field] = path
}
return fieldMap, nil
}

// addApplyFlags registers the flags of a command that applies a plan. Only
// the prompts scope has --resolve, which names variations.
func addApplyFlags(cmd *cobra.Command, scope syncdomain.Scope, dryRun bool) {
Expand Down
54 changes: 52 additions & 2 deletions cmd/sync/prompt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,65 @@ func TestPromptCommandUsesActionSubcommands(t *testing.T) {
command := NewPromptCmd(nil)

assert.Equal(t, "prompts", command.Use)
for _, name := range []string{"watch", "add", "attach", "detach"} {
for _, name := range []string{"watch", "add", "attach", "detach", "link"} {
child, _, err := command.Find([]string{name})
require.NoError(t, err)
assert.Equal(t, name, child.Name())
}
for _, name := range []string{"add", "attach-tool", "attach-skill", "detach", "watch"} {
for _, name := range []string{"add", "attach-tool", "attach-skill", "detach", "link", "watch"} {
assert.Nil(t, command.Flags().Lookup(name), "legacy --%s should not exist", name)
}
add, _, err := command.Find([]string{"add"})
require.NoError(t, err)
assert.Equal(t, "add [project-key/config-key/variation-key...]", add.Use)
link, _, err := command.Find([]string{"link"})
require.NoError(t, err)
assert.Nil(t, link.Flags().Lookup(toFlag))
assert.Nil(t, link.Flags().Lookup(modelConfigKeyFlag))
}

func TestSourceLinkFlagsBuildTargetAndMappings(t *testing.T) {
command := newLinkCmd(nil)
require.NoError(t, command.ParseFlags([]string{
"--file", "agents.py",
"--format", "python",
"--select", `agents[id="support"]`,
"--map", "prompt=instructions",
"--project", "production",
"--config-key", "support",
"--key", "default",
}))

id, err := sourceLinkID(command, syncdomain.KindVariation)
require.NoError(t, err)
assert.Equal(t, syncdomain.VariationID("production", "support", "default"), id)
mappings, err := parseFieldMappings(mustStringArray(command, mapFlag))
require.NoError(t, err)
assert.Equal(t, map[string]string{"prompt": "instructions"}, mappings)
}

func TestSyncProblemRendersStableFields(t *testing.T) {
problem := syncdomain.NewProblem(
syncdomain.ErrorPathNotFound,
"mapped field was not found",
"Update the map path.",
).WithResource(syncdomain.VariationID("project", "config", "default")).
WithFile("agents.py", 4, 12)

jsonError := syncCommandOutputError(problem, "json")
require.JSONEq(t, `{
"code": "path_not_found",
"message": "mapped field was not found",
"resource": "variation project/config/default",
"file": "agents.py",
"line": 4,
"column": 12,
"suggestion": "Update the map path."
}`, jsonError.Error())

plainError := syncCommandOutputError(problem, "plaintext")
assert.Contains(t, plainError.Error(), "mapped field was not found (code: path_not_found)")
assert.Contains(t, plainError.Error(), "Suggestion: Update the map path.")
}

func TestSyncCommandIncludesExamples(t *testing.T) {
Expand All @@ -53,6 +101,7 @@ func TestPromptCommandHelpIncludesExamples(t *testing.T) {
"attach tool": {"attach", "tool"},
"attach skill": {"attach", "skill"},
"detach": {"detach"},
"link": {"link"},
}

for name, path := range tests {
Expand Down Expand Up @@ -107,6 +156,7 @@ func TestPromptCommandsRegisterTargetFlags(t *testing.T) {
"add": {path: []string{"add"}, help: questionFlagHelp},
"detach": {path: []string{"detach"}, help: questionFlagHelp},
"attach tool": {path: []string{"attach", "tool"}, help: targetFlagHelp{project: questionFlagHelp.project}},
"link": {path: []string{"link"}, help: questionFlagHelp},
}

for name, test := range tests {
Expand Down
28 changes: 18 additions & 10 deletions cmd/sync/scope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,20 @@ var syncCommandFlags = map[string][]string{
"sync prompts attach tool": {"conflict", "diff", "no-input", "project", "resolve", "to", "yes"},
"sync prompts attach skill": {"conflict", "diff", "no-input", "project", "resolve", "to", "yes"},
"sync prompts detach": {"archive", "config-key", "key", "no-input", "project", "yes"},
"sync tools": {"conflict", "diff", "dry-run", "key", "no-input", "project", "yes"},
"sync tools watch": {"conflict", "diff", "key", "no-input", "project", "yes"},
"sync tools add": {"description", "diff", "dry-run", "json-schema", "key", "no-input", "project", "yes"},
"sync tools detach": {"key", "no-input", "project", "yes"},
"sync skills": {"conflict", "diff", "dry-run", "key", "no-input", "project", "yes"},
"sync skills watch": {"conflict", "diff", "key", "no-input", "project", "yes"},
"sync skills add": {"content", "description", "diff", "dry-run", "key", "name", "no-input", "project", "yes"},
"sync skills detach": {"key", "no-input", "project", "yes"},
"sync prompts link": {
"config-key", "conflict", "diff", "file", "format", "key", "map",
"no-input", "project", "resolve", "select", "yes",
},
"sync tools": {"conflict", "diff", "dry-run", "key", "no-input", "project", "yes"},
"sync tools watch": {"conflict", "diff", "key", "no-input", "project", "yes"},
"sync tools add": {"description", "diff", "dry-run", "json-schema", "key", "no-input", "project", "yes"},
"sync tools detach": {"key", "no-input", "project", "yes"},
"sync tools link": {"conflict", "diff", "file", "format", "key", "map", "no-input", "project", "select", "yes"},
"sync skills": {"conflict", "diff", "dry-run", "key", "no-input", "project", "yes"},
"sync skills watch": {"conflict", "diff", "key", "no-input", "project", "yes"},
"sync skills add": {"content", "description", "diff", "dry-run", "key", "name", "no-input", "project", "yes"},
"sync skills detach": {"key", "no-input", "project", "yes"},
"sync skills link": {"conflict", "diff", "file", "format", "key", "map", "no-input", "project", "select", "yes"},
}

func TestSyncCommandsHaveExactFlags(t *testing.T) {
Expand Down Expand Up @@ -65,14 +71,16 @@ func TestSyncCommandsReadSyncFlagsOnlyFromTheCommandLine(t *testing.T) {
t.Setenv("LD_"+strings.ToUpper(strings.ReplaceAll(name, "-", "_")), "true")
}
for _, name := range []string{
"config-key", "conflict", "content", "description", "format", "json-schema", "key", "model-config-key", "name", "resolve", "to",
"config-key", "conflict", "content", "description", "file", "format", "json-schema", "key",
"map", "model-config-key", "name", "resolve", "select", "to",
} {
t.Setenv("LD_"+strings.ToUpper(strings.ReplaceAll(name, "-", "_")), "from-environment")
}

for _, command := range runnableSyncCommands(NewSyncCmd(nil, nil)) {
require.NoError(t, command.ParseFlags(nil))
require.NoError(t, command.Args(command, nil), command.CommandPath())
var args []string
require.NoError(t, command.Args(command, args), command.CommandPath())
command.LocalNonPersistentFlags().VisitAll(func(flag *pflag.Flag) {
assert.False(t, flag.Changed, "%s --%s", command.CommandPath(), flag.Name)
})
Expand Down
Loading
Loading