Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions cmd/setup/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,22 @@ import (

"github.com/spf13/cobra"

"github.com/launchdarkly/ldcli/cmd/cliflags"
"github.com/launchdarkly/ldcli/internal/errors"
"github.com/launchdarkly/ldcli/internal/setup"
"github.com/launchdarkly/ldcli/internal/setup/agent"
)

// agentTask builds the agent task from the wizard's detection result.
func agentTask(dir string, detection *setup.DetectResult, flagKey string, sdkInstalled bool) agent.Task {
t := agent.Task{Dir: dir, FlagKey: flagKey, SDKInstalled: sdkInstalled}
func agentTask(dir string, detection *setup.DetectResult, flagKey string, sdkInstalled bool, projectKey, envKey string) agent.Task {
t := agent.Task{
Dir: dir,
FlagKey: flagKey,
SDKInstalled: sdkInstalled,
ProjectKey: projectKey,
EnvironmentKey: envKey,
ProviderKeys: agent.ProviderKeys(dir, os.Environ()),
}
if detection != nil {
t.SDKID = detection.SDKID
t.Language = detection.Language
Expand Down Expand Up @@ -95,6 +103,8 @@ variables.`,
_ = cmd.MarkFlagRequired(sdkIDFlag)
cmd.Flags().String(flagKeyFlag, "", "Feature flag key that the new code evaluates")
_ = cmd.MarkFlagRequired(flagKeyFlag)
cmd.Flags().String(cliflags.ProjectFlag, "", "LaunchDarkly project key, given to the agent as a fact")
cmd.Flags().String(cliflags.EnvironmentFlag, "", "LaunchDarkly environment key, given to the agent as a fact")
cmd.Flags().String(sdkKeyFlag, "", "Server-side SDK key, shown in the closing instructions only")
cmd.Flags().String(clientIDFlag, "", "Client-side environment ID, shown in the closing instructions only")
cmd.Flags().String(mobileFlag, "", "Mobile SDK key, shown in the closing instructions only")
Expand Down Expand Up @@ -123,7 +133,8 @@ func runAgent(svc setup.Service, runner agent.Runner) func(*cobra.Command, []str
detection.EntryPoint, detection.EntryPointExists = setup.EntryPointFor(dir, sdkID)
detection.PackageManager = setup.PackageManagerFor(dir, sdkID)
}
task := agentTask(dir, detection, getFlag(cmd, flagKeyFlag), setup.IsInstalled(dir, sdkID))
task := agentTask(dir, detection, getFlag(cmd, flagKeyFlag), setup.IsInstalled(dir, sdkID),
getFlag(cmd, cliflags.ProjectFlag), getFlag(cmd, cliflags.EnvironmentFlag))

// Stop OpenCode on Ctrl-C, on kill, and when the terminal closes.
ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
Expand Down
6 changes: 5 additions & 1 deletion cmd/setup/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,9 @@ func startedAgent(t *testing.T, run *fakeRun) wizardModel {

func TestWizard_StartsAgentWithTheTask(t *testing.T) {
runner := &fakeRunner{run: newFakeRun()}
m, cmd := update(t, agentWizard(runner), flagCreatedMsg{key: "my-new-flag"})
w := agentWizard(runner)
w.selectedProject, w.selectedEnv = "proj", "prod"
m, cmd := update(t, w, flagCreatedMsg{key: "my-new-flag"})
require.Equal(t, stepAgent, m.step)

msg := cmd()
Expand All @@ -123,6 +125,8 @@ func TestWizard_StartsAgentWithTheTask(t *testing.T) {
assert.Equal(t, "node-server", runner.tasks[0].SDKID)
assert.Equal(t, "index.js", runner.tasks[0].EntryPoint)
assert.Equal(t, "my-new-flag", runner.tasks[0].FlagKey)
assert.Equal(t, "proj", runner.tasks[0].ProjectKey)
assert.Equal(t, "prod", runner.tasks[0].EnvironmentKey)
assert.NotEmpty(t, runner.tasks[0].Dir)
assert.Contains(t, m.View(), "Starting the agent")
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/setup/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,13 +190,13 @@ func isRemoteSession() bool {
func (m wizardModel) startAgent() tea.Cmd {
runner, tracker := m.agentRunner, m.agentRuns
sdkInstalled := m.installResult != nil && !m.installResult.Failed
task := agentTask("", m.detectResult, m.flagKey, sdkInstalled)
detection, flagKey, project, env := m.detectResult, m.flagKey, m.selectedProject, m.selectedEnv
return func() tea.Msg {
dir, err := os.Getwd()
if err != nil {
return agentFailedMsg{err: err}
}
task.Dir = dir
task := agentTask(dir, detection, flagKey, sdkInstalled, project, env)
ctx := context.Background()
if err := runner.Check(ctx); err != nil {
return agentFailedMsg{err: err}
Expand Down
7 changes: 7 additions & 0 deletions internal/setup/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ type Task struct {
// SDKInstalled reports that the install step added the SDK package, so the
// agent does not need to install it.
SDKInstalled bool
// ProjectKey and EnvironmentKey are where setup created the flag. They
// are empty when they are not known.
ProjectKey string
EnvironmentKey string
// ProviderKeys are the names of the model provider keys that are set. The
// values never reach the agent.
ProviderKeys []string
}

// Config is how the agent behaves for one run: the model and the instructions.
Expand Down
10 changes: 10 additions & 0 deletions internal/setup/agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -440,3 +440,13 @@ func TestRun_CloseIsSafeTwice(t *testing.T) {
assert.NoError(t, run.Close())
assert.NoError(t, run.Close())
}

func TestBuildPrompt_HasTheSameFactsAsBuildFacts(t *testing.T) {
task := Task{SDKID: "python-server-sdk", FlagKey: "f", ProjectKey: "proj", EnvironmentKey: "prod", ProviderKeys: []string{"ANTHROPIC_API_KEY"}}
for _, p := range []string{BuildPrompt(task), BuildFacts(task)} {
assert.Contains(t, p, "LaunchDarkly project: proj")
assert.Contains(t, p, "LaunchDarkly environment: prod")
assert.Contains(t, p, "ANTHROPIC_API_KEY (names only)")
assert.Contains(t, p, "$TMPDIR")
}
}
22 changes: 22 additions & 0 deletions internal/setup/agent/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,29 @@ func BuildFacts(t Task) string {
installed = "yes"
}
writeFact(&b, "SDK package installed", installed)
writeFact(&b, "LaunchDarkly project", t.ProjectKey)
writeFact(&b, "LaunchDarkly environment", t.EnvironmentKey)
writeFact(&b, "Flag created by setup", t.FlagKey)
writeFact(&b, "Environment variable for the SDK credential", CredentialEnvVar(t.SDKID))
writeFact(&b, "Model provider keys that are set", providerKeysFact(t.ProviderKeys))
if t.SDKID == flutterSDK {
writeFact(&b, "Environment variable for a Flutter web app", ClientSideIDEnvVar)
}
b.WriteString("\n" + setupRules)
return b.String()
}

// setupRules ends both prompts, so the agent gets the same rules with or
// without the AI Config.
const setupRules = "ldcli gives the user the SDK credential. Do not look for keys, print the environment, or ask the user for a key. Use your temporary folder in $TMPDIR, not /tmp.\n"

func providerKeysFact(names []string) string {
if len(names) == 0 {
return "none"
}
return strings.Join(names, ", ") + " (names only)"
}

// BuildPrompt returns the task message for one run.
func BuildPrompt(t Task) string {
var b strings.Builder
Expand All @@ -103,6 +121,9 @@ func BuildPrompt(t Task) string {
writeFact(&b, "Framework", t.Framework)
writeFact(&b, "Package manager", t.PackageManager)
writeFact(&b, "Entry point", t.EntryPoint)
writeFact(&b, "LaunchDarkly project", t.ProjectKey)
writeFact(&b, "LaunchDarkly environment", t.EnvironmentKey)
writeFact(&b, "Model provider keys that are set", providerKeysFact(t.ProviderKeys))
b.WriteString("\n")

if t.SDKInstalled {
Expand All @@ -118,6 +139,7 @@ func BuildPrompt(t Task) string {
if t.EntryPoint == "" {
b.WriteString("ldcli did not find an entry point. Find where the application starts before you edit.\n")
}
b.WriteString(setupRules)
Comment thread
atornsii marked this conversation as resolved.
return b.String()
}

Expand Down
70 changes: 70 additions & 0 deletions internal/setup/agent/provider_keys.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package agent

import (
"bufio"
"os"
"path/filepath"
"strings"
)

// providerKeyNames are the environment variables that hold the API keys of
// the model providers that LaunchDarkly AI SDKs support.
var providerKeyNames = []string{
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"AZURE_OPENAI_API_KEY",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"MISTRAL_API_KEY",
"COHERE_API_KEY",
"GROQ_API_KEY",
"AWS_ACCESS_KEY_ID",
"AWS_PROFILE",
}

// ProviderKeys returns the names of the provider keys that are set, in the
// environment or in the project's .env file. It never returns a value. The
// agent uses the answer to decide whether it can run the app, so it never
// has to ask for a key or print the environment.
func ProviderKeys(dir string, environ []string) []string {
set := map[string]bool{}
for _, kv := range environ {
if name, value, ok := strings.Cut(kv, "="); ok && strings.TrimSpace(value) != "" {
set[name] = true
}
}
for name := range dotEnvNames(filepath.Join(dir, ".env")) {
set[name] = true
}
var found []string
for _, name := range providerKeyNames {
if set[name] {
found = append(found, name)
}
}
return found
}

// dotEnvNames returns the names in a .env file that have a value.
func dotEnvNames(path string) map[string]bool {
names := map[string]bool{}
f, err := os.Open(path)
if err != nil {
return names
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
line = strings.TrimPrefix(line, "export ")
if line == "" || strings.HasPrefix(line, "#") {
continue
}
name, value, ok := strings.Cut(line, "=")
value = strings.TrimSpace(strings.Trim(strings.TrimSpace(value), `"'`))
if ok && value != "" {
names[strings.TrimSpace(name)] = true
}
}
return names
}
39 changes: 39 additions & 0 deletions internal/setup/agent/provider_keys_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package agent

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestProviderKeys_NamesFromTheEnvironmentAndDotEnv(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"),
[]byte("# keys\nexport OPENAI_API_KEY=\"sk-123\"\nGEMINI_API_KEY=\nLAUNCHDARKLY_SDK_KEY=sdk-1\n"), 0o600))

