11package main
22
33import (
4+ "bufio"
45 "encoding/json"
56 "fmt"
67 "os"
78 "os/exec"
89 "path/filepath"
10+ "regexp"
911 "sort"
1012 "strconv"
1113 "strings"
@@ -23,6 +25,8 @@ import (
2325
2426var composeUpDetach bool
2527
28+ var envVarPattern = regexp .MustCompile (`\$\{([A-Z_][A-Z0-9_]*)\}` )
29+
2630var (
2731 extractServiceSkillFromImage = runtime .ExtractServiceSkill
2832 writeRuntimeFile = os .WriteFile
3135 generateClawDockerfile = build .Generate
3236 buildGeneratedImage = build .BuildFromGenerated
3337 dockerBuildTaggedImage = dockerBuildTaggedImageDefault
38+ findClawdapusRepoRoot = findRepoRoot
39+ runInfraDockerCommand = runInfraDockerCommandDefault
3440)
3541
3642var composeUpCmd = & cobra.Command {
@@ -69,9 +75,12 @@ func runComposeUp(podFile string) error {
6975 if err != nil {
7076 return fmt .Errorf ("resolve pod directory: %w" , err )
7177 }
78+ if err := resolveRuntimePlaceholders (podDir , p ); err != nil {
79+ return fmt .Errorf ("resolve x-claw runtime placeholders: %w" , err )
80+ }
7281 runtimeDir := filepath .Join (podDir , ".claw-runtime" )
73- if err := os . MkdirAll (runtimeDir , 0700 ); err != nil {
74- return fmt .Errorf ("create runtime dir: %w" , err )
82+ if err := resetRuntimeDir (runtimeDir ); err != nil {
83+ return fmt .Errorf ("reset runtime dir: %w" , err )
7584 }
7685
7786 results := make (map [string ]* driver.MaterializeResult )
@@ -525,6 +534,139 @@ func runComposeUp(podFile string) error {
525534 return nil
526535}
527536
537+ func resetRuntimeDir (path string ) error {
538+ if err := os .RemoveAll (path ); err != nil {
539+ return err
540+ }
541+ return os .MkdirAll (path , 0o700 )
542+ }
543+
544+ func resolveRuntimePlaceholders (podDir string , p * pod.Pod ) error {
545+ env , err := loadRuntimeEnv (podDir )
546+ if err != nil {
547+ return err
548+ }
549+ expand := func (value string ) string {
550+ return envVarPattern .ReplaceAllStringFunc (value , func (match string ) string {
551+ key := match [2 : len (match )- 1 ]
552+ if v , ok := env [key ]; ok {
553+ return v
554+ }
555+ return match
556+ })
557+ }
558+
559+ for _ , svc := range p .Services {
560+ if svc == nil || svc .Claw == nil {
561+ continue
562+ }
563+ svc .Claw .Agent = expand (svc .Claw .Agent )
564+ svc .Claw .Persona = expand (svc .Claw .Persona )
565+ for i , value := range svc .Claw .Cllama {
566+ svc .Claw .Cllama [i ] = expand (value )
567+ }
568+ for key , value := range svc .Claw .CllamaEnv {
569+ svc .Claw .CllamaEnv [key ] = expand (value )
570+ }
571+ for i , value := range svc .Claw .Skills {
572+ svc .Claw .Skills [i ] = expand (value )
573+ }
574+ for i := range svc .Claw .Invoke {
575+ svc .Claw .Invoke [i ].Schedule = expand (svc .Claw .Invoke [i ].Schedule )
576+ svc .Claw .Invoke [i ].Message = expand (svc .Claw .Invoke [i ].Message )
577+ svc .Claw .Invoke [i ].Name = expand (svc .Claw .Invoke [i ].Name )
578+ svc .Claw .Invoke [i ].To = expand (svc .Claw .Invoke [i ].To )
579+ }
580+ for _ , handle := range svc .Claw .Handles {
581+ if handle == nil {
582+ continue
583+ }
584+ handle .ID = expand (handle .ID )
585+ handle .Username = expand (handle .Username )
586+ for gi := range handle .Guilds {
587+ handle .Guilds [gi ].ID = expand (handle .Guilds [gi ].ID )
588+ handle .Guilds [gi ].Name = expand (handle .Guilds [gi ].Name )
589+ for ci := range handle .Guilds [gi ].Channels {
590+ handle .Guilds [gi ].Channels [ci ].ID = expand (handle .Guilds [gi ].Channels [ci ].ID )
591+ handle .Guilds [gi ].Channels [ci ].Name = expand (handle .Guilds [gi ].Channels [ci ].Name )
592+ }
593+ }
594+ }
595+ for i := range svc .Claw .Surfaces {
596+ svc .Claw .Surfaces [i ].Target = expand (svc .Claw .Surfaces [i ].Target )
597+ svc .Claw .Surfaces [i ].AccessMode = expand (svc .Claw .Surfaces [i ].AccessMode )
598+ if cc := svc .Claw .Surfaces [i ].ChannelConfig ; cc != nil {
599+ cc .DM .Policy = expand (cc .DM .Policy )
600+ for j , value := range cc .DM .AllowFrom {
601+ cc .DM .AllowFrom [j ] = expand (value )
602+ }
603+ expandedGuilds := make (map [string ]driver.ChannelGuildConfig , len (cc .Guilds ))
604+ for guildID , guildCfg := range cc .Guilds {
605+ guildCfg .Policy = expand (guildCfg .Policy )
606+ users := make ([]string , len (guildCfg .Users ))
607+ for j , value := range guildCfg .Users {
608+ users [j ] = expand (value )
609+ }
610+ guildCfg .Users = users
611+ expandedGuilds [expand (guildID )] = guildCfg
612+ }
613+ cc .Guilds = expandedGuilds
614+ }
615+ }
616+ }
617+ return nil
618+ }
619+
620+ func loadRuntimeEnv (podDir string ) (map [string ]string , error ) {
621+ env := make (map [string ]string )
622+ dotEnvPath := filepath .Join (podDir , ".env" )
623+ if fileEnv , err := readDotEnvFile (dotEnvPath ); err == nil {
624+ for key , value := range fileEnv {
625+ env [key ] = value
626+ }
627+ } else if ! os .IsNotExist (err ) {
628+ return nil , err
629+ }
630+ for _ , entry := range os .Environ () {
631+ eq := strings .IndexByte (entry , '=' )
632+ if eq < 0 {
633+ continue
634+ }
635+ env [entry [:eq ]] = entry [eq + 1 :]
636+ }
637+ return env , nil
638+ }
639+
640+ func readDotEnvFile (path string ) (map [string ]string , error ) {
641+ f , err := os .Open (path )
642+ if err != nil {
643+ return nil , err
644+ }
645+ defer f .Close ()
646+
647+ out := make (map [string ]string )
648+ scanner := bufio .NewScanner (f )
649+ for scanner .Scan () {
650+ line := strings .TrimSpace (scanner .Text ())
651+ if line == "" || strings .HasPrefix (line , "#" ) {
652+ continue
653+ }
654+ line = strings .TrimPrefix (line , "export " )
655+ eq := strings .IndexByte (line , '=' )
656+ if eq < 0 {
657+ continue
658+ }
659+ key := strings .TrimSpace (line [:eq ])
660+ value := strings .TrimSpace (line [eq + 1 :])
661+ value = strings .Trim (value , `"'` )
662+ out [key ] = value
663+ }
664+ if err := scanner .Err (); err != nil {
665+ return nil , err
666+ }
667+ return out , nil
668+ }
669+
528670func mergeResolvedSkills (imageSkills , podSkills []driver.ResolvedSkill ) []driver.ResolvedSkill {
529671 merged := make ([]driver.ResolvedSkill , 0 , len (imageSkills )+ len (podSkills ))
530672 byName := make (map [string ]int , len (imageSkills ))
@@ -1304,23 +1446,25 @@ func ensureInfraImages(cllamaEnabled bool, proxies []pod.CllamaProxyConfig, dash
13041446}
13051447
13061448// ensureImage builds a Docker image if it doesn't exist locally.
1307- // It tries: local build from repo source, then git URL build, then errors.
1449+ // It tries: local image, docker pull, local build from repo source, git URL build,
1450+ // then errors with explicit manual-build guidance.
13081451func ensureImage (imageRef , name , dockerfilePath , contextDir string ) error {
1309- if build . ImageExistsLocally (imageRef ) {
1452+ if imageExistsLocally (imageRef ) {
13101453 return nil
13111454 }
13121455
13131456 fmt .Printf ("[claw] building %s image (first time only)\n " , name )
13141457
1315- repoRoot , found := findRepoRoot ()
1458+ if err := runInfraDockerCommand ("pull" , imageRef ); err == nil {
1459+ return nil
1460+ }
1461+
1462+ repoRoot , found := findClawdapusRepoRoot ()
13161463 if found {
13171464 df := filepath .Join (repoRoot , dockerfilePath )
13181465 ctx := filepath .Join (repoRoot , contextDir )
13191466 if _ , err := os .Stat (df ); err == nil {
1320- cmd := exec .Command ("docker" , "build" , "-t" , imageRef , "-f" , df , ctx )
1321- cmd .Stdout = os .Stdout
1322- cmd .Stderr = os .Stderr
1323- if err := cmd .Run (); err != nil {
1467+ if err := runInfraDockerCommand ("build" , "-t" , imageRef , "-f" , df , ctx ); err != nil {
13241468 return fmt .Errorf ("build %s image from local source: %w" , name , err )
13251469 }
13261470 return nil
@@ -1329,15 +1473,19 @@ func ensureImage(imageRef, name, dockerfilePath, contextDir string) error {
13291473
13301474 // Fallback: build from git URL.
13311475 gitURL := fmt .Sprintf ("https://github.com/mostlydev/clawdapus.git#master:%s" , contextDir )
1332- cmd := exec .Command ("docker" , "build" , "-t" , imageRef , gitURL )
1333- cmd .Stdout = os .Stdout
1334- cmd .Stderr = os .Stderr
1335- if err := cmd .Run (); err != nil {
1476+ if err := runInfraDockerCommand ("build" , "-t" , imageRef , gitURL ); err != nil {
13361477 return fmt .Errorf ("could not build %s image; run 'docker build -t %s -f %s %s' from the repo root" , name , imageRef , dockerfilePath , contextDir )
13371478 }
13381479 return nil
13391480}
13401481
1482+ func runInfraDockerCommandDefault (args ... string ) error {
1483+ cmd := exec .Command ("docker" , args ... )
1484+ cmd .Stdout = os .Stdout
1485+ cmd .Stderr = os .Stderr
1486+ return cmd .Run ()
1487+ }
1488+
13411489// findRepoRoot walks up from cwd looking for go.mod with the clawdapus module.
13421490func findRepoRoot () (string , bool ) {
13431491 dir , err := os .Getwd ()
0 commit comments