Skip to content

Commit fd491c1

Browse files
committed
feat: add the create flags of sync tools add and sync skills add
sync tools add takes --json-schema and --description, and sync skills add takes --content, --name, and --description. A content flag turns the add into a create. Both commands get --yes, which skips the confirmation of a create. The command builds the create input only from flags that the command line sets. The errors for a missing tool or skill now name the command that creates it. A missing resource without a content flag names the add with its create flags. A tool or skill file without upsert names the sync that creates it after the user adds upsert. AddCommand moves into bootstrap, so that both packages use it. A JSON outcome of a skill create has a create object with the name that sync sent and its source: flag, frontMatter, or key.
1 parent 3c72af5 commit fd491c1

14 files changed

Lines changed: 268 additions & 57 deletions

‎cmd/sync/attachments.go‎

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
resourcescmd "github.com/launchdarkly/ldcli/cmd/resources"
99
"github.com/launchdarkly/ldcli/internal/resources"
1010
syncdomain "github.com/launchdarkly/ldcli/internal/sync"
11+
synccreate "github.com/launchdarkly/ldcli/internal/sync/create"
1112
syncprompt "github.com/launchdarkly/ldcli/internal/sync/prompt"
1213
)
1314

@@ -18,6 +19,21 @@ var attachmentExamples = map[syncdomain.AttachmentKind][2]string{
1819
syncdomain.AttachmentSkill: {"summarize", "triage"},
1920
}
2021

22+
// createExamples are the examples of an add that creates a tool or a skill.
23+
var createExamples = map[syncdomain.AttachmentKind]string{
24+
syncdomain.AttachmentTool: ` # Create a tool without prompting
25+
ldcli sync tools add --project production --key refund \
26+
--description "Issue a refund" \
27+
--json-schema '{"type":"object","properties":{"orderId":{"type":"string"}},"required":["orderId"]}' \
28+
--yes --no-input`,
29+
syncdomain.AttachmentSkill: ` # Create a skill from a Markdown file without prompting
30+
ldcli sync skills add --project production --key handoff \
31+
--name "Human handoff" \
32+
--description "Hand a conversation to a person" \
33+
--content "$(cat handoff.md)" \
34+
--yes --no-input`,
35+
}
36+
2137
// newAttachmentScopeCmd creates the command that syncs the tools or the
2238
// skills of the repository, with its watch, add, and detach commands. They
2339
// run the same sync as sync prompts, in the scope of kind.
@@ -60,35 +76,89 @@ func attachmentScopeLong(kind syncdomain.AttachmentKind) string {
6076
name = " A skill that sync creates from a file takes its name from name: in the file, or from its key."
6177
}
6278
return fmt.Sprintf("Synchronize local %[1]s files with LaunchDarkly. A %[1]s syncs on its own, also when no prompt variation uses it. "+
63-
"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)
79+
"To create a %[1]s, run sync %[1]ss add with %[4]s, or %[2]s.%[3]s "+
80+
"Sync rechecks state before every write. If the state changed, run sync again.", kind, upsert, name, createFlag(kind))
81+
}
82+
83+
// attachmentAddLong is the description of sync tools add or sync skills add.
84+
func attachmentAddLong(kind syncdomain.AttachmentKind) string {
85+
content, skill := "schema", ""
86+
if kind == syncdomain.AttachmentSkill {
87+
content, skill = "content", " --content is the skill Markdown. The name comes from --name, or from the key."
88+
}
89+
return fmt.Sprintf("Add %[1]ss from LaunchDarkly, or create a %[1]s. "+
90+
"Without %[2]s, add starts syncing %[1]ss that exist in LaunchDarkly. "+
91+
"With %[2]s, add creates the %[1]s if it does not exist.%[4]s "+
92+
"If the %[1]s exists with the same %[3]s and description, add starts syncing it. "+
93+
"If they differ, add stops and changes nothing.", kind, createFlag(kind), content, skill)
94+
}
95+
96+
// createFlag is the flag that starts the create of a tool or a skill.
97+
func createFlag(kind syncdomain.AttachmentKind) string {
98+
if kind == syncdomain.AttachmentTool {
99+
return "--" + jsonSchemaFlag
100+
}
101+
return "--" + contentFlag
64102
}
65103

