From c8e4b294965b53964176e0d2f8b89e4ac773ced1 Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Mon, 19 Oct 2026 23:36:36 -0700 Subject: [PATCH 1/2] feat(setup): add the setup agent step to the wizard The wizard now uses the setup agent to add the SDK initialization code. The agent step replaces the init step, and `ldcli setup agent` runs only that step. The --agent flag controls the step. With auto, the default, setup uses the agent if OpenCode 1.19.0 or later is installed, and the templates if not. With on, setup stops if the agent cannot run. With off, setup always uses the templates. The step shows the agent's finished tool calls and its questions, with numbered options. After the run, it lists the changed files and the export command for the SDK's own credential. If the agent fails after it changed a file, setup stops and does not apply a template on top. Setup stops OpenCode when the user quits, when the run starts late, and when the terminal closes. Co-Authored-By: Claude Opus 5.5 --- cmd/setup/agent.go | 287 ++++++++++++++++++++++++++ cmd/setup/agent_test.go | 444 ++++++++++++++++++++++++++++++++++++++++ cmd/setup/commands.go | 52 +++++ cmd/setup/model.go | 96 ++++++++- cmd/setup/setup.go | 11 +- cmd/setup/update.go | 131 ++++++++++++ cmd/setup/view.go | 105 +++++++++- 7 files changed, 1122 insertions(+), 4 deletions(-) create mode 100644 cmd/setup/agent.go create mode 100644 cmd/setup/agent_test.go diff --git a/cmd/setup/agent.go b/cmd/setup/agent.go new file mode 100644 index 000000000..80b7cee1a --- /dev/null +++ b/cmd/setup/agent.go @@ -0,0 +1,287 @@ +package setup + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + + "github.com/spf13/cobra" + + "github.com/launchdarkly/ldcli/internal/errors" + "github.com/launchdarkly/ldcli/internal/setup" + "github.com/launchdarkly/ldcli/internal/setup/agent" +) + +// agentMode is the value of the --agent flag. +type agentMode string + +const ( + // agentOff always uses the init templates. It is the zero value, so a wizard + // built without a mode never starts OpenCode. + agentOff agentMode = "" + // agentAuto uses the agent if it can run, and the init templates if not. + agentAuto agentMode = "auto" + // agentOn uses the agent, and stops with an error if it cannot run. + agentOn agentMode = "on" +) + +const agentFlag = "agent" + +func parseAgentMode(s string) (agentMode, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "auto": + return agentAuto, nil + case "on": + return agentOn, nil + case "off": + return agentOff, nil + } + return agentOff, errors.NewError(fmt.Sprintf("--agent must be auto, on, or off, not %q", s)) +} + +// 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} + if detection != nil { + t.SDKID = detection.SDKID + t.Language = detection.Language + t.Framework = detection.Framework + t.PackageManager = detection.PackageManager + if detection.EntryPointExists { + t.EntryPoint = detection.EntryPoint + } + } + return t +} + +// credentialExport returns the shell command that sets the credential that +// the agent's code reads for the SDK, or an empty string if that credential is +// not known. The agent never sees the value, so the user must set it before +// the application starts. Only the SDK's own credential is shown, so the +// screen does not print keys that the application does not use. +func credentialExport(sdkID, sdkKey, clientSideID, mobileKey string) string { + name := agent.CredentialEnvVar(sdkID) + value := map[string]string{ + agent.SDKKeyEnvVar: sdkKey, + agent.ClientSideIDEnvVar: clientSideID, + agent.MobileKeyEnvVar: mobileKey, + }[name] + if value == "" { + return "" + } + return fmt.Sprintf("export %s=%s", name, value) +} + +// describeTool returns one line for a tool call. +func describeTool(tc agent.ToolCall) string { + what := tc.Name + if tc.Title != "" { + what = tc.Name + ": " + tc.Title + } + switch tc.Status { + case "completed": + return "✓ " + what + case "error": + return "✗ " + what + } + return "… " + what +} + +// describePermission returns the question for a permission request. +func describePermission(req agent.PermissionRequest) string { + if len(req.Patterns) == 0 { + return fmt.Sprintf("The agent asks to use %s.", req.Permission) + } + return fmt.Sprintf("The agent asks to use %s: %s", req.Permission, strings.Join(req.Patterns, ", ")) +} + +func newAgentCmd(svc setup.Service, runner agent.Runner) *cobra.Command { + cmd := &cobra.Command{ + Use: "agent", + Short: "Use the setup agent to add SDK initialization code to a project", + Long: `Runs only the agent step of setup. + +The setup agent uses OpenCode to edit the project. OpenCode must be installed. +The agent runs commands in the project without asking, and it asks you only +when it needs information, such as where to store a key. It never receives your +LaunchDarkly credentials: the code that it writes reads them from environment +variables.`, + RunE: runAgent(svc, runner), + } + + cmd.Flags().String(pathFlag, ".", "Path to the project directory") + cmd.Flags().String(sdkIDFlag, "", "SDK identifier (e.g. node-server, react-client-sdk)") + _ = cmd.MarkFlagRequired(sdkIDFlag) + cmd.Flags().String(flagKeyFlag, "", "Feature flag key that the new code evaluates") + _ = cmd.MarkFlagRequired(flagKeyFlag) + 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") + + return cmd +} + +func runAgent(svc setup.Service, runner agent.Runner) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + dir, err := filepath.Abs(getFlag(cmd, pathFlag)) + if err != nil { + return err + } + sdkID := getFlag(cmd, sdkIDFlag) + + // Reuse detection for the facts in the prompt. If the project is a + // different language from the SDK, the detected entry point is wrong, so + // look it up again for the SDK that the user named. + detection, err := svc.Detect(dir) + if err != nil || detection == nil { + detection = &setup.DetectResult{} + } + if detection.SDKID != sdkID { + detection.SDKID = sdkID + detection.Framework = "" + 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)) + + // Stop OpenCode on Ctrl-C, on kill, and when the terminal closes. + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) + defer stop() + + if err := runner.Check(ctx); err != nil { + return err + } + out := cmd.OutOrStdout() + fmt.Fprintf(out, "Starting the setup agent in %s\n", dir) + run, err := runner.Start(ctx, task) + if err != nil { + return err + } + defer run.Close() + + files, err := streamAgent(ctx, run, out, bufio.NewReader(cmd.InOrStdin())) + if err != nil { + return err + } + + fmt.Fprintln(out) + fmt.Fprintln(out) + if len(files) == 0 { + fmt.Fprintln(out, "The setup agent finished but did not report changed files.") + } else { + fmt.Fprintln(out, "The setup agent changed these files:") + for _, f := range files { + fmt.Fprintf(out, " %s\n", f) + } + } + if export := credentialExport(sdkID, getFlag(cmd, sdkKeyFlag), getFlag(cmd, clientIDFlag), getFlag(cmd, mobileFlag)); export != "" { + fmt.Fprintln(out) + fmt.Fprintln(out, "Set the credential before you start your application:") + fmt.Fprintf(out, " %s\n", export) + } + return nil + } +} + +// streamAgent prints the run's events and asks the user to answer permission +// requests. It returns the changed files when the run finishes. +func streamAgent(ctx context.Context, run agent.Run, out io.Writer, in *bufio.Reader) ([]string, error) { + changed := map[string]bool{} + for { + var ev agent.Event + var ok bool + select { + case <-ctx.Done(): + return nil, ctx.Err() + case ev, ok = <-run.Events(): + } + if !ok { + return nil, errors.NewError("The setup agent stopped without a result.") + } + switch ev.Kind { + case agent.EventText: + fmt.Fprint(out, ev.Text) + case agent.EventTool: + if ev.Tool.Status != "running" { + fmt.Fprintf(out, "\n%s\n", describeTool(ev.Tool)) + } + case agent.EventFileEdited: + changed[ev.File] = true + case agent.EventUsage: + if ev.Usage.ModelID != "" { + fmt.Fprintf(out, "\n(model %s/%s, %d input and %d output tokens)\n", + ev.Usage.ProviderID, ev.Usage.ModelID, ev.Usage.InputTokens, ev.Usage.OutputTokens) + } + case agent.EventPermission: + fmt.Fprintf(out, "\n%s\nAllow? [y/N] ", describePermission(ev.Permission)) + answer, _ := in.ReadString('\n') + allow := strings.EqualFold(strings.TrimSpace(answer), "y") + if err := run.Reply(ctx, ev.Permission, allow); err != nil { + return nil, fmt.Errorf("could not answer the agent: %w", err) + } + case agent.EventQuestion: + answers, err := askQuestions(out, in, ev.Question) + if err != nil { + return nil, err + } + if err := run.Answer(ctx, ev.Question, answers); err != nil { + return nil, fmt.Errorf("could not answer the agent: %w", err) + } + case agent.EventDone: + return sortedKeys(changed), nil + case agent.EventError: + return nil, errors.NewError("The setup agent failed: " + ev.Text) + } + } +} + +// askQuestions prints each question with numbered options and reads the +// user's choice. An empty answer skips the request, and the agent goes on +// without answers. +func askQuestions(out io.Writer, in *bufio.Reader, req agent.QuestionRequest) ([][]string, error) { + var answers [][]string + for _, q := range req.Questions { + fmt.Fprintf(out, "\nThe agent asks: %s\n", q.Text) + for i, o := range q.Options { + fmt.Fprintf(out, " %d. %s", i+1, o.Label) + if o.Description != "" { + fmt.Fprintf(out, " — %s", o.Description) + } + fmt.Fprintln(out) + } + for { + fmt.Fprintf(out, "Choose 1-%d, or press Enter to skip: ", len(q.Options)) + line, err := in.ReadString('\n') + line = strings.TrimSpace(line) + if line == "" { + return nil, nil + } + choice, convErr := strconv.Atoi(line) + if convErr == nil && choice >= 1 && choice <= len(q.Options) { + answers = append(answers, []string{q.Options[choice-1].Label}) + break + } + if err != nil { + return nil, nil + } + } + } + return answers, nil +} + +func sortedKeys(m map[string]bool) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/cmd/setup/agent_test.go b/cmd/setup/agent_test.go new file mode 100644 index 000000000..900ac4af7 --- /dev/null +++ b/cmd/setup/agent_test.go @@ -0,0 +1,444 @@ +package setup + +import ( + "bufio" + "bytes" + "context" + "strings" + "sync" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/setup" + "github.com/launchdarkly/ldcli/internal/setup/agent" +) + +type fakeReply struct { + id string + allow bool +} + +type fakeAnswer struct { + id string + answers [][]string +} + +type fakeRun struct { + events chan agent.Event + + mu sync.Mutex + replies []fakeReply + answers []fakeAnswer + closed int +} + +func newFakeRun(events ...agent.Event) *fakeRun { + r := &fakeRun{events: make(chan agent.Event, len(events)+1)} + for _, e := range events { + r.events <- e + } + return r +} + +func (r *fakeRun) Events() <-chan agent.Event { return r.events } + +func (r *fakeRun) Reply(_ context.Context, req agent.PermissionRequest, allow bool) error { + r.mu.Lock() + defer r.mu.Unlock() + r.replies = append(r.replies, fakeReply{id: req.ID, allow: allow}) + return nil +} + +func (r *fakeRun) Answer(_ context.Context, req agent.QuestionRequest, answers [][]string) error { + r.mu.Lock() + defer r.mu.Unlock() + r.answers = append(r.answers, fakeAnswer{id: req.ID, answers: answers}) + return nil +} + +func (r *fakeRun) Close() error { + r.mu.Lock() + defer r.mu.Unlock() + r.closed++ + return nil +} + +type fakeRunner struct { + checkErr error + run *fakeRun + tasks []agent.Task +} + +func (f *fakeRunner) Check(context.Context) error { return f.checkErr } + +func (f *fakeRunner) Start(_ context.Context, task agent.Task) (agent.Run, error) { + f.tasks = append(f.tasks, task) + return f.run, nil +} + +func agentWizard(mode agentMode, runner agent.Runner) wizardModel { + return wizardModel{ + step: stepCreateFlag, + width: 80, + height: 30, + agentMode: mode, + agentRunner: runner, + agentRuns: &runTracker{}, + sdkKey: "sdk-123", + detectResult: &setup.DetectResult{SDKID: "node-server", Language: "JavaScript", EntryPoint: "index.js", EntryPointExists: true}, + } +} + +func update(t *testing.T, m wizardModel, msg tea.Msg) (wizardModel, tea.Cmd) { + t.Helper() + next, cmd := m.Update(msg) + return next.(wizardModel), cmd +} + +// startedAgent drives the wizard to a running agent step. +func startedAgent(t *testing.T, mode agentMode, run *fakeRun) wizardModel { + t.Helper() + runner := &fakeRunner{run: run} + m, cmd := update(t, agentWizard(mode, runner), flagCreatedMsg{key: "my-new-flag"}) + require.Equal(t, stepAgent, m.step) + require.NotNil(t, cmd) + started, ok := cmd().(agentStartedMsg) + require.True(t, ok) + m, _ = update(t, m, started) + require.Len(t, runner.tasks, 1) + return m +} + +func TestParseAgentMode(t *testing.T) { + for in, want := range map[string]agentMode{"auto": agentAuto, "ON": agentOn, " off ": agentOff} { + got, err := parseAgentMode(in) + require.NoError(t, err) + assert.Equal(t, want, got) + } + _, err := parseAgentMode("sometimes") + assert.Error(t, err) +} + +func TestWizard_AgentOff_UsesInit(t *testing.T) { + m, _ := update(t, agentWizard(agentOff, &fakeRunner{}), flagCreatedMsg{key: "f"}) + + assert.Equal(t, stepInit, m.step) +} + +func TestWizard_AgentAuto_StartsAgentWithTheTask(t *testing.T) { + runner := &fakeRunner{run: newFakeRun()} + m, cmd := update(t, agentWizard(agentAuto, runner), flagCreatedMsg{key: "my-new-flag"}) + require.Equal(t, stepAgent, m.step) + + msg := cmd() + + require.IsType(t, agentStartedMsg{}, msg) + require.Len(t, runner.tasks, 1) + 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.NotEmpty(t, runner.tasks[0].Dir) + assert.Contains(t, m.View(), "Starting the agent") +} + +func TestWizard_AgentUnavailable_AutoFallsBackToInit(t *testing.T) { + m, cmd := update(t, agentWizard(agentAuto, &fakeRunner{checkErr: agent.ErrNotInstalled}), flagCreatedMsg{key: "f"}) + + m, _ = update(t, m, cmd()) + + assert.Equal(t, stepInit, m.step) + assert.Contains(t, m.agentNote, "OpenCode is not installed") + assert.Nil(t, m.err) +} + +func TestWizard_AgentUnavailable_OnStopsWithError(t *testing.T) { + m, cmd := update(t, agentWizard(agentOn, &fakeRunner{checkErr: agent.ErrNotInstalled}), flagCreatedMsg{key: "f"}) + + m, _ = update(t, m, cmd()) + + require.Error(t, m.err) + assert.Contains(t, m.View(), "OpenCode is not installed") +} + +func TestWizard_AgentEvents_LogToolsAndFiles(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + + m, cmd := update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventTool, Tool: agent.ToolCall{Name: "edit", Status: "completed", Title: "index.js"}}}) + require.NotNil(t, cmd, "the wizard asks for the next event") + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventFileEdited, File: "index.js"}}) + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventFileEdited, File: "index.js"}}) + + assert.Equal(t, []string{"index.js"}, m.agentFiles) + assert.Contains(t, m.View(), "✓ edit: index.js") +} + +func TestWizard_AgentPermission_YAllows(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + req := agent.PermissionRequest{ID: "per1", Permission: "bash", Patterns: []string{"npm test"}} + + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventPermission, Permission: req}}) + assert.Contains(t, m.View(), "The agent asks to use bash: npm test") + + m, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) + require.NotNil(t, cmd) + m, _ = update(t, m, cmd()) + + assert.Empty(t, m.agentPermissions) + assert.Equal(t, []fakeReply{{id: "per1", allow: true}}, run.replies) +} + +func TestWizard_AgentPermission_NRejects(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventPermission, Permission: agent.PermissionRequest{ID: "per1"}}}) + _, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}) + cmd() + + assert.Equal(t, []fakeReply{{id: "per1", allow: false}}, run.replies) +} + +func TestWizard_AgentDone_GoesToWaitForAppWithExports(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventFileEdited, File: "index.js"}}) + + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventDone}}) + + assert.Equal(t, stepWaitForApp, m.step) + assert.Equal(t, 1, run.closed) + view := m.View() + assert.Contains(t, view, "index.js") + assert.Contains(t, view, "export LAUNCHDARKLY_SDK_KEY=sdk-123") + assert.NotContains(t, view, "LAUNCHDARKLY_MOBILE_KEY", "only the SDK's own credential is shown") + + m, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + assert.Equal(t, stepVerify, m.step) + assert.NotNil(t, cmd) +} + +func TestWizard_AgentDone_InstallFailed_GoesToDone(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + m.installResult = &setup.InstallResult{Failed: true, Command: "npm install x"} + + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventDone}}) + + assert.Equal(t, stepDone, m.step) +} + +func TestWizard_AgentError_NoEdits_AutoFallsBack(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + + m, cmd := update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventError, Text: "no API key"}}) + + assert.Equal(t, stepInit, m.step) + assert.NotNil(t, cmd) + assert.Contains(t, m.agentNote, "no API key") + assert.Equal(t, 1, run.closed) +} + +func TestWizard_AgentError_AfterEdits_DoesNotFallBack(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventFileEdited, File: "index.js"}}) + + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventError, Text: "boom"}}) + + require.Error(t, m.err) + assert.Equal(t, stepAgent, m.step) +} + +func TestWizard_AgentEvent_FromOldRunIsIgnored(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + + m, cmd := update(t, m, agentEventMsg{run: newFakeRun(), ev: agent.Event{Kind: agent.EventDone}}) + + assert.Equal(t, stepAgent, m.step) + assert.Nil(t, cmd) +} + +func TestWizard_Quit_ClosesAgent(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + + _, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) + + assert.True(t, quitsOn(cmd)) + assert.Equal(t, 1, run.closed) +} + +func TestRunTracker_ClosesRunsAndLateStarts(t *testing.T) { + tracker := &runTracker{} + early := newFakeRun() + tracker.add(early) + + tracker.closeAll() + late := newFakeRun() + tracker.add(late) + + assert.Equal(t, 1, early.closed) + assert.Equal(t, 1, late.closed, "a run that starts after setup ends is closed at once") +} + +func TestWizard_Plan_AgentMode_SaysAgent(t *testing.T) { + m := agentWizard(agentAuto, &fakeRunner{}) + m.step = stepPlan + m.selectedProject, m.selectedEnv = "proj", "prod" + + view := m.View() + + assert.Contains(t, view, "Use the setup agent to add initialization code") + assert.Contains(t, view, "uses the template if the agent cannot run") +} + +func TestStreamAgent_AnswersPermissionAndReturnsFiles(t *testing.T) { + run := newFakeRun( + agent.Event{Kind: agent.EventText, Text: "Working"}, + agent.Event{Kind: agent.EventPermission, Permission: agent.PermissionRequest{ID: "per1", Permission: "bash", Patterns: []string{"npm test"}}}, + agent.Event{Kind: agent.EventFileEdited, File: "b.js"}, + agent.Event{Kind: agent.EventFileEdited, File: "a.js"}, + agent.Event{Kind: agent.EventDone}, + ) + var out bytes.Buffer + + files, err := streamAgent(context.Background(), run, &out, bufio.NewReader(strings.NewReader("y\n"))) + + require.NoError(t, err) + assert.Equal(t, []string{"a.js", "b.js"}, files) + assert.Equal(t, []fakeReply{{id: "per1", allow: true}}, run.replies) + assert.Contains(t, out.String(), "The agent asks to use bash: npm test") +} + +func TestStreamAgent_EmptyAnswerRejects(t *testing.T) { + run := newFakeRun( + agent.Event{Kind: agent.EventPermission, Permission: agent.PermissionRequest{ID: "per1"}}, + agent.Event{Kind: agent.EventDone}, + ) + + _, err := streamAgent(context.Background(), run, &bytes.Buffer{}, bufio.NewReader(strings.NewReader("\n"))) + + require.NoError(t, err) + assert.Equal(t, []fakeReply{{id: "per1", allow: false}}, run.replies) +} + +func TestStreamAgent_Error(t *testing.T) { + run := newFakeRun(agent.Event{Kind: agent.EventError, Text: "no API key"}) + + _, err := streamAgent(context.Background(), run, &bytes.Buffer{}, bufio.NewReader(strings.NewReader(""))) + + require.Error(t, err) + assert.Contains(t, err.Error(), "no API key") +} + +func TestCredentialExport_ShowsOnlyTheSDKsCredential(t *testing.T) { + assert.Equal(t, "export LAUNCHDARKLY_SDK_KEY=sdk-1", credentialExport("python-server-sdk", "sdk-1", "cid", "mob-1")) + assert.Equal(t, "export LAUNCHDARKLY_CLIENT_SIDE_ID=cid", credentialExport("react-client-sdk", "sdk-1", "cid", "mob-1")) + assert.Equal(t, "export LAUNCHDARKLY_MOBILE_KEY=mob-1", credentialExport("android", "sdk-1", "cid", "mob-1")) + assert.Equal(t, "", credentialExport("node-server", "", "cid", "mob-1"), "no value, no line") +} + +// OpenCode can ask for several tool calls at once. Each one must get its own +// answer, or OpenCode waits forever. +func TestWizard_AgentPermissions_AreQueued(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + for _, id := range []string{"per1", "per2", "per3"} { + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventPermission, Permission: agent.PermissionRequest{ID: id, Permission: "bash", Patterns: []string{id}}}}) + } + view := m.View() + assert.Contains(t, view, "bash: per1", "the oldest request is shown first") + assert.Contains(t, view, "2 more waiting") + + for _, key := range []string{"y", "n", "y"} { + var cmd tea.Cmd + m, cmd = update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}) + require.NotNil(t, cmd) + cmd() + } + + assert.Equal(t, []fakeReply{{id: "per1", allow: true}, {id: "per2", allow: false}, {id: "per3", allow: true}}, run.replies) + assert.Empty(t, m.agentPermissions) + assert.Contains(t, m.View(), "The agent is working") +} + +func secretQuestion() agent.QuestionRequest { + return agent.QuestionRequest{ID: "que1", Questions: []agent.Question{{ + Header: "Secret storage", + Text: "Where should LAUNCHDARKLY_SDK_KEY live?", + Options: []agent.QuestionOption{ + {Label: ".env (Recommended)", Description: "Added to .env."}, + {Label: "Shell profile", Description: "You export it yourself."}, + }, + }}} +} + +func TestWizard_AgentQuestion_NumberAnswers(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventQuestion, Question: secretQuestion()}}) + view := m.View() + assert.Contains(t, view, "Where should LAUNCHDARKLY_SDK_KEY live?") + assert.Contains(t, view, "1. .env (Recommended)") + assert.Contains(t, view, "1-2 choose · s skip") + + _, none := update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("3")}) + assert.Nil(t, none, "a number without an option does nothing") + + m, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")}) + require.NotNil(t, cmd) + cmd() + + assert.Equal(t, []fakeAnswer{{id: "que1", answers: [][]string{{"Shell profile"}}}}, run.answers) + assert.Empty(t, m.agentQuestions) +} + +func TestWizard_AgentQuestion_SSkips(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventQuestion, Question: secretQuestion()}}) + + _, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("s")}) + require.NotNil(t, cmd) + cmd() + + assert.Equal(t, []fakeAnswer{{id: "que1", answers: nil}}, run.answers) +} + +func TestWizard_AgentQuestion_SeveralQuestionsAnsweredInOrder(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + req := secretQuestion() + req.Questions = append(req.Questions, agent.Question{Header: "Flag", Text: "Log the value?", Options: []agent.QuestionOption{{Label: "Yes"}, {Label: "No"}}}) + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventQuestion, Question: req}}) + + m, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("1")}) + assert.Nil(t, cmd, "the wizard waits for the second answer") + assert.Contains(t, m.View(), "question 2 of 2") + _, cmd = update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")}) + require.NotNil(t, cmd) + cmd() + + assert.Equal(t, []fakeAnswer{{id: "que1", answers: [][]string{{".env (Recommended)"}, {"No"}}}}, run.answers) +} + +func TestStreamAgent_AnswersQuestion(t *testing.T) { + run := newFakeRun( + agent.Event{Kind: agent.EventQuestion, Question: secretQuestion()}, + agent.Event{Kind: agent.EventDone}, + ) + var out bytes.Buffer + + _, err := streamAgent(context.Background(), run, &out, bufio.NewReader(strings.NewReader("9\n1\n"))) + + require.NoError(t, err) + assert.Equal(t, []fakeAnswer{{id: "que1", answers: [][]string{{".env (Recommended)"}}}}, run.answers, "an invalid number asks again") + assert.Contains(t, out.String(), "The agent asks: Where should LAUNCHDARKLY_SDK_KEY live?") +} diff --git a/cmd/setup/commands.go b/cmd/setup/commands.go index 85bd479e7..54e298266 100644 --- a/cmd/setup/commands.go +++ b/cmd/setup/commands.go @@ -1,6 +1,7 @@ package setup import ( + "context" "fmt" "io" "os" @@ -11,6 +12,7 @@ import ( "golang.org/x/term" "github.com/launchdarkly/ldcli/internal/setup" + "github.com/launchdarkly/ldcli/internal/setup/agent" ) func (m wizardModel) fetchProjects() tea.Cmd { @@ -199,3 +201,53 @@ func isRemoteSession() bool { } return false } + +// startAgent checks that the agent can run and starts it. +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) + return func() tea.Msg { + dir, err := os.Getwd() + if err != nil { + return agentFailedMsg{err: err} + } + task.Dir = dir + ctx := context.Background() + if err := runner.Check(ctx); err != nil { + return agentFailedMsg{err: err} + } + run, err := runner.Start(ctx, task) + if err != nil { + return agentFailedMsg{err: err} + } + if tracker != nil { + tracker.add(run) + } + return agentStartedMsg{run: run} + } +} + +// waitForAgentEvent returns the run's next event. Update calls it again after +// each event, so the wizard reads the events one at a time. +func waitForAgentEvent(run agent.Run) tea.Cmd { + return func() tea.Msg { + ev, ok := <-run.Events() + if !ok { + return agentEventMsg{run: run, closed: true} + } + return agentEventMsg{run: run, ev: ev} + } +} + +func answerAgent(run agent.Run, req agent.QuestionRequest, answers [][]string) tea.Cmd { + return func() tea.Msg { + return agentRepliedMsg{err: run.Answer(context.Background(), req, answers)} + } +} + +func replyAgent(run agent.Run, req agent.PermissionRequest, allow bool) tea.Cmd { + return func() tea.Msg { + return agentRepliedMsg{err: run.Reply(context.Background(), req, allow)} + } +} diff --git a/cmd/setup/model.go b/cmd/setup/model.go index fe09b7c93..1eeb33206 100644 --- a/cmd/setup/model.go +++ b/cmd/setup/model.go @@ -2,6 +2,10 @@ package setup import ( "io" + "os" + "os/signal" + "sync" + "syscall" "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/list" @@ -14,6 +18,7 @@ import ( "github.com/launchdarkly/ldcli/internal/analytics" "github.com/launchdarkly/ldcli/internal/errors" "github.com/launchdarkly/ldcli/internal/setup" + "github.com/launchdarkly/ldcli/internal/setup/agent" ) // copyState records how the visible snippet was copied, so the view can confirm a @@ -39,6 +44,9 @@ const ( stepPlan stepInstall stepCreateFlag + // stepAgent runs the setup agent. It replaces stepInit when the agent can + // run, and it falls back to stepInit in auto mode when the agent fails. + stepAgent stepInit stepWaitForApp stepVerify @@ -99,6 +107,31 @@ type wizardModel struct { initResult *setup.InitResult verifyResult *setup.VerifyResult + agentMode agentMode + agentRunner agent.Runner + // agentRuns records every run that starts, so setup can close a run whose + // start finished after the user quit. + agentRuns *runTracker + agentRun agent.Run + // agentLog holds one line per finished tool call, newest last. + agentLog []string + // agentFiles are the files that the agent changed. + agentFiles []string + // agentPermissions are the requests that wait for the user's answer, oldest + // first. OpenCode can ask for several tool calls at once, and each one + // waits until it gets its own answer. + agentPermissions []agent.PermissionRequest + // agentQuestions are the agent's question requests that wait for answers, + // oldest first. agentAnswers holds the answers to the first request so far, + // one for each of its questions that the user answered. + agentQuestions []agent.QuestionRequest + agentAnswers [][]string + agentUsage *agent.Usage + agentDone bool + // agentNote tells the user why setup used the templates after the agent + // could not run. + agentNote string + // nativeCopy puts content on the operating system's clipboard, and clipboard // receives the OSC 52 sequence used when that is not available. Both are fields // so tests can drive either path without a real clipboard or terminal. @@ -188,13 +221,28 @@ type flagCreatedMsg struct{ key string } type initDoneMsg struct{ result *setup.InitResult } type copiedMsg struct{ viaTerminal bool } type verifyDoneMsg struct{ result *setup.VerifyResult } +type agentStartedMsg struct{ run agent.Run } +type agentFailedMsg struct{ err error } +type agentEventMsg struct { + run agent.Run + ev agent.Event + // closed reports that the run's event channel closed without a last event. + closed bool +} +type agentRepliedMsg struct{ err error } type wizardErrMsg struct{ err error } func runSetupWizard( analyticsTrackerFn analytics.TrackerFn, svc setup.Service, + runner agent.Runner, ) func(*cobra.Command, []string) error { return func(cmd *cobra.Command, args []string) error { + mode, err := parseAgentMode(getFlag(cmd, agentFlag)) + if err != nil { + return err + } + // Pre-flight: the wizard's first action is an authenticated API call, so // bail early with clear guidance rather than dumping a raw 401 mid-TUI. if viper.GetString(cliflags.AccessTokenFlag) == "" { @@ -216,14 +264,60 @@ func runSetupWizard( clipboard: terminalWriter(), nativeCopy: clipboard.WriteAll, remoteSession: isRemoteSession(), + agentMode: mode, + agentRunner: runner, + agentRuns: &runTracker{}, } p := tea.NewProgram(m, tea.WithAltScreen()) - _, err := p.Run() + // Bubble Tea quits on SIGINT and SIGTERM. A closed terminal sends + // SIGHUP, which would end ldcli at once and leave OpenCode running, so + // it quits the wizard the same way. + hangup := make(chan os.Signal, 1) + signal.Notify(hangup, syscall.SIGHUP) + defer signal.Stop(hangup) + go func() { + if _, ok := <-hangup; ok { + p.Quit() + } + }() + _, err = p.Run() + // The wizard closes a run when the user quits, but a run can finish + // starting after that. OpenCode must not outlive setup. + m.agentRuns.closeAll() return err } } +// runTracker records agent runs. It is a pointer in the model, so every copy +// of the model shares one tracker. +type runTracker struct { + mu sync.Mutex + runs []agent.Run + closed bool +} + +// add records a run. If the tracker is already closed, it closes the run. +func (t *runTracker) add(run agent.Run) { + t.mu.Lock() + defer t.mu.Unlock() + if t.closed { + _ = run.Close() + return + } + t.runs = append(t.runs, run) +} + +func (t *runTracker) closeAll() { + t.mu.Lock() + defer t.mu.Unlock() + t.closed = true + for _, r := range t.runs { + _ = r.Close() + } + t.runs = nil +} + func (m wizardModel) Init() tea.Cmd { // Detect the project once, up front, so navigating the flow never re-runs it. return tea.Batch(m.spinner.Tick, m.fetchProjects(), m.runDetect()) diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index b0f32f95c..8fe4ae053 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -10,6 +10,7 @@ import ( "github.com/launchdarkly/ldcli/cmd/cliflags" "github.com/launchdarkly/ldcli/internal/analytics" "github.com/launchdarkly/ldcli/internal/setup" + "github.com/launchdarkly/ldcli/internal/setup/agent" ) // NewSetupCmd creates the top-level setup command and registers its hidden subcommands. @@ -25,6 +26,7 @@ func NewSetupCmd( Installer: installer, Initializer: setup.Initializer{}, } + runner := agent.NewOpenCode() cmd := &cobra.Command{ Use: "setup", Short: "Set up LaunchDarkly in your project", @@ -32,7 +34,10 @@ func NewSetupCmd( Detects your project's language and framework, installs the correct SDK, initializes it with your environment's SDK key, creates a feature flag, -and verifies the connection.`, +and verifies the connection. + +With --agent=auto (the default), setup uses the setup agent to add the +initialization code if OpenCode is installed, and the built-in templates if not.`, PreRun: func(cmd *cobra.Command, args []string) { // Dim the notice and set it off with a blank line so it reads as a // transitional notice, visually distinct from command output. @@ -46,12 +51,14 @@ and verifies the connection.`, viper.GetBool(cliflags.AnalyticsOptOut), ).SendCommandRunEvent(cmdAnalytics.CmdRunEventProperties(cmd, "setup", nil)) }, - RunE: runSetupWizard(analyticsTrackerFn, svc), + RunE: runSetupWizard(analyticsTrackerFn, svc, runner), } + cmd.Flags().String(agentFlag, "auto", "Use the setup agent to add initialization code: auto, on, or off") cmd.AddCommand(newDetectCmd(svc)) cmd.AddCommand(newInstallCmd(svc)) cmd.AddCommand(newInitCmd(svc)) + cmd.AddCommand(newAgentCmd(svc, runner)) return cmd } diff --git a/cmd/setup/update.go b/cmd/setup/update.go index a484a3c99..0bbc6719e 100644 --- a/cmd/setup/update.go +++ b/cmd/setup/update.go @@ -1,7 +1,9 @@ package setup import ( + "errors" "os" + "slices" "strings" "github.com/charmbracelet/bubbles/key" @@ -10,6 +12,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/launchdarkly/ldcli/internal/setup" + "github.com/launchdarkly/ldcli/internal/setup/agent" ) func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -37,6 +40,7 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c": m.quitting = true + m.closeAgent() return m, tea.Quit // esc is not used due to arrow keys becoming the escape sequence. case "q": @@ -44,7 +48,18 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break // let the list receive 'q' as filter input } m.quitting = true + m.closeAgent() return m, tea.Quit + case "y", "n": + if m.step == stepAgent && len(m.agentPermissions) > 0 && m.agentRun != nil { + req := m.agentPermissions[0] + m.agentPermissions = m.agentPermissions[1:] + return m, replyAgent(m.agentRun, req, msg.String() == "y") + } + case "1", "2", "3", "4", "5", "6", "7", "8", "9", "s": + if m.step == stepAgent && len(m.agentPermissions) == 0 && len(m.agentQuestions) > 0 && m.agentRun != nil { + return m.answerAgentQuestion(msg.String()) + } case "left", "h": if m.isFiltering() { break // let the list receive the key as filter input @@ -143,9 +158,29 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case flagCreatedMsg: m.flagKey = msg.key + if m.agentMode != agentOff && m.agentRunner != nil { + m.step = stepAgent + return m, m.startAgent() + } m.step = stepInit return m, m.runInit() + case agentStartedMsg: + m.agentRun = msg.run + return m, waitForAgentEvent(msg.run) + + case agentFailedMsg: + return m.agentFailed(msg.err) + + case agentRepliedMsg: + if msg.err != nil { + return m.agentFailed(msg.err) + } + return m, nil + + case agentEventMsg: + return m.handleAgentEvent(msg) + case initDoneMsg: m.initResult = msg.result // Skip the live verify if init didn't inject runnable code, or if the SDK @@ -217,6 +252,102 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } +// handleAgentEvent applies one event of the agent run and asks for the next. +func (m wizardModel) handleAgentEvent(msg agentEventMsg) (tea.Model, tea.Cmd) { + // An event of a run that already ended, for example after a fallback, has + // nothing left to change. + if m.agentRun == nil || msg.run != m.agentRun { + return m, nil + } + if msg.closed { + return m.agentFailed(errors.New("the setup agent stopped without a result")) + } + ev := msg.ev + switch ev.Kind { + case agent.EventTool: + if ev.Tool.Status != "running" { + m.agentLog = append(m.agentLog, describeTool(ev.Tool)) + } + case agent.EventFileEdited: + if !slices.Contains(m.agentFiles, ev.File) { + m.agentFiles = append(m.agentFiles, ev.File) + } + case agent.EventUsage: + u := ev.Usage + m.agentUsage = &u + case agent.EventPermission: + req := ev.Permission + m.agentPermissions = append(m.agentPermissions, req) + case agent.EventQuestion: + m.agentQuestions = append(m.agentQuestions, ev.Question) + case agent.EventDone: + m.agentDone = true + m.closeAgent() + // As after init: without an installed SDK, the app cannot connect. + if m.installResult != nil && m.installResult.Failed { + m.step = stepDone + return m, nil + } + m.step = stepWaitForApp + return m, nil + case agent.EventError: + return m.agentFailed(errors.New(ev.Text)) + } + return m, waitForAgentEvent(m.agentRun) +} + +// agentFailed ends the agent run. In auto mode, setup continues with the init +// templates, but only if the agent changed no files: a template on top of a +// partial edit can initialize the SDK twice. +func (m wizardModel) agentFailed(err error) (tea.Model, tea.Cmd) { + m.closeAgent() + m.agentRun = nil + m.agentPermissions = nil + m.agentQuestions = nil + m.agentAnswers = nil + if m.agentMode == agentOn || len(m.agentFiles) > 0 { + m.err = errors.New("The setup agent failed: " + err.Error()) + return m, nil + } + m.agentNote = "The setup agent could not run, so setup used the built-in template. " + err.Error() + m.agentLog = nil + m.step = stepInit + return m, m.runInit() +} + +// answerAgentQuestion records the user's choice for the current question. A +// number picks an option, and s skips the whole request, so the agent goes on +// without answers. When every question of the request has an answer, the +// wizard sends them. +func (m wizardModel) answerAgentQuestion(key string) (tea.Model, tea.Cmd) { + req := m.agentQuestions[0] + if key == "s" { + m.agentQuestions = m.agentQuestions[1:] + m.agentAnswers = nil + return m, answerAgent(m.agentRun, req, nil) + } + question := req.Questions[len(m.agentAnswers)] + choice := int(key[0] - '1') + if choice >= len(question.Options) { + return m, nil + } + m.agentAnswers = append(m.agentAnswers, []string{question.Options[choice].Label}) + if len(m.agentAnswers) < len(req.Questions) { + return m, nil + } + answers := m.agentAnswers + m.agentQuestions = m.agentQuestions[1:] + m.agentAnswers = nil + return m, answerAgent(m.agentRun, req, answers) +} + +// closeAgent stops OpenCode if a run is active. +func (m wizardModel) closeAgent() { + if m.agentRun != nil { + _ = m.agentRun.Close() + } +} + // keepEscFromQuitting rebinds a list's quit key to q alone. bubbles binds it to // both q and esc, and returns tea.Quit when either matches, so removing our own esc // binding was not enough — the key fell through to whichever list was on screen and diff --git a/cmd/setup/view.go b/cmd/setup/view.go index 7f1abcd8c..691c58910 100644 --- a/cmd/setup/view.go +++ b/cmd/setup/view.go @@ -37,6 +37,16 @@ func (m wizardModel) View() string { return titleStyle.Render("Error") + "\n\n" + m.err.Error() + "\n\nPress ctrl+c to quit." } + view := m.stepView() + // Say why setup used a template on every screen that follows, so the user + // knows that the agent did not run. + if m.agentNote != "" && (m.step == stepWaitForApp || m.step == stepDone) { + view = mutedStyle.Render(m.wrap("Note: "+m.agentNote)) + "\n\n" + view + } + return view +} + +func (m wizardModel) stepView() string { switch m.step { case stepSelectProject: if !m.projectsLoaded { @@ -81,7 +91,13 @@ func (m wizardModel) View() string { case stepInit: return m.spinner.View() + " Injecting initialization code..." + case stepAgent: + return m.agentView() + case stepWaitForApp: + if m.agentDone { + return m.agentWaitView() + } // The newline stays outside the wrap: wrapping pads each line to the full // width, so a trailing one inside would put a row of spaces in front of the // path and push it past the edge of the terminal. @@ -364,7 +380,14 @@ func (m wizardModel) planView() string { add(fmt.Sprintf("Add the %s SDK %s", name, mutedStyle.Render("(manual install)"))) } add(fmt.Sprintf("Create a feature flag in %s / %s", m.selectedProject, m.selectedEnv)) - if setup.InjectsInPlace(m.detectResult.SDKID) { + if m.agentMode != agentOff && m.agentRunner != nil { + note := "(asks you only when it needs information)" + if m.agentMode == agentAuto { + note = "(asks you only when it needs information · uses the template if the agent cannot run)" + } + add("Use the setup agent to add initialization code " + mutedStyle.Render(note)) + add("Verify the SDK connects to LaunchDarkly") + } else if setup.InjectsInPlace(m.detectResult.SDKID) { // Say when the entry file does not exist yet: a file we create is not loaded // by the project, so the user needs the chance to back out and point us at // the real entry point. @@ -389,3 +412,83 @@ func (m wizardModel) planView() string { // orchestration service: it calls a step method and maps the result or error // onto a wizard message. All API/filesystem work and business rules live in // internal/setup.Service. + +// agentLogLines is how many finished tool calls the agent screen shows. +const agentLogLines = 8 + +// agentView shows the agent's progress, and the question when a tool call +// waits for the user's answer. +func (m wizardModel) agentView() string { + body := titleStyle.Render("Setting up the SDK with the setup agent") + "\n\n" + log := m.agentLog + if len(log) > agentLogLines { + log = log[len(log)-agentLogLines:] + } + for _, line := range log { + body += m.wrap(line) + "\n" + } + if len(log) > 0 { + body += "\n" + } + if len(m.agentPermissions) > 0 { + hint := "y allow once · n reject" + if waiting := len(m.agentPermissions) - 1; waiting > 0 { + hint += fmt.Sprintf(" · %d more waiting", waiting) + } + question := box(true, m.sdkBoxWidth()).Render( + wrapText(describePermission(m.agentPermissions[0]), m.sdkBoxWidth()-4) + "\n" + + mutedStyle.Render(hint)) + return body + question + "\n" + } + if len(m.agentQuestions) > 0 { + req := m.agentQuestions[0] + question := req.Questions[len(m.agentAnswers)] + width := m.sdkBoxWidth() - 4 + text := headerStyle.Render(question.Header) + "\n" + wrapText(question.Text, width) + "\n\n" + for i, o := range question.Options { + line := fmt.Sprintf("%d. %s", i+1, o.Label) + if o.Description != "" { + line += " — " + o.Description + } + text += wrapText(line, width) + "\n" + } + hint := fmt.Sprintf("1-%d choose · s skip", len(question.Options)) + if total := len(req.Questions); total > 1 { + hint = fmt.Sprintf("question %d of %d · ", len(m.agentAnswers)+1, total) + hint + } + return body + box(true, m.sdkBoxWidth()).Render(text+"\n"+mutedStyle.Render(hint)) + "\n" + } + status := " The agent is working..." + if m.agentRun == nil { + status = " Starting the agent..." + } + return body + m.spinner.View() + status + "\n\n" + mutedStyle.Render("q quit") +} + +// agentWaitView replaces the wait-for-app screen after an agent run. The +// agent's code reads the credential from an environment variable, so the +// user must set it before the application starts. +func (m wizardModel) agentWaitView() string { + body := titleStyle.Render("Start your application") + "\n\n" + if len(m.agentFiles) == 0 { + body += m.wrap("The setup agent finished but did not report changed files.") + "\n\n" + } else { + body += m.wrap("The setup agent changed these files:") + "\n" + for _, f := range m.agentFiles { + body += m.wrap(" "+f) + "\n" + } + body += "\n" + } + sdkID := "" + if m.detectResult != nil { + sdkID = m.detectResult.SDKID + } + if export := credentialExport(sdkID, m.sdkKey, m.clientSideID, m.mobileKey); export != "" { + body += m.wrap("Set the credential that your SDK uses before you start the application:") + "\n\n" + + code(export) + "\n\n" + } + if m.agentUsage != nil && m.agentUsage.ModelID != "" { + body += mutedStyle.Render(m.wrap(fmt.Sprintf("Model: %s/%s", m.agentUsage.ProviderID, m.agentUsage.ModelID))) + "\n\n" + } + return body + m.wrap("Start your application now, then press Enter to verify the connection.") + "\n" +} From e477f4c42e4482197235fbe152b984f6f6af9a77 Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Tue, 20 Oct 2026 11:57:58 -0700 Subject: [PATCH 2/2] feat(setup): always run the setup agent, and add --project and --environment Setup now always uses the setup agent. The --agent flag and its auto, on, and off modes are gone, and so is the fallback to the init templates. If the agent cannot run or fails, setup stops with the error. ldcli setup now takes --project and --environment. The wizard selects them and skips their screens, so one command can start setup. The subcommand that runs only the agent step is now ldcli setup agentcontrol. Co-Authored-By: Claude Opus 5.5 --- cmd/setup/agent.go | 33 +------ cmd/setup/agent_test.go | 204 ++++++++++++++++++++++------------------ cmd/setup/commands.go | 16 ---- cmd/setup/model.go | 25 ++--- cmd/setup/setup.go | 7 +- cmd/setup/update.go | 54 +++++------ cmd/setup/view.go | 21 +---- 7 files changed, 156 insertions(+), 204 deletions(-) diff --git a/cmd/setup/agent.go b/cmd/setup/agent.go index 80b7cee1a..73587ecc4 100644 --- a/cmd/setup/agent.go +++ b/cmd/setup/agent.go @@ -20,33 +20,6 @@ import ( "github.com/launchdarkly/ldcli/internal/setup/agent" ) -// agentMode is the value of the --agent flag. -type agentMode string - -const ( - // agentOff always uses the init templates. It is the zero value, so a wizard - // built without a mode never starts OpenCode. - agentOff agentMode = "" - // agentAuto uses the agent if it can run, and the init templates if not. - agentAuto agentMode = "auto" - // agentOn uses the agent, and stops with an error if it cannot run. - agentOn agentMode = "on" -) - -const agentFlag = "agent" - -func parseAgentMode(s string) (agentMode, error) { - switch strings.ToLower(strings.TrimSpace(s)) { - case "auto": - return agentAuto, nil - case "on": - return agentOn, nil - case "off": - return agentOff, nil - } - return agentOff, errors.NewError(fmt.Sprintf("--agent must be auto, on, or off, not %q", s)) -} - // 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} @@ -105,9 +78,9 @@ func describePermission(req agent.PermissionRequest) string { func newAgentCmd(svc setup.Service, runner agent.Runner) *cobra.Command { cmd := &cobra.Command{ - Use: "agent", - Short: "Use the setup agent to add SDK initialization code to a project", - Long: `Runs only the agent step of setup. + Use: "agentcontrol", + Short: "Use the setup agent to set up LaunchDarkly AgentControl in a project", + Long: `Runs only the agent step of setup, without the wizard. The setup agent uses OpenCode to edit the project. OpenCode must be installed. The agent runs commands in the project without asking, and it asks you only diff --git a/cmd/setup/agent_test.go b/cmd/setup/agent_test.go index 900ac4af7..08749ad67 100644 --- a/cmd/setup/agent_test.go +++ b/cmd/setup/agent_test.go @@ -79,12 +79,11 @@ func (f *fakeRunner) Start(_ context.Context, task agent.Task) (agent.Run, error return f.run, nil } -func agentWizard(mode agentMode, runner agent.Runner) wizardModel { +func agentWizard(runner agent.Runner) wizardModel { return wizardModel{ step: stepCreateFlag, width: 80, height: 30, - agentMode: mode, agentRunner: runner, agentRuns: &runTracker{}, sdkKey: "sdk-123", @@ -99,10 +98,10 @@ func update(t *testing.T, m wizardModel, msg tea.Msg) (wizardModel, tea.Cmd) { } // startedAgent drives the wizard to a running agent step. -func startedAgent(t *testing.T, mode agentMode, run *fakeRun) wizardModel { +func startedAgent(t *testing.T, run *fakeRun) wizardModel { t.Helper() runner := &fakeRunner{run: run} - m, cmd := update(t, agentWizard(mode, runner), flagCreatedMsg{key: "my-new-flag"}) + m, cmd := update(t, agentWizard(runner), flagCreatedMsg{key: "my-new-flag"}) require.Equal(t, stepAgent, m.step) require.NotNil(t, cmd) started, ok := cmd().(agentStartedMsg) @@ -112,25 +111,9 @@ func startedAgent(t *testing.T, mode agentMode, run *fakeRun) wizardModel { return m } -func TestParseAgentMode(t *testing.T) { - for in, want := range map[string]agentMode{"auto": agentAuto, "ON": agentOn, " off ": agentOff} { - got, err := parseAgentMode(in) - require.NoError(t, err) - assert.Equal(t, want, got) - } - _, err := parseAgentMode("sometimes") - assert.Error(t, err) -} - -func TestWizard_AgentOff_UsesInit(t *testing.T) { - m, _ := update(t, agentWizard(agentOff, &fakeRunner{}), flagCreatedMsg{key: "f"}) - - assert.Equal(t, stepInit, m.step) -} - -func TestWizard_AgentAuto_StartsAgentWithTheTask(t *testing.T) { +func TestWizard_StartsAgentWithTheTask(t *testing.T) { runner := &fakeRunner{run: newFakeRun()} - m, cmd := update(t, agentWizard(agentAuto, runner), flagCreatedMsg{key: "my-new-flag"}) + m, cmd := update(t, agentWizard(runner), flagCreatedMsg{key: "my-new-flag"}) require.Equal(t, stepAgent, m.step) msg := cmd() @@ -144,18 +127,8 @@ func TestWizard_AgentAuto_StartsAgentWithTheTask(t *testing.T) { assert.Contains(t, m.View(), "Starting the agent") } -func TestWizard_AgentUnavailable_AutoFallsBackToInit(t *testing.T) { - m, cmd := update(t, agentWizard(agentAuto, &fakeRunner{checkErr: agent.ErrNotInstalled}), flagCreatedMsg{key: "f"}) - - m, _ = update(t, m, cmd()) - - assert.Equal(t, stepInit, m.step) - assert.Contains(t, m.agentNote, "OpenCode is not installed") - assert.Nil(t, m.err) -} - -func TestWizard_AgentUnavailable_OnStopsWithError(t *testing.T) { - m, cmd := update(t, agentWizard(agentOn, &fakeRunner{checkErr: agent.ErrNotInstalled}), flagCreatedMsg{key: "f"}) +func TestWizard_AgentUnavailable_StopsWithError(t *testing.T) { + m, cmd := update(t, agentWizard(&fakeRunner{checkErr: agent.ErrNotInstalled}), flagCreatedMsg{key: "f"}) m, _ = update(t, m, cmd()) @@ -165,7 +138,7 @@ func TestWizard_AgentUnavailable_OnStopsWithError(t *testing.T) { func TestWizard_AgentEvents_LogToolsAndFiles(t *testing.T) { run := newFakeRun() - m := startedAgent(t, agentAuto, run) + m := startedAgent(t, run) m, cmd := update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventTool, Tool: agent.ToolCall{Name: "edit", Status: "completed", Title: "index.js"}}}) require.NotNil(t, cmd, "the wizard asks for the next event") @@ -178,7 +151,7 @@ func TestWizard_AgentEvents_LogToolsAndFiles(t *testing.T) { func TestWizard_AgentPermission_YAllows(t *testing.T) { run := newFakeRun() - m := startedAgent(t, agentAuto, run) + m := startedAgent(t, run) req := agent.PermissionRequest{ID: "per1", Permission: "bash", Patterns: []string{"npm test"}} m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventPermission, Permission: req}}) @@ -194,7 +167,7 @@ func TestWizard_AgentPermission_YAllows(t *testing.T) { func TestWizard_AgentPermission_NRejects(t *testing.T) { run := newFakeRun() - m := startedAgent(t, agentAuto, run) + m := startedAgent(t, run) m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventPermission, Permission: agent.PermissionRequest{ID: "per1"}}}) _, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}) @@ -205,7 +178,7 @@ func TestWizard_AgentPermission_NRejects(t *testing.T) { func TestWizard_AgentDone_GoesToWaitForAppWithExports(t *testing.T) { run := newFakeRun() - m := startedAgent(t, agentAuto, run) + m := startedAgent(t, run) m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventFileEdited, File: "index.js"}}) m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventDone}}) @@ -224,7 +197,7 @@ func TestWizard_AgentDone_GoesToWaitForAppWithExports(t *testing.T) { func TestWizard_AgentDone_InstallFailed_GoesToDone(t *testing.T) { run := newFakeRun() - m := startedAgent(t, agentAuto, run) + m := startedAgent(t, run) m.installResult = &setup.InstallResult{Failed: true, Command: "npm install x"} m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventDone}}) @@ -232,21 +205,22 @@ func TestWizard_AgentDone_InstallFailed_GoesToDone(t *testing.T) { assert.Equal(t, stepDone, m.step) } -func TestWizard_AgentError_NoEdits_AutoFallsBack(t *testing.T) { +func TestWizard_AgentError_StopsWithError(t *testing.T) { run := newFakeRun() - m := startedAgent(t, agentAuto, run) + m := startedAgent(t, run) m, cmd := update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventError, Text: "no API key"}}) - assert.Equal(t, stepInit, m.step) - assert.NotNil(t, cmd) - assert.Contains(t, m.agentNote, "no API key") + require.Error(t, m.err) + assert.Contains(t, m.err.Error(), "no API key") + assert.Equal(t, stepAgent, m.step) + assert.Nil(t, cmd) assert.Equal(t, 1, run.closed) } func TestWizard_AgentError_AfterEdits_DoesNotFallBack(t *testing.T) { run := newFakeRun() - m := startedAgent(t, agentAuto, run) + m := startedAgent(t, run) m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventFileEdited, File: "index.js"}}) m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventError, Text: "boom"}}) @@ -257,7 +231,7 @@ func TestWizard_AgentError_AfterEdits_DoesNotFallBack(t *testing.T) { func TestWizard_AgentEvent_FromOldRunIsIgnored(t *testing.T) { run := newFakeRun() - m := startedAgent(t, agentAuto, run) + m := startedAgent(t, run) m, cmd := update(t, m, agentEventMsg{run: newFakeRun(), ev: agent.Event{Kind: agent.EventDone}}) @@ -267,7 +241,7 @@ func TestWizard_AgentEvent_FromOldRunIsIgnored(t *testing.T) { func TestWizard_Quit_ClosesAgent(t *testing.T) { run := newFakeRun() - m := startedAgent(t, agentAuto, run) + m := startedAgent(t, run) _, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) @@ -288,54 +262,15 @@ func TestRunTracker_ClosesRunsAndLateStarts(t *testing.T) { assert.Equal(t, 1, late.closed, "a run that starts after setup ends is closed at once") } -func TestWizard_Plan_AgentMode_SaysAgent(t *testing.T) { - m := agentWizard(agentAuto, &fakeRunner{}) +func TestWizard_Plan_SaysAgent(t *testing.T) { + m := agentWizard(&fakeRunner{}) m.step = stepPlan m.selectedProject, m.selectedEnv = "proj", "prod" view := m.View() assert.Contains(t, view, "Use the setup agent to add initialization code") - assert.Contains(t, view, "uses the template if the agent cannot run") -} - -func TestStreamAgent_AnswersPermissionAndReturnsFiles(t *testing.T) { - run := newFakeRun( - agent.Event{Kind: agent.EventText, Text: "Working"}, - agent.Event{Kind: agent.EventPermission, Permission: agent.PermissionRequest{ID: "per1", Permission: "bash", Patterns: []string{"npm test"}}}, - agent.Event{Kind: agent.EventFileEdited, File: "b.js"}, - agent.Event{Kind: agent.EventFileEdited, File: "a.js"}, - agent.Event{Kind: agent.EventDone}, - ) - var out bytes.Buffer - - files, err := streamAgent(context.Background(), run, &out, bufio.NewReader(strings.NewReader("y\n"))) - - require.NoError(t, err) - assert.Equal(t, []string{"a.js", "b.js"}, files) - assert.Equal(t, []fakeReply{{id: "per1", allow: true}}, run.replies) - assert.Contains(t, out.String(), "The agent asks to use bash: npm test") -} - -func TestStreamAgent_EmptyAnswerRejects(t *testing.T) { - run := newFakeRun( - agent.Event{Kind: agent.EventPermission, Permission: agent.PermissionRequest{ID: "per1"}}, - agent.Event{Kind: agent.EventDone}, - ) - - _, err := streamAgent(context.Background(), run, &bytes.Buffer{}, bufio.NewReader(strings.NewReader("\n"))) - - require.NoError(t, err) - assert.Equal(t, []fakeReply{{id: "per1", allow: false}}, run.replies) -} - -func TestStreamAgent_Error(t *testing.T) { - run := newFakeRun(agent.Event{Kind: agent.EventError, Text: "no API key"}) - - _, err := streamAgent(context.Background(), run, &bytes.Buffer{}, bufio.NewReader(strings.NewReader(""))) - - require.Error(t, err) - assert.Contains(t, err.Error(), "no API key") + assert.NotContains(t, view, "template") } func TestCredentialExport_ShowsOnlyTheSDKsCredential(t *testing.T) { @@ -349,7 +284,7 @@ func TestCredentialExport_ShowsOnlyTheSDKsCredential(t *testing.T) { // answer, or OpenCode waits forever. func TestWizard_AgentPermissions_AreQueued(t *testing.T) { run := newFakeRun() - m := startedAgent(t, agentAuto, run) + m := startedAgent(t, run) for _, id := range []string{"per1", "per2", "per3"} { m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventPermission, Permission: agent.PermissionRequest{ID: id, Permission: "bash", Patterns: []string{id}}}}) } @@ -382,7 +317,7 @@ func secretQuestion() agent.QuestionRequest { func TestWizard_AgentQuestion_NumberAnswers(t *testing.T) { run := newFakeRun() - m := startedAgent(t, agentAuto, run) + m := startedAgent(t, run) m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventQuestion, Question: secretQuestion()}}) view := m.View() assert.Contains(t, view, "Where should LAUNCHDARKLY_SDK_KEY live?") @@ -402,7 +337,7 @@ func TestWizard_AgentQuestion_NumberAnswers(t *testing.T) { func TestWizard_AgentQuestion_SSkips(t *testing.T) { run := newFakeRun() - m := startedAgent(t, agentAuto, run) + m := startedAgent(t, run) m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventQuestion, Question: secretQuestion()}}) _, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("s")}) @@ -414,7 +349,7 @@ func TestWizard_AgentQuestion_SSkips(t *testing.T) { func TestWizard_AgentQuestion_SeveralQuestionsAnsweredInOrder(t *testing.T) { run := newFakeRun() - m := startedAgent(t, agentAuto, run) + m := startedAgent(t, run) req := secretQuestion() req.Questions = append(req.Questions, agent.Question{Header: "Flag", Text: "Log the value?", Options: []agent.QuestionOption{{Label: "Yes"}, {Label: "No"}}}) m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventQuestion, Question: req}}) @@ -429,6 +364,89 @@ func TestWizard_AgentQuestion_SeveralQuestionsAnsweredInOrder(t *testing.T) { assert.Equal(t, []fakeAnswer{{id: "que1", answers: [][]string{{".env (Recommended)"}, {"No"}}}}, run.answers) } +func presetWizard(project, env string) wizardModel { + m := agentWizard(&fakeRunner{}) + m.step = stepSelectProject + m.presetProject, m.presetEnv = project, env + return m +} + +func TestWizard_PresetProjectAndEnvironment_SkipTheScreens(t *testing.T) { + m := presetWizard("proj", "prod") + + m, cmd := update(t, m, projectsFetchedMsg{projects: []projectItem{{key: "other"}, {key: "proj"}}}) + require.Equal(t, stepSelectEnvironment, m.step) + assert.Equal(t, "proj", m.selectedProject) + assert.NotNil(t, cmd, "setup fetches the environments of the preset project") + + m, cmd = update(t, m, envsFetchedMsg{project: "proj", environments: []envItem{{key: "test"}, {key: "prod"}}}) + assert.Equal(t, "prod", m.selectedEnv) + assert.NotNil(t, cmd, "setup fetches the keys of the preset environment") + assert.Empty(t, m.presetProject) + assert.Empty(t, m.presetEnv) +} + +func TestWizard_PresetProjectOnly_ShowsTheEnvironmentScreen(t *testing.T) { + m := presetWizard("proj", "") + m, _ = update(t, m, projectsFetchedMsg{projects: []projectItem{{key: "proj"}}}) + + m, _ = update(t, m, envsFetchedMsg{project: "proj", environments: []envItem{{key: "prod"}}}) + + assert.Equal(t, stepSelectEnvironment, m.step) + assert.Empty(t, m.selectedEnv) +} + +func TestWizard_UnknownPreset_StopsWithError(t *testing.T) { + m, _ := update(t, presetWizard("nope", ""), projectsFetchedMsg{projects: []projectItem{{key: "proj"}}}) + require.Error(t, m.err) + assert.Contains(t, m.err.Error(), `"nope"`) + + m = presetWizard("proj", "nope") + m, _ = update(t, m, projectsFetchedMsg{projects: []projectItem{{key: "proj"}}}) + m, _ = update(t, m, envsFetchedMsg{project: "proj", environments: []envItem{{key: "prod"}}}) + require.Error(t, m.err) + assert.Contains(t, m.err.Error(), "--environment") +} + +func TestStreamAgent_AnswersPermissionAndReturnsFiles(t *testing.T) { + run := newFakeRun( + agent.Event{Kind: agent.EventText, Text: "Working"}, + agent.Event{Kind: agent.EventPermission, Permission: agent.PermissionRequest{ID: "per1", Permission: "bash", Patterns: []string{"npm test"}}}, + agent.Event{Kind: agent.EventFileEdited, File: "b.js"}, + agent.Event{Kind: agent.EventFileEdited, File: "a.js"}, + agent.Event{Kind: agent.EventDone}, + ) + var out bytes.Buffer + + files, err := streamAgent(context.Background(), run, &out, bufio.NewReader(strings.NewReader("y\n"))) + + require.NoError(t, err) + assert.Equal(t, []string{"a.js", "b.js"}, files) + assert.Equal(t, []fakeReply{{id: "per1", allow: true}}, run.replies) + assert.Contains(t, out.String(), "The agent asks to use bash: npm test") +} + +func TestStreamAgent_EmptyAnswerRejects(t *testing.T) { + run := newFakeRun( + agent.Event{Kind: agent.EventPermission, Permission: agent.PermissionRequest{ID: "per1"}}, + agent.Event{Kind: agent.EventDone}, + ) + + _, err := streamAgent(context.Background(), run, &bytes.Buffer{}, bufio.NewReader(strings.NewReader("\n"))) + + require.NoError(t, err) + assert.Equal(t, []fakeReply{{id: "per1", allow: false}}, run.replies) +} + +func TestStreamAgent_Error(t *testing.T) { + run := newFakeRun(agent.Event{Kind: agent.EventError, Text: "no API key"}) + + _, err := streamAgent(context.Background(), run, &bytes.Buffer{}, bufio.NewReader(strings.NewReader(""))) + + require.Error(t, err) + assert.Contains(t, err.Error(), "no API key") +} + func TestStreamAgent_AnswersQuestion(t *testing.T) { run := newFakeRun( agent.Event{Kind: agent.EventQuestion, Question: secretQuestion()}, diff --git a/cmd/setup/commands.go b/cmd/setup/commands.go index 54e298266..56591d169 100644 --- a/cmd/setup/commands.go +++ b/cmd/setup/commands.go @@ -109,22 +109,6 @@ func (m wizardModel) runCreateFlag() tea.Cmd { } } -func (m wizardModel) runInit() tea.Cmd { - return func() tea.Msg { - cfg := setup.InitConfig{ - SDKKey: m.sdkKey, - ClientSideID: m.clientSideID, - MobileKey: m.mobileKey, - FlagKey: m.flagKey, - } - result, err := m.svc.Inject(m.detectResult.SDKID, m.detectResult.EntryPoint, cfg) - if err != nil { - return wizardErrMsg{err: err} - } - return initDoneMsg{result: result} - } -} - func (m wizardModel) runVerify() tea.Cmd { return func() tea.Msg { result, err := m.svc.Verify(m.auth, m.selectedProject, m.selectedEnv, m.detectResult.SDKID) diff --git a/cmd/setup/model.go b/cmd/setup/model.go index 1eeb33206..bb58f4fca 100644 --- a/cmd/setup/model.go +++ b/cmd/setup/model.go @@ -4,6 +4,7 @@ import ( "io" "os" "os/signal" + "strings" "sync" "syscall" @@ -44,10 +45,8 @@ const ( stepPlan stepInstall stepCreateFlag - // stepAgent runs the setup agent. It replaces stepInit when the agent can - // run, and it falls back to stepInit in auto mode when the agent fails. + // stepAgent runs the setup agent, which adds the initialization code. stepAgent - stepInit stepWaitForApp stepVerify stepDone @@ -107,7 +106,11 @@ type wizardModel struct { initResult *setup.InitResult verifyResult *setup.VerifyResult - agentMode agentMode + // presetProject and presetEnv come from --project and --environment. The + // wizard selects them when their list loads, and then clears them. + presetProject string + presetEnv string + agentRunner agent.Runner // agentRuns records every run that starts, so setup can close a run whose // start finished after the user quit. @@ -128,9 +131,6 @@ type wizardModel struct { agentAnswers [][]string agentUsage *agent.Usage agentDone bool - // agentNote tells the user why setup used the templates after the agent - // could not run. - agentNote string // nativeCopy puts content on the operating system's clipboard, and clipboard // receives the OSC 52 sequence used when that is not available. Both are fields @@ -218,7 +218,6 @@ type detectDoneMsg struct{ result *setup.DetectResult } type detectFailedMsg struct{} type installDoneMsg struct{ result *setup.InstallResult } type flagCreatedMsg struct{ key string } -type initDoneMsg struct{ result *setup.InitResult } type copiedMsg struct{ viaTerminal bool } type verifyDoneMsg struct{ result *setup.VerifyResult } type agentStartedMsg struct{ run agent.Run } @@ -238,11 +237,6 @@ func runSetupWizard( runner agent.Runner, ) func(*cobra.Command, []string) error { return func(cmd *cobra.Command, args []string) error { - mode, err := parseAgentMode(getFlag(cmd, agentFlag)) - if err != nil { - return err - } - // Pre-flight: the wizard's first action is an authenticated API call, so // bail early with clear guidance rather than dumping a raw 401 mid-TUI. if viper.GetString(cliflags.AccessTokenFlag) == "" { @@ -264,7 +258,8 @@ func runSetupWizard( clipboard: terminalWriter(), nativeCopy: clipboard.WriteAll, remoteSession: isRemoteSession(), - agentMode: mode, + presetProject: strings.TrimSpace(getFlag(cmd, cliflags.ProjectFlag)), + presetEnv: strings.TrimSpace(getFlag(cmd, cliflags.EnvironmentFlag)), agentRunner: runner, agentRuns: &runTracker{}, } @@ -281,7 +276,7 @@ func runSetupWizard( p.Quit() } }() - _, err = p.Run() + _, err := p.Run() // The wizard closes a run when the user quits, but a run can finish // starting after that. OpenCode must not outlive setup. m.agentRuns.closeAll() diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 8fe4ae053..76da61eda 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -36,8 +36,8 @@ Detects your project's language and framework, installs the correct SDK, initializes it with your environment's SDK key, creates a feature flag, and verifies the connection. -With --agent=auto (the default), setup uses the setup agent to add the -initialization code if OpenCode is installed, and the built-in templates if not.`, +The setup agent adds the initialization code. It needs OpenCode. To skip the +project and environment screens, pass --project and --environment.`, PreRun: func(cmd *cobra.Command, args []string) { // Dim the notice and set it off with a blank line so it reads as a // transitional notice, visually distinct from command output. @@ -53,7 +53,8 @@ initialization code if OpenCode is installed, and the built-in templates if not. }, RunE: runSetupWizard(analyticsTrackerFn, svc, runner), } - cmd.Flags().String(agentFlag, "auto", "Use the setup agent to add initialization code: auto, on, or off") + cmd.Flags().String(cliflags.ProjectFlag, "", "The project key. Setup skips the project screen.") + cmd.Flags().String(cliflags.EnvironmentFlag, "", "The environment key. Setup skips the environment screen.") cmd.AddCommand(newDetectCmd(svc)) cmd.AddCommand(newInstallCmd(svc)) diff --git a/cmd/setup/update.go b/cmd/setup/update.go index 0bbc6719e..fcd3d4efa 100644 --- a/cmd/setup/update.go +++ b/cmd/setup/update.go @@ -4,6 +4,7 @@ import ( "errors" "os" "slices" + "strconv" "strings" "github.com/charmbracelet/bubbles/key" @@ -98,6 +99,17 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.projectList.SetShowStatusBar(false) keepEscFromQuitting(&m.projectList) m.projectList.AdditionalShortHelpKeys = listHints(false) + if preset := m.presetProject; preset != "" && m.step == stepSelectProject { + m.presetProject = "" + if !slices.ContainsFunc(msg.projects, func(p projectItem) bool { return p.key == preset }) { + m.err = errors.New("Setup could not find the project " + strconv.Quote(preset) + ". Check the --project value.") + return m, nil + } + m.selectedProject = preset + m.resetEnvSelection() + m.step = stepSelectEnvironment + return m, m.fetchEnvironments() + } return m, nil case envsFetchedMsg: @@ -116,6 +128,15 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.envList.SetShowStatusBar(false) keepEscFromQuitting(&m.envList) m.envList.AdditionalShortHelpKeys = listHints(true) + if preset := m.presetEnv; preset != "" && m.step == stepSelectEnvironment { + m.presetEnv = "" + if !slices.ContainsFunc(msg.environments, func(e envItem) bool { return e.key == preset }) { + m.err = errors.New("Setup could not find the environment " + strconv.Quote(preset) + " in the project " + strconv.Quote(m.selectedProject) + ". Check the --environment value.") + return m, nil + } + m.selectedEnv = preset + return m, m.fetchEnvDetails() + } return m, nil case envDetailsFetchedMsg: @@ -158,12 +179,8 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case flagCreatedMsg: m.flagKey = msg.key - if m.agentMode != agentOff && m.agentRunner != nil { - m.step = stepAgent - return m, m.startAgent() - } - m.step = stepInit - return m, m.runInit() + m.step = stepAgent + return m, m.startAgent() case agentStartedMsg: m.agentRun = msg.run @@ -181,17 +198,6 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case agentEventMsg: return m.handleAgentEvent(msg) - case initDoneMsg: - m.initResult = msg.result - // Skip the live verify if init didn't inject runnable code, or if the SDK - // wasn't actually installed (auto-install failed) — the app can't connect. - if !msg.result.Success || (m.installResult != nil && m.installResult.Failed) { - m.step = stepDone - return m, nil - } - m.step = stepWaitForApp - return m, nil - case verifyDoneMsg: m.verifyResult = msg.result m.step = stepDone @@ -296,23 +302,15 @@ func (m wizardModel) handleAgentEvent(msg agentEventMsg) (tea.Model, tea.Cmd) { return m, waitForAgentEvent(m.agentRun) } -// agentFailed ends the agent run. In auto mode, setup continues with the init -// templates, but only if the agent changed no files: a template on top of a -// partial edit can initialize the SDK twice. +// agentFailed ends the agent run and stops setup with the error. func (m wizardModel) agentFailed(err error) (tea.Model, tea.Cmd) { m.closeAgent() m.agentRun = nil m.agentPermissions = nil m.agentQuestions = nil m.agentAnswers = nil - if m.agentMode == agentOn || len(m.agentFiles) > 0 { - m.err = errors.New("The setup agent failed: " + err.Error()) - return m, nil - } - m.agentNote = "The setup agent could not run, so setup used the built-in template. " + err.Error() - m.agentLog = nil - m.step = stepInit - return m, m.runInit() + m.err = errors.New("The setup agent failed: " + err.Error()) + return m, nil } // answerAgentQuestion records the user's choice for the current question. A diff --git a/cmd/setup/view.go b/cmd/setup/view.go index 691c58910..975b4435d 100644 --- a/cmd/setup/view.go +++ b/cmd/setup/view.go @@ -37,16 +37,6 @@ func (m wizardModel) View() string { return titleStyle.Render("Error") + "\n\n" + m.err.Error() + "\n\nPress ctrl+c to quit." } - view := m.stepView() - // Say why setup used a template on every screen that follows, so the user - // knows that the agent did not run. - if m.agentNote != "" && (m.step == stepWaitForApp || m.step == stepDone) { - view = mutedStyle.Render(m.wrap("Note: "+m.agentNote)) + "\n\n" + view - } - return view -} - -func (m wizardModel) stepView() string { switch m.step { case stepSelectProject: if !m.projectsLoaded { @@ -88,9 +78,6 @@ func (m wizardModel) stepView() string { case stepCreateFlag: return m.spinner.View() + " Creating feature flag..." - case stepInit: - return m.spinner.View() + " Injecting initialization code..." - case stepAgent: return m.agentView() @@ -380,12 +367,8 @@ func (m wizardModel) planView() string { add(fmt.Sprintf("Add the %s SDK %s", name, mutedStyle.Render("(manual install)"))) } add(fmt.Sprintf("Create a feature flag in %s / %s", m.selectedProject, m.selectedEnv)) - if m.agentMode != agentOff && m.agentRunner != nil { - note := "(asks you only when it needs information)" - if m.agentMode == agentAuto { - note = "(asks you only when it needs information · uses the template if the agent cannot run)" - } - add("Use the setup agent to add initialization code " + mutedStyle.Render(note)) + if m.agentRunner != nil { + add("Use the setup agent to add initialization code " + mutedStyle.Render("(asks you only when it needs information)")) add("Verify the SDK connects to LaunchDarkly") } else if setup.InjectsInPlace(m.detectResult.SDKID) { // Say when the entry file does not exist yet: a file we create is not loaded