diff --git a/cmd/onboard.go b/cmd/onboard.go new file mode 100644 index 00000000..002e99ca --- /dev/null +++ b/cmd/onboard.go @@ -0,0 +1,72 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + cmdAnalytics "github.com/launchdarkly/ldcli/cmd/analytics" + "github.com/launchdarkly/ldcli/cmd/cliflags" + resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" + "github.com/launchdarkly/ldcli/cmd/validators" + "github.com/launchdarkly/ldcli/internal/analytics" + "github.com/launchdarkly/ldcli/internal/onboarding" +) + +func NewOnboardCmd( + analyticsTrackerFn analytics.TrackerFn, +) *cobra.Command { + cmd := &cobra.Command{ + Args: validators.Validate(), + Long: "Generate an AI-agent-compatible onboarding plan for integrating the LaunchDarkly SDK into your project. The output is a structured workflow of agent-skills that guide detection, installation, validation, and first flag creation.", + Short: "Generate an AI-agent onboarding plan for SDK integration", + Use: "onboard", + PreRun: func(cmd *cobra.Command, args []string) { + analyticsTrackerFn( + viper.GetString(cliflags.AccessTokenFlag), + viper.GetString(cliflags.BaseURIFlag), + viper.GetBool(cliflags.AnalyticsOptOut), + ).SendCommandRunEvent(cmdAnalytics.CmdRunEventProperties(cmd, "onboard", nil)) + }, + RunE: runOnboard(), + } + + cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + initOnboardFlags(cmd) + + return cmd +} + +func runOnboard() func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + baseURI := viper.GetString(cliflags.BaseURIFlag) + project := viper.GetString(cliflags.ProjectFlag) + environment := viper.GetString(cliflags.EnvironmentFlag) + + plan := onboarding.BuildPlan(baseURI, project, environment) + + outputKind := cliflags.GetOutputKind(cmd) + if outputKind == "json" { + data, err := plan.ToJSON() + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), string(data)) + return nil + } + + fmt.Fprintln(cmd.OutOrStdout(), plan.Plaintext()) + return nil + } +} + +func initOnboardFlags(cmd *cobra.Command) { + cmd.Flags().String(cliflags.ProjectFlag, "default", "The project key") + _ = cmd.Flags().SetAnnotation(cliflags.ProjectFlag, "required", []string{"true"}) + _ = viper.BindPFlag(cliflags.ProjectFlag, cmd.Flags().Lookup(cliflags.ProjectFlag)) + + cmd.Flags().String(cliflags.EnvironmentFlag, "test", "The environment key") + _ = cmd.Flags().SetAnnotation(cliflags.EnvironmentFlag, "required", []string{"true"}) + _ = viper.BindPFlag(cliflags.EnvironmentFlag, cmd.Flags().Lookup(cliflags.EnvironmentFlag)) +} diff --git a/cmd/onboard_test.go b/cmd/onboard_test.go new file mode 100644 index 00000000..b19e70cd --- /dev/null +++ b/cmd/onboard_test.go @@ -0,0 +1,77 @@ +package cmd_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/cmd" + "github.com/launchdarkly/ldcli/internal/analytics" +) + +func TestOnboard(t *testing.T) { + t.Run("with JSON output returns valid JSON plan", func(t *testing.T) { + args := []string{ + "onboard", + "--access-token", "test-token", + "--project", "default", + "--environment", "test", + "--output", "json", + } + + output, err := cmd.CallCmd( + t, + cmd.APIClients{}, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), `"steps"`) + assert.Contains(t, string(output), `"sdk_recipes"`) + assert.Contains(t, string(output), `"recovery_options"`) + assert.Contains(t, string(output), `"detect"`) + }) + + t.Run("with plaintext output returns readable summary", func(t *testing.T) { + args := []string{ + "onboard", + "--access-token", "test-token", + "--project", "default", + "--environment", "test", + } + + output, err := cmd.CallCmd( + t, + cmd.APIClients{}, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), "LaunchDarkly AI-Agent Onboarding Plan") + assert.Contains(t, string(output), "Detect Repository Stack") + assert.Contains(t, string(output), "--json") + }) + + t.Run("with --json flag returns JSON output", func(t *testing.T) { + args := []string{ + "onboard", + "--access-token", "test-token", + "--project", "default", + "--environment", "test", + "--json", + } + + output, err := cmd.CallCmd( + t, + cmd.APIClients{}, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), `"steps"`) + }) +} diff --git a/cmd/root.go b/cmd/root.go index 34ffdca9..09949f65 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -210,6 +210,7 @@ func NewRootCommand( configCmd := configcmd.NewConfigCmd(configService, analyticsTrackerFn) cmd.AddCommand(configCmd.Cmd()) + cmd.AddCommand(NewOnboardCmd(analyticsTrackerFn)) cmd.AddCommand(NewQuickStartCmd(analyticsTrackerFn, clients.EnvironmentsClient, clients.FlagsClient)) cmd.AddCommand(logincmd.NewLoginCmd(clients.ResourcesClient)) cmd.AddCommand(signupcmd.NewSignupCmd(analyticsTrackerFn)) diff --git a/cmd/templates.go b/cmd/templates.go index 3a96b533..abbcd32d 100644 --- a/cmd/templates.go +++ b/cmd/templates.go @@ -11,6 +11,7 @@ func getUsageTemplate() string { {{.CommandPath}} [command] Commands: + {{rpad "onboard" 29}} Generate an AI-agent onboarding plan for SDK integration {{rpad "setup" 29}} Create your first feature flag using a step-by-step guide {{rpad "config" 29}} View and modify specific configuration values {{rpad "completion" 29}} Enable command autocompletion within supported shells diff --git a/internal/onboarding/onboarding.go b/internal/onboarding/onboarding.go new file mode 100644 index 00000000..beaaa0ca --- /dev/null +++ b/internal/onboarding/onboarding.go @@ -0,0 +1,118 @@ +package onboarding + +import ( + "encoding/json" + "fmt" + "strings" +) + +// Step represents a single step in the onboarding workflow that an AI agent should execute. +type Step struct { + ID string `json:"id"` + Title string `json:"title"` + Instructions string `json:"instructions"` + Tools []string `json:"tools"` + SuccessCriteria string `json:"success_criteria"` + Next string `json:"next"` + OnFailure string `json:"on_failure"` +} + +// RecoveryOption represents a ranked recovery action when a step fails. +type RecoveryOption struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Priority int `json:"priority"` +} + +// OnboardingPlan is the top-level structure returned by the onboard command, +// providing the AI agent with a complete set of instructions. +type OnboardingPlan struct { + Steps []Step `json:"steps"` + SDKRecipes []SDKRecipe `json:"sdk_recipes"` + RecoveryOptions []RecoveryOption `json:"recovery_options"` +} + +// ToJSON serializes the onboarding plan to indented JSON. +func (p OnboardingPlan) ToJSON() ([]byte, error) { + return json.MarshalIndent(p, "", " ") +} + +// Plaintext returns a human-readable summary of the onboarding plan. +func (p OnboardingPlan) Plaintext() string { + var sb strings.Builder + sb.WriteString("LaunchDarkly AI-Agent Onboarding Plan\n") + sb.WriteString("=====================================\n\n") + + for i, step := range p.Steps { + sb.WriteString(fmt.Sprintf("Step %d: %s\n", i+1, step.Title)) + sb.WriteString(fmt.Sprintf(" ID: %s\n", step.ID)) + sb.WriteString(fmt.Sprintf(" Success Criteria: %s\n", step.SuccessCriteria)) + if step.Next != "" { + sb.WriteString(fmt.Sprintf(" Next: %s\n", step.Next)) + } + sb.WriteString("\n") + } + + sb.WriteString(fmt.Sprintf("SDK Recipes: %d available\n", len(p.SDKRecipes))) + sb.WriteString(fmt.Sprintf("Recovery Options: %d defined\n\n", len(p.RecoveryOptions))) + sb.WriteString("Run with --json for the full structured plan suitable for AI agent consumption.\n") + + return sb.String() +} + +// BuildPlan constructs the full onboarding plan with all steps, SDK recipes, and recovery options. +func BuildPlan(baseURI, project, environment string) OnboardingPlan { + return OnboardingPlan{ + Steps: buildSteps(baseURI, project, environment), + SDKRecipes: AllSDKRecipes(), + RecoveryOptions: buildRecoveryOptions(), + } +} + +func buildRecoveryOptions() []RecoveryOption { + return []RecoveryOption{ + { + ID: "retry-step", + Title: "Retry Current Step", + Description: "Re-attempt the current step after reviewing the error output.", + Priority: 1, + }, + { + ID: "check-credentials", + Title: "Verify Credentials", + Description: "Confirm the LaunchDarkly access token is valid and has write-level access. Run: ldcli environments list --project ", + Priority: 2, + }, + { + ID: "check-network", + Title: "Check Network Connectivity", + Description: "Verify the application can reach LaunchDarkly endpoints. Check firewall rules and proxy settings.", + Priority: 3, + }, + { + ID: "manual-install", + Title: "Manual SDK Install", + Description: "If automatic dependency installation fails, provide the user with copy/paste instructions from the SDK recipe.", + Priority: 4, + }, + { + ID: "switch-sdk", + Title: "Try Alternative SDK", + Description: "If detection chose the wrong SDK, re-run the detect step or allow the user to manually select an SDK.", + Priority: 5, + }, + { + ID: "skip-step", + Title: "Skip to Next Step", + Description: "If a step is non-critical (e.g., auto-run failed but user can start manually), skip to the next step.", + Priority: 6, + }, + { + ID: "abort", + Title: "Abort Onboarding", + Description: "Stop the onboarding workflow entirely and present the user with documentation links for manual setup.", + Priority: 7, + }, + } +} diff --git a/internal/onboarding/onboarding_test.go b/internal/onboarding/onboarding_test.go new file mode 100644 index 00000000..51fa3c0c --- /dev/null +++ b/internal/onboarding/onboarding_test.go @@ -0,0 +1,140 @@ +package onboarding_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/onboarding" +) + +func TestBuildPlan(t *testing.T) { + plan := onboarding.BuildPlan("https://app.launchdarkly.com", "default", "test") + + t.Run("has all required steps", func(t *testing.T) { + require.Len(t, plan.Steps, 7) + + expectedIDs := []string{"detect", "plan", "apply", "run", "validate", "first-flag", "recover"} + for i, step := range plan.Steps { + assert.Equal(t, expectedIDs[i], step.ID) + } + }) + + t.Run("steps have non-empty fields", func(t *testing.T) { + for _, step := range plan.Steps { + assert.NotEmpty(t, step.ID, "step ID should not be empty") + assert.NotEmpty(t, step.Title, "step Title should not be empty") + assert.NotEmpty(t, step.Instructions, "step Instructions should not be empty") + assert.NotEmpty(t, step.Tools, "step Tools should not be empty") + assert.NotEmpty(t, step.SuccessCriteria, "step SuccessCriteria should not be empty") + } + }) + + t.Run("step flow is connected", func(t *testing.T) { + assert.Equal(t, "plan", plan.Steps[0].Next) + assert.Equal(t, "apply", plan.Steps[1].Next) + assert.Equal(t, "run", plan.Steps[2].Next) + assert.Equal(t, "validate", plan.Steps[3].Next) + assert.Equal(t, "first-flag", plan.Steps[4].Next) + assert.Empty(t, plan.Steps[5].Next) // first-flag is terminal + assert.Empty(t, plan.Steps[6].Next) // recover is terminal + }) + + t.Run("non-terminal steps have on_failure set to recover", func(t *testing.T) { + for _, step := range plan.Steps[:6] { + assert.Equal(t, "recover", step.OnFailure, "step %s should have on_failure=recover", step.ID) + } + }) + + t.Run("has SDK recipes", func(t *testing.T) { + assert.Greater(t, len(plan.SDKRecipes), 0) + }) + + t.Run("has recovery options", func(t *testing.T) { + assert.Greater(t, len(plan.RecoveryOptions), 0) + }) + + t.Run("validate step includes project and environment", func(t *testing.T) { + validateStep := plan.Steps[4] + assert.Contains(t, validateStep.Instructions, "default") + assert.Contains(t, validateStep.Instructions, "test") + assert.Contains(t, validateStep.Instructions, "https://app.launchdarkly.com") + }) +} + +func TestToJSON(t *testing.T) { + plan := onboarding.BuildPlan("https://app.launchdarkly.com", "default", "test") + + data, err := plan.ToJSON() + require.NoError(t, err) + + t.Run("produces valid JSON", func(t *testing.T) { + var parsed onboarding.OnboardingPlan + err := json.Unmarshal(data, &parsed) + require.NoError(t, err) + assert.Len(t, parsed.Steps, 7) + assert.Greater(t, len(parsed.SDKRecipes), 0) + assert.Greater(t, len(parsed.RecoveryOptions), 0) + }) +} + +func TestPlaintext(t *testing.T) { + plan := onboarding.BuildPlan("https://app.launchdarkly.com", "default", "test") + text := plan.Plaintext() + + t.Run("contains header", func(t *testing.T) { + assert.Contains(t, text, "LaunchDarkly AI-Agent Onboarding Plan") + }) + + t.Run("contains all step titles", func(t *testing.T) { + assert.Contains(t, text, "Detect Repository Stack") + assert.Contains(t, text, "Generate Integration Plan") + assert.Contains(t, text, "Install Dependencies and Apply Code Changes") + assert.Contains(t, text, "Start the Application") + assert.Contains(t, text, "Validate SDK Connection") + assert.Contains(t, text, "Create Your First Feature Flag") + assert.Contains(t, text, "Recovery: Diagnose and Resume") + }) + + t.Run("contains JSON hint", func(t *testing.T) { + assert.Contains(t, text, "--json") + }) +} + +func TestAllSDKRecipes(t *testing.T) { + recipes := onboarding.AllSDKRecipes() + + t.Run("has recipes for major languages", func(t *testing.T) { + sdkIDs := make(map[string]bool) + for _, r := range recipes { + sdkIDs[r.SDKID] = true + } + + assert.True(t, sdkIDs["node-server"], "should have Node server recipe") + assert.True(t, sdkIDs["python-server-sdk"], "should have Python recipe") + assert.True(t, sdkIDs["go-server-sdk"], "should have Go recipe") + assert.True(t, sdkIDs["java-server-sdk"], "should have Java recipe") + assert.True(t, sdkIDs["react-client-sdk"], "should have React recipe") + }) + + t.Run("all recipes have required fields", func(t *testing.T) { + for _, r := range recipes { + assert.NotEmpty(t, r.SDKID, "recipe SDKID should not be empty") + assert.NotEmpty(t, r.DisplayName, "recipe DisplayName should not be empty") + assert.NotEmpty(t, r.SDKType, "recipe SDKType should not be empty") + assert.NotEmpty(t, r.DetectFiles, "recipe DetectFiles should not be empty") + assert.NotEmpty(t, r.InstallCmd, "recipe InstallCmd should not be empty") + assert.NotEmpty(t, r.ImportSnippet, "recipe ImportSnippet should not be empty") + assert.NotEmpty(t, r.InitSnippet, "recipe InitSnippet should not be empty") + } + }) + + t.Run("SDK types are valid", func(t *testing.T) { + validTypes := map[string]bool{"server": true, "client": true, "mobile": true} + for _, r := range recipes { + assert.True(t, validTypes[r.SDKType], "recipe %s has invalid type %s", r.SDKID, r.SDKType) + } + }) +} diff --git a/internal/onboarding/sdk_recipes.go b/internal/onboarding/sdk_recipes.go new file mode 100644 index 00000000..8db8c42d --- /dev/null +++ b/internal/onboarding/sdk_recipes.go @@ -0,0 +1,209 @@ +package onboarding + +// SDKRecipe maps a detected language/framework to the correct LaunchDarkly SDK +// and provides the agent with the information needed to install and initialize it. +type SDKRecipe struct { + SDKID string `json:"sdk_id"` + DisplayName string `json:"display_name"` + SDKType string `json:"sdk_type"` + DetectFiles []string `json:"detect_files"` + DetectPatterns []string `json:"detect_patterns"` + PackageManager string `json:"package_manager"` + InstallCmd string `json:"install_cmd"` + ImportSnippet string `json:"import_snippet"` + InitSnippet string `json:"init_snippet"` +} + +// AllSDKRecipes returns the complete set of SDK recipes the agent uses +// during the Detect and Plan steps to match a repo to the right SDK. +func AllSDKRecipes() []SDKRecipe { + return []SDKRecipe{ + { + SDKID: "node-server", + DisplayName: "Node.js (server-side)", + SDKType: "server", + DetectFiles: []string{"package.json"}, + DetectPatterns: []string{"express", "fastify", "koa", "hapi", "nestjs", "next", "\"type\": \"module\""}, + PackageManager: "npm", + InstallCmd: "npm install @launchdarkly/node-server-sdk --save", + ImportSnippet: "const LaunchDarkly = require('@launchdarkly/node-server-sdk');", + InitSnippet: "const ldClient = LaunchDarkly.init('YOUR_SDK_KEY');\nawait ldClient.waitForInitialization();", + }, + { + SDKID: "python-server-sdk", + DisplayName: "Python (server-side)", + SDKType: "server", + DetectFiles: []string{"requirements.txt", "pyproject.toml", "setup.py", "Pipfile"}, + DetectPatterns: []string{"flask", "django", "fastapi", "starlette"}, + PackageManager: "pip", + InstallCmd: "pip install launchdarkly-server-sdk", + ImportSnippet: "import ldclient\nfrom ldclient.config import Config", + InitSnippet: "ldclient.set_config(Config('YOUR_SDK_KEY'))\nclient = ldclient.get()", + }, + { + SDKID: "go-server-sdk", + DisplayName: "Go (server-side)", + SDKType: "server", + DetectFiles: []string{"go.mod", "go.sum"}, + DetectPatterns: []string{"net/http", "gin", "echo", "fiber", "chi"}, + PackageManager: "go", + InstallCmd: "go get github.com/launchdarkly/go-server-sdk/v7", + ImportSnippet: "ld \"github.com/launchdarkly/go-server-sdk/v7\"", + InitSnippet: "ldClient, _ := ld.MakeClient(\"YOUR_SDK_KEY\", 5*time.Second)", + }, + { + SDKID: "java-server-sdk", + DisplayName: "Java (server-side)", + SDKType: "server", + DetectFiles: []string{"pom.xml", "build.gradle", "build.gradle.kts"}, + DetectPatterns: []string{"spring", "quarkus", "micronaut", "dropwizard"}, + PackageManager: "maven/gradle", + InstallCmd: "Add com.launchdarkly:launchdarkly-java-server-sdk to your build file", + ImportSnippet: "import com.launchdarkly.sdk.*;\nimport com.launchdarkly.sdk.server.*;", + InitSnippet: "LDClient client = new LDClient(\"YOUR_SDK_KEY\");", + }, + { + SDKID: "ruby-server-sdk", + DisplayName: "Ruby (server-side)", + SDKType: "server", + DetectFiles: []string{"Gemfile", "*.gemspec"}, + DetectPatterns: []string{"rails", "sinatra", "hanami"}, + PackageManager: "bundler", + InstallCmd: "gem install launchdarkly-server-sdk", + ImportSnippet: "require 'ldclient-rb'", + InitSnippet: "client = LaunchDarkly::LDClient.new('YOUR_SDK_KEY')", + }, + { + SDKID: "dotnet-server-sdk", + DisplayName: ".NET (server-side)", + SDKType: "server", + DetectFiles: []string{"*.csproj", "*.sln", "*.fsproj"}, + DetectPatterns: []string{"Microsoft.AspNetCore", "Microsoft.NET"}, + PackageManager: "nuget", + InstallCmd: "dotnet add package LaunchDarkly.ServerSdk", + ImportSnippet: "using LaunchDarkly.Sdk;\nusing LaunchDarkly.Sdk.Server;", + InitSnippet: "var client = new LdClient(\"YOUR_SDK_KEY\");", + }, + { + SDKID: "php-server-sdk", + DisplayName: "PHP (server-side)", + SDKType: "server", + DetectFiles: []string{"composer.json"}, + DetectPatterns: []string{"laravel", "symfony", "slim"}, + PackageManager: "composer", + InstallCmd: "composer require launchdarkly/server-sdk", + ImportSnippet: "use LaunchDarkly\\LDClient;", + InitSnippet: "$client = new LDClient('YOUR_SDK_KEY');", + }, + { + SDKID: "rust-server-sdk", + DisplayName: "Rust (server-side)", + SDKType: "server", + DetectFiles: []string{"Cargo.toml"}, + DetectPatterns: []string{"actix", "rocket", "axum", "warp"}, + PackageManager: "cargo", + InstallCmd: "cargo add launchdarkly-server-sdk", + ImportSnippet: "use launchdarkly_server_sdk::{Client, ConfigBuilder};", + InitSnippet: "let client = Client::build(ConfigBuilder::new(\"YOUR_SDK_KEY\").build()).expect(\"client\");", + }, + { + SDKID: "erlang-server-sdk", + DisplayName: "Erlang (server-side)", + SDKType: "server", + DetectFiles: []string{"rebar.config", "mix.exs"}, + DetectPatterns: []string{"erlang", "elixir", "phoenix"}, + PackageManager: "rebar/mix", + InstallCmd: "Add ldclient to your rebar.config or mix.exs dependencies", + ImportSnippet: "-include_lib(\"ldclient/include/ldclient.hrl\").", + InitSnippet: "ldclient:start_instance(\"YOUR_SDK_KEY\")", + }, + { + SDKID: "react-client-sdk", + DisplayName: "React (client-side)", + SDKType: "client", + DetectFiles: []string{"package.json"}, + DetectPatterns: []string{"react", "react-dom", "\"react\":"}, + PackageManager: "npm", + InstallCmd: "npm install launchdarkly-react-client-sdk --save", + ImportSnippet: "import { asyncWithLDProvider } from 'launchdarkly-react-client-sdk';", + InitSnippet: "const LDProvider = await asyncWithLDProvider({ clientSideID: 'YOUR_CLIENT_SIDE_ID' });", + }, + { + SDKID: "vue", + DisplayName: "Vue (client-side)", + SDKType: "client", + DetectFiles: []string{"package.json"}, + DetectPatterns: []string{"vue", "\"vue\":"}, + PackageManager: "npm", + InstallCmd: "npm install launchdarkly-vue-client-sdk --save", + ImportSnippet: "import { LDPlugin } from 'launchdarkly-vue-client-sdk';", + InitSnippet: "app.use(LDPlugin, { clientSideID: 'YOUR_CLIENT_SIDE_ID' });", + }, + { + SDKID: "js-client-sdk", + DisplayName: "JavaScript (client-side)", + SDKType: "client", + DetectFiles: []string{"package.json", "index.html"}, + DetectPatterns: []string{"vanilla", "webpack", "vite", "parcel", "rollup"}, + PackageManager: "npm", + InstallCmd: "npm install launchdarkly-js-client-sdk --save", + ImportSnippet: "import * as LDClient from 'launchdarkly-js-client-sdk';", + InitSnippet: "const client = LDClient.initialize('YOUR_CLIENT_SIDE_ID', context);", + }, + { + SDKID: "node-client-sdk", + DisplayName: "Node.js (client-side / Electron)", + SDKType: "client", + DetectFiles: []string{"package.json"}, + DetectPatterns: []string{"electron"}, + PackageManager: "npm", + InstallCmd: "npm install launchdarkly-node-client-sdk --save", + ImportSnippet: "const LDClient = require('launchdarkly-node-client-sdk');", + InitSnippet: "const client = LDClient.initialize('YOUR_CLIENT_SIDE_ID', context);", + }, + { + SDKID: "swift-client-sdk", + DisplayName: "Swift / iOS (client-side)", + SDKType: "mobile", + DetectFiles: []string{"Package.swift", "Podfile", "*.xcodeproj"}, + DetectPatterns: []string{"UIKit", "SwiftUI", "ios"}, + PackageManager: "swift-pm/cocoapods", + InstallCmd: "Add LaunchDarkly to your Package.swift or Podfile", + ImportSnippet: "import LaunchDarkly", + InitSnippet: "var config = LDConfig(mobileKey: \"YOUR_MOBILE_KEY\")\nLDClient.start(config: config, context: context)", + }, + { + SDKID: "android", + DisplayName: "Android (client-side)", + SDKType: "mobile", + DetectFiles: []string{"build.gradle", "build.gradle.kts", "AndroidManifest.xml"}, + DetectPatterns: []string{"android", "com.android", "androidx"}, + PackageManager: "gradle", + InstallCmd: "Add com.launchdarkly:launchdarkly-android-client-sdk to your build.gradle", + ImportSnippet: "import com.launchdarkly.sdk.android.*;", + InitSnippet: "LDConfig config = new LDConfig.Builder().mobileKey(\"YOUR_MOBILE_KEY\").build();\nLDClient.init(application, config, context);", + }, + { + SDKID: "flutter-client-sdk", + DisplayName: "Flutter (client-side)", + SDKType: "mobile", + DetectFiles: []string{"pubspec.yaml"}, + DetectPatterns: []string{"flutter"}, + PackageManager: "pub", + InstallCmd: "flutter pub add launchdarkly_flutter_client_sdk", + ImportSnippet: "import 'package:launchdarkly_flutter_client_sdk/launchdarkly_flutter_client_sdk.dart';", + InitSnippet: "final config = LDConfig(AutoEnvAttributes.enabled, \"YOUR_MOBILE_KEY\");\nawait LDClient.start(config, context);", + }, + { + SDKID: "react-native", + DisplayName: "React Native (client-side)", + SDKType: "mobile", + DetectFiles: []string{"package.json"}, + DetectPatterns: []string{"react-native"}, + PackageManager: "npm", + InstallCmd: "npm install @launchdarkly/react-native-client-sdk --save", + ImportSnippet: "import { LDProvider } from '@launchdarkly/react-native-client-sdk';", + InitSnippet: "{children}", + }, + } +} diff --git a/internal/onboarding/steps.go b/internal/onboarding/steps.go new file mode 100644 index 00000000..103dee2b --- /dev/null +++ b/internal/onboarding/steps.go @@ -0,0 +1,227 @@ +package onboarding + +import "fmt" + +// buildSteps returns the ordered list of onboarding steps for the agent to follow. +func buildSteps(baseURI, project, environment string) []Step { + return []Step{ + buildDetectStep(), + buildPlanStep(), + buildApplyStep(), + buildRunStep(), + buildValidateStep(baseURI, project, environment), + buildFirstFlagStep(baseURI, project, environment), + buildRecoverStep(), + } +} + +func buildDetectStep() Step { + return Step{ + ID: "detect", + Title: "Detect Repository Stack", + Instructions: `Inspect the repository to identify the language, framework, package manager, and entrypoints. + +1. List the files in the project root directory. +2. Look for these key indicator files to determine the language/framework: + - package.json → Node.js / JavaScript / TypeScript (check for react, vue, next, express, etc.) + - go.mod → Go + - requirements.txt / pyproject.toml / setup.py / Pipfile → Python + - pom.xml / build.gradle / build.gradle.kts → Java + - Gemfile → Ruby + - *.csproj / *.sln → .NET + - composer.json → PHP + - Cargo.toml → Rust + - pubspec.yaml → Flutter/Dart + - Package.swift / Podfile / *.xcodeproj → iOS/Swift + - AndroidManifest.xml → Android + - rebar.config / mix.exs → Erlang/Elixir +3. If package.json exists, read it to check for framework-specific dependencies: + - "react" or "react-dom" → React client-side SDK + - "react-native" → React Native mobile SDK + - "vue" → Vue client-side SDK + - "electron" → Node client-side SDK + - "express", "fastify", "koa", "nestjs" → Node server-side SDK +4. Search for existing LaunchDarkly SDK imports in the codebase: + - Look for patterns: "launchdarkly", "ldclient", "go-server-sdk", "LaunchDarkly" + - If found, note which SDK is already in use and whether it appears properly initialized. +5. Identify candidate targets: + - frontend: client-side SDKs (React, Vue, JS, etc.) + - backend: server-side SDKs (Node, Go, Python, Java, etc.) + - mobile: mobile SDKs (Swift, Android, Flutter, React Native) +6. Report your findings as structured data: language, framework, package_manager, sdk_already_present, candidate_targets.`, + Tools: []string{"readFile", "listDirectory", "searchPattern"}, + SuccessCriteria: "Language, framework, and package manager identified. Existing SDK presence determined.", + Next: "plan", + OnFailure: "recover", + } +} + +func buildPlanStep() Step { + return Step{ + ID: "plan", + Title: "Generate Integration Plan", + Instructions: `Based on the detection results, choose the correct SDK and generate a minimal integration plan. + +1. Match the detected stack to an SDK recipe from the sdk_recipes list in this plan. + - For server-side apps: use the server SDK for the detected language. + - For client-side web apps: use the appropriate client SDK (React, Vue, or JS). + - For mobile apps: use the mobile SDK (Swift, Android, Flutter, or React Native). +2. If multiple targets exist (e.g., a Next.js app with both server and client), choose the server-side SDK first. +3. If an SDK is already present and initialized: + - Skip to the "validate" step to check if it's working. + - If validation fails, proceed with the plan to fix the initialization. +4. Generate an integration plan with these details: + - SDK to install (name and install command) + - Files to modify (entrypoint files where SDK initialization should be added) + - The import statement to add + - The initialization code snippet + - The key type needed: SDK key (server), client-side ID (client), or mobile key (mobile) +5. Present the plan to the user for confirmation before proceeding to the Apply step.`, + Tools: []string{"readFile", "searchPattern"}, + SuccessCriteria: "SDK selected and integration plan generated with specific files and code changes identified.", + Next: "apply", + OnFailure: "recover", + } +} + +func buildApplyStep() Step { + return Step{ + ID: "apply", + Title: "Install Dependencies and Apply Code Changes", + Instructions: `Execute the integration plan by installing the SDK and adding initialization code. + +1. Install the SDK dependency using the package manager: + - Run the install command from the chosen SDK recipe. + - Verify the installation succeeded by checking the exit code and package lock file. +2. Add the SDK initialization code to the identified entrypoint file(s): + - Add the import statement at the top of the file with other imports. + - Add the initialization code near the application startup logic. + - For server-side SDKs: initialize early in the app lifecycle (before request handling). + - For client-side SDKs: wrap the app in the SDK provider component. + - Use environment variables for the SDK key (e.g., LAUNCHDARKLY_SDK_KEY). +3. Add a simple feature flag evaluation to demonstrate the integration: + - Add a boolean flag check with a hardcoded flag key (will be updated in the First Flag step). + - Log the flag value to stdout so it's visible when the app runs. +4. If file edits are not permitted: + - Present the exact code changes as copy/paste instructions. + - Clearly indicate which file and line numbers to modify. +5. Commit the changes with a descriptive message.`, + Tools: []string{"writeFile", "runCommand", "readFile"}, + SuccessCriteria: "SDK dependency installed and initialization code added to the application entrypoint.", + Next: "run", + OnFailure: "recover", + } +} + +func buildRunStep() Step { + return Step{ + ID: "run", + Title: "Start the Application", + Instructions: `Attempt to start the application and confirm it can initialize the LaunchDarkly SDK. + +1. Determine the correct start command for the application: + - Check package.json scripts for "start", "dev", or "serve" commands. + - Check for Makefiles, Procfiles, or docker-compose.yml. + - For Go: "go run ." or "go run main.go" + - For Python: check for manage.py (Django), or run the main script directly. +2. Ensure the SDK key environment variable is set: + - The user must provide a valid LaunchDarkly SDK key. + - Set LAUNCHDARKLY_SDK_KEY (server), or configure the client-side ID / mobile key in code. +3. Start the application and watch the output for: + - "SDK successfully initialized" or similar success messages. + - Connection errors or authentication failures. + - The feature flag evaluation log line added in the Apply step. +4. If the app fails to start: + - Check for missing dependencies or build errors. + - Check that the SDK key is valid and has the correct permissions. + - Trigger the recover step with relevant error details. +5. If the app starts but the SDK doesn't initialize: + - Check network connectivity to LaunchDarkly. + - Verify the SDK key matches the expected environment. + - Check for firewall or proxy issues. +6. Leave the application running for the Validate step.`, + Tools: []string{"runCommand", "readFile"}, + SuccessCriteria: "Application is running and the LaunchDarkly SDK has initialized successfully.", + Next: "validate", + OnFailure: "recover", + } +} + +func buildValidateStep(baseURI, project, environment string) Step { + return Step{ + ID: "validate", + Title: "Validate SDK Connection", + Instructions: fmt.Sprintf(`Confirm that the LaunchDarkly API sees the SDK connection. + +1. Wait 15-30 seconds after the application starts to allow the SDK to report activity. +2. Check the SDK active status using the LaunchDarkly API: + - Run: ldcli environments get-sdk-active --project %s --environment %s --base-uri %s + - Or call the API directly: GET %s/api/v2/projects/%s/environments/%s/sdk-active +3. If the response shows "active": true, the SDK is successfully connected. +4. If the response shows "active": false: + - Verify the application is still running. + - Check that the SDK key used in the application matches the environment being queried. + - Wait another 30 seconds and retry (the signal can take up to 60 seconds). + - If still not active after 2 minutes, trigger the recover step. +5. Once validated, report success and proceed to the First Flag step.`, project, environment, baseURI, baseURI, project, environment), + Tools: []string{"runCommand"}, + SuccessCriteria: "LaunchDarkly API confirms SDK is active for the target environment.", + Next: "first-flag", + OnFailure: "recover", + } +} + +func buildFirstFlagStep(baseURI, project, environment string) Step { + return Step{ + ID: "first-flag", + Title: "Create Your First Feature Flag", + Instructions: fmt.Sprintf(`Now that the SDK is verified, create and evaluate a feature flag. + +1. Create a new boolean feature flag: + - Run: ldcli flags create --project %s --data '{"name": "my-first-flag", "key": "my-first-flag", "clientSideAvailability": {"usingEnvironmentId": true, "usingMobileKey": true}}' + - Or use the MCP tool: createFlag with project key and flag details. +2. Update the application code to evaluate this flag: + - Replace any placeholder flag key with "my-first-flag". + - Ensure the evaluation uses the correct context (e.g., user key, kind). +3. Verify the flag evaluates correctly: + - With the flag OFF (default), the evaluation should return false. + - Toggle the flag ON in LaunchDarkly: + Run: ldcli flags toggle-on --access-token --project %s --environment %s --flag my-first-flag + - The application should now show the flag evaluating to true. +4. Demonstrate the flag toggle: + - Toggle the flag off: ldcli flags toggle-off --access-token --project %s --environment %s --flag my-first-flag + - Observe the real-time update in the application output. +5. Congratulate the user on their first feature flag integration! + - Suggest next steps: creating more flags, using targeting rules, setting up environments. + - Point to documentation: https://docs.launchdarkly.com`, project, project, environment, project, environment), + Tools: []string{"runCommand", "writeFile", "readFile"}, + SuccessCriteria: "Feature flag created, evaluated successfully, and toggled on/off with visible results.", + Next: "", + OnFailure: "recover", + } +} + +func buildRecoverStep() Step { + return Step{ + ID: "recover", + Title: "Recovery: Diagnose and Resume", + Instructions: `When any step fails, follow this recovery procedure. + +1. Identify the failed step and the error message. +2. Present the user with ranked recovery options: + a. Retry Current Step: Re-attempt after reviewing the error. + b. Verify Credentials: Check the access token is valid (ldcli environments list --project ). + c. Check Network: Verify connectivity to LaunchDarkly endpoints. + d. Manual Install: Provide copy/paste SDK installation instructions. + e. Try Alternative SDK: Re-run detection or let the user pick a different SDK. + f. Skip Step: If the failure is non-critical, move to the next step. + g. Abort: Stop onboarding and provide manual setup documentation links. +3. Based on the user's choice, resume the workflow from the appropriate step. +4. If the same step fails 3 times, automatically suggest skipping or aborting. +5. Keep a log of all attempted actions and errors for debugging.`, + Tools: []string{"runCommand", "readFile"}, + SuccessCriteria: "User has chosen a recovery action and the workflow has resumed or been gracefully terminated.", + Next: "", + OnFailure: "", + } +}