diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-02-08 21:35:45 -1000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-02-08 21:35:45 -1000 |
| commit | 2e2b2187b957e9af78797a67ec5c6874615fae02 (patch) | |
| tree | 1181dbb7e43f5d30cb025fa4d50fd4e7a2c893b3 /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.go | 58 | ||||
| -rw-r--r-- | internal/cli/list.go | 59 | ||||
| -rw-r--r-- | internal/cli/root.go | 40 | ||||
| -rw-r--r-- | internal/cli/run.go | 128 | ||||
| -rw-r--r-- | internal/cli/serve.go | 86 | ||||
| -rw-r--r-- | internal/cli/status.go | 63 |
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(¶llel, "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 +} |
