clawdex/internal/cli/cli.go
2026-05-08 14:18:34 +01:00

803 lines
21 KiB
Go

package cli
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"sort"
"strings"
"time"
"github.com/alecthomas/kong"
"github.com/openclaw/clawdex/internal/apple"
"github.com/openclaw/clawdex/internal/avatar"
"github.com/openclaw/clawdex/internal/birdclaw"
"github.com/openclaw/clawdex/internal/discrawl"
"github.com/openclaw/clawdex/internal/google"
"github.com/openclaw/clawdex/internal/index"
"github.com/openclaw/clawdex/internal/markdown"
"github.com/openclaw/clawdex/internal/model"
"github.com/openclaw/clawdex/internal/repo"
"github.com/openclaw/clawdex/internal/vcard"
)
var Version = "dev"
type CLI struct {
Config string `name:"config" help:"Config path" env:"CLAWDEX_CONFIG"`
Repo string `name:"repo" help:"Contacts data repo path" env:"CLAWDEX_REPO"`
JSON bool `name:"json" help:"Write JSON to stdout"`
Plain bool `name:"plain" help:"Write stable plain text to stdout"`
DryRun bool `name:"dry-run" short:"n" help:"Preview changes without writing"`
NoInput bool `name:"no-input" help:"Never prompt"`
Verbose bool `name:"verbose" short:"v" help:"Verbose diagnostics"`
Version kong.VersionFlag `name:"version" help:"Print version and exit"`
Init InitCmd `cmd:"" help:"Initialize a contacts data repo"`
ConfigC ConfigCmd `cmd:"" name:"config" help:"Show or edit clawdex config"`
Person PersonCmd `cmd:"" help:"Manage people"`
Note NoteCmd `cmd:"" help:"Manage notes"`
Timeline TimelineCmd `cmd:"" help:"Show person timeline"`
Search SearchCmd `cmd:"" help:"Search people and notes"`
Import ImportCmd `cmd:"" help:"Import contacts into local markdown"`
Sync SyncCmd `cmd:"" help:"Preview sync with address books"`
Export ExportCmd `cmd:"" help:"Export contacts"`
Git GitCmd `cmd:"" help:"Run data repo git helpers"`
Doctor DoctorCmd `cmd:"" help:"Check repo health"`
}
type Runtime struct {
ctx context.Context
stdout io.Writer
stderr io.Writer
root *CLI
configPath string
cfg repo.Config
repo repo.Repo
store index.Store
}
func Execute(args []string, stdout, stderr io.Writer) error {
var root CLI
parser, err := kong.New(&root,
kong.Name("clawdex"),
kong.Description("Personal contact index backed by markdown and private Git."),
kong.UsageOnError(),
kong.Writers(stdout, stderr),
kong.Vars{"version": Version},
)
if err != nil {
return err
}
kctx, err := parser.Parse(args)
if err != nil {
return usageErr{err}
}
configPath := repo.ResolveConfigPath(root.Config)
cfg, err := repo.LoadConfig(configPath)
if err != nil {
return err
}
repoPath, err := repo.ResolveRepoPath(root.Repo, cfg)
if err != nil {
repoPath = cfg.RepoPath
}
r := &Runtime{
ctx: context.Background(),
stdout: stdout,
stderr: stderr,
root: &root,
configPath: configPath,
cfg: cfg,
repo: repo.Open(repoPath, cfg),
}
r.store = index.New(r.repo)
kctx.Bind(r)
if err := kctx.Run(r); err != nil {
return err
}
return nil
}
func ExitCode(err error) int {
if err == nil {
return 0
}
var usage usageErr
if errors.As(err, &usage) {
return 2
}
return 1
}
type usageErr struct{ error }
type InitCmd struct {
Dir string `arg:"" optional:"" help:"Contacts data repo directory"`
Remote string `name:"remote" help:"Git remote for contacts backup"`
NoConfig bool `name:"no-config" help:"Do not write app config"`
}
func (c *InitCmd) Run(r *Runtime) error {
cfg := r.cfg
if c.Dir != "" {
cfg.RepoPath = c.Dir
}
if c.Remote != "" {
cfg.Git.Remote = c.Remote
}
cfg.Normalize()
dataRepo := repo.Open(cfg.RepoPath, cfg)
if err := dataRepo.Init(r.ctx); err != nil {
return err
}
if !c.NoConfig {
if err := repo.WriteConfig(r.configPath, cfg); err != nil {
return err
}
}
return r.print(map[string]any{"repo_path": cfg.RepoPath, "remote": cfg.Git.Remote, "config_path": r.configPath})
}
type ConfigCmd struct {
Show ConfigShowCmd `cmd:"" default:"1" help:"Show config"`
Set ConfigSetCmd `cmd:"" help:"Set config value"`
}
type ConfigShowCmd struct{}
func (c *ConfigShowCmd) Run(r *Runtime) error {
return r.print(r.cfg)
}
type ConfigSetCmd struct {
Key string `arg:"" help:"Config key"`
Value string `arg:"" help:"Config value"`
}
func (c *ConfigSetCmd) Run(r *Runtime) error {
cfg := r.cfg
switch c.Key {
case "repo_path":
cfg.RepoPath = c.Value
case "git.remote":
cfg.Git.Remote = c.Value
case "git.branch":
cfg.Git.Branch = c.Value
case "google.default_account":
cfg.Google.DefaultAccount = c.Value
default:
return usageErr{fmt.Errorf("unsupported config key %q", c.Key)}
}
if r.root.DryRun {
return r.print(cfg)
}
if err := repo.WriteConfig(r.configPath, cfg); err != nil {
return err
}
return r.print(map[string]any{"config_path": r.configPath, "set": c.Key})
}
type PersonCmd struct {
Add PersonAddCmd `cmd:"" help:"Add a person"`
List PersonListCmd `cmd:"" help:"List people"`
Show PersonShowCmd `cmd:"" help:"Show a person"`
Edit PersonEditCmd `cmd:"" help:"Edit a person markdown file"`
Avatar PersonAvatarCmd `cmd:"" help:"Manage person avatars"`
}
type PersonAddCmd struct {
Name string `arg:"" help:"Person name"`
Email []string `name:"email" short:"e" help:"Email address"`
Phone []string `name:"phone" short:"p" help:"Phone number"`
Tag []string `name:"tag" short:"t" help:"Tag"`
}
func (c *PersonAddCmd) Run(r *Runtime) error {
if err := r.repo.Require(); err != nil {
return err
}
if r.root.DryRun {
return r.print(map[string]any{"would_create": c.Name})
}
p, err := r.store.AddPerson(c.Name, c.Email, c.Phone, c.Tag, time.Now())
if err != nil {
return err
}
return r.printPerson(p)
}
type PersonListCmd struct {
Query string `name:"query" short:"q" help:"Filter query"`
}
func (c *PersonListCmd) Run(r *Runtime) error {
people, err := r.store.People()
if err != nil {
return err
}
if c.Query != "" {
filtered := people[:0]
q := strings.ToLower(c.Query)
for _, p := range people {
if strings.Contains(strings.ToLower(p.Name+" "+p.ID+" "+strings.Join(p.Tags, " ")), q) {
filtered = append(filtered, p)
}
}
people = filtered
}
return r.printPeople(people)
}
type PersonShowCmd struct {
Query string `arg:"" help:"ID, name, email, or phone"`
}
func (c *PersonShowCmd) Run(r *Runtime) error {
p, err := r.store.FindPerson(c.Query)
if err != nil {
return err
}
return r.printPerson(p)
}
type PersonEditCmd struct {
Query string `arg:"" help:"ID, name, email, or phone"`
}
func (c *PersonEditCmd) Run(r *Runtime) error {
p, err := r.store.FindPerson(c.Query)
if err != nil {
return err
}
editor := strings.TrimSpace(os.Getenv("EDITOR"))
if editor == "" {
editor = "code"
}
// #nosec G204,G702 -- EDITOR is a deliberate user-controlled executable; no shell is involved.
cmd := exec.CommandContext(r.ctx, editor, p.Path)
cmd.Stdout = r.stdout
cmd.Stderr = r.stderr
cmd.Stdin = os.Stdin
return cmd.Run()
}
type PersonAvatarCmd struct {
Set PersonAvatarSetCmd `cmd:"" help:"Set a local avatar image"`
Show PersonAvatarShowCmd `cmd:"" help:"Show avatar metadata"`
Clear PersonAvatarClearCmd `cmd:"" help:"Clear avatar metadata"`
}
type PersonAvatarSetCmd struct {
Person string `arg:"" help:"Person query"`
File string `arg:"" help:"Image file"`
}
func (c *PersonAvatarSetCmd) Run(r *Runtime) error {
p, err := r.store.FindPerson(c.Person)
if err != nil {
return err
}
if r.root.DryRun {
ref, err := avatar.InspectFile(c.File)
if err != nil {
return err
}
ref.Path = "avatars/avatar"
ref.Source = "manual"
return r.print(map[string]any{"would_set_avatar": p.ID, "mime": ref.MIME, "sha256": ref.SHA256})
}
p, err = r.store.SetAvatar(c.Person, c.File, time.Now())
if err != nil {
return err
}
return r.print(p.Avatar)
}
type PersonAvatarShowCmd struct {
Person string `arg:"" help:"Person query"`
Path bool `name:"path" help:"Print absolute avatar path only"`
}
func (c *PersonAvatarShowCmd) Run(r *Runtime) error {
p, err := r.store.FindPerson(c.Person)
if err != nil {
return err
}
if p.Avatar.Path == "" {
return fmt.Errorf("%s has no avatar", p.Name)
}
if c.Path {
path, err := avatar.AbsolutePath(p)
if err != nil {
return err
}
_, err = fmt.Fprintln(r.stdout, path)
return err
}
return r.print(p.Avatar)
}
type PersonAvatarClearCmd struct {
Person string `arg:"" help:"Person query"`
}
func (c *PersonAvatarClearCmd) Run(r *Runtime) error {
p, err := r.store.FindPerson(c.Person)
if err != nil {
return err
}
if r.root.DryRun {
return r.print(map[string]any{"would_clear_avatar": p.ID})
}
p, err = r.store.ClearAvatar(c.Person, time.Now())
if err != nil {
return err
}
return r.printPerson(p)
}
type NoteCmd struct {
Add NoteAddCmd `cmd:"" help:"Add a note"`
List NoteListCmd `cmd:"" help:"List notes"`
}
type NoteAddCmd struct {
Person string `arg:"" help:"Person query"`
Kind string `name:"kind" required:"" help:"Note kind"`
Source string `name:"source" required:"" help:"Note source"`
Text string `name:"text" help:"Note body"`
OccurredAt string `name:"occurred-at" help:"Occurrence time"`
Topic []string `name:"topic" help:"Topic"`
}
func (c *NoteAddCmd) Run(r *Runtime) error {
if c.Text == "" {
return usageErr{errors.New("--text is required")}
}
occurredAt, err := parseOptionalTime(c.OccurredAt)
if err != nil {
return err
}
n := markdown.NewNote("", c.Kind, c.Source, c.Text, occurredAt, time.Now(), c.Topic)
if r.root.DryRun {
return r.print(n)
}
n, err = r.store.AddNote(c.Person, n)
if err != nil {
return err
}
return r.print(n)
}
type NoteListCmd struct {
Person string `arg:"" help:"Person query"`
}
func (c *NoteListCmd) Run(r *Runtime) error {
notes, err := r.store.Notes(c.Person)
if err != nil {
return err
}
return r.print(notes)
}
type TimelineCmd struct {
Person string `arg:"" help:"Person query"`
}
func (c *TimelineCmd) Run(r *Runtime) error {
notes, err := r.store.Notes(c.Person)
if err != nil {
return err
}
return r.printTimeline(notes)
}
type SearchCmd struct {
Query string `arg:"" help:"Search query"`
}
func (c *SearchCmd) Run(r *Runtime) error {
hits, err := r.store.Search(c.Query)
if err != nil {
return err
}
return r.print(hits)
}
type ImportCmd struct {
Apple ImportAppleCmd `cmd:"" help:"Import Apple Contacts into local markdown"`
Birdclaw ImportBirdclawCmd `cmd:"" help:"Import X/Twitter DM contacts from local birdclaw archive"`
Google ImportGoogleCmd `cmd:"" help:"Import Google Contacts into local markdown"`
Discrawl ImportDiscrawlCmd `cmd:"" help:"Import Discord DM contacts from local discrawl archive"`
}
type ImportAppleCmd struct {
Input string `name:"input" help:"JSON/NDJSON contact file instead of macOS Contacts"`
Avatars bool `name:"avatars" help:"Import local avatar thumbnails"`
}
func (c *ImportAppleCmd) Run(r *Runtime) error {
var contacts []apple.Contact
var err error
if c.Input != "" {
contacts, err = apple.ReadFile(c.Input)
} else {
contacts, err = apple.ReadSystem(r.ctx)
}
if err != nil {
return err
}
changes, err := r.store.ImportContacts("apple", apple.ToSourceContacts(contacts, c.Avatars), r.root.DryRun, time.Now())
if err != nil {
return err
}
return r.print(changes)
}
type ImportGoogleCmd struct {
Account string `name:"account" help:"Google account email"`
Avatars bool `name:"avatars" help:"Fetch Google contact avatar bytes through gog raw photo URLs"`
}
func (c *ImportGoogleCmd) Run(r *Runtime) error {
account := c.Account
if account == "" {
account = r.cfg.Google.DefaultAccount
}
contacts, err := (google.GogAdapter{}).ListContactsWithOptions(r.ctx, account, google.Options{IncludeAvatars: c.Avatars})
if err != nil {
return err
}
changes, err := r.store.ImportContacts("google", contacts, r.root.DryRun, time.Now())
if err != nil {
return err
}
return r.print(changes)
}
type ImportDiscrawlCmd struct {
DBPath string `name:"db" help:"discrawl SQLite database path" default:"~/.discrawl/discrawl.db"`
MinMessages int `name:"min-messages" help:"Import DMs with more than this many messages" default:"4"`
}
type ImportBirdclawCmd struct {
DBPath string `name:"db" help:"birdclaw SQLite database path" default:"~/.birdclaw/birdclaw.sqlite"`
MinMessages int `name:"min-messages" help:"Import DMs with more than this many messages" default:"4"`
}
func (c *ImportBirdclawCmd) Run(r *Runtime) error {
contacts, err := (birdclaw.Adapter{DBPath: c.DBPath}).ListDMContacts(r.ctx, c.MinMessages)
if err != nil {
return err
}
changes, err := r.store.ImportContacts("x", contacts, r.root.DryRun, time.Now())
if err != nil {
return err
}
return r.print(changes)
}
func (c *ImportDiscrawlCmd) Run(r *Runtime) error {
contacts, err := (discrawl.Adapter{DBPath: c.DBPath}).ListDMContacts(r.ctx, c.MinMessages)
if err != nil {
return err
}
changes, err := r.store.ImportContacts("discord", contacts, r.root.DryRun, time.Now())
if err != nil {
return err
}
return r.print(changes)
}
type SyncCmd struct {
Apple SyncAppleCmd `cmd:"" help:"Preview Apple Contacts sync"`
Google SyncGoogleCmd `cmd:"" help:"Preview Google Contacts sync"`
}
type SyncAppleCmd struct{}
func (c *SyncAppleCmd) Run(r *Runtime) error {
return r.print(map[string]any{"dry_run": true, "status": "remote writes not implemented yet; use import apple for local markdown projection"})
}
type SyncGoogleCmd struct {
Account string `name:"account" help:"Google account email"`
}
func (c *SyncGoogleCmd) Run(r *Runtime) error {
return r.print(map[string]any{"dry_run": true, "account": firstNonEmpty(c.Account, r.cfg.Google.DefaultAccount), "status": "remote writes not implemented yet; use import google for local markdown projection"})
}
type ExportCmd struct {
VCard ExportVCardCmd `cmd:"" name:"vcard" help:"Export vCard"`
}
type ExportVCardCmd struct {
Person string `name:"person" help:"Person query"`
All bool `name:"all" help:"Export all people"`
IncludeAvatars bool `name:"include-avatars" help:"Include avatar PHOTO fields"`
Out string `name:"out" short:"o" required:"" help:"Output .vcf path, or - for stdout"`
}
func (c *ExportVCardCmd) Run(r *Runtime) error {
var people []model.Person
switch {
case c.All:
var err error
people, err = r.store.People()
if err != nil {
return err
}
case c.Person != "":
p, err := r.store.FindPerson(c.Person)
if err != nil {
return err
}
people = []model.Person{p}
default:
return usageErr{errors.New("provide --person or --all")}
}
if c.Out == "-" {
return vcard.WriteWithOptions(r.stdout, people, vcard.Options{IncludeAvatars: c.IncludeAvatars})
}
f, err := os.Create(c.Out)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
if err := vcard.WriteWithOptions(f, people, vcard.Options{IncludeAvatars: c.IncludeAvatars}); err != nil {
return err
}
if err := f.Close(); err != nil {
return err
}
return r.print(map[string]any{"exported": len(people), "out": c.Out})
}
type GitCmd struct {
Status GitStatusCmd `cmd:"" default:"1" help:"Show git status"`
Pull GitPullCmd `cmd:"" help:"Pull data repo"`
Push GitPushCmd `cmd:"" help:"Push data repo"`
Commit GitCommitCmd `cmd:"" help:"Commit data repo changes"`
}
type GitStatusCmd struct{}
func (c *GitStatusCmd) Run(r *Runtime) error {
// #nosec G204 -- git is fixed and repo path is passed as a plain argument.
cmd := exec.CommandContext(r.ctx, "git", "-C", r.repo.Path, "status", "--short", "--branch")
cmd.Stdout = r.stdout
cmd.Stderr = r.stderr
return cmd.Run()
}
type GitPullCmd struct{}
func (c *GitPullCmd) Run(r *Runtime) error {
return r.repo.Pull(r.ctx)
}
type GitPushCmd struct{}
func (c *GitPushCmd) Run(r *Runtime) error {
return r.repo.Push(r.ctx)
}
type GitCommitCmd struct {
Message string `name:"message" short:"m" help:"Commit message" default:"sync: update clawdex contacts"`
}
func (c *GitCommitCmd) Run(r *Runtime) error {
committed, err := r.repo.Commit(r.ctx, c.Message)
if err != nil {
return err
}
return r.print(map[string]any{"committed": committed})
}
type DoctorCmd struct {
Repair bool `name:"repair" help:"Repair damaged markdown frontmatter"`
}
func (c *DoctorCmd) Run(r *Runtime) error {
store := r.store
if c.Repair {
store.Repo.Config.Repair.AutoRepair = false
}
people, err := store.People()
if err != nil {
return err
}
dirty, _ := r.repo.Dirty(r.ctx)
result := map[string]any{
"config_path": r.configPath,
"repo_path": r.repo.Path,
"remote": r.cfg.Git.Remote,
"people": len(people),
"git_dirty": dirty,
}
avatarProblems := 0
for _, p := range people {
avatarProblems += len(avatar.Validate(p))
}
if avatarProblems > 0 {
result["avatar_problems"] = avatarProblems
}
if c.Repair {
var repaired int
var avatarRepaired int
for _, p := range people {
loaded, report, err := markdown.ReadPerson(p.Path)
if err != nil {
return err
}
if report.Needed {
repaired++
if !r.root.DryRun {
if err := markdown.RepairPerson(p.Path, r.repo.RepairDir(), loaded, report, r.cfg.Repair.BackupBeforeRepair); err != nil {
return err
}
}
}
if len(avatar.Validate(loaded)) > 0 {
avatarRepaired++
if !r.root.DryRun {
_, _, err := store.RepairAvatarMetadata(loaded, time.Now())
if err != nil {
return err
}
}
}
}
result["repaired"] = repaired
result["avatar_repaired"] = avatarRepaired
result["dry_run"] = r.root.DryRun
}
return r.print(result)
}
func (r *Runtime) print(value any) error {
if r.root.JSON {
enc := json.NewEncoder(r.stdout)
enc.SetIndent("", " ")
return enc.Encode(value)
}
switch v := value.(type) {
case map[string]any:
keys := make([]string, 0, len(v))
for key := range v {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if _, err := fmt.Fprintf(r.stdout, "%s: %v\n", key, v[key]); err != nil {
return err
}
}
return nil
case model.Note:
_, err := fmt.Fprintf(r.stdout, "%s\t%s\t%s\t%s\n", v.ID, v.Kind, v.Source, v.Path)
return err
case []model.Note:
return r.printTimeline(v)
case []model.SearchHit:
return r.printHits(v)
case []model.ImportChange:
for _, change := range v {
if _, err := fmt.Fprintf(r.stdout, "%s\t%s\t%s\n", change.Action, change.Name, change.PersonID); err != nil {
return err
}
}
return nil
default:
enc := json.NewEncoder(r.stdout)
enc.SetIndent("", " ")
return enc.Encode(value)
}
}
func (r *Runtime) printPerson(p model.Person) error {
if r.root.JSON {
return r.print(p)
}
if r.root.Plain {
_, err := fmt.Fprintf(r.stdout, "%s\t%s\t%s\n", p.ID, p.Name, p.Path)
return err
}
if _, err := fmt.Fprintf(r.stdout, "id: %s\nname: %s\npath: %s\n", p.ID, p.Name, p.Path); err != nil {
return err
}
for _, email := range p.Emails {
if _, err := fmt.Fprintf(r.stdout, "email: %s\n", email.Value); err != nil {
return err
}
}
for _, phone := range p.Phones {
if _, err := fmt.Fprintf(r.stdout, "phone: %s\n", phone.Value); err != nil {
return err
}
}
return nil
}
func (r *Runtime) printPeople(people []model.Person) error {
if r.root.JSON {
return r.print(people)
}
for _, p := range people {
if r.root.Plain {
if _, err := fmt.Fprintf(r.stdout, "%s\t%s\t%s\n", p.ID, p.Name, p.Path); err != nil {
return err
}
} else {
if _, err := fmt.Fprintf(r.stdout, "%s\t%s\t%s\n", p.ID, p.Name, firstEmail(p)); err != nil {
return err
}
}
}
return nil
}
func (r *Runtime) printTimeline(notes []model.Note) error {
if r.root.JSON {
return r.print(notes)
}
for _, n := range notes {
if _, err := fmt.Fprintf(r.stdout, "%s\t%s\t%s\t%s\n", n.OccurredAt.Format(time.RFC3339), n.Kind, n.Source, strings.ReplaceAll(n.Body, "\n", " ")); err != nil {
return err
}
}
return nil
}
func (r *Runtime) printHits(hits []model.SearchHit) error {
for _, hit := range hits {
if r.root.Plain {
if _, err := fmt.Fprintf(r.stdout, "%s\t%s\t%s\t%s\n", hit.Kind, hit.ID, hit.Name, hit.Path); err != nil {
return err
}
} else {
if _, err := fmt.Fprintf(r.stdout, "%s\t%s\t%s\t%s\n", hit.Kind, hit.Name, hit.Snippet, hit.Path); err != nil {
return err
}
}
}
return nil
}
func firstEmail(p model.Person) string {
if len(p.Emails) == 0 {
return ""
}
return p.Emails[0].Value
}
func parseOptionalTime(value string) (time.Time, error) {
if strings.TrimSpace(value) == "" {
return time.Time{}, nil
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04", "2006-01-02"} {
t, err := time.Parse(layout, value)
if err == nil {
return t, nil
}
}
return time.Time{}, usageErr{fmt.Errorf("invalid time %q", value)}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}