Skip to content

Commit c8ac712

Browse files
committed
feat: add reference mode for local team-ai-directives directories
1 parent 4b2bb4f commit c8ac712

3 files changed

Lines changed: 94 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ All notable changes to the Specify CLI and templates are documented here.
66

77
# [0.5.10] - 2026-04-20
88

9+
### Added
10+
11+
- **team-ai-directives reference mode**: Local directories are now used in-place without copying
12+
- When `--team-ai-directives` points to a local directory, it's used directly (reference mode)
13+
- When `--team-ai-directives` is a ZIP URL, it's downloaded and installed to `.specify/extensions/`
14+
- Added `get_team_directives_path()` helper to resolve path from init-options or extensions dir
15+
- Added `install` parameter to `sync_team_ai_directives()` for explicit control
16+
917
### Fixed
1018

1119
- **team-ai-directives duplicate installation**: Removed duplicate `sync_team_ai_directives()` call

src/specify_cli/__init__.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,9 +242,20 @@ def _derive_target_repo_from_url(zip_url: str) -> str:
242242

243243

244244
def sync_team_ai_directives(
245-
repo_url: str, project_root: Path, *, skip_tls: bool = False
245+
repo_url: str, project_root: Path, *, install: bool = True, skip_tls: bool = False
246246
) -> tuple[str, Path]:
247-
"""Install team-ai-directives as extension from ZIP URL or local path."""
247+
"""Install team-ai-directives as extension from ZIP URL or local path.
248+
249+
Args:
250+
repo_url: URL or local path to team-ai-directives
251+
project_root: Project root directory
252+
install: If True, copy local directories to .specify/extensions/.
253+
If False, use local directories in-place (reference mode).
254+
skip_tls: Skip TLS verification (for HTTPS URLs)
255+
256+
Returns:
257+
Tuple of (status, path) where status is "installed", "local", or "reference"
258+
"""
248259
from .extensions import ExtensionManager, ExtensionManifest # noqa: F401
249260

250261
repo_url = (repo_url or "").strip()
@@ -254,6 +265,25 @@ def sync_team_ai_directives(
254265
potential_path = Path(repo_url).expanduser()
255266

256267
if potential_path.exists() and potential_path.is_dir():
268+
# Validate it's a proper extension
269+
manifest_path = potential_path / "extension.yml"
270+
if not manifest_path.exists():
271+
raise ValueError(
272+
f"Invalid team-ai-directives directory: {potential_path}\n"
273+
f"Missing extension.yml manifest file"
274+
)
275+
276+
if not install:
277+
# Reference mode: use directory in-place without copying
278+
manifest = ExtensionManifest(manifest_path)
279+
if manifest.id != TEAM_DIRECTIVES_DIRNAME:
280+
raise ValueError(
281+
f"Extension ID mismatch: expected '{TEAM_DIRECTIVES_DIRNAME}', "
282+
f"got '{manifest.id}'"
283+
)
284+
return ("reference", potential_path)
285+
286+
# Install mode: copy to .specify/extensions/
257287
ext_manager = ExtensionManager(project_root)
258288
speckit_version = get_speckit_version()
259289
manifest = ext_manager.install_from_directory(
@@ -1199,6 +1229,24 @@ def load_init_options(project_path: Path) -> dict[str, Any]:
11991229
return {}
12001230

12011231

1232+
def get_team_directives_path(project_path: Path) -> Path | None:
1233+
"""Get team-ai-directives path from init-options or fallback to extensions dir.
1234+
1235+
Checks init-options.json first for external/override path, then falls back
1236+
to the standard .specify/extensions/team-ai-directives location.
1237+
1238+
Returns None if neither location exists.
1239+
"""
1240+
init_opts = load_init_options(project_path)
1241+
if "team_ai_directives" in init_opts:
1242+
path = Path(init_opts["team_ai_directives"])
1243+
if path.exists():
1244+
return path
1245+
# Fallback to installed extension
1246+
fallback = project_path / ".specify" / "extensions" / TEAM_DIRECTIVES_DIRNAME
1247+
return fallback if fallback.exists() else None
1248+
1249+
12021250
def _get_skills_dir(project_path: Path, selected_ai: str) -> Path:
12031251
"""Resolve the agent-specific skills directory.
12041252

src/specify_cli/cli_customization.py

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -326,13 +326,25 @@ def pre_init(
326326
tracker.start("team-directives")
327327
directives_path = None
328328
try:
329-
status, directives_path = sync_team_ai_directives(
330-
team_ai_directives, project_path
331-
)
332-
if status == "installed":
333-
tracker.complete("team-directives", f"installed to {directives_path}")
334-
elif status == "local":
335-
tracker.complete("team-directives", f"local: {directives_path}")
329+
# Determine if this is a local directory (use reference mode) or URL (install)
330+
potential_path = Path(team_ai_directives).expanduser()
331+
is_local_dir = potential_path.exists() and potential_path.is_dir()
332+
333+
if is_local_dir:
334+
# Local directory: use reference mode (no copy)
335+
status, directives_path = sync_team_ai_directives(
336+
team_ai_directives, project_path, install=False
337+
)
338+
tracker.complete("team-directives", f"referenced: {directives_path}")
339+
else:
340+
# ZIP URL: install to .specify/extensions/
341+
status, directives_path = sync_team_ai_directives(
342+
team_ai_directives, project_path, install=True
343+
)
344+
if status == "installed":
345+
tracker.complete("team-directives", f"installed to {directives_path}")
346+
elif status == "local":
347+
tracker.complete("team-directives", f"local: {directives_path}")
336348
os.environ["SPECIFY_TEAM_DIRECTIVES"] = str(directives_path)
337349
except Exception as e:
338350
tracker.error("team-directives", str(e))
@@ -927,24 +939,21 @@ def skill_install(
927939
# Check for team manifest and blocked skills enforcement
928940
team_manifest = None
929941
if not skip_blocked_check:
930-
ext_path = project_path / ".specify" / "extensions" / "team-ai-directives"
931-
if ext_path.exists():
932-
team_directives_path = str(ext_path)
942+
from . import get_team_directives_path
933943

944+
team_directives_path = get_team_directives_path(project_path)
934945
if team_directives_path:
935-
team_directives = Path(team_directives_path)
936-
if team_directives.exists():
937-
team_manifest = TeamSkillsManifest(team_directives)
938-
if team_manifest.exists() and team_manifest.should_enforce_blocked():
939-
blocked = team_manifest.get_blocked_skills()
940-
for blocked_skill in blocked:
941-
if blocked_skill in skill_ref or skill_ref in blocked_skill:
942-
console.print(
943-
f"[red]✗ Skill blocked by team policy:[/red] {skill_ref}\n"
944-
f" Blocked pattern: {blocked_skill}\n"
945-
f" Use --skip-blocked-check to override (not recommended)"
946-
)
947-
raise typer.Exit(1)
946+
team_manifest = TeamSkillsManifest(team_directives_path)
947+
if team_manifest.exists() and team_manifest.should_enforce_blocked():
948+
blocked = team_manifest.get_blocked_skills()
949+
for blocked_skill in blocked:
950+
if blocked_skill in skill_ref or skill_ref in blocked_skill:
951+
console.print(
952+
f"[red]✗ Skill blocked by team policy:[/red] {skill_ref}\n"
953+
f" Blocked pattern: {blocked_skill}\n"
954+
f" Use --skip-blocked-check to override (not recommended)"
955+
)
956+
raise typer.Exit(1)
948957

949958
installer = SkillInstaller(manifest, team_manifest)
950959

@@ -1227,8 +1236,9 @@ def skill_sync_team(
12271236

12281237
project_path = Path.cwd()
12291238

1230-
ext_path = project_path / ".specify" / "extensions" / "team-ai-directives"
1231-
team_directives_path = str(ext_path) if ext_path.exists() else None
1239+
from . import get_team_directives_path
1240+
1241+
team_directives_path = get_team_directives_path(project_path)
12321242

12331243
if not team_directives_path:
12341244
console.print(
@@ -1237,7 +1247,7 @@ def skill_sync_team(
12371247
)
12381248
return
12391249

1240-
team_directives = Path(team_directives_path)
1250+
team_directives = team_directives_path
12411251
if not team_directives.exists():
12421252
console.print(f"[red]Team directives not found:[/red] {team_directives}")
12431253
return

0 commit comments

Comments
 (0)