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
59 changes: 52 additions & 7 deletions cmd/sync/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,24 @@ import (
"github.com/launchdarkly/ldcli/cmd/validators"
"github.com/launchdarkly/ldcli/internal/output"
"github.com/launchdarkly/ldcli/internal/resources"
syncdomain "github.com/launchdarkly/ldcli/internal/sync"
syncprompt "github.com/launchdarkly/ldcli/internal/sync/prompt"
)

const (
addFlag = "add"
detachFlag = "detach"
dryRunFlag = "dry-run"
formatFlag = "format"
linkFlag = "link"
watchFlag = "watch"
yesFlag = "yes"
addFlag = "add"
attachSkillFlag = "attach-skill"
attachToolFlag = "attach-tool"
detachFlag = "detach"
dryRunFlag = "dry-run"
formatFlag = "format"
linkFlag = "link"
projectFlag = "project"
variationFlag = "variation"
watchFlag = "watch"
yesFlag = "yes"

interactiveAttachment = "__interactive__"
)

// NewPromptCmd creates the prompt synchronization command.
Expand All @@ -42,10 +49,16 @@ func NewPromptCmd(client resources.Client) *cobra.Command {
}

cmd.Flags().Bool(addFlag, false, "Select additional prompt variations from LaunchDarkly")
cmd.Flags().String(attachSkillFlag, "", "Attach the latest skill version by key, or select interactively")
cmd.Flags().Lookup(attachSkillFlag).NoOptDefVal = interactiveAttachment
cmd.Flags().String(attachToolFlag, "", "Attach the latest tool version by key, or select interactively")
cmd.Flags().Lookup(attachToolFlag).NoOptDefVal = interactiveAttachment
cmd.Flags().Bool(detachFlag, false, "Select local resources to stop syncing")
cmd.Flags().Bool(dryRunFlag, false, "Preview synchronization changes without applying them")
cmd.Flags().String(linkFlag, "", "Link an external prompt file")
cmd.Flags().String(formatFlag, "", "Format adapter for --link (for example, plain-markdown)")
cmd.Flags().String(projectFlag, "", "Project key for an attachment operation")
cmd.Flags().String(variationFlag, "", "Config and variation key for an attachment operation (config/variation)")
cmd.Flags().Bool(watchFlag, false, "Sync when managed or referenced files change")
cmd.Flags().Bool(yesFlag, false, "Apply synchronization changes without interactive confirmation")
cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate())
Expand All @@ -61,6 +74,10 @@ func runPrompt(client resources.Client) func(*cobra.Command, []string) error {
}

add, _ := cmd.Flags().GetBool(addFlag)
attachment, err := attachmentRequest(cmd)
if err != nil {
return err
}
detach, _ := cmd.Flags().GetBool(detachFlag)
dryRun, _ := cmd.Flags().GetBool(dryRunFlag)
format, _ := cmd.Flags().GetString(formatFlag)
Expand All @@ -75,6 +92,7 @@ func runPrompt(client resources.Client) func(*cobra.Command, []string) error {
BaseURI: viper.GetString(cliflags.BaseURIFlag),
OutputKind: outputKind,
Add: add,
Attachment: attachment,
Detach: detach,
DryRun: dryRun,
Format: format,
Expand All @@ -92,3 +110,30 @@ func runPrompt(client resources.Client) func(*cobra.Command, []string) error {
return nil
}
}

func attachmentRequest(cmd *cobra.Command) (*syncprompt.AttachmentRequest, error) {
attachSkill := cmd.Flags().Changed(attachSkillFlag)
attachTool := cmd.Flags().Changed(attachToolFlag)
project, _ := cmd.Flags().GetString(projectFlag)
variation, _ := cmd.Flags().GetString(variationFlag)

if attachSkill && attachTool {
return nil, fmt.Errorf("--attach-skill and --attach-tool cannot be combined")
}
if !attachSkill && !attachTool {
if project != "" || variation != "" {
return nil, fmt.Errorf("--project and --variation require --attach-skill or --attach-tool")
}
return nil, nil
}

kind, flagName := syncdomain.AttachmentSkill, attachSkillFlag
if attachTool {
kind, flagName = syncdomain.AttachmentTool, attachToolFlag
}
key, _ := cmd.Flags().GetString(flagName)
if key == interactiveAttachment {
key = ""
}
return &syncprompt.AttachmentRequest{Kind: kind, Key: key, ProjectKey: project, Variation: variation}, nil
}
21 changes: 19 additions & 2 deletions cmd/sync/prompt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,32 @@ import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

syncdomain "github.com/launchdarkly/ldcli/internal/sync"
)

