From fa8adafc1adb84496e69092ba78ac613c764ab83 Mon Sep 17 00:00:00 2001 From: Serhii Shtokal Date: Thu, 25 Dec 2025 00:56:56 +0100 Subject: [PATCH 1/2] feat(cli): add --local-templates option for local development testing Add support for loading templates from a local directory instead of GitHub releases. This enables testing template and CLI changes locally before publishing. - New load_template_from_local() function with input validation - Validates directory exists and is accessible - Preserves local template files (doesn't delete after extraction) - Displays [DEV MODE] indicator in output --- CONTRIBUTING.md | 11 +++- README.md | 4 ++ src/specify_cli/__init__.py | 116 ++++++++++++++++++++++++++++++------ 3 files changed, 111 insertions(+), 20 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2b42e8fd61..ff9e916eac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,12 +75,19 @@ To test your templates, commands, and other changes locally, follow these steps: ./.github/workflows/scripts/create-release-packages.sh v1.0.0 ``` -2. **Copy the relevant package to your test project** +2. **Test with local templates** + + Use the `--local-templates` option to load templates from the local `.genreleases` directory: ```bash - cp -r .genreleases/sdd-copilot-package-sh/. / + specify init my-test-project --ai claude --local-templates ".genreleases" ``` + > **Alternative:** If you prefer not to use the CLI, you can manually copy the package: + > ```bash + > cp -r .genreleases/sdd-copilot-package-sh/. / + > ``` + 3. **Open and test the agent** Navigate to your test project folder and open the agent to verify your implementation. diff --git a/README.md b/README.md index 76149512f6..41857d8be2 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ The `specify` command supports the following options: | `--skip-tls` | Flag | Skip SSL/TLS verification (not recommended) | | `--debug` | Flag | Enable detailed debug output for troubleshooting | | `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) | +| `--local-templates` | Option | Path to local templates directory for development testing (bypasses GitHub download) | ### Examples @@ -238,6 +239,9 @@ specify init my-project --ai claude --debug # Use GitHub token for API requests (helpful for corporate environments) specify init my-project --ai claude --github-token ghp_your_token_here +# Use local templates for development testing (contributors only) +specify init my-project --ai claude --local-templates ".genreleases" + # Check system requirements specify check ``` diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 1dedb31949..bf70effc54 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -634,6 +634,61 @@ def deep_merge(base: dict, update: dict) -> dict: return merged +def load_template_from_local(ai_assistant: str, local_dir: Path, *, script_type: str = "sh", verbose: bool = True) -> tuple[Path, dict]: + """Load template from local .genreleases directory for development testing. + + Args: + ai_assistant: AI assistant name (e.g., 'antigravity', 'claude') + local_dir: Path to local directory containing template zip files + script_type: Script type ('sh' or 'ps') + verbose: Whether to print status messages + + Returns: + Tuple of (zip_path, metadata_dict) + + Raises: + typer.Exit: If local_dir doesn't exist, is not a directory, or no matching template is found. + """ + if not local_dir.exists(): + console.print(f"[red]Local templates directory not found:[/red] {local_dir}") + raise typer.Exit(1) + if not local_dir.is_dir(): + console.print(f"[red]Path is not a directory:[/red] {local_dir}") + raise typer.Exit(1) + + pattern = f"spec-kit-template-{ai_assistant}-{script_type}-*.zip" + matches = list(local_dir.glob(pattern)) + + if not matches: + console.print(f"[red]No local template found[/red] matching pattern: [bold]{pattern}[/bold]") + console.print(f"[dim]Searched in: {local_dir}[/dim]") + available_zips = list(local_dir.glob("spec-kit-template-*.zip")) + if available_zips: + console.print(Panel( + "\n".join(z.name for z in sorted(available_zips)[:10]), + title="Available local templates", + border_style="yellow" + )) + raise typer.Exit(1) + + # Sort alphabetically - versions like v1.0.0, v1.0.1 will order correctly + zip_path = sorted(matches)[-1] + file_stat = zip_path.stat() + + if verbose: + console.print(f"[cyan]Using local template:[/cyan] {zip_path.name}") + console.print(f"[cyan]Size:[/cyan] {file_stat.st_size:,} bytes") + console.print(f"[yellow][DEV MODE][/yellow] Loading from local directory") + + metadata = { + "filename": zip_path.name, + "size": file_stat.st_size, + "release": "local-dev", + "asset_url": str(zip_path), + "is_local": True + } + return zip_path, metadata + def download_template_from_github(ai_assistant: str, download_dir: Path, *, script_type: str = "sh", verbose: bool = True, show_progress: bool = True, client: httpx.Client = None, debug: bool = False, github_token: str = None) -> Tuple[Path, dict]: repo_owner = "github" repo_name = "spec-kit" @@ -748,35 +803,52 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri } return zip_path, metadata -def download_and_extract_template(project_path: Path, ai_assistant: str, script_type: str, is_current_dir: bool = False, *, verbose: bool = True, tracker: StepTracker | None = None, client: httpx.Client = None, debug: bool = False, github_token: str = None) -> Path: +def download_and_extract_template(project_path: Path, ai_assistant: str, script_type: str, is_current_dir: bool = False, *, verbose: bool = True, tracker: StepTracker | None = None, client: httpx.Client = None, debug: bool = False, github_token: str = None, local_dir: Path | None = None) -> Path: """Download the latest release and extract it to create a new project. Returns project_path. Uses tracker if provided (with keys: fetch, download, extract, cleanup) + + If local_dir is provided, loads template from local directory instead of GitHub. """ current_dir = Path.cwd() + is_local = local_dir is not None if tracker: - tracker.start("fetch", "contacting GitHub API") + if is_local: + tracker.start("fetch", "loading from local directory") + else: + tracker.start("fetch", "contacting GitHub API") try: - zip_path, meta = download_template_from_github( - ai_assistant, - current_dir, - script_type=script_type, - verbose=verbose and tracker is None, - show_progress=(tracker is None), - client=client, - debug=debug, - github_token=github_token - ) + if is_local: + # Use local template - no network request needed + zip_path, meta = load_template_from_local( + ai_assistant, + local_dir, + script_type=script_type, + verbose=verbose and tracker is None + ) + else: + # Download from GitHub releases + zip_path, meta = download_template_from_github( + ai_assistant, + current_dir, + script_type=script_type, + verbose=verbose and tracker is None, + show_progress=(tracker is None), + client=client, + debug=debug, + github_token=github_token + ) if tracker: - tracker.complete("fetch", f"release {meta['release']} ({meta['size']:,} bytes)") - tracker.add("download", "Download template") + release_info = f"local-dev" if is_local else f"release {meta['release']}" + tracker.complete("fetch", f"{release_info} ({meta['size']:,} bytes)") + tracker.add("download", "Download template" if not is_local else "Load template") tracker.complete("download", meta['filename']) except Exception as e: if tracker: tracker.error("fetch", str(e)) else: if verbose: - console.print(f"[red]Error downloading template:[/red] {e}") + console.print(f"[red]Error {'loading' if is_local else 'downloading'} template:[/red] {e}") raise if tracker: @@ -886,14 +958,18 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ tracker.complete("extract") finally: if tracker: - tracker.add("cleanup", "Remove temporary archive") + tracker.add("cleanup", "Remove temporary archive" if not is_local else "Skip cleanup (local)") - if zip_path.exists(): + # Only delete downloaded files, not local templates + if not is_local and zip_path.exists(): zip_path.unlink() if tracker: tracker.complete("cleanup") elif verbose: console.print(f"Cleaned up: {zip_path.name}") + elif is_local: + if tracker: + tracker.complete("cleanup", "local file preserved") return project_path @@ -954,6 +1030,7 @@ def init( skip_tls: bool = typer.Option(False, "--skip-tls", help="Skip SSL/TLS verification (not recommended)"), debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"), github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"), + local_templates: str = typer.Option(None, "--local-templates", help="Path to local .genreleases directory for development testing (bypasses GitHub download)"), ): """ Initialize a new Specify project from the latest template. @@ -1124,7 +1201,10 @@ def init( local_ssl_context = ssl_context if verify else False local_client = httpx.Client(verify=local_ssl_context) - download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token) + # Convert local_templates to Path if provided + local_dir = Path(local_templates) if local_templates else None + + download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token, local_dir=local_dir) ensure_executable_scripts(project_path, tracker=tracker) From d0001ec99574a3bb01604ec29d6dbd80b3db5608 Mon Sep 17 00:00:00 2001 From: Serhii Shtokal Date: Thu, 25 Dec 2025 01:55:27 +0100 Subject: [PATCH 2/2] fix(cli): simplify template resolution for --local-templates option --- src/specify_cli/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index bf70effc54..3dd641c66c 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -671,8 +671,8 @@ def load_template_from_local(ai_assistant: str, local_dir: Path, *, script_type: )) raise typer.Exit(1) - # Sort alphabetically - versions like v1.0.0, v1.0.1 will order correctly - zip_path = sorted(matches)[-1] + # Use first match - typically only one version exists per agent/script combo + zip_path = matches[0] file_stat = zip_path.stat() if verbose: