Skip to content

feat: create skills during sync, and read again after a 409 on create - #940

Merged
ctawiah merged 1 commit into
mainfrom
ctawiah/create-skills-1e8a
Oct 22, 2026
Merged

ctawiah merged 1 commit into
mainfrom
ctawiah/create-skills-1e8a

Conversation

@ctawiah

@ctawiah ctawiah commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Context

ldcli sync prompts keeps local prompt files in step with the variations of LaunchDarkly AI Configs. A variation can use tools and skills. Locally, a skill is a Markdown file with YAML front matter.

Sync can create a missing tool when its file has "upsert": true, but it cannot create a skill. api.Client.CreateAttachment returns creating skills is not supported, and a skill file has no upsert key. So a variation that uses a new local skill fails with a 404 on apply.

The work to apply tool and skill rows was too large for one PR. This PR adds skill creation, and #941 applies the rows.

What changes

  • Skill front matter accepts two optional keys: upsert: true, which lets sync create a missing skill, and name:. Sync still rejects every other key.
  • CreateAttachment creates a skill with POST /api/v2/projects/{projectKey}/ai-configs/skills. It sends key, name, description, and markdown.
  • Without name:, sync makes a name from the key. For example, incident-summary becomes Incident summary.
  • LaunchDarkly owns the name after the create. A skill PATCH sends no name, a rename in LaunchDarkly makes no plan row, and a pull never writes the LaunchDarkly name into a file.
  • A pull keeps an existing name: line and upsert: line in a skill file.
  • A missing skill without upsert fails with skill "handoff" does not exist in LaunchDarkly; add upsert: true to its front matter to create it during sync.
  • A 409 on create of a tool, a skill, or a variation is no longer a definite failure. Sync reads the resource again. Equal content succeeds, and different content fails with the existing "changed concurrently" error. A 409 on update still fails.
  • The plan rejects a new skill name over 256 characters. A plain-text create row reads new, name "Human handoff".

A skill file that sync can create, at .launchdarkly/production/skills/handoff.md:

---
key: handoff
description: Hand off to a person
name: Human handoff
upsert: true
---

# Handoff

With this file, ldcli sync prompts --yes creates the skill Human handoff in LaunchDarkly. The variation pins version 1, and the file keeps both new lines.

Design notes

  • Neither new key is part of the fingerprint, which is the value that sync compares to find a change. CanonicalAttachment already clears the skill name and Upsert, so existing fingerprints and sync.lock rows do not change.
  • syncdomain.DisplayName makes the name from the key. This PR moved it without changes from link.displayName.
  • preserveToolUpsert is now preserveLocalFields, and it handles skill files too. It parses the existing skill file, as it already did for tool files.
  • Another working copy (another checkout of the repository), or watch, can create the same key first. So a 409 on create reads the resource again. Different content fails, and the next run shows a conflict.
  • renderAttachment never writes name:, because the rendered content comes from LaunchDarkly in every path today. A later PR adds a create through add, and that create writes name:.

Review focus

  • Is it right that LaunchDarkly owns the skill name after the create, and that a rename makes no plan row?
  • Is the 409 read-back safe for tools, skills, and variations? Does a 409 on update still fail?
  • Does every pull keep name: and upsert: in a skill file?

Verification

  • go test ./...
  • golangci-lint run (v2.9.0)
  • TestPromptCreatesMissingSkillWhenUpsertIsEnabled runs sync prompts --yes with the skill file above. LaunchDarkly gets the skill with that name, the variation pins version 1, and the local file keeps both lines.
  • TestPublishReadsAgainAfterACreateConflict and TestApplyServerChangeReadsAgainAfterAVariationCreateConflict cover the 409 read-back for a tool and for a variation.
  • The link tests pass without edits after the DisplayName move.
  • I built ldcli and ran the same flow with a skill file without name: against a local stand-in for the LaunchDarkly API. The request log shows POST ai-configs/skills with the name Incident summary, and then the variation PATCH that pins version 1.

Related changes

  1. feat: show a compact sync plan and add a --diff flag #937: show a compact sync plan and add a --diff flag
  2. feat: add target flags to sync prompts and read sync flags only from the command line #938: add target flags, and read sync flags only from the command line
  3. feat: plan standalone tool and skill rows by sync scope #939: plan tool and skill rows by sync scope
  4. This PR: create skills during sync, and read again after a 409 on create
  5. feat: apply tool and skill rows in a sync #941: apply tool and skill rows in a sync

Note

Overview
Enables creating missing AI skills during ldcli sync, mirroring the existing tool upsert flow. Skill Markdown front matter can set optional upsert: true and name:; create requests send key, name (from the file or DisplayName on the key), description, and markdown, while updates still omit name because LaunchDarkly owns it after create. Planning ignores remote skill renames, and pulls keep local name/upsert via generalized preserveLocalFields.

