@@ -89,36 +89,43 @@ def wsl_to_windows(path: str) -> str:
8989 raise RuntimeError (f"Failed to convert path with wslpath: { path } " ) from e
9090
9191
92- def resolve_template_dir () -> str :
93- """Return the user template directory (DEVCODE_TEMPLATE_DIR or XDG default)."""
94- override = os .environ .get ("DEVCODE_TEMPLATE_DIR" )
95- if override :
96- return override
92+ def resolve_template_search_path () -> list [str ]:
93+ """Return ordered list of template search directories from DEVCODE_TEMPLATE_PATH."""
94+ new_var = os .environ .get ("DEVCODE_TEMPLATE_PATH" )
9795 xdg = os .environ .get ("XDG_DATA_HOME" , os .path .join (os .path .expanduser ("~" ), ".local" , "share" ))
98- return os .path .join (xdg , "dev-code" , "templates" )
96+ default = os .path .join (xdg , "dev-code" , "templates" )
97+ if not new_var :
98+ return [default ]
99+ dirs = [d for d in new_var .split (":" ) if d ]
100+ return dirs if dirs else [default ]
101+
102+
103+ def _write_template_dir () -> str :
104+ """Return the first (canonical write) directory from the template search path."""
105+ return resolve_template_search_path ()[0 ]
99106
100107
101108def _list_template_names () -> list :
102- """Return sorted deduplicated list of all template names (builtins + user) ."""
103- names = set ()
104- try :
105- module_dir = os . path . dirname ( os . path . abspath ( __file__ ))
106- builtin_base = os .path .join ( module_dir , "dev_code_templates" )
107- if os . path . isdir ( builtin_base ):
108- for name in os . listdir ( builtin_base ):
109- if os . path . isdir ( os . path . join ( builtin_base , name )) :
110- names . add ( name )
111- except Exception :
112- pass
113- try :
114- user_dir = resolve_template_dir ()
115- if os . path . isdir ( user_dir ):
116- for name in os . listdir ( user_dir ):
117- if os . path . isdir ( os . path . join ( user_dir , name )) :
118- names . add ( name )
119- except Exception :
120- pass
121- return sorted (names )
109+ """Return sorted deduplicated list of valid user template names across all search dirs ."""
110+ seen = []
111+ seen_set = set ()
112+ for search_dir in resolve_template_search_path ():
113+ if not os .path .isdir ( search_dir ):
114+ logger . debug ( "template search dir not found, skipping: %s" , search_dir )
115+ continue
116+ try :
117+ for name in sorted ( os . listdir ( search_dir )):
118+ if name in seen_set :
119+ continue
120+ candidate = os . path . join ( search_dir , name )
121+ if _is_valid_template ( candidate ):
122+ seen . append ( name )
123+ seen_set . add ( name )
124+ else :
125+ logger . debug ( "skipping invalid template: %s" , candidate )
126+ except Exception :
127+ pass
128+ return sorted (seen )
122129
123130
124131def get_builtin_template_path (name : str ) -> str | None :
@@ -137,6 +144,22 @@ def get_builtin_template_path(name: str) -> str | None:
137144 return None
138145
139146
147+ def _is_valid_template (template_root : str ) -> bool :
148+ """Return True if template_root contains .devcontainer/devcontainer.json."""
149+ return os .path .isfile (
150+ os .path .join (template_root , ".devcontainer" , "devcontainer.json" )
151+ )
152+
153+
154+ def _find_template_in_search_path (name : str ) -> str | None :
155+ """Search all template dirs for name; return template root path or None."""
156+ for search_dir in resolve_template_search_path ():
157+ candidate = os .path .join (search_dir , name )
158+ if _is_valid_template (candidate ):
159+ return candidate
160+ return None
161+
162+
140163def resolve_template (name : str ) -> str :
141164 """Return absolute path to template's devcontainer.json. Exits on failure."""
142165 # 1. Explicit path prefix — skip template lookup entirely
@@ -153,27 +176,17 @@ def resolve_template(name: str) -> str:
153176 sys .exit (1 )
154177 logger .error ("path not found: %s" , name )
155178 sys .exit (1 )
156- # 2. Try template lookup (user templates, then builtins)
157- user_path = os .path .join (resolve_template_dir (), name , ".devcontainer" , "devcontainer.json" )
158- if os .path .exists (user_path ):
179+ # 2. Try template lookup across all search dirs
180+ template_root = _find_template_in_search_path (name )
181+ if template_root :
182+ config = os .path .join (template_root , ".devcontainer" , "devcontainer.json" )
159183 if _resolve_as_path (name ):
160184 logger .warning (
161185 "'%s' matches both a template and a local path — using template. "
162186 "Use './%s' to open as path instead." ,
163187 name , name ,
164188 )
165- return user_path
166- builtin = get_builtin_template_path (name )
167- if builtin :
168- path = os .path .join (builtin , ".devcontainer" , "devcontainer.json" )
169- if os .path .exists (path ):
170- if _resolve_as_path (name ):
171- logger .warning (
172- "'%s' matches both a template and a local path — using template. "
173- "Use './%s' to open as path instead." ,
174- name , name ,
175- )
176- return path
189+ return config
177190 # 3. No template — try path fallback
178191 path_result = _resolve_as_path (name )
179192 if path_result :
@@ -548,19 +561,19 @@ def _cmd_open_dry_run(config_file: str, project_path: str, uri: str) -> None:
548561
549562def cmd_new (args ) -> None :
550563 """Create a new template by copying a base template."""
551- template_dir = resolve_template_dir ()
552- dest = os .path .join (template_dir , args .name )
564+ write_dir = _write_template_dir ()
565+ dest = os .path .join (write_dir , args .name )
553566
554567 # Step 1: fail if name already exists
555568 if os .path .exists (dest ):
556569 logger .error ("template '%s' already exists: %s" , args .name , dest )
557570 sys .exit (1 )
558571
559- # Step 2: resolve base (check before creating dirs)
572+ # Step 2: resolve base — search all dirs, then builtins
560573 base_name = args .base or "dev-code"
561- base_user = os . path . join ( template_dir , base_name )
562- if os . path . isdir ( base_user ) :
563- base_src = base_user
574+ base_root = _find_template_in_search_path ( base_name )
575+ if base_root :
576+ base_src = base_root
564577 else :
565578 builtin = get_builtin_template_path (base_name )
566579 if builtin :
@@ -569,11 +582,11 @@ def cmd_new(args) -> None:
569582 logger .error ("base template not found: %s" , base_name )
570583 sys .exit (1 )
571584
572- # Step 3-4: create template dir
585+ # Step 3-4: create write dir
573586 try :
574- os .makedirs (template_dir , exist_ok = True )
587+ os .makedirs (write_dir , exist_ok = True )
575588 except OSError as e :
576- logger .error ("cannot create template dir %s: %s" , template_dir , e )
589+ logger .error ("cannot create template dir %s: %s" , write_dir , e )
577590 sys .exit (1 )
578591
579592 # Step 5: copy
@@ -593,28 +606,22 @@ def cmd_new(args) -> None:
593606
594607
595608def cmd_edit (args ) -> None :
596- """Open a template directory for editing using the built-in dev-code devcontainer."""
597- template_dir = resolve_template_dir ()
598-
599- if args .template is None :
600- if not os .path .isdir (template_dir ):
601- logger .error ("template dir not found: %s — run 'devcode init' first" , template_dir )
602- sys .exit (1 )
603- project_path = template_dir
604- else :
605- project_path = os .path .join (template_dir , args .template )
606- if not os .path .isdir (project_path ):
609+ """Open a template directory directly in VS Code for editing."""
610+ if args .template is not None :
611+ template_root = _find_template_in_search_path (args .template )
612+ if template_root is None :
607613 logger .error ("template not found: %s" , args .template )
608614 sys .exit (1 )
609-
610- open_args = argparse .Namespace (
611- template = "dev-code" ,
612- projectpath = project_path ,
613- container_folder = None ,
614- timeout = 300 ,
615- dry_run = False ,
616- )
617- cmd_open (open_args )
615+ subprocess .run (["code" , template_root ])
616+ else :
617+ for search_dir in resolve_template_search_path ():
618+ if os .path .isdir (search_dir ):
619+ subprocess .run (["code" , search_dir ])
620+ return
621+ logger .error (
622+ "no template directory found — run 'devcode init' or 'devcode new <name>' to get started"
623+ )
624+ sys .exit (1 )
618625
619626
620627def cmd_init (args ) -> None :
@@ -624,17 +631,17 @@ def cmd_init(args) -> None:
624631 logger .error ("built-in template 'dev-code' not found — packaging error" )
625632 sys .exit (1 )
626633
627- template_dir = resolve_template_dir ()
628- dest = os .path .join (template_dir , "dev-code" )
634+ write_dir = _write_template_dir ()
635+ dest = os .path .join (write_dir , "dev-code" )
629636
630637 if os .path .exists (dest ):
631638 print (f"Skipped 'dev-code': already exists at { dest } " )
632639 return
633640
634641 try :
635- os .makedirs (template_dir , exist_ok = True )
642+ os .makedirs (write_dir , exist_ok = True )
636643 except OSError as e :
637- logger .error ("cannot create template dir %s: %s" , template_dir , e )
644+ logger .error ("cannot create template dir %s: %s" , write_dir , e )
638645 sys .exit (1 )
639646
640647 try :
@@ -648,55 +655,38 @@ def cmd_init(args) -> None:
648655
649656def cmd_list (args ) -> None :
650657 """List available templates."""
651- # Collect built-ins
652- builtins = []
653- module_dir = os .path .dirname (os .path .abspath (__file__ ))
654- builtin_base = os .path .join (module_dir , "dev_code_templates" )
655- if os .path .isdir (builtin_base ):
656- for name in sorted (os .listdir (builtin_base )):
657- p = os .path .join (builtin_base , name )
658- if os .path .isdir (p ):
659- builtins .append ((name , p ))
660-
661- # Collect user templates
662- template_dir = resolve_template_dir ()
663- user = []
664- if os .path .isdir (template_dir ):
665- for name in sorted (os .listdir (template_dir )):
666- p = os .path .join (template_dir , name )
667- if os .path .isdir (p ):
668- user .append ((name , p ))
658+ search_dirs = resolve_template_search_path ()
669659
670660 if not args .long :
671- for name , _ in builtins :
672- print (name )
673- for name , _ in user :
661+ names = _list_template_names ()
662+ for name in names :
674663 print (name )
675- if not user and not builtins :
676- print ("(no templates — run 'devcode init' to get started)" )
677- elif not user :
678- print ("(no user templates — run 'devcode init' to get started)" )
664+ if not names :
665+ print ("(no templates — run 'devcode init' or 'devcode new <name>' to get started)" )
679666 return
680667
681- # --long output
682- print (f"Template dir: { template_dir } " )
683- print ()
684-
685- all_names = [n for n , _ in builtins ] + [n for n , _ in user ]
686- col_w = max ((len (n ) for n in all_names ), default = 8 ) + 2
687-
688- if builtins :
689- print ("BUILT-IN" )
690- for name , path in builtins :
691- print (f" { name :<{col_w }} { path } " )
668+ # --long output: one section per search dir
669+ any_printed = False
670+ for search_dir in search_dirs :
671+ if not os .path .isdir (search_dir ):
672+ logger .debug ("template search dir not found, skipping: %s" , search_dir )
673+ continue
674+ templates = [
675+ name for name in sorted (os .listdir (search_dir ))
676+ if _is_valid_template (os .path .join (search_dir , name ))
677+ ]
678+ print (search_dir )
679+ if templates :
680+ col_w = max (len (n ) for n in templates ) + 2
681+ for name in templates :
682+ print (f" { name :<{col_w }} { os .path .join (search_dir , name )} " )
683+ else :
684+ print (" (no templates)" )
692685 print ()
686+ any_printed = True
693687
694- if user :
695- print ("USER" )
696- for name , path in user :
697- print (f" { name :<{col_w }} { path } " )
698- else :
699- print (" (no user templates — run 'devcode init' to get started)" )
688+ if not any_printed :
689+ print ("(no template directories found — run 'devcode init' or 'devcode new <name>' to get started)" )
700690
701691
702692def _template_name_from_config (config_path : str ) -> str :
0 commit comments