11package cli
22
33import (
4+ "bytes"
45 "context"
56 "encoding/json"
67 "errors"
78 "fmt"
89 "io"
910 "os"
1011 "os/exec"
12+ "path/filepath"
1113 "sort"
1214 "strings"
1315 "time"
@@ -16,13 +18,15 @@ import (
1618 "github.com/openclaw/clawdex/internal/apple"
1719 "github.com/openclaw/clawdex/internal/avatar"
1820 "github.com/openclaw/clawdex/internal/birdclaw"
21+ "github.com/openclaw/clawdex/internal/contactexport"
1922 "github.com/openclaw/clawdex/internal/discrawl"
2023 "github.com/openclaw/clawdex/internal/google"
2124 "github.com/openclaw/clawdex/internal/index"
2225 "github.com/openclaw/clawdex/internal/markdown"
2326 "github.com/openclaw/clawdex/internal/model"
2427 "github.com/openclaw/clawdex/internal/repo"
2528 "github.com/openclaw/clawdex/internal/vcard"
29+ "github.com/openclaw/crawlkit/control"
2630)
2731
2832var Version = "dev"
@@ -414,10 +418,116 @@ func (c *SearchCmd) Run(r *Runtime) error {
414418type ImportCmd struct {
415419 Apple ImportAppleCmd `cmd:"" help:"Import Apple Contacts into local markdown"`
416420 Birdclaw ImportBirdclawCmd `cmd:"" help:"Import X/Twitter DM contacts from local birdclaw archive"`
421+ Contacts ImportContactsCmd `cmd:"" help:"Import contacts from a source crawler"`
417422 Google ImportGoogleCmd `cmd:"" help:"Import Google Contacts into local markdown"`
418423 Discrawl ImportDiscrawlCmd `cmd:"" help:"Import Discord DM contacts from local discrawl archive"`
419424}
420425
426+ type ImportContactsCmd struct {
427+ From string `name:"from" help:"Crawler binary to import contacts from" required:""`
428+ }
429+
430+ func (c * ImportContactsCmd ) Run (r * Runtime ) error {
431+ source , contacts , err := readCrawlerContacts (r .ctx , c .From )
432+ if err != nil {
433+ return err
434+ }
435+ changes , err := r .store .ImportContacts (source , contacts , r .root .DryRun , time .Now ())
436+ if err != nil {
437+ return err
438+ }
439+ return r .print (changes )
440+ }
441+
442+ func readCrawlerContacts (ctx context.Context , binary string ) (string , []model.SourceContact , error ) {
443+ manifest , err := readCrawlerManifest (ctx , binary )
444+ if err != nil {
445+ return "" , nil , err
446+ }
447+ command , ok := manifest .Commands ["contact-export" ]
448+ if ! ok {
449+ return "" , nil , fmt .Errorf ("%s metadata does not advertise contact-export" , binary )
450+ }
451+ if ! command .JSON {
452+ return "" , nil , fmt .Errorf ("%s contact-export must advertise json output" , binary )
453+ }
454+ if command .Mutates {
455+ return "" , nil , fmt .Errorf ("%s contact-export must be read-only" , binary )
456+ }
457+ if len (command .Argv ) == 0 {
458+ return "" , nil , fmt .Errorf ("%s contact-export argv is empty" , binary )
459+ }
460+ argv , err := contactExportArgv (binary , command .Argv )
461+ if err != nil {
462+ return "" , nil , err
463+ }
464+ cmd := exec .CommandContext (ctx , argv [0 ], argv [1 :]... ) // #nosec G204 -- argv comes from the local crawler manifest and is executed without a shell.
465+ var stderr bytes.Buffer
466+ cmd .Stderr = & stderr
467+ data , err := cmd .Output ()
468+ if err != nil {
469+ msg := strings .TrimSpace (stderr .String ())
470+ if msg != "" {
471+ return "" , nil , fmt .Errorf ("%s contact-export failed: %w: %s" , binary , err , msg )
472+ }
473+ return "" , nil , fmt .Errorf ("%s contact-export failed: %w" , binary , err )
474+ }
475+ export , err := contactexport .Decode (bytes .NewReader (data ))
476+ if err != nil {
477+ return "" , nil , fmt .Errorf ("%s contact-export decode failed: %w" , binary , err )
478+ }
479+ source := strings .TrimSpace (manifest .ID )
480+ if source == "" {
481+ source = filepath .Base (binary )
482+ }
483+ return source , sourceContactsFromExport (source , export ), nil
484+ }
485+
486+ func contactExportArgv (binary string , advertised []string ) ([]string , error ) {
487+ if len (advertised ) == 0 {
488+ return nil , fmt .Errorf ("%s contact-export argv is empty" , binary )
489+ }
490+ requestedName := filepath .Base (binary )
491+ advertisedName := filepath .Base (advertised [0 ])
492+ if requestedName != "" && advertisedName != "" && requestedName != advertisedName {
493+ return nil , fmt .Errorf ("%s contact-export argv starts with %q, want %q" , binary , advertised [0 ], requestedName )
494+ }
495+ argv := append ([]string (nil ), advertised ... )
496+ argv [0 ] = binary
497+ return argv , nil
498+ }
499+
500+ func readCrawlerManifest (ctx context.Context , binary string ) (control.Manifest , error ) {
501+ cmd := exec .CommandContext (ctx , binary , "--json" , "metadata" ) // #nosec G204 -- binary is an explicit local crawler command.
502+ var stderr bytes.Buffer
503+ cmd .Stderr = & stderr
504+ data , err := cmd .Output ()
505+ if err != nil {
506+ msg := strings .TrimSpace (stderr .String ())
507+ if msg != "" {
508+ return control.Manifest {}, fmt .Errorf ("%s metadata failed: %w: %s" , binary , err , msg )
509+ }
510+ return control.Manifest {}, fmt .Errorf ("%s metadata failed: %w" , binary , err )
511+ }
512+ var manifest control.Manifest
513+ if err := json .Unmarshal (data , & manifest ); err != nil {
514+ return control.Manifest {}, fmt .Errorf ("%s metadata decode failed: %w" , binary , err )
515+ }
516+ return manifest , nil
517+ }
518+
519+ func sourceContactsFromExport (source string , export contactexport.ContactExport ) []model.SourceContact {
520+ contacts := make ([]model.SourceContact , 0 , len (export .Contacts ))
521+ for _ , c := range export .Contacts {
522+ contact := model.SourceContact {Source : source , Name : c .DisplayName }
523+ for i , phone := range c .PhoneNumbers {
524+ contact .Phones = append (contact .Phones , model.ContactValue {Value : phone , Source : source , Primary : i == 0 })
525+ }
526+ contacts = append (contacts , contact )
527+ }
528+ return contacts
529+ }
530+
421531type ImportAppleCmd struct {
422532 Input string `name:"input" help:"JSON/NDJSON contact file instead of macOS Contacts"`
423533 Avatars bool `name:"avatars" help:"Import local avatar thumbnails"`
0 commit comments