From fc0160e153d46678ba86af3f038eb0ce063464a7 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Fri, 2 Oct 2026 16:35:59 -0400 Subject: [PATCH] feat(sync): add searchable attachment API foundation --- internal/sync/api/attachment.go | 246 ++++++++++++++ internal/sync/api/attachment_test.go | 181 +++++++++++ internal/sync/api/catalog.go | 159 ++++----- internal/sync/api/catalog_test.go | 101 +++--- internal/sync/api/client.go | 25 +- internal/sync/bootstrap/bootstrap.go | 79 +++-- internal/sync/bootstrap/bootstrap_test.go | 8 +- internal/sync/interactive/form.go | 379 +++++++++++++++++++--- internal/sync/interactive/form_test.go | 95 +++++- internal/sync/link/link.go | 97 +++--- internal/sync/prompt/runner.go | 6 +- internal/sync/resource.go | 59 ++++ 12 files changed, 1123 insertions(+), 312 deletions(-) create mode 100644 internal/sync/api/attachment.go create mode 100644 internal/sync/api/attachment_test.go diff --git a/internal/sync/api/attachment.go b/internal/sync/api/attachment.go new file mode 100644 index 00000000..ede25699 --- /dev/null +++ b/internal/sync/api/attachment.go @@ -0,0 +1,246 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +type toolResponse struct { + syncdomain.Tool + Version int `json:"version"` +} + +type skillResponse struct { + syncdomain.Skill + Version int `json:"version"` +} + +type toolMutationRequest struct { + Key string `json:"key,omitempty"` + Description *string `json:"description"` + Schema map[string]any `json:"schema"` + CustomParameters map[string]any `json:"customParameters"` + Tags []string `json:"tags"` +} + +type skillMutationRequest struct { + Description string `json:"description"` + Markdown string `json:"markdown"` +} + +// SearchAttachments returns one API-filtered page of latest attachments. +func (client Client) SearchAttachments( + projectKey string, + kind syncdomain.AttachmentKind, + query string, + limit, offset int, +) (Page[syncdomain.Attachment], error) { + endpoint, err := client.attachmentEndpoint(projectKey, kind, "") + if err != nil { + return Page[syncdomain.Attachment]{}, err + } + + values := url.Values{ + "limit": {strconv.Itoa(limit)}, + "offset": {strconv.Itoa(offset)}, + } + if query != "" { + values.Set("filter", "query equals "+strconv.Quote(query)) + } + + response, err := client.transport.MakeRequest(client.accessToken, http.MethodGet, endpoint, "", values, nil, false) + if err != nil { + return Page[syncdomain.Attachment]{}, fmt.Errorf("search %ss: %w", kind, err) + } + + switch kind { + case syncdomain.AttachmentTool: + var page Page[toolResponse] + if err := json.Unmarshal(response, &page); err != nil { + return Page[syncdomain.Attachment]{}, fmt.Errorf("decode tool search response: %w", err) + } + items := make([]syncdomain.Attachment, 0, len(page.Items)) + for index := range page.Items { + item := page.Items[index] + // Tool search can return an older matching version, so resolve each + // key before presenting the result as the latest version. + attachment, err := client.ReadAttachment(projectKey, kind, item.Key) + if err != nil { + return Page[syncdomain.Attachment]{}, err + } + items = append(items, attachment) + } + return Page[syncdomain.Attachment]{Items: items, TotalCount: page.TotalCount}, nil + + case syncdomain.AttachmentSkill: + var page Page[skillResponse] + if err := json.Unmarshal(response, &page); err != nil { + return Page[syncdomain.Attachment]{}, fmt.Errorf("decode skill search response: %w", err) + } + items := make([]syncdomain.Attachment, 0, len(page.Items)) + for index := range page.Items { + item := page.Items[index] + items = append(items, syncdomain.Attachment{Kind: kind, Version: item.Version, Skill: &item.Skill}) + } + return Page[syncdomain.Attachment]{Items: items, TotalCount: page.TotalCount}, nil + + default: + return Page[syncdomain.Attachment]{}, fmt.Errorf("unsupported attachment kind %q", kind) + } +} + +// ReadAttachment returns the latest version of one project-scoped attachment. +func (client Client) ReadAttachment(projectKey string, kind syncdomain.AttachmentKind, key string) (syncdomain.Attachment, error) { + endpoint, err := client.attachmentEndpoint(projectKey, kind, key) + if err != nil { + return syncdomain.Attachment{}, err + } + + response, err := client.transport.MakeRequest(client.accessToken, http.MethodGet, endpoint, "", nil, nil, false) + if err != nil { + return syncdomain.Attachment{}, fmt.Errorf("get %s %q: %w", kind, key, err) + } + attachment, err := decodeAttachment(kind, response) + if err != nil { + return syncdomain.Attachment{}, err + } + if attachment.Key() != key { + return syncdomain.Attachment{}, fmt.Errorf("get %s %q: response key was %q", kind, key, attachment.Key()) + } + return attachment, nil +} + +// UpdateAttachment creates a new version when canonical attachment content changed. +func (client Client) UpdateAttachment(projectKey string, attachment syncdomain.Attachment) error { + key := attachment.Key() + endpoint, err := client.attachmentEndpoint(projectKey, attachment.Kind, key) + if err != nil { + return err + } + + request, err := attachmentMutationRequest(attachment, false) + if err != nil { + return err + } + return client.mutateAttachment(http.MethodPatch, endpoint, "update", attachment, request) +} + +// CreateAttachment creates the first version of a locally defined tool. +func (client Client) CreateAttachment(projectKey string, attachment syncdomain.Attachment) error { + if attachment.Kind != syncdomain.AttachmentTool { + return fmt.Errorf("creating %ss is not supported", attachment.Kind) + } + endpoint, err := client.attachmentEndpoint(projectKey, attachment.Kind, "") + if err != nil { + return err + } + request, err := attachmentMutationRequest(attachment, true) + if err != nil { + return err + } + return client.mutateAttachment(http.MethodPost, endpoint, "create", attachment, request) +} + +func attachmentMutationRequest(attachment syncdomain.Attachment, includeKey bool) (any, error) { + switch attachment.Kind { + case syncdomain.AttachmentTool: + if attachment.Tool == nil { + return nil, fmt.Errorf("tool content is required") + } + customParameters := attachment.Tool.CustomParameters + if customParameters == nil { + customParameters = map[string]any{} + } + tags := attachment.Tool.Tags + if tags == nil { + tags = []string{} + } + request := toolMutationRequest{ + Description: attachment.Tool.Description, + Schema: attachment.Tool.Schema, + CustomParameters: customParameters, + Tags: tags, + } + if includeKey { + request.Key = attachment.Key() + } + return request, nil + case syncdomain.AttachmentSkill: + if attachment.Skill == nil { + return nil, fmt.Errorf("skill content is required") + } + return skillMutationRequest{Description: attachment.Skill.Description, Markdown: attachment.Skill.Markdown}, nil + default: + return nil, fmt.Errorf("unsupported attachment kind %q", attachment.Kind) + } +} + +func (client Client) mutateAttachment(method, endpoint, action string, attachment syncdomain.Attachment, request any) error { + body, err := json.Marshal(request) + if err != nil { + return fmt.Errorf("encode %s %q: %w", attachment.Kind, attachment.Key(), err) + } + _, err = client.transport.MakeRequest(client.accessToken, method, endpoint, "application/json", nil, body, false) + if err != nil { + return newResourceMutationError(action, string(attachment.Kind), attachment.Key(), err) + } + return nil +} + +func (client Client) attachmentEndpoint(projectKey string, kind syncdomain.AttachmentKind, key string) (string, error) { + var parts []string + switch kind { + case syncdomain.AttachmentTool: + parts = []string{"api/v2/projects", projectKey, "ai-tools"} + case syncdomain.AttachmentSkill: + parts = []string{"api/v2/projects", projectKey, "ai-configs/skills"} + default: + return "", fmt.Errorf("unsupported attachment kind %q", kind) + } + if key != "" { + parts = append(parts, key) + } + endpoint, err := url.JoinPath(client.baseURI, parts...) + if err != nil { + return "", fmt.Errorf("build %s endpoint: %w", kind, err) + } + return endpoint, nil +} + +func decodeAttachment(kind syncdomain.AttachmentKind, data []byte) (syncdomain.Attachment, error) { + var attachment syncdomain.Attachment + switch kind { + case syncdomain.AttachmentTool: + var response toolResponse + if err := json.Unmarshal(data, &response); err != nil { + return syncdomain.Attachment{}, fmt.Errorf("decode tool response: %w", err) + } + attachment = syncdomain.Attachment{Kind: kind, Version: response.Version, Tool: &response.Tool} + case syncdomain.AttachmentSkill: + var response skillResponse + if err := json.Unmarshal(data, &response); err != nil { + return syncdomain.Attachment{}, fmt.Errorf("decode skill response: %w", err) + } + attachment = syncdomain.Attachment{Kind: kind, Version: response.Version, Skill: &response.Skill} + default: + return syncdomain.Attachment{}, fmt.Errorf("unsupported attachment kind %q", kind) + } + if attachment.Key() == "" { + return syncdomain.Attachment{}, fmt.Errorf("decode %s response: key is required", kind) + } + if attachment.Version < 1 { + return syncdomain.Attachment{}, fmt.Errorf("decode %s response: version must be positive", kind) + } + return attachment, nil +} + +// IsNotFound reports whether a wrapped LaunchDarkly API error has a 404 status. +func IsNotFound(err error) bool { + status, ok := responseStatusCode(err) + return ok && status == http.StatusNotFound +} diff --git a/internal/sync/api/attachment_test.go b/internal/sync/api/attachment_test.go new file mode 100644 index 00000000..d070c847 --- /dev/null +++ b/internal/sync/api/attachment_test.go @@ -0,0 +1,181 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +func TestSearchAttachmentsUsesServerFilterAndResolvesLatestTool(t *testing.T) { + transport := &recordingClient{Responses: [][]byte{[]byte(`{ + "items": [{ + "key": "search", + "description": "Search documentation", + "schema": {"type": "object"}, + "version": 3 + }], + "totalCount": 1 + }`), []byte(`{ + "key": "search", + "description": "Search documentation", + "schema": {"type": "object"}, + "version": 4 + }`)}} + client := NewClient(transport, "token", "https://example.com") + + page, err := client.SearchAttachments("project", syncdomain.AttachmentTool, "docs search", 25, 50) + + require.NoError(t, err) + require.Len(t, page.Items, 1) + assert.Equal(t, "search", page.Items[0].Key()) + assert.Equal(t, 4, page.Items[0].Version) + require.Len(t, transport.Requests, 2) + request := transport.Requests[0] + assert.Equal(t, http.MethodGet, request.Method) + assert.Equal(t, "https://example.com/api/v2/projects/project/ai-tools", request.Path) + assert.Equal(t, "query equals \"docs search\"", request.Query.Get("filter")) + assert.Equal(t, "25", request.Query.Get("limit")) + assert.Equal(t, "50", request.Query.Get("offset")) + assert.Equal(t, "https://example.com/api/v2/projects/project/ai-tools/search", transport.Requests[1].Path) +} + +func TestSearchAttachmentsDecodesLatestSkills(t *testing.T) { + transport := &recordingClient{Responses: [][]byte{[]byte(`{ + "items": [{ + "key": "support", + "name": "Support", + "markdown": "# Support", + "version": 3 + }], + "totalCount": 1 + }`)}} + client := NewClient(transport, "token", "https://example.com") + + page, err := client.SearchAttachments("project", syncdomain.AttachmentSkill, "support", 25, 0) + + require.NoError(t, err) + require.Len(t, page.Items, 1) + assert.Equal(t, "support", page.Items[0].Key()) + assert.Equal(t, 3, page.Items[0].Version) + assert.Equal(t, "https://example.com/api/v2/projects/project/ai-configs/skills", transport.Requests[0].Path) +} + +func TestUpdateSkillSendsEditableFields(t *testing.T) { + description := "Customer support guidance" + markdown := "Help the customer.\n" + transport := &recordingClient{Responses: [][]byte{[]byte(`{ + "key": "support", + "name": "support", + "description": "Customer support guidance", + "markdown": "Help the customer.\n", + "version": 3 + }`)}} + client := NewClient(transport, "token", "https://example.com") + skill := syncdomain.Skill{Key: "support", Name: "support", Description: description, Markdown: markdown} + + err := client.UpdateAttachment("project", syncdomain.Attachment{ + Kind: syncdomain.AttachmentSkill, Skill: &skill, + }) + + require.NoError(t, err) + require.Len(t, transport.Requests, 1) + assert.Equal(t, http.MethodPatch, transport.Requests[0].Method) + + var body map[string]any + require.NoError(t, json.Unmarshal(transport.Requests[0].Body, &body)) + assert.Equal(t, map[string]any{"description": description, "markdown": markdown}, body) + assert.NotContains(t, body, "name") +} + +func TestUpdateSkillSendsEmptyDescriptionToClearRemoteValue(t *testing.T) { + transport := &recordingClient{Responses: [][]byte{[]byte(`{ + "key": "support", + "markdown": "Help the customer.\n", + "version": 3 + }`)}} + client := NewClient(transport, "token", "https://example.com") + skill := syncdomain.Skill{Key: "support", Markdown: "Help the customer.\n"} + + err := client.UpdateAttachment("project", syncdomain.Attachment{ + Kind: syncdomain.AttachmentSkill, Skill: &skill, + }) + + require.NoError(t, err) + var body map[string]any + require.NoError(t, json.Unmarshal(transport.Requests[0].Body, &body)) + assert.Equal(t, "", body["description"]) +} + +func TestUpdateToolSendsEmptyCollectionsToClearRemoteValues(t *testing.T) { + transport := &recordingClient{Responses: [][]byte{[]byte(`{ + "key": "search", + "schema": {"type": "object"}, + "customParameters": {}, + "tags": [], + "version": 3 + }`)}} + client := NewClient(transport, "token", "https://example.com") + + err := client.UpdateAttachment("project", syncdomain.Attachment{ + Kind: syncdomain.AttachmentTool, + Tool: &syncdomain.Tool{Key: "search", Schema: map[string]any{"type": "object"}}, + }) + + require.NoError(t, err) + var body map[string]any + require.NoError(t, json.Unmarshal(transport.Requests[0].Body, &body)) + assert.Contains(t, body, "description") + assert.Nil(t, body["description"]) + assert.Equal(t, map[string]any{}, body["customParameters"]) + assert.Equal(t, []any{}, body["tags"]) +} + +func TestCreateToolIncludesItsKey(t *testing.T) { + transport := &recordingClient{Responses: [][]byte{[]byte(`{ + "key": "search", + "schema": {"type": "object"}, + "customParameters": {}, + "tags": [], + "version": 1 + }`)}} + client := NewClient(transport, "token", "https://example.com") + + err := client.CreateAttachment("project", syncdomain.Attachment{ + Kind: syncdomain.AttachmentTool, + Tool: &syncdomain.Tool{Key: "search", Schema: map[string]any{"type": "object"}}, + }) + + require.NoError(t, err) + require.Len(t, transport.Requests, 1) + assert.Equal(t, http.MethodPost, transport.Requests[0].Method) + assert.Equal(t, "https://example.com/api/v2/projects/project/ai-tools", transport.Requests[0].Path) + var body map[string]any + require.NoError(t, json.Unmarshal(transport.Requests[0].Body, &body)) + assert.Equal(t, "search", body["key"]) +} + +func TestReadAttachmentRequiresMatchingVersionedIdentity(t *testing.T) { + for name, response := range map[string]string{ + "key": `{"key":"other","schema":{},"version":2}`, + "version": `{"key":"search","schema":{},"version":0}`, + } { + t.Run(name, func(t *testing.T) { + client := NewClient(&recordingClient{Responses: [][]byte{[]byte(response)}}, "token", "https://example.com") + + _, err := client.ReadAttachment("project", syncdomain.AttachmentTool, "search") + + require.Error(t, err) + }) + } +} + +func TestIsNotFoundRecognizesAPIStatus(t *testing.T) { + assert.True(t, IsNotFound(errors.New(`{"code":"not_found","statusCode":404}`))) + assert.False(t, IsNotFound(errors.New(`{"code":"invalid_request","statusCode":400}`))) +} diff --git a/internal/sync/api/catalog.go b/internal/sync/api/catalog.go index fb74282a..585028d1 100644 --- a/internal/sync/api/catalog.go +++ b/internal/sync/api/catalog.go @@ -3,22 +3,26 @@ package api import ( "encoding/json" "fmt" - "maps" "net/http" "net/url" + "strconv" + "strings" - "github.com/launchdarkly/ldcli/internal/resources" syncdomain "github.com/launchdarkly/ldcli/internal/sync" ) -const catalogPageLimit = 25 - // Project is the project metadata needed by interactive sync flows. type Project struct { Key string `json:"key"` Name string `json:"name"` } +// Page is one server-filtered page of catalog resources. +type Page[T any] struct { + Items []T `json:"items"` + TotalCount int `json:"totalCount"` +} + // ModelConfig contains the model settings assigned to a new variation. type ModelConfig struct { Key string `json:"key"` @@ -85,40 +89,35 @@ func (config *Config) UnmarshalJSON(data []byte) error { return nil } -// CatalogClient lists resources used by bootstrap and link flows. -type CatalogClient struct { - transport resources.Client - accessToken string - baseURI string -} - -// NewCatalogClient creates a client for sync catalog reads. -func NewCatalogClient(transport resources.Client, accessToken, baseURI string) CatalogClient { - return CatalogClient{ - transport: transport, - accessToken: accessToken, - baseURI: baseURI, +// SearchProjects returns one name-sorted API page filtered by project name or key. +func (client Client) SearchProjects(query string, limit, offset int) (Page[Project], error) { + endpoint, err := url.JoinPath(client.baseURI, "api/v2/projects") + if err != nil { + return Page[Project]{}, fmt.Errorf("build projects endpoint: %w", err) } -} -// Projects returns projects ordered by name. -func (client CatalogClient) Projects() ([]Project, error) { - endpoint, err := url.JoinPath(client.baseURI, "api/v2/projects") + values := url.Values{ + "limit": {strconv.Itoa(limit)}, + "offset": {strconv.Itoa(offset)}, + "sort": {"name"}, + } + if query != "" { + values.Set("filter", "query:"+query) + } + response, err := client.transport.MakeRequest(client.accessToken, http.MethodGet, endpoint, "", values, nil, false) if err != nil { - return nil, fmt.Errorf("build projects endpoint: %w", err) + return Page[Project]{}, fmt.Errorf("search projects: %w", err) } - return listCatalog[Project]( - client, - endpoint, - "projects", - false, - url.Values{"sort": {"name"}}, - ) + var page Page[Project] + if err := json.Unmarshal(response, &page); err != nil { + return Page[Project]{}, fmt.Errorf("decode projects response: %w", err) + } + return page, nil } // ModelConfigs returns model configs available to one project. -func (client CatalogClient) ModelConfigs(projectKey string) ([]ModelConfig, error) { +func (client Client) ModelConfigs(projectKey string) ([]ModelConfig, error) { endpoint, err := url.JoinPath(client.baseURI, "api/v2/projects", projectKey, "ai-configs/model-configs") if err != nil { return nil, fmt.Errorf("build model configs endpoint: %w", err) @@ -145,38 +144,51 @@ func (client CatalogClient) ModelConfigs(projectKey string) ([]ModelConfig, erro return modelConfigs, nil } -// Configs returns the agent and completion configs in one project. -func (client CatalogClient) Configs(projectKey string) ([]Config, error) { +// SearchConfigs returns one name-sorted page of agent and completion configs. +func (client Client) SearchConfigs(projectKey, query string, modes []syncdomain.VariationMode, limit, offset int) (Page[Config], error) { endpoint, err := url.JoinPath(client.baseURI, "api/v2/projects", projectKey, "ai-configs") if err != nil { - return nil, fmt.Errorf("build configs endpoint: %w", err) + return Page[Config]{}, fmt.Errorf("build configs endpoint: %w", err) } - configs, err := listCatalog[Config]( - client, - endpoint, - "configs", - false, - url.Values{ - "sort": {"name"}, - "filter": {`mode anyOf ["agent","completion"]`}, - }, - ) + if len(modes) == 0 { + modes = []syncdomain.VariationMode{syncdomain.VariationModeAgent, syncdomain.VariationModeCompletion} + } + modeValues := make([]string, len(modes)) + for index, mode := range modes { + modeValues[index] = strconv.Quote(string(mode)) + } + + filter := "mode anyOf [" + strings.Join(modeValues, ",") + "]" + if query != "" { + filter = "query equals " + strconv.Quote(query) + ", " + filter + } + + values := url.Values{ + "filter": {filter}, + "limit": {strconv.Itoa(limit)}, + "offset": {strconv.Itoa(offset)}, + "sort": {"name"}, + } + response, err := client.transport.MakeRequest(client.accessToken, http.MethodGet, endpoint, "", values, nil, false) if err != nil { - return nil, err + return Page[Config]{}, fmt.Errorf("search configs: %w", err) } - for index := range configs { - if err := configs[index].applyMode(); err != nil { - return nil, err + var page Page[Config] + if err := json.Unmarshal(response, &page); err != nil { + return Page[Config]{}, fmt.Errorf("decode configs response: %w", err) + } + for index := range page.Items { + if err := page.Items[index].applyMode(); err != nil { + return Page[Config]{}, err } } - - return configs, nil + return page, nil } // Config returns one config with its variation modes normalized. -func (client CatalogClient) Config(projectKey, configKey string) (Config, error) { +func (client Client) Config(projectKey, configKey string) (Config, error) { endpoint, err := url.JoinPath(client.baseURI, "api/v2/projects", projectKey, "ai-configs", configKey) if err != nil { return Config{}, fmt.Errorf("build config endpoint: %w", err) @@ -226,50 +238,3 @@ func (config *Config) applyMode() error { return nil } - -type catalogPage[T any] struct { - Items []T `json:"items"` - TotalCount int `json:"totalCount"` -} - -// listCatalog retrieves every page from a list endpoint while preserving the -// server's requested sort order. -func listCatalog[T any]( - client CatalogClient, - endpoint string, - resourceName string, - beta bool, - baseQuery url.Values, -) ([]T, error) { - var items []T - - for offset := 0; ; offset += catalogPageLimit { - query := maps.Clone(baseQuery) - query.Set("limit", fmt.Sprintf("%d", catalogPageLimit)) - query.Set("offset", fmt.Sprintf("%d", offset)) - - response, err := client.transport.MakeRequest( - client.accessToken, - http.MethodGet, - endpoint, - "", - query, - nil, - beta, - ) - if err != nil { - return nil, fmt.Errorf("list %s: %w", resourceName, err) - } - - var page catalogPage[T] - if err := json.Unmarshal(response, &page); err != nil { - return nil, fmt.Errorf("decode %s response: %w", resourceName, err) - } - - items = append(items, page.Items...) - if len(page.Items) < catalogPageLimit || - (page.TotalCount > 0 && len(items) >= page.TotalCount) { - return items, nil - } - } -} diff --git a/internal/sync/api/catalog_test.go b/internal/sync/api/catalog_test.go index 7fdf3204..a79341f8 100644 --- a/internal/sync/api/catalog_test.go +++ b/internal/sync/api/catalog_test.go @@ -11,51 +11,30 @@ import ( syncdomain "github.com/launchdarkly/ldcli/internal/sync" ) -func TestCatalogClientProjectsPaginatesInNameOrder(t *testing.T) { - firstPage := make([]Project, catalogPageLimit) - for index := range firstPage { - firstPage[index] = Project{ - Key: string(rune('a' + index)), - Name: string(rune('A' + index)), - } - } - +func TestCatalogClientSearchProjectsFiltersAndReturnsOnePage(t *testing.T) { transport := &recordingClient{Responses: [][]byte{ - mustCatalogJSON(t, catalogPage[Project]{ - Items: firstPage, - TotalCount: 26, - }), - mustCatalogJSON(t, catalogPage[Project]{ - Items: []Project{{Key: "z", Name: "Z"}}, - TotalCount: 26, + mustCatalogJSON(t, Page[Project]{ + Items: []Project{{Key: "support", Name: "Support"}}, + TotalCount: 1, }), }} - projects, err := NewCatalogClient( - transport, - "token", - "https://example.com", - ).Projects() + page, err := NewClient(transport, "token", "https://example.com").SearchProjects("supp", 10, 20) require.NoError(t, err) - require.Len(t, projects, 26) - require.Len(t, transport.Requests, 2) - - firstRequest := transport.Requests[0] - assert.Equal(t, "GET", firstRequest.Method) - assert.Equal(t, "token", firstRequest.AccessToken) - assert.Equal(t, "https://example.com/api/v2/projects", firstRequest.Path) - assert.Equal(t, "name", firstRequest.Query.Get("sort")) - assert.Equal(t, "25", firstRequest.Query.Get("limit")) - assert.Equal(t, "0", firstRequest.Query.Get("offset")) - assert.False(t, firstRequest.IsBeta) - assert.Equal(t, "25", transport.Requests[1].Query.Get("offset")) - assert.Equal(t, "Z", projects[25].Name) + require.Equal(t, []Project{{Key: "support", Name: "Support"}}, page.Items) + assert.Equal(t, 1, page.TotalCount) + require.Len(t, transport.Requests, 1) + request := transport.Requests[0] + assert.Equal(t, "query:supp", request.Query.Get("filter")) + assert.Equal(t, "name", request.Query.Get("sort")) + assert.Equal(t, "10", request.Query.Get("limit")) + assert.Equal(t, "20", request.Query.Get("offset")) } -func TestCatalogClientConfigsPaginatesAndAppliesMode(t *testing.T) { +func TestCatalogClientSearchConfigsFiltersAndAppliesMode(t *testing.T) { transport := &recordingClient{Responses: [][]byte{ - mustCatalogJSON(t, catalogPage[Config]{ + mustCatalogJSON(t, Page[Config]{ Items: []Config{{ Key: "support", Name: "Support agent", @@ -69,20 +48,21 @@ func TestCatalogClientConfigsPaginatesAndAppliesMode(t *testing.T) { }), }} - configs, err := NewCatalogClient( + page, err := NewClient( transport, "token", "https://example.com", - ).Configs("project") + ).SearchConfigs("project", "support", []syncdomain.VariationMode{syncdomain.VariationModeAgent}, 10, 20) require.NoError(t, err) - require.Len(t, configs, 1) - require.Len(t, configs[0].Variations, 1) + require.Len(t, page.Items, 1) + require.Len(t, page.Items[0].Variations, 1) assert.Equal( t, syncdomain.VariationModeAgent, - configs[0].Variations[0].Mode, + page.Items[0].Variations[0].Mode, ) + assert.Equal(t, 1, page.TotalCount) request := transport.Requests[0] assert.Equal( @@ -93,15 +73,17 @@ func TestCatalogClientConfigsPaginatesAndAppliesMode(t *testing.T) { assert.Equal(t, "name", request.Query.Get("sort")) assert.Equal( t, - `mode anyOf ["agent","completion"]`, + `query equals "support", mode anyOf ["agent"]`, request.Query.Get("filter"), ) + assert.Equal(t, "10", request.Query.Get("limit")) + assert.Equal(t, "20", request.Query.Get("offset")) assert.False(t, request.IsBeta) } func TestCatalogClientConfigsDefaultsCompletionMode(t *testing.T) { transport := &recordingClient{Responses: [][]byte{ - mustCatalogJSON(t, catalogPage[Config]{ + mustCatalogJSON(t, Page[Config]{ Items: []Config{{ Key: "completion", Name: "Completion", @@ -111,25 +93,26 @@ func TestCatalogClientConfigsDefaultsCompletionMode(t *testing.T) { }), }} - configs, err := NewCatalogClient( + page, err := NewClient( transport, "token", "https://example.com", - ).Configs("project") + ).SearchConfigs("project", "", nil, 25, 0) require.NoError(t, err) - require.Len(t, configs, 1) - require.Len(t, configs[0].Variations, 1) + require.Len(t, page.Items, 1) + require.Len(t, page.Items[0].Variations, 1) assert.Equal( t, syncdomain.VariationModeCompletion, - configs[0].Variations[0].Mode, + page.Items[0].Variations[0].Mode, ) + assert.Equal(t, `mode anyOf ["agent","completion"]`, transport.Requests[0].Query.Get("filter")) } func TestCatalogClientConfigsRejectsJudgeMode(t *testing.T) { transport := &recordingClient{Responses: [][]byte{ - mustCatalogJSON(t, catalogPage[Config]{ + mustCatalogJSON(t, Page[Config]{ Items: []Config{{ Key: "config", Name: "Config", @@ -139,11 +122,11 @@ func TestCatalogClientConfigsRejectsJudgeMode(t *testing.T) { }), }} - _, err := NewCatalogClient( + _, err := NewClient( transport, "token", "https://example.com", - ).Configs("project") + ).SearchConfigs("project", "", nil, 25, 0) require.ErrorContains(t, err, `unsupported mode "judge"`) } @@ -162,7 +145,7 @@ func TestCatalogClientConfigGetsExactConfigAndAppliesMode(t *testing.T) { }), }} - config, err := NewCatalogClient( + config, err := NewClient( transport, "token", "https://example.com", @@ -185,7 +168,7 @@ func TestCatalogClientConfigGetsExactConfigAndAppliesMode(t *testing.T) { } func TestCatalogClientConfigRejectsInvalidResponse(t *testing.T) { - client := NewCatalogClient( + client := NewClient( &recordingClient{Responses: [][]byte{[]byte(`not json`)}}, "token", "https://example.com", @@ -196,14 +179,14 @@ func TestCatalogClientConfigRejectsInvalidResponse(t *testing.T) { require.ErrorContains(t, err, "decode config response") } -func TestCatalogClientProjectsRejectsInvalidResponse(t *testing.T) { - client := NewCatalogClient( +func TestCatalogClientSearchProjectsRejectsInvalidResponse(t *testing.T) { + client := NewClient( &recordingClient{Responses: [][]byte{[]byte(`not json`)}}, "token", "https://example.com", ) - _, err := client.Projects() + _, err := client.SearchProjects("", 25, 0) require.ErrorContains(t, err, "decode projects response") } @@ -222,7 +205,7 @@ func TestCatalogClientModelConfigsGetsBareArray(t *testing.T) { {"key":"gpt-5","id":"gpt-5-2025-08-07","name":"GPT-5","provider":"openai"} ]`)}} - modelConfigs, err := NewCatalogClient( + modelConfigs, err := NewClient( transport, "token", "https://example.com", @@ -259,7 +242,7 @@ func TestCatalogClientModelConfigsGetsBareArray(t *testing.T) { } func TestCatalogClientModelConfigsReturnsRequestError(t *testing.T) { - client := NewCatalogClient( + client := NewClient( &recordingClient{Err: errors.New("unavailable")}, "token", "https://example.com", @@ -271,7 +254,7 @@ func TestCatalogClientModelConfigsReturnsRequestError(t *testing.T) { } func TestCatalogClientModelConfigsRejectsInvalidResponse(t *testing.T) { - client := NewCatalogClient( + client := NewClient( &recordingClient{Responses: [][]byte{[]byte(`{"items":[]}`)}}, "token", "https://example.com", diff --git a/internal/sync/api/client.go b/internal/sync/api/client.go index 8df18c01..2bba1674 100644 --- a/internal/sync/api/client.go +++ b/internal/sync/api/client.go @@ -98,7 +98,7 @@ func (client Client) ModelConfig(projectKey, modelConfigKey string) (ModelConfig // ReadVariation returns one variation and its parent config mode. func (client Client) ReadVariation(projectKey, configKey, variationKey string) (VariationState, error) { - config, err := NewCatalogClient(client.transport, client.accessToken, client.baseURI).Config(projectKey, configKey) + config, err := client.Config(projectKey, configKey) if err != nil { return VariationState{}, err } @@ -266,14 +266,27 @@ func (client Client) variationEndpoint(projectKey, configKey string, path ...str // response. Errors without a status code may represent a committed write whose // response was lost, so the reconciliation layer verifies those with a read. func newMutationError(action, variationKey string, err error) error { - var response struct { - StatusCode int `json:"statusCode"` - } - definitiveResponse := json.Unmarshal([]byte(err.Error()), &response) == nil && response.StatusCode != 0 + return newResourceMutationError(action, "config variation", variationKey, err) +} + +func newResourceMutationError(action, resource, key string, err error) error { + _, definitiveResponse := responseStatusCode(err) return mutationError{ err: err, - message: fmt.Sprintf("%s config variation %q: %s", action, variationKey, err), + message: fmt.Sprintf("%s %s %q: %s", action, resource, key, err), uncertain: !definitiveResponse, } } + +func responseStatusCode(err error) (int, bool) { + for current := err; current != nil; current = errors.Unwrap(current) { + var response struct { + StatusCode int `json:"statusCode"` + } + if json.Unmarshal([]byte(current.Error()), &response) == nil && response.StatusCode != 0 { + return response.StatusCode, true + } + } + return 0, false +} diff --git a/internal/sync/bootstrap/bootstrap.go b/internal/sync/bootstrap/bootstrap.go index e001309f..5de9b8f8 100644 --- a/internal/sync/bootstrap/bootstrap.go +++ b/internal/sync/bootstrap/bootstrap.go @@ -19,8 +19,8 @@ import ( // Catalog lists projects and configs available for bootstrap. type Catalog interface { - Projects() ([]syncapi.Project, error) - Configs(projectKey string) ([]syncapi.Config, error) + SearchProjects(query string, limit, offset int) (syncapi.Page[syncapi.Project], error) + SearchConfigs(projectKey, query string, modes []syncdomain.VariationMode, limit, offset int) (syncapi.Page[syncapi.Config], error) } // ManifestStore persists the synchronization baseline after local files are written. @@ -61,33 +61,36 @@ func Run(options Options) error { // selectVariationFiles guides the user from project to config to variations // and converts the selections into local wrapper definitions. func selectVariationFiles(options Options) ([]synclocal.VariationFile, bool, error) { - console := syncconsole.New(options.Output) - _ = console.Line("Loading LaunchDarkly projects...") - projects, err := options.Catalog.Projects() - if err != nil { - return nil, false, err - } - project, canceled, err := syncinteractive.Select( - options.Input, - options.Output, - "Choose a LaunchDarkly project", - projectChoices(projects), - ) + project, canceled, err := syncinteractive.SearchSelect(syncinteractive.SearchOptions[syncapi.Project]{ + Input: options.Input, + Output: options.Output, + SearchTitle: "Search LaunchDarkly projects", + SearchPlaceholder: "Project name or key", + SelectTitle: "Choose a LaunchDarkly project", + ItemName: "projects", + Fetch: func(query string, limit, offset int) ([]syncapi.Project, int, error) { + page, err := options.Catalog.SearchProjects(query, limit, offset) + return page.Items, page.TotalCount, err + }, + Choice: projectChoice, + }) if err != nil || canceled { return nil, canceled, err } - _ = console.Line("Loading configs...") - configs, err := options.Catalog.Configs(project.Key) - if err != nil { - return nil, false, err - } - config, canceled, err := syncinteractive.Select( - options.Input, - options.Output, - "Choose a config", - configChoices(configs), - ) + config, canceled, err := syncinteractive.SearchSelect(syncinteractive.SearchOptions[syncapi.Config]{ + Input: options.Input, + Output: options.Output, + SearchTitle: "Search LaunchDarkly configs", + SearchPlaceholder: "Config name or key", + SelectTitle: "Choose a config", + ItemName: "configs", + Fetch: func(query string, limit, offset int) ([]syncapi.Config, int, error) { + page, err := options.Catalog.SearchConfigs(project.Key, query, nil, limit, offset) + return page.Items, page.TotalCount, err + }, + Choice: configChoice, + }) if err != nil || canceled { return nil, canceled, err } @@ -164,32 +167,24 @@ func variationChoices( continue } choices = append(choices, syncinteractive.Choice[syncdomain.Variation]{ - Title: variation.Name, Description: variation.Key, Value: variation, + Title: variation.Name, Description: "Key: " + variation.Key, Value: variation, }) } return choices, existingCount, nil } -// projectChoices adapts API projects to the shared interactive choice model. -func projectChoices(projects []syncapi.Project) []syncinteractive.Choice[syncapi.Project] { - choices := make([]syncinteractive.Choice[syncapi.Project], 0, len(projects)) - for _, project := range projects { - choices = append(choices, syncinteractive.Choice[syncapi.Project]{ - Title: project.Name, Description: project.Key, Value: project, - }) +// projectChoice keeps the readable project name above its stable key. +func projectChoice(project syncapi.Project) syncinteractive.Choice[syncapi.Project] { + return syncinteractive.Choice[syncapi.Project]{ + Title: project.Name, Description: "Key: " + project.Key, Value: project, } - return choices } -// configChoices adapts configs to labels that include both identity and mode. -func configChoices(configs []syncapi.Config) []syncinteractive.Choice[syncapi.Config] { - choices := make([]syncinteractive.Choice[syncapi.Config], 0, len(configs)) - for _, config := range configs { - choices = append(choices, syncinteractive.Choice[syncapi.Config]{ - Title: config.Name, Description: fmt.Sprintf("%s · %s", config.Key, config.Mode), Value: config, - }) +// configChoice includes both the stable config identity and its mode. +func configChoice(config syncapi.Config) syncinteractive.Choice[syncapi.Config] { + return syncinteractive.Choice[syncapi.Config]{ + Title: config.Name, Description: fmt.Sprintf("Key: %s · Mode: %s", config.Key, config.Mode), Value: config, } - return choices } // finishSelection validates the selected variations, renders dry-run previews, diff --git a/internal/sync/bootstrap/bootstrap_test.go b/internal/sync/bootstrap/bootstrap_test.go index b16c1e6d..54cb4908 100644 --- a/internal/sync/bootstrap/bootstrap_test.go +++ b/internal/sync/bootstrap/bootstrap_test.go @@ -266,12 +266,12 @@ type fakeCatalog struct{} var _ Catalog = &fakeCatalog{} -func (*fakeCatalog) Projects() ([]syncapi.Project, error) { - return nil, nil +func (*fakeCatalog) SearchProjects(string, int, int) (syncapi.Page[syncapi.Project], error) { + return syncapi.Page[syncapi.Project]{}, nil } -func (*fakeCatalog) Configs(string) ([]syncapi.Config, error) { - return nil, nil +func (*fakeCatalog) SearchConfigs(string, string, []syncdomain.VariationMode, int, int) (syncapi.Page[syncapi.Config], error) { + return syncapi.Page[syncapi.Config]{}, nil } type failingManifestStore struct{} diff --git a/internal/sync/interactive/form.go b/internal/sync/interactive/form.go index f1b75116..2a4ec4c7 100644 --- a/internal/sync/interactive/form.go +++ b/internal/sync/interactive/form.go @@ -5,12 +5,22 @@ import ( "errors" "fmt" "io" + "strings" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" ) -const choiceSeparator = "\n " +const ( + choiceSeparator = "\n" + selectionHeight = 20 +) + +var formAccent = lipgloss.AdaptiveColor{Light: "24", Dark: "67"} // Choice is one labeled value in an interactive selection. type Choice[T any] struct { @@ -19,24 +29,314 @@ type Choice[T any] struct { Value T } +// SearchOptions describes a reusable server-filtered, incrementally paged selector. +type SearchOptions[T any] struct { + Input io.Reader + Output io.Writer + SearchTitle string + SearchPlaceholder string + SelectTitle string + ItemName string + PageSize int + Fetch func(query string, limit, offset int) ([]T, int, error) + Choice func(T) Choice[T] +} + +type searchAction int + +const ( + chooseSearchResult searchAction = iota + loadMoreSearchResults + restartSearch +) + +type searchItem[T any] struct { + choice Choice[T] + action searchAction +} + +func (item searchItem[T]) Title() string { return item.choice.Title } +func (item searchItem[T]) Description() string { return item.choice.Description } +func (item searchItem[T]) FilterValue() string { return "" } + +type searchPage[T any] struct { + query string + offset int + items []T + totalCount int + err error +} + +type searchModel[T any] struct { + options SearchOptions[T] + list list.Model + input textinput.Model + items []T + query string + pageSize int + totalCount int + hasMore bool + searching bool + loading bool + canceled bool + selected bool + value T + err error +} + +// SearchSelect presents one reusable selector whose search and pagination are +// both backed by the catalog API. Pressing slash opens the search field. +func SearchSelect[T any](options SearchOptions[T]) (T, bool, error) { + var zero T + pageSize := options.PageSize + if pageSize <= 0 { + pageSize = 25 + } + + items, totalCount, err := options.Fetch("", pageSize, 0) + if err != nil { + return zero, false, err + } + + model := newSearchModel(options, items, totalCount, pageSize) + program := tea.NewProgram( + model, + tea.WithAltScreen(), + tea.WithReportFocus(), + tea.WithInput(options.Input), + tea.WithOutput(options.Output), + ) + final, err := program.Run() + if err != nil { + return zero, false, err + } + result, ok := final.(*searchModel[T]) + if !ok { + return zero, false, errors.New("search selector returned an unexpected result") + } + if result.err != nil { + return zero, false, result.err + } + if !result.selected { + return zero, result.canceled, nil + } + return result.value, false, nil +} + +func newSearchModel[T any](options SearchOptions[T], items []T, totalCount, pageSize int) *searchModel[T] { + input := textinput.New() + input.Prompt = "/ " + input.Placeholder = options.SearchPlaceholder + input.PromptStyle = lipgloss.NewStyle().Foreground(formAccent) + input.Cursor.Style = lipgloss.NewStyle().Foreground(formAccent) + + delegate := list.NewDefaultDelegate() + delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.Foreground(formAccent).BorderLeft(false) + delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.Foreground(formAccent).BorderLeft(false) + + selector := list.New(nil, delegate, 80, selectionHeight+6) + selector.Title = options.SelectTitle + selector.Styles.Title = lipgloss.NewStyle().Foreground(formAccent).Bold(true).Padding(0, 1) + selector.SetFilteringEnabled(false) + selector.SetShowStatusBar(false) + selector.SetShowPagination(false) + selector.DisableQuitKeybindings() + selector.AdditionalShortHelpKeys = func() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")), + key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), + } + } + + model := &searchModel[T]{ + options: options, + list: selector, + input: input, + items: items, + pageSize: pageSize, + totalCount: totalCount, + } + model.hasMore = len(items) == pageSize && (totalCount == 0 || len(items) < totalCount) + model.rebuildList(false) + return model +} + +func (model *searchModel[T]) Init() tea.Cmd { + return nil +} + +func (model *searchModel[T]) Update(message tea.Msg) (tea.Model, tea.Cmd) { + switch message := message.(type) { + case searchPage[T]: + return model.receivePage(message) + case tea.WindowSizeMsg: + height := min(message.Height, selectionHeight+6) + model.list.SetSize(message.Width, height) + model.input.Width = max(1, min(message.Width-4, 76)) + case tea.KeyMsg: + if model.loading { + if message.String() == "ctrl+c" { + model.canceled = true + return model, tea.Quit + } + return model, nil + } + if model.searching { + return model.updateSearch(message) + } + switch message.String() { + case "/": + return model.openSearch() + case "esc", "ctrl+c": + model.canceled = true + return model, tea.Quit + case "enter": + return model.choose() + } + } + + var command tea.Cmd + model.list, command = model.list.Update(message) + return model, command +} + +func (model *searchModel[T]) View() string { + if model.loading { + return model.list.Styles.Title.Render(model.options.SelectTitle) + "\n\nSearching LaunchDarkly…\n" + } + if model.searching { + return model.list.Styles.Title.Render(model.options.SearchTitle) + "\n\n" + + model.input.View() + "\n\n" + + lipgloss.NewStyle().Faint(true).Render("enter search • esc back") + "\n" + } + return model.list.View() +} + +func (model *searchModel[T]) openSearch() (tea.Model, tea.Cmd) { + model.searching = true + model.input.SetValue(model.query) + model.input.CursorEnd() + return model, model.input.Focus() +} + +func (model *searchModel[T]) updateSearch(message tea.KeyMsg) (tea.Model, tea.Cmd) { + switch message.String() { + case "ctrl+c": + model.canceled = true + return model, tea.Quit + case "esc": + model.searching = false + model.input.Blur() + return model, nil + case "enter": + model.searching = false + model.loading = true + model.input.Blur() + return model, model.fetch(strings.TrimSpace(model.input.Value()), 0) + } + + var command tea.Cmd + model.input, command = model.input.Update(message) + return model, command +} + +func (model *searchModel[T]) choose() (tea.Model, tea.Cmd) { + item, ok := model.list.SelectedItem().(searchItem[T]) + if !ok { + return model, nil + } + switch item.action { + case chooseSearchResult: + model.selected = true + model.value = item.choice.Value + return model, tea.Quit + case loadMoreSearchResults: + model.loading = true + return model, model.fetch(model.query, len(model.items)) + case restartSearch: + return model.openSearch() + } + return model, nil +} + +func (model *searchModel[T]) fetch(query string, offset int) tea.Cmd { + return func() tea.Msg { + items, totalCount, err := model.options.Fetch(query, model.pageSize, offset) + return searchPage[T]{query: query, offset: offset, items: items, totalCount: totalCount, err: err} + } +} + +func (model *searchModel[T]) receivePage(page searchPage[T]) (tea.Model, tea.Cmd) { + model.loading = false + if page.err != nil { + model.err = page.err + return model, tea.Quit + } + + previousCount := len(model.items) + if page.offset == 0 { + model.items = page.items + model.query = page.query + previousCount = 0 + } else { + model.items = append(model.items, page.items...) + } + model.totalCount = page.totalCount + model.hasMore = len(page.items) == model.pageSize && (page.totalCount == 0 || len(model.items) < page.totalCount) + model.rebuildList(page.offset > 0) + if page.offset > 0 { + model.list.Select(previousCount) + } + return model, nil +} + +func (model *searchModel[T]) rebuildList(loadedMore bool) { + items := make([]list.Item, 0, len(model.items)+2) + for _, value := range model.items { + items = append(items, searchItem[T]{ + choice: model.options.Choice(value), + action: chooseSearchResult, + }) + } + if model.hasMore { + description := fmt.Sprintf("%d loaded", len(model.items)) + if model.totalCount > 0 { + description = fmt.Sprintf("%d of %d loaded", len(model.items), model.totalCount) + } + items = append(items, searchItem[T]{ + choice: Choice[T]{Title: "Load more", Description: description}, + action: loadMoreSearchResults, + }) + } + + if len(model.items) == 0 { + items = append(items, searchItem[T]{ + choice: Choice[T]{ + Title: "Search again", + Description: fmt.Sprintf("No %s matched", model.options.ItemName), + }, + action: restartSearch, + }) + } + _ = model.list.SetItems(items) + + title := model.options.SelectTitle + if model.query != "" { + title += fmt.Sprintf(" · Results for %q", model.query) + } + model.list.Title = title + if !loadedMore { + model.list.Select(0) + } +} + // Select asks the user to choose one value. -func Select[T any]( - input io.Reader, - output io.Writer, - title string, - choices []Choice[T], -) (T, bool, error) { +func Select[T any](input io.Reader, output io.Writer, title string, choices []Choice[T]) (T, bool, error) { return SelectContext(context.Background(), input, output, title, choices) } // SelectContext asks the user to choose one value and stops when the context ends. -func SelectContext[T any]( - ctx context.Context, - input io.Reader, - output io.Writer, - title string, - choices []Choice[T], -) (T, bool, error) { +func SelectContext[T any](ctx context.Context, input io.Reader, output io.Writer, title string, choices []Choice[T]) (T, bool, error) { var zero T if len(choices) == 0 { return zero, false, fmt.Errorf("%s: no choices are available", title) @@ -48,14 +348,17 @@ func SelectContext[T any]( options[index] = huh.NewOption(choiceLabel(choice), index) } + field := huh.NewSelect[int](). + Title(title). + Options(options...). + Height(selectionHeight). + Value(&selected) + canceled, err := RunFormContext( ctx, input, output, - huh.NewSelect[int](). - Title(title). - Options(options...). - Value(&selected), + field, ) if err != nil || canceled { return zero, canceled, err @@ -64,13 +367,7 @@ func SelectContext[T any]( } // MultiSelect asks the user to choose one or more values. -func MultiSelect[T any]( - input io.Reader, - output io.Writer, - title string, - description string, - choices []Choice[T], -) ([]T, bool, error) { +func MultiSelect[T any](input io.Reader, output io.Writer, title, description string, choices []Choice[T]) ([]T, bool, error) { if len(choices) == 0 { return nil, false, nil } @@ -85,6 +382,7 @@ func MultiSelect[T any]( Title(title). Description(description). Options(options...). + Height(selectionHeight). Value(&selected). Validate(func(values []int) error { if len(values) == 0 { @@ -105,22 +403,14 @@ func MultiSelect[T any]( } // RunForm runs fields with the shared sync theme and terminal streams. -func RunForm( - input io.Reader, - output io.Writer, - fields ...huh.Field, -) (bool, error) { +func RunForm(input io.Reader, output io.Writer, fields ...huh.Field) (bool, error) { return RunFormContext(context.Background(), input, output, fields...) } // RunFormContext runs fields until they complete, abort, or the context ends. -func RunFormContext( - ctx context.Context, - input io.Reader, - output io.Writer, - fields ...huh.Field, -) (bool, error) { +func RunFormContext(ctx context.Context, input io.Reader, output io.Writer, fields ...huh.Field) (bool, error) { err := huh.NewForm(huh.NewGroup(fields...)). + WithProgramOptions(tea.WithAltScreen(), tea.WithReportFocus()). WithInput(input). WithOutput(output). WithAccessible(false). @@ -143,16 +433,19 @@ func choiceLabel[T any](choice Choice[T]) string { // formTheme applies the subdued sync palette consistently to every form. func formTheme() *huh.Theme { theme := huh.ThemeBase() - accent := lipgloss.AdaptiveColor{Light: "24", Dark: "67"} buttonText := lipgloss.AdaptiveColor{Light: "255", Dark: "0"} - theme.Focused.Title = theme.Focused.Title.Foreground(accent).Bold(true) - theme.Focused.SelectSelector = theme.Focused.SelectSelector.Foreground(accent) - theme.Focused.MultiSelectSelector = theme.Focused.MultiSelectSelector.Foreground(accent) - theme.Focused.SelectedOption = theme.Focused.SelectedOption.Foreground(accent) - theme.Focused.SelectedPrefix = theme.Focused.SelectedPrefix.Foreground(accent) + theme.Focused.Base = lipgloss.NewStyle() + theme.Focused.Card = theme.Focused.Base + theme.Blurred.Base = lipgloss.NewStyle() + theme.Blurred.Card = theme.Blurred.Base + theme.Focused.Title = theme.Focused.Title.Foreground(formAccent).Bold(true) + theme.Focused.SelectSelector = theme.Focused.SelectSelector.Foreground(formAccent) + theme.Focused.MultiSelectSelector = theme.Focused.MultiSelectSelector.Foreground(formAccent) + theme.Focused.SelectedOption = theme.Focused.SelectedOption.Foreground(formAccent) + theme.Focused.SelectedPrefix = theme.Focused.SelectedPrefix.Foreground(formAccent) theme.Focused.FocusedButton = theme.Focused.FocusedButton. - Background(accent). + Background(formAccent). Foreground(buttonText) return theme diff --git a/internal/sync/interactive/form_test.go b/internal/sync/interactive/form_test.go index 92d52793..ab1c8320 100644 --- a/internal/sync/interactive/form_test.go +++ b/internal/sync/interactive/form_test.go @@ -1,18 +1,22 @@ package interactive import ( + "bytes" + "strings" "testing" + tea "github.com/charmbracelet/bubbletea" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestChoiceLabelPlacesDescriptionBelowTitle(t *testing.T) { assert.Equal( t, - "Support prompt\n support\n", + "Support prompt\nKey: support\n", choiceLabel(Choice[string]{ Title: "Support prompt", - Description: "support", + Description: "Key: support", }), ) assert.Equal( @@ -21,3 +25,90 @@ func TestChoiceLabelPlacesDescriptionBelowTitle(t *testing.T) { choiceLabel(Choice[string]{Title: "Support prompt"}), ) } + +func TestFormThemeDoesNotRenderFieldSidebars(t *testing.T) { + theme := formTheme() + + assert.Equal(t, "content", theme.Focused.Base.Render("content")) + assert.Equal(t, "content", theme.Focused.Card.Render("content")) + assert.Equal(t, "content", theme.Blurred.Base.Render("content")) + assert.Equal(t, "content", theme.Blurred.Card.Render("content")) +} + +func TestSelectUsesAlternateScreen(t *testing.T) { + var output bytes.Buffer + + selected, canceled, err := Select( + strings.NewReader("\r"), + &output, + "Choose", + []Choice[string]{{Title: "First", Value: "first"}}, + ) + + require.NoError(t, err) + assert.False(t, canceled) + assert.Equal(t, "first", selected) + assert.Contains(t, output.String(), "\x1b[?1049h") + assert.Contains(t, output.String(), "\x1b[?1049l") +} + +func TestSearchModelUsesBackendForSlashSearch(t *testing.T) { + var queries []string + model := newSearchModel(SearchOptions[string]{ + SearchTitle: "Search projects", + SearchPlaceholder: "Name or key", + SelectTitle: "Choose a project", + ItemName: "projects", + Fetch: func(query string, _, _ int) ([]string, int, error) { + queries = append(queries, query) + return []string{"support"}, 1, nil + }, + Choice: func(value string) Choice[string] { + return Choice[string]{Title: "Support", Description: value, Value: value} + }, + }, []string{"alpha"}, 1, 25) + + _, command := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + require.NotNil(t, command) + assert.True(t, model.searching) + + model.input.SetValue("support") + _, command = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + require.NotNil(t, command) + assert.Empty(t, queries) + + _, command = model.Update(command()) + assert.Nil(t, command) + assert.Equal(t, []string{"support"}, queries) + assert.Equal(t, []string{"support"}, model.items) + + _, command = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + require.NotNil(t, command) + assert.True(t, model.selected) + assert.Equal(t, "support", model.value) +} + +func TestSearchModelLoadsTheNextBackendPage(t *testing.T) { + var offsets []int + model := newSearchModel(SearchOptions[string]{ + SelectTitle: "Choose", + ItemName: "items", + Fetch: func(_ string, _, offset int) ([]string, int, error) { + offsets = append(offsets, offset) + return []string{"third"}, 3, nil + }, + Choice: func(value string) Choice[string] { + return Choice[string]{Title: value, Value: value} + }, + }, []string{"first", "second"}, 3, 2) + model.list.Select(2) + + _, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + require.NotNil(t, command) + _, _ = model.Update(command()) + + assert.Equal(t, []int{2}, offsets) + assert.Equal(t, []string{"first", "second", "third"}, model.items) + assert.False(t, model.hasMore) + assert.Equal(t, 2, model.list.Index()) +} diff --git a/internal/sync/link/link.go b/internal/sync/link/link.go index 3a450448..60bdf034 100644 --- a/internal/sync/link/link.go +++ b/internal/sync/link/link.go @@ -23,8 +23,8 @@ import ( // Catalog lists the LaunchDarkly resources required to link a prompt. type Catalog interface { - Projects() ([]syncapi.Project, error) - Configs(projectKey string) ([]syncapi.Config, error) + SearchProjects(query string, limit, offset int) (syncapi.Page[syncapi.Project], error) + SearchConfigs(projectKey, query string, modes []syncdomain.VariationMode, limit, offset int) (syncapi.Page[syncapi.Config], error) ModelConfigs(projectKey string) ([]syncapi.ModelConfig, error) } @@ -69,33 +69,40 @@ func Run(options Options) (string, error) { } console := syncconsole.New(options.Output) - _ = console.Line("Loading LaunchDarkly projects...") - projects, err := options.Catalog.Projects() - if err != nil { - return "", err - } - project, canceled, err := syncinteractive.Select( - options.Input, - options.Output, - "Choose a LaunchDarkly project", - projectChoices(projects), - ) + project, canceled, err := syncinteractive.SearchSelect(syncinteractive.SearchOptions[syncapi.Project]{ + Input: options.Input, + Output: options.Output, + SearchTitle: "Search LaunchDarkly projects", + SearchPlaceholder: "Project name or key", + SelectTitle: "Choose a LaunchDarkly project", + ItemName: "projects", + Fetch: func(query string, limit, offset int) ([]syncapi.Project, int, error) { + page, err := options.Catalog.SearchProjects(query, limit, offset) + return page.Items, page.TotalCount, err + }, + Choice: projectChoice, + }) if err != nil || canceled { return "", err } - _ = console.Line("Loading configs...") - configs, err := options.Catalog.Configs(project.Key) - if err != nil { - return "", err - } - configs = configsForPrompt(configs, prompt.parsed) - config, canceled, err := syncinteractive.Select( - options.Input, - options.Output, - "Choose a config", - configChoices(configs), - ) + var configModes []syncdomain.VariationMode + if prompt.parsed.Mode != "" { + configModes = []syncdomain.VariationMode{syncdomain.VariationMode(prompt.parsed.Mode)} + } + config, canceled, err := syncinteractive.SearchSelect(syncinteractive.SearchOptions[syncapi.Config]{ + Input: options.Input, + Output: options.Output, + SearchTitle: "Search LaunchDarkly configs", + SearchPlaceholder: "Config name or key", + SelectTitle: "Choose a config", + ItemName: "configs", + Fetch: func(query string, limit, offset int) ([]syncapi.Config, int, error) { + page, err := options.Catalog.SearchConfigs(project.Key, query, configModes, limit, offset) + return page.Items, page.TotalCount, err + }, + Choice: configChoice, + }) if err != nil || canceled { return "", err } @@ -293,20 +300,6 @@ func readLinkedPrompt(options Options) (linkedPrompt, error) { return linkedPrompt{reference: reference, parsed: prompt, originalContent: content, content: content}, nil } -// configsForPrompt limits destinations when the adapter supplied a mode. -func configsForPrompt(configs []syncapi.Config, prompt adapters.Prompt) []syncapi.Config { - if prompt.Mode == "" { - return configs - } - result := make([]syncapi.Config, 0, len(configs)) - for _, config := range configs { - if string(config.Mode) == string(prompt.Mode) { - result = append(result, config) - } - } - return result -} - // validateDerivedKey ensures a filename-derived key is also a safe path segment. func validateDerivedKey(key string) error { switch { @@ -339,26 +332,18 @@ func displayName(key string) string { return string(runes) } -// projectChoices adapts projects to interactive labels. -func projectChoices(projects []syncapi.Project) []syncinteractive.Choice[syncapi.Project] { - choices := make([]syncinteractive.Choice[syncapi.Project], 0, len(projects)) - for _, project := range projects { - choices = append(choices, syncinteractive.Choice[syncapi.Project]{ - Title: project.Name, Description: project.Key, Value: project, - }) +// projectChoice keeps the readable project name above its stable key. +func projectChoice(project syncapi.Project) syncinteractive.Choice[syncapi.Project] { + return syncinteractive.Choice[syncapi.Project]{ + Title: project.Name, Description: "Key: " + project.Key, Value: project, } - return choices } -// configChoices adapts configs to labels that expose key and mode. -func configChoices(configs []syncapi.Config) []syncinteractive.Choice[syncapi.Config] { - choices := make([]syncinteractive.Choice[syncapi.Config], 0, len(configs)) - for _, config := range configs { - choices = append(choices, syncinteractive.Choice[syncapi.Config]{ - Title: config.Name, Description: fmt.Sprintf("%s · %s", config.Key, config.Mode), Value: config, - }) +// configChoice includes both the stable config identity and its mode. +func configChoice(config syncapi.Config) syncinteractive.Choice[syncapi.Config] { + return syncinteractive.Choice[syncapi.Config]{ + Title: config.Name, Description: fmt.Sprintf("Key: %s · Mode: %s", config.Key, config.Mode), Value: config, } - return choices } // modelConfigChoices adapts model configs to interactive labels. @@ -366,7 +351,7 @@ func modelConfigChoices(configs []syncapi.ModelConfig) []syncinteractive.Choice[ choices := make([]syncinteractive.Choice[syncapi.ModelConfig], 0, len(configs)) for _, config := range configs { choices = append(choices, syncinteractive.Choice[syncapi.ModelConfig]{ - Title: config.Name, Description: config.Key, Value: config, + Title: config.Name, Description: "Key: " + config.Key, Value: config, }) } return choices diff --git a/internal/sync/prompt/runner.go b/internal/sync/prompt/runner.go index 2429d3b4..f1ab5489 100644 --- a/internal/sync/prompt/runner.go +++ b/internal/sync/prompt/runner.go @@ -87,7 +87,7 @@ func (runner Runner) Run(options Options) error { local: synclocal.NewStore(resolvedWorkspace.Root), manifest: syncmanifest.NewStore(resolvedWorkspace.Root), } - catalog := syncapi.NewCatalogClient(runner.client, options.AccessToken, options.BaseURI) + apiClient := syncapi.NewClient(runner.client, options.AccessToken, options.BaseURI) if options.Detach { return runner.detach(syncdetach.Options{ @@ -100,7 +100,7 @@ func (runner Runner) Run(options Options) error { } if options.Link != "" { path, err := runner.link(synclink.Options{ - Catalog: catalog, + Catalog: apiClient, Store: workspace.local, RepositoryRoot: workspace.root, WorkingDirectory: options.WorkingDirectory, @@ -128,7 +128,7 @@ func (runner Runner) Run(options Options) error { if !localDirectoryExists || options.Add { if err := runner.bootstrap(syncbootstrap.Options{ - Catalog: catalog, + Catalog: apiClient, Store: workspace.local, Manifest: workspace.manifest, Input: options.Input, diff --git a/internal/sync/resource.go b/internal/sync/resource.go index 638d50f3..44fba785 100644 --- a/internal/sync/resource.go +++ b/internal/sync/resource.go @@ -66,6 +66,65 @@ type Message struct { Content string `json:"content"` } +// AttachmentKind identifies a versioned resource referenced by a variation. +type AttachmentKind string + +const ( + AttachmentTool AttachmentKind = "tool" + AttachmentSkill AttachmentKind = "skill" +) + +// AttachmentRef is the stable local reference and exact API pin for one +// variation attachment. Local files persist only the key. +type AttachmentRef struct { + Key string `json:"key" yaml:"key"` + Version int `json:"version,omitempty" yaml:"-"` +} + +// Tool is the canonical, version-independent content of an AI tool. +type Tool struct { + Key string `json:"key" yaml:"key"` + Description *string `json:"description,omitempty" yaml:"description,omitempty"` + Schema map[string]any `json:"schema" yaml:"schema"` + CustomParameters map[string]any `json:"customParameters,omitempty" yaml:"customParameters,omitempty"` + Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"` +} + +// Skill carries one Markdown file plus catalog metadata used for display. +// Local synchronization owns Key, Description, and Markdown; Name remains +// server-owned. +type Skill struct { + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Markdown string `json:"markdown"` +} + +// Attachment pairs canonical content with the latest version observed from +// LaunchDarkly. Exactly one type-specific payload is present. +type Attachment struct { + Kind AttachmentKind + Version int + Upsert bool + Tool *Tool + Skill *Skill +} + +// Key returns the stable key of the type-specific attachment payload. +func (attachment Attachment) Key() string { + switch attachment.Kind { + case AttachmentTool: + if attachment.Tool != nil { + return attachment.Tool.Key + } + case AttachmentSkill: + if attachment.Skill != nil { + return attachment.Skill.Key + } + } + return "" +} + // NormalizePromptText gives semantically equivalent prompt text one stable // representation across API responses, local files, and operating systems. func NormalizePromptText(content string) string {