Files
tiennm99 fb61a57785 feat: implement personal AI agent gateway with Telegram channel and exec policy engine
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.
2026-08-01 18:19:27 +07:00

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
}
}