Hardens ambiguous creates: a 409 on create for tools, skills, or variations triggers a follow-up read—success if content matches, otherwise the existing concurrent-change error. User-facing plan and error text now describe skill upsert and show the resolved create name (e.g. new, name "Human handoff").

Reviewed by Cursor Bugbot for commit b0f6757. Bugbot is set up for automated code reviews on this repo. Configure here.

@cursor
cursor Bot force-pushed the ctawiah/create-skills-1e8a branch from 7258eff to b9a3396 Compare October 22, 2026 05:25
@ctawiah
ctawiah added this pull request to stack #942 October 22, 2026 13:28
@cursor
cursor Bot force-pushed the ctawiah/create-skills-1e8a branch from b9a3396 to f3f372d Compare October 22, 2026 15:07
@cursor
cursor Bot force-pushed the ctawiah/create-skills-1e8a branch from f3f372d to 027c99c Compare October 22, 2026 15:15
@cursor
cursor Bot force-pushed the ctawiah/create-skills-1e8a branch from 027c99c to fcdc2af Compare October 22, 2026 15:47
@ctawiah
ctawiah marked this pull request as ready for review October 22, 2026 15:47
@ctawiah
ctawiah requested a review from a team as a code owner October 22, 2026 15:47
@ctawiah
ctawiah requested review from davidbrackbill and nkester-ld and removed request for a team October 22, 2026 15:47
@cursor
cursor Bot force-pushed the ctawiah/create-skills-1e8a branch 2 times, most recently from b0f6757 to 4c40cdf Compare October 22, 2026 16:15
Comment on lines +143 to +144
body := skillMutationRequest{Description: attachment.Skill.Description, Markdown: attachment.Skill.Markdown}
request.body = body

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

not sure if this added anything lol but it didn't hurt anything

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

body is shared by both paths. The create branch below wraps it in skillCreateRequest with the key and name, so the description and markdown are built once.

Comment on lines -369 to -377

// displayName converts a key such as "support-agent" to "Support agent".
func displayName(key string) string {
runes := []rune(strings.NewReplacer("-", " ", "_", " ").Replace(key))
if len(runes) != 0 {
runes[0] = unicode.ToUpper(runes[0])
}
return string(runes)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

i love cleanup


func (resolver *attachmentResolver) publish(projectKey string, local syncdomain.Attachment) (syncdomain.Attachment, error) {
remote, err := resolver.client.ReadAttachment(projectKey, local.Kind, local.Key())
remote, readErr := resolver.client.ReadAttachment(projectKey, local.Kind, local.Key())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I like this more specific rename of the error. makes it more readable.

)
case syncapi.IsNotFound(readErr):
return syncdomain.Attachment{}, fmt.Errorf(
"skill %q does not exist in LaunchDarkly; add upsert: true to its front matter to create it during sync",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

might be good to include the command to create it?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

There is no create command in this stack yet, so upsert: true followed by the sync that is already running is the only create path. A later PR in this series adds ldcli sync skills add, which creates a skill from flags, and that PR can name the command in this message and in the tool version.

return remote, nil
case err == nil:
case readErr == nil:
writeErr = resolver.client.UpdateAttachment(projectKey, local)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

oh nice, read error aligns well to the writeError

create = `To create it during sync, add "upsert": true to its file. `
consent := `add "upsert": true to its file`
if id.Kind == syncdomain.KindSkill {
consent = "add upsert: true to its front matter"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

the exact command might be helpful here

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Same as the other thread: in this stack the file edit is the only way to give consent, and the next sync then creates the resource. The --create flag and ldcli sync skills add come in later PRs, and those can add the exact command to this message.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good call, i will make sure we capture adding the commands to later PRs that actually create the skill

@ctawiah
ctawiah force-pushed the ctawiah/create-skills-1e8a branch 2 times, most recently from e36972a to fd6a92d Compare October 22, 2026 19:33
Base automatically changed from ctawiah/plan-attachment-rows-1e8a to main October 22, 2026 19:39
A skill file can now set upsert: true and name:. A variation push that
uses a missing skill with upsert creates it with the name from the file,
or a name made from the key. Neither key is part of the fingerprint, and
a pull keeps both lines. LaunchDarkly keeps the name after the create.
The error for a missing skill now also gives the upsert step.
A 409 on the create of a tool, a skill, or a variation now reads the
resource again: equal content succeeds, and different content fails.
@ctawiah
ctawiah force-pushed the ctawiah/create-skills-1e8a branch from fd6a92d to dc3d694 Compare October 22, 2026 19:39
@ctawiah
ctawiah merged commit af1a2fa into main Oct 22, 2026
9 checks passed
@ctawiah
ctawiah deleted the ctawiah/create-skills-1e8a branch October 22, 2026 19:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants