Skip to content

Commit c99443e

Browse files
authored
feat: remote template lock (#344)
1 parent 54935bc commit c99443e

File tree

7 files changed

+577
-11
lines changed

7 files changed

+577
-11
lines changed

docs/remote-templates/creating-remote-templates.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,57 @@ dependencies = [
231231

232232
**Best Practice:** Always include a `uv.lock` file for reproducible builds.
233233

234+
### Version Locking for Guaranteed Compatibility
235+
236+
For maximum compatibility and stability, you can lock your remote template to a specific version of the Agent Starter Pack. This ensures that your template will always be processed with the exact version it was designed for, preventing potential breaking changes from affecting your users.
237+
238+
**To enable version locking:**
239+
240+
1. **Add agent-starter-pack as a dev dependency** in your `pyproject.toml`:
241+
```toml
242+
[dependency-groups]
243+
dev = [
244+
"agent-starter-pack==0.14.1", # Lock to specific version
245+
# ... your other dev dependencies
246+
]
247+
```
248+
249+
2. **Generate the lock file:**
250+
```bash
251+
uv lock
252+
```
253+
254+
3. **Commit both files:**
255+
```bash
256+
git add pyproject.toml uv.lock
257+
git commit -m "Lock agent-starter-pack version for compatibility"
258+
```
259+
260+
**How it works:**
261+
- When users fetch your remote template, the starter pack automatically detects the locked version in `uv.lock`
262+
- It then executes `uvx agent-starter-pack==VERSION` with the locked version (requires `uv` to be installed)
263+
- This guarantees your template is processed with the exact version you tested it with
264+
265+
**Requirements:**
266+
- Users must have `uv` installed to use version-locked templates
267+
- If `uv` is not available, the command will fail with installation instructions
268+
269+
**When to use version locking:**
270+
- ✅ Your template uses specific starter pack features that might change
271+
- ✅ You want to guarantee long-term stability for your users
272+
- ✅ Your template is critical infrastructure that needs predictable behavior
273+
- ❌ You always want the latest starter pack features (trade-off: potential breaking changes)
274+
275+
**Example user experience:**
276+
```bash
277+
uvx agent-starter-pack create my-project -a github.com/you/your-template
278+
279+
# Output:
280+
# 🔒 Remote template specifies agent-starter-pack version 0.14.1 in uv.lock
281+
# 📦 Executing nested command: uvx agent-starter-pack==0.14.1
282+
# [continues with locked version]
283+
```
284+
234285
### Makefile Customization
235286

236287
If your template includes a `Makefile`, it will be intelligently merged:

docs/remote-templates/index.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,10 @@ uvx agent-starter-pack create test-agent -a https://github.com/you/your-template
3737

3838
Remote templates work by:
3939
1. **Fetching** template repositories from Git
40-
2. **Applying** intelligent defaults based on repository structure
41-
3. **Merging** template files with base agent infrastructure
42-
4. **Generating** complete, production-ready agent projects
40+
2. **Version locking** - automatically uses the exact starter pack version specified by the template for guaranteed compatibility
41+
3. **Applying** intelligent defaults based on repository structure
42+
4. **Merging** template files with base agent infrastructure
43+
5. **Generating** complete, production-ready agent projects
4344

4445
Any Git repository can become a template - the system handles the complexity automatically.
4546

docs/remote-templates/using-remote-templates.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ Remote templates let you instantly create production-ready AI agents from Git re
77
When you use a remote template, the system:
88

99
1. **Fetches** the template repository from Git
10-
2. **Applies intelligent defaults** based on repository structure
11-
3. **Merges** template files with base agent infrastructure
12-
4. **Generates** a complete, production-ready agent project
10+
2. **Checks for version locking** - if the template specifies a starter pack version in `uv.lock`, automatically uses that version for guaranteed compatibility
11+
3. **Applies intelligent defaults** based on repository structure
12+
4. **Merges** template files with base agent infrastructure
13+
5. **Generates** a complete, production-ready agent project
1314

1415
The file merging follows this priority order:
1516
1. Base template files (foundation)

src/cli/commands/create.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,20 @@ def normalize_project_name(project_name: str) -> str:
226226
help="Template files directly into the current directory instead of creating a new project directory",
227227
default=False,
228228
)
229+
@click.option(
230+
"--skip-welcome",
231+
is_flag=True,
232+
hidden=True,
233+
help="Skip the welcome banner",
234+
default=False,
235+
)
236+
@click.option(
237+
"--locked",
238+
is_flag=True,
239+
hidden=True,
240+
help="Internal flag for version-locked remote templates",
241+
default=False,
242+
)
229243
@shared_template_options
230244
@handle_cli_error
231245
def create(
@@ -247,6 +261,7 @@ def create(
247261
agent_garden: bool = False,
248262
base_template: str | None = None,
249263
skip_welcome: bool = False,
264+
locked: bool = False,
250265
cli_overrides: dict | None = None,
251266
) -> None:
252267
"""Create GCP-based AI agent projects from templates."""
@@ -337,8 +352,22 @@ def create(
337352
ignore=get_standard_ignore_patterns(),
338353
)
339354

355+
# Check for version lock and execute nested command if found
356+
from ..utils.remote_template import check_and_execute_with_version_lock
357+
358+
if check_and_execute_with_version_lock(
359+
template_source_path, agent, locked
360+
):
361+
# If we executed with locked version, cleanup and exit
362+
shutil.rmtree(temp_dir, ignore_errors=True)
363+
return
364+
340365
selected_agent = f"local_{template_source_path.name}"
341-
console.print(f"Using local template: {local_path}")
366+
if locked:
367+
# In locked mode, show a nicer message
368+
console.print("✅ Using version-locked template", style="green")
369+
else:
370+
console.print(f"Using local template: {local_path}")
342371
logging.debug(
343372
f"Copied local template to temporary dir: {template_source_path}"
344373
)
@@ -354,7 +383,7 @@ def create(
354383
else:
355384
console.print(f"Fetching remote template: {agent}")
356385
template_source_path, temp_dir_path = fetch_remote_template(
357-
remote_spec
386+
remote_spec, agent, locked
358387
)
359388
temp_dir_to_clean = str(temp_dir_path)
360389
selected_agent = f"remote_{hash(agent)}" # Generate unique name for remote template
@@ -416,7 +445,7 @@ def create(
416445
else:
417446
console.print(f"Fetching remote template: {agent}")
418447
template_source_path, temp_dir_path = fetch_remote_template(
419-
remote_spec
448+
remote_spec, agent, locked
420449
)
421450
temp_dir_to_clean = str(temp_dir_path)
422451
final_agent = f"remote_{hash(agent)}" # Generate unique name for remote template

src/cli/utils/remote_template.py

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,11 @@
2828
else:
2929
import tomli as tomllib
3030
from jinja2 import Environment
31+
from packaging import version as pkg_version
3132
from rich.console import Console
3233

34+
from src.cli.utils.version import get_current_version
35+
3336

3437
@dataclass
3538
class RemoteTemplateSpec:
@@ -127,15 +130,113 @@ def parse_agent_spec(agent_spec: str) -> RemoteTemplateSpec | None:
127130
return None
128131

129132

133+
def check_and_execute_with_version_lock(
134+
template_dir: pathlib.Path,
135+
original_agent_spec: str | None = None,
136+
locked: bool = False,
137+
) -> bool:
138+
"""Check if remote template has agent-starter-pack version lock and execute if found.
139+
140+
Args:
141+
template_dir: Path to the fetched template directory
142+
original_agent_spec: The original agent spec (remote URL) to replace with local path
143+
locked: Whether this is already a locked execution (prevents recursion)
144+
145+
Returns:
146+
True if version lock was found and executed, False otherwise
147+
"""
148+
# Skip version locking if we're already in a locked execution (prevents recursion)
149+
if locked:
150+
return False
151+
uv_lock_path = template_dir / "uv.lock"
152+
version = parse_agent_starter_pack_version_from_lock(uv_lock_path)
153+
154+
if version:
155+
console = Console()
156+
console.print(
157+
f"🔒 Remote template requires agent-starter-pack version {version}",
158+
style="bold blue",
159+
)
160+
console.print(
161+
f"📦 Switching to version {version}...",
162+
style="dim",
163+
)
164+
165+
# Reconstruct the original command but with version constraint
166+
import sys
167+
168+
original_args = sys.argv[1:] # Skip 'agent-starter-pack' or script name
169+
170+
# Add version lock specific parameters and handle remote URL replacement
171+
if original_agent_spec:
172+
# Replace remote agent spec with local path
173+
modified_args = []
174+
for arg in original_args:
175+
if arg == original_agent_spec:
176+
# Replace remote URL with local template directory
177+
modified_args.append(f"local@{template_dir}")
178+
else:
179+
modified_args.append(arg)
180+
original_args = modified_args
181+
182+
# Add version lock flags only for ASP versions 0.14.1 and above
183+
current_version = get_current_version()
184+
if pkg_version.parse(current_version) > pkg_version.parse("0.14.1"):
185+
original_args.extend(["--skip-welcome", "--locked"])
186+
187+
try:
188+
# Check if uvx is available
189+
subprocess.run(["uvx", "--version"], capture_output=True, check=True)
190+
except (subprocess.CalledProcessError, FileNotFoundError):
191+
console.print(
192+
f"❌ Remote template requires agent-starter-pack version {version}, but 'uvx' is not installed",
193+
style="bold red",
194+
)
195+
console.print(
196+
"💡 Install uv to use version-locked remote templates:",
197+
style="bold blue",
198+
)
199+
console.print(" curl -LsSf https://astral.sh/uv/install.sh | sh")
200+
console.print(
201+
" OR visit: https://docs.astral.sh/uv/getting-started/installation/"
202+
)
203+
sys.exit(1)
204+
205+
try:
206+
# Execute uvx with the locked version
207+
cmd = ["uvx", f"agent-starter-pack=={version}", *original_args]
208+
logging.debug(f"Executing nested command: {' '.join(cmd)}")
209+
subprocess.run(cmd, check=True)
210+
return True
211+
212+
except subprocess.CalledProcessError as e:
213+
console.print(
214+
f"❌ Failed to execute with locked version {version}: {e}",
215+
style="bold red",
216+
)
217+
console.print(
218+
"⚠️ Continuing with current version, but compatibility is not guaranteed",
219+
style="yellow",
220+
)
221+
# Continue with current execution instead of failing completely
222+
223+
return False
224+
225+
130226
def fetch_remote_template(
131227
spec: RemoteTemplateSpec,
228+
original_agent_spec: str | None = None,
229+
locked: bool = False,
132230
) -> tuple[pathlib.Path, pathlib.Path]:
133231
"""Fetch remote template and return path to template directory.
134232
135-
Uses Git to clone the remote repository.
233+
Uses Git to clone the remote repository. If the template contains a uv.lock
234+
with agent-starter-pack version constraint, will execute nested uvx command.
136235
137236
Args:
138237
spec: Remote template specification
238+
original_agent_spec: Original agent spec string (used to prevent recursion)
239+
locked: Whether this is already a locked execution (prevents recursion)
139240
140241
Returns:
141242
A tuple containing:
@@ -188,6 +289,16 @@ def fetch_remote_template(
188289
f"Template path not found in the repository: {spec.template_path}"
189290
)
190291

292+
# Check for version lock and execute nested command if found
293+
if check_and_execute_with_version_lock(
294+
template_dir, original_agent_spec, locked
295+
):
296+
# If we executed with locked version, the nested process will handle everything
297+
# Clean up and exit successfully
298+
shutil.rmtree(temp_path, ignore_errors=True)
299+
# Exit with success since the nested command will handle the rest
300+
sys.exit(0)
301+
191302
return template_dir, temp_path
192303
except Exception as e:
193304
# Clean up on error
@@ -458,6 +569,41 @@ def display_adk_caveat_if_needed(agents: dict[int, dict[str, Any]]) -> None:
458569
)
459570

460571

572+
def parse_agent_starter_pack_version_from_lock(
573+
uv_lock_path: pathlib.Path,
574+
) -> str | None:
575+
"""Parse agent-starter-pack version from uv.lock file.
576+
577+
Args:
578+
uv_lock_path: Path to uv.lock file
579+
580+
Returns:
581+
Version string if found, None otherwise
582+
"""
583+
if not uv_lock_path.exists():
584+
return None
585+
586+
try:
587+
with open(uv_lock_path, "rb") as f:
588+
lock_data = tomllib.load(f)
589+
590+
# Look for agent-starter-pack in the packages section
591+
packages = lock_data.get("package", [])
592+
for package in packages:
593+
if package.get("name") == "agent-starter-pack":
594+
version = package.get("version")
595+
if version:
596+
logging.debug(
597+
f"Found agent-starter-pack version {version} in uv.lock"
598+
)
599+
return version
600+
601+
except Exception as e:
602+
logging.warning(f"Error parsing uv.lock file {uv_lock_path}: {e}")
603+
604+
return None
605+
606+
461607
def render_and_merge_makefiles(
462608
base_template_path: pathlib.Path,
463609
final_destination: pathlib.Path,

0 commit comments

Comments
 (0)