keys := ProviderKeys(dir, []string{"ANTHROPIC_API_KEY=sk-ant", "MISTRAL_API_KEY=", "PATH=/bin"})

assert.Equal(t, []string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY"}, keys, "empty values do not count")
}

func TestBuildFacts_HasProjectEnvironmentAndKeyNames(t *testing.T) {
facts := BuildFacts(Task{
SDKID: "python-server-sdk", ProjectKey: "experimentation", EnvironmentKey: "production",
ProviderKeys: []string{"ANTHROPIC_API_KEY"},
})

assert.Contains(t, facts, "- LaunchDarkly project: experimentation")
assert.Contains(t, facts, "- LaunchDarkly environment: production")
assert.Contains(t, facts, "- Model provider keys that are set: ANTHROPIC_API_KEY (names only)")
assert.Contains(t, facts, "Do not look for keys")
assert.Contains(t, BuildFacts(Task{}), "- Model provider keys that are set: none")
}

func TestProviderKeys_BlankQuotedValueIsNotSet(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte("OPENAI_API_KEY=\" \"\nANTHROPIC_API_KEY=' x '\n"), 0o600))
assert.Equal(t, []string{"ANTHROPIC_API_KEY"}, ProviderKeys(dir, nil))
}
6 changes: 6 additions & 0 deletions internal/setup/agent/relay_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httputil"
Expand Down Expand Up @@ -72,6 +74,9 @@ func startRelayProxy(relayBase, accessToken string) (*relayProxy, error) {
// Send each part of a response as it arrives. The flag stream is a
// long-lived event stream, and a buffer would hold its events back.
FlushInterval: -1,
// The proxy logs to stderr by default, which writes into the wizard's
// screen. A failed request reaches the SDK as an error anyway.
ErrorLog: log.New(io.Discard, "", 0),
}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != prefix && !strings.HasPrefix(r.URL.Path, prefix+"/") {
Expand All @@ -85,6 +90,7 @@ func startRelayProxy(relayBase, accessToken string) (*relayProxy, error) {
server: &http.Server{
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ErrorLog: log.New(io.Discard, "", 0),
},
listener: listener,
baseURL: "http://" + listener.Addr().String() + prefix,
Expand Down
Loading
Loading