diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e28305f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,34 @@ +# Forge - CLAUDE.md + +## Project Structure + +Multi-module Go workspace with three modules: +- `forge-core/` — Core library (registry, tools, security, channels, LLM) +- `forge-cli/` — CLI commands, TUI wizard, runtime +- `forge-plugins/` — Channel plugins (telegram, slack), markdown converter + +## Pre-Commit Requirements + +**Always run before committing:** + +```sh +# Format all modules +gofmt -w forge-core/ forge-cli/ forge-plugins/ + +# Lint all modules +golangci-lint run ./forge-core/... +golangci-lint run ./forge-cli/... +golangci-lint run ./forge-plugins/... +``` + +Fix any lint errors and formatting issues before creating commits. + +## Testing + +Run tests for affected modules before committing: + +```sh +cd forge-core && go test ./... +cd forge-cli && go test ./... +cd forge-plugins && go test ./... +``` diff --git a/forge-cli/cmd/init.go b/forge-cli/cmd/init.go index cd090e1..65486d1 100644 --- a/forge-cli/cmd/init.go +++ b/forge-cli/cmd/init.go @@ -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" @@ -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) @@ -154,269 +163,141 @@ 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, + OneOfEnv: s.OneOfEnv, + OptionalEnv: s.OptionalEnv, + 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, envVars map[string]string) []string { + tmpOpts := &initOptions{ + ModelProvider: provider, + Channels: channels, + BuiltinTools: tools, + EnvVars: envVars, } + 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 callback + validateKeyFn := func(provider, key string) error { + return validateProviderKey(provider, 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 + // Build web search key validation callback + validateWebSearchKeyFn := func(provider, key string) error { + return validateWebSearchKey(provider, key) + } - 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, validateWebSearchKeyFn), + 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 } @@ -664,6 +545,19 @@ func scaffold(opts *initOptions) error { if err := os.WriteFile(skillPath, content, 0o644); err != nil { return fmt.Errorf("writing skill file %s: %w", skillName, err) } + + // Vendor script if the skill has one + if skillreg.HasSkillScript(skillName) { + scriptContent, sErr := skillreg.LoadSkillScript(skillName) + if sErr == nil { + scriptDir := filepath.Join(dir, "skills", "scripts") + _ = os.MkdirAll(scriptDir, 0o755) + scriptPath := filepath.Join(scriptDir, skillName+".sh") + if wErr := os.WriteFile(scriptPath, scriptContent, 0o755); wErr != nil { + fmt.Printf("Warning: could not write script for %q: %s\n", skillName, wErr) + } + } + } } fmt.Printf("\nCreated agent project in ./%s\n", opts.AgentID) @@ -877,13 +771,24 @@ func buildEnvVars(opts *initOptions) []envVarEntry { vars = append(vars, envVarEntry{Key: "MODEL_API_KEY", Value: apiKeyVal, Comment: "Model provider API key"}) } - // Perplexity key if web_search selected + // Web search provider key if web_search selected if containsStr(opts.BuiltinTools, "web_search") { - val := opts.EnvVars["PERPLEXITY_API_KEY"] - if val == "" { - val = "your-perplexity-key-here" + provider := opts.EnvVars["WEB_SEARCH_PROVIDER"] + if provider == "perplexity" { + val := opts.EnvVars["PERPLEXITY_API_KEY"] + if val == "" { + val = "your-perplexity-key-here" + } + vars = append(vars, envVarEntry{Key: "PERPLEXITY_API_KEY", Value: val, Comment: "Perplexity API key for web_search"}) + vars = append(vars, envVarEntry{Key: "WEB_SEARCH_PROVIDER", Value: "perplexity", Comment: "Web search provider"}) + } else { + // Default to Tavily + val := opts.EnvVars["TAVILY_API_KEY"] + if val == "" { + val = "your-tavily-key-here" + } + vars = append(vars, envVarEntry{Key: "TAVILY_API_KEY", Value: val, Comment: "Tavily API key for web_search"}) } - vars = append(vars, envVarEntry{Key: "PERPLEXITY_API_KEY", Value: val, Comment: "Perplexity API key for web_search"}) } // Channel env vars @@ -900,21 +805,30 @@ func buildEnvVars(opts *initOptions) []envVarEntry { } } - // Skill env vars + // Skill env vars (skip keys already added above) + written := make(map[string]bool) + for _, v := range vars { + written[v.Key] = true + } for _, skillName := range opts.Skills { info := skillreg.GetSkillByName(skillName) if info == nil { continue } for _, env := range info.RequiredEnv { - val := opts.EnvVars[env] - if val == "" { - val = "" + if written[env] { + continue } + written[env] = true + val := opts.EnvVars[env] vars = append(vars, envVarEntry{Key: env, Value: val, Comment: fmt.Sprintf("Required by %s skill", skillName)}) } if len(info.OneOfEnv) > 0 { for _, env := range info.OneOfEnv { + if written[env] { + continue + } + written[env] = true val := opts.EnvVars[env] vars = append(vars, envVarEntry{ Key: env, diff --git a/forge-cli/cmd/init_egress.go b/forge-cli/cmd/init_egress.go index 6497a6c..2255759 100644 --- a/forge-cli/cmd/init_egress.go +++ b/forge-cli/cmd/init_egress.go @@ -38,9 +38,21 @@ func deriveEgressDomains(opts *initOptions, skills []skillreg.SkillInfo) []strin add(d) } - // 3. Tool domains - for _, d := range security.InferToolDomains(opts.BuiltinTools) { - add(d) + // 3. Tool domains (web_search filtered by provider) + for _, toolName := range opts.BuiltinTools { + if toolName == "web_search" || toolName == "web-search" { + provider := opts.EnvVars["WEB_SEARCH_PROVIDER"] + switch provider { + case "perplexity": + add("api.perplexity.ai") + default: + add("api.tavily.com") + } + continue + } + for _, d := range security.DefaultToolDomains[toolName] { + add(d) + } } // 4. Skill domains diff --git a/forge-cli/cmd/init_prompt.go b/forge-cli/cmd/init_prompt.go deleted file mode 100644 index 3bd501d..0000000 --- a/forge-cli/cmd/init_prompt.go +++ /dev/null @@ -1,80 +0,0 @@ -package cmd - -import ( - "fmt" - "strings" - - "github.com/manifoldco/promptui" -) - -// askText prompts the user for a text value with an optional default. -func askText(label, defaultVal string) (string, error) { - p := promptui.Prompt{ - Label: label, - Default: defaultVal, - } - result, err := p.Run() - if err != nil { - return "", fmt.Errorf("prompt %q failed: %w", label, err) - } - return result, nil -} - -// askSelect presents a list of items and returns the selected index and value. -func askSelect(label string, items []string) (int, string, error) { - s := promptui.Select{ - Label: label, - Items: items, - } - idx, val, err := s.Run() - if err != nil { - return -1, "", fmt.Errorf("prompt %q failed: %w", label, err) - } - return idx, val, nil -} - -// askMultiSelect lets the user confirm each item individually, returning selected items. -func askMultiSelect(label string, items []string) ([]string, error) { - fmt.Printf("%s (confirm each):\n", label) - var selected []string - for _, item := range items { - p := promptui.Prompt{ - Label: fmt.Sprintf(" Include %s", item), - IsConfirm: true, - } - if _, err := p.Run(); err == nil { - selected = append(selected, item) - } - } - return selected, nil -} - -// askConfirm asks for a yes/no confirmation. -func askConfirm(label string) (bool, error) { - p := promptui.Prompt{ - Label: label, - IsConfirm: true, - } - _, err := p.Run() - if err != nil { - // promptui returns an error for "No" — distinguish from real errors - if strings.Contains(err.Error(), "^C") || err == promptui.ErrAbort { - return false, fmt.Errorf("prompt aborted") - } - return false, nil - } - return true, nil -} - -// askPassword prompts for a secret value with character masking. -func askPassword(label string) (string, error) { - p := promptui.Prompt{ - Label: label, - Mask: '*', - } - result, err := p.Run() - if err != nil { - return "", fmt.Errorf("prompt %q failed: %w", label, err) - } - return result, nil -} diff --git a/forge-cli/cmd/init_test.go b/forge-cli/cmd/init_test.go index 85a7f9c..3e0fe9c 100644 --- a/forge-cli/cmd/init_test.go +++ b/forge-cli/cmd/init_test.go @@ -455,8 +455,8 @@ func TestScaffold_EgressInForgeYAML(t *testing.T) { if !strings.Contains(yamlStr, "api.openai.com") { t.Error("forge.yaml missing api.openai.com in egress domains") } - if !strings.Contains(yamlStr, "api.perplexity.ai") { - t.Error("forge.yaml missing api.perplexity.ai in egress domains") + if !strings.Contains(yamlStr, "api.tavily.com") { + t.Error("forge.yaml missing api.tavily.com in egress domains") } } @@ -504,13 +504,13 @@ func TestDeriveEgressDomains(t *testing.T) { domains := deriveEgressDomains(opts, skillInfos) expected := map[string]bool{ - "api.openai.com": true, - "slack.com": true, - "hooks.slack.com": true, - "api.slack.com": true, - "api.perplexity.ai": true, - "api.github.com": true, - "github.com": true, + "api.openai.com": true, + "slack.com": true, + "hooks.slack.com": true, + "api.slack.com": true, + "api.tavily.com": true, + "api.github.com": true, + "github.com": true, } for _, d := range domains { if !expected[d] { @@ -550,8 +550,8 @@ func TestBuildEnvVars(t *testing.T) { if !found["OPENAI_API_KEY"] { t.Error("missing OPENAI_API_KEY") } - if !found["PERPLEXITY_API_KEY"] { - t.Error("missing PERPLEXITY_API_KEY") + if !found["TAVILY_API_KEY"] { + t.Error("missing TAVILY_API_KEY") } if !found["GH_TOKEN"] { t.Error("missing GH_TOKEN") diff --git a/forge-cli/cmd/init_validate.go b/forge-cli/cmd/init_validate.go index f814ceb..3fbb2b6 100644 --- a/forge-cli/cmd/init_validate.go +++ b/forge-cli/cmd/init_validate.go @@ -17,6 +17,7 @@ var ( anthropicValidationURL = "https://api.anthropic.com/v1/messages" geminiValidationURL = "https://generativelanguage.googleapis.com/v1beta/models" ollamaValidationURL = "http://localhost:11434/api/tags" + tavilyValidationURL = "https://api.tavily.com/search" perplexityValidationURL = "https://api.perplexity.ai/chat/completions" ) @@ -140,6 +141,52 @@ func validateOllamaConnection(ctx context.Context) error { return nil } +// validateWebSearchKey validates a web search API key based on the provider. +func validateWebSearchKey(provider, apiKey string) error { + switch provider { + case "tavily": + return validateTavilyKey(apiKey) + case "perplexity": + return validatePerplexityKey(apiKey) + default: + return fmt.Errorf("unknown web search provider %q", provider) + } +} + +// validateTavilyKey validates a Tavily API key with a minimal search request. +func validateTavilyKey(apiKey string) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + body := map[string]any{ + "query": "test", + "max_results": 1, + } + bodyBytes, _ := json.Marshal(body) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tavilyValidationURL, bytes.NewReader(bodyBytes)) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("connecting to Tavily: %w", err) + } + defer func() { _ = resp.Body.Close() }() + _, _ = io.Copy(io.Discard, resp.Body) + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return fmt.Errorf("invalid Tavily API key (%d)", resp.StatusCode) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("tavily API returned status %d", resp.StatusCode) + } + return nil +} + // validatePerplexityKey validates a Perplexity API key with a minimal request. func validatePerplexityKey(apiKey string) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) diff --git a/forge-cli/cmd/init_validate_test.go b/forge-cli/cmd/init_validate_test.go index da707d5..d87cdfb 100644 --- a/forge-cli/cmd/init_validate_test.go +++ b/forge-cli/cmd/init_validate_test.go @@ -129,6 +129,46 @@ func TestValidateProviderKey_Timeout(t *testing.T) { } } +func TestValidateTavilyKey_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer valid-key" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"query":"test","results":[]}`)) + })) + defer server.Close() + + orig := tavilyValidationURL + tavilyValidationURL = server.URL + defer func() { tavilyValidationURL = orig }() + + err := validateWebSearchKey("tavily", "valid-key") + if err != nil { + t.Fatalf("expected nil error, got: %v", err) + } +} + +func TestValidateTavilyKey_Unauthorized(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + orig := tavilyValidationURL + tavilyValidationURL = server.URL + defer func() { tavilyValidationURL = orig }() + + err := validateWebSearchKey("tavily", "bad-key") + if err == nil { + t.Fatal("expected error for unauthorized key") + } + if !strings.Contains(err.Error(), "invalid") { + t.Errorf("expected error containing 'invalid', got: %v", err) + } +} + func TestValidatePerplexityKey_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "Bearer valid-key" { @@ -144,7 +184,7 @@ func TestValidatePerplexityKey_Success(t *testing.T) { perplexityValidationURL = server.URL defer func() { perplexityValidationURL = orig }() - err := validatePerplexityKey("valid-key") + err := validateWebSearchKey("perplexity", "valid-key") if err != nil { t.Fatalf("expected nil error, got: %v", err) } @@ -160,7 +200,7 @@ func TestValidatePerplexityKey_Unauthorized(t *testing.T) { perplexityValidationURL = server.URL defer func() { perplexityValidationURL = orig }() - err := validatePerplexityKey("bad-key") + err := validateWebSearchKey("perplexity", "bad-key") if err == nil { t.Fatal("expected error for unauthorized key") } diff --git a/forge-cli/cmd/root.go b/forge-cli/cmd/root.go index c8b144f..1648f80 100644 --- a/forge-cli/cmd/root.go +++ b/forge-cli/cmd/root.go @@ -9,9 +9,10 @@ import ( ) var ( - cfgFile string - verbose bool - outputDir string + cfgFile string + verbose bool + outputDir string + themeOverride string appVersion = "dev" ) @@ -26,6 +27,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "forge.yaml", "config file path") rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "enable verbose output") rootCmd.PersistentFlags().StringVarP(&outputDir, "output-dir", "o", ".", "output directory") + rootCmd.PersistentFlags().StringVar(&themeOverride, "theme", "", "TUI color theme: dark, light, or auto") rootCmd.AddCommand(initCmd) rootCmd.AddCommand(validateCmd) diff --git a/forge-cli/cmd/skills.go b/forge-cli/cmd/skills.go index a1793c1..d651e84 100644 --- a/forge-cli/cmd/skills.go +++ b/forge-cli/cmd/skills.go @@ -1,13 +1,16 @@ package cmd import ( + "bufio" "fmt" "os" + "os/exec" "path/filepath" "strings" "github.com/initializ/forge/forge-cli/config" cliskills "github.com/initializ/forge/forge-cli/skills" + skillreg "github.com/initializ/forge/forge-core/registry" coreskills "github.com/initializ/forge/forge-core/skills" "github.com/spf13/cobra" ) @@ -23,8 +26,115 @@ var skillsValidateCmd = &cobra.Command{ RunE: runSkillsValidate, } +var skillsAddCmd = &cobra.Command{ + Use: "add ", + Short: "Add a registry skill to the current project", + Args: cobra.ExactArgs(1), + RunE: runSkillsAdd, +} + func init() { skillsCmd.AddCommand(skillsValidateCmd) + skillsCmd.AddCommand(skillsAddCmd) +} + +func runSkillsAdd(cmd *cobra.Command, args []string) error { + name := args[0] + + // Look up skill in registry + info := skillreg.GetSkillByName(name) + if info == nil { + return fmt.Errorf("skill %q not found in registry", name) + } + + wd, err := os.Getwd() + if err != nil { + return fmt.Errorf("getting working directory: %w", err) + } + + // Write skill markdown + skillDir := filepath.Join(wd, "skills") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + return fmt.Errorf("creating skills directory: %w", err) + } + + content, err := skillreg.LoadSkillFile(name) + if err != nil { + return fmt.Errorf("loading skill file: %w", err) + } + + skillPath := filepath.Join(skillDir, name+".md") + if err := os.WriteFile(skillPath, content, 0o644); err != nil { + return fmt.Errorf("writing skill file: %w", err) + } + fmt.Printf(" Added skill file: skills/%s.md\n", name) + + // Write script if the skill has one + if skillreg.HasSkillScript(name) { + scriptContent, sErr := skillreg.LoadSkillScript(name) + if sErr == nil { + scriptDir := filepath.Join(skillDir, "scripts") + if mkErr := os.MkdirAll(scriptDir, 0o755); mkErr != nil { + fmt.Printf(" Warning: could not create scripts directory: %s\n", mkErr) + } else { + scriptPath := filepath.Join(scriptDir, name+".sh") + if wErr := os.WriteFile(scriptPath, scriptContent, 0o755); wErr != nil { + fmt.Printf(" Warning: could not write script: %s\n", wErr) + } else { + fmt.Printf(" Added script: skills/scripts/%s.sh\n", name) + } + } + } + } + + // Check binary requirements + if len(info.RequiredBins) > 0 { + fmt.Println("\n Binary requirements:") + for _, bin := range info.RequiredBins { + if _, lookErr := exec.LookPath(bin); lookErr != nil { + fmt.Printf(" %s — MISSING (not found in PATH)\n", bin) + } else { + fmt.Printf(" %s — ok\n", bin) + } + } + } + + // Check env var requirements + missingEnvs := []string{} + if len(info.RequiredEnv) > 0 { + fmt.Println("\n Environment requirements:") + for _, env := range info.RequiredEnv { + if os.Getenv(env) == "" { + fmt.Printf(" %s — NOT SET\n", env) + missingEnvs = append(missingEnvs, env) + } else { + fmt.Printf(" %s — ok\n", env) + } + } + } + + // Prompt for missing env vars + if len(missingEnvs) > 0 { + reader := bufio.NewReader(os.Stdin) + for _, env := range missingEnvs { + fmt.Printf("\n Enter value for %s (or press Enter to skip): ", env) + val, _ := reader.ReadString('\n') + val = strings.TrimSpace(val) + if val != "" { + // Append to .env file + envPath := filepath.Join(wd, ".env") + f, fErr := os.OpenFile(envPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if fErr == nil { + _, _ = fmt.Fprintf(f, "# Required by %s skill\n%s=%s\n", name, env, val) + _ = f.Close() + fmt.Printf(" Added %s to .env\n", env) + } + } + } + } + + fmt.Printf("\nSkill %q added successfully.\n", info.DisplayName) + return nil } func runSkillsValidate(cmd *cobra.Command, args []string) error { diff --git a/forge-cli/go.mod b/forge-cli/go.mod index 40cb49b..79b5582 100644 --- a/forge-cli/go.mod +++ b/forge-cli/go.mod @@ -3,21 +3,43 @@ module github.com/initializ/forge/forge-cli go 1.25.0 require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/initializ/forge/forge-core v0.0.0 github.com/initializ/forge/forge-plugins v0.0.0 - github.com/manifoldco/promptui v0.9.0 github.com/spf13/cobra v1.10.2 + golang.org/x/term v0.40.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect - golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.3.8 // indirect ) replace ( diff --git a/forge-cli/go.sum b/forge-cli/go.sum index d1ac988..58b539d 100644 --- a/forge-cli/go.sum +++ b/forge-cli/go.sum @@ -1,18 +1,52 @@ -github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= -github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= @@ -27,9 +61,19 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b h1:MQE+LT/ABUuuvEZ+YQAMSXindAdUh7slEmAkup74op4= -golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/forge-cli/internal/tui/banner.go b/forge-cli/internal/tui/banner.go new file mode 100644 index 0000000..bc84b62 --- /dev/null +++ b/forge-cli/internal/tui/banner.go @@ -0,0 +1,31 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// RenderBanner returns the branded header for the wizard. +func RenderBanner(styles *StyleSet, version string, width int) string { + if version == "" { + version = "dev" + } + + forge := styles.Banner.Render("⚒ F O R G E") + " " + styles.VersionPill.Render("v"+version) + subtitle := styles.Subtitle.Render("Turn a SKILL.md into a portable, secure, runnable AI agent.") + + dividerWidth := width - 4 + if dividerWidth < 20 { + dividerWidth = 20 + } + if dividerWidth > 60 { + dividerWidth = 60 + } + divider := lipgloss.NewStyle(). + Foreground(styles.Theme.Border). + Render(strings.Repeat("─", dividerWidth)) + + return fmt.Sprintf(" %s\n %s\n %s\n\n", forge, subtitle, divider) +} diff --git a/forge-cli/internal/tui/components/egress_display.go b/forge-cli/internal/tui/components/egress_display.go new file mode 100644 index 0000000..5d03dcf --- /dev/null +++ b/forge-cli/internal/tui/components/egress_display.go @@ -0,0 +1,101 @@ +package components + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// EgressDomain represents a domain with its source annotation. +type EgressDomain struct { + Domain string + Source string // e.g., "model provider", "channel", "tool", "skill" +} + +// EgressDisplay shows a read-only list of egress domains. +type EgressDisplay struct { + Domains []EgressDomain + done bool + + // Styles + PrimaryStyle lipgloss.Style + DimStyle lipgloss.Style + BorderStyle lipgloss.Style + AccentStyle lipgloss.Style + SecondaryStyle lipgloss.Style + kbd KbdHint +} + +// NewEgressDisplay creates a new egress domain display. +func NewEgressDisplay(domains []EgressDomain, primaryStyle, dimStyle, borderStyle, accentStyle, secondaryStyle lipgloss.Style, kbdKeyStyle, kbdDescStyle lipgloss.Style) EgressDisplay { + kbd := NewKbdHint(kbdKeyStyle, kbdDescStyle) + kbd.Bindings = []KeyBinding{ + {Key: "⏎", Desc: "accept"}, + {Key: "backspace", Desc: "back"}, + {Key: "esc", Desc: "quit"}, + } + + return EgressDisplay{ + Domains: domains, + PrimaryStyle: primaryStyle, + DimStyle: dimStyle, + BorderStyle: borderStyle, + AccentStyle: accentStyle, + SecondaryStyle: secondaryStyle, + kbd: kbd, + } +} + +// Init resets done state so the component can be re-used after back-navigation. +func (e *EgressDisplay) Init() tea.Cmd { + e.done = false + return nil +} + +// Update handles keyboard input. +func (e EgressDisplay) Update(msg tea.Msg) (EgressDisplay, tea.Cmd) { + if e.done { + return e, nil + } + + if msg, ok := msg.(tea.KeyMsg); ok { + switch msg.String() { + case "enter": + e.done = true + } + } + + return e, nil +} + +// View renders the egress domain list. +func (e EgressDisplay) View(width int) string { + var out string + + header := e.AccentStyle.Render(fmt.Sprintf(" Network Egress · restricted · %d domains", len(e.Domains))) + out += header + "\n\n" + + boxWidth := width - 8 + if boxWidth < 30 { + boxWidth = 30 + } + + var content string + for _, d := range e.Domains { + domain := e.PrimaryStyle.Render(d.Domain) + source := e.DimStyle.Render(fmt.Sprintf(" ← %s", d.Source)) + content += fmt.Sprintf(" %s%s\n", domain, source) + } + + box := e.BorderStyle.Width(boxWidth).Render(content) + out += " " + box + "\n" + + out += "\n" + e.kbd.View() + return out +} + +// Done returns true when the user has accepted. +func (e EgressDisplay) Done() bool { + return e.done +} diff --git a/forge-cli/internal/tui/components/file_list.go b/forge-cli/internal/tui/components/file_list.go new file mode 100644 index 0000000..42f0aea --- /dev/null +++ b/forge-cli/internal/tui/components/file_list.go @@ -0,0 +1,155 @@ +package components + +import ( + "fmt" + "time" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// FileStatus represents the generation status of a file. +type FileStatus int + +const ( + FilePending FileStatus = iota + FileWriting + FileDone + FileError +) + +// FileEntry represents a file being generated. +type FileEntry struct { + Icon string + Path string + Status FileStatus +} + +// FileList shows progressive file generation with status icons. +type FileList struct { + Files []FileEntry + revealed int // number of files revealed so far + spinner spinner.Model + done bool + + // Styles + PrimaryStyle lipgloss.Style + SuccessStyle lipgloss.Style + ErrorStyle lipgloss.Style + DimStyle lipgloss.Style +} + +// NewFileList creates a new file list display. +func NewFileList(files []FileEntry, primaryStyle, successStyle, errorStyle, dimStyle lipgloss.Style, accentColor lipgloss.Color) FileList { + sp := spinner.New() + sp.Spinner = spinner.Dot + sp.Style = lipgloss.NewStyle().Foreground(accentColor) + + return FileList{ + Files: files, + spinner: sp, + PrimaryStyle: primaryStyle, + SuccessStyle: successStyle, + ErrorStyle: errorStyle, + DimStyle: dimStyle, + } +} + +// Init starts the spinner and file reveal timer. +func (f FileList) Init() tea.Cmd { + return tea.Batch(f.spinner.Tick, f.tickCmd()) +} + +// Update handles messages. +func (f FileList) Update(msg tea.Msg) (FileList, tea.Cmd) { + switch msg := msg.(type) { + case spinner.TickMsg: + var cmd tea.Cmd + f.spinner, cmd = f.spinner.Update(msg) + return f, cmd + case fileRevealMsg: + if f.revealed < len(f.Files) { + f.Files[f.revealed].Status = FileDone + f.revealed++ + if f.revealed < len(f.Files) { + return f, f.tickCmd() + } + f.done = true + } + return f, nil + } + return f, nil +} + +// View renders the file list. +func (f FileList) View(width int) string { + var out string + + for i, file := range f.Files { + if i >= f.revealed && i > 0 && f.Files[i-1].Status != FileDone { + break + } + + var statusIcon string + var pathStyle lipgloss.Style + + switch file.Status { + case FilePending: + if i == f.revealed { + statusIcon = f.spinner.View() + pathStyle = f.PrimaryStyle + } else { + statusIcon = f.DimStyle.Render("·") + pathStyle = f.DimStyle + } + case FileWriting: + statusIcon = f.spinner.View() + pathStyle = f.PrimaryStyle + case FileDone: + statusIcon = f.SuccessStyle.Render("✓") + pathStyle = f.SuccessStyle + case FileError: + statusIcon = f.ErrorStyle.Render("✗") + pathStyle = f.ErrorStyle + } + + icon := file.Icon + if icon == "" { + icon = "📄" + } + + out += fmt.Sprintf(" %s %s %s\n", statusIcon, icon, pathStyle.Render(file.Path)) + } + + return out +} + +// Done returns true when all files have been revealed. +func (f FileList) Done() bool { + return f.done +} + +// MarkFile updates a specific file's status. +func (f *FileList) MarkFile(index int, status FileStatus) { + if index >= 0 && index < len(f.Files) { + f.Files[index].Status = status + } +} + +// RevealAll marks all files as done immediately. +func (f *FileList) RevealAll() { + for i := range f.Files { + f.Files[i].Status = FileDone + } + f.revealed = len(f.Files) + f.done = true +} + +type fileRevealMsg struct{} + +func (f FileList) tickCmd() tea.Cmd { + return tea.Tick(50*time.Millisecond, func(time.Time) tea.Msg { + return fileRevealMsg{} + }) +} diff --git a/forge-cli/internal/tui/components/kbd_hint.go b/forge-cli/internal/tui/components/kbd_hint.go new file mode 100644 index 0000000..eff6848 --- /dev/null +++ b/forge-cli/internal/tui/components/kbd_hint.go @@ -0,0 +1,74 @@ +package components + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// KeyBinding represents a keyboard shortcut hint. +type KeyBinding struct { + Key string + Desc string +} + +// KbdHint renders a horizontal keyboard shortcut hint bar. +type KbdHint struct { + Bindings []KeyBinding + KeyStyle lipgloss.Style + DescStyle lipgloss.Style +} + +// NewKbdHint creates a KbdHint with the given styles. +func NewKbdHint(keyStyle, descStyle lipgloss.Style) KbdHint { + return KbdHint{ + KeyStyle: keyStyle, + DescStyle: descStyle, + } +} + +// View renders the keyboard hints. +func (k KbdHint) View() string { + var parts []string + for _, b := range k.Bindings { + part := k.KeyStyle.Render(b.Key) + " " + k.DescStyle.Render(b.Desc) + parts = append(parts, part) + } + return " " + strings.Join(parts, " ") +} + +// SelectHints returns standard hints for single-select components. +func SelectHints() []KeyBinding { + return []KeyBinding{ + {Key: "↑↓", Desc: "navigate"}, + {Key: "⏎", Desc: "select"}, + {Key: "esc", Desc: "quit"}, + } +} + +// MultiSelectHints returns standard hints for multi-select components. +func MultiSelectHints() []KeyBinding { + return []KeyBinding{ + {Key: "↑↓", Desc: "navigate"}, + {Key: "space", Desc: "toggle"}, + {Key: "⏎", Desc: "confirm"}, + {Key: "esc", Desc: "quit"}, + } +} + +// InputHints returns standard hints for text input components. +func InputHints() []KeyBinding { + return []KeyBinding{ + {Key: "⏎", Desc: "submit"}, + {Key: "esc", Desc: "quit"}, + } +} + +// ReviewHints returns standard hints for the review step. +func ReviewHints() []KeyBinding { + return []KeyBinding{ + {Key: "⏎", Desc: "confirm"}, + {Key: "backspace", Desc: "back"}, + {Key: "esc", Desc: "quit"}, + } +} diff --git a/forge-cli/internal/tui/components/multi_select.go b/forge-cli/internal/tui/components/multi_select.go new file mode 100644 index 0000000..b989b76 --- /dev/null +++ b/forge-cli/internal/tui/components/multi_select.go @@ -0,0 +1,176 @@ +package components + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// MultiSelectItem represents an option in a multi-select list. +type MultiSelectItem struct { + Label string + Value string + Description string + Icon string + RequirementLine string + Checked bool +} + +// MultiSelect is a navigable checkbox list. +type MultiSelect struct { + Items []MultiSelectItem + cursor int + done bool + + // Styles + AccentColor lipgloss.Color + AccentDimColor lipgloss.Color + PrimaryColor lipgloss.Color + SecondaryColor lipgloss.Color + DimColor lipgloss.Color + ActiveBorder lipgloss.Style + InactiveBorder lipgloss.Style + kbd KbdHint +} + +// NewMultiSelect creates a new multi-select component. +func NewMultiSelect(items []MultiSelectItem, accentColor, accentDimColor, primaryColor, secondaryColor, dimColor lipgloss.Color, activeBorder, inactiveBorder lipgloss.Style, kbdKeyStyle, kbdDescStyle lipgloss.Style) MultiSelect { + kbd := NewKbdHint(kbdKeyStyle, kbdDescStyle) + kbd.Bindings = MultiSelectHints() + + return MultiSelect{ + Items: items, + AccentColor: accentColor, + AccentDimColor: accentDimColor, + PrimaryColor: primaryColor, + SecondaryColor: secondaryColor, + DimColor: dimColor, + ActiveBorder: activeBorder, + InactiveBorder: inactiveBorder, + kbd: kbd, + } +} + +// Init resets done state so the component can be re-used after back-navigation. +func (m *MultiSelect) Init() tea.Cmd { + m.done = false + return nil +} + +// Update handles keyboard input. +func (m MultiSelect) Update(msg tea.Msg) (MultiSelect, tea.Cmd) { + if m.done { + return m, nil + } + + if msg, ok := msg.(tea.KeyMsg); ok { + switch msg.String() { + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.Items)-1 { + m.cursor++ + } + case " ": + m.Items[m.cursor].Checked = !m.Items[m.cursor].Checked + case "enter": + m.done = true + } + } + + return m, nil +} + +// View renders the multi-select list. +func (m MultiSelect) View(width int) string { + var out string + + itemWidth := width - 6 + if itemWidth < 30 { + itemWidth = 30 + } + + for i, item := range m.Items { + isCursor := i == m.cursor + var checkbox, icon, label, desc string + + icon = item.Icon + " " + + if item.Checked { + checkbox = lipgloss.NewStyle().Foreground(m.AccentColor).Render("☑") + } else { + checkbox = lipgloss.NewStyle().Foreground(m.DimColor).Render("☐") + } + + if isCursor { + label = lipgloss.NewStyle().Foreground(m.PrimaryColor).Bold(true).Render(item.Label) + if item.Description != "" { + desc += "\n " + lipgloss.NewStyle().Foreground(m.SecondaryColor).Render(item.Description) + } + if item.RequirementLine != "" { + desc += "\n " + lipgloss.NewStyle().Foreground(m.AccentDimColor).Render("⚡ "+item.RequirementLine) + } + } else { + label = lipgloss.NewStyle().Foreground(m.SecondaryColor).Render(item.Label) + } + + firstLine := fmt.Sprintf(" %s%s", icon, label) + firstLineWidth := lipgloss.Width(firstLine) + padding := itemWidth - firstLineWidth - 4 + if padding < 1 { + padding = 1 + } + content := firstLine + strings.Repeat(" ", padding) + checkbox + if desc != "" { + content += desc + } + + var border lipgloss.Style + if isCursor { + border = m.ActiveBorder.Width(itemWidth) + } else { + border = m.InactiveBorder.Width(itemWidth) + } + + out += " " + border.Render(content) + "\n" + } + + out += "\n" + m.kbd.View() + return out +} + +// Done returns true when selection is confirmed. +func (m MultiSelect) Done() bool { + return m.done +} + +// Reset clears the done state so the user can re-select. +func (m *MultiSelect) Reset() { + m.done = false +} + +// SelectedValues returns the values of all checked items. +func (m MultiSelect) SelectedValues() []string { + var vals []string + for _, item := range m.Items { + if item.Checked { + vals = append(vals, item.Value) + } + } + return vals +} + +// SelectedLabels returns the labels of all checked items. +func (m MultiSelect) SelectedLabels() []string { + var labels []string + for _, item := range m.Items { + if item.Checked { + labels = append(labels, item.Label) + } + } + return labels +} diff --git a/forge-cli/internal/tui/components/secret_input.go b/forge-cli/internal/tui/components/secret_input.go new file mode 100644 index 0000000..394c2e2 --- /dev/null +++ b/forge-cli/internal/tui/components/secret_input.go @@ -0,0 +1,178 @@ +package components + +import ( + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// SecretInputState tracks the validation state of a secret input. +type SecretInputState int + +const ( + SecretInputEditing SecretInputState = iota + SecretInputValidated // validation succeeded + SecretInputFailed // validation failed +) + +// SecretInput is a masked text entry with validation feedback. +type SecretInput struct { + Label string + input textinput.Model + done bool + state SecretInputState + err string + allowSkip bool + + // Styles + LabelStyle lipgloss.Style + BorderStyle lipgloss.Style + SuccessStyle lipgloss.Style + ErrorStyle lipgloss.Style + HintStyle lipgloss.Style + AccentColor lipgloss.Color + SuccessColor lipgloss.Color + ErrorColor lipgloss.Color + BorderColor lipgloss.Color + kbd KbdHint +} + +// NewSecretInput creates a new masked input component. +func NewSecretInput(label string, allowSkip bool, accentColor, successColor, errorColor, borderColor lipgloss.Color, labelStyle, borderStyle, successStyle, errorStyle, hintStyle lipgloss.Style, kbdKeyStyle, kbdDescStyle lipgloss.Style) SecretInput { + ti := textinput.New() + ti.Placeholder = "paste key here" + ti.EchoMode = textinput.EchoPassword + ti.EchoCharacter = '•' + ti.Focus() + ti.CharLimit = 200 + ti.Cursor.Style = lipgloss.NewStyle().Foreground(accentColor) + + hints := InputHints() + if allowSkip { + hints = append(hints, KeyBinding{Key: "⏎", Desc: "(empty) skip"}) + } + + kbd := NewKbdHint(kbdKeyStyle, kbdDescStyle) + kbd.Bindings = hints + + return SecretInput{ + Label: label, + input: ti, + allowSkip: allowSkip, + state: SecretInputEditing, + LabelStyle: labelStyle, + BorderStyle: borderStyle, + SuccessStyle: successStyle, + ErrorStyle: errorStyle, + HintStyle: hintStyle, + AccentColor: accentColor, + SuccessColor: successColor, + ErrorColor: errorColor, + BorderColor: borderColor, + kbd: kbd, + } +} + +// Init focuses the input. +func (s SecretInput) Init() tea.Cmd { + return textinput.Blink +} + +// Update handles messages. +func (s SecretInput) Update(msg tea.Msg) (SecretInput, tea.Cmd) { + if s.done { + return s, nil + } + + if msg, ok := msg.(tea.KeyMsg); ok { + switch msg.String() { + case "enter": + val := strings.TrimSpace(s.input.Value()) + if val == "" && s.allowSkip { + s.done = true + s.state = SecretInputValidated + return s, nil + } + if val == "" { + s.err = "key is required" + return s, nil + } + s.done = true + s.err = "" + return s, nil + } + } + + var cmd tea.Cmd + s.input, cmd = s.input.Update(msg) + s.err = "" + return s, cmd +} + +// View renders the secret input. +func (s SecretInput) View(width int) string { + var out string + + out += "\n " + s.LabelStyle.Render(s.Label) + "\n\n" + + inputWidth := width - 8 + if inputWidth < 20 { + inputWidth = 20 + } + s.input.Width = inputWidth + + // Determine border style based on state + var borderStyle lipgloss.Style + switch s.state { + case SecretInputValidated: + borderStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(s.SuccessColor). + Padding(0, 1) + case SecretInputFailed: + borderStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(s.ErrorColor). + Padding(0, 1) + default: + borderStyle = s.BorderStyle + } + + inputBox := borderStyle.Width(inputWidth).Render(s.input.View()) + out += " " + inputBox + "\n" + + // Status messages + switch s.state { + case SecretInputValidated: + out += " " + s.SuccessStyle.Render("✓ Key validated") + "\n" + case SecretInputFailed: + if s.err != "" { + out += " " + s.ErrorStyle.Render("✗ "+s.err) + "\n" + } + default: + if s.err != "" { + out += " " + s.ErrorStyle.Render("✗ "+s.err) + "\n" + } + } + + out += "\n" + s.kbd.View() + return out +} + +// Done returns true when input is submitted. +func (s SecretInput) Done() bool { + return s.done +} + +// Value returns the current input value. +func (s SecretInput) Value() string { + return strings.TrimSpace(s.input.Value()) +} + +// SetState updates the validation state and optional error. +func (s *SecretInput) SetState(state SecretInputState, errMsg string) { + s.state = state + s.err = errMsg +} diff --git a/forge-cli/internal/tui/components/single_select.go b/forge-cli/internal/tui/components/single_select.go new file mode 100644 index 0000000..39b3d04 --- /dev/null +++ b/forge-cli/internal/tui/components/single_select.go @@ -0,0 +1,168 @@ +package components + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// SingleSelectItem represents an option in a single-select list. +type SingleSelectItem struct { + Label string + Value string + Description string + Icon string +} + +// SingleSelect is a navigable radio-button list. +type SingleSelect struct { + Items []SingleSelectItem + cursor int + selected int + done bool + + // Styles + ActiveBorder lipgloss.Style + InactiveBorder lipgloss.Style + ActiveBg lipgloss.Color + AccentColor lipgloss.Color + PrimaryColor lipgloss.Color + SecondaryColor lipgloss.Color + DimColor lipgloss.Color + kbd KbdHint +} + +// NewSingleSelect creates a new single-select component. +func NewSingleSelect(items []SingleSelectItem, accentColor, primaryColor, secondaryColor, dimColor lipgloss.Color, borderColor, activeBorderColor lipgloss.Color, activeBg lipgloss.Color, kbdKeyStyle, kbdDescStyle lipgloss.Style) SingleSelect { + kbd := NewKbdHint(kbdKeyStyle, kbdDescStyle) + kbd.Bindings = SelectHints() + + return SingleSelect{ + Items: items, + selected: -1, + AccentColor: accentColor, + PrimaryColor: primaryColor, + SecondaryColor: secondaryColor, + DimColor: dimColor, + ActiveBg: activeBg, + ActiveBorder: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(activeBorderColor). + Padding(0, 1), + InactiveBorder: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Padding(0, 1), + kbd: kbd, + } +} + +// Init resets done state so the component can be re-used after back-navigation. +func (s *SingleSelect) Init() tea.Cmd { + s.done = false + return nil +} + +// Update handles keyboard input. +func (s SingleSelect) Update(msg tea.Msg) (SingleSelect, tea.Cmd) { + if s.done { + return s, nil + } + + if msg, ok := msg.(tea.KeyMsg); ok { + switch msg.String() { + case "up", "k": + if s.cursor > 0 { + s.cursor-- + } + case "down", "j": + if s.cursor < len(s.Items)-1 { + s.cursor++ + } + case "enter": + s.selected = s.cursor + s.done = true + } + } + + return s, nil +} + +// View renders the select list. +func (s SingleSelect) View(width int) string { + var out string + + itemWidth := width - 6 + if itemWidth < 30 { + itemWidth = 30 + } + + for i, item := range s.Items { + isCursor := i == s.cursor + var radio, icon, label, desc string + + icon = item.Icon + " " + if isCursor { + radio = lipgloss.NewStyle().Foreground(s.AccentColor).Render("◉") + label = lipgloss.NewStyle().Foreground(s.PrimaryColor).Bold(true).Render(item.Label) + if item.Description != "" { + desc = "\n " + lipgloss.NewStyle().Foreground(s.SecondaryColor).Render(item.Description) + } + } else { + radio = lipgloss.NewStyle().Foreground(s.DimColor).Render("○") + label = lipgloss.NewStyle().Foreground(s.SecondaryColor).Render(item.Label) + } + + firstLine := fmt.Sprintf(" %s%s", icon, label) + firstLineWidth := lipgloss.Width(firstLine) + padding := itemWidth - firstLineWidth - 4 + if padding < 1 { + padding = 1 + } + content := firstLine + strings.Repeat(" ", padding) + radio + if desc != "" { + content += desc + } + + var border lipgloss.Style + if isCursor { + border = s.ActiveBorder.Width(itemWidth) + } else { + border = s.InactiveBorder.Width(itemWidth) + } + + out += " " + border.Render(content) + "\n" + } + + out += "\n" + s.kbd.View() + return out +} + +// Done returns true when a selection has been made. +func (s SingleSelect) Done() bool { + return s.done +} + +// Reset clears the selection so the user can pick again. +func (s *SingleSelect) Reset() { + s.done = false + s.selected = -1 +} + +// Selected returns the index and value of the selected item. +func (s SingleSelect) Selected() (int, string) { + if s.selected >= 0 && s.selected < len(s.Items) { + return s.selected, s.Items[s.selected].Value + } + return -1, "" +} + +// SelectedItem returns the selected item, or nil if none selected. +func (s SingleSelect) SelectedItem() *SingleSelectItem { + if s.selected >= 0 && s.selected < len(s.Items) { + return &s.Items[s.selected] + } + return nil +} diff --git a/forge-cli/internal/tui/components/summary_box.go b/forge-cli/internal/tui/components/summary_box.go new file mode 100644 index 0000000..e391b52 --- /dev/null +++ b/forge-cli/internal/tui/components/summary_box.go @@ -0,0 +1,50 @@ +package components + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +// SummaryRow represents a key-value pair in the summary. +type SummaryRow struct { + Key string + Value string +} + +// SummaryBox renders a 2-column key/value grid in a bordered box. +type SummaryBox struct { + Rows []SummaryRow + + // Styles + KeyStyle lipgloss.Style + ValueStyle lipgloss.Style + BorderStyle lipgloss.Style +} + +// NewSummaryBox creates a new summary box. +func NewSummaryBox(rows []SummaryRow, keyStyle, valueStyle, borderStyle lipgloss.Style) SummaryBox { + return SummaryBox{ + Rows: rows, + KeyStyle: keyStyle, + ValueStyle: valueStyle, + BorderStyle: borderStyle, + } +} + +// View renders the summary box. +func (s SummaryBox) View(width int) string { + boxWidth := width - 8 + if boxWidth < 30 { + boxWidth = 30 + } + + var content string + for _, row := range s.Rows { + key := s.KeyStyle.Width(16).Render(row.Key) + value := s.ValueStyle.Render(row.Value) + content += fmt.Sprintf(" %s %s\n", key, value) + } + + return " " + s.BorderStyle.Width(boxWidth).Render(content) +} diff --git a/forge-cli/internal/tui/components/text_input.go b/forge-cli/internal/tui/components/text_input.go new file mode 100644 index 0000000..77c1cb1 --- /dev/null +++ b/forge-cli/internal/tui/components/text_input.go @@ -0,0 +1,148 @@ +package components + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// TextInput is a styled text entry component wrapping bubbles/textinput. +type TextInput struct { + Label string + input textinput.Model + done bool + err string + slugHint bool // show slug hint below input + validateFn func(string) error + + // Styles + LabelStyle lipgloss.Style + BorderStyle lipgloss.Style + ErrorStyle lipgloss.Style + HintStyle lipgloss.Style + AccentColor lipgloss.Color + kbd KbdHint +} + +// NewTextInput creates a new styled text input. +func NewTextInput(label, placeholder string, slugHint bool, validateFn func(string) error, accentColor lipgloss.Color, labelStyle, borderStyle, errorStyle, hintStyle lipgloss.Style, kbdKeyStyle, kbdDescStyle lipgloss.Style) TextInput { + ti := textinput.New() + ti.Placeholder = placeholder + ti.Focus() + ti.CharLimit = 100 + ti.Cursor.Style = lipgloss.NewStyle().Foreground(accentColor) + + kbd := NewKbdHint(kbdKeyStyle, kbdDescStyle) + kbd.Bindings = InputHints() + + return TextInput{ + Label: label, + input: ti, + slugHint: slugHint, + validateFn: validateFn, + LabelStyle: labelStyle, + BorderStyle: borderStyle, + ErrorStyle: errorStyle, + HintStyle: hintStyle, + AccentColor: accentColor, + kbd: kbd, + } +} + +// Init focuses the text input. +func (t TextInput) Init() tea.Cmd { + return textinput.Blink +} + +// Update handles messages. +func (t TextInput) Update(msg tea.Msg) (TextInput, tea.Cmd) { + if t.done { + return t, nil + } + + if msg, ok := msg.(tea.KeyMsg); ok { + switch msg.String() { + case "enter": + val := strings.TrimSpace(t.input.Value()) + if t.validateFn != nil { + if err := t.validateFn(val); err != nil { + t.err = err.Error() + return t, nil + } + } + t.done = true + t.err = "" + return t, nil + } + } + + var cmd tea.Cmd + t.input, cmd = t.input.Update(msg) + t.err = "" // clear error on typing + return t, cmd +} + +// View renders the text input. +func (t TextInput) View(width int) string { + var out string + + out += "\n " + t.LabelStyle.Render(t.Label) + "\n\n" + + inputWidth := width - 8 + if inputWidth < 20 { + inputWidth = 20 + } + t.input.Width = inputWidth + + inputBox := t.BorderStyle.Width(inputWidth).Render(t.input.View()) + out += " " + inputBox + "\n" + + if t.err != "" { + out += " " + t.ErrorStyle.Render("✗ "+t.err) + "\n" + } + + if t.slugHint && t.input.Value() != "" { + slug := slugify(t.input.Value()) + out += " " + t.HintStyle.Render(fmt.Sprintf("→ ./%s/", slug)) + "\n" + } + + out += "\n" + t.kbd.View() + return out +} + +// Done returns true when input is submitted. +func (t TextInput) Done() bool { + return t.done +} + +// Value returns the current input value. +func (t TextInput) Value() string { + return strings.TrimSpace(t.input.Value()) +} + +// SetValue sets the input value. +func (t *TextInput) SetValue(v string) { + t.input.SetValue(v) +} + +// slugify converts a string to a URL-friendly slug. +func slugify(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + s = strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + return r + } + if r == ' ' || r == '_' { + return '-' + } + return -1 + }, s) + // Collapse multiple dashes + for strings.Contains(s, "--") { + s = strings.ReplaceAll(s, "--", "-") + } + return strings.Trim(s, "-") +} diff --git a/forge-cli/internal/tui/messages.go b/forge-cli/internal/tui/messages.go new file mode 100644 index 0000000..f75275e --- /dev/null +++ b/forge-cli/internal/tui/messages.go @@ -0,0 +1,26 @@ +package tui + +// StepBackMsg is emitted by a step when the user presses backspace at the first sub-phase. +type StepBackMsg struct{} + +// StepCompleteMsg is emitted by a step when it finishes. +type StepCompleteMsg struct{} + +// ValidationResultMsg carries the result of an async validation. +type ValidationResultMsg struct { + Err error +} + +// GenerationProgressMsg reports file generation progress. +type GenerationProgressMsg struct { + File string + Status string // "writing", "done", "error" +} + +// GenerationDoneMsg signals that all file generation is complete. +type GenerationDoneMsg struct { + Err error +} + +// FileTickMsg triggers the next file generation display. +type FileTickMsg struct{} diff --git a/forge-cli/internal/tui/step.go b/forge-cli/internal/tui/step.go new file mode 100644 index 0000000..4ccedd4 --- /dev/null +++ b/forge-cli/internal/tui/step.go @@ -0,0 +1,56 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Step is the interface that all wizard steps must implement. +type Step interface { + // Title returns the step's display title. + Title() string + // Icon returns the step's icon/emoji. + Icon() string + // Init returns the initial command for this step. + Init() tea.Cmd + // Update handles messages and returns the updated step and command. + Update(msg tea.Msg) (Step, tea.Cmd) + // View renders the step content. + View(width int) string + // Complete returns true when the step has finished. + Complete() bool + // Summary returns a one-line summary for the collapsed view. + Summary() string + // Apply writes collected data to the wizard context. + Apply(ctx *WizardContext) +} + +// RenderProgress renders the step progress sidebar showing completed and active steps. +func RenderProgress(steps []Step, current int, styles *StyleSet, width int) string { + var out string + + for i := 0; i < current; i++ { + badge := styles.StepBadgeComplete.Render(" ✓ ") + title := styles.PrimaryTxt.Bold(true).Render(steps[i].Title()) + out += fmt.Sprintf(" %s %s\n", badge, title) + summary := styles.SecondaryTxt.Render(steps[i].Summary()) + out += fmt.Sprintf(" %s\n\n", summary) + } + + if current < len(steps) { + numStr := fmt.Sprintf(" %d ", current+1) + badge := styles.StepBadgeActive.Render(numStr) + title := styles.PrimaryTxt.Bold(true).Render(steps[current].Title()) + dividerLen := width - 10 - lipgloss.Width(numStr) - lipgloss.Width(steps[current].Title()) + if dividerLen < 2 { + dividerLen = 2 + } + divider := styles.DimTxt.Render(" " + strings.Repeat("─", dividerLen)) + out += fmt.Sprintf(" %s %s%s\n", badge, title, divider) + } + + return out +} diff --git a/forge-cli/internal/tui/steps/channel_step.go b/forge-cli/internal/tui/steps/channel_step.go new file mode 100644 index 0000000..b8f6a7a --- /dev/null +++ b/forge-cli/internal/tui/steps/channel_step.go @@ -0,0 +1,245 @@ +package steps + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/initializ/forge/forge-cli/internal/tui" + "github.com/initializ/forge/forge-cli/internal/tui/components" +) + +type channelPhase int + +const ( + channelSelectPhase channelPhase = iota + channelTokenPhase + channelSlackBotTokenPhase + channelDonePhase +) + +// ChannelStep handles channel connector selection. +type ChannelStep struct { + styles *tui.StyleSet + phase channelPhase + selector components.SingleSelect + keyInput components.SecretInput + complete bool + channel string + tokens map[string]string +} + +// NewChannelStep creates a new channel step. +func NewChannelStep(styles *tui.StyleSet) *ChannelStep { + items := []components.SingleSelectItem{ + {Label: "None", Value: "none", Description: "CLI / API only", Icon: "🚫"}, + {Label: "Telegram", Value: "telegram", Description: "Easy setup, no public URL needed", Icon: "✈️"}, + {Label: "Slack", Value: "slack", Description: "Socket Mode, no public URL needed", Icon: "💬"}, + } + + selector := components.NewSingleSelect( + items, + styles.Theme.Accent, + styles.Theme.Primary, + styles.Theme.Secondary, + styles.Theme.Dim, + styles.Theme.Border, + styles.Theme.ActiveBorder, + styles.Theme.ActiveBg, + styles.KbdKey, + styles.KbdDesc, + ) + + return &ChannelStep{ + styles: styles, + selector: selector, + tokens: make(map[string]string), + } +} + +func (s *ChannelStep) Title() string { return "Channel Connector" } +func (s *ChannelStep) Icon() string { return "📡" } + +func (s *ChannelStep) Init() tea.Cmd { + return s.selector.Init() +} + +func (s *ChannelStep) Update(msg tea.Msg) (tui.Step, tea.Cmd) { + if s.complete { + return s, nil + } + + switch s.phase { + case channelSelectPhase: + return s.updateSelectPhase(msg) + case channelTokenPhase: + return s.updateTokenPhase(msg) + case channelSlackBotTokenPhase: + return s.updateSlackBotTokenPhase(msg) + } + + return s, nil +} + +func (s *ChannelStep) updateSelectPhase(msg tea.Msg) (tui.Step, tea.Cmd) { + updated, cmd := s.selector.Update(msg) + s.selector = updated + + if s.selector.Done() { + _, val := s.selector.Selected() + s.channel = val + + switch val { + case "none": + s.complete = true + return s, func() tea.Msg { return tui.StepCompleteMsg{} } + case "telegram": + s.phase = channelTokenPhase + s.keyInput = components.NewSecretInput( + "Telegram Bot Token (from @BotFather)", + true, + s.styles.Theme.Accent, + s.styles.Theme.Success, + s.styles.Theme.Error, + s.styles.Theme.Border, + s.styles.AccentTxt, + s.styles.InactiveBorder, + s.styles.SuccessTxt, + s.styles.ErrorTxt, + s.styles.DimTxt, + s.styles.KbdKey, + s.styles.KbdDesc, + ) + return s, s.keyInput.Init() + case "slack": + s.phase = channelTokenPhase + s.keyInput = components.NewSecretInput( + "Slack App Token (xapp-...)", + true, + s.styles.Theme.Accent, + s.styles.Theme.Success, + s.styles.Theme.Error, + s.styles.Theme.Border, + s.styles.AccentTxt, + s.styles.InactiveBorder, + s.styles.SuccessTxt, + s.styles.ErrorTxt, + s.styles.DimTxt, + s.styles.KbdKey, + s.styles.KbdDesc, + ) + return s, s.keyInput.Init() + } + } + + return s, cmd +} + +func (s *ChannelStep) updateTokenPhase(msg tea.Msg) (tui.Step, tea.Cmd) { + updated, cmd := s.keyInput.Update(msg) + s.keyInput = updated + + if s.keyInput.Done() { + val := s.keyInput.Value() + + switch s.channel { + case "telegram": + if val != "" { + s.tokens["TELEGRAM_BOT_TOKEN"] = val + } + s.complete = true + return s, func() tea.Msg { return tui.StepCompleteMsg{} } + case "slack": + if val != "" { + s.tokens["SLACK_APP_TOKEN"] = val + } + // Need bot token too + s.phase = channelSlackBotTokenPhase + s.keyInput = components.NewSecretInput( + "Slack Bot Token (xoxb-...)", + true, + s.styles.Theme.Accent, + s.styles.Theme.Success, + s.styles.Theme.Error, + s.styles.Theme.Border, + s.styles.AccentTxt, + s.styles.InactiveBorder, + s.styles.SuccessTxt, + s.styles.ErrorTxt, + s.styles.DimTxt, + s.styles.KbdKey, + s.styles.KbdDesc, + ) + return s, s.keyInput.Init() + } + } + + return s, cmd +} + +func (s *ChannelStep) updateSlackBotTokenPhase(msg tea.Msg) (tui.Step, tea.Cmd) { + updated, cmd := s.keyInput.Update(msg) + s.keyInput = updated + + if s.keyInput.Done() { + if val := s.keyInput.Value(); val != "" { + s.tokens["SLACK_BOT_TOKEN"] = val + } + s.complete = true + return s, func() tea.Msg { return tui.StepCompleteMsg{} } + } + + return s, cmd +} + +func (s *ChannelStep) View(width int) string { + switch s.phase { + case channelSelectPhase: + return s.selector.View(width) + case channelTokenPhase: + var instructions string + switch s.channel { + case "telegram": + instructions = fmt.Sprintf(" %s\n %s\n %s\n %s\n\n", + s.styles.SecondaryTxt.Render("Telegram Bot Setup:"), + s.styles.DimTxt.Render("1. Open Telegram, message @BotFather"), + s.styles.DimTxt.Render("2. Send /newbot and follow prompts"), + s.styles.DimTxt.Render("3. Copy the bot token"), + ) + case "slack": + instructions = fmt.Sprintf(" %s\n %s\n %s\n %s\n\n", + s.styles.SecondaryTxt.Render("Slack Socket Mode Setup:"), + s.styles.DimTxt.Render("1. Create a Slack App at https://api.slack.com/apps"), + s.styles.DimTxt.Render("2. Enable Socket Mode, generate app-level token"), + s.styles.DimTxt.Render("3. Add bot scopes: chat:write, app_mentions:read"), + ) + } + return instructions + s.keyInput.View(width) + case channelSlackBotTokenPhase: + return s.keyInput.View(width) + } + return "" +} + +func (s *ChannelStep) Complete() bool { + return s.complete +} + +func (s *ChannelStep) Summary() string { + switch s.channel { + case "none": + return "None" + case "telegram": + return "Telegram" + case "slack": + return "Slack" + } + return s.channel +} + +func (s *ChannelStep) Apply(ctx *tui.WizardContext) { + ctx.Channel = s.channel + for k, v := range s.tokens { + ctx.ChannelTokens[k] = v + } +} diff --git a/forge-cli/internal/tui/steps/egress_step.go b/forge-cli/internal/tui/steps/egress_step.go new file mode 100644 index 0000000..d21101e --- /dev/null +++ b/forge-cli/internal/tui/steps/egress_step.go @@ -0,0 +1,171 @@ +package steps + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/initializ/forge/forge-cli/internal/tui" + "github.com/initializ/forge/forge-cli/internal/tui/components" +) + +// DeriveEgressFunc computes egress domains from wizard context. +type DeriveEgressFunc func(provider string, channels, tools, skills []string, envVars map[string]string) []string + +// EgressStep handles egress domain review. +type EgressStep struct { + styles *tui.StyleSet + display components.EgressDisplay + complete bool + domains []string + deriveFn DeriveEgressFunc + empty bool + prepared bool +} + +// NewEgressStep creates a new egress review step. +func NewEgressStep(styles *tui.StyleSet, deriveFn DeriveEgressFunc) *EgressStep { + return &EgressStep{ + styles: styles, + deriveFn: deriveFn, + } +} + +// Prepare computes egress domains using the accumulated wizard context. +func (s *EgressStep) Prepare(ctx *tui.WizardContext) { + var channels []string + if ctx.Channel != "" && ctx.Channel != "none" { + channels = []string{ctx.Channel} + } + + s.domains = nil + if s.deriveFn != nil { + s.domains = s.deriveFn(ctx.Provider, channels, ctx.BuiltinTools, ctx.Skills, ctx.EnvVars) + } + + s.empty = len(s.domains) == 0 + s.prepared = true + + if !s.empty { + var egressDomains []components.EgressDomain + for _, d := range s.domains { + source := inferSource(d, ctx) + egressDomains = append(egressDomains, components.EgressDomain{ + Domain: d, + Source: source, + }) + } + + s.display = components.NewEgressDisplay( + egressDomains, + s.styles.PrimaryTxt, + s.styles.DimTxt, + s.styles.BorderedBox, + s.styles.AccentTxt, + s.styles.SecondaryTxt, + s.styles.KbdKey, + s.styles.KbdDesc, + ) + } +} + +func (s *EgressStep) Title() string { return "Egress Review" } +func (s *EgressStep) Icon() string { return "🌐" } + +func (s *EgressStep) Init() tea.Cmd { + s.complete = false + if s.empty { + s.complete = true + return func() tea.Msg { return tui.StepCompleteMsg{} } + } + return s.display.Init() +} + +func (s *EgressStep) Update(msg tea.Msg) (tui.Step, tea.Cmd) { + if s.complete { + return s, nil + } + + // Handle backspace for going back + if msg, ok := msg.(tea.KeyMsg); ok && msg.String() == "backspace" { + return s, func() tea.Msg { return tui.StepBackMsg{} } + } + + updated, cmd := s.display.Update(msg) + s.display = updated + + if s.display.Done() { + s.complete = true + return s, func() tea.Msg { return tui.StepCompleteMsg{} } + } + + return s, cmd +} + +func (s *EgressStep) View(width int) string { + if s.empty { + return fmt.Sprintf(" %s\n", s.styles.DimTxt.Render("No egress domains needed.")) + } + return s.display.View(width) +} + +func (s *EgressStep) Complete() bool { + return s.complete +} + +func (s *EgressStep) Summary() string { + if len(s.domains) == 0 { + return "none" + } + return fmt.Sprintf("restricted · %d domains", len(s.domains)) +} + +func (s *EgressStep) Apply(ctx *tui.WizardContext) { + ctx.EgressDomains = s.domains +} + +// inferSource guesses the source of an egress domain based on context. +func inferSource(domain string, ctx *tui.WizardContext) string { + // Provider domains + providerDomains := map[string]string{ + "api.openai.com": "model provider", + "api.anthropic.com": "model provider", + "generativelanguage.googleapis.com": "model provider", + } + if src, ok := providerDomains[domain]; ok { + return src + } + + // Channel domains + channelDomains := map[string]string{ + "api.telegram.org": "channel", + "slack.com": "channel", + "hooks.slack.com": "channel", + "api.slack.com": "channel", + } + if src, ok := channelDomains[domain]; ok { + return src + } + + // Tool domains + toolDomains := map[string]string{ + "api.tavily.com": "web_search tool", + "api.perplexity.ai": "web_search tool", + } + if src, ok := toolDomains[domain]; ok { + return src + } + + // Skill domains + skillDomains := map[string]string{ + "api.github.com": "github skill", + "github.com": "github skill", + "api.openweathermap.org": "weather skill", + "api.weatherapi.com": "weather skill", + } + if src, ok := skillDomains[domain]; ok { + return src + } + + return "configured" +} diff --git a/forge-cli/internal/tui/steps/name_step.go b/forge-cli/internal/tui/steps/name_step.go new file mode 100644 index 0000000..957f7f1 --- /dev/null +++ b/forge-cli/internal/tui/steps/name_step.go @@ -0,0 +1,97 @@ +package steps + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/initializ/forge/forge-cli/internal/tui" + "github.com/initializ/forge/forge-cli/internal/tui/components" +) + +// NameStep collects the agent name. +type NameStep struct { + input components.TextInput + complete bool + name string + prefill string +} + +// NewNameStep creates a new name step. +func NewNameStep(styles *tui.StyleSet, prefill string) *NameStep { + validate := func(val string) error { + if val == "" { + return fmt.Errorf("name is required") + } + return nil + } + + input := components.NewTextInput( + "What should we call your agent?", + "my-agent", + true, // show slug hint + validate, + styles.Theme.Accent, + styles.AccentTxt, + styles.InactiveBorder, + styles.ErrorTxt, + styles.DimTxt, + styles.KbdKey, + styles.KbdDesc, + ) + + if prefill != "" { + input.SetValue(prefill) + } + + return &NameStep{ + input: input, + prefill: prefill, + } +} + +func (s *NameStep) Title() string { return "Agent Name" } +func (s *NameStep) Icon() string { return "📝" } + +func (s *NameStep) Init() tea.Cmd { + // If pre-filled, auto-complete + if s.prefill != "" { + s.complete = true + s.name = s.prefill + return func() tea.Msg { return tui.StepCompleteMsg{} } + } + return s.input.Init() +} + +func (s *NameStep) Update(msg tea.Msg) (tui.Step, tea.Cmd) { + if s.complete { + return s, nil + } + + updated, cmd := s.input.Update(msg) + s.input = updated + + if s.input.Done() { + s.complete = true + s.name = s.input.Value() + return s, func() tea.Msg { return tui.StepCompleteMsg{} } + } + + return s, cmd +} + +func (s *NameStep) View(width int) string { + return s.input.View(width) +} + +func (s *NameStep) Complete() bool { + return s.complete +} + +func (s *NameStep) Summary() string { + return s.name +} + +func (s *NameStep) Apply(ctx *tui.WizardContext) { + ctx.Name = s.name +} diff --git a/forge-cli/internal/tui/steps/provider_step.go b/forge-cli/internal/tui/steps/provider_step.go new file mode 100644 index 0000000..09acfde --- /dev/null +++ b/forge-cli/internal/tui/steps/provider_step.go @@ -0,0 +1,383 @@ +package steps + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/initializ/forge/forge-cli/internal/tui" + "github.com/initializ/forge/forge-cli/internal/tui/components" +) + +type providerPhase int + +const ( + providerSelectPhase providerPhase = iota + providerKeyPhase + providerValidatingPhase + providerCustomURLPhase + providerCustomModelPhase + providerCustomAuthPhase + providerDonePhase +) + +// ValidateKeyFunc validates an API key for a provider. +type ValidateKeyFunc func(provider, key string) error + +// ProviderStep handles model provider selection and API key entry. +type ProviderStep struct { + styles *tui.StyleSet + phase providerPhase + selector components.SingleSelect + keyInput components.SecretInput + textInput components.TextInput + complete bool + provider string + apiKey string + customURL string + customModel string + customAuth string + validateFn ValidateKeyFunc + validating bool + valErr error +} + +// NewProviderStep creates a new provider selection step. +func NewProviderStep(styles *tui.StyleSet, validateFn ValidateKeyFunc) *ProviderStep { + items := []components.SingleSelectItem{ + {Label: "OpenAI", Value: "openai", Description: "GPT-4o, GPT-4o-mini", Icon: "🔷"}, + {Label: "Anthropic", Value: "anthropic", Description: "Claude Sonnet, Haiku, Opus", Icon: "🟠"}, + {Label: "Google Gemini", Value: "gemini", Description: "Gemini 2.5 Flash, Pro", Icon: "🔵"}, + {Label: "Ollama (local)", Value: "ollama", Description: "Run models locally, no API key needed", Icon: "🦙"}, + {Label: "Custom URL", Value: "custom", Description: "Any OpenAI-compatible endpoint", Icon: "⚙️"}, + } + + selector := components.NewSingleSelect( + items, + styles.Theme.Accent, + styles.Theme.Primary, + styles.Theme.Secondary, + styles.Theme.Dim, + styles.Theme.Border, + styles.Theme.ActiveBorder, + styles.Theme.ActiveBg, + styles.KbdKey, + styles.KbdDesc, + ) + + return &ProviderStep{ + styles: styles, + selector: selector, + validateFn: validateFn, + } +} + +func (s *ProviderStep) Title() string { return "Model Provider" } +func (s *ProviderStep) Icon() string { return "🤖" } + +func (s *ProviderStep) Init() tea.Cmd { + return s.selector.Init() +} + +func (s *ProviderStep) Update(msg tea.Msg) (tui.Step, tea.Cmd) { + if s.complete { + return s, nil + } + + switch s.phase { + case providerSelectPhase: + return s.updateSelectPhase(msg) + case providerKeyPhase: + return s.updateKeyPhase(msg) + case providerValidatingPhase: + return s.updateValidatingPhase(msg) + case providerCustomURLPhase: + return s.updateCustomURLPhase(msg) + case providerCustomModelPhase: + return s.updateCustomModelPhase(msg) + case providerCustomAuthPhase: + return s.updateCustomAuthPhase(msg) + } + + return s, nil +} + +func (s *ProviderStep) updateSelectPhase(msg tea.Msg) (tui.Step, tea.Cmd) { + updated, cmd := s.selector.Update(msg) + s.selector = updated + + if s.selector.Done() { + _, val := s.selector.Selected() + s.provider = val + + switch val { + case "ollama": + // Skip key, go to validation + s.phase = providerValidatingPhase + s.validating = true + return s, s.runValidation() + case "custom": + s.phase = providerCustomURLPhase + s.textInput = components.NewTextInput( + "Base URL (e.g. http://localhost:11434/v1)", + "http://localhost:11434/v1", + false, nil, + s.styles.Theme.Accent, + s.styles.AccentTxt, + s.styles.InactiveBorder, + s.styles.ErrorTxt, + s.styles.DimTxt, + s.styles.KbdKey, + s.styles.KbdDesc, + ) + return s, s.textInput.Init() + default: + // openai, anthropic, gemini → ask for key + s.phase = providerKeyPhase + label := fmt.Sprintf("%s API Key", providerDisplayName(val)) + s.keyInput = components.NewSecretInput( + label, true, + s.styles.Theme.Accent, + s.styles.Theme.Success, + s.styles.Theme.Error, + s.styles.Theme.Border, + s.styles.AccentTxt, + s.styles.InactiveBorder, + s.styles.SuccessTxt, + s.styles.ErrorTxt, + s.styles.DimTxt, + s.styles.KbdKey, + s.styles.KbdDesc, + ) + return s, s.keyInput.Init() + } + } + + return s, cmd +} + +func (s *ProviderStep) updateKeyPhase(msg tea.Msg) (tui.Step, tea.Cmd) { + // Handle backspace at empty input → go back to provider selector (internal back) + if msg, ok := msg.(tea.KeyMsg); ok && msg.String() == "backspace" { + if s.keyInput.Value() == "" { + s.phase = providerSelectPhase + s.provider = "" + s.selector.Reset() + return s, s.selector.Init() + } + } + + updated, cmd := s.keyInput.Update(msg) + s.keyInput = updated + + if s.keyInput.Done() { + s.apiKey = s.keyInput.Value() + if s.apiKey == "" { + // Skipped validation + s.complete = true + return s, func() tea.Msg { return tui.StepCompleteMsg{} } + } + // Validate + s.phase = providerValidatingPhase + s.validating = true + return s, s.runValidation() + } + + return s, cmd +} + +func (s *ProviderStep) updateValidatingPhase(msg tea.Msg) (tui.Step, tea.Cmd) { + if msg, ok := msg.(tui.ValidationResultMsg); ok { + s.validating = false + if msg.Err != nil { + s.valErr = msg.Err + // Go back to key input on failure — create fresh input for retry + if s.provider != "ollama" { + s.phase = providerKeyPhase + label := fmt.Sprintf("%s API Key (retry — %s)", providerDisplayName(s.provider), msg.Err) + s.keyInput = components.NewSecretInput( + label, true, + s.styles.Theme.Accent, + s.styles.Theme.Success, + s.styles.Theme.Error, + s.styles.Theme.Border, + s.styles.AccentTxt, + s.styles.InactiveBorder, + s.styles.SuccessTxt, + s.styles.ErrorTxt, + s.styles.DimTxt, + s.styles.KbdKey, + s.styles.KbdDesc, + ) + s.keyInput.SetState(components.SecretInputFailed, msg.Err.Error()) + return s, s.keyInput.Init() + } + // For ollama, warn but continue + s.complete = true + return s, func() tea.Msg { return tui.StepCompleteMsg{} } + } + s.complete = true + return s, func() tea.Msg { return tui.StepCompleteMsg{} } + } + + return s, nil +} + +func (s *ProviderStep) updateCustomURLPhase(msg tea.Msg) (tui.Step, tea.Cmd) { + updated, cmd := s.textInput.Update(msg) + s.textInput = updated + + if s.textInput.Done() { + s.customURL = s.textInput.Value() + s.phase = providerCustomModelPhase + s.textInput = components.NewTextInput( + "Model name", + "default", + false, nil, + s.styles.Theme.Accent, + s.styles.AccentTxt, + s.styles.InactiveBorder, + s.styles.ErrorTxt, + s.styles.DimTxt, + s.styles.KbdKey, + s.styles.KbdDesc, + ) + return s, s.textInput.Init() + } + + return s, cmd +} + +func (s *ProviderStep) updateCustomModelPhase(msg tea.Msg) (tui.Step, tea.Cmd) { + updated, cmd := s.textInput.Update(msg) + s.textInput = updated + + if s.textInput.Done() { + s.customModel = s.textInput.Value() + s.phase = providerCustomAuthPhase + s.keyInput = components.NewSecretInput( + "API key or auth token (optional)", + true, + s.styles.Theme.Accent, + s.styles.Theme.Success, + s.styles.Theme.Error, + s.styles.Theme.Border, + s.styles.AccentTxt, + s.styles.InactiveBorder, + s.styles.SuccessTxt, + s.styles.ErrorTxt, + s.styles.DimTxt, + s.styles.KbdKey, + s.styles.KbdDesc, + ) + return s, s.keyInput.Init() + } + + return s, cmd +} + +func (s *ProviderStep) updateCustomAuthPhase(msg tea.Msg) (tui.Step, tea.Cmd) { + updated, cmd := s.keyInput.Update(msg) + s.keyInput = updated + + if s.keyInput.Done() { + s.customAuth = s.keyInput.Value() + s.complete = true + return s, func() tea.Msg { return tui.StepCompleteMsg{} } + } + + return s, cmd +} + +func (s *ProviderStep) runValidation() tea.Cmd { + provider := s.provider + key := s.apiKey + validateFn := s.validateFn + return func() tea.Msg { + if validateFn == nil { + return tui.ValidationResultMsg{Err: nil} + } + err := validateFn(provider, key) + return tui.ValidationResultMsg{Err: err} + } +} + +func (s *ProviderStep) View(width int) string { + switch s.phase { + case providerSelectPhase: + return s.selector.View(width) + case providerKeyPhase: + return s.keyInput.View(width) + case providerValidatingPhase: + if s.validating { + return " " + s.styles.AccentTxt.Render("⣾ Validating...") + "\n" + } + return s.keyInput.View(width) + case providerCustomURLPhase, providerCustomModelPhase: + return s.textInput.View(width) + case providerCustomAuthPhase: + return s.keyInput.View(width) + } + return "" +} + +func (s *ProviderStep) Complete() bool { + return s.complete +} + +func (s *ProviderStep) Summary() string { + name := providerDisplayName(s.provider) + switch s.provider { + case "openai": + return name + " · gpt-4o-mini" + case "anthropic": + return name + " · claude-sonnet-4-20250514" + case "gemini": + return name + " · gemini-2.5-flash" + case "ollama": + return name + " · llama3" + case "custom": + if s.customModel != "" { + return "Custom · " + s.customModel + } + return "Custom URL" + } + return name +} + +func (s *ProviderStep) Apply(ctx *tui.WizardContext) { + ctx.Provider = s.provider + ctx.APIKey = s.apiKey + ctx.CustomBaseURL = s.customURL + ctx.CustomModel = s.customModel + ctx.CustomAPIKey = s.customAuth + + // Store the provider API key in EnvVars so later steps (e.g. skills) + // can detect it's already collected and skip re-prompting. + if s.apiKey != "" { + switch s.provider { + case "openai": + ctx.EnvVars["OPENAI_API_KEY"] = s.apiKey + case "anthropic": + ctx.EnvVars["ANTHROPIC_API_KEY"] = s.apiKey + case "gemini": + ctx.EnvVars["GEMINI_API_KEY"] = s.apiKey + } + } +} + +func providerDisplayName(provider string) string { + switch provider { + case "openai": + return "OpenAI" + case "anthropic": + return "Anthropic" + case "gemini": + return "Google Gemini" + case "ollama": + return "Ollama" + case "custom": + return "Custom" + } + return provider +} diff --git a/forge-cli/internal/tui/steps/review_step.go b/forge-cli/internal/tui/steps/review_step.go new file mode 100644 index 0000000..ce8b79c --- /dev/null +++ b/forge-cli/internal/tui/steps/review_step.go @@ -0,0 +1,118 @@ +package steps + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/initializ/forge/forge-cli/internal/tui" + "github.com/initializ/forge/forge-cli/internal/tui/components" +) + +// ReviewStep handles the final summary and confirmation. +// Actual scaffolding is handled by the caller after the wizard exits. +type ReviewStep struct { + styles *tui.StyleSet + summary components.SummaryBox + complete bool + kbd components.KbdHint + prepared bool +} + +// NewReviewStep creates a new review step. +func NewReviewStep(styles *tui.StyleSet) *ReviewStep { + kbd := components.NewKbdHint(styles.KbdKey, styles.KbdDesc) + kbd.Bindings = components.ReviewHints() + + return &ReviewStep{ + styles: styles, + kbd: kbd, + } +} + +// Prepare builds the summary from wizard context. +func (s *ReviewStep) Prepare(ctx *tui.WizardContext) { + s.prepared = true + s.complete = false + + var rows []components.SummaryRow + rows = append(rows, components.SummaryRow{Key: "Name", Value: ctx.Name}) + rows = append(rows, components.SummaryRow{Key: "Provider", Value: providerDisplayName(ctx.Provider)}) + + if ctx.Channel != "" && ctx.Channel != "none" { + rows = append(rows, components.SummaryRow{Key: "Channel", Value: ctx.Channel}) + } + + if len(ctx.BuiltinTools) > 0 { + var toolNames []string + for _, name := range ctx.BuiltinTools { + if name == "web_search" { + if p := ctx.EnvVars["WEB_SEARCH_PROVIDER"]; p != "" { + toolNames = append(toolNames, fmt.Sprintf("web_search [%s]", p)) + continue + } + } + toolNames = append(toolNames, name) + } + rows = append(rows, components.SummaryRow{Key: "Tools", Value: strings.Join(toolNames, ", ")}) + } + + if len(ctx.Skills) > 0 { + rows = append(rows, components.SummaryRow{Key: "Skills", Value: strings.Join(ctx.Skills, ", ")}) + } + + if len(ctx.EgressDomains) > 0 { + rows = append(rows, components.SummaryRow{Key: "Egress", Value: fmt.Sprintf("restricted · %d domains", len(ctx.EgressDomains))}) + } + + s.summary = components.NewSummaryBox( + rows, + s.styles.SummaryKey, + s.styles.SummaryValue, + s.styles.BorderedBox, + ) +} + +func (s *ReviewStep) Title() string { return "Review & Generate" } +func (s *ReviewStep) Icon() string { return "🚀" } + +func (s *ReviewStep) Init() tea.Cmd { + return nil +} + +func (s *ReviewStep) Update(msg tea.Msg) (tui.Step, tea.Cmd) { + if s.complete { + return s, nil + } + + if msg, ok := msg.(tea.KeyMsg); ok { + switch msg.String() { + case "enter": + s.complete = true + return s, func() tea.Msg { return tui.StepCompleteMsg{} } + case "backspace": + return s, func() tea.Msg { return tui.StepBackMsg{} } + } + } + return s, nil +} + +func (s *ReviewStep) View(width int) string { + out := s.summary.View(width) + "\n\n" + out += " " + s.styles.AccentTxt.Render("Press Enter to generate project, Backspace to go back") + "\n\n" + out += s.kbd.View() + return out +} + +func (s *ReviewStep) Complete() bool { + return s.complete +} + +func (s *ReviewStep) Summary() string { + return "confirmed" +} + +func (s *ReviewStep) Apply(ctx *tui.WizardContext) { + // No additional data to apply — scaffolding is handled by the caller. +} diff --git a/forge-cli/internal/tui/steps/skills_step.go b/forge-cli/internal/tui/steps/skills_step.go new file mode 100644 index 0000000..8d8fae0 --- /dev/null +++ b/forge-cli/internal/tui/steps/skills_step.go @@ -0,0 +1,414 @@ +package steps + +import ( + "fmt" + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/initializ/forge/forge-cli/internal/tui" + "github.com/initializ/forge/forge-cli/internal/tui/components" +) + +// SkillInfo represents a registry skill for the skills step. +type SkillInfo struct { + Name string + DisplayName string + Description string + RequiredEnv []string + OneOfEnv []string + OptionalEnv []string + RequiredBins []string + EgressDomains []string +} + +type skillsPhase int + +const ( + skillsSelectPhase skillsPhase = iota + skillsEnvPhase +) + +// envPrompt describes a single env var prompt to show. +type envPrompt struct { + envVar string + label string + allowSkip bool + skillName string + kind string // "required", "one_of", "optional" +} + +// SkillsStep handles external skill selection. +type SkillsStep struct { + styles *tui.StyleSet + allSkills []SkillInfo + multiSelect components.MultiSelect + phase skillsPhase + complete bool + selected []string + empty bool + + // Env prompting + envPrompts []envPrompt + currentPrompt int + keyInput components.SecretInput + envValues map[string]string + knownEnvVars map[string]string // env vars already collected by earlier steps +} + +// NewSkillsStep creates a new skills selection step. +func NewSkillsStep(styles *tui.StyleSet, skills []SkillInfo) *SkillsStep { + if len(skills) == 0 { + return &SkillsStep{ + styles: styles, + complete: false, + empty: true, + envValues: make(map[string]string), + } + } + + var items []components.MultiSelectItem + for _, sk := range skills { + icon := skillIcon(sk.Name) + var reqs []string + if len(sk.RequiredBins) > 0 { + reqs = append(reqs, "bins: "+strings.Join(sk.RequiredBins, ", ")) + } + if len(sk.RequiredEnv) > 0 { + reqs = append(reqs, "env: "+strings.Join(sk.RequiredEnv, ", ")) + } + if len(sk.OneOfEnv) > 0 { + reqs = append(reqs, "one of: "+strings.Join(sk.OneOfEnv, " / ")) + } + var reqLine string + if len(reqs) > 0 { + reqLine = strings.Join(reqs, " · ") + } + + items = append(items, components.MultiSelectItem{ + Label: sk.DisplayName, + Value: sk.Name, + Description: sk.Description, + Icon: icon, + RequirementLine: reqLine, + }) + } + + ms := components.NewMultiSelect( + items, + styles.Theme.Accent, + styles.Theme.AccentDim, + styles.Theme.Primary, + styles.Theme.Secondary, + styles.Theme.Dim, + styles.ActiveBorder, + styles.InactiveBorder, + styles.KbdKey, + styles.KbdDesc, + ) + + return &SkillsStep{ + styles: styles, + allSkills: skills, + multiSelect: ms, + envValues: make(map[string]string), + } +} + +// Prepare captures env vars already collected by earlier wizard steps. +func (s *SkillsStep) Prepare(ctx *tui.WizardContext) { + s.knownEnvVars = make(map[string]string) + for k, v := range ctx.EnvVars { + s.knownEnvVars[k] = v + } +} + +func (s *SkillsStep) Title() string { return "External Skills" } +func (s *SkillsStep) Icon() string { return "📦" } + +func (s *SkillsStep) Init() tea.Cmd { + s.complete = false + s.phase = skillsSelectPhase + s.currentPrompt = 0 + s.envPrompts = nil + s.envValues = make(map[string]string) + if s.empty { + s.complete = true + return func() tea.Msg { return tui.StepCompleteMsg{} } + } + return s.multiSelect.Init() +} + +func (s *SkillsStep) Update(msg tea.Msg) (tui.Step, tea.Cmd) { + if s.complete { + return s, nil + } + + switch s.phase { + case skillsSelectPhase: + updated, cmd := s.multiSelect.Update(msg) + s.multiSelect = updated + + if s.multiSelect.Done() { + s.selected = s.multiSelect.SelectedValues() + + // Build env prompts for selected skills + s.buildEnvPrompts() + + if len(s.envPrompts) == 0 { + s.complete = true + return s, func() tea.Msg { return tui.StepCompleteMsg{} } + } + + // Start env prompting + s.phase = skillsEnvPhase + s.currentPrompt = 0 + s.initCurrentPrompt() + return s, s.keyInput.Init() + } + + return s, cmd + + case skillsEnvPhase: + updated, cmd := s.keyInput.Update(msg) + s.keyInput = updated + + if s.keyInput.Done() { + val := s.keyInput.Value() + prompt := s.envPrompts[s.currentPrompt] + if val != "" { + s.envValues[prompt.envVar] = val + } + + s.currentPrompt++ + + // Check if we're done with all prompts + if s.currentPrompt >= len(s.envPrompts) { + // Check one_of groups + if s.checkOneOfGroups() { + s.complete = true + return s, func() tea.Msg { return tui.StepCompleteMsg{} } + } + // One or more one_of groups unsatisfied — prompts were appended + } + + s.initCurrentPrompt() + return s, s.keyInput.Init() + } + + return s, cmd + } + + return s, nil +} + +// envAlreadyKnown returns true if the env var is already set in OS env or +// was collected by an earlier wizard step (provider key, web search key, etc.). +func (s *SkillsStep) envAlreadyKnown(env string) bool { + if os.Getenv(env) != "" { + return true + } + if v, ok := s.knownEnvVars[env]; ok && v != "" { + return true + } + return false +} + +// buildEnvPrompts creates the list of env prompts for selected skills. +func (s *SkillsStep) buildEnvPrompts() { + s.envPrompts = nil + seen := make(map[string]bool) + + for _, skillName := range s.selected { + sk := s.findSkill(skillName) + if sk == nil { + continue + } + + // Required env vars + for _, env := range sk.RequiredEnv { + if seen[env] || s.envAlreadyKnown(env) { + continue + } + seen[env] = true + s.envPrompts = append(s.envPrompts, envPrompt{ + envVar: env, + label: fmt.Sprintf("%s (required by %s)", env, sk.DisplayName), + allowSkip: false, + skillName: sk.Name, + kind: "required", + }) + } + + // One-of env vars + if len(sk.OneOfEnv) > 0 { + // Check if any one-of is already available + anySet := false + for _, env := range sk.OneOfEnv { + if s.envAlreadyKnown(env) { + anySet = true + break + } + } + if !anySet { + for _, env := range sk.OneOfEnv { + if seen[env] { + continue + } + seen[env] = true + s.envPrompts = append(s.envPrompts, envPrompt{ + envVar: env, + label: fmt.Sprintf("%s (one of %s — %s)", env, strings.Join(sk.OneOfEnv, " / "), sk.DisplayName), + allowSkip: true, // initially skippable, but group must have at least one + skillName: sk.Name, + kind: "one_of", + }) + } + } + } + + // Optional env vars + for _, env := range sk.OptionalEnv { + if seen[env] || s.envAlreadyKnown(env) { + continue + } + seen[env] = true + s.envPrompts = append(s.envPrompts, envPrompt{ + envVar: env, + label: fmt.Sprintf("%s (optional — %s)", env, sk.DisplayName), + allowSkip: true, + skillName: sk.Name, + kind: "optional", + }) + } + } +} + +// checkOneOfGroups verifies that all one_of groups have at least one value. +// If not, appends a mandatory re-prompt and returns false. +func (s *SkillsStep) checkOneOfGroups() bool { + // Collect one_of skills that need checking + type group struct { + skillName string + envVars []string + } + seen := make(map[string]bool) + var groups []group + + for _, p := range s.envPrompts { + if p.kind != "one_of" || seen[p.skillName] { + continue + } + seen[p.skillName] = true + sk := s.findSkill(p.skillName) + if sk != nil { + groups = append(groups, group{skillName: p.skillName, envVars: sk.OneOfEnv}) + } + } + + allSatisfied := true + for _, g := range groups { + hasValue := false + for _, env := range g.envVars { + if v, ok := s.envValues[env]; ok && v != "" { + hasValue = true + break + } + } + if !hasValue { + // Re-prompt the first env var as required + sk := s.findSkill(g.skillName) + displayName := g.skillName + if sk != nil { + displayName = sk.DisplayName + } + label := fmt.Sprintf("%s (required — at least one needed for %s)", g.envVars[0], displayName) + s.envPrompts = append(s.envPrompts, envPrompt{ + envVar: g.envVars[0], + label: label, + allowSkip: false, + skillName: g.skillName, + kind: "required", + }) + allSatisfied = false + } + } + + return allSatisfied +} + +func (s *SkillsStep) initCurrentPrompt() { + if s.currentPrompt >= len(s.envPrompts) { + return + } + prompt := s.envPrompts[s.currentPrompt] + s.keyInput = components.NewSecretInput( + prompt.label, + prompt.allowSkip, + s.styles.Theme.Accent, + s.styles.Theme.Success, + s.styles.Theme.Error, + s.styles.Theme.Border, + s.styles.AccentTxt, + s.styles.InactiveBorder, + s.styles.SuccessTxt, + s.styles.ErrorTxt, + s.styles.DimTxt, + s.styles.KbdKey, + s.styles.KbdDesc, + ) +} + +func (s *SkillsStep) findSkill(name string) *SkillInfo { + for i := range s.allSkills { + if s.allSkills[i].Name == name { + return &s.allSkills[i] + } + } + return nil +} + +func (s *SkillsStep) View(width int) string { + if s.empty { + return fmt.Sprintf(" %s\n", s.styles.DimTxt.Render("No skills available in registry.")) + } + switch s.phase { + case skillsSelectPhase: + return s.multiSelect.View(width) + case skillsEnvPhase: + return s.keyInput.View(width) + } + return "" +} + +func (s *SkillsStep) Complete() bool { + return s.complete +} + +func (s *SkillsStep) Summary() string { + if len(s.selected) == 0 { + return "none" + } + return strings.Join(s.selected, ", ") +} + +func (s *SkillsStep) Apply(ctx *tui.WizardContext) { + ctx.Skills = s.selected + for k, v := range s.envValues { + ctx.EnvVars[k] = v + } +} + +func skillIcon(name string) string { + icons := map[string]string{ + "summarize": "🧾", + "github": "🐙", + "weather": "🌤️", + "tavily-search": "🔍", + } + if icon, ok := icons[name]; ok { + return icon + } + return "📦" +} diff --git a/forge-cli/internal/tui/steps/tools_step.go b/forge-cli/internal/tui/steps/tools_step.go new file mode 100644 index 0000000..8fc547c --- /dev/null +++ b/forge-cli/internal/tui/steps/tools_step.go @@ -0,0 +1,306 @@ +package steps + +import ( + "fmt" + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/initializ/forge/forge-cli/internal/tui" + "github.com/initializ/forge/forge-cli/internal/tui/components" +) + +// ToolInfo represents a builtin tool for the tools step. +type ToolInfo struct { + Name string + Description string +} + +// ValidateWebSearchKeyFunc validates a web search API key for a given provider. +type ValidateWebSearchKeyFunc func(provider, key string) error + +type toolsPhase int + +const ( + toolsSelectPhase toolsPhase = iota + toolsWebSearchProviderPhase + toolsWebSearchKeyPhase + toolsWebSearchValidatingPhase + toolsDonePhase +) + +// ToolsStep handles builtin tool selection. +type ToolsStep struct { + styles *tui.StyleSet + phase toolsPhase + multiSelect components.MultiSelect + providerSelect components.SingleSelect + keyInput components.SecretInput + complete bool + selected []string + webSearchKey string + webSearchKeyName string // "TAVILY_API_KEY" or "PERPLEXITY_API_KEY" + webSearchProvider string // "tavily" or "perplexity" + validateFn ValidateWebSearchKeyFunc + validating bool +} + +// NewToolsStep creates a new tools selection step. +func NewToolsStep(styles *tui.StyleSet, tools []ToolInfo, validateFn ValidateWebSearchKeyFunc) *ToolsStep { + var items []components.MultiSelectItem + for _, t := range tools { + icon := toolIcon(t.Name) + items = append(items, components.MultiSelectItem{ + Label: t.Name, + Value: t.Name, + Description: t.Description, + Icon: icon, + }) + } + + ms := components.NewMultiSelect( + items, + styles.Theme.Accent, + styles.Theme.AccentDim, + styles.Theme.Primary, + styles.Theme.Secondary, + styles.Theme.Dim, + styles.ActiveBorder, + styles.InactiveBorder, + styles.KbdKey, + styles.KbdDesc, + ) + + return &ToolsStep{ + styles: styles, + multiSelect: ms, + validateFn: validateFn, + } +} + +func (s *ToolsStep) Title() string { return "Built-in Tools" } +func (s *ToolsStep) Icon() string { return "🔧" } + +func (s *ToolsStep) Init() tea.Cmd { + return s.multiSelect.Init() +} + +func (s *ToolsStep) Update(msg tea.Msg) (tui.Step, tea.Cmd) { + if s.complete { + return s, nil + } + + switch s.phase { + case toolsSelectPhase: + updated, cmd := s.multiSelect.Update(msg) + s.multiSelect = updated + + if s.multiSelect.Done() { + s.selected = s.multiSelect.SelectedValues() + + // Check if web_search selected and no key is already set + if containsStr(s.selected, "web_search") && + os.Getenv("TAVILY_API_KEY") == "" && + os.Getenv("PERPLEXITY_API_KEY") == "" { + // Show provider selection + s.phase = toolsWebSearchProviderPhase + s.providerSelect = components.NewSingleSelect( + []components.SingleSelectItem{ + {Label: "Tavily (Recommended)", Value: "tavily", Description: "LLM-optimized search with structured results", Icon: "🔍"}, + {Label: "Perplexity", Value: "perplexity", Description: "AI-powered search with citations", Icon: "🌐"}, + }, + s.styles.Theme.Accent, + s.styles.Theme.Primary, + s.styles.Theme.Secondary, + s.styles.Theme.Dim, + s.styles.Theme.Border, + s.styles.Theme.Accent, + s.styles.Theme.AccentDim, + s.styles.KbdKey, + s.styles.KbdDesc, + ) + return s, s.providerSelect.Init() + } + + // If a key is already set in env, detect the provider + if containsStr(s.selected, "web_search") { + if os.Getenv("TAVILY_API_KEY") != "" { + s.webSearchProvider = "tavily" + } else if os.Getenv("PERPLEXITY_API_KEY") != "" { + s.webSearchProvider = "perplexity" + } + } + + s.complete = true + return s, func() tea.Msg { return tui.StepCompleteMsg{} } + } + + return s, cmd + + case toolsWebSearchProviderPhase: + updated, cmd := s.providerSelect.Update(msg) + s.providerSelect = updated + + if s.providerSelect.Done() { + _, s.webSearchProvider = s.providerSelect.Selected() + s.initKeyInput("") + return s, s.keyInput.Init() + } + + return s, cmd + + case toolsWebSearchKeyPhase: + updated, cmd := s.keyInput.Update(msg) + s.keyInput = updated + + if s.keyInput.Done() { + s.webSearchKey = s.keyInput.Value() + + // Run validation if we have a key and a validateFn + if s.webSearchKey != "" && s.validateFn != nil { + s.phase = toolsWebSearchValidatingPhase + s.validating = true + return s, s.runValidation() + } + + s.complete = true + return s, func() tea.Msg { return tui.StepCompleteMsg{} } + } + + return s, cmd + + case toolsWebSearchValidatingPhase: + if msg, ok := msg.(tui.ValidationResultMsg); ok { + s.validating = false + if msg.Err != nil { + // Validation failed — go back to key input with error + s.initKeyInput(fmt.Sprintf("retry — %s", msg.Err)) + s.keyInput.SetState(components.SecretInputFailed, msg.Err.Error()) + return s, s.keyInput.Init() + } + // Success + s.complete = true + return s, func() tea.Msg { return tui.StepCompleteMsg{} } + } + + return s, nil + } + + return s, nil +} + +// initKeyInput creates a fresh SecretInput for the web search API key. +func (s *ToolsStep) initKeyInput(suffix string) { + keyLabel := "Tavily API key for web_search" + s.webSearchKeyName = "TAVILY_API_KEY" + if s.webSearchProvider == "perplexity" { + keyLabel = "Perplexity API key for web_search" + s.webSearchKeyName = "PERPLEXITY_API_KEY" + } + if suffix != "" { + keyLabel = fmt.Sprintf("%s (%s)", keyLabel, suffix) + } + + s.phase = toolsWebSearchKeyPhase + s.keyInput = components.NewSecretInput( + keyLabel, + false, // required — cannot skip + s.styles.Theme.Accent, + s.styles.Theme.Success, + s.styles.Theme.Error, + s.styles.Theme.Border, + s.styles.AccentTxt, + s.styles.InactiveBorder, + s.styles.SuccessTxt, + s.styles.ErrorTxt, + s.styles.DimTxt, + s.styles.KbdKey, + s.styles.KbdDesc, + ) +} + +// runValidation runs the web search key validation asynchronously. +func (s *ToolsStep) runValidation() tea.Cmd { + provider := s.webSearchProvider + key := s.webSearchKey + validateFn := s.validateFn + return func() tea.Msg { + if validateFn == nil { + return tui.ValidationResultMsg{Err: nil} + } + err := validateFn(provider, key) + return tui.ValidationResultMsg{Err: err} + } +} + +func (s *ToolsStep) View(width int) string { + switch s.phase { + case toolsSelectPhase: + return s.multiSelect.View(width) + case toolsWebSearchProviderPhase: + return s.providerSelect.View(width) + case toolsWebSearchKeyPhase: + return s.keyInput.View(width) + case toolsWebSearchValidatingPhase: + if s.validating { + return " " + s.styles.AccentTxt.Render("⣾ Validating...") + "\n" + } + return s.keyInput.View(width) + } + return "" +} + +func (s *ToolsStep) Complete() bool { + return s.complete +} + +func (s *ToolsStep) Summary() string { + if len(s.selected) == 0 { + return "none" + } + var parts []string + for _, name := range s.selected { + if name == "web_search" && s.webSearchProvider != "" { + parts = append(parts, fmt.Sprintf("web_search [%s]", s.webSearchProvider)) + } else { + parts = append(parts, name) + } + } + return strings.Join(parts, ", ") +} + +func (s *ToolsStep) Apply(ctx *tui.WizardContext) { + ctx.BuiltinTools = s.selected + if s.webSearchKey != "" && s.webSearchKeyName != "" { + ctx.EnvVars[s.webSearchKeyName] = s.webSearchKey + } + if s.webSearchProvider != "" { + ctx.EnvVars["WEB_SEARCH_PROVIDER"] = s.webSearchProvider + } +} + +func toolIcon(name string) string { + icons := map[string]string{ + "http_request": "🌐", + "json_parse": "📋", + "csv_parse": "📊", + "datetime_now": "🕐", + "uuid_generate": "🔑", + "math_calculate": "🔢", + "web_search": "🔍", + } + if icon, ok := icons[name]; ok { + return icon + } + return "🔧" +} + +func containsStr(slice []string, val string) bool { + for _, s := range slice { + if s == val { + return true + } + } + return false +} diff --git a/forge-cli/internal/tui/theme.go b/forge-cli/internal/tui/theme.go new file mode 100644 index 0000000..e13be36 --- /dev/null +++ b/forge-cli/internal/tui/theme.go @@ -0,0 +1,226 @@ +package tui + +import ( + "os" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// TermTheme holds all color values for a TUI theme. +type TermTheme struct { + Name string + + // Brand + Accent lipgloss.Color + AccentDim lipgloss.Color + + // Semantic + Success lipgloss.Color + Warning lipgloss.Color + Error lipgloss.Color + + // Text + Primary lipgloss.Color + Secondary lipgloss.Color + Dim lipgloss.Color + + // Surfaces + Surface lipgloss.Color + Border lipgloss.Color + ActiveBorder lipgloss.Color + ActiveBg lipgloss.Color +} + +// DarkTheme is the default dark terminal theme. +var DarkTheme = TermTheme{ + Name: "dark", + Accent: lipgloss.Color("#f97316"), + AccentDim: lipgloss.Color("#c2410c"), + Success: lipgloss.Color("#22c55e"), + Warning: lipgloss.Color("#eab308"), + Error: lipgloss.Color("#ef4444"), + Primary: lipgloss.Color("#e0e0e8"), + Secondary: lipgloss.Color("#888888"), + Dim: lipgloss.Color("#5a5a70"), + Surface: lipgloss.Color("#1a1a24"), + Border: lipgloss.Color("#2a2a3a"), + ActiveBorder: lipgloss.Color("#f97316"), + ActiveBg: lipgloss.Color("#1c1408"), +} + +// LightTheme is the light terminal theme. +var LightTheme = TermTheme{ + Name: "light", + Accent: lipgloss.Color("#c2410c"), + AccentDim: lipgloss.Color("#7c2d12"), + Success: lipgloss.Color("#15803d"), + Warning: lipgloss.Color("#a16207"), + Error: lipgloss.Color("#b91c1c"), + Primary: lipgloss.Color("#0f172a"), + Secondary: lipgloss.Color("#374151"), + Dim: lipgloss.Color("#4b5563"), + Surface: lipgloss.Color("#ffffff"), + Border: lipgloss.Color("#d1d5db"), + ActiveBorder: lipgloss.Color("#c2410c"), + ActiveBg: lipgloss.Color("#fff7ed"), +} + +// DetectTheme returns the appropriate theme based on flag, env, or detection. +func DetectTheme(flagVal string) TermTheme { + // 1. --theme flag + switch strings.ToLower(flagVal) { + case "dark": + return DarkTheme + case "light": + return LightTheme + } + + // 2. FORGE_THEME env + if env := os.Getenv("FORGE_THEME"); env != "" { + switch strings.ToLower(env) { + case "dark": + return DarkTheme + case "light": + return LightTheme + } + } + + // 3. COLORFGBG heuristic (format: "fg;bg") + if colorfgbg := os.Getenv("COLORFGBG"); colorfgbg != "" { + parts := strings.Split(colorfgbg, ";") + if len(parts) >= 2 { + bg := parts[len(parts)-1] + // bg values 0-6 or "0" are typically dark backgrounds + // bg values 7-15 are typically light backgrounds + if bg == "15" || bg == "7" { + return LightTheme + } + } + } + + // 4. Default to dark + return DarkTheme +} + +// StyleSet contains pre-computed lipgloss styles derived from a theme. +type StyleSet struct { + Theme TermTheme + + // Text styles + Title lipgloss.Style + Subtitle lipgloss.Style + AccentTxt lipgloss.Style + DimTxt lipgloss.Style + SuccessTxt lipgloss.Style + WarningTxt lipgloss.Style + ErrorTxt lipgloss.Style + PrimaryTxt lipgloss.Style + SecondaryTxt lipgloss.Style + + // Border styles + ActiveBorder lipgloss.Style + InactiveBorder lipgloss.Style + + // Item styles + SelectedItem lipgloss.Style + UnselectedItem lipgloss.Style + Cursor lipgloss.Style + + // Kbd hint + KbdKey lipgloss.Style + KbdDesc lipgloss.Style + + // Banner + Banner lipgloss.Style + + // Summary + SummaryKey lipgloss.Style + SummaryValue lipgloss.Style + + // Bordered box + BorderedBox lipgloss.Style + + // Badge styles (filled background with contrasting text) + StepBadgeComplete lipgloss.Style + StepBadgeActive lipgloss.Style + StepBadgePending lipgloss.Style + + // Version pill + VersionPill lipgloss.Style +} + +// NewStyleSet creates a StyleSet from a theme. +func NewStyleSet(theme TermTheme) *StyleSet { + return &StyleSet{ + Theme: theme, + + Title: lipgloss.NewStyle().Foreground(theme.Accent).Bold(true), + Subtitle: lipgloss.NewStyle().Foreground(theme.Secondary), + AccentTxt: lipgloss.NewStyle().Foreground(theme.Accent), + DimTxt: lipgloss.NewStyle().Foreground(theme.Dim), + SuccessTxt: lipgloss.NewStyle().Foreground(theme.Success), + WarningTxt: lipgloss.NewStyle().Foreground(theme.Warning), + ErrorTxt: lipgloss.NewStyle().Foreground(theme.Error), + PrimaryTxt: lipgloss.NewStyle().Foreground(theme.Primary), + SecondaryTxt: lipgloss.NewStyle().Foreground(theme.Secondary), + + ActiveBorder: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(theme.ActiveBorder), + InactiveBorder: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(theme.Border), + + SelectedItem: lipgloss.NewStyle(). + Foreground(theme.Primary). + Bold(true), + UnselectedItem: lipgloss.NewStyle(). + Foreground(theme.Secondary), + Cursor: lipgloss.NewStyle(). + Foreground(theme.Accent), + + KbdKey: lipgloss.NewStyle(). + Foreground(theme.Primary). + Background(theme.Dim). + Padding(0, 1), + KbdDesc: lipgloss.NewStyle(). + Foreground(theme.Dim), + + Banner: lipgloss.NewStyle(). + Foreground(theme.Accent). + Bold(true), + + SummaryKey: lipgloss.NewStyle(). + Foreground(theme.Secondary). + Width(16), + SummaryValue: lipgloss.NewStyle(). + Foreground(theme.Primary). + Bold(true), + + BorderedBox: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(theme.Border). + Padding(0, 1), + + StepBadgeComplete: lipgloss.NewStyle(). + Background(theme.Success). + Foreground(lipgloss.Color("#ffffff")). + Bold(true). + Padding(0, 1), + StepBadgeActive: lipgloss.NewStyle(). + Background(theme.Accent). + Foreground(lipgloss.Color("#ffffff")). + Bold(true). + Padding(0, 1), + StepBadgePending: lipgloss.NewStyle(). + Background(theme.Border). + Foreground(theme.Secondary). + Padding(0, 1), + VersionPill: lipgloss.NewStyle(). + Background(theme.Accent). + Foreground(lipgloss.Color("#ffffff")). + Padding(0, 1). + Bold(true), + } +} diff --git a/forge-cli/internal/tui/wizard.go b/forge-cli/internal/tui/wizard.go new file mode 100644 index 0000000..1ff8bca --- /dev/null +++ b/forge-cli/internal/tui/wizard.go @@ -0,0 +1,159 @@ +package tui + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" +) + +// WizardContext accumulates all data across wizard steps. +type WizardContext struct { + Name string + Provider string + APIKey string + Channel string + ChannelTokens map[string]string + BuiltinTools []string + Skills []string + EgressDomains []string + CustomBaseURL string + CustomModel string + CustomAPIKey string + EnvVars map[string]string +} + +// NewWizardContext creates an initialized WizardContext. +func NewWizardContext() *WizardContext { + return &WizardContext{ + ChannelTokens: make(map[string]string), + EnvVars: make(map[string]string), + } +} + +// WizardModel is the top-level bubbletea model that orchestrates the wizard. +type WizardModel struct { + styles *StyleSet + theme TermTheme + steps []Step + current int + ctx *WizardContext + width int + height int + done bool + err error + version string +} + +// NewWizardModel creates a new wizard with the given steps. +func NewWizardModel(theme TermTheme, steps []Step, version string) WizardModel { + return WizardModel{ + styles: NewStyleSet(theme), + theme: theme, + steps: steps, + ctx: NewWizardContext(), + width: 80, + height: 24, + version: version, + } +} + +// Init initializes the first step. +func (w WizardModel) Init() tea.Cmd { + if len(w.steps) > 0 { + return w.steps[0].Init() + } + return nil +} + +// advanceStep applies the current step's data and moves to the next one. +func (w *WizardModel) advanceStep() tea.Cmd { + if w.current < len(w.steps) { + w.steps[w.current].Apply(w.ctx) + } + + w.current++ + if w.current >= len(w.steps) { + w.done = true + return tea.Quit + } + + // Prepare the next step if it supports it + if preparer, ok := w.steps[w.current].(interface{ Prepare(ctx *WizardContext) }); ok { + preparer.Prepare(w.ctx) + } + return w.steps[w.current].Init() +} + +// Update handles messages for the wizard. +func (w WizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + w.width = msg.Width + w.height = msg.Height + return w, nil + + case tea.KeyMsg: + if msg.String() == "ctrl+c" || msg.String() == "esc" { + w.err = fmt.Errorf("wizard cancelled") + return w, tea.Quit + } + + case StepBackMsg: + if w.current > 0 { + w.current-- + return w, w.steps[w.current].Init() + } + return w, nil + + case StepCompleteMsg: + // This is the sole path for step advancement. + cmd := w.advanceStep() + return w, cmd + } + + // Delegate to current step + if w.current < len(w.steps) { + updated, cmd := w.steps[w.current].Update(msg) + w.steps[w.current] = updated + // Steps signal completion only via StepCompleteMsg — never check Complete() here. + return w, cmd + } + + return w, nil +} + +// View renders the entire wizard UI. +func (w WizardModel) View() string { + var out string + + // Banner + out += "\n" + RenderBanner(w.styles, w.version, w.width) + out += "\n" + + // Step progress (completed steps) + out += RenderProgress(w.steps, w.current, w.styles, w.width) + out += "\n" + + // Active step content + if w.current < len(w.steps) { + out += w.steps[w.current].View(w.width) + } + out += "\n" + + return out +} + +// Context returns the accumulated wizard context. +func (w WizardModel) Context() *WizardContext { + return w.ctx +} + +// Err returns any error that occurred during the wizard. +func (w WizardModel) Err() error { + return w.err +} + +// Done returns true if the wizard completed successfully. +func (w WizardModel) Done() bool { + return w.done +} diff --git a/forge-core/registry/index.json b/forge-core/registry/index.json index fbdb831..6b00870 100644 --- a/forge-core/registry/index.json +++ b/forge-core/registry/index.json @@ -26,5 +26,14 @@ "skill_file": "weather.md", "required_bins": ["curl"], "egress_domains": ["api.openweathermap.org", "api.weatherapi.com"] + }, + { + "name": "tavily-search", + "display_name": "Tavily Search", + "description": "Search the web using Tavily AI search API", + "skill_file": "tavily-search.md", + "required_env": ["TAVILY_API_KEY"], + "required_bins": ["curl", "jq"], + "egress_domains": ["api.tavily.com"] } ] diff --git a/forge-core/registry/registry.go b/forge-core/registry/registry.go index e21d5e6..98d3ebd 100644 --- a/forge-core/registry/registry.go +++ b/forge-core/registry/registry.go @@ -10,6 +10,9 @@ import ( //go:embed skills var skillFS embed.FS +//go:embed scripts +var scriptFS embed.FS + //go:embed index.json var indexJSON []byte @@ -53,3 +56,14 @@ func GetSkillByName(name string) *SkillInfo { } return nil } + +// LoadSkillScript reads an embedded script for a skill. +func LoadSkillScript(name string) ([]byte, error) { + return scriptFS.ReadFile("scripts/" + name + ".sh") +} + +// HasSkillScript checks if a skill has an embedded script. +func HasSkillScript(name string) bool { + _, err := scriptFS.ReadFile("scripts/" + name + ".sh") + return err == nil +} diff --git a/forge-core/registry/registry_test.go b/forge-core/registry/registry_test.go index 9034602..811ea41 100644 --- a/forge-core/registry/registry_test.go +++ b/forge-core/registry/registry_test.go @@ -29,7 +29,7 @@ func TestLoadIndex(t *testing.T) { } } - for _, expected := range []string{"summarize", "github", "weather"} { + for _, expected := range []string{"summarize", "github", "weather", "tavily-search"} { if !names[expected] { t.Errorf("expected skill %q not found in index", expected) } @@ -107,3 +107,66 @@ func TestWeatherSkillRequiredBins(t *testing.T) { t.Error("weather skill should require curl binary") } } + +func TestTavilySearchSkillRequirements(t *testing.T) { + s := GetSkillByName("tavily-search") + if s == nil { + t.Fatal("tavily-search skill not found") + } + if s.DisplayName != "Tavily Search" { + t.Errorf("expected display_name \"Tavily Search\", got %q", s.DisplayName) + } + if len(s.RequiredEnv) == 0 { + t.Error("tavily-search skill should have required_env") + } + foundKey := false + for _, env := range s.RequiredEnv { + if env == "TAVILY_API_KEY" { + foundKey = true + } + } + if !foundKey { + t.Error("tavily-search skill should require TAVILY_API_KEY") + } + if len(s.RequiredBins) < 2 { + t.Error("tavily-search skill should require curl and jq") + } + if len(s.EgressDomains) == 0 { + t.Error("tavily-search skill should have egress_domains") + } + foundDomain := false + for _, d := range s.EgressDomains { + if d == "api.tavily.com" { + foundDomain = true + } + } + if !foundDomain { + t.Error("tavily-search skill should have api.tavily.com egress domain") + } +} + +func TestLoadSkillScript(t *testing.T) { + // tavily-search should have a script + if !HasSkillScript("tavily-search") { + t.Fatal("HasSkillScript(\"tavily-search\") returned false") + } + + data, err := LoadSkillScript("tavily-search") + if err != nil { + t.Fatalf("LoadSkillScript(\"tavily-search\") error: %v", err) + } + if len(data) == 0 { + t.Error("LoadSkillScript(\"tavily-search\") returned empty content") + } + if !strings.Contains(string(data), "TAVILY_API_KEY") { + t.Error("tavily-search script should reference TAVILY_API_KEY") + } + + // Skills without scripts should return false + if HasSkillScript("github") { + t.Error("HasSkillScript(\"github\") should return false") + } + if HasSkillScript("nonexistent") { + t.Error("HasSkillScript(\"nonexistent\") should return false") + } +} diff --git a/forge-core/registry/scripts/tavily-search.sh b/forge-core/registry/scripts/tavily-search.sh new file mode 100755 index 0000000..8635a3f --- /dev/null +++ b/forge-core/registry/scripts/tavily-search.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# tavily-search.sh — Search the web using the Tavily API. +# Usage: ./tavily-search.sh '{"query": "search terms", "max_results": 5}' +# +# Requires: curl, jq, TAVILY_API_KEY environment variable. +set -euo pipefail + +# --- Validate environment --- +if [ -z "${TAVILY_API_KEY:-}" ]; then + echo '{"error": "TAVILY_API_KEY environment variable is not set"}' >&2 + exit 1 +fi + +# --- Read input --- +INPUT="${1:-}" +if [ -z "$INPUT" ]; then + echo '{"error": "usage: tavily-search.sh {\"query\": \"...\"}"}' >&2 + exit 1 +fi + +# Validate JSON +if ! echo "$INPUT" | jq empty 2>/dev/null; then + echo '{"error": "invalid JSON input"}' >&2 + exit 1 +fi + +# --- Check required fields --- +QUERY=$(echo "$INPUT" | jq -r '.query // empty') +if [ -z "$QUERY" ]; then + echo '{"error": "query field is required"}' >&2 + exit 1 +fi + +# --- Call Tavily API --- +RESPONSE=$(curl -s -w "\n%{http_code}" \ + -X POST "https://api.tavily.com/search" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${TAVILY_API_KEY}" \ + -d "$INPUT") + +# Split response body and status code +HTTP_CODE=$(echo "$RESPONSE" | tail -1) +BODY=$(echo "$RESPONSE" | sed '$d') + +if [ "$HTTP_CODE" -ne 200 ]; then + echo "{\"error\": \"Tavily API returned status $HTTP_CODE\", \"details\": $BODY}" >&2 + exit 1 +fi + +# --- Pretty-print response --- +echo "$BODY" | jq . diff --git a/forge-core/registry/skills/tavily-search.md b/forge-core/registry/skills/tavily-search.md new file mode 100644 index 0000000..08a08c5 --- /dev/null +++ b/forge-core/registry/skills/tavily-search.md @@ -0,0 +1,81 @@ +--- +name: tavily-search +description: Search the web using Tavily AI search API +metadata: + forge: + requires: + bins: + - curl + - jq + env: + required: + - TAVILY_API_KEY + one_of: [] + optional: [] +--- + +# Tavily Web Search Skill + +Search the web using the Tavily AI search API, optimized for LLM applications. + +## Authentication + +Set the `TAVILY_API_KEY` environment variable with your Tavily API key. +Get your key at https://tavily.com + +No OAuth or MCP configuration required. + +## Quick Start + +```bash +./scripts/tavily-search.sh '{"query": "latest AI news"}' +``` + +## Tool: tavily_search + +Search the web using Tavily AI. + +**Input:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| query | string | yes | The search query | +| search_depth | string | no | `basic` (fast) or `advanced` (thorough). Default: `basic` | +| max_results | integer | no | Maximum results to return (1-20). Default: 5 | +| time_range | string | no | Filter by time: `day`, `week`, `month`, `year` | +| include_domains | array | no | Only include results from these domains | +| exclude_domains | array | no | Exclude results from these domains | + +**Output:** JSON object with `query`, `answer`, `results` (array of `{title, url, content, score}`), and `response_time`. + +### Search Depth + +| Depth | Speed | Detail | Use Case | +|-------|-------|--------|----------| +| basic | Fast (~1s) | Standard snippets | Quick lookups, fact checks | +| advanced | Slower (~3s) | Detailed content | Research, analysis | + +### Response Format + +```json +{ + "query": "your search query", + "answer": "AI-generated summary answer", + "response_time": 0.5, + "results": [ + { + "title": "Page Title", + "url": "https://example.com", + "content": "Relevant content snippet...", + "score": 0.95 + } + ] +} +``` + +### Tips + +- Use `search_depth: advanced` for research tasks that need detailed content +- Use `include_domains` to restrict searches to trusted sources +- Use `time_range: day` for breaking news or very recent information +- The `answer` field provides a concise AI-generated summary when available diff --git a/forge-core/security/tool_domains.go b/forge-core/security/tool_domains.go index db65bd6..afaf0d7 100644 --- a/forge-core/security/tool_domains.go +++ b/forge-core/security/tool_domains.go @@ -2,8 +2,8 @@ package security // DefaultToolDomains maps tool names to their known required domains. var DefaultToolDomains = map[string][]string{ - "web_search": {"api.perplexity.ai"}, - "web-search": {"api.perplexity.ai"}, + "web_search": {"api.tavily.com", "api.perplexity.ai"}, + "web-search": {"api.tavily.com", "api.perplexity.ai"}, "http_request": {}, // dynamic — depends on user config "slack_notify": {"slack.com", "hooks.slack.com"}, "github_api": {"api.github.com", "github.com"}, diff --git a/forge-core/tools/builtins/builtins_test.go b/forge-core/tools/builtins/builtins_test.go index ea34a27..9add8be 100644 --- a/forge-core/tools/builtins/builtins_test.go +++ b/forge-core/tools/builtins/builtins_test.go @@ -181,11 +181,21 @@ func TestMathCalculateTool_DivisionByZero(t *testing.T) { } func TestWebSearchTool_NoKey(t *testing.T) { - orig := os.Getenv("PERPLEXITY_API_KEY") + origTavily := os.Getenv("TAVILY_API_KEY") + origPerp := os.Getenv("PERPLEXITY_API_KEY") + origProvider := os.Getenv("WEB_SEARCH_PROVIDER") + _ = os.Unsetenv("TAVILY_API_KEY") _ = os.Unsetenv("PERPLEXITY_API_KEY") + _ = os.Unsetenv("WEB_SEARCH_PROVIDER") defer func() { - if orig != "" { - _ = os.Setenv("PERPLEXITY_API_KEY", orig) + if origTavily != "" { + _ = os.Setenv("TAVILY_API_KEY", origTavily) + } + if origPerp != "" { + _ = os.Setenv("PERPLEXITY_API_KEY", origPerp) + } + if origProvider != "" { + _ = os.Setenv("WEB_SEARCH_PROVIDER", origProvider) } }() @@ -195,8 +205,172 @@ func TestWebSearchTool_NoKey(t *testing.T) { if err != nil { t.Fatalf("Execute error: %v", err) } - if !strings.Contains(result, "PERPLEXITY_API_KEY") { - t.Errorf("expected missing key message, got: %q", result) + if !strings.Contains(result, "TAVILY_API_KEY") || !strings.Contains(result, "PERPLEXITY_API_KEY") { + t.Errorf("expected error mentioning both API keys, got: %q", result) + } +} + +func TestWebSearchTool_TavilyProvider(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify authorization header + if auth := r.Header.Get("Authorization"); auth != "Bearer test-tavily-key" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ //nolint:errcheck + "query": "test query", + "response_time": 0.5, + "answer": "Tavily answer", + "results": []map[string]any{ + { + "title": "Result 1", + "url": "https://example.com", + "content": "Example content", + "score": 0.95, + }, + }, + }) + })) + defer ts.Close() + + // Create a Tavily provider with test server URL + p := &tavilyProvider{apiKey: "test-tavily-key", baseURL: ts.URL} + result, err := p.search(context.Background(), "test query", webSearchOpts{MaxResults: 5}) + if err != nil { + t.Fatalf("search error: %v", err) + } + if !strings.Contains(result, "Tavily answer") { + t.Errorf("expected Tavily answer in result, got: %q", result) + } + if !strings.Contains(result, "Result 1") { + t.Errorf("expected result title in result, got: %q", result) + } +} + +func TestWebSearchTool_PerplexityProvider(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if auth := r.Header.Get("Authorization"); auth != "Bearer test-perplexity-key" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ //nolint:errcheck + "choices": []map[string]any{ + { + "message": map[string]string{ + "content": "Perplexity answer", + }, + }, + }, + "citations": []string{"https://source.com"}, + }) + })) + defer ts.Close() + + p := &perplexityProvider{apiKey: "test-perplexity-key", baseURL: ts.URL} + result, err := p.search(context.Background(), "test query", webSearchOpts{}) + if err != nil { + t.Fatalf("search error: %v", err) + } + if !strings.Contains(result, "Perplexity answer") { + t.Errorf("expected Perplexity answer in result, got: %q", result) + } + if !strings.Contains(result, "source.com") { + t.Errorf("expected citation in result, got: %q", result) + } +} + +func TestWebSearchTool_ProviderOverride(t *testing.T) { + origTavily := os.Getenv("TAVILY_API_KEY") + origPerp := os.Getenv("PERPLEXITY_API_KEY") + origProvider := os.Getenv("WEB_SEARCH_PROVIDER") + _ = os.Unsetenv("TAVILY_API_KEY") + _ = os.Unsetenv("PERPLEXITY_API_KEY") + _ = os.Setenv("WEB_SEARCH_PROVIDER", "tavily") + defer func() { + if origTavily != "" { + _ = os.Setenv("TAVILY_API_KEY", origTavily) + } else { + _ = os.Unsetenv("TAVILY_API_KEY") + } + if origPerp != "" { + _ = os.Setenv("PERPLEXITY_API_KEY", origPerp) + } else { + _ = os.Unsetenv("PERPLEXITY_API_KEY") + } + if origProvider != "" { + _ = os.Setenv("WEB_SEARCH_PROVIDER", origProvider) + } else { + _ = os.Unsetenv("WEB_SEARCH_PROVIDER") + } + }() + + tool := GetByName("web_search") + args, _ := json.Marshal(map[string]string{"query": "test"}) + result, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + // Should error because TAVILY_API_KEY is not set + if !strings.Contains(result, "TAVILY_API_KEY") { + t.Errorf("expected missing TAVILY_API_KEY message, got: %q", result) + } +} + +func TestWebSearchTool_ExplicitPerplexity(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ //nolint:errcheck + "choices": []map[string]any{ + {"message": map[string]string{"content": "Perplexity explicit"}}, + }, + }) + })) + defer ts.Close() + + // Both keys set, but WEB_SEARCH_PROVIDER=perplexity -> should use Perplexity + origTavily := os.Getenv("TAVILY_API_KEY") + origPerp := os.Getenv("PERPLEXITY_API_KEY") + origProvider := os.Getenv("WEB_SEARCH_PROVIDER") + _ = os.Setenv("TAVILY_API_KEY", "some-tavily-key") + _ = os.Setenv("PERPLEXITY_API_KEY", "test-perp-key") + _ = os.Setenv("WEB_SEARCH_PROVIDER", "perplexity") + defer func() { + if origTavily != "" { + _ = os.Setenv("TAVILY_API_KEY", origTavily) + } else { + _ = os.Unsetenv("TAVILY_API_KEY") + } + if origPerp != "" { + _ = os.Setenv("PERPLEXITY_API_KEY", origPerp) + } else { + _ = os.Unsetenv("PERPLEXITY_API_KEY") + } + if origProvider != "" { + _ = os.Setenv("WEB_SEARCH_PROVIDER", origProvider) + } else { + _ = os.Unsetenv("WEB_SEARCH_PROVIDER") + } + }() + + // Use the provider directly with test server + p := &perplexityProvider{apiKey: "test-perp-key", baseURL: ts.URL} + result, err := p.search(context.Background(), "test", webSearchOpts{}) + if err != nil { + t.Fatalf("search error: %v", err) + } + if !strings.Contains(result, "Perplexity explicit") { + t.Errorf("expected Perplexity response, got: %q", result) + } + + // Also verify resolveWebSearchProvider picks Perplexity + provider, resolveErr := resolveWebSearchProvider() + if resolveErr != nil { + t.Fatalf("resolveWebSearchProvider error: %v", resolveErr) + } + if provider.name() != "perplexity" { + t.Errorf("expected perplexity provider, got %q", provider.name()) } } diff --git a/forge-core/tools/builtins/web_search.go b/forge-core/tools/builtins/web_search.go index fde0f68..9d79a74 100644 --- a/forge-core/tools/builtins/web_search.go +++ b/forge-core/tools/builtins/web_search.go @@ -1,12 +1,9 @@ package builtins import ( - "bytes" "context" "encoding/json" "fmt" - "io" - "net/http" "os" "github.com/initializ/forge/forge-core/tools" @@ -15,7 +12,7 @@ import ( type webSearchTool struct{} func (t *webSearchTool) Name() string { return "web_search" } -func (t *webSearchTool) Description() string { return "Search the web using Perplexity AI" } +func (t *webSearchTool) Description() string { return "Search the web using Tavily or Perplexity AI" } func (t *webSearchTool) Category() tools.Category { return tools.CategoryBuiltin } func (t *webSearchTool) InputSchema() json.RawMessage { @@ -23,23 +20,26 @@ func (t *webSearchTool) InputSchema() json.RawMessage { "type": "object", "properties": { "query": {"type": "string", "description": "Search query"}, - "max_results": {"type": "integer", "description": "Maximum number of results (default 5)"} + "max_results": {"type": "integer", "description": "Maximum number of results (default 5)"}, + "search_depth": {"type": "string", "description": "Search depth: basic or advanced (Tavily only)", "enum": ["basic", "advanced"]}, + "time_range": {"type": "string", "description": "Time range filter: day, week, month, year (Tavily only)"}, + "include_domains": {"type": "array", "items": {"type": "string"}, "description": "Only include results from these domains (Tavily only)"}, + "exclude_domains": {"type": "array", "items": {"type": "string"}, "description": "Exclude results from these domains (Tavily only)"} }, "required": ["query"] }`) } type webSearchInput struct { - Query string `json:"query"` - MaxResults int `json:"max_results,omitempty"` + Query string `json:"query"` + MaxResults int `json:"max_results,omitempty"` + SearchDepth string `json:"search_depth,omitempty"` + TimeRange string `json:"time_range,omitempty"` + IncludeDomains []string `json:"include_domains,omitempty"` + ExcludeDomains []string `json:"exclude_domains,omitempty"` } func (t *webSearchTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { - apiKey := os.Getenv("PERPLEXITY_API_KEY") - if apiKey == "" { - return `{"error": "PERPLEXITY_API_KEY is not set. Add it to your .env file to enable web search."}`, nil - } - var input webSearchInput if err := json.Unmarshal(args, &input); err != nil { return "", fmt.Errorf("parsing web_search input: %w", err) @@ -48,65 +48,53 @@ func (t *webSearchTool) Execute(ctx context.Context, args json.RawMessage) (stri return `{"error": "query is required"}`, nil } - // Build Perplexity chat completion request - reqBody := map[string]any{ - "model": "sonar", - "messages": []map[string]string{ - {"role": "user", "content": input.Query}, - }, - } - bodyBytes, err := json.Marshal(reqBody) - if err != nil { - return "", fmt.Errorf("marshalling request: %w", err) - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.perplexity.ai/chat/completions", bytes.NewReader(bodyBytes)) - if err != nil { - return "", fmt.Errorf("creating request: %w", err) - } - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+apiKey) - - resp, err := http.DefaultClient.Do(httpReq) + provider, err := resolveWebSearchProvider() if err != nil { - return "", fmt.Errorf("calling Perplexity API: %w", err) + return fmt.Sprintf(`{"error": %q}`, err.Error()), nil } - defer func() { _ = resp.Body.Close() }() - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("reading response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return fmt.Sprintf(`{"error": "Perplexity API returned status %d: %s"}`, resp.StatusCode, string(respBody)), nil - } - - // Extract the answer from the response - var pResp struct { - Choices []struct { - Message struct { - Content string `json:"content"` - } `json:"message"` - } `json:"choices"` - Citations []string `json:"citations,omitempty"` - } - if err := json.Unmarshal(respBody, &pResp); err != nil { - return "", fmt.Errorf("parsing Perplexity response: %w", err) + opts := webSearchOpts{ + MaxResults: input.MaxResults, + SearchDepth: input.SearchDepth, + TimeRange: input.TimeRange, + IncludeDomains: input.IncludeDomains, + ExcludeDomains: input.ExcludeDomains, } - if len(pResp.Choices) == 0 { - return `{"error": "no results from Perplexity"}`, nil - } + return provider.search(ctx, input.Query, opts) +} - result := map[string]any{ - "query": input.Query, - "answer": pResp.Choices[0].Message.Content, +// resolveWebSearchProvider selects the web search provider based on environment. +// Priority: WEB_SEARCH_PROVIDER env > auto-detect (Tavily first, then Perplexity). +func resolveWebSearchProvider() (webSearchProvider, error) { + override := os.Getenv("WEB_SEARCH_PROVIDER") + + switch override { + case "tavily": + key := os.Getenv("TAVILY_API_KEY") + if key == "" { + return nil, fmt.Errorf("WEB_SEARCH_PROVIDER is set to tavily but TAVILY_API_KEY is not set") + } + return newTavilyProvider(key), nil + + case "perplexity": + key := os.Getenv("PERPLEXITY_API_KEY") + if key == "" { + return nil, fmt.Errorf("WEB_SEARCH_PROVIDER is set to perplexity but PERPLEXITY_API_KEY is not set") + } + return newPerplexityProvider(key), nil + + case "": + // Auto-detect: try Tavily first, then Perplexity + if key := os.Getenv("TAVILY_API_KEY"); key != "" { + return newTavilyProvider(key), nil + } + if key := os.Getenv("PERPLEXITY_API_KEY"); key != "" { + return newPerplexityProvider(key), nil + } + return nil, fmt.Errorf("no web search API key set. Set TAVILY_API_KEY or PERPLEXITY_API_KEY in your .env file to enable web search") + + default: + return nil, fmt.Errorf("unknown WEB_SEARCH_PROVIDER %q: must be tavily or perplexity", override) } - if len(pResp.Citations) > 0 { - result["citations"] = pResp.Citations - } - - out, _ := json.Marshal(result) - return string(out), nil } diff --git a/forge-core/tools/builtins/web_search_perplexity.go b/forge-core/tools/builtins/web_search_perplexity.go new file mode 100644 index 0000000..1193eda --- /dev/null +++ b/forge-core/tools/builtins/web_search_perplexity.go @@ -0,0 +1,90 @@ +package builtins + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// perplexityProvider implements webSearchProvider using the Perplexity API. +type perplexityProvider struct { + apiKey string + baseURL string // defaults to "https://api.perplexity.ai" +} + +func newPerplexityProvider(apiKey string) *perplexityProvider { + return &perplexityProvider{apiKey: apiKey, baseURL: "https://api.perplexity.ai"} +} + +func (p *perplexityProvider) name() string { return "perplexity" } + +func (p *perplexityProvider) egressDomains() []string { + return []string{"api.perplexity.ai"} +} + +func (p *perplexityProvider) search(ctx context.Context, query string, opts webSearchOpts) (string, error) { + // Perplexity uses the chat completions API with the sonar model. + // Tavily-specific opts (search_depth, time_range, domains) are ignored gracefully. + reqBody := map[string]any{ + "model": "sonar", + "messages": []map[string]string{ + {"role": "user", "content": query}, + }, + } + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("marshalling Perplexity request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/chat/completions", bytes.NewReader(bodyBytes)) + if err != nil { + return "", fmt.Errorf("creating Perplexity request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+p.apiKey) + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return "", fmt.Errorf("calling Perplexity API: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading Perplexity response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Sprintf(`{"error": "Perplexity API returned status %d: %s"}`, resp.StatusCode, string(respBody)), nil + } + + var pResp struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + Citations []string `json:"citations,omitempty"` + } + if err := json.Unmarshal(respBody, &pResp); err != nil { + return "", fmt.Errorf("parsing Perplexity response: %w", err) + } + + if len(pResp.Choices) == 0 { + return `{"error": "no results from Perplexity"}`, nil + } + + result := map[string]any{ + "query": query, + "answer": pResp.Choices[0].Message.Content, + } + if len(pResp.Citations) > 0 { + result["citations"] = pResp.Citations + } + + out, _ := json.Marshal(result) + return string(out), nil +} diff --git a/forge-core/tools/builtins/web_search_provider.go b/forge-core/tools/builtins/web_search_provider.go new file mode 100644 index 0000000..06ef4cd --- /dev/null +++ b/forge-core/tools/builtins/web_search_provider.go @@ -0,0 +1,19 @@ +package builtins + +import "context" + +// webSearchProvider abstracts a web search backend (Tavily, Perplexity, etc.). +type webSearchProvider interface { + name() string + search(ctx context.Context, query string, opts webSearchOpts) (string, error) + egressDomains() []string +} + +// webSearchOpts holds optional parameters for a web search request. +type webSearchOpts struct { + MaxResults int `json:"max_results"` + SearchDepth string `json:"search_depth"` + TimeRange string `json:"time_range"` + IncludeDomains []string `json:"include_domains"` + ExcludeDomains []string `json:"exclude_domains"` +} diff --git a/forge-core/tools/builtins/web_search_tavily.go b/forge-core/tools/builtins/web_search_tavily.go new file mode 100644 index 0000000..8674d99 --- /dev/null +++ b/forge-core/tools/builtins/web_search_tavily.go @@ -0,0 +1,113 @@ +package builtins + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// tavilyProvider implements webSearchProvider using the Tavily API. +type tavilyProvider struct { + apiKey string + baseURL string // defaults to "https://api.tavily.com" +} + +func newTavilyProvider(apiKey string) *tavilyProvider { + return &tavilyProvider{apiKey: apiKey, baseURL: "https://api.tavily.com"} +} + +func (p *tavilyProvider) name() string { return "tavily" } + +func (p *tavilyProvider) egressDomains() []string { + return []string{"api.tavily.com"} +} + +func (p *tavilyProvider) search(ctx context.Context, query string, opts webSearchOpts) (string, error) { + reqBody := map[string]any{ + "query": query, + } + if opts.MaxResults > 0 { + reqBody["max_results"] = opts.MaxResults + } + if opts.SearchDepth != "" { + reqBody["search_depth"] = opts.SearchDepth + } + if opts.TimeRange != "" { + reqBody["time_range"] = opts.TimeRange + } + if len(opts.IncludeDomains) > 0 { + reqBody["include_domains"] = opts.IncludeDomains + } + if len(opts.ExcludeDomains) > 0 { + reqBody["exclude_domains"] = opts.ExcludeDomains + } + + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("marshalling Tavily request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/search", bytes.NewReader(bodyBytes)) + if err != nil { + return "", fmt.Errorf("creating Tavily request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+p.apiKey) + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return "", fmt.Errorf("calling Tavily API: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading Tavily response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Sprintf(`{"error": "Tavily API returned status %d: %s"}`, resp.StatusCode, string(respBody)), nil + } + + // Parse the Tavily response + var tResp struct { + Query string `json:"query"` + ResponseTime float64 `json:"response_time"` + Answer string `json:"answer,omitempty"` + Results []struct { + Title string `json:"title"` + URL string `json:"url"` + Content string `json:"content"` + Score float64 `json:"score"` + } `json:"results"` + } + if err := json.Unmarshal(respBody, &tResp); err != nil { + return "", fmt.Errorf("parsing Tavily response: %w", err) + } + + result := map[string]any{ + "query": tResp.Query, + "response_time": tResp.ResponseTime, + } + if tResp.Answer != "" { + result["answer"] = tResp.Answer + } + if len(tResp.Results) > 0 { + var results []map[string]any + for _, r := range tResp.Results { + results = append(results, map[string]any{ + "title": r.Title, + "url": r.URL, + "content": r.Content, + "score": r.Score, + }) + } + result["results"] = results + } + + out, _ := json.Marshal(result) + return string(out), nil +} diff --git a/forge-plugins/channels/telegram/telegram.go b/forge-plugins/channels/telegram/telegram.go index d272e6b..173a466 100644 --- a/forge-plugins/channels/telegram/telegram.go +++ b/forge-plugins/channels/telegram/telegram.go @@ -136,7 +136,9 @@ func (p *Plugin) makeWebhookHandler(handler channels.EventHandler) http.HandlerF go func() { ctx := context.Background() + stopTyping := p.startTypingIndicator(ctx, event.WorkspaceID) resp, err := handler(ctx, event) + stopTyping() if err != nil { fmt.Printf("telegram: handler error: %v\n", err) return @@ -189,7 +191,9 @@ func (p *Plugin) startPolling(ctx context.Context, handler channels.EventHandler } go func() { + stopTyping := p.startTypingIndicator(ctx, event.WorkspaceID) resp, err := handler(ctx, event) + stopTyping() if err != nil { fmt.Printf("telegram: handler error: %v\n", err) return @@ -280,6 +284,67 @@ func (p *Plugin) SendResponse(event *channels.ChannelEvent, response *a2a.Messag return nil } +// sendChatAction sends a chat action (e.g. "typing") to indicate activity. +func (p *Plugin) sendChatAction(chatID, action string) error { + payload := map[string]string{ + "chat_id": chatID, + "action": action, + } + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshalling chat action: %w", err) + } + + url := fmt.Sprintf("%s/bot%s/sendChatAction", p.apiBase, p.botToken) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("creating chat action request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := p.client.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + _, _ = io.ReadAll(resp.Body) + return nil +} + +// startTypingIndicator sends "typing" chat action repeatedly until the +// returned stop function is called. Telegram's typing indicator expires +// after ~5 seconds, so we resend every 4 seconds. +func (p *Plugin) startTypingIndicator(ctx context.Context, chatID string) (stop func()) { + done := make(chan struct{}) + stop = func() { + select { + case <-done: + default: + close(done) + } + } + + // Send the first typing indicator immediately. + _ = p.sendChatAction(chatID, "typing") + + go func() { + ticker := time.NewTicker(4 * time.Second) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-ctx.Done(): + return + case <-ticker.C: + _ = p.sendChatAction(chatID, "typing") + } + } + }() + + return stop +} + // sendMessage posts a JSON payload to the Telegram sendMessage API. func (p *Plugin) sendMessage(payload map[string]any) error { body, err := json.Marshal(payload) diff --git a/forge-plugins/channels/telegram/telegram_test.go b/forge-plugins/channels/telegram/telegram_test.go index 92b5d34..b295fb5 100644 --- a/forge-plugins/channels/telegram/telegram_test.go +++ b/forge-plugins/channels/telegram/telegram_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/initializ/forge/forge-core/a2a" "github.com/initializ/forge/forge-core/channels" @@ -272,6 +273,66 @@ func TestInit_MissingToken(t *testing.T) { } } +func TestSendChatAction(t *testing.T) { + var receivedAction string + var receivedChatID string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var payload map[string]string + json.Unmarshal(body, &payload) //nolint:errcheck + receivedChatID = payload["chat_id"] + receivedAction = payload["action"] + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) //nolint:errcheck + })) + defer srv.Close() + + p := New() + p.botToken = "test-token" + p.apiBase = srv.URL + + err := p.sendChatAction("12345", "typing") + if err != nil { + t.Fatalf("sendChatAction() error: %v", err) + } + if receivedChatID != "12345" { + t.Errorf("chat_id = %q, want 12345", receivedChatID) + } + if receivedAction != "typing" { + t.Errorf("action = %q, want typing", receivedAction) + } +} + +func TestStartTypingIndicator(t *testing.T) { + var actionCount int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Count sendChatAction calls (path contains sendChatAction) + if strings.Contains(r.URL.Path, "sendChatAction") { + actionCount++ + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) //nolint:errcheck + })) + defer srv.Close() + + p := New() + p.botToken = "test-token" + p.apiBase = srv.URL + + ctx := context.Background() + stop := p.startTypingIndicator(ctx, "67890") + + // The first typing action is sent immediately + // Give it a moment to process + time.Sleep(100 * time.Millisecond) + + if actionCount < 1 { + t.Errorf("expected at least 1 typing action, got %d", actionCount) + } + + stop() +} + func TestInit_InvalidMode(t *testing.T) { p := New() diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..ce1ac27 --- /dev/null +++ b/go.work.sum @@ -0,0 +1,13 @@ +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=