66104
func newAttachmentAddCmd(client resources.Client, kind syncdomain.AttachmentKind) *cobra.Command {
67105
scope := syncdomain.Scope(kind + "s")
68106
keys := attachmentExamples[kind]
69107
cmd := &cobra.Command{
70108
Use: "add",
71-
Short: fmt.Sprintf("Add %ss from LaunchDarkly", kind),
72-
Long: fmt.Sprintf("Add %[1]ss from LaunchDarkly. Add writes the file of each %[1]s and records it in sync.lock and "+
73-
"in the LaunchDarkly manifest. Each %[1]s must exist in LaunchDarkly.", kind),
109+
Short: fmt.Sprintf("Add %ss from LaunchDarkly, or create a %s", kind, kind),
110+
Long: attachmentAddLong(kind),
74111
Example: fmt.Sprintf(` # Choose a project and a %[1]s interactively
75112
ldcli sync %[2]s add
76113
77114
# Add %[1]ss by key without prompting
78-
ldcli sync %[2]s add --project production --key %[3]s --key %[4]s --no-input`, kind, scope, keys[0], keys[1]),
115+
ldcli sync %[2]s add --project production --key %[3]s --key %[4]s --no-input
116+
117+
%[5]s`, kind, scope, keys[0], keys[1], createExamples[kind]),
79118
Args: validatedArgs(cobra.NoArgs),
80119
RunE: runCommand(client, scope, func(cmd *cobra.Command, _ []string) (syncprompt.CommandAction, error) {
81120
dryRun, _ := cmd.Flags().GetBool(dryRunFlag)
82-
return syncprompt.AddAction{Filter: targetFilter(cmd), DryRun: dryRun}, nil
121+
return syncprompt.AddAction{Filter: targetFilter(cmd), DryRun: dryRun, Create: createInput(cmd)}, nil
83122
}),
84123
}
85124
addTargetFlags(cmd, attachmentFlagHelp(kind, false))
125+
if kind == syncdomain.AttachmentTool {
126+
cmd.Flags().String(jsonSchemaFlag, "", "Input JSON Schema of a tool that add creates")
127+
} else {
128+
cmd.Flags().String(contentFlag, "", "Markdown of a skill that add creates")
129+
cmd.Flags().String(nameFlag, "", "Name of a skill that add creates. The default comes from the key.")
130+
}
131+
cmd.Flags().String(descriptionFlag, "", fmt.Sprintf("Description of a %s that add creates", kind))
86132
cmd.Flags().Bool(dryRunFlag, false, "Preview local files without creating them")
87133
cmd.Flags().Bool(diffFlag, false, "Show full diffs and file content in the plan")
134+
cmd.Flags().Bool(yesFlag, false, "Create without confirmation")
88135
cmd.Flags().Bool(noInputFlag, false, "Fail instead of prompting for missing input")
89136
return cmd
90137
}
91138

139+
// createInput reads the create flags of add. It is nil when the command line
140+
// sets none of them, so that add then adds only resources that LaunchDarkly
141+
// has. A flag set to an empty value is set.
142+
func createInput(cmd *cobra.Command) *synccreate.Input {
143+
set := false
144+
value := func(name string) *string {
145+
flag := cmd.Flags().Lookup(name)
146+
if flag == nil || !flag.Changed {
147+
return nil
148+
}
149+
set = true
150+
text := flag.Value.String()
151+
return &text
152+
}
153+
input := synccreate.Input{
154+
Content: value(contentFlag), JSONSchema: value(jsonSchemaFlag), Name: value(nameFlag), Description: value(descriptionFlag),
155+
}
156+
if !set {
157+
return nil
158+
}
159+
return &input
160+
}
161+
92162
func newAttachmentDetachCmd(client resources.Client, kind syncdomain.AttachmentKind) *cobra.Command {
93163
scope := syncdomain.Scope(kind + "s")
94164
cmd := &cobra.Command{

‎cmd/sync/prompt.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ const (
2424
configKeyFlag = "config-key"
2525
conflictFlag = "conflict"
2626
contentFlag = "content"
27+
descriptionFlag = "description"
2728
diffFlag = "diff"
2829
dryRunFlag = "dry-run"
2930
formatFlag = "format"
31+
jsonSchemaFlag = "json-schema"
3032
keyFlag = "key"
3133
modelConfigKeyFlag = "model-config-key"
3234
nameFlag = "name"

‎cmd/sync/scope_test.go‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,11 @@ var syncCommandFlags = map[string][]string{
2828
},
2929
"sync tools": {"conflict", "diff", "dry-run", "key", "no-input", "project", "yes"},
3030
"sync tools watch": {"conflict", "diff", "key", "no-input", "project", "yes"},
31-
"sync tools add": {"diff", "dry-run", "key", "no-input", "project"},
31+
"sync tools add": {"description", "diff", "dry-run", "json-schema", "key", "no-input", "project", "yes"},
3232
"sync tools detach": {"key", "no-input", "project", "yes"},
3333
"sync skills": {"conflict", "diff", "dry-run", "key", "no-input", "project", "yes"},
3434
"sync skills watch": {"conflict", "diff", "key", "no-input", "project", "yes"},
35-
"sync skills add": {"diff", "dry-run", "key", "no-input", "project"},
35+
"sync skills add": {"content", "description", "diff", "dry-run", "key", "name", "no-input", "project", "yes"},
3636
"sync skills detach": {"key", "no-input", "project", "yes"},
3737
}
3838

@@ -65,7 +65,9 @@ func TestSyncCommandsReadSyncFlagsOnlyFromTheCommandLine(t *testing.T) {
6565
for _, name := range []string{"archive", "diff", "dry-run", "no-input", "yes"} {
6666
t.Setenv("LD_"+strings.ToUpper(strings.ReplaceAll(name, "-", "_")), "true")
6767
}
68-
for _, name := range []string{"config-key", "conflict", "content", "format", "key", "model-config-key", "name", "resolve", "to"} {
68+
for _, name := range []string{
69+
"config-key", "conflict", "content", "description", "format", "json-schema", "key", "model-config-key", "name", "resolve", "to",
70+
} {
6971
t.Setenv("LD_"+strings.ToUpper(strings.ReplaceAll(name, "-", "_")), "from-environment")
7072
}
7173

@@ -82,6 +84,26 @@ func TestSyncCommandsReadSyncFlagsOnlyFromTheCommandLine(t *testing.T) {
8284
}
8385
}
8486

87+
// An add creates a resource only when the command line sets a create flag. A
88+
// flag set to an empty value still counts.
89+
func TestAddReadsTheCreateInputFromTheFlagsThatAreSet(t *testing.T) {
90+
root := NewSyncCmd(nil, nil)
91+
tools, _, err := root.Find([]string{"tools", "add"})
92+
require.NoError(t, err)
93+
require.NoError(t, tools.ParseFlags([]string{"--project", "demo"}))
94+
assert.Nil(t, createInput(tools))
95+
96+
skills, _, err := root.Find([]string{"skills", "add"})
97+
require.NoError(t, err)
98+
require.NoError(t, skills.ParseFlags([]string{"--content", "Ask for a person.", "--description", ""}))
99+
input := createInput(skills)
100+
require.NotNil(t, input)
101+
assert.Equal(t, "Ask for a person.", *input.Content)
102+
assert.Equal(t, "", *input.Description)
103+
assert.Nil(t, input.Name)
104+
assert.Nil(t, input.JSONSchema)
105+
}
106+
85107
func TestSyncCommandScopeNamesTheKindOfTheCommand(t *testing.T) {
86108
root := NewSyncCmd(nil, nil)
87109
for path, want := range map[string]string{

‎internal/sync/bootstrap/attachments.go‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ func readNewAttachments(options Options, selections []syncdomain.ResourceID) ([]
8383
for _, id := range selections {
8484
attachment, err := options.Attachments.ReadAttachment(id.ProjectKey, syncdomain.AttachmentKind(id.Kind), id.LookupKey)
8585
if syncapi.IsNotFound(err) {
86-
return nil, fmt.Errorf("%s does not exist in LaunchDarkly.", id.Label())
86+
return nil, fmt.Errorf("%s does not exist in LaunchDarkly. To create it, run %s with %s.",
87+
id.Label(), AddCommand(id), createFlags[id.Kind])
8788
}
8889
if err != nil {
8990
return nil, err
@@ -93,6 +94,20 @@ func readNewAttachments(options Options, selections []syncdomain.ResourceID) ([]
9394
return attachments, nil
9495
}
9596

97+
// createFlags names the flags of an add that creates a tool or a skill.
98+
var createFlags = map[syncdomain.Kind]string{
99+
syncdomain.KindTool: "--json-schema, and optionally --description",
100+
syncdomain.KindSkill: "--content, and optionally --name and --description",
101+
}
102+
103+
// AddCommand is the command that starts syncing one resource.
104+
func AddCommand(id syncdomain.ResourceID) string {
105+
if configKey, variationKey, err := id.VariationKeys(); err == nil {
106+
return fmt.Sprintf("ldcli sync prompts add --project %s --config-key %s --key %s", id.ProjectKey, configKey, variationKey)
107+
}
108+
return fmt.Sprintf("ldcli sync %ss add --project %s --key %s", id.Kind, id.ProjectKey, id.LookupKey)
109+
}
110+
96111
// CheckNotSynced returns an error for a tool or skill that this repository
97112
// already syncs, because it has a local file or a sync.lock entry. It reads
98113
// only local files.

‎internal/sync/bootstrap/attachments_test.go‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,10 @@ func TestRunRefusesAToolBeforeAnyWrite(t *testing.T) {
6262
launchDarkly []syncdomain.Attachment
6363
want string
6464
}{
65-
"missing in LaunchDarkly": {want: "tool project/search does not exist in LaunchDarkly."},
65+
"missing in LaunchDarkly": {
66+
want: "tool project/search does not exist in LaunchDarkly. " +
67+
"To create it, run ldcli sync tools add --project project --key search with --json-schema, and optionally --description.",
68+
},
6669
"already synced": {
6770
local: true, launchDarkly: []syncdomain.Attachment{search},
6871
want: "tool project/search is already synced. Edit .launchdarkly/project/tools/search.json and run ldcli sync tools or ldcli sync all.",
@@ -105,6 +108,18 @@ func TestRunRefusesAToolBeforeAnyWrite(t *testing.T) {
105108
}
106109
}
107110

111+
func TestRunNamesTheCreateFlagsOfAMissingSkill(t *testing.T) {
112+
id := syncdomain.ResourceID{Kind: syncdomain.KindSkill, ProjectKey: "project", LookupKey: "handoff"}
113+
114+
err := Run(Options{
115+
Kind: syncdomain.KindSkill, Attachments: &fakeAttachments{}, Store: synclocal.NewStore(t.TempDir()),
116+
Baselines: &memoryManifestStore{manifest: syncmanifest.New()}, Output: &bytes.Buffer{}, Selections: []syncdomain.ResourceID{id},
117+
})
118+
119+
require.EqualError(t, err, "skill project/handoff does not exist in LaunchDarkly. "+
120+
"To create it, run ldcli sync skills add --project project --key handoff with --content, and optionally --name and --description.")
121+
}
122+
108123
func TestRunPreviewsToolsAndSkillsInADryRun(t *testing.T) {
109124
root := t.TempDir()
110125
skill := testAttachment(syncdomain.KindSkill)

‎internal/sync/prompt/acceptance_test.go‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -645,8 +645,9 @@ func TestPromptExplainsUpsertForMissingTool(t *testing.T) {
645645

646646
output, _, err := runPrompt(t, root, api, "--yes")
647647

648-
require.ErrorContains(t, err, `add "upsert": true`)
649-
assert.Contains(t, output, `add "upsert": true`)
648+
fix := `To create it, add "upsert": true to its file and run ldcli sync tools --project production --key my-second-tool.`
649+
require.ErrorContains(t, err, fix)
650+
assert.Contains(t, output, fix)
650651
assert.NotContains(t, err.Error(), "unknown error")
651652
}
652653

‎internal/sync/prompt/attachment_versions.go‎

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -137,16 +137,8 @@ func (resolver *attachmentResolver) publish(projectKey string, local syncdomain.
137137
writeErr = resolver.client.UpdateAttachment(projectKey, local)
138138
case syncapi.IsNotFound(readErr) && local.Upsert:
139139
writeErr = resolver.client.CreateAttachment(projectKey, local)
140-
case syncapi.IsNotFound(readErr) && local.Kind == syncdomain.AttachmentTool:
141-
return syncdomain.Attachment{}, fmt.Errorf(
142-
"tool %q does not exist in LaunchDarkly; add \"upsert\": true to its local JSON file to create it during sync",
143-
local.Key(),
144-
)
145140
case syncapi.IsNotFound(readErr):
146-
return syncdomain.Attachment{}, fmt.Errorf(
147-
"skill %q does not exist in LaunchDarkly; add upsert: true to its front matter to create it during sync",
148-
local.Key(),
149-
)
141+
return syncdomain.Attachment{}, errors.New(missingAttachment(local.ID(projectKey)))
150142
default:
151143
return syncdomain.Attachment{}, readErr
152144
}

‎internal/sync/prompt/create.go‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ func (runner Runner) runCreate(options Options, workspace syncWorkspace, action
5757
if created, err = input.Attachment(id); err != nil {
5858
return err
5959
}
60-
return runner.createAttachment(options, workspace, client, syncdomain.SyncedAttachment{ProjectKey: id.ProjectKey, Attachment: created})
60+
return runner.createAttachment(options, workspace, client, syncdomain.SyncedAttachment{
61+
ProjectKey: id.ProjectKey, Attachment: created,
62+
}, input.Name != nil)
6163
}
6264

6365
// adoptAttachment starts syncing a tool or skill that LaunchDarkly already
@@ -72,7 +74,7 @@ func (runner Runner) adoptAttachment(
7274
) error {
7375
if !action.Create.Matches(created, existing) {
7476
return fmt.Errorf("%s already exists in LaunchDarkly with different content, and add does not change it. "+
75-
"To sync the LaunchDarkly version, run %s, then edit the file and run ldcli sync %ss or ldcli sync all.", id.Label(), addCommand(id), id.Kind)
77+
"To sync the LaunchDarkly version, run %s, then edit the file and run ldcli sync %ss or ldcli sync all.", id.Label(), syncbootstrap.AddCommand(id), id.Kind)
7678
}
7779
adopt := AddAction{Filter: syncdomain.Filter{Project: id.ProjectKey, Keys: []string{id.LookupKey}}, DryRun: action.DryRun}
7880
if err := runner.runAdd(options, workspace, adopt); err != nil {
@@ -94,18 +96,19 @@ func (runner Runner) adoptAttachment(
9496
// createAttachment creates a tool or skill that LaunchDarkly does not have. It
9597
// plans the create in memory and asks before it writes the file, so that a
9698
// cancel leaves nothing and a watch cannot create the resource before the
97-
// answer.
99+
// answer. named is true when the user chose the name of a skill.
98100
func (runner Runner) createAttachment(
99101
options Options,
100102
workspace syncWorkspace,
101103
client syncapi.Client,
102104
created syncdomain.SyncedAttachment,
105+
named bool,
103106
) error {
104107
path, err := synclocal.AttachmentPath(created.ID())
105108
if err != nil {
106109
return err
107110
}
108-
reviewed := reviewedCreate(options.Scope, created)
111+
reviewed := reviewedCreate(options.Scope, created, named)
109112
if options.dryRun() {
110113
if err := writePlanOutput(options.Output, options.OutputKind, reviewed.plan, options.Diff); err != nil {
111114
return err
@@ -130,9 +133,15 @@ func (runner Runner) createAttachment(
130133

131134
// reviewedCreate is the state that the user reviews before a create: the
132135
// create row of the new resource, with no file yet.
133-
func reviewedCreate(scope syncdomain.Scope, created syncdomain.SyncedAttachment) workspaceState {
136+
func reviewedCreate(scope syncdomain.Scope, created syncdomain.SyncedAttachment, named bool) workspaceState {
134137
id := created.ID()
135138
row := planAttachment(id, &created.Attachment, nil, "", false)
139+
if id.Kind == syncdomain.KindSkill {
140+
row.nameSource = nameFromKey
141+
if named {
142+
row.nameSource = nameFromFlag
143+
}
144+
}
136145
return workspaceState{projectKeys: []string{id.ProjectKey}, plan: Plan{Scope: scope, Resources: []PlannedResource{row}}}
137146
}
138147

@@ -167,6 +176,9 @@ func (runner Runner) applyCreate(
167176
if err != nil {
168177
return remove(err)
169178
}
179+
// The new file has the name of the skill, but only the reviewed row knows
180+
// where the name came from.
181+
current.plan.Resources[0].nameSource = reviewed.plan.Resources[0].nameSource
170182

171183
outcomes, err := runner.applyPlan(options, workspace, client, current, nil)
172184
switch {

0 commit comments

Comments
 (0)