Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
15 changes: 15 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
version: "2"

linters:
settings:
staticcheck:
# The v1 defaults: the staticcheck (SA) and gosimple (S1) checks. v2 adds
# the stylecheck (ST) and quickfix (QF) checks to staticcheck.
checks:
- "SA*"
- "S1*"
exclusions:
# The v1 default exclusions: unchecked Close, Flush, and print calls are
# not errors here.
presets:
- std-error-handling
7 changes: 5 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
# Cache bust: force rebuild for Go 1.26.0 compatibility
repos:
- repo: https://github.com/golangci/golangci-lint
rev: v1.63.4
rev: v2.11.4
hooks:
- id: golangci-lint
entry: golangci-lint run
language: golang
# Build and run the linter with the Go version in go.mod. Without a pin,
# pre-commit downloads the newest Go, and the linter cannot read the type
# data of a Go release newer than itself.
language_version: 1.26.0
require_serial: true
types: [go]
- repo: https://github.com/pre-commit/pre-commit-hooks
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,5 +132,5 @@ Installed via `make install-hooks`. Checks:

## Linting

- Go: `golangci-lint` (v1.63.4) via pre-commit
- Go: `golangci-lint` (v2.11.4) via pre-commit, configured in `.golangci.yml`
- Frontend: ESLint + Prettier
90 changes: 68 additions & 22 deletions cmd/setup/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import (
"os/signal"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"

"github.com/spf13/cobra"

Expand Down Expand Up @@ -60,20 +62,22 @@ func agentTask(dir string, detection *setup.DetectResult, flagKey string, sdkIns
return t
}

// credentialExports returns the shell commands that set the credentials the
// agent's code reads. The agent never sees the values, so the user must set
// them before the application starts.
func credentialExports(sdkKey, clientSideID, mobileKey string) []string {
var lines []string
add := func(name, value string) {
if value != "" {
lines = append(lines, fmt.Sprintf("export %s=%s", name, value))
}
// 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 ""
}
add(agent.SDKKeyEnvVar, sdkKey)
add(agent.ClientSideIDEnvVar, clientSideID)
add(agent.MobileKeyEnvVar, mobileKey)
return lines
return fmt.Sprintf("export %s=%s", name, value)
}

// describeTool returns one line for a tool call.
Expand All @@ -94,9 +98,9 @@ func describeTool(tc agent.ToolCall) string {
// describePermission returns the question for a permission request.
func describePermission(req agent.PermissionRequest) string {
if len(req.Patterns) == 0 {
return fmt.Sprintf("OpenCode asks to use %s.", req.Permission)
return fmt.Sprintf("The agent asks to use %s.", req.Permission)
}
return fmt.Sprintf("OpenCode asks to use %s: %s", req.Permission, strings.Join(req.Patterns, ", "))
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 {
Expand All @@ -106,7 +110,8 @@ func newAgentCmd(svc setup.Service, runner agent.Runner) *cobra.Command {
Long: `Runs only the agent step of setup.

The setup agent uses OpenCode to edit the project. OpenCode must be installed.
The agent asks before it runs a shell command. It never receives your
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),
Expand Down Expand Up @@ -147,7 +152,8 @@ func runAgent(svc setup.Service, runner agent.Runner) func(*cobra.Command, []str
}
task := agentTask(dir, detection, getFlag(cmd, flagKeyFlag), setup.IsInstalled(dir, sdkID))

ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt)
// 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 {
Expand Down Expand Up @@ -176,12 +182,10 @@ func runAgent(svc setup.Service, runner agent.Runner) func(*cobra.Command, []str
fmt.Fprintf(out, " %s\n", f)
}
}
if exports := credentialExports(getFlag(cmd, sdkKeyFlag), getFlag(cmd, clientIDFlag), getFlag(cmd, mobileFlag)); len(exports) > 0 {
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:")
for _, e := range exports {
fmt.Fprintf(out, " %s\n", e)
}
fmt.Fprintf(out, " %s\n", export)
}
return nil
}
Expand Down Expand Up @@ -221,7 +225,15 @@ func streamAgent(ctx context.Context, run agent.Run, out io.Writer, in *bufio.Re
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 OpenCode: %w", err)
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
Expand All @@ -231,6 +243,40 @@ func streamAgent(ctx context.Context, run agent.Run, out io.Writer, in *bufio.Re
}
}

// 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 {
Expand Down
129 changes: 122 additions & 7 deletions cmd/setup/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,17 @@ type fakeReply struct {
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
}

Expand All @@ -46,6 +52,13 @@ func (r *fakeRun) Reply(_ context.Context, req agent.PermissionRequest, allow bo
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()
Expand Down Expand Up @@ -128,7 +141,7 @@ func TestWizard_AgentAuto_StartsAgentWithTheTask(t *testing.T) {
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 OpenCode")
assert.Contains(t, m.View(), "Starting the agent")
}

func TestWizard_AgentUnavailable_AutoFallsBackToInit(t *testing.T) {
Expand Down Expand Up @@ -169,13 +182,13 @@ func TestWizard_AgentPermission_YAllows(t *testing.T) {
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(), "OpenCode asks to use bash: npm test")
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.Nil(t, m.agentPermission)
assert.Empty(t, m.agentPermissions)
assert.Equal(t, []fakeReply{{id: "per1", allow: true}}, run.replies)
}

Expand All @@ -202,6 +215,7 @@ func TestWizard_AgentDone_GoesToWaitForAppWithExports(t *testing.T) {
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)
Expand Down Expand Up @@ -282,7 +296,7 @@ func TestWizard_Plan_AgentMode_SaysAgent(t *testing.T) {
view := m.View()

assert.Contains(t, view, "Use the setup agent to add initialization code")
assert.Contains(t, view, "uses the template if OpenCode cannot run")
assert.Contains(t, view, "uses the template if the agent cannot run")
}

func TestStreamAgent_AnswersPermissionAndReturnsFiles(t *testing.T) {
Expand All @@ -300,7 +314,7 @@ func TestStreamAgent_AnswersPermissionAndReturnsFiles(t *testing.T) {
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(), "OpenCode asks to use bash: npm test")
assert.Contains(t, out.String(), "The agent asks to use bash: npm test")
}

func TestStreamAgent_EmptyAnswerRejects(t *testing.T) {
Expand All @@ -324,6 +338,107 @@ func TestStreamAgent_Error(t *testing.T) {
assert.Contains(t, err.Error(), "no API key")
}

func TestCredentialExports_SkipsEmptyValues(t *testing.T) {
assert.Equal(t, []string{"export LAUNCHDARKLY_CLIENT_SIDE_ID=abc"}, credentialExports("", "abc", ""))
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?")
}
6 changes: 6 additions & 0 deletions cmd/setup/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,12 @@ func waitForAgentEvent(run agent.Run) tea.Cmd {
}
}

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)}
Expand Down
Loading
Loading