@@ -67,6 +67,33 @@ def _ensure_configs(cls) -> None:
6767 except ImportError :
6868 pass # Circular import during module init; retry on next access
6969
70+ @staticmethod
71+ def _hyphenate_frontmatter_refs (val : Any ) -> Any :
72+ """Recursively find any dotted references starting with speckit. and hyphenate them."""
73+ if isinstance (val , dict ):
74+ return {
75+ k : CommandRegistrar ._hyphenate_frontmatter_refs (v )
76+ for k , v in val .items ()
77+ }
78+ elif isinstance (val , list ):
79+ return [CommandRegistrar ._hyphenate_frontmatter_refs (x ) for x in val ]
80+ elif isinstance (val , str ):
81+ return re .sub (
82+ r"\bspeckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*\b" ,
83+ lambda m : m .group (0 ).replace ("." , "-" ),
84+ val ,
85+ )
86+ return val
87+
88+ @staticmethod
89+ def _hyphenate_body_refs (body : str ) -> str :
90+ """Hyphenate dotted speckit references in command body text."""
91+ return re .sub (
92+ r"\bspeckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*\b" ,
93+ lambda m : m .group (0 ).replace ("." , "-" ),
94+ body ,
95+ )
96+
7097 @staticmethod
7198 def parse_frontmatter (content : str ) -> tuple [dict , str ]:
7299 """Parse YAML frontmatter from Markdown content.
@@ -408,6 +435,9 @@ def _compute_output_name(
408435 ) -> str :
409436 """Compute the on-disk command or skill name for an agent."""
410437 if agent_config ["extension" ] != "/SKILL.md" :
438+ format_name = agent_config .get ("format_name" )
439+ if format_name :
440+ return format_name (cmd_name )
411441 return cmd_name
412442
413443 short_name = cmd_name
@@ -437,6 +467,13 @@ def _ensure_inside(candidate: Path, base: Path) -> None:
437467 if not normalized .is_relative_to (base_normalized ):
438468 raise ValueError (f"Output path { candidate !r} escapes directory { base !r} " )
439469
470+ @staticmethod
471+ def _is_safe_command_name (name : str ) -> bool :
472+ """Reject names that could escape the commands directory via path traversal."""
473+ if os .path .sep in name or "/" in name or "\\ " in name :
474+ return False
475+ return os .path .normpath (name ) == name
476+
440477 def register_commands (
441478 self ,
442479 agent_name : str ,
@@ -482,9 +519,11 @@ def register_commands(
482519 commands_dir .mkdir (parents = True , exist_ok = True )
483520
484521 registered = []
522+ is_cline_ext = agent_name == "cline" and source_id != "core"
485523
486524 for cmd_info in commands :
487525 cmd_name = cmd_info ["name" ]
526+ aliases = cmd_info .get ("aliases" , [])
488527 cmd_file = cmd_info ["file" ]
489528
490529 source_file = source_dir / cmd_file
@@ -516,6 +555,10 @@ def register_commands(
516555 format_name = agent_config .get ("format_name" )
517556 frontmatter ["name" ] = format_name (cmd_name ) if format_name else cmd_name
518557
558+ if is_cline_ext :
559+ frontmatter = self ._hyphenate_frontmatter_refs (frontmatter )
560+ body = self ._hyphenate_body_refs (body )
561+
519562 body = self ._convert_argument_placeholder (
520563 body , "$ARGUMENTS" , agent_config ["args" ]
521564 )
@@ -585,7 +628,7 @@ def register_commands(
585628
586629 registered .append (cmd_name )
587630
588- for alias in cmd_info . get ( " aliases" , []) :
631+ for alias in aliases :
589632 alias_output_name = self ._compute_output_name (
590633 agent_name , alias , agent_config
591634 )
@@ -909,22 +952,32 @@ def unregister_commands(
909952 output_name = self ._compute_output_name (
910953 agent_name , cmd_name , agent_config
911954 )
955+
956+ names_to_clean = [output_name ]
957+ if output_name != cmd_name and self ._is_safe_command_name (cmd_name ):
958+ names_to_clean .append (cmd_name )
959+
912960 for target_dir in dirs_to_clean :
913- cmd_file = (
914- target_dir / f"{ output_name } { agent_config ['extension' ]} "
915- )
916- if cmd_file .exists () or cmd_file .is_symlink ():
917- cmd_file .unlink ()
918- # For SKILL.md agents each command lives in its own
919- # subdirectory (e.g. .agents/skills/speckit-ext-cmd/
920- # SKILL.md). Remove the parent dir when it becomes
921- # empty to avoid orphaned directories.
922- parent = cmd_file .parent
923- if parent != target_dir and parent .exists ():
924- try :
925- parent .rmdir ()
926- except OSError :
927- pass
961+ for name in names_to_clean :
962+ cmd_file = (
963+ target_dir / f"{ name } { agent_config ['extension' ]} "
964+ )
965+ try :
966+ self ._ensure_inside (cmd_file , target_dir )
967+ except ValueError :
968+ continue
969+ if cmd_file .exists () or cmd_file .is_symlink ():
970+ cmd_file .unlink ()
971+ # For SKILL.md agents each command lives in its own
972+ # subdirectory (e.g. .agents/skills/speckit-ext-cmd/
973+ # SKILL.md). Remove the parent dir when it becomes
974+ # empty to avoid orphaned directories.
975+ parent = cmd_file .parent
976+ if parent != target_dir and parent .exists ():
977+ try :
978+ parent .rmdir ()
979+ except OSError :
980+ pass
928981
929982 if agent_name == "copilot" :
930983 prompt_file = (
0 commit comments