Skip to content

Commit 3899dcc

Browse files
authored
Stage 2: Copilot integration — proof of concept with shared template primitives (#2035)
* feat: Stage 2a — CopilotIntegration with shared template primitives - base.py: added granular primitives (shared_commands_dir, shared_templates_dir, list_command_templates, command_filename, commands_dest, copy_command_to_directory, record_file_in_manifest, write_file_and_record, process_template) - CopilotIntegration: uses primitives to produce .agent.md commands, companion .prompt.md files, and .vscode/settings.json - Verified byte-for-byte parity with old release script output - Copilot auto-registered in INTEGRATION_REGISTRY - 70 tests (22 new: base primitives + copilot integration) Part of #1924 * feat: Stage 2b — --integration flag, routing, agent.json, shared infra - Added --integration flag to init() (mutually exclusive with --ai) - --ai copilot auto-promotes to integration path with migration nudge - Integration setup writes .specify/agent.json with integration key - _install_shared_infra() copies scripts and templates to .specify/ - init-options.json records 'integration' key when used - 4 new CLI tests: mutual exclusivity, unknown rejection, copilot end-to-end, auto-promote (74 total integration tests) Part of #1924 * feat: Stage 2 completion — integration scripts, integration.json, shared manifest - Added copilot/scripts/update-context.sh and .ps1 (thin wrappers that delegate to the shared update-agent-context script) - CopilotIntegration.setup() installs integration scripts to .specify/integrations/copilot/scripts/ - Renamed agent.json → integration.json with script paths - _install_shared_infra() now tracks files in integration-shared.manifest.json - Updated tests: scripts installed, integration.json has script paths, shared manifest recorded (74 tests) Part of #1924 * refactor: rename shared manifest to speckit.manifest.json Cleaner naming — the shared infrastructure (scripts, templates) belongs to spec-kit itself, not to any specific integration. * fix: copilot update-context scripts reflect target architecture Scripts now source shared functions (via SPECKIT_SOURCE_ONLY=1) and call update_agent_file directly with .github/copilot-instructions.md, rather than delegating back to the shared case statement. * fix: simplify copilot scripts — dispatcher sources common functions Integration scripts now contain only copilot-specific logic (target path + agent name). The dispatcher is responsible for sourcing shared functions before calling the integration script. * fix: copilot update-context scripts are self-contained implementations These scripts ARE the implementation — the dispatcher calls them. They source common.sh + update-agent-context functions, gather feature/plan data, then call update_agent_file with the copilot target path (.github/copilot-instructions.md). * docs: add Stage 7 activation note to copilot update-context scripts * test: add complete file inventory test for copilot integration Validates every single file (37 total) produced by specify init --integration copilot --script sh --no-git. * test: add PowerShell file inventory test for copilot integration Validates all 37 files produced by --script ps variant, including .specify/scripts/powershell/ instead of bash. * refactor: split test_integrations.py into tests/integrations/ directory - test_base.py: IntegrationOption, IntegrationBase, MarkdownIntegration, primitives - test_manifest.py: IntegrationManifest, path traversal, persistence, validation - test_registry.py: INTEGRATION_REGISTRY - test_copilot.py: CopilotIntegration unit tests - test_cli.py: --integration flag, auto-promote, file inventories (sh + ps) - conftest.py: shared StubIntegration helper 76 integration tests + 48 consistency tests = 124 total, all passing. * refactor: move file inventory tests from test_cli to test_copilot File inventories are copilot-specific. test_cli.py now only tests CLI flag mechanics (mutual exclusivity, unknown rejection, auto-promote). * fix: skip JSONC merge to preserve user settings, fix docstring - _merge_vscode_settings() now returns early (skips merge) when existing settings.json can't be parsed (e.g. JSONC with comments), instead of overwriting with empty settings - Updated _install_shared_infra() docstring to match implementation (scripts + templates, speckit.manifest.json) * fix: warn user when JSONC settings merge is skipped * fix: show template content when JSONC merge is skipped User now sees the exact settings they should add manually. * fix: document process_template requirement, merge scripts without rmtree - base.py setup() docstring now explicitly states raw copy behavior and directs to CopilotIntegration for process_template example - _install_shared_infra() uses merge/overwrite instead of rmtree to preserve user-added files under .specify/scripts/ * fix: don't overwrite pre-existing shared scripts or templates Only write files that don't already exist — preserves any user modifications to shared scripts (common.sh etc.) and templates. * fix: warn user about skipped pre-existing shared files Lists all shared scripts and templates that were not copied because they already existed in the project. * test: add test for shared infra skip behavior on pre-existing files Verifies that _install_shared_infra() preserves user-modified scripts and templates while still installing missing ones. * fix: address review — containment check, deterministic prompts, manifest accuracy - CopilotIntegration.setup() adds dest containment check (relative_to) - Companion prompts generated from templates list, not directory glob - _install_shared_infra() only records files actually copied (not pre-existing) - VS Code settings tests made unconditional (assert template exists) - Inventory tests use .as_posix() for cross-platform paths * fix: correct PS1 function names, document SPECKIT_SOURCE_ONLY prerequisite - Fixed Get-FeaturePaths → Get-FeaturePathsEnv, Read-PlanData → Parse-PlanData - Documented that shared scripts must guard Main with SPECKIT_SOURCE_ONLY before these integration scripts can be activated (Stage 7) * fix: add dict type check for settings merge, simplify PS1 to subprocess - _merge_vscode_settings() skips merge with warning if parsed JSON is not a dict (array, null, etc.) - PS1 update-context.ps1 uses & invocation instead of dot-sourcing since the shared script runs Main unconditionally * fix: skip-write on no-op merge, bash subprocess, dynamic integration list - _merge_vscode_settings() only writes when keys were actually added - update-context.sh uses exec subprocess like PS1 version - Unknown integration error lists available integrations dynamically * fix: align path rewriting with release script, add .specify/.specify/ fix Path rewrite regex matches the release script's rewrite_paths() exactly (verified byte-identical output). Added .specify/.specify/ double-prefix fix for additional safety.
1 parent b8335a5 commit 3899dcc

File tree

13 files changed

+1263
-254
lines changed

13 files changed

+1263
-254
lines changed

src/specify_cli/__init__.py

Lines changed: 150 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1197,6 +1197,84 @@ def _locate_release_script() -> tuple[Path, str]:
11971197
raise FileNotFoundError(f"Release script '{name}' not found in core_pack or source checkout")
11981198

11991199

1200+
def _install_shared_infra(
1201+
project_path: Path,
1202+
script_type: str,
1203+
tracker: StepTracker | None = None,
1204+
) -> bool:
1205+
"""Install shared infrastructure files into *project_path*.
1206+
1207+
Copies ``.specify/scripts/`` and ``.specify/templates/`` from the
1208+
bundled core_pack or source checkout. Tracks all installed files
1209+
in ``speckit.manifest.json``.
1210+
Returns ``True`` on success.
1211+
"""
1212+
from .integrations.manifest import IntegrationManifest
1213+
1214+
core = _locate_core_pack()
1215+
manifest = IntegrationManifest("speckit", project_path, version=get_speckit_version())
1216+
1217+
# Scripts
1218+
if core and (core / "scripts").is_dir():
1219+
scripts_src = core / "scripts"
1220+
else:
1221+
repo_root = Path(__file__).parent.parent.parent
1222+
scripts_src = repo_root / "scripts"
1223+
1224+
skipped_files: list[str] = []
1225+
1226+
if scripts_src.is_dir():
1227+
dest_scripts = project_path / ".specify" / "scripts"
1228+
dest_scripts.mkdir(parents=True, exist_ok=True)
1229+
variant_dir = "bash" if script_type == "sh" else "powershell"
1230+
variant_src = scripts_src / variant_dir
1231+
if variant_src.is_dir():
1232+
dest_variant = dest_scripts / variant_dir
1233+
dest_variant.mkdir(parents=True, exist_ok=True)
1234+
# Merge without overwriting — only add files that don't exist yet
1235+
for src_path in variant_src.rglob("*"):
1236+
if src_path.is_file():
1237+
rel_path = src_path.relative_to(variant_src)
1238+
dst_path = dest_variant / rel_path
1239+
if dst_path.exists():
1240+
skipped_files.append(str(dst_path.relative_to(project_path)))
1241+
else:
1242+
dst_path.parent.mkdir(parents=True, exist_ok=True)
1243+
shutil.copy2(src_path, dst_path)
1244+
rel = dst_path.relative_to(project_path).as_posix()
1245+
manifest.record_existing(rel)
1246+
1247+
# Page templates (not command templates, not vscode-settings.json)
1248+
if core and (core / "templates").is_dir():
1249+
templates_src = core / "templates"
1250+
else:
1251+
repo_root = Path(__file__).parent.parent.parent
1252+
templates_src = repo_root / "templates"
1253+
1254+
if templates_src.is_dir():
1255+
dest_templates = project_path / ".specify" / "templates"
1256+
dest_templates.mkdir(parents=True, exist_ok=True)
1257+
for f in templates_src.iterdir():
1258+
if f.is_file() and f.name != "vscode-settings.json" and not f.name.startswith("."):
1259+
dst = dest_templates / f.name
1260+
if dst.exists():
1261+
skipped_files.append(str(dst.relative_to(project_path)))
1262+
else:
1263+
shutil.copy2(f, dst)
1264+
rel = dst.relative_to(project_path).as_posix()
1265+
manifest.record_existing(rel)
1266+
1267+
if skipped_files:
1268+
import logging
1269+
logging.getLogger(__name__).warning(
1270+
"The following shared files already exist and were not overwritten:\n%s",
1271+
"\n".join(f" {f}" for f in skipped_files),
1272+
)
1273+
1274+
manifest.save()
1275+
return True
1276+
1277+
12001278
def scaffold_from_core_pack(
12011279
project_path: Path,
12021280
ai_assistant: str,
@@ -1828,6 +1906,7 @@ def init(
18281906
offline: bool = typer.Option(False, "--offline", help="Use assets bundled in the specify-cli package instead of downloading from GitHub (no network access required). Bundled assets will become the default in v0.6.0 and this flag will be removed."),
18291907
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
18301908
branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, ...) or 'timestamp' (YYYYMMDD-HHMMSS)"),
1909+
integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."),
18311910
):
18321911
"""
18331912
Initialize a new Specify project.
@@ -1889,6 +1968,35 @@ def init(
18891968
if ai_assistant:
18901969
ai_assistant = AI_ASSISTANT_ALIASES.get(ai_assistant, ai_assistant)
18911970

1971+
# --integration and --ai are mutually exclusive
1972+
if integration and ai_assistant:
1973+
console.print("[red]Error:[/red] --integration and --ai are mutually exclusive")
1974+
console.print("[yellow]Use:[/yellow] --integration for the new integration system, or --ai for the legacy path")
1975+
raise typer.Exit(1)
1976+
1977+
# Auto-promote: --ai copilot → integration path with a nudge
1978+
use_integration = False
1979+
if integration:
1980+
from .integrations import INTEGRATION_REGISTRY, get_integration
1981+
resolved_integration = get_integration(integration)
1982+
if not resolved_integration:
1983+
console.print(f"[red]Error:[/red] Unknown integration: '{integration}'")
1984+
available = ", ".join(sorted(INTEGRATION_REGISTRY))
1985+
console.print(f"[yellow]Available integrations:[/yellow] {available}")
1986+
raise typer.Exit(1)
1987+
use_integration = True
1988+
# Map integration key to the ai_assistant variable for downstream compatibility
1989+
ai_assistant = integration
1990+
elif ai_assistant == "copilot":
1991+
from .integrations import get_integration
1992+
resolved_integration = get_integration("copilot")
1993+
if resolved_integration:
1994+
use_integration = True
1995+
console.print(
1996+
"[dim]Tip: Use [bold]--integration copilot[/bold] instead of "
1997+
"--ai copilot. The --ai flag will be deprecated in a future release.[/dim]"
1998+
)
1999+
18922000
if project_name == ".":
18932001
here = True
18942002
project_name = None # Clear project_name to use existing validation logic
@@ -2057,7 +2165,10 @@ def init(
20572165
"This will become the default in v0.6.0."
20582166
)
20592167

2060-
if use_github:
2168+
if use_integration:
2169+
tracker.add("integration", "Install integration")
2170+
tracker.add("shared-infra", "Install shared infrastructure")
2171+
elif use_github:
20612172
for key, label in [
20622173
("fetch", "Fetch latest release"),
20632174
("download", "Download template"),
@@ -2092,7 +2203,39 @@ def init(
20922203
verify = not skip_tls
20932204
local_ssl_context = ssl_context if verify else False
20942205

2095-
if use_github:
2206+
if use_integration:
2207+
# Integration-based scaffolding (new path)
2208+
from .integrations.manifest import IntegrationManifest
2209+
tracker.start("integration")
2210+
manifest = IntegrationManifest(
2211+
resolved_integration.key, project_path, version=get_speckit_version()
2212+
)
2213+
resolved_integration.setup(
2214+
project_path, manifest,
2215+
script_type=selected_script,
2216+
)
2217+
manifest.save()
2218+
2219+
# Write .specify/integration.json
2220+
script_ext = "sh" if selected_script == "sh" else "ps1"
2221+
integration_json = project_path / ".specify" / "integration.json"
2222+
integration_json.parent.mkdir(parents=True, exist_ok=True)
2223+
integration_json.write_text(json.dumps({
2224+
"integration": resolved_integration.key,
2225+
"version": get_speckit_version(),
2226+
"scripts": {
2227+
"update-context": f".specify/integrations/{resolved_integration.key}/scripts/update-context.{script_ext}",
2228+
},
2229+
}, indent=2) + "\n", encoding="utf-8")
2230+
2231+
tracker.complete("integration", resolved_integration.config.get("name", resolved_integration.key))
2232+
2233+
# Install shared infrastructure (scripts, templates)
2234+
tracker.start("shared-infra")
2235+
_install_shared_infra(project_path, selected_script, tracker=tracker)
2236+
tracker.complete("shared-infra", f"scripts ({selected_script}) + templates")
2237+
2238+
elif use_github:
20962239
with httpx.Client(verify=local_ssl_context) as local_client:
20972240
download_and_extract_template(
20982241
project_path,
@@ -2227,7 +2370,7 @@ def init(
22272370
# Persist the CLI options so later operations (e.g. preset add)
22282371
# can adapt their behaviour without re-scanning the filesystem.
22292372
# Must be saved BEFORE preset install so _get_skills_dir() works.
2230-
save_init_options(project_path, {
2373+
init_opts = {
22312374
"ai": selected_ai,
22322375
"ai_skills": ai_skills,
22332376
"ai_commands_dir": ai_commands_dir,
@@ -2237,7 +2380,10 @@ def init(
22372380
"offline": offline,
22382381
"script": selected_script,
22392382
"speckit_version": get_speckit_version(),
2240-
})
2383+
}
2384+
if use_integration:
2385+
init_opts["integration"] = resolved_integration.key
2386+
save_init_options(project_path, init_opts)
22412387

22422388
# Install preset if specified
22432389
if preset:

src/specify_cli/integrations/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,15 @@ def _register(integration: IntegrationBase) -> None:
3232
def get_integration(key: str) -> IntegrationBase | None:
3333
"""Return the integration for *key*, or ``None`` if not registered."""
3434
return INTEGRATION_REGISTRY.get(key)
35+
36+
37+
# -- Register built-in integrations --------------------------------------
38+
39+
def _register_builtins() -> None:
40+
"""Register all built-in integrations."""
41+
from .copilot import CopilotIntegration
42+
43+
_register(CopilotIntegration())
44+
45+
46+
_register_builtins()

0 commit comments

Comments
 (0)