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
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,8 @@ internal/
doctor/ Health check engine
stats/ Database statistics
query/ Preset SQL queries
mcp/ MCP JSON-RPC server
mcp/ MCP JSON-RPC server + structured logging
ui/ Centralized lipgloss styles + table helpers
tools/
registry/ Tool registration framework
hive/ Hive tool handlers (11 tools)
Expand All @@ -271,6 +272,8 @@ originally by [Joel Hooks](https://github.com/joelhooks).
## Active Technologies
- Go 1.25+ + `cobra` (CLI), `modernc.org/sqlite` (pure Go SQLite), stdlib `encoding/json` (MCP JSON-RPC), stdlib `os/exec` (git operations) (001-go-rewrite-phases)
- SQLite at `~/.config/swarm-tools/swarm.db` (WAL mode, compatible with cyborg-swarm) (001-go-rewrite-phases)
- Go 1.25+ + `charmbracelet/lipgloss v1.1.0`, `charmbracelet/log v1.0.0`, `muesli/termenv v0.16.0`, `charmbracelet/lipgloss/table` (sub-package of lipgloss) (002-charm-ux)
- SQLite via `modernc.org/sqlite` (unchanged) (002-charm-ux)

## Recent Changes
- 001-go-rewrite-phases: Added Go 1.25+ + `cobra` (CLI), `modernc.org/sqlite` (pure Go SQLite), stdlib `encoding/json` (MCP JSON-RPC), stdlib `os/exec` (git operations)
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@ internal/
doctor/ Health check engine
stats/ Database activity summary
query/ Preset SQL analytics
mcp/ MCP JSON-RPC server
mcp/ MCP JSON-RPC server + structured logging
ui/ Centralized lipgloss styles + table helpers
tools/
registry/ Tool registration framework
hive/ Hive tool handlers (11)
Expand Down
21 changes: 15 additions & 6 deletions cmd/replicator/cells.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@ package main
import (
"encoding/json"
"fmt"
"os"

"github.com/unbound-force/replicator/internal/config"
"github.com/unbound-force/replicator/internal/db"
"github.com/unbound-force/replicator/internal/hive"
)

// listCells queries and prints hive cells as JSON.
// jsonOutput controls whether cells are printed as JSON or a styled table.
var jsonOutput bool

// listCells queries and prints hive cells.
// With --json, outputs indented JSON. Otherwise, renders a styled table.
func listCells(cfg *config.Config) error {
store, err := db.Open(cfg.DatabasePath)
if err != nil {
Expand All @@ -22,10 +27,14 @@ func listCells(cfg *config.Config) error {
return fmt.Errorf("query cells: %w", err)
}

out, err := json.MarshalIndent(cells, "", " ")
if err != nil {
return err
if jsonOutput {
out, err := json.MarshalIndent(cells, "", " ")
if err != nil {
return fmt.Errorf("marshal cells: %w", err)
}
fmt.Println(string(out))
return nil
}
fmt.Println(string(out))
return nil

return hive.FormatCells(cells, os.Stdout)
}
38 changes: 3 additions & 35 deletions cmd/replicator/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ package main

import (
"fmt"
"time"
"os"

"github.com/unbound-force/replicator/internal/config"
"github.com/unbound-force/replicator/internal/db"
"github.com/unbound-force/replicator/internal/doctor"
)

// runDoctor executes health checks and prints results as a table.
// runDoctor executes health checks and prints styled results.
func runDoctor(cfg *config.Config) error {
store, err := db.Open(cfg.DatabasePath)
if err != nil {
Expand All @@ -22,37 +22,5 @@ func runDoctor(cfg *config.Config) error {
return fmt.Errorf("run checks: %w", err)
}

// Print header.
fmt.Printf("%-12s %-6s %s\n", "CHECK", "STATUS", "MESSAGE")
fmt.Printf("%-12s %-6s %s\n", "-----", "------", "-------")

hasFailure := false
for _, r := range results {
icon := statusIcon(r.Status)
fmt.Printf("%-12s %s %-4s %s (%s)\n", r.Name, icon, r.Status, r.Message, r.Duration.Round(time.Millisecond))
if r.Status == "fail" {
hasFailure = true
}
}

if hasFailure {
fmt.Println("\nSome checks failed. Run 'replicator setup' to fix common issues.")
} else {
fmt.Println("\nAll checks passed.")
}

return nil
}

func statusIcon(status string) string {
switch status {
case "pass":
return "\u2713" // checkmark
case "fail":
return "\u2717" // X mark
case "warn":
return "!" // warning
default:
return "?"
}
return doctor.FormatText(results, os.Stdout)
}
7 changes: 5 additions & 2 deletions cmd/replicator/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"path/filepath"

"github.com/spf13/cobra"
"github.com/unbound-force/replicator/internal/ui"
)

func initCmd() *cobra.Command {
Expand All @@ -27,12 +28,14 @@ global database (replicator setup) or any external services.`,
}

// runInit creates the .hive/ directory and seeds cells.json.
// Uses styled output: green for success, dim for already-initialized.
func runInit(targetDir string) error {
styles := ui.NewStyles(os.Stdout)
hiveDir := filepath.Join(targetDir, ".hive")

// Check if already initialized.
if info, err := os.Stat(hiveDir); err == nil && info.IsDir() {
fmt.Println("already initialized")
fmt.Println(styles.Dim.Render("already initialized"))
return nil
}

Expand All @@ -47,6 +50,6 @@ func runInit(targetDir string) error {
return fmt.Errorf("write cells.json: %w", err)
}

fmt.Println("initialized .hive/")
fmt.Println(styles.Pass.Render("initialized .hive/"))
return nil
}
9 changes: 6 additions & 3 deletions cmd/replicator/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/spf13/cobra"
"github.com/unbound-force/replicator/internal/config"
"github.com/unbound-force/replicator/internal/ui"
)

// Build-time variables set via ldflags.
Expand Down Expand Up @@ -71,6 +72,7 @@ func cellsCmd() *cobra.Command {
return listCells(cfg)
},
}
cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON instead of a styled table")
return cmd
}

Expand All @@ -79,12 +81,13 @@ func versionCmd() *cobra.Command {
Use: "version",
Short: "Print version information",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("replicator %s\n", Version)
styles := ui.NewStyles(os.Stdout)
fmt.Printf("replicator %s\n", styles.Bold.Render(Version))
if commit != "unknown" {
fmt.Printf(" commit: %s\n", commit)
fmt.Printf(" commit: %s\n", styles.Dim.Render(commit))
}
if date != "unknown" {
fmt.Printf(" built: %s\n", date)
fmt.Printf(" built: %s\n", styles.Dim.Render(date))
}
},
}
Expand Down
14 changes: 9 additions & 5 deletions cmd/replicator/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ package main
import (
"fmt"
"os"
"strings"

"github.com/unbound-force/replicator/internal/config"
"github.com/unbound-force/replicator/internal/db"
"github.com/unbound-force/replicator/internal/query"
"github.com/unbound-force/replicator/internal/ui"
)

// runQuery executes a preset query and prints results.
Expand All @@ -21,13 +21,17 @@ func runQuery(cfg *config.Config, presetName string) error {
return query.Run(store, presetName, os.Stdout)
}

// listQueryPresets prints available preset names.
// listQueryPresets prints available preset names with styled output.
func listQueryPresets() {
fmt.Println("Available query presets:")
styles := ui.NewStyles(os.Stdout)

fmt.Println(styles.Bold.Render("Available query presets:"))
for _, p := range query.ListPresets() {
fmt.Printf(" %s\n", p)
}
fmt.Println()
fmt.Println("Usage: replicator query <preset>")
fmt.Printf("Example: replicator query %s\n", strings.Join(query.ListPresets()[:1], ""))
fmt.Println(styles.Dim.Render("Usage: replicator query <preset>"))
fmt.Printf("%s replicator query %s\n",
styles.Dim.Render("Example:"),
query.ListPresets()[0])
}
47 changes: 46 additions & 1 deletion cmd/replicator/serve.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
package main

import (
"fmt"
"io"
"os"
"path/filepath"

charmlog "github.com/charmbracelet/log"

"github.com/unbound-force/replicator/internal/config"
"github.com/unbound-force/replicator/internal/db"
"github.com/unbound-force/replicator/internal/mcp"
Expand All @@ -16,6 +23,13 @@ import (
func serveMCP() error {
cfg := config.Load()

// Set up structured logging to file (and stderr).
// Bootstrap exception: use fmt.Fprintf for errors before the logger exists.
logger, logCloser := setupLogger()
if logCloser != nil {
defer logCloser.Close()
}

store, err := db.Open(cfg.DatabasePath)
if err != nil {
return err
Expand All @@ -31,6 +45,37 @@ func serveMCP() error {
memClient := memory.NewClient(cfg.DeweyURL)
memorytools.Register(reg, memClient)

server := mcp.NewServer(reg, Version)
server := mcp.NewServer(reg, Version, logger)
return server.ServeStdio()
}

// setupLogger creates a charmbracelet/log logger that writes to both
// stderr and .unbound-force/replicator.log. If the log file cannot be
// created, logging falls back to stderr only and a warning is printed.
// The returned io.Closer should be deferred by the caller; it may be nil.
func setupLogger() (*charmlog.Logger, io.Closer) {
logDir := filepath.Join(".", ".unbound-force")
if err := os.MkdirAll(logDir, 0o755); err != nil {
fmt.Fprintf(os.Stderr, "warning: cannot create log directory: %v (logging to stderr only)\n", err)
return charmlog.NewWithOptions(os.Stderr, charmlog.Options{
ReportTimestamp: true,
Level: charmlog.InfoLevel,
}), nil
}

logFile, err := os.Create(filepath.Join(logDir, "replicator.log"))
if err != nil {
fmt.Fprintf(os.Stderr, "warning: cannot create log file: %v (logging to stderr only)\n", err)
return charmlog.NewWithOptions(os.Stderr, charmlog.Options{
ReportTimestamp: true,
Level: charmlog.InfoLevel,
}), nil
}

logWriter := io.MultiWriter(os.Stderr, logFile)
logger := charmlog.NewWithOptions(logWriter, charmlog.Options{
ReportTimestamp: true,
Level: charmlog.InfoLevel,
})
return logger, logFile
}
98 changes: 98 additions & 0 deletions cmd/replicator/serve_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package main

import (
"os"
"path/filepath"
"testing"
)

func TestSetupLogger_CreatesLogFile(t *testing.T) {
// Run setupLogger in a temp directory so it creates
// .unbound-force/replicator.log there.
dir := t.TempDir()
orig, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd: %v", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("Chdir: %v", err)
}
t.Cleanup(func() { os.Chdir(orig) })

logger, closer := setupLogger()
if closer != nil {
defer closer.Close()
}
if logger == nil {
t.Fatal("expected non-nil logger")
}

logPath := filepath.Join(dir, ".unbound-force", "replicator.log")
if _, err := os.Stat(logPath); err != nil {
t.Fatalf("log file not created: %v", err)
}
}

func TestSetupLogger_Truncates(t *testing.T) {
dir := t.TempDir()
orig, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd: %v", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("Chdir: %v", err)
}
t.Cleanup(func() { os.Chdir(orig) })

// First call: write a marker to the log file.
logDir := filepath.Join(dir, ".unbound-force")
os.MkdirAll(logDir, 0o755)
logPath := filepath.Join(logDir, "replicator.log")
os.WriteFile(logPath, []byte("MARKER_SHOULD_BE_GONE"), 0o644)

// Second call: setupLogger uses os.Create which truncates.
_, closer := setupLogger()
if closer != nil {
closer.Close()
}

data, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(data) == "MARKER_SHOULD_BE_GONE" {
t.Error("log file was not truncated; marker still present")
}
}

func TestSetupLogger_ReadOnlyDir(t *testing.T) {
// Verify that a read-only directory doesn't cause a panic.
// setupLogger should fall back to stderr-only logging.
dir := t.TempDir()
orig, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd: %v", err)
}

// Create the .unbound-force dir as read-only so file creation fails.
logDir := filepath.Join(dir, ".unbound-force")
os.MkdirAll(logDir, 0o755)
os.Chmod(logDir, 0o444)
t.Cleanup(func() {
os.Chmod(logDir, 0o755) // restore so TempDir cleanup works
})

if err := os.Chdir(dir); err != nil {
t.Fatalf("Chdir: %v", err)
}
t.Cleanup(func() { os.Chdir(orig) })

// Should not panic — falls back to stderr-only.
logger, closer := setupLogger()
if closer != nil {
defer closer.Close()
}
if logger == nil {
t.Fatal("expected non-nil logger even when log file creation fails")
}
}
Loading
Loading