5454from rich .tree import Tree
5555from typer .core import TyperGroup
5656
57+ from .integration_runtime import (
58+ invoke_separator_for_integration as _invoke_separator_for_integration ,
59+ resolve_integration_options as _resolve_integration_options_impl ,
60+ with_integration_setting as _with_integration_setting ,
61+ )
62+ from .integration_state import (
63+ INTEGRATION_JSON ,
64+ dedupe_integration_keys as _dedupe_integration_keys ,
65+ default_integration_key as _default_integration_key ,
66+ installed_integration_keys as _installed_integration_keys ,
67+ integration_setting as _integration_setting ,
68+ integration_settings as _integration_settings ,
69+ normalize_integration_state as _normalize_integration_state ,
70+ write_integration_json as _write_integration_json_file ,
71+ )
72+ from .shared_infra import (
73+ install_shared_infra as _install_shared_infra_impl ,
74+ refresh_shared_templates as _refresh_shared_templates_impl ,
75+ )
76+
5777# For cross-platform keyboard input
5878import readchar
5979
@@ -643,6 +663,11 @@ def _locate_core_pack() -> Path | None:
643663 return None
644664
645665
666+ def _repo_root () -> Path :
667+ """Return the source checkout root used for editable installs."""
668+ return Path (__file__ ).parent .parent .parent
669+
670+
646671def _locate_bundled_extension (extension_id : str ) -> Path | None :
647672 """Return the path to a bundled extension, or None.
648673
@@ -660,8 +685,7 @@ def _locate_bundled_extension(extension_id: str) -> Path | None:
660685 return candidate
661686
662687 # Source-checkout / editable install: look relative to repo root
663- repo_root = Path (__file__ ).parent .parent .parent
664- candidate = repo_root / "extensions" / extension_id
688+ candidate = _repo_root () / "extensions" / extension_id
665689 if (candidate / "extension.yml" ).is_file ():
666690 return candidate
667691
@@ -685,8 +709,7 @@ def _locate_bundled_workflow(workflow_id: str) -> Path | None:
685709 return candidate
686710
687711 # Source-checkout / editable install: look relative to repo root
688- repo_root = Path (__file__ ).parent .parent .parent
689- candidate = repo_root / "workflows" / workflow_id
712+ candidate = _repo_root () / "workflows" / workflow_id
690713 if (candidate / "workflow.yml" ).is_file ():
691714 return candidate
692715
@@ -710,82 +733,29 @@ def _locate_bundled_preset(preset_id: str) -> Path | None:
710733 return candidate
711734
712735 # Source-checkout / editable install: look relative to repo root
713- repo_root = Path (__file__ ).parent .parent .parent
714- candidate = repo_root / "presets" / preset_id
736+ candidate = _repo_root () / "presets" / preset_id
715737 if (candidate / "preset.yml" ).is_file ():
716738 return candidate
717739
718740 return None
719741
720742
721- def _load_speckit_manifest (project_path : Path ) -> Any :
722- """Load the shared infrastructure manifest, preserving existing entries."""
723- from .integrations .manifest import IntegrationManifest
724-
725- manifest_path = project_path / ".specify" / "integrations" / "speckit.manifest.json"
726- if manifest_path .exists ():
727- try :
728- manifest = IntegrationManifest .load ("speckit" , project_path )
729- manifest .version = get_speckit_version ()
730- return manifest
731- except (ValueError , FileNotFoundError ):
732- pass
733- return IntegrationManifest ("speckit" , project_path , version = get_speckit_version ())
734-
735-
736- def _shared_templates_source () -> Path :
737- """Return the bundled/source shared templates directory."""
738- core = _locate_core_pack ()
739- if core and (core / "templates" ).is_dir ():
740- return core / "templates"
741- repo_root = Path (__file__ ).parent .parent .parent
742- return repo_root / "templates"
743-
744-
745743def _refresh_shared_templates (
746744 project_path : Path ,
747745 * ,
748746 invoke_separator : str ,
749747 force : bool = False ,
750748) -> None :
751749 """Refresh default-sensitive shared templates without touching scripts."""
752- from .integrations .base import IntegrationBase
753-
754- templates_src = _shared_templates_source ()
755- if not templates_src .is_dir ():
756- return
757-
758- manifest = _load_speckit_manifest (project_path )
759- tracked_files = manifest .files
760- modified = set (manifest .check_modified ())
761- skipped_files : list [str ] = []
762-
763- dest_templates = project_path / ".specify" / "templates"
764- dest_templates .mkdir (parents = True , exist_ok = True )
765- for src in templates_src .iterdir ():
766- if not src .is_file () or src .name == "vscode-settings.json" or src .name .startswith ("." ):
767- continue
768-
769- dst = dest_templates / src .name
770- rel = dst .relative_to (project_path ).as_posix ()
771- if dst .exists () and not force :
772- if rel not in tracked_files or rel in modified :
773- skipped_files .append (rel )
774- continue
775-
776- content = src .read_text (encoding = "utf-8" )
777- content = IntegrationBase .resolve_command_refs (content , invoke_separator )
778- dst .write_text (content , encoding = "utf-8" )
779- manifest .record_existing (rel )
780-
781- manifest .save ()
782-
783- if skipped_files :
784- console .print (
785- f"[yellow]⚠[/yellow] { len (skipped_files )} modified or untracked shared template file(s) were not updated:"
786- )
787- for rel in skipped_files :
788- console .print (f" { rel } " )
750+ _refresh_shared_templates_impl (
751+ project_path ,
752+ version = get_speckit_version (),
753+ core_pack = _locate_core_pack (),
754+ repo_root = _repo_root (),
755+ console = console ,
756+ invoke_separator = invoke_separator ,
757+ force = force ,
758+ )
789759
790760
791761def _install_shared_infra (
@@ -811,74 +781,16 @@ def _install_shared_infra(
811781
812782 Returns ``True`` on success.
813783 """
814- from .integrations .base import IntegrationBase
815-
816- core = _locate_core_pack ()
817- manifest = _load_speckit_manifest (project_path )
818-
819- # Scripts
820- if core and (core / "scripts" ).is_dir ():
821- scripts_src = core / "scripts"
822- else :
823- repo_root = Path (__file__ ).parent .parent .parent
824- scripts_src = repo_root / "scripts"
825-
826- skipped_files : list [str ] = []
827-
828- if scripts_src .is_dir ():
829- dest_scripts = project_path / ".specify" / "scripts"
830- dest_scripts .mkdir (parents = True , exist_ok = True )
831- variant_dir = "bash" if script_type == "sh" else "powershell"
832- variant_src = scripts_src / variant_dir
833- if variant_src .is_dir ():
834- dest_variant = dest_scripts / variant_dir
835- dest_variant .mkdir (parents = True , exist_ok = True )
836- for src_path in variant_src .rglob ("*" ):
837- if src_path .is_file ():
838- rel_path = src_path .relative_to (variant_src )
839- dst_path = dest_variant / rel_path
840- if dst_path .exists () and not force :
841- skipped_files .append (str (dst_path .relative_to (project_path )))
842- else :
843- dst_path .parent .mkdir (parents = True , exist_ok = True )
844- shutil .copy2 (src_path , dst_path )
845- rel = dst_path .relative_to (project_path ).as_posix ()
846- manifest .record_existing (rel )
847-
848- # Page templates (not command templates, not vscode-settings.json)
849- templates_src = _shared_templates_source ()
850-
851- if templates_src .is_dir ():
852- dest_templates = project_path / ".specify" / "templates"
853- dest_templates .mkdir (parents = True , exist_ok = True )
854- for f in templates_src .iterdir ():
855- if f .is_file () and f .name != "vscode-settings.json" and not f .name .startswith ("." ):
856- dst = dest_templates / f .name
857- if dst .exists () and not force :
858- skipped_files .append (str (dst .relative_to (project_path )))
859- else :
860- content = f .read_text (encoding = "utf-8" )
861- content = IntegrationBase .resolve_command_refs (
862- content , invoke_separator
863- )
864- dst .write_text (content , encoding = "utf-8" )
865- rel = dst .relative_to (project_path ).as_posix ()
866- manifest .record_existing (rel )
867-
868- if skipped_files :
869- console .print (
870- f"[yellow]⚠[/yellow] { len (skipped_files )} shared infrastructure file(s) already exist and were not updated:"
871- )
872- for f in skipped_files :
873- console .print (f" { f } " )
874- console .print (
875- "To refresh shared infrastructure, run "
876- "[cyan]specify init --here --force[/cyan] or "
877- "[cyan]specify integration upgrade --force[/cyan]."
878- )
879-
880- manifest .save ()
881- return True
784+ return _install_shared_infra_impl (
785+ project_path ,
786+ script_type ,
787+ version = get_speckit_version (),
788+ core_pack = _locate_core_pack (),
789+ repo_root = _repo_root (),
790+ console = console ,
791+ force = force ,
792+ invoke_separator = invoke_separator ,
793+ )
882794
883795
884796def ensure_executable_scripts (project_path : Path , tracker : StepTracker | None = None ) -> None :
@@ -1937,7 +1849,7 @@ def get_speckit_version() -> str:
19371849 # Fallback: try reading from pyproject.toml
19381850 try :
19391851 import tomllib
1940- pyproject_path = Path ( __file__ ). parent . parent . parent / "pyproject.toml"
1852+ pyproject_path = _repo_root () / "pyproject.toml"
19411853 if pyproject_path .exists ():
19421854 with open (pyproject_path , "rb" ) as f :
19431855 data = tomllib .load (f )
@@ -1959,18 +1871,6 @@ def get_speckit_version() -> str:
19591871app .add_typer (integration_app , name = "integration" )
19601872
19611873
1962- from .integration_state import ( # noqa: E402
1963- INTEGRATION_JSON ,
1964- dedupe_integration_keys as _dedupe_integration_keys ,
1965- default_integration_key as _default_integration_key ,
1966- installed_integration_keys as _installed_integration_keys ,
1967- integration_setting as _integration_setting ,
1968- integration_settings as _integration_settings ,
1969- normalize_integration_state as _normalize_integration_state ,
1970- write_integration_json as _write_integration_json_file ,
1971- )
1972-
1973-
19741874def _read_integration_json (project_root : Path ) -> dict [str , Any ]:
19751875 """Load ``.specify/integration.json``. Returns normalized state when present."""
19761876 path = project_root / INTEGRATION_JSON
@@ -2076,74 +1976,13 @@ def _resolve_integration_options(
20761976 raw_options : str | None ,
20771977) -> tuple [str | None , dict [str , Any ] | None ]:
20781978 """Resolve raw and parsed options for an integration operation."""
2079- if raw_options is not None :
2080- return raw_options , _parse_integration_options (integration , raw_options )
2081-
2082- setting = _integration_setting (state , key )
2083- stored_raw = setting .get ("raw_options" )
2084- if not isinstance (stored_raw , str ):
2085- stored_raw = None
2086-
2087- stored_parsed = setting .get ("parsed_options" )
2088- if isinstance (stored_parsed , dict ):
2089- return stored_raw , stored_parsed or None
2090-
2091- if stored_raw :
2092- return stored_raw , _parse_integration_options (integration , stored_raw )
2093-
2094- return None , None
2095-
2096-
2097- def _with_integration_setting (
2098- state : dict [str , Any ],
2099- key : str ,
2100- integration : Any ,
2101- * ,
2102- script_type : str | None = None ,
2103- raw_options : str | None = None ,
2104- parsed_options : dict [str , Any ] | None = None ,
2105- ) -> dict [str , dict [str , Any ]]:
2106- """Return integration settings with *key* updated."""
2107- settings = _integration_settings (state )
2108- current = dict (settings .get (key , {}))
2109-
2110- if script_type :
2111- current ["script" ] = script_type
2112- if raw_options is not None :
2113- current ["raw_options" ] = raw_options
2114- elif "raw_options" in current and not current .get ("raw_options" ):
2115- current .pop ("raw_options" , None )
2116-
2117- if parsed_options is not None :
2118- current ["parsed_options" ] = parsed_options
2119- elif raw_options is not None :
2120- current .pop ("parsed_options" , None )
2121-
2122- current ["invoke_separator" ] = integration .effective_invoke_separator (parsed_options )
2123- settings [key ] = current
2124- return settings
2125-
2126-
2127- def _invoke_separator_for_integration (
2128- integration : Any ,
2129- state : dict [str , Any ],
2130- key : str ,
2131- parsed_options : dict [str , Any ] | None = None ,
2132- ) -> str :
2133- """Resolve the invocation separator for stored/default integration state."""
2134- if parsed_options is not None :
2135- return integration .effective_invoke_separator (parsed_options )
2136-
2137- setting = _integration_setting (state , key )
2138- stored_separator = setting .get ("invoke_separator" )
2139- if isinstance (stored_separator , str ) and stored_separator :
2140- return stored_separator
2141-
2142- stored_parsed = setting .get ("parsed_options" )
2143- if isinstance (stored_parsed , dict ):
2144- return integration .effective_invoke_separator (stored_parsed )
2145-
2146- return integration .effective_invoke_separator (None )
1979+ return _resolve_integration_options_impl (
1980+ integration ,
1981+ state ,
1982+ key ,
1983+ raw_options ,
1984+ parse_options = _parse_integration_options ,
1985+ )
21471986
21481987
21491988def _set_default_integration (
0 commit comments