package cmd import ( "context" "encoding/json" "fmt" "strings" "github.com/spf13/cobra" "github.com/yourorg/devour/internal/projectstate" "github.com/yourorg/devour/internal/scraper" "github.com/yourorg/devour/internal/search" "github.com/yourorg/devour/internal/server" ) var serveCmd = &cobra.Command{ Use: "serve", Short: "Start the local Devour RPC server", Long: `Start the Devour RPC server. Local mode (default): JSON-RPC over stdin/stdout for agent/skill integration. Remote mode (--remote): experimental HTTP RPC endpoint at /rpc. Examples: devour serve devour serve --remote devour serve --remote --port 3000`, RunE: runServe, } var ( serveRemote bool servePort int serveHost string ) func init() { serveCmd.Flags().BoolVar(&serveRemote, "remote", false, "run as remote HTTP server (experimental)") serveCmd.Flags().IntVarP(&servePort, "port", "p", 8080, "HTTP port (remote mode only)") serveCmd.Flags().StringVar(&serveHost, "host", "localhost", "HTTP host (remote mode only)") } func runServe(cmd *cobra.Command, args []string) error { if _, err := loadAppConfig(); err != nil { return fmt.Errorf("load app config for server startup: %w", err) } srvCfg := &server.Config{ Mode: "local", Transport: "stdio", Host: serveHost, Port: servePort, Handler: func(ctx context.Context, method string, params json.RawMessage) (any, error) { return handleServeMethod(ctx, method, params) }, } if serveRemote { srvCfg.Mode = "remote" fmt.Printf("🚀 Starting Devour RPC server in remote experimental mode\n") fmt.Printf(" URL: http://%s:%d/rpc\n", serveHost, servePort) } else { fmt.Println("🚀 Starting Devour RPC server in local mode (stdio)") fmt.Println(" Protocol: JSON-RPC 2.0 over stdin/stdout") } srv := server.NewServer(srvCfg) if err := srv.Start(context.Background()); err != nil { return fmt.Errorf("start rpc server: %w", err) } return nil } func handleServeMethod(ctx context.Context, method string, params json.RawMessage) (any, error) { // The method implementation needs full typed config. Load per-call to avoid stale state. loadedCfg, err := loadAppConfig() if err != nil { return nil, fmt.Errorf("load app config for rpc method %q: %w", method, err) } switch strings.TrimSpace(method) { case "devour_query": var req struct { Query string `json:"query"` Limit int `json:"limit"` Threshold float64 `json:"threshold"` } if len(params) > 0 { if err := json.Unmarshal(params, &req); err != nil { return nil, fmt.Errorf("decode devour_query params: %w", err) } } engine := search.NewEngine(loadedCfg) results, stats, err := engine.Search(ctx, req.Query, search.SearchOptions{Limit: req.Limit, Threshold: req.Threshold}) if err != nil { return nil, fmt.Errorf("run devour_query search: %w", err) } return map[string]any{"query": req.Query, "count": len(results), "results": results, "indexed": stats.Documents}, nil case "devour_status": docsStats, err := projectstate.CollectDocsStats(loadedCfg.Storage.DocsDir) if err != nil { return nil, fmt.Errorf("collect docs stats: %w", err) } state, err := projectstate.LoadSourceState(loadedCfg.Storage.MetadataDir) if err != nil { return nil, fmt.Errorf("load source state: %w", err) } engine := search.NewEngine(loadedCfg) idxStats, err := engine.EnsureIndexed(ctx) if err != nil { return nil, fmt.Errorf("ensure search index: %w", err) } return map[string]any{ "documents": docsStats.DocumentCount, "storage_bytes": docsStats.StorageBytes, "last_updated": docsStats.LastUpdated, "sources": state.Sources, "indexed_docs": idxStats.Documents, "index_timestamp": idxStats.LastIndexedAt, }, nil case "devour_scrape": var req struct { Source string `json:"source"` Type string `json:"type"` Format string `json:"format"` Output string `json:"output"` Query string `json:"query"` ResultLimit int `json:"result_limit"` Domains []string `json:"domains"` Include []string `json:"include"` Exclude []string `json:"exclude"` } if err := json.Unmarshal(params, &req); err != nil { return nil, fmt.Errorf("decode devour_scrape params: %w", err) } if strings.TrimSpace(req.Source) == "" { return nil, fmt.Errorf("source is required") } st := scraper.SourceType(req.Type) if st == "" { st = detectSourceType(req.Source) } source := &scraper.Source{ Name: extractName(req.Source), Type: st, URL: req.Source, Query: strings.TrimSpace(req.Query), ResultLimit: req.ResultLimit, Domains: append([]string(nil), req.Domains...), Include: append([]string(nil), req.Include...), Exclude: append([]string(nil), req.Exclude...), } if st == scraper.SourceTypeLocal { source.Path = req.Source } applySourceProfile(source) prevFormat := scrapeFormat prevOutput := scrapeOutput prevAllowEmpty := scrapeAllowEmpty defer func() { scrapeFormat = prevFormat scrapeOutput = prevOutput scrapeAllowEmpty = prevAllowEmpty }() scrapeFormat = coalesceString(req.Format, "json") scrapeOutput = req.Output scrapeAllowEmpty = false count, err := scrapeOne(nil, loadedCfg, source, resolveOutputDir(loadedCfg, req.Output)) if err != nil { return nil, fmt.Errorf("run scrape for %q: %w", req.Source, err) } return map[string]any{"source": req.Source, "type": st, "documents": count}, nil case "devour_ask": var req struct { Question string `json:"question"` Limit int `json:"limit"` } if err := json.Unmarshal(params, &req); err != nil { return nil, fmt.Errorf("decode devour_ask params: %w", err) } if strings.TrimSpace(req.Question) == "" { return nil, fmt.Errorf("question is required") } limit := req.Limit if limit <= 0 { limit = 5 } engine := search.NewEngine(loadedCfg) results, _, err := engine.Search(ctx, req.Question, search.SearchOptions{Limit: limit}) if err != nil { return nil, fmt.Errorf("run devour_ask search: %w", err) } summary := "No relevant docs found." if len(results) > 0 { summary = results[0].Snippet } return map[string]any{"question": req.Question, "summary": summary, "sources": results}, nil case "devour_sync": prevForce, prevRebuild, prevSource := syncForce, syncRebuild, syncSource var req struct { Source string `json:"source"` Force bool `json:"force"` Rebuild bool `json:"rebuild"` } if len(params) > 0 { if err := json.Unmarshal(params, &req); err != nil { return nil, fmt.Errorf("decode devour_sync params: %w", err) } } syncForce = req.Force syncRebuild = req.Rebuild syncSource = req.Source defer func() { syncForce, syncRebuild, syncSource = prevForce, prevRebuild, prevSource }() err := runSync(nil, nil) if err != nil { return nil, fmt.Errorf("run devour_sync: %w", err) } return map[string]any{"ok": true}, nil default: return nil, fmt.Errorf("unknown method: %s", method) } } func coalesceString(primary, fallback string) string { if strings.TrimSpace(primary) != "" { return primary } return fallback }