From 210aacc978026fd93a21729ceff67dac768e0f3b Mon Sep 17 00:00:00 2001 From: Cliff Tawiah Date: Thu, 22 Oct 2026 21:14:20 +0000 Subject: [PATCH] feat: add the sync all, sync tools, and sync skills commands ldcli sync all syncs prompt variations, tools, and skills in one plan, with one conflict pass and one confirmation. ldcli sync tools and ldcli sync skills sync one kind, and have their own add and detach. Each new command has watch. All of them run the sync of sync prompts with another scope. sync all takes --project only, so --key is an unknown flag there. Only sync prompts has --resolve. The help of sync prompts says that it leaves out tool and skill files that no variation uses, and every watch says that it creates a resource from a file with upsert when the file is saved. A test walks every sync command and checks its exact flags, and checks that no LD_* variable or configuration value sets a sync flag. The analytics event of sync gets a scope property, because the command name is add for every kind. --- cmd/sync/all.go | 38 ++++++++++++++ cmd/sync/attachments.go | 114 ++++++++++++++++++++++++++++++++++++++++ cmd/sync/prompt.go | 76 +++++++++++++++++++-------- cmd/sync/prompt_test.go | 9 +++- cmd/sync/scope_test.go | 107 +++++++++++++++++++++++++++++++++++++ cmd/sync/sync.go | 33 +++++++++--- 6 files changed, 347 insertions(+), 30 deletions(-) create mode 100644 cmd/sync/all.go create mode 100644 cmd/sync/attachments.go create mode 100644 cmd/sync/scope_test.go diff --git a/cmd/sync/all.go b/cmd/sync/all.go new file mode 100644 index 000000000..2f8116e35 --- /dev/null +++ b/cmd/sync/all.go @@ -0,0 +1,38 @@ +package sync + +import ( + "github.com/spf13/cobra" + + resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" + "github.com/launchdarkly/ldcli/internal/resources" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +// NewAllCmd creates the command that syncs prompt variations, tools, and +// skills in one plan. +func NewAllCmd(client resources.Client) *cobra.Command { + help := targetFlagHelp{project: filterFlagHelp.project} + cmd := &cobra.Command{ + Use: "all", + Short: "Synchronize prompt variations, tools, and skills with LaunchDarkly", + Long: "Synchronize prompt variations, tools, and skills with LaunchDarkly in one plan. " + + "Sync applies tools and skills before the prompt variations that use them. " + + "One conflict choice covers every conflict. " + + "To choose for one resource, use sync prompts, sync tools, or sync skills with --key.", + Example: ` # Preview every change + ldcli sync all --dry-run + + # Apply every change without prompting + ldcli sync all --yes --no-input + + # Keep the local side of every conflict in one project + ldcli sync all --project production --conflict=local --yes`, + Args: validatedArgs(cobra.NoArgs), + RunE: runCommand(client, syncdomain.ScopeAll, buildSyncAction), + } + addTargetFlags(cmd, help) + addApplyFlags(cmd, syncdomain.ScopeAll, true) + cmd.AddCommand(newWatchCmd(client, syncdomain.ScopeAll, help)) + cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + return cmd +} diff --git a/cmd/sync/attachments.go b/cmd/sync/attachments.go new file mode 100644 index 000000000..f95407e2d --- /dev/null +++ b/cmd/sync/attachments.go @@ -0,0 +1,114 @@ +package sync + +import ( + "fmt" + + "github.com/spf13/cobra" + + resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" + "github.com/launchdarkly/ldcli/internal/resources" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncprompt "github.com/launchdarkly/ldcli/internal/sync/prompt" +) + +// attachmentExamples are the keys that the help of a tool or skill command +// uses in its examples. +var attachmentExamples = map[syncdomain.AttachmentKind][2]string{ + syncdomain.AttachmentTool: {"search", "lookup-order"}, + syncdomain.AttachmentSkill: {"summarize", "triage"}, +} + +// newAttachmentScopeCmd creates the command that syncs the tools or the +// skills of the repository, with its watch, add, and detach commands. They +// run the same sync as sync prompts, in the scope of kind. +func newAttachmentScopeCmd(client resources.Client, kind syncdomain.AttachmentKind) *cobra.Command { + scope := syncdomain.Scope(kind + "s") + key := attachmentExamples[kind][0] + cmd := &cobra.Command{ + Use: string(scope), + Short: fmt.Sprintf("Synchronize local %s files with LaunchDarkly", kind), + Long: attachmentScopeLong(kind), + Example: fmt.Sprintf(` # Preview %[1]s changes + ldcli sync %[2]s --dry-run + + # Apply %[1]s changes without prompting + ldcli sync %[2]s --yes --no-input + + # Keep the local version of one %[1]s when it conflicts with LaunchDarkly + ldcli sync %[2]s --project production --key %[3]s --conflict=local --yes`, kind, scope, key), + Args: validatedArgs(cobra.NoArgs), + RunE: runCommand(client, scope, buildSyncAction), + } + help := attachmentFlagHelp(kind, true) + addTargetFlags(cmd, help) + addApplyFlags(cmd, scope, true) + cmd.AddCommand( + newWatchCmd(client, scope, help), + newAttachmentAddCmd(client, kind), + newAttachmentDetachCmd(client, kind), + ) + cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + return cmd +} + +// attachmentScopeLong is the description of sync tools or sync skills. +func attachmentScopeLong(kind syncdomain.AttachmentKind) string { + upsert := `set "upsert": true in its file` + name := "" + if kind == syncdomain.AttachmentSkill { + upsert = "set upsert: true in its front matter" + name = " A skill that sync creates from a file takes its name from name: in the file, or from its key." + } + return fmt.Sprintf("Synchronize local %[1]s files with LaunchDarkly. A %[1]s syncs on its own, also when no prompt variation uses it. "+ + "To create a %[1]s, %[2]s.%[3]s Sync rechecks state before every write. If the state changed, run sync again.", kind, upsert, name) +} + +func newAttachmentAddCmd(client resources.Client, kind syncdomain.AttachmentKind) *cobra.Command { + scope := syncdomain.Scope(kind + "s") + keys := attachmentExamples[kind] + cmd := &cobra.Command{ + Use: "add", + Short: fmt.Sprintf("Add %ss from LaunchDarkly", kind), + Long: fmt.Sprintf("Add %[1]ss from LaunchDarkly. Add writes the file of each %[1]s and records it in sync.lock and "+ + "in the LaunchDarkly manifest. Each %[1]s must exist in LaunchDarkly.", kind), + Example: fmt.Sprintf(` # Choose a project and a %[1]s interactively + ldcli sync %[2]s add + + # Add %[1]ss by key without prompting + ldcli sync %[2]s add --project production --key %[3]s --key %[4]s --no-input`, kind, scope, keys[0], keys[1]), + Args: validatedArgs(cobra.NoArgs), + RunE: runCommand(client, scope, func(cmd *cobra.Command, _ []string) (syncprompt.CommandAction, error) { + dryRun, _ := cmd.Flags().GetBool(dryRunFlag) + return syncprompt.AddAction{Filter: targetFilter(cmd), DryRun: dryRun}, nil + }), + } + addTargetFlags(cmd, attachmentFlagHelp(kind, false)) + cmd.Flags().Bool(dryRunFlag, false, "Preview local files without creating them") + cmd.Flags().Bool(diffFlag, false, "Show full diffs and file content in the plan") + cmd.Flags().Bool(noInputFlag, false, "Fail instead of prompting for missing input") + return cmd +} + +func newAttachmentDetachCmd(client resources.Client, kind syncdomain.AttachmentKind) *cobra.Command { + scope := syncdomain.Scope(kind + "s") + cmd := &cobra.Command{ + Use: "detach", + Short: fmt.Sprintf("Stop syncing local %ss", kind), + Long: fmt.Sprintf("Stop syncing local %[1]ss. Detach deletes the local %[1]s file and its sync.lock entry. "+ + "LaunchDarkly keeps the %[1]s. Detach cannot remove a %[1]s that a synced prompt variation uses. "+ + "Remove it from the variation first. Detach asks before it deletes files. Without a terminal, pass --yes.", kind), + Example: fmt.Sprintf(` # Choose %[1]ss interactively + ldcli sync %[2]s detach + + # Stop syncing one %[1]s without prompting + ldcli sync %[2]s detach --project production --key %[3]s --yes`, kind, scope, attachmentExamples[kind][0]), + Args: validatedArgs(cobra.NoArgs), + RunE: runCommand(client, scope, func(cmd *cobra.Command, _ []string) (syncprompt.CommandAction, error) { + return syncprompt.DetachAction{Filter: targetFilter(cmd)}, nil + }), + } + addTargetFlags(cmd, attachmentFlagHelp(kind, false)) + cmd.Flags().Bool(yesFlag, false, "Detach without confirmation") + cmd.Flags().Bool(noInputFlag, false, "Fail instead of prompting for missing input") + return cmd +} diff --git a/cmd/sync/prompt.go b/cmd/sync/prompt.go index 0ac39361c..0346db2a3 100644 --- a/cmd/sync/prompt.go +++ b/cmd/sync/prompt.go @@ -42,7 +42,8 @@ func NewPromptCmd(client resources.Client) *cobra.Command { Use: "prompts", Short: "Synchronize local prompt variations with LaunchDarkly", Long: "Bootstrap local prompt variations from LaunchDarkly, add more variations, or synchronize changes using the LaunchDarkly manifest. " + - "Sync rechecks state before every write, rerun sync after a change.", + "Sync rechecks state before every write, rerun sync after a change. " + + "Tool and skill files that no prompt variation uses are not in this plan. Use ldcli sync all, ldcli sync tools, or ldcli sync skills to sync them.", Example: ` # Preview synchronization changes ldcli sync prompts --dry-run @@ -55,16 +56,13 @@ func NewPromptCmd(client resources.Client) *cobra.Command { # Sync one config, and prefer local changes when conflicts occur ldcli sync prompts --project production --config-key support --yes --conflict=local`, Args: validatedArgs(cobra.NoArgs), - RunE: runPrompt(client, func(cmd *cobra.Command, _ []string) (syncprompt.CommandAction, error) { - dryRun, _ := cmd.Flags().GetBool(dryRunFlag) - return syncprompt.SyncAction{DryRun: dryRun, Filter: targetFilter(cmd)}, nil - }), + RunE: runCommand(client, syncdomain.ScopePrompts, buildSyncAction), } addTargetFlags(cmd, filterFlagHelp) - addApplyFlags(cmd, true) + addApplyFlags(cmd, syncdomain.ScopePrompts, true) cmd.AddCommand( - newWatchCmd(client), + newWatchCmd(client, syncdomain.ScopePrompts, filterFlagHelp), newAddCmd(client), newAttachCmd(client), newDetachCmd(client), @@ -77,7 +75,15 @@ func NewPromptCmd(client resources.Client) *cobra.Command { type actionBuilder func(*cobra.Command, []string) (syncprompt.CommandAction, error) -func runPrompt(client resources.Client, buildAction actionBuilder) func(*cobra.Command, []string) error { +// buildSyncAction reads the flags of a root sync command. +func buildSyncAction(cmd *cobra.Command, _ []string) (syncprompt.CommandAction, error) { + dryRun, _ := cmd.Flags().GetBool(dryRunFlag) + return syncprompt.SyncAction{DryRun: dryRun, Filter: targetFilter(cmd)}, nil +} + +// runCommand returns the RunE function of a sync command. It runs the action +// that buildAction reads from the flags, in the scope of the command. +func runCommand(client resources.Client, scope syncdomain.Scope, buildAction actionBuilder) func(*cobra.Command, []string) error { return func(cmd *cobra.Command, args []string) error { workingDirectory, err := os.Getwd() if err != nil { @@ -101,6 +107,7 @@ func runPrompt(client resources.Client, buildAction actionBuilder) func(*cobra.C AccessToken: viper.GetString(cliflags.AccessTokenFlag), BaseURI: viper.GetString(cliflags.BaseURIFlag), OutputKind: outputKind, + Scope: scope, Action: action, ConflictPolicy: policy, Yes: yes, @@ -118,22 +125,26 @@ func runPrompt(client resources.Client, buildAction actionBuilder) func(*cobra.C } } -func newWatchCmd(client resources.Client) *cobra.Command { +// 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 { cmd := &cobra.Command{ Use: "watch", Short: "Synchronize when managed files change", - Example: ` # Watch files and confirm each set of changes - ldcli sync prompts watch + Long: "Synchronize each time a managed file changes. " + + "Watch creates a resource from a file with upsert when the file is saved.", + Example: fmt.Sprintf(` # Watch files and confirm each set of changes + ldcli sync %s watch # Apply watched changes without prompting and report unresolved conflicts - ldcli sync prompts watch --yes --no-input`, + ldcli sync %s watch --yes --no-input`, scope, scope), Args: validatedArgs(cobra.NoArgs), - RunE: runPrompt(client, func(cmd *cobra.Command, _ []string) (syncprompt.CommandAction, error) { + RunE: runCommand(client, scope, func(cmd *cobra.Command, _ []string) (syncprompt.CommandAction, error) { return syncprompt.SyncAction{Watch: true, Filter: targetFilter(cmd)}, nil }), } - addTargetFlags(cmd, filterFlagHelp) - addApplyFlags(cmd, false) + addTargetFlags(cmd, help) + addApplyFlags(cmd, scope, false) return cmd } @@ -150,7 +161,7 @@ func newAddCmd(client resources.Client) *cobra.Command { # Add variations by key without prompting ldcli sync prompts add --project production --config-key support --key default --key concise --no-input`, Args: validatedArgs(cobra.MinimumNArgs(0)), - RunE: runPrompt(client, func(cmd *cobra.Command, args []string) (syncprompt.CommandAction, error) { + RunE: runCommand(client, syncdomain.ScopePrompts, func(cmd *cobra.Command, args []string) (syncprompt.CommandAction, error) { variations, err := parseVariationSelectors(args) if err != nil { return nil, err @@ -195,7 +206,7 @@ func newAttachKindCmd(client resources.Client, kind syncdomain.AttachmentKind) * --to production/support/default \ --yes --no-input`, kind, kind, kind, kind), Args: validatedArgs(cobra.MaximumNArgs(1)), - RunE: runPrompt(client, func(cmd *cobra.Command, args []string) (syncprompt.CommandAction, error) { + RunE: runCommand(client, syncdomain.ScopePrompts, func(cmd *cobra.Command, args []string) (syncprompt.CommandAction, error) { target, err := optionalVariationFlag(cmd, toFlag) if err != nil { return nil, err @@ -209,7 +220,7 @@ func newAttachKindCmd(client resources.Client, kind syncdomain.AttachmentKind) * } addTargetFlags(cmd, targetFlagHelp{project: questionFlagHelp.project}) cmd.Flags().String(toFlag, "", "Target variation (project-key/config-key/variation-key)") - addApplyFlags(cmd, false) + addApplyFlags(cmd, syncdomain.ScopePrompts, false) return cmd } @@ -229,7 +240,7 @@ func newDetachCmd(client resources.Client) *cobra.Command { # Stop syncing one variation and archive it in LaunchDarkly ldcli sync prompts detach --project production --config-key support --key default --archive --yes --no-input`, Args: validatedArgs(cobra.MinimumNArgs(0)), - RunE: runPrompt(client, func(cmd *cobra.Command, args []string) (syncprompt.CommandAction, error) { + RunE: runCommand(client, syncdomain.ScopePrompts, func(cmd *cobra.Command, args []string) (syncprompt.CommandAction, error) { variations, err := parseVariationSelectors(args) if err != nil { return nil, err @@ -256,7 +267,7 @@ func newLinkCmd(client resources.Client) *cobra.Command { --model-config-key claude \ --yes --no-input`, Args: validatedArgs(cobra.ExactArgs(1)), - RunE: runPrompt(client, func(cmd *cobra.Command, args []string) (syncprompt.CommandAction, error) { + RunE: runCommand(client, syncdomain.ScopePrompts, func(cmd *cobra.Command, args []string) (syncprompt.CommandAction, error) { format, _ := cmd.Flags().GetString(formatFlag) target, err := linkTarget(cmd) if err != nil { @@ -272,17 +283,21 @@ func newLinkCmd(client resources.Client) *cobra.Command { cmd.Flags().String(nameFlag, "", "Variation name when absent from the linked file") cmd.Flags().String(contentFlag, "", "Prompt content when absent from the linked file") _ = cmd.MarkFlagRequired(formatFlag) - addApplyFlags(cmd, false) + addApplyFlags(cmd, syncdomain.ScopePrompts, false) return cmd } -func addApplyFlags(cmd *cobra.Command, dryRun bool) { +// 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) { if dryRun { cmd.Flags().Bool(dryRunFlag, false, "Preview synchronization changes without applying them") } cmd.Flags().Bool(diffFlag, false, "Show full diffs in the plan") cmd.Flags().String(conflictFlag, "", "Default conflict resolution: launchdarkly, local, or abort") - cmd.Flags().StringArray(resolveFlag, nil, "Resolve one variation conflict (project-key/config-key/variation-key=choice)") + if scope == syncdomain.ScopePrompts { + cmd.Flags().StringArray(resolveFlag, nil, "Resolve one variation conflict (project-key/config-key/variation-key=choice)") + } cmd.Flags().Bool(yesFlag, false, "Apply changes without confirmation") cmd.Flags().Bool(noInputFlag, false, "Fail instead of prompting for missing input") } @@ -310,6 +325,21 @@ var ( } ) +// attachmentFlagHelp describes the target flags of a tool or skill command. +// With filter, the flags limit a sync. Otherwise they answer questions. +func attachmentFlagHelp(kind syncdomain.AttachmentKind, filter bool) targetFlagHelp { + if filter { + return targetFlagHelp{ + project: filterFlagHelp.project, + key: fmt.Sprintf("Sync only this %s. Repeat for more %ss. Needs --project.", kind, kind), + } + } + return targetFlagHelp{ + project: questionFlagHelp.project, + key: fmt.Sprintf("%s key. Repeat for more %ss. Needs --project.", strings.ToUpper(string(kind[:1]))+string(kind[1:]), kind), + } +} + // addTargetFlags registers --project, --config-key, and --key. func addTargetFlags(cmd *cobra.Command, help targetFlagHelp) { cmd.Flags().String(cliflags.ProjectFlag, "", help.project) diff --git a/cmd/sync/prompt_test.go b/cmd/sync/prompt_test.go index 1cc923aa0..fc7dbc954 100644 --- a/cmd/sync/prompt_test.go +++ b/cmd/sync/prompt_test.go @@ -37,7 +37,14 @@ func TestPromptCommandUsesActionSubcommands(t *testing.T) { func TestSyncCommandIncludesExamples(t *testing.T) { command := NewSyncCmd(nil, nil) - assert.Contains(t, command.Example, "ldcli sync prompts") + assert.Contains(t, command.Example, "ldcli sync all\n") + assert.Contains(t, command.Example, "ldcli sync all --dry-run") + assert.Contains(t, command.Example, "ldcli sync tools") + for _, name := range []string{"all", "prompts", "tools", "skills"} { + child, _, err := command.Find([]string{name}) + require.NoError(t, err) + assert.Equal(t, name, child.Name()) + } } func TestPromptCommandHelpIncludesExamples(t *testing.T) { diff --git a/cmd/sync/scope_test.go b/cmd/sync/scope_test.go new file mode 100644 index 000000000..c17b358f6 --- /dev/null +++ b/cmd/sync/scope_test.go @@ -0,0 +1,107 @@ +package sync + +import ( + "slices" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// syncCommandFlags is the exact flag set of every sync command that runs. A +// new command, or a new question without a flag, must change this list. +var syncCommandFlags = map[string][]string{ + "sync all": {"conflict", "diff", "dry-run", "no-input", "project", "yes"}, + "sync all watch": {"conflict", "diff", "no-input", "project", "yes"}, + "sync prompts": {"config-key", "conflict", "diff", "dry-run", "key", "no-input", "project", "resolve", "yes"}, + "sync prompts watch": {"config-key", "conflict", "diff", "key", "no-input", "project", "resolve", "yes"}, + "sync prompts add": {"config-key", "diff", "dry-run", "key", "no-input", "project"}, + "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 prompts link": { + "conflict", "content", "diff", "format", "model-config-key", "name", "no-input", "project", "resolve", "to", "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": {"diff", "dry-run", "key", "no-input", "project"}, + "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": {"diff", "dry-run", "key", "no-input", "project"}, + "sync skills detach": {"key", "no-input", "project", "yes"}, +} + +func TestSyncCommandsHaveExactFlags(t *testing.T) { + var found []string + for _, command := range runnableSyncCommands(NewSyncCmd(nil, nil)) { + path := command.CommandPath() + found = append(found, path) + var flags []string + command.LocalNonPersistentFlags().VisitAll(func(flag *pflag.Flag) { flags = append(flags, flag.Name) }) + slices.Sort(flags) + want, listed := syncCommandFlags[path] + require.True(t, listed, "%s is not in syncCommandFlags", path) + assert.Equal(t, want, flags, path) + } + assert.Len(t, found, len(syncCommandFlags)) +} + +// No sync flag takes its value from an LD_* environment variable or from the +// ldcli configuration, in any sync command. +func TestSyncCommandsReadSyncFlagsOnlyFromTheCommandLine(t *testing.T) { + t.Cleanup(viper.Reset) + viper.Reset() + viper.SetEnvPrefix("LD") + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + viper.AutomaticEnv() + viper.SetConfigType("yaml") + require.NoError(t, viper.ReadConfig(strings.NewReader("project: from-config\nyes: true\noutput: json\n"))) + t.Setenv("LD_BASE_URI", "https://example.test") + for _, name := range []string{"archive", "diff", "dry-run", "no-input", "yes"} { + t.Setenv("LD_"+strings.ToUpper(strings.ReplaceAll(name, "-", "_")), "true") + } + for _, name := range []string{"config-key", "conflict", "content", "format", "key", "model-config-key", "name", "resolve", "to"} { + t.Setenv("LD_"+strings.ToUpper(strings.ReplaceAll(name, "-", "_")), "from-environment") + } + + for _, command := range runnableSyncCommands(NewSyncCmd(nil, nil)) { + require.NoError(t, command.ParseFlags(nil)) + var args []string + if command.Name() == "link" { + args = []string{"prompt.md"} + } + 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) + }) + } +} + +func TestSyncCommandScopeNamesTheKindOfTheCommand(t *testing.T) { + root := NewSyncCmd(nil, nil) + for path, want := range map[string]string{ + "all watch": "all", "prompts attach tool": "prompts", "tools add": "tools", "skills detach": "skills", + } { + command, _, err := root.Find(strings.Fields(path)) + require.NoError(t, err) + assert.Equal(t, want, commandScope(command), path) + } + assert.Empty(t, commandScope(root)) +} + +// runnableSyncCommands returns each command under root that runs an action. +func runnableSyncCommands(root *cobra.Command) []*cobra.Command { + var commands []*cobra.Command + if root.RunE != nil { + commands = append(commands, root) + } + for _, child := range root.Commands() { + commands = append(commands, runnableSyncCommands(child)...) + } + return commands +} diff --git a/cmd/sync/sync.go b/cmd/sync/sync.go index 0fd24677b..643d8f62e 100644 --- a/cmd/sync/sync.go +++ b/cmd/sync/sync.go @@ -9,6 +9,7 @@ import ( resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" "github.com/launchdarkly/ldcli/internal/analytics" "github.com/launchdarkly/ldcli/internal/resources" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" ) // NewSyncCmd creates the parent command for synchronization workflows. @@ -16,11 +17,14 @@ func NewSyncCmd(client resources.Client, analyticsTrackerFn analytics.TrackerFn) cmd := &cobra.Command{ Use: "sync", Short: "Synchronize local resources with LaunchDarkly", - Example: ` # Synchronize prompt variations - ldcli sync prompts + Example: ` # Synchronize prompt variations, tools, and skills + ldcli sync all - # Preview prompt synchronization changes - ldcli sync prompts --dry-run`, + # Preview every change + ldcli sync all --dry-run + + # Synchronize only tools + ldcli sync tools`, Args: cobra.MinimumNArgs(1), PersistentPreRun: func(cmd *cobra.Command, _ []string) { tracker := analyticsTrackerFn( @@ -31,13 +35,30 @@ func NewSyncCmd(client resources.Client, analyticsTrackerFn analytics.TrackerFn) tracker.SendCommandRunEvent(cmdAnalytics.CmdRunEventProperties( cmd, "sync", - map[string]interface{}{"action": cmd.Name()}, + map[string]interface{}{"action": cmd.Name(), "scope": commandScope(cmd)}, )) }, } - cmd.AddCommand(NewPromptCmd(client)) + cmd.AddCommand( + NewAllCmd(client), + NewPromptCmd(client), + newAttachmentScopeCmd(client, syncdomain.AttachmentTool), + newAttachmentScopeCmd(client, syncdomain.AttachmentSkill), + ) cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) return cmd } + +// commandScope returns the scope of a sync command: all, prompts, tools, or +// skills. The name of the command is add for every kind, so analytics needs +// the scope too. +func commandScope(cmd *cobra.Command) string { + for ; cmd.HasParent(); cmd = cmd.Parent() { + if cmd.Parent().Name() == "sync" { + return cmd.Name() + } + } + return "" +}