Add prune, uninstall and init --with-apm. Fixes APM package collisions (APM packages with same repo name different owner are now handled well)

This commit is contained in:
danielmeppiel
2025-09-16 10:24:12 +02:00
parent 9d449539bb
commit 6e4f287913
5 changed files with 646 additions and 77 deletions

View File

@@ -97,6 +97,7 @@ The `specify` command supports the following options:
| `--here` | Flag | Initialize project in the current directory instead of creating a new one |
| `--skip-tls` | Flag | Skip SSL/TLS verification (not recommended) |
| `--debug` | Flag | Enable detailed debug output for troubleshooting |
| `--use-apm` | Flag | Include APM (Agent Package Manager) structure for context management |
### Examples
@@ -107,14 +108,17 @@ specify init my-project
# Initialize with specific AI assistant
specify init my-project --ai claude
# Initialize with APM support
specify init my-project --ai claude --use-apm
# Initialize with Cursor support
specify init my-project --ai cursor
# Initialize with PowerShell scripts (Windows/cross-platform)
specify init my-project --ai copilot --script ps
# Initialize in current directory
specify init --here --ai copilot
# Initialize in current directory with APM
specify init --here --ai copilot --use-apm
# Skip git initialization
specify init my-project --ai gemini --no-git
@@ -139,9 +143,25 @@ specify init my-project --ai claude
### APM Commands
```bash
# Core APM commands available under 'apm' subcommand
specify apm compile # Generate AGENTS.md from your context
specify apm install # Install APM package dependencies
specify apm deps list # List available APM packages
# Install APM packages from apm.yml
specify apm install
# Add APM package to apm.yml and install
specify apm install org/repo
# Remove package from apm.yml and apm_modules
specify apm uninstall org/repo
# Remove orphaned packages not in apm.yml
specify apm prune
# List installed APM packages
specify apm deps list
# Generate nested optimal AGENTS.md tree
# Uses installed APM packages and local context files
specify apm compile
```
## <20>📚 Core philosophy

View File

@@ -116,7 +116,53 @@ def _lazy_confirm():
return None
def _check_orphaned_packages():
"""Check for packages in apm_modules/ that are not declared in apm.yml.
Returns:
List[str]: List of orphaned package names in org/repo format
"""
try:
from pathlib import Path
# Check if apm.yml exists
if not Path('apm.yml').exists():
return []
# Check if apm_modules exists
apm_modules_dir = Path('apm_modules')
if not apm_modules_dir.exists():
return []
# Parse apm.yml to get declared dependencies
try:
apm_package = APMPackage.from_apm_yml(Path('apm.yml'))
declared_deps = apm_package.get_apm_dependencies()
declared_repos = set(dep.repo_url for dep in declared_deps)
declared_names = set()
for dep in declared_deps:
if '/' in dep.repo_url:
declared_names.add(dep.repo_url.split('/')[-1])
else:
declared_names.add(dep.repo_url)
except Exception:
return [] # If can't parse apm.yml, assume no orphans
# Find installed packages and check for orphans (org-namespaced structure)
orphaned_packages = []
for org_dir in apm_modules_dir.iterdir():
if org_dir.is_dir() and not org_dir.name.startswith('.'):
for repo_dir in org_dir.iterdir():
if repo_dir.is_dir() and not repo_dir.name.startswith('.'):
org_repo_name = f"{org_dir.name}/{repo_dir.name}"
# Check if orphaned
if org_repo_name not in declared_repos:
orphaned_packages.append(org_repo_name)
return orphaned_packages
except Exception:
return [] # Return empty list if any error occurs
def _load_template_file(template_name, filename, **variables):
@@ -264,14 +310,124 @@ def init(ctx, project_name, force, yes):
_rich_error(f"Error initializing project: {e}")
sys.exit(1)
def _validate_and_add_packages_to_apm_yml(packages, dry_run=False):
"""Validate packages exist and can be accessed, then add to apm.yml dependencies section."""
import yaml
from pathlib import Path
import subprocess
import tempfile
apm_yml_path = Path('apm.yml')
# Read current apm.yml
try:
with open(apm_yml_path, 'r') as f:
data = yaml.safe_load(f) or {}
except Exception as e:
_rich_error(f"Failed to read apm.yml: {e}")
sys.exit(1)
# Ensure dependencies structure exists
if 'dependencies' not in data:
data['dependencies'] = {}
if 'apm' not in data['dependencies']:
data['dependencies']['apm'] = []
current_deps = data['dependencies']['apm'] or []
validated_packages = []
# First, validate all packages
_rich_info(f"Validating {len(packages)} package(s)...")
for package in packages:
# Validate package format (should be owner/repo)
if '/' not in package:
_rich_error(f"Invalid package format: {package}. Use 'owner/repo' format.")
continue
# Check if package is already in dependencies
if package in current_deps:
_rich_warning(f"Package {package} already exists in apm.yml")
continue
# Validate package exists and is accessible
if _validate_package_exists(package):
validated_packages.append(package)
_rich_info(f"{package} - accessible")
else:
_rich_error(f"{package} - not accessible or doesn't exist")
if not validated_packages:
if dry_run:
_rich_warning("No new valid packages to add")
return []
if dry_run:
_rich_info(f"Dry run: Would add {len(validated_packages)} package(s) to apm.yml:")
for pkg in validated_packages:
_rich_info(f" + {pkg}")
return validated_packages
# Add validated packages to dependencies
for package in validated_packages:
current_deps.append(package)
_rich_info(f"Added {package} to apm.yml")
# Update dependencies
data['dependencies']['apm'] = current_deps
# Write back to apm.yml
try:
with open(apm_yml_path, 'w') as f:
yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False)
_rich_success(f"Updated apm.yml with {len(validated_packages)} new package(s)")
except Exception as e:
_rich_error(f"Failed to write apm.yml: {e}")
sys.exit(1)
return validated_packages
def _validate_package_exists(package):
"""Validate that a package exists and is accessible on GitHub."""
import subprocess
import tempfile
import os
# Try to do a shallow clone to test accessibility
with tempfile.TemporaryDirectory() as temp_dir:
try:
# Try cloning with minimal fetch
cmd = [
'git', 'ls-remote', '--heads', '--exit-code',
f'https://github.com/{package}.git'
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30 # 30 second timeout
)
return result.returncode == 0
except subprocess.TimeoutExpired:
return False
except Exception:
return False
@cli.command(help="Install APM and MCP dependencies from apm.yml")
@click.argument('packages', nargs=-1)
@click.option('--runtime', help="Target specific runtime only (copilot, codex, vscode)")
@click.option('--exclude', help="Exclude specific runtime from installation")
@click.option('--only', type=click.Choice(['apm', 'mcp']), help="Install only specific dependency type")
@click.option('--update', is_flag=True, help="Update dependencies to latest Git references")
@click.option('--dry-run', is_flag=True, help="Show what would be installed without installing")
@click.pass_context
def install(ctx, runtime, exclude, only, update, dry_run):
def install(ctx, packages, runtime, exclude, only, update, dry_run):
"""Install APM and MCP dependencies from apm.yml (like npm install).
This command automatically detects AI runtimes from your apm.yml scripts and installs
@@ -279,12 +435,14 @@ def install(ctx, runtime, exclude, only, update, dry_run):
dependencies from GitHub repositories.
Examples:
apm install # Install APM deps then MCP deps for all runtimes
apm install --exclude codex # Install for all except Codex CLI
apm install --only=apm # Install only APM dependencies
apm install --only=mcp # Install only MCP dependencies
apm install --update # Update dependencies to latest Git refs
apm install --dry-run # Show what would be installed
apm install # Install existing deps from apm.yml
apm install org/pkg1 # Add package to apm.yml and install
apm install org/pkg1 org/pkg2 # Add multiple packages and install
apm install --exclude codex # Install for all except Codex CLI
apm install --only=apm # Install only APM dependencies
apm install --only=mcp # Install only MCP dependencies
apm install --update # Update dependencies to latest Git refs
apm install --dry-run # Show what would be installed
"""
try:
# Check if apm.yml exists
@@ -292,6 +450,13 @@ def install(ctx, runtime, exclude, only, update, dry_run):
_rich_error("No apm.yml found. Run 'apm init' first.")
sys.exit(1)
# If packages are specified, validate and add them to apm.yml first
if packages:
validated_packages = _validate_and_add_packages_to_apm_yml(packages, dry_run)
if not validated_packages and not dry_run:
_rich_error("No valid packages to install")
sys.exit(1)
_rich_info("Installing dependencies from apm.yml...")
# Parse apm.yml to get both APM and MCP dependencies
@@ -363,6 +528,245 @@ def install(ctx, runtime, exclude, only, update, dry_run):
sys.exit(1)
@cli.command(help="Remove APM packages not listed in apm.yml")
@click.option('--dry-run', is_flag=True, help="Show what would be removed without removing")
@click.pass_context
def prune(ctx, dry_run):
"""Remove installed APM packages that are not listed in apm.yml (like npm prune).
This command cleans up the apm_modules/ directory by removing packages that
were previously installed but are no longer declared as dependencies in apm.yml.
Examples:
apm prune # Remove orphaned packages
apm prune --dry-run # Show what would be removed
"""
try:
# Check if apm.yml exists
if not Path('apm.yml').exists():
_rich_error("No apm.yml found. Run 'specify apm init' first.")
sys.exit(1)
# Check if apm_modules exists
apm_modules_dir = Path('apm_modules')
if not apm_modules_dir.exists():
_rich_info("No apm_modules/ directory found. Nothing to prune.")
return
_rich_info("Analyzing installed packages vs apm.yml...")
# Parse apm.yml to get declared dependencies
try:
apm_package = APMPackage.from_apm_yml(Path('apm.yml'))
declared_deps = apm_package.get_apm_dependencies()
# Keep full org/repo format (e.g., "github/design-guidelines")
declared_repos = set()
declared_names = set() # For directory name matching
for dep in declared_deps:
declared_repos.add(dep.repo_url)
# Also track directory names for filesystem matching
if '/' in dep.repo_url:
package_name = dep.repo_url.split('/')[-1]
declared_names.add(package_name)
else:
declared_names.add(dep.repo_url)
except Exception as e:
_rich_error(f"Failed to parse apm.yml: {e}")
sys.exit(1)
# Find installed packages in apm_modules/ (now org-namespaced)
installed_packages = {} # {"github/design-guidelines": "github/design-guidelines"}
if apm_modules_dir.exists():
for org_dir in apm_modules_dir.iterdir():
if org_dir.is_dir() and not org_dir.name.startswith('.'):
# Check if this is an org directory with packages inside
for repo_dir in org_dir.iterdir():
if repo_dir.is_dir() and not repo_dir.name.startswith('.'):
org_repo_name = f"{org_dir.name}/{repo_dir.name}"
installed_packages[org_repo_name] = org_repo_name
# Find orphaned packages (installed but not declared)
orphaned_packages = {}
for org_repo_name, display_name in installed_packages.items():
if org_repo_name not in declared_repos:
orphaned_packages[org_repo_name] = display_name
if not orphaned_packages:
_rich_success("No orphaned packages found. apm_modules/ is clean.")
return
# Show what will be removed
_rich_info(f"Found {len(orphaned_packages)} orphaned package(s):")
for dir_name, display_name in orphaned_packages.items():
if dry_run:
_rich_info(f" - {display_name} (would be removed)")
else:
_rich_info(f" - {display_name}")
if dry_run:
_rich_success("Dry run complete - no changes made")
return
# Remove orphaned packages
removed_count = 0
for org_repo_name, display_name in orphaned_packages.items():
# Convert org/repo to filesystem path
org_name, repo_name = org_repo_name.split('/', 1)
pkg_path = apm_modules_dir / org_name / repo_name
try:
import shutil
shutil.rmtree(pkg_path)
_rich_info(f"✓ Removed {display_name}")
removed_count += 1
# Clean up empty org directory
org_path = apm_modules_dir / org_name
if org_path.exists() and not any(org_path.iterdir()):
org_path.rmdir()
except Exception as e:
_rich_error(f"✗ Failed to remove {display_name}: {e}")
# Final summary
if removed_count > 0:
_rich_success(f"Pruned {removed_count} orphaned package(s)")
else:
_rich_warning("No packages were removed")
except Exception as e:
_rich_error(f"Error pruning packages: {e}")
sys.exit(1)
@cli.command(help="Remove APM packages from apm.yml and apm_modules")
@click.argument('packages', nargs=-1, required=True)
@click.option('--dry-run', is_flag=True, help="Show what would be removed without removing")
@click.pass_context
def uninstall(ctx, packages, dry_run):
"""Remove APM packages from apm.yml and apm_modules (like npm uninstall).
This command removes packages from both the apm.yml dependencies list
and the apm_modules/ directory. It's the opposite of 'apm install <package>'.
Examples:
apm uninstall github/design-guidelines # Remove one package
apm uninstall org/pkg1 org/pkg2 # Remove multiple packages
apm uninstall github/pkg --dry-run # Show what would be removed
"""
try:
# Check if apm.yml exists
if not Path('apm.yml').exists():
_rich_error("No apm.yml found. Run 'apm init' first.")
sys.exit(1)
if not packages:
_rich_error("No packages specified. Specify packages to uninstall.")
sys.exit(1)
_rich_info(f"Uninstalling {len(packages)} package(s)...")
# Read current apm.yml
import yaml
apm_yml_path = Path('apm.yml')
try:
with open(apm_yml_path, 'r') as f:
data = yaml.safe_load(f) or {}
except Exception as e:
_rich_error(f"Failed to read apm.yml: {e}")
sys.exit(1)
# Ensure dependencies structure exists
if 'dependencies' not in data:
data['dependencies'] = {}
if 'apm' not in data['dependencies']:
data['dependencies']['apm'] = []
current_deps = data['dependencies']['apm'] or []
packages_to_remove = []
packages_not_found = []
# Validate which packages can be removed
for package in packages:
# Validate package format (should be owner/repo)
if '/' not in package:
_rich_error(f"Invalid package format: {package}. Use 'owner/repo' format.")
continue
# Check if package exists in dependencies
if package in current_deps:
packages_to_remove.append(package)
_rich_info(f"{package} - found in apm.yml")
else:
packages_not_found.append(package)
_rich_warning(f"{package} - not found in apm.yml")
if not packages_to_remove:
_rich_warning("No packages found in apm.yml to remove")
return
if dry_run:
_rich_info(f"Dry run: Would remove {len(packages_to_remove)} package(s):")
for pkg in packages_to_remove:
_rich_info(f" - {pkg} from apm.yml")
# Check if package exists in apm_modules
package_name = pkg.split('/')[-1]
apm_modules_dir = Path('apm_modules')
if apm_modules_dir.exists() and (apm_modules_dir / package_name).exists():
_rich_info(f" - {package_name} from apm_modules/")
_rich_success("Dry run complete - no changes made")
return
# Remove packages from apm.yml
for package in packages_to_remove:
current_deps.remove(package)
_rich_info(f"Removed {package} from apm.yml")
# Update dependencies in apm.yml
data['dependencies']['apm'] = current_deps
# Write back to apm.yml
try:
with open(apm_yml_path, 'w') as f:
yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False)
_rich_success(f"Updated apm.yml (removed {len(packages_to_remove)} package(s))")
except Exception as e:
_rich_error(f"Failed to write apm.yml: {e}")
sys.exit(1)
# Remove packages from apm_modules/
apm_modules_dir = Path('apm_modules')
removed_from_modules = 0
if apm_modules_dir.exists():
for package in packages_to_remove:
package_name = package.split('/')[-1] # Extract package name
package_path = apm_modules_dir / package_name
if package_path.exists():
try:
import shutil
shutil.rmtree(package_path)
_rich_info(f"✓ Removed {package_name} from apm_modules/")
removed_from_modules += 1
except Exception as e:
_rich_error(f"✗ Failed to remove {package_name} from apm_modules/: {e}")
else:
_rich_warning(f"Package {package_name} not found in apm_modules/")
# Final summary
summary_lines = []
summary_lines.append(f"Removed {len(packages_to_remove)} package(s) from apm.yml")
if removed_from_modules > 0:
summary_lines.append(f"Removed {removed_from_modules} package(s) from apm_modules/")
_rich_success("Uninstall complete: " + ", ".join(summary_lines))
if packages_not_found:
_rich_warning(f"Note: {len(packages_not_found)} package(s) were not found in apm.yml")
except Exception as e:
_rich_error(f"Error uninstalling packages: {e}")
sys.exit(1)
def _install_apm_dependencies(apm_package: 'APMPackage', update_refs: bool = False):
"""Install APM package dependencies.
@@ -412,13 +816,22 @@ def _install_apm_dependencies(apm_package: 'APMPackage', update_refs: bool = Fal
installed_count = 0
for dep_ref in deps_to_install:
# Determine installation directory (use alias if provided, otherwise repo name)
# Determine installation directory using namespaced structure
# e.g., github/design-guidelines -> apm_modules/github/design-guidelines/
if dep_ref.alias:
# If alias is provided, use it directly (assume user handles namespacing)
install_name = dep_ref.alias
install_path = apm_modules_dir / install_name
else:
install_name = dep_ref.repo_url.split('/')[-1]
install_path = apm_modules_dir / install_name
# Use org/repo structure to prevent collisions
repo_parts = dep_ref.repo_url.split('/')
if len(repo_parts) >= 2:
org_name = repo_parts[0]
repo_name = repo_parts[1]
install_path = apm_modules_dir / org_name / repo_name
else:
# Fallback for invalid repo URLs
install_path = apm_modules_dir / dep_ref.repo_url
# Skip if already exists and not updating
if install_path.exists() and not update_refs:
@@ -1239,9 +1652,9 @@ def compile(ctx, output, dry_run, no_links, chatmode, watch, validate, with_cons
if not apm_modules_exists and not local_apm_exists and not constitution_exists:
_rich_warning("No APM dependencies, local .apm/ directory, or constitution found")
_rich_info("💡 Nothing to compile. To get started:")
_rich_info(" 1. Install APM dependencies: apm install")
_rich_info(" 2. Or initialize APM project: apm init")
_rich_info(" 3. Then run: apm compile")
_rich_info(" 1. Install APM dependencies: specify apm install")
_rich_info(" 2. Or initialize APM project: specify apm init")
_rich_info(" 3. Then run: specify apm compile")
return
except Exception:
pass # Continue with compilation if check fails
@@ -1404,6 +1817,18 @@ def compile(ctx, output, dry_run, no_links, chatmode, watch, validate, with_cons
click.echo(f"{error}")
sys.exit(1)
# Check for orphaned packages after successful compilation
try:
orphaned_packages = _check_orphaned_packages()
if orphaned_packages:
_rich_blank_line()
_rich_warning(f"⚠️ Found {len(orphaned_packages)} orphaned package(s) that were included in compilation:")
for pkg in orphaned_packages:
_rich_info(f"{pkg}")
_rich_info("💡 Run 'specify apm prune' to remove orphaned packages")
except Exception:
pass # Continue if orphan check fails
except ImportError as e:
_rich_error(f"Compilation module not available: {e}")
_rich_info("This might be a development environment issue.")

View File

@@ -43,44 +43,72 @@ def list_packages():
if not apm_modules_path.exists():
if has_rich:
console.print("💡 No APM dependencies installed yet", style="cyan")
console.print("Run 'apm install' to install dependencies from apm.yml", style="dim")
console.print("Run 'specify apm install' to install dependencies from apm.yml", style="dim")
else:
click.echo("💡 No APM dependencies installed yet")
click.echo("Run 'apm install' to install dependencies from apm.yml")
click.echo("Run 'specify apm install' to install dependencies from apm.yml")
return
# Scan for installed packages
# Load project dependencies to check for orphaned packages
declared_deps = set()
try:
apm_yml_path = project_root / "apm.yml"
if apm_yml_path.exists():
project_package = APMPackage.from_apm_yml(apm_yml_path)
for dep in project_package.get_apm_dependencies():
declared_deps.add(dep.repo_url)
except Exception:
pass # Continue without orphan detection if apm.yml parsing fails
# Scan for installed packages in org-namespaced structure
installed_packages = []
for package_dir in apm_modules_path.iterdir():
if package_dir.is_dir():
try:
# Try to load package metadata
apm_yml_path = package_dir / "apm.yml"
if apm_yml_path.exists():
package = APMPackage.from_apm_yml(apm_yml_path)
# Count context files and workflows separately
context_count, workflow_count = _count_package_files(package_dir)
installed_packages.append({
'name': package.name,
'version': package.version or 'unknown',
'source': package.source or 'local',
'context': context_count,
'workflows': workflow_count,
'path': package_dir.name
})
else:
# Package without apm.yml - show basic info
context_count, workflow_count = _count_package_files(package_dir)
installed_packages.append({
'name': package_dir.name,
'version': 'unknown',
'source': 'unknown',
'context': context_count,
'workflows': workflow_count,
'path': package_dir.name
})
except Exception as e:
click.echo(f"⚠️ Warning: Failed to read package {package_dir.name}: {e}")
orphaned_packages = []
for org_dir in apm_modules_path.iterdir():
if org_dir.is_dir() and not org_dir.name.startswith('.'):
for package_dir in org_dir.iterdir():
if package_dir.is_dir() and not package_dir.name.startswith('.'):
try:
# org/repo format
org_repo_name = f"{org_dir.name}/{package_dir.name}"
# Try to load package metadata
apm_yml_path = package_dir / "apm.yml"
if apm_yml_path.exists():
package = APMPackage.from_apm_yml(apm_yml_path)
# Count context files and workflows separately
context_count, workflow_count = _count_package_files(package_dir)
# Check if this package is orphaned
is_orphaned = org_repo_name not in declared_deps
if is_orphaned:
orphaned_packages.append(org_repo_name)
installed_packages.append({
'name': org_repo_name,
'version': package.version or 'unknown',
'source': 'orphaned' if is_orphaned else 'github',
'context': context_count,
'workflows': workflow_count,
'path': str(package_dir),
'is_orphaned': is_orphaned
})
else:
# Package without apm.yml - show basic info
context_count, workflow_count = _count_package_files(package_dir)
is_orphaned = True # Assume orphaned if no apm.yml
orphaned_packages.append(org_repo_name)
installed_packages.append({
'name': org_repo_name,
'version': 'unknown',
'source': 'orphaned',
'context': context_count,
'workflows': workflow_count,
'path': str(package_dir),
'is_orphaned': is_orphaned
})
except Exception as e:
click.echo(f"⚠️ Warning: Failed to read package {org_dir.name}/{package_dir.name}: {e}")
if not installed_packages:
if has_rich:
@@ -108,6 +136,13 @@ def list_packages():
)
console.print(table)
# Show orphaned packages warning
if orphaned_packages:
console.print(f"\n⚠️ {len(orphaned_packages)} orphaned package(s) found (not in apm.yml):", style="yellow")
for pkg in orphaned_packages:
console.print(f"{pkg}", style="dim yellow")
console.print("\n💡 Run 'specify apm prune' to remove orphaned packages", style="cyan")
else:
# Fallback text table
click.echo("📋 APM Dependencies:")
@@ -124,6 +159,13 @@ def list_packages():
click.echo(f"{name}{version}{source}{context}{workflows}")
click.echo("└─────────────────────┴─────────┴──────────────┴─────────────┴─────────────┘")
# Show orphaned packages warning
if orphaned_packages:
click.echo(f"\n⚠️ {len(orphaned_packages)} orphaned package(s) found (not in apm.yml):")
for pkg in orphaned_packages:
click.echo(f"{pkg}")
click.echo("\n💡 Run 'specify apm prune' to remove orphaned packages")
except Exception as e:
_rich_error(f"Error listing dependencies: {e}")
@@ -318,7 +360,7 @@ def info(package: str):
if not apm_modules_path.exists():
_rich_error("No apm_modules/ directory found")
_rich_info("Run 'apm install' to install dependencies first")
_rich_info("Run 'specify apm install' to install dependencies first")
sys.exit(1)
# Find the package directory

View File

@@ -157,7 +157,14 @@ def scan_dependency_primitives(base_dir: str, collection: PrimitiveCollection) -
# Process dependencies in declaration order
for dep_name in dependency_order:
dep_path = apm_modules_path / dep_name
# Handle org-namespaced structure (e.g., "github/design-guidelines")
if "/" in dep_name:
org_name, repo_name = dep_name.split("/", 1)
dep_path = apm_modules_path / org_name / repo_name
else:
# Fallback for non-namespaced dependencies
dep_path = apm_modules_path / dep_name
if dep_path.exists() and dep_path.is_dir():
scan_directory_with_source(dep_path, collection, source=f"dependency:{dep_name}")
@@ -180,15 +187,15 @@ def get_dependency_declaration_order(base_dir: str) -> List[str]:
apm_dependencies = package.get_apm_dependencies()
# Extract package names from dependency references
# Use alias if provided, otherwise use repository name
# Use alias if provided, otherwise use full org/repo path for org-namespaced structure
dependency_names = []
for dep in apm_dependencies:
if dep.alias:
dependency_names.append(dep.alias)
else:
# Extract repository name from repo_url (e.g., "user/repo" -> "repo")
repo_name = dep.repo_url.split("/")[-1]
dependency_names.append(repo_name)
# Use full org/repo path (e.g., "github/design-guidelines")
# This matches our org-namespaced directory structure
dependency_names.append(dep.repo_url)
return dependency_names

View File

@@ -55,7 +55,7 @@ from rich.tree import Tree
from typer.core import TyperGroup
# APM imports
from apm_cli.cli import init as apm_init, install as apm_install, compile as apm_compile
from apm_cli.cli import init as apm_init, install as apm_install, compile as apm_compile, prune as apm_prune, uninstall as apm_uninstall
from apm_cli.commands.deps import deps as apm_deps
import click
from click.testing import CliRunner
@@ -318,7 +318,9 @@ def apm_click():
# Add APM commands to the Click group
apm_click.add_command(apm_init, name="init")
apm_click.add_command(apm_install, name="install")
apm_click.add_command(apm_uninstall, name="uninstall")
apm_click.add_command(apm_compile, name="compile")
apm_click.add_command(apm_prune, name="prune")
apm_click.add_command(apm_deps, name="deps")
@@ -352,14 +354,26 @@ def apm_init_wrapper(
@apm_app.command("install", context_settings={"allow_extra_args": True, "allow_interspersed_args": False})
def apm_install_wrapper(
ctx: typer.Context,
packages: list[str] = typer.Argument(None, help="APM packages to add and install (owner/repo format)"),
runtime: str = typer.Option(None, "--runtime", help="Target specific runtime only (codex, vscode)"),
exclude: str = typer.Option(None, "--exclude", help="Exclude specific runtime from installation"),
only: str = typer.Option(None, "--only", help="Install only specific dependency type (apm or mcp)"),
update: bool = typer.Option(False, "--update", help="Update dependencies to latest Git references"),
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be installed without installing"),
):
"""Install APM and MCP dependencies from apm.yml"""
"""Install APM and MCP dependencies from apm.yml.
Examples:
specify apm install # Install existing deps from apm.yml
specify apm install github/design-guidelines # Add package and install
specify apm install org/pkg1 org/pkg2 # Add multiple packages and install
"""
args = []
# Add package arguments first
if packages:
args.extend(packages)
if runtime:
args.extend(["--runtime", runtime])
if exclude:
@@ -422,6 +436,61 @@ def apm_compile_wrapper(
_run_apm_command(["compile"] + args)
@apm_app.command("prune", context_settings={"allow_extra_args": True, "allow_interspersed_args": False})
def apm_prune_wrapper(
ctx: typer.Context,
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be removed without removing"),
):
"""Remove APM packages not listed in apm.yml.
This command cleans up the apm_modules/ directory by removing packages that
were previously installed but are no longer declared as dependencies in apm.yml.
Examples:
specify apm prune # Remove orphaned packages
specify apm prune --dry-run # Show what would be removed
"""
args = []
if dry_run:
args.append("--dry-run")
# Add any extra arguments
if ctx.args:
args.extend(ctx.args)
_run_apm_command(["prune"] + args)
@apm_app.command("uninstall", context_settings={"allow_extra_args": True, "allow_interspersed_args": False})
def apm_uninstall_wrapper(
ctx: typer.Context,
packages: list[str] = typer.Argument(..., help="APM packages to remove (owner/repo format)"),
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be removed without removing"),
):
"""Remove APM packages from apm.yml and apm_modules.
This command removes packages from both the apm.yml dependencies list
and the apm_modules/ directory. It's the opposite of 'specify apm install <package>'.
Examples:
specify apm uninstall github/design-guidelines # Remove one package
specify apm uninstall org/pkg1 org/pkg2 # Remove multiple packages
specify apm uninstall github/pkg --dry-run # Show what would be removed
"""
args = []
# Add package arguments first
if packages:
args.extend(packages)
if dry_run:
args.append("--dry-run")
# Add any extra arguments
if ctx.args:
args.extend(ctx.args)
_run_apm_command(["uninstall"] + args)
# Create deps subcommands as Typer sub-application
deps_app = typer.Typer(
name="deps",
@@ -957,6 +1026,7 @@ def init(
here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"),
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"),
use_apm: bool = typer.Option(False, "--use-apm", help="Include APM (Agent Package Manager) structure for context management"),
):
"""
Initialize a new Specify project from the latest template.
@@ -968,16 +1038,17 @@ def init(
4. Extract the template to a new project directory or current directory
5. Initialize a fresh git repository (if not --no-git and no existing repo)
6. Optionally set up AI assistant commands
7. Optionally include APM support (with --use-apm flag)
Examples:
specify init my-project
specify init my-project --ai claude
specify init my-project --ai gemini
specify init my-project --ai gemini --use-apm
specify init my-project --ai copilot --no-git
specify init my-project --ai cursor
specify init my-project --ai cursor --use-apm
specify init --ignore-agent-tools my-project
specify init --here --ai claude
specify init --here
specify init --here --use-apm
"""
# Show banner first
show_banner()
@@ -1114,13 +1185,16 @@ def init(
download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug)
# APM structure creation
tracker.start("apm", "setting up APM structure")
try:
_create_apm_structure(project_path, project_path.name, selected_ai)
tracker.complete("apm", "APM structure created")
except Exception as e:
tracker.error("apm", f"APM setup failed: {str(e)}")
# APM structure creation (conditional)
if use_apm:
tracker.start("apm", "setting up APM structure")
try:
_create_apm_structure(project_path, project_path.name, selected_ai)
tracker.complete("apm", "APM structure created")
except Exception as e:
tracker.error("apm", f"APM setup failed: {str(e)}")
else:
tracker.skip("apm", "APM not requested")
# Ensure scripts are executable (POSIX)
ensure_executable_scripts(project_path, tracker=tracker)
@@ -1192,12 +1266,13 @@ def init(
step_num += 1
steps_lines.append(f"{step_num}. Update [bold magenta]CONSTITUTION.md[/bold magenta] with your project's non-negotiable principles")
# Add APM-specific next steps if available
step_num += 1
steps_lines.append(f"{step_num}. Use APM commands to manage your AI-native project:")
steps_lines.append(" - [bold cyan]specify apm compile[/bold cyan] - Generate AGENTS.md from your context")
steps_lines.append(" - [bold cyan]specify apm install[/bold cyan] - Install APM package dependencies")
steps_lines.append(" - [bold cyan]specify apm deps list[/bold cyan] - List available APM packages")
# Add APM-specific next steps if APM was enabled
if use_apm:
step_num += 1
steps_lines.append(f"{step_num}. Use APM commands to manage your project context:")
steps_lines.append(" - [bold cyan]specify apm compile[/bold cyan] - Generate AGENTS.md from APM instructions and packages")
steps_lines.append(" - [bold cyan]specify apm install[/bold cyan] - Install APM packages")
steps_lines.append(" - [bold cyan]specify apm deps list[/bold cyan] - List installed APM packages")
steps_panel = Panel("\n".join(steps_lines), title="Next steps", border_style="cyan", padding=(1,2))
console.print() # blank line