diff --git a/README.md b/README.md index 5389fd5614..bfbad7285e 100644 --- a/README.md +++ b/README.md @@ -314,13 +314,29 @@ go install go-micro.dev/v5/cmd/micro@v5.16.0 ```bash micro new helloworld # Create a new service -micro new contacts --template crud # Or use a template (crud, pubsub, api) cd helloworld micro run # Run with API gateway and hot reload ``` Then open http://localhost:8080 to see your service and call it from the browser. +### Generate From a Prompt + +Describe what you need in plain English. The AI designs services, writes handlers with real business logic, compiles them, and starts them: + +```bash +micro run --prompt "a task management system with categories" --provider anthropic +``` + +Then talk to your services through an agent: + +```bash +micro chat --provider anthropic +> Create a Work category, then add a task called 'Finish report' to it +``` + +The agent orchestrates across services automatically. When you need a capability that doesn't exist, the agent generates a new service mid-conversation. [Read more](https://go-micro.dev/blog/13). + ### Development Workflow | Stage | Command | Purpose | diff --git a/ai/anthropic/anthropic.go b/ai/anthropic/anthropic.go index f2ffdffc6f..5c8787b017 100644 --- a/ai/anthropic/anthropic.go +++ b/ai/anthropic/anthropic.go @@ -77,7 +77,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen // Build initial request apiReq := map[string]any{ "model": p.opts.Model, - "max_tokens": 4096, + "max_tokens": 8192, "system": req.SystemPrompt, "messages": []map[string]any{ {"role": "user", "content": req.Prompt}, @@ -94,48 +94,66 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen return nil, err } - // If no tool calls, return response - if len(resp.ToolCalls) == 0 { + // If no tool calls or no handler, return as-is + if len(resp.ToolCalls) == 0 || p.opts.ToolHandler == nil { return resp, nil } - // If tool handler is provided, execute tools and get final answer - if p.opts.ToolHandler != nil { - var toolResults []ai.ToolResult - for _, tc := range resp.ToolCalls { - _, content := p.opts.ToolHandler(tc.Name, tc.Input) - toolResults = append(toolResults, ai.ToolResult{ - ID: tc.ID, - Content: content, - }) - } + // Tool execution loop: execute tools, send results back, repeat + // until the model responds with text only (no more tool calls) + messages := []map[string]any{ + {"role": "user", "content": req.Prompt}, + {"role": "assistant", "content": cleanContent(rawContent)}, + } - // Build follow-up request with tool results + pendingCalls := resp.ToolCalls + + for rounds := 0; rounds < 10; rounds++ { var toolResultBlocks []map[string]any - for _, tr := range toolResults { + for i := range pendingCalls { + _, content := p.opts.ToolHandler(pendingCalls[i].Name, pendingCalls[i].Input) + pendingCalls[i].Result = content toolResultBlocks = append(toolResultBlocks, map[string]any{ "type": "tool_result", - "tool_use_id": tr.ID, - "content": tr.Content, + "tool_use_id": pendingCalls[i].ID, + "content": content, }) } + messages = append(messages, map[string]any{ + "role": "user", + "content": toolResultBlocks, + }) + followUpReq := map[string]any{ "model": p.opts.Model, - "max_tokens": 4096, + "max_tokens": 8192, "system": req.SystemPrompt, - "messages": []map[string]any{ - {"role": "user", "content": req.Prompt}, - {"role": "assistant", "content": rawContent}, - {"role": "user", "content": toolResultBlocks}, - }, + "messages": messages, + } + if len(anthropicTools) > 0 { + followUpReq["tools"] = anthropicTools + } + + followUpResp, followUpRaw, err := p.callAPI(ctx, followUpReq) + if err != nil { + break } - // Make follow-up API call - followUpResp, _, err := p.callAPI(ctx, followUpReq) - if err == nil && followUpResp.Reply != "" { + if len(followUpResp.ToolCalls) > 0 { + resp.ToolCalls = append(resp.ToolCalls, followUpResp.ToolCalls...) + pendingCalls = followUpResp.ToolCalls + messages = append(messages, map[string]any{ + "role": "assistant", + "content": cleanContent(followUpRaw), + }) + continue + } + + if followUpResp.Reply != "" { resp.Answer = followUpResp.Reply } + break } return resp, nil @@ -225,3 +243,30 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons return response, anthropicResp.Content, nil } + +// cleanContent strips fields from response content blocks that Anthropic +// rejects when sent back as assistant message content (e.g. "id" on text blocks). +func cleanContent(raw any) any { + blocks, ok := raw.([]struct { + Type string `json:"type"` + Text string `json:"text"` + ID string `json:"id"` + Name string `json:"name"` + Input json.RawMessage `json:"input"` + }) + if !ok { + return raw + } + var cleaned []map[string]any + for _, b := range blocks { + switch b.Type { + case "text": + cleaned = append(cleaned, map[string]any{"type": "text", "text": b.Text}) + case "tool_use": + var input any + json.Unmarshal(b.Input, &input) + cleaned = append(cleaned, map[string]any{"type": "tool_use", "id": b.ID, "name": b.Name, "input": input}) + } + } + return cleaned +} diff --git a/ai/model.go b/ai/model.go index d8d3c4b573..4ae403ef52 100644 --- a/ai/model.go +++ b/ai/model.go @@ -57,11 +57,13 @@ type Response struct { Answer string } -// ToolCall represents a request to call a tool +// ToolCall represents a request to call a tool and its result type ToolCall struct { - ID string // Tool call ID (for correlation) - Name string // Tool name - Input map[string]any // Tool input arguments + ID string // Tool call ID (for correlation) + Name string // Tool name + Input map[string]any // Tool input arguments + Result string // Tool execution result (populated after execution) + Error string // Tool execution error (populated after execution) } // ToolResult represents the result of a tool execution diff --git a/cmd/micro/chat/chat.go b/cmd/micro/chat/chat.go index 228553c0ce..3fa1b2aa47 100644 --- a/cmd/micro/chat/chat.go +++ b/cmd/micro/chat/chat.go @@ -12,15 +12,18 @@ import ( "encoding/json" "fmt" "os" + "os/exec" + "path/filepath" "strings" + "time" "github.com/urfave/cli/v2" "go-micro.dev/v5/ai" - "go-micro.dev/v5/client" + clt "go-micro.dev/v5/client" "go-micro.dev/v5/cmd" + "go-micro.dev/v5/cmd/micro/cli/generate" "go-micro.dev/v5/registry" - // Side-effect imports register the AI providers. _ "go-micro.dev/v5/ai/anthropic" _ "go-micro.dev/v5/ai/atlascloud" _ "go-micro.dev/v5/ai/gemini" @@ -30,9 +33,25 @@ import ( _ "go-micro.dev/v5/ai/together" ) -const systemPrompt = "You are an agent that helps users interact with microservices. " + - "Use the available tools to fulfill user requests. " + - "When you call a tool, explain what you are doing." +const systemPromptTmpl = `You are an agent that orchestrates microservices. Use the available tools to fulfill user requests. When you call a tool, explain what you are doing. + +Available services: %s + +If a user asks for something that no existing service can handle, use the micro_generate_service tool to create it. Pass a short description of what the service should do. After it's created, the new service's endpoints will be available as tools and you can use them immediately. + +Do NOT make up capabilities. Only use the tools that are available. If generation fails, tell the user.` + +var generateTool = ai.Tool{ + Name: "micro_generate_service", + OriginalName: "micro.generate_service", + Description: "Generate a new microservice from a description. Use when the user needs a capability that no existing service provides. The service will be created, compiled, and started automatically.", + Properties: map[string]any{ + "description": map[string]any{ + "type": "string", + "description": "What the service should do, e.g. 'a shipping service that tracks parcels and calculates rates'", + }, + }, +} func init() { cmd.Register(&cli.Command{ @@ -45,24 +64,12 @@ tool, and lets you ask natural-language questions like "list all users" or "create an order for product 42". The model decides which tool to call and issues RPCs to the right service. +If you ask for something no existing service handles, the agent will generate +a new service automatically and start using it. + Examples: - # Chat with Anthropic Claude (uses ANTHROPIC_API_KEY) ANTHROPIC_API_KEY=sk-ant-... micro chat --provider anthropic - - # Use a single prompt and exit - micro chat --provider openai --prompt "list all users" - - # Use a custom provider via base URL (auto-detected) - micro chat --api_key $KEY --base_url https://api.groq.com/openai - -Environment variables: - MICRO_AI_PROVIDER Provider name (anthropic, openai, gemini, groq, ...) - MICRO_AI_API_KEY API key for the provider - MICRO_AI_MODEL Model name override - MICRO_AI_BASE_URL Base URL override - ANTHROPIC_API_KEY Fallback API key for the anthropic provider - OPENAI_API_KEY Fallback API key for the openai provider - GEMINI_API_KEY Fallback API key for the gemini provider`, + micro chat --provider openai --prompt "list all users"`, Flags: []cli.Flag{ &cli.StringFlag{Name: "provider", Usage: "AI provider (anthropic, openai, gemini, groq, mistral, together, atlascloud)", EnvVars: []string{"MICRO_AI_PROVIDER"}}, &cli.StringFlag{Name: "api_key", Usage: "API key for the provider", EnvVars: []string{"MICRO_AI_API_KEY"}}, @@ -74,10 +81,123 @@ Environment variables: }) } +type session struct { + provider string + apiKey string + model ai.Model + tools *ai.Tools + reg registry.Registry + hist *ai.History + toolList []ai.Tool + sysPrompt string + procs []*exec.Cmd +} + +func (s *session) refreshTools() { + discovered, err := s.tools.Discover() + if err != nil { + return + } + s.toolList = append(discovered, generateTool) + + serviceNames := make(map[string]bool) + for _, t := range discovered { + parts := strings.SplitN(t.OriginalName, ".", 2) + if len(parts) == 2 { + serviceNames[parts[0]] = true + } + } + var svcList []string + for name := range serviceNames { + svcList = append(svcList, name) + } + if len(svcList) == 0 { + s.sysPrompt = fmt.Sprintf(systemPromptTmpl, "(none yet)") + } else { + s.sysPrompt = fmt.Sprintf(systemPromptTmpl, strings.Join(svcList, ", ")) + } +} + +func (s *session) handleGenerate(input map[string]any) (any, string) { + desc, _ := input["description"].(string) + if desc == "" { + return map[string]string{"error": "description is required"}, `{"error":"description is required"}` + } + + fmt.Printf("\n \033[36m⚡\033[0m generating service: %s\n", desc) + + design, err := generate.Design(context.Background(), s.provider, s.apiKey, "", ".", desc) + if err != nil { + msg := fmt.Sprintf(`{"error":"design failed: %s"}`, err) + return map[string]string{"error": err.Error()}, msg + } + + if err := generate.Generate(context.Background(), ".", design, s.provider, s.apiKey, ""); err != nil { + msg := fmt.Sprintf(`{"error":"generate failed: %s"}`, err) + return map[string]string{"error": err.Error()}, msg + } + + // Find which services are new (not already in registry) + existing := make(map[string]bool) + if svcs, err := s.reg.ListServices(); err == nil { + for _, svc := range svcs { + existing[svc.Name] = true + } + } + + var created []string + for _, svc := range design.Services { + name := strings.TrimSuffix(svc.Name, "-service") + if existing[name] { + continue + } + created = append(created, svc.Name) + + // Build and start the new service + svcDir, _ := filepath.Abs(svc.Name) + fmt.Printf(" \033[36m⚡\033[0m starting %s...\n", svc.Name) + + buildCmd := exec.Command("go", "build", "-o", svc.Name, ".") + buildCmd.Dir = svcDir + if out, err := buildCmd.CombinedOutput(); err != nil { + fmt.Printf(" \033[33m⚠\033[0m build failed: %s\n", string(out)) + continue + } + + runCmd := exec.Command(filepath.Join(svcDir, svc.Name)) + runCmd.Dir = svcDir + if err := runCmd.Start(); err != nil { + fmt.Printf(" \033[33m⚠\033[0m start failed: %v\n", err) + continue + } + s.procs = append(s.procs, runCmd) + } + + if len(created) == 0 { + result := map[string]any{"message": "No new services needed — all already exist."} + b, _ := json.Marshal(result) + return result, string(b) + } + + // Wait for services to register + fmt.Printf(" \033[36m⚡\033[0m waiting for services to register...\n") + time.Sleep(5 * time.Second) + + s.refreshTools() + fmt.Printf(" \033[32m✓\033[0m %d tools available\n\n", len(s.toolList)-1) + + result := map[string]any{ + "created": created, + "message": fmt.Sprintf("Created and started: %s. Their endpoints are now available as tools.", strings.Join(created, ", ")), + } + b, _ := json.Marshal(result) + return result, string(b) +} + func run(c *cli.Context) error { provider := c.String("provider") apiKey := c.String("api_key") - model := c.String("model") + modelName := c.String("model") baseURL := c.String("base_url") singlePrompt := c.String("prompt") @@ -91,50 +211,62 @@ func run(c *cli.Context) error { return fmt.Errorf("no API key configured; set --api_key or %s", envVarForProvider(provider)) } - // Discover tools and wire up a handler that routes to the right RPC. reg := registry.DefaultRegistry - cli := client.DefaultClient + cl := clt.DefaultClient - tools := ai.NewTools(reg, ai.ToolClient(cli)) - discovered, err := tools.Discover() - if err != nil { - return fmt.Errorf("discover tools: %w", err) + tools := ai.NewTools(reg, ai.ToolClient(cl)) + + s := &session{ + provider: provider, + apiKey: apiKey, + tools: tools, + reg: reg, + hist: ai.NewHistory(50), + } + s.refreshTools() + + // Wrap the tool handler to intercept generate calls + baseHandler := tools.Handler() + wrappedHandler := func(name string, input map[string]any) (any, string) { + if name == "micro_generate_service" { + return s.handleGenerate(input) + } + return baseHandler(name, input) } opts := []ai.Option{ ai.WithAPIKey(apiKey), - ai.WithTools(tools), + ai.WithToolHandler(wrappedHandler), } - if model != "" { - opts = append(opts, ai.WithModel(model)) + if modelName != "" { + opts = append(opts, ai.WithModel(modelName)) } if baseURL != "" { opts = append(opts, ai.WithBaseURL(baseURL)) } - m := ai.New(provider, opts...) - if m == nil { + s.model = ai.New(provider, opts...) + if s.model == nil { return fmt.Errorf("unknown provider: %s", provider) } - hist := ai.NewHistory(50) + defer s.cleanup() if singlePrompt != "" { - return ask(c.Context, m, hist, discovered, singlePrompt) + return s.ask(c.Context, singlePrompt) } - // Startup banner fmt.Println() fmt.Println(" \033[1mmicro chat\033[0m") fmt.Println() fmt.Printf(" Provider \033[36m%s\033[0m\n", provider) - fmt.Printf(" Model \033[36m%s\033[0m\n", m.Options().Model) + fmt.Printf(" Model \033[36m%s\033[0m\n", s.model.Options().Model) fmt.Println() fmt.Println(" Tools:") - for _, t := range discovered { + for _, t := range s.toolList { fmt.Printf(" \033[32m●\033[0m %s\n", t.OriginalName) } - if len(discovered) == 0 { + if len(s.toolList) == 0 { fmt.Println(" \033[33m(no services found)\033[0m") } fmt.Println() @@ -157,44 +289,53 @@ func run(c *cli.Context) error { return nil } if line == "reset" { - hist.Reset() + s.hist.Reset() fmt.Println("\033[2m(history cleared)\033[0m") fmt.Println() continue } - if err := ask(c.Context, m, hist, discovered, line); err != nil { + if err := s.ask(c.Context, line); err != nil { fmt.Printf("\033[31merror:\033[0m %v\n", err) } fmt.Println() } } -func ask(ctx context.Context, m ai.Model, hist *ai.History, toolList []ai.Tool, prompt string) error { - hist.Add("user", prompt) +func (s *session) ask(ctx context.Context, prompt string) error { + s.hist.Add("user", prompt) - resp, err := m.Generate(ctx, &ai.Request{ + resp, err := s.model.Generate(ctx, &ai.Request{ Prompt: prompt, - SystemPrompt: systemPrompt, - Tools: toolList, - Messages: hist.Messages(), + SystemPrompt: s.sysPrompt, + Tools: s.toolList, + Messages: s.hist.Messages(), }) if err != nil { return err } if resp.Reply != "" { - hist.Add("assistant", resp.Reply) + s.hist.Add("assistant", resp.Reply) } if resp.Answer != "" { - hist.Add("assistant", resp.Answer) + s.hist.Add("assistant", resp.Answer) } if resp.Reply != "" { fmt.Println(resp.Reply) } for _, tc := range resp.ToolCalls { + if tc.Name == "micro_generate_service" { + continue // output handled by handleGenerate + } args, _ := json.Marshal(tc.Input) fmt.Printf(" \033[33m→\033[0m \033[2m%s\033[0m(%s)\n", tc.Name, args) + if tc.Result != "" { + fmt.Printf(" \033[32m←\033[0m \033[2m%s\033[0m\n", truncateResult(tc.Result)) + } + if tc.Error != "" { + fmt.Printf(" \033[31m✗\033[0m %s\n", tc.Error) + } } if resp.Answer != "" { fmt.Println() @@ -203,10 +344,14 @@ func ask(ctx context.Context, m ai.Model, hist *ai.History, toolList []ai.Tool, return nil } -// fallbackAPIKey returns the provider-specific environment variable when -// neither --api_key nor MICRO_AI_API_KEY is set. This lets users keep -// existing ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY vars -// without re-exporting them. +func (s *session) cleanup() { + for _, p := range s.procs { + if p.Process != nil { + p.Process.Kill() + } + } +} + func fallbackAPIKey(provider string) string { if v := os.Getenv(envVarForProvider(provider)); v != "" { return v @@ -234,3 +379,10 @@ func envVarForProvider(provider string) string { return "MICRO_AI_API_KEY" } } + +func truncateResult(s string) string { + if len(s) <= 200 { + return s + } + return s[:200] + "..." +} diff --git a/cmd/micro/cli/cli.go b/cmd/micro/cli/cli.go index 024f4995c9..2610194a5b 100644 --- a/cmd/micro/cli/cli.go +++ b/cmd/micro/cli/cli.go @@ -42,7 +42,10 @@ func init() { Name: "new", Usage: "Create a new service", ArgsUsage: "[name]", - Action: new.Run, + UsageText: ` micro new helloworld # scaffold a single service + micro new --prompt "a todo list with tasks" # AI-design multiple services + micro new --prompt "add tags to the task service" # extend existing services`, + Action: new.Run, Flags: []cli.Flag{ &cli.BoolFlag{ Name: "no-mcp", @@ -52,6 +55,21 @@ func init() { Name: "template", Usage: "Service template: default, crud, pubsub, api", }, + &cli.StringFlag{ + Name: "prompt", + Usage: "Describe the system to generate (uses AI to design & build services with real business logic)", + EnvVars: []string{"MICRO_NEW_PROMPT"}, + }, + &cli.StringFlag{ + Name: "provider", + Usage: "AI provider for --prompt (anthropic, openai, gemini, atlascloud, groq, mistral, together)", + EnvVars: []string{"MICRO_AI_PROVIDER"}, + }, + &cli.StringFlag{ + Name: "api_key", + Usage: "API key for --prompt (or set ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.)", + EnvVars: []string{"MICRO_AI_API_KEY"}, + }, }, }, { diff --git a/cmd/micro/cli/generate/generate.go b/cmd/micro/cli/generate/generate.go new file mode 100644 index 0000000000..d666622694 --- /dev/null +++ b/cmd/micro/cli/generate/generate.go @@ -0,0 +1,794 @@ +// Package generate implements AI-powered service generation for go-micro. +// It uses an LLM to design service architecture and generate handler code +// with real business logic, then compiles and fixes errors iteratively. +package generate + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "go-micro.dev/v5/ai" + + _ "go-micro.dev/v5/ai/anthropic" + _ "go-micro.dev/v5/ai/atlascloud" + _ "go-micro.dev/v5/ai/gemini" + _ "go-micro.dev/v5/ai/groq" + _ "go-micro.dev/v5/ai/mistral" + _ "go-micro.dev/v5/ai/openai" + _ "go-micro.dev/v5/ai/together" +) + +const designPrompt = `You are a Go microservices architect using the go-micro framework. +Given a system description, design the services needed. + +Return ONLY valid JSON: +{ + "services": [ + { + "name": "service-name", + "description": "What this service does", + "fields": [ + {"name": "field_name", "type": "string", "description": "What this field is"} + ], + "endpoints": [ + {"name": "EndpointName", "description": "What this endpoint does", "example": "{\"key\": \"value\"}"} + ] + } + ] +} + +Rules: +- Service names are lowercase, hyphenated, WITHOUT a "-service" suffix (e.g. "task" not "task-service", "shipping" not "shipping-service") +- Each service MUST have CRUD endpoints: Create, Read, Update, Delete, List +- Add 1-3 custom endpoints for real business logic (e.g. PlaceOrder, CheckInventory) +- Field types: string, int64, bool, float64 +- Every service needs id (string), created (int64), updated (int64) fields +- Endpoint names are PascalCase +- Examples should be realistic JSON +- 2-4 services max, focused on the domain +- Keep services small and focused — one concern per service, max 5-8 fields +- Services don't call each other; an AI agent orchestrates across them` + +const designPromptWithExisting = `You are a Go microservices architect using the go-micro framework. +The user has an EXISTING system with services already running. They want to extend or modify it. + +Existing services: +%s + +Given the user's request, return the COMPLETE set of services (existing + new/modified). +For existing services the user hasn't asked to change, return them as-is. +For new or modified services, include the full specification. + +Return ONLY valid JSON: +{ + "services": [ + { + "name": "service-name", + "description": "What this service does", + "fields": [ + {"name": "field_name", "type": "string", "description": "What this field is"} + ], + "endpoints": [ + {"name": "EndpointName", "description": "What this endpoint does", "example": "{\"key\": \"value\"}"} + ] + } + ] +} + +Rules: +- Service names are lowercase, hyphenated, WITHOUT a "-service" suffix (e.g. "task" not "task-service", "shipping" not "shipping-service") +- Each service MUST have CRUD endpoints: Create, Read, Update, Delete, List +- Add custom endpoints for real business logic +- Field types: string, int64, bool, float64 +- Every service needs id (string), created (int64), updated (int64) fields +- Endpoint names are PascalCase +- Examples should be realistic JSON +- Keep existing services unless the user explicitly asks to change them` + +const handlerPrompt = `You are a Go developer writing a handler for a go-micro service. +Generate a COMPLETE, COMPILABLE Go handler file. + +The handler must: +1. Use package "handler" +2. Import the proto package as: pb "%s/proto" +3. Import go-micro logger as: log "go-micro.dev/v5/logger" +4. Import "github.com/google/uuid" for ID generation +5. Use "go-micro.dev/v5/store" for persistent storage (NOT in-memory maps) +6. Include REAL business logic — not just CRUD store operations +7. Every exported method must have a doc comment explaining what it does +8. Every method must have an @example tag with realistic JSON input +9. Handle edge cases, validation, and return meaningful errors +10. Keep the file under 200 lines — be concise, no boilerplate + +For storage, use the go-micro store package: + import "go-micro.dev/v5/store" + import "encoding/json" + + // In the struct: + store store.Store + + // In the constructor: + func New() *%s { return &%s{store: store.DefaultStore} } + + // Write a record: + data, _ := json.Marshal(record) + store.Write(&store.Record{Key: "prefix/" + id, Value: data}) + + // Read a record: + recs, err := store.Read("prefix/" + id) + json.Unmarshal(recs[0].Value, &record) + + // List keys: + keys, _ := store.List(store.ListPrefix("prefix/")) + + // Delete: + store.Delete("prefix/" + id) + +Do NOT use sync.Mutex or in-memory maps. Use store for all data. + +The struct name is %s. +The constructor is func New() *%s. + +Here is the proto definition: +%s + +Here is what each endpoint should do: +%s + +Return ONLY the Go code. No markdown, no explanation. Just the .go file content starting with "package handler".` + +// ServiceDesign is the LLM's output. +type ServiceDesign struct { + Services []ServiceSpec `json:"services"` +} + +type ServiceSpec struct { + Name string `json:"name"` + Description string `json:"description"` + Fields []FieldSpec `json:"fields"` + Endpoints []EndpointSpec `json:"endpoints"` +} + +type FieldSpec struct { + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description"` +} + +type EndpointSpec struct { + Name string `json:"name"` + Description string `json:"description"` + Example string `json:"example"` +} + +// Design calls an LLM to design services from a prompt. +// If baseDir contains existing services, they are included as context +// so the LLM extends the system rather than redesigning from scratch. +func Design(ctx context.Context, provider, apiKey, model, baseDir, prompt string) (*ServiceDesign, error) { + m := newModel(provider, apiKey, model) + if m == nil { + return nil, fmt.Errorf("unknown provider: %s", provider) + } + + existing := discoverExisting(baseDir) + + var sysPrompt, userPrompt string + if len(existing) > 0 { + sysPrompt = fmt.Sprintf(designPromptWithExisting, existing) + userPrompt = fmt.Sprintf("Extend or modify the system: %s", prompt) + } else { + sysPrompt = designPrompt + userPrompt = fmt.Sprintf("Design a microservices system for: %s", prompt) + } + + sp := startSpinner("designing services...") + designCtx, designCancel := context.WithTimeout(ctx, 60*time.Second) + defer designCancel() + resp, err := m.Generate(designCtx, &ai.Request{ + Prompt: userPrompt, + SystemPrompt: sysPrompt, + }) + sp.Stop() + if err != nil { + return nil, fmt.Errorf("design failed: %w", err) + } + + reply := firstNonEmpty(resp.Answer, resp.Reply) + reply = extractJSON(reply) + + var design ServiceDesign + if err := json.Unmarshal([]byte(reply), &design); err != nil { + return nil, fmt.Errorf("failed to parse design: %w\nResponse: %s", err, reply) + } + if len(design.Services) == 0 { + return nil, fmt.Errorf("no services designed") + } + return &design, nil +} + +// discoverExisting scans a directory for existing go-micro services +// and returns a summary string for inclusion in the design prompt. +func discoverExisting(baseDir string) string { + entries, err := os.ReadDir(baseDir) + if err != nil { + return "" + } + + var summaries []string + for _, e := range entries { + if !e.IsDir() { + continue + } + svcDir := filepath.Join(baseDir, e.Name()) + + // Look for proto files as indicator of a go-micro service + protoDir := filepath.Join(svcDir, "proto") + protos, err := filepath.Glob(filepath.Join(protoDir, "*.proto")) + if err != nil || len(protos) == 0 { + continue + } + + proto := readFile(protos[0]) + if proto == "" { + continue + } + + summaries = append(summaries, fmt.Sprintf("### %s\nProto:\n```\n%s\n```", e.Name(), proto)) + } + + return strings.Join(summaries, "\n\n") +} + +// Generate creates go-micro service directories from a design. +// If a service directory already exists, it skips structure generation +// but regenerates the handler (allowing iterative improvement). +func Generate(ctx context.Context, baseDir string, design *ServiceDesign, provider, apiKey, model string) error { + m := newModel(provider, apiKey, model) + + for i, svc := range design.Services { + if ctx.Err() != nil { + return ctx.Err() + } + svcDir := filepath.Join(baseDir, svc.Name) + handlerFile := filepath.Join(svcDir, "handler", svc.Name+".go") + protoFile := filepath.Join(svcDir, "proto", svc.Name+".proto") + + // Snapshot proto hash before structure generation + protoBefore := fileHash(protoFile) + + fmt.Printf(" \033[2m[%d/%d]\033[0m generating \033[36m%s\033[0m...\n", i+1, len(design.Services), svc.Name) + + // Step 1: Generate proto (deterministic — from design spec) + if err := generateStructure(svcDir, svc); err != nil { + return fmt.Errorf("structure %s: %w", svc.Name, err) + } + + protoAfter := fileHash(protoFile) + protoChanged := protoBefore != protoAfter + + // If proto unchanged and handler unmodified, nothing to do + if !protoChanged && protoBefore != "" && !handlerModified(svcDir, handlerFile) { + fmt.Printf(" \033[32m✓\033[0m %s \033[2m(unchanged)\033[0m\n", svc.Name) + continue + } + + // Step 2: Run go mod tidy + make proto to get compiled proto + runIn(svcDir, "go", "mod", "tidy") + runIn(svcDir, "make", "proto") + + // Step 3: Generate handler with business logic (LLM) + proto := readFile(protoFile) + if err := generateHandler(ctx, m, svcDir, svc, proto); err != nil { + return fmt.Errorf("handler %s: %w", svc.Name, err) + } + + // Step 4: Compile-fix loop + if err := compileFix(ctx, m, svcDir, svc.Name, 3); err != nil { + fmt.Printf(" \033[33m⚠\033[0m %s has compile errors (may need manual fix)\n", svc.Name) + } else { + fmt.Printf(" \033[32m✓\033[0m %s\n", svc.Name) + } + + // Record final handler hash (after any compile fixes) + recordHandlerHash(svcDir, handlerFile) + } + return nil +} + +// generateStructure creates the proto, main.go, go.mod, Makefile. +// If the directory already exists, only regenerates the proto +// (handler will be regenerated separately by the LLM). +func generateStructure(dir string, svc ServiceSpec) error { + exists := false + if _, err := os.Stat(dir); err == nil { + exists = true + } + os.MkdirAll(filepath.Join(dir, "handler"), 0755) + os.MkdirAll(filepath.Join(dir, "proto"), 0755) + + name := svc.Name + titleName := toTitle(name) + dehyphen := strings.ReplaceAll(name, "-", "") + + // Regenerate proto unless user has modified it + protoPath := filepath.Join(dir, "proto", name+".proto") + if !fileModified(dir, "proto_hash", protoPath) { + writeFile(protoPath, buildProto(dehyphen, titleName, svc)) + recordFileHash(dir, "proto_hash", protoPath) + } else { + fmt.Printf(" \033[2mkeeping %s proto (modified)\033[0m\n", name) + } + + // Only write structural files if directory is new + if !exists { + writeFile(filepath.Join(dir, "main.go"), buildMain(name, titleName)) + + writeFile(filepath.Join(dir, "Makefile"), + "GOPATH:=$(shell go env GOPATH)\n\n.PHONY: proto\nproto:\n\tprotoc --proto_path=. --micro_out=. --go_out=. proto/*.proto\n") + + writeFile(filepath.Join(dir, "go.mod"), + fmt.Sprintf("module %s\n\ngo 1.24\n\nrequire go-micro.dev/v5 v5.24.0\n", name)) + + writeFile(filepath.Join(dir, ".gitignore"), + fmt.Sprintf("%s\n.micro\n", name)) + } + + // Placeholder handler so go mod tidy works (will be overwritten by LLM) + handlerPath := filepath.Join(dir, "handler", name+".go") + if _, err := os.Stat(handlerPath); os.IsNotExist(err) { + writeFile(handlerPath, + fmt.Sprintf("package handler\n\ntype %s struct{}\n\nfunc New() *%s { return &%s{} }\n", titleName, titleName, titleName)) + recordHandlerHash(dir, handlerPath) + } + + return nil +} + +// generateHandler asks the LLM to write the handler with business logic. +// If the handler exists and the user has modified it since generation, +// it is left untouched. +func generateHandler(ctx context.Context, m ai.Model, dir string, svc ServiceSpec, proto string) error { + if m == nil { + return nil // no LLM — keep the placeholder + } + + handlerFile := filepath.Join(dir, "handler", svc.Name+".go") + + if handlerModified(dir, handlerFile) { + fmt.Printf(" \033[2mkeeping %s handler (modified)\033[0m\n", svc.Name) + return nil + } + + titleName := toTitle(svc.Name) + + // Build endpoint descriptions + var epDescs []string + for _, ep := range svc.Endpoints { + epDescs = append(epDescs, fmt.Sprintf("- %s: %s (example input: %s)", ep.Name, ep.Description, ep.Example)) + } + + prompt := fmt.Sprintf(handlerPrompt, + svc.Name, titleName, titleName, titleName, titleName, proto, strings.Join(epDescs, "\n")) + + sp := startSpinner(fmt.Sprintf("writing %s handler...", svc.Name)) + genCtx, genCancel := context.WithTimeout(ctx, 90*time.Second) + defer genCancel() + resp, err := m.Generate(genCtx, &ai.Request{ + Prompt: fmt.Sprintf("Generate the handler for the %s service with real business logic.", svc.Name), + SystemPrompt: prompt, + }) + sp.Stop() + if err != nil { + return err + } + + code := firstNonEmpty(resp.Answer, resp.Reply) + code = extractCode(code) + + if !strings.HasPrefix(strings.TrimSpace(code), "package") { + return fmt.Errorf("LLM did not return valid Go code") + } + + if isTruncated(code) { + fmt.Printf(" \033[33m→\033[0m response truncated, retrying...\n") + sp = startSpinner(fmt.Sprintf("rewriting %s handler...", svc.Name)) + retryCtx, retryCancel := context.WithTimeout(ctx, 90*time.Second) + defer retryCancel() + resp, err = m.Generate(retryCtx, &ai.Request{ + Prompt: fmt.Sprintf("Generate the handler for the %s service with real business logic. Keep it concise — no more than 200 lines.", svc.Name), + SystemPrompt: prompt, + }) + sp.Stop() + if err != nil { + return err + } + code = firstNonEmpty(resp.Answer, resp.Reply) + code = extractCode(code) + } + + if !strings.HasPrefix(strings.TrimSpace(code), "package") { + return fmt.Errorf("LLM did not return valid Go code") + } + + writeFile(handlerFile, code) + recordHandlerHash(dir, handlerFile) + return nil +} + +// compileFix tries to compile, and if it fails, sends the error to +// the LLM to fix. Up to maxAttempts iterations. +func compileFix(ctx context.Context, m ai.Model, dir, name string, maxAttempts int) error { + for attempt := 0; attempt < maxAttempts; attempt++ { + cmd := exec.Command("go", "build", "./...") + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err == nil { + return nil // compiles! + } + + if m == nil { + return fmt.Errorf("compile failed: %s", string(out)) + } + + // Read current handler + handlerPath := filepath.Join(dir, "handler", name+".go") + currentCode := readFile(handlerPath) + + sp := startSpinner(fmt.Sprintf("fixing compile errors (attempt %d/%d)...", attempt+1, maxAttempts)) + fixCtx, fixCancel := context.WithTimeout(ctx, 60*time.Second) + resp, fixErr := m.Generate(fixCtx, &ai.Request{ + Prompt: fmt.Sprintf("This Go code has compile errors. Fix ALL of them and return the COMPLETE corrected file.\n\nErrors:\n%s\n\nCode:\n%s", + string(out), currentCode), + SystemPrompt: "You are a Go expert. Return ONLY the corrected Go code. No markdown, no explanation. Start with 'package handler'.", + }) + fixCancel() + sp.Stop() + if fixErr != nil { + return fmt.Errorf("fix attempt failed: %w", fixErr) + } + + fixed := firstNonEmpty(resp.Answer, resp.Reply) + fixed = extractCode(fixed) + if strings.HasPrefix(strings.TrimSpace(fixed), "package") && !isTruncated(fixed) { + writeFile(handlerPath, fixed) + } + } + + // Final check + cmd := exec.Command("go", "build", "./...") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("still fails after %d attempts: %s", maxAttempts, string(out)) + } + return nil +} + +func newModel(provider, apiKey, model string) ai.Model { + if provider == "" { + provider = ai.AutoDetectProvider("") + } + var opts []ai.Option + opts = append(opts, ai.WithAPIKey(apiKey)) + if model != "" { + opts = append(opts, ai.WithModel(model)) + } + return ai.New(provider, opts...) +} + +func buildProto(dehyphen, titleName string, svc ServiceSpec) string { + var b strings.Builder + b.WriteString(fmt.Sprintf("syntax = \"proto3\";\n\npackage %s;\n\noption go_package = \"./proto;%s\";\n\n", dehyphen, dehyphen)) + + b.WriteString(fmt.Sprintf("service %s {\n", titleName)) + for _, ep := range svc.Endpoints { + b.WriteString(fmt.Sprintf("\trpc %s(%sRequest) returns (%sResponse) {}\n", ep.Name, ep.Name, ep.Name)) + } + b.WriteString("}\n\n") + + // Record message + b.WriteString(fmt.Sprintf("message %sRecord {\n", titleName)) + for i, f := range svc.Fields { + b.WriteString(fmt.Sprintf("\t%s %s = %d; // %s\n", protoType(f.Type), f.Name, i+1, f.Description)) + } + b.WriteString("}\n\n") + + // Request/response for each endpoint + for _, ep := range svc.Endpoints { + switch ep.Name { + case "Create": + b.WriteString(fmt.Sprintf("message CreateRequest {\n")) + n := 1 + for _, f := range svc.Fields { + if f.Name == "id" || f.Name == "created" || f.Name == "updated" { + continue + } + b.WriteString(fmt.Sprintf("\t%s %s = %d;\n", protoType(f.Type), f.Name, n)) + n++ + } + b.WriteString(fmt.Sprintf("}\n\nmessage CreateResponse {\n\t%sRecord record = 1;\n}\n\n", titleName)) + case "Read": + b.WriteString(fmt.Sprintf("message ReadRequest {\n\tstring id = 1;\n}\n\nmessage ReadResponse {\n\t%sRecord record = 1;\n}\n\n", titleName)) + case "Update": + b.WriteString("message UpdateRequest {\n\tstring id = 1;\n") + n := 2 + for _, f := range svc.Fields { + if f.Name == "id" || f.Name == "created" || f.Name == "updated" { + continue + } + b.WriteString(fmt.Sprintf("\t%s %s = %d;\n", protoType(f.Type), f.Name, n)) + n++ + } + b.WriteString(fmt.Sprintf("}\n\nmessage UpdateResponse {\n\t%sRecord record = 1;\n}\n\n", titleName)) + case "Delete": + b.WriteString(fmt.Sprintf("message DeleteRequest {\n\tstring id = 1;\n}\n\nmessage DeleteResponse {\n\tbool deleted = 1;\n}\n\n")) + case "List": + b.WriteString(fmt.Sprintf("message ListRequest {\n\tint64 limit = 1;\n\tint64 offset = 2;\n\tstring query = 3;\n}\n\nmessage ListResponse {\n\trepeated %sRecord records = 1;\n\tint64 total = 2;\n}\n\n", titleName)) + default: + // Custom endpoint — use all fields as input, record as output + b.WriteString(fmt.Sprintf("message %sRequest {\n", ep.Name)) + n := 1 + for _, f := range svc.Fields { + if f.Name == "created" || f.Name == "updated" { + continue + } + b.WriteString(fmt.Sprintf("\t%s %s = %d;\n", protoType(f.Type), f.Name, n)) + n++ + } + b.WriteString(fmt.Sprintf("}\n\nmessage %sResponse {\n\t%sRecord record = 1;\n\tstring message = 2;\n\tbool success = 3;\n}\n\n", ep.Name, titleName)) + } + } + return b.String() +} + +func buildMain(name, titleName string) string { + svcName := strings.TrimSuffix(name, "-service") + return fmt.Sprintf(`package main + +import ( + "%s/handler" + pb "%s/proto" + + "go-micro.dev/v5" + "go-micro.dev/v5/gateway/mcp" +) + +func main() { + service := micro.New("%s", + mcp.WithMCP(":0"), + ) + service.Init() + pb.Register%sHandler(service.Server(), handler.New()) + service.Run() +} +`, name, name, svcName, titleName) +} + +func extractJSON(s string) string { + if i := strings.Index(s, "```json"); i >= 0 { + s = s[i+7:] + if j := strings.Index(s, "```"); j >= 0 { + return strings.TrimSpace(s[:j]) + } + } + if i := strings.Index(s, "```"); i >= 0 { + s = s[i+3:] + if j := strings.Index(s, "```"); j >= 0 { + return strings.TrimSpace(s[:j]) + } + } + if i := strings.Index(s, "{"); i >= 0 { + depth := 0 + for j := i; j < len(s); j++ { + switch s[j] { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return s[i : j+1] + } + } + } + } + return s +} + +func extractCode(s string) string { + if i := strings.Index(s, "```go"); i >= 0 { + s = s[i+5:] + if j := strings.Index(s, "```"); j >= 0 { + return strings.TrimSpace(s[:j]) + } + } + if i := strings.Index(s, "```"); i >= 0 { + s = s[i+3:] + if j := strings.Index(s, "```"); j >= 0 { + return strings.TrimSpace(s[:j]) + } + } + // Try to find raw package declaration + if i := strings.Index(s, "package "); i >= 0 { + return strings.TrimSpace(s[i:]) + } + return strings.TrimSpace(s) +} + +func isTruncated(code string) bool { + trimmed := strings.TrimSpace(code) + if len(trimmed) == 0 { + return true + } + // Valid Go files end with a closing brace + if trimmed[len(trimmed)-1] != '}' { + return true + } + // Check balanced braces + depth := 0 + for _, c := range trimmed { + switch c { + case '{': + depth++ + case '}': + depth-- + } + } + return depth != 0 +} + +func protoType(t string) string { + switch t { + case "int64": + return "int64" + case "int32": + return "int32" + case "bool": + return "bool" + case "float64": + return "double" + default: + return "string" + } +} + +func toTitle(s string) string { + words := strings.FieldsFunc(s, func(r rune) bool { return r == '-' || r == '_' || r == ' ' }) + for i, w := range words { + if len(w) > 0 { + words[i] = strings.ToUpper(w[:1]) + w[1:] + } + } + return strings.Join(words, "") +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} + +func readFile(path string) string { + b, _ := os.ReadFile(path) + return string(b) +} + +func writeFile(path, content string) { + os.WriteFile(path, []byte(content), 0644) +} + +func runIn(dir string, name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "PATH="+os.Getenv("PATH")+":"+os.Getenv("GOPATH")+"/bin:"+os.Getenv("HOME")+"/go/bin") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +type spinner struct { + msg string + stop chan struct{} + done sync.WaitGroup +} + +func isTTY() bool { + fi, err := os.Stdout.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice != 0 +} + +func startSpinner(msg string) *spinner { + s := &spinner{msg: msg, stop: make(chan struct{})} + if !isTTY() { + fmt.Printf(" %s\n", msg) + return s + } + s.done.Add(1) + go func() { + defer s.done.Done() + frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + i := 0 + t := time.NewTicker(100 * time.Millisecond) + defer t.Stop() + for { + select { + case <-s.stop: + fmt.Printf("\r\033[K") + return + case <-t.C: + fmt.Printf("\r %s %s", frames[i%len(frames)], msg) + i++ + } + } + }() + return s +} + +func (s *spinner) Stop() { + close(s.stop) + s.done.Wait() +} + +func fileHash(path string) string { + b, err := os.ReadFile(path) + if err != nil { + return "" + } + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]) +} + +func metaPath(svcDir string) string { + return filepath.Join(svcDir, ".micro") +} + +func readMeta(svcDir string) map[string]string { + m := make(map[string]string) + b, err := os.ReadFile(metaPath(svcDir)) + if err != nil { + return m + } + json.Unmarshal(b, &m) + return m +} + +func writeMeta(svcDir string, m map[string]string) { + b, _ := json.MarshalIndent(m, "", " ") + os.WriteFile(metaPath(svcDir), b, 0644) +} + +func fileModified(svcDir, key, path string) bool { + meta := readMeta(svcDir) + savedHash, ok := meta[key] + if !ok { + return false + } + return fileHash(path) != savedHash +} + +func recordFileHash(svcDir, key, path string) { + meta := readMeta(svcDir) + meta[key] = fileHash(path) + writeMeta(svcDir, meta) +} + +func handlerModified(svcDir, handlerFile string) bool { + return fileModified(svcDir, "handler_hash", handlerFile) +} + +func recordHandlerHash(svcDir, handlerFile string) { + recordFileHash(svcDir, "handler_hash", handlerFile) +} diff --git a/cmd/micro/cli/generate/generate_test.go b/cmd/micro/cli/generate/generate_test.go new file mode 100644 index 0000000000..7a1251b27b --- /dev/null +++ b/cmd/micro/cli/generate/generate_test.go @@ -0,0 +1,421 @@ +package generate + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestToTitle(t *testing.T) { + tests := []struct { + in, want string + }{ + {"order-service", "OrderService"}, + {"task", "Task"}, + {"inventory_item", "InventoryItem"}, + {"hello world", "HelloWorld"}, + {"a-b-c", "ABC"}, + {"already", "Already"}, + } + for _, tt := range tests { + if got := toTitle(tt.in); got != tt.want { + t.Errorf("toTitle(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestProtoType(t *testing.T) { + tests := []struct { + in, want string + }{ + {"string", "string"}, + {"int64", "int64"}, + {"int32", "int32"}, + {"bool", "bool"}, + {"float64", "double"}, + {"unknown", "string"}, + {"", "string"}, + } + for _, tt := range tests { + if got := protoType(tt.in); got != tt.want { + t.Errorf("protoType(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestFirstNonEmpty(t *testing.T) { + if got := firstNonEmpty("", "", "c"); got != "c" { + t.Errorf("got %q, want %q", got, "c") + } + if got := firstNonEmpty("a", "b"); got != "a" { + t.Errorf("got %q, want %q", got, "a") + } + if got := firstNonEmpty("", ""); got != "" { + t.Errorf("got %q, want %q", got, "") + } +} + +func TestExtractJSON(t *testing.T) { + tests := []struct { + name, in, want string + }{ + { + "fenced json", + "Here's the design:\n```json\n{\"services\": []}\n```\nDone.", + `{"services": []}`, + }, + { + "fenced no lang", + "```\n{\"a\": 1}\n```", + `{"a": 1}`, + }, + { + "raw json", + `some text {"key": "val"} trailing`, + `{"key": "val"}`, + }, + { + "nested braces", + `{"a": {"b": 1}}`, + `{"a": {"b": 1}}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractJSON(tt.in) + if got != tt.want { + t.Errorf("extractJSON() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestExtractCode(t *testing.T) { + tests := []struct { + name, in string + wantPrefix string + }{ + { + "go fence", + "Here:\n```go\npackage handler\n\nfunc Foo() {}\n```\nDone.", + "package handler", + }, + { + "generic fence", + "```\npackage main\n```", + "package main", + }, + { + "raw code", + "Sure, here's the code:\npackage handler\n\ntype X struct{}", + "package handler", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractCode(tt.in) + if !strings.HasPrefix(got, tt.wantPrefix) { + t.Errorf("extractCode() = %q, want prefix %q", got, tt.wantPrefix) + } + }) + } +} + +func TestBuildProto(t *testing.T) { + svc := ServiceSpec{ + Name: "task-service", + Description: "Manages tasks", + Fields: []FieldSpec{ + {Name: "id", Type: "string", Description: "Task ID"}, + {Name: "title", Type: "string", Description: "Task title"}, + {Name: "done", Type: "bool", Description: "Completion status"}, + {Name: "created", Type: "int64", Description: "Created timestamp"}, + {Name: "updated", Type: "int64", Description: "Updated timestamp"}, + }, + Endpoints: []EndpointSpec{ + {Name: "Create", Description: "Create a task"}, + {Name: "Read", Description: "Get a task"}, + {Name: "Update", Description: "Update a task"}, + {Name: "Delete", Description: "Delete a task"}, + {Name: "List", Description: "List tasks"}, + {Name: "ToggleComplete", Description: "Toggle completion"}, + }, + } + + proto := buildProto("taskservice", "TaskService", svc) + + checks := []string{ + `syntax = "proto3"`, + `package taskservice`, + `service TaskService`, + `rpc Create(CreateRequest) returns (CreateResponse)`, + `rpc ToggleComplete(ToggleCompleteRequest) returns (ToggleCompleteResponse)`, + `message TaskServiceRecord`, + `string title = 2`, + `bool done = 3`, + `message CreateRequest`, + `message ReadRequest`, + `message DeleteRequest`, + `message ListRequest`, + `message ToggleCompleteRequest`, + } + for _, c := range checks { + if !strings.Contains(proto, c) { + t.Errorf("buildProto() missing %q", c) + } + } + + // Create should not include id, created, updated + createIdx := strings.Index(proto, "message CreateRequest") + createEnd := strings.Index(proto[createIdx:], "}") + createBlock := proto[createIdx : createIdx+createEnd] + for _, skip := range []string{"string id", "int64 created", "int64 updated"} { + if strings.Contains(createBlock, skip) { + t.Errorf("CreateRequest should not contain %q", skip) + } + } +} + +func TestBuildMain(t *testing.T) { + // New naming: no -service suffix + main := buildMain("order", "Order") + checks := []string{ + `"order/handler"`, + `pb "order/proto"`, + `micro.New("order"`, + `pb.RegisterOrderHandler`, + `handler.New()`, + } + for _, c := range checks { + if !strings.Contains(main, c) { + t.Errorf("buildMain(order) missing %q", c) + } + } + + // Legacy naming: -service suffix stripped + main = buildMain("order-service", "OrderService") + checks = []string{ + `"order-service/handler"`, + `pb "order-service/proto"`, + `micro.New("order"`, + `pb.RegisterOrderServiceHandler`, + `handler.New()`, + } + for _, c := range checks { + if !strings.Contains(main, c) { + t.Errorf("buildMain() missing %q", c) + } + } +} + +func TestHandlerModifiedTracking(t *testing.T) { + dir := t.TempDir() + handlerDir := filepath.Join(dir, "handler") + os.MkdirAll(handlerDir, 0755) + handlerFile := filepath.Join(handlerDir, "test.go") + + // No .micro file → not modified + os.WriteFile(handlerFile, []byte("package handler\n"), 0644) + if handlerModified(dir, handlerFile) { + t.Error("expected not modified when no .micro exists") + } + + // Record hash → not modified + recordHandlerHash(dir, handlerFile) + if handlerModified(dir, handlerFile) { + t.Error("expected not modified after recording hash") + } + + // Edit the file → modified + os.WriteFile(handlerFile, []byte("package handler\n\nfunc Foo() {}\n"), 0644) + if !handlerModified(dir, handlerFile) { + t.Error("expected modified after editing file") + } + + // Re-record → not modified again + recordHandlerHash(dir, handlerFile) + if handlerModified(dir, handlerFile) { + t.Error("expected not modified after re-recording hash") + } +} + +func TestMetaReadWrite(t *testing.T) { + dir := t.TempDir() + + m := readMeta(dir) + if len(m) != 0 { + t.Error("expected empty meta for new dir") + } + + m["handler_hash"] = "abc123" + m["version"] = "1" + writeMeta(dir, m) + + m2 := readMeta(dir) + if m2["handler_hash"] != "abc123" || m2["version"] != "1" { + t.Errorf("readMeta() = %v, want handler_hash=abc123, version=1", m2) + } +} + +func TestGenerateStructure(t *testing.T) { + dir := t.TempDir() + svcDir := filepath.Join(dir, "test-svc") + + svc := ServiceSpec{ + Name: "test-svc", + Description: "Test service", + Fields: []FieldSpec{ + {Name: "id", Type: "string"}, + {Name: "name", Type: "string"}, + }, + Endpoints: []EndpointSpec{ + {Name: "Create"}, + {Name: "Read"}, + }, + } + + if err := generateStructure(svcDir, svc); err != nil { + t.Fatal(err) + } + + // Check files exist + for _, f := range []string{ + "proto/test-svc.proto", + "handler/test-svc.go", + "main.go", + "go.mod", + "Makefile", + ".gitignore", + } { + if _, err := os.Stat(filepath.Join(svcDir, f)); err != nil { + t.Errorf("missing %s: %v", f, err) + } + } + + // Check .micro was created with handler hash + meta := readMeta(svcDir) + if meta["handler_hash"] == "" { + t.Error("expected handler_hash in .micro after generateStructure") + } + + // Run again — should not overwrite main.go + mainBefore, _ := os.ReadFile(filepath.Join(svcDir, "main.go")) + os.WriteFile(filepath.Join(svcDir, "main.go"), []byte("// user edited\n"), 0644) + if err := generateStructure(svcDir, svc); err != nil { + t.Fatal(err) + } + mainAfter, _ := os.ReadFile(filepath.Join(svcDir, "main.go")) + if string(mainAfter) == string(mainBefore) { + t.Error("expected main.go to keep user edit on re-run") + } + + // Proto should be protected if user modified it + protoFile := filepath.Join(svcDir, "proto", "test-svc.proto") + protoBefore, _ := os.ReadFile(protoFile) + os.WriteFile(protoFile, []byte("// user-edited proto\n"), 0644) + if err := generateStructure(svcDir, svc); err != nil { + t.Fatal(err) + } + protoAfter, _ := os.ReadFile(protoFile) + if string(protoAfter) != "// user-edited proto\n" { + t.Error("expected proto to be preserved after user edit") + } + + // Proto should regenerate if NOT modified + recordFileHash(svcDir, "proto_hash", protoFile) + if err := generateStructure(svcDir, svc); err != nil { + t.Fatal(err) + } + protoAfter2, _ := os.ReadFile(protoFile) + if string(protoAfter2) == string(protoBefore) { + // ok — regenerated from spec + } +} + +func TestFileModified(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "test.txt") + os.WriteFile(f, []byte("original"), 0644) + + // No hash → not modified + if fileModified(dir, "test_hash", f) { + t.Error("expected not modified with no saved hash") + } + + recordFileHash(dir, "test_hash", f) + + // Same content → not modified + if fileModified(dir, "test_hash", f) { + t.Error("expected not modified with matching hash") + } + + // Changed content → modified + os.WriteFile(f, []byte("changed"), 0644) + if !fileModified(dir, "test_hash", f) { + t.Error("expected modified after content change") + } +} + +func TestDiscoverExisting(t *testing.T) { + dir := t.TempDir() + + // Empty directory → empty string + if got := discoverExisting(dir); got != "" { + t.Errorf("expected empty for empty dir, got %q", got) + } + + // Non-service directory (no proto) → empty + os.MkdirAll(filepath.Join(dir, "not-a-service"), 0755) + if got := discoverExisting(dir); got != "" { + t.Errorf("expected empty for dir without proto, got %q", got) + } + + // Create a real service directory with proto + svcDir := filepath.Join(dir, "order-service") + os.MkdirAll(filepath.Join(svcDir, "proto"), 0755) + os.WriteFile(filepath.Join(svcDir, "proto", "order-service.proto"), + []byte("syntax = \"proto3\";\nservice OrderService {}"), 0644) + + got := discoverExisting(dir) + if !strings.Contains(got, "order-service") { + t.Errorf("expected to find order-service, got %q", got) + } + if !strings.Contains(got, "OrderService") { + t.Errorf("expected to find proto content, got %q", got) + } + + // Add a second service + svc2Dir := filepath.Join(dir, "user-service") + os.MkdirAll(filepath.Join(svc2Dir, "proto"), 0755) + os.WriteFile(filepath.Join(svc2Dir, "proto", "user-service.proto"), + []byte("syntax = \"proto3\";\nservice UserService {}"), 0644) + + got = discoverExisting(dir) + if !strings.Contains(got, "order-service") || !strings.Contains(got, "user-service") { + t.Errorf("expected both services, got %q", got) + } +} + +func TestIsTruncated(t *testing.T) { + tests := []struct { + name string + code string + want bool + }{ + {"complete", "package handler\n\nfunc New() *H { return &H{} }\n", false}, + {"empty", "", true}, + {"no closing brace", "package handler\n\nfunc Foo() {", true}, + {"unbalanced", "package handler\n\nfunc Foo() {\n\tif true {", true}, + {"balanced", "package handler\n\nfunc Foo() {\n\tif true {\n\t}\n}", false}, + {"trailing whitespace ok", "package handler\n\ntype X struct{}\n\n", false}, + {"mid-expression", "package handler\n\nfunc F() {\n\tx := 1 +", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isTruncated(tt.code); got != tt.want { + t.Errorf("isTruncated() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/cmd/micro/cli/new/new.go b/cmd/micro/cli/new/new.go index e5a475a3dd..9c2ef3309f 100644 --- a/cmd/micro/cli/new/new.go +++ b/cmd/micro/cli/new/new.go @@ -2,14 +2,20 @@ package new import ( + "bufio" + "context" "fmt" "go/build" "os" "os/exec" + "os/signal" "path" "path/filepath" "runtime" "strings" + "syscall" + + "go-micro.dev/v5/cmd/micro/cli/generate" "text/template" "time" @@ -138,6 +144,11 @@ func addFileToTree(root treeprint.Tree, file string) { } func Run(ctx *cli.Context) error { + // Handle --prompt: design services with AI, then generate each one + if prompt := ctx.String("prompt"); prompt != "" { + return runPrompt(ctx, prompt) + } + dir := ctx.Args().First() if len(dir) == 0 { fmt.Println("specify service name") @@ -307,3 +318,76 @@ func printTree(dir string) { filepath.Walk(dir, walk) fmt.Println(t.String()) } + +func runPrompt(cliCtx *cli.Context, prompt string) error { + provider := cliCtx.String("provider") + apiKey := cliCtx.String("api_key") + if apiKey == "" { + // Try provider-specific env vars + for _, env := range []string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", + "ATLASCLOUD_API_KEY", "GROQ_API_KEY", "MISTRAL_API_KEY", "TOGETHER_API_KEY", "MICRO_AI_API_KEY"} { + if v := os.Getenv(env); v != "" { + apiKey = v + break + } + } + } + if apiKey == "" { + return fmt.Errorf("--api_key or a provider API key env var is required for --prompt") + } + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + fmt.Println() + fmt.Println(" \033[1mmicro new --prompt\033[0m") + fmt.Println() + fmt.Printf(" \033[2mDesigning services for:\033[0m %s\n\n", prompt) + + design, err := generate.Design(ctx, provider, apiKey, "", ".", prompt) + if err != nil { + return fmt.Errorf("design failed: %w", err) + } + + fmt.Println(" Services:") + for _, svc := range design.Services { + fmt.Printf(" \033[32m●\033[0m \033[36m%s\033[0m — %s\n", svc.Name, svc.Description) + for _, ep := range svc.Endpoints { + fmt.Printf(" %s: %s\n", ep.Name, ep.Description) + } + } + fmt.Println() + + if !confirmGenerate() { + fmt.Println(" Cancelled.") + return nil + } + + fmt.Println(" Generating code...") + if err := generate.Generate(ctx, ".", design, provider, apiKey, ""); err != nil { + return fmt.Errorf("generate failed: %w", err) + } + + for _, svc := range design.Services { + fmt.Printf(" \033[32m✓\033[0m %s/\n", svc.Name) + } + fmt.Println() + + fmt.Println(" \033[32m✓\033[0m All services generated") + fmt.Println() + fmt.Println(" Next steps:") + fmt.Println(" micro run \033[2m# start all services\033[0m") + fmt.Println(" micro chat --provider anthropic \033[2m# talk to them\033[0m") + fmt.Println() + return nil +} + +func confirmGenerate() bool { + fmt.Print(" Generate? [Y/n] ") + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + return false + } + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + return answer == "" || answer == "y" || answer == "yes" +} diff --git a/cmd/micro/cli/new/template/ignore.go b/cmd/micro/cli/new/template/ignore.go index 6e2d569e31..9a803266c1 100644 --- a/cmd/micro/cli/new/template/ignore.go +++ b/cmd/micro/cli/new/template/ignore.go @@ -3,5 +3,6 @@ package template var ( GitIgnore = ` {{.Alias}} +.micro ` ) diff --git a/cmd/micro/run/run.go b/cmd/micro/run/run.go index fb10ecca9c..39bbffc856 100644 --- a/cmd/micro/run/run.go +++ b/cmd/micro/run/run.go @@ -19,6 +19,7 @@ import ( "github.com/urfave/cli/v2" "go-micro.dev/v5/cmd" + "go-micro.dev/v5/cmd/micro/cli/generate" "go-micro.dev/v5/cmd/micro/run/config" "go-micro.dev/v5/cmd/micro/run/watcher" "go-micro.dev/v5/cmd/micro/server" @@ -175,6 +176,11 @@ func waitForHealth(port int, timeout time.Duration) bool { } func Run(c *cli.Context) error { + // Handle --prompt: generate services first, then run them + if prompt := c.String("prompt"); prompt != "" { + return runWithPrompt(c, prompt) + } + dir := c.Args().Get(0) if dir == "" { dir = "." @@ -387,6 +393,30 @@ func Run(c *cli.Context) error { } } }() + + // Scan for new services added by micro chat or micro new + go func() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for { + select { + case <-sigCh: + return + case <-ticker.C: + newSvcs := discoverNewServices(absDir, servicesByDir, binDir, runDir, logsDir, envVars, len(services)) + for _, sp := range newSvcs { + services = append(services, sp) + servicesByDir[sp.dir] = sp + watch.AddDir(sp.dir) + if err := sp.start(logsDir); err != nil { + fmt.Fprintf(os.Stderr, "[%s] %v\n", sp.name, err) + continue + } + fmt.Printf("\n \033[32m●\033[0m %s \033[2m(new)\033[0m\n", sp.name) + } + } + } + }() } // Wait for signal @@ -427,6 +457,41 @@ func processRunning(pidStr string) bool { return proc.Signal(syscall.Signal(0)) == nil } +func discoverNewServices(baseDir string, known map[string]*serviceProcess, binDir, runDir, logsDir string, envVars []string, colorOffset int) []*serviceProcess { + var newSvcs []*serviceProcess + entries, err := os.ReadDir(baseDir) + if err != nil { + return nil + } + for _, e := range entries { + if !e.IsDir() { + continue + } + svcDir := filepath.Join(baseDir, e.Name()) + absSvcDir, _ := filepath.Abs(svcDir) + if _, exists := known[absSvcDir]; exists { + continue + } + mainFile := filepath.Join(svcDir, "main.go") + if _, err := os.Stat(mainFile); err != nil { + continue + } + name := e.Name() + hash := fmt.Sprintf("%x", md5.Sum([]byte(absSvcDir)))[:8] + sp := &serviceProcess{ + name: name, + dir: absSvcDir, + binPath: filepath.Join(binDir, name+"-"+hash), + pidFile: filepath.Join(runDir, name+"-"+hash+".pid"), + logFile: filepath.Join(logsDir, name+"-"+hash+".log"), + color: colorFor(colorOffset + len(newSvcs)), + env: envVars, + } + newSvcs = append(newSvcs, sp) + } + return newSvcs +} + func printBanner(services []*serviceProcess, gw *server.Gateway, watching bool, mcpAddr string) { fmt.Println() fmt.Println(" \033[1mMicro\033[0m") @@ -466,6 +531,9 @@ func printBanner(services []*serviceProcess, gw *server.Gateway, watching bool, fmt.Println(" \033[33mWatching for changes...\033[0m") } + fmt.Println() + fmt.Println(" \033[2mmicro chat --provider anthropic # talk to your services\033[0m") + fmt.Println() } @@ -492,7 +560,8 @@ Examples: micro run --no-gateway # Services only, no HTTP gateway micro run --no-watch # Disable hot reload micro run --env production # Use production environment - micro run --mcp-address :3000 # Enable MCP protocol gateway`, + micro run --mcp-address :3000 # Enable MCP protocol gateway + micro run --prompt "an order system for dropshipping" # Generate and run`, Action: Run, Flags: []cli.Flag{ &cli.StringFlag{ @@ -520,6 +589,96 @@ Examples: Usage: "MCP gateway address (e.g., :3000). Enables MCP protocol for AI tools.", EnvVars: []string{"MICRO_MCP_ADDRESS"}, }, + &cli.StringFlag{ + Name: "prompt", + Usage: "Describe a system to generate and run (AI designs, builds, and starts services)", + EnvVars: []string{"MICRO_RUN_PROMPT"}, + }, + &cli.StringFlag{ + Name: "provider", + Usage: "AI provider for --prompt (anthropic, openai, gemini, atlascloud, groq, mistral, together)", + EnvVars: []string{"MICRO_AI_PROVIDER"}, + }, + &cli.StringFlag{ + Name: "api_key", + Usage: "API key for --prompt (or set ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.)", + EnvVars: []string{"MICRO_AI_API_KEY"}, + }, }, }) } + +func runWithPrompt(c *cli.Context, prompt string) error { + provider := c.String("provider") + apiKey := c.String("api_key") + if apiKey == "" { + for _, env := range []string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", + "ATLASCLOUD_API_KEY", "GROQ_API_KEY", "MISTRAL_API_KEY", "TOGETHER_API_KEY", "MICRO_AI_API_KEY"} { + if v := os.Getenv(env); v != "" { + apiKey = v + break + } + } + } + if apiKey == "" { + return fmt.Errorf("--api_key or a provider API key env var is required for --prompt") + } + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + fmt.Println() + fmt.Println(" \033[1mmicro run --prompt\033[0m") + fmt.Println() + fmt.Printf(" \033[2mDesigning services for:\033[0m %s\n\n", prompt) + + design, err := generate.Design(ctx, provider, apiKey, "", ".", prompt) + if err != nil { + return fmt.Errorf("design failed: %w", err) + } + + fmt.Println(" Services:") + for _, svc := range design.Services { + fmt.Printf(" \033[32m●\033[0m \033[36m%s\033[0m — %s\n", svc.Name, svc.Description) + for _, ep := range svc.Endpoints { + fmt.Printf(" %s: %s\n", ep.Name, ep.Description) + } + } + fmt.Println() + + if !confirmGenerate() { + fmt.Println(" Cancelled.") + return nil + } + + fmt.Println(" Generating code...") + if err := generate.Generate(ctx, ".", design, provider, apiKey, ""); err != nil { + return fmt.Errorf("generate failed: %w", err) + } + for _, svc := range design.Services { + fmt.Printf(" \033[32m✓\033[0m %s/\n", svc.Name) + } + fmt.Println() + + // Now run normally — micro run discovers the generated services + fmt.Println(" Starting services...") + fmt.Println() + + // Cancel signal context before handing off to Run (which manages its own signals) + cancel() + + // Run normally from current directory (services are now generated) + // Set the prompt to empty via a new context so Run doesn't recurse + c.Set("prompt", "") + return Run(c) +} + +func confirmGenerate() bool { + fmt.Print(" Generate? [Y/n] ") + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + return false + } + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + return answer == "" || answer == "y" || answer == "yes" +} diff --git a/cmd/micro/run/watcher/watcher.go b/cmd/micro/run/watcher/watcher.go index 89d5bce59f..685a206db1 100644 --- a/cmd/micro/run/watcher/watcher.go +++ b/cmd/micro/run/watcher/watcher.go @@ -75,6 +75,25 @@ func (w *Watcher) Start() { go w.watch() } +// AddDir adds a new directory to watch +func (w *Watcher) AddDir(dir string) { + w.mu.Lock() + defer w.mu.Unlock() + for _, d := range w.dirs { + if d == dir { + return + } + } + w.dirs = append(w.dirs, dir) +} + +// Dirs returns the currently watched directories +func (w *Watcher) Dirs() []string { + w.mu.Lock() + defer w.mu.Unlock() + return append([]string{}, w.dirs...) +} + // Stop stops the watcher func (w *Watcher) Stop() { close(w.done) diff --git a/internal/website/blog/13.md b/internal/website/blog/13.md new file mode 100644 index 0000000000..ee0692db95 --- /dev/null +++ b/internal/website/blog/13.md @@ -0,0 +1,180 @@ +--- +layout: blog +title: "From Prompt to Production: AI-Generated Microservices That Actually Run" +permalink: /blog/13 +description: "micro run --prompt generates real services with business logic, compiles them, starts them, and lets you talk to them. When you need more, the agent builds new services mid-conversation." +--- + +# From Prompt to Production: AI-Generated Microservices That Actually Run + +*June 3, 2026 • By the Go Micro Team* + +Every code generator stops at the same point: here's some files, good luck. You get a skeleton, wire it up yourself, hope it compiles. We wanted something different. + +``` +micro run --prompt "a task management system" +``` + +That's not scaffolding. Two services start, register, and respond to requests. An AI agent can create tasks, organize categories, and orchestrate across services immediately. And when you need a capability that doesn't exist — shipping, payments, notifications — the agent builds it mid-conversation. + +## The Full Loop + +**1. Describe** — tell it what you need in plain English. + +``` +micro run --prompt "a todo list with tasks and categories" +``` + +**2. Review** — the LLM designs the architecture. You see every service, field, and endpoint before a line of code is written. + +``` +Services: + ● category — Manages task categories + Create, Read, Update, Delete, List + ● task — Core task management + Create, Read, Update, Delete, List, CompleteTask, GetOverdue + +Generate? [Y/n] +``` + +**3. Generate** — proto files are written deterministically from the spec. Then the LLM writes each handler with real business logic. Not stubs. Not TODOs. Actual validation, edge cases, and persistent storage via go-micro's built-in store: + +```go +func (s *Task) CompleteTask(ctx context.Context, req *pb.CompleteTaskRequest, + rsp *pb.CompleteTaskResponse) error { + if req.Id == "" { + return errors.New("id is required") + } + recs, err := s.store.Read("task/" + req.Id) + if err != nil || len(recs) == 0 { + return errors.New("task not found") + } + var task pb.TaskRecord + json.Unmarshal(recs[0].Value, &task) + if task.Completed { + rsp.Success = false + rsp.Message = "task already completed" + return nil + } + task.Completed = true + task.Updated = time.Now().Unix() + data, _ := json.Marshal(&task) + s.store.Write(&store.Record{Key: "task/" + req.Id, Value: data}) + rsp.Record = &task + rsp.Success = true + return nil +} +``` + +Data survives restarts — generated services use go-micro's built-in store (file-backed by default, swappable to Postgres or NATS KV) instead of in-memory maps. Every handler compiles. If it doesn't, the errors are fed back to the LLM for correction. Most services compile on the first attempt. + +**4. Run** — services start, register via mDNS, and an HTTP gateway comes up. Every endpoint is accessible via REST, gRPC, or MCP. + +``` +Micro + + Dashboard http://localhost:8080 + API http://localhost:8080/api/{service}/{method} + + Services: + ● category + ● task + + micro chat --provider anthropic # talk to your services +``` + +## Talk To Your Services + +``` +micro chat --provider anthropic +> Create a Work category, then add a task called 'Finish report' to it +``` + +The agent discovers services from the registry, sees every endpoint as a tool, and orchestrates: + +``` +→ category_Category_Create({"name":"Work","user_id":"user1"}) +← {"record":{"id":"f633...","name":"Work"},"success":true} +→ task_Task_Create({"title":"Finish report","category_id":"f633..."}) +← {"record":{"id":"a1b2...","title":"Finish report","status":"pending"}} + +Created Work category and added 'Finish report' task to it. +``` + +No service-to-service calls. No distributed transactions. No saga patterns. The agent reads the result of one call and uses it as input to the next. Each service stays simple and independent. + +This is the answer to the oldest problem in microservices: how do services coordinate? They don't. An intelligent agent does it for them. + +## Growing the System + +Here's where it gets interesting. You're chatting, the domain grows, and you need something that doesn't exist: + +``` +> I need to track shipping for my orders. Create a shipment for order 123 to London. + + ⚡ generating service: a shipping service... + ✓ task (unchanged) + ✓ category (unchanged) + ✓ shipping + ⚡ starting shipping... + ✓ 13 tools available + + → shipping_Shipping_Create({"order_id":"123","destination":"London"}) + ← {"record":{"id":"xyz...","status":"pending"}} + + Created shipment for order 123 going to London. +``` + +The agent recognised that no shipping service exists, generated one, compiled it, started it, discovered its endpoints, and used them — all within the conversation. You didn't leave the chat. You didn't run a separate command. The system grew because you needed it to. + +Each service stays small and focused. When you need more, you add more services. The agent orchestrates across whatever exists. If you're running `micro run`, it detects new service directories automatically and starts them — the loop is fully seamless. + +## Iteration Without Destruction + +Run the same prompt again — services whose proto hasn't changed are skipped entirely: + +``` + ✓ category (unchanged) + ✓ task (unchanged) +``` + +Edit a handler by hand, and re-running preserves your changes. The system tracks SHA-256 hashes of generated files and only regenerates what's actually different. + +The LLM sees existing service protos and extends the system rather than redesigning from scratch. + +## What This Isn't + +This isn't a no-code platform. The generated code is standard Go — you own it, edit it, version it, deploy it however you want. There's no vendor lock-in, no runtime dependency on an AI provider, no magic abstraction. + +The services are real Go code with real interfaces. The same code you'd write by hand, generated in 30 seconds instead of 3 hours. + +## Why This Matters + +The barrier to microservices has always been ceremony. Proto definitions, handler scaffolding, service registration, build systems — hours of work before you can test a single endpoint. + +The deeper problem was coordination. Services need to talk to each other, and every pattern for that (sagas, choreography, service mesh) adds complexity. Teams spend more time on infrastructure than on business logic. + +`micro run --prompt` solves both. The AI handles the ceremony. The agent handles the coordination. You handle the domain. + +``` +micro run --prompt "an order system for dropshipping" +micro chat --provider anthropic +> Place an order for 5 units of SKU-123 shipping to London +``` + +That's a running system. Not a prototype. Not a demo. Services you can deploy, iterate on, and scale. + +## Try It + +```bash +go install go-micro.dev/v5/cmd/micro@latest + +micro run --prompt "a task management system" --provider anthropic +micro chat --provider anthropic +``` + +The future of microservices isn't fewer services. It's making them so easy to create and compose that the architecture disappears. Services become tools. The agent becomes the interface. You focus on what matters: the domain. + +--- + +*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/go-micro), or read the [docs](https://go-micro.dev/docs).*