diff --git a/cmd/sync/all.go b/cmd/sync/all.go new file mode 100644 index 00000000..2f8116e3 --- /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 00000000..f95407e2 --- /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 0ac39361..0346db2a 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 1cc923aa..fc7dbc95 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 00000000..c17b358f --- /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 0fd24677..643d8f62 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 "" +}