func TestPromptCommandDefinesSyncFlags(t *testing.T) {
command := NewPromptCmd(nil)

assert.Equal(t, "prompt", command.Use)
for _, name := range []string{addFlag, detachFlag, dryRunFlag, formatFlag, linkFlag, watchFlag, yesFlag} {
for _, name := range []string{
addFlag, attachSkillFlag, attachToolFlag, detachFlag, dryRunFlag, formatFlag,
linkFlag, projectFlag, variationFlag, watchFlag, yesFlag,
} {
assert.NotNil(t, command.Flags().Lookup(name), "missing --%s", name)
}
assert.Nil(t, command.Flags().Lookup("apply"))
assert.Nil(t, command.Flags().Lookup("project"))
}

func TestAttachmentFlagAcceptsNoKeyForInteractiveSelection(t *testing.T) {
command := NewPromptCmd(nil)

require.NoError(t, command.ParseFlags([]string{"--attach-tool"}))

request, err := attachmentRequest(command)
require.NoError(t, err)
require.NotNil(t, request)
assert.Equal(t, syncdomain.AttachmentTool, request.Kind)
assert.Empty(t, request.Key)
}
26 changes: 26 additions & 0 deletions internal/sync/local/attachment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,32 @@ func TestPreserveToolUpsertAcrossServerWrites(t *testing.T) {
assert.Equal(t, "New", *file.Description)
}

func TestAttachVariationRemovesNewAttachmentWhenVariationUpdateFails(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
existing := localVariation("default")
_, err := store.Add([]VariationFile{existing})
require.NoError(t, err)

tool := syncdomain.Tool{Key: "search", Schema: map[string]any{"type": "object"}}
variation := existing.Variation
variation.Tools = []syncdomain.AttachmentRef{{Key: tool.Key}}
variation.Attachments = []syncdomain.Attachment{{
Kind: syncdomain.AttachmentTool, Tool: &tool,
}}

err = store.AttachVariation("project", "../invalid", variation)

require.Error(t, err)
_, statErr := os.Stat(filepath.Join(root, ".launchdarkly", "project", "tools", "search.json"))
require.ErrorIs(t, statErr, os.ErrNotExist)

resources, compileErr := CompileWorkspace(root)
require.NoError(t, compileErr)
require.Len(t, resources, 1)
assert.Empty(t, resources[0].Attachments)
}

func TestReplaceVariationsLeavesAttachmentUnchangedWhenPreflightFails(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
Expand Down
109 changes: 109 additions & 0 deletions internal/sync/prompt/acceptance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,115 @@ func TestPromptFirstSyncCreatesUpsertVariation(t *testing.T) {
requireOnlyReads(t, api.requests)
}

func TestPromptAttachesLatestToolToManagedVariation(t *testing.T) {
root := initRepository(t)
baseline := variation("Baseline")
writeVariation(t, root, baseline, true)
writeManifest(t, root, baseline)
tool := syncdomain.Tool{
Key: "search", Description: pointer("Search documentation"),
Schema: map[string]any{"type": "object"},
}
api := &directAPI{
variation: pointer(baseline),
tools: map[string]versionedTool{
"search": {Tool: tool, Version: 4},
},
}

_, _, err := runPrompt(
t,
root,
api,
"--attach-tool=search",
"--project=production",
"--variation=support/default",
"--yes",
)

require.NoError(t, err)
require.Equal(t, []syncdomain.AttachmentRef{{Key: "search", Version: 4}}, api.variation.Tools)

resources, err := synclocal.CompileWorkspace(root)
require.NoError(t, err)
require.Len(t, resources, 1)
require.Len(t, resources[0].Attachments, 1)
assert.Equal(t, "search", resources[0].Attachments[0].Key())
_, err = os.Stat(filepath.Join(root, ".launchdarkly", "production", "tools", "search.json"))
require.NoError(t, err)

expected := baseline
expected.Tools = []syncdomain.AttachmentRef{{Key: "search"}}
expected.Attachments = []syncdomain.Attachment{toolAttachment(tool, 0)}
assertManifestFingerprint(t, root, expected)
}

func TestPromptAttachesLatestSkillAsMarkdownFile(t *testing.T) {
root := initRepository(t)
baseline := variation("Baseline")
writeVariation(t, root, baseline, true)
writeManifest(t, root, baseline)
description := "Customer support guidance"
markdown := "Follow the support process.\n"
skill := syncdomain.Skill{Key: "support", Name: "Support", Description: description, Markdown: markdown}
api := &directAPI{
variation: pointer(baseline),
skills: map[string]versionedSkill{
"support": {Skill: skill, Version: 3},
},
}

_, _, err := runPrompt(
t,
root,
api,
"--attach-skill=support",
"--project=production",
"--variation=support/default",
"--yes",
)

require.NoError(t, err)
require.Equal(t, []syncdomain.AttachmentRef{{Key: "support", Version: 3}}, api.variation.Skills)
content, err := os.ReadFile(filepath.Join(
root, ".launchdarkly", "production", "skills", "support.md",
))
require.NoError(t, err)
assert.Equal(t, "---\nkey: support\ndescription: Customer support guidance\n---\n\nFollow the support process.\n", string(content))
}

func TestPromptRejectsSkillAttachmentForCompletionVariation(t *testing.T) {
root := initRepository(t)
completion := variation("Completion")
completion.Mode = syncdomain.VariationModeCompletion
completion.Instructions = ""
completion.Messages = []syncdomain.Message{{Role: "system", Content: "Help"}}
writeVariation(t, root, completion, true)
skill := syncdomain.Skill{Key: "support", Name: "Support", Markdown: "# Support\n"}
api := &directAPI{
skills: map[string]versionedSkill{
"support": {Skill: skill, Version: 3},
},
}

_, _, err := runPrompt(
t,
root,
api,
"--attach-skill=support",
"--project=production",
"--variation=support/default",
"--yes",
)

require.ErrorContains(t, err, "skills can only be attached to agent-mode configs")
assert.Empty(t, api.requests)
resources, compileErr := synclocal.CompileWorkspace(root)
require.NoError(t, compileErr)
require.Len(t, resources, 1)
assert.Empty(t, resources[0].Attachments)
}

func TestPromptCreatesMissingToolWhenUpsertIsEnabled(t *testing.T) {
root := initRepository(t)
baseline := variation("Baseline")
Expand Down
Loading
Loading