mirror of
https://github.com/tiennm99/MTClaw.git
synced 2026-08-04 15:23:27 +00:00
YAML config with env-indirected secrets, SQLite persistence, OpenAI provider, think/act/observe agent loop, fs/web_fetch/exec tools behind deny->allow->mode policy with approval flows, Telegram long-polling channel with gating and inline approvals, gateway with per-session serialization + instance lock + graceful shutdown, cron scheduler.
152 lines
4.4 KiB
Go
152 lines
4.4 KiB
Go
// Package cli wires cobra commands to the config loader and logger. It is
|
|
// the only package main.go calls into.
|
|
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/tiennm99/MTClaw/internal/config"
|
|
"github.com/tiennm99/MTClaw/internal/logging"
|
|
"github.com/tiennm99/MTClaw/internal/store"
|
|
"github.com/tiennm99/MTClaw/internal/store/sqlite"
|
|
)
|
|
|
|
// state is process-wide command state populated by the root command's
|
|
// PersistentPreRunE and read by subcommands. It exists because cobra
|
|
// subcommands are constructed once at startup, before flags are parsed.
|
|
type state struct {
|
|
configFlag string
|
|
logLevelFlag string
|
|
|
|
configPath string
|
|
configSource string
|
|
cfg *config.Config
|
|
|
|
store store.Store
|
|
}
|
|
|
|
// Execute builds and runs the mtclaw command tree, returning any error so
|
|
// main can set the process exit code. The store (see state.openStore) is
|
|
// closed here, after the whole command tree has run, rather than from a
|
|
// PersistentPostRunE: cobra skips PersistentPostRunE entirely when RunE
|
|
// returns an error, which would leak the handle on every failing command
|
|
// that had opened one.
|
|
func Execute() error {
|
|
s := &state{}
|
|
err := newRootCmd(s).Execute()
|
|
if closeErr := s.closeStore(); closeErr != nil && err == nil {
|
|
err = closeErr
|
|
}
|
|
return err
|
|
}
|
|
|
|
// openStore lazily opens the sqlite store backing cfg.Storage.Path,
|
|
// reusing the same handle across multiple calls within one process.
|
|
// readOnly selects a read-only connection for inspection commands
|
|
// (`sessions list`, `sessions show`); write commands (`sessions rm` today;
|
|
// `prompt` and `cron run` in later phases) must pass false. See
|
|
// Execute for where the handle gets closed.
|
|
func (s *state) openStore(ctx context.Context, readOnly bool) (store.Store, error) {
|
|
if s.store != nil {
|
|
return s.store, nil
|
|
}
|
|
if s.cfg == nil {
|
|
return nil, fmt.Errorf("open store: config not loaded")
|
|
}
|
|
db, err := sqlite.Open(ctx, s.cfg.Storage.Path, readOnly)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open store: %w", err)
|
|
}
|
|
s.store = sqlite.New(db)
|
|
return s.store, nil
|
|
}
|
|
|
|
// closeStore closes the store handle if openStore ever opened one.
|
|
func (s *state) closeStore() error {
|
|
if s.store == nil {
|
|
return nil
|
|
}
|
|
err := s.store.Close()
|
|
s.store = nil
|
|
return err
|
|
}
|
|
|
|
func newRootCmd(s *state) *cobra.Command {
|
|
root := &cobra.Command{
|
|
Use: "mtclaw",
|
|
Short: "MTClaw: a single-binary personal AI agent gateway",
|
|
SilenceUsage: true,
|
|
SilenceErrors: false,
|
|
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
|
return s.prepare(cmd)
|
|
},
|
|
}
|
|
|
|
root.PersistentFlags().StringVar(&s.configFlag, "config", "", "path to the config file (default: $MTCLAW_CONFIG or ~/.mtclaw/config.yaml)")
|
|
root.PersistentFlags().StringVar(&s.logLevelFlag, "log-level", "", "override log.level from the config file (debug|info|warn|error)")
|
|
|
|
root.AddCommand(newVersionCmd())
|
|
root.AddCommand(newConfigCmd(s))
|
|
root.AddCommand(newSessionsCmd(s))
|
|
root.AddCommand(newPromptCmd(s))
|
|
root.AddCommand(newApprovalsCmd(s))
|
|
root.AddCommand(newSendCmd(s))
|
|
root.AddCommand(newGatewayCmd(s))
|
|
root.AddCommand(newCronCmd(s))
|
|
root.AddCommand(newOnboardCmd(s))
|
|
root.AddCommand(newDoctorCmd(s))
|
|
|
|
return root
|
|
}
|
|
|
|
// prepare resolves the config path for every command (cheap, no I/O beyond
|
|
// os.UserHomeDir) and, unless the command is exempt, loads and validates
|
|
// the config file and installs the resulting logger as slog's default.
|
|
func (s *state) prepare(cmd *cobra.Command) error {
|
|
path, source, err := config.ConfigPath(s.configFlag)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.configPath, s.configSource = path, source
|
|
|
|
if skipsConfigLoad(cmd) {
|
|
return nil
|
|
}
|
|
|
|
cfg, err := config.LoadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if s.logLevelFlag != "" {
|
|
cfg.Log.Level = s.logLevelFlag
|
|
}
|
|
s.cfg = cfg
|
|
|
|
logger, err := logging.New(cfg.Log)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
slog.SetDefault(logger)
|
|
|
|
return nil
|
|
}
|
|
|
|
// skipsConfigLoad reports whether cmd must work with a broken or absent
|
|
// config file: `mtclaw version` and `mtclaw config path` always did;
|
|
// `mtclaw onboard` (phase 9) exists specifically to write a config that
|
|
// does not exist yet, and `mtclaw doctor` loads the config itself so a
|
|
// parse/validation failure becomes a diagnosable FAIL row instead of a
|
|
// command that cannot even start.
|
|
func skipsConfigLoad(cmd *cobra.Command) bool {
|
|
switch cmd.CommandPath() {
|
|
case "mtclaw version", "mtclaw config path", "mtclaw onboard", "mtclaw doctor":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|