summaryrefslogtreecommitdiff
path: root/internal/cli
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-02-08 21:35:45 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-02-08 21:35:45 -1000
commit2e2b2187b957e9af78797a67ec5c6874615fae02 (patch)
tree1181dbb7e43f5d30cb025fa4d50fd4e7a2c893b3 /internal/cli
Initial project: task model, executor, API server, CLI, storage, reporter
Claudomator automation toolkit for Claude Code with: - Task model with YAML parsing, validation, state machine (49 tests, 0 races) - SQLite storage for tasks and executions - Executor pool with bounded concurrency, timeout, cancellation - REST API + WebSocket for mobile PWA integration - Webhook/multi-notifier system - CLI: init, run, serve, list, status commands - Console, JSON, HTML reporters with cost tracking Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/cli')
-rw-r--r--internal/cli/init.go58
-rw-r--r--internal/cli/list.go59
-rw-r--r--internal/cli/root.go40
-rw-r--r--internal/cli/run.go128
-rw-r--r--internal/cli/serve.go86
-rw-r--r--internal/cli/status.go63
6 files changed, 434 insertions, 0 deletions
diff --git a/internal/cli/init.go b/internal/cli/init.go
new file mode 100644
index 0000000..6660f9d
--- /dev/null
+++ b/internal/cli/init.go
@@ -0,0 +1,58 @@
+package cli
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/claudomator/claudomator/internal/storage"
+ "github.com/spf13/cobra"
+)
+
+func newInitCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "init",
+ Short: "Initialize Claudomator data directory",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return initClaudomator()
+ },
+ }
+}
+
+func initClaudomator() error {
+ if err := cfg.EnsureDirs(); err != nil {
+ return fmt.Errorf("creating directories: %w", err)
+ }
+
+ // Initialize database.
+ store, err := storage.Open(cfg.DBPath)
+ if err != nil {
+ return fmt.Errorf("initializing database: %w", err)
+ }
+ store.Close()
+
+ // Create example task file if it doesn't exist.
+ examplePath := filepath.Join(cfg.DataDir, "example-task.yaml")
+ if _, err := os.Stat(examplePath); os.IsNotExist(err) {
+ example := `name: "Example Task"
+description: "A sample task to get started"
+claude:
+ model: "sonnet"
+ instructions: |
+ Say hello and list the files in the current directory.
+ working_dir: "."
+timeout: "5m"
+tags:
+ - "example"
+`
+ if err := os.WriteFile(examplePath, []byte(example), 0644); err != nil {
+ return fmt.Errorf("writing example: %w", err)
+ }
+ }
+
+ fmt.Printf("Claudomator initialized at %s\n", cfg.DataDir)
+ fmt.Printf(" Database: %s\n", cfg.DBPath)
+ fmt.Printf(" Logs: %s\n", cfg.LogDir)
+ fmt.Printf(" Example: %s\n", examplePath)
+ return nil
+}
diff --git a/internal/cli/list.go b/internal/cli/list.go
new file mode 100644
index 0000000..a7515a1
--- /dev/null
+++ b/internal/cli/list.go
@@ -0,0 +1,59 @@
+package cli
+
+import (
+ "fmt"
+ "os"
+ "text/tabwriter"
+
+ "github.com/claudomator/claudomator/internal/storage"
+ "github.com/claudomator/claudomator/internal/task"
+ "github.com/spf13/cobra"
+)
+
+func newListCmd() *cobra.Command {
+ var state string
+
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List tasks",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return listTasks(state)
+ },
+ }
+
+ cmd.Flags().StringVar(&state, "state", "", "filter by state (PENDING, RUNNING, COMPLETED, FAILED)")
+
+ return cmd
+}
+
+func listTasks(state string) error {
+ store, err := storage.Open(cfg.DBPath)
+ if err != nil {
+ return fmt.Errorf("opening db: %w", err)
+ }
+ defer store.Close()
+
+ filter := storage.TaskFilter{}
+ if state != "" {
+ filter.State = task.State(state)
+ }
+
+ tasks, err := store.ListTasks(filter)
+ if err != nil {
+ return err
+ }
+
+ if len(tasks) == 0 {
+ fmt.Println("No tasks found.")
+ return nil
+ }
+
+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
+ fmt.Fprintln(w, "ID\tNAME\tSTATE\tPRIORITY\tCREATED")
+ for _, t := range tasks {
+ fmt.Fprintf(w, "%.8s\t%s\t%s\t%s\t%s\n",
+ t.ID, t.Name, t.State, t.Priority, t.CreatedAt.Format("2006-01-02 15:04"))
+ }
+ w.Flush()
+ return nil
+}
diff --git a/internal/cli/root.go b/internal/cli/root.go
new file mode 100644
index 0000000..2800a76
--- /dev/null
+++ b/internal/cli/root.go
@@ -0,0 +1,40 @@
+package cli
+
+import (
+ "github.com/claudomator/claudomator/internal/config"
+ "github.com/spf13/cobra"
+)
+
+var (
+ cfgFile string
+ verbose bool
+ cfg *config.Config
+)
+
+func NewRootCmd() *cobra.Command {
+ cfg = config.Default()
+
+ cmd := &cobra.Command{
+ Use: "claudomator",
+ Short: "Automation toolkit for Claude Code",
+ Long: "Claudomator captures tasks, dispatches them to Claude Code, and reports results.",
+ }
+
+ cmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default $HOME/.claudomator/config.toml)")
+ cmd.PersistentFlags().StringVar(&cfg.DataDir, "data-dir", cfg.DataDir, "data directory")
+ cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
+
+ cmd.AddCommand(
+ newRunCmd(),
+ newServeCmd(),
+ newListCmd(),
+ newStatusCmd(),
+ newInitCmd(),
+ )
+
+ return cmd
+}
+
+func Execute() error {
+ return NewRootCmd().Execute()
+}
diff --git a/internal/cli/run.go b/internal/cli/run.go
new file mode 100644
index 0000000..e74b247
--- /dev/null
+++ b/internal/cli/run.go
@@ -0,0 +1,128 @@
+package cli
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "os/signal"
+ "syscall"
+
+ "github.com/claudomator/claudomator/internal/executor"
+ "github.com/claudomator/claudomator/internal/storage"
+ "github.com/claudomator/claudomator/internal/task"
+ "github.com/spf13/cobra"
+)
+
+func newRunCmd() *cobra.Command {
+ var (
+ parallel int
+ dryRun bool
+ )
+
+ cmd := &cobra.Command{
+ Use: "run <task-file>",
+ Short: "Run task(s) from a YAML file",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return runTasks(args[0], parallel, dryRun)
+ },
+ }
+
+ cmd.Flags().IntVarP(&parallel, "parallel", "p", 3, "max concurrent executions")
+ cmd.Flags().BoolVar(&dryRun, "dry-run", false, "validate without executing")
+
+ return cmd
+}
+
+func runTasks(file string, parallel int, dryRun bool) error {
+ tasks, err := task.ParseFile(file)
+ if err != nil {
+ return fmt.Errorf("parsing: %w", err)
+ }
+
+ // Validate all tasks.
+ for i := range tasks {
+ if err := task.Validate(&tasks[i]); err != nil {
+ return fmt.Errorf("task %q: %w", tasks[i].Name, err)
+ }
+ }
+
+ if dryRun {
+ fmt.Printf("Validated %d task(s) successfully.\n", len(tasks))
+ for _, t := range tasks {
+ fmt.Printf(" - %s (model: %s, timeout: %v)\n", t.Name, t.Claude.Model, t.Timeout.Duration)
+ }
+ return nil
+ }
+
+ // Setup infrastructure.
+ if err := cfg.EnsureDirs(); err != nil {
+ return fmt.Errorf("creating dirs: %w", err)
+ }
+
+ store, err := storage.Open(cfg.DBPath)
+ if err != nil {
+ return fmt.Errorf("opening db: %w", err)
+ }
+ defer store.Close()
+
+ level := slog.LevelInfo
+ if verbose {
+ level = slog.LevelDebug
+ }
+ logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}))
+
+ runner := &executor.ClaudeRunner{
+ BinaryPath: cfg.ClaudeBinaryPath,
+ Logger: logger,
+ LogDir: cfg.LogDir,
+ }
+ pool := executor.NewPool(parallel, runner, store, logger)
+
+ // Handle graceful shutdown.
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ sigCh := make(chan os.Signal, 1)
+ signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
+ go func() {
+ <-sigCh
+ fmt.Fprintln(os.Stderr, "\nShutting down...")
+ cancel()
+ }()
+
+ // Submit all tasks.
+ fmt.Printf("Dispatching %d task(s) (max concurrency: %d)...\n", len(tasks), parallel)
+ for i := range tasks {
+ if err := store.CreateTask(&tasks[i]); err != nil {
+ return fmt.Errorf("storing task: %w", err)
+ }
+ if err := store.UpdateTaskState(tasks[i].ID, task.StateQueued); err != nil {
+ return fmt.Errorf("queuing task: %w", err)
+ }
+ tasks[i].State = task.StateQueued
+ if err := pool.Submit(ctx, &tasks[i]); err != nil {
+ logger.Warn("could not submit task", "name", tasks[i].Name, "error", err)
+ }
+ }
+
+ // Wait for all results.
+ completed, failed := 0, 0
+ for i := 0; i < len(tasks); i++ {
+ result := <-pool.Results()
+ if result.Err != nil {
+ failed++
+ fmt.Printf(" FAIL %s: %v\n", result.TaskID, result.Err)
+ } else {
+ completed++
+ fmt.Printf(" OK %s (cost: $%.4f)\n", result.TaskID, result.Execution.CostUSD)
+ }
+ }
+
+ fmt.Printf("\nDone: %d completed, %d failed\n", completed, failed)
+ if failed > 0 {
+ return fmt.Errorf("%d task(s) failed", failed)
+ }
+ return nil
+}
diff --git a/internal/cli/serve.go b/internal/cli/serve.go
new file mode 100644
index 0000000..5d41395
--- /dev/null
+++ b/internal/cli/serve.go
@@ -0,0 +1,86 @@
+package cli
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "github.com/claudomator/claudomator/internal/api"
+ "github.com/claudomator/claudomator/internal/executor"
+ "github.com/claudomator/claudomator/internal/storage"
+ "github.com/spf13/cobra"
+)
+
+func newServeCmd() *cobra.Command {
+ var addr string
+
+ cmd := &cobra.Command{
+ Use: "serve",
+ Short: "Start the Claudomator API server",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return serve(addr)
+ },
+ }
+
+ cmd.Flags().StringVar(&addr, "addr", ":8484", "listen address")
+
+ return cmd
+}
+
+func serve(addr string) error {
+ if err := cfg.EnsureDirs(); err != nil {
+ return fmt.Errorf("creating dirs: %w", err)
+ }
+
+ store, err := storage.Open(cfg.DBPath)
+ if err != nil {
+ return fmt.Errorf("opening db: %w", err)
+ }
+ defer store.Close()
+
+ level := slog.LevelInfo
+ if verbose {
+ level = slog.LevelDebug
+ }
+ logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}))
+
+ runner := &executor.ClaudeRunner{
+ BinaryPath: cfg.ClaudeBinaryPath,
+ Logger: logger,
+ LogDir: cfg.LogDir,
+ }
+ pool := executor.NewPool(cfg.MaxConcurrent, runner, store, logger)
+
+ srv := api.NewServer(store, pool, logger)
+ srv.StartHub()
+
+ httpSrv := &http.Server{
+ Addr: addr,
+ Handler: srv.Handler(),
+ }
+
+ // Graceful shutdown.
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ sigCh := make(chan os.Signal, 1)
+ signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
+ go func() {
+ <-sigCh
+ logger.Info("shutting down server...")
+ shutdownCtx, shutdownCancel := context.WithTimeout(ctx, 5*time.Second)
+ defer shutdownCancel()
+ httpSrv.Shutdown(shutdownCtx)
+ }()
+
+ fmt.Printf("Claudomator server listening on %s\n", addr)
+ if err := httpSrv.ListenAndServe(); err != http.ErrServerClosed {
+ return err
+ }
+ return nil
+}
diff --git a/internal/cli/status.go b/internal/cli/status.go
new file mode 100644
index 0000000..4613fee
--- /dev/null
+++ b/internal/cli/status.go
@@ -0,0 +1,63 @@
+package cli
+
+import (
+ "fmt"
+ "os"
+ "text/tabwriter"
+
+ "github.com/claudomator/claudomator/internal/storage"
+ "github.com/spf13/cobra"
+)
+
+func newStatusCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "status <task-id>",
+ Short: "Show task status and execution history",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return showStatus(args[0])
+ },
+ }
+ return cmd
+}
+
+func showStatus(id string) error {
+ store, err := storage.Open(cfg.DBPath)
+ if err != nil {
+ return fmt.Errorf("opening db: %w", err)
+ }
+ defer store.Close()
+
+ // Try full ID first, then prefix match.
+ t, err := store.GetTask(id)
+ if err != nil {
+ return fmt.Errorf("task %q not found", id)
+ }
+
+ fmt.Printf("Task: %s\n", t.Name)
+ fmt.Printf("ID: %s\n", t.ID)
+ fmt.Printf("State: %s\n", t.State)
+ fmt.Printf("Priority: %s\n", t.Priority)
+ fmt.Printf("Model: %s\n", t.Claude.Model)
+ if t.Description != "" {
+ fmt.Printf("Description: %s\n", t.Description)
+ }
+
+ execs, err := store.ListExecutions(t.ID)
+ if err != nil {
+ return err
+ }
+
+ if len(execs) > 0 {
+ fmt.Printf("\nExecutions (%d):\n", len(execs))
+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
+ fmt.Fprintln(w, " ID\tSTATUS\tEXIT\tCOST\tDURATION\tSTARTED")
+ for _, e := range execs {
+ dur := e.EndTime.Sub(e.StartTime)
+ fmt.Fprintf(w, " %.8s\t%s\t%d\t$%.4f\t%v\t%s\n",
+ e.ID, e.Status, e.ExitCode, e.CostUSD, dur.Round(1e9), e.StartTime.Format("15:04:05"))
+ }
+ w.Flush()
+ }
+ return nil
+}