Skip to content
Merged
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
Comment thread
cursor[bot] marked this conversation as resolved.
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Project flag collides with config

High Severity

The new --project flag shares the name of the global default project stored in config and LD_PROJECT. Validate rebinds every command flag from Viper, so a configured project fills --project even when the user omitted it. A normal ldcli sync prompt then fails because --project is treated as an attach-only flag, and attach can also target the wrong project without an explicit flag.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 090b7ef. Configure here.

}

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