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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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 ./...
```
67 changes: 52 additions & 15 deletions forge-cli/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,38 +187,42 @@ func collectInteractive(opts *initOptions) error {
DisplayName: s.DisplayName,
Description: s.Description,
RequiredEnv: s.RequiredEnv,
OneOfEnv: s.OneOfEnv,
OptionalEnv: s.OptionalEnv,
RequiredBins: s.RequiredBins,
EgressDomains: s.EgressDomains,
})
}
}

// Build the egress derivation callback (avoids circular import)
deriveEgressFn := func(provider string, channels, tools, skills []string) []string {
deriveEgressFn := func(provider string, channels, tools, skills []string, envVars map[string]string) []string {
tmpOpts := &initOptions{
ModelProvider: provider,
Channels: channels,
BuiltinTools: tools,
EnvVars: make(map[string]string),
EnvVars: envVars,
}
selectedInfos := lookupSelectedSkills(skills)
return deriveEgressDomains(tmpOpts, selectedInfos)
}

// Build validation callbacks
// Build validation callback
validateKeyFn := func(provider, key string) error {
return validateProviderKey(provider, key)
}
validatePerpFn := func(key string) error {
return validatePerplexityKey(key)

// Build web search key validation callback
validateWebSearchKeyFn := func(provider, key string) error {
return validateWebSearchKey(provider, key)
}

// Build step list
wizardSteps := []tui.Step{
steps.NewNameStep(styles, opts.Name),
steps.NewProviderStep(styles, validateKeyFn),
steps.NewChannelStep(styles),
steps.NewToolsStep(styles, toolInfos, validatePerpFn),
steps.NewToolsStep(styles, toolInfos, validateWebSearchKeyFn),
steps.NewSkillsStep(styles, skillInfos),
steps.NewEgressStep(styles, deriveEgressFn),
steps.NewReviewStep(styles), // scaffold is handled by the caller after collectInteractive returns
Expand Down Expand Up @@ -541,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)
Expand Down Expand Up @@ -754,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
Expand All @@ -777,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,
Expand Down
18 changes: 15 additions & 3 deletions forge-cli/cmd/init_egress.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 11 additions & 11 deletions forge-cli/cmd/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}

Expand Down Expand Up @@ -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] {
Expand Down Expand Up @@ -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")
Expand Down
47 changes: 47 additions & 0 deletions forge-cli/cmd/init_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
Expand Down
44 changes: 42 additions & 2 deletions forge-cli/cmd/init_validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand All @@ -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)
}
Expand All @@ -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")
}
Expand Down
Loading
Loading