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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
339 changes: 108 additions & 231 deletions forge-cli/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ import (
"strings"
"text/template"

tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
"golang.org/x/term"

"github.com/initializ/forge/forge-cli/internal/tui"
"github.com/initializ/forge/forge-cli/internal/tui/steps"
"github.com/initializ/forge/forge-cli/skills"
"github.com/initializ/forge/forge-cli/templates"
skillreg "github.com/initializ/forge/forge-core/registry"
Expand Down Expand Up @@ -128,6 +132,11 @@ func runInit(cmd *cobra.Command, args []string) error {
opts.NonInteractive = nonInteractive
opts.Force, _ = cmd.Flags().GetBool("force")

// TTY detection: require a terminal for interactive mode
if !nonInteractive && !term.IsTerminal(int(os.Stdout.Fd())) {
return fmt.Errorf("interactive mode requires a terminal; use --non-interactive")
}

var err error
if nonInteractive {
err = collectNonInteractive(opts)
Expand All @@ -154,269 +163,137 @@ func runInit(cmd *cobra.Command, args []string) error {
}

func collectInteractive(opts *initOptions) error {
var err error

// ── Step 1: Name ──
if opts.Name == "" {
opts.Name, err = askText("Agent name", "my-agent")
if err != nil {
return err
}
}

// Default framework and language (no interactive prompt per rework spec)
if opts.Framework == "" {
opts.Framework = "custom"
}
if opts.Language == "" {
opts.Language = "python"
// Detect theme
theme := tui.DetectTheme(themeOverride)
styles := tui.NewStyleSet(theme)

// Load tool info for the tools step
allTools := builtins.All()
var toolInfos []steps.ToolInfo
for _, t := range allTools {
toolInfos = append(toolInfos, steps.ToolInfo{
Name: t.Name(),
Description: t.Description(),
})
}

// ── Step 2: Provider + API Key Validation ──
if opts.ModelProvider == "" {
_, opts.ModelProvider, err = askSelect("Model provider", []string{"openai", "anthropic", "gemini", "ollama", "custom"})
if err != nil {
return err
// Load skill info for the skills step
var skillInfos []steps.SkillInfo
regSkills, err := skillreg.LoadIndex()
if err == nil {
for _, s := range regSkills {
skillInfos = append(skillInfos, steps.SkillInfo{
Name: s.Name,
DisplayName: s.DisplayName,
Description: s.Description,
RequiredEnv: s.RequiredEnv,
RequiredBins: s.RequiredBins,
EgressDomains: s.EgressDomains,
})
}
}

if opts.APIKey == "" && (opts.ModelProvider == "openai" || opts.ModelProvider == "anthropic" || opts.ModelProvider == "gemini") {
for {
opts.APIKey, err = askPassword(fmt.Sprintf("%s API key", titleCase(opts.ModelProvider)))
if err != nil {
return err
}
if opts.APIKey == "" {
fmt.Println(" Skipping API key validation.")
break
}

fmt.Print(" Validating API key... ")
if valErr := validateProviderKey(opts.ModelProvider, opts.APIKey); valErr != nil {
fmt.Printf("FAILED: %s\n", valErr)
retry, _ := askConfirm("Retry with a different key?")
if !retry {
fmt.Println(" Continuing without validation.")
break
}
continue
}
fmt.Println("OK")
break
// Build the egress derivation callback (avoids circular import)
deriveEgressFn := func(provider string, channels, tools, skills []string) []string {
tmpOpts := &initOptions{
ModelProvider: provider,
Channels: channels,
BuiltinTools: tools,
EnvVars: make(map[string]string),
}
selectedInfos := lookupSelectedSkills(skills)
return deriveEgressDomains(tmpOpts, selectedInfos)
}

if opts.ModelProvider == "ollama" {
fmt.Print(" Checking Ollama connectivity... ")
if valErr := validateProviderKey("ollama", ""); valErr != nil {
fmt.Printf("WARNING: %s\n", valErr)
} else {
fmt.Println("OK")
}
// Build validation callbacks
validateKeyFn := func(provider, key string) error {
return validateProviderKey(provider, key)
}
validatePerpFn := func(key string) error {
return validatePerplexityKey(key)
}

if opts.ModelProvider == "custom" {
baseURL, urlErr := askText("Base URL (e.g. http://localhost:11434/v1)", "")
if urlErr != nil {
return urlErr
}
if baseURL != "" {
opts.EnvVars["MODEL_BASE_URL"] = baseURL
}
modelName, modErr := askText("Model name", "default")
if modErr != nil {
return modErr
}
opts.CustomModel = modelName

needsAuth, _ := askConfirm("Does this endpoint require an auth header?")
if needsAuth {
key, keyErr := askPassword("API key or auth token")
if keyErr != nil {
return keyErr
}
if key != "" {
opts.EnvVars["MODEL_API_KEY"] = key
}
}
// Build step list
wizardSteps := []tui.Step{
steps.NewNameStep(styles, opts.Name),
steps.NewProviderStep(styles, validateKeyFn),
steps.NewChannelStep(styles),
steps.NewToolsStep(styles, toolInfos, validatePerpFn),
steps.NewSkillsStep(styles, skillInfos),
steps.NewEgressStep(styles, deriveEgressFn),
steps.NewReviewStep(styles), // scaffold is handled by the caller after collectInteractive returns
}

// Store provider API key
storeProviderEnvVar(opts)
// Create and run the Bubble Tea program
model := tui.NewWizardModel(theme, wizardSteps, appVersion)
p := tea.NewProgram(model, tea.WithAltScreen())

// ── Step 3: Channel Connector (optional) ──
if len(opts.Channels) == 0 {
_, channel, chErr := askSelect("Channel connector", []string{
"none — CLI / API only",
"telegram — easy setup, no public URL needed",
"slack — Socket Mode, no public URL needed",
})
if chErr != nil {
return chErr
}
channelName := strings.SplitN(channel, " — ", 2)[0]
if channelName != "none" {
opts.Channels = []string{channelName}
}

// Collect channel tokens
if channelName == "telegram" {
fmt.Println("\n Telegram Bot Setup:")
fmt.Println(" 1. Open Telegram, message @BotFather")
fmt.Println(" 2. Send /newbot and follow prompts")
fmt.Println(" 3. Copy the bot token")
token, tokErr := askPassword("Telegram Bot Token")
if tokErr != nil {
return tokErr
}
if token != "" {
opts.EnvVars["TELEGRAM_BOT_TOKEN"] = token
}
}
if channelName == "slack" {
fmt.Println("\n Slack Socket Mode Setup:")
fmt.Println(" 1. Create a Slack App at https://api.slack.com/apps")
fmt.Println(" 2. Enable Socket Mode, generate app-level token")
fmt.Println(" 3. Add bot scopes: chat:write, app_mentions:read")
appToken, appErr := askPassword("Slack App Token (xapp-...)")
if appErr != nil {
return appErr
}
botToken, botErr := askPassword("Slack Bot Token (xoxb-...)")
if botErr != nil {
return botErr
}
if appToken != "" {
opts.EnvVars["SLACK_APP_TOKEN"] = appToken
}
if botToken != "" {
opts.EnvVars["SLACK_BOT_TOKEN"] = botToken
}
}
finalModel, err := p.Run()
if err != nil {
return fmt.Errorf("TUI wizard error: %w", err)
}

// ── Step 4: Builtin Tools ──
if len(opts.BuiltinTools) == 0 {
allTools := builtins.All()
var toolDescriptions []string
for _, t := range allTools {
toolDescriptions = append(toolDescriptions, fmt.Sprintf("%s — %s", t.Name(), t.Description()))
}
fmt.Println("\nBuiltin tools:")
selectedDescs, err := askMultiSelect("Builtin tools", toolDescriptions)
if err != nil {
return err
}
// Extract tool names from "name — description" format
for _, desc := range selectedDescs {
name := strings.SplitN(desc, " — ", 2)[0]
opts.BuiltinTools = append(opts.BuiltinTools, name)
}
wiz, ok := finalModel.(tui.WizardModel)
if !ok {
return fmt.Errorf("unexpected model type from wizard")
}

// If web_search selected, check for Perplexity key
if containsStr(opts.BuiltinTools, "web_search") && os.Getenv("PERPLEXITY_API_KEY") == "" {
if _, exists := opts.EnvVars["PERPLEXITY_API_KEY"]; !exists {
key, err := askPassword("Perplexity API key for web_search")
if err != nil {
return err
}
if key != "" {
fmt.Print(" Validating Perplexity key... ")
if valErr := validatePerplexityKey(key); valErr != nil {
fmt.Printf("FAILED: %s\n", valErr)
fmt.Println(" Key saved anyway — you can fix it later in .env")
} else {
fmt.Println("OK")
}
opts.EnvVars["PERPLEXITY_API_KEY"] = key
}
}
if wiz.Err() != nil {
return wiz.Err()
}

// ── Step 6: External Skills ──
if len(opts.Skills) == 0 {
regSkills, err := skillreg.LoadIndex()
if err != nil {
fmt.Printf(" Warning: could not load skill registry: %s\n", err)
} else if len(regSkills) > 0 {
var skillDescriptions []string
for _, s := range regSkills {
desc := fmt.Sprintf("%s — %s", s.Name, s.Description)
if len(s.RequiredEnv) > 0 {
desc += fmt.Sprintf(" (requires: %s)", strings.Join(s.RequiredEnv, ", "))
}
if len(s.RequiredBins) > 0 {
desc += fmt.Sprintf(" (bins: %s)", strings.Join(s.RequiredBins, ", "))
}
skillDescriptions = append(skillDescriptions, desc)
}
fmt.Println("\nExternal skills (from registry):")
selectedDescs, err := askMultiSelect("External skills", skillDescriptions)
if err != nil {
return err
}
for _, desc := range selectedDescs {
name := strings.SplitN(desc, " — ", 2)[0]
opts.Skills = append(opts.Skills, name)
}
}
// Convert WizardContext → initOptions
ctx := wiz.Context()
opts.Name = ctx.Name

// Default framework and language
if opts.Framework == "" {
opts.Framework = "custom"
}
if opts.Language == "" {
opts.Language = "python"
}

// Check requirements for selected skills
checkSkillRequirements(opts)
opts.ModelProvider = ctx.Provider
opts.APIKey = ctx.APIKey
opts.CustomModel = ctx.CustomModel

// ── Step 7: Egress Review ──
selectedSkillInfos := lookupSelectedSkills(opts.Skills)
egressDomains := deriveEgressDomains(opts, selectedSkillInfos)

if len(egressDomains) > 0 {
fmt.Println("\nComputed egress domains:")
for _, d := range egressDomains {
fmt.Printf(" - %s\n", d)
}
accepted, _ := askConfirm("Accept egress domains?")
if !accepted {
customDomains, err := askText("Additional domains (comma-separated, or empty)", "")
if err != nil {
return err
}
if customDomains != "" {
for _, d := range strings.Split(customDomains, ",") {
d = strings.TrimSpace(d)
if d != "" {
egressDomains = append(egressDomains, d)
}
}
}
}
if ctx.Channel != "" && ctx.Channel != "none" {
opts.Channels = []string{ctx.Channel}
}

// Store computed egress domains for scaffold
opts.EnvVars["__egress_domains"] = strings.Join(egressDomains, ",")
opts.BuiltinTools = ctx.BuiltinTools
opts.Skills = ctx.Skills

// ── Step 8: Review + Generate ──
fmt.Println("\n=== Project Summary ===")
fmt.Printf(" Name: %s\n", opts.Name)
fmt.Printf(" Provider: %s\n", opts.ModelProvider)
if len(opts.Channels) > 0 {
fmt.Printf(" Channels: %s\n", strings.Join(opts.Channels, ", "))
// Store provider env var
storeProviderEnvVar(opts)

// Copy channel tokens
for k, v := range ctx.ChannelTokens {
opts.EnvVars[k] = v
}
if len(opts.BuiltinTools) > 0 {
fmt.Printf(" Builtin tools: %s\n", strings.Join(opts.BuiltinTools, ", "))

// Copy other env vars from wizard
for k, v := range ctx.EnvVars {
opts.EnvVars[k] = v
}
if len(opts.Skills) > 0 {
fmt.Printf(" Skills: %s\n", strings.Join(opts.Skills, ", "))

// Custom provider env vars
if ctx.CustomBaseURL != "" {
opts.EnvVars["MODEL_BASE_URL"] = ctx.CustomBaseURL
}
if len(egressDomains) > 0 {
fmt.Printf(" Egress: %d domains\n", len(egressDomains))
if ctx.CustomAPIKey != "" {
opts.EnvVars["MODEL_API_KEY"] = ctx.CustomAPIKey
}

confirmed, _ := askConfirm("Create Agent?")
if !confirmed {
return fmt.Errorf("agent creation cancelled")
// Store egress domains
if len(ctx.EgressDomains) > 0 {
opts.EnvVars["__egress_domains"] = strings.Join(ctx.EgressDomains, ",")
}

// Check skill requirements
checkSkillRequirements(opts)

return nil
}

Expand Down
Loading
Loading